@tanstack/ai-devtools-core 0.3.38 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Shell/DKCFIT6H.js +12142 -0
- package/dist/Shell/SLOX2TPU.js +12119 -0
- package/dist/Shell/Z6HLJOVZ.js +10170 -0
- package/dist/dev.js +1 -1
- package/dist/index.js +1 -1
- package/dist/production/Shell/DKCFIT6H.js +12142 -0
- package/dist/production/Shell/SLOX2TPU.js +12119 -0
- package/dist/production/Shell/Z6HLJOVZ.js +10170 -0
- package/dist/production/dev.js +1 -1
- package/dist/production/index.js +1 -1
- package/dist/production/server.js +1 -1
- package/dist/server.js +1 -1
- package/package.json +4 -4
- package/src/components/Shell.tsx +74 -30
- package/src/components/conversation/IterationCard.tsx +261 -12
- package/src/components/conversation/IterationTimeline.tsx +66 -16
- package/src/components/conversation/index.ts +0 -9
- package/src/components/hooks/FixtureNamePopover.tsx +69 -0
- package/src/components/hooks/GenerationPanel.tsx +847 -0
- package/src/components/hooks/HookDashboard.tsx +292 -0
- package/src/components/hooks/HookDetails.tsx +1757 -0
- package/src/components/hooks/ToolFixtureForm.tsx +347 -0
- package/src/components/hooks/hook-dashboard-model.ts +134 -0
- package/src/components/hooks/index.ts +4 -0
- package/src/components/hooks/preview-messages.ts +117 -0
- package/src/components/hooks/preview-model.ts +195 -0
- package/src/components/utils/format.ts +0 -58
- package/src/store/ai-context.tsx +1165 -159
- package/src/store/ai-store.ts +0 -1
- package/src/store/hook-registry.ts +815 -0
- package/src/store/message-event-utils.ts +60 -0
- package/src/styles/use-styles.ts +1558 -99
- package/dist/Shell/MRWZEHAJ.js +0 -6225
- package/dist/Shell/RRRNZJ5E.js +0 -7560
- package/dist/production/Shell/MRWZEHAJ.js +0 -6225
- package/dist/production/Shell/RRRNZJ5E.js +0 -7560
- package/src/components/ConversationDetails.tsx +0 -216
- package/src/components/ConversationsList.tsx +0 -21
- package/src/components/conversation/ActivityEventsTab.tsx +0 -57
- package/src/components/conversation/ChunkBadges.tsx +0 -51
- package/src/components/conversation/ChunkItem.tsx +0 -273
- package/src/components/conversation/ChunksCollapsible.tsx +0 -56
- package/src/components/conversation/ChunksTab.tsx +0 -87
- package/src/components/conversation/ConversationHeader.tsx +0 -106
- package/src/components/conversation/ConversationTabs.tsx +0 -205
- package/src/components/conversation/MessageCard.tsx +0 -174
- package/src/components/conversation/MessageGroup.tsx +0 -101
- package/src/components/conversation/MessagesTab.tsx +0 -30
- package/src/components/conversation/SummariesTab.tsx +0 -91
- package/src/components/conversation/ToolCallDisplay.tsx +0 -99
- package/src/components/list/ConversationRow.tsx +0 -85
- package/src/components/list/index.ts +0 -1
|
@@ -0,0 +1,1757 @@
|
|
|
1
|
+
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
|
|
2
|
+
import { JsonTree } from '@tanstack/devtools-ui'
|
|
3
|
+
import { useAIStore } from '../../store/ai-context'
|
|
4
|
+
import { useStyles } from '../../styles/use-styles'
|
|
5
|
+
import {
|
|
6
|
+
createToolFixtureRecord,
|
|
7
|
+
getHookUnseenEventCount,
|
|
8
|
+
} from '../../store/hook-registry'
|
|
9
|
+
import { IterationTimeline } from '../conversation'
|
|
10
|
+
import { FixtureNamePopover } from './FixtureNamePopover'
|
|
11
|
+
import { ToolFixtureForm } from './ToolFixtureForm'
|
|
12
|
+
import {
|
|
13
|
+
createHookDashboardSummary,
|
|
14
|
+
getHookDisplayName,
|
|
15
|
+
groupHooksByCategory,
|
|
16
|
+
isHookRunning,
|
|
17
|
+
visibleHooks,
|
|
18
|
+
} from './hook-dashboard-model'
|
|
19
|
+
import {
|
|
20
|
+
createHoverTarget,
|
|
21
|
+
createHoverTargetFromDataAttributes,
|
|
22
|
+
getHoverDataAttributes,
|
|
23
|
+
hoverTargetMatchesElement,
|
|
24
|
+
isMessageHighlighted,
|
|
25
|
+
isMessageOrPartHighlighted,
|
|
26
|
+
parseJsonishValue,
|
|
27
|
+
structuredOutputJsonItems,
|
|
28
|
+
structuredOutputPartId,
|
|
29
|
+
toolCallPartId,
|
|
30
|
+
toolResultPartId,
|
|
31
|
+
} from './preview-model'
|
|
32
|
+
import {
|
|
33
|
+
hasStructuredOutputPreview,
|
|
34
|
+
mergePreviewMessagesForUserView,
|
|
35
|
+
partsToMessageText,
|
|
36
|
+
visiblePreviewPartsForMessage,
|
|
37
|
+
} from './preview-messages'
|
|
38
|
+
import { GenerationPanel, GenerationPreview } from './GenerationPanel'
|
|
39
|
+
import type { HoverOrigin, HoverTarget, PreviewJsonItem } from './preview-model'
|
|
40
|
+
import type {
|
|
41
|
+
HookRecord,
|
|
42
|
+
RunRecord,
|
|
43
|
+
ToolFixtureMessage,
|
|
44
|
+
ToolFixtureRecord,
|
|
45
|
+
} from '../../store/hook-registry'
|
|
46
|
+
import type { Conversation, Message, ToolCall } from '../../store/ai-store'
|
|
47
|
+
import type { Component, Setter } from 'solid-js'
|
|
48
|
+
|
|
49
|
+
type DetailTab = 'conversation' | 'tools' | 'state'
|
|
50
|
+
type MessagePart = NonNullable<Message['parts']>[number]
|
|
51
|
+
const scrollAnimations = new WeakMap<HTMLElement, number>()
|
|
52
|
+
|
|
53
|
+
interface PreviewMessage {
|
|
54
|
+
id: string
|
|
55
|
+
role: string
|
|
56
|
+
content: string
|
|
57
|
+
parts: Array<PreviewPart>
|
|
58
|
+
sourceMessage?: ToolFixtureMessage
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface PreviewPart {
|
|
62
|
+
id: string
|
|
63
|
+
label: string
|
|
64
|
+
content?: string
|
|
65
|
+
jsonItems?: Array<PreviewJsonItem>
|
|
66
|
+
kind:
|
|
67
|
+
| 'text'
|
|
68
|
+
| 'thinking'
|
|
69
|
+
| 'tool-call'
|
|
70
|
+
| 'tool-result'
|
|
71
|
+
| 'structured-output'
|
|
72
|
+
| 'media'
|
|
73
|
+
fixture?: PreviewToolFixture
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PreviewToolFixture {
|
|
77
|
+
toolName: string
|
|
78
|
+
input: unknown
|
|
79
|
+
output?: unknown
|
|
80
|
+
toolCallId?: string
|
|
81
|
+
messageId?: string
|
|
82
|
+
errorText?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface PreviewPartSource {
|
|
86
|
+
id?: unknown
|
|
87
|
+
type?: unknown
|
|
88
|
+
toolCallId?: unknown
|
|
89
|
+
name?: unknown
|
|
90
|
+
input?: unknown
|
|
91
|
+
arguments?: unknown
|
|
92
|
+
output?: unknown
|
|
93
|
+
content?: unknown
|
|
94
|
+
text?: unknown
|
|
95
|
+
error?: unknown
|
|
96
|
+
toolName?: unknown
|
|
97
|
+
approval?: unknown
|
|
98
|
+
state?: unknown
|
|
99
|
+
status?: unknown
|
|
100
|
+
raw?: unknown
|
|
101
|
+
partial?: unknown
|
|
102
|
+
data?: unknown
|
|
103
|
+
reasoning?: unknown
|
|
104
|
+
errorMessage?: unknown
|
|
105
|
+
source?: unknown
|
|
106
|
+
metadata?: unknown
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const HookDetails: Component = () => {
|
|
110
|
+
const { state, selectHook, selectConversation } = useAIStore()
|
|
111
|
+
const styles = useStyles()
|
|
112
|
+
const [activeTab, setActiveTab] = createSignal<DetailTab>('conversation')
|
|
113
|
+
const [hoverTarget, setHoverTarget] = createSignal<HoverTarget | null>(null)
|
|
114
|
+
let timelinePane: HTMLElement | undefined
|
|
115
|
+
let previewPane: HTMLElement | undefined
|
|
116
|
+
|
|
117
|
+
const hook = createMemo((): HookRecord | undefined => {
|
|
118
|
+
const id = state.hooks.activeHookId
|
|
119
|
+
return id ? state.hooks.hooks[id] : undefined
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const conversation = createMemo(() => {
|
|
123
|
+
const activeHook = hook()
|
|
124
|
+
if (!activeHook) return undefined
|
|
125
|
+
return findConversationForHook(activeHook, state.conversations)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const runs = createMemo(() => {
|
|
129
|
+
const activeHook = hook()
|
|
130
|
+
if (!activeHook) return []
|
|
131
|
+
return activeHook.runIds
|
|
132
|
+
.map((id) => state.hooks.runs[id])
|
|
133
|
+
.filter((run): run is RunRecord => Boolean(run))
|
|
134
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const isGenerationHook = createMemo(() => {
|
|
138
|
+
const outputKind = hook()?.outputKind
|
|
139
|
+
// 'structured' is a chat hook variant (useChat with outputSchema), not a
|
|
140
|
+
// generation hook — it still has a conversation timeline and tool calls.
|
|
141
|
+
return Boolean(
|
|
142
|
+
outputKind && outputKind !== 'chat' && outputKind !== 'structured',
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
createEffect(() => {
|
|
147
|
+
if (isGenerationHook() && activeTab() === 'tools') {
|
|
148
|
+
setActiveTab('conversation')
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const previewMessages = createMemo(() => {
|
|
153
|
+
const activeHook = hook()
|
|
154
|
+
if (!activeHook) return []
|
|
155
|
+
const snapshotMessages = messagesFromSnapshot(activeHook.state)
|
|
156
|
+
const conversationMessages = conversation()?.messages ?? []
|
|
157
|
+
if (conversationMessages.length > 0) {
|
|
158
|
+
return mergePreviewMessagesForUserView(
|
|
159
|
+
conversationMessages.map(messageFromConversation),
|
|
160
|
+
snapshotMessages,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (snapshotMessages.length > 0) return snapshotMessages
|
|
164
|
+
return []
|
|
165
|
+
})
|
|
166
|
+
const showSecondaryPane = createMemo(() => {
|
|
167
|
+
// Tools tab owns its own form/saved-fixtures layout that fills the
|
|
168
|
+
// primary pane; the secondary "User View" preview squeezes the tool
|
|
169
|
+
// detail column to zero width on narrower hookDetails widths.
|
|
170
|
+
if (activeTab() === 'tools' && !isGenerationHook()) return false
|
|
171
|
+
return isGenerationHook() || !hasStructuredOutputPreview(previewMessages())
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
createEffect(() => {
|
|
175
|
+
const target = hoverTarget()
|
|
176
|
+
if (!target) return
|
|
177
|
+
|
|
178
|
+
const pane = target.origin === 'timeline' ? previewPane : timelinePane
|
|
179
|
+
if (!pane) return
|
|
180
|
+
|
|
181
|
+
const element = findMatchingHoverElement(pane, target)
|
|
182
|
+
if (!element) return
|
|
183
|
+
|
|
184
|
+
scrollElementIntoPaneView(pane, element, {
|
|
185
|
+
align:
|
|
186
|
+
target.origin === 'preview' && target.partIds.length === 0
|
|
187
|
+
? 'start'
|
|
188
|
+
: 'nearest',
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<Show
|
|
194
|
+
when={hook()}
|
|
195
|
+
fallback={
|
|
196
|
+
<HookOverview
|
|
197
|
+
onSelectHook={(selectedHook) => {
|
|
198
|
+
selectHook(selectedHook.id)
|
|
199
|
+
if (state.conversations[selectedHook.id]) {
|
|
200
|
+
selectConversation(selectedHook.id)
|
|
201
|
+
}
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
}
|
|
205
|
+
>
|
|
206
|
+
{(activeHook) => (
|
|
207
|
+
<div
|
|
208
|
+
class={styles().hookDetails.container}
|
|
209
|
+
data-testid="ai-devtools-hook-detail"
|
|
210
|
+
>
|
|
211
|
+
<HookHeader
|
|
212
|
+
hook={activeHook()}
|
|
213
|
+
runs={runs().length}
|
|
214
|
+
messages={previewMessages().length}
|
|
215
|
+
totalTokens={conversation()?.usage?.totalTokens ?? 0}
|
|
216
|
+
isGenerationHook={isGenerationHook()}
|
|
217
|
+
/>
|
|
218
|
+
|
|
219
|
+
<nav
|
|
220
|
+
class={styles().hookDetails.tabs}
|
|
221
|
+
data-testid="ai-devtools-hook-tabs"
|
|
222
|
+
>
|
|
223
|
+
<TabButton
|
|
224
|
+
label={isGenerationHook() ? 'Generation' : 'Conversation'}
|
|
225
|
+
tab="conversation"
|
|
226
|
+
activeTab={activeTab()}
|
|
227
|
+
onSelect={setActiveTab}
|
|
228
|
+
/>
|
|
229
|
+
<Show when={!isGenerationHook()}>
|
|
230
|
+
<TabButton
|
|
231
|
+
label="Tools"
|
|
232
|
+
tab="tools"
|
|
233
|
+
activeTab={activeTab()}
|
|
234
|
+
onSelect={setActiveTab}
|
|
235
|
+
/>
|
|
236
|
+
</Show>
|
|
237
|
+
<TabButton
|
|
238
|
+
label="State"
|
|
239
|
+
tab="state"
|
|
240
|
+
activeTab={activeTab()}
|
|
241
|
+
onSelect={setActiveTab}
|
|
242
|
+
/>
|
|
243
|
+
</nav>
|
|
244
|
+
|
|
245
|
+
<div
|
|
246
|
+
class={`${styles().hookDetails.body} ${
|
|
247
|
+
showSecondaryPane() ? '' : styles().hookDetails.bodySinglePane
|
|
248
|
+
}`}
|
|
249
|
+
>
|
|
250
|
+
<main
|
|
251
|
+
class={styles().hookDetails.primary}
|
|
252
|
+
data-testid="ai-devtools-primary-pane"
|
|
253
|
+
ref={(el) => {
|
|
254
|
+
timelinePane = el
|
|
255
|
+
}}
|
|
256
|
+
onMouseOver={(event) =>
|
|
257
|
+
setHoverTargetFromEvent(event, 'timeline', setHoverTarget)
|
|
258
|
+
}
|
|
259
|
+
onMouseLeave={() => setHoverTarget(null)}
|
|
260
|
+
>
|
|
261
|
+
<Show when={activeTab() === 'conversation'}>
|
|
262
|
+
<Show
|
|
263
|
+
when={isGenerationHook()}
|
|
264
|
+
fallback={
|
|
265
|
+
<ConversationPanel
|
|
266
|
+
hook={activeHook()}
|
|
267
|
+
conversation={conversation()}
|
|
268
|
+
messages={previewMessages()}
|
|
269
|
+
hoverTarget={hoverTarget()}
|
|
270
|
+
onHoverTarget={setHoverTarget}
|
|
271
|
+
/>
|
|
272
|
+
}
|
|
273
|
+
>
|
|
274
|
+
<GenerationPanel
|
|
275
|
+
hook={activeHook()}
|
|
276
|
+
hoverTarget={hoverTarget()}
|
|
277
|
+
/>
|
|
278
|
+
</Show>
|
|
279
|
+
</Show>
|
|
280
|
+
<Show when={!isGenerationHook() && activeTab() === 'tools'}>
|
|
281
|
+
<ToolsView hook={activeHook()} />
|
|
282
|
+
</Show>
|
|
283
|
+
<Show when={activeTab() === 'state'}>
|
|
284
|
+
<JsonPanel value={activeHook().state} />
|
|
285
|
+
</Show>
|
|
286
|
+
</main>
|
|
287
|
+
|
|
288
|
+
<Show when={showSecondaryPane()}>
|
|
289
|
+
<aside
|
|
290
|
+
class={styles().hookDetails.previewPane}
|
|
291
|
+
data-testid="ai-devtools-preview-pane"
|
|
292
|
+
ref={(el) => {
|
|
293
|
+
previewPane = el
|
|
294
|
+
}}
|
|
295
|
+
onMouseOver={(event) =>
|
|
296
|
+
setHoverTargetFromEvent(event, 'preview', setHoverTarget)
|
|
297
|
+
}
|
|
298
|
+
onMouseLeave={() => setHoverTarget(null)}
|
|
299
|
+
>
|
|
300
|
+
<div class={styles().hookDetails.previewHeader}>
|
|
301
|
+
{isGenerationHook() ? 'Generated Output' : 'User View'}
|
|
302
|
+
</div>
|
|
303
|
+
<Show
|
|
304
|
+
when={isGenerationHook()}
|
|
305
|
+
fallback={
|
|
306
|
+
<MessagesPreview
|
|
307
|
+
hook={activeHook()}
|
|
308
|
+
messages={previewMessages()}
|
|
309
|
+
hoverTarget={hoverTarget()}
|
|
310
|
+
onHoverTarget={setHoverTarget}
|
|
311
|
+
/>
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
<GenerationPreview
|
|
315
|
+
hook={activeHook()}
|
|
316
|
+
hoverTarget={hoverTarget()}
|
|
317
|
+
/>
|
|
318
|
+
</Show>
|
|
319
|
+
</aside>
|
|
320
|
+
</Show>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</Show>
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const HookOverview: Component<{
|
|
329
|
+
onSelectHook: (hook: HookRecord) => void
|
|
330
|
+
}> = (props) => {
|
|
331
|
+
const { state } = useAIStore()
|
|
332
|
+
const styles = useStyles()
|
|
333
|
+
const hooks = createMemo(() =>
|
|
334
|
+
visibleHooks(Object.values(state.hooks.hooks)).sort(
|
|
335
|
+
(a, b) => a.registeredAt - b.registeredAt,
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
const groups = createMemo(() => groupHooksByCategory(hooks()))
|
|
339
|
+
const summary = createMemo(() =>
|
|
340
|
+
createHookDashboardSummary(hooks(), state.hooks.runs),
|
|
341
|
+
)
|
|
342
|
+
const totalMessages = createMemo(() =>
|
|
343
|
+
Object.values(state.conversations).reduce(
|
|
344
|
+
(count, conversation) => count + conversation.messages.length,
|
|
345
|
+
0,
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
const totalTokens = createMemo(() =>
|
|
349
|
+
Object.values(state.conversations).reduce(
|
|
350
|
+
(count, conversation) => count + (conversation.usage?.totalTokens ?? 0),
|
|
351
|
+
0,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<Show
|
|
357
|
+
when={hooks().length > 0}
|
|
358
|
+
fallback={
|
|
359
|
+
<div class={styles().hookDetails.empty}>
|
|
360
|
+
Open a page with TanStack AI hooks to inspect live state.
|
|
361
|
+
</div>
|
|
362
|
+
}
|
|
363
|
+
>
|
|
364
|
+
<div
|
|
365
|
+
class={styles().hookDetails.overview}
|
|
366
|
+
data-testid="ai-devtools-dashboard-overview"
|
|
367
|
+
>
|
|
368
|
+
<header class={styles().hookDetails.overviewHeader}>
|
|
369
|
+
<div>
|
|
370
|
+
<div class={styles().hookDetails.overviewTitle}>
|
|
371
|
+
Hooks Dashboard
|
|
372
|
+
</div>
|
|
373
|
+
<div class={styles().hookDetails.overviewSubtitle}>
|
|
374
|
+
Select a hook from the left or open one of the active hooks below.
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
</header>
|
|
378
|
+
|
|
379
|
+
<div class={styles().hookDetails.overviewMetricGrid}>
|
|
380
|
+
<OverviewMetric label="active hooks" value={summary().active} />
|
|
381
|
+
<OverviewMetric label="tools" value={summary().tools} />
|
|
382
|
+
<OverviewMetric
|
|
383
|
+
label="messages / runs"
|
|
384
|
+
value={`${totalMessages()} / ${summary().runs}`}
|
|
385
|
+
/>
|
|
386
|
+
<OverviewMetric
|
|
387
|
+
label="tokens"
|
|
388
|
+
value={formatMetricValue(totalTokens())}
|
|
389
|
+
/>
|
|
390
|
+
<OverviewMetric label="hooks" value={summary().total} />
|
|
391
|
+
<OverviewMetric label="active" value={summary().active} />
|
|
392
|
+
<OverviewMetric label="running" value={summary().running} />
|
|
393
|
+
<OverviewMetric label="categories" value={summary().categories} />
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div class={styles().hookDetails.overviewGroups}>
|
|
397
|
+
<For each={groups()}>
|
|
398
|
+
{(group) => (
|
|
399
|
+
<section
|
|
400
|
+
class={styles().hookDetails.overviewGroupCard}
|
|
401
|
+
data-testid="ai-devtools-overview-category"
|
|
402
|
+
data-category={group.id}
|
|
403
|
+
>
|
|
404
|
+
<div class={styles().hookDetails.overviewGroupHeader}>
|
|
405
|
+
<span>{group.label}</span>
|
|
406
|
+
<span>{group.hooks.length}</span>
|
|
407
|
+
</div>
|
|
408
|
+
<For each={group.hooks}>
|
|
409
|
+
{(hook) => {
|
|
410
|
+
const running = createMemo(() =>
|
|
411
|
+
isHookRunning(hook, state.hooks.runs),
|
|
412
|
+
)
|
|
413
|
+
const unseenCount = createMemo(() =>
|
|
414
|
+
getHookUnseenEventCount(state.hooks, hook.id),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
return (
|
|
418
|
+
<button
|
|
419
|
+
type="button"
|
|
420
|
+
data-testid="ai-devtools-overview-hook"
|
|
421
|
+
data-hook-id={hook.id}
|
|
422
|
+
data-hook-name={hook.hookName}
|
|
423
|
+
data-display-name={getHookDisplayName(hook)}
|
|
424
|
+
class={styles().hookDetails.overviewHookButton}
|
|
425
|
+
onClick={() => props.onSelectHook(hook)}
|
|
426
|
+
>
|
|
427
|
+
<div class={styles().hookDetails.overviewHookMain}>
|
|
428
|
+
<span class={styles().hookDetails.overviewHookTitle}>
|
|
429
|
+
{getHookDisplayName(hook)}
|
|
430
|
+
</span>
|
|
431
|
+
<span class={styles().hookDetails.overviewHookId}>
|
|
432
|
+
<Show when={hook.displayName} fallback={hook.id}>
|
|
433
|
+
{hook.hookName} - {hook.id}
|
|
434
|
+
</Show>
|
|
435
|
+
</span>
|
|
436
|
+
</div>
|
|
437
|
+
<div class={styles().hookDetails.overviewHookMeta}>
|
|
438
|
+
<span class={styles().hookDetails.kind}>
|
|
439
|
+
{hook.outputKind ?? 'hook'}
|
|
440
|
+
</span>
|
|
441
|
+
<Show when={running()}>
|
|
442
|
+
<span class={styles().hookDetails.lifecycle}>
|
|
443
|
+
running
|
|
444
|
+
</span>
|
|
445
|
+
</Show>
|
|
446
|
+
<Show when={unseenCount() > 0}>
|
|
447
|
+
<span class={styles().hookDetails.kind}>
|
|
448
|
+
{unseenCount()} new
|
|
449
|
+
</span>
|
|
450
|
+
</Show>
|
|
451
|
+
</div>
|
|
452
|
+
</button>
|
|
453
|
+
)
|
|
454
|
+
}}
|
|
455
|
+
</For>
|
|
456
|
+
</section>
|
|
457
|
+
)}
|
|
458
|
+
</For>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
</Show>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const OverviewMetric: Component<{ label: string; value: number | string }> = (
|
|
466
|
+
props,
|
|
467
|
+
) => {
|
|
468
|
+
const styles = useStyles()
|
|
469
|
+
return (
|
|
470
|
+
<div class={styles().hookDetails.overviewMetricCard}>
|
|
471
|
+
<span class={styles().hookDetails.metricValue}>{props.value}</span>
|
|
472
|
+
<span class={styles().hookDetails.metricLabel}>{props.label}</span>
|
|
473
|
+
</div>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const HookHeader: Component<{
|
|
478
|
+
hook: HookRecord
|
|
479
|
+
runs: number
|
|
480
|
+
messages: number
|
|
481
|
+
totalTokens: number
|
|
482
|
+
isGenerationHook: boolean
|
|
483
|
+
}> = (props) => {
|
|
484
|
+
const styles = useStyles()
|
|
485
|
+
return (
|
|
486
|
+
<header
|
|
487
|
+
class={styles().hookDetails.header}
|
|
488
|
+
data-testid="ai-devtools-hook-header"
|
|
489
|
+
data-hook-id={props.hook.id}
|
|
490
|
+
data-hook-name={props.hook.hookName}
|
|
491
|
+
data-display-name={getHookDisplayName(props.hook)}
|
|
492
|
+
>
|
|
493
|
+
<div class={styles().hookDetails.headerMain}>
|
|
494
|
+
<div class={styles().hookDetails.titleRow}>
|
|
495
|
+
<span
|
|
496
|
+
class={styles().hookDetails.title}
|
|
497
|
+
data-testid="ai-devtools-hook-title"
|
|
498
|
+
>
|
|
499
|
+
{getHookDisplayName(props.hook)}
|
|
500
|
+
</span>
|
|
501
|
+
<span class={styles().hookDetails.lifecycle}>
|
|
502
|
+
{props.hook.lifecycle}
|
|
503
|
+
</span>
|
|
504
|
+
<Show when={props.hook.outputKind}>
|
|
505
|
+
<span class={styles().hookDetails.kind}>
|
|
506
|
+
{props.hook.outputKind}
|
|
507
|
+
</span>
|
|
508
|
+
</Show>
|
|
509
|
+
</div>
|
|
510
|
+
<div class={styles().hookDetails.identity}>
|
|
511
|
+
<Show when={props.hook.displayName}>
|
|
512
|
+
<span data-testid="ai-devtools-hook-technical-name">
|
|
513
|
+
{props.hook.hookName}
|
|
514
|
+
</span>
|
|
515
|
+
</Show>
|
|
516
|
+
<span>{props.hook.id}</span>
|
|
517
|
+
<Show when={props.hook.threadId}>
|
|
518
|
+
<span>thread {props.hook.threadId}</span>
|
|
519
|
+
</Show>
|
|
520
|
+
<Show when={props.hook.framework}>
|
|
521
|
+
<span>{props.hook.framework}</span>
|
|
522
|
+
</Show>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
<div class={styles().hookDetails.metrics}>
|
|
526
|
+
<Metric label="runs" value={props.runs} testId="runs" />
|
|
527
|
+
<Show when={!props.isGenerationHook}>
|
|
528
|
+
<Metric
|
|
529
|
+
label="tools"
|
|
530
|
+
value={props.hook.tools.length}
|
|
531
|
+
testId="tools"
|
|
532
|
+
/>
|
|
533
|
+
<Metric label="messages" value={props.messages} testId="messages" />
|
|
534
|
+
<Metric
|
|
535
|
+
label="tokens"
|
|
536
|
+
value={formatMetricValue(props.totalTokens)}
|
|
537
|
+
testId="tokens"
|
|
538
|
+
/>
|
|
539
|
+
</Show>
|
|
540
|
+
</div>
|
|
541
|
+
</header>
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const Metric: Component<{
|
|
546
|
+
label: string
|
|
547
|
+
value: number | string
|
|
548
|
+
testId?: string
|
|
549
|
+
}> = (props) => {
|
|
550
|
+
const styles = useStyles()
|
|
551
|
+
return (
|
|
552
|
+
<div
|
|
553
|
+
class={styles().hookDetails.metric}
|
|
554
|
+
data-testid={
|
|
555
|
+
props.testId ? `ai-devtools-hook-metric-${props.testId}` : undefined
|
|
556
|
+
}
|
|
557
|
+
>
|
|
558
|
+
<span class={styles().hookDetails.metricValue}>{props.value}</span>
|
|
559
|
+
<span class={styles().hookDetails.metricLabel}>{props.label}</span>
|
|
560
|
+
</div>
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function formatMetricValue(value: number): string {
|
|
565
|
+
return Intl.NumberFormat(undefined, { notation: 'compact' }).format(value)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const TabButton: Component<{
|
|
569
|
+
label: string
|
|
570
|
+
tab: DetailTab
|
|
571
|
+
activeTab: DetailTab
|
|
572
|
+
onSelect: Setter<DetailTab>
|
|
573
|
+
}> = (props) => {
|
|
574
|
+
const styles = useStyles()
|
|
575
|
+
return (
|
|
576
|
+
<button
|
|
577
|
+
type="button"
|
|
578
|
+
data-testid="ai-devtools-hook-tab"
|
|
579
|
+
data-tab={props.label}
|
|
580
|
+
class={`${styles().hookDetails.tab} ${
|
|
581
|
+
props.activeTab === props.tab ? styles().hookDetails.tabActive : ''
|
|
582
|
+
}`}
|
|
583
|
+
onClick={() => props.onSelect(props.tab)}
|
|
584
|
+
>
|
|
585
|
+
{props.label}
|
|
586
|
+
</button>
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const ConversationPanel: Component<{
|
|
591
|
+
hook: HookRecord
|
|
592
|
+
conversation?: Conversation
|
|
593
|
+
messages: Array<PreviewMessage>
|
|
594
|
+
hoverTarget: HoverTarget | null
|
|
595
|
+
onHoverTarget: (target: HoverTarget | null) => void
|
|
596
|
+
}> = (props) => {
|
|
597
|
+
const styles = useStyles()
|
|
598
|
+
return (
|
|
599
|
+
<Show
|
|
600
|
+
when={props.conversation}
|
|
601
|
+
fallback={
|
|
602
|
+
<Show
|
|
603
|
+
when={props.messages.length > 0}
|
|
604
|
+
fallback={
|
|
605
|
+
<div class={styles().hookDetails.stack}>
|
|
606
|
+
<section class={styles().hookDetails.section}>
|
|
607
|
+
<div class={styles().hookDetails.sectionTitle}>
|
|
608
|
+
Current State
|
|
609
|
+
</div>
|
|
610
|
+
<JsonPanel value={props.hook.state} compact />
|
|
611
|
+
</section>
|
|
612
|
+
</div>
|
|
613
|
+
}
|
|
614
|
+
>
|
|
615
|
+
<HookMessageTimeline
|
|
616
|
+
messages={props.messages}
|
|
617
|
+
hoverTarget={props.hoverTarget}
|
|
618
|
+
onHoverTarget={props.onHoverTarget}
|
|
619
|
+
/>
|
|
620
|
+
</Show>
|
|
621
|
+
}
|
|
622
|
+
>
|
|
623
|
+
{(conversation) => (
|
|
624
|
+
<Show
|
|
625
|
+
when={conversation().iterations.length > 0}
|
|
626
|
+
fallback={
|
|
627
|
+
<HookMessageTimeline
|
|
628
|
+
messages={props.messages}
|
|
629
|
+
hoverTarget={props.hoverTarget}
|
|
630
|
+
onHoverTarget={props.onHoverTarget}
|
|
631
|
+
/>
|
|
632
|
+
}
|
|
633
|
+
>
|
|
634
|
+
<IterationTimeline
|
|
635
|
+
iterations={conversation().iterations}
|
|
636
|
+
messages={conversation().messages}
|
|
637
|
+
hoverTarget={props.hoverTarget}
|
|
638
|
+
onHoverTarget={props.onHoverTarget}
|
|
639
|
+
/>
|
|
640
|
+
</Show>
|
|
641
|
+
)}
|
|
642
|
+
</Show>
|
|
643
|
+
)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const MessagesPreview: Component<{
|
|
647
|
+
hook: HookRecord
|
|
648
|
+
messages: Array<PreviewMessage>
|
|
649
|
+
hoverTarget: HoverTarget | null
|
|
650
|
+
onHoverTarget: (target: HoverTarget | null) => void
|
|
651
|
+
}> = (props) => {
|
|
652
|
+
const { saveToolFixture, applyToolFixture } = useAIStore()
|
|
653
|
+
const styles = useStyles()
|
|
654
|
+
const [pendingFixture, setPendingFixture] =
|
|
655
|
+
createSignal<ToolFixtureRecord | null>(null)
|
|
656
|
+
const partClass = (kind: PreviewPart['kind']) => {
|
|
657
|
+
const hookDetails = styles().hookDetails
|
|
658
|
+
if (kind === 'thinking') return hookDetails.previewPartThinking
|
|
659
|
+
if (kind === 'tool-call') return hookDetails.previewPartToolCall
|
|
660
|
+
if (kind === 'tool-result') return hookDetails.previewPartToolResult
|
|
661
|
+
if (kind === 'structured-output')
|
|
662
|
+
return hookDetails.previewPartStructuredOutput
|
|
663
|
+
if (kind === 'media') return hookDetails.previewPartMedia
|
|
664
|
+
return ''
|
|
665
|
+
}
|
|
666
|
+
const saveObservedToolCall = (message: PreviewMessage, part: PreviewPart) => {
|
|
667
|
+
const fixture = createFixtureFromPreviewPart(props.hook, message, part)
|
|
668
|
+
if (!fixture) return
|
|
669
|
+
setPendingFixture(fixture)
|
|
670
|
+
}
|
|
671
|
+
const confirmSaveObservedToolCall = (name: string) => {
|
|
672
|
+
const fixture = pendingFixture()
|
|
673
|
+
if (!fixture) return
|
|
674
|
+
saveToolFixture({ ...fixture, name })
|
|
675
|
+
setPendingFixture(null)
|
|
676
|
+
}
|
|
677
|
+
const replayObservedToolCall = (
|
|
678
|
+
message: PreviewMessage,
|
|
679
|
+
part: PreviewPart,
|
|
680
|
+
) => {
|
|
681
|
+
const fixture = createFixtureFromPreviewPart(props.hook, message, part)
|
|
682
|
+
if (!fixture) return
|
|
683
|
+
applyToolFixture(fixture)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return (
|
|
687
|
+
<Show
|
|
688
|
+
when={props.messages.length > 0}
|
|
689
|
+
fallback={<div class={styles().hookDetails.emptySmall}>No messages.</div>}
|
|
690
|
+
>
|
|
691
|
+
<div class={styles().hookDetails.messages}>
|
|
692
|
+
<Show when={pendingFixture()}>
|
|
693
|
+
{(fixture) => (
|
|
694
|
+
<FixtureNamePopover
|
|
695
|
+
defaultName={`${fixture().toolName} fixture`}
|
|
696
|
+
onCancel={() => setPendingFixture(null)}
|
|
697
|
+
onSave={confirmSaveObservedToolCall}
|
|
698
|
+
/>
|
|
699
|
+
)}
|
|
700
|
+
</Show>
|
|
701
|
+
<For each={props.messages}>
|
|
702
|
+
{(message) => {
|
|
703
|
+
const visibleParts = () => visiblePreviewPartsForMessage(message)
|
|
704
|
+
return (
|
|
705
|
+
<div
|
|
706
|
+
{...getHoverDataAttributes({
|
|
707
|
+
messageIds: [message.id],
|
|
708
|
+
partIds: visibleParts().map((part) => part.id),
|
|
709
|
+
})}
|
|
710
|
+
class={`${styles().hookDetails.message} ${
|
|
711
|
+
isMessageOrPartHighlighted(
|
|
712
|
+
message.id,
|
|
713
|
+
visibleParts().map((part) => part.id),
|
|
714
|
+
props.hoverTarget,
|
|
715
|
+
)
|
|
716
|
+
? styles().hookDetails.messageHighlighted
|
|
717
|
+
: ''
|
|
718
|
+
}`}
|
|
719
|
+
data-testid="ai-devtools-preview-message"
|
|
720
|
+
data-message-id={message.id}
|
|
721
|
+
data-role={message.role}
|
|
722
|
+
onMouseEnter={() =>
|
|
723
|
+
props.onHoverTarget(
|
|
724
|
+
createHoverTarget({
|
|
725
|
+
messageIds: [message.id],
|
|
726
|
+
origin: 'preview',
|
|
727
|
+
}),
|
|
728
|
+
)
|
|
729
|
+
}
|
|
730
|
+
onMouseLeave={() => props.onHoverTarget(null)}
|
|
731
|
+
>
|
|
732
|
+
<div class={styles().hookDetails.messageRole}>
|
|
733
|
+
{message.role}
|
|
734
|
+
</div>
|
|
735
|
+
<Show when={message.content}>
|
|
736
|
+
<div class={styles().hookDetails.messageContent}>
|
|
737
|
+
{message.content}
|
|
738
|
+
</div>
|
|
739
|
+
</Show>
|
|
740
|
+
<For each={visibleParts()}>
|
|
741
|
+
{(part) => (
|
|
742
|
+
<div
|
|
743
|
+
{...getHoverDataAttributes({
|
|
744
|
+
messageIds: [message.id],
|
|
745
|
+
partIds: [part.id],
|
|
746
|
+
})}
|
|
747
|
+
class={`${styles().hookDetails.previewPart} ${partClass(part.kind)} ${
|
|
748
|
+
props.hoverTarget?.partIds.includes(part.id)
|
|
749
|
+
? styles().hookDetails.previewPartHighlighted
|
|
750
|
+
: ''
|
|
751
|
+
}`}
|
|
752
|
+
data-testid="ai-devtools-preview-part"
|
|
753
|
+
data-part-id={part.id}
|
|
754
|
+
data-part-kind={part.kind}
|
|
755
|
+
onMouseEnter={() =>
|
|
756
|
+
props.onHoverTarget(
|
|
757
|
+
createHoverTarget({
|
|
758
|
+
messageIds: [message.id],
|
|
759
|
+
partIds: [part.id],
|
|
760
|
+
origin: 'preview',
|
|
761
|
+
}),
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
onMouseLeave={() =>
|
|
765
|
+
props.onHoverTarget(
|
|
766
|
+
createHoverTarget({
|
|
767
|
+
messageIds: [message.id],
|
|
768
|
+
origin: 'preview',
|
|
769
|
+
}),
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
>
|
|
773
|
+
<span class={styles().hookDetails.previewPartLabel}>
|
|
774
|
+
{part.label}
|
|
775
|
+
</span>
|
|
776
|
+
<Show when={part.fixture}>
|
|
777
|
+
<div class={styles().hookDetails.previewPartActions}>
|
|
778
|
+
<button
|
|
779
|
+
type="button"
|
|
780
|
+
class={styles().hookDetails.previewPartActionButton}
|
|
781
|
+
data-testid="ai-devtools-preview-save-tool-call"
|
|
782
|
+
onClick={(event) => {
|
|
783
|
+
event.stopPropagation()
|
|
784
|
+
saveObservedToolCall(message, part)
|
|
785
|
+
}}
|
|
786
|
+
>
|
|
787
|
+
Save
|
|
788
|
+
</button>
|
|
789
|
+
<button
|
|
790
|
+
type="button"
|
|
791
|
+
class={styles().hookDetails.previewPartActionButton}
|
|
792
|
+
data-testid="ai-devtools-preview-replay-tool-call"
|
|
793
|
+
onClick={(event) => {
|
|
794
|
+
event.stopPropagation()
|
|
795
|
+
replayObservedToolCall(message, part)
|
|
796
|
+
}}
|
|
797
|
+
>
|
|
798
|
+
Replay
|
|
799
|
+
</button>
|
|
800
|
+
</div>
|
|
801
|
+
</Show>
|
|
802
|
+
<Show
|
|
803
|
+
when={part.jsonItems?.length}
|
|
804
|
+
fallback={
|
|
805
|
+
<span class={styles().hookDetails.previewPartContent}>
|
|
806
|
+
{part.content}
|
|
807
|
+
</span>
|
|
808
|
+
}
|
|
809
|
+
>
|
|
810
|
+
<div
|
|
811
|
+
class={`${styles().hookDetails.previewJsonItems} ${
|
|
812
|
+
part.kind === 'structured-output'
|
|
813
|
+
? styles().hookDetails.previewJsonItemsCompare
|
|
814
|
+
: ''
|
|
815
|
+
}`}
|
|
816
|
+
>
|
|
817
|
+
<For each={part.jsonItems}>
|
|
818
|
+
{(item) => (
|
|
819
|
+
<div class={styles().hookDetails.previewJsonItem}>
|
|
820
|
+
<span
|
|
821
|
+
class={
|
|
822
|
+
styles().hookDetails.previewJsonItemLabel
|
|
823
|
+
}
|
|
824
|
+
>
|
|
825
|
+
{item.label}
|
|
826
|
+
</span>
|
|
827
|
+
<div
|
|
828
|
+
class={styles().hookDetails.previewJsonPanel}
|
|
829
|
+
>
|
|
830
|
+
<JsonTree
|
|
831
|
+
value={item.value}
|
|
832
|
+
defaultExpansionDepth={1}
|
|
833
|
+
copyable
|
|
834
|
+
/>
|
|
835
|
+
</div>
|
|
836
|
+
</div>
|
|
837
|
+
)}
|
|
838
|
+
</For>
|
|
839
|
+
</div>
|
|
840
|
+
</Show>
|
|
841
|
+
</div>
|
|
842
|
+
)}
|
|
843
|
+
</For>
|
|
844
|
+
</div>
|
|
845
|
+
)
|
|
846
|
+
}}
|
|
847
|
+
</For>
|
|
848
|
+
</div>
|
|
849
|
+
</Show>
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const HookMessageTimeline: Component<{
|
|
854
|
+
messages: Array<PreviewMessage>
|
|
855
|
+
hoverTarget: HoverTarget | null
|
|
856
|
+
onHoverTarget: (target: HoverTarget | null) => void
|
|
857
|
+
}> = (props) => {
|
|
858
|
+
const styles = useStyles()
|
|
859
|
+
|
|
860
|
+
return (
|
|
861
|
+
<Show
|
|
862
|
+
when={props.messages.length > 0}
|
|
863
|
+
fallback={
|
|
864
|
+
<div class={styles().hookDetails.emptySmall}>
|
|
865
|
+
No messages yet. Start a conversation to see messages here.
|
|
866
|
+
</div>
|
|
867
|
+
}
|
|
868
|
+
>
|
|
869
|
+
<div class={styles().hookDetails.messageTimeline}>
|
|
870
|
+
<For each={props.messages}>
|
|
871
|
+
{(message) => (
|
|
872
|
+
<div
|
|
873
|
+
{...getHoverDataAttributes({ messageIds: [message.id] })}
|
|
874
|
+
data-testid="ai-devtools-timeline-message"
|
|
875
|
+
data-message-id={message.id}
|
|
876
|
+
data-role={message.role}
|
|
877
|
+
class={`${styles().hookDetails.timelineMessage} ${
|
|
878
|
+
isMessageHighlighted(message.id, props.hoverTarget)
|
|
879
|
+
? styles().hookDetails.timelineMessageHighlighted
|
|
880
|
+
: ''
|
|
881
|
+
}`}
|
|
882
|
+
onMouseEnter={() =>
|
|
883
|
+
props.onHoverTarget(
|
|
884
|
+
createHoverTarget({
|
|
885
|
+
messageIds: [message.id],
|
|
886
|
+
origin: 'timeline',
|
|
887
|
+
}),
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
onMouseLeave={() => props.onHoverTarget(null)}
|
|
891
|
+
>
|
|
892
|
+
<div class={styles().hookDetails.messageRole}>{message.role}</div>
|
|
893
|
+
<Show when={message.content}>
|
|
894
|
+
<div class={styles().hookDetails.messageContent}>
|
|
895
|
+
{message.content}
|
|
896
|
+
</div>
|
|
897
|
+
</Show>
|
|
898
|
+
<For each={visiblePreviewPartsForMessage(message)}>
|
|
899
|
+
{(part) => (
|
|
900
|
+
<div
|
|
901
|
+
{...getHoverDataAttributes({
|
|
902
|
+
messageIds: [message.id],
|
|
903
|
+
partIds: [part.id],
|
|
904
|
+
})}
|
|
905
|
+
class={styles().hookDetails.previewPart}
|
|
906
|
+
data-testid="ai-devtools-timeline-part"
|
|
907
|
+
data-part-id={part.id}
|
|
908
|
+
data-part-kind={part.kind}
|
|
909
|
+
>
|
|
910
|
+
<span class={styles().hookDetails.previewPartLabel}>
|
|
911
|
+
{part.label}
|
|
912
|
+
</span>
|
|
913
|
+
<Show
|
|
914
|
+
when={part.jsonItems?.length}
|
|
915
|
+
fallback={
|
|
916
|
+
<span class={styles().hookDetails.previewPartContent}>
|
|
917
|
+
{part.content}
|
|
918
|
+
</span>
|
|
919
|
+
}
|
|
920
|
+
>
|
|
921
|
+
<div
|
|
922
|
+
class={`${styles().hookDetails.previewJsonItems} ${
|
|
923
|
+
part.kind === 'structured-output'
|
|
924
|
+
? styles().hookDetails.previewJsonItemsCompare
|
|
925
|
+
: ''
|
|
926
|
+
}`}
|
|
927
|
+
>
|
|
928
|
+
<For each={part.jsonItems}>
|
|
929
|
+
{(item) => (
|
|
930
|
+
<div class={styles().hookDetails.previewJsonItem}>
|
|
931
|
+
<span
|
|
932
|
+
class={
|
|
933
|
+
styles().hookDetails.previewJsonItemLabel
|
|
934
|
+
}
|
|
935
|
+
>
|
|
936
|
+
{item.label}
|
|
937
|
+
</span>
|
|
938
|
+
<div
|
|
939
|
+
class={styles().hookDetails.previewJsonPanel}
|
|
940
|
+
>
|
|
941
|
+
<JsonTree
|
|
942
|
+
value={item.value}
|
|
943
|
+
defaultExpansionDepth={1}
|
|
944
|
+
copyable
|
|
945
|
+
/>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
)}
|
|
949
|
+
</For>
|
|
950
|
+
</div>
|
|
951
|
+
</Show>
|
|
952
|
+
</div>
|
|
953
|
+
)}
|
|
954
|
+
</For>
|
|
955
|
+
</div>
|
|
956
|
+
)}
|
|
957
|
+
</For>
|
|
958
|
+
</div>
|
|
959
|
+
</Show>
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const ToolsView: Component<{ hook: HookRecord }> = (props) => {
|
|
964
|
+
const { state, applyToolFixture, deleteToolFixture } = useAIStore()
|
|
965
|
+
const styles = useStyles()
|
|
966
|
+
const [selectedToolName, setSelectedToolName] = createSignal<string | null>(
|
|
967
|
+
props.hook.tools[0]?.name ?? null,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
const selectedTool = createMemo(() =>
|
|
971
|
+
props.hook.tools.find((tool) => tool.name === selectedToolName()),
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
const savedFixtures = createMemo(() =>
|
|
975
|
+
state.hooks.fixtures
|
|
976
|
+
.filter(
|
|
977
|
+
(fixture) =>
|
|
978
|
+
fixture.hookId === props.hook.id ||
|
|
979
|
+
(!!props.hook.threadId && fixture.threadId === props.hook.threadId),
|
|
980
|
+
)
|
|
981
|
+
.sort((a, b) => b.createdAt - a.createdAt),
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
return (
|
|
985
|
+
<div class={styles().hookDetails.toolsGrid} data-testid="ai-devtools-tools">
|
|
986
|
+
<div class={styles().hookDetails.toolsList}>
|
|
987
|
+
<Show
|
|
988
|
+
when={props.hook.tools.length > 0}
|
|
989
|
+
fallback={
|
|
990
|
+
<div class={styles().hookDetails.emptySmall}>No tools.</div>
|
|
991
|
+
}
|
|
992
|
+
>
|
|
993
|
+
<For each={props.hook.tools}>
|
|
994
|
+
{(tool) => (
|
|
995
|
+
<button
|
|
996
|
+
type="button"
|
|
997
|
+
data-testid="ai-devtools-tool-row"
|
|
998
|
+
data-tool-name={tool.name}
|
|
999
|
+
class={`${styles().hookDetails.toolRow} ${
|
|
1000
|
+
selectedToolName() === tool.name
|
|
1001
|
+
? styles().hookDetails.toolRowSelected
|
|
1002
|
+
: ''
|
|
1003
|
+
}`}
|
|
1004
|
+
onClick={() => setSelectedToolName(tool.name)}
|
|
1005
|
+
>
|
|
1006
|
+
<span class={styles().hookDetails.toolName}>{tool.name}</span>
|
|
1007
|
+
<Show when={tool.needsApproval}>
|
|
1008
|
+
<span class={styles().hookDetails.eventBadge}>approval</span>
|
|
1009
|
+
</Show>
|
|
1010
|
+
</button>
|
|
1011
|
+
)}
|
|
1012
|
+
</For>
|
|
1013
|
+
</Show>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div class={styles().hookDetails.toolDetail}>
|
|
1016
|
+
<Show
|
|
1017
|
+
when={selectedTool()}
|
|
1018
|
+
fallback={
|
|
1019
|
+
<div class={styles().hookDetails.emptySmall}>Select a tool.</div>
|
|
1020
|
+
}
|
|
1021
|
+
>
|
|
1022
|
+
{(tool) => (
|
|
1023
|
+
<>
|
|
1024
|
+
<ToolFixtureForm
|
|
1025
|
+
hook={props.hook}
|
|
1026
|
+
tool={tool()}
|
|
1027
|
+
onFire={(fixture) => applyToolFixture(fixture)}
|
|
1028
|
+
/>
|
|
1029
|
+
<div class={styles().hookDetails.sectionTitle}>
|
|
1030
|
+
Saved Fixtures
|
|
1031
|
+
</div>
|
|
1032
|
+
<Show
|
|
1033
|
+
when={savedFixtures().length > 0}
|
|
1034
|
+
fallback={
|
|
1035
|
+
<div class={styles().hookDetails.emptySmall}>
|
|
1036
|
+
No saved fixtures for this hook.
|
|
1037
|
+
</div>
|
|
1038
|
+
}
|
|
1039
|
+
>
|
|
1040
|
+
<div class={styles().hookDetails.eventList}>
|
|
1041
|
+
<For each={savedFixtures()}>
|
|
1042
|
+
{(fixture) => (
|
|
1043
|
+
<div
|
|
1044
|
+
class={styles().hookDetails.fixtureRow}
|
|
1045
|
+
data-testid="ai-devtools-fixture-row"
|
|
1046
|
+
data-fixture-name={fixture.name ?? fixture.toolName}
|
|
1047
|
+
data-tool-name={fixture.toolName}
|
|
1048
|
+
>
|
|
1049
|
+
<div class={styles().hookDetails.fixtureInfo}>
|
|
1050
|
+
<span class={styles().hookDetails.fixtureName}>
|
|
1051
|
+
{fixture.name ?? fixture.toolName}
|
|
1052
|
+
</span>
|
|
1053
|
+
<span class={styles().hookDetails.fixtureMeta}>
|
|
1054
|
+
{fixture.toolName} - {formatTime(fixture.createdAt)}
|
|
1055
|
+
</span>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class={styles().hookDetails.fixtureRowActions}>
|
|
1058
|
+
<button
|
|
1059
|
+
type="button"
|
|
1060
|
+
class={styles().hookDetails.fixtureRowButton}
|
|
1061
|
+
data-testid="ai-devtools-fixture-replay"
|
|
1062
|
+
onClick={() => applyToolFixture(fixture)}
|
|
1063
|
+
>
|
|
1064
|
+
Replay
|
|
1065
|
+
</button>
|
|
1066
|
+
<button
|
|
1067
|
+
type="button"
|
|
1068
|
+
class={`${styles().hookDetails.fixtureRowButton} ${styles().hookDetails.fixtureDangerButton}`}
|
|
1069
|
+
data-testid="ai-devtools-fixture-delete"
|
|
1070
|
+
onClick={() => deleteToolFixture(fixture.id)}
|
|
1071
|
+
>
|
|
1072
|
+
Delete
|
|
1073
|
+
</button>
|
|
1074
|
+
</div>
|
|
1075
|
+
</div>
|
|
1076
|
+
)}
|
|
1077
|
+
</For>
|
|
1078
|
+
</div>
|
|
1079
|
+
</Show>
|
|
1080
|
+
</>
|
|
1081
|
+
)}
|
|
1082
|
+
</Show>
|
|
1083
|
+
</div>
|
|
1084
|
+
</div>
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
const JsonPanel: Component<{ value: unknown; compact?: boolean }> = (props) => {
|
|
1089
|
+
const styles = useStyles()
|
|
1090
|
+
return (
|
|
1091
|
+
<div
|
|
1092
|
+
class={`${styles().hookDetails.jsonPanel} ${
|
|
1093
|
+
props.compact ? styles().hookDetails.jsonPanelCompact : ''
|
|
1094
|
+
}`}
|
|
1095
|
+
>
|
|
1096
|
+
<JsonTree
|
|
1097
|
+
value={props.value}
|
|
1098
|
+
defaultExpansionDepth={props.compact ? 1 : 2}
|
|
1099
|
+
copyable
|
|
1100
|
+
/>
|
|
1101
|
+
</div>
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function messagesFromSnapshot(
|
|
1106
|
+
state: Record<string, unknown>,
|
|
1107
|
+
): Array<PreviewMessage> {
|
|
1108
|
+
const messages = state.messages
|
|
1109
|
+
if (!Array.isArray(messages)) return []
|
|
1110
|
+
return messages.map(messageFromUnknown).filter(isPreviewMessage)
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function setHoverTargetFromEvent(
|
|
1114
|
+
event: MouseEvent & { currentTarget: HTMLElement },
|
|
1115
|
+
origin: HoverOrigin,
|
|
1116
|
+
onHoverTarget: (target: HoverTarget | null) => void,
|
|
1117
|
+
): void {
|
|
1118
|
+
const element = findClosestHoverTargetElement(
|
|
1119
|
+
event.target,
|
|
1120
|
+
event.currentTarget,
|
|
1121
|
+
)
|
|
1122
|
+
if (!element) return
|
|
1123
|
+
|
|
1124
|
+
onHoverTarget(
|
|
1125
|
+
createHoverTargetFromDataAttributes({
|
|
1126
|
+
messageIds: element.getAttribute('data-ai-devtools-hover-message-ids'),
|
|
1127
|
+
partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
|
|
1128
|
+
origin,
|
|
1129
|
+
}),
|
|
1130
|
+
)
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function findClosestHoverTargetElement(
|
|
1134
|
+
target: EventTarget | null,
|
|
1135
|
+
container: HTMLElement,
|
|
1136
|
+
): HTMLElement | undefined {
|
|
1137
|
+
if (!(target instanceof Element)) return undefined
|
|
1138
|
+
|
|
1139
|
+
const element = target.closest<HTMLElement>(
|
|
1140
|
+
'[data-ai-devtools-hover-message-ids], [data-ai-devtools-hover-part-ids]',
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
if (!element || !container.contains(element)) return undefined
|
|
1144
|
+
return element
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function findMatchingHoverElement(
|
|
1148
|
+
container: HTMLElement,
|
|
1149
|
+
target: HoverTarget,
|
|
1150
|
+
): HTMLElement | undefined {
|
|
1151
|
+
const elements = Array.from(
|
|
1152
|
+
container.querySelectorAll<HTMLElement>(
|
|
1153
|
+
'[data-ai-devtools-hover-message-ids], [data-ai-devtools-hover-part-ids]',
|
|
1154
|
+
),
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
if (target.partIds.length > 0) {
|
|
1158
|
+
const partElement = elements.find((element) =>
|
|
1159
|
+
hoverTargetMatchesElement(
|
|
1160
|
+
{ ...target, messageIds: [] },
|
|
1161
|
+
{
|
|
1162
|
+
messageIds: element.getAttribute(
|
|
1163
|
+
'data-ai-devtools-hover-message-ids',
|
|
1164
|
+
),
|
|
1165
|
+
partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
|
|
1166
|
+
},
|
|
1167
|
+
),
|
|
1168
|
+
)
|
|
1169
|
+
if (partElement) return partElement
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return elements.find((element) =>
|
|
1173
|
+
hoverTargetMatchesElement(target, {
|
|
1174
|
+
messageIds: element.getAttribute('data-ai-devtools-hover-message-ids'),
|
|
1175
|
+
partIds: element.getAttribute('data-ai-devtools-hover-part-ids'),
|
|
1176
|
+
}),
|
|
1177
|
+
)
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function scrollElementIntoPaneView(
|
|
1181
|
+
pane: HTMLElement,
|
|
1182
|
+
element: HTMLElement,
|
|
1183
|
+
options: { align: 'nearest' | 'start' },
|
|
1184
|
+
): void {
|
|
1185
|
+
const scrollContainer = findScrollContainer(element, pane)
|
|
1186
|
+
const containerRect = scrollContainer.getBoundingClientRect()
|
|
1187
|
+
const elementRect = element.getBoundingClientRect()
|
|
1188
|
+
const padding = 8
|
|
1189
|
+
|
|
1190
|
+
let nextScrollTop = scrollContainer.scrollTop
|
|
1191
|
+
if (options.align === 'start') {
|
|
1192
|
+
nextScrollTop += elementRect.top - containerRect.top - padding
|
|
1193
|
+
} else if (elementRect.top < containerRect.top + padding) {
|
|
1194
|
+
nextScrollTop += elementRect.top - containerRect.top - padding
|
|
1195
|
+
} else if (elementRect.bottom > containerRect.bottom - padding) {
|
|
1196
|
+
nextScrollTop += elementRect.bottom - containerRect.bottom + padding
|
|
1197
|
+
} else {
|
|
1198
|
+
return
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
animateScrollTop(scrollContainer, Math.max(0, nextScrollTop))
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function findScrollContainer(
|
|
1205
|
+
element: HTMLElement,
|
|
1206
|
+
boundary: HTMLElement,
|
|
1207
|
+
): HTMLElement {
|
|
1208
|
+
let current = element.parentElement
|
|
1209
|
+
|
|
1210
|
+
while (current && boundary.contains(current)) {
|
|
1211
|
+
const style = getComputedStyle(current)
|
|
1212
|
+
const canScroll =
|
|
1213
|
+
current.scrollHeight > current.clientHeight &&
|
|
1214
|
+
/(auto|scroll|overlay)/.test(style.overflowY)
|
|
1215
|
+
|
|
1216
|
+
if (canScroll) return current
|
|
1217
|
+
if (current === boundary) break
|
|
1218
|
+
current = current.parentElement
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return boundary
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function animateScrollTop(element: HTMLElement, targetScrollTop: number): void {
|
|
1225
|
+
const startScrollTop = element.scrollTop
|
|
1226
|
+
const distance = targetScrollTop - startScrollTop
|
|
1227
|
+
if (Math.abs(distance) < 1) return
|
|
1228
|
+
|
|
1229
|
+
const existingAnimation = scrollAnimations.get(element)
|
|
1230
|
+
if (existingAnimation !== undefined) {
|
|
1231
|
+
window.clearTimeout(existingAnimation)
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const startedAt = performance.now()
|
|
1235
|
+
const duration = 220
|
|
1236
|
+
|
|
1237
|
+
const step = () => {
|
|
1238
|
+
const now = performance.now()
|
|
1239
|
+
const progress = Math.min(1, (now - startedAt) / duration)
|
|
1240
|
+
const eased = 1 - Math.pow(1 - progress, 3)
|
|
1241
|
+
element.scrollTop = startScrollTop + distance * eased
|
|
1242
|
+
|
|
1243
|
+
if (progress < 1) {
|
|
1244
|
+
scrollAnimations.set(element, window.setTimeout(step, 16))
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
scrollAnimations.delete(element)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
scrollAnimations.set(element, window.setTimeout(step, 16))
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function findConversationForHook(
|
|
1255
|
+
hook: HookRecord,
|
|
1256
|
+
conversations: Record<string, Conversation>,
|
|
1257
|
+
): Conversation | undefined {
|
|
1258
|
+
const candidates: Array<Conversation> = []
|
|
1259
|
+
const addCandidate = (conversation: Conversation | undefined) => {
|
|
1260
|
+
if (!conversation) return
|
|
1261
|
+
if (candidates.some((item) => item.id === conversation.id)) return
|
|
1262
|
+
candidates.push(conversation)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
addCandidate(conversations[hook.id])
|
|
1266
|
+
if (hook.clientId) addCandidate(conversations[hook.clientId])
|
|
1267
|
+
if (hook.threadId) addCandidate(conversations[hook.threadId])
|
|
1268
|
+
|
|
1269
|
+
if (hook.runIds.length > 0) {
|
|
1270
|
+
const runIds = new Set(hook.runIds)
|
|
1271
|
+
addCandidate(
|
|
1272
|
+
Object.values(conversations).find((conversation) =>
|
|
1273
|
+
conversation.runIds?.some((runId) => runIds.has(runId)),
|
|
1274
|
+
),
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const snapshotMessages = hook.state.messages
|
|
1279
|
+
if (Array.isArray(snapshotMessages)) {
|
|
1280
|
+
const messageIds = new Set(
|
|
1281
|
+
snapshotMessages
|
|
1282
|
+
.map((message) =>
|
|
1283
|
+
isRecord(message) && typeof message.id === 'string'
|
|
1284
|
+
? message.id
|
|
1285
|
+
: undefined,
|
|
1286
|
+
)
|
|
1287
|
+
.filter((id): id is string => typeof id === 'string'),
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
if (messageIds.size > 0) {
|
|
1291
|
+
addCandidate(
|
|
1292
|
+
Object.values(conversations).find((conversation) =>
|
|
1293
|
+
conversation.messages.some((message) => messageIds.has(message.id)),
|
|
1294
|
+
),
|
|
1295
|
+
)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return (
|
|
1300
|
+
candidates.find(
|
|
1301
|
+
(conversation) =>
|
|
1302
|
+
conversation.iterations.length > 0 && conversation.messages.length > 0,
|
|
1303
|
+
) ??
|
|
1304
|
+
candidates.find((conversation) => conversation.iterations.length > 0) ??
|
|
1305
|
+
candidates.find((conversation) => conversation.messages.length > 0) ??
|
|
1306
|
+
candidates[0]
|
|
1307
|
+
)
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function messageFromConversation(message: Message): PreviewMessage {
|
|
1311
|
+
const sourceMessage = toolFixtureMessageFromConversation(message)
|
|
1312
|
+
|
|
1313
|
+
if (
|
|
1314
|
+
message.role === 'tool' &&
|
|
1315
|
+
(!message.parts || message.parts.length === 0)
|
|
1316
|
+
) {
|
|
1317
|
+
return {
|
|
1318
|
+
id: message.id,
|
|
1319
|
+
role: message.role,
|
|
1320
|
+
content: '',
|
|
1321
|
+
parts: [toolResultPartFromContent(message.id, message.content)],
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
id: message.id,
|
|
1327
|
+
role: message.role,
|
|
1328
|
+
content: message.content,
|
|
1329
|
+
...(sourceMessage ? { sourceMessage } : {}),
|
|
1330
|
+
parts: [
|
|
1331
|
+
...partsFromMessageParts(message.parts ?? [], message.id),
|
|
1332
|
+
...partsFromToolCalls(message.toolCalls ?? [], message.id),
|
|
1333
|
+
],
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function messageFromUnknown(value: unknown): PreviewMessage | undefined {
|
|
1338
|
+
if (!isRecord(value)) return undefined
|
|
1339
|
+
const id = typeof value.id === 'string' ? value.id : undefined
|
|
1340
|
+
const role = typeof value.role === 'string' ? value.role : undefined
|
|
1341
|
+
if (!id || !role) return undefined
|
|
1342
|
+
const sourceMessage = toolFixtureMessageFromUnknown(value)
|
|
1343
|
+
|
|
1344
|
+
const content =
|
|
1345
|
+
typeof value.content === 'string'
|
|
1346
|
+
? value.content
|
|
1347
|
+
: Array.isArray(value.parts)
|
|
1348
|
+
? partsToMessageText(value.parts)
|
|
1349
|
+
: ''
|
|
1350
|
+
|
|
1351
|
+
return {
|
|
1352
|
+
id,
|
|
1353
|
+
role,
|
|
1354
|
+
content,
|
|
1355
|
+
...(sourceMessage ? { sourceMessage } : {}),
|
|
1356
|
+
parts: Array.isArray(value.parts) ? partsFromUnknown(value.parts, id) : [],
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function partsFromMessageParts(
|
|
1361
|
+
parts: Array<MessagePart>,
|
|
1362
|
+
messageId: string,
|
|
1363
|
+
): Array<PreviewPart> {
|
|
1364
|
+
return parts.map((part, index) =>
|
|
1365
|
+
previewPartFromRecord(part, index, messageId),
|
|
1366
|
+
)
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function partsFromUnknown(
|
|
1370
|
+
parts: Array<unknown>,
|
|
1371
|
+
messageId?: string,
|
|
1372
|
+
): Array<PreviewPart> {
|
|
1373
|
+
return parts
|
|
1374
|
+
.map((part, index) =>
|
|
1375
|
+
isRecord(part)
|
|
1376
|
+
? previewPartFromRecord(part, index, messageId)
|
|
1377
|
+
: undefined,
|
|
1378
|
+
)
|
|
1379
|
+
.filter(isPreviewPart)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function previewPartFromRecord(
|
|
1383
|
+
part: PreviewPartSource,
|
|
1384
|
+
index: number,
|
|
1385
|
+
messageId?: string,
|
|
1386
|
+
): PreviewPart {
|
|
1387
|
+
const type = typeof part.type === 'string' ? part.type : 'part'
|
|
1388
|
+
if (type === 'tool-call') {
|
|
1389
|
+
const name =
|
|
1390
|
+
typeof part.name === 'string'
|
|
1391
|
+
? part.name
|
|
1392
|
+
: typeof part.toolName === 'string'
|
|
1393
|
+
? part.toolName
|
|
1394
|
+
: 'tool'
|
|
1395
|
+
const rawId =
|
|
1396
|
+
typeof part.toolCallId === 'string'
|
|
1397
|
+
? part.toolCallId
|
|
1398
|
+
: typeof part.id === 'string'
|
|
1399
|
+
? part.id
|
|
1400
|
+
: `${index}:${name}`
|
|
1401
|
+
const input = part.input ?? part.arguments
|
|
1402
|
+
const output = part.output
|
|
1403
|
+
const parsedInput = input === undefined ? {} : parseJsonishValue(input)
|
|
1404
|
+
const parsedOutput =
|
|
1405
|
+
output === undefined ? undefined : parseJsonishValue(output)
|
|
1406
|
+
const approvalStatus = approvalStatusFromRecord(part.approval, part.state)
|
|
1407
|
+
const jsonItems: Array<PreviewJsonItem> = []
|
|
1408
|
+
if (input !== undefined) {
|
|
1409
|
+
jsonItems.push({
|
|
1410
|
+
label: 'Input',
|
|
1411
|
+
value: parsedInput,
|
|
1412
|
+
})
|
|
1413
|
+
}
|
|
1414
|
+
if (part.approval !== undefined) {
|
|
1415
|
+
jsonItems.push({
|
|
1416
|
+
label: 'Approval',
|
|
1417
|
+
value: parseJsonishValue(part.approval),
|
|
1418
|
+
})
|
|
1419
|
+
}
|
|
1420
|
+
if (output !== undefined) {
|
|
1421
|
+
jsonItems.push({
|
|
1422
|
+
label: 'Output',
|
|
1423
|
+
value: parsedOutput,
|
|
1424
|
+
})
|
|
1425
|
+
}
|
|
1426
|
+
return {
|
|
1427
|
+
id: toolCallPartId(rawId),
|
|
1428
|
+
label: approvalStatus
|
|
1429
|
+
? `tool call ${name} - ${approvalStatus}`
|
|
1430
|
+
: `tool call ${name}`,
|
|
1431
|
+
content: formatUnknown(input ?? output),
|
|
1432
|
+
jsonItems,
|
|
1433
|
+
kind: 'tool-call',
|
|
1434
|
+
fixture: {
|
|
1435
|
+
toolName: name,
|
|
1436
|
+
input: parsedInput,
|
|
1437
|
+
...(parsedOutput !== undefined ? { output: parsedOutput } : {}),
|
|
1438
|
+
toolCallId: rawId,
|
|
1439
|
+
...(messageId ? { messageId } : {}),
|
|
1440
|
+
},
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
if (type === 'tool-result') {
|
|
1444
|
+
const name =
|
|
1445
|
+
typeof part.name === 'string'
|
|
1446
|
+
? part.name
|
|
1447
|
+
: typeof part.toolName === 'string'
|
|
1448
|
+
? part.toolName
|
|
1449
|
+
: undefined
|
|
1450
|
+
const rawId =
|
|
1451
|
+
typeof part.toolCallId === 'string'
|
|
1452
|
+
? part.toolCallId
|
|
1453
|
+
: typeof part.id === 'string'
|
|
1454
|
+
? part.id
|
|
1455
|
+
: `${index}`
|
|
1456
|
+
const output = part.output ?? part.content ?? part.error
|
|
1457
|
+
return {
|
|
1458
|
+
id: toolResultPartId(rawId),
|
|
1459
|
+
label: name ? `tool result ${name}` : 'tool result',
|
|
1460
|
+
content: formatUnknown(output),
|
|
1461
|
+
jsonItems:
|
|
1462
|
+
output === undefined
|
|
1463
|
+
? []
|
|
1464
|
+
: [
|
|
1465
|
+
{
|
|
1466
|
+
label: part.error ? 'Error' : 'Output',
|
|
1467
|
+
value: parseJsonishValue(output),
|
|
1468
|
+
},
|
|
1469
|
+
],
|
|
1470
|
+
kind: 'tool-result',
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (type === 'structured-output') {
|
|
1474
|
+
const status = typeof part.status === 'string' ? part.status : undefined
|
|
1475
|
+
const raw = typeof part.raw === 'string' ? part.raw : undefined
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
id: structuredOutputPartId(messageId ?? `${index}`),
|
|
1479
|
+
label: status ? `structured output - ${status}` : 'structured output',
|
|
1480
|
+
content: raw ?? formatUnknown(part.data ?? part.partial),
|
|
1481
|
+
jsonItems: structuredOutputJsonItems(part),
|
|
1482
|
+
kind: 'structured-output',
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (type === 'thinking') {
|
|
1486
|
+
return {
|
|
1487
|
+
id: `${index}:thinking`,
|
|
1488
|
+
label: 'reasoning',
|
|
1489
|
+
content: formatUnknown(part.content),
|
|
1490
|
+
kind: 'thinking',
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
if (type === 'image' || type === 'audio' || type === 'video') {
|
|
1494
|
+
return {
|
|
1495
|
+
id: `${index}:${type}`,
|
|
1496
|
+
label: type,
|
|
1497
|
+
content: formatUnknown(part.source ?? part.metadata),
|
|
1498
|
+
kind: 'media',
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
return {
|
|
1502
|
+
id: `${index}:${type}`,
|
|
1503
|
+
label: type,
|
|
1504
|
+
content: formatUnknown(part.content ?? part.text),
|
|
1505
|
+
kind: 'text',
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function partsFromToolCalls(
|
|
1510
|
+
toolCalls: Array<ToolCall>,
|
|
1511
|
+
messageId: string,
|
|
1512
|
+
): Array<PreviewPart> {
|
|
1513
|
+
return toolCalls.map((tool) => {
|
|
1514
|
+
const input = parseJsonishValue(tool.arguments)
|
|
1515
|
+
const output =
|
|
1516
|
+
tool.result === undefined ? undefined : parseJsonishValue(tool.result)
|
|
1517
|
+
const approvalStatus = approvalStatusFromToolCall(tool)
|
|
1518
|
+
const jsonItems: Array<PreviewJsonItem> = [
|
|
1519
|
+
{
|
|
1520
|
+
label: 'Input',
|
|
1521
|
+
value: input,
|
|
1522
|
+
},
|
|
1523
|
+
]
|
|
1524
|
+
|
|
1525
|
+
if (tool.approvalId) {
|
|
1526
|
+
jsonItems.push({
|
|
1527
|
+
label: 'Approval',
|
|
1528
|
+
value: {
|
|
1529
|
+
id: tool.approvalId,
|
|
1530
|
+
required: tool.approvalRequired,
|
|
1531
|
+
approved: tool.approvalApproved,
|
|
1532
|
+
},
|
|
1533
|
+
})
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (output !== undefined) {
|
|
1537
|
+
jsonItems.push({
|
|
1538
|
+
label: 'Output',
|
|
1539
|
+
value: output,
|
|
1540
|
+
})
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
return {
|
|
1544
|
+
id: toolCallPartId(tool.id),
|
|
1545
|
+
label: approvalStatus
|
|
1546
|
+
? `tool call ${tool.name} - ${approvalStatus}`
|
|
1547
|
+
: `tool call ${tool.name}`,
|
|
1548
|
+
content: formatUnknown(tool.arguments),
|
|
1549
|
+
jsonItems,
|
|
1550
|
+
kind: 'tool-call',
|
|
1551
|
+
fixture: {
|
|
1552
|
+
toolName: tool.name,
|
|
1553
|
+
input,
|
|
1554
|
+
...(output !== undefined ? { output } : {}),
|
|
1555
|
+
toolCallId: tool.id,
|
|
1556
|
+
messageId,
|
|
1557
|
+
},
|
|
1558
|
+
}
|
|
1559
|
+
})
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
function toolFixtureMessageFromConversation(
|
|
1563
|
+
message: Message,
|
|
1564
|
+
): ToolFixtureMessage | undefined {
|
|
1565
|
+
if (!isToolFixtureMessageRole(message.role)) return undefined
|
|
1566
|
+
|
|
1567
|
+
const parts =
|
|
1568
|
+
message.parts && message.parts.length > 0
|
|
1569
|
+
? message.parts
|
|
1570
|
+
: message.toolCalls && message.toolCalls.length > 0
|
|
1571
|
+
? message.toolCalls.map(toolCallToFixturePart)
|
|
1572
|
+
: message.content
|
|
1573
|
+
? [{ type: 'text', content: message.content }]
|
|
1574
|
+
: []
|
|
1575
|
+
|
|
1576
|
+
if (parts.length === 0) return undefined
|
|
1577
|
+
|
|
1578
|
+
return {
|
|
1579
|
+
id: message.id,
|
|
1580
|
+
role: message.role,
|
|
1581
|
+
parts: parts.map(jsonSafeValue),
|
|
1582
|
+
createdAt: message.timestamp,
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
function toolFixtureMessageFromUnknown(
|
|
1587
|
+
value: Record<string, unknown>,
|
|
1588
|
+
): ToolFixtureMessage | undefined {
|
|
1589
|
+
const id = typeof value.id === 'string' ? value.id : undefined
|
|
1590
|
+
const role = typeof value.role === 'string' ? value.role : undefined
|
|
1591
|
+
if (!id || !isToolFixtureMessageRole(role)) return undefined
|
|
1592
|
+
|
|
1593
|
+
const parts = Array.isArray(value.parts)
|
|
1594
|
+
? value.parts
|
|
1595
|
+
: typeof value.content === 'string'
|
|
1596
|
+
? [{ type: 'text', content: value.content }]
|
|
1597
|
+
: []
|
|
1598
|
+
if (parts.length === 0) return undefined
|
|
1599
|
+
|
|
1600
|
+
return {
|
|
1601
|
+
id,
|
|
1602
|
+
role,
|
|
1603
|
+
parts: parts.map(jsonSafeValue),
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function approvalStatusFromToolCall(tool: ToolCall): string | undefined {
|
|
1608
|
+
if (tool.approvalApproved === true || tool.state === 'approved') {
|
|
1609
|
+
return 'approved'
|
|
1610
|
+
}
|
|
1611
|
+
if (tool.approvalApproved === false || tool.state === 'denied') {
|
|
1612
|
+
return 'denied'
|
|
1613
|
+
}
|
|
1614
|
+
if (
|
|
1615
|
+
tool.approvalRequired ||
|
|
1616
|
+
tool.approvalId ||
|
|
1617
|
+
tool.state === 'approval-requested'
|
|
1618
|
+
) {
|
|
1619
|
+
return 'approval requested'
|
|
1620
|
+
}
|
|
1621
|
+
if (tool.state === 'approval-responded') {
|
|
1622
|
+
return 'responded'
|
|
1623
|
+
}
|
|
1624
|
+
return undefined
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function approvalStatusFromRecord(
|
|
1628
|
+
approval: unknown,
|
|
1629
|
+
state: unknown,
|
|
1630
|
+
): string | undefined {
|
|
1631
|
+
if (isRecord(approval)) {
|
|
1632
|
+
if (approval.approved === true) return 'approved'
|
|
1633
|
+
if (approval.approved === false) return 'denied'
|
|
1634
|
+
if (approval.needsApproval === true) return 'approval requested'
|
|
1635
|
+
}
|
|
1636
|
+
if (state === 'approved') return 'approved'
|
|
1637
|
+
if (state === 'denied') return 'denied'
|
|
1638
|
+
if (state === 'approval-requested') return 'approval requested'
|
|
1639
|
+
if (state === 'approval-responded') return 'responded'
|
|
1640
|
+
return undefined
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
function toolCallToFixturePart(tool: ToolCall): Record<string, unknown> {
|
|
1644
|
+
return {
|
|
1645
|
+
type: 'tool-call',
|
|
1646
|
+
id: tool.id,
|
|
1647
|
+
name: tool.name,
|
|
1648
|
+
arguments: tool.arguments,
|
|
1649
|
+
input: parseJsonishValue(tool.arguments),
|
|
1650
|
+
state: tool.state,
|
|
1651
|
+
...(tool.approvalId
|
|
1652
|
+
? {
|
|
1653
|
+
approval: {
|
|
1654
|
+
id: tool.approvalId,
|
|
1655
|
+
needsApproval: tool.approvalRequired ?? false,
|
|
1656
|
+
...(tool.approvalApproved !== undefined
|
|
1657
|
+
? { approved: tool.approvalApproved }
|
|
1658
|
+
: {}),
|
|
1659
|
+
},
|
|
1660
|
+
}
|
|
1661
|
+
: {}),
|
|
1662
|
+
...(tool.result !== undefined ? { output: tool.result } : {}),
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function createFixtureFromPreviewPart(
|
|
1667
|
+
hook: HookRecord,
|
|
1668
|
+
message: PreviewMessage,
|
|
1669
|
+
part: PreviewPart,
|
|
1670
|
+
): ReturnType<typeof createToolFixtureRecord> | undefined {
|
|
1671
|
+
if (!part.fixture) return undefined
|
|
1672
|
+
const hasRegisteredClientTool = hook.tools.some(
|
|
1673
|
+
(tool) => tool.name === part.fixture?.toolName,
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
return createToolFixtureRecord({
|
|
1677
|
+
hookId: hook.id,
|
|
1678
|
+
...(hook.threadId ? { threadId: hook.threadId } : {}),
|
|
1679
|
+
...(message.sourceMessage ? { message: message.sourceMessage } : {}),
|
|
1680
|
+
...(hasRegisteredClientTool && part.fixture.output === undefined
|
|
1681
|
+
? { execute: true }
|
|
1682
|
+
: {}),
|
|
1683
|
+
...part.fixture,
|
|
1684
|
+
})
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
function toolResultPartFromContent(
|
|
1688
|
+
messageId: string,
|
|
1689
|
+
content: string,
|
|
1690
|
+
): PreviewPart {
|
|
1691
|
+
return {
|
|
1692
|
+
id: toolResultPartId(messageId),
|
|
1693
|
+
label: 'tool result',
|
|
1694
|
+
content,
|
|
1695
|
+
jsonItems: [
|
|
1696
|
+
{
|
|
1697
|
+
label: 'Output',
|
|
1698
|
+
value: parseJsonishValue(content),
|
|
1699
|
+
},
|
|
1700
|
+
],
|
|
1701
|
+
kind: 'tool-result',
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
function formatUnknown(value: unknown): string {
|
|
1706
|
+
if (value === undefined || value === null) return ''
|
|
1707
|
+
if (typeof value === 'string') return value
|
|
1708
|
+
try {
|
|
1709
|
+
return JSON.stringify(value, null, 2)
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
console.warn(
|
|
1712
|
+
'[ai-devtools] formatUnknown failed to JSON.stringify a value (likely a circular reference or BigInt); using a string placeholder instead.',
|
|
1713
|
+
{ error },
|
|
1714
|
+
)
|
|
1715
|
+
return `[ai-devtools] unserializable value: ${
|
|
1716
|
+
error instanceof Error ? error.message : String(error)
|
|
1717
|
+
}`
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function isPreviewMessage(
|
|
1722
|
+
value: PreviewMessage | undefined,
|
|
1723
|
+
): value is PreviewMessage {
|
|
1724
|
+
return Boolean(value)
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function isPreviewPart(value: PreviewPart | undefined): value is PreviewPart {
|
|
1728
|
+
return Boolean(value)
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1732
|
+
return typeof value === 'object' && value !== null
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
function isToolFixtureMessageRole(
|
|
1736
|
+
role: unknown,
|
|
1737
|
+
): role is ToolFixtureMessage['role'] {
|
|
1738
|
+
return role === 'system' || role === 'user' || role === 'assistant'
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
function jsonSafeValue(value: unknown): unknown {
|
|
1742
|
+
try {
|
|
1743
|
+
return JSON.parse(JSON.stringify(value))
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
console.warn(
|
|
1746
|
+
'[ai-devtools] jsonSafeValue failed to round-trip a fixture part (likely a circular reference or BigInt); using a string placeholder instead of the original value.',
|
|
1747
|
+
{ error },
|
|
1748
|
+
)
|
|
1749
|
+
return `[ai-devtools] unserializable value: ${
|
|
1750
|
+
error instanceof Error ? error.message : String(error)
|
|
1751
|
+
}`
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
function formatTime(timestamp: number): string {
|
|
1756
|
+
return new Date(timestamp).toLocaleTimeString()
|
|
1757
|
+
}
|