@tonyclaw/agent-inspector 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-D5A4bTfV.js → CompareDrawer-3nRwtk8J.js} +1 -1
  3. package/.output/public/assets/ProxyViewerContainer-CbW5VRER.js +101 -0
  4. package/.output/public/assets/ReplayDialog-Cl62N9PI.js +1 -0
  5. package/.output/public/assets/RequestAnatomy-DgQWGvjs.js +1 -0
  6. package/.output/public/assets/ResponseView-Cvc-ct4E.js +1 -0
  7. package/.output/public/assets/StreamingChunkSequence-BCQaCAIe.js +1 -0
  8. package/.output/public/assets/_sessionId-CcD_aLGq.js +1 -0
  9. package/.output/public/assets/index-B_dffD3u.js +1 -0
  10. package/.output/public/assets/index-CX796gvi.css +1 -0
  11. package/.output/public/assets/{json-viewer-BbU0n8eM.js → json-viewer-IXejqXB0.js} +1 -1
  12. package/.output/public/assets/{main-CZT_F-gu.js → main-2NlGzgOe.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +181 -114
  14. package/.output/server/{_sessionId-B-s9P7fJ.mjs → _sessionId-DWCTasJU.mjs} +3 -3
  15. package/.output/server/_ssr/{CompareDrawer-C08L3UOO.mjs → CompareDrawer-DhrN1uC2.mjs} +6 -6
  16. package/.output/server/_ssr/{ProxyViewerContainer-CMWl3Ijy.mjs → ProxyViewerContainer-DRl51s_n.mjs} +910 -186
  17. package/.output/server/_ssr/{ReplayDialog-CPDo9_G5.mjs → ReplayDialog-BQT_ygxC.mjs} +240 -14
  18. package/.output/server/_ssr/{RequestAnatomy-D9wt_K1E.mjs → RequestAnatomy-DS2tZOgq.mjs} +5 -5
  19. package/.output/server/_ssr/{ResponseView-DXaL7nY3.mjs → ResponseView-e0kL2C3x.mjs} +25 -21
  20. package/.output/server/_ssr/{StreamingChunkSequence-B_hudZyb.mjs → StreamingChunkSequence-BJG-m7xs.mjs} +3 -3
  21. package/.output/server/_ssr/{index-CuE_BN86.mjs → index-Dea3OeRw.mjs} +2 -2
  22. package/.output/server/_ssr/index.mjs +2 -2
  23. package/.output/server/_ssr/{json-viewer-Ci6kkjde.mjs → json-viewer-DDU55MLK.mjs} +3 -3
  24. package/.output/server/_ssr/{router-BemxgIg7.mjs → router-Dl7oh0zx.mjs} +164 -82
  25. package/.output/server/_tanstack-start-manifest_v-m-FJNBVf.mjs +4 -0
  26. package/.output/server/index.mjs +70 -70
  27. package/package.json +1 -1
  28. package/src/components/OnboardingBanner.tsx +11 -19
  29. package/src/components/ProxyViewer.tsx +26 -16
  30. package/src/components/ProxyViewerContainer.tsx +2 -1
  31. package/src/components/providers/ProviderCard.tsx +6 -20
  32. package/src/components/providers/SettingsDialog.tsx +140 -3
  33. package/src/components/proxy-viewer/AgentTraceSummary.tsx +731 -72
  34. package/src/components/proxy-viewer/AnswerMarkdown.tsx +16 -0
  35. package/src/components/proxy-viewer/CompareDrawer.tsx +4 -2
  36. package/src/components/proxy-viewer/ConversationGroup.tsx +12 -0
  37. package/src/components/proxy-viewer/ConversationHeader.tsx +6 -6
  38. package/src/components/proxy-viewer/LogEntry.tsx +5 -5
  39. package/src/components/proxy-viewer/LogEntryHeader.tsx +21 -36
  40. package/src/components/proxy-viewer/ReplayDialog.tsx +190 -8
  41. package/src/components/proxy-viewer/ResponseView.tsx +4 -8
  42. package/src/components/proxy-viewer/ToolTraceEvents.tsx +37 -17
  43. package/src/components/proxy-viewer/TurnGroup.tsx +18 -2
  44. package/src/components/proxy-viewer/anatomy/SegmentBar.tsx +2 -2
  45. package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +6 -12
  46. package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +2 -2
  47. package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +10 -14
  48. package/src/components/proxy-viewer/replayComparison.ts +131 -0
  49. package/src/components/proxy-viewer/useKeyboardNavigation.ts +64 -22
  50. package/src/components/proxy-viewer/viewerState.ts +14 -2
  51. package/src/knowledge/candidateStore.ts +32 -1
  52. package/src/lib/runtimeConfig.ts +6 -0
  53. package/src/lib/timeDisplay.ts +22 -0
  54. package/src/lib/useOnboarding.ts +2 -0
  55. package/src/lib/useStripConfig.ts +16 -0
  56. package/src/proxy/config.ts +3 -0
  57. package/src/routes/api/config.ts +5 -1
  58. package/src/routes/api/knowledge.candidates.$candidateId.ts +50 -0
  59. package/src/routes/api/knowledge.sessions.$sessionId.candidates.ts +12 -2
  60. package/.output/public/assets/ProxyViewerContainer-Da0jpBkp.js +0 -101
  61. package/.output/public/assets/ReplayDialog-CxUk_TF0.js +0 -1
  62. package/.output/public/assets/RequestAnatomy-DIlzjgjJ.js +0 -1
  63. package/.output/public/assets/ResponseView-DQCuKJ1G.js +0 -1
  64. package/.output/public/assets/StreamingChunkSequence-DHk4SGGL.js +0 -1
  65. package/.output/public/assets/_sessionId-dY1TTl7N.js +0 -1
  66. package/.output/public/assets/index-D7wwbwly.css +0 -1
  67. package/.output/public/assets/index-FqQZbfl2.js +0 -1
  68. package/.output/server/_tanstack-start-manifest_v--L1_b4sd.mjs +0 -4
@@ -0,0 +1,16 @@
1
+ import type { JSX } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+
4
+ const ANSWER_MARKDOWN_CLASS =
5
+ "prose prose-sm dark:prose-invert max-w-none text-[13px] leading-[1.65] " +
6
+ "[&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] " +
7
+ "[&_p]:my-1 [&_p]:leading-[1.65] [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 " +
8
+ "[&_li]:leading-[1.6]";
9
+
10
+ export function AnswerMarkdown({ text }: { text: string }): JSX.Element {
11
+ return (
12
+ <div className={ANSWER_MARKDOWN_CLASS}>
13
+ <ReactMarkdown>{text}</ReactMarkdown>
14
+ </div>
15
+ );
16
+ }
@@ -550,11 +550,13 @@ function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }
550
550
  <div className="flex items-center gap-3 text-muted-foreground font-mono">
551
551
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
552
552
  <span className="text-emerald-400">
553
- Cache +{formatTokens(log.cacheCreationInputTokens)}
553
+ KV Cache +{formatTokens(log.cacheCreationInputTokens)}
554
554
  </span>
555
555
  )}
556
556
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
557
- <span className="text-purple-400">Cache ~{formatTokens(log.cacheReadInputTokens)}</span>
557
+ <span className="text-purple-400">
558
+ KV Cache ~{formatTokens(log.cacheReadInputTokens)}
559
+ </span>
558
560
  )}
559
561
  <span className="truncate" title={log.timestamp}>
560
562
  {log.timestamp}
@@ -1,5 +1,6 @@
1
1
  import { useState, memo, useMemo } from "react";
2
2
  import type { JSX } from "react";
3
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
3
4
  import type { CapturedLog } from "../../proxy/schemas";
4
5
  import {
5
6
  ConversationHeader,
@@ -29,6 +30,10 @@ export type ConversationGroupProps = {
29
30
  comparisonPredecessors: Map<number, CapturedLog>;
30
31
  /** When true, skip the group header and render content directly. */
31
32
  standalone?: boolean;
33
+ /** When true, the route-level session context already shows rollup metrics. */
34
+ hasPinnedSessionContext?: boolean;
35
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
36
+ timeDisplayFormat: TimeDisplayFormat;
32
37
  /** Clear all logs that belong to this group. */
33
38
  onClearGroup: (ids: number[]) => void;
34
39
  };
@@ -56,6 +61,8 @@ export const ConversationGroup = memo(function ({
56
61
  comparisonPredecessors,
57
62
  onClearGroup,
58
63
  standalone = false,
64
+ hasPinnedSessionContext = false,
65
+ timeDisplayFormat,
59
66
  }: ConversationGroupProps): JSX.Element {
60
67
  const [expanded, setExpanded] = useState(false);
61
68
  const stats = useMemo(() => computeStats(group.logs), [group.logs]);
@@ -63,6 +70,7 @@ export const ConversationGroup = memo(function ({
63
70
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
64
71
  const mixed = hasMixedApiFormat(group.logs);
65
72
  const isLoading = group.logs.some((log) => log.responseStatus === null);
73
+ const showTraceRollupMetrics = standalone && !hasPinnedSessionContext;
66
74
 
67
75
  // Pre-compute stop reasons for each log — used by turnIndices
68
76
  const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
@@ -82,6 +90,7 @@ export const ConversationGroup = memo(function ({
82
90
  hideApiFormat={mixed}
83
91
  isLoading={isLoading}
84
92
  userAgent={group.logs[0]?.userAgent ?? null}
93
+ timeDisplayFormat={timeDisplayFormat}
85
94
  onClear={() => onClearGroup(group.logs.map((l) => l.id))}
86
95
  />
87
96
  )}
@@ -92,6 +101,8 @@ export const ConversationGroup = memo(function ({
92
101
  logs={group.logs}
93
102
  scopeId={group.conversationId}
94
103
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
104
+ showRollupMetrics={showTraceRollupMetrics}
105
+ timeDisplayFormat={timeDisplayFormat}
95
106
  />
96
107
  {turnGroups.map((tg) => (
97
108
  <TurnGroup
@@ -100,6 +111,7 @@ export const ConversationGroup = memo(function ({
100
111
  viewMode={viewMode}
101
112
  strip={strip}
102
113
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
114
+ timeDisplayFormat={timeDisplayFormat}
103
115
  cacheTrends={cacheTrends}
104
116
  onCompareWithPrevious={onCompareWithPrevious}
105
117
  comparisonPredecessors={comparisonPredecessors}
@@ -12,6 +12,8 @@ import {
12
12
  } from "lucide-react";
13
13
  import type { JSX } from "react";
14
14
  import { getSessionPath } from "../../lib/sessionUrl";
15
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
16
+ import { formatTimestampRange } from "../../lib/timeDisplay";
15
17
  import { cn, formatTokens } from "../../lib/utils";
16
18
  import type { CapturedLog } from "../../proxy/schemas";
17
19
  import { Badge } from "../ui/badge";
@@ -43,16 +45,13 @@ export type ConversationHeaderProps = {
43
45
  isLoading?: boolean;
44
46
  /** User-Agent string from the first log in the group. */
45
47
  userAgent?: string | null;
48
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
49
+ timeDisplayFormat: TimeDisplayFormat;
46
50
  /** Clear all logs in this group. After confirmation the parent removes them
47
51
  * from the in-memory store; this header is then unmounted. */
48
52
  onClear?: () => void;
49
53
  };
50
54
 
51
- function formatTimestamp(iso: string): string {
52
- const date = new Date(iso);
53
- return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
54
- }
55
-
56
55
  export function ConversationHeader({
57
56
  conversationId,
58
57
  startTime,
@@ -66,6 +65,7 @@ export function ConversationHeader({
66
65
  hideApiFormat = false,
67
66
  isLoading = false,
68
67
  userAgent,
68
+ timeDisplayFormat,
69
69
  onClear,
70
70
  }: ConversationHeaderProps): JSX.Element {
71
71
  const [confirmOpen, setConfirmOpen] = useState(false);
@@ -157,7 +157,7 @@ export function ConversationHeader({
157
157
  <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
158
158
  <Clock className="size-3" />
159
159
  <span className="font-mono tabular-nums">
160
- {formatTimestamp(startTime)} - {formatTimestamp(endTime)}
160
+ {formatTimestampRange(startTime, endTime, timeDisplayFormat)}
161
161
  </span>
162
162
  </span>
163
163
 
@@ -2,6 +2,7 @@ import { GitCompareArrows } from "lucide-react";
2
2
  import { Suspense, type JSX } from "react";
3
3
  import { useMemo, useRef, useState, memo } from "react";
4
4
  import type { CapturedLog } from "../../proxy/schemas";
5
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
5
6
  import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
6
7
  import { Button } from "../ui/button";
7
8
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
@@ -48,6 +49,8 @@ export type LogEntryProps = {
48
49
  strip: boolean;
49
50
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
50
51
  slowResponseThresholdSeconds: number;
52
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
53
+ timeDisplayFormat: TimeDisplayFormat;
51
54
  /**
52
55
  * Per-log cache token trend, looked up in the viewer-level trend map.
53
56
  * `null` (or absent) means the header should render with no arrows.
@@ -119,6 +122,7 @@ export const LogEntry = memo(function ({
119
122
  viewMode = "simple",
120
123
  strip,
121
124
  slowResponseThresholdSeconds,
125
+ timeDisplayFormat,
122
126
  cacheTrend = null,
123
127
  onCompareWithPrevious,
124
128
  }: LogEntryProps): JSX.Element {
@@ -135,10 +139,6 @@ export const LogEntry = memo(function ({
135
139
  () => adapter.analyzeRequest(log.rawRequestBody),
136
140
  [adapter, log.rawRequestBody],
137
141
  );
138
- const responseAnalysis = useMemo(
139
- () => adapter.analyzeResponse(log.responseText),
140
- [adapter, log.responseText],
141
- );
142
142
  const strippedRequestBody = useMemo(() => {
143
143
  if (!strip || resolvedFormat !== "anthropic" || log.rawRequestBody === null) {
144
144
  return null;
@@ -296,9 +296,9 @@ export const LogEntry = memo(function ({
296
296
  toolCount={requestAnalysis.toolCount}
297
297
  expanded={expanded}
298
298
  onToggle={() => setExpanded(!expanded)}
299
- responseToolNames={responseAnalysis.toolNames}
300
299
  cacheTrend={cacheTrend}
301
300
  slowResponseThresholdSeconds={slowResponseThresholdSeconds}
301
+ timeDisplayFormat={timeDisplayFormat}
302
302
  activeTab={activeTab}
303
303
  tabActions={tabActions}
304
304
  onReplay={
@@ -22,7 +22,9 @@ import {
22
22
  Zap,
23
23
  } from "lucide-react";
24
24
  import type { JSX, MouseEvent } from "react";
25
- import { memo, useMemo } from "react";
25
+ import { memo } from "react";
26
+ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
27
+ import { formatTimestamp } from "../../lib/timeDisplay";
26
28
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
27
29
  import type { CapturedLog } from "../../proxy/schemas";
28
30
  import { Badge } from "../ui/badge";
@@ -105,8 +107,6 @@ export type LogEntryHeaderProps = {
105
107
  toolCount?: number | null;
106
108
  expanded: boolean;
107
109
  onToggle: () => void;
108
- /** Tool call names extracted from the model response (e.g., ["read_file", "grep"]). */
109
- responseToolNames?: string[] | null;
110
110
  /** Per-log cache token trend (creation + read) relative to the previous log
111
111
  * in the same conversation group. When `undefined` or a field is `null`,
112
112
  * the corresponding cache span renders as it did before — no arrow.
@@ -124,6 +124,8 @@ export type LogEntryHeaderProps = {
124
124
  onReplay?: () => void;
125
125
  /** Slow-response threshold in seconds. `0` disables the warning indicator. */
126
126
  slowResponseThresholdSeconds?: number;
127
+ /** Controls whether timestamps render as compact local time or full ISO strings. */
128
+ timeDisplayFormat: TimeDisplayFormat;
127
129
  };
128
130
 
129
131
  export const LogEntryHeader = memo(function ({
@@ -132,12 +134,12 @@ export const LogEntryHeader = memo(function ({
132
134
  toolCount = null,
133
135
  expanded,
134
136
  onToggle,
135
- responseToolNames = null,
136
137
  cacheTrend = null,
137
138
  activeTab,
138
139
  tabActions,
139
140
  onReplay,
140
141
  slowResponseThresholdSeconds = 0,
142
+ timeDisplayFormat,
141
143
  }: LogEntryHeaderProps): JSX.Element {
142
144
  const statusCategory = getStatusCategory(log.responseStatus);
143
145
  const isSlowResponse =
@@ -146,7 +148,6 @@ export const LogEntryHeader = memo(function ({
146
148
  log.elapsedMs > slowResponseThresholdSeconds * 1000;
147
149
 
148
150
  const hasTokens = log.inputTokens !== null || log.outputTokens !== null;
149
- const toolNamesJoined = useMemo(() => responseToolNames?.join(", ") ?? null, [responseToolNames]);
150
151
 
151
152
  return (
152
153
  <TooltipProvider>
@@ -178,7 +179,9 @@ export const LogEntryHeader = memo(function ({
178
179
  {/* Request start time */}
179
180
  <span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
180
181
  <Clock className="size-3" />
181
- <span className="font-mono tabular-nums">{log.timestamp}</span>
182
+ <span className="font-mono tabular-nums" title={log.timestamp}>
183
+ {formatTimestamp(log.timestamp, timeDisplayFormat)}
184
+ </span>
182
185
  </span>
183
186
 
184
187
  {/* Model — logo icon only, model name in tooltip */}
@@ -261,30 +264,20 @@ export const LogEntryHeader = memo(function ({
261
264
  )}
262
265
  {/* Cache tokens */}
263
266
  {log.cacheCreationInputTokens !== null && log.cacheCreationInputTokens > 0 && (
264
- <Tooltip>
265
- <TooltipTrigger asChild>
266
- <span className="flex items-center gap-1 text-xs shrink-0">
267
- <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
268
- <span className="font-mono tabular-nums text-emerald-400">
269
- Cache +{formatTokens(log.cacheCreationInputTokens)}
270
- </span>
271
- </span>
272
- </TooltipTrigger>
273
- <TooltipContent>Tokens cached for reuse, reducing future API cost</TooltipContent>
274
- </Tooltip>
267
+ <span className="flex items-center gap-1 text-xs shrink-0">
268
+ <CacheTrendIndicator trend={cacheTrend?.creation ?? null} />
269
+ <span className="font-mono tabular-nums text-emerald-400">
270
+ KV Cache +{formatTokens(log.cacheCreationInputTokens)}
271
+ </span>
272
+ </span>
275
273
  )}
276
274
  {log.cacheReadInputTokens !== null && log.cacheReadInputTokens > 0 && (
277
- <Tooltip>
278
- <TooltipTrigger asChild>
279
- <span className="flex items-center gap-1 text-xs shrink-0">
280
- <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
281
- <span className="font-mono tabular-nums text-purple-400">
282
- Cache ~{formatTokens(log.cacheReadInputTokens)}
283
- </span>
284
- </span>
285
- </TooltipTrigger>
286
- <TooltipContent>Tokens served from cache, reducing API cost</TooltipContent>
287
- </Tooltip>
275
+ <span className="flex items-center gap-1 text-xs shrink-0">
276
+ <CacheTrendIndicator trend={cacheTrend?.read ?? null} />
277
+ <span className="font-mono tabular-nums text-purple-400">
278
+ KV Cache ~{formatTokens(log.cacheReadInputTokens)}
279
+ </span>
280
+ </span>
288
281
  )}
289
282
 
290
283
  {/* Message count */}
@@ -303,14 +296,6 @@ export const LogEntryHeader = memo(function ({
303
296
  </span>
304
297
  )}
305
298
 
306
- {/* Response tool calls — tool names the model requested to invoke */}
307
- {responseToolNames !== null && responseToolNames.length > 0 && (
308
- <span className="flex items-center gap-1 text-amber-400/80 text-xs shrink-0">
309
- <Wrench className="size-3" />
310
- <span className="font-mono tabular-nums truncate max-w-[160px]">{toolNamesJoined}</span>
311
- </span>
312
- )}
313
-
314
299
  {/* Origin */}
315
300
  {log.origin !== null && (
316
301
  <span
@@ -1,22 +1,31 @@
1
1
  import { RotateCcw } from "lucide-react";
2
2
  import type { JSX } from "react";
3
- import { useState } from "react";
3
+ import { useMemo, useState } from "react";
4
4
  import { z } from "zod";
5
+ import { useProviders } from "../../lib/useProviders";
5
6
  import { Button } from "../ui/button";
6
7
  import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "../ui/tooltip";
7
8
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
8
9
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
9
10
  import { ResponseView } from "./ResponseView";
10
11
  import type { CapturedLog } from "../../proxy/schemas";
12
+ import {
13
+ buildReplayComparisons,
14
+ buildReplayMetrics,
15
+ readRequestModel,
16
+ replaceRequestModel,
17
+ type ReplayMetricComparison,
18
+ type ReplayMetricValue,
19
+ } from "./replayComparison";
11
20
 
12
21
  const ReplayResultSchema = z.object({
13
22
  success: z.boolean(),
14
23
  error: z.string().optional(),
15
- responseStatus: z.number().optional(),
16
- responseText: z.string().optional(),
17
- inputTokens: z.number().optional(),
18
- outputTokens: z.number().optional(),
19
- elapsedMs: z.number().optional(),
24
+ responseStatus: z.number().nullable().optional(),
25
+ responseText: z.string().nullable().optional(),
26
+ inputTokens: z.number().nullable().optional(),
27
+ outputTokens: z.number().nullable().optional(),
28
+ elapsedMs: z.number().nullable().optional(),
20
29
  streaming: z.boolean().optional(),
21
30
  });
22
31
 
@@ -28,7 +37,91 @@ type ReplayDialogProps = {
28
37
  onOpenChange: (open: boolean) => void;
29
38
  };
30
39
 
40
+ type ReplayModelOption = {
41
+ key: string;
42
+ providerName: string;
43
+ model: string;
44
+ };
45
+
46
+ function formatElapsed(ms: number): string {
47
+ if (ms < 1000) return `${String(ms)}ms`;
48
+ return `${(ms / 1000).toFixed(1)}s`;
49
+ }
50
+
51
+ function formatMetricValue(
52
+ value: ReplayMetricValue,
53
+ metricId: ReplayMetricComparison["id"],
54
+ ): string {
55
+ if (value === null) return "-";
56
+ if (typeof value === "boolean") return value ? "stream" : "non-stream";
57
+ switch (metricId) {
58
+ case "elapsed":
59
+ return formatElapsed(value);
60
+ case "input":
61
+ case "output":
62
+ case "bytes":
63
+ return value.toLocaleString();
64
+ case "status":
65
+ return String(value);
66
+ case "streaming":
67
+ return value ? "stream" : "non-stream";
68
+ }
69
+ }
70
+
71
+ function formatDelta(delta: number | null, metricId: ReplayMetricComparison["id"]): string {
72
+ if (delta === null) return "-";
73
+ const sign = delta > 0 ? "+" : "";
74
+ switch (metricId) {
75
+ case "elapsed":
76
+ return `${sign}${formatElapsed(delta)}`;
77
+ case "input":
78
+ case "output":
79
+ case "bytes":
80
+ case "status":
81
+ return `${sign}${delta.toLocaleString()}`;
82
+ case "streaming":
83
+ return "-";
84
+ }
85
+ }
86
+
87
+ function deltaToneClass(delta: number | null): string {
88
+ if (delta === null || delta === 0) return "text-muted-foreground";
89
+ if (delta > 0) return "text-amber-400";
90
+ return "text-emerald-400";
91
+ }
92
+
93
+ function ReplayComparisonTable({
94
+ comparisons,
95
+ }: {
96
+ comparisons: ReplayMetricComparison[];
97
+ }): JSX.Element {
98
+ return (
99
+ <div className="overflow-hidden rounded-md border border-border">
100
+ <div className="grid grid-cols-[1fr_1fr_1fr_1fr] bg-muted/40 px-3 py-2 text-[11px] font-medium text-muted-foreground">
101
+ <span>Metric</span>
102
+ <span>Original</span>
103
+ <span>Replay</span>
104
+ <span>Delta</span>
105
+ </div>
106
+ {comparisons.map((comparison) => (
107
+ <div
108
+ key={comparison.id}
109
+ className="grid grid-cols-[1fr_1fr_1fr_1fr] border-t border-border px-3 py-2 text-xs"
110
+ >
111
+ <span className="text-muted-foreground">{comparison.label}</span>
112
+ <span className="font-mono">{formatMetricValue(comparison.original, comparison.id)}</span>
113
+ <span className="font-mono">{formatMetricValue(comparison.replay, comparison.id)}</span>
114
+ <span className={`font-mono ${deltaToneClass(comparison.delta)}`}>
115
+ {formatDelta(comparison.delta, comparison.id)}
116
+ </span>
117
+ </div>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
31
123
  export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JSX.Element {
124
+ const { providers } = useProviders();
32
125
  const [modifiedBody, setModifiedBody] = useState<string>(() => {
33
126
  return log.rawRequestBody ?? "{}";
34
127
  });
@@ -73,6 +166,55 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
73
166
  onOpenChange(false);
74
167
  }
75
168
 
169
+ const replayModelOptions = useMemo<ReplayModelOption[]>(() => {
170
+ const options: ReplayModelOption[] = [];
171
+ for (const provider of providers) {
172
+ for (const model of provider.models) {
173
+ if (model.trim().length === 0) continue;
174
+ options.push({
175
+ key: `${provider.id}:${model}`,
176
+ providerName: provider.name,
177
+ model,
178
+ });
179
+ }
180
+ }
181
+ return options;
182
+ }, [providers]);
183
+ const currentReplayModel = readRequestModel(modifiedBody) ?? log.model ?? "";
184
+
185
+ function handleReplayModelChange(model: string): void {
186
+ const result = replaceRequestModel(modifiedBody, model);
187
+ if (result.error !== null) {
188
+ setError(result.error);
189
+ return;
190
+ }
191
+ setModifiedBody(result.body);
192
+ setReplayResult(null);
193
+ setError(null);
194
+ }
195
+
196
+ const originalMetrics = buildReplayMetrics({
197
+ status: log.responseStatus,
198
+ elapsedMs: log.elapsedMs,
199
+ inputTokens: log.inputTokens,
200
+ outputTokens: log.outputTokens,
201
+ responseText: log.responseText,
202
+ streaming: log.streaming,
203
+ });
204
+ const replayMetrics =
205
+ replayResult === null
206
+ ? null
207
+ : buildReplayMetrics({
208
+ status: replayResult.responseStatus,
209
+ elapsedMs: replayResult.elapsedMs,
210
+ inputTokens: replayResult.inputTokens,
211
+ outputTokens: replayResult.outputTokens,
212
+ responseText: replayResult.responseText,
213
+ streaming: replayResult.streaming,
214
+ });
215
+ const replayComparisons =
216
+ replayMetrics === null ? [] : buildReplayComparisons(originalMetrics, replayMetrics);
217
+
76
218
  return (
77
219
  <Dialog open={open} onOpenChange={handleClose}>
78
220
  <DialogContent className="max-w-4xl max-h-[85vh] overflow-auto">
@@ -88,9 +230,34 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
88
230
  <TabsTrigger value="modified">Modified Request</TabsTrigger>
89
231
  <TabsTrigger value="original">Original Response</TabsTrigger>
90
232
  {replayResult && <TabsTrigger value="replay">Replay Response</TabsTrigger>}
233
+ {replayResult && <TabsTrigger value="compare">Compare</TabsTrigger>}
91
234
  </TabsList>
92
235
 
93
236
  <TabsContent value="modified" className="space-y-4">
237
+ {replayModelOptions.length > 0 && (
238
+ <div className="grid gap-1.5">
239
+ <label htmlFor={`replay-model-${String(log.id)}`} className="text-sm font-medium">
240
+ Replay target
241
+ </label>
242
+ <select
243
+ id={`replay-model-${String(log.id)}`}
244
+ value={currentReplayModel}
245
+ onChange={(event) => handleReplayModelChange(event.currentTarget.value)}
246
+ className="h-8 rounded-md border border-input bg-background px-2 text-sm"
247
+ >
248
+ {currentReplayModel === "" && <option value="">Select model</option>}
249
+ {currentReplayModel !== "" &&
250
+ !replayModelOptions.some((option) => option.model === currentReplayModel) && (
251
+ <option value={currentReplayModel}>{currentReplayModel}</option>
252
+ )}
253
+ {replayModelOptions.map((option) => (
254
+ <option key={option.key} value={option.model}>
255
+ {option.providerName} / {option.model}
256
+ </option>
257
+ ))}
258
+ </select>
259
+ </div>
260
+ )}
94
261
  <div>
95
262
  <label className="text-sm font-medium mb-2 block">Request Body (JSON)</label>
96
263
  <TooltipProvider>
@@ -127,6 +294,10 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
127
294
  </Button>
128
295
  </div>
129
296
 
297
+ {replayResult !== null && (
298
+ <ReplayComparisonTable comparisons={replayComparisons.slice(0, 4)} />
299
+ )}
300
+
130
301
  {replayResult && replayResult.success && (
131
302
  <Tabs defaultValue="parsed">
132
303
  <TabsList>
@@ -184,7 +355,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
184
355
 
185
356
  {replayResult && replayResult.success && (
186
357
  <TabsContent value="replay">
187
- {replayResult.responseText !== null ? (
358
+ {(replayResult.responseText ?? null) !== null ? (
188
359
  <Tabs defaultValue="parsed">
189
360
  <TabsList>
190
361
  <TabsTrigger value="parsed">Response</TabsTrigger>
@@ -202,7 +373,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
202
373
  </TabsContent>
203
374
  <TabsContent value="raw">
204
375
  <pre className="font-mono text-xs whitespace-pre-wrap bg-muted p-3 rounded-md max-h-96 overflow-auto">
205
- {replayResult.responseText}
376
+ {replayResult.responseText ?? ""}
206
377
  </pre>
207
378
  </TabsContent>
208
379
  </Tabs>
@@ -211,6 +382,17 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
211
382
  )}
212
383
  </TabsContent>
213
384
  )}
385
+
386
+ {replayResult && (
387
+ <TabsContent value="compare" className="space-y-3">
388
+ <ReplayComparisonTable comparisons={replayComparisons} />
389
+ {replayResult.success ? null : (
390
+ <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
391
+ {replayResult.error ?? "Replay failed"}
392
+ </div>
393
+ )}
394
+ </TabsContent>
395
+ )}
214
396
  </Tabs>
215
397
  </DialogContent>
216
398
  </Dialog>
@@ -2,9 +2,9 @@ import { AlertTriangle, Zap } from "lucide-react";
2
2
  import type { JSX } from "react";
3
3
  import { memo } from "react";
4
4
  import { useMemo } from "react";
5
- import ReactMarkdown from "react-markdown";
6
5
  import { cn, formatTokens, getStatusCategory, type StatusCategory } from "../../lib/utils";
7
6
  import type { RequestFormat } from "../../proxy/schemas";
7
+ import { AnswerMarkdown } from "./AnswerMarkdown";
8
8
  import { formatViewFor } from "./formats";
9
9
  import { getLogFormatAdapter } from "./log-formats";
10
10
 
@@ -61,11 +61,7 @@ function ErrorResponseView({ text }: { text: string }): JSX.Element {
61
61
  }
62
62
 
63
63
  function MarkdownFallbackView({ text }: { text: string }): JSX.Element {
64
- return (
65
- <div className="prose prose-sm dark:prose-invert max-w-none [&_pre]:bg-muted [&_pre]:text-foreground [&_code]:text-[0.8em] [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1">
66
- <ReactMarkdown>{text}</ReactMarkdown>
67
- </div>
68
- );
64
+ return <AnswerMarkdown text={text} />;
69
65
  }
70
66
 
71
67
  export const ResponseView = memo(function ResponseView({
@@ -152,14 +148,14 @@ export const ResponseView = memo(function ResponseView({
152
148
  cacheCreationInputTokens !== undefined &&
153
149
  cacheCreationInputTokens > 0 && (
154
150
  <span className="font-mono tabular-nums text-emerald-400">
155
- Cache +{formatTokens(cacheCreationInputTokens)}
151
+ KV Cache +{formatTokens(cacheCreationInputTokens)}
156
152
  </span>
157
153
  )}
158
154
  {cacheReadInputTokens !== null &&
159
155
  cacheReadInputTokens !== undefined &&
160
156
  cacheReadInputTokens > 0 && (
161
157
  <span className="font-mono tabular-nums text-purple-400">
162
- Cache ~{formatTokens(cacheReadInputTokens)}
158
+ KV Cache ~{formatTokens(cacheReadInputTokens)}
163
159
  </span>
164
160
  )}
165
161
  </span>