@alia.onl/sdk 13.0.0 → 13.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 13.1.1
4
+
5
+ - **The avatar flights load the runtime too.** 13.1.0 mounted the character runtime provider inside
6
+ the chat, but the sheet's avatar-flight layer (the avatar that flies from the welcome into the
7
+ header) is drawn outside it, so it asked the host for its own `/bloom-character/runtime.mjs` and got
8
+ the app's HTML back ("Expected a JavaScript-or-Wasm module script but the server responded with
9
+ a MIME type of text/html"). `AliaChatSheet` and `AliaChatScreen` now wrap everything they draw in the
10
+ provider, once; `AliaChatContent` no longer mounts one (it is not an entry point on its own).
11
+
12
+ ## 13.2.0
13
+
14
+ One composer for the sheet, the screen and the Alia app: the app's composer moved here and the
15
+ app consumes it.
16
+
17
+ - **`AliaComposer`** (exported) is Bloom's `ComposerPanel` plus what every Alia surface needs the
18
+ same way: files by paste and drop (read with real progress, retried, refused with a sentence),
19
+ dictation, the suggestions over the field and the questions Alia asked, docked above it. What
20
+ differs per surface is a prop: `addMenu` / `onAddMenuSelect`, `modes` / `mode` /
21
+ `onModeChange` (a power-level selector), `attachments` / `onAddAttachment` /
22
+ `onRemoveAttachment` (a composer given no `onAddAttachment` takes no paste or drop),
23
+ `attachmentKinds` (`['image']` refuses a document with a sentence), `suggestions`, `questions`,
24
+ `status`, `emptyAction`, `accessory`, `onKeyPress`, `speechLanguage`, `labels`, `onError`.
25
+ `AliaChatContent` (so the sheet and the screen) renders it with photos, voice and suggestions;
26
+ a host gets today's behaviour with no setup.
27
+ - **The sheet's composer is the panel, not the pill.** The pill has no mode selector, status tab
28
+ or question slot, which the Alia app needs; the sheet and the app now draw the same card. The
29
+ agent-working light (`ComposerLoader`) is not drawn around it, as in the app: a turn in flight
30
+ is its stop control and the thread's thinking line.
31
+ - **Photos are `AliaAttachment`s** (exported type): the chat's picked photos wait as the same
32
+ tiles a dropped or pasted image becomes, with the same remove and error states.
33
+ - **`AliaChatLabels` gains** `composerMessage`, `composerAddMenu`, `composerModes`,
34
+ `composerVoice`, `composerSend`, `composerStop`, `composerRemove`, `composerRetry`,
35
+ `composerUntitled`, `fileReadFailed`, `fileTooLarge`, `fileEmpty`, `fileUnsupported`,
36
+ `dropHint`, `dropUnavailable` and `voiceError(code)`, all with English defaults. A nested
37
+ `AliaChatLabelsProvider` now refines the labels already in reach instead of resetting them.
38
+ - **`usePendingQuestions(messages, isStreaming)`** (exported) is the "asked above the composer,
39
+ put away until she asks again" rule both the chat and the app used to keep a copy of.
40
+ - **`classifyIntake` / `MAX_ATTACHMENT_BYTES`** (from `@alia.onl/sdk/tools`) are the rules a file
41
+ passes to be attached.
42
+ - **The sheet focuses its composer once it has presented.** `AliaChatSheet` hands each sheet's
43
+ `animatedProgress` (Bloom's own "fully presented" signal, no timer) to its chat, whose composer
44
+ takes focus when it reads `1`: the person can type at once and the keyboard opens on native. It
45
+ focuses once per `present()` for the main sheet and for the conversation sheet over it; dragging
46
+ a sheet part-way and letting go does not take the focus again. `AliaChatContent` takes
47
+ `presentedProgress` for a host that builds its own sheet; the screen and the app's own screens
48
+ pass none and keep their focus. Not verified on a device.
49
+
50
+ A voice call that cannot speak ends and says why, instead of listening on in silence:
51
+
52
+ - **`requestSpeechClip` throws a `SpeechClipError`** (exported) with the route's HTTP `status`,
53
+ its typed `code` (`SPEECH_UNAVAILABLE`, `SPEECH_LIMIT_REACHED`, `SPEECH_REJECTED`,
54
+ `SPEECH_PROVIDER_FAILED`, `SPEECH_GENERATION_FAILED`, `INVALID_REQUEST`, `NOT_AUTHENTICATED`, or
55
+ `null`), `retryable` and `requestId`. An empty token is `NOT_AUTHENTICATED` without a request.
56
+ - **`useVoiceRoom` ends the call when a clip cannot be made or played**, after the answer has
57
+ finished streaming into the transcript, with `errorCode` `synthesis-unavailable`,
58
+ `synthesis-limited`, `synthesis-rejected`, `synthesis-failed`, `not-authenticated` (no session
59
+ token) or `not-played`; `synthesisError` is the `SpeechClipError` behind it. A turn no longer
60
+ skips a sentence it could not speak, and `turnErrorCode` is no longer `not-played`.
61
+ - `VoiceErrorCode` gains the four `synthesis-*` codes; an app that translates every code needs
62
+ their sentences.
63
+
64
+ ## 13.1.0
65
+
66
+ The chat works in any app without host setup it did not document in code:
67
+
68
+ - **The avatar loads.** The chat mounts Bloom's `AgentAvatarProvider` itself, pointing at Alia's
69
+ shared runtime (`ALIA_CHARACTER_RUNTIME_URL`, served with CORS open); a host that serves its own
70
+ passes `characterRuntimeUrl`. Before, an app that had not hosted the 214 MB runtime showed
71
+ Bloom's "unavailable" state.
72
+ - **The empty chat greets.** With no `welcomeGreeting` it greets the signed-in person by name
73
+ (`labels.greeting` / `labels.greetingNamed`). Before, it drew nothing.
74
+
3
75
  ## 13.0.0
4
76
 
5
77
  Fixes found by driving the sheet end to end (the sheet shows plans, links citations, nests lists):
package/README.md CHANGED
@@ -25,20 +25,18 @@ the default in Expo SDK 53+), because every icon import is a subpath.
25
25
  The chat is built from Bloom (`@oxy.so/bloom`, a peer dependency): mount Bloom's
26
26
  `BloomProvider` above it, with the one `KeyboardProvider` your app already has.
27
27
 
28
- Alia's avatar is Bloom's 3D character (`AgentAvatar`), which draws through a
29
- runtime Bloom ships as static files (about 11 MB). Serve the package's
30
- `assets/character-runtime/` directory unchanged and give the URL of `runtime.mjs`
31
- to Bloom's provider, once, near the root:
32
-
33
- ```tsx
34
- import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
35
-
36
- <AgentAvatarProvider runtimeUrl="/bloom-character/runtime.mjs">…</AgentAvatarProvider>
37
- ```
38
-
39
- Without it the avatar shows Bloom's "unavailable" state. Native avatars run in a
28
+ Alia's avatar is Bloom's 3D character (`AgentAvatar`). It draws through a runtime of
29
+ static files (about 214 MB), which the chat loads from Alia's own origin
30
+ (`ALIA_CHARACTER_RUNTIME_URL`, served with CORS open), so the app hosts nothing and mounts
31
+ no avatar provider. An app with a strict CSP allows `https://alia.onl` in `script-src` and
32
+ `connect-src` (and `'wasm-unsafe-eval'`). To serve your own copy instead, serve the Bloom
33
+ package's `assets/character-runtime/` directory unchanged and pass the URL of `runtime.mjs`
34
+ as `characterRuntimeUrl` to `AliaChatSheet` or `AliaChatScreen`; native avatars run in a
40
35
  WebView, so there the URL must be absolute and reachable from the device.
41
36
 
37
+ The empty chat greets the signed-in person by name ("Hi Nate, what's on your mind?"); pass
38
+ `welcomeGreeting` to say something else, or `labels.greeting` / `labels.greetingNamed` to translate it.
39
+
42
40
  Failures (a voice call that cannot start, a dictation error) are shown with Bloom's `toast`,
43
41
  so the app needs a `ToastOutlet` (`OxyProvider` mounts one), or pass `onError` to handle them.
44
42
 
@@ -114,6 +112,37 @@ components render. Those events are the product runtime's, not part of the Oxy
114
112
  generic inference contract, and this package is written against them on
115
113
  purpose: it is a **product** client.
116
114
 
115
+ ## The composer
116
+
117
+ The sheet, the screen and the Alia app share one composer, `AliaComposer`: Bloom's
118
+ `ComposerPanel` with files by paste and drop (read with real progress, retried, refused with a
119
+ sentence), dictation, suggestions over the field and Alia's questions docked above it.
120
+ `AliaChatSheet` and `AliaChatScreen` render it with photos, voice and suggestions, and
121
+ `AliaChatSheet` focuses it once the sheet has presented. To build your own surface, mount it and
122
+ pass what the surface has:
123
+
124
+ ```tsx
125
+ import { AliaComposer } from '@alia.onl/sdk';
126
+
127
+ <AliaComposer
128
+ value={draft}
129
+ onValueChange={setDraft}
130
+ onSubmit={send}
131
+ busy={isStreaming}
132
+ onStop={stop}
133
+ modes={powerLevels} // the mode selector; omit to hide it
134
+ mode={level}
135
+ onModeChange={setLevel}
136
+ addMenu={groups} // the add menu's rows, and what pressing one does
137
+ onAddMenuSelect={onAdd}
138
+ attachments={files} // omit `onAddAttachment` and the composer takes no paste or drop
139
+ onAddAttachment={addFile}
140
+ onRemoveAttachment={removeFile}
141
+ attachmentKinds={['image']} // what your send carries; others are refused with a sentence
142
+ labels={{ composerSend: 'Enviar' }} // anything omitted is English
143
+ />;
144
+ ```
145
+
117
146
  ## Which endpoint the SDK calls, and why
118
147
 
119
148
  The hook sends its turns to `POST {apiUrl}/v1/chat/completions`, with `apiUrl`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "13.0.0",
3
+ "version": "13.2.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",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "clsx": "^2.1.1",
34
34
  "react-native-markdown-display": "^7.0.2",
35
- "tailwind-merge": "^3.6.0",
35
+ "tailwind-merge": "^3.7.0",
36
36
  "zustand": "^5.0.15"
37
37
  },
38
38
  "peerDependencies": {
@@ -8,29 +8,30 @@
8
8
  */
9
9
 
10
10
  import React, { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
11
- import { View } from 'react-native';
11
+ import { View, type TextInput } from 'react-native';
12
+ import type { SharedValue } from 'react-native-reanimated';
12
13
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
13
14
  import { useTTS } from '../hooks/useTTS';
14
- import { useSpeechToText } from '../hooks/useSpeechToText';
15
+ import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
16
+ import { usePendingQuestions } from '../hooks/usePendingQuestions';
15
17
  import { useImagePicker } from '../hooks/useImagePicker';
16
18
  import { imageUriToDataUrl } from '../lib/image-data-url';
17
19
  import { useAmbientWave } from '../hooks/useAmbientWave';
18
20
  import { AmbientField } from './AmbientField';
19
- import { QuestionCard } from './QuestionCard';
20
- import { pendingQuestions } from '../lib/questions';
21
+ import { getAccountDisplayName } from '@oxy.so/core';
22
+ import { useOxy } from '@oxy.so/services/ui/client';
21
23
  import { AliaChatMessageList } from './AliaChatMessageList';
22
24
  import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
23
25
  import { AliaWelcomeMessage } from './AliaWelcomeMessage';
24
- import { ComposerSuggestions } from './ComposerSuggestions';
26
+ import { AliaComposer } from './AliaComposer';
25
27
  import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
26
- import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
27
28
  import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
28
29
  import {
29
30
  AvatarFlightLayer,
30
31
  useAvatarFlights,
31
32
  type AvatarFlights,
32
33
  } from '@oxy.so/bloom/avatar-flight';
33
- import { ComposerPill, type ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
34
+ import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
34
35
  import { RiImageLine } from '@oxy.so/bloom/icons';
35
36
  import { toast } from '@oxy.so/bloom/toast';
36
37
  import { Button } from '@oxy.so/bloom/button';
@@ -38,6 +39,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
38
39
  import type { AliaAvatarState } from './AliaAvatar';
39
40
  import { VoiceModeIcon } from './VoiceModeIcon';
40
41
  import type {
42
+ AliaAttachment,
41
43
  AliaSuggestion,
42
44
  ChatMessage,
43
45
  VoiceMessage,
@@ -84,7 +86,7 @@ export interface AliaChatContentProps {
84
86
  apiUrl?: string;
85
87
  /** Error handler (e.g. toast) */
86
88
  onError?: (message: string) => void;
87
- // Welcome message
89
+ /** The empty chat's greeting; omitted, Alia greets the signed-in person by name (`labels`). */
88
90
  welcomeGreeting?: string;
89
91
  welcomeSubtitle?: string;
90
92
  /** The lines offered over the composer on an empty chat. */
@@ -133,12 +135,21 @@ export interface AliaChatContentProps {
133
135
  flights?: AvatarFlights;
134
136
  /** A turn sent once as this chat mounts: the conversation a sheet was opened for. */
135
137
  initialTurn?: AliaTurn;
138
+ /**
139
+ * The `animatedProgress` of the Bloom sheet this chat is in. The composer takes focus (and on
140
+ * native opens the keyboard) once it reads `1`, so the person can type as soon as the sheet is
141
+ * up. Omitted, the chat is not in a sheet and focus is left alone.
142
+ */
143
+ presentedProgress?: SharedValue<number>;
136
144
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
137
145
  onStopReady?: (stop: (() => void) | null) => void;
138
146
  /** Overrides for the chat's own copy; anything omitted is English. */
139
147
  labels?: Partial<AliaChatLabels>;
140
148
  }
141
149
 
150
+ /** The chat API carries photos (`image_url` parts) and nothing else. */
151
+ const PHOTOS_ONLY = ['image'] as const;
152
+
142
153
  /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
143
154
  function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
144
155
  return [
@@ -186,6 +197,7 @@ function AliaChatContentBody({
186
197
  agents: knownAgents,
187
198
  flights: sharedFlights,
188
199
  initialTurn,
200
+ presentedProgress,
189
201
  onStopReady,
190
202
  }: AliaChatContentProps) {
191
203
  const ownFlights = useAvatarFlights();
@@ -206,15 +218,7 @@ function AliaChatContentBody({
206
218
  const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
207
219
  useAliaChat(chatOptions);
208
220
  const agents = useMemo(() => participatingAgents(messages), [messages]);
209
- // What Alia asked in her last turn and the person has not answered: asked above the composer,
210
- // not in the thread. Any later message, typed or sent from the card, is the answer.
211
- const lastMessage = messages.at(-1);
212
- const questionsAsked = useMemo(
213
- () => pendingQuestions(messages, isStreaming),
214
- [messages, isStreaming],
215
- );
216
- const [dismissedQuestionsOf, setDismissedQuestionsOf] = useState<string | null>(null);
217
- const asking = questionsAsked.length > 0 && dismissedQuestionsOf !== lastMessage?.id;
221
+ const asked = usePendingQuestions(messages, isStreaming);
218
222
 
219
223
  // The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
220
224
  const startsElsewhere = onStart !== undefined && messages.length === 0;
@@ -252,9 +256,7 @@ function AliaChatContentBody({
252
256
  const [inputValue, setInputValue] = useState('');
253
257
 
254
258
  // Picked photos wait here, as tiles above the field, until the turn is sent.
255
- const [photos, setPhotos] = useState<
256
- Array<{ id: string; name: string; uri: string; mimeType?: string }>
257
- >([]);
259
+ const [photos, setPhotos] = useState<AliaAttachment[]>([]);
258
260
  const { pickImage } = useImagePicker();
259
261
 
260
262
  const handleAddMenuSelect = useCallback(
@@ -266,8 +268,10 @@ function AliaChatContentBody({
266
268
  ...prev,
267
269
  ...assets.map((asset) => ({
268
270
  id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
271
+ type: 'image' as const,
269
272
  name: asset.name,
270
273
  uri: asset.uri,
274
+ size: asset.size,
271
275
  mimeType: asset.mimeType,
272
276
  })),
273
277
  ]);
@@ -317,24 +321,6 @@ function AliaChatContentBody({
317
321
  [submitTurn, photos, reportError],
318
322
  );
319
323
 
320
- // ── Dictation: the pill's mic toggles it, the transcript lands in the field ──
321
- const stt = useSpeechToText();
322
- useEffect(() => {
323
- if (stt.error) reportError(stt.error);
324
- }, [stt.error, reportError]);
325
- const sttRef = useRef(stt);
326
- sttRef.current = stt;
327
- const handleListeningChange = useCallback(async (listening: boolean) => {
328
- const current = sttRef.current;
329
- if (listening) {
330
- if (!current.isRecording && !current.isTranscribing) current.startRecording();
331
- return;
332
- }
333
- if (!current.isRecording) return;
334
- const text = await current.stopAndTranscribe();
335
- if (text) setInputValue((prev) => (prev ? `${prev} ${text}` : text));
336
- }, []);
337
-
338
324
  // ── TTS ──
339
325
  const tts = useTTS({ apiUrl });
340
326
 
@@ -399,14 +385,17 @@ function AliaChatContentBody({
399
385
  return 'writing';
400
386
  }, [messages, isStreaming, isVoiceActive, voiceAgentState]);
401
387
 
388
+ const { user } = useOxy();
389
+ const greeting =
390
+ welcomeGreeting ?? (user ? labels.greetingNamed(getAccountDisplayName(user)) : labels.greeting);
402
391
  const welcomeComponent = useMemo(() => {
403
- if (!welcomeGreeting || messages.length > 0) return undefined;
392
+ if (messages.length > 0) return undefined;
404
393
  return (
405
394
  <View className="flex-1 items-center justify-center px-4 py-10">
406
- <AliaWelcomeMessage greeting={welcomeGreeting} subtitle={welcomeSubtitle} />
395
+ <AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} />
407
396
  </View>
408
397
  );
409
- }, [welcomeGreeting, welcomeSubtitle, messages.length]);
398
+ }, [greeting, welcomeSubtitle, messages.length]);
410
399
 
411
400
  // ── Ambient wave — one persistent overlay across idle/voice/TTS/STT ──
412
401
  const wave = useAmbientWave({
@@ -443,16 +432,8 @@ function AliaChatContentBody({
443
432
  [VoiceSession, activateVoice, labels.startVoice],
444
433
  );
445
434
 
446
- const pillAttachments = useMemo(
447
- () =>
448
- photos.map((photo) => ({
449
- id: photo.id,
450
- name: photo.name,
451
- kind: 'image' as const,
452
- src: photo.uri,
453
- })),
454
- [photos],
455
- );
435
+ const composerRef = useRef<TextInput | null>(null);
436
+ useFocusWhenPresented(presentedProgress, () => composerRef.current?.focus());
456
437
 
457
438
  const composer =
458
439
  VoiceSession && isVoiceActive ? (
@@ -461,45 +442,32 @@ function AliaChatContentBody({
461
442
  <VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
462
443
  </Suspense>
463
444
  ) : (
464
- <>
465
- <ComposerSuggestions
466
- completions={suggestions.completions}
467
- selected={suggestions.selected}
468
- onPick={handlePickSuggestion}
469
- />
470
- {/* Beside the loader, not inside the pill: its light traces the box it wraps. */}
471
- {asking ? (
472
- <View className="mb-2">
473
- <QuestionCard
474
- questions={questionsAsked}
475
- onSubmit={(message) => send(message)}
476
- onDismiss={() => setDismissedQuestionsOf(lastMessage?.id ?? null)}
477
- />
478
- </View>
479
- ) : null}
480
- <ComposerLoader active={isStreaming}>
481
- <ComposerPill
482
- onKeyPress={suggestions.onKeyPress}
483
- surface={false}
484
- frosted={isStreaming}
485
- value={inputValue}
486
- onValueChange={setInputValue}
487
- onSubmit={handleSubmit}
488
- busy={isStreaming}
489
- onStop={stop}
490
- emptyAction={voiceActivateButton}
491
- placeholder={labels.placeholder}
492
- addMenu={addMenuGroups}
493
- onAddMenuSelect={handleAddMenuSelect}
494
- attachments={pillAttachments}
495
- onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
496
- voiceState={
497
- stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'
498
- }
499
- onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
500
- />
501
- </ComposerLoader>
502
- </>
445
+ <AliaComposer
446
+ inputRef={composerRef}
447
+ disableKeyboardAvoidance
448
+ onKeyPress={suggestions.onKeyPress}
449
+ suggestions={{ ...suggestions, onPick: handlePickSuggestion }}
450
+ // Asked beside the field, not in the thread: the card docks above the composer.
451
+ questions={{
452
+ items: asked.questions,
453
+ onSubmit: (message) => send(message),
454
+ onDismiss: asked.dismiss,
455
+ }}
456
+ value={inputValue}
457
+ onValueChange={setInputValue}
458
+ onSubmit={() => void handleSubmit(inputValue)}
459
+ busy={isStreaming}
460
+ onStop={stop}
461
+ emptyAction={voiceActivateButton}
462
+ placeholder={labels.placeholder}
463
+ addMenu={addMenuGroups}
464
+ onAddMenuSelect={handleAddMenuSelect}
465
+ attachments={photos}
466
+ onAddAttachment={(photo) => setPhotos((prev) => [...prev, photo])}
467
+ onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
468
+ attachmentKinds={PHOTOS_ONLY}
469
+ onError={onError}
470
+ />
503
471
  );
504
472
 
505
473
  const hasMessages = messages.length > 0;
@@ -20,6 +20,8 @@
20
20
  import React, { useCallback } from 'react';
21
21
  import { View } from 'react-native';
22
22
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
23
+ import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
24
+ import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
23
25
  import { KeyboardAvoidingView } from '../lib/keyboard';
24
26
  import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
25
27
  import { AliaChatHeader } from './AliaChatHeader';
@@ -62,6 +64,8 @@ export interface AliaChatScreenProps {
62
64
  voiceSession?: VoiceSessionComponent;
63
65
  /** Overrides for the chat's own copy (header, composer, plan, research…); omitted, English. */
64
66
  labels?: Partial<AliaChatLabels>;
67
+ /** Where the 3D avatar's runtime is served from; omitted, Alia's shared copy (`ALIA_CHARACTER_RUNTIME_URL`). */
68
+ characterRuntimeUrl?: string;
65
69
  }
66
70
 
67
71
  export function AliaChatScreen({
@@ -82,6 +86,7 @@ export function AliaChatScreen({
82
86
  isDarkMode,
83
87
  voiceSession,
84
88
  labels,
89
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
85
90
  }: AliaChatScreenProps) {
86
91
  const insets = useSafeAreaInsets();
87
92
  // The screen owns the whole window, so it keeps its composer out of both
@@ -111,27 +116,29 @@ export function AliaChatScreen({
111
116
  );
112
117
 
113
118
  return (
114
- <View
115
- className="flex-1 bg-background-full"
116
- style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
117
- >
118
- <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
119
- <AliaChatContent
120
- clientContext={clientContext}
121
- model={model}
122
- apiUrl={apiUrl}
123
- welcomeGreeting={welcomeGreeting}
124
- welcomeSubtitle={welcomeSubtitle}
125
- welcomeSuggestions={welcomeSuggestions}
126
- agents={agents}
127
- suggestionMatches={suggestionMatches}
128
- onSuggestionQuery={onSuggestionQuery}
129
- isDarkMode={isDarkMode}
130
- voiceSession={voiceSession}
131
- labels={labels}
132
- header={header}
133
- />
134
- </KeyboardAvoidingView>
135
- </View>
119
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
120
+ <View
121
+ className="flex-1 bg-background-full"
122
+ style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
123
+ >
124
+ <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
125
+ <AliaChatContent
126
+ clientContext={clientContext}
127
+ model={model}
128
+ apiUrl={apiUrl}
129
+ welcomeGreeting={welcomeGreeting}
130
+ welcomeSubtitle={welcomeSubtitle}
131
+ welcomeSuggestions={welcomeSuggestions}
132
+ agents={agents}
133
+ suggestionMatches={suggestionMatches}
134
+ onSuggestionQuery={onSuggestionQuery}
135
+ isDarkMode={isDarkMode}
136
+ voiceSession={voiceSession}
137
+ labels={labels}
138
+ header={header}
139
+ />
140
+ </KeyboardAvoidingView>
141
+ </View>
142
+ </AgentAvatarProvider>
136
143
  );
137
144
  }
@@ -1,10 +1,13 @@
1
+ import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
1
2
  import { AvatarFlightLayer, useAvatarFlights } from '@oxy.so/bloom/avatar-flight';
2
3
  import { BottomSheet, type BottomSheetRef } from '@oxy.so/bloom/bottom-sheet';
3
4
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
5
  import { useTheme } from '@oxy.so/bloom/theme';
5
6
  import { useWindowDimensions, View } from 'react-native';
7
+ import { useSharedValue } from 'react-native-reanimated';
6
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
9
  import { BREAKPOINTS } from '@oxy.so/bloom/styles';
10
+ import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
8
11
  import type { AliaChatLabels } from '../lib/labels';
9
12
  import type { VoiceSessionComponent } from '../types';
10
13
  import { AliaChatContent, type AliaChatContentProps, type AliaTurn } from './AliaChatContent';
@@ -52,6 +55,8 @@ export interface AliaChatSheetProps {
52
55
  voiceSession?: VoiceSessionComponent;
53
56
  /** Overrides for the chat's own copy (header, composer, plan, research…); omitted, English. */
54
57
  labels?: Partial<AliaChatLabels>;
58
+ /** Where the 3D avatar's runtime is served from; omitted, Alia's shared copy (`ALIA_CHARACTER_RUNTIME_URL`). */
59
+ characterRuntimeUrl?: string;
55
60
  /** Draws the header's share button once there is a conversation; omitted, no share button. */
56
61
  onShare?: () => void;
57
62
  /** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
@@ -78,6 +83,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
78
83
  isDarkMode,
79
84
  voiceSession,
80
85
  labels,
86
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
81
87
  onShare,
82
88
  headerMenu,
83
89
  },
@@ -85,6 +91,9 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
85
91
  ) => {
86
92
  // Bloom's resolved mode, not the system's scheme.
87
93
  const { isDark } = useTheme();
94
+ // How far each sheet is presented (0 closed, 1 up): its chat takes the composer's focus at 1.
95
+ const mainProgress = useSharedValue(0);
96
+ const conversationProgress = useSharedValue(0);
88
97
  const mainRef = useRef<BottomSheetRef>(null);
89
98
  const conversationRef = useRef<BottomSheetRef>(null);
90
99
  // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
@@ -161,13 +170,16 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
161
170
  labels,
162
171
  };
163
172
 
173
+ // The provider wraps the sheets AND the flight layer: both draw avatars, and an avatar drawn
174
+ // outside it would fall back to the host's own `/bloom-character/runtime.mjs`.
164
175
  return (
165
- <>
176
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
166
177
  <BottomSheet
167
178
  ref={mainRef}
168
179
  detached={detached}
169
180
  scrollable={false}
170
181
  manualActivation
182
+ animatedProgress={mainProgress}
171
183
  onDismissAttempt={onMainDismissAttempt}
172
184
  onDismiss={() => setConversation(null)}
173
185
  style={sheetStyle}
@@ -182,6 +194,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
182
194
  agents={agents}
183
195
  suggestionMatches={suggestionMatches}
184
196
  onSuggestionQuery={onSuggestionQuery}
197
+ presentedProgress={mainProgress}
185
198
  onStopReady={registerMainStop}
186
199
  onStart={startConversation}
187
200
  />
@@ -194,6 +207,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
194
207
  detached={detached}
195
208
  scrollable={false}
196
209
  manualActivation
210
+ animatedProgress={conversationProgress}
197
211
  onDismissAttempt={onConversationDismissAttempt}
198
212
  onDismiss={() => setConversation(null)}
199
213
  style={sheetStyle}
@@ -201,6 +215,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
201
215
  <AliaChatContent
202
216
  {...shared}
203
217
  initialTurn={conversation.turn}
218
+ presentedProgress={conversationProgress}
204
219
  agents={agents}
205
220
  onStopReady={registerConversationStop}
206
221
  // The bar is the conversation's: back to the main screen, the profile, share and
@@ -221,7 +236,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
221
236
  </BottomSheet>
222
237
  ) : null}
223
238
  <AvatarFlightLayer flights={flights} />
224
- </>
239
+ </AgentAvatarProvider>
225
240
  );
226
241
  },
227
242
  );