@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,2257 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ChevronRight,
|
|
4
|
+
RefreshCw,
|
|
5
|
+
Trash2,
|
|
6
|
+
Copy,
|
|
7
|
+
Check,
|
|
8
|
+
Zap,
|
|
9
|
+
Wrench,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
AlertCircle,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Settings,
|
|
14
|
+
Brain,
|
|
15
|
+
Loader2,
|
|
16
|
+
BarChart3,
|
|
17
|
+
} from 'lucide-react';
|
|
18
|
+
import { AISDKLogo } from '@/components/icons';
|
|
19
|
+
import { Button } from '@/components/ui/button';
|
|
20
|
+
import { Badge } from '@/components/ui/badge';
|
|
21
|
+
import { Card } from '@/components/ui/card';
|
|
22
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
23
|
+
import {
|
|
24
|
+
Collapsible,
|
|
25
|
+
CollapsibleContent,
|
|
26
|
+
CollapsibleTrigger,
|
|
27
|
+
} from '@/components/ui/collapsible';
|
|
28
|
+
import {
|
|
29
|
+
Drawer,
|
|
30
|
+
DrawerContent,
|
|
31
|
+
DrawerHeader,
|
|
32
|
+
DrawerTitle,
|
|
33
|
+
DrawerTrigger,
|
|
34
|
+
} from '@/components/ui/drawer';
|
|
35
|
+
import {
|
|
36
|
+
Tooltip,
|
|
37
|
+
TooltipContent,
|
|
38
|
+
TooltipTrigger,
|
|
39
|
+
} from '@/components/ui/tooltip';
|
|
40
|
+
|
|
41
|
+
interface Run {
|
|
42
|
+
id: string;
|
|
43
|
+
started_at: string;
|
|
44
|
+
stepCount: number;
|
|
45
|
+
firstMessage?: string;
|
|
46
|
+
hasError?: boolean;
|
|
47
|
+
isInProgress?: boolean;
|
|
48
|
+
type?: 'generate' | 'stream';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Step {
|
|
52
|
+
id: string;
|
|
53
|
+
run_id: string;
|
|
54
|
+
step_number: number;
|
|
55
|
+
type: 'generate' | 'stream';
|
|
56
|
+
model_id: string;
|
|
57
|
+
provider: string | null;
|
|
58
|
+
started_at: string;
|
|
59
|
+
duration_ms: number | null;
|
|
60
|
+
input: string;
|
|
61
|
+
output: string | null;
|
|
62
|
+
usage: string | null;
|
|
63
|
+
error: string | null;
|
|
64
|
+
raw_request: string | null;
|
|
65
|
+
raw_response: string | null;
|
|
66
|
+
raw_chunks: string | null;
|
|
67
|
+
provider_options: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RunDetail {
|
|
71
|
+
run: { id: string; started_at: string; isInProgress?: boolean };
|
|
72
|
+
steps: Step[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type StepType = 'tool-calls' | 'response' | 'error';
|
|
76
|
+
|
|
77
|
+
interface StepSummary {
|
|
78
|
+
type: StepType;
|
|
79
|
+
icon: 'wrench' | 'message' | 'alert';
|
|
80
|
+
label: string;
|
|
81
|
+
toolDetails?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function summarizeToolCalls(toolCalls: any[]): {
|
|
85
|
+
label: string;
|
|
86
|
+
details: string;
|
|
87
|
+
} {
|
|
88
|
+
// Count occurrences of each tool
|
|
89
|
+
const counts = toolCalls.reduce((acc: Record<string, number>, call: any) => {
|
|
90
|
+
acc[call.toolName] = (acc[call.toolName] || 0) + 1;
|
|
91
|
+
return acc;
|
|
92
|
+
}, {});
|
|
93
|
+
|
|
94
|
+
const uniqueTools = Object.keys(counts);
|
|
95
|
+
|
|
96
|
+
// Format tool name with count if > 1
|
|
97
|
+
const formatTool = (name: string) => {
|
|
98
|
+
const count = counts[name] ?? 0;
|
|
99
|
+
return count > 1 ? `${name} (x${count})` : name;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const allToolsFormatted = uniqueTools.map(formatTool);
|
|
103
|
+
|
|
104
|
+
// Single tool type
|
|
105
|
+
if (uniqueTools.length === 1 && uniqueTools[0]) {
|
|
106
|
+
return {
|
|
107
|
+
label: formatTool(uniqueTools[0]),
|
|
108
|
+
details: '', // No tooltip needed
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Two tools - show both
|
|
113
|
+
if (uniqueTools.length === 2) {
|
|
114
|
+
return {
|
|
115
|
+
label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
|
|
116
|
+
details: allToolsFormatted.join(', '),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 3+ tools: show first two + ellipsis
|
|
121
|
+
return {
|
|
122
|
+
label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
|
|
123
|
+
details: allToolsFormatted.join(', '),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface StepInputSummary {
|
|
128
|
+
type: 'user' | 'tool';
|
|
129
|
+
label: string;
|
|
130
|
+
fullText?: string; // For tooltip on user messages
|
|
131
|
+
toolDetails?: string; // For tooltip on tool results
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getStepInputSummary(
|
|
135
|
+
input: any,
|
|
136
|
+
isFirstStep: boolean,
|
|
137
|
+
): StepInputSummary | null {
|
|
138
|
+
const prompt = input?.prompt;
|
|
139
|
+
if (!Array.isArray(prompt)) return null;
|
|
140
|
+
|
|
141
|
+
// For first step: always show the last user message (what kicked off this run)
|
|
142
|
+
if (isFirstStep) {
|
|
143
|
+
const userMessages = prompt.filter((msg: any) => msg.role === 'user');
|
|
144
|
+
const lastUserMessage = userMessages[userMessages.length - 1];
|
|
145
|
+
|
|
146
|
+
if (lastUserMessage) {
|
|
147
|
+
const content = lastUserMessage.content;
|
|
148
|
+
let text: string | null = null;
|
|
149
|
+
|
|
150
|
+
if (typeof content === 'string') {
|
|
151
|
+
text = content;
|
|
152
|
+
} else if (Array.isArray(content)) {
|
|
153
|
+
const textPart = content.find((part: any) => part.type === 'text');
|
|
154
|
+
if (textPart?.text) {
|
|
155
|
+
text = textPart.text;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (text) {
|
|
160
|
+
return {
|
|
161
|
+
type: 'user',
|
|
162
|
+
label: `"${truncateText(text)}"`,
|
|
163
|
+
fullText: text,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// For subsequent steps: show the last tool results
|
|
171
|
+
const toolMessages = prompt.filter((msg: any) => msg.role === 'tool');
|
|
172
|
+
if (toolMessages.length > 0) {
|
|
173
|
+
// Only get tool names from the last tool message (the most recent tool results)
|
|
174
|
+
const lastToolMessage = toolMessages[toolMessages.length - 1];
|
|
175
|
+
const toolCounts: Record<string, number> = {};
|
|
176
|
+
|
|
177
|
+
const content = lastToolMessage.content;
|
|
178
|
+
if (Array.isArray(content)) {
|
|
179
|
+
for (const part of content) {
|
|
180
|
+
if (part.type === 'tool-result' && part.toolName) {
|
|
181
|
+
toolCounts[part.toolName] = (toolCounts[part.toolName] || 0) + 1;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const uniqueTools = Object.keys(toolCounts);
|
|
187
|
+
if (uniqueTools.length === 0) {
|
|
188
|
+
return { type: 'tool', label: 'tool result' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Format tool name with count if > 1
|
|
192
|
+
const formatTool = (name: string) => {
|
|
193
|
+
const count = toolCounts[name];
|
|
194
|
+
return count > 1 ? `${name} (x${count})` : name;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const allToolsFormatted = uniqueTools.map(formatTool);
|
|
198
|
+
|
|
199
|
+
if (uniqueTools.length === 1) {
|
|
200
|
+
return { type: 'tool', label: formatTool(uniqueTools[0]) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (uniqueTools.length === 2) {
|
|
204
|
+
return {
|
|
205
|
+
type: 'tool',
|
|
206
|
+
label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}`,
|
|
207
|
+
toolDetails: allToolsFormatted.join(', '),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 3+ tools: show first two + ellipsis
|
|
212
|
+
return {
|
|
213
|
+
type: 'tool',
|
|
214
|
+
label: `${formatTool(uniqueTools[0])}, ${formatTool(uniqueTools[1])}, ...`,
|
|
215
|
+
toolDetails: allToolsFormatted.join(', '),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Fall back to last user message
|
|
220
|
+
const userMessages = prompt.filter((msg: any) => msg.role === 'user');
|
|
221
|
+
const lastUserMessage = userMessages[userMessages.length - 1];
|
|
222
|
+
|
|
223
|
+
if (!lastUserMessage) return null;
|
|
224
|
+
|
|
225
|
+
// Extract text content from the message
|
|
226
|
+
const content = lastUserMessage.content;
|
|
227
|
+
let text: string | null = null;
|
|
228
|
+
|
|
229
|
+
if (typeof content === 'string') {
|
|
230
|
+
text = content;
|
|
231
|
+
} else if (Array.isArray(content)) {
|
|
232
|
+
const textPart = content.find((part: any) => part.type === 'text');
|
|
233
|
+
if (textPart?.text) {
|
|
234
|
+
text = textPart.text;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!text) return null;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
type: 'user',
|
|
242
|
+
label: `"${truncateText(text)}"`,
|
|
243
|
+
fullText: text,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function truncateText(text: string, maxLength: number = 30): string {
|
|
248
|
+
if (text.length <= maxLength) return text;
|
|
249
|
+
return text.slice(0, maxLength).trim() + '…';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getStepSummary(output: any, error: string | null): StepSummary {
|
|
253
|
+
if (error) {
|
|
254
|
+
return { type: 'error', icon: 'alert', label: 'Error' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// finishReason can be a string or an object with {unified, raw} properties
|
|
258
|
+
const finishReason =
|
|
259
|
+
typeof output?.finishReason === 'string'
|
|
260
|
+
? output.finishReason
|
|
261
|
+
: output?.finishReason?.unified;
|
|
262
|
+
|
|
263
|
+
if (finishReason === 'tool-calls') {
|
|
264
|
+
const toolCalls =
|
|
265
|
+
output?.toolCalls ||
|
|
266
|
+
output?.content?.filter((p: any) => p.type === 'tool-call') ||
|
|
267
|
+
[];
|
|
268
|
+
const { label, details } = summarizeToolCalls(toolCalls);
|
|
269
|
+
return {
|
|
270
|
+
type: 'tool-calls',
|
|
271
|
+
icon: 'wrench',
|
|
272
|
+
label,
|
|
273
|
+
toolDetails: details || undefined,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { type: 'response', icon: 'message', label: 'Response' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function App() {
|
|
281
|
+
const [runs, setRuns] = useState<Run[]>([]);
|
|
282
|
+
const [selectedRun, setSelectedRun] = useState<RunDetail | null>(null);
|
|
283
|
+
const [expandedSteps, setExpandedSteps] = useState<Set<string>>(new Set());
|
|
284
|
+
const [loading, setLoading] = useState(true);
|
|
285
|
+
|
|
286
|
+
const fetchRuns = async () => {
|
|
287
|
+
try {
|
|
288
|
+
const res = await fetch('/api/runs');
|
|
289
|
+
const data = await res.json();
|
|
290
|
+
setRuns(data);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('Failed to fetch runs:', error);
|
|
293
|
+
} finally {
|
|
294
|
+
setLoading(false);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const handleClear = async () => {
|
|
299
|
+
await fetch('/api/clear', { method: 'POST' });
|
|
300
|
+
setRuns([]);
|
|
301
|
+
setSelectedRun(null);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// Initial fetch
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
fetchRuns();
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
309
|
+
// SSE connection for real-time updates
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
let eventSource: EventSource | null = null;
|
|
312
|
+
let reconnectTimeout: NodeJS.Timeout | null = null;
|
|
313
|
+
|
|
314
|
+
const connect = () => {
|
|
315
|
+
eventSource = new EventSource('/api/events');
|
|
316
|
+
|
|
317
|
+
eventSource.addEventListener('connected', () => {
|
|
318
|
+
console.log('[DevTools] Connected to real-time updates');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
eventSource.addEventListener('update', () => {
|
|
322
|
+
// Refresh the runs list when data changes
|
|
323
|
+
fetchRuns();
|
|
324
|
+
// Also refresh selected run if one is selected
|
|
325
|
+
if (selectedRun) {
|
|
326
|
+
fetch(`/api/runs/${selectedRun.run.id}`)
|
|
327
|
+
.then(res => res.json())
|
|
328
|
+
.then(data => {
|
|
329
|
+
if (data && !data.error) {
|
|
330
|
+
setSelectedRun(data);
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
.catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
eventSource.addEventListener('heartbeat', () => {
|
|
338
|
+
// Connection is alive
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
eventSource.onerror = () => {
|
|
342
|
+
console.log('[DevTools] SSE connection lost, reconnecting...');
|
|
343
|
+
eventSource?.close();
|
|
344
|
+
// Reconnect after 2 seconds
|
|
345
|
+
reconnectTimeout = setTimeout(connect, 2000);
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
connect();
|
|
350
|
+
|
|
351
|
+
return () => {
|
|
352
|
+
eventSource?.close();
|
|
353
|
+
if (reconnectTimeout) {
|
|
354
|
+
clearTimeout(reconnectTimeout);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}, [selectedRun?.run.id]);
|
|
358
|
+
|
|
359
|
+
const selectRun = async (runId: string) => {
|
|
360
|
+
const res = await fetch(`/api/runs/${runId}`);
|
|
361
|
+
const data = await res.json();
|
|
362
|
+
setSelectedRun(data);
|
|
363
|
+
// Start with all steps collapsed
|
|
364
|
+
setExpandedSteps(new Set());
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const toggleStep = (stepId: string) => {
|
|
368
|
+
setExpandedSteps(prev => {
|
|
369
|
+
const next = new Set(prev);
|
|
370
|
+
if (next.has(stepId)) {
|
|
371
|
+
next.delete(stepId);
|
|
372
|
+
} else {
|
|
373
|
+
next.add(stepId);
|
|
374
|
+
}
|
|
375
|
+
return next;
|
|
376
|
+
});
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const formatDuration = (ms: number | null) => {
|
|
380
|
+
if (ms === null) return '-';
|
|
381
|
+
if (ms < 1000) return `${ms}ms`;
|
|
382
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const parseJson = (str: string | null) => {
|
|
386
|
+
if (!str) return null;
|
|
387
|
+
try {
|
|
388
|
+
return JSON.parse(str);
|
|
389
|
+
} catch {
|
|
390
|
+
return str;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const getFirstUserMessage = (steps: Step[]): string => {
|
|
395
|
+
const firstStep = steps[0];
|
|
396
|
+
if (!firstStep) return 'Empty run';
|
|
397
|
+
const input = parseJson(firstStep.input);
|
|
398
|
+
const userMsg = input?.prompt?.find((m: any) => m.role === 'user');
|
|
399
|
+
if (userMsg) {
|
|
400
|
+
const content =
|
|
401
|
+
typeof userMsg.content === 'string'
|
|
402
|
+
? userMsg.content
|
|
403
|
+
: userMsg.content?.[0]?.text || '';
|
|
404
|
+
return content.slice(0, 50) + (content.length > 50 ? '...' : '');
|
|
405
|
+
}
|
|
406
|
+
return 'No user message';
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const hasRunError = (steps: Step[]): boolean => {
|
|
410
|
+
return steps.some(s => s.error);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const getTotalDuration = (steps: Step[]): number => {
|
|
414
|
+
return steps.reduce((acc, s) => acc + (s.duration_ms || 0), 0);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const getTotalTokens = (
|
|
418
|
+
steps: Step[],
|
|
419
|
+
): { input: InputTokenBreakdown; output: OutputTokenBreakdown } => {
|
|
420
|
+
return steps.reduce(
|
|
421
|
+
(acc, s) => {
|
|
422
|
+
const usage = parseJson(s.usage);
|
|
423
|
+
const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
|
|
424
|
+
const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
|
|
425
|
+
|
|
426
|
+
const input: InputTokenBreakdown = {
|
|
427
|
+
total: acc.input.total + inputBreakdown.total,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// Only add breakdown properties if they exist in either accumulator or current breakdown
|
|
431
|
+
if (
|
|
432
|
+
acc.input.noCache !== undefined ||
|
|
433
|
+
inputBreakdown.noCache !== undefined
|
|
434
|
+
) {
|
|
435
|
+
input.noCache =
|
|
436
|
+
(acc.input.noCache ?? 0) + (inputBreakdown.noCache ?? 0);
|
|
437
|
+
}
|
|
438
|
+
if (
|
|
439
|
+
acc.input.cacheRead !== undefined ||
|
|
440
|
+
inputBreakdown.cacheRead !== undefined
|
|
441
|
+
) {
|
|
442
|
+
input.cacheRead =
|
|
443
|
+
(acc.input.cacheRead ?? 0) + (inputBreakdown.cacheRead ?? 0);
|
|
444
|
+
}
|
|
445
|
+
if (
|
|
446
|
+
acc.input.cacheWrite !== undefined ||
|
|
447
|
+
inputBreakdown.cacheWrite !== undefined
|
|
448
|
+
) {
|
|
449
|
+
input.cacheWrite =
|
|
450
|
+
(acc.input.cacheWrite ?? 0) + (inputBreakdown.cacheWrite ?? 0);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const output: OutputTokenBreakdown = {
|
|
454
|
+
total: acc.output.total + outputBreakdown.total,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Only add breakdown properties if they exist in either accumulator or current breakdown
|
|
458
|
+
if (
|
|
459
|
+
acc.output.text !== undefined ||
|
|
460
|
+
outputBreakdown.text !== undefined
|
|
461
|
+
) {
|
|
462
|
+
output.text = (acc.output.text ?? 0) + (outputBreakdown.text ?? 0);
|
|
463
|
+
}
|
|
464
|
+
if (
|
|
465
|
+
acc.output.reasoning !== undefined ||
|
|
466
|
+
outputBreakdown.reasoning !== undefined
|
|
467
|
+
) {
|
|
468
|
+
output.reasoning =
|
|
469
|
+
(acc.output.reasoning ?? 0) + (outputBreakdown.reasoning ?? 0);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return { input, output };
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
input: { total: 0 },
|
|
476
|
+
output: { total: 0 },
|
|
477
|
+
} as { input: InputTokenBreakdown; output: OutputTokenBreakdown },
|
|
478
|
+
);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<div className="flex flex-col h-screen bg-background">
|
|
483
|
+
<header className="flex justify-between items-center px-5 py-3 border-b border-border bg-card">
|
|
484
|
+
<div className="flex items-center gap-2">
|
|
485
|
+
<AISDKLogo />
|
|
486
|
+
<span className="text-base font-medium text-muted-foreground">
|
|
487
|
+
DevTools
|
|
488
|
+
</span>
|
|
489
|
+
</div>
|
|
490
|
+
<div className="flex gap-2">
|
|
491
|
+
<Button
|
|
492
|
+
variant="ghost"
|
|
493
|
+
size="sm"
|
|
494
|
+
onClick={fetchRuns}
|
|
495
|
+
className="h-8 px-3 text-xs"
|
|
496
|
+
>
|
|
497
|
+
<RefreshCw className="size-3.5" />
|
|
498
|
+
Refresh
|
|
499
|
+
</Button>
|
|
500
|
+
<Button
|
|
501
|
+
variant="ghost"
|
|
502
|
+
size="sm"
|
|
503
|
+
onClick={handleClear}
|
|
504
|
+
className="h-8 px-3 text-xs text-destructive-foreground hover:bg-destructive/20"
|
|
505
|
+
>
|
|
506
|
+
<Trash2 className="size-3.5" />
|
|
507
|
+
Clear
|
|
508
|
+
</Button>
|
|
509
|
+
</div>
|
|
510
|
+
</header>
|
|
511
|
+
|
|
512
|
+
<div className="flex flex-1 overflow-hidden">
|
|
513
|
+
{/* Sidebar */}
|
|
514
|
+
<aside className="w-[300px] border-r border-border bg-sidebar flex flex-col">
|
|
515
|
+
<div className="px-4 py-3 border-b border-border">
|
|
516
|
+
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
517
|
+
Runs
|
|
518
|
+
</span>
|
|
519
|
+
</div>
|
|
520
|
+
<ScrollArea className="flex-1 overflow-hidden">
|
|
521
|
+
{loading ? (
|
|
522
|
+
<p className="p-4 text-muted-foreground text-sm">Loading...</p>
|
|
523
|
+
) : runs.length === 0 ? (
|
|
524
|
+
<p className="p-4 text-muted-foreground text-sm">No runs yet</p>
|
|
525
|
+
) : (
|
|
526
|
+
<div>
|
|
527
|
+
{runs.map(run => {
|
|
528
|
+
const isSelected = selectedRun?.run.id === run.id;
|
|
529
|
+
return (
|
|
530
|
+
<button
|
|
531
|
+
key={run.id}
|
|
532
|
+
className={`w-full text-left px-4 py-3 border-b border-border/50 transition-colors ${
|
|
533
|
+
isSelected ? 'bg-accent' : 'hover:bg-accent/50'
|
|
534
|
+
}`}
|
|
535
|
+
onClick={() => selectRun(run.id)}
|
|
536
|
+
>
|
|
537
|
+
<div className="flex items-start gap-2 mb-1.5 min-w-0">
|
|
538
|
+
{run.isInProgress ? (
|
|
539
|
+
<Loader2 className="size-3.5 text-blue-400 mt-0.5 shrink-0 animate-spin" />
|
|
540
|
+
) : run.hasError ? (
|
|
541
|
+
<AlertCircle className="size-3.5 text-destructive-foreground mt-0.5 shrink-0" />
|
|
542
|
+
) : (
|
|
543
|
+
<MessageSquare className="size-3.5 text-muted-foreground mt-0.5 shrink-0" />
|
|
544
|
+
)}
|
|
545
|
+
<span className="text-[13px] text-foreground leading-tight line-clamp-1 break-all">
|
|
546
|
+
{run.firstMessage || 'Loading...'}
|
|
547
|
+
</span>
|
|
548
|
+
</div>
|
|
549
|
+
<div className="flex items-center gap-2 ml-5.5 text-[11px] text-muted-foreground">
|
|
550
|
+
{run.type && (
|
|
551
|
+
<span
|
|
552
|
+
className={`flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
|
553
|
+
run.type === 'stream'
|
|
554
|
+
? 'bg-blue-500/15 text-blue-400'
|
|
555
|
+
: 'bg-emerald-500/15 text-emerald-400'
|
|
556
|
+
}`}
|
|
557
|
+
>
|
|
558
|
+
{run.type === 'stream' && (
|
|
559
|
+
<Zap className="size-2.5" />
|
|
560
|
+
)}
|
|
561
|
+
{run.type}
|
|
562
|
+
</span>
|
|
563
|
+
)}
|
|
564
|
+
<span>
|
|
565
|
+
{run.stepCount}{' '}
|
|
566
|
+
{run.stepCount === 1 ? 'step' : 'steps'}
|
|
567
|
+
</span>
|
|
568
|
+
<span>·</span>
|
|
569
|
+
<span className="font-mono">
|
|
570
|
+
{new Date(run.started_at).toLocaleTimeString([], {
|
|
571
|
+
hour: '2-digit',
|
|
572
|
+
minute: '2-digit',
|
|
573
|
+
})}
|
|
574
|
+
</span>
|
|
575
|
+
</div>
|
|
576
|
+
</button>
|
|
577
|
+
);
|
|
578
|
+
})}
|
|
579
|
+
</div>
|
|
580
|
+
)}
|
|
581
|
+
</ScrollArea>
|
|
582
|
+
</aside>
|
|
583
|
+
|
|
584
|
+
{/* Main Content */}
|
|
585
|
+
<main className="flex-1 overflow-hidden">
|
|
586
|
+
<ScrollArea className="h-full">
|
|
587
|
+
{!selectedRun ? (
|
|
588
|
+
<div className="flex items-center justify-center min-h-[calc(100vh-57px)] text-muted-foreground">
|
|
589
|
+
<p className="text-sm">Select a run to view details</p>
|
|
590
|
+
</div>
|
|
591
|
+
) : (
|
|
592
|
+
<div className="p-5">
|
|
593
|
+
{/* Run Header */}
|
|
594
|
+
<div className="flex items-center justify-between mb-5">
|
|
595
|
+
<div className="flex items-center gap-3">
|
|
596
|
+
<h2 className="text-sm font-medium text-foreground">
|
|
597
|
+
{getFirstUserMessage(selectedRun.steps)}
|
|
598
|
+
</h2>
|
|
599
|
+
{selectedRun.run.isInProgress && (
|
|
600
|
+
<Badge
|
|
601
|
+
variant="secondary"
|
|
602
|
+
className="text-[10px] h-5 gap-1.5 bg-blue-500/15 text-blue-400 border-blue-500/30"
|
|
603
|
+
>
|
|
604
|
+
<Loader2 className="size-3 animate-spin" />
|
|
605
|
+
In Progress
|
|
606
|
+
</Badge>
|
|
607
|
+
)}
|
|
608
|
+
{hasRunError(selectedRun.steps) && (
|
|
609
|
+
<Badge variant="destructive" className="text-[10px] h-5">
|
|
610
|
+
Error
|
|
611
|
+
</Badge>
|
|
612
|
+
)}
|
|
613
|
+
</div>
|
|
614
|
+
<div className="flex items-center text-xs text-muted-foreground">
|
|
615
|
+
<span>{selectedRun.steps.length} steps</span>
|
|
616
|
+
<span className="px-3 text-muted-foreground/30">·</span>
|
|
617
|
+
<span className="font-mono">
|
|
618
|
+
{formatDuration(getTotalDuration(selectedRun.steps))}
|
|
619
|
+
</span>
|
|
620
|
+
<span className="px-3 text-muted-foreground/30">·</span>
|
|
621
|
+
<Tooltip>
|
|
622
|
+
<TooltipTrigger asChild>
|
|
623
|
+
<span className="font-mono cursor-help">
|
|
624
|
+
input:{' '}
|
|
625
|
+
{formatInputTokens(
|
|
626
|
+
getTotalTokens(selectedRun.steps).input,
|
|
627
|
+
)}{' '}
|
|
628
|
+
<span className="text-muted-foreground/50">→</span>{' '}
|
|
629
|
+
output:{' '}
|
|
630
|
+
{formatOutputTokens(
|
|
631
|
+
getTotalTokens(selectedRun.steps).output,
|
|
632
|
+
)}
|
|
633
|
+
</span>
|
|
634
|
+
</TooltipTrigger>
|
|
635
|
+
<TooltipContent>
|
|
636
|
+
<TokenBreakdownTooltip
|
|
637
|
+
input={getTotalTokens(selectedRun.steps).input}
|
|
638
|
+
output={getTotalTokens(selectedRun.steps).output}
|
|
639
|
+
/>
|
|
640
|
+
</TooltipContent>
|
|
641
|
+
</Tooltip>
|
|
642
|
+
<span className="px-3 text-muted-foreground/30">·</span>
|
|
643
|
+
<span>
|
|
644
|
+
{new Date(selectedRun.run.started_at).toLocaleString()}
|
|
645
|
+
</span>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
{/* Steps */}
|
|
650
|
+
<div className="flex flex-col gap-3">
|
|
651
|
+
{selectedRun.steps.map((step, index) => {
|
|
652
|
+
const isExpanded = expandedSteps.has(step.id);
|
|
653
|
+
const isLastStep = index === selectedRun.steps.length - 1;
|
|
654
|
+
const isActiveStep =
|
|
655
|
+
isLastStep && selectedRun.run.isInProgress;
|
|
656
|
+
const input = parseJson(step.input);
|
|
657
|
+
const output = parseJson(step.output);
|
|
658
|
+
const usage = parseJson(step.usage);
|
|
659
|
+
|
|
660
|
+
// Get tool results from next step's input
|
|
661
|
+
const nextStep = selectedRun.steps[index + 1];
|
|
662
|
+
const nextInput = nextStep
|
|
663
|
+
? parseJson(nextStep.input)
|
|
664
|
+
: null;
|
|
665
|
+
const toolResults =
|
|
666
|
+
nextInput?.prompt
|
|
667
|
+
?.filter((msg: any) => msg.role === 'tool')
|
|
668
|
+
?.flatMap((msg: any) => msg.content) ?? [];
|
|
669
|
+
|
|
670
|
+
const summary = getStepSummary(output, step.error);
|
|
671
|
+
const isFirstStep = index === 0;
|
|
672
|
+
const inputSummary = getStepInputSummary(
|
|
673
|
+
input,
|
|
674
|
+
isFirstStep,
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
<Collapsible
|
|
679
|
+
key={step.id}
|
|
680
|
+
open={isExpanded}
|
|
681
|
+
onOpenChange={() => toggleStep(step.id)}
|
|
682
|
+
>
|
|
683
|
+
<Card
|
|
684
|
+
className={`overflow-hidden py-0 gap-0 ${isActiveStep ? 'ring-2 ring-blue-500/50 ring-offset-1 ring-offset-background' : ''}`}
|
|
685
|
+
>
|
|
686
|
+
{/* Step Header */}
|
|
687
|
+
<CollapsibleTrigger asChild>
|
|
688
|
+
<button
|
|
689
|
+
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors hover:bg-accent/50 ${
|
|
690
|
+
isExpanded ? 'border-b border-border' : ''
|
|
691
|
+
}`}
|
|
692
|
+
>
|
|
693
|
+
<div className="flex items-center gap-3">
|
|
694
|
+
<span className="text-xs text-muted-foreground font-mono w-4">
|
|
695
|
+
{step.step_number}
|
|
696
|
+
</span>
|
|
697
|
+
<div className="flex items-center gap-2">
|
|
698
|
+
{/* Input side */}
|
|
699
|
+
{inputSummary && (
|
|
700
|
+
<>
|
|
701
|
+
{inputSummary.type === 'user' ? (
|
|
702
|
+
<MessageSquare className="size-3.5 text-muted-foreground" />
|
|
703
|
+
) : (
|
|
704
|
+
<Wrench className="size-3.5 text-muted-foreground" />
|
|
705
|
+
)}
|
|
706
|
+
{inputSummary.fullText ||
|
|
707
|
+
inputSummary.toolDetails ? (
|
|
708
|
+
<Tooltip>
|
|
709
|
+
<TooltipTrigger asChild>
|
|
710
|
+
<span
|
|
711
|
+
className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
|
|
712
|
+
>
|
|
713
|
+
{inputSummary.label}
|
|
714
|
+
</span>
|
|
715
|
+
</TooltipTrigger>
|
|
716
|
+
<TooltipContent
|
|
717
|
+
side="bottom"
|
|
718
|
+
className="max-w-sm max-h-40 overflow-hidden"
|
|
719
|
+
>
|
|
720
|
+
<span className="line-clamp-6">
|
|
721
|
+
{inputSummary.fullText ||
|
|
722
|
+
inputSummary.toolDetails}
|
|
723
|
+
</span>
|
|
724
|
+
</TooltipContent>
|
|
725
|
+
</Tooltip>
|
|
726
|
+
) : (
|
|
727
|
+
<span
|
|
728
|
+
className={`text-sm text-muted-foreground ${inputSummary.type === 'tool' ? 'font-mono' : ''}`}
|
|
729
|
+
>
|
|
730
|
+
{inputSummary.label}
|
|
731
|
+
</span>
|
|
732
|
+
)}
|
|
733
|
+
<span className="text-muted-foreground/50 mx-1">
|
|
734
|
+
→
|
|
735
|
+
</span>
|
|
736
|
+
</>
|
|
737
|
+
)}
|
|
738
|
+
|
|
739
|
+
{/* Output side */}
|
|
740
|
+
{summary.icon === 'wrench' && (
|
|
741
|
+
<Wrench className="size-3.5 text-muted-foreground" />
|
|
742
|
+
)}
|
|
743
|
+
{summary.icon === 'message' && (
|
|
744
|
+
<MessageSquare className="size-3.5 text-muted-foreground" />
|
|
745
|
+
)}
|
|
746
|
+
{summary.icon === 'alert' && (
|
|
747
|
+
<AlertCircle className="size-3.5 text-destructive" />
|
|
748
|
+
)}
|
|
749
|
+
|
|
750
|
+
{summary.toolDetails ? (
|
|
751
|
+
<Tooltip>
|
|
752
|
+
<TooltipTrigger asChild>
|
|
753
|
+
<span className="text-sm font-medium font-mono">
|
|
754
|
+
{summary.label}
|
|
755
|
+
</span>
|
|
756
|
+
</TooltipTrigger>
|
|
757
|
+
<TooltipContent>
|
|
758
|
+
{summary.toolDetails}
|
|
759
|
+
</TooltipContent>
|
|
760
|
+
</Tooltip>
|
|
761
|
+
) : (
|
|
762
|
+
<span
|
|
763
|
+
className={`text-sm font-medium ${
|
|
764
|
+
summary.icon === 'wrench'
|
|
765
|
+
? 'font-mono'
|
|
766
|
+
: ''
|
|
767
|
+
}`}
|
|
768
|
+
>
|
|
769
|
+
{summary.label}
|
|
770
|
+
</span>
|
|
771
|
+
)}
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
<div className="flex items-center gap-4">
|
|
776
|
+
{isActiveStep ? (
|
|
777
|
+
<span className="text-[11px] text-blue-400 font-medium flex items-center gap-1.5">
|
|
778
|
+
<Loader2 className="size-3 animate-spin" />
|
|
779
|
+
streaming
|
|
780
|
+
</span>
|
|
781
|
+
) : (
|
|
782
|
+
<span className="text-[11px] text-muted-foreground font-mono">
|
|
783
|
+
{formatDuration(step.duration_ms)}
|
|
784
|
+
</span>
|
|
785
|
+
)}
|
|
786
|
+
{usage && (
|
|
787
|
+
<Tooltip>
|
|
788
|
+
<TooltipTrigger asChild>
|
|
789
|
+
<span className="text-[11px] font-mono text-muted-foreground cursor-help">
|
|
790
|
+
{formatInputTokens(
|
|
791
|
+
getInputTokenBreakdown(
|
|
792
|
+
usage.inputTokens,
|
|
793
|
+
),
|
|
794
|
+
)}{' '}
|
|
795
|
+
<span className="text-muted-foreground/50">
|
|
796
|
+
→
|
|
797
|
+
</span>{' '}
|
|
798
|
+
{formatOutputTokens(
|
|
799
|
+
getOutputTokenBreakdown(
|
|
800
|
+
usage.outputTokens,
|
|
801
|
+
),
|
|
802
|
+
)}
|
|
803
|
+
</span>
|
|
804
|
+
</TooltipTrigger>
|
|
805
|
+
<TooltipContent>
|
|
806
|
+
<TokenBreakdownTooltip
|
|
807
|
+
input={getInputTokenBreakdown(
|
|
808
|
+
usage.inputTokens,
|
|
809
|
+
)}
|
|
810
|
+
output={getOutputTokenBreakdown(
|
|
811
|
+
usage.outputTokens,
|
|
812
|
+
)}
|
|
813
|
+
raw={usage.raw}
|
|
814
|
+
/>
|
|
815
|
+
</TooltipContent>
|
|
816
|
+
</Tooltip>
|
|
817
|
+
)}
|
|
818
|
+
<ChevronDown
|
|
819
|
+
className={`size-4 text-muted-foreground transition-transform ${
|
|
820
|
+
isExpanded ? 'rotate-180' : ''
|
|
821
|
+
}`}
|
|
822
|
+
/>
|
|
823
|
+
</div>
|
|
824
|
+
</button>
|
|
825
|
+
</CollapsibleTrigger>
|
|
826
|
+
|
|
827
|
+
{/* Step Content */}
|
|
828
|
+
<CollapsibleContent>
|
|
829
|
+
{/* Config Bar */}
|
|
830
|
+
<StepConfigBar
|
|
831
|
+
modelId={step.model_id}
|
|
832
|
+
provider={step.provider}
|
|
833
|
+
input={input}
|
|
834
|
+
providerOptions={parseJson(step.provider_options)}
|
|
835
|
+
usage={usage}
|
|
836
|
+
/>
|
|
837
|
+
|
|
838
|
+
{/* Two Panel Layout */}
|
|
839
|
+
<div className="grid grid-cols-2 divide-x divide-border">
|
|
840
|
+
{/* INPUT Panel */}
|
|
841
|
+
<div className="bg-card/50">
|
|
842
|
+
<InputPanel input={input} />
|
|
843
|
+
</div>
|
|
844
|
+
|
|
845
|
+
{/* OUTPUT Panel */}
|
|
846
|
+
<div className="p-4 bg-background min-h-full">
|
|
847
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
|
848
|
+
Output
|
|
849
|
+
</h3>
|
|
850
|
+
|
|
851
|
+
{step.error ? (
|
|
852
|
+
<div className="p-3 rounded-md bg-destructive/10 border border-destructive/30 text-sm text-destructive-foreground font-mono">
|
|
853
|
+
{step.error}
|
|
854
|
+
</div>
|
|
855
|
+
) : output ? (
|
|
856
|
+
<OutputDisplay
|
|
857
|
+
output={output}
|
|
858
|
+
toolResults={toolResults}
|
|
859
|
+
/>
|
|
860
|
+
) : isActiveStep ? (
|
|
861
|
+
<div className="flex items-center gap-2 text-sm text-blue-400">
|
|
862
|
+
<Loader2 className="size-4 animate-spin" />
|
|
863
|
+
<span>Waiting for response...</span>
|
|
864
|
+
</div>
|
|
865
|
+
) : (
|
|
866
|
+
<p className="text-sm text-muted-foreground">
|
|
867
|
+
No output
|
|
868
|
+
</p>
|
|
869
|
+
)}
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
|
|
873
|
+
{/* Raw Data Toggle */}
|
|
874
|
+
<RawDataSection
|
|
875
|
+
rawRequest={step.raw_request}
|
|
876
|
+
rawResponse={step.raw_response}
|
|
877
|
+
rawChunks={step.raw_chunks}
|
|
878
|
+
isStream={step.type === 'stream'}
|
|
879
|
+
/>
|
|
880
|
+
</CollapsibleContent>
|
|
881
|
+
</Card>
|
|
882
|
+
</Collapsible>
|
|
883
|
+
);
|
|
884
|
+
})}
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
</ScrollArea>
|
|
889
|
+
</main>
|
|
890
|
+
</div>
|
|
891
|
+
</div>
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function StepConfigBar({
|
|
896
|
+
modelId,
|
|
897
|
+
provider,
|
|
898
|
+
input,
|
|
899
|
+
providerOptions,
|
|
900
|
+
usage,
|
|
901
|
+
}: {
|
|
902
|
+
modelId?: string;
|
|
903
|
+
provider?: string | null;
|
|
904
|
+
input: any;
|
|
905
|
+
providerOptions?: any;
|
|
906
|
+
usage?: any;
|
|
907
|
+
}) {
|
|
908
|
+
const toolCount = input?.tools?.length ?? 0;
|
|
909
|
+
|
|
910
|
+
// Collect parameters
|
|
911
|
+
const params: { label: string; value: string }[] = [];
|
|
912
|
+
if (input?.temperature != null)
|
|
913
|
+
params.push({ label: 'temp', value: String(input.temperature) });
|
|
914
|
+
if (input?.maxOutputTokens != null)
|
|
915
|
+
params.push({ label: 'max tokens', value: String(input.maxOutputTokens) });
|
|
916
|
+
if (input?.topP != null)
|
|
917
|
+
params.push({ label: 'topP', value: String(input.topP) });
|
|
918
|
+
if (input?.topK != null)
|
|
919
|
+
params.push({ label: 'topK', value: String(input.topK) });
|
|
920
|
+
if (input?.toolChoice != null) {
|
|
921
|
+
const choice =
|
|
922
|
+
typeof input.toolChoice === 'string'
|
|
923
|
+
? input.toolChoice
|
|
924
|
+
: input.toolChoice.type;
|
|
925
|
+
params.push({ label: 'tool choice', value: choice });
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return (
|
|
929
|
+
<div className="flex items-center gap-2 px-4 py-2 bg-muted/20 border-b border-border text-[11px] text-muted-foreground">
|
|
930
|
+
{provider && (
|
|
931
|
+
<span className="px-1.5 py-0.5 rounded bg-sidebar-primary/10 text-sidebar-primary text-[10px] font-medium">
|
|
932
|
+
{provider}
|
|
933
|
+
</span>
|
|
934
|
+
)}
|
|
935
|
+
<span className="font-mono">{modelId}</span>
|
|
936
|
+
|
|
937
|
+
{params.length > 0 && (
|
|
938
|
+
<>
|
|
939
|
+
<span className="text-muted-foreground/30">·</span>
|
|
940
|
+
{params.map((p, i) => (
|
|
941
|
+
<span key={i}>
|
|
942
|
+
{p.label}: <span className="text-foreground">{p.value}</span>
|
|
943
|
+
{i < params.length - 1 && (
|
|
944
|
+
<span className="text-muted-foreground/30 mx-1">·</span>
|
|
945
|
+
)}
|
|
946
|
+
</span>
|
|
947
|
+
))}
|
|
948
|
+
</>
|
|
949
|
+
)}
|
|
950
|
+
|
|
951
|
+
{toolCount > 0 && (
|
|
952
|
+
<>
|
|
953
|
+
<span className="text-muted-foreground/30">·</span>
|
|
954
|
+
<Drawer direction="right">
|
|
955
|
+
<DrawerTrigger asChild>
|
|
956
|
+
<button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
|
|
957
|
+
<Wrench className="size-3" />
|
|
958
|
+
{toolCount} available {toolCount === 1 ? 'tool' : 'tools'}
|
|
959
|
+
</button>
|
|
960
|
+
</DrawerTrigger>
|
|
961
|
+
<DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
|
|
962
|
+
<DrawerHeader className="border-b border-border shrink-0">
|
|
963
|
+
<DrawerTitle>Available Tools ({toolCount})</DrawerTitle>
|
|
964
|
+
</DrawerHeader>
|
|
965
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
966
|
+
<div className="space-y-3">
|
|
967
|
+
{input?.tools?.map((tool: any, i: number) => (
|
|
968
|
+
<ToolItem key={i} tool={tool} />
|
|
969
|
+
))}
|
|
970
|
+
</div>
|
|
971
|
+
</div>
|
|
972
|
+
</DrawerContent>
|
|
973
|
+
</Drawer>
|
|
974
|
+
</>
|
|
975
|
+
)}
|
|
976
|
+
|
|
977
|
+
{providerOptions && Object.keys(providerOptions).length > 0 && (
|
|
978
|
+
<>
|
|
979
|
+
<span className="text-muted-foreground/30">·</span>
|
|
980
|
+
<Drawer direction="right">
|
|
981
|
+
<DrawerTrigger asChild>
|
|
982
|
+
<button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
|
|
983
|
+
<Settings className="size-3" />
|
|
984
|
+
Provider options
|
|
985
|
+
</button>
|
|
986
|
+
</DrawerTrigger>
|
|
987
|
+
<DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
|
|
988
|
+
<DrawerHeader className="border-b border-border shrink-0">
|
|
989
|
+
<DrawerTitle>Provider Options</DrawerTitle>
|
|
990
|
+
</DrawerHeader>
|
|
991
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
992
|
+
<JsonBlock data={providerOptions} size="lg" />
|
|
993
|
+
</div>
|
|
994
|
+
</DrawerContent>
|
|
995
|
+
</Drawer>
|
|
996
|
+
</>
|
|
997
|
+
)}
|
|
998
|
+
|
|
999
|
+
{usage && (
|
|
1000
|
+
<>
|
|
1001
|
+
<span className="text-muted-foreground/30">·</span>
|
|
1002
|
+
<Drawer direction="right">
|
|
1003
|
+
<DrawerTrigger asChild>
|
|
1004
|
+
<button className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer">
|
|
1005
|
+
<BarChart3 className="size-3" />
|
|
1006
|
+
Usage
|
|
1007
|
+
</button>
|
|
1008
|
+
</DrawerTrigger>
|
|
1009
|
+
<DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
|
|
1010
|
+
<DrawerHeader className="border-b border-border shrink-0">
|
|
1011
|
+
<DrawerTitle>Token Usage</DrawerTitle>
|
|
1012
|
+
</DrawerHeader>
|
|
1013
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
1014
|
+
<UsageDetails usage={usage} />
|
|
1015
|
+
</div>
|
|
1016
|
+
</DrawerContent>
|
|
1017
|
+
</Drawer>
|
|
1018
|
+
</>
|
|
1019
|
+
)}
|
|
1020
|
+
</div>
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function InputPanel({ input }: { input: any }) {
|
|
1025
|
+
const messages = input?.prompt ?? [];
|
|
1026
|
+
const messageCount = messages.length;
|
|
1027
|
+
|
|
1028
|
+
// Get last two messages to display
|
|
1029
|
+
const lastTwoMessages = messages.slice(-2);
|
|
1030
|
+
const previousMessageCount = Math.max(0, messageCount - 2);
|
|
1031
|
+
|
|
1032
|
+
return (
|
|
1033
|
+
<Drawer direction="right">
|
|
1034
|
+
<DrawerTrigger asChild>
|
|
1035
|
+
<button className="w-full h-full text-left p-4 hover:bg-accent/30 transition-colors cursor-pointer flex flex-col justify-start">
|
|
1036
|
+
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-3">
|
|
1037
|
+
Input
|
|
1038
|
+
</h3>
|
|
1039
|
+
|
|
1040
|
+
<div className="space-y-3">
|
|
1041
|
+
{/* Previous messages indicator */}
|
|
1042
|
+
{previousMessageCount > 0 && (
|
|
1043
|
+
<div className="text-[11px] text-muted-foreground/60 text-center py-1.5 rounded-md bg-muted/30">
|
|
1044
|
+
+ {previousMessageCount} previous{' '}
|
|
1045
|
+
{previousMessageCount === 1 ? 'message' : 'messages'}
|
|
1046
|
+
</div>
|
|
1047
|
+
)}
|
|
1048
|
+
|
|
1049
|
+
{/* Last two messages displayed inline */}
|
|
1050
|
+
{lastTwoMessages.map((msg: any, i: number) => (
|
|
1051
|
+
<InputMessagePreview
|
|
1052
|
+
key={i}
|
|
1053
|
+
message={msg}
|
|
1054
|
+
index={previousMessageCount + i + 1}
|
|
1055
|
+
/>
|
|
1056
|
+
))}
|
|
1057
|
+
|
|
1058
|
+
{/* Empty state */}
|
|
1059
|
+
{messageCount === 0 && (
|
|
1060
|
+
<p className="text-sm text-muted-foreground">No messages</p>
|
|
1061
|
+
)}
|
|
1062
|
+
</div>
|
|
1063
|
+
</button>
|
|
1064
|
+
</DrawerTrigger>
|
|
1065
|
+
<DrawerContent className="h-full w-[800px] sm:max-w-[800px] overflow-hidden">
|
|
1066
|
+
<DrawerHeader className="border-b border-border shrink-0">
|
|
1067
|
+
<DrawerTitle>All Messages ({messageCount})</DrawerTitle>
|
|
1068
|
+
</DrawerHeader>
|
|
1069
|
+
<div className="flex-1 overflow-y-auto p-4">
|
|
1070
|
+
<div className="space-y-3">
|
|
1071
|
+
{messages.map((msg: any, i: number) => (
|
|
1072
|
+
<MessageBubble key={i} message={msg} index={i + 1} />
|
|
1073
|
+
))}
|
|
1074
|
+
</div>
|
|
1075
|
+
</div>
|
|
1076
|
+
</DrawerContent>
|
|
1077
|
+
</Drawer>
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function InputMessagePreview({
|
|
1082
|
+
message,
|
|
1083
|
+
index,
|
|
1084
|
+
}: {
|
|
1085
|
+
message: any;
|
|
1086
|
+
index?: number;
|
|
1087
|
+
}) {
|
|
1088
|
+
const role = message.role;
|
|
1089
|
+
const content = message.content;
|
|
1090
|
+
|
|
1091
|
+
const roleLabels: Record<string, string> = {
|
|
1092
|
+
user: 'User',
|
|
1093
|
+
assistant: 'Assistant',
|
|
1094
|
+
system: 'System',
|
|
1095
|
+
tool: 'Tool',
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
// Get text content
|
|
1099
|
+
const getTextContent = (content: any): string => {
|
|
1100
|
+
if (typeof content === 'string') return content;
|
|
1101
|
+
if (Array.isArray(content)) {
|
|
1102
|
+
return content
|
|
1103
|
+
.filter(p => p.type === 'text')
|
|
1104
|
+
.map(p => p.text)
|
|
1105
|
+
.join('');
|
|
1106
|
+
}
|
|
1107
|
+
return '';
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
// Get tool calls from assistant message
|
|
1111
|
+
const getToolCalls = (content: any): any[] => {
|
|
1112
|
+
if (Array.isArray(content)) {
|
|
1113
|
+
return content.filter(p => p.type === 'tool-call');
|
|
1114
|
+
}
|
|
1115
|
+
return [];
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
// Get tool results from tool message
|
|
1119
|
+
const getToolResults = (content: any): any[] => {
|
|
1120
|
+
if (Array.isArray(content)) {
|
|
1121
|
+
return content.filter(p => p.type === 'tool-result');
|
|
1122
|
+
}
|
|
1123
|
+
return [];
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
// Get reasoning/thinking content
|
|
1127
|
+
const getReasoningContent = (content: any): string => {
|
|
1128
|
+
if (Array.isArray(content)) {
|
|
1129
|
+
return content
|
|
1130
|
+
.filter(p => p.type === 'thinking' || p.type === 'reasoning')
|
|
1131
|
+
.map(p => p.thinking || p.text || p.reasoning)
|
|
1132
|
+
.join('');
|
|
1133
|
+
}
|
|
1134
|
+
return '';
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
const textContent = getTextContent(content);
|
|
1138
|
+
const toolCalls = getToolCalls(content);
|
|
1139
|
+
const toolResults = getToolResults(content);
|
|
1140
|
+
const reasoningContent = getReasoningContent(content);
|
|
1141
|
+
|
|
1142
|
+
// Count total parts
|
|
1143
|
+
const partCount =
|
|
1144
|
+
(textContent ? 1 : 0) +
|
|
1145
|
+
(reasoningContent ? 1 : 0) +
|
|
1146
|
+
toolCalls.length +
|
|
1147
|
+
toolResults.length;
|
|
1148
|
+
|
|
1149
|
+
return (
|
|
1150
|
+
<div className="rounded-md border border-border/50 bg-background/50 p-2.5 space-y-2">
|
|
1151
|
+
<div className="flex items-center gap-2">
|
|
1152
|
+
{index && (
|
|
1153
|
+
<span className="text-[10px] font-mono text-muted-foreground/50">
|
|
1154
|
+
{index}
|
|
1155
|
+
</span>
|
|
1156
|
+
)}
|
|
1157
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
1158
|
+
{roleLabels[role] || role}
|
|
1159
|
+
</span>
|
|
1160
|
+
{partCount > 1 && (
|
|
1161
|
+
<span className="text-[10px] text-muted-foreground/60">
|
|
1162
|
+
{partCount} parts
|
|
1163
|
+
</span>
|
|
1164
|
+
)}
|
|
1165
|
+
</div>
|
|
1166
|
+
|
|
1167
|
+
{/* Reasoning/Thinking content */}
|
|
1168
|
+
{reasoningContent && (
|
|
1169
|
+
<div className="text-xs text-amber-500/60">[thinking]</div>
|
|
1170
|
+
)}
|
|
1171
|
+
|
|
1172
|
+
{/* Text content */}
|
|
1173
|
+
{textContent && (
|
|
1174
|
+
<div className="text-xs text-foreground/90 line-clamp-3">
|
|
1175
|
+
{textContent}
|
|
1176
|
+
</div>
|
|
1177
|
+
)}
|
|
1178
|
+
|
|
1179
|
+
{/* Tool calls (from assistant) */}
|
|
1180
|
+
{toolCalls.length > 0 && (
|
|
1181
|
+
<div className="space-y-1">
|
|
1182
|
+
{toolCalls.slice(0, 3).map((call: any, i: number) => {
|
|
1183
|
+
const args = call.args ?? call.input;
|
|
1184
|
+
const parsedArgs =
|
|
1185
|
+
typeof args === 'string' ? safeParseJson(args) : args;
|
|
1186
|
+
return (
|
|
1187
|
+
<div
|
|
1188
|
+
key={i}
|
|
1189
|
+
className="text-[11px] font-mono text-muted-foreground truncate"
|
|
1190
|
+
>
|
|
1191
|
+
{call.toolName}({formatToolParamsInline(parsedArgs)})
|
|
1192
|
+
</div>
|
|
1193
|
+
);
|
|
1194
|
+
})}
|
|
1195
|
+
{toolCalls.length > 3 && (
|
|
1196
|
+
<div className="text-[11px] text-muted-foreground/60">
|
|
1197
|
+
+{toolCalls.length - 3} more tool{' '}
|
|
1198
|
+
{toolCalls.length - 3 === 1 ? 'call' : 'calls'}
|
|
1199
|
+
</div>
|
|
1200
|
+
)}
|
|
1201
|
+
</div>
|
|
1202
|
+
)}
|
|
1203
|
+
|
|
1204
|
+
{/* Tool results */}
|
|
1205
|
+
{toolResults.length > 0 && (
|
|
1206
|
+
<div className="space-y-1">
|
|
1207
|
+
{toolResults.slice(0, 3).map((result: any, i: number) => {
|
|
1208
|
+
const resultContent = result.result ?? result.output ?? result;
|
|
1209
|
+
const resultPreview = formatResultPreview(resultContent);
|
|
1210
|
+
return (
|
|
1211
|
+
<div
|
|
1212
|
+
key={i}
|
|
1213
|
+
className="text-[11px] font-mono text-muted-foreground truncate"
|
|
1214
|
+
>
|
|
1215
|
+
{result.toolName || 'tool'}(…) => {resultPreview}
|
|
1216
|
+
</div>
|
|
1217
|
+
);
|
|
1218
|
+
})}
|
|
1219
|
+
{toolResults.length > 3 && (
|
|
1220
|
+
<div className="text-[11px] text-muted-foreground/60">
|
|
1221
|
+
+{toolResults.length - 3} more tool{' '}
|
|
1222
|
+
{toolResults.length - 3 === 1 ? 'result' : 'results'}
|
|
1223
|
+
</div>
|
|
1224
|
+
)}
|
|
1225
|
+
</div>
|
|
1226
|
+
)}
|
|
1227
|
+
|
|
1228
|
+
{/* Empty content fallback */}
|
|
1229
|
+
{!textContent &&
|
|
1230
|
+
!reasoningContent &&
|
|
1231
|
+
toolCalls.length === 0 &&
|
|
1232
|
+
toolResults.length === 0 && (
|
|
1233
|
+
<div className="text-[11px] text-muted-foreground italic">
|
|
1234
|
+
Empty message
|
|
1235
|
+
</div>
|
|
1236
|
+
)}
|
|
1237
|
+
</div>
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function ToolItem({ tool }: { tool: any }) {
|
|
1242
|
+
const [expanded, setExpanded] = useState(false);
|
|
1243
|
+
|
|
1244
|
+
return (
|
|
1245
|
+
<div className="rounded-md border border-border bg-background overflow-hidden">
|
|
1246
|
+
<button
|
|
1247
|
+
className="w-full flex items-center justify-between p-2.5 hover:bg-accent/50 transition-colors"
|
|
1248
|
+
onClick={() => setExpanded(!expanded)}
|
|
1249
|
+
>
|
|
1250
|
+
<span className="text-sm font-mono text-purple">{tool.name}</span>
|
|
1251
|
+
{tool.parameters && (
|
|
1252
|
+
<ChevronRight
|
|
1253
|
+
className={`size-3 text-muted-foreground transition-transform ${
|
|
1254
|
+
expanded ? 'rotate-90' : ''
|
|
1255
|
+
}`}
|
|
1256
|
+
/>
|
|
1257
|
+
)}
|
|
1258
|
+
</button>
|
|
1259
|
+
{expanded && tool.parameters && (
|
|
1260
|
+
<div className="px-2.5 pb-2.5 border-t border-border">
|
|
1261
|
+
{tool.description && (
|
|
1262
|
+
<p className="text-xs text-muted-foreground mb-2 pt-2">
|
|
1263
|
+
{tool.description}
|
|
1264
|
+
</p>
|
|
1265
|
+
)}
|
|
1266
|
+
<JsonBlock data={tool.parameters} compact />
|
|
1267
|
+
</div>
|
|
1268
|
+
)}
|
|
1269
|
+
{!expanded && tool.description && (
|
|
1270
|
+
<div className="px-2.5 pb-2 -mt-1">
|
|
1271
|
+
<p className="text-[11px] text-muted-foreground truncate">
|
|
1272
|
+
{tool.description}
|
|
1273
|
+
</p>
|
|
1274
|
+
</div>
|
|
1275
|
+
)}
|
|
1276
|
+
</div>
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function CollapsibleToolCall({
|
|
1281
|
+
toolName,
|
|
1282
|
+
toolCallId,
|
|
1283
|
+
data,
|
|
1284
|
+
}: {
|
|
1285
|
+
toolName: string;
|
|
1286
|
+
toolCallId?: string;
|
|
1287
|
+
data: any;
|
|
1288
|
+
}) {
|
|
1289
|
+
const [expanded, setExpanded] = useState(false);
|
|
1290
|
+
const parsedData = typeof data === 'string' ? safeParseJson(data) : data;
|
|
1291
|
+
|
|
1292
|
+
return (
|
|
1293
|
+
<div className="rounded-md border border-purple/30 overflow-hidden">
|
|
1294
|
+
<button
|
|
1295
|
+
className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
|
|
1296
|
+
onClick={() => setExpanded(!expanded)}
|
|
1297
|
+
>
|
|
1298
|
+
<ChevronRight
|
|
1299
|
+
className={`size-3 text-purple transition-transform shrink-0 ${
|
|
1300
|
+
expanded ? 'rotate-90' : ''
|
|
1301
|
+
}`}
|
|
1302
|
+
/>
|
|
1303
|
+
<Wrench className="size-3 text-purple shrink-0" />
|
|
1304
|
+
<span className="text-xs font-mono font-medium text-purple">
|
|
1305
|
+
{toolName}
|
|
1306
|
+
</span>
|
|
1307
|
+
{!expanded && parsedData && (
|
|
1308
|
+
<span className="text-[11px] font-mono text-purple/70 truncate">
|
|
1309
|
+
{formatToolParams(parsedData)}
|
|
1310
|
+
</span>
|
|
1311
|
+
)}
|
|
1312
|
+
{toolCallId && (
|
|
1313
|
+
<span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
|
|
1314
|
+
{toolCallId}
|
|
1315
|
+
</span>
|
|
1316
|
+
)}
|
|
1317
|
+
</button>
|
|
1318
|
+
{expanded && (
|
|
1319
|
+
<div className="p-3 bg-card/50 border-t border-purple/30">
|
|
1320
|
+
<JsonBlock data={parsedData} />
|
|
1321
|
+
</div>
|
|
1322
|
+
)}
|
|
1323
|
+
</div>
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function CollapsibleToolResult({
|
|
1328
|
+
toolName,
|
|
1329
|
+
toolCallId,
|
|
1330
|
+
data,
|
|
1331
|
+
}: {
|
|
1332
|
+
toolName?: string;
|
|
1333
|
+
toolCallId?: string;
|
|
1334
|
+
data: any;
|
|
1335
|
+
}) {
|
|
1336
|
+
const [expanded, setExpanded] = useState(false);
|
|
1337
|
+
|
|
1338
|
+
return (
|
|
1339
|
+
<div className="rounded-md border border-success/30 overflow-hidden">
|
|
1340
|
+
<button
|
|
1341
|
+
className="w-full flex items-center gap-2 px-3 py-2 bg-success/10 hover:bg-success/20 transition-colors"
|
|
1342
|
+
onClick={() => setExpanded(!expanded)}
|
|
1343
|
+
>
|
|
1344
|
+
<ChevronRight
|
|
1345
|
+
className={`size-3 text-success transition-transform shrink-0 ${
|
|
1346
|
+
expanded ? 'rotate-90' : ''
|
|
1347
|
+
}`}
|
|
1348
|
+
/>
|
|
1349
|
+
<span className="text-xs font-medium text-success">Result</span>
|
|
1350
|
+
{toolName && (
|
|
1351
|
+
<span className="text-[11px] font-mono text-muted-foreground">
|
|
1352
|
+
{toolName}
|
|
1353
|
+
</span>
|
|
1354
|
+
)}
|
|
1355
|
+
{toolCallId && (
|
|
1356
|
+
<span className="text-[10px] font-mono text-muted-foreground/60 ml-auto shrink-0">
|
|
1357
|
+
{toolCallId}
|
|
1358
|
+
</span>
|
|
1359
|
+
)}
|
|
1360
|
+
</button>
|
|
1361
|
+
{expanded && (
|
|
1362
|
+
<div className="p-3 bg-card/50 border-t border-success/30">
|
|
1363
|
+
<JsonBlock data={data} />
|
|
1364
|
+
</div>
|
|
1365
|
+
)}
|
|
1366
|
+
</div>
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function MessageBubble({ message, index }: { message: any; index?: number }) {
|
|
1371
|
+
const role = message.role;
|
|
1372
|
+
const content = message.content;
|
|
1373
|
+
|
|
1374
|
+
const roleLabels: Record<string, string> = {
|
|
1375
|
+
user: 'User',
|
|
1376
|
+
assistant: 'Assistant',
|
|
1377
|
+
system: 'System',
|
|
1378
|
+
tool: 'Tool',
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
const getTextContent = (content: any): string => {
|
|
1382
|
+
if (typeof content === 'string') return content;
|
|
1383
|
+
if (Array.isArray(content)) {
|
|
1384
|
+
return content
|
|
1385
|
+
.filter(p => p.type === 'text')
|
|
1386
|
+
.map(p => p.text)
|
|
1387
|
+
.join('');
|
|
1388
|
+
}
|
|
1389
|
+
return '';
|
|
1390
|
+
};
|
|
1391
|
+
|
|
1392
|
+
const getToolCalls = (content: any): any[] => {
|
|
1393
|
+
if (Array.isArray(content)) {
|
|
1394
|
+
return content.filter(p => p.type === 'tool-call');
|
|
1395
|
+
}
|
|
1396
|
+
return [];
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
const getToolResults = (content: any): any[] => {
|
|
1400
|
+
if (Array.isArray(content)) {
|
|
1401
|
+
return content.filter(p => p.type === 'tool-result');
|
|
1402
|
+
}
|
|
1403
|
+
return [];
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
const getReasoningContent = (content: any): string => {
|
|
1407
|
+
if (Array.isArray(content)) {
|
|
1408
|
+
return content
|
|
1409
|
+
.filter(p => p.type === 'thinking' || p.type === 'reasoning')
|
|
1410
|
+
.map(p => p.thinking || p.text || p.reasoning)
|
|
1411
|
+
.join('');
|
|
1412
|
+
}
|
|
1413
|
+
return '';
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
const textContent = getTextContent(content);
|
|
1417
|
+
const toolCalls = getToolCalls(content);
|
|
1418
|
+
const toolResults = getToolResults(content);
|
|
1419
|
+
const reasoningContent = getReasoningContent(content);
|
|
1420
|
+
|
|
1421
|
+
// Count total parts
|
|
1422
|
+
const partCount =
|
|
1423
|
+
(textContent ? 1 : 0) +
|
|
1424
|
+
(reasoningContent ? 1 : 0) +
|
|
1425
|
+
toolCalls.length +
|
|
1426
|
+
toolResults.length;
|
|
1427
|
+
|
|
1428
|
+
return (
|
|
1429
|
+
<div className="rounded-md border border-border/50 bg-background/50 p-3 space-y-2">
|
|
1430
|
+
<div className="flex items-center gap-2">
|
|
1431
|
+
{index && (
|
|
1432
|
+
<span className="text-[10px] font-mono text-muted-foreground/50">
|
|
1433
|
+
{index}
|
|
1434
|
+
</span>
|
|
1435
|
+
)}
|
|
1436
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
1437
|
+
{roleLabels[role] || role}
|
|
1438
|
+
</span>
|
|
1439
|
+
{partCount > 1 && (
|
|
1440
|
+
<span className="text-[10px] text-muted-foreground/60">
|
|
1441
|
+
{partCount} parts
|
|
1442
|
+
</span>
|
|
1443
|
+
)}
|
|
1444
|
+
</div>
|
|
1445
|
+
|
|
1446
|
+
{/* Reasoning/Thinking content - full display */}
|
|
1447
|
+
{reasoningContent && <ReasoningBlock content={reasoningContent} />}
|
|
1448
|
+
|
|
1449
|
+
{/* Full text content */}
|
|
1450
|
+
{textContent && (
|
|
1451
|
+
<TextBlock
|
|
1452
|
+
content={textContent}
|
|
1453
|
+
defaultExpanded={
|
|
1454
|
+
!reasoningContent &&
|
|
1455
|
+
toolCalls.length === 0 &&
|
|
1456
|
+
toolResults.length === 0
|
|
1457
|
+
}
|
|
1458
|
+
isSystem={role === 'system'}
|
|
1459
|
+
/>
|
|
1460
|
+
)}
|
|
1461
|
+
|
|
1462
|
+
{/* Tool calls */}
|
|
1463
|
+
{toolCalls.length > 0 && (
|
|
1464
|
+
<div className="space-y-2">
|
|
1465
|
+
{toolCalls.map((call: any, i: number) => (
|
|
1466
|
+
<CollapsibleToolCall
|
|
1467
|
+
key={i}
|
|
1468
|
+
toolName={call.toolName}
|
|
1469
|
+
toolCallId={call.toolCallId}
|
|
1470
|
+
data={call.args ?? call.input}
|
|
1471
|
+
/>
|
|
1472
|
+
))}
|
|
1473
|
+
</div>
|
|
1474
|
+
)}
|
|
1475
|
+
|
|
1476
|
+
{/* Tool results */}
|
|
1477
|
+
{toolResults.length > 0 && (
|
|
1478
|
+
<div className="space-y-2">
|
|
1479
|
+
{toolResults.map((result: any, i: number) => (
|
|
1480
|
+
<CollapsibleToolResult
|
|
1481
|
+
key={i}
|
|
1482
|
+
toolName={result.toolName}
|
|
1483
|
+
toolCallId={result.toolCallId}
|
|
1484
|
+
data={result.result ?? result.output ?? result}
|
|
1485
|
+
/>
|
|
1486
|
+
))}
|
|
1487
|
+
</div>
|
|
1488
|
+
)}
|
|
1489
|
+
|
|
1490
|
+
{/* Empty content fallback */}
|
|
1491
|
+
{!textContent &&
|
|
1492
|
+
!reasoningContent &&
|
|
1493
|
+
toolCalls.length === 0 &&
|
|
1494
|
+
toolResults.length === 0 && (
|
|
1495
|
+
<div className="text-[11px] text-muted-foreground italic">
|
|
1496
|
+
Empty message
|
|
1497
|
+
</div>
|
|
1498
|
+
)}
|
|
1499
|
+
</div>
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function OutputDisplay({
|
|
1504
|
+
output,
|
|
1505
|
+
toolResults = [],
|
|
1506
|
+
}: {
|
|
1507
|
+
output: any;
|
|
1508
|
+
toolResults?: any[];
|
|
1509
|
+
}) {
|
|
1510
|
+
const getToolResult = (toolCallId: string) => {
|
|
1511
|
+
return toolResults.find((r: any) => r.toolCallId === toolCallId);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
const toolCalls =
|
|
1515
|
+
output?.toolCalls ||
|
|
1516
|
+
output?.content?.filter((p: any) => p.type === 'tool-call') ||
|
|
1517
|
+
[];
|
|
1518
|
+
|
|
1519
|
+
const textParts =
|
|
1520
|
+
output?.textParts ||
|
|
1521
|
+
output?.content?.filter((p: any) => p.type === 'text') ||
|
|
1522
|
+
[];
|
|
1523
|
+
|
|
1524
|
+
// Get reasoning/thinking parts
|
|
1525
|
+
const reasoningParts =
|
|
1526
|
+
output?.reasoningParts ||
|
|
1527
|
+
output?.content?.filter(
|
|
1528
|
+
(p: any) => p.type === 'thinking' || p.type === 'reasoning',
|
|
1529
|
+
) ||
|
|
1530
|
+
[];
|
|
1531
|
+
|
|
1532
|
+
const textContent = textParts.map((p: any) => p.text).join('');
|
|
1533
|
+
const reasoningContent = reasoningParts.map((p: any) => p.text).join('');
|
|
1534
|
+
|
|
1535
|
+
// Check if text is the only content
|
|
1536
|
+
const isTextOnly = textContent && !reasoningContent && toolCalls.length === 0;
|
|
1537
|
+
|
|
1538
|
+
return (
|
|
1539
|
+
<div className="space-y-3">
|
|
1540
|
+
{/* Reasoning/Thinking */}
|
|
1541
|
+
{reasoningContent && <ReasoningBlock content={reasoningContent} />}
|
|
1542
|
+
|
|
1543
|
+
{/* Text Response */}
|
|
1544
|
+
{textContent && (
|
|
1545
|
+
<TextBlock content={textContent} defaultExpanded={isTextOnly} />
|
|
1546
|
+
)}
|
|
1547
|
+
|
|
1548
|
+
{/* Tool Calls with Results */}
|
|
1549
|
+
{toolCalls.map((call: any, i: number) => {
|
|
1550
|
+
const result = getToolResult(call.toolCallId);
|
|
1551
|
+
return (
|
|
1552
|
+
<ToolCallCard
|
|
1553
|
+
key={i}
|
|
1554
|
+
toolName={call.toolName}
|
|
1555
|
+
args={call.args ?? call.input}
|
|
1556
|
+
result={result?.output ?? result?.result}
|
|
1557
|
+
/>
|
|
1558
|
+
);
|
|
1559
|
+
})}
|
|
1560
|
+
</div>
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function ToolCallCard({
|
|
1565
|
+
toolName,
|
|
1566
|
+
args,
|
|
1567
|
+
result,
|
|
1568
|
+
}: {
|
|
1569
|
+
toolName: string;
|
|
1570
|
+
args: any;
|
|
1571
|
+
result?: any;
|
|
1572
|
+
}) {
|
|
1573
|
+
const [expanded, setExpanded] = useState(false);
|
|
1574
|
+
const parsedArgs = typeof args === 'string' ? safeParseJson(args) : args;
|
|
1575
|
+
const parsedResult =
|
|
1576
|
+
typeof result === 'string' ? safeParseJson(result) : result;
|
|
1577
|
+
|
|
1578
|
+
return (
|
|
1579
|
+
<div className="rounded-md border border-purple/30 overflow-hidden">
|
|
1580
|
+
{/* Tool Call Header */}
|
|
1581
|
+
<button
|
|
1582
|
+
className="w-full flex items-center gap-2 px-3 py-2 bg-purple/10 hover:bg-purple/20 transition-colors"
|
|
1583
|
+
onClick={() => setExpanded(!expanded)}
|
|
1584
|
+
>
|
|
1585
|
+
<ChevronRight
|
|
1586
|
+
className={`size-3 text-purple transition-transform shrink-0 ${
|
|
1587
|
+
expanded ? 'rotate-90' : ''
|
|
1588
|
+
}`}
|
|
1589
|
+
/>
|
|
1590
|
+
<Wrench className="size-3 text-purple shrink-0" />
|
|
1591
|
+
<span className="text-xs font-mono font-medium text-purple">
|
|
1592
|
+
{toolName}
|
|
1593
|
+
</span>
|
|
1594
|
+
{!expanded && parsedArgs && (
|
|
1595
|
+
<span className="text-[11px] font-mono text-purple/70 truncate">
|
|
1596
|
+
{formatToolParams(parsedArgs)}
|
|
1597
|
+
</span>
|
|
1598
|
+
)}
|
|
1599
|
+
</button>
|
|
1600
|
+
|
|
1601
|
+
{expanded && (
|
|
1602
|
+
<>
|
|
1603
|
+
{/* Arguments */}
|
|
1604
|
+
<div className="p-3 bg-card/50 border-t border-purple/30">
|
|
1605
|
+
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
1606
|
+
Input
|
|
1607
|
+
</div>
|
|
1608
|
+
<JsonBlock data={parsedArgs} />
|
|
1609
|
+
</div>
|
|
1610
|
+
|
|
1611
|
+
{/* Result */}
|
|
1612
|
+
{parsedResult && (
|
|
1613
|
+
<div className="p-3 border-t border-border bg-success/5">
|
|
1614
|
+
<div className="text-[10px] font-medium uppercase tracking-wider text-success mb-2">
|
|
1615
|
+
Output
|
|
1616
|
+
</div>
|
|
1617
|
+
<JsonBlock data={parsedResult} />
|
|
1618
|
+
</div>
|
|
1619
|
+
)}
|
|
1620
|
+
</>
|
|
1621
|
+
)}
|
|
1622
|
+
</div>
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function ReasoningBlock({ content }: { content: string }) {
|
|
1627
|
+
const [expanded, setExpanded] = useState(false);
|
|
1628
|
+
|
|
1629
|
+
// Truncate preview to first 200 characters
|
|
1630
|
+
const previewContent =
|
|
1631
|
+
content.length > 200 ? content.slice(0, 200) + '…' : content;
|
|
1632
|
+
|
|
1633
|
+
return (
|
|
1634
|
+
<div className="rounded-md border border-amber-500/30 overflow-hidden">
|
|
1635
|
+
<button
|
|
1636
|
+
className="w-full flex items-center gap-2 px-3 py-2 bg-amber-500/10 hover:bg-amber-500/20 transition-colors"
|
|
1637
|
+
onClick={() => setExpanded(!expanded)}
|
|
1638
|
+
>
|
|
1639
|
+
<ChevronRight
|
|
1640
|
+
className={`size-3 text-amber-500 transition-transform shrink-0 ${
|
|
1641
|
+
expanded ? 'rotate-90' : ''
|
|
1642
|
+
}`}
|
|
1643
|
+
/>
|
|
1644
|
+
<Brain className="size-3 text-amber-500 shrink-0" />
|
|
1645
|
+
<span className="text-xs font-medium text-amber-500">Thinking</span>
|
|
1646
|
+
{!expanded && (
|
|
1647
|
+
<span className="text-[11px] text-amber-500/70 truncate ml-1">
|
|
1648
|
+
{previewContent}
|
|
1649
|
+
</span>
|
|
1650
|
+
)}
|
|
1651
|
+
</button>
|
|
1652
|
+
|
|
1653
|
+
{expanded && (
|
|
1654
|
+
<div className="p-3 bg-card/50 border-t border-amber-500/30">
|
|
1655
|
+
<div className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
|
1656
|
+
{content}
|
|
1657
|
+
</div>
|
|
1658
|
+
</div>
|
|
1659
|
+
)}
|
|
1660
|
+
</div>
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function TextBlock({
|
|
1665
|
+
content,
|
|
1666
|
+
defaultExpanded = false,
|
|
1667
|
+
isSystem = false,
|
|
1668
|
+
}: {
|
|
1669
|
+
content: string;
|
|
1670
|
+
defaultExpanded?: boolean;
|
|
1671
|
+
isSystem?: boolean;
|
|
1672
|
+
}) {
|
|
1673
|
+
const [expanded, setExpanded] = useState(defaultExpanded);
|
|
1674
|
+
const [copied, setCopied] = useState(false);
|
|
1675
|
+
|
|
1676
|
+
// Truncate preview to first 200 characters
|
|
1677
|
+
const previewContent =
|
|
1678
|
+
content.length > 200 ? content.slice(0, 200) + '…' : content;
|
|
1679
|
+
|
|
1680
|
+
const borderColor = isSystem ? 'border-blue-500/30' : 'border-border';
|
|
1681
|
+
const bgColor = isSystem ? 'bg-blue-500/10' : 'bg-muted/30';
|
|
1682
|
+
const hoverBgColor = isSystem ? 'hover:bg-blue-500/20' : 'hover:bg-muted/50';
|
|
1683
|
+
const iconColor = isSystem ? 'text-blue-400' : 'text-muted-foreground';
|
|
1684
|
+
const labelColor = isSystem ? 'text-blue-400' : 'text-foreground';
|
|
1685
|
+
|
|
1686
|
+
const handleCopy = async (e: React.MouseEvent) => {
|
|
1687
|
+
e.stopPropagation();
|
|
1688
|
+
await navigator.clipboard.writeText(content);
|
|
1689
|
+
setCopied(true);
|
|
1690
|
+
setTimeout(() => setCopied(false), 2000);
|
|
1691
|
+
};
|
|
1692
|
+
|
|
1693
|
+
return (
|
|
1694
|
+
<div className={`rounded-md border ${borderColor} overflow-hidden`}>
|
|
1695
|
+
<button
|
|
1696
|
+
className={`w-full flex items-center gap-2 px-3 py-2 ${bgColor} ${hoverBgColor} transition-colors`}
|
|
1697
|
+
onClick={() => setExpanded(!expanded)}
|
|
1698
|
+
>
|
|
1699
|
+
<ChevronRight
|
|
1700
|
+
className={`size-3 ${iconColor} transition-transform shrink-0 ${
|
|
1701
|
+
expanded ? 'rotate-90' : ''
|
|
1702
|
+
}`}
|
|
1703
|
+
/>
|
|
1704
|
+
<MessageSquare className={`size-3 ${iconColor} shrink-0`} />
|
|
1705
|
+
<span className={`text-xs font-medium ${labelColor}`}>Text</span>
|
|
1706
|
+
{!expanded && (
|
|
1707
|
+
<span
|
|
1708
|
+
className={`text-[11px] ${isSystem ? 'text-blue-400/70' : 'text-muted-foreground'} truncate ml-1`}
|
|
1709
|
+
>
|
|
1710
|
+
{previewContent}
|
|
1711
|
+
</span>
|
|
1712
|
+
)}
|
|
1713
|
+
</button>
|
|
1714
|
+
|
|
1715
|
+
{expanded && (
|
|
1716
|
+
<div
|
|
1717
|
+
className={`p-3 bg-card/50 border-t ${borderColor} group relative`}
|
|
1718
|
+
>
|
|
1719
|
+
<button
|
|
1720
|
+
onClick={handleCopy}
|
|
1721
|
+
className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
1722
|
+
title="Copy to clipboard"
|
|
1723
|
+
>
|
|
1724
|
+
{copied ? (
|
|
1725
|
+
<Check className="size-3 text-success" />
|
|
1726
|
+
) : (
|
|
1727
|
+
<Copy className="size-3 text-muted-foreground" />
|
|
1728
|
+
)}
|
|
1729
|
+
</button>
|
|
1730
|
+
<div className="text-xs text-foreground leading-relaxed whitespace-pre-wrap max-h-60 overflow-y-auto">
|
|
1731
|
+
{content}
|
|
1732
|
+
</div>
|
|
1733
|
+
</div>
|
|
1734
|
+
)}
|
|
1735
|
+
</div>
|
|
1736
|
+
);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function JsonBlock({
|
|
1740
|
+
data,
|
|
1741
|
+
compact = false,
|
|
1742
|
+
size = 'sm',
|
|
1743
|
+
}: {
|
|
1744
|
+
data: any;
|
|
1745
|
+
compact?: boolean;
|
|
1746
|
+
size?: 'sm' | 'base' | 'lg';
|
|
1747
|
+
}) {
|
|
1748
|
+
const [copied, setCopied] = useState(false);
|
|
1749
|
+
|
|
1750
|
+
const jsonString = JSON.stringify(data, null, 2);
|
|
1751
|
+
const displayString =
|
|
1752
|
+
compact && jsonString.length > 200 ? JSON.stringify(data) : jsonString;
|
|
1753
|
+
|
|
1754
|
+
const sizeClasses = {
|
|
1755
|
+
sm: 'text-xs',
|
|
1756
|
+
base: 'text-sm',
|
|
1757
|
+
lg: 'text-base',
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
const handleCopy = async () => {
|
|
1761
|
+
await navigator.clipboard.writeText(jsonString);
|
|
1762
|
+
setCopied(true);
|
|
1763
|
+
setTimeout(() => setCopied(false), 2000);
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
return (
|
|
1767
|
+
<div className="relative group">
|
|
1768
|
+
<button
|
|
1769
|
+
onClick={handleCopy}
|
|
1770
|
+
className="absolute top-1.5 right-1.5 p-1.5 rounded-md border border-border bg-background opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
|
1771
|
+
title="Copy to clipboard"
|
|
1772
|
+
>
|
|
1773
|
+
{copied ? (
|
|
1774
|
+
<Check className="size-3 text-success" />
|
|
1775
|
+
) : (
|
|
1776
|
+
<Copy className="size-3 text-muted-foreground" />
|
|
1777
|
+
)}
|
|
1778
|
+
</button>
|
|
1779
|
+
<pre
|
|
1780
|
+
className={`font-mono ${sizeClasses[size]} text-muted-foreground whitespace-pre-wrap wrap-break-words bg-background rounded p-2 ${
|
|
1781
|
+
compact ? 'max-h-20 overflow-hidden' : ''
|
|
1782
|
+
}`}
|
|
1783
|
+
>
|
|
1784
|
+
{displayString}
|
|
1785
|
+
</pre>
|
|
1786
|
+
</div>
|
|
1787
|
+
);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
function RawDataSection({
|
|
1791
|
+
rawRequest,
|
|
1792
|
+
rawResponse,
|
|
1793
|
+
rawChunks,
|
|
1794
|
+
isStream,
|
|
1795
|
+
}: {
|
|
1796
|
+
rawRequest: string | null;
|
|
1797
|
+
rawResponse: string | null;
|
|
1798
|
+
rawChunks: string | null;
|
|
1799
|
+
isStream: boolean;
|
|
1800
|
+
}) {
|
|
1801
|
+
const [expanded, setExpanded] = useState(false);
|
|
1802
|
+
const [responseView, setResponseView] = useState<'parsed' | 'raw'>('parsed');
|
|
1803
|
+
|
|
1804
|
+
if (!rawRequest && !rawResponse) return null;
|
|
1805
|
+
|
|
1806
|
+
const hasRawChunks = isStream && rawChunks;
|
|
1807
|
+
|
|
1808
|
+
return (
|
|
1809
|
+
<div className="border-t border-border">
|
|
1810
|
+
<button
|
|
1811
|
+
className="w-full flex items-center gap-2 px-4 py-2.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
1812
|
+
onClick={() => setExpanded(!expanded)}
|
|
1813
|
+
>
|
|
1814
|
+
<ChevronRight
|
|
1815
|
+
className={`size-3 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
|
1816
|
+
/>
|
|
1817
|
+
<span className="font-medium uppercase tracking-wider">
|
|
1818
|
+
Request / Response
|
|
1819
|
+
</span>
|
|
1820
|
+
</button>
|
|
1821
|
+
|
|
1822
|
+
{expanded && (
|
|
1823
|
+
<div className="px-4 pb-4 grid grid-cols-2 gap-4">
|
|
1824
|
+
{rawRequest && (
|
|
1825
|
+
<div className="flex flex-col">
|
|
1826
|
+
<div className="h-7 flex items-end mb-2">
|
|
1827
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
1828
|
+
Request
|
|
1829
|
+
</span>
|
|
1830
|
+
</div>
|
|
1831
|
+
<div className="max-h-[400px] overflow-auto rounded-md border border-border">
|
|
1832
|
+
<JsonBlock data={safeParseJson(rawRequest)} />
|
|
1833
|
+
</div>
|
|
1834
|
+
</div>
|
|
1835
|
+
)}
|
|
1836
|
+
{(rawResponse || rawChunks) && (
|
|
1837
|
+
<div className="flex flex-col">
|
|
1838
|
+
<div className="h-7 flex items-end justify-between mb-2">
|
|
1839
|
+
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
1840
|
+
{isStream ? 'Stream' : 'Response'}
|
|
1841
|
+
</span>
|
|
1842
|
+
{hasRawChunks && (
|
|
1843
|
+
<div className="inline-flex rounded-md border border-border bg-muted/30 p-0.5">
|
|
1844
|
+
<button
|
|
1845
|
+
onClick={() => setResponseView('parsed')}
|
|
1846
|
+
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
|
|
1847
|
+
responseView === 'parsed'
|
|
1848
|
+
? 'bg-background text-foreground shadow-sm'
|
|
1849
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
1850
|
+
}`}
|
|
1851
|
+
>
|
|
1852
|
+
AI SDK
|
|
1853
|
+
</button>
|
|
1854
|
+
<button
|
|
1855
|
+
onClick={() => setResponseView('raw')}
|
|
1856
|
+
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors ${
|
|
1857
|
+
responseView === 'raw'
|
|
1858
|
+
? 'bg-background text-foreground shadow-sm'
|
|
1859
|
+
: 'text-muted-foreground hover:text-foreground'
|
|
1860
|
+
}`}
|
|
1861
|
+
>
|
|
1862
|
+
Provider
|
|
1863
|
+
</button>
|
|
1864
|
+
</div>
|
|
1865
|
+
)}
|
|
1866
|
+
</div>
|
|
1867
|
+
<div className="max-h-[400px] overflow-auto rounded-md border border-border">
|
|
1868
|
+
<JsonBlock
|
|
1869
|
+
data={safeParseJson(
|
|
1870
|
+
hasRawChunks && responseView === 'raw'
|
|
1871
|
+
? rawChunks
|
|
1872
|
+
: rawResponse,
|
|
1873
|
+
)}
|
|
1874
|
+
/>
|
|
1875
|
+
</div>
|
|
1876
|
+
</div>
|
|
1877
|
+
)}
|
|
1878
|
+
</div>
|
|
1879
|
+
)}
|
|
1880
|
+
</div>
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function UsageDetails({ usage }: { usage: any }) {
|
|
1885
|
+
const inputBreakdown = getInputTokenBreakdown(usage?.inputTokens);
|
|
1886
|
+
const outputBreakdown = getOutputTokenBreakdown(usage?.outputTokens);
|
|
1887
|
+
|
|
1888
|
+
return (
|
|
1889
|
+
<div className="space-y-6">
|
|
1890
|
+
{/* Summary Cards */}
|
|
1891
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1892
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
1893
|
+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
1894
|
+
Input Tokens
|
|
1895
|
+
</div>
|
|
1896
|
+
<div className="text-2xl font-semibold">{inputBreakdown.total}</div>
|
|
1897
|
+
{(inputBreakdown.cacheRead !== undefined ||
|
|
1898
|
+
inputBreakdown.cacheWrite !== undefined) && (
|
|
1899
|
+
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
|
|
1900
|
+
{inputBreakdown.cacheRead !== undefined && (
|
|
1901
|
+
<div className="flex justify-between">
|
|
1902
|
+
<span>Cache read</span>
|
|
1903
|
+
<span className="font-mono">{inputBreakdown.cacheRead}</span>
|
|
1904
|
+
</div>
|
|
1905
|
+
)}
|
|
1906
|
+
{inputBreakdown.cacheWrite !== undefined && (
|
|
1907
|
+
<div className="flex justify-between">
|
|
1908
|
+
<span>Cache write</span>
|
|
1909
|
+
<span className="font-mono">{inputBreakdown.cacheWrite}</span>
|
|
1910
|
+
</div>
|
|
1911
|
+
)}
|
|
1912
|
+
{inputBreakdown.noCache !== undefined && (
|
|
1913
|
+
<div className="flex justify-between">
|
|
1914
|
+
<span>No cache</span>
|
|
1915
|
+
<span className="font-mono">{inputBreakdown.noCache}</span>
|
|
1916
|
+
</div>
|
|
1917
|
+
)}
|
|
1918
|
+
</div>
|
|
1919
|
+
)}
|
|
1920
|
+
</div>
|
|
1921
|
+
|
|
1922
|
+
<div className="rounded-lg border border-border bg-card p-4">
|
|
1923
|
+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
1924
|
+
Output Tokens
|
|
1925
|
+
</div>
|
|
1926
|
+
<div className="text-2xl font-semibold">{outputBreakdown.total}</div>
|
|
1927
|
+
{(outputBreakdown.text !== undefined ||
|
|
1928
|
+
outputBreakdown.reasoning !== undefined) && (
|
|
1929
|
+
<div className="mt-3 space-y-1 text-sm text-muted-foreground">
|
|
1930
|
+
{outputBreakdown.text !== undefined && (
|
|
1931
|
+
<div className="flex justify-between">
|
|
1932
|
+
<span>Text</span>
|
|
1933
|
+
<span className="font-mono">{outputBreakdown.text}</span>
|
|
1934
|
+
</div>
|
|
1935
|
+
)}
|
|
1936
|
+
{outputBreakdown.reasoning !== undefined && (
|
|
1937
|
+
<div className="flex justify-between">
|
|
1938
|
+
<span>Reasoning</span>
|
|
1939
|
+
<span className="font-mono">{outputBreakdown.reasoning}</span>
|
|
1940
|
+
</div>
|
|
1941
|
+
)}
|
|
1942
|
+
</div>
|
|
1943
|
+
)}
|
|
1944
|
+
</div>
|
|
1945
|
+
</div>
|
|
1946
|
+
|
|
1947
|
+
{/* Raw Usage Data */}
|
|
1948
|
+
{usage?.raw && (
|
|
1949
|
+
<div>
|
|
1950
|
+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
1951
|
+
Raw Provider Usage
|
|
1952
|
+
</div>
|
|
1953
|
+
<JsonBlock data={usage.raw} size="sm" />
|
|
1954
|
+
</div>
|
|
1955
|
+
)}
|
|
1956
|
+
|
|
1957
|
+
{/* Full Usage Object */}
|
|
1958
|
+
<div>
|
|
1959
|
+
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground mb-2">
|
|
1960
|
+
Full Usage Object
|
|
1961
|
+
</div>
|
|
1962
|
+
<JsonBlock data={usage} size="sm" />
|
|
1963
|
+
</div>
|
|
1964
|
+
</div>
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function TokenBreakdownTooltip({
|
|
1969
|
+
input,
|
|
1970
|
+
output,
|
|
1971
|
+
raw,
|
|
1972
|
+
}: {
|
|
1973
|
+
input: InputTokenBreakdown;
|
|
1974
|
+
output: OutputTokenBreakdown;
|
|
1975
|
+
raw?: unknown;
|
|
1976
|
+
}) {
|
|
1977
|
+
// Check if we have any breakdown metadata (even if values are 0)
|
|
1978
|
+
const hasInputBreakdown =
|
|
1979
|
+
input.noCache !== undefined ||
|
|
1980
|
+
input.cacheRead !== undefined ||
|
|
1981
|
+
input.cacheWrite !== undefined;
|
|
1982
|
+
const hasOutputBreakdown =
|
|
1983
|
+
output.text !== undefined || output.reasoning !== undefined;
|
|
1984
|
+
const hasBreakdown = hasInputBreakdown || hasOutputBreakdown;
|
|
1985
|
+
|
|
1986
|
+
if (!hasBreakdown) {
|
|
1987
|
+
return (
|
|
1988
|
+
<div className="text-xs">
|
|
1989
|
+
<div>Input: {input.total}</div>
|
|
1990
|
+
<div>Output: {output.total}</div>
|
|
1991
|
+
</div>
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
return (
|
|
1996
|
+
<div className="text-xs space-y-2">
|
|
1997
|
+
<div>
|
|
1998
|
+
<div className="font-medium mb-1">Input: {input.total}</div>
|
|
1999
|
+
{input.cacheRead !== undefined && (
|
|
2000
|
+
<div className="text-muted-foreground ml-2">
|
|
2001
|
+
Cache read: {input.cacheRead}
|
|
2002
|
+
</div>
|
|
2003
|
+
)}
|
|
2004
|
+
{input.cacheWrite !== undefined && (
|
|
2005
|
+
<div className="text-muted-foreground ml-2">
|
|
2006
|
+
Cache write: {input.cacheWrite}
|
|
2007
|
+
</div>
|
|
2008
|
+
)}
|
|
2009
|
+
</div>
|
|
2010
|
+
<div>
|
|
2011
|
+
<div className="font-medium mb-1">Output: {output.total}</div>
|
|
2012
|
+
{output.text !== undefined && (
|
|
2013
|
+
<div className="text-muted-foreground ml-2">Text: {output.text}</div>
|
|
2014
|
+
)}
|
|
2015
|
+
{output.reasoning !== undefined && (
|
|
2016
|
+
<div className="text-muted-foreground ml-2">
|
|
2017
|
+
Reasoning: {output.reasoning}
|
|
2018
|
+
</div>
|
|
2019
|
+
)}
|
|
2020
|
+
</div>
|
|
2021
|
+
{raw !== undefined && (
|
|
2022
|
+
<div className="pt-1 border-t border-border mt-1">
|
|
2023
|
+
<div className="text-muted-foreground/70 font-mono text-[10px] max-w-[200px] truncate">
|
|
2024
|
+
Raw: {JSON.stringify(raw)}
|
|
2025
|
+
</div>
|
|
2026
|
+
</div>
|
|
2027
|
+
)}
|
|
2028
|
+
</div>
|
|
2029
|
+
);
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function safeParseJson(value: any): any {
|
|
2033
|
+
if (typeof value === 'string') {
|
|
2034
|
+
try {
|
|
2035
|
+
return JSON.parse(value);
|
|
2036
|
+
} catch {
|
|
2037
|
+
return value;
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
return value;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Token usage can be either a number (old format) or an object with breakdown (ai@6.0.0-beta.139+).
|
|
2045
|
+
* Old format: inputTokens: 4456
|
|
2046
|
+
* New format for input: { total: 4456, noCache: 9, cacheRead: 4100, cacheWrite: 347 }
|
|
2047
|
+
* New format for output: { total: 1262, text: 1262, reasoning: 0 }
|
|
2048
|
+
*/
|
|
2049
|
+
interface InputTokenBreakdown {
|
|
2050
|
+
total: number;
|
|
2051
|
+
noCache?: number;
|
|
2052
|
+
cacheRead?: number;
|
|
2053
|
+
cacheWrite?: number;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
interface OutputTokenBreakdown {
|
|
2057
|
+
total: number;
|
|
2058
|
+
text?: number;
|
|
2059
|
+
reasoning?: number;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function getInputTokenBreakdown(
|
|
2063
|
+
tokens: number | InputTokenBreakdown | null | undefined,
|
|
2064
|
+
): InputTokenBreakdown {
|
|
2065
|
+
if (tokens == null) return { total: 0 };
|
|
2066
|
+
if (typeof tokens === 'number') return { total: tokens };
|
|
2067
|
+
if (typeof tokens === 'object') {
|
|
2068
|
+
// Handle case where total might be missing or not a number
|
|
2069
|
+
const total =
|
|
2070
|
+
'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
|
|
2071
|
+
return {
|
|
2072
|
+
total,
|
|
2073
|
+
// Only include cache fields if they are actual numbers
|
|
2074
|
+
...(typeof tokens.noCache === 'number' && { noCache: tokens.noCache }),
|
|
2075
|
+
...(typeof tokens.cacheRead === 'number' && {
|
|
2076
|
+
cacheRead: tokens.cacheRead,
|
|
2077
|
+
}),
|
|
2078
|
+
...(typeof tokens.cacheWrite === 'number' && {
|
|
2079
|
+
cacheWrite: tokens.cacheWrite,
|
|
2080
|
+
}),
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
return { total: 0 };
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
function getOutputTokenBreakdown(
|
|
2087
|
+
tokens: number | OutputTokenBreakdown | null | undefined,
|
|
2088
|
+
): OutputTokenBreakdown {
|
|
2089
|
+
if (tokens == null) return { total: 0 };
|
|
2090
|
+
if (typeof tokens === 'number') return { total: tokens };
|
|
2091
|
+
if (typeof tokens === 'object') {
|
|
2092
|
+
// Handle case where total might be missing or not a number
|
|
2093
|
+
const total =
|
|
2094
|
+
'total' in tokens && typeof tokens.total === 'number' ? tokens.total : 0;
|
|
2095
|
+
return {
|
|
2096
|
+
total,
|
|
2097
|
+
// Only include text/reasoning if they are actual numbers
|
|
2098
|
+
...(typeof tokens.text === 'number' && { text: tokens.text }),
|
|
2099
|
+
...(typeof tokens.reasoning === 'number' && {
|
|
2100
|
+
reasoning: tokens.reasoning,
|
|
2101
|
+
}),
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
return { total: 0 };
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
/**
|
|
2108
|
+
* Formats input token count with cache info in parentheses if available.
|
|
2109
|
+
* e.g., "4456" or "4456 (4100 cached)"
|
|
2110
|
+
*/
|
|
2111
|
+
function formatInputTokens(breakdown: InputTokenBreakdown): string {
|
|
2112
|
+
const { total, cacheRead } = breakdown;
|
|
2113
|
+
if (cacheRead && cacheRead > 0) {
|
|
2114
|
+
return `${total} (${cacheRead} cached)`;
|
|
2115
|
+
}
|
|
2116
|
+
return String(total);
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* Formats output token count with reasoning info if available.
|
|
2121
|
+
* e.g., "1054" or "1054 (500 reasoning)"
|
|
2122
|
+
*/
|
|
2123
|
+
function formatOutputTokens(breakdown: OutputTokenBreakdown): string {
|
|
2124
|
+
const { total, reasoning } = breakdown;
|
|
2125
|
+
if (reasoning && reasoning > 0) {
|
|
2126
|
+
return `${total} (${reasoning} reasoning)`;
|
|
2127
|
+
}
|
|
2128
|
+
return String(total);
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
/**
|
|
2132
|
+
* Truncates tool call parameters for preview display.
|
|
2133
|
+
*/
|
|
2134
|
+
function formatToolParams(args: any, maxLength = 40): string {
|
|
2135
|
+
if (!args || typeof args !== 'object') return '';
|
|
2136
|
+
|
|
2137
|
+
const entries = Object.entries(args);
|
|
2138
|
+
if (entries.length === 0) return '';
|
|
2139
|
+
|
|
2140
|
+
const formatValue = (value: any, maxLen = 20): string => {
|
|
2141
|
+
if (value === null) return 'null';
|
|
2142
|
+
if (value === undefined) return 'undefined';
|
|
2143
|
+
if (typeof value === 'object') {
|
|
2144
|
+
if (Array.isArray(value)) {
|
|
2145
|
+
return `[${value.length}]`;
|
|
2146
|
+
}
|
|
2147
|
+
return '{…}';
|
|
2148
|
+
}
|
|
2149
|
+
if (typeof value === 'string') {
|
|
2150
|
+
if (value.length > maxLen) {
|
|
2151
|
+
return `"${value.slice(0, maxLen)}…"`;
|
|
2152
|
+
}
|
|
2153
|
+
return `"${value}"`;
|
|
2154
|
+
}
|
|
2155
|
+
return String(value);
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
const firstEntry = entries[0];
|
|
2159
|
+
if (!firstEntry) return '';
|
|
2160
|
+
|
|
2161
|
+
const [firstKey, firstValue] = firstEntry;
|
|
2162
|
+
const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
|
|
2163
|
+
|
|
2164
|
+
if (entries.length === 1) {
|
|
2165
|
+
return `{ ${firstFormatted} }`;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
return `{ ${firstFormatted}, … }`;
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
/**
|
|
2172
|
+
* Formats tool params inline for JS-like syntax: { key: "value", … }
|
|
2173
|
+
*/
|
|
2174
|
+
function formatToolParamsInline(args: any): string {
|
|
2175
|
+
if (!args || typeof args !== 'object') return '';
|
|
2176
|
+
|
|
2177
|
+
const entries = Object.entries(args);
|
|
2178
|
+
if (entries.length === 0) return '';
|
|
2179
|
+
|
|
2180
|
+
const formatValue = (value: any, maxLen = 20): string => {
|
|
2181
|
+
if (value === null) return 'null';
|
|
2182
|
+
if (value === undefined) return 'undefined';
|
|
2183
|
+
if (typeof value === 'object') {
|
|
2184
|
+
if (Array.isArray(value)) {
|
|
2185
|
+
return `[${value.length}]`;
|
|
2186
|
+
}
|
|
2187
|
+
return '{…}';
|
|
2188
|
+
}
|
|
2189
|
+
if (typeof value === 'string') {
|
|
2190
|
+
if (value.length > maxLen) {
|
|
2191
|
+
return `"${value.slice(0, maxLen)}…"`;
|
|
2192
|
+
}
|
|
2193
|
+
return `"${value}"`;
|
|
2194
|
+
}
|
|
2195
|
+
return String(value);
|
|
2196
|
+
};
|
|
2197
|
+
|
|
2198
|
+
const firstEntry = entries[0];
|
|
2199
|
+
if (!firstEntry) return '';
|
|
2200
|
+
|
|
2201
|
+
const [firstKey, firstValue] = firstEntry;
|
|
2202
|
+
const firstFormatted = `${firstKey}: ${formatValue(firstValue)}`;
|
|
2203
|
+
|
|
2204
|
+
if (entries.length === 1) {
|
|
2205
|
+
return `{ ${firstFormatted} }`;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
return `{ ${firstFormatted}, … }`;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
/**
|
|
2212
|
+
* Formats tool result for preview display in JS-like syntax
|
|
2213
|
+
*/
|
|
2214
|
+
function formatResultPreview(result: any): string {
|
|
2215
|
+
if (result === null) return 'null';
|
|
2216
|
+
if (result === undefined) return 'undefined';
|
|
2217
|
+
|
|
2218
|
+
if (typeof result === 'string') {
|
|
2219
|
+
if (result.length > 30) {
|
|
2220
|
+
return `"${result.slice(0, 30)}…"`;
|
|
2221
|
+
}
|
|
2222
|
+
return `"${result}"`;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
if (Array.isArray(result)) {
|
|
2226
|
+
return `[${result.length}]`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
if (typeof result === 'object') {
|
|
2230
|
+
const entries = Object.entries(result);
|
|
2231
|
+
if (entries.length === 0) return '{}';
|
|
2232
|
+
|
|
2233
|
+
const firstEntry = entries[0];
|
|
2234
|
+
if (!firstEntry) return '{…}';
|
|
2235
|
+
|
|
2236
|
+
const [firstKey, firstValue] = firstEntry;
|
|
2237
|
+
let valueStr: string;
|
|
2238
|
+
if (typeof firstValue === 'string' && firstValue.length > 15) {
|
|
2239
|
+
valueStr = `"${firstValue.slice(0, 15)}…"`;
|
|
2240
|
+
} else if (typeof firstValue === 'string') {
|
|
2241
|
+
valueStr = `"${firstValue}"`;
|
|
2242
|
+
} else if (typeof firstValue === 'object') {
|
|
2243
|
+
valueStr = Array.isArray(firstValue) ? `[${firstValue.length}]` : '{…}';
|
|
2244
|
+
} else {
|
|
2245
|
+
valueStr = String(firstValue);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
if (entries.length === 1) {
|
|
2249
|
+
return `{ ${firstKey}: ${valueStr} }`;
|
|
2250
|
+
}
|
|
2251
|
+
return `{ ${firstKey}: ${valueStr}, … }`;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
return String(result);
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
export default App;
|