@alia.onl/sdk 10.1.0 → 11.0.0

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 (64) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -0
  3. package/package.json +5 -7
  4. package/src/components/AliaAvatar.tsx +66 -0
  5. package/src/components/AliaChatContent.tsx +235 -112
  6. package/src/components/AliaChatHeader.tsx +118 -0
  7. package/src/components/AliaChatMessageList.tsx +171 -260
  8. package/src/components/AliaChatScreen.tsx +23 -24
  9. package/src/components/AliaChatSheet.tsx +70 -316
  10. package/src/components/AliaWelcomeMessage.tsx +47 -50
  11. package/src/components/AmbientField.tsx +577 -0
  12. package/src/components/IdentityMark.tsx +10 -2
  13. package/src/components/Markdown.tsx +116 -40
  14. package/src/components/PlanPreviewCard.tsx +71 -55
  15. package/src/components/ThinkingBlock.tsx +47 -0
  16. package/src/components/VoiceModeIcon.tsx +10 -0
  17. package/src/components/voice/VoiceControls.tsx +22 -73
  18. package/src/components/voice/VoiceSession.tsx +2 -1
  19. package/src/hooks/useAliaChat.ts +22 -15
  20. package/src/hooks/useAmbientWave.ts +35 -5
  21. package/src/hooks/useSoundEffects.ts +14 -11
  22. package/src/hooks/useSpeechToText.ts +9 -2
  23. package/src/hooks/useTTS.ts +163 -156
  24. package/src/hooks/useVoiceRoom.ts +228 -170
  25. package/src/index.ts +18 -6
  26. package/src/lib/catalogue.ts +17 -4
  27. package/src/lib/chat-stream.ts +25 -7
  28. package/src/lib/chat-transport.ts +2 -1
  29. package/src/lib/citations.ts +63 -0
  30. package/src/lib/content-utils.ts +3 -2
  31. package/src/lib/image-data-url.ts +15 -0
  32. package/src/lib/keyboard.tsx +1 -1
  33. package/src/lib/labels.tsx +81 -0
  34. package/src/lib/speech-recognition.native.ts +15 -4
  35. package/src/lib/speech-recognition.ts +27 -7
  36. package/src/lib/task-labels.ts +67 -0
  37. package/src/lib/tool-call-status.ts +28 -0
  38. package/src/lib/tool-registry.ts +14 -14
  39. package/src/lib/voice-text.ts +12 -6
  40. package/src/lib/voice-turn.ts +4 -1
  41. package/src/lib/work-log-copy.ts +61 -0
  42. package/src/lib/work-log.ts +378 -0
  43. package/src/tools.ts +11 -0
  44. package/src/types.ts +5 -2
  45. package/src/voice.ts +12 -4
  46. package/src/components/Reasoning.tsx +0 -152
  47. package/src/components/ResearchProgressCard.tsx +0 -159
  48. package/src/components/ThinkingIndicator.tsx +0 -98
  49. package/src/components/ui/button.tsx +0 -104
  50. package/src/components/ui/chat-text-input.tsx +0 -185
  51. package/src/components/ui/dropdown-menu.tsx +0 -2
  52. package/src/components/ui/dropdown-menu.web.tsx +0 -383
  53. package/src/components/ui/prompt-input/actions.tsx +0 -28
  54. package/src/components/ui/prompt-input/add-menu.tsx +0 -83
  55. package/src/components/ui/prompt-input/attachments.tsx +0 -181
  56. package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
  57. package/src/components/ui/prompt-input/context.tsx +0 -71
  58. package/src/components/ui/prompt-input/mic-button.tsx +0 -53
  59. package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
  60. package/src/components/ui/prompt-input/submit-button.tsx +0 -50
  61. package/src/components/ui/prompt-input/textarea.tsx +0 -80
  62. package/src/components/ui/text.tsx +0 -29
  63. package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
  64. package/src/components/voice/VoiceOverlay.tsx +0 -75
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { useOxy } from '@oxy.so/services/ui/client';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
- import { getTextFromContent } from '../lib/content-utils';
4
+ import { getTextFromContent, type MessageContent } from '../lib/content-utils';
5
5
  import { resolveModelId } from '../lib/catalogue';
6
6
  import { streamAliaChat } from '../lib/chat-transport';
7
7
  import type { AliaChatStreamEvent } from '../lib/chat-stream';
@@ -45,10 +45,15 @@ export interface UseAliaChatOptions {
45
45
  accessToken?: string;
46
46
  }
47
47
 
48
+ export interface SendOptions {
49
+ images?: ReadonlyArray<string>;
50
+ }
51
+
48
52
  export interface UseAliaChatReturn {
49
53
  messages: ChatMessage[];
50
54
  setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
51
- send: (text: string) => void;
55
+ /** `images` are `data:` or `https:` URLs sent with the turn as `image_url` parts. */
56
+ send: (text: string, options?: SendOptions) => void;
52
57
  isStreaming: boolean;
53
58
  stop: () => void;
54
59
  clear: () => void;
@@ -112,12 +117,7 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
112
117
  * handler during its recorded compatibility window.
113
118
  */
114
119
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
115
- const {
116
- apiUrl = API_URL,
117
- model,
118
- clientContext,
119
- accessToken: accessTokenProp,
120
- } = options;
120
+ const { apiUrl = API_URL, model, clientContext, accessToken: accessTokenProp } = options;
121
121
  const requestUrl = '/v1/chat/completions';
122
122
 
123
123
  const { oxyServices } = useOxy();
@@ -231,9 +231,10 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
231
231
  }, [cancelPendingTimer]);
232
232
 
233
233
  const send = useCallback(
234
- async (text: string) => {
234
+ async (text: string, options?: SendOptions) => {
235
235
  const trimmed = text.trim();
236
- if (!trimmed) return;
236
+ const images = options?.images ?? [];
237
+ if (!trimmed && images.length === 0) return;
237
238
 
238
239
  const activeToken = oxyServices.session.accessToken;
239
240
  if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
@@ -253,10 +254,17 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
253
254
  flushPendingUpdates(previousPending);
254
255
  }
255
256
 
257
+ const userContent: MessageContent =
258
+ images.length > 0
259
+ ? [
260
+ ...(trimmed ? [{ type: 'text', text: trimmed }] : []),
261
+ ...images.map((url) => ({ type: 'image_url', image_url: { url } })),
262
+ ]
263
+ : trimmed;
256
264
  const userMessage: ChatMessage = {
257
265
  id: generateId(),
258
266
  role: 'user',
259
- content: trimmed,
267
+ content: userContent,
260
268
  createdAt: Date.now(),
261
269
  };
262
270
  const assistantMessage: ChatMessage = {
@@ -281,7 +289,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
281
289
  setError(null);
282
290
  setIsStreaming(true);
283
291
 
284
- const apiMessages: Array<{ role: string; content: string }> = [];
292
+ const apiMessages: Array<{ role: string; content: MessageContent }> = [];
285
293
  if (clientContext) apiMessages.push({ role: 'system', content: clientContext });
286
294
  for (const message of history) {
287
295
  if (message.role === 'system') continue;
@@ -295,7 +303,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
295
303
  }
296
304
  }
297
305
  }
298
- apiMessages.push({ role: 'user', content: trimmed });
306
+ apiMessages.push({ role: 'user', content: userContent });
299
307
 
300
308
  const pending: PendingUpdates = {
301
309
  assistantId: assistantMessage.id,
@@ -351,8 +359,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
351
359
  updateAssistant(pending.assistantId, (last) => ({
352
360
  researchProgress: {
353
361
  ...event.progress,
354
- subQuestions:
355
- event.progress.subQuestions ?? last.researchProgress?.subQuestions,
362
+ subQuestions: event.progress.subQuestions ?? last.researchProgress?.subQuestions,
356
363
  },
357
364
  }));
358
365
  return;
@@ -60,7 +60,12 @@ const ACTIVE_INTENSITY = 0.35;
60
60
  * reanimated-web; the combiner is a pure-read `useDerivedValue`. Effect-free — no
61
61
  * React `useEffect`.
62
62
  */
63
- export function useAmbientWave({ voice, isTTSPlaying, ttsWaveAmplitude, isGenerating }: UseAmbientWaveOptions): UseAmbientWaveResult {
63
+ export function useAmbientWave({
64
+ voice,
65
+ isTTSPlaying,
66
+ ttsWaveAmplitude,
67
+ isGenerating,
68
+ }: UseAmbientWaveOptions): UseAmbientWaveResult {
64
69
  // ── Idle breath: subtle 0.04↔0.09 swell (~7s), started once on mount ──
65
70
  const idleBreath = useSharedValue(0.04);
66
71
  useAnimatedReaction(
@@ -131,14 +136,26 @@ export function useAmbientWave({ voice, isTTSPlaying, ttsWaveAmplitude, isGenera
131
136
  // ── Combined amplitude — pure reads only (ticks on web) ──
132
137
  const voiceAmplitude = voice?.waveAmplitude;
133
138
  const waveAmplitude = useDerivedValue(
134
- () => Math.max(idleBreath.value, voiceAmplitude ? voiceAmplitude.value : 0, ttsWaveAmplitude.value, stt.value, thinkingAmp.value),
139
+ () =>
140
+ Math.max(
141
+ idleBreath.value,
142
+ voiceAmplitude ? voiceAmplitude.value : 0,
143
+ ttsWaveAmplitude.value,
144
+ stt.value,
145
+ thinkingAmp.value,
146
+ ),
135
147
  [voiceAmplitude, ttsWaveAmplitude],
136
148
  );
137
149
 
138
150
  // ── Mode → palette state + overlay intensity (plain derived) ──
139
151
  return useMemo<UseAmbientWaveResult>(() => {
140
152
  if (voice?.isActive && voice.isConnected) {
141
- return { waveAmplitude, agentState: voice.agentState, intensity: ACTIVE_INTENSITY, mode: 'voice' };
153
+ return {
154
+ waveAmplitude,
155
+ agentState: voice.agentState,
156
+ intensity: ACTIVE_INTENSITY,
157
+ mode: 'voice',
158
+ };
142
159
  }
143
160
  if (isTTSPlaying) {
144
161
  return { waveAmplitude, agentState: 'speaking', intensity: ACTIVE_INTENSITY, mode: 'tts' };
@@ -147,8 +164,21 @@ export function useAmbientWave({ voice, isTTSPlaying, ttsWaveAmplitude, isGenera
147
164
  return { waveAmplitude, agentState: 'listening', intensity: ACTIVE_INTENSITY, mode: 'stt' };
148
165
  }
149
166
  if (isGenerating) {
150
- return { waveAmplitude, agentState: 'thinking', intensity: THINKING_INTENSITY, mode: 'thinking' };
167
+ return {
168
+ waveAmplitude,
169
+ agentState: 'thinking',
170
+ intensity: THINKING_INTENSITY,
171
+ mode: 'thinking',
172
+ };
151
173
  }
152
174
  return { waveAmplitude, agentState: 'idle', intensity: IDLE_INTENSITY, mode: 'idle' };
153
- }, [waveAmplitude, voice?.isActive, voice?.isConnected, voice?.agentState, isTTSPlaying, sttIsRecording, isGenerating]);
175
+ }, [
176
+ waveAmplitude,
177
+ voice?.isActive,
178
+ voice?.isConnected,
179
+ voice?.agentState,
180
+ isTTSPlaying,
181
+ sttIsRecording,
182
+ isGenerating,
183
+ ]);
154
184
  }
@@ -70,18 +70,21 @@ export function useSoundEffects(enabled: boolean = false, sounds: SoundSources =
70
70
  };
71
71
  }, [enabled, sounds]);
72
72
 
73
- const play = useCallback((sound: SoundName) => {
74
- if (!enabled) return;
75
- const player = playersRef.current.get(sound);
76
- if (player) {
77
- try {
78
- player.seekTo(0);
79
- player.play();
80
- } catch {
81
- // Playback failed — ignore
73
+ const play = useCallback(
74
+ (sound: SoundName) => {
75
+ if (!enabled) return;
76
+ const player = playersRef.current.get(sound);
77
+ if (player) {
78
+ try {
79
+ player.seekTo(0);
80
+ player.play();
81
+ } catch {
82
+ // Playback failed — ignore
83
+ }
82
84
  }
83
- }
84
- }, [enabled]);
85
+ },
86
+ [enabled],
87
+ );
85
88
 
86
89
  return { play };
87
90
  }
@@ -8,7 +8,11 @@ import {
8
8
  } from '../lib/speech-recognition';
9
9
  import type { SpeechRecognitionSession } from '../lib/speech-recognition-types';
10
10
  import type { SpeechRecognizerChoice } from '../lib/speech-recognition-types';
11
- import { speechFailureCode, VOICE_ERROR_MESSAGES, type VoiceErrorCode } from '../lib/speech-messages';
11
+ import {
12
+ speechFailureCode,
13
+ VOICE_ERROR_MESSAGES,
14
+ type VoiceErrorCode,
15
+ } from '../lib/speech-messages';
12
16
  import { defaultSpeechLanguage } from '../lib/speech-language';
13
17
 
14
18
  // ============== OPTIONS ==============
@@ -81,7 +85,10 @@ export function useSpeechToText(options: UseSTTOptions = {}) {
81
85
  const currentTextRef = useRef('');
82
86
  const restartsRef = useRef(0);
83
87
  /** The recognizer and tag this dictation uses, chosen once when it starts. */
84
- const recognizerRef = useRef<{ lang: string; service?: string; silent: boolean }>({ lang, silent: false });
88
+ const recognizerRef = useRef<{ lang: string; service?: string; silent: boolean }>({
89
+ lang,
90
+ silent: false,
91
+ });
85
92
  const endWaitersRef = useRef<Array<() => void>>([]);
86
93
  const lastMeteringRef = useRef(0);
87
94
  const mountedRef = useRef(true);
@@ -146,177 +146,184 @@ export function useTTS(options: UseTTSOptions = {}) {
146
146
  * gets to rebuild rather than the person getting an error about a button they
147
147
  * pressed twice.
148
148
  */
149
- const playFromUrl = useCallback((
150
- audioUrl: string,
151
- _messageId: string,
152
- signal: AbortSignal,
153
- onUnplayable?: () => void,
154
- ) => {
155
- releasePlayer();
149
+ const playFromUrl = useCallback(
150
+ (audioUrl: string, _messageId: string, signal: AbortSignal, onUnplayable?: () => void) => {
151
+ releasePlayer();
156
152
 
157
- (async () => {
158
- try {
159
- const { createAudioPlayer } = await import('expo-audio');
160
- if (signal.aborted) return;
161
- /**
162
- * `crossOrigin` is web-only and ignored elsewhere, and it is what makes
163
- * the waveform readable there: a browser will not let an `AnalyserNode`
164
- * see a cross-origin media element loaded without CORS, so without this
165
- * `setAudioSamplingEnabled` below silently declines and the field never
166
- * moves. Clips are served by `GET /media` on the API host, which is a
167
- * different origin from the app on every deployment.
168
- *
169
- * It costs the clip its CORS headers if the app's origin is not on the
170
- * API's allowlist — but such an origin cannot load a conversation to
171
- * read aloud in the first place, since `/media` and `/conversations`
172
- * share that allowlist. Measured against production 2026-08-26:
173
- * `Origin: https://alia.onl` is answered
174
- * `access-control-allow-origin: https://alia.onl`, with `Vary: Origin`.
175
- */
176
- const player = createAudioPlayer({ uri: audioUrl }, { crossOrigin: 'anonymous' });
177
- playerRef.current = player;
153
+ (async () => {
154
+ try {
155
+ const { createAudioPlayer } = await import('expo-audio');
156
+ if (signal.aborted) return;
157
+ /**
158
+ * `crossOrigin` is web-only and ignored elsewhere, and it is what makes
159
+ * the waveform readable there: a browser will not let an `AnalyserNode`
160
+ * see a cross-origin media element loaded without CORS, so without this
161
+ * `setAudioSamplingEnabled` below silently declines and the field never
162
+ * moves. Clips are served by `GET /media` on the API host, which is a
163
+ * different origin from the app on every deployment.
164
+ *
165
+ * It costs the clip its CORS headers if the app's origin is not on the
166
+ * API's allowlist — but such an origin cannot load a conversation to
167
+ * read aloud in the first place, since `/media` and `/conversations`
168
+ * share that allowlist. Measured against production 2026-08-26:
169
+ * `Origin: https://alia.onl` is answered
170
+ * `access-control-allow-origin: https://alia.onl`, with `Vary: Origin`.
171
+ */
172
+ const player = createAudioPlayer({ uri: audioUrl }, { crossOrigin: 'anonymous' });
173
+ playerRef.current = player;
174
+
175
+ /**
176
+ * The real waveform, which is what the ambient field behind the
177
+ * conversation moves to. Enabled BEFORE `play()`: on web this is what
178
+ * builds the analyser, and the sampling loop only starts if the player
179
+ * is already playing or starts afterwards.
180
+ *
181
+ * It can decline, and declining is not an error. Android routes
182
+ * playback sampling through `android.media.audiofx.Visualizer`, which
183
+ * needs `RECORD_AUDIO` already granted — and asking a person for the
184
+ * microphone so that a background can move would be a worse trade than
185
+ * the background not moving. Where it declines no buffer ever arrives,
186
+ * the level stays at zero and the field simply rests. That is the
187
+ * honest reading of "we cannot hear this", and it is deliberately not
188
+ * papered over with motion that does not come from the audio.
189
+ */
190
+ player.setAudioSamplingEnabled(true);
191
+ const meter = createAudioLevelMeter();
192
+ player.addListener('audioSampleUpdate', (sample: AudioSample) => {
193
+ const frames = sample.channels[0]?.frames;
194
+ if (frames === undefined) return;
195
+ ttsWaveAmplitude.value = meter.push(frames, Date.now());
196
+ });
178
197
 
179
- /**
180
- * The real waveform, which is what the ambient field behind the
181
- * conversation moves to. Enabled BEFORE `play()`: on web this is what
182
- * builds the analyser, and the sampling loop only starts if the player
183
- * is already playing or starts afterwards.
184
- *
185
- * It can decline, and declining is not an error. Android routes
186
- * playback sampling through `android.media.audiofx.Visualizer`, which
187
- * needs `RECORD_AUDIO` already granted — and asking a person for the
188
- * microphone so that a background can move would be a worse trade than
189
- * the background not moving. Where it declines no buffer ever arrives,
190
- * the level stays at zero and the field simply rests. That is the
191
- * honest reading of "we cannot hear this", and it is deliberately not
192
- * papered over with motion that does not come from the audio.
193
- */
194
- player.setAudioSamplingEnabled(true);
195
- const meter = createAudioLevelMeter();
196
- player.addListener('audioSampleUpdate', (sample: AudioSample) => {
197
- const frames = sample.channels[0]?.frames;
198
- if (frames === undefined) return;
199
- ttsWaveAmplitude.value = meter.push(frames, Date.now());
200
- });
201
-
202
- player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
203
- if (signal.aborted || playerRef.current !== player) return;
204
- if (status.error) {
205
- releasePlayer();
206
- if (onUnplayable) {
207
- onUnplayable();
198
+ player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
199
+ if (signal.aborted || playerRef.current !== player) return;
200
+ if (status.error) {
201
+ releasePlayer();
202
+ if (onUnplayable) {
203
+ onUnplayable();
204
+ return;
205
+ }
206
+ setError(status.error);
208
207
  return;
209
208
  }
210
- setError(status.error);
209
+ if (status.didJustFinish) {
210
+ releasePlayer();
211
+ reset();
212
+ }
213
+ });
214
+
215
+ player.play();
216
+ setPlaybackState('playing');
217
+ } catch {
218
+ if (signal.aborted) return;
219
+ if (onUnplayable) {
220
+ onUnplayable();
211
221
  return;
212
222
  }
213
- if (status.didJustFinish) {
214
- releasePlayer();
215
- reset();
216
- }
217
- });
218
-
219
- player.play();
220
- setPlaybackState('playing');
221
- } catch {
222
- if (signal.aborted) return;
223
- if (onUnplayable) {
224
- onUnplayable();
223
+ setError('Audio playback not available');
224
+ }
225
+ })();
226
+ },
227
+ [releasePlayer, reset, setPlaybackState, setError],
228
+ );
229
+
230
+ const readAloud = useCallback(
231
+ async (messageId: string, text: string, conversationId?: string, audioUrl?: string) => {
232
+ // Read volatile playback state at call time (not via closure) so this
233
+ // callback's identity survives every playback transition. It is passed to
234
+ // memoized message rows — an unstable identity re-renders all of them.
235
+ const { activeMessageId, playbackState } = useTTSStore.getState();
236
+
237
+ // If same message is playing, toggle pause/play
238
+ if (activeMessageId === messageId) {
239
+ if (playbackState === 'playing') {
240
+ playerRef.current?.pause();
241
+ setPlaybackState('paused');
242
+ return;
243
+ }
244
+ if (playbackState === 'paused') {
245
+ playerRef.current?.play();
246
+ setPlaybackState('playing');
225
247
  return;
226
248
  }
227
- setError('Audio playback not available');
228
- }
229
- })();
230
- }, [releasePlayer, reset, setPlaybackState, setError]);
231
-
232
- const readAloud = useCallback(async (
233
- messageId: string,
234
- text: string,
235
- conversationId?: string,
236
- audioUrl?: string,
237
- ) => {
238
- // Read volatile playback state at call time (not via closure) so this
239
- // callback's identity survives every playback transition. It is passed to
240
- // memoized message rows — an unstable identity re-renders all of them.
241
- const { activeMessageId, playbackState } = useTTSStore.getState();
242
-
243
- // If same message is playing, toggle pause/play
244
- if (activeMessageId === messageId) {
245
- if (playbackState === 'playing') {
246
- playerRef.current?.pause();
247
- setPlaybackState('paused');
248
- return;
249
249
  }
250
- if (playbackState === 'paused') {
251
- playerRef.current?.play();
252
- setPlaybackState('playing');
253
- return;
250
+
251
+ // Stop any current playback
252
+ if (activeMessageId) {
253
+ stop();
254
254
  }
255
- }
256
255
 
257
- // Stop any current playback
258
- if (activeMessageId) {
259
- stop();
260
- }
256
+ const controller = new AbortController();
257
+ requestRef.current = controller;
258
+ try {
259
+ setActiveMessage(messageId);
260
+ setPlaybackState('loading');
261
261
 
262
- const controller = new AbortController();
263
- requestRef.current = controller;
264
- try {
265
- setActiveMessage(messageId);
266
- setPlaybackState('loading');
267
-
268
- /**
269
- * Ask the server to make it, and play what comes back.
270
- *
271
- * Hoisted out of the cached branch below so a clip that has aged out of
272
- * storage can fall back to it. `playFromUrl` is called WITHOUT a fallback
273
- * here: a freshly made clip that will not play is a real failure, and
274
- * retrying it would be a loop.
275
- */
276
- const synthesize = async () => {
277
- const token = getToken();
278
- if (!token) {
279
- throw new Error('Not authenticated');
280
- }
262
+ /**
263
+ * Ask the server to make it, and play what comes back.
264
+ *
265
+ * Hoisted out of the cached branch below so a clip that has aged out of
266
+ * storage can fall back to it. `playFromUrl` is called WITHOUT a fallback
267
+ * here: a freshly made clip that will not play is a real failure, and
268
+ * retrying it would be a loop.
269
+ */
270
+ const synthesize = async () => {
271
+ const token = getToken();
272
+ if (!token) {
273
+ throw new Error('Not authenticated');
274
+ }
281
275
 
282
- const audioUrl = await requestSpeechClip({
283
- apiUrl,
284
- token,
285
- model: voiceModel,
286
- input: text,
287
- voice: getTTSVoice(),
288
- speed: getTTSSpeed(),
289
- conversationId,
290
- messageId,
291
- signal: controller.signal,
292
- });
293
- playFromUrl(audioUrl, messageId, controller.signal);
294
- };
295
-
296
- /**
297
- * A stored clip plays straight away, and is remade if it has gone.
298
- *
299
- * The row keeps its key long after a `tts/` object ages out of storage,
300
- * so the link is signed, valid, and points at nothing — a failure only
301
- * the player ever sees. Remaking it is cheaper than keeping every clip
302
- * forever, and the person notices nothing.
303
- */
304
- if (audioUrl) {
305
- playFromUrl(audioUrl, messageId, controller.signal, () => {
306
- void synthesize().catch((e: unknown) => {
307
- if (!controller.signal.aborted) setError(errorMessage(e, 'Failed to read aloud'));
276
+ const audioUrl = await requestSpeechClip({
277
+ apiUrl,
278
+ token,
279
+ model: voiceModel,
280
+ input: text,
281
+ voice: getTTSVoice(),
282
+ speed: getTTSSpeed(),
283
+ conversationId,
284
+ messageId,
285
+ signal: controller.signal,
308
286
  });
309
- });
310
- return;
311
- }
287
+ playFromUrl(audioUrl, messageId, controller.signal);
288
+ };
312
289
 
313
- await synthesize();
314
- } catch (e: unknown) {
315
- if (controller.signal.aborted) return;
316
- console.error('[TTS] Error:', e);
317
- setError(errorMessage(e, 'Failed to read aloud'));
318
- }
319
- }, [getToken, apiUrl, voiceModel, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
290
+ /**
291
+ * A stored clip plays straight away, and is remade if it has gone.
292
+ *
293
+ * The row keeps its key long after a `tts/` object ages out of storage,
294
+ * so the link is signed, valid, and points at nothing — a failure only
295
+ * the player ever sees. Remaking it is cheaper than keeping every clip
296
+ * forever, and the person notices nothing.
297
+ */
298
+ if (audioUrl) {
299
+ playFromUrl(audioUrl, messageId, controller.signal, () => {
300
+ void synthesize().catch((e: unknown) => {
301
+ if (!controller.signal.aborted) setError(errorMessage(e, 'Failed to read aloud'));
302
+ });
303
+ });
304
+ return;
305
+ }
306
+
307
+ await synthesize();
308
+ } catch (e: unknown) {
309
+ if (controller.signal.aborted) return;
310
+ console.error('[TTS] Error:', e);
311
+ setError(errorMessage(e, 'Failed to read aloud'));
312
+ }
313
+ },
314
+ [
315
+ getToken,
316
+ apiUrl,
317
+ voiceModel,
318
+ getTTSVoice,
319
+ getTTSSpeed,
320
+ stop,
321
+ playFromUrl,
322
+ setActiveMessage,
323
+ setPlaybackState,
324
+ setError,
325
+ ],
326
+ );
320
327
 
321
328
  // Cleanup on unmount
322
329
  useEffect(() => {