@roj-ai/debug 0.0.2
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/dist/components/debug/DebugContext.d.ts +10 -0
- package/dist/components/debug/DebugNavigation.d.ts +29 -0
- package/dist/components/debug/DebugShell.d.ts +18 -0
- package/dist/components/debug/LLMCallDetail.d.ts +7 -0
- package/dist/components/debug/TimelineDetailInspector.d.ts +6 -0
- package/dist/components/debug/communication/CommunicationDiagram.d.ts +9 -0
- package/dist/components/debug/communication/DiagramHeader.d.ts +7 -0
- package/dist/components/debug/communication/ParticipantLane.d.ts +7 -0
- package/dist/components/debug/communication/TimeAxis.d.ts +9 -0
- package/dist/components/debug/communication/elements/IdleGap.d.ts +9 -0
- package/dist/components/debug/communication/elements/LLMBlock.d.ts +9 -0
- package/dist/components/debug/communication/elements/MessageArrow.d.ts +10 -0
- package/dist/components/debug/communication/elements/ToolBlock.d.ts +9 -0
- package/dist/components/debug/communication/hooks/useDiagramData.d.ts +12 -0
- package/dist/components/debug/communication/hooks/useTimeCompression.d.ts +7 -0
- package/dist/components/debug/communication/hooks/useZoomPan.d.ts +11 -0
- package/dist/components/debug/communication/popovers/ElementPopover.d.ts +8 -0
- package/dist/components/debug/communication/types.d.ts +136 -0
- package/dist/components/debug/index.d.ts +11 -0
- package/dist/components/debug/pages/AgentDetailPage.d.ts +3 -0
- package/dist/components/debug/pages/AgentsPage.d.ts +1 -0
- package/dist/components/debug/pages/CommunicationPage.d.ts +1 -0
- package/dist/components/debug/pages/DashboardPage.d.ts +1 -0
- package/dist/components/debug/pages/EventsPage.d.ts +1 -0
- package/dist/components/debug/pages/FilesPage.d.ts +1 -0
- package/dist/components/debug/pages/LLMCallPage.d.ts +1 -0
- package/dist/components/debug/pages/LLMCallsPage.d.ts +1 -0
- package/dist/components/debug/pages/LogsPage.d.ts +1 -0
- package/dist/components/debug/pages/MailboxPage.d.ts +1 -0
- package/dist/components/debug/pages/ServicesPage.d.ts +1 -0
- package/dist/components/debug/pages/TimelinePage.d.ts +1 -0
- package/dist/components/debug/pages/UserChatPage.d.ts +1 -0
- package/dist/components/debug/pages/index.d.ts +13 -0
- package/dist/index.d.ts +9 -0
- package/dist/lib/domain-utils.d.ts +7 -0
- package/dist/providers/EventPollingProvider.d.ts +27 -0
- package/dist/stores/event-store.d.ts +93 -0
- package/dist/utils/format.d.ts +1 -0
- package/package.json +43 -0
- package/src/components/debug/DebugContext.tsx +18 -0
- package/src/components/debug/DebugNavigation.tsx +55 -0
- package/src/components/debug/DebugShell.tsx +321 -0
- package/src/components/debug/LLMCallDetail.tsx +740 -0
- package/src/components/debug/TimelineDetailInspector.tsx +204 -0
- package/src/components/debug/communication/CommunicationDiagram.tsx +260 -0
- package/src/components/debug/communication/DiagramHeader.tsx +113 -0
- package/src/components/debug/communication/ParticipantLane.tsx +60 -0
- package/src/components/debug/communication/TimeAxis.tsx +106 -0
- package/src/components/debug/communication/elements/IdleGap.tsx +90 -0
- package/src/components/debug/communication/elements/LLMBlock.tsx +107 -0
- package/src/components/debug/communication/elements/MessageArrow.tsx +119 -0
- package/src/components/debug/communication/elements/ToolBlock.tsx +99 -0
- package/src/components/debug/communication/hooks/useDiagramData.ts +294 -0
- package/src/components/debug/communication/hooks/useTimeCompression.ts +140 -0
- package/src/components/debug/communication/hooks/useZoomPan.ts +87 -0
- package/src/components/debug/communication/popovers/ElementPopover.tsx +158 -0
- package/src/components/debug/communication/types.ts +180 -0
- package/src/components/debug/index.ts +37 -0
- package/src/components/debug/pages/AgentDetailPage.tsx +1295 -0
- package/src/components/debug/pages/AgentsPage.tsx +297 -0
- package/src/components/debug/pages/CommunicationPage.tsx +89 -0
- package/src/components/debug/pages/DashboardPage.tsx +1504 -0
- package/src/components/debug/pages/EventsPage.tsx +276 -0
- package/src/components/debug/pages/FilesPage.tsx +366 -0
- package/src/components/debug/pages/LLMCallPage.tsx +32 -0
- package/src/components/debug/pages/LLMCallsPage.tsx +473 -0
- package/src/components/debug/pages/LogsPage.tsx +199 -0
- package/src/components/debug/pages/MailboxPage.tsx +232 -0
- package/src/components/debug/pages/ServicesPage.tsx +193 -0
- package/src/components/debug/pages/TimelinePage.tsx +569 -0
- package/src/components/debug/pages/UserChatPage.tsx +250 -0
- package/src/components/debug/pages/index.ts +13 -0
- package/src/index.ts +55 -0
- package/src/lib/domain-utils.ts +12 -0
- package/src/providers/EventPollingProvider.tsx +60 -0
- package/src/stores/event-store.ts +497 -0
- package/src/utils/format.ts +8 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import type { LLMCallLogEntry } from '@roj-ai/sdk'
|
|
2
|
+
import type { AgentId } from '@roj-ai/shared'
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
4
|
+
import { api, unwrap } from '@roj-ai/client'
|
|
5
|
+
import { useEventStore } from '../../../stores/event-store'
|
|
6
|
+
import { DebugLink, useDebugSessionId } from '../DebugNavigation'
|
|
7
|
+
|
|
8
|
+
function isLLMCallLogEntry(data: unknown): data is LLMCallLogEntry {
|
|
9
|
+
return typeof data === 'object' && data !== null && 'id' in data && 'status' in data && 'request' in data
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 50
|
|
13
|
+
const FETCH_LIMIT = 1000
|
|
14
|
+
|
|
15
|
+
type SortField = 'time' | 'duration' | 'tokens' | 'cost'
|
|
16
|
+
type SortDirection = 'asc' | 'desc'
|
|
17
|
+
type StatusFilter = 'all' | 'running' | 'success' | 'error'
|
|
18
|
+
|
|
19
|
+
export function LLMCallsPage() {
|
|
20
|
+
const sessionId = useDebugSessionId()
|
|
21
|
+
const [calls, setCalls] = useState<LLMCallLogEntry[]>([])
|
|
22
|
+
const [total, setTotal] = useState(0)
|
|
23
|
+
const [loading, setLoading] = useState(true)
|
|
24
|
+
const [error, setError] = useState<string | null>(null)
|
|
25
|
+
|
|
26
|
+
const [offset, setOffset] = useState(0)
|
|
27
|
+
const [agentFilter, setAgentFilter] = useState<string>('all')
|
|
28
|
+
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
|
29
|
+
const [providerFilter, setProviderFilter] = useState<string>('all')
|
|
30
|
+
const [modelFilter, setModelFilter] = useState<string>('all')
|
|
31
|
+
const [sortField, setSortField] = useState<SortField>('time')
|
|
32
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
|
33
|
+
|
|
34
|
+
const agentProjection = useEventStore((s) => s.agentDetailProjectionState)
|
|
35
|
+
const agentNameById = useMemo(() => {
|
|
36
|
+
const map = new Map<AgentId, string>()
|
|
37
|
+
for (const agent of agentProjection.agents.values()) {
|
|
38
|
+
map.set(agent.id, agent.definitionName)
|
|
39
|
+
}
|
|
40
|
+
return map
|
|
41
|
+
}, [agentProjection])
|
|
42
|
+
|
|
43
|
+
const resolveAgentName = useCallback(
|
|
44
|
+
(agentId: AgentId) => agentNameById.get(agentId) ?? agentId,
|
|
45
|
+
[agentNameById],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const load = useCallback(async () => {
|
|
49
|
+
if (!sessionId) return
|
|
50
|
+
try {
|
|
51
|
+
const data = unwrap(await api.call('llm.getCalls', {
|
|
52
|
+
sessionId,
|
|
53
|
+
limit: FETCH_LIMIT,
|
|
54
|
+
offset: 0,
|
|
55
|
+
}))
|
|
56
|
+
setCalls(data.calls.filter(isLLMCallLogEntry))
|
|
57
|
+
setTotal(data.total)
|
|
58
|
+
setError(null)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : 'Failed to load LLM calls')
|
|
61
|
+
} finally {
|
|
62
|
+
setLoading(false)
|
|
63
|
+
}
|
|
64
|
+
}, [sessionId])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
load()
|
|
68
|
+
const interval = setInterval(load, 5000)
|
|
69
|
+
return () => clearInterval(interval)
|
|
70
|
+
}, [load])
|
|
71
|
+
|
|
72
|
+
const uniqueAgents = useMemo(() => {
|
|
73
|
+
const ids = new Set<AgentId>()
|
|
74
|
+
for (const call of calls) ids.add(call.agentId)
|
|
75
|
+
return Array.from(ids)
|
|
76
|
+
.map((id) => ({ id, name: resolveAgentName(id) }))
|
|
77
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
78
|
+
}, [calls, resolveAgentName])
|
|
79
|
+
|
|
80
|
+
const uniqueProviders = useMemo(() => {
|
|
81
|
+
const set = new Set<string>()
|
|
82
|
+
for (const call of calls) {
|
|
83
|
+
const p = call.metrics?.provider
|
|
84
|
+
if (p) set.add(p)
|
|
85
|
+
}
|
|
86
|
+
return Array.from(set).sort()
|
|
87
|
+
}, [calls])
|
|
88
|
+
|
|
89
|
+
const uniqueModels = useMemo(() => {
|
|
90
|
+
const set = new Set<string>()
|
|
91
|
+
for (const call of calls) set.add(call.request.model)
|
|
92
|
+
return Array.from(set).sort()
|
|
93
|
+
}, [calls])
|
|
94
|
+
|
|
95
|
+
const filteredCalls = useMemo(() => {
|
|
96
|
+
return calls.filter((call) => {
|
|
97
|
+
if (agentFilter !== 'all' && call.agentId !== agentFilter) return false
|
|
98
|
+
if (statusFilter !== 'all' && call.status !== statusFilter) return false
|
|
99
|
+
if (providerFilter !== 'all' && (call.metrics?.provider ?? '') !== providerFilter) return false
|
|
100
|
+
if (modelFilter !== 'all' && call.request.model !== modelFilter) return false
|
|
101
|
+
return true
|
|
102
|
+
})
|
|
103
|
+
}, [calls, agentFilter, statusFilter, providerFilter, modelFilter])
|
|
104
|
+
|
|
105
|
+
const sortedCalls = useMemo(() => {
|
|
106
|
+
const sorted = [...filteredCalls]
|
|
107
|
+
const direction = sortDirection === 'asc' ? 1 : -1
|
|
108
|
+
sorted.sort((a, b) => {
|
|
109
|
+
let aVal: number
|
|
110
|
+
let bVal: number
|
|
111
|
+
switch (sortField) {
|
|
112
|
+
case 'time':
|
|
113
|
+
aVal = a.createdAt
|
|
114
|
+
bVal = b.createdAt
|
|
115
|
+
break
|
|
116
|
+
case 'duration':
|
|
117
|
+
aVal = a.metrics?.latencyMs ?? a.durationMs ?? 0
|
|
118
|
+
bVal = b.metrics?.latencyMs ?? b.durationMs ?? 0
|
|
119
|
+
break
|
|
120
|
+
case 'tokens':
|
|
121
|
+
aVal = a.metrics?.totalTokens ?? 0
|
|
122
|
+
bVal = b.metrics?.totalTokens ?? 0
|
|
123
|
+
break
|
|
124
|
+
case 'cost':
|
|
125
|
+
aVal = a.metrics?.cost ?? 0
|
|
126
|
+
bVal = b.metrics?.cost ?? 0
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
return (aVal - bVal) * direction
|
|
130
|
+
})
|
|
131
|
+
return sorted
|
|
132
|
+
}, [filteredCalls, sortField, sortDirection])
|
|
133
|
+
|
|
134
|
+
const paginatedCalls = useMemo(
|
|
135
|
+
() => sortedCalls.slice(offset, offset + PAGE_SIZE),
|
|
136
|
+
[sortedCalls, offset],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const filteredTotal = sortedCalls.length
|
|
140
|
+
const totalPages = Math.max(1, Math.ceil(filteredTotal / PAGE_SIZE))
|
|
141
|
+
const currentPage = Math.floor(offset / PAGE_SIZE) + 1
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (offset >= filteredTotal && offset > 0) setOffset(0)
|
|
145
|
+
}, [filteredTotal, offset])
|
|
146
|
+
|
|
147
|
+
const totalTokens = sortedCalls.reduce((sum, c) => sum + (c.metrics?.totalTokens ?? 0), 0)
|
|
148
|
+
const totalCost = sortedCalls.reduce((sum, c) => sum + (c.metrics?.cost ?? 0), 0)
|
|
149
|
+
|
|
150
|
+
const toggleSort = (field: SortField) => {
|
|
151
|
+
if (sortField === field) {
|
|
152
|
+
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
|
153
|
+
} else {
|
|
154
|
+
setSortField(field)
|
|
155
|
+
setSortDirection('desc')
|
|
156
|
+
}
|
|
157
|
+
setOffset(0)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const resetPagination = () => setOffset(0)
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="space-y-4">
|
|
164
|
+
{/* Summary */}
|
|
165
|
+
<div className="flex items-center gap-6 text-sm">
|
|
166
|
+
<span className="text-slate-600">
|
|
167
|
+
<span className="font-medium text-slate-900">{filteredTotal}</span>
|
|
168
|
+
{filteredTotal !== total && <span className="text-slate-400"> / {total}</span>} calls
|
|
169
|
+
</span>
|
|
170
|
+
<span className="text-slate-600">
|
|
171
|
+
<span className="font-medium text-slate-900">{totalTokens.toLocaleString()}</span> tokens
|
|
172
|
+
</span>
|
|
173
|
+
{totalCost > 0 && (
|
|
174
|
+
<span className="text-slate-600">
|
|
175
|
+
<span className="font-medium text-green-600">${totalCost.toFixed(4)}</span> cost
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Filters */}
|
|
181
|
+
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
182
|
+
<FilterSelect
|
|
183
|
+
label='Agent'
|
|
184
|
+
value={agentFilter}
|
|
185
|
+
onChange={(v) => {
|
|
186
|
+
setAgentFilter(v)
|
|
187
|
+
resetPagination()
|
|
188
|
+
}}
|
|
189
|
+
options={[
|
|
190
|
+
{ value: 'all', label: 'All agents' },
|
|
191
|
+
...uniqueAgents.map((a) => ({ value: a.id, label: a.name })),
|
|
192
|
+
]}
|
|
193
|
+
/>
|
|
194
|
+
<FilterSelect
|
|
195
|
+
label='Status'
|
|
196
|
+
value={statusFilter}
|
|
197
|
+
onChange={(v) => {
|
|
198
|
+
setStatusFilter(v as StatusFilter)
|
|
199
|
+
resetPagination()
|
|
200
|
+
}}
|
|
201
|
+
options={[
|
|
202
|
+
{ value: 'all', label: 'All statuses' },
|
|
203
|
+
{ value: 'running', label: 'running' },
|
|
204
|
+
{ value: 'success', label: 'success' },
|
|
205
|
+
{ value: 'error', label: 'error' },
|
|
206
|
+
]}
|
|
207
|
+
/>
|
|
208
|
+
<FilterSelect
|
|
209
|
+
label='Provider'
|
|
210
|
+
value={providerFilter}
|
|
211
|
+
onChange={(v) => {
|
|
212
|
+
setProviderFilter(v)
|
|
213
|
+
resetPagination()
|
|
214
|
+
}}
|
|
215
|
+
options={[
|
|
216
|
+
{ value: 'all', label: 'All providers' },
|
|
217
|
+
...uniqueProviders.map((p) => ({ value: p, label: p })),
|
|
218
|
+
]}
|
|
219
|
+
/>
|
|
220
|
+
<FilterSelect
|
|
221
|
+
label='Model'
|
|
222
|
+
value={modelFilter}
|
|
223
|
+
onChange={(v) => {
|
|
224
|
+
setModelFilter(v)
|
|
225
|
+
resetPagination()
|
|
226
|
+
}}
|
|
227
|
+
options={[
|
|
228
|
+
{ value: 'all', label: 'All models' },
|
|
229
|
+
...uniqueModels.map((m) => ({ value: m, label: m })),
|
|
230
|
+
]}
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
{/* Error */}
|
|
235
|
+
{error && <div className="text-red-500 text-sm">{error}</div>}
|
|
236
|
+
|
|
237
|
+
{/* Table */}
|
|
238
|
+
<div className="bg-white rounded-md border border-slate-200 overflow-hidden">
|
|
239
|
+
{loading && calls.length === 0
|
|
240
|
+
? <div className="p-4 text-slate-500 text-sm">Loading...</div>
|
|
241
|
+
: paginatedCalls.length === 0
|
|
242
|
+
? <div className="p-4 text-slate-500 text-sm">No LLM calls found</div>
|
|
243
|
+
: (
|
|
244
|
+
<div className="overflow-x-auto">
|
|
245
|
+
<table className="w-full text-sm">
|
|
246
|
+
<thead className="bg-slate-50 border-b border-slate-200">
|
|
247
|
+
<tr>
|
|
248
|
+
<SortableHeader
|
|
249
|
+
label='Time'
|
|
250
|
+
field='time'
|
|
251
|
+
sortField={sortField}
|
|
252
|
+
sortDirection={sortDirection}
|
|
253
|
+
onClick={toggleSort}
|
|
254
|
+
align='left'
|
|
255
|
+
/>
|
|
256
|
+
<th className="px-3 py-2 text-left font-medium text-slate-600">Status</th>
|
|
257
|
+
<th className="px-3 py-2 text-left font-medium text-slate-600">Provider</th>
|
|
258
|
+
<th className="px-3 py-2 text-left font-medium text-slate-600">Model</th>
|
|
259
|
+
<SortableHeader
|
|
260
|
+
label='Duration'
|
|
261
|
+
field='duration'
|
|
262
|
+
sortField={sortField}
|
|
263
|
+
sortDirection={sortDirection}
|
|
264
|
+
onClick={toggleSort}
|
|
265
|
+
align='right'
|
|
266
|
+
/>
|
|
267
|
+
<SortableHeader
|
|
268
|
+
label='Tokens'
|
|
269
|
+
field='tokens'
|
|
270
|
+
sortField={sortField}
|
|
271
|
+
sortDirection={sortDirection}
|
|
272
|
+
onClick={toggleSort}
|
|
273
|
+
align='right'
|
|
274
|
+
/>
|
|
275
|
+
<SortableHeader
|
|
276
|
+
label='Cost'
|
|
277
|
+
field='cost'
|
|
278
|
+
sortField={sortField}
|
|
279
|
+
sortDirection={sortDirection}
|
|
280
|
+
onClick={toggleSort}
|
|
281
|
+
align='right'
|
|
282
|
+
/>
|
|
283
|
+
<th className="px-3 py-2 text-left font-medium text-slate-600">Agent</th>
|
|
284
|
+
</tr>
|
|
285
|
+
</thead>
|
|
286
|
+
<tbody className="divide-y divide-slate-200">
|
|
287
|
+
{paginatedCalls.map((call) => (
|
|
288
|
+
<CallRow
|
|
289
|
+
key={call.id}
|
|
290
|
+
call={call}
|
|
291
|
+
agentName={resolveAgentName(call.agentId)}
|
|
292
|
+
/>
|
|
293
|
+
))}
|
|
294
|
+
</tbody>
|
|
295
|
+
</table>
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Pagination */}
|
|
301
|
+
{totalPages > 1 && (
|
|
302
|
+
<div className="flex items-center justify-between">
|
|
303
|
+
<button
|
|
304
|
+
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
|
305
|
+
disabled={offset === 0}
|
|
306
|
+
className="px-3 py-1 text-sm border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
307
|
+
>
|
|
308
|
+
Previous
|
|
309
|
+
</button>
|
|
310
|
+
<span className="text-sm text-slate-600">
|
|
311
|
+
Page {currentPage} of {totalPages}
|
|
312
|
+
</span>
|
|
313
|
+
<button
|
|
314
|
+
onClick={() => setOffset(offset + PAGE_SIZE)}
|
|
315
|
+
disabled={currentPage >= totalPages}
|
|
316
|
+
className="px-3 py-1 text-sm border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
317
|
+
>
|
|
318
|
+
Next
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function FilterSelect({
|
|
327
|
+
label,
|
|
328
|
+
value,
|
|
329
|
+
onChange,
|
|
330
|
+
options,
|
|
331
|
+
}: {
|
|
332
|
+
label: string
|
|
333
|
+
value: string
|
|
334
|
+
onChange: (value: string) => void
|
|
335
|
+
options: { value: string; label: string }[]
|
|
336
|
+
}) {
|
|
337
|
+
return (
|
|
338
|
+
<label className="text-slate-600 flex items-center gap-2">
|
|
339
|
+
<span>{label}:</span>
|
|
340
|
+
<select
|
|
341
|
+
value={value}
|
|
342
|
+
onChange={(e) => onChange(e.target.value)}
|
|
343
|
+
className="border border-slate-300 rounded px-2 py-1 text-sm bg-white"
|
|
344
|
+
>
|
|
345
|
+
{options.map((opt) => (
|
|
346
|
+
<option key={opt.value} value={opt.value}>
|
|
347
|
+
{opt.label}
|
|
348
|
+
</option>
|
|
349
|
+
))}
|
|
350
|
+
</select>
|
|
351
|
+
</label>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function SortableHeader({
|
|
356
|
+
label,
|
|
357
|
+
field,
|
|
358
|
+
sortField,
|
|
359
|
+
sortDirection,
|
|
360
|
+
onClick,
|
|
361
|
+
align,
|
|
362
|
+
}: {
|
|
363
|
+
label: string
|
|
364
|
+
field: SortField
|
|
365
|
+
sortField: SortField
|
|
366
|
+
sortDirection: SortDirection
|
|
367
|
+
onClick: (field: SortField) => void
|
|
368
|
+
align: 'left' | 'right'
|
|
369
|
+
}) {
|
|
370
|
+
const active = sortField === field
|
|
371
|
+
const arrow = active ? (sortDirection === 'asc' ? '▲' : '▼') : ''
|
|
372
|
+
return (
|
|
373
|
+
<th className={`px-3 py-2 font-medium text-slate-600 ${align === 'right' ? 'text-right' : 'text-left'}`}>
|
|
374
|
+
<button
|
|
375
|
+
type='button'
|
|
376
|
+
onClick={() => onClick(field)}
|
|
377
|
+
className={`inline-flex items-center gap-1 hover:text-slate-900 ${active ? 'text-slate-900' : ''}`}
|
|
378
|
+
>
|
|
379
|
+
<span>{label}</span>
|
|
380
|
+
{arrow && <span className="text-xs">{arrow}</span>}
|
|
381
|
+
</button>
|
|
382
|
+
</th>
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function CallRow({ call, agentName }: { call: LLMCallLogEntry; agentName: string }) {
|
|
387
|
+
const metrics = call.metrics
|
|
388
|
+
|
|
389
|
+
const promptTokens = metrics?.promptTokens ?? 0
|
|
390
|
+
const completionTokens = metrics?.completionTokens ?? 0
|
|
391
|
+
const totalTokens = promptTokens + completionTokens
|
|
392
|
+
const cost = metrics?.cost
|
|
393
|
+
const latency = metrics?.latencyMs ?? call.durationMs
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<tr className="hover:bg-slate-50">
|
|
397
|
+
{/* Time */}
|
|
398
|
+
<td className="px-3 py-2 font-mono text-xs text-slate-500">
|
|
399
|
+
<DebugLink
|
|
400
|
+
to={`llm-calls/${call.id}`}
|
|
401
|
+
className="hover:text-violet-600"
|
|
402
|
+
>
|
|
403
|
+
{new Date(call.createdAt).toLocaleTimeString()}
|
|
404
|
+
</DebugLink>
|
|
405
|
+
</td>
|
|
406
|
+
|
|
407
|
+
{/* Status */}
|
|
408
|
+
<td className="px-3 py-2">
|
|
409
|
+
<StatusBadge status={call.status} />
|
|
410
|
+
</td>
|
|
411
|
+
|
|
412
|
+
{/* Provider */}
|
|
413
|
+
<td className="px-3 py-2 text-xs text-slate-500">
|
|
414
|
+
{call.metrics?.provider ?? '—'}
|
|
415
|
+
</td>
|
|
416
|
+
|
|
417
|
+
{/* Model */}
|
|
418
|
+
<td className="px-3 py-2">
|
|
419
|
+
<DebugLink
|
|
420
|
+
to={`llm-calls/${call.id}`}
|
|
421
|
+
className="font-mono text-xs hover:text-violet-600"
|
|
422
|
+
>
|
|
423
|
+
{call.request.model.split('/').pop()}
|
|
424
|
+
</DebugLink>
|
|
425
|
+
</td>
|
|
426
|
+
|
|
427
|
+
{/* Duration */}
|
|
428
|
+
<td className="px-3 py-2 text-right font-mono text-xs">
|
|
429
|
+
{latency !== undefined ? `${(latency / 1000).toFixed(2)}s` : '—'}
|
|
430
|
+
</td>
|
|
431
|
+
|
|
432
|
+
{/* Tokens */}
|
|
433
|
+
<td className="px-3 py-2 text-right">
|
|
434
|
+
<div className="font-mono text-xs">
|
|
435
|
+
<span className="text-green-600">{promptTokens.toLocaleString()}</span>
|
|
436
|
+
<span className="text-slate-400">/</span>
|
|
437
|
+
<span className="text-violet-600">{completionTokens.toLocaleString()}</span>
|
|
438
|
+
</div>
|
|
439
|
+
<div className="text-xs text-slate-500">{totalTokens.toLocaleString()} total</div>
|
|
440
|
+
</td>
|
|
441
|
+
|
|
442
|
+
{/* Cost */}
|
|
443
|
+
<td className="px-3 py-2 text-right font-mono text-xs">
|
|
444
|
+
{cost !== undefined ? <span className="text-green-600">${cost.toFixed(5)}</span> : <span className="text-slate-400">—</span>}
|
|
445
|
+
</td>
|
|
446
|
+
|
|
447
|
+
{/* Agent */}
|
|
448
|
+
<td className="px-3 py-2">
|
|
449
|
+
<span title={call.agentId}>
|
|
450
|
+
<DebugLink
|
|
451
|
+
to={`agents/${call.agentId}`}
|
|
452
|
+
className="text-xs text-slate-600 hover:text-violet-600 whitespace-nowrap"
|
|
453
|
+
>
|
|
454
|
+
{agentName}
|
|
455
|
+
</DebugLink>
|
|
456
|
+
</span>
|
|
457
|
+
</td>
|
|
458
|
+
</tr>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function StatusBadge({ status }: { status: 'running' | 'success' | 'error' }) {
|
|
463
|
+
const styles: Record<string, string> = {
|
|
464
|
+
running: 'bg-yellow-100 text-yellow-700',
|
|
465
|
+
success: 'bg-green-100 text-green-700',
|
|
466
|
+
error: 'bg-red-100 text-red-700',
|
|
467
|
+
}
|
|
468
|
+
return (
|
|
469
|
+
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status]}`}>
|
|
470
|
+
{status}
|
|
471
|
+
</span>
|
|
472
|
+
)
|
|
473
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { api } from "@roj-ai/client";
|
|
3
|
+
import { useDebugSessionId } from "../DebugNavigation";
|
|
4
|
+
|
|
5
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
6
|
+
|
|
7
|
+
interface LogEntry {
|
|
8
|
+
timestamp: string;
|
|
9
|
+
level: LogLevel;
|
|
10
|
+
message: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
|
|
15
|
+
|
|
16
|
+
const LEVEL_STYLES: Record<LogLevel, string> = {
|
|
17
|
+
debug: "text-slate-400",
|
|
18
|
+
info: "text-slate-700",
|
|
19
|
+
warn: "text-yellow-700 bg-yellow-50",
|
|
20
|
+
error: "text-red-700 bg-red-50",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const BADGE_STYLES: Record<LogLevel, string> = {
|
|
24
|
+
debug: "bg-slate-100 text-slate-500",
|
|
25
|
+
info: "bg-blue-100 text-blue-700",
|
|
26
|
+
warn: "bg-yellow-100 text-yellow-700",
|
|
27
|
+
error: "bg-red-100 text-red-700",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function parseLogEntry(line: string): LogEntry | null {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
33
|
+
return {
|
|
34
|
+
timestamp: (parsed.timestamp as string) ?? "",
|
|
35
|
+
level: (LOG_LEVELS.includes(parsed.level as LogLevel)
|
|
36
|
+
? parsed.level
|
|
37
|
+
: "info") as LogLevel,
|
|
38
|
+
message: (parsed.message as string) ?? "",
|
|
39
|
+
...parsed,
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function LogsPage() {
|
|
47
|
+
const sessionId = useDebugSessionId();
|
|
48
|
+
const [entries, setEntries] = useState<LogEntry[]>([]);
|
|
49
|
+
const [offset, setOffset] = useState(0);
|
|
50
|
+
const [minLevel, setMinLevel] = useState<LogLevel>("debug");
|
|
51
|
+
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
|
|
52
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const autoScrollRef = useRef(true);
|
|
54
|
+
|
|
55
|
+
const poll = useCallback(async () => {
|
|
56
|
+
if (!sessionId) return;
|
|
57
|
+
try {
|
|
58
|
+
const result = await api.call("logs.tail", { sessionId, since: offset });
|
|
59
|
+
if (!result.ok) return;
|
|
60
|
+
const data = result.value;
|
|
61
|
+
if (data.lines.length > 0) {
|
|
62
|
+
const newEntries = data.lines
|
|
63
|
+
.map(parseLogEntry)
|
|
64
|
+
.filter((e: LogEntry | null): e is LogEntry => e !== null);
|
|
65
|
+
setEntries((prev) => [...prev, ...newEntries]);
|
|
66
|
+
}
|
|
67
|
+
setOffset(data.offset);
|
|
68
|
+
} catch {
|
|
69
|
+
// ignore fetch errors
|
|
70
|
+
}
|
|
71
|
+
}, [sessionId, offset]);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
poll();
|
|
75
|
+
const interval = setInterval(poll, 2000);
|
|
76
|
+
return () => clearInterval(interval);
|
|
77
|
+
}, [poll]);
|
|
78
|
+
|
|
79
|
+
// Auto-scroll
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (autoScrollRef.current && containerRef.current) {
|
|
82
|
+
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const handleScroll = () => {
|
|
87
|
+
const el = containerRef.current;
|
|
88
|
+
if (!el) return;
|
|
89
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
90
|
+
autoScrollRef.current = atBottom;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const toggleRow = (index: number) => {
|
|
94
|
+
setExpandedRows((prev) => {
|
|
95
|
+
const next = new Set(prev);
|
|
96
|
+
if (next.has(index)) {
|
|
97
|
+
next.delete(index);
|
|
98
|
+
} else {
|
|
99
|
+
next.add(index);
|
|
100
|
+
}
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (!sessionId) return null;
|
|
106
|
+
|
|
107
|
+
const minLevelIndex = LOG_LEVELS.indexOf(minLevel);
|
|
108
|
+
const filtered = entries.filter(
|
|
109
|
+
(e) => LOG_LEVELS.indexOf(e.level) >= minLevelIndex,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="flex flex-col h-full gap-3">
|
|
114
|
+
{/* Toolbar */}
|
|
115
|
+
<div className="flex items-center gap-4 text-sm shrink-0">
|
|
116
|
+
<span className="text-slate-600">
|
|
117
|
+
<span className="font-medium text-slate-900">{filtered.length}</span>
|
|
118
|
+
{filtered.length !== entries.length && (
|
|
119
|
+
<span className="text-slate-400"> / {entries.length}</span>
|
|
120
|
+
)}{" "}
|
|
121
|
+
lines
|
|
122
|
+
</span>
|
|
123
|
+
<label className="flex items-center gap-2 text-slate-600">
|
|
124
|
+
Min level:
|
|
125
|
+
<select
|
|
126
|
+
value={minLevel}
|
|
127
|
+
onChange={(e) => setMinLevel(e.target.value as LogLevel)}
|
|
128
|
+
className="text-sm border border-slate-300 rounded px-2 py-1 bg-white"
|
|
129
|
+
>
|
|
130
|
+
{LOG_LEVELS.map((l) => (
|
|
131
|
+
<option key={l} value={l}>
|
|
132
|
+
{l}
|
|
133
|
+
</option>
|
|
134
|
+
))}
|
|
135
|
+
</select>
|
|
136
|
+
</label>
|
|
137
|
+
<span
|
|
138
|
+
className={`text-xs px-2 py-0.5 rounded-full ${autoScrollRef.current ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"}`}
|
|
139
|
+
>
|
|
140
|
+
{autoScrollRef.current ? "auto-scroll on" : "auto-scroll off"}
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
{/* Log entries */}
|
|
145
|
+
<div
|
|
146
|
+
ref={containerRef}
|
|
147
|
+
onScroll={handleScroll}
|
|
148
|
+
className="flex-1 overflow-auto bg-white rounded-md border border-slate-200 font-mono text-xs"
|
|
149
|
+
>
|
|
150
|
+
{entries.length === 0 ? (
|
|
151
|
+
<div className="p-4 text-slate-500 text-sm font-sans">
|
|
152
|
+
No log entries yet
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<table className="w-full">
|
|
156
|
+
<tbody>
|
|
157
|
+
{filtered.map((entry) => {
|
|
158
|
+
const globalIndex = entries.indexOf(entry);
|
|
159
|
+
const expanded = expandedRows.has(globalIndex);
|
|
160
|
+
const { timestamp, level, message, ...context } = entry;
|
|
161
|
+
const hasContext = Object.keys(context).length > 0;
|
|
162
|
+
const time = timestamp
|
|
163
|
+
? new Date(timestamp).toLocaleTimeString()
|
|
164
|
+
: "";
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<tr
|
|
168
|
+
key={globalIndex}
|
|
169
|
+
onClick={() => hasContext && toggleRow(globalIndex)}
|
|
170
|
+
className={`border-b border-slate-100 align-top ${LEVEL_STYLES[level]} ${hasContext ? "cursor-pointer hover:bg-slate-50" : ""}`}
|
|
171
|
+
>
|
|
172
|
+
<td className="px-2 py-1 text-slate-400 whitespace-nowrap w-0">
|
|
173
|
+
{time}
|
|
174
|
+
</td>
|
|
175
|
+
<td className="px-2 py-1 w-0">
|
|
176
|
+
<span
|
|
177
|
+
className={`px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase ${BADGE_STYLES[level]}`}
|
|
178
|
+
>
|
|
179
|
+
{level}
|
|
180
|
+
</span>
|
|
181
|
+
</td>
|
|
182
|
+
<td className="px-2 py-1 break-all">
|
|
183
|
+
<div>{message}</div>
|
|
184
|
+
{expanded && hasContext && (
|
|
185
|
+
<pre className="mt-1 p-2 bg-slate-50 rounded border border-slate-200 text-[11px] whitespace-pre-wrap break-words">
|
|
186
|
+
{JSON.stringify(context, null, 2)}
|
|
187
|
+
</pre>
|
|
188
|
+
)}
|
|
189
|
+
</td>
|
|
190
|
+
</tr>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|