@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 +39 -0
- package/package.json +5 -4
- package/src/components/AliaAvatar.tsx +13 -21
- package/src/components/AliaChatContent.tsx +82 -104
- package/src/components/AliaChatHeader.tsx +14 -21
- package/src/components/AliaChatMessageList.tsx +28 -6
- package/src/components/AliaChatScreen.tsx +17 -19
- package/src/components/AliaChatSheet.tsx +18 -7
- package/src/components/AmbientField.tsx +19 -16
- package/src/components/IdentityMark.tsx +3 -1
- package/src/components/QuestionCard.tsx +62 -0
- package/src/index.ts +9 -7
- package/src/lib/chat-stream.ts +5 -0
- package/src/lib/citations.ts +3 -10
- package/src/lib/domain.ts +12 -0
- package/src/lib/image-data-url.ts +26 -5
- package/src/lib/questions.ts +78 -0
- package/src/lib/task-labels.ts +7 -39
- package/src/lib/work-log.ts +1 -8
- package/src/tools.ts +8 -0
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": "
|
|
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": "
|
|
43
|
-
"@oxy.so/core": "
|
|
44
|
-
"@oxy.so/services": "
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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<
|
|
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(
|
|
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 = [...
|
|
220
|
+
textSnapshotRef.current = [...messagesRef.current];
|
|
243
221
|
setIsVoiceActive(true);
|
|
244
|
-
}, [isVoiceActive
|
|
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
|
-
|
|
242
|
+
readAloud(messageId, text);
|
|
264
243
|
},
|
|
265
|
-
[
|
|
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={
|
|
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
|
-
<
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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,
|
|
5
|
+
import { memo, type ReactNode, useState } from 'react';
|
|
6
|
+
import { Pressable, View } from 'react-native';
|
|
8
7
|
import { useLabels } from '../lib/labels';
|
|
9
|
-
import {
|
|
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
|
-
|
|
49
|
-
onHoverOut={() => setHovered(false)}
|
|
50
|
-
className="items-center"
|
|
47
|
+
className="group/profile items-center"
|
|
51
48
|
>
|
|
52
|
-
<View pointerEvents="none"
|
|
53
|
-
<
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* and
|
|
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
|
|
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
|
|
491
|
-
const vw = (field.width / 100) * (
|
|
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,
|
|
494
|
-
const shiftX =
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/src/lib/chat-stream.ts
CHANGED
|
@@ -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) {
|
package/src/lib/citations.ts
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 = () =>
|
|
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
|
+
}
|
package/src/lib/task-labels.ts
CHANGED
|
@@ -1,46 +1,14 @@
|
|
|
1
1
|
import { englishWorkLog, type Translate } from './work-log-copy';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 (
|
|
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
|
|
32
|
+
return isNamed('done', toolName)
|
|
65
33
|
? t(`tasks.tool.done.${toolName}`)
|
|
66
34
|
: getToolPillLabel(toolName, t);
|
|
67
35
|
}
|
package/src/lib/work-log.ts
CHANGED
|
@@ -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';
|