@datalayer/agent-runtimes 1.5.44 → 1.5.45

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.
@@ -1702,8 +1702,8 @@ export default function ChatView({ workspace }) {
1702
1702
  // opening stops it, and *Stop speaking* while it speaks.
1703
1703
  speaking: speaking, onStopSpeaking: () => controlsRef.current?.stopSpeaking?.(),
1704
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,
1705
+ // the person speaks over it, and the answer marked interrupted.
1706
+ running: busy, onStopRun: () => controlsRef.current?.stop?.(), onInterrupted: () => controlsRef.current?.markInterrupted(), onInterimChange: setVoiceInterim,
1707
1707
  // The menu offers when answers are heard where the
1708
1708
  // application lets them be (VO-21, VO-30).
1709
1709
  answers: Boolean(config?.voice?.output &&
@@ -1030,7 +1030,7 @@ export function ChatFloating({ endpoint, protocol: protocolProp, useStore: useSt
1030
1030
  ...poweredByProps,
1031
1031
  }, description: description, onStateUpdate: onStateUpdate, onNewChat: onNewChat, suggestions: suggestions, submitOnSuggestionClick: submitOnSuggestionClick, hideMessagesAfterToolUI: hideMessagesAfterToolUI, avatarConfig: {
1032
1032
  showAvatars: true,
1033
- }, placeholder: "Type a message...", backgroundColor: "canvas.subtle", frontendTools: frontendTools, showModelSelector: showModelSelector, availableModels: availableModels, showToolsMenu: showToolsMenu, showSkillsMenu: showSkillsMenu, showTokenUsage: showTokenUsage, showContextRing: showContextRing, runtimeId: runtimeId, historyEndpoint: historyEndpoint, historyAuthToken: historyAuthToken, pendingPrompt: pendingPrompt ?? suggestionPrompt, inspector: inspector, showInformation: showInformation, onInformationClick: onInformationClick, onToolCallStart: onToolCallStart, onToolCallComplete: onToolCallComplete, showToolApprovalBanner: showToolApprovalBanner, pendingApprovals: pendingApprovals, onApproveApproval: onApproveApproval, onRejectApproval: onRejectApproval, voice: voice, voiceSpeaking: speakerState.speaking, onStopSpeaking: speaker ? stopSpeaking : undefined, onReadAloud: speaker ? readAnswerAloud : undefined, ...panelProps, ...assistantChat, onLoadingChange: handleLoadingChange, onDisplayItemsChange: handleDisplayItemsChange, onSendReady: handleSendReady, children: conversationBalloon && currentOpen ? (
1033
+ }, placeholder: "Type a message...", backgroundColor: "canvas.subtle", frontendTools: frontendTools, showModelSelector: showModelSelector, availableModels: availableModels, showToolsMenu: showToolsMenu, showSkillsMenu: showSkillsMenu, showTokenUsage: showTokenUsage, showContextRing: showContextRing, runtimeId: runtimeId, historyEndpoint: historyEndpoint, historyAuthToken: historyAuthToken, pendingPrompt: pendingPrompt ?? suggestionPrompt, inspector: inspector, showInformation: showInformation, onInformationClick: onInformationClick, onToolCallStart: onToolCallStart, onToolCallComplete: onToolCallComplete, showToolApprovalBanner: showToolApprovalBanner, pendingApprovals: pendingApprovals, onApproveApproval: onApproveApproval, onRejectApproval: onRejectApproval, voice: voice, voiceSpeaking: speakerState.speaking, voiceSentence: speakerState.sentence, onStopSpeaking: speaker ? stopSpeaking : undefined, onReadAloud: speaker ? readAnswerAloud : undefined, ...panelProps, ...assistantChat, onLoadingChange: handleLoadingChange, onDisplayItemsChange: handleDisplayItemsChange, onSendReady: handleSendReady, children: conversationBalloon && currentOpen ? (
1034
1034
  // Current: the one thing said or done now, over the composer.
1035
1035
  _jsx(Box, { px: 3, pt: 3, pb: 2, overflow: "hidden", children: _jsx(CurrentBalloonBody, { text: toolSaid ? undefined : (currentWords ?? description), fullText: toolSaid ? undefined : currentWhole, tool: toolSaid, busy: atWork, speaking: answering && atWork, waiting: atWork && !answering }) })) : (children) }))] }));
1036
1036
  /*
@@ -52,11 +52,13 @@ import { EphemeralSurfaceControl } from '../EphemeralSurfaceControl';
52
52
  import { ChatMessageList, } from '../messages/ChatMessageList';
53
53
  import { InputPrompt } from '../prompt/InputPrompt';
54
54
  import { ComposerVoice, spokenOf } from '../../voice/ComposerVoice';
55
+ import { answerSpokenOver } from '../../voice/handsFree';
55
56
  import { ReadAloud, readsAloud } from '../../voice/ReadAloud';
56
57
  import { settledVoice } from '../../voice/chatVoice';
57
58
  import { useVoiceSettings } from '../../voice/useVoiceSettings';
58
59
  import { useVoiceSpeaker } from '../../voice/useVoiceSpeaker';
59
60
  import { useSpokenAnswers } from '../../voice/useSpokenAnswers';
61
+ import { saidMessageId, useSaidSentence } from '../../voice/captions';
60
62
  import { lineToSay } from '../../voice/sayAloud';
61
63
  import { isFloatingChatViewMode } from '../viewModes';
62
64
  import { ToolApprovalBanner, ToolApprovalDialog, } from '../tools';
@@ -424,7 +426,7 @@ function ChatBaseInner({ title, subtitle, showHeader = false, showTokenUsage = t
424
426
  // Mode selection
425
427
  useStore: useStoreMode = true, protocol: protocolRaw, onSendMessage, onSendReady, onLoadingChange, onSpeakingChange, onContextSnapshot, enableStreaming = false,
426
428
  // Extended props
427
- brandIcon, avatarConfig, headerButtons, showPoweredBy = false, poweredByProps, emptyState, renderHeader, showTurnFooter = true, notebookToolSurfacesId, footerContent, welcome, trailingContent, voice, voiceSpeaking = false, onStopSpeaking, onReadAloud, showInformation = false, onInformationClick, headerContent, children, borderRadius, backgroundColor, border, boxShadow, compact = false, placeholder, description = 'Start a conversation with the AI agent.', onStateUpdate, onNewChat, onClear, onMessagesChange, onItemsChange, onDisplayItemsChange, autoFocus = false, suggestions, submitOnSuggestionClick = true, hideMessagesAfterToolUI = false, focusTrigger, frontendTools: frontendToolsProp, enableEphemeralNotebook = false, initialEphemeralNotebookOpen = true, onEphemeralNotebookOpenChange, enableEphemeralDocument = false, initialEphemeralSurfaceMode, onEphemeralSurfaceModeChange, collapsed = false, onExpandFromCollapsed, ephemeralNotebookToolbar, ephemeralNotebookToolbarExtraItems, ephemeralDocumentToolbarExtraItems, ephemeralNotebookCollaborationProvider, ephemeralNotebookCollaborationDocumentId, ephemeralDocumentCollaboration, ephemeralRuntimeOverride,
429
+ brandIcon, avatarConfig, headerButtons, showPoweredBy = false, poweredByProps, emptyState, renderHeader, showTurnFooter = true, notebookToolSurfacesId, footerContent, welcome, trailingContent, voice, voiceSpeaking = false, voiceSentence, onStopSpeaking, onReadAloud, showInformation = false, onInformationClick, headerContent, children, borderRadius, backgroundColor, border, boxShadow, compact = false, placeholder, description = 'Start a conversation with the AI agent.', onStateUpdate, onNewChat, onClear, onMessagesChange, onItemsChange, onDisplayItemsChange, autoFocus = false, suggestions, submitOnSuggestionClick = true, hideMessagesAfterToolUI = false, focusTrigger, frontendTools: frontendToolsProp, enableEphemeralNotebook = false, initialEphemeralNotebookOpen = true, onEphemeralNotebookOpenChange, enableEphemeralDocument = false, initialEphemeralSurfaceMode, onEphemeralSurfaceModeChange, collapsed = false, onExpandFromCollapsed, ephemeralNotebookToolbar, ephemeralNotebookToolbarExtraItems, ephemeralDocumentToolbarExtraItems, ephemeralNotebookCollaborationProvider, ephemeralNotebookCollaborationDocumentId, ephemeralDocumentCollaboration, ephemeralRuntimeOverride,
428
430
  // Tool invocation hooks
429
431
  onToolCallStart, onToolCallComplete, inspector: inspectorGiven,
430
432
  // Identity/Authorization props
@@ -2974,6 +2976,10 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
2974
2976
  const ownVoice = useVoiceSpeaker(settled, !hostSpeaks);
2975
2977
  useSpokenAnswers(displayItems, isLoading, !hostSpeaks && settled?.output === 'always', ownVoice.speaker);
2976
2978
  const speakingNow = voiceSpeaking || ownVoice.state.speaking;
2979
+ // The sentence being said, marked in the conversation (VO-65).
2980
+ const sentenceSaid = hostSpeaks ? voiceSentence : ownVoice.state.sentence;
2981
+ const saidConversationRef = useRef(null);
2982
+ useSaidSentence(saidConversationRef, sentenceSaid, displayItems);
2977
2983
  /*
2978
2984
  * A line the agent wants heard, not written (VO-35): a caption while the
2979
2985
  * run goes on, said by the speaker where answers are said by themselves.
@@ -3001,6 +3007,14 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3001
3007
  }, [isLoading]);
3002
3008
  // Answers spoken over, hands-free (VOICE.md VO-13): marked *interrupted*.
3003
3009
  const [interruptedIds, setInterruptedIds] = useState(() => new Set());
3010
+ // The conversation as shown, read when the person speaks over an answer.
3011
+ const shownItemsRef = useRef([]);
3012
+ const markInterrupted = useCallback(() => {
3013
+ const spokenOver = answerSpokenOver(shownItemsRef.current.filter((item) => !isToolCallMessage(item)));
3014
+ if (spokenOver) {
3015
+ setInterruptedIds(ids => new Set([...ids, spokenOver]));
3016
+ }
3017
+ }, []);
3004
3018
  const stopSpeaking = onStopSpeaking ?? (ownVoice.speaker ? ownVoice.stop : undefined);
3005
3019
  const readAnswerAloud = onReadAloud ?? (ownVoice.speaker ? ownVoice.read : undefined);
3006
3020
  const handleSend = useCallback(async (messageOverride,
@@ -3370,6 +3384,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3370
3384
  thread: () => threadIdRef.current,
3371
3385
  // Its speaker, for a host whose composer holds the microphone (VO-10).
3372
3386
  stopSpeaking,
3387
+ // The answer spoken over, marked for a host's own composer (VO-13).
3388
+ markInterrupted,
3373
3389
  });
3374
3390
  return () => {
3375
3391
  onSendReady(null);
@@ -3382,6 +3398,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3382
3398
  handleStop,
3383
3399
  handleNewChat,
3384
3400
  stopSpeaking,
3401
+ markInterrupted,
3385
3402
  ]);
3386
3403
  // Streaming state, for a host that draws the prompt.
3387
3404
  useEffect(() => {
@@ -3748,6 +3765,8 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3748
3765
  ...displayItems,
3749
3766
  ]
3750
3767
  : displayItems;
3768
+ shownItemsRef.current = welcomedItems;
3769
+ const saidId = saidMessageId(welcomedItems, sentenceSaid);
3751
3770
  /*
3752
3771
  * *Read aloud* under each answer the agent has finished (VOICE.md VO-21),
3753
3772
  * when the host reads answers aloud on request.
@@ -3763,7 +3782,7 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3763
3782
  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] }));
3764
3783
  }
3765
3784
  : undefined;
3766
- const messagesContent = children ? (children) : (_jsxs(Box, { display: "flex", flexDirection: "column", minHeight: 0,
3785
+ const messagesContent = children ? (children) : (_jsxs(Box, { ref: saidConversationRef, display: "flex", flexDirection: "column", minHeight: 0,
3767
3786
  /*
3768
3787
  A reading column, not a full-bleed sheet.
3769
3788
 
@@ -3776,16 +3795,12 @@ showToolApprovalBanner = true, pendingApprovals: pendingApprovalsProp, onApprove
3776
3795
  // Tool calls are drawn by the page's Reactor plugins; a named
3777
3796
  // notebook's cell surfaces stand in for the default row of its tools,
3778
3797
  // so every chat with a headless notebook gets the same transcript.
3779
- 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] }));
3798
+ notebookToolSurfacesId: notebookToolSurfacesId, approvalConfig: approvalConfig, messagesEndRef: messagesEndRef, onRespond: handleRespond, showTurnFooters: showTurnFooter, renderMessageFooter: readAloudFooter, saidMessageId: saidId, 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] }));
3780
3799
  // The microphone, in the composer's footer (VO-10, VO-11).
3781
3800
  const voiceControl = settled && settled.input !== 'off' ? (_jsx(ComposerVoice, { voice: settled, input: input, setInput: setInput, disabled: disabled || launching, speaking: speakingNow, onStopSpeaking: stopSpeaking,
3782
3801
  // Hands-free (VO-12, VO-13): the run in progress, stopped when the
3783
3802
  // person speaks over it, and the answer marked interrupted.
3784
- running: isLoading, onStopRun: handleStop, onInterrupted: () => {
3785
- if (writingId) {
3786
- setInterruptedIds(ids => new Set([...ids, writingId]));
3787
- }
3788
- }, onInterimChange: setVoiceInterim,
3803
+ running: isLoading, onStopRun: handleStop, onInterrupted: markInterrupted, onInterimChange: setVoiceInterim,
3789
3804
  // The menu offers when answers are heard where the application
3790
3805
  // lets them be (VO-21, VO-30).
3791
3806
  answers: Boolean(voice?.output && voice.output !== 'off' && voice.speechUrl), onSpoken: metadata => {
@@ -78,6 +78,12 @@ export interface ChatMessageListProps {
78
78
  * `note`, which has no footer of its own.
79
79
  */
80
80
  renderMessageFooter?: (message: ChatMessage) => ReactNode;
81
+ /**
82
+ * The answer a sentence is being said from (VOICE.md VO-65): its row is
83
+ * marked `aria-current` and `data-voice-saying`, with a bar beside it —
84
+ * the sentence itself is underlined by `useSaidSentence`.
85
+ */
86
+ saidMessageId?: string;
81
87
  /**
82
88
  * The MCP servers the agent has, with the tools each serves: a tool call is
83
89
  * drawn with the marks of the server it belongs to. Skills, frontend tools
@@ -109,5 +115,5 @@ export interface SubagentChatPanelProps {
109
115
  * approval decisions are sent over the shared monitoring socket.
110
116
  */
111
117
  export declare function SubagentChatPanel({ toolCallId, subagentName: subagentNameHint, height, avatarConfig, }: SubagentChatPanelProps): React.ReactElement | null;
112
- export declare function ChatMessageList({ displayItems, actOn, upload, isLoading, isStreaming, showLoadingIndicator, hideMessagesAfterToolUI, avatarConfig, padding, notebookToolSurfacesId, emptyContent, messagesEndRef, onRespond, approvalConfig, showTurnFooters, agentUsage, onRemoveItems, renderMessageFooter, mcpServers, density, }: ChatMessageListProps): import("react").JSX.Element;
118
+ export declare function ChatMessageList({ displayItems, actOn, upload, isLoading, isStreaming, showLoadingIndicator, hideMessagesAfterToolUI, avatarConfig, padding, notebookToolSurfacesId, emptyContent, messagesEndRef, onRespond, approvalConfig, showTurnFooters, agentUsage, onRemoveItems, renderMessageFooter, saidMessageId, mcpServers, density, }: ChatMessageListProps): import("react").JSX.Element;
113
119
  export {};
@@ -366,7 +366,7 @@ function DefaultToolCallRenderer({ item, onRespond, mcpServers, density, }) {
366
366
  // ---------------------------------------------------------------------------
367
367
  // Component
368
368
  // ---------------------------------------------------------------------------
369
- export function ChatMessageList({ displayItems, actOn, upload, isLoading, isStreaming, showLoadingIndicator, hideMessagesAfterToolUI, avatarConfig, padding, notebookToolSurfacesId, emptyContent, messagesEndRef, onRespond, approvalConfig, showTurnFooters = false, agentUsage, onRemoveItems, renderMessageFooter, mcpServers, density = 'comfortable', }) {
369
+ export function ChatMessageList({ displayItems, actOn, upload, isLoading, isStreaming, showLoadingIndicator, hideMessagesAfterToolUI, avatarConfig, padding, notebookToolSurfacesId, emptyContent, messagesEndRef, onRespond, approvalConfig, showTurnFooters = false, agentUsage, onRemoveItems, renderMessageFooter, saidMessageId, mcpServers, density = 'comfortable', }) {
370
370
  /*
371
371
  * Who draws a tool call: the Reactor plugins of the page, when it has a
372
372
  * platform. Each plugin binds its own tools (`ToolBindings`) and renders its
@@ -541,7 +541,13 @@ export function ChatMessageList({ displayItems, actOn, upload, isLoading, isStre
541
541
  }
542
542
  // The host's footer, asked once: a row only when it draws something.
543
543
  const messageFooter = renderMessageFooter?.(message);
544
- return (_jsx(Box, { "data-chat-message": message.role, display: "flex", flexDirection: "column", alignItems: isUser ? 'flex-end' : 'flex-start', px: padding, py: compact ? '2px' : 1, children: _jsxs(Box, { display: "flex", gap: 2, flexDirection: isUser ? 'row-reverse' : 'row', alignItems: "flex-start",
544
+ // The answer being said (VO-65): marked by a bar and for assistive tech.
545
+ const said = saidMessageId !== undefined && message.id === saidMessageId;
546
+ return (_jsx(Box, { "data-chat-message": message.role, "data-voice-saying": said ? '' : undefined, "aria-current": said ? 'true' : undefined, sx: said
547
+ ? {
548
+ boxShadow: 'inset 3px 0 0 var(--borderColor-accent-emphasis, #0969da)',
549
+ }
550
+ : undefined, display: "flex", flexDirection: "column", alignItems: isUser ? 'flex-end' : 'flex-start', px: padding, py: compact ? '2px' : 1, children: _jsxs(Box, { display: "flex", gap: 2, flexDirection: isUser ? 'row-reverse' : 'row', alignItems: "flex-start",
545
551
  /*
546
552
  Full width, always.
547
553
 
@@ -627,6 +627,17 @@ export class VercelAIAdapter extends BaseProtocolAdapter {
627
627
  });
628
628
  }
629
629
  }
630
+ else if (typeof event.type === 'string' &&
631
+ event.type.startsWith('data-')) {
632
+ // What the server says besides the conversation's text, as
633
+ // AG-UI's custom events say it: `data-voice.say`, a line the
634
+ // agent says aloud, and `data-voice.settings` (VOICE.md VO-36).
635
+ this.emit({
636
+ type: 'activity',
637
+ activity: { type: event.type, data: event.data },
638
+ timestamp: new Date(),
639
+ });
640
+ }
630
641
  }
631
642
  catch (parseError) {
632
643
  console.error('[VercelAIAdapter] Failed to parse SSE data:', parseError);
@@ -100,6 +100,68 @@ export const SPEECH_MODEL_CATALOGUE = {
100
100
  },
101
101
  ],
102
102
  },
103
+ 'moonshine-base-en-server': {
104
+ id: 'moonshine-base-en-server',
105
+ version: '0.0.1',
106
+ name: 'Moonshine Base (English, server)',
107
+ description: "Speech to text on Datalayer's servers, in English: Useful Sensors' Moonshine Base (61M parameters), the same 8-bit ONNX export the browser runs, run by onnxruntime on the CPU with a greedy decoder. Its encoder is as long as the speech, with no 30-second window: measured beside Whisper small for short spoken turns (decision 18).",
108
+ provider: 'usefulsensors',
109
+ task: 'stt',
110
+ engine: 'onnxruntime',
111
+ dtype: 'q8',
112
+ languages: ['en'],
113
+ where: ['server'],
114
+ streaming: false,
115
+ licence: {
116
+ weights: 'MIT',
117
+ code: 'MIT',
118
+ },
119
+ attribution: '',
120
+ upstream: 'https://huggingface.co/UsefulSensors/moonshine-base',
121
+ revision: 'b1e9b6aae3c3c7298f10c3798393fdf38e8fbbad',
122
+ files: [
123
+ {
124
+ path: 'config.json',
125
+ sha256: 'fab7241d1e9fc6c2370c4c6dfb5da79bb54d67ed9ab6b507ac51d29d2abe01d1',
126
+ size: 922,
127
+ },
128
+ {
129
+ path: 'generation_config.json',
130
+ sha256: 'f9b3f711b57be7def2e50a8942f64f36ee0a55fad5b84ff93a687b6c5bcc1d44',
131
+ size: 147,
132
+ },
133
+ {
134
+ path: 'preprocessor_config.json',
135
+ sha256: 'fa43a7017ef85cd1d0fba0d9aae77c8adb16990ae6f11115631f41ec5d8aa679',
136
+ size: 128,
137
+ },
138
+ {
139
+ path: 'special_tokens_map.json',
140
+ sha256: 'ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356',
141
+ size: 3,
142
+ },
143
+ {
144
+ path: 'tokenizer.json',
145
+ sha256: '7b913404bdd039af4756783218af4440bc07fb7d6d8258d677e34f95b3ec416f',
146
+ size: 3761754,
147
+ },
148
+ {
149
+ path: 'tokenizer_config.json',
150
+ sha256: 'edaee394565d428ea98a663ae7209cdcfeefc5585c42d7a570ff7c986df2cd15',
151
+ size: 135735,
152
+ },
153
+ {
154
+ path: 'onnx/encoder_model_quantized.onnx',
155
+ sha256: '1dd9ab0a7f987113d30affcba5a068d11c8f90fa0223caa3e491ade431ad9751',
156
+ size: 20513063,
157
+ },
158
+ {
159
+ path: 'onnx/decoder_model_merged_quantized.onnx',
160
+ sha256: 'cc9f3cd6698a369c6008b41aa60aa3fb3322e7f03c9bdf19d8e6b7200afca4f3',
161
+ size: 42498870,
162
+ },
163
+ ],
164
+ },
103
165
  'moonshine-base-en': {
104
166
  id: 'moonshine-base-en',
105
167
  version: '0.0.1',
@@ -689,6 +689,11 @@ export interface ChatBaseProps {
689
689
  * Absent while its answers are not heard.
690
690
  */
691
691
  stopSpeaking?: () => void;
692
+ /**
693
+ * Marks the answer the person spoke over *interrupted* (VOICE.md
694
+ * VO-13), for a host whose composer holds the microphone.
695
+ */
696
+ markInterrupted: () => void;
692
697
  } | null) => void;
693
698
  /**
694
699
  * Reports whether the agent's voice is speaking, for a host that draws the
@@ -1013,6 +1018,11 @@ export interface ChatBaseProps {
1013
1018
  voice?: ChatVoice;
1014
1019
  /** The agent is speaking: the composer offers *Stop speaking* (`Esc`). */
1015
1020
  voiceSpeaking?: boolean;
1021
+ /**
1022
+ * The sentence the host's speaker is saying, marked in the conversation
1023
+ * (VOICE.md VO-65); a chat that speaks for itself marks its own.
1024
+ */
1025
+ voiceSentence?: string;
1016
1026
  /** Stops the agent's voice: the composer's *Stop speaking*, and the microphone opening (VO-13). */
1017
1027
  onStopSpeaking?: () => void;
1018
1028
  /**
@@ -2,7 +2,7 @@ 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
4
  import { HandsFree } from './HandsFree';
5
- import { ServerHearing, hearingPlace } from './serverHearing';
5
+ import { AutoHearing, ServerHearing, hearingPlace } from './serverHearing';
6
6
  import { VoiceInput } from './VoiceInput';
7
7
  import { VoiceMenu } from './VoiceMenu';
8
8
  /** Whether something rides on a run as a spoken message's metadata (VO-27). */
@@ -24,13 +24,20 @@ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled
24
24
  return undefined;
25
25
  }
26
26
  const device = new DeviceHearing(voice.modelsUrl, voice.engines);
27
- return place === 'server' && voice.speechUrl
27
+ const server = voice.speechUrl
28
28
  ? new ServerHearing(device, {
29
29
  speechUrl: voice.speechUrl,
30
30
  token: voice.token,
31
31
  detect: voice.detect,
32
32
  })
33
- : device;
33
+ : undefined;
34
+ // `where: auto` (§5): the device when it hears the language and its model
35
+ // loads, the server otherwise — and back to the device when the server
36
+ // refuses (a visitor's day spent).
37
+ if (server && !voice.detect && (voice.where ?? 'auto') === 'auto') {
38
+ return new AutoHearing(device, server, voice.language);
39
+ }
40
+ return place === 'server' && server ? server : device;
34
41
  }, [
35
42
  voice.input,
36
43
  voice.modelsUrl,
@@ -39,6 +46,8 @@ export function ComposerVoice({ voice, input, setInput, onSpoken, send, disabled
39
46
  voice.speechUrl,
40
47
  voice.token,
41
48
  voice.detect,
49
+ voice.where,
50
+ voice.language,
42
51
  ]);
43
52
  // What the box held when the microphone opened: the words heard go after it.
44
53
  const base = useRef('');
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Captions always (VOICE.md VO-65): the sentence being said is marked in the
3
+ * open conversation, not by colour alone. The answer that holds it is marked
4
+ * `aria-current` and `data-voice-saying` with a bar beside it; the sentence
5
+ * itself is underlined in place through the CSS Custom Highlight API, which
6
+ * draws over the text without touching the DOM React owns. A browser without
7
+ * that API keeps the answer's mark.
8
+ *
9
+ * @module voice/captions
10
+ */
11
+ import { type RefObject } from 'react';
12
+ /** The name the sentence's highlight is registered under. */
13
+ export declare const SAID_HIGHLIGHT = "voice-said";
14
+ /**
15
+ * The id of the answer a sentence is being said from: the newest answer of
16
+ * the agent whose words hold it, or none.
17
+ */
18
+ export declare function saidMessageId(items: readonly unknown[], sentence: string | undefined): string | undefined;
19
+ /**
20
+ * Where a sentence is in what an element shows, as a `Range` over its text
21
+ * — across the bold, the links and the line breaks it is drawn with — or
22
+ * none when the element does not show it as said (a code block said as *a
23
+ * code block*).
24
+ */
25
+ export declare function sentenceRange(root: Node, sentence: string): Range | undefined;
26
+ /**
27
+ * Underlines `sentence` in the answer marked `data-voice-saying` under
28
+ * `root`, for as long as it is said; the mark goes when it ends. Drawn
29
+ * again whenever `version` changes (the answer still being written).
30
+ */
31
+ export declare function useSaidSentence(root: RefObject<HTMLElement | null>, sentence: string | undefined, version: unknown): void;
@@ -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
+ * Captions always (VOICE.md VO-65): the sentence being said is marked in the
7
+ * open conversation, not by colour alone. The answer that holds it is marked
8
+ * `aria-current` and `data-voice-saying` with a bar beside it; the sentence
9
+ * itself is underlined in place through the CSS Custom Highlight API, which
10
+ * draws over the text without touching the DOM React owns. A browser without
11
+ * that API keeps the answer's mark.
12
+ *
13
+ * @module voice/captions
14
+ */
15
+ import { useEffect } from 'react';
16
+ import { plainWords } from './sentences';
17
+ import { answerText } from './useSpokenAnswers';
18
+ /** The name the sentence's highlight is registered under. */
19
+ export const SAID_HIGHLIGHT = 'voice-said';
20
+ /** What the sentence being said looks like: underlined, thick, offset. */
21
+ const SAID_STYLE = `::highlight(${SAID_HIGHLIGHT}) {
22
+ text-decoration: underline;
23
+ text-decoration-thickness: 2px;
24
+ text-underline-offset: 3px;
25
+ background-color: var(--bgColor-accent-muted, rgba(84, 174, 255, 0.2));
26
+ }`;
27
+ /** Words compared as words: spaces collapsed, case kept. */
28
+ function squeezed(text) {
29
+ return text.replace(/\s+/g, ' ').trim();
30
+ }
31
+ /**
32
+ * The id of the answer a sentence is being said from: the newest answer of
33
+ * the agent whose words hold it, or none.
34
+ */
35
+ export function saidMessageId(items, sentence) {
36
+ const said = squeezed(sentence ?? '');
37
+ if (!said) {
38
+ return undefined;
39
+ }
40
+ for (let index = items.length - 1; index >= 0; index -= 1) {
41
+ const text = answerText(items[index]);
42
+ if (text !== undefined && squeezed(plainWords(text)).includes(said)) {
43
+ const id = items[index].id;
44
+ return typeof id === 'string' ? id : undefined;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Where a sentence is in what an element shows, as a `Range` over its text
51
+ * — across the bold, the links and the line breaks it is drawn with — or
52
+ * none when the element does not show it as said (a code block said as *a
53
+ * code block*).
54
+ */
55
+ export function sentenceRange(root, sentence) {
56
+ const said = squeezed(sentence);
57
+ if (!said || !root.ownerDocument) {
58
+ return undefined;
59
+ }
60
+ const document = root.ownerDocument;
61
+ const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
62
+ // The text, spaces collapsed, and where each of its characters came from.
63
+ let text = '';
64
+ const from = [];
65
+ let space = true;
66
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
67
+ const value = node.nodeValue ?? '';
68
+ for (let offset = 0; offset < value.length; offset += 1) {
69
+ const char = value[offset];
70
+ if (/\s/.test(char)) {
71
+ if (space) {
72
+ continue;
73
+ }
74
+ space = true;
75
+ text += ' ';
76
+ }
77
+ else {
78
+ space = false;
79
+ text += char;
80
+ }
81
+ from.push({ node, offset });
82
+ }
83
+ }
84
+ const start = text.indexOf(said);
85
+ if (start < 0) {
86
+ return undefined;
87
+ }
88
+ const first = from[start];
89
+ const last = from[start + said.length - 1];
90
+ const range = document.createRange();
91
+ range.setStart(first.node, first.offset);
92
+ range.setEnd(last.node, last.offset + 1);
93
+ return range;
94
+ }
95
+ function registry() {
96
+ const css = globalThis.CSS;
97
+ return css?.highlights;
98
+ }
99
+ function highlightOf(range) {
100
+ const Highlight = globalThis.Highlight;
101
+ return Highlight ? new Highlight(range) : undefined;
102
+ }
103
+ let styled = false;
104
+ function ensureStyle(document) {
105
+ if (styled || document.getElementById('datalayer-voice-said')) {
106
+ styled = true;
107
+ return;
108
+ }
109
+ const style = document.createElement('style');
110
+ style.id = 'datalayer-voice-said';
111
+ style.textContent = SAID_STYLE;
112
+ document.head.appendChild(style);
113
+ styled = true;
114
+ }
115
+ /**
116
+ * Underlines `sentence` in the answer marked `data-voice-saying` under
117
+ * `root`, for as long as it is said; the mark goes when it ends. Drawn
118
+ * again whenever `version` changes (the answer still being written).
119
+ */
120
+ export function useSaidSentence(root, sentence, version) {
121
+ useEffect(() => {
122
+ const highlights = registry();
123
+ const element = root.current?.querySelector('[data-voice-saying]');
124
+ if (!highlights || !element || !sentence) {
125
+ return;
126
+ }
127
+ const underline = () => {
128
+ const range = sentenceRange(element, sentence);
129
+ const highlight = range ? highlightOf(range) : undefined;
130
+ if (!highlight) {
131
+ return false;
132
+ }
133
+ ensureStyle(element.ownerDocument);
134
+ highlights.set(SAID_HIGHLIGHT, highlight);
135
+ return true;
136
+ };
137
+ // The answer's Markdown may be drawn after this effect: wait for it.
138
+ let watcher;
139
+ if (!underline() && typeof MutationObserver !== 'undefined') {
140
+ watcher = new MutationObserver(() => {
141
+ if (underline()) {
142
+ watcher?.disconnect();
143
+ }
144
+ });
145
+ watcher.observe(element, {
146
+ childList: true,
147
+ subtree: true,
148
+ characterData: true,
149
+ });
150
+ }
151
+ return () => {
152
+ watcher?.disconnect();
153
+ highlights.delete(SAID_HIGHLIGHT);
154
+ };
155
+ }, [root, sentence, version]);
156
+ }
@@ -56,3 +56,15 @@ export declare function turnEnded(heard: HeardSoFarLike, turnSilenceMs?: number)
56
56
  * answer's echo — the browser's echo cancellation keeps that out.
57
57
  */
58
58
  export declare function bargedIn(state: HandsFreeState, heard: HeardSoFarLike): boolean;
59
+ /** A message as the conversation holds it: enough to find the answer spoken over. */
60
+ export interface ConversationItemLike {
61
+ id: string;
62
+ role?: string;
63
+ }
64
+ /**
65
+ * The answer the person spoke over (VO-13): the agent's last message after
66
+ * the person's last one — still being written, or written and being said.
67
+ * `undefined` while the agent has not answered the turn yet: there is
68
+ * nothing to mark.
69
+ */
70
+ export declare function answerSpokenOver(items: readonly ConversationItemLike[]): string | undefined;
@@ -46,3 +46,21 @@ export function turnEnded(heard, turnSilenceMs = TURN_SILENCE_MS) {
46
46
  export function bargedIn(state, heard) {
47
47
  return (state === 'speaking' || state === 'thinking') && heard.spoke;
48
48
  }
49
+ /**
50
+ * The answer the person spoke over (VO-13): the agent's last message after
51
+ * the person's last one — still being written, or written and being said.
52
+ * `undefined` while the agent has not answered the turn yet: there is
53
+ * nothing to mark.
54
+ */
55
+ export function answerSpokenOver(items) {
56
+ for (let i = items.length - 1; i >= 0; i -= 1) {
57
+ const role = items[i].role;
58
+ if (role === 'user') {
59
+ return undefined;
60
+ }
61
+ if (role === 'assistant') {
62
+ return items[i].id;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
@@ -12,6 +12,9 @@ export declare const STREAM_FINAL_MS = 60000;
12
12
  export declare const SERVER_HEARING_MODEL = "whisper-large-v3-turbo";
13
13
  /** Where a chat's voice hears: in the browser, or on Datalayer's servers. */
14
14
  export type HearingPlace = 'device' | 'server';
15
+ /** The speech service refused a press, in its own sentence (a limit, a visitor's day). */
16
+ export declare class ServerRefused extends Error {
17
+ }
15
18
  /** What decides where a chat's voice hears. */
16
19
  export interface HearingAsked {
17
20
  language: string;
@@ -88,3 +91,29 @@ export declare class ServerHearing implements Hearing {
88
91
  hearSoFar(audio: Float32Array, language?: string): Promise<HeardSoFar>;
89
92
  hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
90
93
  }
94
+ /** Said when the device's model did not load and the server will hear instead. */
95
+ export declare const MOVED_TO_SERVER = "This device could not load its model: what you say will be heard on Datalayer's servers instead. Press the microphone again to agree.";
96
+ /**
97
+ * `where: auto` (§5): each press heard on the device when a model there
98
+ * hears the language and loads, on the speech service otherwise.
99
+ *
100
+ * The device's model failing to load moves the hearing to the server, and
101
+ * the press is refused in a sentence so that the server's own consent is
102
+ * asked at the next one. The server refusing a press — a limit, a visitor's
103
+ * day spent — moves it back to the device when a model there hears the
104
+ * language, and the press is heard there: the sound then stays in the page.
105
+ */
106
+ export declare class AutoHearing implements Hearing {
107
+ private readonly device;
108
+ private readonly server;
109
+ private readonly hearsHere;
110
+ private place;
111
+ /** The device's model did not load: it is not tried again on this page. */
112
+ private deviceBroken;
113
+ constructor(device: Hearing, server: Hearing, language: string, hearsHere?: (language: string) => boolean);
114
+ get where(): HearingPlace;
115
+ private get current();
116
+ ready(language: string, progress?: HearingProgress): Promise<void>;
117
+ hearSoFar(audio: Float32Array, language: string): Promise<HeardSoFar>;
118
+ hear(audio: Float32Array, language: string): Promise<Transcript | undefined>;
119
+ }