(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 (
} onClick={handleOpenInApp}>
@@ -62,10 +55,6 @@ const Artifacts: FC = ({ html }) => {
} onClick={handleOpenExternal}>
{t('chat.artifacts.button.openExternal')}
-
- } onClick={onDownload}>
- {t('chat.artifacts.button.download')}
-
)
}
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('