@tonyclaw/llm-inspector 1.14.7 → 1.14.9
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-Dv-dj1xH.js +105 -0
- package/.output/public/assets/index-bqeypwJB.css +1 -0
- package/.output/public/assets/{main-BV7uNIIz.js → main-C8OUJKbz.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +87 -79
- package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
- package/.output/server/_ssr/{index-BvHLASu8.mjs → index-_9xcAkkw.mjs} +861 -608
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-lUOA8pi6.mjs → router-CmanwZJc.mjs} +45 -14
- package/.output/server/{_tanstack-start-manifest_v-XNH7fVPN.mjs → _tanstack-start-manifest_v-BVIiyDeJ.mjs} +1 -1
- package/.output/server/index.mjs +23 -23
- package/package.json +1 -1
- package/src/components/ProxyViewer.tsx +137 -146
- package/src/components/providers/ProviderCard.tsx +79 -26
- package/src/components/providers/ProviderForm.tsx +37 -22
- package/src/components/providers/ProvidersPanel.tsx +79 -47
- package/src/components/providers/SettingsDialog.tsx +25 -15
- package/src/components/proxy-viewer/ConversationGroup.tsx +74 -11
- package/src/components/proxy-viewer/ConversationHeader.tsx +63 -2
- package/src/components/proxy-viewer/LogEntry.tsx +184 -54
- package/src/components/proxy-viewer/LogEntryHeader.tsx +148 -143
- package/src/components/proxy-viewer/ReplayDialog.tsx +16 -6
- package/src/components/proxy-viewer/StreamingChunkSequence.tsx +24 -16
- package/src/components/proxy-viewer/ThreadConnector.tsx +93 -0
- package/src/components/proxy-viewer/index.ts +2 -1
- package/src/lib/stopReason.ts +57 -0
- package/src/proxy/formats/anthropic/handler.ts +2 -5
- package/src/proxy/formats/openai/handler.ts +33 -7
- package/src/proxy/formats/openai/schemas.ts +1 -0
- package/src/proxy/formats/openai/stream.ts +24 -0
- package/src/proxy/handler.ts +8 -2
- package/src/proxy/schemas.ts +6 -3
- package/.output/public/assets/index-Cmi8TfeU.js +0 -105
- package/.output/public/assets/index-DXUNTCVh.css +0 -1
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowDown,
|
|
3
3
|
ArrowUp,
|
|
4
|
-
Check,
|
|
5
4
|
ChevronDown,
|
|
6
5
|
ChevronRight,
|
|
7
6
|
Clock,
|
|
@@ -10,16 +9,16 @@ import {
|
|
|
10
9
|
Loader2,
|
|
11
10
|
MessageSquare,
|
|
12
11
|
Radio,
|
|
13
|
-
User,
|
|
14
12
|
Wrench,
|
|
15
13
|
Zap,
|
|
16
14
|
} from "lucide-react";
|
|
17
15
|
import type { JSX } from "react";
|
|
18
16
|
import { memo } from "react";
|
|
19
17
|
import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
|
|
20
|
-
import type { CapturedLog
|
|
18
|
+
import type { CapturedLog } from "../../proxy/schemas";
|
|
21
19
|
import { Badge } from "../ui/badge";
|
|
22
20
|
import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
|
|
21
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
|
|
23
22
|
import type { CacheTrend } from "./cacheTrend";
|
|
24
23
|
|
|
25
24
|
function formatElapsed(ms: number): string {
|
|
@@ -56,44 +55,34 @@ function CacheTrendIndicator({ trend }: { trend: CacheTrend | null }): JSX.Eleme
|
|
|
56
55
|
|
|
57
56
|
export type LogEntryHeaderProps = {
|
|
58
57
|
log: CapturedLog;
|
|
59
|
-
|
|
58
|
+
/** Number of messages in the request (supports both Anthropic and OpenAI formats). */
|
|
59
|
+
messageCount?: number | null;
|
|
60
|
+
/** Number of tools defined in the request (supports both Anthropic and OpenAI formats). */
|
|
61
|
+
toolCount?: number | null;
|
|
60
62
|
expanded: boolean;
|
|
61
63
|
onToggle: () => void;
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Per-log cache token trend (creation + read) relative to the previous log
|
|
64
|
+
/** Tool call names extracted from the model response (e.g., ["read_file", "grep"]). */
|
|
65
|
+
responseToolNames?: string[] | null;
|
|
66
|
+
/** Per-log cache token trend (creation + read) relative to the previous log
|
|
66
67
|
* in the same conversation group. When `undefined` or a field is `null`,
|
|
67
68
|
* the corresponding cache span renders as it did before — no arrow.
|
|
68
69
|
*/
|
|
69
70
|
cacheTrend?: { creation: CacheTrend | null; read: CacheTrend | null } | null;
|
|
70
|
-
/** Whether this log is currently marked for comparison. */
|
|
71
|
-
isSelected?: boolean;
|
|
72
|
-
/** Toggle this log in/out of the comparison selection. */
|
|
73
|
-
onToggleSelect?: (logId: number) => void;
|
|
74
71
|
};
|
|
75
72
|
|
|
76
73
|
export const LogEntryHeader = memo(function ({
|
|
77
74
|
log,
|
|
78
|
-
|
|
75
|
+
messageCount = null,
|
|
76
|
+
toolCount = null,
|
|
79
77
|
expanded,
|
|
80
78
|
onToggle,
|
|
81
|
-
|
|
79
|
+
responseToolNames = null,
|
|
82
80
|
cacheTrend = null,
|
|
83
|
-
isSelected = false,
|
|
84
|
-
onToggleSelect,
|
|
85
81
|
}: LogEntryHeaderProps): JSX.Element {
|
|
86
82
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
87
83
|
|
|
88
84
|
const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
|
|
89
85
|
|
|
90
|
-
const messageCount = parsedRequest !== null ? parsedRequest.messages.length : null;
|
|
91
|
-
|
|
92
|
-
const toolCount =
|
|
93
|
-
parsedRequest !== null && parsedRequest.tools !== undefined && parsedRequest.tools.length > 0
|
|
94
|
-
? parsedRequest.tools.length
|
|
95
|
-
: null;
|
|
96
|
-
|
|
97
86
|
return (
|
|
98
87
|
<div
|
|
99
88
|
role="button"
|
|
@@ -111,86 +100,57 @@ export const LogEntryHeader = memo(function ({
|
|
|
111
100
|
}
|
|
112
101
|
}}
|
|
113
102
|
>
|
|
114
|
-
{/* Selection checkbox (for log-request comparison) */}
|
|
115
|
-
{onToggleSelect !== undefined && (
|
|
116
|
-
<button
|
|
117
|
-
type="button"
|
|
118
|
-
onClick={(e) => {
|
|
119
|
-
e.stopPropagation();
|
|
120
|
-
onToggleSelect(log.id);
|
|
121
|
-
}}
|
|
122
|
-
aria-label={isSelected ? "Deselect for comparison" : "Select for comparison"}
|
|
123
|
-
aria-pressed={isSelected}
|
|
124
|
-
className={cn(
|
|
125
|
-
"shrink-0 size-4 rounded-sm border flex items-center justify-center transition-colors cursor-pointer",
|
|
126
|
-
isSelected
|
|
127
|
-
? "bg-amber-400 border-amber-400 text-amber-950"
|
|
128
|
-
: "border-muted-foreground/40 hover:border-amber-400 hover:bg-amber-400/10",
|
|
129
|
-
)}
|
|
130
|
-
>
|
|
131
|
-
{isSelected && <Check className="size-3" strokeWidth={3} />}
|
|
132
|
-
</button>
|
|
133
|
-
)}
|
|
134
|
-
|
|
135
103
|
{/* Request ID */}
|
|
136
104
|
<span className="text-blue-400/80 font-mono text-xs font-semibold tabular-nums shrink-0">
|
|
137
105
|
#{log.id}
|
|
138
106
|
</span>
|
|
139
107
|
|
|
140
|
-
{/* Model */}
|
|
108
|
+
{/* Model — logo icon only, model name in tooltip */}
|
|
141
109
|
{log.model !== null && (
|
|
142
|
-
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<Badge
|
|
153
|
-
variant="outline"
|
|
154
|
-
className={cn(
|
|
155
|
-
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
156
|
-
log.apiFormat === "openai" && "border-blue-500/40 text-blue-400",
|
|
157
|
-
log.apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
|
|
158
|
-
log.apiFormat === "unknown" && "border-muted text-muted-foreground",
|
|
159
|
-
)}
|
|
160
|
-
>
|
|
161
|
-
{log.apiFormat === "anthropic"
|
|
162
|
-
? "Anthropic"
|
|
163
|
-
: log.apiFormat === "openai"
|
|
164
|
-
? "OpenAI"
|
|
165
|
-
: "Unknown"}
|
|
166
|
-
</Badge>
|
|
110
|
+
<TooltipProvider>
|
|
111
|
+
<Tooltip>
|
|
112
|
+
<TooltipTrigger asChild>
|
|
113
|
+
<span className="shrink-0">
|
|
114
|
+
<ProviderLogo provider={detectProvider(log.model)} className="size-4" />
|
|
115
|
+
</span>
|
|
116
|
+
</TooltipTrigger>
|
|
117
|
+
<TooltipContent>{log.model}</TooltipContent>
|
|
118
|
+
</Tooltip>
|
|
119
|
+
</TooltipProvider>
|
|
167
120
|
)}
|
|
168
121
|
|
|
169
|
-
{/* Response Status */}
|
|
170
|
-
{statusCategory
|
|
171
|
-
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
122
|
+
{/* Response Status — only shown for non-200 or pending */}
|
|
123
|
+
{statusCategory !== "success" && (
|
|
124
|
+
<>
|
|
125
|
+
{statusCategory === "server_error" ? (
|
|
126
|
+
<Badge
|
|
127
|
+
variant="destructive"
|
|
128
|
+
className="text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums"
|
|
129
|
+
>
|
|
130
|
+
{log.responseStatus}
|
|
131
|
+
</Badge>
|
|
132
|
+
) : statusCategory === "pending" ? (
|
|
133
|
+
<Badge
|
|
134
|
+
variant="outline"
|
|
135
|
+
className={cn(
|
|
136
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono tabular-nums",
|
|
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-5 font-mono tabular-nums",
|
|
147
|
+
STATUS_BADGE_CLASSES[statusCategory],
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{log.responseStatus}
|
|
151
|
+
</Badge>
|
|
190
152
|
)}
|
|
191
|
-
|
|
192
|
-
{log.responseStatus}
|
|
193
|
-
</Badge>
|
|
153
|
+
</>
|
|
194
154
|
)}
|
|
195
155
|
|
|
196
156
|
{/* Elapsed time */}
|
|
@@ -220,36 +180,81 @@ export const LogEntryHeader = memo(function ({
|
|
|
220
180
|
)}
|
|
221
181
|
{/* Cache tokens */}
|
|
222
182
|
{log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
|
|
223
|
-
<
|
|
224
|
-
<
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
183
|
+
<TooltipProvider>
|
|
184
|
+
<Tooltip>
|
|
185
|
+
<TooltipTrigger asChild>
|
|
186
|
+
<span className="flex items-center gap-1 text-xs shrink-0">
|
|
187
|
+
<CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
|
|
188
|
+
<span className="font-mono tabular-nums text-emerald-400">
|
|
189
|
+
Cache +{formatTokens(log.cacheCreationInputTokens)}
|
|
190
|
+
</span>
|
|
191
|
+
</span>
|
|
192
|
+
</TooltipTrigger>
|
|
193
|
+
<TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
|
|
194
|
+
</Tooltip>
|
|
195
|
+
</TooltipProvider>
|
|
229
196
|
)}
|
|
230
197
|
{log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
|
|
231
|
-
<
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
198
|
+
<TooltipProvider>
|
|
199
|
+
<Tooltip>
|
|
200
|
+
<TooltipTrigger asChild>
|
|
201
|
+
<span className="flex items-center gap-1 text-xs shrink-0">
|
|
202
|
+
<CacheTrendIndicator trend={cacheTrend?.read ?? null} />
|
|
203
|
+
<span className="font-mono tabular-nums text-purple-400">
|
|
204
|
+
Cache ~{formatTokens(log.cacheReadInputTokens)}
|
|
205
|
+
</span>
|
|
206
|
+
</span>
|
|
207
|
+
</TooltipTrigger>
|
|
208
|
+
<TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
|
|
209
|
+
</Tooltip>
|
|
210
|
+
</TooltipProvider>
|
|
237
211
|
)}
|
|
238
212
|
|
|
239
213
|
{/* Message count */}
|
|
240
214
|
{messageCount !== null && (
|
|
241
|
-
<
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
215
|
+
<TooltipProvider>
|
|
216
|
+
<Tooltip>
|
|
217
|
+
<TooltipTrigger asChild>
|
|
218
|
+
<span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
|
|
219
|
+
<MessageSquare className="size-3" />
|
|
220
|
+
<span className="font-mono tabular-nums">{messageCount}</span>
|
|
221
|
+
</span>
|
|
222
|
+
</TooltipTrigger>
|
|
223
|
+
<TooltipContent>Number of messages in the conversation</TooltipContent>
|
|
224
|
+
</Tooltip>
|
|
225
|
+
</TooltipProvider>
|
|
245
226
|
)}
|
|
246
227
|
|
|
247
228
|
{/* Tool count */}
|
|
248
229
|
{toolCount !== null && (
|
|
249
|
-
<
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
230
|
+
<TooltipProvider>
|
|
231
|
+
<Tooltip>
|
|
232
|
+
<TooltipTrigger asChild>
|
|
233
|
+
<span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
|
|
234
|
+
<Wrench className="size-3" />
|
|
235
|
+
<span className="font-mono tabular-nums">{toolCount}</span>
|
|
236
|
+
</span>
|
|
237
|
+
</TooltipTrigger>
|
|
238
|
+
<TooltipContent>Number of tools defined in the request</TooltipContent>
|
|
239
|
+
</Tooltip>
|
|
240
|
+
</TooltipProvider>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* Response tool calls — tool names the model requested to invoke */}
|
|
244
|
+
{responseToolNames !== null && responseToolNames.length > 0 && (
|
|
245
|
+
<TooltipProvider>
|
|
246
|
+
<Tooltip>
|
|
247
|
+
<TooltipTrigger asChild>
|
|
248
|
+
<span className="flex items-center gap-1 text-amber-400/80 text-xs shrink-0">
|
|
249
|
+
<Wrench className="size-3" />
|
|
250
|
+
<span className="font-mono tabular-nums truncate max-w-[160px]">
|
|
251
|
+
{responseToolNames.join(", ")}
|
|
252
|
+
</span>
|
|
253
|
+
</span>
|
|
254
|
+
</TooltipTrigger>
|
|
255
|
+
<TooltipContent>Tools called by model: {responseToolNames.join(", ")}</TooltipContent>
|
|
256
|
+
</Tooltip>
|
|
257
|
+
</TooltipProvider>
|
|
253
258
|
)}
|
|
254
259
|
|
|
255
260
|
{/* Origin */}
|
|
@@ -265,40 +270,40 @@ export const LogEntryHeader = memo(function ({
|
|
|
265
270
|
</span>
|
|
266
271
|
)}
|
|
267
272
|
|
|
268
|
-
{/* User-Agent */}
|
|
269
|
-
{log.userAgent !== null && (
|
|
270
|
-
<span
|
|
271
|
-
className="flex items-center gap-1 text-muted-foreground text-xs shrink-0"
|
|
272
|
-
title={`User-Agent: ${log.userAgent}`}
|
|
273
|
-
>
|
|
274
|
-
<User className="size-3" />
|
|
275
|
-
<span className="font-mono tabular-nums truncate max-w-[150px]" title={log.userAgent}>
|
|
276
|
-
{log.userAgent}
|
|
277
|
-
</span>
|
|
278
|
-
</span>
|
|
279
|
-
)}
|
|
280
|
-
|
|
281
273
|
{/* Client info (PID + project folder) */}
|
|
282
274
|
{(log.clientPid !== null || log.clientProjectFolder !== null) && (
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
<
|
|
296
|
-
|
|
297
|
-
|
|
275
|
+
<TooltipProvider>
|
|
276
|
+
<Tooltip>
|
|
277
|
+
<TooltipTrigger asChild>
|
|
278
|
+
<span className="flex items-center gap-1 text-purple-400/80 text-xs shrink-0">
|
|
279
|
+
<FileTerminal className="size-3" />
|
|
280
|
+
{log.clientProjectFolder !== null ? (
|
|
281
|
+
<span className="font-mono tabular-nums">{log.clientProjectFolder}</span>
|
|
282
|
+
) : (
|
|
283
|
+
<span className="font-mono tabular-nums">PID {log.clientPid}</span>
|
|
284
|
+
)}
|
|
285
|
+
</span>
|
|
286
|
+
</TooltipTrigger>
|
|
287
|
+
<TooltipContent>
|
|
288
|
+
{log.clientCwd !== null
|
|
289
|
+
? `PID: ${log.clientPid ?? "?"} CWD: ${log.clientCwd}`
|
|
290
|
+
: `Process ID: ${log.clientPid ?? "?"}`}
|
|
291
|
+
</TooltipContent>
|
|
292
|
+
</Tooltip>
|
|
293
|
+
</TooltipProvider>
|
|
298
294
|
)}
|
|
299
295
|
|
|
300
296
|
{/* Streaming indicator */}
|
|
301
|
-
{log.streaming &&
|
|
297
|
+
{log.streaming && (
|
|
298
|
+
<TooltipProvider>
|
|
299
|
+
<Tooltip>
|
|
300
|
+
<TooltipTrigger asChild>
|
|
301
|
+
<Radio className="size-3 text-muted-foreground/60 shrink-0" />
|
|
302
|
+
</TooltipTrigger>
|
|
303
|
+
<TooltipContent>Request used SSE streaming</TooltipContent>
|
|
304
|
+
</Tooltip>
|
|
305
|
+
</TooltipProvider>
|
|
306
|
+
)}
|
|
302
307
|
|
|
303
308
|
{/* Spacer */}
|
|
304
309
|
<span className="flex-1 min-w-0" />
|
|
@@ -3,6 +3,7 @@ import type { JSX } from "react";
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { Button } from "../ui/button";
|
|
6
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
|
|
6
7
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
|
|
7
8
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
8
9
|
import { ResponseView } from "./ResponseView";
|
|
@@ -92,12 +93,21 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
|
|
|
92
93
|
<TabsContent value="modified" className="space-y-4">
|
|
93
94
|
<div>
|
|
94
95
|
<label className="text-sm font-medium mb-2 block">Request Body (JSON)</label>
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
<TooltipProvider>
|
|
97
|
+
<Tooltip>
|
|
98
|
+
<TooltipTrigger asChild>
|
|
99
|
+
<textarea
|
|
100
|
+
className="w-full h-64 p-3 font-mono text-xs bg-muted rounded-md border border-input resize-none focus:outline-none focus:ring-2 focus:ring-ring"
|
|
101
|
+
value={modifiedBody}
|
|
102
|
+
onChange={(e) => setModifiedBody(e.target.value)}
|
|
103
|
+
spellCheck={false}
|
|
104
|
+
/>
|
|
105
|
+
</TooltipTrigger>
|
|
106
|
+
<TooltipContent>
|
|
107
|
+
Edit the request body before re-sending to the provider
|
|
108
|
+
</TooltipContent>
|
|
109
|
+
</Tooltip>
|
|
110
|
+
</TooltipProvider>
|
|
101
111
|
</div>
|
|
102
112
|
|
|
103
113
|
{error !== null && error !== "" && (
|
|
@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, type JSX } from "react";
|
|
|
2
2
|
import { ChevronDown, ChevronRight, Loader2 } from "lucide-react";
|
|
3
3
|
import { Badge } from "../ui/badge";
|
|
4
4
|
import { JsonViewer } from "../ui/json-viewer";
|
|
5
|
+
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
|
|
5
6
|
import type { StreamingChunk } from "../../proxy/schemas";
|
|
6
7
|
|
|
7
8
|
export type StreamingChunkSequenceProps = {
|
|
@@ -153,22 +154,29 @@ export function StreamingChunkSequence({
|
|
|
153
154
|
|
|
154
155
|
return (
|
|
155
156
|
<div className="space-y-1">
|
|
156
|
-
<
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
157
|
+
<TooltipProvider>
|
|
158
|
+
<Tooltip>
|
|
159
|
+
<TooltipTrigger asChild>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
163
|
+
onClick={() => setContainerExpanded((v) => !v)}
|
|
164
|
+
>
|
|
165
|
+
{containerExpanded ? (
|
|
166
|
+
<ChevronDown className="size-3" />
|
|
167
|
+
) : (
|
|
168
|
+
<ChevronRight className="size-3" />
|
|
169
|
+
)}
|
|
170
|
+
<span>Raw SSE Events</span>
|
|
171
|
+
<Badge variant="outline" className="text-[9px] px-1 py-0 h-4 font-mono ml-1">
|
|
172
|
+
{logId}
|
|
173
|
+
{truncated === true ? "+" : ""}
|
|
174
|
+
</Badge>
|
|
175
|
+
</button>
|
|
176
|
+
</TooltipTrigger>
|
|
177
|
+
<TooltipContent>Server-Sent Events streaming chunks from the provider</TooltipContent>
|
|
178
|
+
</Tooltip>
|
|
179
|
+
</TooltipProvider>
|
|
172
180
|
|
|
173
181
|
{containerExpanded === true ? (
|
|
174
182
|
<div className="rounded-md border border-border bg-muted/20 overflow-auto max-h-64">
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import { cn } from "../../lib/utils";
|
|
3
|
+
import type { StopReason } from "../../lib/stopReason";
|
|
4
|
+
|
|
5
|
+
export type ThreadConnectorProps = {
|
|
6
|
+
stopReason: StopReason;
|
|
7
|
+
isPending: boolean;
|
|
8
|
+
isFirst: boolean;
|
|
9
|
+
isLast: boolean;
|
|
10
|
+
/** True when this entry starts a new turn (first overall, or after end_turn/stop). */
|
|
11
|
+
isTurnStart: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Vertical timeline connector for thread view. Uses flexbox layout (no
|
|
16
|
+
* absolute positioning) so the connector naturally tracks its sibling
|
|
17
|
+
* LogEntry height — no scroll jitter.
|
|
18
|
+
*/
|
|
19
|
+
export function ThreadConnector({
|
|
20
|
+
stopReason,
|
|
21
|
+
isPending,
|
|
22
|
+
isFirst,
|
|
23
|
+
isLast: _isLast,
|
|
24
|
+
isTurnStart,
|
|
25
|
+
}: ThreadConnectorProps): JSX.Element {
|
|
26
|
+
const isBoundary = stopReason === "end_turn" || stopReason === "stop";
|
|
27
|
+
const isToolUse = stopReason === "tool_use";
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col items-center w-6 shrink-0">
|
|
31
|
+
{/* Top: incoming line from previous entry, or empty spacer for first.
|
|
32
|
+
Fixed height so the marker stays near the LogEntry header row. */}
|
|
33
|
+
<div className="flex justify-center h-4">
|
|
34
|
+
{!isFirst && <div className="w-0.5 bg-muted-foreground/30" />}
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Center marker — aligned with the LogEntry header row */}
|
|
38
|
+
<div className="flex items-center justify-center py-0.5">
|
|
39
|
+
{isBoundary ? (
|
|
40
|
+
<div
|
|
41
|
+
className={cn(
|
|
42
|
+
"size-2.5 rounded-full border-2",
|
|
43
|
+
"bg-background border-amber-400",
|
|
44
|
+
"shadow-[0_0_6px_rgba(251,191,36,0.4)]",
|
|
45
|
+
)}
|
|
46
|
+
title={stopReason === "end_turn" ? "End of Turn (Anthropic)" : "End of Turn (OpenAI)"}
|
|
47
|
+
/>
|
|
48
|
+
) : isToolUse ? (
|
|
49
|
+
<div
|
|
50
|
+
className={cn(
|
|
51
|
+
"size-2 rounded-full",
|
|
52
|
+
isTurnStart
|
|
53
|
+
? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
|
|
54
|
+
: "bg-muted-foreground/25",
|
|
55
|
+
)}
|
|
56
|
+
title={isTurnStart ? "Tool Use — start of turn" : "Tool Use — turn continues"}
|
|
57
|
+
/>
|
|
58
|
+
) : isPending ? (
|
|
59
|
+
<div
|
|
60
|
+
className="size-2.5 rounded-full border-2 border-dashed border-muted-foreground/30 animate-pulse"
|
|
61
|
+
title="Response pending"
|
|
62
|
+
/>
|
|
63
|
+
) : (
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
"size-1.5 rounded-full",
|
|
67
|
+
isTurnStart
|
|
68
|
+
? "bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.5)]"
|
|
69
|
+
: "bg-muted-foreground/30",
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Bottom: outgoing line to next entry, or short terminator at boundaries.
|
|
76
|
+
flex-1 fills the remaining height of the LogEntry card. */}
|
|
77
|
+
<div className="flex-1 flex justify-center min-h-1">
|
|
78
|
+
{isBoundary ? (
|
|
79
|
+
<div className="w-0.5 bg-muted-foreground/10 h-4" />
|
|
80
|
+
) : (
|
|
81
|
+
<div
|
|
82
|
+
className={cn(
|
|
83
|
+
"w-0.5 h-full",
|
|
84
|
+
isPending
|
|
85
|
+
? "border-dashed bg-transparent border-l-2 border-muted-foreground/20"
|
|
86
|
+
: "bg-muted-foreground/30",
|
|
87
|
+
)}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -4,5 +4,6 @@ export {
|
|
|
4
4
|
getConversationId,
|
|
5
5
|
groupLogsByConversation,
|
|
6
6
|
} from "./ConversationHeader";
|
|
7
|
-
export type { ConversationGroupData } from "./ConversationHeader";
|
|
7
|
+
export type { ConversationGroupData, ViewMode } from "./ConversationHeader";
|
|
8
8
|
export { LogEntry } from "./LogEntry";
|
|
9
|
+
export { ThreadConnector } from "./ThreadConnector";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CapturedLog } from "../proxy/schemas";
|
|
2
|
+
|
|
3
|
+
export type StopReason = "end_turn" | "tool_use" | "stop" | null;
|
|
4
|
+
|
|
5
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the stop/finish reason from a captured log's response text.
|
|
11
|
+
* Returns the raw stop_reason value for Anthropic, the finish_reason for
|
|
12
|
+
* OpenAI, or null if the response is pending, malformed, or unrecognized.
|
|
13
|
+
*/
|
|
14
|
+
export function extractStopReason(log: CapturedLog): StopReason {
|
|
15
|
+
if (log.responseText === null) return null;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
let json: unknown = JSON.parse(log.responseText);
|
|
19
|
+
// Handle double-encoded JSON
|
|
20
|
+
if (typeof json === "string") {
|
|
21
|
+
json = JSON.parse(json);
|
|
22
|
+
}
|
|
23
|
+
if (!isRecord(json)) return null;
|
|
24
|
+
|
|
25
|
+
// Anthropic: { stop_reason: "end_turn" | "tool_use" | ... }
|
|
26
|
+
if (log.apiFormat === "anthropic" && typeof json.stop_reason === "string") {
|
|
27
|
+
if (json.stop_reason === "end_turn" || json.stop_reason === "tool_use") {
|
|
28
|
+
return json.stop_reason;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// OpenAI: { choices: [{ finish_reason: "stop" | ... }] }
|
|
34
|
+
if (
|
|
35
|
+
log.apiFormat === "openai" &&
|
|
36
|
+
Array.isArray(json.choices) &&
|
|
37
|
+
json.choices.length > 0 &&
|
|
38
|
+
isRecord(json.choices[0]) &&
|
|
39
|
+
typeof json.choices[0].finish_reason === "string" &&
|
|
40
|
+
json.choices[0].finish_reason === "stop"
|
|
41
|
+
) {
|
|
42
|
+
return "stop";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Returns true when the stop reason indicates the assistant completed its
|
|
53
|
+
* turn naturally (Anthropic end_turn or OpenAI stop).
|
|
54
|
+
*/
|
|
55
|
+
export function isTurnBoundary(stopReason: StopReason): boolean {
|
|
56
|
+
return stopReason === "end_turn" || stopReason === "stop";
|
|
57
|
+
}
|
|
@@ -60,11 +60,8 @@ export const AnthropicFormatHandler: FormatHandler = {
|
|
|
60
60
|
const json: unknown = JSON.parse(rawBody);
|
|
61
61
|
if (typeof json === "object" && json !== null && !Array.isArray(json)) {
|
|
62
62
|
const keys = Object.keys(json);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
63
|
+
// Anthropic puts `system` as a top-level key alongside `model` and `messages`
|
|
64
|
+
return keys.includes("model") && keys.includes("messages") && keys.includes("system");
|
|
68
65
|
}
|
|
69
66
|
return false;
|
|
70
67
|
} catch {
|