@tonyclaw/llm-inspector 1.11.8 → 1.13.0
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/.output/nitro.json +1 -1
- package/.output/public/assets/index-B0anmGQr.css +1 -0
- package/.output/public/assets/index-H_thmL2_.js +105 -0
- package/.output/public/assets/{main-GI0Ezr88.js → main-C3tLo75s.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +30 -18
- package/.output/server/_ssr/{index-RTuHuCHN.mjs → index-C8VC13EA.mjs} +852 -170
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DeW8M_70.mjs → router-D5ccnemB.mjs} +2 -2
- package/.output/server/{_tanstack-start-manifest_v-DkqH07iU.mjs → _tanstack-start-manifest_v-DUbXa1lt.mjs} +1 -1
- package/.output/server/index.mjs +30 -30
- package/package.json +1 -1
- package/src/components/ProxyViewer.tsx +140 -4
- package/src/components/proxy-viewer/CompareDrawer.tsx +388 -0
- package/src/components/proxy-viewer/ConversationGroup.tsx +16 -0
- package/src/components/proxy-viewer/LogEntry.tsx +22 -1
- package/src/components/proxy-viewer/LogEntryHeader.tsx +60 -0
- package/src/components/proxy-viewer/cacheTrend.ts +50 -0
- package/src/components/proxy-viewer/requestDiff.ts +277 -0
- package/.output/public/assets/index-DiYqfnSp.css +0 -1
- package/.output/public/assets/index-KwQ0pWoC.js +0 -105
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import type { JSX } from "react";
|
|
3
|
+
import { ChevronRight, X } from "lucide-react";
|
|
4
|
+
import { cn, formatTokens } from "../../lib/utils";
|
|
5
|
+
import type { CapturedLog } from "../../proxy/schemas";
|
|
6
|
+
import { parseRequest } from "../../proxy/schemas";
|
|
7
|
+
import {
|
|
8
|
+
type DiffOp,
|
|
9
|
+
type JsonNode,
|
|
10
|
+
diffTrees,
|
|
11
|
+
normalizeRequest,
|
|
12
|
+
previewNode,
|
|
13
|
+
} from "./requestDiff";
|
|
14
|
+
import { getConversationId } from "./ConversationHeader";
|
|
15
|
+
import { JsonViewerFromString } from "../ui/json-viewer";
|
|
16
|
+
import { Badge } from "../ui/badge";
|
|
17
|
+
|
|
18
|
+
export type CompareDrawerProps = {
|
|
19
|
+
/** Log selected first (shown on the left). */
|
|
20
|
+
left: CapturedLog;
|
|
21
|
+
/** Log selected second (shown on the right). */
|
|
22
|
+
right: CapturedLog;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type EqualOp = Extract<DiffOp, { kind: "equal" }>;
|
|
27
|
+
type AddedOp = Extract<DiffOp, { kind: "added" }>;
|
|
28
|
+
type RemovedOp = Extract<DiffOp, { kind: "removed" }>;
|
|
29
|
+
type ChangedOp = Extract<DiffOp, { kind: "changed" }>;
|
|
30
|
+
|
|
31
|
+
/** Walk the JsonNode tree and pretty-print it back to a JSON string for the
|
|
32
|
+
* expanded-equal-subtree view. The node is a plain object structure so
|
|
33
|
+
* `JSON.stringify` produces correct output. */
|
|
34
|
+
function nodeToJsonString(node: JsonNode, indent = 2): string {
|
|
35
|
+
return JSON.stringify(nodeToJsonValue(node), null, indent);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nodeToJsonValue(node: JsonNode): unknown {
|
|
39
|
+
switch (node.kind) {
|
|
40
|
+
case "primitive":
|
|
41
|
+
return node.value;
|
|
42
|
+
case "array":
|
|
43
|
+
return node.value.map(nodeToJsonValue);
|
|
44
|
+
case "object": {
|
|
45
|
+
const out: Record<string, unknown> = {};
|
|
46
|
+
for (const [k, v] of Object.entries(node.value)) {
|
|
47
|
+
out[k] = nodeToJsonValue(v);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The parent path of a JSON path string. E.g. `messages[3].content` →
|
|
55
|
+
* `messages[3]`, `messages[3]` → `messages`, `messages` → `""`. */
|
|
56
|
+
function parentPath(path: string): string {
|
|
57
|
+
if (path === "") return "";
|
|
58
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
59
|
+
const ch = path[i];
|
|
60
|
+
if (ch === "." || ch === "[") {
|
|
61
|
+
return path.substring(0, i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Group contiguous deep-equal ops (object/array, not primitive) that share
|
|
68
|
+
* a common parent into a single collapsed row, so an unchanged block of N
|
|
69
|
+
* sibling subtrees renders as one row instead of N. Primitive equals
|
|
70
|
+
* always render as their own row (they're 1 line each). */
|
|
71
|
+
type GroupedOp = { kind: "single"; op: DiffOp } | { kind: "equal-run"; ops: EqualOp[] };
|
|
72
|
+
|
|
73
|
+
function isDeepEqual(op: DiffOp): op is EqualOp {
|
|
74
|
+
return op.kind === "equal" && (op.value.kind === "object" || op.value.kind === "array");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function groupContiguousEquals(ops: DiffOp[]): GroupedOp[] {
|
|
78
|
+
const out: GroupedOp[] = [];
|
|
79
|
+
let i = 0;
|
|
80
|
+
while (i < ops.length) {
|
|
81
|
+
const op = ops[i];
|
|
82
|
+
if (op !== undefined && isDeepEqual(op)) {
|
|
83
|
+
const startParent = parentPath(op.path);
|
|
84
|
+
let j = i + 1;
|
|
85
|
+
while (j < ops.length) {
|
|
86
|
+
const next = ops[j];
|
|
87
|
+
if (next === undefined) break;
|
|
88
|
+
if (!isDeepEqual(next)) break;
|
|
89
|
+
if (parentPath(next.path) !== startParent) break;
|
|
90
|
+
j++;
|
|
91
|
+
}
|
|
92
|
+
if (j - i > 1) {
|
|
93
|
+
const equalOps: EqualOp[] = [];
|
|
94
|
+
for (let k = i; k < j; k++) {
|
|
95
|
+
const eop = ops[k];
|
|
96
|
+
if (eop !== undefined && eop.kind === "equal") {
|
|
97
|
+
equalOps.push(eop);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
out.push({ kind: "equal-run", ops: equalOps });
|
|
101
|
+
i = j;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (op !== undefined) {
|
|
106
|
+
out.push({ kind: "single", op });
|
|
107
|
+
}
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function EqualRunRow({
|
|
114
|
+
ops,
|
|
115
|
+
expanded,
|
|
116
|
+
onToggle,
|
|
117
|
+
}: {
|
|
118
|
+
ops: EqualOp[];
|
|
119
|
+
expanded: boolean;
|
|
120
|
+
onToggle: () => void;
|
|
121
|
+
}): JSX.Element {
|
|
122
|
+
const first = ops[0];
|
|
123
|
+
const last = ops[ops.length - 1];
|
|
124
|
+
if (first === undefined || last === undefined) {
|
|
125
|
+
return <div className="col-span-3 text-muted-foreground/40 text-xs">—</div>;
|
|
126
|
+
}
|
|
127
|
+
const firstPath = first.path;
|
|
128
|
+
const lastPath = last.path;
|
|
129
|
+
const label = ops.length === 1 ? firstPath : `${firstPath} … ${lastPath}`;
|
|
130
|
+
const summary =
|
|
131
|
+
first.value.kind === "array"
|
|
132
|
+
? `${ops.length} equal arrays`
|
|
133
|
+
: first.value.kind === "object"
|
|
134
|
+
? `${ops.length} equal objects`
|
|
135
|
+
: "equal";
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="col-span-3">
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onToggle}
|
|
142
|
+
className="w-full text-left flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:bg-muted/40 rounded cursor-pointer"
|
|
143
|
+
>
|
|
144
|
+
<ChevronRight
|
|
145
|
+
className={cn("size-3 transition-transform shrink-0", expanded && "rotate-90")}
|
|
146
|
+
/>
|
|
147
|
+
<span className="font-mono truncate flex-1">{label}</span>
|
|
148
|
+
<span className="text-muted-foreground/60 shrink-0">({summary})</span>
|
|
149
|
+
</button>
|
|
150
|
+
{expanded && (
|
|
151
|
+
<div className="ml-5 mt-1 mb-2 space-y-2">
|
|
152
|
+
{ops.map((op) => (
|
|
153
|
+
<div key={op.path} className="border border-border/50 rounded p-2 bg-muted/20">
|
|
154
|
+
<div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
|
|
155
|
+
<JsonViewerFromString text={nodeToJsonString(op.value)} defaultExpandDepth={2} />
|
|
156
|
+
</div>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function AddOrRemoveRow({
|
|
165
|
+
op,
|
|
166
|
+
kind,
|
|
167
|
+
}: {
|
|
168
|
+
op: AddedOp | RemovedOp;
|
|
169
|
+
kind: "added" | "removed";
|
|
170
|
+
}): JSX.Element {
|
|
171
|
+
const accent =
|
|
172
|
+
kind === "added"
|
|
173
|
+
? "border-l-2 border-l-emerald-400/70 bg-emerald-500/5"
|
|
174
|
+
: "border-l-2 border-l-rose-400/70 bg-rose-500/5";
|
|
175
|
+
return (
|
|
176
|
+
<div className={cn("col-span-3 px-2 py-1 rounded text-xs", accent)}>
|
|
177
|
+
<div className="font-mono text-xs text-muted-foreground mb-0.5">{op.path}</div>
|
|
178
|
+
<div className="font-mono break-all">
|
|
179
|
+
{kind === "added" ? (
|
|
180
|
+
<span className="text-emerald-300/90">+ {previewNode(op.value, 400)}</span>
|
|
181
|
+
) : (
|
|
182
|
+
<span className="text-rose-300/90 line-through">- {previewNode(op.value, 400)}</span>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ChangedRow({ op }: { op: ChangedOp }): JSX.Element {
|
|
190
|
+
return (
|
|
191
|
+
<div className="col-span-3 px-2 py-1 rounded text-xs border-l-2 border-l-amber-400/70 bg-amber-500/5">
|
|
192
|
+
<div className="font-mono text-xs text-muted-foreground mb-1">{op.path}</div>
|
|
193
|
+
<div className="grid grid-cols-2 gap-2">
|
|
194
|
+
<div className="font-mono text-rose-300/90 break-all line-through">
|
|
195
|
+
{previewNode(op.left, 400)}
|
|
196
|
+
</div>
|
|
197
|
+
<div className="font-mono text-emerald-300/90 break-all">{previewNode(op.right, 400)}</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }): JSX.Element {
|
|
204
|
+
const conversationId = getConversationId(log);
|
|
205
|
+
return (
|
|
206
|
+
<div className="flex-1 min-w-0 space-y-1 text-xs">
|
|
207
|
+
<div className="flex items-center gap-2">
|
|
208
|
+
<Badge
|
|
209
|
+
variant="outline"
|
|
210
|
+
className={cn(
|
|
211
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
212
|
+
side === "left"
|
|
213
|
+
? "border-rose-500/40 text-rose-400"
|
|
214
|
+
: "border-emerald-500/40 text-emerald-400",
|
|
215
|
+
)}
|
|
216
|
+
>
|
|
217
|
+
{side === "left" ? "← Left" : "Right →"}
|
|
218
|
+
</Badge>
|
|
219
|
+
<span className="font-mono text-blue-400/80">#{log.id}</span>
|
|
220
|
+
{log.model !== null && (
|
|
221
|
+
<span className="font-mono text-muted-foreground truncate">{log.model}</span>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex items-center gap-3 text-muted-foreground font-mono">
|
|
225
|
+
{log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
|
|
226
|
+
<span className="text-emerald-400">
|
|
227
|
+
Cache +{formatTokens(log.cacheCreationInputTokens)}
|
|
228
|
+
</span>
|
|
229
|
+
)}
|
|
230
|
+
{log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
|
|
231
|
+
<span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
|
|
232
|
+
)}
|
|
233
|
+
<span className="truncate" title={log.timestamp}>
|
|
234
|
+
{log.timestamp}
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="text-muted-foreground/70 font-mono truncate" title={conversationId}>
|
|
238
|
+
session: {conversationId}
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
|
|
245
|
+
// Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
|
|
246
|
+
const ops = useMemo<DiffOp[]>(() => {
|
|
247
|
+
const l = normalizeRequest(parseRequest(left.rawRequestBody) ?? left.rawRequestBody);
|
|
248
|
+
const r = normalizeRequest(parseRequest(right.rawRequestBody) ?? right.rawRequestBody);
|
|
249
|
+
return diffTrees(l, r);
|
|
250
|
+
}, [left.rawRequestBody, right.rawRequestBody]);
|
|
251
|
+
|
|
252
|
+
const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);
|
|
253
|
+
|
|
254
|
+
// Track which collapsed equal runs are expanded.
|
|
255
|
+
const [expandedRuns, setExpandedRuns] = useState<Set<number>>(new Set());
|
|
256
|
+
const toggleRun = (idx: number) => {
|
|
257
|
+
setExpandedRuns((prev) => {
|
|
258
|
+
const next = new Set(prev);
|
|
259
|
+
if (next.has(idx)) next.delete(idx);
|
|
260
|
+
else next.add(idx);
|
|
261
|
+
return next;
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Esc keybinding + body scroll lock while the drawer is open.
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
const onKey = (e: KeyboardEvent) => {
|
|
268
|
+
if (e.key === "Escape") onClose();
|
|
269
|
+
};
|
|
270
|
+
document.addEventListener("keydown", onKey);
|
|
271
|
+
const prevOverflow = document.body.style.overflow;
|
|
272
|
+
document.body.style.overflow = "hidden";
|
|
273
|
+
return () => {
|
|
274
|
+
document.removeEventListener("keydown", onKey);
|
|
275
|
+
document.body.style.overflow = prevOverflow;
|
|
276
|
+
};
|
|
277
|
+
}, [onClose]);
|
|
278
|
+
|
|
279
|
+
const sameSession = getConversationId(left) === getConversationId(right);
|
|
280
|
+
const allEqual = ops.length === 1 && ops[0]?.kind === "equal";
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<div
|
|
284
|
+
className="fixed inset-0 z-50 flex justify-end"
|
|
285
|
+
role="dialog"
|
|
286
|
+
aria-modal="true"
|
|
287
|
+
aria-label="Compare two log requests"
|
|
288
|
+
>
|
|
289
|
+
{/* Backdrop */}
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={onClose}
|
|
293
|
+
aria-label="Close compare drawer"
|
|
294
|
+
className="absolute inset-0 bg-black/40 cursor-default"
|
|
295
|
+
tabIndex={-1}
|
|
296
|
+
/>
|
|
297
|
+
|
|
298
|
+
{/* Drawer panel */}
|
|
299
|
+
<div
|
|
300
|
+
className={cn(
|
|
301
|
+
"relative bg-background border-l border-border shadow-xl",
|
|
302
|
+
"w-full md:w-[70vw] max-w-[1100px] flex flex-col h-full",
|
|
303
|
+
)}
|
|
304
|
+
onClick={(e) => e.stopPropagation()}
|
|
305
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
306
|
+
>
|
|
307
|
+
{/* Header */}
|
|
308
|
+
<div className="flex items-start gap-4 px-4 py-3 border-b border-border">
|
|
309
|
+
<div className="flex-1 flex gap-4 min-w-0">
|
|
310
|
+
<SideSummary log={left} side="left" />
|
|
311
|
+
<SideSummary log={right} side="right" />
|
|
312
|
+
</div>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
onClick={onClose}
|
|
316
|
+
aria-label="Close"
|
|
317
|
+
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer"
|
|
318
|
+
>
|
|
319
|
+
<X className="size-4" />
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{!sameSession && (
|
|
324
|
+
<div className="px-4 py-1.5 text-xs text-amber-400 bg-amber-500/10 border-b border-border">
|
|
325
|
+
Heads up: the two selected logs are from different sessions.
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* Body: path-aligned two-pane diff */}
|
|
330
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
331
|
+
{allEqual ? (
|
|
332
|
+
<div className="px-4 py-12 text-center text-muted-foreground text-sm">
|
|
333
|
+
The two Request payloads are identical.
|
|
334
|
+
</div>
|
|
335
|
+
) : (
|
|
336
|
+
<div className="grid grid-cols-[200px_1fr_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs">
|
|
337
|
+
{/* Column headers */}
|
|
338
|
+
<div className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 pb-2 mb-2 border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
339
|
+
<span>Path</span>
|
|
340
|
+
<span>Left (Log #{left.id})</span>
|
|
341
|
+
<span>Right (Log #{right.id})</span>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{grouped.map((g, i) => {
|
|
345
|
+
if (g.kind === "equal-run") {
|
|
346
|
+
return (
|
|
347
|
+
<EqualRunRow
|
|
348
|
+
key={`r${i}`}
|
|
349
|
+
ops={g.ops}
|
|
350
|
+
expanded={expandedRuns.has(i)}
|
|
351
|
+
onToggle={() => toggleRun(i)}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const op = g.op;
|
|
356
|
+
if (op.kind === "equal") {
|
|
357
|
+
return (
|
|
358
|
+
<div
|
|
359
|
+
key={`e${i}`}
|
|
360
|
+
className="col-span-3 grid grid-cols-[200px_1fr_1fr] gap-x-2 px-2 py-0.5 text-muted-foreground"
|
|
361
|
+
>
|
|
362
|
+
<span className="font-mono text-xs truncate" title={op.path}>
|
|
363
|
+
{op.path}
|
|
364
|
+
</span>
|
|
365
|
+
<span className="font-mono text-xs break-all opacity-60">
|
|
366
|
+
{previewNode(op.value, 200)}
|
|
367
|
+
</span>
|
|
368
|
+
<span className="font-mono text-xs break-all opacity-60">
|
|
369
|
+
{previewNode(op.value, 200)}
|
|
370
|
+
</span>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (op.kind === "added") {
|
|
375
|
+
return <AddOrRemoveRow key={`a${i}`} op={op} kind="added" />;
|
|
376
|
+
}
|
|
377
|
+
if (op.kind === "removed") {
|
|
378
|
+
return <AddOrRemoveRow key={`r${i}`} op={op} kind="removed" />;
|
|
379
|
+
}
|
|
380
|
+
return <ChangedRow key={`c${i}`} op={op} />;
|
|
381
|
+
})}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -9,12 +9,22 @@ import {
|
|
|
9
9
|
type ConversationGroupData,
|
|
10
10
|
} from "./ConversationHeader";
|
|
11
11
|
import { LogEntry } from "./LogEntry";
|
|
12
|
+
import type { CacheTrendEntry } from "./cacheTrend";
|
|
12
13
|
|
|
13
14
|
export type ConversationGroupProps = {
|
|
14
15
|
group: ConversationGroupData;
|
|
15
16
|
viewMode?: "simple" | "full";
|
|
16
17
|
/** Live strip-Claude-Code-billing-header flag from the viewer container. */
|
|
17
18
|
strip: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Pre-computed per-log cache token trend map (keyed by `log.id`) shared
|
|
21
|
+
* across the whole viewer. Each `LogEntry` looks up its own entry.
|
|
22
|
+
*/
|
|
23
|
+
cacheTrends?: Map<number, CacheTrendEntry>;
|
|
24
|
+
/** Set of log ids currently marked for comparison. Forwarded to each `LogEntry`. */
|
|
25
|
+
selectedSet: Set<number>;
|
|
26
|
+
/** Toggle a log in/out of the comparison selection. */
|
|
27
|
+
onToggleSelect: (logId: number) => void;
|
|
18
28
|
};
|
|
19
29
|
|
|
20
30
|
function computeStats(logs: CapturedLog[]): {
|
|
@@ -34,6 +44,9 @@ export const ConversationGroup = memo(function ({
|
|
|
34
44
|
group,
|
|
35
45
|
viewMode = "simple",
|
|
36
46
|
strip,
|
|
47
|
+
cacheTrends,
|
|
48
|
+
selectedSet,
|
|
49
|
+
onToggleSelect,
|
|
37
50
|
}: ConversationGroupProps): JSX.Element {
|
|
38
51
|
const [expanded, setExpanded] = useState(false);
|
|
39
52
|
|
|
@@ -73,6 +86,9 @@ export const ConversationGroup = memo(function ({
|
|
|
73
86
|
viewMode={viewMode}
|
|
74
87
|
suppressApiFormatBadge={!mixed}
|
|
75
88
|
strip={strip}
|
|
89
|
+
cacheTrend={cacheTrends?.get(log.id) ?? null}
|
|
90
|
+
isSelected={selectedSet.has(log.id)}
|
|
91
|
+
onToggleSelect={onToggleSelect}
|
|
76
92
|
/>
|
|
77
93
|
))}
|
|
78
94
|
</div>
|
|
@@ -12,6 +12,7 @@ import { LogEntryHeader } from "./LogEntryHeader";
|
|
|
12
12
|
import { ReplayDialog } from "./ReplayDialog";
|
|
13
13
|
import { ResponseView } from "./ResponseView";
|
|
14
14
|
import { StreamingChunkSequence } from "./StreamingChunkSequence";
|
|
15
|
+
import type { CacheTrendEntry } from "./cacheTrend";
|
|
15
16
|
|
|
16
17
|
export type LogEntryProps = {
|
|
17
18
|
log: CapturedLog;
|
|
@@ -25,6 +26,15 @@ export type LogEntryProps = {
|
|
|
25
26
|
* cost).
|
|
26
27
|
*/
|
|
27
28
|
strip: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Per-log cache token trend, looked up in the viewer-level trend map.
|
|
31
|
+
* `null` (or absent) means the header should render with no arrows.
|
|
32
|
+
*/
|
|
33
|
+
cacheTrend?: CacheTrendEntry | null;
|
|
34
|
+
/** Whether this log is currently marked for comparison. */
|
|
35
|
+
isSelected?: boolean;
|
|
36
|
+
/** Toggle this log in/out of the comparison selection. */
|
|
37
|
+
onToggleSelect?: (logId: number) => void;
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -131,6 +141,9 @@ export const LogEntry = memo(function ({
|
|
|
131
141
|
viewMode = "simple",
|
|
132
142
|
suppressApiFormatBadge = false,
|
|
133
143
|
strip,
|
|
144
|
+
cacheTrend = null,
|
|
145
|
+
isSelected = false,
|
|
146
|
+
onToggleSelect,
|
|
134
147
|
}: LogEntryProps): JSX.Element {
|
|
135
148
|
const [expanded, setExpanded] = useState<boolean>(false);
|
|
136
149
|
const [requestCopied, setRequestCopied] = useState<boolean>(false);
|
|
@@ -186,13 +199,21 @@ export const LogEntry = memo(function ({
|
|
|
186
199
|
|
|
187
200
|
return (
|
|
188
201
|
<>
|
|
189
|
-
<div
|
|
202
|
+
<div
|
|
203
|
+
className={cn(
|
|
204
|
+
"border border-border rounded-lg mb-3 overflow-hidden",
|
|
205
|
+
isSelected && "border-l-2 border-l-amber-400",
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
190
208
|
<LogEntryHeader
|
|
191
209
|
log={log}
|
|
192
210
|
parsedRequest={parsedRequest}
|
|
193
211
|
expanded={expanded}
|
|
194
212
|
onToggle={() => setExpanded(!expanded)}
|
|
195
213
|
suppressApiFormatBadge={suppressApiFormatBadge}
|
|
214
|
+
cacheTrend={cacheTrend}
|
|
215
|
+
isSelected={isSelected}
|
|
216
|
+
onToggleSelect={onToggleSelect}
|
|
196
217
|
/>
|
|
197
218
|
|
|
198
219
|
{expanded && (
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ArrowDown,
|
|
3
|
+
ArrowUp,
|
|
4
|
+
Check,
|
|
2
5
|
ChevronDown,
|
|
3
6
|
ChevronRight,
|
|
4
7
|
Clock,
|
|
@@ -17,6 +20,7 @@ import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../
|
|
|
17
20
|
import type { CapturedLog, InspectorRequest } from "../../proxy/schemas";
|
|
18
21
|
import { Badge } from "../ui/badge";
|
|
19
22
|
import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
|
|
23
|
+
import type { CacheTrend } from "./cacheTrend";
|
|
20
24
|
|
|
21
25
|
function formatElapsed(ms: number): string {
|
|
22
26
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -30,6 +34,26 @@ const STATUS_BADGE_CLASSES: Record<StatusCategory, string> = {
|
|
|
30
34
|
pending: "bg-muted text-muted-foreground border-border",
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Inline trend indicator: small arrow (green up / red down) plus the absolute
|
|
39
|
+
* delta in compact form. Returns `null` when there is no trend to display.
|
|
40
|
+
*/
|
|
41
|
+
function CacheTrendIndicator({ trend }: { trend: CacheTrend | null }): JSX.Element | null {
|
|
42
|
+
if (trend === null) return null;
|
|
43
|
+
const isUp = trend.direction === "up";
|
|
44
|
+
const Icon = isUp ? ArrowUp : ArrowDown;
|
|
45
|
+
const sign = isUp ? "+" : "-";
|
|
46
|
+
return (
|
|
47
|
+
<span className="flex items-center gap-0.5 text-muted-foreground tabular-nums">
|
|
48
|
+
<Icon className={isUp ? "size-3 text-emerald-400" : "size-3 text-rose-400"} />
|
|
49
|
+
<span className="font-mono">
|
|
50
|
+
{sign}
|
|
51
|
+
{formatTokens(trend.delta)}
|
|
52
|
+
</span>
|
|
53
|
+
</span>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
export type LogEntryHeaderProps = {
|
|
34
58
|
log: CapturedLog;
|
|
35
59
|
parsedRequest: InspectorRequest | null;
|
|
@@ -37,6 +61,16 @@ export type LogEntryHeaderProps = {
|
|
|
37
61
|
onToggle: () => void;
|
|
38
62
|
/** Suppress the API format badge when log is displayed within a group */
|
|
39
63
|
suppressApiFormatBadge?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Per-log cache token trend (creation + read) relative to the previous log
|
|
66
|
+
* in the same conversation group. When `undefined` or a field is `null`,
|
|
67
|
+
* the corresponding cache span renders as it did before — no arrow.
|
|
68
|
+
*/
|
|
69
|
+
cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
|
|
70
|
+
/** Whether this log is currently marked for comparison. */
|
|
71
|
+
isSelected?: boolean;
|
|
72
|
+
/** Toggle this log in/out of the comparison selection. */
|
|
73
|
+
onToggleSelect?: (logId: number) => void;
|
|
40
74
|
};
|
|
41
75
|
|
|
42
76
|
export const LogEntryHeader = memo(function ({
|
|
@@ -45,6 +79,9 @@ export const LogEntryHeader = memo(function ({
|
|
|
45
79
|
expanded,
|
|
46
80
|
onToggle,
|
|
47
81
|
suppressApiFormatBadge = false,
|
|
82
|
+
cacheTrend = null,
|
|
83
|
+
isSelected = false,
|
|
84
|
+
onToggleSelect,
|
|
48
85
|
}: LogEntryHeaderProps): JSX.Element {
|
|
49
86
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
50
87
|
|
|
@@ -74,6 +111,27 @@ export const LogEntryHeader = memo(function ({
|
|
|
74
111
|
}
|
|
75
112
|
}}
|
|
76
113
|
>
|
|
114
|
+
{/* Selection checkbox (for log-request comparison) */}
|
|
115
|
+
{onToggleSelect !== undefined && (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={(e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
onToggleSelect(log.id);
|
|
121
|
+
}}
|
|
122
|
+
aria-label={isSelected ? "Deselect for comparison" : "Select for comparison"}
|
|
123
|
+
aria-pressed={isSelected}
|
|
124
|
+
className={cn(
|
|
125
|
+
"shrink-0 size-4 rounded-sm border flex items-center justify-center transition-colors cursor-pointer",
|
|
126
|
+
isSelected
|
|
127
|
+
? "bg-amber-400 border-amber-400 text-amber-950"
|
|
128
|
+
: "border-muted-foreground/40 hover:border-amber-400 hover:bg-amber-400/10",
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{isSelected && <Check className="size-3" strokeWidth={3} />}
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
134
|
+
|
|
77
135
|
{/* Request ID */}
|
|
78
136
|
<span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
|
|
79
137
|
#{log.id}
|
|
@@ -163,6 +221,7 @@ export const LogEntryHeader = memo(function ({
|
|
|
163
221
|
{/* Cache tokens */}
|
|
164
222
|
{log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
|
|
165
223
|
<span className="flex items-center gap-1 text-xs shrink-0">
|
|
224
|
+
<CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
|
|
166
225
|
<span className="font-mono tabular-nums text-emerald-400">
|
|
167
226
|
Cache +{formatTokens(log.cacheCreationInputTokens)}
|
|
168
227
|
</span>
|
|
@@ -170,6 +229,7 @@ export const LogEntryHeader = memo(function ({
|
|
|
170
229
|
)}
|
|
171
230
|
{log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
|
|
172
231
|
<span className="flex items-center gap-1 text-xs shrink-0">
|
|
232
|
+
<CacheTrendIndicator trend={cacheTrend?.read ?? null} />
|
|
173
233
|
<span className="font-mono tabular-nums text-purple-400">
|
|
174
234
|
Cache ~{formatTokens(log.cacheReadInputTokens)}
|
|
175
235
|
</span>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ConversationGroupData } from "./ConversationHeader";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Trend direction for a single cache token field, comparing the current log
|
|
5
|
+
* to the previous log in the same conversation group.
|
|
6
|
+
*/
|
|
7
|
+
export type CacheTrend = { direction: "up" | "down"; delta: number };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-log trend entry. `creation` covers `cacheCreationInputTokens`,
|
|
11
|
+
* `read` covers `cacheReadInputTokens`. Each field is `null` when there is
|
|
12
|
+
* no previous log to compare against, when the current log has no value for
|
|
13
|
+
* the field, or when the values are equal (no visual change).
|
|
14
|
+
*/
|
|
15
|
+
export type CacheTrendEntry = { creation: CacheTrend | null; read: CacheTrend | null };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a `logId -> CacheTrendEntry` map for every log that has a previous
|
|
19
|
+
* log in its conversation group. Pure: does not mutate `groups`. Walks each
|
|
20
|
+
* group in array order (which is the timestamp-sorted order produced by
|
|
21
|
+
* `groupLogsByConversation`), so "previous log" is the adjacent entry in
|
|
22
|
+
* that order.
|
|
23
|
+
*/
|
|
24
|
+
export function computeCacheTrends(groups: ConversationGroupData[]): Map<number, CacheTrendEntry> {
|
|
25
|
+
const result = new Map<number, CacheTrendEntry>();
|
|
26
|
+
|
|
27
|
+
for (const group of groups) {
|
|
28
|
+
const logs = group.logs;
|
|
29
|
+
for (let i = 1; i < logs.length; i++) {
|
|
30
|
+
const prev = logs[i - 1];
|
|
31
|
+
const curr = logs[i];
|
|
32
|
+
if (prev === undefined || curr === undefined) continue;
|
|
33
|
+
|
|
34
|
+
result.set(curr.id, {
|
|
35
|
+
creation: compareField(prev.cacheCreationInputTokens, curr.cacheCreationInputTokens),
|
|
36
|
+
read: compareField(prev.cacheReadInputTokens, curr.cacheReadInputTokens),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function compareField(previous: number | null, current: number | null): CacheTrend | null {
|
|
45
|
+
if (current === null) return null;
|
|
46
|
+
if (previous === null) return null;
|
|
47
|
+
if (current > previous) return { direction: "up", delta: current - previous };
|
|
48
|
+
if (current < previous) return { direction: "down", delta: previous - current };
|
|
49
|
+
return null;
|
|
50
|
+
}
|