@adia-ai/a2ui-compose 0.4.2 → 0.4.4
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 +27 -0
- package/README.md +12 -1
- package/core/generator.js +8 -1
- package/package.json +1 -1
- package/strategies/_shared/chunk-loader.js +208 -0
- package/strategies/_shared/chunk-loader.test.js +80 -0
- package/strategies/monolithic/generate-pro.js +85 -10
- package/strategies/registry.js +1 -1
- package/strategies/zettel/_smoke.js +2 -12
- package/strategies/zettel/composer.js +36 -121
- package/strategies/zettel/composition-library.js +207 -0
- package/strategies/zettel/generator-adapter.js +1 -1
- package/strategies/zettel/state-cache.js +6 -1
- package/strategies/zettel/synthesizer.js +16 -337
- package/transpiler/transpiler-maps.js +13 -6
- package/strategies/zettel/fragment-library.js +0 -209
|
@@ -1,343 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Composition synthesizer — RETIRED (§37, 2026-05-12).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Previously: when zettel retrieval was weak, this called the LLM to
|
|
5
|
+
* assemble a NEW composition from the fragment catalog (technique B/C
|
|
6
|
+
* fragment-graph synthesis). Fragments are retired and there's no
|
|
7
|
+
* fragment catalog to draw from anymore. The deterministic path
|
|
8
|
+
* (`resolveComposition` on a retrieved composition) still works.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* fragment names exist) before feeding it to the composer.
|
|
14
|
-
*
|
|
15
|
-
* This is the reasoning layer on top of pure retrieval. It turns fragments into
|
|
16
|
-
* the LLM's typed vocabulary — smaller, more structured than a 97-pattern corpus.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { getAllFragments, getAllCompositions } from './fragment-library.js';
|
|
20
|
-
import { resolveComposition, templateToMessages } from './composer.js';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Build a fragment catalog summary for the LLM prompt — name, role, shape,
|
|
24
|
-
* slot contract, description, AND the internal template so the LLM can inline
|
|
25
|
-
* the fragment if it needs to edit interior nodes (technique B in the prompt).
|
|
26
|
-
*/
|
|
27
|
-
function buildFragmentCatalog() {
|
|
28
|
-
const frags = getAllFragments();
|
|
29
|
-
return frags.map((f) => ({
|
|
30
|
-
name: f.name,
|
|
31
|
-
semantic_role: f.semantic_role,
|
|
32
|
-
shape: f.shape,
|
|
33
|
-
description: f.description,
|
|
34
|
-
keywords: f.keywords || [],
|
|
35
|
-
slots: (f.slots || []).map((s) => ({
|
|
36
|
-
name: s.name,
|
|
37
|
-
attribute: s.attribute,
|
|
38
|
-
required: !!s.required,
|
|
39
|
-
defaultValue: s.defaultValue,
|
|
40
|
-
description: s.description,
|
|
41
|
-
})),
|
|
42
|
-
template: f.template,
|
|
43
|
-
}));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Pick 2–3 in-context example compositions. We want variety (different domains)
|
|
48
|
-
* so the LLM sees the composition shape across intents.
|
|
49
|
-
*/
|
|
50
|
-
function buildExamples(count = 3) {
|
|
51
|
-
const comps = getAllCompositions();
|
|
52
|
-
const byDomain = new Map();
|
|
53
|
-
for (const c of comps) {
|
|
54
|
-
if (!byDomain.has(c.domain)) byDomain.set(c.domain, c);
|
|
55
|
-
if (byDomain.size >= count) break;
|
|
56
|
-
}
|
|
57
|
-
return [...byDomain.values()].map((c) => ({
|
|
58
|
-
name: c.name,
|
|
59
|
-
domain: c.domain,
|
|
60
|
-
description: c.description,
|
|
61
|
-
template: c.template,
|
|
62
|
-
}));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const SYSTEM_PROMPT = `You are a UI composer. Given a user intent, you assemble a UI composition from a catalog of reusable fragments.
|
|
66
|
-
|
|
67
|
-
⚠️ ABSOLUTE OUTPUT CONTRACT ⚠️
|
|
68
|
-
Your ENTIRE response must be a single JSON object. No prose before or after. No clarifying
|
|
69
|
-
questions. No "I'll add..." narration. No markdown fences. Just JSON.
|
|
70
|
-
If a user request is ambiguous, make a best-guess decision and emit the JSON. Never ask.
|
|
71
|
-
|
|
72
|
-
A composition is a flat-adjacency array of A2UI nodes. Each node either:
|
|
73
|
-
(a) declares a component inline:
|
|
74
|
-
{ "id": "foo", "component": "Column", "children": ["a","b"], "gap": "3" }
|
|
75
|
-
(b) references a fragment by name with slot bindings:
|
|
76
|
-
{ "id": "foo", "$fragment": "labeled-input",
|
|
77
|
-
"bindings": { "label": "Email", "name": "email", "type": "email" } }
|
|
78
|
-
|
|
79
|
-
Rules:
|
|
80
|
-
1. Exactly one root node with id "root". Other nodes reference it via children arrays.
|
|
81
|
-
2. Every $fragment ref must name a fragment that exists in the catalog. Never invent fragment names.
|
|
82
|
-
3. Every required slot on a referenced fragment MUST appear in bindings.
|
|
83
|
-
4. Prefer fragments over inline nodes when an atom fits — they carry semantics and reuse.
|
|
84
|
-
5. Use inline nodes (Column/Row/Card/Section/Text/Button/etc.) for layout, glue, and gaps between fragments.
|
|
85
|
-
6. Keep compositions compact — 6 to 15 nodes is typical. Don't over-engineer.
|
|
86
|
-
|
|
87
|
-
EXTENDING FRAGMENTS — four techniques when a fragment almost fits but needs more.
|
|
88
|
-
STRONGLY PREFER technique (A) for "add X above/below the heading" requests — those
|
|
89
|
-
are structurally OUTSIDE the header slot grid, not inside it.
|
|
90
|
-
|
|
91
|
-
A) Place content ABOVE/BELOW a fragment (outside it — MOST ROBUST).
|
|
92
|
-
When the user says "add a logo above the heading" in a form, place a new node
|
|
93
|
-
BEFORE the header fragment at the composition root:
|
|
94
|
-
{ "id": "root", "component": "Card", "children": ["brand", "hdr", "sec", "ftr"] }
|
|
95
|
-
{ "id": "brand", "component": "Row", "children": ["logo"], "justify": "center" }
|
|
96
|
-
{ "id": "logo", "component": "Image", "src": "/logo.svg", "alt": "Brand" }
|
|
97
|
-
{ "id": "hdr", "$fragment": "card-header-with-description", "bindings": { … } }
|
|
98
|
-
⚠️ BUT: Card direct children are restricted to <header>, <section>, <footer>.
|
|
99
|
-
For logos above the header, prefer technique B (inline the header) OR slot
|
|
100
|
-
the logo into the header with slot="icon" via technique C.
|
|
101
|
-
|
|
102
|
-
B) Inject extra children INTO a fragment's root WITH a slot declaration.
|
|
103
|
-
A $fragment node can declare prependChildren or appendChildren (arrays of node
|
|
104
|
-
ids). Injected children should declare slot="…" when the host uses slot-grid
|
|
105
|
-
layout. Example — add a logo to a card-header:
|
|
106
|
-
{ "id": "hdr", "$fragment": "card-header-with-description",
|
|
107
|
-
"bindings": { "heading": "Sign in", "description": "..." },
|
|
108
|
-
"prependChildren": ["logo"] }
|
|
109
|
-
{ "id": "logo", "component": "Image", "src": "/logo.svg", "alt": "Brand",
|
|
110
|
-
"slot": "icon" }
|
|
111
|
-
Common host slots:
|
|
112
|
-
- Card <header>: icon | heading | description | action
|
|
113
|
-
- Card <section>: (default)
|
|
114
|
-
- Card <footer>: (default)
|
|
115
|
-
Without a slot attribute, children flow to the default slot and may not lay
|
|
116
|
-
out as expected on slot-grid hosts.
|
|
117
|
-
|
|
118
|
-
C) Inline the fragment (when you need to edit INTERIOR nodes or restructure the
|
|
119
|
-
layout). Copy the fragment's own template nodes into your composition with
|
|
120
|
-
fresh ids. Bindings and slot indirection go away. Each fragment in the catalog
|
|
121
|
-
below shows its internal template so you know what to copy. Inlined nodes lose
|
|
122
|
-
reuse; use only when (A) and (B) can't solve the problem.
|
|
123
|
-
|
|
124
|
-
D) Compose fragments side by side (two fragments together make the shape).
|
|
125
|
-
Place multiple $fragment refs as siblings inside a layout node.
|
|
126
|
-
|
|
127
|
-
When HISTORY is provided, the user is building on PRIOR turns. You MUST:
|
|
128
|
-
- Treat the latest turn's template as the starting point.
|
|
129
|
-
- Apply the user's new instruction as a MODIFICATION to that template (add, remove, replace, or edit bindings on existing nodes).
|
|
130
|
-
- Preserve node ids from the prior template wherever possible so the canvas edits look incremental, not regenerated from scratch.
|
|
131
|
-
- For additive requests inside a fragment ("add X"), prefer technique (B) WITH a slot declaration matching the host's slot vocabulary. If the host has no matching slot, fall back to (C) inlining.
|
|
132
|
-
- For structural/interior edits, use technique (C) inlining.
|
|
133
|
-
- Only generate a fully fresh template if the new intent is clearly a topic change.
|
|
134
|
-
|
|
135
|
-
Return ONLY a JSON object: { "template": [...nodes] }. No prose, no markdown fences.`;
|
|
136
|
-
|
|
137
|
-
function buildUserPrompt(intent, fragmentCatalog, examples, historySummary = null) {
|
|
138
|
-
const fragLines = fragmentCatalog.map((f) => {
|
|
139
|
-
const slotStr = f.slots.length
|
|
140
|
-
? f.slots.map((s) => `${s.name}${s.required ? '*' : ''}`).join(', ')
|
|
141
|
-
: '(no slots)';
|
|
142
|
-
// Compact template skeleton for technique (B) — ids + component types only
|
|
143
|
-
const tpl = (f.template || []).map((n) => {
|
|
144
|
-
const bits = { id: n.id, component: n.component };
|
|
145
|
-
if (n.children) bits.children = n.children;
|
|
146
|
-
return bits;
|
|
147
|
-
});
|
|
148
|
-
return ` - ${f.name} [${f.semantic_role}] — ${f.description}
|
|
149
|
-
slots: ${slotStr}
|
|
150
|
-
template: ${JSON.stringify(tpl)}`;
|
|
151
|
-
}).join('\n');
|
|
152
|
-
|
|
153
|
-
const exLines = examples.map((ex) => {
|
|
154
|
-
return `--- example: ${ex.name} (${ex.domain}) ---\n${ex.description}\n${JSON.stringify({ template: ex.template }, null, 2)}`;
|
|
155
|
-
}).join('\n\n');
|
|
156
|
-
|
|
157
|
-
const historyBlock = historySummary
|
|
158
|
-
? `\nHISTORY (most recent turn last — build on this):\n${historySummary}\n\n`
|
|
159
|
-
: '\n';
|
|
160
|
-
|
|
161
|
-
return `INTENT: ${intent}
|
|
162
|
-
${historyBlock}FRAGMENT CATALOG:
|
|
163
|
-
${fragLines}
|
|
164
|
-
|
|
165
|
-
EXAMPLES:
|
|
166
|
-
${exLines}
|
|
167
|
-
|
|
168
|
-
Compose a UI for the intent. If HISTORY is present, build on the latest turn's template. Return { "template": [...] } only.`;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Extract the FIRST top-level JSON object from a possibly-messy LLM response.
|
|
173
|
-
* Handles markdown fences and leading/trailing prose.
|
|
174
|
-
*/
|
|
175
|
-
function extractJSON(text) {
|
|
176
|
-
if (!text) return null;
|
|
177
|
-
// Strip markdown fences
|
|
178
|
-
let t = text.trim();
|
|
179
|
-
const fence = t.match(/^```(?:json)?\s*([\s\S]*?)```$/m);
|
|
180
|
-
if (fence) t = fence[1].trim();
|
|
181
|
-
// Find first { and its matching }
|
|
182
|
-
const start = t.indexOf('{');
|
|
183
|
-
if (start === -1) return null;
|
|
184
|
-
let depth = 0;
|
|
185
|
-
let inStr = false;
|
|
186
|
-
let esc = false;
|
|
187
|
-
for (let i = start; i < t.length; i++) {
|
|
188
|
-
const c = t[i];
|
|
189
|
-
if (esc) { esc = false; continue; }
|
|
190
|
-
if (c === '\\') { esc = true; continue; }
|
|
191
|
-
if (c === '"') { inStr = !inStr; continue; }
|
|
192
|
-
if (inStr) continue;
|
|
193
|
-
if (c === '{') depth++;
|
|
194
|
-
else if (c === '}') {
|
|
195
|
-
depth--;
|
|
196
|
-
if (depth === 0) {
|
|
197
|
-
try { return JSON.parse(t.slice(start, i + 1)); } catch { return null; }
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Validate a synthesized template before we try to resolve it. Returns
|
|
206
|
-
* { ok: true, errors: [] } or { ok: false, errors: [msg, ...] }.
|
|
207
|
-
*/
|
|
208
|
-
function validateSynthesis(template, fragmentsByName) {
|
|
209
|
-
const errors = [];
|
|
210
|
-
if (!Array.isArray(template) || template.length === 0) {
|
|
211
|
-
return { ok: false, errors: ['template is empty or not an array'] };
|
|
212
|
-
}
|
|
213
|
-
const root = template.find((n) => n.id === 'root');
|
|
214
|
-
if (!root) errors.push('no node with id "root"');
|
|
215
|
-
|
|
216
|
-
const ids = new Set(template.map((n) => n.id).filter(Boolean));
|
|
217
|
-
for (const node of template) {
|
|
218
|
-
if (!node.id) errors.push('node is missing id');
|
|
219
|
-
if (node.$fragment) {
|
|
220
|
-
const frag = fragmentsByName.get(node.$fragment);
|
|
221
|
-
if (!frag) {
|
|
222
|
-
errors.push(`unknown $fragment: ${node.$fragment} (node ${node.id})`);
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
const bindings = node.bindings || {};
|
|
226
|
-
for (const slot of frag.slots || []) {
|
|
227
|
-
if (slot.required && !(slot.name in bindings) && slot.defaultValue === undefined) {
|
|
228
|
-
errors.push(`missing required binding "${slot.name}" on fragment ${node.$fragment} (node ${node.id})`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
} else if (node.component) {
|
|
232
|
-
// Inline node — children refs must resolve
|
|
233
|
-
const childArrays = [
|
|
234
|
-
node.children,
|
|
235
|
-
];
|
|
236
|
-
for (const arr of childArrays) {
|
|
237
|
-
if (Array.isArray(arr)) {
|
|
238
|
-
for (const c of arr) {
|
|
239
|
-
if (typeof c === 'string' && !ids.has(c)) {
|
|
240
|
-
errors.push(`child "${c}" on node "${node.id}" does not resolve`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
} else {
|
|
246
|
-
errors.push(`node "${node.id}" has neither $fragment nor component`);
|
|
247
|
-
}
|
|
248
|
-
// prependChildren / appendChildren on fragment refs must resolve too
|
|
249
|
-
for (const key of ['prependChildren', 'appendChildren']) {
|
|
250
|
-
const arr = node[key];
|
|
251
|
-
if (!Array.isArray(arr)) continue;
|
|
252
|
-
for (const c of arr) {
|
|
253
|
-
if (typeof c === 'string' && !ids.has(c)) {
|
|
254
|
-
errors.push(`${key} ref "${c}" on node "${node.id}" does not resolve`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
return { ok: errors.length === 0, errors };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Main entry: synthesize a composition from an intent using an LLM.
|
|
265
|
-
*
|
|
266
|
-
* @param {object} opts
|
|
267
|
-
* @param {string} opts.intent
|
|
268
|
-
* @param {object} opts.llmAdapter — must expose `complete({ messages, systemPrompt })`
|
|
269
|
-
* @param {string} [opts.historySummary] — optional prior-turn context (see session-store.js)
|
|
270
|
-
* @param {number} [opts.maxAttempts=2] — retry with feedback on validation failure
|
|
271
|
-
* @returns {Promise<{ template: Array, messages: Array, synthesis: object }>}
|
|
272
|
-
* `synthesis` contains llmRawResponse, attempts, finalValidation for debugging.
|
|
10
|
+
* Until a chunk-based synthesis fallback lands, this export throws on
|
|
11
|
+
* call. Callers should catch and fall through to monolithic-pro's
|
|
12
|
+
* generation path. generator-adapter.js handles this gracefully.
|
|
273
13
|
*/
|
|
274
|
-
export async function synthesizeComposition({ intent, llmAdapter, historySummary = null, maxAttempts = 3 }) {
|
|
275
|
-
if (!llmAdapter?.complete) {
|
|
276
|
-
throw new Error('synthesizeComposition requires an llmAdapter with .complete()');
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const fragmentCatalog = buildFragmentCatalog();
|
|
280
|
-
const fragmentsByName = new Map(getAllFragments().map((f) => [f.name, f]));
|
|
281
|
-
const examples = buildExamples(3);
|
|
282
|
-
|
|
283
|
-
const userPrompt = buildUserPrompt(intent, fragmentCatalog, examples, historySummary);
|
|
284
|
-
let lastError = null;
|
|
285
|
-
const attempts = [];
|
|
286
|
-
|
|
287
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
288
|
-
const retryNudge = lastError
|
|
289
|
-
? `\n\nPREVIOUS ATTEMPT FAILED: ${lastError}. Return ONLY a JSON object shaped as { "template": [...] }. No prose, no questions, no clarifications — just JSON.`
|
|
290
|
-
: '';
|
|
291
|
-
const messages = [{ role: 'user', content: userPrompt + retryNudge }];
|
|
292
|
-
const response = await llmAdapter.complete({ messages, systemPrompt: SYSTEM_PROMPT });
|
|
293
|
-
const raw = response?.content || response?.text || (typeof response === 'string' ? response : '');
|
|
294
|
-
attempts.push({ attempt: i + 1, raw });
|
|
295
|
-
|
|
296
|
-
const parsed = extractJSON(raw);
|
|
297
|
-
if (!parsed || !Array.isArray(parsed.template)) {
|
|
298
|
-
// Detect prose / clarification-style responses specifically
|
|
299
|
-
const isProse = /^(I['’]ll |Sure|Certainly|To do this|Here's)/i.test(raw.trim());
|
|
300
|
-
lastError = isProse
|
|
301
|
-
? 'Response was conversational prose, not JSON. Do not ask questions. Emit the composition directly with your best-guess defaults.'
|
|
302
|
-
: 'Response was not valid JSON with a template array.';
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const { ok, errors } = validateSynthesis(parsed.template, fragmentsByName);
|
|
307
|
-
if (!ok) {
|
|
308
|
-
lastError = errors.slice(0, 3).join('; ');
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Resolve via the composer
|
|
313
|
-
try {
|
|
314
|
-
const resolved = resolveComposition({ template: parsed.template });
|
|
315
|
-
const messagesOut = [{
|
|
316
|
-
type: 'updateComponents',
|
|
317
|
-
components: resolved.map((n) => {
|
|
318
|
-
const {
|
|
319
|
-
id, component, children,
|
|
320
|
-
$fragment, bindings,
|
|
321
|
-
prependChildren, appendChildren,
|
|
322
|
-
...rest
|
|
323
|
-
} = n;
|
|
324
|
-
return { id, component, children: children || [], ...rest };
|
|
325
|
-
}),
|
|
326
|
-
}];
|
|
327
|
-
return {
|
|
328
|
-
template: parsed.template,
|
|
329
|
-
messages: messagesOut,
|
|
330
|
-
synthesis: {
|
|
331
|
-
attempts: i + 1,
|
|
332
|
-
attemptsLog: attempts,
|
|
333
|
-
validation: { ok: true, errors: [] },
|
|
334
|
-
usedHistory: !!historySummary,
|
|
335
|
-
},
|
|
336
|
-
};
|
|
337
|
-
} catch (e) {
|
|
338
|
-
lastError = `Composer failed to resolve: ${e.message}`;
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
14
|
|
|
342
|
-
|
|
15
|
+
export async function synthesizeComposition() {
|
|
16
|
+
const err = new Error(
|
|
17
|
+
'synthesizeComposition is retired (§37 fragment retirement). ' +
|
|
18
|
+
'Use chunk-zettel or monolithic-pro for LLM-driven composition.'
|
|
19
|
+
);
|
|
20
|
+
err.code = 'SYNTHESIZER_RETIRED';
|
|
21
|
+
throw err;
|
|
343
22
|
}
|
|
@@ -68,7 +68,7 @@ export const HTML_TAG_MAP = {
|
|
|
68
68
|
summary: { type: 'Text', props: { variant: 'h5' } },
|
|
69
69
|
dialog: { type: 'Modal' },
|
|
70
70
|
hr: { type: 'Divider' },
|
|
71
|
-
a: { type: '
|
|
71
|
+
a: { type: 'Link' }, // §47 — was: Button + variant=ghost (button.yaml line 60: "for inline navigation use `<link-ui>` instead")
|
|
72
72
|
header: { type: 'Header' },
|
|
73
73
|
footer: { type: 'Footer' },
|
|
74
74
|
section: { type: 'Section' },
|
|
@@ -163,11 +163,18 @@ export function extractProps(el, a2uiType) {
|
|
|
163
163
|
else if (cls.includes('danger')) props.variant = 'danger';
|
|
164
164
|
else if (cls.includes('ghost')) props.variant = 'ghost';
|
|
165
165
|
else if (cls.includes('outline')) props.variant = 'outline';
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Link ── (§47 — <a> tags map to Link, not Button; carries href, target, rel)
|
|
169
|
+
if (a2uiType === 'Link') {
|
|
170
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
171
|
+
if (text) props.text = text;
|
|
172
|
+
const href = attr('href');
|
|
173
|
+
if (href) props.href = href;
|
|
174
|
+
const target = attr('target');
|
|
175
|
+
if (target) props.target = target;
|
|
176
|
+
const rel = attr('rel');
|
|
177
|
+
if (rel) props.rel = rel;
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
// ── TextField / Input ──
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fragment Library — zettel-style loader for A2UI fragments.
|
|
3
|
-
*
|
|
4
|
-
* Loads all /a2ui/fragments/**\/*.json into memory, builds a backlink graph,
|
|
5
|
-
* and exposes typed retrieval helpers. Fragments ARE A2UI chunks — the library
|
|
6
|
-
* preserves their flat-adjacency template verbatim.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import url from 'node:url';
|
|
12
|
-
|
|
13
|
-
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
14
|
-
const FRAGMENTS_ROOT = path.resolve(__dirname, '../../../corpus/fragments');
|
|
15
|
-
const COMPOSITIONS_ROOT = path.resolve(__dirname, '../../../corpus/compositions');
|
|
16
|
-
|
|
17
|
-
/** @type {Map<string, object>} */
|
|
18
|
-
const fragments = new Map();
|
|
19
|
-
/** @type {Map<string, object>} */
|
|
20
|
-
const compositions = new Map();
|
|
21
|
-
|
|
22
|
-
function walk(dir, cb) {
|
|
23
|
-
if (!fs.existsSync(dir)) return;
|
|
24
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
-
const p = path.join(dir, entry.name);
|
|
26
|
-
if (entry.isDirectory()) walk(p, cb);
|
|
27
|
-
else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function loadAll() {
|
|
32
|
-
fragments.clear();
|
|
33
|
-
compositions.clear();
|
|
34
|
-
|
|
35
|
-
walk(FRAGMENTS_ROOT, (p) => {
|
|
36
|
-
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
37
|
-
if (doc.kind !== 'fragment') return;
|
|
38
|
-
doc._sourceFile = p;
|
|
39
|
-
fragments.set(doc.name, doc);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
walk(COMPOSITIONS_ROOT, (p) => {
|
|
43
|
-
const doc = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
44
|
-
if (doc.kind !== 'composition') return;
|
|
45
|
-
doc._sourceFile = p;
|
|
46
|
-
compositions.set(doc.name, doc);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
buildBacklinks();
|
|
50
|
-
return { fragmentCount: fragments.size, compositionCount: compositions.size };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Walk every composition and every fragment, populate `used_by` backlinks. */
|
|
54
|
-
function buildBacklinks() {
|
|
55
|
-
for (const f of fragments.values()) {
|
|
56
|
-
f.links = f.links || {};
|
|
57
|
-
f.links.used_by = [];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// compositions -> fragments they reference
|
|
61
|
-
for (const comp of compositions.values()) {
|
|
62
|
-
for (const node of comp.template || []) {
|
|
63
|
-
if (node.$fragment) {
|
|
64
|
-
const frag = fragments.get(node.$fragment);
|
|
65
|
-
if (frag) frag.links.used_by.push({ type: 'composition', name: comp.name });
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// fragments -> other fragments they embed (future: nested fragment refs)
|
|
71
|
-
for (const f of fragments.values()) {
|
|
72
|
-
for (const node of f.template || []) {
|
|
73
|
-
if (node.$fragment) {
|
|
74
|
-
const target = fragments.get(node.$fragment);
|
|
75
|
-
if (target) target.links.used_by.push({ type: 'fragment', name: f.name });
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function getFragment(name) { return fragments.get(name); }
|
|
82
|
-
export function getComposition(name) { return compositions.get(name); }
|
|
83
|
-
export function getAllFragments() { return [...fragments.values()]; }
|
|
84
|
-
export function getAllCompositions() { return [...compositions.values()]; }
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Keyword search across fragments AND compositions.
|
|
88
|
-
*
|
|
89
|
-
* Ranking design (v2 — fixes false-positive matching):
|
|
90
|
-
* 1. Tokenize query, drop generic UI stop-words ("form", "page", "view"…)
|
|
91
|
-
* so they can't be the sole evidence for a match.
|
|
92
|
-
* 2. Whole-word match, not substring — prevents "form" matching "login-form".
|
|
93
|
-
* 3. Require ≥2 content-token hits for compositions (they're concrete
|
|
94
|
-
* templates — promiscuous matching is expensive). Fragments can match on 1
|
|
95
|
-
* content token since they're meant to be widely reused.
|
|
96
|
-
* 4. Tiered weights: name hit > keyword hit > semantic_role hit > description.
|
|
97
|
-
* 5. Small leverage bias for fragments (reused fragments are more likely
|
|
98
|
-
* relevant when multiple candidates tie).
|
|
99
|
-
*/
|
|
100
|
-
const STOP_WORDS = new Set([
|
|
101
|
-
'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'for', 'with',
|
|
102
|
-
'form', 'forms', 'page', 'pages', 'view', 'views', 'screen', 'screens',
|
|
103
|
-
'ui', 'component', 'components', 'widget', 'widgets', 'block', 'blocks',
|
|
104
|
-
'section', 'sections', 'layout', 'layouts',
|
|
105
|
-
]);
|
|
106
|
-
|
|
107
|
-
function tokenize(str) {
|
|
108
|
-
return (str || '')
|
|
109
|
-
.toLowerCase()
|
|
110
|
-
.split(/[^a-z0-9]+/)
|
|
111
|
-
.filter((t) => t.length >= 2);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function contentTokens(str) {
|
|
115
|
-
return tokenize(str).filter((t) => !STOP_WORDS.has(t));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function searchAll(query, { limit = 20 } = {}) {
|
|
119
|
-
const q = (query || '').trim();
|
|
120
|
-
if (!q) return [];
|
|
121
|
-
const queryTokens = tokenize(q);
|
|
122
|
-
const queryContent = queryTokens.filter((t) => !STOP_WORDS.has(t));
|
|
123
|
-
|
|
124
|
-
const scoreDoc = (doc, isComposition) => {
|
|
125
|
-
const nameTokens = new Set(tokenize(doc.name));
|
|
126
|
-
const roleTokens = new Set(tokenize(doc.semantic_role || ''));
|
|
127
|
-
const keywordTokens = new Set((doc.keywords || []).flatMap((k) => tokenize(k)));
|
|
128
|
-
const descTokens = new Set(tokenize(doc.description || ''));
|
|
129
|
-
const tagTokens = new Set(
|
|
130
|
-
Object.values(doc.tags || {}).flat().flatMap((v) => tokenize(String(v))),
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
let contentHits = 0; // distinct non-stop-word tokens that matched anywhere
|
|
134
|
-
let s = 0;
|
|
135
|
-
const contentMatched = new Set();
|
|
136
|
-
|
|
137
|
-
for (const t of queryTokens) {
|
|
138
|
-
const isStop = STOP_WORDS.has(t);
|
|
139
|
-
let matched = false;
|
|
140
|
-
if (nameTokens.has(t)) { s += isStop ? 1 : 12; matched = true; }
|
|
141
|
-
if (keywordTokens.has(t)) { s += isStop ? 1 : 8; matched = true; }
|
|
142
|
-
if (roleTokens.has(t)) { s += isStop ? 1 : 6; matched = true; }
|
|
143
|
-
if (tagTokens.has(t)) { s += isStop ? 0 : 3; matched = true; }
|
|
144
|
-
if (descTokens.has(t)) { s += isStop ? 0 : 2; matched = true; }
|
|
145
|
-
if (matched && !isStop) {
|
|
146
|
-
contentMatched.add(t);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
contentHits = contentMatched.size;
|
|
150
|
-
|
|
151
|
-
// Gate: compositions normally require ≥2 content-token hits to avoid
|
|
152
|
-
// noisy matches. Exception: a direct name-token match (the query contains
|
|
153
|
-
// the composition's primary concept word, e.g. "weather" → weather-widget)
|
|
154
|
-
// is strong enough on its own.
|
|
155
|
-
const nameHit = queryTokens.some((t) => !STOP_WORDS.has(t) && nameTokens.has(t));
|
|
156
|
-
if (isComposition && contentHits < 2 && !nameHit) return 0;
|
|
157
|
-
// Fragments require ≥1 content-token hit — never match on stop-words alone.
|
|
158
|
-
if (!isComposition && contentHits < 1) return 0;
|
|
159
|
-
|
|
160
|
-
// Coverage bonus: more distinct content tokens matched = better match
|
|
161
|
-
s += contentHits * 3;
|
|
162
|
-
|
|
163
|
-
// Small leverage bias for fragments (reused ones slightly preferred on ties)
|
|
164
|
-
if (!isComposition && doc.metrics?.leverage) {
|
|
165
|
-
s += Math.min(doc.metrics.leverage / 20, 2);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return s;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const all = [
|
|
172
|
-
...getAllFragments().map((d) => ({ doc: d, type: 'fragment' })),
|
|
173
|
-
...getAllCompositions().map((d) => ({ doc: d, type: 'composition' })),
|
|
174
|
-
];
|
|
175
|
-
|
|
176
|
-
return all
|
|
177
|
-
.map((x) => ({ ...x, score: scoreDoc(x.doc, x.type === 'composition') }))
|
|
178
|
-
.filter((x) => x.score > 0)
|
|
179
|
-
.sort((a, b) => b.score - a.score)
|
|
180
|
-
.slice(0, limit)
|
|
181
|
-
.map((x) => ({
|
|
182
|
-
type: x.type,
|
|
183
|
-
name: x.doc.name,
|
|
184
|
-
score: Math.round(x.score * 100) / 100,
|
|
185
|
-
description: x.doc.description,
|
|
186
|
-
semantic_role: x.doc.semantic_role,
|
|
187
|
-
domain: x.doc.domain,
|
|
188
|
-
}));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Return the full graph for inspection / visualization. */
|
|
192
|
-
export function getGraph() {
|
|
193
|
-
return {
|
|
194
|
-
fragments: getAllFragments().map((f) => ({
|
|
195
|
-
name: f.name,
|
|
196
|
-
role: f.semantic_role,
|
|
197
|
-
uses_components: f.links?.uses_components || [],
|
|
198
|
-
used_by: f.links?.used_by || [],
|
|
199
|
-
leverage: f.metrics?.leverage || 0,
|
|
200
|
-
})),
|
|
201
|
-
compositions: getAllCompositions().map((c) => ({
|
|
202
|
-
name: c.name,
|
|
203
|
-
domain: c.domain,
|
|
204
|
-
uses_fragments: (c.template || [])
|
|
205
|
-
.filter((n) => n.$fragment)
|
|
206
|
-
.map((n) => n.$fragment),
|
|
207
|
-
})),
|
|
208
|
-
};
|
|
209
|
-
}
|