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
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Composing UI with a Model
|
|
3
|
+
|
|
4
|
+
Some pages are better arranged by the user than by you: a dashboard, a workspace, a "show me what matters this morning" screen. With `sygnal/ai` the user describes the page in words ("urgent tickets first, then the pending orders"), and a model builds it from **parts your app prepared**: real Sygnal components with their own data, wording and actions.
|
|
5
|
+
|
|
6
|
+
The model only **chooses**. It picks parts, arranges them, and picks their params from the lists you declared. It never writes markup, state paths or action names, so every label, number and button on the composed page is the app's own, and every part is wired into app state the way you wired it.
|
|
7
|
+
|
|
8
|
+
One declaration, the **catalog**, works with two kinds of model:
|
|
9
|
+
|
|
10
|
+
- a **decision model** (TypeSafe Jev, Ollama's `nimble`) through [`decide()`](./ai-decisions.md): it answers multiple-choice questions ("is the revenue chart asked for?", "which comes first?") with probabilities, and code turns the answers into the layout;
|
|
11
|
+
- a **chat LLM** through the [chat driver](./ai-chat.md): it fills a JSON Schema of the catalog in one structured-output request, streamed as a preview.
|
|
12
|
+
|
|
13
|
+
Choosing the backend is the only difference. The app code, the rendering and the tests are the same.
|
|
14
|
+
|
|
15
|
+
Reach for it when the set of useful views is larger than the screens you want to design, and every view is made of parts you can build ahead of time. If the user needs text the app doesn't have (a summary, an answer), that's a [chat](./ai-chat.md); if the app needs one typed answer about some state, that's a [decision](./ai-decisions.md).
|
|
16
|
+
|
|
17
|
+
## The catalog
|
|
18
|
+
|
|
19
|
+
`defineCatalog()` declares the parts a model may place. Each part is a component plus a `description`, which is **all a model ever sees of it**. This catalog is the one every demo on this page composes from:
|
|
20
|
+
|
|
21
|
+
```jsx live-file=./catalog.jsx title="catalog.jsx"
|
|
22
|
+
import { defineCatalog } from 'sygnal/ai'
|
|
23
|
+
|
|
24
|
+
const KPIS = { revenue: ['Revenue', '$48.2k', '+6%'], signups: ['Signups', '312', '+12%'] }
|
|
25
|
+
const SERIES = { year: [31, 34, 33, 38, 41, 40, 44, 43, 46, 45, 47, 48], quarter: [45, 47, 48], month: [44, 46, 45, 47, 49, 48] }
|
|
26
|
+
const ORDERS = [
|
|
27
|
+
{ id: 'A-1041', customer: 'Northwind', amount: '$1,200', status: 'paid' },
|
|
28
|
+
{ id: 'A-1042', customer: 'Globex', amount: '$640', status: 'pending' },
|
|
29
|
+
{ id: 'A-1043', customer: 'Initech', amount: '$2,310', status: 'pending' },
|
|
30
|
+
{ id: 'A-1044', customer: 'Umbrella', amount: '$90', status: 'refunded' },
|
|
31
|
+
]
|
|
32
|
+
export const TICKETS = [
|
|
33
|
+
{ id: 1, title: 'Checkout fails on Safari', priority: 'high' },
|
|
34
|
+
{ id: 2, title: 'Invoice PDF is blank', priority: 'high' },
|
|
35
|
+
{ id: 3, title: 'Typo on the pricing page', priority: 'low' },
|
|
36
|
+
]
|
|
37
|
+
export const TASKS = [
|
|
38
|
+
{ id: 1, text: 'Reply to Globex', done: false },
|
|
39
|
+
{ id: 2, text: 'Review the Q3 numbers', done: true },
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
export function Kpi({ metric }) {
|
|
43
|
+
const [label, value, delta] = KPIS[metric]
|
|
44
|
+
return <section className="card kpi"><h3>{label}</h3><b>{value}</b> <span className="muted">{delta}</span></section>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function RevenueChart({ range }) {
|
|
48
|
+
const values = SERIES[range]
|
|
49
|
+
const points = values.map((v, i) => `${(i / (values.length - 1)) * 200},${60 - (v - 28) * 2.5}`).join(' ')
|
|
50
|
+
return (
|
|
51
|
+
<section className="card">
|
|
52
|
+
<h3>Revenue, last {range}</h3>
|
|
53
|
+
<svg viewBox="0 0 200 60" role="img" aria-label={`Revenue over the last ${range}`}><polyline points={points} /></svg>
|
|
54
|
+
</section>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function OrdersTable({ status }) {
|
|
59
|
+
const rows = ORDERS.filter((o) => status === 'any' || o.status === status)
|
|
60
|
+
return (
|
|
61
|
+
<section className="card">
|
|
62
|
+
<h3>{status === 'any' ? 'Recent orders' : `Orders: ${status}`}</h3>
|
|
63
|
+
<ul>{rows.map((o) => <li>{o.id} · {o.customer} · {o.amount}</li>)}</ul>
|
|
64
|
+
</section>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// a part that talks to its host: "Add to my tasks" goes to the PARENT sink
|
|
69
|
+
export function TicketList({ priority }) {
|
|
70
|
+
const shown = TICKETS.filter((t) => priority === 'any' || t.priority === priority)
|
|
71
|
+
return (
|
|
72
|
+
<section className="card">
|
|
73
|
+
<h3>{priority === 'high' ? 'Urgent tickets' : 'Open tickets'}</h3>
|
|
74
|
+
<ul>{shown.map((t) => <li>{t.title} <button type="button" className="take" data={{ id: t.id }}>Add to my tasks</button></li>)}</ul>
|
|
75
|
+
</section>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
TicketList.intent = ({ DOM }) => ({ TAKE: DOM.click('.take').data('id', Number) })
|
|
79
|
+
TicketList.model = { TAKE: { PARENT: (state, id) => TICKETS.find((t) => t.id === id) } }
|
|
80
|
+
|
|
81
|
+
// a part with state: the catalog gives it the lens `state: 'tasks'`, so it edits the app's tasks
|
|
82
|
+
export function Checklist({ state }) {
|
|
83
|
+
return (
|
|
84
|
+
<section className="card">
|
|
85
|
+
<h3>Today</h3>
|
|
86
|
+
<ul>{state.map((task) => <li><label><input type="checkbox" className="toggle" checked={task.done} data={{ id: task.id }} /> {task.text}</label></li>)}</ul>
|
|
87
|
+
</section>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
Checklist.intent = ({ DOM }) => ({ TOGGLE: DOM.change('.toggle').data('id', Number) })
|
|
91
|
+
Checklist.model = { TOGGLE: (tasks, id) => tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) }
|
|
92
|
+
|
|
93
|
+
export function Weather() {
|
|
94
|
+
return <section className="card"><h3>Weather</h3><b>27°C</b> <span className="muted">sunny in Lisbon</span></section>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const catalog = defineCatalog({
|
|
98
|
+
parts: {
|
|
99
|
+
revenue: { component: Kpi, props: { metric: 'revenue' }, description: "Revenue (KPI card: this month's revenue total as one number)" },
|
|
100
|
+
signups: { component: Kpi, props: { metric: 'signups' }, description: 'Signups (KPI card: new signups this week)' },
|
|
101
|
+
revenueChart: {
|
|
102
|
+
component: RevenueChart,
|
|
103
|
+
description: 'Revenue chart (line chart of the revenue trend over time)',
|
|
104
|
+
params: {
|
|
105
|
+
range: {
|
|
106
|
+
label: 'time range', default: 'year',
|
|
107
|
+
options: { year: 'the last 12 months (this year)', quarter: 'the last 3 months (this quarter)', month: 'the last 30 days (this month)' },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
orders: {
|
|
112
|
+
component: OrdersTable,
|
|
113
|
+
description: 'Orders (table of the most recent orders: customer, amount, status)',
|
|
114
|
+
params: { status: { label: 'order status', default: 'any', options: { any: 'orders of every status', paid: 'paid orders', pending: 'pending orders (not paid yet)', refunded: 'refunded orders' } } },
|
|
115
|
+
},
|
|
116
|
+
tickets: {
|
|
117
|
+
component: TicketList,
|
|
118
|
+
description: 'Tickets (list of open support tickets, by priority)',
|
|
119
|
+
params: { priority: { label: 'priority', default: 'any', options: { any: 'every open ticket', high: 'only high-priority (urgent) tickets' } } },
|
|
120
|
+
},
|
|
121
|
+
tasks: { component: Checklist, state: 'tasks', description: "Tasks (checklist of the user's tasks for today)" },
|
|
122
|
+
weather: { component: Weather, description: "Weather (today's weather forecast for the office city)" },
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
(This block is a module the demos on this page import, `./catalog.jsx`.)
|
|
128
|
+
|
|
129
|
+
```css live
|
|
130
|
+
.composer { display: grid; gap: 0.6rem; }
|
|
131
|
+
.composer .ask { display: flex; gap: 0.5rem; }
|
|
132
|
+
.composer .ask input { flex: 1; }
|
|
133
|
+
.composer .chips { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
134
|
+
.composer .chip { font-size: 0.8rem; }
|
|
135
|
+
.composer .status { margin: 0; font-size: 0.85rem; opacity: 0.8; }
|
|
136
|
+
.compose-page { display: grid; gap: 0.6rem; }
|
|
137
|
+
.compose-row { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 0.6rem; }
|
|
138
|
+
.canvas.preview { opacity: 0.6; }
|
|
139
|
+
.card { border: 1px solid #8884; border-radius: 6px; padding: 0.5rem 0.75rem; }
|
|
140
|
+
.card h3 { margin: 0 0 0.3rem; font-size: 0.9rem; }
|
|
141
|
+
.card ul { margin: 0; padding-left: 1.1rem; }
|
|
142
|
+
.card svg { width: 100%; height: 60px; }
|
|
143
|
+
.card polyline { fill: none; stroke: currentColor; stroke-width: 2; }
|
|
144
|
+
.muted { opacity: 0.7; font-size: 0.85em; }
|
|
145
|
+
.explain { font-size: 0.8rem; }
|
|
146
|
+
.explain ul { margin: 0.2rem 0 0.5rem; padding-left: 1.1rem; }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Parts
|
|
150
|
+
|
|
151
|
+
| Field | What it does |
|
|
152
|
+
|---|---|
|
|
153
|
+
| `component` | The Sygnal component. It renders as a child of the host, so lenses, `CHILD.select`, `EVENTS`, context and drivers work as for any child. (`render`, a view function, works for a part with no state of its own.) |
|
|
154
|
+
| `description` | All a model sees of the part: "Noun (what it shows)" ([below](#writing-descriptions-a-model-picks-well)) |
|
|
155
|
+
| `props` | Fixed props, or `(params, state) => props` |
|
|
156
|
+
| `params` | The inputs a model gives, passed as props ([below](#params)) |
|
|
157
|
+
| `state` | How the part reaches app state: a key of the host's state (`'tasks'`), a lens, or `(node) => lens` for a record part. Without it the part sees the host's state, as any child does |
|
|
158
|
+
| `local` | State kept in the composition instead (`local: { text: '' }`), per placed part |
|
|
159
|
+
| `in` | Where it may go: `'page'` (the default), a container, or `'container:slot'` |
|
|
160
|
+
| `group` | Alternatives: parts in one group are offered as one choice, and the first one in a layout wins |
|
|
161
|
+
| `maxUses` | How often one identity may appear (default 1) |
|
|
162
|
+
| `required`, `pin`, `companions` | Always on the page (never offered to the model); pinned to `'start'` or `'end'`; parts added right after it |
|
|
163
|
+
|
|
164
|
+
### Params
|
|
165
|
+
|
|
166
|
+
Params are the only inputs a model gives, and each kind says what a model may write:
|
|
167
|
+
|
|
168
|
+
| Param | Both backends? | Example |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `{ options, default }` | yes: a decision model picks one option, with "not stated" meaning the default; an LLM fills an enum | `range: { default: 'year', options: { year: 'the last 12 months (this year)', quarter: '…' } }` |
|
|
171
|
+
| `{ type: 'number', bins }` | yes: an LLM writes a number; a decision model picks a bin, which names the vague words it covers | `limit: { type: 'number', default: 5, min: 1, max: 8, bins: { 3: 'a few (top 3)', 8: 'all of them' } }` |
|
|
172
|
+
| `{ type: 'text' }`, `{ type: 'list' }` | LLM only: a decision model keeps the `default`, and a `required` one with no default makes the part LLM-only | `title: { type: 'text', maxLength: 40, required: true }` |
|
|
173
|
+
| `{ from: (state) => records }` | yes: one node per record the request names, found by `name` and `aliases` | below |
|
|
174
|
+
|
|
175
|
+
A **record param** turns one part into a part per record: "Northwind and Globex" places the customer card twice, once per customer. The record's `value` becomes part of the node key (`customer:7`), and `byId()` from `sygnal` gives each card a lens to its own record, so what it edits lands in app state:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import { byId } from 'sygnal'
|
|
179
|
+
|
|
180
|
+
const customer = {
|
|
181
|
+
component: CustomerCard,
|
|
182
|
+
description: "Customer (card: one customer's plan, contact and notes)",
|
|
183
|
+
maxUses: 4,
|
|
184
|
+
params: {
|
|
185
|
+
id: { label: 'customer', from: (state) => state.customers.map((c) => ({ value: c.id, name: c.name, aliases: c.aliases })) },
|
|
186
|
+
},
|
|
187
|
+
state: (node) => byId('customers', node.params.id),
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`byId(field, id)` is a `{ get, set }` lens to the element of `state[field]` whose `id` is `id` (a third argument names another key). It works for any child: `<TodoItem state={byId('todos', 3)} />`.
|
|
192
|
+
|
|
193
|
+
### Containers
|
|
194
|
+
|
|
195
|
+
A container is a **view function** with named slots; parts go into a slot when their `in` names it. The model never chooses a container: a container appears when parts land in its slots, and the built-in `row` appears when the request asks for things side by side.
|
|
196
|
+
|
|
197
|
+
```jsx
|
|
198
|
+
const catalog = defineCatalog({
|
|
199
|
+
parts: {
|
|
200
|
+
orders: { component: OrdersTable, description: 'Orders (table of recent orders)', in: ['page', 'tabs:billing'] },
|
|
201
|
+
invoices: { component: Invoices, description: 'Invoices (list of unpaid invoices)', in: 'tabs:billing' },
|
|
202
|
+
tasks: { component: Checklist, state: 'tasks', description: "Tasks (the user's checklist)", in: ['page', 'tabs:overview'] },
|
|
203
|
+
},
|
|
204
|
+
containers: {
|
|
205
|
+
tabs: {
|
|
206
|
+
slots: { overview: 'Overview tab: the summary', billing: 'Billing tab: money (orders, invoices, payments)' },
|
|
207
|
+
minSlots: 2,
|
|
208
|
+
render: ({ children, ui, control }) => (
|
|
209
|
+
<div className="tabs">
|
|
210
|
+
<div role="tablist">
|
|
211
|
+
{children.map((c, i) => <button type="button" role="tab" aria-selected={String(i === (ui.tab ?? 0))} {...control('tab', i)}>{c.slot}</button>)}
|
|
212
|
+
</div>
|
|
213
|
+
{children.map((c, i) => <div role="tabpanel" hidden={i !== (ui.tab ?? 0)}>{c.vnode}</div>)}
|
|
214
|
+
</div>
|
|
215
|
+
),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
- The view gets `{ key, node, params, slots, children, ui, control, frozen }`: `slots` maps each slot to its rendered parts, `children` lists them in order with their slot.
|
|
222
|
+
- `control(name, value)` returns data attributes to spread on an element: a click on it sets `ui[name] = value` for this container (the behavior's `CONTROL` action), so the open tab is state too.
|
|
223
|
+
- `minSlots`: with fewer filled slots the container is unwrapped and its parts go where it was.
|
|
224
|
+
- A container is a view, never a component with a model ([SYG161](https://sygnal.js.org/reference/errors/#syg161)): its parts must stay the host's children.
|
|
225
|
+
|
|
226
|
+
`in` is the most valuable field a catalog has: with it, a decision model placed parts right in 37 of 37 cases, against 25 of 37 when it chose a parent among every slot.
|
|
227
|
+
|
|
228
|
+
### Guidance and limits
|
|
229
|
+
|
|
230
|
+
- `guidance`: a sentence the order questions and the LLM prompt quote, for what the request doesn't say: `guidance: 'Summaries before details.'`
|
|
231
|
+
- `limits`: `{ maxParts: 12, maxDepth: 4 }` by default.
|
|
232
|
+
- `examples`: few-shot cases for an LLM (`{ prompt, tree }`), written by you.
|
|
233
|
+
- `rows: false`: no built-in row.
|
|
234
|
+
|
|
235
|
+
Fixed structure belongs in data (`required`, `pin`, `companions`, `in`, `guidance`), not in a question to the model.
|
|
236
|
+
|
|
237
|
+
### Writing descriptions a model picks well
|
|
238
|
+
|
|
239
|
+
Most of the measured errors are part *selection*, not arrangement: from the right parts, Jev arranged 62–63 of 65 layouts right. Selection depends mostly on the descriptions:
|
|
240
|
+
|
|
241
|
+
- **"Noun (what it shows)"**, leading with the word a user says: "Revenue chart (line chart of the revenue trend over time)". The demo app's catalog went from 5 to 7 of 11 prompts right on `nimble` with this form alone.
|
|
242
|
+
- **Don't let descriptions overlap.** Don't mention another part's content: "monthly revenue" on both the KPI and the chart pulled in the KPI whenever the chart was asked for.
|
|
243
|
+
- **Alternatives go in a `group`** (cards per project or a project list), never in two similar descriptions.
|
|
244
|
+
- **Each option says what it shows** ("the last 3 months (this quarter)"), and a number's `bins` name the vague words they cover ("a few (top 3)").
|
|
245
|
+
|
|
246
|
+
## Composing a page
|
|
247
|
+
|
|
248
|
+
The `compose` behavior holds a composition in a slice of the host's state, and `catalog.render()` renders it in the host's view:
|
|
249
|
+
|
|
250
|
+
```jsx
|
|
251
|
+
import { compose } from 'sygnal/ai'
|
|
252
|
+
import { catalog } from './catalog.jsx'
|
|
253
|
+
|
|
254
|
+
function Dashboard({ state }) {
|
|
255
|
+
return (
|
|
256
|
+
<div>
|
|
257
|
+
<form className="ask">
|
|
258
|
+
<input className="prompt" aria-label="The page you want" value={state.page.prompt} />
|
|
259
|
+
<button type="submit">Compose</button>
|
|
260
|
+
</form>
|
|
261
|
+
<div className="canvas">{catalog.render(state.page, state)}</div>
|
|
262
|
+
</div>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
Dashboard.uses = {
|
|
267
|
+
page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }), form: '.ask', prompt: '.prompt' }),
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`form` and `prompt` are selectors: typing in the prompt field sets `state.page.prompt`, and submitting the form composes.
|
|
272
|
+
|
|
273
|
+
In this demo, the demo server below stands in for both kinds of model. They only match words, which is enough to show the wiring: the decision route answers each question `decide()` sends, by name, and the chat route writes the layout JSON a chat model would. For a [follow-up edit](#follow-up-edits) both read the change into the same list of removes, moves, adds and setting changes, then answer it in their backend's encoding (the edit questions, or the operations). A real model reads the descriptions instead.
|
|
274
|
+
|
|
275
|
+
```js live-server
|
|
276
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
277
|
+
|
|
278
|
+
// how a request names each part, and each param value (a real model reads the descriptions)
|
|
279
|
+
const PARTS = {
|
|
280
|
+
revenue: /revenue(?! (chart|trend))/i,
|
|
281
|
+
signups: /sign-?ups?/i,
|
|
282
|
+
revenueChart: /chart|trend/i,
|
|
283
|
+
orders: /orders?\b/i,
|
|
284
|
+
tickets: /tickets?|support/i,
|
|
285
|
+
tasks: /tasks?|to-?dos?/i,
|
|
286
|
+
weather: /weather|forecast/i,
|
|
287
|
+
}
|
|
288
|
+
const PARAMS = {
|
|
289
|
+
revenueChart: { range: { quarter: /quarter/i, month: /month/i } },
|
|
290
|
+
orders: { status: { pending: /pending|unpaid/i, paid: /\bpaid/i, refunded: /refund/i } },
|
|
291
|
+
tickets: { priority: { high: /urgent|high/i } },
|
|
292
|
+
}
|
|
293
|
+
const SIDE = /side by side|beside|next to|in a row/i
|
|
294
|
+
const at = (text, id) => text.search(PARTS[id])
|
|
295
|
+
const clause = (text, id) => text.split(/,|\bthen\b/i).find((c) => PARTS[id].test(c)) ?? ''
|
|
296
|
+
const valueOf = (text, id, name) => Object.keys(PARAMS[id][name]).find((v) => PARAMS[id][name][v].test(text))
|
|
297
|
+
const sure = (choice, keys, p = 0.92) => ({
|
|
298
|
+
type: 'choice', choice, confidence: p,
|
|
299
|
+
probabilities: Object.fromEntries(keys.map((k) => [k, k === choice ? p : (1 - p) / (keys.length - 1)])),
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// a decision model: one answer per question, by the question's name
|
|
303
|
+
function answer(name, question, text) {
|
|
304
|
+
const keys = Object.keys(question.criteria ?? {})
|
|
305
|
+
let m
|
|
306
|
+
if (name === 'row') return { type: 'noul', noul: SIDE.test(text) ? 0.91 : 0.04 }
|
|
307
|
+
if ((m = name.match(/^use_(\w+)$/))) return sure(at(text, m[1]) >= 0 ? 'use' : 'omit', keys)
|
|
308
|
+
if ((m = name.match(/^param_(\w+?)__(\w+)$/))) return sure(valueOf(text, m[1], m[2]) ?? 'not_stated', keys)
|
|
309
|
+
if ((m = name.match(/^beside_(\w+)$/))) {
|
|
310
|
+
const c = clause(text, m[1])
|
|
311
|
+
return sure((SIDE.test(c) && keys.find((k) => k !== 'none' && k !== m[1] && PARTS[k]?.test(c))) || 'none', keys)
|
|
312
|
+
}
|
|
313
|
+
if ((m = name.match(/^(?:first|left)_(\w+)_vs_(\w+)$/))) return sure(at(text, m[1]) <= at(text, m[2]) ? m[1] : m[2], keys)
|
|
314
|
+
if (question.type === 'noul') return { type: 'noul', noul: 0.05 }
|
|
315
|
+
return sure(keys[0], keys)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// a follow-up, read into what it changes (each node key here is its part id)
|
|
319
|
+
const named = (text) => Object.keys(PARTS).filter((id) => at(text, id) >= 0).sort((a, b) => at(text, a) - at(text, b))
|
|
320
|
+
const paramsIn = (text, id) => Object.fromEntries(Object.keys(PARAMS[id] ?? {}).map((n) => [n, valueOf(text, id, n)]).filter(([, v]) => v))
|
|
321
|
+
function changes(text, onPage) {
|
|
322
|
+
const out = { remove: [], move: [], add: [], set: [], replace: null }
|
|
323
|
+
if (/\bonly\b/i.test(text)) out.remove = onPage.filter((id) => !named(text).includes(id))
|
|
324
|
+
for (const c of text.split(/,|\bthen\b|\band (?=remove|move|add|show|put)/i)) {
|
|
325
|
+
let m
|
|
326
|
+
if ((m = c.match(/^(.*)\binstead of\b(.*)$/i))) { out.replace = { to: named(m[1])[0], from: named(m[2])[0] }; continue }
|
|
327
|
+
if (/\b(remove|drop|hide|without)\b/i.test(c)) { out.remove.push(...named(c).filter((id) => onPage.includes(id))); continue }
|
|
328
|
+
const ref = c.match(/\b(above|below|under|beside|next to)\s/i)
|
|
329
|
+
const anchor = ref ? named(c.slice(ref.index))[0] : null
|
|
330
|
+
const where = /\b(top|first)\b/i.test(c) ? 'top' : /\b(bottom|last)\b/i.test(c) ? 'bottom'
|
|
331
|
+
: anchor ? `${/above/i.test(ref[1]) ? 'above' : /below|under/i.test(ref[1]) ? 'below' : 'beside'}:${anchor}` : null
|
|
332
|
+
for (const id of named(c).filter((x) => x !== anchor)) {
|
|
333
|
+
const params = paramsIn(c, id)
|
|
334
|
+
if (!onPage.includes(id)) out.add.push({ id, params, where })
|
|
335
|
+
else {
|
|
336
|
+
if (where) out.move.push({ id, where })
|
|
337
|
+
if (Object.keys(params).length) out.set.push({ id, params })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// a decision model, editing: the edit questions (remove_, set_, add_, moved_, where_ and the gates)
|
|
345
|
+
function editAnswer(name, question, ch) {
|
|
346
|
+
const keys = Object.keys(question.criteria ?? {})
|
|
347
|
+
const yes = (b) => ({ type: 'noul', noul: b ? 0.93 : 0.04 })
|
|
348
|
+
const part = (k) => k.replace(/_\d+$/, '')
|
|
349
|
+
let m
|
|
350
|
+
if ((m = name.match(/^remove_(\w+)$/))) return yes(ch.remove.includes(part(m[1])) || ch.replace?.from === part(m[1]))
|
|
351
|
+
if (name === 'swap') return yes(!!ch.replace)
|
|
352
|
+
if (name === 'placed') return yes(ch.move.length > 0)
|
|
353
|
+
if (name === 'change') return yes(ch.set.length > 0)
|
|
354
|
+
if ((m = name.match(/^set_(\w+?)__(\w+)$/))) {
|
|
355
|
+
const v = ch.set.find((s) => s.id === part(m[1]))?.params[m[2]]
|
|
356
|
+
return sure(keys.includes(v) ? v : 'keep', keys)
|
|
357
|
+
}
|
|
358
|
+
if ((m = name.match(/^add_(\w+)$/))) return sure(ch.add.some((a) => a.id === m[1]) || ch.replace?.to === m[1] ? 'use' : 'omit', keys)
|
|
359
|
+
if ((m = name.match(/^moved_(\d)$/))) return sure(ch.move[m[1] - 1]?.id ?? 'none', keys)
|
|
360
|
+
if ((m = name.match(/^where_(\w+)$/))) {
|
|
361
|
+
const w = [...ch.move, ...ch.add].find((x) => x.id === part(m[1]))?.where
|
|
362
|
+
return sure(keys.includes(w) ? w : 'not_stated', keys)
|
|
363
|
+
}
|
|
364
|
+
return null // the params of an added part: as for a new page
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// a chat model, editing: the operations, on the page's n1, n2, … keys
|
|
368
|
+
function ops(text) {
|
|
369
|
+
const nodes = Object.fromEntries([...text.matchAll(/^\s*n(\d+): (\w+)(.*)$/gm)].map(([, n, part, rest]) => [part, { key: `n${n}`, rest }]))
|
|
370
|
+
const ch = changes(text.split('\nChange: ')[1], Object.keys(nodes))
|
|
371
|
+
const place = (w) => {
|
|
372
|
+
const [dir, id] = (w ?? 'bottom').split(':')
|
|
373
|
+
return id ? `${dir === 'beside' ? 'right of' : dir} ${nodes[id].key}` : dir
|
|
374
|
+
}
|
|
375
|
+
const changed = (s) => Object.entries(s.params).some(([k, v]) => !nodes[s.id].rest.includes(`${k}="${v}"`))
|
|
376
|
+
return [
|
|
377
|
+
...ch.remove.map((id) => ({ op: 'remove', node: nodes[id].key })),
|
|
378
|
+
...(nodes[ch.replace?.from] && ch.replace.to ? [{ op: 'replace', node: nodes[ch.replace.from].key, part: ch.replace.to }] : []),
|
|
379
|
+
...ch.set.filter(changed).map((s) => ({ op: 'setParams', node: nodes[s.id].key, params: s.params })),
|
|
380
|
+
...ch.move.map((mv) => ({ op: 'move', node: nodes[mv.id].key, where: place(mv.where) })),
|
|
381
|
+
...ch.add.map((a) => ({ op: 'add', part: a.id, params: a.params, where: place(a.where) })),
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// a chat model: the layout JSON, in the order the request names the parts
|
|
386
|
+
function layout(text) {
|
|
387
|
+
const node = (id) => {
|
|
388
|
+
const params = paramsIn(text, id)
|
|
389
|
+
return Object.keys(params).length ? { part: id, params } : { part: id }
|
|
390
|
+
}
|
|
391
|
+
const parts = named(text)
|
|
392
|
+
const row = parts.filter((id) => SIDE.test(clause(text, id)))
|
|
393
|
+
const children = parts.flatMap((id) => !row.includes(id) || row.length < 2 ? [node(id)] : id === row[0] ? [{ part: 'row', children: row.map(node) }] : [])
|
|
394
|
+
return JSON.stringify({ asked_for: parts.map((id) => text.match(PARTS[id])[0]), children })
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export default {
|
|
398
|
+
'POST /api/decide': ({ json }) => {
|
|
399
|
+
const text = json.state.user_request
|
|
400
|
+
// an edit's questions come with the current page
|
|
401
|
+
const onPage = Object.keys(json.questions).filter((n) => n.startsWith('remove_')).map((n) => n.slice(7).replace(/_\d+$/, ''))
|
|
402
|
+
const ch = json.state.current_page && changes(text, onPage)
|
|
403
|
+
const reply = (name, q) => (ch && editAnswer(name, q, ch)) ?? answer(name, q, text)
|
|
404
|
+
return { delayMs: 350, json: { model: json.model, answers: Object.fromEntries(Object.entries(json.questions).map(([name, q]) => [name, reply(name, q)])) } }
|
|
405
|
+
},
|
|
406
|
+
'POST /v1/responses': ({ json }) => {
|
|
407
|
+
const text = String(json.input.at(-1).content)
|
|
408
|
+
// an edit sends the current page and the change
|
|
409
|
+
const reply = text.startsWith('Current page:') ? JSON.stringify({ ops: ops(text) }) : layout(text)
|
|
410
|
+
return { delayMs: 300, chunkMs: 90, sse: encodeOpenResponses(reply.match(/[\s\S]{1,16}/g)) }
|
|
411
|
+
},
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
The demo composes its first prompt on load. The first row of chips starts a new page (`fresh: true`); the second row changes the page that is there ([follow-up edits](#follow-up-edits)), and so does the field. Try your own with the words the stand-in knows (revenue, signups, chart, orders, tickets, tasks, weather; "side by side"; quarter, month, pending, urgent; top, bottom, above, below, beside, remove, instead of). Under the page, the model's answers: every question of each step, with the probability of the answer it chose.
|
|
416
|
+
|
|
417
|
+
```jsx live live-height=560 title="Dashboard.jsx"
|
|
418
|
+
import { compose } from 'sygnal/ai'
|
|
419
|
+
import { catalog, TASKS } from './catalog.jsx'
|
|
420
|
+
|
|
421
|
+
const EXAMPLES = [
|
|
422
|
+
'Revenue and signups side by side, then the revenue chart for the quarter',
|
|
423
|
+
'Urgent tickets first, then the pending orders',
|
|
424
|
+
'The weather and my tasks',
|
|
425
|
+
'The stock price of Apple',
|
|
426
|
+
]
|
|
427
|
+
const FOLLOW_UPS = [
|
|
428
|
+
'Move the chart to the top',
|
|
429
|
+
'Show the chart for the month',
|
|
430
|
+
'Add my tasks beside the chart',
|
|
431
|
+
'The orders instead of the chart',
|
|
432
|
+
'Remove the signups',
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
const STATUS = {
|
|
436
|
+
idle: () => 'Describe the page you want.',
|
|
437
|
+
composing: () => 'Composing…',
|
|
438
|
+
preview: () => 'Arranging the parts…',
|
|
439
|
+
done: () => 'Done.',
|
|
440
|
+
unavailable: () => 'Nothing in the catalog fits that request.',
|
|
441
|
+
error: (page) => `The model failed: ${page.error}`,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const pct = (p) => `${Math.round((p ?? 1) * 100)}%`
|
|
445
|
+
const answerLine = ([name, a]) => (
|
|
446
|
+
<li><code>{name}</code>: {a.type === 'noul' ? pct(a.noul) : <>{a.choice} <span className="muted">{pct(a.probabilities?.[a.choice])}</span></>}</li>
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
function Dashboard({ state }) {
|
|
450
|
+
const page = state.page
|
|
451
|
+
const busy = page.status === 'composing' || page.status === 'preview'
|
|
452
|
+
return (
|
|
453
|
+
<div className="composer">
|
|
454
|
+
<form className="ask">
|
|
455
|
+
<input className="prompt" aria-label="The page you want" value={page.prompt} />
|
|
456
|
+
<button type="submit" disabled={busy}>Compose</button>
|
|
457
|
+
</form>
|
|
458
|
+
<div className="chips">
|
|
459
|
+
{EXAMPLES.map((p) => <button type="button" className="chip" data={{ prompt: p }} disabled={busy}>{p}</button>)}
|
|
460
|
+
</div>
|
|
461
|
+
<div className="chips">
|
|
462
|
+
{FOLLOW_UPS.map((p) => <button type="button" className="chip follow-up" data={{ prompt: p }} disabled={busy || !page.layout}>{p}</button>)}
|
|
463
|
+
</div>
|
|
464
|
+
<p className="status" aria-live="polite">{STATUS[page.status](page)}</p>
|
|
465
|
+
<div className={page.preview ? 'canvas preview' : 'canvas'}>{catalog.render(page, state)}</div>
|
|
466
|
+
{page.decisions && (
|
|
467
|
+
<details className="explain" open>
|
|
468
|
+
<summary>The model's answers</summary>
|
|
469
|
+
{Object.entries(page.decisions).map(([step, answers]) => (
|
|
470
|
+
<>
|
|
471
|
+
<b>{step}</b>
|
|
472
|
+
<ul>{Object.entries(answers).map(answerLine)}</ul>
|
|
473
|
+
</>
|
|
474
|
+
))}
|
|
475
|
+
</details>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
Dashboard.initialState = { tasks: TASKS }
|
|
482
|
+
|
|
483
|
+
Dashboard.uses = {
|
|
484
|
+
page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }), form: '.ask', prompt: '.prompt' }),
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Dashboard.intent = ({ DOM }) => ({
|
|
488
|
+
EXAMPLE: DOM.click('.chip:not(.follow-up)').data('prompt'),
|
|
489
|
+
FOLLOW_UP: DOM.click('.follow-up').data('prompt'),
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
Dashboard.model = {
|
|
493
|
+
// the demo composes on load only to have something to show
|
|
494
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('page.COMPOSE', EXAMPLES[0]) },
|
|
495
|
+
// a new page: start over
|
|
496
|
+
EXAMPLE: { EFFECT: (state, prompt, next) => next('page.COMPOSE', { prompt, fresh: true }) },
|
|
497
|
+
// a change to the page that is there
|
|
498
|
+
FOLLOW_UP: { EFFECT: (state, prompt, next) => next('page.COMPOSE', prompt) },
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export default Dashboard
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
A decision model composes in at most two `decide()` calls (watch the demo server's notes): a **select** step (which parts, which params, "side by side?"), then, when there is something to arrange, a **layout** step (which comes first, what goes beside what). After the select step the chosen parts show as a preview while the layout step runs.
|
|
505
|
+
|
|
506
|
+
### The slice
|
|
507
|
+
|
|
508
|
+
`state.page` (the key you gave in `uses`):
|
|
509
|
+
|
|
510
|
+
| Field | |
|
|
511
|
+
|---|---|
|
|
512
|
+
| `prompt` | The request |
|
|
513
|
+
| `status` | `idle`, `composing` (a request is out), `preview` (a partial layout shows), `done`, `unavailable` (nothing in the catalog fits), `error` |
|
|
514
|
+
| `layout` | The last finished layout: plain JSON, `{ root: 'page', nodes }`. Kept on an error and when nothing fits |
|
|
515
|
+
| `preview` | The layout being built; `catalog.render` shows it instead while it is set |
|
|
516
|
+
| `decisions` | What the backend explains: a decision model's answers per step, or an LLM's plan (`asked_for`) and what its repair step fixed |
|
|
517
|
+
| `error` | Why the last composition failed |
|
|
518
|
+
| `ui`, `local` | Container state (the open tab) and `local` parts' state, per node |
|
|
519
|
+
|
|
520
|
+
The layout's node keys are **identities**: the part id (`orders`), `part:value` for a record (`customer:7`), `~2` for a repeat, `row` and `row~2` for rows. A part that stays in a new composition keeps its key, so its instance and its state.
|
|
521
|
+
|
|
522
|
+
- **Nothing fits** ("The stock price of Apple"): `status: 'unavailable'`, and the page keeps its layout.
|
|
523
|
+
- **Errors**: a failed request, or a reply the backend can't turn into a valid layout, is `status: 'error'` with `error` set. The page keeps its layout: a model's mistake never becomes an invalid page.
|
|
524
|
+
- A new prompt while one is composing aborts the old one's step in flight.
|
|
525
|
+
|
|
526
|
+
### Actions
|
|
527
|
+
|
|
528
|
+
The behavior's actions are the host's, under the slice's key:
|
|
529
|
+
|
|
530
|
+
| Action | Data |
|
|
531
|
+
|---|---|
|
|
532
|
+
| `page.COMPOSE` | A prompt, `{ prompt, fresh: true }` (start over), or nothing (the form's submit: the slice's prompt). From code: `next('page.COMPOSE', 'Show my tasks')`, as the demo's chips do |
|
|
533
|
+
| `page.SET_LAYOUT` | A layout to show without a model: restore, undo, your own edits. `null` clears |
|
|
534
|
+
| `page.REMOVE` | A node key: that part and anything inside it go |
|
|
535
|
+
| `page.CONTROL` | `{ key, name, value }`: a container control (what `control()` clicks send) |
|
|
536
|
+
| `page.DONE` | Sent when a composition finishes, with the layout. A host entry `'page.DONE'` runs after the behavior's |
|
|
537
|
+
|
|
538
|
+
### Follow-up edits
|
|
539
|
+
|
|
540
|
+
When the page already has parts, `page.COMPOSE` is an **edit** of it, with either backend: "move the orders to the top", "show the chart for the month", "the orders instead of the chart". `{ prompt, fresh: true }` starts over instead (a new page, keys still reused by identity), as the demo's first row of chips does.
|
|
541
|
+
|
|
542
|
+
An edit can do any of these, several at once:
|
|
543
|
+
|
|
544
|
+
- **remove** a part ("drop the weather"; "only my tasks" removes the rest);
|
|
545
|
+
- **change a setting**: a param option ("for the month", "only the pending orders");
|
|
546
|
+
- **add** a part, with its params;
|
|
547
|
+
- **move** a part to the top or the bottom, above, below or beside another part, or into a container's slot;
|
|
548
|
+
- **swap** one part for another ("the orders instead of the chart").
|
|
549
|
+
|
|
550
|
+
What stays the same:
|
|
551
|
+
|
|
552
|
+
- **Parts the edit doesn't touch keep their key, so their instance and their state**, in their place and row.
|
|
553
|
+
- **A setting change keeps the key**: the part re-renders with the new props instead of being replaced. (A record param is the exception: another record is another node.)
|
|
554
|
+
- **A replacement takes the old part's place**, unless the request says where.
|
|
555
|
+
- The part a place is relative to ("below the chart") never moves.
|
|
556
|
+
- An edit that changes nothing, or asks for nothing in the catalog, is `status: 'unavailable'`, and the page stays.
|
|
557
|
+
|
|
558
|
+
The backends edit in their own way:
|
|
559
|
+
|
|
560
|
+
| | `compose.decisions()` | `compose.llm()` |
|
|
561
|
+
|---|---|---|
|
|
562
|
+
| Request | Call 1 asks, per part on the page, "remove it?" and "change its setting?", per part not on it "add it?", and which parts move. Call 2, only when something moves or is added, asks where each one goes | One request with the page (one node per line) and the change; the model writes operations: `add`, `remove`, `move`, `replace`, `setParams`, placed by `top`, `bottom`, `above n2`, `left of n3` |
|
|
563
|
+
| Calls | 1 or 2 | 1 (streamed: the preview applies each finished operation) |
|
|
564
|
+
| Exact edits, measured | Jev: 66/66 on the tuning set, 43/48 held out, every key kept; `nimble`: weaker, 18/22 and 8/16 | `qwen3:8b`: 9/10 and 18/21, every key kept, no invalid reply, about 1.4 s |
|
|
565
|
+
| `decisions` | The answers of `edit` and `where` | `ops`: the operations as applied, with layout keys |
|
|
566
|
+
|
|
567
|
+
The demos above and below edit: in the first, compose a page, then try the second row of chips.
|
|
568
|
+
|
|
569
|
+
#### `profile: 'small'`
|
|
570
|
+
|
|
571
|
+
A small local decision model such as Ollama's `nimble` tends to pick the first of two options, whatever they say. `compose.decisions({ profile: 'small' })` corrects for it: it asks each "which comes first" question in both orders, and when the answers come out about even, it follows the order in which the request names the parts. On the demo app's catalog it took `nimble` from 7/11 to 8/11 flat dashboards, and its side-by-side prompts from 1/4 to 3/4. It costs more questions in the same calls, so leave it off for Jev. It also picks at most 8 parts per composition (`maxParts`), and narrows a catalog of more than 30 parts by category first (`prefilter`).
|
|
572
|
+
|
|
573
|
+
```js
|
|
574
|
+
compose.decisions({ url: 'http://localhost:11434/v1/systemone', model: 'nimble', rowMean: 0.5, profile: 'small' })
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### `catalog.render`
|
|
578
|
+
|
|
579
|
+
`catalog.render(slice, state)` returns the composition as vnodes for the host's view: `slice.preview ?? slice.layout`, each part a child of the host keyed by its node key, and containers through their views. Each part gets its params (defaults filled) as props, its lens, and the prop `node` (its key).
|
|
580
|
+
|
|
581
|
+
It renders anything with a `layout`, so a layout you keep elsewhere renders the same way: `catalog.render({ layout: state.saved }, state)`.
|
|
582
|
+
|
|
583
|
+
### Rendering with `<Composed>`
|
|
584
|
+
|
|
585
|
+
`<Composed>` is the same render as a tag. It names where the composition is in the host's state instead of taking the slice:
|
|
586
|
+
|
|
587
|
+
```jsx
|
|
588
|
+
import { Composed } from 'sygnal/ai'
|
|
589
|
+
|
|
590
|
+
// the same parts, keys and instances as {catalog.render(state.page, state)}
|
|
591
|
+
<div className="canvas"><Composed catalog={catalog} from="page" /></div>
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
- `from` is a key (default `'page'`) or a path, such as `['boards', 0, 'page']`. `local` parts write their state there.
|
|
595
|
+
- `scope`, `frozen` and `answer` are props, as they are options of `catalog.render`.
|
|
596
|
+
- `card={part}` renders an `ask_user` tool part's card, as [`askCard()`](./ai-cards.md) does. With `card`, `from` is the chat's key (default `'assistant'`).
|
|
597
|
+
|
|
598
|
+
The parts are still the host's children, so `CHILD.select`, lenses and keys work the same. The forms differ in three ways:
|
|
599
|
+
|
|
600
|
+
| | `catalog.render(slice, state)` | `<Composed catalog from />` |
|
|
601
|
+
|---|---|---|
|
|
602
|
+
| Reads | Whatever slice you pass, including one the view computes | The host's own state at `from` |
|
|
603
|
+
| A container view or part `props()` throws | The composition becomes an empty `.compose-error` element (SYG406) | The host's `onError` renders in place of the composition (SYG408) |
|
|
604
|
+
| Misuse | A missing slice renders nothing | No catalog, or nothing composed at `from`: [SYG451](https://sygnal.js.org/reference/errors/#syg451) in development, renders nothing |
|
|
605
|
+
|
|
606
|
+
Use `<Composed>` when the composition is a slice of the host's state, as the `compose` behavior keeps it, and you want the host's `onError` to show a failure. Use `catalog.render()` for a layout the view computes or keeps outside state. The [Planner](#parts-are-real-components) demo below uses `<Composed>`. An app that never renders the tag ships none of it.
|
|
607
|
+
|
|
608
|
+
## Parts are real components
|
|
609
|
+
|
|
610
|
+
Composed parts are the host's children. A part with a lens edits app state, a part's `PARENT` value reaches the host through `CHILD.select`, and context and drivers work as for any child.
|
|
611
|
+
|
|
612
|
+
In this demo the checklist edits `state.tasks` (the summary line outside the composition reads it), and the ticket list's "Add to my tasks" goes to the host, which adds a task the checklist then shows. Tick a task, then "Move my tasks to the top": the edit moves the checklist out of the row and keeps its node key (`tasks`), so it is the same instance, with the same ticks. "Only my tasks" removes the tickets and leaves the checklist as it is.
|
|
613
|
+
|
|
614
|
+
```jsx live live-height=300 title="Planner.jsx"
|
|
615
|
+
import { ABORT } from 'sygnal'
|
|
616
|
+
import { compose, Composed } from 'sygnal/ai'
|
|
617
|
+
import { catalog, TASKS, TicketList } from './catalog.jsx'
|
|
618
|
+
|
|
619
|
+
const PROMPTS = ['My tasks beside the urgent tickets', 'Move my tasks to the top', 'Only my tasks']
|
|
620
|
+
|
|
621
|
+
function Planner({ state }) {
|
|
622
|
+
const done = state.tasks.filter((t) => t.done).length
|
|
623
|
+
return (
|
|
624
|
+
<div className="composer">
|
|
625
|
+
<p className="summary">{done} of {state.tasks.length} tasks done</p>
|
|
626
|
+
<div className="chips">{PROMPTS.map((p) => <button type="button" className="chip" data={{ prompt: p }}>{p}</button>)}</div>
|
|
627
|
+
<div className="canvas"><Composed catalog={catalog} from="page" /></div>
|
|
628
|
+
</div>
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
Planner.initialState = { tasks: TASKS }
|
|
633
|
+
|
|
634
|
+
Planner.uses = { page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }) }) }
|
|
635
|
+
|
|
636
|
+
Planner.intent = ({ DOM, CHILD }) => ({
|
|
637
|
+
EXAMPLE: DOM.click('.chip').data('prompt'),
|
|
638
|
+
TAKE: CHILD.select(TicketList),
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
Planner.model = {
|
|
642
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('page.COMPOSE', PROMPTS[0]) },
|
|
643
|
+
// the first prompt starts over; the others edit the page
|
|
644
|
+
EXAMPLE: { EFFECT: (state, prompt, next) => next('page.COMPOSE', prompt === PROMPTS[0] ? { prompt, fresh: true } : prompt) },
|
|
645
|
+
TAKE: (state, ticket) => (state.tasks.some((t) => t.text === ticket.title)
|
|
646
|
+
? ABORT
|
|
647
|
+
: { ...state, tasks: [...state.tasks, { id: state.tasks.length + 1, text: ticket.title, done: false }] }),
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export default Planner
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
**Persisting a page.** The layout is plain state, so [`persist()`](./persistence.md) keeps it across reloads with no model call: pick the slice and the state the parts use.
|
|
654
|
+
|
|
655
|
+
```js
|
|
656
|
+
import { persist } from 'sygnal'
|
|
657
|
+
|
|
658
|
+
Planner.persist = persist({ key: 'planner', pick: ['page', 'tasks'] })
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
## Backends
|
|
662
|
+
|
|
663
|
+
### `compose.decisions()`
|
|
664
|
+
|
|
665
|
+
```js
|
|
666
|
+
compose.decisions({ model: 'jev-latest' }) // POST /api/decide, your server's route
|
|
667
|
+
compose.decisions({ url: 'http://localhost:11434/v1/systemone', model: 'nimble', rowMean: 0.5 }) // a local Ollama
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
It sends [`decide()`](./ai-decisions.md) requests on the `HTTP` sink (the [fetch driver](./http.md)): one when nothing fits or a single part is asked for, otherwise two. The model writes no text; code reads the probabilities, so the layout follows the answers it was most sure of. Options:
|
|
671
|
+
|
|
672
|
+
- `url`: the decision endpoint, `'/api/decide'` by default;
|
|
673
|
+
- `model`: required ([SYG681](https://sygnal.js.org/reference/errors/#syg681) without one);
|
|
674
|
+
- `rowMean`: how sure "side by side" answers must be to make a row (0.25, measured on Jev; 0.5 suits `nimble`);
|
|
675
|
+
- `prefilter`: one more call first, to narrow a large catalog by the parts' `category` (about half the tokens, the same accuracy on Jev). `'auto'` by default: on when the catalog has categories and more than 60 parts (30 with `profile: 'small'`); `true` or `false` to choose;
|
|
676
|
+
- `maxParts`: the most parts one composition picks (the catalog's `limits.maxParts`, 12; 8 with `profile: 'small'`);
|
|
677
|
+
- `maxOptions`: the most options one question may have (255; 25 when `url` looks like Ollama): a larger choice is split into several questions;
|
|
678
|
+
- `maxQuestions`, `maxPromptTokens`, `split`: passed to every `decide()` request ([Large question sets](./ai-decisions.md#large-question-sets)). A step with more than 64 questions, such as `profile: 'small'`'s layout step for 6 or more elements, goes as parallel requests read as one;
|
|
679
|
+
- `profile: 'small'`: for a small local model such as `nimble` ([`profile: 'small'`](#profile-small)).
|
|
680
|
+
|
|
681
|
+
### `compose.llm()`
|
|
682
|
+
|
|
683
|
+
```js
|
|
684
|
+
compose.llm({ model: 'gpt-5-mini' }) // the LLM sink: one structured-output request, streamed
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
It sends one chat request on the `LLM` sink ([`makeChatDriver()`](./ai-chat.md), any transport): a system prompt with the catalog as text and its `guidance`, and a JSON Schema of the catalog as the structured output. The model writes a short plan (`asked_for`: what the request asks for), then the layout tree. Parts are closed objects with enum params, so a schema the provider enforces keeps it to the catalog. While it streams, the complete parts so far show as the preview. The reply goes through a repair step (unknown parts dropped, duplicates removed, bad params set to their default); a reply still invalid after it is `status: 'error'` ([SYG683](https://sygnal.js.org/reference/errors/#syg683)), never a layout.
|
|
688
|
+
|
|
689
|
+
Options: `model`, `sink` (`'LLM'`), `stream` (true), `plan` (true; false when the catalog has `examples`), `instructions` (more rules, or a function of the system prompt), `examples`, and transport options such as `body: { temperature: 0 }`, sent on the request as is.
|
|
690
|
+
|
|
691
|
+
This demo is the first one with `compose.llm()`: its stand-in model streams the layout JSON from the demo server's `'POST /v1/responses'` route, so the page fills in part by part. The second row of chips edits the page: the stand-in streams operations, and the preview applies each one as it completes. Under the page, the model's plan, or the operations of an edit as applied (with the layout's keys).
|
|
692
|
+
|
|
693
|
+
```jsx live live-height=400 title="LlmDashboard.jsx"
|
|
694
|
+
import { compose } from 'sygnal/ai'
|
|
695
|
+
import { catalog, TASKS } from './catalog.jsx'
|
|
696
|
+
|
|
697
|
+
const PROMPTS = ['Signups and revenue side by side, then the monthly revenue chart and the paid orders', 'The weather, then urgent tickets']
|
|
698
|
+
const FOLLOW_UPS = ['Move the paid orders to the top', 'Show the chart for the quarter', 'Add the weather beside the signups', 'Remove the revenue']
|
|
699
|
+
|
|
700
|
+
const opText = (o) => [o.op, o.node, o.part !== o.node && o.part, o.params && JSON.stringify(o.params), o.where].filter(Boolean).join(' ')
|
|
701
|
+
|
|
702
|
+
function LlmDashboard({ state }) {
|
|
703
|
+
const page = state.page
|
|
704
|
+
const busy = page.status === 'composing' || page.status === 'preview'
|
|
705
|
+
return (
|
|
706
|
+
<div className="composer">
|
|
707
|
+
<div className="chips">{PROMPTS.map((p) => <button type="button" className="chip" data={{ prompt: p }} disabled={busy}>{p}</button>)}</div>
|
|
708
|
+
<div className="chips">{FOLLOW_UPS.map((p) => <button type="button" className="chip follow-up" data={{ prompt: p }} disabled={busy || !page.layout}>{p}</button>)}</div>
|
|
709
|
+
<p className="status" aria-live="polite">{page.status === 'preview' ? 'Writing the layout…' : page.status}</p>
|
|
710
|
+
<div className={page.preview ? 'canvas preview' : 'canvas'}>{catalog.render(page, state)}</div>
|
|
711
|
+
{page.decisions?.asked_for && <p className="explain">Plan: {page.decisions.asked_for.join(' · ')}</p>}
|
|
712
|
+
{page.decisions?.ops && <p className="explain">Operations: {page.decisions.ops.map(opText).join(' · ')}</p>}
|
|
713
|
+
</div>
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
LlmDashboard.initialState = { tasks: TASKS }
|
|
718
|
+
|
|
719
|
+
LlmDashboard.uses = { page: compose(catalog, { backend: compose.llm() }) }
|
|
720
|
+
|
|
721
|
+
LlmDashboard.intent = ({ DOM }) => ({
|
|
722
|
+
EXAMPLE: DOM.click('.chip:not(.follow-up)').data('prompt'),
|
|
723
|
+
FOLLOW_UP: DOM.click('.follow-up').data('prompt'),
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
LlmDashboard.model = {
|
|
727
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('page.COMPOSE', PROMPTS[0]) },
|
|
728
|
+
EXAMPLE: { EFFECT: (state, prompt, next) => next('page.COMPOSE', { prompt, fresh: true }) },
|
|
729
|
+
FOLLOW_UP: { EFFECT: (state, prompt, next) => next('page.COMPOSE', prompt) },
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export default LlmDashboard
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
### Choosing one
|
|
736
|
+
|
|
737
|
+
Measured on the research catalogs (exact layouts; the case sets differ by column):
|
|
738
|
+
|
|
739
|
+
| | Jev (TypeSafe) | `nimble` (Ollama) | `qwen3:8b` + schema (Ollama) |
|
|
740
|
+
|---|---|---|---|
|
|
741
|
+
| Backend | `compose.decisions()` | `compose.decisions()` | `compose.llm()` |
|
|
742
|
+
| Flat dashboard (11 prompts) | 9/11 | 9/11 | 11/11 |
|
|
743
|
+
| Rows, sections, tabs, repeats (5 catalogs) | 191/210 | 38/68 | 15/15 |
|
|
744
|
+
| Enum params | 57/60 | 19/20 | 20/20 |
|
|
745
|
+
| Parts from records | 57/60 | 18/20 | — |
|
|
746
|
+
| Free-text params | keeps the default | keeps the default | yes |
|
|
747
|
+
| Time per composition | ~0.35 s, 2 calls | ~3 s | ~3.4 s, 1 call |
|
|
748
|
+
|
|
749
|
+
- **Jev** is the production default: fast, cheap, and strong on structure, params and records.
|
|
750
|
+
- **`nimble`** is fine on your machine for small catalogs (about 10 parts or fewer), and weak on nesting.
|
|
751
|
+
- **An LLM with a schema the provider enforces** is the most accurate local option and the only one that fills free text, about ten times slower than Jev.
|
|
752
|
+
|
|
753
|
+
Switching is one line: the catalog, the host and the tests stay the same. A backend sends on one sink, so a page that switches between kinds at run time uses one host per kind (the [`examples/ai-compose`](https://github.com/tpresley/sygnal/tree/main/examples/ai-compose) app shows both in a `Switchable`).
|
|
754
|
+
|
|
755
|
+
### Keys stay on the server
|
|
756
|
+
|
|
757
|
+
`compose.decisions()` posts to `/api/decide` by default, so the [server route from AI Decisions](./ai-decisions.md#keys-stay-on-the-server) serves it unchanged: it adds the key, fixes the model, requires a session and limits the size of the request. For Jev, point it at TypeSafe's endpoint:
|
|
758
|
+
|
|
759
|
+
```js
|
|
760
|
+
// server: POST /api/decide
|
|
761
|
+
import { currentUser } from './auth.js' // your session check
|
|
762
|
+
|
|
763
|
+
const MODELS = new Set(['jev-latest'])
|
|
764
|
+
const DECIDE_URL = 'https://api.typesafe.ai/v1/systemone'
|
|
765
|
+
|
|
766
|
+
export async function POST(req) {
|
|
767
|
+
if (!(await currentUser(req))) return new Response('Sign in first', { status: 401 })
|
|
768
|
+
const body = await req.text()
|
|
769
|
+
const { model } = JSON.parse(body)
|
|
770
|
+
if (!MODELS.has(model) || body.length > 512_000) return new Response('Bad request', { status: 400 })
|
|
771
|
+
const res = await fetch(DECIDE_URL, {
|
|
772
|
+
method: 'POST',
|
|
773
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${process.env.TYPESAFE_API_KEY}` },
|
|
774
|
+
body,
|
|
775
|
+
})
|
|
776
|
+
return new Response(res.body, { status: res.status, headers: { 'content-type': 'application/json' } })
|
|
777
|
+
}
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
A composition's requests are larger than a single decision (every part's description is in its questions), so allow more than a triage route would. `compose.llm()` goes through your chat driver's transport, which [ships behind your own route](./ai-chat.md#shipping-it) the same way.
|
|
781
|
+
|
|
782
|
+
## Compose from an agent
|
|
783
|
+
|
|
784
|
+
A chat assistant can arrange the page too. `compose.action('page')` is the [`agent`](./agent.md#the-agent-static) action for the behavior's `page.COMPOSE`, and `compose.summary(slice)` is what the assistant reads of the page:
|
|
785
|
+
|
|
786
|
+
```jsx
|
|
787
|
+
import { chat, compose } from 'sygnal/ai'
|
|
788
|
+
import { catalog } from './catalog.jsx'
|
|
789
|
+
|
|
790
|
+
function Workspace({ state }) {
|
|
791
|
+
const a = state.assistant
|
|
792
|
+
return (
|
|
793
|
+
<main>
|
|
794
|
+
<div className="canvas">{catalog.render(state.page, state)}</div>
|
|
795
|
+
<form className="ask">
|
|
796
|
+
<input className="prompt" aria-label="Ask the assistant" value={a.prompt} />
|
|
797
|
+
<button type="submit">Send</button>
|
|
798
|
+
</form>
|
|
799
|
+
</main>
|
|
800
|
+
)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
Workspace.uses = {
|
|
804
|
+
page: compose(catalog, { backend: compose.decisions({ model: 'jev-latest' }) }),
|
|
805
|
+
assistant: chat({ form: '.ask', prompt: '.prompt', instructions: 'You arrange the workspace with the tools.' }),
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
Workspace.agent = {
|
|
809
|
+
name: 'app',
|
|
810
|
+
description: 'The workspace page',
|
|
811
|
+
read: (state) => ({ page: compose.summary(state.page) }),
|
|
812
|
+
actions: { ...compose.action('page') },
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
- The model gets the tool `app_page_compose({ prompt, fresh? })`: `prompt` is what the page should show, or how to change it, in the user's words. Like any `page.COMPOSE`, it [edits](#follow-up-edits) the current page unless `fresh` is true.
|
|
817
|
+
- The tool's result waits for the composition ([`until`](./agent.md#waiting-for-a-result-until)), so its `state` already shows the new page. `compose.summary(slice)` is `{ status, page, error? }`, with `page` the layout as a tree: the assistant can tell the user what it placed, or that nothing fit.
|
|
818
|
+
- `compose.action(key, { description, timeout })` takes another slice key, its own tool description, and a timeout (60 s by default; past it the result says the composition is still pending).
|
|
819
|
+
- With `compose.llm()`, the composition's request goes on the same `LLM` sink as the chat's, in its own lane with its own reply actions, so neither aborts the other.
|
|
820
|
+
|
|
821
|
+
The [`chat` behavior](./agent.md#an-in-app-assistant-chat) and the call rules are in Agents.
|
|
822
|
+
|
|
823
|
+
## Testing
|
|
824
|
+
|
|
825
|
+
Tests never call a model. `composeWith(t, catalog, tree)` answers the pending composition so that it becomes `tree`, whichever backend is pending: it builds each step's reply with the backend's own `fake()` (the `decide()` answers, or the LLM's JSON) and sends it as the model would. So one test runs against both backends:
|
|
826
|
+
|
|
827
|
+
```js
|
|
828
|
+
import { describe, it, expect } from 'vitest'
|
|
829
|
+
import { renderComponent } from 'sygnal'
|
|
830
|
+
import { compose, composeWith } from 'sygnal/ai'
|
|
831
|
+
import { catalog } from './catalog.jsx'
|
|
832
|
+
import { makeDashboard } from './Dashboard.jsx'
|
|
833
|
+
|
|
834
|
+
describe.each([
|
|
835
|
+
['decisions', compose.decisions({ model: 'jev-latest' })],
|
|
836
|
+
['llm', compose.llm()],
|
|
837
|
+
])('compose.%s()', (name, backend) => {
|
|
838
|
+
it('puts revenue and signups in a row, then the chart for the quarter', async () => {
|
|
839
|
+
const t = renderComponent(makeDashboard(backend))
|
|
840
|
+
t.simulateAction('page.COMPOSE', 'Revenue and signups side by side, then the revenue chart for the quarter')
|
|
841
|
+
await composeWith(t, catalog, [
|
|
842
|
+
{ part: 'row', children: [{ part: 'revenue' }, { part: 'signups' }] },
|
|
843
|
+
{ part: 'revenueChart', params: { range: 'quarter' } },
|
|
844
|
+
])
|
|
845
|
+
await t.waitForState((s) => s.page.status === 'done')
|
|
846
|
+
expect(t.query('.compose-row')).toBeTruthy()
|
|
847
|
+
expect(t.html()).toContain('Revenue, last quarter')
|
|
848
|
+
t.dispose()
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('keeps the page when nothing fits', async () => {
|
|
852
|
+
const t = renderComponent(makeDashboard(backend))
|
|
853
|
+
t.simulateAction('page.SET_LAYOUT', { root: 'page', nodes: { page: { part: 'page', slots: { default: ['weather'] } }, weather: { part: 'weather' } } })
|
|
854
|
+
t.simulateAction('page.COMPOSE', 'The stock price of Apple')
|
|
855
|
+
await composeWith(t, catalog, [])
|
|
856
|
+
await t.waitForState((s) => s.page.status === 'unavailable')
|
|
857
|
+
expect(t.html()).toContain('27°C')
|
|
858
|
+
t.dispose()
|
|
859
|
+
})
|
|
860
|
+
})
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Here `makeDashboard(backend)` returns the first demo's component with that backend in its `uses` (and without its `BOOTSTRAP` entry: the demo composes on load only to have something to show).
|
|
864
|
+
|
|
865
|
+
- `composeWith` throws when the backend can't reach `tree` (a record the prompt doesn't name, for a decision model), so a test can't pass by accident.
|
|
866
|
+
- `{ stream: n }` streams an LLM reply in `n` chunks, through the preview.
|
|
867
|
+
- To test the preview or a failure, answer one step yourself: `t.respond('HTTP', backend.fake({ catalog, state: t.state, pending: t.state.page.pending, target }), 'page.REPLY')`, or `t.fail(backend.sink, 500, 'page.FAILED')`.
|
|
868
|
+
- A layout is plain JSON: `t.state.page.layout.nodes` holds the keys and params to assert on.
|
|
869
|
+
|
|
870
|
+
## Cards in a chat
|
|
871
|
+
|
|
872
|
+
The same catalogs build the cards an assistant asks the user with: `chat({ ask: { catalog } })` gives the model an `ask_user` tool, the app composes a card from prepared parts, and the user's click is the tool's result. [Cards in a Chat](./ai-cards.md) documents it, with a demo.
|
|
873
|
+
|
|
874
|
+
## Related
|
|
875
|
+
|
|
876
|
+
- [AI Decisions](./ai-decisions.md): `decide()`, the questions, and the server route that keeps the key
|
|
877
|
+
- [AI Chat](./ai-chat.md): the chat driver, transports and structured output
|
|
878
|
+
- [Agents](./agent.md): the `chat` behavior and the `agent` static
|
|
879
|
+
- [Cards in a Chat](./ai-cards.md): `chat({ ask })`, client tools and composed cards
|
|
880
|
+
- [Parent-Child Communication](https://sygnal.js.org/guide/parent-child/): `PARENT` and `CHILD.select`
|
|
881
|
+
- [Persistence](./persistence.md): `persist()`
|