@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.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +5 -2
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -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__/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/MessageStream.tsx +97 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +91 -29
- package/src/components/demo/TraceNodeModal.tsx +6 -2
- package/src/components/demo/demoBundle.ts +7 -2
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/session/AgentNetwork.tsx +68 -75
- package/src/components/session/AgentTraceViews.tsx +35 -70
- package/src/components/session/AnalyticsTab.tsx +58 -224
- package/src/components/session/TraceGraphView.tsx +36 -30
- package/src/components/session/TraceNodeDetail.tsx +61 -24
- package/src/components/session/agentNetworkShared.ts +10 -0
- package/src/components/session/traceLayout.ts +32 -0
- package/src/components/settings/SettingsDialog.tsx +19 -1
- package/src/components/shell/DesktopShell.tsx +39 -14
- package/src/components/sidebar/Sidebar.tsx +6 -2
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +354 -43
- package/src/contexts/agentsReducer.ts +49 -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 +53 -2
- package/src/i18n/messages/analytics.ts +16 -6
- package/src/i18n/messages/chat.ts +26 -4
- package/src/i18n/messages/contexts.ts +2 -0
- package/src/i18n/messages/network.ts +13 -9
- package/src/i18n/messages/profile.ts +4 -0
- package/src/i18n/messages/settings.ts +4 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +204 -55
- package/src/utils/api.ts +105 -8
- package/src/utils/toolDisplay.ts +74 -0
- package/dist/assets/index-C-8G4D4j.js +0 -448
- 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
|
+
}
|
package/src/contracts/backend.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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",
|