@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 +9 -0
- package/package.json +4 -5
- package/src/components/AliaChatHeader.tsx +4 -1
- package/src/components/PlanPreviewCard.tsx +1 -1
- package/src/hooks/useDictation.ts +7 -9
- package/src/hooks/useTTS.ts +9 -13
- package/src/hooks/useVoiceRoom.ts +18 -10
- package/src/lib/audio-request-auth.ts +4 -0
- package/src/lib/audio-session-binding.ts +27 -0
- package/src/lib/dictation.ts +17 -9
- package/src/lib/speech-synthesis.ts +7 -5
- package/src/lib/voice-call.ts +63 -38
- package/src/lib/work-log.ts +6 -8
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": "
|
|
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": ">=
|
|
60
|
-
"@oxy.so/services": ">=
|
|
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
|
|
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
|
|
123
|
-
|
|
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
|
|
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
|
},
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
}, [
|
|
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
|
+
}
|
package/src/lib/dictation.ts
CHANGED
|
@@ -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
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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,
|
package/src/lib/voice-call.ts
CHANGED
|
@@ -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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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;
|
package/src/lib/work-log.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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';
|