@tonyclaw/llm-inspector 1.16.4 → 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 (56) 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-DbWwVQFh.js → main-CDMdNDY_.js} +1 -1
  11. package/.output/server/_libs/lucide-react.mjs +104 -84
  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-C-z-fZtq.mjs → index-CDjLoMsk.mjs} +1026 -2455
  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-CNM9Kbi0.mjs → router-BrdjOUEW.mjs} +24 -14
  21. package/.output/server/{_tanstack-start-manifest_v-BWfLeIsC.mjs → _tanstack-start-manifest_v-DmOZEcJ3.mjs} +1 -1
  22. package/.output/server/index.mjs +68 -26
  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/ConversationGroup.tsx +1 -1
  28. package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
  29. package/src/components/proxy-viewer/LogEntry.tsx +213 -181
  30. package/src/components/proxy-viewer/LogEntryHeader.tsx +134 -36
  31. package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
  32. package/src/components/proxy-viewer/TurnGroup.tsx +94 -71
  33. package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
  34. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
  35. package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
  36. package/src/components/proxy-viewer/anatomy/types.ts +39 -0
  37. package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
  38. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +3 -23
  39. package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
  40. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +5 -3
  41. package/src/components/proxy-viewer/lazy.ts +37 -0
  42. package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
  43. package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
  44. package/src/components/proxy-viewer/log-formats/types.ts +7 -0
  45. package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
  46. package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
  47. package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
  48. package/src/components/proxy-viewer/viewerState.ts +8 -0
  49. package/src/components/ui/crab-variants.tsx +11 -0
  50. package/src/components/ui/json-expansion-button.tsx +56 -0
  51. package/src/components/ui/json-viewer-bulk.ts +97 -0
  52. package/src/components/ui/json-viewer.tsx +58 -183
  53. package/src/lib/utils.ts +2 -3
  54. package/src/routes/api/logs.stream.ts +26 -16
  55. package/.output/public/assets/index-DRRCmu5p.css +0 -1
  56. package/.output/public/assets/index-X7CHS7fS.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,15 +1,11 @@
1
- import { Check, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Copy } from "lucide-react";
2
- import { type JSX, memo, useCallback, useMemo, useState, useTransition } 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
5
  import { Button } from "./button";
6
6
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
7
-
8
- type JsonPrimitive = string | number | boolean | null;
9
- export type JsonValue =
10
- | JsonPrimitive
11
- | ReadonlyArray<JsonValue>
12
- | Readonly<{ [key: string]: JsonValue }>;
7
+ import type { JsonValue } from "./json-viewer-bulk";
8
+ import { parseJsonText, safeJsonValue } from "./json-viewer-bulk";
13
9
 
14
10
  type DataType = "string" | "number" | "boolean" | "null" | "array" | "object";
15
11
 
@@ -47,133 +43,6 @@ function getEntries(value: JsonValue): ReadonlyArray<readonly [string, JsonValue
47
43
  return [];
48
44
  }
49
45
 
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
- );
175
- }
176
-
177
46
  const STRING_TRUNCATE_LIMIT = 120;
178
47
 
179
48
  function StringValue({ text }: { text: string }): JSX.Element {
@@ -320,6 +189,12 @@ type JsonNodeProps = {
320
189
  level: number;
321
190
  defaultExpandDepth: number;
322
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;
323
198
  };
324
199
 
325
200
  function ExpandCollapseButton({
@@ -345,8 +220,31 @@ const JsonNode = memo(function JsonNode({
345
220
  level,
346
221
  defaultExpandDepth,
347
222
  isArrayItem,
223
+ path,
224
+ expandTargetPath,
225
+ anatomyPaths,
348
226
  }: JsonNodeProps): JSX.Element {
349
- 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
+
350
248
  const [childResetKey, setChildResetKey] = useState(0);
351
249
  const [childDepthOverride, setChildDepthOverride] = useState<number | null>(null);
352
250
  const expandable = isExpandable(value);
@@ -361,6 +259,8 @@ const JsonNode = memo(function JsonNode({
361
259
  const openBracket = dataType === "array" ? "[" : "{";
362
260
  const closeBracket = dataType === "array" ? "]" : "}";
363
261
 
262
+ const hasAnatomyPath = anatomyPaths !== null && anatomyPaths.has(path);
263
+
364
264
  function expandAllDeep(): void {
365
265
  setExpanded(true);
366
266
  setChildDepthOverride(Number.POSITIVE_INFINITY);
@@ -395,6 +295,7 @@ const JsonNode = memo(function JsonNode({
395
295
  "flex items-start gap-1 py-0.5 px-1 -ml-1 rounded-sm group/row",
396
296
  expandable && "cursor-pointer hover:bg-muted/50",
397
297
  )}
298
+ data-anatomy-path={hasAnatomyPath ? path : undefined}
398
299
  onClick={expandable ? handleRowToggle : undefined}
399
300
  onKeyDown={
400
301
  expandable
@@ -458,6 +359,9 @@ const JsonNode = memo(function JsonNode({
458
359
  level={level + 1}
459
360
  defaultExpandDepth={effectiveChildDepth}
460
361
  isArrayItem={dataType === "array"}
362
+ path={path === "" ? `/${key}` : `${path}/${key}`}
363
+ expandTargetPath={expandTargetPath}
364
+ anatomyPaths={anatomyPaths}
461
365
  />
462
366
  ))}
463
367
  <div className="text-muted-foreground py-0.5 px-1">{closeBracket}</div>
@@ -474,6 +378,19 @@ export type JsonViewerProps = {
474
378
  showCopy?: boolean;
475
379
  bulkDepth?: number;
476
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;
477
394
  };
478
395
 
479
396
  export function JsonViewer({
@@ -483,6 +400,8 @@ export function JsonViewer({
483
400
  showCopy = false,
484
401
  bulkDepth: controlledBulkDepth,
485
402
  bulkRevision: controlledBulkRevision,
403
+ anatomyPaths = null,
404
+ expandToPath = null,
486
405
  }: JsonViewerProps): JSX.Element {
487
406
  const expandable = isExpandable(data);
488
407
  const entries = useMemo(() => getEntries(data), [data]);
@@ -523,6 +442,9 @@ export function JsonViewer({
523
442
  level={0}
524
443
  defaultExpandDepth={bulkDepth}
525
444
  isArrayItem={isArray}
445
+ path={`/${key}`}
446
+ expandTargetPath={expandToPath}
447
+ anatomyPaths={anatomyPaths}
526
448
  />
527
449
  ))}
528
450
  </div>
@@ -537,24 +459,6 @@ export type JsonViewerFromStringProps = {
537
459
  className?: string;
538
460
  };
539
461
 
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
462
  export const JsonViewerFromString = memo(function JsonViewerFromString({
559
463
  text,
560
464
  defaultExpandDepth = 0,
@@ -576,32 +480,3 @@ export const JsonViewerFromString = memo(function JsonViewerFromString({
576
480
  <pre className={cn("font-mono text-xs whitespace-pre-wrap break-words", className)}>{text}</pre>
577
481
  );
578
482
  });
579
-
580
- export function safeJsonValue(value: unknown): JsonValue {
581
- if (value === null || value === undefined) return null;
582
- switch (typeof value) {
583
- case "string":
584
- return value;
585
- case "number":
586
- return value;
587
- case "boolean":
588
- return value;
589
- case "object": {
590
- if (Array.isArray(value)) {
591
- return value.map((item: unknown) => safeJsonValue(item));
592
- }
593
- const result: Record<string, JsonValue> = {};
594
- for (const key of Object.keys(value)) {
595
- const descriptor = Object.getOwnPropertyDescriptor(value, key);
596
- result[key] = safeJsonValue(descriptor?.value);
597
- }
598
- return result;
599
- }
600
- case "bigint":
601
- case "symbol":
602
- case "function":
603
- case "undefined":
604
- return String(value);
605
- }
606
- return null;
607
- }
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