@datalayer/agent-runtimes 1.5.26 → 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.
Files changed (49) hide show
  1. package/dist-embed/chunks/{ChatView-CUWE6I5M.js → ChatView-C0PWJ_wj.js} +4 -4
  2. package/dist-embed/datalayer-app-main.js +111 -101
  3. package/lib/apps/apps/AppKept.js +5 -1
  4. package/lib/apps/embed/element.js +10 -4
  5. package/lib/apps/embed/embedConfig.d.ts +10 -1
  6. package/lib/apps/embed/embedConfig.js +25 -1
  7. package/lib/apps/plugins/chat/ChatView.js +2 -2
  8. package/lib/chat/assistant/AssistantStage.js +17 -0
  9. package/lib/chat/assistant/clearance.d.ts +10 -0
  10. package/lib/chat/assistant/clearance.js +14 -0
  11. package/lib/chat/base/ChatBase.js +375 -437
  12. package/lib/chat/base/conversationItems.d.ts +55 -0
  13. package/lib/chat/base/conversationItems.js +126 -0
  14. package/lib/portable/adapterEvents.d.ts +107 -0
  15. package/lib/portable/adapterEvents.js +239 -0
  16. package/lib/portable/approvalCard.d.ts +56 -0
  17. package/lib/portable/approvalCard.js +56 -0
  18. package/lib/portable/conversation.d.ts +71 -18
  19. package/lib/portable/conversation.js +330 -74
  20. package/lib/portable/fixtures.js +12 -0
  21. package/lib/portable/index.d.ts +4 -1
  22. package/lib/portable/index.js +7 -1
  23. package/lib/portable/presence.js +4 -1
  24. package/lib/portable/runMeasures.d.ts +46 -0
  25. package/lib/portable/runMeasures.js +156 -0
  26. package/lib/protocols/ACPAdapter.js +13 -8
  27. package/lib/specs/voices.js +47 -0
  28. package/lib/utils/utils.d.ts +2 -8
  29. package/lib/utils/utils.js +3 -26
  30. package/lib/voice/ComposerVoice.js +25 -3
  31. package/lib/voice/VoiceInput.d.ts +2 -2
  32. package/lib/voice/VoiceInput.js +6 -4
  33. package/lib/voice/VoiceMenu.js +2 -1
  34. package/lib/voice/chatVoice.d.ts +4 -3
  35. package/lib/voice/chatVoice.js +6 -9
  36. package/lib/voice/consent.d.ts +8 -0
  37. package/lib/voice/consent.js +10 -0
  38. package/lib/voice/hearing.d.ts +23 -1
  39. package/lib/voice/hearing.js +18 -3
  40. package/lib/voice/index.d.ts +11 -6
  41. package/lib/voice/index.js +1 -0
  42. package/lib/voice/serverHearing.d.ts +58 -0
  43. package/lib/voice/serverHearing.js +136 -0
  44. package/lib/voice/settings.d.ts +5 -0
  45. package/lib/voice/settings.js +8 -0
  46. package/lib/voice/speaker.d.ts +4 -3
  47. package/lib/voice/speaker.js +1 -2
  48. package/lib/voice/useVoiceSpeaker.js +0 -3
  49. package/package.json +2 -2
@@ -0,0 +1,46 @@
1
+ /**
2
+ * How a streamed turn went, measured without its words (MOBILE M-036, §10):
3
+ * the time to its first streamed token, to its complete answer, how long a
4
+ * Stop took, the category of a stream that failed, and how often a lost
5
+ * stream was read back. Never a prompt, an answer, a tool's arguments or an
6
+ * id — only durations, counts and coarse categories — and an OTLP/JSON body
7
+ * of them for the platform's metrics ingest.
8
+ *
9
+ * Pure: the clock is given.
10
+ *
11
+ * @module portable/runMeasures
12
+ */
13
+ import { type AgUiEvent } from './agui';
14
+ /** Why a stream failed, coarsely. */
15
+ export type StreamFailure = 'refused' | 'cut_short' | 'network' | 'other';
16
+ /** One turn, measured. */
17
+ export type RunMeasure = {
18
+ surface: 'application' | 'agent';
19
+ firstTokenMs: number | null;
20
+ answerMs: number | null;
21
+ stopLatencyMs: number | null;
22
+ failure: StreamFailure | null;
23
+ };
24
+ /** The category of a stream's failure; never its message. */
25
+ export declare function failureOf(error: unknown): StreamFailure;
26
+ /** Measures one turn from its start: fed its events, its Stop, its failure; read once it ended. */
27
+ export declare class RunMeter {
28
+ private readonly surface;
29
+ private readonly now;
30
+ private readonly started;
31
+ private firstToken;
32
+ private answered;
33
+ private stopLatency;
34
+ private failed;
35
+ constructor(surface: RunMeasure['surface'], now?: () => number);
36
+ event(event: AgUiEvent): void;
37
+ stopped(latencyMs: number): void;
38
+ failure(error: unknown): void;
39
+ read(): RunMeasure;
40
+ }
41
+ /**
42
+ * The OTLP/JSON body of turns measured and lost streams read back: gauges of
43
+ * milliseconds and counters, each point with the surface and, for a failure,
44
+ * its category — nothing else.
45
+ */
46
+ export declare function otlpMetricsOf(measures: RunMeasure[], recoveries: number, resource: Record<string, string>, nowMs: number): Record<string, unknown>;
@@ -0,0 +1,156 @@
1
+ /*
2
+ * Copyright (c) 2025-2026 Datalayer, Inc.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+ /**
6
+ * How a streamed turn went, measured without its words (MOBILE M-036, §10):
7
+ * the time to its first streamed token, to its complete answer, how long a
8
+ * Stop took, the category of a stream that failed, and how often a lost
9
+ * stream was read back. Never a prompt, an answer, a tool's arguments or an
10
+ * id — only durations, counts and coarse categories — and an OTLP/JSON body
11
+ * of them for the platform's metrics ingest.
12
+ *
13
+ * Pure: the clock is given.
14
+ *
15
+ * @module portable/runMeasures
16
+ */
17
+ import { RUN_CUT_SHORT } from './agui';
18
+ import { RuntimeRefusal } from './sessions';
19
+ /** The category of a stream's failure; never its message. */
20
+ export function failureOf(error) {
21
+ if (error instanceof RuntimeRefusal)
22
+ return 'refused';
23
+ const message = error instanceof Error ? error.message : String(error);
24
+ if (message === RUN_CUT_SHORT || message.includes(RUN_CUT_SHORT))
25
+ return 'cut_short';
26
+ if (error instanceof TypeError ||
27
+ /network|offline|timed? ?out|fetch/i.test(message))
28
+ return 'network';
29
+ return 'other';
30
+ }
31
+ /** Measures one turn from its start: fed its events, its Stop, its failure; read once it ended. */
32
+ export class RunMeter {
33
+ surface;
34
+ now;
35
+ started;
36
+ firstToken = null;
37
+ answered = null;
38
+ stopLatency = null;
39
+ failed = null;
40
+ constructor(surface, now = () => Date.now()) {
41
+ this.surface = surface;
42
+ this.now = now;
43
+ this.started = now();
44
+ }
45
+ event(event) {
46
+ if (this.firstToken === null && event.type === 'TEXT_MESSAGE_CONTENT') {
47
+ this.firstToken = this.now() - this.started;
48
+ }
49
+ if (event.type === 'RUN_FINISHED' && this.answered === null) {
50
+ this.answered = this.now() - this.started;
51
+ }
52
+ }
53
+ stopped(latencyMs) {
54
+ this.stopLatency = Math.max(0, latencyMs);
55
+ }
56
+ failure(error) {
57
+ this.failed = failureOf(error);
58
+ }
59
+ read() {
60
+ return {
61
+ surface: this.surface,
62
+ firstTokenMs: this.firstToken,
63
+ answerMs: this.failed || this.stopLatency !== null ? null : this.answered,
64
+ stopLatencyMs: this.stopLatency,
65
+ failure: this.failed,
66
+ };
67
+ }
68
+ }
69
+ const attributes = (values) => Object.entries(values).map(([key, value]) => ({
70
+ key,
71
+ value: { stringValue: value },
72
+ }));
73
+ /**
74
+ * The OTLP/JSON body of turns measured and lost streams read back: gauges of
75
+ * milliseconds and counters, each point with the surface and, for a failure,
76
+ * its category — nothing else.
77
+ */
78
+ export function otlpMetricsOf(measures, recoveries, resource, nowMs) {
79
+ const time = String(BigInt(Math.trunc(nowMs)) * 1000000n);
80
+ const gauge = (name, pick) => ({
81
+ name,
82
+ unit: 'ms',
83
+ gauge: {
84
+ dataPoints: measures.flatMap(measure => {
85
+ const value = pick(measure);
86
+ return value === null
87
+ ? []
88
+ : [
89
+ {
90
+ asDouble: value,
91
+ timeUnixNano: time,
92
+ attributes: attributes({ surface: measure.surface }),
93
+ },
94
+ ];
95
+ }),
96
+ },
97
+ });
98
+ const failures = new Map();
99
+ for (const measure of measures) {
100
+ if (measure.failure) {
101
+ const key = `${measure.surface}\n${measure.failure}`;
102
+ failures.set(key, (failures.get(key) ?? 0) + 1);
103
+ }
104
+ }
105
+ const metrics = [
106
+ gauge('datalayer.client.run.first_token', measure => measure.firstTokenMs),
107
+ gauge('datalayer.client.run.answer', measure => measure.answerMs),
108
+ gauge('datalayer.client.run.stop_latency', measure => measure.stopLatencyMs),
109
+ {
110
+ name: 'datalayer.client.run.failures',
111
+ sum: {
112
+ aggregationTemporality: 1,
113
+ isMonotonic: true,
114
+ dataPoints: [...failures].map(([key, count]) => {
115
+ const [surface, category] = key.split('\n');
116
+ return {
117
+ asInt: String(count),
118
+ timeUnixNano: time,
119
+ attributes: attributes({ surface, category }),
120
+ };
121
+ }),
122
+ },
123
+ },
124
+ {
125
+ name: 'datalayer.client.run.recoveries',
126
+ sum: {
127
+ aggregationTemporality: 1,
128
+ isMonotonic: true,
129
+ dataPoints: recoveries > 0
130
+ ? [
131
+ {
132
+ asInt: String(recoveries),
133
+ timeUnixNano: time,
134
+ attributes: [],
135
+ },
136
+ ]
137
+ : [],
138
+ },
139
+ },
140
+ ].filter(metric => 'gauge' in metric
141
+ ? metric.gauge.dataPoints.length > 0
142
+ : metric.sum.dataPoints.length > 0);
143
+ return {
144
+ resourceMetrics: [
145
+ {
146
+ resource: { attributes: attributes(resource) },
147
+ scopeMetrics: [
148
+ {
149
+ scope: { name: '@datalayer/agent-runtimes/portable/runMeasures' },
150
+ metrics,
151
+ },
152
+ ],
153
+ },
154
+ ],
155
+ };
156
+ }
@@ -454,15 +454,20 @@ export class ACPAdapter extends BaseProtocolAdapter {
454
454
  });
455
455
  }
456
456
  if (updateType === 'tool_call_update') {
457
+ // A call's result, once it says it ended; its progress before then is
458
+ // not a result. ACP names the call it updates.
457
459
  const progress = update;
458
- this.emit({
459
- type: 'tool-result',
460
- toolResult: {
461
- success: progress.status !== 'failed',
462
- result: progress.rawOutput,
463
- },
464
- timestamp: new Date(),
465
- });
460
+ if (progress.status === 'completed' || progress.status === 'failed') {
461
+ this.emit({
462
+ type: 'tool-result',
463
+ toolResult: {
464
+ toolCallId: progress.toolCallId ?? '',
465
+ success: progress.status === 'completed',
466
+ result: progress.rawOutput,
467
+ },
468
+ timestamp: new Date(),
469
+ });
470
+ }
466
471
  }
467
472
  if (updateType === 'agent_thought_chunk') {
468
473
  const thought = textOf(update.content);
@@ -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) {
@@ -4,6 +4,7 @@
4
4
  * @module chat/utils
5
5
  */
6
6
  import { QueryClient } from '@tanstack/react-query';
7
+ import { sanitizeAssistantContent } from '../portable/adapterEvents';
7
8
  import type { ChatMessage } from '../types/messages';
8
9
  import type { ProtocolConfig } from '../types/protocol';
9
10
  import { type BaseProtocolAdapter } from '../protocols';
@@ -16,14 +17,7 @@ export declare const PRIMER_PORTAL_ROOT_ID = "__primerPortalRoot__";
16
17
  * Check if an item is a tool call message
17
18
  */
18
19
  export declare function isToolCallMessage(item: DisplayItem): item is ToolCallMessage;
19
- /**
20
- * Strip low-level protocol/tool-call markup from assistant text content.
21
- *
22
- * Some models occasionally leak internal call formats (for example,
23
- * <calls>...</calls> or <tml:invoke ...>) into natural-language responses.
24
- * Keep user/system text untouched and sanitize assistant-visible text only.
25
- */
26
- export declare function sanitizeAssistantContent(content: string): string;
20
+ export { sanitizeAssistantContent };
27
21
  /**
28
22
  * Extract text content from a ChatMessage
29
23
  */
@@ -8,6 +8,7 @@
8
8
  * @module chat/utils
9
9
  */
10
10
  import { QueryClient } from '@tanstack/react-query';
11
+ import { sanitizeAssistantContent } from '../portable/adapterEvents';
11
12
  import { AGUIAdapter, A2AAdapter, VercelAIAdapter, ACPAdapter, BrowserAgentAdapter, } from '../protocols';
12
13
  // ---------------------------------------------------------------------------
13
14
  // Constants
@@ -32,32 +33,8 @@ export const PRIMER_PORTAL_ROOT_ID = '__primerPortalRoot__';
32
33
  export function isToolCallMessage(item) {
33
34
  return 'type' in item && item.type === 'tool-call';
34
35
  }
35
- /**
36
- * Strip low-level protocol/tool-call markup from assistant text content.
37
- *
38
- * Some models occasionally leak internal call formats (for example,
39
- * <calls>...</calls> or <tml:invoke ...>) into natural-language responses.
40
- * Keep user/system text untouched and sanitize assistant-visible text only.
41
- */
42
- export function sanitizeAssistantContent(content) {
43
- if (!content || !content.includes('<')) {
44
- return content;
45
- }
46
- let sanitized = content;
47
- // Remove complete blocks first.
48
- sanitized = sanitized
49
- .replace(/<\s*calls\b[\s\S]*?<\s*\/\s*calls\s*>/gi, '')
50
- .replace(/<\s*(?:an:)?tml:invoke\b[\s\S]*?<\s*\/\s*(?:an:)?tml:invoke\s*>/gi, '')
51
- .replace(/<\s*(?:an:)?tml:parameter\b[\s\S]*?<\s*\/\s*(?:an:)?tml:parameter\s*>/gi, '');
52
- // Remove standalone/open/close tags and malformed tag fragments.
53
- sanitized = sanitized
54
- .replace(/<\s*\/?\s*(?:an:[a-z_]+|tml:[a-z_]+|calls)\b[^>]*>?/gi, '')
55
- .replace(/<\s*\/\s*>/g, '')
56
- .replace(/^[ \t]+$/gm, '')
57
- .replace(/\n{3,}/g, '\n\n')
58
- .trim();
59
- return sanitized;
60
- }
36
+ // What a model leaked of its call markup, taken out: one rule for every client.
37
+ export { sanitizeAssistantContent };
61
38
  /**
62
39
  * Extract text content from a ChatMessage
63
40
  */
@@ -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
  }