@ai-sdk/devtools 0.0.6 ā 0.0.7
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/package.json +3 -2
- 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,286 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { serveStatic } from '@hono/node-server/serve-static';
|
|
3
|
+
import { Hono } from 'hono';
|
|
4
|
+
import { cors } from 'hono/cors';
|
|
5
|
+
import { streamSSE } from 'hono/streaming';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import {
|
|
10
|
+
getRuns,
|
|
11
|
+
getRunWithSteps,
|
|
12
|
+
getStepsForRun,
|
|
13
|
+
clearDatabase,
|
|
14
|
+
reloadDb,
|
|
15
|
+
} from '../db.js';
|
|
16
|
+
|
|
17
|
+
// SSE client management
|
|
18
|
+
type SSEClient = {
|
|
19
|
+
id: string;
|
|
20
|
+
controller: ReadableStreamDefaultController<string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sseClients = new Set<SSEClient>();
|
|
24
|
+
|
|
25
|
+
const broadcastToClients = (event: string, data: Record<string, unknown>) => {
|
|
26
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
27
|
+
for (const client of sseClients) {
|
|
28
|
+
try {
|
|
29
|
+
client.controller.enqueue(message);
|
|
30
|
+
} catch {
|
|
31
|
+
// Client disconnected, will be cleaned up
|
|
32
|
+
sseClients.delete(client);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
|
|
39
|
+
// Determine if we're running from source (tsx) or built (dist)
|
|
40
|
+
const isDevMode =
|
|
41
|
+
__dirname.includes('/src/') || process.env.NODE_ENV === 'development';
|
|
42
|
+
const projectRoot = isDevMode
|
|
43
|
+
? path.resolve(__dirname, '../..')
|
|
44
|
+
: path.resolve(__dirname, '../..');
|
|
45
|
+
|
|
46
|
+
// Client directory: dist/client in both cases
|
|
47
|
+
const clientDir = path.join(projectRoot, 'dist/client');
|
|
48
|
+
|
|
49
|
+
const app = new Hono();
|
|
50
|
+
|
|
51
|
+
// Enable CORS for development
|
|
52
|
+
app.use('/*', cors());
|
|
53
|
+
|
|
54
|
+
// API Routes
|
|
55
|
+
app.get('/api/runs', async c => {
|
|
56
|
+
const runs = await getRuns();
|
|
57
|
+
// Include step count, first message, and error status for each run
|
|
58
|
+
const runsWithMeta = await Promise.all(
|
|
59
|
+
runs.map(async run => {
|
|
60
|
+
const steps = await getStepsForRun(run.id);
|
|
61
|
+
let firstMessage = 'No user message';
|
|
62
|
+
let hasError = false;
|
|
63
|
+
let isInProgress = false;
|
|
64
|
+
|
|
65
|
+
// Extract last user message from first step
|
|
66
|
+
const firstStep = steps[0];
|
|
67
|
+
if (firstStep) {
|
|
68
|
+
try {
|
|
69
|
+
const input = JSON.parse(firstStep.input);
|
|
70
|
+
const userMsg = input?.prompt?.findLast(
|
|
71
|
+
(m: any) => m.role === 'user',
|
|
72
|
+
);
|
|
73
|
+
if (userMsg) {
|
|
74
|
+
const content =
|
|
75
|
+
typeof userMsg.content === 'string'
|
|
76
|
+
? userMsg.content
|
|
77
|
+
: userMsg.content?.[0]?.text || '';
|
|
78
|
+
firstMessage =
|
|
79
|
+
content.slice(0, 60) + (content.length > 60 ? '...' : '');
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// Ignore JSON parse errors
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Check for errors
|
|
86
|
+
hasError = steps.some(s => s.error);
|
|
87
|
+
// Check if any step is still in progress (no output yet)
|
|
88
|
+
isInProgress = steps.some(s => s.duration_ms === null && !s.error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
...run,
|
|
93
|
+
stepCount: steps.length,
|
|
94
|
+
firstMessage,
|
|
95
|
+
hasError,
|
|
96
|
+
isInProgress,
|
|
97
|
+
type: firstStep?.type,
|
|
98
|
+
};
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return c.json(runsWithMeta);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.get('/api/runs/:id', async c => {
|
|
105
|
+
const data = await getRunWithSteps(c.req.param('id'));
|
|
106
|
+
if (!data) {
|
|
107
|
+
return c.json({ error: 'Run not found' }, 404);
|
|
108
|
+
}
|
|
109
|
+
// Compute isInProgress from steps (any step without duration_ms or error)
|
|
110
|
+
const isInProgress = data.steps.some(s => s.duration_ms === null && !s.error);
|
|
111
|
+
return c.json({
|
|
112
|
+
run: { ...data.run, isInProgress },
|
|
113
|
+
steps: data.steps,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.post('/api/clear', async c => {
|
|
118
|
+
await clearDatabase();
|
|
119
|
+
return c.json({ success: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// SSE endpoint for real-time updates
|
|
123
|
+
app.get('/api/events', c => {
|
|
124
|
+
return streamSSE(c, async stream => {
|
|
125
|
+
const clientId = crypto.randomUUID();
|
|
126
|
+
|
|
127
|
+
// Create a client wrapper that uses the stream
|
|
128
|
+
const client: SSEClient = {
|
|
129
|
+
id: clientId,
|
|
130
|
+
controller: null as unknown as ReadableStreamDefaultController<string>,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Send initial connection message
|
|
134
|
+
await stream.writeSSE({
|
|
135
|
+
event: 'connected',
|
|
136
|
+
data: JSON.stringify({ clientId }),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Register client for broadcasts
|
|
140
|
+
const originalWrite = stream.writeSSE.bind(stream);
|
|
141
|
+
client.controller = {
|
|
142
|
+
enqueue: (message: string) => {
|
|
143
|
+
// Parse the raw SSE message and use writeSSE
|
|
144
|
+
const lines = message.split('\n');
|
|
145
|
+
let event = 'message';
|
|
146
|
+
let data = '';
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
if (line.startsWith('event: ')) {
|
|
149
|
+
event = line.slice(7);
|
|
150
|
+
} else if (line.startsWith('data: ')) {
|
|
151
|
+
data = line.slice(6);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
originalWrite({ event, data }).catch(() => {});
|
|
155
|
+
},
|
|
156
|
+
} as ReadableStreamDefaultController<string>;
|
|
157
|
+
|
|
158
|
+
sseClients.add(client);
|
|
159
|
+
|
|
160
|
+
// Keep connection alive with heartbeat
|
|
161
|
+
const heartbeat = setInterval(async () => {
|
|
162
|
+
try {
|
|
163
|
+
await stream.writeSSE({
|
|
164
|
+
event: 'heartbeat',
|
|
165
|
+
data: JSON.stringify({ time: Date.now() }),
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
clearInterval(heartbeat);
|
|
169
|
+
}
|
|
170
|
+
}, 30000);
|
|
171
|
+
|
|
172
|
+
// Wait for client disconnect
|
|
173
|
+
try {
|
|
174
|
+
while (true) {
|
|
175
|
+
await stream.sleep(1000);
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
clearInterval(heartbeat);
|
|
179
|
+
sseClients.delete(client);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Notification endpoint (called by middleware)
|
|
185
|
+
app.post('/api/notify', async c => {
|
|
186
|
+
const body = await c.req.json();
|
|
187
|
+
// Reload database from disk to pick up changes from middleware
|
|
188
|
+
await reloadDb();
|
|
189
|
+
broadcastToClients('update', body);
|
|
190
|
+
return c.json({ success: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Serve static files (pre-built React app)
|
|
194
|
+
app.use(
|
|
195
|
+
'/assets/*',
|
|
196
|
+
serveStatic({
|
|
197
|
+
root: clientDir.replace(/\/+$/, ''),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Fallback to index.html for SPA routing
|
|
202
|
+
app.get('*', async c => {
|
|
203
|
+
// In dev mode, redirect to Vite dev server
|
|
204
|
+
if (isDevMode) {
|
|
205
|
+
return c.html(`
|
|
206
|
+
<!DOCTYPE html>
|
|
207
|
+
<html>
|
|
208
|
+
<head>
|
|
209
|
+
<meta charset="UTF-8">
|
|
210
|
+
<title>AI SDK DevTools</title>
|
|
211
|
+
<style>
|
|
212
|
+
body { font-family: system-ui, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
|
|
213
|
+
.container { text-align: center; }
|
|
214
|
+
a { color: #3b82f6; text-decoration: none; font-size: 1.25rem; }
|
|
215
|
+
a:hover { text-decoration: underline; }
|
|
216
|
+
p { color: #737373; margin-top: 1rem; }
|
|
217
|
+
</style>
|
|
218
|
+
</head>
|
|
219
|
+
<body>
|
|
220
|
+
<div class="container">
|
|
221
|
+
<h2>Development Mode</h2>
|
|
222
|
+
<a href="http://localhost:5173">Open DevTools UI ā</a>
|
|
223
|
+
<p>This port (4983) only serves the API in dev mode.</p>
|
|
224
|
+
</div>
|
|
225
|
+
</body>
|
|
226
|
+
</html>
|
|
227
|
+
`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const indexPath = path.join(clientDir, 'index.html');
|
|
231
|
+
try {
|
|
232
|
+
const html = fs.readFileSync(indexPath, 'utf-8');
|
|
233
|
+
return c.html(html);
|
|
234
|
+
} catch {
|
|
235
|
+
return c.text('DevTools client not built. Run `pnpm build` first.', 500);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export const startViewer = (port = 4983) => {
|
|
240
|
+
const isDev =
|
|
241
|
+
process.env.NODE_ENV === 'development' ||
|
|
242
|
+
process.argv[1]?.includes('/src/');
|
|
243
|
+
|
|
244
|
+
const server = serve(
|
|
245
|
+
{
|
|
246
|
+
fetch: app.fetch,
|
|
247
|
+
port,
|
|
248
|
+
},
|
|
249
|
+
() => {
|
|
250
|
+
if (isDev) {
|
|
251
|
+
console.log(`š AI SDK DevTools API running on port ${port}`);
|
|
252
|
+
console.log(` Open http://localhost:5173 for the dev UI`);
|
|
253
|
+
} else {
|
|
254
|
+
console.log(`š AI SDK DevTools running at http://localhost:${port}`);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
260
|
+
if (err.code === 'EADDRINUSE') {
|
|
261
|
+
console.error(`\nā Port ${port} is already in use.`);
|
|
262
|
+
console.error(
|
|
263
|
+
`\n This likely means AI SDK DevTools is already running.`,
|
|
264
|
+
);
|
|
265
|
+
console.error(` Open http://localhost:${port} in your browser.\n`);
|
|
266
|
+
console.error(` To use a different port, set AI_SDK_DEVTOOLS_PORT:\n`);
|
|
267
|
+
console.error(` AI_SDK_DEVTOOLS_PORT=4984 npx ai-sdk-devtools\n`);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
throw err;
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Allow running directly
|
|
275
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
276
|
+
const isDirectRun =
|
|
277
|
+
process.argv[1] === currentFile ||
|
|
278
|
+
process.argv[1]?.endsWith('/server.ts') ||
|
|
279
|
+
process.argv[1]?.endsWith('/server.js');
|
|
280
|
+
|
|
281
|
+
if (isDirectRun) {
|
|
282
|
+
const port = process.env.AI_SDK_DEVTOOLS_PORT
|
|
283
|
+
? parseInt(process.env.AI_SDK_DEVTOOLS_PORT)
|
|
284
|
+
: 4983;
|
|
285
|
+
startViewer(port);
|
|
286
|
+
}
|