@alia.onl/sdk 12.0.0 → 13.1.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 +120 -0
- package/README.md +10 -12
- package/package.json +3 -3
- package/src/components/AliaAvatar.tsx +5 -1
- package/src/components/AliaChatContent.tsx +233 -57
- package/src/components/AliaChatHeader.tsx +100 -67
- package/src/components/AliaChatMessageList.tsx +173 -129
- package/src/components/AliaChatScreen.tsx +44 -16
- package/src/components/AliaChatSheet.tsx +155 -61
- package/src/components/AliaWelcomeMessage.tsx +26 -53
- package/src/components/ComposerSuggestions.tsx +45 -0
- package/src/components/Markdown.tsx +18 -24
- package/src/components/QuestionCard.tsx +35 -33
- package/src/components/ThinkingBlock.tsx +9 -13
- package/src/components/VoiceModeIcon.tsx +15 -7
- package/src/components/voice/VoiceControls.tsx +17 -9
- package/src/hooks/useAliaChat.ts +124 -5
- package/src/hooks/useComposerSuggestions.ts +84 -0
- package/src/index.ts +10 -3
- package/src/lib/agent-character.ts +28 -0
- package/src/lib/character-runtime.ts +10 -0
- package/src/lib/chat-stream.ts +20 -3
- package/src/lib/citations.ts +59 -0
- package/src/lib/delegation.ts +34 -0
- package/src/lib/flights.ts +24 -0
- package/src/lib/greeting.ts +15 -0
- package/src/lib/labels.tsx +17 -2
- package/src/lib/questions.ts +15 -1
- package/src/lib/suggestions.ts +53 -0
- package/src/lib/use-after-delay.ts +19 -0
- package/src/lib/work-log.ts +2 -1
- package/src/tools.ts +7 -1
- package/src/types.ts +6 -5
|
@@ -5,31 +5,21 @@ import { type AskedQuestion, formatAnswers } from '../lib/questions';
|
|
|
5
5
|
|
|
6
6
|
interface QuestionCardProps {
|
|
7
7
|
questions: AskedQuestion[];
|
|
8
|
-
/** Sends the answers as the person's next message.
|
|
9
|
-
onSubmit
|
|
8
|
+
/** Sends the answers as the person's next message. */
|
|
9
|
+
onSubmit: (message: string) => void;
|
|
10
|
+
/** Shows the corner dismiss; typing a reply in the composer answers just as well. */
|
|
11
|
+
onDismiss?: () => void;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** Ids are positions: the questions are the model's, and carry none. */
|
|
13
15
|
const idOf = (index: number) => `q${index}`;
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
* The questions Alia asked
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
18
|
+
* The questions Alia asked, as Bloom's `Questionnaire`. Give it to the composer's `question`
|
|
19
|
+
* slot while the person can still answer: it docks above the composer, where they are about
|
|
20
|
+
* to type, rather than sitting in the thread.
|
|
19
21
|
*/
|
|
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
|
-
|
|
22
|
+
export function QuestionCard({ questions, onSubmit, onDismiss }: QuestionCardProps) {
|
|
33
23
|
const complete = (answers: QuestionnaireAnswers) =>
|
|
34
24
|
onSubmit(
|
|
35
25
|
formatAnswers(
|
|
@@ -42,21 +32,33 @@ export function QuestionCard({ questions, onSubmit }: QuestionCardProps) {
|
|
|
42
32
|
);
|
|
43
33
|
|
|
44
34
|
return (
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
35
|
+
<Questionnaire
|
|
36
|
+
questions={questions.map((q, i) => ({
|
|
37
|
+
id: idOf(i),
|
|
38
|
+
question: q.question,
|
|
39
|
+
select: q.multiSelect ? 'multiple' : 'single',
|
|
40
|
+
other: true,
|
|
41
|
+
options: q.options.map((option, index) => ({
|
|
42
|
+
value: String(index),
|
|
43
|
+
label: option.label,
|
|
44
|
+
description: option.description,
|
|
45
|
+
})),
|
|
46
|
+
}))}
|
|
47
|
+
onComplete={complete}
|
|
48
|
+
onDismiss={onDismiss}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The questions Alia asked, as plain lines: the thread's record of them once they are answered. */
|
|
54
|
+
export function AskedQuestions({ questions }: { questions: AskedQuestion[] }) {
|
|
55
|
+
return (
|
|
56
|
+
<View className="my-2 gap-1">
|
|
57
|
+
{questions.map((q, i) => (
|
|
58
|
+
// The model's questions never reorder: the position is the identity.
|
|
59
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: see above
|
|
60
|
+
<Muted key={i}>{q.question}</Muted>
|
|
61
|
+
))}
|
|
60
62
|
</View>
|
|
61
63
|
);
|
|
62
64
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { View } from 'react-native';
|
|
4
3
|
import { useLabels } from '../lib/labels';
|
|
5
4
|
import { AliaMarkdown } from './Markdown';
|
|
6
5
|
|
|
@@ -12,10 +11,11 @@ interface ThinkingBlockProps {
|
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
* A turn's reasoning: Bloom's `AgentThinking` as a disclosure
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* A turn's reasoning, once it is done: Bloom's `AgentThinking` as a closed disclosure, "Thought
|
|
15
|
+
* for N seconds", with the text set as Markdown. While the reasoning streams it draws nothing:
|
|
16
|
+
* the avatar under the reply is the one indicator that the turn is thinking
|
|
17
|
+
* (`AgentReplyIdentity`), and a second one beside it is noise. It still counts how long the stream
|
|
18
|
+
* ran, because the message carries no duration.
|
|
19
19
|
*/
|
|
20
20
|
export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
|
|
21
21
|
const labels = useLabels();
|
|
@@ -33,15 +33,11 @@ export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
|
|
|
33
33
|
|
|
34
34
|
const finishedLabel = seconds ? labels.thoughtFor(seconds) : labels.reasoning;
|
|
35
35
|
|
|
36
|
+
if (streaming) return null;
|
|
37
|
+
|
|
36
38
|
return (
|
|
37
|
-
<AgentThinking
|
|
38
|
-
|
|
39
|
-
thinking={streaming}
|
|
40
|
-
label={streaming ? undefined : finishedLabel}
|
|
41
|
-
>
|
|
42
|
-
<View style={{ opacity: streaming ? 0.8 : 1 }}>
|
|
43
|
-
<AliaMarkdown content={text} />
|
|
44
|
-
</View>
|
|
39
|
+
<AgentThinking thinking={false} label={finishedLabel}>
|
|
40
|
+
<AliaMarkdown content={text} />
|
|
45
41
|
</AgentThinking>
|
|
46
42
|
);
|
|
47
43
|
}
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import Svg, { Path } from 'react-native-svg';
|
|
2
|
+
|
|
3
|
+
/** The glyph's outline: a speaker cone with three rays, drawn on a 512 grid (y down). */
|
|
4
|
+
const GLYPH =
|
|
5
|
+
'M136 117Q158 95 212.5 119.5Q267 144 323 200Q379 256 403.5 311Q428 366 406 387Q396 398 373 414Q350 430 313.5 447Q277 464 240 472.5Q203 481 162 472.5Q121 464 90.5 433.5Q60 403 51 361.5Q42 320 50.5 283Q59 246 76.5 209.5Q94 173 109.5 150.5Q125 128 136 117ZM348.5 361.5Q348.5 361.5 358.25 364.25Q368 367 373 362.5Q378 358 372.5 338Q367 318 347 286Q327 254 298 225Q269 196 237 176.5Q205 157 185.5 151Q166 145 161 150Q156 155 162 174.5Q168 194 187.5 226Q207 258 236 287.5Q265 317 297 336.5Q329 356 348.5 361.5ZM395 153Q388 161 377 161Q366 161 358.5 153.5Q351 146 351 135Q351 124 359 117L408 68Q415 61 425.5 61Q436 61 443.5 68.5Q451 76 451 86.5Q451 97 444 104ZM285 97Q272 90 272 75Q272 68 275 62L303 13Q310 0 325 0Q336 0 343.5 7.5Q351 15 351 26Q351 32 347 38L320 87Q312 101 297 101Q291 101 285 97ZM509 174Q512 180 512 187Q512 202 499 209L449 236Q443 240 437 240Q426 240 418.5 232.5Q411 225 411 214Q411 199 424 192L474 165Q480 161 486 161Q501 161 509 174Z';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
|
-
* The glyph of Alia's voice button:
|
|
6
|
-
*
|
|
8
|
+
* The glyph of Alia's voice button: the speaker with rays the button has always worn. Bloom has no
|
|
9
|
+
* equivalent, so it is drawn here against Bloom's icon contract (`width` / `fill`) for a Bloom
|
|
10
|
+
* `Button` to size and colour.
|
|
7
11
|
*/
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
export function VoiceModeIcon({ width = 18, fill }: { width?: number; fill?: string }) {
|
|
13
|
+
return (
|
|
14
|
+
<Svg width={width} height={width} viewBox="23 -17 512 512">
|
|
15
|
+
<Path d={GLYPH} fill={fill} />
|
|
16
|
+
</Svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bottom voice control bar that replaces the composer when voice mode is active
|
|
3
|
-
*
|
|
2
|
+
* Bottom voice control bar that replaces the composer when voice mode is active: the status
|
|
3
|
+
* text over Bloom's own `VoiceControls` (microphone and end), the controls of Bloom's voice
|
|
4
|
+
* mode, not a call's.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import { CallControls } from '@oxy.so/bloom/call-ui';
|
|
7
7
|
import { Loading } from '@oxy.so/bloom/loading';
|
|
8
8
|
import { Text } from '@oxy.so/bloom/typography';
|
|
9
|
+
import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
|
|
9
10
|
import { View } from 'react-native';
|
|
10
11
|
import type { RoomState, AgentState } from '../../types';
|
|
11
12
|
|
|
@@ -60,6 +61,13 @@ function getStatusText(
|
|
|
60
61
|
return '';
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/** What Bloom's voice controls call the call's state: the microphone is only live while it is. */
|
|
65
|
+
function voiceStateOf(roomState: RoomState, agentState: AgentState): VoiceState {
|
|
66
|
+
if (roomState === 'error') return 'error';
|
|
67
|
+
if (roomState !== 'connected') return 'idle';
|
|
68
|
+
return agentState === 'idle' ? 'listening' : agentState;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
export function VoiceControls({
|
|
64
72
|
roomState,
|
|
65
73
|
agentState,
|
|
@@ -84,13 +92,13 @@ export function VoiceControls({
|
|
|
84
92
|
) : null}
|
|
85
93
|
|
|
86
94
|
{roomState === 'connected' ? (
|
|
87
|
-
<
|
|
95
|
+
<BloomVoiceControls
|
|
96
|
+
state={voiceStateOf(roomState, agentState)}
|
|
88
97
|
muted={isMuted}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
labels={{ mute: labels.mute, unmute: labels.unmute, endCall: labels.end }}
|
|
98
|
+
// Called with nothing: Bloom hands its handlers the next value or the press, and
|
|
99
|
+
// `onToggleMute` / `onEnd` of a session take optional arguments of their own.
|
|
100
|
+
onMutedChange={() => onToggleMute()}
|
|
101
|
+
onEnd={() => onEnd()}
|
|
94
102
|
/>
|
|
95
103
|
) : null}
|
|
96
104
|
|
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -3,12 +3,18 @@ import { useOxy } from '@oxy.so/services/ui/client';
|
|
|
3
3
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
4
4
|
import { getTextFromContent, type MessageContent } from '../lib/content-utils';
|
|
5
5
|
import { resolveModelId } from '../lib/catalogue';
|
|
6
|
+
import { agentMessageId, delegatedAgent } from '../lib/delegation';
|
|
6
7
|
import { streamAliaChat } from '../lib/chat-transport';
|
|
7
8
|
import type { AliaChatStreamEvent } from '../lib/chat-stream';
|
|
8
9
|
|
|
9
10
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
10
11
|
|
|
11
12
|
export interface UseAliaChatOptions {
|
|
13
|
+
/**
|
|
14
|
+
* The agents the host knows, with their look: a delegation's thinking avatar is drawn from here
|
|
15
|
+
* until the agent's answer brings its own. Without it (or for an agent it lacks) the default look.
|
|
16
|
+
*/
|
|
17
|
+
agents?: ReadonlyArray<{ id: string; character?: unknown }>;
|
|
12
18
|
/**
|
|
13
19
|
* Base URL the hook sends its requests to. Defaults to
|
|
14
20
|
* `EXPO_PUBLIC_ALIA_API_URL`, then `https://api.alia.onl`.
|
|
@@ -57,6 +63,10 @@ export interface UseAliaChatReturn {
|
|
|
57
63
|
isStreaming: boolean;
|
|
58
64
|
stop: () => void;
|
|
59
65
|
clear: () => void;
|
|
66
|
+
/** The person accepted a proposed plan; the card collapses to "approved". */
|
|
67
|
+
approvePlan: (planId: string) => void;
|
|
68
|
+
/** The person cancelled a proposed plan: the card collapses to "cancelled" and the turn stops. */
|
|
69
|
+
rejectPlan: (planId: string) => void;
|
|
60
70
|
error: string | null;
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -70,6 +80,8 @@ interface PendingUpdates {
|
|
|
70
80
|
content: string;
|
|
71
81
|
reasoning: string;
|
|
72
82
|
tools: ToolInvocation[];
|
|
83
|
+
/** How many delegated agents have answered in this turn. */
|
|
84
|
+
agentAnswers: number;
|
|
73
85
|
timer: ReturnType<typeof setTimeout> | null;
|
|
74
86
|
}
|
|
75
87
|
|
|
@@ -118,6 +130,8 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
|
|
|
118
130
|
*/
|
|
119
131
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
120
132
|
const { apiUrl = API_URL, model, clientContext, accessToken: accessTokenProp } = options;
|
|
133
|
+
const directoryRef = useRef(new Map<string, { id: string; character?: unknown }>());
|
|
134
|
+
directoryRef.current = new Map((options.agents ?? []).map((agent) => [agent.id, agent]));
|
|
121
135
|
const requestUrl = '/v1/chat/completions';
|
|
122
136
|
|
|
123
137
|
const { oxyServices } = useOxy();
|
|
@@ -168,6 +182,75 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
168
182
|
});
|
|
169
183
|
}, []);
|
|
170
184
|
|
|
185
|
+
/**
|
|
186
|
+
* An agent's message of this turn, before Alia's own: opened empty by the call that sends for the
|
|
187
|
+
* agent, filled by its answer. Without an open one, an answer makes a message of its own.
|
|
188
|
+
*/
|
|
189
|
+
const insertAgentMessage = useCallback(
|
|
190
|
+
(
|
|
191
|
+
assistantId: string,
|
|
192
|
+
agent: { id: string; name: string; character?: unknown },
|
|
193
|
+
content: string,
|
|
194
|
+
) => {
|
|
195
|
+
if (!mountedRef.current) return;
|
|
196
|
+
const id = agentMessageId(assistantId, agent.id);
|
|
197
|
+
setMessages((previous) => {
|
|
198
|
+
const open = previous.find((message) => message.id === id);
|
|
199
|
+
let updated: ChatMessage[];
|
|
200
|
+
if (open && (content === '' || getTextFromContent(open.content) === '')) {
|
|
201
|
+
// Already open: an answer fills it; a second call to the same agent changes nothing.
|
|
202
|
+
if (content === '') return previous;
|
|
203
|
+
// The answer carries the look the server has now: it replaces the one the call guessed.
|
|
204
|
+
updated = previous.map((message) =>
|
|
205
|
+
message === open
|
|
206
|
+
? {
|
|
207
|
+
...open,
|
|
208
|
+
content,
|
|
209
|
+
agentInfo: {
|
|
210
|
+
id: agent.id,
|
|
211
|
+
name: agent.name,
|
|
212
|
+
character: agent.character ?? open.agentInfo?.character,
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
: message,
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
const message: ChatMessage = {
|
|
219
|
+
id: open ? `${id}-${previous.length}` : id,
|
|
220
|
+
role: 'assistant',
|
|
221
|
+
content,
|
|
222
|
+
createdAt: Date.now(),
|
|
223
|
+
agentInfo: { id: agent.id, name: agent.name, character: agent.character },
|
|
224
|
+
};
|
|
225
|
+
const at = previous.findIndex((candidate) => candidate.id === assistantId);
|
|
226
|
+
updated = [...previous];
|
|
227
|
+
updated.splice(at < 0 ? updated.length : at, 0, message);
|
|
228
|
+
}
|
|
229
|
+
messagesRef.current = updated;
|
|
230
|
+
return updated;
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
[],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
/** A turn that ends with an agent never answering leaves no empty message of the agent's. */
|
|
237
|
+
const removeUnansweredAgents = useCallback((assistantId: string) => {
|
|
238
|
+
if (!mountedRef.current) return;
|
|
239
|
+
setMessages((previous) => {
|
|
240
|
+
const updated = previous.filter(
|
|
241
|
+
(message) =>
|
|
242
|
+
!(
|
|
243
|
+
message.agentInfo !== undefined &&
|
|
244
|
+
message.id.startsWith(`${assistantId}-agent-`) &&
|
|
245
|
+
getTextFromContent(message.content).trim() === ''
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
if (updated.length === previous.length) return previous;
|
|
249
|
+
messagesRef.current = updated;
|
|
250
|
+
return updated;
|
|
251
|
+
});
|
|
252
|
+
}, []);
|
|
253
|
+
|
|
171
254
|
const flushPendingUpdates = useCallback(
|
|
172
255
|
(pending: PendingUpdates) => {
|
|
173
256
|
const content = pending.content;
|
|
@@ -310,6 +393,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
310
393
|
content: '',
|
|
311
394
|
reasoning: '',
|
|
312
395
|
tools: [],
|
|
396
|
+
agentAnswers: 0,
|
|
313
397
|
timer: null,
|
|
314
398
|
};
|
|
315
399
|
pendingRef.current = pending;
|
|
@@ -339,6 +423,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
339
423
|
if (index < 0) pending.tools = [...pending.tools, invocation];
|
|
340
424
|
else pending.tools[index] = invocation;
|
|
341
425
|
flushPendingUpdates(pending);
|
|
426
|
+
// A call to an agent opens the agent's message at once: empty, thinking, until it answers.
|
|
427
|
+
const agent = delegatedAgent(invocation);
|
|
428
|
+
// The call names the agent; its look is the host's directory's, until the answer brings the server's.
|
|
429
|
+
if (agent)
|
|
430
|
+
insertAgentMessage(
|
|
431
|
+
pending.assistantId,
|
|
432
|
+
{ ...agent, character: directoryRef.current.get(agent.id)?.character },
|
|
433
|
+
'',
|
|
434
|
+
);
|
|
342
435
|
return;
|
|
343
436
|
}
|
|
344
437
|
case 'tool_result': {
|
|
@@ -373,11 +466,14 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
373
466
|
},
|
|
374
467
|
}));
|
|
375
468
|
return;
|
|
376
|
-
case 'agent_answer':
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
469
|
+
case 'agent_answer': {
|
|
470
|
+
// A delegated agent speaks as itself: its answer is its own message, placed before
|
|
471
|
+
// Alia's reply, as the Alia app places it. The call that sent for it already opened
|
|
472
|
+
// the message; an answer with no call opens it. Several agents are several messages.
|
|
473
|
+
pending.agentAnswers++;
|
|
474
|
+
insertAgentMessage(pending.assistantId, event.agent, event.content);
|
|
380
475
|
return;
|
|
476
|
+
}
|
|
381
477
|
}
|
|
382
478
|
};
|
|
383
479
|
|
|
@@ -409,6 +505,9 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
409
505
|
} finally {
|
|
410
506
|
cancelPendingTimer(pending);
|
|
411
507
|
flushPendingUpdates(pending);
|
|
508
|
+
removeUnansweredAgents(pending.assistantId);
|
|
509
|
+
// Alia said nothing herself after handing the turn over: no empty reply under the answers.
|
|
510
|
+
if (pending.agentAnswers > 0) removeEmptyAssistant(pending.assistantId);
|
|
412
511
|
linked.dispose();
|
|
413
512
|
if (pendingRef.current === pending) pendingRef.current = null;
|
|
414
513
|
if (activeRequestRef.current?.key === request.key) {
|
|
@@ -427,6 +526,8 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
427
526
|
oxyServices,
|
|
428
527
|
requestUrl,
|
|
429
528
|
removeEmptyAssistant,
|
|
529
|
+
removeUnansweredAgents,
|
|
530
|
+
insertAgentMessage,
|
|
430
531
|
scheduleFlush,
|
|
431
532
|
updateAssistant,
|
|
432
533
|
],
|
|
@@ -439,5 +540,23 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
439
540
|
setError(null);
|
|
440
541
|
}, [stop]);
|
|
441
542
|
|
|
442
|
-
|
|
543
|
+
const decidePlan = useCallback((planId: string, decision: 'approved' | 'rejected') => {
|
|
544
|
+
setMessages((prev) =>
|
|
545
|
+
prev.map((message) =>
|
|
546
|
+
message.pendingPlan?.planId === planId
|
|
547
|
+
? { ...message, pendingPlan: { ...message.pendingPlan, [decision]: true } }
|
|
548
|
+
: message,
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
}, []);
|
|
552
|
+
const approvePlan = useCallback((planId: string) => decidePlan(planId, 'approved'), [decidePlan]);
|
|
553
|
+
const rejectPlan = useCallback(
|
|
554
|
+
(planId: string) => {
|
|
555
|
+
decidePlan(planId, 'rejected');
|
|
556
|
+
stop();
|
|
557
|
+
},
|
|
558
|
+
[decidePlan, stop],
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
return { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan, error };
|
|
443
562
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type { NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
type Completion,
|
|
5
|
+
SEARCH_DEBOUNCE_MS,
|
|
6
|
+
SEARCH_FROM,
|
|
7
|
+
composerCompletions,
|
|
8
|
+
} from '../lib/suggestions';
|
|
9
|
+
import type { AliaSuggestion } from '../types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The draft, once typing has settled, for the host to search with; empty while the draft is
|
|
13
|
+
* too short to search or suggestions are off. The data stays the host's: the Alia app asks its
|
|
14
|
+
* API through react-query, a host of the SDK chat asks whatever it has.
|
|
15
|
+
*/
|
|
16
|
+
export function useSuggestionQuery(draft: string, enabled: boolean): string {
|
|
17
|
+
const [query, setQuery] = useState('');
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const next = enabled ? draft.trim() : '';
|
|
20
|
+
if (next.length < SEARCH_FROM) {
|
|
21
|
+
setQuery('');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const timer = setTimeout(() => setQuery(next), SEARCH_DEBOUNCE_MS);
|
|
25
|
+
return () => clearTimeout(timer);
|
|
26
|
+
}, [draft, enabled]);
|
|
27
|
+
return query;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The suggestions over a composer: the rows for the draft, which one the keyboard is on, and
|
|
32
|
+
* the composer's own `onKeyPress` (arrows move, Enter picks the selected row, Escape closes).
|
|
33
|
+
* Enter is left to the composer while no row is selected.
|
|
34
|
+
*/
|
|
35
|
+
export function useComposerSuggestions<T extends AliaSuggestion>({
|
|
36
|
+
draft,
|
|
37
|
+
enabled,
|
|
38
|
+
welcome,
|
|
39
|
+
matches,
|
|
40
|
+
onPick,
|
|
41
|
+
}: {
|
|
42
|
+
draft: string;
|
|
43
|
+
/** Only a fresh conversation offers suggestions. */
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
welcome: readonly T[] | undefined;
|
|
46
|
+
matches: readonly T[] | undefined;
|
|
47
|
+
onPick: (suggestion: T) => void;
|
|
48
|
+
}) {
|
|
49
|
+
const [selected, setSelected] = useState(-1);
|
|
50
|
+
const [dismissed, setDismissed] = useState(false);
|
|
51
|
+
|
|
52
|
+
// A new draft reopens the list and clears the keyboard selection.
|
|
53
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the draft is the trigger
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setDismissed(false);
|
|
56
|
+
setSelected(-1);
|
|
57
|
+
}, [draft]);
|
|
58
|
+
|
|
59
|
+
const completions: Completion<T>[] = useMemo(
|
|
60
|
+
() => (enabled && !dismissed ? composerCompletions(draft, welcome, matches) : []),
|
|
61
|
+
[enabled, dismissed, draft, welcome, matches],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const onKeyPress = useCallback(
|
|
65
|
+
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
66
|
+
if (completions.length === 0) return;
|
|
67
|
+
const key = event.nativeEvent.key;
|
|
68
|
+
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
const step = key === 'ArrowDown' ? 1 : -1;
|
|
71
|
+
setSelected((current) => (current + step + completions.length) % completions.length);
|
|
72
|
+
} else if (key === 'Enter' && selected >= 0) {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
onPick(completions[selected]!.suggestion);
|
|
75
|
+
} else if (key === 'Escape') {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
setDismissed(true);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
[completions, selected, onPick],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return { completions, selected, onKeyPress };
|
|
84
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
// Reusable chat UI, voice, and streaming components for Oxy apps
|
|
4
4
|
|
|
5
5
|
// ── Main components ──
|
|
6
|
+
export type { AliaChatAgent, AliaChatState } from './components/AliaChatContent';
|
|
6
7
|
export { AliaChatSheet } from './components/AliaChatSheet';
|
|
7
8
|
export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
|
|
8
9
|
export { AliaChatScreen } from './components/AliaChatScreen';
|
|
9
10
|
export type { AliaChatScreenProps } from './components/AliaChatScreen';
|
|
10
11
|
|
|
11
12
|
// ── Chat UI components ──
|
|
13
|
+
export { ALIA_CHARACTER_RUNTIME_URL } from './lib/character-runtime';
|
|
12
14
|
export { DEFAULT_LABELS } from './lib/labels';
|
|
13
15
|
export type { AliaChatLabels } from './lib/labels';
|
|
14
16
|
export { VoiceModeIcon } from './components/VoiceModeIcon';
|
|
15
17
|
export { AmbientField, PARALLAX_DURATION, PARALLAX_EASE } from './components/AmbientField';
|
|
16
18
|
export type { AmbientFieldProps } from './components/AmbientField';
|
|
17
|
-
export { AliaAvatar } from './components/AliaAvatar';
|
|
19
|
+
export { ALIA_AVATAR, AliaAvatar } from './components/AliaAvatar';
|
|
20
|
+
export { DEFAULT_AGENT_CHARACTER, readAgentCharacter } from './lib/agent-character';
|
|
18
21
|
export type { AliaAvatarProps, AliaAvatarState } from './components/AliaAvatar';
|
|
19
22
|
export { IdentityMark } from './components/IdentityMark';
|
|
20
23
|
export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
|
|
@@ -22,7 +25,11 @@ export { AliaMarkdown } from './components/Markdown';
|
|
|
22
25
|
export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
|
|
23
26
|
export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
|
|
24
27
|
export { PlanPreviewCard } from './components/PlanPreviewCard';
|
|
25
|
-
export { QuestionCard } from './components/QuestionCard';
|
|
28
|
+
export { AskedQuestions, QuestionCard } from './components/QuestionCard';
|
|
29
|
+
export { ComposerSuggestions } from './components/ComposerSuggestions';
|
|
30
|
+
export { useComposerSuggestions, useSuggestionQuery } from './hooks/useComposerSuggestions';
|
|
31
|
+
export { composerCompletions } from './lib/suggestions';
|
|
32
|
+
export type { Completion } from './lib/suggestions';
|
|
26
33
|
export {
|
|
27
34
|
ASK_USER_QUESTION_TOOL,
|
|
28
35
|
askedQuestions,
|
|
@@ -61,7 +68,7 @@ export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from
|
|
|
61
68
|
export type {
|
|
62
69
|
ChatMessage,
|
|
63
70
|
ToolInvocation,
|
|
64
|
-
|
|
71
|
+
AliaSuggestion,
|
|
65
72
|
ResearchProgress,
|
|
66
73
|
ResearchSource,
|
|
67
74
|
PendingPlan,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseAvatarCharacter, type AvatarCharacter } from '@oxy.so/bloom/agent-avatar';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The look of an agent whose own is not known: a server that does not send one yet, an agent that
|
|
5
|
+
* has since been deleted, a stored character this Bloom cannot draw. One neutral character, the same
|
|
6
|
+
* everywhere, so an unknown look is recognisably "not chosen" and never one that belongs to somebody.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_AGENT_CHARACTER: AvatarCharacter = {
|
|
9
|
+
shape: 'rounded_cube',
|
|
10
|
+
eyes: 'oval',
|
|
11
|
+
eyewear: 'none',
|
|
12
|
+
accessory: 'none',
|
|
13
|
+
color: 'violet',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The character an agent is drawn as, from the JSON the server stores for it
|
|
18
|
+
* (`agents.character`): Bloom's own parser decides what it can draw, and anything else is the
|
|
19
|
+
* default look rather than an avatar that fails to render.
|
|
20
|
+
*/
|
|
21
|
+
export function readAgentCharacter(value: unknown): AvatarCharacter {
|
|
22
|
+
if (value === null || value === undefined) return DEFAULT_AGENT_CHARACTER;
|
|
23
|
+
try {
|
|
24
|
+
return parseAvatarCharacter(value);
|
|
25
|
+
} catch {
|
|
26
|
+
return DEFAULT_AGENT_CHARACTER;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the 3D character runtime (Alia's avatar) is served from when the host does not say.
|
|
3
|
+
*
|
|
4
|
+
* The runtime is ~214 MB of engine and data, so the chat loads it from Alia's own web origin
|
|
5
|
+
* instead of asking every app to host a copy. That origin serves it with CORS open
|
|
6
|
+
* (`packages/app/worker/index.js`), which a cross-origin module import needs; an app with a
|
|
7
|
+
* strict CSP allows it in `script-src` and `connect-src`. A host that serves its own copy
|
|
8
|
+
* passes `characterRuntimeUrl`.
|
|
9
|
+
*/
|
|
10
|
+
export const ALIA_CHARACTER_RUNTIME_URL = 'https://alia.onl/bloom-character/runtime.mjs';
|
package/src/lib/chat-stream.ts
CHANGED
|
@@ -31,7 +31,12 @@ export type AliaChatStreamEvent =
|
|
|
31
31
|
| {
|
|
32
32
|
readonly kind: 'agent_answer';
|
|
33
33
|
readonly content: string;
|
|
34
|
-
readonly agent: {
|
|
34
|
+
readonly agent: {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** The agent's look as the server stores it (JSON); absent from a server that does not send it. */
|
|
38
|
+
readonly character?: unknown;
|
|
39
|
+
};
|
|
35
40
|
};
|
|
36
41
|
|
|
37
42
|
export interface AliaChatStreamResult {
|
|
@@ -324,7 +329,15 @@ function parseNamedEvent(eventName: string, payload: unknown): AliaChatStreamEve
|
|
|
324
329
|
case 'alia.agent': {
|
|
325
330
|
assertOnlyKeys(
|
|
326
331
|
body,
|
|
327
|
-
[
|
|
332
|
+
[
|
|
333
|
+
'eventVersion',
|
|
334
|
+
'agentId',
|
|
335
|
+
'agentName',
|
|
336
|
+
'agentHandle',
|
|
337
|
+
'agentColor',
|
|
338
|
+
'agentCharacter',
|
|
339
|
+
'content',
|
|
340
|
+
],
|
|
328
341
|
eventName,
|
|
329
342
|
);
|
|
330
343
|
if (body.agentColor !== null) asString(body.agentColor, `${eventName} agentColor`);
|
|
@@ -335,7 +348,11 @@ function parseNamedEvent(eventName: string, payload: unknown): AliaChatStreamEve
|
|
|
335
348
|
agent: {
|
|
336
349
|
id: asString(body.agentId, `${eventName} agentId`),
|
|
337
350
|
name: asString(body.agentName, `${eventName} agentName`),
|
|
338
|
-
|
|
351
|
+
// As the server sent it, parsed where it is drawn (`readAgentCharacter`). Absent from an
|
|
352
|
+
// older server, null for an agent that could not be read: the default look then.
|
|
353
|
+
...(body.agentCharacter === undefined || body.agentCharacter === null
|
|
354
|
+
? {}
|
|
355
|
+
: { character: body.agentCharacter }),
|
|
339
356
|
},
|
|
340
357
|
};
|
|
341
358
|
}
|