@datalayer/agent-runtimes 1.5.28 → 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.
@@ -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
  });
@@ -383,7 +383,7 @@ export function defineDatalayerAppElement(options = {}) {
383
383
  // A public application, as a visitor without an account (D-07):
384
384
  // an example kept warm or an address open to visitors, run on the
385
385
  // visitors' runtime; a decision framed; a refusal said.
386
- const visitor = await readVisitorApp({ api: this.api(), app });
386
+ const visitor = await readVisitorApp({ api: this.agents(), app });
387
387
  if (visitor.kind === 'decision') {
388
388
  return { kind: 'frame', src: frameSrcOf(this.origin(), visitor.app) };
389
389
  }
@@ -408,11 +408,22 @@ export function defineDatalayerAppElement(options = {}) {
408
408
  }
409
409
  return this.spoken;
410
410
  }
411
- /** 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. */
412
423
  api() {
413
424
  return (this.getAttribute('api') ||
414
425
  coreStore.getState().configuration?.spacerUrl ||
415
- DEFAULT_API).replace(/\/+$/, '');
426
+ 'https://prod1.datalayer.run').replace(/\/+$/, '');
416
427
  }
417
428
  async load() {
418
429
  const turn = ++this.loading;
@@ -32,7 +32,7 @@ export declare const EMBED_ATTRIBUTES: {
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.";
@@ -23,7 +23,7 @@ export const EMBED_ATTRIBUTES = {
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.',
@@ -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. */
@@ -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 &&
@@ -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
  },
@@ -375,6 +375,48 @@ export const SPEECH_MODEL_CATALOGUE = {
375
375
  },
376
376
  ],
377
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
+ },
378
420
  };
379
421
  /** Whether a voice speaks a language: `fr` and `fr-FR` are spoken by a `fr-FR` voice. */
380
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,7 @@
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';
4
5
  import { ServerHearing, hearingPlace } from './serverHearing';
5
6
  import { VoiceInput } from './VoiceInput';
6
7
  import { VoiceMenu } from './VoiceMenu';
@@ -14,7 +15,7 @@ export function spokenOf(value) {
14
15
  ? said
15
16
  : undefined;
16
17
  }
17
- export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled = false, speaking = false, onStopSpeaking, onInterimChange, answers = false, }) {
18
+ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled = false, speaking = false, onStopSpeaking, running = false, onStopRun, onInterrupted, onInterimChange, answers = false, }) {
18
19
  // In the browser, or on Datalayer's servers where the application, the
19
20
  // language or the person's detection asks for it (§5, VO-14, VO-34).
20
21
  const place = hearingPlace(voice);
@@ -45,7 +46,14 @@ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled
45
46
  if (!hearing) {
46
47
  return null;
47
48
  }
48
- 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: () => {
49
57
  base.current = input.trim();
50
58
  onStopSpeaking?.();
51
59
  },
@@ -0,0 +1,55 @@
1
+ /**
2
+ * *Talk with it*: a conversation by voice, hands-free (VOICE.md VO-12,
3
+ * VO-13), beside the composer's microphone where the application's voice
4
+ * says `input: hands_free`.
5
+ *
6
+ * On, the microphone stays open. The words are written in the composer as
7
+ * they are heard (VO-11); once the person has spoken and been silent for the
8
+ * turn's silence (`turnSilenceMs`, 700 ms unless said), what was said is
9
+ * heard whole and **sent** — nothing waits for a click. The answer is said as
10
+ * it is written (hands-free implies `output: always`), and listening goes
11
+ * on through it: speech heard while the agent thinks or speaks is
12
+ * **barge-in** (VO-13) — its voice stops at once, the run is stopped while
13
+ * the answer is still being written, the interrupted answer is marked in
14
+ * the conversation, and what was heard starts the next turn. The state is
15
+ * shown at all times: *Listening…*, *Thinking…*, *Speaking…*, *Paused*.
16
+ * `Esc` or *Pause* pauses it; the toggle again turns it off.
17
+ *
18
+ * The microphone is opened anew when the agent begins answering, so what it
19
+ * holds then is the person's; the browser's echo cancellation keeps the
20
+ * answer's own sound out (`openMicrophone`). The first use asks for the
21
+ * microphone as the click-to-talk does (VO-61), under the same consent.
22
+ *
23
+ * @module voice/HandsFree
24
+ */
25
+ import type { JSX } from 'react';
26
+ import type { Hearing } from './hearing';
27
+ import type { SpokenMetadata } from './types';
28
+ export interface HandsFreeProps {
29
+ hearing: Hearing;
30
+ /** The language listened to, BCP 47. */
31
+ language: string;
32
+ /** Whose consent to the microphone is remembered: the application's id. */
33
+ consentKey: string;
34
+ /** The microphone the person chose (VO-30); the browser's own when unsaid. */
35
+ microphoneId?: string;
36
+ /** The silence after speech that ends a turn, in ms; 700 unless said (VO-12). */
37
+ turnSilenceMs?: number;
38
+ /** A turn, heard whole: sent at once. */
39
+ send: (text: string, metadata: SpokenMetadata) => void;
40
+ /** The words heard so far, for the composer (VO-11); `''` gives it back. */
41
+ onInterim?: (text: string) => void;
42
+ /** The agent is answering: a run is in progress. */
43
+ running?: boolean;
44
+ /** The agent's answer is being said. */
45
+ speaking?: boolean;
46
+ /** Stops the agent's voice at once (barge-in, VO-13). */
47
+ onStopSpeaking?: () => void;
48
+ /** Stops the run while the answer is still being written (barge-in, VO-13). */
49
+ onStopRun?: () => void;
50
+ /** The answer being written was interrupted: marked in the conversation (VO-13). */
51
+ onInterrupted?: () => void;
52
+ disabled?: boolean;
53
+ }
54
+ export declare function HandsFree({ hearing, language, consentKey, microphoneId, turnSilenceMs, send, onInterim, running, speaking, onStopSpeaking, onStopRun, onInterrupted, disabled, }: HandsFreeProps): JSX.Element;
55
+ export default HandsFree;
@@ -0,0 +1,211 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { Button, IconButton, Text, Tooltip } from '@primer/react';
4
+ import { Box } from '@datalayer/primer-addons';
5
+ import { openMicrophone } from './capture';
6
+ import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
7
+ import { HANDS_FREE_WORDS, IDLE_PAUSE_MS, TURN_SILENCE_MS, bargedIn, handsFreeState, turnEnded, } from './handsFree';
8
+ import { INTERIM_MS } from './VoiceInput';
9
+ /** Waits. */
10
+ const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
11
+ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSilenceMs = TURN_SILENCE_MS, send, onInterim, running = false, speaking = false, onStopSpeaking, onStopRun, onInterrupted, disabled = false, }) {
12
+ const [on, setOn] = useState(false);
13
+ const [paused, setPaused] = useState(false);
14
+ const [asking, setAsking] = useState(false);
15
+ const [problem, setProblem] = useState();
16
+ const state = handsFreeState({ on, paused, speaking, running });
17
+ const stateRef = useRef(state);
18
+ stateRef.current = state;
19
+ const runningRef = useRef(running);
20
+ runningRef.current = running;
21
+ const capture = useRef(undefined);
22
+ const latest = useRef({
23
+ send,
24
+ onInterim,
25
+ onStopSpeaking,
26
+ onStopRun,
27
+ onInterrupted,
28
+ });
29
+ latest.current = {
30
+ send,
31
+ onInterim,
32
+ onStopSpeaking,
33
+ onStopRun,
34
+ onInterrupted,
35
+ };
36
+ const key = consentKeyFor(consentKey, hearing.where);
37
+ const turnOn = useCallback(() => {
38
+ if (!consented(key)) {
39
+ setAsking(true);
40
+ return;
41
+ }
42
+ setProblem(undefined);
43
+ setPaused(false);
44
+ setOn(true);
45
+ }, [key]);
46
+ const turnOff = useCallback(() => {
47
+ setOn(false);
48
+ setPaused(false);
49
+ capture.current?.cancel();
50
+ capture.current = undefined;
51
+ latest.current.onInterim?.('');
52
+ }, []);
53
+ /*
54
+ * The loop: while on and not paused, the microphone is open and what it
55
+ * heard so far is looked at every INTERIM_MS. Listening, the turn ends on
56
+ * the silence and is sent; thinking or speaking, speech is barge-in. The
57
+ * microphone is opened anew when the agent begins answering, and after
58
+ * each turn, so what it holds is only what came since.
59
+ */
60
+ const heardWhile = useRef('listening');
61
+ useEffect(() => {
62
+ if (!on || paused) {
63
+ return;
64
+ }
65
+ let alive = true;
66
+ let quietSince = Date.now();
67
+ void (async () => {
68
+ try {
69
+ await hearing.ready(language);
70
+ capture.current = await openMicrophone(microphoneId);
71
+ }
72
+ catch (error) {
73
+ if (alive) {
74
+ setProblem(error instanceof Error
75
+ ? error.message
76
+ : 'The microphone could not be opened.');
77
+ setOn(false);
78
+ }
79
+ return;
80
+ }
81
+ heardWhile.current =
82
+ stateRef.current === 'listening' ? 'listening' : 'answering';
83
+ while (alive) {
84
+ await pause(INTERIM_MS);
85
+ const open = capture.current;
86
+ if (!alive || !open) {
87
+ return;
88
+ }
89
+ const now = stateRef.current;
90
+ const answering = now === 'thinking' || now === 'speaking';
91
+ // The agent began answering, or finished: hear anew from here.
92
+ if ((answering && heardWhile.current === 'listening') ||
93
+ (!answering && heardWhile.current === 'answering')) {
94
+ open.cancel();
95
+ try {
96
+ capture.current = await openMicrophone(microphoneId);
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ heardWhile.current = answering ? 'answering' : 'listening';
102
+ quietSince = Date.now();
103
+ continue;
104
+ }
105
+ let so;
106
+ try {
107
+ so = await hearing.hearSoFar(open.peek(), language);
108
+ }
109
+ catch {
110
+ so = undefined;
111
+ }
112
+ if (!alive || capture.current !== open || !so) {
113
+ continue;
114
+ }
115
+ if (so.text) {
116
+ latest.current.onInterim?.(so.text);
117
+ }
118
+ if (so.spoke) {
119
+ quietSince = Date.now();
120
+ }
121
+ if (bargedIn(now, so)) {
122
+ // The person speaks over the agent (VO-13): its voice and its run
123
+ // stop; what was heard goes on as the next turn.
124
+ latest.current.onStopSpeaking?.();
125
+ if (now === 'thinking' || runningRef.current) {
126
+ latest.current.onStopRun?.();
127
+ }
128
+ latest.current.onInterrupted?.();
129
+ heardWhile.current = 'listening';
130
+ continue;
131
+ }
132
+ if (now === 'listening' && turnEnded(so, turnSilenceMs)) {
133
+ capture.current = undefined;
134
+ const audio = await open.stop();
135
+ let heard;
136
+ try {
137
+ heard = await hearing.hear(audio, language);
138
+ }
139
+ catch (error) {
140
+ heard = undefined;
141
+ if (alive) {
142
+ setProblem(error instanceof Error
143
+ ? error.message
144
+ : 'What you said could not be heard.');
145
+ }
146
+ }
147
+ if (!alive) {
148
+ return;
149
+ }
150
+ latest.current.onInterim?.('');
151
+ if (heard) {
152
+ latest.current.send(heard.text, heard.metadata);
153
+ }
154
+ try {
155
+ capture.current = await openMicrophone(microphoneId);
156
+ }
157
+ catch {
158
+ return;
159
+ }
160
+ heardWhile.current = 'listening';
161
+ quietSince = Date.now();
162
+ continue;
163
+ }
164
+ if (now === 'listening' && Date.now() - quietSince >= IDLE_PAUSE_MS) {
165
+ setPaused(true);
166
+ return;
167
+ }
168
+ }
169
+ })();
170
+ return () => {
171
+ alive = false;
172
+ capture.current?.cancel();
173
+ capture.current = undefined;
174
+ };
175
+ // The loop reads the state through a ref: it is not restarted on it.
176
+ // eslint-disable-next-line react-hooks/exhaustive-deps
177
+ }, [on, paused, hearing, language, microphoneId, turnSilenceMs]);
178
+ // Esc pauses it.
179
+ useEffect(() => {
180
+ if (!on) {
181
+ return;
182
+ }
183
+ const down = (event) => {
184
+ if (event.key === 'Escape' && !paused) {
185
+ setPaused(true);
186
+ capture.current?.cancel();
187
+ capture.current = undefined;
188
+ latest.current.onInterim?.('');
189
+ }
190
+ };
191
+ window.addEventListener('keydown', down);
192
+ return () => window.removeEventListener('keydown', down);
193
+ }, [on, paused]);
194
+ return (_jsxs(Box, { display: "inline-flex", alignItems: "center", sx: { gap: 1, position: 'relative' }, "data-hands-free": state, children: [_jsx(Tooltip, { text: on
195
+ ? 'Stop talking with it'
196
+ : 'Talk with it: speak, it answers, and listens on (hands-free)', direction: "n", children: _jsx(Button, { size: "small", variant: on ? 'primary' : 'invisible', "aria-pressed": on, "aria-label": on ? 'Stop talking with it' : 'Talk with it', disabled: disabled, "data-hands-free-toggle": "", onClick: () => (on ? turnOff() : turnOn()), children: HANDS_FREE_WORDS.off }) }), on ? (_jsxs(_Fragment, { children: [_jsx(Text, { sx: { fontSize: 0, color: 'fg.muted' }, role: "status", "aria-live": "polite", "data-hands-free-state": state, children: HANDS_FREE_WORDS[state] }), paused ? (_jsx(IconButton, { icon: () => _jsx("span", { "aria-hidden": "true", children: "\u25B6" }), size: "small", variant: "invisible", "aria-label": "Resume listening", "data-hands-free-resume": "", onClick: () => setPaused(false) })) : (_jsx(IconButton, { icon: () => _jsx("span", { "aria-hidden": "true", children: "\u275A\u275A" }), size: "small", variant: "invisible", "aria-label": "Pause listening (Esc)", "data-hands-free-pause": "", onClick: () => {
197
+ setPaused(true);
198
+ capture.current?.cancel();
199
+ capture.current = undefined;
200
+ latest.current.onInterim?.('');
201
+ } }))] })) : null, problem ? (_jsx(Text, { sx: { fontSize: 0, color: 'danger.fg' }, "data-hands-free-problem": "", children: problem })) : null, asking ? (_jsxs(Box, { role: "dialog", "aria-label": "The microphone", "data-hands-free-consent": "", position: "absolute", bottom: "100%", left: 0, width: 280, p: 3, bg: "canvas.overlay", border: "1px solid", borderColor: "border.default", borderRadius: 2, boxShadow: "shadow.large", zIndex: 10, children: [_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, children: hearing.where === 'server'
202
+ ? SERVER_CONSENT_SENTENCE
203
+ : CONSENT_SENTENCE }), _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setAsking(false), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-hands-free-consent-yes": "", onClick: () => {
204
+ consent(key);
205
+ setAsking(false);
206
+ setProblem(undefined);
207
+ setPaused(false);
208
+ setOn(true);
209
+ }, children: "Use the microphone" })] })] })) : null] }));
210
+ }
211
+ export default HandsFree;
@@ -35,7 +35,8 @@ export interface ChatVoicePlace {
35
35
  * - **Listening**: a microphone in the composer — click it and speak, the
36
36
  * words written in the box as they are heard, stopped by a second click
37
37
  * or when the person stops speaking; or held, and let go (VO-10, VO-11).
38
- * `hands_free` (VO-12) is not built: `checkAppspec` refuses it.
38
+ * `hands_free` (VO-12): the microphone, and *Talk with it* beside it; it
39
+ * implies answers said as they are written.
39
40
  * - **Speaking**: its answers said by Datalayer's speech service, on request
40
41
  * or always (VO-20, VO-21); refused here when its host says no service.
41
42
  */