@tonyclaw/llm-inspector 1.15.0 → 1.16.0
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/cli.js +1 -0
- package/.output/nitro.json +1 -1
- package/.output/public/assets/index-BmkN9DxE.js +107 -0
- package/.output/public/assets/index-DPe3eOih.css +1 -0
- package/.output/public/assets/{main-BLYgekFx.js → main-BjnjXVBU.js} +1 -1
- package/.output/server/_libs/diff.mjs +2 -2
- package/.output/server/_ssr/{index-P66uoVEU.mjs → index-BIOEVAzU.mjs} +783 -588
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DpLCKk51.mjs → router-THS9ptvu.mjs} +439 -177
- package/.output/server/{_tanstack-start-manifest_v-C9Wq6YdJ.mjs → _tanstack-start-manifest_v-BYhN7q_z.mjs} +1 -1
- package/.output/server/index.mjs +31 -31
- package/README.md +200 -113
- package/package.json +1 -1
- package/src/cli.ts +1 -0
- package/src/components/ProxyViewer.tsx +77 -85
- package/src/components/ProxyViewerContainer.tsx +148 -76
- package/src/components/providers/ImportWizardDialog.tsx +27 -3
- package/src/components/proxy-viewer/CompareDrawer.tsx +17 -4
- package/src/components/proxy-viewer/ConversationGroup.tsx +15 -47
- package/src/components/proxy-viewer/ConversationHeader.tsx +58 -5
- package/src/components/proxy-viewer/LogEntry.tsx +297 -329
- package/src/components/proxy-viewer/LogEntryHeader.tsx +126 -137
- package/src/components/proxy-viewer/ResponseView.tsx +14 -34
- package/src/components/proxy-viewer/StreamingChunkSequence.tsx +3 -3
- package/src/components/proxy-viewer/TurnGroup.tsx +25 -21
- package/src/components/proxy-viewer/diff/DiffView.tsx +5 -3
- package/src/components/proxy-viewer/formats/anthropic/ContentBlocks.tsx +13 -9
- package/src/components/proxy-viewer/formats/anthropic/ResponseView.tsx +3 -3
- package/src/components/proxy-viewer/formats/index.tsx +19 -10
- package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +7 -3
- package/src/components/proxy-viewer/log-formats/anthropic.ts +48 -0
- package/src/components/proxy-viewer/log-formats/index.ts +23 -0
- package/src/components/proxy-viewer/log-formats/openai.ts +40 -0
- package/src/components/proxy-viewer/log-formats/types.ts +33 -0
- package/src/components/proxy-viewer/log-formats/unknown.ts +14 -0
- package/src/components/proxy-viewer/viewerState.ts +58 -0
- package/src/components/ui/json-viewer.tsx +3 -3
- package/src/lib/objectUtils.ts +22 -0
- package/src/proxy/claudeCodeStrip.ts +5 -8
- package/src/proxy/formats/index.ts +1 -1
- package/src/proxy/formats/registry.ts +9 -0
- package/src/proxy/handler.ts +2 -8
- package/src/proxy/logIndex.ts +58 -43
- package/src/proxy/logger.ts +51 -27
- package/src/proxy/openaiOrphanToolStrip.ts +11 -17
- package/src/proxy/providerImporters.ts +245 -19
- package/src/proxy/providers.ts +20 -7
- package/src/proxy/schemas.ts +5 -9
- package/src/proxy/socketTracker.ts +109 -78
- package/src/proxy/store.ts +68 -83
- package/src/routes/api/logs.ts +31 -2
- package/styles/globals.css +22 -0
- package/.output/public/assets/index-CMuJQyt1.js +0 -105
- package/.output/public/assets/index-DciyfYBk.css +0 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { type JSX, useCallback, useEffect, useMemo, useState
|
|
2
|
-
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
1
|
+
import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
|
|
3
2
|
import { Download } from "lucide-react";
|
|
4
3
|
|
|
5
4
|
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./ui/tooltip";
|
|
@@ -14,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|
|
14
13
|
import { SettingsDialog } from "./providers/SettingsDialog";
|
|
15
14
|
import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
|
|
16
15
|
import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
|
|
16
|
+
import { buildValidPredecessors } from "./proxy-viewer/viewerState";
|
|
17
17
|
|
|
18
18
|
function truncateSessionId(id: string): string {
|
|
19
19
|
if (id.length <= 30) return id;
|
|
@@ -91,6 +91,9 @@ export type ProxyViewerProps = {
|
|
|
91
91
|
onSessionChange: (session: string) => void;
|
|
92
92
|
onModelChange: (model: string) => void;
|
|
93
93
|
onClearAll: () => void;
|
|
94
|
+
/** Clear only the logs whose ids are passed. Called by the per-group
|
|
95
|
+
* Clear button on each conversation header. */
|
|
96
|
+
onClearGroup: (ids: number[]) => void;
|
|
94
97
|
viewMode: "simple" | "full";
|
|
95
98
|
onViewModeChange: (mode: "simple" | "full") => void;
|
|
96
99
|
/** Live strip-Claude-Code-billing-header flag, sourced once at the container. */
|
|
@@ -106,13 +109,29 @@ export function ProxyViewer({
|
|
|
106
109
|
onSessionChange,
|
|
107
110
|
onModelChange,
|
|
108
111
|
onClearAll,
|
|
112
|
+
onClearGroup,
|
|
109
113
|
viewMode,
|
|
110
114
|
onViewModeChange,
|
|
111
115
|
strip,
|
|
112
116
|
}: ProxyViewerProps): JSX.Element {
|
|
113
|
-
const { totalIn, totalOut } = computeTokenSummary(logs);
|
|
117
|
+
const { totalIn, totalOut } = useMemo(() => computeTokenSummary(logs), [logs]);
|
|
114
118
|
const [exporting, setExporting] = useState(false);
|
|
115
119
|
const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
|
|
120
|
+
const [crabEntrancePhase, setCrabEntrancePhase] = useState<"hidden" | "playing" | "done">(
|
|
121
|
+
"hidden",
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const perCrabDuration = 400;
|
|
126
|
+
const startDelay = 50;
|
|
127
|
+
const playingDone = startDelay + crabVariants.length * perCrabDuration;
|
|
128
|
+
const t1 = setTimeout(() => setCrabEntrancePhase("playing"), startDelay);
|
|
129
|
+
const t2 = setTimeout(() => setCrabEntrancePhase("done"), playingDone);
|
|
130
|
+
return () => {
|
|
131
|
+
clearTimeout(t1);
|
|
132
|
+
clearTimeout(t2);
|
|
133
|
+
};
|
|
134
|
+
}, []);
|
|
116
135
|
|
|
117
136
|
const handleExport = useCallback(async () => {
|
|
118
137
|
setExporting(true);
|
|
@@ -122,8 +141,6 @@ export function ProxyViewer({
|
|
|
122
141
|
setExporting(false);
|
|
123
142
|
}
|
|
124
143
|
}, [logs]);
|
|
125
|
-
const parentRef = useRef<HTMLDivElement>(null);
|
|
126
|
-
|
|
127
144
|
// Close the compare drawer when the user changes the session or model
|
|
128
145
|
// filter, since the predecessor relationship may no longer be meaningful.
|
|
129
146
|
useEffect(() => {
|
|
@@ -134,62 +151,60 @@ export function ProxyViewer({
|
|
|
134
151
|
setComparePair(null);
|
|
135
152
|
}, []);
|
|
136
153
|
|
|
154
|
+
const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
|
|
155
|
+
const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
|
|
156
|
+
const comparisonPredecessors = useMemo(() => buildValidPredecessors(groups), [groups]);
|
|
137
157
|
const handleCompareWithPrevious = useCallback(
|
|
138
158
|
(log: CapturedLog) => {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
const predecessor = logs[idx - 1];
|
|
142
|
-
if (predecessor === undefined) return;
|
|
143
|
-
setComparePair([predecessor, log]);
|
|
159
|
+
const predecessor = comparisonPredecessors.get(log.id);
|
|
160
|
+
if (predecessor !== undefined) setComparePair([predecessor, log]);
|
|
144
161
|
},
|
|
145
|
-
[
|
|
162
|
+
[comparisonPredecessors],
|
|
146
163
|
);
|
|
147
164
|
|
|
148
|
-
const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
|
|
149
|
-
const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
|
|
150
|
-
|
|
151
|
-
const rowVirtualizer = useVirtualizer({
|
|
152
|
-
count: groups.length,
|
|
153
|
-
getScrollElement: () => parentRef.current,
|
|
154
|
-
estimateSize: () => 150,
|
|
155
|
-
measureElement:
|
|
156
|
-
typeof window !== "undefined"
|
|
157
|
-
? (element) => element.getBoundingClientRect().height
|
|
158
|
-
: undefined,
|
|
159
|
-
overscan: 5,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
165
|
return (
|
|
163
166
|
<div className="max-w-[1200px] mx-auto flex flex-col h-screen" style={{ maxHeight: "100vh" }}>
|
|
164
167
|
{/* Brand row */}
|
|
165
|
-
<div className="flex items-end px-6 pt-6 pb-
|
|
168
|
+
<div className="flex items-end px-6 pt-6 pb-8 relative">
|
|
166
169
|
<h1 className="text-lg font-bold flex items-end gap-2 absolute left-1/2 -translate-x-1/2 whitespace-nowrap">
|
|
167
170
|
{/* Crab family — hover to animate together */}
|
|
168
171
|
<span className="flex items-end gap-1 group cursor-default" aria-hidden="true">
|
|
169
172
|
<CrabLogo className="size-10 text-amber-500 transition-all duration-300 group-hover:scale-125 group-hover:-translate-y-1.5" />
|
|
170
173
|
<span className="flex items-end gap-0.5">
|
|
171
|
-
{crabVariants.map((Crab, i) =>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
174
|
+
{crabVariants.map((Crab, i) => {
|
|
175
|
+
const color = [
|
|
176
|
+
"text-amber-500",
|
|
177
|
+
"text-rose-500",
|
|
178
|
+
"text-sky-500",
|
|
179
|
+
"text-emerald-500",
|
|
180
|
+
"text-violet-500",
|
|
181
|
+
"text-orange-500",
|
|
182
|
+
"text-cyan-500",
|
|
183
|
+
"text-pink-500",
|
|
184
|
+
"text-lime-500",
|
|
185
|
+
"text-blue-500",
|
|
186
|
+
"text-yellow-500",
|
|
187
|
+
"text-fuchsia-500",
|
|
188
|
+
][i];
|
|
189
|
+
const entranceClass =
|
|
190
|
+
crabEntrancePhase === "hidden"
|
|
191
|
+
? "opacity-0 scale-0"
|
|
192
|
+
: crabEntrancePhase === "playing"
|
|
193
|
+
? "animate-crab-piano-pop"
|
|
194
|
+
: "";
|
|
195
|
+
return (
|
|
196
|
+
<Crab
|
|
197
|
+
key={i}
|
|
198
|
+
className={`size-5 ${color} transition-all duration-300 ease-out group-hover:scale-125 group-hover:-translate-y-1 ${entranceClass}`}
|
|
199
|
+
style={{
|
|
200
|
+
transitionDelay: `${i * 50}ms`,
|
|
201
|
+
...(crabEntrancePhase === "playing"
|
|
202
|
+
? { animationDelay: `${i * 400}ms` }
|
|
203
|
+
: {}),
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
})}
|
|
193
208
|
</span>
|
|
194
209
|
</span>
|
|
195
210
|
<span className="flex items-baseline gap-2">
|
|
@@ -304,48 +319,25 @@ export function ProxyViewer({
|
|
|
304
319
|
<div className="flex-1 min-h-0 px-6 pb-6">
|
|
305
320
|
{logs.length === 0 ? (
|
|
306
321
|
<div className="text-center text-muted-foreground py-16 space-y-4">
|
|
307
|
-
<CrabLogo className="size-12 text-muted-foreground/20 mx-auto" />
|
|
308
322
|
<p className="text-sm">No requests captured yet.</p>
|
|
309
323
|
<p className="text-xs">Route AI coding tools through the proxy:</p>
|
|
310
324
|
<CopyableCommand command="LLM_BASE_URL=http://localhost:25947/proxy <your-tool>" />
|
|
311
325
|
</div>
|
|
312
326
|
) : (
|
|
313
|
-
<div
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
data-index={virtualRow.index}
|
|
328
|
-
ref={rowVirtualizer.measureElement}
|
|
329
|
-
style={{
|
|
330
|
-
position: "absolute",
|
|
331
|
-
top: 0,
|
|
332
|
-
left: 0,
|
|
333
|
-
width: "100%",
|
|
334
|
-
transform: `translateY(${virtualRow.start}px)`,
|
|
335
|
-
}}
|
|
336
|
-
>
|
|
337
|
-
<ConversationGroup
|
|
338
|
-
group={group}
|
|
339
|
-
viewMode={viewMode}
|
|
340
|
-
strip={strip}
|
|
341
|
-
cacheTrends={cacheTrends}
|
|
342
|
-
onCompareWithPrevious={handleCompareWithPrevious}
|
|
343
|
-
standalone={groups.length === 1}
|
|
344
|
-
/>
|
|
345
|
-
</div>
|
|
346
|
-
);
|
|
347
|
-
})}
|
|
348
|
-
</div>
|
|
327
|
+
<div className="overflow-y-auto h-full flex flex-col gap-2">
|
|
328
|
+
{groups.map((group) => (
|
|
329
|
+
<ConversationGroup
|
|
330
|
+
key={group.id}
|
|
331
|
+
group={group}
|
|
332
|
+
viewMode={viewMode}
|
|
333
|
+
strip={strip}
|
|
334
|
+
cacheTrends={cacheTrends}
|
|
335
|
+
onCompareWithPrevious={handleCompareWithPrevious}
|
|
336
|
+
comparisonPredecessors={comparisonPredecessors}
|
|
337
|
+
onClearGroup={onClearGroup}
|
|
338
|
+
standalone={groups.length === 1}
|
|
339
|
+
/>
|
|
340
|
+
))}
|
|
349
341
|
</div>
|
|
350
342
|
)}
|
|
351
343
|
</div>
|
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef, type JSX } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo, type JSX } from "react";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { CapturedLogSchema, type CapturedLog } from "../proxy/schemas";
|
|
4
4
|
import { useStripConfig } from "../lib/useStripConfig";
|
|
5
5
|
import { ProxyViewer } from "./ProxyViewer";
|
|
6
6
|
|
|
7
|
-
type logsResponse = {
|
|
8
|
-
logs: CapturedLog[];
|
|
9
|
-
total: number;
|
|
10
|
-
offset: number;
|
|
11
|
-
limit: number;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
const LogsResponseSchema = z.object({
|
|
15
|
-
logs: z.array(CapturedLogSchema),
|
|
16
|
-
total: z.number(),
|
|
17
|
-
offset: z.number(),
|
|
18
|
-
limit: z.number(),
|
|
19
|
-
});
|
|
20
|
-
|
|
21
7
|
type SSEUpdate =
|
|
22
8
|
| {
|
|
23
9
|
type: "init";
|
|
@@ -55,8 +41,31 @@ function extractModels(logs: CapturedLog[]): string[] {
|
|
|
55
41
|
return [...set];
|
|
56
42
|
}
|
|
57
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Filter logs by selected session/model. Both `__all__` and missing values
|
|
46
|
+
* pass through. Returns the input array reference when no log matches the
|
|
47
|
+
* filter, so referential equality is preserved for empty results.
|
|
48
|
+
*/
|
|
49
|
+
function filterLogs(
|
|
50
|
+
logs: CapturedLog[],
|
|
51
|
+
selectedSession: string,
|
|
52
|
+
selectedModel: string,
|
|
53
|
+
): CapturedLog[] {
|
|
54
|
+
if (selectedSession === "__all__" && selectedModel === "__all__") return logs;
|
|
55
|
+
return logs.filter((l) => {
|
|
56
|
+
if (selectedSession !== "__all__" && l.sessionId !== selectedSession) return false;
|
|
57
|
+
if (selectedModel !== "__all__" && l.model !== selectedModel) return false;
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEBOUNCE_MS = 50;
|
|
63
|
+
|
|
58
64
|
export function ProxyViewerContainer(): JSX.Element {
|
|
59
|
-
|
|
65
|
+
// `allLogs` is the unfiltered set populated by the SSE. The single SSE
|
|
66
|
+
// connection never re-opens on filter change — we always carry the full
|
|
67
|
+
// set and derive the displayed view with `useMemo` below.
|
|
68
|
+
const [allLogs, setAllLogs] = useState<CapturedLog[]>([]);
|
|
60
69
|
const [sessions, setSessions] = useState<string[]>([]);
|
|
61
70
|
const [models, setModels] = useState<string[]>([]);
|
|
62
71
|
const [selectedSession, setSelectedSession] = useState("__all__");
|
|
@@ -66,24 +75,63 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
66
75
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
67
76
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// O(1) log lookup by id
|
|
79
|
+
const logIndexRef = useRef<Map<number, number>>(new Map());
|
|
80
|
+
const logsRef = useRef<CapturedLog[]>([]);
|
|
81
|
+
|
|
82
|
+
// Debounce buffer for SSE updates
|
|
83
|
+
const pendingUpdatesRef = useRef<CapturedLog[]>([]);
|
|
84
|
+
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
85
|
+
|
|
86
|
+
// Derived view: the logs the user actually sees. Recomputed only when the
|
|
87
|
+
// underlying set or filter changes — never on every SSE message.
|
|
88
|
+
const logs = useMemo(
|
|
89
|
+
() => filterLogs(allLogs, selectedSession, selectedModel),
|
|
90
|
+
[allLogs, selectedSession, selectedModel],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const flushUpdates = useCallback(() => {
|
|
94
|
+
flushTimerRef.current = null;
|
|
95
|
+
const updates = pendingUpdatesRef.current;
|
|
96
|
+
pendingUpdatesRef.current = [];
|
|
97
|
+
if (updates.length === 0) return;
|
|
98
|
+
|
|
99
|
+
setAllLogs((prev) => {
|
|
100
|
+
let next = prev;
|
|
101
|
+
let sessionsChanged = false;
|
|
102
|
+
let modelsChanged = false;
|
|
103
|
+
for (const log of updates) {
|
|
104
|
+
const idx = logIndexRef.current.get(log.id);
|
|
105
|
+
if (idx !== undefined) {
|
|
106
|
+
// Replace existing entry
|
|
107
|
+
next = [...next.slice(0, idx), log, ...next.slice(idx + 1)];
|
|
108
|
+
} else {
|
|
109
|
+
logIndexRef.current.set(log.id, next.length);
|
|
110
|
+
next = [...next, log];
|
|
111
|
+
}
|
|
112
|
+
if (log.sessionId !== null && log.sessionId !== "" && !sessions.includes(log.sessionId)) {
|
|
113
|
+
sessionsChanged = true;
|
|
114
|
+
}
|
|
115
|
+
if (log.model !== null && log.model !== "" && !models.includes(log.model)) {
|
|
116
|
+
modelsChanged = true;
|
|
117
|
+
}
|
|
82
118
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
119
|
+
logsRef.current = next;
|
|
120
|
+
if (sessionsChanged) setSessions((s) => extractSessions(next));
|
|
121
|
+
if (modelsChanged) setModels((m) => extractModels(next));
|
|
122
|
+
return next;
|
|
123
|
+
});
|
|
124
|
+
}, [sessions, models]);
|
|
125
|
+
|
|
126
|
+
const scheduleUpdate = useCallback(
|
|
127
|
+
(log: CapturedLog) => {
|
|
128
|
+
pendingUpdatesRef.current.push(log);
|
|
129
|
+
if (flushTimerRef.current === null) {
|
|
130
|
+
flushTimerRef.current = setTimeout(flushUpdates, DEBOUNCE_MS);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
[flushUpdates],
|
|
134
|
+
);
|
|
87
135
|
|
|
88
136
|
const connectSSE = useCallback(() => {
|
|
89
137
|
// Clean up existing connection
|
|
@@ -91,11 +139,12 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
91
139
|
eventSourceRef.current.close();
|
|
92
140
|
}
|
|
93
141
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
142
|
+
// Stable, unfiltered connection. The frontend derives filtered views
|
|
143
|
+
// from the complete set, so the SSE never needs to reopen on filter
|
|
144
|
+
// change. If the in-memory set ever grows past what the client can
|
|
145
|
+
// handle, swap this back to a parameterized URL and reconnect on
|
|
146
|
+
// filter change.
|
|
147
|
+
const es = new EventSource("/api/logs/stream");
|
|
99
148
|
eventSourceRef.current = es;
|
|
100
149
|
|
|
101
150
|
es.onmessage = (event: MessageEvent) => {
|
|
@@ -109,42 +158,27 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
109
158
|
}
|
|
110
159
|
const update = updateResult.data;
|
|
111
160
|
if (update.type === "init") {
|
|
112
|
-
|
|
161
|
+
// Flush any pending debounce
|
|
162
|
+
if (flushTimerRef.current !== null) {
|
|
163
|
+
clearTimeout(flushTimerRef.current);
|
|
164
|
+
flushTimerRef.current = null;
|
|
165
|
+
}
|
|
166
|
+
pendingUpdatesRef.current = [];
|
|
167
|
+
|
|
168
|
+
// Build fresh index
|
|
169
|
+
const idx = new Map<number, number>();
|
|
170
|
+
for (let i = 0; i < update.logs.length; i++) {
|
|
171
|
+
const log = update.logs[i];
|
|
172
|
+
if (log !== undefined) idx.set(log.id, i);
|
|
173
|
+
}
|
|
174
|
+
logIndexRef.current = idx;
|
|
175
|
+
logsRef.current = update.logs;
|
|
176
|
+
setAllLogs(update.logs);
|
|
113
177
|
setSessions(extractSessions(update.logs));
|
|
114
178
|
setModels(extractModels(update.logs));
|
|
115
179
|
setError(null);
|
|
116
180
|
} else if (update.type === "update") {
|
|
117
|
-
|
|
118
|
-
// Filter by current selection before adding
|
|
119
|
-
const sessionMatch =
|
|
120
|
-
selectedSession === "__all__" || update.log.sessionId === selectedSession;
|
|
121
|
-
const modelMatch = selectedModel === "__all__" || update.log.model === selectedModel;
|
|
122
|
-
if (!sessionMatch || !modelMatch) return prev;
|
|
123
|
-
|
|
124
|
-
// Update existing log or add as new
|
|
125
|
-
const existsIndex = prev.findIndex((l) => l.id === update.log.id);
|
|
126
|
-
if (existsIndex >= 0) {
|
|
127
|
-
// Replace the existing log with updated data
|
|
128
|
-
return prev.map((l) => (l.id === update.log.id ? update.log : l));
|
|
129
|
-
}
|
|
130
|
-
// Add to end (newest)
|
|
131
|
-
return [...prev, update.log];
|
|
132
|
-
});
|
|
133
|
-
// Update sessions and models
|
|
134
|
-
setSessions((prev) => {
|
|
135
|
-
const sessionId = update.log.sessionId;
|
|
136
|
-
if (sessionId !== null && sessionId !== undefined && sessionId !== "") {
|
|
137
|
-
return prev.includes(sessionId) ? prev : [...prev, sessionId];
|
|
138
|
-
}
|
|
139
|
-
return prev;
|
|
140
|
-
});
|
|
141
|
-
setModels((prev) => {
|
|
142
|
-
const model = update.log.model;
|
|
143
|
-
if (model !== null && model !== undefined && model !== "") {
|
|
144
|
-
return prev.includes(model) ? prev : [...prev, model];
|
|
145
|
-
}
|
|
146
|
-
return prev;
|
|
147
|
-
});
|
|
181
|
+
scheduleUpdate(update.log);
|
|
148
182
|
}
|
|
149
183
|
} catch {
|
|
150
184
|
setError("Failed to parse SSE data");
|
|
@@ -154,16 +188,12 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
154
188
|
es.onerror = () => {
|
|
155
189
|
setError("SSE connection lost, reconnecting...");
|
|
156
190
|
es.close();
|
|
157
|
-
// Clear any existing reconnect timeout
|
|
158
191
|
if (reconnectTimeoutRef.current !== null) {
|
|
159
192
|
clearTimeout(reconnectTimeoutRef.current);
|
|
160
193
|
}
|
|
161
|
-
// Reconnect after 3 seconds
|
|
162
194
|
reconnectTimeoutRef.current = setTimeout(connectSSE, 3000);
|
|
163
195
|
};
|
|
164
|
-
|
|
165
|
-
void fetchSessionsAndModels();
|
|
166
|
-
}, [selectedSession, selectedModel, fetchSessionsAndModels]);
|
|
196
|
+
}, [scheduleUpdate]);
|
|
167
197
|
|
|
168
198
|
useEffect(() => {
|
|
169
199
|
connectSSE();
|
|
@@ -176,6 +206,10 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
176
206
|
clearTimeout(reconnectTimeoutRef.current);
|
|
177
207
|
reconnectTimeoutRef.current = null;
|
|
178
208
|
}
|
|
209
|
+
if (flushTimerRef.current !== null) {
|
|
210
|
+
clearTimeout(flushTimerRef.current);
|
|
211
|
+
flushTimerRef.current = null;
|
|
212
|
+
}
|
|
179
213
|
};
|
|
180
214
|
}, [connectSSE]);
|
|
181
215
|
|
|
@@ -187,7 +221,9 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
187
221
|
setError("Failed to clear logs");
|
|
188
222
|
return;
|
|
189
223
|
}
|
|
190
|
-
|
|
224
|
+
logIndexRef.current.clear();
|
|
225
|
+
logsRef.current = [];
|
|
226
|
+
setAllLogs([]);
|
|
191
227
|
setSessions([]);
|
|
192
228
|
setModels([]);
|
|
193
229
|
setError(null);
|
|
@@ -197,6 +233,41 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
197
233
|
})();
|
|
198
234
|
}, []);
|
|
199
235
|
|
|
236
|
+
const handleClearGroup = useCallback((ids: number[]) => {
|
|
237
|
+
if (ids.length === 0) return;
|
|
238
|
+
void (async () => {
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch("/api/logs", {
|
|
241
|
+
method: "DELETE",
|
|
242
|
+
headers: { "Content-Type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ ids }),
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
setError("Failed to clear group");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const idSet = new Set(ids);
|
|
250
|
+
setAllLogs((prev) => {
|
|
251
|
+
const remaining = prev.filter((l) => !idSet.has(l.id));
|
|
252
|
+
// Rebuild index
|
|
253
|
+
const idx = new Map<number, number>();
|
|
254
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
255
|
+
const log = remaining[i];
|
|
256
|
+
if (log !== undefined) idx.set(log.id, i);
|
|
257
|
+
}
|
|
258
|
+
logIndexRef.current = idx;
|
|
259
|
+
logsRef.current = remaining;
|
|
260
|
+
setSessions(extractSessions(remaining));
|
|
261
|
+
setModels(extractModels(remaining));
|
|
262
|
+
return remaining;
|
|
263
|
+
});
|
|
264
|
+
setError(null);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
setError(err instanceof Error ? err.message : "Unknown error clearing group");
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
200
271
|
// Read the strip config once at the container so the virtualized list does
|
|
201
272
|
// not need N independent SWR subscriptions per row.
|
|
202
273
|
const { strip } = useStripConfig();
|
|
@@ -217,6 +288,7 @@ export function ProxyViewerContainer(): JSX.Element {
|
|
|
217
288
|
onSessionChange={setSelectedSession}
|
|
218
289
|
onModelChange={setSelectedModel}
|
|
219
290
|
onClearAll={handleClearAll}
|
|
291
|
+
onClearGroup={handleClearGroup}
|
|
220
292
|
viewMode={viewMode}
|
|
221
293
|
onViewModeChange={setViewMode}
|
|
222
294
|
strip={strip}
|
|
@@ -38,6 +38,29 @@ function OpenCodeIcon(): JSX.Element {
|
|
|
38
38
|
);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function MiMoCodeIcon(): JSX.Element {
|
|
42
|
+
return (
|
|
43
|
+
<svg
|
|
44
|
+
fill="currentColor"
|
|
45
|
+
viewBox="0 0 24 24"
|
|
46
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
47
|
+
className="size-6 shrink-0"
|
|
48
|
+
>
|
|
49
|
+
<title>MiMo Code</title>
|
|
50
|
+
<text
|
|
51
|
+
x="12"
|
|
52
|
+
y="18"
|
|
53
|
+
textAnchor="middle"
|
|
54
|
+
fontSize="16"
|
|
55
|
+
fontWeight="800"
|
|
56
|
+
fontFamily="system-ui, sans-serif"
|
|
57
|
+
>
|
|
58
|
+
M
|
|
59
|
+
</text>
|
|
60
|
+
</svg>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
41
64
|
const ExternalProviderSchema = z.object({
|
|
42
65
|
name: z.string(),
|
|
43
66
|
apiKey: z.string(),
|
|
@@ -45,7 +68,7 @@ const ExternalProviderSchema = z.object({
|
|
|
45
68
|
anthropicBaseUrl: z.string(),
|
|
46
69
|
openaiBaseUrl: z.string(),
|
|
47
70
|
models: z.array(z.string()),
|
|
48
|
-
sourceTool: z.enum(["claude-code", "opencode"]),
|
|
71
|
+
sourceTool: z.enum(["claude-code", "opencode", "mimo-code"]),
|
|
49
72
|
alreadyExists: z.boolean(),
|
|
50
73
|
});
|
|
51
74
|
|
|
@@ -64,6 +87,7 @@ const ImportResponseSchema = z.object({
|
|
|
64
87
|
const sourceLogoMap: Record<string, () => JSX.Element> = {
|
|
65
88
|
"claude-code": ClaudeCodeIcon,
|
|
66
89
|
opencode: OpenCodeIcon,
|
|
90
|
+
"mimo-code": MiMoCodeIcon,
|
|
67
91
|
};
|
|
68
92
|
|
|
69
93
|
type ImportWizardDialogProps = {
|
|
@@ -213,7 +237,7 @@ export function ImportWizardDialog({
|
|
|
213
237
|
<DialogHeader>
|
|
214
238
|
<DialogTitle>Import from External Tools</DialogTitle>
|
|
215
239
|
<p className="text-xs text-muted-foreground">
|
|
216
|
-
Detect provider configurations from Claude Code and
|
|
240
|
+
Detect provider configurations from Claude Code, OpenCode, and MiMo Code.
|
|
217
241
|
</p>
|
|
218
242
|
</DialogHeader>
|
|
219
243
|
|
|
@@ -238,7 +262,7 @@ export function ImportWizardDialog({
|
|
|
238
262
|
<br />
|
|
239
263
|
<span className="text-xs">
|
|
240
264
|
Supported tools: Claude Code (~/.claude/settings.json), OpenCode
|
|
241
|
-
(~/.config/opencode/opencode.json)
|
|
265
|
+
(~/.config/opencode/opencode.json), MiMo Code (~/.config/mimocode/mimocode.jsonc)
|
|
242
266
|
</span>
|
|
243
267
|
</p>
|
|
244
268
|
)}
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
} from "lucide-react";
|
|
15
15
|
import { cn, formatTokens } from "../../lib/utils";
|
|
16
16
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
17
|
-
import { parseRequest } from "../../proxy/schemas";
|
|
18
17
|
import {
|
|
19
18
|
type DiffOp,
|
|
20
19
|
type JsonNode,
|
|
@@ -25,6 +24,7 @@ import {
|
|
|
25
24
|
import { getConversationId } from "./ConversationHeader";
|
|
26
25
|
import { JsonViewerFromString } from "../ui/json-viewer";
|
|
27
26
|
import { Badge } from "../ui/badge";
|
|
27
|
+
import { getLogFormatAdapter, resolveLogFormat } from "./log-formats";
|
|
28
28
|
|
|
29
29
|
export type CompareDrawerProps = {
|
|
30
30
|
/** Log selected first (shown on the left). */
|
|
@@ -570,10 +570,23 @@ function SideSummary({ log, side }: { log: CapturedLog; side: "left" | "right" }
|
|
|
570
570
|
export function CompareDrawer({ left, right, onClose }: CompareDrawerProps): JSX.Element {
|
|
571
571
|
// Memoize the diff so re-renders (e.g. parent re-renders) don't recompute.
|
|
572
572
|
const ops = useMemo<DiffOp[]>(() => {
|
|
573
|
-
const
|
|
574
|
-
|
|
573
|
+
const leftRequest = getLogFormatAdapter(resolveLogFormat(left)).analyzeRequest(
|
|
574
|
+
left.rawRequestBody,
|
|
575
|
+
);
|
|
576
|
+
const rightRequest = getLogFormatAdapter(resolveLogFormat(right)).analyzeRequest(
|
|
577
|
+
right.rawRequestBody,
|
|
578
|
+
);
|
|
579
|
+
const l = normalizeRequest(leftRequest.comparisonValue);
|
|
580
|
+
const r = normalizeRequest(rightRequest.comparisonValue);
|
|
575
581
|
return diffTrees(l, r);
|
|
576
|
-
}, [
|
|
582
|
+
}, [
|
|
583
|
+
left.apiFormat,
|
|
584
|
+
left.path,
|
|
585
|
+
left.rawRequestBody,
|
|
586
|
+
right.apiFormat,
|
|
587
|
+
right.path,
|
|
588
|
+
right.rawRequestBody,
|
|
589
|
+
]);
|
|
577
590
|
|
|
578
591
|
const grouped = useMemo(() => groupContiguousEquals(ops), [ops]);
|
|
579
592
|
|