diff --git a/studio/frontend/package.json b/studio/frontend/package.json index f4b636c2c6..3d6881cc53 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -66,7 +66,7 @@ "remark-gfm": "^4.0.1", "shadcn": "^3.8.4", "sonner": "^2.0.7", - "streamdown": "^2.2.0", + "streamdown": "^2.3.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", @@ -90,4 +90,4 @@ "typescript-eslint": "^8.55.0", "vite": "^7.3.1" } -} \ No newline at end of file +} diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx index 3f18cfb940..a3f07ab885 100644 --- a/studio/frontend/src/components/assistant-ui/markdown-text.tsx +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -1,14 +1,83 @@ "use client"; import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; import { code } from "@streamdown/code"; import { math } from "@streamdown/math"; import { mermaid } from "@streamdown/mermaid"; -import { Streamdown } from "streamdown"; +import { Block, type BlockProps, Streamdown } from "streamdown"; +import { useEffect, useRef, useState } from "react"; import "katex/dist/katex.min.css"; const { withSmoothContextProvider, useSmoothStatus } = INTERNAL; +function getMermaidSource(blockContent: string): string | null { + const source = blockContent.match(/```mermaid\s*([\s\S]*?)```/i)?.[1]?.trim(); + return source && source.length > 0 ? source : null; +} + +const COPY_RESET_MS = 2000; + +function MermaidCopyButton({ source }: { source: string }) { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + }, []); + + return ( + + ); +} + +function StreamdownBlock(props: BlockProps) { + const hasMermaidFence = props.content.includes("```mermaid"); + const mermaidSource = getMermaidSource(props.content); + + if (props.isIncomplete && hasMermaidFence) { + return ( +
+ Loading diagram... +
+ ); + } + + if (mermaidSource) { + return ( +
+ + +
+ ); + } + + return ; +} + const MarkdownTextImpl = () => { const { text } = useMessagePartText(); const status = useSmoothStatus(); @@ -19,8 +88,16 @@ const MarkdownTextImpl = () => { mode="streaming" isAnimating={status.type === "running"} plugins={{ code, math, mermaid }} - controls={true} + controls={{ + mermaid: { + fullscreen: true, + download: true, + copy: false, + panZoom: true, + }, + }} shikiTheme={["github-light", "github-dark"]} + BlockComponent={StreamdownBlock} > {text} diff --git a/studio/frontend/src/components/markdown/mermaid-error.tsx b/studio/frontend/src/components/markdown/mermaid-error.tsx new file mode 100644 index 0000000000..4e352d4768 --- /dev/null +++ b/studio/frontend/src/components/markdown/mermaid-error.tsx @@ -0,0 +1,28 @@ +import type { MermaidErrorComponentProps } from "streamdown"; + +function hasSlashComment(chart: string): boolean { + return /(^|[^:])\/\/.*/m.test(chart); +} + +export function MermaidError({ + error, + chart, + retry, +}: MermaidErrorComponentProps) { + return ( +
+

Mermaid render failed

+

{error}

+ {hasSlashComment(chart) ? ( +

Hint: Mermaid comments use `%%`, not `//`.

+ ) : null} + +
+ ); +}