@tonyclaw/llm-inspector 1.12.0 → 1.14.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/nitro.json +1 -1
- package/.output/public/assets/index-B5q3Llgm.css +1 -0
- package/.output/public/assets/index-C6tbslcs.js +105 -0
- package/.output/public/assets/{main-BYCM7aJx.js → main-C1k6vRnH.js} +3 -3
- package/.output/server/_libs/cfworker__json-schema.mjs +1 -0
- package/.output/server/_libs/lucide-react.mjs +64 -58
- package/.output/server/_libs/modelcontextprotocol__server.mjs +9738 -0
- package/.output/server/_libs/zod.mjs +79 -16
- package/.output/server/_ssr/{index-DhChP_jV.mjs → index-AxruZp16.mjs} +1201 -165
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-PZjNwOcw.mjs → router-DtleGqN8.mjs} +650 -29
- package/.output/server/_tanstack-start-manifest_v-B1WAHWIa.mjs +4 -0
- package/.output/server/index.mjs +24 -24
- package/README.md +98 -0
- package/package.json +3 -1
- package/src/components/ProxyViewer.tsx +126 -2
- package/src/components/proxy-viewer/CompareDrawer.tsx +880 -0
- package/src/components/proxy-viewer/ConversationGroup.tsx +8 -0
- package/src/components/proxy-viewer/LogEntry.tsx +14 -1
- package/src/components/proxy-viewer/LogEntryHeader.tsx +28 -0
- package/src/components/proxy-viewer/formats/openai/ResponseView.tsx +74 -4
- package/src/components/proxy-viewer/requestDiff.ts +277 -0
- package/src/lib/serverPort.ts +41 -0
- package/src/mcp/loopback.ts +76 -0
- package/src/mcp/previewExtractor.ts +166 -0
- package/src/mcp/server.ts +320 -0
- package/src/mcp/toolHandlers.ts +259 -0
- package/src/proxy/formats/openai/schemas.ts +19 -0
- package/src/proxy/handler.ts +23 -2
- package/src/proxy/openaiOrphanToolStrip.ts +148 -0
- package/src/proxy/schemas.ts +1 -0
- package/src/routes/api/mcp.ts +25 -0
- package/.output/public/assets/index-DVgdkDgq.js +0 -105
- package/.output/public/assets/index-DZx2yk8v.css +0 -1
- package/.output/server/_tanstack-start-manifest_v-l1kWkG0h.mjs +0 -4
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
const tsrStartManifest = () => ({ "routes": { "__root__": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/__root.tsx", "children": ["/", "/api/config", "/api/health", "/api/logs", "/api/mcp", "/api/models", "/api/providers", "/api/sessions", "/proxy/$"], "preloads": ["/assets/main-C1k6vRnH.js"], "assets": [] }, "/": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/index.tsx", "assets": [], "preloads": ["/assets/index-C6tbslcs.js"] }, "/api/config": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.ts", "children": ["/api/config/paths"] }, "/api/health": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/health.ts" }, "/api/logs": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.ts", "children": ["/api/logs/$id", "/api/logs/stream"] }, "/api/mcp": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/mcp.ts" }, "/api/models": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/models.ts" }, "/api/providers": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.ts", "children": ["/api/providers/$providerId", "/api/providers/export", "/api/providers/import"] }, "/api/sessions": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/sessions.ts" }, "/proxy/$": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/proxy/$.ts" }, "/api/config/paths": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/config.paths.ts" }, "/api/logs/$id": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.ts", "children": ["/api/logs/$id/chunks", "/api/logs/$id/replay"] }, "/api/logs/stream": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.stream.ts" }, "/api/providers/$providerId": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.ts", "children": ["/api/providers/$providerId/test"] }, "/api/providers/export": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.export.ts" }, "/api/providers/import": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.import.ts" }, "/api/logs/$id/chunks": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.chunks.ts" }, "/api/logs/$id/replay": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/logs.$id.replay.ts" }, "/api/providers/$providerId/test": { "filePath": "C:/Users/claw/workspace/llm-inspector/src/routes/api/providers.$providerId.test.ts" } }, "clientEntry": "/assets/main-C1k6vRnH.js" });
|
|
2
|
+
export {
|
|
3
|
+
tsrStartManifest
|
|
4
|
+
};
|
package/.output/server/index.mjs
CHANGED
|
@@ -38,51 +38,51 @@ const assets = {
|
|
|
38
38
|
"/assets/alibaba-TTwafVwX.svg": {
|
|
39
39
|
"type": "image/svg+xml",
|
|
40
40
|
"etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
|
|
41
|
-
"mtime": "2026-06-
|
|
41
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
42
42
|
"size": 5915,
|
|
43
43
|
"path": "../public/assets/alibaba-TTwafVwX.svg"
|
|
44
44
|
},
|
|
45
|
-
"/assets/
|
|
46
|
-
"type": "image/jpeg",
|
|
47
|
-
"etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
|
|
48
|
-
"mtime": "2026-06-09T08:02:30.828Z",
|
|
49
|
-
"size": 6918,
|
|
50
|
-
"path": "../public/assets/minimax-BPMzvuL-.jpeg"
|
|
51
|
-
},
|
|
52
|
-
"/assets/index-DZx2yk8v.css": {
|
|
45
|
+
"/assets/index-B5q3Llgm.css": {
|
|
53
46
|
"type": "text/css; charset=utf-8",
|
|
54
|
-
"etag": '"
|
|
55
|
-
"mtime": "2026-06-
|
|
56
|
-
"size":
|
|
57
|
-
"path": "../public/assets/index-
|
|
47
|
+
"etag": '"13e7d-80ipn+yqCPCa5t/vCr1MDg4cP4g"',
|
|
48
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
49
|
+
"size": 81533,
|
|
50
|
+
"path": "../public/assets/index-B5q3Llgm.css"
|
|
58
51
|
},
|
|
59
52
|
"/assets/zhipuai-BPNAnxo-.svg": {
|
|
60
53
|
"type": "image/svg+xml",
|
|
61
54
|
"etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
|
|
62
|
-
"mtime": "2026-06-
|
|
55
|
+
"mtime": "2026-06-10T06:24:38.529Z",
|
|
63
56
|
"size": 11256,
|
|
64
57
|
"path": "../public/assets/zhipuai-BPNAnxo-.svg"
|
|
65
58
|
},
|
|
66
|
-
"/assets/main-
|
|
59
|
+
"/assets/main-C1k6vRnH.js": {
|
|
67
60
|
"type": "text/javascript; charset=utf-8",
|
|
68
|
-
"etag": '"50599-
|
|
69
|
-
"mtime": "2026-06-
|
|
61
|
+
"etag": '"50599-AYrUeIFAuGF/4DPZ+5lAMslWd7Q"',
|
|
62
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
70
63
|
"size": 329113,
|
|
71
|
-
"path": "../public/assets/main-
|
|
64
|
+
"path": "../public/assets/main-C1k6vRnH.js"
|
|
65
|
+
},
|
|
66
|
+
"/assets/minimax-BPMzvuL-.jpeg": {
|
|
67
|
+
"type": "image/jpeg",
|
|
68
|
+
"etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
|
|
69
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
70
|
+
"size": 6918,
|
|
71
|
+
"path": "../public/assets/minimax-BPMzvuL-.jpeg"
|
|
72
72
|
},
|
|
73
73
|
"/assets/qwen-CONDcHqt.png": {
|
|
74
74
|
"type": "image/png",
|
|
75
75
|
"etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
|
|
76
|
-
"mtime": "2026-06-
|
|
76
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
77
77
|
"size": 357059,
|
|
78
78
|
"path": "../public/assets/qwen-CONDcHqt.png"
|
|
79
79
|
},
|
|
80
|
-
"/assets/index-
|
|
80
|
+
"/assets/index-C6tbslcs.js": {
|
|
81
81
|
"type": "text/javascript; charset=utf-8",
|
|
82
|
-
"etag": '"
|
|
83
|
-
"mtime": "2026-06-
|
|
84
|
-
"size":
|
|
85
|
-
"path": "../public/assets/index-
|
|
82
|
+
"etag": '"90252-JCPOfSZg2vTS69tqlOJ4qp75AeM"',
|
|
83
|
+
"mtime": "2026-06-10T06:24:38.532Z",
|
|
84
|
+
"size": 590418,
|
|
85
|
+
"path": "../public/assets/index-C6tbslcs.js"
|
|
86
86
|
}
|
|
87
87
|
};
|
|
88
88
|
function readAsset(id) {
|
package/README.md
CHANGED
|
@@ -77,6 +77,103 @@ ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>
|
|
|
77
77
|
|
|
78
78
|
---
|
|
79
79
|
|
|
80
|
+
## MCP Server (Coding Agent Integration)
|
|
81
|
+
|
|
82
|
+
llm-inspector ships with an embedded [Model Context Protocol](https://modelcontextprotocol.io)
|
|
83
|
+
(MCP) server, mounted at `POST http://localhost:25947/api/mcp` on the same port as
|
|
84
|
+
the Web UI. Any MCP-aware Coding Agent running on the same machine can
|
|
85
|
+
discover and invoke 11 `inspector_*` tools to read captured traffic and
|
|
86
|
+
manage provider configuration without a human round-trip.
|
|
87
|
+
|
|
88
|
+
### Tool catalog (11 tools, all prefixed `inspector_`)
|
|
89
|
+
|
|
90
|
+
**Read (6) — cheap discovery of what was just sent**
|
|
91
|
+
|
|
92
|
+
| Tool | Purpose |
|
|
93
|
+
|------|---------|
|
|
94
|
+
| `inspector_list_logs` | Recent captured logs in reverse-chronological order, with parsed previews of the last user message and the assistant response. Default limit 3, hard-capped at 5. |
|
|
95
|
+
| `inspector_get_log` | Full `CapturedLog` for a single id, including `rawRequestBody` and `responseText` with no truncation. Streaming chunks are NOT included. |
|
|
96
|
+
| `inspector_get_log_chunks` | SSE streaming chunks for a single log, in order received. |
|
|
97
|
+
| `inspector_list_sessions` | All session ids that have been observed in captured logs. |
|
|
98
|
+
| `inspector_list_models` | Distinct model names seen across all logs. |
|
|
99
|
+
| `inspector_list_providers` | All configured providers, including `apiKey` in plaintext. |
|
|
100
|
+
|
|
101
|
+
**Action (2)**
|
|
102
|
+
|
|
103
|
+
| Tool | Purpose |
|
|
104
|
+
|------|---------|
|
|
105
|
+
| `inspector_get_provider` | Full `ProviderConfig` for a single id, `apiKey` in plaintext. |
|
|
106
|
+
| `inspector_replay_log` | Re-send a captured request body to its upstream and return the response summary. |
|
|
107
|
+
|
|
108
|
+
**Provider write (3) — mutate provider config**
|
|
109
|
+
|
|
110
|
+
| Tool | Purpose |
|
|
111
|
+
|------|---------|
|
|
112
|
+
| `inspector_add_provider` | Persist a new provider to `<dataDir>/providers.json`. |
|
|
113
|
+
| `inspector_update_provider` | Patch-style update of an existing provider. |
|
|
114
|
+
| `inspector_test_provider` | Run the UI's connectivity test against a provider. |
|
|
115
|
+
|
|
116
|
+
**Explicitly NOT exposed** (kept UI-only by design):
|
|
117
|
+
|
|
118
|
+
- `delete_provider` — irreversible.
|
|
119
|
+
- `clear_log_cache` — only clears the in-memory LRU; misleads agents that expect a full wipe.
|
|
120
|
+
- `get_runtime_config` / `set_runtime_config` — runtime policy surface; UI is the source of truth.
|
|
121
|
+
|
|
122
|
+
### Connecting an MCP client
|
|
123
|
+
|
|
124
|
+
The server speaks **HTTP Streamable transport** (JSON-RPC over HTTP). The
|
|
125
|
+
simplest way to point a client at it is to register the endpoint URL. For
|
|
126
|
+
example, in Claude Code's `.mcp.json`:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"mcpServers": {
|
|
131
|
+
"llm-inspector": {
|
|
132
|
+
"type": "http",
|
|
133
|
+
"url": "http://localhost:25947/api/mcp"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
For raw clients that send JSON-RPC directly:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
curl -X POST http://localhost:25947/api/mcp \
|
|
143
|
+
-H 'content-type: application/json' \
|
|
144
|
+
-H 'accept: application/json, text/event-stream' \
|
|
145
|
+
-d '{
|
|
146
|
+
"jsonrpc": "2.0",
|
|
147
|
+
"id": 1,
|
|
148
|
+
"method": "initialize",
|
|
149
|
+
"params": {
|
|
150
|
+
"protocolVersion": "2025-06-18",
|
|
151
|
+
"capabilities": {},
|
|
152
|
+
"clientInfo": { "name": "my-client", "version": "0.0.0" }
|
|
153
|
+
}
|
|
154
|
+
}'
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Posture and limitations
|
|
158
|
+
|
|
159
|
+
- **Localhost only, no auth.** The MCP endpoint inherits inspector's
|
|
160
|
+
existing localhost binding. Any process that can call `/api/mcp` can also
|
|
161
|
+
read `<dataDir>/providers.json` directly. Do not run inspector on a
|
|
162
|
+
host where untrusted local users can reach it.
|
|
163
|
+
- **Provider API keys are returned in plaintext** by `inspector_list_providers`
|
|
164
|
+
and `inspector_get_provider`. This is intentional — redacting in MCP but
|
|
165
|
+
not in the underlying JSON file would be performative security.
|
|
166
|
+
- **Reflexive loop:** `inspector_list_logs` may include the agent's own
|
|
167
|
+
recent `/proxy` calls. Filter by `clientPid` (your own PID) or by
|
|
168
|
+
timestamp on the client side. The tool description surfaces this warning.
|
|
169
|
+
- **List limits are hardcoded** at default 3, max 5. Not user-configurable.
|
|
170
|
+
- **Deferred to v1.1** (not in this change): MCP Resources for logs
|
|
171
|
+
(`mcp://llm-inspector/log/{id}`), notifications/subscriptions for live
|
|
172
|
+
log push, filtering out reflexive-loop entries server-side, full-text
|
|
173
|
+
or time-range search, MCP-level auth.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
80
177
|
## Documentation / 文档目录
|
|
81
178
|
|
|
82
179
|
- [特性 (Features)](docs/Features.md) - 核心功能介绍
|
|
@@ -84,3 +181,4 @@ ANTHROPIC_BASE_URL=http://localhost:25947/proxy <your-tool>
|
|
|
84
181
|
- [使用 (Usage)](docs/Usage.md) - 详细使用说明
|
|
85
182
|
- [架构 (Architecture)](docs/Architecture.md) - 系统架构
|
|
86
183
|
- [开发 (Development)](docs/Development.md) - 开发指南
|
|
184
|
+
- [MCP Server](docs/MCP-Server.md) - 把 llm-inspector 当作 MCP 工具接入 Coding Agent
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonyclaw/llm-inspector",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "LLM API proxy inspector — captures and displays requests/responses from AI coding tools in a web UI",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"ladle:build": "ladle build"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
|
49
50
|
"@radix-ui/react-dialog": "^1.1.14",
|
|
50
51
|
"@radix-ui/react-select": "^2.2.6",
|
|
51
52
|
"@tailwindcss/typography": "^0.5.19",
|
|
@@ -69,6 +70,7 @@
|
|
|
69
70
|
},
|
|
70
71
|
"devDependencies": {
|
|
71
72
|
"@biomejs/biome": "^2.3.14",
|
|
73
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
72
74
|
"@eslint/js": "^10.0.1",
|
|
73
75
|
"@ladle/react": "^5.1.1",
|
|
74
76
|
"@tailwindcss/vite": "^4.1.18",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type JSX, useCallback, useMemo, useState, useRef } from "react";
|
|
1
|
+
import { type JSX, useCallback, useEffect, useMemo, useState, useRef } from "react";
|
|
2
2
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
3
|
-
import { Download, LayoutGrid, List } from "lucide-react";
|
|
3
|
+
import { Download, GitCompareArrows, LayoutGrid, List, X } from "lucide-react";
|
|
4
4
|
import type { CapturedLog } from "../proxy/schemas";
|
|
5
5
|
import { exportLogsAsZip } from "../lib/export-logs";
|
|
6
6
|
import packageJson from "../../package.json";
|
|
@@ -14,6 +14,8 @@ import { CrabLogo } from "./ui/crab-logo";
|
|
|
14
14
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
|
15
15
|
import { SettingsDialog } from "./providers/SettingsDialog";
|
|
16
16
|
import { computeCacheTrends } from "./proxy-viewer/cacheTrend";
|
|
17
|
+
import { CompareDrawer } from "./proxy-viewer/CompareDrawer";
|
|
18
|
+
import { getConversationId } from "./proxy-viewer/ConversationHeader";
|
|
17
19
|
|
|
18
20
|
function truncateSessionId(id: string): string {
|
|
19
21
|
if (id.length <= 30) return id;
|
|
@@ -113,6 +115,9 @@ export function ProxyViewer({
|
|
|
113
115
|
const { totalIn, totalOut } = computeTokenSummary(logs);
|
|
114
116
|
const [groupedView, setGroupedView] = useState(true);
|
|
115
117
|
const [exporting, setExporting] = useState(false);
|
|
118
|
+
const [selectedLogIds, setSelectedLogIds] = useState<number[]>([]);
|
|
119
|
+
const [compareOpen, setCompareOpen] = useState(false);
|
|
120
|
+
const [comparePair, setComparePair] = useState<[CapturedLog, CapturedLog] | null>(null);
|
|
116
121
|
|
|
117
122
|
const handleExport = useCallback(async () => {
|
|
118
123
|
setExporting(true);
|
|
@@ -124,6 +129,88 @@ export function ProxyViewer({
|
|
|
124
129
|
}, [logs]);
|
|
125
130
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
126
131
|
|
|
132
|
+
const handleToggleSelect = useCallback((logId: number) => {
|
|
133
|
+
setSelectedLogIds((prev) => {
|
|
134
|
+
if (prev.includes(logId)) {
|
|
135
|
+
return prev.filter((id) => id !== logId);
|
|
136
|
+
}
|
|
137
|
+
if (prev.length < 2) {
|
|
138
|
+
return [...prev, logId];
|
|
139
|
+
}
|
|
140
|
+
// FIFO eviction: drop the oldest, append the new id.
|
|
141
|
+
const newer = prev[1];
|
|
142
|
+
if (newer === undefined) return prev;
|
|
143
|
+
return [newer, logId];
|
|
144
|
+
});
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// Reset the selection (and close the compare drawer) whenever the user
|
|
148
|
+
// changes the session or model filter, since the selected logs may no
|
|
149
|
+
// longer be in the visible list.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
setSelectedLogIds([]);
|
|
152
|
+
setCompareOpen(false);
|
|
153
|
+
}, [selectedSession, selectedModel]);
|
|
154
|
+
|
|
155
|
+
const selectedSet = useMemo(() => new Set(selectedLogIds), [selectedLogIds]);
|
|
156
|
+
|
|
157
|
+
const openCompare = useCallback(() => {
|
|
158
|
+
if (selectedLogIds.length !== 2) return;
|
|
159
|
+
const [idA, idB] = selectedLogIds;
|
|
160
|
+
if (idA === undefined || idB === undefined) return;
|
|
161
|
+
const logA = logs.find((l) => l.id === idA);
|
|
162
|
+
const logB = logs.find((l) => l.id === idB);
|
|
163
|
+
if (logA === undefined || logB === undefined) return;
|
|
164
|
+
setComparePair([logA, logB]);
|
|
165
|
+
setCompareOpen(true);
|
|
166
|
+
}, [selectedLogIds, logs]);
|
|
167
|
+
|
|
168
|
+
const closeCompare = useCallback(() => {
|
|
169
|
+
setCompareOpen(false);
|
|
170
|
+
// Keep `comparePair` so the selection survives across the drawer being
|
|
171
|
+
// closed and re-opened; it is replaced the next time the user opens
|
|
172
|
+
// the drawer with a different pair.
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const clearSelection = useCallback(() => {
|
|
176
|
+
setSelectedLogIds([]);
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
const selectedSummary = useMemo(() => {
|
|
180
|
+
if (selectedLogIds.length !== 2) return null;
|
|
181
|
+
const [idA, idB] = selectedLogIds;
|
|
182
|
+
if (idA === undefined || idB === undefined) return null;
|
|
183
|
+
const logA = logs.find((l) => l.id === idA);
|
|
184
|
+
const logB = logs.find((l) => l.id === idB);
|
|
185
|
+
if (logA === undefined || logB === undefined) return null;
|
|
186
|
+
const sameSession = getConversationId(logA) === getConversationId(logB);
|
|
187
|
+
let elapsed = "";
|
|
188
|
+
if (logA.timestamp !== null && logB.timestamp !== null) {
|
|
189
|
+
const a = Date.parse(logA.timestamp);
|
|
190
|
+
const b = Date.parse(logB.timestamp);
|
|
191
|
+
if (!Number.isNaN(a) && !Number.isNaN(b)) {
|
|
192
|
+
const ms = Math.abs(b - a);
|
|
193
|
+
elapsed = formatElapsed(ms);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
logA,
|
|
198
|
+
logB,
|
|
199
|
+
sameSession,
|
|
200
|
+
elapsed,
|
|
201
|
+
};
|
|
202
|
+
}, [selectedLogIds, logs]);
|
|
203
|
+
|
|
204
|
+
function formatElapsed(ms: number): string {
|
|
205
|
+
if (ms < 1000) return `${ms}ms`;
|
|
206
|
+
const sec = Math.floor(ms / 1000);
|
|
207
|
+
if (sec < 60) return `${sec}s`;
|
|
208
|
+
const min = Math.floor(sec / 60);
|
|
209
|
+
if (min < 60) return `${min}m`;
|
|
210
|
+
const hr = Math.floor(min / 60);
|
|
211
|
+
return `${hr}h${min % 60}m`;
|
|
212
|
+
}
|
|
213
|
+
|
|
127
214
|
const groups = useMemo(() => groupLogsByConversation(logs), [logs]);
|
|
128
215
|
const cacheTrends = useMemo(() => computeCacheTrends(groups), [groups]);
|
|
129
216
|
|
|
@@ -310,6 +397,8 @@ export function ProxyViewer({
|
|
|
310
397
|
viewMode={viewMode}
|
|
311
398
|
strip={strip}
|
|
312
399
|
cacheTrends={cacheTrends}
|
|
400
|
+
selectedSet={selectedSet}
|
|
401
|
+
onToggleSelect={handleToggleSelect}
|
|
313
402
|
/>
|
|
314
403
|
</div>
|
|
315
404
|
);
|
|
@@ -334,6 +423,8 @@ export function ProxyViewer({
|
|
|
334
423
|
viewMode={viewMode}
|
|
335
424
|
strip={strip}
|
|
336
425
|
cacheTrend={cacheTrends.get(log.id) ?? null}
|
|
426
|
+
isSelected={selectedSet.has(log.id)}
|
|
427
|
+
onToggleSelect={handleToggleSelect}
|
|
337
428
|
/>
|
|
338
429
|
</div>
|
|
339
430
|
);
|
|
@@ -343,6 +434,39 @@ export function ProxyViewer({
|
|
|
343
434
|
</div>
|
|
344
435
|
)}
|
|
345
436
|
</div>
|
|
437
|
+
|
|
438
|
+
{/* Floating action bar — shown only when 2 logs are selected. */}
|
|
439
|
+
{selectedSummary !== null && (
|
|
440
|
+
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-background border border-border rounded-lg shadow-lg px-4 py-2 text-xs">
|
|
441
|
+
<GitCompareArrows className="size-4 text-amber-400 shrink-0" />
|
|
442
|
+
<span className="text-muted-foreground font-mono">
|
|
443
|
+
#{selectedSummary.logA.id} ↔ #{selectedSummary.logB.id}
|
|
444
|
+
{" · "}
|
|
445
|
+
{selectedSummary.sameSession ? "same session" : "different sessions"}
|
|
446
|
+
{selectedSummary.elapsed !== "" && ` · ${selectedSummary.elapsed} apart`}
|
|
447
|
+
</span>
|
|
448
|
+
<button
|
|
449
|
+
type="button"
|
|
450
|
+
onClick={clearSelection}
|
|
451
|
+
className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer inline-flex items-center gap-1"
|
|
452
|
+
>
|
|
453
|
+
<X className="size-3" />
|
|
454
|
+
Clear
|
|
455
|
+
</button>
|
|
456
|
+
<button
|
|
457
|
+
type="button"
|
|
458
|
+
onClick={openCompare}
|
|
459
|
+
className="bg-amber-400 text-amber-950 hover:bg-amber-300 transition-colors px-3 py-1 rounded font-medium cursor-pointer"
|
|
460
|
+
>
|
|
461
|
+
Compare 2 logs
|
|
462
|
+
</button>
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
|
|
466
|
+
{/* Compare drawer — sibling of the log list, not a route change. */}
|
|
467
|
+
{compareOpen && comparePair !== null && (
|
|
468
|
+
<CompareDrawer left={comparePair[0]} right={comparePair[1]} onClose={closeCompare} />
|
|
469
|
+
)}
|
|
346
470
|
</div>
|
|
347
471
|
);
|
|
348
472
|
}
|