@alia.onl/sdk 21.5.3 → 22.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 22.0.0
4
+
5
+ Requires `@oxy.so/core >=7.1.0 <8` and `@oxy.so/services >=13.0.0 <14`.
6
+ Text, dictation, speech and voice use the shared account transport. Account
7
+ switches and sign-out invalidate pending work; websocket credentials are
8
+ refreshed through the shared origin-bound getter before connecting. Own Oxy
9
+ apps use their current account without app scopes or consent. External
10
+ credentials retain their existing limits.
11
+
3
12
  ## 21.5.3
4
13
 
5
14
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "21.5.3",
3
+ "version": "22.0.0",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -42,8 +42,7 @@
42
42
  "test": "vitest run",
43
43
  "check:entries": "node scripts/check-entry-isolation.mjs",
44
44
  "check:entry-types": "node scripts/check-entry-types.mjs",
45
- "render:avatar-still": "bun scripts/render-avatar-still.ts",
46
- "prepublishOnly": "bun run typecheck && bun run test && bun run check:entries && bun run check:entry-types"
45
+ "render:avatar-still": "bun scripts/render-avatar-still.ts"
47
46
  },
48
47
  "dependencies": {
49
48
  "clsx": "^2.1.1",
@@ -56,8 +55,8 @@
56
55
  "react-native": "^0.86.0 || ^0.88.0",
57
56
  "@expo/vector-icons": "^15.1.1",
58
57
  "@oxy.so/bloom": ">=15.10.1 <16.0.0",
59
- "@oxy.so/core": ">=3.0.0",
60
- "@oxy.so/services": ">=12.1.0",
58
+ "@oxy.so/core": ">=7.1.0 <8",
59
+ "@oxy.so/services": ">=13.0.0 <14",
61
60
  "@tanstack/react-query": "^5.101",
62
61
  "@react-native-async-storage/async-storage": "^2.2.0",
63
62
  "expo-blur": "^57.0.0 || ^58.0.0",
@@ -5,7 +5,10 @@ import {
5
5
  DropdownMenuItem,
6
6
  DropdownMenuTrigger,
7
7
  } from '@oxy.so/bloom/dropdown-menu';
8
- import { RiArrowLeftLine, RiDeleteBin6Line, RiMoreFill, RiShare2Line } from '@oxy.so/bloom/icons';
8
+ import { RiArrowLeftLine } from '@oxy.so/bloom/icons/RiArrowLeftLine';
9
+ import { RiDeleteBin6Line } from '@oxy.so/bloom/icons/RiDeleteBin6Line';
10
+ import { RiMoreFill } from '@oxy.so/bloom/icons/RiMoreFill';
11
+ import { RiShare2Line } from '@oxy.so/bloom/icons/RiShare2Line';
9
12
  import { PageHeader } from '@oxy.so/bloom/page-header';
10
13
  import { memo, type ReactNode } from 'react';
11
14
  import { useLabels } from '../lib/labels';
@@ -1,5 +1,5 @@
1
1
  import { Card, CardBody, CardHeader, CardTitle } from '@oxy.so/bloom/card';
2
- import { RiListCheck3 } from '@oxy.so/bloom/icons';
2
+ import { RiListCheck3 } from '@oxy.so/bloom/icons/RiListCheck3';
3
3
  import { Item } from '@oxy.so/bloom/item';
4
4
  import { useTheme } from '@oxy.so/bloom/theme';
5
5
  import { Text } from '@oxy.so/bloom/typography';
@@ -29,6 +29,7 @@ import { DictationRecorderError, useDictationRecorder } from '../lib/dictation-r
29
29
  import { defaultSpeechLanguage } from '../lib/speech-language';
30
30
  import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from '../lib/speech-messages';
31
31
  import { ALIA_API_URL } from '../lib/origins';
32
+ import { bindAudioSession } from '../lib/audio-session-binding';
32
33
 
33
34
  /** The remaining time is shown from here on. */
34
35
  const COUNTDOWN_FROM_MS = 30_000;
@@ -119,20 +120,16 @@ export function useDictation(options: UseDictationOptions = {}) {
119
120
 
120
121
  const transcribe = useCallback(
121
122
  async (clip: DictationClip): Promise<string | null> => {
122
- const token = optionsRef.current.accessToken || oxyServices.session.accessToken || '';
123
- if (token === '') {
124
- failedClipRef.current = null;
125
- if (mountedRef.current) setCanRetry(false);
126
- report('not-authenticated');
127
- return null;
128
- }
123
+ const apiUrl = optionsRef.current.apiUrl || ALIA_API_URL;
124
+ const token = optionsRef.current.accessToken;
129
125
  setState('transcribing');
130
126
  const controller = new AbortController();
131
127
  abortRef.current = controller;
128
+ const linked = token ? null : bindAudioSession(oxyServices, apiUrl, () => controller.abort());
132
129
  try {
133
130
  const result = await requestTranscription({
134
- apiUrl: optionsRef.current.apiUrl || ALIA_API_URL,
135
- token,
131
+ apiUrl,
132
+ ...(linked ? { transport: linked } : { token: token ?? '' }),
136
133
  clip,
137
134
  language: optionsRef.current.lang ?? defaultSpeechLanguage(),
138
135
  signal: controller.signal,
@@ -155,6 +152,7 @@ export function useDictation(options: UseDictationOptions = {}) {
155
152
  report(code);
156
153
  return null;
157
154
  } finally {
155
+ linked?.dispose();
158
156
  if (abortRef.current === controller) abortRef.current = null;
159
157
  }
160
158
  },
@@ -8,6 +8,7 @@ import { createAudioLevelMeter } from '../lib/audio-level';
8
8
  import { loadExpoAudio } from '../lib/deferred/expo-audio';
9
9
  import { requestSpeechClip } from '../lib/speech-synthesis';
10
10
  import { ALIA_API_URL } from '../lib/origins';
11
+ import { bindAudioSession } from '../lib/audio-session-binding';
11
12
 
12
13
  // ============== OPTIONS ==============
13
14
 
@@ -103,13 +104,6 @@ export function useTTS(options: UseTTSOptions = {}) {
103
104
  const playerRef = useRef<AudioPlayer | null>(null);
104
105
  const requestRef = useRef<AbortController | null>(null);
105
106
 
106
- // ============== AUTH ==============
107
-
108
- const getToken = useCallback((): string | null => {
109
- if (options.accessToken) return options.accessToken;
110
- return oxyServices.session.accessToken;
111
- }, [options.accessToken, oxyServices]);
112
-
113
107
  const getTTSVoice = useCallback(() => {
114
108
  return voicePref === 'male' ? 'male' : 'female';
115
109
  }, [voicePref]);
@@ -238,6 +232,9 @@ export function useTTS(options: UseTTSOptions = {}) {
238
232
 
239
233
  const controller = new AbortController();
240
234
  requestRef.current = controller;
235
+ const linked = options.accessToken
236
+ ? null
237
+ : bindAudioSession(oxyServices, apiUrl, () => controller.abort());
241
238
  try {
242
239
  setActiveMessage(messageId);
243
240
  setPlaybackState('loading');
@@ -250,13 +247,9 @@ export function useTTS(options: UseTTSOptions = {}) {
250
247
  * `conversationId` (with the message id) files the clip under that
251
248
  * conversation, so deleting the conversation deletes it.
252
249
  */
253
- const token = getToken();
254
- if (!token) {
255
- throw new Error('Not authenticated');
256
- }
257
250
  const audioUrl = await requestSpeechClip({
258
251
  apiUrl,
259
- token,
252
+ ...(linked ? { transport: linked } : { token: options.accessToken ?? '' }),
260
253
  model: voiceModel,
261
254
  input: text,
262
255
  voice: getTTSVoice(),
@@ -270,10 +263,13 @@ export function useTTS(options: UseTTSOptions = {}) {
270
263
  if (controller.signal.aborted) return;
271
264
  console.error('[TTS] Error:', e);
272
265
  setError(errorMessage(e, 'Failed to read aloud'));
266
+ } finally {
267
+ linked?.dispose();
273
268
  }
274
269
  },
275
270
  [
276
- getToken,
271
+ options.accessToken,
272
+ oxyServices,
277
273
  apiUrl,
278
274
  voiceModel,
279
275
  getTTSVoice,
@@ -14,6 +14,7 @@
14
14
  * recognizer anywhere in a call.
15
15
  */
16
16
 
17
+ import { bindAudioSession } from '../lib/audio-session-binding';
17
18
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
19
  import { useOxy } from '@oxy.so/services/ui/client';
19
20
  import type { AgentState, RoomState, VoiceCallState, VoiceMessage } from '../types';
@@ -104,23 +105,24 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
104
105
  const levels = useMemo(() => new VoiceLevelChannel(), []);
105
106
  const [snapshot, setSnapshot] = useState<VoiceCallSnapshot>(IDLE);
106
107
  const callRef = useRef<VoiceCall | null>(null);
108
+ const sessionBindingRef = useRef<ReturnType<typeof bindAudioSession> | null>(null);
107
109
  const mountedRef = useRef(true);
108
110
  const optionsRef = useRef(options);
109
111
  optionsRef.current = options;
110
112
 
111
- const getToken = useCallback((): string | null => {
112
- const { accessToken } = optionsRef.current;
113
- if (accessToken) return accessToken;
114
- return oxyServices.session.accessToken ?? null;
115
- }, [oxyServices]);
116
-
117
113
  const connect = useCallback(async () => {
118
114
  const live = callRef.current?.snapshot().state.kind;
119
115
  if (live !== undefined && live !== 'ended' && live !== 'error') return;
120
116
  const current = optionsRef.current;
117
+ sessionBindingRef.current?.dispose();
118
+ const apiUrl = current.apiUrl || ALIA_API_URL;
119
+ const binding = current.accessToken
120
+ ? null
121
+ : bindAudioSession(oxyServices, apiUrl, () => callRef.current?.hangUp());
122
+ sessionBindingRef.current = binding;
121
123
  const call = new VoiceCall({
122
- apiUrl: current.apiUrl || ALIA_API_URL,
123
- getToken,
124
+ apiUrl,
125
+ getToken: binding?.getToken ?? (() => optionsRef.current.accessToken ?? null),
124
126
  getAppAssertion,
125
127
  conversationId: current.conversationId ?? newConversationId(),
126
128
  ...(current.agentId === undefined ? {} : { agentId: current.agentId }),
@@ -130,19 +132,23 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
130
132
  openSocket: current.openSocket ?? openWebSocket,
131
133
  levels,
132
134
  onChange: (next) => {
133
- if (mountedRef.current && callRef.current === call) setSnapshot(next);
135
+ if (callRef.current !== call) return;
136
+ if (next.state.kind === 'ended' || next.state.kind === 'error') binding?.dispose();
137
+ if (mountedRef.current) setSnapshot(next);
134
138
  },
135
139
  onMessagesSaved: (conversationId, ids) =>
136
140
  optionsRef.current.onMessagesSaved?.(conversationId, ids),
137
141
  });
138
142
  callRef.current = call;
139
143
  await call.start();
140
- }, [getToken, getAppAssertion, levels]);
144
+ }, [oxyServices, getAppAssertion, levels]);
141
145
 
142
146
  /** Hang up and forget the call: the room is back to `disconnected`. */
143
147
  const disconnect = useCallback(() => {
144
148
  const call = callRef.current;
145
149
  callRef.current = null;
150
+ sessionBindingRef.current?.dispose();
151
+ sessionBindingRef.current = null;
146
152
  call?.hangUp();
147
153
  levels.reset();
148
154
  if (mountedRef.current) setSnapshot(IDLE);
@@ -160,6 +166,8 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
160
166
  mountedRef.current = false;
161
167
  const call = callRef.current;
162
168
  callRef.current = null;
169
+ sessionBindingRef.current?.dispose();
170
+ sessionBindingRef.current = null;
163
171
  call?.hangUp();
164
172
  };
165
173
  }, []);
@@ -0,0 +1,4 @@
1
+ /** Own audio calls share Oxy's session transport; explicit external bearers remain supported. */
2
+ export type AudioRequestAuthentication =
3
+ | { readonly transport: { fetch: typeof globalThis.fetch }; readonly token?: never }
4
+ | { readonly token: string; readonly transport?: never };
@@ -0,0 +1,27 @@
1
+ import type { OxyServices } from '@oxy.so/core';
2
+
3
+ /** Bind an audio operation to core's session context while allowing token rotation. */
4
+ export function bindAudioSession(
5
+ oxy: Pick<OxyServices, 'createLinkedClient'>,
6
+ baseURL: string,
7
+ onInvalidated: () => void,
8
+ ) {
9
+ const linked = oxy.createLinkedClient({ baseURL });
10
+ const epoch = linked.client.getSessionEpoch();
11
+ let disposed = false;
12
+ const current = () => !disposed && linked.client.getSessionEpoch() === epoch;
13
+ const unsubscribe = linked.client.addTokenChangeListener(() => {
14
+ if (!current()) onInvalidated();
15
+ });
16
+ return {
17
+ fetch: linked.fetch,
18
+ getToken: (target: string, options?: { signal?: AbortSignal }) =>
19
+ current() ? linked.getAccessToken(target, options) : Promise.resolve(null),
20
+ dispose: () => {
21
+ if (disposed) return;
22
+ disposed = true;
23
+ unsubscribe();
24
+ linked.dispose();
25
+ },
26
+ };
27
+ }
@@ -7,6 +7,7 @@
7
7
  * recognized on the device.
8
8
  */
9
9
 
10
+ import type { AudioRequestAuthentication } from './audio-request-auth';
10
11
  import type { VoiceErrorCode } from './speech-messages';
11
12
 
12
13
  /** The longest clip one dictation may be. */
@@ -115,14 +116,13 @@ function fileNameFor(mediaType: string): string {
115
116
  }
116
117
  }
117
118
 
118
- export interface TranscriptionRequest {
119
+ export type TranscriptionRequest = AudioRequestAuthentication & {
119
120
  readonly apiUrl: string;
120
- readonly token: string;
121
121
  readonly clip: DictationClip;
122
122
  /** BCP-47. */
123
123
  readonly language: string;
124
124
  readonly signal?: AbortSignal;
125
- }
125
+ };
126
126
 
127
127
  /**
128
128
  * Transcribe one clip.
@@ -148,14 +148,22 @@ export async function requestTranscription(
148
148
 
149
149
  let response: Response;
150
150
  try {
151
- response = await fetch(`${request.apiUrl}/v1/audio/transcriptions`, {
152
- method: 'POST',
153
- headers: { Authorization: `Bearer ${request.token}` },
154
- body: form,
155
- ...(request.signal ? { signal: request.signal } : {}),
156
- });
151
+ response = await (request.transport?.fetch ?? fetch)(
152
+ `${request.apiUrl}/v1/audio/transcriptions`,
153
+ {
154
+ method: 'POST',
155
+ headers: request.transport ? undefined : { Authorization: `Bearer ${request.token}` },
156
+ redirect: 'error',
157
+ credentials: 'omit',
158
+ body: form,
159
+ ...(request.signal ? { signal: request.signal } : {}),
160
+ },
161
+ );
157
162
  } catch (caught: unknown) {
158
163
  if ((caught as { name?: unknown } | null)?.name === 'AbortError') throw caught;
164
+ const code = (caught as { code?: unknown } | null)?.code;
165
+ if (code === 'AUTH_REQUIRED' || code === 'AUTH_SESSION_CHANGED')
166
+ throw new DictationError('Not authenticated', { code: 'NOT_AUTHENTICATED', status: null });
159
167
  throw new DictationError('The transcription request did not reach Alia', {
160
168
  code: null,
161
169
  status: null,
@@ -8,14 +8,14 @@
8
8
  * name ever appears on this side.
9
9
  */
10
10
 
11
+ import type { AudioRequestAuthentication } from './audio-request-auth';
11
12
  import { createAudioLevelMeter } from './audio-level';
12
13
  import { loadExpoAudio } from './deferred/expo-audio';
13
14
 
14
15
  export type ProductVoice = 'male' | 'female';
15
16
 
16
- export interface SpeechClipRequest {
17
+ export type SpeechClipRequest = AudioRequestAuthentication & {
17
18
  readonly apiUrl: string;
18
- readonly token: string;
19
19
  /** A speech model; omitted, the server's own speech model answers. */
20
20
  readonly model?: string;
21
21
  readonly input: string;
@@ -29,7 +29,7 @@ export interface SpeechClipRequest {
29
29
  readonly conversationId?: string;
30
30
  readonly messageId?: string;
31
31
  readonly signal: AbortSignal;
32
- }
32
+ };
33
33
 
34
34
  /**
35
35
  * The codes `/v1/audio/speech` answers a failure with, plus the two this side
@@ -127,12 +127,14 @@ function clipErrorFrom(status: number, body: unknown): SpeechClipError {
127
127
  export async function requestSpeechClip(request: SpeechClipRequest): Promise<string> {
128
128
  if (request.token === '')
129
129
  throw new SpeechClipError('Not authenticated', { status: null, code: 'NOT_AUTHENTICATED' });
130
- const response = await fetch(`${request.apiUrl}/v1/audio/speech`, {
130
+ const response = await (request.transport?.fetch ?? fetch)(`${request.apiUrl}/v1/audio/speech`, {
131
131
  method: 'POST',
132
132
  signal: request.signal,
133
+ redirect: 'error',
134
+ credentials: 'omit',
133
135
  headers: {
134
136
  'Content-Type': 'application/json',
135
- Authorization: `Bearer ${request.token}`,
137
+ ...(request.transport ? {} : { Authorization: `Bearer ${request.token}` }),
136
138
  },
137
139
  body: JSON.stringify({
138
140
  model: request.model,
@@ -76,7 +76,10 @@ export interface VoiceCallSnapshot {
76
76
  export interface VoiceCallOptions {
77
77
  readonly apiUrl: string;
78
78
  /** The person's Oxy session token, read at connect and every few minutes. */
79
- readonly getToken: () => string | null;
79
+ readonly getToken: (
80
+ target: string,
81
+ options?: { signal?: AbortSignal },
82
+ ) => string | null | Promise<string | null>;
80
83
  /**
81
84
  * The host's Oxy app assertion, sent with the first `auth` of each socket so
82
85
  * an agent Oxy confines to an app opens only there (ADR 0021). Absent: none.
@@ -138,6 +141,8 @@ export class VoiceCall {
138
141
  private resumeTimer: ReturnType<typeof setTimeout> | null = null;
139
142
  private tokenTimer: ReturnType<typeof setInterval> | null = null;
140
143
  private finished = false;
144
+ private readonly credentialAbort = new AbortController();
145
+ private refreshingToken = false;
141
146
 
142
147
  /** The three facts "listening" needs. */
143
148
  private ready = false;
@@ -172,11 +177,7 @@ export class VoiceCall {
172
177
  async start(): Promise<void> {
173
178
  if (this.state.kind !== 'idle') return;
174
179
  this.setState({ kind: 'connecting' });
175
- const token = this.options.getToken();
176
- if (token === null || token === '') {
177
- this.fail('not-authenticated');
178
- return;
179
- }
180
+ if (!(await this.readToken()) || this.finished) return;
180
181
  const engine = this.options.createEngine();
181
182
  this.engine = engine;
182
183
  try {
@@ -187,9 +188,10 @@ export class VoiceCall {
187
188
  return;
188
189
  }
189
190
  if (this.finished) return;
190
- this.openSocket(false);
191
+ await this.openSocket(false);
192
+ if (this.finished) return;
191
193
  this.tokenTimer = setInterval(
192
- () => this.refreshToken(),
194
+ () => void this.refreshToken(),
193
195
  this.options.tokenRefreshMs ?? TOKEN_REFRESH_MS,
194
196
  );
195
197
  }
@@ -237,7 +239,8 @@ export class VoiceCall {
237
239
  };
238
240
  }
239
241
 
240
- private openSocket(resuming: boolean): void {
242
+ private async openSocket(resuming: boolean): Promise<void> {
243
+ if (!(await this.readToken()) || this.finished) return;
241
244
  this.authenticated = false;
242
245
  let socket: VoiceSocket;
243
246
  try {
@@ -250,29 +253,7 @@ export class VoiceCall {
250
253
  socket.binaryType = 'arraybuffer';
251
254
  this.socket = socket;
252
255
  socket.onopen = () => {
253
- const token = this.options.getToken();
254
- if (token === null || token === '') {
255
- this.fail('not-authenticated');
256
- return;
257
- }
258
- this.lastToken = token;
259
- const getAppAssertion = this.options.getAppAssertion;
260
- if (getAppAssertion === undefined) {
261
- socket.send(JSON.stringify({ type: 'auth', token } satisfies VoiceClientEvent));
262
- return;
263
- }
264
- void getAppAssertion()
265
- .catch(() => null)
266
- .then((appAssertion) => {
267
- if (this.socket !== socket || this.finished || socket.readyState !== OPEN) return;
268
- socket.send(
269
- JSON.stringify({
270
- type: 'auth',
271
- token,
272
- ...(appAssertion ? { appAssertion } : {}),
273
- } satisfies VoiceClientEvent),
274
- );
275
- });
256
+ void this.authenticateSocket(socket);
276
257
  };
277
258
  socket.onmessage = (event) => {
278
259
  if (this.socket !== socket || this.finished) return;
@@ -314,16 +295,59 @@ export class VoiceCall {
314
295
  if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
315
296
  this.resumeTimer = setTimeout(() => {
316
297
  this.resumeTimer = null;
317
- if (!this.finished) this.openSocket(true);
298
+ if (!this.finished) void this.openSocket(true);
318
299
  }, this.options.resumeRetryMs ?? RESUME_RETRY_MS);
319
300
  }
320
301
 
321
- private refreshToken(): void {
322
- if (!this.authenticated || this.socket?.readyState !== OPEN) return;
323
- const token = this.options.getToken();
324
- if (token === null || token === '' || token === this.lastToken) return;
302
+ /** The shared session owns refresh and validates the actual socket destination. */
303
+ private async readToken(): Promise<string | null> {
304
+ try {
305
+ const token = await this.options.getToken(voiceSessionUrl(this.options.apiUrl), {
306
+ signal: this.credentialAbort.signal,
307
+ });
308
+ if (this.finished) return null;
309
+ if (!token) this.fail('not-authenticated');
310
+ return token || null;
311
+ } catch {
312
+ if (!this.finished) this.fail('not-authenticated');
313
+ return null;
314
+ }
315
+ }
316
+
317
+ private async authenticateSocket(socket: VoiceSocket): Promise<void> {
318
+ const assertion = await this.options.getAppAssertion?.().catch(() => null);
319
+ const token = await this.readToken();
320
+ if (!token || this.finished || this.socket !== socket || socket.readyState !== OPEN) return;
325
321
  this.lastToken = token;
326
- this.send({ type: 'auth', token });
322
+ socket.send(
323
+ JSON.stringify({
324
+ type: 'auth',
325
+ token,
326
+ ...(assertion ? { appAssertion: assertion } : {}),
327
+ } satisfies VoiceClientEvent),
328
+ );
329
+ }
330
+
331
+ private async refreshToken(): Promise<void> {
332
+ if (this.refreshingToken || !this.authenticated || this.socket?.readyState !== OPEN) return;
333
+ const socket = this.socket;
334
+ this.refreshingToken = true;
335
+ try {
336
+ const token = await this.readToken();
337
+ if (
338
+ !token ||
339
+ this.finished ||
340
+ this.socket !== socket ||
341
+ !this.authenticated ||
342
+ socket.readyState !== OPEN
343
+ )
344
+ return;
345
+ if (token === this.lastToken) return;
346
+ this.lastToken = token;
347
+ this.send({ type: 'auth', token });
348
+ } finally {
349
+ this.refreshingToken = false;
350
+ }
327
351
  }
328
352
 
329
353
  private send(event: VoiceClientEvent): void {
@@ -564,6 +588,7 @@ export class VoiceCall {
564
588
  private finish(state: VoiceCallState): void {
565
589
  if (this.finished) return;
566
590
  this.finished = true;
591
+ this.credentialAbort.abort();
567
592
  if (this.resumeTimer !== null) clearTimeout(this.resumeTimer);
568
593
  if (this.tokenTimer !== null) clearInterval(this.tokenTimer);
569
594
  this.resumeTimer = null;
@@ -1,11 +1,9 @@
1
- import {
2
- RiBookOpenLine,
3
- RiFileTextLine,
4
- RiGlobalLine,
5
- RiSearchLine,
6
- RiTerminalBoxLine,
7
- RiToolsLine,
8
- } from '@oxy.so/bloom/icons';
1
+ import { RiBookOpenLine } from '@oxy.so/bloom/icons/RiBookOpenLine';
2
+ import { RiFileTextLine } from '@oxy.so/bloom/icons/RiFileTextLine';
3
+ import { RiGlobalLine } from '@oxy.so/bloom/icons/RiGlobalLine';
4
+ import { RiSearchLine } from '@oxy.so/bloom/icons/RiSearchLine';
5
+ import { RiTerminalBoxLine } from '@oxy.so/bloom/icons/RiTerminalBoxLine';
6
+ import { RiToolsLine } from '@oxy.so/bloom/icons/RiToolsLine';
9
7
  import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/task-list';
10
8
  import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
11
9
  import type { ToolInvocation } from '../runtime/types';