@brainpilot/web 0.0.5 → 0.0.7

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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. package/dist/assets/index-C501m5OS.css +0 -1
@@ -9,6 +9,60 @@ export type RenderItem =
9
9
  | { type: "single"; message: ChatMessage }
10
10
  | { type: "activity"; id: string; steps: ChatMessage[]; streaming: boolean };
11
11
 
12
+ /**
13
+ * #134 — tool visibility model. Internal tools are part of the agent's plumbing
14
+ * (trace bookkeeping) rather than the user-facing conversation: the model still
15
+ * sees their calls and results, but the chat UI hides both so an implementation
16
+ * detail (`trace event dispatched`) never surfaces above the Principal's reply.
17
+ * Trace changes are surfaced quietly via the Trace tab badge instead.
18
+ *
19
+ * Hard-coded for the current internal toolset; promote to a richer
20
+ * `ToolVisibility = "user" | "debug" | "internal"` map if/when more land.
21
+ */
22
+ const INTERNAL_TOOL_NAMES: ReadonlySet<string> = new Set([
23
+ "record_trace",
24
+ "create_trace_node",
25
+ "update_trace_node",
26
+ "add_trace_relation",
27
+ "get_trace_graph",
28
+ ]);
29
+
30
+ /** A tool name is internal if it matches bare or mcp-namespaced (server__tool). */
31
+ export function isInternalToolName(name: string | undefined): boolean {
32
+ if (!name) return false;
33
+ if (INTERNAL_TOOL_NAMES.has(name)) return true;
34
+ const bare = name.includes("__") ? name.slice(name.lastIndexOf("__") + 2) : name;
35
+ return INTERNAL_TOOL_NAMES.has(bare);
36
+ }
37
+
38
+ /**
39
+ * Drop internal-tool calls AND their matching results from the chat stream.
40
+ *
41
+ * A TOOL_CALL_START carries the tool name; the later TOOL_CALL_RESULT carries
42
+ * only a `toolCallId` linking back to it. So we first collect the call ids of
43
+ * every internal tool, then filter out both the call message and any tool
44
+ * message whose `toolCallId` (the result) points at one. Presentation-only:
45
+ * the underlying message list and the model's view are untouched.
46
+ */
47
+ export function stripInternalToolMessages(messages: ChatMessage[]): ChatMessage[] {
48
+ const internalCallIds = new Set<string>();
49
+ for (const m of messages) {
50
+ if (m.kind === "tool" && isInternalToolName(m.toolName)) {
51
+ internalCallIds.add(m.id);
52
+ if (m.toolCallId) internalCallIds.add(m.toolCallId);
53
+ }
54
+ }
55
+ if (internalCallIds.size === 0) return messages;
56
+ return messages.filter((m) => {
57
+ if (m.kind !== "tool") return true;
58
+ // The call itself (internal tool name) → drop.
59
+ if (isInternalToolName(m.toolName)) return false;
60
+ // The result, linked by toolCallId to an internal call → drop.
61
+ if (m.toolCallId && internalCallIds.has(m.toolCallId)) return false;
62
+ return true;
63
+ });
64
+ }
65
+
12
66
  /**
13
67
  * Standalone kinds render as their own visible card. All assistant text
14
68
  * messages — whether they come from the Principal or an Expert agent, and
@@ -49,6 +103,8 @@ export function buildRenderItems(
49
103
  runningAgents?: ReadonlySet<string>,
50
104
  ): RenderItem[] {
51
105
  const items: RenderItem[] = [];
106
+ // #134 — internal tools (trace bookkeeping) are hidden from the chat UI.
107
+ messages = stripInternalToolMessages(messages);
52
108
  let buffer: ChatMessage[] = [];
53
109
  // A step keeps its block "in progress" while its owning agent's run is active.
54
110
  // Steps default to the principal agent when unattributed, matching how the
@@ -258,6 +258,10 @@ export function reduceMessagesForEvent(existing: ChatMessage[], event: WebSocket
258
258
  agent,
259
259
  kind: "tool",
260
260
  toolResult: content,
261
+ // #134 — keep the link back to the originating TOOL_CALL_START so the
262
+ // UI can suppress results of internal tools (record_trace) whose name
263
+ // only rode on the call event, not on this result.
264
+ toolCallId: event.toolCallId,
261
265
  },
262
266
  ];
263
267
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Pure helper for the "X 正在工作 / X is working" toast above the composer (#76).
3
+ *
4
+ * Selects which i18n key + vars the toast should render given the set of agents
5
+ * currently working. Kept pure (no React) so it's unit-testable without a DOM —
6
+ * the component just calls `t(key, vars)` with the result. The trace agent is
7
+ * excluded by the caller (it self-records continuously and isn't "the user's
8
+ * task"), matching the runtime's run-active aggregation.
9
+ */
10
+ export interface ToastLabel {
11
+ key: "chat.agentWorking" | "chat.agentsWorking" | "chat.agentThinking";
12
+ /** Interpolation vars; shape matches i18n `TranslateVars` (string|number). */
13
+ vars?: Record<string, string>;
14
+ }
15
+
16
+ /**
17
+ * @param workingAgentNames names of non-trace agents with status "running"
18
+ * @param separator locale-appropriate join for multiple names (default "、")
19
+ */
20
+ export function runningToastLabel(
21
+ workingAgentNames: readonly string[],
22
+ separator = "、",
23
+ ): ToastLabel {
24
+ if (workingAgentNames.length === 1) {
25
+ return { key: "chat.agentWorking", vars: { name: workingAgentNames[0]! } };
26
+ }
27
+ if (workingAgentNames.length > 1) {
28
+ return { key: "chat.agentsWorking", vars: { names: workingAgentNames.join(separator) } };
29
+ }
30
+ // Streaming but no named running agent yet (status not yet "running"): keep the
31
+ // generic label so the toast never renders blank.
32
+ return { key: "chat.agentThinking" };
33
+ }
@@ -0,0 +1,62 @@
1
+ import { CUSTOM_EVENT } from "@brainpilot/protocol";
2
+ import type { TraceGraph, TraceNode, WebSocketEvent } from "../contracts/backend";
3
+ import { normalizeTraceNode } from "../contracts/backend";
4
+
5
+ /**
6
+ * #79: merge a single `CUSTOM:trace_node` event into the live Graph of Trace.
7
+ *
8
+ * The runtime emits `CUSTOM { name:"trace_node", value:{ op, node } }` on every
9
+ * trace mutation (LLM `record_trace`/`create_trace_*` and the deterministic
10
+ * post-turn hook). This keeps the Trace panel live without polling the whole
11
+ * graph every few seconds.
12
+ *
13
+ * Merge rules:
14
+ * - a non-`trace_node` event returns the same graph reference (no-op);
15
+ * - an unparseable / id-less payload is ignored (same reference);
16
+ * - a node id already present is replaced in place (status/summary updates);
17
+ * - a new node id is appended;
18
+ * - `childIds` are recomputed from every node's `parentIds` so edges stay
19
+ * consistent regardless of arrival order (a child can arrive before its
20
+ * parent's childIds is known).
21
+ */
22
+ export function reduceTraceForEvent(
23
+ graph: TraceGraph | null,
24
+ event: WebSocketEvent,
25
+ sessionId: string,
26
+ ): TraceGraph | null {
27
+ const e = event as Record<string, unknown>;
28
+ if (e.type !== "CUSTOM" || e.name !== CUSTOM_EVENT.TRACE_NODE) return graph;
29
+ const value = (e.value ?? {}) as Record<string, unknown>;
30
+ const rawNode = value.node;
31
+ if (!rawNode || typeof rawNode !== "object") return graph;
32
+ const node = normalizeTraceNode(rawNode);
33
+ if (!node.id) return graph;
34
+
35
+ const base: TraceGraph = graph ?? {
36
+ meta: { sessionId },
37
+ nodes: [],
38
+ };
39
+ const idx = base.nodes.findIndex((n) => n.id === node.id);
40
+ const nextNodes =
41
+ idx >= 0
42
+ ? base.nodes.map((n, i) => (i === idx ? node : n))
43
+ : [...base.nodes, node];
44
+
45
+ return { meta: base.meta, nodes: withChildIds(nextNodes) };
46
+ }
47
+
48
+ /** Recompute every node's `childIds` from the parent links across the set. */
49
+ function withChildIds(nodes: TraceNode[]): TraceNode[] {
50
+ const childrenByParent = new Map<string, Set<string>>();
51
+ for (const n of nodes) {
52
+ for (const pid of n.parentIds) {
53
+ const set = childrenByParent.get(pid) ?? new Set<string>();
54
+ set.add(n.id);
55
+ childrenByParent.set(pid, set);
56
+ }
57
+ }
58
+ return nodes.map((n) => ({
59
+ ...n,
60
+ childIds: Array.from(childrenByParent.get(n.id) ?? []),
61
+ }));
62
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ turnTimerReducer,
4
+ initialTurnTimerState,
5
+ type TurnTimerState,
6
+ } from "./turnTimer";
7
+
8
+ /** Apply a sequence of events from the initial state. */
9
+ function run(events: Parameters<typeof turnTimerReducer>[1][]): TurnTimerState {
10
+ return events.reduce(turnTimerReducer, initialTurnTimerState);
11
+ }
12
+
13
+ describe("turnTimerReducer (#99 whole-turn timing)", () => {
14
+ it("times a simple turn: user input → active true → active false → settle", () => {
15
+ const s = run([
16
+ { type: "userInput", atMs: 1000 },
17
+ { type: "active", value: true, atMs: 1010 },
18
+ { type: "active", value: false, atMs: 4200 },
19
+ { type: "settle" },
20
+ ]);
21
+ expect(s.running).toBe(false);
22
+ // Duration spans user input (1000) → terminal active=false (4200) = 3.2s.
23
+ expect(s.lastDurationMs).toBe(3200);
24
+ expect(s.startedAt).toBeNull();
25
+ expect(s.candidateEndAt).toBeNull();
26
+ });
27
+
28
+ it("does NOT end the turn on a mid-flap false that is re-woken before settle", () => {
29
+ let s = run([
30
+ { type: "userInput", atMs: 1000 },
31
+ { type: "active", value: true, atMs: 1010 },
32
+ // Principal turn ends momentarily...
33
+ { type: "active", value: false, atMs: 2000 },
34
+ ]);
35
+ expect(s.running).toBe(true); // still running (candidate pending, not settled)
36
+ expect(s.candidateEndAt).toBe(2000);
37
+
38
+ // ...but a hook / mailbox delivery re-wakes an agent before settle fires.
39
+ s = turnTimerReducer(s, { type: "active", value: true, atMs: 2050 });
40
+ expect(s.candidateEndAt).toBeNull(); // candidate discarded
41
+ expect(s.running).toBe(true);
42
+ expect(s.startedAt).toBe(1000); // original start preserved
43
+
44
+ // Real end later.
45
+ s = turnTimerReducer(s, { type: "active", value: false, atMs: 5000 });
46
+ s = turnTimerReducer(s, { type: "settle" });
47
+ expect(s.lastDurationMs).toBe(4000); // 1000 → 5000, the re-wake didn't reset it
48
+ });
49
+
50
+ it("uses the candidate-end timestamp, not the settle time, for the duration", () => {
51
+ // settle is dispatched 'later' but the committed duration must reflect the
52
+ // authoritative active=false timestamp (the settle window is not counted).
53
+ const s = run([
54
+ { type: "userInput", atMs: 0 },
55
+ { type: "active", value: true, atMs: 5 },
56
+ { type: "active", value: false, atMs: 1000 },
57
+ { type: "settle" }, // wall-clock-wise this happens ~800ms later, irrelevant
58
+ ]);
59
+ expect(s.lastDurationMs).toBe(1000);
60
+ });
61
+
62
+ it("seeds startedAt from active=true if the user-input open was missed (reconnect)", () => {
63
+ const s = run([
64
+ { type: "active", value: true, atMs: 3000 },
65
+ { type: "active", value: false, atMs: 3500 },
66
+ { type: "settle" },
67
+ ]);
68
+ expect(s.lastDurationMs).toBe(500);
69
+ });
70
+
71
+ it("ignores active=false when no turn is running", () => {
72
+ const s = run([{ type: "active", value: false, atMs: 100 }]);
73
+ expect(s).toEqual(initialTurnTimerState);
74
+ });
75
+
76
+ it("preserves the original start when a second userInput arrives mid-turn", () => {
77
+ const s = run([
78
+ { type: "userInput", atMs: 1000 },
79
+ { type: "active", value: true, atMs: 1010 },
80
+ { type: "userInput", atMs: 1500 }, // steering / follow-up during the run
81
+ ]);
82
+ expect(s.startedAt).toBe(1000);
83
+ expect(s.running).toBe(true);
84
+ });
85
+
86
+ it("keeps lastDurationMs across a reset of the active turn but reset() clears all", () => {
87
+ let s = run([
88
+ { type: "userInput", atMs: 0 },
89
+ { type: "active", value: true, atMs: 1 },
90
+ { type: "active", value: false, atMs: 2000 },
91
+ { type: "settle" },
92
+ ]);
93
+ expect(s.lastDurationMs).toBe(2000);
94
+ s = turnTimerReducer(s, { type: "reset" });
95
+ expect(s).toEqual(initialTurnTimerState);
96
+ });
97
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * turnTimer — derive a "whole-turn" wall-clock duration for the Chat footer
3
+ * ("本轮对话用时"), issue #99.
4
+ *
5
+ * A turn is NOT a single assistant message. It spans from the user's input
6
+ * until every agent (principal + delegated experts + delivery loops) has
7
+ * finished — i.e. the authoritative `runState.active` flag (derived by the
8
+ * runtime, trace agent excluded) goes false and STAYS false.
9
+ *
10
+ * The subtlety (#99): `runState.active` can briefly flip true→false→true when a
11
+ * turn ends and a hook / system message / queued mailbox delivery immediately
12
+ * re-wakes an agent. That mid-flap `false` is NOT the end of the turn. So we
13
+ * debounce the terminal transition with a settle window: a false only counts as
14
+ * the turn's end once it has held for `settleMs`. If `active` goes true again
15
+ * inside the window, the candidate end is discarded and the same turn continues.
16
+ *
17
+ * This reducer is pure and driven entirely by authoritative backend signals
18
+ * (`runState.active` + the event's ISO timestamp). The host attaches the settle
19
+ * timer and a live "ticking" clock for the running display.
20
+ */
21
+
22
+ export interface TurnTimerState {
23
+ /** ms epoch when the current turn started (user input / first active=true). */
24
+ startedAt: number | null;
25
+ /** Whether a turn is currently in progress (active, or within settle window). */
26
+ running: boolean;
27
+ /** Pending terminal end: active went false, awaiting settle confirmation. */
28
+ candidateEndAt: number | null;
29
+ /** The last settled whole-turn duration in ms, or null if none yet. */
30
+ lastDurationMs: number | null;
31
+ }
32
+
33
+ export const initialTurnTimerState: TurnTimerState = {
34
+ startedAt: null,
35
+ running: false,
36
+ candidateEndAt: null,
37
+ lastDurationMs: null,
38
+ };
39
+
40
+ export type TurnTimerEvent =
41
+ /** Authoritative runState.active snapshot at time `atMs` (from session_state). */
42
+ | { type: "active"; value: boolean; atMs: number }
43
+ /** The settle window elapsed with active still false → commit the turn end. */
44
+ | { type: "settle" }
45
+ /** A fresh user submission opens a new turn at `atMs` (optimistic start). */
46
+ | { type: "userInput"; atMs: number }
47
+ /** Session switch / reset — clear all timing. */
48
+ | { type: "reset" };
49
+
50
+ /**
51
+ * Advance the turn-timer state machine. Pure: returns the next state. The host
52
+ * is responsible for (re)arming the settle timer whenever `candidateEndAt`
53
+ * becomes non-null, and dispatching `{type:"settle"}` after `settleMs`.
54
+ */
55
+ export function turnTimerReducer(state: TurnTimerState, event: TurnTimerEvent): TurnTimerState {
56
+ switch (event.type) {
57
+ case "reset":
58
+ return initialTurnTimerState;
59
+
60
+ case "userInput": {
61
+ // Opening (or continuing) a turn from the user side. If a turn is already
62
+ // running, keep its original start; otherwise begin a new one. Clears any
63
+ // stale candidate end.
64
+ if (state.running && state.startedAt !== null) {
65
+ return { ...state, candidateEndAt: null };
66
+ }
67
+ return {
68
+ startedAt: event.atMs,
69
+ running: true,
70
+ candidateEndAt: null,
71
+ lastDurationMs: state.lastDurationMs,
72
+ };
73
+ }
74
+
75
+ case "active": {
76
+ if (event.value) {
77
+ // active=true: turn is (still) running. Cancel any pending end. Seed a
78
+ // start if the user-input optimistic open was missed (e.g. reconnect).
79
+ return {
80
+ startedAt: state.startedAt ?? event.atMs,
81
+ running: true,
82
+ candidateEndAt: null,
83
+ lastDurationMs: state.lastDurationMs,
84
+ };
85
+ }
86
+ // active=false: candidate terminal transition. Only meaningful if a turn
87
+ // is in progress. Record the candidate end; the host arms the settle
88
+ // timer. A re-wake (active=true) before settle discards this.
89
+ if (!state.running || state.startedAt === null) return state;
90
+ return { ...state, candidateEndAt: event.atMs };
91
+ }
92
+
93
+ case "settle": {
94
+ // Settle window held with active false → commit the whole-turn duration.
95
+ if (state.candidateEndAt === null || state.startedAt === null) return state;
96
+ const duration = Math.max(0, state.candidateEndAt - state.startedAt);
97
+ return {
98
+ startedAt: null,
99
+ running: false,
100
+ candidateEndAt: null,
101
+ lastDurationMs: duration,
102
+ };
103
+ }
104
+
105
+ default:
106
+ return state;
107
+ }
108
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * useTurnTimer — React host for the whole-turn timer reducer (#99).
3
+ *
4
+ * Consumes the authoritative `runActive` signal from SessionContext
5
+ * (session_state.runState.active + backend timestamp) and produces:
6
+ * - `running`: a turn is in progress (active, or within the settle window);
7
+ * - `elapsedMs`: live elapsed while running (ticks ~every second), or the
8
+ * last settled whole-turn duration once finished;
9
+ * - `lastDurationMs`: the last settled whole-turn duration.
10
+ *
11
+ * The settle window debounces the true→false→true flap that happens when a hook
12
+ * / system message / queued mailbox delivery re-wakes an agent right after a
13
+ * turn momentarily ends — that mid-flap false must NOT end the turn.
14
+ */
15
+ import { useEffect, useReducer, useRef, useState } from "react";
16
+ import {
17
+ turnTimerReducer,
18
+ initialTurnTimerState,
19
+ type TurnTimerState,
20
+ } from "./turnTimer";
21
+
22
+ /** Default debounce for the terminal active=false transition. */
23
+ export const DEFAULT_SETTLE_MS = 900;
24
+
25
+ export interface TurnTiming {
26
+ running: boolean;
27
+ /** Live elapsed while running; the settled duration once finished; null if none. */
28
+ elapsedMs: number | null;
29
+ lastDurationMs: number | null;
30
+ }
31
+
32
+ interface UseTurnTimerOptions {
33
+ /** Backend run-active snapshot; null until the first session_state arrives. */
34
+ runActive: { active: boolean; atMs: number } | null;
35
+ /** Key that resets the timer when it changes (e.g. session id). */
36
+ resetKey?: string | null;
37
+ settleMs?: number;
38
+ /** Test seam: clock source (defaults to Date.now). */
39
+ now?: () => number;
40
+ }
41
+
42
+ export function useTurnTimer(options: UseTurnTimerOptions): TurnTiming {
43
+ const { runActive, resetKey, settleMs = DEFAULT_SETTLE_MS, now = Date.now } = options;
44
+ const [state, dispatch] = useReducer(turnTimerReducer, initialTurnTimerState);
45
+ const settleRef = useRef<number | undefined>(undefined);
46
+ const [, forceTick] = useState(0);
47
+
48
+ // Reset when the session changes.
49
+ useEffect(() => {
50
+ dispatch({ type: "reset" });
51
+ }, [resetKey]);
52
+
53
+ // Feed authoritative active transitions into the reducer.
54
+ useEffect(() => {
55
+ if (!runActive) return;
56
+ dispatch({ type: "active", value: runActive.active, atMs: runActive.atMs });
57
+ }, [runActive]);
58
+
59
+ // Arm/disarm the settle timer based on a pending candidate end.
60
+ const hasCandidate = state.candidateEndAt !== null;
61
+ useEffect(() => {
62
+ if (!hasCandidate) {
63
+ if (settleRef.current !== undefined) {
64
+ window.clearTimeout(settleRef.current);
65
+ settleRef.current = undefined;
66
+ }
67
+ return;
68
+ }
69
+ settleRef.current = window.setTimeout(() => {
70
+ settleRef.current = undefined;
71
+ dispatch({ type: "settle" });
72
+ }, settleMs);
73
+ return () => {
74
+ if (settleRef.current !== undefined) {
75
+ window.clearTimeout(settleRef.current);
76
+ settleRef.current = undefined;
77
+ }
78
+ };
79
+ }, [hasCandidate, settleMs]);
80
+
81
+ // Tick once per second while running so the live elapsed advances.
82
+ useEffect(() => {
83
+ if (!state.running) return;
84
+ const id = window.setInterval(() => forceTick((n) => n + 1), 1000);
85
+ return () => window.clearInterval(id);
86
+ }, [state.running]);
87
+
88
+ return deriveTiming(state, now);
89
+ }
90
+
91
+ function deriveTiming(state: TurnTimerState, now: () => number): TurnTiming {
92
+ if (state.running && state.startedAt !== null) {
93
+ return {
94
+ running: true,
95
+ elapsedMs: Math.max(0, now() - state.startedAt),
96
+ lastDurationMs: state.lastDurationMs,
97
+ };
98
+ }
99
+ return {
100
+ running: false,
101
+ elapsedMs: state.lastDurationMs,
102
+ lastDurationMs: state.lastDurationMs,
103
+ };
104
+ }
@@ -14,10 +14,14 @@ import type {
14
14
  Session,
15
15
  AgentStatus,
16
16
  SessionStateSnapshot,
17
+ SessionTokenUsage,
18
+ TokenUsage,
17
19
  SettingsData,
18
20
  McpServerEntry,
19
21
  ModelHealth,
20
22
  ProviderProfile,
23
+ ProviderApi,
24
+ ProviderAdapter,
21
25
  FileEntry,
22
26
  FileContent,
23
27
  TraceNode,
@@ -34,10 +38,14 @@ export type {
34
38
  Session,
35
39
  AgentStatus,
36
40
  SessionStateSnapshot,
41
+ SessionTokenUsage,
42
+ TokenUsage,
37
43
  SettingsData,
38
44
  McpServerEntry,
39
45
  ModelHealth,
40
46
  ProviderProfile,
47
+ ProviderApi,
48
+ ProviderAdapter,
41
49
  FileEntry,
42
50
  FileContent,
43
51
  TraceNode,
@@ -195,6 +203,8 @@ export interface MessageFilterConfig {
195
203
  export interface ProviderCreate {
196
204
  name: string;
197
205
  baseUrl: string;
206
+ api?: ProviderApi;
207
+ adapter?: ProviderAdapter;
198
208
  apiKey: string;
199
209
  models?: string[];
200
210
  icon?: string;
@@ -205,6 +215,8 @@ export interface ProviderCreate {
205
215
  export interface ProviderUpdate {
206
216
  name?: string;
207
217
  baseUrl?: string;
218
+ api?: ProviderApi;
219
+ adapter?: ProviderAdapter;
208
220
  apiKey?: string;
209
221
  models?: string[];
210
222
  icon?: string;
@@ -400,6 +412,8 @@ interface RawProviderProfile {
400
412
  name?: string;
401
413
  base_url?: string;
402
414
  baseUrl?: string;
415
+ api?: string;
416
+ adapter?: string;
403
417
  models?: string[];
404
418
  icon?: string;
405
419
  icon_color?: string;
@@ -407,6 +421,8 @@ interface RawProviderProfile {
407
421
  notes?: string;
408
422
  is_active?: boolean;
409
423
  isActive?: boolean;
424
+ is_shared?: boolean;
425
+ isShared?: boolean;
410
426
  api_key_masked?: string;
411
427
  apiKeyMasked?: string;
412
428
  created_at?: number;
@@ -639,6 +655,9 @@ export function normalizeProviderProfile(raw: RawProviderProfile): ProviderProfi
639
655
  id: stringValue(raw.id),
640
656
  name: stringValue(raw.name),
641
657
  baseUrl: stringValue(raw.baseUrl ?? raw.base_url),
658
+ api: (raw.api ?? "anthropic-messages") as ProviderApi,
659
+ adapter: (raw.adapter ?? "auto") as ProviderAdapter,
660
+ isShared: Boolean(raw.isShared ?? raw.is_shared),
642
661
  models: Array.isArray(raw.models) ? raw.models : [],
643
662
  icon: stringValue(raw.icon, "circle"),
644
663
  iconColor: stringValue(raw.iconColor ?? raw.icon_color, "#111111"),
@@ -657,6 +676,8 @@ export function serializeProviderCreate(data: ProviderCreate): Record<string, un
657
676
  return {
658
677
  name: data.name,
659
678
  base_url: data.baseUrl,
679
+ api: data.api,
680
+ adapter: data.adapter,
660
681
  api_key: data.apiKey,
661
682
  models: data.models,
662
683
  icon: data.icon,
@@ -669,6 +690,8 @@ export function serializeProviderUpdate(data: ProviderUpdate): Record<string, un
669
690
  return {
670
691
  ...(data.name !== undefined ? { name: data.name } : {}),
671
692
  ...(data.baseUrl !== undefined ? { base_url: data.baseUrl } : {}),
693
+ ...(data.api !== undefined ? { api: data.api } : {}),
694
+ ...(data.adapter !== undefined ? { adapter: data.adapter } : {}),
672
695
  ...(data.apiKey !== undefined ? { api_key: data.apiKey } : {}),
673
696
  ...(data.models !== undefined ? { models: data.models } : {}),
674
697
  ...(data.icon !== undefined ? { icon: data.icon } : {}),
@@ -677,7 +700,7 @@ export function serializeProviderUpdate(data: ProviderUpdate): Record<string, un
677
700
  };
678
701
  }
679
702
 
680
- function normalizeTraceNode(rawValue: unknown): TraceNode {
703
+ export function normalizeTraceNode(rawValue: unknown): TraceNode {
681
704
  const raw = asDict(rawValue);
682
705
  const parents = Array.isArray(raw.parents)
683
706
  ? raw.parents.map((parent) => {
@@ -752,7 +775,7 @@ export function normalizeSessionState(rawValue: unknown): SessionStateSnapshot {
752
775
  alive: typeof a.alive === "boolean" ? a.alive : undefined,
753
776
  };
754
777
  });
755
- return {
778
+ const out: SessionStateSnapshot = {
756
779
  runState: {
757
780
  active: rs.active === true,
758
781
  runId: optionalString(rs.runId) ?? null,
@@ -760,6 +783,34 @@ export function normalizeSessionState(rawValue: unknown): SessionStateSnapshot {
760
783
  agents,
761
784
  lastActivityTs: stringValue(camelized.lastActivityTs, ""),
762
785
  };
786
+ const tokenUsage = normalizeSessionTokenUsage(camelized.tokenUsage);
787
+ if (tokenUsage) out.tokenUsage = tokenUsage;
788
+ return out;
789
+ }
790
+
791
+ /** Coerce one wire token-usage record into the numeric TokenUsage shape. */
792
+ function normalizeTokenUsage(rawValue: unknown): TokenUsage {
793
+ const u = asDict(rawValue);
794
+ const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
795
+ return {
796
+ input: num(u.input),
797
+ output: num(u.output),
798
+ cacheRead: num(u.cacheRead),
799
+ cacheWrite: num(u.cacheWrite),
800
+ total: num(u.total),
801
+ };
802
+ }
803
+
804
+ /** Parse the optional per-session token usage (total + per-agent breakdown). */
805
+ function normalizeSessionTokenUsage(rawValue: unknown): SessionTokenUsage | undefined {
806
+ if (rawValue == null) return undefined;
807
+ const raw = asDict(rawValue);
808
+ const byAgentRaw = asDict(raw.byAgent);
809
+ const byAgent: Record<string, TokenUsage> = {};
810
+ for (const [name, value] of Object.entries(byAgentRaw)) {
811
+ byAgent[name] = normalizeTokenUsage(value);
812
+ }
813
+ return { total: normalizeTokenUsage(raw.total), byAgent };
763
814
  }
764
815
 
765
816
 
@@ -16,9 +16,14 @@ export default defineMessages(
16
16
  "analytics.chart.volume": "消息量(最近一小时)",
17
17
  "analytics.chart.load": "Agent 负载",
18
18
  "analytics.chart.types": "消息类型",
19
- "analytics.chart.latency": "响应延迟",
20
- "analytics.chart.tokens": "Token 估算(已发送)",
21
- "analytics.chart.heatmap": "活动热力图",
19
+ "analytics.chart.latency": "响应延迟",
20
+ "analytics.chart.tokens": "Token 用量",
21
+ "analytics.chart.avgLength": "平均消息长度",
22
+ "analytics.chart.heatmap": "活动热力图",
23
+ "analytics.tokens.total": "总计",
24
+ "analytics.tokens.input": "输入",
25
+ "analytics.tokens.output": "输出",
26
+ "analytics.tokens.cache": "缓存",
22
27
  "analytics.table.agent": "Agent",
23
28
  "analytics.table.msgs": "消息数",
24
29
  "analytics.table.avgLen": "平均长度",
@@ -62,9 +67,14 @@ export default defineMessages(
62
67
  "analytics.chart.volume": "Message volume (last hour)",
63
68
  "analytics.chart.load": "Agent load",
64
69
  "analytics.chart.types": "Message types",
65
- "analytics.chart.latency": "Response latency",
66
- "analytics.chart.tokens": "Token estimate (sent)",
67
- "analytics.chart.heatmap": "Activity heatmap",
70
+ "analytics.chart.latency": "Response latency",
71
+ "analytics.chart.tokens": "Token usage",
72
+ "analytics.chart.avgLength": "Average message length",
73
+ "analytics.chart.heatmap": "Activity heatmap",
74
+ "analytics.tokens.total": "Total",
75
+ "analytics.tokens.input": "Input",
76
+ "analytics.tokens.output": "Output",
77
+ "analytics.tokens.cache": "Cache",
68
78
  "analytics.table.agent": "Agent",
69
79
  "analytics.table.msgs": "Msgs",
70
80
  "analytics.table.avgLen": "Avg len",