@brainpilot/web 0.0.4 → 0.0.6

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 (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,49 @@
1
+ import type { AgentStatus, WebSocketEvent } from "../contracts/backend";
2
+
3
+ /**
4
+ * #70: merge a single `agent_status_update` event into the Agents-panel list.
5
+ *
6
+ * The runtime emits `agent_status_update` on every agent status transition
7
+ * (idle/running/error/stopped). The panel's authoritative source is the
8
+ * wholesale `CUSTOM:session_state` snapshot, but those arrive only on
9
+ * transitions too — between them, and on the very first run, this incremental
10
+ * merge keeps the panel live without a reload/reselect.
11
+ *
12
+ * Merge rules:
13
+ * - non-`agent_status_update` events return the same reference (no-op);
14
+ * - the agent key is the event's top-level `name` (schema field; camelize
15
+ * leaves the single word `name`/`status` untouched);
16
+ * - a name not in the list is appended (task="" — the event carries no task);
17
+ * - a known name has its status/updatedAt/alive updated but its **task
18
+ * preserved** (only session_state / a /state poll carry task);
19
+ * - a no-op status change returns the same reference to avoid a re-render;
20
+ * - `retrying`/`auto_retry*` is normalized to `running` so the panel never
21
+ * shows a non-canonical status (the auto-retry chat card is produced
22
+ * independently by the message reducer).
23
+ */
24
+ const RETRY_STATUSES = new Set(["retrying", "auto_retry", "auto_retry_start"]);
25
+
26
+ export function reduceAgentsForEvent(
27
+ agents: AgentStatus[],
28
+ event: WebSocketEvent,
29
+ ): AgentStatus[] {
30
+ const e = event as Record<string, unknown>;
31
+ if (e.type !== "agent_status_update") return agents;
32
+
33
+ const name = String(e.name ?? "");
34
+ if (!name) return agents;
35
+
36
+ const rawStatus = String(e.status ?? "idle");
37
+ const status = RETRY_STATUSES.has(rawStatus.toLowerCase()) ? "running" : rawStatus;
38
+ const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : new Date().toISOString();
39
+ const alive = status !== "stopped";
40
+
41
+ const idx = agents.findIndex((a) => a.name === name);
42
+ if (idx === -1) {
43
+ return [...agents, { name, status, task: "", updatedAt, alive }];
44
+ }
45
+ if (agents[idx]!.status === status) return agents; // no-op → stable reference
46
+ const next = agents.slice();
47
+ next[idx] = { ...agents[idx]!, status, updatedAt, alive };
48
+ return next;
49
+ }
@@ -0,0 +1,103 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Module-scoped store for unsent textarea drafts, keyed by session id.
5
+ *
6
+ * Why this exists: keeping draft state in SessionContext caused every keystroke
7
+ * to re-render the whole chat subtree (MessageStream + all MarkdownMessage
8
+ * children), producing visible input lag once the conversation grew past a few
9
+ * hundred messages. Pulling draft state out of React context and subscribing to
10
+ * it only from the ComposerInput leaf component lets typing skip the list
11
+ * entirely.
12
+ *
13
+ * Why a module-level store rather than per-component useState:
14
+ * - PromptComposer unmounts when the user switches to the Agents/Trace tab,
15
+ * so local state would lose the unsent draft.
16
+ * - Drafts must be isolated per session — switching sessions keeps the
17
+ * composer mounted but should swap which draft is visible.
18
+ */
19
+ class DraftStore {
20
+ private drafts = new Map<string, string>();
21
+ private listeners = new Map<string, Set<() => void>>();
22
+
23
+ get(sessionId: string): string {
24
+ return this.drafts.get(sessionId) ?? "";
25
+ }
26
+
27
+ set(sessionId: string, value: string): void {
28
+ if (this.drafts.get(sessionId) === value) {
29
+ // Skip notify on no-op writes so React doesn't schedule needless work.
30
+ return;
31
+ }
32
+ this.drafts.set(sessionId, value);
33
+ const subs = this.listeners.get(sessionId);
34
+ if (subs) {
35
+ subs.forEach((listener) => listener());
36
+ }
37
+ }
38
+
39
+ subscribe(sessionId: string, listener: () => void): () => void {
40
+ let subs = this.listeners.get(sessionId);
41
+ if (!subs) {
42
+ subs = new Set();
43
+ this.listeners.set(sessionId, subs);
44
+ }
45
+ subs.add(listener);
46
+ return () => {
47
+ const current = this.listeners.get(sessionId);
48
+ if (!current) return;
49
+ current.delete(listener);
50
+ if (current.size === 0) {
51
+ this.listeners.delete(sessionId);
52
+ }
53
+ };
54
+ }
55
+
56
+ delete(sessionId: string): void {
57
+ this.drafts.delete(sessionId);
58
+ // Keep listener set alive — if a component is currently mounted on this id
59
+ // (rare, but possible during async deletion), it will still get notified
60
+ // of the implicit "" snapshot via get().
61
+ }
62
+ }
63
+
64
+ export const draftStore = new DraftStore();
65
+
66
+ const NOOP_UNSUBSCRIBE = () => {};
67
+ const EMPTY_SUBSCRIBE = (_listener: () => void) => NOOP_UNSUBSCRIBE;
68
+ const EMPTY_SNAPSHOT = () => "";
69
+
70
+ /**
71
+ * Subscribe to the draft for a given session id.
72
+ *
73
+ * When sessionId is null (no active session) returns ["", noop] and does not
74
+ * subscribe to anything — keeps the hook safe to call unconditionally.
75
+ */
76
+ export function useDraft(sessionId: string | null): [string, (value: string) => void] {
77
+ const subscribe = useCallback(
78
+ (listener: () => void) => {
79
+ if (sessionId === null) return NOOP_UNSUBSCRIBE;
80
+ return draftStore.subscribe(sessionId, listener);
81
+ },
82
+ [sessionId],
83
+ );
84
+ const getSnapshot = useCallback(
85
+ () => (sessionId === null ? "" : draftStore.get(sessionId)),
86
+ [sessionId],
87
+ );
88
+
89
+ const draft = useSyncExternalStore(
90
+ sessionId === null ? EMPTY_SUBSCRIBE : subscribe,
91
+ sessionId === null ? EMPTY_SNAPSHOT : getSnapshot,
92
+ );
93
+
94
+ const setDraft = useCallback(
95
+ (value: string) => {
96
+ if (sessionId === null) return;
97
+ draftStore.set(sessionId, value);
98
+ },
99
+ [sessionId],
100
+ );
101
+
102
+ return [draft, setDraft];
103
+ }
@@ -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
+ }