@brainpilot/web 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
@@ -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",
@@ -2,18 +2,25 @@ import { defineMessages } from "../types";
2
2
 
3
3
  export default defineMessages(
4
4
  {
5
- "chat.generating": "正在生成...",
6
- "chat.totalTime": "本轮对话用时 {time}",
5
+ "chat.generating": "正在生成...",
6
+ "chat.streamingPending": "正在组织回复…",
7
+ "chat.totalTime": "本轮对话用时 {time}",
8
+ "chat.turnTimeRunning": "本轮进行中 {time}",
9
+ "chat.stoppedByUser": "已停止本轮任务",
7
10
  "chat.streaming": "生成中",
8
11
  "chat.toolPrefix": "工具:{name}",
9
12
  "chat.thinkingSteps": "思考过程 · {count} 步",
10
13
  "chat.toolCall": "调用工具:{name}",
14
+ "chat.toolArgs": "参数",
15
+ "chat.toolResult": "返回",
11
16
  "chat.thinking": "思考中…",
12
17
  "chat.heading": "要在 BrainPilot 中研究什么?",
13
18
  "chat.aria.messages": "对话消息",
14
19
  "chat.messageCount": "{count} 条消息",
15
20
  "chat.aria.expandThinking": "展开思考过程",
16
21
  "chat.agentThinking": "智能体思考中",
22
+ "chat.agentWorking": "{name} 正在工作",
23
+ "chat.agentsWorking": "{names} 正在工作",
17
24
  "chat.aria.stop": "停止生成",
18
25
  "chat.stop": "停止",
19
26
  "chat.aria.newPrompt": "新的研究提问",
@@ -24,6 +31,10 @@ export default defineMessages(
24
31
  "chat.modelPlaceholder": "模型",
25
32
  "chat.aria.voice": "语音输入",
26
33
  "chat.aria.attachFile": "添加文件",
34
+ "chat.aria.removeAttachment": "移除附件",
35
+ "chat.upload.failed": "上传失败:{msg}",
36
+ "chat.upload.uploading": "正在上传…",
37
+ "chat.upload.notice": "[已上传文件:{names}]",
27
38
  "chat.aria.send": "发送消息",
28
39
  "chat.copy": "复制",
29
40
  "chat.copied": "已复制",
@@ -54,18 +65,25 @@ export default defineMessages(
54
65
  "chat.retry.cancelled": "已取消重试",
55
66
  },
56
67
  {
57
- "chat.generating": "Generating...",
58
- "chat.totalTime": "Total time: {time}",
68
+ "chat.generating": "Generating...",
69
+ "chat.streamingPending": "Preparing the response...",
70
+ "chat.totalTime": "Total time: {time}",
71
+ "chat.turnTimeRunning": "In progress {time}",
72
+ "chat.stoppedByUser": "Turn stopped",
59
73
  "chat.streaming": "streaming",
60
74
  "chat.toolPrefix": "Tool: {name}",
61
75
  "chat.thinkingSteps": "Thinking · {count} steps",
62
76
  "chat.toolCall": "Calling tool: {name}",
77
+ "chat.toolArgs": "Args",
78
+ "chat.toolResult": "Result",
63
79
  "chat.thinking": "Thinking…",
64
80
  "chat.heading": "What would you like to research in BrainPilot?",
65
81
  "chat.aria.messages": "Conversation messages",
66
82
  "chat.messageCount": "{count} messages",
67
83
  "chat.aria.expandThinking": "Expand thinking process",
68
84
  "chat.agentThinking": "Agent is thinking",
85
+ "chat.agentWorking": "{name} is working",
86
+ "chat.agentsWorking": "{names} are working",
69
87
  "chat.aria.stop": "Stop generating",
70
88
  "chat.stop": "stop",
71
89
  "chat.aria.newPrompt": "New research prompt",
@@ -76,6 +94,10 @@ export default defineMessages(
76
94
  "chat.modelPlaceholder": "Model",
77
95
  "chat.aria.voice": "Voice input",
78
96
  "chat.aria.attachFile": "Attach file",
97
+ "chat.aria.removeAttachment": "Remove attachment",
98
+ "chat.upload.failed": "Upload failed: {msg}",
99
+ "chat.upload.uploading": "Uploading…",
100
+ "chat.upload.notice": "[Uploaded file: {names}]",
79
101
  "chat.aria.send": "Send message",
80
102
  "chat.copy": "Copy",
81
103
  "chat.copied": "Copied",
@@ -10,6 +10,7 @@ export default defineMessages(
10
10
  "ctx.session.newSession": "新研究会话",
11
11
  "ctx.session.noConnection": "实时连接尚未建立",
12
12
  "ctx.session.sendFailed": "发送失败",
13
+ "ctx.session.sendTimeout": "发送超时,请重试",
13
14
  "ctx.session.interruptFailed": "中断失败",
14
15
  "ctx.session.refreshFailed": "刷新消息失败",
15
16
  "ctx.sandbox.loadFailed": "加载沙箱失败",
@@ -28,6 +29,7 @@ export default defineMessages(
28
29
  "ctx.session.newSession": "New research session",
29
30
  "ctx.session.noConnection": "Live connection not established yet",
30
31
  "ctx.session.sendFailed": "Failed to send",
32
+ "ctx.session.sendTimeout": "Send timed out — please retry",
31
33
  "ctx.session.interruptFailed": "Failed to interrupt",
32
34
  "ctx.session.refreshFailed": "Failed to refresh messages",
33
35
  "ctx.sandbox.loadFailed": "Failed to load sandbox",