@adia-ai/a2ui-compose 0.0.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.
@@ -0,0 +1,1320 @@
1
+ /**
2
+ * @adia-ai/a2ui-compose — monolithic engine, shared helpers.
3
+ *
4
+ * Pure helpers extracted from engine/generator.js per spec §11 Phase 2:
5
+ * prompt builders, JSON parsers, canvas diff utilities, suggestion
6
+ * generators. All stateless (the only module-level state is the lazy
7
+ * component-catalog cache inside getComponentCatalog).
8
+ */
9
+
10
+ import { listPatterns } from '../../engine/reference.js';
11
+ import { store } from '../../engine/state.js';
12
+ import { checkIntentAlignment } from '../../../retrieval/intent-alignment.js';
13
+ import { composeSubtasks } from '../../../retrieval/decomposer.js';
14
+ import { getWiringCatalog } from '../../../retrieval/wiring-catalog.js';
15
+ import { getComponentData } from '../../../retrieval/pattern-library.js';
16
+
17
+ // Component prop catalog — loaded lazily for prompt injection
18
+ let _componentCatalog = null;
19
+ async function getComponentCatalog() {
20
+ if (_componentCatalog) return _componentCatalog;
21
+ try {
22
+ const IS_NODE = typeof process !== 'undefined' && process.versions?.node;
23
+ if (IS_NODE) {
24
+ const fs = await import(/* @vite-ignore */ 'node:fs/promises');
25
+ const path = await import(/* @vite-ignore */ 'node:path');
26
+ const url = await import(/* @vite-ignore */ 'node:url');
27
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
28
+ const raw = await fs.readFile(path.join(__dirname, '../../../corpus/patterns/_components.json'), 'utf8');
29
+ _componentCatalog = JSON.parse(raw);
30
+ } else {
31
+ // Browser: use fetch (works in all Vite modes)
32
+ const resp = await fetch(new URL('../../../corpus/patterns/_components.json', import.meta.url));
33
+ if (resp.ok) _componentCatalog = await resp.json();
34
+ else _componentCatalog = {};
35
+ }
36
+ } catch {
37
+ _componentCatalog = {};
38
+ }
39
+ return _componentCatalog;
40
+ }
41
+
42
+ export async function buildSystemPrompt(context, patterns, researchContext = '', intent = '', composition = null) {
43
+ const parts = [];
44
+
45
+ // ── Role + output format ──
46
+ parts.push(`You are an A2UI component generator for the AdiaUI design system.
47
+ Output ONLY a valid JSON array of A2UI messages. No markdown fences, no explanation.
48
+
49
+ Output format: [{ "type": "updateComponents", "surfaceId": "default", "components": [...] }]
50
+
51
+ Each component: { "id": "<unique>", "component": "<Type>", "children": ["<childId>", ...], ...props }
52
+ The root must have id "root". Use short, descriptive IDs (e.g., "hdr", "email-field", "submit-btn").`);
53
+
54
+ // ── Card-N content model (critical for quality) ──
55
+ parts.push(`CARD-N CONTENT MODEL (mandatory for any card surface):
56
+ - Card > Header: for title/description. Use Text children with heading variants (h3, h4).
57
+ Card header uses a grid with slots: slot="icon" (leading icon), slot="heading" (title), slot="description" (subtitle), slot="action" (badge/button).
58
+ Card CSS auto-targets native h1-h6 and small/p for heading/description roles.
59
+ - Card > Section: for body content. Always wrap section children in a Column component.
60
+ Section > Column > [content children]
61
+ - Card > Footer: for action buttons. Buttons go here, not in Section.
62
+
63
+ LAYOUT PRIMITIVES:
64
+ - Column (col-ui): vertical stack. Use numeric gap: gap="2", gap="4", gap="6".
65
+ - Row (row-ui): horizontal. Use gap="2", justify="space-between"|"end"|"center".
66
+ - Grid (grid-ui): CSS grid. Use columns="2"|"3"|"4", gap="4".
67
+
68
+ TEXT TYPES (component="Text"):
69
+ - variant="h1" through "h6" for headings (renders native heading tags)
70
+ - variant="body" for paragraphs
71
+ - for small text
72
+ - Use textContent prop for the display text
73
+
74
+ TABS (component="Tabs"):
75
+ - Tabs is a tab BUTTON STRIP only — it holds Tab children, NOT content panels.
76
+ - Tab children have text and value props. They are buttons, not containers.
77
+ - Content panels are SIBLINGS of Tabs, not children. Show/hide based on active tab.
78
+ - WRONG: Tabs > Tab > Card (puts content inside tab buttons)
79
+ - CORRECT: Column > [Tabs, Card, Card, Card] (tabs and panels are siblings)
80
+
81
+ GLOBAL ATTRIBUTES (work on any element — span, p, h1-h6, div, and all *-n components):
82
+ - variant="heading|title|section|label|caption|body|kicker|deck|display|metric|code" — sets typography role
83
+ - weight="thin|light|normal|medium|semibold|bold" — overrides font weight
84
+ - color="text|text-strong|subtle|muted|accent|success|warning|danger|info" — sets text color
85
+ - nomargin — removes block margins
86
+ - truncate — single-line ellipsis
87
+ - grow — flex: 1 (fills remaining space in row/col)
88
+
89
+ PREFER <span> WITH GLOBAL ATTRIBUTES for styled text:
90
+ - CORRECT: { component: "Text", variant: "label", color: "muted", slot: "description", textContent: "Subtitle" }
91
+ - WRONG: wrapping in unnecessary component wrappers
92
+ The renderer maps Text components to <span> elements. Global attributes (variant, weight, color) are set as HTML attributes on the span. This is lighter than using dedicated text components.
93
+
94
+ KEY RULES:
95
+ - IMPORTANT: Generate comprehensive, production-quality UIs. A login form should have 10+ components. A dashboard should have 20+ components. Never return a minimal stub — include all the fields, labels, buttons, and chrome that a real UI would have.
96
+ - Never use raw HTML tags (div, form, input, span). Only A2UI component types.
97
+ - Use Input for text inputs (not "TextField" or "TextInput").
98
+ - Use CheckBox for checkboxes. Use Switch/Toggle for on/off toggles.
99
+ - Use Select for dropdowns (not "ChoicePicker").
100
+ - Use Stat for KPI/metric cards: { component: "Stat", label: "Revenue", value: "$48.2k", change: "+12%", trend: "up" }. Do NOT manually build stat cards with Header+Section+Text — use the Stat component directly.
101
+ - Use Table for tabular data (set data/columns via JS props, not Text children).
102
+ - Use EmptyState for no-content/error states (icon, heading, description, action slot).
103
+ - Use Skeleton for loading placeholders (variant: text|circle|rect).
104
+ - Button: use variant="primary" for main action, "outline" for secondary, "ghost" for tertiary.
105
+ - Button: use text prop for button label.
106
+ - NEVER use Text for clickable/interactive actions. "View logs", "Contact support", "Learn more" = Button (variant="ghost" or "link"), NOT Text. Text is for static content only.
107
+ - For error/status dialogs: use Alert for the error message, Button for recovery actions (retry, view logs, contact support). Don't render action labels as Text captions.
108
+ - For forms: wrap fields in Column with gap="3". Put submit button in a separate Row or Footer.
109
+ - Tab children must ONLY be Tab components. Never put Card, Column, or any content inside Tab.
110
+ - Every Header child MUST have a slot: slot="heading" for title, slot="description" for subtitle, slot="action" for badges/buttons, slot="icon" for leading icons.`);
111
+
112
+ // ── Component prop catalog (ground truth from _components.json) ──
113
+ const catalog = await getComponentCatalog();
114
+ if (catalog && Object.keys(catalog).length > 0) {
115
+ // Pick components most relevant to the intent — show top 20 most used
116
+ const relevantTypes = new Set();
117
+ // Always include core types
118
+ for (const t of ['Column', 'Row', 'Grid', 'Card', 'Header', 'Section', 'Footer',
119
+ 'Text', 'Button', 'Input', 'Badge', 'Icon', 'Avatar', 'Image', 'Divider']) {
120
+ relevantTypes.add(t);
121
+ }
122
+ // Add types from matched patterns
123
+ for (const p of patterns) {
124
+ for (const c of (p.components || [])) relevantTypes.add(c);
125
+ }
126
+
127
+ const catalogLines = [];
128
+ // Aliases live on the canonical catalog entry; surface them inline so the
129
+ // LLM knows it can write "Carousel" / "Slideshow" and the runtime registry
130
+ // will map to the right tag (verified 2026-04-19 — without this, the LLM
131
+ // emits Carousel only to have it ignored, swap-fallback breaks).
132
+ const aliasSuffix = (typeName) => {
133
+ const a = catalog[typeName]?.aliases;
134
+ return Array.isArray(a) && a.length ? ` (also: ${a.join(', ')})` : '';
135
+ };
136
+ for (const typeName of relevantTypes) {
137
+ // Prefer .a2ui.json data (new source of truth), fallback to _components.json
138
+ const a2uiData = getComponentData(typeName);
139
+ if (a2uiData) {
140
+ const props = a2uiData.props ? Object.entries(a2uiData.props).map(([k, v]) => {
141
+ let desc = k;
142
+ if (v.enum) desc += `=(${v.enum.join('|')})`;
143
+ else if (v.type) desc += `:${v.type}`;
144
+ if (v.default !== undefined) desc += `[${v.default}]`;
145
+ return desc;
146
+ }).join(', ') : '';
147
+ const events = a2uiData.events?.length ? ` events: ${a2uiData.events.join(', ')}` : '';
148
+ catalogLines.push(`- ${typeName}${aliasSuffix(typeName)}: ${a2uiData.description || ''} props: ${props || 'none'}${events}`);
149
+ // Include a brief example snippet if the component is relevant. Pick
150
+ // the example whose NAME best matches words in the intent (so a chart
151
+ // prompt gets the chart-legend example, not the first one in the
152
+ // array). Falls back to examples[0] when no name overlap.
153
+ if (a2uiData.examples?.length > 0 && intent) {
154
+ const intentLower = intent.toLowerCase();
155
+ const keywords = a2uiData.keywords || [];
156
+ const isRelevant = keywords.some(kw => intentLower.includes(kw)) ||
157
+ intentLower.includes(typeName.toLowerCase());
158
+ if (isRelevant) {
159
+ const intentTokens = new Set(intentLower.split(/\W+/).filter(w => w.length > 2));
160
+ let bestExample = a2uiData.examples[0];
161
+ let bestScore = 0;
162
+ for (const ex of a2uiData.examples) {
163
+ const nameTokens = String(ex.name || '').toLowerCase().split(/[\s_-]+/);
164
+ const score = nameTokens.filter(t => intentTokens.has(t)).length;
165
+ if (score > bestScore) { bestScore = score; bestExample = ex; }
166
+ }
167
+ if (bestExample?.template) {
168
+ const exSnippet = JSON.stringify(bestExample.template.slice(0, 6));
169
+ catalogLines.push(` example (${bestExample.name || 'default'}): ${exSnippet}`);
170
+ if (bestExample.description) catalogLines.push(` guidance: ${bestExample.description}`);
171
+ }
172
+ }
173
+ }
174
+ } else {
175
+ const comp = catalog[typeName];
176
+ if (!comp) continue;
177
+ const props = comp.props ? Object.entries(comp.props).map(([k, v]) => {
178
+ let desc = k;
179
+ if (v.enum) desc += `=(${v.enum.join('|')})`;
180
+ else if (v.type) desc += `:${v.type}`;
181
+ if (v.default !== undefined) desc += `[${v.default}]`;
182
+ return desc;
183
+ }).join(', ') : '';
184
+ const events = comp.events?.length ? ` events: ${comp.events.join(', ')}` : '';
185
+ catalogLines.push(`- ${typeName}${aliasSuffix(typeName)}: ${comp.description || ''} props: ${props || 'none'}${events}`);
186
+ }
187
+ }
188
+ parts.push(`COMPONENT CATALOG (typed props — use these, not guesses):\n${catalogLines.join('\n')}`);
189
+ } else if (context.components.length > 0) {
190
+ // Fallback to old-style catalog if _components.json not available
191
+ const oldCatalog = context.components.map(c => {
192
+ let props = '';
193
+ if (Array.isArray(c.properties)) {
194
+ props = c.properties.map(p => {
195
+ if (typeof p === 'string') return p;
196
+ if (p && typeof p === 'object') return p.name || p.attribute || JSON.stringify(p);
197
+ return String(p);
198
+ }).join(', ');
199
+ }
200
+ return `- ${c.type}: ${props || '(no props)'}`;
201
+ }).join('\n');
202
+ parts.push(`AVAILABLE COMPONENTS:\n${oldCatalog}`);
203
+ }
204
+
205
+ // ── Anti-patterns ──
206
+ if (context.antiPatterns?.length > 0) {
207
+ const rules = context.antiPatterns.map(ap => `- ${ap.description}`).join('\n');
208
+ parts.push(`AVOID:\n${rules}`);
209
+ }
210
+
211
+ // ── Matched pattern (use as base, adapt content) ──
212
+ if (patterns.length > 0 && patterns[0].template) {
213
+ const best = patterns[0];
214
+ const full = JSON.stringify(best.template, null, 1);
215
+ parts.push(`MATCHED PATTERN: "${best.name}"
216
+ USE THIS AS YOUR BASE STRUCTURE. Adapt the text content and values to match the user's intent, but KEEP the structural patterns:
217
+ - Keep the same component hierarchy and nesting
218
+ - Keep slot attributes (slot="heading", slot="action", slot="description", slot="icon")
219
+ - Keep variant attributes on cards and buttons
220
+ - Adjust textContent, labels, and counts to match the user's request
221
+
222
+ Template:
223
+ ${full}`);
224
+ } else if (patterns.length > 0) {
225
+ // No exact match — show top 3 partial matches so the LLM can compose from parts
226
+ const partials = patterns.slice(0, 3);
227
+ const partialList = partials.map(p => {
228
+ const comps = p.components?.join(', ') || '';
229
+ return `- "${p.name}" (${p.domain}): ${p.description}. Components: [${comps}]`;
230
+ }).join('\n');
231
+ parts.push(`NO EXACT PATTERN MATCH. Here are the closest patterns for reference:
232
+ ${partialList}
233
+
234
+ COMPOSE from scratch using the AVAILABLE COMPONENTS list above. Follow these composition rules:
235
+ 1. Start with a Card as the root container (or Column/Grid for multi-card layouts)
236
+ 2. Card anatomy: Header (slot="heading" + slot="description") → Section > Column → content → Footer (actions)
237
+ 3. Pick the most specific component for each UI element — don't default to Card+Text for everything
238
+ 4. Use the component catalog to find the right type: Table for data, Timeline for sequences, Accordion for collapsible sections, etc.
239
+ 5. Every Header child MUST have a slot attribute: slot="heading", slot="description", slot="action", or slot="icon"
240
+ 6. Use Grid for equal-width columns, Row for content-sized horizontal layouts, Column for vertical stacks`);
241
+ } else {
242
+ parts.push(`NO MATCHING PATTERNS. Generate from scratch using the AVAILABLE COMPONENTS list and CARD-N CONTENT MODEL rules above.
243
+ Compose the UI by picking the most specific component types available — don't just use Card+Text for everything.`);
244
+ }
245
+
246
+ // ── Diverse pattern examples (always included when no exact match) ──
247
+ // Give the LLM structural examples even when keyword search returned nothing.
248
+ // This is the single biggest quality improvement for unmapped intents.
249
+ if (!patterns.length || !patterns[0]?.template) {
250
+ const allPatterns = listPatterns().filter(p => p.template && Array.isArray(p.template) && p.template.length >= 3);
251
+ if (allPatterns.length > 0) {
252
+ // Pick up to 3 diverse examples from different domains
253
+ const seen = new Set();
254
+ const diverse = [];
255
+ for (const p of allPatterns) {
256
+ const d = p.domain || 'general';
257
+ if (!seen.has(d) && diverse.length < 3) {
258
+ seen.add(d);
259
+ diverse.push(p);
260
+ }
261
+ }
262
+ // If we didn't get 3 from unique domains, fill from remaining
263
+ if (diverse.length < 3) {
264
+ for (const p of allPatterns) {
265
+ if (!diverse.includes(p) && diverse.length < 3) diverse.push(p);
266
+ }
267
+ }
268
+ const examples = diverse.map(p => {
269
+ const compact = JSON.stringify(p.template).slice(0, 500);
270
+ return `"${p.name}" (${p.domain || 'general'}): ${compact}${p.template.length > 500 ? '...' : ''}`;
271
+ }).join('\n\n');
272
+ parts.push(`REFERENCE EXAMPLES — diverse patterns from the library to show structural conventions:\n${examples}\n\nUse these as structural guidance, NOT to copy directly. Adapt to the user's intent.`);
273
+ }
274
+ }
275
+
276
+ // ── HTML exemplar (golden output showing AdiaUI markup conventions) ──
277
+ parts.push(`GOLDEN HTML OUTPUT EXAMPLE — this is what correctly rendered AdiaUI markup looks like:
278
+
279
+ <section prose>
280
+ <col-ui gap="12">
281
+ <header align="center" size="lg">
282
+ <col-ui gap="4">
283
+ <span variant="kicker">Category</span>
284
+ <h1 variant="display" nomargin>Page Title</h1>
285
+ <p variant="deck" nomargin>Subtitle description text.</p>
286
+ </col-ui>
287
+ </header>
288
+ <grid-ui columns="3">
289
+ <card-ui>
290
+ <header>
291
+ <icon-ui name="lightning" slot="icon" color="accent"></icon-ui>
292
+ <h3 slot="heading" variant="section" nomargin>Card Title</h3>
293
+ <p slot="description" color="subtle" nomargin>Card subtitle</p>
294
+ </header>
295
+ <section>
296
+ <col-ui gap="2">
297
+ <p variant="body" color="subtle" nomargin>Body text content.</p>
298
+ </col-ui>
299
+ </section>
300
+ <footer>
301
+ <button-ui text="Action" variant="primary" stretch></button-ui>
302
+ </footer>
303
+ </card-ui>
304
+ </grid-ui>
305
+ </col-ui>
306
+ </section>
307
+
308
+ KEY CONVENTIONS from this example:
309
+ - Section content wrapped in col-ui (never bare text in section)
310
+ - Header uses slot="icon", slot="heading", slot="description", slot="action"
311
+ - Typography via variant attribute on semantic elements (h1, h3, p, span)
312
+ - color="subtle" and color="muted" for secondary text
313
+ - nomargin on all typography inside cards
314
+ - Numeric gap values (gap="2", gap="4", gap="12")
315
+ - icon-ui uses Phosphor names with color attribute
316
+ Your A2UI JSON output should produce HTML that follows these conventions.`);
317
+
318
+ // ── Styling & theming guidance ──
319
+ parts.push(`STYLING & THEMING:
320
+ You control visual appearance through component attributes and token overrides — NEVER generate raw CSS.
321
+
322
+ APPEARANCE ATTRIBUTES (set directly on component props):
323
+ - variant: "primary|outline|ghost|danger|link" (buttons), "success|warning|danger|info|accent" (badges/alerts)
324
+ - size: "sm|md|lg" — shifts component sizing + all typography
325
+ - color: "text|text-strong|subtle|muted|accent|success|warning|danger|info" — text/icon color
326
+ - weight: "thin|light|normal|medium|semibold|bold" — font weight
327
+ - density: "compact|spacious" — tightens or loosens spacing on a container
328
+ - radius: "sharp|rounded|round" — border radius presets on a container
329
+ - align: "left|center|right" — text/content alignment
330
+
331
+ THEMING (apply to any container to theme its entire subtree):
332
+ - { component: "Column", "data-theme": "ocean" } — named theme (ocean, forest, sunset, lavender, rose, slate, midnight)
333
+ - Or use the theme-ui provider: { component: "Theme", preset: "ocean", children: [...] }
334
+
335
+ TOKEN OVERRIDES (use style prop for precise control — sparingly):
336
+ - { component: "Card", style: "--card-bg: var(--a-accent-muted)" } — override card background
337
+ - { component: "Button", style: "--button-radius: 999px" } — pill-shaped button
338
+ - The renderer sets el.style.cssText for the style prop.
339
+ - Prefer semantic attributes (variant, size, color) over style overrides.
340
+
341
+ LAYOUT ATTRIBUTES:
342
+ - gap: "1" through "16" — spacing between children (numeric, maps to --a-space-*)
343
+ - columns: "2|3|4|5|6" — grid column count
344
+ - grow: true — flex: 1 (fill available space)
345
+ - block: true — full-width (buttons, inputs)
346
+ - prose: true — content-optimized spacing and typography
347
+ - nomargin: true — remove default margins on typography elements`);
348
+
349
+ // ── Composition rules (when composing from fragment patterns) ──
350
+ if (composition?.fragmentPatterns?.length >= 2) {
351
+ const { fragmentPatterns, decomposition } = composition;
352
+ const layoutComponent = decomposition?.layout?.component || 'Column';
353
+ const layoutChild = decomposition?.layout?.child || 'Card';
354
+ parts.push(`COMPOSITION RULES (multi-section intent detected):
355
+ You are composing ${fragmentPatterns.length} independent sections into a single cohesive UI.
356
+
357
+ LAYOUT: Use ${layoutComponent} as the root container${layoutComponent === 'Tabs' ? ' — Tab buttons are siblings of content panels, NOT parents' : ''}.
358
+ Each section becomes a ${layoutChild} child of the root.
359
+
360
+ COMPOSITION PRINCIPLES:
361
+ 1. UNIQUE IDS: Prefix component IDs per section to avoid collisions (e.g., stats-root, table-hdr, form-submit)
362
+ 2. COHESION: Add a page-level Header above all sections with the overall title
363
+ 3. VISUAL HIERARCHY: Each section should be wrapped in a Card for visual separation (unless using Tabs)
364
+ 4. CONSISTENCY: Use the same sizing, spacing tokens, and theme across all sections
365
+ 5. CROSS-SECTION WIRING: If sections share data (e.g., stats + table both show user data), use shared data sources
366
+
367
+ SECTIONS TO COMPOSE:
368
+ ${fragmentPatterns.map((f, i) => `${i + 1}. ${f.label} — based on "${f.pattern.name}" pattern (${f.pattern.components?.join(', ') || 'various components'})`).join('\n')}`);
369
+ }
370
+
371
+ // ── Web research context (if available) ──
372
+ if (researchContext) {
373
+ parts.push(`DESIGN RESEARCH:\n${researchContext}\nUse these insights to inform your component choices, layout, and content — but output must still be valid A2UI.`);
374
+ }
375
+
376
+ // ── Wiring capabilities — interactive behavior via wireComponents message ──
377
+ // Include when the intent suggests any interactivity, data binding, or user actions
378
+ const wiringKeywords = /\b(form|submit|save|data|api|fetch|navigate|login|checkout|filter|sort|crud|search|delete|edit|update|toggle|select|drag|drop|realtime|stream|websocket|poll|refresh|validate|upload|download|subscribe|notify|toast|modal|dialog|confirm|paginate|infinite.?scroll|load.?more)\b/i;
379
+ if (wiringKeywords.test(intent || '')) {
380
+ const wc = getWiringCatalog();
381
+ parts.push(`WIRING — Declarative Interactivity (5 docking points):
382
+ When the UI needs behavior beyond display, emit a wireComponents message. Each layer is optional — use only what the intent requires.
383
+
384
+ ┌─────────────────────────────────────────────────────────────────┐
385
+ │ DATA — where the surface gets its data │
386
+ │ STATE — controllers that manage component behavior │
387
+ │ ACTIONS — trigger → handler chains (what happens on events) │
388
+ │ PROVIDES — context injection into subtrees │
389
+ │ LIFECYCLE — onMount, onUnmount, onModelChange hooks │
390
+ └─────────────────────────────────────────────────────────────────┘
391
+
392
+ DATA LAYER:
393
+ "data": {
394
+ "params": { "userId": { "from": "route", "key": "id" } },
395
+ "sources": [
396
+ { "id": "users", "uri": "resource://users", "path": "/users", "refresh": "once" },
397
+ { "id": "feed", "uri": "sse://events/{userId}", "path": "/feed", "refresh": "stream" }
398
+ ]
399
+ }
400
+ URI schemes: resource://, api://, mock://, ws://, sse://
401
+ Refresh: once, on-focus, interval:{ms}, stream
402
+ To re-fetch after an action, use refresh-source in onSuccess — not a refresh strategy.
403
+
404
+ STATE LAYER:
405
+ "state": {
406
+ "controllers": [
407
+ { "id": "form1", "type": "FormController", "host": "form-col", "config": { "validateOn": "submit" },
408
+ "bind": { "values": "/form/values", "valid": "/form/valid" } }
409
+ ],
410
+ "model": { "filter": "", "page": 1 }
411
+ }
412
+ Controllers: ${wc.controllers.map(c => c.type).join(', ')} (extensible via registerController)
413
+ "bind" maps controller state ↔ model paths for two-way sync.
414
+
415
+ ACTIONS LAYER — AdiaEvent → handler chains:
416
+ Each action binds a AdiaEvent to a handler. The event is a typed object, not a string.
417
+
418
+ AdiaEvent types: ${wc.adiaEvents.map(e => e.event).join(', ')}
419
+ Each carries a typed payload:
420
+ ${wc.adiaEvents.filter(e => e.payload).map(e => ` ${e.event} → ${e.payload}`).join('\n')}
421
+
422
+ "actions": [
423
+ {
424
+ "event": { "event": "submit", "target": "save-btn" },
425
+ "handler": "submit-resource",
426
+ "config": { "uri": "api://users", "method": "POST" },
427
+ "onSuccess": [{ "handler": "notify", "config": { "message": "Saved!", "variant": "success" } }],
428
+ "onError": [{ "handler": "notify", "config": { "message": "Failed", "variant": "error" } }]
429
+ },
430
+ {
431
+ "event": { "event": "input", "target": "search-input", "debounce": 300 },
432
+ "handler": "update-model",
433
+ "config": { "path": "/filter" }
434
+ }
435
+ ]
436
+
437
+ AdiaEvent shape: { "event": "<type>", "target": "<componentId>", "debounce"?: ms, "throttle"?: ms, "condition"?: { "path", "equals"|"notEquals"|"exists" } }
438
+ "target" scopes to a component id. Omit for surface-level events (mount, unmount).
439
+ "onSuccess" / "onError" are follow-up action arrays — NOT separate entries.
440
+ Handlers: ${wc.handlers.map(h => h.name).join(', ')} (extensible via registerHandler)
441
+
442
+ LIFECYCLE LAYER:
443
+ "lifecycle": {
444
+ "onMount": [{ "handler": "refresh-source", "config": { "sourceId": "users" } }],
445
+ "onModelChange": [{ "path": "/filter", "handler": "refresh-source", "config": { "sourceId": "search" }, "debounce": 300 }]
446
+ }
447
+
448
+ FULL EXAMPLE — Dashboard with data fetch + search + pagination:
449
+ [
450
+ { "type": "updateComponents", "surfaceId": "default", "components": [
451
+ { "id": "root", "component": "Column", "gap": "4", "children": ["toolbar", "table-card"] },
452
+ { "id": "toolbar", "component": "Row", "gap": "2", "children": ["search", "refresh-btn"] },
453
+ { "id": "search", "component": "Input", "type": "search", "placeholder": "Search...", "name": "q", "grow": true },
454
+ { "id": "refresh-btn", "component": "Button", "icon": "arrows-clockwise", "variant": "ghost" },
455
+ { "id": "table-card", "component": "Card", "children": ["table-sec", "table-ftr"] },
456
+ { "id": "table-sec", "component": "Section", "children": ["tbl"] },
457
+ { "id": "tbl", "component": "Table", "sortable": true },
458
+ { "id": "table-ftr", "component": "Footer", "children": ["pager"] },
459
+ { "id": "pager", "component": "Pagination", "total": 100, "pageSize": 10 }
460
+ ]},
461
+ { "type": "wireComponents", "surfaceId": "default",
462
+ "data": {
463
+ "sources": [
464
+ { "id": "items", "uri": "resource://items?q={filter}&page={page}", "path": "/items", "refresh": "once" }
465
+ ]
466
+ },
467
+ "state": {
468
+ "controllers": [
469
+ { "id": "tbl-ctrl", "type": "SelectionController", "host": "tbl", "config": { "mode": "multi" },
470
+ "bind": { "selected": "/selection" } }
471
+ ],
472
+ "model": { "filter": "", "page": 1 }
473
+ },
474
+ "actions": [
475
+ { "event": { "event": "input", "target": "search", "debounce": 300 },
476
+ "handler": "update-model", "config": { "path": "/filter", "value": { "from": "event-target", "key": "value" } } },
477
+ { "event": { "event": "press", "target": "refresh-btn" },
478
+ "handler": "refresh-source", "config": { "sourceId": "items" } },
479
+ { "event": { "event": "change", "target": "pager" },
480
+ "handler": "update-model", "config": { "path": "/page", "value": { "from": "event-detail", "key": "page" } } }
481
+ ],
482
+ "lifecycle": {
483
+ "onMount": [{ "handler": "refresh-source", "config": { "sourceId": "items" } }],
484
+ "onModelChange": [{ "path": "/filter", "handler": "refresh-source", "config": { "sourceId": "items" }, "debounce": 500 }]
485
+ }
486
+ }
487
+ ]
488
+
489
+ WHEN TO INCLUDE wireComponents:
490
+ - Forms → FormController + submit-resource + navigate/notify
491
+ - Tables with data → DataStreamController or data sources + SelectionController
492
+ - Search/filter → update-model with debounce + refresh-source
493
+ - CRUD → submit-resource (POST/PUT/DELETE) + success/error notify
494
+ - Real-time → data source with refresh:"stream" or "interval:{ms}"
495
+ - Toggles/accordions → ToggleController or AccordionController
496
+ - Navigation → navigate handler
497
+
498
+ DO NOT include wireComponents for purely static/display UIs (stat cards, hero sections, profiles without edit).`);
499
+ }
500
+
501
+ return parts.join('\n\n');
502
+ }
503
+
504
+ // ═══════════════════════════════════════════════════════════════════════
505
+ // MULTI-TURN (Feature 3: conversation history as context)
506
+ // ═══════════════════════════════════════════════════════════════════════
507
+
508
+ /**
509
+ * Build chat messages for the LLM, including previous turn context for
510
+ * multi-turn iteration. If this is the first turn, just the user intent.
511
+ * If iterating, includes the previous A2UI output as assistant context.
512
+ *
513
+ * @param {string} intent — Current user intent
514
+ * @param {string} executionId — Execution ID (may have previous turns)
515
+ * @returns {{ role: string, content: string }[]}
516
+ */
517
+ export function buildChatMessages(intent, executionId) {
518
+ const messages = [];
519
+ const prevTurns = store.getAll(executionId);
520
+
521
+ if (prevTurns && prevTurns.length > 0) {
522
+ // Include up to last 2 turns as context
523
+ const recentTurns = prevTurns.slice(-2);
524
+ for (const turn of recentTurns) {
525
+ // Previous user intent
526
+ if (turn.summary) {
527
+ messages.push({ role: 'user', content: turn.summary });
528
+ }
529
+ // Previous A2UI output (compact — just component tree, no full message wrapper)
530
+ if (turn.messages?.length > 0) {
531
+ const components = turn.messages.flatMap(m => m.components || []);
532
+ const compact = components.map(c => {
533
+ const { _surfaceId, ...rest } = c;
534
+ return rest;
535
+ });
536
+ messages.push({
537
+ role: 'assistant',
538
+ content: JSON.stringify(compact),
539
+ });
540
+ }
541
+ }
542
+ }
543
+
544
+ // Current turn
545
+ messages.push({ role: 'user', content: intent });
546
+
547
+ return messages;
548
+ }
549
+
550
+ // ═══════════════════════════════════════════════════════════════════════
551
+ // STREAMING HELPERS (Feature 1)
552
+ // ═══════════════════════════════════════════════════════════════════════
553
+
554
+ /**
555
+ * Try to parse a partial LLM text buffer as A2UI JSON.
556
+ * Handles incomplete JSON by attempting to close open brackets/braces.
557
+ * Returns null if not parseable yet.
558
+ *
559
+ * @param {string} text — Accumulated LLM output so far
560
+ * @returns {object[]|null}
561
+ */
562
+ export function tryParsePartial(text) {
563
+ if (!text) return null;
564
+
565
+ // Strip markdown fences if present
566
+ let json = text.trim();
567
+ const fenceMatch = json.match(/```(?:json)?\s*\n?([\s\S]*)/);
568
+ if (fenceMatch) {
569
+ json = fenceMatch[1].trim();
570
+ }
571
+
572
+ // Must start with [ or { to be JSON
573
+ if (!json.startsWith('[') && !json.startsWith('{')) return null;
574
+
575
+ // Try parsing as-is first (may be complete)
576
+ try {
577
+ const parsed = JSON.parse(json);
578
+ return wrapAsMessages(parsed);
579
+ } catch { /* expected for partial JSON */ }
580
+
581
+ // Try auto-closing: count open brackets and close them
582
+ let openBrackets = 0;
583
+ let openBraces = 0;
584
+ let inString = false;
585
+ let escaped = false;
586
+
587
+ for (const ch of json) {
588
+ if (escaped) { escaped = false; continue; }
589
+ if (ch === '\\') { escaped = true; continue; }
590
+ if (ch === '"') { inString = !inString; continue; }
591
+ if (inString) continue;
592
+ if (ch === '[') openBrackets++;
593
+ if (ch === ']') openBrackets--;
594
+ if (ch === '{') openBraces++;
595
+ if (ch === '}') openBraces--;
596
+ }
597
+
598
+ // Close any open strings, objects, arrays
599
+ let patched = json;
600
+ if (inString) patched += '"';
601
+ for (let i = 0; i < openBraces; i++) patched += '}';
602
+ for (let i = 0; i < openBrackets; i++) patched += ']';
603
+
604
+ try {
605
+ const parsed = JSON.parse(patched);
606
+ return wrapAsMessages(parsed);
607
+ } catch {
608
+ return null;
609
+ }
610
+ }
611
+
612
+ // ═══════════════════════════════════════════════════════════════════════
613
+ // CANVAS DIFF HELPERS — compact summary + merge for multi-turn iteration
614
+ // ═══════════════════════════════════════════════════════════════════════
615
+
616
+ /**
617
+ * Produce a compact summary of existing canvas components.
618
+ * Instead of full JSON (~7K tokens for 100+ components), this yields ~500 tokens.
619
+ * Format:
620
+ * 148 components in 5 sections: Task Summary, Recent Activity, ...
621
+ * root: Column
622
+ * hdr: Header
623
+ * title: Text(heading)
624
+ * ...
625
+ */
626
+ function compactCanvasSummary(components) {
627
+ if (!components || components.length === 0) return '';
628
+
629
+ // Detect sections (Cards with Header children)
630
+ const sections = [];
631
+ const cardIds = new Set();
632
+ for (const c of components) {
633
+ if (c.component === 'Card') {
634
+ cardIds.add(c.id);
635
+ // Try to find the section name from a child Header or Text with heading variant
636
+ const headerChild = components.find(
637
+ h => h.parentId === c.id && (h.component === 'Header' || h.component === 'CardHeader')
638
+ );
639
+ if (headerChild) {
640
+ const titleChild = components.find(
641
+ t => t.parentId === headerChild.id && t.component === 'Text'
642
+ );
643
+ sections.push(titleChild?.text || titleChild?.children || headerChild.title || c.id);
644
+ } else {
645
+ sections.push(c.id);
646
+ }
647
+ }
648
+ }
649
+
650
+ const lines = [];
651
+ const sectionSummary = sections.length > 0
652
+ ? ` in ${sections.length} sections: ${sections.slice(0, 8).join(', ')}${sections.length > 8 ? `, ... +${sections.length - 8} more` : ''}`
653
+ : '';
654
+ lines.push(`${components.length} components${sectionSummary}`);
655
+ lines.push('');
656
+
657
+ // One-line-per-component summary: id: Component(variant) [slot]
658
+ // Layout containers get their structural props so the LLM preserves them
659
+ const LAYOUT_TYPES = new Set(['Grid', 'Row', 'Column', 'Stack']);
660
+ const LAYOUT_PROPS = ['columns', 'gap', 'justify', 'align', 'wrap', 'direction', 'responsive'];
661
+ for (const c of components) {
662
+ let desc = c.component;
663
+ if (c.variant) desc += `(${c.variant})`;
664
+ if (c.slot) desc += ` [slot=${c.slot}]`;
665
+ // Include layout-critical props for containers
666
+ if (LAYOUT_TYPES.has(c.component)) {
667
+ const lp = LAYOUT_PROPS.filter(p => c[p] != null).map(p => `${p}=${c[p]}`);
668
+ if (lp.length) desc += ` {${lp.join(', ')}}`;
669
+ }
670
+ if (c.text) desc += ` "${String(c.text).slice(0, 30)}"`;
671
+ else if (c.label) desc += ` "${String(c.label).slice(0, 30)}"`;
672
+ else if (c.title) desc += ` "${String(c.title).slice(0, 30)}"`;
673
+ if (c.parentId) desc += ` ^${c.parentId}`;
674
+ lines.push(` ${c.id}: ${desc}`);
675
+ }
676
+ return lines.join('\n');
677
+ }
678
+
679
+ /**
680
+ * Merge an LLM-produced diff response with the prior canvas.
681
+ *
682
+ * The diff response contains:
683
+ * - New components (id not in prior): added to result
684
+ * - Modified components ({ id: 'existing', ...changed props }): merged with existing
685
+ * - Deleted components ({ id: 'existing', _delete: true }): removed
686
+ * - Unchanged components: carried forward from prior automatically
687
+ *
688
+ * @param {Array} priorComponents — full component array from last turn
689
+ * @param {Array} diffComponents — sparse diff array from LLM
690
+ * @returns {Array} merged full component array
691
+ */
692
+ export function mergeCanvasDiff(priorComponents, diffComponents) {
693
+ if (!diffComponents || diffComponents.length === 0) return priorComponents;
694
+ if (!priorComponents || priorComponents.length === 0) return diffComponents;
695
+
696
+ // ── Detect full-replacement response ──
697
+ // On iteration turns the LLM is instructed to return DIFF OBJECTS referring
698
+ // to the prior canvas's ids. If the returned array shares almost no ids
699
+ // with the prior canvas AND starts from 'root', the LLM hallucinated a new
700
+ // UI instead of iterating — not a legitimate regeneration.
701
+ //
702
+ // Old behavior: silently accept the replacement (threshold <20% id match).
703
+ // This caused iteration turns to produce entirely unrelated canvases (see
704
+ // kanban "add draggable traits" case, 2026-04-19).
705
+ // New behavior: on iteration, a low-match-ratio replacement is a failure,
706
+ // not a feature. PRESERVE the prior canvas and warn — the user is better
707
+ // off with the unchanged canvas than with a hallucinated one. The ≥60%
708
+ // band still accepts valid whole-canvas rewrites (e.g. major restructures
709
+ // that re-id most components intentionally).
710
+ const priorIds = new Set(priorComponents.map(c => c.id));
711
+ const matchCount = diffComponents.filter(c => priorIds.has(c.id)).length;
712
+ const matchRatio = matchCount / Math.max(diffComponents.length, 1);
713
+ const hasRoot = diffComponents.some(c => c.id === 'root');
714
+
715
+ if (hasRoot && diffComponents.length >= 5 && matchRatio < 0.2) {
716
+ console.warn(`[mergeCanvasDiff] Hallucinated replacement rejected: ${diffComponents.length} new components, only ${matchCount} matching ids (${Math.round(matchRatio * 100)}%). Preserving prior canvas — LLM ignored iteration instructions.`);
717
+ return priorComponents;
718
+ }
719
+ if (hasRoot && diffComponents.length >= 5 && matchRatio < 0.6) {
720
+ // Middle band — could be a valid major rewrite, could be a partial
721
+ // hallucination. Accept but flag. If this turns out to be wrong too,
722
+ // tighten to return priorComponents here as well.
723
+ console.warn(`[mergeCanvasDiff] Low-match rewrite accepted: ${matchCount}/${diffComponents.length} ids match (${Math.round(matchRatio * 100)}%). Treating as a deliberate restructure.`);
724
+ return diffComponents;
725
+ }
726
+
727
+ const priorMap = new Map(priorComponents.map(c => [c.id, c]));
728
+ const deletedIds = new Set();
729
+ const modifiedIds = new Set();
730
+ const newComponents = [];
731
+
732
+ for (const dc of diffComponents) {
733
+ if (!dc || !dc.id) continue;
734
+
735
+ if (dc._delete) {
736
+ // Deletion marker
737
+ deletedIds.add(dc.id);
738
+ continue;
739
+ }
740
+
741
+ if (priorMap.has(dc.id)) {
742
+ // Modification: merge props onto existing component
743
+ const existing = priorMap.get(dc.id);
744
+ // GUARD: Prevent component type changes on layout containers —
745
+ // changing Grid→Column collapses multi-column layouts
746
+ const LAYOUT_CONTAINERS = new Set(['Grid', 'Row', 'Column', 'Stack']);
747
+ if (dc.component && dc.component !== existing.component &&
748
+ (LAYOUT_CONTAINERS.has(existing.component) || LAYOUT_CONTAINERS.has(dc.component))) {
749
+ console.warn(`[mergeCanvasDiff] Blocked layout type change: ${existing.component}→${dc.component} on id="${dc.id}". Preserving original type.`);
750
+ delete dc.component;
751
+ }
752
+ const merged = { ...existing, ...dc };
753
+ // Remove internal _delete if somehow present
754
+ delete merged._delete;
755
+ priorMap.set(dc.id, merged);
756
+ modifiedIds.add(dc.id);
757
+ } else {
758
+ // New component
759
+ newComponents.push(dc);
760
+ }
761
+ }
762
+
763
+ // Build result: prior components (minus deleted) + new components
764
+ const result = [];
765
+ for (const c of priorComponents) {
766
+ if (deletedIds.has(c.id)) continue;
767
+ result.push(priorMap.get(c.id));
768
+ }
769
+
770
+ // Insert new components intelligently: after their parentId if specified, else at end
771
+ for (const nc of newComponents) {
772
+ if (nc.parentId) {
773
+ const parentIdx = result.findIndex(c => c.id === nc.parentId);
774
+ if (parentIdx !== -1) {
775
+ // Find the last sibling under the same parent to insert after
776
+ let insertIdx = parentIdx + 1;
777
+ while (insertIdx < result.length && result[insertIdx].parentId === nc.parentId) {
778
+ insertIdx++;
779
+ }
780
+ result.splice(insertIdx, 0, nc);
781
+ continue;
782
+ }
783
+ }
784
+ result.push(nc);
785
+ }
786
+
787
+ return result;
788
+ }
789
+
790
+ /**
791
+ * Build the diff-mode iteration prompt shared by all hasPriorCanvas branches.
792
+ * @param {string} intent — user's iteration request
793
+ * @param {Array} priorComponents — full prior canvas components
794
+ * @param {object} [opts]
795
+ * @param {string|null} [opts.originalIntent] — original design brief for drift anchoring
796
+ * @returns {string} prompt text for the canvas diff
797
+ */
798
+ export function buildCanvasDiffPrompt(intent, priorComponents, { originalIntent = null } = {}) {
799
+ // Design intent anchor — prevents drift from the original brief
800
+ const intentAnchor = originalIntent && originalIntent !== intent
801
+ ? `\nORIGINAL DESIGN BRIEF (the user's first request — all changes must stay aligned with this): "${originalIntent}"\nThe current modification ("${intent}") should REFINE the original design, not replace its purpose.\n`
802
+ : '';
803
+
804
+ // Full-JSON path for most canvases — the LLM gets the actual component
805
+ // shape and can faithfully modify it. Compact-text summaries lose nesting
806
+ // and props, which sends the LLM into "regenerate from memory" mode and
807
+ // produces whole-new canvases instead of iterations (verified 2026-04-19).
808
+ // The threshold is conservative — 300 components at ~40-60 bytes each is
809
+ // ~15-20KB, well within 32k max_tokens output budget and 120k+ input.
810
+ if (priorComponents.length < 300) {
811
+ return `${intentAnchor}CURRENT CANVAS STATE (this is what the user sees right now — MODIFY it):
812
+ ${JSON.stringify(priorComponents, null, 2)}
813
+
814
+ The user is ITERATING on their existing canvas shown above. They want to MODIFY it, not replace it.
815
+
816
+ Instructions:
817
+ - Preserve ALL existing components unless explicitly asked to remove them
818
+ - Add new components where they logically belong
819
+ - Change text content, labels, icons to match the new intent
820
+ - Preserve slot attributes and Card anatomy rules
821
+ - CRITICAL: Preserve layout container types — do NOT change Grid to Column or Row to Column. If the canvas has a Grid with columns="3" for a card collection, keep it as Grid. Only change layout types when the user explicitly requests a layout change.
822
+ - CONTENT CARRY-OVER on component swaps: when the user asks to upgrade a component to a richer variant (e.g. Image → Carousel, Text → Stat, Toggle → ToggleGroup, Input → Select), the original's content props (src, alt, textContent, text, value, label, name) MUST be carried into the equivalent prop on the new component. The original src becomes the first slide of a Carousel; the original textContent becomes the first item; the original value becomes the default. NEVER emit a swapped component with placeholder/lorem content when real content was present in the original.
823
+ - You MUST return the COMPLETE component array including all existing components plus your modifications
824
+ - Generate at least ${priorComponents.length} components (the existing ones plus any additions)
825
+ - SCOPE GUARD: Only add sections/components that the user explicitly requested. Do NOT speculatively add features, panels, or sections not mentioned in the intent. If adding more than 3 new top-level sections, reconsider whether the user actually asked for them.
826
+ - Output ONLY the JSON array, no explanation`;
827
+ }
828
+
829
+ // For large canvases, use compact diff format to save tokens
830
+ const summary = compactCanvasSummary(priorComponents);
831
+ return `CURRENT CANVAS SUMMARY (${priorComponents.length} components — do NOT regenerate unchanged ones):
832
+ ${summary}
833
+ ${intentAnchor}
834
+ The user wants to MODIFY their existing canvas. Return ONLY the components you need to ADD, MODIFY, or DELETE:
835
+ CRITICAL PRESERVATION RULES:
836
+ - NEVER change layout container types (Grid→Column, Row→Column, Grid→Row) unless the user explicitly asks to change the layout structure.
837
+ - If a Grid has columns="3", keep it as Grid with columns="3". Adding a toggle/filter/button to a toolbar does NOT change the content layout below.
838
+ - Preserve parent-child relationships — adding a control to a toolbar row doesn't affect sibling containers.
839
+ - CONTENT CARRY-OVER on component swaps: when upgrading a component to a richer variant (Image→Carousel, Text→Stat, Toggle→ToggleGroup, Input→Select), the original's content props (src, alt, textContent, text, value, label, name) MUST flow into the equivalent prop on the new component (or become its first item, where applicable). NEVER emit a swapped component with placeholder content when real content was present.
840
+
841
+ DIFF FORMAT:
842
+ - To ADD a new component: include the full component JSON object (with a new unique id)
843
+ - To MODIFY an existing component: { "id": "existing-id", ...only changed props } (e.g. { "id": "title", "text": "New Title" })
844
+ - To DELETE a component: { "id": "existing-id", "_delete": true }
845
+ - UNCHANGED components: do NOT include them — they are preserved automatically
846
+
847
+ CRITICAL: Do NOT change a component's "component" type in modifications (e.g. Grid→Column). Container types (Grid, Row, Column) define layout structure — changing them collapses layouts. To restructure, DELETE the old container and ADD a new one with the correct type and reparent its children.
848
+
849
+ Return a JSON array of ONLY the diff objects. Keep it minimal — typically 2-15 objects for an iteration.
850
+ Output ONLY the JSON array, no explanation.`;
851
+ }
852
+
853
+ /**
854
+ * Normalize parsed JSON into A2UI message array.
855
+ */
856
+ function wrapAsMessages(parsed) {
857
+ if (Array.isArray(parsed)) {
858
+ // Array of messages (may include updateComponents + wireComponents)
859
+ const validTypes = new Set(['updateComponents', 'wireComponents', 'createSurface', 'updateDataModel', 'deleteSurface']);
860
+ if (parsed.length > 0 && validTypes.has(parsed[0].type)) return parsed;
861
+ // Bare components array
862
+ if (parsed.length > 0 && parsed[0].id && parsed[0].component) {
863
+ return [{ type: 'updateComponents', surfaceId: 'default', components: parsed }];
864
+ }
865
+ return parsed;
866
+ }
867
+ if (parsed && typeof parsed === 'object' && parsed.type === 'updateComponents') return [parsed];
868
+ if (parsed && typeof parsed === 'object' && parsed.type === 'wireComponents') return [parsed];
869
+ if (parsed && typeof parsed === 'object' && parsed.id && parsed.component) {
870
+ return [{ type: 'updateComponents', surfaceId: 'default', components: [parsed] }];
871
+ }
872
+ return null;
873
+ }
874
+
875
+ // ═══════════════════════════════════════════════════════════════════════
876
+ // EXISTING HELPERS
877
+ // ═══════════════════════════════════════════════════════════════════════
878
+
879
+ /**
880
+ * Parse raw LLM output into A2UI messages.
881
+ * Multi-strategy JSON extraction with repair for common LLM output issues.
882
+ *
883
+ * Strategies tried in order:
884
+ * 1. Direct parse (clean JSON)
885
+ * 2. Markdown code block extraction (```json ... ```)
886
+ * 3. Outermost JSON extraction (find first [ or { to last ] or })
887
+ * 4. Trailing comma / comment cleanup + re-parse
888
+ * 5. Auto-close brackets (reuse tryParsePartial logic)
889
+ *
890
+ * @param {string} content — Raw LLM response text
891
+ * @param {object} [ctx] — optional debug context forwarded to fallbackMessage
892
+ * @returns {object[]} — Array of A2UI messages
893
+ */
894
+ /**
895
+ * Returns true if the given A2UI message array is a generation-failure
896
+ * fallback surface (Alert + Try Again + debug payload). Stamped at fallback
897
+ * construction via `_fallback: true` on the updateComponents message, so this
898
+ * is a deterministic O(1) check — no fingerprinting heuristics required.
899
+ *
900
+ * Use this in validators, scorers, and downstream consumers that need to
901
+ * distinguish a successful generation from a structurally-valid error surface.
902
+ */
903
+ export function isFallbackResult(messages) {
904
+ if (!Array.isArray(messages) || messages.length === 0) return false;
905
+ return messages.some(m => m && m._fallback === true);
906
+ }
907
+
908
+ export function parseA2UIResponse(content, ctx) {
909
+ // Truncation short-circuit: if the LLM stopped because it hit max_tokens,
910
+ // any remaining JSON is missing tail components. Don't try to auto-close —
911
+ // surface the truncation explicitly so the validator and UI both know.
912
+ if (ctx?.stopReason && ctx.stopReason !== 'end' && ctx.stopReason !== 'end_turn' && ctx.stopReason !== 'stop') {
913
+ return [...fallbackMessage(`LLM response truncated (stop reason: ${ctx.stopReason}). Try a simpler intent or a model with larger output budget.`, { ...ctx, rawResponse: content })];
914
+ }
915
+ if (!content || typeof content !== 'string') {
916
+ return [...fallbackMessage('Empty LLM response', ctx)];
917
+ }
918
+
919
+ const trimmed = content.trim();
920
+
921
+ // Strategy 1: Direct parse (cleanest case)
922
+ const direct = _tryParseAndWrap(trimmed);
923
+ if (direct) return direct;
924
+
925
+ // Strategy 2: Extract from markdown code fences (try all code blocks, prefer ```json)
926
+ const fenceBlocks = [...trimmed.matchAll(/```(?:json)?\s*\n?([\s\S]*?)```/g)];
927
+ for (const match of fenceBlocks) {
928
+ const block = match[1].trim();
929
+ const result = _tryParseAndWrap(block);
930
+ if (result) return result;
931
+ }
932
+
933
+ // Strategy 3: Find outermost JSON — locate first [ or { and last ] or }
934
+ const extracted = _extractOutermostJSON(trimmed);
935
+ if (extracted) {
936
+ const result = _tryParseAndWrap(extracted);
937
+ if (result) return result;
938
+ }
939
+
940
+ // Strategy 4: Cleanup trailing commas, single-line comments, then re-parse
941
+ const candidates = [trimmed, ...(fenceBlocks.map(m => m[1].trim())), extracted].filter(Boolean);
942
+ for (const candidate of candidates) {
943
+ const cleaned = _cleanupJSON(candidate);
944
+ if (cleaned !== candidate) {
945
+ const result = _tryParseAndWrap(cleaned);
946
+ if (result) return result;
947
+ }
948
+ }
949
+
950
+ // Strategy 5: Auto-close brackets (LLM truncated mid-output)
951
+ for (const candidate of candidates) {
952
+ const cleaned = _cleanupJSON(candidate);
953
+ const closed = _autoCloseBrackets(cleaned);
954
+ if (closed !== cleaned) {
955
+ const result = _tryParseAndWrap(closed);
956
+ if (result) return result;
957
+ }
958
+ }
959
+
960
+ // All strategies failed — log raw content for debugging
961
+ _logParseFailure(content);
962
+ return [...fallbackMessage('Failed to parse LLM response as JSON', { ...ctx, rawResponse: content })];
963
+ }
964
+
965
+ /**
966
+ * Try JSON.parse + wrapAsMessages. Returns result or null.
967
+ */
968
+ function _tryParseAndWrap(text) {
969
+ if (!text) return null;
970
+ try {
971
+ const parsed = JSON.parse(text);
972
+ return wrapAsMessages(parsed) || null;
973
+ } catch {
974
+ return null;
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Extract outermost JSON structure from text with surrounding prose.
980
+ * Finds the first [ or { and the matching last ] or }.
981
+ */
982
+ function _extractOutermostJSON(text) {
983
+ // Find first JSON-start character
984
+ const arrStart = text.indexOf('[');
985
+ const objStart = text.indexOf('{');
986
+ if (arrStart === -1 && objStart === -1) return null;
987
+
988
+ let startIdx;
989
+ if (arrStart === -1) startIdx = objStart;
990
+ else if (objStart === -1) startIdx = arrStart;
991
+ else startIdx = Math.min(arrStart, objStart);
992
+
993
+ // Walk forward tracking nesting for both bracket types
994
+ let brackets = 0;
995
+ let braces = 0;
996
+ let inString = false;
997
+ let escaped = false;
998
+ let endIdx = -1;
999
+
1000
+ for (let i = startIdx; i < text.length; i++) {
1001
+ const ch = text[i];
1002
+ if (escaped) { escaped = false; continue; }
1003
+ if (ch === '\\' && inString) { escaped = true; continue; }
1004
+ if (ch === '"') { inString = !inString; continue; }
1005
+ if (inString) continue;
1006
+ if (ch === '[') brackets++;
1007
+ else if (ch === ']') brackets--;
1008
+ else if (ch === '{') braces++;
1009
+ else if (ch === '}') braces--;
1010
+
1011
+ // Balanced when we return to zero for the type we started with
1012
+ if (brackets === 0 && braces === 0) {
1013
+ endIdx = i;
1014
+ break;
1015
+ }
1016
+ }
1017
+
1018
+ if (endIdx === -1) {
1019
+ // No balanced close found — return from start to end for auto-closing
1020
+ return text.slice(startIdx);
1021
+ }
1022
+ return text.slice(startIdx, endIdx + 1);
1023
+ }
1024
+
1025
+ /**
1026
+ * Clean up common LLM JSON issues: trailing commas, single-line comments.
1027
+ */
1028
+ function _cleanupJSON(text) {
1029
+ if (!text) return text;
1030
+ let result = text;
1031
+ // Remove single-line comments (// ...) outside strings
1032
+ result = result.replace(/(?<="[^"]*".*?)\/\/[^\n]*/g, '');
1033
+ // Simpler: remove lines that are just comments
1034
+ result = result.replace(/^\s*\/\/.*$/gm, '');
1035
+ // Remove trailing commas before } or ]
1036
+ result = result.replace(/,\s*([}\]])/g, '$1');
1037
+ return result;
1038
+ }
1039
+
1040
+ /**
1041
+ * Auto-close unclosed brackets/braces (LLM truncated mid-output).
1042
+ */
1043
+ function _autoCloseBrackets(text) {
1044
+ if (!text) return text;
1045
+ // Must start with [ or {
1046
+ const trimmed = text.trim();
1047
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return text;
1048
+
1049
+ let openBrackets = 0;
1050
+ let openBraces = 0;
1051
+ let inString = false;
1052
+ let escaped = false;
1053
+
1054
+ for (const ch of trimmed) {
1055
+ if (escaped) { escaped = false; continue; }
1056
+ if (ch === '\\') { escaped = true; continue; }
1057
+ if (ch === '"') { inString = !inString; continue; }
1058
+ if (inString) continue;
1059
+ if (ch === '[') openBrackets++;
1060
+ if (ch === ']') openBrackets--;
1061
+ if (ch === '{') openBraces++;
1062
+ if (ch === '}') openBraces--;
1063
+ }
1064
+
1065
+ if (openBrackets === 0 && openBraces === 0) return text;
1066
+
1067
+ let patched = trimmed;
1068
+ if (inString) patched += '"';
1069
+ for (let i = 0; i < openBraces; i++) patched += '}';
1070
+ for (let i = 0; i < openBrackets; i++) patched += ']';
1071
+ return patched;
1072
+ }
1073
+
1074
+ /**
1075
+ * Log raw LLM content on parse failure for debugging.
1076
+ * Only logs in Node.js environment (not browser).
1077
+ */
1078
+ function _logParseFailure(content) {
1079
+ try {
1080
+ const preview = content.length > 500 ? content.slice(0, 500) + '...' : content;
1081
+ console.warn(`[A2UI] All JSON parse strategies failed (${content.length} chars). Preview:\n${preview}`);
1082
+ } catch { /* ignore */ }
1083
+ }
1084
+
1085
+ /**
1086
+ * Create a fallback updateComponents message with an error hint.
1087
+ * When debug metadata is available, renders execution context, mode, intent,
1088
+ * a collapsible raw-response preview, and a copy-debug-info button.
1089
+ *
1090
+ * @param {string} reason
1091
+ * @param {object} [opts]
1092
+ * @param {string} [opts.executionId] — Pipeline execution ID
1093
+ * @param {string} [opts.intent] — The user intent that triggered generation
1094
+ * @param {string} [opts.mode] — Generation mode (instant/pro/thinking/stream)
1095
+ * @param {string} [opts.rawResponse] — Raw LLM output (truncated for display)
1096
+ * @returns {object[]} Array of A2UI messages (updateComponents + wireComponents)
1097
+ */
1098
+ function fallbackMessage(reason, opts = {}) {
1099
+ const { executionId, intent, mode, rawResponse } = opts;
1100
+ const children = ['sec', 'ftr'];
1101
+ const components = [
1102
+ { id: 'sec', component: 'Section', children: ['alert'] },
1103
+ { id: 'alert', component: 'Alert', variant: 'error', title: 'Generation Error', description: reason },
1104
+ ];
1105
+
1106
+ // ── Debug metadata: execution ID, mode, intent ──
1107
+ const hasMetadata = executionId || mode || intent;
1108
+ if (hasMetadata) {
1109
+ children.splice(1, 0, 'meta-sec');
1110
+ const metaChildren = [];
1111
+ if (executionId) {
1112
+ metaChildren.push('meta-exec');
1113
+ components.push({ id: 'meta-exec', component: 'Text', variant: 'caption', color: 'muted', textContent: `Execution: ${executionId}` });
1114
+ }
1115
+ if (mode) {
1116
+ metaChildren.push('meta-mode');
1117
+ components.push({ id: 'meta-mode', component: 'Badge', text: mode, variant: 'subtle' });
1118
+ }
1119
+ if (intent) {
1120
+ const truncIntent = intent.length > 120 ? intent.slice(0, 120) + '…' : intent;
1121
+ metaChildren.push('meta-intent');
1122
+ components.push({ id: 'meta-intent', component: 'Text', variant: 'caption', color: 'subtle', textContent: `Prompt: "${truncIntent}"` });
1123
+ }
1124
+ components.push({ id: 'meta-sec', component: 'Section', children: metaChildren });
1125
+ }
1126
+
1127
+ // ── Collapsible raw response preview ──
1128
+ if (rawResponse && typeof rawResponse === 'string') {
1129
+ children.splice(children.indexOf('ftr'), 0, 'raw-acc');
1130
+ const preview = rawResponse.length > 2000 ? rawResponse.slice(0, 2000) + `\n… [truncated, ${rawResponse.length} chars total]` : rawResponse;
1131
+ components.push(
1132
+ { id: 'raw-acc', component: 'Accordion', children: ['raw-item'] },
1133
+ { id: 'raw-item', component: 'AccordionItem', text: 'Raw LLM Response', children: ['raw-code'] },
1134
+ { id: 'raw-code', component: 'Code', language: 'text', textContent: preview },
1135
+ );
1136
+ }
1137
+
1138
+ // ── Footer: Try Again + Copy Debug Info ──
1139
+ const footerChildren = ['retry'];
1140
+ const debugPayload = JSON.stringify({
1141
+ error: reason,
1142
+ ...(executionId && { executionId }),
1143
+ ...(mode && { mode }),
1144
+ ...(intent && { intent }),
1145
+ ...(rawResponse && { rawResponsePreview: rawResponse.slice(0, 4000) }),
1146
+ }, null, 2);
1147
+
1148
+ if (executionId || rawResponse) {
1149
+ footerChildren.push('copy-btn');
1150
+ components.push({ id: 'copy-btn', component: 'Button', text: 'Copy Debug Info', icon: 'clipboard', variant: 'outline', 'data-clipboard': debugPayload });
1151
+ }
1152
+
1153
+ components.push(
1154
+ { id: 'ftr', component: 'Footer', children: footerChildren },
1155
+ { id: 'retry', component: 'Button', text: 'Try Again', icon: 'arrow-clockwise', variant: 'primary' },
1156
+ );
1157
+
1158
+ const updateMsg = {
1159
+ type: 'updateComponents',
1160
+ surfaceId: 'default',
1161
+ // Marker so validators / scorers / consumers can detect that this is a
1162
+ // generation-failure surface (NOT a successful UI). Without this, the
1163
+ // validator was scoring fallbacks ~89/100 because they're structurally
1164
+ // a valid Card. See diagnosis report 2026-04-19.
1165
+ _fallback: true,
1166
+ _fallbackReason: reason,
1167
+ components: [
1168
+ { id: 'root', component: 'Card', children },
1169
+ ...components,
1170
+ ],
1171
+ };
1172
+
1173
+ // ── Wiring: retry button emits a2ui-retry so consumers can re-run generation ──
1174
+ const actions = [
1175
+ {
1176
+ event: { event: 'press', target: 'retry' },
1177
+ handler: 'emit-event',
1178
+ config: {
1179
+ eventName: 'a2ui-retry',
1180
+ detail: { intent: intent || '', mode: mode || 'pro', executionId: executionId || '' },
1181
+ },
1182
+ },
1183
+ ];
1184
+
1185
+ const wireMsg = {
1186
+ type: 'wireComponents',
1187
+ surfaceId: 'default',
1188
+ actions,
1189
+ };
1190
+
1191
+ return [updateMsg, wireMsg];
1192
+ }
1193
+
1194
+ /**
1195
+ * Build a repair prompt from failed validation checks.
1196
+ * @param {object[]} failedChecks
1197
+ * @param {object[]} messages
1198
+ * @returns {string}
1199
+ */
1200
+ export function buildRepairPrompt(failedChecks, messages, catalogFailures = []) {
1201
+ const blocks = [];
1202
+
1203
+ if (failedChecks.length > 0) {
1204
+ const lines = failedChecks.map(c => `- ${c.name}: ${c.detail}`).join('\n');
1205
+ blocks.push(`Heuristic validation errors:\n${lines}`);
1206
+ }
1207
+
1208
+ if (catalogFailures.length > 0) {
1209
+ // Each entry: { id, component, errors: string[] }. Show at most 20 to
1210
+ // keep the prompt bounded; the LLM sees the pattern after a handful.
1211
+ const slice = catalogFailures.slice(0, 20);
1212
+ const lines = slice
1213
+ .map(f => `- ${f.component} id="${f.id}": ${f.errors.slice(0, 3).join('; ')}`)
1214
+ .join('\n');
1215
+ const overflow = catalogFailures.length > 20 ? `\n (+${catalogFailures.length - 20} more — same patterns apply)` : '';
1216
+ blocks.push(`Catalog (v0.9 schema) validation errors:\n${lines}${overflow}\n\nFix by:\n- Removing unknown properties (use declared props only)\n- Aligning enum values (e.g. variant="primary" not variant="main")\n- Using correct types (number fields get numbers, not strings)`);
1217
+ }
1218
+
1219
+ return [
1220
+ `Fix these A2UI validation errors:\n`,
1221
+ blocks.join('\n\n'),
1222
+ `\n\nOriginal output:\n${JSON.stringify(messages)}`,
1223
+ ].join('');
1224
+ }
1225
+
1226
+ /**
1227
+ * Generate 2-4 actionable follow-up suggestions based on what's already on
1228
+ * the canvas. Canvas-aware (not domain-aware) so iteration turns don't drift
1229
+ * into unrelated patterns — a login form should never get "add a chart"
1230
+ * suggested, even when the LATEST turn's intent ("add status feedback")
1231
+ * trips the data-domain classifier.
1232
+ *
1233
+ * Selection logic:
1234
+ * 1. Prefer concept-specific extensions for the ORIGINAL canvas concepts
1235
+ * (e.g. "auth" → forgot-password, social sign-in, remember-me).
1236
+ * 2. Fall back to the original analyzer's impliedComponents that aren't
1237
+ * yet present (e.g. "Add Avatar" if the analysis expected one).
1238
+ * 3. Cap at 4. Never propose components that conflict with the canvas's
1239
+ * existing shape (no "data table" for a login form).
1240
+ *
1241
+ * @param {object} opts
1242
+ * @param {string} opts.intent — Current turn's intent (most-recent)
1243
+ * @param {object} opts.domain — Current turn's domain classification
1244
+ * @param {object[]} opts.messages — Current canvas messages
1245
+ * @param {object} [opts.originalAnalysis] — First turn's analyzer output (from store)
1246
+ * @param {string} [opts.originalIntent] — First turn's intent (from store)
1247
+ */
1248
+ export function generateSuggestions({ intent, domain, messages, originalAnalysis = null, originalIntent = null }) {
1249
+ // Component types currently on canvas
1250
+ const types = new Set();
1251
+ for (const msg of messages || []) {
1252
+ if (msg?.type === 'updateComponents' && Array.isArray(msg.components)) {
1253
+ for (const c of msg.components) if (c?.component) types.add(c.component);
1254
+ }
1255
+ }
1256
+
1257
+ // Concepts that anchor what kind of canvas this IS. Prefer the original
1258
+ // analysis (durable across iteration turns) over the current intent's
1259
+ // domain (which drifts with each chip click).
1260
+ const concepts = new Set((originalAnalysis?.concepts || []).map(c => c.toLowerCase()));
1261
+ const impliedComponents = new Set(originalAnalysis?.impliedComponents || []);
1262
+ const domainName = domain?.domain || 'layout';
1263
+
1264
+ // Concept-specific extension catalogues. Keys match the kind of tags the
1265
+ // analyzer produces (concepts like "auth", "commerce"). When a concept
1266
+ // matches, suggestions extend the canvas instead of drifting away.
1267
+ const CONCEPT_EXTENSIONS = {
1268
+ 'auth': ['Add forgot password link', 'Add Sign in with Google', 'Add Remember me checkbox', 'Add Sign up link'],
1269
+ 'authentication': ['Add forgot password link', 'Add Sign in with Google', 'Add Remember me checkbox', 'Add Sign up link'],
1270
+ 'login': ['Add forgot password link', 'Add Sign in with Google', 'Add Remember me checkbox', 'Add Sign up link'],
1271
+ 'commerce': ['Add price comparison', 'Add product variants picker', 'Add quantity stepper', 'Add Save for later button'],
1272
+ 'product-display': ['Add price comparison', 'Add product variants picker', 'Add rating stars', 'Add Save for later button'],
1273
+ 'settings': ['Add a Save / Cancel footer', 'Add an account section', 'Add a danger zone (delete account)', 'Add a billing section'],
1274
+ 'preferences': ['Add a Save / Cancel footer', 'Add an account section', 'Add a notifications section'],
1275
+ 'navigation': ['Add breadcrumbs', 'Add a sidebar', 'Add a search bar'],
1276
+ 'data-display': ['Add filter controls', 'Add sort controls', 'Add a row-level action menu', 'Add a CSV export button'],
1277
+ 'agent': ['Add a streaming response area', 'Add action buttons', 'Add a citation list'],
1278
+ };
1279
+
1280
+ const suggestions = [];
1281
+
1282
+ // 1. Concept-driven extensions (the high-quality path)
1283
+ for (const concept of concepts) {
1284
+ const ext = CONCEPT_EXTENSIONS[concept];
1285
+ if (ext) {
1286
+ for (const s of ext) if (!suggestions.includes(s)) suggestions.push(s);
1287
+ if (suggestions.length >= 4) break;
1288
+ }
1289
+ }
1290
+
1291
+ // 2. Fill from analyzer's impliedComponents that aren't on canvas yet.
1292
+ // "The brief said you'd want an Avatar but you don't have one."
1293
+ if (suggestions.length < 4) {
1294
+ for (const c of impliedComponents) {
1295
+ if (!types.has(c)) {
1296
+ const label = `Add ${prettyComponentName(c)}`;
1297
+ if (!suggestions.includes(label)) suggestions.push(label);
1298
+ if (suggestions.length >= 4) break;
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ // 3. Last resort — domain heuristics, but ONLY when no concepts AND no
1304
+ // implied components landed. Original generic catalogue, intentionally
1305
+ // weak so it loses to the better paths above.
1306
+ if (suggestions.length === 0) {
1307
+ if (domainName === 'forms') suggestions.push('Add form validation', 'Add helper text');
1308
+ else if (domainName === 'data') suggestions.push('Add filter controls', 'Add a row-level action menu');
1309
+ else suggestions.push('Add a header', 'Add interactive controls');
1310
+ }
1311
+
1312
+ return suggestions.slice(0, 4);
1313
+ }
1314
+
1315
+ /** "AvatarGroup" → "an avatar group", "Button" → "a button" — friendly suggestion text. */
1316
+ function prettyComponentName(name) {
1317
+ const spaced = String(name).replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
1318
+ const article = /^[aeiou]/i.test(spaced) ? 'an' : 'a';
1319
+ return `${article} ${spaced}`;
1320
+ }