@datalayer/agent-runtimes 1.5.27 → 1.5.28

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.
@@ -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
@@ -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() {
@@ -25,7 +25,7 @@ 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.";
@@ -39,6 +39,15 @@ export declare const EMBED_ATTRIBUTES: {
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,7 +16,7 @@ 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.',
@@ -29,6 +29,30 @@ export const EMBED_ATTRIBUTES = {
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
  /**
@@ -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
@@ -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';
@@ -328,6 +328,53 @@ 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
+ },
331
378
  };
332
379
  /** Whether a voice speaks a language: `fr` and `fr-FR` are spoken by a `fr-FR` voice. */
333
380
  export function voiceSpeaks(voice, language) {
@@ -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 { ServerHearing, hearingPlace } from './serverHearing';
4
5
  import { VoiceInput } from './VoiceInput';
5
6
  import { VoiceMenu } from './VoiceMenu';
6
7
  /** Whether something rides on a run as a spoken message's metadata (VO-27). */
@@ -14,9 +15,30 @@ export function spokenOf(value) {
14
15
  : undefined;
15
16
  }
16
17
  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
+ // In the browser, or on Datalayer's servers where the application, the
19
+ // language or the person's detection asks for it (§5, VO-14, VO-34).
20
+ const place = hearingPlace(voice);
21
+ const hearing = useMemo(() => {
22
+ if (voice.input === 'off') {
23
+ return undefined;
24
+ }
25
+ const device = new DeviceHearing(voice.modelsUrl, voice.engines);
26
+ return place === 'server' && voice.speechUrl
27
+ ? new ServerHearing(device, {
28
+ speechUrl: voice.speechUrl,
29
+ token: voice.token,
30
+ detect: voice.detect,
31
+ })
32
+ : device;
33
+ }, [
34
+ voice.input,
35
+ voice.modelsUrl,
36
+ voice.engines,
37
+ place,
38
+ voice.speechUrl,
39
+ voice.token,
40
+ voice.detect,
41
+ ]);
20
42
  // What the box held when the microphone opened: the words heard go after it.
21
43
  const base = useRef('');
22
44
  const afterBase = (words) => [base.current, words].filter(Boolean).join(' ');
@@ -18,7 +18,7 @@
18
18
  * @module voice/VoiceInput
19
19
  */
20
20
  import type { JSX } from 'react';
21
- import type { DeviceHearing } from './hearing';
21
+ import type { Hearing } from './hearing';
22
22
  import type { Transcript } from './types';
23
23
  /** What the microphone is doing, said in words (VO-67, VO-68). */
24
24
  export type ListeningState = 'idle' | 'asking' | 'loading' | 'listening' | 'hearing';
@@ -27,7 +27,7 @@ export declare const INTERIM_MS = 700;
27
27
  /** The longest a microphone listens in one go, in ms: then it stops by itself. */
28
28
  export declare const LONGEST_MS = 60000;
29
29
  export interface VoiceInputProps {
30
- hearing: DeviceHearing;
30
+ hearing: Hearing;
31
31
  /** The language listened to, BCP 47. */
32
32
  language: string;
33
33
  /** What was said, once heard. */
@@ -5,7 +5,7 @@ import { Box } from '@datalayer/primer-addons';
5
5
  import { MicrophoneIcon } from '@datalayer/icons-react';
6
6
  import { MuteIcon } from '@primer/octicons-react';
7
7
  import { MicrophoneRefused, openMicrophone } from './capture';
8
- import { CONSENT_SENTENCE, consent, consented } from './consent';
8
+ import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
9
9
  const WORDS = {
10
10
  idle: '',
11
11
  asking: '',
@@ -49,7 +49,7 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
49
49
  if (disabled || stateRef.current !== 'idle') {
50
50
  return;
51
51
  }
52
- if (!consented(consentKey)) {
52
+ if (!consented(consentKeyFor(consentKey, hearing.where))) {
53
53
  setState('asking');
54
54
  return;
55
55
  }
@@ -277,8 +277,10 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
277
277
  void start();
278
278
  }
279
279
  }
280
- } }) }), state === 'asking' && (_jsxs(Box, { role: "dialog", "aria-label": "Talk instead of typing", "data-voice-consent": "", position: "absolute", bottom: "100%", right: 0, mb: 2, 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: CONSENT_SENTENCE }), _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setState('idle'), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-voice-consent-yes": "", onClick: () => {
281
- consent(consentKey);
280
+ } }) }), state === 'asking' && (_jsxs(Box, { role: "dialog", "aria-label": "Talk instead of typing", "data-voice-consent": "", position: "absolute", bottom: "100%", right: 0, mb: 2, 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'
281
+ ? SERVER_CONSENT_SENTENCE
282
+ : CONSENT_SENTENCE }), _jsxs(Box, { display: "flex", gap: 2, justifyContent: "flex-end", children: [_jsx(Button, { size: "small", onClick: () => setState('idle'), children: "Not now" }), _jsx(Button, { size: "small", variant: "primary", "data-voice-consent-yes": "", onClick: () => {
283
+ consent(consentKeyFor(consentKey, hearing.where));
282
284
  // Asked, and said yes: listen now, without another press.
283
285
  stateRef.current = 'idle';
284
286
  setState('idle');
@@ -20,6 +20,7 @@ export function VoiceMenu({ voice, answers, disabled = false, }) {
20
20
  const [devices, setDevices] = useState([]);
21
21
  const choose = (key, value) => keep(withVoiceSetting(settings, key, value, catalogueVoiceSpeaks));
22
22
  const onDevice = Boolean(settings.onDeviceOnly);
23
+ const detect = Boolean(settings.detectLanguage);
23
24
  const voices = voicesSpeaking(voice.language);
24
25
  const named = devices.filter(device => device.label);
25
26
  return (_jsxs(ActionMenu, { onOpenChange: open => {
@@ -27,6 +28,6 @@ export function VoiceMenu({ voice, answers, disabled = false, }) {
27
28
  void microphones().then(setDevices);
28
29
  }
29
30
  }, children: [_jsx(ActionMenu.Anchor, { children: _jsx(IconButton, { icon: GearIcon, size: "small", variant: "invisible", "aria-label": "Voice settings", "data-voice-menu": "", disabled: disabled }) }), _jsx(ActionMenu.Overlay, { width: "medium", children: _jsxs(ActionList, { children: [_jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Language" }), voiceLanguages().map(language => (_jsx(ActionList.Item, { selected: language.value === voice.language, onSelect: () => choose('language', language.value), "data-voice-language": language.value, children: language.label }, language.value)))] }), answers && !onDevice ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Answers heard" }), OUTPUT_CHOICES.map(output => (_jsx(ActionList.Item, { selected: output.value === voice.output, onSelect: () => choose('output', output.value), "data-voice-output": output.value, children: output.label }, output.value)))] }), voices.length > 0 ? (_jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Voice" }), voices.map(spoken => (_jsx(ActionList.Item, { selected: spoken.id === voice.voice, onSelect: () => choose('voice', spoken.id), "data-voice-voice": spoken.id, children: spoken.name }, spoken.id)))] })) : null, _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Speed" }), RATE_CHOICES.map(rate => (_jsx(ActionList.Item, { selected: rate.value === (voice.rate ?? 1), onSelect: () => choose('rate', rate.value), "data-voice-rate": rate.value, children: rate.label }, rate.value)))] }), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Loudness" }), VOLUME_CHOICES.map(volume => (_jsx(ActionList.Item, { selected: volume.value === (voice.volume ?? 1), onSelect: () => choose('volume', volume.value), "data-voice-volume": volume.value, children: volume.label }, volume.value)))] })] })) : null, named.length > 1 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "single", children: [_jsx(ActionList.GroupHeading, { children: "Microphone" }), named.map(device => (_jsx(ActionList.Item, { selected: device.deviceId ===
30
- (voice.microphoneId ?? named[0].deviceId), onSelect: () => choose('microphoneId', device.deviceId), "data-voice-microphone": device.deviceId, children: device.label }, device.deviceId)))] })] })) : null, _jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "multiple", children: [_jsxs(ActionList.Item, { selected: Boolean(voice.sendWhatISay), onSelect: () => choose('sendWhatISay', !voice.sendWhatISay), "data-voice-send-what-i-say": "", children: ["Send what I say", _jsx(ActionList.Description, { variant: "block", children: "Sent as soon as it is heard, not left in the box." })] }), _jsxs(ActionList.Item, { selected: onDevice, onSelect: () => choose('onDeviceOnly', !onDevice), "data-voice-on-device": "", children: ["Keep speech on this device", _jsx(ActionList.Description, { variant: "block", children: "Nothing is sent to Datalayer\u2019s speech service: you are heard in the browser, answers are not spoken." })] })] }), Object.keys(settings).length > 0 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsx(ActionList.Item, { onSelect: () => keep({}), "data-voice-settings-reset": "", children: "Use the application\u2019s" })] })) : null] }) })] }));
31
+ (voice.microphoneId ?? named[0].deviceId), onSelect: () => choose('microphoneId', device.deviceId), "data-voice-microphone": device.deviceId, children: device.label }, device.deviceId)))] })] })) : null, _jsx(ActionList.Divider, {}), _jsxs(ActionList.Group, { selectionVariant: "multiple", children: [_jsxs(ActionList.Item, { selected: Boolean(voice.sendWhatISay), onSelect: () => choose('sendWhatISay', !voice.sendWhatISay), "data-voice-send-what-i-say": "", children: ["Send what I say", _jsx(ActionList.Description, { variant: "block", children: "Sent as soon as it is heard, not left in the box." })] }), voice.speechUrl && !onDevice ? (_jsxs(ActionList.Item, { selected: detect, onSelect: () => choose('detectLanguage', !detect), "data-voice-detect": "", children: ["Detect the language I speak", _jsx(ActionList.Description, { variant: "block", children: "Heard on Datalayer\u2019s servers by Whisper, which finds the language; nothing is kept there." })] })) : null, _jsxs(ActionList.Item, { selected: onDevice, onSelect: () => choose('onDeviceOnly', !onDevice), "data-voice-on-device": "", children: ["Keep speech on this device", _jsx(ActionList.Description, { variant: "block", children: "Nothing is sent to Datalayer\u2019s speech service: you are heard in the browser, answers are not spoken." })] })] }), Object.keys(settings).length > 0 ? (_jsxs(_Fragment, { children: [_jsx(ActionList.Divider, {}), _jsx(ActionList.Item, { onSelect: () => keep({}), "data-voice-settings-reset": "", children: "Use the application\u2019s" })] })) : null] }) })] }));
31
32
  }
32
33
  export default VoiceMenu;
@@ -44,10 +44,11 @@ export declare function chatVoiceOf(spec: AppVoiceSpec | undefined, place: ChatV
44
44
  * A chat's voice asked of the speech service as the chat itself speaks to
45
45
  * its runtime (VO-37, VO-19): the token its host gave, else the chat's own —
46
46
  * a visitor's token minted from ai-inference, an embed's token, or the
47
- * person's — and the deployment it runs, which an embed's token is checked
48
- * against. One token for the conversation and its voice, never a second.
47
+ * person's. One token for the conversation and its voice, never a second.
48
+ * An embed's deployment is the one its token names (decision 16): the page
49
+ * says none.
49
50
  */
50
- export declare function voiceAskedAs(voice: ChatVoice | undefined, token: string | undefined, deployment: string | undefined): ChatVoice | undefined;
51
+ export declare function voiceAskedAs(voice: ChatVoice | undefined, token: string | undefined): ChatVoice | undefined;
51
52
  /** A chat's voice with every place said: what the chat reads. */
52
53
  export type SettledVoice = Required<Pick<ChatVoice, 'language' | 'input' | 'output' | 'voice' | 'modelsUrl' | 'engines' | 'silenceMs'>> & Omit<ChatVoice, 'language' | 'input' | 'output' | 'voice' | 'modelsUrl' | 'engines' | 'silenceMs'>;
53
54
  /** How long a silence after speech stops a microphone clicked on, when unsaid (VO-10). */
@@ -57,26 +57,23 @@ export function chatVoiceOf(spec, place) {
57
57
  speechUrl: place.speechUrl,
58
58
  token: place.token,
59
59
  consentKey: place.consentKey,
60
+ where: spec.where ?? 'auto',
60
61
  };
61
62
  }
62
63
  /**
63
64
  * A chat's voice asked of the speech service as the chat itself speaks to
64
65
  * its runtime (VO-37, VO-19): the token its host gave, else the chat's own —
65
66
  * a visitor's token minted from ai-inference, an embed's token, or the
66
- * person's — and the deployment it runs, which an embed's token is checked
67
- * against. One token for the conversation and its voice, never a second.
67
+ * person's. One token for the conversation and its voice, never a second.
68
+ * An embed's deployment is the one its token names (decision 16): the page
69
+ * says none.
68
70
  */
69
- export function voiceAskedAs(voice, token, deployment) {
71
+ export function voiceAskedAs(voice, token) {
70
72
  if (!voice) {
71
73
  return undefined;
72
74
  }
73
75
  const asked = voice.token || token;
74
- const runs = voice.deployment || deployment;
75
- return {
76
- ...voice,
77
- ...(asked ? { token: asked } : {}),
78
- ...(runs ? { deployment: runs } : {}),
79
- };
76
+ return asked ? { ...voice, token: asked } : { ...voice };
80
77
  }
81
78
  /** How long a silence after speech stops a microphone clicked on, when unsaid (VO-10). */
82
79
  export const SILENCE_MS = 1500;
@@ -4,3 +4,11 @@ export declare function consented(app: string): boolean;
4
4
  export declare function consent(app: string): void;
5
5
  /** The sentence said before the first use: where the audio goes (VO-61). */
6
6
  export declare const CONSENT_SENTENCE = "What you say is transcribed on this device: the sound never leaves it, and is not kept. The words go in the message box, for you to send.";
7
+ /**
8
+ * The sentence said before the first use when what is said is heard on
9
+ * Datalayer's servers (VO-34, VO-61): asked apart from the device's, since
10
+ * a yes to one is not a yes to the other.
11
+ */
12
+ export declare const SERVER_CONSENT_SENTENCE = "What you say is sent to Datalayer\u2019s servers to be transcribed: the sound and the words are not kept there. The words go in the message box, for you to send.";
13
+ /** Whose consent is remembered for hearing in a place: the application's, apart for the server. */
14
+ export declare function consentKeyFor(app: string, where: 'device' | 'server'): string;
@@ -32,3 +32,13 @@ export function consent(app) {
32
32
  }
33
33
  /** The sentence said before the first use: where the audio goes (VO-61). */
34
34
  export const CONSENT_SENTENCE = 'What you say is transcribed on this device: the sound never leaves it, and is not kept. The words go in the message box, for you to send.';
35
+ /**
36
+ * The sentence said before the first use when what is said is heard on
37
+ * Datalayer's servers (VO-34, VO-61): asked apart from the device's, since
38
+ * a yes to one is not a yes to the other.
39
+ */
40
+ export const SERVER_CONSENT_SENTENCE = 'What you say is sent to Datalayer’s servers to be transcribed: the sound and the words are not kept there. The words go in the message box, for you to send.';
41
+ /** Whose consent is remembered for hearing in a place: the application's, apart for the server. */
42
+ export function consentKeyFor(app, where) {
43
+ return where === 'server' ? `${app}:server` : app;
44
+ }
@@ -22,15 +22,30 @@ export interface HeardSoFar {
22
22
  /** How long it has been silent since the speech last ended, in ms. */
23
23
  silentMs: number;
24
24
  }
25
+ /**
26
+ * What hears a press, wherever it runs (§5): in the page (`DeviceHearing`)
27
+ * or on Datalayer's servers (`ServerHearing`).
28
+ */
29
+ export interface Hearing {
30
+ /** Where the sound is heard: what the consent says, and a transcript's `where`. */
31
+ readonly where: 'device' | 'server';
32
+ /** Load what hears a language, saying how far it has come. */
33
+ ready(language: string, progress?: HearingProgress): Promise<void>;
34
+ /** What was said so far while the microphone stays open (VO-11). */
35
+ hearSoFar(audio: Float32Array, language: string): Promise<HeardSoFar>;
36
+ /** What was said in a press, or `undefined` when nothing was. */
37
+ hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
38
+ }
25
39
  /** Joins the speech a VAD found, in order. */
26
40
  export declare function joinSegments(segments: Float32Array[]): Float32Array;
27
41
  /**
28
42
  * The page's hearing: loaded once, on first use, from Datalayer's origin
29
43
  * (`modelsUrl`), every file checked against its pin.
30
44
  */
31
- export declare class DeviceHearing {
45
+ export declare class DeviceHearing implements Hearing {
32
46
  private readonly modelsUrl;
33
47
  private readonly engines;
48
+ readonly where: "device";
34
49
  private recognizers;
35
50
  private vad?;
36
51
  constructor(modelsUrl: string, engines: VoiceEngines);
@@ -40,6 +55,13 @@ export declare class DeviceHearing {
40
55
  sizeFor(language: string): number;
41
56
  /** Load what hears a language, saying how far it has come. */
42
57
  ready(language: string, progress?: HearingProgress): Promise<void>;
58
+ /** Load only what finds speech (Silero VAD): what hearing on the server needs here. */
59
+ readyToFindSpeech(progress?: HearingProgress): Promise<void>;
60
+ /** The speech in what was heard, and how long it has been silent since it last ended, in ms. */
61
+ findSpeech(audio: Float32Array): Promise<{
62
+ speech: Float32Array;
63
+ silentMs: number;
64
+ }>;
43
65
  private recognizer;
44
66
  private activity;
45
67
  /** The speech Silero VAD finds in what was heard, and where the last ended, in ms. */
@@ -45,6 +45,7 @@ export function joinSegments(segments) {
45
45
  export class DeviceHearing {
46
46
  modelsUrl;
47
47
  engines;
48
+ where = 'device';
48
49
  recognizers = new Map();
49
50
  vad;
50
51
  constructor(modelsUrl, engines) {
@@ -67,6 +68,22 @@ export class DeviceHearing {
67
68
  async ready(language, progress) {
68
69
  await Promise.all([this.recognizer(language, progress), this.activity()]);
69
70
  }
71
+ /** Load only what finds speech (Silero VAD): what hearing on the server needs here. */
72
+ async readyToFindSpeech(progress) {
73
+ const total = downloadSize('silero-vad');
74
+ progress?.(0, total);
75
+ await this.activity();
76
+ progress?.(total, total);
77
+ }
78
+ /** The speech in what was heard, and how long it has been silent since it last ended, in ms. */
79
+ async findSpeech(audio) {
80
+ const { speech, lastEnd } = await this.speechIn(audio);
81
+ const heardMs = (audio.length / HEARING_RATE) * 1000;
82
+ return {
83
+ speech,
84
+ silentMs: lastEnd === undefined ? 0 : Math.max(0, heardMs - lastEnd),
85
+ };
86
+ }
70
87
  recognizer(language, progress) {
71
88
  const model = this.modelFor(language);
72
89
  let loading = this.recognizers.get(model.id);
@@ -163,9 +180,7 @@ export class DeviceHearing {
163
180
  * since the speech ended — what stops a click to talk (VO-10).
164
181
  */
165
182
  async hearSoFar(audio, language) {
166
- const { speech, lastEnd } = await this.speechIn(audio);
167
- const heardMs = (audio.length / HEARING_RATE) * 1000;
168
- const silentMs = lastEnd === undefined ? 0 : Math.max(0, heardMs - lastEnd);
183
+ const { speech, silentMs } = await this.findSpeech(audio);
169
184
  if (speech.length < HEARING_RATE * 0.2) {
170
185
  return { text: '', spoke: false, silentMs };
171
186
  }
@@ -13,6 +13,7 @@ export * from './sentences';
13
13
  export * from './pinned';
14
14
  export * from './capture';
15
15
  export * from './hearing';
16
+ export * from './serverHearing';
16
17
  export * from './speaker';
17
18
  export * from './consent';
18
19
  export * from './settings';
@@ -64,15 +65,19 @@ export interface ChatVoice {
64
65
  * (VO-10, VO-69): 1,500 when unsaid; longer for a person who pauses.
65
66
  */
66
67
  silenceMs?: number;
67
- /** ai-agents, whose speech service says the answers. */
68
+ /** ai-agents, whose speech service says the answers, and hears where the browser does not. */
68
69
  speechUrl?: string;
69
- /** The token the speech service is asked with. */
70
- token?: string;
71
70
  /**
72
- * The deployment an embedded application's page speaks for (VO-19): what
73
- * the speech service checks an embed token against, and counts its spend on.
71
+ * Where what is said is heard (§5): `device`, the browser; `server`,
72
+ * Datalayer's speech service; `auto`, the default, the browser where a
73
+ * model there hears the language, else the server. The Appspec's
74
+ * `interface.voice.where`.
74
75
  */
75
- deployment?: string;
76
+ where?: 'auto' | 'device' | 'server';
77
+ /** The person asked for their language to be found by Whisper on the server (VO-14). */
78
+ detect?: boolean;
79
+ /** The token the speech service is asked with. */
80
+ token?: string;
76
81
  /** Send what is said at once, rather than put it in the composer. */
77
82
  sendWhatISay?: boolean;
78
83
  /**
@@ -7,6 +7,7 @@ export * from './sentences';
7
7
  export * from './pinned';
8
8
  export * from './capture';
9
9
  export * from './hearing';
10
+ export * from './serverHearing';
10
11
  export * from './speaker';
11
12
  export * from './consent';
12
13
  export * from './settings';
@@ -0,0 +1,58 @@
1
+ import type { Hearing, HearingProgress, HeardSoFar } from './hearing';
2
+ import type { Transcript } from './types';
3
+ /** Where the speech service hears a recording, on ai-agents' host. */
4
+ export declare const TRANSCRIPTIONS_PATH = "/api/ai-agents/v1/speech/transcriptions";
5
+ /** The model the speech service hears with (agentspecs' catalogue). */
6
+ export declare const SERVER_HEARING_MODEL = "whisper-large-v3-turbo";
7
+ /** Where a chat's voice hears: in the browser, or on Datalayer's servers. */
8
+ export type HearingPlace = 'device' | 'server';
9
+ /** What decides where a chat's voice hears. */
10
+ export interface HearingAsked {
11
+ language: string;
12
+ /** The application's `interface.voice.where`; `auto` when unsaid. */
13
+ where?: 'auto' | 'device' | 'server';
14
+ /** The person asked for their language to be detected (VO-14). */
15
+ detect?: boolean;
16
+ /** The speech service; none, and everything is heard in the browser. */
17
+ speechUrl?: string;
18
+ }
19
+ /**
20
+ * Where a chat's voice hears (§5): in the browser unless the speech service
21
+ * is named and the person asked for detection, the application says
22
+ * `server`, or it says `auto` and no model in the browser hears the language.
23
+ */
24
+ export declare function hearingPlace(voice: HearingAsked): HearingPlace;
25
+ /** Mono samples at 16 kHz as a 16-bit PCM WAV file: what the service reads. */
26
+ export declare function wavOf(samples: Float32Array, rate?: number): ArrayBuffer;
27
+ /** What finds the speech in what was heard: the browser's VAD (`DeviceHearing`). */
28
+ export interface SpeechFinder {
29
+ readyToFindSpeech(progress?: HearingProgress): Promise<void>;
30
+ findSpeech(audio: Float32Array): Promise<{
31
+ speech: Float32Array;
32
+ silentMs: number;
33
+ }>;
34
+ }
35
+ export interface ServerHearingOptions {
36
+ /** ai-agents' origin, where the speech service is. */
37
+ speechUrl: string;
38
+ /** The token the conversation speaks with: the person's, a visitor's, an embed's. */
39
+ token?: string;
40
+ /** Let Whisper find the language (VO-14), rather than tell it. */
41
+ detect?: boolean;
42
+ fetcher?: typeof fetch;
43
+ }
44
+ /**
45
+ * Hears on Datalayer's servers: the browser's VAD finds the speech and when
46
+ * it stopped; Whisper, on the speech service, writes it.
47
+ */
48
+ export declare class ServerHearing implements Hearing {
49
+ private readonly finder;
50
+ private readonly options;
51
+ readonly where: "server";
52
+ constructor(finder: SpeechFinder, options: ServerHearingOptions);
53
+ /** Only the VAD loads in the page: the words are written on the server. */
54
+ ready(_language: string, progress?: HearingProgress): Promise<void>;
55
+ /** Whether speech was found and how long it has been silent; its words come at the end. */
56
+ hearSoFar(audio: Float32Array): Promise<HeardSoFar>;
57
+ hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
58
+ }