@alia.onl/sdk 8.0.0 → 10.0.1

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,6 +1,87 @@
1
1
  # @alia.onl/sdk
2
2
 
3
- ## Unreleased
3
+ ## 10.0.1
4
+
5
+ ### `@oxy.so/services` 9 is admitted
6
+
7
+ The `@oxy.so/services` peer is now `^8.0.0 || ^9.0.0`. Services 9.0.0 changes
8
+ only its Bloom peer (to Bloom 5); the one thing this package takes from it,
9
+ `useOxy`, is the same in both, so an app can move to Bloom 5 without this
10
+ package refusing the services it needs. No code changed.
11
+
12
+ ## 10.0.0
13
+
14
+ ### Peers: `@oxy.so/core` `^3.0.0`, `@oxy.so/services` `^8.0.0`
15
+
16
+ **Breaking.** Core 3 is the namespaced Oxy client (`oxyServices.session`,
17
+ `oxyServices.users`, …): the SDK now reads the session token as
18
+ `oxyServices.session.accessToken` instead of `getAccessToken()` /
19
+ `httpService`, so it runs only on a host with core 3 and services 8 — one app
20
+ holds one Oxy SDK. Nothing in the SDK's own API changes.
21
+
22
+ ### `AliaMarkdown` builds its parser once
23
+
24
+ `react-native-markdown-display` evaluates its `markdownit`,
25
+ `allowedImageHandlers` and `topLevelMaxExceededItem` defaults on every render,
26
+ so each render of each block built a Markdown parser and rebuilt its renderer
27
+ and styles. `AliaMarkdown` now passes one shared parser and those defaults'
28
+ own values, created once. Output is unchanged; a 1,000-message thread holds
29
+ 7 MiB less heap in the app.
30
+
31
+ ### Voice errors have codes
32
+
33
+ Additive. `useSpeechToText` returns `errorCode` beside `error`, and
34
+ `useVoiceRoom` returns `errorCode` and `turnErrorCode` beside `error` and
35
+ `turnError`: a `VoiceErrorCode` (exported from both entries with
36
+ `VOICE_ERROR_MESSAGES`, the English copy) so an app can say what went wrong in
37
+ its own language. The English strings are unchanged.
38
+
39
+ `VoiceControls` takes `labels` (defaults in `VOICE_CONTROLS_LABELS`) for every
40
+ word it shows, and its mute and end buttons are named buttons for screen
41
+ readers, mute as a toggle.
42
+
43
+ `useSpeechToText` unmounted mid-dictation now also clears `useSTTStore`. It used
44
+ to abort the recognizer but leave the store reading `isRecording: true`, which
45
+ `useAmbientWave` then drew as dictation for as long as the page lived.
46
+
47
+ ## 9.0.0
48
+
49
+ ### The full-screen chat clears the gesture bar and the keyboard
50
+
51
+ `AliaChatScreen` padded only the top safe-area inset, so on edge-to-edge
52
+ Android its composer sat under the gesture bar (OxyHQ/Mention#1140). It now pads
53
+ the bottom inset too, and wraps the chat in a keyboard-avoiding view that
54
+ measures its own position in the window (keyboard-controller's
55
+ `automaticOffset`): the composer rises onto the keyboard, and because that view
56
+ already ends above the inset, the inset is not added on top of the keyboard. The
57
+ avoiding view `PromptInput` carries measured its parent-relative layout and so
58
+ never lifted inside the screen. The web `KeyboardAvoidingView` accepts and
59
+ ignores `automaticOffset`. Also released for 7.x as 7.2.9.
60
+
61
+ ### Real models, and no model identifier in the package
62
+
63
+ Alia has no models of its own: `GET /catalogue` now lists the real models Oxy
64
+ serves (`publisher/model`), with the server's `defaultModelId` and
65
+ `featuredIds`. The SDK follows it, and the product modes and routing profiles
66
+ (`mode:*`, `route:*`) are gone from it.
67
+
68
+ **Breaking:**
69
+
70
+ - `PREFERRED_CHAT_MODEL_ID` and `PREFERRED_VOICE_MODEL_ID` are removed. With no
71
+ `model`, `useAliaChat`, the voice turn sender, `useTTS` and `useVoiceRoom` send
72
+ no `model` and the server's default (chat or speech) answers.
73
+ - `parseCatalogue` and `fetchCatalogue` return a `Catalogue`
74
+ (`{ entries, defaultModelId, featuredIds }`), and `CatalogueEntry` is the new
75
+ model shape (`name`, `publisher`, `contextWindow`, `reasoningEfforts`,
76
+ `pricing`, …). `CatalogueEntryKind` is removed.
77
+ - `resolveSelection(requestedId, catalogue)` and `resolveModelId(apiUrl,
78
+ requestedId?)` take no preferred id; an identifier the catalogue no longer
79
+ lists resolves to `undefined` (omit `model`) instead of a substitute.
80
+ - The `alia.model_switch` stream event is no longer accepted; the server no
81
+ longer sends it.
82
+
83
+ **Added:** `AliaMarkdown` takes `renderCodeBlock` to draw fenced code with the
84
+ host's own code card (the Alia app passes Bloom's `CodeBlock`).
4
85
 
5
86
  ### The consumer-backend path is documented
6
87
 
package/README.md CHANGED
@@ -67,6 +67,12 @@ export function Assistant() {
67
67
  }
68
68
  ```
69
69
 
70
+ `AliaChatScreen` is a full-screen route: it pads itself by the top and bottom
71
+ safe-area insets, so render it where no tab bar or other bottom chrome is drawn.
72
+ On iOS and Android its composer rises with the keyboard through
73
+ `react-native-keyboard-controller`, which needs the app wrapped in its
74
+ `KeyboardProvider` (re-exported here).
75
+
70
76
  Or the hook on its own, with your own UI:
71
77
 
72
78
  ```tsx
@@ -88,8 +94,10 @@ purpose: it is a **product** client.
88
94
 
89
95
  The hook sends its turns to `POST {apiUrl}/v1/chat/completions`, with `apiUrl`
90
96
  defaulting to `EXPO_PUBLIC_ALIA_API_URL` or `https://api.alia.onl`. It also
91
- reads `GET {apiUrl}/catalogue` once per base URL, to check the requested model
92
- identifier against what the server offers.
97
+ reads `GET {apiUrl}/catalogue` once per base URL when a `model` is passed, to
98
+ check that `publisher/model` against the real models the server offers. With no
99
+ `model`, the request carries none and the server's default model answers — the
100
+ SDK ships no model identifier of its own.
93
101
 
94
102
  That is the compatibility surface, not the product route. `POST /alia/chat` is
95
103
  the same handler with the same authentication, and it is where Alia's own
@@ -207,7 +215,7 @@ app.get('/catalogue', (req, res) => relay(req, res, 'GET', '/catalogue'));
207
215
  app.listen(3000);
208
216
  ```
209
217
 
210
- The request body the SDK sends is `{ model, messages, stream: true }` and the
218
+ The request body the SDK sends is `{ model?, messages, stream: true }` and the
211
219
  response Alia writes is `text/event-stream`; the relay forwards both without
212
220
  reading them. CORS between your web app and your backend is yours to configure,
213
221
  as it would be for any of your own routes.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "8.0.0",
4
- "description": "Alia AI chat SDK \u2014 reusable chat UI, voice, and streaming components for Oxy apps",
3
+ "version": "10.0.1",
4
+ "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
7
7
  "source": "src/index.ts",
@@ -42,8 +42,8 @@
42
42
  "peerDependencies": {
43
43
  "react": "^19.2.3",
44
44
  "react-native": "^0.86.0",
45
- "@oxy.so/core": "^1.7.0",
46
- "@oxy.so/services": "^3.0.0 || ^4.0.0",
45
+ "@oxy.so/core": "^3.0.0",
46
+ "@oxy.so/services": "^8.0.0 || ^9.0.0",
47
47
  "@react-native-async-storage/async-storage": "^2.2.0",
48
48
  "expo-blur": "^57.0.0",
49
49
  "react-native-reanimated": "^4.5.0",
@@ -20,6 +20,7 @@
20
20
  import React from 'react';
21
21
  import { View, Text, TouchableOpacity } from 'react-native';
22
22
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
23
+ import { KeyboardAvoidingView } from '../lib/keyboard';
23
24
  import { AliaChatContent } from './AliaChatContent';
24
25
  import { IdentityMark } from './IdentityMark';
25
26
  import type { WelcomeSuggestion } from './AliaWelcomeMessage';
@@ -65,36 +66,51 @@ export function AliaChatScreen({
65
66
  }: AliaChatScreenProps) {
66
67
  const insets = useSafeAreaInsets();
67
68
 
69
+ // The screen owns the whole window, so it keeps its composer out of both
70
+ // system bars: the top inset clears the status bar, the bottom one the
71
+ // gesture bar or home indicator an edge-to-edge app draws over.
72
+ //
73
+ // The keyboard is handled INSIDE the bottom inset. The avoiding view measures
74
+ // its own frame in the window (`automaticOffset`), and that frame already
75
+ // ends `insets.bottom` above the window's edge, so the padding it adds is the
76
+ // keyboard's height minus the inset: the composer sits on the keyboard, not
77
+ // one inset above it. (Its parent-relative layout would read as zero overlap
78
+ // this deep in a host's tree, which is why the offset is measured.)
68
79
  return (
69
- <View className="flex-1 bg-background" style={{ paddingTop: insets.top }}>
70
- <AliaChatContent
71
- clientContext={clientContext}
72
- model={model}
73
- apiUrl={apiUrl}
74
- welcomeGreeting={welcomeGreeting}
75
- welcomeSubtitle={welcomeSubtitle}
76
- welcomeSuggestions={welcomeSuggestions}
77
- primaryColor={primaryColor}
78
- isDarkMode={isDarkMode}
79
- voiceSession={voiceSession}
80
- header={({ markState, hasMessages, clear }) => (
81
- <View className="flex-row items-center justify-between px-4 py-3">
82
- <View className="flex-row items-center gap-2.5">
83
- {headerLeft}
84
- <IdentityMark size={28} state={markState} spinOnPress />
85
- <Text className="text-lg font-semibold text-foreground">Alia</Text>
80
+ <View
81
+ className="flex-1 bg-background"
82
+ style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
83
+ >
84
+ <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
85
+ <AliaChatContent
86
+ clientContext={clientContext}
87
+ model={model}
88
+ apiUrl={apiUrl}
89
+ welcomeGreeting={welcomeGreeting}
90
+ welcomeSubtitle={welcomeSubtitle}
91
+ welcomeSuggestions={welcomeSuggestions}
92
+ primaryColor={primaryColor}
93
+ isDarkMode={isDarkMode}
94
+ voiceSession={voiceSession}
95
+ header={({ markState, hasMessages, clear }) => (
96
+ <View className="flex-row items-center justify-between px-4 py-3">
97
+ <View className="flex-row items-center gap-2.5">
98
+ {headerLeft}
99
+ <IdentityMark size={28} state={markState} spinOnPress />
100
+ <Text className="text-lg font-semibold text-foreground">Alia</Text>
101
+ </View>
102
+ <View className="flex-row items-center gap-2">
103
+ {hasMessages && (
104
+ <TouchableOpacity onPress={clear} className="px-2.5 py-1.5">
105
+ <Text className="text-sm text-muted-foreground">Clear</Text>
106
+ </TouchableOpacity>
107
+ )}
108
+ {headerRight}
109
+ </View>
86
110
  </View>
87
- <View className="flex-row items-center gap-2">
88
- {hasMessages && (
89
- <TouchableOpacity onPress={clear} className="px-2.5 py-1.5">
90
- <Text className="text-sm text-muted-foreground">Clear</Text>
91
- </TouchableOpacity>
92
- )}
93
- {headerRight}
94
- </View>
95
- </View>
96
- )}
97
- />
111
+ )}
112
+ />
113
+ </KeyboardAvoidingView>
98
114
  </View>
99
115
  );
100
116
  }
@@ -203,6 +203,9 @@ export function IdentityMark({
203
203
  // button would promise an action it does not have.
204
204
  accessibilityRole={onPress === undefined ? 'image' : 'button'}
205
205
  accessibilityLabel={accessibilityLabel}
206
+ // Nor a Tab stop: on web a Pressable is focusable by default, so the
207
+ // flourish was an unlabelled stop in the keyboard order that did nothing.
208
+ focusable={onPress !== undefined}
206
209
  className={className}
207
210
  style={box}
208
211
  >
@@ -1,6 +1,6 @@
1
1
  import React, { useMemo, type ReactNode } from 'react';
2
2
  import { View, Text, Platform, Linking, useColorScheme } from 'react-native';
3
- import Markdown, { type ASTNode } from 'react-native-markdown-display';
3
+ import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
4
4
  import type { AliaColors } from '../theme';
5
5
 
6
6
  // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
@@ -24,10 +24,32 @@ const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
24
24
  const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
25
25
  const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
26
26
 
27
- function createRules(colors: AliaColors) {
27
+ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
28
28
  const { text: textColor, muted, border, primary, mutedForeground } = colors;
29
29
 
30
+ /**
31
+ * Fenced and indented code, drawn by the host when it brings a code card of
32
+ * its own (the app passes Bloom's `CodeBlock`). This package has no Bloom
33
+ * dependency, so it cannot name one itself; without the prop the library's
34
+ * default `fence` / `code_block` rendering, styled below, stays.
35
+ */
36
+ const codeRules = renderCodeBlock === undefined
37
+ ? {}
38
+ : {
39
+ fence: (node: ASTNode) => (
40
+ <View key={node.key} style={{ marginVertical: 8 }}>
41
+ {renderCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
42
+ </View>
43
+ ),
44
+ code_block: (node: ASTNode) => (
45
+ <View key={node.key} style={{ marginVertical: 8 }}>
46
+ {renderCodeBlock(node.content.replace(/\n$/, ''), undefined)}
47
+ </View>
48
+ ),
49
+ };
50
+
30
51
  return {
52
+ ...codeRules,
31
53
  heading1: (node: ASTNode, children: ReactNode[]) => (
32
54
  <Text
33
55
  key={node.key}
@@ -249,6 +271,16 @@ function createStyles(colors: AliaColors, fontFamily: string | undefined) {
249
271
  };
250
272
  }
251
273
 
274
+ /** Draws one block of code: its source, and the fence's language when it named one. */
275
+ export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
276
+
277
+ /** The fence's info string up to the first space (```ts title=x → `ts`). */
278
+ function codeLanguage(node: ASTNode): string | undefined {
279
+ const info = (node as ASTNode & { sourceInfo?: string }).sourceInfo?.trim() ?? '';
280
+ const language = info.split(/\s+/)[0];
281
+ return language === '' ? undefined : language;
282
+ }
283
+
252
284
  export interface AliaMarkdownProps {
253
285
  /**
254
286
  * The sans family for body text, as a SINGLE family name — RN's `fontFamily`
@@ -263,6 +295,12 @@ export interface AliaMarkdownProps {
263
295
  fontFamily?: string;
264
296
  content: string;
265
297
  colors?: Partial<AliaColors>;
298
+ /**
299
+ * Draw fenced code with the host's own code card. Keep the function's
300
+ * identity stable (a module-level function or a `useCallback`): it is part of
301
+ * the rules' memo, and a new one per render re-parses every block.
302
+ */
303
+ renderCodeBlock?: RenderCodeBlock;
266
304
  }
267
305
 
268
306
  const FENCE_RE = /^\s*(?:```|~~~)/;
@@ -327,21 +365,64 @@ type MarkdownBlockProps = {
327
365
  styles: ReturnType<typeof createStyles>;
328
366
  };
329
367
 
330
- const MarkdownBlock = React.memo(function MarkdownBlock({ content, rules, styles }: MarkdownBlockProps) {
331
- return <Markdown rules={rules} style={styles}>{content}</Markdown>;
332
- });
368
+ /**
369
+ * `react-native-markdown-display` gives three of its props DEFAULT VALUES that
370
+ * are new objects on every render: `markdownit = MarkdownIt({ typographer:
371
+ * true })`, `allowedImageHandlers = [...]` and `topLevelMaxExceededItem =
372
+ * <Text>`. A default parameter is evaluated per call, so each render of each
373
+ * `<Markdown>` built a parser (compiling linkify-it's regexes), and the two
374
+ * others sit in the deps of its renderer's `useMemo`, so the renderer — and
375
+ * the `StyleSheet.create` of every style inside it — was rebuilt every render
376
+ * too. A streamed answer re-renders its last block per token, and opening a
377
+ * thread renders every block once: both paid for it. Passing the defaults'
378
+ * own values, created once, makes both memos hold. The parser is shared:
379
+ * `parse` keeps no state between calls.
380
+ *
381
+ * The typings omit the last two props, which the component does take.
382
+ */
383
+ const PARSER = MarkdownIt({ typographer: true });
384
+ const IMAGE_HANDLERS = [
385
+ 'data:image/png;base64',
386
+ 'data:image/gif;base64',
387
+ 'data:image/jpeg;base64',
388
+ 'https://',
389
+ 'http://',
390
+ ];
391
+ const TOP_LEVEL_EXCEEDED = <Text key="dotdotdot">...</Text>;
392
+ const StableMarkdown = Markdown as React.ComponentType<
393
+ React.ComponentProps<typeof Markdown> & {
394
+ allowedImageHandlers?: string[];
395
+ topLevelMaxExceededItem?: ReactNode;
396
+ }
397
+ >;
398
+
399
+ function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
400
+ return (
401
+ <StableMarkdown
402
+ rules={rules}
403
+ style={styles}
404
+ markdownit={PARSER}
405
+ allowedImageHandlers={IMAGE_HANDLERS}
406
+ topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
407
+ >
408
+ {content}
409
+ </StableMarkdown>
410
+ );
411
+ }
412
+
413
+ const MarkdownBlock = React.memo(ParsedMarkdown);
333
414
 
334
- export function AliaMarkdown({ content, colors: colorOverrides, fontFamily }: AliaMarkdownProps) {
415
+ export function AliaMarkdown({ content, colors: colorOverrides, fontFamily, renderCodeBlock }: AliaMarkdownProps) {
335
416
  const scheme = useColorScheme();
336
417
  const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
337
418
  const colors = { ...fallback, ...colorOverrides } as AliaColors;
338
419
 
339
- const customRules = useMemo(() => createRules(colors), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground]);
420
+ const customRules = useMemo(() => createRules(colors, renderCodeBlock), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, renderCodeBlock]);
340
421
  const markdownStyles = useMemo(() => createStyles(colors, fontFamily), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily]);
341
422
  const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
342
423
 
343
424
  if (blocks.length === 1) {
344
- return <Markdown rules={customRules} style={markdownStyles}>{blocks[0]}</Markdown>;
425
+ return <ParsedMarkdown content={blocks[0]} rules={customRules} styles={markdownStyles} />;
345
426
  }
346
427
 
347
428
  // Each block's `body` rule cancels its own trailing paragraph margin
@@ -9,7 +9,32 @@ import MicOff from 'lucide-react-native/icons/mic-off';
9
9
  import PhoneOff from 'lucide-react-native/icons/phone-off';
10
10
  import type { RoomState, AgentState } from '../../types';
11
11
 
12
- interface VoiceControlsProps {
12
+ /** Every word the bar shows or announces. An app in another language passes its own. */
13
+ export interface VoiceControlsLabels {
14
+ connecting: string;
15
+ connected: string;
16
+ listening: string;
17
+ muted: string;
18
+ thinking: string;
19
+ speaking: string;
20
+ mute: string;
21
+ unmute: string;
22
+ end: string;
23
+ }
24
+
25
+ export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
26
+ connecting: 'Connecting...',
27
+ connected: 'Connected',
28
+ listening: 'Listening...',
29
+ muted: 'Muted',
30
+ thinking: 'Thinking...',
31
+ speaking: 'Speaking...',
32
+ mute: 'Mute',
33
+ unmute: 'Unmute',
34
+ end: 'End',
35
+ };
36
+
37
+ export interface VoiceControlsProps {
13
38
  roomState: RoomState;
14
39
  agentState: AgentState;
15
40
  isMuted: boolean;
@@ -17,19 +42,22 @@ interface VoiceControlsProps {
17
42
  onEnd: () => void;
18
43
  /** Override theme primary color (resolved hex/hsl value, not CSS var) */
19
44
  primaryColor?: string;
45
+ /** Overrides for any of {@link VOICE_CONTROLS_LABELS}. */
46
+ labels?: Partial<VoiceControlsLabels>;
20
47
  }
21
48
 
22
49
  function getStatusText(
23
50
  roomState: RoomState,
24
51
  agentState: AgentState,
25
52
  isMuted: boolean,
53
+ labels: VoiceControlsLabels,
26
54
  ): string {
27
- if (roomState === 'connecting') return 'Connecting...';
55
+ if (roomState === 'connecting') return labels.connecting;
28
56
  if (roomState === 'connected') {
29
- if (agentState === 'listening') return isMuted ? 'Muted' : 'Listening...';
30
- if (agentState === 'thinking') return 'Thinking...';
31
- if (agentState === 'speaking') return 'Speaking...';
32
- return 'Connected';
57
+ if (agentState === 'listening') return isMuted ? labels.muted : labels.listening;
58
+ if (agentState === 'thinking') return labels.thinking;
59
+ if (agentState === 'speaking') return labels.speaking;
60
+ return labels.connected;
33
61
  }
34
62
  return '';
35
63
  }
@@ -41,13 +69,15 @@ export function VoiceControls({
41
69
  onToggleMute,
42
70
  onEnd,
43
71
  primaryColor,
72
+ labels: labelOverrides,
44
73
  }: VoiceControlsProps) {
45
- const statusText = getStatusText(roomState, agentState, isMuted);
74
+ const labels = { ...VOICE_CONTROLS_LABELS, ...labelOverrides };
75
+ const statusText = getStatusText(roomState, agentState, isMuted, labels);
46
76
 
47
77
  return (
48
78
  <View style={styles.container}>
49
79
  {statusText ? (
50
- <Text className="text-lg font-medium mb-4 text-foreground">
80
+ <Text className="text-lg font-medium mb-4 text-foreground" accessibilityLiveRegion="polite" aria-live="polite">
51
81
  {statusText}
52
82
  </Text>
53
83
  ) : null}
@@ -57,6 +87,10 @@ export function VoiceControls({
57
87
  <View style={styles.buttonWrapper}>
58
88
  <Pressable
59
89
  onPress={onToggleMute}
90
+ accessibilityRole="button"
91
+ accessibilityLabel={labels.mute}
92
+ accessibilityState={{ selected: isMuted }}
93
+ aria-pressed={isMuted}
60
94
  className={isMuted ? undefined : 'bg-muted'}
61
95
  style={[styles.button, isMuted ? { backgroundColor: '#ef4444' } : undefined]}
62
96
  >
@@ -67,18 +101,20 @@ export function VoiceControls({
67
101
  )}
68
102
  </Pressable>
69
103
  <Text className="text-xs text-muted-foreground">
70
- {isMuted ? 'Unmute' : 'Mute'}
104
+ {isMuted ? labels.unmute : labels.mute}
71
105
  </Text>
72
106
  </View>
73
107
 
74
108
  <View style={styles.buttonWrapper}>
75
109
  <Pressable
76
110
  onPress={onEnd}
111
+ accessibilityRole="button"
112
+ accessibilityLabel={labels.end}
77
113
  style={[styles.button, { backgroundColor: '#ef4444' }]}
78
114
  >
79
115
  <PhoneOff size={24} color="white" />
80
116
  </Pressable>
81
- <Text className="text-xs text-muted-foreground">End</Text>
117
+ <Text className="text-xs text-muted-foreground">{labels.end}</Text>
82
118
  </View>
83
119
  </View>
84
120
  )}
@@ -3,7 +3,6 @@ import { useOxy } from '@oxy.so/services';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
  import { getTextFromContent } from '../lib/content-utils';
5
5
  import { resolveModelId } from '../lib/catalogue';
6
- import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
7
6
  import { streamAliaChat } from '../lib/chat-transport';
8
7
  import type { AliaChatStreamEvent } from '../lib/chat-stream';
9
8
 
@@ -28,12 +27,12 @@ export interface UseAliaChatOptions {
28
27
  */
29
28
  apiUrl?: string;
30
29
  /**
31
- * Kaana routing profile to use.
30
+ * The model to answer with, as `publisher/model` from `GET /catalogue`.
32
31
  *
33
- * Optional, and checked against `GET /catalogue` before a request carries it:
34
- * an identifier the server no longer offers is replaced rather than sent, so a
35
- * retirement does not turn into a 400 inside a consumer's app. Omitted, the
36
- * build's `PREFERRED_CHAT_MODEL_ID` is what gets checked.
32
+ * Optional, and checked against the catalogue before a request carries it: an
33
+ * identifier the server no longer offers is not sent, so a retirement does not
34
+ * turn into a 400 inside a consumer's app. Omitted, the request carries no
35
+ * `model` and the server's default model answers.
37
36
  */
38
37
  model?: string;
39
38
  /** App context injected as system message so Alia knows which app the user is in. */
@@ -115,7 +114,7 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
115
114
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
116
115
  const {
117
116
  apiUrl = API_URL,
118
- model = PREFERRED_CHAT_MODEL_ID,
117
+ model,
119
118
  clientContext,
120
119
  accessToken: accessTokenProp,
121
120
  } = options;
@@ -236,7 +235,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
236
235
  const trimmed = text.trim();
237
236
  if (!trimmed) return;
238
237
 
239
- const activeToken = oxyServices.getAccessToken();
238
+ const activeToken = oxyServices.session.accessToken;
240
239
  if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
241
240
  setError('The supplied token is not the active Oxy session.');
242
241
  return;
@@ -377,7 +376,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
377
376
 
378
377
  try {
379
378
  const effectiveModel = await waitWithAbort(
380
- resolveModelId(apiUrl, model, undefined, PREFERRED_CHAT_MODEL_ID),
379
+ resolveModelId(apiUrl, model),
381
380
  controller.signal,
382
381
  );
383
382
  await streamAliaChat(