@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.
- package/lib/chat/ChatFloating.d.ts +9 -1
- package/lib/chat/ChatFloating.js +69 -18
- package/lib/chat/assistant/AssistantStage.d.ts +14 -1
- package/lib/chat/assistant/AssistantStage.js +48 -1
- package/lib/chat/assistant/state.d.ts +7 -1
- package/lib/chat/assistant/state.js +3 -2
- package/lib/chat/base/ChatBase.js +32 -4
- package/lib/chat/messages/ChatMessageList.d.ts +0 -6
- package/lib/chat/messages/ChatMessageList.js +8 -2
- package/lib/components/teams/A2ATeamGraph.d.ts +58 -0
- package/lib/components/teams/A2ATeamGraph.js +190 -0
- package/lib/components/teams/a2aTeamFlow.d.ts +33 -0
- package/lib/components/teams/a2aTeamFlow.js +32 -0
- package/lib/components/teams/index.d.ts +11 -0
- package/lib/components/teams/index.js +15 -0
- package/lib/components/teams/useA2ATeam.d.ts +57 -0
- package/lib/components/teams/useA2ATeam.js +233 -0
- package/lib/examples/AgentA2ATeamExample.d.ts +6 -3
- package/lib/examples/AgentA2ATeamExample.js +42 -208
- package/lib/examples/VoiceChatExample.d.ts +20 -0
- package/lib/examples/VoiceChatExample.js +64 -0
- package/lib/examples/example-selector.js +1 -0
- package/lib/examples/main.js +7 -1
- package/lib/loop/apps/appspec.d.ts +3 -1
- package/lib/loop/apps/appspec.js +31 -0
- package/lib/protocols/VercelAIAdapter.js +5 -0
- package/lib/specs/apps.js +112 -0
- package/lib/specs/appspecSchema.js +65 -0
- package/lib/specs/index.d.ts +1 -0
- package/lib/specs/index.js +1 -0
- package/lib/specs/voices.d.ts +72 -0
- package/lib/specs/voices.js +344 -0
- package/lib/types/agentspecs.d.ts +16 -0
- package/lib/types/chat.d.ts +11 -0
- package/lib/voice/VoiceInput.d.ts +35 -0
- package/lib/voice/VoiceInput.js +233 -0
- package/lib/voice/capture.d.ts +30 -0
- package/lib/voice/capture.js +113 -0
- package/lib/voice/consent.d.ts +6 -0
- package/lib/voice/consent.js +34 -0
- package/lib/voice/hearing.d.ts +37 -0
- package/lib/voice/hearing.js +168 -0
- package/lib/voice/index.d.ts +47 -0
- package/lib/voice/index.js +13 -0
- package/lib/voice/pinned.d.ts +28 -0
- package/lib/voice/pinned.js +75 -0
- package/lib/voice/sentences.d.ts +26 -0
- package/lib/voice/sentences.js +71 -0
- package/lib/voice/speaker.d.ts +66 -0
- package/lib/voice/speaker.js +168 -0
- package/lib/voice/types.d.ts +92 -0
- package/lib/voice/types.js +5 -0
- package/lib/voice/useSpokenAnswers.d.ts +12 -0
- package/lib/voice/useSpokenAnswers.js +90 -0
- package/package.json +7 -3
- package/scripts/codegen/generate_voices.py +279 -0
- package/scripts/voice/measure.py +244 -0
- package/scripts/voice/pin_store.py +159 -0
- package/scripts/voice/serve_store.py +70 -0
- 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 {
|
|
3
|
-
import { Button, Flash, Heading,
|
|
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 {
|
|
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
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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" }), "."] })),
|
|
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
|
-
|
|
87
|
+
px: 2,
|
|
88
|
+
pt: 2,
|
|
263
89
|
mb: 3,
|
|
264
|
-
}, children:
|
|
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',
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
112
|
+
send(draft);
|
|
279
113
|
}
|
|
280
|
-
} }), _jsxs(Box, { sx: { display: 'flex', gap: 2, mt: 2 }, children: [_jsx(Button, { variant: "primary", disabled: !
|
|
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.'),
|
package/lib/examples/main.js
CHANGED
|
@@ -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;
|
package/lib/loop/apps/appspec.js
CHANGED
|
@@ -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
|