@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.
@@ -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
- const [hovered, setHovered] = useState(false);
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: () => setHovered(true), onMouseLeave: () => setHovered(false), 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
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
+ }
@@ -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. */
@@ -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
- format: 'opus',
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
- return await answered.arrayBuffer();
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
- const decoded = await context.decodeAudioData(audio);
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
- try {
239
- this.playing?.stop();
240
- }
241
- catch {
242
- // Already ended.
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: a long first sentence's first clause first.
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datalayer/agent-runtimes",
3
- "version": "1.5.61",
3
+ "version": "1.5.62",
4
4
  "type": "module",
5
5
  "workspaces": [
6
6
  ".",