@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.
- package/dist-embed/chunks/{ChatView-C0PWJ_wj.js → ChatView-C_NOLqwI.js} +3 -3
- package/dist-embed/datalayer-app-main.js +83 -83
- package/lib/apps/apps/appspec.d.ts +0 -5
- package/lib/apps/apps/appspec.js +1 -6
- package/lib/apps/apps/checks.js +4 -9
- package/lib/apps/embed/element.js +14 -3
- package/lib/apps/embed/embedConfig.d.ts +1 -1
- package/lib/apps/embed/embedConfig.js +1 -1
- package/lib/apps/embed/visitorApp.d.ts +9 -2
- package/lib/apps/embed/visitorApp.js +9 -2
- package/lib/apps/plugins/chat/ChatView.js +4 -1
- package/lib/chat/base/ChatBase.js +12 -3
- package/lib/specs/appspecSchema.js +2 -2
- package/lib/specs/voices.js +42 -0
- package/lib/types/agentspecs.d.ts +1 -1
- package/lib/voice/ComposerVoice.d.ts +10 -2
- package/lib/voice/ComposerVoice.js +10 -2
- package/lib/voice/HandsFree.d.ts +55 -0
- package/lib/voice/HandsFree.js +211 -0
- package/lib/voice/chatVoice.d.ts +2 -1
- package/lib/voice/chatVoice.js +5 -3
- package/lib/voice/handsFree.d.ts +58 -0
- package/lib/voice/handsFree.js +48 -0
- package/lib/voice/index.d.ts +10 -2
- package/lib/voice/index.js +2 -0
- package/package.json +1 -1
- package/scripts/voice/measure.py +74 -0
|
@@ -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. */
|
package/lib/apps/apps/appspec.js
CHANGED
|
@@ -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),
|
package/lib/apps/apps/checks.js
CHANGED
|
@@ -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,
|
|
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
|
|
846
|
+
// Its voice (VOICE.md VO-41, VO-12).
|
|
847
847
|
mapping(ui.voice, 'interface.voice', voice => {
|
|
848
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
46
|
-
|
|
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
|
-
/**
|
|
18
|
-
|
|
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?.(),
|
|
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,
|
|
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
|
|
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
|
},
|
package/lib/specs/voices.js
CHANGED
|
@@ -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(
|
|
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;
|
package/lib/voice/chatVoice.d.ts
CHANGED
|
@@ -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)
|
|
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
|
*/
|