@tonyclaw/llm-inspector 1.12.0 → 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.
@@ -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
+ }
@@ -21,6 +21,10 @@ export type ConversationGroupProps = {
21
21
  * across the whole viewer. Each `LogEntry` looks up its own entry.
22
22
  */
23
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;
24
28
  };
25
29
 
26
30
  function computeStats(logs: CapturedLog[]): {
@@ -41,6 +45,8 @@ export const ConversationGroup = memo(function ({
41
45
  viewMode = "simple",
42
46
  strip,
43
47
  cacheTrends,
48
+ selectedSet,
49
+ onToggleSelect,
44
50
  }: ConversationGroupProps): JSX.Element {
45
51
  const [expanded, setExpanded] = useState(false);
46
52
 
@@ -81,6 +87,8 @@ export const ConversationGroup = memo(function ({
81
87
  suppressApiFormatBadge={!mixed}
82
88
  strip={strip}
83
89
  cacheTrend={cacheTrends?.get(log.id) ?? null}
90
+ isSelected={selectedSet.has(log.id)}
91
+ onToggleSelect={onToggleSelect}
84
92
  />
85
93
  ))}
86
94
  </div>
@@ -31,6 +31,10 @@ export type LogEntryProps = {
31
31
  * `null` (or absent) means the header should render with no arrows.
32
32
  */
33
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;
34
38
  };
35
39
 
36
40
  /**
@@ -138,6 +142,8 @@ export const LogEntry = memo(function ({
138
142
  suppressApiFormatBadge = false,
139
143
  strip,
140
144
  cacheTrend = null,
145
+ isSelected = false,
146
+ onToggleSelect,
141
147
  }: LogEntryProps): JSX.Element {
142
148
  const [expanded, setExpanded] = useState<boolean>(false);
143
149
  const [requestCopied, setRequestCopied] = useState<boolean>(false);
@@ -193,7 +199,12 @@ export const LogEntry = memo(function ({
193
199
 
194
200
  return (
195
201
  <>
196
- <div className={cn("border border-border rounded-lg mb-3 overflow-hidden")}>
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
+ >
197
208
  <LogEntryHeader
198
209
  log={log}
199
210
  parsedRequest={parsedRequest}
@@ -201,6 +212,8 @@ export const LogEntry = memo(function ({
201
212
  onToggle={() => setExpanded(!expanded)}
202
213
  suppressApiFormatBadge={suppressApiFormatBadge}
203
214
  cacheTrend={cacheTrend}
215
+ isSelected={isSelected}
216
+ onToggleSelect={onToggleSelect}
204
217
  />
205
218
 
206
219
  {expanded && (
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  ArrowDown,
3
3
  ArrowUp,
4
+ Check,
4
5
  ChevronDown,
5
6
  ChevronRight,
6
7
  Clock,
@@ -66,6 +67,10 @@ export type LogEntryHeaderProps = {
66
67
  * the corresponding cache span renders as it did before — no arrow.
67
68
  */
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;
69
74
  };
70
75
 
71
76
  export const LogEntryHeader = memo(function ({
@@ -75,6 +80,8 @@ export const LogEntryHeader = memo(function ({
75
80
  onToggle,
76
81
  suppressApiFormatBadge = false,
77
82
  cacheTrend = null,
83
+ isSelected = false,
84
+ onToggleSelect,
78
85
  }: LogEntryHeaderProps): JSX.Element {
79
86
  const statusCategory = getStatusCategory(log.responseStatus);
80
87
 
@@ -104,6 +111,27 @@ export const LogEntryHeader = memo(function ({
104
111
  }
105
112
  }}
106
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
+
107
135
  {/* Request ID */}
108
136
  <span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
109
137
  #{log.id}