@ai-sdk/devtools 0.0.0-4115c213-20260122152721
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/LICENSE +13 -0
- package/README.md +73 -0
- package/bin/cli.js +9 -0
- package/dist/client/assets/index-BkkVu33-.js +176 -0
- package/dist/client/assets/index-DtoOzHSk.css +1 -0
- package/dist/client/index.html +19 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +384 -0
- package/dist/viewer/server.js +313 -0
- package/package.json +70 -0
- package/src/db.ts +242 -0
- package/src/index.ts +1 -0
- package/src/middleware.ts +392 -0
- package/src/viewer/client/app.tsx +2257 -0
- package/src/viewer/client/components/icons.tsx +29 -0
- package/src/viewer/client/components/ui/badge.tsx +46 -0
- package/src/viewer/client/components/ui/button.tsx +60 -0
- package/src/viewer/client/components/ui/card.tsx +92 -0
- package/src/viewer/client/components/ui/collapsible.tsx +31 -0
- package/src/viewer/client/components/ui/drawer.tsx +133 -0
- package/src/viewer/client/components/ui/scroll-area.tsx +58 -0
- package/src/viewer/client/components/ui/tooltip.tsx +58 -0
- package/src/viewer/client/index.html +18 -0
- package/src/viewer/client/lib/utils.ts +6 -0
- package/src/viewer/client/main.tsx +6 -0
- package/src/viewer/client/styles.css +145 -0
- package/src/viewer/server.ts +286 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// src/viewer/server.ts
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { cors } from "hono/cors";
|
|
6
|
+
import { streamSSE } from "hono/streaming";
|
|
7
|
+
import path2 from "path";
|
|
8
|
+
import fs2 from "fs";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
// src/db.ts
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
var DB_DIR = path.join(process.cwd(), ".devtools");
|
|
15
|
+
var DB_PATH = path.join(DB_DIR, "generations.json");
|
|
16
|
+
var DEVTOOLS_PORT = process.env.AI_SDK_DEVTOOLS_PORT ? parseInt(process.env.AI_SDK_DEVTOOLS_PORT) : 4983;
|
|
17
|
+
var notifyServer = (event) => {
|
|
18
|
+
notifyServerAsync(event);
|
|
19
|
+
};
|
|
20
|
+
var notifyServerAsync = async (event) => {
|
|
21
|
+
try {
|
|
22
|
+
await fetch(`http://localhost:${DEVTOOLS_PORT}/api/notify`, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ event, timestamp: Date.now() })
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var ensureGitignore = () => {
|
|
31
|
+
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
32
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const content = fs.readFileSync(gitignorePath, "utf-8");
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
const alreadyIgnored = lines.some(
|
|
38
|
+
(line) => line.trim() === ".devtools" || line.trim() === ".devtools/"
|
|
39
|
+
);
|
|
40
|
+
if (!alreadyIgnored) {
|
|
41
|
+
const newContent = content.endsWith("\n") ? `${content}.devtools
|
|
42
|
+
` : `${content}
|
|
43
|
+
.devtools
|
|
44
|
+
`;
|
|
45
|
+
fs.writeFileSync(gitignorePath, newContent);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var readDb = () => {
|
|
49
|
+
try {
|
|
50
|
+
if (fs.existsSync(DB_PATH)) {
|
|
51
|
+
const content = fs.readFileSync(DB_PATH, "utf-8");
|
|
52
|
+
return JSON.parse(content);
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return { runs: [], steps: [] };
|
|
57
|
+
};
|
|
58
|
+
var writeDb = (db) => {
|
|
59
|
+
const isFirstRun = !fs.existsSync(DB_DIR);
|
|
60
|
+
if (isFirstRun) {
|
|
61
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
62
|
+
ensureGitignore();
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
|
|
65
|
+
};
|
|
66
|
+
var dbCache = null;
|
|
67
|
+
var getDb = () => {
|
|
68
|
+
if (!dbCache) {
|
|
69
|
+
dbCache = readDb();
|
|
70
|
+
}
|
|
71
|
+
return dbCache;
|
|
72
|
+
};
|
|
73
|
+
var saveDb = (db) => {
|
|
74
|
+
dbCache = db;
|
|
75
|
+
writeDb(db);
|
|
76
|
+
};
|
|
77
|
+
var reloadDb = async () => {
|
|
78
|
+
dbCache = readDb();
|
|
79
|
+
};
|
|
80
|
+
var getRuns = async () => {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
return [...db.runs].sort(
|
|
83
|
+
(a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime()
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
var getStepsForRun = async (runId) => {
|
|
87
|
+
const db = getDb();
|
|
88
|
+
return db.steps.filter((s) => s.run_id === runId).sort((a, b) => a.step_number - b.step_number);
|
|
89
|
+
};
|
|
90
|
+
var getRunWithSteps = async (runId) => {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
const run = db.runs.find((r) => r.id === runId);
|
|
93
|
+
if (!run) return null;
|
|
94
|
+
const steps = await getStepsForRun(runId);
|
|
95
|
+
return { run, steps };
|
|
96
|
+
};
|
|
97
|
+
var clearDatabase = async () => {
|
|
98
|
+
const db = { runs: [], steps: [] };
|
|
99
|
+
saveDb(db);
|
|
100
|
+
notifyServer("clear");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/viewer/server.ts
|
|
104
|
+
var sseClients = /* @__PURE__ */ new Set();
|
|
105
|
+
var broadcastToClients = (event, data) => {
|
|
106
|
+
const message = `event: ${event}
|
|
107
|
+
data: ${JSON.stringify(data)}
|
|
108
|
+
|
|
109
|
+
`;
|
|
110
|
+
for (const client of sseClients) {
|
|
111
|
+
try {
|
|
112
|
+
client.controller.enqueue(message);
|
|
113
|
+
} catch {
|
|
114
|
+
sseClients.delete(client);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var __dirname = path2.dirname(fileURLToPath(import.meta.url));
|
|
119
|
+
var isDevMode = __dirname.includes("/src/") || process.env.NODE_ENV === "development";
|
|
120
|
+
var projectRoot = isDevMode ? path2.resolve(__dirname, "../..") : path2.resolve(__dirname, "../..");
|
|
121
|
+
var clientDir = path2.join(projectRoot, "dist/client");
|
|
122
|
+
var app = new Hono();
|
|
123
|
+
app.use("/*", cors());
|
|
124
|
+
app.get("/api/runs", async (c) => {
|
|
125
|
+
const runs = await getRuns();
|
|
126
|
+
const runsWithMeta = await Promise.all(
|
|
127
|
+
runs.map(async (run) => {
|
|
128
|
+
const steps = await getStepsForRun(run.id);
|
|
129
|
+
let firstMessage = "No user message";
|
|
130
|
+
let hasError = false;
|
|
131
|
+
let isInProgress = false;
|
|
132
|
+
const firstStep = steps[0];
|
|
133
|
+
if (firstStep) {
|
|
134
|
+
try {
|
|
135
|
+
const input = JSON.parse(firstStep.input);
|
|
136
|
+
const userMsg = input?.prompt?.findLast(
|
|
137
|
+
(m) => m.role === "user"
|
|
138
|
+
);
|
|
139
|
+
if (userMsg) {
|
|
140
|
+
const content = typeof userMsg.content === "string" ? userMsg.content : userMsg.content?.[0]?.text || "";
|
|
141
|
+
firstMessage = content.slice(0, 60) + (content.length > 60 ? "..." : "");
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
hasError = steps.some((s) => s.error);
|
|
146
|
+
isInProgress = steps.some((s) => s.duration_ms === null && !s.error);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
...run,
|
|
150
|
+
stepCount: steps.length,
|
|
151
|
+
firstMessage,
|
|
152
|
+
hasError,
|
|
153
|
+
isInProgress,
|
|
154
|
+
type: firstStep?.type
|
|
155
|
+
};
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
return c.json(runsWithMeta);
|
|
159
|
+
});
|
|
160
|
+
app.get("/api/runs/:id", async (c) => {
|
|
161
|
+
const data = await getRunWithSteps(c.req.param("id"));
|
|
162
|
+
if (!data) {
|
|
163
|
+
return c.json({ error: "Run not found" }, 404);
|
|
164
|
+
}
|
|
165
|
+
const isInProgress = data.steps.some((s) => s.duration_ms === null && !s.error);
|
|
166
|
+
return c.json({
|
|
167
|
+
run: { ...data.run, isInProgress },
|
|
168
|
+
steps: data.steps
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
app.post("/api/clear", async (c) => {
|
|
172
|
+
await clearDatabase();
|
|
173
|
+
return c.json({ success: true });
|
|
174
|
+
});
|
|
175
|
+
app.get("/api/events", (c) => {
|
|
176
|
+
return streamSSE(c, async (stream) => {
|
|
177
|
+
const clientId = crypto.randomUUID();
|
|
178
|
+
const client = {
|
|
179
|
+
id: clientId,
|
|
180
|
+
controller: null
|
|
181
|
+
};
|
|
182
|
+
await stream.writeSSE({
|
|
183
|
+
event: "connected",
|
|
184
|
+
data: JSON.stringify({ clientId })
|
|
185
|
+
});
|
|
186
|
+
const originalWrite = stream.writeSSE.bind(stream);
|
|
187
|
+
client.controller = {
|
|
188
|
+
enqueue: (message) => {
|
|
189
|
+
const lines = message.split("\n");
|
|
190
|
+
let event = "message";
|
|
191
|
+
let data = "";
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
if (line.startsWith("event: ")) {
|
|
194
|
+
event = line.slice(7);
|
|
195
|
+
} else if (line.startsWith("data: ")) {
|
|
196
|
+
data = line.slice(6);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
originalWrite({ event, data }).catch(() => {
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
sseClients.add(client);
|
|
204
|
+
const heartbeat = setInterval(async () => {
|
|
205
|
+
try {
|
|
206
|
+
await stream.writeSSE({
|
|
207
|
+
event: "heartbeat",
|
|
208
|
+
data: JSON.stringify({ time: Date.now() })
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
clearInterval(heartbeat);
|
|
212
|
+
}
|
|
213
|
+
}, 3e4);
|
|
214
|
+
try {
|
|
215
|
+
while (true) {
|
|
216
|
+
await stream.sleep(1e3);
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
clearInterval(heartbeat);
|
|
220
|
+
sseClients.delete(client);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
app.post("/api/notify", async (c) => {
|
|
225
|
+
const body = await c.req.json();
|
|
226
|
+
await reloadDb();
|
|
227
|
+
broadcastToClients("update", body);
|
|
228
|
+
return c.json({ success: true });
|
|
229
|
+
});
|
|
230
|
+
app.use(
|
|
231
|
+
"/assets/*",
|
|
232
|
+
serveStatic({
|
|
233
|
+
root: clientDir.replace(/\/+$/, "")
|
|
234
|
+
})
|
|
235
|
+
);
|
|
236
|
+
app.get("*", async (c) => {
|
|
237
|
+
if (isDevMode) {
|
|
238
|
+
return c.html(`
|
|
239
|
+
<!DOCTYPE html>
|
|
240
|
+
<html>
|
|
241
|
+
<head>
|
|
242
|
+
<meta charset="UTF-8">
|
|
243
|
+
<title>AI SDK DevTools</title>
|
|
244
|
+
<style>
|
|
245
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
|
246
|
+
.container { text-align: center; }
|
|
247
|
+
a { color: #3b82f6; text-decoration: none; font-size: 1.25rem; }
|
|
248
|
+
a:hover { text-decoration: underline; }
|
|
249
|
+
p { color: #737373; margin-top: 1rem; }
|
|
250
|
+
</style>
|
|
251
|
+
</head>
|
|
252
|
+
<body>
|
|
253
|
+
<div class="container">
|
|
254
|
+
<h2>Development Mode</h2>
|
|
255
|
+
<a href="http://localhost:5173">Open DevTools UI \u2192</a>
|
|
256
|
+
<p>This port (4983) only serves the API in dev mode.</p>
|
|
257
|
+
</div>
|
|
258
|
+
</body>
|
|
259
|
+
</html>
|
|
260
|
+
`);
|
|
261
|
+
}
|
|
262
|
+
const indexPath = path2.join(clientDir, "index.html");
|
|
263
|
+
try {
|
|
264
|
+
const html = fs2.readFileSync(indexPath, "utf-8");
|
|
265
|
+
return c.html(html);
|
|
266
|
+
} catch {
|
|
267
|
+
return c.text("DevTools client not built. Run `pnpm build` first.", 500);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
var startViewer = (port = 4983) => {
|
|
271
|
+
const isDev = process.env.NODE_ENV === "development" || process.argv[1]?.includes("/src/");
|
|
272
|
+
const server = serve(
|
|
273
|
+
{
|
|
274
|
+
fetch: app.fetch,
|
|
275
|
+
port
|
|
276
|
+
},
|
|
277
|
+
() => {
|
|
278
|
+
if (isDev) {
|
|
279
|
+
console.log(`\u{1F50D} AI SDK DevTools API running on port ${port}`);
|
|
280
|
+
console.log(` Open http://localhost:5173 for the dev UI`);
|
|
281
|
+
} else {
|
|
282
|
+
console.log(`\u{1F50D} AI SDK DevTools running at http://localhost:${port}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
server.on("error", (err) => {
|
|
287
|
+
if (err.code === "EADDRINUSE") {
|
|
288
|
+
console.error(`
|
|
289
|
+
\u274C Port ${port} is already in use.`);
|
|
290
|
+
console.error(
|
|
291
|
+
`
|
|
292
|
+
This likely means AI SDK DevTools is already running.`
|
|
293
|
+
);
|
|
294
|
+
console.error(` Open http://localhost:${port} in your browser.
|
|
295
|
+
`);
|
|
296
|
+
console.error(` To use a different port, set AI_SDK_DEVTOOLS_PORT:
|
|
297
|
+
`);
|
|
298
|
+
console.error(` AI_SDK_DEVTOOLS_PORT=4984 npx ai-sdk-devtools
|
|
299
|
+
`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
throw err;
|
|
303
|
+
});
|
|
304
|
+
};
|
|
305
|
+
var currentFile = fileURLToPath(import.meta.url);
|
|
306
|
+
var isDirectRun = process.argv[1] === currentFile || process.argv[1]?.endsWith("/server.ts") || process.argv[1]?.endsWith("/server.js");
|
|
307
|
+
if (isDirectRun) {
|
|
308
|
+
const port = process.env.AI_SDK_DEVTOOLS_PORT ? parseInt(process.env.AI_SDK_DEVTOOLS_PORT) : 4983;
|
|
309
|
+
startViewer(port);
|
|
310
|
+
}
|
|
311
|
+
export {
|
|
312
|
+
startViewer
|
|
313
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-sdk/devtools",
|
|
3
|
+
"version": "0.0.0-4115c213-20260122152721",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"devtools": "./bin/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test-d.ts",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"!src/**/__fixtures__",
|
|
25
|
+
"bin"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@hono/node-server": "^1.13.7",
|
|
29
|
+
"hono": "^4.6.14",
|
|
30
|
+
"@ai-sdk/provider": "0.0.0-4115c213-20260122152721"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@radix-ui/react-collapsible": "^1.1.12",
|
|
34
|
+
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
35
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
36
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
37
|
+
"@tailwindcss/vite": "^4.1.17",
|
|
38
|
+
"@types/node": "20.17.24",
|
|
39
|
+
"@types/react": "^18",
|
|
40
|
+
"@types/react-dom": "^18",
|
|
41
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
42
|
+
"class-variance-authority": "^0.7.1",
|
|
43
|
+
"clsx": "^2.1.1",
|
|
44
|
+
"concurrently": "^9.1.0",
|
|
45
|
+
"dotenv": "^17.2.3",
|
|
46
|
+
"lucide-react": "^0.556.0",
|
|
47
|
+
"react": "^18 || ^19",
|
|
48
|
+
"react-dom": "^18 || ^19",
|
|
49
|
+
"tailwind-merge": "^3.4.0",
|
|
50
|
+
"tailwindcss": "^4.1.17",
|
|
51
|
+
"tsup": "^8",
|
|
52
|
+
"tsx": "^4.19.2",
|
|
53
|
+
"tw-animate-css": "^1.4.0",
|
|
54
|
+
"typescript": "5.8.3",
|
|
55
|
+
"vaul": "^1.1.2",
|
|
56
|
+
"vite": "^6.0.3",
|
|
57
|
+
"zod": "3.25.76",
|
|
58
|
+
"ai": "0.0.0-4115c213-20260122152721"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"dev": "concurrently -k \"pnpm dev:api\" \"pnpm dev:client\"",
|
|
62
|
+
"dev:api": "tsx src/viewer/server.ts",
|
|
63
|
+
"dev:client": "vite",
|
|
64
|
+
"example": "tsx examples/basic/index.ts",
|
|
65
|
+
"start": "node dist/viewer/server.js",
|
|
66
|
+
"build": "pnpm build:client && pnpm build:server",
|
|
67
|
+
"build:client": "vite build",
|
|
68
|
+
"build:server": "tsup"
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
|
|
4
|
+
const DB_DIR = path.join(process.cwd(), '.devtools');
|
|
5
|
+
const DB_PATH = path.join(DB_DIR, 'generations.json');
|
|
6
|
+
const DEVTOOLS_PORT = process.env.AI_SDK_DEVTOOLS_PORT
|
|
7
|
+
? parseInt(process.env.AI_SDK_DEVTOOLS_PORT)
|
|
8
|
+
: 4983;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Notify the devtools server that data has changed.
|
|
12
|
+
* Fire-and-forget: doesn't block, ignores errors if server isn't running.
|
|
13
|
+
*/
|
|
14
|
+
const notifyServer = (event: 'run' | 'step' | 'step-update' | 'clear') => {
|
|
15
|
+
notifyServerAsync(event);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Notify the devtools server and wait for the request to complete.
|
|
20
|
+
* Used during process cleanup to ensure notifications are sent before exit.
|
|
21
|
+
*/
|
|
22
|
+
export const notifyServerAsync = async (
|
|
23
|
+
event: 'run' | 'step' | 'step-update' | 'clear',
|
|
24
|
+
): Promise<void> => {
|
|
25
|
+
try {
|
|
26
|
+
await fetch(`http://localhost:${DEVTOOLS_PORT}/api/notify`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
body: JSON.stringify({ event, timestamp: Date.now() }),
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore errors - server might not be running
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface Run {
|
|
37
|
+
id: string;
|
|
38
|
+
started_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Step {
|
|
42
|
+
id: string;
|
|
43
|
+
run_id: string;
|
|
44
|
+
step_number: number;
|
|
45
|
+
type: 'generate' | 'stream';
|
|
46
|
+
model_id: string;
|
|
47
|
+
provider: string | null;
|
|
48
|
+
started_at: string;
|
|
49
|
+
duration_ms: number | null;
|
|
50
|
+
input: string;
|
|
51
|
+
output: string | null;
|
|
52
|
+
usage: string | null;
|
|
53
|
+
error: string | null;
|
|
54
|
+
raw_request: string | null;
|
|
55
|
+
raw_response: string | null;
|
|
56
|
+
raw_chunks: string | null;
|
|
57
|
+
provider_options: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface StepResult {
|
|
61
|
+
duration_ms: number;
|
|
62
|
+
output: string | null;
|
|
63
|
+
usage: string | null;
|
|
64
|
+
error: string | null;
|
|
65
|
+
raw_request?: string | null;
|
|
66
|
+
raw_response?: string | null;
|
|
67
|
+
raw_chunks?: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Database {
|
|
71
|
+
runs: Run[];
|
|
72
|
+
steps: Step[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ensure .devtools is in .gitignore.
|
|
77
|
+
* Only writes if .gitignore exists and doesn't already contain .devtools.
|
|
78
|
+
*/
|
|
79
|
+
const ensureGitignore = (): void => {
|
|
80
|
+
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
81
|
+
|
|
82
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
87
|
+
const lines = content.split('\n');
|
|
88
|
+
|
|
89
|
+
// Check if .devtools is already ignored (exact match or with trailing slash)
|
|
90
|
+
const alreadyIgnored = lines.some(
|
|
91
|
+
line => line.trim() === '.devtools' || line.trim() === '.devtools/',
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (!alreadyIgnored) {
|
|
95
|
+
const newContent = content.endsWith('\n')
|
|
96
|
+
? `${content}.devtools\n`
|
|
97
|
+
: `${content}\n.devtools\n`;
|
|
98
|
+
fs.writeFileSync(gitignorePath, newContent);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const readDb = (): Database => {
|
|
103
|
+
try {
|
|
104
|
+
if (fs.existsSync(DB_PATH)) {
|
|
105
|
+
const content = fs.readFileSync(DB_PATH, 'utf-8');
|
|
106
|
+
return JSON.parse(content);
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// If file is corrupted, start fresh
|
|
110
|
+
}
|
|
111
|
+
return { runs: [], steps: [] };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const writeDb = (db: Database): void => {
|
|
115
|
+
const isFirstRun = !fs.existsSync(DB_DIR);
|
|
116
|
+
|
|
117
|
+
if (isFirstRun) {
|
|
118
|
+
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
119
|
+
ensureGitignore();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// In-memory cache for performance
|
|
126
|
+
let dbCache: Database | null = null;
|
|
127
|
+
|
|
128
|
+
const getDb = (): Database => {
|
|
129
|
+
if (!dbCache) {
|
|
130
|
+
dbCache = readDb();
|
|
131
|
+
}
|
|
132
|
+
return dbCache;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const saveDb = (db: Database): void => {
|
|
136
|
+
dbCache = db;
|
|
137
|
+
writeDb(db);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Reload the database from disk.
|
|
142
|
+
* Used by the viewer server to pick up changes made by the middleware.
|
|
143
|
+
*/
|
|
144
|
+
export const reloadDb = async (): Promise<void> => {
|
|
145
|
+
dbCache = readDb();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const createRun = async (id: string): Promise<Run> => {
|
|
149
|
+
const db = getDb();
|
|
150
|
+
const started_at = new Date().toISOString();
|
|
151
|
+
|
|
152
|
+
// Check if run already exists
|
|
153
|
+
const existing = db.runs.find(r => r.id === id);
|
|
154
|
+
if (existing) {
|
|
155
|
+
return existing;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const run: Run = { id, started_at };
|
|
159
|
+
db.runs.push(run);
|
|
160
|
+
saveDb(db);
|
|
161
|
+
notifyServer('run');
|
|
162
|
+
return run;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const createStep = async (
|
|
166
|
+
step: Omit<
|
|
167
|
+
Step,
|
|
168
|
+
| 'duration_ms'
|
|
169
|
+
| 'output'
|
|
170
|
+
| 'usage'
|
|
171
|
+
| 'error'
|
|
172
|
+
| 'raw_request'
|
|
173
|
+
| 'raw_response'
|
|
174
|
+
| 'raw_chunks'
|
|
175
|
+
>,
|
|
176
|
+
): Promise<void> => {
|
|
177
|
+
const db = getDb();
|
|
178
|
+
const newStep: Step = {
|
|
179
|
+
...step,
|
|
180
|
+
duration_ms: null,
|
|
181
|
+
output: null,
|
|
182
|
+
usage: null,
|
|
183
|
+
error: null,
|
|
184
|
+
raw_request: null,
|
|
185
|
+
raw_response: null,
|
|
186
|
+
raw_chunks: null,
|
|
187
|
+
};
|
|
188
|
+
db.steps.push(newStep);
|
|
189
|
+
saveDb(db);
|
|
190
|
+
notifyServer('step');
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const updateStepResult = async (
|
|
194
|
+
stepId: string,
|
|
195
|
+
result: StepResult,
|
|
196
|
+
): Promise<void> => {
|
|
197
|
+
const db = getDb();
|
|
198
|
+
const step = db.steps.find(s => s.id === stepId);
|
|
199
|
+
if (step) {
|
|
200
|
+
step.duration_ms = result.duration_ms;
|
|
201
|
+
step.output = result.output;
|
|
202
|
+
step.usage = result.usage;
|
|
203
|
+
step.error = result.error;
|
|
204
|
+
step.raw_request = result.raw_request ?? null;
|
|
205
|
+
step.raw_response = result.raw_response ?? null;
|
|
206
|
+
step.raw_chunks = result.raw_chunks ?? null;
|
|
207
|
+
saveDb(db);
|
|
208
|
+
notifyServer('step-update');
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const getRuns = async (): Promise<Run[]> => {
|
|
213
|
+
const db = getDb();
|
|
214
|
+
// Return runs sorted by started_at DESC
|
|
215
|
+
return [...db.runs].sort(
|
|
216
|
+
(a, b) =>
|
|
217
|
+
new Date(b.started_at).getTime() - new Date(a.started_at).getTime(),
|
|
218
|
+
);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export const getStepsForRun = async (runId: string): Promise<Step[]> => {
|
|
222
|
+
const db = getDb();
|
|
223
|
+
return db.steps
|
|
224
|
+
.filter(s => s.run_id === runId)
|
|
225
|
+
.sort((a, b) => a.step_number - b.step_number);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const getRunWithSteps = async (
|
|
229
|
+
runId: string,
|
|
230
|
+
): Promise<{ run: Run; steps: Step[] } | null> => {
|
|
231
|
+
const db = getDb();
|
|
232
|
+
const run = db.runs.find(r => r.id === runId);
|
|
233
|
+
if (!run) return null;
|
|
234
|
+
const steps = await getStepsForRun(runId);
|
|
235
|
+
return { run, steps };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export const clearDatabase = async (): Promise<void> => {
|
|
239
|
+
const db: Database = { runs: [], steps: [] };
|
|
240
|
+
saveDb(db);
|
|
241
|
+
notifyServer('clear');
|
|
242
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { devToolsMiddleware } from './middleware.js';
|