sygnal 6.2.0 → 6.4.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 +112 -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/index.cjs.js +69 -3
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +69 -3
- package/dist/astro/index.mjs.map +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 +211 -11
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +211 -11
- 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/guide/mcp-apps.md +59 -4
- package/dist/index.cjs.js +5704 -350
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +647 -10
- package/dist/index.esm.js +5694 -350
- 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/dist/vite/plugin.cjs.js +69 -3
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +69 -3
- package/dist/vite/plugin.mjs.map +1 -1
- package/llms.txt +7 -5
- 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/dom.ts +10 -2
- package/src/extra/diagnostics/checks/next.ts +103 -3
- package/src/extra/diagnostics/checks/persist.ts +4 -2
- package/src/extra/diagnostics/checks/wiring.ts +14 -6
- package/src/extra/diagnostics/codes.ts +59 -0
- package/src/extra/fetchDriver.ts +58 -27
- package/src/extra/lens.ts +15 -0
- package/src/extra/pwa.ts +3 -1
- package/src/extra/reducers.ts +10 -4
- 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 +50 -2
- 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
- package/src/vite/plugin.ts +63 -2
package/dist/guide/agent.md
CHANGED
|
@@ -468,7 +468,9 @@ In production the provider key belongs on your server: `makeChatDriver({ transpo
|
|
|
468
468
|
| `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none (a plain chat, [AI Chat](./ai-chat.md#the-same-chat-with-the-chat-behavior)), `[Comp, ...]` for only these (inside the host) |
|
|
469
469
|
| `maxSteps` | `8` | The most requests per turn; tool calls in a reply past it are not run |
|
|
470
470
|
| `sink` | `'LLM'` | The chat driver's name |
|
|
471
|
-
| `model`, `transportOptions` | | A model id,
|
|
471
|
+
| `model`, `transportOptions` | | A model id, or a function of the host's state that returns one, read per request (`(state) => state.chatModel`, for a model picker); extra request keys for the transport |
|
|
472
|
+
| `clientTools` | | Tools the app answers itself with `TOOL_OUTPUT` ([client tools](./ai-cards.md#client-tools)) |
|
|
473
|
+
| `ask` | | `{ catalog, backend?, instructions?, direct? }`: the `ask_user` tool, which asks the user with a composed card ([Cards in a Chat](./ai-cards.md)) |
|
|
472
474
|
|
|
473
475
|
The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
|
|
474
476
|
|
|
@@ -489,6 +491,8 @@ Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the
|
|
|
489
491
|
|
|
490
492
|
**Approvals.** A consequential call sets `pending` and waits. Render `pending.description` and `pending.label` with the approve and deny buttons. `APPROVE` runs it; `DENY` tells the model the user declined, and the model continues from there. `STOP` and removing the component decline too.
|
|
491
493
|
|
|
494
|
+
**Composing the page.** An assistant can arrange a [composed page](./ai-compose.md) as an action: `actions: { ...compose.action('page') }` gives it the tool `<name>_page_compose({ prompt, fresh? })`, whose result waits for the composition, and `read: (state) => ({ page: compose.summary(state.page) })` shows it the page ([Compose from an agent](./ai-compose.md#compose-from-an-agent)).
|
|
495
|
+
|
|
492
496
|
## A command bar: `commandBar`
|
|
493
497
|
|
|
494
498
|
For "do one thing in this app" from one line of text, a chat model's conversation loop is more than you need. `commandBar` asks a **decision model** one question per command: which of the `agent` actions it means, and which Collection item. The answer comes with a confidence, and the action runs through the [call rules](#the-call-rules) only when the model is sure enough. In the research behind this feature, a local decision model picked the right action and target for 8 of 8 commands, in about 570 ms each. Decision models, `decide()` and confidence are in [AI Decisions](./ai-decisions.md).
|
|
@@ -695,6 +699,8 @@ To test the assistant itself, drive the `LLM` fake: `t.stream('LLM', [{ toolCall
|
|
|
695
699
|
## Related
|
|
696
700
|
|
|
697
701
|
- [AI Chat](./ai-chat.md): the chat driver under the `chat` behavior, transports, and the raw tool loop
|
|
702
|
+
- [Cards in a Chat](./ai-cards.md): client tools (`clientTools`, `TOOL_OUTPUT`) and `chat({ ask })`
|
|
703
|
+
- [Composing UI with a Model](./ai-compose.md#compose-from-an-agent): `compose.action()`, the page as an agent action
|
|
698
704
|
- [AI Decisions](./ai-decisions.md): decision models, `decide()` and confidence, which the command bar uses
|
|
699
705
|
- [WebMCP](./webmcp.md): the same tools for the browser's agent (experimental)
|
|
700
706
|
- [MCP Apps](./mcp-apps.md): your app inside Claude, ChatGPT or VS Code, with its tools
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Cards in a Chat
|
|
3
|
+
|
|
4
|
+
Some answers are easier to click than to type: one of a few options, a plan from the app's list, a short form, "does this look right?". With `chat({ ask })` the assistant asks for them with a **card**. The model calls the `ask_user` tool and says in one sentence what it needs, the app composes a card from **parts it prepared** (the same [catalog](./ai-compose.md#the-catalog) as a composed page), and the user's answer goes back to the model as the tool's result.
|
|
5
|
+
|
|
6
|
+
As with a composed page, the model only chooses: every button and label on the card is the app's own component, and the answer is a value your component sends.
|
|
7
|
+
|
|
8
|
+
## A plan assistant
|
|
9
|
+
|
|
10
|
+
The demo server below stands in for the model. It matches words and replies in the Open Responses format a real model streams, through the page's `LLM` driver:
|
|
11
|
+
|
|
12
|
+
- the chat's first request: "Let me ask.", then an `ask_user` call;
|
|
13
|
+
- a card's request (a structured-output request: it has `text.format`): the layout JSON a chat model writes, from the call's `need`;
|
|
14
|
+
- a request after a card's answer (the last input is the tool's output, `{ part, answer }`): the next question, or the end.
|
|
15
|
+
|
|
16
|
+
```js live-server
|
|
17
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
18
|
+
|
|
19
|
+
const words = (text) => text.match(/\S+\s*/g)
|
|
20
|
+
const say = (chunks) => ({ sse: encodeOpenResponses(chunks), delayMs: 300, chunkMs: 40 })
|
|
21
|
+
const ask = (need) => ({ toolCall: { name: 'ask_user', input: { need } } })
|
|
22
|
+
|
|
23
|
+
// the card for a need: the layout JSON compose.llm() asks for (a real model reads the catalog)
|
|
24
|
+
function card(need) {
|
|
25
|
+
const children = /plan/i.test(need)
|
|
26
|
+
? [{ part: 'plans' }]
|
|
27
|
+
: [{ part: 'choice', params: { question: need, options: ['Just me', '2 to 10', 'More than 10'] } }]
|
|
28
|
+
return JSON.stringify({ asked_for: children.map((c) => c.part), children }).match(/[\s\S]{1,16}/g)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default {
|
|
32
|
+
'POST /v1/responses': ({ json }) => {
|
|
33
|
+
const last = json.input.at(-1)
|
|
34
|
+
// compose.llm() building a card: its message is the call's `need`
|
|
35
|
+
if (json.text?.format) return say(card(String(last.content)))
|
|
36
|
+
// a card was answered (or closed): its output is { part, answer } (or { error })
|
|
37
|
+
if (last.type === 'function_call_output') {
|
|
38
|
+
const out = JSON.parse(last.output)
|
|
39
|
+
if (out.part === 'choice' && out.answer === 'Just me') return say([...words('Then Free is enough. '), ask('Pick the plan you want')])
|
|
40
|
+
if (out.part === 'choice') return say([...words(`For ${out.answer.toLowerCase()} people, Team or Business. `), ask('Pick the plan you want')])
|
|
41
|
+
if (out.part === 'plans') return say(words(`Done: you are on the ${out.answer} plan.`))
|
|
42
|
+
return say(words('No card then. Tell me how many people will use it.'))
|
|
43
|
+
}
|
|
44
|
+
return say(['Let me ask. ', ask('How many people will use it?')])
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The catalog: parts with `result: true` answer a card. `choice` has free-text params (the model writes the question and the options); `plans` is the app's own list.
|
|
50
|
+
|
|
51
|
+
```jsx live-file=./cards.jsx title="cards.jsx"
|
|
52
|
+
import { defineCatalog, compose } from 'sygnal/ai'
|
|
53
|
+
|
|
54
|
+
export const PLANS = [
|
|
55
|
+
{ id: 'free', name: 'Free', price: '$0' },
|
|
56
|
+
{ id: 'team', name: 'Team', price: '$12' },
|
|
57
|
+
{ id: 'business', name: 'Business', price: '$30' },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
export function Choice({ question, options = [], answer, frozen }) {
|
|
61
|
+
return (
|
|
62
|
+
<div className="choice">
|
|
63
|
+
<p>{question}</p>
|
|
64
|
+
{options.map((o) => <button type="button" className={o === answer ? 'option picked' : 'option'} data={{ value: o }} disabled={frozen}>{o}</button>)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
Choice.intent = ({ DOM }) => ({ PICK: DOM.click('.option').data('value') })
|
|
69
|
+
Choice.model = { PICK: { PARENT: (state, value, next, props) => compose.answer(props, value) } }
|
|
70
|
+
|
|
71
|
+
// no `state` in the catalog entry: the part sees the host's state, as any child does
|
|
72
|
+
export function PlanPicker({ state, answer, frozen }) {
|
|
73
|
+
return (
|
|
74
|
+
<ul className="plans">
|
|
75
|
+
{state.plans.map((p) => (
|
|
76
|
+
<li><button type="button" className={p.id === answer ? 'plan picked' : 'plan'} data={{ id: p.id }} disabled={frozen}>{p.name} ({p.price})</button></li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
PlanPicker.intent = ({ DOM }) => ({ PICK: DOM.click('.plan').data('id') })
|
|
82
|
+
PlanPicker.model = { PICK: { PARENT: (state, id, next, props) => compose.answer(props, id) } }
|
|
83
|
+
|
|
84
|
+
export const catalog = defineCatalog({
|
|
85
|
+
parts: {
|
|
86
|
+
choice: {
|
|
87
|
+
component: Choice, result: true,
|
|
88
|
+
description: 'A question with 2 to 5 short options; the user picks one',
|
|
89
|
+
params: {
|
|
90
|
+
question: { type: 'text', maxLength: 120, required: true },
|
|
91
|
+
options: { type: 'list', items: { maxLength: 40 }, min: 2, max: 5, required: true },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
plans: { component: PlanPicker, result: true, description: 'The pricing plans to pick from (Free, Team, Business)' },
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```css live
|
|
100
|
+
.plan-chat { display: grid; gap: 0.5rem; }
|
|
101
|
+
.plan-chat ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
|
|
102
|
+
.plan-chat .ask-card { border: 1px solid #8884; border-radius: 6px; padding: 0.4rem 0.6rem; }
|
|
103
|
+
.plan-chat .ask-card p { margin: 0 0 0.3rem; }
|
|
104
|
+
.plan-chat .ask-card ul { list-style: none; margin: 0; padding: 0; display: flex; gap: 0.4rem; }
|
|
105
|
+
.plan-chat .ask-card fieldset { border: 0; margin: 0; padding: 0; }
|
|
106
|
+
.plan-chat .option, .plan-chat .plan { margin-right: 0.3rem; }
|
|
107
|
+
.plan-chat .picked { font-weight: bold; }
|
|
108
|
+
.plan-chat .card-status { font-size: 0.8rem; opacity: 0.7; }
|
|
109
|
+
.plan-chat .ask { display: flex; gap: 0.5rem; }
|
|
110
|
+
.plan-chat .ask input { flex: 1; }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The demo asks on load. Answer the card: the answer goes back to the model, which asks again with a second card, built from the app's plans.
|
|
114
|
+
|
|
115
|
+
```jsx live live-height=380 title="PlanChat.jsx"
|
|
116
|
+
import { chat, askCard } from 'sygnal/ai'
|
|
117
|
+
import { catalog, PLANS } from './cards.jsx'
|
|
118
|
+
|
|
119
|
+
// a message part: text, or an ask_user call with its card
|
|
120
|
+
function part(p, state) {
|
|
121
|
+
if (p.type === 'text') return <p>{p.text}</p>
|
|
122
|
+
if (p.type !== 'tool-ask_user') return null
|
|
123
|
+
return (
|
|
124
|
+
<section className="ask-card">
|
|
125
|
+
{askCard(catalog, state, p) ?? <p>Composing a card…</p>}
|
|
126
|
+
<span className="card-status">{p.ui?.status}</span>
|
|
127
|
+
</section>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function PlanChat({ state }) {
|
|
132
|
+
const a = state.assistant
|
|
133
|
+
const busy = a.status === 'submitted' || a.status === 'streaming'
|
|
134
|
+
return (
|
|
135
|
+
<div className="plan-chat">
|
|
136
|
+
<ol aria-live="polite">
|
|
137
|
+
{a.messages.map((m) => <li className={m.role}><b>{m.role}:</b> {m.parts.map((p) => part(p, state))}</li>)}
|
|
138
|
+
{a.draft && <li className="assistant"><b>assistant:</b> {a.draft}</li>}
|
|
139
|
+
</ol>
|
|
140
|
+
<form className="ask">
|
|
141
|
+
<input className="prompt" aria-label="Message" value={a.prompt} />
|
|
142
|
+
<button type="submit" disabled={busy}>Send</button>
|
|
143
|
+
<button type="button" className="stop" hidden={!busy}>Stop</button>
|
|
144
|
+
</form>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
PlanChat.initialState = { plans: PLANS }
|
|
150
|
+
|
|
151
|
+
PlanChat.uses = {
|
|
152
|
+
assistant: chat({
|
|
153
|
+
form: '.ask', prompt: '.prompt', stop: '.stop',
|
|
154
|
+
instructions: 'Help the user choose a plan. Ask with ask_user when they need to choose something.',
|
|
155
|
+
ask: { catalog },
|
|
156
|
+
}),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
PlanChat.model = {
|
|
160
|
+
// the demo asks on load only to have something to show
|
|
161
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('assistant.SEND', 'Which plan should I take?') },
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export default PlanChat
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## How a card works
|
|
168
|
+
|
|
169
|
+
`chat({ ask: { catalog, backend?, instructions?, direct? } })` adds the tool `ask_user({ need })` to the chat's requests. Its description tells the model to say in one sentence what it needs, and to prefer a card to a question in text when the answer is one of a few options or structured. `instructions` adds your own guidance on when and how to ask.
|
|
170
|
+
|
|
171
|
+
1. **The model calls `ask_user`** with its `need`. The call stays open (`input-available`), and its card starts composing.
|
|
172
|
+
2. **The backend composes the card** from the `need` and the app state, never the conversation: `compose.llm()` by default, on the chat's own sink, or `compose.decisions()` ([below](#two-backends)). Cards compose one at a time, each under its own request key (`assistant.ask:<call id>`), so a card's request never aborts the chat's.
|
|
173
|
+
3. **The host renders it** with `askCard(catalog, state, part)` wherever it renders the message's `tool-ask_user` part. It is null while there is no card yet.
|
|
174
|
+
4. **The user answers** in a result part, which sends `compose.answer(props, value)` to `PARENT`. The call closes with the output `{ part, answer }` (the part's id, the value), and once no call is open the chat sends the next request, so the model continues from the answer.
|
|
175
|
+
|
|
176
|
+
The card lives on the tool part, as `part.ui`:
|
|
177
|
+
|
|
178
|
+
| Field | |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `status` | `composing` (queued, or its request is out), `open` (answerable), `answered`, `failed`, `cancelled` |
|
|
181
|
+
| `layout` | The card: a layout, as on a composed page |
|
|
182
|
+
| `answer` | `{ node, part, value }` once answered, else `null` |
|
|
183
|
+
| `local`, `ui` | The card's `local` parts' state and container state, per node key |
|
|
184
|
+
|
|
185
|
+
- **Statuses of the chat.** While a card composes, the chat's `status` is `'streaming'`. Once every open card is composed and only the user's answers are outstanding, it is `'ready'`, and the turn is not over (no `DONE`). Several `ask_user` calls in one reply give several cards; the next request goes when all are answered.
|
|
186
|
+
- **A failed card** (its request fails, or the reply is not a valid card) closes the call with the error "could not show a card: …; ask in text instead", and the turn goes on: the model can ask in text.
|
|
187
|
+
- **STOP** aborts a card that is composing and cancels the open ones: their calls close as "not answered: stopped by the user", and nothing is sent. **SEND** of a new message cancels them too ("not answered: the user sent a new message"), so the model sees its question went unanswered. A cancelled or answered card renders frozen.
|
|
188
|
+
|
|
189
|
+
### Result parts
|
|
190
|
+
|
|
191
|
+
A part with `result: true` answers the card. It sends its answer to `PARENT` with `compose.answer(props, value)`: `props` carries the card's `scope` and the part's `node`, so the answer reaches the right call. Any value works: a string, an id, a form's values.
|
|
192
|
+
|
|
193
|
+
- `askCard` renders each card in its own scope, so two cards with the same part are two instances, with their own `local` state (a small form keeps what the user typed, per card).
|
|
194
|
+
- The answered part gets the prop `answer` (its value, to mark the pick), and every part of a card that isn't open gets `frozen`. The card is wrapped in a disabled `<fieldset>` too.
|
|
195
|
+
- The host hears result parts as well: `CHILD.select(PlanPicker)` in the host's intent gets `{ scope, node, value }`, to keep the pick in app state.
|
|
196
|
+
- A catalog for `ask` needs at least one result part ([SYG686](https://sygnal.js.org/reference/errors/#syg686)), and a result part rendered outside a card can't answer anything ([SYG160](https://sygnal.js.org/reference/errors/#syg160)).
|
|
197
|
+
|
|
198
|
+
## Two backends
|
|
199
|
+
|
|
200
|
+
| | `compose.llm()` (the default) | `compose.decisions({ model })` |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| Who composes the card | The chat model, in a second, structured-output request | A decision model, from the parts' descriptions |
|
|
203
|
+
| Sink | The chat's (`LLM`) | `HTTP` (`/api/decide` by default) |
|
|
204
|
+
| Parts it can use | All, free-text params too (a question and its options written for this need) | Prepared parts only: a part with required free text is LLM-only |
|
|
205
|
+
|
|
206
|
+
```js
|
|
207
|
+
chat({ ask: { catalog, backend: compose.decisions({ model: 'jev-latest' }) } })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Why two steps.** The chat model only says what it needs (`need`), and a separate request builds the card. Writing the card as the tool's arguments in one step needs a provider that enforces tool schemas: on Ollama, none of the measured calls with the layout as tool arguments was a valid card as emitted, against 5 of 5 with the two-step form.
|
|
211
|
+
|
|
212
|
+
**`direct: true`** is that one-step form, for providers that do enforce tool schemas: the `ask_user` input schema is the catalog's layout schema, and the card opens as soon as the call arrives, with no compose round trip. An input that is not a card closes the call with the error, so the model can try again. It is LLM-only.
|
|
213
|
+
|
|
214
|
+
**What the backend explains** stays with the card as `part.ui.decisions`, as a [composed page](./ai-compose.md) keeps it in its slice: `compose.llm()`'s plan (`asked_for`) and repairs, or `compose.decisions()`'s answers per step by question name (`use_<part>`, `rec_<part>__<n>`, `first_<a>_vs_<b>`, ...). It is persisted with the card, for an explain panel.
|
|
215
|
+
|
|
216
|
+
**Switching models.** `model` may be a function of the host's state, read per request: `chat({ model: (state) => state.chatModel, ask: { catalog } })` follows the app's model picker with one chat behavior, and the default card backend follows it too (`compose.llm({ model: (state) => state.chatModel })` for an explicit one). The card backend itself is fixed when `chat()` is called, since it decides the sink the cards go to: an app that lets the user pick it makes one host component per backend.
|
|
217
|
+
|
|
218
|
+
**Call `chat()` before `run()`.** The first `chat()` call links the sygnal/ai behaviors to the apps started after it (and to the page's first running app). A host component defined in a module the entry imports is in time; one created only later, in a second app, runs without tools ([SYG442](https://sygnal.js.org/reference/errors/#syg442)). An app that makes host components on demand calls `chat()` once at load.
|
|
219
|
+
|
|
220
|
+
## Client tools
|
|
221
|
+
|
|
222
|
+
`ask_user` is one case of a **client tool**: a tool the app answers itself, often with the user's help, instead of an `agent` action that runs on its own. `clientTools` declares your own:
|
|
223
|
+
|
|
224
|
+
```jsx
|
|
225
|
+
import { z } from 'zod'
|
|
226
|
+
import { chat, openToolCalls } from 'sygnal/ai'
|
|
227
|
+
|
|
228
|
+
function confirmations(a) {
|
|
229
|
+
return openToolCalls(a).filter((p) => p.type === 'tool-confirm').map((p) => (
|
|
230
|
+
<p className="confirm">
|
|
231
|
+
{p.input.question}
|
|
232
|
+
<button type="button" className="yes" data={{ id: p.toolCallId }}>Yes</button>
|
|
233
|
+
<button type="button" className="no" data={{ id: p.toolCallId }}>No</button>
|
|
234
|
+
</p>
|
|
235
|
+
))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function Booking({ state }) {
|
|
239
|
+
const a = state.assistant
|
|
240
|
+
return (
|
|
241
|
+
<main>
|
|
242
|
+
{confirmations(a)}
|
|
243
|
+
<form className="ask"><input className="prompt" aria-label="Message" value={a.prompt} /></form>
|
|
244
|
+
</main>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
Booking.uses = {
|
|
249
|
+
assistant: chat({
|
|
250
|
+
form: '.ask', prompt: '.prompt',
|
|
251
|
+
clientTools: {
|
|
252
|
+
confirm: { description: 'Ask the user a yes / no question', inputSchema: z.object({ question: z.string().min(1) }) },
|
|
253
|
+
},
|
|
254
|
+
}),
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
Booking.intent = ({ DOM }) => ({ YES: DOM.click('.yes').data('id'), NO: DOM.click('.no').data('id') })
|
|
258
|
+
|
|
259
|
+
Booking.model = {
|
|
260
|
+
YES: { EFFECT: (state, id, next) => next('assistant.TOOL_OUTPUT', { toolCallId: id, output: { confirmed: true } }) },
|
|
261
|
+
NO: { EFFECT: (state, id, next) => next('assistant.TOOL_OUTPUT', { toolCallId: id, output: { confirmed: false } }) },
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- A client tool is `{ description, inputSchema }`: a JSON Schema, or a Standard Schema (zod, valibot) with a JSON Schema form. A Standard Schema input is validated first; an invalid one closes the call with the issues, so the model can repair it.
|
|
266
|
+
- Its calls stay open until `'assistant.TOOL_OUTPUT'` answers them: `{ toolCallId, output }`, or `{ toolCallId, errorText }` for an error. A TOOL_OUTPUT for a call that isn't open does nothing ([SYG685](https://sygnal.js.org/reference/errors/#syg685)).
|
|
267
|
+
- `openToolCalls(state.assistant)` lists the calls of the last message that wait for an answer (each a `tool-<name>` part with its `input`).
|
|
268
|
+
- A host entry `'assistant.TOOL_CALL'` runs once when each call opens, with `{ id, name, input }` (the input after validation).
|
|
269
|
+
- STOP and SEND close open calls as they close cards. A client tool with the name of an `agent` tool replaces it ([SYG684](https://sygnal.js.org/reference/errors/#syg684)).
|
|
270
|
+
|
|
271
|
+
These are the AI SDK's `addToolOutput` and `onToolCall`, on the `chat` behavior.
|
|
272
|
+
|
|
273
|
+
## Persistence
|
|
274
|
+
|
|
275
|
+
Cards live on the messages (`part.ui`), so [`persist()`](./persistence.md) of the chat slice keeps them: an answered card comes back frozen with its pick, an open card stays answerable, and a card that was composing composes again. A chat saved while a reply streamed comes back `'ready'`, so Send works after a reload.
|
|
276
|
+
|
|
277
|
+
```js
|
|
278
|
+
import { persist } from 'sygnal'
|
|
279
|
+
|
|
280
|
+
PlanChat.persist = persist({ key: 'plan-chat', pick: ['assistant'] })
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
On the wire, the `openResponses()` and `chatCompletions()` transports send only a call's input and output, never `ui`. `uiMessageStream()` sends the parts as they are: a server route that stores conversations must store `body.messages` as received to keep the cards ([Storing conversations](./ai-chat.md#storing-conversations)).
|
|
284
|
+
|
|
285
|
+
## Testing
|
|
286
|
+
|
|
287
|
+
`composeWith(t, catalog, tree)` answers a card's pending composition too, whichever backend it uses. Here `makePlanChat()` returns the demo's component without its `BOOTSTRAP` entry:
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
import { it, expect } from 'vitest'
|
|
291
|
+
import { renderComponent } from 'sygnal'
|
|
292
|
+
import { composeWith } from 'sygnal/ai'
|
|
293
|
+
import { catalog } from './cards.jsx'
|
|
294
|
+
import { makePlanChat } from './PlanChat.jsx'
|
|
295
|
+
|
|
296
|
+
it('asks with a card, and the pick goes back to the model', async () => {
|
|
297
|
+
const t = renderComponent(makePlanChat())
|
|
298
|
+
t.simulateAction('assistant.SEND', 'Which plan should I take?')
|
|
299
|
+
await t.stream('LLM', [{ toolCall: { id: 'c1', name: 'ask_user', input: { need: 'Pick the plan you want' } } }])
|
|
300
|
+
await composeWith(t, catalog, [{ part: 'plans' }])
|
|
301
|
+
expect(t.state.assistant.status).toBe('ready')
|
|
302
|
+
|
|
303
|
+
t.simulateEvent('.plan[data-id="team"]', 'click')
|
|
304
|
+
await t.waitForState((s) => s.assistant.status === 'submitted')
|
|
305
|
+
const call = t.requests('LLM').at(-1).messages.at(-1).parts.find((p) => p.type === 'tool-ask_user')
|
|
306
|
+
expect(call.output).toEqual({ part: 'plans', answer: 'team' })
|
|
307
|
+
t.dispose()
|
|
308
|
+
})
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
- `t.stream('LLM', chunks)` answers the chat's request; a `{ toolCall }` chunk is a call.
|
|
312
|
+
- To test a failed card, fail its request: `t.fail('LLM', new Error('overloaded'), 'assistant.CARD_FAILED')`.
|
|
313
|
+
- A client tool: `t.simulateAction('assistant.TOOL_OUTPUT', { toolCallId: 'c1', output: { confirmed: true } })`.
|
|
314
|
+
|
|
315
|
+
## Related
|
|
316
|
+
|
|
317
|
+
- [Composing UI with a Model](./ai-compose.md): catalogs, parts and the two backends
|
|
318
|
+
- [AI Chat](./ai-chat.md): the chat driver, transports and tools
|
|
319
|
+
- [Agents](./agent.md): the `chat` behavior and its call rules
|
package/dist/guide/ai-chat.md
CHANGED
|
@@ -416,6 +416,8 @@ Shopping.model = {
|
|
|
416
416
|
|
|
417
417
|
This is the low-level form. For an in-app assistant that operates your components, the [`chat` behavior](./agent.md#an-in-app-assistant-chat) (`uses = { assistant: chat({ … }) }`, from `sygnal/ai`) runs this loop for you: it builds the tools from the components' [`agent` declarations](./agent.md#the-agent-static), validates every call against its schema, asks the user before consequential ones, and stops after `maxSteps`. [Agents](./agent.md) documents it.
|
|
418
418
|
|
|
419
|
+
Some calls the app answers itself, often with the user: a confirmation, a date picker, or a **card** composed from prepared parts that the assistant asks the user with (`chat({ ask })`). Those are client tools of the `chat` behavior, answered with `TOOL_OUTPUT`: [Cards in a Chat](./ai-cards.md).
|
|
420
|
+
|
|
419
421
|
## Testing
|
|
420
422
|
|
|
421
423
|
`renderComponent` serves the `LLM` sink with a fake: the real chat driver over an in-memory stream that the test writes to. No transport, no network, no timers. Each request stays pending until the test answers it:
|
|
@@ -733,5 +735,7 @@ await page.route('**/v1/responses', (route) => route.fulfill({
|
|
|
733
735
|
|
|
734
736
|
- [Agents](./agent.md): the `chat` behavior, an assistant that operates your components through their `agent` declarations
|
|
735
737
|
- [AI Decisions](./ai-decisions.md): typed answers from decision models, and escalating unsure ones to chat
|
|
738
|
+
- [Composing UI with a Model](./ai-compose.md): an LLM builds a page from your catalog of parts with `compose.llm()`, a structured-output request
|
|
739
|
+
- [Cards in a Chat](./ai-cards.md): client tools, and an assistant that asks the user with a composed card
|
|
736
740
|
- [Recipes](./recipes/overview.md#ai): a support inbox, structured output into a form, on-device summaries
|
|
737
741
|
- [HTTP](./http.md): the fetch driver, whose reply actions the chat driver shares
|