diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json
index af2b9c0c..0e418f8c 100644
--- a/apps/dashboard/messages/en.json
+++ b/apps/dashboard/messages/en.json
@@ -412,6 +412,11 @@
"loadingHistory": "Loading history…",
"toolRunning": "running",
"toolDone": "done",
+ "toolPending": "pending",
+ "toolError": "error",
+ "toolDenied": "denied",
+ "toolAwaitingApproval": "awaiting approval",
+ "toolResponded": "responded",
"toolResult": "output",
"errorGeneric": "Agent reply failed. Please retry — likely an upstream LLM error or dropped stream.",
"errorDismiss": "Dismiss",
diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json
index 81c2db03..8c587110 100644
--- a/apps/dashboard/messages/zh.json
+++ b/apps/dashboard/messages/zh.json
@@ -412,6 +412,11 @@
"loadingHistory": "加载历史会话…",
"toolRunning": "调用中",
"toolDone": "已完成",
+ "toolPending": "待处理",
+ "toolError": "出错",
+ "toolDenied": "已拒绝",
+ "toolAwaitingApproval": "待审批",
+ "toolResponded": "已响应",
"toolResult": "输出",
"errorGeneric": "Agent 回复失败,请重试 —— 多半是上游 LLM 报错或连接中断。",
"errorDismiss": "关闭",
diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index b079dc10..fa80046b 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",
@@ -32,9 +34,9 @@
"openai": "^6.42.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
- "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..a02dedfc 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
@@ -60,15 +66,15 @@ importers:
react-dom:
specifier: ^19.2.6
version: 19.2.7(react@19.2.7)
- react-markdown:
- specifier: ^10.1.0
- version: 10.1.0(@types/react@19.2.16)(react@19.2.7)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
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 +1261,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 +2808,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 +2868,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 +2933,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 +2952,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 +2971,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==}
@@ -3399,12 +3467,6 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
- react-markdown@10.1.0:
- resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
- peerDependencies:
- '@types/react': '>=18'
- react: '>=18'
-
react-markdown@8.0.7:
resolution: {integrity: sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==}
peerDependencies:
@@ -3487,6 +3549,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 +3569,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 +3747,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 +5401,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 +7011,8 @@ snapshots:
marked@16.4.2: {}
+ marked@17.0.6: {}
+
math-intrinsics@1.1.0: {}
mdast-util-definitions@5.1.2:
@@ -7090,6 +7198,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 +7325,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 +7346,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 +7365,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
@@ -7831,24 +7993,6 @@ snapshots:
react-is@18.3.1: {}
- react-markdown@10.1.0(@types/react@19.2.16)(react@19.2.7):
- dependencies:
- '@types/hast': 3.0.4
- '@types/mdast': 4.0.4
- '@types/react': 19.2.16
- devlop: 1.1.0
- hast-util-to-jsx-runtime: 2.3.6
- html-url-attributes: 3.0.1
- mdast-util-to-hast: 13.2.1
- react: 19.2.7
- remark-parse: 11.0.0
- remark-rehype: 11.1.2
- unified: 11.0.5
- unist-util-visit: 5.1.0
- vfile: 6.0.3
- transitivePeerDependencies:
- - supports-color
-
react-markdown@8.0.7(@types/react@19.2.16)(react@19.2.7):
dependencies:
'@types/hast': 2.3.10
@@ -7957,6 +8101,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 +8123,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 +8430,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/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..2500ad04
--- /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("loadingHistory")}
+
+ ) : 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..f5265c74
--- /dev/null
+++ b/apps/dashboard/src/components/chat/ChatInput.tsx
@@ -0,0 +1,118 @@
+"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 传入。
+ *
+ * 聚焦:`open` 由父组件传入——对话栏由 `translate-x` 滑入而非条件卸载,
+ * 所以"打开时聚焦输入框"必须由 open prop 变化驱动(组件挂载 effect 补不上)。
+ */
+export function ChatInput({
+ draft,
+ isLoading,
+ open,
+ contextAttached,
+ contextKind,
+ contextId,
+ onDraftChange,
+ onSubmit,
+ onStop,
+ onContextDismiss,
+}: {
+ draft: string;
+ isLoading: boolean;
+ open: 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);
+
+ // 打开对话栏时聚焦输入框(Phase 3 拆分后从 ChatThread 迁移进来)。
+ useEffect(() => {
+ if (open) textareaRef.current?.focus();
+ }, [open]);
+
+ 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)}
+
+ )}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/chat/ChatMessage.tsx b/apps/dashboard/src/components/chat/ChatMessage.tsx
new file mode 100644
index 00000000..117578a1
--- /dev/null
+++ b/apps/dashboard/src/components/chat/ChatMessage.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { DivinationCard } from "@/components/divination/DivinationCard";
+import { isDivinationTool, parseDivination } from "@/components/divination/types";
+import { stripPageContext } from "@/lib/page-context";
+import { ChatStreamdown } from "./ChatStreamdown";
+import { ChatToolChip } from "./ChatToolChip";
+import { inferToolState, type ToolState } from "./tool-states";
+
+/** AG-UI 消息最小形态。 */
+export type AGMessage = {
+ id: string;
+ role: "user" | "assistant" | "system" | "tool" | "reasoning" | string;
+ content?: unknown;
+ toolCalls?: { id: string; function?: { name?: string; arguments?: string } }[];
+ toolCallId?: string;
+};
+
+/** AG-UI content 兼容 string / 多模态数组 → 可显示纯文本。 */
+function textOf(content: unknown): string {
+ if (typeof content === "string") return content;
+ if (Array.isArray(content)) {
+ return content
+ .map((p) =>
+ p && typeof p === "object" && "text" in p ? String(p.text ?? "") : "",
+ )
+ .join("");
+ }
+ return "";
+}
+
+/**
+ * 单条消息渲染:用户气泡 / agent 文本 + 工具 chip / 工具结果。
+ */
+export function ChatMessage({
+ message,
+ toolNames,
+ resolvedToolCallIds,
+ toolDone,
+ toolResultLabel,
+ toolStateLabels,
+ isStreaming,
+}: {
+ message: AGMessage;
+ toolNames: Map;
+ resolvedToolCallIds: Set;
+ toolDone: string;
+ toolResultLabel: string;
+ /** 七态 → 本地化文案(由 ChatMessageList 经 next-intl 解析后注入)。 */
+ toolStateLabels: Record;
+ isStreaming?: boolean;
+}) {
+ const text = textOf(message.content);
+
+ if (message.role === "user") {
+ return (
+
+
+ {stripPageContext(text)}
+
+
+ );
+ }
+
+ if (message.role === "tool") {
+ const toolName = toolNames.get(message.toolCallId ?? "") ?? "tool";
+ if (isDivinationTool(toolName)) {
+ const reading = parseDivination(text);
+ if (reading) {
+ return (
+
+ );
+ }
+ }
+ const hasError = (() => {
+ try {
+ const parsed = JSON.parse(text);
+ return Boolean(parsed?.isError || parsed?.error);
+ } catch {
+ return false;
+ }
+ })();
+ const state = inferToolState(true, hasError);
+ const stateLabel = toolStateLabels[state];
+
+ return (
+
+
+
+ );
+ }
+
+ // assistant
+ const calls = (message.toolCalls ?? []).filter(
+ (c) => !resolvedToolCallIds.has(c.id),
+ );
+ if (!text && calls.length === 0) return null;
+
+ return (
+
+ {text && (
+
+ {/* 完成态 / 历史消息也走 ChatStreamdown(mode=static):与流式态共用
+ 同一条渲染管线,保住 @streamdown/code 高亮 + @streamdown/cjk 排版
+ + 空白名单图片拦截,消除"回复一结束高亮消失、闪一下降级"的回归。 */}
+ {text}
+
+ )}
+ {calls.map((c) => {
+ const runningState = inferToolState(false);
+ const runningLabel = toolStateLabels[runningState];
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/dashboard/src/components/chat/ChatMessageList.tsx b/apps/dashboard/src/components/chat/ChatMessageList.tsx
new file mode 100644
index 00000000..799aaa2f
--- /dev/null
+++ b/apps/dashboard/src/components/chat/ChatMessageList.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useLayoutEffect, useRef } from "react";
+import { useTranslations } from "next-intl";
+
+import { ChatMessage, type AGMessage } from "./ChatMessage";
+import { TOOL_STATE_I18N_KEY, type ToolState } from "./tool-states";
+
+/**
+ * 消息列表 + 自动滚动 + 思考中指示器。
+ */
+export function ChatMessageList({
+ messages,
+ toolNames,
+ resolvedToolCallIds,
+ isLoading,
+ historyLoading,
+ toolDone,
+ toolResultLabel,
+ emptyLabel,
+ thinkingLabel,
+ loadingHistoryLabel,
+}: {
+ messages: AGMessage[];
+ toolNames: Map;
+ resolvedToolCallIds: Set;
+ isLoading: boolean;
+ historyLoading: boolean;
+ toolDone: string;
+ toolResultLabel: string;
+ emptyLabel: string;
+ thinkingLabel: string;
+ loadingHistoryLabel: string;
+}) {
+ const t = useTranslations("chat");
+ const toolStateLabels = Object.fromEntries(
+ Object.entries(TOOL_STATE_I18N_KEY).map(([state, key]) => [state, t(key)]),
+ ) as Record;
+
+ const endRef = useRef(null);
+
+ useLayoutEffect(() => {
+ endRef.current?.scrollIntoView({ block: "end" });
+ }, [messages, isLoading]);
+
+ const visible = messages.filter(
+ (m) => m.role === "user" || m.role === "assistant" || m.role === "tool",
+ );
+
+ return (
+
+ {historyLoading ? (
+
+
+ {loadingHistoryLabel}
+
+ ) : visible.length === 0 ? (
+
+ {emptyLabel}
+
+ ) : (
+ visible.map((m, i) => (
+
+ ))
+ )}
+ {isLoading && !historyLoading && (
+
+
+ {thinkingLabel}
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/chat/ChatMarkdown.tsx b/apps/dashboard/src/components/chat/ChatStreamdown.tsx
similarity index 63%
rename from apps/dashboard/src/components/chat/ChatMarkdown.tsx
rename to apps/dashboard/src/components/chat/ChatStreamdown.tsx
index 9f9556f9..8d43b718 100644
--- a/apps/dashboard/src/components/chat/ChatMarkdown.tsx
+++ b/apps/dashboard/src/components/chat/ChatStreamdown.tsx
@@ -1,11 +1,14 @@
"use client";
-import ReactMarkdown, { type Components } from "react-markdown";
+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";
+
/**
- * agent 气泡的 markdown 渲染 —— 组件级覆盖样式,套用「印章终端」主题
- * (电光青链接 / 等宽 code / hairline 表格),不引 prose 插件保持轻量。
+ * Streamdown 的 Components 类型与 react-markdown 兼容(key 和签名一致),
+ * 直接从 ChatMarkdown 的样式映射迁移,保持「印章终端」主题。
*/
const COMPONENTS: Components = {
p: ({ children }) => {children}
,
@@ -45,7 +48,8 @@ const COMPONENTS: Components = {
),
code: ({ className, children }) => {
- // 块级 code 带 language-* className;行内 code 没有。
+ // 块级 code 带 language-* className;行内 code 没有。
+ // @streamdown/code 插件已处理语法高亮,这里只加 seal 终端主题的容器样式。
const isBlock = Boolean(className);
if (isBlock) {
return (
@@ -83,10 +87,34 @@ const COMPONENTS: Components = {
hr: () =>
,
};
-export function ChatMarkdown({ children }: { children: string }) {
+/**
+ * 流式 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..e7737029 100644
--- a/apps/dashboard/src/components/chat/ChatThread.tsx
+++ b/apps/dashboard/src/components/chat/ChatThread.tsx
@@ -3,12 +3,7 @@
import { useCopilotChatInternal } from "@copilotkit/react-core";
import {
History,
- MapPin,
- SendHorizontal,
SquarePen,
- Square,
- TriangleAlert,
- Wrench,
X,
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -26,66 +21,21 @@ import {
import { cn } from "@/lib/cn";
import {
buildPageContextEnvelope,
- stripPageContext,
usePageContext,
} from "@/lib/page-context";
-import { DivinationCard } from "@/components/divination/DivinationCard";
-import { isDivinationTool, parseDivination } from "@/components/divination/types";
-import { ChatMarkdown } from "./ChatMarkdown";
-import { ToolOutput } from "./ToolOutput";
-import { resolveToolView } from "./tool-views";
-
-/** AG-UI 消息(@ag-ui/core)的最小形态 —— 只取渲染需要的字段。 */
-type AGMessage = {
- id: string;
- role: "user" | "assistant" | "system" | "tool" | "reasoning" | string;
- content?: unknown;
- toolCalls?: { id: string; function?: { name?: string; arguments?: string } }[];
- toolCallId?: string;
-};
-
-/** AG-UI content 兼容 string / 多模态数组 —— 抽出可显示纯文本。 */
-function textOf(content: unknown): string {
- if (typeof content === "string") return content;
- if (Array.isArray(content)) {
- return content
- .map((p) =>
- p && typeof p === "object" && "text" in p ? String(p.text ?? "") : "",
- )
- .join("");
- }
- return "";
-}
-
-/** 历史会话摘要(来自 /api/chat/threads)。 */
-interface ThreadSummary {
- id: string;
- title: string | null;
- updatedAt: string;
-}
+import { ChatErrorBanner } from "./ChatErrorBanner";
+import { ChatHistoryPanel, type ThreadSummary } from "./ChatHistoryPanel";
+import { ChatInput } from "./ChatInput";
+import { ChatMessageList } from "./ChatMessageList";
+import type { AGMessage } from "./ChatMessage";
/**
- * 滑出对话栏(headless 自渲染)。
+ * 滑出对话栏(headless 自渲染)。
*
- * CopilotKit 1.59 是 AG-UI「agent.messages」模型:用 `useCopilotChatInternal()`(非 cloud-gated)
- * 读 `messages` / 发 `sendMessage` / 中断 `stopGeneration` / 回填 `setMessages`。
+ * CopilotKit 1.59 AG-UI「agent.messages」模型:用 `useCopilotChatInternal()` 读
+ * `messages` / 发 `sendMessage` / 中断 `stopGeneration` / 回填 `setMessages`。
*
- * 会话管理:`threadId` 由父组件(ConsoleChat)持有并驱动 ``;
- * - 新建会话 → 父组件换 threadId → 本组件监听到变化拉空消息回填(清空 UI)。
- * - 切历史会话 → 父组件换 threadId → 本组件拉 `/api/chat/threads/:id/messages` 回填。
- *
- * 完全套用「印章终端」主题:用户气泡(电光青右对齐)/ agent 气泡(左对齐)/ 工具调用
- * 内联 chip(金=调用、绿=结果),均 `.rise` 入场;流式时底部 `caret-blink` 光标。
- *
- * @param open 是否展开(驱动 translate-x 滑入)
- * @param width 当前栏宽(px),由左缘分隔条拖动调整
- * @param threadId 当前会话 ID(变化即触发回填)
- * @param freshThreads 本地「新建会话」刚生成的 threadId 集合 —— 必然无历史,跳过回填 fetch
- * @param onClose 收起回调
- * @param onWidthChange 拖动时上报新宽度(父组件 clamp + 持久化 + 驱动 main reflow)
- * @param onDragChange 拖动开始/结束(父组件打 data-chat-dragging 关 main 过渡)
- * @param onNewSession 新建会话
- * @param onSwitchThread 切到指定历史会话
+ * 会话管理:`threadId` 由父组件(ConsoleChat)持有并驱动 ``。
*/
export function ChatThread({
open,
@@ -109,46 +59,31 @@ export function ChatThread({
onSwitchThread: (id: string) => void;
}) {
const t = useTranslations("chat");
- // ⚠️ `useCopilotChatInternal` 是 CopilotKit 内部 hook(名字含 Internal),跨大版本无稳定性保证。
- // 升级 CopilotKit(>1.59.x)时必须验证此 hook 仍存在、且 messages / sendMessage /
- // stopGeneration / setMessages / agent 字段签名一致,否则会静默变 undefined 致对话栏失效。
- // CI 已固定 @copilotkit/* 到 1.59.5 + override @ag-ui/client 0.0.53(见 pnpm-workspace.yaml)。
- // DivinationClient 也用了同一 hook,升级时一并验。
+
const hook = useCopilotChatInternal();
const messages = (hook.messages ?? []) as unknown as AGMessage[];
const { sendMessage, setMessages, isLoading, stopGeneration } = hook;
+
const [draft, setDraft] = useState("");
- // 当前页面上下文(随路由更新)+ 用户是否临时摘掉本页上下文。
- // 摘掉只对「当前这页」生效 —— 一旦导航到别的页(kind/id 变化)即恢复默认带上。
const page = usePageContext();
const [contextDismissed, setContextDismissed] = useState(false);
- useEffect(() => {
- setContextDismissed(false);
- }, [page.kind, page.id]);
+ useEffect(() => setContextDismissed(false), [page.kind, page.id]);
const contextAttached = !contextDismissed;
- // 切会话回填历史消息的在途态 —— 与「思考中」(agent 生成中)区分:切 thread 时 CopilotKit
- // 会重连 agent(connectAgent → isRunning=true),若此时只看 isLoading 会把「正在拉历史」
- // 误显示成「思考中」,且历史还没回填 → 满屏只有「思考中」。见下方回填 effect 与消息区渲染。
+
const [historyLoading, setHistoryLoading] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [threads, setThreads] = useState(null);
const [historyError, setHistoryError] = useState(false);
const [chatError, setChatError] = useState(null);
- const endRef = useRef(null);
- const inputRef = useRef(null);
+
const loadedThreadRef = useRef(null);
- // 持最新 setMessages —— 回填 effect 只依赖 threadId,不把 setMessages 进依赖:它来自
- // useCopilotChatInternal,agent 重连/流式时身份会变,若进依赖会让回填 effect 反复 cleanup
- // 重跑;而重跑因 loadedThreadRef 去重提前 return,**原 fetch 的 finally 被 cancelled 跳过
- // → historyLoading 永远卡 true、栏内一直显示「加载历史会话…」**。用 ref 断开这条链。
const setMessagesRef = useRef(setMessages);
setMessagesRef.current = setMessages;
- // 本轮 run 期间所有在途 `/api/copilotkit` 请求的中止器 + "正在停止"标志(见 handleStop)。
+
const inflightAborts = useRef>(new Set());
const stoppingRef = useRef(false);
- // 外部(占卜台「去对话栏深聊此卦」)请求把某卦交给 agent 解读 —— 注入一条用户消息。
- // 用 ref 持最新 sendMessage,监听器只挂一次,避免每次渲染重绑。开栏由 ConsoleChat 监听同一事件。
+ // Divination event listener
const sendRef = useRef(sendMessage);
sendRef.current = sendMessage;
useEffect(() => {
@@ -165,161 +100,81 @@ export function ChatThread({
return () => window.removeEventListener("inalpha:divination-consult", handler);
}, []);
- /**
- * 停止生成兜底 —— 修复"点暂停没反应,回复继续输出"。
- *
- * 当前 runtime 是 v1 GraphQL endpoint(`/api/copilotkit`):流式走 base 路径,但
- * CopilotKit 的 `stopGeneration → agent.abortRun` 会 POST 到
- * `/api/copilotkit/agent//stop/`(本 route 是固定路径,子路径 **404**),
- * 既没掐断本地流,也没让 run 收尾 → `agent.isRunning` 卡 true、`isLoading` 不复位、回复继续刷。
- *
- * 这里在 fetch 层登记本轮所有 copilotkit 请求的 AbortController(`isLoading` 落回 false 即清空),
- * 点"停止"时由 handleStop 一并 abort + 强制收尾。`stoppingRef` 期间到来的后续 tool 段请求立即掐掉。
- */
+ // Fetch patch: intercept /api/copilotkit requests for abort-on-stop
useEffect(() => {
const orig = window.fetch;
- // strict-mode(dev)双 mount:mount1 patch→unmount1 cleanup 还原 orig→mount2 见 orig 未被
- // patch、重新 patch,捕获的是 mount2 的 ref(= 当前活跃组件的 ref),stop 正常。下方 cleanup
- // 「仅自己仍是最外层时还原」保证这条还原链成立。若哪天 patch 无 cleanup,二次 mount 会因
- // __inalphaPatched 早返、旧闭包捕获旧 ref 致 dev 下 stop 失效 —— 故 cleanup 不可删。
if ((orig as { __inalphaPatched?: boolean }).__inalphaPatched) return;
const patched: typeof window.fetch = (input, init) => {
let url = "";
try {
- url =
- typeof input === "string"
- ? input
- : input instanceof URL
- ? input.href
- : (input as Request).url;
- } catch {
- url = "";
- }
+ url = typeof input === "string" ? input
+ : input instanceof URL ? input.href
+ : (input as Request).url;
+ } catch { /* ignore */ }
if (url.includes("/api/copilotkit")) {
const ctrl = new AbortController();
- if (stoppingRef.current) ctrl.abort(); // 停止后到来的后续段直接掐掉
+ const signal = init?.signal;
+ if (signal) signal.addEventListener("abort", () => ctrl.abort());
+ // 停止后到来的后续 tool 段:用**已 abort 的 signal**发请求,
+ // 浏览器立即以 AbortError 拒绝——这才真正掐断"点暂停后回复继续输出"。
+ // (ctrl 必须挂到 init.signal 上,否则 abort 形同虚设。)
+ if (stoppingRef.current) {
+ ctrl.abort();
+ return orig(input, { ...init, signal: ctrl.signal });
+ }
inflightAborts.current.add(ctrl);
const drop = () => inflightAborts.current.delete(ctrl);
- const signal =
- init?.signal && typeof AbortSignal.any === "function"
- ? AbortSignal.any([init.signal, ctrl.signal])
- : ctrl.signal;
- // 请求一结束就移出在途集合,防长 run(多工具段)内 Set 累积已完成的 ctrl。
- // **不能**在 fetch promise resolve(仅收到 header)时删 —— 流式响应 body 还在传,
- // 删早了 handleStop 就掐不断它;用 TransformStream.flush 探 body 真正读完。
- return orig(input, { ...init, signal }).then(
- (res) => {
- if (!res.body) {
- drop();
- return res;
- }
- const monitored = res.body.pipeThrough(
- new TransformStream({
- flush() {
- drop();
- },
- }),
- );
- return new Response(monitored, {
- status: res.status,
- statusText: res.statusText,
- headers: res.headers,
- });
- },
- (err) => {
- drop();
- throw err;
- },
- );
+ return orig(input, { ...init, signal: ctrl.signal }).then((res) => {
+ if (!res.ok || !res.body) { drop(); return res; }
+ const monitored = res.body.pipeThrough(new TransformStream({ flush() { drop(); } }));
+ return new Response(monitored, { status: res.status, statusText: res.statusText, headers: res.headers });
+ }, (err) => { drop(); throw err; });
}
return orig(input, init);
};
(patched as { __inalphaPatched?: boolean }).__inalphaPatched = true;
window.fetch = patched;
- return () => {
- // 只在自己仍是最外层 fetch 时还原,避免撤掉之后别的模块叠加的 fetch 替换。
- if (window.fetch === patched) window.fetch = orig;
- };
+ return () => { if (window.fetch === patched) window.fetch = orig; };
}, []);
- // 流式结束(isLoading 落回 false)→ 解除"正在停止"并清空在途集合,下一条消息正常发。
+ // Reset stopping state when loading ends
useEffect(() => {
- if (!isLoading) {
- stoppingRef.current = false;
- inflightAborts.current.clear();
- }
+ if (!isLoading) { stoppingRef.current = false; inflightAborts.current.clear(); }
}, [isLoading]);
- // 订阅 agent run 错误(上游 LLM 报错 / 流中断)→ 在对话栏顶条红字提示,
- // 而不是只剩一个空助手气泡让人误以为是 dashboard 坏了(典型:LLM 余额不足 / 限流)。
+ // Agent run error subscription
useEffect(() => {
const agent = hook.agent;
if (!agent) return;
const sub = agent.subscribe({
- onRunErrorEvent: ({
- event,
- }: {
- event?: { message?: string; code?: string };
- }) => {
+ onRunErrorEvent: ({ event }: { event?: { message?: string; code?: string } }) => {
const raw = event?.message;
const code = event?.code;
- // 用户点"停止"或任何 abort 触发的报错不算错(掐断在途 fetch 必然抛
- // "BodyStreamBuffer was aborted"/AbortError)—— 别顶错误条。
- if (
- stoppingRef.current ||
- /abort|BodyStreamBuffer|signal is aborted/i.test(`${raw ?? ""} ${code ?? ""}`)
- ) {
- return;
- }
- // @ag-ui/mastra 把上游错误对象塞进 Error() 会变成 "[object Object]" —— 当无效信息丢弃。
+ if (stoppingRef.current || /abort|BodyStreamBuffer|signal is aborted/i.test(`${raw ?? ""} ${code ?? ""}`)) return;
const human = raw && raw !== "[object Object]" ? raw : null;
- setChatError(
- human
- ? `${human}${code ? ` (${code})` : ""}`
- : code
- ? `${t("errorGeneric")} (${code})`
- : t("errorGeneric"),
- );
+ setChatError(human ? `${human}${code ? ` (${code})` : ""}` : code ? `${t("errorGeneric")} (${code})` : t("errorGeneric"));
},
} as Parameters[0]);
return () => sub.unsubscribe();
}, [hook.agent, t]);
- /** 点"停止":掐断所有在途流式请求,并强制 run 收尾让 UI 立刻复位。 */
- const handleStop = () => {
+ // Stop handler
+ const handleStop = useCallback(() => {
stoppingRef.current = true;
- setChatError(null); // 主动停止不是错误,清掉可能残留的错误条
+ setChatError(null);
stopGeneration();
inflightAborts.current.forEach((c) => c.abort());
inflightAborts.current.clear();
- // 兜底:HTTP 已返回但 run 卡在 isRunning=true 时,直接收尾 + 触发订阅重渲染让 UI 复位。
- const agent = hook.agent as
- | { isRunning?: boolean; messages?: unknown[]; setMessages?: (m: unknown[]) => void }
- | undefined;
- if (agent) {
- agent.isRunning = false;
- agent.setMessages?.([...(agent.messages ?? [])]);
- }
- // 兜底:`stoppingRef` 平时靠 useEffect([isLoading]) 在 isLoading→false 时复位,而那条
- // 依赖上面 `agent.isRunning = false` 能触发 CopilotKit 重渲染让 isLoading 变 false。
- // 万一某版本 isLoading 来自别的内部信号、这条 mutation 不生效,stoppingRef 会永久卡 true,
- // 之后每条新消息都被 fetch patch 立即 abort。3s 后强制复位,不依赖 isLoading 这条链路。
- window.setTimeout(() => {
- stoppingRef.current = false;
- }, 3000);
- };
+ const agent = hook.agent as { isRunning?: boolean; messages?: unknown[]; setMessages?: (m: unknown[]) => void } | undefined;
+ if (agent) { agent.isRunning = false; agent.setMessages?.([...(agent.messages ?? [])]); }
+ window.setTimeout(() => { stoppingRef.current = false; }, 3000);
+ }, [stopGeneration, hook.agent]);
- // threadId 变化(新建 / 切换 / 刷新恢复)→ 回填该会话历史消息;新会话返回空即清空。
- // 回填期间打 historyLoading:消息区显示「加载历史会话…」而非误显示的「思考中」/ 上个会话残影。
+ // History backfill on threadId change
useEffect(() => {
if (!threadId || loadedThreadRef.current === threadId) return;
loadedThreadRef.current = threadId;
- // 「新建会话」刚生成的 thread 后端必然为空 —— 同步清空即可,不打 loading 不发请求
- // (否则点新建要等一次网络往返,后端慢时面板会闪「加载历史会话…」)。
- if (freshThreads?.has(threadId)) {
- setMessagesRef.current([] as never);
- return;
- }
+ if (freshThreads?.has(threadId)) { setMessagesRef.current([] as never); return; }
let cancelled = false;
setHistoryLoading(true);
fetch(`/api/chat/threads/${threadId}/messages`)
@@ -327,31 +182,18 @@ export function ChatThread({
.then((d: { messages?: { id: string; role: string; content: string }[] }) => {
if (!cancelled) setMessagesRef.current((d.messages ?? []) as never);
})
- .catch(() => {
- if (!cancelled) setMessagesRef.current([] as never);
- })
- .finally(() => {
- // 不加 cancelled 守卫:即便本 effect 被 cleanup,该 threadId 的回填确已结束,
- // loading 态必须落回 false,否则一旦 cleanup 抢在 finally 前就永久卡 true。
- setHistoryLoading(false);
- });
- return () => {
- cancelled = true;
- };
+ .catch(() => { if (!cancelled) setMessagesRef.current([] as never); })
+ .finally(() => setHistoryLoading(false));
+ return () => { cancelled = true; };
}, [threadId, freshThreads]);
- // 重载当前会话历史。用于「在历史列表里点了已经激活的那条会话」:此时父组件 setThreadId
- // 值不变,上面的回填 effect(依赖 threadId)不会重跑 → 过去表现为「点第一条(恰是当前
- // 会话、被高亮)没反应」。这里手动再拉一次,给出可见反馈并复原到最新持久化状态。
const reloadCurrentThread = useCallback(() => {
const id = loadedThreadRef.current;
if (!id) return;
setHistoryLoading(true);
fetch(`/api/chat/threads/${id}/messages`)
- // 非 2xx 返 null(不要 {messages:[]})——否则会把当前会话清空白(M-1)。
.then((r) => (r.ok ? r.json() : null))
.then((d: { messages?: AGMessage[] } | null) => {
- // null(请求失败)或飞行途中已切到别的会话 → 不覆写,避免清空 / 旧数据串台(M-2)。
if (!d || loadedThreadRef.current !== id) return;
setMessagesRef.current((d.messages ?? []) as never);
})
@@ -359,9 +201,7 @@ export function ChatThread({
.finally(() => setHistoryLoading(false));
}, []);
- // 点开历史下拉:**保留上次列表立即展示**(不再清空回 loading 态),后台 no-store 重新拉、
- // 拿到再替换 —— 重开瞬间出内容,避免每次「思考中」白屏 +(标题持久化后)后端只剩一次
- // listMemoryThreads 调用。仅首次(threads===null)才显加载态。
+ // History list fetch
useEffect(() => {
if (!historyOpen) return;
let cancelled = false;
@@ -373,64 +213,36 @@ export function ChatThread({
setThreads(d.threads ?? []);
if (d.sourceDown) setHistoryError(true);
})
- .catch(() => {
- if (!cancelled) setHistoryError(true);
- });
- return () => {
- cancelled = true;
- };
+ .catch(() => { if (!cancelled) setHistoryError(true); });
+ return () => { cancelled = true; };
}, [historyOpen]);
- // toolCallId → tool 名(tool-result 消息只带 id,名字在前面的 assistant.toolCalls 里)。
+ // Tool name/id lookups
const toolNames = useMemo(() => {
const map = new Map();
for (const m of messages) {
- if (m.toolCalls)
- for (const c of m.toolCalls) map.set(c.id, c.function?.name ?? "tool");
+ if (m.toolCalls) for (const c of m.toolCalls) map.set(c.id, c.function?.name ?? "tool");
}
return map;
}, [messages]);
- // 已有结果(tool-result 消息)的 toolCallId —— 这些「调用中」chip 不再展示,只留「已完成」。
const resolvedToolCallIds = useMemo(() => {
const s = new Set();
- for (const m of messages) {
- if (m.role === "tool" && m.toolCallId) s.add(m.toolCallId);
- }
+ for (const m of messages) if (m.role === "tool" && m.toolCallId) s.add(m.toolCallId);
return s;
}, [messages]);
- const visible = messages.filter(
- (m) => m.role === "user" || m.role === "assistant" || m.role === "tool",
- );
-
- // 新消息 / 流式增量到达时贴底。
- useLayoutEffect(() => {
- endRef.current?.scrollIntoView({ block: "end" });
- }, [messages, isLoading]);
-
- // 打开时聚焦输入框。
- useEffect(() => {
- if (open) inputRef.current?.focus();
- }, [open]);
+ // 聚焦输入框由 ChatInput 内部按 open prop 驱动(Phase 3 拆分后 textarea 归 ChatInput 管)。
- const submit = async () => {
+ // Submit message
+ const submit = useCallback(async () => {
const text = draft.trim();
if (!text || isLoading) return;
- const isFirst = messages.length === 0; // 该会话首条 → 用它当会话标题
- setChatError(null); // 重发即清掉上一条错误
- // 解除"正在停止":用户主动发新消息 = 不再是停止态,否则停止后 3s 窗口内这条会被
- // fetch patch 当作"停止后的后续段"静默 abort 掉(消息凭空消失、无任何反馈)。
+ const isFirst = messages.length === 0;
+ setChatError(null);
stoppingRef.current = false;
setDraft("");
- // 带上页面上下文(若用户没摘掉):拼在 content 开头,让 agent 知道用户此刻在看哪个页面。
- // 标题仍用原始 text(见下),不被 envelope 污染;用户气泡渲染时由 stripPageContext 剥回原话。
- const content = contextAttached
- ? buildPageContextEnvelope(page) + text
- : text;
- // 首条消息:先等待标题落库,再发消息。setChatThreadTitle 内置 create 兜底
- // (线程不存在时创建带标题的线程),确保 8s 活动流轮询和页面切换时标题已就位。
- // 同机 loopback 通常 <100ms,用户无感;失败静默放弃不阻塞发送。
+ const content = contextAttached ? buildPageContextEnvelope(page) + text : text;
if (isFirst && threadId) {
await fetch(`/api/chat/threads/${threadId}/title`, {
method: "POST",
@@ -438,26 +250,14 @@ export function ChatThread({
body: JSON.stringify({ title: text }),
}).catch(() => {});
}
- void sendMessage({
- id: crypto.randomUUID(),
- role: "user",
- content,
- } as Parameters[0]);
- };
+ void sendMessage({ id: crypto.randomUUID(), role: "user", content } as Parameters[0]);
+ }, [draft, isLoading, messages.length, contextAttached, page, threadId, sendMessage]);
- const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- submit();
- }
- };
-
- // 左缘分隔条拖动:窗口右侧到指针的距离即为栏宽。
- const startResize = (e: ReactPointerEvent) => {
+ // Resize handler
+ const startResize = useCallback((e: ReactPointerEvent) => {
e.preventDefault();
onDragChange(true);
- const move = (ev: PointerEvent) =>
- onWidthChange(window.innerWidth - ev.clientX);
+ const move = (ev: PointerEvent) => onWidthChange(window.innerWidth - ev.clientX);
const up = () => {
onDragChange(false);
window.removeEventListener("pointermove", move);
@@ -465,7 +265,17 @@ export function ChatThread({
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
- };
+ }, [onWidthChange, onDragChange]);
+
+ const onHistorySwitch = useCallback((id: string) => {
+ onSwitchThread(id);
+ setHistoryOpen(false);
+ }, [onSwitchThread]);
+
+ const onHistoryReload = useCallback((id: string) => {
+ reloadCurrentThread();
+ setHistoryOpen(false);
+ }, [reloadCurrentThread]);
return (