@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -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/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -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 +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -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 +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -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 +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -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 +191 -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 +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -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/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -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 +188 -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 +136 -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 +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -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,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
|
+
}
|