@datalayer/agent-runtimes 1.5.27 → 1.5.30

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 (46) hide show
  1. package/dist-embed/chunks/ChatView-C_NOLqwI.js +32 -0
  2. package/dist-embed/datalayer-app-main.js +103 -93
  3. package/lib/apps/apps/AppKept.js +5 -1
  4. package/lib/apps/apps/appspec.d.ts +0 -5
  5. package/lib/apps/apps/appspec.js +1 -6
  6. package/lib/apps/apps/checks.js +4 -9
  7. package/lib/apps/embed/element.js +24 -7
  8. package/lib/apps/embed/embedConfig.d.ts +11 -2
  9. package/lib/apps/embed/embedConfig.js +26 -2
  10. package/lib/apps/embed/visitorApp.d.ts +9 -2
  11. package/lib/apps/embed/visitorApp.js +9 -2
  12. package/lib/apps/plugins/chat/ChatView.js +6 -3
  13. package/lib/chat/assistant/AssistantStage.js +17 -0
  14. package/lib/chat/assistant/clearance.d.ts +10 -0
  15. package/lib/chat/assistant/clearance.js +14 -0
  16. package/lib/chat/base/ChatBase.js +12 -3
  17. package/lib/specs/appspecSchema.js +2 -2
  18. package/lib/specs/voices.js +89 -0
  19. package/lib/types/agentspecs.d.ts +1 -1
  20. package/lib/voice/ComposerVoice.d.ts +10 -2
  21. package/lib/voice/ComposerVoice.js +35 -5
  22. package/lib/voice/HandsFree.d.ts +55 -0
  23. package/lib/voice/HandsFree.js +211 -0
  24. package/lib/voice/VoiceInput.d.ts +2 -2
  25. package/lib/voice/VoiceInput.js +6 -4
  26. package/lib/voice/VoiceMenu.js +2 -1
  27. package/lib/voice/chatVoice.d.ts +6 -4
  28. package/lib/voice/chatVoice.js +11 -12
  29. package/lib/voice/consent.d.ts +8 -0
  30. package/lib/voice/consent.js +10 -0
  31. package/lib/voice/handsFree.d.ts +58 -0
  32. package/lib/voice/handsFree.js +48 -0
  33. package/lib/voice/hearing.d.ts +23 -1
  34. package/lib/voice/hearing.js +18 -3
  35. package/lib/voice/index.d.ts +21 -8
  36. package/lib/voice/index.js +3 -0
  37. package/lib/voice/serverHearing.d.ts +58 -0
  38. package/lib/voice/serverHearing.js +136 -0
  39. package/lib/voice/settings.d.ts +5 -0
  40. package/lib/voice/settings.js +8 -0
  41. package/lib/voice/speaker.d.ts +4 -3
  42. package/lib/voice/speaker.js +1 -2
  43. package/lib/voice/useVoiceSpeaker.js +0 -3
  44. package/package.json +2 -2
  45. package/scripts/voice/measure.py +74 -0
  46. package/dist-embed/chunks/ChatView-CUWE6I5M.js +0 -32
@@ -4,6 +4,7 @@ import { contribution, definePlugin, signal } from '@datalayer/reactor';
4
4
  import { useContributions, useSignalValue } from '@datalayer/reactor/react';
5
5
  import { LoopChatTurn, LoopPromptPanel } from '../core';
6
6
  import { beforeFirstMessage } from './kept';
7
+ import { ASSISTANT_CLEARANCE_VAR } from '../../chat/assistant/clearance';
7
8
  export const APP_KEPT_PLUGIN_NAME = '@datalayer/loop-plugin-app-kept';
8
9
  /* A signal to read when no chat contributed a turn: the hook needs one. */
9
10
  const NO_TURN = signal({ id: 0, status: 'idle' });
@@ -13,7 +14,10 @@ export function AppKept({ says }) {
13
14
  if (!beforeFirstMessage(turn)) {
14
15
  return null;
15
16
  }
16
- return (_jsx("div", { role: "note", style: { padding: '4px 8px' }, children: _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: says }) }));
17
+ return (
18
+ // Clear of the floating assistant in its corner, at a phone's width too
19
+ // (REVIEWS Q-08).
20
+ _jsx("div", { role: "note", style: { padding: `4px var(${ASSISTANT_CLEARANCE_VAR}, 8px) 4px 8px` }, children: _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, children: says }) }));
17
21
  }
18
22
  /**
19
23
  * The plugin, for one application: what it keeps, under its prompt. Made
@@ -99,11 +99,6 @@ export declare const PAP_PROFILE: PapProfile;
99
99
  * (`pap_profile_problem`); `undefined` when it is one Datalayer speaks.
100
100
  */
101
101
  export declare function papProfileProblem(value: unknown): string | undefined;
102
- /**
103
- * Why `interface.voice.input: hands_free` is refused until VOICE.md VO-12 is
104
- * built (decided 14 October 2026): never taken for click-to-talk.
105
- */
106
- export declare const HANDS_FREE_NOT_BUILT = "hands_free is not built yet (VOICE.md VO-12): say push_to_talk \u2014 click the microphone or hold it, speak, and it stops when let go or once the person stops speaking";
107
102
  /** An application's voice (VOICE.md VO-41): off unless said. */
108
103
  export declare const DEFAULT_VOICE: AppVoiceSpec;
109
104
  /** What a page's output may be drawn with (LOOP P-05), and the property of each that shows its value. */
@@ -713,11 +713,6 @@ function parseSurface(data) {
713
713
  composedAt: text(data.composed_at),
714
714
  };
715
715
  }
716
- /**
717
- * Why `interface.voice.input: hands_free` is refused until VOICE.md VO-12 is
718
- * built (decided 14 October 2026): never taken for click-to-talk.
719
- */
720
- export const HANDS_FREE_NOT_BUILT = 'hands_free is not built yet (VOICE.md VO-12): say push_to_talk — click the microphone or hold it, speak, and it stops when let go or once the person stops speaking';
721
716
  /** An application's voice (VOICE.md VO-41): off unless said. */
722
717
  export const DEFAULT_VOICE = {
723
718
  enabled: false,
@@ -731,7 +726,7 @@ function parseVoice(data) {
731
726
  const voice = isData(data) ? data : {};
732
727
  return {
733
728
  enabled: voice.enabled === true,
734
- input: oneOf(voice.input, ['off', 'push_to_talk'], DEFAULT_VOICE.input),
729
+ input: oneOf(voice.input, ['off', 'push_to_talk', 'hands_free'], DEFAULT_VOICE.input),
735
730
  output: oneOf(voice.output, ['off', 'on_request', 'always'], DEFAULT_VOICE.output),
736
731
  voice: text(voice.voice),
737
732
  language: text(voice.language),
@@ -39,7 +39,7 @@ import { getTeamSpec } from '../../specs/teams';
39
39
  import { getBackendToolSpec } from '../../specs/backendTools';
40
40
  import { getTrack } from '../../specs/tracks';
41
41
  import { pluginsOffSetupNotes } from '../plugins/canvas-blocks';
42
- import { APP_THEME_MODES, APP_THEME_VARIANTS, HANDS_FREE_NOT_BUILT, isAssistantCharacterId, PAGE_OUTPUT_SHOWS, parseAppspec, } from './appspec';
42
+ import { APP_THEME_MODES, APP_THEME_VARIANTS, isAssistantCharacterId, PAGE_OUTPUT_SHOWS, parseAppspec, } from './appspec';
43
43
  import { classesOf, splitRef, toolBehaviours } from './rules';
44
44
  import { COMMAND_INPUT, COMMAND_NAME, MODE_ID } from './composer';
45
45
  import { HOST_NAME, HOST_USERS, hostToolsOf } from './hostTools';
@@ -685,7 +685,7 @@ const ENUMS = {
685
685
  visibility: ['private', 'invited', 'organization', 'link', 'public'],
686
686
  mode: ['inline', 'bubble', 'panel', 'assistant'],
687
687
  balloon: ['history', 'current'],
688
- voiceInput: ['off', 'push_to_talk'],
688
+ voiceInput: ['off', 'push_to_talk', 'hands_free'],
689
689
  voiceOutput: ['off', 'on_request', 'always'],
690
690
  voiceWhere: ['auto', 'device', 'server'],
691
691
  themeVariant: APP_THEME_VARIANTS,
@@ -843,14 +843,9 @@ export function documentShapeProblems(document) {
843
843
  if (ui.assistant !== undefined && !isAssistantCharacterId(ui.assistant)) {
844
844
  at('interface.assistant', 'names a character by the id a plugin contributes it under, lowercase words joined by a hyphen: `paperclip`, `acme-owl`');
845
845
  }
846
- // Its voice (VOICE.md VO-41); hands-free refused until it is built (VO-12).
846
+ // Its voice (VOICE.md VO-41, VO-12).
847
847
  mapping(ui.voice, 'interface.voice', voice => {
848
- if (voice.input === 'hands_free') {
849
- at('interface.voice.input', HANDS_FREE_NOT_BUILT);
850
- }
851
- else {
852
- oneOf(voice.input, ENUMS.voiceInput, 'interface.voice.input');
853
- }
848
+ oneOf(voice.input, ENUMS.voiceInput, 'interface.voice.input');
854
849
  oneOf(voice.output, ENUMS.voiceOutput, 'interface.voice.output');
855
850
  oneOf(voice.where, ENUMS.voiceWhere, 'interface.voice.where');
856
851
  });
@@ -70,7 +70,7 @@ import { parseAppspec } from '../apps/appspec';
70
70
  import { readAppspecYaml } from '../apps/yaml';
71
71
  import { AppEmbed, embedAssistantCharacter } from './AppEmbed';
72
72
  import { WINDOW_NOT_YET } from './embedSession';
73
- import { EMBED_HOST_VARIABLES, EMBED_OBSERVED_ATTRIBUTES, EMBED_TAG, EmbedAttributeError, adoptPropertiesSetEarly, embedLookOf, inlineHeightOf, languageOf, resumeOf, runnable, } from './embedConfig';
73
+ import { EMBED_HOST_VARIABLES, EMBED_OBSERVED_ATTRIBUTES, EMBED_TAG, EMBED_TOKEN_NAMES_NO_DEPLOYMENT, EmbedAttributeError, adoptPropertiesSetEarly, embedLookOf, embedTokenDeployment, inlineHeightOf, languageOf, resumeOf, runnable, } from './embedConfig';
74
74
  import { embedShadowCss } from './embedTheme';
75
75
  import { DEFAULT_API, readVisitorApp } from './visitorApp';
76
76
  export { runnable };
@@ -164,9 +164,15 @@ export async function readEmbeddedApp({ api, origin, app, token, fetcher = fetch
164
164
  return { kind: 'frame', src: frameSrcOf(origin, app, token) };
165
165
  }
166
166
  const spec = runnable(parseAppspec(definition.spec));
167
- return spec.kind === 'decision'
168
- ? { kind: 'frame', src: frameSrcOf(origin, app, token) }
169
- : { kind: 'spec', app: spec };
167
+ if (spec.kind === 'decision') {
168
+ return { kind: 'frame', src: frameSrcOf(origin, app, token) };
169
+ }
170
+ // The visit runs the deployment its token names, and no other (VOICE.md
171
+ // decision 16): a token naming none is refused before anything is drawn.
172
+ if (!embedTokenDeployment(token)) {
173
+ return { kind: 'said', text: EMBED_TOKEN_NAMES_NO_DEPLOYMENT };
174
+ }
175
+ return { kind: 'spec', app: spec };
170
176
  }
171
177
  /** The script that is running, when it is a script tag. */
172
178
  function currentScriptUrl() {
@@ -377,7 +383,7 @@ export function defineDatalayerAppElement(options = {}) {
377
383
  // A public application, as a visitor without an account (D-07):
378
384
  // an example kept warm or an address open to visitors, run on the
379
385
  // visitors' runtime; a decision framed; a refusal said.
380
- const visitor = await readVisitorApp({ api: this.api(), app });
386
+ const visitor = await readVisitorApp({ api: this.agents(), app });
381
387
  if (visitor.kind === 'decision') {
382
388
  return { kind: 'frame', src: frameSrcOf(this.origin(), visitor.app) };
383
389
  }
@@ -402,11 +408,22 @@ export function defineDatalayerAppElement(options = {}) {
402
408
  }
403
409
  return this.spoken;
404
410
  }
405
- /** Datalayer's services: the `api` attribute, else the platform's, else prod1's. */
411
+ /**
412
+ * ai-agents, where a visitor's address is read and beside which the
413
+ * visitors' runtime and ai-inference answer: the `api` attribute, else
414
+ * the platform's, else r1's. Not Spacer's address (`api()`), which lives
415
+ * on another plane.
416
+ */
417
+ agents() {
418
+ return (this.getAttribute('api') ||
419
+ coreStore.getState().configuration?.aiAgentsUrl ||
420
+ DEFAULT_API).replace(/\/+$/, '');
421
+ }
422
+ /** Spacer, where an application is read with its embed token: the `api` attribute, else the platform's, else prod1's. */
406
423
  api() {
407
424
  return (this.getAttribute('api') ||
408
425
  coreStore.getState().configuration?.spacerUrl ||
409
- DEFAULT_API).replace(/\/+$/, '');
426
+ 'https://prod1.datalayer.run').replace(/\/+$/, '');
410
427
  }
411
428
  async load() {
412
429
  const turn = ++this.loading;
@@ -25,20 +25,29 @@ export declare const EMBED_STYLESHEET_PATH = "/embed/datalayer-app.css";
25
25
  export declare const EMBED_ATTRIBUTES: {
26
26
  readonly app: "The application’s id on Datalayer.";
27
27
  readonly origin: "Where Datalayer is; the origin the script was loaded from by default.";
28
- readonly token: "An embed token for an application that is not public, issued to your server for each visit.";
28
+ readonly token: "An embed token for an application that is not public, issued to your server for each visit; it names the embedded deployment the visit runs, which nothing else on the page names.";
29
29
  readonly spec: "The address of an Appspec (YAML or JSON) to run, in place of `app`.";
30
30
  readonly mode: "inline, bubble, panel or assistant; the Appspec’s `deployment.embedded.mode` by default, then inline.";
31
31
  readonly accent: "green, rose, sky, lime, sun or violet; the Appspec’s `interface.accent` by default, else the theme’s own colours.";
32
32
  readonly theme: "light, dark or auto (the visitor’s system); auto by default.";
33
33
  readonly font: "A CSS font family for the application’s text; Inter, then the system’s sans-serif, by default.";
34
34
  readonly server: "An agent-runtimes server the application’s agent runs on; a Datalayer runtime by default.";
35
- readonly api: "Datalayer’s API, where an application is read with its token; the platform’s by default.";
35
+ readonly api: "Datalayer’s API: where an application is read with its token (Spacer), and where a visitor’s address is read (ai-agents); the platform’s by default.";
36
36
  readonly height: "The height of an inline application, in pixels; 640 by default.";
37
37
  readonly resume: "true or false: whether a visit’s conversation is picked up again after the page reloads, kept in the page’s storage; true by default.";
38
38
  readonly language: "The language the visitor reads the application in, as BCP 47 tags it (fr, pt-BR): its translation into it, and the chat’s own words; the visitor’s browser’s by default.";
39
39
  readonly 'user-token': "A token your server signed with the deployment’s secret, naming your user (HS256: sub, name, exp at most an hour away): what an application that says user: signed opens its session with.";
40
40
  };
41
41
  export type EmbedAttribute = keyof typeof EMBED_ATTRIBUTES;
42
+ /**
43
+ * The deployment an embed token names (VOICE.md decision 16): the embedded
44
+ * deployment its owner's server asked it for. Read without vouching for it —
45
+ * the services that take the token verify it — and the only place the
46
+ * element learns its deployment: it takes no attribute for one.
47
+ */
48
+ export declare function embedTokenDeployment(token: string): string | undefined;
49
+ /** What an embed token naming no deployment is told (VOICE.md decision 16). */
50
+ export declare const EMBED_TOKEN_NAMES_NO_DEPLOYMENT = "datalayer-app: the embed token names no deployment. Your server asks for one naming the application\u2019s embedded deployment: POST \u2026/api/spacer/v1/apps/<id>/embed-token with {\"deployment\": \"<its uid>\"}.";
42
51
  /** Every attribute the element watches. */
43
52
  export declare const EMBED_OBSERVED_ATTRIBUTES: EmbedAttribute[];
44
53
  /**
@@ -16,19 +16,43 @@ export const EMBED_STYLESHEET_PATH = '/embed/datalayer-app.css';
16
16
  export const EMBED_ATTRIBUTES = {
17
17
  app: 'The application’s id on Datalayer.',
18
18
  origin: 'Where Datalayer is; the origin the script was loaded from by default.',
19
- token: 'An embed token for an application that is not public, issued to your server for each visit.',
19
+ token: 'An embed token for an application that is not public, issued to your server for each visit; it names the embedded deployment the visit runs, which nothing else on the page names.',
20
20
  spec: 'The address of an Appspec (YAML or JSON) to run, in place of `app`.',
21
21
  mode: 'inline, bubble, panel or assistant; the Appspec’s `deployment.embedded.mode` by default, then inline.',
22
22
  accent: 'green, rose, sky, lime, sun or violet; the Appspec’s `interface.accent` by default, else the theme’s own colours.',
23
23
  theme: 'light, dark or auto (the visitor’s system); auto by default.',
24
24
  font: 'A CSS font family for the application’s text; Inter, then the system’s sans-serif, by default.',
25
25
  server: 'An agent-runtimes server the application’s agent runs on; a Datalayer runtime by default.',
26
- api: 'Datalayer’s API, where an application is read with its token; the platform’s by default.',
26
+ api: 'Datalayer’s API: where an application is read with its token (Spacer), and where a visitor’s address is read (ai-agents); the platform’s by default.',
27
27
  height: 'The height of an inline application, in pixels; 640 by default.',
28
28
  resume: 'true or false: whether a visit’s conversation is picked up again after the page reloads, kept in the page’s storage; true by default.',
29
29
  language: 'The language the visitor reads the application in, as BCP 47 tags it (fr, pt-BR): its translation into it, and the chat’s own words; the visitor’s browser’s by default.',
30
30
  'user-token': 'A token your server signed with the deployment’s secret, naming your user (HS256: sub, name, exp at most an hour away): what an application that says user: signed opens its session with.',
31
31
  };
32
+ /**
33
+ * The deployment an embed token names (VOICE.md decision 16): the embedded
34
+ * deployment its owner's server asked it for. Read without vouching for it —
35
+ * the services that take the token verify it — and the only place the
36
+ * element learns its deployment: it takes no attribute for one.
37
+ */
38
+ export function embedTokenDeployment(token) {
39
+ const payload = token.split('.')[1];
40
+ if (!payload) {
41
+ return undefined;
42
+ }
43
+ try {
44
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
45
+ const claims = JSON.parse(atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=')));
46
+ return typeof claims.deployment_uid === 'string' && claims.deployment_uid
47
+ ? claims.deployment_uid
48
+ : undefined;
49
+ }
50
+ catch {
51
+ return undefined;
52
+ }
53
+ }
54
+ /** What an embed token naming no deployment is told (VOICE.md decision 16). */
55
+ export const EMBED_TOKEN_NAMES_NO_DEPLOYMENT = 'datalayer-app: the embed token names no deployment. Your server asks for one naming the application’s embedded deployment: POST …/api/spacer/v1/apps/<id>/embed-token with {"deployment": "<its uid>"}.';
32
56
  /** Every attribute the element watches. */
33
57
  export const EMBED_OBSERVED_ATTRIBUTES = Object.keys(EMBED_ATTRIBUTES);
34
58
  /**
@@ -42,8 +42,15 @@ export declare const VISITORS_PATH = "/api/runtime-visitors";
42
42
  export declare const VISITOR_EXAMPLES: readonly string[];
43
43
  /** The key of an application at its address, as a visitor's token names it. */
44
44
  export declare const AT_PREFIX = "at:";
45
- /** Datalayer's services, where the element names none (`api`). */
46
- export declare const DEFAULT_API = "https://prod1.datalayer.run";
45
+ /**
46
+ * Datalayer's services for a visitor, where the element names none (`api`)
47
+ * and the platform's configuration says none: ai-agents, where an address is
48
+ * read, and beside it the visitors' runtime and ai-inference, which mints the
49
+ * visitor's token. Not Spacer's address, which reads an application with its
50
+ * embed token: the two live on different planes (ai-agents on r1, Spacer on
51
+ * prod1), found 15 October 2026 on datalayer.ai's embed.
52
+ */
53
+ export declare const DEFAULT_API = "https://r1.datalayer.run";
47
54
  /** An application's id on Datalayer: a ULID. */
48
55
  export declare const isAppUid: (value: string) => boolean;
49
56
  /** What `app` with no token resolves to. */
@@ -14,8 +14,15 @@ export const VISITOR_EXAMPLES = [
14
14
  ];
15
15
  /** The key of an application at its address, as a visitor's token names it. */
16
16
  export const AT_PREFIX = 'at:';
17
- /** Datalayer's services, where the element names none (`api`). */
18
- export const DEFAULT_API = 'https://prod1.datalayer.run';
17
+ /**
18
+ * Datalayer's services for a visitor, where the element names none (`api`)
19
+ * and the platform's configuration says none: ai-agents, where an address is
20
+ * read, and beside it the visitors' runtime and ai-inference, which mints the
21
+ * visitor's token. Not Spacer's address, which reads an application with its
22
+ * embed token: the two live on different planes (ai-agents on r1, Spacer on
23
+ * prod1), found 15 October 2026 on datalayer.ai's embed.
24
+ */
25
+ export const DEFAULT_API = 'https://r1.datalayer.run';
19
26
  /** An application's id on Datalayer: a ULID. */
20
27
  export const isAppUid = (value) => /^[0-9A-HJKMNP-TV-Z]{26}$/.test(value);
21
28
  /** An address, as ai-agents spells one. */
@@ -1321,9 +1321,9 @@ export default function ChatView({ workspace }) {
1321
1321
  /*
1322
1322
  * Its voice asked of the speech service as this chat speaks to its runtime
1323
1323
  * (VOICE.md VO-37, VO-19): the host's token, else this one — a visitor's,
1324
- * an embed's or the person's — and the deployment it runs.
1324
+ * an embed's or the person's; an embed's names its deployment (decision 16).
1325
1325
  */
1326
- const askedVoice = useMemo(() => voiceAskedAs(config?.voice, iamToken || undefined, deploymentUid), [config?.voice, iamToken, deploymentUid]);
1326
+ const askedVoice = useMemo(() => voiceAskedAs(config?.voice, iamToken || undefined), [config?.voice, iamToken]);
1327
1327
  /*
1328
1328
  * A deployment's runtime, and the visitors', answer only the application's
1329
1329
  * own routes (OwnRoutesOnly, PAP_DATALAYER's release gates): its chat calls
@@ -1700,7 +1700,10 @@ export default function ChatView({ workspace }) {
1700
1700
  footerExtras: promptActions.length > 0 || (voice && voice.input !== 'off') ? (_jsxs(_Fragment, { children: [promptActions.length > 0 ? (_jsx(ReactorSlot, { slot: LoopSlots.promptAction, props: { workspace } })) : null, voice && voice.input !== 'off' ? (_jsx(ComposerVoice, { voice: voice, input: draft, setInput: setDraft, disabled: chatDisabled,
1701
1701
  // The chat's own speaker, never a second one: the microphone
1702
1702
  // opening stops it, and *Stop speaking* while it speaks.
1703
- speaking: speaking, onStopSpeaking: () => controlsRef.current?.stopSpeaking?.(), onInterimChange: setVoiceInterim,
1703
+ speaking: speaking, onStopSpeaking: () => controlsRef.current?.stopSpeaking?.(),
1704
+ // Hands-free (VO-12, VO-13): the run in progress, stopped when
1705
+ // the person speaks over it.
1706
+ running: busy, onStopRun: () => controlsRef.current?.stop?.(), onInterimChange: setVoiceInterim,
1704
1707
  // The menu offers when answers are heard where the
1705
1708
  // application lets them be (VO-21, VO-30).
1706
1709
  answers: Boolean(config?.voice?.output &&
@@ -7,6 +7,7 @@ import { Dialog, Text } from '@primer/react';
7
7
  import { assistantCharacter } from './characters';
8
8
  import { BalloonHistoryLarge, SpeechBalloon, } from './SpeechBalloon';
9
9
  import { conversationHeaderText } from './BalloonParts';
10
+ import { ASSISTANT_CLEARANCE_VAR } from './clearance';
10
11
  import { AssistantContextMenu, LONG_PRESS_MS, opensContextMenu, } from './AssistantContextMenu';
11
12
  import { BalloonExpandContext, ExpandedVisual, focusExpanded, } from './BalloonVisual';
12
13
  import { SpriteCharacter } from './SpriteCharacter';
@@ -405,6 +406,22 @@ export function AssistantStage({ character, state, size = 88, place, stageRef, o
405
406
  telling ||
406
407
  detailing !== null ||
407
408
  stayPut);
409
+ // What it covers in its corner, for a line written under a prompt there
410
+ // (REVIEWS Q-08).
411
+ const cornerRight = place.left === undefined && place.top === undefined
412
+ ? pixels(place.right)
413
+ : undefined;
414
+ const cornerBottom = pixels(place.bottom);
415
+ useEffect(() => {
416
+ if (aside || cornerRight === undefined || cornerBottom === undefined) {
417
+ return;
418
+ }
419
+ const root = document.documentElement;
420
+ root.style.setProperty(ASSISTANT_CLEARANCE_VAR, `${cornerRight + size}px`);
421
+ return () => {
422
+ root.style.removeProperty(ASSISTANT_CLEARANCE_VAR);
423
+ };
424
+ }, [aside, cornerRight, cornerBottom, size]);
408
425
  useMouth(stageRef, mouthLevel, state === 'speaking');
409
426
  // A decision being asked, or its answer, keeps the balloon up.
410
427
  const [deciding, setDeciding] = useState(false);
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What the floating assistant covers from the right edge of the window while
3
+ * it stands in a bottom corner, as a CSS length on the document: a line
4
+ * written under a prompt there (what an application keeps, R-31) leaves it
5
+ * free, so that the character never stands over its words at a phone's width
6
+ * (REVIEWS Q-08). Unset when it stands anywhere else, steps aside or is gone.
7
+ *
8
+ * @module chat/assistant/clearance
9
+ */
10
+ export declare const ASSISTANT_CLEARANCE_VAR = "--assistant-clearance";
@@ -0,0 +1,14 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * What the floating assistant covers from the right edge of the window while
7
+ * it stands in a bottom corner, as a CSS length on the document: a line
8
+ * written under a prompt there (what an application keeps, R-31) leaves it
9
+ * free, so that the character never stands over its words at a phone's width
10
+ * (REVIEWS Q-08). Unset when it stands anywhere else, steps aside or is gone.
11
+ *
12
+ * @module chat/assistant/clearance
13
+ */
14
+ export const ASSISTANT_CLEARANCE_VAR = '--assistant-clearance';
@@ -2968,6 +2968,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2968
2968
  const ownVoice = useVoiceSpeaker(settled, !hostSpeaks);
2969
2969
  useSpokenAnswers(displayItems, isLoading, !hostSpeaks && settled?.output === 'always', ownVoice.speaker);
2970
2970
  const speakingNow = voiceSpeaking || ownVoice.state.speaking;
2971
+ // Answers spoken over, hands-free (VOICE.md VO-13): marked *interrupted*.
2972
+ const [interruptedIds, setInterruptedIds] = useState(() => new Set());
2971
2973
  const stopSpeaking = onStopSpeaking ?? (ownVoice.speaker ? ownVoice.stop : undefined);
2972
2974
  const readAnswerAloud = onReadAloud ?? (ownVoice.speaker ? ownVoice.read : undefined);
2973
2975
  const handleSend = useCallback(async (messageOverride,
@@ -3724,10 +3726,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3724
3726
  .reverse()
3725
3727
  .find((item) => !isToolCallMessage(item) && item.role === 'assistant')?.id
3726
3728
  : undefined;
3727
- const readAloudFooter = readAnswerAloud
3729
+ const readAloudFooter = readAnswerAloud || interruptedIds.size
3728
3730
  ? (message) => {
3729
3731
  const text = getMessageText(message);
3730
- return readsAloud(message, text, writingId) ? (_jsx(ReadAloud, { onRead: () => readAnswerAloud(text) })) : null;
3732
+ return (_jsxs(_Fragment, { children: [interruptedIds.has(message.id) ? (_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', fontStyle: 'italic' }, "data-interrupted": "", children: "interrupted" })) : null, readAnswerAloud && readsAloud(message, text, writingId) ? (_jsx(ReadAloud, { onRead: () => readAnswerAloud(text) })) : null] }));
3731
3733
  }
3732
3734
  : undefined;
3733
3735
  const messagesContent = children ? (children) : (_jsxs(Box, { display: "flex", flexDirection: "column", minHeight: 0,
@@ -3745,7 +3747,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3745
3747
  // so every chat with a headless notebook gets the same transcript.
3746
3748
  notebookToolSurfacesId: notebookToolSurfacesId, approvalConfig: approvalConfig, messagesEndRef: messagesEndRef, onRespond: handleRespond, showTurnFooters: showTurnFooter, renderMessageFooter: readAloudFooter, agentUsage: agentUsage, onRemoveItems: handleRemoveItems, mcpServers: filteredMcpServers, emptyContent: launching ? null : (_jsx(ChatEmptyState, { emptyState: emptyState, brandIcon: brandIcon, description: resolvedDescription, suggestions: resolvedSuggestions, submitOnSuggestionClick: submitOnSuggestionClick, onSuggestionSubmit: handleSuggestionSubmit, onSuggestionFill: handleSuggestionFill })) }), trailingContent] }));
3747
3749
  // The microphone, in the composer's footer (VO-10, VO-11).
3748
- const voiceControl = settled && settled.input !== 'off' ? (_jsx(ComposerVoice, { voice: settled, input: input, setInput: setInput, disabled: disabled || launching, speaking: speakingNow, onStopSpeaking: stopSpeaking, onInterimChange: setVoiceInterim,
3750
+ const voiceControl = settled && settled.input !== 'off' ? (_jsx(ComposerVoice, { voice: settled, input: input, setInput: setInput, disabled: disabled || launching, speaking: speakingNow, onStopSpeaking: stopSpeaking,
3751
+ // Hands-free (VO-12, VO-13): the run in progress, stopped when the
3752
+ // person speaks over it, and the answer marked interrupted.
3753
+ running: isLoading, onStopRun: handleStop, onInterrupted: () => {
3754
+ if (writingId) {
3755
+ setInterruptedIds(ids => new Set([...ids, writingId]));
3756
+ }
3757
+ }, onInterimChange: setVoiceInterim,
3749
3758
  // The menu offers when answers are heard where the application
3750
3759
  // lets them be (VO-21, VO-30).
3751
3760
  answers: Boolean(voice?.output && voice.output !== 'off' && voice.speechUrl), onSpoken: metadata => {
@@ -2522,8 +2522,8 @@ export const APPSPEC_SCHEMA = {
2522
2522
  type: 'string',
2523
2523
  },
2524
2524
  VoiceInput: {
2525
- description: 'How a person talks to the application (VOICE.md VO-10).\n\n`hands_free` (VO-12) is not one yet: it is refused in a sentence until it\nis built, never taken for click-to-talk.',
2526
- enum: ['off', 'push_to_talk'],
2525
+ description: 'How a person talks to the application (VOICE.md VO-10, VO-12).\n\n`push_to_talk`: the microphone in the composer, clicked or held.\n`hands_free`: a conversation by voice — the composer offers *Talk with\nit*; a turn ends after a short silence and is sent, the answer is said,\nand listening goes on; speaking over the agent stops its voice (VO-13).\nHands-free implies answers said as they are written (`output: always`).',
2526
+ enum: ['off', 'push_to_talk', 'hands_free'],
2527
2527
  title: 'VoiceInput',
2528
2528
  type: 'string',
2529
2529
  },
@@ -328,6 +328,95 @@ export const SPEECH_MODEL_CATALOGUE = {
328
328
  },
329
329
  ],
330
330
  },
331
+ 'whisper-large-v3-turbo': {
332
+ id: 'whisper-large-v3-turbo',
333
+ version: '0.0.1',
334
+ name: 'Whisper Large v3 Turbo',
335
+ description: "Speech to text on Datalayer's servers, on CPU: OpenAI's Whisper large-v3-turbo (809M parameters, 99 languages), converted to CTranslate2 in half precision and run by faster-whisper, its weights quantized to 8 bits when loaded. A recording of up to a minute at a time; the language is detected when the page does not say it.",
336
+ provider: 'openai',
337
+ task: 'stt',
338
+ engine: 'faster-whisper',
339
+ dtype: 'float16',
340
+ languages: ['en', 'fr', 'es', 'it', 'pt', 'de', 'nl', 'ja', 'zh', 'hi'],
341
+ where: ['server'],
342
+ streaming: false,
343
+ licence: {
344
+ weights: 'MIT',
345
+ code: 'MIT',
346
+ },
347
+ attribution: '',
348
+ upstream: 'https://huggingface.co/openai/whisper-large-v3-turbo',
349
+ revision: '0a363e9161cbc7ed1431c9597a8ceaf0c4f78fcf',
350
+ files: [
351
+ {
352
+ path: 'config.json',
353
+ sha256: 'b0253ea6c0d3bea6b1e19e91a02acfd3b53f4467362efcb5a3e6b16c9b3a9b7e',
354
+ size: 2263,
355
+ },
356
+ {
357
+ path: 'preprocessor_config.json',
358
+ sha256: '7ccc62c6f2765af1f3b46c00c9b5894426835a05021c8b9c01eecb6dfb542711',
359
+ size: 340,
360
+ },
361
+ {
362
+ path: 'tokenizer.json',
363
+ sha256: '297b13372ac43916285644fb9687add3cc62ee2a1adb60da3dc25cc94c1871fd',
364
+ size: 2710337,
365
+ },
366
+ {
367
+ path: 'vocabulary.json',
368
+ sha256: 'c69260f2ab26d659b7c398f9a2b2b48ed0df16c3b47d7326782fd9cba71690c1',
369
+ size: 1068114,
370
+ },
371
+ {
372
+ path: 'model.bin',
373
+ sha256: 'e76620f83d5f5b69efd3d87e3dc180c1bd21df9fbebacfd4335e5e1efcc018da',
374
+ size: 1617884929,
375
+ },
376
+ ],
377
+ },
378
+ 'whisper-small': {
379
+ id: 'whisper-small',
380
+ version: '0.0.1',
381
+ name: 'Whisper Small',
382
+ description: "Speech to text on Datalayer's servers for short spoken turns, on CPU: OpenAI's Whisper small (244M parameters, 99 languages), converted to CTranslate2 and run by faster-whisper, its weights quantized to 8 bits when loaded. About four times faster than large-v3-turbo; on the fixtures it hears English as well (3.3% against 2.4%) and French less well (16.1% against 2.6%). Recordings longer than a turn go to large-v3-turbo.",
383
+ provider: 'openai',
384
+ task: 'stt',
385
+ engine: 'faster-whisper',
386
+ dtype: 'float16',
387
+ languages: ['en', 'fr', 'es', 'it', 'pt', 'de', 'nl', 'ja', 'zh', 'hi'],
388
+ where: ['server'],
389
+ streaming: false,
390
+ licence: {
391
+ weights: 'MIT',
392
+ code: 'MIT',
393
+ },
394
+ attribution: '',
395
+ upstream: 'https://huggingface.co/openai/whisper-small',
396
+ revision: '536b0662742c02347bc0e980a01041f333bce120',
397
+ files: [
398
+ {
399
+ path: 'config.json',
400
+ sha256: 'b55496ac7940a7ae47d2c01eab40edfd8701feec1229d9cce3b40014383fb828',
401
+ size: 2370,
402
+ },
403
+ {
404
+ path: 'tokenizer.json',
405
+ sha256: 'fb7b63191e9bb045082c79fd742a3106a12c99513ab30df4a0d47fa6cb6fd0ab',
406
+ size: 2203239,
407
+ },
408
+ {
409
+ path: 'vocabulary.txt',
410
+ sha256: '34ce3fe1c5041027b3f8d42912270993f986dbc4bb34cf27f951e34a1e453913',
411
+ size: 459861,
412
+ },
413
+ {
414
+ path: 'model.bin',
415
+ sha256: '3e305921506d8872816023e4c273e75d2419fb89b24da97b4fe7bce14170d671',
416
+ size: 483546902,
417
+ },
418
+ ],
419
+ },
331
420
  };
332
421
  /** Whether a voice speaks a language: `fr` and `fr-FR` are spoken by a `fr-FR` voice. */
333
422
  export function voiceSpeaks(voice, language) {
@@ -958,7 +958,7 @@ export interface AppPageSpec {
958
958
  export interface AppVoiceSpec {
959
959
  enabled: boolean;
960
960
  /** `hands_free` (VO-12) is refused until it is built. */
961
- input: 'off' | 'push_to_talk';
961
+ input: 'off' | 'push_to_talk' | 'hands_free';
962
962
  output: 'off' | 'on_request' | 'always';
963
963
  voice: string;
964
964
  language: string;
@@ -7,7 +7,9 @@
7
7
  * person to edit and send — or sent at once with *Send what I say*. While
8
8
  * the words are still being heard the host is told (`onInterimChange`), to
9
9
  * grey them until they are final (VO-11). Beside the microphone, the
10
- * person's voice menu (`VoiceMenu`, VO-30).
10
+ * person's voice menu (`VoiceMenu`, VO-30). Where the application's voice
11
+ * says `hands_free`, *Talk with it* too (`HandsFree`, VO-12, VO-13): a turn
12
+ * is sent by itself and the agent's answer may be spoken over.
11
13
  *
12
14
  * @module voice/ComposerVoice
13
15
  */
@@ -29,6 +31,12 @@ export interface ComposerVoiceProps {
29
31
  speaking?: boolean;
30
32
  /** Stops the agent's voice, also when the microphone opens (VO-13). */
31
33
  onStopSpeaking?: () => void;
34
+ /** The agent is answering: a run is in progress (hands-free, VO-12). */
35
+ running?: boolean;
36
+ /** Stops the run while the answer is still being written (barge-in, VO-13). */
37
+ onStopRun?: () => void;
38
+ /** The answer being written was spoken over: marked in the conversation (VO-13). */
39
+ onInterrupted?: () => void;
32
40
  /**
33
41
  * The words in the box are still being heard (`true`), or final
34
42
  * (`false`): the host greys them until they are (VO-11).
@@ -42,5 +50,5 @@ export interface ComposerVoiceProps {
42
50
  }
43
51
  /** Whether something rides on a run as a spoken message's metadata (VO-27). */
44
52
  export declare function spokenOf(value: unknown): SpokenMetadata | undefined;
45
- export declare function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled, speaking, onStopSpeaking, onInterimChange, answers, }: ComposerVoiceProps): JSX.Element | null;
53
+ export declare function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled, speaking, onStopSpeaking, running, onStopRun, onInterrupted, onInterimChange, answers, }: ComposerVoiceProps): JSX.Element | null;
46
54
  export default ComposerVoice;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useRef } from 'react';
3
3
  import { DeviceHearing } from './hearing';
4
+ import { HandsFree } from './HandsFree';
5
+ import { ServerHearing, hearingPlace } from './serverHearing';
4
6
  import { VoiceInput } from './VoiceInput';
5
7
  import { VoiceMenu } from './VoiceMenu';
6
8
  /** Whether something rides on a run as a spoken message's metadata (VO-27). */
@@ -13,17 +15,45 @@ export function spokenOf(value) {
13
15
  ? said
14
16
  : undefined;
15
17
  }
16
- export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled = false, speaking = false, onStopSpeaking, onInterimChange, answers = false, }) {
17
- const hearing = useMemo(() => voice.input !== 'off'
18
- ? new DeviceHearing(voice.modelsUrl, voice.engines)
19
- : undefined, [voice.input, voice.modelsUrl, voice.engines]);
18
+ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled = false, speaking = false, onStopSpeaking, running = false, onStopRun, onInterrupted, onInterimChange, answers = false, }) {
19
+ // In the browser, or on Datalayer's servers where the application, the
20
+ // language or the person's detection asks for it (§5, VO-14, VO-34).
21
+ const place = hearingPlace(voice);
22
+ const hearing = useMemo(() => {
23
+ if (voice.input === 'off') {
24
+ return undefined;
25
+ }
26
+ const device = new DeviceHearing(voice.modelsUrl, voice.engines);
27
+ return place === 'server' && voice.speechUrl
28
+ ? new ServerHearing(device, {
29
+ speechUrl: voice.speechUrl,
30
+ token: voice.token,
31
+ detect: voice.detect,
32
+ })
33
+ : device;
34
+ }, [
35
+ voice.input,
36
+ voice.modelsUrl,
37
+ voice.engines,
38
+ place,
39
+ voice.speechUrl,
40
+ voice.token,
41
+ voice.detect,
42
+ ]);
20
43
  // What the box held when the microphone opened: the words heard go after it.
21
44
  const base = useRef('');
22
45
  const afterBase = (words) => [base.current, words].filter(Boolean).join(' ');
23
46
  if (!hearing) {
24
47
  return null;
25
48
  }
26
- return (_jsxs("span", { "data-composer-voice": "", style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [_jsx(VoiceMenu, { voice: voice, answers: answers, disabled: disabled }), _jsx(VoiceInput, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', silenceMs: voice.silenceMs, microphoneId: voice.microphoneId, disabled: disabled, speaking: speaking, onStopSpeaking: onStopSpeaking, onListen: () => {
49
+ return (_jsxs("span", { "data-composer-voice": "", style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [_jsx(VoiceMenu, { voice: voice, answers: answers, disabled: disabled }), voice.input === 'hands_free' ? (_jsx(HandsFree, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', microphoneId: voice.microphoneId, turnSilenceMs: voice.turnSilenceMs, disabled: disabled, running: running, speaking: speaking, onStopSpeaking: onStopSpeaking, onStopRun: onStopRun, onInterrupted: onInterrupted, onInterim: (words) => {
50
+ setInput(words ? afterBase(words) : base.current);
51
+ onInterimChange?.(words !== '');
52
+ }, send: (text, metadata) => {
53
+ onInterimChange?.(false);
54
+ setInput(base.current);
55
+ send(text, metadata);
56
+ } })) : null, _jsx(VoiceInput, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', silenceMs: voice.silenceMs, microphoneId: voice.microphoneId, disabled: disabled, speaking: speaking, onStopSpeaking: onStopSpeaking, onListen: () => {
27
57
  base.current = input.trim();
28
58
  onStopSpeaking?.();
29
59
  },