@tonyclaw/llm-inspector 1.18.0 → 1.18.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/CompareDrawer-C-4ypEWs.js +1 -0
- package/.output/public/assets/ProxyViewerContainer-WRenRpeh.js +101 -0
- package/.output/public/assets/ReplayDialog-CyBKOgba.js +1 -0
- package/.output/public/assets/RequestAnatomy-C0IrVQ3q.js +1 -0
- package/.output/public/assets/ResponseView-MogToC4i.js +1 -0
- package/.output/public/assets/StreamingChunkSequence-ClhUhT-s.js +1 -0
- package/.output/public/assets/_sessionId-BO47oA3Z.js +1 -0
- package/.output/public/assets/index-BRvz6-L6.css +1 -0
- package/.output/public/assets/index-Btw8ec7-.js +1 -0
- package/.output/public/assets/{json-viewer-D-z1r1Pp.js → json-viewer-BicGakI5.js} +1 -1
- package/.output/public/assets/{main-CZJ63sQh.js → main-Be2qqUUW.js} +8 -7
- package/.output/server/_libs/lucide-react.mjs +23 -17
- package/.output/server/_sessionId-DhKJIdQC.mjs +122 -0
- package/.output/server/_ssr/{CompareDrawer-BJr-913n.mjs → CompareDrawer-BGUgukJ8.mjs} +57 -57
- package/.output/server/_ssr/{index-C7I_Qgt0.mjs → ProxyViewerContainer--3K3o3Sm.mjs} +277 -191
- package/.output/server/_ssr/{ReplayDialog-BwmToGuR.mjs → ReplayDialog-Bo86xZI4.mjs} +57 -57
- package/.output/server/_ssr/{RequestAnatomy-BmMiPRPB.mjs → RequestAnatomy-jRU5qgwB.mjs} +4 -4
- package/.output/server/_ssr/{ResponseView-ZB9-8Raw.mjs → ResponseView-DdO_-79a.mjs} +5 -5
- package/.output/server/_ssr/{StreamingChunkSequence-DWm4CQWC.mjs → StreamingChunkSequence-BigLwhh4.mjs} +57 -57
- package/.output/server/_ssr/index-BHG6vOnr.mjs +117 -0
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{json-viewer-D9XETzwp.mjs → json-viewer-B4c_WjXD.mjs} +4 -4
- package/.output/server/_ssr/{router-711KpGkz.mjs → router-DVixpJO-.mjs} +79 -22
- package/.output/server/_tanstack-start-manifest_v-BbvWUF4v.mjs +4 -0
- package/.output/server/index.mjs +70 -56
- package/README.md +109 -59
- package/package.json +1 -1
- package/src/assets/logos/mcp.png +0 -0
- package/src/components/ProxyViewer.tsx +240 -64
- package/src/components/ProxyViewerContainer.tsx +42 -11
- package/src/components/proxy-viewer/ConversationGroup.tsx +1 -8
- package/src/components/proxy-viewer/ConversationHeader.tsx +35 -7
- package/src/components/ui/mcp-logo.tsx +20 -0
- package/src/lib/sessionUrl.ts +44 -0
- package/src/routes/session/$sessionId.tsx +23 -0
- package/.output/public/assets/CompareDrawer-BpwZCB6M.js +0 -1
- package/.output/public/assets/ReplayDialog-Clratkzl.js +0 -1
- package/.output/public/assets/RequestAnatomy-EtiX0r_G.js +0 -1
- package/.output/public/assets/ResponseView-CJqxo-EN.js +0 -1
- package/.output/public/assets/StreamingChunkSequence-BIbRqQiV.js +0 -1
- package/.output/public/assets/index-B-0F9n1w.js +0 -101
- package/.output/public/assets/index-DoGvsnbA.css +0 -1
- package/.output/server/_tanstack-start-manifest_v-noQw0Vmw.mjs +0 -4
|
Binary file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type JSX, useCallback, useEffect, useMemo, useRef, useState, Suspense } from "react";
|
|
2
|
-
import { Download } from "lucide-react";
|
|
2
|
+
import { ArrowLeft, Check, Copy, Download, Plus } from "lucide-react";
|
|
3
3
|
|
|
4
4
|
import type { CapturedLog } from "../proxy/schemas";
|
|
5
5
|
import { exportLogsAsZip } from "../lib/export-logs";
|
|
@@ -9,7 +9,9 @@ import { ConversationGroup, groupLogsByConversation } from "./proxy-viewer";
|
|
|
9
9
|
|
|
10
10
|
import { CrabLogo } from "./ui/crab-logo";
|
|
11
11
|
import { crabVariants } from "./ui/crab-variants";
|
|
12
|
+
import { McpLogo } from "./ui/mcp-logo";
|
|
12
13
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
14
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
|
13
15
|
import { SettingsDialog } from "./providers/SettingsDialog";
|
|
14
16
|
import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
|
|
15
17
|
import { LazyCompareDrawer } from "./proxy-viewer/lazy";
|
|
@@ -31,6 +33,28 @@ function computeTokenSummary(logs: CapturedLog[]): { totalIn: number; totalOut:
|
|
|
31
33
|
return { totalIn, totalOut };
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function formatTimeRange(logs: CapturedLog[]): string | null {
|
|
37
|
+
const first = logs[0];
|
|
38
|
+
const last = logs[logs.length - 1];
|
|
39
|
+
if (first === undefined || last === undefined) return null;
|
|
40
|
+
const format = (iso: string): string =>
|
|
41
|
+
new Date(iso).toLocaleTimeString([], {
|
|
42
|
+
hour: "2-digit",
|
|
43
|
+
minute: "2-digit",
|
|
44
|
+
second: "2-digit",
|
|
45
|
+
});
|
|
46
|
+
return `${format(first.timestamp)} - ${format(last.timestamp)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getFirstUserAgent(logs: CapturedLog[]): string | null {
|
|
50
|
+
for (const log of logs) {
|
|
51
|
+
if (log.userAgent !== null && log.userAgent !== undefined && log.userAgent !== "") {
|
|
52
|
+
return log.userAgent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
function CopyableCommand({ command }: { command: string }): JSX.Element {
|
|
35
59
|
const [copied, setCopied] = useState(false);
|
|
36
60
|
|
|
@@ -83,6 +107,97 @@ function CopyableCommand({ command }: { command: string }): JSX.Element {
|
|
|
83
107
|
);
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
function McpReadyBadge(): JSX.Element {
|
|
111
|
+
return (
|
|
112
|
+
<TooltipProvider>
|
|
113
|
+
<Tooltip>
|
|
114
|
+
<TooltipTrigger asChild>
|
|
115
|
+
<span className="inline-flex h-7 items-center gap-2 rounded-md border border-cyan-400/30 bg-cyan-500/10 px-2.5 font-mono text-[11px] font-medium text-cyan-300 shadow-[0_0_16px_rgba(34,211,238,0.08)]">
|
|
116
|
+
<span className="size-1.5 rounded-full bg-emerald-300 shadow-[0_0_8px_rgba(110,231,183,0.8)]" />
|
|
117
|
+
MCP Ready
|
|
118
|
+
<span className="hidden text-cyan-200/70 sm:inline">/api/mcp</span>
|
|
119
|
+
</span>
|
|
120
|
+
</TooltipTrigger>
|
|
121
|
+
<TooltipContent sideOffset={8} className="max-w-[320px] text-left leading-relaxed">
|
|
122
|
+
Coding agents can inspect logs, replay requests, test providers, and debug sessions
|
|
123
|
+
through MCP at /api/mcp.
|
|
124
|
+
</TooltipContent>
|
|
125
|
+
</Tooltip>
|
|
126
|
+
</TooltipProvider>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function SessionContextBar({
|
|
131
|
+
sessionId,
|
|
132
|
+
logs,
|
|
133
|
+
totalIn,
|
|
134
|
+
totalOut,
|
|
135
|
+
}: {
|
|
136
|
+
sessionId: string;
|
|
137
|
+
logs: CapturedLog[];
|
|
138
|
+
totalIn: number;
|
|
139
|
+
totalOut: number;
|
|
140
|
+
}): JSX.Element {
|
|
141
|
+
const [copied, setCopied] = useState(false);
|
|
142
|
+
const timeRange = useMemo(() => formatTimeRange(logs), [logs]);
|
|
143
|
+
const userAgent = useMemo(() => getFirstUserAgent(logs), [logs]);
|
|
144
|
+
|
|
145
|
+
const handleCopyLink = useCallback(() => {
|
|
146
|
+
void window.navigator.clipboard.writeText(window.location.href).then(() => {
|
|
147
|
+
setCopied(true);
|
|
148
|
+
setTimeout(() => setCopied(false), 2000);
|
|
149
|
+
});
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="mb-4 flex items-center gap-3 border border-border rounded-md bg-muted/20 px-3 py-2 text-xs">
|
|
154
|
+
<a
|
|
155
|
+
href="/"
|
|
156
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
157
|
+
aria-label="Back to all sessions"
|
|
158
|
+
title="Back to all sessions"
|
|
159
|
+
>
|
|
160
|
+
<ArrowLeft className="size-3.5" />
|
|
161
|
+
</a>
|
|
162
|
+
<div className="min-w-0 flex-1">
|
|
163
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
164
|
+
<span className="font-mono font-semibold text-purple-400/90 truncate" title={sessionId}>
|
|
165
|
+
{truncateSessionId(sessionId)}
|
|
166
|
+
</span>
|
|
167
|
+
{userAgent !== null && (
|
|
168
|
+
<span
|
|
169
|
+
className="font-mono text-muted-foreground truncate max-w-[220px]"
|
|
170
|
+
title={userAgent}
|
|
171
|
+
>
|
|
172
|
+
{userAgent}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-muted-foreground">
|
|
177
|
+
<span>
|
|
178
|
+
{logs.length} request{logs.length !== 1 ? "s" : ""}
|
|
179
|
+
</span>
|
|
180
|
+
{timeRange !== null && <span>{timeRange}</span>}
|
|
181
|
+
{(totalIn > 0 || totalOut > 0) && (
|
|
182
|
+
<span className="font-mono">
|
|
183
|
+
{formatTokens(totalIn)} in / {formatTokens(totalOut)} out
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={handleCopyLink}
|
|
191
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
192
|
+
aria-label={copied ? "Copied session link" : "Copy session link"}
|
|
193
|
+
title={copied ? "Copied session link" : "Copy session link"}
|
|
194
|
+
>
|
|
195
|
+
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
86
201
|
export type ProxyViewerProps = {
|
|
87
202
|
logs: CapturedLog[];
|
|
88
203
|
sessions: string[];
|
|
@@ -101,6 +216,12 @@ export type ProxyViewerProps = {
|
|
|
101
216
|
strip: boolean;
|
|
102
217
|
/** Slow-response threshold in seconds. `0` disables the warning indicator. */
|
|
103
218
|
slowResponseThresholdSeconds: number;
|
|
219
|
+
/** Hide the session filter dropdown. Used on `/session/$id` routes where
|
|
220
|
+
* the session is already pinned by the URL and the dropdown would just
|
|
221
|
+
* fight the URL state. */
|
|
222
|
+
hideSessionFilter?: boolean;
|
|
223
|
+
/** Session id pinned by a `/session/$id` route. Enables session-page chrome. */
|
|
224
|
+
pinnedSessionId?: string;
|
|
104
225
|
};
|
|
105
226
|
|
|
106
227
|
export function ProxyViewer({
|
|
@@ -117,6 +238,8 @@ export function ProxyViewer({
|
|
|
117
238
|
onViewModeChange,
|
|
118
239
|
strip,
|
|
119
240
|
slowResponseThresholdSeconds,
|
|
241
|
+
hideSessionFilter = false,
|
|
242
|
+
pinnedSessionId,
|
|
120
243
|
}: ProxyViewerProps): JSX.Element {
|
|
121
244
|
const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
|
|
122
245
|
const [exporting, setExporting] = useState(false);
|
|
@@ -140,6 +263,15 @@ export function ProxyViewer({
|
|
|
140
263
|
};
|
|
141
264
|
}, []);
|
|
142
265
|
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
if (pinnedSessionId === undefined) {
|
|
268
|
+
document.title = "LLM Inspector";
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const requestLabel = logs.length === 1 ? "1 req" : `${logs.length} req`;
|
|
272
|
+
document.title = `${truncateSessionId(pinnedSessionId)} - ${requestLabel} - LLM Inspector`;
|
|
273
|
+
}, [logs.length, pinnedSessionId]);
|
|
274
|
+
|
|
143
275
|
const handleExport = useCallback(async () => {
|
|
144
276
|
setExporting(true);
|
|
145
277
|
try {
|
|
@@ -172,73 +304,92 @@ export function ProxyViewer({
|
|
|
172
304
|
return (
|
|
173
305
|
<div className="max-w-[1400px] xl:max-w-[1600px] 2xl:max-w-[1800px] mx-auto px-6 pb-6">
|
|
174
306
|
{/* Brand row */}
|
|
175
|
-
<div className="
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
<span className="flex items-end gap-
|
|
179
|
-
|
|
180
|
-
<span className="flex items-end gap-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
? "
|
|
201
|
-
: ""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
307
|
+
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3 pt-6 pb-8">
|
|
308
|
+
<div />
|
|
309
|
+
<h1 className="flex min-w-0 flex-col items-center gap-2 text-center">
|
|
310
|
+
<span className="flex max-w-[calc(100vw-7rem)] items-end gap-2 whitespace-nowrap">
|
|
311
|
+
{/* Crab family — hover to animate together */}
|
|
312
|
+
<span className="flex shrink-0 items-end gap-1 group cursor-default" aria-hidden="true">
|
|
313
|
+
<CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
|
|
314
|
+
<span className="hidden items-end gap-0.5 sm:flex">
|
|
315
|
+
{crabVariants.map((Crab, i) => {
|
|
316
|
+
const color = [
|
|
317
|
+
"text-amber-500",
|
|
318
|
+
"text-rose-500",
|
|
319
|
+
"text-sky-500",
|
|
320
|
+
"text-emerald-500",
|
|
321
|
+
"text-violet-500",
|
|
322
|
+
"text-orange-500",
|
|
323
|
+
"text-cyan-500",
|
|
324
|
+
"text-pink-500",
|
|
325
|
+
"text-lime-500",
|
|
326
|
+
"text-blue-500",
|
|
327
|
+
"text-yellow-500",
|
|
328
|
+
"text-fuchsia-500",
|
|
329
|
+
][i];
|
|
330
|
+
const entranceClass =
|
|
331
|
+
crabEntrancePhase === "hidden"
|
|
332
|
+
? "opacity-0 scale-0"
|
|
333
|
+
: crabEntrancePhase === "playing"
|
|
334
|
+
? "animate-crab-piano-pop"
|
|
335
|
+
: "";
|
|
336
|
+
return (
|
|
337
|
+
<Crab
|
|
338
|
+
key={i}
|
|
339
|
+
className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
|
|
340
|
+
style={{
|
|
341
|
+
transitionDelay: `${i * 50}ms`,
|
|
342
|
+
...(crabEntrancePhase === "playing"
|
|
343
|
+
? { animationDelay: `${i * 400}ms` }
|
|
344
|
+
: {}),
|
|
345
|
+
}}
|
|
346
|
+
/>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</span>
|
|
215
350
|
</span>
|
|
351
|
+
<span className="flex min-w-0 items-baseline gap-2 pl-1">
|
|
352
|
+
<span className="truncate text-lg font-bold">LLM Inspector</span>
|
|
353
|
+
<span className="shrink-0 font-mono text-xs font-semibold text-muted-foreground">
|
|
354
|
+
v{packageJson.version}
|
|
355
|
+
</span>
|
|
356
|
+
</span>
|
|
357
|
+
<Plus className="size-4 shrink-0 text-muted-foreground/70" aria-hidden="true" />
|
|
358
|
+
<McpLogo className="size-10 shrink-0" />
|
|
216
359
|
</span>
|
|
217
|
-
<
|
|
218
|
-
LLM Inspector
|
|
219
|
-
<span className="text-xs text-muted-foreground font-mono">v{packageJson.version}</span>
|
|
220
|
-
</span>
|
|
360
|
+
<McpReadyBadge />
|
|
221
361
|
</h1>
|
|
222
|
-
<div className="
|
|
362
|
+
<div className="justify-self-end">
|
|
223
363
|
<SettingsDialog />
|
|
224
364
|
</div>
|
|
225
365
|
</div>
|
|
226
366
|
|
|
367
|
+
{pinnedSessionId !== undefined && (
|
|
368
|
+
<SessionContextBar
|
|
369
|
+
sessionId={pinnedSessionId}
|
|
370
|
+
logs={logs}
|
|
371
|
+
totalIn={totalIn}
|
|
372
|
+
totalOut={totalOut}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
|
|
227
376
|
{/* Controls + Filters */}
|
|
228
377
|
<div className="flex items-center gap-3 mb-4">
|
|
229
|
-
|
|
230
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
<
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
378
|
+
{!hideSessionFilter && (
|
|
379
|
+
<Select value={selectedSession} onValueChange={onSessionChange}>
|
|
380
|
+
<SelectTrigger className="flex-1 max-w-[350px] text-xs">
|
|
381
|
+
<SelectValue placeholder="All sessions" />
|
|
382
|
+
</SelectTrigger>
|
|
383
|
+
<SelectContent>
|
|
384
|
+
<SelectItem value="__all__">All sessions</SelectItem>
|
|
385
|
+
{sessions.map((s) => (
|
|
386
|
+
<SelectItem key={s} value={s}>
|
|
387
|
+
{truncateSessionId(s)}
|
|
388
|
+
</SelectItem>
|
|
389
|
+
))}
|
|
390
|
+
</SelectContent>
|
|
391
|
+
</Select>
|
|
392
|
+
)}
|
|
242
393
|
<Select value={selectedModel} onValueChange={onModelChange}>
|
|
243
394
|
<SelectTrigger className="flex-1 max-w-[250px] text-xs">
|
|
244
395
|
<SelectValue placeholder="All models" />
|
|
@@ -316,11 +467,36 @@ export function ProxyViewer({
|
|
|
316
467
|
{/* Log list */}
|
|
317
468
|
<div>
|
|
318
469
|
{logs.length === 0 ? (
|
|
319
|
-
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
470
|
+
selectedSession !== "__all__" ? (
|
|
471
|
+
<div className="text-center text-muted-foreground py-16 space-y-4">
|
|
472
|
+
<p className="text-sm font-medium">Session not found</p>
|
|
473
|
+
<p className="text-xs font-mono bg-muted px-3 py-1 rounded inline-block max-w-[500px] break-all">
|
|
474
|
+
{truncateSessionId(selectedSession)}
|
|
475
|
+
</p>
|
|
476
|
+
<p className="text-xs">
|
|
477
|
+
This session may have been cleared or never existed.{" "}
|
|
478
|
+
{hideSessionFilter ? (
|
|
479
|
+
<a href="/" className="underline hover:text-foreground transition-colors">
|
|
480
|
+
Back to all sessions
|
|
481
|
+
</a>
|
|
482
|
+
) : (
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => onSessionChange("__all__")}
|
|
486
|
+
className="underline hover:text-foreground transition-colors"
|
|
487
|
+
>
|
|
488
|
+
Show all sessions
|
|
489
|
+
</button>
|
|
490
|
+
)}
|
|
491
|
+
</p>
|
|
492
|
+
</div>
|
|
493
|
+
) : (
|
|
494
|
+
<div className="text-center text-muted-foreground py-16 space-y-4">
|
|
495
|
+
<p className="text-sm">No requests captured yet.</p>
|
|
496
|
+
<p className="text-xs">Route AI coding tools through the proxy:</p>
|
|
497
|
+
<CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
|
|
498
|
+
</div>
|
|
499
|
+
)
|
|
324
500
|
) : (
|
|
325
501
|
<div
|
|
326
502
|
ref={logListWrapperRef}
|
|
@@ -62,12 +62,32 @@ function filterLogs(
|
|
|
62
62
|
|
|
63
63
|
const DEBOUNCE_MS = 50;
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
function buildLogsStreamUrl(sessionId: string | undefined): string {
|
|
66
|
+
if (sessionId === undefined) return "/api/logs/stream";
|
|
67
|
+
const params = new URLSearchParams({ sessionId });
|
|
68
|
+
return `/api/logs/stream?${params.toString()}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ProxyViewerContainer({
|
|
72
|
+
initialSessionId,
|
|
73
|
+
}: {
|
|
74
|
+
/**
|
|
75
|
+
* Initial session filter. When the route is `/session/$id`, pass the URL
|
|
76
|
+
* param here so the page opens already scoped to that session. Default
|
|
77
|
+
* `__all__` keeps the existing "all sessions" behavior on `/`.
|
|
78
|
+
*
|
|
79
|
+
* NOTE: the value is only used as the initial state on mount. To pick up
|
|
80
|
+
* a new value (e.g. user navigates to a different `/session/$id`), the
|
|
81
|
+
* caller should also pass a `key` prop matching the session id, which
|
|
82
|
+
* forces a remount.
|
|
83
|
+
*/
|
|
84
|
+
initialSessionId?: string;
|
|
85
|
+
} = {}): JSX.Element {
|
|
66
86
|
// `allLogs` is the unfiltered set populated by the SSE. The single SSE
|
|
67
87
|
// connection never re-opens on filter change — we always carry the full
|
|
68
88
|
// set and derive the displayed view with `useMemo` below.
|
|
69
89
|
const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
|
|
70
|
-
const [selectedSession, setSelectedSession] = useState("__all__");
|
|
90
|
+
const [selectedSession, setSelectedSession] = useState(initialSessionId ?? "__all__");
|
|
71
91
|
const [selectedModel, setSelectedModel] = useState("__all__");
|
|
72
92
|
const [viewMode, setViewMode] = useState<"simple" | "full">("simple");
|
|
73
93
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -128,12 +148,7 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
128
148
|
eventSourceRef.current.close();
|
|
129
149
|
}
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
// from the complete set, so the SSE never needs to reopen on filter
|
|
133
|
-
// change. If the in-memory set ever grows past what the client can
|
|
134
|
-
// handle, swap this back to a parameterized URL and reconnect on
|
|
135
|
-
// filter change.
|
|
136
|
-
const es = new EventSource("/api/logs/stream");
|
|
151
|
+
const es = new EventSource(buildLogsStreamUrl(initialSessionId));
|
|
137
152
|
eventSourceRef.current = es;
|
|
138
153
|
|
|
139
154
|
es.onmessage = (event: MessageEvent) => {
|
|
@@ -179,7 +194,7 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
179
194
|
}
|
|
180
195
|
reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
|
|
181
196
|
};
|
|
182
|
-
}, [scheduleUpdate]);
|
|
197
|
+
}, [initialSessionId, scheduleUpdate]);
|
|
183
198
|
|
|
184
199
|
useEffect(() => {
|
|
185
200
|
connectSSE();
|
|
@@ -200,9 +215,22 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
200
215
|
}, [connectSSE]);
|
|
201
216
|
|
|
202
217
|
const handleClearAll = useCallback(() => {
|
|
218
|
+
if (initialSessionId !== undefined && allLogs.length === 0) return;
|
|
203
219
|
void (async () => {
|
|
204
220
|
try {
|
|
205
|
-
const
|
|
221
|
+
const body =
|
|
222
|
+
initialSessionId === undefined
|
|
223
|
+
? undefined
|
|
224
|
+
: JSON.stringify({ ids: allLogs.map((log) => log.id) });
|
|
225
|
+
const res = await fetch("/api/logs", {
|
|
226
|
+
method: "DELETE",
|
|
227
|
+
...(body === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: {
|
|
230
|
+
headers: { "Content-Type": "application/json" },
|
|
231
|
+
body,
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
206
234
|
if (!res.ok) {
|
|
207
235
|
setError("Failed to clear logs");
|
|
208
236
|
return;
|
|
@@ -214,7 +242,7 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
214
242
|
setError(err instanceof Error ? err.message : "Unknown error clearing logs");
|
|
215
243
|
}
|
|
216
244
|
})();
|
|
217
|
-
}, []);
|
|
245
|
+
}, [allLogs, initialSessionId]);
|
|
218
246
|
|
|
219
247
|
const handleClearGroup = useCallback((ids: number[]) => {
|
|
220
248
|
if (ids.length === 0) return;
|
|
@@ -274,6 +302,9 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
274
302
|
onViewModeChange={setViewMode}
|
|
275
303
|
strip={strip}
|
|
276
304
|
slowResponseThresholdSeconds={slowResponseThresholdSeconds}
|
|
305
|
+
// Session filter is the URL's job when `initialSessionId` was given.
|
|
306
|
+
hideSessionFilter={initialSessionId !== undefined}
|
|
307
|
+
pinnedSessionId={initialSessionId}
|
|
277
308
|
/>
|
|
278
309
|
</>
|
|
279
310
|
);
|
|
@@ -65,18 +65,11 @@ export const ConversationGroup = memo(function ({
|
|
|
65
65
|
|
|
66
66
|
// Pre-compute stop reasons for each log — used by turnIndices
|
|
67
67
|
const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
|
|
68
|
-
const displayId =
|
|
69
|
-
group.conversationId.startsWith("PID:") || group.conversationId.includes("|")
|
|
70
|
-
? group.conversationId
|
|
71
|
-
: group.conversationId.length > 24
|
|
72
|
-
? group.conversationId.slice(0, 12) + "…" + group.conversationId.slice(-12)
|
|
73
|
-
: group.conversationId;
|
|
74
|
-
|
|
75
68
|
return (
|
|
76
69
|
<div className="mb-2">
|
|
77
70
|
{!standalone && (
|
|
78
71
|
<ConversationHeader
|
|
79
|
-
conversationId={
|
|
72
|
+
conversationId={group.conversationId}
|
|
80
73
|
startTime={startTime}
|
|
81
74
|
endTime={endTime}
|
|
82
75
|
totalCalls={group.logs.length}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
ChevronDown,
|
|
4
4
|
ChevronRight,
|
|
5
5
|
Clock,
|
|
6
|
+
ExternalLink,
|
|
6
7
|
Loader2,
|
|
7
8
|
MessageSquare,
|
|
8
9
|
Trash2,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
Zap,
|
|
11
12
|
} from "lucide-react";
|
|
12
13
|
import type { JSX } from "react";
|
|
14
|
+
import { getSessionPath } from "../../lib/sessionUrl";
|
|
13
15
|
import { cn, formatTokens } from "../../lib/utils";
|
|
14
16
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
15
17
|
import { Badge } from "../ui/badge";
|
|
@@ -34,7 +36,7 @@ export type ConversationHeaderProps = {
|
|
|
34
36
|
expanded: boolean;
|
|
35
37
|
onToggle: () => void;
|
|
36
38
|
/** Hide the API format badge on the header (used when the group contains
|
|
37
|
-
* mixed formats
|
|
39
|
+
* mixed formats - the per-log badges are shown instead). */
|
|
38
40
|
hideApiFormat?: boolean;
|
|
39
41
|
/** When true and the group is collapsed, show a spinner instead of the
|
|
40
42
|
* expand chevron to indicate an in-flight request inside the group. */
|
|
@@ -74,6 +76,14 @@ export function ConversationHeader({
|
|
|
74
76
|
setConfirmOpen(true);
|
|
75
77
|
};
|
|
76
78
|
|
|
79
|
+
const handleOpenInNewTab = useCallback(
|
|
80
|
+
(e: React.MouseEvent | React.KeyboardEvent): void => {
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
window.open(getSessionPath(conversationId), "_blank", "noopener,noreferrer");
|
|
83
|
+
},
|
|
84
|
+
[conversationId],
|
|
85
|
+
);
|
|
86
|
+
|
|
77
87
|
return (
|
|
78
88
|
<div
|
|
79
89
|
role="button"
|
|
@@ -96,7 +106,7 @@ export function ConversationHeader({
|
|
|
96
106
|
}
|
|
97
107
|
}}
|
|
98
108
|
>
|
|
99
|
-
{/* Expand chevron
|
|
109
|
+
{/* Expand chevron - shows spinner when collapsed and group has pending logs */}
|
|
100
110
|
{expanded ? (
|
|
101
111
|
<ChevronDown className="size-4 text-muted-foreground shrink-0" />
|
|
102
112
|
) : isLoading ? (
|
|
@@ -110,9 +120,11 @@ export function ConversationHeader({
|
|
|
110
120
|
className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
|
|
111
121
|
title={conversationId}
|
|
112
122
|
>
|
|
113
|
-
{conversationId.
|
|
114
|
-
? conversationId
|
|
115
|
-
: conversationId
|
|
123
|
+
{conversationId.startsWith("PID:") || conversationId.includes("|")
|
|
124
|
+
? conversationId
|
|
125
|
+
: conversationId.length > 24
|
|
126
|
+
? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
|
|
127
|
+
: conversationId}
|
|
116
128
|
</span>
|
|
117
129
|
|
|
118
130
|
{/* User-Agent */}
|
|
@@ -170,7 +182,23 @@ export function ConversationHeader({
|
|
|
170
182
|
{/* Spacer */}
|
|
171
183
|
<span className="flex-1 min-w-0" />
|
|
172
184
|
|
|
173
|
-
{/*
|
|
185
|
+
{/* Open this session in a new tab - deep link to /session/$id */}
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={handleOpenInNewTab}
|
|
189
|
+
onKeyDown={(e) => {
|
|
190
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
191
|
+
handleOpenInNewTab(e);
|
|
192
|
+
}
|
|
193
|
+
}}
|
|
194
|
+
aria-label={`Open session ${conversationId} in a new tab`}
|
|
195
|
+
title="Open this session in a new tab"
|
|
196
|
+
className="text-muted-foreground hover:text-foreground transition-colors shrink-0 inline-flex items-center justify-center size-8 rounded hover:bg-muted cursor-pointer"
|
|
197
|
+
>
|
|
198
|
+
<ExternalLink className="size-3.5" />
|
|
199
|
+
</button>
|
|
200
|
+
|
|
201
|
+
{/* Per-group Clear - does not toggle the group's expand state */}
|
|
174
202
|
{onClear !== undefined && (
|
|
175
203
|
<button
|
|
176
204
|
type="button"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { JSX } from "react";
|
|
2
|
+
import { cn } from "../../lib/utils";
|
|
3
|
+
import McpLogoPng from "../../assets/logos/mcp.png";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Official Model Context Protocol logo (from Wikimedia Commons). The PNG
|
|
7
|
+
* is a black-on-white raster of the official mark — flipped to a white
|
|
8
|
+
* silhouette on the dark header via `invert` so it reads against the
|
|
9
|
+
* app background.
|
|
10
|
+
*/
|
|
11
|
+
export function McpLogo({ className }: { className?: string }): JSX.Element {
|
|
12
|
+
return (
|
|
13
|
+
<img
|
|
14
|
+
src={McpLogoPng}
|
|
15
|
+
alt="Model Context Protocol"
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
className={cn("inline-block size-8 object-contain invert", className)}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const B64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
2
|
+
|
|
3
|
+
function bytesToBinary(bytes: Uint8Array): string {
|
|
4
|
+
let binary = "";
|
|
5
|
+
for (const byte of bytes) {
|
|
6
|
+
binary += String.fromCharCode(byte);
|
|
7
|
+
}
|
|
8
|
+
return binary;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function binaryToBytes(binary: string): Uint8Array {
|
|
12
|
+
const bytes = new Uint8Array(binary.length);
|
|
13
|
+
for (let i = 0; i < binary.length; i++) {
|
|
14
|
+
bytes[i] = binary.charCodeAt(i);
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function encodeSessionIdForPath(sessionId: string): string {
|
|
20
|
+
const bytes = new TextEncoder().encode(sessionId);
|
|
21
|
+
const base64 = btoa(bytesToBinary(bytes));
|
|
22
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decodeSessionIdFromPath(encoded: string): string {
|
|
26
|
+
if (encoded.startsWith("%")) {
|
|
27
|
+
return decodeURIComponent(encoded);
|
|
28
|
+
}
|
|
29
|
+
if (!B64URL_RE.test(encoded)) {
|
|
30
|
+
return encoded;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=");
|
|
34
|
+
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
35
|
+
const binary = atob(base64);
|
|
36
|
+
return new TextDecoder().decode(binaryToBytes(binary));
|
|
37
|
+
} catch {
|
|
38
|
+
return encoded;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSessionPath(sessionId: string): string {
|
|
43
|
+
return `/session/${encodeSessionIdForPath(sessionId)}`;
|
|
44
|
+
}
|