@alia.onl/sdk 15.8.0 → 19.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 +262 -13
- package/README.md +223 -151
- package/package.json +16 -6
- package/src/animations.ts +0 -1
- package/src/components/AliaAskProvider.tsx +127 -0
- package/src/components/AliaChatContent.tsx +604 -392
- package/src/components/AliaChatHeader.tsx +14 -38
- package/src/components/AliaChatScreen.tsx +56 -31
- package/src/components/AliaChatSheet.tsx +110 -36
- package/src/components/AliaComposer.tsx +1 -1
- package/src/components/AliaParticipants.tsx +202 -0
- package/src/components/AliaParticipantsHeader.tsx +60 -0
- package/src/components/AliaSharedConversation.tsx +86 -0
- package/src/components/AliaWelcomeMessage.tsx +18 -5
- package/src/components/Image.tsx +12 -0
- package/src/components/Markdown.tsx +498 -14
- package/src/components/PlanPreviewCard.tsx +7 -49
- package/src/components/ReplyMarkdown.tsx +359 -0
- package/src/components/RichBlocks.tsx +195 -0
- package/src/components/canvas/CanvasComponent.tsx +59 -0
- package/src/components/canvas/ChartRenderer.tsx +73 -0
- package/src/components/canvas/CodeRenderer.tsx +24 -0
- package/src/components/canvas/FormRenderer.tsx +101 -0
- package/src/components/canvas/MarkdownRenderer.tsx +14 -0
- package/src/components/canvas/TableRenderer.tsx +47 -0
- package/src/components/thought/FilesAndSources.tsx +120 -0
- package/src/components/thought/ThoughtPanel.tsx +417 -0
- package/src/components/thought/ToolStep.tsx +141 -0
- package/src/components/thread/AgentResultCard.tsx +125 -0
- package/src/components/thread/AgentTaskCard.tsx +127 -0
- package/src/components/thread/AliaThread.tsx +401 -0
- package/src/components/thread/MessageBlockBoundary.tsx +78 -0
- package/src/components/thread/NewConversationOffer.tsx +54 -0
- package/src/components/thread/ThreadRow.tsx +458 -0
- package/src/components/thread/ThreadSearch.tsx +129 -0
- package/src/components/thread/ToolResultCard.tsx +254 -0
- package/src/components/thread/TurnMenu.tsx +54 -0
- package/src/components/thread/TurnStatusLine.tsx +34 -0
- package/src/components/voice/AliaVoiceCall.tsx +21 -0
- package/src/components/voice/VoiceControls.tsx +2 -28
- package/src/components/voice/VoiceSession.tsx +39 -11
- package/src/hooks/useAliaActivity.ts +2 -2
- package/src/hooks/useAtBottom.ts +51 -0
- package/src/hooks/useAttachMenu.ts +94 -0
- package/src/hooks/useChatComposer.ts +299 -0
- package/src/hooks/useChatVoice.ts +129 -0
- package/src/hooks/useDocumentPicker.ts +3 -29
- package/src/hooks/useFailedTurnToast.ts +43 -0
- package/src/hooks/useMessageVote.ts +50 -0
- package/src/hooks/useOpenThought.ts +79 -0
- package/src/hooks/usePendingQuestions.ts +2 -2
- package/src/hooks/useReadAloud.ts +92 -0
- package/src/hooks/useShareHandler.ts +24 -0
- package/src/hooks/useSoundEffects.ts +1 -1
- package/src/hooks/useThoughtScope.ts +59 -0
- package/src/hooks/useThreadRows.ts +145 -0
- package/src/hooks/useThreadView.ts +68 -0
- package/src/hooks/useTimelineWindow.ts +256 -0
- package/src/hooks/useVoiceLauncher.ts +180 -0
- package/src/hooks/useVoiceRoom.ts +4 -4
- package/src/index.ts +43 -29
- package/src/lib/activity-store.ts +1 -1
- package/src/lib/add-menu.ts +7 -0
- package/src/lib/call-thread.ts +33 -0
- package/src/lib/citations.ts +79 -2
- package/src/lib/clipboard.ts +23 -0
- package/src/lib/content-utils.ts +10 -0
- package/src/lib/copy-en.ts +284 -0
- package/src/lib/copy.ts +32 -0
- package/src/lib/document-picker.ts +31 -0
- package/src/lib/flights.ts +22 -13
- package/src/lib/focus-return.ts +66 -0
- package/src/lib/keyboard.native.tsx +19 -0
- package/src/lib/keyboard.tsx +9 -0
- package/src/lib/labels.tsx +69 -17
- package/src/lib/participants.ts +86 -0
- package/src/lib/power-level-options.ts +66 -0
- package/src/lib/questions.ts +2 -2
- package/src/lib/task-labels.ts +4 -4
- package/src/lib/tool-call-status.ts +1 -1
- package/src/lib/tool-icons.ts +36 -0
- package/src/lib/tool-renderers.tsx +48 -0
- package/src/lib/turn-state.ts +2 -2
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +98 -35
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +34 -15
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +141 -0
- package/src/runtime/app-assertion.ts +53 -0
- package/src/runtime/attachment-utils.ts +96 -0
- package/src/runtime/composer-draft-store.ts +270 -0
- package/src/runtime/config.tsx +186 -0
- package/src/runtime/conversation-store.ts +49 -0
- package/src/runtime/conversations.ts +557 -0
- package/src/runtime/error-utils.ts +104 -0
- package/src/runtime/host-tools.ts +140 -0
- package/src/runtime/index.ts +113 -0
- package/src/runtime/message-history.ts +124 -0
- package/src/runtime/model.ts +39 -0
- package/src/runtime/oxy-files.ts +37 -0
- package/src/runtime/power-levels.ts +66 -0
- package/src/runtime/query-keys.ts +34 -0
- package/src/runtime/random-uuid.ts +25 -0
- package/src/runtime/routes.ts +51 -0
- package/src/runtime/sharing.ts +165 -0
- package/src/runtime/sse-frame-reader.ts +88 -0
- package/src/runtime/stream-events.ts +146 -0
- package/src/runtime/stream-outcome.ts +21 -0
- package/src/runtime/thought-store.ts +103 -0
- package/src/runtime/thread/agent-activity.ts +261 -0
- package/src/runtime/thread/automation-labels.ts +79 -0
- package/src/runtime/thread/conversation-export.ts +135 -0
- package/src/runtime/thread/message-days.ts +120 -0
- package/src/runtime/thread/message-processor.ts +93 -0
- package/src/runtime/thread/task-utils.ts +11 -0
- package/src/runtime/thread/thought-utils.ts +752 -0
- package/src/runtime/thread/thread-history.ts +139 -0
- package/src/runtime/thread/timeline.ts +202 -0
- package/src/runtime/thread/title-tags.ts +8 -0
- package/src/runtime/thread/tool-cards.ts +383 -0
- package/src/runtime/turn-failure.ts +108 -0
- package/src/runtime/types.ts +148 -0
- package/src/runtime/usage-limit-error.ts +44 -0
- package/src/runtime/use-agent-activity.ts +80 -0
- package/src/runtime/use-conversation-stream.ts +1433 -0
- package/src/runtime/use-conversation-thread.ts +556 -0
- package/src/runtime/use-start-conversation.ts +107 -0
- package/src/runtime/use-thread-history.ts +64 -0
- package/src/runtime/use-thread-search.ts +95 -0
- package/src/runtime/use-turn-edit.ts +99 -0
- package/src/tools.ts +2 -2
- package/src/types.ts +18 -47
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/catalogue.ts +0 -236
- package/src/lib/chat-stream.ts +0 -655
- package/src/lib/chat-transport.ts +0 -98
- package/src/lib/delegation.ts +0 -34
- package/src/lib/work-log-copy.ts +0 -61
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { AvatarFlights } from '@oxy.so/bloom/avatar-flight';
|
|
2
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import type { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
|
|
5
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
6
|
+
import { leadToProfile } from '../lib/flights';
|
|
7
|
+
import type { AliaLead } from '../lib/participants';
|
|
8
|
+
import { imageUriToDataUrl } from '../lib/image-data-url';
|
|
9
|
+
import {
|
|
10
|
+
useComposerDraft,
|
|
11
|
+
useComposerDraftStore,
|
|
12
|
+
useDraftAddress,
|
|
13
|
+
} from '../runtime/composer-draft-store';
|
|
14
|
+
import type { SendOptions } from '../runtime/types';
|
|
15
|
+
import type { ConversationThread } from '../runtime/use-conversation-thread';
|
|
16
|
+
import { useTurnEdit } from '../runtime/use-turn-edit';
|
|
17
|
+
import type { AliaAttachment, AliaSuggestion } from '../types';
|
|
18
|
+
import { useComposerSuggestions, useSuggestionQuery } from './useComposerSuggestions';
|
|
19
|
+
import { useFocusWhenPresented } from './useFocusWhenPresented';
|
|
20
|
+
|
|
21
|
+
/** A turn of the person's: the text, and the photos (`data:` or `https:` URLs) sent with it. */
|
|
22
|
+
export interface AliaTurn {
|
|
23
|
+
text: string;
|
|
24
|
+
images?: ReadonlyArray<string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseChatComposerOptions {
|
|
28
|
+
conversationId: string | undefined;
|
|
29
|
+
thread: ConversationThread;
|
|
30
|
+
/** Nothing is stored: the first turn is sent without creating a conversation (ghost mode). */
|
|
31
|
+
ephemeral: boolean;
|
|
32
|
+
/** The composer is closed (the usage limit): nothing may be sent. */
|
|
33
|
+
closed: boolean;
|
|
34
|
+
/** A write is done in the present, so it ends a jump into the thread's past. */
|
|
35
|
+
onWrite: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Hands the first turn of an empty chat to the host instead of sending it here (a sheet opens
|
|
38
|
+
* the conversation in a new sheet over this one).
|
|
39
|
+
*/
|
|
40
|
+
onStart: ((turn: AliaTurn) => void) | undefined;
|
|
41
|
+
/** The avatar flights: the greeting leaves with the first turn. */
|
|
42
|
+
flights: AvatarFlights;
|
|
43
|
+
/** Whose greeting it is: the lead's avatar is the one that flies. */
|
|
44
|
+
lead: AliaLead;
|
|
45
|
+
/** A turn sent once as the chat mounts: the conversation a sheet was opened for. */
|
|
46
|
+
initialTurn: AliaTurn | undefined;
|
|
47
|
+
/** The signed-out person sent a turn: the host asks them to sign in. */
|
|
48
|
+
onSignInRequired: (() => void) | undefined;
|
|
49
|
+
/** A refusal to say, with its title. */
|
|
50
|
+
reportError: (message: string, title?: string) => void;
|
|
51
|
+
/** The suggestions over the composer on an empty chat, and the host's side of finding them. */
|
|
52
|
+
welcomeSuggestions: AliaSuggestion[] | undefined;
|
|
53
|
+
suggestionMatches: AliaSuggestion[] | undefined;
|
|
54
|
+
onSuggestionQuery: ((query: string) => void) | undefined;
|
|
55
|
+
onSuggestionPicked: ((suggestion: AliaSuggestion) => void) | undefined;
|
|
56
|
+
/** The sheet this chat is in: the composer takes focus once it is up. */
|
|
57
|
+
presentedProgress: SharedValue<number> | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A photo handed over as a `data:` URL, as the composer would hold it. */
|
|
61
|
+
function dataUrlAttachment(url: string, index: number): AliaAttachment {
|
|
62
|
+
const mimeType = /^data:([^;,]+)/.exec(url)?.[1] ?? 'image/jpeg';
|
|
63
|
+
return {
|
|
64
|
+
id: `turn-photo-${index}`,
|
|
65
|
+
type: 'image',
|
|
66
|
+
name: `photo-${index + 1}`,
|
|
67
|
+
uri: url,
|
|
68
|
+
size: 0,
|
|
69
|
+
mimeType,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything the person does with the composer: the draft they are writing (kept per conversation,
|
|
75
|
+
* shared with every other surface), sending it, rewriting a question already sent, answering
|
|
76
|
+
* Alia's questions, picking a suggestion, asking for a reply again.
|
|
77
|
+
*
|
|
78
|
+
* Sending is the one path every turn takes. The draft is cleared at once and handed back if the
|
|
79
|
+
* request fails; an edit replaces the question it rewrites; an empty chat may hand its first turn
|
|
80
|
+
* to the host; a signed-out person is sent to sign in with their draft kept.
|
|
81
|
+
*/
|
|
82
|
+
export function useChatComposer({
|
|
83
|
+
conversationId,
|
|
84
|
+
thread,
|
|
85
|
+
ephemeral,
|
|
86
|
+
closed,
|
|
87
|
+
onWrite,
|
|
88
|
+
onStart,
|
|
89
|
+
flights,
|
|
90
|
+
lead,
|
|
91
|
+
initialTurn,
|
|
92
|
+
onSignInRequired,
|
|
93
|
+
reportError,
|
|
94
|
+
welcomeSuggestions,
|
|
95
|
+
suggestionMatches,
|
|
96
|
+
onSuggestionQuery,
|
|
97
|
+
onSuggestionPicked,
|
|
98
|
+
presentedProgress,
|
|
99
|
+
}: UseChatComposerOptions) {
|
|
100
|
+
const { isAuthenticated } = useOxy();
|
|
101
|
+
const {
|
|
102
|
+
messages,
|
|
103
|
+
isLoading,
|
|
104
|
+
sendMessage,
|
|
105
|
+
createNewConversation,
|
|
106
|
+
editMessage,
|
|
107
|
+
regenerateMessage,
|
|
108
|
+
turnOptionsOf,
|
|
109
|
+
} = thread;
|
|
110
|
+
|
|
111
|
+
// ── The draft ──
|
|
112
|
+
const target = conversationId ?? null;
|
|
113
|
+
const draft = useComposerDraft(target);
|
|
114
|
+
const address = useDraftAddress(target);
|
|
115
|
+
const photos = draft.attachments;
|
|
116
|
+
/** What a send carries beyond the text: the connector and skills chosen for this turn. */
|
|
117
|
+
const turnOptions = useMemo<SendOptions>(
|
|
118
|
+
() => ({ mcpServerId: draft.mcpServerId, skillNames: draft.skillNames }),
|
|
119
|
+
[draft.mcpServerId, draft.skillNames],
|
|
120
|
+
);
|
|
121
|
+
const setText = useCallback(
|
|
122
|
+
(text: string) => useComposerDraftStore.getState().setText(address, text),
|
|
123
|
+
[address],
|
|
124
|
+
);
|
|
125
|
+
const addAttachment = useCallback(
|
|
126
|
+
(attachment: AliaAttachment) =>
|
|
127
|
+
useComposerDraftStore.getState().addAttachment(address, attachment),
|
|
128
|
+
[address],
|
|
129
|
+
);
|
|
130
|
+
const removeAttachment = useCallback(
|
|
131
|
+
(id: string) => useComposerDraftStore.getState().removeAttachment(address, id),
|
|
132
|
+
[address],
|
|
133
|
+
);
|
|
134
|
+
const edit = useTurnEdit({ target, messages, turnOptionsOf, onEditMessage: editMessage });
|
|
135
|
+
const { editing } = edit;
|
|
136
|
+
|
|
137
|
+
// ── Sending ──
|
|
138
|
+
// A turn goes into the open conversation; with none open, it creates one and is sent from it —
|
|
139
|
+
// unless nothing is to be stored.
|
|
140
|
+
const sendTurn = useCallback(
|
|
141
|
+
(text: string, attachments?: AliaAttachment[], options?: SendOptions) =>
|
|
142
|
+
conversationId || ephemeral
|
|
143
|
+
? sendMessage(text, attachments, options)
|
|
144
|
+
: createNewConversation(text, attachments, options),
|
|
145
|
+
[conversationId, ephemeral, sendMessage, createNewConversation],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// The first turn of an empty chat goes to the host when it asked for it; every other turn is
|
|
149
|
+
// sent here.
|
|
150
|
+
const firstTurn = messages.length === 0;
|
|
151
|
+
const startsElsewhere = onStart !== undefined && firstTurn;
|
|
152
|
+
const submitTurn = useCallback(
|
|
153
|
+
async (text: string, attachments: AliaAttachment[] = [], options?: SendOptions) => {
|
|
154
|
+
onWrite();
|
|
155
|
+
if (startsElsewhere) {
|
|
156
|
+
const images = await Promise.all(
|
|
157
|
+
attachments.map((photo) => imageUriToDataUrl(photo.uri, photo.mimeType)),
|
|
158
|
+
);
|
|
159
|
+
// The host's conversation sheet opens first so that the flight, drawn when it launches, is
|
|
160
|
+
// above it; this chat stays on the greeting, so the avatar can still be measured.
|
|
161
|
+
onStart({ text, ...(images.length > 0 ? { images } : {}) });
|
|
162
|
+
await flights.launch([leadToProfile(lead)]);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// The greeting leaves with the first turn: measure it before it does.
|
|
166
|
+
if (firstTurn) await flights.launch([leadToProfile(lead)]);
|
|
167
|
+
await sendTurn(text, attachments, options);
|
|
168
|
+
},
|
|
169
|
+
[startsElsewhere, onStart, firstTurn, flights, lead, sendTurn, onWrite],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// The conversation this chat was opened for, sent once.
|
|
173
|
+
const initialSent = useRef(false);
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!initialTurn || initialSent.current) return;
|
|
176
|
+
initialSent.current = true;
|
|
177
|
+
void sendTurn(initialTurn.text, initialTurn.images?.map(dataUrlAttachment));
|
|
178
|
+
}, [initialTurn, sendTurn]);
|
|
179
|
+
|
|
180
|
+
const submit = useCallback(
|
|
181
|
+
async (value: string) => {
|
|
182
|
+
const text = value.trim();
|
|
183
|
+
if ((!text && photos.length === 0) || isLoading || closed) return;
|
|
184
|
+
// An edit rewrites the question's words; an attachment alone does not make a replacement.
|
|
185
|
+
if (editing !== undefined && !text) return;
|
|
186
|
+
// Signed out: the host asks for the sign-in instead of firing a request that would 401. The
|
|
187
|
+
// draft stays in the composer for after.
|
|
188
|
+
if (!isAuthenticated && onSignInRequired) {
|
|
189
|
+
onSignInRequired();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const attachments = photos.length > 0 ? photos : undefined;
|
|
193
|
+
// Cleared optimistically: the send path restores text, attachments and the turn's connector
|
|
194
|
+
// and skills to this draft if the request fails.
|
|
195
|
+
useComposerDraftStore.getState().clear(address);
|
|
196
|
+
try {
|
|
197
|
+
if (editing !== undefined) await edit.submit(text, turnOptions, attachments);
|
|
198
|
+
else await submitTurn(text, attachments, turnOptions);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Nothing was sent (the photos could not be read): keep the draft.
|
|
201
|
+
useComposerDraftStore.getState().restore(address, { text: value, attachments: photos });
|
|
202
|
+
reportError(err instanceof Error ? err.message : 'Could not attach the photos');
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
[
|
|
206
|
+
photos,
|
|
207
|
+
isLoading,
|
|
208
|
+
closed,
|
|
209
|
+
editing,
|
|
210
|
+
isAuthenticated,
|
|
211
|
+
onSignInRequired,
|
|
212
|
+
address,
|
|
213
|
+
edit,
|
|
214
|
+
turnOptions,
|
|
215
|
+
submitTurn,
|
|
216
|
+
reportError,
|
|
217
|
+
],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// A reply again, sent the way its question was (the composer's choice when it is unknown).
|
|
221
|
+
const latest = useRef({ regenerateMessage, turnOptions });
|
|
222
|
+
latest.current = { regenerateMessage, turnOptions };
|
|
223
|
+
const regenerate = useCallback((assistantMessageId: string) => {
|
|
224
|
+
const { regenerateMessage: again, turnOptions: fallback } = latest.current;
|
|
225
|
+
void again(assistantMessageId, fallback);
|
|
226
|
+
}, []);
|
|
227
|
+
|
|
228
|
+
// ── Suggestions over the composer on an empty chat ──
|
|
229
|
+
const query = useSuggestionQuery(draft.text, firstTurn && !closed);
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
onSuggestionQuery?.(query);
|
|
232
|
+
}, [query, onSuggestionQuery]);
|
|
233
|
+
// Read through a ref: it is a prop of the suggestions' rows and the send path changes per token.
|
|
234
|
+
const submitRef = useRef(submit);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
submitRef.current = submit;
|
|
237
|
+
});
|
|
238
|
+
const pickSuggestion = useCallback(
|
|
239
|
+
(suggestion: AliaSuggestion) => {
|
|
240
|
+
onSuggestionPicked?.(suggestion);
|
|
241
|
+
// A template has blanks to fill, so it goes into the composer for the person to finish
|
|
242
|
+
// instead of being sent with its placeholders.
|
|
243
|
+
if (suggestion.template) setText(suggestion.text);
|
|
244
|
+
else void submitRef.current(suggestion.text);
|
|
245
|
+
},
|
|
246
|
+
[onSuggestionPicked, setText],
|
|
247
|
+
);
|
|
248
|
+
const suggestions = useComposerSuggestions({
|
|
249
|
+
draft: draft.text,
|
|
250
|
+
enabled: firstTurn && !closed,
|
|
251
|
+
welcome: welcomeSuggestions,
|
|
252
|
+
matches: suggestionMatches,
|
|
253
|
+
onPick: pickSuggestion,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── The field: focus, and its keys ──
|
|
257
|
+
const inputRef = useRef<TextInput | null>(null);
|
|
258
|
+
useFocusWhenPresented(presentedProgress, () => inputRef.current?.focus());
|
|
259
|
+
// Starting an edit puts the caret in the composer that now holds the question: the Edit press
|
|
260
|
+
// left focus on the row's button, so typing went nowhere and Escape could not reach the edit.
|
|
261
|
+
const editingId = editing?.messageId;
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (editingId !== undefined) inputRef.current?.focus();
|
|
264
|
+
}, [editingId]);
|
|
265
|
+
const { onKeyPress: onSuggestionKeyPress } = suggestions;
|
|
266
|
+
const cancelEdit = edit.cancel;
|
|
267
|
+
const onKeyPress = useCallback(
|
|
268
|
+
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
269
|
+
// Escape leaves an edit, as its Cancel does: it is the top-most thing open while the
|
|
270
|
+
// question is being rewritten.
|
|
271
|
+
if (editing !== undefined && event.nativeEvent.key === 'Escape') {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
cancelEdit();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
onSuggestionKeyPress(event);
|
|
277
|
+
},
|
|
278
|
+
[editing, cancelEdit, onSuggestionKeyPress],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
text: draft.text,
|
|
283
|
+
setText,
|
|
284
|
+
photos,
|
|
285
|
+
addAttachment,
|
|
286
|
+
removeAttachment,
|
|
287
|
+
address,
|
|
288
|
+
turnOptions,
|
|
289
|
+
edit,
|
|
290
|
+
editing,
|
|
291
|
+
firstTurn,
|
|
292
|
+
sendTurn,
|
|
293
|
+
submit,
|
|
294
|
+
regenerate,
|
|
295
|
+
suggestions: { ...suggestions, onPick: pickSuggestion },
|
|
296
|
+
inputRef,
|
|
297
|
+
onKeyPress,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import type { AliaAvatarState } from '../components/AliaAvatar';
|
|
5
|
+
import { callThread } from '../lib/call-thread';
|
|
6
|
+
import { turnActivityState } from '../lib/turn-state';
|
|
7
|
+
import { useAliaRuntime } from '../runtime/config';
|
|
8
|
+
import { aliaQueryKeys } from '../runtime/query-keys';
|
|
9
|
+
import type { Message } from '../runtime/types';
|
|
10
|
+
import { type SoundSources, useVoiceSoundEffects } from './useSoundEffects';
|
|
11
|
+
import { type UseVoiceLauncherOptions, useVoiceLauncher } from './useVoiceLauncher';
|
|
12
|
+
|
|
13
|
+
/** What a host tells the chat about its voice call (see {@link UseVoiceLauncherOptions}). */
|
|
14
|
+
export type AliaVoiceOptions = Omit<
|
|
15
|
+
UseVoiceLauncherOptions,
|
|
16
|
+
'apiUrl' | 'conversationId' | 'agentId' | 'accountId' | 'resolveConversation' | 'isFocused'
|
|
17
|
+
> & {
|
|
18
|
+
/** The cues played as the call thinks, uses tools, connects and ends. Omitted, the call is silent. */
|
|
19
|
+
sounds?: SoundSources;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface UseChatVoiceOptions {
|
|
23
|
+
/** The host's say: on by default, `false` for a text-only chat, an object for its callbacks. */
|
|
24
|
+
options: false | AliaVoiceOptions | undefined;
|
|
25
|
+
conversationId: string | undefined;
|
|
26
|
+
agentId: string | undefined;
|
|
27
|
+
/** Whether the screen holding the chat is the one on show. A live call ends when it is not. */
|
|
28
|
+
isFocused: boolean;
|
|
29
|
+
/** Makes the conversation a call lives in when none is open (after the gate). */
|
|
30
|
+
ensureConversation: () => Promise<string | undefined>;
|
|
31
|
+
messages: Message[];
|
|
32
|
+
setMessages: (messages: Message[]) => void;
|
|
33
|
+
/** The conversation's stored messages: a call message shows as stored once it is. */
|
|
34
|
+
storedMessages: readonly Message[] | undefined;
|
|
35
|
+
/** A text turn is in flight. */
|
|
36
|
+
isLoading: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The chat's voice call.
|
|
41
|
+
*
|
|
42
|
+
* The launcher owns whether a call is open, the gate before one opens and what it reports back
|
|
43
|
+
* (for the ambient wave, the cues and the transcript); the call itself loads on the first one.
|
|
44
|
+
* The transcript is merged into the thread: the text messages the chat held when the call
|
|
45
|
+
* started, then the call's, so the conversation reads as one. And what Alia is doing — the
|
|
46
|
+
* header's mark — follows the call while one is open and the text turn otherwise.
|
|
47
|
+
*/
|
|
48
|
+
export function useChatVoice({
|
|
49
|
+
options,
|
|
50
|
+
conversationId,
|
|
51
|
+
agentId,
|
|
52
|
+
isFocused,
|
|
53
|
+
ensureConversation,
|
|
54
|
+
messages,
|
|
55
|
+
setMessages,
|
|
56
|
+
storedMessages,
|
|
57
|
+
isLoading,
|
|
58
|
+
}: UseChatVoiceOptions) {
|
|
59
|
+
const runtime = useAliaRuntime();
|
|
60
|
+
const { user } = useOxy();
|
|
61
|
+
const queryClient = useQueryClient();
|
|
62
|
+
const enabled = options !== false;
|
|
63
|
+
const { sounds, ...launcherOptions }: AliaVoiceOptions =
|
|
64
|
+
options === false || options === undefined ? {} : options;
|
|
65
|
+
const { onCallEnded } = launcherOptions;
|
|
66
|
+
|
|
67
|
+
const refreshConversation = useCallback(
|
|
68
|
+
(id: string) => {
|
|
69
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.detail(id) });
|
|
70
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
|
|
71
|
+
},
|
|
72
|
+
[queryClient],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const launcher = useVoiceLauncher({
|
|
76
|
+
apiUrl: runtime.apiUrl,
|
|
77
|
+
conversationId,
|
|
78
|
+
agentId,
|
|
79
|
+
accountId: user?.id ?? null,
|
|
80
|
+
locale: runtime.speech.language,
|
|
81
|
+
voice: runtime.speech.voice,
|
|
82
|
+
resolveConversation: ensureConversation,
|
|
83
|
+
onMessagesSaved: refreshConversation,
|
|
84
|
+
isFocused,
|
|
85
|
+
...launcherOptions,
|
|
86
|
+
// The call's transcript is in the thread, whatever else the host refreshes.
|
|
87
|
+
onCallEnded: () => {
|
|
88
|
+
if (conversationId !== undefined) refreshConversation(conversationId);
|
|
89
|
+
onCallEnded?.();
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const { isActive, state, start } = launcher;
|
|
93
|
+
const agentState = state?.agentState;
|
|
94
|
+
|
|
95
|
+
useVoiceSoundEffects({
|
|
96
|
+
isVoiceActive: isActive,
|
|
97
|
+
agentState: agentState ?? 'idle',
|
|
98
|
+
isConnected: state?.isConnected ?? false,
|
|
99
|
+
sounds: sounds ?? {},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// The transcript, after the text messages the chat held when the call started.
|
|
103
|
+
const textBeforeCall = useRef<Message[]>([]);
|
|
104
|
+
const voiceMessages = state?.messages;
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!voiceMessages) return;
|
|
107
|
+
setMessages(callThread(textBeforeCall.current, voiceMessages, storedMessages));
|
|
108
|
+
}, [voiceMessages, storedMessages, setMessages]);
|
|
109
|
+
|
|
110
|
+
const latestMessages = useRef(messages);
|
|
111
|
+
latestMessages.current = messages;
|
|
112
|
+
const activate = useCallback(() => {
|
|
113
|
+
if (isActive) return;
|
|
114
|
+
textBeforeCall.current = [...latestMessages.current];
|
|
115
|
+
void start();
|
|
116
|
+
}, [isActive, start]);
|
|
117
|
+
|
|
118
|
+
// What Alia is doing, which the header's mark shows.
|
|
119
|
+
const markState = useMemo<AliaAvatarState>(() => {
|
|
120
|
+
if (isActive) {
|
|
121
|
+
if (agentState === 'speaking') return 'writing';
|
|
122
|
+
if (agentState === 'thinking') return 'thinking';
|
|
123
|
+
return 'idle';
|
|
124
|
+
}
|
|
125
|
+
return turnActivityState(messages, isLoading);
|
|
126
|
+
}, [messages, isLoading, isActive, agentState]);
|
|
127
|
+
|
|
128
|
+
return { enabled, launcher, isActive, state, agentState, activate, markState };
|
|
129
|
+
}
|
|
@@ -1,37 +1,11 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { type DocumentPickerResult, pickDocuments } from '../lib/document-picker';
|
|
2
2
|
|
|
3
|
-
export type DocumentPickerResult
|
|
4
|
-
uri: string;
|
|
5
|
-
name: string;
|
|
6
|
-
size: number;
|
|
7
|
-
mimeType: string;
|
|
8
|
-
};
|
|
3
|
+
export type { DocumentPickerResult };
|
|
9
4
|
|
|
10
5
|
type UseDocumentPickerResult = {
|
|
11
6
|
pickDocument: () => Promise<DocumentPickerResult[] | undefined>;
|
|
12
7
|
};
|
|
13
8
|
|
|
14
9
|
export function useDocumentPicker(): UseDocumentPickerResult {
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
const result = await DocumentPicker.getDocumentAsync({
|
|
18
|
-
type: '*/*',
|
|
19
|
-
multiple: true,
|
|
20
|
-
copyToCacheDirectory: true,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
if (result.canceled === false && result.assets && result.assets.length > 0) {
|
|
24
|
-
return result.assets.map((asset) => ({
|
|
25
|
-
uri: asset.uri,
|
|
26
|
-
name: asset.name,
|
|
27
|
-
size: asset.size || 0,
|
|
28
|
-
mimeType: asset.mimeType || 'application/octet-stream',
|
|
29
|
-
}));
|
|
30
|
-
}
|
|
31
|
-
} catch (error) {
|
|
32
|
-
console.warn('Failed to pick document:', error);
|
|
33
|
-
}
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
return { pickDocument };
|
|
10
|
+
return { pickDocument: pickDocuments };
|
|
37
11
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useLabels } from '../lib/labels';
|
|
2
|
+
import type { FailedTurn } from '../runtime/model';
|
|
3
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
4
|
+
import { useEffect, useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
/** One toast for the failed turn, replaced by the next failure and dismissed with the turn. */
|
|
7
|
+
export const FAILED_TURN_TOAST_ID = 'failed-turn';
|
|
8
|
+
/** Long enough to read the line and press Retry; a toast that is gone before that is no error. */
|
|
9
|
+
export const FAILED_TURN_TOAST_MS = 10_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A failed turn is told with a toast, with Retry as its action.
|
|
13
|
+
*
|
|
14
|
+
* The person's message is still in the thread, and the toast says why it has no answer in the
|
|
15
|
+
* app's own words, in the reader's language (the server's stand-in text, "all models are busy…",
|
|
16
|
+
* is never shown as Alia's own). Real output before the failure reads "interrupted", not "couldn't
|
|
17
|
+
* answer". The server's words, if any, go under the line. It leaves with the turn: when the failure
|
|
18
|
+
* is cleared (a retry, a new message, another chat), so does the toast.
|
|
19
|
+
*/
|
|
20
|
+
export function useFailedTurnToast(
|
|
21
|
+
failedTurn: FailedTurn | null | undefined,
|
|
22
|
+
onRetry?: () => void,
|
|
23
|
+
) {
|
|
24
|
+
const { t } = useLabels();
|
|
25
|
+
// The latest retry, without making a new toast of every render.
|
|
26
|
+
const retry = useRef(onRetry);
|
|
27
|
+
retry.current = onRetry;
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!failedTurn) return;
|
|
31
|
+
const line = failedTurn.partial ? t('chat.turnInterrupted') : t('chat.turnFailed');
|
|
32
|
+
const canRetry = failedTurn.retryable && retry.current !== undefined;
|
|
33
|
+
toast.error(canRetry ? `${line} ${t('chat.turnFailedRetryHint')}` : line, {
|
|
34
|
+
id: FAILED_TURN_TOAST_ID,
|
|
35
|
+
description: failedTurn.detail || undefined,
|
|
36
|
+
duration: FAILED_TURN_TOAST_MS,
|
|
37
|
+
...(canRetry ? { action: { label: t('chat.retry'), onClick: () => retry.current?.() } } : {}),
|
|
38
|
+
});
|
|
39
|
+
return () => {
|
|
40
|
+
toast.dismiss(FAILED_TURN_TOAST_ID);
|
|
41
|
+
};
|
|
42
|
+
}, [failedTurn, t]);
|
|
43
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
2
|
+
import { useCallback, useRef } from 'react';
|
|
3
|
+
import { useLabels } from '../lib/labels';
|
|
4
|
+
import { useAliaApi } from '../runtime/api';
|
|
5
|
+
import { ALIA_API_ROUTES } from '../runtime/model';
|
|
6
|
+
|
|
7
|
+
export type Vote = 'up' | 'down';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Like or dislike a reply; pressing the same vote again retracts it.
|
|
11
|
+
*
|
|
12
|
+
* `conversationId` is the conversation that holds the MESSAGE, which is not always the one on
|
|
13
|
+
* screen — a thread shows several, and an old message's vote sent to the stretch being streamed
|
|
14
|
+
* into would fail quietly. It is `undefined` for a message no conversation holds yet
|
|
15
|
+
* (`Message.unsaved`): a turn that failed or was stopped. There is nothing to record the vote on,
|
|
16
|
+
* and the button cannot be hidden on its own, so the press says so rather than thanking the person
|
|
17
|
+
* for nothing.
|
|
18
|
+
*/
|
|
19
|
+
export function useMessageVote() {
|
|
20
|
+
const { t } = useLabels();
|
|
21
|
+
const api = useAliaApi();
|
|
22
|
+
/** This screen's votes, read to decide whether a press casts or retracts one. */
|
|
23
|
+
const votes = useRef<Record<string, Vote>>({});
|
|
24
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
25
|
+
|
|
26
|
+
return useCallback(
|
|
27
|
+
(messageId: string, vote: Vote, conversationId: string | undefined) => {
|
|
28
|
+
if (conversationId === undefined) {
|
|
29
|
+
toast.error(t('chat.feedbackFailed'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (inFlight.current.has(messageId)) return;
|
|
33
|
+
const next = votes.current[messageId] === vote ? null : vote;
|
|
34
|
+
if (next) votes.current[messageId] = next;
|
|
35
|
+
else delete votes.current[messageId];
|
|
36
|
+
inFlight.current.add(messageId);
|
|
37
|
+
api
|
|
38
|
+
.patch(ALIA_API_ROUTES.conversations.vote(conversationId, messageId), { vote: next })
|
|
39
|
+
.then(() => toast.success(t('chat.thanksFeedback')))
|
|
40
|
+
.catch(() => {
|
|
41
|
+
// Back to what the server holds, so the next press casts it again.
|
|
42
|
+
if (next) delete votes.current[messageId];
|
|
43
|
+
else votes.current[messageId] = vote;
|
|
44
|
+
toast.error(t('chat.feedbackFailed'));
|
|
45
|
+
})
|
|
46
|
+
.finally(() => inFlight.current.delete(messageId));
|
|
47
|
+
},
|
|
48
|
+
[api, t],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { rememberOpener } from '../lib/focus-return';
|
|
3
|
+
import { useAliaRuntime } from '../runtime/config';
|
|
4
|
+
import { type ThoughtScope, type ThoughtTab, useThoughtStore } from '../runtime/thought-store';
|
|
5
|
+
import type { ThreadMessage } from '../runtime/thread/thread-history';
|
|
6
|
+
|
|
7
|
+
/** Open the thought panel on a turn; `opener` is the control pressed, to give focus back to. */
|
|
8
|
+
export type OpenThought = (messageId: string, tab?: ThoughtTab, opener?: unknown) => void;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The scope a press on a row hands the thought panel: the conversation the row belongs to, which
|
|
12
|
+
* is not always the one being streamed into.
|
|
13
|
+
*
|
|
14
|
+
* A live row belongs to the conversation on screen. A history row belongs to an earlier stretch
|
|
15
|
+
* of the thread — persisted and complete — so its scope is that stretch's messages and nothing
|
|
16
|
+
* about the live turn. Keyed by conversation and message: the panel never shows one
|
|
17
|
+
* conversation's turn under another's.
|
|
18
|
+
*/
|
|
19
|
+
export function thoughtScopeFor(
|
|
20
|
+
messageId: string,
|
|
21
|
+
live: ThoughtScope,
|
|
22
|
+
history: readonly ThreadMessage[],
|
|
23
|
+
): ThoughtScope {
|
|
24
|
+
if (live.messages.some((m) => m.id === messageId)) return live;
|
|
25
|
+
const past = history.find((m) => m.id === messageId);
|
|
26
|
+
if (past === undefined) return live;
|
|
27
|
+
return {
|
|
28
|
+
conversationId: past.conversationId,
|
|
29
|
+
messages: history.filter((m) => m.conversationId === past.conversationId),
|
|
30
|
+
status: 'ready',
|
|
31
|
+
isLoading: false,
|
|
32
|
+
failedTurn: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Open the thought panel on any turn — the one in flight or one long past — or `undefined` when
|
|
38
|
+
* the host has no panel to show it in (`AliaRuntimeHost.showThought`): the row then draws its
|
|
39
|
+
* "worked for" line as plain text.
|
|
40
|
+
*
|
|
41
|
+
* `runningTurnId` is the turn in flight once it has begun using tools. The first tool of a turn
|
|
42
|
+
* opens the panel on it unless the host declines (something else is open), once per turn: a
|
|
43
|
+
* reader who closes it keeps it closed.
|
|
44
|
+
*
|
|
45
|
+
* The returned function is stable: it is a prop of every memoised row, while the live scope
|
|
46
|
+
* changes per streamed token, so the scopes are read at press time through a ref.
|
|
47
|
+
*/
|
|
48
|
+
export function useOpenThought(
|
|
49
|
+
live: ThoughtScope,
|
|
50
|
+
history: readonly ThreadMessage[],
|
|
51
|
+
runningTurnId: string | null,
|
|
52
|
+
): OpenThought | undefined {
|
|
53
|
+
const { showThought } = useAliaRuntime().host;
|
|
54
|
+
const select = useThoughtStore((s) => s.open);
|
|
55
|
+
const latest = useRef({ live, history, showThought });
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
latest.current = { live, history, showThought };
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const open = useCallback<OpenThought>(
|
|
61
|
+
(messageId, tab, opener) => {
|
|
62
|
+
const { live: scope, history: past, showThought: show } = latest.current;
|
|
63
|
+
rememberOpener(opener);
|
|
64
|
+
select(messageId, thoughtScopeFor(messageId, scope, past), tab);
|
|
65
|
+
show?.('pressed');
|
|
66
|
+
},
|
|
67
|
+
[select],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const autoOpened = useRef<string | null>(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (runningTurnId === null || autoOpened.current === runningTurnId) return;
|
|
73
|
+
autoOpened.current = runningTurnId;
|
|
74
|
+
const { live: scope, showThought: show } = latest.current;
|
|
75
|
+
if (show?.('automatic') === true) select(runningTurnId, scope);
|
|
76
|
+
}, [runningTurnId, select]);
|
|
77
|
+
|
|
78
|
+
return showThought === undefined ? undefined : open;
|
|
79
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { type AskedQuestion, pendingQuestions } from '../lib/questions';
|
|
3
|
-
import type {
|
|
3
|
+
import type { Message } from '../runtime/types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* What Alia asked in her last turn and the person has not answered: asked above the composer,
|
|
@@ -8,7 +8,7 @@ import type { ChatMessage } from '../types';
|
|
|
8
8
|
* puts the card away until she asks again.
|
|
9
9
|
*/
|
|
10
10
|
export function usePendingQuestions(
|
|
11
|
-
messages: readonly Pick<
|
|
11
|
+
messages: readonly Pick<Message, 'id' | 'role' | 'toolInvocations'>[],
|
|
12
12
|
isStreaming: boolean,
|
|
13
13
|
): { questions: AskedQuestion[]; dismiss: () => void } {
|
|
14
14
|
const asked = useMemo(() => pendingQuestions(messages, isStreaming), [messages, isStreaming]);
|