Compare commits

...

22 Commits

Author SHA1 Message Date
icarus
fa29a6034d refactor(S3BackupManager): remove unused Space import from antd 2025-10-28 19:42:34 +08:00
GitHub Action
aaded50d3f fix(i18n): Auto update translations for PR #10874 2025-10-28 11:36:12 +00:00
icarus
0b1f02a661 Merge branch 'v2' of github.com:CherryHQ/cherry-studio into feat/fix-mermaid 2025-10-28 19:35:12 +08:00
icarus
e06e920086 feat(useMermaidFixTool): improve instructions for mermaid code fixing
Clarify output format requirements and add detailed steps for error analysis
Provide concrete examples for both fixable and unfixable cases
2025-10-22 19:13:03 +08:00
icarus
c455b0a70a refactor: remove unused setError parameter from mermaid components
Clean up code by removing unnecessary setError parameter that was not being used effectively. This simplifies the component interfaces and reduces potential error handling confusion.
2025-10-22 18:56:29 +08:00
icarus
552694df11 docs(useMermaidFixTool): improve type comments for Input interface
Update JSDoc comments to be more descriptive and follow standard conventions
2025-10-22 07:43:12 +08:00
icarus
eabbe593dd docs(CodeBlockView): fix comment typo in props interface 2025-10-22 07:39:42 +08:00
icarus
ed651b995e Merge branch 'v2' of github.com:CherryHQ/cherry-studio into feat/fix-mermaid 2025-10-22 07:33:18 +08:00
icarus
b8a8f19892 test: update snapshots and add mock for ui components
Update test snapshots to reflect changes in component styling and structure
Add mock for @cherrystudio/ui components in CodeBlock tests
2025-10-22 07:26:08 +08:00
icarus
e69260defa refactor(CodeBlock): add todo comment for future improvement
The event form cannot obtain handler completion status via promise. Plan to use useContext for topicId and useEditCodeBlock for editing.
2025-10-22 07:21:30 +08:00
icarus
d3d02712a4 refactor(code-block): extract code block editing logic to custom hook
Move code block editing functionality from Messages component to useEditCodeBlock hook for better reusability and maintainability
2025-10-22 07:17:28 +08:00
icarus
d2b25af146 refactor(Messages): add TODO for extracting event processing logic
Move event processing function to a reusable hook to improve code reuse and enable promise-based completion tracking
2025-10-22 06:28:41 +08:00
icarus
63e522bf82 fix(CodeToolbar): correct tool cleanup and fix callback invocation
Ensure the correct tool ID is used for cleanup and wrap fixCode in arrow function to maintain context
2025-10-22 06:22:58 +08:00
icarus
55b63d345e fix(useMermaidFixTool): add missing dependency to useEffect hook
Add setError to the dependency array to prevent stale closure issues
2025-10-22 06:21:35 +08:00
icarus
8ba48d9df0 feat(i18n): update translations for multiple languages
- Add mermaid_fix translations for all supported languages
- Complete missing translations marked with "[to be translated]"
- Convert array to object for accessibility description in zh-cn
2025-10-22 06:19:18 +08:00
icarus
1d94d56e2a feat(mermaid): add error handling and fix tool for mermaid diagrams
- Add mermaid_fix tool to handle and fix mermaid diagram errors
- Implement pending state tracking for async operations
- Add error callback to MermaidPreview component
- Update i18n strings for mermaid error handling
- Extend cache schema with pending_map for task tracking
2025-10-22 06:12:21 +08:00
icarus
0fb5480b0a style(BlockingOverlay): change positioning from fixed to absolute 2025-10-22 06:05:19 +08:00
icarus
91cf5d2e7d feat(components): add BlockingOverlay component for modal interactions
Introduce a new BlockingOverlay component to handle modal overlays with click handling and visibility control. The component includes click event propagation prevention and supports custom styling through className.
2025-10-22 05:45:54 +08:00
icarus
578cf38072 feat(ui): add new spinner component and update lucide-react
- Add new general-purpose spinner component with multiple variants
- Rename old Spinner component to SearchSpinner to clarify its purpose
- Update lucide-react dependency to v0.546.0
2025-10-22 05:26:00 +08:00
icarus
a0445a307a feat(hooks): add usePendingMap hook for managing pending state
Implement a custom hook to track pending states with cache persistence. The hook provides methods to set and check pending status for given IDs, with automatic cleanup of undefined values.
2025-10-22 04:24:38 +08:00
icarus
2f3e634880 feat(hooks): add systemPrompt parameter to useQuickCompletion hook 2025-10-22 04:07:16 +08:00
icarus
684c0a7b63 feat(hooks): add useQuickCompletion hook for quick model completions 2025-10-22 03:45:50 +08:00
31 changed files with 798 additions and 83 deletions

View File

@@ -293,7 +293,7 @@
"lint-staged": "^15.5.0",
"lodash": "^4.17.21",
"lru-cache": "^11.1.0",
"lucide-react": "^0.525.0",
"lucide-react": "^0.546.0",
"macos-release": "^3.4.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.10.1",

View File

@@ -8,6 +8,8 @@ export type UseCacheSchema = {
// App state
'app.dist.update_state': CacheValueTypes.CacheAppUpdateState
'app.user.avatar': string
/** Used to indicate whether any asynchronous task with a given ID is currently in progress */
'app.pending_map': Record<string, boolean>
// Chat context
'chat.multi_select_mode': boolean
@@ -53,6 +55,7 @@ export const DefaultUseCache: UseCacheSchema = {
available: false
},
'app.user.avatar': '',
'app.pending_map': {},
// Chat context
'chat.multi_select_mode': false,

View File

@@ -0,0 +1,43 @@
import { cn } from '@heroui/react'
import type { ReactNode } from 'react'
import React, { useCallback } from 'react'
// 定义组件的 props 类型
interface BlockingOverlayProps {
isVisible: boolean
onClick?: () => void
children?: ReactNode
className?: string
}
export const BlockingOverlay = ({ isVisible, onClick, children, className }: BlockingOverlayProps) => {
const handleClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
event.preventDefault()
if (onClick) {
onClick()
}
},
[onClick]
)
if (!isVisible) {
return null
}
return (
<div
className={cn(
'absolute inset-0',
'bg-black/50',
'z-[9999]',
'flex items-center justify-center',
'pointer-events-auto',
className
)}
onClick={handleClick}>
{children}
</div>
)
}

View File

@@ -7,7 +7,7 @@ export { default as EmojiIcon } from './primitives/emojiIcon'
export type { CustomFallbackProps, ErrorBoundaryCustomizedProps } from './primitives/ErrorBoundary'
export { ErrorBoundary } from './primitives/ErrorBoundary'
export { default as IndicatorLight } from './primitives/indicatorLight'
export { default as Spinner } from './primitives/spinner'
export { default as SearchSpinner } from './primitives/spinner'
export { DescriptionSwitch, Switch } from './primitives/switch'
export { getToastUtilities, type ToastUtilities } from './primitives/toast'
export { Tooltip, type TooltipProps } from './primitives/tooltip'
@@ -79,6 +79,8 @@ export { HelpTooltip, type IconTooltipProps, InfoTooltip, WarnTooltip } from './
export { default as ImageToolButton } from './composites/ImageToolButton'
// Sortable
export { Sortable } from './composites/Sortable'
// BlockingOverlay
export { BlockingOverlay } from './composites/BlockingOverlay'
/* Shadcn Primitive Components */
export * from './primitives/button'
@@ -87,3 +89,4 @@ export * from './primitives/dialog'
export * from './primitives/popover'
export * from './primitives/radioGroup'
export * from './primitives/shadcn-io/dropzone'
export * from './primitives/shadcn-io/spinner'

View File

@@ -0,0 +1,215 @@
import { cn } from '@cherrystudio/ui/utils/index'
import { LoaderCircleIcon, LoaderIcon, LoaderPinwheelIcon, type LucideProps } from 'lucide-react'
type SpinnerVariantProps = Omit<SpinnerProps, 'variant'>
const Default = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderIcon className={cn('animate-spin', className)} {...props} />
)
const Circle = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderCircleIcon className={cn('animate-spin', className)} {...props} />
)
const Pinwheel = ({ className, ...props }: SpinnerVariantProps) => (
<LoaderPinwheelIcon className={cn('animate-spin', className)} {...props} />
)
const CircleFilled = ({ className, size = 24, ...props }: SpinnerVariantProps) => (
<div className="relative" style={{ width: size, height: size }}>
<div className="absolute inset-0 rotate-180">
<LoaderCircleIcon
className={cn('animate-spin', className, 'text-foreground opacity-20')}
size={size}
{...props}
/>
</div>
<LoaderCircleIcon className={cn('relative animate-spin', className)} size={size} {...props} />
</div>
)
const Ellipsis = ({ size = 24, ...props }: SpinnerVariantProps) => {
return (
<svg height={size} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg" {...props}>
<title>Loading...</title>
<circle cx="4" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="0;ellipsis3.end+0.25s"
calcMode="spline"
dur="0.6s"
id="ellipsis1"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="12" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="ellipsis1.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
<circle cx="20" cy="12" fill="currentColor" r="2">
<animate
attributeName="cy"
begin="ellipsis1.begin+0.2s"
calcMode="spline"
dur="0.6s"
id="ellipsis3"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
values="12;6;12"
/>
</circle>
</svg>
)
}
const Ring = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg
height={size}
stroke="currentColor"
viewBox="0 0 44 44"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}>
<title>Loading...</title>
<g fill="none" fillRule="evenodd" strokeWidth="2">
<circle cx="22" cy="22" r="1">
<animate
attributeName="r"
begin="0s"
calcMode="spline"
dur="1.8s"
keySplines="0.165, 0.84, 0.44, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 20"
/>
<animate
attributeName="stroke-opacity"
begin="0s"
calcMode="spline"
dur="1.8s"
keySplines="0.3, 0.61, 0.355, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 0"
/>
</circle>
<circle cx="22" cy="22" r="1">
<animate
attributeName="r"
begin="-0.9s"
calcMode="spline"
dur="1.8s"
keySplines="0.165, 0.84, 0.44, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 20"
/>
<animate
attributeName="stroke-opacity"
begin="-0.9s"
calcMode="spline"
dur="1.8s"
keySplines="0.3, 0.61, 0.355, 1"
keyTimes="0; 1"
repeatCount="indefinite"
values="1; 0"
/>
</circle>
</g>
</svg>
)
const Bars = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg height={size} viewBox="0 0 24 24" width={size} xmlns="http://www.w3.org/2000/svg" {...props}>
<title>Loading...</title>
<style>{`
.spinner-bar {
animation: spinner-bars-animation .8s linear infinite;
animation-delay: -.8s;
}
.spinner-bars-2 {
animation-delay: -.65s;
}
.spinner-bars-3 {
animation-delay: -0.5s;
}
@keyframes spinner-bars-animation {
0% {
y: 1px;
height: 22px;
}
93.75% {
y: 5px;
height: 14px;
opacity: 0.2;
}
}
`}</style>
<rect className="spinner-bar" fill="currentColor" height="22" width="6" x="1" y="1" />
<rect className="spinner-bar spinner-bars-2" fill="currentColor" height="22" width="6" x="9" y="1" />
<rect className="spinner-bar spinner-bars-3" fill="currentColor" height="22" width="6" x="17" y="1" />
</svg>
)
const Infinite = ({ size = 24, ...props }: SpinnerVariantProps) => (
<svg
height={size}
preserveAspectRatio="xMidYMid"
viewBox="0 0 100 100"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}>
<title>Loading...</title>
<path
d="M24.3 30C11.4 30 5 43.3 5 50s6.4 20 19.3 20c19.3 0 32.1-40 51.4-40 C88.6 30 95 43.3 95 50s-6.4 20-19.3 20C56.4 70 43.6 30 24.3 30z"
fill="none"
stroke="currentColor"
strokeDasharray="205.271142578125 51.317785644531256"
strokeLinecap="round"
strokeWidth="10"
style={{
transform: 'scale(0.8)',
transformOrigin: '50px 50px'
}}>
<animate
attributeName="stroke-dashoffset"
dur="2s"
keyTimes="0;1"
repeatCount="indefinite"
values="0;256.58892822265625"
/>
</path>
</svg>
)
export type SpinnerProps = LucideProps & {
variant?: 'default' | 'circle' | 'pinwheel' | 'circle-filled' | 'ellipsis' | 'ring' | 'bars' | 'infinite'
}
export const Spinner = ({ variant, ...props }: SpinnerProps) => {
switch (variant) {
case 'circle':
return <Circle {...props} />
case 'pinwheel':
return <Pinwheel {...props} />
case 'circle-filled':
return <CircleFilled {...props} />
case 'ellipsis':
return <Ellipsis {...props} />
case 'ring':
return <Ring {...props} />
case 'bars':
return <Bars {...props} />
case 'infinite':
return <Infinite {...props} />
default:
return <Default {...props} />
}
}

View File

@@ -17,6 +17,7 @@ const spinnerVariants = {
}
}
// FIXME: This is not a general spinner. It's just for searching.
export default function Spinner({ text, className = '' }: Props) {
return (
<motion.div

View File

@@ -1,6 +1,6 @@
import type { ActionToolSpec } from './types'
export const TOOL_SPECS: Record<string, ActionToolSpec> = {
export const TOOL_SPECS = {
// Core tools
copy: {
id: 'copy',
@@ -72,5 +72,10 @@ export const TOOL_SPECS: Record<string, ActionToolSpec> = {
id: 'zoom-out',
type: 'quick',
order: 41
},
mermaid_fix: {
id: 'mermaid-fix',
type: 'core',
order: 42
}
}
} as const satisfies Record<string, ActionToolSpec>

View File

@@ -26,6 +26,7 @@ import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef,
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { useMermaidFixTool } from '../CodeToolbar/hooks/useMermaidFixTool'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import StatusBar from './StatusBar'
import type { ViewMode } from './types'
@@ -33,9 +34,12 @@ import type { ViewMode } from './types'
const logger = loggerService.withContext('CodeBlockView')
interface Props {
// FIXME: It's may not runtime string!
children: string
language: string
onSave?: (newContent: string) => void
// Message Block ID
blockId: string
onSave: (newContent: string) => Promise<void>
}
/**
@@ -54,7 +58,7 @@ interface Props {
* - quick 工具
* - core 工具
*/
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
export const CodeBlockView: React.FC<Props> = memo(({ children: code, language, blockId, onSave }) => {
const { t } = useTranslation()
const [codeExecutionEnabled] = usePreference('chat.code.execution.enabled')
@@ -113,6 +117,8 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
const specialViewRef = useRef<BasicPreviewHandles>(null)
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
const [error, setError] = useState<unknown>(null)
const isMermaid = language === 'mermaid'
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
@@ -146,16 +152,16 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
}, [])
const handleCopySource = useCallback(() => {
navigator.clipboard.writeText(children)
navigator.clipboard.writeText(code)
window.toast.success(t('code_block.copy.success'))
}, [children, t])
}, [code, t])
const handleDownloadSource = useCallback(() => {
let fileName = ''
// 尝试提取 HTML 标题
if (language === 'html') {
fileName = getFileNameFromHtmlTitle(extractHtmlTitle(children)) || ''
fileName = getFileNameFromHtmlTitle(extractHtmlTitle(code)) || ''
}
// 默认使用日期格式命名
@@ -164,15 +170,15 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
}
const ext = getExtensionByLanguage(language)
window.api.file.save(`${fileName}${ext}`, children)
}, [children, language])
window.api.file.save(`${fileName}${ext}`, code)
}, [code, language])
const handleRunScript = useCallback(() => {
setIsRunning(true)
setExecutionResult(null)
pyodideService
.runScript(children, {}, codeExecutionTimeoutMinutes * 60000)
.runScript(code, {}, codeExecutionTimeoutMinutes * 60000)
.then((result) => {
setExecutionResult(result)
})
@@ -185,7 +191,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
.finally(() => {
setIsRunning(false)
})
}, [children, codeExecutionTimeoutMinutes])
}, [code, codeExecutionTimeoutMinutes])
const showPreviewTools = useMemo(() => {
return viewMode !== 'source' && hasSpecialView
@@ -257,6 +263,18 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
setTools
})
// Mermaid fix tool
useMermaidFixTool({
enabled: isMermaid && error !== undefined && error !== null,
context: {
blockId,
error,
content: code
},
onSave,
setTools
})
// 源代码视图组件
const sourceView = useMemo(
() =>
@@ -266,7 +284,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
ref={sourceViewRef}
theme={activeCmTheme}
fontSize={fontSize - 1}
value={children}
value={code}
language={language}
onSave={onSave}
onHeightChange={handleHeightChange}
@@ -278,7 +296,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
) : (
<CodeViewer
className="source-view"
value={children}
value={code}
language={language}
onHeightChange={handleHeightChange}
expanded={shouldExpand}
@@ -288,7 +306,7 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
),
[
activeCmTheme,
children,
code,
codeEditor,
codeShowLineNumbers,
fontSize,
@@ -307,11 +325,11 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
if (!SpecialView) return null
return (
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools}>
{children}
<SpecialView ref={specialViewRef} enableToolbar={codeImageTools} onError={setError}>
{code}
</SpecialView>
)
}, [children, codeImageTools, language])
}, [code, codeImageTools, language])
const renderHeader = useMemo(() => {
const langTag = '<' + language.toUpperCase() + '>'

View File

@@ -0,0 +1,229 @@
import { loggerService } from '@logger'
import type { ActionTool } from '@renderer/components/ActionTools'
import { TOOL_SPECS, useToolManager } from '@renderer/components/ActionTools'
import { usePendingMap } from '@renderer/hooks/usePendingMap'
import { useQuickCompletion } from '@renderer/hooks/useQuickCompletion'
import { useSettings } from '@renderer/hooks/useSettings'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import { getErrorMessage, parseJSON } from '@renderer/utils'
import { abortCompletion, readyToAbort } from '@renderer/utils/abortController'
import { WrenchIcon } from 'lucide-react'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
const logger = loggerService.withContext('useMermaidFixTool')
interface UseMermaidFixTool {
enabled?: boolean
context: {
/** Code block id */
blockId: string
/** Error */
error: unknown
/** Mermaid code */
content: string
}
onSave: (newContent: string) => Promise<void>
setTools: React.Dispatch<React.SetStateAction<ActionTool[]>>
}
const ResultSchema = z.union([
z.object({
fixed: z.literal(true),
result: z.string()
}),
z.object({
fixed: z.literal(false),
reason: z.string()
})
])
/**
* Input shape for the Mermaid fix prompt.
*/
type Input = {
/** Mermaid diagram code to be fixed */
mermaid: string
/** Error message returned by the renderer */
error: string
/** Users language code (e.g. zh-cn, en-us) */
lang: string
}
const SYSTEM_PROMPT = `
You are an AI assistant that fixes Mermaid code. The input is a JSON string with the following structure: {"mermaid": "the Mermaid code", "error": "the error message from rendering", "lang": "the user's language code"}.
Your task is to analyze the error and the Mermaid code. If the error is due to a mistake in the Mermaid code, fix it and output a JSON string with {"fixed": true, "result": "the fixed Mermaid code"}. If the error is not caused by the code (e.g., environment issues, unsupported features, or other non-code errors), output {"fixed": false, "reason": "a brief explanation in the language specified by the 'lang' field"}.
Your output must be a pure JSON string with no additional text, comments, or formatting (e.g., no markdown code blocks like \`\`\`json). The entire response body must be the JSON object itself.
# Steps
1. **Analyze**: Carefully examine the input \`mermaid\` code and the \`error\` message.
2. **Diagnose**: Determine the root cause of the error. Is it a fixable syntax or logical error within the code, or an external issue (e.g., environment problem, unsupported feature)?
3. **Generate Output**:
* If the code can be fixed, generate a JSON object containing the fixed code.
* If the code cannot be fixed, generate a JSON object explaining the reason.
# Output Format
Your output must be a well-formed, pure JSON string.
**Crucially**: Do not include any explanatory text, comments, or markdown code blocks (e.g., \`\`\`json) outside of the JSON output. Your entire response content must be the raw JSON object itself.
* **If fixed**: Output a JSON object with the following structure:
\`{"fixed": true, "result": "the fixed Mermaid code"}\`
The \`result\` field must contain the complete, runnable Mermaid code string.
* **If not fixed**: Output a JSON object with the following structure:
\`{"fixed": false, "reason": "a brief explanation"}\`
The explanation in the \`reason\` field must be in the language specified by the input \`lang\` field.
# Examples
**Example 1: Fixable error**
* **Input**:
\`\`\`json
{
"mermaid": "graph TD\nA[Start] --> B{Error?",
"error": "Syntax error: a node is not properly closed",
"lang": "en-us"
}
\`\`\`
* **Output**:
\`\`\`json
{"fixed": true, "result": "graph TD\n A[Start] --> B{Error?}"}
\`\`\`
**Example 2: Unfixable error**
* **Input**:
\`\`\`json
{
"mermaid": "gitGraph\n commit\n branch new-feature\n checkout new-feature",
"error": "Feature not supported in this version",
"lang": "en-us"
}
\`\`\`
* **Output**:
\`\`\`json
{"fixed": false, "reason": "The error is due to an unsupported feature in the current environment."}
\`\`\`
# Notes
* When returning \`{"fixed": false, ...}\`, ensure the \`reason\` field's text content matches the language specified by the input \`lang\` code.
* Your primary objective is to provide a clean, machine-readable JSON output.
`
export const useMermaidFixTool = ({ enabled, context, onSave, setTools }: UseMermaidFixTool) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useToolManager(setTools)
const { language } = useSettings()
const completion = useQuickCompletion(SYSTEM_PROMPT)
const { error, content, blockId } = context
const abortKeyRef = useRef<string | null>(null)
const { setPending } = usePendingMap()
logger.debug('input', {
mermaid: content,
error: getErrorMessage(error),
lang: language
})
const prompt = JSON.stringify({
mermaid: content,
error: getErrorMessage(error),
lang: language
} satisfies Input)
const fixCode = useCallback(async () => {
setPending(blockId, true)
const abortKey = crypto.randomUUID()
abortKeyRef.current = abortKey
const signal = readyToAbort(abortKey)
let result = ''
const onChunk = (chunk: Chunk) => {
if (chunk.type === ChunkType.TEXT_DELTA) {
result = chunk.text
}
}
try {
await completion({
prompt,
onChunk,
params: {
options: {
signal
}
}
})
} catch (e) {
window.toast.error({ title: t('code_block.mermaid_fix.failed'), description: getErrorMessage(e) })
return
}
result = result.trim()
logger.debug('output', { result })
const parsedJson = parseJSON(result)
if (parsedJson === null) {
window.toast.error({
title: t('code_block.mermaid_fix.failed'),
description: t('code_block.mermaid_fix.invalid_result')
})
} else {
logger.debug('parseJSON success', { parsedJson })
const parsedResult = ResultSchema.safeParse(parsedJson)
logger.debug('validation', { parsedResult })
if (parsedResult.success) {
const validResult = parsedResult.data
if (validResult.fixed) {
await onSave(validResult.result)
} else {
window.toast.warning({ title: t('code_block.mermaid_fix.failed'), description: validResult.reason })
}
} else {
window.toast.error({
title: t('code_block.mermaid_fix.failed'),
description: t('code_block.mermaid_fix.invalid_result')
})
}
}
setPending(blockId, false)
}, [setPending, blockId, completion, prompt, t, onSave])
// when unmounted
useEffect(() => {
return () => {
const abortKey = abortKeyRef.current
if (abortKey) {
abortCompletion(abortKey)
}
}
}, [])
useEffect(() => {
if (enabled) {
registerTool({
...TOOL_SPECS.mermaid_fix,
icon: <WrenchIcon size={'1rem'} className="tool-icon" />,
tooltip: t('code_block.mermaid_fix.label'),
visible: () => error !== undefined && error !== null,
onClick: () => fixCode()
})
}
return () => removeTool(TOOL_SPECS.mermaid_fix.id)
}, [enabled, error, fixCode, registerTool, removeTool, t])
}

View File

@@ -16,8 +16,9 @@ import { renderSvgInShadowHost } from './utils'
const MermaidPreview = ({
children,
enableToolbar = false,
ref
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null> }) => {
ref,
onError
}: BasicPreviewProps & { ref?: React.RefObject<BasicPreviewHandles | null>; onError?: (error: unknown) => void }) => {
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError, forceRenderKey } = useMermaid()
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const [isVisible, setIsVisible] = useState(true)
@@ -122,6 +123,12 @@ const MermaidPreview = ({
const isLoading = isLoadingMermaid || isRendering
const error = mermaidError || renderError
useEffect(() => {
if (onError !== undefined) {
onError(error)
}
}, [error, onError])
return (
<ImagePreviewLayout
loading={isLoading}

View File

@@ -3,7 +3,7 @@ import { Button, Tooltip } from '@cherrystudio/ui'
import { restoreFromS3 } from '@renderer/services/BackupService'
import type { S3Config } from '@renderer/types'
import { formatFileSize } from '@renderer/utils'
import { Modal, Space, Table } from 'antd'
import { Modal, Table } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

View File

@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'
import { useStore } from 'react-redux'
const logger = loggerService.withContext('useChatContext')
// TODO: use useContext to refactor it.
export const useChatContext = (activeTopic: Topic) => {
const { t } = useTranslation()
const store = useStore<RootState>()

View File

@@ -0,0 +1,55 @@
import { loggerService } from '@logger'
import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk'
import type { MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockType } from '@renderer/types/newMessage'
import { updateCodeBlock } from '@renderer/utils/markdown'
import { isTextLikeBlock } from '@renderer/utils/messageUtils/is'
import { t } from 'i18next'
import { useCallback } from 'react'
const logger = loggerService.withContext('useEditCodeBlock')
export const useEditCodeBlock = () => {
const dispatch = useAppDispatch()
const editCodeBlock = useCallback(
async (data: { topicId: string; msgBlockId: string; codeBlockId: string; newContent: string }) => {
const { topicId, msgBlockId, codeBlockId, newContent } = data
const msgBlock = messageBlocksSelectors.selectById(store.getState(), msgBlockId)
// FIXME: 目前 error block 没有 content
if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) {
try {
const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent)
const updatedBlock: MessageBlock = {
...msgBlock,
content: updatedRaw,
updatedAt: new Date().toISOString()
}
dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } }))
await dispatch(updateMessageAndBlocksThunk(topicId, null, [updatedBlock]))
window.toast.success(t('code_block.edit.save.success'))
} catch (error) {
logger.error(
`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`,
error as Error
)
window.toast.error(t('code_block.edit.save.failed.label'))
}
} else {
logger.error(
`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}: no such message block or the block doesn't have a content field`
)
window.toast.error(t('code_block.edit.save.failed.label'))
}
},
[dispatch]
)
return editCodeBlock
}

View File

@@ -0,0 +1,31 @@
import { useCache } from '@data/hooks/useCache'
import { useCallback } from 'react'
export const usePendingMap = () => {
const [pendingMap, setPendingMap] = useCache('app.pending_map')
const setPending = useCallback(
(id: string, value: boolean | undefined) => {
if (value !== undefined) {
setPendingMap({
...pendingMap,
[id]: value
})
} else {
const newMap = { ...pendingMap }
delete newMap[id]
setPendingMap(newMap)
}
},
[pendingMap, setPendingMap]
)
const isPending = useCallback(
(id: string) => {
return pendingMap[id]
},
[pendingMap]
)
return { pendingMap, setPending, isPending }
}

View File

@@ -0,0 +1,50 @@
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getDefaultAssistant } from '@renderer/services/AssistantService'
import type { Assistant, FetchChatCompletionParams } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { useCallback } from 'react'
import { useDefaultModel } from './useAssistant'
/**
* Parameters for performing a quick completion using the quick model.
*/
export type QuickCompletionParams = {
/**
* The user message text (not the system prompt) to send to the model.
* The system prompt is set via the `systemPrompt` parameter passed to `useQuickCompletion`.
*/
prompt: string
/**
* Callback invoked whenever a new chunk of the streaming response arrives.
*/
onChunk: (chunk: Chunk) => void
/**
* Optional partial assistant settings to override the default quick assistant.
*/
assistantUpdate?: Partial<Assistant>
/**
* Optional additional parameters to pass to the underlying fetchChatCompletion call.
* Excludes prompt, messages, assistant, and onChunkReceived which are handled internally.
*/
params?: Partial<Omit<FetchChatCompletionParams, 'prompt' | 'messages' | 'assistant' | 'onChunkReceived'>>
}
export const useQuickCompletion = (systemPrompt: string) => {
const { quickModel } = useDefaultModel()
const completion = useCallback(
async ({ prompt, onChunk, assistantUpdate, params }: QuickCompletionParams) => {
const assistant = {
...getDefaultAssistant(),
prompt: systemPrompt,
model: quickModel,
...assistantUpdate
} satisfies Assistant
return fetchChatCompletion({ prompt, assistant, onChunkReceived: onChunk, ...params })
},
[quickModel, systemPrompt]
)
return completion
}

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Expand",
"mermaid_fix": {
"failed": "Failed to fix",
"invalid_result": "Model returned invalid data. Please try again or try changing the quick model.",
"label": "Fix mermaid error"
},
"more": "More",
"run": "Run",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "展开",
"mermaid_fix": {
"failed": "修复失败",
"invalid_result": "模型返回了无效数据。请重试或尝试更换快速模型。",
"label": "修复 mermaid 错误"
},
"more": "更多",
"run": "运行代码",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "展開",
"mermaid_fix": {
"failed": "修復失敗",
"invalid_result": "模型傳回了無效的資料。請再試一次,或嘗試更換快速模型。",
"label": "修復 mermaid 錯誤"
},
"more": "更多",
"run": "運行代碼",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Ausklappen",
"mermaid_fix": {
"failed": "Konnte nicht repariert werden",
"invalid_result": "Das Modell hat ungültige Daten zurückgegeben. Bitte versuchen Sie es erneut oder wechseln Sie das Schnellmodell.",
"label": "Mermaid-Fehler beheben"
},
"more": "Mehr",
"run": "Code ausführen",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "επιλογή",
"mermaid_fix": {
"failed": "Απέτυχε να διορθωθεί",
"invalid_result": "Το μοντέλο επέστρεψε μη έγκυρα δεδομένα. Προσπαθήστε ξανά ή δοκιμάστε να αλλάξετε το γρήγορο μοντέλο.",
"label": "Διόρθωσε το σφάλμα της μερμηγκιάς"
},
"more": "Περισσότερα",
"run": "Εκτέλεση κώδικα",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Expandir",
"mermaid_fix": {
"failed": "No se pudo arreglar",
"invalid_result": "El modelo devolvió datos inválidos. Por favor, inténtalo de nuevo o cambia el modelo rápido.",
"label": "Corregir error de mermaid"
},
"more": "Más",
"run": "Ejecutar código",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Développer",
"mermaid_fix": {
"failed": "Échec de la réparation",
"invalid_result": "Le modèle a renvoyé des données invalides. Veuillez réessayer ou essayer de changer le modèle rapide.",
"label": "Corriger l'erreur mermaid"
},
"more": "Plus",
"run": "Exécuter le code",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "展開する",
"mermaid_fix": {
"failed": "修正に失敗しました",
"invalid_result": "モデルが無効なデータを返しました。もう一度お試しいただくか、クイックモデルを変更してみてください。",
"label": "Mermaidエラーを修正"
},
"more": "もっと",
"run": "コードを実行",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Expandir",
"mermaid_fix": {
"failed": "Falha ao corrigir",
"invalid_result": "O modelo retornou dados inválidos. Tente novamente ou tente alterar o modelo rápido.",
"label": "Corrigir erro do mermaid"
},
"more": "Mais",
"run": "Executar código",
"split": {

View File

@@ -940,6 +940,11 @@
}
},
"expand": "Развернуть",
"mermaid_fix": {
"failed": "Не удалось исправить",
"invalid_result": "Модель вернула некорректные данные. Попробуйте ещё раз или смените быструю модель.",
"label": "Исправить ошибку Mermaid"
},
"more": "Ещё",
"run": "Выполнить код",
"split": {

View File

@@ -1,4 +1,6 @@
import { BlockingOverlay, cn, Spinner } from '@cherrystudio/ui'
import { CodeBlockView, HtmlArtifactsCard } from '@renderer/components/CodeBlockView'
import { usePendingMap } from '@renderer/hooks/usePendingMap'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store'
@@ -21,6 +23,8 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const isMultiline = children?.includes('\n')
const language = languageMatch?.[1] ?? (isMultiline ? 'text' : null)
const { codeFancyBlock } = useSettings()
const { isPending } = usePendingMap()
const isBlockPending = isPending(blockId)
// 代码块 id
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
@@ -30,8 +34,10 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const isStreaming = useMemo(() => msgBlock?.status === MessageBlockStatus.STREAMING, [msgBlock?.status])
const handleSave = useCallback(
(newContent: string) => {
async (newContent: string) => {
if (id !== undefined) {
// The event form cannot obtain the completion status of the handler via a promise.
// TODO: Use useContext to get topicId, use useEditCodeBlock to edit code block.
EventEmitter.emit(EVENT_NAMES.EDIT_CODE_BLOCK, {
msgBlockId: blockId,
codeBlockId: id,
@@ -52,14 +58,24 @@ const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
}
return (
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
<div className="relative">
<CodeBlockView language={language} onSave={handleSave} blockId={blockId}>
{children}
</CodeBlockView>
{isBlockPending && (
<BlockingOverlay isVisible={isBlockPending}>
<Spinner />
</BlockingOverlay>
)}
{/* <BlockingOverlay isVisible={true}>
<Spinner />
</BlockingOverlay> */}
</div>
)
}
return (
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
<code className={cn('relative', className)} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
{children}
</code>
)

View File

@@ -58,6 +58,12 @@ vi.mock('@renderer/hooks/useSettings', () => ({
useSettings: () => mocks.useSettings()
}))
vi.mock('@cherrystudio/ui', () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(' ')),
Spinner: vi.fn(() => <div data-testid="spinner" />),
BlockingOverlay: vi.fn(({ children }) => <div>{children}</div>)
}))
vi.mock('@renderer/components/CodeBlockView', () => ({
CodeBlockView: mocks.CodeBlockView,
HtmlArtifactsCard: mocks.HtmlArtifactsCard

View File

@@ -2,15 +2,19 @@
exports[`CodeBlock > rendering > should render a snapshot 1`] = `
<div>
<div>
<code>
console.log("hello world")
</code>
<button
type="button"
>
Save
</button>
<div
class="relative"
>
<div>
<code>
console.log("hello world")
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
</div>
`;

View File

@@ -5,6 +5,7 @@ import { LoadingIcon } from '@renderer/components/Icons'
import { LOAD_MORE_COUNT } from '@renderer/config/constant'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useEditCodeBlock } from '@renderer/hooks/useEditCodeBlock'
import { useMessageOperations, useTopicMessages } from '@renderer/hooks/useMessageOperations'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useShortcut } from '@renderer/hooks/useShortcuts'
@@ -15,22 +16,18 @@ import { getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { getContextCount, getGroupedMessages, getUserMessage } from '@renderer/services/MessagesService'
import { estimateHistoryTokens } from '@renderer/services/TokenService'
import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { useAppDispatch } from '@renderer/store'
import { newMessagesActions } from '@renderer/store/newMessage'
import { saveMessageAndBlocksToDB, updateMessageAndBlocksThunk } from '@renderer/store/thunk/messageThunk'
import { saveMessageAndBlocksToDB } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import type { MessageBlock } from '@renderer/types/newMessage'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import { type Message } from '@renderer/types/newMessage'
import {
captureScrollableAsBlob,
captureScrollableAsDataURL,
removeSpecialCharactersForFileName,
runAsyncFunction
} from '@renderer/utils'
import { updateCodeBlock } from '@renderer/utils/markdown'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { isTextLikeBlock } from '@renderer/utils/messageUtils/is'
import { last } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -70,6 +67,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
const messages = useTopicMessages(topic.id)
const { displayCount, clearTopicMessages, deleteMessage, createTopicBranch } = useMessageOperations(topic)
const { setTimeoutTimer } = useTimer()
const editCodeBlock = useEditCodeBlock()
const { isMultiSelectMode, handleSelectMessage } = useChatContext(topic)
@@ -199,40 +197,18 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
window.toast.error(t('message.branch.error')) // Example error message
}
}),
// TODO: We should move this processing function out of the component and turn it into a Hook, so that other components can reuse this function.
// The current implementation prevents the party emitting the event from knowing the completion status of the asynchronous function through a promise.
EventEmitter.on(
EVENT_NAMES.EDIT_CODE_BLOCK,
async (data: { msgBlockId: string; codeBlockId: string; newContent: string }) => {
const { msgBlockId, codeBlockId, newContent } = data
const msgBlock = messageBlocksSelectors.selectById(store.getState(), msgBlockId)
// FIXME: 目前 error block 没有 content
if (msgBlock && isTextLikeBlock(msgBlock) && msgBlock.type !== MessageBlockType.ERROR) {
try {
const updatedRaw = updateCodeBlock(msgBlock.content, codeBlockId, newContent)
const updatedBlock: MessageBlock = {
...msgBlock,
content: updatedRaw,
updatedAt: new Date().toISOString()
}
dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } }))
await dispatch(updateMessageAndBlocksThunk(topic.id, null, [updatedBlock]))
window.toast.success(t('code_block.edit.save.success'))
} catch (error) {
logger.error(
`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`,
error as Error
)
window.toast.error(t('code_block.edit.save.failed.label'))
}
} else {
logger.error(
`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}: no such message block or the block doesn't have a content field`
)
window.toast.error(t('code_block.edit.save.failed.label'))
}
return editCodeBlock({
topicId: topic.id,
msgBlockId,
codeBlockId,
newContent
})
}
)
]

View File

@@ -1,5 +1,7 @@
import { loggerService } from '@logger'
// TODO: We may refactor it to a service with pendingMap
const logger = loggerService.withContext('AbortController')
export const abortMap = new Map<string, (() => void)[]>()

View File

@@ -17363,7 +17363,7 @@ __metadata:
lint-staged: "npm:^15.5.0"
lodash: "npm:^4.17.21"
lru-cache: "npm:^11.1.0"
lucide-react: "npm:^0.525.0"
lucide-react: "npm:^0.546.0"
macos-release: "npm:^3.4.0"
markdown-it: "npm:^14.1.0"
mermaid: "npm:^11.10.1"
@@ -25366,15 +25366,6 @@ __metadata:
languageName: node
linkType: hard
"lucide-react@npm:^0.525.0":
version: 0.525.0
resolution: "lucide-react@npm:0.525.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/81c4438e2cf1c86ea2ebe0a97b378201512450894283cccce766a89bc6a4e47c8df1d9115d845d98f582bb0a10be31c454aa232520fea35018dac1cd8466ea9b
languageName: node
linkType: hard
"lucide-react@npm:^0.545.0":
version: 0.545.0
resolution: "lucide-react@npm:0.545.0"
@@ -25384,6 +25375,15 @@ __metadata:
languageName: node
linkType: hard
"lucide-react@npm:^0.546.0":
version: 0.546.0
resolution: "lucide-react@npm:0.546.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/42ee0bd358517f012297aefb69b54da0b3c62f1ac8485ffa24141b63f05c9f4a682eacc2637c4b13f597aed11f9a8c79627af0c17717400bebb25581daeaad80
languageName: node
linkType: hard
"lz-string@npm:^1.5.0":
version: 1.5.0
resolution: "lz-string@npm:1.5.0"