@brainpilot/web 0.0.4 → 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.
- package/dist/assets/index-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +187 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +184 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +84 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- 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
|
+
|