@datalayer/agent-runtimes 1.5.59 → 1.5.61

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 (39) hide show
  1. package/dist-embed/chunks/{ChatView-lgDCrM9e.js → ChatView-CjD1TsaE.js} +1 -1
  2. package/dist-embed/datalayer-app-main.js +96 -96
  3. package/lib/apps/apps/AppRenderer.js +8 -1
  4. package/lib/apps/plugins/chat/ChatView.js +3 -1
  5. package/lib/chat/ChatFloating.js +4 -0
  6. package/lib/chat/base/ChatBase.js +34 -5
  7. package/lib/chat/prompt/InputPrompt.js +3 -0
  8. package/lib/chat/prompt/stack.d.ts +22 -0
  9. package/lib/chat/prompt/stack.js +40 -1
  10. package/lib/models/PageTag.d.ts +1 -1
  11. package/lib/protocols/AGUIAdapter.d.ts +8 -0
  12. package/lib/protocols/AGUIAdapter.js +26 -0
  13. package/lib/types/chat.d.ts +6 -0
  14. package/lib/voice/ComposerVoice.js +2 -2
  15. package/lib/voice/HandsFree.d.ts +14 -3
  16. package/lib/voice/HandsFree.js +52 -8
  17. package/lib/voice/VoiceInput.d.ts +6 -1
  18. package/lib/voice/VoiceInput.js +21 -6
  19. package/lib/voice/chatVoice.d.ts +1 -1
  20. package/lib/voice/chatVoice.js +3 -1
  21. package/lib/voice/consent.d.ts +6 -2
  22. package/lib/voice/consent.js +8 -3
  23. package/lib/voice/handsFree.d.ts +2 -2
  24. package/lib/voice/handsFree.js +1 -1
  25. package/lib/voice/hearing.d.ts +13 -0
  26. package/lib/voice/hearing.js +8 -0
  27. package/lib/voice/index.d.ts +8 -1
  28. package/lib/voice/index.js +1 -0
  29. package/lib/voice/keptSound.d.ts +15 -0
  30. package/lib/voice/keptSound.js +69 -0
  31. package/lib/voice/sayAloud.d.ts +7 -0
  32. package/lib/voice/sayAloud.js +7 -0
  33. package/lib/voice/serverHearing.d.ts +4 -0
  34. package/lib/voice/serverHearing.js +20 -0
  35. package/lib/voice/speaker.d.ts +15 -0
  36. package/lib/voice/speaker.js +36 -1
  37. package/lib/voice/types.d.ts +12 -0
  38. package/lib/voice/useVoiceSpeaker.js +2 -0
  39. package/package.json +1 -1
@@ -71,6 +71,7 @@ import { keepsFeedback } from './feedback';
71
71
  import { keptBeforeFirstMessage } from './kept';
72
72
  import { ChatLanguage } from '../../chat/ChatLanguage';
73
73
  import { chatVoiceOf } from '../../voice/chatVoice';
74
+ import { keptAudioDays } from '../../voice/keptSound';
74
75
  import { defineAppRuntimePlugin } from './AppRuntimePlugins';
75
76
  import { hasFolderModules } from './customComponents';
76
77
  import { appPluginPair } from './pluginPair';
@@ -471,7 +472,11 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
471
472
  ...speech,
472
473
  consentKey: app.id,
473
474
  language,
474
- }),
475
+ },
476
+ // What is said kept as sound, as its record says (VO-63): only for
477
+ // a person signed in (the host gave their token), never a visitor
478
+ // nor an embed's.
479
+ visitor || !speech.token ? undefined : keptAudioDays(app.record)),
475
480
  };
476
481
  }
477
482
  catch (error) {
@@ -482,6 +487,8 @@ export function AppRenderer({ app, plugins = NO_PLUGINS, instance, onPresence, o
482
487
  // eslint-disable-next-line react-hooks/exhaustive-deps
483
488
  }, [
484
489
  JSON.stringify(app.interface?.voice ?? null),
490
+ JSON.stringify(app.record?.include ?? null),
491
+ visitor,
485
492
  app.id,
486
493
  language,
487
494
  speech?.speechUrl,
@@ -1720,7 +1720,9 @@ export default function ChatView({ workspace }) {
1720
1720
  playbackLevel: () => controlsRef.current?.voiceLevel?.() ?? 0,
1721
1721
  // Hands-free (VO-12, VO-13): the run in progress, stopped when
1722
1722
  // the person speaks over it, and the answer marked interrupted.
1723
- running: busy, onStopRun: () => controlsRef.current?.stop?.(), onInterrupted: () => controlsRef.current?.markInterrupted(), onInterimChange: setVoiceInterim,
1723
+ running: busy,
1724
+ // Spoken over, the application's session stays open (VO-46).
1725
+ onStopRun: () => (controlsRef.current?.interrupt ?? controlsRef.current?.stop)?.(), onInterrupted: () => controlsRef.current?.markInterrupted(), onInterimChange: setVoiceInterim,
1724
1726
  // The menu offers when answers are heard where the
1725
1727
  // application lets them be (VO-21, VO-30).
1726
1728
  answers: Boolean(config?.voice?.output &&
@@ -10,6 +10,7 @@ import { ButtonGlow } from './display/ButtonGlow';
10
10
  import { useViewportDrag } from './useViewportDrag';
11
11
  import { disabledChatViewModes, resolveMountPoint } from './viewModes';
12
12
  import { AssistantStage } from './assistant/AssistantStage';
13
+ import { ASSISTANT_CLEARANCE_VAR } from './assistant/clearance';
13
14
  import { decisionsAskerAt } from './assistant/decisions';
14
15
  import { DecisionAsk } from './assistant/DecisionAsk';
15
16
  import { useSpokenAnswers } from '../voice/useSpokenAnswers';
@@ -854,6 +855,9 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
854
855
  : undefined;
855
856
  /* The window itself; where it goes is decided below. */
856
857
  const chatWindow = (_jsxs(Box, { ref: popupRef, className: className, sx: {
858
+ // Its own conversation stands beside the character, never under it:
859
+ // its prompt's controls keep their own padding (T-27).
860
+ ...(assistantShown ? { [ASSISTANT_CLEARANCE_VAR]: 'initial' } : {}),
857
861
  position: viewMode === 'panel' && !isMobile && (panelInFlow || dockedInMount)
858
862
  ? 'relative'
859
863
  : 'fixed',
@@ -59,7 +59,7 @@ import { useVoiceSettings } from '../../voice/useVoiceSettings';
59
59
  import { useVoiceSpeaker } from '../../voice/useVoiceSpeaker';
60
60
  import { useSpokenAnswers } from '../../voice/useSpokenAnswers';
61
61
  import { saidMessageId, useSaidSentence } from '../../voice/captions';
62
- import { lineToSay } from '../../voice/sayAloud';
62
+ import { isVoiceInterrupted, lineToSay, } from '../../voice/sayAloud';
63
63
  import { isFloatingChatViewMode } from '../viewModes';
64
64
  import { ToolApprovalBanner, ToolApprovalDialog, } from '../tools';
65
65
  import { initialModelId, isOffered, offeredModels as offeredModelsFor, } from './modelChoice';
@@ -2986,6 +2986,16 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2986
2986
  */
2987
2987
  const [voiceSaid, setVoiceSaid] = useState('');
2988
2988
  voiceSayRef.current = activity => {
2989
+ if (isVoiceInterrupted(activity)) {
2990
+ // Spoken over, the runtime says (VO-46): its voice stops.
2991
+ if (onStopSpeaking) {
2992
+ onStopSpeaking();
2993
+ }
2994
+ else if (ownVoice.speaker) {
2995
+ ownVoice.stop();
2996
+ }
2997
+ return;
2998
+ }
2989
2999
  const line = lineToSay(activity);
2990
3000
  if (!line) {
2991
3001
  return;
@@ -3077,7 +3087,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3077
3087
  const forwardedProps = spoken
3078
3088
  ? { ...(forwardedPropsGiven ?? {}), voice: spoken }
3079
3089
  : forwardedPropsGiven;
3080
- const userMessage = createUserMessage(messageContent, spoken ? { ...spoken } : undefined);
3090
+ // The message says how it was heard; its sound, sent to be kept
3091
+ // (VO-63), rides on the run only, never on the conversation.
3092
+ let heardAs;
3093
+ if (spoken) {
3094
+ const { sound: _sound, ...rest } = spoken;
3095
+ heardAs = rest;
3096
+ }
3097
+ const userMessage = createUserMessage(messageContent, heardAs ? { ...heardAs } : undefined);
3081
3098
  const currentMessages = displayItems.filter((item) => !isToolCallMessage(item));
3082
3099
  const allMessages = [...currentMessages, userMessage];
3083
3100
  {
@@ -3251,7 +3268,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3251
3268
  applyServerApprovalDecision,
3252
3269
  ]);
3253
3270
  // ---- handleStop ----
3254
- const handleStop = useCallback(() => {
3271
+ // Spoken over (VO-46, VO-13), an application's session is told so and
3272
+ // stays open; Stop leaves it to be resumed.
3273
+ const stopTurn = useCallback((spokenOver) => {
3255
3274
  stoppedRef.current = true;
3256
3275
  abortControllerRef.current?.abort();
3257
3276
  // Best-effort cancellation without tearing down adapter/session.
@@ -3262,7 +3281,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3262
3281
  adapter.stopGeneration();
3263
3282
  }
3264
3283
  // Also tell the backend to stop (server-side cancellation).
3265
- if (typeof adapter.terminateSession === 'function') {
3284
+ if (spokenOver && typeof adapter.interruptSession === 'function') {
3285
+ void adapter.interruptSession().catch(() => { });
3286
+ }
3287
+ else if (typeof adapter.terminateSession === 'function') {
3266
3288
  void adapter.terminateSession().catch(() => { });
3267
3289
  }
3268
3290
  else if (typeof adapter.terminateAgent === 'function') {
@@ -3314,6 +3336,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3314
3336
  protocol?.authToken,
3315
3337
  protocol?.agentId,
3316
3338
  ]);
3339
+ const handleStop = useCallback(() => stopTurn(false), [stopTurn]);
3340
+ const handleSpokenOver = useCallback(() => stopTurn(true), [stopTurn]);
3317
3341
  /*
3318
3342
  * How a message's display presses what it offered (RU-4,
3319
3343
  * RU-W): with the token of the offer, as the run's press — which
@@ -3390,6 +3414,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3390
3414
  voiceLevel: playbackLevel,
3391
3415
  // The answer spoken over, marked for a host's own composer (VO-13).
3392
3416
  markInterrupted,
3417
+ // Spoken over: the run ends, the application's session stays open (VO-46).
3418
+ interrupt: handleSpokenOver,
3393
3419
  });
3394
3420
  return () => {
3395
3421
  onSendReady(null);
@@ -3400,6 +3426,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3400
3426
  onSendMessage,
3401
3427
  handleSend,
3402
3428
  handleStop,
3429
+ handleSpokenOver,
3403
3430
  handleNewChat,
3404
3431
  stopSpeaking,
3405
3432
  playbackLevel,
@@ -3808,7 +3835,9 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3808
3835
  playbackLevel: playbackLevel,
3809
3836
  // Hands-free (VO-12, VO-13): the run in progress, stopped when the
3810
3837
  // person speaks over it, and the answer marked interrupted.
3811
- running: isLoading, onStopRun: handleStop, onInterrupted: markInterrupted, onInterimChange: setVoiceInterim,
3838
+ running: isLoading,
3839
+ // Spoken over, the application's session stays open (VO-46).
3840
+ onStopRun: handleSpokenOver, onInterrupted: markInterrupted, onInterimChange: setVoiceInterim,
3812
3841
  // The menu offers when answers are heard where the application
3813
3842
  // lets them be (VO-21, VO-30).
3814
3843
  answers: Boolean(voice?.output && voice.output !== 'off' && voice.speechUrl), onSpoken: metadata => {
@@ -151,6 +151,9 @@ export function InputPrompt({ input, setInput, isLoading, kernelStatus, connecti
151
151
  {
152
152
  id: 'session-controls',
153
153
  px: padding,
154
+ // Its right end — the voice controls — is never under
155
+ // the floating assistant in its corner (T-27).
156
+ clearsAssistant: true,
154
157
  py: 0.5,
155
158
  minHeight: 36,
156
159
  bordered: true,
@@ -18,6 +18,19 @@
18
18
  * @module chat/prompt/stack
19
19
  */
20
20
  import type { ReactNode } from 'react';
21
+ /**
22
+ * A theme space step (`px={2}`) as a CSS length, from the theme's scale or
23
+ * else Primer's own.
24
+ */
25
+ export declare function spaceLength(step: number, scale?: readonly (string | number)[]): string;
26
+ /**
27
+ * The right padding of a band that `clearsAssistant`: the floating
28
+ * assistant's clearance while it stands in a bottom corner (what it covers
29
+ * from the window's right edge, `--assistant-clearance`), so that the
30
+ * character is never drawn over the band's controls and never takes their
31
+ * clicks; else the band's own `padding`, a CSS length.
32
+ */
33
+ export declare function clearOfAssistant(padding: string): string;
21
34
  /** One band in a region. */
22
35
  export type PromptStack = {
23
36
  /** Identity, for React and for a host reasoning about which band is which. */
@@ -69,6 +82,15 @@ export type PromptStack = {
69
82
  * showing the page behind it — a pale stripe down the right-hand side.
70
83
  */
71
84
  layout?: 'row' | 'block';
85
+ /**
86
+ * Keep the band's right end clear of the floating assistant while it
87
+ * stands in a bottom corner: its right padding becomes the assistant's
88
+ * clearance (`--assistant-clearance`), so that the character is never
89
+ * drawn over the band's controls and never takes their clicks. For a band
90
+ * that runs to the page's right edge, as the composer's session controls
91
+ * do on an application's page.
92
+ */
93
+ clearsAssistant?: boolean;
72
94
  };
73
95
  export type PromptStacksProps = {
74
96
  /** The bands, top to bottom. */
@@ -1,5 +1,37 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useTheme } from '@primer/react';
2
3
  import { Box } from '@datalayer/primer-addons';
4
+ import { ASSISTANT_CLEARANCE_VAR } from '../assistant/clearance';
5
+ /** Primer's space scale, for a band drawn without a theme. */
6
+ const PRIMER_SPACE = [
7
+ '0',
8
+ '4px',
9
+ '8px',
10
+ '16px',
11
+ '24px',
12
+ '32px',
13
+ '40px',
14
+ '48px',
15
+ '64px',
16
+ ];
17
+ /**
18
+ * A theme space step (`px={2}`) as a CSS length, from the theme's scale or
19
+ * else Primer's own.
20
+ */
21
+ export function spaceLength(step, scale = PRIMER_SPACE) {
22
+ const value = scale[step] ?? PRIMER_SPACE[step] ?? `${step * 8}px`;
23
+ return typeof value === 'number' ? `${value}px` : value;
24
+ }
25
+ /**
26
+ * The right padding of a band that `clearsAssistant`: the floating
27
+ * assistant's clearance while it stands in a bottom corner (what it covers
28
+ * from the window's right edge, `--assistant-clearance`), so that the
29
+ * character is never drawn over the band's controls and never takes their
30
+ * clicks; else the band's own `padding`, a CSS length.
31
+ */
32
+ export function clearOfAssistant(padding) {
33
+ return `var(${ASSISTANT_CLEARANCE_VAR}, ${padding})`;
34
+ }
3
35
  /**
4
36
  * Whether a band would draw anything.
5
37
  *
@@ -12,12 +44,19 @@ function isRendered(stack) {
12
44
  /** Render a region's bands, skipping the ones with nothing to show. */
13
45
  export function PromptStacks({ stacks, disabled = false, }) {
14
46
  const visible = stacks.filter(isRendered);
47
+ const { theme } = useTheme();
15
48
  if (visible.length === 0) {
16
49
  return null;
17
50
  }
18
51
  return (_jsx(_Fragment, { children: visible.map(stack => {
19
52
  const isDisabled = disabled || stack.disabled;
20
- return (_jsx(Box, { "data-prompt-stack": stack.id, "aria-disabled": isDisabled || undefined, px: stack.px ?? 2, py: stack.py ?? 1, minHeight: stack.minHeight, display: stack.layout === 'block' ? undefined : 'flex', alignItems: stack.layout === 'block' ? undefined : 'center', gap: stack.layout === 'block' ? undefined : (stack.gap ?? 2), pt: stack.pt === undefined ? undefined : stack.pt, pb: stack.pb === undefined ? undefined : stack.pb, borderTop: stack.bordered ? '1px solid' : undefined, borderColor: stack.bordered ? 'border.default' : undefined, bg: stack.subtle ? 'canvas.subtle' : undefined, opacity: isDisabled ? 0.5 : undefined, pointerEvents: isDisabled ? 'none' : undefined, children: stack.content }, stack.id));
53
+ const px = stack.px ?? 2;
54
+ const right = Array.isArray(px) ? px[px.length - 1] : px;
55
+ return (_jsx(Box, { "data-prompt-stack": stack.id, "aria-disabled": isDisabled || undefined, px: px, "data-clears-assistant": stack.clearsAssistant ? '' : undefined, sx: stack.clearsAssistant
56
+ ? {
57
+ paddingRight: clearOfAssistant(spaceLength(right, theme?.space)),
58
+ }
59
+ : undefined, py: stack.py ?? 1, minHeight: stack.minHeight, display: stack.layout === 'block' ? undefined : 'flex', alignItems: stack.layout === 'block' ? undefined : 'center', gap: stack.layout === 'block' ? undefined : (stack.gap ?? 2), pt: stack.pt === undefined ? undefined : stack.pt, pb: stack.pb === undefined ? undefined : stack.pb, borderTop: stack.bordered ? '1px solid' : undefined, borderColor: stack.bordered ? 'border.default' : undefined, bg: stack.subtle ? 'canvas.subtle' : undefined, opacity: isDisabled ? 0.5 : undefined, pointerEvents: isDisabled ? 'none' : undefined, children: stack.content }, stack.id));
21
60
  }) }));
22
61
  }
23
62
  export default PromptStacks;
@@ -15,5 +15,5 @@ export type PageTag = {
15
15
  export declare const PAGE_TAGE_NONE: PageTag;
16
16
  export declare const PAGE_TAGS: PageTag[];
17
17
  export declare const PAGE_TAGS_BY_NAME_MAP: Map<PageTagName | undefined, PageTag>;
18
- export declare const PAGE_TAGS_BY_COLOR_MAP: Map<"default" | "green" | "lime" | "blue" | "coral" | "gray" | "indigo" | "orange" | "pink" | "purple" | "red" | "teal" | "yellow" | "lemon", PageTag>;
18
+ export declare const PAGE_TAGS_BY_COLOR_MAP: Map<"blue" | "coral" | "gray" | "green" | "indigo" | "lime" | "orange" | "pink" | "purple" | "red" | "teal" | "yellow" | "default" | "lemon", PageTag>;
19
19
  export {};
@@ -66,6 +66,14 @@ export declare class AGUIAdapter extends BaseProtocolAdapter {
66
66
  * Terminate a running agent thread on the backend
67
67
  */
68
68
  terminateAgent(threadId?: string): Promise<void>;
69
+ /**
70
+ * The person spoke over the agent (VO-46, VO-13): an application's
71
+ * session is told (`POST {sessions}/{uid}/interrupt`) — what runs is
72
+ * cancelled, its code's `@app.interrupted` runs, and it stays open for
73
+ * what is said next, where Stop would leave it waiting to be resumed.
74
+ * Another agent's run is stopped, as Stop stops it.
75
+ */
76
+ interruptSession(threadId?: string): Promise<void>;
69
77
  /**
70
78
  * Stop an application's session (`POST {sessions}/{uid}/stop`): what runs
71
79
  * is cancelled and it waits to be resumed. Its thread is the session; with
@@ -126,6 +126,32 @@ export class AGUIAdapter extends BaseProtocolAdapter {
126
126
  // Ignore errors - agent may have already completed
127
127
  }
128
128
  }
129
+ /**
130
+ * The person spoke over the agent (VO-46, VO-13): an application's
131
+ * session is told (`POST {sessions}/{uid}/interrupt`) — what runs is
132
+ * cancelled, its code's `@app.interrupted` runs, and it stays open for
133
+ * what is said next, where Stop would leave it waiting to be resumed.
134
+ * Another agent's run is stopped, as Stop stops it.
135
+ */
136
+ async interruptSession(threadId) {
137
+ const sessions = this.aguiConfig.appSessions;
138
+ const thread = threadId || this.currentThreadId;
139
+ if (!sessions) {
140
+ await this.terminateAgent(threadId);
141
+ return;
142
+ }
143
+ if (!thread) {
144
+ return;
145
+ }
146
+ const url = `${sessions.replace(/\/+$/, '')}/${encodeURIComponent(thread)}/interrupt`;
147
+ const response = await this.fetchOf()(url, {
148
+ method: 'POST',
149
+ headers: this.buildHeaders(),
150
+ });
151
+ if (!response.ok) {
152
+ throw new Error(`The session ${thread} could not be told it was spoken over (${response.status}).`);
153
+ }
154
+ }
129
155
  /**
130
156
  * Stop an application's session (`POST {sessions}/{uid}/stop`): what runs
131
157
  * is cancelled and it waits to be resumed. Its thread is the session; with
@@ -700,6 +700,12 @@ export interface ChatBaseProps {
700
700
  * for a host whose composer holds the microphone.
701
701
  */
702
702
  markInterrupted: () => void;
703
+ /**
704
+ * The person spoke over the run (VO-46, VO-13): it ends as Stop ends
705
+ * it, but an application's session is told it was spoken over and
706
+ * stays open for what is said next — Stop leaves it to be resumed.
707
+ */
708
+ interrupt?: () => void;
703
709
  } | null) => void;
704
710
  /**
705
711
  * Reports whether the agent's voice is speaking, for a host that draws the
@@ -55,14 +55,14 @@ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled
55
55
  if (!hearing) {
56
56
  return null;
57
57
  }
58
- 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, playbackLevel: playbackLevel, onStopSpeaking: onStopSpeaking, onStopRun: onStopRun, onInterrupted: onInterrupted, onInterim: (words) => {
58
+ 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', keepsAudio: voice.keepsAudio, microphoneId: voice.microphoneId, turnSilenceMs: voice.turnSilenceMs, disabled: disabled, running: running, speaking: speaking, playbackLevel: playbackLevel, onStopSpeaking: onStopSpeaking, onStopRun: onStopRun, onInterrupted: onInterrupted, onInterim: (words) => {
59
59
  setInput(words ? afterBase(words) : base.current);
60
60
  onInterimChange?.(words !== '');
61
61
  }, send: (text, metadata) => {
62
62
  onInterimChange?.(false);
63
63
  setInput(base.current);
64
64
  send(text, metadata);
65
- } })) : 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: () => {
65
+ } })) : null, _jsx(VoiceInput, { hearing: hearing, language: voice.language, consentKey: voice.consentKey ?? 'chat', keepsAudio: voice.keepsAudio, silenceMs: voice.silenceMs, microphoneId: voice.microphoneId, disabled: disabled, speaking: speaking, onStopSpeaking: onStopSpeaking, onListen: () => {
66
66
  base.current = input.trim();
67
67
  onStopSpeaking?.();
68
68
  },
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * On, the microphone stays open. The words are written in the composer as
7
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
8
+ * turn's silence (`turnSilenceMs`, 500 ms unless said), what was said is
9
9
  * heard whole and **sent** — nothing waits for a click. The answer is said as
10
10
  * it is written (hands-free implies `output: always`), and listening goes
11
11
  * on through it: speech heard while the agent thinks or speaks is
@@ -24,6 +24,12 @@
24
24
  */
25
25
  import type { JSX } from 'react';
26
26
  import type { Hearing } from './hearing';
27
+ /**
28
+ * While the agent answers, how often the microphone is looked at for the
29
+ * person speaking over it, by the VAD alone (VO-52): looked at every
30
+ * `INTERIM_MS` with the words transcribed, barge-in waited up to 0.7 s.
31
+ */
32
+ export declare const BARGE_IN_POLL_MS = 150;
27
33
  import type { SpokenMetadata } from './types';
28
34
  export interface HandsFreeProps {
29
35
  hearing: Hearing;
@@ -31,9 +37,14 @@ export interface HandsFreeProps {
31
37
  language: string;
32
38
  /** Whose consent to the microphone is remembered: the application's id. */
33
39
  consentKey: string;
40
+ /**
41
+ * The days the application keeps what is said as sound (VO-63): asked in
42
+ * the first sentence, and each turn's recording sent with its words.
43
+ */
44
+ keepsAudio?: number;
34
45
  /** The microphone the person chose (VO-30); the browser's own when unsaid. */
35
46
  microphoneId?: string;
36
- /** The silence after speech that ends a turn, in ms; 700 unless said (VO-12). */
47
+ /** The silence after speech that ends a turn, in ms; 500 unless said (VO-12). */
37
48
  turnSilenceMs?: number;
38
49
  /** A turn, heard whole: sent at once. */
39
50
  send: (text: string, metadata: SpokenMetadata) => void;
@@ -57,5 +68,5 @@ export interface HandsFreeProps {
57
68
  onInterrupted?: () => void;
58
69
  disabled?: boolean;
59
70
  }
60
- export declare function HandsFree({ hearing, language, consentKey, microphoneId, turnSilenceMs, send, onInterim, running, speaking, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled, }: HandsFreeProps): JSX.Element;
71
+ export declare function HandsFree({ hearing, language, consentKey, keepsAudio, microphoneId, turnSilenceMs, send, onInterim, running, speaking, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled, }: HandsFreeProps): JSX.Element;
61
72
  export default HandsFree;
@@ -2,13 +2,24 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { Button, IconButton, Text, Tooltip } from '@primer/react';
4
4
  import { Box } from '@datalayer/primer-addons';
5
- import { openMicrophone } from './capture';
5
+ import { HEARING_RATE, openMicrophone } from './capture';
6
6
  import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
7
7
  import { HANDS_FREE_WORDS, IDLE_PAUSE_MS, TURN_SILENCE_MS, bargedIn, handsFreeState, turnEnded, } from './handsFree';
8
+ import { keptAudioSentence, soundOf } from './keptSound';
8
9
  import { INTERIM_MS } from './VoiceInput';
10
+ /**
11
+ * While the agent answers, how often the microphone is looked at for the
12
+ * person speaking over it, by the VAD alone (VO-52): looked at every
13
+ * `INTERIM_MS` with the words transcribed, barge-in waited up to 0.7 s.
14
+ */
15
+ export const BARGE_IN_POLL_MS = 150;
16
+ /** The last of the sound looked at for barge-in: 1.5 s, so each look is cheap. */
17
+ const BARGE_IN_WINDOW = Math.round(HEARING_RATE * 1.5);
18
+ /** The least speech that is barge-in, in seconds: a short word (VO-52). */
19
+ const BARGE_IN_SHORTEST = 0.12;
9
20
  /** Waits. */
10
21
  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, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled = false, }) {
22
+ export function HandsFree({ hearing, language, consentKey, keepsAudio, microphoneId, turnSilenceMs = TURN_SILENCE_MS, send, onInterim, running = false, speaking = false, playbackLevel, onStopSpeaking, onStopRun, onInterrupted, disabled = false, }) {
12
23
  const [on, setOn] = useState(false);
13
24
  const [paused, setPaused] = useState(false);
14
25
  const [asking, setAsking] = useState(false);
@@ -35,7 +46,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
35
46
  onStopRun,
36
47
  onInterrupted,
37
48
  };
38
- const key = consentKeyFor(consentKey, hearing.where);
49
+ const key = consentKeyFor(consentKey, hearing.where, keepsAudio);
39
50
  const turnOn = useCallback(() => {
40
51
  if (!consented(key)) {
41
52
  setAsking(true);
@@ -82,8 +93,18 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
82
93
  }
83
94
  heardWhile.current =
84
95
  stateRef.current === 'listening' ? 'listening' : 'answering';
96
+ // Once the person spoke in this turn, its end is looked for often,
97
+ // by the VAD alone, the words between (VO-52).
98
+ let spokeThisTurn = false;
99
+ let wordsAt = 0;
85
100
  while (alive) {
86
- await pause(INTERIM_MS);
101
+ const quick = (Boolean(hearing.spokeSoFar) &&
102
+ (stateRef.current === 'thinking' ||
103
+ stateRef.current === 'speaking')) ||
104
+ (Boolean(hearing.quietSoFar) &&
105
+ spokeThisTurn &&
106
+ stateRef.current === 'listening');
107
+ await pause(quick ? BARGE_IN_POLL_MS : INTERIM_MS);
87
108
  const open = capture.current;
88
109
  if (!alive || !open) {
89
110
  return;
@@ -102,11 +123,27 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
102
123
  }
103
124
  heardWhile.current = answering ? 'answering' : 'listening';
104
125
  quietSince = Date.now();
126
+ spokeThisTurn = false;
105
127
  continue;
106
128
  }
107
129
  let so;
108
130
  try {
109
- so = await hearing.hearSoFar(open.peek(), language);
131
+ if (answering && hearing.spokeSoFar) {
132
+ // Barge-in only: the VAD over the last moment, nothing transcribed.
133
+ const sound = open.peek();
134
+ const spoke = await hearing.spokeSoFar(sound.subarray(Math.max(0, sound.length - BARGE_IN_WINDOW)), BARGE_IN_SHORTEST);
135
+ so = { text: '', spoke, silentMs: 0 };
136
+ }
137
+ else if (spokeThisTurn &&
138
+ hearing.quietSoFar &&
139
+ Date.now() - wordsAt < INTERIM_MS) {
140
+ // The turn's end looked for: the VAD alone, no words.
141
+ so = await hearing.quietSoFar(open.peek());
142
+ }
143
+ else {
144
+ so = await hearing.hearSoFar(open.peek(), language);
145
+ wordsAt = Date.now();
146
+ }
110
147
  }
111
148
  catch {
112
149
  so = undefined;
@@ -119,6 +156,9 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
119
156
  }
120
157
  if (so.spoke) {
121
158
  quietSince = Date.now();
159
+ if (now === 'listening') {
160
+ spokeThisTurn = true;
161
+ }
122
162
  }
123
163
  const playing = latest.current.playbackLevel;
124
164
  const levels = playing
@@ -155,7 +195,10 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
155
195
  }
156
196
  latest.current.onInterim?.('');
157
197
  if (heard) {
158
- latest.current.send(heard.text, heard.metadata);
198
+ // Kept as sound too, as the person agreed (VO-63).
199
+ latest.current.send(heard.text, keepsAudio
200
+ ? { ...heard.metadata, sound: soundOf(audio) }
201
+ : heard.metadata);
159
202
  }
160
203
  try {
161
204
  capture.current = await openMicrophone(microphoneId);
@@ -165,6 +208,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
165
208
  }
166
209
  heardWhile.current = 'listening';
167
210
  quietSince = Date.now();
211
+ spokeThisTurn = false;
168
212
  continue;
169
213
  }
170
214
  if (now === 'listening' && Date.now() - quietSince >= IDLE_PAUSE_MS) {
@@ -179,7 +223,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
179
223
  capture.current = undefined;
180
224
  };
181
225
  // The loop reads the state through a ref: it is not restarted on it.
182
- }, [on, paused, hearing, language, microphoneId, turnSilenceMs]);
226
+ }, [on, paused, hearing, language, microphoneId, turnSilenceMs, keepsAudio]);
183
227
  // Esc pauses it.
184
228
  useEffect(() => {
185
229
  if (!on) {
@@ -215,7 +259,7 @@ export function HandsFree({ hearing, language, consentKey, microphoneId, turnSil
215
259
  latest.current.onInterim?.('');
216
260
  } }))] })) : 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'
217
261
  ? SERVER_CONSENT_SENTENCE
218
- : 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: () => {
262
+ : CONSENT_SENTENCE }), keepsAudio ? (_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, "data-voice-consent-kept": "", children: keptAudioSentence(keepsAudio) })) : null, _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: () => {
219
263
  consent(key);
220
264
  setAsking(false);
221
265
  setProblem(undefined);
@@ -48,10 +48,15 @@ export interface VoiceInputProps {
48
48
  onListen?: () => void;
49
49
  /** Whose consent is remembered: the application's id. */
50
50
  consentKey: string;
51
+ /**
52
+ * The days the application keeps what is said as sound (VO-63): asked in
53
+ * the first sentence, and each turn's recording sent with its words.
54
+ */
55
+ keepsAudio?: number;
51
56
  disabled?: boolean;
52
57
  /** The agent is speaking: offer to stop it. */
53
58
  speaking?: boolean;
54
59
  onStopSpeaking?: () => void;
55
60
  }
56
- export declare function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs, microphoneId, onListen, consentKey, disabled, speaking, onStopSpeaking, }: VoiceInputProps): JSX.Element;
61
+ export declare function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs, microphoneId, onListen, consentKey, keepsAudio, disabled, speaking, onStopSpeaking, }: VoiceInputProps): JSX.Element;
57
62
  export default VoiceInput;
@@ -6,6 +6,7 @@ import { MicrophoneIcon } from '@datalayer/icons-react';
6
6
  import { MuteIcon } from '@primer/octicons-react';
7
7
  import { MicrophoneRefused, openMicrophone } from './capture';
8
8
  import { CONSENT_SENTENCE, SERVER_CONSENT_SENTENCE, consent, consentKeyFor, consented, } from './consent';
9
+ import { keptAudioSentence, soundOf } from './keptSound';
9
10
  const WORDS = {
10
11
  idle: '',
11
12
  asking: '',
@@ -27,7 +28,7 @@ function prefersStill() {
27
28
  return (typeof window !== 'undefined' &&
28
29
  !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
29
30
  }
30
- export function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs = 1500, microphoneId, onListen, consentKey, disabled = false, speaking = false, onStopSpeaking, }) {
31
+ export function VoiceInput({ hearing, language, onTranscript, onInterim, silenceMs = 1500, microphoneId, onListen, consentKey, keepsAudio, disabled = false, speaking = false, onStopSpeaking, }) {
31
32
  const [state, setState] = useState('idle');
32
33
  const [said, setSaid] = useState('');
33
34
  const [problem, setProblem] = useState();
@@ -49,7 +50,7 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
49
50
  if (disabled || stateRef.current !== 'idle') {
50
51
  return;
51
52
  }
52
- if (!consented(consentKeyFor(consentKey, hearing.where))) {
53
+ if (!consented(consentKeyFor(consentKey, hearing.where, keepsAudio))) {
53
54
  setState('asking');
54
55
  return;
55
56
  }
@@ -75,7 +76,15 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
75
76
  ? error.message
76
77
  : 'The microphone could not be opened.');
77
78
  }
78
- }, [consentKey, disabled, hearing, language, microphoneId, onListen]);
79
+ }, [
80
+ consentKey,
81
+ keepsAudio,
82
+ disabled,
83
+ hearing,
84
+ language,
85
+ microphoneId,
86
+ onListen,
87
+ ]);
79
88
  const stop = useCallback(async () => {
80
89
  const open = capture.current;
81
90
  capture.current = undefined;
@@ -99,7 +108,13 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
99
108
  return;
100
109
  }
101
110
  setSaid(`Heard: ${transcript.text}`);
102
- onTranscript(transcript);
111
+ // Kept as sound too, as the person agreed (VO-63).
112
+ onTranscript(keepsAudio
113
+ ? {
114
+ ...transcript,
115
+ metadata: { ...transcript.metadata, sound: soundOf(audio) },
116
+ }
117
+ : transcript);
103
118
  }
104
119
  catch (error) {
105
120
  setState('idle');
@@ -287,8 +302,8 @@ export function VoiceInput({ hearing, language, onTranscript, onInterim, silence
287
302
  }
288
303
  } }) }), 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'
289
304
  ? SERVER_CONSENT_SENTENCE
290
- : 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: () => {
291
- consent(consentKeyFor(consentKey, hearing.where));
305
+ : CONSENT_SENTENCE }), keepsAudio ? (_jsx(Text, { as: "p", sx: { fontSize: 1, mb: 2 }, "data-voice-consent-kept": "", children: keptAudioSentence(keepsAudio) })) : null, _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: () => {
306
+ consent(consentKeyFor(consentKey, hearing.where, keepsAudio));
292
307
  // Asked, and said yes: listen now, without another press.
293
308
  stateRef.current = 'idle';
294
309
  setState('idle');
@@ -40,7 +40,7 @@ export interface ChatVoicePlace {
40
40
  * - **Speaking**: its answers said by Datalayer's speech service, on request
41
41
  * or always (VO-20, VO-21); refused here when its host says no service.
42
42
  */
43
- export declare function chatVoiceOf(spec: AppVoiceSpec | undefined, place: ChatVoicePlace): ChatVoice | undefined;
43
+ export declare function chatVoiceOf(spec: AppVoiceSpec | undefined, place: ChatVoicePlace, keepsAudio?: number): ChatVoice | undefined;
44
44
  /**
45
45
  * A chat's voice asked of the speech service as the chat itself speaks to
46
46
  * its runtime (VO-37, VO-19): the token its host gave, else the chat's own —