@brainpilot/web 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,29 @@
1
+ import { ChatMessage, MessageFilterRule } from "../contracts/backend";
2
+
3
+ export const defaultFilterRules: MessageFilterRule[] = [
4
+ {
5
+ id: "spurious-dot",
6
+ name: "Hide spurious single-dot messages",
7
+ description:
8
+ "Hides assistant text messages that contain only a single '.' character. " +
9
+ "These often appear when the model enters defensive thinking mode and emits minimal text before a tool call.",
10
+ enabled: true,
11
+ test: (msg: ChatMessage, _all: ChatMessage[]) => {
12
+ if (msg.role !== "assistant") return false;
13
+ if (msg.kind !== "text") return false;
14
+ if (msg.streaming) return false;
15
+ return msg.content.trim() === ".";
16
+ },
17
+ },
18
+ ];
19
+
20
+ export function applyMessageFilters(
21
+ messages: ChatMessage[],
22
+ rules: MessageFilterRule[]
23
+ ): ChatMessage[] {
24
+ const activeRules = rules.filter((r) => r.enabled);
25
+ if (activeRules.length === 0) return messages;
26
+ return messages.filter((msg) => {
27
+ return !activeRules.some((rule) => rule.test(msg, messages));
28
+ });
29
+ }
@@ -0,0 +1,77 @@
1
+ import { ChatMessage } from "../contracts/backend";
2
+
3
+ /**
4
+ * A unit of rendering in the chat stream. Either a single standalone message
5
+ * (user prompt, assistant text, error, hook note) or an "activity" group that
6
+ * folds adjacent reasoning and tool calls/results into one collapsible block.
7
+ */
8
+ export type RenderItem =
9
+ | { type: "single"; message: ChatMessage }
10
+ | { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean };
11
+
12
+ /**
13
+ * Standalone kinds render as their own visible card. All assistant text
14
+ * messages — whether they come from the Principal or an Expert agent, and
15
+ * whether they are an intermediate utterance or the turn's final answer —
16
+ * stay standalone so the formal conversation is never hidden behind the
17
+ * "思考过程" fold. Only reasoning (`thinking`) and tool calls/results fold
18
+ * into activity blocks.
19
+ */
20
+ function isStandalone(message: ChatMessage): boolean {
21
+ if (message.role === "user") return true;
22
+ if (message.kind === "error" || message.kind === "status" || message.kind === "hook") return true;
23
+ // 修正6 — new-UI kinds each render as their own standalone card.
24
+ if (message.kind === "system_message" || message.kind === "ask_user" || message.kind === "auto_retry") return true;
25
+ if (message.role === "assistant" && (message.kind === "text" || message.kind === undefined)) return true;
26
+ if (message.role === "system" && (message.kind === "text" || message.kind === undefined)) return true;
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * Transform the flat message list into render items. Adjacent reasoning and
32
+ * tool steps fold into a single activity group; everything else renders
33
+ * standalone. The activity group id is its first step's id so the native
34
+ * <details> DOM node is reused across re-renders, preserving the user's
35
+ * expand/collapse toggle without explicit React state.
36
+ *
37
+ * `runningAgents` is the authoritative set of agent names whose run is still
38
+ * active (RUN_STARTED..RUN_FINISHED, sourced from `session_state`). An activity
39
+ * block is "in progress" if any of its steps is still streaming OR its owning
40
+ * agent's run is still active. Without this, the per-message `streaming` flags
41
+ * all go false between ReAct rounds (each message's END clears it), so the
42
+ * block would flash "思考过程 · N 步" (done) in the gap before the next round —
43
+ * AG-UI explicitly warns against treating a message/tool END as run completion.
44
+ * Omitting `runningAgents` (e.g. demo replay, where messages are already
45
+ * terminal) preserves the original streaming-flag-only behavior.
46
+ */
47
+ export function buildRenderItems(
48
+ messages: ChatMessage[],
49
+ runningAgents?: ReadonlySet<string>,
50
+ ): RenderItem[] {
51
+ const items: RenderItem[] = [];
52
+ let buffer: ChatMessage[] = [];
53
+ // A step keeps its block "in progress" while its owning agent's run is active.
54
+ // Steps default to the principal agent when unattributed, matching how the
55
+ // reducer/UI fall back elsewhere.
56
+ const agentActive = (s: ChatMessage) => runningAgents?.has(s.agent ?? "principal") ?? false;
57
+ const flush = () => {
58
+ if (buffer.length === 0) return;
59
+ items.push({
60
+ type: "activity",
61
+ id: buffer[0].id,
62
+ steps: buffer,
63
+ streaming: buffer.some((s) => s.streaming || agentActive(s)),
64
+ });
65
+ buffer = [];
66
+ };
67
+ for (const m of messages) {
68
+ if (isStandalone(m)) {
69
+ flush();
70
+ items.push({ type: "single", message: m });
71
+ } else {
72
+ buffer.push(m);
73
+ }
74
+ }
75
+ flush();
76
+ return items;
77
+ }
@@ -0,0 +1,401 @@
1
+ import { AgUiMessage, ChatMessage, WebSocketEvent } from "../contracts/backend";
2
+ import {
3
+ askUserToChatMessage,
4
+ autoRetryToChatMessage,
5
+ isAutoRetryStatus,
6
+ systemMessageToChatMessage,
7
+ } from "./newUiEvents";
8
+
9
+ /**
10
+ * AG-UI event → message-list reducer, extracted from SessionContext so both the
11
+ * live session and the demo replay player fold the same way. Keeping a single
12
+ * implementation guarantees the replayed conversation is byte-identical to live.
13
+ */
14
+
15
+ export function generateUUID(): string {
16
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
17
+ return crypto.randomUUID();
18
+ }
19
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
20
+ const r = (Math.random() * 16) | 0;
21
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
22
+ return v.toString(16);
23
+ });
24
+ }
25
+
26
+ export function createAssistantMessage(agent?: string): ChatMessage {
27
+ return {
28
+ id: generateUUID(),
29
+ role: "assistant",
30
+ content: "",
31
+ createdAt: new Date().toISOString(),
32
+ agent: agent || "principal",
33
+ streaming: true,
34
+ kind: "text",
35
+ };
36
+ }
37
+
38
+ export function appendAssistantChunk(messages: ChatMessage[], text: string, agent?: string): ChatMessage[] {
39
+ const last = messages[messages.length - 1];
40
+ if (last?.role === "assistant" && last.streaming && last.kind === "text") {
41
+ return [...messages.slice(0, -1), { ...last, content: last.content + text, agent: agent || last.agent }];
42
+ }
43
+ return [...messages, { ...createAssistantMessage(agent), content: text }];
44
+ }
45
+
46
+ export function finalizeAssistant(messages: ChatMessage[]): ChatMessage[] {
47
+ const last = messages[messages.length - 1];
48
+ if (last?.role === "assistant" && last.streaming) {
49
+ return [...messages.slice(0, -1), { ...last, streaming: false }];
50
+ }
51
+ return messages;
52
+ }
53
+
54
+ export function eventSessionId(event: WebSocketEvent): string | undefined {
55
+ // AG-UI events carry sessionId / threadId at the top level (flat shape).
56
+ return event.sessionId || event.threadId;
57
+ }
58
+
59
+ /**
60
+ * Clear `streaming` on any message still marked in-progress for a finished run.
61
+ * A START with no matching END (interrupt, mid-run error, dropped END) would
62
+ * otherwise leave a message stuck at streaming:true, and the activity group
63
+ * shows "智能体思考中" forever. RUN_FINISHED / RUN_ERROR are the authoritative
64
+ * terminators: once a run ends, nothing under that agent can still be streaming.
65
+ * Scoped to `agentName` so a finishing sub-agent never clears another agent's
66
+ * still-live spinner in a multi-agent run (undefined sweeps all, as a fallback).
67
+ */
68
+ function sweepStreaming(messages: ChatMessage[], agentName?: string): ChatMessage[] {
69
+ let changed = false;
70
+ const next = messages.map((m) => {
71
+ if (m.streaming && (!agentName || m.agent === agentName)) {
72
+ changed = true;
73
+ return { ...m, streaming: false };
74
+ }
75
+ return m;
76
+ });
77
+ return changed ? next : messages;
78
+ }
79
+
80
+ /**
81
+ * Convert an AG-UI message (from MESSAGES_SNAPSHOT) into a UI ChatMessage.
82
+ */
83
+ export function agUiMessageToChatMessage(msg: AgUiMessage): ChatMessage {
84
+ const role = msg.role === "user" || msg.role === "system" || msg.role === "tool" ? msg.role : "assistant";
85
+ let kind: ChatMessage["kind"] = "text";
86
+ if (msg.role === "reasoning") kind = "thinking";
87
+ if (msg.role === "tool") kind = "tool";
88
+ if (msg.error && msg.role === "system") kind = "error";
89
+ if (msg.kind === "hook" || (msg.hookFamily && msg.role === "system")) kind = "hook";
90
+ return {
91
+ id: msg.id,
92
+ role: role === "tool" ? "assistant" : role,
93
+ content: msg.content ?? "",
94
+ createdAt: new Date().toISOString(),
95
+ agent: role === "user" ? "user" : msg.agentName,
96
+ streaming: !!msg.unfinished,
97
+ kind,
98
+ toolResult: msg.role === "tool" ? msg.content : undefined,
99
+ toolCallId: msg.toolCallId,
100
+ reasoning: msg.role === "reasoning" ? msg.content : undefined,
101
+ hookFamily: msg.hookFamily,
102
+ hookPhase: msg.hookPhase,
103
+ hookLevel: msg.hookLevel,
104
+ hookData: msg.hookData,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Apply an AG-UI canonical event to the running messages array. Events are
110
+ * keyed by `messageId` / `toolCallId`; START emits a placeholder, CONTENT
111
+ * appends delta, END marks completion. MESSAGES_SNAPSHOT replaces state
112
+ * wholesale.
113
+ */
114
+ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocketEvent): ChatMessage[] {
115
+ const agent = event.agentName;
116
+ switch (event.type) {
117
+ case "MESSAGES_SNAPSHOT": {
118
+ const messages = Array.isArray(event.messages) ? event.messages : [];
119
+ // Each AG-UI message may carry `tool_calls[]` nested on an assistant
120
+ // message (fold.py groups them so `last_assistant_message`'s tool_calls
121
+ // list grows). Flatten them out as standalone `kind: "tool"` ChatMessages
122
+ // so views that scan tool calls (Agent network, filters) see them on
123
+ // refresh, mirroring how live TOOL_CALL_START events would have created
124
+ // standalone entries.
125
+ const out: ChatMessage[] = [];
126
+ for (const m of messages) {
127
+ out.push(agUiMessageToChatMessage(m));
128
+ if (Array.isArray(m.toolCalls)) {
129
+ for (const tc of m.toolCalls) {
130
+ out.push({
131
+ id: tc.id,
132
+ role: "assistant",
133
+ content: `Tool: ${tc.name ?? "unknown"}`,
134
+ createdAt: new Date().toISOString(),
135
+ agent: m.agentName,
136
+ streaming: false,
137
+ kind: "tool",
138
+ toolName: tc.name,
139
+ toolInput: tc.arguments ?? "",
140
+ });
141
+ }
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ case "TEXT_MESSAGE_START": {
148
+ const id = event.messageId;
149
+ if (!id || existing.some((m) => m.id === id)) {
150
+ return existing;
151
+ }
152
+ const role = event.role === "user" || event.role === "system" ? event.role : "assistant";
153
+ return [
154
+ ...existing,
155
+ {
156
+ id,
157
+ role,
158
+ content: "",
159
+ createdAt: new Date().toISOString(),
160
+ agent,
161
+ streaming: true,
162
+ kind: "text",
163
+ },
164
+ ];
165
+ }
166
+
167
+ case "TEXT_MESSAGE_CONTENT": {
168
+ const id = event.messageId;
169
+ let delta = typeof event.delta === "string" ? event.delta : "";
170
+ if (!id || !delta) return existing;
171
+ // Strip NO-RENDER wrapper used by record_trace "Message Complete" hint
172
+ delta = delta.replace(/<!--NO-RENDER-->[\s\S]*?<!--\/NO-RENDER-->/g, "");
173
+ if (!delta) return existing;
174
+ return existing.map((m) =>
175
+ m.id === id ? { ...m, content: (m.content ?? "") + delta } : m,
176
+ );
177
+ }
178
+
179
+ case "TEXT_MESSAGE_END": {
180
+ const id = event.messageId;
181
+ if (!id) return existing;
182
+ // Drop messages whose entire content was a NO-RENDER wrapper
183
+ return existing
184
+ .filter((m) => !(m.id === id && (m.content ?? "").trim() === ""))
185
+ .map((m) => (m.id === id ? { ...m, streaming: false } : m));
186
+ }
187
+
188
+ case "TEXT_MESSAGE_CHUNK": {
189
+ // Atomic message — created and completed in one step.
190
+ const id = event.messageId;
191
+ if (!id || existing.some((m) => m.id === id)) {
192
+ return existing;
193
+ }
194
+ const role = event.role === "assistant" || event.role === "system" ? event.role : "user";
195
+ return [
196
+ ...existing,
197
+ {
198
+ id,
199
+ role,
200
+ content: typeof event.delta === "string" ? event.delta : "",
201
+ createdAt: new Date().toISOString(),
202
+ agent: role === "user" ? "user" : agent,
203
+ streaming: false,
204
+ kind: "text",
205
+ },
206
+ ];
207
+ }
208
+
209
+ case "TOOL_CALL_START": {
210
+ const id = event.toolCallId;
211
+ if (!id || existing.some((m) => m.id === id)) {
212
+ return existing;
213
+ }
214
+ return [
215
+ ...existing,
216
+ {
217
+ id,
218
+ role: "assistant",
219
+ content: `Tool: ${event.toolCallName ?? "unknown"}`,
220
+ createdAt: new Date().toISOString(),
221
+ agent,
222
+ streaming: true,
223
+ kind: "tool",
224
+ toolName: event.toolCallName,
225
+ toolInput: "",
226
+ },
227
+ ];
228
+ }
229
+
230
+ case "TOOL_CALL_ARGS": {
231
+ const id = event.toolCallId;
232
+ const delta = typeof event.delta === "string" ? event.delta : "";
233
+ if (!id || !delta) return existing;
234
+ return existing.map((m) =>
235
+ m.id === id ? { ...m, toolInput: ((m.toolInput as string) ?? "") + delta } : m,
236
+ );
237
+ }
238
+
239
+ case "TOOL_CALL_END": {
240
+ const id = event.toolCallId;
241
+ if (!id) return existing;
242
+ return existing.map((m) => (m.id === id ? { ...m, streaming: false } : m));
243
+ }
244
+
245
+ case "TOOL_CALL_RESULT": {
246
+ const id = event.messageId;
247
+ const content = typeof event.content === "string" ? event.content : "";
248
+ if (!id || existing.some((m) => m.id === id)) {
249
+ return existing;
250
+ }
251
+ return [
252
+ ...existing,
253
+ {
254
+ id,
255
+ role: "assistant",
256
+ content: "Tool result",
257
+ createdAt: new Date().toISOString(),
258
+ agent,
259
+ kind: "tool",
260
+ toolResult: content,
261
+ },
262
+ ];
263
+ }
264
+
265
+ case "REASONING_MESSAGE_START": {
266
+ const id = event.messageId;
267
+ if (!id || existing.some((m) => m.id === id)) return existing;
268
+ return [
269
+ ...existing,
270
+ {
271
+ id,
272
+ role: "assistant",
273
+ content: "",
274
+ createdAt: new Date().toISOString(),
275
+ agent,
276
+ streaming: true,
277
+ kind: "thinking",
278
+ reasoning: "",
279
+ },
280
+ ];
281
+ }
282
+
283
+ case "REASONING_MESSAGE_CONTENT": {
284
+ const id = event.messageId;
285
+ const delta = typeof event.delta === "string" ? event.delta : "";
286
+ if (!id || !delta) return existing;
287
+ return existing.map((m) =>
288
+ m.id === id
289
+ ? { ...m, content: (m.content ?? "") + delta, reasoning: (m.reasoning ?? "") + delta }
290
+ : m,
291
+ );
292
+ }
293
+
294
+ case "REASONING_MESSAGE_END": {
295
+ const id = event.messageId;
296
+ if (!id) return existing;
297
+ return existing.map((m) => (m.id === id ? { ...m, streaming: false } : m));
298
+ }
299
+
300
+ case "RUN_ERROR": {
301
+ const message = event.message ?? "Run error";
302
+ // Run is over → sweep any dangling streaming flag before appending error.
303
+ const swept = sweepStreaming(existing, event.agentName);
304
+ return [
305
+ ...swept,
306
+ {
307
+ id: generateUUID(),
308
+ role: "system",
309
+ content: String(message),
310
+ createdAt: new Date().toISOString(),
311
+ kind: "error",
312
+ },
313
+ ];
314
+ }
315
+
316
+ // 修正6 — system_message: 4-level styled bubble in the conversation stream.
317
+ case "system_message": {
318
+ return [...existing, systemMessageToChatMessage(event)];
319
+ }
320
+
321
+ // 修正6 — user_input_request (ask_user): interactive card. Keyed by
322
+ // requestId so a duplicate re-emit doesn't stack a second card.
323
+ case "user_input_request": {
324
+ const msg = askUserToChatMessage(event);
325
+ if (existing.some((m) => m.id === msg.id)) return existing;
326
+ return [...existing, msg];
327
+ }
328
+
329
+ // 修正6 — user_input_response: echo of the submitted answer. Resolve the
330
+ // matching ask_user card (renders as answered) rather than adding a row.
331
+ case "user_input_response": {
332
+ const e = event as Record<string, unknown>;
333
+ const requestId = String(e.requestId ?? e.request_id ?? "");
334
+ const answer = String(e.answer ?? "");
335
+ if (!requestId) return existing;
336
+ return existing.map((m) =>
337
+ m.kind === "ask_user" && m.askUser?.requestId === requestId
338
+ ? { ...m, askUser: { ...m.askUser, answer } }
339
+ : m,
340
+ );
341
+ }
342
+
343
+ // 修正6 — auto-retry: Pi auto_retry_start surfaces as an
344
+ // agent_status_update (status retrying) carrying attempt/maxAttempts/delayMs.
345
+ case "agent_status_update": {
346
+ if (isAutoRetryStatus(event)) {
347
+ return [...existing, autoRetryToChatMessage(event)];
348
+ }
349
+ return existing;
350
+ }
351
+
352
+ // RUN_FINISHED is the authoritative end of a run: sweep any message left
353
+ // streaming because its END never arrived (interrupt / dropped END), so the
354
+ // "thinking" spinner reliably clears.
355
+ case "RUN_FINISHED":
356
+ return sweepStreaming(existing, event.agentName);
357
+
358
+ // Lifecycle / brackets / extensions — no message-list change
359
+ case "RUN_STARTED":
360
+ case "REASONING_START":
361
+ case "REASONING_END":
362
+ return existing;
363
+ case "CUSTOM": {
364
+ const name = (event as any).name;
365
+ // Hook diagnostic — surface as a small system entry in the message
366
+ // stream so users can see tracker resets, flag flips, reminders, and
367
+ // fallback fires alongside conversation events.
368
+ if (name === "hook_event") {
369
+ const value = ((event as any).value ?? {}) as {
370
+ hook?: string;
371
+ phase?: string;
372
+ level?: string;
373
+ message?: string;
374
+ agent_name?: string;
375
+ data?: Record<string, unknown>;
376
+ };
377
+ const id = `hook-${(event as any).timestamp ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
378
+ return [
379
+ ...existing,
380
+ {
381
+ id,
382
+ role: "system",
383
+ content: value.message ?? "(hook event)",
384
+ createdAt: new Date().toISOString(),
385
+ agent: value.agent_name,
386
+ streaming: false,
387
+ kind: "hook",
388
+ hookFamily: value.hook,
389
+ hookPhase: value.phase,
390
+ hookLevel: value.level,
391
+ hookData: value.data,
392
+ },
393
+ ];
394
+ }
395
+ return existing;
396
+ }
397
+ case "PING":
398
+ default:
399
+ return existing;
400
+ }
401
+ }
@@ -0,0 +1,190 @@
1
+ import type {
2
+ AskUserView,
3
+ AutoRetryView,
4
+ ChatMessage,
5
+ SystemMessageView,
6
+ WebSocketEvent,
7
+ } from "../contracts/backend";
8
+ import { generateUUID } from "./messageReducer";
9
+
10
+ /**
11
+ * 修正6 — event → view-model mapping for the three new UI features
12
+ * (system_message bubble, ask_user card, auto-retry countdown).
13
+ *
14
+ * Kept as pure functions (no React, no DOM) so the mapping is unit-testable in
15
+ * isolation. The reducer in messageReducer.ts calls these to fold the new
16
+ * AG-UI events into the ChatMessage stream, consistently with the existing
17
+ * event handling.
18
+ *
19
+ * Events arrive post-`normalizeAgUiEvent`, which camelizes every wire key. So a
20
+ * wire `request_id` is read here as `requestId`, `allow_free_text` as
21
+ * `allowFreeText`, etc. We still tolerate the snake_case originals as a
22
+ * fallback in case an event bypassed normalization.
23
+ */
24
+
25
+ function str(value: unknown, fallback = ""): string {
26
+ return typeof value === "string" && value.length > 0 ? value : fallback;
27
+ }
28
+
29
+ function optStr(value: unknown): string | undefined {
30
+ return typeof value === "string" && value.length > 0 ? value : undefined;
31
+ }
32
+
33
+ function num(value: unknown, fallback: number): number {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
35
+ }
36
+
37
+ /** Map a `system_message` event to its view-model. */
38
+ export function toSystemMessageView(event: WebSocketEvent): SystemMessageView {
39
+ const e = event as Record<string, unknown>;
40
+ const rawLevel = str(e.level, "info");
41
+ const level: SystemMessageView["level"] =
42
+ rawLevel === "warning" || rawLevel === "error" || rawLevel === "fatal" ? rawLevel : "info";
43
+ // `recoverable` defaults to false for fatal, true otherwise, when the wire
44
+ // omits it.
45
+ const recoverable =
46
+ typeof e.recoverable === "boolean" ? e.recoverable : level !== "fatal";
47
+ return {
48
+ level,
49
+ message: str(e.message),
50
+ details: optStr(e.details),
51
+ agent: optStr(e.agent) ?? optStr(e.agentName),
52
+ recoverable,
53
+ timestamp: optStr(e.timestamp),
54
+ };
55
+ }
56
+
57
+ /** Map a `user_input_request` event to the ask_user card view-model. */
58
+ export function toAskUserView(event: WebSocketEvent): AskUserView {
59
+ const e = event as Record<string, unknown>;
60
+ const options = Array.isArray(e.options)
61
+ ? (e.options as unknown[]).filter((o): o is string => typeof o === "string")
62
+ : undefined;
63
+ return {
64
+ requestId: str(e.requestId ?? e.request_id),
65
+ agent: str(e.agent ?? e.agentName, "principal"),
66
+ question: str(e.question),
67
+ options: options && options.length > 0 ? options : undefined,
68
+ allowFreeText:
69
+ typeof (e.allowFreeText ?? e.allow_free_text) === "boolean"
70
+ ? (e.allowFreeText ?? e.allow_free_text) as boolean
71
+ : undefined,
72
+ timeoutSec: typeof (e.timeoutSec ?? e.timeout_sec) === "number"
73
+ ? (e.timeoutSec ?? e.timeout_sec) as number
74
+ : undefined,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Map an auto-retry indicator to its view-model. Pi's `auto_retry_start`
80
+ * surfaces as an `agent_status_update` (status `retrying`) carrying
81
+ * attempt/maxAttempts/delayMs, mirrored by a `system_message`. We read whichever
82
+ * fields are present, tolerating camel/snake casing.
83
+ */
84
+ export function toAutoRetryView(event: WebSocketEvent): AutoRetryView {
85
+ const e = event as Record<string, unknown>;
86
+ // The retry detail may be nested under `data` / `value` / `autoRetry`.
87
+ const nested =
88
+ (e.autoRetry as Record<string, unknown> | undefined) ??
89
+ (e.data as Record<string, unknown> | undefined) ??
90
+ (e.value as Record<string, unknown> | undefined) ??
91
+ e;
92
+ return {
93
+ attempt: num(nested.attempt, 1),
94
+ maxAttempts: num(nested.maxAttempts ?? nested.max_attempts, 1),
95
+ delayMs: num(nested.delayMs ?? nested.delay_ms, 0),
96
+ reason: optStr(nested.reason) ?? optStr(e.message),
97
+ };
98
+ }
99
+
100
+ /** True iff this `agent_status_update` represents an auto-retry start. */
101
+ export function isAutoRetryStatus(event: WebSocketEvent): boolean {
102
+ const e = event as Record<string, unknown>;
103
+ if (e.type !== "agent_status_update") return false;
104
+ const status = str(e.status ?? (e as { runStatus?: string }).runStatus).toLowerCase();
105
+ // Pi auto_retry_start surfaces with a retrying/auto_retry status marker, or an
106
+ // explicit autoRetry payload.
107
+ if (status === "retrying" || status === "auto_retry" || status === "auto_retry_start") {
108
+ return true;
109
+ }
110
+ return Boolean(e.autoRetry ?? e.auto_retry);
111
+ }
112
+
113
+ /** Build the `system_message` ChatMessage. */
114
+ export function systemMessageToChatMessage(event: WebSocketEvent): ChatMessage {
115
+ const view = toSystemMessageView(event);
116
+ const e = event as Record<string, unknown>;
117
+ return {
118
+ id: str(e.id ?? e.messageId) || `sysmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
119
+ role: "system",
120
+ content: view.message,
121
+ createdAt: view.timestamp ?? new Date().toISOString(),
122
+ agent: view.agent,
123
+ streaming: false,
124
+ kind: "system_message",
125
+ systemMessage: view,
126
+ };
127
+ }
128
+
129
+ /** Build the `ask_user` ChatMessage. Keyed by requestId so it can be resolved. */
130
+ export function askUserToChatMessage(event: WebSocketEvent): ChatMessage {
131
+ const view = toAskUserView(event);
132
+ return {
133
+ id: view.requestId ? `ask-${view.requestId}` : generateUUID(),
134
+ role: "system",
135
+ content: view.question,
136
+ createdAt: new Date().toISOString(),
137
+ agent: view.agent,
138
+ streaming: false,
139
+ kind: "ask_user",
140
+ askUser: view,
141
+ };
142
+ }
143
+
144
+ /** Build the `auto_retry` ChatMessage from an auto-retry status update. */
145
+ export function autoRetryToChatMessage(event: WebSocketEvent): ChatMessage {
146
+ const view = toAutoRetryView(event);
147
+ const e = event as Record<string, unknown>;
148
+ return {
149
+ id: `retry-${str(e.agentName, "agent")}-${view.attempt}-${Date.now()}`,
150
+ role: "system",
151
+ content: view.reason ?? `Retrying (attempt ${view.attempt}/${view.maxAttempts})`,
152
+ createdAt: new Date().toISOString(),
153
+ agent: optStr(e.agentName),
154
+ streaming: false,
155
+ kind: "auto_retry",
156
+ autoRetry: view,
157
+ };
158
+ }
159
+
160
+ // ── Pure interaction helpers (shared by the components; testable w/o a DOM) ──
161
+
162
+ /**
163
+ * Resolve an ask_user submission. Returns the `{ requestId, answer }` to send,
164
+ * or `null` when the input is empty or the card is no longer accepting input
165
+ * (already answered / timed out). The trimmed answer is what gets sent.
166
+ */
167
+ export function resolveAskUserSubmission(
168
+ view: AskUserView,
169
+ rawAnswer: string,
170
+ opts: { answered?: boolean; timedOut?: boolean } = {},
171
+ ): { requestId: string; answer: string } | null {
172
+ if (opts.answered || opts.timedOut) return null;
173
+ const answer = rawAnswer.trim();
174
+ if (!answer || !view.requestId) return null;
175
+ return { requestId: view.requestId, answer };
176
+ }
177
+
178
+ /** Whether an ask_user card should still show interactive inputs. */
179
+ export function isAskUserOpen(
180
+ view: AskUserView,
181
+ opts: { timedOut?: boolean } = {},
182
+ ): boolean {
183
+ return view.answer === undefined && !opts.timedOut;
184
+ }
185
+
186
+ /** Initial countdown (whole seconds) for an auto-retry from its delayMs. */
187
+ export function autoRetryCountdownSeconds(view: AutoRetryView): number {
188
+ return view.delayMs > 0 ? Math.ceil(view.delayMs / 1000) : 0;
189
+ }
190
+