@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,347 @@
|
|
|
1
|
+
import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'
|
|
2
|
+
import { useAIStore } from '../../store/ai-context'
|
|
3
|
+
import { useStyles } from '../../styles/use-styles'
|
|
4
|
+
import { FixtureNamePopover } from './FixtureNamePopover'
|
|
5
|
+
import type {
|
|
6
|
+
HookRecord,
|
|
7
|
+
RegisteredTool,
|
|
8
|
+
ToolFixtureRecord,
|
|
9
|
+
} from '../../store/hook-registry'
|
|
10
|
+
import type { Component } from 'solid-js'
|
|
11
|
+
|
|
12
|
+
interface ToolFixtureFormProps {
|
|
13
|
+
hook: HookRecord
|
|
14
|
+
tool: RegisteredTool
|
|
15
|
+
onFire: (fixture: ToolFixtureRecord) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SchemaField {
|
|
19
|
+
name: string
|
|
20
|
+
type: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
|
|
21
|
+
required: boolean
|
|
22
|
+
description?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ToolFixtureForm: Component<ToolFixtureFormProps> = (props) => {
|
|
26
|
+
const { saveToolFixture } = useAIStore()
|
|
27
|
+
const styles = useStyles()
|
|
28
|
+
const fields = createMemo(() => fieldsFromSchema(props.tool.inputSchema))
|
|
29
|
+
const [inputValues, setInputValues] = createSignal<Record<string, string>>(
|
|
30
|
+
defaultValues(fields()),
|
|
31
|
+
)
|
|
32
|
+
const [rawInput, setRawInput] = createSignal('{}')
|
|
33
|
+
const [output, setOutput] = createSignal('null')
|
|
34
|
+
const [error, setError] = createSignal<string | null>(null)
|
|
35
|
+
const [pendingSaveName, setPendingSaveName] = createSignal<string | null>(
|
|
36
|
+
null,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
createEffect(() => {
|
|
40
|
+
void props.tool.name
|
|
41
|
+
setInputValues(defaultValues(fields()))
|
|
42
|
+
setRawInput('{}')
|
|
43
|
+
setOutput('null')
|
|
44
|
+
setError(null)
|
|
45
|
+
setPendingSaveName(null)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const setFieldValue = (name: string, value: string) => {
|
|
49
|
+
setInputValues((current) => ({ ...current, [name]: value }))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const createFixture = (name?: string): ToolFixtureRecord | undefined => {
|
|
53
|
+
try {
|
|
54
|
+
setError(null)
|
|
55
|
+
const createdAt = Date.now()
|
|
56
|
+
const parsedInput =
|
|
57
|
+
fields().length > 0
|
|
58
|
+
? parseObjectInput(fields(), inputValues())
|
|
59
|
+
: parseJson(rawInput(), 'Input')
|
|
60
|
+
const parsedOutput = parseJson(output(), 'Output')
|
|
61
|
+
return {
|
|
62
|
+
id: `fixture:${props.hook.id}:${props.tool.name}:${createdAt}:${Math.random()
|
|
63
|
+
.toString(36)
|
|
64
|
+
.slice(2)}`,
|
|
65
|
+
createdAt,
|
|
66
|
+
...(name ? { name } : {}),
|
|
67
|
+
hookId: props.hook.id,
|
|
68
|
+
...(props.hook.threadId ? { threadId: props.hook.threadId } : {}),
|
|
69
|
+
toolName: props.tool.name,
|
|
70
|
+
input: parsedInput,
|
|
71
|
+
output: parsedOutput,
|
|
72
|
+
execute: true,
|
|
73
|
+
}
|
|
74
|
+
} catch (fixtureError) {
|
|
75
|
+
setError(
|
|
76
|
+
fixtureError instanceof Error
|
|
77
|
+
? fixtureError.message
|
|
78
|
+
: 'Unable to create fixture.',
|
|
79
|
+
)
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const handleFire = () => {
|
|
85
|
+
const fixture = createFixture()
|
|
86
|
+
if (!fixture) return
|
|
87
|
+
props.onFire(fixture)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handleSave = () => {
|
|
91
|
+
setPendingSaveName(`${props.tool.name} fixture`)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const handleConfirmSave = (name: string) => {
|
|
95
|
+
const fixture = createFixture(name)
|
|
96
|
+
if (!fixture) return
|
|
97
|
+
saveToolFixture(fixture)
|
|
98
|
+
setPendingSaveName(null)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
class={styles().hookDetails.fixtureForm}
|
|
104
|
+
data-testid="ai-devtools-tool-fixture-form"
|
|
105
|
+
data-tool-name={props.tool.name}
|
|
106
|
+
>
|
|
107
|
+
<div class={styles().hookDetails.sectionTitle}>Tool Fixture</div>
|
|
108
|
+
<div class={styles().hookDetails.fixtureHelp}>
|
|
109
|
+
{props.tool.description || props.tool.name}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<Show
|
|
113
|
+
when={fields().length > 0}
|
|
114
|
+
fallback={
|
|
115
|
+
<label class={styles().hookDetails.fixtureField}>
|
|
116
|
+
<span class={styles().hookDetails.fixtureLabel}>Input JSON</span>
|
|
117
|
+
<textarea
|
|
118
|
+
class={styles().hookDetails.fixtureTextarea}
|
|
119
|
+
value={rawInput()}
|
|
120
|
+
onInput={(event) => setRawInput(event.currentTarget.value)}
|
|
121
|
+
spellcheck={false}
|
|
122
|
+
/>
|
|
123
|
+
</label>
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
<For each={fields()}>
|
|
127
|
+
{(field) => (
|
|
128
|
+
<label class={styles().hookDetails.fixtureField}>
|
|
129
|
+
<span class={styles().hookDetails.fixtureLabel}>
|
|
130
|
+
{field.name}
|
|
131
|
+
<Show when={field.required}>
|
|
132
|
+
<span class={styles().hookDetails.requiredMark}>*</span>
|
|
133
|
+
</Show>
|
|
134
|
+
</span>
|
|
135
|
+
<Show when={field.description}>
|
|
136
|
+
<span class={styles().hookDetails.fixtureHelp}>
|
|
137
|
+
{field.description}
|
|
138
|
+
</span>
|
|
139
|
+
</Show>
|
|
140
|
+
<FieldInput
|
|
141
|
+
field={field}
|
|
142
|
+
value={inputValues()[field.name] ?? ''}
|
|
143
|
+
onInput={(value) => setFieldValue(field.name, value)}
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
146
|
+
)}
|
|
147
|
+
</For>
|
|
148
|
+
</Show>
|
|
149
|
+
|
|
150
|
+
<label class={styles().hookDetails.fixtureField}>
|
|
151
|
+
<span class={styles().hookDetails.fixtureLabel}>Output JSON</span>
|
|
152
|
+
<textarea
|
|
153
|
+
class={styles().hookDetails.fixtureTextarea}
|
|
154
|
+
value={output()}
|
|
155
|
+
onInput={(event) => setOutput(event.currentTarget.value)}
|
|
156
|
+
spellcheck={false}
|
|
157
|
+
/>
|
|
158
|
+
</label>
|
|
159
|
+
|
|
160
|
+
<Show when={error()}>
|
|
161
|
+
{(message) => (
|
|
162
|
+
<div class={styles().hookDetails.fixtureError}>{message()}</div>
|
|
163
|
+
)}
|
|
164
|
+
</Show>
|
|
165
|
+
|
|
166
|
+
<div class={styles().hookDetails.fixtureActions}>
|
|
167
|
+
<button
|
|
168
|
+
class={styles().hookDetails.fixtureButton}
|
|
169
|
+
data-testid="ai-devtools-tool-fire"
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={handleFire}
|
|
172
|
+
>
|
|
173
|
+
Fire
|
|
174
|
+
</button>
|
|
175
|
+
<button
|
|
176
|
+
class={`${styles().hookDetails.fixtureButton} ${styles().hookDetails.fixtureButtonSecondary}`}
|
|
177
|
+
data-testid="ai-devtools-tool-save"
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={handleSave}
|
|
180
|
+
>
|
|
181
|
+
Save
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
<Show when={pendingSaveName()}>
|
|
185
|
+
{(defaultName) => (
|
|
186
|
+
<FixtureNamePopover
|
|
187
|
+
defaultName={defaultName()}
|
|
188
|
+
onCancel={() => setPendingSaveName(null)}
|
|
189
|
+
onSave={handleConfirmSave}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
</Show>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const FieldInput: Component<{
|
|
198
|
+
field: SchemaField
|
|
199
|
+
value: string
|
|
200
|
+
onInput: (value: string) => void
|
|
201
|
+
}> = (props) => {
|
|
202
|
+
const styles = useStyles()
|
|
203
|
+
|
|
204
|
+
if (props.field.type === 'boolean') {
|
|
205
|
+
return (
|
|
206
|
+
<select
|
|
207
|
+
class={styles().hookDetails.fixtureInput}
|
|
208
|
+
value={props.value}
|
|
209
|
+
onInput={(event) => props.onInput(event.currentTarget.value)}
|
|
210
|
+
>
|
|
211
|
+
<option value="false">false</option>
|
|
212
|
+
<option value="true">true</option>
|
|
213
|
+
</select>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (props.field.type === 'object' || props.field.type === 'array') {
|
|
218
|
+
return (
|
|
219
|
+
<textarea
|
|
220
|
+
class={styles().hookDetails.fixtureTextarea}
|
|
221
|
+
value={props.value}
|
|
222
|
+
onInput={(event) => props.onInput(event.currentTarget.value)}
|
|
223
|
+
spellcheck={false}
|
|
224
|
+
/>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<input
|
|
230
|
+
class={styles().hookDetails.fixtureInput}
|
|
231
|
+
type={props.field.type === 'string' ? 'text' : 'number'}
|
|
232
|
+
value={props.value}
|
|
233
|
+
onInput={(event) => props.onInput(event.currentTarget.value)}
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fieldsFromSchema(schema: unknown): Array<SchemaField> {
|
|
239
|
+
if (!isJsonSchemaObject(schema)) return []
|
|
240
|
+
const properties = schema.properties
|
|
241
|
+
if (!isRecord(properties)) return []
|
|
242
|
+
const required = Array.isArray(schema.required)
|
|
243
|
+
? new Set(
|
|
244
|
+
schema.required.filter(
|
|
245
|
+
(item): item is string => typeof item === 'string',
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
: new Set<string>()
|
|
249
|
+
|
|
250
|
+
return Object.entries(properties).map(([name, property]) => {
|
|
251
|
+
const propertySchema = isJsonSchemaObject(property) ? property : undefined
|
|
252
|
+
const type = normalizeType(propertySchema?.type)
|
|
253
|
+
return {
|
|
254
|
+
name,
|
|
255
|
+
type,
|
|
256
|
+
required: required.has(name),
|
|
257
|
+
...(typeof propertySchema?.description === 'string'
|
|
258
|
+
? { description: propertySchema.description }
|
|
259
|
+
: {}),
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function defaultValues(fields: Array<SchemaField>): Record<string, string> {
|
|
265
|
+
const values: Record<string, string> = {}
|
|
266
|
+
for (const field of fields) {
|
|
267
|
+
if (field.type === 'boolean') {
|
|
268
|
+
values[field.name] = 'false'
|
|
269
|
+
} else if (field.type === 'number' || field.type === 'integer') {
|
|
270
|
+
values[field.name] = '0'
|
|
271
|
+
} else if (field.type === 'array') {
|
|
272
|
+
values[field.name] = '[]'
|
|
273
|
+
} else if (field.type === 'object') {
|
|
274
|
+
values[field.name] = '{}'
|
|
275
|
+
} else {
|
|
276
|
+
values[field.name] = ''
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return values
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function parseObjectInput(
|
|
283
|
+
fields: Array<SchemaField>,
|
|
284
|
+
values: Record<string, string>,
|
|
285
|
+
): Record<string, unknown> {
|
|
286
|
+
const input: Record<string, unknown> = {}
|
|
287
|
+
for (const field of fields) {
|
|
288
|
+
const rawValue = values[field.name] ?? ''
|
|
289
|
+
if (!field.required && rawValue.length === 0) {
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
input[field.name] = parseFieldValue(field, rawValue)
|
|
293
|
+
}
|
|
294
|
+
return input
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseFieldValue(field: SchemaField, rawValue: string): unknown {
|
|
298
|
+
if (field.type === 'boolean') return rawValue === 'true'
|
|
299
|
+
if (field.type === 'number') {
|
|
300
|
+
const value = Number(rawValue)
|
|
301
|
+
if (!Number.isFinite(value)) {
|
|
302
|
+
throw new Error(`${field.name} must be a number.`)
|
|
303
|
+
}
|
|
304
|
+
return value
|
|
305
|
+
}
|
|
306
|
+
if (field.type === 'integer') {
|
|
307
|
+
const value = Number(rawValue)
|
|
308
|
+
if (!Number.isInteger(value)) {
|
|
309
|
+
throw new Error(`${field.name} must be an integer.`)
|
|
310
|
+
}
|
|
311
|
+
return value
|
|
312
|
+
}
|
|
313
|
+
if (field.type === 'object' || field.type === 'array') {
|
|
314
|
+
return parseJson(rawValue, field.name)
|
|
315
|
+
}
|
|
316
|
+
return rawValue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function parseJson(value: string, label: string): unknown {
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(value)
|
|
322
|
+
} catch {
|
|
323
|
+
throw new Error(`${label} must be valid JSON.`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function normalizeType(value: unknown): SchemaField['type'] {
|
|
328
|
+
if (value === 'boolean') return 'boolean'
|
|
329
|
+
if (value === 'number') return 'number'
|
|
330
|
+
if (value === 'integer') return 'integer'
|
|
331
|
+
if (value === 'array') return 'array'
|
|
332
|
+
if (value === 'object') return 'object'
|
|
333
|
+
return 'string'
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isJsonSchemaObject(value: unknown): value is {
|
|
337
|
+
type?: unknown
|
|
338
|
+
description?: unknown
|
|
339
|
+
properties?: unknown
|
|
340
|
+
required?: unknown
|
|
341
|
+
} {
|
|
342
|
+
return isRecord(value)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
346
|
+
return typeof value === 'object' && value !== null
|
|
347
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { HookRecord, RunRecord } from '../../store/hook-registry'
|
|
2
|
+
|
|
3
|
+
export type HookCategoryId =
|
|
4
|
+
| 'chat'
|
|
5
|
+
| 'structured'
|
|
6
|
+
| 'image'
|
|
7
|
+
| 'audio'
|
|
8
|
+
| 'video'
|
|
9
|
+
| 'speech'
|
|
10
|
+
| 'transcription'
|
|
11
|
+
| 'summarize'
|
|
12
|
+
| 'text'
|
|
13
|
+
| 'other'
|
|
14
|
+
|
|
15
|
+
export interface HookCategoryGroup {
|
|
16
|
+
id: HookCategoryId
|
|
17
|
+
label: string
|
|
18
|
+
hooks: Array<HookRecord>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HookDashboardSummary {
|
|
22
|
+
total: number
|
|
23
|
+
active: number
|
|
24
|
+
running: number
|
|
25
|
+
categories: number
|
|
26
|
+
tools: number
|
|
27
|
+
runs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hookCategoryOrder: Array<HookCategoryId> = [
|
|
31
|
+
'chat',
|
|
32
|
+
'structured',
|
|
33
|
+
'image',
|
|
34
|
+
'audio',
|
|
35
|
+
'video',
|
|
36
|
+
'speech',
|
|
37
|
+
'transcription',
|
|
38
|
+
'summarize',
|
|
39
|
+
'text',
|
|
40
|
+
'other',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const hookCategoryLabels: Record<HookCategoryId, string> = {
|
|
44
|
+
chat: 'Chat',
|
|
45
|
+
structured: 'Structured',
|
|
46
|
+
image: 'Image',
|
|
47
|
+
audio: 'Audio',
|
|
48
|
+
video: 'Video',
|
|
49
|
+
speech: 'Speech',
|
|
50
|
+
transcription: 'Transcription',
|
|
51
|
+
summarize: 'Summarize',
|
|
52
|
+
text: 'Text',
|
|
53
|
+
other: 'Other',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function visibleHooks(hooks: Array<HookRecord>): Array<HookRecord> {
|
|
57
|
+
return hooks
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getHookDisplayName(hook: HookRecord): string {
|
|
61
|
+
return hook.displayName ?? hook.hookName
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function groupHooksByCategory(
|
|
65
|
+
hooks: Array<HookRecord>,
|
|
66
|
+
): Array<HookCategoryGroup> {
|
|
67
|
+
const groups = new Map<HookCategoryId, Array<HookRecord>>()
|
|
68
|
+
|
|
69
|
+
for (const hook of hooks) {
|
|
70
|
+
const category = inferHookCategory(hook)
|
|
71
|
+
groups.set(category, [...(groups.get(category) ?? []), hook])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return hookCategoryOrder.flatMap((id) => {
|
|
75
|
+
const categoryHooks = groups.get(id)
|
|
76
|
+
if (!categoryHooks?.length) return []
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
id,
|
|
81
|
+
label: hookCategoryLabels[id],
|
|
82
|
+
hooks: [...categoryHooks].sort(
|
|
83
|
+
(a, b) => a.registeredAt - b.registeredAt,
|
|
84
|
+
),
|
|
85
|
+
},
|
|
86
|
+
]
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createHookDashboardSummary(
|
|
91
|
+
hooks: Array<HookRecord>,
|
|
92
|
+
runs: Record<string, RunRecord>,
|
|
93
|
+
): HookDashboardSummary {
|
|
94
|
+
return {
|
|
95
|
+
total: hooks.length,
|
|
96
|
+
active: hooks.length,
|
|
97
|
+
running: hooks.filter((hook) => isHookRunning(hook, runs)).length,
|
|
98
|
+
categories: groupHooksByCategory(hooks).length,
|
|
99
|
+
tools: hooks.reduce((count, hook) => count + hook.tools.length, 0),
|
|
100
|
+
runs: Object.keys(runs).length,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isHookRunning(
|
|
105
|
+
hook: HookRecord,
|
|
106
|
+
runs: Record<string, RunRecord>,
|
|
107
|
+
): boolean {
|
|
108
|
+
if (hook.lifecycle === 'streaming') {
|
|
109
|
+
return true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return hook.runIds.some((runId) => {
|
|
113
|
+
const run = runs[runId]
|
|
114
|
+
return run ? isRunActive(run) : false
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isRunActive(run: RunRecord): boolean {
|
|
119
|
+
return (
|
|
120
|
+
run.status === 'created' ||
|
|
121
|
+
run.status === 'started' ||
|
|
122
|
+
run.status === 'updated'
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function inferHookCategory(hook: HookRecord): HookCategoryId {
|
|
127
|
+
const hookName = hook.hookName.toLowerCase()
|
|
128
|
+
|
|
129
|
+
if (hookName.includes('speech')) return 'speech'
|
|
130
|
+
if (hookName.includes('transcription')) return 'transcription'
|
|
131
|
+
if (hookName.includes('summarize')) return 'summarize'
|
|
132
|
+
|
|
133
|
+
return hook.outputKind ?? 'other'
|
|
134
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface PreviewMessageForMerge {
|
|
2
|
+
id: string
|
|
3
|
+
role?: string
|
|
4
|
+
content?: string
|
|
5
|
+
parts?: Array<PreviewPartForMerge>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PreviewPartForMerge {
|
|
9
|
+
id: string
|
|
10
|
+
kind?: string
|
|
11
|
+
content?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mergePreviewMessagesForUserView<
|
|
15
|
+
TMessage extends PreviewMessageForMerge,
|
|
16
|
+
>(
|
|
17
|
+
conversationMessages: Array<TMessage>,
|
|
18
|
+
snapshotMessages: Array<TMessage>,
|
|
19
|
+
): Array<TMessage> {
|
|
20
|
+
if (conversationMessages.length === 0) {
|
|
21
|
+
return snapshotMessages.filter(isRenderablePreviewMessage)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const conversationIds = new Set(
|
|
25
|
+
conversationMessages.map((message) => message.id),
|
|
26
|
+
)
|
|
27
|
+
const snapshotById = new Map(
|
|
28
|
+
snapshotMessages
|
|
29
|
+
.filter(isRenderablePreviewMessage)
|
|
30
|
+
.map((message) => [message.id, message] as const),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const renderableConversationMessages = conversationMessages.filter(
|
|
34
|
+
isRenderablePreviewMessage,
|
|
35
|
+
)
|
|
36
|
+
const shouldUseSnapshotNonUserMessages =
|
|
37
|
+
renderableConversationMessages.length > 0 &&
|
|
38
|
+
!renderableConversationMessages.some(isNonUserPreviewMessage)
|
|
39
|
+
|
|
40
|
+
const mergedMessages = [
|
|
41
|
+
...conversationMessages.map(
|
|
42
|
+
(message) => snapshotById.get(message.id) ?? message,
|
|
43
|
+
),
|
|
44
|
+
...snapshotMessages.filter(
|
|
45
|
+
(message) =>
|
|
46
|
+
!conversationIds.has(message.id) &&
|
|
47
|
+
(isFixtureReplayMessage(message) ||
|
|
48
|
+
(shouldUseSnapshotNonUserMessages &&
|
|
49
|
+
isNonUserPreviewMessage(message))),
|
|
50
|
+
),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
return mergedMessages.filter(isRenderablePreviewMessage)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function partsToMessageText(parts: Array<unknown>): string {
|
|
57
|
+
return parts
|
|
58
|
+
.map((part) => {
|
|
59
|
+
if (!isRecord(part)) return ''
|
|
60
|
+
if (part.type !== 'text') return ''
|
|
61
|
+
if (typeof part.text === 'string') return part.text
|
|
62
|
+
if (typeof part.content === 'string') return part.content
|
|
63
|
+
return ''
|
|
64
|
+
})
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function visiblePreviewPartsForMessage<
|
|
70
|
+
TPart extends PreviewPartForMerge,
|
|
71
|
+
>(message: { content?: string; parts?: Array<TPart> }): Array<TPart> {
|
|
72
|
+
const messageContent = normalizeText(message.content)
|
|
73
|
+
return (message.parts ?? []).filter((part) => {
|
|
74
|
+
if (part.kind !== 'text') return true
|
|
75
|
+
if (!messageContent) return true
|
|
76
|
+
return normalizeText(part.content) !== messageContent
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hasStructuredOutputPreview(
|
|
81
|
+
messages: Array<PreviewMessageForMerge>,
|
|
82
|
+
): boolean {
|
|
83
|
+
return messages.some((message) =>
|
|
84
|
+
message.parts?.some((part) => part.kind === 'structured-output'),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isFixtureReplayMessage(message: PreviewMessageForMerge): boolean {
|
|
89
|
+
if (message.id.startsWith('fixture-msg-')) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return Boolean(
|
|
94
|
+
message.parts?.some((part) =>
|
|
95
|
+
part.id.startsWith('tool-call:fixture-tool-call-'),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isRenderablePreviewMessage(message: PreviewMessageForMerge): boolean {
|
|
101
|
+
if (message.content?.trim()) {
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
return Boolean(message.parts?.length)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isNonUserPreviewMessage(message: PreviewMessageForMerge): boolean {
|
|
108
|
+
return message.role !== undefined && message.role !== 'user'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
112
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeText(value: string | undefined): string {
|
|
116
|
+
return value?.trim() ?? ''
|
|
117
|
+
}
|