@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
// TODO(dead-code): SessionEventEntry removed with pre-AG-UI polling protocol.
|
|
3
|
-
import { AgentStatus, ChatMessage, MessageFilterRule, Session, /* SessionEventEntry, */ SessionMessageEntry } from "../contracts/backend";
|
|
3
|
+
import { AgentStatus, ChatMessage, MessageFilterRule, Session, SessionTokenUsage, TraceGraph, normalizeWebSocketEvent, /* SessionEventEntry, */ SessionMessageEntry } from "../contracts/backend";
|
|
4
4
|
import { api } from "../utils/api";
|
|
5
5
|
import { tg } from "../i18n/translate";
|
|
6
6
|
import { useAuth } from "./AuthContext";
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
generateUUID,
|
|
15
15
|
reduceMessagesForEvent,
|
|
16
16
|
} from "./messageReducer";
|
|
17
|
+
import { reduceAgentsForEvent } from "./agentsReducer";
|
|
18
|
+
import { reduceTraceForEvent } from "./traceReducer";
|
|
17
19
|
|
|
18
20
|
export interface AgentMessageFilter {
|
|
19
21
|
hideMessages: boolean;
|
|
@@ -37,7 +39,24 @@ interface SessionContextValue {
|
|
|
37
39
|
error: string | null;
|
|
38
40
|
currentView: "chat" | "agents" | "trace";
|
|
39
41
|
agents: AgentStatus[];
|
|
42
|
+
/**
|
|
43
|
+
* #99: authoritative whole-turn run-active signal from session_state.runState
|
|
44
|
+
* (trace agent excluded, delivery loops included), with the backend timestamp
|
|
45
|
+
* of the snapshot. null until the first session_state arrives. Drives the
|
|
46
|
+
* whole-turn timer in the Chat footer.
|
|
47
|
+
*/
|
|
48
|
+
runActive: { active: boolean; atMs: number } | null;
|
|
49
|
+
/**
|
|
50
|
+
* Cumulative real token usage for the current session (total + per-agent),
|
|
51
|
+
* fed live from `session_state` frames. null until the first frame carrying
|
|
52
|
+
* usage arrives.
|
|
53
|
+
*/
|
|
54
|
+
tokenUsage: SessionTokenUsage | null;
|
|
40
55
|
agentFilters: Record<string, AgentMessageFilter>;
|
|
56
|
+
/** Live Graph of Trace for the current session (#79), or null if none/unloaded. */
|
|
57
|
+
currentTrace: TraceGraph | null;
|
|
58
|
+
/** Re-seed the trace graph from the HTTP route (manual refresh). */
|
|
59
|
+
refreshTrace: (sessionId: string) => Promise<void>;
|
|
41
60
|
selectSession: (sessionId: string) => void;
|
|
42
61
|
createSession: (title?: string, opts?: { providerId?: string; modelId?: string }) => Promise<Session | null>;
|
|
43
62
|
/**
|
|
@@ -48,7 +67,9 @@ interface SessionContextValue {
|
|
|
48
67
|
startDraftSession: () => void;
|
|
49
68
|
updateSessionTitle: (sessionId: string, title: string) => Promise<void>;
|
|
50
69
|
deleteSession: (sessionId: string) => Promise<void>;
|
|
51
|
-
|
|
70
|
+
/** Resolves true when the message was accepted, false on validation/timeout/
|
|
71
|
+
* error — the composer uses this to restore the draft so input isn't lost. */
|
|
72
|
+
sendPrompt: (content: string, opts?: { providerId?: string; modelId?: string }) => Promise<boolean>;
|
|
52
73
|
interruptCurrent: () => Promise<void>;
|
|
53
74
|
/**
|
|
54
75
|
* 修正6 — answer an ask_user (user_input_request) card. Optimistically
|
|
@@ -68,6 +89,82 @@ interface SessionContextValue {
|
|
|
68
89
|
export const DRAFT_SESSION_ID = "__draft__";
|
|
69
90
|
|
|
70
91
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
|
92
|
+
// Runtime treats `limit=0` as "return the full persisted event log". Rehydrate
|
|
93
|
+
// must not use a fixed tail size because slicing through TEXT_MESSAGE_START /
|
|
94
|
+
// CONTENT / END leaves old long sessions looking empty.
|
|
95
|
+
const HISTORY_REHYDRATE_LIMIT = 0;
|
|
96
|
+
|
|
97
|
+
function foldSessionHistory(events: unknown[], sessionId: string): {
|
|
98
|
+
messages: ChatMessage[];
|
|
99
|
+
trace: TraceGraph | null;
|
|
100
|
+
agents: AgentStatus[] | null;
|
|
101
|
+
tokenUsage: SessionTokenUsage | null;
|
|
102
|
+
} {
|
|
103
|
+
let messages: ChatMessage[] = [];
|
|
104
|
+
let trace: TraceGraph | null = null;
|
|
105
|
+
let lastAgents: AgentStatus[] | null = null;
|
|
106
|
+
let lastUsage: SessionTokenUsage | null = null;
|
|
107
|
+
|
|
108
|
+
for (const raw of events) {
|
|
109
|
+
const ev = normalizeWebSocketEvent(raw);
|
|
110
|
+
if (!ev) continue;
|
|
111
|
+
messages = reduceMessagesForEvent(messages, ev);
|
|
112
|
+
trace = reduceTraceForEvent(trace, ev, sessionId);
|
|
113
|
+
if (ev.type === "CUSTOM" && ev.name === "session_state") {
|
|
114
|
+
const v = (ev.value ?? {}) as Record<string, unknown>;
|
|
115
|
+
if (Array.isArray(v.agents)) {
|
|
116
|
+
lastAgents = (v.agents as Array<Record<string, unknown>>).map((agent) => ({
|
|
117
|
+
name: String(agent.name ?? ""),
|
|
118
|
+
status: String(agent.status ?? "idle"),
|
|
119
|
+
task: String(agent.task ?? ""),
|
|
120
|
+
updatedAt:
|
|
121
|
+
typeof agent.updatedAt === "string"
|
|
122
|
+
? agent.updatedAt
|
|
123
|
+
: new Date().toISOString(),
|
|
124
|
+
alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
const usage = parseTokenUsageValue(v.tokenUsage);
|
|
128
|
+
if (usage) lastUsage = usage;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { messages, trace, agents: lastAgents, tokenUsage: lastUsage };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Coerce a wire `session_state.tokenUsage` value into SessionTokenUsage, or
|
|
137
|
+
* null when absent/malformed. Mirrors `normalizeSessionTokenUsage` in the
|
|
138
|
+
* backend contract but works off the already-shaped CUSTOM event value.
|
|
139
|
+
*/
|
|
140
|
+
function parseTokenUsageValue(rawValue: unknown): SessionTokenUsage | null {
|
|
141
|
+
if (rawValue == null || typeof rawValue !== "object") return null;
|
|
142
|
+
const raw = rawValue as Record<string, unknown>;
|
|
143
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
144
|
+
const one = (x: unknown) => {
|
|
145
|
+
const u = (x ?? {}) as Record<string, unknown>;
|
|
146
|
+
return {
|
|
147
|
+
input: num(u.input),
|
|
148
|
+
output: num(u.output),
|
|
149
|
+
cacheRead: num(u.cacheRead),
|
|
150
|
+
cacheWrite: num(u.cacheWrite),
|
|
151
|
+
total: num(u.total),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
const byAgentRaw = (raw.byAgent ?? {}) as Record<string, unknown>;
|
|
155
|
+
const byAgent: SessionTokenUsage["byAgent"] = {};
|
|
156
|
+
for (const [name, value] of Object.entries(byAgentRaw)) byAgent[name] = one(value);
|
|
157
|
+
return { total: one(raw.total), byAgent };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultAgentFilter(agentName: string): AgentMessageFilter {
|
|
161
|
+
const isTrace = agentName === "trace";
|
|
162
|
+
return {
|
|
163
|
+
hideMessages: isTrace,
|
|
164
|
+
hideTools: isTrace,
|
|
165
|
+
hideHooks: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
71
168
|
|
|
72
169
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
|
73
170
|
const { isAuthReady } = useAuth();
|
|
@@ -92,8 +189,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
92
189
|
// so keystrokes don't re-render the whole chat subtree. Drafts are keyed by
|
|
93
190
|
// session id and survive PromptComposer unmount (tab switches).
|
|
94
191
|
const [agents, setAgents] = useState<AgentStatus[]>([]);
|
|
192
|
+
// #99: authoritative whole-turn run-active flag from session_state.runState
|
|
193
|
+
// (derived by the runtime: trace agent excluded, delivery loops included),
|
|
194
|
+
// paired with the backend ISO timestamp of the snapshot that carried it. The
|
|
195
|
+
// turn timer keys off transitions of this flag, not the per-agent list.
|
|
196
|
+
const [runActive, setRunActive] = useState<{ active: boolean; atMs: number } | null>(null);
|
|
197
|
+
// Cumulative real token usage for the current session, fed from session_state.
|
|
198
|
+
const [tokenUsage, setTokenUsage] = useState<SessionTokenUsage | null>(null);
|
|
95
199
|
const [agentFilters, setAgentFilters] = useState<Record<string, AgentMessageFilter>>({});
|
|
96
200
|
const [messageFilters, setMessageFilters] = useState<MessageFilterRule[]>(defaultFilterRules);
|
|
201
|
+
// #79: live Graph of Trace per session. Seeded by a fetch on session change,
|
|
202
|
+
// then kept live by CUSTOM:trace_node SSE events (see the queue drain below).
|
|
203
|
+
const [traceBySession, setTraceBySession] = useState<Record<string, TraceGraph>>({});
|
|
97
204
|
|
|
98
205
|
|
|
99
206
|
const currentSession = useMemo(
|
|
@@ -133,23 +240,76 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
133
240
|
void refreshSessions();
|
|
134
241
|
}, [refreshSessions]);
|
|
135
242
|
|
|
243
|
+
// Sessions whose persisted history has already been pulled this page-load.
|
|
244
|
+
// Guards against re-hydrating on every tab switch — once SSE is attached we
|
|
245
|
+
// own the up-to-date state and don't want to refetch the .jsonl tail.
|
|
246
|
+
const hydratedSessionsRef = useRef<Set<string>>(new Set());
|
|
247
|
+
|
|
136
248
|
useEffect(() => {
|
|
137
249
|
if (!currentSessionId) {
|
|
138
250
|
return;
|
|
139
251
|
}
|
|
140
252
|
const sessionId = currentSessionId;
|
|
253
|
+
if (hydratedSessionsRef.current.has(sessionId)) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
141
256
|
let cancelled = false;
|
|
142
|
-
|
|
257
|
+
|
|
258
|
+
async function loadHistory() {
|
|
143
259
|
setIsRefreshingMessages(true);
|
|
144
|
-
|
|
145
|
-
|
|
260
|
+
try {
|
|
261
|
+
const { events } = await api.sessions.getHistory(sessionId, { limit: HISTORY_REHYDRATE_LIMIT });
|
|
262
|
+
if (cancelled) return;
|
|
263
|
+
|
|
264
|
+
// Replay the persisted event stream through the same reducers SSE uses
|
|
265
|
+
// (messageReducer / traceReducer / agents seed via session_state). The
|
|
266
|
+
// SSE ring buffer that arrives next is deduped by messageId/toolCallId
|
|
267
|
+
// inside the reducer, so any overlap is a no-op.
|
|
268
|
+
const { messages: nextMessages, trace: nextTrace, agents: lastAgents, tokenUsage: lastUsage } =
|
|
269
|
+
foldSessionHistory(events, sessionId);
|
|
270
|
+
|
|
271
|
+
if (cancelled) return;
|
|
272
|
+
hydratedSessionsRef.current.add(sessionId);
|
|
273
|
+
if (lastUsage) setTokenUsage(lastUsage);
|
|
274
|
+
|
|
275
|
+
// Only seed the message list if the user hasn't already started typing
|
|
276
|
+
// / receiving live SSE for this session in the brief window before
|
|
277
|
+
// history arrived (otherwise we'd clobber their in-flight messages).
|
|
278
|
+
setMessagesBySession((current) => {
|
|
279
|
+
if ((current[sessionId]?.length ?? 0) > 0) return current;
|
|
280
|
+
return { ...current, [sessionId]: nextMessages };
|
|
281
|
+
});
|
|
282
|
+
if (nextTrace) {
|
|
283
|
+
setTraceBySession((current) =>
|
|
284
|
+
current[sessionId] ? current : { ...current, [sessionId]: nextTrace! },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Only seed agents when the current panel is empty — live SSE may have
|
|
288
|
+
// already pushed an authoritative session_state since selection.
|
|
289
|
+
if (lastAgents && lastAgents.length > 0) {
|
|
290
|
+
setAgents((current) => (current.length === 0 ? lastAgents! : current));
|
|
291
|
+
setAgentFilters((current) => {
|
|
292
|
+
let changed = false;
|
|
293
|
+
const next = { ...current };
|
|
294
|
+
for (const agent of lastAgents) {
|
|
295
|
+
if (!next[agent.name]) {
|
|
296
|
+
changed = true;
|
|
297
|
+
next[agent.name] = defaultAgentFilter(agent.name);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return changed ? next : current;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// Best-effort. SSE will eventually drive the panel; we shouldn't surface
|
|
305
|
+
// a banner just because the history file was unreachable for a moment.
|
|
306
|
+
console.warn(`[SessionContext] history rehydrate failed for ${sessionId}:`, err);
|
|
307
|
+
} finally {
|
|
308
|
+
if (!cancelled) setIsRefreshingMessages(false);
|
|
146
309
|
}
|
|
147
|
-
setIsRefreshingMessages(false);
|
|
148
310
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
cancelled = true;
|
|
152
|
-
};
|
|
311
|
+
|
|
312
|
+
void loadHistory();
|
|
153
313
|
return () => {
|
|
154
314
|
cancelled = true;
|
|
155
315
|
};
|
|
@@ -160,6 +320,8 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
160
320
|
setIsDraft(false);
|
|
161
321
|
setCurrentSessionId(sessionId);
|
|
162
322
|
setCurrentView("chat");
|
|
323
|
+
setRunActive(null); // #99: drop the previous session's turn-active signal
|
|
324
|
+
setTokenUsage(null); // drop the previous session's token totals
|
|
163
325
|
connectSession(sessionId);
|
|
164
326
|
}, [connectSession]);
|
|
165
327
|
|
|
@@ -204,6 +366,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
204
366
|
// Drop any unsent draft so re-creating a session with the same id (rare,
|
|
205
367
|
// but possible) doesn't resurrect stale text.
|
|
206
368
|
draftStore.delete(sessionId);
|
|
369
|
+
hydratedSessionsRef.current.delete(sessionId);
|
|
207
370
|
} catch (err) {
|
|
208
371
|
setError(err instanceof Error ? err.message : tg("ctx.session.deleteFailed"));
|
|
209
372
|
throw err;
|
|
@@ -247,26 +410,30 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
247
410
|
const trimmed = content.trim();
|
|
248
411
|
console.log(`[SessionContext] sendPrompt: "${trimmed.slice(0, 40)}...", isConnected=${isConnected}, isDraft=${isDraft}`);
|
|
249
412
|
if (!trimmed) {
|
|
250
|
-
return;
|
|
413
|
+
return false;
|
|
251
414
|
}
|
|
252
415
|
// A draft has no SSE connection yet — the session is created and
|
|
253
416
|
// connected below. Only block on connection for an already-persisted
|
|
254
417
|
// session.
|
|
255
418
|
if (!currentSession && !isDraft) {
|
|
256
419
|
setError(tg("ctx.session.noConnection"));
|
|
257
|
-
return;
|
|
420
|
+
return false;
|
|
258
421
|
}
|
|
259
422
|
if (currentSession && !isConnected) {
|
|
260
423
|
setError(tg("ctx.session.noConnection"));
|
|
261
|
-
return;
|
|
424
|
+
return false;
|
|
262
425
|
}
|
|
263
426
|
|
|
264
427
|
setIsSending(true);
|
|
265
428
|
setError(null);
|
|
429
|
+
// Tracked so we can roll back the optimistic user message if the post
|
|
430
|
+
// fails/times out (#106) — otherwise the bubble lingers and a retry
|
|
431
|
+
// duplicates it.
|
|
432
|
+
let optimistic: { sessionId: string; messageId: string } | null = null;
|
|
266
433
|
try {
|
|
267
434
|
const session = currentSession ?? (await createSession(trimmed.slice(0, 48), opts));
|
|
268
435
|
if (!session) {
|
|
269
|
-
return;
|
|
436
|
+
return false;
|
|
270
437
|
}
|
|
271
438
|
// Freshly created (draft → persisted): open the SSE stream so the
|
|
272
439
|
// assistant's streamed reply is received.
|
|
@@ -286,6 +453,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
286
453
|
createdAt: timestamp,
|
|
287
454
|
agent: "user",
|
|
288
455
|
};
|
|
456
|
+
optimistic = { sessionId: session.id, messageId: uuid };
|
|
289
457
|
setMessagesBySession((current) => ({
|
|
290
458
|
...current,
|
|
291
459
|
[session.id]: [...(current[session.id] ?? []), userMessage],
|
|
@@ -293,9 +461,33 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
293
461
|
console.log(`[SessionContext] posting message to ${session.id}`);
|
|
294
462
|
await api.sessions.postMessage(session.id, { content: trimmed, uuid, timestamp });
|
|
295
463
|
console.log(`[SessionContext] postMessage success`);
|
|
464
|
+
return true;
|
|
296
465
|
} catch (err) {
|
|
297
466
|
console.error(`[SessionContext] sendPrompt error:`, err);
|
|
298
|
-
|
|
467
|
+
// #106: roll back the optimistic bubble so the failed send doesn't
|
|
468
|
+
// leave a ghost message (and a retry doesn't duplicate it).
|
|
469
|
+
if (optimistic) {
|
|
470
|
+
const { sessionId: sid, messageId } = optimistic;
|
|
471
|
+
setMessagesBySession((current) => ({
|
|
472
|
+
...current,
|
|
473
|
+
[sid]: (current[sid] ?? []).filter((m) => m.id !== messageId),
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
// AbortSignal.timeout() rejects with a TimeoutError (and a hard abort
|
|
477
|
+
// with AbortError). Surface a clear, retryable message instead of the
|
|
478
|
+
// raw DOMException text, and let `finally` release isSending so the
|
|
479
|
+
// composer never stays stuck on "正在准备发送".
|
|
480
|
+
const isTimeout =
|
|
481
|
+
err instanceof DOMException &&
|
|
482
|
+
(err.name === "TimeoutError" || err.name === "AbortError");
|
|
483
|
+
setError(
|
|
484
|
+
isTimeout
|
|
485
|
+
? tg("ctx.session.sendTimeout")
|
|
486
|
+
: err instanceof Error
|
|
487
|
+
? err.message
|
|
488
|
+
: tg("ctx.session.sendFailed"),
|
|
489
|
+
);
|
|
490
|
+
return false;
|
|
299
491
|
} finally {
|
|
300
492
|
setIsSending(false);
|
|
301
493
|
}
|
|
@@ -307,24 +499,34 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
307
499
|
async () => {
|
|
308
500
|
if (!currentSession) return;
|
|
309
501
|
const sid = currentSession.id;
|
|
310
|
-
//
|
|
311
|
-
// the
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
content: "Task stopped by user",
|
|
318
|
-
createdAt: new Date().toISOString(),
|
|
319
|
-
kind: "status",
|
|
320
|
-
};
|
|
321
|
-
return { ...current, [sid]: [...finalizeAssistant(msgs), stoppedMsg] };
|
|
322
|
-
});
|
|
323
|
-
setAgents((current) =>
|
|
324
|
-
current.map((a) => ({ ...a, status: "idle", task: "" })),
|
|
325
|
-
);
|
|
502
|
+
// #90: NOT optimistic. Wait for the interrupt to actually land before
|
|
503
|
+
// touching the UI — the old code optimistically forced every agent idle
|
|
504
|
+
// and inserted a "stopped" message even when the request hit the wrong
|
|
505
|
+
// endpoint and failed, permanently masking the failure while the runtime
|
|
506
|
+
// kept the agent running. We never speculatively mutate agent state now, so
|
|
507
|
+
// a failed interrupt leaves the true (still-running) state visible via the
|
|
508
|
+
// authoritative SSE session_state stream.
|
|
326
509
|
try {
|
|
327
|
-
await api.sessions.interrupt(sid);
|
|
510
|
+
const { interrupted } = await api.sessions.interrupt(sid);
|
|
511
|
+
if (!interrupted) {
|
|
512
|
+
// Nothing was running to interrupt (or the session is gone). Surface it
|
|
513
|
+
// rather than pretending the task stopped.
|
|
514
|
+
setError(tg("ctx.session.interruptFailed"));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
// Confirmed stopped: insert the status line. Agent state (incl. the PI
|
|
518
|
+
// interrupt-acknowledgement run the runtime now fires) flows in via SSE.
|
|
519
|
+
setMessagesBySession((current) => {
|
|
520
|
+
const msgs = current[sid] ?? [];
|
|
521
|
+
const stoppedMsg: ChatMessage = {
|
|
522
|
+
id: generateUUID(),
|
|
523
|
+
role: "system",
|
|
524
|
+
content: tg("chat.stoppedByUser"),
|
|
525
|
+
createdAt: new Date().toISOString(),
|
|
526
|
+
kind: "status",
|
|
527
|
+
};
|
|
528
|
+
return { ...current, [sid]: [...finalizeAssistant(msgs), stoppedMsg] };
|
|
529
|
+
});
|
|
328
530
|
} catch (err) {
|
|
329
531
|
setError(err instanceof Error ? err.message : tg("ctx.session.interruptFailed"));
|
|
330
532
|
}
|
|
@@ -362,15 +564,40 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
362
564
|
if (!currentSession) {
|
|
363
565
|
return;
|
|
364
566
|
}
|
|
365
|
-
|
|
567
|
+
console.log(`[SessionContext] refreshMessages: ${currentSession.id}`);
|
|
366
568
|
setIsRefreshingMessages(true);
|
|
367
569
|
setError(null);
|
|
368
570
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
571
|
+
const { events } = await api.sessions.getHistory(currentSession.id, {
|
|
572
|
+
limit: HISTORY_REHYDRATE_LIMIT,
|
|
573
|
+
});
|
|
574
|
+
const { messages: nextMessages, trace: nextTrace, agents: nextAgents, tokenUsage: nextUsage } =
|
|
575
|
+
foldSessionHistory(events, currentSession.id);
|
|
576
|
+
|
|
577
|
+
if (nextUsage) setTokenUsage(nextUsage);
|
|
578
|
+
setMessagesBySession((current) => ({ ...current, [currentSession.id]: nextMessages }));
|
|
579
|
+
if (nextTrace) {
|
|
580
|
+
setTraceBySession((current) => ({ ...current, [currentSession.id]: nextTrace }));
|
|
581
|
+
}
|
|
582
|
+
if (nextAgents && nextAgents.length > 0) {
|
|
583
|
+
setAgents(nextAgents);
|
|
584
|
+
setAgentFilters((current) => {
|
|
585
|
+
let changed = false;
|
|
586
|
+
const next = { ...current };
|
|
587
|
+
for (const agent of nextAgents) {
|
|
588
|
+
if (!next[agent.name]) {
|
|
589
|
+
changed = true;
|
|
590
|
+
next[agent.name] = defaultAgentFilter(agent.name);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return changed ? next : current;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
hydratedSessionsRef.current.add(currentSession.id);
|
|
597
|
+
|
|
598
|
+
// Re-connect the SSE stream so live updates continue after the disk
|
|
599
|
+
// history has refreshed the local cache.
|
|
371
600
|
disconnectSession(currentSession.id);
|
|
372
|
-
// Clear local state so the snapshot becomes the authoritative source.
|
|
373
|
-
setMessagesBySession((current) => ({ ...current, [currentSession.id]: [] }));
|
|
374
601
|
connectSession(currentSession.id);
|
|
375
602
|
} catch (err) {
|
|
376
603
|
setError(err instanceof Error ? err.message : tg("ctx.session.refreshFailed"));
|
|
@@ -405,6 +632,23 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
405
632
|
};
|
|
406
633
|
}, [currentSession?.id]);
|
|
407
634
|
|
|
635
|
+
// #79: seed the Graph of Trace once per session. Live updates thereafter
|
|
636
|
+
// arrive incrementally via CUSTOM:trace_node (queue drain below), so no poll.
|
|
637
|
+
const refreshTrace = useCallback(async (sessionId: string) => {
|
|
638
|
+
try {
|
|
639
|
+
const graph = await api.sessions.getTrace(sessionId);
|
|
640
|
+
setTraceBySession((current) => ({ ...current, [sessionId]: graph }));
|
|
641
|
+
} catch {
|
|
642
|
+
// Non-fatal — the panel shows an empty graph until events arrive.
|
|
643
|
+
}
|
|
644
|
+
}, []);
|
|
645
|
+
|
|
646
|
+
useEffect(() => {
|
|
647
|
+
const sid = currentSession?.id;
|
|
648
|
+
if (!sid) return;
|
|
649
|
+
void refreshTrace(sid);
|
|
650
|
+
}, [currentSession?.id, refreshTrace]);
|
|
651
|
+
|
|
408
652
|
// Auto-connect SSE whenever the current session changes. Other connections
|
|
409
653
|
// are kept open so background sessions continue receiving events.
|
|
410
654
|
useEffect(() => {
|
|
@@ -461,6 +705,18 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
461
705
|
alive: typeof agent.alive === "boolean" ? agent.alive : undefined,
|
|
462
706
|
}));
|
|
463
707
|
setAgents(nextAgents);
|
|
708
|
+
// #99: feed the whole-turn timer. runState.active is the authoritative
|
|
709
|
+
// "the user's task is still running" flag; lastActivityTs is the backend
|
|
710
|
+
// timestamp of this snapshot (fallback to now() if absent).
|
|
711
|
+
const rs = (value.runState ?? {}) as Record<string, unknown>;
|
|
712
|
+
const tsRaw = typeof value.lastActivityTs === "string" ? Date.parse(value.lastActivityTs) : NaN;
|
|
713
|
+
setRunActive({
|
|
714
|
+
active: rs.active === true,
|
|
715
|
+
atMs: Number.isNaN(tsRaw) ? Date.now() : tsRaw,
|
|
716
|
+
});
|
|
717
|
+
// Cumulative real token usage rides on the same frame (optional).
|
|
718
|
+
const usage = parseTokenUsageValue(value.tokenUsage);
|
|
719
|
+
if (usage) setTokenUsage(usage);
|
|
464
720
|
// Apply default filters for newly discovered agents:
|
|
465
721
|
// - trace agent: hide messages and tools by default
|
|
466
722
|
// - all agents: hide hooks by default
|
|
@@ -471,12 +727,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
471
727
|
const existing = current[agent.name];
|
|
472
728
|
if (!existing) {
|
|
473
729
|
changed = true;
|
|
474
|
-
|
|
475
|
-
next[agent.name] = {
|
|
476
|
-
hideMessages: isTrace,
|
|
477
|
-
hideTools: isTrace,
|
|
478
|
-
hideHooks: true,
|
|
479
|
-
};
|
|
730
|
+
next[agent.name] = defaultAgentFilter(agent.name);
|
|
480
731
|
}
|
|
481
732
|
}
|
|
482
733
|
return changed ? next : current;
|
|
@@ -507,6 +758,53 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
507
758
|
}
|
|
508
759
|
}
|
|
509
760
|
|
|
761
|
+
// #70: keep the Agents panel live between session_state snapshots by merging
|
|
762
|
+
// standalone agent_status_update events. session_state (above) remains the
|
|
763
|
+
// wholesale authority; this applies single-agent deltas on top so the panel
|
|
764
|
+
// updates on the first run without a reload/reselect. The queue is already
|
|
765
|
+
// scoped to the current session (queueRef.get(sid)), so background-session
|
|
766
|
+
// events don't leak in here.
|
|
767
|
+
setAgents((current) => {
|
|
768
|
+
let nextAgents = current;
|
|
769
|
+
const appended: AgentStatus[] = [];
|
|
770
|
+
for (const event of queue) {
|
|
771
|
+
if (event.type !== "agent_status_update") continue;
|
|
772
|
+
const before = nextAgents;
|
|
773
|
+
nextAgents = reduceAgentsForEvent(nextAgents, event);
|
|
774
|
+
if (nextAgents.length > before.length) {
|
|
775
|
+
appended.push(nextAgents[nextAgents.length - 1]!);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (appended.length > 0) {
|
|
779
|
+
// Apply the same default filters session_state uses for newly seen
|
|
780
|
+
// agents (trace: hide messages/tools; all: hide hooks).
|
|
781
|
+
setAgentFilters((cur) => {
|
|
782
|
+
let changed = false;
|
|
783
|
+
const nf = { ...cur };
|
|
784
|
+
for (const agent of appended) {
|
|
785
|
+
if (!cur[agent.name]) {
|
|
786
|
+
changed = true;
|
|
787
|
+
nf[agent.name] = defaultAgentFilter(agent.name);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return changed ? nf : cur;
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
return nextAgents;
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
// #79: fold CUSTOM:trace_node events into the live Graph of Trace. The queue
|
|
797
|
+
// is already scoped to the current session, so only this session's graph
|
|
798
|
+
// updates. reduceTraceForEvent ignores non-trace events (same reference).
|
|
799
|
+
setTraceBySession((current) => {
|
|
800
|
+
const start = current[sid] ?? null;
|
|
801
|
+
let graph: TraceGraph | null = start;
|
|
802
|
+
for (const event of queue) {
|
|
803
|
+
graph = reduceTraceForEvent(graph, event, sid);
|
|
804
|
+
}
|
|
805
|
+
return graph && graph !== start ? { ...current, [sid]: graph } : current;
|
|
806
|
+
});
|
|
807
|
+
|
|
510
808
|
// Process all events through the message reducer.
|
|
511
809
|
setMessagesBySession((current) => {
|
|
512
810
|
let messages = current[sid] ?? [];
|
|
@@ -539,6 +837,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
539
837
|
[],
|
|
540
838
|
);
|
|
541
839
|
|
|
840
|
+
const currentTrace = useMemo(
|
|
841
|
+
() => (currentSessionId ? (traceBySession[currentSessionId] ?? null) : null),
|
|
842
|
+
[currentSessionId, traceBySession],
|
|
843
|
+
);
|
|
844
|
+
|
|
542
845
|
const value = useMemo(
|
|
543
846
|
() => ({
|
|
544
847
|
sessions,
|
|
@@ -552,7 +855,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
552
855
|
error,
|
|
553
856
|
currentView,
|
|
554
857
|
agents,
|
|
858
|
+
runActive,
|
|
859
|
+
tokenUsage,
|
|
555
860
|
agentFilters,
|
|
861
|
+
currentTrace,
|
|
862
|
+
refreshTrace,
|
|
556
863
|
selectSession,
|
|
557
864
|
createSession,
|
|
558
865
|
startDraftSession,
|
|
@@ -579,7 +886,11 @@ export function SessionProvider({ children }: { children: ReactNode }) {
|
|
|
579
886
|
error,
|
|
580
887
|
currentView,
|
|
581
888
|
agents,
|
|
889
|
+
runActive,
|
|
890
|
+
tokenUsage,
|
|
582
891
|
agentFilters,
|
|
892
|
+
currentTrace,
|
|
893
|
+
refreshTrace,
|
|
583
894
|
selectSession,
|
|
584
895
|
createSession,
|
|
585
896
|
startDraftSession,
|
|
@@ -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,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
|
+
}
|