sygnal 6.1.1 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +100 -0
- package/README.md +3 -1
- package/dist/ai.cjs.js +36 -0
- package/dist/ai.cjs.js.map +1 -1
- package/dist/ai.esm.js +1 -1
- package/dist/astro/server.cjs.js +3 -3
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3 -3
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +174 -6
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +174 -6
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/adapters.md +2 -1
- package/dist/guide/agent.md +119 -7
- package/dist/guide/ai-cards.md +319 -0
- package/dist/guide/ai-chat.md +64 -16
- package/dist/guide/ai-compose.md +881 -0
- package/dist/guide/ai-decisions.md +17 -1
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/error-boundaries.md +2 -1
- package/dist/guide/forms.md +1 -1
- package/dist/guide/http.md +5 -4
- package/dist/guide/mcp-apps.md +4 -2
- package/dist/guide/persistence.md +1 -1
- package/dist/guide/recipes/ai-form-fill.md +1 -1
- package/dist/guide/recipes/ai-summarize.md +1 -1
- package/dist/guide/recipes/ai-support-inbox.md +1 -1
- package/dist/guide/resources.md +11 -10
- package/dist/guide/ui/combobox.md +1 -1
- package/dist/guide/undo.md +1 -1
- package/dist/guide/web-components.md +1 -0
- package/dist/guide/webmcp.md +1 -2
- package/dist/index.cjs.js +5842 -370
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +679 -13
- package/dist/index.esm.js +5832 -370
- package/dist/index.esm.js.map +1 -1
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/vike/config/package.json +1 -1
- package/dist/vike/onRenderClient.cjs.js +3 -2
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +3 -2
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/llms.txt +8 -6
- package/package.json +2 -1
- package/src/ai.d.ts +683 -12
- package/src/ai.ts +6 -0
- package/src/collection.ts +2 -1
- package/src/core/instance.ts +6 -3
- package/src/core/markers/lazy.ts +7 -3
- package/src/core/markers/portal.ts +5 -2
- package/src/core/markers/suspense.ts +3 -2
- package/src/core/markers/transition.ts +6 -3
- package/src/core/registry.ts +9 -5
- package/src/cycle/dom/IsolateModule.ts +25 -22
- package/src/extra/ai/agent/index.ts +111 -26
- package/src/extra/ai/chat/behavior.ts +368 -87
- package/src/extra/ai/chat/driver.ts +7 -4
- package/src/extra/ai/compose/agent.ts +55 -0
- package/src/extra/ai/compose/ask.ts +147 -0
- package/src/extra/ai/compose/behavior.ts +183 -0
- package/src/extra/ai/compose/catalog.ts +211 -0
- package/src/extra/ai/compose/composed.ts +61 -0
- package/src/extra/ai/compose/decisions.ts +1649 -0
- package/src/extra/ai/compose/diag.ts +33 -0
- package/src/extra/ai/compose/index.ts +23 -0
- package/src/extra/ai/compose/layout.ts +441 -0
- package/src/extra/ai/compose/llm.ts +1120 -0
- package/src/extra/ai/compose/render.ts +159 -0
- package/src/extra/ai/compose/testing.ts +71 -0
- package/src/extra/ai/compose/types.ts +159 -0
- package/src/extra/ai/decide.ts +97 -1
- package/src/extra/ai/index.ts +6 -1
- package/src/extra/ai/webmcp.ts +5 -3
- package/src/extra/diagnostics/checks/next.ts +86 -2
- package/src/extra/diagnostics/codes.ts +64 -3
- package/src/extra/fetchDriver.ts +58 -27
- package/src/extra/lens.ts +15 -0
- package/src/extra/ssr.ts +3 -3
- package/src/extra/testing.ts +2 -0
- package/src/extra/widget.ts +3 -3
- package/src/index.d.ts +42 -1
- package/src/index.ts +8 -0
- package/src/lazy.ts +3 -2
- package/src/portal.ts +5 -2
- package/src/pragma/index.ts +2 -1
- package/src/suspense.ts +5 -2
- package/src/switchable.ts +2 -1
- package/src/transition.ts +5 -2
- package/src/vike/onRenderClient.ts +3 -2
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 B-3: compose.llm(), the GenAI LLM backend of the `compose` behavior (phase-1 contract,
|
|
3
|
+
* dev-plans/research/ai-compose/phase1-contract.md). A port of the research's recommended path
|
|
4
|
+
* (dev-plans/research/ai-compose/llm/llm-composer.mjs, REPORT.md §1–§6, §9) with its exact
|
|
5
|
+
* prompt and schema, onto the normalized catalog:
|
|
6
|
+
*
|
|
7
|
+
* - start(): ONE chat request with structured output: the `tree+plan` schema (an `asked_for`
|
|
8
|
+
* list, then a nested tree: per place and depth an `anyOf` of one closed object per part, enum
|
|
9
|
+
* params, record enums from state, `in`, rows (leaves only, at most 4) and `maxDepth` (types.ts:
|
|
10
|
+
* a container at depth d, the page's children at 1, only when d <= maxDepth) in the
|
|
11
|
+
* grammar) and the system prompt (the catalog as text, the rules, the catalog's `guidance`,
|
|
12
|
+
* its `examples` as few-shot turns). `delta` when streaming.
|
|
13
|
+
* - delta(): a preview of the complete nodes so far (a leaf shows once its object closes, so its
|
|
14
|
+
* params are final; containers show what they hold). Keys are identities (fromTree), so a part
|
|
15
|
+
* keeps its key as the reply grows.
|
|
16
|
+
* - reply(): read (lenient: `type` / `component` / `name` for `part`, flat params, the list under
|
|
17
|
+
* another key), repair (unknown parts dropped, duplicates beyond `maxUses`, one per `group`,
|
|
18
|
+
* bad params → default, text cut to `maxLength`, unknown slots → default, containers too deep
|
|
19
|
+
* or in a row lifted, a row of one unwrapped, empty containers dropped), then a schema check
|
|
20
|
+
* of the repaired tree, fromTree, finish. An empty tree is `unavailable`; a reply that still
|
|
21
|
+
* violates the schema is `{ error }` (the behavior's status 'error') and SYG683, never a layout.
|
|
22
|
+
* - fake(): the JSON text of a target tree in this schema (composeWith).
|
|
23
|
+
* - Requests carry `parse: false` (the chat driver sends the schema, reply() reads the text), so
|
|
24
|
+
* prose or a code fence around the JSON is repaired and a reply with no JSON at all is
|
|
25
|
+
* `{ error }` + SYG683 rather than the driver's FAILED.
|
|
26
|
+
* - B-4: with a `previous` layout that has nodes, start() sends an edit instead (ops2, the section
|
|
27
|
+
* "B-4: edits" below); fake() then returns the operations that make the target.
|
|
28
|
+
* Pure apart from SYG683's report.
|
|
29
|
+
*/
|
|
30
|
+
import {jsonSchema, check} from '../schema/jsonSchema'
|
|
31
|
+
import {fromTree, finish, identityOf, toTree} from './layout'
|
|
32
|
+
import {report} from '../../diagnostics/index'
|
|
33
|
+
|
|
34
|
+
export interface LlmOptions {
|
|
35
|
+
/** the model id (the transport's default otherwise), or a function of the host's state that returns it (per request) */
|
|
36
|
+
model?: string | ((state: any) => string | undefined)
|
|
37
|
+
/** the chat driver's sink (default 'LLM') */
|
|
38
|
+
sink?: string
|
|
39
|
+
/** the request key (default: the behavior's key) */
|
|
40
|
+
key?: string
|
|
41
|
+
/** stream a preview through `delta` (default true) */
|
|
42
|
+
stream?: boolean
|
|
43
|
+
/** the `asked_for` plan field (default true; false when there are examples) */
|
|
44
|
+
plan?: boolean
|
|
45
|
+
/** more instructions after the rules, or a function of the system prompt that returns the one to send */
|
|
46
|
+
instructions?: string | ((prompt: string) => string)
|
|
47
|
+
/** few-shot examples: [prompt, tree, asked_for?] or { prompt, tree, asked_for? }; default: the catalog's */
|
|
48
|
+
examples?: any[]
|
|
49
|
+
/** anything else goes on the request as is (transport options: `body`, `coalesce`, ...) */
|
|
50
|
+
[option: string]: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Issue = {kind: string; detail: string}
|
|
54
|
+
type TNode = {part: string; params?: Record<string, any>; slots?: Record<string, TNode[]>}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------- the catalog, as the model sees it
|
|
57
|
+
const ROOT = 'page'
|
|
58
|
+
const PLACE = ROOT + ':default'
|
|
59
|
+
/** a param's kind (types.ts' normalized `kind`, 'options' read as 'enum'; a raw declaration reads the same) */
|
|
60
|
+
const kindOf = (d: any) => {
|
|
61
|
+
if (!d || typeof d != 'object') return ''
|
|
62
|
+
const k = d.kind ?? (typeof d.from == 'function' ? 'record' : d.options ? 'options' : d.type)
|
|
63
|
+
return k == 'options' ? 'enum' : k == 'record' || k == 'number' || k == 'list' || k == 'text' ? k : ''
|
|
64
|
+
}
|
|
65
|
+
const labelOf = (d: any) => d.label ?? d.description
|
|
66
|
+
|
|
67
|
+
function records(d: any, state: any): any[] {
|
|
68
|
+
let r: any
|
|
69
|
+
try { r = d.from(state) } catch (_) { return [] }
|
|
70
|
+
return Array.isArray(r) ? r.filter(x => x && (typeof x.value == 'string' || typeof x.value == 'number')) : []
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** the choosable items (parts, then containers; required parts are added by finish, not asked) */
|
|
74
|
+
function context(catalog: any, state: any) {
|
|
75
|
+
const items: any[] = []
|
|
76
|
+
const byId: Record<string, any> = {}
|
|
77
|
+
const add = (id: string, def: any, container: boolean) => {
|
|
78
|
+
const params: any[] = []
|
|
79
|
+
let rec: string | undefined
|
|
80
|
+
for (const [name, d] of Object.entries<any>(def.params || {})) {
|
|
81
|
+
const kind = kindOf(d)
|
|
82
|
+
if (!kind) continue
|
|
83
|
+
const p: any = {name, d, kind}
|
|
84
|
+
if (kind == 'record') {
|
|
85
|
+
p.records = records(d, state)
|
|
86
|
+
if (!p.records.length) return // nothing to show: not choosable now
|
|
87
|
+
rec = name
|
|
88
|
+
}
|
|
89
|
+
params.push(p)
|
|
90
|
+
}
|
|
91
|
+
const row = container && (def.direction == 'row' || id == 'row')
|
|
92
|
+
const slots = container ? Object.keys(def.slots || {default: null}) : []
|
|
93
|
+
// leaves: holds parts only (a row; or a container declared `accepts: 'leaves'`)
|
|
94
|
+
const it = {id, def, container, row, leaves: row || def.accepts == 'leaves', params, rec, slots,
|
|
95
|
+
min: def.min ?? (row ? 2 : 0), max: def.max ?? (row ? 4 : undefined), uses: def.maxUses ?? 1}
|
|
96
|
+
items.push(it)
|
|
97
|
+
byId[id] = it
|
|
98
|
+
}
|
|
99
|
+
for (const [id, def] of Object.entries<any>(catalog.parts || {})) if (!def.required) add(id, def, false)
|
|
100
|
+
for (const [id, def] of Object.entries<any>(catalog.containers || {})) if (id != ROOT) add(id, def, true)
|
|
101
|
+
const required = new Set(Object.keys(catalog.parts || {}).filter(id => catalog.parts[id].required))
|
|
102
|
+
return {items, byId, required, root: catalog.containers?.[ROOT] || {}, maxDepth: catalog.limits?.maxDepth ?? 4, maxParts: catalog.limits?.maxParts ?? 12}
|
|
103
|
+
}
|
|
104
|
+
type Ctx = ReturnType<typeof context>
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* the places `it` may go. The normalized catalog lists 'container:slot' entries (types.ts; the root
|
|
108
|
+
* is 'page:default'); a raw `in` may name a container alone. A row's `in` is empty: it goes where its
|
|
109
|
+
* parts may go (never checked here).
|
|
110
|
+
*/
|
|
111
|
+
const placesOf = (it: any): string[] => [].concat(it.def.in ?? [ROOT])
|
|
112
|
+
/** may `it` sit at `place` ('container:slot')? */
|
|
113
|
+
const placeOk = (it: any, place: string) => placesOf(it).some((e: string) => e == place || e == place.split(':')[0])
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* an `asked_for` item's length: 60 (the research's), or the longest text param's. A model copies its
|
|
117
|
+
* plan into the text it writes, so a shorter cap cut a 120-character question at 60 under a
|
|
118
|
+
* grammar that enforces maxLength (Ollama)
|
|
119
|
+
*/
|
|
120
|
+
const askedMax = (ctx: Ctx) => Math.max(60, ...ctx.items.flatMap((it: any) => it.params.filter((p: any) => p.kind == 'text').map((p: any) => p.d.maxLength ?? 40)))
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------- prompt (research wording, exact)
|
|
123
|
+
function paramText(p: any) {
|
|
124
|
+
const {name, d, kind} = p
|
|
125
|
+
const label = labelOf(d)
|
|
126
|
+
const def = d.default !== undefined ? ` (default ${d.default})` : ''
|
|
127
|
+
if (kind == 'enum') return `${name}: ${label ? label + '. ' : ''}one of ${Object.entries(d.options).map(([v, m]) => `${v} = ${m}`).join('; ')}${def}`
|
|
128
|
+
if (kind == 'record') {
|
|
129
|
+
const rs = p.records.map((r: any) => `${r.value} = ${[r.name, r.description].filter(Boolean).join(': ') || r.value}${r.aliases?.length ? ` (also ${r.aliases.join(', ')})` : ''}`)
|
|
130
|
+
return `${name}: ${label || 'which record'}. one of ${rs.join('; ')}${def}`
|
|
131
|
+
}
|
|
132
|
+
if (kind == 'number') {
|
|
133
|
+
const range = d.min != null && d.max != null ? ` from ${d.min} to ${d.max}` : d.min != null ? ` of at least ${d.min}` : d.max != null ? ` of at most ${d.max}` : ''
|
|
134
|
+
const bins = d.bins ? `; ${Object.entries(d.bins).map(([v, m]) => `${v} = ${m}`).join('; ')}` : ''
|
|
135
|
+
return `${name}: ${label ? label + '. ' : ''}a number${range}${bins}${def}`
|
|
136
|
+
}
|
|
137
|
+
if (kind == 'list') return `${name}${label ? `: ${label}` : ''}; a list of ${d.min ? `${d.min} to ` : 'at most '}${d.max ?? 6} short texts`
|
|
138
|
+
return `${name}${label ? `: ${label}` : ''}; short text, at most ${d.maxLength ?? 40} characters${d.default !== undefined ? `; "${d.default}" when the request does not say` : d.required ? '; always set it' : ''}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** ' (only inside tabs or panel slot side)': a container whose every slot is allowed is named alone */
|
|
142
|
+
const onlyIn = (it: any, ctx: Ctx) => {
|
|
143
|
+
const ins = placesOf(it)
|
|
144
|
+
if (!ins.length || ins.some(e => e == ROOT || e == PLACE)) return ''
|
|
145
|
+
const by = new Map<string, string[]>()
|
|
146
|
+
for (const e of ins) {
|
|
147
|
+
const [c, s] = e.split(':')
|
|
148
|
+
if (!by.has(c)) by.set(c, [])
|
|
149
|
+
if (s !== undefined) by.get(c)!.push(s)
|
|
150
|
+
}
|
|
151
|
+
const names = [...by].flatMap(([c, ss]) => {
|
|
152
|
+
const all = ctx.byId[c]?.slots ?? []
|
|
153
|
+
return !ss.length || all.every((x: string) => ss.includes(x)) ? [c] : ss.map(x => `${c} slot ${x}`)
|
|
154
|
+
})
|
|
155
|
+
return ` (only inside ${names.join(' or ')})`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** the catalog as the prompt shows it */
|
|
159
|
+
export function catalogText(ctx: Ctx) {
|
|
160
|
+
const lines = ['Parts (id: what it shows):']
|
|
161
|
+
for (const it of ctx.items.filter(i => !i.container)) {
|
|
162
|
+
lines.push(`- ${it.id}: ${it.def.description ?? it.id}${onlyIn(it, ctx)}`)
|
|
163
|
+
for (const p of it.params) lines.push(` param ${paramText(p)}`)
|
|
164
|
+
}
|
|
165
|
+
const cs = ctx.items.filter(i => i.container)
|
|
166
|
+
if (cs.length) {
|
|
167
|
+
lines.push('', 'Containers (hold other parts):')
|
|
168
|
+
for (const it of cs) {
|
|
169
|
+
const slots = it.slots.map((s: string) => {
|
|
170
|
+
const lim = [it.min && `at least ${it.min}`, it.max && `at most ${it.max}`].filter(Boolean).join(', ')
|
|
171
|
+
const acc = it.leaves ? 'parts only, no containers' : ''
|
|
172
|
+
const desc = it.def.slots?.[s]
|
|
173
|
+
return s == 'default' ? [lim, acc].filter(Boolean).join('; ') : `slot ${s}${desc ? `: ${desc}` : ''}`
|
|
174
|
+
}).filter(Boolean).join('; ')
|
|
175
|
+
const desc = it.def.description ?? (it.row ? 'Row: shows its parts side by side, left to right' : it.id)
|
|
176
|
+
lines.push(`- ${it.id}: ${desc}${slots ? ` (${slots})` : ''}${onlyIn(it, ctx)}`)
|
|
177
|
+
for (const p of it.params) lines.push(` param ${paramText(p)}`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return lines.join('\n')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const RULES = [
|
|
184
|
+
'Use only the parts the request asks for, by name or by what they show. Include every part it asks for. Do not add parts it does not ask for, even related ones.',
|
|
185
|
+
'Use each part at most once.',
|
|
186
|
+
'Order the parts top to bottom as the request says (first, at the top, above, then, below, under, at the bottom). When it does not say, keep the order in which the request mentions them.',
|
|
187
|
+
'Use a container only when the request asks for it (a row only when it wants parts side by side, next to each other or in a row).',
|
|
188
|
+
'Set a param only from what the request says; otherwise use its default.',
|
|
189
|
+
'If nothing in the catalog fits the request, return an empty list.',
|
|
190
|
+
]
|
|
191
|
+
const PLAN = 'First write asked_for: each thing the request asks for, in its own words, one item per thing (an empty list when it asks for nothing the parts show). Then use exactly one part for each item, and no other parts.'
|
|
192
|
+
const SHAPE = `"children": [ {"part": "<id>", "params": {...}}, {"part": "<container id>", "children": [ ... ]} ]} (top to bottom; a container's children in order)`
|
|
193
|
+
|
|
194
|
+
/** the system prompt (`tree`, or `tree+plan` with `plan`) */
|
|
195
|
+
export function systemPrompt(catalog: any, state: any, {plan = true, instructions}: {plan?: boolean; instructions?: LlmOptions['instructions']} = {}, ctx: Ctx = context(catalog, state)) {
|
|
196
|
+
const intro = 'You compose a page of an app from parts the app has prepared. Each part already has its own data and wording; you only choose which parts the request asks for and arrange them.'
|
|
197
|
+
const guidance = [].concat(catalog.guidance ?? []).flatMap((g: string) => String(g).split('\n')).map(s => s.trim()).filter(Boolean)
|
|
198
|
+
const rules = [...RULES, ...guidance.map(g => g.replace(/^[-*]\s*/, ''))]
|
|
199
|
+
if (plan) rules.push(PLAN)
|
|
200
|
+
const named = ctx.items.some(i => i.container && i.slots.some((s: string) => s != 'default'))
|
|
201
|
+
const extra = typeof instructions == 'string' && instructions ? `\n\n${instructions}` : ''
|
|
202
|
+
const prompt = `${intro}\n\n${catalogText(ctx)}\n\nRules:\n${rules.map(r => `- ${r}`).join('\n')}${extra}\n\nReply with JSON only, in this shape:\n` +
|
|
203
|
+
`{${plan ? '"asked_for": ["<thing the request asks for>", ...], ' : ''}${SHAPE}` +
|
|
204
|
+
(named ? '\nA container with named slots lists each slot\'s parts under the slot\'s name instead of "children".' : '')
|
|
205
|
+
return typeof instructions == 'function' ? instructions(prompt) : prompt
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------- schema
|
|
209
|
+
function paramsSchema(it: any) {
|
|
210
|
+
const props: any = {}
|
|
211
|
+
const required: string[] = []
|
|
212
|
+
for (const p of it.params) {
|
|
213
|
+
const d = p.d
|
|
214
|
+
if (p.kind == 'enum') { props[p.name] = {enum: Object.keys(d.options)}; required.push(p.name) }
|
|
215
|
+
else if (p.kind == 'record') { props[p.name] = {enum: p.records.map((r: any) => r.value)}; if (d.default === undefined) required.push(p.name) }
|
|
216
|
+
else if (p.kind == 'number') props[p.name] = {type: 'number', ...(d.min != null && {minimum: d.min}), ...(d.max != null && {maximum: d.max})}
|
|
217
|
+
else if (p.kind == 'list') {
|
|
218
|
+
props[p.name] = {type: 'array', items: {type: 'string', maxLength: d.items?.maxLength ?? 40}, ...(d.min && {minItems: d.min}), maxItems: d.max ?? 6}
|
|
219
|
+
if (d.min) required.push(p.name)
|
|
220
|
+
} else { props[p.name] = {type: 'string', maxLength: d.maxLength ?? 40}; if (d.required && d.default === undefined) required.push(p.name) }
|
|
221
|
+
}
|
|
222
|
+
return {props, required}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The JSON Schema of the reply. Per place (container slot; a row takes its parent's place) and
|
|
227
|
+
* depth, one `$defs` entry: an `anyOf` of one closed object per part allowed there. `relaxIn`
|
|
228
|
+
* leaves `in` out (the post-repair check: `finish` moves misplaced parts).
|
|
229
|
+
*/
|
|
230
|
+
export function layoutSchema(catalog: any, state: any, {plan = true, relaxIn = false}: {plan?: boolean; relaxIn?: boolean} = {}, ctx: Ctx = context(catalog, state)) {
|
|
231
|
+
const defs: any = {}
|
|
232
|
+
const refs = new Map<string, any>()
|
|
233
|
+
let n = 0
|
|
234
|
+
const slot = (place: string, depth: number, inRow: boolean, arranged: boolean): any => {
|
|
235
|
+
const k = `${place}|${depth}|${inRow}|${arranged}`
|
|
236
|
+
if (refs.has(k)) return refs.get(k)
|
|
237
|
+
const name = `d${++n}`
|
|
238
|
+
const ref = {$ref: `#/$defs/${name}`}
|
|
239
|
+
refs.set(k, ref)
|
|
240
|
+
defs[name] = true
|
|
241
|
+
const alts: any[] = []
|
|
242
|
+
for (const it of ctx.items) {
|
|
243
|
+
if (it.container && (inRow || depth > ctx.maxDepth || (it.row && arranged))) continue
|
|
244
|
+
if (!relaxIn && !it.row && !placeOk(it, place)) continue
|
|
245
|
+
const props: any = {part: {const: it.id}}
|
|
246
|
+
const required = ['part']
|
|
247
|
+
const ps = paramsSchema(it)
|
|
248
|
+
if (Object.keys(ps.props).length) {
|
|
249
|
+
props.params = {type: 'object', properties: ps.props, required: ps.required, additionalProperties: false}
|
|
250
|
+
required.push('params')
|
|
251
|
+
}
|
|
252
|
+
if (it.container) {
|
|
253
|
+
let any = false
|
|
254
|
+
for (const s of it.slots) {
|
|
255
|
+
const items = slot(it.row ? place : `${it.id}:${s}`, depth + 1, it.leaves, !!it.def.arranged)
|
|
256
|
+
if (!items) continue
|
|
257
|
+
any = true
|
|
258
|
+
const key = s == 'default' ? 'children' : s
|
|
259
|
+
props[key] = {type: 'array', items, ...(it.max && {maxItems: it.max})}
|
|
260
|
+
required.push(key)
|
|
261
|
+
}
|
|
262
|
+
if (!any) continue
|
|
263
|
+
}
|
|
264
|
+
alts.push({type: 'object', properties: props, required, additionalProperties: false})
|
|
265
|
+
}
|
|
266
|
+
if (!alts.length) { delete defs[name]; refs.set(k, null); return null }
|
|
267
|
+
// places that allow the same parts share one definition
|
|
268
|
+
const body = JSON.stringify(alts)
|
|
269
|
+
const same = Object.keys(defs).find(d => d != name && defs[d] !== true && JSON.stringify(defs[d].anyOf) == body)
|
|
270
|
+
if (same) { delete defs[name]; refs.set(k, {$ref: `#/$defs/${same}`}); return refs.get(k) }
|
|
271
|
+
defs[name] = {anyOf: alts}
|
|
272
|
+
return ref
|
|
273
|
+
}
|
|
274
|
+
const items = slot(PLACE, 1, false, !!ctx.root.arranged)
|
|
275
|
+
const children = items ? {type: 'array', items} : {type: 'array', maxItems: 0}
|
|
276
|
+
const schema = {
|
|
277
|
+
type: 'object',
|
|
278
|
+
properties: {...(plan && {asked_for: {type: 'array', items: {type: 'string', maxLength: askedMax(ctx)}, maxItems: 8}}), children},
|
|
279
|
+
required: [...(plan ? ['asked_for'] : []), 'children'],
|
|
280
|
+
additionalProperties: false,
|
|
281
|
+
$defs: defs,
|
|
282
|
+
}
|
|
283
|
+
// names d1, d2, ... in order of first use (no gaps after sharing)
|
|
284
|
+
const order: Record<string, string> = {}
|
|
285
|
+
let i = 0
|
|
286
|
+
const json = JSON.stringify(schema).replace(/"#\/\$defs\/(d\d+)"/g, (_, d) => `"#/$defs/${order[d] ??= `d${++i}`}"`)
|
|
287
|
+
const out = JSON.parse(json)
|
|
288
|
+
out.$defs = Object.fromEntries(Object.entries<any>(out.$defs).map(([d, v]): [string, any] => [order[d], v]).sort(([a], [b]) => +a.slice(1) - +b.slice(1)))
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------- reading the reply
|
|
293
|
+
const PART_ALIASES = ['type', 'component', 'name', 'id']
|
|
294
|
+
|
|
295
|
+
function readNode(ctx: Ctx, n: any, issues: Issue[]): TNode | null {
|
|
296
|
+
if (typeof n == 'string') return {part: n}
|
|
297
|
+
if (!n || typeof n != 'object' || Array.isArray(n)) return null
|
|
298
|
+
let part = n.part
|
|
299
|
+
if (typeof part != 'string') {
|
|
300
|
+
const k = PART_ALIASES.find(a => typeof n[a] == 'string' && ctx.byId[n[a]])
|
|
301
|
+
if (k) { part = n[k]; issues.push({kind: 'alias', detail: `${k}: ${part}`}) }
|
|
302
|
+
}
|
|
303
|
+
const it = ctx.byId[part]
|
|
304
|
+
let params = n.params
|
|
305
|
+
if (!params && it?.params.length) {
|
|
306
|
+
// params written flat on the node
|
|
307
|
+
const flat = Object.fromEntries(Object.entries(n).filter(([k]) => it.params.some((p: any) => p.name == k)))
|
|
308
|
+
if (Object.keys(flat).length) { params = flat; issues.push({kind: 'alias', detail: `flat params on ${part}`}) }
|
|
309
|
+
}
|
|
310
|
+
const t: TNode = {part, params}
|
|
311
|
+
if (it?.container) {
|
|
312
|
+
t.slots = {}
|
|
313
|
+
for (const s of it.slots) t.slots[s] = (Array.isArray(n[s == 'default' ? 'children' : s]) ? n[s == 'default' ? 'children' : s] : []).map((x: any) => readNode(ctx, x, issues))
|
|
314
|
+
// a slot the container does not have (or the list under another key): kept, repaired later
|
|
315
|
+
for (const k of Object.keys(n)) if (!['part', 'params', 'children', ...it.slots].includes(k) && Array.isArray(n[k]) && !it.params.some((p: any) => p.name == k)) t.slots[k] = n[k].map((x: any) => readNode(ctx, x, issues))
|
|
316
|
+
} else {
|
|
317
|
+
const kids = Array.isArray(n.children) ? n.children : Array.isArray(n.parts) ? n.parts : null
|
|
318
|
+
if (kids?.length) t.slots = {default: kids.map((x: any) => readNode(ctx, x, issues))} // children on a leaf: lifted later
|
|
319
|
+
}
|
|
320
|
+
return t
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** the parsed reply → the page's children (lenient) and the plan */
|
|
324
|
+
export function readReply(ctx: Ctx, value: any, issues: Issue[] = []) {
|
|
325
|
+
let list: any = undefined
|
|
326
|
+
if (Array.isArray(value)) { list = value; issues.push({kind: 'alias', detail: 'a bare list'}) }
|
|
327
|
+
else if (!value || typeof value != 'object') issues.push({kind: 'invalid', detail: 'not an object'})
|
|
328
|
+
else {
|
|
329
|
+
list = value.children
|
|
330
|
+
if (!Array.isArray(list)) {
|
|
331
|
+
// unconstrained output: the list under another name ('parts', 'items', 'ui', ...)
|
|
332
|
+
const k = Object.keys(value).find(x => Array.isArray(value[x]) && value[x].some((n: any) => n && typeof n == 'object'))
|
|
333
|
+
if (k) { list = value[k]; issues.push({kind: 'alias', detail: `${k} for children`}) }
|
|
334
|
+
else if (value.part || value.type) { list = [value]; issues.push({kind: 'alias', detail: 'a single node'}) }
|
|
335
|
+
else if (!('children' in value) && !Array.isArray(value.asked_for)) issues.push({kind: 'invalid', detail: 'no list of parts'})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
const raw = Array.isArray(list) ? list : []
|
|
339
|
+
const asked = value && Array.isArray(value.asked_for) ? value.asked_for.filter((x: any) => typeof x == 'string').map((x: string) => x.slice(0, askedMax(ctx))).slice(0, 8) : undefined
|
|
340
|
+
return {kids: raw.map((n: any) => readNode(ctx, n, issues)), raw: raw.length, asked, issues}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------- repair
|
|
344
|
+
function fixParams(it: any, params: any, issues: Issue[]): {params?: Record<string, any>; drop?: boolean} {
|
|
345
|
+
const out: any = {}
|
|
346
|
+
const given = params && typeof params == 'object' && !Array.isArray(params) ? params : {}
|
|
347
|
+
for (const p of it.params) {
|
|
348
|
+
const {name: n, d, kind} = p
|
|
349
|
+
const v = given[n]
|
|
350
|
+
if (kind == 'enum') {
|
|
351
|
+
const ok = v !== undefined && v !== null && Object.prototype.hasOwnProperty.call(d.options, String(v))
|
|
352
|
+
if (v !== undefined && !ok) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
|
|
353
|
+
const val = ok ? String(v) : d.default
|
|
354
|
+
if (val !== undefined) out[n] = val
|
|
355
|
+
} else if (kind == 'record') {
|
|
356
|
+
const r = p.records.find((r: any) => String(r.value) == String(v))
|
|
357
|
+
if (r) out[n] = r.value
|
|
358
|
+
else {
|
|
359
|
+
if (v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
|
|
360
|
+
if (d.default === undefined) { issues.push({kind: 'param', detail: `${it.id}: no record`}); return {drop: true} }
|
|
361
|
+
out[n] = d.default
|
|
362
|
+
}
|
|
363
|
+
} else if (kind == 'number') {
|
|
364
|
+
const x = typeof v == 'number' ? v : typeof v == 'string' && /^-?\d+(\.\d+)?$/.test(v.trim()) ? Number(v) : NaN
|
|
365
|
+
if (isFinite(x)) out[n] = Math.min(d.max ?? Infinity, Math.max(d.min ?? -Infinity, x))
|
|
366
|
+
else {
|
|
367
|
+
if (v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n}=${v}`})
|
|
368
|
+
if (d.default !== undefined) out[n] = d.default
|
|
369
|
+
}
|
|
370
|
+
} else if (kind == 'list') {
|
|
371
|
+
const max = d.items?.maxLength ?? 40
|
|
372
|
+
const items = (Array.isArray(v) ? v : typeof v == 'string' ? v.split(/\s*[,;|]\s*/) : []).filter((x: any) => typeof x == 'string' && x.trim()).map((x: string) => x.trim().slice(0, max))
|
|
373
|
+
const uniq = [...new Set(items)].slice(0, d.max ?? 6)
|
|
374
|
+
if (!Array.isArray(v) && v !== undefined) issues.push({kind: 'param', detail: `${it.id}.${n} not a list`})
|
|
375
|
+
if (uniq.length >= (d.min || 1)) out[n] = uniq
|
|
376
|
+
else if (d.default !== undefined) out[n] = d.default
|
|
377
|
+
else if (d.min || d.required) { issues.push({kind: 'param', detail: `${it.id}.${n} missing`}); return {drop: true} }
|
|
378
|
+
} else {
|
|
379
|
+
if (typeof v == 'string' && v.trim()) out[n] = v.trim().slice(0, d.maxLength ?? 40)
|
|
380
|
+
else if (d.default !== undefined && d.default !== '') out[n] = d.default
|
|
381
|
+
else if (d.required && d.default === undefined) { issues.push({kind: 'param', detail: `${it.id}.${n} missing`}); return {drop: true} }
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const n of Object.keys(given)) if (!it.params.some((p: any) => p.name == n)) issues.push({kind: 'param', detail: `${it.id}.${n} unknown`})
|
|
385
|
+
return {params: Object.keys(out).length ? out : undefined}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Validate and repair the page's children (research `repairTree`, on the normalized catalog):
|
|
390
|
+
* see the header. `relaxMin` (previews): a container under its minimum stays (a row of one
|
|
391
|
+
* shows as that part's row until its second part arrives).
|
|
392
|
+
*/
|
|
393
|
+
export function repairTree(ctx: Ctx, kids: Array<TNode | null>, issues: Issue[] = [], {relaxMin = false} = {}): TNode[] {
|
|
394
|
+
const uses: Record<string, number> = {}
|
|
395
|
+
const groups = new Set<string>()
|
|
396
|
+
const walk = (n: TNode | null, depth: number, place: string, inRow: boolean, arranged: boolean): TNode[] => {
|
|
397
|
+
if (!n || typeof n.part != 'string') { issues.push({kind: 'invalid', detail: 'node without part'}); return [] }
|
|
398
|
+
const it = ctx.byId[n.part]
|
|
399
|
+
const lift = (d: number) => Object.values(n.slots || {}).flat().flatMap(k => walk(k, d, place, inRow, arranged))
|
|
400
|
+
if (!it) { issues.push({kind: ctx.required.has(n.part) ? 'required' : 'unknownPart', detail: n.part}); return lift(depth) }
|
|
401
|
+
if (!it.container) {
|
|
402
|
+
const lifted = n.slots ? (issues.push({kind: 'structure', detail: `children on leaf ${it.id}`}), lift(depth)) : []
|
|
403
|
+
if (!placeOk(it, place)) issues.push({kind: 'structure', detail: `${it.id} not allowed in ${place}`})
|
|
404
|
+
const fixed = fixParams(it, n.params, issues)
|
|
405
|
+
if (fixed.drop) return lifted
|
|
406
|
+
const ident = it.rec ? `${it.id}:${fixed.params?.[it.rec]}` : it.id
|
|
407
|
+
if ((uses[ident] || 0) >= it.uses) { issues.push({kind: 'duplicate', detail: ident}); return lifted }
|
|
408
|
+
const g = it.def.group
|
|
409
|
+
if (g && groups.has(g) && !uses[ident]) { issues.push({kind: 'duplicate', detail: `group ${g}: ${it.id}`}); return lifted }
|
|
410
|
+
uses[ident] = (uses[ident] || 0) + 1
|
|
411
|
+
if (g) groups.add(g)
|
|
412
|
+
return [{part: it.id, ...(fixed.params && {params: fixed.params}) }, ...lifted]
|
|
413
|
+
}
|
|
414
|
+
// a container
|
|
415
|
+
if (depth > ctx.maxDepth || inRow || (it.row && arranged)) {
|
|
416
|
+
issues.push({kind: 'structure', detail: `${it.id} too deep or not accepted`})
|
|
417
|
+
return lift(depth)
|
|
418
|
+
}
|
|
419
|
+
const slots: Record<string, TNode[]> = {}
|
|
420
|
+
for (const s of it.slots) slots[s] = []
|
|
421
|
+
for (const [s, ks] of Object.entries(n.slots || {})) {
|
|
422
|
+
const target = s in slots ? s : 'default' in slots ? 'default' : it.slots[0]
|
|
423
|
+
if (target != s) issues.push({kind: 'structure', detail: `unknown slot ${it.id}.${s}`})
|
|
424
|
+
slots[target].push(...(ks || []).flatMap(k => walk(k, depth + 1, it.row ? place : `${it.id}:${target}`, it.leaves, !!it.def.arranged)))
|
|
425
|
+
}
|
|
426
|
+
const all = Object.values(slots).flat()
|
|
427
|
+
if (all.length == 0) { issues.push({kind: 'structure', detail: `empty ${it.id}`}); return [] }
|
|
428
|
+
const fixed = fixParams(it, n.params, issues)
|
|
429
|
+
if (fixed.drop) return all
|
|
430
|
+
const make = (s: Record<string, TNode[]>): TNode => ({part: it.id, ...(fixed.params && {params: fixed.params}), slots: s})
|
|
431
|
+
const filled = Object.values(slots).filter(k => k.length).length
|
|
432
|
+
if (!relaxMin && (all.length < it.min || filled < (it.def.minSlots || 0))) {
|
|
433
|
+
issues.push({kind: 'structure', detail: `${it.id} under min, unwrapped`})
|
|
434
|
+
return all
|
|
435
|
+
}
|
|
436
|
+
if (it.max && it.slots.length == 1 && all.length > it.max) {
|
|
437
|
+
// over the maximum: split into consecutive containers (a trailing one under the minimum is unwrapped)
|
|
438
|
+
issues.push({kind: 'structure', detail: `${it.id} over max, split`})
|
|
439
|
+
const out: TNode[] = []
|
|
440
|
+
for (let i = 0; i < all.length; i += it.max) {
|
|
441
|
+
const chunk = all.slice(i, i + it.max)
|
|
442
|
+
if (!relaxMin && chunk.length < it.min) out.push(...chunk)
|
|
443
|
+
else out.push(make({[it.slots[0]]: chunk}))
|
|
444
|
+
}
|
|
445
|
+
return out
|
|
446
|
+
}
|
|
447
|
+
return [make(slots)]
|
|
448
|
+
}
|
|
449
|
+
return kids.flatMap(k => walk(k, 1, PLACE, false, !!ctx.root.arranged))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ---------------------------------------------------------------- trees: internal ↔ contract ↔ reply
|
|
453
|
+
const stripDefaults = (it: any, params?: Record<string, any>) => {
|
|
454
|
+
if (!params) return undefined
|
|
455
|
+
const out: any = {}
|
|
456
|
+
for (const k in params) if (params[k] !== it.params.find((p: any) => p.name == k)?.d.default) out[k] = params[k]
|
|
457
|
+
return Object.keys(out).length ? out : undefined
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** internal tree → the contract's Tree (params without defaults; `children` array for a default-only container) */
|
|
461
|
+
export function toContractTree(ctx: Ctx, kids: TNode[]): any[] {
|
|
462
|
+
return kids.map(n => {
|
|
463
|
+
const it = ctx.byId[n.part]
|
|
464
|
+
const params = stripDefaults(it, n.params)
|
|
465
|
+
const o: any = {part: n.part, ...(params && {params})}
|
|
466
|
+
if (n.slots) {
|
|
467
|
+
const names = Object.keys(n.slots)
|
|
468
|
+
o.children = names.length == 1 && names[0] == 'default' ? toContractTree(ctx, n.slots.default)
|
|
469
|
+
: Object.fromEntries(names.map(s => [s, toContractTree(ctx, n.slots![s])]))
|
|
470
|
+
}
|
|
471
|
+
return o
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** a contract Tree → the reply shape (enum params filled with defaults, every slot present) */
|
|
476
|
+
function toReply(ctx: Ctx, tree: any[]): any[] {
|
|
477
|
+
return (tree || []).map((n: any) => {
|
|
478
|
+
let part = n.part, params = {...n.params}
|
|
479
|
+
if (!ctx.byId[part] && typeof part == 'string' && part.includes(':')) {
|
|
480
|
+
// `part:recordValue` as the part
|
|
481
|
+
const [base, ...rest] = part.split(':')
|
|
482
|
+
const it = ctx.byId[base]
|
|
483
|
+
if (it?.rec) { part = base; params[it.rec] ??= rest.join(':') }
|
|
484
|
+
}
|
|
485
|
+
const it = ctx.byId[part]
|
|
486
|
+
const o: any = {part}
|
|
487
|
+
if (it?.params.length) {
|
|
488
|
+
for (const p of it.params) if (params[p.name] === undefined && p.d.default !== undefined && (p.kind == 'enum' || p.kind == 'record')) params[p.name] = p.d.default
|
|
489
|
+
if (it.rec && params[it.rec] !== undefined) {
|
|
490
|
+
const r = it.params.find((p: any) => p.name == it.rec).records.find((r: any) => String(r.value) == String(params[it.rec]))
|
|
491
|
+
if (r) params[it.rec] = r.value
|
|
492
|
+
}
|
|
493
|
+
o.params = params
|
|
494
|
+
}
|
|
495
|
+
if (it?.container) {
|
|
496
|
+
for (const s of it.slots) {
|
|
497
|
+
const kids = Array.isArray(n.children) ? (s == 'default' || it.slots.length == 1 ? n.children : []) : n.children?.[s] ?? []
|
|
498
|
+
o[s == 'default' ? 'children' : s] = toReply(ctx, kids)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return o
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------- streaming: partial JSON
|
|
506
|
+
const PARTIAL = Symbol.for('sygnal.partial')
|
|
507
|
+
const markPartial = (x: any) => (Object.defineProperty(x, PARTIAL, {value: true, enumerable: false}), x)
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Parse a JSON prefix: closes open containers, drops an unfinished string, number or key and the
|
|
511
|
+
* key whose value never started; an object or array cut short is marked partial. Returns
|
|
512
|
+
* undefined when nothing is usable yet.
|
|
513
|
+
*/
|
|
514
|
+
export function parsePartialJSON(text: string): any {
|
|
515
|
+
let i = 0
|
|
516
|
+
const s = text
|
|
517
|
+
const INCOMPLETE = Symbol('incomplete')
|
|
518
|
+
const sp = () => { while (i < s.length && /\s/.test(s[i])) i++ }
|
|
519
|
+
const value = (): any => {
|
|
520
|
+
sp()
|
|
521
|
+
if (i >= s.length) return INCOMPLETE
|
|
522
|
+
const ch = s[i]
|
|
523
|
+
if (ch == '{') {
|
|
524
|
+
i++
|
|
525
|
+
const o: any = {}
|
|
526
|
+
for (;;) {
|
|
527
|
+
sp()
|
|
528
|
+
if (i >= s.length) return markPartial(o)
|
|
529
|
+
if (s[i] == '}') { i++; return o }
|
|
530
|
+
if (s[i] == ',') { i++; continue }
|
|
531
|
+
const k = value()
|
|
532
|
+
if (k === INCOMPLETE || typeof k != 'string') return markPartial(o)
|
|
533
|
+
sp()
|
|
534
|
+
if (s[i] != ':') return markPartial(o)
|
|
535
|
+
i++
|
|
536
|
+
const v = value()
|
|
537
|
+
if (v === INCOMPLETE) return markPartial(o)
|
|
538
|
+
o[k] = v
|
|
539
|
+
if (v && typeof v == 'object' && v[PARTIAL]) return markPartial(o)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (ch == '[') {
|
|
543
|
+
i++
|
|
544
|
+
const a: any[] = []
|
|
545
|
+
for (;;) {
|
|
546
|
+
sp()
|
|
547
|
+
if (i >= s.length) return markPartial(a)
|
|
548
|
+
if (s[i] == ']') { i++; return a }
|
|
549
|
+
if (s[i] == ',') { i++; continue }
|
|
550
|
+
const v = value()
|
|
551
|
+
if (v === INCOMPLETE) return markPartial(a)
|
|
552
|
+
a.push(v)
|
|
553
|
+
if (v && typeof v == 'object' && v[PARTIAL]) return markPartial(a)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (ch == '"') {
|
|
557
|
+
let j = i + 1, out = ''
|
|
558
|
+
while (j < s.length && s[j] != '"') { if (s[j] == '\\') { j++; if (j >= s.length) break } out += s[j]; j++ }
|
|
559
|
+
if (j >= s.length) { i = s.length; return INCOMPLETE }
|
|
560
|
+
const raw = s.slice(i, j + 1)
|
|
561
|
+
i = j + 1
|
|
562
|
+
try { return JSON.parse(raw) } catch (_) { return out }
|
|
563
|
+
}
|
|
564
|
+
const m = /^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)/.exec(s.slice(i))
|
|
565
|
+
if (!m || i + m[0].length >= s.length) { i = s.length; return INCOMPLETE }
|
|
566
|
+
i += m[0].length
|
|
567
|
+
return JSON.parse(m[0])
|
|
568
|
+
}
|
|
569
|
+
const v = value()
|
|
570
|
+
return v === INCOMPLETE ? undefined : v
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// settled: a leaf shows once its object closes (its params are final); a container still filling shows what it holds
|
|
574
|
+
const prune = (x: any): any => {
|
|
575
|
+
if (Array.isArray(x)) return x.filter(y => !(y && typeof y == 'object' && !Array.isArray(y) && y[PARTIAL] && !Object.values(y).some(Array.isArray))).map(prune)
|
|
576
|
+
if (x && typeof x == 'object') return Object.fromEntries(Object.entries(x).map(([k, y]) => [k, prune(y)]))
|
|
577
|
+
return x
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** the model's text → a value (code fences, think blocks and prose around the JSON tolerated) */
|
|
581
|
+
function parseText(text: any): {value?: any; error?: string} {
|
|
582
|
+
if (typeof text != 'string') return {error: 'no text'}
|
|
583
|
+
const t = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim().replace(/^```(?:json)?\s*([\s\S]*?)\s*```$/, '$1')
|
|
584
|
+
try { return {value: JSON.parse(t)} } catch (_) {}
|
|
585
|
+
const m = /[{[][\s\S]*[}\]]/.exec(t)
|
|
586
|
+
if (m) try { return {value: JSON.parse(m[0])} } catch (_) {}
|
|
587
|
+
return {error: `not JSON: ${t.length > 80 ? t.slice(0, 80) + '…' : t}`}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const cap = (kids: TNode[], max: number) => {
|
|
591
|
+
let n = 0
|
|
592
|
+
const go = (ks: TNode[]): TNode[] => ks.flatMap(k => {
|
|
593
|
+
if (!k.slots) return n++ < max ? [k] : []
|
|
594
|
+
const slots = Object.fromEntries(Object.entries(k.slots).map(([s, x]) => [s, go(x)]))
|
|
595
|
+
return Object.values(slots).some(x => x.length) ? [{...k, slots}] : []
|
|
596
|
+
})
|
|
597
|
+
return go(kids)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// a Standard Schema that accepts any value: the reply's checks are the repair's (the schema still goes to the model)
|
|
601
|
+
const ANY = {'~standard': {version: 1, vendor: 'sygnal', validate: (value: unknown) => ({value})}}
|
|
602
|
+
|
|
603
|
+
const say = (message: string, data: any) => {
|
|
604
|
+
try { report('SYG683', {component: 'compose.llm', message, data}) } catch (e) { queueMicrotask(() => { throw e }) }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** examples → few-shot turns */
|
|
608
|
+
function shots(ctx: Ctx, examples: any[], plan: boolean) {
|
|
609
|
+
return examples.flatMap((x: any) => {
|
|
610
|
+
const [prompt, tree, asked] = Array.isArray(x) ? x : [x?.prompt, x?.tree ?? x?.layout, x?.asked_for]
|
|
611
|
+
if (typeof prompt != 'string' || !Array.isArray(tree)) return []
|
|
612
|
+
const children = toReply(ctx, tree)
|
|
613
|
+
const leaves = (ks: any[]): string[] => ks.flatMap(k => ctx.byId[k.part]?.container ? Object.keys(k).filter(s => Array.isArray(k[s])).flatMap(s => leaves(k[s])) : [ctx.byId[k.part]?.def.description ?? k.part])
|
|
614
|
+
return [
|
|
615
|
+
{role: 'user', content: prompt},
|
|
616
|
+
{role: 'assistant', content: JSON.stringify({...(plan && {asked_for: Array.isArray(asked) ? asked : leaves(children)}), children})},
|
|
617
|
+
]
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** a reply value → `{ layout, decisions }`, `unavailable`, or `{ error }`: read, repair, check, fromTree (keys from `previous`), finish */
|
|
622
|
+
function readTree(catalog: any, state: any, previous: any, value: any): any {
|
|
623
|
+
const ctx = context(catalog, state)
|
|
624
|
+
const {kids, raw, asked, issues} = readReply(ctx, value)
|
|
625
|
+
const fixed = repairTree(ctx, kids, issues)
|
|
626
|
+
const decisions = {...(asked && {asked_for: asked}), ...(issues.length && {repairs: issues})}
|
|
627
|
+
if (!fixed.length) {
|
|
628
|
+
// nothing fits (an empty list) is `unavailable`; a reply that isn't a list of parts, or
|
|
629
|
+
// whose every part is unknown or malformed, is an error
|
|
630
|
+
const bad = issues.filter(i => i.kind == 'invalid' || i.kind == 'unknownPart')
|
|
631
|
+
const notAList = issues.some(i => i.kind == 'invalid' && i.detail != 'node without part')
|
|
632
|
+
const allBogus = raw > 0 && bad.length > 0 && !issues.some(i => i.kind == 'required' || i.kind == 'duplicate' || i.kind == 'param')
|
|
633
|
+
if (notAList || allBogus) {
|
|
634
|
+
const message = `the reply has no usable part: ${bad.map(i => i.detail).join(', ')}`
|
|
635
|
+
say(message, {issues, value})
|
|
636
|
+
return {error: `The model's reply is not a layout (${bad.map(i => i.detail).join(', ')})`, decisions}
|
|
637
|
+
}
|
|
638
|
+
return {layout: null, unavailable: true, decisions}
|
|
639
|
+
}
|
|
640
|
+
// the repaired tree must fit the schema (`in` left to finish): otherwise it is an error, never a layout
|
|
641
|
+
const wrong = check(layoutSchema(catalog, state, {plan: false, relaxIn: true}, ctx), {children: toReply(ctx, toContractTree(ctx, fixed))})
|
|
642
|
+
if (wrong.length) {
|
|
643
|
+
const message = `the layout is still invalid after repair: ${wrong.slice(0, 3).map(i => `${(i.path || []).join('.')}: ${i.message}`).join('; ')}`
|
|
644
|
+
say(message, {issues: wrong, value})
|
|
645
|
+
return {error: `The model's layout is invalid (${message})`, decisions}
|
|
646
|
+
}
|
|
647
|
+
return {layout: finish(catalog, fromTree(catalog, toContractTree(ctx, fixed), previous), state), decisions}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** the reply's value: `reply.value` when a driver parsed it, else its text, read leniently (SYG683 when it isn't JSON) */
|
|
651
|
+
function valueOf(reply: any): {value?: any; error?: string} {
|
|
652
|
+
if (reply?.value !== undefined) return {value: reply.value}
|
|
653
|
+
const r = parseText(reply?.text)
|
|
654
|
+
if (r.error) say(`the reply is not a layout: ${r.error}`, {text: reply?.text})
|
|
655
|
+
return r
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** compose.llm(options): the chat-LLM backend (one structured-output request, streamed preview; ops2 edits) */
|
|
659
|
+
export function llm(options: LlmOptions = {}) {
|
|
660
|
+
const {model, sink = 'LLM', key, stream = true, plan, instructions, examples, ...rest} = options
|
|
661
|
+
const planFor = (catalog: any) => plan ?? !(examples ?? catalog.examples ?? []).length
|
|
662
|
+
const base = (names: any, state: any) => {
|
|
663
|
+
// a function of the host's state: resolved per request (an app's model picker)
|
|
664
|
+
const m = typeof model == 'function' ? model(state) : model
|
|
665
|
+
return {
|
|
666
|
+
...rest,
|
|
667
|
+
...(m && {model: m}),
|
|
668
|
+
key: key ?? names?.key ?? 'compose',
|
|
669
|
+
latest: true,
|
|
670
|
+
// the driver sends the schema but leaves the text to reply(): a reply that isn't strict JSON
|
|
671
|
+
// (prose around it, a code fence) still reaches parseText and the repair, or fails as SYG683
|
|
672
|
+
parse: false,
|
|
673
|
+
...(names?.ok && {ok: names.ok}),
|
|
674
|
+
...(names?.error && {error: names.error}),
|
|
675
|
+
...(stream && names?.delta && {delta: names.delta}),
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
sink,
|
|
680
|
+
kind: 'llm',
|
|
681
|
+
start({catalog, state, prompt, previous, names}: any) {
|
|
682
|
+
const ctx = context(catalog, state)
|
|
683
|
+
const text = String(prompt ?? '')
|
|
684
|
+
if (hasNodes(previous)) {
|
|
685
|
+
// B-4: an edit of the current layout (ops2: no plan field, no examples)
|
|
686
|
+
const order = preorder(previous)
|
|
687
|
+
const request: any = {
|
|
688
|
+
...base(names, state),
|
|
689
|
+
instructions: editPrompt(catalog, state, {instructions}, ctx),
|
|
690
|
+
messages: [{role: 'user', content: `Current page:\n${currentPage(previous, {ctx, order})}\n\nChange: ${text}`}],
|
|
691
|
+
output: jsonSchema(opsSchema(catalog, state, previous, ctx, order), {validate: ANY}),
|
|
692
|
+
}
|
|
693
|
+
return {request, pending: {step: 'edit', prompt: text, previous, order}}
|
|
694
|
+
}
|
|
695
|
+
const p = planFor(catalog)
|
|
696
|
+
const request: any = {
|
|
697
|
+
...base(names, state),
|
|
698
|
+
instructions: systemPrompt(catalog, state, {plan: p, instructions}, ctx),
|
|
699
|
+
messages: [...shots(ctx, examples ?? catalog.examples ?? [], p), {role: 'user', content: text}],
|
|
700
|
+
output: jsonSchema(layoutSchema(catalog, state, {plan: p}, ctx), {validate: ANY}),
|
|
701
|
+
}
|
|
702
|
+
return {request, pending: {step: 'llm', prompt: text, plan: p, ...(previous && {previous})}}
|
|
703
|
+
},
|
|
704
|
+
delta({catalog, state, pending, delta}: any) {
|
|
705
|
+
const text = typeof delta == 'string' ? delta : delta?.text
|
|
706
|
+
if (typeof text != 'string') return {}
|
|
707
|
+
const v = parsePartialJSON(text.replace(/<think>[\s\S]*?(<\/think>|$)/g, '').replace(/^\s*```(?:json)?\s*/, ''))
|
|
708
|
+
if (!v || typeof v != 'object') return {}
|
|
709
|
+
if (pending?.step == 'edit') {
|
|
710
|
+
// the operations complete so far, applied to the previous layout (its keys kept)
|
|
711
|
+
const done = (opsOf(v) || []).filter((o: any) => !(o && typeof o == 'object' && o[PARTIAL]))
|
|
712
|
+
if (!done.length) return {}
|
|
713
|
+
const r = applyOps(catalog, state, pending.previous, pending.order, done)
|
|
714
|
+
return r.applied ? {preview: finish(catalog, r.layout, state), keyed: true} : {}
|
|
715
|
+
}
|
|
716
|
+
const ctx = context(catalog, state)
|
|
717
|
+
const {kids} = readReply(ctx, prune(v))
|
|
718
|
+
const fixed = cap(repairTree(ctx, kids, [], {relaxMin: true}), ctx.maxParts)
|
|
719
|
+
if (!fixed.length) return {}
|
|
720
|
+
return {preview: fromTree(catalog, toContractTree(ctx, fixed), pending?.previous)}
|
|
721
|
+
},
|
|
722
|
+
reply({catalog, state, pending, reply}: any) {
|
|
723
|
+
const r = valueOf(reply)
|
|
724
|
+
if (r.error) return {error: `The model's reply is not a layout (${r.error})`}
|
|
725
|
+
if (pending?.step != 'edit') return readTree(catalog, state, pending?.previous, r.value)
|
|
726
|
+
const ops = opsOf(r.value)
|
|
727
|
+
// a whole page instead of operations: read as a composition, keys reused from the previous layout
|
|
728
|
+
if (!ops) return readTree(catalog, state, pending.previous, r.value)
|
|
729
|
+
// no operation: nothing in the catalog fits the change (or it changes nothing); the layout stays
|
|
730
|
+
if (!ops.length) return {layout: null, unavailable: true, decisions: {ops: []}}
|
|
731
|
+
const a = applyOps(catalog, state, pending.previous, pending.order, ops)
|
|
732
|
+
const decisions = {ops: a.ops, ...(a.issues.length && {repairs: a.issues})}
|
|
733
|
+
if (!a.applied) {
|
|
734
|
+
const what = a.issues.map(i => i.detail).join(', ')
|
|
735
|
+
say(`no operation of the reply applies: ${what}`, {issues: a.issues, value: r.value})
|
|
736
|
+
return {error: `The model's edit does not apply (${what})`, decisions}
|
|
737
|
+
}
|
|
738
|
+
return {layout: finish(catalog, a.layout, state), keyed: true, decisions}
|
|
739
|
+
},
|
|
740
|
+
fake({catalog, state, pending, target}: any) {
|
|
741
|
+
const ctx = context(catalog, state)
|
|
742
|
+
if (pending?.step == 'edit') {
|
|
743
|
+
// `[]` is "nothing fits" (no operation); otherwise the operations that turn the previous
|
|
744
|
+
// layout into `target`, or the whole page when operations can't reach it (a new container
|
|
745
|
+
// other than a row, a row of new parts only)
|
|
746
|
+
if (!target?.length) return JSON.stringify({ops: []})
|
|
747
|
+
const ops = editOps(catalog, state, pending.previous, pending.order, target)
|
|
748
|
+
return JSON.stringify(ops ? {ops} : {children: toReply(ctx, target)})
|
|
749
|
+
}
|
|
750
|
+
const children = toReply(ctx, target || [])
|
|
751
|
+
const p = pending?.plan ?? planFor(catalog)
|
|
752
|
+
const leaves = (ks: any[]): string[] => ks.flatMap(k => ctx.byId[k.part]?.container ? Object.keys(k).filter(s => Array.isArray(k[s])).flatMap(s => leaves(k[s])) : [k.part])
|
|
753
|
+
return JSON.stringify({...(p && {asked_for: leaves(children)}), children})
|
|
754
|
+
},
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ---------------------------------------------------------------- B-4: edits (ops2)
|
|
759
|
+
/*
|
|
760
|
+
* An edit sends the current page and the change, and the model returns operations positioned by
|
|
761
|
+
* named places (research REPORT.md §1 "The edit prompt (ops2)", llm-composer.mjs opsSchemaV2 /
|
|
762
|
+
* opsSystemPromptV2 / applyOpsV2). The model sees the nodes as `n0` (the page), `n1`, … in
|
|
763
|
+
* preorder (the research's keys; `pending.order` maps them back), never the layout's identity
|
|
764
|
+
* keys. The operations are applied to the previous layout itself, so keys are kept by
|
|
765
|
+
* construction:
|
|
766
|
+
* - a node no operation touches keeps its key (and its place);
|
|
767
|
+
* - move and setParams keep the node's key (a setParams that changes a record param is a
|
|
768
|
+
* different record: a new key, in the same place);
|
|
769
|
+
* - replace puts a new node (a new key) in the old one's place; a replace with the same part is a
|
|
770
|
+
* setParams (key kept); an add or a replace with a part already on the page as often as it may
|
|
771
|
+
* be (`maxUses`) moves that node there instead (key kept);
|
|
772
|
+
* - places: 'top' / 'bottom' of the page, 'above k' / 'below k' (around k's block on the page),
|
|
773
|
+
* 'left of k' / 'right of k' (in k's row, or a new row made of k and the part in k's place).
|
|
774
|
+
* The reference node never moves (a move relative to itself or its own subtree is skipped).
|
|
775
|
+
* Then finish(). A reply with a whole page (`children`) instead is read as a composition with the
|
|
776
|
+
* previous keys reused; no operation is `unavailable` (the layout stays); operations of which none
|
|
777
|
+
* applies are an error (SYG683).
|
|
778
|
+
*/
|
|
779
|
+
const own = (o: any, k: string) => !!o && Object.prototype.hasOwnProperty.call(o, k)
|
|
780
|
+
|
|
781
|
+
/** a layout's node keys in preorder, the root first (the edit prompt's n0, n1, …) */
|
|
782
|
+
export function preorder(layout: any): string[] {
|
|
783
|
+
const out: string[] = []
|
|
784
|
+
const nodes = layout?.nodes || {}
|
|
785
|
+
const go = (k: string) => {
|
|
786
|
+
if (out.includes(k) || !nodes[k]) return
|
|
787
|
+
out.push(k)
|
|
788
|
+
for (const ks of Object.values<any>(nodes[k].slots || {})) if (Array.isArray(ks)) ks.forEach(go)
|
|
789
|
+
}
|
|
790
|
+
go(layout?.root || 'page')
|
|
791
|
+
return out
|
|
792
|
+
}
|
|
793
|
+
const hasNodes = (l: any) => !!l?.nodes && preorder(l).length > 1
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* a layout as the edit prompt shows it: one node per line (`n1: part param="value"`, params with
|
|
797
|
+
* their defaults filled, as the app holds them), a container's children indented under it
|
|
798
|
+
*/
|
|
799
|
+
export function currentPage(layout: any, {ctx, order = preorder(layout)}: {ctx?: Ctx; order?: string[]} = {}): string {
|
|
800
|
+
const lines: string[] = []
|
|
801
|
+
const go = (key: string, depth: number) => {
|
|
802
|
+
const n = layout.nodes[key]
|
|
803
|
+
if (!n) return
|
|
804
|
+
const it = ctx?.byId[n.part]
|
|
805
|
+
const params = it ? fixParams(it, n.params, []).params : n.params
|
|
806
|
+
const ps = params ? ' ' + Object.entries(params).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ') : ''
|
|
807
|
+
lines.push(`${' '.repeat(depth)}n${order.indexOf(key)}: ${n.part}${ps}`)
|
|
808
|
+
for (const [s, ks] of Object.entries<any>(n.slots || {})) {
|
|
809
|
+
if (s != 'default') lines.push(`${' '.repeat(depth + 1)}[${s}]`)
|
|
810
|
+
ks.forEach((k: string) => go(k, depth + 1))
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
go(layout.root, 0)
|
|
814
|
+
return lines.join('\n')
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const EDIT = `The user message gives the current page (one node per line: key: part params; a row's parts are indented under it) and a change. Return only the operations that make the change, in order:
|
|
818
|
+
- {"op":"add","part":"<id>","params":{...},"where":"<place>"}
|
|
819
|
+
- {"op":"remove","node":"<key>"}
|
|
820
|
+
- {"op":"move","node":"<key>","where":"<place>"}
|
|
821
|
+
- {"op":"replace","node":"<key>","part":"<id>","params":{...}} (the new part takes the old one's place)
|
|
822
|
+
- {"op":"setParams","node":"<key>","params":{"<param>":"<value>"}}
|
|
823
|
+
A place is "top" or "bottom" of the page, "above <key>" or "below <key>" (on the page), or "left of <key>" / "right of <key>" (side by side with that part, in a row).
|
|
824
|
+
Change only what the request asks; everything else stays as it is. A request that changes nothing gets no operations.
|
|
825
|
+
|
|
826
|
+
Reply with JSON only: {"ops": [ ... ]}`
|
|
827
|
+
|
|
828
|
+
/** the edit's system prompt: the catalog and the rules (no plan line), then the operations (research ops2, exact) */
|
|
829
|
+
export function editPrompt(catalog: any, state: any, {instructions}: {instructions?: LlmOptions['instructions']} = {}, ctx: Ctx = context(catalog, state)) {
|
|
830
|
+
const head = systemPrompt(catalog, state, {plan: false, instructions: typeof instructions == 'string' ? instructions : undefined}, ctx).split('\n\nReply with JSON only')[0]
|
|
831
|
+
const prompt = `${head}\n\n${EDIT}`
|
|
832
|
+
return typeof instructions == 'function' ? instructions(prompt) : prompt
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** the edit's JSON Schema: `node` an enum of the current nodes, `where` an enum of every place, `part` + `params` per part */
|
|
836
|
+
export function opsSchema(catalog: any, state: any, previous: any, ctx: Ctx = context(catalog, state), order: string[] = preorder(previous)) {
|
|
837
|
+
const nodeKeys = order.slice(1).map((_, i) => `n${i + 1}`)
|
|
838
|
+
const places = ['top', 'bottom']
|
|
839
|
+
order.forEach((k, i) => {
|
|
840
|
+
if (i == 0) return
|
|
841
|
+
places.push(`above n${i}`, `below n${i}`)
|
|
842
|
+
if (catalog.get?.(previous.nodes[k].part)?.kind != 'container') places.push(`left of n${i}`, `right of n${i}`)
|
|
843
|
+
})
|
|
844
|
+
const where = {enum: places}
|
|
845
|
+
const leaves = ctx.items.filter(i => !i.container)
|
|
846
|
+
const withParams = (b: any, it: any) => {
|
|
847
|
+
const ps = paramsSchema(it)
|
|
848
|
+
// a record part needs its record (the research catalogs have none)
|
|
849
|
+
const rec = it.rec && ps.required.includes(it.rec) ? {required: [it.rec]} : {}
|
|
850
|
+
return Object.keys(ps.props).length ? {...b, params: {type: 'object', properties: ps.props, ...rec, additionalProperties: false}} : b
|
|
851
|
+
}
|
|
852
|
+
const needs = (it: any, r: string[]) => it.rec && paramsSchema(it).required.includes(it.rec) ? [...r, 'params'] : r
|
|
853
|
+
const ops: any[] = leaves.map(it => ({type: 'object', properties: {...withParams({op: {const: 'add'}, part: {const: it.id}}, it), where}, required: needs(it, ['op', 'part', 'where']), additionalProperties: false}))
|
|
854
|
+
if (nodeKeys.length) {
|
|
855
|
+
ops.push(
|
|
856
|
+
{type: 'object', properties: {op: {const: 'remove'}, node: {enum: nodeKeys}}, required: ['op', 'node'], additionalProperties: false},
|
|
857
|
+
{type: 'object', properties: {op: {const: 'move'}, node: {enum: nodeKeys}, where}, required: ['op', 'node', 'where'], additionalProperties: false},
|
|
858
|
+
...leaves.map(it => ({type: 'object', properties: withParams({op: {const: 'replace'}, node: {enum: nodeKeys}, part: {const: it.id}}, it), required: needs(it, ['op', 'node', 'part']), additionalProperties: false})),
|
|
859
|
+
{type: 'object', properties: {op: {const: 'setParams'}, node: {enum: nodeKeys}, params: {type: 'object'}}, required: ['op', 'node', 'params'], additionalProperties: false},
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
return {type: 'object', properties: {ops: {type: 'array', items: {anyOf: ops}}}, required: ['ops'], additionalProperties: false}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** the operations in a reply (lenient: a bare list, `operations`, a single operation); undefined when it has none (a whole page, say) */
|
|
866
|
+
function opsOf(v: any): any[] | undefined {
|
|
867
|
+
const isOp = (o: any) => !!o && typeof o == 'object' && typeof o.op == 'string'
|
|
868
|
+
if (Array.isArray(v)) return v.every(isOp) ? v : undefined
|
|
869
|
+
if (!v || typeof v != 'object') return
|
|
870
|
+
for (const k of ['ops', 'operations', 'changes', 'edits']) if (Array.isArray(v[k])) return v[k]
|
|
871
|
+
if (isOp(v)) return [v]
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** apply operations to `previous` (see the section's header): the layout (not finished), how many applied, the operations as applied (layout keys), issues */
|
|
875
|
+
export function applyOps(catalog: any, state: any, previous: any, order: string[], ops: any[]) {
|
|
876
|
+
const ctx = context(catalog, state)
|
|
877
|
+
const issues: Issue[] = [], done: any[] = []
|
|
878
|
+
const root = previous.root || 'page'
|
|
879
|
+
const L: any = {root, nodes: JSON.parse(JSON.stringify(previous.nodes))}
|
|
880
|
+
L.nodes[root].slots ||= {}
|
|
881
|
+
L.nodes[root].slots.default ||= []
|
|
882
|
+
const used = new Set<string>(Object.keys(L.nodes))
|
|
883
|
+
const page = (): string[] => L.nodes[root].slots.default
|
|
884
|
+
const keyOf = (ref: any): string | undefined => {
|
|
885
|
+
if (typeof ref != 'string' && typeof ref != 'number') return
|
|
886
|
+
const s = String(ref).trim()
|
|
887
|
+
const m = /^n(\d+)$/.exec(s)
|
|
888
|
+
const k = m && order[+m[1]] !== undefined ? order[+m[1]] : s
|
|
889
|
+
return own(L.nodes, k) && k != root ? k : undefined
|
|
890
|
+
}
|
|
891
|
+
const parentOf = (k: string) => {
|
|
892
|
+
for (const pk in L.nodes) {
|
|
893
|
+
const sl = L.nodes[pk].slots
|
|
894
|
+
for (const s in sl) { const i = sl[s].indexOf(k); if (i >= 0) return {pk, s, i, arr: sl[s] as string[]} }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
const isRow = (k: string) => { const e = catalog.get(L.nodes[k]?.part); return e?.kind == 'container' && (e.direction == 'row' || e.id == 'row') }
|
|
898
|
+
const rowPart: string | undefined = catalog.get('row')?.kind == 'container' ? 'row' : (Object.values<any>(catalog.containers || {}).find(c => c.direction == 'row')?.id)
|
|
899
|
+
const topOf = (k: string) => { let p = parentOf(k); while (p && p.pk != root) { k = p.pk; p = parentOf(k) } return k }
|
|
900
|
+
const within = (k: string, anc: string) => { for (let x: string | undefined = k; x && x != root; x = parentOf(x)?.pk) if (x == anc) return true; return false }
|
|
901
|
+
const detach = (k: string) => { const p = parentOf(k); if (p) p.arr.splice(p.i, 1) }
|
|
902
|
+
const drop = (k: string) => { const n = L.nodes[k]; if (!n) return; delete L.nodes[k]; for (const s in n.slots) for (const c of n.slots[s]) drop(c) }
|
|
903
|
+
const fresh = (part: string, params?: any) => { let k = identityOf(catalog, part, params); for (let i = 2; used.has(k); i++) k = `${identityOf(catalog, part, params)}~${i}`; used.add(k); return k }
|
|
904
|
+
const idOf = (k: string) => identityOf(catalog, L.nodes[k].part, L.nodes[k].params)
|
|
905
|
+
const existing = (id: string) => preorder(L).find(k => k != root && idOf(k) == id)
|
|
906
|
+
const count = (id: string) => preorder(L).filter(k => k != root && idOf(k) == id).length
|
|
907
|
+
/** a place → the function that inserts a key there (null: no such place; `self`: the node that moves) */
|
|
908
|
+
const placement = (where: any, self?: string): ((k: string) => void) | null => {
|
|
909
|
+
const w = typeof where == 'string' ? where.trim() : ''
|
|
910
|
+
if (w == 'top') return k => { page().unshift(k) }
|
|
911
|
+
if (w == 'bottom') return k => { page().push(k) }
|
|
912
|
+
const m = /^(above|below|left of|right of)\s+(\S+)$/.exec(w)
|
|
913
|
+
const ref = m ? keyOf(m[2]) : undefined
|
|
914
|
+
if (!m || !ref || (self && within(ref, self))) return null
|
|
915
|
+
const rel = m[1]
|
|
916
|
+
if (rel == 'above' || rel == 'below') return k => { const pg = page(); pg.splice(pg.indexOf(topOf(ref)) + (rel == 'below' ? 1 : 0), 0, k) }
|
|
917
|
+
const right = rel == 'right of'
|
|
918
|
+
return k => {
|
|
919
|
+
if (isRow(ref)) { const s = L.nodes[ref].slots.default ||= []; right ? s.push(k) : s.unshift(k); return }
|
|
920
|
+
const p = parentOf(ref)!
|
|
921
|
+
if (isRow(p.pk) || !rowPart) {
|
|
922
|
+
if (!rowPart) issues.push({kind: 'op', detail: `no row container: ${w} is next to it instead`})
|
|
923
|
+
p.arr.splice(p.i + (right ? 1 : 0), 0, k)
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
const r = fresh(rowPart)
|
|
927
|
+
L.nodes[r] = {part: rowPart, slots: {default: right ? [ref, k] : [k, ref]}}
|
|
928
|
+
p.arr.splice(p.i, 1, r)
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const setParams = (k: string, given: any): string | null => {
|
|
932
|
+
const n = L.nodes[k], it = ctx.byId[n.part]
|
|
933
|
+
const merged = {...n.params, ...given}
|
|
934
|
+
if (!it) { if (!catalog.get(n.part)) return 'unknown part'; n.params = merged; return null }
|
|
935
|
+
const f = fixParams(it, merged, issues)
|
|
936
|
+
if (f.drop) return 'params'
|
|
937
|
+
const before = idOf(k)
|
|
938
|
+
if (identityOf(catalog, it.id, f.params) != before) {
|
|
939
|
+
// another record: another node, in the same place
|
|
940
|
+
const id = identityOf(catalog, it.id, f.params)
|
|
941
|
+
if (count(id) >= it.uses) return `${id} is on the page`
|
|
942
|
+
const nk = fresh(it.id, f.params), p = parentOf(k)!
|
|
943
|
+
L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
|
|
944
|
+
p.arr.splice(p.i, 1, nk)
|
|
945
|
+
drop(k)
|
|
946
|
+
return null
|
|
947
|
+
}
|
|
948
|
+
if (f.params) n.params = f.params
|
|
949
|
+
else delete n.params
|
|
950
|
+
return null
|
|
951
|
+
}
|
|
952
|
+
for (const op of Array.isArray(ops) ? ops : []) {
|
|
953
|
+
const fail = (why: string) => { issues.push({kind: 'op', detail: `${why}: ${JSON.stringify(op)}`}) }
|
|
954
|
+
if (!op || typeof op != 'object') { fail('not an operation'); continue }
|
|
955
|
+
const k = keyOf(op.node)
|
|
956
|
+
if (op.op == 'add') {
|
|
957
|
+
const it = ctx.byId[op.part]
|
|
958
|
+
if (!it || it.container) { fail('unknown part'); continue }
|
|
959
|
+
const f = fixParams(it, op.params, issues)
|
|
960
|
+
if (f.drop) { fail('params'); continue }
|
|
961
|
+
const id = identityOf(catalog, it.id, f.params)
|
|
962
|
+
if (count(id) >= it.uses) {
|
|
963
|
+
// on the page as often as it may be: moved there instead (its key kept)
|
|
964
|
+
const e = existing(id)!
|
|
965
|
+
const at = placement(op.where, e)
|
|
966
|
+
if (!at) { fail('place'); continue }
|
|
967
|
+
detach(e); at(e)
|
|
968
|
+
issues.push({kind: 'op', detail: `add of ${id}, which is on the page: moved`})
|
|
969
|
+
done.push({op: 'move', node: e, where: op.where})
|
|
970
|
+
} else {
|
|
971
|
+
const nk = fresh(it.id, f.params)
|
|
972
|
+
L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
|
|
973
|
+
const at = placement(op.where)
|
|
974
|
+
if (at) at(nk)
|
|
975
|
+
else { issues.push({kind: 'op', detail: `place ${op.where}: at the bottom`}); page().push(nk) }
|
|
976
|
+
done.push({op: 'add', node: nk, part: it.id, ...(f.params && {params: f.params}), where: op.where})
|
|
977
|
+
}
|
|
978
|
+
} else if (op.op == 'remove') {
|
|
979
|
+
if (!k) { fail('no such node'); continue }
|
|
980
|
+
detach(k); drop(k)
|
|
981
|
+
done.push({op: 'remove', node: k})
|
|
982
|
+
} else if (op.op == 'move') {
|
|
983
|
+
const at = k && placement(op.where, k)
|
|
984
|
+
if (!k || !at) { fail(k ? 'place' : 'no such node'); continue }
|
|
985
|
+
detach(k); at(k)
|
|
986
|
+
done.push({op: 'move', node: k, where: op.where})
|
|
987
|
+
} else if (op.op == 'replace') {
|
|
988
|
+
const it = ctx.byId[op.part]
|
|
989
|
+
if (!k || !it || it.container) { fail(k ? 'unknown part' : 'no such node'); continue }
|
|
990
|
+
if (L.nodes[k].part == it.id) {
|
|
991
|
+
// the same part: a param change (its key kept)
|
|
992
|
+
const why = setParams(k, op.params || {})
|
|
993
|
+
if (why) { fail(why); continue }
|
|
994
|
+
issues.push({kind: 'op', detail: `replace ${k} with the same part = setParams (key kept)`})
|
|
995
|
+
done.push({op: 'setParams', node: k, params: op.params || {}})
|
|
996
|
+
continue
|
|
997
|
+
}
|
|
998
|
+
const f = fixParams(it, op.params, issues)
|
|
999
|
+
if (f.drop) { fail('params'); continue }
|
|
1000
|
+
const id = identityOf(catalog, it.id, f.params)
|
|
1001
|
+
const e = count(id) >= it.uses ? existing(id) : undefined
|
|
1002
|
+
if (e && within(k, e)) { fail('place'); continue }
|
|
1003
|
+
if (e) detach(e)
|
|
1004
|
+
const nk = e || fresh(it.id, f.params)
|
|
1005
|
+
if (!e) L.nodes[nk] = {part: it.id, ...(f.params && {params: f.params})}
|
|
1006
|
+
const p = parentOf(k)!
|
|
1007
|
+
p.arr.splice(p.i, 1, nk)
|
|
1008
|
+
drop(k)
|
|
1009
|
+
done.push({op: 'replace', node: k, part: it.id, ...(f.params && {params: f.params}), ...(e && {moved: e})})
|
|
1010
|
+
} else if (op.op == 'setParams' || op.op == 'setParam') {
|
|
1011
|
+
const given = op.params && typeof op.params == 'object' && !Array.isArray(op.params) ? op.params : typeof op.param == 'string' ? {[op.param]: op.value} : null
|
|
1012
|
+
if (!k || !given) { fail(k ? 'no params' : 'no such node'); continue }
|
|
1013
|
+
const why = setParams(k, given)
|
|
1014
|
+
if (why) { fail(why); continue }
|
|
1015
|
+
done.push({op: 'setParams', node: k, params: given})
|
|
1016
|
+
} else fail('unknown operation')
|
|
1017
|
+
}
|
|
1018
|
+
return {layout: L, applied: done.length, ops: done, issues}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/** a value with its object keys sorted (tree comparison) */
|
|
1022
|
+
const canon = (v: any): string => JSON.stringify(v, (_k, x) => x && typeof x == 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map(k => [k, x[k]])) : x)
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* fake(): the operations that turn `previous` into `target` (keys of the parts both have kept),
|
|
1026
|
+
* found greedily and then minimized; null when operations can't reach it (the caller sends the
|
|
1027
|
+
* whole page). Every result is checked by applying it.
|
|
1028
|
+
*/
|
|
1029
|
+
function editOps(catalog: any, state: any, previous: any, order: string[], target: any[]): any[] | null {
|
|
1030
|
+
const want = finish(catalog, fromTree(catalog, target, previous), state)
|
|
1031
|
+
const goal = canon(toTree(want))
|
|
1032
|
+
const root = previous.root || 'page'
|
|
1033
|
+
const alias = (k: string) => `n${order.indexOf(k)}`
|
|
1034
|
+
const P = previous.nodes, W = want.nodes
|
|
1035
|
+
const isLeaf = (k: string, nodes: any) => catalog.get(nodes[k]?.part)?.kind == 'part'
|
|
1036
|
+
const isRow = (k: string, nodes: any) => { const e = catalog.get(nodes[k]?.part); return e?.kind == 'container' && (e.direction == 'row' || e.id == 'row') }
|
|
1037
|
+
const kept = new Set(order.filter(k => k != root && own(W, k) && W[k].part == P[k].part))
|
|
1038
|
+
const keptLeaves = [...kept].filter(k => isLeaf(k, P))
|
|
1039
|
+
const ok = (ops: any[]) => {
|
|
1040
|
+
const a = applyOps(catalog, state, previous, order, ops)
|
|
1041
|
+
if (a.issues.some(i => i.kind == 'op')) return false
|
|
1042
|
+
const got = finish(catalog, a.layout, state)
|
|
1043
|
+
return canon(toTree(got)) == goal && keptLeaves.every(k => got.nodes[k]?.part == P[k].part)
|
|
1044
|
+
}
|
|
1045
|
+
const ops: any[] = []
|
|
1046
|
+
// params of the kept nodes (a param back to its default is set to the default)
|
|
1047
|
+
for (const k of kept) {
|
|
1048
|
+
const a = P[k].params || {}, b = W[k].params || {}
|
|
1049
|
+
if (canon(a) == canon(b)) continue
|
|
1050
|
+
if (!isLeaf(k, P)) return null
|
|
1051
|
+
const e: any = catalog.get(P[k].part), params: any = {}
|
|
1052
|
+
for (const n of new Set([...Object.keys(a), ...Object.keys(b)])) if (canon(a[n]) != canon(b[n])) params[n] = n in b ? b[n] : e.params[n]?.default
|
|
1053
|
+
ops.push({op: 'setParams', node: alias(k), params})
|
|
1054
|
+
}
|
|
1055
|
+
// the page's blocks in order: each block's anchor is a kept node it holds
|
|
1056
|
+
const pageOf = (n: any) => n[want.root || 'page']?.slots?.default || []
|
|
1057
|
+
const blocks = pageOf(W).map((b: string) => {
|
|
1058
|
+
if (isLeaf(b, W)) return {b, anchor: kept.has(b) ? b : null, kids: null}
|
|
1059
|
+
if (isRow(b, W)) {
|
|
1060
|
+
const kids: string[] = W[b].slots?.default || []
|
|
1061
|
+
const k = kids.filter(x => kept.has(x))
|
|
1062
|
+
if (!k.length) return null
|
|
1063
|
+
return {b, anchor: k[0], kids}
|
|
1064
|
+
}
|
|
1065
|
+
// another container: only moved as a whole, when it holds the same nodes as before
|
|
1066
|
+
const same = kept.has(b) && canon(toTree({root: 'page', nodes: {...W, page: {part: 'page', slots: {default: [b]}}}} as any)) == canon(toTree({root: 'page', nodes: {...P, page: {part: 'page', slots: {default: [b]}}}} as any))
|
|
1067
|
+
return same ? {b, anchor: b, kids: null} : null
|
|
1068
|
+
})
|
|
1069
|
+
if (blocks.some((x: any) => !x)) return null
|
|
1070
|
+
const parentIn = (nodes: any, k: string) => Object.keys(nodes).find(pk => Object.values<any>(nodes[pk].slots || {}).some((s: string[]) => s.includes(k)))
|
|
1071
|
+
const anchored = blocks.filter((x: any) => x.anchor)
|
|
1072
|
+
const moves: any[] = [], adds: any[] = []
|
|
1073
|
+
const addOp = (k: string, where: string) => ({op: 'add', part: W[k].part, ...(W[k].params && {params: W[k].params}), where})
|
|
1074
|
+
anchored.forEach((x: any, j: number) => {
|
|
1075
|
+
// the block: its row as a whole when the anchor's row is the same row; else the anchor alone
|
|
1076
|
+
const pr = parentIn(P, x.anchor)
|
|
1077
|
+
const mover = x.kids && pr && pr == x.b ? x.b : x.anchor
|
|
1078
|
+
moves.push({op: 'move', node: alias(mover), where: j == 0 ? 'top' : `below ${alias(anchored[j - 1].anchor)}`})
|
|
1079
|
+
if (!x.kids) return
|
|
1080
|
+
// a row's parts: the kept ones in order after the anchor, then the new ones around them
|
|
1081
|
+
const K = x.kids.filter((c: string) => kept.has(c))
|
|
1082
|
+
for (let i = 1; i < K.length; i++) moves.push({op: 'move', node: alias(K[i]), where: `right of ${alias(K[i - 1])}`})
|
|
1083
|
+
const first = x.kids.indexOf(K[0])
|
|
1084
|
+
for (const c of x.kids.slice(0, first)) adds.push(addOp(c, `left of ${alias(K[0])}`))
|
|
1085
|
+
K.forEach((kk: string, i: number) => {
|
|
1086
|
+
const from = x.kids.indexOf(kk) + 1, to = i + 1 < K.length ? x.kids.indexOf(K[i + 1]) : x.kids.length
|
|
1087
|
+
for (const c of x.kids.slice(from, to).reverse()) adds.push(addOp(c, `right of ${alias(kk)}`))
|
|
1088
|
+
})
|
|
1089
|
+
})
|
|
1090
|
+
// new parts on the page: before the first kept block, between, after
|
|
1091
|
+
const firstA = blocks.findIndex((x: any) => x.anchor)
|
|
1092
|
+
if (firstA < 0) for (const x of blocks) adds.push(addOp(x.b, 'bottom'))
|
|
1093
|
+
else {
|
|
1094
|
+
for (const x of blocks.slice(0, firstA)) adds.push(addOp(x.b, `above ${alias(blocks[firstA].anchor)}`))
|
|
1095
|
+
blocks.forEach((x: any, i: number) => {
|
|
1096
|
+
if (!x.anchor) return
|
|
1097
|
+
let to = i + 1
|
|
1098
|
+
while (to < blocks.length && !blocks[to].anchor) to++
|
|
1099
|
+
for (const y of blocks.slice(i + 1, to).reverse()) adds.push(addOp(y.b, `below ${alias(x.anchor)}`))
|
|
1100
|
+
})
|
|
1101
|
+
}
|
|
1102
|
+
// removes last (a removed row's kept parts have moved out by then), outermost first
|
|
1103
|
+
const removes: any[] = []
|
|
1104
|
+
const gone = new Set<string>()
|
|
1105
|
+
for (const k of order) {
|
|
1106
|
+
if (k == root || kept.has(k)) continue
|
|
1107
|
+
const p = parentIn(P, k)
|
|
1108
|
+
if (p && gone.has(p)) { gone.add(k); continue }
|
|
1109
|
+
gone.add(k)
|
|
1110
|
+
removes.push({op: 'remove', node: alias(k)})
|
|
1111
|
+
}
|
|
1112
|
+
let all = [...ops, ...moves, ...adds, ...removes]
|
|
1113
|
+
if (!ok(all)) return null
|
|
1114
|
+
// minimize: drop each operation the result doesn't need
|
|
1115
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
1116
|
+
const fewer = all.slice(0, i).concat(all.slice(i + 1))
|
|
1117
|
+
if (ok(fewer)) all = fewer
|
|
1118
|
+
}
|
|
1119
|
+
return all
|
|
1120
|
+
}
|