@alia.onl/sdk 15.8.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 +242 -13
- package/README.md +223 -151
- package/package.json +15 -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/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 +0 -3
- 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 +76 -34
- package/src/lib/voice-controls-labels.ts +31 -0
- package/src/lib/voice-protocol.ts +32 -14
- 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/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,557 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type QueryClient,
|
|
3
|
+
useInfiniteQuery,
|
|
4
|
+
useMutation,
|
|
5
|
+
useQuery,
|
|
6
|
+
useQueryClient,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
import { toast } from '@oxy.so/bloom/toast';
|
|
9
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
10
|
+
import { useCallback, useMemo } from 'react';
|
|
11
|
+
import { useLabels } from '../lib/labels';
|
|
12
|
+
import { type AliaApi, useAliaApi } from './api';
|
|
13
|
+
import { type OfflineConversationStore, useAliaRuntime } from './config';
|
|
14
|
+
import { errorMessage as getErrorMessage, errorStatus } from './error-utils';
|
|
15
|
+
import { normalizeConversationMessages } from './message-history';
|
|
16
|
+
import { aliaQueryKeys } from './query-keys';
|
|
17
|
+
import { API_ROUTES } from './routes';
|
|
18
|
+
import { createRandomUuid } from './random-uuid';
|
|
19
|
+
import type { Conversation, ConversationsInfiniteData, ConversationsPage, Message } from './types';
|
|
20
|
+
|
|
21
|
+
/** Conversation as returned by the API (timestamps are ISO strings, not Dates). */
|
|
22
|
+
type ApiConversation = Omit<Conversation, 'createdAt' | 'updatedAt' | 'messages'> & {
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
messages?: Message[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const PAGE_SIZE = 20;
|
|
29
|
+
const FIVE_MINUTES = 1000 * 60 * 5;
|
|
30
|
+
|
|
31
|
+
/** A stable signal for a URL whose server-side conversation no longer exists. */
|
|
32
|
+
export class ConversationNotFoundError extends Error {
|
|
33
|
+
constructor(readonly conversationId: string) {
|
|
34
|
+
super('Conversation not found');
|
|
35
|
+
this.name = 'ConversationNotFoundError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Message content is either a plain string or a parts array; previews (titles,
|
|
40
|
+
// lastMessage) need the flattened text either way.
|
|
41
|
+
function messageText(message: Message | undefined): string {
|
|
42
|
+
const content = message?.content;
|
|
43
|
+
if (typeof content === 'string') return content;
|
|
44
|
+
if (!content) return '';
|
|
45
|
+
return content
|
|
46
|
+
.map((part) => (typeof part.text === 'string' ? part.text : ''))
|
|
47
|
+
.join(' ')
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The offline copy of the conversations: what a session whose token failed
|
|
53
|
+
* (a 401) reads and writes instead of the server. Without a store configured
|
|
54
|
+
* it holds nothing, and the 401 is the caller's to handle.
|
|
55
|
+
*/
|
|
56
|
+
class OfflineConversations {
|
|
57
|
+
constructor(private readonly store: OfflineConversationStore | null) {}
|
|
58
|
+
|
|
59
|
+
get available(): boolean {
|
|
60
|
+
return this.store !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async read(): Promise<Conversation[]> {
|
|
64
|
+
const stored = await this.store?.getItem();
|
|
65
|
+
if (!stored) return [];
|
|
66
|
+
return (JSON.parse(stored) as Conversation[]).map((conv) => ({
|
|
67
|
+
...conv,
|
|
68
|
+
createdAt: new Date(conv.createdAt),
|
|
69
|
+
updatedAt: new Date(conv.updatedAt),
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async write(conversations: Conversation[]): Promise<void> {
|
|
74
|
+
await this.store?.setItem(JSON.stringify(conversations));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async remove(id: string): Promise<void> {
|
|
78
|
+
if (!this.store) return;
|
|
79
|
+
const stored = await this.store.getItem();
|
|
80
|
+
if (!stored) return;
|
|
81
|
+
try {
|
|
82
|
+
const conversations: Conversation[] = JSON.parse(stored);
|
|
83
|
+
const remaining = conversations.filter((conversation) => conversation.id !== id);
|
|
84
|
+
if (remaining.length !== conversations.length) {
|
|
85
|
+
await this.store.setItem(JSON.stringify(remaining));
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// A malformed offline cache cannot make a valid server response invalid.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the conversation functions need: the API and the offline copy. */
|
|
94
|
+
export interface ConversationsClient {
|
|
95
|
+
api: AliaApi;
|
|
96
|
+
offline: OfflineConversations;
|
|
97
|
+
/** Read at call time: the title of a chat created while signed out. */
|
|
98
|
+
newChatTitle: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The runtime's conversation client for the nearest provider. */
|
|
102
|
+
export function useConversationsClient(): ConversationsClient {
|
|
103
|
+
const api = useAliaApi();
|
|
104
|
+
const { offline } = useAliaRuntime();
|
|
105
|
+
const { newChat } = useLabels();
|
|
106
|
+
return useMemo(
|
|
107
|
+
() => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat }),
|
|
108
|
+
[api, offline, newChat],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fetchConversationsPage(
|
|
113
|
+
client: ConversationsClient,
|
|
114
|
+
pageParam: string | undefined,
|
|
115
|
+
agentId: string | undefined,
|
|
116
|
+
): Promise<ConversationsPage> {
|
|
117
|
+
try {
|
|
118
|
+
const { data } = await client.api.get<{
|
|
119
|
+
conversations: ApiConversation[];
|
|
120
|
+
nextCursor: string | null;
|
|
121
|
+
hasMore: boolean;
|
|
122
|
+
}>(API_ROUTES.conversations.list, {
|
|
123
|
+
params: {
|
|
124
|
+
limit: PAGE_SIZE,
|
|
125
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
126
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
conversations: data.conversations.map((conv) => ({
|
|
131
|
+
...conv,
|
|
132
|
+
createdAt: new Date(conv.createdAt),
|
|
133
|
+
updatedAt: new Date(conv.updatedAt),
|
|
134
|
+
messages: [], // Don't include messages in list view
|
|
135
|
+
})),
|
|
136
|
+
nextCursor: data.nextCursor,
|
|
137
|
+
hasMore: data.hasMore,
|
|
138
|
+
};
|
|
139
|
+
} catch (error: unknown) {
|
|
140
|
+
// If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's are
|
|
141
|
+
// never kept offline).
|
|
142
|
+
if (errorStatus(error) === 401 && agentId === undefined) {
|
|
143
|
+
const conversations = (await client.offline.read()).map((c) => ({
|
|
144
|
+
...c,
|
|
145
|
+
messages: [] as Message[],
|
|
146
|
+
}));
|
|
147
|
+
const offset = pageParam ? Number.parseInt(pageParam, 10) : 0;
|
|
148
|
+
const page = conversations.slice(offset, offset + PAGE_SIZE);
|
|
149
|
+
return {
|
|
150
|
+
conversations: page,
|
|
151
|
+
nextCursor: offset + PAGE_SIZE < conversations.length ? String(offset + PAGE_SIZE) : null,
|
|
152
|
+
hasMore: offset + PAGE_SIZE < conversations.length,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The person's conversations, newest first, a page at a time. The sidebar and the sheet read this.
|
|
161
|
+
* Given `agentId`, only the conversations with that agent (`GET /v1/conversations?agentId=`).
|
|
162
|
+
*/
|
|
163
|
+
export function useConversations({ agentId }: { agentId?: string } = {}) {
|
|
164
|
+
const { isAuthenticated } = useOxy();
|
|
165
|
+
const client = useConversationsClient();
|
|
166
|
+
|
|
167
|
+
return useInfiniteQuery({
|
|
168
|
+
queryKey:
|
|
169
|
+
agentId === undefined
|
|
170
|
+
? aliaQueryKeys.conversations.all
|
|
171
|
+
: aliaQueryKeys.conversations.withAgent(agentId),
|
|
172
|
+
queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
|
|
173
|
+
fetchConversationsPage(client, pageParam, agentId),
|
|
174
|
+
initialPageParam: undefined as string | undefined,
|
|
175
|
+
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
|
|
176
|
+
staleTime: FIVE_MINUTES,
|
|
177
|
+
retry: 2,
|
|
178
|
+
enabled: isAuthenticated,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function fetchConversation(client: ConversationsClient, id: string): Promise<Conversation> {
|
|
183
|
+
try {
|
|
184
|
+
const { data } = await client.api.get<ApiConversation>(API_ROUTES.conversations.get(id));
|
|
185
|
+
return {
|
|
186
|
+
...data,
|
|
187
|
+
createdAt: new Date(data.createdAt),
|
|
188
|
+
updatedAt: new Date(data.updatedAt),
|
|
189
|
+
messages: normalizeConversationMessages(data.messages || []),
|
|
190
|
+
};
|
|
191
|
+
} catch (error: unknown) {
|
|
192
|
+
const status = errorStatus(error);
|
|
193
|
+
// A server-side 404 is authoritative for an authenticated request. Keeping
|
|
194
|
+
// an older offline copy here resurrects a deleted conversation and makes
|
|
195
|
+
// every later read and write hit the same missing id again.
|
|
196
|
+
if (status === 404) {
|
|
197
|
+
await client.offline.remove(id);
|
|
198
|
+
throw new ConversationNotFoundError(id);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// An unavailable authenticated session can still read its offline copy.
|
|
202
|
+
if (status === 401) {
|
|
203
|
+
const conversation = (await client.offline.read()).find((c) => c.id === id);
|
|
204
|
+
if (conversation) {
|
|
205
|
+
return {
|
|
206
|
+
...conversation,
|
|
207
|
+
messages: normalizeConversationMessages(conversation.messages || []),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One conversation with its messages. */
|
|
216
|
+
export function useConversation(id: string | undefined) {
|
|
217
|
+
const { isAuthenticated } = useOxy();
|
|
218
|
+
const client = useConversationsClient();
|
|
219
|
+
|
|
220
|
+
return useQuery({
|
|
221
|
+
queryKey: aliaQueryKeys.conversations.detail(id ?? ''),
|
|
222
|
+
queryFn: () => fetchConversation(client, id ?? ''),
|
|
223
|
+
enabled: isAuthenticated && !!id,
|
|
224
|
+
staleTime: FIVE_MINUTES,
|
|
225
|
+
// A missing conversation is deterministic. Retrying only repeats the same
|
|
226
|
+
// 404 and makes one stale local link look like an API outage in the console.
|
|
227
|
+
retry: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Prefetch a conversation's detail (call from the sidebar on press-in / hover). */
|
|
232
|
+
export function usePrefetchConversation(): (id: string) => void {
|
|
233
|
+
const queryClient = useQueryClient();
|
|
234
|
+
const client = useConversationsClient();
|
|
235
|
+
return useCallback((id) => prefetchConversation(queryClient, client, id), [queryClient, client]);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function prefetchConversation(
|
|
239
|
+
queryClient: QueryClient,
|
|
240
|
+
client: ConversationsClient,
|
|
241
|
+
id: string,
|
|
242
|
+
): void {
|
|
243
|
+
void queryClient.prefetchQuery({
|
|
244
|
+
queryKey: aliaQueryKeys.conversations.detail(id),
|
|
245
|
+
queryFn: () => fetchConversation(client, id),
|
|
246
|
+
staleTime: FIVE_MINUTES,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Put a conversation's row at the top of the first page (or make the first page). */
|
|
251
|
+
function upsertOnTop(
|
|
252
|
+
old: ConversationsInfiniteData | undefined,
|
|
253
|
+
row: Conversation,
|
|
254
|
+
moveExisting: boolean,
|
|
255
|
+
): ConversationsInfiniteData {
|
|
256
|
+
if (!old?.pages) {
|
|
257
|
+
return {
|
|
258
|
+
pages: [{ conversations: [row], nextCursor: null, hasMore: false }],
|
|
259
|
+
pageParams: [undefined],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const pages = [...old.pages];
|
|
263
|
+
if (moveExisting) {
|
|
264
|
+
// Remove the conversation from its current position (if it is in any page)
|
|
265
|
+
for (let i = 0; i < pages.length; i++) {
|
|
266
|
+
const existingIndex = pages[i].conversations.findIndex((c) => c.id === row.id);
|
|
267
|
+
if (existingIndex >= 0) {
|
|
268
|
+
pages[i] = {
|
|
269
|
+
...pages[i],
|
|
270
|
+
conversations: [
|
|
271
|
+
...pages[i].conversations.slice(0, existingIndex),
|
|
272
|
+
...pages[i].conversations.slice(existingIndex + 1),
|
|
273
|
+
],
|
|
274
|
+
};
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} else if (pages[0]?.conversations.some((c) => c.id === row.id)) {
|
|
279
|
+
return { ...old, pages };
|
|
280
|
+
}
|
|
281
|
+
// Always add to top of first page (most recently updated first)
|
|
282
|
+
if (pages[0]) {
|
|
283
|
+
pages[0] = { ...pages[0], conversations: [row, ...pages[0].conversations] };
|
|
284
|
+
}
|
|
285
|
+
return { ...old, pages };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Save a conversation's messages (and title). */
|
|
289
|
+
export function useSaveConversation() {
|
|
290
|
+
const queryClient = useQueryClient();
|
|
291
|
+
const client = useConversationsClient();
|
|
292
|
+
const labels = useLabels();
|
|
293
|
+
|
|
294
|
+
return useMutation({
|
|
295
|
+
retry: 1,
|
|
296
|
+
mutationFn: async ({
|
|
297
|
+
id,
|
|
298
|
+
messages,
|
|
299
|
+
title,
|
|
300
|
+
}: {
|
|
301
|
+
id: string;
|
|
302
|
+
messages: Message[];
|
|
303
|
+
title?: string;
|
|
304
|
+
}): Promise<Conversation> => {
|
|
305
|
+
const lastMessage = messageText(messages[messages.length - 1]).slice(0, 100) || undefined;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const { data } = await client.api.post<ApiConversation>(API_ROUTES.conversations.save, {
|
|
309
|
+
conversationId: id,
|
|
310
|
+
messages,
|
|
311
|
+
...(title && { title }),
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
id: data.id,
|
|
315
|
+
title: data.title,
|
|
316
|
+
lastMessage: data.lastMessage,
|
|
317
|
+
source: data.source,
|
|
318
|
+
createdAt: new Date(data.createdAt),
|
|
319
|
+
updatedAt: new Date(data.updatedAt),
|
|
320
|
+
messages,
|
|
321
|
+
};
|
|
322
|
+
} catch (error: unknown) {
|
|
323
|
+
// If unauthorized, save to the offline copy
|
|
324
|
+
if (errorStatus(error) === 401) {
|
|
325
|
+
const conversations = await client.offline.read();
|
|
326
|
+
const existingIndex = conversations.findIndex((c) => c.id === id);
|
|
327
|
+
|
|
328
|
+
const offlineTitle =
|
|
329
|
+
title ||
|
|
330
|
+
messageText(messages.find((m) => m.role === 'user')).slice(0, 50) ||
|
|
331
|
+
client.newChatTitle;
|
|
332
|
+
const conversation: Conversation = {
|
|
333
|
+
id,
|
|
334
|
+
title: offlineTitle,
|
|
335
|
+
lastMessage,
|
|
336
|
+
createdAt: existingIndex >= 0 ? conversations[existingIndex].createdAt : new Date(),
|
|
337
|
+
updatedAt: new Date(),
|
|
338
|
+
messages,
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const next = [...conversations];
|
|
342
|
+
if (existingIndex >= 0) next[existingIndex] = conversation;
|
|
343
|
+
else next.unshift(conversation);
|
|
344
|
+
|
|
345
|
+
await client.offline.write(next);
|
|
346
|
+
return conversation;
|
|
347
|
+
}
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
onSuccess: (data) => {
|
|
352
|
+
queryClient.setQueryData(
|
|
353
|
+
aliaQueryKeys.conversations.all,
|
|
354
|
+
(old: ConversationsInfiniteData | undefined) =>
|
|
355
|
+
upsertOnTop(old, { ...data, messages: [] }, true),
|
|
356
|
+
);
|
|
357
|
+
// Update individual conversation cache with full data including messages
|
|
358
|
+
queryClient.setQueryData(aliaQueryKeys.conversations.detail(data.id), data);
|
|
359
|
+
},
|
|
360
|
+
onError: (error: unknown) => {
|
|
361
|
+
toast.error(getErrorMessage(error) || labels.conversationSaveFailed);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Rename a conversation.
|
|
368
|
+
*
|
|
369
|
+
* The API has no title-only route: `POST /conversations` writes the title AND
|
|
370
|
+
* replaces the messages. So the full thread is read fresh first (`staleTime:
|
|
371
|
+
* 0` — the sidebar seeds the detail cache from the list with no messages, and
|
|
372
|
+
* saving that seed would empty the thread) and saved back with the new title.
|
|
373
|
+
*/
|
|
374
|
+
export function useRenameConversation() {
|
|
375
|
+
const queryClient = useQueryClient();
|
|
376
|
+
const client = useConversationsClient();
|
|
377
|
+
const save = useSaveConversation();
|
|
378
|
+
|
|
379
|
+
return useMutation({
|
|
380
|
+
mutationFn: async ({ id, title }: { id: string; title: string }) => {
|
|
381
|
+
const conversation = await queryClient.fetchQuery({
|
|
382
|
+
queryKey: aliaQueryKeys.conversations.detail(id),
|
|
383
|
+
queryFn: () => fetchConversation(client, id),
|
|
384
|
+
staleTime: 0,
|
|
385
|
+
});
|
|
386
|
+
return save.mutateAsync({ id, messages: conversation.messages, title });
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Delete a conversation. */
|
|
392
|
+
export function useDeleteConversation() {
|
|
393
|
+
const queryClient = useQueryClient();
|
|
394
|
+
const client = useConversationsClient();
|
|
395
|
+
const labels = useLabels();
|
|
396
|
+
|
|
397
|
+
return useMutation({
|
|
398
|
+
retry: 1,
|
|
399
|
+
mutationFn: async (id: string) => {
|
|
400
|
+
try {
|
|
401
|
+
await client.api.delete(API_ROUTES.conversations.remove(id));
|
|
402
|
+
} catch (error: unknown) {
|
|
403
|
+
// If unauthorized, delete from the offline copy
|
|
404
|
+
if (errorStatus(error) === 401) {
|
|
405
|
+
const conversations = await client.offline.read();
|
|
406
|
+
await client.offline.write(conversations.filter((c) => c.id !== id));
|
|
407
|
+
} else {
|
|
408
|
+
throw error;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return id;
|
|
412
|
+
},
|
|
413
|
+
onSuccess: (id) => {
|
|
414
|
+
queryClient.setQueryData(
|
|
415
|
+
aliaQueryKeys.conversations.all,
|
|
416
|
+
(old: ConversationsInfiniteData | undefined) => {
|
|
417
|
+
if (!old?.pages) return old;
|
|
418
|
+
return {
|
|
419
|
+
...old,
|
|
420
|
+
pages: old.pages.map((page) => ({
|
|
421
|
+
...page,
|
|
422
|
+
conversations: page.conversations.filter((c) => c.id !== id),
|
|
423
|
+
})),
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
);
|
|
427
|
+
queryClient.removeQueries({ queryKey: aliaQueryKeys.conversations.detail(id) });
|
|
428
|
+
},
|
|
429
|
+
onError: (error: unknown) => {
|
|
430
|
+
toast.error(getErrorMessage(error) || labels.conversationDeleteFailed);
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Empty a conversation on the server, keeping the conversation.
|
|
437
|
+
*
|
|
438
|
+
* The persistent half of "Clear conversation" (#553). The local half —
|
|
439
|
+
* `setMessages([])` in `useAliaConversation` — is NOT enough on its own: the
|
|
440
|
+
* detail query still holds the history, and the sync effect there treats
|
|
441
|
+
* "cache has messages, screen has none" as a data upgrade and hydrates them
|
|
442
|
+
* straight back, which is the bug. So the cache is emptied HERE, on success
|
|
443
|
+
* only, and the caller resets its screen after `mutateAsync` resolves — by
|
|
444
|
+
* which point React Query has run this `onSuccess`, so there is no longer a
|
|
445
|
+
* history for the effect to restore. Nothing is touched optimistically: a
|
|
446
|
+
* clear the server refused must leave the thread exactly as it was, because
|
|
447
|
+
* the dialog that asked for it promised the opposite of "it will be back".
|
|
448
|
+
*
|
|
449
|
+
* The list entry keeps its place and loses its preview — the server dropped
|
|
450
|
+
* `lastMessage` in the same transaction — and both queries are then
|
|
451
|
+
* invalidated so whatever the server has (the moved `updatedAt`, a title it
|
|
452
|
+
* may have kept) replaces the local guess.
|
|
453
|
+
*/
|
|
454
|
+
export function useClearConversation() {
|
|
455
|
+
const queryClient = useQueryClient();
|
|
456
|
+
const client = useConversationsClient();
|
|
457
|
+
|
|
458
|
+
return useMutation({
|
|
459
|
+
retry: 1,
|
|
460
|
+
mutationFn: async (id: string) => {
|
|
461
|
+
try {
|
|
462
|
+
await client.api.delete(API_ROUTES.conversations.clearMessages(id));
|
|
463
|
+
} catch (error: unknown) {
|
|
464
|
+
// If unauthorized, clear the offline copy — the same branch a delete takes.
|
|
465
|
+
if (errorStatus(error) === 401) {
|
|
466
|
+
const conversations = await client.offline.read();
|
|
467
|
+
await client.offline.write(
|
|
468
|
+
conversations.map((c) =>
|
|
469
|
+
c.id === id
|
|
470
|
+
? { ...c, messages: [], lastMessage: undefined, updatedAt: new Date() }
|
|
471
|
+
: c,
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
} else {
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return id;
|
|
479
|
+
},
|
|
480
|
+
onSuccess: (id) => {
|
|
481
|
+
queryClient.setQueryData(
|
|
482
|
+
aliaQueryKeys.conversations.detail(id),
|
|
483
|
+
(old: Conversation | undefined) =>
|
|
484
|
+
old ? { ...old, messages: [], lastMessage: undefined } : old,
|
|
485
|
+
);
|
|
486
|
+
queryClient.setQueryData(
|
|
487
|
+
aliaQueryKeys.conversations.all,
|
|
488
|
+
(old: ConversationsInfiniteData | undefined) => {
|
|
489
|
+
if (!old?.pages) return old;
|
|
490
|
+
return {
|
|
491
|
+
...old,
|
|
492
|
+
pages: old.pages.map((page) => ({
|
|
493
|
+
...page,
|
|
494
|
+
conversations: page.conversations.map((c) =>
|
|
495
|
+
c.id === id ? { ...c, lastMessage: undefined } : c,
|
|
496
|
+
),
|
|
497
|
+
})),
|
|
498
|
+
};
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.detail(id) });
|
|
502
|
+
void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Create a conversation (optionally the next stretch of an agent's thread). */
|
|
508
|
+
export function useCreateConversation() {
|
|
509
|
+
const queryClient = useQueryClient();
|
|
510
|
+
const client = useConversationsClient();
|
|
511
|
+
const labels = useLabels();
|
|
512
|
+
|
|
513
|
+
return useMutation({
|
|
514
|
+
mutationFn: async (params?: { agentId?: string }): Promise<Conversation> => {
|
|
515
|
+
try {
|
|
516
|
+
const { data } = await client.api.post<ApiConversation>(API_ROUTES.conversations.create, {
|
|
517
|
+
...(params?.agentId && { agentId: params.agentId }),
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
id: data.id,
|
|
521
|
+
title: data.title,
|
|
522
|
+
lastMessage: undefined,
|
|
523
|
+
source: data.source,
|
|
524
|
+
agentId: data.agentId,
|
|
525
|
+
createdAt: new Date(data.createdAt),
|
|
526
|
+
updatedAt: new Date(data.updatedAt),
|
|
527
|
+
messages: [],
|
|
528
|
+
};
|
|
529
|
+
} catch (error: unknown) {
|
|
530
|
+
// If unauthorized, create it in the offline copy
|
|
531
|
+
if (errorStatus(error) === 401 && client.offline.available) {
|
|
532
|
+
const conversation: Conversation = {
|
|
533
|
+
id: createRandomUuid(),
|
|
534
|
+
title: client.newChatTitle,
|
|
535
|
+
lastMessage: undefined,
|
|
536
|
+
createdAt: new Date(),
|
|
537
|
+
updatedAt: new Date(),
|
|
538
|
+
messages: [],
|
|
539
|
+
};
|
|
540
|
+
await client.offline.write([conversation, ...(await client.offline.read())]);
|
|
541
|
+
return conversation;
|
|
542
|
+
}
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
onSuccess: (data) => {
|
|
547
|
+
queryClient.setQueryData(
|
|
548
|
+
aliaQueryKeys.conversations.all,
|
|
549
|
+
(old: ConversationsInfiniteData | undefined) => upsertOnTop(old, data, false),
|
|
550
|
+
);
|
|
551
|
+
queryClient.setQueryData(aliaQueryKeys.conversations.detail(data.id), data);
|
|
552
|
+
},
|
|
553
|
+
onError: (error: unknown) => {
|
|
554
|
+
toast.error(getErrorMessage(error) || labels.conversationCreateFailed);
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow helpers for handling `unknown` caught errors without `any`.
|
|
3
|
+
*
|
|
4
|
+
* Supports both plain `Error` and Axios-style `{ response: { data, status } }` errors.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Body shape of an Axios-style error `response.data`.
|
|
9
|
+
*
|
|
10
|
+
* `error` is a STRING on some surfaces and an OBJECT on others: everything
|
|
11
|
+
* under `/v1` answers `{ error: { message, type } }`, which is the OpenAI shape
|
|
12
|
+
* it is compatible with. Typing it as a string was not a simplification, it was
|
|
13
|
+
* a claim that is false half the time — and it is what let an object reach a
|
|
14
|
+
* `<Text>` and crash the screen with React error #31.
|
|
15
|
+
*/
|
|
16
|
+
export interface ErrorResponseData {
|
|
17
|
+
error?: string | { message?: string; type?: string };
|
|
18
|
+
message?: string;
|
|
19
|
+
code?: string;
|
|
20
|
+
creditsNeeded?: number;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MaybeHttpError {
|
|
25
|
+
message?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
code?: number | string;
|
|
28
|
+
status?: number;
|
|
29
|
+
statusCode?: number;
|
|
30
|
+
response?: {
|
|
31
|
+
status?: number;
|
|
32
|
+
data?: ErrorResponseData;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function asObject(err: unknown): MaybeHttpError {
|
|
37
|
+
return typeof err === 'object' && err !== null ? (err as MaybeHttpError) : {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A string out of an API error body, whichever shape it came in.
|
|
42
|
+
*
|
|
43
|
+
* `/v1` answers `{ error: { message, type } }` — the OpenAI shape it is
|
|
44
|
+
* compatible with — and everything else answers `{ error: "text" }`. Both are
|
|
45
|
+
* correct for their surface, so the reader has to know both; the alternative is
|
|
46
|
+
* every call site guessing, and the ones that guessed wrong put an object into
|
|
47
|
+
* a `<Text>` and took the screen down with React error #31.
|
|
48
|
+
*
|
|
49
|
+
* Exported because some callers hold a parsed body rather than the error it
|
|
50
|
+
* came from — a `fetch` that checked `res.ok` itself, for instance.
|
|
51
|
+
*/
|
|
52
|
+
export function errorBodyText(
|
|
53
|
+
data: ErrorResponseData | undefined,
|
|
54
|
+
fallback = 'Something went wrong',
|
|
55
|
+
): string {
|
|
56
|
+
if (typeof data?.error === 'string' && data.error) return data.error;
|
|
57
|
+
if (typeof data?.error === 'object' && data.error?.message) return data.error.message;
|
|
58
|
+
if (data?.message) return data.message;
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Best-effort human-readable message, preferring an API `response.data.error`.
|
|
64
|
+
*
|
|
65
|
+
* Returns a STRING, always. It used to return whatever `data.error` held while
|
|
66
|
+
* declaring a string, so an `/v1` body — `{ error: { message, type } }` — came
|
|
67
|
+
* out as an object, was stored as an error, and crashed the render that tried
|
|
68
|
+
* to show it. A function whose return type is a promise the body does not keep
|
|
69
|
+
* is worse than one that admits it might fail.
|
|
70
|
+
*/
|
|
71
|
+
export function errorMessage(err: unknown, fallback = 'Something went wrong'): string {
|
|
72
|
+
const e = asObject(err);
|
|
73
|
+
const body = e.response?.data;
|
|
74
|
+
if (body) {
|
|
75
|
+
const fromBody = errorBodyText(body, '');
|
|
76
|
+
if (fromBody) return fromBody;
|
|
77
|
+
}
|
|
78
|
+
if (err instanceof Error) return err.message;
|
|
79
|
+
if (e.message) return e.message;
|
|
80
|
+
if (typeof err === 'string') return err;
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** HTTP status from `response.status`, `status`, or `statusCode`. */
|
|
85
|
+
export function errorStatus(err: unknown): number | undefined {
|
|
86
|
+
const e = asObject(err);
|
|
87
|
+
return e.response?.status ?? e.status ?? e.statusCode;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Application/system error code (e.g. `'MODEL_NOT_IN_PLAN'`, `'ENOENT'`, `11000`). */
|
|
91
|
+
export function errorCode(err: unknown): number | string | undefined {
|
|
92
|
+
return asObject(err).code;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Error `name` (e.g. `'AbortError'`). */
|
|
96
|
+
export function errorName(err: unknown): string | undefined {
|
|
97
|
+
if (err instanceof Error) return err.name;
|
|
98
|
+
return asObject(err).name;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The Axios-style `response.data` body of an error, if present. */
|
|
102
|
+
export function errorResponseData(err: unknown): ErrorResponseData | undefined {
|
|
103
|
+
return asObject(err).response?.data;
|
|
104
|
+
}
|