diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 9a70fac51d..0a435c5493 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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 + } } } } diff --git a/package.json b/package.json index 819042befe..3459bdb54d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 52b098c957..24024374ec 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { - + @@ -46,7 +46,7 @@ function App(): React.ReactElement { - + diff --git a/src/renderer/src/assets/styles/markdown.scss b/src/renderer/src/assets/styles/markdown.scss index e24569b0f2..c96e5b2f58 100644 --- a/src/renderer/src/assets/styles/markdown.scss +++ b/src/renderer/src/assets/styles/markdown.scss @@ -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; + } + } +} diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx new file mode 100644 index 0000000000..f63f3cecba --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -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([]) + const codeContentRef = useRef(null) + const prevCodeLengthRef = useRef(0) + const safeCodeStringRef = useRef(children) + const highlightQueueRef = useRef>(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 ? : , + 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 ? : , + 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 ( + + {tokenLines.length > 0 ? ( + + ) : ( +
{children}
+ )} +
+ ) +} + +/** + * 渲染 Shiki 高亮后的 tokens + * + * 独立出来,方便将来做 virtual list + */ +const ShikiTokensRenderer: React.FC<{ language: string; tokenLines: ThemedToken[][] }> = memo( + ({ language, tokenLines }) => { + const { getShikiPreProperties } = useCodeStyle() + const rendererRef = useRef(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 ( +
+        
+          {tokenLines.map((lineTokens, lineIndex) => (
+            
+              {lineTokens.map((token, tokenIndex) => (
+                
+                  {token.content}
+                
+              ))}
+            
+          ))}
+        
+      
+ ) + } +) + +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) diff --git a/src/renderer/src/pages/home/Markdown/Artifacts.tsx b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx similarity index 84% rename from src/renderer/src/pages/home/Markdown/Artifacts.tsx rename to src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx index 746eb170c5..e979ea1541 100644 --- a/src/renderer/src/pages/home/Markdown/Artifacts.tsx +++ b/src/renderer/src/components/CodeBlockView/HtmlArtifacts.tsx @@ -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 = ({ html }) => { } } - /** - * 下载文件 - */ - const onDownload = () => { - window.api.file.save(`${title}.html`, html) - } - return ( - - ) } diff --git a/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx new file mode 100644 index 0000000000..f02261c466 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/MermaidPreview.tsx @@ -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 = ({ children }) => { + const { mermaid, isLoading, error: mermaidError } = useMermaid() + const mermaidRef = useRef(null) + const [error, setError] = useState(null) + const diagramId = useRef(`mermaid-${nanoid(6)}`).current + const errorTimeoutRef = useRef(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 ( + + {(mermaidError || error) && {mermaidError || error}} + + + ) +} + +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) diff --git a/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx new file mode 100644 index 0000000000..9af10a5aa7 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/PlantUmlPreview.tsx @@ -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 + 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 = ({ format, diagram, onClick, className }) => { + const [loading, setLoading] = useState(true) + // FIXME: 黑暗模式背景太黑了,目前让 PlantUML 和 SVG 一样保持白色背景 + const url = getPlantUMLImageUrl(format, diagram, false) + return ( + + + }> + { + setLoading(false) + }} + onError={(e) => { + setLoading(false) + const target = e.target as HTMLImageElement + target.style.opacity = '0.5' + target.style.filter = 'blur(2px)' + }} + /> + + + ) +} + +interface PlantUMLProps { + children: string +} + +const PlantUmlPreview: React.FC = ({ children }) => { + const { t } = useTranslation() + const containerRef = useRef(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 ( +
+ +
+ ) +} + +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) diff --git a/src/renderer/src/components/CodeBlockView/StatusBar.tsx b/src/renderer/src/components/CodeBlockView/StatusBar.tsx new file mode 100644 index 0000000000..7e4c5e9e04 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/StatusBar.tsx @@ -0,0 +1,22 @@ +import { FC, memo } from 'react' +import styled from 'styled-components' + +interface Props { + children: string +} + +const StatusBar: FC = ({ children }) => { + return {children} +} + +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) diff --git a/src/renderer/src/components/CodeBlockView/SvgPreview.tsx b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx new file mode 100644 index 0000000000..1e1f20b60e --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/SvgPreview.tsx @@ -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 = ({ children }) => { + const svgContainerRef = useRef(null) + + // 使用通用图像工具 + const { handleCopyImage, handleDownload } = usePreviewToolHandlers(svgContainerRef, { + imgSelector: '.svg-preview svg', + prefix: 'svg-image' + }) + + // 使用工具栏 + usePreviewTools({ + handleCopyImage, + handleDownload + }) + + return ( + + ) +} + +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) diff --git a/src/renderer/src/components/CodeBlockView/index.tsx b/src/renderer/src/components/CodeBlockView/index.tsx new file mode 100644 index 0000000000..86a5f0d043 --- /dev/null +++ b/src/renderer/src/components/CodeBlockView/index.tsx @@ -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 = ({ children, language, onSave }) => { + const { t } = useTranslation() + const { codeEditor, codeExecution } = useSettings() + const [viewMode, setViewMode] = useState('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('')) { + 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: , + tooltip: t('code_block.copy.source'), + onClick: handleCopySource + }) + + // 下载按钮 + registerTool({ + ...TOOL_SPECS.download, + 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' ? : , + tooltip: viewMode === 'source' ? t('code_block.preview') : t('code_block.edit'), + onClick: () => setViewMode(viewMode === 'source' ? 'special' : 'source') + }) + } else { + registerTool({ + ...viewSourceToolSpec, + icon: viewMode === 'source' ? : , + 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' ? : , + 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 ? : , + 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 ( + + {children} + + ) + }, [children, codeEditor.enabled, language, onSave]) + + // 特殊视图组件映射 + const specialView = useMemo(() => { + if (language === 'mermaid') { + return {children} + } else if (language === 'plantuml' && isValidPlantUML(children)) { + return {children} + } else if (language === 'svg') { + return {children} + } + return null + }, [children, language]) + + const renderHeader = useMemo(() => { + const langTag = '<' + language.toUpperCase() + '>' + return {isInSpecialView ? '' : langTag} + }, [isInSpecialView, language]) + + // 根据视图模式和语言选择组件,优先展示特殊视图,fallback是源代码视图 + const renderContent = useMemo(() => { + const showSpecialView = specialView && ['special', 'split'].includes(viewMode) + const showSourceView = !specialView || viewMode !== 'special' + + return ( + + {showSpecialView && specialView} + {showSourceView && sourceView} + + ) + }, [specialView, sourceView, viewMode]) + + const renderArtifacts = useMemo(() => { + if (language === 'html') { + return + } + return null + }, [children, language]) + + return ( + + {renderHeader} + + {renderContent} + {renderArtifacts} + {isExecutable && output && {output}} + + ) +} + +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) diff --git a/src/renderer/src/components/CodeEditor/index.tsx b/src/renderer/src/components/CodeEditor/index.tsx new file mode 100644 index 0000000000..744abf7f2a --- /dev/null +++ b/src/renderer/src/components/CodeEditor/index.tsx @@ -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() + +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([]) + const [editorReady, setEditorReady] = useState(false) + const editorViewRef = useRef(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 ? : , + 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 ? : , + 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: , + 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 ( + { + 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) diff --git a/src/renderer/src/components/CodeToolbar/constants.ts b/src/renderer/src/components/CodeToolbar/constants.ts new file mode 100644 index 0000000000..00e7fa7958 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/constants.ts @@ -0,0 +1,76 @@ +import { CodeToolSpec } from './types' + +export const TOOL_SPECS: Record = { + // 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 + } +} diff --git a/src/renderer/src/components/CodeToolbar/context.tsx b/src/renderer/src/components/CodeToolbar/context.tsx new file mode 100644 index 0000000000..32be179d85 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/context.tsx @@ -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) => void +} + +const defaultCodeToolbarContext: CodeToolbarContextType = { + tools: [], + context: defaultContext, + registerTool: () => {}, + removeTool: () => {}, + updateContext: () => {} +} + +const CodeToolbarContext = createContext(defaultCodeToolbarContext) + +export const CodeToolbarProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [tools, setTools] = useState([]) + const [context, setContext] = useState(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) => { + setContext((prev) => ({ ...prev, ...newContext })) + }, []) + + const value: CodeToolbarContextType = useMemo( + () => ({ + tools, + context, + registerTool, + removeTool, + updateContext + }), + [tools, context, registerTool, removeTool, updateContext] + ) + + return {children} +} + +export const useCodeToolbar = () => { + const context = use(CodeToolbarContext) + if (!context) { + throw new Error('useCodeToolbar must be used within a CodeToolbarProvider') + } + return context +} diff --git a/src/renderer/src/components/CodeToolbar/index.ts b/src/renderer/src/components/CodeToolbar/index.ts new file mode 100644 index 0000000000..63d28e27f8 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/index.ts @@ -0,0 +1,5 @@ +export * from './constants' +export * from './context' +export * from './toolbar' +export * from './types' +export * from './usePreviewTools' diff --git a/src/renderer/src/components/CodeToolbar/toolbar.tsx b/src/renderer/src/components/CodeToolbar/toolbar.tsx new file mode 100644 index 0000000000..9a2f282bc3 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/toolbar.tsx @@ -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 = memo(({ tool }) => { + const { context } = useCodeToolbar() + + return ( + + tool.onClick(context)}>{tool.icon} + + ) +}) + +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) => ) + } + + return null + }, [quickTools, showQuickTools]) + + if (visibleTools.length === 0) { + return null + } + + return ( + + + {/* 有多个快捷工具时通过 more 按钮展示 */} + {quickToolButtons} + {quickTools.length > 1 && ( + + setShowQuickTools(!showQuickTools)} className={showQuickTools ? 'active' : ''}> + + + + )} + + {/* 始终显示核心工具 */} + {coreTools.map((tool) => ( + + ))} + + + ) +}) + +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); + } +` diff --git a/src/renderer/src/components/CodeToolbar/types.ts b/src/renderer/src/components/CodeToolbar/types.ts new file mode 100644 index 0000000000..83db869371 --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/types.ts @@ -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 +} diff --git a/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx new file mode 100644 index 0000000000..7cd49f95da --- /dev/null +++ b/src/renderer/src/components/CodeToolbar/usePreviewTools.tsx @@ -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, + 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((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 + 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: , + tooltip: t('code_block.preview.zoom_in'), + onClick: () => handleZoom(0.1) + }) + + // 缩小工具 + registerTool({ + ...TOOL_SPECS['zoom-out'], + icon: , + tooltip: t('code_block.preview.zoom_out'), + onClick: () => handleZoom(-0.1) + }) + } + + if (handleCopyImage) { + // 复制图片工具 + registerTool({ + ...TOOL_SPECS['copy-image'], + icon: , + tooltip: t('code_block.preview.copy.image'), + onClick: handleCopyImage + }) + } + + if (handleDownload) { + // 下载 SVG 工具 + registerTool({ + ...TOOL_SPECS['download-svg'], + icon: , + tooltip: t('code_block.download.svg'), + onClick: () => handleDownload('svg') + }) + + // 下载 PNG 工具 + registerTool({ + ...TOOL_SPECS['download-png'], + icon: , + 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]) +} diff --git a/src/renderer/src/components/Icons/DownloadIcons.tsx b/src/renderer/src/components/Icons/DownloadIcons.tsx new file mode 100644 index 0000000000..55c6f00f1a --- /dev/null +++ b/src/renderer/src/components/Icons/DownloadIcons.tsx @@ -0,0 +1,68 @@ +import { SVGProps } from 'react' + +// 基础下载图标 +export const DownloadIcon = (props: SVGProps) => ( + + + + + +) + +// 带有文件类型的下载图标基础组件 +const DownloadTypeIconBase = ({ type, ...props }: SVGProps & { type: string }) => ( + + + {type} + + + + + +) + +// JPG 文件下载图标 +export const DownloadJpgIcon = (props: SVGProps) => + +// PNG 文件下载图标 +export const DownloadPngIcon = (props: SVGProps) => + +// SVG 文件下载图标 +export const DownloadSvgIcon = (props: SVGProps) => diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx new file mode 100644 index 0000000000..050f225615 --- /dev/null +++ b/src/renderer/src/context/CodeStyleProvider.tsx @@ -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 + cleanupTokenizers: (callerId: string) => void + getShikiPreProperties: (language: string) => Promise + themeNames: string[] + activeShikiTheme: string + activeCmTheme: any + languageMap: Record +} + +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(defaultCodeStyleContext) + +export const CodeStyleProvider: React.FC = ({ 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 + }, []) + + 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 {children} +} + +export const useCodeStyle = () => { + const context = use(CodeStyleContext) + if (!context) { + throw new Error('useCodeStyle must be used within a CodeStyleProvider') + } + return context +} diff --git a/src/renderer/src/context/SyntaxHighlighterProvider.tsx b/src/renderer/src/context/SyntaxHighlighterProvider.tsx deleted file mode 100644 index 9e94665dbb..0000000000 --- a/src/renderer/src/context/SyntaxHighlighterProvider.tsx +++ /dev/null @@ -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 | 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 -} - -const SyntaxHighlighterContext = createContext(undefined) - -export const SyntaxHighlighterProvider: React.FC = ({ 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 = { - 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 `
${escapedCode}
` - } - } - }, - [highlighterTheme] - ) - - return {children} -} - -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[] diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 2ef23faf77..7674f15efd 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -19,7 +19,6 @@ declare global { message: MessageInstance modal: HookAPI keyv: KeyvStorage - mermaid: any store: any navigate: NavigateFunction } diff --git a/src/renderer/src/hooks/useMermaid.ts b/src/renderer/src/hooks/useMermaid.ts index 3bc5de12c0..1ef9b43069 100644 --- a/src/renderer/src/hooks/useMermaid.ts +++ b/src/renderer/src/hooks/useMermaid.ts @@ -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 | 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(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 + } } diff --git a/src/renderer/src/hooks/useMinappPopup.ts b/src/renderer/src/hooks/useMinappPopup.ts index e5c5adf4f7..578246cc06 100644 --- a/src/renderer/src/hooks/useMinappPopup.ts +++ b/src/renderer/src/hooks/useMinappPopup.ts @@ -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, diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 12dcef76ce..94ee6c07a9 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 4c743a4e34..fef52b7d58 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "このテキストを要約してください", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 30332b88a3..bda4157737 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Суммируйте этот текст", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index df40aacdbe..852a3ed89e 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "帮我总结一下这段话", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index de3c0a9593..980b946f45 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "幫我總結一下這段話", diff --git a/src/renderer/src/middlewares/extractReasoningMiddleware.ts b/src/renderer/src/middlewares/extractReasoningMiddleware.ts index 4c39925aaf..a466822d8c 100644 --- a/src/renderer/src/middlewares/extractReasoningMiddleware.ts +++ b/src/renderer/src/middlewares/extractReasoningMiddleware.ts @@ -14,12 +14,12 @@ function escapeRegExp(str: string) { } // 支持泛型 T,默认 T = { type: string; textDelta: string } -export function extractReasoningMiddleware({ - 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 0) { const prefix = afterSwitch && (isReasoning ? !isFirstReasoning : !isFirstText) ? separator : '' @@ -80,7 +80,7 @@ export function extractReasoningMiddleware void [key: string]: any } -const CodeBlock: React.FC = ({ children, className }) => { - const match = /language-(\w+)/.exec(className || '') || children?.includes('\n') - const { codeShowLineNumbers, fontSize, codeCollapsible, codeWrappable } = useSettings() +const CodeBlock: React.FC = ({ children, className, id, onSave }) => { + const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n') const language = match?.[1] ?? 'text' - // const [html, setHtml] = useState('') - const { codeToHtml } = useSyntaxHighlighter() - const [isExpanded, setIsExpanded] = useState(!codeCollapsible) - const [isUnwrapped, setIsUnwrapped] = useState(!codeWrappable) - const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false) - const codeContentRef = useRef(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 - } - - if (language === 'plantuml' && isValidPlantUML(children)) { - return - } - - if (language === 'svg') { - return ( - - - {''} - - - {children} - - ) - } + }, + [id, onSave] + ) return match ? ( - - - {'<' + language.toUpperCase() + '>'} - - - - {showDownloadButton && } - {codeWrappable && setIsUnwrapped(!isUnwrapped)} />} - {codeCollapsible && shouldShowExpandButton && ( - setIsExpanded(!isExpanded)} /> - )} - - - - - {codeCollapsible && ( - setIsExpanded(!isExpanded)} - showButton={shouldShowExpandButton} - /> - )} - {showFooterCopyButton && ( - - - - )} - {language === 'html' && children?.includes('') && } - + + + {children} + + ) : ( {children} @@ -181,268 +36,4 @@ const CodeBlock: React.FC = ({ 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 ( - - - {expanded ? : } - - - ) -} - -const ExpandButton: React.FC<{ - isExpanded: boolean - onClick: () => void - showButton: boolean -}> = ({ isExpanded, onClick, showButton }) => { - const { t } = useTranslation() - if (!showButton) return null - - return ( - -
{isExpanded ? t('code_block.collapse') : t('code_block.expand')}
-
- ) -} - -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 ( - - - {unwrapped ? ( - - ) : ( - - )} - - - ) -} - -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 ( - - - {copied ? : } - - - ) -} - -const DownloadButton = ({ language, data }: { language: string; data: string }) => { - const onDownload = () => { - const fileName = `${dayjs().format('YYYYMMDDHHmm')}.${language}` - window.api.file.save(fileName, data) - } - - return ( - - - - ) -} - -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) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 34c156a3a2..8e2d64177a 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -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 = ({ 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) => , - code: CodeBlock, + code: (props: any) => ( + + ), img: ImagePreview, pre: (props: any) =>
     } as Partial
-  }, [])
+  }, [onSaveCodeBlock])
 
   // if (role === 'user' && !renderInputMessageAsMarkdown) {
   //   return 

{messageContent}

@@ -99,4 +113,4 @@ const Markdown: FC = ({ block }) => { ) } -export default Markdown +export default memo(Markdown) diff --git a/src/renderer/src/pages/home/Markdown/Mermaid.tsx b/src/renderer/src/pages/home/Markdown/Mermaid.tsx deleted file mode 100644 index f15595724e..0000000000 --- a/src/renderer/src/pages/home/Markdown/Mermaid.tsx +++ /dev/null @@ -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 = ({ chart }) => { - const { theme } = useTheme() - const mermaidRef = useRef(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 ( -
- {chart} -
- ) -} - -export default Mermaid diff --git a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx b/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx deleted file mode 100644 index 1975f132cb..0000000000 --- a/src/renderer/src/pages/home/Markdown/MermaidPopup.tsx +++ /dev/null @@ -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 = ({ 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((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 ( - - {activeTab === 'source' && } - {activeTab === 'preview' && ( - <> - - - - - - - )} - - ]}> - setActiveTab(key)} - items={[ - { - key: 'preview', - label: t('mermaid.tabs.preview'), - children: ( - - {chart} - - ) - }, - { - key: 'source', - label: t('mermaid.tabs.source'), - children: ( -
-                {chart}
-              
- ) - } - ]} - /> -
- ) -} - -export default class MermaidPopup { - static topviewId = 0 - static hide() { - TopView.hide('MermaidPopup') - } - static show(props: ShowParams) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - />, - 'MermaidPopup' - ) - }) - } -} - -const StyledMermaid = styled.div` - max-height: calc(80vh - 200px); - text-align: center; - overflow-y: auto; -` diff --git a/src/renderer/src/pages/home/Markdown/PlantUML.tsx b/src/renderer/src/pages/home/Markdown/PlantUML.tsx deleted file mode 100644 index 8a0995fdcb..0000000000 --- a/src/renderer/src/pages/home/Markdown/PlantUML.tsx +++ /dev/null @@ -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 - 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 = ({ format, diagram, onClick, className }) => { - const [loading, setLoading] = useState(true) - const { theme } = useTheme() - const isDark = theme === 'dark' - const url = getPlantUMLImageUrl(format, diagram, isDark) - return ( - - - }> - { - setLoading(false) - }} - onError={(e) => { - setLoading(false) - const target = e.target as HTMLImageElement - target.style.opacity = '0.5' - target.style.filter = 'blur(2px)' - }} - /> - - - ) -} - -const PlantUMLPopupCantaier: React.FC = ({ 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((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 ( - - {activeTab === 'source' && ( - - )} - {activeTab === 'preview' && ( - <> - - - - - - - )} - - ]}> - setActiveTab(key)} - items={[ - { - key: 'preview', - label: t('plantuml.tabs.preview'), - children: - }, - { - key: 'source', - label: t('plantuml.tabs.source'), - children: ( -
-                {diagram}
-              
- ) - } - ]} - /> -
- ) -} - -class PlantUMLPopupTopView { - static topviewId = 0 - static hide() { - TopView.hide('PlantUMLPopup') - } - static show(diagram: string) { - return new Promise((resolve) => { - TopView.show( - { - resolve(v) - this.hide() - }} - diagram={diagram} - />, - 'PlantUMLPopup' - ) - }) - } -} -interface PlantUMLProps { - diagram: string -} -export const PlantUML: React.FC = ({ diagram }) => { - // const { t } = useTranslation() - const onPreview = () => { - PlantUMLPopupTopView.show(diagram) - } - return -} - -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) -} diff --git a/src/renderer/src/pages/home/Markdown/SvgPreview.tsx b/src/renderer/src/pages/home/Markdown/SvgPreview.tsx deleted file mode 100644 index 27685a4ade..0000000000 --- a/src/renderer/src/pages/home/Markdown/SvgPreview.tsx +++ /dev/null @@ -1,16 +0,0 @@ -const SvgPreview = ({ children }: { children: string }) => { - return ( -
- ) -} - -export default SvgPreview diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index f69dc678aa..3d95dc609b 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -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 = ({ 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()) diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index 06bd5e335b..43a439a1ae 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -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) => { 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) => { codeShowLineNumbers, codeCollapsible, codeWrappable, - codeCacheable, - codeCacheMaxSize, - codeCacheTTL, - codeCacheThreshold, + codeEditor, + codePreview, + codeExecution, mathEngine, autoTranslateWithSpace, pasteLongTextThreshold, @@ -144,6 +142,32 @@ const SettingsTab: FC = (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) => { /> - - {t('chat.settings.show_line_numbers')} - dispatch(setCodeShowLineNumbers(checked))} - /> - - - - {t('chat.settings.code_collapsible')} - dispatch(setCodeCollapsible(checked))} - /> - - - - {t('chat.settings.code_wrappable')} - dispatch(setCodeWrappable(checked))} /> - - - - - {t('chat.settings.code_cacheable')}{' '} - - - - - dispatch(setCodeCacheable(checked))} /> - - {codeCacheable && ( - <> - - - - {t('chat.settings.code_cache_max_size')} - - - - - dispatch(setCodeCacheMaxSize(value ?? 1000))} - style={{ width: 80 }} - /> - - - - - {t('chat.settings.code_cache_ttl')} - - - - - dispatch(setCodeCacheTTL(value ?? 15))} - style={{ width: 80 }} - /> - - - - - {t('chat.settings.code_cache_threshold')} - - - - - dispatch(setCodeCacheThreshold(value ?? 2))} - style={{ width: 80 }} - /> - - - )} - {t('chat.settings.thought_auto_collapse')} @@ -437,21 +370,6 @@ const SettingsTab: FC = (props) => { - - {t('message.message.code_style')} - dispatch(setCodeStyle(value as CodeStyleVarious))} - style={{ width: 135 }} - size="small"> - {codeThemes.map((theme) => ( - - {theme} - - ))} - - - {t('settings.messages.math_engine')} = (props) => { - {t('settings.messages.input.title')} + {t('chat.settings.code.title')} + + + {t('message.message.code_style')} + onCodeStyleChange(value as CodeStyleVarious)} + style={{ width: 135 }} + size="small"> + {themeNames.map((theme) => ( + + {theme} + + ))} + + + + + + {t('chat.settings.code_execution.title')} + + + + + dispatch(setCodeExecution({ enabled: checked }))} + /> + + {codeExecution.enabled && ( + <> + + + + {t('chat.settings.code_execution.timeout_minutes')} + + + + + dispatch(setCodeExecution({ timeoutMinutes: value ?? 1 }))} + style={{ width: 80 }} + /> + + + )} + + + {t('chat.settings.code_editor.title')} + dispatch(setCodeEditor({ enabled: checked }))} + /> + + {codeEditor.enabled && ( + <> + + + {t('chat.settings.code_editor.highlight_active_line')} + dispatch(setCodeEditor({ highlightActiveLine: checked }))} + /> + + + + {t('chat.settings.code_editor.fold_gutter')} + dispatch(setCodeEditor({ foldGutter: checked }))} + /> + + + + {t('chat.settings.code_editor.autocompletion')} + dispatch(setCodeEditor({ autocompletion: checked }))} + /> + + + + {t('chat.settings.code_editor.keymap')} + dispatch(setCodeEditor({ keymap: checked }))} + /> + + + )} + + + {t('chat.settings.show_line_numbers')} + dispatch(setCodeShowLineNumbers(checked))} + /> + + + + {t('chat.settings.code_collapsible')} + dispatch(setCodeCollapsible(checked))} + /> + + + + {t('chat.settings.code_wrappable')} + dispatch(setCodeWrappable(checked))} /> + + + + {t('settings.messages.input.title')} {t('settings.messages.input.show_estimated_tokens')} diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx index fc4324801a..894a6d0948 100644 --- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx @@ -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 = ({ resolve }) => { resolve({}) } + const handleChange = useCallback((newContent: string) => { + setJsonConfig(newContent) + }, []) + EditMcpJsonPopup.hide = onCancel return ( @@ -118,17 +123,15 @@ const PopupContainer: React.FC = ({ resolve }) => { {jsonError ? {jsonError} : ''}
-