sygnal 5.4.0 → 6.1.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 +716 -0
- package/README.md +24 -9
- package/dist/ai.cjs.js +115 -0
- package/dist/ai.cjs.js.map +1 -0
- package/dist/ai.esm.js +2 -0
- package/dist/ai.esm.js.map +1 -0
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +958 -92
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +958 -93
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1840 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1827 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3190 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3190 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +310 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/agent.md +588 -0
- package/dist/guide/ai-chat.md +693 -0
- package/dist/guide/ai-decisions.md +330 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +409 -0
- package/dist/guide/forms.md +251 -0
- package/dist/guide/http.md +301 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/mcp-apps.md +272 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/ai-form-fill.md +163 -0
- package/dist/guide/recipes/ai-summarize.md +155 -0
- package/dist/guide/recipes/ai-support-inbox.md +190 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +43 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/webmcp.md +226 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +19288 -7408
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +4466 -552
- package/dist/index.esm.js +19274 -7461
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +919 -89
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +919 -90
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +172 -103
- package/package.json +112 -14
- package/src/ai.d.ts +1186 -0
- package/src/ai.ts +23 -0
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +170 -0
- package/src/devtools.ts +37 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/ai/agent/index.ts +484 -0
- package/src/extra/ai/answers.ts +115 -0
- package/src/extra/ai/chat/behavior.ts +336 -0
- package/src/extra/ai/chat/driver.ts +271 -0
- package/src/extra/ai/chat/memoryTransport.ts +61 -0
- package/src/extra/ai/chat/output.ts +37 -0
- package/src/extra/ai/commandBar.ts +281 -0
- package/src/extra/ai/decide.ts +104 -0
- package/src/extra/ai/index.ts +42 -0
- package/src/extra/ai/link.ts +90 -0
- package/src/extra/ai/mcpApp.ts +314 -0
- package/src/extra/ai/messages.ts +51 -0
- package/src/extra/ai/schema/index.ts +166 -0
- package/src/extra/ai/schema/jsonSchema.ts +72 -0
- package/src/extra/ai/schema/strict.ts +152 -0
- package/src/extra/ai/transports/agui.ts +177 -0
- package/src/extra/ai/transports/anthropicMessages.ts +175 -0
- package/src/extra/ai/transports/chatCompletions.ts +105 -0
- package/src/extra/ai/transports/chromePrompt.ts +77 -0
- package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
- package/src/extra/ai/transports/fromAISDK.ts +126 -0
- package/src/extra/ai/transports/openResponses.ts +105 -0
- package/src/extra/ai/transports/shared.ts +125 -0
- package/src/extra/ai/transports/tools.ts +59 -0
- package/src/extra/ai/transports/uiMessageStream.ts +107 -0
- package/src/extra/ai/webmcp.ts +260 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devMcp.ts +386 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/chat.ts +71 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +171 -0
- package/src/extra/diagnostics/checks/index.ts +103 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +115 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +69 -10
- package/src/extra/diagnostics/codes.ts +335 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +284 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/formTool.ts +150 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2550 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2754 -127
- package/src/index.ts +45 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +57 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/mcp.ts +568 -0
- package/src/vite/plugin.d.ts +47 -3
- package/src/vite/plugin.ts +262 -25
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# AI Chat
|
|
3
|
+
|
|
4
|
+
`sygnal/ai` is Sygnal's subpath for working with language models. It has a **chat driver** that streams replies as actions, **transports** that speak each provider's wire format, helpers for [decision models](./ai-decisions.md), and the layer that lets agents operate your components. Everything is pay per use: an app that never imports `sygnal/ai` carries none of it, and each transport is left out unless you import it.
|
|
5
|
+
|
|
6
|
+
A chat request is a driver request like an [HTTP](./http.md) one. A model entry sends it to the `LLM` sink and names the actions that receive the reply: the text so far while it streams, the finished message, and the failure. No component calls `fetch` or a provider SDK.
|
|
7
|
+
|
|
8
|
+
## Getting started
|
|
9
|
+
|
|
10
|
+
Start with a model on your own machine: no account, no key, no server code. Install [Ollama](https://ollama.com), then pull a small model:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
ollama pull llama3.2
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Ollama serves the Open Responses API at `http://localhost:11434/v1`. Register the chat driver once in `main.js`, with the `openResponses()` transport pointed at it:
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { run } from 'sygnal'
|
|
20
|
+
import { makeChatDriver, openResponses } from 'sygnal/ai'
|
|
21
|
+
import Chat from './Chat.jsx'
|
|
22
|
+
|
|
23
|
+
run(Chat, {
|
|
24
|
+
LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }),
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Ollama accepts requests from pages on `localhost` by default. A dev server on another host name needs `OLLAMA_ORIGINS` set.
|
|
29
|
+
|
|
30
|
+
The live examples on this page get the same driver, under the same name, with one difference: `openResponses()` posts to the page's demo server instead of Ollama. The demo server's `POST /v1/responses` route is the "model". It answers in the Open Responses format a real model streams, built with `encodeOpenResponses` ([below](#encodeopenresponses-scripted-replies)). The component code is what you would run against Ollama.
|
|
31
|
+
|
|
32
|
+
## A streaming chat
|
|
33
|
+
|
|
34
|
+
The demo asks its first question as soon as it shows, and the reply streams in. Ask another one, or press **Stop** while a reply streams: the text that arrived stays, marked as stopped.
|
|
35
|
+
|
|
36
|
+
```js live-server
|
|
37
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
38
|
+
|
|
39
|
+
const words = (text) => text.match(/\S+\s*/g)
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
'POST /v1/responses': ({ json }) => {
|
|
43
|
+
const question = String(json.input.at(-1).content).toLowerCase()
|
|
44
|
+
const text = /\b(hello|hi)\b/.test(question)
|
|
45
|
+
? 'Hello! I am this page\'s demo model: a route, not an LLM.'
|
|
46
|
+
: 'A reply is a request to the LLM driver. Its text streams back as actions, at most one per frame, so the view renders a few dozen times, not once per token.'
|
|
47
|
+
return { sse: encodeOpenResponses(words(text)), delayMs: 300, chunkMs: 60 }
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```jsx live live-height=260
|
|
53
|
+
import { ABORT } from 'sygnal'
|
|
54
|
+
import { messageText } from 'sygnal/ai'
|
|
55
|
+
|
|
56
|
+
function Chat({ state }) {
|
|
57
|
+
const busy = state.status === 'streaming'
|
|
58
|
+
return (
|
|
59
|
+
<section className="chat">
|
|
60
|
+
<ol className="messages" aria-live="polite">
|
|
61
|
+
{state.messages.map((m, i) => <li key={i} className={m.role}><b>{m.role}:</b> {messageText(m)}</li>)}
|
|
62
|
+
{state.draft && <li className="assistant streaming"><b>assistant:</b> {state.draft}</li>}
|
|
63
|
+
</ol>
|
|
64
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
65
|
+
<form className="ask">
|
|
66
|
+
<label>Message <input className="prompt" value={state.prompt} /></label>
|
|
67
|
+
<button type="submit" disabled={busy}>Send</button>
|
|
68
|
+
<button type="button" className="stop" hidden={!busy}>Stop</button>
|
|
69
|
+
</form>
|
|
70
|
+
</section>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const userSays = (text) => ({ role: 'user', parts: [{ type: 'text', text }] })
|
|
75
|
+
const ask = (messages) => ({ messages, key: 'reply', delta: 'DELTA', ok: 'DONE', error: 'FAILED' })
|
|
76
|
+
|
|
77
|
+
Chat.initialState = { messages: [], prompt: '', draft: '', status: 'ready', error: null }
|
|
78
|
+
|
|
79
|
+
Chat.intent = ({ DOM }) => ({
|
|
80
|
+
TYPE: DOM.input('.prompt').value(),
|
|
81
|
+
SEND: DOM.select('.ask').events('submit', { preventDefault: true }),
|
|
82
|
+
STOP: DOM.click('.stop'),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
Chat.model = {
|
|
86
|
+
// the first question, as soon as the demo shows
|
|
87
|
+
BOOTSTRAP: {
|
|
88
|
+
STATE: (state) => ({ ...state, messages: [userSays('How does a reply stream?')], status: 'streaming' }),
|
|
89
|
+
LLM: () => ask([userSays('How does a reply stream?')]),
|
|
90
|
+
},
|
|
91
|
+
TYPE: (state, prompt) => ({ ...state, prompt }),
|
|
92
|
+
SEND: {
|
|
93
|
+
STATE: (state) => (state.prompt.trim()
|
|
94
|
+
? { ...state, messages: [...state.messages, userSays(state.prompt)], prompt: '', status: 'streaming', error: null }
|
|
95
|
+
: ABORT),
|
|
96
|
+
// sinks see the state before the action: the conversation plus the prompt being sent
|
|
97
|
+
LLM: (state) => (state.prompt.trim() ? ask([...state.messages, userSays(state.prompt)]) : ABORT),
|
|
98
|
+
},
|
|
99
|
+
STOP: {
|
|
100
|
+
// keep the text that arrived; nothing arrives after the abort
|
|
101
|
+
STATE: (state) => ({
|
|
102
|
+
...state,
|
|
103
|
+
draft: '',
|
|
104
|
+
status: 'ready',
|
|
105
|
+
messages: state.draft ? [...state.messages, { role: 'assistant', parts: [{ type: 'text', text: `${state.draft} [stopped]` }] }] : state.messages,
|
|
106
|
+
}),
|
|
107
|
+
LLM: () => ({ abort: 'reply' }),
|
|
108
|
+
},
|
|
109
|
+
DELTA: (state, { text }) => ({ ...state, draft: text }),
|
|
110
|
+
DONE: (state, { message }) => ({ ...state, messages: [...state.messages, message], draft: '', status: 'ready' }),
|
|
111
|
+
FAILED: (state, { error }) => ({ ...state, draft: '', status: 'ready', error: error.message }),
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
What the component does:
|
|
116
|
+
|
|
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.
|
|
118
|
+
- **`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
|
+
- **`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
|
+
- **`DONE` gets the finished message** and appends it to the conversation as is.
|
|
121
|
+
- **`STOP` aborts the request** by its key. Nothing arrives after an abort, not even `FAILED`.
|
|
122
|
+
|
|
123
|
+
## Requests
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
| Field | Default | Meaning |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `messages` | (required) | The conversation: `{ role, parts }` messages, or `{ role, content }` with a plain string for messages you write by hand |
|
|
130
|
+
| `instructions` | — | System instructions |
|
|
131
|
+
| `model` | the transport's | A model id for this request |
|
|
132
|
+
| `delta` | — | Action for the reply so far, while it streams |
|
|
133
|
+
| `ok` | — | Action for the finished reply |
|
|
134
|
+
| `error` | — | Action for a failure. Without one, a failure is only logged ([SYG678](https://sygnal.js.org/reference/errors/#syg678)) |
|
|
135
|
+
| `tool` | — | Action for each tool call the model makes ([Tools](#tools)) |
|
|
136
|
+
| `tools` | — | Tools the model may call: `{ name: { description, inputSchema } }` |
|
|
137
|
+
| `output` | — | A schema for [structured output](#structured-output) |
|
|
138
|
+
| `key` | the `ok` action, else `error` | The group for `latest` and `abort` |
|
|
139
|
+
| `latest` | `true` | A new request aborts this instance's request with the same key that is still streaming |
|
|
140
|
+
| `coalesce` | the driver's (`'frame'`) | How often `delta` fires: `'frame'`, `'none'` or a number of ms ([Streaming](#streaming-and-delta-coalescing)) |
|
|
141
|
+
| `continue` | — | The reply continues the last message, an assistant message (after tool results) |
|
|
142
|
+
|
|
143
|
+
The reply goes to **exactly the instance that sent the request**, as with the fetch driver: two chat panels, or the items of a Collection, can use the same action names. A request with a `then` or `catch` key is not sent ([SYG610](https://sygnal.js.org/reference/errors/#syg610)): use `ok` and `error`.
|
|
144
|
+
|
|
145
|
+
### Reply actions
|
|
146
|
+
|
|
147
|
+
| Action | Data |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `delta` | `{ key, text, reasoning, delta, message }`: all the text so far, all the reasoning so far, the text new since the previous `delta` (`''` when only the reasoning grew), and the assistant message so far |
|
|
150
|
+
| `ok` | `{ key, message, text, value, toolCalls, finishReason, usage }`: the finished assistant message (append it to the conversation), its text, the validated structured output (with `output`), the tool calls, the finish reason (`'stop'`, `'length'`, `'tool-calls'`, …) and the token usage |
|
|
151
|
+
| `tool` | `{ key, call: { id, name, input } }`, once per completed tool call, before `ok` |
|
|
152
|
+
| `error` | `{ key, error, request, issues }`: the Error (`error.status` for an HTTP error), the request as sent, and the schema issues when structured output failed |
|
|
153
|
+
|
|
154
|
+
### Messages
|
|
155
|
+
|
|
156
|
+
Messages have the same shape as the AI SDK's `UIMessage`: an `id`, a `role` and a list of `parts`. The parts a reply can have are `text`, `reasoning`, `tool-<name>` (a tool call, with its `state`, `input` and `output`), `file`, `source-*` and `data-*`. Because the shape is the AI SDK's, a conversation goes to an AI SDK server route unchanged.
|
|
157
|
+
|
|
158
|
+
Every reply message has an `id` that doesn't change: the provider's (the response id, or an AI SDK server's message id) or, when the transport gives none, a new random one. A reply that continues a message (`continue: true`, after tool results or an approval) keeps that message's id. The [`chat()` behavior](./agent.md#an-in-app-assistant-chat) gives the user's messages an id when they are sent, so each message in `state.assistant.messages` keeps the same `id` from request to request: a server can store a conversation by message id. Messages you write yourself may leave it out.
|
|
159
|
+
|
|
160
|
+
`messageText(message)` returns a message's text: its text parts joined, or its `content`. Render the other parts as your app needs them: a reasoning part in a collapsed `<details>`, a tool part as a line saying what the model did.
|
|
161
|
+
|
|
162
|
+
## Streaming and delta coalescing
|
|
163
|
+
|
|
164
|
+
A model streams one event per token or so: a 300-token reply is about 300 events. One action per event would render 300 times. The driver coalesces them instead: by default (`coalesce: 'frame'`) `delta` fires at most once per animation frame, and at most every 15 ms on a fast display. In a hidden tab, where the browser stops animation frames, it fires about once a second. The pending text is always delivered before `tool` and `ok`, so nothing is lost.
|
|
165
|
+
|
|
166
|
+
`coalesce: 'none'` fires once per streamed event; a number fires at most every that many ms. Set it on a request, or for every request with `makeChatDriver({ transport, coalesce })`.
|
|
167
|
+
|
|
168
|
+
Reasoning models stream their reasoning first. It arrives through `delta` too: `reasoning` grows while `text` stays `''`. The finished message keeps it as `reasoning` parts.
|
|
169
|
+
|
|
170
|
+
## Stop, abort and latest
|
|
171
|
+
|
|
172
|
+
- **`{ abort: 'reply' }`** aborts this instance's request with the key `'reply'`. The transport stops reading and the connection closes. `{ abort: true, key: 'reply' }` is the same, and `{ abort: true }` aborts all of this instance's requests.
|
|
173
|
+
- **`latest` is on by default.** Sending a request aborts the instance's request with the same key that is still streaming, so a second question replaces the first. `latest: false` lets both run, each with its own key.
|
|
174
|
+
- **An aborted request delivers nothing.** The text it had streamed is in your state already, through `delta`. Keep it, as the demo does, or drop it.
|
|
175
|
+
- **A removed component's requests are aborted**, as are all requests when the app is disposed.
|
|
176
|
+
|
|
177
|
+
## Structured output
|
|
178
|
+
|
|
179
|
+
With `output`, the model answers with JSON that matches a schema, and `ok` gets the validated value as `value`. The schema is a [Standard Schema](https://standardschema.dev) that can also give a JSON Schema (zod 4.2 or later, ArkType, Valibot through `toStandardJsonSchema()`). The transport sends its JSON Schema to the model; the driver parses the reply and validates it. A reply that isn't JSON, or doesn't match, is the `error` reply, with the schema's `issues`.
|
|
180
|
+
|
|
181
|
+
In this demo, the note is turned into a task as soon as it shows. Edit the note and extract again.
|
|
182
|
+
|
|
183
|
+
```js live-server
|
|
184
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
185
|
+
|
|
186
|
+
export default {
|
|
187
|
+
'POST /v1/responses': ({ json }) => {
|
|
188
|
+
// json.text.format holds the task's JSON Schema; a real model answers to match it
|
|
189
|
+
const note = String(json.input.at(-1).content)
|
|
190
|
+
const task = {
|
|
191
|
+
title: note.split(/[,.]/)[0].trim(),
|
|
192
|
+
due: note.match(/\b(today|tomorrow|monday|tuesday|wednesday|thursday|friday)\b/i)?.[0].toLowerCase() ?? null,
|
|
193
|
+
priority: /\b(urgent|asap)\b/i.test(note) ? 'high' : 'normal',
|
|
194
|
+
}
|
|
195
|
+
return { sse: encodeOpenResponses(JSON.stringify(task).match(/.{1,16}/g)), delayMs: 300, chunkMs: 40 }
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
```jsx live live-height=200
|
|
201
|
+
import { z } from 'zod'
|
|
202
|
+
import { ABORT } from 'sygnal'
|
|
203
|
+
|
|
204
|
+
const Task = z.object({
|
|
205
|
+
title: z.string().describe('What to do, in a few words'),
|
|
206
|
+
due: z.string().nullable().describe('The day it is due, or null'),
|
|
207
|
+
priority: z.enum(['low', 'normal', 'high']),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const extract = (note) => ({
|
|
211
|
+
instructions: 'Turn the note into a task.',
|
|
212
|
+
messages: [{ role: 'user', content: note }],
|
|
213
|
+
output: Task,
|
|
214
|
+
ok: 'EXTRACTED',
|
|
215
|
+
error: 'FAILED',
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
function QuickAdd({ state }) {
|
|
219
|
+
const task = state.task
|
|
220
|
+
return (
|
|
221
|
+
<section className="quick-add">
|
|
222
|
+
<form className="extract">
|
|
223
|
+
<label>Note <input className="note" value={state.note} /></label>
|
|
224
|
+
<button type="submit" disabled={state.status === 'reading'}>Make a task</button>
|
|
225
|
+
</form>
|
|
226
|
+
<p aria-live="polite">{state.status === 'reading' ? 'Reading the note…' : ''}</p>
|
|
227
|
+
{task && (
|
|
228
|
+
<dl className="task">
|
|
229
|
+
<dt>Title</dt><dd>{task.title}</dd>
|
|
230
|
+
<dt>Due</dt><dd>{task.due ?? 'no date'}</dd>
|
|
231
|
+
<dt>Priority</dt><dd>{task.priority}</dd>
|
|
232
|
+
</dl>
|
|
233
|
+
)}
|
|
234
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
235
|
+
</section>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
QuickAdd.initialState = { note: 'Call the plumber about the leak tomorrow, urgent', task: null, status: 'ready', error: null }
|
|
240
|
+
|
|
241
|
+
QuickAdd.intent = ({ DOM }) => ({
|
|
242
|
+
TYPE: DOM.input('.note').value(),
|
|
243
|
+
EXTRACT: DOM.select('.extract').events('submit', { preventDefault: true }),
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
QuickAdd.model = {
|
|
247
|
+
BOOTSTRAP: {
|
|
248
|
+
STATE: (state) => ({ ...state, status: 'reading' }),
|
|
249
|
+
LLM: (state) => extract(state.note),
|
|
250
|
+
},
|
|
251
|
+
TYPE: (state, note) => ({ ...state, note }),
|
|
252
|
+
EXTRACT: {
|
|
253
|
+
STATE: (state) => (state.note.trim() ? { ...state, status: 'reading', error: null } : ABORT),
|
|
254
|
+
LLM: (state) => (state.note.trim() ? extract(state.note) : ABORT),
|
|
255
|
+
},
|
|
256
|
+
EXTRACTED: (state, { value }) => ({ ...state, task: value, status: 'ready' }),
|
|
257
|
+
FAILED: (state, { error, issues }) => ({
|
|
258
|
+
...state,
|
|
259
|
+
status: 'ready',
|
|
260
|
+
error: issues ? 'The model\'s answer did not match the task schema.' : error.message,
|
|
261
|
+
}),
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- `value` is the schema's **output**: transforms and defaults have run, and its type is the schema's output type.
|
|
266
|
+
- A schema whose root isn't an object (a `z.array(...)`, a `z.enum(...)`) is sent wrapped as `{ value }` and unwrapped again, because providers want an object at the root.
|
|
267
|
+
- `.describe()` texts go to the model with the schema. They are the best place to say what a field means. With ArkType, a field's `.describe()` also replaces the expected text in its `issues` messages (`title must be What to do (was a number)`); see [Input schemas](./agent.md#input-schemas) for how to keep both.
|
|
268
|
+
- Small local models follow a schema less reliably than hosted ones. Handle `issues` in `error` (ask again, or show the text), and keep schemas small and flat.
|
|
269
|
+
- For OpenAI's and Anthropic's strict schema modes, see [Strict schemas](#strict-schemas).
|
|
270
|
+
|
|
271
|
+
The [structured output into a form](./recipes/ai-form-fill.md) recipe fills a `form` behavior's fields from a reply like this one.
|
|
272
|
+
|
|
273
|
+
## Tools
|
|
274
|
+
|
|
275
|
+
A request's `tools` lists functions the model may call, each with a description and a JSON Schema for its input. When the model calls one, the `tool` action gets the call, and the reply ends with `finishReason: 'tool-calls'`. Your app runs the call, writes the result into the tool's part of the message, and sends the conversation again so the model can answer with the result.
|
|
276
|
+
|
|
277
|
+
In this demo, the model has one tool, `add_item`. Ask it to add something to the list.
|
|
278
|
+
|
|
279
|
+
```js live-server
|
|
280
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
281
|
+
|
|
282
|
+
const reply = (chunks) => ({ sse: encodeOpenResponses(chunks), delayMs: 300, chunkMs: 40 })
|
|
283
|
+
|
|
284
|
+
export default {
|
|
285
|
+
'POST /v1/responses': ({ json }) => {
|
|
286
|
+
const last = json.input.at(-1)
|
|
287
|
+
// the app ran the tool and sent its output back: answer with it
|
|
288
|
+
if (last.type === 'function_call_output') {
|
|
289
|
+
const { items } = JSON.parse(last.output)
|
|
290
|
+
return reply([`Done. The list has ${items.length} item${items.length === 1 ? '' : 's'}: `, items.join(', '), '.'])
|
|
291
|
+
}
|
|
292
|
+
const item = String(last.content).match(/add (.+?)\.?$/i)?.[1]
|
|
293
|
+
if (item) return reply([{ toolCall: { name: 'add_item', input: { name: item } } }])
|
|
294
|
+
return reply(['Ask me to add something, like ', '"add oat milk".'])
|
|
295
|
+
},
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```jsx live live-height=260
|
|
300
|
+
import { ABORT } from 'sygnal'
|
|
301
|
+
import { messageText, withToolResults } from 'sygnal/ai'
|
|
302
|
+
|
|
303
|
+
const tools = {
|
|
304
|
+
add_item: {
|
|
305
|
+
description: 'Add an item to the shopping list',
|
|
306
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
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
|
+
const ask = (messages) => ({ messages, tools, key: 'reply', delta: 'DELTA', tool: 'TOOL', ok: 'DONE', error: 'FAILED' })
|
|
313
|
+
|
|
314
|
+
// the reply with each add_item call answered: the output goes into the call's tool-add_item part
|
|
315
|
+
const answered = (message, items) => withToolResults(message, { add_item: { items } })
|
|
316
|
+
|
|
317
|
+
function Shopping({ state }) {
|
|
318
|
+
const busy = state.status === 'streaming'
|
|
319
|
+
return (
|
|
320
|
+
<section className="shopping">
|
|
321
|
+
<ul className="items">{state.items.map((item) => <li>{item}</li>)}</ul>
|
|
322
|
+
<ol className="messages" aria-live="polite">
|
|
323
|
+
{state.messages.map((m, i) => <li key={i} className={m.role}><b>{m.role}:</b> {said(m)}</li>)}
|
|
324
|
+
{state.draft && <li className="assistant streaming"><b>assistant:</b> {state.draft}</li>}
|
|
325
|
+
</ol>
|
|
326
|
+
{state.error && <p role="alert">{state.error}</p>}
|
|
327
|
+
<form className="ask">
|
|
328
|
+
<label>Message <input className="prompt" value={state.prompt} /></label>
|
|
329
|
+
<button type="submit" disabled={busy}>Send</button>
|
|
330
|
+
</form>
|
|
331
|
+
</section>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
Shopping.initialState = { items: ['bread'], messages: [], prompt: 'Add oat milk', draft: '', status: 'ready', error: null }
|
|
336
|
+
|
|
337
|
+
Shopping.intent = ({ DOM }) => ({
|
|
338
|
+
TYPE: DOM.input('.prompt').value(),
|
|
339
|
+
SEND: DOM.select('.ask').events('submit', { preventDefault: true }),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
Shopping.model = {
|
|
343
|
+
TYPE: (state, prompt) => ({ ...state, prompt }),
|
|
344
|
+
SEND: {
|
|
345
|
+
STATE: (state) => (state.prompt.trim()
|
|
346
|
+
? { ...state, messages: [...state.messages, userSays(state.prompt)], prompt: '', status: 'streaming', error: null }
|
|
347
|
+
: ABORT),
|
|
348
|
+
LLM: (state) => (state.prompt.trim() ? ask([...state.messages, userSays(state.prompt)]) : ABORT),
|
|
349
|
+
},
|
|
350
|
+
DELTA: (state, { text }) => ({ ...state, draft: text }),
|
|
351
|
+
// runs the call as it arrives; the model's input is unchecked, so check it
|
|
352
|
+
TOOL: (state, { call }) => (call.name === 'add_item' && typeof call.input.name === 'string'
|
|
353
|
+
? { ...state, items: [...state.items, call.input.name] }
|
|
354
|
+
: ABORT),
|
|
355
|
+
DONE: {
|
|
356
|
+
STATE: (state, { message, toolCalls }) => ({
|
|
357
|
+
...state,
|
|
358
|
+
messages: [...state.messages, answered(message, state.items)],
|
|
359
|
+
draft: '',
|
|
360
|
+
status: toolCalls.length ? 'streaming' : 'ready',
|
|
361
|
+
}),
|
|
362
|
+
// after tool calls, send the conversation again with their outputs
|
|
363
|
+
LLM: (state, { message, toolCalls }) => (toolCalls.length ? ask([...state.messages, answered(message, state.items)]) : ABORT),
|
|
364
|
+
},
|
|
365
|
+
FAILED: (state, { error }) => ({ ...state, draft: '', status: 'ready', error: error.message }),
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
- **`tool` arrives before `ok`**, once per call, so `TOOL` has run by the time `DONE` builds the outputs from the state.
|
|
370
|
+
- **`withToolResults(message, results)`** answers the message's open tool calls: each `tool-<name>` part waiting for a result gets `state: 'output-available'` and the `output`. `results` is an object keyed by the call's `id` or, as here, the tool's name, or a function of the call (`{ id, name, input }`) that returns the output. An `Error` (returned or thrown) becomes `state: 'output-error'` with its message as `errorText`, which tells the model the call failed; `undefined` leaves the part open.
|
|
371
|
+
- **The input is the model's, unchecked.** The driver doesn't validate a call's input against the tool's `inputSchema`. Check it in the reducer as you would any outside input.
|
|
372
|
+
- **The loop needs a limit in a real app**: count the steps in state and stop sending after a few.
|
|
373
|
+
|
|
374
|
+
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.
|
|
375
|
+
|
|
376
|
+
## Testing
|
|
377
|
+
|
|
378
|
+
`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:
|
|
379
|
+
|
|
380
|
+
- `t.requests('LLM')`: the requests sent, as the model built them.
|
|
381
|
+
- `await t.stream('LLM', chunks, target?)`: streams chunks into the pending request, then ends it (the `ok` action). Chunks are strings (text), `{ reasoning }`, `{ toolCall: { id?, name, input } }`, `{ data, name? }` or `{ finish: reason }`. Each call is one frame: at most one `delta`. Pass `{ end: false }` to keep the stream open for another call.
|
|
382
|
+
- `await t.respond('LLM', text, target?)`: the whole reply as one chunk. A non-string is sent as JSON, for structured output.
|
|
383
|
+
- `await t.fail('LLM', error, target?)`: the stream fails. A number is an HTTP status: `t.fail('LLM', 429)` gives an Error `'HTTP 429'` with `status: 429`.
|
|
384
|
+
|
|
385
|
+
`target` picks the request as for the HTTP fake: an action name or key (`'reply'`), a partial request, or a predicate. The newest pending request is the default.
|
|
386
|
+
|
|
387
|
+
Here `Chat.jsx` is the streaming demo's component without its `BOOTSTRAP` entry (an app rarely sends a question on its own):
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
import { it, expect, afterEach } from 'vitest'
|
|
391
|
+
import { renderComponent } from 'sygnal'
|
|
392
|
+
import Chat from './Chat.jsx'
|
|
393
|
+
|
|
394
|
+
let t
|
|
395
|
+
afterEach(() => t?.dispose())
|
|
396
|
+
|
|
397
|
+
it('streams a reply, and keeps the text when stopped', async () => {
|
|
398
|
+
t = renderComponent(Chat)
|
|
399
|
+
t.simulateEvent('.prompt', 'input', { value: 'Tell me a story' })
|
|
400
|
+
t.simulateEvent('.ask', 'submit')
|
|
401
|
+
await t.stream('LLM', ['Once', ' upon'], 'reply', { end: false })
|
|
402
|
+
expect(t.state.draft).toBe('Once upon')
|
|
403
|
+
t.simulateEvent('.stop', 'click')
|
|
404
|
+
await t.settle()
|
|
405
|
+
expect(t.state.messages.at(-1).parts[0].text).toBe('Once upon [stopped]')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('shows a failure', async () => {
|
|
409
|
+
t = renderComponent(Chat)
|
|
410
|
+
t.simulateEvent('.prompt', 'input', { value: 'Hello' })
|
|
411
|
+
t.simulateEvent('.ask', 'submit')
|
|
412
|
+
await t.fail('LLM', 429, 'reply')
|
|
413
|
+
expect(t.html()).toContain('HTTP 429')
|
|
414
|
+
})
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The fake uses the real driver, so `latest`, `abort`, coalescing and the reply routing behave as in the app. It works under `vi.useFakeTimers()` unchanged. The sink name is `'LLM'`; for another name, pass `renderComponent(C, { llmSink: 'AI' })`.
|
|
418
|
+
|
|
419
|
+
For structured output, respond with the object: `await t.respond('LLM', { title: 'Call the plumber', due: null, priority: 'high' })` reaches `EXTRACTED` with it as `value`. For tools, stream a call: `await t.stream('LLM', [{ toolCall: { name: 'add_item', input: { name: 'eggs' } } }])`.
|
|
420
|
+
|
|
421
|
+
## Shipping it
|
|
422
|
+
|
|
423
|
+
Ollama on your machine has no key to protect. A hosted model has one, and it must never reach the browser: anyone who opens the page can read every header and every byte of your bundle. In production the browser talks to **your server**, and your server talks to the provider.
|
|
424
|
+
|
|
425
|
+
The recommended server is the [AI SDK](https://ai-sdk.dev) (version 7). Its `streamText()` works with every major provider, and `toUIMessageStreamResponse()` answers in the UI message stream format that the `uiMessageStream()` transport reads. A route, as a handler that takes a `Request` and returns a `Response` (Next.js, Hono, SvelteKit, Vike with a server, Cloudflare and Deno all have this shape):
|
|
426
|
+
|
|
427
|
+
```js
|
|
428
|
+
// server: POST /api/chat
|
|
429
|
+
import { streamText, convertToModelMessages, tool, jsonSchema } from 'ai'
|
|
430
|
+
import { anthropic } from '@ai-sdk/anthropic' // reads ANTHROPIC_API_KEY on the server
|
|
431
|
+
import { currentUser, overLimit } from './auth.js' // your session and rate limit
|
|
432
|
+
|
|
433
|
+
export async function POST(req) {
|
|
434
|
+
const user = await currentUser(req)
|
|
435
|
+
if (!user) return new Response('Sign in first', { status: 401 })
|
|
436
|
+
if (await overLimit(user)) return new Response('Too many requests', { status: 429 })
|
|
437
|
+
|
|
438
|
+
const { messages, tools = {} } = await req.json()
|
|
439
|
+
const result = streamText({
|
|
440
|
+
model: anthropic('claude-opus-5-5'), // the server picks the model…
|
|
441
|
+
instructions: 'You help customers of Acme with their orders. Keep replies short.', // …and the instructions
|
|
442
|
+
messages: await convertToModelMessages(messages),
|
|
443
|
+
maxOutputTokens: 1000,
|
|
444
|
+
// the app's tools, without execute: the calls go back to the browser, which runs them
|
|
445
|
+
tools: Object.fromEntries(Object.entries(tools).map(([name, t]) => [name, tool({ description: t.description, inputSchema: jsonSchema(t.inputSchema) })])),
|
|
446
|
+
})
|
|
447
|
+
return result.toUIMessageStreamResponse()
|
|
448
|
+
}
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
The request body is what the AI SDK's own client sends (`{ id, messages, trigger }`), plus `instructions`, `model`, `tools` and `output` from the chat request. The messages are already `UIMessage`s, so `convertToModelMessages()` takes them as they are. Tools you define on the server with an `execute` run there; their calls and results come back as tool parts of the message, and they don't reach your `tool` action.
|
|
452
|
+
|
|
453
|
+
:::caution[A proxy that only adds your key is an open relay]
|
|
454
|
+
A route that forwards whatever the browser sends (any model, any instructions, any length) and adds your API key lets anyone on the internet use your account as a free LLM API. Your route is public the moment the page is: its URL is in the bundle.
|
|
455
|
+
|
|
456
|
+
- **The server decides** the model, the system instructions and the token limit. Ignore the `model` and `instructions` the request carries, or check them against a short list.
|
|
457
|
+
- **Require a session**, and **rate-limit per user**, as you would any endpoint that costs money.
|
|
458
|
+
- **Set a spending limit** with the provider as the last line of defence.
|
|
459
|
+
:::
|
|
460
|
+
|
|
461
|
+
The transports refuse the other mistake for you. In a browser, a request with an auth header (`Authorization`, `x-api-key`, …) to a host that is neither local nor the page's own origin is not sent: it fails with [SYG670](https://sygnal.js.org/reference/errors/#syg670). The only exception is a key the user typed in themselves (a "bring your own key" app): pass `dangerouslyAllowBrowser: true` to the transport for that.
|
|
462
|
+
|
|
463
|
+
### The same component, two transports
|
|
464
|
+
|
|
465
|
+
The component never changes. Only the transport in `main.js` does: Ollama while you develop, your AI SDK route in production.
|
|
466
|
+
|
|
467
|
+
```js
|
|
468
|
+
// main.js: development, a local model
|
|
469
|
+
import { run } from 'sygnal'
|
|
470
|
+
import { makeChatDriver, openResponses } from 'sygnal/ai'
|
|
471
|
+
import Chat from './Chat.jsx'
|
|
472
|
+
|
|
473
|
+
run(Chat, {
|
|
474
|
+
LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' }) }),
|
|
475
|
+
})
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
```js
|
|
479
|
+
// main.js: production, the AI SDK route above
|
|
480
|
+
import { run } from 'sygnal'
|
|
481
|
+
import { makeChatDriver, uiMessageStream } from 'sygnal/ai'
|
|
482
|
+
import Chat from './Chat.jsx'
|
|
483
|
+
|
|
484
|
+
run(Chat, {
|
|
485
|
+
LLM: makeChatDriver({ transport: uiMessageStream('/api/chat') }),
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
Or pick one at build time, so the development transport isn't in the production bundle:
|
|
490
|
+
|
|
491
|
+
```js
|
|
492
|
+
import { run } from 'sygnal'
|
|
493
|
+
import { makeChatDriver, openResponses, uiMessageStream } from 'sygnal/ai'
|
|
494
|
+
import Chat from './Chat.jsx'
|
|
495
|
+
|
|
496
|
+
const transport = import.meta.env.DEV
|
|
497
|
+
? openResponses({ baseURL: 'http://localhost:11434/v1', model: 'llama3.2' })
|
|
498
|
+
: uiMessageStream('/api/chat')
|
|
499
|
+
|
|
500
|
+
run(Chat, { LLM: makeChatDriver({ transport }) })
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
`uiMessageStream(url, { headers })` takes headers, as an object or a function of the request, for a session token your server checks. Same-origin requests carry the page's cookies anyway.
|
|
504
|
+
|
|
505
|
+
### What `uiMessageStream` sends
|
|
506
|
+
|
|
507
|
+
Each request is one `POST` to the URL with a JSON body and the headers `content-type: application/json` plus your `headers` option. With the transport `uiMessageStream('/api/chat', { body: { tenant: 'acme' } })` and this request:
|
|
508
|
+
|
|
509
|
+
```js
|
|
510
|
+
// the value the LLM sink sends
|
|
511
|
+
const request = {
|
|
512
|
+
messages: [
|
|
513
|
+
{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'Where is my order?' }] },
|
|
514
|
+
{ id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'Which order number?' }] },
|
|
515
|
+
{ role: 'user', content: '42' },
|
|
516
|
+
],
|
|
517
|
+
instructions: 'Be brief',
|
|
518
|
+
chatId: 'chat-1',
|
|
519
|
+
body: { locale: 'en' },
|
|
520
|
+
ok: 'DONE',
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
the body is:
|
|
525
|
+
|
|
526
|
+
```json
|
|
527
|
+
{
|
|
528
|
+
"id": "chat-1",
|
|
529
|
+
"messages": [
|
|
530
|
+
{ "id": "u1", "role": "user", "parts": [{ "type": "text", "text": "Where is my order?" }] },
|
|
531
|
+
{ "id": "a1", "role": "assistant", "parts": [{ "type": "text", "text": "Which order number?" }] },
|
|
532
|
+
{ "id": "m2", "role": "user", "parts": [{ "type": "text", "text": "42" }] }
|
|
533
|
+
],
|
|
534
|
+
"trigger": "submit-message",
|
|
535
|
+
"instructions": "Be brief",
|
|
536
|
+
"tenant": "acme",
|
|
537
|
+
"locale": "en"
|
|
538
|
+
}
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
| Key | Sent | Value |
|
|
542
|
+
|---|---|---|
|
|
543
|
+
| `id` | when the request has `chatId` | The request's `chatId` |
|
|
544
|
+
| `messages` | always | AI SDK `UIMessage`s `{ id, role, parts, metadata? }`: a `content` string becomes one text part, a message without an `id` gets `m<index>`. The [`chat()` behavior](./agent.md) adds its app-state message (id `sygnal-app-state`) |
|
|
545
|
+
| `trigger` | always | `'submit-message'`. There is no other value: a regenerate sends the conversation again |
|
|
546
|
+
| `messageId` | with `continue: true` | The last message's id, when it is the assistant's: the reply continues it (after tool results or an approval) |
|
|
547
|
+
| `instructions`, `model` | when the request has them | From the request. The server should ignore them, or check them against a short list |
|
|
548
|
+
| `tools` | when the request has them | `{ name: { description, inputSchema } }`: client tools, declared on the server without `execute` |
|
|
549
|
+
| `output` | with `output` | `{ schema }`: the JSON Schema of the [structured output](#structured-output) |
|
|
550
|
+
|
|
551
|
+
The transport's `body` option and then the request's own `body` are merged in (per-request fields win). They can add keys and replace `instructions`, `model`, `tools` and `output`, but not the protocol fields `id`, `messages`, `trigger` and `messageId`, which are always the transport's (as in the AI SDK's own transport). The [`chat()` behavior](./agent.md) puts its `transportOptions` into each request, so `transportOptions: { chatId: 'chat-1', body: { locale: 'en' } }` sets `id` and adds `locale`. Other request keys (`key`, `ok`, `delta`, `coalesce`, …) are not sent.
|
|
552
|
+
|
|
553
|
+
An AI SDK 7 route needs only `messages`: `await convertToModelMessages(messages)` takes them as they are. It reads `tools` if the app has client tools, and `id` if it stores conversations. It ignores `trigger` and `messageId` (the client builds the continued message), and it should ignore `model` and `instructions` and set its own, as [the route above](#shipping-it) does.
|
|
554
|
+
|
|
555
|
+
#### Storing conversations
|
|
556
|
+
|
|
557
|
+
A route that saves the conversation (the AI SDK's `originalMessages` and `onFinish`) should not save the `chat()` behavior's app-state message: the app state is rebuilt for every request, and a stored copy would be sent again as stale data. That message is marked `metadata: { sygnal: 'sygnal-app-state', … }` (its id is always `sygnal-app-state`). Send every message to the model, and keep the others:
|
|
558
|
+
|
|
559
|
+
```js
|
|
560
|
+
// server: POST /api/chat, storing conversations
|
|
561
|
+
import { streamText, convertToModelMessages, validateUIMessages } from 'ai'
|
|
562
|
+
import { anthropic } from '@ai-sdk/anthropic'
|
|
563
|
+
import { saveChat } from './store.js' // your store
|
|
564
|
+
|
|
565
|
+
export async function POST(req) {
|
|
566
|
+
const body = await req.json()
|
|
567
|
+
// the model sees the app state; the store doesn't
|
|
568
|
+
const messages = await validateUIMessages({ messages: body.messages })
|
|
569
|
+
const stored = messages.filter((m) => !m.metadata?.sygnal)
|
|
570
|
+
|
|
571
|
+
const result = streamText({ model: anthropic('claude-opus-5-5'), messages: await convertToModelMessages(messages) })
|
|
572
|
+
return result.toUIMessageStreamResponse({
|
|
573
|
+
originalMessages: stored,
|
|
574
|
+
onFinish: ({ messages }) => saveChat(body.id, messages),
|
|
575
|
+
})
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
The ids round-trip: the user's messages keep the ids the browser gave them, and the reply takes the id the server sends (with `generateMessageId`) or the client makes, so the next request names the same messages.
|
|
580
|
+
|
|
581
|
+
## Transports
|
|
582
|
+
|
|
583
|
+
A transport speaks one wire format. Pick the one your server or model speaks; every transport gives the component the same actions and the same messages.
|
|
584
|
+
|
|
585
|
+
| Transport | Speaks | Use it for |
|
|
586
|
+
|---|---|---|
|
|
587
|
+
| `uiMessageStream(url)` | The AI SDK UI message stream (v1) | **Production**: an AI SDK route on your server. Server-run tools, tool approvals and `data-*` parts come back as message parts |
|
|
588
|
+
| `openResponses({ baseURL, model })` | Open Responses / OpenAI Responses | **Getting started**: Ollama, vLLM, OpenRouter, OpenAI (through your proxy) |
|
|
589
|
+
| `chatCompletions({ baseURL, model })` | Chat Completions | Older local servers (LM Studio, llama.cpp) and anything that only has `/chat/completions` |
|
|
590
|
+
| `chromePrompt()` | Chrome's built-in Prompt API (`LanguageModel`) | On-device answers with no server and no key ([recipe](./recipes/ai-summarize.md)) |
|
|
591
|
+
| `anthropicMessages({ baseURL, model })` | Anthropic Messages | Claude through your pass-through proxy, or Ollama's `/v1/messages`. Thinking comes back as `reasoning` parts; `serverTools` adds Anthropic's server tools |
|
|
592
|
+
| `agui(url)` | AG-UI events | An agent server built with TanStack AI, CopilotKit, LangGraph or Mastra. The agent's state comes back as a `data-agui-state` part |
|
|
593
|
+
| `fromAISDK({ streamText, model })` | The AI SDK, in process | Server rendering, Node scripts, tests against an AI SDK mock model. Not in a browser: the key would be in the page |
|
|
594
|
+
|
|
595
|
+
Each HTTP transport takes `headers` (an object, or a function of the request), `fetch` (another fetch: a test server, a proxy, SSR), `body` (extra JSON fields for every request) and `dangerouslyAllowBrowser`.
|
|
596
|
+
|
|
597
|
+
- `chromePrompt()` sends no tools (the Prompt API has none yet). The Prompt API takes one system prompt, so `instructions` and every `system` message, wherever it is in the conversation, are joined into it. Its `status()` resolves with `'available'`, `'downloadable'`, `'downloading'` or `'unavailable'`; a component reads it through [`driverFromAsync`](https://sygnal.js.org/guide/custom-drivers/), with no awaiting in `main.js`:
|
|
598
|
+
|
|
599
|
+
```js
|
|
600
|
+
// main.js
|
|
601
|
+
const onDevice = chromePrompt()
|
|
602
|
+
run(App, { LLM: makeChatDriver({ transport: onDevice }), MODEL: driverFromAsync(onDevice.status) })
|
|
603
|
+
|
|
604
|
+
// App.jsx
|
|
605
|
+
App.model = {
|
|
606
|
+
BOOTSTRAP: { MODEL: () => ({ ok: 'MODEL_STATUS' }) },
|
|
607
|
+
MODEL_STATUS: (state, status) => ({ ...state, model: status }), // 'available', 'downloadable', …
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
- `fromAISDK` takes `streamText` (and `Output`, for structured output) from your own `ai` import, so Sygnal never depends on the AI SDK: `fromAISDK({ streamText, Output, model: anthropic('claude-opus-5-5') })`. A request's `model` picks another model: a model id is resolved through the `models` option, a provider or a map (`fromAISDK({ streamText, model: anthropic('claude-opus-5-5'), models: anthropic })`, then `{ model: 'claude-haiku-5-5', ... }` on a request). A model id without `models`, or one `models` doesn't resolve, fails the request: it never falls back to the AI SDK's global provider or to the default model.
|
|
612
|
+
|
|
613
|
+
### Strict schemas
|
|
614
|
+
|
|
615
|
+
OpenAI and Anthropic have a strict mode for tool and output schemas: the model's JSON is guaranteed to match. Strict mode only accepts a subset of JSON Schema (every key required, no extra keys). `openResponses`, `chatCompletions` and `anthropicMessages` take a `strict` option for it:
|
|
616
|
+
|
|
617
|
+
```js
|
|
618
|
+
import { makeChatDriver, openResponses, strictSchemas } from 'sygnal/ai'
|
|
619
|
+
|
|
620
|
+
makeChatDriver({ transport: openResponses({ baseURL: '/api/openai/v1', model: 'gpt-6-luna', strict: strictSchemas }) })
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
`strictSchemas` rewrites each schema to the provider's subset (optional keys become required and nullable; the nulls are dropped again before validation). A schema with no strict form is sent non-strict, with a [SYG675](https://sygnal.js.org/reference/errors/#syg675) warning in development. It is an import, not `strict: true`, so apps that don't use strict mode don't carry it; `strict: true` is a development error ([SYG672](https://sygnal.js.org/reference/errors/#syg672)). Claude's structured output always uses its subset, so without `strict`, `anthropicMessages` still sends the `output` schema in it: keywords Claude doesn't support (`minLength`, `pattern`, `minimum`, `uniqueItems`, …) move into the description, where the model still reads them, and every object gets `additionalProperties: false`. The reply is validated against your original schema. Tool schemas without `strict` go as they are: Claude takes any JSON Schema there.
|
|
624
|
+
|
|
625
|
+
### Writing your own transport
|
|
626
|
+
|
|
627
|
+
A transport is an object with one method, `stream(request, signal)`, that returns an async iterable of events. The driver calls it once per request, with the request as the model sent it. Stop when `signal` aborts; a throw is the request's failure (the `error` action).
|
|
628
|
+
|
|
629
|
+
```js
|
|
630
|
+
// a transport for a server that streams lines of JSON: {"text":"…"}
|
|
631
|
+
export function jsonLines(url) {
|
|
632
|
+
return {
|
|
633
|
+
async *stream(request, signal) {
|
|
634
|
+
const res = await fetch(url, {
|
|
635
|
+
method: 'POST',
|
|
636
|
+
headers: { 'content-type': 'application/json' },
|
|
637
|
+
body: JSON.stringify({ messages: request.messages, instructions: request.instructions }),
|
|
638
|
+
signal,
|
|
639
|
+
})
|
|
640
|
+
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status })
|
|
641
|
+
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
|
|
642
|
+
let buffer = ''
|
|
643
|
+
for (;;) {
|
|
644
|
+
const { value, done } = await reader.read()
|
|
645
|
+
if (done) break
|
|
646
|
+
buffer += value
|
|
647
|
+
const lines = buffer.split('\n')
|
|
648
|
+
buffer = lines.pop()
|
|
649
|
+
for (const line of lines) if (line.trim()) yield { type: 'text', delta: JSON.parse(line).text }
|
|
650
|
+
}
|
|
651
|
+
yield { type: 'finish', reason: 'stop' }
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The events, in order:
|
|
658
|
+
|
|
659
|
+
| Event | Meaning |
|
|
660
|
+
|---|---|
|
|
661
|
+
| `{ type: 'start', id? }` | The message id |
|
|
662
|
+
| `{ type: 'text', delta }` | More text |
|
|
663
|
+
| `{ type: 'reasoning', delta }` | More reasoning |
|
|
664
|
+
| `{ type: 'tool-call', id, name, input }` | A complete tool call (`executed: true` when the server ran it: no `tool` action) |
|
|
665
|
+
| `{ type: 'tool-result', id, output }`, `{ type: 'tool-error', id, error }` | The result of a call the server ran |
|
|
666
|
+
| `{ type: 'data', name, data }` | A `data-<name>` part |
|
|
667
|
+
| `{ type: 'file', mediaType, url }`, `{ type: 'source-url', … }` | A file or source part |
|
|
668
|
+
| `{ type: 'finish', reason?, usage? }` | The end, with the finish reason and usage |
|
|
669
|
+
|
|
670
|
+
Unknown event types are ignored; a known type with the wrong shape is skipped, with a [SYG673](https://sygnal.js.org/reference/errors/#syg673) warning in development. A transport that sends `output` should send its JSON Schema: `outputJsonSchema(request.output)` (from `sygnal/ai`) returns `{ schema, wrapped }`, and the driver parses and validates the reply text.
|
|
671
|
+
|
|
672
|
+
### encodeOpenResponses: scripted replies
|
|
673
|
+
|
|
674
|
+
`encodeOpenResponses(chunks)` returns the Open Responses events a model would stream for a scripted reply: strings for text, and `{ reasoning }`, `{ toolCall: { name, input } }` and `{ finish }` items. This page's demo server uses it, and so can a mock server for end-to-end tests or a demo without a model. In Playwright:
|
|
675
|
+
|
|
676
|
+
```js
|
|
677
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
678
|
+
|
|
679
|
+
const sse = (events) => events.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join('')
|
|
680
|
+
|
|
681
|
+
await page.route('**/v1/responses', (route) => route.fulfill({
|
|
682
|
+
status: 200,
|
|
683
|
+
contentType: 'text/event-stream',
|
|
684
|
+
body: sse(encodeOpenResponses(['Hello ', 'there.'])),
|
|
685
|
+
}))
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
## Related
|
|
689
|
+
|
|
690
|
+
- [Agents](./agent.md): the `chat` behavior, an assistant that operates your components through their `agent` declarations
|
|
691
|
+
- [AI Decisions](./ai-decisions.md): typed answers from decision models, and escalating unsure ones to chat
|
|
692
|
+
- [Recipes](./recipes/overview.md#ai): a support inbox, structured output into a form, on-device summaries
|
|
693
|
+
- [HTTP](./http.md): the fetch driver, whose reply actions the chat driver shares
|