@alia.onl/sdk 13.2.0 → 15.2.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +52 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +4 -7
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +1 -1
  8. package/src/components/AliaChatSheet.tsx +15 -4
  9. package/src/components/AliaComposer.tsx +57 -11
  10. package/src/components/voice/VoiceControls.tsx +28 -4
  11. package/src/components/voice/VoiceSession.tsx +23 -7
  12. package/src/hooks/useAliaActivity.ts +31 -0
  13. package/src/hooks/useAliaChat.ts +14 -3
  14. package/src/hooks/useAmbientWave.ts +3 -3
  15. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  16. package/src/hooks/useDictation.ts +295 -0
  17. package/src/hooks/useTTS.ts +24 -63
  18. package/src/hooks/useVoiceRoom.ts +148 -736
  19. package/src/index.ts +9 -3
  20. package/src/lib/activity-socket.ts +102 -0
  21. package/src/lib/activity-store.ts +53 -0
  22. package/src/lib/api-url.ts +2 -0
  23. package/src/lib/audio-level.ts +2 -2
  24. package/src/lib/dictation-recorder-types.ts +37 -0
  25. package/src/lib/dictation-recorder.native.ts +90 -0
  26. package/src/lib/dictation-recorder.ts +131 -0
  27. package/src/lib/dictation.ts +208 -0
  28. package/src/lib/labels.tsx +3 -0
  29. package/src/lib/pcm.ts +57 -0
  30. package/src/lib/speech-language.ts +1 -1
  31. package/src/lib/speech-messages.ts +43 -97
  32. package/src/lib/speech-synthesis.ts +5 -1
  33. package/src/lib/turn-state.ts +15 -0
  34. package/src/lib/voice-audio-engine-types.ts +59 -0
  35. package/src/lib/voice-audio-engine.native.ts +232 -0
  36. package/src/lib/voice-audio-engine.ts +350 -0
  37. package/src/lib/voice-call.ts +539 -0
  38. package/src/lib/voice-levels.ts +7 -3
  39. package/src/lib/voice-protocol.ts +309 -0
  40. package/src/types.ts +49 -4
  41. package/src/voice.ts +31 -12
  42. package/tsconfig.json +2 -0
  43. package/src/hooks/useSpeechToText.ts +0 -268
  44. package/src/lib/speech-recognition-types.ts +0 -108
  45. package/src/lib/speech-recognition.native.ts +0 -237
  46. package/src/lib/speech-recognition.ts +0 -308
  47. package/src/lib/voice-text.ts +0 -198
  48. package/src/lib/voice-turn.ts +0 -89
package/CHANGELOG.md CHANGED
@@ -1,5 +1,134 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 15.2.1
4
+
5
+ - **The shipped source compiles under `noUnusedLocals`.** The package ships its `.tsx` source, so a
6
+ consumer's own `tsc` (an app with `noUnusedLocals`/`noUnusedParameters`, such as the Oxy website)
7
+ type-checks it and failed on eight unused imports in `AliaChatMessageList` (`Pressable` and the
8
+ reanimated worklet helpers, left behind by the work-log move). They are gone, and the SDK's
9
+ tsconfig now turns the two flags on so it cannot regress.
10
+
11
+ ## 15.2.0
12
+
13
+ - **`AliaChatSheetRef.present(turn?)`.** Given a turn (`{ text, images? }`), the conversation opens
14
+ over the sheet with that message already sent: the "ask Alia about this" button of a profile, a
15
+ post or a file (the way X opens a conversation with Grok about a post). Without a turn nothing
16
+ changes. `AliaTurn` is exported.
17
+
18
+ ## 15.0.0
19
+
20
+ A voice call is a realtime speech-to-speech session through Alia and Kaana, and the composer's
21
+ microphone is transcribed by a Kaana model (Alia ADR 0017). Nothing is recognized by the browser
22
+ or the operating system any more, anywhere in the SDK.
23
+
24
+ - **Breaking: `useVoiceRoom` is a call over Alia's `GET /v1/voice/session` WebSocket.** It sends
25
+ the microphone as PCM16 24 kHz frames and plays the answer's PCM16 frames; turn detection and
26
+ barge-in are the model's, on the server, and tools, memory and the stored transcript are Alia's.
27
+ New options: `conversationId` (where the transcript is stored; omitted, each call starts a
28
+ conversation of its own), `agentId`, `voicePreference`, `lang`, `onMessagesSaved` (refresh the
29
+ thread), `onApprovalRequested`, and the test seams `createAudioEngine` / `openSocket`. Gone:
30
+ `sendTurn`, `chatModel`, `model`, `endOfUtteranceMs`, `bargeIn`, `synthesisError`, `turnError`,
31
+ `turnErrorCode`, `warning`, `warningCode`.
32
+ - **Honest states.** `callState` (`VoiceCallState`) is `connecting` → `listening` (only once the
33
+ session is ready upstream, the microphone delivered a buffer AND Alia acknowledged the first
34
+ appended audio) → `user-speaking` → `thinking` → `using-tool` → `speaking` (only when the first
35
+ answer frame is PLAYING) → … → `ended { reason }` or `error { code }`, plus `reconnecting`
36
+ while a dropped socket resumes within the session's resume window or Alia re-opens the session
37
+ upstream. `roomState` (now with `reconnecting`) and `agentState` are its coarse projections;
38
+ `endedReason` says why a call ended — `call-moved-elsewhere` (the person opened a call
39
+ elsewhere) is a reason, not an error. `endingSoonMs`, `model` (the serving model's name; never
40
+ the operator) are new.
41
+ - **Barge-in:** when the server hears the person start speaking, the playback queue is flushed and
42
+ Alia is told how many milliseconds of the answer were actually heard, so the model remembers
43
+ what was said, not what it generated.
44
+ - **The audio engine** (`createVoiceAudioEngine`, `VoiceAudioEngine`): web is `getUserMedia` with
45
+ echo cancellation, noise suppression and gain control plus two `AudioWorklet`s (capture to
46
+ 24 kHz PCM16, jitter-buffered playback) in one `AudioContext` — Firefox gets calls. iOS and
47
+ Android use **`react-native-audio-api`** (new optional peer, `^0.13.6`), capture and playback in
48
+ one native stack; iOS runs `playAndRecord` in mode `voiceChat` (voice processing). **A host app
49
+ needs a new native build** with the library's config plugin. On Android the library exposes no
50
+ `voiceCommunication` input preset yet, so platform echo cancellation is a gate to measure on
51
+ device (ADR 0017 §4).
52
+ - **Breaking: `useSpeechToText` and `useSTTStore` are replaced by `useDictation` and
53
+ `useDictationStore`.** Dictation records a clip (`MediaRecorder` on web, Opus/WebM or MP4 on
54
+ Safari; the `expo-audio` recorder on iOS/Android, m4a sent as `audio/mp4`) and transcribes it with
55
+ `POST /v1/audio/transcriptions` when the person stops. States: `idle`, `requesting-permission`,
56
+ `recording` (only once the recorder started; levels are the recorder's), `transcribing`,
57
+ `error`. The text arrives through `onText`, never partially; a failed transcription keeps the
58
+ clip for one `retry()`. Clips stop by themselves at five minutes, and `remainingMs` counts the
59
+ last 30 s. `requestTranscription` and `DictationError` are exported. Dictation works in Firefox
60
+ now.
61
+ - **`AliaComposer`** dictates with `useDictation`: the transcription is appended to the draft the
62
+ person has when it arrives, a failure is said with a Retry that re-sends the same clip, and the
63
+ time left shows over the composer in the last 30 s. New prop `apiUrl`; new label
64
+ `dictationRemaining`.
65
+ - **Breaking: `VoiceErrorCode` is rewritten.** The recognizer's codes (`speech-*`,
66
+ `microphone-*`, `recognizer-*`, `no-audio-input`, `turn-failed`, `no-answer`, `not-played`,
67
+ `synthesis-*`) are gone. It is now Alia's call codes (`not-authenticated`,
68
+ `credential-not-supported`, `plan-required`, `credits-exhausted`, `agent-unavailable`,
69
+ `invalid-request`, `resume-unavailable`, `realtime-unavailable`, `realtime-limited`,
70
+ `realtime-rejected`, `realtime-failed`), the device's (`mic-denied`, `mic-unavailable`,
71
+ `connection-lost`, `voice-unavailable`) and dictation's (`too-long`,
72
+ `transcription-unavailable`, `transcription-limited`, `transcription-rejected`,
73
+ `transcription-failed`). An app with its own translations replaces its keys.
74
+ - **Removed:** `createAliaVoiceTurnSender` and the `VoiceTurn*` types, the device recognizers
75
+ (`speech-recognition*`, `chooseSpeechRecognizer`), `isInterruption` and the rest of
76
+ `voice-text`, and the **`expo-speech-recognition` peer** — a host app removes it and its config
77
+ plugin. Read-aloud (`useTTS`, `/v1/audio/speech`) is unchanged.
78
+ - `VoiceSession` takes `conversationId`, `agentId`, `locale` and `voice`; `VoiceControls` takes
79
+ `callState` and shows `reconnecting` and `usingTool` (new labels).
80
+ - The `./voice` entry still holds the call: `scripts/check-entry-isolation.mjs` now also proves the
81
+ root entry never reaches `react-native-audio-api` (nor `expo-speech-recognition`).
82
+
83
+ ## 14.1.0
84
+
85
+ An "Ask Alia" button can show her live avatar and react to what she is doing.
86
+
87
+ - **`AliaAvatarIcon`**: a Bloom icon component (`icon={AliaAvatarIcon}`) that draws the live avatar
88
+ at the icon size under its own `AgentAvatarProvider` on `ALIA_CHARACTER_RUNTIME_URL`, not interactive.
89
+ - **`useAliaActivity()`** returns `'idle' | 'thinking' | 'working' | 'writing'`, the busiest of the
90
+ turns running in this app (`useAliaChat` now publishes its lifecycle to a shared store) and the
91
+ server's `alia:activity` events for the signed-in person (one shared `socket.io-client` connection
92
+ per API origin, none while signed out, closed with the last consumer). New dependency:
93
+ `socket.io-client`. Needs the API that emits `alia:activity` (an older API simply never sends it:
94
+ buttons then follow local turns only).
95
+ - The header mark and the buttons read a turn from one function (`turnActivityState`).
96
+
97
+ ## 14.0.0
98
+
99
+ Read-aloud asks the server every time, and the server answers a clip it already made from storage:
100
+ a message read aloud twice, or a sentence voice mode says again, is synthesized and paid for once.
101
+
102
+ - **Breaking: `useTTS().readAloud(messageId, text, conversationId?)`** loses its fourth argument,
103
+ `audioUrl`. A message no longer carries a stored clip of its own: `POST /v1/audio/speech` finds
104
+ the clip by its text, voice, speed and speech model, for the signed-in person, and answers a
105
+ stored one at once. There is no "play the stored link, remake it if it has gone" path any more.
106
+ - **Breaking: `ChatMessage.audioUrl` is removed.** `GET /conversations/:id` no longer sends it.
107
+ - `requestSpeechClip`'s `conversationId` + `messageId` now file the clip under that conversation,
108
+ so deleting or clearing the conversation deletes it. The route still answers 404 for a message
109
+ the person does not hold, and `requestId` is present only when the clip was made by that request.
110
+
111
+ A voice call says "Listening…" only while something listens.
112
+
113
+ - **`useVoiceRoom` stays `connecting` until the recognizer has started** (`roomState` is
114
+ `'connected'` and `agentState` `'listening'` from the first session's start, not from
115
+ `connect()`), and a session that does not start within 8 s ends the call as
116
+ `recognizer-not-started`.
117
+ - **An `aborted` nobody asked for is a failure.** Chrome runs one microphone recognition per
118
+ browser and a new one aborts the live one, in any tab; the engines now report that as the
119
+ failure code `interrupted` (only the abort the caller asked for stays silent), and a call cut
120
+ off three times in a row ends as `recognizer-aborted` instead of restarting forever. The web
121
+ engine serializes its own sessions, so a page never aborts itself.
122
+ - **The call watches itself while it listens:** voice-level sound for 5 s with no word back ends
123
+ it as `recognizer-unresponsive`; five sessions in a row that end without listening end it with
124
+ their error's code; a microphone silent for 10 s raises **`warning` / `warningCode`**
125
+ (`no-audio-input`) without ending the call. Recognizer errors are logged as a structured
126
+ `console.warn`, never with the transcript.
127
+ - **`VoiceErrorCode` gains** `recognizer-not-started`, `recognizer-unresponsive`,
128
+ `recognizer-aborted` and `no-audio-input` (an app with its own translations adds them);
129
+ `VoiceControls` takes a `notice` it shows in place of "Listening…". Dictation reports an
130
+ interrupted session as `recognizer-aborted` too.
131
+
3
132
  ## 13.1.1
4
133
 
5
134
  - **The avatar flights load the runtime too.** 13.1.0 mounted the character runtime provider inside
package/README.md CHANGED
@@ -53,31 +53,39 @@ Two entry points:
53
53
 
54
54
  ## Speech
55
55
 
56
- Dictation (`useSpeechToText`) and the voice call (`useVoiceRoom`) recognize
57
- speech **on the device**: the Web Speech API in browsers that have it (Chromium
58
- and Safari — not Firefox, where `useSpeechToText().isSupported` is `false`), and
59
- [`expo-speech-recognition`](https://github.com/jamsch/expo-speech-recognition)
60
- on iOS and Android. No audio is sent to Alia; whether the platform's recognizer
61
- uses a network service of its own is the platform's (Chrome's does).
62
-
63
- On native, add `expo-speech-recognition` (a peer dependency, like `expo-audio`:
64
- the root entry's mic button reaches it) and its config plugin to your app, then
65
- make a new native build:
56
+ Nothing is recognized on the device. Speech is recognized by Kaana, through
57
+ Alia (Alia ADR 0017):
58
+
59
+ - **Dictation** (`useDictation`, the composer's mic) records a clip — the
60
+ browser's `MediaRecorder`, or the `expo-audio` recorder on iOS and Android —
61
+ and, when the person stops, sends it to `POST /v1/audio/transcriptions`.
62
+ The text arrives once, never partially. Five minutes per clip. Free on every
63
+ Alia plan.
64
+ - **A voice call** (`useVoiceRoom`, `VoiceSession`) is a realtime
65
+ speech-to-speech session: a WebSocket to Alia's `GET /v1/voice/session`
66
+ carrying PCM16 24 kHz audio both ways. Turn detection and barge-in are the
67
+ model's; tools run in Alia; the transcript is stored in the conversation.
68
+ Voice calls need a paid Alia plan and use credits per minute.
69
+
70
+ Audio reaches Alia, Kaana and the model's provider; Alia logs no audio and no
71
+ transcript content.
72
+
73
+ On iOS and Android a call needs
74
+ [`react-native-audio-api`](https://github.com/software-mansion/react-native-audio-api)
75
+ (an optional peer of `@alia.onl/sdk/voice`; the root entry never reaches it)
76
+ and its config plugin, then a new native build:
66
77
 
67
78
  ```json
68
- ["expo-speech-recognition", {
69
- "microphonePermission": "Allow $(PRODUCT_NAME) to hear what you dictate and say in voice calls.",
70
- "speechRecognitionPermission": "Allow $(PRODUCT_NAME) to turn your speech into text."
79
+ ["react-native-audio-api", {
80
+ "iosMicrophonePermission": "Allow $(PRODUCT_NAME) to hear what you dictate and say in voice calls.",
81
+ "iosBackgroundMode": false,
82
+ "androidForegroundService": false,
83
+ "androidPermissions": ["android.permission.RECORD_AUDIO"]
71
84
  }]
72
85
  ```
73
86
 
74
- A voice call is a turn loop, not a realtime session: it listens until the
75
- person pauses, sends what they said through the ordinary chat path
76
- (`POST /v1/chat/completions` with `responseMode: "voice"`, or your own
77
- `sendTurn`), and speaks the answer sentence by sentence through
78
- `POST /v1/audio/speech` in the product voice (`male` / `female`). Talking over
79
- the answer interrupts it. Pass `lang` to both hooks to follow your app's
80
- language.
87
+ Dictation needs only `expo-audio` (already a peer). Pass `lang` to both hooks
88
+ (`speechLanguage` to `AliaComposer`) to follow your app's language.
81
89
 
82
90
  ## Use
83
91
 
@@ -112,6 +120,30 @@ components render. Those events are the product runtime's, not part of the Oxy
112
120
  generic inference contract, and this package is written against them on
113
121
  purpose: it is a **product** client.
114
122
 
123
+ ## An "Ask Alia" button with her live avatar
124
+
125
+ ```tsx
126
+ import { AliaAvatarIcon, useAliaActivity } from '@alia.onl/sdk';
127
+
128
+ <Button icon={AliaAvatarIcon} onPress={openAlia}>Ask Alia</Button>;
129
+ const state = useAliaActivity(); // 'idle' | 'thinking' | 'working' | 'writing'
130
+ ```
131
+
132
+ `AliaAvatarIcon` is a Bloom icon component (`BloomIconComponent`): pass it as `icon` /
133
+ `leadingIcon`. It draws the live 3D avatar at the icon size under its own
134
+ `AgentAvatarProvider` (`ALIA_CHARACTER_RUNTIME_URL`), and plays what Alia is doing.
135
+
136
+ `useAliaActivity()` is the busiest of two sources, so a button reacts even with the sheet closed:
137
+
138
+ - a turn running in this app: every `useAliaChat` publishes its lifecycle to a shared store;
139
+ - her runs for the signed-in person elsewhere (background work, another device): the API's
140
+ `alia:activity` event on the person's `user:<id>` room, over ONE Socket.IO connection per
141
+ API origin, opened with the Oxy session's token only while someone is signed in and a
142
+ consumer is mounted, closed with the last one. A run that goes quiet for 90 s is treated as over.
143
+
144
+ `socket.io-client` is a regular dependency, not a peer: the SDK owns this connection and an
145
+ app should not have to install a transport to use a button.
146
+
115
147
  ## The composer
116
148
 
117
149
  The sheet, the screen and the Alia app share one composer, `AliaComposer`: Bloom's
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "13.2.0",
4
- "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
3
+ "version": "15.2.1",
4
+ "description": "Alia AI chat SDK — reusable chat UI, voice calls, dictation 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",
@@ -32,6 +32,7 @@
32
32
  "dependencies": {
33
33
  "clsx": "^2.1.1",
34
34
  "react-native-markdown-display": "^7.0.2",
35
+ "socket.io-client": "^4.8.3",
35
36
  "tailwind-merge": "^3.7.0",
36
37
  "zustand": "^5.0.15"
37
38
  },
@@ -57,7 +58,7 @@
57
58
  "nativewind": "^5.0.0-preview.3",
58
59
  "expo-clipboard": "^57.0.0",
59
60
  "expo-file-system": "^57.0.0",
60
- "expo-speech-recognition": "^57.1.0"
61
+ "react-native-audio-api": "^0.13.6"
61
62
  },
62
63
  "peerDependenciesMeta": {
63
64
  "nativewind": {
@@ -65,6 +66,9 @@
65
66
  },
66
67
  "expo-clipboard": {
67
68
  "optional": true
69
+ },
70
+ "react-native-audio-api": {
71
+ "optional": true
68
72
  }
69
73
  },
70
74
  "devDependencies": {
@@ -72,6 +76,6 @@
72
76
  "react-test-renderer": "19.2.3",
73
77
  "typescript": "^5.9.3",
74
78
  "vitest": "^4.1.10",
75
- "expo-speech-recognition": "~57.1.0"
79
+ "react-native-audio-api": "0.13.6"
76
80
  }
77
81
  }
@@ -0,0 +1,30 @@
1
+ import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
2
+ import type { BloomIconComponent } from '@oxy.so/bloom/icons';
3
+ import { useAliaActivity } from '../hooks/useAliaActivity';
4
+ import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
5
+ import { AliaAvatar } from './AliaAvatar';
6
+
7
+ const DEFAULT_SIZE = 20;
8
+
9
+ /**
10
+ * Alia's live avatar as a Bloom icon: pass it as `icon` / `leadingIcon` to a Bloom `Button` and
11
+ * the button shows her, reacting as she thinks, works and writes (`useAliaActivity`) — also while
12
+ * the chat sheet is closed.
13
+ *
14
+ * ```tsx
15
+ * <Button icon={AliaAvatarIcon} onPress={openAlia}>Ask Alia</Button>
16
+ * ```
17
+ *
18
+ * It mounts its own `AgentAvatarProvider` on `ALIA_CHARACTER_RUNTIME_URL`: an avatar outside the
19
+ * chat's provider would ask the host for `/bloom-character/runtime.mjs` and get its HTML. The
20
+ * colour (`fill`) is ignored — the likeness is painted — and it is never interactive, so the
21
+ * button keeps the press. Reduced motion is Bloom's engine's to honour.
22
+ */
23
+ export const AliaAvatarIcon: BloomIconComponent = ({ width, height }) => {
24
+ const state = useAliaActivity();
25
+ return (
26
+ <AgentAvatarProvider runtimeUrl={ALIA_CHARACTER_RUNTIME_URL}>
27
+ <AliaAvatar size={width ?? height ?? DEFAULT_SIZE} state={state} />
28
+ </AgentAvatarProvider>
29
+ );
30
+ };
@@ -2,7 +2,7 @@
2
2
  * Shared chat content component used by both AliaChatSheet and AliaChatScreen.
3
3
  *
4
4
  * Owns the text chat; voice is an injected capability (`voiceSession`) so that
5
- * nothing here reaches `livekit-client` at module-evaluation time. Containers
5
+ * nothing here reaches the call or its audio engine at module-evaluation time. Containers
6
6
  * only handle layout chrome (sheet modal, full-screen SafeAreaView). The
7
7
  * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
8
8
  */
@@ -10,6 +10,7 @@
10
10
  import React, { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
11
11
  import { View, type TextInput } from 'react-native';
12
12
  import type { SharedValue } from 'react-native-reanimated';
13
+ import { turnActivityState } from '../lib/turn-state';
13
14
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
14
15
  import { useTTS } from '../hooks/useTTS';
15
16
  import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
@@ -377,12 +378,7 @@ function AliaChatContentBody({
377
378
  if (voiceAgentState === 'thinking') return 'thinking';
378
379
  return 'idle';
379
380
  }
380
- if (!isStreaming) return 'idle';
381
- const lastMsg = messages[messages.length - 1];
382
- if (lastMsg?.role === 'assistant' && lastMsg.toolInvocations?.some((t) => t.state === 'call'))
383
- return 'working';
384
- if (lastMsg?.thinking) return 'thinking';
385
- return 'writing';
381
+ return turnActivityState(messages, isStreaming);
386
382
  }, [messages, isStreaming, isVoiceActive, voiceAgentState]);
387
383
 
388
384
  const { user } = useOxy();
@@ -443,6 +439,7 @@ function AliaChatContentBody({
443
439
  </Suspense>
444
440
  ) : (
445
441
  <AliaComposer
442
+ apiUrl={apiUrl}
446
443
  inputRef={composerRef}
447
444
  disableKeyboardAvoidance
448
445
  onKeyPress={suggestions.onKeyPress}
@@ -1,13 +1,6 @@
1
1
  import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
- import { View, Pressable, Platform } from 'react-native';
3
- import Animated, {
4
- useSharedValue,
5
- useAnimatedStyle,
6
- withRepeat,
7
- withTiming,
8
- withSequence,
9
- cancelAnimation,
10
- } from 'react-native-reanimated';
2
+ import { View, Platform } from 'react-native';
3
+ import Animated from 'react-native-reanimated';
11
4
  import { Textarea } from '@oxy.so/bloom/textarea';
12
5
  import { TaskList } from '@oxy.so/bloom/task-list';
13
6
  import { Text } from '@oxy.so/bloom/typography';
@@ -41,7 +34,6 @@ import { AskedQuestions } from './QuestionCard';
41
34
  import { askedQuestions, isQuestionInvocation } from '../lib/questions';
42
35
  import { extractCitationSources, linkifyCitations } from '../lib/citations';
43
36
  import { chatMessageEnter } from '../animations';
44
- import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
45
37
  import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
46
38
  import type { ChatMessage } from '../types';
47
39
 
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * Voice is opt-in — pass `voiceSession={VoiceSession}` from
16
16
  * `@alia.onl/sdk/voice` to offer calls. Without it the screen is text-only and
17
- * `livekit-client` never enters the bundle.
17
+ * `react-native-audio-api` never enters the bundle.
18
18
  */
19
19
 
20
20
  import React, { useCallback } from 'react';
@@ -49,7 +49,7 @@ export interface AliaChatSheetProps {
49
49
  isDarkMode?: boolean;
50
50
  /**
51
51
  * Voice capability — pass `VoiceSession` from `@alia.onl/sdk/voice` to offer
52
- * voice calls. Omitted, the sheet is text-only and `livekit-client` never
52
+ * voice calls. Omitted, the sheet is text-only and `react-native-audio-api` never
53
53
  * enters the bundle.
54
54
  */
55
55
  voiceSession?: VoiceSessionComponent;
@@ -64,7 +64,12 @@ export interface AliaChatSheetProps {
64
64
  }
65
65
 
66
66
  export interface AliaChatSheetRef {
67
- present: () => void;
67
+ /**
68
+ * Opens the sheet. Given a `turn`, the conversation opens over it with that message already
69
+ * sent (an "ask Alia about this" button: a profile, a post, a file); without one, the sheet
70
+ * opens on its welcome, with the composer focused.
71
+ */
72
+ present: (turn?: AliaTurn) => void;
68
73
  dismiss: () => void;
69
74
  }
70
75
 
@@ -137,8 +142,14 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
137
142
 
138
143
  useImperativeHandle(
139
144
  ref,
140
- () => ({ present: () => mainRef.current?.present(), dismiss: handleDismiss }),
141
- [handleDismiss],
145
+ () => ({
146
+ present: (turn) => {
147
+ mainRef.current?.present();
148
+ if (turn) startConversation(turn);
149
+ },
150
+ dismiss: handleDismiss,
151
+ }),
152
+ [handleDismiss, startConversation],
142
153
  );
143
154
 
144
155
  // Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
@@ -20,8 +20,9 @@ import {
20
20
  type ComposerPanelPermissionOption,
21
21
  } from '@oxy.so/bloom/composer-panel';
22
22
  import { toast } from '@oxy.so/bloom/toast';
23
+ import { Text } from '@oxy.so/bloom/typography';
23
24
  import { useAttachmentIntake } from '../hooks/useAttachmentIntake';
24
- import { useSpeechToText } from '../hooks/useSpeechToText';
25
+ import { useDictation } from '../hooks/useDictation';
25
26
  import type { Completion } from '../lib/suggestions';
26
27
  import { composerTiles, intakeError } from '../lib/attachment-tiles';
27
28
  import type { IntakeKind } from '../lib/attachment-intake';
@@ -121,6 +122,8 @@ export interface AliaComposerProps<T extends AliaSuggestion = AliaSuggestion> {
121
122
  inputRef?: RefObject<TextInput | null>;
122
123
  /** Dictation's language, a BCP-47 tag; omitted, the platform's own. */
123
124
  speechLanguage?: string;
125
+ /** Alia's API, which transcribes dictation; omitted, `EXPO_PUBLIC_ALIA_API_URL` or production. */
126
+ apiUrl?: string;
124
127
  /** Where a refused file or a failed dictation is said; omitted, Bloom's toast. */
125
128
  onError?: (message: string) => void;
126
129
  /** The composer's copy, over whatever labels are already in reach. */
@@ -153,6 +156,7 @@ function AliaComposerBody<T extends AliaSuggestion>({
153
156
  disableKeyboardAvoidance = false,
154
157
  inputRef,
155
158
  speechLanguage,
159
+ apiUrl,
156
160
  onError,
157
161
  }: Omit<AliaComposerProps<T>, 'labels'>) {
158
162
  const labels = useLabels();
@@ -221,25 +225,52 @@ function AliaComposerBody<T extends AliaSuggestion>({
221
225
  [intake, removeAttachment],
222
226
  );
223
227
 
224
- const stt = useSpeechToText({ lang: speechLanguage });
228
+ // The draft at the moment the transcription lands, not when the mic was pressed.
229
+ const draftRef = useRef(value);
230
+ draftRef.current = value;
231
+ const insertDictation = useCallback(
232
+ (text: string) => {
233
+ const draft = draftRef.current;
234
+ onValueChange(draft === '' ? text : `${draft} ${text}`);
235
+ },
236
+ [onValueChange],
237
+ );
238
+ const dictation = useDictation({
239
+ lang: speechLanguage,
240
+ ...(apiUrl === undefined ? {} : { apiUrl }),
241
+ onText: insertDictation,
242
+ });
225
243
  // Said once per failure: a host re-rendering with new labels is not a new failure.
226
- const sayVoiceError = useRef((code: VoiceErrorCode) => report(labels.voiceError(code)));
227
- sayVoiceError.current = (code) => report(labels.voiceError(code));
244
+ const sayVoiceError = useRef<(code: VoiceErrorCode, retry: (() => void) | null) => void>(
245
+ () => undefined,
246
+ );
247
+ sayVoiceError.current = (code, retry) => {
248
+ const message = labels.voiceError(code);
249
+ if (onError || retry === null) report(message);
250
+ else toast.error(message, { action: { label: labels.composerRetry, onClick: retry } });
251
+ };
252
+ const {
253
+ errorCode: dictationError,
254
+ canRetry: dictationCanRetry,
255
+ retry: retryDictation,
256
+ } = dictation;
228
257
  useEffect(() => {
229
- if (stt.errorCode !== null) sayVoiceError.current(stt.errorCode);
230
- }, [stt.errorCode]);
258
+ if (dictationError !== null)
259
+ sayVoiceError.current(dictationError, dictationCanRetry ? () => void retryDictation() : null);
260
+ }, [dictationError, dictationCanRetry, retryDictation]);
231
261
 
232
262
  const handleListeningChange = useCallback(
233
263
  async (next: boolean) => {
234
264
  if (next) {
235
- if (!disabled && !busy && !stt.isTranscribing) stt.startRecording();
265
+ if (!disabled && !busy && !dictation.isTranscribing) void dictation.start();
236
266
  return;
237
267
  }
238
- const text = await stt.stopAndTranscribe();
239
- if (text !== null) onValueChange(value === '' ? text : `${value} ${text}`);
268
+ await dictation.stop();
240
269
  },
241
- [disabled, busy, stt, value, onValueChange],
270
+ [disabled, busy, dictation],
242
271
  );
272
+ const remainingSeconds =
273
+ dictation.remainingMs === null ? null : Math.ceil(dictation.remainingMs / 1000);
243
274
 
244
275
  const hasContent = value.trim() !== '' || attachments.length > 0;
245
276
  const panelLabels = useMemo(
@@ -262,6 +293,15 @@ function AliaComposerBody<T extends AliaSuggestion>({
262
293
  // The transcript's column (Bloom's `AgentChat`: 768 at most, centred), so
263
294
  // the composer and the conversation share one edge.
264
295
  <View id={hostId} className="w-full max-w-[768px] self-center">
296
+ {remainingSeconds !== null ? (
297
+ <Text
298
+ className="mb-1 self-center text-sm text-text-secondary"
299
+ accessibilityLiveRegion="polite"
300
+ aria-live="polite"
301
+ >
302
+ {labels.dictationRemaining(remainingSeconds)}
303
+ </Text>
304
+ ) : null}
265
305
  <ComposerPanel
266
306
  value={value}
267
307
  onValueChange={onValueChange}
@@ -276,7 +316,13 @@ function AliaComposerBody<T extends AliaSuggestion>({
276
316
  permissions={modes ?? NO_MODES}
277
317
  permission={mode}
278
318
  onPermissionChange={onModeChange}
279
- voiceState={stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'}
319
+ voiceState={
320
+ dictation.isTranscribing
321
+ ? 'transcribing'
322
+ : dictation.isRecording || dictation.state === 'requesting-permission'
323
+ ? 'recording'
324
+ : 'idle'
325
+ }
280
326
  onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
281
327
  attachments={tiles}
282
328
  onRemoveAttachment={removeTile}
@@ -8,15 +8,17 @@ import { Loading } from '@oxy.so/bloom/loading';
8
8
  import { Text } from '@oxy.so/bloom/typography';
9
9
  import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
10
10
  import { View } from 'react-native';
11
- import type { RoomState, AgentState } from '../../types';
11
+ import type { RoomState, AgentState, VoiceCallState } from '../../types';
12
12
 
13
13
  /** Every word the bar shows or announces. An app in another language passes its own. */
14
14
  export interface VoiceControlsLabels {
15
15
  connecting: string;
16
+ reconnecting: string;
16
17
  connected: string;
17
18
  listening: string;
18
19
  muted: string;
19
20
  thinking: string;
21
+ usingTool: string;
20
22
  speaking: string;
21
23
  mute: string;
22
24
  unmute: string;
@@ -25,10 +27,12 @@ export interface VoiceControlsLabels {
25
27
 
26
28
  export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
27
29
  connecting: 'Connecting...',
30
+ reconnecting: 'Reconnecting...',
28
31
  connected: 'Connected',
29
32
  listening: 'Listening...',
30
33
  muted: 'Muted',
31
34
  thinking: 'Thinking...',
35
+ usingTool: 'Working on it...',
32
36
  speaking: 'Speaking...',
33
37
  mute: 'Mute',
34
38
  unmute: 'Unmute',
@@ -41,6 +45,16 @@ export interface VoiceControlsProps {
41
45
  isMuted: boolean;
42
46
  onToggleMute: () => void;
43
47
  onEnd: () => void;
48
+ /**
49
+ * Something the person should know while the call listens — e.g. that the
50
+ * call is about to reach its time limit. Shown in place of "Listening…".
51
+ */
52
+ notice?: string | null;
53
+ /**
54
+ * The call's full state, when the host has it: a tool running reads as such
55
+ * rather than as "Thinking…".
56
+ */
57
+ callState?: VoiceCallState;
44
58
  /** Overrides for any of {@link VOICE_CONTROLS_LABELS}. */
45
59
  labels?: Partial<VoiceControlsLabels>;
46
60
  }
@@ -49,11 +63,15 @@ function getStatusText(
49
63
  roomState: RoomState,
50
64
  agentState: AgentState,
51
65
  isMuted: boolean,
66
+ notice: string | null,
52
67
  labels: VoiceControlsLabels,
68
+ callState: VoiceCallState | undefined,
53
69
  ): string {
54
70
  if (roomState === 'connecting') return labels.connecting;
71
+ if (roomState === 'reconnecting') return labels.reconnecting;
55
72
  if (roomState === 'connected') {
56
- if (agentState === 'listening') return isMuted ? labels.muted : labels.listening;
73
+ if (agentState === 'listening') return isMuted ? labels.muted : (notice ?? labels.listening);
74
+ if (callState?.kind === 'using-tool') return labels.usingTool;
57
75
  if (agentState === 'thinking') return labels.thinking;
58
76
  if (agentState === 'speaking') return labels.speaking;
59
77
  return labels.connected;
@@ -74,10 +92,12 @@ export function VoiceControls({
74
92
  isMuted,
75
93
  onToggleMute,
76
94
  onEnd,
95
+ notice = null,
96
+ callState,
77
97
  labels: labelOverrides,
78
98
  }: VoiceControlsProps) {
79
99
  const labels = { ...VOICE_CONTROLS_LABELS, ...labelOverrides };
80
- const statusText = getStatusText(roomState, agentState, isMuted, labels);
100
+ const statusText = getStatusText(roomState, agentState, isMuted, notice, labels, callState);
81
101
 
82
102
  return (
83
103
  <View className="items-center gap-4 pb-6 pt-4">
@@ -102,7 +122,11 @@ export function VoiceControls({
102
122
  />
103
123
  ) : null}
104
124
 
105
- {roomState === 'connecting' ? <Loading accessibilityLabel={labels.connecting} /> : null}
125
+ {roomState === 'connecting' || roomState === 'reconnecting' ? (
126
+ <Loading
127
+ accessibilityLabel={roomState === 'connecting' ? labels.connecting : labels.reconnecting}
128
+ />
129
+ ) : null}
106
130
  </View>
107
131
  );
108
132
  }