@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 +142 -6
- package/README.md +242 -0
- package/package.json +8 -12
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- package/src/components/ResearchProgressCard.tsx +3 -0
- package/src/components/ui/prompt-input/mic-button.tsx +2 -3
- package/src/components/ui/prompt-input/prompt-input.tsx +1 -4
- package/src/components/voice/VoiceControls.tsx +48 -63
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +24 -10
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +196 -147
- package/src/hooks/useTTS.ts +33 -38
- package/src/hooks/useVoiceRoom.ts +581 -416
- package/src/index.ts +6 -19
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +14 -2
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +76 -0
- package/src/lib/speech-recognition-types.ts +108 -0
- package/src/lib/speech-recognition.native.ts +226 -0
- package/src/lib/speech-recognition.ts +288 -0
- package/src/lib/speech-synthesis.ts +122 -0
- package/src/lib/voice-levels.ts +60 -0
- package/src/lib/voice-text.ts +192 -0
- package/src/lib/voice-turn.ts +86 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +11 -3
- package/src/lib/config.ts +0 -36
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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": "
|
|
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": "^
|
|
49
|
-
"@oxy.so/services": "^
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
-
|
|
331
|
-
|
|
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 <
|
|
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
|
|
13
|
+
export function PromptInputMicButton({ className }: PromptInputMicButtonProps) {
|
|
15
14
|
const { value, setValue, onError } = usePromptInput();
|
|
16
|
-
const stt = useSpeechToText(
|
|
15
|
+
const stt = useSpeechToText();
|
|
17
16
|
|
|
18
17
|
useEffect(() => {
|
|
19
18
|
if (stt.error) {
|