From dae02d56dac8a349525b24e8d417c6777be86305 Mon Sep 17 00:00:00 2001 From: Miro Date: Wed, 1 Jul 2026 17:00:04 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(dashboard):=20streamdown=20=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=20react-markdown=EF=BC=8C=E5=AE=9E=E7=8E=B0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=20Markdown=20=E6=B8=90=E8=BF=9B=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChatStreamdown.tsx:封装 Streamdown,保留印章终端主题样式 - 新增 chat-streamdown-security.ts:禁用远程图片加载 - ChatThread.tsx:流式 assistant 消息用 ChatStreamdown 逐 token 渲染, 历史消息仍用 ChatMarkdown - 安装 streamdown ^2.5.0 + @streamdown/cjk + @streamdown/code Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/package.json | 3 + apps/dashboard/pnpm-lock.yaml | 217 ++++++++++++++++++ .../src/components/chat/ChatStreamdown.tsx | 120 ++++++++++ .../src/components/chat/ChatThread.tsx | 16 +- .../chat/chat-streamdown-security.ts | 36 +++ 5 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 apps/dashboard/src/components/chat/ChatStreamdown.tsx create mode 100644 apps/dashboard/src/components/chat/chat-streamdown-security.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index b079dc10..f38a8764 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -20,6 +20,8 @@ "@copilotkit/react-core": "^1.59.5", "@copilotkit/runtime": "^1.59.5", "@mastra/client-js": "^1.23.2", + "@streamdown/cjk": "^1.0.3", + "@streamdown/code": "^1.1.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "geist": "^1.7.1", @@ -35,6 +37,7 @@ "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "server-only": "^0.0.1", + "streamdown": "^2.5.0", "swr": "^2.3.6", "tailwind-merge": "^3.6.0" }, diff --git a/apps/dashboard/pnpm-lock.yaml b/apps/dashboard/pnpm-lock.yaml index 88e7e230..8ba6d0dd 100644 --- a/apps/dashboard/pnpm-lock.yaml +++ b/apps/dashboard/pnpm-lock.yaml @@ -24,6 +24,12 @@ importers: '@mastra/client-js': specifier: ^1.23.2 version: 1.23.2(@bufbuild/protobuf@2.12.0)(@cfworker/json-schema@4.1.1)(ai@6.0.196(zod@4.4.3))(express@5.2.1)(rxjs@7.8.1)(zod@4.4.3) + '@streamdown/cjk': + specifier: ^1.0.3 + version: 1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.7)(unified@11.0.5) + '@streamdown/code': + specifier: ^1.1.1 + version: 1.1.1(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -69,6 +75,9 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 + streamdown: + specifier: ^2.5.0 + version: 2.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) swr: specifier: ^2.3.6 version: 2.4.1(react@19.2.7) @@ -1255,6 +1264,16 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@streamdown/cjk@1.0.3': + resolution: {integrity: sha512-WRg8HR/gHbBoTgsMd91OKFUClIoDcEFVofJvluvEAyjx3KpU0aGgD9tGDqHkHj14ShoMSkX0IYetWGegTcwIJw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + + '@streamdown/code@1.1.1': + resolution: {integrity: sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + '@swc/core-darwin-arm64@1.15.40': resolution: {integrity: sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==} engines: {node: '>=10'} @@ -2792,6 +2811,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2847,6 +2871,24 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0: + resolution: {integrity: sha512-1ePVfB4P/vz3xSsm6H3D32r6VYGErxclnuLLFK02/2ReF+UdEKm7caulK6Vm0LBIp5gPRtB2Z1OYDznCkX3k2w==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': '*' + peerDependenciesMeta: + '@types/mdast': + optional: true + + mdast-util-to-markdown-cjk-friendly@1.0.0: + resolution: {integrity: sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': '*' + peerDependenciesMeta: + '@types/mdast': + optional: true + mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -2894,6 +2936,16 @@ packages: micromark-util-types: optional: true + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg==} + engines: {node: '>=18'} + peerDependencies: + micromark: ^4.0.0 + micromark-util-types: ^2.0.0 + peerDependenciesMeta: + micromark-util-types: + optional: true + micromark-extension-cjk-friendly-util@2.1.1: resolution: {integrity: sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg==} engines: {node: '>=16'} @@ -2903,6 +2955,15 @@ packages: micromark-util-types: optional: true + micromark-extension-cjk-friendly-util@3.0.1: + resolution: {integrity: sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==} + engines: {node: '>=18'} + peerDependencies: + micromark-util-types: '*' + peerDependenciesMeta: + micromark-util-types: + optional: true + micromark-extension-cjk-friendly@1.2.3: resolution: {integrity: sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q==} engines: {node: '>=16'} @@ -2913,6 +2974,16 @@ packages: micromark-util-types: optional: true + micromark-extension-cjk-friendly@2.0.1: + resolution: {integrity: sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==} + engines: {node: '>=18'} + peerDependencies: + micromark: ^4.0.0 + micromark-util-types: ^2.0.0 + peerDependenciesMeta: + micromark-util-types: + optional: true + micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -3487,6 +3558,16 @@ packages: '@types/mdast': optional: true + remark-cjk-friendly-gfm-strikethrough@2.3.1: + resolution: {integrity: sha512-JE3TGgouk/sy92SemNMEUhO5mNP4on04cmzOV3s3R5Dbk160ewmpM4tgPiinKKvoJ5UW2fTu7FOYsjVbusSA9w==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': ^4.0.0 + unified: ^11.0.0 + peerDependenciesMeta: + '@types/mdast': + optional: true + remark-cjk-friendly@1.2.3: resolution: {integrity: sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g==} engines: {node: '>=16'} @@ -3497,6 +3578,16 @@ packages: '@types/mdast': optional: true + remark-cjk-friendly@2.3.1: + resolution: {integrity: sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/mdast': ^4.0.0 + unified: ^11.0.0 + peerDependenciesMeta: + '@types/mdast': + optional: true + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -3665,6 +3756,12 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + streamdown@2.5.0: + resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -5313,6 +5410,24 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@streamdown/cjk@1.0.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.7)(unified@11.0.5)': + dependencies: + react: 19.2.7 + remark-cjk-friendly: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + remark-cjk-friendly-gfm-strikethrough: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) + unist-util-visit: 5.1.0 + transitivePeerDependencies: + - '@types/mdast' + - micromark + - micromark-util-types + - supports-color + - unified + + '@streamdown/code@1.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + shiki: 3.23.0 + '@swc/core-darwin-arm64@1.15.40': optional: true @@ -6905,6 +7020,8 @@ snapshots: marked@16.4.2: {} + marked@17.0.6: {} + math-intrinsics@1.1.0: {} mdast-util-definitions@5.1.2: @@ -7090,6 +7207,28 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + mdast-util-to-markdown-cjk-friendly-gfm-strikethrough@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2): + dependencies: + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-to-markdown: 2.1.2 + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-symbol: 2.0.1 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark-util-types + - supports-color + + mdast-util-to-markdown-cjk-friendly@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2): + dependencies: + mdast-util-to-markdown: 2.1.2 + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-symbol: 2.0.1 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark-util-types + mdast-util-to-markdown@2.1.2: dependencies: '@types/mdast': 4.0.4 @@ -7195,6 +7334,19 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): + dependencies: + devlop: 1.1.0 + get-east-asian-width: 1.6.0 + micromark: 4.0.2 + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + optionalDependencies: + micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly-util@2.1.1(micromark-util-types@2.0.2): dependencies: get-east-asian-width: 1.6.0 @@ -7203,6 +7355,14 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly-util@3.0.1(micromark-util-types@2.0.2): + dependencies: + get-east-asian-width: 1.6.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + optionalDependencies: + micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly@1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2): dependencies: devlop: 1.1.0 @@ -7214,6 +7374,17 @@ snapshots: optionalDependencies: micromark-util-types: 2.0.2 + micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2): + dependencies: + devlop: 1.1.0 + micromark: 4.0.2 + micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2) + micromark-util-chunked: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + optionalDependencies: + micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 @@ -7957,6 +8128,18 @@ snapshots: - micromark - micromark-util-types + remark-cjk-friendly-gfm-strikethrough@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + dependencies: + mdast-util-to-markdown-cjk-friendly-gfm-strikethrough: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2) + micromark-extension-cjk-friendly-gfm-strikethrough: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) + unified: 11.0.5 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark + - micromark-util-types + - supports-color + remark-cjk-friendly@1.2.3(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): dependencies: micromark-extension-cjk-friendly: 1.2.3(micromark-util-types@2.0.2)(micromark@4.0.2) @@ -7967,6 +8150,17 @@ snapshots: - micromark - micromark-util-types + remark-cjk-friendly@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5): + dependencies: + mdast-util-to-markdown-cjk-friendly: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2) + micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2) + unified: 11.0.5 + optionalDependencies: + '@types/mdast': 4.0.4 + transitivePeerDependencies: + - micromark + - micromark-util-types + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -8263,6 +8457,29 @@ snapshots: - micromark-util-types - supports-color + streamdown@2.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.6 + mermaid: 11.15.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.0 + tailwind-merge: 3.6.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 diff --git a/apps/dashboard/src/components/chat/ChatStreamdown.tsx b/apps/dashboard/src/components/chat/ChatStreamdown.tsx new file mode 100644 index 00000000..8d43b718 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatStreamdown.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { type Components } from "streamdown"; +import { Streamdown } from "streamdown"; +import remarkGfm from "remark-gfm"; + +import { CHAT_LINK_SAFETY, createSecureRehypePlugins, STREAMDOWN_PLUGINS } from "./chat-streamdown-security"; + +/** + * Streamdown 的 Components 类型与 react-markdown 兼容(key 和签名一致), + * 直接从 ChatMarkdown 的样式映射迁移,保持「印章终端」主题。 + */ +const COMPONENTS: Components = { + p: ({ children }) =>

{children}

, + a: ({ children, href }) => ( + + {children} + + ), + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => {children}, + ul: ({ children }) => ( + + ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) =>
  • {children}
  • , + h1: ({ children }) => ( +

    {children}

    + ), + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + code: ({ className, children }) => { + // 块级 code 带 language-* className;行内 code 没有。 + // @streamdown/code 插件已处理语法高亮,这里只加 seal 终端主题的容器样式。 + const isBlock = Boolean(className); + if (isBlock) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () =>
    , +}; + +/** + * 流式 Markdown 渐进渲染。 + * + * 用 streamdown(Apache 2.0)替换 react-markdown: + * - 流式模式下逐 token 渐进渲染,而非等全文到达再一次性 parse + * - @streamdown/code 提供语法高亮 + * - @streamdown/cjk 优化中日韩文字排版 + * - 保留「印章终端」主题的组件样式覆盖 + * + * @param streaming 是否为流式输出(agent 正在生成)。静态模式用于历史消息回填。 + */ +export function ChatStreamdown({ + children, + streaming = false, +}: { + children: string; + streaming?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/apps/dashboard/src/components/chat/ChatThread.tsx b/apps/dashboard/src/components/chat/ChatThread.tsx index fbebba10..de114bcf 100644 --- a/apps/dashboard/src/components/chat/ChatThread.tsx +++ b/apps/dashboard/src/components/chat/ChatThread.tsx @@ -32,6 +32,7 @@ import { import { DivinationCard } from "@/components/divination/DivinationCard"; import { isDivinationTool, parseDivination } from "@/components/divination/types"; import { ChatMarkdown } from "./ChatMarkdown"; +import { ChatStreamdown } from "./ChatStreamdown"; import { ToolOutput } from "./ToolOutput"; import { resolveToolView } from "./tool-views"; @@ -616,7 +617,7 @@ export function ChatThread({ {t("empty")}

    ) : ( - visible.map((m) => ( + visible.map((m, i) => ( )) )} @@ -704,6 +710,7 @@ function MessageRow({ toolRunning, toolDone, toolResultLabel, + isStreaming, }: { message: AGMessage; toolNames: Map; @@ -711,6 +718,7 @@ function MessageRow({ toolRunning: string; toolDone: string; toolResultLabel: string; + isStreaming?: boolean; }) { const text = textOf(message.content); @@ -763,7 +771,11 @@ function MessageRow({
    {text && (
    - {text} + {isStreaming ? ( + {text} + ) : ( + {text} + )}
    )} {calls.map((c) => ( diff --git a/apps/dashboard/src/components/chat/chat-streamdown-security.ts b/apps/dashboard/src/components/chat/chat-streamdown-security.ts new file mode 100644 index 00000000..c0982837 --- /dev/null +++ b/apps/dashboard/src/components/chat/chat-streamdown-security.ts @@ -0,0 +1,36 @@ +import { cjk } from "@streamdown/cjk"; +import { code } from "@streamdown/code"; +import { defaultRehypePlugins, type LinkSafetyConfig } from "streamdown"; + +/** + * Streamdown 安全配置。 + * + * 参照 Omnigent 的做法: + * 1. 禁用远程图片加载(空白名单),防止通过图片 URL 渗出数据 + * 2. 保留其余默认 rehype 插件(sanitize、harden 等) + */ + +/** Streamdown 内容插件包。CJK 优化 + 流式代码高亮。 */ +export const STREAMDOWN_PLUGINS = { cjk, code } as const; + +/** 链接安全:不禁用 target="_blank",但不弹确认窗。 */ +export const CHAT_LINK_SAFETY: LinkSafetyConfig = { enabled: false }; + +/** + * 安全的 rehype 插件列表。 + * defaultRehypePlugins 是 Record,key 为插件名。 + * 将 harden 插件的 allowedImagePrefixes 设为空数组,阻断所有远程图片加载。 + */ +export function createSecureRehypePlugins() { + return Object.entries(defaultRehypePlugins).map( + ([key, plugin]): (typeof defaultRehypePlugins)[string] => { + if (key !== "harden" || !Array.isArray(plugin)) return plugin; + + const [fn, options] = plugin; + if (typeof options === "object" && options !== null) { + return [fn, { ...(options as Record), allowedImagePrefixes: [] }]; + } + return plugin; + }, + ); +} From 8a5d796ad5c05d2dc4a29a703cb6cd0a7f038c47 Mon Sep 17 00:00:00 2001 From: Miro Date: Wed, 1 Jul 2026 17:03:13 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(dashboard):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=8A=B6=E6=80=81=E6=9C=BA=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BB=8E=E4=BA=8C=E6=80=81=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=B8=BA=E4=B8=83=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 tool-states.ts:定义七种 ToolState,含图标/颜色/展开态/pulse 动画 - ToolChip 从 done 布尔值改为 state 驱动,中间态有 pulse/spinner, 完成态绿色可展开,错误态红色高亮,审批态有 clock 图标 - 基于 AG-UI 消息自动推断工具状态(tool call → Running, tool result → Completed/Error) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/chat/ChatThread.tsx | 95 +++++++++++----- .../src/components/chat/tool-states.ts | 104 ++++++++++++++++++ 2 files changed, 174 insertions(+), 25 deletions(-) create mode 100644 apps/dashboard/src/components/chat/tool-states.ts diff --git a/apps/dashboard/src/components/chat/ChatThread.tsx b/apps/dashboard/src/components/chat/ChatThread.tsx index de114bcf..41beb56c 100644 --- a/apps/dashboard/src/components/chat/ChatThread.tsx +++ b/apps/dashboard/src/components/chat/ChatThread.tsx @@ -8,7 +8,6 @@ import { SquarePen, Square, TriangleAlert, - Wrench, X, } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -35,6 +34,11 @@ import { ChatMarkdown } from "./ChatMarkdown"; import { ChatStreamdown } from "./ChatStreamdown"; import { ToolOutput } from "./ToolOutput"; import { resolveToolView } from "./tool-views"; +import { + TOOL_STATE_MAP, + inferToolState, + type ToolState, +} from "./tool-states"; /** AG-UI 消息(@ag-ui/core)的最小形态 —— 只取渲染需要的字段。 */ type AGMessage = { @@ -746,7 +750,18 @@ function MessageRow({ ); } } - // 已完成 chip:展开只看**输出结果**(入参噪音大,按用户要求不展示)。 + // 检测结果中是否含 error 标记。 + const hasError = (() => { + try { + const parsed = JSON.parse(text); + return Boolean(parsed?.isError || parsed?.error); + } catch { + return false; + } + })(); + const state = inferToolState(true, hasError); + const { label: stateLabel } = TOOL_STATE_MAP[state]; + return (
    ); @@ -778,14 +794,20 @@ function MessageRow({ )}
    )} - {calls.map((c) => ( - - ))} + {calls.map((c) => { + const runningState = inferToolState(false); + const { label: runningLabel } = TOOL_STATE_MAP[runningState]; + return ( + + ); + })} ); } @@ -801,8 +823,17 @@ function pretty(raw: string): string { /** * 工具调用 / 结果的紧凑 chip。 - * 调用中(金):仅 chip 标头;已完成(绿):展开看**输出结果**(入参不展示,按需看 raw)。 - * 输出按优先级渲染:工具专属视图(tool-views,行情卡/回测指标格等)→ 通用结构化 + * + * 状态机驱动(七态): + * - Running: gold chip + 无展开 + * - Completed: green chip + 可展开查看结果 + * - Error: red chip + 展开显示错误 + * - Denied: orange chip + * - Pending: gray chip + pulse 动画 + * - Awaiting Approval: yellow chip + clock + pulse + * - Responded: blue chip + * + * 输出按优先级渲染:工具专属视图(tool-views,行情卡/回测指标格等)→ 通用结构化 * ({@link ToolOutput} 键值行/表格)→ raw 钮永远可切回原始 JSON。 */ function ToolChip({ @@ -810,16 +841,20 @@ function ToolChip({ label, resultLabel, result, - done = false, + state, + stateLabel, }: { name: string; label: string; resultLabel: string; result?: string; - done?: boolean; + state: ToolState; + stateLabel: string; }) { const [showRaw, setShowRaw] = useState(false); - // 工具专属视图:结果可解析且形态命中才有;否则 null 落回通用结构化视图。 + const { Icon, color, expandable, pulse } = TOOL_STATE_MAP[state]; + + // 工具专属视图:结果可解析且形态命中才有;否则 null 落回通用结构化视图。 const view = useMemo(() => { if (!result) return null; try { @@ -833,21 +868,31 @@ function ToolChip({ const head = ( <> - {name} - - {label} + + {stateLabel} ); - // 调用中 / 无结果:没有可展开内容,渲染普通行,不给假的展开预期。 - if (!done || !result) { + // 非展开态:Running / Pending / Awaiting Approval / Responded 等中间状态。 + if (!expandable || !result) { return ( -
    +
    {head}
    ); @@ -858,12 +903,12 @@ function ToolChip({ {head} - {done && result && ( + {result && (
    {resultLabel}
    diff --git a/apps/dashboard/src/components/chat/tool-states.ts b/apps/dashboard/src/components/chat/tool-states.ts new file mode 100644 index 00000000..e31fb3ef --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-states.ts @@ -0,0 +1,104 @@ +import { + CheckCircle, + Circle, + Clock, + Wrench, + XCircle, + type LucideIcon, +} from "lucide-react"; + +/** + * 工具调用状态机(7 态)。 + * + * 参照 Omnigent 的 ToolUIPart["state"] 设计,适配 AG-UI 的消息模型。 + * AG-UI 当前仅传递二态(tool call → tool result),完整七态需要 mastra + * 侧上报中间状态。现阶段基于可用数据推断: + * - toolCalls 存在但无对应 tool result → Running + * - tool result 存在且无 error → Completed + * - tool result 存在且有 error → Error + */ + +export type ToolState = + | "input-streaming" // 正在接收工具参数 + | "input-available" // 参数就绪,执行中 + | "output-available" // 执行成功 + | "output-error" // 执行失败 + | "output-denied" // 被权限拒绝 + | "approval-requested" // 等待审批 + | "approval-responded"; // 审批已响应 + +export interface ToolStateProps { + label: string; + Icon: LucideIcon; + color: string; + /** 是否可展开查看结果 */ + expandable: boolean; + /** 是否显示 pulse 动画 */ + pulse: boolean; +} + +export const TOOL_STATE_MAP: Record = { + "input-streaming": { + label: "Pending", + Icon: Circle, + color: "text-fg-muted", + expandable: false, + pulse: true, + }, + "input-available": { + label: "Running", + Icon: Wrench, + color: "text-gold", + expandable: false, + pulse: false, + }, + "output-available": { + label: "Completed", + Icon: CheckCircle, + color: "text-bull", + expandable: true, + pulse: false, + }, + "output-error": { + label: "Error", + Icon: XCircle, + color: "text-fox-red", + expandable: true, + pulse: false, + }, + "output-denied": { + label: "Denied", + Icon: XCircle, + color: "text-orange-500", + expandable: true, + pulse: false, + }, + "approval-requested": { + label: "Awaiting Approval", + Icon: Clock, + color: "text-yellow-500", + expandable: false, + pulse: true, + }, + "approval-responded": { + label: "Responded", + Icon: CheckCircle, + color: "text-blue-500", + expandable: false, + pulse: false, + }, +}; + +/** + * 从 AG-UI 消息推断工具状态。 + * + * @param hasResult 工具结果是否已到达 + * @param hasError 结果中是否包含 error + */ +export function inferToolState( + hasResult: boolean, + hasError = false, +): ToolState { + if (hasResult) return hasError ? "output-error" : "output-available"; + return "input-available"; +} From 0d4c14cd85a4763fa19b4a852a03e356e08761f3 Mon Sep 17 00:00:00 2001 From: Miro Date: Wed, 1 Jul 2026 17:08:47 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(dashboard):=20ChatThread=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=8B=86=E5=88=86=20=E2=80=94=E2=80=94=20941=20?= =?UTF-8?q?=E8=A1=8C=20=E2=86=92=20373=20=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 ChatThread.tsx 拆分为 7 个独立组件: - ChatErrorBanner.tsx — 错误横幅 - ChatHistoryPanel.tsx — 历史会话下拉面板 - ChatToolChip.tsx — 工具调用 chip(七态状态机) - ChatMessage.tsx — 单条消息渲染(user/assistant/tool) - ChatMessageList.tsx — 消息列表 + 自动滚动 + 空态/思考中 - ChatInput.tsx — 输入框 + 发送/停止 + 页面上下文胶囊 - ChatThread.tsx — 精简为 373 行的纯容器/编排层 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/chat/ChatErrorBanner.tsx | 35 + .../src/components/chat/ChatHistoryPanel.tsx | 82 ++ .../src/components/chat/ChatInput.tsx | 112 +++ .../src/components/chat/ChatMessage.tsx | 131 +++ .../src/components/chat/ChatMessageList.tsx | 81 ++ .../src/components/chat/ChatThread.tsx | 828 +++--------------- .../src/components/chat/ChatToolChip.tsx | 132 +++ 7 files changed, 703 insertions(+), 698 deletions(-) create mode 100644 apps/dashboard/src/components/chat/ChatErrorBanner.tsx create mode 100644 apps/dashboard/src/components/chat/ChatHistoryPanel.tsx create mode 100644 apps/dashboard/src/components/chat/ChatInput.tsx create mode 100644 apps/dashboard/src/components/chat/ChatMessage.tsx create mode 100644 apps/dashboard/src/components/chat/ChatMessageList.tsx create mode 100644 apps/dashboard/src/components/chat/ChatToolChip.tsx diff --git a/apps/dashboard/src/components/chat/ChatErrorBanner.tsx b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx new file mode 100644 index 00000000..5b297cb8 --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatErrorBanner.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { TriangleAlert, X } from "lucide-react"; + +/** + * Agent 错误横幅。 + * + * 上游 LLM 报错 / 流中断时在对话栏顶部显示红字提示, + * 用于区分"agent 在思考"和"真的出错了"。 + */ +export function ChatErrorBanner({ + error, + onDismiss, + dismissLabel, +}: { + error: string; + onDismiss: () => void; + dismissLabel: string; +}) { + return ( +
    + + {error} + +
    + ); +} diff --git a/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx new file mode 100644 index 00000000..eb283b5f --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatHistoryPanel.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { cn } from "@/lib/cn"; + +/** 历史会话摘要。 */ +export interface ThreadSummary { + id: string; + title: string | null; + updatedAt: string; +} + +/** + * 历史会话下拉面板。 + * + * 纯展示组件,状态由父组件(ChatThread)管理。 + */ +export function ChatHistoryPanel({ + open, + threads, + historyError, + currentThreadId, + sourceDownLabel, + untitledLabel, + onSwitch, + onReload, +}: { + open: boolean; + threads: ThreadSummary[] | null; + historyError: boolean; + currentThreadId: string; + sourceDownLabel: string; + untitledLabel: string; + onSwitch: (threadId: string) => void; + onReload: (threadId: string) => void; +}) { + const t = useTranslations("chat"); + + if (!open) return null; + + return ( +
    + {threads === null ? ( +

    + {t("historyLoading")} +

    + ) : threads.length === 0 ? ( +

    + {t("historyEmpty")} +

    + ) : ( + threads.map((th) => ( + + )) + )} + {historyError && ( +

    + {sourceDownLabel} +

    + )} +
    + ); +} diff --git a/apps/dashboard/src/components/chat/ChatInput.tsx b/apps/dashboard/src/components/chat/ChatInput.tsx new file mode 100644 index 00000000..3654798a --- /dev/null +++ b/apps/dashboard/src/components/chat/ChatInput.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { MapPin, SendHorizontal, Square, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { type KeyboardEvent, useCallback, useEffect, useRef } from "react"; + +import { cn } from "@/lib/cn"; + +/** + * 对话输入区域:输入框 + 发送/停止按钮 + 页面上下文胶囊。 + * + * 所有状态由父组件(ChatThread)管理,通过 props 传入。 + */ +export function ChatInput({ + draft, + isLoading, + contextAttached, + contextKind, + contextId, + onDraftChange, + onSubmit, + onStop, + onContextDismiss, +}: { + draft: string; + isLoading: boolean; + contextAttached: boolean; + contextKind: string; + contextId?: string; + onDraftChange: (v: string) => void; + onSubmit: () => void; + onStop: () => void; + onContextDismiss: () => void; +}) { + const t = useTranslations("chat"); + const textareaRef = useRef(null); + + useEffect(() => { + if (draft === "") textareaRef.current?.focus(); + }, [draft]); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + onSubmit(); + } + }, + [onSubmit], + ); + + return ( +
    + {contextAttached && ( +
    + + + {t(`context.kind.${contextKind}`)} + + {contextId && ( + + {contextId.slice(0, 8)} + + )} + +
    + )} +
    +