@pouchy_ai/companion-sdk 0.41.0 → 0.42.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/CHANGELOG.md +26 -0
- package/README.md +14 -0
- package/dist/a2ui.d.ts +216 -0
- package/dist/a2ui.d.ts.map +1 -0
- package/dist/a2ui.js +622 -0
- package/dist/a2ui.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/a2ui.ts +881 -0
- package/src/index.ts +15 -0
package/src/a2ui.ts
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
// Instant UI ⇄ Google A2UI v0.9 adapter — the PUBLIC, package-shipped home of
|
|
2
|
+
// the projection (round #127; owner-approved 2026-07-25). Integrators import
|
|
3
|
+
// `toA2UI` / `fromA2UI` from `@pouchy_ai/companion-sdk` (or the CDN bundle) and
|
|
4
|
+
// put the adapter in front of any renderer built against the A2UI catalog —
|
|
5
|
+
// no more vendoring the repo source, which was the one DX gap of the A2UI
|
|
6
|
+
// alignment.
|
|
7
|
+
//
|
|
8
|
+
// WHY the projection exists: Pouchy's Instant UI is a NESTED, state-bag schema;
|
|
9
|
+
// Google's A2UI v0.9 (the standard AGenUI implements) is a FLAT component list
|
|
10
|
+
// with ID references, a separate dataModel, and JSON-Pointer bindings. Three
|
|
11
|
+
// structural transforms:
|
|
12
|
+
// (i) nested tree → flat components map + ID refs
|
|
13
|
+
// (ii) state bag + bind/template → dataModel + {path:"/key"} JSON Pointer
|
|
14
|
+
// (iii) Button's embedded action → A2UI event {name, context}, the action
|
|
15
|
+
// carried in context so the host can dispatch it locally (no LLM
|
|
16
|
+
// round-trip) — external A2UI agents just receive the event.
|
|
17
|
+
//
|
|
18
|
+
// Fidelity: concepts A2UI genuinely has no standard for (Progress, Accordion,
|
|
19
|
+
// Chart's area/pie kinds, computed, `when`, Text tone, Slider step, TextField
|
|
20
|
+
// placeholder/maxLength, Select placeholder) ride a per-component `_pouchy`
|
|
21
|
+
// extension (A2UI renderers ignore unknown keys; fromA2UI reconstructs from
|
|
22
|
+
// it). Input widgets otherwise emit their STANDARD A2UI fields — TextField
|
|
23
|
+
// `variant` (longText|shortText), ChoicePicker `variant` (mutuallyExclusive),
|
|
24
|
+
// and a single-placeholder Text template emits a real {path} binding — so an
|
|
25
|
+
// external renderer honours the widget kind, not just a default.
|
|
26
|
+
// `fromA2UI(toA2UI(x))` is a faithful round-trip (pinned by the app-side
|
|
27
|
+
// adapter tests + the admin A2UI conformance probe).
|
|
28
|
+
//
|
|
29
|
+
// Spec conformance: component + field names below are verified against
|
|
30
|
+
// AGenUI's agenui_catalog.json and google/A2UI v0.9 — Text/Button/Row/Column/
|
|
31
|
+
// Card/Tabs/Image(url,description)/Video(url)/AudioPlayer(url)/Slider/
|
|
32
|
+
// CheckBox/ChoicePicker/TextField/DateTimeInput/Divider(axis) are standard;
|
|
33
|
+
// Table(columns,rows), Chart(chartType,data) and Markdown(content) are also
|
|
34
|
+
// standard catalog entries, so they emit the standard shape while `_pouchy`
|
|
35
|
+
// carries Pouchy's richer original for a lossless round-trip. Only Progress +
|
|
36
|
+
// Accordion stay `pouchy/`-namespaced custom components. Centralized in the
|
|
37
|
+
// A2UI map so a spec change is a one-line edit, never a transform rewrite.
|
|
38
|
+
//
|
|
39
|
+
// TYPES: the panel types below are a STRUCTURAL MIRROR of the app's canonical
|
|
40
|
+
// genui schema (src/lib/genui/schema.ts) — the package must stay standalone
|
|
41
|
+
// (no app imports; the npm build's rootDir is this directory), so the type
|
|
42
|
+
// text is duplicated ON PURPOSE, with one deliberate loosening: a Button /
|
|
43
|
+
// Table-row action is an opaque `EmbeddedAction { type }` here (the app's full
|
|
44
|
+
// AgentAction union is app-internal; the adapter only carries actions
|
|
45
|
+
// verbatim, never reads their fields). Drift is compile-enforced: the app-side
|
|
46
|
+
// shim (src/lib/genui/a2ui-adapter.ts) passes its STRICT DynamicInterface
|
|
47
|
+
// straight into `toA2UI` with no cast, so a mirror that goes incompatible or
|
|
48
|
+
// loses a node kind fails `pnpm check`; the node-type CATALOG is additionally
|
|
49
|
+
// pinned to the app's GENUI_NODE_SET by a2ui-sdk-drift.test.ts.
|
|
50
|
+
|
|
51
|
+
import type { RenderInterfacePayload } from './protocol';
|
|
52
|
+
|
|
53
|
+
// ─── Instant UI panel types (mirror — see header) ────────────────────────────
|
|
54
|
+
|
|
55
|
+
export type StateValue = string | number | boolean;
|
|
56
|
+
|
|
57
|
+
/** A prop value that is either a literal or a live reference to a state key. */
|
|
58
|
+
export type ValueRef<T extends StateValue> = T | { $state: string };
|
|
59
|
+
|
|
60
|
+
/** An action embedded in a Button / Table row — carried OPAQUELY through the
|
|
61
|
+
* projection (stuffed into the A2UI event context verbatim, restored on the
|
|
62
|
+
* way back). The app narrows it to its full AgentAction union; hosts treat it
|
|
63
|
+
* as data to hand back, never to interpret. */
|
|
64
|
+
export interface EmbeddedAction {
|
|
65
|
+
type: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type GenuiTextTone = 'default' | 'muted' | 'strong';
|
|
69
|
+
export type GenuiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
70
|
+
export type GenuiProgressVariant = 'default' | 'affection' | 'energy' | 'health' | 'tier';
|
|
71
|
+
|
|
72
|
+
export interface GenuiTextNode {
|
|
73
|
+
type: 'Text';
|
|
74
|
+
text?: string;
|
|
75
|
+
/** Template with `{key}` placeholders resolved against interface state. */
|
|
76
|
+
template?: string;
|
|
77
|
+
tone?: GenuiTextTone;
|
|
78
|
+
}
|
|
79
|
+
export interface GenuiButtonNode {
|
|
80
|
+
type: 'Button';
|
|
81
|
+
label: string;
|
|
82
|
+
variant?: GenuiButtonVariant;
|
|
83
|
+
action: EmbeddedAction | null;
|
|
84
|
+
}
|
|
85
|
+
export interface GenuiProgressNode {
|
|
86
|
+
type: 'Progress';
|
|
87
|
+
value: ValueRef<number>;
|
|
88
|
+
max?: number;
|
|
89
|
+
variant?: GenuiProgressVariant;
|
|
90
|
+
}
|
|
91
|
+
export interface GenuiIconNode {
|
|
92
|
+
type: 'Icon';
|
|
93
|
+
name: string;
|
|
94
|
+
size?: number;
|
|
95
|
+
}
|
|
96
|
+
export interface GenuiSliderNode {
|
|
97
|
+
type: 'Slider';
|
|
98
|
+
bind: string;
|
|
99
|
+
min?: number;
|
|
100
|
+
max?: number;
|
|
101
|
+
step?: number;
|
|
102
|
+
label?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface GenuiSwitchNode {
|
|
105
|
+
type: 'Switch';
|
|
106
|
+
bind: string;
|
|
107
|
+
label?: string;
|
|
108
|
+
}
|
|
109
|
+
export interface GenuiSelectOption {
|
|
110
|
+
value: string;
|
|
111
|
+
label: string;
|
|
112
|
+
}
|
|
113
|
+
export interface GenuiSelectNode {
|
|
114
|
+
type: 'Select';
|
|
115
|
+
bind: string;
|
|
116
|
+
options: GenuiSelectOption[];
|
|
117
|
+
placeholder?: string;
|
|
118
|
+
}
|
|
119
|
+
export interface GenuiTextInputNode {
|
|
120
|
+
type: 'TextInput';
|
|
121
|
+
bind: string;
|
|
122
|
+
label?: string;
|
|
123
|
+
placeholder?: string;
|
|
124
|
+
multiline?: boolean;
|
|
125
|
+
maxLength?: number;
|
|
126
|
+
}
|
|
127
|
+
export type GenuiGroupDirection = 'row' | 'column';
|
|
128
|
+
export interface GenuiGroupNode {
|
|
129
|
+
type: 'Group';
|
|
130
|
+
direction: GenuiGroupDirection;
|
|
131
|
+
gap?: number;
|
|
132
|
+
wrap?: boolean;
|
|
133
|
+
children: GenuiNode[];
|
|
134
|
+
}
|
|
135
|
+
export interface GenuiImageNode {
|
|
136
|
+
type: 'Image';
|
|
137
|
+
src: string;
|
|
138
|
+
alt?: string;
|
|
139
|
+
ratio?: number;
|
|
140
|
+
}
|
|
141
|
+
export interface GenuiVideoNode {
|
|
142
|
+
type: 'Video';
|
|
143
|
+
src: string;
|
|
144
|
+
ratio?: number;
|
|
145
|
+
}
|
|
146
|
+
export interface GenuiAudioNode {
|
|
147
|
+
type: 'AudioPlayer';
|
|
148
|
+
src: string;
|
|
149
|
+
title?: string;
|
|
150
|
+
}
|
|
151
|
+
export interface GenuiMarkdownNode {
|
|
152
|
+
type: 'Markdown';
|
|
153
|
+
text: string;
|
|
154
|
+
}
|
|
155
|
+
export type GenuiChartKind = 'bar' | 'line' | 'area' | 'pie' | 'donut';
|
|
156
|
+
export interface GenuiChartDatum {
|
|
157
|
+
label?: string;
|
|
158
|
+
value: number;
|
|
159
|
+
}
|
|
160
|
+
export interface GenuiChartNode {
|
|
161
|
+
type: 'Chart';
|
|
162
|
+
chart: GenuiChartKind;
|
|
163
|
+
data: GenuiChartDatum[];
|
|
164
|
+
max?: number;
|
|
165
|
+
title?: string;
|
|
166
|
+
}
|
|
167
|
+
export interface GenuiTableNode {
|
|
168
|
+
type: 'Table';
|
|
169
|
+
columns: string[];
|
|
170
|
+
rows: string[][];
|
|
171
|
+
title?: string;
|
|
172
|
+
rowActions?: (EmbeddedAction | null)[];
|
|
173
|
+
}
|
|
174
|
+
export interface GenuiDatePickerNode {
|
|
175
|
+
type: 'DatePicker';
|
|
176
|
+
bind: string;
|
|
177
|
+
label?: string;
|
|
178
|
+
min?: string;
|
|
179
|
+
max?: string;
|
|
180
|
+
}
|
|
181
|
+
export interface GenuiWhen {
|
|
182
|
+
key: string;
|
|
183
|
+
equals?: StateValue;
|
|
184
|
+
truthy?: boolean;
|
|
185
|
+
}
|
|
186
|
+
export interface GenuiTab {
|
|
187
|
+
label: string;
|
|
188
|
+
children: GenuiNode[];
|
|
189
|
+
}
|
|
190
|
+
export interface GenuiTabsNode {
|
|
191
|
+
type: 'Tabs';
|
|
192
|
+
tabs: GenuiTab[];
|
|
193
|
+
bind?: string;
|
|
194
|
+
}
|
|
195
|
+
export interface GenuiAccordionSection {
|
|
196
|
+
label: string;
|
|
197
|
+
children: GenuiNode[];
|
|
198
|
+
open?: boolean;
|
|
199
|
+
}
|
|
200
|
+
export interface GenuiAccordionNode {
|
|
201
|
+
type: 'Accordion';
|
|
202
|
+
sections: GenuiAccordionSection[];
|
|
203
|
+
multi?: boolean;
|
|
204
|
+
}
|
|
205
|
+
export interface GenuiDividerNode {
|
|
206
|
+
type: 'Divider';
|
|
207
|
+
}
|
|
208
|
+
export interface GenuiCardNode {
|
|
209
|
+
type: 'Card';
|
|
210
|
+
title?: string;
|
|
211
|
+
children: GenuiNode[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export type GenuiNode = (
|
|
215
|
+
| GenuiTextNode
|
|
216
|
+
| GenuiButtonNode
|
|
217
|
+
| GenuiProgressNode
|
|
218
|
+
| GenuiIconNode
|
|
219
|
+
| GenuiSliderNode
|
|
220
|
+
| GenuiSwitchNode
|
|
221
|
+
| GenuiSelectNode
|
|
222
|
+
| GenuiTextInputNode
|
|
223
|
+
| GenuiGroupNode
|
|
224
|
+
| GenuiImageNode
|
|
225
|
+
| GenuiChartNode
|
|
226
|
+
| GenuiTableNode
|
|
227
|
+
| GenuiDatePickerNode
|
|
228
|
+
| GenuiTabsNode
|
|
229
|
+
| GenuiAccordionNode
|
|
230
|
+
| GenuiDividerNode
|
|
231
|
+
| GenuiCardNode
|
|
232
|
+
| GenuiVideoNode
|
|
233
|
+
| GenuiAudioNode
|
|
234
|
+
| GenuiMarkdownNode
|
|
235
|
+
) & { when?: GenuiWhen };
|
|
236
|
+
|
|
237
|
+
export interface GenuiComputed {
|
|
238
|
+
op: 'mul' | 'add' | 'sub' | 'div';
|
|
239
|
+
args: (string | number)[];
|
|
240
|
+
round?: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The Instant UI panel — what `companion.ui_action` carries in
|
|
244
|
+
* `payload.interface` (typed strictly here; the event payload declares the
|
|
245
|
+
* loose wire shape). */
|
|
246
|
+
export interface DynamicInterface {
|
|
247
|
+
title?: string;
|
|
248
|
+
nodes: GenuiNode[];
|
|
249
|
+
state?: Record<string, StateValue>;
|
|
250
|
+
computed?: Record<string, GenuiComputed>;
|
|
251
|
+
reportChanges?: boolean;
|
|
252
|
+
allowedActions?: string[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** What `toA2UI` accepts: the strict panel type, or the loose
|
|
256
|
+
* `payload.interface` exactly as `onRender` delivers it (already validated
|
|
257
|
+
* and clamped server-side, so the structural cast inside is safe). */
|
|
258
|
+
export type InstantUIInput = DynamicInterface | RenderInterfacePayload['interface'];
|
|
259
|
+
|
|
260
|
+
/** The Instant UI node-type catalog, as data — drift-pinned against the app's
|
|
261
|
+
* canonical GENUI_NODE_SET so the mirror can never silently lag a new atom. */
|
|
262
|
+
export const INSTANT_UI_NODE_TYPES = [
|
|
263
|
+
'Text',
|
|
264
|
+
'Button',
|
|
265
|
+
'Progress',
|
|
266
|
+
'Icon',
|
|
267
|
+
'Slider',
|
|
268
|
+
'Switch',
|
|
269
|
+
'Select',
|
|
270
|
+
'TextInput',
|
|
271
|
+
'Group',
|
|
272
|
+
'Image',
|
|
273
|
+
'Chart',
|
|
274
|
+
'Table',
|
|
275
|
+
'DatePicker',
|
|
276
|
+
'Tabs',
|
|
277
|
+
'Accordion',
|
|
278
|
+
'Divider',
|
|
279
|
+
'Card',
|
|
280
|
+
'Video',
|
|
281
|
+
'AudioPlayer',
|
|
282
|
+
'Markdown'
|
|
283
|
+
] as const;
|
|
284
|
+
|
|
285
|
+
// ─── A2UI wire types (the subset we emit/accept) ──────────────────────────────
|
|
286
|
+
|
|
287
|
+
/** A2UI binding: a literal, a JSON-Pointer data binding, or a function call. */
|
|
288
|
+
export type A2UIValue = StateValue | { path: string } | { call: string; args?: unknown };
|
|
289
|
+
|
|
290
|
+
export interface A2UIComponent {
|
|
291
|
+
id: string;
|
|
292
|
+
/** A2UI component type, e.g. "Text" / "Button" / "Row" / "pouchy/Progress". */
|
|
293
|
+
component: string;
|
|
294
|
+
/** Standard A2UI property bag (component-specific; see the mapping per type). */
|
|
295
|
+
[key: string]: unknown;
|
|
296
|
+
/** Pouchy-only reconstruction data for concepts A2UI has no standard for.
|
|
297
|
+
* A2UI renderers ignore it; fromA2UI() uses it to rebuild the exact node. */
|
|
298
|
+
_pouchy?: Record<string, unknown>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** An A2UI surface = a flat component map + a root id + the data model. The
|
|
302
|
+
* `pouchy` block carries panel-level extensions A2UI has no slot for. */
|
|
303
|
+
export interface A2UISurface {
|
|
304
|
+
root: string;
|
|
305
|
+
components: Record<string, A2UIComponent>;
|
|
306
|
+
dataModel: Record<string, StateValue>;
|
|
307
|
+
pouchy?: {
|
|
308
|
+
title?: string;
|
|
309
|
+
computed?: Record<string, GenuiComputed>;
|
|
310
|
+
reportChanges?: boolean;
|
|
311
|
+
allowedActions?: string[];
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Centralized so a spec correction never touches the transform logic below.
|
|
316
|
+
const A2UI = {
|
|
317
|
+
rowComponent: 'Row',
|
|
318
|
+
columnComponent: 'Column',
|
|
319
|
+
// Pouchy-custom components — NOT in the A2UI v0.9 catalog, so they're
|
|
320
|
+
// namespaced and registered as custom components for external interop
|
|
321
|
+
// (they carry semantics A2UI's standard catalog lacks). Progress + Accordion
|
|
322
|
+
// have no standard A2UI equivalent; Table/Chart/Markdown DO (Table+Chart are
|
|
323
|
+
// SDK/Playground catalog entries, Markdown is a Playground example), so those
|
|
324
|
+
// use the standard names below and round-trip losslessly via `_pouchy`.
|
|
325
|
+
progressComponent: 'pouchy/Progress',
|
|
326
|
+
accordionComponent: 'pouchy/Accordion',
|
|
327
|
+
// Standard-catalog component names (verified against AGenUI agenui_catalog.json
|
|
328
|
+
// / google/A2UI v0.9: Table columns/rows, Chart chartType+data, Markdown content).
|
|
329
|
+
chartComponent: 'Chart',
|
|
330
|
+
tableComponent: 'Table',
|
|
331
|
+
textComponent: 'Text',
|
|
332
|
+
buttonComponent: 'Button',
|
|
333
|
+
iconComponent: 'Icon',
|
|
334
|
+
imageComponent: 'Image',
|
|
335
|
+
sliderComponent: 'Slider',
|
|
336
|
+
checkboxComponent: 'CheckBox',
|
|
337
|
+
choicePickerComponent: 'ChoicePicker',
|
|
338
|
+
textFieldComponent: 'TextField',
|
|
339
|
+
dateTimeComponent: 'DateTimeInput',
|
|
340
|
+
tabsComponent: 'Tabs',
|
|
341
|
+
dividerComponent: 'Divider',
|
|
342
|
+
cardComponent: 'Card',
|
|
343
|
+
videoComponent: 'Video',
|
|
344
|
+
audioComponent: 'AudioPlayer',
|
|
345
|
+
markdownComponent: 'Markdown',
|
|
346
|
+
// The event name carrying a Pouchy-embedded action.
|
|
347
|
+
pouchyActionEvent: 'pouchyAction'
|
|
348
|
+
} as const;
|
|
349
|
+
|
|
350
|
+
/** JSON Pointer for a top-level dataModel key (keys are flat, no nesting). */
|
|
351
|
+
const ptr = (key: string): { path: string } => ({ path: `/${key}` });
|
|
352
|
+
const isPtr = (v: unknown): v is { path: string } =>
|
|
353
|
+
!!v && typeof v === 'object' && typeof (v as { path?: unknown }).path === 'string';
|
|
354
|
+
/** Key from a top-level JSON Pointer ("/qty" → "qty"). */
|
|
355
|
+
const ptrKey = (p: { path: string }): string => p.path.replace(/^\//, '');
|
|
356
|
+
|
|
357
|
+
/** If a template is exactly ONE whole-value placeholder ("{total}"), return the
|
|
358
|
+
* key; else null. A2UI Text has no inline string interpolation, but its `text`
|
|
359
|
+
* IS a DynamicString that accepts a {path} binding — so a single-placeholder
|
|
360
|
+
* template can be emitted as a real binding an external renderer interpolates.
|
|
361
|
+
* A mixed template ("¥{total} left") has no A2UI form and stays literal. */
|
|
362
|
+
const singlePlaceholderKey = (tmpl: string): string | null => {
|
|
363
|
+
const m = /^\{([\w.-]+)\}$/.exec(tmpl.trim());
|
|
364
|
+
return m ? m[1] : null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// ─── toA2UI: Instant UI panel → A2UI surface ─────────────────────────────────
|
|
368
|
+
|
|
369
|
+
export function toA2UI(input: InstantUIInput): A2UISurface {
|
|
370
|
+
// The loose `payload.interface` shape is server-validated before it ever
|
|
371
|
+
// reaches a host, so the structural narrow here is safe (see InstantUIInput).
|
|
372
|
+
const iface = input as DynamicInterface;
|
|
373
|
+
const components: Record<string, A2UIComponent> = {};
|
|
374
|
+
let counter = 0;
|
|
375
|
+
const nextId = (): string => `c${counter++}`;
|
|
376
|
+
|
|
377
|
+
/** Emit one component (and its descendants), return its id. */
|
|
378
|
+
const emit = (node: GenuiNode): string => {
|
|
379
|
+
const id = nextId();
|
|
380
|
+
const c = buildComponent(id, node, emit);
|
|
381
|
+
if (node.when) c._pouchy = { ...(c._pouchy ?? {}), when: node.when };
|
|
382
|
+
components[id] = c;
|
|
383
|
+
return id;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// The surface root is a Column wrapping the top-level node list (A2UI surfaces
|
|
387
|
+
// have a single root component).
|
|
388
|
+
const childIds = iface.nodes.map(emit);
|
|
389
|
+
const rootId = nextId();
|
|
390
|
+
components[rootId] = {
|
|
391
|
+
id: rootId,
|
|
392
|
+
component: A2UI.columnComponent,
|
|
393
|
+
children: childIds
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const pouchy: A2UISurface['pouchy'] = {};
|
|
397
|
+
if (iface.title) pouchy.title = iface.title;
|
|
398
|
+
if (iface.computed) pouchy.computed = iface.computed;
|
|
399
|
+
if (iface.reportChanges) pouchy.reportChanges = true;
|
|
400
|
+
if (iface.allowedActions) pouchy.allowedActions = iface.allowedActions;
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
root: rootId,
|
|
404
|
+
components,
|
|
405
|
+
dataModel: { ...(iface.state ?? {}) },
|
|
406
|
+
...(Object.keys(pouchy).length > 0 ? { pouchy } : {})
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Encode a Button's embedded action as an A2UI event (host dispatches it
|
|
411
|
+
* locally from context — external agents just receive the event). */
|
|
412
|
+
const encodeAction = (action: EmbeddedAction | null) =>
|
|
413
|
+
action ? { event: { name: A2UI.pouchyActionEvent, context: { action } } } : null;
|
|
414
|
+
|
|
415
|
+
/** Map Pouchy's chart kinds to A2UI's standard `chartType` (line | donut | bar).
|
|
416
|
+
* A2UI has no area/pie, so area→line and pie→donut — the original kind is kept
|
|
417
|
+
* in `_pouchy.chart` so fromA2UI restores it exactly. */
|
|
418
|
+
const A2UI_CHART_TYPE: Record<string, 'line' | 'donut' | 'bar'> = {
|
|
419
|
+
bar: 'bar',
|
|
420
|
+
line: 'line',
|
|
421
|
+
area: 'line',
|
|
422
|
+
pie: 'donut',
|
|
423
|
+
donut: 'donut'
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/** Build A2UI's standard Chart `data` ({series:[{name,data:[{value,label}]}]})
|
|
427
|
+
* from Pouchy's number[] | {label?,value}[] so an external A2UI renderer draws
|
|
428
|
+
* the series even without reading `_pouchy`. */
|
|
429
|
+
function toA2UIChartData(data: unknown): {
|
|
430
|
+
series: Array<{ name: string; data: Array<{ value: number; label?: string }> }>;
|
|
431
|
+
} {
|
|
432
|
+
const arr = Array.isArray(data) ? data : [];
|
|
433
|
+
const points = arr.map((d, i) =>
|
|
434
|
+
typeof d === 'number'
|
|
435
|
+
? { value: d, label: String(i + 1) }
|
|
436
|
+
: {
|
|
437
|
+
value: Number((d as { value?: unknown })?.value ?? 0),
|
|
438
|
+
...((d as { label?: unknown })?.label !== undefined
|
|
439
|
+
? { label: String((d as { label?: unknown }).label) }
|
|
440
|
+
: {})
|
|
441
|
+
}
|
|
442
|
+
);
|
|
443
|
+
return { series: [{ name: 'series', data: points }] };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function buildComponent(
|
|
447
|
+
id: string,
|
|
448
|
+
node: GenuiNode,
|
|
449
|
+
emit: (n: GenuiNode) => string
|
|
450
|
+
): A2UIComponent {
|
|
451
|
+
switch (node.type) {
|
|
452
|
+
case 'Text': {
|
|
453
|
+
// A2UI Text's `text` is a DynamicString (literal | {path} | function).
|
|
454
|
+
// A single-placeholder template ("{total}") becomes a real {path}
|
|
455
|
+
// binding so an external renderer interpolates it live; a mixed template
|
|
456
|
+
// stays literal (no A2UI inline-interp form). Tone + the literal-vs-
|
|
457
|
+
// template distinction ride `_pouchy` so the round-trip is exact.
|
|
458
|
+
const soleKey = node.template ? singlePlaceholderKey(node.template) : null;
|
|
459
|
+
const text: A2UIValue = soleKey ? ptr(soleKey) : (node.template ?? node.text ?? '');
|
|
460
|
+
return {
|
|
461
|
+
id,
|
|
462
|
+
component: A2UI.textComponent,
|
|
463
|
+
text,
|
|
464
|
+
_pouchy: { kind: node.template ? 'template' : 'text', ...(node.tone ? { tone: node.tone } : {}) }
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
case 'Button': {
|
|
468
|
+
// A2UI Button references a child component (the label Text).
|
|
469
|
+
const labelId = emit({ type: 'Text', text: node.label });
|
|
470
|
+
return {
|
|
471
|
+
id,
|
|
472
|
+
component: A2UI.buttonComponent,
|
|
473
|
+
child: labelId,
|
|
474
|
+
action: encodeAction(node.action),
|
|
475
|
+
...(node.variant ? { variant: node.variant } : {})
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
case 'Progress':
|
|
479
|
+
return {
|
|
480
|
+
id,
|
|
481
|
+
component: A2UI.progressComponent,
|
|
482
|
+
value: isStateRefVal(node.value) ? ptr(node.value.$state) : node.value,
|
|
483
|
+
...(node.max !== undefined ? { max: node.max } : {}),
|
|
484
|
+
...(node.variant ? { variant: node.variant } : {})
|
|
485
|
+
};
|
|
486
|
+
case 'Icon':
|
|
487
|
+
return { id, component: A2UI.iconComponent, name: node.name, ...(node.size ? { size: node.size } : {}) };
|
|
488
|
+
case 'Slider':
|
|
489
|
+
return {
|
|
490
|
+
id,
|
|
491
|
+
component: A2UI.sliderComponent,
|
|
492
|
+
value: ptr(node.bind),
|
|
493
|
+
...(node.min !== undefined ? { min: node.min } : {}),
|
|
494
|
+
...(node.max !== undefined ? { max: node.max } : {}),
|
|
495
|
+
...(node.label ? { label: node.label } : {}),
|
|
496
|
+
_pouchy: { ...(node.step !== undefined ? { step: node.step } : {}) }
|
|
497
|
+
};
|
|
498
|
+
case 'Switch':
|
|
499
|
+
return {
|
|
500
|
+
id,
|
|
501
|
+
component: A2UI.checkboxComponent,
|
|
502
|
+
value: ptr(node.bind),
|
|
503
|
+
...(node.label ? { label: node.label } : {}),
|
|
504
|
+
_pouchy: { kind: 'switch' }
|
|
505
|
+
};
|
|
506
|
+
case 'Select':
|
|
507
|
+
// Pouchy Select is single-select → A2UI ChoicePicker variant
|
|
508
|
+
// 'mutuallyExclusive'. placeholder is non-standard → rides `_pouchy`.
|
|
509
|
+
return {
|
|
510
|
+
id,
|
|
511
|
+
component: A2UI.choicePickerComponent,
|
|
512
|
+
value: ptr(node.bind),
|
|
513
|
+
options: node.options,
|
|
514
|
+
variant: 'mutuallyExclusive',
|
|
515
|
+
...(node.placeholder ? { _pouchy: { placeholder: node.placeholder } } : {})
|
|
516
|
+
};
|
|
517
|
+
case 'TextInput':
|
|
518
|
+
// A2UI TextField uses `variant` (longText | shortText | number |
|
|
519
|
+
// obscured) instead of a multiline flag; placeholder + maxLength are
|
|
520
|
+
// non-standard, so they ride `_pouchy` for the round-trip.
|
|
521
|
+
return {
|
|
522
|
+
id,
|
|
523
|
+
component: A2UI.textFieldComponent,
|
|
524
|
+
value: ptr(node.bind),
|
|
525
|
+
variant: node.multiline ? 'longText' : 'shortText',
|
|
526
|
+
...(node.label ? { label: node.label } : {}),
|
|
527
|
+
...(node.placeholder !== undefined || node.maxLength !== undefined
|
|
528
|
+
? {
|
|
529
|
+
_pouchy: {
|
|
530
|
+
...(node.placeholder ? { placeholder: node.placeholder } : {}),
|
|
531
|
+
...(node.maxLength !== undefined ? { maxLength: node.maxLength } : {})
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
: {})
|
|
535
|
+
};
|
|
536
|
+
case 'DatePicker':
|
|
537
|
+
return {
|
|
538
|
+
id,
|
|
539
|
+
component: A2UI.dateTimeComponent,
|
|
540
|
+
value: ptr(node.bind),
|
|
541
|
+
...(node.label ? { label: node.label } : {}),
|
|
542
|
+
...(node.min ? { min: node.min } : {}),
|
|
543
|
+
...(node.max ? { max: node.max } : {})
|
|
544
|
+
};
|
|
545
|
+
case 'Image':
|
|
546
|
+
return {
|
|
547
|
+
id,
|
|
548
|
+
component: A2UI.imageComponent,
|
|
549
|
+
url: node.src,
|
|
550
|
+
...(node.alt ? { description: node.alt } : {}),
|
|
551
|
+
...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {})
|
|
552
|
+
};
|
|
553
|
+
case 'Group': {
|
|
554
|
+
// gap/wrap have no A2UI standard, so they ride `_pouchy` — but only emit
|
|
555
|
+
// the extension when there's something to carry, so plain layout
|
|
556
|
+
// wrappers (and the Column wrappers Tabs/Card synthesize) stay clean
|
|
557
|
+
// instead of shipping an empty `_pouchy: {}`.
|
|
558
|
+
const ext = {
|
|
559
|
+
...(node.gap !== undefined ? { gap: node.gap } : {}),
|
|
560
|
+
...(node.wrap ? { wrap: true } : {})
|
|
561
|
+
};
|
|
562
|
+
return {
|
|
563
|
+
id,
|
|
564
|
+
component: node.direction === 'row' ? A2UI.rowComponent : A2UI.columnComponent,
|
|
565
|
+
children: node.children.map(emit),
|
|
566
|
+
...(Object.keys(ext).length > 0 ? { _pouchy: ext } : {})
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
case 'Tabs':
|
|
570
|
+
return {
|
|
571
|
+
id,
|
|
572
|
+
component: A2UI.tabsComponent,
|
|
573
|
+
// A2UI tab = { title, child }; wrap each tab's node list in a Column.
|
|
574
|
+
tabs: node.tabs.map((t) => ({
|
|
575
|
+
title: t.label,
|
|
576
|
+
child: emit({ type: 'Group', direction: 'column', children: t.children })
|
|
577
|
+
})),
|
|
578
|
+
...(node.bind ? { _pouchy: { bind: node.bind } } : {})
|
|
579
|
+
};
|
|
580
|
+
case 'Accordion':
|
|
581
|
+
return {
|
|
582
|
+
id,
|
|
583
|
+
component: A2UI.accordionComponent,
|
|
584
|
+
_pouchy: {
|
|
585
|
+
sections: node.sections.map((s) => ({
|
|
586
|
+
label: s.label,
|
|
587
|
+
childIds: s.children.map(emit),
|
|
588
|
+
...(s.open ? { open: true } : {})
|
|
589
|
+
})),
|
|
590
|
+
...(node.multi ? { multi: true } : {})
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
case 'Chart':
|
|
594
|
+
// Emit the STANDARD A2UI Chart shape (chartType + data series) so an
|
|
595
|
+
// external renderer draws it, AND carry Pouchy's richer original (area/pie
|
|
596
|
+
// kinds, raw data, max) in `_pouchy` for a lossless round-trip back.
|
|
597
|
+
return {
|
|
598
|
+
id,
|
|
599
|
+
component: A2UI.chartComponent,
|
|
600
|
+
chartType: A2UI_CHART_TYPE[node.chart] ?? 'bar',
|
|
601
|
+
data: toA2UIChartData(node.data),
|
|
602
|
+
_pouchy: {
|
|
603
|
+
chart: node.chart,
|
|
604
|
+
data: node.data,
|
|
605
|
+
...(node.max !== undefined ? { max: node.max } : {}),
|
|
606
|
+
...(node.title ? { title: node.title } : {})
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
case 'Table':
|
|
610
|
+
return {
|
|
611
|
+
id,
|
|
612
|
+
component: A2UI.tableComponent,
|
|
613
|
+
columns: node.columns,
|
|
614
|
+
rows: node.rows,
|
|
615
|
+
...(node.title ? { _pouchy: { title: node.title } } : {})
|
|
616
|
+
};
|
|
617
|
+
case 'Video':
|
|
618
|
+
return {
|
|
619
|
+
id,
|
|
620
|
+
component: A2UI.videoComponent,
|
|
621
|
+
url: node.src,
|
|
622
|
+
...(node.ratio !== undefined ? { _pouchy: { ratio: node.ratio } } : {})
|
|
623
|
+
};
|
|
624
|
+
case 'AudioPlayer':
|
|
625
|
+
return {
|
|
626
|
+
id,
|
|
627
|
+
component: A2UI.audioComponent,
|
|
628
|
+
url: node.src,
|
|
629
|
+
...(node.title ? { _pouchy: { title: node.title } } : {})
|
|
630
|
+
};
|
|
631
|
+
case 'Markdown':
|
|
632
|
+
// A2UI's Markdown field is `content` (not `text`).
|
|
633
|
+
return { id, component: A2UI.markdownComponent, content: node.text };
|
|
634
|
+
case 'Divider':
|
|
635
|
+
// A2UI Divider carries an `axis`; Pouchy only renders horizontal rules.
|
|
636
|
+
return { id, component: A2UI.dividerComponent, axis: 'horizontal' };
|
|
637
|
+
case 'Card':
|
|
638
|
+
// A2UI Card is single-child; wrap the node list in a Column and carry
|
|
639
|
+
// the optional title on the extension (A2UI Card has no title slot).
|
|
640
|
+
return {
|
|
641
|
+
id,
|
|
642
|
+
component: A2UI.cardComponent,
|
|
643
|
+
child: emit({ type: 'Group', direction: 'column', children: node.children }),
|
|
644
|
+
...(node.title ? { _pouchy: { title: node.title } } : {})
|
|
645
|
+
};
|
|
646
|
+
default:
|
|
647
|
+
// Unreachable for validated panels (the exhaustive union above). Kept
|
|
648
|
+
// for LOOSE SDK input (InstantUIInput): an unknown atom degrades to an
|
|
649
|
+
// empty Column instead of crashing the whole projection.
|
|
650
|
+
return { id, component: A2UI.columnComponent, children: [] };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const isStateRefVal = (v: unknown): v is { $state: string } =>
|
|
655
|
+
!!v && typeof v === 'object' && typeof (v as { $state?: unknown }).$state === 'string';
|
|
656
|
+
|
|
657
|
+
// ─── fromA2UI: A2UI surface → Instant UI panel ───────────────────────────────
|
|
658
|
+
|
|
659
|
+
export function fromA2UI(surface: A2UISurface): DynamicInterface {
|
|
660
|
+
const { components, root } = surface;
|
|
661
|
+
|
|
662
|
+
const rebuild = (id: string): GenuiNode | null => {
|
|
663
|
+
const c = components[id];
|
|
664
|
+
if (!c) return null;
|
|
665
|
+
const node = rebuildNode(c, rebuild);
|
|
666
|
+
if (!node) return null;
|
|
667
|
+
const when = c._pouchy?.when;
|
|
668
|
+
if (when) (node as GenuiNode).when = when as GenuiNode['when'];
|
|
669
|
+
return node;
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const rootComp = components[root];
|
|
673
|
+
const nodes: GenuiNode[] = [];
|
|
674
|
+
if (rootComp && Array.isArray(rootComp.children)) {
|
|
675
|
+
for (const childId of rootComp.children as string[]) {
|
|
676
|
+
const n = rebuild(childId);
|
|
677
|
+
if (n) nodes.push(n);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const iface: DynamicInterface = { nodes };
|
|
682
|
+
if (surface.pouchy?.title) iface.title = surface.pouchy.title;
|
|
683
|
+
if (surface.dataModel && Object.keys(surface.dataModel).length > 0)
|
|
684
|
+
iface.state = { ...surface.dataModel };
|
|
685
|
+
if (surface.pouchy?.computed) iface.computed = surface.pouchy.computed;
|
|
686
|
+
if (surface.pouchy?.reportChanges) iface.reportChanges = true;
|
|
687
|
+
if (surface.pouchy?.allowedActions) iface.allowedActions = surface.pouchy.allowedActions;
|
|
688
|
+
return iface;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Decode an A2UI action event back to the embedded action. */
|
|
692
|
+
const decodeAction = (raw: unknown): EmbeddedAction | null => {
|
|
693
|
+
const ev = (raw as { event?: { name?: string; context?: { action?: unknown } } } | null)?.event;
|
|
694
|
+
if (ev?.name === A2UI.pouchyActionEvent && ev.context?.action)
|
|
695
|
+
return ev.context.action as EmbeddedAction;
|
|
696
|
+
return null;
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const bindFrom = (v: unknown): string => (isPtr(v) ? ptrKey(v) : '');
|
|
700
|
+
|
|
701
|
+
function rebuildNode(c: A2UIComponent, rebuild: (id: string) => GenuiNode | null): GenuiNode | null {
|
|
702
|
+
const p = c._pouchy ?? {};
|
|
703
|
+
switch (c.component) {
|
|
704
|
+
case A2UI.textComponent: {
|
|
705
|
+
// `text` may be a literal string OR a {path} binding (a single-
|
|
706
|
+
// placeholder template); reconstruct the "{key}" template from the
|
|
707
|
+
// pointer in that case.
|
|
708
|
+
let node: GenuiNode;
|
|
709
|
+
if (p.kind === 'template') {
|
|
710
|
+
const tmpl = isPtr(c.text) ? `{${ptrKey(c.text)}}` : typeof c.text === 'string' ? c.text : '';
|
|
711
|
+
node = { type: 'Text', template: tmpl };
|
|
712
|
+
} else {
|
|
713
|
+
node = { type: 'Text', text: typeof c.text === 'string' ? c.text : '' };
|
|
714
|
+
}
|
|
715
|
+
if (p.tone) (node as { tone?: unknown }).tone = p.tone;
|
|
716
|
+
return node;
|
|
717
|
+
}
|
|
718
|
+
case A2UI.buttonComponent: {
|
|
719
|
+
// The label is the child Text component A2UI Button references.
|
|
720
|
+
const child = typeof c.child === 'string' ? rebuild(c.child) : null;
|
|
721
|
+
const label = child && child.type === 'Text' ? (child.template ?? child.text ?? '') : '';
|
|
722
|
+
return {
|
|
723
|
+
type: 'Button',
|
|
724
|
+
label,
|
|
725
|
+
...(c.variant ? { variant: c.variant as never } : {}),
|
|
726
|
+
action: decodeAction(c.action)
|
|
727
|
+
} as GenuiNode;
|
|
728
|
+
}
|
|
729
|
+
case A2UI.progressComponent:
|
|
730
|
+
return {
|
|
731
|
+
type: 'Progress',
|
|
732
|
+
value: isPtr(c.value) ? { $state: ptrKey(c.value) } : (c.value as number),
|
|
733
|
+
...(typeof c.max === 'number' ? { max: c.max } : {}),
|
|
734
|
+
...(c.variant ? { variant: c.variant as never } : {})
|
|
735
|
+
} as GenuiNode;
|
|
736
|
+
case A2UI.iconComponent:
|
|
737
|
+
return {
|
|
738
|
+
type: 'Icon',
|
|
739
|
+
name: String(c.name ?? ''),
|
|
740
|
+
...(typeof c.size === 'number' ? { size: c.size } : {})
|
|
741
|
+
};
|
|
742
|
+
case A2UI.sliderComponent:
|
|
743
|
+
return {
|
|
744
|
+
type: 'Slider',
|
|
745
|
+
bind: bindFrom(c.value),
|
|
746
|
+
...(typeof c.min === 'number' ? { min: c.min } : {}),
|
|
747
|
+
...(typeof c.max === 'number' ? { max: c.max } : {}),
|
|
748
|
+
...(typeof p.step === 'number' ? { step: p.step } : {}),
|
|
749
|
+
...(c.label ? { label: c.label as string } : {})
|
|
750
|
+
} as GenuiNode;
|
|
751
|
+
case A2UI.checkboxComponent:
|
|
752
|
+
return {
|
|
753
|
+
type: 'Switch',
|
|
754
|
+
bind: bindFrom(c.value),
|
|
755
|
+
...(c.label ? { label: c.label as string } : {})
|
|
756
|
+
} as GenuiNode;
|
|
757
|
+
case A2UI.choicePickerComponent:
|
|
758
|
+
// placeholder rides `_pouchy`; `variant` (mutuallyExclusive) is the
|
|
759
|
+
// single-select marker Pouchy already implies, so it's dropped here.
|
|
760
|
+
return {
|
|
761
|
+
type: 'Select',
|
|
762
|
+
bind: bindFrom(c.value),
|
|
763
|
+
options: (c.options as never) ?? [],
|
|
764
|
+
...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {})
|
|
765
|
+
} as GenuiNode;
|
|
766
|
+
case A2UI.textFieldComponent:
|
|
767
|
+
// multiline ← variant:'longText'; placeholder + maxLength ← `_pouchy`.
|
|
768
|
+
return {
|
|
769
|
+
type: 'TextInput',
|
|
770
|
+
bind: bindFrom(c.value),
|
|
771
|
+
...(c.label ? { label: c.label as string } : {}),
|
|
772
|
+
...(typeof p.placeholder === 'string' ? { placeholder: p.placeholder } : {}),
|
|
773
|
+
...(c.variant === 'longText' ? { multiline: true } : {}),
|
|
774
|
+
...(typeof p.maxLength === 'number' ? { maxLength: p.maxLength } : {})
|
|
775
|
+
} as GenuiNode;
|
|
776
|
+
case A2UI.dateTimeComponent:
|
|
777
|
+
return {
|
|
778
|
+
type: 'DatePicker',
|
|
779
|
+
bind: bindFrom(c.value),
|
|
780
|
+
...(c.label ? { label: c.label as string } : {}),
|
|
781
|
+
...(c.min ? { min: c.min as string } : {}),
|
|
782
|
+
...(c.max ? { max: c.max as string } : {})
|
|
783
|
+
} as GenuiNode;
|
|
784
|
+
case A2UI.imageComponent:
|
|
785
|
+
return {
|
|
786
|
+
type: 'Image',
|
|
787
|
+
src: String(c.url ?? ''),
|
|
788
|
+
...(c.description ? { alt: c.description as string } : {}),
|
|
789
|
+
...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {})
|
|
790
|
+
} as GenuiNode;
|
|
791
|
+
case A2UI.rowComponent:
|
|
792
|
+
case A2UI.columnComponent: {
|
|
793
|
+
const children = Array.isArray(c.children)
|
|
794
|
+
? (c.children as string[]).map(rebuild).filter((n): n is GenuiNode => n !== null)
|
|
795
|
+
: [];
|
|
796
|
+
return {
|
|
797
|
+
type: 'Group',
|
|
798
|
+
direction: c.component === A2UI.rowComponent ? 'row' : 'column',
|
|
799
|
+
...(typeof p.gap === 'number' ? { gap: p.gap } : {}),
|
|
800
|
+
...(p.wrap ? { wrap: true } : {}),
|
|
801
|
+
children
|
|
802
|
+
} as GenuiNode;
|
|
803
|
+
}
|
|
804
|
+
case A2UI.tabsComponent: {
|
|
805
|
+
const tabs = Array.isArray(c.tabs)
|
|
806
|
+
? (c.tabs as Array<{ title: string; child: string }>).map((t) => {
|
|
807
|
+
const wrapper = rebuild(t.child);
|
|
808
|
+
const children = wrapper && wrapper.type === 'Group' ? wrapper.children : [];
|
|
809
|
+
return { label: t.title, children };
|
|
810
|
+
})
|
|
811
|
+
: [];
|
|
812
|
+
return { type: 'Tabs', tabs, ...(p.bind ? { bind: p.bind as string } : {}) } as GenuiNode;
|
|
813
|
+
}
|
|
814
|
+
case A2UI.accordionComponent: {
|
|
815
|
+
const sections = Array.isArray(p.sections)
|
|
816
|
+
? (p.sections as Array<{ label: string; childIds: string[]; open?: boolean }>).map((s) => ({
|
|
817
|
+
label: s.label,
|
|
818
|
+
children: s.childIds.map(rebuild).filter((n): n is GenuiNode => n !== null),
|
|
819
|
+
...(s.open ? { open: true } : {})
|
|
820
|
+
}))
|
|
821
|
+
: [];
|
|
822
|
+
return { type: 'Accordion', sections, ...(p.multi ? { multi: true } : {}) } as GenuiNode;
|
|
823
|
+
}
|
|
824
|
+
case A2UI.chartComponent: {
|
|
825
|
+
// Prefer Pouchy's lossless `_pouchy` original; when absent (an external
|
|
826
|
+
// A2UI Chart), reconstruct from the standard chartType + data series.
|
|
827
|
+
if (p.chart !== undefined || p.data !== undefined) {
|
|
828
|
+
return {
|
|
829
|
+
type: 'Chart',
|
|
830
|
+
chart: p.chart as never,
|
|
831
|
+
data: (p.data as never) ?? [],
|
|
832
|
+
...(typeof p.max === 'number' ? { max: p.max } : {}),
|
|
833
|
+
...(p.title ? { title: p.title as string } : {})
|
|
834
|
+
} as GenuiNode;
|
|
835
|
+
}
|
|
836
|
+
const series = (
|
|
837
|
+
c.data as { series?: Array<{ data?: Array<{ value?: number; label?: string }> }> }
|
|
838
|
+
)?.series?.[0]?.data;
|
|
839
|
+
const data = Array.isArray(series)
|
|
840
|
+
? series.map((pt) => ({ value: Number(pt?.value ?? 0), ...(pt?.label ? { label: pt.label } : {}) }))
|
|
841
|
+
: [];
|
|
842
|
+
return {
|
|
843
|
+
type: 'Chart',
|
|
844
|
+
chart: (c.chartType as never) ?? ('bar' as never),
|
|
845
|
+
data: data as never
|
|
846
|
+
} as GenuiNode;
|
|
847
|
+
}
|
|
848
|
+
case A2UI.tableComponent:
|
|
849
|
+
return {
|
|
850
|
+
type: 'Table',
|
|
851
|
+
columns: (c.columns as never) ?? [],
|
|
852
|
+
rows: (c.rows as never) ?? [],
|
|
853
|
+
...(p.title ? { title: p.title as string } : {})
|
|
854
|
+
} as GenuiNode;
|
|
855
|
+
case A2UI.videoComponent:
|
|
856
|
+
return {
|
|
857
|
+
type: 'Video',
|
|
858
|
+
src: String(c.url ?? ''),
|
|
859
|
+
...(typeof p.ratio === 'number' ? { ratio: p.ratio } : {})
|
|
860
|
+
} as GenuiNode;
|
|
861
|
+
case A2UI.audioComponent:
|
|
862
|
+
return {
|
|
863
|
+
type: 'AudioPlayer',
|
|
864
|
+
src: String(c.url ?? ''),
|
|
865
|
+
...(p.title ? { title: p.title as string } : {})
|
|
866
|
+
} as GenuiNode;
|
|
867
|
+
case A2UI.markdownComponent:
|
|
868
|
+
// Prefer A2UI's `content`; fall back to `text` when ingesting a surface
|
|
869
|
+
// that used the older field name.
|
|
870
|
+
return { type: 'Markdown', text: String(c.content ?? c.text ?? '') } as GenuiNode;
|
|
871
|
+
case A2UI.dividerComponent:
|
|
872
|
+
return { type: 'Divider' };
|
|
873
|
+
case A2UI.cardComponent: {
|
|
874
|
+
const wrapper = typeof c.child === 'string' ? rebuild(c.child) : null;
|
|
875
|
+
const children = wrapper && wrapper.type === 'Group' ? wrapper.children : [];
|
|
876
|
+
return { type: 'Card', ...(p.title ? { title: p.title as string } : {}), children } as GenuiNode;
|
|
877
|
+
}
|
|
878
|
+
default:
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
}
|