feat: code tools, editor, executor (#4632)

* feat: code tools, editor, executor

CodeEditor & Preview
- CodeEditor: CodeMirror 6
  - Switch to CodeEditor in the settings
  - Support edit&save with a accurate diff&lookup strategy
  - Use CodeEditor for editing MCP json configuration
- CodePreview: Original Shiki syntax highlighting
  - Implemented using a custom Shiki stream tokenizer
  - Remov code caching as it is incompatible with the current streaming code highlighting
  - Add a webworker for shiki
- Other preview components
  - Merge MermaidPopup and Mermaid to MermaidPreview, use local mermaidjs
  - Show mermaid syntax error message on demand
  - Rename PlantUML to PlantUmlPreview
- Rename SyntaxHighlighterProvider to CodeStyleProvider for clarity
- Both light and dark themes are preserved for convenience

CodeToolbar
- Top sticky toolbar provides quick tools (left) and core tools (right)
- Quick tools are hidden under the `More` button to avoid clutter, while core tools are always visible
- View&edit mode
  - Allow switching between preview and edit modes
  - Add a split view

Code execution
- Pyodide for executing Python scripts
- Add a webworker for Pyodide

* fix: migrate version and lint error

* refactor: use constants for defining tool specs

* refactor: add user-select, fix tool specs

* refactor: simplify some state changing

* fix: make sure editor tools registered after the editor is ready

---------

Co-authored-by: 自由的世界人 <3196812536@qq.com>
This commit is contained in:
one 2025-05-16 13:43:47 +08:00 committed by kangfenmao
parent c6b87b307b
commit 2dedd95fcc
61 changed files with 7389 additions and 1918 deletions

View File

@ -73,13 +73,26 @@ export default defineConfig({
}
},
optimizeDeps: {
exclude: []
exclude: ['pyodide']
},
worker: {
format: 'es'
},
build: {
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
miniWindow: resolve(__dirname, 'src/renderer/miniWindow.html')
},
output: {
manualChunks: (id) => {
// 检测所有 worker 文件,提取 worker 名称作为 chunk 名
if (id.includes('.worker') && id.endsWith('?worker')) {
const workerName = id.split('/').pop()?.split('.')[0] || 'worker'
return `workers/${workerName}`
}
return undefined
}
}
}
}

View File

@ -74,6 +74,9 @@
"@strongtz/win32-arm64-msvc": "^0.4.7",
"@tanstack/react-query": "^5.27.0",
"@types/react-infinite-scroll-component": "^5.0.0",
"@uiw/codemirror-extensions-langs": "^4.23.12",
"@uiw/codemirror-themes-all": "^4.23.12",
"@uiw/react-codemirror": "^4.23.12",
"archiver": "^7.0.1",
"async-mutex": "^0.5.0",
"color": "^5.0.0",
@ -84,12 +87,14 @@
"electron-updater": "6.6.4",
"electron-window-state": "^5.0.3",
"epub": "patch:epub@npm%3A1.3.0#~/.yarn/patches/epub-npm-1.3.0-8325494ffe.patch",
"fast-diff": "^1.3.0",
"fast-xml-parser": "^5.2.0",
"fetch-socks": "^1.3.2",
"fs-extra": "^11.2.0",
"got-scraping": "^4.1.1",
"jsdom": "^26.0.0",
"markdown-it": "^14.1.0",
"mermaid": "^11.6.0",
"node-stream-zip": "^1.15.0",
"officeparser": "^4.1.1",
"os-proxy-config": "^1.1.2",
@ -145,6 +150,7 @@
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1",
"@vitest/web-worker": "^3.1.3",
"@xyflow/react": "^12.4.4",
"antd": "^5.22.5",
"applescript": "^1.0.0",

View File

@ -8,8 +8,8 @@ import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { CodeStyleProvider } from './context/CodeStyleProvider'
import StyleSheetManager from './context/StyleSheetManager'
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
import { ThemeProvider } from './context/ThemeProvider'
import NavigationHandler from './handler/NavigationHandler'
import AgentsPage from './pages/agents/AgentsPage'
@ -27,7 +27,7 @@ function App(): React.ReactElement {
<StyleSheetManager>
<ThemeProvider>
<AntdProvider>
<SyntaxHighlighterProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
@ -46,7 +46,7 @@ function App(): React.ReactElement {
</HashRouter>
</TopViewContainer>
</PersistGate>
</SyntaxHighlighterProvider>
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
</StyleSheetManager>

View File

@ -125,7 +125,9 @@
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(> .mermaid) {
&:has(.mermaid),
&:has(.plantuml-preview),
&:has(.svg-preview) {
background-color: transparent;
}
&:not(pre pre) {
@ -304,3 +306,26 @@ emoji-picker {
mjx-container {
overflow-x: auto;
}
/* CodeMirror 相关样式 */
.cm-editor {
.cm-scroller {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
padding: 1px;
border-radius: 5px;
.cm-gutters {
line-height: 1.6;
}
.cm-content {
line-height: 1.6;
padding-left: 0.25em;
}
.cm-lineWrapping * {
word-wrap: break-word;
white-space: pre-wrap;
}
}
}

View File

@ -0,0 +1,285 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { uuid } from '@renderer/utils'
import { getReactStyleFromToken } from '@renderer/utils/shiki'
import { ChevronsDownUp, ChevronsUpDown, Text as UnWrapIcon, WrapText as WrapIcon } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
children: string
language: string
}
/**
* Shiki
*
* - shiki tokenizer
* - tokenizer
*/
const CodePreview = ({ children, language }: CodePreviewProps) => {
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const { activeShikiTheme, highlightCodeChunk, cleanupTokenizers } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [tokenLines, setTokenLines] = useState<ThemedToken[][]>([])
const codeContentRef = useRef<HTMLDivElement>(null)
const prevCodeLengthRef = useRef(0)
const safeCodeStringRef = useRef(children)
const highlightQueueRef = useRef<Promise<void>>(Promise.resolve())
const callerId = useRef(`${Date.now()}-${uuid()}`).current
const shikiThemeRef = useRef(activeShikiTheme)
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
// 展开/折叠工具
useEffect(() => {
registerTool({
...TOOL_SPECS.expand,
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => {
const scrollHeight = codeContentRef.current?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350
},
onClick: () => setIsExpanded((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.expand.id)
}, [codeCollapsible, isExpanded, registerTool, removeTool, t])
// 自动换行工具
useEffect(() => {
registerTool({
...TOOL_SPECS.wrap,
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
visible: () => codeWrappable,
onClick: () => setIsUnwrapped((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.wrap.id)
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
// 更新展开状态
useEffect(() => {
setIsExpanded(!codeCollapsible)
}, [codeCollapsible])
// 更新换行状态
useEffect(() => {
setIsUnwrapped(!codeWrappable)
}, [codeWrappable])
// 处理尾部空白字符
const safeCodeString = useMemo(() => {
return typeof children === 'string' ? children.trimEnd() : ''
}, [children])
const highlightCode = useCallback(async () => {
if (!safeCodeString) return
if (prevCodeLengthRef.current === safeCodeString.length) return
// 捕获当前状态
const startPos = prevCodeLengthRef.current
const endPos = safeCodeString.length
// 添加到处理队列,确保按顺序处理
highlightQueueRef.current = highlightQueueRef.current.then(async () => {
// FIXME: 长度有问题,或者破坏了流式内容,需要清理 tokenizer 并使用完整代码重新高亮
if (prevCodeLengthRef.current > safeCodeString.length || !safeCodeString.startsWith(safeCodeStringRef.current)) {
cleanupTokenizers(callerId)
prevCodeLengthRef.current = 0
safeCodeStringRef.current = ''
const result = await highlightCodeChunk(safeCodeString, language, callerId)
setTokenLines(result.lines)
prevCodeLengthRef.current = safeCodeString.length
safeCodeStringRef.current = safeCodeString
return
}
// 跳过 race condition延迟到后续任务
if (prevCodeLengthRef.current !== startPos) {
return
}
const incrementalCode = safeCodeString.slice(startPos, endPos)
const result = await highlightCodeChunk(incrementalCode, language, callerId)
setTokenLines((lines) => [...lines.slice(0, Math.max(0, lines.length - result.recall)), ...result.lines])
prevCodeLengthRef.current = endPos
safeCodeStringRef.current = safeCodeString
})
}, [callerId, cleanupTokenizers, highlightCodeChunk, language, safeCodeString])
// 主题变化时强制重新高亮
useEffect(() => {
if (shikiThemeRef.current !== activeShikiTheme) {
prevCodeLengthRef.current++
shikiThemeRef.current = activeShikiTheme
}
}, [activeShikiTheme])
// 组件卸载时清理资源
useEffect(() => {
return () => cleanupTokenizers(callerId)
}, [callerId, cleanupTokenizers])
// 处理第二次开始的代码高亮
useEffect(() => {
if (prevCodeLengthRef.current > 0) {
setTimeout(highlightCode, 0)
}
}, [highlightCode])
// 视口检测逻辑,只处理第一次代码高亮
useEffect(() => {
const codeElement = codeContentRef.current
if (!codeElement || prevCodeLengthRef.current > 0) return
let isMounted = true
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && isMounted) {
setTimeout(highlightCode, 0)
observer.disconnect()
}
})
observer.observe(codeElement)
return () => {
isMounted = false
observer.disconnect()
}
}, [highlightCode])
return (
<ContentContainer
ref={codeContentRef}
$isShowLineNumbers={codeShowLineNumbers}
$isUnwrapped={isUnwrapped}
$isCodeWrappable={codeWrappable}
style={{
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible'
}}>
{tokenLines.length > 0 ? (
<ShikiTokensRenderer language={language} tokenLines={tokenLines} />
) : (
<div style={{ opacity: 0.1 }}>{children}</div>
)}
</ContentContainer>
)
}
/**
* Shiki tokens
*
* 便 virtual list
*/
const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo(
({ language, tokenLines }) => {
const { getShikiPreProperties } = useCodeStyle()
const rendererRef = useRef<HTMLPreElement>(null)
// 设置 pre 标签属性
useEffect(() => {
getShikiPreProperties(language).then((properties) => {
const pre = rendererRef.current
if (pre) {
pre.className = properties.class
pre.style.cssText = properties.style
pre.tabIndex = properties.tabindex
}
})
}, [language, getShikiPreProperties])
return (
<pre className="shiki" ref={rendererRef}>
<code>
{tokenLines.map((lineTokens, lineIndex) => (
<span key={`line-${lineIndex}`} className="line">
{lineTokens.map((token, tokenIndex) => (
<span key={`token-${tokenIndex}`} style={getReactStyleFromToken(token)}>
{token.content}
</span>
))}
</span>
))}
</code>
</pre>
)
}
)
const ContentContainer = styled.div<{
$isShowLineNumbers: boolean
$isUnwrapped: boolean
$isCodeWrappable: boolean
}>`
position: relative;
border: 0.5px solid var(--color-code-background);
border-radius: 5px;
margin-top: 0;
transition: opacity 0.3s ease;
.shiki {
padding: 1em;
code {
display: flex;
flex-direction: column;
width: 100%;
.line {
display: block;
min-height: 1.3rem;
padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')};
}
}
}
${(props) =>
props.$isShowLineNumbers &&
`
code {
counter-reset: step;
counter-increment: step 0;
position: relative;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
position: absolute;
left: 0;
text-align: right;
opacity: 0.35;
}
`}
${(props) =>
props.$isCodeWrappable &&
!props.$isUnwrapped &&
`
code .line * {
word-wrap: break-word;
white-space: pre-wrap;
}
`}
`
CodePreview.displayName = 'CodePreview'
export default memo(CodePreview)

View File

@ -1,4 +1,4 @@
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { ExpandOutlined, LinkOutlined } from '@ant-design/icons'
import { AppLogo } from '@renderer/config/env'
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
import { extractTitle } from '@renderer/utils/formats'
@ -46,13 +46,6 @@ const Artifacts: FC<Props> = ({ html }) => {
}
}
/**
*
*/
const onDownload = () => {
window.api.file.save(`${title}.html`, html)
}
return (
<Container>
<Button icon={<ExpandOutlined />} onClick={handleOpenInApp}>
@ -62,10 +55,6 @@ const Artifacts: FC<Props> = ({ html }) => {
<Button icon={<LinkOutlined />} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<DownloadOutlined />} onClick={onDownload}>
{t('chat.artifacts.button.download')}
</Button>
</Container>
)
}

View File

@ -0,0 +1,99 @@
import { nanoid } from '@reduxjs/toolkit'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex } from 'antd'
import React, { memo, startTransition, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const MermaidPreview: React.FC<Props> = ({ children }) => {
const { mermaid, isLoading, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
const errorTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(mermaidRef, {
imgSelector: 'svg',
prefix: 'mermaid',
enableWheelZoom: true
})
// 使用工具栏
usePreviewTools({
handleZoom,
handleCopyImage,
handleDownload
})
const render = useCallback(async () => {
try {
if (!children) return
// 验证语法,提前抛出异常
await mermaid.parse(children)
if (!mermaidRef.current) return
const { svg } = await mermaid.render(diagramId, children, mermaidRef.current)
// 避免不可见时产生 undefined 和 NaN
const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
mermaidRef.current.innerHTML = fixedSvg
// 没有语法错误时清除错误记录和定时器
setError(null)
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = null
}
} catch (error) {
// 延迟显示错误
if (errorTimeoutRef.current) clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = setTimeout(() => {
setError((error as Error).message)
}, 500)
}
}, [children, diagramId, mermaid])
// 渲染Mermaid图表
useEffect(() => {
if (isLoading) return
startTransition(render)
// 清理定时器
return () => {
if (errorTimeoutRef.current) {
clearTimeout(errorTimeoutRef.current)
errorTimeoutRef.current = null
}
}
}, [isLoading, render])
return (
<Flex vertical>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
)
}
const StyledMermaid = styled.div`
overflow: auto;
`
const StyledError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(MermaidPreview)

View File

@ -0,0 +1,193 @@
import { LoadingOutlined } from '@ant-design/icons'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd'
import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data[i], data[i + 1], 0)
} else if (i + 1 === data.length) {
r += append3bytes(data[i], 0, 0)
} else {
r += append3bytes(data[i], data[i + 1], data[i + 2])
}
}
return r
}
function encode6bit(b: number) {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return '-'
}
if (b === 1) {
return '_'
}
return '?'
}
function append3bytes(b1: number, b2: number, b3: number) {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
const c4 = b3 & 0x3f
let r = ''
r += encode6bit(c1 & 0x3f)
r += encode6bit(c2 & 0x3f)
r += encode6bit(c3 & 0x3f)
r += encode6bit(c4 & 0x3f)
return r
}
/**
* https://plantuml.com/zh/code-javascript-synchronous
* To use PlantUML image generation, a text diagram description have to be :
1. Encoded in UTF-8
2. Compressed using Deflate algorithm
3. Reencoded in ASCII using a transformation _close_ to base64
*/
function encodeDiagram(diagram: string): string {
const utf8text = new TextEncoder().encode(diagram)
const compressed = pako.deflateRaw(utf8text)
return encode64(compressed)
}
async function downloadUrl(url: string, filename: string) {
const response = await fetch(url)
if (!response.ok) {
window.message.warning({ content: response.statusText, duration: 1.5 })
return
}
const blob = await response.blob()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
}
type PlantUMLServerImageProps = {
format: 'png' | 'svg'
diagram: string
onClick?: React.MouseEventHandler<HTMLDivElement>
className?: string
}
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
const encodedDiagram = encodeDiagram(diagram)
if (isDark) {
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
}
return `${PlantUMLServer}/${format}/${encodedDiagram}`
}
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
const [loading, setLoading] = useState(true)
// FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景
const url = getPlantUMLImageUrl(format, diagram, false)
return (
<StyledPlantUML onClick={onClick} className={className}>
<Spin
spinning={loading}
indicator={
<LoadingOutlined
spin
style={{
fontSize: 32
}}
/>
}>
<img
src={url}
onLoad={() => {
setLoading(false)
}}
onError={(e) => {
setLoading(false)
const target = e.target as HTMLImageElement
target.style.opacity = '0.5'
target.style.filter = 'blur(2px)'
}}
/>
</Spin>
</StyledPlantUML>
)
}
interface PlantUMLProps {
children: string
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
const encodedDiagram = encodeDiagram(children)
// 自定义 PlantUML 下载方法
const customDownload = useCallback(
(format: 'svg' | 'png') => {
const timestamp = Date.now()
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
const filename = `plantuml-diagram-${timestamp}.${format}`
downloadUrl(url, filename).catch(() => {
window.message.error(t('code_block.download.failed.network'))
})
},
[encodedDiagram, t]
)
// 使用通用图像工具,提供自定义下载方法
const { handleZoom, handleCopyImage } = usePreviewToolHandlers(containerRef, {
imgSelector: '.plantuml-preview img',
prefix: 'plantuml-diagram',
enableWheelZoom: true,
customDownloader: customDownload
})
// 使用工具栏
usePreviewTools({
handleZoom,
handleCopyImage,
handleDownload: customDownload
})
return (
<div ref={containerRef}>
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
</div>
)
}
const StyledPlantUML = styled.div`
max-height: calc(80vh - 100px);
text-align: left;
overflow-y: auto;
background-color: white;
img {
max-width: 100%;
height: auto;
min-height: 100px;
transition: transform 0.2s ease;
}
`
export default memo(PlantUmlPreview)

View File

@ -0,0 +1,22 @@
import { FC, memo } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const StatusBar: FC<Props> = ({ children }) => {
return <Container>{children}</Container>
}
const Container = styled.div`
margin: 10px;
display: flex;
flex-direction: row;
gap: 8px;
padding-bottom: 10px;
overflow-y: auto;
text-wrap: wrap;
`
export default memo(StatusBar)

View File

@ -0,0 +1,38 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useRef } from 'react'
import styled from 'styled-components'
interface Props {
children: string
}
const SvgPreview: React.FC<Props> = ({ children }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
// 使用通用图像工具
const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, {
imgSelector: '.svg-preview svg',
prefix: 'svg-image'
})
// 使用工具栏
usePreviewTools({
handleCopyImage,
handleDownload
})
return (
<SvgPreviewContainer ref={svgContainerRef} className="svg-preview" dangerouslySetInnerHTML={{ __html: children }} />
)
}
const SvgPreviewContainer = styled.div`
padding: 1em;
background-color: white;
overflow: auto;
border: 0.5px solid var(--color-code-background);
border-top-left-radius: 0;
border-top-right-radius: 0;
`
export default memo(SvgPreview)

View File

@ -0,0 +1,324 @@
import { LoadingOutlined } from '@ant-design/icons'
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbar, CodeToolContext, TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { isValidPlantUML } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import { CirclePlay, CodeXml, Copy, Download, Eye, Square, SquarePen, SquareSplitHorizontal } from 'lucide-react'
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import CodePreview from './CodePreview'
import HtmlArtifacts from './HtmlArtifacts'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import StatusBar from './StatusBar'
import SvgPreview from './SvgPreview'
type ViewMode = 'source' | 'special' | 'split'
interface Props {
children: string
language: string
onSave?: (newContent: string) => void
}
/**
*
*
*
* - preview: 预览视图
* - edit: 编辑视图
*
*
* - source: 源代码视图模式
* - special: 特殊视图模式MermaidPlantUMLSVG
* - split: 分屏模式
*
* sticky
* - quick
* - core
*/
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
const [viewMode, setViewMode] = useState<ViewMode>('special')
const [isRunning, setIsRunning] = useState(false)
const [output, setOutput] = useState('')
const isExecutable = useMemo(() => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
}, [hasSpecialView, viewMode])
const { updateContext, registerTool, removeTool } = useCodeToolbar()
useEffect(() => {
updateContext({
code: children,
language
})
}, [children, language, updateContext])
const handleCopySource = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
navigator.clipboard.writeText(ctx.code)
window.message.success({ content: t('code_block.copy.success'), key: 'copy-code' })
},
[t]
)
const handleDownloadSource = useCallback((ctx?: CodeToolContext) => {
if (!ctx) return
const { code, language } = ctx
let fileName = ''
// 尝试提取标题
if (language === 'html' && code.includes('</html>')) {
const title = extractTitle(code)
if (title) {
fileName = `${title}.html`
}
}
// 默认使用日期格式命名
if (!fileName) {
fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
}
window.api.file.save(fileName, code)
}, [])
const handleRunScript = useCallback(
(ctx?: CodeToolContext) => {
if (!ctx) return
setIsRunning(true)
setOutput('')
pyodideService
.runScript(ctx.code, {}, codeExecution.timeoutMinutes * 60000)
.then((formattedOutput) => {
setOutput(formattedOutput)
})
.catch((error) => {
console.error('Unexpected error:', error)
setOutput(`Unexpected error: ${error.message || 'Unknown error'}`)
})
.finally(() => {
setIsRunning(false)
})
},
[codeExecution.timeoutMinutes]
)
useEffect(() => {
// 复制按钮
registerTool({
...TOOL_SPECS.copy,
icon: <Copy className="icon" />,
tooltip: t('code_block.copy.source'),
onClick: handleCopySource
})
// 下载按钮
registerTool({
...TOOL_SPECS.download,
icon: <Download className="icon" />,
tooltip: t('code_block.download.source'),
onClick: handleDownloadSource
})
return () => {
removeTool(TOOL_SPECS.copy.id)
removeTool(TOOL_SPECS.download.id)
}
}, [handleCopySource, handleDownloadSource, registerTool, removeTool, t])
// 特殊视图的编辑按钮,在分屏模式下不可用
useEffect(() => {
if (!hasSpecialView || viewMode === 'split') return
const viewSourceToolSpec = codeEditor.enabled ? TOOL_SPECS.edit : TOOL_SPECS['view-source']
if (codeEditor.enabled) {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <SquarePen className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
} else {
registerTool({
...viewSourceToolSpec,
icon: viewMode === 'source' ? <Eye className="icon" /> : <CodeXml className="icon" />,
tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.preview.source'),
onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source')
})
}
return () => removeTool(viewSourceToolSpec.id)
}, [codeEditor.enabled, hasSpecialView, viewMode, registerTool, removeTool, t])
// 特殊视图的分屏按钮
useEffect(() => {
if (!hasSpecialView) return
registerTool({
...TOOL_SPECS['split-view'],
icon: viewMode === 'split' ? <Square className="icon" /> : <SquareSplitHorizontal className="icon" />,
tooltip: viewMode === 'split' ? t('code_block.split.restore') : t('code_block.split'),
onClick: () => setViewMode(viewMode === 'split' ? 'special' : 'split')
})
return () => removeTool(TOOL_SPECS['split-view'].id)
}, [hasSpecialView, viewMode, registerTool, removeTool, t])
// 运行按钮
useEffect(() => {
if (!isExecutable) return
registerTool({
...TOOL_SPECS.run,
icon: isRunning ? <LoadingOutlined /> : <CirclePlay className="icon" />,
tooltip: t('code_block.run'),
onClick: (ctx) => !isRunning && handleRunScript(ctx)
})
return () => isExecutable && removeTool(TOOL_SPECS.run.id)
}, [isExecutable, isRunning, handleRunScript, registerTool, removeTool, t])
// 源代码视图组件
const sourceView = useMemo(() => {
const SourceView = codeEditor.enabled ? CodeEditor : CodePreview
return (
<SourceView language={language} onSave={onSave}>
{children}
</SourceView>
)
}, [children, codeEditor.enabled, language, onSave])
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview>{children}</SvgPreview>
}
return null
}, [children, language])
const renderHeader = useMemo(() => {
const langTag = '<' + language.toUpperCase() + '>'
return <CodeHeader $isInSpecialView={isInSpecialView}>{isInSpecialView ? '' : langTag}</CodeHeader>
}, [isInSpecialView, language])
// 根据视图模式和语言选择组件优先展示特殊视图fallback是源代码视图
const renderContent = useMemo(() => {
const showSpecialView = specialView && ['special', 'split'].includes(viewMode)
const showSourceView = !specialView || viewMode !== 'special'
return (
<SplitViewWrapper className="split-view-wrapper">
{showSpecialView && specialView}
{showSourceView && sourceView}
</SplitViewWrapper>
)
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
if (language === 'html') {
return <HtmlArtifacts html={children} />
}
return null
}, [children, language])
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}
<CodeToolbar />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)
}
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
.code-toolbar {
opacity: 0;
transition: opacity 0.2s ease;
transform: translateZ(0);
will-change: opacity;
&.show {
opacity: 1;
}
}
&:hover {
.code-toolbar {
opacity: 1;
}
}
${(props) =>
props.$isInSpecialView &&
css`
.code-toolbar {
margin-top: 20px;
}
`}
${(props) =>
!props.$isInSpecialView &&
css`
.code-toolbar {
background-color: var(--color-background-mute);
border-radius: 4px;
}
`}
`
const CodeHeader = styled.div<{ $isInSpecialView: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text);
font-size: 14px;
font-weight: bold;
height: 34px;
padding: 0 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
${(props) =>
props.$isInSpecialView &&
css`
height: 16px;
`}
`
const SplitViewWrapper = styled.div`
display: flex;
width: 100%;
> * {
flex: 1 1 0;
min-width: 0;
overflow: auto;
}
`
export default memo(CodeBlockView)

View File

@ -0,0 +1,251 @@
import { TOOL_SPECS, useCodeToolbar } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import CodeMirror, { Annotation, EditorView, Extension, keymap } from '@uiw/react-codemirror'
import diff from 'fast-diff'
import {
ChevronsDownUp,
ChevronsUpDown,
Save as SaveIcon,
Text as UnWrapIcon,
WrapText as WrapIcon
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
// 标记非用户编辑的变更
const External = Annotation.define<boolean>()
interface Props {
children: string
language: string
onSave?: (newContent: string) => void
onChange?: (newContent: string) => void
// options used to override the default behaviour
options?: {
maxHeight?: string
}
}
/**
* CodeMirror
*
* CodeToolbar 使
*/
const CodeEditor = ({ children, language, onSave, onChange, options }: Props) => {
const { fontSize, codeShowLineNumbers, codeCollapsible, codeWrappable, codeEditor } = useSettings()
const { activeCmTheme, languageMap } = useCodeStyle()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const initialContent = useRef(children?.trimEnd() ?? '')
const [langExtension, setLangExtension] = useState<Extension[]>([])
const [editorReady, setEditorReady] = useState(false)
const editorViewRef = useRef<EditorView | null>(null)
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setLangExtension([extension])
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
// 展开/折叠工具
useEffect(() => {
registerTool({
...TOOL_SPECS.expand,
icon: isExpanded ? <ChevronsDownUp className="icon" /> : <ChevronsUpDown className="icon" />,
tooltip: isExpanded ? t('code_block.collapse') : t('code_block.expand'),
visible: () => {
const scrollHeight = editorViewRef?.current?.scrollDOM?.scrollHeight
return codeCollapsible && (scrollHeight ?? 0) > 350
},
onClick: () => setIsExpanded((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.expand.id)
}, [codeCollapsible, isExpanded, registerTool, removeTool, t, editorReady])
// 自动换行工具
useEffect(() => {
registerTool({
...TOOL_SPECS.wrap,
icon: isUnwrapped ? <WrapIcon className="icon" /> : <UnWrapIcon className="icon" />,
tooltip: isUnwrapped ? t('code_block.wrap.on') : t('code_block.wrap.off'),
visible: () => codeWrappable,
onClick: () => setIsUnwrapped((prev) => !prev)
})
return () => removeTool(TOOL_SPECS.wrap.id)
}, [codeWrappable, isUnwrapped, registerTool, removeTool, t])
const handleSave = useCallback(() => {
const currentDoc = editorViewRef.current?.state.doc.toString() ?? ''
onSave?.(currentDoc)
}, [onSave])
// 保存按钮
useEffect(() => {
registerTool({
...TOOL_SPECS.save,
icon: <SaveIcon className="icon" />,
tooltip: t('code_block.edit.save'),
onClick: handleSave
})
return () => removeTool(TOOL_SPECS.save.id)
}, [handleSave, registerTool, removeTool, t])
// 流式响应过程中计算 changes 来更新 EditorView
// 无法处理用户在流式响应过程中编辑代码的情况(应该也不必处理)
useEffect(() => {
if (!editorViewRef.current) return
const newContent = children?.trimEnd() ?? ''
const currentDoc = editorViewRef.current.state.doc.toString()
const changes = prepareCodeChanges(currentDoc, newContent)
if (changes && changes.length > 0) {
editorViewRef.current.dispatch({
changes,
annotations: [External.of(true)]
})
}
}, [children])
useEffect(() => {
setIsExpanded(!codeCollapsible)
}, [codeCollapsible])
useEffect(() => {
setIsUnwrapped(!codeWrappable)
}, [codeWrappable])
// 保存功能的快捷键
const saveKeymap = useMemo(() => {
return keymap.of([
{
key: 'Mod-s',
run: () => {
handleSave()
return true
},
preventDefault: true
}
])
}, [handleSave])
const enabledExtensions = useMemo(() => {
return [
...langExtension,
...(isUnwrapped ? [] : [EditorView.lineWrapping]),
...(codeEditor.keymap ? [saveKeymap] : [])
]
}, [codeEditor.keymap, langExtension, isUnwrapped, saveKeymap])
return (
<CodeMirror
// 维持一个稳定值,避免触发 CodeMirror 重置
value={initialContent.current}
width="100%"
maxHeight={codeCollapsible && !isExpanded ? (options?.maxHeight ?? '350px') : 'none'}
editable={true}
// @ts-ignore 强制使用,见 react-codemirror 的 Example.tsx
theme={activeCmTheme}
extensions={enabledExtensions}
onCreateEditor={(view: EditorView) => {
editorViewRef.current = view
setEditorReady(true)
}}
onChange={(value, viewUpdate) => {
if (onChange && viewUpdate.docChanged) onChange(value)
}}
basicSetup={{
lineNumbers: codeShowLineNumbers,
highlightActiveLineGutter: codeEditor.highlightActiveLine,
foldGutter: codeEditor.foldGutter,
dropCursor: true,
allowMultipleSelections: true,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: codeEditor.autocompletion,
rectangularSelection: true,
crosshairCursor: true,
highlightActiveLine: codeEditor.highlightActiveLine,
highlightSelectionMatches: true,
closeBracketsKeymap: codeEditor.keymap,
searchKeymap: codeEditor.keymap,
foldKeymap: codeEditor.keymap,
completionKeymap: codeEditor.keymap,
lintKeymap: codeEditor.keymap
}}
style={{
fontSize: `${fontSize - 1}px`,
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
position: 'relative',
border: '0.5px solid var(--color-code-background)',
borderRadius: '5px',
marginTop: 0
}}
/>
)
}
CodeEditor.displayName = 'CodeEditor'
/**
* 使 fast-diff CodeMirror changes
*
* @param oldCode
* @param newCode
* @returns EditorView.dispatch changes
*/
function prepareCodeChanges(oldCode: string, newCode: string) {
const diffResult = diff(oldCode, newCode)
const changes: { from: number; to: number; insert: string }[] = []
let offset = 0
// operation: 1=插入, -1=删除, 0=相等
for (const [operation, text] of diffResult) {
if (operation === 1) {
changes.push({
from: offset,
to: offset,
insert: text
})
} else if (operation === -1) {
changes.push({
from: offset,
to: offset + text.length,
insert: ''
})
offset += text.length
} else {
offset += text.length
}
}
return changes
}
export default memo(CodeEditor)

View File

@ -0,0 +1,76 @@
import { CodeToolSpec } from './types'
export const TOOL_SPECS: Record<string, CodeToolSpec> = {
// Core tools
copy: {
id: 'copy',
type: 'core',
order: 10
},
download: {
id: 'download',
type: 'core',
order: 11
},
edit: {
id: 'edit',
type: 'core',
order: 12
},
'view-source': {
id: 'view-source',
type: 'core',
order: 12
},
save: {
id: 'save',
type: 'core',
order: 13
},
expand: {
id: 'expand',
type: 'core',
order: 20
},
// Quick tools
'split-view': {
id: 'split-view',
type: 'quick',
order: 10
},
run: {
id: 'run',
type: 'quick',
order: 11
},
wrap: {
id: 'wrap',
type: 'quick',
order: 20
},
'copy-image': {
id: 'copy-image',
type: 'quick',
order: 30
},
'download-svg': {
id: 'download-svg',
type: 'quick',
order: 31
},
'download-png': {
id: 'download-png',
type: 'quick',
order: 32
},
'zoom-in': {
id: 'zoom-in',
type: 'quick',
order: 40
},
'zoom-out': {
id: 'zoom-out',
type: 'quick',
order: 41
}
}

View File

@ -0,0 +1,71 @@
import React, { createContext, use, useCallback, useMemo, useState } from 'react'
import { CodeTool, CodeToolContext } from './types'
// 定义上下文默认值
const defaultContext: CodeToolContext = {
code: '',
language: ''
}
export interface CodeToolbarContextType {
tools: CodeTool[]
context: CodeToolContext
registerTool: (tool: CodeTool) => void
removeTool: (id: string) => void
updateContext: (newContext: Partial<CodeToolContext>) => void
}
const defaultCodeToolbarContext: CodeToolbarContextType = {
tools: [],
context: defaultContext,
registerTool: () => {},
removeTool: () => {},
updateContext: () => {}
}
const CodeToolbarContext = createContext<CodeToolbarContextType>(defaultCodeToolbarContext)
export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [tools, setTools] = useState<CodeTool[]>([])
const [context, setContext] = useState<CodeToolContext>(defaultContext)
// 注册工具如果已存在同ID工具则替换
const registerTool = useCallback((tool: CodeTool) => {
setTools((prev) => {
const filtered = prev.filter((t) => t.id !== tool.id)
return [...filtered, tool].sort((a, b) => b.order - a.order)
})
}, [])
// 移除工具
const removeTool = useCallback((id: string) => {
setTools((prev) => prev.filter((tool) => tool.id !== id))
}, [])
// 更新上下文
const updateContext = useCallback((newContext: Partial<CodeToolContext>) => {
setContext((prev) => ({ ...prev, ...newContext }))
}, [])
const value: CodeToolbarContextType = useMemo(
() => ({
tools,
context,
registerTool,
removeTool,
updateContext
}),
[tools, context, registerTool, removeTool, updateContext]
)
return <CodeToolbarContext value={value}>{children}</CodeToolbarContext>
}
export const useCodeToolbar = () => {
const context = use(CodeToolbarContext)
if (!context) {
throw new Error('useCodeToolbar must be used within a CodeToolbarProvider')
}
return context
}

View File

@ -0,0 +1,5 @@
export * from './constants'
export * from './context'
export * from './toolbar'
export * from './types'
export * from './usePreviewTools'

View File

@ -0,0 +1,119 @@
import { HStack } from '@renderer/components/Layout'
import { Tooltip } from 'antd'
import { EllipsisVertical } from 'lucide-react'
import React, { memo, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useCodeToolbar } from './context'
import { CodeTool } from './types'
interface CodeToolButtonProps {
tool: CodeTool
}
const CodeToolButton: React.FC<CodeToolButtonProps> = memo(({ tool }) => {
const { context } = useCodeToolbar()
return (
<Tooltip key={`${tool.id}-${tool.tooltip}`} title={tool.tooltip} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => tool.onClick(context)}>{tool.icon}</ToolWrapper>
</Tooltip>
)
})
export const CodeToolbar: React.FC = memo(() => {
const { tools, context } = useCodeToolbar()
const [showQuickTools, setShowQuickTools] = useState(false)
const { t } = useTranslation()
// 根据条件显示工具
const visibleTools = tools.filter((tool) => !tool.visible || tool.visible(context))
// 按类型分组
const coreTools = visibleTools.filter((tool) => tool.type === 'core')
const quickTools = visibleTools.filter((tool) => tool.type === 'quick')
// 点击了 more 按钮或者只有一个快捷工具时
const quickToolButtons = useMemo(() => {
if (quickTools.length === 1 || (quickTools.length > 1 && showQuickTools)) {
return quickTools.map((tool) => <CodeToolButton key={tool.id} tool={tool} />)
}
return null
}, [quickTools, showQuickTools])
if (visibleTools.length === 0) {
return null
}
return (
<StickyWrapper>
<ToolbarWrapper className="code-toolbar">
{/* 有多个快捷工具时通过 more 按钮展示 */}
{quickToolButtons}
{quickTools.length > 1 && (
<Tooltip title={t('code_block.more')} mouseEnterDelay={0.5}>
<ToolWrapper onClick={() => setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}>
<EllipsisVertical className="icon" />
</ToolWrapper>
</Tooltip>
)}
{/* 始终显示核心工具 */}
{coreTools.map((tool) => (
<CodeToolButton key={tool.id} tool={tool} />
))}
</ToolbarWrapper>
</StickyWrapper>
)
})
const StickyWrapper = styled.div`
position: sticky;
top: 28px;
z-index: 10;
`
const ToolbarWrapper = styled(HStack)`
position: absolute;
align-items: center;
bottom: 0.3rem;
right: 0.5rem;
height: 24px;
gap: 4px;
`
const ToolWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
color: var(--color-text-3);
&:hover {
background-color: var(--color-background-soft);
.icon {
color: var(--color-text-1);
}
}
&.active {
color: var(--color-primary);
.icon {
color: var(--color-primary);
}
}
/* For Lucide icons */
.icon {
width: 14px;
height: 14px;
color: var(--color-text-3);
}
`

View File

@ -0,0 +1,35 @@
/**
*
*/
export interface CodeToolSpec {
id: string
type: 'core' | 'quick'
order: number
}
/**
*
* @param id
* @param type
* @param icon
* @param tooltip
* @param condition
* @param onClick
* @param order
*/
export interface CodeTool extends CodeToolSpec {
icon: React.ReactNode
tooltip: string
visible?: (ctx?: CodeToolContext) => boolean
onClick: (ctx?: CodeToolContext) => void
}
/**
*
* @param code
* @param language
*/
export interface CodeToolContext {
code: string
language: string
}

View File

@ -0,0 +1,360 @@
import { download } from '@renderer/utils/download'
import { FileImage, ZoomIn, ZoomOut } from 'lucide-react'
import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DownloadPngIcon, DownloadSvgIcon } from '../Icons/DownloadIcons'
import { TOOL_SPECS } from './constants'
import { useCodeToolbar } from './context'
// 预编译正则表达式用于查询位置
const TRANSFORM_REGEX = /translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
/**
* 使Hook
*
*/
export const usePreviewToolHandlers = (
containerRef: RefObject<HTMLDivElement | null>,
options: {
prefix: string
imgSelector: string
enableWheelZoom?: boolean
customDownloader?: (format: 'svg' | 'png') => void
}
) => {
const transformRef = useRef({ scale: 1, x: 0, y: 0 }) // 管理变换状态
const [renderTrigger, setRenderTrigger] = useState(0) // 仅用于触发组件重渲染的状态
const { imgSelector, prefix, customDownloader, enableWheelZoom } = options
const { t } = useTranslation()
// 创建选择器函数
const getImgElement = useCallback(() => {
if (!containerRef.current) return null
return containerRef.current.querySelector(imgSelector) as SVGElement | null
}, [containerRef, imgSelector])
// 查询当前位置
const getCurrentPosition = useCallback(() => {
const imgElement = getImgElement()
if (!imgElement) return { x: transformRef.current.x, y: transformRef.current.y }
const transform = imgElement.style.transform
if (!transform || transform === 'none') return { x: transformRef.current.x, y: transformRef.current.y }
const match = transform.match(TRANSFORM_REGEX)
if (match && match.length >= 3) {
return {
x: parseFloat(match[1]),
y: parseFloat(match[2])
}
}
return { x: transformRef.current.x, y: transformRef.current.y }
}, [getImgElement])
// 平移缩放变换
const applyTransform = useCallback((element: SVGElement | null, x: number, y: number, scale: number) => {
if (!element) return
element.style.transformOrigin = 'top left'
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
}, [])
// 拖拽平移支持
useEffect(() => {
const container = containerRef.current
if (!container) return
let isDragging = false
const startPos = { x: 0, y: 0 }
const startOffset = { x: 0, y: 0 }
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return // 只响应左键
// 更新当前实际位置
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
isDragging = true
startPos.x = e.clientX
startPos.y = e.clientY
startOffset.x = position.x
startOffset.y = position.y
container.style.cursor = 'grabbing'
e.preventDefault()
}
const onMouseMove = (e: MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - startPos.x
const dy = e.clientY - startPos.y
const newX = startOffset.x + dx
const newY = startOffset.y + dy
const imgElement = getImgElement()
applyTransform(imgElement, newX, newY, transformRef.current.scale)
e.preventDefault()
}
const stopDrag = () => {
if (!isDragging) return
// 更新位置但不立即触发状态变更
const position = getCurrentPosition()
transformRef.current.x = position.x
transformRef.current.y = position.y
// 只触发一次渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
isDragging = false
container.style.cursor = 'default'
}
// 绑定到document以确保拖拽可以在鼠标离开容器后继续
container.addEventListener('mousedown', onMouseDown)
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', stopDrag)
return () => {
container.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', stopDrag)
}
}, [containerRef, getCurrentPosition, getImgElement, applyTransform])
// 缩放处理函数
const handleZoom = useCallback(
(delta: number) => {
const newScale = Math.max(0.1, Math.min(3, transformRef.current.scale + delta))
transformRef.current.scale = newScale
const imgElement = getImgElement()
applyTransform(imgElement, transformRef.current.x, transformRef.current.y, newScale)
// 触发重渲染以保持组件状态同步
setRenderTrigger((prev) => prev + 1)
},
[getImgElement, applyTransform]
)
// 滚轮缩放支持
useEffect(() => {
if (!enableWheelZoom || !containerRef.current) return
const container = containerRef.current
const handleWheel = (e: WheelEvent) => {
if ((e.ctrlKey || e.metaKey) && e.target) {
// 确认事件发生在容器内部
if (container.contains(e.target as Node)) {
const delta = e.deltaY < 0 ? 0.1 : -0.1
handleZoom(delta)
}
}
}
container.addEventListener('wheel', handleWheel, { passive: true })
return () => container.removeEventListener('wheel', handleWheel)
}, [containerRef, handleZoom, enableWheelZoom])
// 复制图像处理函数
const handleCopyImage = useCallback(async () => {
try {
const imgElement = getImgElement()
if (!imgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = async () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.message.success(t('message.copy.success'))
}
}
img.src = svgBase64
} catch (error) {
console.error('Copy failed:', error)
window.message.error(t('message.copy.failed'))
}
}, [getImgElement, t])
// 下载处理函数
const handleDownload = useCallback(
(format: 'svg' | 'png') => {
// 如果有自定义下载器,使用自定义实现
if (customDownloader) {
customDownloader(format)
return
}
try {
const imgElement = getImgElement()
if (!imgElement) return
const timestamp = Date.now()
if (format === 'svg') {
const svgData = new XMLSerializer().serializeToString(imgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `${prefix}-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = imgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || imgElement.clientWidth || imgElement.getBoundingClientRect().width
const height = viewBox[3] || imgElement.clientHeight || imgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(imgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `${prefix}-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
}
img.src = svgBase64
}
} catch (error) {
console.error('Download failed:', error)
}
},
[getImgElement, prefix, customDownloader]
)
return {
scale: transformRef.current.scale,
handleZoom,
handleCopyImage,
handleDownload,
renderTrigger // 导出渲染触发器,万一要用
}
}
export interface PreviewToolsOptions {
handleZoom?: (delta: number) => void
handleCopyImage?: () => Promise<void>
handleDownload?: (format: 'svg' | 'png') => void
}
/**
* Hook
*/
export const usePreviewTools = ({ handleZoom, handleCopyImage, handleDownload }: PreviewToolsOptions) => {
const { t } = useTranslation()
const { registerTool, removeTool } = useCodeToolbar()
const toolIds = useCallback(() => {
return {
zoomIn: 'preview-zoom-in',
zoomOut: 'preview-zoom-out',
copyImage: 'preview-copy-image',
downloadSvg: 'preview-download-svg',
downloadPng: 'preview-download-png'
}
}, [])
useEffect(() => {
// 根据提供的功能有选择性地注册工具
if (handleZoom) {
// 放大工具
registerTool({
...TOOL_SPECS['zoom-in'],
icon: <ZoomIn className="icon" />,
tooltip: t('code_block.preview.zoom_in'),
onClick: () => handleZoom(0.1)
})
// 缩小工具
registerTool({
...TOOL_SPECS['zoom-out'],
icon: <ZoomOut className="icon" />,
tooltip: t('code_block.preview.zoom_out'),
onClick: () => handleZoom(-0.1)
})
}
if (handleCopyImage) {
// 复制图片工具
registerTool({
...TOOL_SPECS['copy-image'],
icon: <FileImage className="icon" />,
tooltip: t('code_block.preview.copy.image'),
onClick: handleCopyImage
})
}
if (handleDownload) {
// 下载 SVG 工具
registerTool({
...TOOL_SPECS['download-svg'],
icon: <DownloadSvgIcon />,
tooltip: t('code_block.download.svg'),
onClick: () => handleDownload('svg')
})
// 下载 PNG 工具
registerTool({
...TOOL_SPECS['download-png'],
icon: <DownloadPngIcon />,
tooltip: t('code_block.download.png'),
onClick: () => handleDownload('png')
})
}
// 清理函数
return () => {
if (handleZoom) {
removeTool(TOOL_SPECS['zoom-in'].id)
removeTool(TOOL_SPECS['zoom-out'].id)
}
if (handleCopyImage) {
removeTool(TOOL_SPECS['copy-image'].id)
}
if (handleDownload) {
removeTool(TOOL_SPECS['download-svg'].id)
removeTool(TOOL_SPECS['download-png'].id)
}
}
}, [handleCopyImage, handleDownload, handleZoom, registerTool, removeTool, t, toolIds])
}

View File

@ -0,0 +1,68 @@
import { SVGProps } from 'react'
// 基础下载图标
export const DownloadIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.1em"
height="1.1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<path d="M12 15V3" />
<polygon points="12,15 9,11 15,11" fill="currentColor" stroke="none" />
</svg>
)
// 带有文件类型的下载图标基础组件
const DownloadTypeIconBase = ({ type, ...props }: SVGProps<SVGSVGElement> & { type: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.1em"
height="1.1em"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
{...props}>
<text
x="12"
y="7"
fontSize="8"
textAnchor="middle"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.3"
letterSpacing="1"
fontFamily="Arial Black, sans-serif"
style={{
paintOrder: 'stroke',
fontStretch: 'expanded',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}}>
{type}
</text>
<path d="M21 16v3a2 2 0 01-2 2H5a2 2 0 01-2-2v-3" />
<path d="M12 17V10" />
<polygon points="12,17 9.5,14 14.5,14" fill="currentColor" stroke="none" />
</svg>
)
// JPG 文件下载图标
export const DownloadJpgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="JPG" {...props} />
// PNG 文件下载图标
export const DownloadPngIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="PNG" {...props} />
// SVG 文件下载图标
export const DownloadSvgIcon = (props: SVGProps<SVGSVGElement>) => <DownloadTypeIconBase type="SVG" {...props} />

View File

@ -0,0 +1,149 @@
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { HighlightChunkResult, ShikiPreProperties, shikiStreamService } from '@renderer/services/ShikiStreamService'
import { ThemeMode } from '@renderer/types'
import * as cmThemes from '@uiw/codemirror-themes-all'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useEffect, useMemo, useState } from 'react'
interface CodeStyleContextType {
highlightCodeChunk: (trunk: string, language: string, callerId: string) => Promise<HighlightChunkResult>
cleanupTokenizers: (callerId: string) => void
getShikiPreProperties: (language: string) => Promise<ShikiPreProperties>
themeNames: string[]
activeShikiTheme: string
activeCmTheme: any
languageMap: Record<string, string>
}
const defaultCodeStyleContext: CodeStyleContextType = {
highlightCodeChunk: async () => ({ lines: [], recall: 0 }),
cleanupTokenizers: () => {},
getShikiPreProperties: async () => ({ class: '', style: '', tabindex: 0 }),
themeNames: ['auto'],
activeShikiTheme: 'auto',
activeCmTheme: null,
languageMap: {}
}
const CodeStyleContext = createContext<CodeStyleContextType>(defaultCodeStyleContext)
export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { codeEditor, codePreview, theme } = useSettings()
const [shikiThemes, setShikiThemes] = useState({})
useMermaid()
useEffect(() => {
if (!codeEditor.enabled) {
import('shiki').then(({ bundledThemes }) => {
setShikiThemes(bundledThemes)
})
}
}, [codeEditor.enabled])
// 获取支持的主题名称列表
const themeNames = useMemo(() => {
// CodeMirror 主题
// 更保险的做法可能是硬编码主题列表
if (codeEditor.enabled) {
return ['auto', 'light', 'dark']
.concat(Object.keys(cmThemes))
.filter((item) => typeof cmThemes[item as keyof typeof cmThemes] !== 'function')
.filter((item) => !/^(defaultSettings)/.test(item as string) && !/(Style)$/.test(item as string))
}
// Shiki 主题
return ['auto', ...Object.keys(shikiThemes)]
}, [codeEditor.enabled, shikiThemes])
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
const activeShikiTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
const codeStyle = codePreview[field]
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
}, [theme, codePreview, themeNames])
// 获取当前使用的 CodeMirror 主题对象(只用于编辑器)
const activeCmTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
let themeName = codeEditor[field]
if (!themeName || themeName === 'auto' || !themeNames.includes(themeName)) {
themeName = theme === ThemeMode.light ? 'materialLight' : 'dark'
}
return cmThemes[themeName as keyof typeof cmThemes] || themeName
}, [theme, codeEditor, themeNames])
// 一些语言的别名
const languageMap = useMemo(() => {
return {
bash: 'shell',
'objective-c++': 'objective-cpp',
svg: 'xml',
vab: 'vb'
} as Record<string, string>
}, [])
useEffect(() => {
// 在组件卸载时清理 Worker
return () => {
shikiStreamService.dispose()
}
}, [])
// 流式代码高亮,返回已高亮的 token lines
const highlightCodeChunk = useCallback(
async (trunk: string, language: string, callerId: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.highlightCodeChunk(trunk, normalizedLang, activeShikiTheme, callerId)
},
[activeShikiTheme, languageMap]
)
// 清理代码高亮资源
const cleanupTokenizers = useCallback((callerId: string) => {
shikiStreamService.cleanupTokenizers(callerId)
}, [])
// 获取 Shiki pre 标签属性
const getShikiPreProperties = useCallback(
async (language: string) => {
const normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
return shikiStreamService.getShikiPreProperties(normalizedLang, activeShikiTheme)
},
[activeShikiTheme, languageMap]
)
const contextValue = useMemo(
() => ({
highlightCodeChunk,
cleanupTokenizers,
getShikiPreProperties,
themeNames,
activeShikiTheme,
activeCmTheme,
languageMap
}),
[
highlightCodeChunk,
cleanupTokenizers,
getShikiPreProperties,
themeNames,
activeShikiTheme,
activeCmTheme,
languageMap
]
)
return <CodeStyleContext value={contextValue}>{children}</CodeStyleContext>
}
export const useCodeStyle = () => {
const context = use(CodeStyleContext)
if (!context) {
throw new Error('useCodeStyle must be used within a CodeStyleProvider')
}
return context
}

View File

@ -1,109 +0,0 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeCacheService } from '@renderer/services/CodeCacheService'
import { type CodeStyleVarious, ThemeMode } from '@renderer/types'
import type React from 'react'
import { createContext, type PropsWithChildren, use, useCallback, useMemo } from 'react'
import { bundledLanguages, bundledThemes, createHighlighter, type Highlighter } from 'shiki'
let highlighterPromise: Promise<Highlighter> | null = null
async function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
langs: ['javascript', 'typescript', 'python', 'java', 'markdown'],
themes: ['one-light', 'material-theme-darker']
})
}
return await highlighterPromise
}
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string, enableCache: boolean) => Promise<string>
}
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme()
const { codeStyle } = useSettings()
useMermaid()
const highlighterTheme = useMemo(() => {
if (!codeStyle || codeStyle === 'auto') {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
}, [theme, codeStyle])
const codeToHtml = useCallback(
async (_code: string, language: string, enableCache: boolean) => {
{
if (!_code) return ''
const key = CodeCacheService.generateCacheKey(_code, language, highlighterTheme)
const cached = enableCache ? CodeCacheService.getCachedResult(key) : null
if (cached) return cached
const languageMap: Record<string, string> = {
vab: 'vb'
}
const mappedLanguage = languageMap[language] || language
const code = _code?.trimEnd() ?? ''
const escapedCode = code?.replace(/[<>]/g, (char) => ({ '<': '&lt;', '>': '&gt;' })[char]!)
try {
const highlighter = await getHighlighter()
if (!highlighter.getLoadedThemes().includes(highlighterTheme)) {
const themeImportFn = bundledThemes[highlighterTheme]
if (themeImportFn) {
await highlighter.loadTheme(await themeImportFn())
}
}
if (!highlighter.getLoadedLanguages().includes(mappedLanguage)) {
const languageImportFn = bundledLanguages[mappedLanguage]
if (languageImportFn) {
await highlighter.loadLanguage(await languageImportFn())
}
}
// 生成高亮HTML
const html = highlighter.codeToHtml(code, {
lang: mappedLanguage,
theme: highlighterTheme
})
// 设置缓存
if (enableCache) {
CodeCacheService.setCachedResult(key, html, _code.length)
}
return html
} catch (error) {
console.debug(`Error highlighting code for language '${mappedLanguage}':`, error)
return `<pre style="padding: 10px"><code>${escapedCode}</code></pre>`
}
}
},
[highlighterTheme]
)
return <SyntaxHighlighterContext value={{ codeToHtml }}>{children}</SyntaxHighlighterContext>
}
export const useSyntaxHighlighter = () => {
const context = use(SyntaxHighlighterContext)
if (!context) {
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
}
return context
}
export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[]

View File

@ -19,7 +19,6 @@ declare global {
message: MessageInstance
modal: HookAPI
keyv: KeyvStorage
mermaid: any
store: any
navigate: NavigateFunction
}

View File

@ -1,54 +1,76 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { loadScript, runAsyncFunction } from '@renderer/utils'
import { useEffect, useRef } from 'react'
import { useEffect, useState } from 'react'
// 跟踪 mermaid 模块状态,单例模式
let mermaidModule: any = null
let mermaidLoading = false
let mermaidLoadPromise: Promise<any> | null = null
/**
* mermaid
*/
const loadMermaidModule = async () => {
if (mermaidModule) return mermaidModule
if (mermaidLoading && mermaidLoadPromise) return mermaidLoadPromise
mermaidLoading = true
mermaidLoadPromise = import('mermaid')
.then((module) => {
mermaidModule = module.default || module
mermaidLoading = false
return mermaidModule
})
.catch((error) => {
mermaidLoading = false
throw error
})
return mermaidLoadPromise
}
export const useMermaid = () => {
const { theme } = useTheme()
const mermaidLoaded = useRef(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 初始化 mermaid 并监听主题变化
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.6.0/dist/mermaid.min.js')
}
let mounted = true
if (!mermaidLoaded.current) {
await window.mermaid.initialize({
startOnLoad: false,
const initialize = async () => {
try {
setIsLoading(true)
const mermaid = await loadMermaidModule()
if (!mounted) return
mermaid.initialize({
startOnLoad: false, // 禁用自动启动
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
mermaidLoaded.current = true
EventEmitter.emit('mermaid-loaded')
}
})
}, [theme])
useEffect(() => {
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
const mermaidElement = (e.target as HTMLElement).closest('.mermaid')
if (!mermaidElement) return
const svg = mermaidElement.querySelector('svg')
if (!svg) return
const currentScale = parseFloat(svg.style.transform?.match(/scale\((.*?)\)/)?.[1] || '1')
const delta = e.deltaY < 0 ? 0.1 : -0.1
const newScale = Math.max(0.1, Math.min(3, currentScale + delta))
const container = svg.parentElement
if (container) {
container.style.overflow = 'auto'
container.style.position = 'relative'
svg.style.transformOrigin = 'top left'
svg.style.transform = `scale(${newScale})`
setError(null)
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to initialize Mermaid')
} finally {
if (mounted) {
setIsLoading(false)
}
}
}
document.addEventListener('wheel', handleWheel, { passive: true })
return () => document.removeEventListener('wheel', handleWheel)
}, [])
initialize()
return () => {
mounted = false
}
}, [theme])
return {
mermaid: mermaidModule,
isLoading,
error
}
}

View File

@ -8,6 +8,7 @@ import {
setOpenedOneOffMinapp
} from '@renderer/store/runtime'
import { MinAppType } from '@renderer/types'
import { useCallback } from 'react'
/**
* Usage:
@ -29,74 +30,86 @@ export const useMinappPopup = () => {
const { maxKeepAliveMinapps } = useSettings() // 使用设置中的值
/** Open a minapp (popup shows and minapp loaded) */
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
if (keepAlive) {
// 如果小程序已经打开,只切换显示
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
const openMinapp = useCallback(
(app: MinAppType, keepAlive: boolean = false) => {
if (keepAlive) {
// 如果小程序已经打开,只切换显示
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
// 如果缓存数量未达上限,添加到缓存列表
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
} else {
// 缓存数量达到上限,移除最后一个,添加新的
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
}
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
// 如果缓存数量未达上限,添加到缓存列表
if (openedKeepAliveMinapps.length < maxKeepAliveMinapps) {
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
} else {
// 缓存数量达到上限,移除最后一个,添加新的
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, maxKeepAliveMinapps - 1)]))
}
dispatch(setOpenedOneOffMinapp(null))
//if the minapp is not keep alive, open it as one-off minapp
dispatch(setOpenedOneOffMinapp(app))
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
//if the minapp is not keep alive, open it as one-off minapp
dispatch(setOpenedOneOffMinapp(app))
dispatch(setCurrentMinappId(app.id))
dispatch(setMinappShow(true))
return
}
},
[dispatch, maxKeepAliveMinapps, openedKeepAliveMinapps]
)
/** a wrapper of openMinapp(app, true) */
const openMinappKeepAlive = (app: MinAppType) => {
openMinapp(app, true)
}
const openMinappKeepAlive = useCallback(
(app: MinAppType) => {
openMinapp(app, true)
},
[openMinapp]
)
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
const openMinappById = (id: string, keepAlive: boolean = false) => {
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
if (app) {
openMinapp(app, keepAlive)
}
})
}
const openMinappById = useCallback(
(id: string, keepAlive: boolean = false) => {
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
if (app) {
openMinapp(app, keepAlive)
}
})
},
[openMinapp]
)
/** Close a minapp immediately (popup hides and minapp unloaded) */
const closeMinapp = (appid: string) => {
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
} else if (openedOneOffMinapp?.id === appid) {
dispatch(setOpenedOneOffMinapp(null))
}
const closeMinapp = useCallback(
(appid: string) => {
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
} else if (openedOneOffMinapp?.id === appid) {
dispatch(setOpenedOneOffMinapp(null))
}
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
return
}
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
return
},
[dispatch, openedKeepAliveMinapps, openedOneOffMinapp]
)
/** Close all minapps (popup hides and all minapps unloaded) */
const closeAllMinapps = () => {
const closeAllMinapps = useCallback(() => {
dispatch(setOpenedKeepAliveMinapps([]))
dispatch(setOpenedOneOffMinapp(null))
dispatch(setCurrentMinappId(''))
dispatch(setMinappShow(false))
}
}, [dispatch])
/** Hide the minapp popup (only one-off minapp unloaded) */
const hideMinappPopup = () => {
const hideMinappPopup = useCallback(() => {
if (!minappShow) return
if (openedOneOffMinapp) {
@ -104,7 +117,7 @@ export const useMinappPopup = () => {
dispatch(setCurrentMinappId(''))
}
dispatch(setMinappShow(false))
}
}, [dispatch, minappShow, openedOneOffMinapp])
return {
openMinapp,

View File

@ -198,6 +198,20 @@
},
"resend": "Resend",
"save": "Save",
"settings.code.title": "Code Block Settings",
"settings.code_editor": {
"title": "Code Editor",
"highlight_active_line": "Highlight active line",
"fold_gutter": "Fold gutter",
"autocompletion": "Autocompletion",
"keymap": "Keymap"
},
"settings.code_execution": {
"title": "Code Execution",
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
"timeout_minutes": "Timeout",
"timeout_minutes.tip": "The timeout time (minutes) of code execution"
},
"settings.code_collapsible": "Code block collapsible",
"settings.code_wrappable": "Code block wrappable",
"settings.code_cacheable": "Code block cache",
@ -303,9 +317,32 @@
},
"code_block": {
"collapse": "Collapse",
"disable_wrap": "Unwrap",
"enable_wrap": "Wrap",
"expand": "Expand"
"copy.failed": "Copy failed",
"copy.source": "Copy Source Code",
"copy.success": "Copied",
"copy": "Copy",
"download.failed.network": "Download failed, please check the network",
"download.png": "Download PNG",
"download.source": "Download Source Code",
"download.svg": "Download SVG",
"download": "Download",
"edit.save.failed.message_not_found": "Save failed, message not found",
"edit.save.failed": "Save failed",
"edit.save.success": "Saved",
"edit.save": "Save Changes",
"edit": "Edit",
"expand": "Expand",
"more": "More",
"preview.copy.image": "Copy as image",
"preview.source": "View Source Code",
"preview.zoom_in": "Zoom In",
"preview.zoom_out": "Zoom Out",
"preview": "Preview",
"run": "Run",
"split.restore": "Restore Split View",
"split": "Split View",
"wrap.off": "Unwrap",
"wrap.on": "Wrap"
},
"common": {
"add": "Add",
@ -526,21 +563,6 @@
"keep_alive_time.title": "Keep Alive Time",
"title": "LM Studio"
},
"mermaid": {
"download": {
"png": "Download PNG",
"svg": "Download SVG"
},
"resize": {
"zoom-in": "Zoom In",
"zoom-out": "Zoom Out"
},
"tabs": {
"preview": "Preview",
"source": "Source"
},
"title": "Mermaid Diagram"
},
"message": {
"agents": {
"imported": "Imported successfully",
@ -825,18 +847,6 @@
"magic_prompt_option_tip": "Intelligently enhances upscaling prompts"
}
},
"plantuml": {
"download": {
"failed": "Download failed, please check the network",
"png": "Download PNG",
"svg": "Download SVG"
},
"tabs": {
"preview": "Preview",
"source": "Source"
},
"title": "PlantUML Diagram"
},
"prompts": {
"explanation": "Explain this concept to me",
"summarize": "Summarize this text",

View File

@ -198,6 +198,20 @@
},
"resend": "再送信",
"save": "保存",
"settings.code.title": "コード設定",
"settings.code_editor": {
"title": "コードエディター",
"highlight_active_line": "アクティブ行をハイライト",
"fold_gutter": "折りたたみガター",
"autocompletion": "自動補完",
"keymap": "キーマップ"
},
"settings.code_execution": {
"title": "コード実行",
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
"timeout_minutes": "タイムアウト時間",
"timeout_minutes.tip": "コード実行のタイムアウト時間(分)"
},
"settings.code_collapsible": "コードブロック折り畳み",
"settings.code_wrappable": "コードブロック折り返し",
"settings.code_cacheable": "コードブロックキャッシュ",
@ -303,9 +317,32 @@
},
"code_block": {
"collapse": "折りたたむ",
"disable_wrap": "改行解除",
"enable_wrap": "改行",
"expand": "展開する"
"copy.failed": "コピーに失敗しました",
"copy.source": "コピー源コード",
"copy.success": "コピーしました",
"copy": "コピー",
"download.failed.network": "ダウンロードに失敗しました。ネットワークを確認してください",
"download.png": "PNGとしてダウンロード",
"download.source": "ダウンロード源コード",
"download.svg": "SVGとしてダウンロード",
"download": "ダウンロード",
"edit.save.failed.message_not_found": "保存に失敗しました。対応するメッセージが見つかりませんでした",
"edit.save.failed": "保存に失敗しました",
"edit.save.success": "保存しました",
"edit.save": "保存する",
"edit": "編集",
"expand": "展開する",
"more": "もっと",
"preview.copy.image": "画像としてコピー",
"preview.source": "ソースコードを表示",
"preview.zoom_in": "拡大",
"preview.zoom_out": "縮小",
"preview": "プレビュー",
"run": "コードを実行",
"split.restore": "分割視圖を解除",
"split": "分割視圖",
"wrap.off": "改行解除",
"wrap.on": "改行"
},
"common": {
"add": "追加",
@ -526,21 +563,6 @@
"keep_alive_time.title": "保持時間",
"title": "LM Studio"
},
"mermaid": {
"download": {
"png": "PNGをダウンロード",
"svg": "SVGをダウンロード"
},
"resize": {
"zoom-in": "拡大する",
"zoom-out": "ズームアウト"
},
"tabs": {
"preview": "プレビュー",
"source": "ソース"
},
"title": "Mermaid図"
},
"message": {
"agents": {
"imported": "インポートに成功しました",
@ -825,18 +847,6 @@
"magic_prompt_option_tip": "拡大効果を向上させるための提示詞を最適化します"
}
},
"plantuml": {
"download": {
"failed": "ダウンロードに失敗しました。ネットワークを確認してください",
"png": "PNG をダウンロード",
"svg": "SVG をダウンロード"
},
"tabs": {
"preview": "プレビュー",
"source": "ソースコード"
},
"title": "PlantUML 図表"
},
"prompts": {
"explanation": "この概念を説明してください",
"summarize": "このテキストを要約してください",

View File

@ -198,6 +198,20 @@
},
"resend": "Переотправить",
"save": "Сохранить",
"settings.code.title": "Настройки кода",
"settings.code_editor": {
"title": "Редактор кода",
"highlight_active_line": "Выделить активную строку",
"fold_gutter": "Свернуть",
"autocompletion": "Автодополнение",
"keymap": "Клавиатурные сокращения"
},
"settings.code_execution": {
"title": "Выполнение кода",
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
"timeout_minutes": "Время выполнения",
"timeout_minutes.tip": "Время выполнения кода (минуты)"
},
"settings.code_collapsible": "Блок кода свернут",
"settings.code_wrappable": "Блок кода можно переносить",
"settings.code_cacheable": "Кэш блока кода",
@ -303,9 +317,32 @@
},
"code_block": {
"collapse": "Свернуть",
"disable_wrap": "Отменить перенос строки",
"enable_wrap": "Перенос строки",
"expand": "Развернуть"
"copy.failed": "Не удалось скопировать",
"copy.source": "Копировать исходный код",
"copy.success": "Скопировано",
"copy": "Копировать",
"download.failed.network": "Не удалось скачать. Пожалуйста, проверьте ваше интернет-соединение",
"download.png": "Скачать PNG",
"download.source": "Скачать исходный код",
"download.svg": "Скачать SVG",
"download": "Скачать",
"edit.save.failed.message_not_found": "Не удалось сохранить изменения, не найдено сообщение",
"edit.save.failed": "Не удалось сохранить изменения",
"edit.save.success": "Изменения сохранены",
"edit.save": "Сохранить изменения",
"edit": "Редактировать",
"expand": "Развернуть",
"more": "Ещё",
"preview.copy.image": "Скопировать как изображение",
"preview.source": "Смотреть исходный код",
"preview.zoom_in": "Увеличить",
"preview.zoom_out": "Уменьшить",
"preview": "Предварительный просмотр",
"run": "Выполнить код",
"split.restore": "Вернуться к одному окну",
"split": "Разделить на два окна",
"wrap.off": "Отменить перенос строки",
"wrap.on": "Перенос строки"
},
"common": {
"add": "Добавить",
@ -526,21 +563,6 @@
"keep_alive_time.title": "Время жизни модели",
"title": "LM Studio"
},
"mermaid": {
"download": {
"png": "Скачать PNG",
"svg": "Скачать SVG"
},
"resize": {
"zoom-in": "Yвеличить",
"zoom-out": "Yменьшить масштаб"
},
"tabs": {
"preview": "Предпросмотр",
"source": "Исходный код"
},
"title": "Диаграмма Mermaid"
},
"message": {
"agents": {
"imported": "Импорт успешно выполнен",
@ -825,18 +847,6 @@
"magic_prompt_option_tip": "Улучшает увеличение изображений с помощью интеллектуального оптимизирования промптов"
}
},
"plantuml": {
"download": {
"failed": "下载失败,请检查网络",
"png": "下载 PNG",
"svg": "下载 SVG"
},
"tabs": {
"preview": "Предпросмотр",
"source": "Исходный код"
},
"title": "PlantUML 图表"
},
"prompts": {
"explanation": "Объясните мне этот концепт",
"summarize": "Суммируйте этот текст",

View File

@ -212,6 +212,20 @@
},
"resend": "重新发送",
"save": "保存",
"settings.code.title": "代码块设置",
"settings.code_editor": {
"title": "代码编辑器",
"highlight_active_line": "高亮当前行",
"fold_gutter": "折叠控件",
"autocompletion": "自动补全",
"keymap": "快捷键"
},
"settings.code_execution": {
"title": "代码执行",
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
"timeout_minutes": "超时时间",
"timeout_minutes.tip": "代码执行超时时间(分钟)"
},
"settings.code_collapsible": "代码块可折叠",
"settings.code_wrappable": "代码块可换行",
"settings.code_cacheable": "代码块缓存",
@ -303,9 +317,32 @@
},
"code_block": {
"collapse": "收起",
"disable_wrap": "取消换行",
"enable_wrap": "换行",
"expand": "展开"
"copy.failed": "复制失败",
"copy.source": "复制源代码",
"copy.success": "复制成功",
"copy": "复制",
"download.failed.network": "下载失败,请检查网络",
"download.png": "下载 PNG",
"download.source": "下载源代码",
"download.svg": "下载 SVG",
"download": "下载",
"edit.save.failed.message_not_found": "保存失败,没有找到对应的消息",
"edit.save.failed": "保存失败",
"edit.save.success": "已保存",
"edit.save": "保存修改",
"edit": "编辑",
"expand": "展开",
"more": "更多",
"preview.copy.image": "复制为图片",
"preview.source": "查看源代码",
"preview.zoom_in": "放大",
"preview.zoom_out": "缩小",
"preview": "预览",
"run": "运行代码",
"split.restore": "取消分割视图",
"split": "分割视图",
"wrap.off": "取消换行",
"wrap.on": "换行"
},
"common": {
"add": "添加",
@ -526,21 +563,6 @@
"keep_alive_time.title": "保持活跃时间",
"title": "LM Studio"
},
"mermaid": {
"download": {
"png": "下载 PNG",
"svg": "下载 SVG"
},
"resize": {
"zoom-in": "放大",
"zoom-out": "缩小"
},
"tabs": {
"preview": "预览",
"source": "源码"
},
"title": "Mermaid 图表"
},
"message": {
"agents": {
"imported": "导入成功",
@ -825,18 +847,6 @@
"magic_prompt_option_tip": "智能优化放大提示词"
}
},
"plantuml": {
"download": {
"failed": "下载失败,请检查网络",
"png": "下载 PNG",
"svg": "下载 SVG"
},
"tabs": {
"preview": "预览",
"source": "源码"
},
"title": "PlantUML 图表"
},
"prompts": {
"explanation": "帮我解释一下这个概念",
"summarize": "帮我总结一下这段话",

View File

@ -198,6 +198,20 @@
},
"resend": "重新傳送",
"save": "儲存",
"settings.code.title": "程式碼區塊",
"settings.code_editor": {
"title": "程式碼編輯器",
"highlight_active_line": "高亮當前行",
"fold_gutter": "折疊控件",
"autocompletion": "自動補全",
"keymap": "快捷鍵"
},
"settings.code_execution": {
"title": "程式碼執行",
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
"timeout_minutes": "超時時間",
"timeout_minutes.tip": "程式碼執行超時時間(分鐘)"
},
"settings.code_collapsible": "程式碼區塊可折疊",
"settings.code_wrappable": "程式碼區塊可自動換行",
"settings.code_cacheable": "程式碼區塊快取",
@ -303,9 +317,32 @@
},
"code_block": {
"collapse": "折疊",
"disable_wrap": "停用自動換行",
"enable_wrap": "自動換行",
"expand": "展開"
"copy.failed": "複製失敗",
"copy.source": "複製源碼",
"copy.success": "已複製",
"copy": "複製",
"download.failed.network": "下載失敗,請檢查網路連線",
"download.png": "下載 PNG",
"download.source": "下載源碼",
"download.svg": "下載 SVG",
"download": "下載",
"edit.save.failed.message_not_found": "保存失敗,沒有找到對應的消息",
"edit.save.failed": "保存失敗",
"edit.save.success": "已保存",
"edit.save": "保存修改",
"edit": "編輯",
"expand": "展開",
"more": "更多",
"preview.copy.image": "複製為圖片",
"preview.source": "查看源碼",
"preview.zoom_in": "放大",
"preview.zoom_out": "縮小",
"preview": "預覽",
"run": "運行代碼",
"split.restore": "取消分割視圖",
"split": "分割視圖",
"wrap.off": "停用自動換行",
"wrap.on": "自動換行"
},
"common": {
"add": "新增",
@ -526,21 +563,6 @@
"keep_alive_time.title": "保持活躍時間",
"title": "LM Studio"
},
"mermaid": {
"download": {
"png": "下載 PNG",
"svg": "下載 SVG"
},
"resize": {
"zoom-in": "放大",
"zoom-out": "縮小"
},
"tabs": {
"preview": "預覽",
"source": "原始碼"
},
"title": "Mermaid 圖表"
},
"message": {
"agents": {
"imported": "匯入成功",
@ -825,18 +847,6 @@
"magic_prompt_option_tip": "智能優化放大提示詞"
}
},
"plantuml": {
"download": {
"failed": "下載失敗,請檢查網路",
"png": "下載 PNG",
"svg": "下載 SVG"
},
"tabs": {
"preview": "預覽",
"source": "原始碼"
},
"title": "PlantUML 圖表"
},
"prompts": {
"explanation": "幫我解釋一下這個概念",
"summarize": "幫我總結一下這段話",

View File

@ -14,12 +14,12 @@ function escapeRegExp(str: string) {
}
// 支持泛型 T默认 T = { type: string; textDelta: string }
export function extractReasoningMiddleware<T extends { type: string } = { type: string; textDelta: string }>({
openingTag,
closingTag,
separator = '\n',
enableReasoning
}: ExtractReasoningMiddlewareOptions) {
export function extractReasoningMiddleware<
T extends { type: string } & (
| { type: 'text-delta' | 'reasoning'; textDelta: string }
| { type: string } // 其他类型
) = { type: string; textDelta: string }
>({ openingTag, closingTag, separator = '\n', enableReasoning }: ExtractReasoningMiddlewareOptions) {
const openingTagEscaped = escapeRegExp(openingTag)
const closingTagEscaped = escapeRegExp(closingTag)
@ -71,8 +71,8 @@ export function extractReasoningMiddleware<T extends { type: string } = { type:
controller.enqueue(chunk)
return
}
// @ts-expect-error: textDelta 只在 text-delta/reasoning chunk 上
buffer += chunk.textDelta
// textDelta 只在 text-delta/reasoning chunk 上
buffer += (chunk as { textDelta: string }).textDelta
function publish(text: string) {
if (text.length > 0) {
const prefix = afterSwitch && (isReasoning ? !isFirstReasoning : !isFirstText) ? separator : ''
@ -80,7 +80,7 @@ export function extractReasoningMiddleware<T extends { type: string } = { type:
...chunk,
type: isReasoning ? 'reasoning' : 'text-delta',
textDelta: prefix + text
})
} as T)
afterSwitch = false
if (isReasoning) {
isFirstReasoning = false

View File

@ -1,179 +1,34 @@
import { CheckOutlined, DownloadOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import UnWrapIcon from '@renderer/components/Icons/UnWrapIcon'
import WrapIcon from '@renderer/components/Icons/WrapIcon'
import { HStack } from '@renderer/components/Layout'
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { Tooltip } from 'antd'
import dayjs from 'dayjs'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodeBlockView from '@renderer/components/CodeBlockView'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import React, { memo, useCallback } from 'react'
import Artifacts from './Artifacts'
import Mermaid from './Mermaid'
import { isValidPlantUML, PlantUML } from './PlantUML'
import SvgPreview from './SvgPreview'
interface CodeBlockProps {
interface Props {
children: string
className?: string
id?: string
onSave?: (id: string, newContent: string) => void
[key: string]: any
}
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const match = /language-(\w+)/.exec(className || '') || children?.includes('\n')
const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings()
const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
const language = match?.[1] ?? 'text'
// const [html, setHtml] = useState<string>('')
const { codeToHtml } = useSyntaxHighlighter()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable)
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
const codeContentRef = useRef<HTMLDivElement>(null)
const childrenLengthRef = useRef(0)
const isStreamingRef = useRef(false)
const showFooterCopyButton = children && children.length > 500 && !codeCollapsible
const showDownloadButton = ['csv', 'json', 'txt', 'md'].includes(language)
const shouldShowExpandButtonRef = useRef(false)
const shouldHighlight = useCallback((lang: string) => {
const NON_HIGHLIGHT_LANGS = ['mermaid', 'plantuml', 'svg']
return !NON_HIGHLIGHT_LANGS.includes(lang)
}, [])
const highlightCode = useCallback(async () => {
if (!codeContentRef.current) return
const codeElement = codeContentRef.current
// 只在非流式输出状态才尝试启用cache
const highlightedHtml = await codeToHtml(children, language, !isStreamingRef.current)
codeElement.innerHTML = highlightedHtml
codeElement.style.opacity = '1'
const isShowExpandButton = codeElement.scrollHeight > 350
if (shouldShowExpandButtonRef.current === isShowExpandButton) return
shouldShowExpandButtonRef.current = isShowExpandButton
setShouldShowExpandButton(shouldShowExpandButtonRef.current)
}, [language, codeToHtml, children])
useEffect(() => {
// 跳过非文本代码块
if (!codeContentRef.current || !shouldHighlight(language)) return
let isMounted = true
const codeElement = codeContentRef.current
if (childrenLengthRef.current > 0 && childrenLengthRef.current !== children?.length) {
isStreamingRef.current = true
} else {
isStreamingRef.current = false
codeElement.style.opacity = '0.1'
}
if (childrenLengthRef.current === 0) {
// 挂载时显示原始代码
codeElement.textContent = children
}
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting && isMounted) {
setTimeout(highlightCode, 0)
observer.disconnect()
const handleSave = useCallback(
(newContent: string) => {
if (id !== undefined) {
onSave?.(id, newContent)
}
})
observer.observe(codeElement)
return () => {
childrenLengthRef.current = children?.length
isMounted = false
observer.disconnect()
}
}, [children, highlightCode, language, shouldHighlight])
useEffect(() => {
setIsExpanded(!codeCollapsible)
setShouldShowExpandButton(codeCollapsible && (codeContentRef.current?.scrollHeight ?? 0) > 350)
}, [codeCollapsible])
useEffect(() => {
setIsUnwrapped(!codeWrappable)
}, [codeWrappable])
if (language === 'mermaid') {
return <Mermaid chart={children} />
}
if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUML diagram={children} />
}
if (language === 'svg') {
return (
<CodeBlockWrapper className="code-block">
<CodeHeader>
<CodeLanguage>{'<SVG>'}</CodeLanguage>
<CopyButton text={children} />
</CodeHeader>
<SvgPreview>{children}</SvgPreview>
</CodeBlockWrapper>
)
}
},
[id, onSave]
)
return match ? (
<CodeBlockWrapper className="code-block">
<CodeHeader>
<CodeLanguage>{'<' + language.toUpperCase() + '>'}</CodeLanguage>
</CodeHeader>
<StickyWrapper>
<HStack
position="absolute"
gap={12}
alignItems="center"
style={{ bottom: '0.2rem', right: '1rem', height: '27px' }}>
{showDownloadButton && <DownloadButton language={language} data={children} />}
{codeWrappable && <UnwrapButton unwrapped={isUnwrapped} onClick={() => setIsUnwrapped(!isUnwrapped)} />}
{codeCollapsible && shouldShowExpandButton && (
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
)}
<CopyButton text={children} />
</HStack>
</StickyWrapper>
<CodeContent
ref={codeContentRef}
$isShowLineNumbers={codeShowLineNumbers}
$isUnwrapped={isUnwrapped}
$isCodeWrappable={codeWrappable}
// dangerouslySetInnerHTML={{ __html: html }}
style={{
padding: '1px',
marginTop: 0,
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
position: 'relative'
}}
/>
{codeCollapsible && (
<ExpandButton
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
showButton={shouldShowExpandButton}
/>
)}
{showFooterCopyButton && (
<CodeFooter>
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
</CodeFooter>
)}
{language === 'html' && children?.includes('</html>') && <Artifacts html={children} />}
</CodeBlockWrapper>
<CodeToolbarProvider>
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
</CodeToolbarProvider>
) : (
<code className={className} style={{ textWrap: 'wrap' }}>
{children}
@ -181,268 +36,4 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
)
}
const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => {
const { t } = useTranslation()
const [tooltipVisible, setTooltipVisible] = useState(false)
const handleClick = () => {
setTooltipVisible(false)
onClick()
}
return (
<Tooltip
title={expanded ? t('code_block.collapse') : t('code_block.expand')}
open={tooltipVisible}
onOpenChange={setTooltipVisible}>
<CollapseIconWrapper onClick={handleClick}>
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
</CollapseIconWrapper>
</Tooltip>
)
}
const ExpandButton: React.FC<{
isExpanded: boolean
onClick: () => void
showButton: boolean
}> = ({ isExpanded, onClick, showButton }) => {
const { t } = useTranslation()
if (!showButton) return null
return (
<ExpandButtonWrapper onClick={onClick}>
<div className="button-text">{isExpanded ? t('code_block.collapse') : t('code_block.expand')}</div>
</ExpandButtonWrapper>
)
}
const UnwrapButton: React.FC<{ unwrapped: boolean; onClick: () => void }> = ({ unwrapped, onClick }) => {
const { t } = useTranslation()
const unwrapLabel = unwrapped ? t('code_block.enable_wrap') : t('code_block.disable_wrap')
return (
<Tooltip title={unwrapLabel}>
<UnwrapButtonWrapper onClick={onClick} title={unwrapLabel}>
{unwrapped ? (
<UnWrapIcon style={{ width: '100%', height: '100%' }} />
) : (
<WrapIcon style={{ width: '100%', height: '100%' }} />
)}
</UnwrapButtonWrapper>
</Tooltip>
)
}
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const copy = t('common.copy')
const onCopy = () => {
if (!text) return
navigator.clipboard.writeText(text)
window.message.success({ content: t('message.copied'), key: 'copy-code' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Tooltip title={copy}>
<CopyButtonWrapper onClick={onCopy} style={style}>
{copied ? <CheckOutlined style={{ color: 'var(--color-primary)' }} /> : <CopyIcon className="copy" />}
</CopyButtonWrapper>
</Tooltip>
)
}
const DownloadButton = ({ language, data }: { language: string; data: string }) => {
const onDownload = () => {
const fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}`
window.api.file.save(fileName, data)
}
return (
<DownloadWrapper onClick={onDownload}>
<DownloadOutlined />
</DownloadWrapper>
)
}
const CodeBlockWrapper = styled.div`
position: relative;
`
const CodeContent = styled.div<{ $isShowLineNumbers: boolean; $isUnwrapped: boolean; $isCodeWrappable: boolean }>`
transition: opacity 0.3s ease;
.shiki {
padding: 1em;
code {
display: flex;
flex-direction: column;
width: 100%;
.line {
display: block;
min-height: 1.3rem;
padding-left: ${(props) => (props.$isShowLineNumbers ? '2rem' : '0')};
}
}
}
${(props) =>
props.$isShowLineNumbers &&
`
code {
counter-reset: step;
counter-increment: step 0;
position: relative;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
position: absolute;
left: 0;
text-align: right;
opacity: 0.35;
}
`}
${(props) =>
props.$isCodeWrappable &&
!props.$isUnwrapped &&
`
code .line * {
word-wrap: break-word;
white-space: pre-wrap;
}
`}
`
const CodeHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
color: var(--color-text);
font-size: 14px;
font-weight: bold;
height: 34px;
padding: 0 10px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
`
const CodeLanguage = styled.div`
font-weight: bold;
`
const CodeFooter = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
position: relative;
.copy {
cursor: pointer;
color: var(--color-text-3);
transition: color 0.3s;
}
.copy:hover {
color: var(--color-text-1);
}
`
const CopyButtonWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-3);
transition: color 0.3s;
font-size: 16px;
&:hover {
color: var(--color-text-1);
}
`
const ExpandButtonWrapper = styled.div`
position: relative;
cursor: pointer;
height: 25px;
margin-top: -25px;
.button-text {
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
padding: 8px;
color: var(--color-text-3);
z-index: 1;
transition: color 0.2s;
font-size: 12px;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
sans-serif;
}
&:hover .button-text {
color: var(--color-text-1);
}
`
const CollapseIconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s ease;
&:hover {
color: var(--color-text-1);
}
`
const UnwrapButtonWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text-1);
}
`
const DownloadWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-3);
transition: color 0.3s;
font-size: 16px;
&:hover {
color: var(--color-text-1);
}
`
const StickyWrapper = styled.div`
position: sticky;
top: 28px;
z-index: 10;
`
export default memo(CodeBlock)

View File

@ -4,12 +4,13 @@ import 'katex/dist/contrib/mhchem'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren } from '@renderer/utils/markdown'
import { findCitationInChildren, getCodeBlockId } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, useMemo } from 'react'
import { type FC, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
@ -65,14 +66,27 @@ const Markdown: FC<Props> = ({ block }) => {
return plugins
}, [mathEngine, messageContent])
const onSaveCodeBlock = useCallback(
(id: string, newContent: string) => {
EventEmitter.emit(EVENT_NAMES.EDIT_CODE_BLOCK, {
msgBlockId: block.id,
codeBlockId: id,
newContent
})
},
[block.id]
)
const components = useMemo(() => {
return {
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
code: CodeBlock,
code: (props: any) => (
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
),
img: ImagePreview,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />
} as Partial<Components>
}, [])
}, [onSaveCodeBlock])
// if (role === 'user' && !renderInputMessageAsMarkdown) {
// return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
@ -99,4 +113,4 @@ const Markdown: FC<Props> = ({ block }) => {
)
}
export default Markdown
export default memo(Markdown)

View File

@ -1,68 +0,0 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { EventEmitter } from '@renderer/services/EventService'
import { ThemeMode } from '@renderer/types'
import { debounce, isEmpty } from 'lodash'
import React, { useCallback, useEffect, useRef } from 'react'
import MermaidPopup from './MermaidPopup'
interface Props {
chart: string
}
const Mermaid: React.FC<Props> = ({ chart }) => {
const { theme } = useTheme()
const mermaidRef = useRef<HTMLDivElement>(null)
const renderMermaidBase = useCallback(async () => {
if (!mermaidRef.current || !window.mermaid || isEmpty(chart)) return
try {
mermaidRef.current.innerHTML = chart
mermaidRef.current.removeAttribute('data-processed')
await window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
await window.mermaid.run({ nodes: [mermaidRef.current] })
} catch (error) {
console.error('Failed to render mermaid chart:', error)
}
}, [chart, theme])
// eslint-disable-next-line react-hooks/exhaustive-deps
const renderMermaid = useCallback(debounce(renderMermaidBase, 1000), [renderMermaidBase])
useEffect(() => {
renderMermaid()
// Make sure to cancel any pending debounced calls when unmounting
return () => renderMermaid.cancel()
}, [renderMermaid])
useEffect(() => {
setTimeout(renderMermaidBase, 0)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const removeListener = EventEmitter.on('mermaid-loaded', renderMermaid)
return () => {
removeListener()
renderMermaid.cancel()
}
}, [renderMermaid])
const onPreview = () => {
MermaidPopup.show({ chart })
}
return (
<div ref={mermaidRef} className="mermaid" onClick={onPreview} style={{ cursor: 'pointer' }}>
{chart}
</div>
)
}
export default Mermaid

View File

@ -1,276 +0,0 @@
import { TopView } from '@renderer/components/TopView'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { download } from '@renderer/utils/download'
import { Button, Modal, Space, Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ShowParams {
chart: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { theme } = useTheme()
const mermaidId = `mermaid-popup-${Date.now()}`
const [activeTab, setActiveTab] = useState('preview')
const [scale, setScale] = useState(1)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const handleZoom = (delta: number) => {
const newScale = Math.max(0.1, Math.min(3, scale + delta))
setScale(newScale)
const element = document.getElementById(mermaidId)
if (!element) return
const svg = element.querySelector('svg')
if (!svg) return
const container = svg.parentElement
if (container) {
container.style.overflow = 'auto'
container.style.position = 'relative'
svg.style.transformOrigin = 'top left'
svg.style.transform = `scale(${newScale})`
}
}
const handleCopyImage = async () => {
try {
const element = document.getElementById(mermaidId)
if (!element) return
const svgElement = element.querySelector('svg')
if (!svgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = async () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.message.success(t('message.copy.success'))
}
}
img.src = svgBase64
} catch (error) {
console.error('Copy failed:', error)
window.message.error(t('message.copy.failed'))
}
}
const handleDownload = async (format: 'svg' | 'png') => {
try {
const element = document.getElementById(mermaidId)
if (!element) return
const timestamp = Date.now()
const backgroundColor = theme === ThemeMode.dark ? '#1F1F1F' : '#fff'
const svgElement = element.querySelector('svg')
if (!svgElement) return
if (format === 'svg') {
// Add background color to SVG
svgElement.style.backgroundColor = backgroundColor
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `mermaid-diagram-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
// Add background color to SVG before converting to image
svgElement.style.backgroundColor = backgroundColor
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
// Fill background
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `mermaid-diagram-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
}
img.src = svgBase64
}
svgElement.style.backgroundColor = 'transparent'
} catch (error) {
console.error('Download failed:', error)
}
}
const handleCopy = () => {
navigator.clipboard.writeText(chart)
window.message.success(t('message.copy.success'))
}
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) return
try {
const element = document.getElementById(mermaidId)
if (!element) return
// Clear previous content
element.innerHTML = chart
element.removeAttribute('data-processed')
await window.mermaid.initialize({
startOnLoad: false,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
await window.mermaid.run({
nodes: [element]
})
} catch (error) {
console.error('Failed to render mermaid chart in popup:', error)
}
})
}, [activeTab, theme, mermaidId, chart])
return (
<Modal
title={t('mermaid.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={1000}
transitionName="animation-move-down"
centered
footer={[
<Space key="download-buttons">
{activeTab === 'source' && <Button onClick={() => handleCopy()}>{t('common.copy')}</Button>}
{activeTab === 'preview' && (
<>
<Button onClick={() => handleZoom(0.1)}>{t('mermaid.resize.zoom-in')}</Button>
<Button onClick={() => handleZoom(-0.1)}>{t('mermaid.resize.zoom-out')}</Button>
<Button onClick={() => handleCopyImage()}>{t('common.copy')}</Button>
<Button onClick={() => handleDownload('svg')}>{t('mermaid.download.svg')}</Button>
<Button onClick={() => handleDownload('png')}>{t('mermaid.download.png')}</Button>
</>
)}
</Space>
]}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
items={[
{
key: 'preview',
label: t('mermaid.tabs.preview'),
children: (
<StyledMermaid id={mermaidId} className="mermaid">
{chart}
</StyledMermaid>
)
},
{
key: 'source',
label: t('mermaid.tabs.source'),
children: (
<pre
style={{
maxHeight: 'calc(80vh - 200px)',
overflowY: 'auto',
padding: '16px'
}}>
{chart}
</pre>
)
}
]}
/>
</Modal>
)
}
export default class MermaidPopup {
static topviewId = 0
static hide() {
TopView.hide('MermaidPopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'MermaidPopup'
)
})
}
}
const StyledMermaid = styled.div`
max-height: calc(80vh - 200px);
text-align: center;
overflow-y: auto;
`

View File

@ -1,338 +0,0 @@
import { CopyOutlined, LoadingOutlined } from '@ant-design/icons'
import { TopView } from '@renderer/components/TopView'
import { useTheme } from '@renderer/context/ThemeProvider'
import { Button, Modal, Space, Spin, Tabs } from 'antd'
import pako from 'pako'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface PlantUMLPopupProps {
resolve: (data: any) => void
diagram: string
}
export function isValidPlantUML(diagram: string | null): boolean {
if (!diagram || !diagram.trim().startsWith('@start')) {
return false
}
const diagramType = diagram.match(/@start(\w+)/)?.[1]
return diagramType !== undefined && diagram.search(`@end${diagramType}`) !== -1
}
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data[i], data[i + 1], 0)
} else if (i + 1 === data.length) {
r += append3bytes(data[i], 0, 0)
} else {
r += append3bytes(data[i], data[i + 1], data[i + 2])
}
}
return r
}
function encode6bit(b: number) {
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return '-'
}
if (b === 1) {
return '_'
}
return '?'
}
function append3bytes(b1: number, b2: number, b3: number) {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
const c4 = b3 & 0x3f
let r = ''
r += encode6bit(c1 & 0x3f)
r += encode6bit(c2 & 0x3f)
r += encode6bit(c3 & 0x3f)
r += encode6bit(c4 & 0x3f)
return r
}
/**
* https://plantuml.com/zh/code-javascript-synchronous
* To use PlantUML image generation, a text diagram description have to be :
1. Encoded in UTF-8
2. Compressed using Deflate algorithm
3. Reencoded in ASCII using a transformation _close_ to base64
*/
function encodeDiagram(diagram: string): string {
const utf8text = new TextEncoder().encode(diagram)
const compressed = pako.deflateRaw(utf8text)
return encode64(compressed)
}
type PlantUMLServerImageProps = {
format: 'png' | 'svg'
diagram: string
onClick?: React.MouseEventHandler<HTMLDivElement>
className?: string
}
function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
const encodedDiagram = encodeDiagram(diagram)
if (isDark) {
return `${PlantUMLServer}/d${format}/${encodedDiagram}`
}
return `${PlantUMLServer}/${format}/${encodedDiagram}`
}
const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagram, onClick, className }) => {
const [loading, setLoading] = useState(true)
const { theme } = useTheme()
const isDark = theme === 'dark'
const url = getPlantUMLImageUrl(format, diagram, isDark)
return (
<StyledPlantUML onClick={onClick} className={className}>
<Spin
spinning={loading}
indicator={
<LoadingOutlined
spin
style={{
fontSize: 32
}}
/>
}>
<img
src={url}
onLoad={() => {
setLoading(false)
}}
onError={(e) => {
setLoading(false)
const target = e.target as HTMLImageElement
target.style.opacity = '0.5'
target.style.filter = 'blur(2px)'
}}
/>
</Spin>
</StyledPlantUML>
)
}
const PlantUMLPopupCantaier: React.FC<PlantUMLPopupProps> = ({ resolve, diagram }) => {
const [open, setOpen] = useState(true)
const [downloading, setDownloading] = useState({
png: false,
svg: false
})
const [scale, setScale] = useState(1)
const [activeTab, setActiveTab] = useState('preview')
const { t } = useTranslation()
const encodedDiagram = encodeDiagram(diagram)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const handleZoom = (delta: number) => {
const newScale = Math.max(0.1, Math.min(3, scale + delta))
setScale(newScale)
const container = document.querySelector('.plantuml-image-container')
if (container) {
const img = container.querySelector('img')
if (img) {
img.style.transformOrigin = 'top left'
img.style.transform = `scale(${newScale})`
}
}
}
const handleCopyImage = async () => {
try {
const imageElement = document.querySelector('.plantuml-image-container img')
if (!imageElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = imageElement as HTMLImageElement
if (!img.complete) {
await new Promise((resolve) => {
img.onload = resolve
})
}
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
if (ctx) {
ctx.drawImage(img, 0, 0)
const blob = await new Promise<Blob>((resolve) => canvas.toBlob((b) => resolve(b!), 'image/png'))
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
window.message.success(t('message.copy.success'))
}
} catch (error) {
console.error('Copy failed:', error)
window.message.error(t('message.copy.failed'))
}
}
const handleDownload = (format: 'svg' | 'png') => {
const timestamp = Date.now()
const url = `${PlantUMLServer}/${format}/${encodedDiagram}`
setDownloading((prev) => ({ ...prev, [format]: true }))
const filename = `plantuml-diagram-${timestamp}.${format}`
downloadUrl(url, filename)
.catch(() => {
window.message.error(t('plantuml.download.failed'))
})
.finally(() => {
setDownloading((prev) => ({ ...prev, [format]: false }))
})
}
function handleCopy() {
navigator.clipboard.writeText(diagram)
window.message.success(t('message.copy.success'))
}
return (
<Modal
title={t('plantuml.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={1000}
transitionName="animation-move-down"
centered
footer={[
<Space key="download-buttons">
{activeTab === 'source' && (
<Button onClick={handleCopy} icon={<CopyOutlined />}>
{t('common.copy')}
</Button>
)}
{activeTab === 'preview' && (
<>
<Button onClick={() => handleZoom(0.1)}>{t('mermaid.resize.zoom-in')}</Button>
<Button onClick={() => handleZoom(-0.1)}>{t('mermaid.resize.zoom-out')}</Button>
<Button onClick={handleCopyImage}>{t('common.copy')}</Button>
<Button onClick={() => handleDownload('svg')} loading={downloading.svg}>
{t('plantuml.download.svg')}
</Button>
<Button onClick={() => handleDownload('png')} loading={downloading.png}>
{t('plantuml.download.png')}
</Button>
</>
)}
</Space>
]}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key)}
items={[
{
key: 'preview',
label: t('plantuml.tabs.preview'),
children: <PlantUMLServerImage format="svg" diagram={diagram} className="plantuml-image-container" />
},
{
key: 'source',
label: t('plantuml.tabs.source'),
children: (
<pre
style={{
maxHeight: 'calc(80vh - 200px)',
overflowY: 'auto',
padding: '16px'
}}>
{diagram}
</pre>
)
}
]}
/>
</Modal>
)
}
class PlantUMLPopupTopView {
static topviewId = 0
static hide() {
TopView.hide('PlantUMLPopup')
}
static show(diagram: string) {
return new Promise<any>((resolve) => {
TopView.show(
<PlantUMLPopupCantaier
resolve={(v) => {
resolve(v)
this.hide()
}}
diagram={diagram}
/>,
'PlantUMLPopup'
)
})
}
}
interface PlantUMLProps {
diagram: string
}
export const PlantUML: React.FC<PlantUMLProps> = ({ diagram }) => {
// const { t } = useTranslation()
const onPreview = () => {
PlantUMLPopupTopView.show(diagram)
}
return <PlantUMLServerImage onClick={onPreview} format="svg" diagram={diagram} />
}
const StyledPlantUML = styled.div`
max-height: calc(80vh - 100px);
text-align: center;
overflow-y: auto;
img {
max-width: 100%;
height: auto;
min-height: 100px;
background: var(--color-code-background);
cursor: pointer;
transition: transform 0.2s ease;
}
`
async function downloadUrl(url: string, filename: string) {
const response = await fetch(url)
if (!response.ok) {
window.message.warning({ content: response.statusText, duration: 1.5 })
return
}
const blob = await response.blob()
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
}

View File

@ -1,16 +0,0 @@
const SvgPreview = ({ children }: { children: string }) => {
return (
<div
dangerouslySetInnerHTML={{ __html: children }}
style={{
padding: '1em',
backgroundColor: 'white',
border: '0.5px solid var(--color-code-background)',
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
/>
)
}
export default SvgPreview

View File

@ -10,18 +10,21 @@ 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 { useAppDispatch } from '@renderer/store'
import store, { useAppDispatch } from '@renderer/store'
import { messageBlocksSelectors, updateOneBlock } from '@renderer/store/messageBlock'
import { newMessagesActions } from '@renderer/store/newMessage'
import { saveMessageAndBlocksToDB } from '@renderer/store/thunk/messageThunk'
import type { Assistant, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { type Message, MessageBlockType } from '@renderer/types/newMessage'
import {
captureScrollableDivAsBlob,
captureScrollableDivAsDataURL,
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'
@ -183,7 +186,32 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
console.error(`[NEW_BRANCH] Failed to create topic branch for topic ${newTopic.id}`)
window.message.error(t('message.branch.error')) // Example error message
}
})
}),
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)
dispatch(updateOneBlock({ id: msgBlockId, changes: { content: updatedRaw } }))
window.message.success({ content: t('code_block.edit.save.success'), key: 'save-code' })
} catch (error) {
console.error(`Failed to save code block ${codeBlockId} content to message block ${msgBlockId}:`, error)
window.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' })
}
} else {
console.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.message.error({ content: t('code_block.edit.save.failed'), key: 'save-code-failed' })
}
}
)
]
return () => unsubscribes.forEach((unsub) => unsub())

View File

@ -8,7 +8,7 @@ import {
isMac,
isWindows
} from '@renderer/config/constant'
import { codeThemes } from '@renderer/context/SyntaxHighlighterProvider'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { SettingDivider, SettingRow, SettingRowTitle, SettingSubtitle } from '@renderer/pages/settings'
@ -17,13 +17,11 @@ import { useAppDispatch } from '@renderer/store'
import {
SendMessageShortcut,
setAutoTranslateWithSpace,
setCodeCacheable,
setCodeCacheMaxSize,
setCodeCacheThreshold,
setCodeCacheTTL,
setCodeCollapsible,
setCodeEditor,
setCodeExecution,
setCodePreview,
setCodeShowLineNumbers,
setCodeStyle,
setCodeWrappable,
setEnableBackspaceDeleteModel,
setEnableQuickPanelTriggers,
@ -53,7 +51,7 @@ import {
import { modalConfirm } from '@renderer/utils'
import { Button, Col, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { CircleHelp, RotateCcw, Settings2 } from 'lucide-react'
import { FC, useEffect, useState } from 'react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -63,7 +61,8 @@ interface Props {
const SettingsTab: FC<Props> = (props) => {
const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id)
const { messageStyle, codeStyle, fontSize, language } = useSettings()
const { messageStyle, fontSize, language, theme } = useSettings()
const { themeNames } = useCodeStyle()
const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
@ -89,10 +88,9 @@ const SettingsTab: FC<Props> = (props) => {
codeShowLineNumbers,
codeCollapsible,
codeWrappable,
codeCacheable,
codeCacheMaxSize,
codeCacheTTL,
codeCacheThreshold,
codeEditor,
codePreview,
codeExecution,
mathEngine,
autoTranslateWithSpace,
pasteLongTextThreshold,
@ -144,6 +142,32 @@ const SettingsTab: FC<Props> = (props) => {
})
}
const codeStyle = useMemo(() => {
return codeEditor.enabled
? theme === ThemeMode.light
? codeEditor.themeLight
: codeEditor.themeDark
: theme === ThemeMode.light
? codePreview.themeLight
: codePreview.themeDark
}, [
codeEditor.enabled,
codeEditor.themeLight,
codeEditor.themeDark,
theme,
codePreview.themeLight,
codePreview.themeDark
])
const onCodeStyleChange = useCallback(
(value: CodeStyleVarious) => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
const action = codeEditor.enabled ? setCodeEditor : setCodePreview
dispatch(action({ [field]: value }))
},
[dispatch, theme, codeEditor.enabled]
)
useEffect(() => {
setTemperature(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
setContextCount(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
@ -291,97 +315,6 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.show_line_numbers')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeShowLineNumbers}
onChange={(checked) => dispatch(setCodeShowLineNumbers(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_collapsible')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeCollapsible}
onChange={(checked) => dispatch(setCodeCollapsible(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cacheable')}{' '}
<Tooltip title={t('chat.settings.code_cacheable.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch size="small" checked={codeCacheable} onChange={(checked) => dispatch(setCodeCacheable(checked))} />
</SettingRow>
{codeCacheable && (
<>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_max_size')}
<Tooltip title={t('chat.settings.code_cache_max_size.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={1000}
max={10000}
step={1000}
value={codeCacheMaxSize}
onChange={(value) => dispatch(setCodeCacheMaxSize(value ?? 1000))}
style={{ width: 80 }}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_ttl')}
<Tooltip title={t('chat.settings.code_cache_ttl.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={15}
max={720}
step={15}
value={codeCacheTTL}
onChange={(value) => dispatch(setCodeCacheTTL(value ?? 15))}
style={{ width: 80 }}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_cache_threshold')}
<Tooltip title={t('chat.settings.code_cache_threshold.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={0}
max={50}
step={1}
value={codeCacheThreshold}
onChange={(value) => dispatch(setCodeCacheThreshold(value ?? 2))}
style={{ width: 80 }}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.thought_auto_collapse')}
@ -437,21 +370,6 @@ const SettingsTab: FC<Props> = (props) => {
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect
value={codeStyle}
onChange={(value) => dispatch(setCodeStyle(value as CodeStyleVarious))}
style={{ width: 135 }}
size="small">
{codeThemes.map((theme) => (
<Select.Option key={theme} value={theme}>
{theme}
</Select.Option>
))}
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
<StyledSelect
@ -487,7 +405,133 @@ const SettingsTab: FC<Props> = (props) => {
</Row>
</SettingGroup>
<SettingGroup>
<SettingSubtitle style={{ marginTop: 0 }}>{t('settings.messages.input.title')}</SettingSubtitle>
<SettingSubtitle style={{ marginTop: 0 }}>{t('chat.settings.code.title')}</SettingSubtitle>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect
value={codeStyle}
onChange={(value) => onCodeStyleChange(value as CodeStyleVarious)}
style={{ width: 135 }}
size="small">
{themeNames.map((theme) => (
<Select.Option key={theme} value={theme}>
{theme}
</Select.Option>
))}
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>
{t('chat.settings.code_execution.title')}
<Tooltip title={t('chat.settings.code_execution.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<Switch
size="small"
checked={codeExecution.enabled}
onChange={(checked) => dispatch(setCodeExecution({ enabled: checked }))}
/>
</SettingRow>
{codeExecution.enabled && (
<>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>
{t('chat.settings.code_execution.timeout_minutes')}
<Tooltip title={t('chat.settings.code_execution.timeout_minutes.tip')}>
<CircleHelp size={14} style={{ marginLeft: 4 }} color="var(--color-text-2)" />
</Tooltip>
</SettingRowTitleSmall>
<InputNumber
size="small"
min={1}
max={60}
step={1}
value={codeExecution.timeoutMinutes}
onChange={(value) => dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))}
style={{ width: 80 }}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_editor.title')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.enabled}
onChange={(checked) => dispatch(setCodeEditor({ enabled: checked }))}
/>
</SettingRow>
{codeEditor.enabled && (
<>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.highlight_active_line')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.highlightActiveLine}
onChange={(checked) => dispatch(setCodeEditor({ highlightActiveLine: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.fold_gutter')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.foldGutter}
onChange={(checked) => dispatch(setCodeEditor({ foldGutter: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.autocompletion')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.autocompletion}
onChange={(checked) => dispatch(setCodeEditor({ autocompletion: checked }))}
/>
</SettingRow>
<SettingDivider />
<SettingRow style={{ paddingLeft: 8 }}>
<SettingRowTitleSmall>{t('chat.settings.code_editor.keymap')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeEditor.keymap}
onChange={(checked) => dispatch(setCodeEditor({ keymap: checked }))}
/>
</SettingRow>
</>
)}
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.show_line_numbers')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeShowLineNumbers}
onChange={(checked) => dispatch(setCodeShowLineNumbers(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_collapsible')}</SettingRowTitleSmall>
<Switch
size="small"
checked={codeCollapsible}
onChange={(checked) => dispatch(setCodeCollapsible(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('chat.settings.code_wrappable')}</SettingRowTitleSmall>
<Switch size="small" checked={codeWrappable} onChange={(checked) => dispatch(setCodeWrappable(checked))} />
</SettingRow>
</SettingGroup>
<SettingGroup>
<SettingSubtitle style={{ marginTop: 10 }}>{t('settings.messages.input.title')}</SettingSubtitle>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.show_estimated_tokens')}</SettingRowTitleSmall>

View File

@ -1,10 +1,11 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { CodeToolbarProvider } from '@renderer/components/CodeToolbar'
import { TopView } from '@renderer/components/TopView'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { setMCPServers } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types'
import { Modal, Typography } from 'antd'
import TextArea from 'antd/es/input/TextArea'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
@ -99,6 +100,10 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
resolve({})
}
const handleChange = useCallback((newContent: string) => {
setJsonConfig(newContent)
}, [])
EditMcpJsonPopup.hide = onCancel
return (
@ -118,17 +123,15 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
{jsonError ? <span style={{ color: 'red' }}>{jsonError}</span> : ''}
</Typography.Text>
</div>
<TextArea
value={jsonConfig}
onChange={(e) => setJsonConfig(e.target.value)}
style={{
width: '100%',
fontFamily: 'monospace',
minHeight: '60vh',
marginBottom: '16px'
}}
onFocus={() => setJsonError('')}
/>
{jsonConfig && (
<div style={{ marginBottom: '16px' }}>
<CodeToolbarProvider>
<CodeEditor language="json" onChange={handleChange} options={{ maxHeight: '60vh' }}>
{jsonConfig}
</CodeEditor>
</CodeToolbarProvider>
</div>
)}
<Typography.Text type="secondary">{t('settings.mcp.jsonModeHint')}</Typography.Text>
</Modal>
)

View File

@ -1,219 +0,0 @@
import Logger from '@renderer/config/logger'
import store from '@renderer/store'
import { LRUCache } from 'lru-cache'
/**
* FNV-1a哈希函数
* @param input
* @param maxInputLength 50000
* @returns 36
*/
const fastHash = (input: string, maxInputLength: number = 50000) => {
let hash = 2166136261 // FNV偏移基数
const count = Math.min(input.length, maxInputLength)
for (let i = 0; i < count; i++) {
hash ^= input.charCodeAt(i)
hash *= 16777619 // FNV素数
hash >>>= 0 // 保持为32位无符号整数
}
return hash.toString(36)
}
/**
* 使
* @param input
* @returns
*/
const enhancedHash = (input: string) => {
const THRESHOLD = 50000
if (input.length <= THRESHOLD) {
return fastHash(input)
}
const mid = Math.floor(input.length / 2)
// 三段hash保证唯一性
const frontSection = input.slice(0, 10000)
const midSection = input.slice(mid - 15000, mid + 15000)
const endSection = input.slice(-10000)
return `${fastHash(frontSection)}-${fastHash(midSection)}-${fastHash(endSection)}`
}
// 高亮结果缓存实例
let highlightCache: LRUCache<string, string> | null = null
/**
*
*/
const haveSettingsChanged = (prev: any, current: any) => {
if (!prev || !current) return true
return (
prev.codeCacheable !== current.codeCacheable ||
prev.codeCacheMaxSize !== current.codeCacheMaxSize ||
prev.codeCacheTTL !== current.codeCacheTTL ||
prev.codeCacheThreshold !== current.codeCacheThreshold
)
}
/**
*
*
*/
export const CodeCacheService = {
/**
* 使
*/
_lastConfig: {
codeCacheable: false,
codeCacheMaxSize: 0,
codeCacheTTL: 0,
codeCacheThreshold: 0
},
/**
*
* @returns
*/
getConfig() {
try {
if (!store || !store.getState) return this._lastConfig
const { codeCacheable, codeCacheMaxSize, codeCacheTTL, codeCacheThreshold } = store.getState().settings
return { codeCacheable, codeCacheMaxSize, codeCacheTTL, codeCacheThreshold }
} catch (error) {
console.warn('[CodeCacheService] Failed to get config', error)
return this._lastConfig
}
},
/**
*
*
* @returns null
*/
ensureCache() {
const currentConfig = this.getConfig()
// 检查配置是否变化
if (haveSettingsChanged(this._lastConfig, currentConfig)) {
this._lastConfig = currentConfig
this._updateCacheInstance(currentConfig)
}
return highlightCache
},
/**
*
* @param config
*/
_updateCacheInstance(config: any) {
try {
const { codeCacheable, codeCacheMaxSize, codeCacheTTL } = config
const newMaxSize = codeCacheMaxSize * 1000
const newTTLMilliseconds = codeCacheTTL * 60 * 1000
// 根据配置决定是否创建或清除缓存
if (codeCacheable) {
if (!highlightCache) {
// 缓存不存在,创建新缓存
highlightCache = new LRUCache<string, string>({
max: 200, // 最大缓存条目数
maxSize: newMaxSize, // 最大缓存大小
sizeCalculation: (value) => value.length, // 缓存大小计算
ttl: newTTLMilliseconds // 缓存过期时间(毫秒)
})
return
}
// 尝试从当前缓存获取配置信息
const maxSize = highlightCache.max || 0
const ttl = highlightCache.ttl || 0
// 检查实际配置是否变化
if (maxSize !== newMaxSize || ttl !== newTTLMilliseconds) {
Logger.log('[CodeCacheService] Cache config changed, recreating cache')
highlightCache.clear()
highlightCache = new LRUCache<string, string>({
max: 500,
maxSize: newMaxSize,
sizeCalculation: (value) => value.length,
ttl: newTTLMilliseconds
})
}
} else if (highlightCache) {
// 缓存被禁用,清理资源
highlightCache.clear()
highlightCache = null
}
} catch (error) {
Logger.warn('[CodeCacheService] Failed to update cache config', error)
}
},
/**
*
* @param code
* @param language
* @param theme
* @returns
*/
generateCacheKey: (code: string, language: string, theme: string) => {
return `${language}|${theme}|${code.length}|${enhancedHash(code)}`
},
/**
*
* @param key
* @returns HTML或null
*/
getCachedResult: (key: string) => {
try {
// 确保缓存配置是最新的
CodeCacheService.ensureCache()
if (!store || !store.getState) return null
const { codeCacheable } = store.getState().settings
if (!codeCacheable) return null
return highlightCache?.get(key) || null
} catch (error) {
Logger.warn('[CodeCacheService] Failed to get cached result', error)
return null
}
},
/**
*
* @param key
* @param html HTML
* @param codeLength
*/
setCachedResult: (key: string, html: string, codeLength: number) => {
try {
// 确保缓存配置是最新的
CodeCacheService.ensureCache()
if (!store || !store.getState) return
const { codeCacheable, codeCacheThreshold } = store.getState().settings
// 判断是否可以缓存
if (!codeCacheable || codeLength < codeCacheThreshold * 1000) return
highlightCache?.set(key, html)
} catch (error) {
Logger.warn('[CodeCacheService] Failed to set cached result', error)
}
},
/**
*
*/
clear: () => {
highlightCache?.clear()
}
}

View File

@ -26,5 +26,6 @@ export const EVENT_NAMES = {
ADD_NEW_TOPIC: 'ADD_NEW_TOPIC',
RESEND_MESSAGE: 'RESEND_MESSAGE',
SHOW_MODEL_SELECTOR: 'SHOW_MODEL_SELECTOR',
QUOTE_TEXT: 'QUOTE_TEXT'
QUOTE_TEXT: 'QUOTE_TEXT',
EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK'
}

View File

@ -0,0 +1,231 @@
import { uuid } from '@renderer/utils'
// 定义结果类型接口
export interface PyodideOutput {
result: any
text: string | null
error: string | null
}
/**
* Pyodide Web Worker
*/
class PyodideService {
private static instance: PyodideService | null = null
private worker: Worker | null = null
private initPromise: Promise<void> | null = null
private initRetryCount: number = 0
private static readonly MAX_INIT_RETRY = 2
private resolvers: Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }> = new Map()
private constructor() {
// 单例模式
}
/**
* PyodideService
*/
public static getInstance(): PyodideService {
if (!PyodideService.instance) {
PyodideService.instance = new PyodideService()
}
return PyodideService.instance
}
/**
* Pyodide Worker
*/
private async initialize(): Promise<void> {
if (this.initPromise) {
return this.initPromise
}
if (this.worker) {
return Promise.resolve()
}
if (this.initRetryCount >= PyodideService.MAX_INIT_RETRY) {
return Promise.reject(new Error('Pyodide worker initialization failed too many times'))
}
this.initPromise = new Promise<void>((resolve, reject) => {
// 动态导入 worker
import('../workers/pyodide.worker?worker')
.then((WorkerModule) => {
this.worker = new WorkerModule.default()
// 设置通用消息处理器
this.worker.onmessage = this.handleMessage.bind(this)
// 设置初始化超时
const timeout = setTimeout(() => {
this.worker = null
this.initPromise = null
this.initRetryCount++
reject(new Error('Pyodide initialization timeout'))
}, 10000) // 10秒初始化超时
// 设置初始化处理器
const initHandler = (event: MessageEvent) => {
if (event.data?.type === 'initialized') {
clearTimeout(timeout)
this.worker?.removeEventListener('message', initHandler)
this.initRetryCount = 0
this.initPromise = null
resolve()
} else if (event.data?.type === 'error') {
clearTimeout(timeout)
this.worker?.removeEventListener('message', initHandler)
this.worker?.terminate()
this.worker = null
this.initPromise = null
this.initRetryCount++
reject(new Error(`Pyodide initialization failed: ${event.data.error}`))
}
}
this.worker.addEventListener('message', initHandler)
})
.catch((error) => {
this.worker = null
this.initPromise = null
this.initRetryCount++
reject(new Error(`Failed to load Pyodide worker: ${error instanceof Error ? error.message : String(error)}`))
})
})
return this.initPromise
}
/**
* Worker
*/
private handleMessage(event: MessageEvent): void {
// 忽略初始化消息,已由专门的处理器处理
if (event.data?.type === 'initialized' || event.data?.type === 'error') {
return
}
const { id, output } = event.data
// 查找对应的解析器
const resolver = this.resolvers.get(id)
if (resolver) {
this.resolvers.delete(id)
resolver.resolve(output)
}
}
/**
* Python脚本
* @param script Python脚本
* @param context
* @param timeout
* @returns
*/
public async runScript(script: string, context: Record<string, any> = {}, timeout: number = 60000): Promise<string> {
// 确保Pyodide已初始化
try {
await this.initialize()
} catch (error: unknown) {
console.error('Pyodide initialization failed, cannot execute Python code', error)
return `Initialization failed: ${error instanceof Error ? error.message : String(error)}`
}
if (!this.worker) {
return 'Internal error: Pyodide worker is not initialized'
}
try {
const output = await new Promise<PyodideOutput>((resolve, reject) => {
const id = uuid()
// 设置消息超时
const timeoutId = setTimeout(() => {
this.resolvers.delete(id)
reject(new Error('Python execution timed out'))
}, timeout)
this.resolvers.set(id, {
resolve: (output) => {
clearTimeout(timeoutId)
resolve(output)
},
reject: (error) => {
clearTimeout(timeoutId)
reject(error)
}
})
this.worker?.postMessage({
id,
python: script,
context
})
})
return this.formatOutput(output)
} catch (error: unknown) {
return `Internal error: ${error instanceof Error ? error.message : String(error)}`
}
}
/**
* Pyodide
*/
public formatOutput(output: PyodideOutput): string {
let displayText = ''
// 优先显示标准输出
if (output.text) {
displayText = output.text.trim()
}
// 如果有执行结果且无标准输出,显示结果
if (!displayText && output.result !== null && output.result !== undefined) {
if (typeof output.result === 'object' && output.result.__error__) {
displayText = `Result Error: ${output.result.details}`
} else {
try {
displayText =
typeof output.result === 'object' ? JSON.stringify(output.result, null, 2) : String(output.result)
} catch (e) {
displayText = `Result formatting failed: ${String(e)}`
}
}
}
// 如果有错误信息,附加显示
if (output.error) {
if (displayText) displayText += '\n\n'
displayText += `Error: ${output.error.trim()}`
}
// 如果没有任何输出,提供清晰提示
if (!displayText) {
displayText = 'Execution completed with no output.'
}
return displayText
}
/**
* Pyodide Worker
*/
public terminate(): void {
if (this.worker) {
this.worker.terminate()
this.worker = null
this.initPromise = null
this.initRetryCount = 0
// 清理所有等待的请求
this.resolvers.forEach((resolver) => {
resolver.reject(new Error('Worker terminated'))
})
this.resolvers.clear()
}
}
}
// 创建并导出单例实例
export const pyodideService = PyodideService.getInstance()

View File

@ -0,0 +1,497 @@
import { LRUCache } from 'lru-cache'
import type { HighlighterCore, SpecialLanguage, ThemedToken } from 'shiki/core'
import { ShikiStreamTokenizer, ShikiStreamTokenizerOptions } from './ShikiStreamTokenizer'
export type ShikiPreProperties = {
class: string
style: string
tabindex: number
}
/**
* chunk
*
* @param lines
* @param recall
*/
export interface HighlightChunkResult {
lines: ThemedToken[][]
recall: number
}
/**
* Shiki
*
* -
* - 使 Worker
*/
class ShikiStreamService {
// 默认配置
private static readonly DEFAULT_LANGUAGES = ['javascript', 'typescript', 'python', 'java', 'markdown']
private static readonly DEFAULT_THEMES = ['one-light', 'material-theme-darker']
// 主线程 highlighter 和 tokenizers
private highlighter: HighlighterCore | null = null
private highlighterInitPromise: Promise<void> | null = null
// 保存以 callerId-language-theme 为键的 tokenizer map
private tokenizerCache = new LRUCache<string, ShikiStreamTokenizer>({
max: 100, // 最大缓存数量
ttl: 1000 * 60 * 30, // 30分钟过期时间
updateAgeOnGet: true,
dispose: (value) => {
if (value) value.clear()
}
})
// Worker 相关资源
private worker: Worker | null = null
private workerInitPromise: Promise<void> | null = null
private workerInitRetryCount: number = 0
private static readonly MAX_WORKER_INIT_RETRY = 2
private pendingRequests = new Map<
number,
{
resolve: (value: any) => void
reject: (reason?: any) => void
}
>()
private requestId = 0
// 降级策略相关变量,用于记录调用 worker 失败过的 callerId
private workerDegradationCache = new LRUCache<string, boolean>({
max: 1000, // 最大记录数量
ttl: 1000 * 60 * 60 * 12 // 12小时自动过期
})
constructor() {
// 延迟初始化
}
/**
* 使 Worker
*/
public hasWorkerHighlighter(): boolean {
return !!this.worker && !this.workerInitPromise
}
/**
* 使线
*/
public hasMainHighlighter(): boolean {
return !!this.highlighter && !this.highlighterInitPromise
}
/**
* Worker
*/
private async initWorker(): Promise<void> {
if (typeof Worker === 'undefined') return
if (this.workerInitPromise) return this.workerInitPromise
if (this.worker) return
if (this.workerInitRetryCount >= ShikiStreamService.MAX_WORKER_INIT_RETRY) {
console.debug('ShikiStream worker initialization failed too many times, stop trying')
return
}
this.workerInitPromise = (async () => {
try {
// 动态导入 worker
const WorkerModule = await import('../workers/shiki-stream.worker?worker')
this.worker = new WorkerModule.default()
// 设置消息处理器
this.worker.onmessage = (event) => {
const { id, type, result, error } = event.data
// 查找对应的请求
const pendingRequest = this.pendingRequests.get(id)
if (!pendingRequest) return
this.pendingRequests.delete(id)
if (type === 'error') {
pendingRequest.reject(new Error(error))
} else if (type === 'init-result') {
pendingRequest.resolve({ success: true })
this.workerInitRetryCount = 0
} else {
pendingRequest.resolve(result)
}
}
// 初始化 worker
await this.sendWorkerMessage({
type: 'init',
languages: ShikiStreamService.DEFAULT_LANGUAGES,
themes: ShikiStreamService.DEFAULT_THEMES
})
this.workerInitRetryCount = 0
} catch (error) {
this.worker?.terminate()
this.worker = null
this.workerInitRetryCount++
throw error
} finally {
this.workerInitPromise = null
}
})()
return this.workerInitPromise
}
/**
* Worker
*/
private sendWorkerMessage(message: any): Promise<any> {
if (!this.worker) {
return Promise.reject(new Error('Worker not available'))
}
const id = this.requestId++
let timerId: ReturnType<typeof setTimeout>
let settled = false
const promise = new Promise((resolve, reject) => {
const safeResolve = (value: any) => {
if (!settled) {
settled = true
clearTimeout(timerId)
this.pendingRequests.delete(id)
resolve(value)
}
}
const safeReject = (reason?: any) => {
if (!settled) {
settled = true
clearTimeout(timerId)
this.pendingRequests.delete(id)
reject(reason)
}
}
this.pendingRequests.set(id, { resolve: safeResolve, reject: safeReject })
// 根据操作类型设置不同的超时时间
const getTimeoutForMessageType = (type: string): number => {
switch (type) {
case 'init':
return 5000 // 初始化操作 (5秒)
case 'highlight':
return 30000 // 高亮操作 (30秒)
case 'cleanup':
case 'dispose':
default:
return 10000 // 其他操作 (10秒)
}
}
const timeout = getTimeoutForMessageType(message.type)
// 设置超时处理
timerId = setTimeout(() => {
// 如果是高亮操作超时说明代码块太长记录callerId以便降级
if (message.type === 'highlight' && message.callerId) {
this.workerDegradationCache.set(message.callerId, true)
safeReject(new Error(`Worker ${message.type} request timeout for callerId ${message.callerId}`))
} else {
safeReject(new Error(`Worker ${message.type} request timeout`))
}
}, timeout)
})
try {
this.worker.postMessage({ id, ...message })
} catch (error) {
const pendingRequest = this.pendingRequests.get(id)
if (pendingRequest) {
pendingRequest.reject(error instanceof Error ? error : new Error(String(error)))
}
}
return promise
}
/**
* highlighter
*/
private async initHighlighter(): Promise<void> {
if (this.highlighterInitPromise) {
return this.highlighterInitPromise
} else if (this.highlighter) {
return Promise.resolve()
}
this.highlighterInitPromise = (async () => {
const { createHighlighter } = await import('shiki')
this.highlighter = await createHighlighter({
langs: ShikiStreamService.DEFAULT_LANGUAGES,
themes: ShikiStreamService.DEFAULT_THEMES
})
})()
return this.highlighterInitPromise
}
/**
* highlighter
* @param language
* @param theme
*/
private async ensureHighlighterConfigured(
language: string,
theme: string
): Promise<{ actualLanguage: string; actualTheme: string }> {
// 确保 highlighter 已初始化
if (!this.hasMainHighlighter()) {
await this.initHighlighter()
}
if (!this.highlighter) {
throw new Error('Highlighter not initialized')
}
const shiki = await import('shiki')
let actualLanguage = language
let actualTheme = theme
// 加载语言
if (!this.highlighter.getLoadedLanguages().includes(language)) {
try {
if (['text', 'ansi'].includes(language)) {
await this.highlighter.loadLanguage(language as SpecialLanguage)
} else {
const languageImportFn = shiki.bundledLanguages[language]
const langData = await languageImportFn()
await this.highlighter.loadLanguage(langData)
}
} catch (error) {
await this.highlighter.loadLanguage('text')
actualLanguage = 'text'
}
}
// 加载主题
if (!this.highlighter.getLoadedThemes().includes(theme)) {
try {
const themeImportFn = shiki.bundledThemes[theme]
const themeData = await themeImportFn()
await this.highlighter.loadTheme(themeData)
} catch (error) {
// 回退到 one-light
console.debug(`Failed to load theme '${theme}', falling back to 'one-light':`, error)
const oneLightTheme = await shiki.bundledThemes['one-light']()
await this.highlighter.loadTheme(oneLightTheme)
actualTheme = 'one-light'
}
}
return { actualLanguage, actualTheme }
}
/**
* Shiki pre
*
* hast properties
*
* @param language
* @param theme
* @returns pre
*/
async getShikiPreProperties(language: string, theme: string): Promise<ShikiPreProperties> {
const { actualLanguage, actualTheme } = await this.ensureHighlighterConfigured(language, theme)
if (!this.highlighter) {
throw new Error('Highlighter not initialized')
}
const hast = this.highlighter.codeToHast('1', {
lang: actualLanguage,
theme: actualTheme
})
// @ts-ignore hack
return hast.children[0].properties as ShikiPreProperties
}
/**
* chunk ThemedToken
*
* 使 Worker 退线
*
* @param chunk
* @param language
* @param theme
* @param callerId ID
* @returns ThemedToken
*/
async highlightCodeChunk(
chunk: string,
language: string,
theme: string,
callerId: string
): Promise<HighlightChunkResult> {
// 检查callerId是否需要降级处理
if (this.workerDegradationCache.has(callerId)) {
return this.highlightWithMainThread(chunk, language, theme, callerId)
}
// 初始化 worker
if (!this.worker) {
try {
await this.initWorker()
} catch (error) {
console.warn('Failed to initialize worker, falling back to main thread:', error)
}
}
// 如果 Worker 可用,优先使用 Worker 处理
if (this.hasWorkerHighlighter()) {
try {
const result = await this.sendWorkerMessage({
type: 'highlight',
callerId,
chunk,
language,
theme
})
return result
} catch (error) {
// Worker 处理失败记录callerId并永久降级到主线程
// FIXME: 这种情况如果出现,流式高亮语法状态就会丢失,目前用降级策略来处理
this.workerDegradationCache.set(callerId, true)
console.error(
`Worker highlight failed for callerId ${callerId}, permanently falling back to main thread:`,
error
)
}
}
// 使用主线程处理
return this.highlightWithMainThread(chunk, language, theme, callerId)
}
/**
* 使线
* @param chunk
* @param language
* @param theme
* @param callerId ID
* @returns
*/
private async highlightWithMainThread(
chunk: string,
language: string,
theme: string,
callerId: string
): Promise<HighlightChunkResult> {
try {
const tokenizer = await this.getStreamTokenizer(callerId, language, theme)
const result = await tokenizer.enqueue(chunk)
// 合并稳定和不稳定的行作为本次高亮的所有行
return {
lines: [...result.stable, ...result.unstable],
recall: result.recall
}
} catch (error) {
console.error('Failed to highlight code chunk:', error)
// 提供简单的 fallback
const fallbackToken: ThemedToken = { content: chunk || '', color: '#000000', offset: 0 }
return {
lines: [[fallbackToken]],
recall: 0
}
}
}
/**
* tokenizer
* @param callerId ID
* @param language
* @param theme
* @returns tokenizer
*/
private async getStreamTokenizer(callerId: string, language: string, theme: string): Promise<ShikiStreamTokenizer> {
// 创建复合键
const cacheKey = `${callerId}-${language}-${theme}`
// 如果已存在,直接返回
if (this.tokenizerCache.has(cacheKey)) {
return this.tokenizerCache.get(cacheKey)!
}
// 确保 highlighter 已配置
const { actualLanguage, actualTheme } = await this.ensureHighlighterConfigured(language, theme)
if (!this.highlighter) {
throw new Error('Highlighter not initialized')
}
// 创建新的 tokenizer
const options: ShikiStreamTokenizerOptions = {
highlighter: this.highlighter,
lang: actualLanguage,
theme: actualTheme
}
const tokenizer = new ShikiStreamTokenizer(options)
this.tokenizerCache.set(cacheKey, tokenizer)
return tokenizer
}
/**
* tokenizers
* @param callerId ID
*/
cleanupTokenizers(callerId: string): void {
// 先尝试清理 Worker 中的 tokenizers
if (this.hasWorkerHighlighter()) {
this.sendWorkerMessage({
type: 'cleanup',
callerId
}).catch((error) => {
console.error('Failed to cleanup worker tokenizer:', error)
})
}
// 再清理主线程中的 tokenizers移除所有以 callerId 开头的缓存项
for (const key of this.tokenizerCache.keys()) {
if (key.startsWith(`${callerId}-`)) {
this.tokenizerCache.delete(key)
}
}
}
/**
*
*/
dispose() {
if (this.worker) {
this.sendWorkerMessage({ type: 'dispose' }).catch((error) => {
console.warn('Failed to dispose worker:', error)
})
this.worker.terminate()
this.worker = null
this.pendingRequests.clear()
this.requestId = 0
}
this.workerDegradationCache.clear()
this.tokenizerCache.clear()
this.highlighter?.dispose()
this.highlighter = null
this.highlighterInitPromise = null
this.workerInitPromise = null
this.workerInitRetryCount = 0
}
}
export const shikiStreamService = new ShikiStreamService()

View File

@ -0,0 +1,111 @@
import type { CodeToTokensOptions, GrammarState, HighlighterCore, HighlighterGeneric, ThemedToken } from 'shiki/core'
export type ShikiStreamTokenizerOptions = CodeToTokensOptions<string, string> & {
highlighter: HighlighterCore | HighlighterGeneric<any, any>
}
export interface ShikiStreamTokenizerEnqueueResult {
/**
*
*/
recall: number
/**
*
*/
stable: ThemedToken[][]
/**
*
*/
unstable: ThemedToken[][]
}
/**
* shiki-stream tokenizer
*
* shiki-stream
* - tokenizer subtrunk subtrunk
* - chunk
*/
export class ShikiStreamTokenizer {
public readonly options: ShikiStreamTokenizerOptions
// public linesStable: ThemedToken[][] = []
public linesUnstable: ThemedToken[][] = []
public lastUnstableCodeChunk: string = ''
public lastStableGrammarState: GrammarState | undefined
constructor(options: ShikiStreamTokenizerOptions) {
this.options = options
}
/**
* 使 tokenizer
*/
async enqueue(chunk: string): Promise<ShikiStreamTokenizerEnqueueResult> {
const subTrunks = splitToSubTrunks(this.lastUnstableCodeChunk + chunk)
const stable: ThemedToken[][] = []
const unstable: ThemedToken[][] = []
const recall = this.linesUnstable.length
subTrunks.forEach((subTrunck, i) => {
const isLastChunk = i === subTrunks.length - 1
const result = this.options.highlighter.codeToTokens(subTrunck, {
...this.options,
grammarState: this.lastStableGrammarState
})
if (!isLastChunk) {
this.lastStableGrammarState = result.grammarState
result.tokens.forEach((tokenLine) => {
stable.push(tokenLine)
})
} else {
unstable.push(result.tokens[0])
this.lastUnstableCodeChunk = subTrunck
}
})
// this.linesStable.push(...stable)
this.linesUnstable = unstable
return {
recall,
stable,
unstable
}
}
close(): { stable: ThemedToken[][] } {
const stable = this.linesUnstable
this.linesUnstable = []
this.lastUnstableCodeChunk = ''
this.lastStableGrammarState = undefined
return {
stable
}
}
clear(): void {
// this.linesStable = []
this.linesUnstable = []
this.lastUnstableCodeChunk = ''
this.lastStableGrammarState = undefined
}
}
/**
* chunk subtrunks
* @param chunk
* @returns subtrunks
*/
export function splitToSubTrunks(chunk: string) {
const lastNewlineIndex = chunk.lastIndexOf('\n')
if (lastNewlineIndex === -1) {
return [chunk]
}
return [chunk.substring(0, lastNewlineIndex), chunk.substring(lastNewlineIndex + 1)]
}

View File

@ -0,0 +1,293 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { shikiStreamService } from '../ShikiStreamService'
describe('ShikiStreamService', () => {
const language = 'typescript'
const theme = 'one-light'
const callerId = 'test-caller'
// 保证每次测试环境干净
beforeEach(() => {
shikiStreamService.dispose()
})
afterEach(() => {
shikiStreamService.dispose()
})
describe('Worker initialization and degradation', () => {
it('should initialize worker and highlight via worker', async () => {
const code = 'const x = 1;'
// 这里不 mock Worker直接走真实逻辑
const result = await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
expect(shikiStreamService.hasWorkerHighlighter()).toBe(true)
expect(shikiStreamService.hasMainHighlighter()).toBe(false)
expect(result.lines.length).toBeGreaterThan(0)
expect(result.recall).toBe(0)
})
it('should fallback to main thread if worker initialization fails', async () => {
const originalWorker = globalThis.Worker
// @ts-ignore: 强制删除 Worker 构造函数
globalThis.Worker = undefined
const code = 'const y = 2;'
const result = await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
expect(shikiStreamService.hasWorkerHighlighter()).toBe(false)
expect(result.lines.length).toBeGreaterThan(0)
expect(result.recall).toBe(0)
// @ts-ignore: 恢复 Worker 构造函数
globalThis.Worker = originalWorker
})
it('should not retry worker after too many init failures', async () => {
// 模拟多次初始化失败
const spy = vi.spyOn(shikiStreamService as any, 'initWorker').mockImplementation(() => {
return Promise.reject(new Error('init failed'))
})
// @ts-ignore: access private
const maxRetryCount = shikiStreamService.MAX_WORKER_INIT_RETRY
// 连续多次调用
for (let i = 1; i < maxRetryCount + 2; i++) {
shikiStreamService.highlightCodeChunk('const a = ' + i, language, theme, callerId).catch(() => {})
// @ts-ignore: access private
expect(shikiStreamService.workerInitRetryCount).toBe(Math.min(i, maxRetryCount))
}
spy.mockRestore()
})
})
describe('tokenizer management (main)', () => {
let originalWorker: any
beforeEach(() => {
originalWorker = globalThis.Worker
// @ts-ignore: 强制删除 Worker 构造函数
globalThis.Worker = undefined
})
afterEach(() => {
// @ts-ignore: 恢复 Worker 构造函数
globalThis.Worker = originalWorker
})
it('should reuse the same tokenizer for the same callerId-language-theme', async () => {
const code1 = 'const a = 1;'
const code2 = 'const b = 2;'
const cacheKey = `${callerId}-${language}-${theme}`
// 先高亮一次,创建 tokenizer
await shikiStreamService.highlightCodeChunk(code1, language, theme, callerId)
// @ts-ignore: access private
const tokenizer1 = shikiStreamService.tokenizerCache.get(cacheKey)
// 再高亮一次,应该复用 tokenizer
await shikiStreamService.highlightCodeChunk(code2, language, theme, callerId)
// @ts-ignore: access private
const tokenizer2 = shikiStreamService.tokenizerCache.get(cacheKey)
expect(tokenizer1).toBe(tokenizer2)
})
it.each([
// [desc, callerId, language, theme, other, otherDesc]
['different language', 'javascript', 'one-light', 'test-caller'],
['different theme', 'typescript', 'material-theme-darker', 'test-caller'],
['different callerId', 'typescript', 'one-light', 'another-caller']
])('should create a new tokenizer for %s', async (_description, _language, _theme, _callerId) => {
const code = 'const x = 1;'
const cacheKey = `${callerId}-${language}-${theme}`
const otherCacheKey = `${_callerId}-${_language}-${_theme}`
await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(otherCacheKey)).toBe(false)
await shikiStreamService.highlightCodeChunk(code, _language, _theme, _callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(otherCacheKey)).toBe(true)
})
it('should cleanup tokenizer for a specific callerId', async () => {
const code = 'const x = 1;'
const cacheKey = `${callerId}-${language}-${theme}`
await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
shikiStreamService.cleanupTokenizers(callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(false)
})
it('should not affect other callerIds when cleaning up', async () => {
const code1 = 'const x = 1;'
const code2 = 'const y = 2;'
const otherCallerId = 'other-caller'
const cacheKey1 = `${callerId}-${language}-${theme}`
const cacheKey2 = `${otherCallerId}-${language}-${theme}`
await shikiStreamService.highlightCodeChunk(code1, language, theme, callerId)
await shikiStreamService.highlightCodeChunk(code2, language, theme, otherCallerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey1)).toBe(true)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey2)).toBe(true)
shikiStreamService.cleanupTokenizers(callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey1)).toBe(false)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey2)).toBe(true)
})
it('should cleanup tokenizers concurrently for different callerIds', async () => {
const code = 'const x = 1;'
const callerIds = ['concurrent-1', 'concurrent-2', 'concurrent-3']
// 先为每个 callerId 创建 tokenizer
await Promise.all(callerIds.map((id) => shikiStreamService.highlightCodeChunk(code, language, theme, id)))
// 检查缓存
for (const id of callerIds) {
const cacheKey = `${id}-${language}-${theme}`
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
}
// 并发清理
await Promise.all(callerIds.map((id) => Promise.resolve(shikiStreamService.cleanupTokenizers(id))))
// 检查缓存都被清理
for (const id of callerIds) {
const cacheKey = `${id}-${language}-${theme}`
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(false)
}
})
it('should cleanup tokenizers concurrently for the same callerId', async () => {
const code = 'const x = 1;'
const cacheKey = `${callerId}-${language}-${theme}`
await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
// 并发清理同一个 callerId
await Promise.all([
Promise.resolve(shikiStreamService.cleanupTokenizers(callerId)),
Promise.resolve(shikiStreamService.cleanupTokenizers(callerId)),
Promise.resolve(shikiStreamService.cleanupTokenizers(callerId))
])
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(false)
})
it('should not affect highlightCodeChunk when cleanupTokenizers is called concurrently', async () => {
const code = 'const x = 1;'
await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
const cacheKey = `${callerId}-${language}-${theme}`
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
// 并发高亮和清理
await Promise.all([
shikiStreamService.highlightCodeChunk(code, language, theme, callerId),
Promise.resolve(shikiStreamService.cleanupTokenizers(callerId)),
shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
])
// 高亮后缓存应该存在
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(true)
// 最后清理
shikiStreamService.cleanupTokenizers(callerId)
// @ts-ignore: access private
expect(shikiStreamService.tokenizerCache.has(cacheKey)).toBe(false)
})
})
describe('dispose', () => {
it('should release all resources and reset state', async () => {
// 先初始化资源
const code = 'const x = 1;'
await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
// mock 关键方法
const worker = (shikiStreamService as any).worker
const highlighter = (shikiStreamService as any).highlighter
const workerTerminateSpy = worker ? vi.spyOn(worker, 'terminate') : undefined
const highlighterDisposeSpy = highlighter ? vi.spyOn(highlighter, 'dispose') : undefined
const tokenizerCache = (shikiStreamService as any).tokenizerCache
const tokenizerClearSpies: any[] = []
for (const tokenizer of tokenizerCache.values()) {
tokenizerClearSpies.push(vi.spyOn(tokenizer, 'clear'))
}
// dispose
shikiStreamService.dispose()
// worker terminated
if (workerTerminateSpy) {
expect(workerTerminateSpy).toHaveBeenCalled()
}
// highlighter disposed
if (highlighterDisposeSpy) {
expect(highlighterDisposeSpy).toHaveBeenCalled()
}
// all tokenizers cleared
for (const spy of tokenizerClearSpies) {
expect(spy).toHaveBeenCalled()
}
// assert cache and references are cleared
expect((shikiStreamService as any).worker).toBeNull()
expect((shikiStreamService as any).highlighter).toBeNull()
expect((shikiStreamService as any).tokenizerCache.size).toBe(0)
expect((shikiStreamService as any).pendingRequests.size).toBe(0)
expect((shikiStreamService as any).highlighterInitPromise).toBeNull()
expect((shikiStreamService as any).workerInitPromise).toBeNull()
expect((shikiStreamService as any).workerInitRetryCount).toBe(0)
})
it('should be idempotent when called multiple times', () => {
// 重复 dispose 不抛异常
expect(() => {
shikiStreamService.dispose()
shikiStreamService.dispose()
shikiStreamService.dispose()
}).not.toThrow()
expect((shikiStreamService as any).worker).toBeNull()
expect((shikiStreamService as any).highlighter).toBeNull()
expect((shikiStreamService as any).tokenizerCache.size).toBe(0)
})
it('should re-initialize after dispose when highlightCodeChunk is called', async () => {
const code = 'const x = 1;'
shikiStreamService.dispose()
const result = await shikiStreamService.highlightCodeChunk(code, language, theme, callerId)
expect(result.lines.length).toBeGreaterThan(0)
})
it('should not throw when cleanupTokenizers is called after dispose', () => {
shikiStreamService.dispose()
expect(() => {
shikiStreamService.cleanupTokenizers('any-caller')
}).not.toThrow()
})
})
})

View File

@ -0,0 +1,200 @@
import { createHighlighter, HighlighterCore } from 'shiki'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ShikiStreamTokenizer } from '../ShikiStreamTokenizer'
import {
generateEqualLengthChunks,
getExpectedHighlightedCode,
highlightCode
} from './helpers/ShikiStreamTokenizer.helper'
describe('ShikiStreamTokenizer', () => {
const highlighterPromise = createHighlighter({
langs: ['typescript'],
themes: ['one-light']
})
let highlighter: HighlighterCore | null = null
let tokenizer: ShikiStreamTokenizer
beforeEach(async () => {
highlighter = await highlighterPromise
tokenizer = new ShikiStreamTokenizer({
highlighter,
lang: 'typescript',
theme: 'one-light'
})
})
afterEach(() => {
tokenizer.clear()
highlighter = null
})
describe('enqueue', () => {
it('should handle single line code chunk correctly', async () => {
const chunk = 'const x = 5;'
const result = await tokenizer.enqueue(chunk)
expect(result.stable).toEqual([])
expect(result.unstable.length).toBe(1)
expect(result.recall).toBe(0)
})
it('should handle multi-line code chunk with stable and unstable lines', async () => {
const chunk = 'const x = 5;\nconst y = 10;'
const result = await tokenizer.enqueue(chunk)
expect(result.stable.length).toBe(1)
expect(result.unstable.length).toBe(1)
expect(result.recall).toBe(0)
})
it('should handle empty chunk', async () => {
const chunk = ''
const result = await tokenizer.enqueue(chunk)
expect(result.stable).toEqual([])
expect(result.unstable).toEqual([[]]) // 有一个空的 token
expect(result.recall).toBe(0)
})
it('should handle very long single line', async () => {
const longLine = 'const longVariableName = ' + 'a'.repeat(1000) + ';'
const result = await tokenizer.enqueue(longLine)
expect(result.stable).toEqual([])
expect(result.unstable.length).toBe(1)
expect(result.recall).toBe(0)
})
it('should handle sequential chunks where the first is a full line', async () => {
const firstChunk = 'const x = 5;\n'
const secondChunk = 'const y = 10;'
// 由于第一个 chunk 是完整的行,会产生一个 stable line 和一个 unstable line (空的)
const firstResult = await tokenizer.enqueue(firstChunk)
expect(firstResult.stable.length).toBe(1)
expect(firstResult.unstable.length).toBe(1)
expect(firstResult.recall).toBe(0)
// 第二个 chunk 来的时候,前面的 unstable line 实际上是空的,因此不会有 stable line
const secondResult = await tokenizer.enqueue(secondChunk)
expect(secondResult.stable.length).toBe(0)
expect(secondResult.unstable.length).toBe(1)
expect(secondResult.recall).toBe(1)
})
it('should handle sequential chunks where the first is a partial line', async () => {
const firstChunk = 'const x = 5'
const secondChunk = ';\nconst y = 10;'
const firstResult = await tokenizer.enqueue(firstChunk)
expect(firstResult.stable.length).toBe(0)
expect(firstResult.unstable.length).toBe(1)
expect(firstResult.recall).toBe(0)
const secondResult = await tokenizer.enqueue(secondChunk)
expect(secondResult.stable.length).toBe(1)
expect(secondResult.unstable.length).toBe(1)
expect(secondResult.recall).toBe(1)
})
})
describe('close', () => {
it('should finalize unstable lines to stable', async () => {
await tokenizer.enqueue('const x = 5;')
const result = tokenizer.close()
expect(result.stable.length).toBe(1)
expect(tokenizer.linesUnstable).toEqual([])
expect(tokenizer.lastUnstableCodeChunk).toBe('')
})
it('should handle close with no unstable lines', () => {
const result = tokenizer.close()
expect(result.stable).toEqual([])
expect(tokenizer.linesUnstable).toEqual([])
expect(tokenizer.lastUnstableCodeChunk).toBe('')
})
})
describe('clear', () => {
it('should reset tokenizer state', async () => {
await tokenizer.enqueue('const x = 5;')
tokenizer.clear()
expect(tokenizer.linesUnstable).toEqual([])
expect(tokenizer.lastUnstableCodeChunk).toBe('')
expect(tokenizer.lastStableGrammarState).toBeUndefined()
})
it('should handle clear with no data', () => {
tokenizer.clear()
expect(tokenizer.linesUnstable).toEqual([])
expect(tokenizer.lastUnstableCodeChunk).toBe('')
expect(tokenizer.lastStableGrammarState).toBeUndefined()
})
})
describe('streaming', () => {
const fixture = {
tsCode: `
/* 块注释 */
enum E{A,B='C'} interface I{id:number;fn:(x:string)=>boolean} type T=[num:number,str:string]|'fixed'; // 枚举/接口/类型别名
const f=<T extends string|number>(a:T):T=>a; // 泛型函数
// 单行注释
class C{static s=0; private #p=''; readonly r:symbol=Symbol(); m():\`\${string}-\${number}\`{return \`\${this.#p}\${C.s}\`}} // 类:静态/私有/只读/模板类型
const v:string|undefined=null??'val'; const n=v?.length??0; const u=v as string; // 空值合并/可选链/类型断言
const [x,,y]:T=[10,'ts']; const {id:z}:I={id:1,fn:s=>s.length>0}; // 元组解构/对象解构重命名
console.log(typeof f, E.B, new C() instanceof C, /^ts$/.test('ts')); // typeof/枚举值/instanceof/正则
`
}
it('should handle a single chunk of complex code', async () => {
const result = await highlightCode([fixture.tsCode], tokenizer)
const expected = getExpectedHighlightedCode(fixture.tsCode, highlighter)
expect(result).toBe(expected)
})
it('should handle chunks of full lines', async () => {
const lines = fixture.tsCode.split('\n')
const chunks = lines.map((line, index) => {
if (index === lines.length - 1) {
return line
}
return line + '\n'
})
const result = await highlightCode(chunks, tokenizer)
const expected = getExpectedHighlightedCode(fixture.tsCode, highlighter)
expect(result).toBe(expected)
})
it('should handle chunks of partial lines with leading newlines', async () => {
const lines = fixture.tsCode.split('\n')
const chunks = lines.map((line, index) => {
if (index === 0) {
return line
}
return '\n' + line
})
const result = await highlightCode(chunks, tokenizer)
const expected = getExpectedHighlightedCode(fixture.tsCode, highlighter)
expect(result).toBe(expected)
})
it.each([13, 31, 53, 101])('should handle chunks of equal length %i', async (chunkLength) => {
const chunks = generateEqualLengthChunks(fixture.tsCode, chunkLength)
const result = await highlightCode(chunks, tokenizer)
const expected = getExpectedHighlightedCode(fixture.tsCode, highlighter)
expect(result).toBe(expected)
})
})
})

View File

@ -0,0 +1,95 @@
import { ShikiStreamTokenizer } from '@renderer/services/ShikiStreamTokenizer'
import { getTokenStyleObject, HighlighterCore, stringifyTokenStyle, type ThemedToken } from 'shiki/core'
/**
* 使 ShikiStreamTokenizer
* @param chunks
* @param tokenizer tokenizer
* @returns HTML
*/
export async function highlightCode(chunks: string[], tokenizer: ShikiStreamTokenizer): Promise<string> {
let tokenLines: ThemedToken[][] = []
for (const chunk of chunks) {
const result = await tokenizer.enqueue(chunk)
// 根据 recall 值移除可能需要重新处理的行
if (result.recall > 0 && tokenLines.length > 0) {
tokenLines = tokenLines.slice(0, Math.max(0, tokenLines.length - result.recall))
}
// 添加稳定的行和不稳定的行
tokenLines = [...tokenLines, ...result.stable, ...result.unstable]
}
// 这里就不获取返回值了,因为最后一行应该已经处理完了
tokenizer.close()
return tokenLinesToHtml(tokenLines)
}
/**
* 使 shiki codeToTokens
* @param code
* @param highlighter
* @returns html
*/
export function getExpectedHighlightedCode(code: string, highlighter: HighlighterCore | null) {
const expected = highlighter?.codeToTokens(code, {
lang: 'typescript',
theme: 'one-light'
})
return tokenLinesToHtml(expected?.tokens ?? [])
}
/**
* token html
* @param token
* @returns span
*/
export function tokenToHtml(token: ThemedToken): string {
return `<span style="${stringifyTokenStyle(token.htmlStyle || getTokenStyleObject(token))}">${escapeHtml(token.content)}</span>`
}
/**
* token html
* @param tokenLine token
* @returns span with className line
*/
export function tokenLineToHtml(tokenLine: ThemedToken[]): string {
return `<span className="line">${tokenLine.map(tokenToHtml).join('')}</span>`
}
/**
* token html
* @param tokenLines token
* @returns spans with className line
*/
export function tokenLinesToHtml(tokenLines: ThemedToken[][]): string {
return tokenLines.map(tokenLineToHtml).join('\n')
}
/**
* html
* @param html html
* @returns html
*/
export function escapeHtml(html: string): string {
return html.replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* n
* @param code
* @param n
* @returns
*/
export function generateEqualLengthChunks(code: string, n: number): string[] {
if (n <= 0) throw new Error('n must be greater than 0')
const result: string[] = []
for (let i = 0; i < code.length; i += n) {
result.push(code.slice(i, i + n))
}
return result
}

View File

@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 100,
version: 102,
blacklist: ['runtime', 'messages', 'messageBlocks'],
migrate
},

View File

@ -542,6 +542,7 @@ const migrateConfig = {
},
'39': (state: RootState) => {
try {
// @ts-ignore eslint-disable-next-line
state.settings.codeStyle = 'auto'
return state
} catch (error) {
@ -1167,9 +1168,13 @@ const migrateConfig = {
},
'91': (state: RootState) => {
try {
// @ts-ignore eslint-disable-next-line
state.settings.codeCacheable = false
// @ts-ignore eslint-disable-next-line
state.settings.codeCacheMaxSize = 1000
// @ts-ignore eslint-disable-next-line
state.settings.codeCacheTTL = 15
// @ts-ignore eslint-disable-next-line
state.settings.codeCacheThreshold = 2
addProvider(state, 'qiniu')
return state
@ -1343,6 +1348,35 @@ const migrateConfig = {
} catch (error) {
return state
}
},
'102': (state: RootState) => {
try {
state.settings.codeExecution = settingsInitialState.codeExecution
state.settings.codeEditor = settingsInitialState.codeEditor
state.settings.codePreview = settingsInitialState.codePreview
// @ts-ignore eslint-disable-next-line
if (state.settings.codeStyle) {
// @ts-ignore eslint-disable-next-line
state.settings.codePreview.themeLight = state.settings.codeStyle
// @ts-ignore eslint-disable-next-line
state.settings.codePreview.themeDark = state.settings.codeStyle
}
// @ts-ignore eslint-disable-next-line
delete state.settings.codeStyle
// @ts-ignore eslint-disable-next-line
delete state.settings.codeCacheable
// @ts-ignore eslint-disable-next-line
delete state.settings.codeCacheMaxSize
// @ts-ignore eslint-disable-next-line
delete state.settings.codeCacheTTL
// @ts-ignore eslint-disable-next-line
delete state.settings.codeCacheThreshold
return state
} catch (error) {
return state
}
}
}

View File

@ -50,17 +50,29 @@ export interface SettingsState {
clickAssistantToShowTopic: boolean
autoCheckUpdate: boolean
renderInputMessageAsMarkdown: boolean
// 代码执行
codeExecution: {
enabled: boolean
timeoutMinutes: number
}
codeEditor: {
enabled: boolean
themeLight: string
themeDark: string
highlightActiveLine: boolean
foldGutter: boolean
autocompletion: boolean
keymap: boolean
}
codePreview: {
themeLight: CodeStyleVarious
themeDark: CodeStyleVarious
}
codeShowLineNumbers: boolean
codeCollapsible: boolean
codeWrappable: boolean
// 代码块缓存
codeCacheable: boolean
codeCacheMaxSize: number
codeCacheTTL: number
codeCacheThreshold: number
mathEngine: MathEngine
messageStyle: 'plain' | 'bubble'
codeStyle: CodeStyleVarious
foldDisplayMode: 'expanded' | 'compact'
gridColumns: number
gridPopoverTrigger: 'hover' | 'click'
@ -164,16 +176,28 @@ export const initialState: SettingsState = {
clickAssistantToShowTopic: true,
autoCheckUpdate: true,
renderInputMessageAsMarkdown: false,
codeExecution: {
enabled: false,
timeoutMinutes: 1
},
codeEditor: {
enabled: false,
themeLight: 'auto',
themeDark: 'auto',
highlightActiveLine: false,
foldGutter: false,
autocompletion: true,
keymap: false
},
codePreview: {
themeLight: 'auto',
themeDark: 'auto'
},
codeShowLineNumbers: false,
codeCollapsible: false,
codeWrappable: false,
codeCacheable: false,
codeCacheMaxSize: 1000, // 缓存最大容量,千字符数
codeCacheTTL: 15, // 缓存过期时间,分钟
codeCacheThreshold: 2, // 允许缓存的最小代码长度,千字符数
mathEngine: 'KaTeX',
messageStyle: 'plain',
codeStyle: 'auto',
foldDisplayMode: 'expanded',
gridColumns: 2,
gridPopoverTrigger: 'click',
@ -353,6 +377,56 @@ const settingsSlice = createSlice({
setWebdavMaxBackups: (state, action: PayloadAction<number>) => {
state.webdavMaxBackups = action.payload
},
setCodeExecution: (state, action: PayloadAction<{ enabled?: boolean; timeoutMinutes?: number }>) => {
if (action.payload.enabled !== undefined) {
state.codeExecution.enabled = action.payload.enabled
}
if (action.payload.timeoutMinutes !== undefined) {
state.codeExecution.timeoutMinutes = action.payload.timeoutMinutes
}
},
setCodeEditor: (
state,
action: PayloadAction<{
enabled?: boolean
themeLight?: string
themeDark?: string
highlightActiveLine?: boolean
foldGutter?: boolean
autocompletion?: boolean
keymap?: boolean
}>
) => {
if (action.payload.enabled !== undefined) {
state.codeEditor.enabled = action.payload.enabled
}
if (action.payload.themeLight !== undefined) {
state.codeEditor.themeLight = action.payload.themeLight
}
if (action.payload.themeDark !== undefined) {
state.codeEditor.themeDark = action.payload.themeDark
}
if (action.payload.highlightActiveLine !== undefined) {
state.codeEditor.highlightActiveLine = action.payload.highlightActiveLine
}
if (action.payload.foldGutter !== undefined) {
state.codeEditor.foldGutter = action.payload.foldGutter
}
if (action.payload.autocompletion !== undefined) {
state.codeEditor.autocompletion = action.payload.autocompletion
}
if (action.payload.keymap !== undefined) {
state.codeEditor.keymap = action.payload.keymap
}
},
setCodePreview: (state, action: PayloadAction<{ themeLight?: string; themeDark?: string }>) => {
if (action.payload.themeLight !== undefined) {
state.codePreview.themeLight = action.payload.themeLight
}
if (action.payload.themeDark !== undefined) {
state.codePreview.themeDark = action.payload.themeDark
}
},
setCodeShowLineNumbers: (state, action: PayloadAction<boolean>) => {
state.codeShowLineNumbers = action.payload
},
@ -362,18 +436,6 @@ const settingsSlice = createSlice({
setCodeWrappable: (state, action: PayloadAction<boolean>) => {
state.codeWrappable = action.payload
},
setCodeCacheable: (state, action: PayloadAction<boolean>) => {
state.codeCacheable = action.payload
},
setCodeCacheMaxSize: (state, action: PayloadAction<number>) => {
state.codeCacheMaxSize = action.payload
},
setCodeCacheTTL: (state, action: PayloadAction<number>) => {
state.codeCacheTTL = action.payload
},
setCodeCacheThreshold: (state, action: PayloadAction<number>) => {
state.codeCacheThreshold = action.payload
},
setMathEngine: (state, action: PayloadAction<MathEngine>) => {
state.mathEngine = action.payload
},
@ -389,9 +451,6 @@ const settingsSlice = createSlice({
setMessageStyle: (state, action: PayloadAction<'plain' | 'bubble'>) => {
state.messageStyle = action.payload
},
setCodeStyle: (state, action: PayloadAction<CodeStyleVarious>) => {
state.codeStyle = action.payload
},
setTranslateModelPrompt: (state, action: PayloadAction<string>) => {
state.translateModelPrompt = action.payload
},
@ -559,19 +618,17 @@ export const {
setWebdavAutoSync,
setWebdavSyncInterval,
setWebdavMaxBackups,
setCodeExecution,
setCodeEditor,
setCodePreview,
setCodeShowLineNumbers,
setCodeCollapsible,
setCodeWrappable,
setCodeCacheable,
setCodeCacheMaxSize,
setCodeCacheTTL,
setCodeCacheThreshold,
setMathEngine,
setFoldDisplayMode,
setGridColumns,
setGridPopoverTrigger,
setMessageStyle,
setCodeStyle,
setTranslateModelPrompt,
setAutoTranslateWithSpace,
setShowTranslateConfirm,

View File

@ -2,7 +2,6 @@ import type { WebSearchResultBlock } from '@anthropic-ai/sdk/resources'
import type { GroundingMetadata } from '@google/genai'
import type OpenAI from 'openai'
import React from 'react'
import { BuiltinTheme } from 'shiki'
import type { Message } from './newMessage'
@ -306,7 +305,7 @@ export type TranslateLanguageVarious =
| 'portuguese'
| 'russian'
export type CodeStyleVarious = BuiltinTheme | 'auto'
export type CodeStyleVarious = 'auto' | string
export type WebDavConfig = {
webdavHost: string

View File

@ -1,6 +1,15 @@
// import remarkParse from 'remark-parse'
// import { unified } from 'unified'
// import { visit } from 'unist-util-visit'
import { describe, expect, it } from 'vitest'
import { convertMathFormula, findCitationInChildren, removeTrailingDoubleSpaces } from '../markdown'
import {
convertMathFormula,
findCitationInChildren,
getCodeBlockId,
removeTrailingDoubleSpaces,
updateCodeBlock
} from '../markdown'
describe('markdown', () => {
describe('findCitationInChildren', () => {
@ -131,4 +140,186 @@ describe('markdown', () => {
expect(result).toBe('')
})
})
describe('getCodeBlockId', () => {
it('should generate ID from position information', () => {
// 从位置信息生成ID
const start = { line: 10, column: 5, offset: 123 }
const result = getCodeBlockId(start)
expect(result).toBe('10:5:123')
})
it('should handle zero position values', () => {
// 处理零值位置
const start = { line: 1, column: 0, offset: 0 }
const result = getCodeBlockId(start)
expect(result).toBe('1:0:0')
})
it('should return null for null or undefined input', () => {
// 处理null或undefined输入
expect(getCodeBlockId(null)).toBeNull()
expect(getCodeBlockId(undefined)).toBeNull()
})
it('should handle missing properties in position object', () => {
// 处理缺少属性的位置对象
const invalidStart = { line: 5 }
const result = getCodeBlockId(invalidStart)
expect(result).toBe('5:undefined:undefined')
})
})
describe('updateCodeBlock', () => {
/**
* ID
*
* 使
* 1.
* 2. ID
* 3. ID ID
* 4.
*/
// function getAllCodeBlockIds(markdown: string): { [content: string]: string } {
// const result: { [content: string]: string } = {}
// const tree = unified().use(remarkParse).parse(markdown)
// visit(tree, 'code', (node) => {
// const id = getCodeBlockId(node.position?.start)
// if (id) {
// result[node.value] = id
// console.log(`Code Block ID: "${id}" for content: "${node.value}" lang: "${node.lang}"`)
// }
// })
// return result
// }
it('should format content using remark-stringify', () => {
const markdown = '# Test\n```js\nvar x = 1;\n```'
const expectedResult = '# Test\n\n```js\nvar x = 1;\n```\n'
const actualId = '2:1:7'
const newContent = 'var x = 1;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, actualId, newContent)
expect(result).toBe(expectedResult)
})
it('should update code block content when ID matches', () => {
const markdown = '# Test\n```js\nvar x = 1;\n```\nOther content'
const expectedResult = '# Test\n\n```js\nconst x = 2;\n```\n\nOther content\n'
const actualId = '2:1:7'
const newContent = 'const x = 2;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, actualId, newContent)
expect(result).toBe(expectedResult)
})
it('should not modify content when code block ID does not match', () => {
const markdown = '# Test\n```js\nvar x = 1;\n```\nOther content'
const wrongId = 'non-existent-id'
const newContent = 'const x = 2;'
const result = updateCodeBlock(markdown, wrongId, newContent)
expect(result).toContain('var x = 1;')
expect(result).not.toContain(newContent)
})
it('should preserve code block language tag', () => {
const markdown = '# Title\n\n```python\nprint("Hello")\n```\n'
const expectedResult = '# Title\n\n```python\nprint("Updated")\n```\n'
const pythonBlockId = '3:1:9'
const newContent = 'print("Updated")'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, pythonBlockId, newContent)
expect(result).toBe(expectedResult)
})
it('should only update the code block with matching ID when multiple blocks exist', () => {
const markdown = '```js\nvar x = 1;\n```\n\n```py\nprint("test")\n```'
const expectedResult = '```js\nconst y = 2;\n```\n\n```py\nprint("test")\n```\n'
const firstBlockId = '1:1:0'
const newContent = 'const y = 2;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, firstBlockId, newContent)
expect(result).toBe(expectedResult)
})
it('should only update the second of two identical code blocks', () => {
// 创建包含两个相同内容代码块的Markdown文本和代码块交替出现
const markdown =
'# Heading\n\nFirst paragraph.\n\n```js\nconst value = 100;\n```\n\nMiddle paragraph with some text.\n\n```js\nconst value = 100;\n```\n\nFinal text paragraph.'
const expectedResult =
'# Heading\n\nFirst paragraph.\n\n```js\nconst value = 100;\n```\n\nMiddle paragraph with some text.\n\n```js\nconst updatedValue = 200;\n```\n\nFinal text paragraph.\n'
const secondBlockId = '11:1:93'
const newContent = 'const updatedValue = 200;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, secondBlockId, newContent)
expect(result).toBe(expectedResult)
})
it('should handle code blocks with special characters', () => {
const markdown = '```js\nconst special = "\\n\\t\\"\\u{1F600}";\n```'
const expectedResult = '```js\nconst updated = true;\n```\n'
const blockId = '1:1:0'
const newContent = 'const updated = true;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, blockId, newContent)
expect(result).toBe(expectedResult)
})
it('should handle empty code blocks', () => {
const markdown = '```js\n\n```'
const expectedResult = '```js\nconsole.log("no longer empty");\n```\n'
const blockId = '1:1:0'
const newContent = 'console.log("no longer empty");'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, blockId, newContent)
expect(result).toBe(expectedResult)
})
it('should handle code blocks with indentation', () => {
const markdown = ' ```js\n const indented = true;\n ```'
const expectedResult = '```js\nconst noLongerIndented = true;\n```\n'
const blockId = '1:3:2'
const newContent = 'const noLongerIndented = true;'
// getAllCodeBlockIds(markdown)
const result = updateCodeBlock(markdown, blockId, newContent)
expect(result).toBe(expectedResult)
})
})
})

View File

@ -0,0 +1,198 @@
import { splitToSubTrunks } from '@renderer/services/ShikiStreamTokenizer'
import type { ThemedToken } from 'shiki/types'
import { describe, expect, it } from 'vitest'
import { getReactStyleFromToken } from '../shiki'
// FontStyle 常量,避免类型错误
const FS_ITALIC = 1
const FS_BOLD = 2
const FS_UNDERLINE = 4
/**
* ThemedToken
* 使
*/
function createThemedToken(partial: Partial<ThemedToken> = {}): ThemedToken {
return {
content: 'default-content',
offset: 0,
...partial
}
}
describe('shiki', () => {
describe('splitToSubTrunks', () => {
it('should return the original string when there is no newline', () => {
const chunk = 'console.log("Hello world")'
const result = splitToSubTrunks(chunk)
expect(result).toEqual([chunk])
})
it('should split string with one newline into two parts', () => {
const chunk = 'const x = 5;\nconsole.log(x)'
const result = splitToSubTrunks(chunk)
expect(result).toEqual(['const x = 5;', 'console.log(x)'])
})
it('should split by the last newline when multiple newlines exist', () => {
const chunk = 'const x = 5;\nconst y = 10;\nconsole.log(x + y)'
const result = splitToSubTrunks(chunk)
expect(result).toEqual(['const x = 5;\nconst y = 10;', 'console.log(x + y)'])
})
it('should handle string ending with a newline', () => {
const chunk = 'const x = 5;\nconst y = 10;\n'
const result = splitToSubTrunks(chunk)
expect(result).toEqual(['const x = 5;\nconst y = 10;', ''])
})
it('should handle empty string', () => {
const chunk = ''
const result = splitToSubTrunks(chunk)
expect(result).toEqual([''])
})
})
describe('getReactStyleFromToken', () => {
it('should get styles from token htmlStyle', () => {
const token = createThemedToken({
content: 'test',
htmlStyle: {
'font-style': 'italic',
'font-weight': 'bold',
'background-color': '#f5f5f5',
'text-decoration': 'underline',
color: '#ff0000'
}
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
fontStyle: 'italic',
fontWeight: 'bold',
backgroundColor: '#f5f5f5',
textDecoration: 'underline',
color: '#ff0000'
})
})
it('should use getTokenStyleObject when htmlStyle is not available', () => {
const token = createThemedToken({
content: 'test',
color: '#ff0000',
fontStyle: FS_ITALIC
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
fontStyle: 'italic',
color: '#ff0000'
})
})
it('should properly convert all CSS properties to React style', () => {
const token = createThemedToken({
content: 'test',
htmlStyle: {
'font-style': 'italic',
'font-weight': 'bold',
'background-color': '#f5f5f5',
'text-decoration': 'underline',
color: '#ff0000',
'font-family': 'monospace',
'border-radius': '2px'
}
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
fontStyle: 'italic',
fontWeight: 'bold',
backgroundColor: '#f5f5f5',
textDecoration: 'underline',
color: '#ff0000',
'font-family': 'monospace',
'border-radius': '2px'
})
})
it('should keep other CSS property names unchanged', () => {
const token = createThemedToken({
content: 'const',
offset: 0,
htmlStyle: {
color: '#FF0000',
opacity: '0.8',
border: '1px solid black'
}
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
color: '#FF0000',
opacity: '0.8',
border: '1px solid black'
})
})
it('should handle complex style combinations', () => {
const token = createThemedToken({
content: 'const',
offset: 0,
htmlStyle: {
color: '#FF0000',
'font-style': 'italic',
'font-weight': 'bold',
'background-color': '#EEEEEE',
'text-decoration': 'underline',
opacity: '0.8',
border: '1px solid black'
}
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
color: '#FF0000',
fontStyle: 'italic',
fontWeight: 'bold',
backgroundColor: '#EEEEEE',
textDecoration: 'underline',
opacity: '0.8',
border: '1px solid black'
})
})
it('should handle multiple fontStyle values', () => {
const token = createThemedToken({
content: 'const',
offset: 0,
color: '#0000FF',
fontStyle: FS_BOLD | FS_UNDERLINE
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({
color: '#0000FF',
fontWeight: 'bold',
textDecoration: 'underline'
})
})
it('should handle tokens with no style', () => {
const token = createThemedToken({
content: 'const',
offset: 0
})
const result = getReactStyleFromToken(token)
expect(result).toEqual({})
})
})
})

View File

@ -1,3 +1,8 @@
import remarkParse from 'remark-parse'
import remarkStringify from 'remark-stringify'
import { unified } from 'unified'
import { visit } from 'unist-util-visit'
// 更彻底的查找方法,递归搜索所有子元素
export const findCitationInChildren = (children) => {
if (!children) return null
@ -44,6 +49,15 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
return markdown.replace(/ {2}$/gm, '')
}
/**
* ID
* @param start
* @returns Markdown ID
*/
export function getCodeBlockId(start: any): string | null {
return start ? `${start.line}:${start.column}:${start.offset}` : null
}
/**
* HTML实体编码辅助函数
* @param str
@ -61,3 +75,42 @@ export const encodeHTML = (str: string) => {
return entities[match]
})
}
/**
* Markdown字符串中的代码块内容
*
* 使remark-stringify
* -
* - trimmed
* -
*
* @param raw Markdown字符串
* @param id ID
* @param newContent
* @returns Markdown字符串
*/
export function updateCodeBlock(raw: string, id: string, newContent: string): string {
const tree = unified().use(remarkParse).parse(raw)
visit(tree, 'code', (node) => {
const startIndex = getCodeBlockId(node.position?.start)
if (startIndex && id && startIndex === id) {
node.value = newContent
}
})
return unified().use(remarkStringify).stringify(tree)
}
/**
* PlantUML
* @param code PlantUML
* @returns true false
*/
export function isValidPlantUML(code: string | null): boolean {
if (!code || !code.trim().startsWith('@start')) {
return false
}
const diagramType = code.match(/@start(\w+)/)?.[1]
return diagramType !== undefined && code.search(`@end${diagramType}`) !== -1
}

View File

@ -4,6 +4,37 @@ import { MarkdownItShikiOptions, setupMarkdownIt } from '@shikijs/markdown-it'
import MarkdownIt from 'markdown-it'
import { useEffect, useRef, useState } from 'react'
import { BuiltinLanguage, BuiltinTheme, bundledLanguages, createHighlighter } from 'shiki'
import { getTokenStyleObject, ThemedToken } from 'shiki/core'
/**
* Shiki token React
*
* @param token Shiki themed token
* @returns React
*/
export function getReactStyleFromToken(token: ThemedToken): Record<string, string> {
const style = token.htmlStyle || getTokenStyleObject(token)
const reactStyle: Record<string, string> = {}
for (const [key, value] of Object.entries(style)) {
switch (key) {
case 'font-style':
reactStyle.fontStyle = value
break
case 'font-weight':
reactStyle.fontWeight = value
break
case 'background-color':
reactStyle.backgroundColor = value
break
case 'text-decoration':
reactStyle.textDecoration = value
break
default:
reactStyle[key] = value
}
}
return reactStyle
}
const defaultOptions = {
themes: {

View File

@ -8,7 +8,7 @@ import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import AntdProvider from '../../context/AntdProvider'
import { SyntaxHighlighterProvider } from '../../context/SyntaxHighlighterProvider'
import { CodeStyleProvider } from '../../context/CodeStyleProvider'
import { ThemeProvider } from '../../context/ThemeProvider'
import HomeWindow from './home/HomeWindow'
@ -42,12 +42,12 @@ function MiniWindow(): React.ReactElement {
<Provider store={store}>
<ThemeProvider>
<AntdProvider>
<SyntaxHighlighterProvider>
<CodeStyleProvider>
<PersistGate loading={null} persistor={persistor}>
{messageContextHolder}
<MiniWindowContent />
</PersistGate>
</SyntaxHighlighterProvider>
</CodeStyleProvider>
</AntdProvider>
</ThemeProvider>
</Provider>

View File

@ -0,0 +1,155 @@
/// <reference lib="webworker" />
// 定义输出结构类型
interface PyodideOutput {
result: any
text: string | null
error: string | null
}
// 声明全局变量用于输出
let output: PyodideOutput = {
result: null,
text: null,
error: null
}
const pyodidePromise = (async () => {
// 重置输出变量
output = {
result: null,
text: null,
error: null
}
try {
// 动态加载 Pyodide 脚本
// @ts-ignore - 忽略动态导入错误
const pyodideModule = await import('https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.mjs')
// 加载 Pyodide 并捕获标准输出/错误
return await pyodideModule.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.27.5/full/',
stdout: (text: string) => {
if (output.text) {
output.text += `${text}\n`
} else {
output.text = `${text}\n`
}
},
stderr: (text: string) => {
if (output.error) {
output.error += `${text}\n`
} else {
output.error = `${text}\n`
}
}
})
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Failed to load Pyodide:', errorMessage)
// 通知主线程初始化错误
self.postMessage({
type: 'error',
error: errorMessage
})
throw error
}
})()
// 处理结果,确保所有类型都能安全序列化
function processResult(result: any): any {
try {
if (result && typeof result.toJs === 'function') {
return processResult(result.toJs())
}
if (Array.isArray(result)) {
return result.map((item) => processResult(item))
}
if (typeof result === 'object' && result !== null) {
return Object.fromEntries(Object.entries(result).map(([key, value]) => [key, processResult(value)]))
}
return result
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Result processing error:', errorMessage)
return { __error__: 'Result processing failed', details: errorMessage }
}
}
// 通知主线程已加载
pyodidePromise
.then(() => {
self.postMessage({ type: 'initialized' })
})
.catch((error: unknown) => {
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Failed to load Pyodide:', errorMessage)
self.postMessage({ type: 'error', error: errorMessage })
})
// 处理消息
self.onmessage = async (event) => {
const { id, python, context } = event.data
// 重置输出变量
output = {
result: null,
text: null,
error: null
}
try {
const pyodide = await pyodidePromise
// 将上下文变量设置为全局作用域变量
const globalContext: Record<string, any> = {}
for (const key of Object.keys(context || {})) {
globalContext[key] = context[key]
}
// 载入需要的包
try {
await pyodide.loadPackagesFromImports(python)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to load required packages: ${errorMessage}`)
}
// 创建 Python 上下文
const globals = pyodide.globals.get('dict')(Object.entries(context || {}))
// 执行代码
try {
output.result = await pyodide.runPythonAsync(python, { globals })
// 处理结果,确保安全序列化
output.result = processResult(output.result)
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error)
// 不设置 output.result但设置错误信息
if (output.error) {
output.error += `\nExecution error:\n${errorMessage}`
} else {
output.error = `Execution error:\n${errorMessage}`
}
}
} catch (error: unknown) {
// 处理所有其他错误
const errorMessage = error instanceof Error ? error.message : String(error)
console.error('Python processing error:', errorMessage)
if (output.error) {
output.error += `\nSystem error:\n${errorMessage}`
} else {
output.error = `System error:\n${errorMessage}`
}
} finally {
// 统一发送处理后的输出对象
self.postMessage({ id, output })
}
}

View File

@ -0,0 +1,236 @@
/// <reference lib="webworker" />
import { LRUCache } from 'lru-cache'
import type { HighlighterCore, SpecialLanguage, ThemedToken } from 'shiki/core'
// 注意保持 ShikiStreamTokenizer 依赖简单,避免打包出问题
import { ShikiStreamTokenizer, ShikiStreamTokenizerOptions } from '../services/ShikiStreamTokenizer'
// Worker 消息类型
type WorkerMessageType = 'init' | 'highlight' | 'cleanup' | 'dispose'
interface WorkerRequest {
id: number
type: WorkerMessageType
callerId?: string
chunk?: string
language?: string
theme?: string
languages?: string[]
themes?: string[]
}
interface WorkerResponse {
id: number
type: string
result?: any
error?: string
}
interface HighlightChunkResult {
lines: ThemedToken[][]
recall: number
}
// Worker 全局变量
let highlighter: HighlighterCore | null = null
// 保存以 callerId-language-theme 为键的 tokenizer map
const tokenizerMap = new LRUCache<string, ShikiStreamTokenizer>({
max: 100, // 最大缓存数量
ttl: 1000 * 60 * 15, // 15分钟过期时间
updateAgeOnGet: true,
dispose: (value) => {
if (value) value.clear()
}
})
// 初始化高亮器
async function initHighlighter(themes: string[], languages: string[]): Promise<void> {
const { createHighlighter } = await import('shiki')
highlighter = await createHighlighter({
langs: languages,
themes: themes
})
}
// 确保语言和主题已加载
async function ensureLanguageAndThemeLoaded(
language: string,
theme: string
): Promise<{ actualLanguage: string; actualTheme: string }> {
if (!highlighter) {
throw new Error('Highlighter not initialized')
}
let actualLanguage = language
let actualTheme = theme
// 加载语言
if (!highlighter.getLoadedLanguages().includes(language)) {
try {
if (['text', 'ansi'].includes(language)) {
await highlighter.loadLanguage(language as SpecialLanguage)
} else {
const { bundledLanguages } = await import('shiki')
const languageImportFn = bundledLanguages[language]
const langData = await languageImportFn()
await highlighter.loadLanguage(langData)
}
} catch (error) {
// 回退到 text
await highlighter.loadLanguage('text')
actualLanguage = 'text'
}
}
// 加载主题
if (!highlighter.getLoadedThemes().includes(theme)) {
try {
const { bundledThemes } = await import('shiki')
const themeImportFn = bundledThemes[theme]
const themeData = await themeImportFn()
await highlighter.loadTheme(themeData)
} catch (error) {
// 回退到 one-light
console.debug(`Worker: Failed to load theme '${theme}', falling back to 'one-light':`, error)
const { bundledThemes } = await import('shiki')
const oneLightTheme = await bundledThemes['one-light']()
await highlighter.loadTheme(oneLightTheme)
actualTheme = 'one-light'
}
}
return { actualLanguage, actualTheme }
}
// 获取或创建 tokenizer
async function getStreamTokenizer(callerId: string, language: string, theme: string): Promise<ShikiStreamTokenizer> {
// 创建复合键
const cacheKey = `${callerId}-${language}-${theme}`
// 如果已存在,直接返回
if (tokenizerMap.has(cacheKey)) {
return tokenizerMap.get(cacheKey)!
}
if (!highlighter) {
throw new Error('Highlighter not initialized')
}
// 确保语言和主题已加载
const { actualLanguage, actualTheme } = await ensureLanguageAndThemeLoaded(language, theme)
// 创建新的 tokenizer
const options: ShikiStreamTokenizerOptions = {
highlighter,
lang: actualLanguage,
theme: actualTheme
}
const tokenizer = new ShikiStreamTokenizer(options)
tokenizerMap.set(cacheKey, tokenizer)
return tokenizer
}
// 高亮代码 chunk
async function highlightCodeChunk(
callerId: string,
chunk: string,
language: string,
theme: string
): Promise<HighlightChunkResult> {
try {
// 获取 tokenizer
const tokenizer = await getStreamTokenizer(callerId, language, theme)
// 处理代码 chunk
const result = await tokenizer.enqueue(chunk)
// 返回结果
return {
lines: [...result.stable, ...result.unstable],
recall: result.recall
}
} catch (error) {
console.error('Worker failed to highlight code chunk:', error)
// 提供简单的 fallback
const fallbackToken: ThemedToken = { content: chunk || '', color: '#000000', offset: 0 }
return {
lines: [[fallbackToken]],
recall: 0
}
}
}
// 清理特定调用者的 tokenizer
function cleanupTokenizer(callerId: string): void {
// 清理所有以callerId开头的缓存
for (const key of tokenizerMap.keys()) {
if (key.startsWith(`${callerId}-`)) {
tokenizerMap.delete(key)
}
}
}
// 定义 worker 上下文类型
declare const self: DedicatedWorkerGlobalScope
// 监听消息
self.onmessage = async (e: MessageEvent<WorkerRequest>) => {
const { id, type } = e.data
try {
switch (type) {
case 'init':
if (e.data.languages && e.data.themes) {
await initHighlighter(e.data.themes, e.data.languages)
self.postMessage({ id, type: 'init-result', result: { success: true } } as WorkerResponse)
} else {
throw new Error('Missing required init parameters')
}
break
case 'highlight':
if (!highlighter) {
throw new Error('Highlighter not initialized')
}
if (e.data.callerId && e.data.chunk && e.data.language && e.data.theme) {
const result = await highlightCodeChunk(e.data.callerId, e.data.chunk, e.data.language, e.data.theme)
self.postMessage({ id, type: 'highlight-result', result } as WorkerResponse)
} else {
throw new Error('Missing required highlight parameters')
}
break
case 'cleanup':
if (e.data.callerId) {
cleanupTokenizer(e.data.callerId)
self.postMessage({ id, type: 'cleanup-result', result: { success: true } } as WorkerResponse)
} else {
throw new Error('Missing callerId for cleanup')
}
break
case 'dispose':
tokenizerMap.clear()
highlighter?.dispose()
highlighter = null
self.postMessage({ id, type: 'dispose-result', result: { success: true } } as WorkerResponse)
break
default:
throw new Error(`Unknown command: ${type}`)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
self.postMessage({
id,
type: 'error',
error: errorMessage
} as WorkerResponse)
}
}

View File

@ -10,6 +10,7 @@
"composite": true,
"jsx": "react-jsx",
"baseUrl": ".",
"moduleResolution": "bundler",
"paths": {
"@renderer/*": ["src/renderer/src/*"],
"@shared/*": ["packages/shared/*"],

View File

@ -15,11 +15,11 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/renderer/__tests__/setup.ts'],
setupFiles: ['@vitest/web-worker', './src/renderer/__tests__/setup.ts'],
include: [
// 只测试渲染进程
'src/renderer/**/*.{test,spec}.{ts,tsx}',
'src/renderer/**/__tests__/**/*.{ts,tsx}'
'src/renderer/**/__tests__/**/*.{test,spec}.{ts,tsx}'
],
exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**', '**/src/renderer/__tests__/setup.ts'],
coverage: {

2264
yarn.lock

File diff suppressed because it is too large Load Diff