@stina/extension-api 0.51.0 → 0.53.1
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/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/runtime.cjs +2 -2
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.d.cts +2 -2
- package/dist/runtime.d.ts +2 -2
- package/dist/runtime.js +2 -2
- package/dist/runtime.js.map +1 -1
- package/dist/schemas/index.cjs +545 -2
- package/dist/schemas/index.cjs.map +1 -1
- package/dist/schemas/index.d.cts +735 -35
- package/dist/schemas/index.d.ts +735 -35
- package/dist/schemas/index.js +520 -1
- package/dist/schemas/index.js.map +1 -1
- package/dist/{types.tools-XpXCoPrJ.d.cts → types.tools-BL9WPsi_.d.cts} +314 -2
- package/dist/{types.tools-XpXCoPrJ.d.ts → types.tools-BL9WPsi_.d.ts} +314 -2
- package/package.json +1 -1
- package/src/index.ts +20 -0
- package/src/runtime.ts +2 -2
- package/src/schemas/card.schema.test.ts +235 -0
- package/src/schemas/card.schema.ts +606 -0
- package/src/schemas/components.schema.ts +241 -0
- package/src/schemas/index.ts +30 -0
- package/src/types.components.ts +336 -0
- package/src/types.context.ts +15 -1
- package/src/types.tools.ts +15 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Card Schema
|
|
3
|
+
*
|
|
4
|
+
* The subset of the component DSL that may appear *in a conversation*, as
|
|
5
|
+
* opposed to in a panel or a settings form.
|
|
6
|
+
*
|
|
7
|
+
* A panel is a place the user went to; a card arrives unbidden in the middle of
|
|
8
|
+
* a reply, and it is composed by a language model rather than by an extension
|
|
9
|
+
* author who tested it. So the card profile is narrower than the DSL in three
|
|
10
|
+
* ways, and each one is a rule about what a card *is*:
|
|
11
|
+
*
|
|
12
|
+
* - **No interaction.** No buttons, no inputs, no modals. A card shows; it does
|
|
13
|
+
* not offer. Everything the user might want to do next, they ask for in
|
|
14
|
+
* words — which is the whole premise of talking to Stina rather than
|
|
15
|
+
* operating her.
|
|
16
|
+
* - **No data binding.** Panels pull from actions and iterate over the result;
|
|
17
|
+
* a card is a finished value, written out in full. There is no scope for a
|
|
18
|
+
* `$reference` to resolve against, so one is rejected rather than silently
|
|
19
|
+
* rendered as nothing — see `findScopeReference`.
|
|
20
|
+
* - **Strict props.** The panel schemas pass unknown properties through, on the
|
|
21
|
+
* theory that a newer extension may know something this build does not. Cards
|
|
22
|
+
* have no such future to protect: an unknown property here is a model that
|
|
23
|
+
* invented one, and saying so is more useful than dropping it.
|
|
24
|
+
*
|
|
25
|
+
* The check runs where the card enters the system — in `core_show_card`, and in
|
|
26
|
+
* anything else that accepts a card from a tool — so a rejection comes back to
|
|
27
|
+
* the model as an error it can act on, in the same turn.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { z } from 'zod'
|
|
31
|
+
import { ExtensionComponentStyleSchema } from './components.schema.js'
|
|
32
|
+
import {
|
|
33
|
+
WeatherConditionSchema,
|
|
34
|
+
WeatherWindSchema,
|
|
35
|
+
ChartKindSchema,
|
|
36
|
+
StatTrendSchema,
|
|
37
|
+
TimelineVariantSchema,
|
|
38
|
+
NoteVariantSchema,
|
|
39
|
+
CalendarEventStatusSchema,
|
|
40
|
+
PillVariantSchema,
|
|
41
|
+
FrameVariantSchema,
|
|
42
|
+
} from './components.schema.js'
|
|
43
|
+
|
|
44
|
+
/** A card component and, recursively, everything it may contain. */
|
|
45
|
+
export type ChatCardComponent = {
|
|
46
|
+
component: string
|
|
47
|
+
style?: Record<string, string>
|
|
48
|
+
[key: string]: unknown
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const style = ExtensionComponentStyleSchema.optional()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Children of a card component.
|
|
55
|
+
*
|
|
56
|
+
* An array only. The iterator form (`{ each, as, items }`) exists to expand a
|
|
57
|
+
* data source, and a card has none — the caller writes the rows out.
|
|
58
|
+
*/
|
|
59
|
+
const children: z.ZodType<ChatCardComponent[]> = z.lazy(() =>
|
|
60
|
+
z.array(ChatCardComponentSchema).max(40)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// -----------------------------------------------------------------------------
|
|
64
|
+
// Layout
|
|
65
|
+
// -----------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const VerticalStackCardSchema = z
|
|
68
|
+
.object({
|
|
69
|
+
component: z.literal('VerticalStack'),
|
|
70
|
+
gap: z.number().min(0).max(8).optional(),
|
|
71
|
+
children,
|
|
72
|
+
style,
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
|
|
76
|
+
const HorizontalStackCardSchema = z
|
|
77
|
+
.object({
|
|
78
|
+
component: z.literal('HorizontalStack'),
|
|
79
|
+
gap: z.number().min(0).max(8).optional(),
|
|
80
|
+
children,
|
|
81
|
+
style,
|
|
82
|
+
})
|
|
83
|
+
.strict()
|
|
84
|
+
|
|
85
|
+
const GridCardSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
component: z.literal('Grid'),
|
|
88
|
+
columns: z.number().min(1).max(6),
|
|
89
|
+
gap: z.number().min(0).max(8).optional(),
|
|
90
|
+
children,
|
|
91
|
+
style,
|
|
92
|
+
})
|
|
93
|
+
.strict()
|
|
94
|
+
|
|
95
|
+
const FrameCardSchema = z
|
|
96
|
+
.object({
|
|
97
|
+
component: z.literal('Frame'),
|
|
98
|
+
// Only the string form: the components form of a title is for panels that
|
|
99
|
+
// put controls in their header, which a card has no business doing.
|
|
100
|
+
title: z.string().optional(),
|
|
101
|
+
collapsible: z.boolean().optional(),
|
|
102
|
+
defaultExpanded: z.boolean().optional(),
|
|
103
|
+
variant: FrameVariantSchema.optional(),
|
|
104
|
+
children,
|
|
105
|
+
style,
|
|
106
|
+
})
|
|
107
|
+
.strict()
|
|
108
|
+
|
|
109
|
+
const ListCardSchema = z.object({ component: z.literal('List'), children, style }).strict()
|
|
110
|
+
|
|
111
|
+
const DividerCardSchema = z.object({ component: z.literal('Divider'), style }).strict()
|
|
112
|
+
|
|
113
|
+
// -----------------------------------------------------------------------------
|
|
114
|
+
// Text
|
|
115
|
+
// -----------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const HeaderCardSchema = z
|
|
118
|
+
.object({
|
|
119
|
+
component: z.literal('Header'),
|
|
120
|
+
level: z.number().min(1).max(6),
|
|
121
|
+
title: z.string(),
|
|
122
|
+
description: z.union([z.string(), z.array(z.string())]).optional(),
|
|
123
|
+
icon: z.string().optional(),
|
|
124
|
+
style,
|
|
125
|
+
})
|
|
126
|
+
.strict()
|
|
127
|
+
|
|
128
|
+
const LabelCardSchema = z
|
|
129
|
+
.object({ component: z.literal('Label'), text: z.string(), style })
|
|
130
|
+
.strict()
|
|
131
|
+
|
|
132
|
+
const ParagraphCardSchema = z
|
|
133
|
+
.object({ component: z.literal('Paragraph'), text: z.string(), style })
|
|
134
|
+
.strict()
|
|
135
|
+
|
|
136
|
+
const MarkdownCardSchema = z
|
|
137
|
+
.object({ component: z.literal('Markdown'), content: z.string(), style })
|
|
138
|
+
.strict()
|
|
139
|
+
|
|
140
|
+
// -----------------------------------------------------------------------------
|
|
141
|
+
// Visual
|
|
142
|
+
// -----------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
const IconCardSchema = z
|
|
145
|
+
.object({
|
|
146
|
+
component: z.literal('Icon'),
|
|
147
|
+
name: z.string(),
|
|
148
|
+
title: z.string().optional(),
|
|
149
|
+
style,
|
|
150
|
+
})
|
|
151
|
+
.strict()
|
|
152
|
+
|
|
153
|
+
const PillCardSchema = z
|
|
154
|
+
.object({
|
|
155
|
+
component: z.literal('Pill'),
|
|
156
|
+
text: z.string(),
|
|
157
|
+
icon: z.string().optional(),
|
|
158
|
+
variant: PillVariantSchema.optional(),
|
|
159
|
+
style,
|
|
160
|
+
})
|
|
161
|
+
.strict()
|
|
162
|
+
|
|
163
|
+
// -----------------------------------------------------------------------------
|
|
164
|
+
// Purpose-built
|
|
165
|
+
// -----------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const WeatherNowCardSchema = z
|
|
168
|
+
.object({
|
|
169
|
+
component: z.literal('WeatherNow'),
|
|
170
|
+
place: z.string(),
|
|
171
|
+
condition: WeatherConditionSchema,
|
|
172
|
+
conditionLabel: z.string().optional(),
|
|
173
|
+
temperature: z.number(),
|
|
174
|
+
feelsLike: z.number().optional(),
|
|
175
|
+
unit: z.string().optional(),
|
|
176
|
+
wind: WeatherWindSchema.optional(),
|
|
177
|
+
night: z.boolean().optional(),
|
|
178
|
+
summary: z.string().optional(),
|
|
179
|
+
style,
|
|
180
|
+
})
|
|
181
|
+
.strict()
|
|
182
|
+
|
|
183
|
+
const WeatherForecastCardSchema = z
|
|
184
|
+
.object({
|
|
185
|
+
component: z.literal('WeatherForecast'),
|
|
186
|
+
title: z.string().optional(),
|
|
187
|
+
icon: z.string().optional(),
|
|
188
|
+
unit: z.string().optional(),
|
|
189
|
+
steps: z
|
|
190
|
+
.array(
|
|
191
|
+
z
|
|
192
|
+
.object({
|
|
193
|
+
label: z.string(),
|
|
194
|
+
condition: WeatherConditionSchema,
|
|
195
|
+
conditionLabel: z.string().optional(),
|
|
196
|
+
high: z.number(),
|
|
197
|
+
low: z.number().optional(),
|
|
198
|
+
night: z.boolean().optional(),
|
|
199
|
+
precipitation: z.number().min(0).max(100).optional(),
|
|
200
|
+
current: z.boolean().optional(),
|
|
201
|
+
})
|
|
202
|
+
.strict()
|
|
203
|
+
)
|
|
204
|
+
.min(1)
|
|
205
|
+
.max(24),
|
|
206
|
+
style,
|
|
207
|
+
})
|
|
208
|
+
.strict()
|
|
209
|
+
|
|
210
|
+
const ChartCardSchema = z
|
|
211
|
+
.object({
|
|
212
|
+
component: z.literal('Chart'),
|
|
213
|
+
chart: ChartKindSchema.optional(),
|
|
214
|
+
series: z
|
|
215
|
+
.array(
|
|
216
|
+
z
|
|
217
|
+
.object({
|
|
218
|
+
name: z.string().optional(),
|
|
219
|
+
points: z.array(z.number().nullable()).min(1).max(200),
|
|
220
|
+
})
|
|
221
|
+
.strict()
|
|
222
|
+
)
|
|
223
|
+
.min(1)
|
|
224
|
+
.max(8),
|
|
225
|
+
labels: z.array(z.string()).max(200).optional(),
|
|
226
|
+
title: z.string().optional(),
|
|
227
|
+
icon: z.string().optional(),
|
|
228
|
+
unit: z.string().optional(),
|
|
229
|
+
zeroBaseline: z.boolean().optional(),
|
|
230
|
+
height: z.number().min(4).max(24).optional(),
|
|
231
|
+
style,
|
|
232
|
+
})
|
|
233
|
+
.strict()
|
|
234
|
+
|
|
235
|
+
const StatTileCardSchema = z
|
|
236
|
+
.object({
|
|
237
|
+
component: z.literal('StatTile'),
|
|
238
|
+
label: z.string(),
|
|
239
|
+
value: z.union([z.string(), z.number()]),
|
|
240
|
+
unit: z.string().optional(),
|
|
241
|
+
caption: z.string().optional(),
|
|
242
|
+
icon: z.string().optional(),
|
|
243
|
+
trend: StatTrendSchema.optional(),
|
|
244
|
+
trendLabel: z.string().optional(),
|
|
245
|
+
trendIsGood: z.boolean().optional(),
|
|
246
|
+
style,
|
|
247
|
+
})
|
|
248
|
+
.strict()
|
|
249
|
+
|
|
250
|
+
const KeyValueListCardSchema = z
|
|
251
|
+
.object({
|
|
252
|
+
component: z.literal('KeyValueList'),
|
|
253
|
+
rows: z
|
|
254
|
+
.array(
|
|
255
|
+
z
|
|
256
|
+
.object({ label: z.string(), value: z.string(), icon: z.string().optional() })
|
|
257
|
+
.strict()
|
|
258
|
+
)
|
|
259
|
+
.min(1)
|
|
260
|
+
.max(40),
|
|
261
|
+
style,
|
|
262
|
+
})
|
|
263
|
+
.strict()
|
|
264
|
+
|
|
265
|
+
const TimelineCardSchema = z
|
|
266
|
+
.object({
|
|
267
|
+
component: z.literal('Timeline'),
|
|
268
|
+
title: z.string().optional(),
|
|
269
|
+
icon: z.string().optional(),
|
|
270
|
+
entries: z
|
|
271
|
+
.array(
|
|
272
|
+
z
|
|
273
|
+
.object({
|
|
274
|
+
time: z.string(),
|
|
275
|
+
title: z.string(),
|
|
276
|
+
description: z.string().optional(),
|
|
277
|
+
icon: z.string().optional(),
|
|
278
|
+
duration: z.string().optional(),
|
|
279
|
+
badge: z.string().optional(),
|
|
280
|
+
current: z.boolean().optional(),
|
|
281
|
+
past: z.boolean().optional(),
|
|
282
|
+
variant: TimelineVariantSchema.optional(),
|
|
283
|
+
})
|
|
284
|
+
.strict()
|
|
285
|
+
)
|
|
286
|
+
.min(1)
|
|
287
|
+
.max(40),
|
|
288
|
+
style,
|
|
289
|
+
})
|
|
290
|
+
.strict()
|
|
291
|
+
|
|
292
|
+
const NoteCardSchema = z
|
|
293
|
+
.object({
|
|
294
|
+
component: z.literal('Note'),
|
|
295
|
+
title: z.string().optional(),
|
|
296
|
+
icon: z.string().optional(),
|
|
297
|
+
badge: z.string().optional(),
|
|
298
|
+
content: z.string(),
|
|
299
|
+
footer: z.string().optional(),
|
|
300
|
+
variant: NoteVariantSchema.optional(),
|
|
301
|
+
style,
|
|
302
|
+
})
|
|
303
|
+
.strict()
|
|
304
|
+
|
|
305
|
+
const CalendarEventCardSchema = z
|
|
306
|
+
.object({
|
|
307
|
+
component: z.literal('CalendarEvent'),
|
|
308
|
+
title: z.string(),
|
|
309
|
+
start: z.string(),
|
|
310
|
+
end: z.string().optional(),
|
|
311
|
+
allDay: z.boolean().optional(),
|
|
312
|
+
location: z.string().optional(),
|
|
313
|
+
organizer: z.string().optional(),
|
|
314
|
+
attendees: z.array(z.string()).max(40).optional(),
|
|
315
|
+
calendar: z.string().optional(),
|
|
316
|
+
status: CalendarEventStatusSchema.optional(),
|
|
317
|
+
recurrence: z.string().optional(),
|
|
318
|
+
notes: z.string().optional(),
|
|
319
|
+
style,
|
|
320
|
+
})
|
|
321
|
+
.strict()
|
|
322
|
+
|
|
323
|
+
/** Every component name a card may use. Exported so the docs and the tool description stay in step. */
|
|
324
|
+
export const CHAT_CARD_COMPONENTS = [
|
|
325
|
+
'VerticalStack',
|
|
326
|
+
'HorizontalStack',
|
|
327
|
+
'Grid',
|
|
328
|
+
'Frame',
|
|
329
|
+
'List',
|
|
330
|
+
'Divider',
|
|
331
|
+
'Header',
|
|
332
|
+
'Label',
|
|
333
|
+
'Paragraph',
|
|
334
|
+
'Markdown',
|
|
335
|
+
'Icon',
|
|
336
|
+
'Pill',
|
|
337
|
+
'WeatherNow',
|
|
338
|
+
'WeatherForecast',
|
|
339
|
+
'Chart',
|
|
340
|
+
'StatTile',
|
|
341
|
+
'KeyValueList',
|
|
342
|
+
'Timeline',
|
|
343
|
+
'Note',
|
|
344
|
+
'CalendarEvent',
|
|
345
|
+
] as const
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every component of the card profile, in the order they are documented.
|
|
349
|
+
*
|
|
350
|
+
* One list, used twice: it is what the union validates against and what
|
|
351
|
+
* `describeChatCardProfile` reads to write the vocabulary out. A component
|
|
352
|
+
* added here is legal *and* documented, in one edit — the alternative is a
|
|
353
|
+
* prose list that drifts, and a model reading a drifted list writes cards the
|
|
354
|
+
* validator then rejects.
|
|
355
|
+
*/
|
|
356
|
+
const CARD_MEMBERS = [
|
|
357
|
+
VerticalStackCardSchema,
|
|
358
|
+
HorizontalStackCardSchema,
|
|
359
|
+
GridCardSchema,
|
|
360
|
+
FrameCardSchema,
|
|
361
|
+
ListCardSchema,
|
|
362
|
+
DividerCardSchema,
|
|
363
|
+
HeaderCardSchema,
|
|
364
|
+
LabelCardSchema,
|
|
365
|
+
ParagraphCardSchema,
|
|
366
|
+
MarkdownCardSchema,
|
|
367
|
+
IconCardSchema,
|
|
368
|
+
PillCardSchema,
|
|
369
|
+
WeatherNowCardSchema,
|
|
370
|
+
WeatherForecastCardSchema,
|
|
371
|
+
ChartCardSchema,
|
|
372
|
+
StatTileCardSchema,
|
|
373
|
+
KeyValueListCardSchema,
|
|
374
|
+
TimelineCardSchema,
|
|
375
|
+
NoteCardSchema,
|
|
376
|
+
CalendarEventCardSchema,
|
|
377
|
+
] as const
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* One component of a chat card.
|
|
381
|
+
*
|
|
382
|
+
* Discriminated on `component` so a wrong property is reported against the
|
|
383
|
+
* component the author actually named, rather than as "no union member matched"
|
|
384
|
+
* across eighteen alternatives.
|
|
385
|
+
*/
|
|
386
|
+
export const ChatCardComponentSchema: z.ZodType<ChatCardComponent> = z.lazy(
|
|
387
|
+
() =>
|
|
388
|
+
z.discriminatedUnion(
|
|
389
|
+
'component',
|
|
390
|
+
CARD_MEMBERS as unknown as [(typeof CARD_MEMBERS)[number], (typeof CARD_MEMBERS)[number]]
|
|
391
|
+
) as unknown as z.ZodType<ChatCardComponent>
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
/** The root of a card. Same profile as any component within it. */
|
|
395
|
+
export const ChatCardSchema = ChatCardComponentSchema
|
|
396
|
+
|
|
397
|
+
/** How deeply components may nest inside a card, root included. */
|
|
398
|
+
const MAX_CARD_DEPTH = 8
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Rejects `$`-prefixed strings anywhere in the card.
|
|
402
|
+
*
|
|
403
|
+
* In a panel a leading `$` means "look this up in scope". A card has no scope,
|
|
404
|
+
* so the lookup would return nothing and the value would render as an empty
|
|
405
|
+
* space — a price written `$120` would simply disappear. Zod cannot see this,
|
|
406
|
+
* because to it the value is a perfectly good string; so it is checked
|
|
407
|
+
* separately, and the caller is told to write the value without the prefix.
|
|
408
|
+
*
|
|
409
|
+
* @returns The offending path, or null when the card is clean.
|
|
410
|
+
*/
|
|
411
|
+
function findScopeReference(value: unknown, path: string): string | null {
|
|
412
|
+
if (typeof value === 'string') {
|
|
413
|
+
return value.startsWith('$') ? path : null
|
|
414
|
+
}
|
|
415
|
+
if (Array.isArray(value)) {
|
|
416
|
+
for (let i = 0; i < value.length; i++) {
|
|
417
|
+
const found = findScopeReference(value[i], `${path}[${i}]`)
|
|
418
|
+
if (found) return found
|
|
419
|
+
}
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
if (value && typeof value === 'object') {
|
|
423
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
424
|
+
const found = findScopeReference(child, path ? `${path}.${key}` : key)
|
|
425
|
+
if (found) return found
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return null
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Depth of the deepest `children` chain, counting the root as 1. */
|
|
432
|
+
function cardDepth(value: unknown): number {
|
|
433
|
+
if (!value || typeof value !== 'object') return 0
|
|
434
|
+
const kids = (value as { children?: unknown }).children
|
|
435
|
+
if (!Array.isArray(kids) || kids.length === 0) return 1
|
|
436
|
+
let deepest = 0
|
|
437
|
+
for (const child of kids) {
|
|
438
|
+
const depth = cardDepth(child)
|
|
439
|
+
if (depth > deepest) deepest = depth
|
|
440
|
+
}
|
|
441
|
+
return deepest + 1
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Catch a chart whose series do not line up with its labels.
|
|
446
|
+
*
|
|
447
|
+
* Points map to labels by position, so a series one short does not fail to
|
|
448
|
+
* render — it renders the wrong reading against every label after the gap,
|
|
449
|
+
* which is worse than not drawing at all. Zod cannot express the relationship
|
|
450
|
+
* between two sibling arrays, so it is checked here.
|
|
451
|
+
*
|
|
452
|
+
* @returns A message naming the mismatch, or null when everything lines up.
|
|
453
|
+
*/
|
|
454
|
+
function findMisalignedChart(value: unknown): string | null {
|
|
455
|
+
if (!value || typeof value !== 'object') return null
|
|
456
|
+
|
|
457
|
+
if (Array.isArray(value)) {
|
|
458
|
+
for (const item of value) {
|
|
459
|
+
const found = findMisalignedChart(item)
|
|
460
|
+
if (found) return found
|
|
461
|
+
}
|
|
462
|
+
return null
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const node = value as { component?: string; labels?: unknown; series?: unknown }
|
|
466
|
+
if (node.component === 'Chart' && Array.isArray(node.labels) && Array.isArray(node.series)) {
|
|
467
|
+
const expected = node.labels.length
|
|
468
|
+
for (let i = 0; i < node.series.length; i++) {
|
|
469
|
+
const points = (node.series[i] as { points?: unknown[] })?.points
|
|
470
|
+
if (Array.isArray(points) && points.length !== expected) {
|
|
471
|
+
return (
|
|
472
|
+
`Chart series ${i} has ${points.length} points but there are ${expected} labels. ` +
|
|
473
|
+
`Points line up with labels by position — use null for a reading you do not have.`
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
for (const child of Object.values(node as Record<string, unknown>)) {
|
|
480
|
+
const found = findMisalignedChart(child)
|
|
481
|
+
if (found) return found
|
|
482
|
+
}
|
|
483
|
+
return null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** What came back from validating a card. */
|
|
487
|
+
export type ChatCardValidation =
|
|
488
|
+
| { ok: true; card: ChatCardComponent }
|
|
489
|
+
| { ok: false; error: string }
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Validate a card against the chat profile.
|
|
493
|
+
*
|
|
494
|
+
* Returns a message rather than throwing, because the caller is almost always a
|
|
495
|
+
* tool that has to hand the reason back to the model — an exception would become
|
|
496
|
+
* "the tool failed", which is not something anyone can fix.
|
|
497
|
+
*/
|
|
498
|
+
export function validateChatCard(card: unknown): ChatCardValidation {
|
|
499
|
+
const parsed = ChatCardSchema.safeParse(card)
|
|
500
|
+
if (!parsed.success) {
|
|
501
|
+
const issue = parsed.error.issues[0]
|
|
502
|
+
const where = issue?.path.length ? ` at ${issue.path.join('.')}` : ''
|
|
503
|
+
return { ok: false, error: `${issue?.message ?? 'Invalid card'}${where}` }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const depth = cardDepth(parsed.data)
|
|
507
|
+
if (depth > MAX_CARD_DEPTH) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: `Card nests ${depth} levels deep; at most ${MAX_CARD_DEPTH} are allowed. Flatten it.`,
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const misaligned = findMisalignedChart(parsed.data)
|
|
515
|
+
if (misaligned) {
|
|
516
|
+
return { ok: false, error: misaligned }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const reference = findScopeReference(parsed.data, '')
|
|
520
|
+
if (reference) {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
error:
|
|
524
|
+
`The value at ${reference} starts with "$", which the component system reads as a ` +
|
|
525
|
+
`reference to data that a card does not have, so it would render as nothing. ` +
|
|
526
|
+
`Write the value without the leading "$".`,
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return { ok: true, card: parsed.data }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// -----------------------------------------------------------------------------
|
|
534
|
+
// Describing the profile
|
|
535
|
+
// -----------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Render one field's type compactly: `line|bar|area`, `number?`, `{a, b?}[]`.
|
|
539
|
+
*
|
|
540
|
+
* Enough for a reader to write a valid value, and short enough that the whole
|
|
541
|
+
* vocabulary fits in a tool description without crowding out everything else
|
|
542
|
+
* the model has to hold. Anything this cannot name falls back to the value's
|
|
543
|
+
* own kind rather than to nothing.
|
|
544
|
+
*/
|
|
545
|
+
function describeType(schema: z.ZodTypeAny): string {
|
|
546
|
+
const def = schema._def as { typeName?: string; [key: string]: unknown }
|
|
547
|
+
|
|
548
|
+
switch (def.typeName) {
|
|
549
|
+
case 'ZodOptional':
|
|
550
|
+
case 'ZodNullable':
|
|
551
|
+
case 'ZodDefault':
|
|
552
|
+
return describeType((schema as unknown as { unwrap(): z.ZodTypeAny }).unwrap())
|
|
553
|
+
case 'ZodLazy':
|
|
554
|
+
return 'component[]'
|
|
555
|
+
case 'ZodArray': {
|
|
556
|
+
const inner = describeType((def as { type: z.ZodTypeAny }).type)
|
|
557
|
+
return `${inner}[]`
|
|
558
|
+
}
|
|
559
|
+
case 'ZodObject': {
|
|
560
|
+
const shape = (schema as unknown as z.AnyZodObject).shape
|
|
561
|
+
const fields = Object.entries(shape).map(
|
|
562
|
+
([key, value]) => `${key}${(value as z.ZodTypeAny).isOptional() ? '?' : ''}`
|
|
563
|
+
)
|
|
564
|
+
return `{${fields.join(', ')}}`
|
|
565
|
+
}
|
|
566
|
+
case 'ZodEnum':
|
|
567
|
+
return ((def as { values: readonly string[] }).values ?? []).join('|')
|
|
568
|
+
case 'ZodUnion':
|
|
569
|
+
return ((def as { options: z.ZodTypeAny[] }).options ?? []).map(describeType).join('|')
|
|
570
|
+
case 'ZodLiteral':
|
|
571
|
+
return String((def as { value: unknown }).value)
|
|
572
|
+
case 'ZodString':
|
|
573
|
+
return 'string'
|
|
574
|
+
case 'ZodNumber':
|
|
575
|
+
return 'number'
|
|
576
|
+
case 'ZodBoolean':
|
|
577
|
+
return 'boolean'
|
|
578
|
+
case 'ZodRecord':
|
|
579
|
+
return 'object'
|
|
580
|
+
default:
|
|
581
|
+
return 'value'
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The card vocabulary, written out from the schemas themselves.
|
|
587
|
+
*
|
|
588
|
+
* This is what a language model is given as documentation, so it is generated
|
|
589
|
+
* rather than kept by hand: a description that has drifted from the validator
|
|
590
|
+
* teaches the model to write cards that are then rejected, and the model has no
|
|
591
|
+
* way to tell which of the two was wrong.
|
|
592
|
+
*
|
|
593
|
+
* `style` is left out of every line. It is legal everywhere and interesting
|
|
594
|
+
* almost nowhere — the components carry their own look, and a card that reaches
|
|
595
|
+
* for CSS is usually one that should have picked a different component.
|
|
596
|
+
*/
|
|
597
|
+
export function describeChatCardProfile(): string {
|
|
598
|
+
return CARD_MEMBERS.map((member) => {
|
|
599
|
+
const shape = (member as unknown as z.AnyZodObject).shape as Record<string, z.ZodTypeAny>
|
|
600
|
+
const name = (shape['component']!._def as { value: string }).value
|
|
601
|
+
const fields = Object.entries(shape)
|
|
602
|
+
.filter(([key]) => key !== 'component' && key !== 'style')
|
|
603
|
+
.map(([key, value]) => `${key}${value.isOptional() ? '?' : ''}: ${describeType(value)}`)
|
|
604
|
+
return fields.length ? `${name} — ${fields.join('; ')}` : name
|
|
605
|
+
}).join('\n')
|
|
606
|
+
}
|