@brainpilot/web 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-DWOsU22G.css +1 -0
- package/dist/assets/index-j3rGyO6m.js +445 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +118 -0
- package/src/__tests__/chatScrollBehavior.test.ts +48 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +96 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/internalToolStrip.test.ts +108 -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 +104 -56
- package/src/components/chat/PromptComposer.tsx +120 -29
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoView.tsx +98 -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 +72 -17
- package/src/components/sidebar/SessionList.tsx +127 -0
- package/src/components/sidebar/Sidebar.tsx +94 -98
- package/src/contexts/SSEContext.tsx +90 -1
- package/src/contexts/SessionContext.tsx +397 -43
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/messageGroups.ts +56 -0
- package/src/contexts/messageReducer.ts +4 -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/shell.ts +2 -0
- package/src/i18n/messages/trace.ts +69 -17
- package/src/mocks/backend.ts +7 -0
- package/src/styles/global.css +289 -70
- 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,9 +1,8 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { Network, Pause, Play, RefreshCw, Search, UserRoundCog, X } from "lucide-react";
|
|
3
|
-
import {
|
|
3
|
+
import { TraceNode } from "../../contracts/backend";
|
|
4
4
|
import { useSessions } from "../../contexts/SessionContext";
|
|
5
5
|
import { useT } from "../../i18n/useT";
|
|
6
|
-
import { api } from "../../utils/api";
|
|
7
6
|
import { CustomSelect } from "../primitives/CustomSelect";
|
|
8
7
|
import { IconButton } from "../primitives/IconButton";
|
|
9
8
|
import { AgentNetwork } from "./AgentNetwork";
|
|
@@ -12,6 +11,7 @@ import { TraceNodeDetail } from "./TraceNodeDetail";
|
|
|
12
11
|
import {
|
|
13
12
|
formatTime,
|
|
14
13
|
getNodeKind,
|
|
14
|
+
getNodeKindLabelKey,
|
|
15
15
|
getStatusLabelKey,
|
|
16
16
|
normalizeStatus,
|
|
17
17
|
} from "./traceLayout";
|
|
@@ -54,23 +54,27 @@ export function AgentsPanel() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export function TracePanel() {
|
|
57
|
-
|
|
57
|
+
// #79: trace is now live — seeded + kept current by SessionContext via SSE
|
|
58
|
+
// (CUSTOM:trace_node), so this panel reads it instead of polling.
|
|
59
|
+
const { currentSession, currentTrace, refreshTrace } = useSessions();
|
|
58
60
|
const t = useT();
|
|
59
|
-
const
|
|
61
|
+
const trace = currentTrace;
|
|
60
62
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
61
63
|
const [query, setQuery] = useState("");
|
|
62
64
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
63
65
|
const [typeFilter, setTypeFilter] = useState("all");
|
|
64
66
|
const [direction, setDirection] = useState<"LR" | "TB">("LR");
|
|
65
67
|
const [zoom, setZoom] = useState(1);
|
|
66
|
-
const [
|
|
67
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
68
|
-
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
69
69
|
const [playbackIndex, setPlaybackIndex] = useState(0);
|
|
70
70
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
71
71
|
const [fitToken, setFitToken] = useState(0);
|
|
72
72
|
const wasUserAdjustedRef = useRef(false);
|
|
73
73
|
const prevNodeCountRef = useRef(0);
|
|
74
|
+
const formatNodeKind = (kind: string) => {
|
|
75
|
+
const key = getNodeKindLabelKey(kind);
|
|
76
|
+
return key ? t(key) : kind;
|
|
77
|
+
};
|
|
74
78
|
|
|
75
79
|
const allNodes = trace?.nodes ?? [];
|
|
76
80
|
const playbackNodes = useMemo(() => allNodes.slice(0, playbackIndex), [allNodes, playbackIndex]);
|
|
@@ -126,54 +130,24 @@ export function TracePanel() {
|
|
|
126
130
|
return visibleNodes.find((node) => node.id === selectedNodeId) ?? visibleNodes[0] ?? null;
|
|
127
131
|
}, [selectedNodeId, trace, visibleNodes]);
|
|
128
132
|
|
|
129
|
-
const
|
|
130
|
-
if (!currentSession)
|
|
131
|
-
|
|
132
|
-
setSelectedNodeId(null);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (!silent) {
|
|
136
|
-
setIsLoading(true);
|
|
137
|
-
}
|
|
138
|
-
setError(null);
|
|
133
|
+
const handleRefresh = async () => {
|
|
134
|
+
if (!currentSession) return;
|
|
135
|
+
setIsRefreshing(true);
|
|
139
136
|
try {
|
|
140
|
-
|
|
141
|
-
setTrace(nextTrace);
|
|
142
|
-
if (!silent) {
|
|
143
|
-
prevNodeCountRef.current = nextTrace.nodes.length;
|
|
144
|
-
if (!wasUserAdjustedRef.current) {
|
|
145
|
-
setPlaybackIndex(nextTrace.nodes.length);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
setSelectedNodeId((current) => {
|
|
149
|
-
if (current && nextTrace.nodes.some((node) => node.id === current)) {
|
|
150
|
-
return current;
|
|
151
|
-
}
|
|
152
|
-
return nextTrace.nodes[0]?.id ?? null;
|
|
153
|
-
});
|
|
154
|
-
} catch (err) {
|
|
155
|
-
if (!silent) {
|
|
156
|
-
setTrace(null);
|
|
157
|
-
setError(err instanceof Error ? err.message : t("trace.loadFailed"));
|
|
158
|
-
}
|
|
137
|
+
await refreshTrace(currentSession.id);
|
|
159
138
|
} finally {
|
|
160
|
-
|
|
161
|
-
setIsLoading(false);
|
|
162
|
-
}
|
|
139
|
+
setIsRefreshing(false);
|
|
163
140
|
}
|
|
164
141
|
};
|
|
165
142
|
|
|
143
|
+
// Keep a selection valid as nodes stream in: default to the first node, and
|
|
144
|
+
// hold the current selection while it still exists.
|
|
166
145
|
useEffect(() => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
const interval = window.setInterval(() => void loadTrace(true), 3000);
|
|
175
|
-
return () => window.clearInterval(interval);
|
|
176
|
-
}, [currentSession?.id, isAutoRefresh]);
|
|
146
|
+
setSelectedNodeId((current) => {
|
|
147
|
+
if (current && allNodes.some((node) => node.id === current)) return current;
|
|
148
|
+
return allNodes[0]?.id ?? null;
|
|
149
|
+
});
|
|
150
|
+
}, [allNodes]);
|
|
177
151
|
|
|
178
152
|
useEffect(() => {
|
|
179
153
|
if (selectedNodeId && visibleNodes.length > 0 && !visibleNodeIds.has(selectedNodeId)) {
|
|
@@ -250,36 +224,26 @@ export function TracePanel() {
|
|
|
250
224
|
</div>
|
|
251
225
|
<div className="trace-toolbar">
|
|
252
226
|
<div className="trace-segmented" aria-label={t("trace.aria.layoutDir")}>
|
|
253
|
-
<button className={direction === "LR" ? "is-active" : ""} onClick={() => setDirection("LR")} type="button">
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
<RefreshCw size={15} />
|
|
259
|
-
</IconButton>
|
|
260
|
-
<button
|
|
261
|
-
aria-pressed={isAutoRefresh}
|
|
262
|
-
className={`trace-live-toggle ${isAutoRefresh ? "is-active" : ""}`}
|
|
263
|
-
disabled={!currentSession}
|
|
264
|
-
onClick={() => setIsAutoRefresh((current) => !current)}
|
|
265
|
-
title={t("trace.autoRefreshTitle")}
|
|
266
|
-
type="button"
|
|
267
|
-
>
|
|
268
|
-
<span aria-hidden="true" />
|
|
269
|
-
{t("trace.live")}
|
|
227
|
+
<button className={direction === "LR" ? "is-active" : ""} onClick={() => setDirection("LR")} type="button">
|
|
228
|
+
{t("trace.layout.horizontal")}
|
|
229
|
+
</button>
|
|
230
|
+
<button className={direction === "TB" ? "is-active" : ""} onClick={() => setDirection("TB")} type="button">
|
|
231
|
+
{t("trace.layout.vertical")}
|
|
270
232
|
</button>
|
|
271
233
|
</div>
|
|
234
|
+
<IconButton className={isRefreshing ? "is-active" : ""} disabled={!currentSession} label={t("trace.aria.refresh")} onClick={() => void handleRefresh()}>
|
|
235
|
+
<RefreshCw size={15} />
|
|
236
|
+
</IconButton>
|
|
272
237
|
</div>
|
|
273
238
|
</header>
|
|
274
239
|
|
|
275
|
-
{error ? <p className="workspace-panel__empty workspace-panel__empty--error">{error}</p> : null}
|
|
276
240
|
{!currentSession ? <p className="workspace-panel__empty">{t("trace.emptyNoSession")}</p> : null}
|
|
277
241
|
|
|
278
242
|
{trace ? (
|
|
279
243
|
<>
|
|
280
244
|
<div className="trace-meta">
|
|
281
245
|
<span>{trace.meta.projectName || currentSession?.title || t("trace.untitled")}</span>
|
|
282
|
-
<span>{t("trace.focus", { focus: String(trace.meta.currentFocus
|
|
246
|
+
{trace.meta.currentFocus ? <span>{t("trace.focus", { focus: String(trace.meta.currentFocus) })}</span> : null}
|
|
283
247
|
<span>{t("trace.nodes", { visible: visibleNodes.length, total: trace.nodes.length })}</span>
|
|
284
248
|
<span>{t("trace.created", { time: formatTime(trace.meta.createdAt) })}</span>
|
|
285
249
|
</div>
|
|
@@ -318,7 +282,7 @@ export function TracePanel() {
|
|
|
318
282
|
onChange={setTypeFilter}
|
|
319
283
|
options={[
|
|
320
284
|
{ label: t("trace.allTypes"), value: "all" },
|
|
321
|
-
...typeOptions.map((type) => ({ label: type, value: type })),
|
|
285
|
+
...typeOptions.map((type) => ({ label: formatNodeKind(type), value: type })),
|
|
322
286
|
]}
|
|
323
287
|
value={typeFilter}
|
|
324
288
|
/>
|
|
@@ -336,6 +300,7 @@ export function TracePanel() {
|
|
|
336
300
|
onZoomChange={setZoom}
|
|
337
301
|
fitToken={fitToken}
|
|
338
302
|
emptyLabel={t("trace.noMatch")}
|
|
303
|
+
formatKind={formatNodeKind}
|
|
339
304
|
zoomLabels={{
|
|
340
305
|
controls: t("trace.aria.zoomControls"),
|
|
341
306
|
zoomIn: t("trace.aria.zoomIn"),
|
|
@@ -370,7 +335,7 @@ export function TracePanel() {
|
|
|
370
335
|
</div>
|
|
371
336
|
|
|
372
337
|
<article className="trace-detail">
|
|
373
|
-
<TraceNodeDetail node={selectedNode} onSelectNode={setSelectedNodeId} t={t} />
|
|
338
|
+
<TraceNodeDetail node={selectedNode} nodes={allNodes} onSelectNode={setSelectedNodeId} formatKind={formatNodeKind} t={t} />
|
|
374
339
|
</article>
|
|
375
340
|
</div>
|
|
376
341
|
</>
|
|
@@ -1,29 +1,19 @@
|
|
|
1
1
|
/* --------------------------------------------------------------------------
|
|
2
2
|
* AnalyticsTab — session-level statistics rendered as hand-rolled SVG (the
|
|
3
3
|
* house style; no chart library). Always global: not affected by node
|
|
4
|
-
* selection.
|
|
4
|
+
* selection. Kept intentionally compact for end users: traffic, average
|
|
5
|
+
* message length, and lifecycle heatmap.
|
|
5
6
|
* ------------------------------------------------------------------------ */
|
|
6
7
|
import { useMemo } from "react";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
Inbox,
|
|
10
|
-
Timer,
|
|
11
|
-
TrendingUp,
|
|
12
|
-
Users,
|
|
13
|
-
} from "lucide-react";
|
|
14
|
-
import { AgentStatus, ChatMessage } from "../../contracts/backend";
|
|
8
|
+
import { Inbox } from "lucide-react";
|
|
9
|
+
import { AgentStatus, ChatMessage, TokenUsage } from "../../contracts/backend";
|
|
15
10
|
import { AgentEdge, getAgentAccentVar } from "./agentNetworkShared";
|
|
16
11
|
import {
|
|
17
12
|
computeAgentLoad,
|
|
18
|
-
computeErrorCount,
|
|
19
13
|
computeLifecycleHeatmap,
|
|
20
|
-
computeMessageTrend,
|
|
21
|
-
computeResponseLatencies,
|
|
22
|
-
computeTypeDistribution,
|
|
23
14
|
estimateTokens,
|
|
24
|
-
formatDuration,
|
|
25
|
-
summarizeLatencies,
|
|
26
15
|
} from "./agentAnalytics";
|
|
16
|
+
import { useSessions } from "../../contexts/SessionContext";
|
|
27
17
|
import { useT } from "../../i18n/useT";
|
|
28
18
|
|
|
29
19
|
interface AnalyticsTabProps {
|
|
@@ -33,27 +23,33 @@ interface AnalyticsTabProps {
|
|
|
33
23
|
now: number;
|
|
34
24
|
}
|
|
35
25
|
|
|
26
|
+
/** Compact number formatting for token counts (1.2k, 3.4M). */
|
|
27
|
+
function fmtTokens(n: number): string {
|
|
28
|
+
if (n < 1000) return String(n);
|
|
29
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
|
|
30
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
31
|
+
}
|
|
32
|
+
|
|
36
33
|
export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps) {
|
|
37
34
|
const t = useT();
|
|
38
|
-
const
|
|
35
|
+
const { tokenUsage } = useSessions();
|
|
39
36
|
const load = useMemo(() => computeAgentLoad(edges), [edges]);
|
|
40
|
-
const
|
|
41
|
-
const latencyStats = useMemo(
|
|
42
|
-
() => summarizeLatencies(computeResponseLatencies(messages)),
|
|
43
|
-
[messages],
|
|
44
|
-
);
|
|
45
|
-
const tokens = useMemo(() => estimateTokens(messages), [messages]);
|
|
37
|
+
const lengthRows = useMemo(() => estimateTokens(messages), [messages]);
|
|
46
38
|
const heatmap = useMemo(
|
|
47
39
|
() => computeLifecycleHeatmap(messages, agents.map((a) => a.name), now),
|
|
48
40
|
[messages, agents, now],
|
|
49
41
|
);
|
|
50
|
-
|
|
42
|
+
|
|
43
|
+
// Per-agent real-usage rows, sorted by total desc. Empty when no provider
|
|
44
|
+
// usage has been reported yet (e.g. a freshly restored session pre-turn).
|
|
45
|
+
const usageRows = useMemo(() => {
|
|
46
|
+
if (!tokenUsage) return [] as Array<{ name: string } & TokenUsage>;
|
|
47
|
+
return Object.entries(tokenUsage.byAgent)
|
|
48
|
+
.map(([name, u]) => ({ name, ...u }))
|
|
49
|
+
.sort((a, b) => b.total - a.total);
|
|
50
|
+
}, [tokenUsage]);
|
|
51
51
|
|
|
52
52
|
const totalMessages = edges.reduce((s, e) => s + e.messages.length, 0);
|
|
53
|
-
const liveCount = agents.length;
|
|
54
|
-
const runningCount = agents.filter(
|
|
55
|
-
(a) => a.status === "running" || a.status === "in_progress",
|
|
56
|
-
).length;
|
|
57
53
|
|
|
58
54
|
if (totalMessages === 0) {
|
|
59
55
|
return (
|
|
@@ -66,56 +62,39 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
|
|
|
66
62
|
|
|
67
63
|
return (
|
|
68
64
|
<div className="agent-analytics">
|
|
69
|
-
{
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
className={`agent-analytics__card ${errorCount > 0 ? "agent-analytics__card--danger" : "agent-analytics__card--ok"}`}
|
|
103
|
-
>
|
|
104
|
-
<span className="agent-analytics__card-label">
|
|
105
|
-
<AlertTriangle size={12} /> {t("analytics.card.errors")}
|
|
106
|
-
</span>
|
|
107
|
-
<span className="agent-analytics__card-value">{errorCount}</span>
|
|
108
|
-
<span className="agent-analytics__card-sub">{errorCount === 0 ? t("analytics.card.allClear") : t("analytics.card.needsAttention")}</span>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
{/* ---- Group 2: trends & distribution ---- */}
|
|
113
|
-
<section className="agent-analytics__chart">
|
|
114
|
-
<h4 className="agent-analytics__chart-title">
|
|
115
|
-
<TrendingUp size={13} /> {t("analytics.chart.volume")}
|
|
116
|
-
</h4>
|
|
117
|
-
<LineChart values={trend.map((t) => t.count)} />
|
|
118
|
-
</section>
|
|
65
|
+
{tokenUsage && tokenUsage.total.total > 0 ? (
|
|
66
|
+
<section className="agent-analytics__chart">
|
|
67
|
+
<h4 className="agent-analytics__chart-title">{t("analytics.chart.tokens")}</h4>
|
|
68
|
+
<div className="agent-analytics__token-total">
|
|
69
|
+
{fmtTokens(tokenUsage.total.total)}{" "}
|
|
70
|
+
<span className="agent-analytics__token-total-label">
|
|
71
|
+
{t("analytics.tokens.total")}
|
|
72
|
+
</span>
|
|
73
|
+
</div>
|
|
74
|
+
<table className="agent-analytics__table">
|
|
75
|
+
<thead>
|
|
76
|
+
<tr>
|
|
77
|
+
<th>{t("analytics.table.agent")}</th>
|
|
78
|
+
<th>{t("analytics.tokens.input")}</th>
|
|
79
|
+
<th>{t("analytics.tokens.output")}</th>
|
|
80
|
+
<th>{t("analytics.tokens.cache")}</th>
|
|
81
|
+
<th>{t("analytics.tokens.total")}</th>
|
|
82
|
+
</tr>
|
|
83
|
+
</thead>
|
|
84
|
+
<tbody>
|
|
85
|
+
{usageRows.map((row) => (
|
|
86
|
+
<tr key={row.name}>
|
|
87
|
+
<td>{row.name}</td>
|
|
88
|
+
<td>{fmtTokens(row.input)}</td>
|
|
89
|
+
<td>{fmtTokens(row.output)}</td>
|
|
90
|
+
<td>{fmtTokens(row.cacheRead + row.cacheWrite)}</td>
|
|
91
|
+
<td>{fmtTokens(row.total)}</td>
|
|
92
|
+
</tr>
|
|
93
|
+
))}
|
|
94
|
+
</tbody>
|
|
95
|
+
</table>
|
|
96
|
+
</section>
|
|
97
|
+
) : null}
|
|
119
98
|
|
|
120
99
|
<section className="agent-analytics__chart">
|
|
121
100
|
<h4 className="agent-analytics__chart-title">{t("analytics.chart.load")}</h4>
|
|
@@ -123,36 +102,21 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
|
|
|
123
102
|
</section>
|
|
124
103
|
|
|
125
104
|
<section className="agent-analytics__chart">
|
|
126
|
-
<h4 className="agent-analytics__chart-title">{t("analytics.chart.
|
|
127
|
-
<PieChart dist={typeDist} />
|
|
128
|
-
</section>
|
|
129
|
-
|
|
130
|
-
{/* ---- Group 3: advanced ---- */}
|
|
131
|
-
{latencyStats ? (
|
|
132
|
-
<section className="agent-analytics__chart">
|
|
133
|
-
<h4 className="agent-analytics__chart-title">{t("analytics.chart.latency")}</h4>
|
|
134
|
-
<BoxPlot stats={latencyStats} />
|
|
135
|
-
</section>
|
|
136
|
-
) : null}
|
|
137
|
-
|
|
138
|
-
<section className="agent-analytics__chart">
|
|
139
|
-
<h4 className="agent-analytics__chart-title">{t("analytics.chart.tokens")}</h4>
|
|
105
|
+
<h4 className="agent-analytics__chart-title">{t("analytics.chart.avgLength")}</h4>
|
|
140
106
|
<table className="agent-analytics__table">
|
|
141
107
|
<thead>
|
|
142
108
|
<tr>
|
|
143
109
|
<th>{t("analytics.table.agent")}</th>
|
|
144
110
|
<th>{t("analytics.table.msgs")}</th>
|
|
145
111
|
<th>{t("analytics.table.avgLen")}</th>
|
|
146
|
-
<th>{t("analytics.table.tokens")}</th>
|
|
147
112
|
</tr>
|
|
148
113
|
</thead>
|
|
149
114
|
<tbody>
|
|
150
|
-
{
|
|
115
|
+
{lengthRows.map((row) => (
|
|
151
116
|
<tr key={row.name}>
|
|
152
117
|
<td>{row.name}</td>
|
|
153
118
|
<td>{row.sentMsgs}</td>
|
|
154
119
|
<td>{row.avgLen}</td>
|
|
155
|
-
<td>{row.tokens.toLocaleString()}</td>
|
|
156
120
|
</tr>
|
|
157
121
|
))}
|
|
158
122
|
</tbody>
|
|
@@ -173,41 +137,6 @@ export function AnalyticsTab({ agents, messages, edges, now }: AnalyticsTabProps
|
|
|
173
137
|
* Hand-rolled SVG primitives
|
|
174
138
|
* ------------------------------------------------------------------------ */
|
|
175
139
|
|
|
176
|
-
function Sparkline({ values }: { values: number[] }) {
|
|
177
|
-
const w = 200;
|
|
178
|
-
const h = 28;
|
|
179
|
-
if (values.length === 0) return null;
|
|
180
|
-
const max = Math.max(1, ...values);
|
|
181
|
-
const step = values.length > 1 ? w / (values.length - 1) : w;
|
|
182
|
-
const points = values.map((v, i) => `${i * step},${h - (v / max) * (h - 2) - 1}`).join(" ");
|
|
183
|
-
return (
|
|
184
|
-
<svg className="agent-analytics__sparkline" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" aria-hidden="true">
|
|
185
|
-
<polyline points={points} fill="none" stroke="var(--color-info)" strokeWidth="1.5" />
|
|
186
|
-
</svg>
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function LineChart({ values }: { values: number[] }) {
|
|
191
|
-
const t = useT();
|
|
192
|
-
const w = 320;
|
|
193
|
-
const h = 110;
|
|
194
|
-
const pad = 6;
|
|
195
|
-
const max = Math.max(1, ...values);
|
|
196
|
-
const step = values.length > 1 ? (w - pad * 2) / (values.length - 1) : w;
|
|
197
|
-
const pts = values.map((v, i) => ({
|
|
198
|
-
x: pad + i * step,
|
|
199
|
-
y: h - pad - (v / max) * (h - pad * 2),
|
|
200
|
-
}));
|
|
201
|
-
const line = pts.map((p) => `${p.x},${p.y}`).join(" ");
|
|
202
|
-
const area = `${pad},${h - pad} ${line} ${pad + (values.length - 1) * step},${h - pad}`;
|
|
203
|
-
return (
|
|
204
|
-
<svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.volume")}>
|
|
205
|
-
<polygon points={area} fill="color-mix(in srgb, var(--color-info) 16%, transparent)" stroke="none" />
|
|
206
|
-
<polyline points={line} fill="none" stroke="var(--color-info)" strokeWidth="1.75" strokeLinejoin="round" />
|
|
207
|
-
</svg>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
140
|
function BarChart({ rows }: { rows: { name: string; total: number }[] }) {
|
|
212
141
|
const t = useT();
|
|
213
142
|
if (rows.length === 0) return <p className="agent-analytics__hint">No traffic yet.</p>;
|
|
@@ -246,101 +175,6 @@ function BarChart({ rows }: { rows: { name: string; total: number }[] }) {
|
|
|
246
175
|
);
|
|
247
176
|
}
|
|
248
177
|
|
|
249
|
-
function PieChart({ dist }: { dist: { delegate: number; result: number; other: number } }) {
|
|
250
|
-
const t = useT();
|
|
251
|
-
const segments = [
|
|
252
|
-
{ key: "delegate", value: dist.delegate, color: "var(--color-info)" },
|
|
253
|
-
{ key: "result", value: dist.result, color: "var(--color-success)" },
|
|
254
|
-
{ key: "other", value: dist.other, color: "var(--color-text-subtle)" },
|
|
255
|
-
].filter((s) => s.value > 0);
|
|
256
|
-
const total = segments.reduce((s, seg) => s + seg.value, 0);
|
|
257
|
-
if (total === 0) return <p className="agent-analytics__hint">No typed messages.</p>;
|
|
258
|
-
|
|
259
|
-
const cx = 55;
|
|
260
|
-
const cy = 55;
|
|
261
|
-
const r = 48;
|
|
262
|
-
let angle = -Math.PI / 2;
|
|
263
|
-
const arcs = segments.map((seg) => {
|
|
264
|
-
const frac = seg.value / total;
|
|
265
|
-
const start = angle;
|
|
266
|
-
const end = angle + frac * Math.PI * 2;
|
|
267
|
-
angle = end;
|
|
268
|
-
const x1 = cx + r * Math.cos(start);
|
|
269
|
-
const y1 = cy + r * Math.sin(start);
|
|
270
|
-
const x2 = cx + r * Math.cos(end);
|
|
271
|
-
const y2 = cy + r * Math.sin(end);
|
|
272
|
-
const large = end - start > Math.PI ? 1 : 0;
|
|
273
|
-
// Full-circle guard (single segment = 100%).
|
|
274
|
-
const d =
|
|
275
|
-
segments.length === 1
|
|
276
|
-
? `M ${cx - r} ${cy} a ${r} ${r} 0 1 0 ${r * 2} 0 a ${r} ${r} 0 1 0 ${-r * 2} 0`
|
|
277
|
-
: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`;
|
|
278
|
-
return { d, color: seg.color, key: seg.key };
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
return (
|
|
282
|
-
<div className="agent-analytics__pie-wrap">
|
|
283
|
-
<svg className="agent-analytics__pie" viewBox="0 0 110 110" role="img" aria-label={t("analytics.aria.types")}>
|
|
284
|
-
{arcs.map((a) => (
|
|
285
|
-
<path key={a.key} d={a.d} fill={a.color} stroke="var(--color-surface)" strokeWidth="1" />
|
|
286
|
-
))}
|
|
287
|
-
<circle cx={cx} cy={cy} r={22} fill="var(--color-surface-raised)" />
|
|
288
|
-
<text x={cx} y={cy + 4} textAnchor="middle" className="agent-analytics__pie-total">
|
|
289
|
-
{total}
|
|
290
|
-
</text>
|
|
291
|
-
</svg>
|
|
292
|
-
<ul className="agent-analytics__legend">
|
|
293
|
-
{segments.map((s) => (
|
|
294
|
-
<li key={s.key}>
|
|
295
|
-
<i style={{ background: s.color }} /> {s.key} ({s.value})
|
|
296
|
-
</li>
|
|
297
|
-
))}
|
|
298
|
-
</ul>
|
|
299
|
-
</div>
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function BoxPlot({
|
|
304
|
-
stats,
|
|
305
|
-
}: {
|
|
306
|
-
stats: { min: number; q1: number; median: number; q3: number; max: number };
|
|
307
|
-
}) {
|
|
308
|
-
const t = useT();
|
|
309
|
-
const w = 320;
|
|
310
|
-
const h = 56;
|
|
311
|
-
const pad = 14;
|
|
312
|
-
const span = Math.max(1, stats.max - stats.min);
|
|
313
|
-
const scale = (v: number) => pad + ((v - stats.min) / span) * (w - pad * 2);
|
|
314
|
-
const midY = h / 2;
|
|
315
|
-
return (
|
|
316
|
-
<div>
|
|
317
|
-
<svg className="agent-analytics__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label={t("analytics.aria.latency")}>
|
|
318
|
-
{/* whisker */}
|
|
319
|
-
<line x1={scale(stats.min)} x2={scale(stats.max)} y1={midY} y2={midY} stroke="var(--color-border-strong)" strokeWidth="1.5" />
|
|
320
|
-
<line x1={scale(stats.min)} x2={scale(stats.min)} y1={midY - 8} y2={midY + 8} stroke="var(--color-border-strong)" />
|
|
321
|
-
<line x1={scale(stats.max)} x2={scale(stats.max)} y1={midY - 8} y2={midY + 8} stroke="var(--color-border-strong)" />
|
|
322
|
-
{/* box */}
|
|
323
|
-
<rect
|
|
324
|
-
x={scale(stats.q1)}
|
|
325
|
-
y={midY - 12}
|
|
326
|
-
width={Math.max(2, scale(stats.q3) - scale(stats.q1))}
|
|
327
|
-
height={24}
|
|
328
|
-
rx={3}
|
|
329
|
-
fill="color-mix(in srgb, var(--color-info) 20%, transparent)"
|
|
330
|
-
stroke="var(--color-info)"
|
|
331
|
-
/>
|
|
332
|
-
{/* median */}
|
|
333
|
-
<line x1={scale(stats.median)} x2={scale(stats.median)} y1={midY - 12} y2={midY + 12} stroke="var(--color-info)" strokeWidth="2" />
|
|
334
|
-
</svg>
|
|
335
|
-
<div className="agent-analytics__boxplot-legend">
|
|
336
|
-
<span>min {formatDuration(stats.min)}</span>
|
|
337
|
-
<span>med {formatDuration(stats.median)}</span>
|
|
338
|
-
<span>max {formatDuration(stats.max)}</span>
|
|
339
|
-
</div>
|
|
340
|
-
</div>
|
|
341
|
-
);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
178
|
function HeatmapGrid({
|
|
345
179
|
heatmap,
|
|
346
180
|
}: {
|
|
@@ -26,6 +26,7 @@ interface TraceGraphViewProps {
|
|
|
26
26
|
fitToken?: number | string;
|
|
27
27
|
/** Shown when there are no nodes to display. */
|
|
28
28
|
emptyLabel?: string;
|
|
29
|
+
formatKind?: (kind: string) => string;
|
|
29
30
|
zoomLabels?: {
|
|
30
31
|
controls?: string;
|
|
31
32
|
zoomIn?: string;
|
|
@@ -49,6 +50,7 @@ export function TraceGraphView({
|
|
|
49
50
|
onZoomChange,
|
|
50
51
|
fitToken,
|
|
51
52
|
emptyLabel,
|
|
53
|
+
formatKind,
|
|
52
54
|
zoomLabels,
|
|
53
55
|
}: TraceGraphViewProps) {
|
|
54
56
|
const [nodeOffsets, setNodeOffsets] = useState<Map<string, { dx: number; dy: number }>>(new Map());
|
|
@@ -264,36 +266,40 @@ export function TraceGraphView({
|
|
|
264
266
|
);
|
|
265
267
|
}),
|
|
266
268
|
)}
|
|
267
|
-
{adjustedLayout.positioned.map(({ node, x, y }) =>
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
269
|
+
{adjustedLayout.positioned.map(({ node, x, y }) => {
|
|
270
|
+
const kind = getNodeKind(node);
|
|
271
|
+
const kindLabel = formatKind?.(kind) ?? kind;
|
|
272
|
+
return (
|
|
273
|
+
<g
|
|
274
|
+
className={`trace-map-node trace-map-node--${kind} trace-map-node--${normalizeStatus(node.status)} ${selectedNodeId === node.id ? "is-selected" : ""} ${draggingNodeRef.current?.nodeId === node.id ? "is-dragging" : ""}`}
|
|
275
|
+
key={node.id}
|
|
276
|
+
onMouseDown={(e) => {
|
|
277
|
+
e.stopPropagation();
|
|
278
|
+
const offset = nodeOffsets.get(node.id) || { dx: 0, dy: 0 };
|
|
279
|
+
draggingNodeRef.current = {
|
|
280
|
+
nodeId: node.id,
|
|
281
|
+
startClientX: e.clientX,
|
|
282
|
+
startClientY: e.clientY,
|
|
283
|
+
startDx: offset.dx,
|
|
284
|
+
startDy: offset.dy,
|
|
285
|
+
};
|
|
286
|
+
}}
|
|
287
|
+
onClick={() => {
|
|
288
|
+
if (dragRef.current.hasPanned || draggingNodeRef.current) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
onSelectNode(node.id);
|
|
292
|
+
}}
|
|
293
|
+
style={{ transform: `translate(${x}px, ${y}px)` }}
|
|
294
|
+
>
|
|
295
|
+
<rect height={adjustedLayout.nodeHeight} rx="8" width={adjustedLayout.nodeWidth} />
|
|
296
|
+
<circle className={`trace-node__dot--${normalizeStatus(node.status)}`} cx="16" cy="24" r="4" />
|
|
297
|
+
<text className="trace-map-node__title" x="28" y="26">{truncateNodeTitle(node.title)}</text>
|
|
298
|
+
<text className="trace-map-node__meta" x="28" y="44">{node.agent || kindLabel}</text>
|
|
299
|
+
<text className="trace-map-node__kind" x="28" y="58">{kindLabel}</text>
|
|
300
|
+
</g>
|
|
301
|
+
);
|
|
302
|
+
})}
|
|
297
303
|
</svg>
|
|
298
304
|
</div>
|
|
299
305
|
</>
|