@tonyclaw/llm-inspector 1.16.1 → 1.16.2
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-BdXOO2hn.js +107 -0
- package/.output/public/assets/index-DRRCmu5p.css +1 -0
- package/.output/public/assets/{main-BA7dEkGs.js → main-pT0WLAFQ.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +2 -2
- package/.output/server/_ssr/{index-quoKBRYG.mjs → index-BVUWpOQi.mjs} +2747 -2474
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DxmdQgBG.mjs → router-DEx3DlVG.mjs} +8 -6
- package/.output/server/{_tanstack-start-manifest_v-CXomfWbm.mjs → _tanstack-start-manifest_v-nEbvsqrj.mjs} +1 -1
- package/.output/server/index.mjs +24 -24
- package/package.json +1 -1
- package/src/components/OnboardingBanner.tsx +67 -0
- package/src/components/ProxyViewer.tsx +24 -34
- package/src/components/ProxyViewerContainer.tsx +2 -0
- package/src/components/providers/SettingsDialog.tsx +15 -25
- package/src/components/proxy-viewer/LogEntry.tsx +4 -34
- package/src/components/proxy-viewer/LogEntryHeader.tsx +12 -29
- package/src/components/proxy-viewer/ThreadConnector.tsx +70 -66
- package/src/components/proxy-viewer/TurnGroup.tsx +237 -36
- package/src/components/ui/json-viewer.tsx +9 -2
- package/src/lib/runtimeConfig.ts +1 -0
- package/src/lib/useOnboarding.ts +74 -0
- package/src/proxy/config.ts +2 -2
- package/src/routes/api/config.ts +1 -0
- package/.output/public/assets/index-BhFaDZUL.js +0 -107
- package/.output/public/assets/index-DPe3eOih.css +0 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type JSX, useMemo } from "react";
|
|
2
|
-
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
2
|
import { cn } from "../../lib/utils";
|
|
4
3
|
import type { StopReason } from "../../lib/stopReason";
|
|
5
4
|
import { getCrabVariant } from "../ui/crab-variants";
|
|
@@ -12,20 +11,17 @@ export type ThreadConnectorProps = {
|
|
|
12
11
|
isTurnStart: boolean;
|
|
13
12
|
/** Seed for crab variant selection (0-11). */
|
|
14
13
|
crabIndex?: number;
|
|
15
|
-
/** When true
|
|
14
|
+
/** When true the crab is clickable (collapse / expand a turn). */
|
|
16
15
|
collapsible?: boolean;
|
|
17
|
-
collapsed?: boolean;
|
|
18
16
|
onToggle?: () => void;
|
|
19
17
|
};
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
* Vertical timeline connector
|
|
23
|
-
*
|
|
24
|
-
* LogEntry
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* during processing it "crawls" downward with a bounce animation, and
|
|
28
|
-
* at turn boundaries it stops with a glow.
|
|
20
|
+
* Vertical timeline connector. Top spacer uses a calc() that
|
|
21
|
+
* mixes rem (py-1 + half-line) and px (border) so the crab
|
|
22
|
+
* centre-line matches the LogEntry `#id` index regardless of the
|
|
23
|
+
* root font-size. The bottom spacer is flex-1 so the outgoing
|
|
24
|
+
* line adapts to whatever height the LogEntry occupies.
|
|
29
25
|
*/
|
|
30
26
|
export function ThreadConnector({
|
|
31
27
|
stopReason,
|
|
@@ -34,74 +30,82 @@ export function ThreadConnector({
|
|
|
34
30
|
isTurnStart,
|
|
35
31
|
crabIndex = 0,
|
|
36
32
|
collapsible = false,
|
|
37
|
-
collapsed = false,
|
|
38
33
|
onToggle,
|
|
39
34
|
}: ThreadConnectorProps): JSX.Element {
|
|
40
35
|
const isBoundary = stopReason === "end_turn" || stopReason === "stop";
|
|
41
36
|
const isRunning = isPending && !isBoundary;
|
|
42
37
|
const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
|
|
43
38
|
|
|
39
|
+
const interactiveProps =
|
|
40
|
+
collapsible && onToggle
|
|
41
|
+
? ({
|
|
42
|
+
role: "button" as const,
|
|
43
|
+
tabIndex: 0,
|
|
44
|
+
className: "cursor-pointer",
|
|
45
|
+
onClick: (e: React.MouseEvent) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
onToggle();
|
|
48
|
+
},
|
|
49
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
50
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
onToggle();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
} as const)
|
|
56
|
+
: ({} as const);
|
|
57
|
+
|
|
44
58
|
return (
|
|
45
|
-
<div className="flex flex-col items-center w-6 shrink-0">
|
|
46
|
-
{/* Top
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
<div className="flex flex-col items-center w-6 shrink-0 pt-0.5 pb-0.5">
|
|
60
|
+
{/* Top spacer — crab centre must land on the LogEntry index midline.
|
|
61
|
+
* Index midline from container top = border(1px) + py-1(0.25rem) + ½line(0.5rem)
|
|
62
|
+
* = 1px + 0.75rem. Crab centre = pt(2px) + spacer + 7px(half crab).
|
|
63
|
+
* ∴ spacer = 0.75rem - 8px. (at 16px root: 12-8=4px; 2+4+7=13px). */}
|
|
64
|
+
<div className="flex justify-center h-[calc(0.75rem-8px)]">
|
|
65
|
+
{!isFirst && <div className="w-0.5 bg-muted-foreground/30 h-full" />}
|
|
49
66
|
</div>
|
|
50
67
|
|
|
51
|
-
{/*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
title={
|
|
57
|
-
|
|
68
|
+
{/* Crab — pinned to the fixed offset above so it never drifts
|
|
69
|
+
* when the LogEntry expands / collapses. Clickable when the
|
|
70
|
+
* turn is collapsible (replaces the old chevron toggle). */}
|
|
71
|
+
{isBoundary ? (
|
|
72
|
+
<span
|
|
73
|
+
title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
|
|
74
|
+
{...interactiveProps}
|
|
58
75
|
>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<Crab
|
|
84
|
-
className={cn(
|
|
85
|
-
"size-3.5 text-emerald-400",
|
|
86
|
-
"animate-crab-appear",
|
|
87
|
-
"drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
88
|
-
)}
|
|
89
|
-
/>
|
|
90
|
-
</span>
|
|
91
|
-
) : isRunning ? (
|
|
92
|
-
<span title="Processing…">
|
|
93
|
-
<Crab className={cn("size-3.5 text-amber-300/80", "animate-crab-crawl")} />
|
|
94
|
-
</span>
|
|
95
|
-
) : (
|
|
76
|
+
<Crab
|
|
77
|
+
className={cn(
|
|
78
|
+
"size-3.5 text-amber-400",
|
|
79
|
+
"animate-crab-settle",
|
|
80
|
+
"drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
</span>
|
|
84
|
+
) : isTurnStart ? (
|
|
85
|
+
<span title="Start of turn" {...interactiveProps}>
|
|
86
|
+
<Crab
|
|
87
|
+
className={cn(
|
|
88
|
+
"size-3.5 text-emerald-400",
|
|
89
|
+
"animate-crab-appear",
|
|
90
|
+
"drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
91
|
+
)}
|
|
92
|
+
/>
|
|
93
|
+
</span>
|
|
94
|
+
) : isRunning ? (
|
|
95
|
+
<span title="Processing…">
|
|
96
|
+
<Crab className={cn("size-3.5 text-amber-300/80", "animate-crab-crawl")} />
|
|
97
|
+
</span>
|
|
98
|
+
) : (
|
|
99
|
+
<span>
|
|
96
100
|
<Crab className="size-3.5 text-muted-foreground/40" />
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
99
103
|
|
|
100
|
-
{/* Bottom
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
{/* Bottom spacer — flex-1 fills whatever height the LogEntry
|
|
105
|
+
* consumes (header + expanded content). Turn boundaries have
|
|
106
|
+
* no outgoing line. */}
|
|
107
|
+
<div className="flex-1 flex justify-center min-h-0">
|
|
108
|
+
{!isBoundary && (
|
|
105
109
|
<div
|
|
106
110
|
className={cn(
|
|
107
111
|
"w-0.5 h-full",
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { type JSX, memo, useCallback, useMemo, useState } from "react";
|
|
1
|
+
import { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ChevronRight, Clock, Zap } from "lucide-react";
|
|
2
3
|
import { isTurnBoundary } from "../../lib/stopReason";
|
|
3
|
-
import { cn } from "../../lib/utils";
|
|
4
|
+
import { cn, formatTokens } from "../../lib/utils";
|
|
4
5
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
6
|
+
import { getCrabVariant } from "../ui/crab-variants";
|
|
7
|
+
import { ProviderLogo, detectProvider, type Provider } from "../providers/ProviderLogo";
|
|
5
8
|
import type { CacheTrendEntry } from "./cacheTrend";
|
|
6
9
|
import { LogEntry } from "./LogEntry";
|
|
7
10
|
import { ThreadConnector } from "./ThreadConnector";
|
|
8
11
|
import type { TurnEntry } from "./viewerState";
|
|
9
12
|
|
|
13
|
+
function formatElapsed(ms: number): string {
|
|
14
|
+
if (ms < 1000) return `${ms}ms`;
|
|
15
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
10
18
|
type TurnGroupProps = {
|
|
11
19
|
entries: TurnEntry[];
|
|
12
20
|
viewMode: "simple" | "full";
|
|
@@ -14,7 +22,6 @@ type TurnGroupProps = {
|
|
|
14
22
|
cacheTrends?: Map<number, CacheTrendEntry>;
|
|
15
23
|
onCompareWithPrevious: (log: CapturedLog) => void;
|
|
16
24
|
comparisonPredecessors: Map<number, CapturedLog>;
|
|
17
|
-
/** Turn index for alternating background colours. */
|
|
18
25
|
turnIndex?: number;
|
|
19
26
|
};
|
|
20
27
|
|
|
@@ -34,54 +41,248 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
34
41
|
const collapsible = isComplete && !isPending;
|
|
35
42
|
const [collapsed, setCollapsed] = useState(false);
|
|
36
43
|
|
|
44
|
+
// Auto-collapse when the turn finishes (transitions from incomplete → complete)
|
|
45
|
+
const prevCompleteRef = useRef(isComplete);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (isComplete && !prevCompleteRef.current) {
|
|
48
|
+
setCollapsed(true);
|
|
49
|
+
}
|
|
50
|
+
prevCompleteRef.current = isComplete;
|
|
51
|
+
}, [isComplete]);
|
|
52
|
+
|
|
37
53
|
const toggleCollapse = useCallback(() => {
|
|
38
54
|
if (collapsible) setCollapsed((prev) => !prev);
|
|
39
55
|
}, [collapsible]);
|
|
40
56
|
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
// Aggregate totals for collapsed summary
|
|
58
|
+
const aggregate = useMemo(() => {
|
|
59
|
+
let totalInput = 0;
|
|
60
|
+
let totalOutput = 0;
|
|
61
|
+
let totalElapsed = 0;
|
|
62
|
+
let hasTokens = false;
|
|
63
|
+
let hasElapsed = false;
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
if (e.log.inputTokens !== null) {
|
|
66
|
+
totalInput += e.log.inputTokens;
|
|
67
|
+
hasTokens = true;
|
|
68
|
+
}
|
|
69
|
+
if (e.log.outputTokens !== null) {
|
|
70
|
+
totalOutput += e.log.outputTokens;
|
|
71
|
+
hasTokens = true;
|
|
72
|
+
}
|
|
73
|
+
if (e.log.elapsedMs !== null) {
|
|
74
|
+
totalElapsed += e.log.elapsedMs;
|
|
75
|
+
hasElapsed = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
totalInput,
|
|
80
|
+
totalOutput,
|
|
81
|
+
hasTokens,
|
|
82
|
+
totalElapsed,
|
|
83
|
+
hasElapsed,
|
|
84
|
+
};
|
|
85
|
+
}, [entries, lastIdx]);
|
|
86
|
+
|
|
87
|
+
// Unique providers across all entries (for collapsed model logos)
|
|
88
|
+
const uniqueProviders = useMemo(() => {
|
|
89
|
+
const seen = new Set<Provider>();
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
const p = detectProvider(e.log.model);
|
|
92
|
+
if (p !== "unknown") seen.add(p);
|
|
93
|
+
}
|
|
94
|
+
return [...seen];
|
|
95
|
+
}, [entries]);
|
|
96
|
+
|
|
97
|
+
// Crab variant creators for the dual-crab collapsed layout
|
|
98
|
+
const StartCrab = useMemo(() => getCrabVariant(entries[0]?.log.id ?? 0), [entries]);
|
|
99
|
+
const EndCrab = useMemo(() => getCrabVariant(entries[lastIdx]?.log.id ?? 0), [entries, lastIdx]);
|
|
47
100
|
|
|
48
101
|
const bgClass = turnIndex % 2 === 0 ? "bg-muted/10" : "bg-muted/25";
|
|
49
102
|
|
|
103
|
+
// ResizeObserver → re-render connectors when any LogEntry height changes
|
|
104
|
+
const [layoutVersion, setLayoutVersion] = useState(0);
|
|
105
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const el = containerRef.current;
|
|
108
|
+
if (!el) return;
|
|
109
|
+
let raf = 0;
|
|
110
|
+
const ro = new ResizeObserver(() => {
|
|
111
|
+
window.cancelAnimationFrame(raf);
|
|
112
|
+
raf = window.requestAnimationFrame(() => setLayoutVersion((v) => v + 1));
|
|
113
|
+
});
|
|
114
|
+
ro.observe(el);
|
|
115
|
+
return () => {
|
|
116
|
+
ro.disconnect();
|
|
117
|
+
window.cancelAnimationFrame(raf);
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
50
121
|
return (
|
|
51
122
|
<div
|
|
123
|
+
ref={containerRef}
|
|
52
124
|
className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
|
|
53
125
|
>
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
viewMode={viewMode}
|
|
75
|
-
strip={strip}
|
|
76
|
-
cacheTrend={cacheTrends?.get(log.id) ?? null}
|
|
77
|
-
onCompareWithPrevious={
|
|
78
|
-
comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
|
|
126
|
+
{collapsed ? (
|
|
127
|
+
/* ---- Collapsed: dual-crab + summary ---- */
|
|
128
|
+
<div className="flex items-stretch">
|
|
129
|
+
{/* Dual-crab connector */}
|
|
130
|
+
<div className="w-6 shrink-0 flex flex-col items-center pt-0.5 pb-0.5">
|
|
131
|
+
{/* Start turn crab (clickable to expand) */}
|
|
132
|
+
<div className="flex justify-center h-[calc(0.75rem-8px)]" />
|
|
133
|
+
<span
|
|
134
|
+
role="button"
|
|
135
|
+
tabIndex={0}
|
|
136
|
+
title="Start of turn — click to expand"
|
|
137
|
+
className="cursor-pointer"
|
|
138
|
+
onClick={(e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
toggleCollapse();
|
|
141
|
+
}}
|
|
142
|
+
onKeyDown={(e) => {
|
|
143
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
toggleCollapse();
|
|
79
146
|
}
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<StartCrab
|
|
150
|
+
className={cn(
|
|
151
|
+
"size-3.5 text-emerald-400",
|
|
152
|
+
"animate-crab-appear",
|
|
153
|
+
"drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
154
|
+
)}
|
|
80
155
|
/>
|
|
156
|
+
</span>
|
|
157
|
+
|
|
158
|
+
{/* Connecting line between the two crabs */}
|
|
159
|
+
<div className="flex-1 flex justify-center min-h-0">
|
|
160
|
+
<div className="w-0.5 bg-muted-foreground/30 h-full" />
|
|
81
161
|
</div>
|
|
162
|
+
|
|
163
|
+
{/* End turn crab (clickable to expand) */}
|
|
164
|
+
<span
|
|
165
|
+
role="button"
|
|
166
|
+
tabIndex={0}
|
|
167
|
+
title="End of Turn — click to expand"
|
|
168
|
+
className="cursor-pointer"
|
|
169
|
+
onClick={(e) => {
|
|
170
|
+
e.stopPropagation();
|
|
171
|
+
toggleCollapse();
|
|
172
|
+
}}
|
|
173
|
+
onKeyDown={(e) => {
|
|
174
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
175
|
+
e.preventDefault();
|
|
176
|
+
toggleCollapse();
|
|
177
|
+
}
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<EndCrab
|
|
181
|
+
className={cn(
|
|
182
|
+
"size-3.5 text-amber-400",
|
|
183
|
+
"animate-crab-settle",
|
|
184
|
+
"drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Summary content — card-like appearance matching LogEntry */}
|
|
191
|
+
<div
|
|
192
|
+
className={cn(
|
|
193
|
+
"flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs cursor-pointer",
|
|
194
|
+
bgClass,
|
|
195
|
+
)}
|
|
196
|
+
onClick={toggleCollapse}
|
|
197
|
+
role="button"
|
|
198
|
+
tabIndex={0}
|
|
199
|
+
onKeyDown={(e) => {
|
|
200
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
toggleCollapse();
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{/* ID range */}
|
|
207
|
+
<span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
|
|
208
|
+
#{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
|
|
209
|
+
</span>
|
|
210
|
+
|
|
211
|
+
{/* Request count */}
|
|
212
|
+
<span className="text-muted-foreground shrink-0">
|
|
213
|
+
{entries.length} request{entries.length > 1 ? "s" : ""}
|
|
214
|
+
</span>
|
|
215
|
+
|
|
216
|
+
{/* Model logos — one per unique provider */}
|
|
217
|
+
{uniqueProviders.length > 0 && (
|
|
218
|
+
<span className="flex items-center gap-0.5 shrink-0">
|
|
219
|
+
{uniqueProviders.map((p) => (
|
|
220
|
+
<ProviderLogo key={p} provider={p} className="size-4" />
|
|
221
|
+
))}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{/* Elapsed */}
|
|
226
|
+
{aggregate.hasElapsed && (
|
|
227
|
+
<span className="hidden xl:flex items-center gap-1 text-muted-foreground shrink-0">
|
|
228
|
+
<Clock className="size-3" />
|
|
229
|
+
<span className="font-mono tabular-nums">
|
|
230
|
+
{formatElapsed(aggregate.totalElapsed)}
|
|
231
|
+
</span>
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Tokens */}
|
|
236
|
+
{aggregate.hasTokens && (
|
|
237
|
+
<span className="flex items-center gap-1 shrink-0">
|
|
238
|
+
<Zap className="size-3 text-muted-foreground" />
|
|
239
|
+
<span className="font-mono tabular-nums">
|
|
240
|
+
<span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
|
|
241
|
+
{" / "}
|
|
242
|
+
<span className="text-amber-400">OUT {formatTokens(aggregate.totalOutput)}</span>
|
|
243
|
+
</span>
|
|
244
|
+
</span>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{/* Spacer */}
|
|
248
|
+
<span className="flex-1 min-w-0" />
|
|
249
|
+
|
|
250
|
+
{/* Expand chevron */}
|
|
251
|
+
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
82
252
|
</div>
|
|
83
|
-
|
|
84
|
-
|
|
253
|
+
</div>
|
|
254
|
+
) : (
|
|
255
|
+
/* ---- Expanded: full entries ---- */
|
|
256
|
+
entries.map((entry, visibleIdx) => {
|
|
257
|
+
const { log, stopReason: reason } = entry;
|
|
258
|
+
const isTurnStart = visibleIdx === 0;
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div key={log.id} className="flex items-stretch">
|
|
262
|
+
<ThreadConnector
|
|
263
|
+
stopReason={reason}
|
|
264
|
+
isPending={log.responseStatus === null}
|
|
265
|
+
isFirst={visibleIdx === 0}
|
|
266
|
+
isTurnStart={isTurnStart}
|
|
267
|
+
crabIndex={log.id % 12}
|
|
268
|
+
collapsible={collapsible && entries.length > 1 && isTurnStart}
|
|
269
|
+
onToggle={toggleCollapse}
|
|
270
|
+
/>
|
|
271
|
+
<div className={cn("flex-1 min-w-0 mb-0.5 rounded-lg", bgClass)}>
|
|
272
|
+
<LogEntry
|
|
273
|
+
log={log}
|
|
274
|
+
viewMode={viewMode}
|
|
275
|
+
strip={strip}
|
|
276
|
+
cacheTrend={cacheTrends?.get(log.id) ?? null}
|
|
277
|
+
onCompareWithPrevious={
|
|
278
|
+
comparisonPredecessors.has(log.id) ? onCompareWithPrevious : undefined
|
|
279
|
+
}
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
);
|
|
284
|
+
})
|
|
285
|
+
)}
|
|
85
286
|
</div>
|
|
86
287
|
);
|
|
87
288
|
});
|
|
@@ -251,8 +251,7 @@ const JsonNode = memo(function JsonNode({
|
|
|
251
251
|
const openBracket = dataType === "array" ? "[" : "{";
|
|
252
252
|
const closeBracket = dataType === "array" ? "]" : "}";
|
|
253
253
|
|
|
254
|
-
function
|
|
255
|
-
e.stopPropagation();
|
|
254
|
+
function toggleDeepExpansion(): void {
|
|
256
255
|
if (allExpanded) {
|
|
257
256
|
setExpanded(false);
|
|
258
257
|
setChildDepthOverride(0);
|
|
@@ -266,6 +265,11 @@ const JsonNode = memo(function JsonNode({
|
|
|
266
265
|
}
|
|
267
266
|
}
|
|
268
267
|
|
|
268
|
+
function handleExpandAll(e: React.MouseEvent): void {
|
|
269
|
+
e.stopPropagation();
|
|
270
|
+
toggleDeepExpansion();
|
|
271
|
+
}
|
|
272
|
+
|
|
269
273
|
const effectiveChildDepth = childDepthOverride ?? defaultExpandDepth;
|
|
270
274
|
|
|
271
275
|
return (
|
|
@@ -295,6 +299,9 @@ const JsonNode = memo(function JsonNode({
|
|
|
295
299
|
}
|
|
296
300
|
: undefined
|
|
297
301
|
}
|
|
302
|
+
onDoubleClick={
|
|
303
|
+
expandable && hasExpandableDescendant(value) ? () => toggleDeepExpansion() : undefined
|
|
304
|
+
}
|
|
298
305
|
role={expandable ? "button" : undefined}
|
|
299
306
|
tabIndex={expandable ? 0 : undefined}
|
|
300
307
|
>
|
package/src/lib/runtimeConfig.ts
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import useSWR, { type SWRResponse, useSWRConfig } from "swr";
|
|
2
|
+
import { fetchJson } from "./apiClient";
|
|
3
|
+
import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
|
|
4
|
+
|
|
5
|
+
export const ONBOARDING_SWR_KEY = "/api/config";
|
|
6
|
+
|
|
7
|
+
async function fetcher(url: string): Promise<RuntimeConfig> {
|
|
8
|
+
return fetchJson(
|
|
9
|
+
url,
|
|
10
|
+
RuntimeConfigSchema,
|
|
11
|
+
undefined,
|
|
12
|
+
(response) => `Failed to fetch ${url}: ${response.status}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function patchRuntimeConfig(patch: Partial<RuntimeConfig>): Promise<RuntimeConfig> {
|
|
17
|
+
return fetchJson(
|
|
18
|
+
ONBOARDING_SWR_KEY,
|
|
19
|
+
RuntimeConfigSchema,
|
|
20
|
+
{
|
|
21
|
+
method: "PATCH",
|
|
22
|
+
headers: { "content-type": "application/json" },
|
|
23
|
+
body: JSON.stringify(patch),
|
|
24
|
+
},
|
|
25
|
+
(response) => `PATCH failed with status ${response.status}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UseOnboarding = {
|
|
30
|
+
hasSeenOnboarding: boolean;
|
|
31
|
+
isLoading: boolean;
|
|
32
|
+
markSeen: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hook for the first-launch onboarding banner. The server's
|
|
37
|
+
* `hasSeenOnboarding` flag is the source of truth — set to `true` on
|
|
38
|
+
* dismissal and persisted in the server config file so it survives
|
|
39
|
+
* across browser sessions.
|
|
40
|
+
*
|
|
41
|
+
* `hasSeenOnboarding` defaults to `false` until the SWR fetch resolves,
|
|
42
|
+
* which means the banner shows briefly on first load. That is
|
|
43
|
+
* acceptable: the fetch is local and resolves within milliseconds.
|
|
44
|
+
*/
|
|
45
|
+
export function useOnboarding(): UseOnboarding {
|
|
46
|
+
const response: SWRResponse<RuntimeConfig, Error> = useSWR<RuntimeConfig, Error>(
|
|
47
|
+
ONBOARDING_SWR_KEY,
|
|
48
|
+
fetcher,
|
|
49
|
+
{
|
|
50
|
+
revalidateOnFocus: false,
|
|
51
|
+
revalidateIfStale: false,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
const { mutate: globalMutate } = useSWRConfig();
|
|
55
|
+
|
|
56
|
+
const hasSeenOnboarding = response.data?.hasSeenOnboarding ?? false;
|
|
57
|
+
|
|
58
|
+
const markSeen = async (): Promise<void> => {
|
|
59
|
+
await globalMutate(ONBOARDING_SWR_KEY, patchRuntimeConfig({ hasSeenOnboarding: true }), {
|
|
60
|
+
optimisticData: {
|
|
61
|
+
stripClaudeCodeBillingHeader: response.data?.stripClaudeCodeBillingHeader ?? false,
|
|
62
|
+
hasSeenOnboarding: true,
|
|
63
|
+
},
|
|
64
|
+
rollbackOnError: true,
|
|
65
|
+
revalidate: false,
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
hasSeenOnboarding,
|
|
71
|
+
isLoading: response.isLoading,
|
|
72
|
+
markSeen,
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/proxy/config.ts
CHANGED
|
@@ -73,11 +73,11 @@ function resolveInitialConfig(): RuntimeConfig {
|
|
|
73
73
|
|
|
74
74
|
// 2. Env var
|
|
75
75
|
if (process.env["LLM_INSPECTOR_STRIP_CLAUDE_CODE_BILLING_HEADER"] === "1") {
|
|
76
|
-
return { stripClaudeCodeBillingHeader: true };
|
|
76
|
+
return { stripClaudeCodeBillingHeader: true, hasSeenOnboarding: false };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// 3. Default off
|
|
80
|
-
return { stripClaudeCodeBillingHeader: false };
|
|
80
|
+
return { stripClaudeCodeBillingHeader: false, hasSeenOnboarding: false };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
package/src/routes/api/config.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getConfig, setConfig, RuntimeConfigSchema } from "../../proxy/config";
|
|
|
6
6
|
const RuntimeConfigPatchSchema = z
|
|
7
7
|
.object({
|
|
8
8
|
stripClaudeCodeBillingHeader: z.boolean().optional(),
|
|
9
|
+
hasSeenOnboarding: z.boolean().optional(),
|
|
9
10
|
})
|
|
10
11
|
.strict()
|
|
11
12
|
.refine((v) => Object.keys(v).length > 0, {
|