@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/CompareDrawer-C1w4KUGZ.js +1 -0
- package/.output/public/assets/ReplayDialog-DR2Sgq_g.js +1 -0
- package/.output/public/assets/RequestAnatomy-DAre35kj.js +1 -0
- package/.output/public/assets/ResponseView-ackes7_g.js +1 -0
- package/.output/public/assets/StreamingChunkSequence-GrXwIGKA.js +1 -0
- package/.output/public/assets/index-BGzHFOEX.css +1 -0
- package/.output/public/assets/index-DX88k9br.js +101 -0
- package/.output/public/assets/json-viewer-C_QUhGeu.js +14 -0
- package/.output/public/assets/{main-Cpts3Ifr.js → main-CDMdNDY_.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +96 -76
- package/.output/server/_ssr/CompareDrawer-ftkJxyk6.mjs +1040 -0
- package/.output/server/_ssr/ReplayDialog-DcmE3lj5.mjs +321 -0
- package/.output/server/_ssr/RequestAnatomy-rK_LNMdG.mjs +351 -0
- package/.output/server/_ssr/ResponseView-CbQ4n-aJ.mjs +601 -0
- package/.output/server/_ssr/StreamingChunkSequence-84FZkIzv.mjs +301 -0
- package/.output/server/_ssr/{index-CjvQZBI0.mjs → index-CDjLoMsk.mjs} +1036 -2352
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/json-viewer-B-qpM5xC.mjs +510 -0
- package/.output/server/_ssr/{router-CO9_4CVh.mjs → router-BrdjOUEW.mjs} +24 -14
- package/.output/server/{_tanstack-start-manifest_v-D-9SW7K3.mjs → _tanstack-start-manifest_v-DmOZEcJ3.mjs} +1 -1
- package/.output/server/index.mjs +72 -30
- package/package.json +1 -1
- package/src/components/OnboardingBanner.tsx +2 -2
- package/src/components/ProxyViewer.tsx +38 -26
- package/src/components/ProxyViewerContainer.tsx +3 -24
- package/src/components/proxy-viewer/CompareDrawer.tsx +6 -6
- package/src/components/proxy-viewer/ConversationGroup.tsx +1 -1
- package/src/components/proxy-viewer/ConversationHeader.tsx +4 -1
- package/src/components/proxy-viewer/LogEntry.tsx +230 -163
- package/src/components/proxy-viewer/LogEntryHeader.tsx +134 -36
- package/src/components/proxy-viewer/StreamingChunkSequence.tsx +1 -1
- package/src/components/proxy-viewer/ThreadConnector.tsx +17 -2
- package/src/components/proxy-viewer/TurnGroup.tsx +94 -71
- package/src/components/proxy-viewer/anatomy/RequestAnatomy.tsx +98 -0
- package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +196 -0
- package/src/components/proxy-viewer/anatomy/tokenEstimate.ts +53 -0
- package/src/components/proxy-viewer/anatomy/types.ts +39 -0
- package/src/components/proxy-viewer/anatomy/useAnatomyJump.ts +114 -0
- package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +4 -24
- package/src/components/proxy-viewer/formats/anthropic/thinkingExtract.ts +21 -0
- package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +6 -4
- package/src/components/proxy-viewer/lazy.ts +37 -0
- package/src/components/proxy-viewer/log-formats/anthropic.ts +146 -0
- package/src/components/proxy-viewer/log-formats/openai.ts +127 -0
- package/src/components/proxy-viewer/log-formats/types.ts +7 -0
- package/src/components/proxy-viewer/log-formats/unknown.ts +4 -0
- package/src/components/proxy-viewer/logEntryVisibility.ts +39 -0
- package/src/components/proxy-viewer/useKeyboardNavigation.ts +190 -0
- package/src/components/proxy-viewer/viewerState.ts +8 -0
- package/src/components/ui/crab-variants.tsx +11 -0
- package/src/components/ui/json-expansion-button.tsx +56 -0
- package/src/components/ui/json-viewer-bulk.ts +97 -0
- package/src/components/ui/json-viewer.tsx +129 -118
- package/src/lib/utils.ts +2 -3
- package/src/routes/api/logs.stream.ts +26 -16
- package/.output/public/assets/index-DRRCmu5p.css +0 -1
- package/.output/public/assets/index-DfjhkDNi.js +0 -107
|
@@ -1,38 +1,46 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AlertTriangle,
|
|
2
3
|
ArrowDown,
|
|
3
4
|
ArrowUp,
|
|
5
|
+
Check,
|
|
4
6
|
ChevronDown,
|
|
5
7
|
ChevronRight,
|
|
8
|
+
ChevronsDown,
|
|
9
|
+
ChevronsUp,
|
|
6
10
|
Clock,
|
|
11
|
+
Copy,
|
|
7
12
|
FileTerminal,
|
|
8
13
|
Globe,
|
|
9
14
|
Loader2,
|
|
10
15
|
MessageSquare,
|
|
16
|
+
OctagonAlert,
|
|
11
17
|
Radio,
|
|
18
|
+
RotateCcw,
|
|
12
19
|
Wrench,
|
|
13
20
|
Zap,
|
|
14
21
|
} from "lucide-react";
|
|
15
|
-
import type { JSX } from "react";
|
|
22
|
+
import type { JSX, MouseEvent } from "react";
|
|
16
23
|
import { memo, useMemo } from "react";
|
|
17
24
|
import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
|
|
18
25
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
19
26
|
import { Badge } from "../ui/badge";
|
|
27
|
+
import { Button } from "../ui/button";
|
|
20
28
|
import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
|
|
21
29
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
|
|
22
30
|
import type { CacheTrend } from "./cacheTrend";
|
|
23
31
|
|
|
24
|
-
function formatElapsed(ms: number): string {
|
|
25
|
-
if (ms < 1000) return `${ms}ms`;
|
|
26
|
-
return `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
32
|
const STATUS_BADGE_CLASSES: Record<StatusCategory, string> = {
|
|
30
33
|
success: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
|
|
31
34
|
client_error: "bg-amber-500/15 text-amber-400 border-amber-500/25",
|
|
32
|
-
server_error: "",
|
|
35
|
+
server_error: "bg-rose-500/15 text-rose-400 border-rose-500/25",
|
|
33
36
|
pending: "bg-muted text-muted-foreground border-border",
|
|
34
37
|
};
|
|
35
38
|
|
|
39
|
+
function formatElapsed(ms: number): string {
|
|
40
|
+
if (ms < 1000) return `${ms}ms`;
|
|
41
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
/**
|
|
37
45
|
* Inline trend indicator: small arrow (green up / red down) plus the absolute
|
|
38
46
|
* delta in compact form. Returns `null` when there is no trend to display.
|
|
@@ -68,6 +76,21 @@ export type LogEntryHeaderProps = {
|
|
|
68
76
|
* the corresponding cache span renders as it did before — no arrow.
|
|
69
77
|
*/
|
|
70
78
|
cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
|
|
79
|
+
/** Re-send this request to the provider. Rendered in the header row when
|
|
80
|
+
* `expanded` is true. */
|
|
81
|
+
onReplay?: () => void;
|
|
82
|
+
/** Copy the request body to the clipboard. Omit to hide the button. */
|
|
83
|
+
onCopyRequest?: (event: MouseEvent) => void;
|
|
84
|
+
/** Whether the latest copy of the request body succeeded (shows "Copied!"). */
|
|
85
|
+
requestCopied?: boolean;
|
|
86
|
+
/** Toggle the JSON bulk-expansion state for the request body. */
|
|
87
|
+
onToggleRequestExpansion?: () => void;
|
|
88
|
+
/** Current state of the JSON bulk-expansion button. `null` means the
|
|
89
|
+
* request body is not JSON, so the button is hidden. */
|
|
90
|
+
requestExpansionState?: {
|
|
91
|
+
isExpanded: boolean;
|
|
92
|
+
isPending: boolean;
|
|
93
|
+
} | null;
|
|
71
94
|
};
|
|
72
95
|
|
|
73
96
|
export const LogEntryHeader = memo(function ({
|
|
@@ -78,6 +101,11 @@ export const LogEntryHeader = memo(function ({
|
|
|
78
101
|
onToggle,
|
|
79
102
|
responseToolNames = null,
|
|
80
103
|
cacheTrend = null,
|
|
104
|
+
onReplay,
|
|
105
|
+
onCopyRequest,
|
|
106
|
+
requestCopied = false,
|
|
107
|
+
onToggleRequestExpansion,
|
|
108
|
+
requestExpansionState = null,
|
|
81
109
|
}: LogEntryHeaderProps): JSX.Element {
|
|
82
110
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
83
111
|
|
|
@@ -89,10 +117,13 @@ export const LogEntryHeader = memo(function ({
|
|
|
89
117
|
<div
|
|
90
118
|
role="button"
|
|
91
119
|
tabIndex={0}
|
|
120
|
+
data-nav-id={`log-${log.id}`}
|
|
121
|
+
data-nav-action={expanded ? "collapse" : "expand"}
|
|
92
122
|
className={cn(
|
|
93
123
|
"flex items-center gap-2 px-3 py-1 cursor-pointer transition-colors",
|
|
94
124
|
"hover:bg-muted/50",
|
|
95
125
|
"select-none",
|
|
126
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none",
|
|
96
127
|
)}
|
|
97
128
|
onClick={onToggle}
|
|
98
129
|
onKeyDown={(e) => {
|
|
@@ -119,38 +150,26 @@ export const LogEntryHeader = memo(function ({
|
|
|
119
150
|
</Tooltip>
|
|
120
151
|
)}
|
|
121
152
|
|
|
122
|
-
{/* Response Status — only shown for non-200 or pending
|
|
153
|
+
{/* Response Status — only shown for non-200 or pending. Each category
|
|
154
|
+
carries a distinct icon in addition to color so the meaning is
|
|
155
|
+
legible without color perception. */}
|
|
123
156
|
{statusCategory !== "success" && (
|
|
124
|
-
|
|
157
|
+
<Badge
|
|
158
|
+
variant="outline"
|
|
159
|
+
className={cn(
|
|
160
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums gap-1",
|
|
161
|
+
STATUS_BADGE_CLASSES[statusCategory],
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
125
164
|
{statusCategory === "server_error" ? (
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
>
|
|
130
|
-
{log.responseStatus}
|
|
131
|
-
</Badge>
|
|
165
|
+
<OctagonAlert className="size-3" aria-label="Server error" />
|
|
166
|
+
) : statusCategory === "client_error" ? (
|
|
167
|
+
<AlertTriangle className="size-3" aria-label="Client error" />
|
|
132
168
|
) : statusCategory === "pending" ? (
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
STATUS_BADGE_CLASSES[statusCategory],
|
|
138
|
-
)}
|
|
139
|
-
>
|
|
140
|
-
<Loader2 className="size-3 animate-spin" />
|
|
141
|
-
</Badge>
|
|
142
|
-
) : (
|
|
143
|
-
<Badge
|
|
144
|
-
variant="outline"
|
|
145
|
-
className={cn(
|
|
146
|
-
"text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums",
|
|
147
|
-
STATUS_BADGE_CLASSES[statusCategory],
|
|
148
|
-
)}
|
|
149
|
-
>
|
|
150
|
-
{log.responseStatus}
|
|
151
|
-
</Badge>
|
|
152
|
-
)}
|
|
153
|
-
</>
|
|
169
|
+
<Loader2 className="size-3 animate-spin" aria-label="Pending" />
|
|
170
|
+
) : null}
|
|
171
|
+
{log.responseStatus}
|
|
172
|
+
</Badge>
|
|
154
173
|
)}
|
|
155
174
|
|
|
156
175
|
{/* Elapsed time */}
|
|
@@ -279,6 +298,85 @@ export const LogEntryHeader = memo(function ({
|
|
|
279
298
|
{/* Spacer */}
|
|
280
299
|
<span className="flex-1 min-w-0" />
|
|
281
300
|
|
|
301
|
+
{/* Header actions — only when expanded, so the collapsed view stays
|
|
302
|
+
compact. Buttons stop propagation so they don't toggle the log. */}
|
|
303
|
+
{expanded && (
|
|
304
|
+
<span
|
|
305
|
+
className="flex items-center gap-1.5 shrink-0"
|
|
306
|
+
onClick={(e) => e.stopPropagation()}
|
|
307
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
308
|
+
>
|
|
309
|
+
{requestExpansionState !== null && onToggleRequestExpansion !== undefined && (
|
|
310
|
+
<Tooltip>
|
|
311
|
+
<TooltipTrigger asChild>
|
|
312
|
+
<Button
|
|
313
|
+
variant="outline"
|
|
314
|
+
size="icon"
|
|
315
|
+
className="size-8"
|
|
316
|
+
onClick={onToggleRequestExpansion}
|
|
317
|
+
disabled={requestExpansionState.isPending}
|
|
318
|
+
aria-pressed={requestExpansionState.isExpanded}
|
|
319
|
+
aria-label={
|
|
320
|
+
requestExpansionState.isExpanded ? "Collapse all JSON" : "Expand all JSON"
|
|
321
|
+
}
|
|
322
|
+
>
|
|
323
|
+
{requestExpansionState.isExpanded ? (
|
|
324
|
+
<ChevronsUp className="size-3.5" />
|
|
325
|
+
) : (
|
|
326
|
+
<ChevronsDown className="size-3.5" />
|
|
327
|
+
)}
|
|
328
|
+
</Button>
|
|
329
|
+
</TooltipTrigger>
|
|
330
|
+
<TooltipContent>
|
|
331
|
+
{requestExpansionState.isExpanded
|
|
332
|
+
? "Collapse all JSON nodes"
|
|
333
|
+
: "Expand all JSON nodes"}
|
|
334
|
+
</TooltipContent>
|
|
335
|
+
</Tooltip>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{onCopyRequest !== undefined && (
|
|
339
|
+
<Tooltip>
|
|
340
|
+
<TooltipTrigger asChild>
|
|
341
|
+
<Button
|
|
342
|
+
variant="outline"
|
|
343
|
+
size="icon"
|
|
344
|
+
className="size-8"
|
|
345
|
+
onClick={onCopyRequest}
|
|
346
|
+
aria-label="Copy request body"
|
|
347
|
+
>
|
|
348
|
+
{requestCopied ? (
|
|
349
|
+
<Check className="size-3.5 text-emerald-500" />
|
|
350
|
+
) : (
|
|
351
|
+
<Copy className="size-3.5" />
|
|
352
|
+
)}
|
|
353
|
+
</Button>
|
|
354
|
+
</TooltipTrigger>
|
|
355
|
+
<TooltipContent>
|
|
356
|
+
{requestCopied ? "Copied to clipboard" : "Copy request body"}
|
|
357
|
+
</TooltipContent>
|
|
358
|
+
</Tooltip>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{onReplay !== undefined && (
|
|
362
|
+
<Tooltip>
|
|
363
|
+
<TooltipTrigger asChild>
|
|
364
|
+
<Button
|
|
365
|
+
variant="outline"
|
|
366
|
+
size="icon"
|
|
367
|
+
className="size-8"
|
|
368
|
+
onClick={onReplay}
|
|
369
|
+
aria-label="Replay request"
|
|
370
|
+
>
|
|
371
|
+
<RotateCcw className="size-3.5" />
|
|
372
|
+
</Button>
|
|
373
|
+
</TooltipTrigger>
|
|
374
|
+
<TooltipContent>Re-send this request to the provider</TooltipContent>
|
|
375
|
+
</Tooltip>
|
|
376
|
+
)}
|
|
377
|
+
</span>
|
|
378
|
+
)}
|
|
379
|
+
|
|
282
380
|
{/* Expand chevron */}
|
|
283
381
|
{expanded ? (
|
|
284
382
|
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
|
@@ -140,7 +140,7 @@ export const StreamingChunkSequence = memo(function StreamingChunkSequence({
|
|
|
140
140
|
{chunk.type}
|
|
141
141
|
</span>
|
|
142
142
|
</div>
|
|
143
|
-
<JsonViewer data={chunk} defaultExpandDepth={
|
|
143
|
+
<JsonViewer data={chunk} defaultExpandDepth={0} showCopy />
|
|
144
144
|
</div>
|
|
145
145
|
))}
|
|
146
146
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type JSX, useMemo } from "react";
|
|
2
2
|
import { cn } from "../../lib/utils";
|
|
3
3
|
import type { StopReason } from "../../lib/stopReason";
|
|
4
|
-
import { getCrabVariant } from "../ui/crab-variants";
|
|
4
|
+
import { getCrabVariant, getInteriorCrabVariant } from "../ui/crab-variants";
|
|
5
5
|
|
|
6
6
|
export type ThreadConnectorProps = {
|
|
7
7
|
stopReason: StopReason;
|
|
@@ -9,6 +9,8 @@ export type ThreadConnectorProps = {
|
|
|
9
9
|
isFirst: boolean;
|
|
10
10
|
/** True when this entry starts a new turn (first overall, or after end_turn/stop). */
|
|
11
11
|
isTurnStart: boolean;
|
|
12
|
+
/** True when this entry is the turn's only request. */
|
|
13
|
+
isOnlyEntry?: boolean;
|
|
12
14
|
/** Seed for crab variant selection (0-11). */
|
|
13
15
|
crabIndex?: number;
|
|
14
16
|
/** When true the crab is clickable (collapse / expand a turn). */
|
|
@@ -28,13 +30,16 @@ export function ThreadConnector({
|
|
|
28
30
|
isPending,
|
|
29
31
|
isFirst,
|
|
30
32
|
isTurnStart,
|
|
33
|
+
isOnlyEntry = false,
|
|
31
34
|
crabIndex = 0,
|
|
32
35
|
collapsible = false,
|
|
33
36
|
onToggle,
|
|
34
37
|
}: ThreadConnectorProps): JSX.Element {
|
|
35
38
|
const isBoundary = stopReason === "end_turn" || stopReason === "stop";
|
|
39
|
+
const isFusedBoundary = isOnlyEntry && isTurnStart && isBoundary;
|
|
36
40
|
const isRunning = isPending && !isBoundary;
|
|
37
41
|
const Crab = useMemo(() => getCrabVariant(crabIndex), [crabIndex]);
|
|
42
|
+
const FusedCrab = useMemo(() => getInteriorCrabVariant(crabIndex), [crabIndex]);
|
|
38
43
|
|
|
39
44
|
const interactiveProps =
|
|
40
45
|
collapsible && onToggle
|
|
@@ -68,7 +73,17 @@ export function ThreadConnector({
|
|
|
68
73
|
{/* Crab — pinned to the fixed offset above so it never drifts
|
|
69
74
|
* when the LogEntry expands / collapses. Clickable when the
|
|
70
75
|
* turn is collapsible (replaces the old chevron toggle). */}
|
|
71
|
-
{
|
|
76
|
+
{isFusedBoundary ? (
|
|
77
|
+
<span title="Start and end of turn">
|
|
78
|
+
<FusedCrab
|
|
79
|
+
className={cn(
|
|
80
|
+
"size-3.5 text-[#80FF00]",
|
|
81
|
+
"animate-crab-settle",
|
|
82
|
+
"drop-shadow-[0_0_4px_rgba(128,255,0,0.5)]",
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
</span>
|
|
86
|
+
) : isBoundary ? (
|
|
72
87
|
<span
|
|
73
88
|
title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
|
|
74
89
|
{...interactiveProps}
|
|
@@ -8,7 +8,7 @@ import { ProviderLogo, detectProvider, type Provider } from "../providers/Provid
|
|
|
8
8
|
import type { CacheTrendEntry } from "./cacheTrend";
|
|
9
9
|
import { LogEntry } from "./LogEntry";
|
|
10
10
|
import { ThreadConnector } from "./ThreadConnector";
|
|
11
|
-
import type
|
|
11
|
+
import { isTurnCollapsible, type TurnEntry } from "./viewerState";
|
|
12
12
|
|
|
13
13
|
function formatElapsed(ms: number): string {
|
|
14
14
|
if (ms < 1000) return `${ms}ms`;
|
|
@@ -38,17 +38,20 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
38
38
|
const lastStop = entries[lastIdx]?.stopReason ?? null;
|
|
39
39
|
const isComplete = lastStop !== null ? isTurnBoundary(lastStop) : false;
|
|
40
40
|
const isPending = entries[lastIdx]?.log.responseStatus === null;
|
|
41
|
-
const
|
|
41
|
+
const isSingleLog = entries.length === 1;
|
|
42
|
+
const collapsible = isTurnCollapsible(entries.length, isComplete, isPending);
|
|
42
43
|
const [collapsed, setCollapsed] = useState(false);
|
|
43
44
|
|
|
44
45
|
// Auto-collapse when the turn finishes (transitions from incomplete → complete)
|
|
45
|
-
const prevCompleteRef = useRef(
|
|
46
|
+
const prevCompleteRef = useRef(false);
|
|
46
47
|
useEffect(() => {
|
|
47
|
-
if (
|
|
48
|
+
if (!collapsible) {
|
|
49
|
+
setCollapsed(false);
|
|
50
|
+
} else if (isComplete && !prevCompleteRef.current) {
|
|
48
51
|
setCollapsed(true);
|
|
49
52
|
}
|
|
50
53
|
prevCompleteRef.current = isComplete;
|
|
51
|
-
}, [isComplete]);
|
|
54
|
+
}, [collapsible, isComplete]);
|
|
52
55
|
|
|
53
56
|
const toggleCollapse = useCallback(() => {
|
|
54
57
|
if (collapsible) setCollapsed((prev) => !prev);
|
|
@@ -124,11 +127,30 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
124
127
|
className={cn("border rounded-lg", isPending ? "border-amber-500/10" : "border-transparent")}
|
|
125
128
|
>
|
|
126
129
|
{collapsed ? (
|
|
127
|
-
/* ---- Collapsed: dual-crab + summary ---- */
|
|
128
|
-
<div
|
|
129
|
-
{
|
|
130
|
+
/* ---- Collapsed: dual-crab (+ summary card for multi-log turns) ---- */
|
|
131
|
+
<div
|
|
132
|
+
data-nav-id={`turn-collapsed-${entries[0]?.log.id ?? turnIndex}`}
|
|
133
|
+
data-nav-action="expand"
|
|
134
|
+
role="button"
|
|
135
|
+
tabIndex={0}
|
|
136
|
+
className="flex items-stretch cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none rounded-lg"
|
|
137
|
+
onClick={toggleCollapse}
|
|
138
|
+
onKeyDown={(e) => {
|
|
139
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
toggleCollapse();
|
|
142
|
+
}
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
{/* Turn number */}
|
|
146
|
+
<div className="w-5 shrink-0 flex items-start pt-1.5">
|
|
147
|
+
<span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
|
|
148
|
+
{turnIndex + 1}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{/* Dual-crab connector for collapsed multi-request turns. */}
|
|
130
153
|
<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
154
|
<div className="flex justify-center h-[calc(0.75rem-8px)]" />
|
|
133
155
|
<span
|
|
134
156
|
role="button"
|
|
@@ -149,18 +171,15 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
149
171
|
<StartCrab
|
|
150
172
|
className={cn(
|
|
151
173
|
"size-3.5 text-emerald-400",
|
|
152
|
-
"animate-crab-appear",
|
|
153
|
-
"drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
174
|
+
"animate-crab-appear drop-shadow-[0_0_4px_rgba(52,211,153,0.5)]",
|
|
154
175
|
)}
|
|
155
176
|
/>
|
|
156
177
|
</span>
|
|
157
178
|
|
|
158
|
-
{/* Connecting line between the two crabs */}
|
|
159
179
|
<div className="flex-1 flex justify-center min-h-0">
|
|
160
180
|
<div className="w-0.5 bg-muted-foreground/30 h-full" />
|
|
161
181
|
</div>
|
|
162
182
|
|
|
163
|
-
{/* End turn crab (clickable to expand) */}
|
|
164
183
|
<span
|
|
165
184
|
role="button"
|
|
166
185
|
tabIndex={0}
|
|
@@ -180,76 +199,70 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
180
199
|
<EndCrab
|
|
181
200
|
className={cn(
|
|
182
201
|
"size-3.5 text-amber-400",
|
|
183
|
-
"animate-crab-settle",
|
|
184
|
-
"drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
202
|
+
"animate-crab-settle drop-shadow-[0_0_4px_rgba(251,191,36,0.5)]",
|
|
185
203
|
)}
|
|
186
204
|
/>
|
|
187
205
|
</span>
|
|
188
206
|
</div>
|
|
189
207
|
|
|
190
|
-
{/* Summary content —
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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>
|
|
208
|
+
{/* Summary content — hidden for single-log turns. */}
|
|
209
|
+
{entries.length > 1 && (
|
|
210
|
+
<div
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex-1 min-w-0 mb-0.5 rounded-lg border border-border py-1 px-3 flex items-center gap-3 text-xs",
|
|
213
|
+
bgClass,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{/* ID range */}
|
|
217
|
+
<span className="text-blue-400/80 font-mono font-semibold tabular-nums shrink-0">
|
|
218
|
+
#{entries[0]?.log.id ?? "?"} ~ #{entries[lastIdx]?.log.id ?? "?"}
|
|
219
|
+
</span>
|
|
215
220
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{uniqueProviders.map((p) => (
|
|
220
|
-
<ProviderLogo key={p} provider={p} className="size-4" />
|
|
221
|
-
))}
|
|
221
|
+
{/* Request count */}
|
|
222
|
+
<span className="text-muted-foreground shrink-0">
|
|
223
|
+
{entries.length} request{entries.length > 1 ? "s" : ""}
|
|
222
224
|
</span>
|
|
223
|
-
)}
|
|
224
225
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
226
|
+
{/* Model logos — one per unique provider */}
|
|
227
|
+
{uniqueProviders.length > 0 && (
|
|
228
|
+
<span className="flex items-center gap-0.5 shrink-0">
|
|
229
|
+
{uniqueProviders.map((p) => (
|
|
230
|
+
<ProviderLogo key={p} provider={p} className="size-4" />
|
|
231
|
+
))}
|
|
231
232
|
</span>
|
|
232
|
-
|
|
233
|
-
)}
|
|
233
|
+
)}
|
|
234
234
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
<span className="text-amber-400">OUT {formatTokens(aggregate.totalOutput)}</span>
|
|
235
|
+
{/* Elapsed */}
|
|
236
|
+
{aggregate.hasElapsed && (
|
|
237
|
+
<span className="flex items-center gap-1 text-muted-foreground shrink-0">
|
|
238
|
+
<Clock className="size-3" />
|
|
239
|
+
<span className="font-mono tabular-nums">
|
|
240
|
+
{formatElapsed(aggregate.totalElapsed)}
|
|
241
|
+
</span>
|
|
243
242
|
</span>
|
|
244
|
-
|
|
245
|
-
)}
|
|
243
|
+
)}
|
|
246
244
|
|
|
247
|
-
|
|
248
|
-
|
|
245
|
+
{/* Tokens */}
|
|
246
|
+
{aggregate.hasTokens && (
|
|
247
|
+
<span className="flex items-center gap-1 shrink-0">
|
|
248
|
+
<Zap className="size-3 text-muted-foreground" />
|
|
249
|
+
<span className="font-mono tabular-nums">
|
|
250
|
+
<span className="text-blue-400">IN {formatTokens(aggregate.totalInput)}</span>
|
|
251
|
+
{" / "}
|
|
252
|
+
<span className="text-amber-400">
|
|
253
|
+
OUT {formatTokens(aggregate.totalOutput)}
|
|
254
|
+
</span>
|
|
255
|
+
</span>
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
249
258
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
259
|
+
{/* Spacer */}
|
|
260
|
+
<span className="flex-1 min-w-0" />
|
|
261
|
+
|
|
262
|
+
{/* Expand chevron */}
|
|
263
|
+
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
253
266
|
</div>
|
|
254
267
|
) : (
|
|
255
268
|
/* ---- Expanded: full entries ---- */
|
|
@@ -259,13 +272,23 @@ export const TurnGroup = memo(function TurnGroup({
|
|
|
259
272
|
|
|
260
273
|
return (
|
|
261
274
|
<div key={log.id} className="flex items-stretch">
|
|
275
|
+
{isTurnStart ? (
|
|
276
|
+
<div className="w-5 shrink-0 flex items-start pt-1.5">
|
|
277
|
+
<span className="text-[10px] text-muted-foreground/50 font-mono tabular-nums leading-none select-none">
|
|
278
|
+
{turnIndex + 1}
|
|
279
|
+
</span>
|
|
280
|
+
</div>
|
|
281
|
+
) : (
|
|
282
|
+
<div className="w-5 shrink-0" />
|
|
283
|
+
)}
|
|
262
284
|
<ThreadConnector
|
|
263
285
|
stopReason={reason}
|
|
264
286
|
isPending={log.responseStatus === null}
|
|
265
287
|
isFirst={visibleIdx === 0}
|
|
266
288
|
isTurnStart={isTurnStart}
|
|
289
|
+
isOnlyEntry={isSingleLog}
|
|
267
290
|
crabIndex={log.id % 12}
|
|
268
|
-
collapsible={collapsible &&
|
|
291
|
+
collapsible={collapsible && isTurnStart}
|
|
269
292
|
onToggle={toggleCollapse}
|
|
270
293
|
/>
|
|
271
294
|
<div className={cn("flex-1 min-w-0 mb-0.5 rounded-lg", bgClass)}>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Info } from "lucide-react";
|
|
2
|
+
import { type JSX, useMemo } from "react";
|
|
3
|
+
import { cn, formatTokens } from "../../../lib/utils";
|
|
4
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip";
|
|
5
|
+
import { SegmentBar } from "./SegmentBar";
|
|
6
|
+
import type { AnatomySegment } from "./types";
|
|
7
|
+
|
|
8
|
+
export type RequestAnatomyProps = {
|
|
9
|
+
/** Parsed request body, or `null` if it cannot be parsed. */
|
|
10
|
+
parsed: unknown | null;
|
|
11
|
+
/** Server-reported input token count, or `null` if unknown. */
|
|
12
|
+
inputTokens: number | null;
|
|
13
|
+
/** Optional callback fired when the user activates a segment. */
|
|
14
|
+
onSegmentActivate?: (segment: AnatomySegment) => void;
|
|
15
|
+
/** Pre-computed segments; if provided, `parsed` is ignored. */
|
|
16
|
+
segments?: AnatomySegment[] | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DIVERGENCE_AMBER_THRESHOLD = 0.25;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render the request anatomy view: a one-line summary plus the
|
|
23
|
+
* stacked segment bar. Returns `null` when the request cannot be
|
|
24
|
+
* parsed or the segment list is empty.
|
|
25
|
+
*/
|
|
26
|
+
export function RequestAnatomy({
|
|
27
|
+
parsed,
|
|
28
|
+
inputTokens,
|
|
29
|
+
onSegmentActivate,
|
|
30
|
+
segments: providedSegments,
|
|
31
|
+
}: RequestAnatomyProps): JSX.Element | null {
|
|
32
|
+
// Caller is expected to pre-compute segments in production (see
|
|
33
|
+
// LogEntry). When called standalone, fall back to deriving from
|
|
34
|
+
// `parsed` directly using the unknown adapter is not appropriate —
|
|
35
|
+
// the caller is responsible for choosing the right adapter.
|
|
36
|
+
const segments = useMemo(() => providedSegments ?? null, [providedSegments]);
|
|
37
|
+
const total = useMemo(() => (segments ?? []).reduce((sum, s) => sum + s.size, 0), [segments]);
|
|
38
|
+
|
|
39
|
+
// Show divergence warning when both estimate and server numbers exist.
|
|
40
|
+
const showDivergenceWarning = useMemo(() => {
|
|
41
|
+
if (segments === null || segments === undefined) return false;
|
|
42
|
+
if (inputTokens === null) return false;
|
|
43
|
+
if (total === 0) return false;
|
|
44
|
+
const ratio = Math.abs(inputTokens - total) / Math.max(inputTokens, total);
|
|
45
|
+
return ratio >= DIVERGENCE_AMBER_THRESHOLD;
|
|
46
|
+
}, [inputTokens, segments, total]);
|
|
47
|
+
|
|
48
|
+
const summaryColorClass = useMemo(() => {
|
|
49
|
+
if (inputTokens === null) return "text-muted-foreground";
|
|
50
|
+
if (showDivergenceWarning) return "text-amber-400";
|
|
51
|
+
return "text-muted-foreground";
|
|
52
|
+
}, [inputTokens, showDivergenceWarning]);
|
|
53
|
+
|
|
54
|
+
if (segments === null || segments === undefined) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (segments.length === 0) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (parsed === null && providedSegments === undefined) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<TooltipProvider delayDuration={150}>
|
|
66
|
+
<div className="px-4 py-3 space-y-2" data-testid="anatomy-root">
|
|
67
|
+
<div className="flex items-center gap-2 text-xs">
|
|
68
|
+
<span className="font-mono tabular-nums text-foreground">
|
|
69
|
+
~{formatTokens(total)} tokens
|
|
70
|
+
</span>
|
|
71
|
+
{inputTokens !== null && (
|
|
72
|
+
<span className={cn("font-mono tabular-nums", summaryColorClass)}>
|
|
73
|
+
(server: {formatTokens(inputTokens)})
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{showDivergenceWarning && (
|
|
77
|
+
<Tooltip>
|
|
78
|
+
<TooltipTrigger asChild>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className="inline-flex items-center text-amber-400 hover:text-amber-300"
|
|
82
|
+
aria-label="Token estimate diverges from server"
|
|
83
|
+
>
|
|
84
|
+
<Info className="size-3.5" />
|
|
85
|
+
</button>
|
|
86
|
+
</TooltipTrigger>
|
|
87
|
+
<TooltipContent className="max-w-xs text-xs">
|
|
88
|
+
Bar uses a token estimate heuristic (~4 ASCII chars / token, ~1 CJK / emoji char per
|
|
89
|
+
token). The server's reported value is the source of truth for cost.
|
|
90
|
+
</TooltipContent>
|
|
91
|
+
</Tooltip>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
<SegmentBar segments={segments} totalTokens={total} onActivate={onSegmentActivate} />
|
|
95
|
+
</div>
|
|
96
|
+
</TooltipProvider>
|
|
97
|
+
);
|
|
98
|
+
}
|