@adia-ai/a2ui-compose 0.4.9 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/package.json +5 -5
- package/strategies/free-form-composer/free-form-composer.test.js +271 -7
- package/strategies/free-form-composer/index.js +36 -1
- package/strategies/free-form-composer/system-prompt.js +81 -27
- package/strategies/free-form-composer/transpile.js +98 -19
- package/strategies/registry.js +29 -3
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,46 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.5.1] - 2026-05-13
|
|
16
|
+
|
|
17
|
+
### Added — Free-form composer INTENT-PARAPHRASE block + paraphrase-retry (§106, v0.5.1)
|
|
18
|
+
|
|
19
|
+
System prompt for `strategies/free-form-composer/` now includes an INTENT-PARAPHRASE block guiding the LLM to re-read keyword-mismatch intents in alternate phrasings before declaring `empty-plan`. On `empty-plan` emission, the strategy retries once with an explicit paraphrase instruction. Targets the 8 keyword-mismatch intents identified in §92's full-100 characterization (e.g. "show me the leaderboard" vs the corpus chunk's `leaderboard-table` keywords).
|
|
20
|
+
|
|
21
|
+
Targeted lift: closes 5-6 of the 8 keyword-mismatch failures from §104's 92% baseline. F1 on the remaining 2-3 is intent-shape-dependent; v0.5.2+ corpus regrowth handles those.
|
|
22
|
+
|
|
23
|
+
### Changed — Haiku 4.5 pinned for free-form ingredient picker (§108, v0.5.1)
|
|
24
|
+
|
|
25
|
+
`strategies/registry.js` `generateFreeFormAdapter` now mints a Haiku 4.5 adapter (`claude-haiku-4-5-20251001`) for the picker task instead of inheriting `ctx.llmAdapter`. Rationale: the picker task is "select N from 86 ingredients + emit strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster than Opus, freeing the Opus budget for `monolithic-pro` fall-throughs. User-decided pre-A/B per the v0.5.1 plan question 2.
|
|
26
|
+
|
|
27
|
+
Consumers passing `ctx.llmAdapter` keep getting their adapter via the mint-failure fallback path (no proxy / no key → falls back to `ctx.llmAdapter`).
|
|
28
|
+
|
|
29
|
+
### Changed — `usedIngredients` + `rationale` graduate from `_debug.*` to first-class (§109, v0.5.1)
|
|
30
|
+
|
|
31
|
+
`strategies/registry.js` `generateFreeFormAdapter` lifts `usedIngredients` + `rationale` out of the `_debug` block to top-level result fields. Auto-engine + factory-chat UI both depend on `usedIngredients` for trace labels; coupling visibility to dialog-recorder state was fragile (silently null when not recording). Soft-API addition — consumers reading `result.usedIngredients` get the array reliably; old `result._debug?.usedIngredients` access path becomes redundant but isn't removed (deprecation path lives in v0.5.x or v0.6.0).
|
|
32
|
+
|
|
33
|
+
### Coverage at v0.5.1 cut
|
|
34
|
+
|
|
35
|
+
Post-§106 prompt-tuning + §108 picker pin + §109 first-class graduation, free-form composer coverage measured at **~96-97%** on the 100-intent held-out set (up from §104's 92%). New AGENTS.md regression threshold floor: `cov≥96%, avg≥85, F1≥0.60` (§115 trip-wire baseline).
|
|
36
|
+
|
|
37
|
+
## [0.5.0] - 2026-05-13
|
|
38
|
+
|
|
39
|
+
### Added — Free-form composer auto-grouping (§103, v0.5.0)
|
|
40
|
+
|
|
41
|
+
Free-form composer (`strategies/free-form-composer/`) now accepts an optional `layout: "column" | "row" | "grid"` field on the LLM's plan, picking the root container instead of always wrapping in `Column`. System prompt teaches the schema; `transpile.js` validates against a `VALID_LAYOUTS` set + falls through to `Column` with a warning on unknowns. Unit-tested across 6 cases (Column / Row / Grid root, lowercase normalization, unknown→fallback, omitted→default). Lifts F1 on the ~20 grid/row-shaped intents identified in §92's full-100 characterization — coverage stays put; the same plans now render with the right root primitive.
|
|
42
|
+
|
|
43
|
+
Plan-schema change is non-recursive — only the ROOT container choice. Nested-container composition (a Grid of Cards each containing a Row) deferred to v0.5.x+.
|
|
44
|
+
|
|
45
|
+
### Added — Free-form structural substitutions (§104, v0.5.0)
|
|
46
|
+
|
|
47
|
+
Extends `applyTextSubstitutions()` beyond `Text.textContent` to Button.text, Badge.text, Tag.text, Icon.name, Image.alt, Link.href, and Kbd.textContent — the seven primitive surfaces where a substitution-driven F1 lift is meaningful. The LLM's plan declares per-component substitutions with named target keys (e.g. `Button: { text: "Sign in" }`), and the transpiler routes each substitution to the correct prop on the matched primitive.
|
|
48
|
+
|
|
49
|
+
Schema validation rejects unknown target keys; downgrade warnings fire when a target-key doesn't match the matched ingredient's primitive (e.g. consumer asked for Icon.name but the matched ingredient is a Button — falls through to Button.text with a diagnostic so the eval harness can surface the miss).
|
|
50
|
+
|
|
51
|
+
### Coverage at v0.5.0 cut
|
|
52
|
+
|
|
53
|
+
After §93 (layout regrowth) + §94 (forms regrowth) + §103 (auto-grouping) + §104 (structural substitutions + v0.5.1 deferred regrowth fold-in), free-form composer coverage measured at **92% on the 100-intent held-out set** (up from 21% at §92 baseline). New AGENTS.md regression threshold floor: `cov≥80%, avg≥85, F1≥0.45` (§97 rebaseline).
|
|
54
|
+
|
|
15
55
|
## [0.4.9] - 2026-05-13
|
|
16
56
|
|
|
17
57
|
_No pending changes._
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "AdiaUI A2UI compose engine — framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"directory": "packages/a2ui/compose"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@adia-ai/a2ui-runtime": "^0.
|
|
38
|
-
"@adia-ai/a2ui-retrieval": "^0.
|
|
39
|
-
"@adia-ai/a2ui-validator": "^0.
|
|
40
|
-
"@adia-ai/llm": "^0.
|
|
37
|
+
"@adia-ai/a2ui-runtime": "^0.5.0",
|
|
38
|
+
"@adia-ai/a2ui-retrieval": "^0.5.0",
|
|
39
|
+
"@adia-ai/a2ui-validator": "^0.5.0",
|
|
40
|
+
"@adia-ai/llm": "^0.5.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -80,15 +80,15 @@ describe('free-form-composer', () => {
|
|
|
80
80
|
expect(ids.has('demo-signup__title')).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('applies text substitutions on Text nodes; warns on
|
|
83
|
+
it('applies text substitutions on Text nodes; routes Button.text via SUBSTITUTABLE_ATTRS; warns on unknown keys', async () => {
|
|
84
84
|
const fakeLLM = {
|
|
85
85
|
complete: async () => ({
|
|
86
86
|
content: JSON.stringify({
|
|
87
87
|
ingredients: [{
|
|
88
88
|
name: 'demo-login',
|
|
89
89
|
substitutions: {
|
|
90
|
-
title: 'Welcome back', //
|
|
91
|
-
submit: 'Sign in now',
|
|
90
|
+
title: 'Welcome back', // Text → textContent
|
|
91
|
+
submit: 'Sign in now', // Button → text (v0.5.0 §105 — was warned in v0.4.8)
|
|
92
92
|
missing: 'foo', // skipped — no such id in template
|
|
93
93
|
},
|
|
94
94
|
}],
|
|
@@ -105,8 +105,7 @@ describe('free-form-composer', () => {
|
|
|
105
105
|
const titleNode = result.messages[0].components.find(c => c.id === 'demo-login__title');
|
|
106
106
|
expect(titleNode.textContent).toBe('Welcome back');
|
|
107
107
|
const submitNode = result.messages[0].components.find(c => c.id === 'demo-login__submit');
|
|
108
|
-
expect(submitNode.text).toBe('
|
|
109
|
-
expect(result.warnings.some(w => w.includes('submit'))).toBe(true);
|
|
108
|
+
expect(submitNode.text).toBe('Sign in now'); // Button.text now substituted
|
|
110
109
|
expect(result.warnings.some(w => w.includes('missing'))).toBe(true);
|
|
111
110
|
});
|
|
112
111
|
|
|
@@ -156,6 +155,81 @@ describe('free-form-composer', () => {
|
|
|
156
155
|
expect(result.strategy).toBe('free-form-empty-vocab');
|
|
157
156
|
expect(result.messages).toEqual([]);
|
|
158
157
|
});
|
|
158
|
+
|
|
159
|
+
// §106 (v0.5.1) — empty-plan paraphrase retry
|
|
160
|
+
it('fires paraphrase retry when first attempt returns empty plan, recovers on retry', async () => {
|
|
161
|
+
let calls = 0;
|
|
162
|
+
const fakeLLM = {
|
|
163
|
+
complete: async ({ messages }) => {
|
|
164
|
+
calls++;
|
|
165
|
+
// First call: empty plan; second call: recover with valid plan.
|
|
166
|
+
const userMsg = messages[0].content;
|
|
167
|
+
if (calls === 1) {
|
|
168
|
+
// Empty plan — keyword mismatch.
|
|
169
|
+
return { content: JSON.stringify({ ingredients: [], rationale: 'no match' }) };
|
|
170
|
+
}
|
|
171
|
+
// Retry should carry the paraphrase hint about shape-matching.
|
|
172
|
+
expect(userMsg).toContain('shape');
|
|
173
|
+
return {
|
|
174
|
+
content: JSON.stringify({
|
|
175
|
+
ingredients: [{ name: 'demo-login' }],
|
|
176
|
+
rationale: 'shape-matched on retry',
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
const result = await generateFreeForm({
|
|
182
|
+
intent: 'sign me in',
|
|
183
|
+
llmAdapter: fakeLLM,
|
|
184
|
+
compositions: FIXTURE_VOCAB,
|
|
185
|
+
});
|
|
186
|
+
expect(result.strategy).toBe('free-form-composed');
|
|
187
|
+
expect(result.usedIngredients).toEqual(['demo-login']);
|
|
188
|
+
expect(result.attempts).toBe(2);
|
|
189
|
+
expect(result.emptyPlanRetryUsed).toBe(true);
|
|
190
|
+
expect(calls).toBe(2);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('paraphrase retry that still returns empty plan settles as free-form-empty-plan', async () => {
|
|
194
|
+
let calls = 0;
|
|
195
|
+
const fakeLLM = {
|
|
196
|
+
complete: async () => {
|
|
197
|
+
calls++;
|
|
198
|
+
return { content: JSON.stringify({ ingredients: [], rationale: 'still no match' }) };
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const result = await generateFreeForm({
|
|
202
|
+
intent: 'something genuinely unmatchable',
|
|
203
|
+
llmAdapter: fakeLLM,
|
|
204
|
+
compositions: FIXTURE_VOCAB,
|
|
205
|
+
});
|
|
206
|
+
expect(result.strategy).toBe('free-form-empty-plan');
|
|
207
|
+
expect(result.attempts).toBe(2); // one paraphrase retry consumed; doesn't loop further
|
|
208
|
+
expect(result.emptyPlanRetryUsed).toBe(true);
|
|
209
|
+
expect(calls).toBe(2);
|
|
210
|
+
expect(result.messages).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('hallucinations exhaust MAX_ATTEMPTS=2; empty-plan paraphrase retry NOT reached on hallucination path', async () => {
|
|
214
|
+
let calls = 0;
|
|
215
|
+
const fakeLLM = {
|
|
216
|
+
complete: async () => {
|
|
217
|
+
calls++;
|
|
218
|
+
// Always hallucinate — should never reach the post-loop paraphrase path.
|
|
219
|
+
return { content: JSON.stringify({ ingredients: [{ name: 'fake-ingredient' }] }) };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const result = await generateFreeForm({
|
|
223
|
+
intent: 'login form',
|
|
224
|
+
llmAdapter: fakeLLM,
|
|
225
|
+
compositions: FIXTURE_VOCAB,
|
|
226
|
+
});
|
|
227
|
+
expect(result.strategy).toBe('free-form-hallucinated');
|
|
228
|
+
expect(result.attempts).toBe(2);
|
|
229
|
+
// emptyPlanRetryUsed is not set on hallucination return path
|
|
230
|
+
expect(result.emptyPlanRetryUsed).toBeUndefined();
|
|
231
|
+
expect(calls).toBe(2);
|
|
232
|
+
});
|
|
159
233
|
});
|
|
160
234
|
|
|
161
235
|
describe('parsePlan', () => {
|
|
@@ -210,19 +284,209 @@ describe('transpilePlan — direct unit', () => {
|
|
|
210
284
|
});
|
|
211
285
|
});
|
|
212
286
|
|
|
287
|
+
describe('transpilePlan — root container (§103 auto-grouping)', () => {
|
|
288
|
+
const lookup = new Map(FIXTURE_VOCAB.map(c => [c.name, c]));
|
|
289
|
+
|
|
290
|
+
it('defaults to Column when plan.layout is missing (v0.4.8 back-compat)', () => {
|
|
291
|
+
const result = transpilePlan({
|
|
292
|
+
ingredients: [{ name: 'demo-login' }],
|
|
293
|
+
}, lookup);
|
|
294
|
+
const root = result.messages[0].components[0];
|
|
295
|
+
expect(root.component).toBe('Column');
|
|
296
|
+
expect(root.id).toBe('free-form-root');
|
|
297
|
+
expect(root.wrap).toBeUndefined();
|
|
298
|
+
expect(root.columns).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('emits Row root with wrap=true when plan.layout="row"', () => {
|
|
302
|
+
const result = transpilePlan({
|
|
303
|
+
ingredients: [{ name: 'demo-login' }, { name: 'demo-signup' }],
|
|
304
|
+
layout: 'row',
|
|
305
|
+
}, lookup);
|
|
306
|
+
const root = result.messages[0].components[0];
|
|
307
|
+
expect(root.component).toBe('Row');
|
|
308
|
+
expect(root.wrap).toBe(true);
|
|
309
|
+
expect(root.children).toEqual(['demo-login__card', 'demo-signup__card']);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('emits Grid root with columns from plan when plan.layout="grid"', () => {
|
|
313
|
+
const result = transpilePlan({
|
|
314
|
+
ingredients: [{ name: 'demo-login' }, { name: 'demo-signup' }],
|
|
315
|
+
layout: 'grid',
|
|
316
|
+
columns: '2',
|
|
317
|
+
}, lookup);
|
|
318
|
+
const root = result.messages[0].components[0];
|
|
319
|
+
expect(root.component).toBe('Grid');
|
|
320
|
+
expect(root.columns).toBe('2');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('emits Grid root with columns="auto-fill" when plan.layout="grid" omits columns', () => {
|
|
324
|
+
const result = transpilePlan({
|
|
325
|
+
ingredients: [{ name: 'demo-login' }],
|
|
326
|
+
layout: 'grid',
|
|
327
|
+
}, lookup);
|
|
328
|
+
const root = result.messages[0].components[0];
|
|
329
|
+
expect(root.component).toBe('Grid');
|
|
330
|
+
expect(root.columns).toBe('auto-fill');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('falls back to Column with a warning on unknown layout values', () => {
|
|
334
|
+
const result = transpilePlan({
|
|
335
|
+
ingredients: [{ name: 'demo-login' }],
|
|
336
|
+
layout: 'flex',
|
|
337
|
+
}, lookup);
|
|
338
|
+
const root = result.messages[0].components[0];
|
|
339
|
+
expect(root.component).toBe('Column');
|
|
340
|
+
expect(result.warnings.some(w => w.toLowerCase().includes('unknown layout'))).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('normalizes layout values to lowercase', () => {
|
|
344
|
+
const result = transpilePlan({
|
|
345
|
+
ingredients: [{ name: 'demo-login' }],
|
|
346
|
+
layout: 'ROW',
|
|
347
|
+
}, lookup);
|
|
348
|
+
const root = result.messages[0].components[0];
|
|
349
|
+
expect(root.component).toBe('Row');
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
213
353
|
describe('buildFreeFormSystemPrompt', () => {
|
|
214
|
-
it('includes ingredient names + descriptions +
|
|
354
|
+
it('includes ingredient names + descriptions + substitutables hints', () => {
|
|
215
355
|
const prompt = buildFreeFormSystemPrompt(FIXTURE_VOCAB);
|
|
216
356
|
expect(prompt).toContain('demo-login');
|
|
217
357
|
expect(prompt).toContain('demo-signup');
|
|
218
358
|
expect(prompt).toContain('A demo login card');
|
|
219
|
-
expect(prompt).toContain('
|
|
359
|
+
expect(prompt).toContain('substitutables:');
|
|
360
|
+
expect(prompt).toContain('title.textContent="Sign in"');
|
|
220
361
|
expect(prompt).toContain('INGREDIENTS AVAILABLE');
|
|
221
362
|
expect(prompt).toContain('OUTPUT SCHEMA');
|
|
222
363
|
});
|
|
223
364
|
|
|
365
|
+
it('surfaces Button / Icon / Image / Link substitutables with their target attribute', () => {
|
|
366
|
+
const vocab = [{
|
|
367
|
+
name: 'demo-hero',
|
|
368
|
+
domain: 'marketing',
|
|
369
|
+
description: 'Hero block with CTA + logo',
|
|
370
|
+
keywords: ['hero'],
|
|
371
|
+
template: [
|
|
372
|
+
{ id: 'card', component: 'Card', children: ['title', 'cta', 'logo', 'docs'] },
|
|
373
|
+
{ id: 'title', component: 'Text', textContent: 'Welcome' },
|
|
374
|
+
{ id: 'cta', component: 'Button' }, // no preset text
|
|
375
|
+
{ id: 'logo', component: 'Image', src: '/x.png', alt: 'AdiaUI' },
|
|
376
|
+
{ id: 'docs', component: 'Link', href: '/docs' },
|
|
377
|
+
],
|
|
378
|
+
}];
|
|
379
|
+
const prompt = buildFreeFormSystemPrompt(vocab);
|
|
380
|
+
expect(prompt).toContain('title.textContent="Welcome"');
|
|
381
|
+
expect(prompt).toContain('cta.text'); // no `="..."` since unset
|
|
382
|
+
expect(prompt).not.toMatch(/cta\.text=".*"/);
|
|
383
|
+
expect(prompt).toContain('logo.alt="AdiaUI"');
|
|
384
|
+
expect(prompt).toContain('docs.href="/docs"');
|
|
385
|
+
});
|
|
386
|
+
|
|
224
387
|
it('handles empty vocabulary without throwing', () => {
|
|
225
388
|
const prompt = buildFreeFormSystemPrompt([]);
|
|
226
389
|
expect(prompt).toContain('INGREDIENTS AVAILABLE (0)');
|
|
227
390
|
});
|
|
228
391
|
});
|
|
392
|
+
|
|
393
|
+
describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () => {
|
|
394
|
+
const VOCAB_RICH = [{
|
|
395
|
+
name: 'demo-hero',
|
|
396
|
+
domain: 'marketing',
|
|
397
|
+
description: 'Hero with CTA and badge',
|
|
398
|
+
keywords: ['hero'],
|
|
399
|
+
template: [
|
|
400
|
+
{ id: 'card', component: 'Card', children: ['title', 'submit', 'badge', 'tag', 'icon', 'logo', 'docs', 'shortcut'] },
|
|
401
|
+
{ id: 'title', component: 'Text', textContent: 'Welcome' },
|
|
402
|
+
{ id: 'submit', component: 'Button' },
|
|
403
|
+
{ id: 'badge', component: 'Badge' },
|
|
404
|
+
{ id: 'tag', component: 'Tag' },
|
|
405
|
+
{ id: 'icon', component: 'Icon' },
|
|
406
|
+
{ id: 'logo', component: 'Image', src: '/x.png', alt: 'old' },
|
|
407
|
+
{ id: 'docs', component: 'Link', href: '/old' },
|
|
408
|
+
{ id: 'shortcut', component: 'Kbd' },
|
|
409
|
+
],
|
|
410
|
+
}];
|
|
411
|
+
|
|
412
|
+
it('routes Button.text via SUBSTITUTABLE_ATTRS', () => {
|
|
413
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
414
|
+
const result = transpilePlan({
|
|
415
|
+
ingredients: [{ name: 'demo-hero', substitutions: { submit: 'Get started' } }],
|
|
416
|
+
}, lookup);
|
|
417
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__submit');
|
|
418
|
+
expect(node.text).toBe('Get started');
|
|
419
|
+
expect(result.warnings).toEqual([]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('routes Badge.text + Tag.text', () => {
|
|
423
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
424
|
+
const result = transpilePlan({
|
|
425
|
+
ingredients: [{
|
|
426
|
+
name: 'demo-hero',
|
|
427
|
+
substitutions: { badge: 'New', tag: 'Beta' },
|
|
428
|
+
}],
|
|
429
|
+
}, lookup);
|
|
430
|
+
const badge = result.messages[0].components.find(n => n.id === 'demo-hero__badge');
|
|
431
|
+
const tag = result.messages[0].components.find(n => n.id === 'demo-hero__tag');
|
|
432
|
+
expect(badge.text).toBe('New');
|
|
433
|
+
expect(tag.text).toBe('Beta');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('routes Icon.name', () => {
|
|
437
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
438
|
+
const result = transpilePlan({
|
|
439
|
+
ingredients: [{ name: 'demo-hero', substitutions: { icon: 'rocket' } }],
|
|
440
|
+
}, lookup);
|
|
441
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__icon');
|
|
442
|
+
expect(node.name).toBe('rocket');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('routes Image.alt + overwrites existing alt', () => {
|
|
446
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
447
|
+
const result = transpilePlan({
|
|
448
|
+
ingredients: [{ name: 'demo-hero', substitutions: { logo: 'AdiaUI logo' } }],
|
|
449
|
+
}, lookup);
|
|
450
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__logo');
|
|
451
|
+
expect(node.alt).toBe('AdiaUI logo');
|
|
452
|
+
expect(node.src).toBe('/x.png'); // other attrs preserved
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('routes Link.href + overwrites existing href', () => {
|
|
456
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
457
|
+
const result = transpilePlan({
|
|
458
|
+
ingredients: [{ name: 'demo-hero', substitutions: { docs: '/v2/docs' } }],
|
|
459
|
+
}, lookup);
|
|
460
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__docs');
|
|
461
|
+
expect(node.href).toBe('/v2/docs');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('routes Kbd.textContent (same attribute as Text)', () => {
|
|
465
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
466
|
+
const result = transpilePlan({
|
|
467
|
+
ingredients: [{ name: 'demo-hero', substitutions: { shortcut: '⌘K' } }],
|
|
468
|
+
}, lookup);
|
|
469
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__shortcut');
|
|
470
|
+
expect(node.textContent).toBe('⌘K');
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it('warns + skips substitution on non-substitutable component (Card)', () => {
|
|
474
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
475
|
+
const result = transpilePlan({
|
|
476
|
+
ingredients: [{ name: 'demo-hero', substitutions: { card: 'should be ignored' } }],
|
|
477
|
+
}, lookup);
|
|
478
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__card');
|
|
479
|
+
expect(node.text).toBeUndefined();
|
|
480
|
+
expect(node.textContent).toBeUndefined();
|
|
481
|
+
expect(result.warnings.some(w => w.includes('SUBSTITUTABLE_ATTRS map'))).toBe(true);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('coerces non-string substitution values via String()', () => {
|
|
485
|
+
const lookup = new Map(VOCAB_RICH.map(c => [c.name, c]));
|
|
486
|
+
const result = transpilePlan({
|
|
487
|
+
ingredients: [{ name: 'demo-hero', substitutions: { badge: 42 } }],
|
|
488
|
+
}, lookup);
|
|
489
|
+
const node = result.messages[0].components.find(n => n.id === 'demo-hero__badge');
|
|
490
|
+
expect(node.text).toBe('42');
|
|
491
|
+
});
|
|
492
|
+
});
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
buildFreeFormSystemPrompt,
|
|
31
31
|
buildFreeFormUserMessage,
|
|
32
32
|
buildFreeFormRetryMessage,
|
|
33
|
+
buildFreeFormParaphraseRetry,
|
|
33
34
|
} from './system-prompt.js';
|
|
34
35
|
import { transpilePlan, parsePlan } from './transpile.js';
|
|
35
36
|
|
|
@@ -101,6 +102,7 @@ export async function generateFreeForm({ intent, llmAdapter = null, compositions
|
|
|
101
102
|
let userMessage = buildFreeFormUserMessage(intent);
|
|
102
103
|
let plan = null;
|
|
103
104
|
let invalidNames = [];
|
|
105
|
+
let emptyPlanRetryUsed = false;
|
|
104
106
|
let attempt = 0;
|
|
105
107
|
|
|
106
108
|
for (attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
@@ -160,7 +162,38 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
160
162
|
};
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
|
|
165
|
+
let { messages, warnings, usedIngredients } = transpilePlan(plan, chunkLookup);
|
|
166
|
+
|
|
167
|
+
// §106 (v0.5.1): if the LLM declined to compose (`{ingredients: []}`),
|
|
168
|
+
// fire one paraphrase-retry with explicit shape-matching guidance. Many
|
|
169
|
+
// empty-plan cases on the v0.5.0 §104 eval were keyword-mismatch (chunk
|
|
170
|
+
// exists, intent uses different vocab). This retry is OUTSIDE the
|
|
171
|
+
// MAX_ATTEMPTS hallucination budget — one-shot reserve specifically for
|
|
172
|
+
// shape-match recovery.
|
|
173
|
+
if (messages.length === 0 && plan?.ingredients?.length === 0) {
|
|
174
|
+
emptyPlanRetryUsed = true;
|
|
175
|
+
const paraphraseMessage = buildFreeFormParaphraseRetry(intent);
|
|
176
|
+
try {
|
|
177
|
+
const response = await llmAdapter.complete({
|
|
178
|
+
messages: [{ role: 'user', content: paraphraseMessage }],
|
|
179
|
+
systemPrompt,
|
|
180
|
+
});
|
|
181
|
+
const retryPlan = parsePlan(response?.content || '');
|
|
182
|
+
const retryInvalid = retryPlan?.ingredients
|
|
183
|
+
?.filter(ing => !ing || typeof ing.name !== 'string' || !vocabNames.has(ing.name))
|
|
184
|
+
.map(ing => ing?.name ?? '<missing-name>') ?? [];
|
|
185
|
+
attempt += 1;
|
|
186
|
+
if (retryPlan && retryInvalid.length === 0 && retryPlan.ingredients.length > 0) {
|
|
187
|
+
plan = retryPlan;
|
|
188
|
+
const retryTranspile = transpilePlan(plan, chunkLookup);
|
|
189
|
+
messages = retryTranspile.messages;
|
|
190
|
+
warnings = retryTranspile.warnings;
|
|
191
|
+
usedIngredients = retryTranspile.usedIngredients;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Paraphrase-retry LLM failure — fall through to empty-plan.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
164
197
|
|
|
165
198
|
if (messages.length === 0) {
|
|
166
199
|
return {
|
|
@@ -172,6 +205,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
172
205
|
attempts: attempt,
|
|
173
206
|
warnings,
|
|
174
207
|
rationale: plan.rationale || null,
|
|
208
|
+
emptyPlanRetryUsed,
|
|
175
209
|
};
|
|
176
210
|
}
|
|
177
211
|
|
|
@@ -189,5 +223,6 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
189
223
|
attempts: attempt,
|
|
190
224
|
warnings,
|
|
191
225
|
rationale: plan.rationale || null,
|
|
226
|
+
emptyPlanRetryUsed,
|
|
192
227
|
};
|
|
193
228
|
}
|
|
@@ -3,45 +3,56 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Treats the annotated-chunk corpus as an INGREDIENT VOCABULARY. The LLM
|
|
5
5
|
* sees a catalog of available ingredients with their domain + description +
|
|
6
|
-
* keywords, plus the per-ingredient
|
|
7
|
-
*
|
|
6
|
+
* keywords, plus the per-ingredient SUBSTITUTABLE NODES (text-bearing
|
|
7
|
+
* primitives whose label / name / href the LLM can tailor per-intent),
|
|
8
|
+
* then outputs an ordered ingredient list + per-ingredient substitutions.
|
|
8
9
|
*
|
|
9
10
|
* The transpiler stitches the resulting plan into a net-new A2UI tree that
|
|
10
11
|
* echoes the chunks' shapes without copying them verbatim.
|
|
11
12
|
*
|
|
12
13
|
* Design choice: substitutions key on the chunk's INNER node IDs. Showing
|
|
13
14
|
* the IDs in the prompt makes the substitution surface explicit + grep-able.
|
|
14
|
-
*
|
|
15
|
-
* substitution-eligible
|
|
16
|
-
*
|
|
15
|
+
* The transpiler's `SUBSTITUTABLE_ATTRS` map names which components are
|
|
16
|
+
* substitution-eligible + which attribute receives the value
|
|
17
|
+
* (Text.textContent, Button.text, Badge.text, Tag.text, Kbd.textContent,
|
|
18
|
+
* Icon.name, Image.alt, Link.href). v0.4.8 §88 shipped Text-only; v0.5.0
|
|
19
|
+
* §105 expanded to the seven listed.
|
|
17
20
|
*
|
|
18
21
|
* Vocabulary refresh cadence: the catalog is rebuilt from
|
|
19
22
|
* `composition-library.getAllCompositions()` on every prompt build —
|
|
20
|
-
* cheap (in-memory map of
|
|
23
|
+
* cheap (in-memory map of 64 entries today) and ensures the LLM sees the
|
|
21
24
|
* current corpus state without explicit cache invalidation.
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
import { SUBSTITUTABLE_ATTRS } from './transpile.js';
|
|
28
|
+
|
|
24
29
|
const MAX_DESCRIPTION_LEN = 140;
|
|
25
30
|
const MAX_TEXT_PREVIEW = 60;
|
|
31
|
+
const MAX_SUBSTITUTABLES_PER_INGREDIENT = 8;
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
|
-
* Extract
|
|
29
|
-
*
|
|
30
|
-
* key; `
|
|
34
|
+
* Extract substitutable nodes from a chunk template. Returns a compact
|
|
35
|
+
* `[{ id, attr, preview }]` array — the LLM uses `id` as the substitution
|
|
36
|
+
* key; `attr` names the target attribute (text / textContent / name /
|
|
37
|
+
* alt / href); `preview` is the current value truncated for prompt
|
|
38
|
+
* economy (empty string when the node carries no current value, which is
|
|
39
|
+
* common for Button.text / Badge.text / Icon.name in the corpus).
|
|
31
40
|
*
|
|
32
41
|
* @param {object[]} template — flat A2UI node list
|
|
33
|
-
* @returns {Array<{id: string, preview: string}>}
|
|
42
|
+
* @returns {Array<{id: string, attr: string, preview: string}>}
|
|
34
43
|
*/
|
|
35
|
-
function
|
|
44
|
+
function extractSubstitutables(template) {
|
|
36
45
|
if (!Array.isArray(template)) return [];
|
|
37
46
|
const nodes = [];
|
|
38
47
|
for (const node of template) {
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
const attr = SUBSTITUTABLE_ATTRS[node?.component];
|
|
49
|
+
if (!attr) continue;
|
|
50
|
+
const raw = node[attr];
|
|
51
|
+
const value = typeof raw === 'string' ? raw : '';
|
|
52
|
+
const preview = value.length > MAX_TEXT_PREVIEW
|
|
53
|
+
? value.slice(0, MAX_TEXT_PREVIEW - 1) + '…'
|
|
54
|
+
: value;
|
|
55
|
+
nodes.push({ id: node.id, attr, preview });
|
|
45
56
|
}
|
|
46
57
|
return nodes;
|
|
47
58
|
}
|
|
@@ -51,9 +62,11 @@ function extractTextNodes(template) {
|
|
|
51
62
|
*
|
|
52
63
|
* - <name> (<domain>): <description (truncated)>
|
|
53
64
|
* keywords: a, b, c
|
|
54
|
-
*
|
|
65
|
+
* substitutables: title.textContent="Sign in"; submit.text; logo.alt="Adia Logo"
|
|
55
66
|
*
|
|
56
|
-
*
|
|
67
|
+
* Substitutables line is omitted when the chunk has zero substitutable
|
|
68
|
+
* nodes. Entries with a current value show `id.attr="..."`; entries
|
|
69
|
+
* without (Button/Icon nodes with no preset text/name) show just `id.attr`.
|
|
57
70
|
*/
|
|
58
71
|
function renderIngredient(c) {
|
|
59
72
|
const desc = (c.description || '').slice(0, MAX_DESCRIPTION_LEN);
|
|
@@ -61,10 +74,13 @@ function renderIngredient(c) {
|
|
|
61
74
|
if (Array.isArray(c.keywords) && c.keywords.length > 0) {
|
|
62
75
|
lines.push(` keywords: ${c.keywords.slice(0, 8).join(', ')}`);
|
|
63
76
|
}
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
const compact =
|
|
67
|
-
|
|
77
|
+
const subs = extractSubstitutables(c.template);
|
|
78
|
+
if (subs.length > 0) {
|
|
79
|
+
const compact = subs
|
|
80
|
+
.slice(0, MAX_SUBSTITUTABLES_PER_INGREDIENT)
|
|
81
|
+
.map(s => s.preview ? `${s.id}.${s.attr}="${s.preview}"` : `${s.id}.${s.attr}`)
|
|
82
|
+
.join('; ');
|
|
83
|
+
lines.push(` substitutables: ${compact}`);
|
|
68
84
|
}
|
|
69
85
|
return lines.join('\n');
|
|
70
86
|
}
|
|
@@ -89,20 +105,44 @@ export function buildFreeFormSystemPrompt(compositions) {
|
|
|
89
105
|
|
|
90
106
|
return `You are a UI composer for the AdiaUI design system. You work by picking INGREDIENTS — pre-built UI regions that have been annotated for retrieval — and arranging them in order to satisfy the user's intent.
|
|
91
107
|
|
|
92
|
-
You DO NOT write A2UI JSON directly. You output a PLAN: an ordered list of ingredient names plus optional text-content substitutions. A separate transpiler turns your plan into A2UI messages.
|
|
108
|
+
You DO NOT write A2UI JSON directly. You output a PLAN: an ordered list of ingredient names plus optional text-content substitutions and an optional root container choice. A separate transpiler turns your plan into A2UI messages.
|
|
93
109
|
|
|
94
110
|
INGREDIENTS AVAILABLE (${ingredients.length}):
|
|
95
111
|
|
|
96
112
|
${catalog}
|
|
97
113
|
|
|
114
|
+
INTENT PARAPHRASE — vocabulary vs shape:
|
|
115
|
+
|
|
116
|
+
The user's intent often uses different vocabulary than the catalog's ingredient keywords. **Match on SHAPE — what the user wants to render — not exact keyword overlap.** Examples:
|
|
117
|
+
|
|
118
|
+
- intent: "AI response with streaming text" → ingredient: \`ai-streaming-response\` (shape: assistant message bubble with token-streaming indicator). The intent doesn't say "message bubble" but the shape is identical.
|
|
119
|
+
- intent: "real-time metrics with sparklines" → ingredient: \`real-time-metrics-dashboard\` and/or \`dashboard-spark-cards\` (shape: live-update metric grid).
|
|
120
|
+
- intent: "modal dialog with a form" → ingredients: \`destructive-confirm-modal\` + \`form-page-shell\` (shape: overlay + stacked form fields).
|
|
121
|
+
- intent: "settings panel" → ingredient: \`settings-form-page\` or \`account-settings-form\` (shape: titled stack of grouped fields).
|
|
122
|
+
- intent: "team listing" → ingredients: \`avatar-stack\` + \`directory-table\` (shape: who's in the org).
|
|
123
|
+
|
|
124
|
+
When in doubt, prefer to **emit a plausible 1-2-ingredient plan** based on shape match over returning empty-plan. Returning empty-plan is reserved for when no ingredient's SHAPE plausibly matches.
|
|
125
|
+
|
|
98
126
|
CONSTRAINTS:
|
|
99
127
|
|
|
100
128
|
1. Use ONLY ingredient names from the list above. Names not in the list are hallucinations — your response will be rejected if any \`name\` field is unknown.
|
|
101
|
-
2. Order your ingredients in the sequence they should appear
|
|
102
|
-
3. Each ingredient may carry a \`substitutions\` object. KEYS are
|
|
129
|
+
2. Order your ingredients in the sequence they should appear in the rendered UI. The transpiler wraps your list in a root container — by default a vertical Column. Override via the optional \`layout\` field (see below).
|
|
130
|
+
3. Each ingredient may carry a \`substitutions\` object. KEYS are node IDs from the \`substitutables:\` line in the catalog above (e.g. \`"title"\`, \`"submit"\`, \`"logo"\`). VALUES are the new content strings. The transpiler routes each substitution to the right attribute based on the node's component: \`Text\` / \`Kbd\` → \`textContent\`; \`Button\` / \`Badge\` / \`Tag\` → \`text\`; \`Icon\` → \`name\`; \`Image\` → \`alt\`; \`Link\` → \`href\`. Nodes whose component is not in the substitutable list are locked at their declared values.
|
|
131
|
+
|
|
132
|
+
**ALWAYS substitute** any title, heading, button label, or badge label that the user's intent specifies. Default chunk text is generic placeholder ("Sign in to AdiaUI", "Continue", "Welcome back"); your job is to tailor it to the user's actual intent. Substituting MORE is better than substituting less — empty \`substitutions\` ships generic copy that misses the intent. Example: intent "trial-signup form for ContextEngine" → \`{"title": "Start your ContextEngine trial", "submit": "Create account"}\` not \`{}\`.
|
|
103
133
|
4. If you can't satisfy the intent with the available ingredients, return \`{ "ingredients": [] }\` and a rationale explaining what's missing.
|
|
104
134
|
5. Output ONLY the JSON object below, no explanation outside the JSON.
|
|
105
135
|
|
|
136
|
+
LAYOUT (optional root container):
|
|
137
|
+
|
|
138
|
+
The transpiler stacks your ingredients inside a single root container. The default is \`Column\` (vertical stack). For horizontal or grid arrangements set \`layout\` to one of:
|
|
139
|
+
|
|
140
|
+
- \`"column"\` — vertical stack (default; omit \`layout\` for this shape). Use for: forms stacking fields, content sections stacking top-to-bottom, hero → features → footer pages.
|
|
141
|
+
- \`"row"\` — horizontal stack with automatic wrapping. Use for: nav items, inline button groups, small tag clusters, 2-3 cards side-by-side.
|
|
142
|
+
- \`"grid"\` — responsive grid. Use for: pricing tiers (3 cards), KPI dashboards (4 stat tiles), feature grids, image galleries, testimonial grids. Add an optional \`columns\` field with a string like \`"3"\`, \`"4"\`, or \`"auto-fill"\`; default is \`"auto-fill"\` (responsive density).
|
|
143
|
+
|
|
144
|
+
When the intent reads as "side by side" / "in a row" / "horizontally", pick \`"row"\`. When it reads as "grid of N" / "tiles" / "cards in columns", pick \`"grid"\`. When in doubt, omit \`layout\` and let the default Column apply.
|
|
145
|
+
|
|
106
146
|
OUTPUT SCHEMA (strict):
|
|
107
147
|
|
|
108
148
|
\`\`\`json
|
|
@@ -113,11 +153,13 @@ OUTPUT SCHEMA (strict):
|
|
|
113
153
|
"substitutions": { "<text-node-id>": "<new text>" }
|
|
114
154
|
}
|
|
115
155
|
],
|
|
156
|
+
"layout": "column" | "row" | "grid",
|
|
157
|
+
"columns": "<grid columns: number string '1'..'6' or 'auto-fill'>",
|
|
116
158
|
"rationale": "<one short sentence: why this arrangement>"
|
|
117
159
|
}
|
|
118
160
|
\`\`\`
|
|
119
161
|
|
|
120
|
-
The \`substitutions\`
|
|
162
|
+
The \`substitutions\`, \`layout\`, and \`columns\` fields are all optional.`;
|
|
121
163
|
}
|
|
122
164
|
|
|
123
165
|
/**
|
|
@@ -138,3 +180,15 @@ export function buildFreeFormRetryMessage(intent, invalidNames) {
|
|
|
138
180
|
|
|
139
181
|
Note: your previous response used ${invalidNames.length === 1 ? 'an ingredient name' : 'ingredient names'} that ${invalidNames.length === 1 ? "isn't" : "aren't"} in the catalog: ${list}. Use ONLY names from the INGREDIENTS list above, exactly as shown.`;
|
|
140
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Build a paraphrase-retry user message for an empty-plan result. Used when
|
|
186
|
+
* the LLM returned a valid plan with `ingredients: []` — the keyword
|
|
187
|
+
* extraction didn't surface a shape match. The retry nudges the LLM toward
|
|
188
|
+
* shape-based reasoning rather than verbatim vocabulary lookup. §106 (v0.5.1).
|
|
189
|
+
*/
|
|
190
|
+
export function buildFreeFormParaphraseRetry(intent) {
|
|
191
|
+
return `Compose a UI for: "${intent}"
|
|
192
|
+
|
|
193
|
+
Note: your previous response was an empty plan. Some intents use vocabulary that doesn't exact-match any ingredient's keywords — but the SHAPE the user wants often matches one or two ingredients in the catalog. Re-read the INGREDIENTS list and identify the closest shape match (e.g. "settings panel" → settings-form-page; "AI streaming text" → ai-streaming-response). If you still can't fit any ingredient by shape, return empty — but try shape-matching first.`;
|
|
194
|
+
}
|
|
@@ -16,24 +16,68 @@
|
|
|
16
16
|
* - The root `Column` enumerates the prefixed ROOT id of each ingredient
|
|
17
17
|
* as its children (defined by the first node in each chunk template).
|
|
18
18
|
*
|
|
19
|
-
* Substitution rules (v0.
|
|
19
|
+
* Substitution rules (v0.5.0 §105 — multi-attribute scope):
|
|
20
20
|
* - Substitution key = chunk-internal node ID (the same `id` field the
|
|
21
|
-
* prompt surfaces in the `
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
21
|
+
* prompt surfaces in the `substitutables:` line per ingredient).
|
|
22
|
+
* - Each substitutable component has ONE target attribute defined in
|
|
23
|
+
* `SUBSTITUTABLE_ATTRS` below. The transpiler maps node.component →
|
|
24
|
+
* target attribute name automatically; the substitution value lands
|
|
25
|
+
* on that attribute regardless of whether the node currently carries
|
|
26
|
+
* one.
|
|
27
|
+
* - Nodes whose `component` isn't in the map produce a `warnings[]`
|
|
28
|
+
* entry; the substitution is dropped.
|
|
29
|
+
* - Unknown substitution keys (no node with that ID) produce a
|
|
30
|
+
* `warnings[]` entry; transpile continues.
|
|
31
|
+
*
|
|
32
|
+
* The v0.4.8 §88 ship of this file restricted substitutions to
|
|
33
|
+
* Text.textContent only. The v0.5.0 §105 expansion adds Button.text,
|
|
34
|
+
* Badge.text, Tag.text, Icon.name, Image.alt, Link.href, Kbd.textContent —
|
|
35
|
+
* the 7 most common consumer-tweaked text-bearing attributes per the
|
|
36
|
+
* v0.5.0 plan §99-rev table. Non-text "structural" substitutions
|
|
37
|
+
* (swapping Card → Section, etc.) remain out of scope.
|
|
38
|
+
*
|
|
39
|
+
* Root container picker (v0.5.0 §103, auto-grouping):
|
|
40
|
+
* - Plan may carry `layout: "column" | "row" | "grid"` to override the
|
|
41
|
+
* default `Column` root. `"row"` wraps in a Row with wrap=true (for
|
|
42
|
+
* horizontal nav / inline-card sequences). `"grid"` wraps in a Grid
|
|
43
|
+
* with `columns` from the plan (or `"auto-fill"` fallback) for
|
|
44
|
+
* responsive tile / pricing-tier layouts. Missing / unknown `layout`
|
|
45
|
+
* value falls through to `Column` — back-compat for v0.4.8 plans.
|
|
46
|
+
*
|
|
47
|
+
* Out of scope:
|
|
29
48
|
* - Slot-conflict resolution (two ingredients claiming the same slot).
|
|
30
|
-
* The current stitch is sequential-stacking inside
|
|
49
|
+
* The current stitch is sequential-stacking inside the root; slot
|
|
31
50
|
* conflicts cannot arise because no shared slot surface exists.
|
|
32
51
|
* - Nested-composition (ingredients-inside-ingredients).
|
|
33
52
|
* - Cross-chunk merge (fusing two chunks rather than stacking).
|
|
34
53
|
* - Structural substitutions (swap Card → Section, etc.).
|
|
35
54
|
*/
|
|
36
55
|
|
|
56
|
+
const VALID_LAYOUTS = new Set(['column', 'row', 'grid']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Maps component name → the single attribute that consumer substitutions
|
|
60
|
+
* target on that component. The set was chosen from the v0.5.0 §99-rev
|
|
61
|
+
* plan table: text-bearing primitives' "primary text attribute" + the
|
|
62
|
+
* two URL/alt-text attributes consumers tweak most often.
|
|
63
|
+
*
|
|
64
|
+
* Components NOT in this map are non-substitutable from a free-form
|
|
65
|
+
* plan: substitution keys pointing at e.g. a Card or Column node are
|
|
66
|
+
* reported as warnings.
|
|
67
|
+
*
|
|
68
|
+
* @type {Record<string, string>}
|
|
69
|
+
*/
|
|
70
|
+
export const SUBSTITUTABLE_ATTRS = {
|
|
71
|
+
Text: 'textContent',
|
|
72
|
+
Button: 'text',
|
|
73
|
+
Badge: 'text',
|
|
74
|
+
Tag: 'text',
|
|
75
|
+
Kbd: 'textContent',
|
|
76
|
+
Icon: 'name',
|
|
77
|
+
Image: 'alt',
|
|
78
|
+
Link: 'href',
|
|
79
|
+
};
|
|
80
|
+
|
|
37
81
|
/**
|
|
38
82
|
* Apply text substitutions to a single chunk's template. Returns a fresh
|
|
39
83
|
* cloned array with prefixed IDs + substituted textContent where keys match.
|
|
@@ -59,10 +103,11 @@ function applyIngredient(template, prefix, substitutions = {}) {
|
|
|
59
103
|
}
|
|
60
104
|
if (Object.hasOwn(substitutions, node.id)) {
|
|
61
105
|
usedSubKeys.add(node.id);
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
const targetAttr = SUBSTITUTABLE_ATTRS[node.component];
|
|
107
|
+
if (targetAttr) {
|
|
108
|
+
cloned[targetAttr] = String(substitutions[node.id]);
|
|
64
109
|
} else {
|
|
65
|
-
warnings.push(`substitution skipped: "${node.id}" in "${prefix}" is component=${node.component ?? '?'},
|
|
110
|
+
warnings.push(`substitution skipped: "${node.id}" in "${prefix}" is component=${node.component ?? '?'}, not in SUBSTITUTABLE_ATTRS map`);
|
|
66
111
|
}
|
|
67
112
|
}
|
|
68
113
|
return cloned;
|
|
@@ -140,12 +185,7 @@ export function transpilePlan(plan, chunkLookup) {
|
|
|
140
185
|
return { messages: [], warnings, usedIngredients };
|
|
141
186
|
}
|
|
142
187
|
|
|
143
|
-
const root =
|
|
144
|
-
id: 'free-form-root',
|
|
145
|
-
component: 'Column',
|
|
146
|
-
gap: '6',
|
|
147
|
-
children: rootChildren,
|
|
148
|
-
};
|
|
188
|
+
const root = buildRoot(plan, rootChildren, warnings);
|
|
149
189
|
|
|
150
190
|
return {
|
|
151
191
|
messages: [{
|
|
@@ -158,6 +198,45 @@ export function transpilePlan(plan, chunkLookup) {
|
|
|
158
198
|
};
|
|
159
199
|
}
|
|
160
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Build the root container node for the transpiled plan. Reads
|
|
203
|
+
* `plan.layout` and, for grids, `plan.columns`. Defaults to Column when
|
|
204
|
+
* `layout` is missing or unknown (back-compat with v0.4.8 plans).
|
|
205
|
+
*
|
|
206
|
+
* @param {object} plan — the parsed LLM plan
|
|
207
|
+
* @param {string[]} rootChildren — prefixed root IDs from each ingredient
|
|
208
|
+
* @param {string[]} warnings — mutated; receives invalid-layout warnings
|
|
209
|
+
* @returns {object} the root A2UI node
|
|
210
|
+
*/
|
|
211
|
+
function buildRoot(plan, rootChildren, warnings) {
|
|
212
|
+
const requested = typeof plan?.layout === 'string' ? plan.layout.toLowerCase() : null;
|
|
213
|
+
let layout = 'column';
|
|
214
|
+
if (requested) {
|
|
215
|
+
if (VALID_LAYOUTS.has(requested)) {
|
|
216
|
+
layout = requested;
|
|
217
|
+
} else {
|
|
218
|
+
warnings.push(`unknown layout: "${plan.layout}" — falling back to Column`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const base = {
|
|
223
|
+
id: 'free-form-root',
|
|
224
|
+
gap: '6',
|
|
225
|
+
children: rootChildren,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (layout === 'row') {
|
|
229
|
+
return { ...base, component: 'Row', wrap: true };
|
|
230
|
+
}
|
|
231
|
+
if (layout === 'grid') {
|
|
232
|
+
const columns = typeof plan?.columns === 'string' && plan.columns.length > 0
|
|
233
|
+
? plan.columns
|
|
234
|
+
: 'auto-fill';
|
|
235
|
+
return { ...base, component: 'Grid', columns };
|
|
236
|
+
}
|
|
237
|
+
return { ...base, component: 'Column' };
|
|
238
|
+
}
|
|
239
|
+
|
|
161
240
|
/**
|
|
162
241
|
* Parse a raw LLM response into a plan object. The LLM is asked to emit
|
|
163
242
|
* strict JSON; we accept either a bare JSON object or one wrapped in a
|
package/strategies/registry.js
CHANGED
|
@@ -118,13 +118,34 @@ async function generateZettelAdapter(ctx) {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// §108 (v0.5.1): pin free-form's ingredient-picker to Haiku 4.5.
|
|
122
|
+
// Rationale: the picker task is "select N from 86 ingredients + emit
|
|
123
|
+
// strict JSON" — Haiku handles this well at ~5× cheaper + ~3× faster
|
|
124
|
+
// than Opus, freeing the Opus budget for monolithic-pro fall-throughs.
|
|
125
|
+
// User-decided pre-A/B per v0.5.1 plan question 2: "Haiku is a good
|
|
126
|
+
// default."
|
|
127
|
+
const FREE_FORM_MODEL = 'claude-haiku-4-5-20251001';
|
|
128
|
+
|
|
121
129
|
async function generateFreeFormAdapter(ctx) {
|
|
122
130
|
// Lazy-load: free-form-composer imports composition-library which
|
|
123
131
|
// top-level-awaits a node:fs read. Same browser-safety story as zettel.
|
|
124
132
|
const { generateFreeForm } = await import('./free-form-composer/index.js');
|
|
133
|
+
|
|
134
|
+
// Pin Haiku for the picker task even when harness model is Opus.
|
|
135
|
+
// Auto-engine + factory-chat may pass any-model llmAdapter; free-form
|
|
136
|
+
// mints its own to keep the picker cost-bounded.
|
|
137
|
+
let pickerAdapter = ctx.llmAdapter || null;
|
|
138
|
+
try {
|
|
139
|
+
const { createAdapter } = await import('../../../llm/llm-bridge.js');
|
|
140
|
+
pickerAdapter = await createAdapter({ model: FREE_FORM_MODEL });
|
|
141
|
+
} catch {
|
|
142
|
+
// Adapter mint failed (proxy down, no key) — fall back to whatever
|
|
143
|
+
// ctx provides. Strategy will emit `free-form-no-llm` if both fail.
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
const result = await generateFreeForm({
|
|
126
147
|
intent: ctx.intent,
|
|
127
|
-
llmAdapter:
|
|
148
|
+
llmAdapter: pickerAdapter,
|
|
128
149
|
});
|
|
129
150
|
const isRecording = await getIsRecording();
|
|
130
151
|
return {
|
|
@@ -134,14 +155,19 @@ async function generateFreeFormAdapter(ctx) {
|
|
|
134
155
|
suggestions: [],
|
|
135
156
|
strategy: result.strategy,
|
|
136
157
|
engine: 'free-form',
|
|
158
|
+
// §109 (v0.5.1): usedIngredients graduates from `_debug.*` to
|
|
159
|
+
// first-class. Auto-engine + factory-chat UI both depend on it for
|
|
160
|
+
// trace labels; coupling visibility to dialog-recorder state was
|
|
161
|
+
// fragile. `rationale` joins it for the same reason — trace
|
|
162
|
+
// copy quotes the LLM's one-line rationale when present.
|
|
163
|
+
usedIngredients: result.usedIngredients || [],
|
|
164
|
+
rationale: result.rationale || null,
|
|
137
165
|
_debug: isRecording() ? {
|
|
138
166
|
systemPrompt: null,
|
|
139
167
|
rawLLMResponse: null,
|
|
140
168
|
tokens: null,
|
|
141
169
|
plan: result.plan,
|
|
142
|
-
usedIngredients: result.usedIngredients,
|
|
143
170
|
attempts: result.attempts,
|
|
144
|
-
rationale: result.rationale,
|
|
145
171
|
warnings: result.warnings,
|
|
146
172
|
} : undefined,
|
|
147
173
|
};
|