@tonyclaw/llm-inspector 1.16.2 → 1.16.4

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.
@@ -1,11 +1,15 @@
1
1
  import { Check, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Copy } from "lucide-react";
2
- import { type JSX, memo, useState } from "react";
2
+ import { type JSX, memo, useCallback, useMemo, useState, useTransition } from "react";
3
3
  import ReactMarkdown from "react-markdown";
4
4
  import { cn } from "../../lib/utils";
5
+ import { Button } from "./button";
5
6
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
6
7
 
7
8
  type JsonPrimitive = string | number | boolean | null;
8
- type JsonValue = JsonPrimitive | ReadonlyArray<JsonValue> | Readonly<{ [key: string]: JsonValue }>;
9
+ export type JsonValue =
10
+ | JsonPrimitive
11
+ | ReadonlyArray<JsonValue>
12
+ | Readonly<{ [key: string]: JsonValue }>;
9
13
 
10
14
  type DataType = "string" | "number" | "boolean" | "null" | "array" | "object";
11
15
 
@@ -33,29 +37,141 @@ function isExpandable(value: JsonValue): boolean {
33
37
  return value !== null && (Array.isArray(value) || typeof value === "object");
34
38
  }
35
39
 
36
- function getPropertyValue(obj: object, key: string): unknown {
37
- const descriptor = Object.getOwnPropertyDescriptor(obj, key);
38
- if (descriptor === undefined) return undefined;
39
- return descriptor.value;
40
- }
41
-
42
40
  function getEntries(value: JsonValue): ReadonlyArray<readonly [string, JsonValue]> {
43
41
  if (Array.isArray(value)) {
44
- return value.map((item, index) => [String(index), item] as const);
42
+ return value.map((item, index): readonly [string, JsonValue] => [String(index), item]);
45
43
  }
46
44
  if (typeof value === "object" && value !== null) {
47
- return Object.keys(value).map((key) => {
48
- const raw = getPropertyValue(value, key);
49
- return [key, safeJsonValue(raw)] as const;
50
- });
45
+ return Object.entries(value);
51
46
  }
52
47
  return [];
53
48
  }
54
49
 
55
- function getItemCount(value: JsonValue): number {
56
- if (Array.isArray(value)) return value.length;
57
- if (typeof value === "object" && value !== null) return Object.keys(value).length;
58
- return 0;
50
+ export const JSON_FULL_EXPANSION_NODE_LIMIT = 1000;
51
+ export const JSON_LARGE_EXPANSION_DEPTH = 2;
52
+
53
+ export type JsonExpansionPolicy = {
54
+ depth: number;
55
+ limited: boolean;
56
+ countedNodes: number;
57
+ };
58
+
59
+ export function countJsonNodes(value: JsonValue, limit = Number.POSITIVE_INFINITY): number {
60
+ let count = 0;
61
+ const pending: JsonValue[] = [value];
62
+
63
+ while (pending.length > 0) {
64
+ const current = pending.pop();
65
+ if (current === undefined) continue;
66
+ count += 1;
67
+ if (count > limit) return count;
68
+ if (!isExpandable(current)) continue;
69
+ for (const [, child] of getEntries(current)) {
70
+ pending.push(child);
71
+ }
72
+ }
73
+
74
+ return count;
75
+ }
76
+
77
+ export function getJsonExpansionPolicy(value: JsonValue): JsonExpansionPolicy {
78
+ const countedNodes = countJsonNodes(value, JSON_FULL_EXPANSION_NODE_LIMIT);
79
+ const limited = countedNodes > JSON_FULL_EXPANSION_NODE_LIMIT;
80
+ return {
81
+ depth: limited ? JSON_LARGE_EXPANSION_DEPTH : Number.POSITIVE_INFINITY,
82
+ limited,
83
+ countedNodes,
84
+ };
85
+ }
86
+
87
+ export type JsonBulkExpansion = {
88
+ parsedData: JsonValue | null;
89
+ policy: JsonExpansionPolicy | null;
90
+ isExpanded: boolean;
91
+ toggle: () => void;
92
+ isPending: boolean;
93
+ bulkDepth: number;
94
+ bulkRevision: number;
95
+ };
96
+
97
+ export function useJsonBulkExpansion(text: string | null): JsonBulkExpansion {
98
+ const parsed = useMemo(() => (text === null ? null : parseJsonText(text)), [text]);
99
+ const parsedData = parsed?.kind === "json" ? parsed.data : null;
100
+ const policy = useMemo(
101
+ () => (parsedData === null ? null : getJsonExpansionPolicy(parsedData)),
102
+ [parsedData],
103
+ );
104
+ const [isExpanded, setIsExpanded] = useState(false);
105
+ const [bulkDepth, setBulkDepth] = useState(0);
106
+ const [bulkRevision, setBulkRevision] = useState(0);
107
+ const [isPending, startTransition] = useTransition();
108
+
109
+ const toggle = useCallback(() => {
110
+ const nextExpanded = !isExpanded;
111
+ const targetDepth = nextExpanded && policy !== null ? policy.depth : 0;
112
+ startTransition(() => {
113
+ setIsExpanded(nextExpanded);
114
+ setBulkDepth(targetDepth);
115
+ setBulkRevision((current) => current + 1);
116
+ });
117
+ }, [isExpanded, policy]);
118
+
119
+ return { parsedData, policy, isExpanded, toggle, isPending, bulkDepth, bulkRevision };
120
+ }
121
+
122
+ export function JsonExpansionButton({
123
+ policy,
124
+ isExpanded,
125
+ isPending,
126
+ onToggle,
127
+ }: {
128
+ policy: JsonExpansionPolicy | null;
129
+ isExpanded: boolean;
130
+ isPending: boolean;
131
+ onToggle: () => void;
132
+ }): JSX.Element | null {
133
+ if (policy === null) return null;
134
+
135
+ const label = isPending
136
+ ? "Updating..."
137
+ : isExpanded
138
+ ? "Collapse all"
139
+ : policy.limited
140
+ ? `Expand ${JSON_LARGE_EXPANSION_DEPTH} levels`
141
+ : "Expand all";
142
+
143
+ const tooltip =
144
+ policy.limited && !isExpanded
145
+ ? `Large JSON detected; expand ${JSON_LARGE_EXPANSION_DEPTH} levels to protect rendering performance`
146
+ : isExpanded
147
+ ? "Collapse all JSON nodes"
148
+ : "Expand all JSON nodes";
149
+
150
+ return (
151
+ <Tooltip>
152
+ <TooltipTrigger asChild>
153
+ <Button
154
+ variant="outline"
155
+ size="sm"
156
+ className="h-7 text-xs"
157
+ onClick={(e) => {
158
+ e.stopPropagation();
159
+ onToggle();
160
+ }}
161
+ disabled={isPending}
162
+ aria-pressed={isExpanded}
163
+ >
164
+ {isExpanded ? (
165
+ <ChevronsUp className="size-3 mr-1" />
166
+ ) : (
167
+ <ChevronsDown className="size-3 mr-1" />
168
+ )}
169
+ {label}
170
+ </Button>
171
+ </TooltipTrigger>
172
+ <TooltipContent>{tooltip}</TooltipContent>
173
+ </Tooltip>
174
+ );
59
175
  }
60
176
 
61
177
  const STRING_TRUNCATE_LIMIT = 120;
@@ -206,16 +322,9 @@ type JsonNodeProps = {
206
322
  isArrayItem: boolean;
207
323
  };
208
324
 
209
- function hasExpandableDescendant(value: JsonValue): boolean {
210
- if (!isExpandable(value)) return false;
211
- return getEntries(value).some(([, v]) => isExpandable(v));
212
- }
213
-
214
325
  function ExpandCollapseButton({
215
- allExpanded,
216
326
  onClick,
217
327
  }: {
218
- allExpanded: boolean;
219
328
  onClick: (e: React.MouseEvent) => void;
220
329
  }): JSX.Element {
221
330
  return (
@@ -223,13 +332,9 @@ function ExpandCollapseButton({
223
332
  type="button"
224
333
  onClick={onClick}
225
334
  className="opacity-0 group-hover/row:opacity-100 hover:bg-muted p-0.5 rounded transition-opacity shrink-0"
226
- title={allExpanded ? "Collapse all" : "Expand all"}
335
+ title="Expand all descendants"
227
336
  >
228
- {allExpanded ? (
229
- <ChevronsUp className="size-3.5 text-muted-foreground" />
230
- ) : (
231
- <ChevronsDown className="size-3.5 text-muted-foreground" />
232
- )}
337
+ <ChevronsDown className="size-3.5 text-muted-foreground" />
233
338
  </button>
234
339
  );
235
340
  }
@@ -244,30 +349,41 @@ const JsonNode = memo(function JsonNode({
244
349
  const [expanded, setExpanded] = useState(level < defaultExpandDepth);
245
350
  const [childResetKey, setChildResetKey] = useState(0);
246
351
  const [childDepthOverride, setChildDepthOverride] = useState<number | null>(null);
247
- const [allExpanded, setAllExpanded] = useState(false);
248
352
  const expandable = isExpandable(value);
353
+ const fullyExpanded = childDepthOverride === Number.POSITIVE_INFINITY;
354
+ const entries = useMemo(() => getEntries(value), [value]);
355
+ const hasExpandableChild = useMemo(
356
+ () => entries.some(([, child]) => isExpandable(child)),
357
+ [entries],
358
+ );
249
359
 
250
360
  const dataType = classifyValue(value);
251
361
  const openBracket = dataType === "array" ? "[" : "{";
252
362
  const closeBracket = dataType === "array" ? "]" : "}";
253
363
 
254
- function toggleDeepExpansion(): void {
255
- if (allExpanded) {
256
- setExpanded(false);
257
- setChildDepthOverride(0);
258
- setChildResetKey((k) => k + 1);
259
- setAllExpanded(false);
364
+ function expandAllDeep(): void {
365
+ setExpanded(true);
366
+ setChildDepthOverride(Number.POSITIVE_INFINITY);
367
+ setChildResetKey((k) => k + 1);
368
+ }
369
+
370
+ function collapseToDefault(): void {
371
+ setExpanded(false);
372
+ setChildDepthOverride(0);
373
+ setChildResetKey((k) => k + 1);
374
+ }
375
+
376
+ function handleRowToggle(): void {
377
+ if (fullyExpanded) {
378
+ collapseToDefault();
260
379
  } else {
261
- setExpanded(true);
262
- setChildDepthOverride(Number.POSITIVE_INFINITY);
263
- setChildResetKey((k) => k + 1);
264
- setAllExpanded(true);
380
+ setExpanded(!expanded);
265
381
  }
266
382
  }
267
383
 
268
384
  function handleExpandAll(e: React.MouseEvent): void {
269
385
  e.stopPropagation();
270
- toggleDeepExpansion();
386
+ expandAllDeep();
271
387
  }
272
388
 
273
389
  const effectiveChildDepth = childDepthOverride ?? defaultExpandDepth;
@@ -279,29 +395,18 @@ const JsonNode = memo(function JsonNode({
279
395
  "flex items-start gap-1 py-0.5 px-1 -ml-1 rounded-sm group/row",
280
396
  expandable && "cursor-pointer hover:bg-muted/50",
281
397
  )}
282
- onClick={
283
- expandable
284
- ? () => {
285
- if (expanded) {
286
- setAllExpanded(false);
287
- }
288
- setExpanded(!expanded);
289
- }
290
- : undefined
291
- }
398
+ onClick={expandable ? handleRowToggle : undefined}
292
399
  onKeyDown={
293
400
  expandable
294
401
  ? (e) => {
295
402
  if (e.key === "Enter" || e.key === " ") {
296
403
  e.preventDefault();
297
- setExpanded(!expanded);
404
+ handleRowToggle();
298
405
  }
299
406
  }
300
407
  : undefined
301
408
  }
302
- onDoubleClick={
303
- expandable && hasExpandableDescendant(value) ? () => toggleDeepExpansion() : undefined
304
- }
409
+ onDoubleClick={expandable && hasExpandableChild ? () => expandAllDeep() : undefined}
305
410
  role={expandable ? "button" : undefined}
306
411
  tabIndex={expandable ? 0 : undefined}
307
412
  >
@@ -328,7 +433,7 @@ const JsonNode = memo(function JsonNode({
328
433
  {openBracket}
329
434
  <span className="text-muted-foreground/60 text-xs">
330
435
  {" "}
331
- {getItemCount(value)} {getItemCount(value) === 1 ? "item" : "items"} {closeBracket}
436
+ {entries.length} {entries.length === 1 ? "item" : "items"} {closeBracket}
332
437
  </span>
333
438
  </span>
334
439
  ) : (
@@ -337,15 +442,15 @@ const JsonNode = memo(function JsonNode({
337
442
  </span>
338
443
  )}
339
444
 
340
- {expandable && hasExpandableDescendant(value) && (
341
- <ExpandCollapseButton allExpanded={allExpanded} onClick={handleExpandAll} />
445
+ {expandable && hasExpandableChild && !fullyExpanded && (
446
+ <ExpandCollapseButton onClick={handleExpandAll} />
342
447
  )}
343
448
  <CopyButton value={value} />
344
449
  </div>
345
450
 
346
451
  {expandable && expanded && (
347
452
  <div className="pl-4" key={childResetKey}>
348
- {getEntries(value).map(([key, childValue]) => (
453
+ {entries.map(([key, childValue]) => (
349
454
  <JsonNode
350
455
  key={key}
351
456
  name={key}
@@ -367,15 +472,23 @@ export type JsonViewerProps = {
367
472
  defaultExpandDepth?: number;
368
473
  className?: string;
369
474
  showCopy?: boolean;
475
+ bulkDepth?: number;
476
+ bulkRevision?: number;
370
477
  };
371
478
 
372
479
  export function JsonViewer({
373
480
  data,
374
- defaultExpandDepth = 2,
481
+ defaultExpandDepth = 0,
375
482
  className,
376
483
  showCopy = false,
484
+ bulkDepth: controlledBulkDepth,
485
+ bulkRevision: controlledBulkRevision,
377
486
  }: JsonViewerProps): JSX.Element {
378
487
  const expandable = isExpandable(data);
488
+ const entries = useMemo(() => getEntries(data), [data]);
489
+
490
+ const bulkDepth = controlledBulkDepth ?? defaultExpandDepth;
491
+ const bulkRevision = controlledBulkRevision ?? 0;
379
492
 
380
493
  if (!expandable) {
381
494
  return (
@@ -396,17 +509,23 @@ export function JsonViewer({
396
509
  return (
397
510
  <TooltipProvider>
398
511
  <div className={cn("font-mono text-xs leading-relaxed", className)}>
399
- {showCopy && <CopyValueButton value={data} />}
400
- {getEntries(data).map(([key, childValue]) => (
401
- <JsonNode
402
- key={key}
403
- name={key}
404
- value={childValue}
405
- level={0}
406
- defaultExpandDepth={defaultExpandDepth}
407
- isArrayItem={isArray}
408
- />
409
- ))}
512
+ {showCopy && (
513
+ <div className="mb-2 flex items-center justify-end gap-2">
514
+ <CopyValueButton value={data} />
515
+ </div>
516
+ )}
517
+ <div key={bulkRevision}>
518
+ {entries.map(([key, childValue]) => (
519
+ <JsonNode
520
+ key={key}
521
+ name={key}
522
+ value={childValue}
523
+ level={0}
524
+ defaultExpandDepth={bulkDepth}
525
+ isArrayItem={isArray}
526
+ />
527
+ ))}
528
+ </div>
410
529
  </div>
411
530
  </TooltipProvider>
412
531
  );
@@ -418,28 +537,45 @@ export type JsonViewerFromStringProps = {
418
537
  className?: string;
419
538
  };
420
539
 
421
- export function JsonViewerFromString({
540
+ type ParsedJsonText =
541
+ | {
542
+ kind: "json";
543
+ data: JsonValue;
544
+ }
545
+ | {
546
+ kind: "text";
547
+ };
548
+
549
+ function parseJsonText(text: string): ParsedJsonText {
550
+ try {
551
+ const parsed: unknown = JSON.parse(text);
552
+ return { kind: "json", data: safeJsonValue(parsed) };
553
+ } catch {
554
+ return { kind: "text" };
555
+ }
556
+ }
557
+
558
+ export const JsonViewerFromString = memo(function JsonViewerFromString({
422
559
  text,
423
- defaultExpandDepth = 2,
560
+ defaultExpandDepth = 0,
424
561
  className,
425
562
  }: JsonViewerFromStringProps): JSX.Element {
426
- try {
427
- const parsed: unknown = JSON.parse(text);
563
+ const parsed = useMemo(() => parseJsonText(text), [text]);
564
+
565
+ if (parsed.kind === "json") {
428
566
  return (
429
567
  <JsonViewer
430
- data={safeJsonValue(parsed)}
568
+ data={parsed.data}
431
569
  defaultExpandDepth={defaultExpandDepth}
432
570
  className={className}
433
571
  />
434
572
  );
435
- } catch {
436
- return (
437
- <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>
438
- {text}
439
- </pre>
440
- );
441
573
  }
442
- }
574
+
575
+ return (
576
+ <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>{text}</pre>
577
+ );
578
+ });
443
579
 
444
580
  export function safeJsonValue(value: unknown): JsonValue {
445
581
  if (value === null || value === undefined) return null;