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,245 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Persistence
|
|
3
|
+
|
|
4
|
+
`persist()` saves part of the app's state in the browser's storage and puts it back when the app starts again. You declare it once, on the root component: which keys to keep, under which storage key, and in which version of their shape.
|
|
5
|
+
|
|
6
|
+
```jsx live
|
|
7
|
+
// TodoApp.jsx
|
|
8
|
+
import { ABORT, persist } from 'sygnal'
|
|
9
|
+
|
|
10
|
+
export function TodoApp({ state }) {
|
|
11
|
+
const shown = state.filter === 'open' ? state.todos.filter((todo) => !todo.done) : state.todos
|
|
12
|
+
return (
|
|
13
|
+
<main>
|
|
14
|
+
<label>New todo <input className="draft" value={state.draft} /></label>
|
|
15
|
+
<button className="add">Add</button>
|
|
16
|
+
<button className="show-all">All</button>
|
|
17
|
+
<button className="show-open">Open</button>
|
|
18
|
+
<button className="start-over">Start over</button>
|
|
19
|
+
<ul>{shown.map((todo) => <li>{todo.title}</li>)}</ul>
|
|
20
|
+
</main>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
TodoApp.initialState = { todos: [], filter: 'all', draft: '' }
|
|
25
|
+
|
|
26
|
+
TodoApp.intent = ({ DOM }) => ({
|
|
27
|
+
DRAFT: DOM.input('.draft').value(),
|
|
28
|
+
ADD: DOM.click('.add'),
|
|
29
|
+
SHOW_ALL: DOM.click('.show-all'),
|
|
30
|
+
SHOW_OPEN: DOM.click('.show-open'),
|
|
31
|
+
START_OVER: DOM.click('.start-over'),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
TodoApp.model = {
|
|
35
|
+
DRAFT: (state, draft) => ({ ...state, draft }),
|
|
36
|
+
ADD: (state) => state.draft
|
|
37
|
+
? { ...state, todos: [...state.todos, { title: state.draft, done: false }], draft: '' }
|
|
38
|
+
: ABORT,
|
|
39
|
+
SHOW_ALL: (state) => ({ ...state, filter: 'all' }),
|
|
40
|
+
SHOW_OPEN: (state) => ({ ...state, filter: 'open' }),
|
|
41
|
+
START_OVER: {
|
|
42
|
+
STATE: (state) => ({ ...state, todos: [], filter: 'all' }),
|
|
43
|
+
PERSIST: { clear: true },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Version 1 saved { items: ['milk', ...] }; version 2 saves { todos: [{ title, done }], filter }
|
|
48
|
+
TodoApp.persist = persist({
|
|
49
|
+
key: 'todo-app',
|
|
50
|
+
pick: ['todos', 'filter'],
|
|
51
|
+
version: 2,
|
|
52
|
+
migrate: (old, fromVersion) => fromVersion === 1
|
|
53
|
+
? { todos: old.items.map((title) => ({ title, done: false })), filter: 'all' }
|
|
54
|
+
: undefined,
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Reload the page and the todos and the filter are still there; the half-typed `draft` is not, because it isn't picked. **Start over** empties the list and removes the saved copy.
|
|
59
|
+
|
|
60
|
+
## What is saved
|
|
61
|
+
|
|
62
|
+
The stored value is JSON, `{ version, state }`, under `key`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{"version":2,"state":{"todos":[{"title":"milk","done":false}],"filter":"all"}}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`state` has the top-level keys listed in `pick`, or, with `omit` instead, every key except those. [Calculated fields](https://sygnal.js.org/guide/calculated-fields/) are never saved: they are computed again from the restored state. Pick what has to survive a reload (the user's data, a chosen view) and leave out what doesn't (drafts, loading flags, server data you fetch again, an undo history). A [behavior](./behaviors.md#persisted-state-and-undo-steps) of the root component whose slice is UI state (`persist: false`, as [`sortable`](./drag-and-drop.md)'s drag state) is never saved or restored, even when `pick` names it. A sub-component's or Collection item's behavior slice is part of the data it lives in and is saved with it.
|
|
69
|
+
|
|
70
|
+
Values must survive `JSON.stringify`: plain objects, arrays, strings, numbers, booleans and `null`. A `Date` comes back as a string, and a `Map` or `Set` as `{}`.
|
|
71
|
+
|
|
72
|
+
### A plain format
|
|
73
|
+
|
|
74
|
+
When something else reads or writes the same entry (an older version of the app, another script, a spec that fixes the format), `format: 'plain'` stores the picked keys themselves, with no envelope:
|
|
75
|
+
|
|
76
|
+
```jsx
|
|
77
|
+
Note.persist = persist({ key: 'note-draft', pick: ['title', 'body'], format: 'plain' })
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{"title":"Groceries","body":"milk, eggs"}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A plain entry has no version, so `version` and `migrate` don't apply: TypeScript rejects them with `format: 'plain'`, and they are ignored at runtime. A stored value that isn't an object (an array, a string, a number) is ignored, and the app starts from `initialState`. Everything else (`pick`/`omit`, `sync`, `storage`, clearing, the restore and the debounced writes) works as with the default `format: 'versioned'`.
|
|
85
|
+
|
|
86
|
+
## When it is restored
|
|
87
|
+
|
|
88
|
+
When the app starts, the root component reads the stored entry synchronously, before its first state, and merges the saved keys into `initialState`. The first render already shows the saved todos, and the `INITIALIZE` action carries them: there is no separate "load" step and no flash of the empty list.
|
|
89
|
+
|
|
90
|
+
`persist` works on the **root component** only, the one passed to `run()`. It saves and restores the app's whole state tree, and a sub-component's state is a slice of it: to save a child's state, pick the key the root keeps it under. On any other component the static is ignored, which is [SYG224](https://sygnal.js.org/reference/errors/#syg224) in development and in `sygnal-check`.
|
|
91
|
+
|
|
92
|
+
:::note[Where persist works]
|
|
93
|
+
On any component that is its app's root:
|
|
94
|
+
|
|
95
|
+
- the component you pass to `run()`
|
|
96
|
+
- the component you pass to [`renderComponent`](#testing) in tests
|
|
97
|
+
- an [Astro](https://sygnal.js.org/integration/astro/) island (each island is its own app)
|
|
98
|
+
- a [Vike](https://sygnal.js.org/integration/vike/) page with no Layout or Wrapper
|
|
99
|
+
|
|
100
|
+
Not (yet) on a Vike page, Layout or Wrapper rendered in a Layout/Wrapper shell: the shell is the root there, so `persist` is ignored and SYG224 says so. Save that state yourself: write it with [`STATE.watch`](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch) and read it back in `BOOTSTRAP`.
|
|
101
|
+
:::
|
|
102
|
+
|
|
103
|
+
## When it is saved
|
|
104
|
+
|
|
105
|
+
A write happens after the state stops changing for `debounceMs` (100 ms by default), so typing or a burst of actions makes one write. A pending write is also made at once when the page is hidden for good (the `pagehide` event: closing the tab, navigating away) and when the app is disposed. Nothing is written while the picked keys are unchanged, and the initial state is not written back.
|
|
106
|
+
|
|
107
|
+
## Versions and migrate
|
|
108
|
+
|
|
109
|
+
`version` (default 1) is saved with the state (not with `format: 'plain'`). When the stored entry has another version, `migrate(old, fromVersion)` turns the old `state` into this version's keys; the result is merged into `initialState` like any restore and saved under the new version on the next write. When `migrate` returns nothing (`undefined` or `null`), or there is no `migrate`, the stored entry is ignored and the app starts from `initialState`.
|
|
110
|
+
|
|
111
|
+
Bump `version` whenever the shape of a picked key changes, and keep the `migrate` branches for the versions your users may still have.
|
|
112
|
+
|
|
113
|
+
## Clearing
|
|
114
|
+
|
|
115
|
+
`PERSIST: { clear: true }` in a model entry removes the stored copy. Like other sinks it can be a function of `(state, data)` that returns the command, or `ABORT` to do nothing:
|
|
116
|
+
|
|
117
|
+
```jsx
|
|
118
|
+
Account.model = {
|
|
119
|
+
LOG_OUT: {
|
|
120
|
+
STATE: (state) => ({ ...state, user: null, cart: [] }),
|
|
121
|
+
PERSIST: { clear: true },
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The state the same action produces isn't saved; the next change is. `PERSIST` needs no driver.
|
|
127
|
+
|
|
128
|
+
## Several tabs: sync
|
|
129
|
+
|
|
130
|
+
With `sync: true`, a write made in another tab (or window) of the same site is applied in this one, as a built-in `RESTORE` action whose data is the saved keys. The default `RESTORE` merges them into the state; a `RESTORE` entry in your model replaces it:
|
|
131
|
+
|
|
132
|
+
```jsx
|
|
133
|
+
TodoApp.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'], version: 2, migrate, sync: true })
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Clearing in another tab doesn't reset this one: it keeps its state and saves it again on its next change.
|
|
137
|
+
|
|
138
|
+
## Storage
|
|
139
|
+
|
|
140
|
+
`storage` is `'local'` (`localStorage`, the default), `'session'` (`sessionStorage`: one tab, until it closes) or an object with synchronous `getItem`, `setItem` and `removeItem`. With `sync`, an object can also have `subscribe(fn)`: call `fn(key, newValue)` when another writer changes a key, and return a function that unsubscribes.
|
|
141
|
+
|
|
142
|
+
```jsx
|
|
143
|
+
const memory = new Map()
|
|
144
|
+
|
|
145
|
+
export const memoryStorage = {
|
|
146
|
+
getItem: (key) => memory.get(key) ?? null,
|
|
147
|
+
setItem: (key, value) => { memory.set(key, value) },
|
|
148
|
+
removeItem: (key) => { memory.delete(key) },
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Asynchronous storages (IndexedDB, a server) don't fit the synchronous restore. Load from those with a request in `BOOTSTRAP` and save with [`STATE.watch`](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch) instead.
|
|
153
|
+
|
|
154
|
+
During server rendering nothing is read or written: `renderToString` runs views only.
|
|
155
|
+
|
|
156
|
+
## Server rendering: hydrate
|
|
157
|
+
|
|
158
|
+
When the client starts from server-rendered HTML (the [`hydrateState`](./ssr.md) state), restoring before the first render would make that render differ from the server's markup. So when the app hydrates, the first render uses the server's state, and the saved keys follow in a `RESTORE` action once it is on the page. The app hydrates when:
|
|
159
|
+
|
|
160
|
+
- `run()`'s mount point starts with markup from [`renderToString()`](./ssr.md): its root element carries a `data-sygnal-ssr` attribute, which the first client render removes. Other markup in the mount point, such as a client-only app's loading placeholder, isn't server markup: the saved keys are part of the first render
|
|
161
|
+
- an [Astro](https://sygnal.js.org/integration/astro/) island was rendered on the server (not `client:only`)
|
|
162
|
+
- a [Vike](https://sygnal.js.org/integration/vike/) page hydrates its server HTML (not a client-side navigation)
|
|
163
|
+
|
|
164
|
+
```jsx
|
|
165
|
+
App.persist = persist({ key: 'app', pick: ['theme'] })
|
|
166
|
+
App.initialState = window.__SYGNAL_STATE__ || App.initialState
|
|
167
|
+
|
|
168
|
+
run(App, {}, { mountPoint: '#app' })
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The `hydrate` option overrides the detection: `hydrate: true` always restores after the first render, `hydrate: false` always before it. Use `hydrate: true` when the server's HTML doesn't come from `renderToString()` (another renderer, or markup you post-process without the attribute).
|
|
172
|
+
|
|
173
|
+
## With undo
|
|
174
|
+
|
|
175
|
+
Keep an [undo history](./undo.md) out of the saved state: pick the document, not `history`. After a reload the document is back and the history starts empty.
|
|
176
|
+
|
|
177
|
+
## Diagnostics
|
|
178
|
+
|
|
179
|
+
| Code | When |
|
|
180
|
+
|---|---|
|
|
181
|
+
| [SYG642](https://sygnal.js.org/reference/errors/#syg642) (warning) | The stored entry isn't JSON, `migrate` threw, or a write failed (the storage is full or blocked). The app continues: from `initialState` after a failed restore, unsaved after a failed write (retried at the next state change). Printed in production too, once per kind (restore, save, clear): a storage that refuses every write warns once, and again only after a save has worked |
|
|
182
|
+
| [SYG223](https://sygnal.js.org/reference/errors/#syg223) (warning) | A `pick` or `omit` key that isn't a key of `initialState` (a typo) |
|
|
183
|
+
| [SYG224](https://sygnal.js.org/reference/errors/#syg224) (error) | `persist` on a component that isn't the root |
|
|
184
|
+
|
|
185
|
+
## Testing
|
|
186
|
+
|
|
187
|
+
`renderComponent` gives a persisting root a fake storage. The `storage` option fills it (key to stored entry, or a raw string), `t.storage(key)` reads an entry, and `t.settle()` makes the pending writes:
|
|
188
|
+
|
|
189
|
+
```jsx
|
|
190
|
+
// TodoApp.test.jsx
|
|
191
|
+
import { it, expect } from 'vitest'
|
|
192
|
+
import { renderComponent } from 'sygnal'
|
|
193
|
+
import { TodoApp } from './TodoApp.jsx'
|
|
194
|
+
|
|
195
|
+
it('restores version 1 todos and saves version 2', async () => {
|
|
196
|
+
const t = renderComponent(TodoApp, {
|
|
197
|
+
storage: { 'todo-app': { version: 1, state: { items: ['milk'] } } },
|
|
198
|
+
})
|
|
199
|
+
await t.ready()
|
|
200
|
+
expect(t.state.todos).toEqual([{ title: 'milk', done: false }])
|
|
201
|
+
|
|
202
|
+
t.simulateAction('DRAFT', 'eggs')
|
|
203
|
+
t.simulateAction('ADD')
|
|
204
|
+
await t.settle()
|
|
205
|
+
expect(t.storage('todo-app')).toEqual({
|
|
206
|
+
version: 2,
|
|
207
|
+
state: { todos: [{ title: 'milk', done: false }, { title: 'eggs', done: false }], filter: 'all' },
|
|
208
|
+
})
|
|
209
|
+
t.dispose()
|
|
210
|
+
})
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The object you pass is used as the storage, not copied: writes land in it, and two `renderComponent` calls given the same object share it, so `sync: true` can be tested with two instances. The fake serves `'local'` and `'session'` alike; a component whose `storage` is an object uses that object.
|
|
214
|
+
|
|
215
|
+
With `format: 'plain'` the entries are the stored keys themselves, both in the `storage` option and from `t.storage(key)`:
|
|
216
|
+
|
|
217
|
+
```jsx
|
|
218
|
+
it('saves the draft as { title, body }', async () => {
|
|
219
|
+
const t = renderComponent(Note, { storage: { 'note-draft': { title: 'Groceries', body: '' } } })
|
|
220
|
+
await t.ready()
|
|
221
|
+
expect(t.state.title).toBe('Groceries')
|
|
222
|
+
|
|
223
|
+
t.simulateAction('BODY', 'milk')
|
|
224
|
+
await t.settle()
|
|
225
|
+
expect(t.storage('note-draft')).toEqual({ title: 'Groceries', body: 'milk' })
|
|
226
|
+
t.dispose()
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Options
|
|
231
|
+
|
|
232
|
+
| Option | Default | |
|
|
233
|
+
|---|---|---|
|
|
234
|
+
| `key` | (required) | The storage key |
|
|
235
|
+
| `pick` | all keys | The top-level state keys to save |
|
|
236
|
+
| `omit` | `[]` | The top-level state keys not to save (instead of `pick`) |
|
|
237
|
+
| `version` | `1` | Saved with the state; another stored version goes through `migrate` |
|
|
238
|
+
| `migrate` | none | `(old, fromVersion) => keys` for a stored entry of another version; nothing discards it |
|
|
239
|
+
| `format` | `'versioned'` | `'versioned'` stores `{ version, state }`; `'plain'` stores the picked keys themselves (no `version` / `migrate`) |
|
|
240
|
+
| `storage` | `'local'` | `'local'`, `'session'` or a synchronous `{ getItem, setItem, removeItem, subscribe? }` |
|
|
241
|
+
| `sync` | `false` | Apply other tabs' writes (a `RESTORE` action) |
|
|
242
|
+
| `hydrate` | detected | Restore after the first render (in `RESTORE`): `true` / `false` override the detection of server-rendered HTML |
|
|
243
|
+
| `debounceMs` | `100` | Wait this long without a state change before writing |
|
|
244
|
+
|
|
245
|
+
In TypeScript, `pick` and `omit` are checked against the root component's state keys.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# Structured Output into a Form
|
|
3
|
+
|
|
4
|
+
A contact form with a shortcut: paste an email signature, and a [chat model](../ai-chat.md) fills in the fields. The model answers with [structured output](../ai-chat.md#structured-output), validated against a schema, and each field it found goes into the [`form` behavior](../forms.md) as if the user had typed it. Nothing is saved until the user checks the fields and submits, with the form's own validation.
|
|
5
|
+
|
|
6
|
+
The pieces:
|
|
7
|
+
|
|
8
|
+
- **Two schemas.** The extraction schema says what the model may answer: every field can be `null` (not in the text). The form's schema says what the user may submit.
|
|
9
|
+
- **The `ok` action fills the form** with one `form.SET` holding the fields the model found, so those fields are validated and marked as changed like typed ones.
|
|
10
|
+
- **The model never submits.** The user does.
|
|
11
|
+
|
|
12
|
+
## The form
|
|
13
|
+
|
|
14
|
+
In this demo, the signature is read as soon as the demo shows. Paste another one and fill again; or edit a field and submit.
|
|
15
|
+
|
|
16
|
+
```js live-server
|
|
17
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
18
|
+
|
|
19
|
+
let nextId = 1
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
// the chat model: reads a signature (a real model reads anything)
|
|
23
|
+
'POST /v1/responses': ({ json }) => {
|
|
24
|
+
const text = String(json.input.at(-1).content)
|
|
25
|
+
const contact = {
|
|
26
|
+
name: text.split('\n')[0].trim() || null,
|
|
27
|
+
email: text.match(/[\w.+-]+@[\w-]+\.[\w.]+/)?.[0] ?? null,
|
|
28
|
+
company: text.split('\n')[1]?.split('|')[1]?.trim() || null,
|
|
29
|
+
phone: text.match(/\+?[\d ()-]{7,}\d/)?.[0].trim() ?? null,
|
|
30
|
+
}
|
|
31
|
+
return { sse: encodeOpenResponses(JSON.stringify(contact).match(/.{1,20}/g)), delayMs: 300, chunkMs: 40 }
|
|
32
|
+
},
|
|
33
|
+
'POST /api/contacts': ({ json }) => ({ status: 201, json: { id: nextId++, ...json } }),
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```jsx live live-height=420
|
|
38
|
+
import { z } from 'zod'
|
|
39
|
+
import { ABORT, form } from 'sygnal'
|
|
40
|
+
|
|
41
|
+
// what the model may answer: null for "not in the text"
|
|
42
|
+
const Extracted = z.object({
|
|
43
|
+
name: z.string().nullable(),
|
|
44
|
+
email: z.string().nullable(),
|
|
45
|
+
company: z.string().nullable(),
|
|
46
|
+
phone: z.string().nullable(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// what the user may submit
|
|
50
|
+
const contactSchema = z.object({
|
|
51
|
+
name: z.string().trim().min(1, 'Enter a name'),
|
|
52
|
+
email: z.string().trim().email('Enter a valid email address'),
|
|
53
|
+
company: z.string().trim(),
|
|
54
|
+
phone: z.string().trim(),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const SIGNATURE = 'Dana Whitfield\nHead of Operations | Northwind Traders\ndana.whitfield@northwind.example\n+1 (555) 014-2290'
|
|
58
|
+
|
|
59
|
+
const read = (text) => ({
|
|
60
|
+
instructions: 'Extract the contact details from the text. Use null for anything that is not there.',
|
|
61
|
+
messages: [{ role: 'user', content: text }],
|
|
62
|
+
output: Extracted,
|
|
63
|
+
ok: 'EXTRACTED',
|
|
64
|
+
error: 'NOT_EXTRACTED',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
function Field({ field, label, id }) {
|
|
68
|
+
return (
|
|
69
|
+
<p>
|
|
70
|
+
<label for={id}>{label}</label>{' '}
|
|
71
|
+
<input id={id} name={field.name} value={field.value} aria-invalid={field.invalid} aria-describedby={`${id}-error`} />
|
|
72
|
+
<span id={`${id}-error`}> {field.error}</span>
|
|
73
|
+
</p>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function NewContact({ state, uid }) {
|
|
78
|
+
const f = state.form.fields
|
|
79
|
+
return (
|
|
80
|
+
<section className="new-contact">
|
|
81
|
+
<form className="paste">
|
|
82
|
+
<label for={uid('paste')}>Paste a signature</label>
|
|
83
|
+
<textarea id={uid('paste')} className="pasted" rows="4" value={state.pasted} />
|
|
84
|
+
<button type="submit" disabled={state.reading}>{state.reading ? 'Reading…' : 'Fill in the form'}</button>
|
|
85
|
+
<span role="status"> {state.note}</span>
|
|
86
|
+
</form>
|
|
87
|
+
<form className="contact" noValidate>
|
|
88
|
+
<Field field={f.name} label="Name" id={uid('name')} />
|
|
89
|
+
<Field field={f.email} label="Email" id={uid('email')} />
|
|
90
|
+
<Field field={f.company} label="Company" id={uid('company')} />
|
|
91
|
+
<Field field={f.phone} label="Phone" id={uid('phone')} />
|
|
92
|
+
<button type="submit" disabled={state.form.submitting}>Save contact</button>
|
|
93
|
+
<span className="saved"> {state.savedId ? `Saved as contact ${state.savedId}.` : ''}</span>
|
|
94
|
+
</form>
|
|
95
|
+
</section>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
NewContact.initialState = { pasted: SIGNATURE, reading: false, note: '', savedId: null }
|
|
100
|
+
|
|
101
|
+
NewContact.uses = {
|
|
102
|
+
form: form(contactSchema, { form: '.contact', values: { name: '', email: '', company: '', phone: '' }, submit: 'SAVE' }),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
NewContact.intent = ({ DOM }) => ({
|
|
106
|
+
PASTE: DOM.input('.pasted').value(),
|
|
107
|
+
FILL: DOM.select('.paste').events('submit', { preventDefault: true }),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
NewContact.model = {
|
|
111
|
+
BOOTSTRAP: {
|
|
112
|
+
STATE: (state) => ({ ...state, reading: true }),
|
|
113
|
+
LLM: (state) => read(state.pasted),
|
|
114
|
+
},
|
|
115
|
+
PASTE: (state, pasted) => ({ ...state, pasted }),
|
|
116
|
+
FILL: {
|
|
117
|
+
STATE: (state) => (state.pasted.trim() ? { ...state, reading: true, note: '' } : ABORT),
|
|
118
|
+
LLM: (state) => (state.pasted.trim() ? read(state.pasted) : ABORT),
|
|
119
|
+
},
|
|
120
|
+
EXTRACTED: {
|
|
121
|
+
STATE: (state, { value }) => ({ ...state, reading: false, note: value.name === null ? 'No name found: add it, then save.' : 'Check the fields, then save.' }),
|
|
122
|
+
// the fields the model found, as if the user had typed them
|
|
123
|
+
EFFECT: (state, { value }, next) => {
|
|
124
|
+
next('form.SET', { values: Object.fromEntries(Object.entries(value).filter(([, text]) => text !== null)) })
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
NOT_EXTRACTED: (state) => ({ ...state, reading: false, note: 'Could not read that. Fill in the form by hand.' }),
|
|
128
|
+
SAVE: { HTTP: (state, values) => ({ url: '/api/contacts', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
|
|
129
|
+
'form.DONE': (state, contact) => ({ ...state, savedId: contact.id }),
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```css live
|
|
134
|
+
.new-contact textarea { width: 100%; }
|
|
135
|
+
.new-contact form { margin-bottom: 1rem; }
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- **The extraction schema is loose, the form's is strict.** The model may find no email; the form still requires one. A wrong value from the model (an email without `@`) shows the form's error once the user leaves the field or submits, as for anything typed.
|
|
139
|
+
- **`form.SET`, not `form.RESET`**: a reset makes the values the form's starting point, so they would count as unchanged, and it would clear what the user had already typed in the fields the model didn't find. `form.SET` changes only the fields it names, in one validation.
|
|
140
|
+
- **`null`, not optional.** Providers' strict modes require every key, so "not found" is a `null` value. `.nullable()` also tells the model plainly that it may say so.
|
|
141
|
+
- **Keep the user in the loop.** The model's answer is a suggestion: the form shows it, the user saves it.
|
|
142
|
+
|
|
143
|
+
## Testing
|
|
144
|
+
|
|
145
|
+
`t.respond('LLM', object)` sends the object as the reply's JSON, so `EXTRACTED` gets it as `value`:
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import { it, expect } from 'vitest'
|
|
149
|
+
import { renderComponent } from 'sygnal'
|
|
150
|
+
import NewContact from './NewContact.jsx'
|
|
151
|
+
|
|
152
|
+
it('fills the fields the model found, and saves only on submit', async () => {
|
|
153
|
+
const t = renderComponent(NewContact)
|
|
154
|
+
await t.respond('LLM', { name: 'Dana Whitfield', email: 'dana@northwind.example', company: null, phone: null })
|
|
155
|
+
expect(t.state.form.values).toEqual({ name: 'Dana Whitfield', email: 'dana@northwind.example', company: '', phone: '' })
|
|
156
|
+
expect(t.requests('HTTP')).toEqual([])
|
|
157
|
+
|
|
158
|
+
t.simulateEvent('.contact', 'submit')
|
|
159
|
+
await t.respond('HTTP', { id: 7 })
|
|
160
|
+
expect(t.state.savedId).toBe(7)
|
|
161
|
+
t.dispose()
|
|
162
|
+
})
|
|
163
|
+
```
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/recipes by scripts/copy-guides.mjs; online: https://sygnal.js.org/recipes/ -->
|
|
2
|
+
# On-device Summaries
|
|
3
|
+
|
|
4
|
+
Chrome can run a small language model on the user's device, through the [Prompt API](https://developer.chrome.com/docs/ai/prompt-api) (`LanguageModel`). The `chromePrompt()` transport from `sygnal/ai` puts it behind the [chat driver](../ai-chat.md), so a component asks it for a summary exactly as it would ask a hosted model. Nothing leaves the device: no server, no key, no cost per request.
|
|
5
|
+
|
|
6
|
+
Not every visitor has it: other browsers don't, and Chrome needs a capable device and a one-time model download. So the feature must **degrade**: where the model is unavailable, the component shows a plain excerpt instead.
|
|
7
|
+
|
|
8
|
+
## main.js
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { run } from 'sygnal'
|
|
12
|
+
import { makeChatDriver, chromePrompt } from 'sygnal/ai'
|
|
13
|
+
import Article from './Article.jsx'
|
|
14
|
+
|
|
15
|
+
run(Article, { LLM: makeChatDriver({ transport: chromePrompt() }) })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Where the browser has no `LanguageModel`, or the device can't run it, every request fails at once with an Error whose `status` is `'unavailable'`. The component's `error` action handles that like any failure, so `main.js` needs no check.
|
|
19
|
+
|
|
20
|
+
To use a hosted model where the on-device one is missing, choose the transport before `run()` with `status()`:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { run } from 'sygnal'
|
|
24
|
+
import { makeChatDriver, chromePrompt, uiMessageStream } from 'sygnal/ai'
|
|
25
|
+
import Article from './Article.jsx'
|
|
26
|
+
|
|
27
|
+
const onDevice = chromePrompt()
|
|
28
|
+
const transport = (await onDevice.status()) === 'unavailable' ? uiMessageStream('/api/summarize') : onDevice
|
|
29
|
+
|
|
30
|
+
run(Article, { LLM: makeChatDriver({ transport }) })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`status()` resolves with `'available'`, `'downloadable'`, `'downloading'` or `'unavailable'`. A `'downloadable'` model is downloaded by its first request, which must come from a user action (a click), so summarise on a click rather than on load. `chromePrompt({ monitor })` gets the download's progress events.
|
|
34
|
+
|
|
35
|
+
A component can show the status too, for example to say "Summaries need a one-time download" before the first click. A promise can't be read in a view, so give `status` to [`driverFromAsync`](https://sygnal.js.org/guide/custom-drivers/) and ask for it like any request:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
import { run, driverFromAsync } from 'sygnal'
|
|
39
|
+
import { makeChatDriver, chromePrompt } from 'sygnal/ai'
|
|
40
|
+
import Article from './Article.jsx'
|
|
41
|
+
|
|
42
|
+
const onDevice = chromePrompt()
|
|
43
|
+
run(Article, { LLM: makeChatDriver({ transport: onDevice }), MODEL: driverFromAsync(onDevice.status) })
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
Article.model = {
|
|
48
|
+
BOOTSTRAP: { MODEL: () => ({ ok: 'MODEL_STATUS' }) },
|
|
49
|
+
MODEL_STATUS: (state, status) => ({ ...state, model: status }),
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In a test, `await t.respond('MODEL', 'downloadable')` answers it.
|
|
54
|
+
|
|
55
|
+
## The component
|
|
56
|
+
|
|
57
|
+
The component is the same whatever the transport. In this demo it runs on the page's demo model (as every demo here does), and asks for a summary as soon as it shows. In your app, the same code runs on the device.
|
|
58
|
+
|
|
59
|
+
```js live-server
|
|
60
|
+
import { encodeOpenResponses } from 'sygnal/ai'
|
|
61
|
+
|
|
62
|
+
export default {
|
|
63
|
+
'POST /v1/responses': () => ({
|
|
64
|
+
sse: encodeOpenResponses(['Octopuses ', 'have three hearts ', 'and blue blood, ', 'and each arm ', 'can act ', 'on its own.']),
|
|
65
|
+
delayMs: 300,
|
|
66
|
+
chunkMs: 80,
|
|
67
|
+
}),
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```jsx live live-height=260
|
|
72
|
+
import { ABORT } from 'sygnal'
|
|
73
|
+
|
|
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.'
|
|
75
|
+
|
|
76
|
+
// the fallback: the first two sentences
|
|
77
|
+
const excerpt = (text) => text.match(/[^.!?]+[.!?]+/g)?.slice(0, 2).join(' ') ?? text
|
|
78
|
+
|
|
79
|
+
const summarize = (text) => ({
|
|
80
|
+
instructions: 'Summarise the text in one sentence of at most 25 words.',
|
|
81
|
+
messages: [{ role: 'user', content: text }],
|
|
82
|
+
key: 'summary',
|
|
83
|
+
delta: 'SUMMARY',
|
|
84
|
+
ok: 'SUMMARIZED',
|
|
85
|
+
error: 'NO_SUMMARY',
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
function Article({ state }) {
|
|
89
|
+
return (
|
|
90
|
+
<article className="article">
|
|
91
|
+
<p>{state.text}</p>
|
|
92
|
+
<button type="button" className="summarize" disabled={state.status === 'busy'}>Summarise</button>
|
|
93
|
+
<p aria-live="polite">
|
|
94
|
+
{state.status === 'busy' && !state.summary && 'Summarising…'}
|
|
95
|
+
{state.summary && <><b>Summary:</b> {state.summary}</>}
|
|
96
|
+
{state.status === 'fallback' && <><b>Excerpt:</b> {excerpt(state.text)} <i>(summaries need Chrome's built-in model)</i></>}
|
|
97
|
+
</p>
|
|
98
|
+
</article>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
Article.initialState = { text: TEXT, summary: '', status: 'ready' }
|
|
103
|
+
|
|
104
|
+
Article.intent = ({ DOM }) => ({
|
|
105
|
+
SUMMARIZE: DOM.click('.summarize'),
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
Article.model = {
|
|
109
|
+
BOOTSTRAP: {
|
|
110
|
+
STATE: (state) => ({ ...state, status: 'busy' }),
|
|
111
|
+
LLM: (state) => summarize(state.text),
|
|
112
|
+
},
|
|
113
|
+
SUMMARIZE: {
|
|
114
|
+
STATE: (state) => (state.status === 'busy' ? ABORT : { ...state, summary: '', status: 'busy' }),
|
|
115
|
+
LLM: (state) => (state.status === 'busy' ? ABORT : summarize(state.text)),
|
|
116
|
+
},
|
|
117
|
+
SUMMARY: (state, { text }) => ({ ...state, summary: text }),
|
|
118
|
+
SUMMARIZED: (state, { text }) => ({ ...state, summary: text, status: 'ready' }),
|
|
119
|
+
// no model on this device: show the excerpt; any other failure: let the user try again
|
|
120
|
+
NO_SUMMARY: (state, { error }) => ({ ...state, summary: '', status: error.status === 'unavailable' ? 'fallback' : 'ready' }),
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
- **The summary streams in** through `SUMMARY`, and `SUMMARIZED` ends the busy state.
|
|
125
|
+
- **Degrade, don't hide.** The excerpt is computed in the view from the text, so it needs no model. Where a summary isn't essential, an excerpt is an honest fallback.
|
|
126
|
+
- **Keep prompts short and in `instructions`.** The on-device model is small and has a small context window. Long articles need splitting, or a hosted model.
|
|
127
|
+
|
|
128
|
+
## What the on-device model can't do
|
|
129
|
+
|
|
130
|
+
- **No tools.** The Prompt API has no tool calling yet, so `chromePrompt()` doesn't send `tools`. An assistant that operates the app needs a hosted model.
|
|
131
|
+
- **One system prompt.** The Prompt API takes system text only at the start of a session, so `instructions` and every `system` message (wherever it is in the conversation) are joined into that one prompt.
|
|
132
|
+
- **Structured output works.** `output` becomes the Prompt API's `responseConstraint`, and `ok` gets the validated `value`, as with any transport.
|
|
133
|
+
|
|
134
|
+
## Testing
|
|
135
|
+
|
|
136
|
+
The LLM fake doesn't care which transport `main.js` uses. Fail a request with the transport's error to test the fallback:
|
|
137
|
+
|
|
138
|
+
```js
|
|
139
|
+
import { it, expect } from 'vitest'
|
|
140
|
+
import { renderComponent } from 'sygnal'
|
|
141
|
+
import Article from './Article.jsx'
|
|
142
|
+
|
|
143
|
+
it('summarises, and falls back to an excerpt without the model', async () => {
|
|
144
|
+
const t = renderComponent(Article)
|
|
145
|
+
await t.respond('LLM', 'Octopuses have three hearts.')
|
|
146
|
+
expect(t.html()).toContain('Summary:')
|
|
147
|
+
|
|
148
|
+
t.simulateEvent('.summarize', 'click')
|
|
149
|
+
await t.fail('LLM', Object.assign(new Error('the on-device model is unavailable'), { status: 'unavailable' }))
|
|
150
|
+
expect(t.html()).toContain('Excerpt:')
|
|
151
|
+
t.dispose()
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
To test the transport itself, `chromePrompt({ LanguageModel })` takes a stub of the Prompt API instead of the browser's.
|