sygnal 6.1.1 → 6.2.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 +39 -0
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs.map +1 -1
- package/dist/diagnostics.cjs.js +10 -3
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +10 -3
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/guide/adapters.md +2 -1
- package/dist/guide/agent.md +112 -6
- package/dist/guide/ai-chat.md +60 -16
- package/dist/guide/ai-decisions.md +1 -1
- package/dist/guide/behaviors.md +1 -1
- package/dist/guide/error-boundaries.md +2 -1
- package/dist/guide/forms.md +1 -1
- package/dist/guide/http.md +4 -4
- package/dist/guide/mcp-apps.md +4 -2
- package/dist/guide/persistence.md +1 -1
- package/dist/guide/recipes/ai-form-fill.md +1 -1
- package/dist/guide/recipes/ai-summarize.md +1 -1
- package/dist/guide/recipes/ai-support-inbox.md +1 -1
- package/dist/guide/resources.md +11 -10
- package/dist/guide/ui/combobox.md +1 -1
- package/dist/guide/undo.md +1 -1
- package/dist/guide/web-components.md +1 -0
- package/dist/guide/webmcp.md +1 -2
- package/dist/index.cjs.js +155 -31
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +42 -6
- package/dist/index.esm.js +155 -31
- 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/llms.txt +2 -2
- package/package.json +1 -1
- package/src/ai.d.ts +41 -5
- package/src/extra/ai/agent/index.ts +111 -26
- package/src/extra/ai/chat/behavior.ts +14 -6
- package/src/extra/ai/webmcp.ts +5 -3
- package/src/extra/diagnostics/codes.ts +10 -3
- package/src/index.d.ts +1 -1
package/dist/guide/adapters.md
CHANGED
|
@@ -24,7 +24,8 @@ npm install react react-dom
|
|
|
24
24
|
The React component here is a small one of our own, written with React's `createElement` (the JSX on these pages is Sygnal's); a component from npm is wrapped the same way:
|
|
25
25
|
|
|
26
26
|
```js live-file=./Rating.js
|
|
27
|
-
// Rating.js
|
|
27
|
+
// Rating.js
|
|
28
|
+
// a React component
|
|
28
29
|
import { createElement as h } from 'react'
|
|
29
30
|
|
|
30
31
|
export function Rating({ value, max, onChange }) {
|
package/dist/guide/agent.md
CHANGED
|
@@ -69,6 +69,7 @@ TodoApp.agent = {
|
|
|
69
69
|
| `label(state)` | A [Collection item](#collection-items)'s label for the model |
|
|
70
70
|
| `untrusted` | `read` returns text users typed (see [Security](#security)). Unset, Sygnal infers it from the strings in the projection |
|
|
71
71
|
| `actions` | The only actions agents may run, by action name |
|
|
72
|
+
| `queries` | Read-only tools that take an input: data too large for `read`, or answers computed from the state ([Queries](#queries)) |
|
|
72
73
|
|
|
73
74
|
Each entry of `actions`:
|
|
74
75
|
|
|
@@ -79,6 +80,8 @@ Each entry of `actions`:
|
|
|
79
80
|
| `consequential` | Ask the user before running it |
|
|
80
81
|
| `when(state)` | The tool is offered only while this is true |
|
|
81
82
|
| `idempotent` | A call that changes nothing is a success (`{ ok: true, unchanged: true }`), not an error |
|
|
83
|
+
| `until` | The call answers once the action's result is in state: a resource name, or a function of the state ([Waiting for a result](#waiting-for-a-result-until)) |
|
|
84
|
+
| `timeout` | With `until`: how long to wait, in ms (default 10,000) |
|
|
82
85
|
|
|
83
86
|
### Input schemas
|
|
84
87
|
|
|
@@ -109,7 +112,7 @@ In TypeScript, on a component typed with its actions ([TypeScript](https://sygna
|
|
|
109
112
|
|
|
110
113
|
## What agents see
|
|
111
114
|
|
|
112
|
-
**Tools.** One per listed action, plus `<name>_read` when the declaration has a `read`. The todo app above offers:
|
|
115
|
+
**Tools.** One per listed action and [query](#queries), plus `<name>_read` when the declaration has a `read`. The todo app above offers:
|
|
113
116
|
|
|
114
117
|
| Tool | Input | Hints |
|
|
115
118
|
|---|---|---|
|
|
@@ -131,7 +134,7 @@ Every call, from any kind of agent, goes through the same steps:
|
|
|
131
134
|
2. **Availability.** A tool whose `when` is false now gets `{ ok: false, error: 'todos_clear_done is not available now' }`.
|
|
132
135
|
3. **Confirmation.** A `consequential` action waits for the user (each consumer asks in its own way). A declined call is `{ ok: false, error: 'the user declined' }`. The target and `when` are checked again after the answer.
|
|
133
136
|
4. **Dispatch.** The action runs with `cause: 'agent'`, through the same queue, reducers and render as a click. It shows in `t.actions`, the DevTools action log and the dev MCP endpoint's `recent_actions` with that cause.
|
|
134
|
-
5. **The result**, after the render: `{ ok: true, state }`, where `state` is the nearest `read` projection (the component's, or for an item, its own or its list's).
|
|
137
|
+
5. **The result**, after the render: `{ ok: true, state }`, where `state` is the nearest `read` projection (the component's, or for an item, its own or its list's). An action with [`until`](#waiting-for-a-result-until) answers later, once its result is in state.
|
|
135
138
|
|
|
136
139
|
**No-op detection.** A call succeeds when one of the action's handlers had an effect: a reducer changed the state, a driver sink got a value, or an `EFFECT` ran. A call that changed nothing is reported as a failure, not a false success. Here, `SET_FILTER` to the filter already set, on an action without `idempotent`:
|
|
137
140
|
|
|
@@ -158,6 +161,107 @@ A refusal is reported even on an `idempotent` action.
|
|
|
158
161
|
|
|
159
162
|
**One at a time.** Calls run serially, in the order the model made them, so each one sees the previous one's result. A model that sends three calls at once gets three results computed in sequence.
|
|
160
163
|
|
|
164
|
+
## Waiting for a result: `until`
|
|
165
|
+
|
|
166
|
+
Some actions only start something: a search sets the query, and a [`resources`](./resources.md) entry fetches the results. Right after the action, the results aren't there yet, so the call would answer with `status: 'loading'`, and the model would have to guess or ask again. With `until`, the call answers once the result is in state:
|
|
167
|
+
|
|
168
|
+
```jsx
|
|
169
|
+
// Shop.jsx
|
|
170
|
+
import { z } from 'zod'
|
|
171
|
+
|
|
172
|
+
export default function Shop({ state }) {
|
|
173
|
+
const { status, data } = state.search
|
|
174
|
+
return (
|
|
175
|
+
<section>
|
|
176
|
+
{status === 'loading' && <p>Searching…</p>}
|
|
177
|
+
<ul className="results">{(data ?? []).map((p) => <li key={p.id}>{p.name}</li>)}</ul>
|
|
178
|
+
</section>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
Shop.initialState = { query: '' }
|
|
183
|
+
|
|
184
|
+
Shop.resources = { search: (state) => state.query && { url: '/api/search', query: { q: state.query } } }
|
|
185
|
+
|
|
186
|
+
Shop.model = { SEARCH: (state, query) => ({ ...state, query }) }
|
|
187
|
+
|
|
188
|
+
Shop.agent = {
|
|
189
|
+
name: 'shop',
|
|
190
|
+
description: 'The product search the user sees',
|
|
191
|
+
read: (state) => ({ query: state.query, status: state.search.status, results: (state.search.data ?? []).map(({ id, name }) => ({ id, name })) }),
|
|
192
|
+
actions: {
|
|
193
|
+
SEARCH: { description: 'Search the catalog; the results show on the page', input: z.string().min(1), until: 'search' },
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`shop_search({ value: 'red shoes' })` now answers with the results the user sees, `{ ok: true, state: { query: 'red shoes', status: 'success', results: [...] } }`, or with `status: 'error'` when the request failed.
|
|
199
|
+
|
|
200
|
+
- **`until: 'search'`** names one of the component's resources. The call answers once that resource is neither loading nor refreshing, so a [`keepPrevious`](./resources.md) request or a refetch, which keep the status and set `refreshing`, are waited for too. A name the component's `resources` doesn't have is [SYG248](https://sygnal.js.org/reference/errors/#syg248): the call answers at once.
|
|
201
|
+
- **`until: (state) => boolean`** is for a result a reply action writes (a driver request with an `ok` action). The action's own reducer must mark it pending, `status: 'loading'`, so the test is false right after the action: `until: (state) => state.status !== 'loading'`. A throwing `until` ends the wait ([SYG241](https://sygnal.js.org/reference/errors/#syg241)).
|
|
202
|
+
- **The test runs after the action's render and after every render that changed something.** A result already in state answers at once.
|
|
203
|
+
- **No result within `timeout`** (default 10 s): `{ ok: true, pending: true, note, state }`. The note tells the model the result will be in the app state later, where the `chat` behavior's next request shows it.
|
|
204
|
+
- **Calls run one at a time**, so a waiting call delays the model's next one. The [`chat` behavior](#an-in-app-assistant-chat) ends the wait when the user presses Stop or sends a new message (the call answers `pending`), and `agentTools()` has `cancel()` for other consumers.
|
|
205
|
+
- An item that removes itself while its call waits answers `{ ok: true, removed: true, state }`.
|
|
206
|
+
|
|
207
|
+
The side effect stays where it was: the request goes through `resources` or a driver, and a test answers it with the fetch fake, `await t.respond('HTTP', [...])`. `t.callTool()` waits for it like any agent.
|
|
208
|
+
|
|
209
|
+
## Queries
|
|
210
|
+
|
|
211
|
+
`read` goes to the model whole, with every request. That suits a summary, not a table of 5,000 invoices: sending them all costs tokens on every turn, and a model reads a long list badly anyway. A query is a read-only tool that takes an input and answers from the state: the model asks for the overdue invoices of one customer and gets those.
|
|
212
|
+
|
|
213
|
+
```jsx
|
|
214
|
+
// Invoices.jsx
|
|
215
|
+
import { z } from 'zod'
|
|
216
|
+
|
|
217
|
+
export default function Invoices({ state }) {
|
|
218
|
+
return <p className="count">{state.invoices.length} invoices</p>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
Invoices.initialState = { invoices: [] }
|
|
222
|
+
|
|
223
|
+
const Filter = z.object({
|
|
224
|
+
customer: z.string().optional().describe('The customer name'),
|
|
225
|
+
overdue: z.boolean().optional(),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
Invoices.agent = {
|
|
229
|
+
name: 'invoices',
|
|
230
|
+
description: 'The invoices',
|
|
231
|
+
untrusted: true,
|
|
232
|
+
read: (state) => ({ count: state.invoices.length }), // the summary, on every request
|
|
233
|
+
queries: {
|
|
234
|
+
// invoices_find({ customer?, overdue? })
|
|
235
|
+
find: {
|
|
236
|
+
description: 'The invoices matching a filter, at most 50',
|
|
237
|
+
input: Filter,
|
|
238
|
+
run: (state, { customer, overdue }) => state.invoices
|
|
239
|
+
.filter((i) => (customer === undefined || i.customer === customer) && (overdue === undefined || i.overdue === overdue))
|
|
240
|
+
.slice(0, 50),
|
|
241
|
+
},
|
|
242
|
+
// invoices_total()
|
|
243
|
+
total: { description: 'The sum of all invoices', run: (state) => state.invoices.reduce((sum, i) => sum + i.total, 0) },
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| Key | What it does |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `description` | What the query returns, for the model |
|
|
251
|
+
| `input` | The schema of its input, as for an action ([Input schemas](#input-schemas)). Without one the query takes no arguments |
|
|
252
|
+
| `when(state)` | The tool is offered only while this is true |
|
|
253
|
+
| `run(state, input)` | The result: a pure, synchronous function of the component's state now and the validated input |
|
|
254
|
+
|
|
255
|
+
- **A query is a tool named `<name>_<query>`**, marked read-only. It is offered next to the actions, to every kind of agent (the command bar, which picks one action, leaves read-only tools out).
|
|
256
|
+
- **A call validates the input and checks `when`**, as for an action, then returns `{ ok: true, result }` with what `run` returned. Nothing is dispatched, nothing is confirmed, and nothing renders: a query can't change the app.
|
|
257
|
+
- **It must be synchronous.** A `run` that returns a promise (an `async` function, a `fetch`) fails the call and is [SYG246](https://sygnal.js.org/reference/errors/#syg246), as is a query without `run`. Data that has to be loaded belongs to an action: its model entry asks for it (a [`resources`](./resources.md) entry or a driver request), and [`until`](#waiting-for-a-result-until) makes the call answer with the result.
|
|
258
|
+
- **A throw** fails the call with its message and is [SYG241](https://sygnal.js.org/reference/errors/#syg241).
|
|
259
|
+
- **Actions and queries share the tool names.** A query whose snake-case name is an action's (`find` next to `FIND`) is [SYG247](https://sygnal.js.org/reference/errors/#syg247), and only the action is offered.
|
|
260
|
+
- **On a Collection item component**, a query gets the same key parameter as the item's actions (`contact_notes({ id, word })`), and answers from that item's state.
|
|
261
|
+
- **Results are data.** When they hold text users typed, set `untrusted: true` on the declaration: WebMCP then marks the queries with `untrustedContentHint` too ([Security](#security)). Keep results small (filter, then cut): WebMCP cuts a result over 1,500 characters of JSON ([SYG242](https://sygnal.js.org/reference/errors/#syg242)).
|
|
262
|
+
|
|
263
|
+
In TypeScript, `run`'s `state` is typed from the component; type the input yourself, `run: (state, input: z.infer<typeof Filter>) => …`, since it isn't inferred from `input`.
|
|
264
|
+
|
|
161
265
|
## Collection items
|
|
162
266
|
|
|
163
267
|
An item component's actions don't become one tool per item. They become one tool per action, with a parameter that names the item: an enum of the live keys (the items' `id`s), routed to the item with that key.
|
|
@@ -259,7 +363,7 @@ export default {
|
|
|
259
363
|
}
|
|
260
364
|
```
|
|
261
365
|
|
|
262
|
-
```jsx live live-height=380
|
|
366
|
+
```jsx live live-height=380 title="TodoApp.jsx"
|
|
263
367
|
import { z } from 'zod'
|
|
264
368
|
import { Collection } from 'sygnal'
|
|
265
369
|
import { chat, messageText } from 'sygnal/ai'
|
|
@@ -361,7 +465,7 @@ In production the provider key belongs on your server: `makeChatDriver({ transpo
|
|
|
361
465
|
| `prompt` | | The prompt field (its input sets `prompt`) |
|
|
362
466
|
| `stop`, `approve`, `deny`, `regenerate` | | Buttons: their clicks dispatch `STOP`, `APPROVE`, `DENY`, `REGENERATE` |
|
|
363
467
|
| `instructions` | | System instructions: your app's own text |
|
|
364
|
-
| `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none, `[Comp, ...]` for only these (inside the host) |
|
|
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) |
|
|
365
469
|
| `maxSteps` | `8` | The most requests per turn; tool calls in a reply past it are not run |
|
|
366
470
|
| `sink` | `'LLM'` | The chat driver's name |
|
|
367
471
|
| `model`, `transportOptions` | | A model id, and extra request keys for the transport |
|
|
@@ -377,7 +481,7 @@ The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
|
|
|
377
481
|
| `pending` | A consequential call waiting for `APPROVE` / `DENY`: `{ tool, component, action, description, input, key?, label? }`, else `null` |
|
|
378
482
|
| `error` | The last failure's message, else `null` |
|
|
379
483
|
|
|
380
|
-
Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the text as data), `STOP` (aborts the reply, keeps the text so far, declines a waiting call), `REGENERATE` (sends the last user message again), `APPROVE`, `DENY`, and `DONE` (the turn is over: `{ message, text, finishReason, usage, steps }`; a host entry `'assistant.DONE'` runs after the behavior's).
|
|
484
|
+
Actions, as `'assistant.X'`: `SEND` (the form's submit; or dispatch it with the text as data), `STOP` (aborts the reply, keeps the text so far, declines a waiting call, and answers a call waiting on [`until`](#waiting-for-a-result-until) as pending), `REGENERATE` (sends the last user message again), `APPROVE`, `DENY`, and `DONE` (the turn is over: `{ message, text, finishReason, usage, steps }`; a host entry `'assistant.DONE'` runs after the behavior's).
|
|
381
485
|
|
|
382
486
|
**The markup belongs to the host's view.** A behavior listens to selectors in its host's own view, like every intent ([SYG104](https://sygnal.js.org/reference/errors/#syg104)): elements rendered inside a child component are out of its reach. So the panel is a plain function the host's view calls, `{assistantPanel(state.assistant)}`, not a `<AssistantPanel />` component. A function can still live in its own file and be shared.
|
|
383
487
|
|
|
@@ -414,7 +518,7 @@ export default {
|
|
|
414
518
|
}
|
|
415
519
|
```
|
|
416
520
|
|
|
417
|
-
```jsx live live-height=300
|
|
521
|
+
```jsx live live-height=300 title="Commands.jsx"
|
|
418
522
|
import { z } from 'zod'
|
|
419
523
|
import { Collection } from 'sygnal'
|
|
420
524
|
import { commandBar } from 'sygnal/ai'
|
|
@@ -581,6 +685,8 @@ it('is operable by agents', async () => {
|
|
|
581
685
|
```
|
|
582
686
|
|
|
583
687
|
- `t.tools()` lists the offered tools, plus any declared tool that can't be offered (SYG240) with its `error`.
|
|
688
|
+
- A [query](#queries) is called the same way: `await t.callTool('invoices_find', { customer: 'Acme' })` resolves with `{ ok: true, result }`.
|
|
689
|
+
- A call to an action with [`until`](#waiting-for-a-result-until) resolves once its result is in state: start it, answer the request (`await t.respond('HTTP', data)`), then await it. Under `vi.useFakeTimers()`, advancing past `timeout` gives `{ ok: true, pending: true }`.
|
|
584
690
|
- `t.callTool(name, args, { confirm })` resolves with the [call result](#the-call-rules) after the render. A consequential tool without `confirm` throws, so a test can't approve one by accident.
|
|
585
691
|
- `t.agentContext()` returns what the model sees: one entry per declaration with a `read`, keyed by the declaration's `name`, holding what its `read` returned. That is why the example above reads `todos.todos`: the declaration is named `todos`, and its `read` returns `{ todos, filter }`. An item declaration's entry is an array with one projection per live item, each with its `id` (`{ todo: [{ id: 1, done: false }] }` for `read: (s) => ({ done: s.done })`).
|
|
586
692
|
|
package/dist/guide/ai-chat.md
CHANGED
|
@@ -49,7 +49,7 @@ export default {
|
|
|
49
49
|
}
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
```jsx live live-height=260
|
|
52
|
+
```jsx live live-height=260 title="Chat.jsx"
|
|
53
53
|
import { ABORT } from 'sygnal'
|
|
54
54
|
import { messageText } from 'sygnal/ai'
|
|
55
55
|
|
|
@@ -71,7 +71,6 @@ function Chat({ state }) {
|
|
|
71
71
|
)
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const userSays = (text) => ({ role: 'user', parts: [{ type: 'text', text }] })
|
|
75
74
|
const ask = (messages) => ({ messages, key: 'reply', delta: 'DELTA', ok: 'DONE', error: 'FAILED' })
|
|
76
75
|
|
|
77
76
|
Chat.initialState = { messages: [], prompt: '', draft: '', status: 'ready', error: null }
|
|
@@ -85,16 +84,16 @@ Chat.intent = ({ DOM }) => ({
|
|
|
85
84
|
Chat.model = {
|
|
86
85
|
// the first question, as soon as the demo shows
|
|
87
86
|
BOOTSTRAP: {
|
|
88
|
-
STATE: (state) => ({ ...state, messages: [
|
|
89
|
-
LLM: () => ask([
|
|
87
|
+
STATE: (state) => ({ ...state, messages: [{ role: 'user', content: 'How does a reply stream?' }], status: 'streaming' }),
|
|
88
|
+
LLM: () => ask([{ role: 'user', content: 'How does a reply stream?' }]),
|
|
90
89
|
},
|
|
91
90
|
TYPE: (state, prompt) => ({ ...state, prompt }),
|
|
92
91
|
SEND: {
|
|
93
92
|
STATE: (state) => (state.prompt.trim()
|
|
94
|
-
? { ...state, messages: [...state.messages,
|
|
93
|
+
? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
|
|
95
94
|
: ABORT),
|
|
96
95
|
// sinks see the state before the action: the conversation plus the prompt being sent
|
|
97
|
-
LLM: (state) => (state.prompt.trim() ? ask([...state.messages,
|
|
96
|
+
LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
|
|
98
97
|
},
|
|
99
98
|
STOP: {
|
|
100
99
|
// keep the text that arrived; nothing arrives after the abort
|
|
@@ -114,12 +113,57 @@ Chat.model = {
|
|
|
114
113
|
|
|
115
114
|
What the component does:
|
|
116
115
|
|
|
117
|
-
- **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn.
|
|
116
|
+
- **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn. A message you write yourself can be `{ role: 'user', content: text }`: the driver, `messageText()` and every transport accept that short form.
|
|
118
117
|
- **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request. Both see the state from before the action ([Model](https://sygnal.js.org/guide/model/#sinks-see-the-state-before-the-action)), so the `LLM` sink builds the message list with the prompt itself.
|
|
119
118
|
- **`DELTA` shows the reply so far.** Its `text` is all the text received, not just the new piece, so the reducer only replaces `draft`.
|
|
120
119
|
- **`DONE` gets the finished message** and appends it to the conversation as is.
|
|
121
120
|
- **`STOP` aborts the request** by its key. Nothing arrives after an abort, not even `FAILED`.
|
|
122
121
|
|
|
122
|
+
### The same chat with the `chat` behavior
|
|
123
|
+
|
|
124
|
+
The component above shows how the driver works. For a conversation in your app, use the `chat` behavior from `sygnal/ai` instead: it holds the conversation, sends the requests, and handles streaming, Stop, failures and Retry. With `agent: false` it sends no tools, so it is a plain chat. Its markup stays in your view, and its options name the elements to listen to.
|
|
125
|
+
|
|
126
|
+
This demo asks the same first question. Press **Stop** while it streams to keep the partial reply.
|
|
127
|
+
|
|
128
|
+
```jsx live live-height=260
|
|
129
|
+
import { chat, messageText } from 'sygnal/ai'
|
|
130
|
+
|
|
131
|
+
function Chat({ state }) {
|
|
132
|
+
const c = state.chat
|
|
133
|
+
const busy = c.status === 'submitted' || c.status === 'streaming'
|
|
134
|
+
return (
|
|
135
|
+
<section className="chat">
|
|
136
|
+
<ol className="messages" aria-live="polite">
|
|
137
|
+
{c.messages.map((m) => <li key={m.id} className={m.role}><b>{m.role}:</b> {messageText(m)}</li>)}
|
|
138
|
+
{c.draft && <li className="assistant streaming"><b>assistant:</b> {c.draft}</li>}
|
|
139
|
+
</ol>
|
|
140
|
+
{c.error && <p role="alert">{c.error} <button type="button" className="retry">Retry</button></p>}
|
|
141
|
+
<form className="ask">
|
|
142
|
+
<label>Message <input className="prompt" value={c.prompt} /></label>
|
|
143
|
+
<button type="submit" disabled={busy}>Send</button>
|
|
144
|
+
<button type="button" className="stop" hidden={!busy}>Stop</button>
|
|
145
|
+
</form>
|
|
146
|
+
</section>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
Chat.uses = {
|
|
151
|
+
chat: chat({ agent: false, form: '.ask', prompt: '.prompt', stop: '.stop', regenerate: '.retry' }),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Chat.model = {
|
|
155
|
+
// the first question, as soon as the demo shows
|
|
156
|
+
BOOTSTRAP: { EFFECT: (state, data, next) => next('chat.SEND', 'How does a reply stream?') },
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- **`state.chat` is the behavior's slice**: `messages`, `prompt`, `draft`, `status` (`'ready'`, `'submitted'`, `'streaming'` or `'error'`) and `error`.
|
|
161
|
+
- **Retry doesn't repeat the question.** `regenerate` sends the conversation again up to the last user message, without a second copy of it, and drops the failed partial reply.
|
|
162
|
+
- **Every message has an `id`**, the user's too, so `key={m.id}` works and a server can store the conversation by message id.
|
|
163
|
+
- **The host can drive it.** `next('chat.SEND', text)` sends a message, and a `'chat.DONE'` entry in the host's model runs when a reply is finished.
|
|
164
|
+
|
|
165
|
+
Use the driver directly for one-off requests, like a summary or [structured output](#structured-output), or when you need your own state shape. The [`chat` behavior](./agent.md#options-and-state) documents its options, state and actions. Without `agent: false` it also runs your components' [agent tools](./agent.md).
|
|
166
|
+
|
|
123
167
|
## Requests
|
|
124
168
|
|
|
125
169
|
A request is an object sent to the `LLM` sink. The driver passes it to the transport as is, so a transport's own options can go in it too.
|
|
@@ -197,7 +241,7 @@ export default {
|
|
|
197
241
|
}
|
|
198
242
|
```
|
|
199
243
|
|
|
200
|
-
```jsx live live-height=200
|
|
244
|
+
```jsx live live-height=200 title="QuickAdd.jsx"
|
|
201
245
|
import { z } from 'zod'
|
|
202
246
|
import { ABORT } from 'sygnal'
|
|
203
247
|
|
|
@@ -296,7 +340,7 @@ export default {
|
|
|
296
340
|
}
|
|
297
341
|
```
|
|
298
342
|
|
|
299
|
-
```jsx live live-height=260
|
|
343
|
+
```jsx live live-height=260 title="Shopping.jsx"
|
|
300
344
|
import { ABORT } from 'sygnal'
|
|
301
345
|
import { messageText, withToolResults } from 'sygnal/ai'
|
|
302
346
|
|
|
@@ -308,7 +352,6 @@ const tools = {
|
|
|
308
352
|
}
|
|
309
353
|
|
|
310
354
|
const said = (m) => messageText(m) || m.parts.filter((p) => p.type.startsWith('tool-')).map((p) => `(called ${p.type.slice(5)})`).join(' ')
|
|
311
|
-
const userSays = (text) => ({ role: 'user', parts: [{ type: 'text', text }] })
|
|
312
355
|
const ask = (messages) => ({ messages, tools, key: 'reply', delta: 'DELTA', tool: 'TOOL', ok: 'DONE', error: 'FAILED' })
|
|
313
356
|
|
|
314
357
|
// the reply with each add_item call answered: the output goes into the call's tool-add_item part
|
|
@@ -343,9 +386,9 @@ Shopping.model = {
|
|
|
343
386
|
TYPE: (state, prompt) => ({ ...state, prompt }),
|
|
344
387
|
SEND: {
|
|
345
388
|
STATE: (state) => (state.prompt.trim()
|
|
346
|
-
? { ...state, messages: [...state.messages,
|
|
389
|
+
? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
|
|
347
390
|
: ABORT),
|
|
348
|
-
LLM: (state) => (state.prompt.trim() ? ask([...state.messages,
|
|
391
|
+
LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
|
|
349
392
|
},
|
|
350
393
|
DELTA: (state, { text }) => ({ ...state, draft: text }),
|
|
351
394
|
// runs the call as it arrives; the model's input is unchecked, so check it
|
|
@@ -464,8 +507,8 @@ The transports refuse the other mistake for you. In a browser, a request with an
|
|
|
464
507
|
|
|
465
508
|
The component never changes. Only the transport in `main.js` does: Ollama while you develop, your AI SDK route in production.
|
|
466
509
|
|
|
467
|
-
```js
|
|
468
|
-
//
|
|
510
|
+
```js title="main.js (development)"
|
|
511
|
+
// a local model, through Ollama
|
|
469
512
|
import { run } from 'sygnal'
|
|
470
513
|
import { makeChatDriver, openResponses } from 'sygnal/ai'
|
|
471
514
|
import Chat from './Chat.jsx'
|
|
@@ -475,8 +518,8 @@ run(Chat, {
|
|
|
475
518
|
})
|
|
476
519
|
```
|
|
477
520
|
|
|
478
|
-
```js
|
|
479
|
-
//
|
|
521
|
+
```js title="main.js (production)"
|
|
522
|
+
// the AI SDK route above
|
|
480
523
|
import { run } from 'sygnal'
|
|
481
524
|
import { makeChatDriver, uiMessageStream } from 'sygnal/ai'
|
|
482
525
|
import Chat from './Chat.jsx'
|
|
@@ -489,6 +532,7 @@ run(Chat, {
|
|
|
489
532
|
Or pick one at build time, so the development transport isn't in the production bundle:
|
|
490
533
|
|
|
491
534
|
```js
|
|
535
|
+
// main.js
|
|
492
536
|
import { run } from 'sygnal'
|
|
493
537
|
import { makeChatDriver, openResponses, uiMessageStream } from 'sygnal/ai'
|
|
494
538
|
import Chat from './Chat.jsx'
|
package/dist/guide/behaviors.md
CHANGED
|
@@ -123,7 +123,8 @@ Compared with React, which unmounts the whole tree when an error isn't caught by
|
|
|
123
123
|
In a [Vike](https://sygnal.js.org/integration/vike/) app, the hook is the `sygnalOnError` config: define it in `+sygnalOnError.js` (or in `+config.js`). It is passed to `renderToString` on the server and to `run()` in the browser. (Vike's own `onError` is a different hook: server-only, called with `(error, pageContext)` for any error during rendering.)
|
|
124
124
|
|
|
125
125
|
```js
|
|
126
|
-
// pages/+sygnalOnError.js
|
|
126
|
+
// pages/+sygnalOnError.js
|
|
127
|
+
// used by renderToString on the server and by run() in the browser
|
|
127
128
|
export default function sygnalOnError(error, { componentName, action, phase }) {
|
|
128
129
|
console.error('[' + phase + ']', componentName, action, error)
|
|
129
130
|
}
|
package/dist/guide/forms.md
CHANGED
|
@@ -21,7 +21,7 @@ export default {
|
|
|
21
21
|
|
|
22
22
|
Validation, a list of rows the user adds and removes, the submit to the server, the pending button, and the server's errors:
|
|
23
23
|
|
|
24
|
-
```jsx live
|
|
24
|
+
```jsx live title="Signup.jsx"
|
|
25
25
|
import { form } from 'sygnal'
|
|
26
26
|
import { z } from 'zod'
|
|
27
27
|
|
package/dist/guide/http.md
CHANGED
|
@@ -24,7 +24,7 @@ export default {
|
|
|
24
24
|
}
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
```jsx live
|
|
27
|
+
```jsx live title="Quote.jsx"
|
|
28
28
|
import { ABORT } from 'sygnal'
|
|
29
29
|
|
|
30
30
|
function Quote({ state }) {
|
|
@@ -84,7 +84,7 @@ export default {
|
|
|
84
84
|
}
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
```jsx live
|
|
87
|
+
```jsx live title="Search.jsx"
|
|
88
88
|
import { ABORT, debounce } from 'sygnal'
|
|
89
89
|
|
|
90
90
|
function Search({ state }) {
|
|
@@ -219,7 +219,7 @@ export default {
|
|
|
219
219
|
}
|
|
220
220
|
```
|
|
221
221
|
|
|
222
|
-
```jsx live
|
|
222
|
+
```jsx live title="Todos.jsx"
|
|
223
223
|
function Todos({ state }) {
|
|
224
224
|
return (
|
|
225
225
|
<div>
|
|
@@ -261,7 +261,7 @@ export default {
|
|
|
261
261
|
}
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
-
```jsx live
|
|
264
|
+
```jsx live title="Profile.jsx"
|
|
265
265
|
import { ABORT } from 'sygnal'
|
|
266
266
|
|
|
267
267
|
function Profile({ state }) {
|
package/dist/guide/mcp-apps.md
CHANGED
|
@@ -8,7 +8,8 @@ In Sygnal that bridge is a driver. The view is an ordinary component.
|
|
|
8
8
|
## The view
|
|
9
9
|
|
|
10
10
|
```jsx
|
|
11
|
-
// App.jsx
|
|
11
|
+
// App.jsx
|
|
12
|
+
// the view of the get_forecast tool
|
|
12
13
|
function App({ state }) {
|
|
13
14
|
return (
|
|
14
15
|
<main>
|
|
@@ -168,7 +169,8 @@ run(App, { MCP: makeMcpAppDriver({ tools: agentTools }) })
|
|
|
168
169
|
```
|
|
169
170
|
|
|
170
171
|
```jsx
|
|
171
|
-
// App.jsx
|
|
172
|
+
// App.jsx
|
|
173
|
+
// what the model may do in the view
|
|
172
174
|
import { z } from 'zod'
|
|
173
175
|
|
|
174
176
|
App.model = {
|
|
@@ -214,7 +214,7 @@ The object you pass is used as the storage, not copied: writes land in it, and t
|
|
|
214
214
|
|
|
215
215
|
With `format: 'plain'` the entries are the stored keys themselves, both in the `storage` option and from `t.storage(key)`:
|
|
216
216
|
|
|
217
|
-
```jsx
|
|
217
|
+
```jsx title="Note.test.jsx"
|
|
218
218
|
it('saves the draft as { title, body }', async () => {
|
|
219
219
|
const t = renderComponent(Note, { storage: { 'note-draft': { title: 'Groceries', body: '' } } })
|
|
220
220
|
await t.ready()
|
|
@@ -68,7 +68,7 @@ export default {
|
|
|
68
68
|
}
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
```jsx live live-height=260
|
|
71
|
+
```jsx live live-height=260 title="Article.jsx"
|
|
72
72
|
import { ABORT } from 'sygnal'
|
|
73
73
|
|
|
74
74
|
const TEXT = 'The octopus has three hearts: two pump blood through the gills, and one through the rest of the body. Its blood is blue, because it carries oxygen with copper instead of iron. Two thirds of its neurons are in its arms, so each arm can taste, touch and react without waiting for the brain. Most octopuses live only a year or two.'
|
package/dist/guide/resources.md
CHANGED
|
@@ -17,7 +17,7 @@ export default {
|
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
```jsx live
|
|
20
|
+
```jsx live title="Quote.jsx"
|
|
21
21
|
function Quote({ state }) {
|
|
22
22
|
const { status, data, refreshing } = state.quote
|
|
23
23
|
return (
|
|
@@ -126,7 +126,8 @@ export const router = makeRouter({
|
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
```jsx
|
|
129
|
-
// TaskPage.jsx
|
|
129
|
+
// TaskPage.jsx
|
|
130
|
+
// the page reads the same request
|
|
130
131
|
TaskPage.resources = { task: (state) => routeData.task(state.route)[0] }
|
|
131
132
|
|
|
132
133
|
// App.jsx: { prefetch } on the ROUTER sink, e.g. on hover, warms the route's data
|
|
@@ -151,8 +152,7 @@ A seeded entry keeps its `updatedAt`, so it stays fresh for `staleTime` after th
|
|
|
151
152
|
|
|
152
153
|
A `+data` hook seeds a cache and puts its snapshot on `pageContext.queryCache`. The Sygnal Vike extension renders the page's resources from it, passes it to the client, and hydrates the cache of every fetch driver in the `drivers` config before the page runs. On client-side navigation it does the same with the next page's snapshot.
|
|
153
154
|
|
|
154
|
-
```javascript
|
|
155
|
-
// pages/quote/@id/+data.js
|
|
155
|
+
```javascript title="pages/quote/@id/+data.js"
|
|
156
156
|
import { queryCache } from 'sygnal'
|
|
157
157
|
|
|
158
158
|
export async function data(pageContext) {
|
|
@@ -166,14 +166,14 @@ export async function data(pageContext) {
|
|
|
166
166
|
```
|
|
167
167
|
|
|
168
168
|
```javascript
|
|
169
|
-
// pages/+drivers.js
|
|
169
|
+
// pages/+drivers.js
|
|
170
|
+
// client-only; one cache for the session
|
|
170
171
|
import { makeFetchDriver, queryCache } from 'sygnal'
|
|
171
172
|
|
|
172
173
|
export default { HTTP: makeFetchDriver({ cache: queryCache({ staleTime: 30000 }) }) }
|
|
173
174
|
```
|
|
174
175
|
|
|
175
|
-
```jsx
|
|
176
|
-
// pages/quote/@id/+Page.jsx
|
|
176
|
+
```jsx title="pages/quote/@id/+Page.jsx"
|
|
177
177
|
function Page({ state }) {
|
|
178
178
|
const { status, data } = state.quote
|
|
179
179
|
return <p className="quote">{status === 'success' ? data.text : 'Loading…'}</p>
|
|
@@ -294,7 +294,7 @@ export default {
|
|
|
294
294
|
.rows.stale { opacity: 0.5; }
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
-
```jsx live
|
|
297
|
+
```jsx live title="Pages.jsx"
|
|
298
298
|
function Pages({ state }) {
|
|
299
299
|
const { data, refreshing } = state.list
|
|
300
300
|
return (
|
|
@@ -373,7 +373,7 @@ export default {
|
|
|
373
373
|
}
|
|
374
374
|
```
|
|
375
375
|
|
|
376
|
-
```jsx live
|
|
376
|
+
```jsx live title="App.jsx"
|
|
377
377
|
const statusOf = (r) => (r?.status === 'loading' ? 'Loading…' : r?.refreshing ? 'Updating…' : '')
|
|
378
378
|
|
|
379
379
|
function App({ state }) {
|
|
@@ -426,7 +426,8 @@ App.model = {
|
|
|
426
426
|
```
|
|
427
427
|
|
|
428
428
|
```javascript
|
|
429
|
-
// main.js
|
|
429
|
+
// main.js
|
|
430
|
+
// the tests use renderComponent(App, { http: { cache: queryCache() } })
|
|
430
431
|
run(App, { HTTP: makeFetchDriver({ cache: queryCache() }) })
|
|
431
432
|
```
|
|
432
433
|
|
package/dist/guide/undo.md
CHANGED
|
@@ -152,6 +152,7 @@ defineElement('score-card', ScoreCard, {
|
|
|
152
152
|
```
|
|
153
153
|
|
|
154
154
|
```html
|
|
155
|
+
<!-- index.html -->
|
|
155
156
|
<score-card title="Pasta" score="3"></score-card>
|
|
156
157
|
<script>
|
|
157
158
|
document.querySelector('score-card').addEventListener('score-change', (e) => console.log(e.detail.score))
|
package/dist/guide/webmcp.md
CHANGED
|
@@ -43,8 +43,7 @@ Chrome 153 has WebMCP behind a flag: enable **WebMCP for testing** at `chrome://
|
|
|
43
43
|
|
|
44
44
|
**Other browsers: the polyfill.** [`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill) installs a `document.modelContext` in any browser, so an agent extension that speaks it can call your tools. Load it before `experimentalExposeWebMcp`, in development only:
|
|
45
45
|
|
|
46
|
-
```js
|
|
47
|
-
// main.js (development)
|
|
46
|
+
```js title="main.js (development)"
|
|
48
47
|
import { run } from 'sygnal'
|
|
49
48
|
import { experimentalExposeWebMcp } from 'sygnal/ai'
|
|
50
49
|
import TodoApp from './TodoApp.jsx'
|