@alia.onl/sdk 11.0.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 12.0.0
4
+
5
+ Breaking: Bloom 10 peer; the `header` render prop replaces the breadcrumb path (`title`, `actions`)
6
+ and the mark-state context. Hosts on Bloom 9 stay on 11.1.0.
7
+
8
+ - **The `@oxy.so` peers are open-ended** above their floors (`bloom >=10.0.0`, `core >=3.0.0`,
9
+ `services >=8.0.0`). The `^10.0.0`, `^4.0.0` and `^11.0.0` caps made each new Oxy major a peer
10
+ conflict in every app until this package republished; apps always run the latest Oxy SDK.
11
+
12
+ - **`AmbientField` takes `layout="sheet"`** for a short card (the chat's sheet): the blobs are
13
+ drawn at the size of a phone screen, spread wider and shifted back to the right, cropped by the
14
+ card's edges. It is asked for, not inferred from the field's height, so a short app screen is
15
+ never reinterpreted. `AmbientField`, `AliaAvatar` and `AliaChatHeader` are memoised and the
16
+ chat body no longer repaints them on every streamed token; read-aloud and starting a call no
17
+ longer change identity per flush. `AliaAvatar` takes `portrait` (a still thumbnail, used for
18
+ the sidebar's permanent mark).
19
+ - **One `header` render prop** on `AliaChatContent`, handed `hasMessages`, `clear` and the live
20
+ avatar `markState`; the breadcrumb path (`title`, `actions`) and the mark-state context are gone.
21
+ - The profile and the screen's background are NativeWind classes with Bloom tokens
22
+ (`bg-background-full`, `group-hover/profile:…`), not JS-computed colours.
23
+ - **Bloom is a peer at `^10.0.0`** (was `^9.0.0`). Bloom 10 makes an avatar one flat
24
+ `AvatarCharacter`; `AliaAvatar` is `{ preset: 'blue_beret', accessory: 'none', color: 'orchid' }`
25
+ and no longer builds a `config`. Hosts mount `AgentAvatarProvider` as before. Hosts on Bloom 9
26
+ stay on the previous release.
27
+
28
+ ## 11.1.0
29
+
30
+ Alia can ask clarifying questions, as ChatGPT and Claude do.
31
+
32
+ - **New:** `QuestionCard` (Bloom's `Questionnaire`: single or multiple choice, a free-text
33
+ "Other", one question per step), drawn under a reply that asked. `AliaChatMessageList`
34
+ renders it from the turn's `askUserQuestion` tool call; `AliaChatContent` sends the
35
+ answers as the person's next message (`onAnswerQuestions` on the list, for hosts that
36
+ compose their own).
37
+ - **New, pure** (root entry and `@alia.onl/sdk/tools`): `askedQuestions`, `formatAnswers`,
38
+ `isQuestionInvocation`, `ASK_USER_QUESTION_TOOL`. The question is the stored tool call, so
39
+ it survives a reload; it is answered once any later message exists, typed or picked.
40
+ - The turn **ends** at the card: the server stops after the call and writes no answer under it.
41
+
3
42
  ## 11.0.0
4
43
 
5
44
  The chat UI is Bloom's, not a copy of it. Breaking:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "11.0.0",
3
+ "version": "12.0.0",
4
4
  "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -39,9 +39,9 @@
39
39
  "react": "^19.2.3",
40
40
  "react-native": "^0.86.0",
41
41
  "@expo/vector-icons": "^15.1.1",
42
- "@oxy.so/bloom": "^9.0.0",
43
- "@oxy.so/core": "^3.0.0 || ^4.0.0",
44
- "@oxy.so/services": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
42
+ "@oxy.so/bloom": ">=10.0.0",
43
+ "@oxy.so/core": ">=3.0.0",
44
+ "@oxy.so/services": ">=8.0.0",
45
45
  "@react-native-async-storage/async-storage": "^2.2.0",
46
46
  "expo-blur": "^57.0.0",
47
47
  "react-native-reanimated": "^4.5.0",
@@ -56,6 +56,7 @@
56
56
  "expo-haptics": "^57.0.0",
57
57
  "nativewind": "^5.0.0-preview.3",
58
58
  "expo-clipboard": "^57.0.0",
59
+ "expo-file-system": "^57.0.0",
59
60
  "expo-speech-recognition": "^57.1.0"
60
61
  },
61
62
  "peerDependenciesMeta": {
@@ -1,27 +1,15 @@
1
- import { AgentAvatar, type AvatarConfig, DEFAULT_CONFIG } from '@oxy.so/bloom/agent-avatar';
2
- import { useEffect, useState } from 'react';
1
+ import { AgentAvatar, type AvatarCharacter } from '@oxy.so/bloom/agent-avatar';
2
+ import { memo, useEffect, useState } from 'react';
3
3
 
4
4
  /** What Alia is doing; the avatar plays a working turn each time it starts to think or work. */
5
5
  export type AliaAvatarState = 'idle' | 'thinking' | 'working' | 'writing';
6
6
 
7
7
  /**
8
- * Alia's likeness in Bloom's 3D character engine: the six-lobed flower in orchid,
9
- * plain oval eyes, nothing on its head. The engine ids come from Bloom's catalog
10
- * (`character-shapes.ts`, `character-colors.ts`); change them here and nowhere else.
8
+ * Alia's likeness in Bloom's 3D character engine: the six-lobed flower in orchid, plain oval
9
+ * eyes, nothing on its head. It is `blue_beret` with the hat taken off and the paint changed,
10
+ * so Bloom retargets that preset's animation onto it; change it here and nowhere else.
11
11
  */
12
- const ALIA_AVATAR: AvatarConfig = {
13
- ...DEFAULT_CONFIG,
14
- character: {
15
- preset: 'blue_beret',
16
- selections: {
17
- shape: 'six_lobed_flower',
18
- color: 'orchid',
19
- eyes: 'oval',
20
- eyewear: 'none',
21
- accessory: 'none',
22
- },
23
- },
24
- };
12
+ const ALIA_AVATAR: AvatarCharacter = { preset: 'blue_beret', accessory: 'none', color: 'orchid' };
25
13
 
26
14
  const BUSY: ReadonlySet<AliaAvatarState> = new Set(['thinking', 'working']);
27
15
 
@@ -34,6 +22,8 @@ export interface AliaAvatarProps {
34
22
  interactive?: boolean;
35
23
  /** Change it to replay the avatar's reaction (a wave) — for a button that wraps it. */
36
24
  reactionKey?: number;
25
+ /** A still thumbnail instead of a live 3D avatar: for small, permanent spots like a sidebar. */
26
+ portrait?: boolean;
37
27
  }
38
28
 
39
29
  /**
@@ -41,11 +31,12 @@ export interface AliaAvatarProps {
41
31
  * app must mount `AgentAvatarProvider` with the URL of the hosted `character-runtime`;
42
32
  * without it Bloom shows its own "unavailable" state.
43
33
  */
44
- export function AliaAvatar({
34
+ export const AliaAvatar = memo(function AliaAvatar({
45
35
  size,
46
36
  state = 'idle',
47
37
  interactive = false,
48
38
  reactionKey,
39
+ portrait = false,
49
40
  }: AliaAvatarProps) {
50
41
  const [workingKey, setWorkingKey] = useState(0);
51
42
  const busy = BUSY.has(state);
@@ -55,12 +46,13 @@ export function AliaAvatar({
55
46
 
56
47
  return (
57
48
  <AgentAvatar
58
- config={ALIA_AVATAR}
49
+ character={ALIA_AVATAR}
59
50
  size={size}
60
51
  label="Alia"
61
52
  workingKey={workingKey}
62
53
  interactive={interactive}
63
54
  reactionKey={reactionKey}
55
+ portrait={portrait}
64
56
  />
65
57
  );
66
- }
58
+ });
@@ -7,17 +7,8 @@
7
7
  * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
8
8
  */
9
9
 
10
- import React, {
11
- createContext,
12
- Suspense,
13
- useContext,
14
- useState,
15
- useCallback,
16
- useEffect,
17
- useMemo,
18
- useRef,
19
- } from 'react';
20
- import { View, StyleSheet } from 'react-native';
10
+ import React, { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
11
+ import { View } from 'react-native';
21
12
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
22
13
  import { useTTS } from '../hooks/useTTS';
23
14
  import { useSpeechToText } from '../hooks/useSpeechToText';
@@ -34,7 +25,7 @@ import { RiImageLine } from '@oxy.so/bloom/icons';
34
25
  import { toast } from '@oxy.so/bloom/toast';
35
26
  import { Button } from '@oxy.so/bloom/button';
36
27
  import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
37
- import { AliaAvatar, type AliaAvatarState } from './AliaAvatar';
28
+ import type { AliaAvatarState } from './AliaAvatar';
38
29
  import { VoiceModeIcon } from './VoiceModeIcon';
39
30
  import type { ChatMessage, VoiceMessage, VoiceSessionComponent, VoiceSessionState } from '../types';
40
31
 
@@ -56,17 +47,12 @@ export interface AliaChatContentProps {
56
47
  onRejectPlan?: (planId: string) => void;
57
48
  /** Override markdown renderer (app passes CustomMarkdown) */
58
49
  renderMarkdown?: (content: string) => React.ReactNode;
59
- /** Breadcrumb title beside the brand mark. Default `Alia`; `null` drops the breadcrumb. */
60
- title?: string | null;
61
- /**
62
- * Above the breadcrumb row — a back button, a menu, or a whole custom header
63
- * (given `title={null}`). A function receives the clear handler.
64
- */
65
- headerLeading?:
66
- | React.ReactNode
67
- | ((state: { hasMessages: boolean; clear: () => void }) => React.ReactNode);
68
- /** Right end of the breadcrumb row; receives the clear handler. Replaces Bloom's share/more. */
69
- actions?: (state: { hasMessages: boolean; clear: () => void }) => React.ReactNode;
50
+ /** The chat's top bar. Receives what it needs from the chat: whether there is anything to clear, how, and what Alia is doing. */
51
+ header?: (state: {
52
+ hasMessages: boolean;
53
+ clear: () => void;
54
+ markState: AliaAvatarState;
55
+ }) => React.ReactNode;
70
56
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
71
57
  isDarkMode?: boolean;
72
58
  /**
@@ -81,19 +67,6 @@ export interface AliaChatContentProps {
81
67
  labels?: Partial<AliaChatLabels>;
82
68
  }
83
69
 
84
- /** The live state of the chat, read by the avatar wherever the header or welcome draws it. */
85
- const MarkStateContext = createContext<AliaAvatarState>('idle');
86
-
87
- /** Alia's avatar at any size, following the chat's live state. */
88
- export function AliaChatMark({ size, reactionKey }: { size: number; reactionKey?: number }) {
89
- return <AliaAvatar size={size} state={useContext(MarkStateContext)} reactionKey={reactionKey} />;
90
- }
91
-
92
- /** `AiChatContainer`'s `projectIcon` takes a component that is handed its size as `width`. */
93
- function BreadcrumbMark({ width }: { width?: number }) {
94
- return <AliaChatMark size={width ?? 16} />;
95
- }
96
-
97
70
  /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
98
71
  function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
99
72
  return [
@@ -131,9 +104,7 @@ function AliaChatContentBody({
131
104
  onApprovePlan,
132
105
  onRejectPlan,
133
106
  renderMarkdown,
134
- title = 'Alia',
135
- headerLeading,
136
- actions,
107
+ header,
137
108
  isDarkMode,
138
109
  voiceSession: VoiceSession,
139
110
  onStopReady,
@@ -162,7 +133,9 @@ function AliaChatContentBody({
162
133
  const [inputValue, setInputValue] = useState('');
163
134
 
164
135
  // Picked photos wait here, as tiles above the field, until the turn is sent.
165
- const [photos, setPhotos] = useState<Array<{ id: string; name: string; uri: string }>>([]);
136
+ const [photos, setPhotos] = useState<
137
+ Array<{ id: string; name: string; uri: string; mimeType?: string }>
138
+ >([]);
166
139
  const { pickImage } = useImagePicker();
167
140
 
168
141
  const handleAddMenuSelect = useCallback(
@@ -176,6 +149,7 @@ function AliaChatContentBody({
176
149
  id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
177
150
  name: asset.name,
178
151
  uri: asset.uri,
152
+ mimeType: asset.mimeType,
179
153
  })),
180
154
  ]);
181
155
  },
@@ -190,7 +164,9 @@ function AliaChatContentBody({
190
164
  setInputValue('');
191
165
  setPhotos([]);
192
166
  try {
193
- const images = await Promise.all(pending.map((photo) => imageUriToDataUrl(photo.uri)));
167
+ const images = await Promise.all(
168
+ pending.map((photo) => imageUriToDataUrl(photo.uri, photo.mimeType)),
169
+ );
194
170
  send(text, { images });
195
171
  } catch (err) {
196
172
  // Keep the draft: the photos could not be read, nothing was sent.
@@ -237,11 +213,13 @@ function AliaChatContentBody({
237
213
  setMessages([...textSnapshotRef.current, ...voiceMessages.map(adaptVoiceMessage)]);
238
214
  }, [voiceMessages, setMessages]);
239
215
 
216
+ const messagesRef = useRef(messages);
217
+ messagesRef.current = messages;
240
218
  const activateVoice = useCallback(() => {
241
219
  if (isVoiceActive) return;
242
- textSnapshotRef.current = [...messages];
220
+ textSnapshotRef.current = [...messagesRef.current];
243
221
  setIsVoiceActive(true);
244
- }, [isVoiceActive, messages]);
222
+ }, [isVoiceActive]);
245
223
 
246
224
  // Unmounting the session disconnects the room; the transcript merged above
247
225
  // stays in the chat.
@@ -258,11 +236,12 @@ function AliaChatContentBody({
258
236
  );
259
237
 
260
238
  // ── TTS read aloud handler ──
239
+ const { readAloud } = tts;
261
240
  const handleReadAloud = useCallback(
262
241
  (messageId: string, text: string) => {
263
- tts.readAloud(messageId, text);
242
+ readAloud(messageId, text);
264
243
  },
265
- [tts],
244
+ [readAloud],
266
245
  );
267
246
 
268
247
  // ── Mark state (drives the header brand mark) ──
@@ -329,6 +308,17 @@ function AliaChatContentBody({
329
308
  [VoiceSession, activateVoice, labels.startVoice],
330
309
  );
331
310
 
311
+ const pillAttachments = useMemo(
312
+ () =>
313
+ photos.map((photo) => ({
314
+ id: photo.id,
315
+ name: photo.name,
316
+ kind: 'image' as const,
317
+ src: photo.uri,
318
+ })),
319
+ [photos],
320
+ );
321
+
332
322
  const composer =
333
323
  VoiceSession && isVoiceActive ? (
334
324
  /* Suspense so the session may be handed to us as a React.lazy import. */
@@ -350,12 +340,7 @@ function AliaChatContentBody({
350
340
  placeholder={labels.placeholder}
351
341
  addMenu={addMenuGroups}
352
342
  onAddMenuSelect={handleAddMenuSelect}
353
- attachments={photos.map((photo) => ({
354
- id: photo.id,
355
- name: photo.name,
356
- kind: 'image' as const,
357
- src: photo.uri,
358
- }))}
343
+ attachments={pillAttachments}
359
344
  onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
360
345
  voiceState={
361
346
  stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'
@@ -366,63 +351,56 @@ function AliaChatContentBody({
366
351
  </>
367
352
  );
368
353
 
354
+ const hasMessages = messages.length > 0;
355
+ const headerNode = useMemo(
356
+ () => header?.({ hasMessages, clear, markState }),
357
+ [header, hasMessages, clear, markState],
358
+ );
359
+ const background = useMemo(
360
+ () => (
361
+ <AmbientField
362
+ layout="sheet"
363
+ waveAmplitude={wave.waveAmplitude}
364
+ agentState={wave.agentState}
365
+ intensity={ambientIntensity}
366
+ isDarkMode={isDarkMode}
367
+ />
368
+ ),
369
+ [wave.waveAmplitude, wave.agentState, ambientIntensity, isDarkMode],
370
+ );
371
+
369
372
  return (
370
- <MarkStateContext.Provider value={markState}>
371
- <AiChatContainer
372
- project={title ?? undefined}
373
- projectIcon={BreadcrumbMark}
374
- header={
375
- typeof headerLeading === 'function'
376
- ? headerLeading({ hasMessages: messages.length > 0, clear })
377
- : headerLeading
378
- }
379
- actions={actions?.({ hasMessages: messages.length > 0, clear })}
380
- composer={composer}
381
- // The thread scrolls behind the header and the composer, which fade in over it — as
382
- // in the app's conversation. The wave is the container's background layer, between
383
- // its surface and the turns.
384
- floatingChrome
385
- background={
386
- <AmbientField
387
- waveAmplitude={wave.waveAmplitude}
388
- agentState={wave.agentState}
389
- intensity={ambientIntensity}
390
- isDarkMode={isDarkMode}
391
- />
392
- }
393
- >
394
- <View style={styles.body}>
395
- {/* Welcome or Messages */}
396
- <AliaChatMessageList
397
- messages={messages}
398
- isStreaming={isStreaming}
399
- onReadAloud={handleReadAloud}
400
- ttsActiveMessageId={tts.activeMessageId}
401
- ttsPlaybackState={tts.playbackState}
402
- onEditMessage={onEditMessage}
403
- onThumbsUp={onThumbsUp}
404
- onThumbsDown={onThumbsDown}
405
- onApprovePlan={onApprovePlan}
406
- onRejectPlan={onRejectPlan}
407
- renderMarkdown={renderMarkdown}
408
- welcomeComponent={welcomeComponent}
409
- />
410
- </View>
411
- </AiChatContainer>
412
- </MarkStateContext.Provider>
373
+ <AiChatContainer
374
+ header={headerNode}
375
+ composer={composer}
376
+ // The thread scrolls behind the header and the composer, which fade in over it — as
377
+ // in the app's conversation. The wave is the container's background layer, between
378
+ // its surface and the turns.
379
+ floatingChrome
380
+ background={background}
381
+ >
382
+ <View className="flex-1">
383
+ {/* Welcome or Messages */}
384
+ <AliaChatMessageList
385
+ messages={messages}
386
+ isStreaming={isStreaming}
387
+ onReadAloud={handleReadAloud}
388
+ ttsActiveMessageId={tts.activeMessageId}
389
+ ttsPlaybackState={tts.playbackState}
390
+ onEditMessage={onEditMessage}
391
+ onThumbsUp={onThumbsUp}
392
+ onThumbsDown={onThumbsDown}
393
+ onApprovePlan={onApprovePlan}
394
+ onRejectPlan={onRejectPlan}
395
+ onAnswerQuestions={(message) => send(message)}
396
+ renderMarkdown={renderMarkdown}
397
+ welcomeComponent={welcomeComponent}
398
+ />
399
+ </View>
400
+ </AiChatContainer>
413
401
  );
414
402
  }
415
403
 
416
- const styles = StyleSheet.create({
417
- container: {
418
- flex: 1,
419
- position: 'relative',
420
- },
421
- body: {
422
- flex: 1,
423
- },
424
- });
425
-
426
404
  /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
427
405
  export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
428
406
  return (
@@ -1,14 +1,15 @@
1
1
  import { ButtonGroup, ButtonGroupItem } from '@oxy.so/bloom/button-group';
2
2
  import { RiCloseLine, RiDeleteBin6Line } from '@oxy.so/bloom/icons';
3
3
  import { PageHeader } from '@oxy.so/bloom/page-header';
4
- import { useTheme } from '@oxy.so/bloom/theme';
5
4
  import { Text } from '@oxy.so/bloom/typography';
6
- import { type ReactNode, useState } from 'react';
7
- import { Pressable, StyleSheet, View } from 'react-native';
5
+ import { memo, type ReactNode, useState } from 'react';
6
+ import { Pressable, View } from 'react-native';
8
7
  import { useLabels } from '../lib/labels';
9
- import { AliaChatMark } from './AliaChatContent';
8
+ import { AliaAvatar, type AliaAvatarState } from './AliaAvatar';
10
9
 
11
10
  export interface AliaChatHeaderProps {
11
+ /** What Alia is doing; the avatar shows it. */
12
+ markState: AliaAvatarState;
12
13
  /** The chat has messages to clear. */
13
14
  hasMessages: boolean;
14
15
  onClear: () => void;
@@ -29,9 +30,7 @@ export interface AliaChatHeaderProps {
29
30
  * of radius 14, `backgroundSecondary` with a hairline border) but is not exported, so
30
31
  * this repeats its geometry. Use Bloom's component when it is.
31
32
  */
32
- function AliaProfile({ onPress }: { onPress?: () => void }) {
33
- const { colors } = useTheme();
34
- const [hovered, setHovered] = useState(false);
33
+ function AliaProfile({ markState, onPress }: { markState: AliaAvatarState; onPress?: () => void }) {
35
34
  const [reactionKey, setReactionKey] = useState(0);
36
35
 
37
36
  // Without a host action the profile is still a button: the avatar waves back.
@@ -45,21 +44,14 @@ function AliaProfile({ onPress }: { onPress?: () => void }) {
45
44
  accessibilityRole="button"
46
45
  accessibilityLabel="Alia"
47
46
  onPress={handlePress}
48
- onHoverIn={() => setHovered(true)}
49
- onHoverOut={() => setHovered(false)}
50
- className="items-center"
47
+ className="group/profile items-center"
51
48
  >
52
- <View pointerEvents="none" style={{ marginBottom: -2, zIndex: 1 }}>
53
- <AliaChatMark size={56} reactionKey={reactionKey} />
49
+ <View pointerEvents="none" className="z-10 -mb-0.5">
50
+ <AliaAvatar size={56} state={markState} reactionKey={reactionKey} />
54
51
  </View>
55
52
  <View
56
53
  pointerEvents="none"
57
- className="items-center justify-center rounded-[14px] px-2.5 py-1"
58
- style={{
59
- backgroundColor: hovered ? colors.backgroundTertiary : colors.backgroundSecondary,
60
- borderWidth: StyleSheet.hairlineWidth,
61
- borderColor: colors.border,
62
- }}
54
+ className="items-center justify-center rounded-[14px] border border-border-secondary bg-background-secondary-default px-2.5 py-1 group-hover/profile:bg-background-tertiary-default"
63
55
  >
64
56
  <Text numberOfLines={1} className="text-[16px] font-medium leading-5 text-text-primary">
65
57
  Alia
@@ -73,7 +65,8 @@ function AliaProfile({ onPress }: { onPress?: () => void }) {
73
65
  * The chat's top bar: Bloom's `PageHeader`, set up as the shell sets it up for the
74
66
  * agents' screens (inline, no scrim, no safe area), with Alia's profile as its title.
75
67
  */
76
- export function AliaChatHeader({
68
+ export const AliaChatHeader = memo(function AliaChatHeader({
69
+ markState,
77
70
  hasMessages,
78
71
  onClear,
79
72
  onClose,
@@ -85,7 +78,7 @@ export function AliaChatHeader({
85
78
  return (
86
79
  <PageHeader
87
80
  leading={leading}
88
- title={<AliaProfile onPress={onProfilePress} />}
81
+ title={<AliaProfile markState={markState} onPress={onProfilePress} />}
89
82
  titleAlign="center"
90
83
  actions={
91
84
  <>
@@ -115,4 +108,4 @@ export function AliaChatHeader({
115
108
  safeArea={false}
116
109
  />
117
110
  );
118
- }
111
+ });
@@ -33,6 +33,8 @@ import {
33
33
  } from '@oxy.so/bloom/icons';
34
34
  import { ThinkingBlock } from './ThinkingBlock';
35
35
  import { PlanPreviewCard } from './PlanPreviewCard';
36
+ import { QuestionCard } from './QuestionCard';
37
+ import { askedQuestions, isQuestionInvocation } from '../lib/questions';
36
38
  import { chatMessageEnter } from '../animations';
37
39
  import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
38
40
  import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
@@ -67,6 +69,8 @@ export interface AliaChatMessageListProps {
67
69
  onThumbsDown?: (messageId: string) => void;
68
70
  onApprovePlan?: (planId: string) => void;
69
71
  onRejectPlan?: (planId: string) => void;
72
+ /** Sends the answers to Alia's questions as the person's next message. */
73
+ onAnswerQuestions?: (message: string) => void;
70
74
  // Welcome
71
75
  welcomeComponent?: React.ReactNode;
72
76
  // Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
@@ -150,11 +154,7 @@ const UserBubble = React.memo(function UserBubble({
150
154
  <AiChatMessageLine block>
151
155
  <View className="flex-row flex-wrap gap-2">
152
156
  {images.map((imgUrl) => (
153
- <View
154
- key={imgUrl}
155
- className="rounded-xl overflow-hidden"
156
- style={{ width: 120, height: 120 }}
157
- >
157
+ <View key={imgUrl} className="size-[120px] overflow-hidden rounded-xl">
158
158
  <ExpoImage
159
159
  source={{ uri: imgUrl }}
160
160
  className="w-full h-full"
@@ -188,6 +188,7 @@ const AssistantMessage = React.memo(function AssistantMessage({
188
188
  onThumbsDown,
189
189
  onApprovePlan,
190
190
  onRejectPlan,
191
+ onAnswerQuestions,
191
192
  renderMarkdown,
192
193
  }: {
193
194
  message: ChatMessage;
@@ -203,6 +204,7 @@ const AssistantMessage = React.memo(function AssistantMessage({
203
204
  onThumbsDown?: (messageId: string) => void;
204
205
  onApprovePlan?: (planId: string) => void;
205
206
  onRejectPlan?: (planId: string) => void;
207
+ onAnswerQuestions?: (message: string) => void;
206
208
  renderMarkdown?: (content: string) => React.ReactNode;
207
209
  }) {
208
210
  const labels = useLabels();
@@ -217,8 +219,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
217
219
  () => webSearchLog(toolInvocations, researchProgress, { t: labels.workLog }),
218
220
  [toolInvocations, researchProgress, labels.workLog],
219
221
  );
222
+ // The questions Alia asked, drawn as a card. Answerable only on the last message once the
223
+ // turn is over; any later message (typed or sent from the card) is the answer.
224
+ const questions = useMemo(
225
+ () => (toolInvocations ?? []).flatMap((inv) => askedQuestions(inv) ?? []),
226
+ [toolInvocations],
227
+ );
220
228
  const taskLog = useMemo(() => {
221
- const tasks = (toolInvocations ?? []).filter((inv) => !isWebInvocation(inv));
229
+ const tasks = (toolInvocations ?? []).filter(
230
+ (inv) => !isWebInvocation(inv) && !isQuestionInvocation(inv),
231
+ );
222
232
  return tasks.length === 0 || isStreamingThis
223
233
  ? null
224
234
  : taskListLog(tasks, false, { t: labels.workLog });
@@ -338,6 +348,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
338
348
  </View>
339
349
  )}
340
350
 
351
+ {questions.length === 0 ? null : (
352
+ <QuestionCard
353
+ questions={questions}
354
+ onSubmit={
355
+ isLastMessage && !isLoading && onAnswerQuestions !== undefined
356
+ ? onAnswerQuestions
357
+ : undefined
358
+ }
359
+ />
360
+ )}
341
361
  {thinkingIndicator}
342
362
  </View>
343
363
  );
@@ -356,6 +376,7 @@ export function AliaChatMessageList({
356
376
  onThumbsDown,
357
377
  onApprovePlan,
358
378
  onRejectPlan,
379
+ onAnswerQuestions,
359
380
  welcomeComponent,
360
381
  renderMarkdown,
361
382
  }: AliaChatMessageListProps) {
@@ -459,6 +480,7 @@ export function AliaChatMessageList({
459
480
  onThumbsDown={onThumbsDown}
460
481
  onApprovePlan={onApprovePlan}
461
482
  onRejectPlan={onRejectPlan}
483
+ onAnswerQuestions={onAnswerQuestions}
462
484
  renderMarkdown={renderMarkdown}
463
485
  />
464
486
  )}
@@ -17,12 +17,11 @@
17
17
  * `livekit-client` never enters the bundle.
18
18
  */
19
19
 
20
- import React from 'react';
21
- import { useTheme } from '@oxy.so/bloom/theme';
20
+ import React, { useCallback } from 'react';
22
21
  import { View } from 'react-native';
23
22
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
24
23
  import { KeyboardAvoidingView } from '../lib/keyboard';
25
- import { AliaChatContent } from './AliaChatContent';
24
+ import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
26
25
  import { AliaChatHeader } from './AliaChatHeader';
27
26
  import type { WelcomeSuggestion } from './AliaWelcomeMessage';
28
27
  import type { AliaChatLabels } from '../lib/labels';
@@ -67,7 +66,18 @@ export function AliaChatScreen({
67
66
  labels,
68
67
  }: AliaChatScreenProps) {
69
68
  const insets = useSafeAreaInsets();
70
- const { colors } = useTheme();
69
+ const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
70
+ ({ hasMessages, clear, markState }) => (
71
+ <AliaChatHeader
72
+ markState={markState}
73
+ hasMessages={hasMessages}
74
+ onClear={clear}
75
+ leading={headerLeft}
76
+ trailing={headerRight}
77
+ />
78
+ ),
79
+ [headerLeft, headerRight],
80
+ );
71
81
 
72
82
  // The screen owns the whole window, so it keeps its composer out of both
73
83
  // system bars: the top inset clears the status bar, the bottom one the
@@ -81,12 +91,8 @@ export function AliaChatScreen({
81
91
  // this deep in a host's tree, which is why the offset is measured.)
82
92
  return (
83
93
  <View
84
- className="flex-1"
85
- style={{
86
- backgroundColor: colors.background,
87
- paddingTop: insets.top,
88
- paddingBottom: insets.bottom,
89
- }}
94
+ className="flex-1 bg-background-full"
95
+ style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
90
96
  >
91
97
  <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
92
98
  <AliaChatContent
@@ -99,15 +105,7 @@ export function AliaChatScreen({
99
105
  isDarkMode={isDarkMode}
100
106
  voiceSession={voiceSession}
101
107
  labels={labels}
102
- title={null}
103
- headerLeading={({ hasMessages, clear }) => (
104
- <AliaChatHeader
105
- hasMessages={hasMessages}
106
- onClear={clear}
107
- leading={headerLeft}
108
- trailing={headerRight}
109
- />
110
- )}
108
+ header={header}
111
109
  />
112
110
  </KeyboardAvoidingView>
113
111
  </View>
@@ -3,7 +3,7 @@ import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
3
3
  import { useColorScheme, View } from 'react-native';
4
4
  import type { AliaChatLabels } from '../lib/labels';
5
5
  import type { VoiceSessionComponent } from '../types';
6
- import { AliaChatContent } from './AliaChatContent';
6
+ import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
7
7
  import { AliaChatHeader } from './AliaChatHeader';
8
8
  import type { WelcomeSuggestion } from './AliaWelcomeMessage';
9
9
 
@@ -90,6 +90,22 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
90
90
  return true;
91
91
  }, [stopChat]);
92
92
 
93
+ // The top padding clears the 28px drag handle, which Bloom floats over the content instead
94
+ // of reserving room for. Stable, so the chat's memoised header only re-renders when it must.
95
+ const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
96
+ ({ hasMessages, clear, markState }) => (
97
+ <View className="pt-8">
98
+ <AliaChatHeader
99
+ markState={markState}
100
+ hasMessages={hasMessages}
101
+ onClear={clear}
102
+ onClose={handleDismiss}
103
+ />
104
+ </View>
105
+ ),
106
+ [handleDismiss],
107
+ );
108
+
93
109
  return (
94
110
  <BottomSheet
95
111
  ref={sheetRef}
@@ -113,12 +129,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
113
129
  onStopReady={registerChatStop}
114
130
  // The top padding clears the 28px drag handle, which Bloom floats over the content
115
131
  // instead of reserving room for.
116
- title={null}
117
- headerLeading={({ hasMessages, clear }) => (
118
- <View className="pt-8">
119
- <AliaChatHeader hasMessages={hasMessages} onClear={clear} onClose={handleDismiss} />
120
- </View>
121
- )}
132
+ header={header}
122
133
  />
123
134
  </BottomSheet>
124
135
  );
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { memo, useEffect, useRef, useState } from 'react';
2
2
  import { AppState, Platform, View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
3
3
  import Animated, {
4
4
  cancelAnimation,
@@ -55,16 +55,16 @@ const EXIT_EASE = Easing.bezier(0.2, 0.7, 0.2, 1);
55
55
  const PULSE_DURATION = 900;
56
56
 
57
57
  /**
58
- * A field shorter than a phone screen (the chat's sheet) is not drawn at its own scale: the
59
- * blobs are sized in `vmax` of the field, which would be its small side, and laid out across
60
- * its width, which would pile them on the centre. It takes the blob size of a phone screen,
61
- * and its blobs are spread wider (`SHORT_FIELD_SPREAD`), cropped by its edges — the blue, warm
62
- * and green of the intro side by side, not one smudge in the middle.
58
+ * How the field lays itself out in a container that is not a screen. The blobs are sized in
59
+ * `vmax` of the field and laid out across its width, which on a short, wide card (the chat's
60
+ * sheet) would make them half as big and pile them on the centre. The `sheet` layout draws
61
+ * them at the size of a phone screen (`reference`), spread wider across the width (`spread`)
62
+ * and shifted back toward the right (`shift`, a share of the width, since the spread leaves the
63
+ * composition leaning left), cropped by the card's edges: the blue, warm and green of the
64
+ * intro side by side. Asked for, not inferred from the height, so a short app screen is
65
+ * never reinterpreted.
63
66
  */
64
- const MIN_FIELD_REFERENCE = 800;
65
- const SHORT_FIELD_SPREAD = 1.4;
66
- /** The spread leaves the composition leaning left; this much of the width puts it back. */
67
- const SHORT_FIELD_SHIFT = 0.17;
67
+ const SHEET_LAYOUT = { reference: 800, spread: 1.4, shift: 0.17 } as const;
68
68
 
69
69
  /** One keyframe stop of a float loop: [translateX in vw, translateY in vh, scale]. */
70
70
  type FloatStop = readonly [number, number, number];
@@ -368,6 +368,8 @@ function Flourish({
368
368
  }
369
369
 
370
370
  export interface AmbientFieldProps {
371
+ /** `screen` (default) fills a page; `sheet` is for a short card, see `SHEET_LAYOUT`. */
372
+ layout?: 'screen' | 'sheet';
371
373
  /**
372
374
  * Combined amplitude from `useAmbientWave` — swells and brightens the blobs.
373
375
  * Omit where there is no audio (the intro) and the field simply rests.
@@ -420,7 +422,8 @@ export interface AmbientFieldProps {
420
422
  * layer, nothing is seen THROUGH it, and the translucent surfaces over it are
421
423
  * Bloom's, which follow that setting themselves.
422
424
  */
423
- export function AmbientField({
425
+ export const AmbientField = memo(function AmbientField({
426
+ layout = 'screen',
424
427
  waveAmplitude,
425
428
  agentState = 'idle',
426
429
  intensity = 1,
@@ -487,11 +490,11 @@ export function AmbientField({
487
490
  setField({ width, height });
488
491
  };
489
492
 
490
- const short = field.height > 0 && field.height < MIN_FIELD_REFERENCE;
491
- const vw = (field.width / 100) * (short ? SHORT_FIELD_SPREAD : 1);
493
+ const sheet = layout === 'sheet';
494
+ const vw = (field.width / 100) * (sheet ? SHEET_LAYOUT.spread : 1);
492
495
  const vh = field.height / 100;
493
- const vmax = Math.max(field.width, field.height, short ? MIN_FIELD_REFERENCE : 0) / 100;
494
- const shiftX = short ? field.width * SHORT_FIELD_SHIFT : 0;
496
+ const vmax = Math.max(field.width, field.height, sheet ? SHEET_LAYOUT.reference : 0) / 100;
497
+ const shiftX = sheet ? field.width * SHEET_LAYOUT.shift : 0;
495
498
 
496
499
  const fieldStyle = useAnimatedStyle(() => ({
497
500
  // On the way out the field keeps 45% of its opacity, sinking as it goes.
@@ -572,6 +575,6 @@ export function AmbientField({
572
575
  ) : null}
573
576
  </View>
574
577
  );
575
- }
578
+ });
576
579
 
577
580
  export { PARALLAX_DURATION, PARALLAX_EASE };
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import { Pressable, View } from 'react-native';
3
+ import type { AliaAvatarState } from './AliaAvatar';
3
4
  import * as Haptics from 'expo-haptics';
4
5
  import Svg, { Path } from 'react-native-svg';
5
6
  import Animated, {
@@ -13,7 +14,8 @@ import Animated, {
13
14
  Easing,
14
15
  } from 'react-native-reanimated';
15
16
 
16
- export type IdentityMarkState = 'idle' | 'thinking' | 'working' | 'writing';
17
+ /** What the mark is doing: the same states as the avatar's. */
18
+ export type IdentityMarkState = AliaAvatarState;
17
19
 
18
20
  export interface IdentityMarkProps {
19
21
  /** Width & height in px. Default 24. */
@@ -0,0 +1,62 @@
1
+ import { Questionnaire, type QuestionnaireAnswers } from '@oxy.so/bloom/questionnaire';
2
+ import { Muted } from '@oxy.so/bloom/typography';
3
+ import { View } from 'react-native';
4
+ import { type AskedQuestion, formatAnswers } from '../lib/questions';
5
+
6
+ interface QuestionCardProps {
7
+ questions: AskedQuestion[];
8
+ /** Sends the answers as the person's next message. Absent: the card is read-only. */
9
+ onSubmit?: (message: string) => void;
10
+ }
11
+
12
+ /** Ids are positions: the questions are the model's, and carry none. */
13
+ const idOf = (index: number) => `q${index}`;
14
+
15
+ /**
16
+ * The questions Alia asked: Bloom's `Questionnaire` while the person can still answer, and
17
+ * the questions as plain lines once they have (the answer is their message, right below).
18
+ * Typing a reply in the composer answers just as well, which is why there is no dismiss.
19
+ */
20
+ export function QuestionCard({ questions, onSubmit }: QuestionCardProps) {
21
+ if (onSubmit === undefined) {
22
+ return (
23
+ <View className="my-2 gap-1">
24
+ {questions.map((q, i) => (
25
+ // The model's questions never reorder: the position is the identity.
26
+ // biome-ignore lint/suspicious/noArrayIndexKey: see above
27
+ <Muted key={i}>{q.question}</Muted>
28
+ ))}
29
+ </View>
30
+ );
31
+ }
32
+
33
+ const complete = (answers: QuestionnaireAnswers) =>
34
+ onSubmit(
35
+ formatAnswers(
36
+ questions,
37
+ questions.map((_, i) => ({
38
+ picked: (answers[idOf(i)]?.values ?? []).map(Number),
39
+ other: answers[idOf(i)]?.other,
40
+ })),
41
+ ),
42
+ );
43
+
44
+ return (
45
+ <View className="my-2">
46
+ <Questionnaire
47
+ questions={questions.map((q, i) => ({
48
+ id: idOf(i),
49
+ question: q.question,
50
+ select: q.multiSelect ? 'multiple' : 'single',
51
+ other: true,
52
+ options: q.options.map((option, index) => ({
53
+ value: String(index),
54
+ label: option.label,
55
+ description: option.description,
56
+ })),
57
+ }))}
58
+ onComplete={complete}
59
+ />
60
+ </View>
61
+ );
62
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,14 @@ export { AliaMarkdown } from './components/Markdown';
22
22
  export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
23
23
  export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
24
24
  export { PlanPreviewCard } from './components/PlanPreviewCard';
25
+ export { QuestionCard } from './components/QuestionCard';
26
+ export {
27
+ ASK_USER_QUESTION_TOOL,
28
+ askedQuestions,
29
+ formatAnswers,
30
+ isQuestionInvocation,
31
+ } from './lib/questions';
32
+ export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
25
33
 
26
34
  // ── Chat hook ──
27
35
  export { useAliaChat } from './hooks/useAliaChat';
@@ -75,13 +83,7 @@ export { getTextFromContent, getImagesFromContent } from './lib/content-utils';
75
83
  // ── The turn's work log: tool calls as Bloom's `WebSearch` and `TaskList` ──
76
84
  export { isWebInvocation, taskListLog, webSearchLog } from './lib/work-log';
77
85
  export type { LiveResearch, TaskListLog, WebSearchLog, WorkLogOptions } from './lib/work-log';
78
- export { englishWorkLog } from './lib/work-log-copy';
79
- export type { Translate } from './lib/work-log-copy';
80
- export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
81
- export { extractCitationSources } from './lib/citations';
82
- export type { CitationSource } from './lib/citations';
83
- export { toolCallStatus } from './lib/tool-call-status';
84
- export type { ToolCallStatus } from './lib/tool-call-status';
86
+ // The pure parts (call status, citations, tool labels, `Translate`) are `@alia.onl/sdk/tools`.
85
87
 
86
88
  // ── Picker hooks ──
87
89
  export { useImagePicker } from './hooks/useImagePicker';
@@ -1,4 +1,5 @@
1
1
  import type { PlanStep, ResearchProgress, ResearchSource } from '../types';
2
+ import { ASK_USER_QUESTION_TOOL } from './questions';
2
3
 
3
4
  const MAX_STREAM_BYTES = 8 * 1024 * 1024;
4
5
  const MAX_STREAM_LINES = 65_536;
@@ -579,6 +580,10 @@ export async function consumeAliaChatStream(
579
580
  }
580
581
  for (const event of parsed.events) {
581
582
  if (event.kind === 'content' && event.content.trim().length > 0) answerReceived = true;
583
+ // A turn that ends at a question card is answered by the card, even with no sentence before it.
584
+ if (event.kind === 'tool_call' && event.toolName === ASK_USER_QUESTION_TOOL) {
585
+ answerReceived = true;
586
+ }
582
587
  onEvent(event);
583
588
  }
584
589
  if (parsed.finishReason !== null) {
@@ -1,4 +1,5 @@
1
1
  import type { ToolInvocation } from '../types';
2
+ import { domainOf } from './domain';
2
3
 
3
4
  export interface CitationSource {
4
5
  /** The citation number, as it appears in `[n]`. */
@@ -14,14 +15,6 @@ interface PersistedResearchSource {
14
15
  title?: unknown;
15
16
  }
16
17
 
17
- function domainOf(url: string): string {
18
- try {
19
- return new URL(url).hostname.replace(/^www\./, '');
20
- } catch {
21
- return url;
22
- }
23
- }
24
-
25
18
  /**
26
19
  * The numbered sources of a research answer.
27
20
  *
@@ -43,8 +36,8 @@ export function extractCitationSources(
43
36
  const title =
44
37
  typeof raw.title === 'string' && raw.title.trim().length > 0
45
38
  ? raw.title.trim()
46
- : domainOf(url);
47
- byId.set(id, { id, url, title, domain: domainOf(url) });
39
+ : domainOf(url, { stripWww: true });
40
+ byId.set(id, { id, url, title, domain: domainOf(url, { stripWww: true }) });
48
41
  };
49
42
 
50
43
  for (const inv of toolInvocations ?? []) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The host of a URL, or the string itself when it is not one. Citations drop a leading `www.`
3
+ * (the number in `[3]` names a site); a source row in the work log keeps the host as served.
4
+ */
5
+ export function domainOf(url: string, { stripWww = false }: { stripWww?: boolean } = {}): string {
6
+ try {
7
+ const host = new URL(url).hostname;
8
+ return stripWww ? host.replace(/^www\./, '') : host;
9
+ } catch {
10
+ return url;
11
+ }
12
+ }
@@ -1,15 +1,36 @@
1
+ import * as FileSystem from 'expo-file-system';
2
+ import { Platform } from 'react-native';
3
+
1
4
  /**
2
- * Reads a picked image (a `file://` URI on native, a `blob:` URL on web) into a
3
- * `data:` URL, the one form the chat API accepts as an `image_url` part without
4
- * the server having to reach the device.
5
+ * Reads a picked image into a `data:` URL, the one form the chat API accepts as an `image_url`
6
+ * part without the server having to reach the device. The picker's URI means nothing off the
7
+ * device: on web it is a `blob:` URL (a handle into this tab's memory), on native a `file://`
8
+ * path, and sent as it is either arrives as a string the model cannot open.
9
+ *
10
+ * Web reads the blob back; native reads the file with `expo-file-system`, as the Alia app does.
5
11
  */
6
- export async function imageUriToDataUrl(uri: string): Promise<string> {
12
+ export async function imageUriToDataUrl(uri: string, mimeType?: string): Promise<string> {
7
13
  if (uri.startsWith('data:')) return uri;
14
+ if (Platform.OS !== 'web') {
15
+ const base64 = await FileSystem.readAsStringAsync(uri, {
16
+ encoding: FileSystem.EncodingType.Base64,
17
+ });
18
+ return `data:${mimeType || 'image/jpeg'};base64,${base64}`;
19
+ }
8
20
  const blob = await (await fetch(uri)).blob();
9
21
  return await new Promise<string>((resolve, reject) => {
10
22
  const reader = new FileReader();
11
23
  reader.onerror = () => reject(reader.error ?? new Error('Could not read the image'));
12
- reader.onload = () => resolve(String(reader.result));
24
+ reader.onload = () => {
25
+ const result = String(reader.result);
26
+ // `readAsDataURL` stamps the blob's own type; it is only octet-stream when the blob has
27
+ // none, and then the picker's type is the better guess.
28
+ resolve(
29
+ mimeType && result.startsWith('data:application/octet-stream')
30
+ ? result.replace('data:application/octet-stream', `data:${mimeType}`)
31
+ : result,
32
+ );
33
+ };
13
34
  reader.readAsDataURL(blob);
14
35
  });
15
36
  }
@@ -0,0 +1,78 @@
1
+ // The clarifying questions an assistant turn asked, read back from the stored tool call.
2
+ //
3
+ // The server's `askUserQuestion` ends the turn at the card, so the question IS the tool
4
+ // call: it is stored with the message, survives a reload, and is answered by the person's
5
+ // next message. Pure functions, no React Native, so a host's models and tests can use them.
6
+
7
+ import type { ToolInvocation } from '../types';
8
+
9
+ /** The tool's name on the wire. */
10
+ export const ASK_USER_QUESTION_TOOL = 'askUserQuestion';
11
+
12
+ export interface AskedOption {
13
+ label: string;
14
+ description?: string;
15
+ }
16
+
17
+ export interface AskedQuestion {
18
+ question: string;
19
+ options: AskedOption[];
20
+ multiSelect: boolean;
21
+ }
22
+
23
+ /** What the person picked for one question: option indexes, in option order, and typed text. */
24
+ export interface QuestionAnswer {
25
+ picked: number[];
26
+ other?: string;
27
+ }
28
+
29
+ export const isQuestionInvocation = (inv: Pick<ToolInvocation, 'toolName'>): boolean =>
30
+ inv.toolName === ASK_USER_QUESTION_TOOL;
31
+
32
+ const text = (value: unknown): string | null =>
33
+ typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
34
+
35
+ /**
36
+ * The questions of a finished call, or `null` when it is not one or its arguments are not
37
+ * drawable. Model output is data the app did not write: every field is checked, and a
38
+ * malformed card is no card rather than a thrown render.
39
+ */
40
+ export function askedQuestions(inv: ToolInvocation): AskedQuestion[] | null {
41
+ if (!isQuestionInvocation(inv) || inv.state !== 'result') return null;
42
+ const raw = (inv.args as { questions?: unknown } | undefined)?.questions;
43
+ if (!Array.isArray(raw)) return null;
44
+
45
+ const questions: AskedQuestion[] = [];
46
+ for (const item of raw) {
47
+ const q = item as { question?: unknown; options?: unknown; multiSelect?: unknown };
48
+ const question = text(q.question);
49
+ if (question === null || !Array.isArray(q.options)) return null;
50
+ const options: AskedOption[] = [];
51
+ for (const o of q.options) {
52
+ const label = text((o as { label?: unknown })?.label);
53
+ if (label === null) return null;
54
+ const description = text((o as { description?: unknown }).description);
55
+ options.push(description === null ? { label } : { label, description });
56
+ }
57
+ if (options.length < 2) return null;
58
+ questions.push({ question, options, multiSelect: q.multiSelect === true });
59
+ }
60
+ return questions.length === 0 ? null : questions;
61
+ }
62
+
63
+ /**
64
+ * The answers as the message the person "sends": one line per question, so the thread reads
65
+ * as what they said and the model reads the same text. Language-neutral on purpose.
66
+ */
67
+ export function formatAnswers(questions: AskedQuestion[], answers: QuestionAnswer[]): string {
68
+ return questions
69
+ .map((q, i) => {
70
+ const answer = answers[i];
71
+ const parts = [
72
+ ...(answer?.picked ?? []).map((index) => q.options[index]?.label),
73
+ answer?.other?.trim(),
74
+ ].filter((part): part is string => !!part);
75
+ return `${q.question}\n→ ${parts.join(', ')}`;
76
+ })
77
+ .join('\n\n');
78
+ }
@@ -1,46 +1,14 @@
1
1
  import { englishWorkLog, type Translate } from './work-log-copy';
2
2
 
3
- /** Tools that have a name of their own in the catalogue. Others are named from their id. */
4
- const NAMED_PILL = new Set([
5
- 'browse',
6
- 'webSearch',
7
- 'webScraper',
8
- 'generateFile',
9
- 'sendTelegramMessage',
10
- 'sendSlack',
11
- 'sendDiscord',
12
- 'sendSignal',
13
- 'sendEmail',
14
- 'delegateToAgent',
15
- 'askAgent',
16
- // The API's app router: the model opening a connected app whose tools did not all fit in
17
- // one request.
18
- 'useApps',
19
- 'agentSearch',
20
- 'userMemory',
21
- 'shellExec',
22
- 'fileEdit',
23
- 'codeInterpreter',
24
- ]);
25
-
26
- /** Tools whose finished label differs from the running one; the rest read the same. */
27
- const NAMED_DONE = new Set([
28
- 'browse',
29
- 'webScraper',
30
- 'generateFile',
31
- 'delegateToAgent',
32
- 'askAgent',
33
- 'useApps',
34
- 'agentSearch',
35
- 'userMemory',
36
- 'shellExec',
37
- 'fileEdit',
38
- 'codeInterpreter',
39
- ]);
3
+ /** Whether the catalogue names this tool itself; otherwise the label is made from its id. */
4
+ function isNamed(kind: 'pill' | 'done', toolName: string): boolean {
5
+ const key = `tasks.tool.${kind}.${toolName}`;
6
+ return englishWorkLog(key) !== key;
7
+ }
40
8
 
41
9
  /** A tool's short label, in the reader's language when a translator is given. */
42
10
  export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog): string {
43
- if (NAMED_PILL.has(toolName)) return t(`tasks.tool.pill.${toolName}`);
11
+ if (isNamed('pill', toolName)) return t(`tasks.tool.pill.${toolName}`);
44
12
 
45
13
  // Oxy service tools: oxy_serviceName__toolName → "ServiceName"
46
14
  if (toolName.startsWith('oxy_')) {
@@ -61,7 +29,7 @@ export function getToolPillLabel(toolName: string, t: Translate = englishWorkLog
61
29
  * ("Telegram", "Email") read the same either way, so they fall back to the running label.
62
30
  */
63
31
  export function getToolDoneLabel(toolName: string, t: Translate = englishWorkLog): string {
64
- return NAMED_DONE.has(toolName)
32
+ return isNamed('done', toolName)
65
33
  ? t(`tasks.tool.done.${toolName}`)
66
34
  : getToolPillLabel(toolName, t);
67
35
  }
@@ -10,6 +10,7 @@ import type { TaskListChip, TaskListStep, TaskListTask } from '@oxy.so/bloom/tas
10
10
  import type { WebSearchBrand, WebSearchSource, WebSearchStep } from '@oxy.so/bloom/web-search';
11
11
  import type { ToolInvocation } from '../types';
12
12
  import { extractCitationSources } from './citations';
13
+ import { domainOf } from './domain';
13
14
  import { getToolDoneLabel, getToolPillLabel } from './task-labels';
14
15
  import { toolCallStatus } from './tool-call-status';
15
16
  import { englishWorkLog, type Translate } from './work-log-copy';
@@ -63,14 +64,6 @@ function str(value: unknown): string | undefined {
63
64
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
64
65
  }
65
66
 
66
- function domainOf(url: string): string {
67
- try {
68
- return new URL(url).hostname;
69
- } catch {
70
- return url;
71
- }
72
- }
73
-
74
67
  /** The site marks Bloom draws, by registrable domain. Anything else is a quiet dot. */
75
68
  const BRANDS: Array<[string, WebSearchBrand]> = [
76
69
  ['github.com', 'github'],
package/src/tools.ts CHANGED
@@ -3,8 +3,16 @@
3
3
  // words for a tool. They load no React Native, so a host's models and tests can use them.
4
4
 
5
5
  export { extractCitationSources } from './lib/citations';
6
+ export { domainOf } from './lib/domain';
6
7
  export type { CitationSource } from './lib/citations';
7
8
  export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
9
+ export {
10
+ ASK_USER_QUESTION_TOOL,
11
+ askedQuestions,
12
+ formatAnswers,
13
+ isQuestionInvocation,
14
+ } from './lib/questions';
15
+ export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
8
16
  export { toolCallStatus } from './lib/tool-call-status';
9
17
  export type { ToolCallStatus } from './lib/tool-call-status';
10
18
  export { englishWorkLog } from './lib/work-log-copy';