From 1e674e4666b9c1a50779f4b121c253e3f4a924bc Mon Sep 17 00:00:00 2001 From: liyp Date: Mon, 10 Aug 2026 16:56:47 +0800 Subject: [PATCH] feat: enable mermaid diagram export as SVG PNG and MMD Enable streamdown's built-in download menu for mermaid blocks via controls.mermaid.download, override its labels through the translations prop (chat.markdown.diagram) in both en-US and zh-CN, and scope the mermaid action bar CSS to direct child buttons so the dropdown items keep a visible hover state. --- frontend/i18n/messages/en-US/chat.json | 6 +++++ frontend/i18n/messages/zh-CN/chat.json | 6 +++++ .../components/markdown/streamdown-render.tsx | 26 ++++++++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json index b332728f..a10d19a2 100644 --- a/frontend/i18n/messages/en-US/chat.json +++ b/frontend/i18n/messages/en-US/chat.json @@ -155,6 +155,12 @@ "thinking": { "active": "Thinking", "done": "Thinking complete" + }, + "diagram": { + "downloadDiagram": "Download diagram", + "downloadDiagramAsSvg": "Download diagram as SVG", + "downloadDiagramAsPng": "Download diagram as PNG", + "downloadDiagramAsMmd": "Download diagram as MMD" } }, "artifacts": { diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json index c03014bf..5bc349b0 100644 --- a/frontend/i18n/messages/zh-CN/chat.json +++ b/frontend/i18n/messages/zh-CN/chat.json @@ -155,6 +155,12 @@ "thinking": { "active": "正在思考", "done": "思考完成" + }, + "diagram": { + "downloadDiagram": "下载图表", + "downloadDiagramAsSvg": "导出为 SVG", + "downloadDiagramAsPng": "导出为 PNG", + "downloadDiagramAsMmd": "导出为 MMD 源码" } }, "artifacts": { diff --git a/frontend/shared/components/markdown/streamdown-render.tsx b/frontend/shared/components/markdown/streamdown-render.tsx index eba79b3b..2df55d73 100644 --- a/frontend/shared/components/markdown/streamdown-render.tsx +++ b/frontend/shared/components/markdown/streamdown-render.tsx @@ -104,13 +104,26 @@ const STREAMDOWN_CONTROLS = { }, mermaid: { copy: true, - download: false, + download: true, fullscreen: true, panZoom: true, }, table: false, } as const; +function useStreamdownTranslations() { + const t = useTranslations("chat.markdown.diagram"); + return React.useMemo( + () => ({ + downloadDiagram: t("downloadDiagram"), + downloadDiagramAsSvg: t("downloadDiagramAsSvg"), + downloadDiagramAsPng: t("downloadDiagramAsPng"), + downloadDiagramAsMmd: t("downloadDiagramAsMmd"), + }), + [t], + ); +} + const STREAMDOWN_REMEND = { linkMode: "text-only", } as const; @@ -216,7 +229,8 @@ const BASE_MARKDOWN_CLASSNAME = cn( "[&_[data-streamdown='mermaid']_svg]:mx-auto [&_[data-streamdown='mermaid']_svg]:block [&_[data-streamdown='mermaid']_svg]:h-auto [&_[data-streamdown='mermaid']_svg]:max-h-[280px] [&_[data-streamdown='mermaid']_svg]:max-w-full [&_[data-streamdown='mermaid']_svg]:bg-transparent", "[&_[data-streamdown='mermaid']>div>div:first-child]:!left-0 [&_[data-streamdown='mermaid']>div>div:first-child]:rounded-none [&_[data-streamdown='mermaid']>div>div:first-child]:border-0 [&_[data-streamdown='mermaid']>div>div:first-child]:bg-transparent [&_[data-streamdown='mermaid']>div>div:first-child]:p-0 [&_[data-streamdown='mermaid']>div>div:first-child]:shadow-none [&_[data-streamdown='mermaid']>div>div:first-child]:backdrop-blur-none", "[&_[data-streamdown='mermaid-block-actions']]:gap-2 [&_[data-streamdown='mermaid-block-actions']]:border-0 [&_[data-streamdown='mermaid-block-actions']]:rounded-none [&_[data-streamdown='mermaid-block-actions']]:bg-transparent [&_[data-streamdown='mermaid-block-actions']]:p-0 [&_[data-streamdown='mermaid-block-actions']]:shadow-none [&_[data-streamdown='mermaid-block-actions']]:backdrop-blur-none", - "[&_[data-streamdown='mermaid-block-actions']_button]:border-0 [&_[data-streamdown='mermaid-block-actions']_button]:bg-transparent [&_[data-streamdown='mermaid-block-actions']_button]:shadow-none [&_[data-streamdown='mermaid-block-actions']_button:hover]:bg-foreground/[0.04] [&_[data-streamdown='mermaid-block-actions']_button:hover]:text-foreground", + "[&_[data-streamdown='mermaid-block-actions']>button]:border-0 [&_[data-streamdown='mermaid-block-actions']>button]:bg-transparent [&_[data-streamdown='mermaid-block-actions']>button]:shadow-none [&_[data-streamdown='mermaid-block-actions']>button:hover]:bg-foreground/[0.04] [&_[data-streamdown='mermaid-block-actions']>button:hover]:text-foreground", + "[&_[data-streamdown='mermaid-block-actions']>div>button]:border-0 [&_[data-streamdown='mermaid-block-actions']>div>button]:bg-transparent [&_[data-streamdown='mermaid-block-actions']>div>button]:shadow-none [&_[data-streamdown='mermaid-block-actions']>div>button:hover]:bg-foreground/[0.04] [&_[data-streamdown='mermaid-block-actions']>div>button:hover]:text-foreground", "[&_[data-streamdown='mermaid-block-actions']_svg]:size-3", "[&_[data-streamdown='mermaid-block']_button>svg]:size-3", "[&_code:not(pre_code)]:rounded-md [&_code:not(pre_code)]:bg-foreground/[0.05] [&_code:not(pre_code)]:px-1.5 [&_code:not(pre_code)]:py-0.5 [&_code:not(pre_code)]:font-mono [&_code:not(pre_code)]:text-[0.85em] [&_code:not(pre_code)]:text-primary [&_code:not(pre_code)]:whitespace-pre-wrap [&_code:not(pre_code)]:break-words [&_code:not(pre_code)]:[overflow-wrap:anywhere]", @@ -451,6 +465,7 @@ function ThinkingSegmentBlock({ streaming: boolean; }) { const t = useTranslations("chat.markdown.thinking"); + const translations = useStreamdownTranslations(); const active = streaming || incomplete; const [accordionValue, setAccordionValue] = React.useState(() => (active ? "thinking" : "")); const wasActiveRef = React.useRef(active); @@ -525,6 +540,7 @@ function ThinkingSegmentBlock({ shikiTheme={["github-light", "github-dark"]} animated={false} isAnimating={false} + translations={translations} > {content} @@ -546,6 +562,7 @@ function HTMLMarkdownRenderProvider({ components: Components; plugins: PluginConfig; }) { + const translations = useStreamdownTranslations(); const renderHTMLMarkdown = React.useCallback( (source: string) => ( @@ -563,12 +580,13 @@ function HTMLMarkdownRenderProvider({ shikiTheme={["github-light", "github-dark"]} animated={false} isAnimating={false} + translations={translations} > {source} ), - [className, components, plugins], + [className, components, plugins, translations], ); return ( @@ -613,6 +631,7 @@ export const StreamdownRender = React.memo(function StreamdownRender({ () => segments.filter((segment): segment is Extract => segment.type === "thinking"), [segments], ); + const translations = useStreamdownTranslations(); const markdownSegments = React.useMemo( () => segments.filter((segment): segment is Extract => segment.type === "markdown"), [segments], @@ -683,6 +702,7 @@ export const StreamdownRender = React.memo(function StreamdownRender({ shikiTheme={["github-light", "github-dark"]} animated={false} isAnimating={streaming} + translations={translations} > {segment.content}