sygnal 6.2.0 → 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 +61 -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 +164 -3
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +164 -3
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/agent.md +7 -1
- package/dist/guide/ai-cards.md +319 -0
- package/dist/guide/ai-chat.md +4 -0
- package/dist/guide/ai-compose.md +881 -0
- package/dist/guide/ai-decisions.md +16 -0
- package/dist/guide/http.md +1 -0
- package/dist/index.cjs.js +5695 -347
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +638 -8
- package/dist/index.esm.js +5685 -347
- 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 +6 -4
- package/package.json +2 -1
- package/src/ai.d.ts +642 -7
- 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/chat/behavior.ts +354 -81
- 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/diagnostics/checks/next.ts +86 -2
- package/src/extra/diagnostics/codes.ts +54 -0
- 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 +41 -0
- 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
|
@@ -12,7 +12,7 @@ import {messageId} from '../messages';
|
|
|
12
12
|
*
|
|
13
13
|
* - Request (sink value): `{ messages, instructions?, tools?, model?, output?, key?, latest?,
|
|
14
14
|
* delta?, ok?, error?, tool?, coalesce?, ...transportOptions }`, passed to the transport as is
|
|
15
|
-
* (sender-stamped, scope-tagged). `{ abort: key | true }` / `{ abort: true, key }` stop the
|
|
15
|
+
* (sender-stamped, scope-tagged). `{ abort: key | [keys] | true }` / `{ abort: true, key }` stop the
|
|
16
16
|
* sender's runs under that key (all of them for a bare `abort: true`).
|
|
17
17
|
* - A `then` / `catch` key is SYG610 (allowed()); a value that isn't a request (no `messages`
|
|
18
18
|
* array, an unknown `coalesce`, an `output` that isn't a Standard Schema) is SYG679: not sent.
|
|
@@ -24,7 +24,8 @@ import {messageId} from '../messages';
|
|
|
24
24
|
* `delta` is the text that is new since the last one (D251);
|
|
25
25
|
* - `tool` `{ key, call: { id, name, input } }` once per completed call;
|
|
26
26
|
* - `ok` `{ key, message, text, value?, toolCalls, finishReason, usage }` (`value`: the
|
|
27
|
-
* validated structured output when the request has `output`, G-604
|
|
27
|
+
* validated structured output when the request has `output`, G-604; not with `parse: false`,
|
|
28
|
+
* which sends the schema but leaves reading `text` to the caller: PLAN-9 compose.llm());
|
|
28
29
|
* - `error` `{ key, error, request, issues? }`. A failure with no `error` action is SYG678
|
|
29
30
|
* (logged, also in production; G-584).
|
|
30
31
|
* - An aborted run (abort, latest, sender dispose, app dispose, sink complete) delivers nothing:
|
|
@@ -221,7 +222,8 @@ export function makeChatDriver(options: any = {}) {
|
|
|
221
222
|
if (!live()) return;
|
|
222
223
|
flush();
|
|
223
224
|
let value: any;
|
|
224
|
-
|
|
225
|
+
// `parse: false` (PLAN-9 B-4): the transport still sends the schema, the caller reads `text` (compose.llm()'s lenient reader)
|
|
226
|
+
const structured = req.output !== undefined && req.parse !== false && (!toolCalls.length || !!text.trim());
|
|
225
227
|
if (structured) {
|
|
226
228
|
value = await readOutput(req.output, text);
|
|
227
229
|
if (!live()) return;
|
|
@@ -244,8 +246,9 @@ export function makeChatDriver(options: any = {}) {
|
|
|
244
246
|
if (sender === undefined) return void dev('SYG677', req);
|
|
245
247
|
const {abort} = req;
|
|
246
248
|
if (abort !== undefined && abort !== false) {
|
|
249
|
+
// D469 (PLAN-9 H-2): a list of keys stops each (STOP: the reply and a card being composed)
|
|
247
250
|
const k = abort === true ? req.key : abort;
|
|
248
|
-
return abortWhere(r => r.sender === sender && (k === undefined || r.key === k));
|
|
251
|
+
return abortWhere(r => r.sender === sender && (k === undefined || (Array.isArray(k) ? k.includes(r.key) : r.key === k)));
|
|
249
252
|
}
|
|
250
253
|
const why = problem(req);
|
|
251
254
|
if (why) return void logError('SYG679', req.__emitterName, `makeChatDriver: request not sent: ${why}`, FIX, req);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 C-2: a composition as an agent action, so a chat assistant (or WebMCP, the command bar)
|
|
3
|
+
* can rearrange the page. The behavior's COMPOSE is an ordinary host action ('page.COMPOSE'), so
|
|
4
|
+
* the A-1 agent layer runs it like any other; these helpers only save the boilerplate:
|
|
5
|
+
*
|
|
6
|
+
* App.uses = { page: compose(catalog, { backend }), assistant: chat({ form: '.ask', prompt: '.prompt' }) }
|
|
7
|
+
* App.agent = {
|
|
8
|
+
* name: 'app',
|
|
9
|
+
* read: (state) => ({ page: compose.summary(state.page) }),
|
|
10
|
+
* actions: { ...compose.action('page') }, // the tool app_page_compose({ prompt, fresh? })
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* - compose.action(key = 'page', { description?, timeout? }): `{ [key + '.COMPOSE']: { description,
|
|
14
|
+
* input: { prompt, fresh? }, until, timeout } }`. `until` holds the tool result until the
|
|
15
|
+
* composition is over (status neither 'composing' nor 'preview': done, unavailable or error), so
|
|
16
|
+
* the result's `state` (the `read` projection) shows the new page; `timeout` (default 60 s, a
|
|
17
|
+
* model call takes a while) answers `{ ok: true, pending: true }` instead, as for any `until`.
|
|
18
|
+
* - compose.summary(slice): what the `read` projection shows of a composition: `{ status, page:
|
|
19
|
+
* the layout as a tree (toTree), error? }`.
|
|
20
|
+
*
|
|
21
|
+
* With compose.llm() the composition's request goes on the same LLM sink as the chat's, in its own
|
|
22
|
+
* lane (`key: 'compose'` vs the chat's uses key) with its own reply actions ('page.REPLY'), so the
|
|
23
|
+
* two never abort or answer each other; the chat's next request waits for `until`.
|
|
24
|
+
*/
|
|
25
|
+
import {jsonSchema} from '../schema/jsonSchema'
|
|
26
|
+
import {toTree} from './layout'
|
|
27
|
+
|
|
28
|
+
const INPUT = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
prompt: {type: 'string', minLength: 1, description: 'What the page should show, or how to change it, in the user\'s words'},
|
|
32
|
+
fresh: {type: 'boolean', description: 'true: start over instead of changing the current page'},
|
|
33
|
+
},
|
|
34
|
+
required: ['prompt'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** the `agent.actions` entry for the composition under `key` (see the header) */
|
|
39
|
+
export function composeAction(key: string = 'page', options: {description?: string; timeout?: number} = {}) {
|
|
40
|
+
const {description = 'Rearrange the page from its prepared parts: say what it should show (it changes the current page unless fresh is true). The result has the new page', timeout = 60000} = options
|
|
41
|
+
return {
|
|
42
|
+
[key + '.COMPOSE']: {
|
|
43
|
+
description,
|
|
44
|
+
input: jsonSchema(INPUT),
|
|
45
|
+
until: (state: any) => { const s = state?.[key]?.status; return s != 'composing' && s != 'preview' },
|
|
46
|
+
timeout,
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** a composition as an agent reads it: `{ status, page, error? }` (`page`: the layout as a tree) */
|
|
52
|
+
export function composeSummary(slice: any) {
|
|
53
|
+
if (!slice || typeof slice != 'object') return undefined
|
|
54
|
+
return {status: slice.status, page: toTree(slice.layout), ...(slice.error && {error: slice.error})}
|
|
55
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 H-2 / H-3: cards in the chat (dev-plans/research/ai-compose/chat-client-tools.md, D454,
|
|
3
|
+
* D455, D469). `chat({ ask: { catalog, backend?, instructions?, direct? } })` adds the client tool
|
|
4
|
+
* `ask_user({ need })`; the configured backend composes a card from `need` and the app state (never
|
|
5
|
+
* the conversation), and a result part's answer becomes the call's output `{ part, answer }`.
|
|
6
|
+
*
|
|
7
|
+
* The chat behavior reaches this module through the catalog (`catalog._ask`, set by defineCatalog),
|
|
8
|
+
* so an app with `chat` and no catalog carries none of it (nor compose.llm).
|
|
9
|
+
*
|
|
10
|
+
* The card lives on the tool part: `part.ui = { status, layout, local, ui, answer, pending, decisions? }`:
|
|
11
|
+
* - status: 'composing' (queued, or its backend step is out: `pending` set) | 'open' (answerable) |
|
|
12
|
+
* 'answered' | 'failed' (the part is output-error "could not show a card: …") | 'cancelled'
|
|
13
|
+
* (STOP, SEND);
|
|
14
|
+
* - layout: the card (a decision backend's preview while it composes); ui / local: container state
|
|
15
|
+
* and `local` parts' state per node key, as in a compose slice; answer: `{ node, part, value }`;
|
|
16
|
+
* - pending: the backend's step while its request is out (plain JSON);
|
|
17
|
+
* - decisions: what the backend explains, its last reply's `decisions` (as a compose slice keeps
|
|
18
|
+
* them; absent until a reply carries some).
|
|
19
|
+
* Cards compose one at a time, on the backend's sink, under the key `<use>.ask:<toolCallId>`, with
|
|
20
|
+
* the reply actions `<use>.CARD` / `<use>.CARD_FAILED`.
|
|
21
|
+
*
|
|
22
|
+
* `direct: true` (H-3): the tool's input schema is the layout schema (compose.llm's, without the
|
|
23
|
+
* plan field) and the call's input is the card, read and repaired like an LLM backend's reply: no
|
|
24
|
+
* compose round trip.
|
|
25
|
+
*
|
|
26
|
+
* `askCard(catalog, state, part, { key })` renders a card in the host's view: catalog.render with
|
|
27
|
+
* the call id as scope, frozen unless the card is open, the answer marked, `local` written through
|
|
28
|
+
* a lens to the part.
|
|
29
|
+
*/
|
|
30
|
+
import {warn} from '../../diagnostics/legacy'
|
|
31
|
+
import {llm, layoutSchema} from './llm'
|
|
32
|
+
import {cardArgs} from './render'
|
|
33
|
+
|
|
34
|
+
export const ASK = 'ask_user'
|
|
35
|
+
|
|
36
|
+
const DESCRIPTION = 'Ask the user and wait for their answer: a choice, a pick among the app\'s items, a short form or a confirmation. Say in one sentence what you need from them; the app shows a card for it. Prefer this to asking in text when the answer is one of a few options or structured.'
|
|
37
|
+
const DIRECT = 'Ask the user and wait for their answer: a choice, a pick among the app\'s items, a short form or a confirmation. The input is the card, built from the app\'s parts; include a part the user answers with. Prefer this to asking in text when the answer is one of a few options or structured.'
|
|
38
|
+
const NEED = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {need: {type: 'string', maxLength: 300, description: 'What you need from the user, in one sentence'}},
|
|
41
|
+
required: ['need'],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** the card compositions per catalog (composeWith answers their pending step) */
|
|
46
|
+
const cards = /*#__PURE__*/ new WeakMap<object, Array<{backend: any; keys: Set<string>; mine: WeakSet<object>}>>()
|
|
47
|
+
export const cardsOf = (catalog: object) => cards.get(catalog) || []
|
|
48
|
+
|
|
49
|
+
/** the chat's `ask` option, resolved once per chat() call */
|
|
50
|
+
export function askConfig(ask: any, o: any, sink: string) {
|
|
51
|
+
const {catalog, backend, direct, instructions} = ask
|
|
52
|
+
if (!catalog || typeof catalog.render != 'function') throw new Error('[Sygnal] chat({ ask }): `ask.catalog` must be a catalog made by defineCatalog()')
|
|
53
|
+
const results = [...new Set(Object.values<any>(catalog.parts).filter(p => p.result && p.component).map(p => p.component))]
|
|
54
|
+
if (!Object.values<any>(catalog.parts).some(p => p.result)) {
|
|
55
|
+
warn('SYG686', 'chat', 'the ask catalog has no result part (`result: true`), so no card can be answered: every ask_user call waits until STOP or a new message', 'Mark the parts the user answers with `result: true` and send compose.answer(props, value) to PARENT')
|
|
56
|
+
}
|
|
57
|
+
const be = direct ? null : backend ?? llm({sink, ...(o.model && {model: o.model}), ...o.transportOptions})
|
|
58
|
+
// mine: the pending steps this chat() made, so composeWith picks its backend among the catalog's
|
|
59
|
+
const entry = {backend: be, keys: new Set<string>(), mine: new WeakSet<object>()}
|
|
60
|
+
if (be) {
|
|
61
|
+
if (!cards.has(catalog)) cards.set(catalog, [])
|
|
62
|
+
cards.get(catalog)!.push(entry)
|
|
63
|
+
}
|
|
64
|
+
return {catalog, backend: be, direct: !!direct, instructions, results, entry, reader: direct ? llm() : null,
|
|
65
|
+
controls: Object.values<any>(catalog.containers || {}).some(c => typeof c.decl?.render == 'function')}
|
|
66
|
+
}
|
|
67
|
+
export type AskConfig = ReturnType<typeof askConfig>
|
|
68
|
+
|
|
69
|
+
/** the ask_user tool for a request (direct: the layout schema, for the host state of now) */
|
|
70
|
+
export const askTool = (c: AskConfig, state: any) => ({
|
|
71
|
+
description: (c.direct ? DIRECT : DESCRIPTION) + (c.instructions ? ' ' + c.instructions : ''),
|
|
72
|
+
inputSchema: c.direct ? layoutSchema(c.catalog, state, {plan: false}) : NEED,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const message = (e: any) => String(e?.message ?? e ?? 'error')
|
|
76
|
+
const ui = (p: any, patch: any) => ({...p, ui: {...p.ui, ...patch}})
|
|
77
|
+
|
|
78
|
+
/** the card can't be shown: the call answers with an error, and the turn goes on */
|
|
79
|
+
export const failed = (p: any, why: string) => ({
|
|
80
|
+
...ui(p, {status: 'failed', pending: null}),
|
|
81
|
+
state: 'output-error',
|
|
82
|
+
errorText: `could not show a card: ${why}; ask in text instead`,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
/** an ask_user call as it opens: composing (queued), or (direct) the card itself */
|
|
86
|
+
export function openCard(c: AskConfig, p: any, state: any): any {
|
|
87
|
+
const base = {...p, ui: {status: 'composing', layout: null, local: {}, ui: {}, answer: null, pending: null}}
|
|
88
|
+
if (c.direct) return read(c, base, c.reader!.reply({catalog: c.catalog, state, pending: {plan: false}, reply: {value: p.input}} as any))
|
|
89
|
+
const need = p.input?.need
|
|
90
|
+
return typeof need == 'string' && need.trim() ? base : failed(base, 'the call has no `need` (one sentence saying what you need from the user)')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** a backend result → the card part, and the next request when there is one */
|
|
94
|
+
function read(c: AskConfig, p: any, r: any): any {
|
|
95
|
+
if (!r) return failed(p, 'the backend gave no layout')
|
|
96
|
+
// what the backend explains (its last `decisions`), kept on the card like a compose slice's
|
|
97
|
+
if (r.decisions !== undefined) p = ui(p, {decisions: r.decisions})
|
|
98
|
+
if (r.error != null) return failed(p, message(r.error))
|
|
99
|
+
if (r.request !== undefined) return {part: ui(p, {pending: own(c, r.pending ?? p.ui.pending), ...(r.preview && {layout: r.preview})}), request: r.request}
|
|
100
|
+
if (r.layout) return ui(p, {status: 'open', layout: r.layout, pending: null})
|
|
101
|
+
return failed(p, 'nothing in the catalog fits it')
|
|
102
|
+
}
|
|
103
|
+
const own = (c: AskConfig, pending: any) => { if (pending && typeof pending == 'object') c.entry.mine.add(pending); return pending }
|
|
104
|
+
const names = (k: string, id: string) => ({ok: k + '.CARD', error: k + '.CARD_FAILED', key: `${k}.ask:${id}`}) as any
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* G-756: an HTTP reply (compose.decisions()) is the parsed body, with no request key, so a card's
|
|
108
|
+
* request tags its JSON body with the card's key (`askKey`); the chat's CARD step drops a reply
|
|
109
|
+
* tagged for another card (one closed by TOOL_OUTPUT while it composed). A request with its own
|
|
110
|
+
* `parse`, or one for the chat driver (its replies carry the key), is sent as it is.
|
|
111
|
+
*/
|
|
112
|
+
const tag = (req: any, key: string) => req && typeof req == 'object' && typeof req.url == 'string' && req.parse === undefined
|
|
113
|
+
? {...req, parse: async (res: any) => {
|
|
114
|
+
const text = await res.text(), v = text ? JSON.parse(text) : null
|
|
115
|
+
return v && typeof v == 'object' && !Array.isArray(v) ? {...v, askKey: key} : v
|
|
116
|
+
}}
|
|
117
|
+
: req
|
|
118
|
+
|
|
119
|
+
/** start composing a queued card: `{ part, request }`, or the failed part */
|
|
120
|
+
export function startCard(c: AskConfig, p: any, state: any, k: string): any {
|
|
121
|
+
c.entry.keys.add(k)
|
|
122
|
+
try {
|
|
123
|
+
const n = names(k, p.toolCallId)
|
|
124
|
+
const r = c.backend!.start({catalog: c.catalog, state, prompt: String(p.input.need).trim(), previous: null, names: n})
|
|
125
|
+
return {part: ui(p, {pending: own(c, r.pending ?? {})}), request: tag(r.request, n.key)}
|
|
126
|
+
} catch (e) { return failed(p, message(e)) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** a backend reply for the card in flight: `{ part, request }` (another step), or the card part */
|
|
130
|
+
export function cardReply(c: AskConfig, p: any, reply: any, state: any, k: string): any {
|
|
131
|
+
try {
|
|
132
|
+
const n = names(k, p.toolCallId), r = read(c, p, c.backend!.reply({catalog: c.catalog, state, pending: p.ui.pending, reply, names: n}))
|
|
133
|
+
return r?.request !== undefined ? {...r, request: tag(r.request, n.key)} : r
|
|
134
|
+
} catch (e) { return failed(p, message(e)) }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* askCard(catalog, state, part, { key = 'assistant' }): the card of an ask_user tool part, for
|
|
139
|
+
* the host's view (null while it has no layout)
|
|
140
|
+
*/
|
|
141
|
+
export function askCard(catalog: any, state: any, part: any, options: {key?: string} = {}): any {
|
|
142
|
+
const c = cardArgs(state, part, options.key)
|
|
143
|
+
return c && catalog.render(c[0], state, c[1])
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** what chat() reads through `catalog._ask` */
|
|
147
|
+
export const askKit = {askConfig, askTool, openCard, startCard, cardReply, failed}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 C-1: the `compose` behavior (phase1-contract.md, PROPOSAL.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Workspace.uses = { page: compose(catalog, { backend: compose.decisions({ model }), form: '.ask', prompt: '.prompt' }) }
|
|
5
|
+
* view: {catalog.render(state.page, state)}
|
|
6
|
+
*
|
|
7
|
+
* Slice (state.page): { prompt, status, layout, preview, pending, decisions, error, ui, local }
|
|
8
|
+
* - status: 'idle' | 'composing' (a request is out) | 'preview' (a partial layout shows: after a
|
|
9
|
+
* decision step, or while an LLM streams) | 'done' | 'unavailable' (nothing in the catalog fits:
|
|
10
|
+
* the layout is kept) | 'error' (the layout is kept; `error` says why);
|
|
11
|
+
* - layout: the last finished layout; preview: the one being built (rendered instead while set);
|
|
12
|
+
* - pending: the backend's step (plain JSON); decisions: what the backend explains (its last);
|
|
13
|
+
* - ui: container state per node key (CONTROL: `{ key, name, value }` → ui[key][name] = value; the
|
|
14
|
+
* clicks are read only when a container has its own render);
|
|
15
|
+
* local: the state of `local` parts per node key.
|
|
16
|
+
*
|
|
17
|
+
* Actions ('page.X'): COMPOSE (data: a prompt string, `{ prompt, fresh? }`, or the form's submit:
|
|
18
|
+
* the slice's prompt; an edit of the current layout unless `fresh`), SET_LAYOUT (a layout, finished
|
|
19
|
+
* first: restore, undo, the app's own edits; null clears), REMOVE (a node key), CONTROL, DONE (a
|
|
20
|
+
* composition finished: data is the layout; a host entry 'page.DONE' runs after it). Internal:
|
|
21
|
+
* PROMPT (typing), REPLY / DELTA / FAILED (the backend sink's reply actions). A backend reply
|
|
22
|
+
* `{ error }` (not a valid layout even after repair), or a start / reply that throws, is handled as
|
|
23
|
+
* FAILED: status 'error', the layout kept; a delta that throws is ignored.
|
|
24
|
+
*
|
|
25
|
+
* The only sink it declares is `backend.sink`. Requests go in the lane `key` (default 'compose')
|
|
26
|
+
* with `latest`, so a new prompt aborts the old one's step in flight. The reply names are the
|
|
27
|
+
* use's key: `page.REPLY`, `page.DELTA`, `page.FAILED`. Finished layouts and previews are re-keyed
|
|
28
|
+
* against the current layout (fromTree's previous-key reuse), so a part a follow-up doesn't change
|
|
29
|
+
* keeps its node key, and so its instance and state, whatever keys the backend gave it. A reply
|
|
30
|
+
* or delta with `keyed: true` (both backends' edits, B-4) already carries the previous layout's keys
|
|
31
|
+
* and is used as is (re-keying by identity could swap the keys of two copies of one part).
|
|
32
|
+
*
|
|
33
|
+
* The handlers that need the host's whole state (record params read it) are HOST entries; a sink
|
|
34
|
+
* reads it as `props.state` (the pre-action state), and one memo per behavior value makes STATE,
|
|
35
|
+
* the sink and EFFECT of one action compute the same step.
|
|
36
|
+
*/
|
|
37
|
+
import {defineBehavior} from '../../behaviors'
|
|
38
|
+
import {ABORT} from '../../../shared'
|
|
39
|
+
import {fail} from '../../diagnostics/legacy'
|
|
40
|
+
import {finish, fromTree, removeNode, toTree} from './layout'
|
|
41
|
+
import type {Backend, Catalog, Layout} from './types'
|
|
42
|
+
|
|
43
|
+
/** the compositions per catalog (composeWith finds the pending one): backend, lane and use keys */
|
|
44
|
+
const uses = /*#__PURE__*/ new WeakMap<object, Array<{backend: Backend; lane: string; keys: Set<string>; mine: WeakSet<object>}>>()
|
|
45
|
+
export const usesOf = (catalog: object) => uses.get(catalog) || []
|
|
46
|
+
|
|
47
|
+
const message = (e: any) => String(e?.message ?? e ?? 'error')
|
|
48
|
+
|
|
49
|
+
/** one step per (host state, data, key): STATE, the sink and EFFECT of one action share it */
|
|
50
|
+
const once = <R>(f: (st: any, d: any, k: string) => R) => {
|
|
51
|
+
let a: any[] = [], r: R
|
|
52
|
+
return (st: any, d: any, k: string): R => a[0] === st && a[1] === d && a[2] === k ? r : (a = [st, d, k], r = f(st, d, k))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** a click on a container control (render's `control()` data attributes) */
|
|
56
|
+
const controlOf = (e: any) => {
|
|
57
|
+
const el = e?.ownerTarget || e?.target?.closest?.('[data-compose-control]') || e?.target
|
|
58
|
+
const ds = el?.dataset || {}
|
|
59
|
+
if (!ds.composeControl || !ds.composeKey) return null
|
|
60
|
+
let value: any = ds.composeValue
|
|
61
|
+
try { value = JSON.parse(value) } catch (_) { /* a plain string */ }
|
|
62
|
+
return {key: ds.composeKey, name: ds.composeControl, value, at: ds.composeAt, scope: ds.composeScope}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* compose(catalog, { backend, form?, prompt?, key? }): the behavior (a `uses` value). Types:
|
|
67
|
+
* src/ai.d.ts.
|
|
68
|
+
*/
|
|
69
|
+
export function composeBehavior(catalog: Catalog, options: any = {}): any {
|
|
70
|
+
const {backend, form, prompt: promptSel, key: lane = 'compose'} = options
|
|
71
|
+
if (!backend || typeof backend.sink != 'string' || typeof backend.start != 'function' || typeof backend.reply != 'function') {
|
|
72
|
+
fail('SYG681', 'compose', 'compose(catalog, { backend }) needs a backend', 'Pass compose.decisions({ model }) or compose.llm({ model })')
|
|
73
|
+
}
|
|
74
|
+
if (!catalog || typeof catalog.render != 'function') fail('SYG681', 'compose', 'compose(catalog, …) needs a catalog made by defineCatalog()', 'Pass defineCatalog({ parts })')
|
|
75
|
+
const S: string = backend.sink
|
|
76
|
+
// mine: the pending steps this use made, so composeWith picks its backend among the catalog's uses
|
|
77
|
+
const entry = {backend, lane, keys: new Set<string>(), mine: new WeakSet<object>()}
|
|
78
|
+
const own = (p: any) => { if (p && typeof p == 'object') entry.mine.add(p); return p }
|
|
79
|
+
if (!uses.has(catalog)) uses.set(catalog, [])
|
|
80
|
+
uses.get(catalog)!.push(entry)
|
|
81
|
+
const names = (k: string) => ({ok: k + '.REPLY', error: k + '.FAILED', delta: k + '.DELTA', key: lane})
|
|
82
|
+
const rekey = (l: Layout, prev: Layout | null): Layout => (prev ? fromTree(catalog, toTree(l), prev) : l)
|
|
83
|
+
|
|
84
|
+
const start = once((st, d, k) => {
|
|
85
|
+
const s = st?.[k]
|
|
86
|
+
if (!s) return null
|
|
87
|
+
const raw = typeof d == 'string' ? d : d && typeof d == 'object' && typeof d.prompt == 'string' ? d.prompt : s.prompt
|
|
88
|
+
const prompt = String(raw ?? '').trim()
|
|
89
|
+
if (!prompt) return null
|
|
90
|
+
entry.keys.add(k)
|
|
91
|
+
const base = {...s, prompt, preview: null, decisions: null, error: null}
|
|
92
|
+
try {
|
|
93
|
+
const r = backend.start({catalog, state: st, prompt, previous: d?.fresh ? null : s.layout, names: names(k)})
|
|
94
|
+
return {slice: {...base, status: 'composing', pending: own(r.pending ?? {})}, request: r.request}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return {slice: {...base, status: 'error', pending: null, error: message(e)}}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const reply = once((st, d, k) => {
|
|
101
|
+
const s = st?.[k]
|
|
102
|
+
if (!s?.pending) return null
|
|
103
|
+
let r: any
|
|
104
|
+
try { r = backend.reply({catalog, state: st, pending: s.pending, reply: d, names: names(k)}) || {} } catch (e) {
|
|
105
|
+
return {slice: {...s, status: 'error', preview: null, pending: null, error: message(e)}}
|
|
106
|
+
}
|
|
107
|
+
const decisions = r.decisions !== undefined ? r.decisions : s.decisions
|
|
108
|
+
// a reply that isn't a valid layout (even after the backend's repair): as FAILED
|
|
109
|
+
if (r.error != null) return {slice: {...s, status: 'error', preview: null, pending: null, decisions, error: message(r.error)}}
|
|
110
|
+
if (r.request !== undefined) {
|
|
111
|
+
const preview = r.preview ? (r.keyed ? r.preview : rekey(r.preview, s.layout)) : s.preview
|
|
112
|
+
return {slice: {...s, status: preview ? 'preview' : 'composing', preview, pending: own(r.pending ?? s.pending), decisions}, request: r.request}
|
|
113
|
+
}
|
|
114
|
+
if (r.layout) {
|
|
115
|
+
// `keyed` (B-4 edits): the backend kept the previous keys itself (moves, new rows): not re-keyed
|
|
116
|
+
const layout = r.keyed ? r.layout : rekey(r.layout, s.layout)
|
|
117
|
+
return {slice: {...s, status: 'done', layout, preview: null, pending: null, decisions, error: null}, done: layout}
|
|
118
|
+
}
|
|
119
|
+
if (r.unavailable || r.layout === null) return {slice: {...s, status: 'unavailable', preview: null, pending: null, decisions}}
|
|
120
|
+
return {slice: {...s, status: 'error', preview: null, pending: null, decisions, error: 'the backend gave no layout'}}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const host = (step: (st: any, d: any, k: string) => any) =>
|
|
124
|
+
(st: any, d: any, _n: any, _p: any, _o: any, k: string) => { const r = step(st, d, k); return r?.slice ? {...st, [k]: r.slice} : ABORT }
|
|
125
|
+
const sink = (step: (st: any, d: any, k: string) => any) =>
|
|
126
|
+
(_s: any, d: any, _n: any, p: any, _o: any, k: string) => { const r = step(p?.state, d, k); return r?.request !== undefined ? r.request : ABORT }
|
|
127
|
+
|
|
128
|
+
// `prompt` (a selector) must not set the slice's prompt (defineBehavior merges options naming
|
|
129
|
+
// initialState keys): the factory gets the other options
|
|
130
|
+
const {prompt: _selector, ...rest} = options
|
|
131
|
+
const controls = Object.values(catalog.containers || {}).some((c: any) => typeof c.decl?.render == 'function')
|
|
132
|
+
return defineBehavior({
|
|
133
|
+
initialState: {prompt: '', status: 'idle', layout: null, preview: null, pending: null, decisions: null, error: null, ui: {}, local: {}},
|
|
134
|
+
intent: (so: any, _o: any, k: string) => {
|
|
135
|
+
const {DOM} = so
|
|
136
|
+
if (!DOM) return {}
|
|
137
|
+
return {
|
|
138
|
+
...(promptSel && {PROMPT: DOM.select(promptSel).events('input').map((e: any) => e.target.value)}),
|
|
139
|
+
...(form && {COMPOSE: DOM.select(form).events('submit', {preventDefault: true}).mapTo(undefined)}),
|
|
140
|
+
// only when a container has its own render (the built-in views render no controls): else
|
|
141
|
+
// the selector never matches (SYG103 in dev)
|
|
142
|
+
...(controls && {CONTROL: DOM.select('[data-compose-control]').events('click').map(controlOf)
|
|
143
|
+
.filter((c: any) => c && c.scope === undefined && (c.at === undefined || c.at === k))
|
|
144
|
+
.map(({key, name, value}: any) => ({key, name, value}))}),
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
model: {
|
|
148
|
+
PROMPT: (s: any, p: any) => typeof p == 'string' && p !== s.prompt ? {...s, prompt: p} : ABORT,
|
|
149
|
+
COMPOSE: {HOST: host(start), [S]: sink(start)},
|
|
150
|
+
REPLY: {
|
|
151
|
+
HOST: host(reply),
|
|
152
|
+
[S]: sink(reply),
|
|
153
|
+
EFFECT: (_s: any, d: any, next: any, p: any, _o: any, k: string) => { const r = reply(p?.state, d, k); if (r?.done) next('DONE', r.done) },
|
|
154
|
+
},
|
|
155
|
+
DELTA: {
|
|
156
|
+
HOST: (st: any, d: any, _n: any, _p: any, _o: any, k: string) => {
|
|
157
|
+
const s = st[k]
|
|
158
|
+
if (!s?.pending || typeof backend.delta != 'function') return ABORT
|
|
159
|
+
let r: any
|
|
160
|
+
try { r = backend.delta({catalog, state: st, pending: s.pending, delta: d}) } catch (_) { return ABORT }
|
|
161
|
+
return r?.preview ? {...st, [k]: {...s, status: 'preview', preview: r.keyed ? r.preview : rekey(r.preview, s.layout)}} : ABORT
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
FAILED: (s: any, d: any) => s.pending ? {...s, status: 'error', preview: null, pending: null, error: message(d?.error ?? d)} : ABORT,
|
|
165
|
+
SET_LAYOUT: {
|
|
166
|
+
HOST: (st: any, d: any, _n: any, _p: any, _o: any, k: string) => {
|
|
167
|
+
const layout = d == null ? null : finish(catalog, d, st)
|
|
168
|
+
return {...st, [k]: {...st[k], layout, preview: null, pending: null, error: null, status: layout ? 'done' : 'idle'}}
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
REMOVE: {
|
|
172
|
+
HOST: (st: any, key: any, _n: any, _p: any, _o: any, k: string) => {
|
|
173
|
+
const s = st[k], l = s?.layout
|
|
174
|
+
if (!l?.nodes || typeof key != 'string' || !Object.prototype.hasOwnProperty.call(l.nodes, key) || key == l.root) return ABORT
|
|
175
|
+
return {...st, [k]: {...s, layout: finish(catalog, removeNode(l, key), st)}}
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
CONTROL: (s: any, c: any) => c && typeof c.key == 'string' && typeof c.name == 'string'
|
|
179
|
+
? {...s, ui: {...s.ui, [c.key]: {...s.ui?.[c.key], [c.name]: c.value}}} : ABORT,
|
|
180
|
+
DONE: () => ABORT,
|
|
181
|
+
},
|
|
182
|
+
})(rest)
|
|
183
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* PLAN-9 K-1: defineCatalog(decl), the declaration both backends compose from (phase1-contract.md,
|
|
3
|
+
* PROPOSAL.md §1). Normalizes and validates the parts and containers (one id namespace; the
|
|
4
|
+
* built-ins `page`, the root, and `row`, side by side: `{ min: 2, max: 4, accepts: 'leaves' }`;
|
|
5
|
+
* `rows: false` (or `containers: { row: false }`) opts out of it), and returns the Catalog: `parts`,
|
|
6
|
+
* `containers`, `get(id)`, `options(state)` (every selectable option, a record part once per
|
|
7
|
+
* record), `records(part, state)`, `finish(layout, state)` (K-3), `render(slice, state, opts)`
|
|
8
|
+
* (K-2), `description` (the page's label, with `containers.page.description`), `categories`
|
|
9
|
+
* (`{ id: description }`, for a category prefilter; parts name one in `category`), `rows`,
|
|
10
|
+
* `guidance`, `limits` (`{ maxParts: 12, maxDepth: 4 }`) and `examples`. Containers carry
|
|
11
|
+
* `description`, `in`, `slots`, `minSlots`, `min` / `max` (children per slot), `accepts: 'leaves'`,
|
|
12
|
+
* `arranged`, `direction` and `params`.
|
|
13
|
+
*
|
|
14
|
+
* Errors: an id that can't be a node key (empty, or with ':', '~', '/'), a part id that is also a
|
|
15
|
+
* container's, a part named `page` / `row`, a malformed param and two record params on a part throw
|
|
16
|
+
* at the call (an authoring mistake). The rest are dev diagnostics (./diag.ts): SYG161 (a
|
|
17
|
+
* container's `render` is a component with a model: it is still called as a view), SYG162 (a part
|
|
18
|
+
* with neither `component` nor `render`: it renders nothing), SYG450 (`in` names an unknown
|
|
19
|
+
* container or slot: that entry is dropped), SYG250 (an unknown companion: dropped) and, when the
|
|
20
|
+
* records are read, SYG252.
|
|
21
|
+
*/
|
|
22
|
+
import {createElement as h} from '../../../pragma/index'
|
|
23
|
+
import {dev} from './diag'
|
|
24
|
+
import {finish} from './layout'
|
|
25
|
+
import {renderLayout} from './render'
|
|
26
|
+
import {askKit} from './ask'
|
|
27
|
+
import type {Catalog, Container, Entry, Option, Param, Part} from './types'
|
|
28
|
+
|
|
29
|
+
const BAD_ID = /^$|[:~/]/
|
|
30
|
+
const fail = (msg: string): never => { throw new Error(`[Sygnal] defineCatalog: ${msg}`) }
|
|
31
|
+
|
|
32
|
+
const pageView = ({slots}: any) => h('div', {className: 'compose-page'}, slots.default)
|
|
33
|
+
const rowView = ({slots}: any) => h('div', {className: 'compose-row'}, slots.default)
|
|
34
|
+
/** a container declared without `render`: its slots in order */
|
|
35
|
+
const slotsView = ({slots}: any) => h('div', {className: 'compose-container'}, Object.keys(slots).map(s => slots[s]))
|
|
36
|
+
|
|
37
|
+
function param(part: string, name: string, p: any): Param {
|
|
38
|
+
const where = `part '${part}', param '${name}'`
|
|
39
|
+
if (!p || typeof p != 'object') return fail(`${where} is not an object`)
|
|
40
|
+
if (typeof p.from == 'function') return {kind: 'record', from: p.from, label: p.label}
|
|
41
|
+
if (p.options && typeof p.options == 'object') {
|
|
42
|
+
const keys = Object.keys(p.options)
|
|
43
|
+
if (!keys.length) fail(`${where} has no options`)
|
|
44
|
+
if (p.default !== undefined && !keys.includes(String(p.default))) fail(`${where}: the default '${p.default}' is not one of its options`)
|
|
45
|
+
return {kind: 'options', options: p.options, default: p.default ?? keys[0], label: p.label, ask: p.ask}
|
|
46
|
+
}
|
|
47
|
+
if (p.type == 'number') {
|
|
48
|
+
if (p.default !== undefined && typeof p.default != 'number') fail(`${where}: the default is not a number`)
|
|
49
|
+
return {kind: 'number', min: p.min, max: p.max, default: p.default, bins: p.bins, label: p.label}
|
|
50
|
+
}
|
|
51
|
+
if (p.type == 'text') return {kind: 'text', maxLength: p.maxLength, default: p.default, required: !!p.required, label: p.label}
|
|
52
|
+
if (p.type == 'list') return {kind: 'list', items: p.items, min: p.min, max: p.max, required: !!p.required, default: p.default, label: p.label}
|
|
53
|
+
return fail(`${where} needs \`options\`, \`from\`, or \`type: 'number' | 'text' | 'list'\``)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isComponent = (f: any) => typeof f == 'function' && !!(f.model || f.intent || f.initialState || f.uses)
|
|
57
|
+
|
|
58
|
+
function container(id: string, d: any): Container {
|
|
59
|
+
const slots = d.slots && typeof d.slots == 'object' && Object.keys(d.slots).length ? d.slots : {default: null}
|
|
60
|
+
let render = d.render
|
|
61
|
+
if (isComponent(render)) {
|
|
62
|
+
dev('SYG161', 'defineCatalog', `container '${id}': its render is a component with a model or intent; containers are view functions (a component would own the parts, so the host's CHILD.select and lenses stop reaching them). It is called as a view; its model doesn't run`, 'error')
|
|
63
|
+
}
|
|
64
|
+
if (typeof render != 'function') render = id == 'page' ? pageView : id == 'row' ? rowView : slotsView
|
|
65
|
+
// min / max: children per slot (non-negative integers, min <= max); accepts: 'leaves' (no nested
|
|
66
|
+
// containers). An invalid value is ignored (SYG450, like an invalid `in`)
|
|
67
|
+
const count = (k: string) => {
|
|
68
|
+
const v = d[k]
|
|
69
|
+
if (v == null) return undefined
|
|
70
|
+
if (Number.isInteger(v) && v >= 0) return v
|
|
71
|
+
dev('SYG450', 'defineCatalog', `container '${id}': \`${k}\` must be a non-negative integer (children per slot); it is ignored`)
|
|
72
|
+
}
|
|
73
|
+
let min = count('min'), max = count('max')
|
|
74
|
+
if (min != null && max != null && min > max) {
|
|
75
|
+
dev('SYG450', 'defineCatalog', `container '${id}': \`min\` ${min} is above \`max\` ${max}; both are ignored`)
|
|
76
|
+
min = max = undefined
|
|
77
|
+
}
|
|
78
|
+
let accepts = d.accepts
|
|
79
|
+
if (accepts != null && accepts != 'leaves') {
|
|
80
|
+
dev('SYG450', 'defineCatalog', `container '${id}': \`accepts\` takes only 'leaves'; it is ignored`)
|
|
81
|
+
accepts = undefined
|
|
82
|
+
}
|
|
83
|
+
const params: Record<string, Param> = {}
|
|
84
|
+
for (const k in d.params || {}) {
|
|
85
|
+
const p = params[k] = param(id, k, d.params[k])
|
|
86
|
+
if (p.kind == 'record') fail(`container '${id}', param '${k}': containers take no record params`)
|
|
87
|
+
}
|
|
88
|
+
return {id, kind: 'container', slots, slotNames: Object.keys(slots), minSlots: d.minSlots, min, max, accepts, params,
|
|
89
|
+
arranged: !!d.arranged, direction: d.direction ?? (id == 'row' ? 'row' : undefined), description: d.description, render, in: d.in ?? ['page'], decl: d}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function part(id: string, d: any): Part {
|
|
93
|
+
if (!d || typeof d != 'object') return fail(`part '${id}' is not an object`)
|
|
94
|
+
if (typeof d.component != 'function' && typeof d.render != 'function') {
|
|
95
|
+
dev('SYG162', 'defineCatalog', `part '${id}' has neither a component nor a render function; it renders nothing`, 'error')
|
|
96
|
+
}
|
|
97
|
+
const params: Record<string, Param> = {}
|
|
98
|
+
let record: string | undefined
|
|
99
|
+
for (const k in d.params || {}) {
|
|
100
|
+
const p = params[k] = param(id, k, d.params[k])
|
|
101
|
+
if (p.kind == 'record') {
|
|
102
|
+
if (record) fail(`part '${id}' has two record params ('${record}', '${k}'); a part takes at most one`)
|
|
103
|
+
record = k
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const pin = d.pin == 'start' || d.pin == 'end' ? d.pin : undefined
|
|
107
|
+
return {id, kind: 'part', component: typeof d.component == 'function' ? d.component : undefined,
|
|
108
|
+
render: typeof d.component != 'function' && typeof d.render == 'function' ? d.render : undefined,
|
|
109
|
+
props: d.props, description: String(d.description ?? id), state: d.state, local: d.local, hasLocal: 'local' in d,
|
|
110
|
+
params, record, in: d.in ?? ['page'], group: d.group, maxUses: Math.max(1, Number(d.maxUses) || 1),
|
|
111
|
+
required: !!d.required, pin, companions: Array.isArray(d.companions) ? d.companions : [], category: d.category,
|
|
112
|
+
result: !!d.result, decl: d}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 'container' | 'container:slot' entries → 'container:slot' (SYG450 for an unknown one) */
|
|
116
|
+
function places(e: Entry, containers: Record<string, Container>): string[] {
|
|
117
|
+
const out: string[] = []
|
|
118
|
+
for (const x of Array.isArray(e.in) ? e.in : [e.in]) {
|
|
119
|
+
const [c, s] = String(x).split(':')
|
|
120
|
+
const ct = containers[c]
|
|
121
|
+
if (!ct || c == 'row' || (s !== undefined && !ct.slotNames.includes(s))) {
|
|
122
|
+
dev('SYG450', 'defineCatalog', `'${e.id}': \`in\` names ${!ct ? `an unknown container '${c}'` : c == 'row' ? "'row' (a row is placed where its parts may go)" : `an unknown slot '${s}' of '${c}'`}; that entry is ignored`)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
for (const n of s === undefined ? ct.slotNames : [s]) if (!out.includes(c + ':' + n)) out.push(c + ':' + n)
|
|
126
|
+
}
|
|
127
|
+
return out.length ? out : ['page:default']
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** a record part's records, validated (SYG252: not an array of `{ value }`, or `from` threw) */
|
|
131
|
+
function records(cat: Catalog, p: any, state: any): any[] {
|
|
132
|
+
const part: Part | undefined = typeof p == 'string' ? cat.parts[p] : p
|
|
133
|
+
const rp = part?.record && (part.params[part.record] as any)
|
|
134
|
+
if (!rp) return []
|
|
135
|
+
let list: any
|
|
136
|
+
try { list = rp.from(state) } catch (e: any) {
|
|
137
|
+
dev('SYG252', 'catalog', `part '${part!.id}': the record param's from() threw (${e?.message ?? e}); no records`, 'warn', cat, part!.id + 'threw')
|
|
138
|
+
return []
|
|
139
|
+
}
|
|
140
|
+
const ok = (r: any) => r && typeof r == 'object' && (typeof r.value == 'string' || (typeof r.value == 'number' && isFinite(r.value)))
|
|
141
|
+
if (!Array.isArray(list) || !list.every(ok)) {
|
|
142
|
+
dev('SYG252', 'catalog', `part '${part!.id}': the record param's from() must return an array of { value, name?, aliases?, description? } (value: a string or a number); ${Array.isArray(list) ? 'entries without one are skipped' : 'no records'}`, 'warn', cat, part!.id)
|
|
143
|
+
return Array.isArray(list) ? list.filter(ok) : []
|
|
144
|
+
}
|
|
145
|
+
return list
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* defineCatalog({ parts, containers?, guidance?, limits?, examples? }): the normalized, validated
|
|
150
|
+
* catalog. Types: src/ai.d.ts.
|
|
151
|
+
*/
|
|
152
|
+
export function defineCatalog(decl: any = {}): any {
|
|
153
|
+
const containers: Record<string, Container> = {}
|
|
154
|
+
const parts: Record<string, Part> = {}
|
|
155
|
+
const dc = decl.containers || {}
|
|
156
|
+
containers.page = container('page', {...dc.page, in: [], slots: {default: null}})
|
|
157
|
+
// the built-in row, unless the catalog opts out: `rows: false` (canonical; `containers: { row: false }` too)
|
|
158
|
+
const rows = decl.rows !== false && dc.row !== false
|
|
159
|
+
if (rows) containers.row = container('row', {direction: 'row', min: 2, max: 4, accepts: 'leaves', ...dc.row, slots: {default: null}})
|
|
160
|
+
for (const id in dc) {
|
|
161
|
+
if (id == 'page' || id == 'row') continue
|
|
162
|
+
if (BAD_ID.test(id)) fail(`the container id '${id}' can't be a node key (no ':', '~' or '/')`)
|
|
163
|
+
containers[id] = container(id, dc[id] || {})
|
|
164
|
+
}
|
|
165
|
+
for (const id in decl.parts || {}) {
|
|
166
|
+
if (BAD_ID.test(id)) fail(`the part id '${id}' can't be a node key (no ':', '~' or '/')`)
|
|
167
|
+
if (containers[id] || id == 'row') fail(`'${id}' is both a part and a container (they share one id namespace${id == 'page' || id == 'row' ? `; '${id}' is built in` : ''})`)
|
|
168
|
+
parts[id] = part(id, decl.parts[id])
|
|
169
|
+
}
|
|
170
|
+
const cat: any = {
|
|
171
|
+
parts,
|
|
172
|
+
containers,
|
|
173
|
+
description: decl.description,
|
|
174
|
+
categories: decl.categories && typeof decl.categories == 'object' ? decl.categories : {},
|
|
175
|
+
rows,
|
|
176
|
+
guidance: decl.guidance,
|
|
177
|
+
limits: {maxParts: 12, maxDepth: 4, ...decl.limits},
|
|
178
|
+
examples: Array.isArray(decl.examples) ? decl.examples : [],
|
|
179
|
+
get: (id: string): Entry | undefined => Object.prototype.hasOwnProperty.call(parts, id) ? parts[id]
|
|
180
|
+
: Object.prototype.hasOwnProperty.call(containers, id) ? containers[id] : undefined,
|
|
181
|
+
records: (p: any, state: any) => records(cat, p, state),
|
|
182
|
+
options: (state: any): Option[] => {
|
|
183
|
+
const out: Option[] = []
|
|
184
|
+
for (const id in parts) {
|
|
185
|
+
const p = parts[id]
|
|
186
|
+
if (!p.record) { out.push({option: id, part: id, description: p.description}); continue }
|
|
187
|
+
for (const r of records(cat, p, state)) {
|
|
188
|
+
out.push({option: `${id}:${r.value}`, part: id, params: {[p.record]: r.value},
|
|
189
|
+
description: `${p.description}: ${r.description ?? r.name ?? r.value}`})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out
|
|
193
|
+
},
|
|
194
|
+
finish: (layout: any, state?: any) => finish(cat, layout, state),
|
|
195
|
+
render: (slice: any, state: any, opts?: any) => renderLayout(cat, slice, state, opts),
|
|
196
|
+
}
|
|
197
|
+
for (const id in containers) if (id != 'page') containers[id].in = places(containers[id], containers)
|
|
198
|
+
if (rows) containers.row.in = []
|
|
199
|
+
for (const id in parts) {
|
|
200
|
+
const p = parts[id]
|
|
201
|
+
p.in = places(p, containers)
|
|
202
|
+
p.companions = p.companions.filter(c => {
|
|
203
|
+
if (parts[c] && c != id) return true
|
|
204
|
+
dev('SYG250', 'defineCatalog', `part '${id}': the companion '${c}' is not a part of this catalog; it is ignored`)
|
|
205
|
+
return false
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
// H-2: what chat({ ask: { catalog } }) runs on (so an app with chat and no catalog carries none of it)
|
|
209
|
+
Object.defineProperty(cat, '_ask', {value: askKit})
|
|
210
|
+
return cat as Catalog
|
|
211
|
+
}
|