返回 JoyAI-Echo
EventStackerPage.tsx
根目录 / echo_longvideo / Director_Agent / webui / src / pages / EventStackerPage.tsx
1 import { useState, useMemo } from "react";
2 import { useEventStack, type EventRecord } from "@/hooks/useEventStack";
3
4 // ---------------------------------------------------------------------------
5 // Styles
6 // ---------------------------------------------------------------------------
7
8 const TYPE_STYLE: Record<string, { bg: string; text: string; label: string }> = {
9 turn_start: { bg: "bg-green-500/15", text: "text-green-700 dark:text-green-400", label: "START" },
10 turn_end: { bg: "bg-gray-500/15", text: "text-gray-600 dark:text-gray-400", label: "END" },
11 context_group: { bg: "bg-blue-500/15", text: "text-blue-700 dark:text-blue-400", label: "CONTEXT" },
12 governance: { bg: "bg-amber-500/15", text: "text-amber-700 dark:text-amber-400", label: "TRIM" },
13 model_request: { bg: "bg-purple-500/15", text: "text-purple-700 dark:text-purple-400", label: "CALL" },
14 model_response: { bg: "bg-emerald-500/15", text: "text-emerald-700 dark:text-emerald-400", label: "REPLY" },
15 tool_exec: { bg: "bg-orange-500/15", text: "text-orange-700 dark:text-orange-400", label: "TOOL" },
16 injection: { bg: "bg-cyan-500/15", text: "text-cyan-700 dark:text-cyan-400", label: "INJECT" },
17 retry: { bg: "bg-red-500/15", text: "text-red-700 dark:text-red-400", label: "RETRY" },
18 error: { bg: "bg-red-500/20", text: "text-red-700 dark:text-red-300", label: "ERROR" },
19 content_transform: { bg: "bg-yellow-500/15", text: "text-yellow-700 dark:text-yellow-400", label: "TRANSFORM" },
20 };
21 const DEFAULT_STYLE = { bg: "bg-gray-500/15", text: "text-gray-600 dark:text-gray-400", label: "EVT" };
22 function getStyle(type: string) { return TYPE_STYLE[type] ?? DEFAULT_STYLE; }
23
24 const PART_COLORS: Record<string, { bar: string; text: string }> = {
25 identity: { bar: "bg-blue-400", text: "text-blue-600 dark:text-blue-400" },
26 bootstrap: { bar: "bg-indigo-400", text: "text-indigo-600 dark:text-indigo-400" },
27 memory: { bar: "bg-purple-400", text: "text-purple-600 dark:text-purple-400" },
28 active_skills: { bar: "bg-pink-400", text: "text-pink-600 dark:text-pink-400" },
29 skills_summary: { bar: "bg-rose-400", text: "text-rose-600 dark:text-rose-400" },
30 recent_history: { bar: "bg-amber-400", text: "text-amber-600 dark:text-amber-400" },
31 history: { bar: "bg-orange-400", text: "text-orange-600 dark:text-orange-400" },
32 runtime_context: { bar: "bg-gray-400", text: "text-gray-600 dark:text-gray-400" },
33 user_message: { bar: "bg-green-400", text: "text-green-600 dark:text-green-400" },
34 system_instruction: { bar: "bg-red-400", text: "text-red-600 dark:text-red-400" },
35 };
36 const DEFAULT_PART = { bar: "bg-slate-400", text: "text-slate-600 dark:text-slate-400" };
37 function getPartColor(label: string) { return PART_COLORS[label] ?? DEFAULT_PART; }
38
39 function formatChars(n: number): string {
40 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
41 return String(n);
42 }
43
44 // ---------------------------------------------------------------------------
45 // Event grouping: merge consecutive context_part events into one group
46 // ---------------------------------------------------------------------------
47
48 interface SingleItem { kind: "single"; event: EventRecord }
49 interface ContextGroupItem { kind: "context_group"; events: EventRecord[]; totalChars: number; phase: string }
50 type ProcessedItem = SingleItem | ContextGroupItem;
51
52 const PHASE_LABELS: Record<string, string> = {
53 turn_context: "Turn Context",
54 token_estimation: "Token Estimation (Consolidator)",
55 };
56 function phaseLabel(phase: string): string {
57 return PHASE_LABELS[phase] || phase;
58 }
59
60 function groupEvents(events: EventRecord[]): ProcessedItem[] {
61 const result: ProcessedItem[] = [];
62 let buf: EventRecord[] = [];
63 let bufPhase = "";
64
65 const flush = () => {
66 if (buf.length > 0) {
67 const totalChars = buf.reduce((s, e) => s + Number(e.data.char_count || 0), 0);
68 result.push({ kind: "context_group", events: [...buf], totalChars, phase: bufPhase });
69 buf = [];
70 bufPhase = "";
71 }
72 };
73
74 for (const event of events) {
75 if (event.type === "context_part") {
76 const phase = String(event.data.phase || "");
77 if (buf.length > 0 && phase !== bufPhase) {
78 flush();
79 }
80 if (buf.length === 0) bufPhase = phase;
81 buf.push(event);
82 } else {
83 flush();
84 result.push({ kind: "single", event });
85 }
86 }
87 flush();
88 return result;
89 }
90
91 // ---------------------------------------------------------------------------
92 // Context group component — collapsed proportion bar + expandable parts
93 // ---------------------------------------------------------------------------
94
95 function ContextGroupCard({
96 group,
97 expanded,
98 onToggle,
99 }: {
100 group: ContextGroupItem;
101 expanded: boolean;
102 onToggle: () => void;
103 }) {
104 const [expandedParts, setExpandedParts] = useState<Set<number>>(new Set());
105 const style = getStyle("context_group");
106
107 const togglePart = (idx: number) => {
108 setExpandedParts((prev) => {
109 const next = new Set(prev);
110 if (next.has(idx)) next.delete(idx);
111 else next.add(idx);
112 return next;
113 });
114 };
115
116 return (
117 <div>
118 {/* Header row */}
119 <button
120 onClick={onToggle}
121 className="flex w-full items-center gap-2 px-2 py-0.5 text-xs text-left rounded hover:bg-muted/30 transition-colors cursor-pointer"
122 >
123 <span className={`inline-flex items-center justify-center rounded px-1.5 py-0 text-[10px] font-bold shrink-0 w-16 ${style.bg} ${style.text}`}>
124 {style.label}
125 </span>
126 {group.phase && (
127 <span className="text-[10px] font-medium text-blue-500 dark:text-blue-400 shrink-0">
128 {phaseLabel(group.phase)}
129 </span>
130 )}
131 <span className="text-muted-foreground text-[11px]">
132 {group.events.length} parts &middot; {formatChars(group.totalChars)} total
133 </span>
134 {/* Inline mini proportion bar */}
135 <span className="flex-1 flex h-2.5 rounded overflow-hidden bg-muted/40 min-w-[80px] max-w-[260px]">
136 {group.events.map((e, i) => {
137 const pct = group.totalChars > 0
138 ? (Number(e.data.char_count || 0) / group.totalChars) * 100
139 : 0;
140 const pc = getPartColor(String(e.data.label || ""));
141 return (
142 <span
143 key={i}
144 className={`${pc.bar} h-full opacity-70`}
145 style={{ width: `${pct}%` }}
146 title={`${e.data.label}: ${formatChars(Number(e.data.char_count))} (${pct.toFixed(1)}%)`}
147 />
148 );
149 })}
150 </span>
151 <span className="text-[10px] text-muted-foreground shrink-0">
152 {expanded ? "▼" : "▶"}
153 </span>
154 </button>
155
156 {/* Expanded: full proportion bar + part list */}
157 {expanded && (
158 <div className="ml-16 mr-2 mt-1 mb-2 p-2 bg-muted/20 border rounded-md">
159 {/* Full proportion bar with labels */}
160 <div className="flex h-5 rounded overflow-hidden mb-2">
161 {group.events.map((e, i) => {
162 const pct = group.totalChars > 0
163 ? (Number(e.data.char_count || 0) / group.totalChars) * 100
164 : 0;
165 const pc = getPartColor(String(e.data.label || ""));
166 return (
167 <div
168 key={i}
169 className={`${pc.bar} h-full flex items-center justify-center opacity-80 border-r border-background/30 last:border-r-0`}
170 style={{ width: `${pct}%` }}
171 title={`${e.data.label}: ${formatChars(Number(e.data.char_count))} (${pct.toFixed(1)}%)`}
172 >
173 {pct > 8 && (
174 <span className="text-[9px] font-bold text-white/90 truncate px-0.5">
175 {String(e.data.label || "").replace(/_/g, " ")}
176 </span>
177 )}
178 </div>
179 );
180 })}
181 </div>
182
183 {/* Part list */}
184 <div className="flex flex-col gap-0.5">
185 {group.events.map((e, i) => {
186 const label = String(e.data.label || "");
187 const chars = Number(e.data.char_count || 0);
188 const pct = group.totalChars > 0 ? (chars / group.totalChars) * 100 : 0;
189 const pc = getPartColor(label);
190 const isPartExpanded = expandedParts.has(i);
191
192 return (
193 <div key={i}>
194 <button
195 onClick={() => togglePart(i)}
196 className="flex w-full items-center gap-2 px-1 py-0.5 text-xs rounded hover:bg-muted/40 text-left"
197 >
198 <span className={`w-2 h-2 rounded-full shrink-0 ${pc.bar}`} />
199 <span className={`font-medium w-28 shrink-0 truncate ${pc.text}`}>
200 {label.replace(/_/g, " ")}
201 </span>
202 <span className="font-mono text-[10px] text-muted-foreground w-12 text-right shrink-0">
203 {formatChars(chars)}
204 </span>
205 <span className="font-mono text-[10px] text-muted-foreground w-12 text-right shrink-0">
206 {pct.toFixed(1)}%
207 </span>
208 {/* Mini bar */}
209 <span className="flex-1 h-1.5 rounded bg-muted/30 overflow-hidden">
210 <span className={`block h-full ${pc.bar} opacity-60`} style={{ width: `${pct}%` }} />
211 </span>
212 <span className="text-[10px] text-muted-foreground">{isPartExpanded ? "▼" : "▶"}</span>
213 </button>
214 {isPartExpanded && (
215 <div className="ml-6 mr-1 mt-0.5 mb-1 p-2 bg-muted/10 border rounded max-h-80 overflow-auto">
216 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">
217 {String(e.data.content || "")}
218 </pre>
219 </div>
220 )}
221 </div>
222 );
223 })}
224 </div>
225 </div>
226 )}
227 </div>
228 );
229 }
230
231 // ---------------------------------------------------------------------------
232 // Single event summary + detail (non-context events)
233 // ---------------------------------------------------------------------------
234
235 function EventSummary({ event }: { event: EventRecord }) {
236 const d = event.data;
237 switch (event.type) {
238 case "turn_start":
239 return <span className="text-muted-foreground">{String(d.model || "")}</span>;
240 case "turn_end":
241 return (
242 <span className="text-muted-foreground">
243 {String(d.stop_reason || "completed")}
244 {d.usage && typeof d.usage === "object" ? (
245 <span className="ml-2 font-mono text-[10px] opacity-70">
246 {Object.entries(d.usage as Record<string, number>)
247 .map(([k, v]) => `${k}:${v}`)
248 .join(" ")}
249 </span>
250 ) : null}
251 </span>
252 );
253 case "governance":
254 return (
255 <span className="text-muted-foreground font-mono text-[11px]">
256 {String(d.input_messages ?? "?")} msgs &rarr; {String(d.output_messages ?? "?")} msgs
257 {d.input_chars != null && d.output_chars != null && (
258 <span className="ml-2">
259 {formatChars(Number(d.input_chars))} &rarr; {formatChars(Number(d.output_chars))} chars
260 </span>
261 )}
262 </span>
263 );
264 case "model_request":
265 return (
266 <span className="text-muted-foreground">
267 <span className="font-mono text-[11px]">
268 iter #{String(d.iteration ?? 0)} &middot; {String(d.messages_count ?? 0)} msgs &middot; {formatChars(Number(d.total_chars || 0))}
269 &middot; {String(d.tools_count ?? "?")} tools
270 </span>
271 {d.model && <span className="ml-2 text-[10px] opacity-60">{String(d.model)}</span>}
272 {d.type && <span className="ml-1 text-[10px] text-yellow-600 dark:text-yellow-400">({String(d.type)})</span>}
273 </span>
274 );
275 case "model_response": {
276 const content = String(d.content || "");
277 const toolCalls = d.tool_calls as { name: string }[] | undefined;
278 const usage = d.usage as Record<string, number> | undefined;
279 return (
280 <span className="text-muted-foreground">
281 {toolCalls && toolCalls.length > 0 && (
282 <span className="text-orange-600 dark:text-orange-400 font-mono text-[11px] mr-2">
283 [{toolCalls.map(tc => tc.name).join(", ")}]
284 </span>
285 )}
286 <span className="truncate">{content.slice(0, 120)}{content.length > 120 ? "..." : ""}</span>
287 {usage && (
288 <span className="ml-2 font-mono text-[10px] opacity-60">
289 {Object.entries(usage).filter(([,v]) => v > 0).map(([k, v]) => `${k}:${v}`).join(" ")}
290 </span>
291 )}
292 </span>
293 );
294 }
295 case "tool_exec":
296 return (
297 <span className="text-muted-foreground">
298 <span className="font-medium text-orange-600 dark:text-orange-400">{String(d.name || "")}</span>
299 <span className="ml-2 font-mono text-[10px]">result: {formatChars(Number(d.result_chars || 0))}</span>
300 </span>
301 );
302 case "injection":
303 return (
304 <span className="text-muted-foreground">
305 {String(d.count || 0)} message(s) &middot; {String(d.phase || "")}
306 </span>
307 );
308 case "retry":
309 return (
310 <span className="text-muted-foreground">
311 {String(d.type || "unknown")} &middot; iter #{String(d.iteration ?? "?")}
312 </span>
313 );
314 case "error":
315 return (
316 <span className="text-red-600 dark:text-red-400">
317 {String(d.type || "Error")}: {String(d.message || "").slice(0, 150)}
318 </span>
319 );
320 case "content_transform":
321 return (
322 <span className="text-muted-foreground">
323 <span className="font-medium text-yellow-600 dark:text-yellow-400">{String(d.stage || "")}</span>
324 <span className="ml-2 font-mono text-[11px]">
325 {formatChars(Number(d.original_chars || 0))} &rarr; {formatChars(Number(d.transformed_chars || 0))} chars
326 </span>
327 </span>
328 );
329 default:
330 return <span className="text-muted-foreground font-mono text-[10px]">{JSON.stringify(d).slice(0, 200)}</span>;
331 }
332 }
333
334 function EventDetail({ event }: { event: EventRecord }) {
335 const d = event.data;
336 switch (event.type) {
337 case "model_request": {
338 const messages = d.messages as Array<Record<string, any>> | undefined;
339 if (!messages) return <span className="text-xs text-muted-foreground">No message data</span>;
340 return <ModelRequestDetail data={d} messages={messages} />;
341 }
342 case "model_response":
343 return (
344 <div className="flex flex-col gap-2">
345 {d.reasoning_content && (
346 <div>
347 <span className="text-[10px] text-muted-foreground font-medium">Reasoning:</span>
348 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/70 mt-0.5">
349 {String(d.reasoning_content)}
350 </pre>
351 </div>
352 )}
353 <div>
354 <span className="text-[10px] text-muted-foreground font-medium">Content:</span>
355 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80 mt-0.5">
356 {String(d.content || "")}
357 </pre>
358 </div>
359 {d.tool_calls && (d.tool_calls as unknown[]).length > 0 && (
360 <div>
361 <span className="text-[10px] text-muted-foreground font-medium">Tool Calls:</span>
362 {(d.tool_calls as Array<{ name: string; arguments: string }>).map((tc, j) => (
363 <pre key={j} className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/60 mt-0.5">
364 {tc.name}({tc.arguments})
365 </pre>
366 ))}
367 </div>
368 )}
369 </div>
370 );
371 case "tool_exec":
372 return (
373 <div className="flex flex-col gap-2">
374 <div>
375 <span className="text-[10px] text-muted-foreground font-medium">Arguments:</span>
376 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/70 mt-0.5 max-h-40 overflow-auto">
377 {String(d.arguments || "")}
378 </pre>
379 </div>
380 <div>
381 <span className="text-[10px] text-muted-foreground font-medium">Result:</span>
382 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80 mt-0.5 max-h-60 overflow-auto">
383 {String(d.result || "")}
384 </pre>
385 </div>
386 </div>
387 );
388 case "content_transform":
389 return (
390 <div className="flex flex-col gap-2">
391 <div className="flex gap-4 text-[10px] text-muted-foreground">
392 <span>Stage: <span className="font-medium text-yellow-600 dark:text-yellow-400">{String(d.stage || "")}</span></span>
393 <span>Original: {formatChars(Number(d.original_chars || 0))}</span>
394 <span>Transformed: {formatChars(Number(d.transformed_chars || 0))}</span>
395 </div>
396 <div>
397 <span className="text-[10px] text-muted-foreground font-medium">Transformed content:</span>
398 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80 mt-0.5 max-h-60 overflow-auto">
399 {String(d.transformed || "")}
400 </pre>
401 </div>
402 </div>
403 );
404 default:
405 return (
406 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">
407 {JSON.stringify(d, null, 2)}
408 </pre>
409 );
410 }
411 }
412
413 function extractContent(msg: Record<string, any>): string {
414 const raw = msg.content;
415 if (typeof raw === "string") return raw;
416 if (Array.isArray(raw)) {
417 return raw
418 .map((b: any) => {
419 if (typeof b === "string") return b;
420 if (b?.type === "text") return b.text || "";
421 if (b?.type === "image_url") return "[image]";
422 return JSON.stringify(b);
423 })
424 .join("\n");
425 }
426 return raw ? JSON.stringify(raw) : "";
427 }
428
429 function MessageRow({ msg, idx }: { msg: Record<string, any>; idx: number }) {
430 const [expanded, setExpanded] = useState(false);
431 const role = String(msg.role || "unknown");
432 const content = extractContent(msg);
433 const toolCalls = msg.tool_calls as Array<{ id?: string; function?: { name: string; arguments: string }; name?: string; arguments?: string }> | undefined;
434 const hasToolCalls = toolCalls && toolCalls.length > 0;
435 const toolCallId = msg.tool_call_id as string | undefined;
436 const charCount = content.length;
437 const roleColor =
438 role === "system" ? "text-purple-600 dark:text-purple-400" :
439 role === "user" ? "text-blue-600 dark:text-blue-400" :
440 role === "assistant" ? "text-green-600 dark:text-green-400" :
441 "text-orange-600 dark:text-orange-400";
442
443 const summary = hasToolCalls
444 ? toolCalls!.map(tc => tc.function?.name || tc.name || "?").join(", ")
445 : content.slice(0, 150);
446
447 return (
448 <div className="border rounded overflow-hidden">
449 <button
450 onClick={() => setExpanded(!expanded)}
451 className="flex w-full items-center gap-2 px-2 py-0.5 text-xs hover:bg-muted/50 text-left"
452 >
453 <span className="text-[10px] text-muted-foreground w-4">{idx}</span>
454 <span className={`font-medium w-12 shrink-0 ${roleColor}`}>{role}</span>
455 {msg.name && <span className="text-orange-500 font-mono text-[10px] shrink-0">[{String(msg.name)}]</span>}
456 {toolCallId && <span className="text-orange-500 font-mono text-[10px] shrink-0">tool_result</span>}
457 {hasToolCalls ? (
458 <span className="truncate flex-1">
459 <span className="text-orange-600 dark:text-orange-400 font-mono text-[11px]">
460 tool_call → [{summary}]
461 </span>
462 </span>
463 ) : (
464 <span className="truncate flex-1 text-muted-foreground">{summary}</span>
465 )}
466 <span className="font-mono text-[10px] text-muted-foreground shrink-0">{formatChars(charCount)}</span>
467 <span className="text-[10px] text-muted-foreground">{expanded ? "▼" : "▶"}</span>
468 </button>
469 {expanded && (
470 <div className="border-t bg-muted/20 p-2 max-h-[600px] overflow-auto flex flex-col gap-2">
471 {content && (
472 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/80">{content}</pre>
473 )}
474 {hasToolCalls && toolCalls!.map((tc, j) => {
475 const name = tc.function?.name || tc.name || "unknown";
476 const args = tc.function?.arguments || tc.arguments || "";
477 return (
478 <div key={j} className="border rounded p-1.5 bg-orange-500/5">
479 <div className="text-[10px] text-orange-600 dark:text-orange-400 font-bold mb-0.5">
480 tool_call: {name}
481 {(tc.id || tc.function) && <span className="ml-2 font-normal text-muted-foreground">{tc.id || ""}</span>}
482 </div>
483 <pre className="text-xs whitespace-pre-wrap break-words font-mono text-foreground/70">{args}</pre>
484 </div>
485 );
486 })}
487 </div>
488 )}
489 </div>
490 );
491 }
492
493 // ---------------------------------------------------------------------------
494 // Model Request Detail — structured view of LLM input
495 // ---------------------------------------------------------------------------
496
497 interface MessageSection {
498 label: string;
499 color: string;
500 messages: { msg: Record<string, any>; originalIdx: number }[];
501 charCount: number;
502 }
503
504 function classifyMessages(messages: Array<Record<string, any>>): MessageSection[] {
505 const sections: MessageSection[] = [];
506 let i = 0;
507
508 // 1. System prompt — always [0], contains identity+bootstrap+skills+memory merged
509 if (i < messages.length && messages[i].role === "system") {
510 const c = extractContent(messages[i]);
511 sections.push({
512 label: "System Prompt",
513 color: "text-purple-600 dark:text-purple-400",
514 messages: [{ msg: messages[i], originalIdx: 0 }],
515 charCount: c.length,
516 });
517 i++;
518 }
519
520 // Scan remaining messages to classify
521 const rest: { msg: Record<string, any>; originalIdx: number }[] = [];
522 for (let j = i; j < messages.length; j++) {
523 rest.push({ msg: messages[j], originalIdx: j });
524 }
525
526 // Find boundaries:
527 // - Runtime Context = user message starting with "[Runtime Context"
528 // - Tool round = assistant(tool_calls) + tool(result) pairs at the tail (iteration > 0 appended)
529 // - History = past conversation turns
530 // - Current user message = the real user input
531
532 // Separate trailing runtime context
533 const runtimeMsgs: typeof rest = [];
534 while (
535 rest.length > 0 &&
536 rest[rest.length - 1].msg.role === "user" &&
537 String(rest[rest.length - 1].msg.content || "").startsWith("[Runtime Context")
538 ) {
539 runtimeMsgs.unshift(rest.pop()!);
540 }
541
542 // Separate trailing tool round (from previous iteration: assistant+tool_calls then tool results)
543 // These are appended by runner.py when iteration > 0
544 const toolRoundMsgs: typeof rest = [];
545 // Walk backwards: tool results first, then the assistant with tool_calls
546 let cursor = rest.length - 1;
547 while (cursor >= 0 && rest[cursor].msg.role === "tool") {
548 cursor--;
549 }
550 if (
551 cursor >= 0 &&
552 rest[cursor].msg.role === "assistant" &&
553 rest[cursor].msg.tool_calls?.length > 0
554 ) {
555 // Check if these are the tail — there should be user messages before this block
556 const hasHistoryBefore = cursor > 0;
557 if (hasHistoryBefore) {
558 for (let j = cursor; j < rest.length; j++) {
559 toolRoundMsgs.push(rest[j]);
560 }
561 rest.splice(cursor, rest.length - cursor);
562 }
563 }
564
565 // Now rest = history conversation turns
566 // Find the last real user message (not runtime context) as "Current Input"
567 const currentMsgs: typeof rest = [];
568 if (rest.length > 0 && rest[rest.length - 1].msg.role === "user") {
569 currentMsgs.unshift(rest.pop()!);
570 }
571
572 // Everything left is History
573 const historyMsgs = rest;
574 const historyChars = historyMsgs.reduce((s, r) => s + extractContent(r.msg).length, 0);
575 const currentChars = currentMsgs.reduce((s, r) => s + extractContent(r.msg).length, 0);
576 const toolRoundChars = toolRoundMsgs.reduce((s, r) => s + extractContent(r.msg).length, 0);
577 const runtimeChars = runtimeMsgs.reduce((s, r) => s + extractContent(r.msg).length, 0);
578
579 if (historyMsgs.length > 0) {
580 sections.push({ label: "History", color: "text-amber-600 dark:text-amber-400", messages: historyMsgs, charCount: historyChars });
581 }
582 if (currentMsgs.length > 0) {
583 sections.push({ label: "User Message", color: "text-green-600 dark:text-green-400", messages: currentMsgs, charCount: currentChars });
584 }
585 if (toolRoundMsgs.length > 0) {
586 sections.push({ label: "Tool Round (prev iteration)", color: "text-orange-600 dark:text-orange-400", messages: toolRoundMsgs, charCount: toolRoundChars });
587 }
588 if (runtimeMsgs.length > 0) {
589 sections.push({ label: "Runtime Context", color: "text-gray-500 dark:text-gray-400", messages: runtimeMsgs, charCount: runtimeChars });
590 }
591
592 return sections;
593 }
594
595 function ToolDefRow({ tool }: { tool: Record<string, any> }) {
596 const [expanded, setExpanded] = useState(false);
597 const fn = tool.function || tool;
598 const name = String(fn.name || "");
599 const desc = String(fn.description || "");
600
601 return (
602 <div className="border rounded overflow-hidden">
603 <button
604 onClick={() => setExpanded(!expanded)}
605 className="flex w-full items-center gap-2 px-2 py-0.5 text-[10px] hover:bg-muted/40 text-left"
606 >
607 <span className="font-mono font-bold text-foreground/80 shrink-0">{name}</span>
608 <span className="text-muted-foreground truncate flex-1">{desc.slice(0, 80)}</span>
609 <span className="text-muted-foreground shrink-0">{expanded ? "▼" : "▶"}</span>
610 </button>
611 {expanded && (
612 <div className="border-t bg-muted/10 p-2 max-h-60 overflow-auto">
613 <pre className="text-[10px] whitespace-pre-wrap break-words font-mono text-foreground/80">
614 {JSON.stringify(tool, null, 2)}
615 </pre>
616 </div>
617 )}
618 </div>
619 );
620 }
621
622 function ModelRequestDetail({ data, messages }: { data: Record<string, any>; messages: Array<Record<string, any>> }) {
623 const sections = useMemo(() => classifyMessages(messages), [messages]);
624 const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
625 const [showTools, setShowTools] = useState(false);
626
627 const toggleSection = (label: string) => {
628 setCollapsedSections((prev) => {
629 const next = new Set(prev);
630 if (next.has(label)) next.delete(label);
631 else next.add(label);
632 return next;
633 });
634 };
635
636 const totalChars = sections.reduce((s, sec) => s + sec.charCount, 0);
637 const tools = data.tools as Array<{ name: string; description: string }> | undefined;
638 const toolsCount = Number(data.tools_count ?? tools?.length ?? 0);
639
640 return (
641 <div className="flex flex-col gap-2">
642 {/* Model params bar */}
643 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground border-b pb-1.5">
644 <span className="font-medium text-foreground text-xs">LLM Input</span>
645 <span>{messages.length} messages</span>
646 <span>{formatChars(totalChars)} chars</span>
647 {data.temperature != null && <span>temp: {String(data.temperature)}</span>}
648 {data.max_tokens != null && <span>max_tokens: {String(data.max_tokens)}</span>}
649 {data.reasoning_effort != null && <span>reasoning: {String(data.reasoning_effort)}</span>}
650 <button
651 onClick={() => setShowTools(!showTools)}
652 className="text-blue-500 hover:underline cursor-pointer"
653 >
654 {toolsCount} tools {showTools ? "▼" : "▶"}
655 </button>
656 {/* Proportion bar */}
657 <span className="flex h-3 rounded overflow-hidden bg-muted/30 min-w-[120px] max-w-[300px] flex-1">
658 {sections.map((sec) => {
659 const pct = totalChars > 0 ? (sec.charCount / totalChars) * 100 : 0;
660 const barColor =
661 sec.label === "System Prompt" ? "bg-purple-400" :
662 sec.label === "History" ? "bg-amber-400" :
663 sec.label === "User Message" ? "bg-green-400" :
664 sec.label.startsWith("Tool Round") ? "bg-orange-400" :
665 "bg-gray-400";
666 return (
667 <span
668 key={sec.label}
669 className={`${barColor} h-full opacity-70`}
670 style={{ width: `${pct}%` }}
671 title={`${sec.label}: ${formatChars(sec.charCount)} (${pct.toFixed(1)}%)`}
672 />
673 );
674 })}
675 </span>
676 </div>
677
678 {/* Tools list (collapsible) */}
679 {showTools && tools && tools.length > 0 && (
680 <div className="border rounded p-2 bg-blue-500/5">
681 <div className="text-[10px] font-medium text-blue-600 dark:text-blue-400 mb-1">
682 Available Tools ({tools.length})
683 </div>
684 <div className="flex flex-col gap-0.5">
685 {tools.map((t: any, j: number) => (
686 <ToolDefRow key={j} tool={t} />
687 ))}
688 </div>
689 </div>
690 )}
691
692 {/* Message sections */}
693 {sections.map((sec) => {
694 const isCollapsed = collapsedSections.has(sec.label);
695 return (
696 <div key={sec.label} className="border rounded">
697 <button
698 onClick={() => toggleSection(sec.label)}
699 className="flex w-full items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40 text-left"
700 >
701 <span className="text-[10px]">{isCollapsed ? "▶" : "▼"}</span>
702 <span className={`font-bold ${sec.color}`}>{sec.label}</span>
703 <span className="text-muted-foreground text-[10px]">
704 {sec.messages.length} msg{sec.messages.length > 1 ? "s" : ""}
705 </span>
706 <span className="font-mono text-[10px] text-muted-foreground">{formatChars(sec.charCount)}</span>
707 <span className="font-mono text-[10px] text-muted-foreground">
708 ({totalChars > 0 ? ((sec.charCount / totalChars) * 100).toFixed(1) : 0}%)
709 </span>
710 </button>
711 {!isCollapsed && (
712 <div className="border-t px-1 py-0.5 flex flex-col gap-0.5">
713 {sec.messages.map(({ msg, originalIdx }) => (
714 <MessageRow key={originalIdx} msg={msg} idx={originalIdx} />
715 ))}
716 </div>
717 )}
718 </div>
719 );
720 })}
721 </div>
722 );
723 }
724
725 function hasExpandableContent(type: string): boolean {
726 return ["model_request", "model_response", "tool_exec", "governance", "injection", "content_transform"].includes(type);
727 }
728
729 // ---------------------------------------------------------------------------
730 // Page
731 // ---------------------------------------------------------------------------
732
733 export function EventStackerPage() {
734 const { sessions, activeSession, setActiveSession, events, loading, refresh } = useEventStack();
735 const [expandedEvents, setExpandedEvents] = useState<Set<number>>(new Set());
736 const [expandedContextGroups, setExpandedContextGroups] = useState<Set<string>>(new Set());
737 const [collapsedTurns, setCollapsedTurns] = useState<Set<string>>(new Set());
738
739 const turnGroups = useMemo(() => {
740 const groups: { turnId: string; items: ProcessedItem[]; model: string; timestamp: string; eventCount: number }[] = [];
741 // First pass: group by turn
742 const rawGroups: { turnId: string; events: EventRecord[]; model: string; timestamp: string }[] = [];
743 let current: (typeof rawGroups)[0] | null = null;
744 for (const event of events) {
745 if (!current || current.turnId !== event.turn_id) {
746 current = {
747 turnId: event.turn_id,
748 events: [],
749 model: event.type === "turn_start" ? String(event.data.model || "") : "",
750 timestamp: event.timestamp,
751 };
752 rawGroups.push(current);
753 }
754 current.events.push(event);
755 if (event.type === "turn_start" && !current.model) {
756 current.model = String(event.data.model || "");
757 }
758 }
759 // Second pass: group context_part events within each turn
760 for (const rg of rawGroups) {
761 groups.push({
762 turnId: rg.turnId,
763 items: groupEvents(rg.events),
764 model: rg.model,
765 timestamp: rg.timestamp,
766 eventCount: rg.events.length,
767 });
768 }
769 return groups;
770 }, [events]);
771
772 const toggleEvent = (seq: number) => {
773 setExpandedEvents((prev) => {
774 const next = new Set(prev);
775 if (next.has(seq)) next.delete(seq);
776 else next.add(seq);
777 return next;
778 });
779 };
780
781 const toggleContextGroup = (key: string) => {
782 setExpandedContextGroups((prev) => {
783 const next = new Set(prev);
784 if (next.has(key)) next.delete(key);
785 else next.add(key);
786 return next;
787 });
788 };
789
790 const toggleTurn = (turnId: string) => {
791 setCollapsedTurns((prev) => {
792 const next = new Set(prev);
793 if (next.has(turnId)) next.delete(turnId);
794 else next.add(turnId);
795 return next;
796 });
797 };
798
799 return (
800 <div className="flex h-screen w-screen bg-background text-foreground">
801 {/* Sidebar */}
802 <aside className="w-64 shrink-0 border-r overflow-y-auto flex flex-col">
803 <div className="flex items-center justify-between px-3 py-3 border-b">
804 <h1 className="text-sm font-semibold">Event Stacker</h1>
805 <div className="flex gap-1">
806 <button
807 onClick={refresh}
808 className="rounded-md px-2 py-1 text-xs hover:bg-muted transition-colors"
809 >
810 Refresh
811 </button>
812 <a href="/" className="rounded-md px-2 py-1 text-xs hover:bg-muted transition-colors">
813 Chat
814 </a>
815 </div>
816 </div>
817 <div className="flex-1 overflow-y-auto">
818 {sessions.length === 0 ? (
819 <div className="p-4 text-xs text-muted-foreground text-center">No sessions</div>
820 ) : (
821 sessions.map((s) => (
822 <button
823 key={s.id}
824 onClick={() => setActiveSession(s.id)}
825 className={`w-full text-left px-3 py-2 border-b text-xs hover:bg-muted/50 transition-colors ${
826 activeSession === s.id ? "bg-primary/8 border-l-2 border-l-primary" : ""
827 }`}
828 >
829 <div className="font-medium truncate">{s.id}</div>
830 <div className="text-[10px] text-muted-foreground mt-0.5 flex gap-2">
831 <span>{s.event_count} events</span>
832 <span>{(s.size_bytes / 1024).toFixed(1)}KB</span>
833 </div>
834 <div className="text-[10px] text-muted-foreground truncate">
835 {new Date(s.modified).toLocaleString()}
836 </div>
837 </button>
838 ))
839 )}
840 </div>
841 </aside>
842
843 {/* Main */}
844 <main className="flex-1 flex flex-col min-w-0 overflow-hidden">
845 {loading ? (
846 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
847 Loading events...
848 </div>
849 ) : events.length === 0 ? (
850 <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
851 {activeSession ? "No events in this session" : "Select a session from the sidebar"}
852 </div>
853 ) : (
854 <div className="flex-1 overflow-y-auto">
855 {turnGroups.map((group) => {
856 const isCollapsed = collapsedTurns.has(group.turnId);
857 return (
858 <div key={group.turnId} className="border-b">
859 {/* Turn header */}
860 <button
861 onClick={() => toggleTurn(group.turnId)}
862 className="flex w-full items-center gap-2 px-4 py-2 text-xs font-medium bg-muted/40 hover:bg-muted/60 transition-colors sticky top-0 z-10"
863 >
864 <span className="text-[10px]">{isCollapsed ? "▶" : "▼"}</span>
865 <span className="text-foreground">{group.turnId.replace("_", " #")}</span>
866 <span className="font-mono text-[10px] text-muted-foreground">{group.model}</span>
867 <span className="text-[10px] text-muted-foreground">{group.eventCount} events</span>
868 <span className="text-[10px] text-muted-foreground ml-auto">
869 {new Date(group.timestamp).toLocaleTimeString()}
870 </span>
871 </button>
872
873 {/* Events timeline */}
874 {!isCollapsed && (
875 <div className="relative pl-8 pr-4 py-1">
876 <div className="absolute left-5 top-0 bottom-0 w-px bg-border" />
877
878 {group.items.map((item, itemIdx) => {
879 if (item.kind === "context_group") {
880 const cgKey = `${group.turnId}_cg_${itemIdx}`;
881 return (
882 <div key={cgKey} className="relative py-0.5">
883 <div className="absolute -left-3 top-1.5 w-2 h-2 rounded-full border-2 border-background bg-blue-500/15 ring-1 ring-border" />
884 <div className="ml-2">
885 <ContextGroupCard
886 group={item}
887 expanded={expandedContextGroups.has(cgKey)}
888 onToggle={() => toggleContextGroup(cgKey)}
889 />
890 </div>
891 </div>
892 );
893 }
894
895 const event = item.event;
896 const style = getStyle(event.type);
897 const isExpanded = expandedEvents.has(event.seq);
898 const canExpand = hasExpandableContent(event.type);
899
900 return (
901 <div key={event.seq} className="relative py-0.5">
902 <div className={`absolute -left-3 top-1.5 w-2 h-2 rounded-full border-2 border-background ${style.bg} ring-1 ring-border`} />
903 <div className="ml-2">
904 <button
905 onClick={() => canExpand && toggleEvent(event.seq)}
906 className={`flex w-full items-center gap-2 px-2 py-0.5 text-xs text-left rounded hover:bg-muted/30 transition-colors ${
907 canExpand ? "cursor-pointer" : "cursor-default"
908 }`}
909 >
910 <span className={`inline-flex items-center justify-center rounded px-1.5 py-0 text-[10px] font-bold shrink-0 w-16 ${style.bg} ${style.text}`}>
911 {style.label}
912 </span>
913 <span className="flex-1 min-w-0 flex items-center gap-1 overflow-hidden">
914 <EventSummary event={event} />
915 </span>
916 <span className="text-[10px] text-muted-foreground/50 shrink-0 font-mono">
917 {new Date(event.timestamp).toLocaleTimeString(undefined, { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 } as Intl.DateTimeFormatOptions)}
918 </span>
919 {canExpand && (
920 <span className="text-[10px] text-muted-foreground shrink-0">
921 {isExpanded ? "▼" : "▶"}
922 </span>
923 )}
924 </button>
925
926 {isExpanded && (
927 <div className="ml-16 mr-2 mt-1 mb-2 p-2 bg-muted/20 border rounded-md max-h-96 overflow-auto">
928 <EventDetail event={event} />
929 </div>
930 )}
931 </div>
932 </div>
933 );
934 })}
935 </div>
936 )}
937 </div>
938 );
939 })}
940 </div>
941 )}
942 </main>
943 </div>
944 );
945 }
946
946 lines Plain Text