@datalayer/agent-runtimes 1.5.61 → 1.5.62
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/dist-embed/datalayer-app-main.js +30 -30
- package/lib/chat/assistant/AssistantStage.js +51 -3
- package/lib/chat/assistant/balloonHover.d.ts +29 -0
- package/lib/chat/assistant/balloonHover.js +48 -0
- package/lib/voice/speaker.d.ts +15 -0
- package/lib/voice/speaker.js +116 -8
- package/lib/voice/useVoiceSpeaker.js +3 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Suspense, lazy, useCallback, useEffect, useId, useRef, useState, } from 'react';
|
|
2
|
+
import { Suspense, lazy, useCallback, useEffect, useId, useReducer, useRef, useState, } from 'react';
|
|
3
3
|
import { ActionList, ActionMenu, IconButton, useTheme } from '@primer/react';
|
|
4
4
|
import { Box } from '@datalayer/primer-addons';
|
|
5
5
|
import { CodeIcon, CommentDiscussionIcon, DependabotIcon, InfoIcon, MuteIcon, PencilIcon, PlusIcon, PulseIcon, ScreenNormalIcon, SquareFillIcon, TrashIcon, UnmuteIcon, XIcon, } from '@primer/octicons-react';
|
|
@@ -11,6 +11,7 @@ import { ASSISTANT_CLEARANCE_VAR } from './clearance';
|
|
|
11
11
|
import { AssistantContextMenu, LONG_PRESS_MS, opensContextMenu, } from './AssistantContextMenu';
|
|
12
12
|
import { BalloonExpandContext, ExpandedVisual, focusExpanded, } from './BalloonVisual';
|
|
13
13
|
import { SpriteCharacter } from './SpriteCharacter';
|
|
14
|
+
import { BALLOON_GRACE_MS, BALLOON_HOVER_START, balloonHover, graceRuns, } from './balloonHover';
|
|
14
15
|
import { isAssistantModel3D } from './avatar3d';
|
|
15
16
|
import { conversationCount, } from './toolLine';
|
|
16
17
|
import { ASSISTANT_OBSTACLES, POINTER_CALM_MS, mouthOpening, boxesMeet, pointerNear, } from './state';
|
|
@@ -393,7 +394,33 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
393
394
|
// Drawn for the chat's colour mode: the dark drawing on a dark page (T-25).
|
|
394
395
|
const { colorScheme } = useTheme();
|
|
395
396
|
const colorMode = colorScheme?.startsWith('dark') ? 'dark' : 'light';
|
|
396
|
-
|
|
397
|
+
// The figure and its balloon are one hover region: the balloon stays
|
|
398
|
+
// while the pointer crosses to it, and closes once the pointer has been
|
|
399
|
+
// on neither for a moment, on Escape or on a press outside.
|
|
400
|
+
const [hover, dispatchHover] = useReducer(balloonHover, BALLOON_HOVER_START);
|
|
401
|
+
const hovered = hover.shown;
|
|
402
|
+
const hoverGrace = graceRuns(hover);
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
if (!hoverGrace) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const grace = setTimeout(() => dispatchHover('grace'), BALLOON_GRACE_MS);
|
|
408
|
+
return () => clearTimeout(grace);
|
|
409
|
+
}, [hoverGrace]);
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
if (!hovered) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const onPress = (event) => {
|
|
415
|
+
const target = event.target;
|
|
416
|
+
if (target && stageRef.current?.contains(target)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
dispatchHover('outside');
|
|
420
|
+
};
|
|
421
|
+
document.addEventListener('pointerdown', onPress, true);
|
|
422
|
+
return () => document.removeEventListener('pointerdown', onPress, true);
|
|
423
|
+
}, [hovered, stageRef]);
|
|
397
424
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
398
425
|
// Its own menu, where it was opened; the inspector and *About* it opens.
|
|
399
426
|
const [menuAt, setMenuAt] = useState(null);
|
|
@@ -596,7 +623,28 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
|
|
|
596
623
|
'@media (prefers-reduced-motion: reduce)': {
|
|
597
624
|
'& *': { animation: 'none !important' },
|
|
598
625
|
},
|
|
599
|
-
}, onMouseEnter: () =>
|
|
626
|
+
}, onMouseEnter: () => dispatchHover('enter'), onMouseLeave: () => dispatchHover('leave'), onFocus: (event) => {
|
|
627
|
+
// Keyboard focus keeps the balloon; a click's focus does not.
|
|
628
|
+
let keyboard = true;
|
|
629
|
+
try {
|
|
630
|
+
keyboard = event.target.matches(':focus-visible');
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// No :focus-visible here: count it.
|
|
634
|
+
}
|
|
635
|
+
if (keyboard) {
|
|
636
|
+
dispatchHover('focus');
|
|
637
|
+
}
|
|
638
|
+
}, onBlur: (event) => {
|
|
639
|
+
const next = event.relatedTarget;
|
|
640
|
+
if (!next || !event.currentTarget.contains(next)) {
|
|
641
|
+
dispatchHover('blur');
|
|
642
|
+
}
|
|
643
|
+
}, onKeyDown: (event) => {
|
|
644
|
+
if (event.key === 'Escape' && hovered) {
|
|
645
|
+
dispatchHover('escape');
|
|
646
|
+
}
|
|
647
|
+
}, children: [shown && (_jsx(ExpandedVisual, { visual: shown, target: expandTarget, onClose: () => setExpanded(null) })), showBalloon && balloon && (_jsx(BalloonExpandContext.Provider, { value: expand, children: _jsx(SpeechBalloon, { text: balloon.text, more: balloon.more, approval: balloon.approval, onDismissPeek: balloon.onDismiss, display: balloonDisplay, tool: balloon.tool, speaking: balloon.speaking, busy: balloon.busy, attachment: balloon.attachment, history: balloon.history, onExpand: expand ?? undefined, expandTitle: visual?.title, onExpandHistory: historyVisual
|
|
600
648
|
? () => {
|
|
601
649
|
setExpanded(historyVisual);
|
|
602
650
|
if (expandTarget) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the assistant's balloon shows for a hover — the hover-intent rule.
|
|
3
|
+
*
|
|
4
|
+
* The figure and its balloon are one region: the balloon opens when the
|
|
5
|
+
* pointer enters either, and stays while it is over either. Leaving them
|
|
6
|
+
* does not close it at once — the pointer crosses the gap between the figure
|
|
7
|
+
* and the balloon, or a 3D figure's canvas redraws under it — it closes once
|
|
8
|
+
* the pointer has been on neither for `BALLOON_GRACE_MS`. Keyboard focus in
|
|
9
|
+
* the region keeps it open too. Escape, or a press outside, closes it at
|
|
10
|
+
* once; it opens again on the next enter or focus.
|
|
11
|
+
*
|
|
12
|
+
* Pure: the stage dispatches what happened and runs the grace timer while
|
|
13
|
+
* `graceRuns` says so.
|
|
14
|
+
*/
|
|
15
|
+
/** How long the balloon stays once the pointer is on neither, in ms. */
|
|
16
|
+
export declare const BALLOON_GRACE_MS = 700;
|
|
17
|
+
export interface BalloonHover {
|
|
18
|
+
/** The balloon shows for the hover. */
|
|
19
|
+
shown: boolean;
|
|
20
|
+
/** The pointer is over the figure or its balloon. */
|
|
21
|
+
over: boolean;
|
|
22
|
+
/** Keyboard focus is in the figure or its balloon. */
|
|
23
|
+
focused: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type BalloonHoverEvent = 'enter' | 'leave' | 'focus' | 'blur' | 'grace' | 'escape' | 'outside';
|
|
26
|
+
export declare const BALLOON_HOVER_START: BalloonHover;
|
|
27
|
+
export declare function balloonHover(state: BalloonHover, event: BalloonHoverEvent): BalloonHover;
|
|
28
|
+
/** Whether the grace timer runs: shown, the pointer on neither, no focus. */
|
|
29
|
+
export declare function graceRuns(state: BalloonHover): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* When the assistant's balloon shows for a hover — the hover-intent rule.
|
|
7
|
+
*
|
|
8
|
+
* The figure and its balloon are one region: the balloon opens when the
|
|
9
|
+
* pointer enters either, and stays while it is over either. Leaving them
|
|
10
|
+
* does not close it at once — the pointer crosses the gap between the figure
|
|
11
|
+
* and the balloon, or a 3D figure's canvas redraws under it — it closes once
|
|
12
|
+
* the pointer has been on neither for `BALLOON_GRACE_MS`. Keyboard focus in
|
|
13
|
+
* the region keeps it open too. Escape, or a press outside, closes it at
|
|
14
|
+
* once; it opens again on the next enter or focus.
|
|
15
|
+
*
|
|
16
|
+
* Pure: the stage dispatches what happened and runs the grace timer while
|
|
17
|
+
* `graceRuns` says so.
|
|
18
|
+
*/
|
|
19
|
+
/** How long the balloon stays once the pointer is on neither, in ms. */
|
|
20
|
+
export const BALLOON_GRACE_MS = 700;
|
|
21
|
+
export const BALLOON_HOVER_START = {
|
|
22
|
+
shown: false,
|
|
23
|
+
over: false,
|
|
24
|
+
focused: false,
|
|
25
|
+
};
|
|
26
|
+
export function balloonHover(state, event) {
|
|
27
|
+
switch (event) {
|
|
28
|
+
case 'enter':
|
|
29
|
+
return { ...state, over: true, shown: true };
|
|
30
|
+
case 'leave':
|
|
31
|
+
// Still shown: the grace runs.
|
|
32
|
+
return { ...state, over: false };
|
|
33
|
+
case 'focus':
|
|
34
|
+
return { ...state, focused: true, shown: true };
|
|
35
|
+
case 'blur':
|
|
36
|
+
return { ...state, focused: false };
|
|
37
|
+
case 'grace':
|
|
38
|
+
return state.over || state.focused ? state : { ...state, shown: false };
|
|
39
|
+
case 'escape':
|
|
40
|
+
return { ...state, shown: false };
|
|
41
|
+
case 'outside':
|
|
42
|
+
return { ...state, shown: false, over: false, focused: false };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Whether the grace timer runs: shown, the pointer on neither, no focus. */
|
|
46
|
+
export function graceRuns(state) {
|
|
47
|
+
return state.shown && !state.over && !state.focused;
|
|
48
|
+
}
|
package/lib/voice/speaker.d.ts
CHANGED
|
@@ -57,6 +57,14 @@ export interface SpeakerOptions {
|
|
|
57
57
|
* caller that follows its sentences one by one (a deck's narrator).
|
|
58
58
|
*/
|
|
59
59
|
firstClauseFirst?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Ask each sentence streamed (VO-52): the speech service says it piece by
|
|
62
|
+
* piece where it pauses and sends each piece's 16-bit PCM as soon as it is
|
|
63
|
+
* said, and the speaker plays the pieces as they come — the first sound
|
|
64
|
+
* after the first piece, not the whole sentence. A service that answers a
|
|
65
|
+
* whole sound instead is played as before.
|
|
66
|
+
*/
|
|
67
|
+
streamed?: boolean;
|
|
60
68
|
fetcher?: typeof fetch;
|
|
61
69
|
}
|
|
62
70
|
/**
|
|
@@ -84,6 +92,8 @@ export declare class ServerSpeaker {
|
|
|
84
92
|
private buffer?;
|
|
85
93
|
private queue;
|
|
86
94
|
private playing?;
|
|
95
|
+
/** The pieces of a streamed sentence scheduled to play, stopped together. */
|
|
96
|
+
private scheduled;
|
|
87
97
|
private running;
|
|
88
98
|
private generation;
|
|
89
99
|
private state;
|
|
@@ -109,6 +119,11 @@ export declare class ServerSpeaker {
|
|
|
109
119
|
private askAhead;
|
|
110
120
|
private fetch;
|
|
111
121
|
private run;
|
|
122
|
+
/**
|
|
123
|
+
* A sentence streamed as 16-bit PCM (VO-52): played in slices as they
|
|
124
|
+
* come, each scheduled right after the last, until the last has ended.
|
|
125
|
+
*/
|
|
126
|
+
private playStreamed;
|
|
112
127
|
/** Stop at once: what plays and what waits (barge-in, `Esc`; VO-13). */
|
|
113
128
|
stop(): void;
|
|
114
129
|
/** Stop, and let the audio context go. */
|
package/lib/voice/speaker.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
/** The path of the syntheses on ai-agents. */
|
|
15
15
|
export const SYNTHESES_PATH = '/api/ai-agents/v1/speech/syntheses';
|
|
16
|
+
/** Played as it comes: at least this much at a time, in seconds. */
|
|
17
|
+
const STREAMED_SLICE_SECONDS = 0.25;
|
|
16
18
|
/**
|
|
17
19
|
* How many sentences are asked of the speech service ahead of the one
|
|
18
20
|
* playing: enough that the next is ready when one ends, and under the calls
|
|
@@ -58,6 +60,8 @@ export class ServerSpeaker {
|
|
|
58
60
|
buffer;
|
|
59
61
|
queue = [];
|
|
60
62
|
playing;
|
|
63
|
+
/** The pieces of a streamed sentence scheduled to play, stopped together. */
|
|
64
|
+
scheduled = [];
|
|
61
65
|
running = false;
|
|
62
66
|
generation = 0;
|
|
63
67
|
state = { speaking: false };
|
|
@@ -129,6 +133,7 @@ export class ServerSpeaker {
|
|
|
129
133
|
}
|
|
130
134
|
// Nothing playing or waiting: this is the first thing heard (VO-52).
|
|
131
135
|
const first = this.options.firstClauseFirst &&
|
|
136
|
+
!this.options.streamed &&
|
|
132
137
|
!this.queue.length &&
|
|
133
138
|
!this.playing &&
|
|
134
139
|
!this.running;
|
|
@@ -165,7 +170,9 @@ export class ServerSpeaker {
|
|
|
165
170
|
text,
|
|
166
171
|
voice,
|
|
167
172
|
language,
|
|
168
|
-
|
|
173
|
+
...(this.options.streamed
|
|
174
|
+
? { format: 'pcm', stream: true }
|
|
175
|
+
: { format: 'opus' }),
|
|
169
176
|
}),
|
|
170
177
|
});
|
|
171
178
|
if (!answered.ok) {
|
|
@@ -174,7 +181,18 @@ export class ServerSpeaker {
|
|
|
174
181
|
? body.detail
|
|
175
182
|
: `The answer could not be spoken (${answered.status}).`;
|
|
176
183
|
}
|
|
177
|
-
|
|
184
|
+
const kind = answered.headers.get('content-type') ?? '';
|
|
185
|
+
if (kind.toLowerCase().startsWith('audio/l16')) {
|
|
186
|
+
const rate = Number(answered.headers.get('x-speech-rate')) ||
|
|
187
|
+
Number(/rate=(\d+)/i.exec(kind)?.[1]) ||
|
|
188
|
+
24000;
|
|
189
|
+
return {
|
|
190
|
+
kind: 'pcm',
|
|
191
|
+
rate,
|
|
192
|
+
body: answered.body ?? (await answered.arrayBuffer()),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { kind: 'whole', audio: await answered.arrayBuffer() };
|
|
178
196
|
}
|
|
179
197
|
catch {
|
|
180
198
|
return 'Datalayer’s speech service could not be reached: the answer is still written here.';
|
|
@@ -200,7 +218,11 @@ export class ServerSpeaker {
|
|
|
200
218
|
this.queue.shift();
|
|
201
219
|
this.askAhead();
|
|
202
220
|
const context = this.audio();
|
|
203
|
-
|
|
221
|
+
if (audio.kind === 'pcm') {
|
|
222
|
+
await this.playStreamed(generation, next, audio);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const decoded = await context.decodeAudioData(audio.audio);
|
|
204
226
|
if (generation !== this.generation) {
|
|
205
227
|
return;
|
|
206
228
|
}
|
|
@@ -230,17 +252,103 @@ export class ServerSpeaker {
|
|
|
230
252
|
}
|
|
231
253
|
}
|
|
232
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* A sentence streamed as 16-bit PCM (VO-52): played in slices as they
|
|
257
|
+
* come, each scheduled right after the last, until the last has ended.
|
|
258
|
+
*/
|
|
259
|
+
async playStreamed(generation, next, answer) {
|
|
260
|
+
const context = this.audio();
|
|
261
|
+
const speed = this.speed();
|
|
262
|
+
if (this.gain) {
|
|
263
|
+
this.gain.gain.value = this.loudness();
|
|
264
|
+
}
|
|
265
|
+
let at = 0;
|
|
266
|
+
let first = true;
|
|
267
|
+
let pending = new Uint8Array(0);
|
|
268
|
+
let last = Promise.resolve();
|
|
269
|
+
const slice = Math.round(answer.rate * STREAMED_SLICE_SECONDS) * 2;
|
|
270
|
+
const play = (bytes) => {
|
|
271
|
+
const samples = new Float32Array(bytes.length / 2);
|
|
272
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
|
|
273
|
+
for (let k = 0; k < samples.length; k++) {
|
|
274
|
+
samples[k] = view.getInt16(k * 2, true) / 32768;
|
|
275
|
+
}
|
|
276
|
+
const buffer = context.createBuffer(1, samples.length, answer.rate);
|
|
277
|
+
buffer.getChannelData(0).set(samples);
|
|
278
|
+
const source = context.createBufferSource();
|
|
279
|
+
source.buffer = buffer;
|
|
280
|
+
source.playbackRate.value = speed;
|
|
281
|
+
source.connect(this.analyser);
|
|
282
|
+
at = Math.max(at, context.currentTime);
|
|
283
|
+
last = new Promise(resolve => {
|
|
284
|
+
source.onended = () => {
|
|
285
|
+
this.scheduled = this.scheduled.filter(one => one !== source);
|
|
286
|
+
resolve();
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
this.scheduled.push(source);
|
|
290
|
+
this.playing = source;
|
|
291
|
+
if (first) {
|
|
292
|
+
first = false;
|
|
293
|
+
this.set({ speaking: true, sentence: next.text, refused: undefined });
|
|
294
|
+
this.options.onFirstSound?.(Math.round(performance.now() - next.asked));
|
|
295
|
+
}
|
|
296
|
+
source.start(at);
|
|
297
|
+
at += buffer.duration / speed;
|
|
298
|
+
};
|
|
299
|
+
const take = (chunk, done) => {
|
|
300
|
+
const joined = new Uint8Array(pending.length + chunk.length);
|
|
301
|
+
joined.set(pending);
|
|
302
|
+
joined.set(chunk, pending.length);
|
|
303
|
+
pending = joined;
|
|
304
|
+
while (pending.length >= slice || (done && pending.length >= 2)) {
|
|
305
|
+
const size = Math.min(pending.length, Math.max(slice, 2));
|
|
306
|
+
const even = size - (size % 2);
|
|
307
|
+
play(pending.subarray(0, even));
|
|
308
|
+
pending = pending.slice(even);
|
|
309
|
+
if (done && pending.length < 2) {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
if (answer.body instanceof ArrayBuffer) {
|
|
315
|
+
take(new Uint8Array(answer.body), true);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const reader = answer.body.getReader();
|
|
319
|
+
for (;;) {
|
|
320
|
+
const { done, value } = await reader.read();
|
|
321
|
+
if (generation !== this.generation) {
|
|
322
|
+
void reader.cancel().catch(() => undefined);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (done) {
|
|
326
|
+
take(new Uint8Array(0), true);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
take(value, false);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
await last;
|
|
333
|
+
this.playing = undefined;
|
|
334
|
+
}
|
|
233
335
|
/** Stop at once: what plays and what waits (barge-in, `Esc`; VO-13). */
|
|
234
336
|
stop() {
|
|
235
337
|
this.generation += 1;
|
|
236
338
|
this.queue = [];
|
|
237
339
|
this.running = false;
|
|
238
|
-
|
|
239
|
-
this.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
340
|
+
for (const source of [
|
|
341
|
+
...this.scheduled,
|
|
342
|
+
...(this.playing ? [this.playing] : []),
|
|
343
|
+
]) {
|
|
344
|
+
try {
|
|
345
|
+
source.stop();
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
// Already ended.
|
|
349
|
+
}
|
|
243
350
|
}
|
|
351
|
+
this.scheduled = [];
|
|
244
352
|
this.playing = undefined;
|
|
245
353
|
this.set({ speaking: false, sentence: undefined });
|
|
246
354
|
}
|
|
@@ -30,7 +30,9 @@ export function useVoiceSpeaker(voice, on = true) {
|
|
|
30
30
|
rate: voice.rate,
|
|
31
31
|
volume: voice.volume,
|
|
32
32
|
onState: setState,
|
|
33
|
-
// The chat's answers:
|
|
33
|
+
// The chat's answers: streamed, played as they are said (VO-52);
|
|
34
|
+
// from a service that does not stream, the first clause first.
|
|
35
|
+
streamed: true,
|
|
34
36
|
firstClauseFirst: true,
|
|
35
37
|
})
|
|
36
38
|
: undefined,
|