@alia.onl/sdk 5.1.0 → 7.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 +56 -0
- package/LICENSE +202 -0
- package/NOTICE +15 -0
- package/package.json +4 -2
- package/src/components/AliaChatContent.tsx +3 -3
- package/src/components/AliaChatScreen.tsx +3 -3
- package/src/components/AliaChatSheet.tsx +3 -3
- package/src/components/AliaWelcomeMessage.tsx +2 -2
- package/src/components/{AliaMark.tsx → IdentityMark.tsx} +61 -19
- package/src/components/Markdown.tsx +31 -6
- package/src/components/ThinkingIndicator.tsx +2 -2
- package/src/components/ui/button.tsx +2 -2
- package/src/components/ui/chat-text-input.tsx +27 -11
- package/src/components/ui/prompt-input/textarea.tsx +19 -3
- package/src/hooks/useAliaChat.ts +50 -3
- package/src/hooks/useTTS.ts +87 -34
- package/src/hooks/useVoiceRoom.ts +26 -2
- package/src/index.ts +15 -2
- package/src/lib/catalogue.ts +209 -0
- package/src/lib/config.ts +38 -0
- package/src/lib/tool-registry.ts +1 -1
|
@@ -21,6 +21,7 @@ declare module "react-native" {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
|
|
24
|
+
unstyled?: boolean;
|
|
24
25
|
noFocus?: boolean;
|
|
25
26
|
onEnterPress?: () => void;
|
|
26
27
|
onCompletionKey?: (key: string) => boolean;
|
|
@@ -36,6 +37,7 @@ type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
|
|
|
36
37
|
const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
37
38
|
({
|
|
38
39
|
className,
|
|
40
|
+
unstyled = false,
|
|
39
41
|
noFocus = false,
|
|
40
42
|
onEnterPress,
|
|
41
43
|
onCompletionKey,
|
|
@@ -54,6 +56,28 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
|
54
56
|
const inputRef = React.useRef<TextInput>(null);
|
|
55
57
|
const wrapperRef = React.useRef<View>(null);
|
|
56
58
|
|
|
59
|
+
// react-native-css observes `style` by reference. Memoizing the composed
|
|
60
|
+
// value prevents its own state update from manufacturing another changed
|
|
61
|
+
// style prop and recursively re-rendering this TextInput on native.
|
|
62
|
+
const inputStyle = React.useMemo(() => [
|
|
63
|
+
style,
|
|
64
|
+
!fillContainer && props.multiline && !disableAutoHeight && ({
|
|
65
|
+
minHeight,
|
|
66
|
+
maxHeight,
|
|
67
|
+
...(Platform.OS === 'web'
|
|
68
|
+
? { overflow: 'auto' as const, fieldSizing: 'content' }
|
|
69
|
+
: {}),
|
|
70
|
+
} as TextStyle & { fieldSizing?: string }),
|
|
71
|
+
fillContainer && { flex: 1, height: '100%' as const },
|
|
72
|
+
], [
|
|
73
|
+
disableAutoHeight,
|
|
74
|
+
fillContainer,
|
|
75
|
+
maxHeight,
|
|
76
|
+
minHeight,
|
|
77
|
+
props.multiline,
|
|
78
|
+
style,
|
|
79
|
+
]);
|
|
80
|
+
|
|
57
81
|
React.useImperativeHandle(ref, () => inputRef.current as TextInput);
|
|
58
82
|
|
|
59
83
|
// Attach paste event listener (web only)
|
|
@@ -135,7 +159,8 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
|
135
159
|
<TextInput
|
|
136
160
|
ref={inputRef}
|
|
137
161
|
className={cn(
|
|
138
|
-
"native:text-md native:leading-[1.25]
|
|
162
|
+
"native:text-md native:leading-[1.25] text-base text-foreground file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground web:flex web:w-full web:py-2 lg:text-sm",
|
|
163
|
+
!unstyled && "rounded-xl border border-input bg-background px-3.5",
|
|
139
164
|
"web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
|
140
165
|
!fillContainer && !props.multiline && "h-9",
|
|
141
166
|
fillContainer && "h-full",
|
|
@@ -147,16 +172,7 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
|
147
172
|
onKeyPress={handleKeyPress}
|
|
148
173
|
onContentSizeChange={handleContentSizeChange}
|
|
149
174
|
scrollEnabled={fillContainer || props.multiline}
|
|
150
|
-
style={
|
|
151
|
-
style,
|
|
152
|
-
!fillContainer && props.multiline && !disableAutoHeight && ({
|
|
153
|
-
minHeight,
|
|
154
|
-
maxHeight,
|
|
155
|
-
overflow: 'auto',
|
|
156
|
-
...(Platform.OS === 'web' ? { fieldSizing: 'content' } : {}),
|
|
157
|
-
} as TextStyle & { overflow: 'auto'; fieldSizing?: string }),
|
|
158
|
-
fillContainer && { flex: 1, height: '100%' },
|
|
159
|
-
]}
|
|
175
|
+
style={inputStyle}
|
|
160
176
|
{...props}
|
|
161
177
|
/>
|
|
162
178
|
</View>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { View } from "react-native";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
3
|
import { cn } from "../../../lib/utils";
|
|
4
4
|
import { ChatTextInput } from "../chat-text-input";
|
|
5
5
|
import { usePromptInput } from "./context";
|
|
@@ -28,6 +28,11 @@ export function PromptInputTextarea({
|
|
|
28
28
|
handleCompletionKey,
|
|
29
29
|
} = usePromptInput();
|
|
30
30
|
|
|
31
|
+
const inputStyle = React.useMemo(
|
|
32
|
+
() => [style, styles.borderless, isFullscreen && styles.fullscreen],
|
|
33
|
+
[isFullscreen, style],
|
|
34
|
+
);
|
|
35
|
+
|
|
31
36
|
const textInput = (
|
|
32
37
|
<ChatTextInput
|
|
33
38
|
ref={textareaRef}
|
|
@@ -42,12 +47,14 @@ export function PromptInputTextarea({
|
|
|
42
47
|
maxHeight={isFullscreen ? 10000 : maxHeight}
|
|
43
48
|
onImagePaste={onImagePaste}
|
|
44
49
|
fillContainer={isFullscreen}
|
|
50
|
+
unstyled
|
|
45
51
|
className={cn(
|
|
46
|
-
"w-full border-0 bg-transparent text-foreground shadow-none",
|
|
52
|
+
"w-full border-0 bg-transparent text-foreground web:shadow-none",
|
|
47
53
|
isFullscreen ? "px-4 pt-4" : "min-h-[44px] py-3",
|
|
48
54
|
className
|
|
49
55
|
)}
|
|
50
|
-
style={
|
|
56
|
+
style={inputStyle}
|
|
57
|
+
underlineColorAndroid="transparent"
|
|
51
58
|
placeholder={placeholder}
|
|
52
59
|
multiline
|
|
53
60
|
editable={!disabled}
|
|
@@ -62,3 +69,12 @@ export function PromptInputTextarea({
|
|
|
62
69
|
|
|
63
70
|
return textInput;
|
|
64
71
|
}
|
|
72
|
+
|
|
73
|
+
const styles = StyleSheet.create({
|
|
74
|
+
borderless: {
|
|
75
|
+
borderWidth: 0,
|
|
76
|
+
},
|
|
77
|
+
fullscreen: {
|
|
78
|
+
paddingBottom: 100,
|
|
79
|
+
},
|
|
80
|
+
});
|
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -2,13 +2,22 @@ import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
|
2
2
|
import { useOxy } from '@oxyhq/services';
|
|
3
3
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
4
4
|
import { getTextFromContent } from '../lib/content-utils';
|
|
5
|
+
import { resolveModelId } from '../lib/catalogue';
|
|
6
|
+
import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
|
|
5
7
|
|
|
6
8
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
7
9
|
|
|
8
10
|
export interface UseAliaChatOptions {
|
|
9
11
|
/** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
|
|
10
12
|
apiUrl?: string;
|
|
11
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Alia model to use.
|
|
15
|
+
*
|
|
16
|
+
* Optional, and checked against `GET /catalogue` before a request carries it:
|
|
17
|
+
* an identifier the server no longer offers is replaced rather than sent, so a
|
|
18
|
+
* retirement does not turn into a 400 inside a consumer's app. Omitted, the
|
|
19
|
+
* build's `PREFERRED_CHAT_MODEL_ID` is what gets checked.
|
|
20
|
+
*/
|
|
12
21
|
model?: string;
|
|
13
22
|
/** App context injected as system message so Alia knows which app the user is in */
|
|
14
23
|
clientContext?: string;
|
|
@@ -43,7 +52,7 @@ function generateId(): string {
|
|
|
43
52
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
44
53
|
const {
|
|
45
54
|
apiUrl = API_URL,
|
|
46
|
-
model =
|
|
55
|
+
model = PREFERRED_CHAT_MODEL_ID,
|
|
47
56
|
clientContext,
|
|
48
57
|
accessToken: accessTokenProp,
|
|
49
58
|
} = options;
|
|
@@ -214,6 +223,40 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
214
223
|
toolInvocationsRef.current = [];
|
|
215
224
|
|
|
216
225
|
try {
|
|
226
|
+
/**
|
|
227
|
+
* This SDK stays on `/v1/chat/completions`, and that is a decision
|
|
228
|
+
* rather than an oversight — epic #139 workstream 6.
|
|
229
|
+
*
|
|
230
|
+
* Every other first-party Alia client moved to the product runtime at
|
|
231
|
+
* `POST /alia/chat`, which serves the identical handler. This one cannot
|
|
232
|
+
* follow, for two reasons that are independent of each other.
|
|
233
|
+
*
|
|
234
|
+
* **CORS.** `/v1` is the only surface with the public wildcard policy;
|
|
235
|
+
* everything else falls to the Oxy allowlist
|
|
236
|
+
* (`packages/api/src/index.ts`). Measured 2026-08-19 against the running
|
|
237
|
+
* service: an `OPTIONS /alia/chat` preflight from
|
|
238
|
+
* `https://alia.onl` and `https://console.alia.onl` is answered with a
|
|
239
|
+
* matching `access-control-allow-origin`, and one from an unlisted
|
|
240
|
+
* origin comes back with NO such header, so a browser blocks the
|
|
241
|
+
* request. `OPTIONS /v1/chat/completions` answers `*` for all three.
|
|
242
|
+
* This package is installed by apps on origins Alia does not enumerate,
|
|
243
|
+
* so the product surface would refuse them. Widening the allowlist is
|
|
244
|
+
* not the repair: the narrow policy is what makes `/alia/chat` a product
|
|
245
|
+
* surface, and it is one of the three recorded differences in
|
|
246
|
+
* `packages/api/src/routes/__tests__/v1-compatibility-surface.test.ts`.
|
|
247
|
+
*
|
|
248
|
+
* **Raw source.** `@alia.onl/sdk` publishes `src/`, so consumers' own
|
|
249
|
+
* Metro and tsc compile this line and it changes what every installed
|
|
250
|
+
* copy sends on its NEXT build — not on a release this repository
|
|
251
|
+
* controls. `docs/migration/compatibility-window.md` records the same
|
|
252
|
+
* property for the aliases: installed copies resume sending whatever
|
|
253
|
+
* their source says the moment the service is reachable.
|
|
254
|
+
*
|
|
255
|
+
* The consequence is recorded rather than hidden: `/v1/chat/completions`
|
|
256
|
+
* cannot reach its removal gate while this SDK is a consumer, so
|
|
257
|
+
* migrating it is a browser-CORS decision on the product surface, taken
|
|
258
|
+
* with the SDK's consumers enumerated.
|
|
259
|
+
*/
|
|
217
260
|
const response = await fetch(`${apiUrl}/v1/chat/completions`, {
|
|
218
261
|
method: 'POST',
|
|
219
262
|
headers: {
|
|
@@ -221,7 +264,11 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
221
264
|
Authorization: `Bearer ${token}`,
|
|
222
265
|
},
|
|
223
266
|
body: JSON.stringify({
|
|
224
|
-
|
|
267
|
+
// Resolved HERE rather than at render: this is the moment a request
|
|
268
|
+
// is about to name a model, and it is the only moment at which
|
|
269
|
+
// sending a retired identifier would actually cost anything. A
|
|
270
|
+
// catalogue that cannot be read leaves the choice alone.
|
|
271
|
+
model: await resolveModelId(apiUrl, model, token, PREFERRED_CHAT_MODEL_ID),
|
|
225
272
|
messages: apiMessages,
|
|
226
273
|
stream: true,
|
|
227
274
|
}),
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { useOxy } from '@oxyhq/services';
|
|
12
12
|
import { errorMessage } from '../lib/utils';
|
|
13
13
|
import { create } from 'zustand';
|
|
14
|
+
import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
|
|
14
15
|
|
|
15
16
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
16
17
|
|
|
@@ -21,6 +22,15 @@ export interface UseTTSOptions {
|
|
|
21
22
|
accessToken?: string;
|
|
22
23
|
voice?: 'male' | 'female';
|
|
23
24
|
tone?: 'brief' | 'chill' | 'default';
|
|
25
|
+
/**
|
|
26
|
+
* Speech model. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
|
|
27
|
+
*
|
|
28
|
+
* Not checked against `GET /catalogue`: that surface describes what a chat
|
|
29
|
+
* picker may offer and its resolver filters to `chat_visible` entries, so
|
|
30
|
+
* passing a voice identifier through it would substitute a chat model that
|
|
31
|
+
* cannot speak. See `lib/config.ts`.
|
|
32
|
+
*/
|
|
33
|
+
model?: string;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
// ============== INLINE TTS STORE ==============
|
|
@@ -52,6 +62,7 @@ const useTTSStore = create<TTSStore>((set) => ({
|
|
|
52
62
|
export function useTTS(options: UseTTSOptions = {}) {
|
|
53
63
|
const apiUrl = options.apiUrl || API_URL;
|
|
54
64
|
const voicePref = options.voice ?? 'female';
|
|
65
|
+
const voiceModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
|
|
55
66
|
const tonePref = options.tone ?? 'default';
|
|
56
67
|
|
|
57
68
|
const { oxyServices } = useOxy();
|
|
@@ -120,7 +131,21 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
120
131
|
reset();
|
|
121
132
|
}, [reset, releasePlayer]);
|
|
122
133
|
|
|
123
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Play a stored clip.
|
|
136
|
+
*
|
|
137
|
+
* `onUnplayable` is how a caller says "I can make this again". Stored audio
|
|
138
|
+
* is not permanent — `tts/` objects age out — and a link to something that is
|
|
139
|
+
* gone fails in the player, not at the request, so the only place to notice
|
|
140
|
+
* is here. Without a fallback the failure is reported; with one, the caller
|
|
141
|
+
* gets to rebuild rather than the person getting an error about a button they
|
|
142
|
+
* pressed twice.
|
|
143
|
+
*/
|
|
144
|
+
const playFromUrl = useCallback((
|
|
145
|
+
audioUrl: string,
|
|
146
|
+
_messageId: string,
|
|
147
|
+
onUnplayable?: () => void,
|
|
148
|
+
) => {
|
|
124
149
|
releasePlayer();
|
|
125
150
|
|
|
126
151
|
(async () => {
|
|
@@ -132,6 +157,10 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
132
157
|
player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
|
|
133
158
|
if (status.error) {
|
|
134
159
|
releasePlayer();
|
|
160
|
+
if (onUnplayable) {
|
|
161
|
+
onUnplayable();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
135
164
|
setError(status.error);
|
|
136
165
|
return;
|
|
137
166
|
}
|
|
@@ -144,6 +173,10 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
144
173
|
player.play();
|
|
145
174
|
setPlaybackState('playing');
|
|
146
175
|
} catch {
|
|
176
|
+
if (onUnplayable) {
|
|
177
|
+
onUnplayable();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
147
180
|
setError('Audio playback not available');
|
|
148
181
|
}
|
|
149
182
|
})();
|
|
@@ -183,44 +216,64 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
183
216
|
setActiveMessage(messageId);
|
|
184
217
|
setPlaybackState('loading');
|
|
185
218
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Ask the server to make it, and play what comes back.
|
|
221
|
+
*
|
|
222
|
+
* Hoisted out of the cached branch below so a clip that has aged out of
|
|
223
|
+
* storage can fall back to it. `playFromUrl` is called WITHOUT a fallback
|
|
224
|
+
* here: a freshly made clip that will not play is a real failure, and
|
|
225
|
+
* retrying it would be a loop.
|
|
226
|
+
*/
|
|
227
|
+
const synthesize = async () => {
|
|
228
|
+
const token = getToken();
|
|
229
|
+
if (!token) {
|
|
230
|
+
throw new Error('Not authenticated');
|
|
231
|
+
}
|
|
191
232
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
233
|
+
const response = await fetch(`${apiUrl}/v1/audio/speech`, {
|
|
234
|
+
method: 'POST',
|
|
235
|
+
headers: {
|
|
236
|
+
'Content-Type': 'application/json',
|
|
237
|
+
Authorization: `Bearer ${token}`,
|
|
238
|
+
},
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
model: voiceModel,
|
|
241
|
+
input: text,
|
|
242
|
+
voice: getTTSVoice(),
|
|
243
|
+
speed: getTTSSpeed(),
|
|
244
|
+
conversationId,
|
|
245
|
+
messageId,
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
197
248
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
body: JSON.stringify({
|
|
205
|
-
model: 'alia-v1-voice',
|
|
206
|
-
input: text,
|
|
207
|
-
voice: getTTSVoice(),
|
|
208
|
-
speed: getTTSSpeed(),
|
|
209
|
-
conversationId,
|
|
210
|
-
messageId,
|
|
211
|
-
}),
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
if (!response.ok) {
|
|
215
|
-
if (response.status === 504) {
|
|
216
|
-
throw new Error('Request timed out — please try again');
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
if (response.status === 504) {
|
|
251
|
+
throw new Error('Request timed out — please try again');
|
|
252
|
+
}
|
|
253
|
+
const errData = await response.json().catch(() => ({}));
|
|
254
|
+
throw new Error(errData.error?.message || errData.error || 'TTS failed');
|
|
217
255
|
}
|
|
218
|
-
|
|
219
|
-
|
|
256
|
+
|
|
257
|
+
const data = await response.json();
|
|
258
|
+
playFromUrl(data.audioUrl, messageId);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* A stored clip plays straight away, and is remade if it has gone.
|
|
263
|
+
*
|
|
264
|
+
* The row keeps its key long after a `tts/` object ages out of storage,
|
|
265
|
+
* so the link is signed, valid, and points at nothing — a failure only
|
|
266
|
+
* the player ever sees. Remaking it is cheaper than keeping every clip
|
|
267
|
+
* forever, and the person notices nothing.
|
|
268
|
+
*/
|
|
269
|
+
if (audioUrl) {
|
|
270
|
+
playFromUrl(audioUrl, messageId, () => {
|
|
271
|
+
void synthesize().catch((e: unknown) => setError(errorMessage(e, 'Failed to read aloud')));
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
220
274
|
}
|
|
221
275
|
|
|
222
|
-
|
|
223
|
-
playFromUrl(data.audioUrl, messageId);
|
|
276
|
+
await synthesize();
|
|
224
277
|
} catch (e: unknown) {
|
|
225
278
|
console.error('[TTS] Error:', e);
|
|
226
279
|
setError(errorMessage(e, 'Failed to read aloud'));
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { useOxy } from '@oxyhq/services';
|
|
21
21
|
import { errorMessage } from '../lib/utils';
|
|
22
22
|
import type { RoomState, AgentState, VoiceMessage, VoiceToolInvocation } from '../types';
|
|
23
|
+
import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
|
|
23
24
|
|
|
24
25
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
25
26
|
|
|
@@ -33,6 +34,23 @@ export interface UseVoiceRoomOptions {
|
|
|
33
34
|
apiUrl?: string;
|
|
34
35
|
voicePreference?: 'male' | 'female';
|
|
35
36
|
accessToken?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Voice session model. Defaults to the build's `PREFERRED_VOICE_MODEL_ID`.
|
|
39
|
+
*
|
|
40
|
+
* Not checked against `GET /catalogue`, for the reason given in
|
|
41
|
+
* `lib/config.ts`: the chat resolver filters to `chat_visible` entries and
|
|
42
|
+
* would substitute something that cannot hold a voice session.
|
|
43
|
+
*/
|
|
44
|
+
model?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The agent this session belongs to, when it is a thread with one.
|
|
47
|
+
*
|
|
48
|
+
* Without it the session is ordinary Alia: the API composes the generic
|
|
49
|
+
* prompt and the identity guard says the model's name, so an agent's voice
|
|
50
|
+
* answers as Alia. It is the caller's to supply because only the screen knows
|
|
51
|
+
* whose thread is open.
|
|
52
|
+
*/
|
|
53
|
+
agentId?: string;
|
|
36
54
|
}
|
|
37
55
|
|
|
38
56
|
// ============== TITLE TAG UTILITIES ==============
|
|
@@ -105,6 +123,8 @@ function micDeviceErrorMessage(name: string): string | null {
|
|
|
105
123
|
export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
|
|
106
124
|
const apiUrl = options.apiUrl || API_URL;
|
|
107
125
|
const voicePref = options.voicePreference ?? 'female';
|
|
126
|
+
const voiceModel = options.model ?? PREFERRED_VOICE_MODEL_ID;
|
|
127
|
+
const agentId = options.agentId;
|
|
108
128
|
|
|
109
129
|
const [roomState, setRoomState] = useState<RoomState>('disconnected');
|
|
110
130
|
const [agentState, setAgentState] = useState<AgentState>('idle');
|
|
@@ -329,8 +349,12 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
|
|
|
329
349
|
Authorization: `Bearer ${token}`,
|
|
330
350
|
},
|
|
331
351
|
body: JSON.stringify({
|
|
332
|
-
model:
|
|
352
|
+
model: voiceModel,
|
|
333
353
|
voice: voicePref === 'male' ? 'echo' : 'nova',
|
|
354
|
+
// Omitted rather than sent as undefined: the API branches on the key
|
|
355
|
+
// being a string, and a session with no agent must look exactly as it
|
|
356
|
+
// did before this existed.
|
|
357
|
+
...(agentId ? { agentId } : {}),
|
|
334
358
|
}),
|
|
335
359
|
});
|
|
336
360
|
|
|
@@ -408,7 +432,7 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
|
|
|
408
432
|
setRoomState('error');
|
|
409
433
|
cleanup();
|
|
410
434
|
}
|
|
411
|
-
}, [getToken, cleanup, handleDataMessage, apiUrl, voicePref]);
|
|
435
|
+
}, [getToken, cleanup, handleDataMessage, apiUrl, voicePref, voiceModel, agentId]);
|
|
412
436
|
|
|
413
437
|
// ============== DISCONNECT ==============
|
|
414
438
|
|
package/src/index.ts
CHANGED
|
@@ -9,8 +9,8 @@ export { AliaChatScreen } from './components/AliaChatScreen';
|
|
|
9
9
|
export type { AliaChatScreenProps } from './components/AliaChatScreen';
|
|
10
10
|
|
|
11
11
|
// ── Chat UI components ──
|
|
12
|
-
export {
|
|
13
|
-
export type {
|
|
12
|
+
export { IdentityMark } from './components/IdentityMark';
|
|
13
|
+
export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
|
|
14
14
|
export { ThinkingIndicator } from './components/ThinkingIndicator';
|
|
15
15
|
export { AliaMarkdown } from './components/Markdown';
|
|
16
16
|
export type { AliaMarkdownProps } from './components/Markdown';
|
|
@@ -29,6 +29,19 @@ export type { UseTTSOptions } from './hooks/useTTS';
|
|
|
29
29
|
export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
|
|
30
30
|
export type { UseSTTOptions } from './hooks/useSpeechToText';
|
|
31
31
|
|
|
32
|
+
// ── Model catalogue (GET /catalogue) ──
|
|
33
|
+
// Exported so a consumer building its own picker reads the surface the SDK
|
|
34
|
+
// reads, rather than hardcoding an identifier the way this package used to.
|
|
35
|
+
export {
|
|
36
|
+
fetchCatalogue,
|
|
37
|
+
parseCatalogue,
|
|
38
|
+
resolveSelection,
|
|
39
|
+
resolveModelId,
|
|
40
|
+
clearCatalogueCache,
|
|
41
|
+
} from './lib/catalogue';
|
|
42
|
+
export type { CatalogueEntry, CatalogueEntryKind, ModelSelection } from './lib/catalogue';
|
|
43
|
+
export { PREFERRED_CHAT_MODEL_ID, PREFERRED_VOICE_MODEL_ID } from './lib/config';
|
|
44
|
+
|
|
32
45
|
// ── Types ──
|
|
33
46
|
export type {
|
|
34
47
|
ChatMessage,
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model catalogue, as the SDK consumes it (`GET /catalogue`, epic #139
|
|
3
|
+
* workstream 5).
|
|
4
|
+
*
|
|
5
|
+
* Every surface used to hardcode an `alia-*` identifier as its default —
|
|
6
|
+
* `useAliaChat` sent `alia-v1`, `useTTS` and `useVoiceRoom` sent `alia-v1-voice`
|
|
7
|
+
* — which meant a retired identifier became a 400 in a consumer's app with
|
|
8
|
+
* nothing the consumer could do about it, since the string was baked into the
|
|
9
|
+
* package they installed. This module is how the SDK asks the server what it
|
|
10
|
+
* offers instead.
|
|
11
|
+
*
|
|
12
|
+
* ## Deliberately not React, and deliberately not react-query
|
|
13
|
+
*
|
|
14
|
+
* `packages/app` reads the same surface through `useCatalogue`, a react-query
|
|
15
|
+
* hook. The SDK cannot copy that: it declares `@tanstack/react-query` as a
|
|
16
|
+
* dependency but imports it in **zero** modules today — measured — so a consumer
|
|
17
|
+
* mounting `<AliaChatSheet>` is not required to have a `QueryClientProvider`
|
|
18
|
+
* anywhere above it. Introducing one here would turn "the model default came
|
|
19
|
+
* from the server" into a breaking change for every consumer, which is a bad
|
|
20
|
+
* trade for a fallback.
|
|
21
|
+
*
|
|
22
|
+
* Resolution therefore happens where it matters — inside `send`, at the moment a
|
|
23
|
+
* request is about to name a model — rather than at render. A module-level cache
|
|
24
|
+
* keeps that from becoming a fetch per message.
|
|
25
|
+
*
|
|
26
|
+
* ## The parsing rules are the app's, because the contract is the same one
|
|
27
|
+
*
|
|
28
|
+
* Two rules, both about not inventing (see `packages/app/lib/hooks/use-catalogue.ts`,
|
|
29
|
+
* which states them at length):
|
|
30
|
+
*
|
|
31
|
+
* - An entry whose `object` is neither known value is DROPPED. Defaulting it to
|
|
32
|
+
* either kind would break ADR 0003 invariant 1 in one direction or the other.
|
|
33
|
+
* - A response whose entries all fail to parse THROWS rather than reading as an
|
|
34
|
+
* empty catalogue, because "no models" and "we could not read the models"
|
|
35
|
+
* look identical to every caller below and one of them means "offer nothing".
|
|
36
|
+
*
|
|
37
|
+
* This file is a second implementation of that contract rather than a shared
|
|
38
|
+
* one, and that is forced rather than chosen: `@alia.onl/sdk` ships as raw
|
|
39
|
+
* source, so it cannot depend on an unpublished workspace package — a consumer's
|
|
40
|
+
* Metro would fail to resolve it. The three Node-side clients (Codea, Cowork,
|
|
41
|
+
* the CLI) share one instead.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** A product-owned policy that picks a model per request, or a reference to one named model. */
|
|
45
|
+
export type CatalogueEntryKind = 'routing_profile' | 'model';
|
|
46
|
+
|
|
47
|
+
export interface CatalogueEntry {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly kind: CatalogueEntryKind;
|
|
50
|
+
readonly displayName: string;
|
|
51
|
+
readonly description: string;
|
|
52
|
+
/** Product policy: whether a chat picker surfaces this entry. */
|
|
53
|
+
readonly chatVisible: boolean;
|
|
54
|
+
/** The server states this as a claim; an entry that omits it is not called unavailable. */
|
|
55
|
+
readonly unavailable: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type JsonObject = Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
function asObject(value: unknown): JsonObject | null {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
62
|
+
? (value as JsonObject)
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function asText(value: unknown): string | null {
|
|
67
|
+
return typeof value === 'string' ? value : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseEntry(value: unknown): CatalogueEntry | null {
|
|
71
|
+
const raw = asObject(value);
|
|
72
|
+
if (raw === null) return null;
|
|
73
|
+
|
|
74
|
+
const id = asText(raw.id);
|
|
75
|
+
const displayName = asText(raw.display_name);
|
|
76
|
+
if (id === null || displayName === null) return null;
|
|
77
|
+
|
|
78
|
+
if (raw.object !== 'model' && raw.object !== 'routing_profile') return null;
|
|
79
|
+
const availability = asObject(raw.availability) ?? {};
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
id,
|
|
83
|
+
kind: raw.object,
|
|
84
|
+
displayName,
|
|
85
|
+
description: asText(raw.description) ?? '',
|
|
86
|
+
chatVisible: raw.chat_visible === true,
|
|
87
|
+
unavailable: availability.status === 'unavailable',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Turn a catalogue response into entries, or throw. */
|
|
92
|
+
export function parseCatalogue(payload: unknown): CatalogueEntry[] {
|
|
93
|
+
const body = asObject(payload);
|
|
94
|
+
const data = body === null ? null : body.data;
|
|
95
|
+
if (!Array.isArray(data)) throw new Error('The model catalogue response could not be read.');
|
|
96
|
+
|
|
97
|
+
const entries: CatalogueEntry[] = [];
|
|
98
|
+
for (const value of data) {
|
|
99
|
+
const entry = parseEntry(value);
|
|
100
|
+
if (entry !== null) entries.push(entry);
|
|
101
|
+
}
|
|
102
|
+
if (data.length > 0 && entries.length === 0) {
|
|
103
|
+
throw new Error('The model catalogue response could not be read.');
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ModelSelection {
|
|
109
|
+
/** What the caller asked for, which is what a picker keeps showing as chosen. */
|
|
110
|
+
readonly requestedId: string;
|
|
111
|
+
/** What a request should carry. Differs from `requestedId` only when replaced. */
|
|
112
|
+
readonly effectiveId: string;
|
|
113
|
+
/** `replaced` when the requested identifier is not one the catalogue offers. */
|
|
114
|
+
readonly source: 'requested' | 'replaced';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a requested identifier against the catalogue.
|
|
119
|
+
*
|
|
120
|
+
* The same three deliberate non-behaviours as the app's `resolveSelection`:
|
|
121
|
+
*
|
|
122
|
+
* - **No catalogue — not loaded, or the request failed — leaves the choice
|
|
123
|
+
* alone.** Replacing on missing data changes the model under the user on any
|
|
124
|
+
* slow cold start, which is the same wrong answer as a retirement.
|
|
125
|
+
* - **An entry reported UNAVAILABLE is still honoured.** That is a health
|
|
126
|
+
* signal about the models behind an entry and the server already falls back
|
|
127
|
+
* among them; picking a different one is a product decision nobody made.
|
|
128
|
+
* - **`preferredId` is never trusted.** It is a build-time value, so it is
|
|
129
|
+
* checked against the catalogue like any other and falls through when the
|
|
130
|
+
* catalogue does not offer it.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveSelection(
|
|
133
|
+
requestedId: string,
|
|
134
|
+
entries: readonly CatalogueEntry[] | undefined,
|
|
135
|
+
preferredId?: string,
|
|
136
|
+
): ModelSelection {
|
|
137
|
+
if (entries === undefined) {
|
|
138
|
+
return { requestedId, effectiveId: requestedId, source: 'requested' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const offered = entries.filter((entry) => entry.chatVisible);
|
|
142
|
+
if (offered.some((entry) => entry.id === requestedId)) {
|
|
143
|
+
return { requestedId, effectiveId: requestedId, source: 'requested' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const replacement =
|
|
147
|
+
(preferredId === undefined
|
|
148
|
+
? undefined
|
|
149
|
+
: offered.find((entry) => entry.id === preferredId)) ??
|
|
150
|
+
offered.find((entry) => !entry.unavailable) ??
|
|
151
|
+
offered[0];
|
|
152
|
+
if (replacement === undefined) {
|
|
153
|
+
return { requestedId, effectiveId: requestedId, source: 'requested' };
|
|
154
|
+
}
|
|
155
|
+
return { requestedId, effectiveId: replacement.id, source: 'replaced' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The catalogue for one API base URL, fetched at most once per URL.
|
|
160
|
+
*
|
|
161
|
+
* The promise is cached rather than the result, so concurrent callers share one
|
|
162
|
+
* request. A REJECTED promise is evicted, because caching a failure would make a
|
|
163
|
+
* single cold-start network blip permanent for the life of the process.
|
|
164
|
+
*/
|
|
165
|
+
const inFlight = new Map<string, Promise<CatalogueEntry[]>>();
|
|
166
|
+
|
|
167
|
+
export function fetchCatalogue(apiUrl: string, accessToken?: string): Promise<CatalogueEntry[]> {
|
|
168
|
+
const cached = inFlight.get(apiUrl);
|
|
169
|
+
if (cached !== undefined) return cached;
|
|
170
|
+
|
|
171
|
+
const request = (async () => {
|
|
172
|
+
const response = await fetch(`${apiUrl}/catalogue`, {
|
|
173
|
+
headers: accessToken === undefined ? {} : { Authorization: `Bearer ${accessToken}` },
|
|
174
|
+
});
|
|
175
|
+
if (!response.ok) throw new Error(`The model catalogue request failed (${response.status}).`);
|
|
176
|
+
return parseCatalogue(await response.json());
|
|
177
|
+
})();
|
|
178
|
+
|
|
179
|
+
inFlight.set(apiUrl, request);
|
|
180
|
+
request.catch(() => inFlight.delete(apiUrl));
|
|
181
|
+
return request;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Drop the cache. Exported for consumers that switch API base URL or sign a different user in. */
|
|
185
|
+
export function clearCatalogueCache(): void {
|
|
186
|
+
inFlight.clear();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The identifier a request should carry, given what the caller asked for.
|
|
191
|
+
*
|
|
192
|
+
* Never throws: a catalogue that cannot be read leaves the requested identifier
|
|
193
|
+
* alone, which is the same answer as "the catalogue has not loaded yet". A
|
|
194
|
+
* consumer that wants to KNOW the catalogue is unreadable calls
|
|
195
|
+
* {@link fetchCatalogue} directly.
|
|
196
|
+
*/
|
|
197
|
+
export async function resolveModelId(
|
|
198
|
+
apiUrl: string,
|
|
199
|
+
requestedId: string,
|
|
200
|
+
accessToken?: string,
|
|
201
|
+
preferredId?: string,
|
|
202
|
+
): Promise<string> {
|
|
203
|
+
try {
|
|
204
|
+
const entries = await fetchCatalogue(apiUrl, accessToken);
|
|
205
|
+
return resolveSelection(requestedId, entries, preferredId).effectiveId;
|
|
206
|
+
} catch {
|
|
207
|
+
return requestedId;
|
|
208
|
+
}
|
|
209
|
+
}
|