Compare commits
22 Commits
v2
...
feat/fix-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa29a6034d | ||
|
|
aaded50d3f | ||
|
|
0b1f02a661 | ||
|
|
e06e920086 | ||
|
|
c455b0a70a | ||
|
|
552694df11 | ||
|
|
eabbe593dd | ||
|
|
ed651b995e | ||
|
|
b8a8f19892 | ||
|
|
e69260defa | ||
|
|
d3d02712a4 | ||
|
|
d2b25af146 | ||
|
|
63e522bf82 | ||
|
|
55b63d345e | ||
|
|
8ba48d9df0 | ||
|
|
1d94d56e2a | ||
|
|
0fb5480b0a | ||
|
|
91cf5d2e7d | ||
|
|
578cf38072 | ||
|
|
a0445a307a | ||
|
|
2f3e634880 | ||
|
|
684c0a7b63 |
@@ -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",
|
||||
|
||||
3
packages/shared/data/cache/cacheSchemas.ts
vendored
3
packages/shared/data/cache/cacheSchemas.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() + '>'
|
||||
|
||||
@@ -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
|
||||
/** User’s 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])
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>()
|
||||
|
||||
55
src/renderer/src/hooks/useEditCodeBlock.ts
Normal file
55
src/renderer/src/hooks/useEditCodeBlock.ts
Normal 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
|
||||
}
|
||||
31
src/renderer/src/hooks/usePendingMap.ts
Normal file
31
src/renderer/src/hooks/usePendingMap.ts
Normal 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 }
|
||||
}
|
||||
50
src/renderer/src/hooks/useQuickCompletion.ts
Normal file
50
src/renderer/src/hooks/useQuickCompletion.ts
Normal 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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -940,6 +940,11 @@
|
||||
}
|
||||
},
|
||||
"expand": "展开",
|
||||
"mermaid_fix": {
|
||||
"failed": "修复失败",
|
||||
"invalid_result": "模型返回了无效数据。请重试或尝试更换快速模型。",
|
||||
"label": "修复 mermaid 错误"
|
||||
},
|
||||
"more": "更多",
|
||||
"run": "运行代码",
|
||||
"split": {
|
||||
|
||||
@@ -940,6 +940,11 @@
|
||||
}
|
||||
},
|
||||
"expand": "展開",
|
||||
"mermaid_fix": {
|
||||
"failed": "修復失敗",
|
||||
"invalid_result": "模型傳回了無效的資料。請再試一次,或嘗試更換快速模型。",
|
||||
"label": "修復 mermaid 錯誤"
|
||||
},
|
||||
"more": "更多",
|
||||
"run": "運行代碼",
|
||||
"split": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -940,6 +940,11 @@
|
||||
}
|
||||
},
|
||||
"expand": "επιλογή",
|
||||
"mermaid_fix": {
|
||||
"failed": "Απέτυχε να διορθωθεί",
|
||||
"invalid_result": "Το μοντέλο επέστρεψε μη έγκυρα δεδομένα. Προσπαθήστε ξανά ή δοκιμάστε να αλλάξετε το γρήγορο μοντέλο.",
|
||||
"label": "Διόρθωσε το σφάλμα της μερμηγκιάς"
|
||||
},
|
||||
"more": "Περισσότερα",
|
||||
"run": "Εκτέλεση κώδικα",
|
||||
"split": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -940,6 +940,11 @@
|
||||
}
|
||||
},
|
||||
"expand": "展開する",
|
||||
"mermaid_fix": {
|
||||
"failed": "修正に失敗しました",
|
||||
"invalid_result": "モデルが無効なデータを返しました。もう一度お試しいただくか、クイックモデルを変更してみてください。",
|
||||
"label": "Mermaidエラーを修正"
|
||||
},
|
||||
"more": "もっと",
|
||||
"run": "コードを実行",
|
||||
"split": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -940,6 +940,11 @@
|
||||
}
|
||||
},
|
||||
"expand": "Развернуть",
|
||||
"mermaid_fix": {
|
||||
"failed": "Не удалось исправить",
|
||||
"invalid_result": "Модель вернула некорректные данные. Попробуйте ещё раз или смените быструю модель.",
|
||||
"label": "Исправить ошибку Mermaid"
|
||||
},
|
||||
"more": "Ещё",
|
||||
"run": "Выполнить код",
|
||||
"split": {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)[]>()
|
||||
|
||||
20
yarn.lock
20
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user