@datalayer/agent-runtimes 1.3.63 → 1.3.65

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 (60) 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 +48 -1
  5. package/lib/chat/assistant/state.d.ts +7 -1
  6. package/lib/chat/assistant/state.js +3 -2
  7. package/lib/chat/base/ChatBase.js +32 -4
  8. package/lib/chat/messages/ChatMessageList.d.ts +0 -6
  9. package/lib/chat/messages/ChatMessageList.js +8 -2
  10. package/lib/components/teams/A2ATeamGraph.d.ts +58 -0
  11. package/lib/components/teams/A2ATeamGraph.js +190 -0
  12. package/lib/components/teams/a2aTeamFlow.d.ts +33 -0
  13. package/lib/components/teams/a2aTeamFlow.js +32 -0
  14. package/lib/components/teams/index.d.ts +11 -0
  15. package/lib/components/teams/index.js +15 -0
  16. package/lib/components/teams/useA2ATeam.d.ts +57 -0
  17. package/lib/components/teams/useA2ATeam.js +233 -0
  18. package/lib/examples/AgentA2ATeamExample.d.ts +6 -3
  19. package/lib/examples/AgentA2ATeamExample.js +42 -208
  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 +7 -1
  24. package/lib/loop/apps/appspec.d.ts +3 -1
  25. package/lib/loop/apps/appspec.js +31 -0
  26. package/lib/protocols/VercelAIAdapter.js +5 -0
  27. package/lib/specs/apps.js +112 -0
  28. package/lib/specs/appspecSchema.js +65 -0
  29. package/lib/specs/index.d.ts +1 -0
  30. package/lib/specs/index.js +1 -0
  31. package/lib/specs/voices.d.ts +72 -0
  32. package/lib/specs/voices.js +344 -0
  33. package/lib/types/agentspecs.d.ts +16 -0
  34. package/lib/types/chat.d.ts +11 -0
  35. package/lib/voice/VoiceInput.d.ts +35 -0
  36. package/lib/voice/VoiceInput.js +233 -0
  37. package/lib/voice/capture.d.ts +30 -0
  38. package/lib/voice/capture.js +113 -0
  39. package/lib/voice/consent.d.ts +6 -0
  40. package/lib/voice/consent.js +34 -0
  41. package/lib/voice/hearing.d.ts +37 -0
  42. package/lib/voice/hearing.js +168 -0
  43. package/lib/voice/index.d.ts +47 -0
  44. package/lib/voice/index.js +13 -0
  45. package/lib/voice/pinned.d.ts +28 -0
  46. package/lib/voice/pinned.js +75 -0
  47. package/lib/voice/sentences.d.ts +26 -0
  48. package/lib/voice/sentences.js +71 -0
  49. package/lib/voice/speaker.d.ts +66 -0
  50. package/lib/voice/speaker.js +168 -0
  51. package/lib/voice/types.d.ts +92 -0
  52. package/lib/voice/types.js +5 -0
  53. package/lib/voice/useSpokenAnswers.d.ts +12 -0
  54. package/lib/voice/useSpokenAnswers.js +90 -0
  55. package/package.json +7 -3
  56. package/scripts/codegen/generate_voices.py +279 -0
  57. package/scripts/voice/measure.py +244 -0
  58. package/scripts/voice/pin_store.py +159 -0
  59. package/scripts/voice/serve_store.py +70 -0
  60. package/scripts/voice/transcribe.mjs +58 -0
@@ -1,33 +1,21 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { Button, Flash, Heading, Label, Text, Textarea } from '@primer/react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Button, Flash, Heading, Text, Textarea } from '@primer/react';
4
4
  import { Box } from '@datalayer/primer-addons';
5
5
  import { AnonymousKeyExpired } from '@datalayer/core/lib/components/anonymous/AnonymousKeyExpired';
6
6
  import { AnonymousKeyTimer } from '@datalayer/core/lib/components/anonymous/AnonymousKeyTimer';
7
- import { stepCountIs, ToolLoopAgent } from 'ai';
8
7
  import { Streamdown } from 'streamdown';
9
8
  import { ThemedProvider } from './utils/themedProvider';
10
9
  import { useExampleAgentRuntimesUrl } from './utils/useExampleAgentRuntimesUrl';
11
- import { AssistantStage } from '../chat/assistant/AssistantStage';
12
10
  import { useBrowserInference } from '../hooks/useBrowserInference';
13
- import { createBrowserModel } from '../runtimes/browser/model';
14
11
  import { readJwtClaims, tokenLifetimeMs, useAnonymousSessionStore, } from '../runtimes/browser/anonymousToken';
15
- import { a2aPeerTool, connectA2APeer, } from '../runtimes/browser/a2aPeer';
12
+ import { connectA2APeer } from '../runtimes/browser/a2aPeer';
13
+ import { A2ATeamGraph, useA2ATeam } from '../components/teams';
16
14
  import { ACCOUNTING_APP_0_0_1, SALES_APP_0_0_1 } from '../specs/apps';
17
15
  import { SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1 } from '../specs/teams/teams';
18
16
  const TEAM = SALES_AND_ACCOUNTING_TEAM_SPEC_0_0_1;
19
17
  const SALES = SALES_APP_0_0_1;
20
18
  const ACCOUNTING = ACCOUNTING_APP_0_0_1;
21
- /** The name Sales's tool goes by, as its instructions call it. */
22
- const ASK_TOOL = 'ask_accounting';
23
- const STAGE_SIZE = 104;
24
- const STAGE_HEIGHT = 300;
25
- const AT_REST = { state: 'idle', insist: false, away: false };
26
- /** A line short enough for a balloon. */
27
- function line(text, length = 120) {
28
- const flat = text.replace(/\s+/g, ' ').trim();
29
- return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
30
- }
31
19
  /** The members of the team, as the team spec says them. */
32
20
  function members() {
33
21
  const sales = TEAM.agents.find(member => member.id === TEAM.entry);
@@ -38,25 +26,6 @@ function members() {
38
26
  }
39
27
  return { sales, accounting };
40
28
  }
41
- /** One persona at its desk, with its name and where it runs under it. */
42
- function Desk({ app, persona, where, left, onToggle, onAway, }) {
43
- const stageRef = useRef(null);
44
- const character = app.interface.assistant ?? 'paperclip';
45
- return (_jsxs(Box, { sx: {
46
- position: 'absolute',
47
- left,
48
- top: 0,
49
- width: 220,
50
- height: STAGE_HEIGHT,
51
- }, "data-team-member": app.id, "data-member-state": persona.state, children: [persona.away ? (_jsxs(Button, { size: "small", sx: { position: 'absolute', top: STAGE_HEIGHT - 150 }, onClick: () => onAway(false), children: ["Call ", app.name, " back"] })) : (_jsx(AssistantStage, { character: character, state: persona.state, size: STAGE_SIZE, place: { position: 'absolute', left: 56, top: STAGE_HEIGHT - 170 }, stageRef: stageRef, onDragStart: () => undefined, open: false, onToggle: onToggle, balloon: persona.saying
52
- ? { text: persona.saying, more: persona.saying.endsWith('…') }
53
- : undefined, insist: persona.insist, onDismiss: away => onAway(away !== 'none') })), _jsxs(Box, { sx: {
54
- position: 'absolute',
55
- bottom: 0,
56
- width: '100%',
57
- textAlign: 'center',
58
- }, children: [_jsxs(Text, { sx: { fontWeight: 600, display: 'block' }, children: [app.emoji, " ", app.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: where })] })] }));
59
- }
60
29
  function AgentA2ATeam() {
61
30
  const { sales: salesMember } = useMemo(members, []);
62
31
  const runtimeUrl = useExampleAgentRuntimesUrl();
@@ -76,21 +45,8 @@ function AgentA2ATeam() {
76
45
  accountingKeyClock.expiresAt <= Date.now());
77
46
  const [peer, setPeer] = useState(null);
78
47
  const [peerError, setPeerError] = useState(null);
79
- const [sales, setSales] = useState({
80
- ...AT_REST,
81
- state: 'greeting',
82
- saying: line(SALES.interface.welcome ?? ''),
83
- insist: true,
84
- });
85
- const [accounting, setAccounting] = useState(AT_REST);
86
- const [turns, setTurns] = useState([]);
87
- const [report, setReport] = useState(null);
88
- const [exchange, setExchange] = useState([]);
89
48
  const [draft, setDraft] = useState('');
90
- const [busy, setBusy] = useState(false);
91
49
  const composer = useRef(null);
92
- const history = useRef([]);
93
- const abort = useRef(null);
94
50
  useEffect(() => {
95
51
  let current = true;
96
52
  setPeer(null);
@@ -103,148 +59,19 @@ function AgentA2ATeam() {
103
59
  current = false;
104
60
  };
105
61
  }, [accountingUrl, accountingKey]);
106
- // What Accounting does, as it does it: its own state and balloon.
107
- const onPeerEvent = useCallback((event) => {
108
- if (event.phase === 'asked') {
109
- setExchange(prev => [...prev, `Sales → Accounting: ${event.request}`]);
110
- setAccounting(prev => ({
111
- ...prev,
112
- state: 'greeting',
113
- saying: 'On it. Let me read the books.',
114
- insist: true,
115
- }));
116
- }
117
- else if (event.phase === 'working') {
118
- if (event.note) {
119
- setExchange(prev => [...prev, `Accounting: ${event.note}`]);
120
- }
121
- setAccounting(prev => ({
122
- ...prev,
123
- state: 'working',
124
- saying: event.note ? line(event.note) : prev.saying,
125
- insist: Boolean(event.note) || prev.insist,
126
- }));
127
- }
128
- else if (event.phase === 'answered') {
129
- setExchange(prev => [...prev, 'Accounting → Sales: the report']);
130
- setReport(event.answer);
131
- setAccounting(prev => ({
132
- ...prev,
133
- state: 'speaking',
134
- saying: line(event.answer),
135
- insist: true,
136
- }));
137
- }
138
- else {
139
- setExchange(prev => [
140
- ...prev,
141
- `Accounting could not answer: ${event.error}`,
142
- ]);
143
- setAccounting(prev => ({
144
- ...prev,
145
- state: 'idle',
146
- saying: line(event.error),
147
- insist: true,
148
- }));
149
- }
150
- }, []);
151
- const agent = useMemo(() => {
152
- if (!peer) {
153
- return null;
154
- }
155
- return new ToolLoopAgent({
156
- id: SALES.id,
157
- model: createBrowserModel({
158
- ...inference,
159
- model: SALES.model || undefined,
160
- }),
161
- instructions: SALES.instructions,
162
- tools: { [ASK_TOOL]: a2aPeerTool({ peer, onEvent: onPeerEvent }) },
163
- stopWhen: stepCountIs(6),
164
- });
165
- }, [peer, inference, onPeerEvent]);
166
- const send = useCallback(async (text) => {
167
- const asked = text.trim();
168
- if (!asked || !agent || busy) {
169
- return;
170
- }
171
- setBusy(true);
62
+ // Sales runs here and asks Accounting over A2A: the conversation, the
63
+ // exchange, the report and the way the link carries a message.
64
+ const team = useA2ATeam({
65
+ entry: SALES,
66
+ peerApp: ACCOUNTING,
67
+ peer,
68
+ inference,
69
+ askTool: 'ask_accounting',
70
+ });
71
+ const send = (text) => {
172
72
  setDraft('');
173
- setReport(null);
174
- setExchange([]);
175
- setTurns(prev => [...prev, { role: 'user', text: asked }]);
176
- setSales({
177
- ...AT_REST,
178
- state: 'thinking',
179
- saying: 'Let me see…',
180
- insist: true,
181
- });
182
- setAccounting(AT_REST);
183
- history.current = [...history.current, { role: 'user', content: asked }];
184
- abort.current = new AbortController();
185
- let said = '';
186
- try {
187
- const result = await agent.stream({
188
- messages: history.current,
189
- abortSignal: abort.current.signal,
190
- });
191
- for await (const part of result.fullStream) {
192
- if (part.type === 'tool-call' && part.toolName === ASK_TOOL) {
193
- const request = String(part.input?.request ?? '');
194
- setSales(prev => ({
195
- ...prev,
196
- state: 'waiting',
197
- saying: line(`Accounting, could you: ${request}`),
198
- insist: true,
199
- }));
200
- }
201
- else if (part.type === 'tool-result' &&
202
- part.toolName === ASK_TOOL) {
203
- setSales(prev => ({
204
- ...prev,
205
- state: 'thinking',
206
- saying: 'Thanks!',
207
- }));
208
- }
209
- else if (part.type === 'text-delta') {
210
- said += part.text;
211
- setSales(prev => ({
212
- ...prev,
213
- state: 'speaking',
214
- saying: line(said),
215
- insist: true,
216
- }));
217
- }
218
- else if (part.type === 'error') {
219
- throw part.error;
220
- }
221
- }
222
- history.current = [
223
- ...history.current,
224
- { role: 'assistant', content: said },
225
- ];
226
- setTurns(prev => [...prev, { role: 'assistant', text: said }]);
227
- setSales(prev => ({ ...prev, state: 'idle', saying: line(said) }));
228
- }
229
- catch (error) {
230
- const message = error instanceof Error ? error.message : String(error);
231
- setTurns(prev => [
232
- ...prev,
233
- { role: 'assistant', text: `Something went wrong: ${message}` },
234
- ]);
235
- setSales(prev => ({
236
- ...prev,
237
- state: 'idle',
238
- saying: line(`Something went wrong: ${message}`),
239
- insist: true,
240
- }));
241
- }
242
- finally {
243
- abort.current = null;
244
- setBusy(false);
245
- }
246
- }, [agent, busy]);
247
- useEffect(() => () => abort.current?.abort(), []);
73
+ void team.send(text);
74
+ };
248
75
  const accountingWhere = accountingKey
249
76
  ? `on a runtime, ${new URL(accountingUrl).host}, with a temporary key`
250
77
  : `on a runtime, ${new URL(accountingUrl).host}`;
@@ -252,43 +79,50 @@ function AgentA2ATeam() {
252
79
  (accountingKeyClock && !accountingKeyEnded)) && (_jsxs(Box, { sx: { display: 'flex', gap: 4, flexWrap: 'wrap', mb: 3 }, children: [anonymous.status === 'active' && anonymous.expiresAt && (_jsx(AnonymousKeyTimer, { expiresAt: anonymous.expiresAt, grantedMs: anonymous.grantedMs, label: `${SALES.name}'s trial key`, onExpire: () => useAnonymousSessionStore.getState().expire() })), accountingKeyClock && !accountingKeyEnded && (_jsx(AnonymousKeyTimer, { expiresAt: accountingKeyClock.expiresAt, grantedMs: accountingKeyClock.grantedMs, label: `${ACCOUNTING.name}'s temporary key`, onExpire: () => setAccountingKeyEnded(true) }))] })), anonymous.status === 'expired' && (_jsx(Box, { sx: { mb: 3 }, children: _jsx(AnonymousKeyExpired, { agentName: SALES.name,
253
80
  // Sales runs in this page: what ended is its model's key, and
254
81
  // signing in gives it the person's own.
255
- onSignedIn: () => useAnonymousSessionStore.getState().clear() }) })), accountingKeyEnded && (_jsxs(Flash, { variant: "danger", sx: { mb: 3 }, children: [ACCOUNTING.name, "\u2019s temporary key has ended: it no longer answers. Make a new one with", ' ', _jsx("code", { children: "examples/sales-accounting-a2a/make_temp_key.py" }), " and reload."] })), needsSignIn && (_jsx(Flash, { variant: "warning", sx: { mb: 3 }, children: "Sales asks its model through the Datalayer inference service: sign in, or wait for a visitor's trial key." })), peerError && (_jsxs(Flash, { variant: "danger", sx: { mb: 3 }, children: ["Accounting is not reachable at ", accountingUrl, ": ", peerError, " Serve it with", ' ', _jsx("code", { children: "python examples/sales-accounting-a2a/serve_accounting.py" }), ' ', "on the local server, or", ' ', _jsx("code", { children: "loop apps run accounting.yaml --cloud --a2a" }), " and set", ' ', _jsx("code", { children: "VITE_A2A_ACCOUNTING_URL" }), "."] })), _jsxs(Box, { sx: {
256
- position: 'relative',
257
- height: STAGE_HEIGHT,
82
+ onSignedIn: () => useAnonymousSessionStore.getState().clear() }) })), accountingKeyEnded && (_jsxs(Flash, { variant: "danger", sx: { mb: 3 }, children: [ACCOUNTING.name, "\u2019s temporary key has ended: it no longer answers. Make a new one with", ' ', _jsx("code", { children: "examples/sales-accounting-a2a/make_temp_key.py" }), " and reload."] })), needsSignIn && (_jsx(Flash, { variant: "warning", sx: { mb: 3 }, children: "Sales asks its model through the Datalayer inference service: sign in, or wait for a visitor's trial key." })), peerError && (_jsxs(Flash, { variant: "danger", sx: { mb: 3 }, children: ["Accounting is not reachable at ", accountingUrl, ": ", peerError, " Serve it with", ' ', _jsx("code", { children: "python examples/sales-accounting-a2a/serve_accounting.py" }), ' ', "on the local server, or", ' ', _jsx("code", { children: "loop apps run accounting.yaml --cloud --a2a" }), " and set", ' ', _jsx("code", { children: "VITE_A2A_ACCOUNTING_URL" }), "."] })), _jsx(Box, { sx: {
258
83
  border: '1px solid',
259
84
  borderColor: 'border.default',
260
85
  borderRadius: 2,
261
86
  bg: 'canvas.subtle',
262
- overflow: 'visible',
87
+ px: 2,
88
+ pt: 2,
263
89
  mb: 3,
264
- }, children: [_jsx(Desk, { app: SALES, persona: sales, where: salesMember.runsIn === 'browser'
90
+ }, children: _jsx(A2ATeamGraph, { entry: {
91
+ id: SALES.id,
92
+ name: SALES.name,
93
+ emoji: SALES.emoji,
94
+ character: SALES.interface.assistant ?? 'paperclip',
95
+ where: salesMember.runsIn === 'browser'
265
96
  ? 'in your browser'
266
- : 'on a runtime', left: "8%", onToggle: () => composer.current?.focus(), onAway: away => setSales(prev => ({ ...prev, away })) }), _jsxs(Box, { sx: {
267
- position: 'absolute',
268
- left: '38%',
269
- right: '38%',
270
- top: STAGE_HEIGHT / 2,
271
- textAlign: 'center',
272
- }, children: [_jsx(Box, { sx: {
273
- borderTop: '2px dashed',
274
- borderColor: peer ? 'accent.emphasis' : 'border.muted',
275
- } }), _jsxs(Label, { variant: peer ? 'accent' : 'secondary', sx: { mt: 2 }, children: ["A2A ", peer ? `· ${peer.skill.name}` : '· not connected'] })] }), _jsx(Desk, { app: ACCOUNTING, persona: accounting, where: accountingWhere, left: "66%", onToggle: () => undefined, onAway: away => setAccounting(prev => ({ ...prev, away })) })] }), _jsxs(Box, { sx: { display: 'flex', gap: 3, flexWrap: 'wrap' }, children: [_jsxs(Box, { sx: { flex: '1 1 420px', minWidth: 0 }, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: ["Ask ", SALES.name] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }, children: (SALES.interface.starters ?? []).map(starter => (_jsx(Button, { size: "small", disabled: !agent || busy, onClick: () => void send(starter.message), children: starter.label }, starter.label))) }), _jsx(Textarea, { ref: composer, block: true, rows: 3, value: draft, placeholder: "Ask for a financial report\u2026", onChange: event => setDraft(event.target.value), onKeyDown: event => {
97
+ : 'on a runtime',
98
+ persona: team.entryPersona,
99
+ onToggle: () => composer.current?.focus(),
100
+ onAway: team.setEntryAway,
101
+ }, peer: {
102
+ id: ACCOUNTING.id,
103
+ name: ACCOUNTING.name,
104
+ emoji: ACCOUNTING.emoji,
105
+ character: ACCOUNTING.interface.assistant ?? 'wizard',
106
+ where: accountingWhere,
107
+ persona: team.peerPersona,
108
+ onAway: team.setPeerAway,
109
+ }, flow: team.flow, connected: peer !== null, label: peer ? `A2A · ${peer.skill.name}` : 'A2A · not connected' }) }), _jsxs(Box, { sx: { display: 'flex', gap: 3, flexWrap: 'wrap' }, children: [_jsxs(Box, { sx: { flex: '1 1 420px', minWidth: 0 }, children: [_jsxs(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: ["Ask ", SALES.name] }), _jsx(Box, { sx: { display: 'flex', gap: 2, flexWrap: 'wrap', mb: 2 }, children: (SALES.interface.starters ?? []).map(starter => (_jsx(Button, { size: "small", disabled: !team.ready || team.busy, onClick: () => send(starter.message), children: starter.label }, starter.label))) }), _jsx(Textarea, { ref: composer, block: true, rows: 3, value: draft, placeholder: "Ask for a financial report\u2026", onChange: event => setDraft(event.target.value), onKeyDown: event => {
276
110
  if (event.key === 'Enter' && !event.shiftKey) {
277
111
  event.preventDefault();
278
- void send(draft);
112
+ send(draft);
279
113
  }
280
- } }), _jsxs(Box, { sx: { display: 'flex', gap: 2, mt: 2 }, children: [_jsx(Button, { variant: "primary", disabled: !agent || busy || !draft.trim(), onClick: () => void send(draft), children: "Ask" }), busy && (_jsx(Button, { onClick: () => abort.current?.abort(), children: "Stop" }))] }), _jsx(Box, { sx: { mt: 3 }, "data-team-conversation": "", children: turns.map((turn, index) => (_jsxs(Box, { sx: {
114
+ } }), _jsxs(Box, { sx: { display: 'flex', gap: 2, mt: 2 }, children: [_jsx(Button, { variant: "primary", disabled: !team.ready || team.busy || !draft.trim(), onClick: () => send(draft), children: "Ask" }), team.busy && _jsx(Button, { onClick: team.stop, children: "Stop" })] }), _jsx(Box, { sx: { mt: 3 }, "data-team-conversation": "", children: team.turns.map((turn, index) => (_jsxs(Box, { sx: {
281
115
  p: 2,
282
116
  mb: 2,
283
117
  borderRadius: 2,
284
118
  bg: turn.role === 'user' ? 'accent.subtle' : 'canvas.subtle',
285
- }, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', display: 'block' }, children: turn.role === 'user' ? 'You' : SALES.name }), _jsx(Streamdown, { children: turn.text })] }, index))) })] }), _jsxs(Box, { sx: { flex: '1 1 360px', minWidth: 0 }, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: "The exchange" }), _jsx(Box, { as: "ol", sx: { pl: 3, color: 'fg.muted', fontSize: 1 }, "data-team-exchange": "", children: exchange.length === 0 ? (_jsx(Text, { as: "li", children: "Nothing asked yet." })) : (exchange.map((step, index) => (_jsx(Text, { as: "li", children: step }, index)))) }), _jsx(Heading, { as: "h3", sx: { fontSize: 2, mt: 3, mb: 2 }, children: "The report" }), _jsx(Box, { sx: {
119
+ }, children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', display: 'block' }, children: turn.role === 'user' ? 'You' : SALES.name }), _jsx(Streamdown, { children: turn.text })] }, index))) })] }), _jsxs(Box, { sx: { flex: '1 1 360px', minWidth: 0 }, children: [_jsx(Heading, { as: "h3", sx: { fontSize: 2, mb: 2 }, children: "The exchange" }), _jsx(Box, { as: "ol", sx: { pl: 3, color: 'fg.muted', fontSize: 1 }, "data-team-exchange": "", children: team.exchange.length === 0 ? (_jsx(Text, { as: "li", children: "Nothing asked yet." })) : (team.exchange.map((step, index) => (_jsx(Text, { as: "li", children: step }, index)))) }), _jsx(Heading, { as: "h3", sx: { fontSize: 2, mt: 3, mb: 2 }, children: "The report" }), _jsx(Box, { sx: {
286
120
  p: 3,
287
121
  border: '1px solid',
288
122
  borderColor: 'border.default',
289
123
  borderRadius: 2,
290
124
  minHeight: 120,
291
- }, "data-team-report": "", children: report ? (_jsx(Streamdown, { children: report })) : (_jsx(Text, { sx: { color: 'fg.muted' }, children: "The report Accounting returns appears here, as it returned it." })) })] })] })] }));
125
+ }, "data-team-report": "", children: team.report ? (_jsx(Streamdown, { children: team.report })) : (_jsx(Text, { sx: { color: 'fg.muted' }, children: "The report Accounting returns appears here, as it returned it." })) })] })] })] }));
292
126
  }
293
127
  export function AgentA2ATeamExample() {
294
128
  return (_jsx(ThemedProvider, { children: _jsx(AgentA2ATeam, {}) }));
@@ -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.'),
@@ -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,6 +91,9 @@ 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' ||
@@ -108,7 +114,7 @@ const getExampleGroup = (id) => {
108
114
  // runtime: checkpoints, hooks, memory, guardrails…
109
115
  if (id.startsWith('Agent'))
110
116
  return 'Capabilities';
111
- if (id.startsWith('Chat'))
117
+ if (id.startsWith('Chat') || id === 'VoiceChatExample')
112
118
  return 'Chat';
113
119
  // The document examples: the ones on the Lexical editor, and the page
114
120
  // with a document on it.
@@ -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) {
@@ -187,6 +187,11 @@ export class VercelAIAdapter extends BaseProtocolAdapter {
187
187
  id: msg.id,
188
188
  role: msg.role,
189
189
  parts,
190
+ // How a message was heard, when it was said (VOICE.md VO-27):
191
+ // the runtime keeps the turn marked spoken.
192
+ ...(msg.role === 'user' && msg.metadata?.input === 'voice'
193
+ ? { metadata: msg.metadata }
194
+ : {}),
190
195
  };
191
196
  });
192
197
  // For tool-result messages, add them as separate tool parts