mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
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:
parent
c6b87b307b
commit
2dedd95fcc
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
285
src/renderer/src/components/CodeBlockView/CodePreview.tsx
Normal file
285
src/renderer/src/components/CodeBlockView/CodePreview.tsx
Normal 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)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
99
src/renderer/src/components/CodeBlockView/MermaidPreview.tsx
Normal file
99
src/renderer/src/components/CodeBlockView/MermaidPreview.tsx
Normal 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)
|
||||
193
src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx
Normal file
193
src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx
Normal 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)
|
||||
22
src/renderer/src/components/CodeBlockView/StatusBar.tsx
Normal file
22
src/renderer/src/components/CodeBlockView/StatusBar.tsx
Normal 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)
|
||||
38
src/renderer/src/components/CodeBlockView/SvgPreview.tsx
Normal file
38
src/renderer/src/components/CodeBlockView/SvgPreview.tsx
Normal 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)
|
||||
324
src/renderer/src/components/CodeBlockView/index.tsx
Normal file
324
src/renderer/src/components/CodeBlockView/index.tsx
Normal 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: 特殊视图模式(Mermaid、PlantUML、SVG)
|
||||
* - 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)
|
||||
251
src/renderer/src/components/CodeEditor/index.tsx
Normal file
251
src/renderer/src/components/CodeEditor/index.tsx
Normal 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)
|
||||
76
src/renderer/src/components/CodeToolbar/constants.ts
Normal file
76
src/renderer/src/components/CodeToolbar/constants.ts
Normal 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
|
||||
}
|
||||
}
|
||||
71
src/renderer/src/components/CodeToolbar/context.tsx
Normal file
71
src/renderer/src/components/CodeToolbar/context.tsx
Normal 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
|
||||
}
|
||||
5
src/renderer/src/components/CodeToolbar/index.ts
Normal file
5
src/renderer/src/components/CodeToolbar/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './context'
|
||||
export * from './toolbar'
|
||||
export * from './types'
|
||||
export * from './usePreviewTools'
|
||||
119
src/renderer/src/components/CodeToolbar/toolbar.tsx
Normal file
119
src/renderer/src/components/CodeToolbar/toolbar.tsx
Normal 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);
|
||||
}
|
||||
`
|
||||
35
src/renderer/src/components/CodeToolbar/types.ts
Normal file
35
src/renderer/src/components/CodeToolbar/types.ts
Normal 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
|
||||
}
|
||||
360
src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
Normal file
360
src/renderer/src/components/CodeToolbar/usePreviewTools.tsx
Normal 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])
|
||||
}
|
||||
68
src/renderer/src/components/Icons/DownloadIcons.tsx
Normal file
68
src/renderer/src/components/Icons/DownloadIcons.tsx
Normal 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} />
|
||||
149
src/renderer/src/context/CodeStyleProvider.tsx
Normal file
149
src/renderer/src/context/CodeStyleProvider.tsx
Normal 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
|
||||
}
|
||||
@ -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) => ({ '<': '<', '>': '>' })[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[]
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@ -19,7 +19,6 @@ declare global {
|
||||
message: MessageInstance
|
||||
modal: HookAPI
|
||||
keyv: KeyvStorage
|
||||
mermaid: any
|
||||
store: any
|
||||
navigate: NavigateFunction
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "このテキストを要約してください",
|
||||
|
||||
@ -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": "Суммируйте этот текст",
|
||||
|
||||
@ -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": "帮我总结一下这段话",
|
||||
|
||||
@ -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": "幫我總結一下這段話",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
@ -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;
|
||||
`
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
@ -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())
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
|
||||
231
src/renderer/src/services/PyodideService.ts
Normal file
231
src/renderer/src/services/PyodideService.ts
Normal 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()
|
||||
497
src/renderer/src/services/ShikiStreamService.ts
Normal file
497
src/renderer/src/services/ShikiStreamService.ts
Normal 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()
|
||||
111
src/renderer/src/services/ShikiStreamTokenizer.ts
Normal file
111
src/renderer/src/services/ShikiStreamTokenizer.ts
Normal 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)]
|
||||
}
|
||||
293
src/renderer/src/services/__tests__/ShikiStreamService.test.ts
Normal file
293
src/renderer/src/services/__tests__/ShikiStreamService.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
200
src/renderer/src/services/__tests__/ShikiStreamTokenizer.test.ts
Normal file
200
src/renderer/src/services/__tests__/ShikiStreamTokenizer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串按指定长度 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
|
||||
}
|
||||
@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 100,
|
||||
version: 102,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
198
src/renderer/src/utils/__tests__/shiki.test.ts
Normal file
198
src/renderer/src/utils/__tests__/shiki.test.ts
Normal 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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
155
src/renderer/src/workers/pyodide.worker.ts
Normal file
155
src/renderer/src/workers/pyodide.worker.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
236
src/renderer/src/workers/shiki-stream.worker.ts
Normal file
236
src/renderer/src/workers/shiki-stream.worker.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@renderer/*": ["src/renderer/src/*"],
|
||||
"@shared/*": ["packages/shared/*"],
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user