@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.
- package/CHANGELOG.md +129 -0
- package/README.md +52 -20
- package/package.json +8 -4
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +4 -7
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +1 -1
- package/src/components/AliaChatSheet.tsx +15 -4
- package/src/components/AliaComposer.tsx +57 -11
- package/src/components/voice/VoiceControls.tsx +28 -4
- package/src/components/voice/VoiceSession.tsx +23 -7
- package/src/hooks/useAliaActivity.ts +31 -0
- package/src/hooks/useAliaChat.ts +14 -3
- package/src/hooks/useAmbientWave.ts +3 -3
- package/src/hooks/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -736
- package/src/index.ts +9 -3
- package/src/lib/activity-socket.ts +102 -0
- package/src/lib/activity-store.ts +53 -0
- package/src/lib/api-url.ts +2 -0
- package/src/lib/audio-level.ts +2 -2
- package/src/lib/dictation-recorder-types.ts +37 -0
- package/src/lib/dictation-recorder.native.ts +90 -0
- package/src/lib/dictation-recorder.ts +131 -0
- package/src/lib/dictation.ts +208 -0
- package/src/lib/labels.tsx +3 -0
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -97
- package/src/lib/speech-synthesis.ts +5 -1
- package/src/lib/turn-state.ts +15 -0
- package/src/lib/voice-audio-engine-types.ts +59 -0
- package/src/lib/voice-audio-engine.native.ts +232 -0
- package/src/lib/voice-audio-engine.ts +350 -0
- package/src/lib/voice-call.ts +539 -0
- package/src/lib/voice-levels.ts +7 -3
- package/src/lib/voice-protocol.ts +309 -0
- package/src/types.ts +49 -4
- package/src/voice.ts +31 -12
- package/tsconfig.json +2 -0
- package/src/hooks/useSpeechToText.ts +0 -268
- package/src/lib/speech-recognition-types.ts +0 -108
- package/src/lib/speech-recognition.native.ts +0 -237
- package/src/lib/speech-recognition.ts +0 -308
- package/src/lib/voice-text.ts +0 -198
- 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
["
|
|
69
|
-
"
|
|
70
|
-
"
|
|
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
|
-
|
|
75
|
-
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
() => ({
|
|
141
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
227
|
-
|
|
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 (
|
|
230
|
-
|
|
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 && !
|
|
265
|
+
if (!disabled && !busy && !dictation.isTranscribing) void dictation.start();
|
|
236
266
|
return;
|
|
237
267
|
}
|
|
238
|
-
|
|
239
|
-
if (text !== null) onValueChange(value === '' ? text : `${value} ${text}`);
|
|
268
|
+
await dictation.stop();
|
|
240
269
|
},
|
|
241
|
-
[disabled, busy,
|
|
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={
|
|
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'
|
|
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
|
}
|