@alia.onl/sdk 5.0.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.
@@ -13,7 +13,7 @@ const buttonVariants = cva(
13
13
  destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
14
14
  outline:
15
15
  "border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
16
- secondary: "bg-secondary web:hover:opacity-80 active:opacity-80",
16
+ secondary: "bg-muted web:hover:opacity-80 active:opacity-80",
17
17
  ghost:
18
18
  "web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
19
19
  link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
@@ -42,7 +42,7 @@ const buttonTextVariants = cva(
42
42
  destructive: "text-destructive-foreground",
43
43
  outline: "group-active:text-accent-foreground",
44
44
  secondary:
45
- "text-secondary-foreground group-active:text-secondary-foreground",
45
+ "text-muted-foreground group-active:text-muted-foreground",
46
46
  ghost: "group-active:text-accent-foreground",
47
47
  link: "text-primary group-active:underline",
48
48
  pill: "text-primary-foreground",
@@ -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] rounded-xl border border-input bg-background px-3.5 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",
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={[style, isFullscreen && { paddingBottom: 100 }]}
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
+ });
@@ -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
- /** Alia model to use (default: 'alia-v1') */
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 = 'alia-v1',
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
- model,
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
  }),
@@ -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
- const playFromUrl = useCallback((audioUrl: string, _messageId: string) => {
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
- // If cached audioUrl exists, play directly
187
- if (audioUrl) {
188
- playFromUrl(audioUrl, messageId);
189
- return;
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
- // Call backend TTS API
193
- const token = getToken();
194
- if (!token) {
195
- throw new Error('Not authenticated');
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
- const response = await fetch(`${apiUrl}/v1/audio/speech`, {
199
- method: 'POST',
200
- headers: {
201
- 'Content-Type': 'application/json',
202
- Authorization: `Bearer ${token}`,
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
- const errData = await response.json().catch(() => ({}));
219
- throw new Error(errData.error?.message || errData.error || 'TTS failed');
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
- const data = await response.json();
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: 'alia-v1-voice',
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 { AliaMark } from './components/AliaMark';
13
- export type { AliaMarkProps, AliaMarkState } from './components/AliaMark';
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,