@alia.onl/sdk 15.7.0 → 19.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 +255 -12
- package/README.md +241 -155
- package/package.json +17 -5
- 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/csp.ts +50 -0
- package/src/hooks/useAliaActivity.ts +3 -3
- 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/useDictation.ts +2 -3
- 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/useTTS.ts +2 -3
- 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 +2 -6
- 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/character-runtime.ts +6 -4
- 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/origins.ts +15 -0
- 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-audio-engine.ts +14 -158
- package/src/lib/voice-call-request.ts +49 -0
- package/src/lib/voice-call.ts +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- package/src/lib/voice-worklets.ts +19 -0
- package/src/lib/work-log.ts +6 -6
- package/src/runtime/agents.ts +151 -0
- package/src/runtime/api.ts +123 -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 +1427 -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/worklets/voice-processors.js +152 -0
- package/src/components/AliaChatMessageList.tsx +0 -532
- package/src/components/ThinkingBlock.tsx +0 -43
- package/src/hooks/useAliaChat.ts +0 -573
- package/src/lib/api-url.ts +0 -2
- 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,556 @@
|
|
|
1
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
2
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { useCallback, useEffect, useId, useRef } from 'react';
|
|
5
|
+
import { useAliaActivityStore } from '../lib/activity-store';
|
|
6
|
+
import { turnActivityState } from '../lib/turn-state';
|
|
7
|
+
import { useLabels } from '../lib/labels';
|
|
8
|
+
import { getTextFromContent } from '../lib/content-utils';
|
|
9
|
+
import type { AliaAttachment } from '../types';
|
|
10
|
+
import { buildMessageContent } from './attachment-utils';
|
|
11
|
+
import type { AliaHostTools } from './host-tools';
|
|
12
|
+
import { useDocumentUpload } from './oxy-files';
|
|
13
|
+
import { useAliaRuntime } from './config';
|
|
14
|
+
import { releaseAttachments, useComposerDraftStore } from './composer-draft-store';
|
|
15
|
+
import { useConversationStore } from './conversation-store';
|
|
16
|
+
import {
|
|
17
|
+
ConversationNotFoundError,
|
|
18
|
+
useClearConversation,
|
|
19
|
+
useConversation,
|
|
20
|
+
useCreateConversation,
|
|
21
|
+
useDeleteConversation,
|
|
22
|
+
} from './conversations';
|
|
23
|
+
import { aliaQueryKeys } from './query-keys';
|
|
24
|
+
import { reportDroppedAttachments, useStartConversation } from './use-start-conversation';
|
|
25
|
+
import { useConversationStream } from './use-conversation-stream';
|
|
26
|
+
import type { Message, SendOptions } from './types';
|
|
27
|
+
|
|
28
|
+
export interface UseConversationThreadOptions {
|
|
29
|
+
conversationId?: string;
|
|
30
|
+
/** The power level (or device model) every turn is sent with. */
|
|
31
|
+
model?: string;
|
|
32
|
+
agentId?: string;
|
|
33
|
+
/** What the host app is; see `useConversationStream`. */
|
|
34
|
+
clientContext?: string;
|
|
35
|
+
/** The UI effects the host performs; see `useConversationStream`. */
|
|
36
|
+
hostTools?: AliaHostTools;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type MessageContent = Message['content'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What an edit sends in place of the original user turn.
|
|
43
|
+
*
|
|
44
|
+
* A STRING is the composer's edit: it replaces the text of the turn and keeps
|
|
45
|
+
* every non-text part (`image_url`, files) the turn already carried, because
|
|
46
|
+
* the composer only ever shows the text for editing and a person rewording a
|
|
47
|
+
* question about a picture has not asked for the picture to go. A full parts
|
|
48
|
+
* ARRAY is sent verbatim — that is how regenerate replays a turn unchanged, and
|
|
49
|
+
* how a caller that has deliberately removed an attachment says so.
|
|
50
|
+
*/
|
|
51
|
+
export type EditedContent = string | MessageContent;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge an edit into the original turn's content, per the `EditedContent` rule.
|
|
55
|
+
* The replacement text lands where the original's first text part was, so a
|
|
56
|
+
* turn built as `[text, image]` stays `[text, image]` rather than reordering
|
|
57
|
+
* the picture ahead of the question; an original with no text part (image-only)
|
|
58
|
+
* gets the new text appended after its attachments.
|
|
59
|
+
*/
|
|
60
|
+
export function mergeEditedContent(
|
|
61
|
+
original: MessageContent | undefined,
|
|
62
|
+
edit: EditedContent,
|
|
63
|
+
): MessageContent {
|
|
64
|
+
if (typeof edit !== 'string') return edit;
|
|
65
|
+
if (!Array.isArray(original)) return edit;
|
|
66
|
+
const attachments = original.filter((part) => part.type !== 'text');
|
|
67
|
+
if (attachments.length === 0) return edit;
|
|
68
|
+
if (!edit.trim()) return attachments;
|
|
69
|
+
const textPart = { type: 'text', text: edit };
|
|
70
|
+
const firstTextIndex = original.findIndex((part) => part.type === 'text');
|
|
71
|
+
if (firstTextIndex < 0) return [...attachments, textPart];
|
|
72
|
+
const before = original.slice(0, firstTextIndex).filter((part) => part.type !== 'text');
|
|
73
|
+
const after = original.slice(firstTextIndex + 1).filter((part) => part.type !== 'text');
|
|
74
|
+
return [...before, textPart, ...after];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True when there is nothing to send: no text and no attachment part. */
|
|
78
|
+
function isEmptyContent(content: MessageContent | undefined): boolean {
|
|
79
|
+
if (content === undefined || content === null) return true;
|
|
80
|
+
if (typeof content === 'string') return !content.trim();
|
|
81
|
+
return content.length === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function useConversationThread({
|
|
85
|
+
conversationId,
|
|
86
|
+
model,
|
|
87
|
+
agentId,
|
|
88
|
+
clientContext,
|
|
89
|
+
hostTools,
|
|
90
|
+
}: UseConversationThreadOptions = {}) {
|
|
91
|
+
const queryClient = useQueryClient();
|
|
92
|
+
const labels = useLabels();
|
|
93
|
+
const { host } = useAliaRuntime();
|
|
94
|
+
const { user } = useOxy();
|
|
95
|
+
const bindDraftAccount = useComposerDraftStore((state) => state.bindAccount);
|
|
96
|
+
// The drafts are this account's: a different person starts with none.
|
|
97
|
+
useEffect(() => bindDraftAccount(user?.id ?? null), [bindDraftAccount, user?.id]);
|
|
98
|
+
const hasSentPendingMessage = useRef(false);
|
|
99
|
+
const lastConversationId = useRef<string | null>(null);
|
|
100
|
+
const wasLoadingRef = useRef(false);
|
|
101
|
+
|
|
102
|
+
const pendingInitialMessage = useConversationStore((state) => state.pendingInitialMessage);
|
|
103
|
+
const {
|
|
104
|
+
data: conversation,
|
|
105
|
+
error: conversationQueryError,
|
|
106
|
+
isLoading: conversationQueryLoading,
|
|
107
|
+
isFetching: conversationFetching,
|
|
108
|
+
} = useConversation(conversationId || '');
|
|
109
|
+
const createConversationMutation = useCreateConversation();
|
|
110
|
+
const { mutateAsync: deleteConversation } = useDeleteConversation();
|
|
111
|
+
const { mutateAsync: clearMessages } = useClearConversation();
|
|
112
|
+
|
|
113
|
+
// A missing row is not a transient loading failure and the URL cannot become
|
|
114
|
+
// useful by staying open. Remove its cached detail/list state and return to a
|
|
115
|
+
// valid composer; the query itself has retries disabled, so this happens once.
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!(conversationQueryError instanceof ConversationNotFoundError)) return;
|
|
118
|
+
queryClient.removeQueries({
|
|
119
|
+
queryKey: aliaQueryKeys.conversations.detail(conversationQueryError.conversationId),
|
|
120
|
+
});
|
|
121
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
|
|
122
|
+
host.closeConversation?.();
|
|
123
|
+
}, [conversationQueryError, queryClient, host]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Turns go to `/v1/chat/completions`, Alia's product API: the same handler `/alia/chat` mounts,
|
|
127
|
+
* on the one surface that answers a browser from any Oxy app's origin (see `routes.ts`).
|
|
128
|
+
*/
|
|
129
|
+
const {
|
|
130
|
+
messages,
|
|
131
|
+
append,
|
|
132
|
+
isLoading,
|
|
133
|
+
error,
|
|
134
|
+
clearError,
|
|
135
|
+
setMessages,
|
|
136
|
+
stop,
|
|
137
|
+
suggestedNewConversation,
|
|
138
|
+
dismissSuggestedNewConversation,
|
|
139
|
+
agentRun,
|
|
140
|
+
failedTurn,
|
|
141
|
+
retryFailedTurn: retry,
|
|
142
|
+
clearFailedTurn,
|
|
143
|
+
turnOptionsOf,
|
|
144
|
+
} = useConversationStream({
|
|
145
|
+
conversationId,
|
|
146
|
+
model,
|
|
147
|
+
agentId,
|
|
148
|
+
clientContext,
|
|
149
|
+
hostTools,
|
|
150
|
+
});
|
|
151
|
+
const upload = useDocumentUpload();
|
|
152
|
+
|
|
153
|
+
// The turn's lifecycle, published for every "Ask Alia" button (`useAliaActivity`).
|
|
154
|
+
const activitySource = useId();
|
|
155
|
+
const activity = turnActivityState(messages, isLoading);
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const { setLocal } = useAliaActivityStore.getState();
|
|
158
|
+
setLocal(activitySource, activity);
|
|
159
|
+
return () => setLocal(activitySource, 'idle');
|
|
160
|
+
}, [activitySource, activity]);
|
|
161
|
+
|
|
162
|
+
// Expose streaming state globally so sidebar can show a spinner
|
|
163
|
+
const setStreamingConversationId = useConversationStore((s) => s.setStreamingConversationId);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
setStreamingConversationId(isLoading && conversationId ? conversationId : null);
|
|
166
|
+
return () => {
|
|
167
|
+
if (useConversationStore.getState().streamingConversationId === conversationId) {
|
|
168
|
+
setStreamingConversationId(null);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}, [isLoading, conversationId, setStreamingConversationId]);
|
|
172
|
+
|
|
173
|
+
// Refresh sidebar when streaming finishes (backend auto-saves with AI-generated title)
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (wasLoadingRef.current && !isLoading && conversationId) {
|
|
176
|
+
// Immediate refetch (gets saved conversation data)
|
|
177
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
|
|
178
|
+
void queryClient.invalidateQueries({
|
|
179
|
+
queryKey: aliaQueryKeys.conversations.detail(conversationId),
|
|
180
|
+
});
|
|
181
|
+
// Delayed fallback for async title generation. Only the list needs it —
|
|
182
|
+
// re-fetching the whole message detail 5s after every exchange just for
|
|
183
|
+
// a possible title was a second full refetch per message.
|
|
184
|
+
const timer = setTimeout(() => {
|
|
185
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
|
|
186
|
+
}, 5000);
|
|
187
|
+
wasLoadingRef.current = isLoading;
|
|
188
|
+
return () => clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
wasLoadingRef.current = isLoading;
|
|
191
|
+
}, [isLoading, conversationId, queryClient]);
|
|
192
|
+
|
|
193
|
+
// Sync chatId and load messages when conversation changes or when
|
|
194
|
+
// seeded cache data upgrades to full data (messages go from empty to populated).
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!conversationId || conversationQueryLoading) return;
|
|
197
|
+
|
|
198
|
+
const incomingMessages = conversation?.messages || [];
|
|
199
|
+
const isNewConversation = lastConversationId.current !== conversationId;
|
|
200
|
+
const isDataUpgrade =
|
|
201
|
+
!isNewConversation && incomingMessages.length > 0 && messages.length === 0;
|
|
202
|
+
|
|
203
|
+
if (!isNewConversation && !isDataUpgrade) return;
|
|
204
|
+
|
|
205
|
+
if (isNewConversation) {
|
|
206
|
+
lastConversationId.current = conversationId;
|
|
207
|
+
hasSentPendingMessage.current = false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const validMessages = incomingMessages
|
|
211
|
+
.filter((msg) => msg?.role && msg?.content !== undefined)
|
|
212
|
+
.map((msg, index) => ({
|
|
213
|
+
...msg,
|
|
214
|
+
id: msg.id || `db-${conversationId}-${index}`,
|
|
215
|
+
}));
|
|
216
|
+
setMessages(validMessages);
|
|
217
|
+
}, [conversationId, conversation, conversationQueryLoading, setMessages, messages.length]);
|
|
218
|
+
|
|
219
|
+
// An agent wrote into THIS conversation on its own (a result, a check-in).
|
|
220
|
+
// Appended to what is on screen: the detail refetch alone would not reach an
|
|
221
|
+
// open chat, whose local messages are only replaced when the id changes. The
|
|
222
|
+
// server keeps it even if the next turn is sent before this arrives.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (!conversationId) return;
|
|
225
|
+
return host.subscribeConversationMessages?.(conversationId, (incoming) => {
|
|
226
|
+
if (!incoming?.id) return;
|
|
227
|
+
setMessages((prev) => (prev.some((m) => m.id === incoming.id) ? prev : [...prev, incoming]));
|
|
228
|
+
});
|
|
229
|
+
}, [conversationId, setMessages, host]);
|
|
230
|
+
|
|
231
|
+
// Send pending initial message for new conversations
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (!conversationId || !pendingInitialMessage || isLoading) return;
|
|
234
|
+
// Addressed to the conversation it was made for: a second open thread must not take it.
|
|
235
|
+
if (pendingInitialMessage.conversationId !== conversationId) return;
|
|
236
|
+
if (hasSentPendingMessage.current) return;
|
|
237
|
+
if (messages.length > 0) return; // Only send if no messages yet
|
|
238
|
+
|
|
239
|
+
const pending = pendingInitialMessage;
|
|
240
|
+
// Taken now: a failure is handed back to the account that sent it, never
|
|
241
|
+
// to one signed in while the request was out.
|
|
242
|
+
const newChatDraft = useComposerDraftStore.getState().address(null);
|
|
243
|
+
hasSentPendingMessage.current = true;
|
|
244
|
+
host.onTurnStart?.();
|
|
245
|
+
useConversationStore.getState().clearPendingInitialMessage();
|
|
246
|
+
|
|
247
|
+
void append(
|
|
248
|
+
{ role: 'user', content: pending.content },
|
|
249
|
+
{ mcpServerId: pending.mcpServerId, skillNames: pending.skillNames },
|
|
250
|
+
).then(async (outcome) => {
|
|
251
|
+
// The request carries the bytes inline now; the temporary URLs behind
|
|
252
|
+
// the tiles have nothing left to show.
|
|
253
|
+
if (outcome !== 'failed') {
|
|
254
|
+
releaseAttachments(pending.attachments);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The conversation was created by POST /conversations/new before the model
|
|
259
|
+
// was ever reached, and this guard ran on messages.length === 0, so the row
|
|
260
|
+
// is provably empty. Undo it rather than leave a blank chat in the sidebar.
|
|
261
|
+
// A failed delete still leaves the user whole — it only leaves the row.
|
|
262
|
+
await deleteConversation(conversationId).catch(() => {});
|
|
263
|
+
useComposerDraftStore.getState().restore(newChatDraft, {
|
|
264
|
+
text: pending.text,
|
|
265
|
+
attachments: pending.attachments,
|
|
266
|
+
mcpServerId: pending.mcpServerId,
|
|
267
|
+
skillNames: pending.skillNames,
|
|
268
|
+
});
|
|
269
|
+
host.closeConversation?.();
|
|
270
|
+
toast.error(labels.sendFailed);
|
|
271
|
+
});
|
|
272
|
+
}, [
|
|
273
|
+
conversationId,
|
|
274
|
+
pendingInitialMessage,
|
|
275
|
+
isLoading,
|
|
276
|
+
messages.length,
|
|
277
|
+
append,
|
|
278
|
+
deleteConversation,
|
|
279
|
+
host,
|
|
280
|
+
labels,
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
// Actions
|
|
284
|
+
const sendMessage = useCallback(
|
|
285
|
+
async (
|
|
286
|
+
content: string,
|
|
287
|
+
attachments?: AliaAttachment[],
|
|
288
|
+
options?: SendOptions,
|
|
289
|
+
): Promise<boolean> => {
|
|
290
|
+
if ((!content.trim() && !attachments?.length) || isLoading) return false;
|
|
291
|
+
const draft = useComposerDraftStore.getState().address(conversationId ?? null);
|
|
292
|
+
|
|
293
|
+
host.onTurnStart?.();
|
|
294
|
+
|
|
295
|
+
const built = attachments?.length
|
|
296
|
+
? await buildMessageContent(content, attachments, upload)
|
|
297
|
+
: null;
|
|
298
|
+
const messageContent = built ? built.content : content;
|
|
299
|
+
reportDroppedAttachments(built?.dropped, labels);
|
|
300
|
+
|
|
301
|
+
const outcome = await append({ role: 'user', content: messageContent }, options);
|
|
302
|
+
|
|
303
|
+
// Nothing was persisted server-side (a turn without an assistant response is
|
|
304
|
+
// never saved), so returning the composer to its pre-send state is the whole
|
|
305
|
+
// rollback. Only the usage-limit errors end here now; every other failure
|
|
306
|
+
// is `errored` — the turn stays in the thread with its error and a retry,
|
|
307
|
+
// and the composer stays clear because the text is already on screen.
|
|
308
|
+
if (outcome === 'failed') {
|
|
309
|
+
useComposerDraftStore.getState().restore(draft, {
|
|
310
|
+
text: content,
|
|
311
|
+
attachments: attachments ?? [],
|
|
312
|
+
mcpServerId: options?.mcpServerId ?? null,
|
|
313
|
+
skillNames: options?.skillNames ?? [],
|
|
314
|
+
});
|
|
315
|
+
toast.error(labels.sendFailed);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
// A retry re-sends the built content, whose bytes are inline.
|
|
319
|
+
releaseAttachments(attachments ?? []);
|
|
320
|
+
return true;
|
|
321
|
+
},
|
|
322
|
+
[isLoading, append, conversationId, host, labels, upload],
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const startConversation = useStartConversation({ agentId });
|
|
326
|
+
const createNewConversation = useCallback(
|
|
327
|
+
(text: string, attachments?: AliaAttachment[], options?: SendOptions) =>
|
|
328
|
+
startConversation(text, { attachments, options }),
|
|
329
|
+
[startConversation],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The conversation a voice call lives in: the open one, or — on a new-chat
|
|
334
|
+
* screen — a new one, created now and shown, so the transcript has a home
|
|
335
|
+
* the moment the call starts. `undefined` when it could not be created.
|
|
336
|
+
*/
|
|
337
|
+
const ensureConversation = useCallback(async (): Promise<string | undefined> => {
|
|
338
|
+
if (conversationId) return conversationId;
|
|
339
|
+
try {
|
|
340
|
+
const created = await createConversationMutation.mutateAsync({ agentId });
|
|
341
|
+
host.openConversation?.(created.id);
|
|
342
|
+
return created.id;
|
|
343
|
+
} catch {
|
|
344
|
+
// useCreateConversation.onError already told the person.
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
}, [conversationId, createConversationMutation, agentId, host]);
|
|
348
|
+
|
|
349
|
+
/** Leave this thread for an empty one. Nothing is deleted: the conversation stays in the list. */
|
|
350
|
+
const startOver = useCallback(() => {
|
|
351
|
+
stop();
|
|
352
|
+
setMessages([]);
|
|
353
|
+
clearFailedTurn();
|
|
354
|
+
host.closeConversation?.();
|
|
355
|
+
}, [stop, setMessages, clearFailedTurn, host]);
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Rewrite a user turn: cut the thread back to it and send the new version.
|
|
359
|
+
*
|
|
360
|
+
* `attachments` are the ones added in the composer while editing. They go
|
|
361
|
+
* AFTER whatever the turn already carried — a string edit keeps those, see
|
|
362
|
+
* `EditedContent` — so rewording a question and adding a second picture is
|
|
363
|
+
* one edit, not a lost picture.
|
|
364
|
+
*/
|
|
365
|
+
const editMessage = useCallback(
|
|
366
|
+
async (
|
|
367
|
+
messageId: string,
|
|
368
|
+
newContent: EditedContent,
|
|
369
|
+
options?: SendOptions,
|
|
370
|
+
attachments?: AliaAttachment[],
|
|
371
|
+
): Promise<boolean> => {
|
|
372
|
+
// Truncate to messages before the edited one, then re-send.
|
|
373
|
+
// setMessages eagerly syncs messagesRef so append reads truncated history.
|
|
374
|
+
const beforeEdit = messages;
|
|
375
|
+
const draft = useComposerDraftStore.getState().address(conversationId ?? null);
|
|
376
|
+
const original = messages.find((msg) => msg.id === messageId);
|
|
377
|
+
if (original === undefined) return false;
|
|
378
|
+
// A string edit keeps the original turn's attachments; only a full parts
|
|
379
|
+
// array replaces them. See `EditedContent`.
|
|
380
|
+
let content = mergeEditedContent(original.content, newContent);
|
|
381
|
+
if (attachments?.length && typeof newContent === 'string') {
|
|
382
|
+
const built = await buildMessageContent(newContent, attachments, upload);
|
|
383
|
+
reportDroppedAttachments(built.dropped, labels);
|
|
384
|
+
const added =
|
|
385
|
+
typeof built.content === 'string'
|
|
386
|
+
? []
|
|
387
|
+
: built.content.filter((part) => part.type !== 'text');
|
|
388
|
+
if (added.length > 0) {
|
|
389
|
+
content = [
|
|
390
|
+
...(typeof content === 'string' ? [{ type: 'text', text: content }] : content),
|
|
391
|
+
...added,
|
|
392
|
+
];
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (isEmptyContent(content)) return false;
|
|
396
|
+
|
|
397
|
+
setMessages((prev) => {
|
|
398
|
+
const idx = prev.findIndex((msg) => msg.id === messageId);
|
|
399
|
+
return idx < 0 ? prev : prev.slice(0, idx);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const outcome = await append({ role: 'user', content }, options);
|
|
403
|
+
|
|
404
|
+
// append rolls back to the truncated list; only this scope still knows what
|
|
405
|
+
// was cut, so it restores the rest and returns the edit to the composer.
|
|
406
|
+
// The original turn — attachments included — comes back with the rest, so
|
|
407
|
+
// nothing the person attached is orphaned by a send that never happened;
|
|
408
|
+
// the composer gets the text, and the attachments added while editing.
|
|
409
|
+
if (outcome === 'failed') {
|
|
410
|
+
setMessages(beforeEdit);
|
|
411
|
+
useComposerDraftStore.getState().restore(draft, {
|
|
412
|
+
text: typeof newContent === 'string' ? newContent : getTextFromContent(content),
|
|
413
|
+
attachments: attachments ?? [],
|
|
414
|
+
mcpServerId: options?.mcpServerId ?? null,
|
|
415
|
+
skillNames: options?.skillNames ?? [],
|
|
416
|
+
});
|
|
417
|
+
toast.error(labels.sendFailed);
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
releaseAttachments(attachments ?? []);
|
|
421
|
+
return true;
|
|
422
|
+
},
|
|
423
|
+
[setMessages, append, messages, conversationId, labels, upload],
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
/** The user turn a reply answers: the nearest one before it, or `undefined`. */
|
|
427
|
+
const promptOf = useCallback(
|
|
428
|
+
(assistantMessageId: string): Message | undefined => {
|
|
429
|
+
const idx = messages.findIndex((msg) => msg.id === assistantMessageId);
|
|
430
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
431
|
+
if (messages[i].role === 'user') return messages[i];
|
|
432
|
+
}
|
|
433
|
+
return undefined;
|
|
434
|
+
},
|
|
435
|
+
[messages],
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Ask for this reply again, with the options its prompt was sent with.
|
|
440
|
+
*
|
|
441
|
+
* `fallback` is for a prompt this screen did not send (the conversation was
|
|
442
|
+
* reloaded since): nothing records what it carried, so the caller's current
|
|
443
|
+
* choice — the composer's — is the best there is.
|
|
444
|
+
*/
|
|
445
|
+
const regenerateMessage = useCallback(
|
|
446
|
+
async (assistantMessageId: string, fallback?: SendOptions): Promise<boolean> => {
|
|
447
|
+
// Regenerating IS re-sending the prompt that produced this answer. Walk back
|
|
448
|
+
// to the user turn before it and replay that — editMessage already truncates
|
|
449
|
+
// the history and re-appends, so there is no second path to keep in step.
|
|
450
|
+
//
|
|
451
|
+
// The turn goes through WHOLE: a string stays a string and a parts array
|
|
452
|
+
// keeps its `image_url`/file parts. Reducing it to its text first is what
|
|
453
|
+
// used to regenerate "what is in this picture" without the picture, and
|
|
454
|
+
// refuse outright on a prompt that was only a picture.
|
|
455
|
+
const prompt = promptOf(assistantMessageId);
|
|
456
|
+
if (prompt === undefined || isEmptyContent(prompt.content)) return false;
|
|
457
|
+
return editMessage(prompt.id, prompt.content, turnOptionsOf(prompt.id) ?? fallback);
|
|
458
|
+
},
|
|
459
|
+
[promptOf, editMessage, turnOptionsOf],
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const stopGeneration = useCallback(() => {
|
|
463
|
+
stop();
|
|
464
|
+
}, [stop]);
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Send the failed turn again. Guarded on `isLoading` like `sendMessage`:
|
|
468
|
+
* the card is gone the moment a send starts, but a double tap can land
|
|
469
|
+
* before that render.
|
|
470
|
+
*/
|
|
471
|
+
const retryFailedTurn = useCallback(async (): Promise<boolean> => {
|
|
472
|
+
if (isLoading) return false;
|
|
473
|
+
host.onTurnStart?.();
|
|
474
|
+
const outcome = await retry();
|
|
475
|
+
return outcome !== 'failed';
|
|
476
|
+
}, [isLoading, retry, host]);
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Empty the thread — on the server first, then on screen.
|
|
480
|
+
*
|
|
481
|
+
* The order is the fix for #553. `setMessages([])` alone cleared the screen
|
|
482
|
+
* and nothing else, and the sync effect above, seeing a cached history and
|
|
483
|
+
* no messages, read that as a data upgrade and hydrated it straight back
|
|
484
|
+
* under a dialog that had just promised the action could not be undone.
|
|
485
|
+
* `useClearConversation` empties the cache in its `onSuccess`, which has run
|
|
486
|
+
* by the time `mutateAsync` resolves, so the local reset that follows has
|
|
487
|
+
* nothing left to be undone by.
|
|
488
|
+
*
|
|
489
|
+
* A turn still streaming is stopped first rather than refused: the person
|
|
490
|
+
* has just confirmed they want the thread empty, and an answer that kept
|
|
491
|
+
* arriving into it would be the next thing to clear. Stopping settles the
|
|
492
|
+
* assistant placeholder as `cancelled` and lets the server close the turn,
|
|
493
|
+
* so the clear that follows is of a thread nothing is writing to.
|
|
494
|
+
*
|
|
495
|
+
* A refusal keeps everything: the screen is not reset, the cache is not
|
|
496
|
+
* touched, and the error is surfaced — a cleared view whose history returns
|
|
497
|
+
* on the next fetch is exactly the state this exists to end.
|
|
498
|
+
*
|
|
499
|
+
* With no conversation id there is nothing persisted to clear, so the
|
|
500
|
+
* screen's own list is all there is and the local reset is the whole job.
|
|
501
|
+
*/
|
|
502
|
+
const clearConversation = useCallback(async (): Promise<boolean> => {
|
|
503
|
+
if (isLoading) stop();
|
|
504
|
+
|
|
505
|
+
if (conversationId) {
|
|
506
|
+
try {
|
|
507
|
+
await clearMessages(conversationId);
|
|
508
|
+
} catch {
|
|
509
|
+
toast.error(labels.conversationClearFailed);
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
setMessages([]);
|
|
515
|
+
// The message the error hung under is gone with the rest.
|
|
516
|
+
clearFailedTurn();
|
|
517
|
+
return true;
|
|
518
|
+
}, [isLoading, stop, conversationId, clearMessages, setMessages, clearFailedTurn, labels]);
|
|
519
|
+
|
|
520
|
+
// True while loading conversation messages (initial fetch or seeded→full upgrade)
|
|
521
|
+
const conversationLoading =
|
|
522
|
+
conversationQueryLoading ||
|
|
523
|
+
(conversationFetching && (!conversation?.messages || conversation.messages.length === 0));
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
// State
|
|
527
|
+
conversationId,
|
|
528
|
+
conversation,
|
|
529
|
+
messages,
|
|
530
|
+
isLoading,
|
|
531
|
+
conversationLoading,
|
|
532
|
+
error,
|
|
533
|
+
|
|
534
|
+
// Actions
|
|
535
|
+
sendMessage,
|
|
536
|
+
createNewConversation,
|
|
537
|
+
ensureConversation,
|
|
538
|
+
startOver,
|
|
539
|
+
editMessage,
|
|
540
|
+
regenerateMessage,
|
|
541
|
+
promptOf,
|
|
542
|
+
turnOptionsOf,
|
|
543
|
+
stopGeneration,
|
|
544
|
+
clearConversation,
|
|
545
|
+
clearError,
|
|
546
|
+
setMessages,
|
|
547
|
+
suggestedNewConversation,
|
|
548
|
+
dismissSuggestedNewConversation,
|
|
549
|
+
agentRun,
|
|
550
|
+
failedTurn,
|
|
551
|
+
retryFailedTurn,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** What `useConversationThread` hands back: the open conversation and everything that can be done to it. */
|
|
556
|
+
export type ConversationThread = ReturnType<typeof useConversationThread>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
2
|
+
import { useCallback } from 'react';
|
|
3
|
+
import { type AliaChatLabels, useLabels } from '../lib/labels';
|
|
4
|
+
import type { AliaAttachment } from '../types';
|
|
5
|
+
import { buildMessageContent, type DroppedAttachment } from './attachment-utils';
|
|
6
|
+
import { useDocumentUpload } from './oxy-files';
|
|
7
|
+
import { useComposerDraftStore, type DraftTarget } from './composer-draft-store';
|
|
8
|
+
import { useAliaRuntime } from './config';
|
|
9
|
+
import { useConversationStore } from './conversation-store';
|
|
10
|
+
import { useCreateConversation } from './conversations';
|
|
11
|
+
import type { SendOptions } from './types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Say which attachments the turn went without.
|
|
15
|
+
*
|
|
16
|
+
* `buildMessageContent` used to drop them in silence: a document was filtered
|
|
17
|
+
* out by `a.type === 'image'`, and an image whose bytes would not read was a
|
|
18
|
+
* bare `continue`. The composer had already shown both as attached, so the
|
|
19
|
+
* turn left with the person believing the file went with it — the visible
|
|
20
|
+
* state and the real capability disagreeing, which is the failure #608 §1.6
|
|
21
|
+
* names.
|
|
22
|
+
*
|
|
23
|
+
* A toast rather than a blocked send: the message itself is fine and worth
|
|
24
|
+
* sending, and refusing the whole turn over a file the format cannot carry
|
|
25
|
+
* would be a worse trade. What matters is that nobody finds out by noticing
|
|
26
|
+
* the answer ignored their PDF.
|
|
27
|
+
*/
|
|
28
|
+
export function reportDroppedAttachments(
|
|
29
|
+
dropped: DroppedAttachment[] | undefined,
|
|
30
|
+
labels: Pick<AliaChatLabels, 'attachmentNotSent' | 'attachmentUnreadable'>,
|
|
31
|
+
): void {
|
|
32
|
+
if (dropped === undefined || dropped.length === 0) return;
|
|
33
|
+
|
|
34
|
+
for (const attachment of dropped) {
|
|
35
|
+
toast.error(
|
|
36
|
+
attachment.reason === 'unsupported'
|
|
37
|
+
? labels.attachmentNotSent(attachment.name)
|
|
38
|
+
: labels.attachmentUnreadable(attachment.name),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StartConversationOptions {
|
|
44
|
+
attachments?: AliaAttachment[];
|
|
45
|
+
options?: SendOptions;
|
|
46
|
+
/**
|
|
47
|
+
* The composer whose draft the message came from, to hand it back if the conversation cannot
|
|
48
|
+
* be made. A new-chat screen is `null` (the default); a screen that asks outside a chat names
|
|
49
|
+
* its own (`surface:automations`).
|
|
50
|
+
*/
|
|
51
|
+
draft?: DraftTarget;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Start a conversation with a first message.
|
|
56
|
+
*
|
|
57
|
+
* The conversation is created, the message is queued for THAT conversation, and the host is told
|
|
58
|
+
* to show it (`openConversation`): the thread that opens sends the message, so the person lands
|
|
59
|
+
* on the conversation being answered. If the conversation cannot be made the composer gets its
|
|
60
|
+
* draft back. This is the one path for a new-chat screen, the sheet's first turn and a screen
|
|
61
|
+
* that starts a chat from elsewhere (a scheduled task described in words).
|
|
62
|
+
*/
|
|
63
|
+
export function useStartConversation({ agentId }: { agentId?: string } = {}) {
|
|
64
|
+
const labels = useLabels();
|
|
65
|
+
const { host } = useAliaRuntime();
|
|
66
|
+
const { mutateAsync: createConversation } = useCreateConversation();
|
|
67
|
+
const upload = useDocumentUpload();
|
|
68
|
+
|
|
69
|
+
return useCallback(
|
|
70
|
+
async (
|
|
71
|
+
text: string,
|
|
72
|
+
{ attachments = [], options, draft: target = null }: StartConversationOptions = {},
|
|
73
|
+
): Promise<boolean> => {
|
|
74
|
+
if (!text.trim() && attachments.length === 0) return false;
|
|
75
|
+
const draft = useComposerDraftStore.getState().address(target);
|
|
76
|
+
|
|
77
|
+
// Attachments become multi-part content. The raw text and attachments ride along so a
|
|
78
|
+
// failed send can restore them.
|
|
79
|
+
const built =
|
|
80
|
+
attachments.length > 0 ? await buildMessageContent(text, attachments, upload) : null;
|
|
81
|
+
reportDroppedAttachments(built?.dropped, labels);
|
|
82
|
+
try {
|
|
83
|
+
const conversation = await createConversation({ agentId });
|
|
84
|
+
useConversationStore.getState().setPendingInitialMessage({
|
|
85
|
+
conversationId: conversation.id,
|
|
86
|
+
content: built ? built.content : text,
|
|
87
|
+
text,
|
|
88
|
+
attachments,
|
|
89
|
+
mcpServerId: options?.mcpServerId ?? null,
|
|
90
|
+
skillNames: options?.skillNames ?? [],
|
|
91
|
+
});
|
|
92
|
+
host.openConversation?.(conversation.id);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
// useCreateConversation.onError already shows the toast.
|
|
96
|
+
useComposerDraftStore.getState().restore(draft, {
|
|
97
|
+
text,
|
|
98
|
+
attachments,
|
|
99
|
+
mcpServerId: options?.mcpServerId ?? null,
|
|
100
|
+
skillNames: options?.skillNames ?? [],
|
|
101
|
+
});
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[labels, host, createConversation, agentId, upload],
|
|
106
|
+
);
|
|
107
|
+
}
|