@alia.onl/sdk 7.2.9 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,41 @@
1
1
  # @alia.onl/sdk
2
2
 
3
- ## 7.2.9
3
+ ## 10.0.0
4
+
5
+ ### Peers: `@oxy.so/core` `^3.0.0`, `@oxy.so/services` `^8.0.0`
6
+
7
+ **Breaking.** Core 3 is the namespaced Oxy client (`oxyServices.session`,
8
+ `oxyServices.users`, …): the SDK now reads the session token as
9
+ `oxyServices.session.accessToken` instead of `getAccessToken()` /
10
+ `httpService`, so it runs only on a host with core 3 and services 8 — one app
11
+ holds one Oxy SDK. Nothing in the SDK's own API changes.
12
+
13
+ ### `AliaMarkdown` builds its parser once
14
+
15
+ `react-native-markdown-display` evaluates its `markdownit`,
16
+ `allowedImageHandlers` and `topLevelMaxExceededItem` defaults on every render,
17
+ so each render of each block built a Markdown parser and rebuilt its renderer
18
+ and styles. `AliaMarkdown` now passes one shared parser and those defaults'
19
+ own values, created once. Output is unchanged; a 1,000-message thread holds
20
+ 7 MiB less heap in the app.
21
+
22
+ ### Voice errors have codes
23
+
24
+ Additive. `useSpeechToText` returns `errorCode` beside `error`, and
25
+ `useVoiceRoom` returns `errorCode` and `turnErrorCode` beside `error` and
26
+ `turnError`: a `VoiceErrorCode` (exported from both entries with
27
+ `VOICE_ERROR_MESSAGES`, the English copy) so an app can say what went wrong in
28
+ its own language. The English strings are unchanged.
29
+
30
+ `VoiceControls` takes `labels` (defaults in `VOICE_CONTROLS_LABELS`) for every
31
+ word it shows, and its mute and end buttons are named buttons for screen
32
+ readers, mute as a toggle.
33
+
34
+ `useSpeechToText` unmounted mid-dictation now also clears `useSTTStore`. It used
35
+ to abort the recognizer but leave the store reading `isRecording: true`, which
36
+ `useAmbientWave` then drew as dictation for as long as the page lived.
37
+
38
+ ## 9.0.0
4
39
 
5
40
  ### The full-screen chat clears the gesture bar and the keyboard
6
41
 
@@ -12,11 +47,112 @@ measures its own position in the window (keyboard-controller's
12
47
  already ends above the inset, the inset is not added on top of the keyboard. The
13
48
  avoiding view `PromptInput` carries measured its parent-relative layout and so
14
49
  never lifted inside the screen. The web `KeyboardAvoidingView` accepts and
15
- ignores `automaticOffset`.
16
-
17
- `AliaChatScreen` is a full-screen route: render it where no tab bar or other
18
- bottom chrome is drawn. Its keyboard lift needs the app wrapped in
19
- keyboard-controller's `KeyboardProvider` (re-exported by this package).
50
+ ignores `automaticOffset`. Also released for 7.x as 7.2.9.
51
+
52
+ ### Real models, and no model identifier in the package
53
+
54
+ Alia has no models of its own: `GET /catalogue` now lists the real models Oxy
55
+ serves (`publisher/model`), with the server's `defaultModelId` and
56
+ `featuredIds`. The SDK follows it, and the product modes and routing profiles
57
+ (`mode:*`, `route:*`) are gone from it.
58
+
59
+ **Breaking:**
60
+
61
+ - `PREFERRED_CHAT_MODEL_ID` and `PREFERRED_VOICE_MODEL_ID` are removed. With no
62
+ `model`, `useAliaChat`, the voice turn sender, `useTTS` and `useVoiceRoom` send
63
+ no `model` and the server's default (chat or speech) answers.
64
+ - `parseCatalogue` and `fetchCatalogue` return a `Catalogue`
65
+ (`{ entries, defaultModelId, featuredIds }`), and `CatalogueEntry` is the new
66
+ model shape (`name`, `publisher`, `contextWindow`, `reasoningEfforts`,
67
+ `pricing`, …). `CatalogueEntryKind` is removed.
68
+ - `resolveSelection(requestedId, catalogue)` and `resolveModelId(apiUrl,
69
+ requestedId?)` take no preferred id; an identifier the catalogue no longer
70
+ lists resolves to `undefined` (omit `model`) instead of a substitute.
71
+ - The `alia.model_switch` stream event is no longer accepted; the server no
72
+ longer sends it.
73
+
74
+ **Added:** `AliaMarkdown` takes `renderCodeBlock` to draw fenced code with the
75
+ host's own code card (the Alia app passes Bloom's `CodeBlock`).
76
+
77
+ ### The consumer-backend path is documented
78
+
79
+ Documentation only; no code, wire or default changed, and no version is cut for
80
+ it.
81
+
82
+ The package now has a README. It says which endpoint `useAliaChat` calls and
83
+ why it is `/v1/chat/completions` rather than `/alia/chat`: this package ships
84
+ raw source and compiles into apps on origins Alia does not enumerate, and
85
+ `/alia/chat` answers CORS preflights only for Alia's own origins. It then
86
+ documents the path that works from any origin today — `useAliaChat({ apiUrl })`
87
+ pointed at the consumer's own backend, which calls `POST /alia/chat`
88
+ server-to-server, forwards the user's Oxy token, and streams the SSE body back
89
+ unchanged — with a minimal relay. The `apiUrl` option's JSDoc says the same in
90
+ short. Issue #244 records the two other shapes and why neither is available
91
+ yet.
92
+
93
+ ## 8.0.0
94
+
95
+ ### Voice runs on the device
96
+
97
+ Dictation and voice calls work again. Both used Alia endpoints that called
98
+ speech providers directly — `POST /v1/voice/transcribe` and a LiveKit room from
99
+ `POST /v1/voice/token` — and both have been refusing since Alia's inference
100
+ moved behind Oxy, which serves chat and speech synthesis but no transcription or
101
+ realtime session. Those endpoints are removed from the API.
102
+
103
+ - **`useSpeechToText`** recognizes on the device (Web Speech API on web,
104
+ `expo-speech-recognition` on iOS/Android). Its shape is unchanged —
105
+ `startRecording`, `stopAndTranscribe`, `cancel`, the three states,
106
+ `useSTTStore` metering — and it gains `lang` and `isSupported`.
107
+ - **`useVoiceRoom`** is a turn loop: on-device listening with end-of-utterance
108
+ detection, each utterance sent through the chat path (`sendTurn`, by default
109
+ `createAliaVoiceTurnSender`, which posts `/v1/chat/completions` with
110
+ `responseMode: 'voice'`), the answer spoken sentence by sentence through
111
+ `/v1/audio/speech`, and barge-in. `connect`, `disconnect`, `roomState`,
112
+ `agentState`, `messages`, `isMuted`, `toggleMute` and `error` keep their
113
+ meaning; it adds `turnError` and the options `sendTurn`, `chatModel`, `lang`,
114
+ `endOfUtteranceMs` and `bargeIn`.
115
+ - The call asks `/v1/audio/speech` for the product voices `male` / `female`;
116
+ it no longer sends upstream voice names.
117
+
118
+ **Breaking:**
119
+
120
+ - `livekit-client` is no longer a peer dependency; `expo-speech-recognition`
121
+ is a new one (the root entry's dictation reaches it on native), and a native
122
+ app needs a new build with its config plugin.
123
+ - `useVoiceRoom().room` is a `VoiceLevelSource` (live capture and playback
124
+ levels), not a LiveKit `Room`; `useAudioLevelMonitor` takes that.
125
+ - `livekit-client` is gone from the package entirely; nothing in either entry
126
+ reaches it (`check:entries` asserts that).
127
+ - The cohost is removed. `useVoiceRoom` no longer returns `cohostActive`,
128
+ `roundComplete`, `enableCohost`, `disableCohost` or `continueCohost`, and
129
+ `currentSpeaker` is `'primary' | 'user' | null`. `VoiceControls` no longer
130
+ takes `cohostActive`, `currentSpeaker`, `roundComplete`, `onEnableCohost`,
131
+ `onDisableCohost` or `onContinueCohost`, and draws no cohost button or
132
+ "Continue conversation" prompt. `ChatMessage.speaker` and
133
+ `VoiceMessage.speaker` are `'primary'` only, and the message list draws no
134
+ "Cohost" label.
135
+ - `useSpeechToText` no longer takes `apiUrl` or `accessToken`; dictation never
136
+ talks to the Alia API. Pass `lang` or nothing.
137
+ - The pre-Bloom composer is no longer public. `PromptInput`,
138
+ `PromptInputTextarea`, `PromptInputActions`, `PromptInputSubmitButton`,
139
+ `PromptInputMicButton`, `PromptInputAddMenu`, `PromptInputAttachments`,
140
+ `PromptInputAutocomplete`, `PromptInputContext`, `usePromptInput`,
141
+ `useIsFullscreen`, `ChatTextInput` and the types `PromptInputProps`,
142
+ `PromptInputContextType`, `Attachment` and `Completion` are removed from the
143
+ root entry. `AliaChatScreen` / `AliaChatSheet` still draw their composer;
144
+ build your own from the primitives if you composed one from these parts.
145
+ - Dependencies trimmed: `@tanstack/react-query`,
146
+ `@tanstack/react-query-persist-client` and
147
+ `@tanstack/query-async-storage-persister` are no longer dependencies (no
148
+ module imported them), and `socket.io-client` and `expo-font` are no longer
149
+ peer dependencies (likewise unused). Keep them in your app if your app uses
150
+ them itself.
151
+
152
+ **Server side, alongside this release:** the `alia_sk_*` developer keys are
153
+ retired and refused by the Alia API. The SDK never used them — it sends the
154
+ signed-in user's Oxy session — but a backend that relays the SDK's requests
155
+ with such a key must forward the user's Oxy token instead (see the README).
20
156
 
21
157
  ## 7.2.8
22
158
 
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # @alia.onl/sdk
2
+
3
+ Alia's chat SDK for React Native and Expo apps built on Oxy: the chat sheet and
4
+ screen, the streaming hook, speech in and out, and the primitives they are made
5
+ of. It renders inside the one `OxyProvider` your app already mounts and sends
6
+ the signed-in user's Oxy session with every request; it never holds a token of
7
+ its own.
8
+
9
+ The package ships **raw TypeScript source** — `main`, `types` and every
10
+ `exports` entry point at `src/`. Your bundler compiles it with the rest of your
11
+ app, which is why the section on endpoints below matters more than it would for
12
+ a prebuilt library.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add @alia.onl/sdk
18
+ ```
19
+
20
+ `react`, `react-native`, `@oxy.so/core`, `@oxy.so/services` and the Expo modules
21
+ listed under `peerDependencies` in `package.json` are yours to install. Metro
22
+ must resolve package `exports` (`resolver.unstable_enablePackageExports = true`,
23
+ the default in Expo SDK 53+), because every icon import is a subpath.
24
+
25
+ Two entry points:
26
+
27
+ - `@alia.onl/sdk` — text chat, dictation, read-aloud, catalogue, primitives.
28
+ - `@alia.onl/sdk/voice` — `VoiceSession` and `useVoiceRoom`, the voice call.
29
+ Pass `VoiceSession` to the shell as `voiceSession` to offer calls; leave it
30
+ out for text-only chat and the call is never compiled.
31
+
32
+ ## Speech
33
+
34
+ Dictation (`useSpeechToText`) and the voice call (`useVoiceRoom`) recognize
35
+ speech **on the device**: the Web Speech API in browsers that have it (Chromium
36
+ and Safari — not Firefox, where `useSpeechToText().isSupported` is `false`), and
37
+ [`expo-speech-recognition`](https://github.com/jamsch/expo-speech-recognition)
38
+ on iOS and Android. No audio is sent to Alia; whether the platform's recognizer
39
+ uses a network service of its own is the platform's (Chrome's does).
40
+
41
+ On native, add `expo-speech-recognition` (a peer dependency, like `expo-audio`:
42
+ the root entry's mic button reaches it) and its config plugin to your app, then
43
+ make a new native build:
44
+
45
+ ```json
46
+ ["expo-speech-recognition", {
47
+ "microphonePermission": "Allow $(PRODUCT_NAME) to hear what you dictate and say in voice calls.",
48
+ "speechRecognitionPermission": "Allow $(PRODUCT_NAME) to turn your speech into text."
49
+ }]
50
+ ```
51
+
52
+ A voice call is a turn loop, not a realtime session: it listens until the
53
+ person pauses, sends what they said through the ordinary chat path
54
+ (`POST /v1/chat/completions` with `responseMode: "voice"`, or your own
55
+ `sendTurn`), and speaks the answer sentence by sentence through
56
+ `POST /v1/audio/speech` in the product voice (`male` / `female`). Talking over
57
+ the answer interrupts it. Pass `lang` to both hooks to follow your app's
58
+ language.
59
+
60
+ ## Use
61
+
62
+ ```tsx
63
+ import { AliaChatScreen } from '@alia.onl/sdk';
64
+
65
+ export function Assistant() {
66
+ return <AliaChatScreen />;
67
+ }
68
+ ```
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
+
76
+ Or the hook on its own, with your own UI:
77
+
78
+ ```tsx
79
+ import { useAliaChat } from '@alia.onl/sdk';
80
+
81
+ const { messages, send, isStreaming, stop, error } = useAliaChat({
82
+ clientContext: 'The user is looking at their order history.',
83
+ });
84
+ ```
85
+
86
+ `useAliaChat` streams a turn as Server-Sent Events and switches on Alia's
87
+ product events — `alia.reasoning`, `alia.tool_result`, `alia.research_progress`,
88
+ `alia.plan_preview`, delegated-agent answers — into `ChatMessage` fields your
89
+ components render. Those events are the product runtime's, not part of the Oxy
90
+ generic inference contract, and this package is written against them on
91
+ purpose: it is a **product** client.
92
+
93
+ ## Which endpoint the SDK calls, and why
94
+
95
+ The hook sends its turns to `POST {apiUrl}/v1/chat/completions`, with `apiUrl`
96
+ defaulting to `EXPO_PUBLIC_ALIA_API_URL` or `https://api.alia.onl`. It also
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.
101
+
102
+ That is the compatibility surface, not the product route. `POST /alia/chat` is
103
+ the same handler with the same authentication, and it is where Alia's own
104
+ clients go — but the two mounts answer browsers differently:
105
+
106
+ | Preflight | `access-control-allow-origin` |
107
+ | --- | --- |
108
+ | `OPTIONS /alia/chat` from `https://alia.onl` or `https://console.alia.onl` | that origin, with credentials |
109
+ | `OPTIONS /alia/chat` from an origin Alia does not enumerate | **absent** |
110
+ | `OPTIONS /v1/chat/completions` from anywhere | `*` |
111
+
112
+ `/alia/chat` takes Alia's exact-origin allowlist (`packages/api/src/lib/cors-origins.ts`);
113
+ `/v1` is public CORS. Because this package compiles into *your* app on *your*
114
+ origin, defaulting the hook to `/alia/chat` would fail every consumer's
115
+ preflight on web. So it stays on `/v1/chat/completions`, which is Alia's
116
+ **permanent product API** — it does not sunset
117
+ ([ADR 0010](https://github.com/OxyHQ/Alia/blob/main/docs/adr/0010-alia-keeps-a-product-api-credentials-come-from-oxy-console.md)).
118
+ Generic model access is a different product, Kaana through Oxy; this package
119
+ is written against Alia's, and the credential a consumer presents is an Oxy
120
+ one — the signed-in user's session today. A change of default to `/alia/chat`
121
+ would be a semver-major of this package, would reach only consumers who upgrade
122
+ **and** rebuild, and is not needed to escape a sunset; it is taken only if the
123
+ per-application origin policy below makes it worthwhile
124
+ ([#244](https://github.com/OxyHQ/Alia/issues/244), closed on that decision).
125
+
126
+ Native apps are not subject to CORS at all — React Native sends no `Origin`
127
+ header — so the table above is about web builds and the backend path below.
128
+
129
+ ## Using the SDK from your own product: the consumer-backend path
130
+
131
+ The supported way for an application outside Alia's origins to use this SDK
132
+ today is to put your own backend between the SDK and Alia. Your backend
133
+ receives the SDK's request, calls Alia's product route server-to-server — a
134
+ server sends no `Origin` header, so `/alia/chat` answers it on its credential
135
+ alone — and streams the SSE body back unchanged. Then point the hook at that
136
+ backend:
137
+
138
+ ```tsx
139
+ useAliaChat({ apiUrl: 'https://your-backend.example' });
140
+ ```
141
+
142
+ The hook keeps its paths, so your backend answers
143
+ `POST /v1/chat/completions` and `GET /catalogue` under whatever base URL you
144
+ give it. Nothing about the stream changes: the SDK's parser reads the bytes Alia
145
+ wrote.
146
+
147
+ **Which credential.** The SDK attaches the signed-in user's Oxy access token as
148
+ `Authorization: Bearer …` on the request your backend receives. Forward that
149
+ header as it is. `POST /alia/chat` authenticates with
150
+ `authenticateTokenOrApiKey` (`packages/api/src/routes/chat.ts`,
151
+ `packages/api/src/middleware/auth.ts`), which accepts an Oxy session token —
152
+ verified with Oxy, the same way it would be if the browser had called Alia
153
+ directly — and the turn is metered to that user's Alia entitlement, the same as
154
+ a direct call. The other credentials the middleware recognises are
155
+ Alia-internal. There is no consumer-application credential for this route yet — that is Oxy Applications' to issue (OxyHQ/oxy#972).
156
+
157
+ A minimal relay, Node 18+ and Express:
158
+
159
+ ```ts
160
+ import express from 'express';
161
+
162
+ const ALIA = 'https://api.alia.onl';
163
+ const app = express();
164
+ app.use(express.json({ limit: '1mb' }));
165
+
166
+ /** Relay one request to Alia and stream the answer back unchanged. */
167
+ async function relay(
168
+ req: express.Request,
169
+ res: express.Response,
170
+ method: 'GET' | 'POST',
171
+ path: string,
172
+ ): Promise<void> {
173
+ const controller = new AbortController();
174
+ req.on('close', () => controller.abort());
175
+
176
+ const authorization = req.get('authorization');
177
+ const upstream = await fetch(`${ALIA}${path}`, {
178
+ method,
179
+ headers: {
180
+ ...(authorization ? { Authorization: authorization } : {}),
181
+ Accept: req.get('accept') ?? 'application/json',
182
+ ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
183
+ },
184
+ body: method === 'POST' ? JSON.stringify(req.body) : undefined,
185
+ signal: controller.signal,
186
+ });
187
+
188
+ res.status(upstream.status);
189
+ res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'application/json');
190
+ res.setHeader('Cache-Control', 'no-cache');
191
+ res.setHeader('X-Accel-Buffering', 'no'); // keep proxies from buffering the SSE stream
192
+ res.flushHeaders();
193
+
194
+ if (upstream.body === null) {
195
+ res.end();
196
+ return;
197
+ }
198
+ try {
199
+ for await (const chunk of upstream.body) res.write(chunk);
200
+ } catch (error) {
201
+ if (!controller.signal.aborted) throw error;
202
+ } finally {
203
+ res.end();
204
+ }
205
+ }
206
+
207
+ // The SDK's turn: answered by Alia's PRODUCT route, server-to-server.
208
+ app.post('/v1/chat/completions', (req, res) => relay(req, res, 'POST', '/alia/chat'));
209
+
210
+ // The SDK's model check. Without this the hook still works — an unreadable
211
+ // catalogue leaves the requested identifier alone — but it never learns what
212
+ // the server offers.
213
+ app.get('/catalogue', (req, res) => relay(req, res, 'GET', '/catalogue'));
214
+
215
+ app.listen(3000);
216
+ ```
217
+
218
+ The request body the SDK sends is `{ model?, messages, stream: true }` and the
219
+ response Alia writes is `text/event-stream`; the relay forwards both without
220
+ reading them. CORS between your web app and your backend is yours to configure,
221
+ as it would be for any of your own routes.
222
+
223
+ **The trade-off.** This is one more hop, and one more process in the path of a
224
+ stream. Some consumers will not want it, which is why it is documented as the
225
+ path that works today rather than chosen as the only one. The other two shapes
226
+ — a CORS policy on `/alia/chat` for registered consumer origins, and the SDK
227
+ default moving to `/alia/chat` in a major — are recorded in #244: the first is
228
+ blocked on Oxy Applications carrying an origin registry (OxyHQ/oxy#972), and
229
+ the second is an adoption window rather than a switch, for the raw-source
230
+ reason above.
231
+
232
+ ## Scripts
233
+
234
+ ```sh
235
+ bun run typecheck # tsc --noEmit
236
+ bun run test # vitest
237
+ bun run check:entries # the root entry must not reach the voice call
238
+ ```
239
+
240
+ ## Licence
241
+
242
+ Apache-2.0. See `LICENSE` and `NOTICE`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "7.2.9",
3
+ "version": "10.0.0",
4
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",
@@ -31,9 +31,6 @@
31
31
  "dependencies": {
32
32
  "@radix-ui/react-dropdown-menu": "^2.1.20",
33
33
  "@radix-ui/react-icons": "^1.3.2",
34
- "@tanstack/query-async-storage-persister": "^5.101.2",
35
- "@tanstack/react-query": "^5.102.8",
36
- "@tanstack/react-query-persist-client": "^5.102.8",
37
34
  "class-variance-authority": "^0.7.1",
38
35
  "clsx": "^2.1.1",
39
36
  "lucide-react-native": "^1.24.0",
@@ -45,25 +42,23 @@
45
42
  "peerDependencies": {
46
43
  "react": "^19.2.3",
47
44
  "react-native": "^0.86.0",
48
- "@oxy.so/core": "^1.0.1",
49
- "@oxy.so/services": "^1.0.1",
45
+ "@oxy.so/core": "^3.0.0",
46
+ "@oxy.so/services": "^8.0.0",
50
47
  "@react-native-async-storage/async-storage": "^2.2.0",
51
- "socket.io-client": "^4.8.3",
52
- "expo-font": "^57.0.0",
53
48
  "expo-blur": "^57.0.0",
54
49
  "react-native-reanimated": "^4.5.0",
55
50
  "react-native-gesture-handler": "^2.32.0",
56
51
  "react-native-safe-area-context": "^5.8.0",
57
52
  "react-native-keyboard-controller": "^1.21.9",
58
- "react-native-svg": "^15.15.4",
59
- "livekit-client": "^2.20.1",
53
+ "react-native-svg": "^15.15.5",
60
54
  "expo-audio": "^57.0.0",
61
55
  "expo-image": "^57.0.0",
62
56
  "expo-image-picker": "^57.0.2",
63
57
  "expo-document-picker": "^57.0.0",
64
58
  "expo-haptics": "^57.0.0",
65
59
  "nativewind": "^5.0.0-preview.3",
66
- "expo-clipboard": "^57.0.0"
60
+ "expo-clipboard": "^57.0.0",
61
+ "expo-speech-recognition": "^57.1.0"
67
62
  },
68
63
  "peerDependenciesMeta": {
69
64
  "nativewind": {
@@ -77,6 +72,7 @@
77
72
  "@types/react-test-renderer": "^19.1.0",
78
73
  "react-test-renderer": "19.2.3",
79
74
  "typescript": "^5.9.3",
80
- "vitest": "^4.1.10"
75
+ "vitest": "^4.1.10",
76
+ "expo-speech-recognition": "~57.1.0"
81
77
  }
82
78
  }
@@ -286,7 +286,6 @@ export function AliaChatContent({
286
286
  onStop={stop}
287
287
  emptyAction={voiceActivateButton}
288
288
  placeholder="Ask Alia..."
289
- apiUrl={apiUrl}
290
289
  autocomplete={!!useSuggestions}
291
290
  useSuggestions={useSuggestions}
292
291
  onSuggestionUsed={onSuggestionUsed}
@@ -337,10 +337,6 @@ const AssistantMessage = React.memo(function AssistantMessage({
337
337
  {/* Message Content */}
338
338
  {(hasContent || message.isStreaming) && (
339
339
  <View className="flex-col items-start gap-0.5">
340
- {/* Voice cohost label */}
341
- {message.source === 'voice' && message.speaker === 'cohost' && (
342
- <Text className="text-xs text-indigo-400 mb-0.5">Cohost</Text>
343
- )}
344
340
  {/* Agent identity */}
345
341
  {message.agentInfo && (
346
342
  <View className="flex-row items-center gap-2 mb-0.5">
@@ -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
@@ -23,6 +23,9 @@ const PHASE_LABELS: Record<string, string> = {
23
23
  follow_up: 'Follow-up research',
24
24
  finalizing: 'Polishing report',
25
25
  complete: 'Research complete',
26
+ // The engine finished searching but could not write the final answer; the
27
+ // message carries what was found, and this label keeps the card honest.
28
+ failed: 'Research incomplete',
26
29
  };
27
30
 
28
31
  const PHASE_ORDER = ['decomposing', 'searching', 'synthesizing', 'follow_up', 'finalizing', 'complete'];
@@ -8,12 +8,11 @@ import { usePromptInput } from "./context";
8
8
 
9
9
  export type PromptInputMicButtonProps = {
10
10
  className?: string;
11
- apiUrl?: string;
12
11
  };
13
12
 
14
- export function PromptInputMicButton({ className, apiUrl }: PromptInputMicButtonProps) {
13
+ export function PromptInputMicButton({ className }: PromptInputMicButtonProps) {
15
14
  const { value, setValue, onError } = usePromptInput();
16
- const stt = useSpeechToText({ apiUrl });
15
+ const stt = useSpeechToText();
17
16
 
18
17
  useEffect(() => {
19
18
  if (stt.error) {