@alia.onl/sdk 13.1.0 → 15.2.1

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 (55) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +83 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +57 -107
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +28 -25
  8. package/src/components/AliaChatSheet.tsx +30 -8
  9. package/src/components/AliaComposer.tsx +382 -0
  10. package/src/components/ComposerDropZone.tsx +235 -0
  11. package/src/components/voice/VoiceControls.tsx +28 -4
  12. package/src/components/voice/VoiceSession.tsx +23 -7
  13. package/src/hooks/useAliaActivity.ts +31 -0
  14. package/src/hooks/useAliaChat.ts +14 -3
  15. package/src/hooks/useAmbientWave.ts +3 -3
  16. package/src/hooks/useAttachmentIntake.ts +401 -0
  17. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  18. package/src/hooks/useDictation.ts +295 -0
  19. package/src/hooks/useFocusWhenPresented.ts +41 -0
  20. package/src/hooks/usePendingQuestions.ts +19 -0
  21. package/src/hooks/useTTS.ts +24 -63
  22. package/src/hooks/useVoiceRoom.ts +148 -671
  23. package/src/index.ts +19 -3
  24. package/src/lib/activity-socket.ts +102 -0
  25. package/src/lib/activity-store.ts +53 -0
  26. package/src/lib/api-url.ts +2 -0
  27. package/src/lib/attachment-intake.ts +154 -0
  28. package/src/lib/attachment-tiles.ts +74 -0
  29. package/src/lib/audio-level.ts +2 -2
  30. package/src/lib/dictation-recorder-types.ts +37 -0
  31. package/src/lib/dictation-recorder.native.ts +90 -0
  32. package/src/lib/dictation-recorder.ts +131 -0
  33. package/src/lib/dictation.ts +208 -0
  34. package/src/lib/labels.tsx +50 -1
  35. package/src/lib/pcm.ts +57 -0
  36. package/src/lib/speech-language.ts +1 -1
  37. package/src/lib/speech-messages.ts +43 -65
  38. package/src/lib/speech-synthesis.ts +104 -9
  39. package/src/lib/turn-state.ts +15 -0
  40. package/src/lib/voice-audio-engine-types.ts +59 -0
  41. package/src/lib/voice-audio-engine.native.ts +232 -0
  42. package/src/lib/voice-audio-engine.ts +350 -0
  43. package/src/lib/voice-call.ts +539 -0
  44. package/src/lib/voice-levels.ts +7 -3
  45. package/src/lib/voice-protocol.ts +309 -0
  46. package/src/tools.ts +1 -0
  47. package/src/types.ts +63 -4
  48. package/src/voice.ts +31 -10
  49. package/tsconfig.json +2 -0
  50. package/src/hooks/useSpeechToText.ts +0 -268
  51. package/src/lib/speech-recognition-types.ts +0 -108
  52. package/src/lib/speech-recognition.native.ts +0 -237
  53. package/src/lib/speech-recognition.ts +0 -308
  54. package/src/lib/voice-text.ts +0 -198
  55. package/src/lib/voice-turn.ts +0 -89
@@ -1,308 +0,0 @@
1
- /**
2
- * On-device speech recognition for the web: the browser's Web Speech API.
3
- *
4
- * Metro resolves `speech-recognition.native.ts` on iOS and Android, so this
5
- * file is what a web bundle — and every test — gets. See
6
- * `speech-recognition-types.ts` for the contract both implement.
7
- *
8
- * ## Why a microphone stream is opened beside the recognizer
9
- *
10
- * The Web Speech API reports words, not sound: there is no level event, and a
11
- * refused or missing microphone surfaces as a bare `not-allowed` or
12
- * `audio-capture` with nothing to tell "blocked" from "busy". Opening the
13
- * microphone first with `getUserMedia` answers both. It is where the browser
14
- * asks for permission (once — the recognizer shares the origin's grant), its
15
- * DOMException names say exactly what is wrong, and an `AnalyserNode` on the
16
- * stream is the honest level the dictation trace and the voice field move to.
17
- * The stream is never recorded or sent anywhere.
18
- *
19
- * ## Support
20
- *
21
- * Chromium browsers and Safari implement it (Chrome as `webkitSpeechRecognition`
22
- * and through a Google speech service; Safari through Apple's). Firefox does
23
- * not, and `isSpeechRecognitionAvailable()` says so before anything asks for a
24
- * microphone.
25
- */
26
-
27
- import { levelFromDbfs } from './audio-level';
28
- import type {
29
- SpeechRecognitionFailure,
30
- SpeechRecognitionFailureCode,
31
- SpeechRecognitionHandlers,
32
- SpeechRecognitionOptions,
33
- SpeechRecognitionSession,
34
- SpeechRecognizerChoice,
35
- } from './speech-recognition-types';
36
-
37
- // ── The slice of the Web Speech API this uses ──
38
- // Declared here rather than taken from lib.dom: TypeScript's DOM library does
39
- // not ship the prefixed constructor, and the unprefixed one is not everywhere.
40
-
41
- interface WebSpeechAlternative {
42
- readonly transcript: string;
43
- }
44
- interface WebSpeechResult {
45
- readonly isFinal: boolean;
46
- readonly length: number;
47
- readonly [index: number]: WebSpeechAlternative;
48
- }
49
- interface WebSpeechResultList {
50
- readonly length: number;
51
- readonly [index: number]: WebSpeechResult;
52
- }
53
- interface WebSpeechResultEvent {
54
- readonly results: WebSpeechResultList;
55
- }
56
- interface WebSpeechErrorEvent {
57
- readonly error: string;
58
- readonly message?: string;
59
- }
60
-
61
- interface WebSpeechRecognition {
62
- lang: string;
63
- continuous: boolean;
64
- interimResults: boolean;
65
- maxAlternatives: number;
66
- onresult: ((event: WebSpeechResultEvent) => void) | null;
67
- onerror: ((event: WebSpeechErrorEvent) => void) | null;
68
- onend: (() => void) | null;
69
- start(): void;
70
- stop(): void;
71
- abort(): void;
72
- }
73
-
74
- type WebSpeechRecognitionConstructor = new () => WebSpeechRecognition;
75
-
76
- function recognitionConstructor(): WebSpeechRecognitionConstructor | null {
77
- const scope = globalThis as unknown as {
78
- SpeechRecognition?: WebSpeechRecognitionConstructor;
79
- webkitSpeechRecognition?: WebSpeechRecognitionConstructor;
80
- };
81
- return scope.SpeechRecognition ?? scope.webkitSpeechRecognition ?? null;
82
- }
83
-
84
- /** Whether this browser can recognize speech at all. Firefox cannot. */
85
- export function isSpeechRecognitionAvailable(): boolean {
86
- return recognitionConstructor() !== null;
87
- }
88
-
89
- /**
90
- * Ask for the microphone before a session needs it.
91
- *
92
- * The browser asks once per origin; `startSpeechRecognition` opens the stream
93
- * itself, so this is only for callers that want the answer up front (voice mode
94
- * does, so a refusal ends the call before it starts rather than on the first
95
- * word).
96
- */
97
- export async function requestSpeechRecognitionPermission(): Promise<SpeechRecognitionFailure | null> {
98
- if (!isSpeechRecognitionAvailable()) return { code: 'unsupported' };
99
- const media = globalThis.navigator?.mediaDevices;
100
- if (media?.getUserMedia === undefined) return null;
101
- try {
102
- const stream = await media.getUserMedia({ audio: true });
103
- for (const track of stream.getTracks()) track.stop();
104
- return null;
105
- } catch (error: unknown) {
106
- return mediaFailure(error);
107
- }
108
- }
109
-
110
- function domErrorName(error: unknown): string {
111
- // A DOMException is not always `instanceof Error`, so read `.name` off the object.
112
- if (
113
- typeof error === 'object' &&
114
- error !== null &&
115
- 'name' in error &&
116
- typeof error.name === 'string'
117
- ) {
118
- return error.name;
119
- }
120
- return '';
121
- }
122
-
123
- function mediaFailure(error: unknown): SpeechRecognitionFailure {
124
- const name = domErrorName(error);
125
- switch (name) {
126
- case 'NotAllowedError':
127
- case 'PermissionDeniedError':
128
- case 'SecurityError':
129
- return { code: 'not-allowed', detail: name };
130
- case 'NotFoundError':
131
- case 'DevicesNotFoundError':
132
- case 'OverconstrainedError':
133
- case 'NotReadableError':
134
- case 'TrackStartError':
135
- return { code: 'audio-capture', detail: name };
136
- default:
137
- return { code: 'other', detail: name || 'getUserMedia failed' };
138
- }
139
- }
140
-
141
- function recognitionFailure(event: WebSpeechErrorEvent): SpeechRecognitionFailure {
142
- const map: Record<string, SpeechRecognitionFailureCode> = {
143
- 'not-allowed': 'not-allowed',
144
- 'service-not-allowed': 'not-allowed',
145
- 'audio-capture': 'audio-capture',
146
- network: 'network',
147
- 'language-not-supported': 'language-not-supported',
148
- 'no-speech': 'no-speech',
149
- aborted: 'aborted',
150
- };
151
- return { code: map[event.error] ?? 'other', detail: event.error };
152
- }
153
-
154
- /** Everything heard so far, as one string: Chrome segments carry their own leading spaces. */
155
- function transcriptOf(results: WebSpeechResultList): { transcript: string; isFinal: boolean } {
156
- let transcript = '';
157
- let isFinal = results.length > 0;
158
- for (let index = 0; index < results.length; index += 1) {
159
- const result = results[index];
160
- if (result === undefined) continue;
161
- transcript += result[0]?.transcript ?? '';
162
- if (!result.isFinal) isFinal = false;
163
- }
164
- return { transcript: transcript.replace(/\s+/g, ' ').trim(), isFinal };
165
- }
166
-
167
- const LEVEL_INTERVAL_MS = 100;
168
-
169
- /** An analyser on the microphone, polled on a clock. Returns its teardown. */
170
- function meterStream(stream: MediaStream, onLevel: (level: number) => void): () => void {
171
- const AudioContextCtor = (globalThis as unknown as { AudioContext?: typeof AudioContext })
172
- .AudioContext;
173
- if (AudioContextCtor === undefined) return () => undefined;
174
- let context: AudioContext;
175
- try {
176
- context = new AudioContextCtor();
177
- } catch {
178
- return () => undefined;
179
- }
180
- if (context.state === 'suspended') context.resume().catch(() => undefined);
181
- const analyser = context.createAnalyser();
182
- analyser.fftSize = 512;
183
- context.createMediaStreamSource(stream).connect(analyser);
184
- const buffer = new Float32Array(analyser.fftSize);
185
- const timer = setInterval(() => {
186
- analyser.getFloatTimeDomainData(buffer);
187
- let sum = 0;
188
- for (let index = 0; index < buffer.length; index += 1) sum += buffer[index] * buffer[index];
189
- const rms = Math.sqrt(sum / buffer.length);
190
- onLevel(rms > 0 ? levelFromDbfs(20 * Math.log10(rms)) : 0);
191
- }, LEVEL_INTERVAL_MS);
192
- return () => {
193
- clearInterval(timer);
194
- context.close().catch(() => undefined);
195
- };
196
- }
197
-
198
- /**
199
- * Listen until stopped.
200
- *
201
- * Continuous with interim results: the caller decides when an utterance is
202
- * over (voice mode on a silence timer, dictation on the stop button), because
203
- * the browser's own end-of-speech detection cuts people off mid-thought.
204
- */
205
- /**
206
- * The browser has one recognizer and no way to ask it which languages it
207
- * knows: the language goes as given (see the native file for Android's).
208
- */
209
- export async function chooseSpeechRecognizer(lang: string): Promise<SpeechRecognizerChoice> {
210
- return { lang, silent: false };
211
- }
212
-
213
- export function startSpeechRecognition(
214
- options: SpeechRecognitionOptions,
215
- handlers: SpeechRecognitionHandlers,
216
- ): SpeechRecognitionSession {
217
- let recognition: WebSpeechRecognition | null = null;
218
- let stream: MediaStream | null = null;
219
- let stopMeter: (() => void) | null = null;
220
- let ended = false;
221
- let stopRequested: 'stop' | 'abort' | null = null;
222
-
223
- const release = (): void => {
224
- stopMeter?.();
225
- stopMeter = null;
226
- if (stream !== null) for (const track of stream.getTracks()) track.stop();
227
- stream = null;
228
- };
229
-
230
- const finish = (): void => {
231
- if (ended) return;
232
- ended = true;
233
- release();
234
- handlers.onLevel?.(0);
235
- handlers.onEnd();
236
- };
237
-
238
- const fail = (failure: SpeechRecognitionFailure): void => {
239
- if (ended) return;
240
- handlers.onError(failure);
241
- finish();
242
- };
243
-
244
- void (async () => {
245
- const Recognition = recognitionConstructor();
246
- if (Recognition === null) {
247
- fail({ code: 'unsupported' });
248
- return;
249
- }
250
-
251
- const media = globalThis.navigator?.mediaDevices;
252
- if (media?.getUserMedia !== undefined) {
253
- try {
254
- stream = await media.getUserMedia({
255
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
256
- });
257
- } catch (error: unknown) {
258
- fail(mediaFailure(error));
259
- return;
260
- }
261
- if (stopRequested !== null) {
262
- finish();
263
- return;
264
- }
265
- if (handlers.onLevel !== undefined) stopMeter = meterStream(stream, handlers.onLevel);
266
- }
267
-
268
- const instance = new Recognition();
269
- recognition = instance;
270
- instance.lang = options.lang;
271
- instance.continuous = true;
272
- instance.interimResults = true;
273
- instance.maxAlternatives = 1;
274
- instance.onresult = (event) => {
275
- if (ended) return;
276
- handlers.onResult(transcriptOf(event.results));
277
- };
278
- instance.onerror = (event) => {
279
- if (ended) return;
280
- const failure = recognitionFailure(event);
281
- if (failure.code === 'aborted') return;
282
- handlers.onError(failure);
283
- };
284
- instance.onend = finish;
285
- try {
286
- instance.start();
287
- } catch (error: unknown) {
288
- fail({ code: 'other', detail: domErrorName(error) || 'start failed' });
289
- return;
290
- }
291
- if (stopRequested === 'stop') instance.stop();
292
- if (stopRequested === 'abort') instance.abort();
293
- })();
294
-
295
- return {
296
- stop() {
297
- if (ended) return;
298
- stopRequested = 'stop';
299
- recognition?.stop();
300
- },
301
- abort() {
302
- if (ended) return;
303
- stopRequested = 'abort';
304
- if (recognition === null) return;
305
- recognition.abort();
306
- },
307
- };
308
- }
@@ -1,198 +0,0 @@
1
- /**
2
- * Text in and out of a voice call, as pure functions: what of an answer is
3
- * ready to be spoken, how it is made speakable, and whether what the
4
- * microphone just heard was the person or the call's own voice.
5
- *
6
- * Imports nothing, so every rule here is asserted directly
7
- * (`tests/lib/voice-text.test.ts`) rather than through a hook.
8
- */
9
-
10
- // ============== TITLE TAGS ==============
11
-
12
- const TAG = String.raw`ALIA_TITLE|TITLE|TÍTULO|TITRE|TITOLO|TITEL|ЗАГОЛОВОК`;
13
-
14
- const TITLE_STRIP_RE = new RegExp(String.raw`\[(${TAG})\].*?\[\/\1\]|<(${TAG})>.*?<\/\2>`, 'gi');
15
-
16
- const TITLE_PARTIAL_RE = new RegExp(
17
- String.raw`\[(${TAG})\].*?(\[\/\1\])?$|<(${TAG})>.*?(<\/\3>)?$`,
18
- 'si',
19
- );
20
-
21
- /**
22
- * A settled answer without the conversation-title tag a model may append.
23
- *
24
- * `trim: false` keeps surrounding whitespace, which the speech chunker needs:
25
- * a trailing space is how it knows a period ended a sentence.
26
- */
27
- export function stripTitleTags(content: string, { trim = true }: { trim?: boolean } = {}): string {
28
- const stripped = content.replace(TITLE_STRIP_RE, '');
29
- return trim ? stripped.trim() : stripped;
30
- }
31
-
32
- /** The same, for an answer still streaming: an opened tag hides the rest. */
33
- export function stripTitleTagsPartial(
34
- content: string,
35
- { trim = true }: { trim?: boolean } = {},
36
- ): string {
37
- const stripped = content.replace(TITLE_STRIP_RE, '').replace(TITLE_PARTIAL_RE, '');
38
- return trim ? stripped.trim() : stripped;
39
- }
40
-
41
- // ============== SPEAKABLE TEXT ==============
42
-
43
- /**
44
- * Markdown and links read aloud are noise ("asterisk asterisk", a URL spelled
45
- * out). The voice prompt asks for none, but a model does not always listen, and
46
- * an agent's own prompt may not ask at all — so the formatting is taken off
47
- * here, at the last moment, and the transcript on screen keeps it.
48
- */
49
- export function toSpeakableText(text: string): string {
50
- return text
51
- .replace(/```[\s\S]*?(```|$)/g, ' ')
52
- .replace(/`([^`]*)`/g, '$1')
53
- .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
54
- .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
55
- .replace(/https?:\/\/\S+/g, ' ')
56
- .replace(/^\s{0,3}#{1,6}\s+/gm, '')
57
- .replace(/^\s*[-*+•]\s+/gm, '')
58
- .replace(/^\s*>\s?/gm, '')
59
- .replace(/\|/g, ' ')
60
- .replace(/(\*\*|__|\*|_|~~)(?=\S)([\s\S]*?\S)\1/g, '$2')
61
- .replace(/\s+/g, ' ')
62
- .trim();
63
- }
64
-
65
- /**
66
- * A sentence ends at terminal punctuation followed by whitespace, or at a line
67
- * break. Requiring the whitespace is what keeps `3.5` and a half-streamed
68
- * `e.g` whole: until the next character arrives, the period might not be one.
69
- */
70
- const SENTENCE_END = /[.!?…。!?]+["'”’)\]]*\s+|\n+/g;
71
-
72
- /** Past this, a run with no sentence end is cut at a pause anyway. */
73
- const MAX_RUN_CHARS = 280;
74
- /** After the first, chunks gather sentences until this long: fewer, fuller requests. */
75
- const MIN_CHUNK_CHARS = 80;
76
-
77
- export interface SpeechChunks {
78
- /** Ready to synthesize, in order, already speakable. */
79
- readonly chunks: string[];
80
- /** Not yet ready: the caller keeps it and passes it again with more text. */
81
- readonly rest: string;
82
- }
83
-
84
- /**
85
- * Split what has streamed so far into what can be spoken now and what must
86
- * wait.
87
- *
88
- * The FIRST chunk of an answer is a single sentence, however short: time to
89
- * first sound is the latency a person feels. Later chunks gather sentences up
90
- * to `MIN_CHUNK_CHARS`, because each chunk is a synthesis request and playback
91
- * of the one before covers the wait for the next. `final` flushes everything.
92
- */
93
- export function takeSpeechChunks(
94
- pending: string,
95
- { final, isFirst }: { final: boolean; isFirst: boolean },
96
- ): SpeechChunks {
97
- const sentences: string[] = [];
98
- let cursor = 0;
99
- for (const match of pending.matchAll(SENTENCE_END)) {
100
- const end = (match.index ?? 0) + match[0].length;
101
- sentences.push(pending.slice(cursor, end));
102
- cursor = end;
103
- }
104
- let rest = pending.slice(cursor);
105
-
106
- if (final) {
107
- if (rest.trim() !== '') sentences.push(rest);
108
- rest = '';
109
- } else if (rest.length > MAX_RUN_CHARS) {
110
- const window = rest.slice(0, MAX_RUN_CHARS);
111
- const pause = Math.max(
112
- window.lastIndexOf(', '),
113
- window.lastIndexOf('; '),
114
- window.lastIndexOf(': '),
115
- );
116
- const cut = pause > 0 ? pause + 2 : Math.max(window.lastIndexOf(' '), 1);
117
- sentences.push(rest.slice(0, cut));
118
- rest = rest.slice(cut);
119
- }
120
-
121
- const chunks: string[] = [];
122
- let gathering = '';
123
- for (const sentence of sentences) {
124
- gathering += sentence;
125
- const threshold = chunks.length === 0 && isFirst ? 1 : MIN_CHUNK_CHARS;
126
- if (gathering.trim().length >= threshold) {
127
- chunks.push(gathering);
128
- gathering = '';
129
- }
130
- }
131
- if (gathering !== '') {
132
- if (final) chunks.push(gathering);
133
- else rest = gathering + rest;
134
- }
135
-
136
- return {
137
- chunks: chunks.map(toSpeakableText).filter((chunk) => chunk !== ''),
138
- rest,
139
- };
140
- }
141
-
142
- // ============== BARGE-IN ==============
143
-
144
- function words(text: string): string[] {
145
- return text
146
- .toLowerCase()
147
- .replace(/[^\p{L}\p{N}\s']/gu, ' ')
148
- .split(/\s+/)
149
- .filter((word) => word !== '');
150
- }
151
-
152
- export function wordCount(text: string): number {
153
- return words(text).length;
154
- }
155
-
156
- /** How many of the most recent words decide whether the person is talking. */
157
- const INTERRUPTION_WINDOW = 6;
158
-
159
- /**
160
- * Whether what the microphone heard is the person talking over the answer,
161
- * rather than the answer coming back through it.
162
- *
163
- * Voice mode keeps listening while it speaks so a person can interrupt, and on
164
- * a laptop speaker the recognizer can hear the answer. Where the platform
165
- * cancels echo that never reaches here; where it does not, the words give it
166
- * away. `spoken` is everything the call has said this turn, and the transcript
167
- * of one session keeps the echo it already heard, so only the latest words
168
- * are judged, from the first one the call did not say: at least `minWords` of
169
- * them — and at least half — must be new. A single misheard word of echo is
170
- * not the person; two new words in a row are.
171
- */
172
- export function isInterruption(heard: string, spoken: string, minWords: number): boolean {
173
- const said = new Set(words(spoken));
174
- const tail = words(heard).slice(-INTERRUPTION_WINDOW);
175
- // Judge from the first word the call did not say: echo before it is echo.
176
- const first = tail.findIndex((word) => !said.has(word));
177
- if (first < 0) return false;
178
- const candidate = tail.slice(first);
179
- const novel = candidate.filter((word) => !said.has(word)).length;
180
- return novel >= minWords && novel * 2 >= candidate.length;
181
- }
182
-
183
- /**
184
- * The person's words without the echo the same session heard before them:
185
- * leading words the call itself had just said are dropped.
186
- */
187
- export function dropEchoPrefix(heard: string, spoken: string): string {
188
- const said = new Set(words(spoken));
189
- if (said.size === 0) return heard.trim();
190
- const tokens = heard.trim().split(/\s+/);
191
- let start = 0;
192
- while (start < tokens.length) {
193
- const normalized = words(tokens[start] ?? '');
194
- if (normalized.length === 0 || normalized.every((word) => said.has(word))) start += 1;
195
- else break;
196
- }
197
- return tokens.slice(start).join(' ');
198
- }
@@ -1,89 +0,0 @@
1
- /**
2
- * How a voice call gets its answers: through the ordinary chat path.
3
- *
4
- * A spoken turn is a chat turn. There is no voice session endpoint and no
5
- * realtime model behind this — the person's words, recognized on the device,
6
- * go to Alia's chat handler exactly as typed words do (conversation, memory,
7
- * agents, tools and all), and the answer comes back as the same SSE stream.
8
- * The only thing that marks it as spoken is `responseMode: 'voice'`, which asks
9
- * for an answer meant to be heard.
10
- *
11
- * A consumer that already owns a chat — the Alia app, whose conversation screen
12
- * persists every turn and shows it in the thread — passes its own sender, so
13
- * the call's turns ARE that conversation's turns. `createAliaVoiceTurnSender`
14
- * is the default for everyone else: the same request `useAliaChat` makes.
15
- */
16
-
17
- import { streamAliaChat, type AuthenticatedResponseClient } from './chat-transport';
18
- import { resolveModelId } from './catalogue';
19
-
20
- export interface VoiceTurnMessage {
21
- readonly role: 'user' | 'assistant';
22
- readonly content: string;
23
- }
24
-
25
- export interface VoiceTurn {
26
- /** What the person said, as recognized. */
27
- readonly text: string;
28
- /** Earlier turns of this call, oldest first, not including `text`. */
29
- readonly history: readonly VoiceTurnMessage[];
30
- /** Aborted when the person interrupts or the call ends. */
31
- readonly signal: AbortSignal;
32
- /** Report the answer as it streams — the WHOLE answer so far, each time. */
33
- readonly onText: (answerSoFar: string) => void;
34
- }
35
-
36
- /**
37
- * Send one spoken turn and stream its answer through `onText`.
38
- *
39
- * Resolves when the answer is complete; rejects when the turn could not be
40
- * sent at all. An answer that is empty is not a rejection — the voice loop
41
- * reports it and keeps listening.
42
- */
43
- export type VoiceTurnSender = (turn: VoiceTurn) => Promise<void>;
44
-
45
- interface LinkedClientFactory {
46
- createLinkedClient(config: { baseURL: string }): {
47
- client: AuthenticatedResponseClient;
48
- dispose(): void;
49
- };
50
- }
51
-
52
- export interface AliaVoiceTurnSenderOptions {
53
- readonly oxyServices: LinkedClientFactory;
54
- readonly apiUrl: string;
55
- /** `publisher/model`, checked against `GET /catalogue` like `useAliaChat`'s; omitted, the server's default. */
56
- readonly model?: string;
57
- readonly agentId?: string;
58
- }
59
-
60
- /** The default sender: `POST /v1/chat/completions` as `useAliaChat` sends it, marked as voice. */
61
- export function createAliaVoiceTurnSender(options: AliaVoiceTurnSenderOptions): VoiceTurnSender {
62
- const { oxyServices, apiUrl, model, agentId } = options;
63
- return async ({ text, history, signal, onText }) => {
64
- const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
65
- try {
66
- const effectiveModel = await resolveModelId(apiUrl, model);
67
- signal.throwIfAborted();
68
- let answer = '';
69
- await streamAliaChat(
70
- linked.client,
71
- {
72
- url: '/v1/chat/completions',
73
- model: effectiveModel,
74
- messages: [...history, { role: 'user', content: text }],
75
- responseMode: 'voice',
76
- ...(agentId === undefined ? {} : { agentId }),
77
- },
78
- signal,
79
- (event) => {
80
- if (event.kind !== 'content' && event.kind !== 'agent_answer') return;
81
- answer += event.content;
82
- onText(answer);
83
- },
84
- );
85
- } finally {
86
- linked.dispose();
87
- }
88
- };
89
- }