@datalayer/agent-runtimes 1.3.62 → 1.3.64

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.
Files changed (64) hide show
  1. package/lib/chat/ChatFloating.d.ts +9 -1
  2. package/lib/chat/ChatFloating.js +69 -18
  3. package/lib/chat/assistant/AssistantStage.d.ts +14 -1
  4. package/lib/chat/assistant/AssistantStage.js +53 -1
  5. package/lib/chat/assistant/SpriteCharacter.js +1 -1
  6. package/lib/chat/assistant/characters.js +1 -5
  7. package/lib/chat/assistant/state.d.ts +7 -1
  8. package/lib/chat/assistant/state.js +3 -2
  9. package/lib/chat/base/ChatBase.js +32 -4
  10. package/lib/chat/messages/ChatMessageList.d.ts +0 -6
  11. package/lib/chat/messages/ChatMessageList.js +8 -2
  12. package/lib/config/AgentConfiguration.js +0 -6
  13. package/lib/examples/AgentA2ATeamExample.js +19 -2
  14. package/lib/examples/ChatAssistantExample.d.ts +3 -1
  15. package/lib/examples/ChatAssistantExample.js +75 -7
  16. package/lib/examples/ChatAssistantGalleryExample.d.ts +3 -2
  17. package/lib/examples/ChatAssistantGalleryExample.js +11 -6
  18. package/lib/examples/DecksAgent.js +4 -2
  19. package/lib/examples/LoopShellExample.js +4 -2
  20. package/lib/examples/VoiceChatExample.d.ts +20 -0
  21. package/lib/examples/VoiceChatExample.js +64 -0
  22. package/lib/examples/example-selector.js +1 -0
  23. package/lib/examples/main.js +12 -7
  24. package/lib/examples/utils/clippyJsCharacters.d.ts +18 -0
  25. package/lib/examples/utils/clippyJsCharacters.js +129 -0
  26. package/lib/loop/apps/appspec.d.ts +3 -1
  27. package/lib/loop/apps/appspec.js +31 -0
  28. package/lib/loop/apps/checks.js +1 -1
  29. package/lib/protocols/VercelAIAdapter.js +5 -0
  30. package/lib/specs/apps.js +112 -0
  31. package/lib/specs/appspecSchema.js +65 -0
  32. package/lib/specs/index.d.ts +1 -0
  33. package/lib/specs/index.js +1 -0
  34. package/lib/specs/voices.d.ts +72 -0
  35. package/lib/specs/voices.js +344 -0
  36. package/lib/types/agents.d.ts +1 -1
  37. package/lib/types/agentspecs.d.ts +16 -0
  38. package/lib/types/chat.d.ts +11 -0
  39. package/lib/voice/VoiceInput.d.ts +35 -0
  40. package/lib/voice/VoiceInput.js +233 -0
  41. package/lib/voice/capture.d.ts +30 -0
  42. package/lib/voice/capture.js +113 -0
  43. package/lib/voice/consent.d.ts +6 -0
  44. package/lib/voice/consent.js +34 -0
  45. package/lib/voice/hearing.d.ts +37 -0
  46. package/lib/voice/hearing.js +168 -0
  47. package/lib/voice/index.d.ts +47 -0
  48. package/lib/voice/index.js +13 -0
  49. package/lib/voice/pinned.d.ts +28 -0
  50. package/lib/voice/pinned.js +75 -0
  51. package/lib/voice/sentences.d.ts +26 -0
  52. package/lib/voice/sentences.js +71 -0
  53. package/lib/voice/speaker.d.ts +66 -0
  54. package/lib/voice/speaker.js +168 -0
  55. package/lib/voice/types.d.ts +92 -0
  56. package/lib/voice/types.js +5 -0
  57. package/lib/voice/useSpokenAnswers.d.ts +12 -0
  58. package/lib/voice/useSpokenAnswers.js +90 -0
  59. package/package.json +7 -3
  60. package/scripts/codegen/generate_voices.py +279 -0
  61. package/scripts/voice/measure.py +244 -0
  62. package/scripts/voice/pin_store.py +159 -0
  63. package/scripts/voice/serve_store.py +70 -0
  64. package/scripts/voice/transcribe.mjs +58 -0
@@ -10,9 +10,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  *
11
11
  * The characters offered are what the enabled plugins contribute to
12
12
  * `loop.assistant.character` (T-24): Datalayer's four, and an owl from a
13
- * small example plugin (`utils/owlCharacterPlugin`).
13
+ * small example plugin (`utils/owlCharacterPlugin`). Pixel, the test sprite,
14
+ * is read through the clippy.js reader, as a character a person brings is,
15
+ * and so are the characters clippy.js publishes, fetched when picked.
14
16
  */
15
- import { useState } from 'react';
17
+ import { useEffect, useRef, useState } from 'react';
16
18
  import { buildReactorFromPlugins } from '@datalayer/reactor';
17
19
  import { Button, Heading, Text } from '@primer/react';
18
20
  import { Box } from '@datalayer/primer-addons';
@@ -20,6 +22,8 @@ import { ThemedProvider } from './utils/themedProvider';
20
22
  import { ChatFloating } from '../chat';
21
23
  import { AssistantCharactersPlugin, assistantCharacterNamed, assistantCharactersOf, } from '../loop/plugins/assistant-characters';
22
24
  import { OwlCharacterPlugin } from './utils/owlCharacterPlugin';
25
+ import { readTestSpriteCharacter } from './utils/testSpriteCharacter';
26
+ import { CLIPPY_JS_CHARACTERS, CLIPPY_JS_VERSION, readClippyJsCharacter, } from './utils/clippyJsCharacters';
23
27
  import { readAcsCharacter, readClippyCharacter, } from '../chat/assistant/formats';
24
28
  /**
25
29
  * The agent-runtimes server the assistant talks to: `?agentRuntimesUrl=`
@@ -69,10 +73,72 @@ const ChatAssistantExample = () => {
69
73
  ? assistantCharacterNamed(reactor, character)
70
74
  : character;
71
75
  const [loadError, setLoadError] = useState();
72
- return (_jsx(ThemedProvider, { children: _jsxs(Box, { sx: { minHeight: '100vh', bg: 'canvas.default', p: 4 }, children: [_jsxs(Box, { sx: { maxWidth: 720, mx: 'auto' }, children: [_jsx(Heading, { as: "h1", sx: { mb: 2 }, children: "Chat Assistant" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', mb: 3 }, children: "The chat as a character on the page. It greets you, acts out what the agent is doing, and says the agent\u2019s words in a balloon while the conversation is closed. Click it to talk, drag it to move it, hover it to send it away." }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: CATALOGUE.map(option => (_jsx(Button, { variant: option.id === character ? 'primary' : 'default', onClick: () => {
73
- setCharacter(option.id);
74
- setLoadError(undefined);
75
- }, "aria-pressed": option.id === character, "data-assistant-character": option.id, children: option.name }, option.id))) }), _jsxs(Box, { as: "section", sx: { mt: 4 }, children: [_jsx(Heading, { as: "h2", sx: { fontSize: 2, mb: 1 }, children: "A character you bring" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 2 }, children: ["The characters of Microsoft Office \u2014 Clippy, Merlin, Links and the others \u2014 are Microsoft\u2019s. Load only a character file you have the right to use: a Microsoft Agent ", _jsx("code", { children: ".acs" }), " file, or a clippy.js character\u2019s ", _jsx("code", { children: "agent.js" }), " and map image. It is read in this page and sent nowhere."] }), _jsx("input", { type: "file", multiple: true, accept: ".acs,.js,.png,.gif,.webp,.jpg,.jpeg", "aria-label": "Character files", onChange: async (event) => {
76
+ // Pixel, the test sprite, read once through the clippy.js reader.
77
+ const [pixel, setPixel] = useState();
78
+ useEffect(() => {
79
+ let cancelled = false;
80
+ readTestSpriteCharacter().then(read => {
81
+ if (!cancelled) {
82
+ setPixel(read);
83
+ }
84
+ }, error => {
85
+ if (!cancelled) {
86
+ setLoadError(error instanceof Error ? error.message : String(error));
87
+ }
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, []);
93
+ // The object URL lives as long as the page does.
94
+ useEffect(() => () => {
95
+ if (pixel) {
96
+ URL.revokeObjectURL(pixel.sprite);
97
+ }
98
+ }, [pixel]);
99
+ // The clippy.js characters, each fetched the first time it is picked.
100
+ const [clippyJs, setClippyJs] = useState({});
101
+ const [fetching, setFetching] = useState();
102
+ const fetched = useRef([]);
103
+ useEffect(() => () => {
104
+ for (const read of fetched.current) {
105
+ URL.revokeObjectURL(read.sprite);
106
+ }
107
+ }, []);
108
+ const pickClippyJs = async (name) => {
109
+ const known = clippyJs[name];
110
+ if (known) {
111
+ setCharacter(known);
112
+ setLoadError(undefined);
113
+ return;
114
+ }
115
+ setFetching(name);
116
+ try {
117
+ const read = await readClippyJsCharacter(name);
118
+ fetched.current.push(read);
119
+ setClippyJs(previous => ({ ...previous, [name]: read }));
120
+ setCharacter(read);
121
+ setLoadError(undefined);
122
+ }
123
+ catch (error) {
124
+ setLoadError(error instanceof Error ? error.message : String(error));
125
+ }
126
+ finally {
127
+ setFetching(undefined);
128
+ }
129
+ };
130
+ const isClippyJs = typeof character !== 'string' &&
131
+ Object.values(clippyJs).includes(character);
132
+ return (_jsx(ThemedProvider, { children: _jsxs(Box, { sx: { minHeight: '100vh', bg: 'canvas.default', p: 4 }, children: [_jsxs(Box, { sx: { maxWidth: 720, mx: 'auto' }, children: [_jsx(Heading, { as: "h1", sx: { mb: 2 }, children: "Chat Assistant" }), _jsx(Text, { as: "p", sx: { color: 'fg.muted', mb: 3 }, children: "The chat as a character on the page. It greets you, acts out what the agent is doing, and says the agent\u2019s words in a balloon while the conversation is closed. Click it to talk, drag it to move it, hover it to send it away." }), _jsxs(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: [CATALOGUE.map(option => (_jsx(Button, { variant: option.id === character ? 'primary' : 'default', onClick: () => {
133
+ setCharacter(option.id);
134
+ setLoadError(undefined);
135
+ }, "aria-pressed": option.id === character, "data-assistant-character": option.id, children: option.name }, option.id))), pixel && (_jsxs(Button, { variant: character === pixel ? 'primary' : 'default', onClick: () => {
136
+ setCharacter(pixel);
137
+ setLoadError(undefined);
138
+ }, "aria-pressed": character === pixel, "data-assistant-character": "sprite", children: [pixel.name, " (sprite)"] }))] }), _jsxs(Box, { as: "section", sx: { mt: 4 }, children: [_jsx(Heading, { as: "h2", sx: { fontSize: 2, mb: 1 }, children: "The clippy.js characters" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 2 }, children: ["Fetched when picked from the", ' ', _jsxs("code", { children: ["clippyjs@", CLIPPY_JS_VERSION] }), " package on jsDelivr and read as data by the same reader. They are not in this repository: clippy.js\u2019s licence covers its code only, and the characters are Microsoft\u2019s."] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: CLIPPY_JS_CHARACTERS.map(name => {
139
+ const picked = clippyJs[name] !== undefined && character === clippyJs[name];
140
+ return (_jsx(Button, { variant: picked ? 'primary' : 'default', onClick: () => void pickClippyJs(name), disabled: fetching !== undefined, "aria-pressed": picked, "data-assistant-character": `clippy-js-${name.toLowerCase()}`, children: fetching === name ? `${name}…` : name }, name));
141
+ }) })] }), _jsxs(Box, { as: "section", sx: { mt: 4 }, children: [_jsx(Heading, { as: "h2", sx: { fontSize: 2, mb: 1 }, children: "A character you bring" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 2 }, children: ["The characters of Microsoft Office \u2014 Clippy, Merlin, Links and the others \u2014 are Microsoft\u2019s. Load only a character file you have the right to use: a Microsoft Agent ", _jsx("code", { children: ".acs" }), " file, or a clippy.js character\u2019s ", _jsx("code", { children: "agent.js" }), " and map image. It is read in this page and sent nowhere."] }), _jsx("input", { type: "file", multiple: true, accept: ".acs,.js,.png,.gif,.webp,.jpg,.jpeg", "aria-label": "Character files", onChange: async (event) => {
76
142
  const files = Array.from(event.target.files ?? []);
77
143
  if (!files.length) {
78
144
  return;
@@ -84,7 +150,9 @@ const ChatAssistantExample = () => {
84
150
  catch (error) {
85
151
  setLoadError(error instanceof Error ? error.message : String(error));
86
152
  }
87
- } }), loadError && (_jsx(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mt: 2 }, children: loadError })), typeof character !== 'string' && (_jsxs(Text, { as: "p", sx: { mt: 2 }, children: ["Playing ", character.name, "."] }))] })] }), _jsx(ChatFloating, { defaultViewMode: "assistant", assistantCharacter: drawn, protocol: "vercel-ai", endpoint: `${SERVER}/api/v1/vercel-ai/assistant`, title: "Assistant", description: "Hello! Ask me anything about this page.", position: "bottom-right", useStore: false,
153
+ } }), loadError && (_jsx(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mt: 2 }, children: loadError })), typeof character !== 'string' &&
154
+ character !== pixel &&
155
+ !isClippyJs && (_jsxs(Text, { as: "p", sx: { mt: 2 }, children: ["Playing ", character.name, "."] }))] })] }), _jsx(ChatFloating, { defaultViewMode: "assistant", assistantCharacter: drawn, protocol: "vercel-ai", endpoint: `${SERVER}/api/v1/vercel-ai/assistant`, title: "Assistant", description: "Hello! Ask me anything about this page.", position: "bottom-right", useStore: false,
88
156
  // Ask a decision beside the composer: Jev, through this server.
89
157
  decisions: { serverUrl: SERVER } }, typeof character === 'string' ? character : character.sprite)] }) }));
90
158
  };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Every representation of the floating assistant, as a gallery (LOOP T-21
3
3
  * to T-27): each character — Datalayer's four, the owl an example plugin
4
- * contributes to `loop.assistant.character`, and a test sprite read through
5
- * the clippy.js reader — in each state it acts, stepped aside, with its
4
+ * contributes to `loop.assistant.character`, a test sprite read through
5
+ * the clippy.js reader, and, when asked, the characters clippy.js publishes,
6
+ * fetched from jsDelivr — in each state it acts, stepped aside, with its
6
7
  * balloon, in light and dark, moving or still.
7
8
  *
8
9
  * Static data, no agent: it opens without a server or an account.
@@ -6,8 +6,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  /**
7
7
  * Every representation of the floating assistant, as a gallery (LOOP T-21
8
8
  * to T-27): each character — Datalayer's four, the owl an example plugin
9
- * contributes to `loop.assistant.character`, and a test sprite read through
10
- * the clippy.js reader — in each state it acts, stepped aside, with its
9
+ * contributes to `loop.assistant.character`, a test sprite read through
10
+ * the clippy.js reader, and, when asked, the characters clippy.js publishes,
11
+ * fetched from jsDelivr — in each state it acts, stepped aside, with its
11
12
  * balloon, in light and dark, moving or still.
12
13
  *
13
14
  * Static data, no agent: it opens without a server or an account.
@@ -21,6 +22,7 @@ import { AssistantStage } from '../chat/assistant/AssistantStage';
21
22
  import { useViewportDrag } from '../chat/useViewportDrag';
22
23
  import { AssistantGalleryGrid, GalleryObstacle, Still, useGalleryCharacters, } from './utils/AssistantGalleryGrid';
23
24
  import { GALLERY_POSES, GALLERY_POSE_LABELS, balloonForPose, sampleApproval, sampleSaying, stateOfPose, } from './utils/assistantGallery';
25
+ import { CLIPPY_JS_VERSION, useClippyJsCharacters, } from './utils/clippyJsCharacters';
24
26
  const STAGE_SIZE = 96;
25
27
  const PANEL_HEIGHT = 420;
26
28
  const LEAVE_MS = 700;
@@ -98,7 +100,10 @@ function InModes({ modes, stacked = false, children, }) {
98
100
  }, children: shown.map(mode => (_jsx(Box, { sx: { flex: '1 1 320px', minWidth: 0 }, children: _jsx(DatalayerThemeProvider, { colorMode: mode, theme: config.primerTheme, themeStyles: config.themeStyles, children: children(mode) }) }, mode))) }));
99
101
  }
100
102
  const ChatAssistantGalleryExample = () => {
101
- const { characters, problem } = useGalleryCharacters();
103
+ const { characters: ours, problem } = useGalleryCharacters();
104
+ const [withClippyJs, setWithClippyJs] = useState(false);
105
+ const clippyJs = useClippyJsCharacters(withClippyJs);
106
+ const characters = [...ours, ...clippyJs.characters];
102
107
  const [characterId, setCharacterId] = useState('paperclip');
103
108
  const [pose, setPose] = useState('idle');
104
109
  const [view, setView] = useState('stage');
@@ -142,7 +147,7 @@ const ChatAssistantGalleryExample = () => {
142
147
  bg: 'canvas.default',
143
148
  color: 'fg.default',
144
149
  p: 4,
145
- }, children: _jsxs(Box, { sx: { maxWidth: 1200, mx: 'auto' }, children: [_jsx(Heading, { as: "h1", sx: { mb: 2 }, children: "Chat Assistant gallery" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 3, maxWidth: 760 }, children: ["Every character in every state the floating assistant acts, with its balloon, in light and dark. Datalayer\u2019s four and the owl are what the enabled plugins contribute to", ' ', _jsx("code", { children: "loop.assistant.character" }), "; Pixel is a test sprite of our own, read through the clippy.js reader. No agent is behind it: the words are a sample conversation."] }), _jsxs(Box, { as: "section", "aria-label": "Gallery controls", sx: {
150
+ }, children: _jsxs(Box, { sx: { maxWidth: 1200, mx: 'auto' }, children: [_jsx(Heading, { as: "h1", sx: { mb: 2 }, children: "Chat Assistant gallery" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 3, maxWidth: 760 }, children: ["Every character in every state the floating assistant acts, with its balloon, in light and dark. Datalayer\u2019s four and the owl are what the enabled plugins contribute to", ' ', _jsx("code", { children: "loop.assistant.character" }), "; Pixel is a test sprite of our own, read through the clippy.js reader. No agent is behind it: the words are a sample conversation."] }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 3, maxWidth: 760 }, children: ["The characters clippy.js publishes \u2014 Clippy, Merlin, Links, Rover and the others \u2014 are loaded on request from the", ' ', _jsxs("code", { children: ["clippyjs@", CLIPPY_JS_VERSION] }), " package on jsDelivr (about 15 MB) and read by the same reader. They are not in this repository: clippy.js\u2019s licence covers its code only, and the characters are Microsoft\u2019s."] }), _jsxs(Box, { as: "section", "aria-label": "Gallery controls", sx: {
146
151
  display: 'grid',
147
152
  gap: 3,
148
153
  mb: 4,
@@ -160,7 +165,7 @@ const ChatAssistantGalleryExample = () => {
160
165
  ? 'Side by side'
161
166
  : option === 'light'
162
167
  ? 'Light'
163
- : 'Dark' }, option))) }), _jsxs(FormControl, { children: [_jsx(Checkbox, { checked: still, onChange: event => setStill(event.target.checked) }), _jsx(FormControl.Label, { children: "Reduced motion" })] }), _jsx(Button, { onClick: () => setReplay(replay + 1), children: "Replay the motions" })] }), view === 'stage' && (_jsxs(_Fragment, { children: [_jsx(Box, { role: "group", "aria-label": "Character", sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: characters.map(option => (_jsx(Button, { size: "small", variant: option.id === character.id ? 'primary' : 'default', "aria-pressed": option.id === character.id, "data-gallery-character": option.id, onClick: () => setCharacterId(option.id), children: option.name }, option.id))) }), _jsx(Box, { role: "group", "aria-label": "State", sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: GALLERY_POSES.map(option => (_jsx(Button, { size: "small", variant: option === pose ? 'primary' : 'default', "aria-pressed": option === pose, "data-gallery-pose": option, onClick: () => {
168
+ : 'Dark' }, option))) }), _jsxs(FormControl, { children: [_jsx(Checkbox, { checked: still, onChange: event => setStill(event.target.checked) }), _jsx(FormControl.Label, { children: "Reduced motion" })] }), _jsxs(FormControl, { children: [_jsx(Checkbox, { checked: withClippyJs, onChange: event => setWithClippyJs(event.target.checked), "data-gallery-clippy-js": "" }), _jsxs(FormControl.Label, { children: ["clippy.js characters", clippyJs.loading ? ' (loading…)' : ''] })] }), _jsx(Button, { onClick: () => setReplay(replay + 1), children: "Replay the motions" })] }), view === 'stage' && (_jsxs(_Fragment, { children: [_jsx(Box, { role: "group", "aria-label": "Character", sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: characters.map(option => (_jsx(Button, { size: "small", variant: option.id === character.id ? 'primary' : 'default', "aria-pressed": option.id === character.id, "data-gallery-character": option.id, onClick: () => setCharacterId(option.id), children: option.name }, option.id))) }), _jsx(Box, { role: "group", "aria-label": "State", sx: { display: 'flex', gap: 2, flexWrap: 'wrap' }, children: GALLERY_POSES.map(option => (_jsx(Button, { size: "small", variant: option === pose ? 'primary' : 'default', "aria-pressed": option === pose, "data-gallery-pose": option, onClick: () => {
164
169
  setPose(option);
165
170
  setDecision(undefined);
166
171
  setReplay(replay + 1);
@@ -169,7 +174,7 @@ const ChatAssistantGalleryExample = () => {
169
174
  gap: 2,
170
175
  flexWrap: 'wrap',
171
176
  alignItems: 'center',
172
- }, children: [_jsx(Button, { onClick: say, "data-gallery-say": "", children: "Say something" }), saying !== undefined && (_jsx(Button, { variant: "invisible", onClick: () => setSaying(undefined), children: "Hush" })), presence === 'away' ? (_jsx(Button, { onClick: callBack, children: "Call it back" })) : (_jsx(Button, { onClick: () => sendAway('page'), disabled: presence !== 'here', "data-gallery-send-away": "", children: "Send it away" })), _jsx(Text, { sx: { color: 'fg.muted', fontSize: 1 }, children: "Drag the character to move it; hover it for its own send-away menu." }), decision && (_jsx(Text, { role: "status", sx: { fontSize: 1 }, children: decision }))] })] }))] }), problem && (_jsxs(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mb: 3 }, children: ["The test sprite could not be read: ", problem] })), _jsx(Still, { still: still, children: view === 'stage' ? (_jsx(InModes, { modes: modes, children: mode => (_jsx(ModePanel, { mode: mode, character: character, pose: pose, presence: presence, balloon: balloon, insist: insist, onToggle: () => saying === undefined ? say() : setSaying(undefined), onDismiss: sendAway, onCallBack: callBack, away: away, replay: replay })) })) : (_jsx(InModes, { modes: modes, stacked: true, children: mode => (_jsx(Box, { "data-gallery-mode": mode, sx: {
177
+ }, children: [_jsx(Button, { onClick: say, "data-gallery-say": "", children: "Say something" }), saying !== undefined && (_jsx(Button, { variant: "invisible", onClick: () => setSaying(undefined), children: "Hush" })), presence === 'away' ? (_jsx(Button, { onClick: callBack, children: "Call it back" })) : (_jsx(Button, { onClick: () => sendAway('page'), disabled: presence !== 'here', "data-gallery-send-away": "", children: "Send it away" })), _jsx(Text, { sx: { color: 'fg.muted', fontSize: 1 }, children: "Drag the character to move it; hover it for its own send-away menu." }), decision && (_jsx(Text, { role: "status", sx: { fontSize: 1 }, children: decision }))] })] }))] }), problem && (_jsxs(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mb: 3 }, children: ["The test sprite could not be read: ", problem] })), clippyJs.problem && (_jsxs(Text, { as: "p", role: "alert", sx: { color: 'danger.fg', mb: 3 }, children: ["A clippy.js character could not be read: ", clippyJs.problem] })), _jsx(Still, { still: still, children: view === 'stage' ? (_jsx(InModes, { modes: modes, children: mode => (_jsx(ModePanel, { mode: mode, character: character, pose: pose, presence: presence, balloon: balloon, insist: insist, onToggle: () => saying === undefined ? say() : setSaying(undefined), onDismiss: sendAway, onCallBack: callBack, away: away, replay: replay })) })) : (_jsx(InModes, { modes: modes, stacked: true, children: mode => (_jsx(Box, { "data-gallery-mode": mode, sx: {
173
178
  bg: 'canvas.default',
174
179
  border: '1px solid',
175
180
  borderColor: 'border.default',
@@ -1,13 +1,15 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box } from '@datalayer/primer-addons';
3
3
  import { ThemedProvider } from './utils/themedProvider';
4
- import { registerDecks } from '@datalayer/decks';
5
- import { exampleDecks } from '@datalayer/decks/examples';
4
+ import { registerDeckComponents, registerDecks } from '@datalayer/decks';
5
+ import { exampleDeckComponents, exampleDecks } from '@datalayer/decks/examples';
6
6
  import { LoopEmbed } from '../loop';
7
7
  import { DeckViewPlugin, LoopDecksPlugin } from '../loop/plugins/decks';
8
8
  // The package's example decks, so the catalog has something to open and the
9
9
  // agent's suggestions point at decks that exist. Once, at module load.
10
10
  registerDecks(exampleDecks);
11
+ // And the components their slides name (AgentsHero, LiveAppearance…).
12
+ registerDeckComponents(exampleDeckComponents);
11
13
  // Module-level, so the embed builds its reactor once rather than on every
12
14
  // render of this component.
13
15
  const DECK_PLUGINS = [LoopDecksPlugin, DeckViewPlugin];
@@ -3,8 +3,8 @@ import { useMemo } from 'react';
3
3
  import { Box } from '@primer/react';
4
4
  import { QueryClientProvider } from '@tanstack/react-query';
5
5
  import { useReactor } from '@datalayer/reactor/react';
6
- import { registerDecks } from '@datalayer/decks';
7
- import { exampleDecks } from '@datalayer/decks/examples';
6
+ import { registerDeckComponents, registerDecks } from '@datalayer/decks';
7
+ import { exampleDeckComponents, exampleDecks } from '@datalayer/decks/examples';
8
8
  import { buildLoopReactor, LoopWorkspace } from '../loop/shell';
9
9
  import { DeckViewPlugin, LoopDecksPlugin } from '../loop/plugins/decks';
10
10
  import { loopPlugins } from '../loop/presets';
@@ -18,6 +18,8 @@ const FRONT_DOOR = 'jupyter-data-analyst';
18
18
  // The package's example decks, so the Decks member has something to open and
19
19
  // change before it has made one. Once, at module load.
20
20
  registerDecks(exampleDecks);
21
+ // And the components their slides name (AgentsHero, LiveAppearance…).
22
+ registerDeckComponents(exampleDeckComponents);
21
23
  export function LoopShellExample({ serverUrl = resolveExampleAgentRuntimesUrl('local'), agentId = FRONT_DOOR, defaultEditor = 'none', inheritTheme = false, }) {
22
24
  // Built once: rebuilding would restart every plugin on each render.
23
25
  const reactor = useMemo(() => buildLoopReactor([
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A voice chat with the floating assistant (VOICE.md V1).
3
+ *
4
+ * Hold the microphone (or `Ctrl`+`Space`), speak, let go: what you said is
5
+ * heard in this page — Moonshine for English, Whisper for French, Silero VAD
6
+ * keeping only the speech — and put in the composer, marked as said. Send
7
+ * it, and the answer is read aloud as it is written, sentence by sentence,
8
+ * by Datalayer's speech service (Kokoro, on ai-agents), the assistant's
9
+ * mouth moving with the sound and its balloon showing the sentence said.
10
+ *
11
+ * Three places, each named in the address when it is not this machine's:
12
+ * - `?agentRuntimesUrl=` — the agent-runtimes server and its `assistant`
13
+ * agent (http://127.0.0.1:8765);
14
+ * - `?voiceModelsUrl=` — the browser's models, as pinned
15
+ * (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
16
+ * - `?speechUrl=` — ai-agents' speech service (http://127.0.0.1:4401).
17
+ */
18
+ import React from 'react';
19
+ declare const VoiceChatExample: React.FC;
20
+ export default VoiceChatExample;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /*
3
+ * Copyright (c) 2025-2026 Datalayer, Inc.
4
+ * Distributed under the terms of the Modified BSD License.
5
+ */
6
+ /**
7
+ * A voice chat with the floating assistant (VOICE.md V1).
8
+ *
9
+ * Hold the microphone (or `Ctrl`+`Space`), speak, let go: what you said is
10
+ * heard in this page — Moonshine for English, Whisper for French, Silero VAD
11
+ * keeping only the speech — and put in the composer, marked as said. Send
12
+ * it, and the answer is read aloud as it is written, sentence by sentence,
13
+ * by Datalayer's speech service (Kokoro, on ai-agents), the assistant's
14
+ * mouth moving with the sound and its balloon showing the sentence said.
15
+ *
16
+ * Three places, each named in the address when it is not this machine's:
17
+ * - `?agentRuntimesUrl=` — the agent-runtimes server and its `assistant`
18
+ * agent (http://127.0.0.1:8765);
19
+ * - `?voiceModelsUrl=` — the browser's models, as pinned
20
+ * (`scripts/voice/serve_store.py`, http://127.0.0.1:8770);
21
+ * - `?speechUrl=` — ai-agents' speech service (http://127.0.0.1:4401).
22
+ */
23
+ import { useMemo, useState } from 'react';
24
+ import { Heading, Link, SegmentedControl, Text } from '@primer/react';
25
+ import { Box } from '@datalayer/primer-addons';
26
+ import { useSimpleAuthStore } from '@datalayer/core/lib/views/otel';
27
+ import { ThemedProvider } from './utils/themedProvider';
28
+ import { ChatFloating } from '../chat';
29
+ import { VOICE_CATALOGUE, voiceFor } from '../specs/voices';
30
+ function fromAddress(name, otherwise) {
31
+ return ((typeof window === 'undefined'
32
+ ? null
33
+ : new URLSearchParams(window.location.search).get(name)) || otherwise).replace(/\/+$/, '');
34
+ }
35
+ const SERVER = fromAddress('agentRuntimesUrl', 'http://127.0.0.1:8765');
36
+ const MODELS = fromAddress('voiceModelsUrl', 'http://127.0.0.1:8770');
37
+ const SPEECH = fromAddress('speechUrl', 'http://127.0.0.1:4401');
38
+ /** The packages that hear, loaded the first time the microphone is used. */
39
+ const ENGINES = {
40
+ transformers: () => import('@huggingface/transformers'),
41
+ vad: () => import('@ricky0123/vad-web'),
42
+ };
43
+ const LANGUAGES = [
44
+ { tag: 'en-US', label: 'English' },
45
+ { tag: 'fr-FR', label: 'Français' },
46
+ ];
47
+ const VoiceChatExample = () => {
48
+ const { token } = useSimpleAuthStore();
49
+ const [language, setLanguage] = useState('en-US');
50
+ const voiceSpec = voiceFor(language) ?? VOICE_CATALOGUE['kokoro-af-heart'];
51
+ const voice = useMemo(() => ({
52
+ language,
53
+ input: 'push_to_talk',
54
+ output: 'always',
55
+ voice: voiceSpec.id,
56
+ modelsUrl: MODELS,
57
+ engines: ENGINES,
58
+ speechUrl: SPEECH,
59
+ token: token || undefined,
60
+ consentKey: 'voice-chat-example',
61
+ }), [language, voiceSpec.id, token]);
62
+ return (_jsx(ThemedProvider, { children: _jsxs(Box, { sx: { minHeight: '100vh', bg: 'canvas.default', p: 4 }, children: [_jsxs(Box, { sx: { maxWidth: 720, mx: 'auto' }, children: [_jsx(Heading, { as: "h1", sx: { mb: 2 }, children: "Voice chat" }), _jsxs(Text, { as: "p", sx: { color: 'fg.muted', mb: 3 }, children: ["Talk to the assistant instead of typing: hold the microphone in its composer, or ", _jsx("kbd", { children: "Ctrl" }), "+", _jsx("kbd", { children: "Space" }), ", speak, and let go. What you say is heard in this page and never leaves it; the words go in the message box for you to send. Its answers are read aloud as they are written, by Datalayer\u2019s speech service, and not kept.", ' ', _jsx("kbd", { children: "Esc" }), " stops its voice."] }), _jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 3, mb: 3 }, children: [_jsx(SegmentedControl, { "aria-label": "Language", children: LANGUAGES.map(option => (_jsx(SegmentedControl.Button, { selected: language === option.tag, onClick: () => setLanguage(option.tag), "data-voice-language": option.tag, children: option.label }, option.tag))) }), _jsxs(Text, { sx: { color: 'fg.muted', fontSize: 1 }, children: ["Voice: ", voiceSpec.name] })] }), voiceSpec.attribution && (_jsx(Text, { as: "p", sx: { fontSize: 0, color: 'fg.muted', mb: 3 }, children: voiceSpec.attribution })), _jsxs(Text, { as: "p", sx: { fontSize: 0, color: 'fg.muted' }, children: ["Models from ", MODELS, ", speech from ", SPEECH, ", the agent at ", SERVER, ". See", ' ', _jsx(Link, { href: "https://agent-runtimes.datalayer.tech/chat/voice", children: "Voice" }), ' ', "for how to run each."] })] }), _jsx(ChatFloating, { defaultViewMode: "assistant", assistantCharacter: "paperclip", protocol: "vercel-ai", endpoint: `${SERVER}/api/v1/vercel-ai/assistant`, title: "Assistant", description: "Hello! Hold the microphone and ask me anything.", position: "bottom-right", useStore: false, voice: voice })] }) }));
63
+ };
64
+ export default VoiceChatExample;
@@ -88,6 +88,7 @@ export const EXAMPLE_ENTRIES = [
88
88
  makeEntry('DecksAgent', () => import('./DecksAgent'), 'A deck beside the chat: the decks plugin in a Loop, and an agent that writes presentations as data.'),
89
89
  makeEntry('CellExample', () => import('./CellExample'), 'Simple cell example.'),
90
90
  makeEntry('ChatAssistantExample', () => import('./ChatAssistantExample'), 'The floating assistant: the chat as a character on the page.'),
91
+ makeEntry('VoiceChatExample', () => import('./VoiceChatExample'), 'A voice chat with the floating assistant: push-to-talk heard in the page, answers read aloud by the speech service, the mouth moving with the sound.'),
91
92
  makeEntry('ChatAssistantGalleryExample', () => import('./ChatAssistantGalleryExample'), 'Every representation of the floating assistant: each character in each state, its balloon, light and dark, still or moving, one at a time or as a grid.'),
92
93
  makeEntry('ChatCustomExample', () => import('./ChatCustomExample'), 'Custom chat experience composition example.'),
93
94
  makeEntry('ChatExample', () => import('./ChatExample'), 'Baseline chat integration example.'),
@@ -47,7 +47,7 @@ const DEFAULT_LOCAL_JUPYTER_SERVER_URL = 'http://localhost:8888/api/jupyter-serv
47
47
  const DEFAULT_LOCAL_JUPYTER_SERVER_TOKEN = '60c1661cc408f978c309d04157af55c9588ff9557c9380e4fb50785750703da6';
48
48
  const DEFAULT_CLOUD_RUNTIME_ENVIRONMENT = 'ai-agents-env';
49
49
  const EXAMPLE_GROUP_ORDER = [
50
- 'Loop',
50
+ 'Apps',
51
51
  'A2UI',
52
52
  'A2A',
53
53
  'AG-UI',
@@ -70,6 +70,9 @@ const ANONYMOUS_EXAMPLES = new Set([
70
70
  // An application beside its computer, on the local agent-runtimes server:
71
71
  // nothing allocated on Datalayer, and the machine itself needs no token.
72
72
  'LoopAppComputerExample',
73
+ // The Sales and Accounting team: Sales in the page on a visitor's trial
74
+ // key, Accounting on the local server, which answers the machine itself.
75
+ 'AgentA2ATeamExample',
73
76
  // Decide, on the local agent-runtimes server as above: Jev is asked
74
77
  // through the server's ai-inference, with the server's own key.
75
78
  'AgentDecideExample',
@@ -88,14 +91,15 @@ const ANONYMOUS_EXAMPLES = new Set([
88
91
  'ChatAssistantExample',
89
92
  // Every character in every state, from static data: no agent at all.
90
93
  'ChatAssistantGalleryExample',
94
+ // Voice: heard in the page, the agent on the local server, the speech
95
+ // service named in the address.
96
+ 'VoiceChatExample',
91
97
  ]);
92
98
  const getExampleGroup = (id) => {
93
99
  if (id === 'AgentspecsExample' ||
94
- id === 'LoopStrategyExample' ||
95
- id === 'LoopWorkspaceExample' ||
96
- id === 'LoopShellExample' ||
100
+ id.startsWith('Loop') ||
97
101
  id === 'DecksAgent') {
98
- return 'Loop';
102
+ return 'Apps';
99
103
  }
100
104
  if (id.startsWith('A2Ui'))
101
105
  return 'A2UI';
@@ -110,7 +114,7 @@ const getExampleGroup = (id) => {
110
114
  // runtime: checkpoints, hooks, memory, guardrails…
111
115
  if (id.startsWith('Agent'))
112
116
  return 'Capabilities';
113
- if (id.startsWith('Chat'))
117
+ if (id.startsWith('Chat') || id === 'VoiceChatExample')
114
118
  return 'Chat';
115
119
  // The document examples: the ones on the Lexical editor, and the page
116
120
  // with a document on it.
@@ -1072,13 +1076,14 @@ const ExampleAppThemed = ({ selectedExample, isChangingExample, error, ExampleCo
1072
1076
  }
1073
1077
  for (const [groupName, examples] of groups) {
1074
1078
  examples.sort((left, right) => {
1075
- if (groupName === 'Loop') {
1079
+ if (groupName === 'Apps') {
1076
1080
  // The shells first, most naked first; then the strategy that drives a
1077
1081
  // notebook; then the library of specs behind them all.
1078
1082
  const LOOP_ORDER = [
1079
1083
  'LoopShellExample',
1080
1084
  'LoopWorkspaceExample',
1081
1085
  'LoopStrategyExample',
1086
+ 'LoopAppComputerExample',
1082
1087
  'AgentspecsExample',
1083
1088
  ];
1084
1089
  const loopOrder = (id) => {
@@ -0,0 +1,18 @@
1
+ import type { AssistantCharacterData } from '../../chat/assistant/formats/types';
2
+ import type { GalleryCharacter } from './AssistantGalleryGrid';
3
+ /** The version of `clippyjs` the characters are read from. */
4
+ export declare const CLIPPY_JS_VERSION = "0.1.0";
5
+ /** The characters the package publishes: their names, and their folders. */
6
+ export declare const CLIPPY_JS_CHARACTERS: readonly ["Clippy", "Merlin", "Links", "Rover", "Genie", "Genius", "F1", "Peedy", "Rocky", "Bonzi"];
7
+ export type ClippyJsCharacterName = (typeof CLIPPY_JS_CHARACTERS)[number];
8
+ /** One clippy.js character, fetched and read. */
9
+ export declare function readClippyJsCharacter(name: ClippyJsCharacterName): Promise<AssistantCharacterData>;
10
+ /**
11
+ * The clippy.js characters as the gallery shows them, fetched once `enabled`
12
+ * and kept while it stays so; each as soon as it is read.
13
+ */
14
+ export declare function useClippyJsCharacters(enabled: boolean): {
15
+ characters: readonly GalleryCharacter[];
16
+ loading: boolean;
17
+ problem?: string;
18
+ };
@@ -0,0 +1,129 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * The characters clippy.js publishes — Clippy, Merlin, Links, Rover and the
7
+ * others — loaded into the floating assistant's gallery from the `clippyjs`
8
+ * package on jsDelivr, at the reader's request, and read through the same
9
+ * clippy.js reader a person's files go through.
10
+ *
11
+ * Nothing of them is in this repository: clippy.js's licence covers its code
12
+ * only, and the characters, their names and their drawings are Microsoft's
13
+ * (§6.9). The page fetches them when asked and keeps them in memory.
14
+ *
15
+ * The package ships each character as ES modules: `agent.mjs` exports the
16
+ * character's data, `map.mjs` its sprite sheet as a data URL. Both are read as
17
+ * text, never run: the data is rewritten as the `clippy.ready(...)` call the
18
+ * reader expects. Sounds are left out.
19
+ *
20
+ * @module examples/utils/clippyJsCharacters
21
+ */
22
+ import { useEffect, useState } from 'react';
23
+ import { readClippyCharacter } from '../../chat/assistant/formats/clippy';
24
+ /** The version of `clippyjs` the characters are read from. */
25
+ export const CLIPPY_JS_VERSION = '0.1.0';
26
+ const BASE = `https://cdn.jsdelivr.net/npm/clippyjs@${CLIPPY_JS_VERSION}/dist/agents`;
27
+ /** The characters the package publishes: their names, and their folders. */
28
+ export const CLIPPY_JS_CHARACTERS = [
29
+ 'Clippy',
30
+ 'Merlin',
31
+ 'Links',
32
+ 'Rover',
33
+ 'Genie',
34
+ 'Genius',
35
+ 'F1',
36
+ 'Peedy',
37
+ 'Rocky',
38
+ 'Bonzi',
39
+ ];
40
+ async function fetchText(url) {
41
+ const response = await fetch(url);
42
+ if (!response.ok) {
43
+ throw new Error(`${url} answered ${response.status}.`);
44
+ }
45
+ return response.text();
46
+ }
47
+ /** The value a module exports by default: `var x = <value>;\nexport …`. */
48
+ function defaultExportText(module, what) {
49
+ const match = /^var [A-Za-z_$][\w$]* = ([\s\S]*?);\s*export\s*\{/m.exec(module);
50
+ if (!match) {
51
+ throw new Error(`${what} does not export its value as clippy.js 0.1 does.`);
52
+ }
53
+ return match[1];
54
+ }
55
+ /** One clippy.js character, fetched and read. */
56
+ export async function readClippyJsCharacter(name) {
57
+ const folder = `${BASE}/${name.toLowerCase()}`;
58
+ const [agentModule, mapModule] = await Promise.all([
59
+ fetchText(`${folder}/agent.mjs`),
60
+ fetchText(`${folder}/map.mjs`),
61
+ ]);
62
+ const data = defaultExportText(agentModule, `${name}'s agent.mjs`);
63
+ const mapUrl = defaultExportText(mapModule, `${name}'s map.mjs`);
64
+ if (!/^"data:image\/(png|gif|webp|jpeg);base64,[A-Za-z0-9+/=]+"$/.test(mapUrl)) {
65
+ throw new Error(`${name}'s map.mjs does not hold an image.`);
66
+ }
67
+ const mapPng = await (await fetch(mapUrl.slice(1, -1))).blob();
68
+ return readClippyCharacter({
69
+ agentJs: `clippy.ready(${JSON.stringify(name)}, ${data});`,
70
+ mapPng,
71
+ });
72
+ }
73
+ /**
74
+ * The clippy.js characters as the gallery shows them, fetched once `enabled`
75
+ * and kept while it stays so; each as soon as it is read.
76
+ */
77
+ export function useClippyJsCharacters(enabled) {
78
+ const [read, setRead] = useState([]);
79
+ const [pending, setPending] = useState(0);
80
+ const [problem, setProblem] = useState();
81
+ useEffect(() => {
82
+ if (!enabled) {
83
+ return;
84
+ }
85
+ let cancelled = false;
86
+ const loaded = [];
87
+ setRead([]);
88
+ setProblem(undefined);
89
+ setPending(CLIPPY_JS_CHARACTERS.length);
90
+ for (const name of CLIPPY_JS_CHARACTERS) {
91
+ readClippyJsCharacter(name).then(character => {
92
+ if (cancelled) {
93
+ URL.revokeObjectURL(character.sprite);
94
+ return;
95
+ }
96
+ loaded.push(character);
97
+ setRead(previous => [
98
+ ...previous,
99
+ {
100
+ id: `clippy-js-${name.toLowerCase()}`,
101
+ name: character.name,
102
+ character,
103
+ from: `clippy.js ${CLIPPY_JS_VERSION}`,
104
+ },
105
+ ].sort((a, b) => CLIPPY_JS_CHARACTERS.indexOf(a.name) -
106
+ CLIPPY_JS_CHARACTERS.indexOf(b.name)));
107
+ setPending(count => count - 1);
108
+ }, error => {
109
+ if (cancelled) {
110
+ return;
111
+ }
112
+ setProblem(`${name}: ${error instanceof Error ? error.message : String(error)}`);
113
+ setPending(count => count - 1);
114
+ });
115
+ }
116
+ // The object URLs live as long as the characters are shown.
117
+ return () => {
118
+ cancelled = true;
119
+ for (const character of loaded) {
120
+ URL.revokeObjectURL(character.sprite);
121
+ }
122
+ };
123
+ }, [enabled]);
124
+ return {
125
+ characters: enabled ? read : [],
126
+ loading: enabled && pending > 0,
127
+ problem: enabled ? problem : undefined,
128
+ };
129
+ }
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * @module loop/apps/appspec
26
26
  */
27
- import type { AppAccent, AppAssistantCharacter, AppEmbedMode, AppKind, AppLayout, AppSpec } from '../../types/agentspecs';
27
+ import type { AppAccent, AppAssistantCharacter, AppEmbedMode, AppVoiceSpec, AppKind, AppLayout, AppSpec } from '../../types/agentspecs';
28
28
  /** The version of the spec itself. */
29
29
  export declare const APP_SCHEMA = "loop.app/v1";
30
30
  /** The face of an application that has not chosen one. */
@@ -51,6 +51,8 @@ type Data = Record<string, unknown>;
51
51
  export declare function retentionDays(keepFor: string): number;
52
52
  /** An application with nothing said: what *Start blank* opens. */
53
53
  export declare function emptyAppspec(kind?: AppKind): AppSpec;
54
+ /** An application's voice (VOICE.md VO-41): off unless said. */
55
+ export declare const DEFAULT_VOICE: AppVoiceSpec;
54
56
  /** What reading a document gives: the application, and what the document got wrong. */
55
57
  export interface ParsedAppspec {
56
58
  app: AppSpec;
@@ -214,6 +214,26 @@ function parseSurface(data) {
214
214
  composedAt: text(data.composed_at),
215
215
  };
216
216
  }
217
+ /** An application's voice (VOICE.md VO-41): off unless said. */
218
+ export const DEFAULT_VOICE = {
219
+ enabled: false,
220
+ input: 'push_to_talk',
221
+ output: 'on_request',
222
+ voice: '',
223
+ language: '',
224
+ where: 'auto',
225
+ };
226
+ function parseVoice(data) {
227
+ const voice = isData(data) ? data : {};
228
+ return {
229
+ enabled: voice.enabled === true,
230
+ input: oneOf(voice.input, ['off', 'push_to_talk', 'hands_free'], DEFAULT_VOICE.input),
231
+ output: oneOf(voice.output, ['off', 'on_request', 'always'], DEFAULT_VOICE.output),
232
+ voice: text(voice.voice),
233
+ language: text(voice.language),
234
+ where: oneOf(voice.where, ['auto', 'device', 'server'], DEFAULT_VOICE.where),
235
+ };
236
+ }
217
237
  function parseInterface(data, kind) {
218
238
  const parsed = {
219
239
  layout: oneOf(data.layout, ['chat', 'page', 'split'], DEFAULT_LAYOUTS[kind]),
@@ -225,6 +245,7 @@ function parseInterface(data, kind) {
225
245
  })),
226
246
  settings: records(data.settings).map(parseSetting),
227
247
  components: texts(data.components),
248
+ voice: parseVoice(data.voice),
228
249
  };
229
250
  if (isData(data.surface)) {
230
251
  parsed.surface = parseSurface(data.surface);
@@ -493,6 +514,16 @@ function dumpInterface(spec, kind) {
493
514
  if (spec.assistant) {
494
515
  writer.data.assistant = spec.assistant;
495
516
  }
517
+ // Its voice, when it says one (VO-41): a spec made before voice has none.
518
+ if (spec.voice) {
519
+ writer.part('voice', new Writer()
520
+ .value('enabled', spec.voice.enabled, DEFAULT_VOICE.enabled)
521
+ .value('input', spec.voice.input, DEFAULT_VOICE.input)
522
+ .value('output', spec.voice.output, DEFAULT_VOICE.output)
523
+ .text('voice', spec.voice.voice)
524
+ .text('language', spec.voice.language)
525
+ .value('where', spec.voice.where, DEFAULT_VOICE.where).data);
526
+ }
496
527
  return writer.data;
497
528
  }
498
529
  function dumpDecision(decision) {
@@ -484,7 +484,7 @@ export function documentShapeProblems(document) {
484
484
  }
485
485
  for (const key of [
486
486
  'skills',
487
- 'tools',
487
+ 'backend_tools',
488
488
  'context',
489
489
  'contents',
490
490
  'notifications',