@tonyclaw/llm-inspector 1.16.3 → 1.16.5

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.
Files changed (58) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/CompareDrawer-C1w4KUGZ.js +1 -0
  3. package/.output/public/assets/ReplayDialog-DR2Sgq_g.js +1 -0
  4. package/.output/public/assets/RequestAnatomy-DAre35kj.js +1 -0
  5. package/.output/public/assets/ResponseView-ackes7_g.js +1 -0
  6. package/.output/public/assets/StreamingChunkSequence-GrXwIGKA.js +1 -0
  7. package/.output/public/assets/index-BGzHFOEX.css +1 -0
  8. package/.output/public/assets/index-DX88k9br.js +101 -0
  9. package/.output/public/assets/json-viewer-C_QUhGeu.js +14 -0
  10. package/.output/public/assets/{main-Cpts3Ifr.js → main-CDMdNDY_.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +96 -76
  12. package/.output/server/_ssr/CompareDrawer-ftkJxyk6.mjs +1040 -0
  13. package/.output/server/_ssr/ReplayDialog-DcmE3lj5.mjs +321 -0
  14. package/.output/server/_ssr/RequestAnatomy-rK_LNMdG.mjs +351 -0
  15. package/.output/server/_ssr/ResponseView-CbQ4n-aJ.mjs +601 -0
  16. package/.output/server/_ssr/StreamingChunkSequence-84FZkIzv.mjs +301 -0
  17. package/.output/server/_ssr/{index-CjvQZBI0.mjs → index-CDjLoMsk.mjs} +1036 -2352
  18. package/.output/server/_ssr/index.mjs +2 -2
  19. package/.output/server/_ssr/json-viewer-B-qpM5xC.mjs +510 -0
  20. package/.output/server/_ssr/{router-CO9_4CVh.mjs → router-BrdjOUEW.mjs} +24 -14
  21. package/.output/server/{_tanstack-start-manifest_v-D-9SW7K3.mjs → _tanstack-start-manifest_v-DmOZEcJ3.mjs} +1 -1
  22. package/.output/server/index.mjs +72 -30
  23. package/package.json +1 -1
  24. package/src/components/OnboardingBanner.tsx +2 -2
  25. package/src/components/ProxyViewer.tsx +38 -26
  26. package/src/components/ProxyViewerContainer.tsx +3 -24
  27. package/src/components/proxy-viewer/CompareDrawer.tsx +6 -6
  28. package/src/components/proxy-viewer/ConversationGroup.tsx +1 -1
  29. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  30. package/src/components/proxy-viewer/LogEntry.tsx +230 -163
  31. package/src/components/proxy-viewer/LogEntryHeader.tsx +134 -36
  32. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +1 -1
  33. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  34. package/src/components/proxy-viewer/TurnGroup.tsx +94 -71
  35. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  36. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  37. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  38. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  39. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  40. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +4 -24
  41. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  42. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +6 -4
  43. package/src/components/proxy-viewer/lazy.ts +37 -0
  44. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  45. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  46. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  47. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  48. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  49. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  50. package/src/components/proxy-viewer/viewerState.ts +8 -0
  51. package/src/components/ui/crab-variants.tsx +11 -0
  52. package/src/components/ui/json-expansion-button.tsx +56 -0
  53. package/src/components/ui/json-viewer-bulk.ts +97 -0
  54. package/src/components/ui/json-viewer.tsx +129 -118
  55. package/src/lib/utils.ts +2 -3
  56. package/src/routes/api/logs.stream.ts +26 -16
  57. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  58. package/.output/public/assets/index-DfjhkDNi.js +0 -107
@@ -0,0 +1,97 @@
1
+ import { useCallback, useMemo, useState, useTransition } from "react";
2
+
3
+ type JsonPrimitive = string | number | boolean | null;
4
+ export type JsonValue =
5
+ | JsonPrimitive
6
+ | ReadonlyArray<JsonValue>
7
+ | Readonly<{ [key: string]: JsonValue }>;
8
+
9
+ export type JsonExpansionPolicy = {
10
+ depth: number;
11
+ };
12
+
13
+ export function getJsonExpansionPolicy(_value: JsonValue): JsonExpansionPolicy {
14
+ return { depth: Number.POSITIVE_INFINITY };
15
+ }
16
+
17
+ export type JsonBulkExpansion = {
18
+ parsedData: JsonValue | null;
19
+ policy: JsonExpansionPolicy | null;
20
+ isExpanded: boolean;
21
+ toggle: () => void;
22
+ isPending: boolean;
23
+ bulkDepth: number;
24
+ bulkRevision: number;
25
+ };
26
+
27
+ type ParsedJsonText =
28
+ | {
29
+ kind: "json";
30
+ data: JsonValue;
31
+ }
32
+ | {
33
+ kind: "text";
34
+ };
35
+
36
+ export function parseJsonText(text: string): ParsedJsonText {
37
+ try {
38
+ const parsed: unknown = JSON.parse(text);
39
+ return { kind: "json", data: safeJsonValue(parsed) };
40
+ } catch {
41
+ return { kind: "text" };
42
+ }
43
+ }
44
+
45
+ export function useJsonBulkExpansion(text: string | null): JsonBulkExpansion {
46
+ const parsed = useMemo(() => (text === null ? null : parseJsonText(text)), [text]);
47
+ const parsedData = parsed?.kind === "json" ? parsed.data : null;
48
+ const policy = useMemo(
49
+ () => (parsedData === null ? null : getJsonExpansionPolicy(parsedData)),
50
+ [parsedData],
51
+ );
52
+ const [isExpanded, setIsExpanded] = useState(false);
53
+ const [bulkDepth, setBulkDepth] = useState(0);
54
+ const [bulkRevision, setBulkRevision] = useState(0);
55
+ const [isPending, startTransition] = useTransition();
56
+
57
+ const toggle = useCallback(() => {
58
+ const nextExpanded = !isExpanded;
59
+ const targetDepth = nextExpanded && policy !== null ? policy.depth : 0;
60
+ startTransition(() => {
61
+ setIsExpanded(nextExpanded);
62
+ setBulkDepth(targetDepth);
63
+ setBulkRevision((current) => current + 1);
64
+ });
65
+ }, [isExpanded, policy]);
66
+
67
+ return { parsedData, policy, isExpanded, toggle, isPending, bulkDepth, bulkRevision };
68
+ }
69
+
70
+ export function safeJsonValue(value: unknown): JsonValue {
71
+ if (value === null || value === undefined) return null;
72
+ switch (typeof value) {
73
+ case "string":
74
+ return value;
75
+ case "number":
76
+ return value;
77
+ case "boolean":
78
+ return value;
79
+ case "object": {
80
+ if (Array.isArray(value)) {
81
+ return value.map((item: unknown) => safeJsonValue(item));
82
+ }
83
+ const result: Record<string, JsonValue> = {};
84
+ for (const key of Object.keys(value)) {
85
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
86
+ result[key] = safeJsonValue(descriptor?.value);
87
+ }
88
+ return result;
89
+ }
90
+ case "bigint":
91
+ case "symbol":
92
+ case "function":
93
+ case "undefined":
94
+ return String(value);
95
+ }
96
+ return null;
97
+ }
@@ -1,11 +1,11 @@
1
- import { Check, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Copy } from "lucide-react";
2
- import { type JSX, memo, useState } from "react";
1
+ import { Check, ChevronDown, ChevronRight, ChevronsDown, Copy } from "lucide-react";
2
+ import { type JSX, memo, useEffect, useMemo, useState } 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
- type JsonPrimitive = string | number | boolean | null;
8
- type JsonValue = JsonPrimitive | ReadonlyArray<JsonValue> | Readonly<{ [key: string]: JsonValue }>;
7
+ import type { JsonValue } from "./json-viewer-bulk";
8
+ import { parseJsonText, safeJsonValue } from "./json-viewer-bulk";
9
9
 
10
10
  type DataType = "string" | "number" | "boolean" | "null" | "array" | "object";
11
11
 
@@ -33,31 +33,16 @@ function isExpandable(value: JsonValue): boolean {
33
33
  return value !== null && (Array.isArray(value) || typeof value === "object");
34
34
  }
35
35
 
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
36
  function getEntries(value: JsonValue): ReadonlyArray<readonly [string, JsonValue]> {
43
37
  if (Array.isArray(value)) {
44
- return value.map((item, index) => [String(index), item] as const);
38
+ return value.map((item, index): readonly [string, JsonValue] => [String(index), item]);
45
39
  }
46
40
  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
- });
41
+ return Object.entries(value);
51
42
  }
52
43
  return [];
53
44
  }
54
45
 
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;
59
- }
60
-
61
46
  const STRING_TRUNCATE_LIMIT = 120;
62
47
 
63
48
  function StringValue({ text }: { text: string }): JSX.Element {
@@ -204,18 +189,17 @@ type JsonNodeProps = {
204
189
  level: number;
205
190
  defaultExpandDepth: number;
206
191
  isArrayItem: boolean;
192
+ /** Full JSON-pointer-style path to this node (e.g. "/messages/3"). */
193
+ path: string;
194
+ /** When set, ancestors of this path auto-expand for one render. */
195
+ expandTargetPath: string | null;
196
+ /** Set of paths that should expose `data-anatomy-path` on their row. */
197
+ anatomyPaths: Set<string> | null;
207
198
  };
208
199
 
209
- function hasExpandableDescendant(value: JsonValue): boolean {
210
- if (!isExpandable(value)) return false;
211
- return getEntries(value).some(([, v]) => isExpandable(v));
212
- }
213
-
214
200
  function ExpandCollapseButton({
215
- allExpanded,
216
201
  onClick,
217
202
  }: {
218
- allExpanded: boolean;
219
203
  onClick: (e: React.MouseEvent) => void;
220
204
  }): JSX.Element {
221
205
  return (
@@ -223,13 +207,9 @@ function ExpandCollapseButton({
223
207
  type="button"
224
208
  onClick={onClick}
225
209
  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"}
210
+ title="Expand all descendants"
227
211
  >
228
- {allExpanded ? (
229
- <ChevronsUp className="size-3.5 text-muted-foreground" />
230
- ) : (
231
- <ChevronsDown className="size-3.5 text-muted-foreground" />
232
- )}
212
+ <ChevronsDown className="size-3.5 text-muted-foreground" />
233
213
  </button>
234
214
  );
235
215
  }
@@ -240,34 +220,70 @@ const JsonNode = memo(function JsonNode({
240
220
  level,
241
221
  defaultExpandDepth,
242
222
  isArrayItem,
223
+ path,
224
+ expandTargetPath,
225
+ anatomyPaths,
243
226
  }: JsonNodeProps): JSX.Element {
244
- const [expanded, setExpanded] = useState(level < defaultExpandDepth);
227
+ // A node is an ancestor of the target if the target starts with this
228
+ // node's path followed by a path separator. The root path is "" and
229
+ // is an ancestor of every non-empty path.
230
+ const isAncestorOfTarget = useMemo(() => {
231
+ if (expandTargetPath === null) return false;
232
+ if (path === "") return expandTargetPath.length > 0;
233
+ return expandTargetPath === path || expandTargetPath.startsWith(`${path}/`);
234
+ }, [expandTargetPath, path]);
235
+
236
+ const initialExpanded = level < defaultExpandDepth || (isAncestorOfTarget && isExpandable(value));
237
+
238
+ const [expanded, setExpanded] = useState(initialExpanded);
239
+ // Re-evaluate on every render so a freshly-changed expandTargetPath
240
+ // immediately auto-expands a previously-collapsed ancestor.
241
+ useEffect(() => {
242
+ if (isAncestorOfTarget && isExpandable(value) && !expanded) {
243
+ setExpanded(true);
244
+ }
245
+ // We deliberately only react to expandTargetPath changes here.
246
+ }, [expandTargetPath]);
247
+
245
248
  const [childResetKey, setChildResetKey] = useState(0);
246
249
  const [childDepthOverride, setChildDepthOverride] = useState<number | null>(null);
247
- const [allExpanded, setAllExpanded] = useState(false);
248
250
  const expandable = isExpandable(value);
251
+ const fullyExpanded = childDepthOverride === Number.POSITIVE_INFINITY;
252
+ const entries = useMemo(() => getEntries(value), [value]);
253
+ const hasExpandableChild = useMemo(
254
+ () => entries.some(([, child]) => isExpandable(child)),
255
+ [entries],
256
+ );
249
257
 
250
258
  const dataType = classifyValue(value);
251
259
  const openBracket = dataType === "array" ? "[" : "{";
252
260
  const closeBracket = dataType === "array" ? "]" : "}";
253
261
 
254
- function toggleDeepExpansion(): void {
255
- if (allExpanded) {
256
- setExpanded(false);
257
- setChildDepthOverride(0);
258
- setChildResetKey((k) => k + 1);
259
- setAllExpanded(false);
262
+ const hasAnatomyPath = anatomyPaths !== null && anatomyPaths.has(path);
263
+
264
+ function expandAllDeep(): void {
265
+ setExpanded(true);
266
+ setChildDepthOverride(Number.POSITIVE_INFINITY);
267
+ setChildResetKey((k) => k + 1);
268
+ }
269
+
270
+ function collapseToDefault(): void {
271
+ setExpanded(false);
272
+ setChildDepthOverride(0);
273
+ setChildResetKey((k) => k + 1);
274
+ }
275
+
276
+ function handleRowToggle(): void {
277
+ if (fullyExpanded) {
278
+ collapseToDefault();
260
279
  } else {
261
- setExpanded(true);
262
- setChildDepthOverride(Number.POSITIVE_INFINITY);
263
- setChildResetKey((k) => k + 1);
264
- setAllExpanded(true);
280
+ setExpanded(!expanded);
265
281
  }
266
282
  }
267
283
 
268
284
  function handleExpandAll(e: React.MouseEvent): void {
269
285
  e.stopPropagation();
270
- toggleDeepExpansion();
286
+ expandAllDeep();
271
287
  }
272
288
 
273
289
  const effectiveChildDepth = childDepthOverride ?? defaultExpandDepth;
@@ -279,29 +295,19 @@ const JsonNode = memo(function JsonNode({
279
295
  "flex items-start gap-1 py-0.5 px-1 -ml-1 rounded-sm group/row",
280
296
  expandable && "cursor-pointer hover:bg-muted/50",
281
297
  )}
282
- onClick={
283
- expandable
284
- ? () => {
285
- if (expanded) {
286
- setAllExpanded(false);
287
- }
288
- setExpanded(!expanded);
289
- }
290
- : undefined
291
- }
298
+ data-anatomy-path={hasAnatomyPath ? path : undefined}
299
+ onClick={expandable ? handleRowToggle : undefined}
292
300
  onKeyDown={
293
301
  expandable
294
302
  ? (e) => {
295
303
  if (e.key === "Enter" || e.key === " ") {
296
304
  e.preventDefault();
297
- setExpanded(!expanded);
305
+ handleRowToggle();
298
306
  }
299
307
  }
300
308
  : undefined
301
309
  }
302
- onDoubleClick={
303
- expandable && hasExpandableDescendant(value) ? () => toggleDeepExpansion() : undefined
304
- }
310
+ onDoubleClick={expandable && hasExpandableChild ? () => expandAllDeep() : undefined}
305
311
  role={expandable ? "button" : undefined}
306
312
  tabIndex={expandable ? 0 : undefined}
307
313
  >
@@ -328,7 +334,7 @@ const JsonNode = memo(function JsonNode({
328
334
  {openBracket}
329
335
  <span className="text-muted-foreground/60 text-xs">
330
336
  {" "}
331
- {getItemCount(value)} {getItemCount(value) === 1 ? "item" : "items"} {closeBracket}
337
+ {entries.length} {entries.length === 1 ? "item" : "items"} {closeBracket}
332
338
  </span>
333
339
  </span>
334
340
  ) : (
@@ -337,15 +343,15 @@ const JsonNode = memo(function JsonNode({
337
343
  </span>
338
344
  )}
339
345
 
340
- {expandable && hasExpandableDescendant(value) && (
341
- <ExpandCollapseButton allExpanded={allExpanded} onClick={handleExpandAll} />
346
+ {expandable && hasExpandableChild && !fullyExpanded && (
347
+ <ExpandCollapseButton onClick={handleExpandAll} />
342
348
  )}
343
349
  <CopyButton value={value} />
344
350
  </div>
345
351
 
346
352
  {expandable && expanded && (
347
353
  <div className="pl-4" key={childResetKey}>
348
- {getEntries(value).map(([key, childValue]) => (
354
+ {entries.map(([key, childValue]) => (
349
355
  <JsonNode
350
356
  key={key}
351
357
  name={key}
@@ -353,6 +359,9 @@ const JsonNode = memo(function JsonNode({
353
359
  level={level + 1}
354
360
  defaultExpandDepth={effectiveChildDepth}
355
361
  isArrayItem={dataType === "array"}
362
+ path={path === "" ? `/${key}` : `${path}/${key}`}
363
+ expandTargetPath={expandTargetPath}
364
+ anatomyPaths={anatomyPaths}
356
365
  />
357
366
  ))}
358
367
  <div className="text-muted-foreground py-0.5 px-1">{closeBracket}</div>
@@ -367,15 +376,38 @@ export type JsonViewerProps = {
367
376
  defaultExpandDepth?: number;
368
377
  className?: string;
369
378
  showCopy?: boolean;
379
+ bulkDepth?: number;
380
+ bulkRevision?: number;
381
+ /**
382
+ * Set of JSON-pointer-style paths whose row should expose a
383
+ * `data-anatomy-path` attribute. Used by the Anatomy view to
384
+ * locate rows for click-to-jump. When `null` or `undefined`,
385
+ * no `data-anatomy-path` attribute is emitted (zero cost).
386
+ */
387
+ anatomyPaths?: Set<string> | null;
388
+ /**
389
+ * When set, every ancestor of this path in the tree auto-expands
390
+ * for one render so the target row becomes visible. The hook
391
+ * flips this back to `null` after the scroll lands.
392
+ */
393
+ expandToPath?: string | null;
370
394
  };
371
395
 
372
396
  export function JsonViewer({
373
397
  data,
374
- defaultExpandDepth = 2,
398
+ defaultExpandDepth = 0,
375
399
  className,
376
400
  showCopy = false,
401
+ bulkDepth: controlledBulkDepth,
402
+ bulkRevision: controlledBulkRevision,
403
+ anatomyPaths = null,
404
+ expandToPath = null,
377
405
  }: JsonViewerProps): JSX.Element {
378
406
  const expandable = isExpandable(data);
407
+ const entries = useMemo(() => getEntries(data), [data]);
408
+
409
+ const bulkDepth = controlledBulkDepth ?? defaultExpandDepth;
410
+ const bulkRevision = controlledBulkRevision ?? 0;
379
411
 
380
412
  if (!expandable) {
381
413
  return (
@@ -396,17 +428,26 @@ export function JsonViewer({
396
428
  return (
397
429
  <TooltipProvider>
398
430
  <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
- ))}
431
+ {showCopy && (
432
+ <div className="mb-2 flex items-center justify-end gap-2">
433
+ <CopyValueButton value={data} />
434
+ </div>
435
+ )}
436
+ <div key={bulkRevision}>
437
+ {entries.map(([key, childValue]) => (
438
+ <JsonNode
439
+ key={key}
440
+ name={key}
441
+ value={childValue}
442
+ level={0}
443
+ defaultExpandDepth={bulkDepth}
444
+ isArrayItem={isArray}
445
+ path={`/${key}`}
446
+ expandTargetPath={expandToPath}
447
+ anatomyPaths={anatomyPaths}
448
+ />
449
+ ))}
450
+ </div>
410
451
  </div>
411
452
  </TooltipProvider>
412
453
  );
@@ -418,54 +459,24 @@ export type JsonViewerFromStringProps = {
418
459
  className?: string;
419
460
  };
420
461
 
421
- export function JsonViewerFromString({
462
+ export const JsonViewerFromString = memo(function JsonViewerFromString({
422
463
  text,
423
- defaultExpandDepth = 2,
464
+ defaultExpandDepth = 0,
424
465
  className,
425
466
  }: JsonViewerFromStringProps): JSX.Element {
426
- try {
427
- const parsed: unknown = JSON.parse(text);
467
+ const parsed = useMemo(() => parseJsonText(text), [text]);
468
+
469
+ if (parsed.kind === "json") {
428
470
  return (
429
471
  <JsonViewer
430
- data={safeJsonValue(parsed)}
472
+ data={parsed.data}
431
473
  defaultExpandDepth={defaultExpandDepth}
432
474
  className={className}
433
475
  />
434
476
  );
435
- } catch {
436
- return (
437
- <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>
438
- {text}
439
- </pre>
440
- );
441
477
  }
442
- }
443
478
 
444
- export function safeJsonValue(value: unknown): JsonValue {
445
- if (value === null || value === undefined) return null;
446
- switch (typeof value) {
447
- case "string":
448
- return value;
449
- case "number":
450
- return value;
451
- case "boolean":
452
- return value;
453
- case "object": {
454
- if (Array.isArray(value)) {
455
- return value.map((item: unknown) => safeJsonValue(item));
456
- }
457
- const result: Record<string, JsonValue> = {};
458
- for (const key of Object.keys(value)) {
459
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
460
- result[key] = safeJsonValue(descriptor?.value);
461
- }
462
- return result;
463
- }
464
- case "bigint":
465
- case "symbol":
466
- case "function":
467
- case "undefined":
468
- return String(value);
469
- }
470
- return null;
471
- }
479
+ return (
480
+ <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>{text}</pre>
481
+ );
482
+ });
package/src/lib/utils.ts CHANGED
@@ -6,9 +6,8 @@ export function cn(...inputs: ClassValue[]): string {
6
6
  }
7
7
 
8
8
  export function formatTokens(count: number): string {
9
- if (count >= 1_048_576) return (count / 1_048_576).toFixed(1).replace(/\.0$/, "") + "M";
10
- if (count >= 1024) return (count / 1024).toFixed(1).replace(/\.0$/, "") + "K";
11
- return count.toString();
9
+ if (count >= 1_000_000) return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
10
+ return (count / 1000).toFixed(1).replace(/\.0$/, "") + "K";
12
11
  }
13
12
 
14
13
  export type StatusCategory = "success" | "client_error" | "server_error" | "pending";
@@ -11,20 +11,10 @@ export const Route = createFileRoute("/api/logs/stream")({
11
11
  const model = url.searchParams.get("model") ?? undefined;
12
12
 
13
13
  let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
14
-
15
- // Send heartbeat comment every 30s to keep connection alive
16
- const heartbeat = setInterval(() => {
17
- if (controllerRef) {
18
- try {
19
- controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
20
- } catch {
21
- // Stream closed
22
- }
23
- }
24
- }, 30000);
14
+ let cleanedUp = false;
25
15
 
26
16
  const unsubscribe = onLogUpdate((log: CapturedLog) => {
27
- if (!controllerRef) return;
17
+ if (controllerRef === null) return;
28
18
  // Filter by session/model if specified
29
19
  if (sessionId !== undefined && log.sessionId !== sessionId) return;
30
20
  if (model !== undefined && log.model !== model) return;
@@ -32,10 +22,32 @@ export const Route = createFileRoute("/api/logs/stream")({
32
22
  const data = `data: ${JSON.stringify({ type: "update", log })}\n\n`;
33
23
  controllerRef.enqueue(new TextEncoder().encode(data));
34
24
  } catch {
35
- // Stream closed
25
+ cleanup();
36
26
  }
37
27
  });
38
28
 
29
+ const cleanup = (): void => {
30
+ if (cleanedUp) return;
31
+ cleanedUp = true;
32
+ clearInterval(heartbeat);
33
+ unsubscribe();
34
+ controllerRef = null;
35
+ request.signal.removeEventListener("abort", cleanup);
36
+ };
37
+
38
+ // Send heartbeat comment every 30s to keep connection alive
39
+ const heartbeat = setInterval(() => {
40
+ if (controllerRef !== null) {
41
+ try {
42
+ controllerRef.enqueue(new TextEncoder().encode(": heartbeat\n\n"));
43
+ } catch {
44
+ cleanup();
45
+ }
46
+ }
47
+ }, 30000);
48
+
49
+ request.signal.addEventListener("abort", cleanup, { once: true });
50
+
39
51
  const stream = new ReadableStream<Uint8Array>({
40
52
  start(controller) {
41
53
  controllerRef = controller;
@@ -45,9 +57,7 @@ export const Route = createFileRoute("/api/logs/stream")({
45
57
  controller.enqueue(new TextEncoder().encode(initData));
46
58
  },
47
59
  cancel() {
48
- clearInterval(heartbeat);
49
- unsubscribe();
50
- controllerRef = null;
60
+ cleanup();
51
61
  },
52
62
  });
53
63