@alia.onl/sdk 7.0.0 → 7.2.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 +37 -0
- package/package.json +11 -6
- package/src/components/AliaChatContent.tsx +8 -0
- package/src/components/AliaChatScreen.tsx +2 -2
- package/src/components/AliaChatSheet.tsx +16 -4
- package/src/components/AliaWelcomeMessage.tsx +1 -1
- package/src/components/IdentityMark.tsx +38 -12
- package/src/hooks/useAliaChat.ts +306 -389
- package/src/hooks/useAmbientWave.ts +14 -2
- package/src/hooks/useSpeechToText.ts +4 -2
- package/src/hooks/useTTS.ts +75 -31
- package/src/lib/audio-level.ts +129 -0
- package/src/lib/catalogue.ts +1 -1
- package/src/lib/chat-stream.ts +560 -0
- package/src/lib/chat-transport.ts +55 -0
- package/src/lib/config.ts +1 -1
- package/src/lib/tool-registry.ts +19 -3
- package/src/lib/utils.ts +14 -4
- package/tsconfig.json +1 -1
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useOxy } from '@oxyhq/services';
|
|
3
3
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
4
4
|
import { getTextFromContent } from '../lib/content-utils';
|
|
5
5
|
import { resolveModelId } from '../lib/catalogue';
|
|
6
6
|
import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
|
|
7
|
+
import { streamAliaChat } from '../lib/chat-transport';
|
|
8
|
+
import type { AliaChatStreamEvent } from '../lib/chat-stream';
|
|
7
9
|
|
|
8
10
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
9
11
|
|
|
@@ -11,7 +13,7 @@ export interface UseAliaChatOptions {
|
|
|
11
13
|
/** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
|
|
12
14
|
apiUrl?: string;
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
16
|
+
* Kaana routing profile to use.
|
|
15
17
|
*
|
|
16
18
|
* Optional, and checked against `GET /catalogue` before a request carries it:
|
|
17
19
|
* an identifier the server no longer offers is replaced rather than sent, so a
|
|
@@ -19,9 +21,13 @@ export interface UseAliaChatOptions {
|
|
|
19
21
|
* build's `PREFERRED_CHAT_MODEL_ID` is what gets checked.
|
|
20
22
|
*/
|
|
21
23
|
model?: string;
|
|
22
|
-
/** App context injected as system message so Alia knows which app the user is in */
|
|
24
|
+
/** App context injected as system message so Alia knows which app the user is in. */
|
|
23
25
|
clientContext?: string;
|
|
24
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Optional assertion for callers that already hold the surrounding
|
|
28
|
+
* OxyProvider's active token. A different token is refused; the SDK never
|
|
29
|
+
* writes this value to an Authorization header itself.
|
|
30
|
+
*/
|
|
25
31
|
accessToken?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -35,6 +41,19 @@ export interface UseAliaChatReturn {
|
|
|
35
41
|
error: string | null;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
interface ActiveRequest {
|
|
45
|
+
readonly key: symbol;
|
|
46
|
+
readonly controller: AbortController;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PendingUpdates {
|
|
50
|
+
readonly assistantId: string;
|
|
51
|
+
content: string;
|
|
52
|
+
reasoning: string;
|
|
53
|
+
tools: ToolInvocation[];
|
|
54
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
function generateId(): string {
|
|
39
58
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
40
59
|
return `msg-${crypto.randomUUID()}`;
|
|
@@ -42,12 +61,41 @@ function generateId(): string {
|
|
|
42
61
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
function isAbort(error: unknown, signal: AbortSignal): boolean {
|
|
65
|
+
return signal.aborted || (error instanceof Error && error.name === 'AbortError');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function abortError(): Error {
|
|
69
|
+
const error = new Error('The Alia request was aborted.');
|
|
70
|
+
error.name = 'AbortError';
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
75
|
+
if (signal.aborted) return Promise.reject(abortError());
|
|
76
|
+
return new Promise<T>((resolve, reject) => {
|
|
77
|
+
const onAbort = (): void => reject(abortError());
|
|
78
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
79
|
+
promise.then(
|
|
80
|
+
(value) => {
|
|
81
|
+
signal.removeEventListener('abort', onAbort);
|
|
82
|
+
resolve(value);
|
|
83
|
+
},
|
|
84
|
+
(error: unknown) => {
|
|
85
|
+
signal.removeEventListener('abort', onAbort);
|
|
86
|
+
reject(error);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
45
92
|
/**
|
|
46
93
|
* SSE streaming chat hook for Alia.
|
|
47
94
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
95
|
+
* The SDK deliberately stays on `/v1/chat/completions`: unlike the product
|
|
96
|
+
* origin, this raw-source package is embedded by apps whose browser origins are
|
|
97
|
+
* not all on Alia's narrow CORS allowlist. The endpoint is still Alia's product
|
|
98
|
+
* handler during its recorded compatibility window.
|
|
51
99
|
*/
|
|
52
100
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
53
101
|
const {
|
|
@@ -56,449 +104,318 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
56
104
|
clientContext,
|
|
57
105
|
accessToken: accessTokenProp,
|
|
58
106
|
} = options;
|
|
107
|
+
const requestUrl = '/v1/chat/completions';
|
|
59
108
|
|
|
60
109
|
const { oxyServices } = useOxy();
|
|
61
110
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
62
111
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
63
112
|
const [error, setError] = useState<string | null>(null);
|
|
64
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
65
|
-
|
|
66
|
-
// Use a ref to read current messages without putting it in send's dep array
|
|
67
113
|
const messagesRef = useRef<ChatMessage[]>([]);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const pendingContentRef = useRef('');
|
|
72
|
-
const pendingReasoningRef = useRef('');
|
|
73
|
-
const toolInvocationsRef = useRef<ToolInvocation[]>([]);
|
|
74
|
-
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
75
|
-
|
|
76
|
-
const flushPendingUpdates = useCallback(() => {
|
|
77
|
-
const content = pendingContentRef.current;
|
|
78
|
-
const reasoning = pendingReasoningRef.current;
|
|
79
|
-
const tools = toolInvocationsRef.current;
|
|
80
|
-
if (!content && !reasoning && tools.length === 0) return;
|
|
81
|
-
|
|
82
|
-
pendingContentRef.current = '';
|
|
83
|
-
pendingReasoningRef.current = '';
|
|
84
|
-
|
|
85
|
-
setMessages((prev) => {
|
|
86
|
-
const updated = [...prev];
|
|
87
|
-
const last = updated[updated.length - 1];
|
|
88
|
-
if (last?.role === 'assistant') {
|
|
89
|
-
const changes: Partial<ChatMessage> = {};
|
|
90
|
-
if (content) changes.content = last.content + content;
|
|
91
|
-
if (reasoning) changes.thinking = (last.thinking || '') + reasoning;
|
|
92
|
-
if (tools.length > 0) changes.toolInvocations = [...tools];
|
|
93
|
-
updated[updated.length - 1] = { ...last, ...changes };
|
|
94
|
-
}
|
|
95
|
-
return updated;
|
|
96
|
-
});
|
|
97
|
-
}, []);
|
|
114
|
+
const activeRequestRef = useRef<ActiveRequest | null>(null);
|
|
115
|
+
const pendingRef = useRef<PendingUpdates | null>(null);
|
|
116
|
+
const mountedRef = useRef(true);
|
|
98
117
|
|
|
99
|
-
const scheduleFlush = useCallback(() => {
|
|
100
|
-
if (flushTimerRef.current) return;
|
|
101
|
-
flushTimerRef.current = setTimeout(() => {
|
|
102
|
-
flushTimerRef.current = null;
|
|
103
|
-
flushPendingUpdates();
|
|
104
|
-
}, 50);
|
|
105
|
-
}, [flushPendingUpdates]);
|
|
106
|
-
|
|
107
|
-
// Cleanup flush timer on unmount
|
|
108
118
|
useEffect(() => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (flushTimerRef.current) {
|
|
112
|
-
clearTimeout(flushTimerRef.current);
|
|
113
|
-
flushTimerRef.current = null;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
}, [flushPendingUpdates]);
|
|
117
|
-
|
|
118
|
-
const getToken = useCallback((): string | null => {
|
|
119
|
-
if (accessTokenProp) return accessTokenProp;
|
|
120
|
-
return oxyServices.httpService.getAccessToken();
|
|
121
|
-
}, [accessTokenProp, oxyServices]);
|
|
119
|
+
messagesRef.current = messages;
|
|
120
|
+
}, [messages]);
|
|
122
121
|
|
|
123
|
-
/** Update the last assistant message with partial changes (callback receives current message) */
|
|
124
122
|
const updateAssistant = useCallback(
|
|
125
|
-
(updater: (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
123
|
+
(assistantId: string, updater: (message: ChatMessage) => Partial<ChatMessage>) => {
|
|
124
|
+
if (!mountedRef.current) return;
|
|
125
|
+
setMessages((previous) => {
|
|
126
|
+
const index = previous.findIndex((message) => message.id === assistantId);
|
|
127
|
+
if (index < 0 || previous[index]?.role !== 'assistant') return previous;
|
|
128
|
+
const updated = [...previous];
|
|
129
|
+
const current = updated[index];
|
|
130
|
+
if (!current) return previous;
|
|
131
|
+
updated[index] = { ...current, ...updater(current) };
|
|
132
|
+
messagesRef.current = updated;
|
|
132
133
|
return updated;
|
|
133
134
|
});
|
|
134
135
|
},
|
|
135
136
|
[],
|
|
136
137
|
);
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
139
|
+
const removeEmptyAssistant = useCallback((assistantId: string) => {
|
|
140
|
+
if (!mountedRef.current) return;
|
|
141
|
+
setMessages((previous) => {
|
|
142
|
+
const target = previous.find((message) => message.id === assistantId);
|
|
143
|
+
if (
|
|
144
|
+
target?.role !== 'assistant' ||
|
|
145
|
+
getTextFromContent(target.content).trim().length > 0 ||
|
|
146
|
+
(target.thinking?.trim().length ?? 0) > 0 ||
|
|
147
|
+
(target.toolInvocations?.length ?? 0) > 0
|
|
148
|
+
) {
|
|
149
|
+
return previous;
|
|
150
|
+
}
|
|
151
|
+
const updated = previous.filter((message) => message.id !== assistantId);
|
|
152
|
+
messagesRef.current = updated;
|
|
153
|
+
return updated;
|
|
154
|
+
});
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const flushPendingUpdates = useCallback(
|
|
158
|
+
(pending: PendingUpdates) => {
|
|
159
|
+
const content = pending.content;
|
|
160
|
+
const reasoning = pending.reasoning;
|
|
161
|
+
const tools = [...pending.tools];
|
|
162
|
+
if (!content && !reasoning && tools.length === 0) return;
|
|
163
|
+
|
|
164
|
+
pending.content = '';
|
|
165
|
+
pending.reasoning = '';
|
|
166
|
+
updateAssistant(pending.assistantId, (last) => ({
|
|
167
|
+
...(content ? { content: getTextFromContent(last.content) + content } : {}),
|
|
168
|
+
...(reasoning ? { thinking: (last.thinking ?? '') + reasoning } : {}),
|
|
169
|
+
...(tools.length > 0 ? { toolInvocations: tools } : {}),
|
|
170
|
+
}));
|
|
154
171
|
},
|
|
155
172
|
[updateAssistant],
|
|
156
173
|
);
|
|
157
174
|
|
|
175
|
+
const cancelPendingTimer = useCallback((pending: PendingUpdates) => {
|
|
176
|
+
if (pending.timer !== null) {
|
|
177
|
+
clearTimeout(pending.timer);
|
|
178
|
+
pending.timer = null;
|
|
179
|
+
}
|
|
180
|
+
}, []);
|
|
181
|
+
|
|
182
|
+
const scheduleFlush = useCallback(
|
|
183
|
+
(pending: PendingUpdates) => {
|
|
184
|
+
if (pending.timer !== null) return;
|
|
185
|
+
pending.timer = setTimeout(() => {
|
|
186
|
+
pending.timer = null;
|
|
187
|
+
flushPendingUpdates(pending);
|
|
188
|
+
}, 50);
|
|
189
|
+
},
|
|
190
|
+
[flushPendingUpdates],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const stop = useCallback(() => {
|
|
194
|
+
const active = activeRequestRef.current;
|
|
195
|
+
activeRequestRef.current = null;
|
|
196
|
+
active?.controller.abort();
|
|
197
|
+
|
|
198
|
+
const pending = pendingRef.current;
|
|
199
|
+
if (pending !== null) {
|
|
200
|
+
cancelPendingTimer(pending);
|
|
201
|
+
flushPendingUpdates(pending);
|
|
202
|
+
pendingRef.current = null;
|
|
203
|
+
}
|
|
204
|
+
if (mountedRef.current) setIsStreaming(false);
|
|
205
|
+
}, [cancelPendingTimer, flushPendingUpdates]);
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
mountedRef.current = true;
|
|
209
|
+
return () => {
|
|
210
|
+
mountedRef.current = false;
|
|
211
|
+
activeRequestRef.current?.controller.abort();
|
|
212
|
+
activeRequestRef.current = null;
|
|
213
|
+
const pending = pendingRef.current;
|
|
214
|
+
if (pending !== null) cancelPendingTimer(pending);
|
|
215
|
+
pendingRef.current = null;
|
|
216
|
+
};
|
|
217
|
+
}, [cancelPendingTimer]);
|
|
218
|
+
|
|
158
219
|
const send = useCallback(
|
|
159
220
|
async (text: string) => {
|
|
160
221
|
const trimmed = text.trim();
|
|
161
|
-
if (!trimmed
|
|
222
|
+
if (!trimmed) return;
|
|
162
223
|
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
setError('
|
|
224
|
+
const activeToken = oxyServices.getAccessToken();
|
|
225
|
+
if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
|
|
226
|
+
setError('The supplied token is not the active Oxy session.');
|
|
166
227
|
return;
|
|
167
228
|
}
|
|
168
229
|
|
|
169
|
-
|
|
230
|
+
// A new turn supersedes the previous one. Scope every later update to its
|
|
231
|
+
// assistant id and request key so an abort racing with the replacement
|
|
232
|
+
// cannot mutate the new placeholder or clear its streaming state.
|
|
233
|
+
const previousRequest = activeRequestRef.current;
|
|
234
|
+
activeRequestRef.current = null;
|
|
235
|
+
previousRequest?.controller.abort();
|
|
236
|
+
const previousPending = pendingRef.current;
|
|
237
|
+
if (previousPending !== null) {
|
|
238
|
+
cancelPendingTimer(previousPending);
|
|
239
|
+
flushPendingUpdates(previousPending);
|
|
240
|
+
}
|
|
170
241
|
|
|
171
|
-
|
|
172
|
-
const userMsg: ChatMessage = {
|
|
242
|
+
const userMessage: ChatMessage = {
|
|
173
243
|
id: generateId(),
|
|
174
244
|
role: 'user',
|
|
175
245
|
content: trimmed,
|
|
176
246
|
createdAt: Date.now(),
|
|
177
247
|
};
|
|
178
|
-
|
|
179
|
-
// Prepare assistant placeholder
|
|
180
|
-
const assistantMsg: ChatMessage = {
|
|
248
|
+
const assistantMessage: ChatMessage = {
|
|
181
249
|
id: generateId(),
|
|
182
250
|
role: 'assistant',
|
|
183
251
|
content: '',
|
|
184
252
|
toolInvocations: [],
|
|
185
253
|
createdAt: Date.now(),
|
|
186
254
|
};
|
|
187
|
-
|
|
188
|
-
|
|
255
|
+
const history = messagesRef.current.filter(
|
|
256
|
+
(message) =>
|
|
257
|
+
message.role !== 'assistant' ||
|
|
258
|
+
getTextFromContent(message.content).trim().length > 0 ||
|
|
259
|
+
(message.thinking?.trim().length ?? 0) > 0 ||
|
|
260
|
+
(message.toolInvocations?.length ?? 0) > 0,
|
|
261
|
+
);
|
|
262
|
+
setMessages((previous) => {
|
|
263
|
+
const updated = [...previous, userMessage, assistantMessage];
|
|
264
|
+
messagesRef.current = updated;
|
|
265
|
+
return updated;
|
|
266
|
+
});
|
|
267
|
+
setError(null);
|
|
189
268
|
setIsStreaming(true);
|
|
190
269
|
|
|
191
|
-
// Build messages payload for the API (read from ref, not closure)
|
|
192
270
|
const apiMessages: Array<{ role: string; content: string }> = [];
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (msg.toolInvocations?.length) {
|
|
204
|
-
for (const tool of msg.toolInvocations) {
|
|
205
|
-
if (tool.state === 'result' && tool.result != null) {
|
|
206
|
-
apiMessages.push({
|
|
207
|
-
role: 'system',
|
|
208
|
-
content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
|
|
209
|
-
});
|
|
210
|
-
}
|
|
271
|
+
if (clientContext) apiMessages.push({ role: 'system', content: clientContext });
|
|
272
|
+
for (const message of history) {
|
|
273
|
+
if (message.role === 'system') continue;
|
|
274
|
+
apiMessages.push({ role: message.role, content: getTextFromContent(message.content) });
|
|
275
|
+
for (const tool of message.toolInvocations ?? []) {
|
|
276
|
+
if (tool.state === 'result' && tool.result !== undefined && tool.result !== null) {
|
|
277
|
+
apiMessages.push({
|
|
278
|
+
role: 'system',
|
|
279
|
+
content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
|
|
280
|
+
});
|
|
211
281
|
}
|
|
212
282
|
}
|
|
213
283
|
}
|
|
214
|
-
|
|
215
284
|
apiMessages.push({ role: 'user', content: trimmed });
|
|
216
285
|
|
|
286
|
+
const pending: PendingUpdates = {
|
|
287
|
+
assistantId: assistantMessage.id,
|
|
288
|
+
content: '',
|
|
289
|
+
reasoning: '',
|
|
290
|
+
tools: [],
|
|
291
|
+
timer: null,
|
|
292
|
+
};
|
|
293
|
+
pendingRef.current = pending;
|
|
217
294
|
const controller = new AbortController();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
* with the SDK's consumers enumerated.
|
|
259
|
-
*/
|
|
260
|
-
const response = await fetch(`${apiUrl}/v1/chat/completions`, {
|
|
261
|
-
method: 'POST',
|
|
262
|
-
headers: {
|
|
263
|
-
'Content-Type': 'application/json',
|
|
264
|
-
Authorization: `Bearer ${token}`,
|
|
265
|
-
},
|
|
266
|
-
body: JSON.stringify({
|
|
267
|
-
// Resolved HERE rather than at render: this is the moment a request
|
|
268
|
-
// is about to name a model, and it is the only moment at which
|
|
269
|
-
// sending a retired identifier would actually cost anything. A
|
|
270
|
-
// catalogue that cannot be read leaves the choice alone.
|
|
271
|
-
model: await resolveModelId(apiUrl, model, token, PREFERRED_CHAT_MODEL_ID),
|
|
272
|
-
messages: apiMessages,
|
|
273
|
-
stream: true,
|
|
274
|
-
}),
|
|
275
|
-
signal: controller.signal,
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
if (!response.ok) {
|
|
279
|
-
const body = await response.text().catch(() => '');
|
|
280
|
-
throw new Error(`API error ${response.status}: ${body.slice(0, 200)}`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Non-streaming fallback
|
|
284
|
-
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
285
|
-
const json = await response.json();
|
|
286
|
-
const content = json.choices?.[0]?.message?.content ?? '';
|
|
287
|
-
updateAssistant(() => ({ content }));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Stream SSE (supports named events: event: X\ndata: Y)
|
|
292
|
-
const reader = response.body.getReader();
|
|
293
|
-
const decoder = new TextDecoder();
|
|
294
|
-
let buffer = '';
|
|
295
|
-
let currentEventType = '';
|
|
296
|
-
|
|
297
|
-
try {
|
|
298
|
-
while (true) {
|
|
299
|
-
const { done, value } = await reader.read();
|
|
300
|
-
if (done) break;
|
|
301
|
-
|
|
302
|
-
buffer += decoder.decode(value, { stream: true });
|
|
303
|
-
const lines = buffer.split('\n');
|
|
304
|
-
buffer = lines.pop() ?? '';
|
|
305
|
-
|
|
306
|
-
for (const line of lines) {
|
|
307
|
-
// Track named SSE event type
|
|
308
|
-
if (line.startsWith('event: ')) {
|
|
309
|
-
currentEventType = line.slice(7).trim();
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Reset event type on empty line (SSE event boundary)
|
|
314
|
-
if (line === '') {
|
|
315
|
-
currentEventType = '';
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const trimmedLine = line.trim();
|
|
320
|
-
if (!trimmedLine.startsWith('data: ')) continue;
|
|
321
|
-
const data = trimmedLine.slice(6);
|
|
322
|
-
if (data === '[DONE]') { currentEventType = ''; continue; }
|
|
323
|
-
|
|
324
|
-
try {
|
|
325
|
-
const parsed = JSON.parse(data);
|
|
326
|
-
|
|
327
|
-
// ── Named SSE events (Alia extensions) ──
|
|
328
|
-
if (currentEventType) {
|
|
329
|
-
switch (currentEventType) {
|
|
330
|
-
case 'alia.reasoning': {
|
|
331
|
-
const content = parsed.content;
|
|
332
|
-
if (content) {
|
|
333
|
-
pendingReasoningRef.current += content;
|
|
334
|
-
scheduleFlush();
|
|
335
|
-
}
|
|
336
|
-
currentEventType = '';
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
case 'alia.tool_result': {
|
|
340
|
-
const { tool_call_id, name, output } = parsed;
|
|
341
|
-
if (tool_call_id) {
|
|
342
|
-
applyToolResult(tool_call_id, name, output);
|
|
343
|
-
}
|
|
344
|
-
currentEventType = '';
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
case 'alia.research_progress': {
|
|
348
|
-
updateAssistant((last) => ({
|
|
349
|
-
researchProgress: {
|
|
350
|
-
phase: parsed.phase,
|
|
351
|
-
message: parsed.message,
|
|
352
|
-
subQuestions: parsed.subQuestions || last.researchProgress?.subQuestions,
|
|
353
|
-
sourcesFound: parsed.sourcesFound,
|
|
354
|
-
currentQuery: parsed.currentQuery,
|
|
355
|
-
iteration: parsed.iteration,
|
|
356
|
-
},
|
|
357
|
-
}));
|
|
358
|
-
currentEventType = '';
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
case 'alia.plan_preview': {
|
|
362
|
-
updateAssistant(() => ({
|
|
363
|
-
pendingPlan: {
|
|
364
|
-
planId: parsed.planId,
|
|
365
|
-
steps: parsed.steps || [],
|
|
366
|
-
approved: false,
|
|
367
|
-
rejected: false,
|
|
368
|
-
},
|
|
369
|
-
}));
|
|
370
|
-
currentEventType = '';
|
|
371
|
-
continue;
|
|
372
|
-
}
|
|
373
|
-
default:
|
|
374
|
-
// Unknown named event — skip
|
|
375
|
-
currentEventType = '';
|
|
376
|
-
continue;
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ── Standard OpenAI data events ──
|
|
381
|
-
|
|
382
|
-
const choice = parsed.choices?.[0];
|
|
383
|
-
if (!choice) {
|
|
384
|
-
// Legacy alia.tool_call event (type-based, not named SSE)
|
|
385
|
-
if (parsed.type === 'alia.tool_call') {
|
|
386
|
-
toolInvocationsRef.current = [
|
|
387
|
-
...toolInvocationsRef.current,
|
|
388
|
-
{
|
|
389
|
-
toolName: parsed.tool || 'unknown',
|
|
390
|
-
state: 'call',
|
|
391
|
-
args: parsed.args,
|
|
392
|
-
},
|
|
393
|
-
];
|
|
394
|
-
flushPendingUpdates();
|
|
395
|
-
}
|
|
396
|
-
// Legacy format matches by toolName (no toolCallId), so can't use applyToolResult
|
|
397
|
-
if (parsed.type === 'alia.tool_result') {
|
|
398
|
-
toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
|
|
399
|
-
t.toolName === parsed.tool && t.state === 'call'
|
|
400
|
-
? { ...t, state: 'result' as const, result: parsed.result }
|
|
401
|
-
: t,
|
|
402
|
-
);
|
|
403
|
-
flushPendingUpdates();
|
|
404
|
-
}
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
const delta = choice.delta;
|
|
409
|
-
if (!delta) continue;
|
|
410
|
-
|
|
411
|
-
// Reasoning/thinking content (batched)
|
|
412
|
-
if (delta.reasoning) {
|
|
413
|
-
pendingReasoningRef.current += delta.reasoning;
|
|
414
|
-
scheduleFlush();
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Text content (batched)
|
|
418
|
-
if (delta.content) {
|
|
419
|
-
pendingContentRef.current += delta.content;
|
|
420
|
-
scheduleFlush();
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// Tool calls (OpenAI format: delta.tool_calls)
|
|
424
|
-
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
425
|
-
for (const tc of delta.tool_calls) {
|
|
426
|
-
const toolCallId = tc.id;
|
|
427
|
-
const toolName = tc.function?.name;
|
|
428
|
-
if (!toolCallId || !toolName) continue;
|
|
429
|
-
|
|
430
|
-
let args: Record<string, unknown> | undefined;
|
|
431
|
-
if (tc.function?.arguments) {
|
|
432
|
-
try { args = JSON.parse(tc.function.arguments) as Record<string, unknown>; } catch { args = { _raw: tc.function.arguments }; }
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const invocation: ToolInvocation = { toolCallId, toolName, state: 'call', args };
|
|
436
|
-
const idx = toolInvocationsRef.current.findIndex((t) => t.toolCallId === toolCallId);
|
|
437
|
-
if (idx >= 0) {
|
|
438
|
-
toolInvocationsRef.current[idx] = invocation;
|
|
439
|
-
} else {
|
|
440
|
-
toolInvocationsRef.current = [...toolInvocationsRef.current, invocation];
|
|
441
|
-
}
|
|
442
|
-
flushPendingUpdates();
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Tool results (delta.tool_result)
|
|
447
|
-
if (delta.tool_result) {
|
|
448
|
-
const { tool_call_id, name, output } = delta.tool_result;
|
|
449
|
-
if (tool_call_id) {
|
|
450
|
-
applyToolResult(tool_call_id, name, output);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
} catch {
|
|
454
|
-
// Skip malformed chunks
|
|
455
|
-
}
|
|
456
|
-
}
|
|
295
|
+
const request: ActiveRequest = { key: Symbol('alia-chat-request'), controller };
|
|
296
|
+
activeRequestRef.current = request;
|
|
297
|
+
const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
298
|
+
|
|
299
|
+
const applyEvent = (event: AliaChatStreamEvent): void => {
|
|
300
|
+
switch (event.kind) {
|
|
301
|
+
case 'content':
|
|
302
|
+
pending.content += event.content;
|
|
303
|
+
scheduleFlush(pending);
|
|
304
|
+
return;
|
|
305
|
+
case 'reasoning':
|
|
306
|
+
pending.reasoning += event.content;
|
|
307
|
+
scheduleFlush(pending);
|
|
308
|
+
return;
|
|
309
|
+
case 'tool_call': {
|
|
310
|
+
const invocation: ToolInvocation = {
|
|
311
|
+
toolCallId: event.toolCallId,
|
|
312
|
+
toolName: event.toolName,
|
|
313
|
+
state: 'call',
|
|
314
|
+
args: event.args,
|
|
315
|
+
};
|
|
316
|
+
const index = pending.tools.findIndex((tool) => tool.toolCallId === event.toolCallId);
|
|
317
|
+
if (index < 0) pending.tools = [...pending.tools, invocation];
|
|
318
|
+
else pending.tools[index] = invocation;
|
|
319
|
+
flushPendingUpdates(pending);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
case 'tool_result': {
|
|
323
|
+
const index = pending.tools.findIndex((tool) => tool.toolCallId === event.toolCallId);
|
|
324
|
+
const result: ToolInvocation = {
|
|
325
|
+
toolCallId: event.toolCallId,
|
|
326
|
+
toolName: event.toolName,
|
|
327
|
+
state: 'result',
|
|
328
|
+
result: event.output,
|
|
329
|
+
...(index < 0 ? {} : { args: pending.tools[index]?.args }),
|
|
330
|
+
};
|
|
331
|
+
if (index < 0) pending.tools = [...pending.tools, result];
|
|
332
|
+
else pending.tools[index] = result;
|
|
333
|
+
flushPendingUpdates(pending);
|
|
334
|
+
return;
|
|
457
335
|
}
|
|
458
|
-
|
|
459
|
-
|
|
336
|
+
case 'research_progress':
|
|
337
|
+
updateAssistant(pending.assistantId, (last) => ({
|
|
338
|
+
researchProgress: {
|
|
339
|
+
...event.progress,
|
|
340
|
+
subQuestions:
|
|
341
|
+
event.progress.subQuestions ?? last.researchProgress?.subQuestions,
|
|
342
|
+
},
|
|
343
|
+
}));
|
|
344
|
+
return;
|
|
345
|
+
case 'plan_preview':
|
|
346
|
+
updateAssistant(pending.assistantId, () => ({
|
|
347
|
+
pendingPlan: {
|
|
348
|
+
planId: event.planId,
|
|
349
|
+
steps: event.steps,
|
|
350
|
+
approved: false,
|
|
351
|
+
rejected: false,
|
|
352
|
+
},
|
|
353
|
+
}));
|
|
354
|
+
return;
|
|
355
|
+
case 'agent_answer':
|
|
356
|
+
pending.content += event.content;
|
|
357
|
+
updateAssistant(pending.assistantId, () => ({ agentInfo: event.agent }));
|
|
358
|
+
scheduleFlush(pending);
|
|
359
|
+
return;
|
|
460
360
|
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const effectiveModel = await waitWithAbort(
|
|
365
|
+
resolveModelId(apiUrl, model, undefined, PREFERRED_CHAT_MODEL_ID),
|
|
366
|
+
controller.signal,
|
|
467
367
|
);
|
|
368
|
+
await streamAliaChat(
|
|
369
|
+
linked.client,
|
|
370
|
+
{ url: requestUrl, model: effectiveModel, messages: apiMessages },
|
|
371
|
+
controller.signal,
|
|
372
|
+
applyEvent,
|
|
373
|
+
);
|
|
374
|
+
} catch (caught: unknown) {
|
|
375
|
+
cancelPendingTimer(pending);
|
|
376
|
+
flushPendingUpdates(pending);
|
|
377
|
+
if (isAbort(caught, controller.signal)) {
|
|
378
|
+
removeEmptyAssistant(pending.assistantId);
|
|
379
|
+
} else if (mountedRef.current) {
|
|
380
|
+
const message = caught instanceof Error ? caught.message : 'Something went wrong.';
|
|
381
|
+
setError(message);
|
|
382
|
+
updateAssistant(pending.assistantId, (last) =>
|
|
383
|
+
getTextFromContent(last.content).trim().length === 0
|
|
384
|
+
? { content: "I'm having trouble connecting right now. Please try again." }
|
|
385
|
+
: {},
|
|
386
|
+
);
|
|
387
|
+
}
|
|
468
388
|
} finally {
|
|
469
|
-
|
|
470
|
-
flushPendingUpdates();
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
389
|
+
cancelPendingTimer(pending);
|
|
390
|
+
flushPendingUpdates(pending);
|
|
391
|
+
linked.dispose();
|
|
392
|
+
if (pendingRef.current === pending) pendingRef.current = null;
|
|
393
|
+
if (activeRequestRef.current?.key === request.key) {
|
|
394
|
+
activeRequestRef.current = null;
|
|
395
|
+
if (mountedRef.current) setIsStreaming(false);
|
|
474
396
|
}
|
|
475
|
-
setIsStreaming(false);
|
|
476
|
-
abortRef.current = null;
|
|
477
397
|
}
|
|
478
398
|
},
|
|
479
|
-
[
|
|
399
|
+
[
|
|
400
|
+
accessTokenProp,
|
|
401
|
+
apiUrl,
|
|
402
|
+
cancelPendingTimer,
|
|
403
|
+
clientContext,
|
|
404
|
+
flushPendingUpdates,
|
|
405
|
+
model,
|
|
406
|
+
oxyServices,
|
|
407
|
+
requestUrl,
|
|
408
|
+
removeEmptyAssistant,
|
|
409
|
+
scheduleFlush,
|
|
410
|
+
updateAssistant,
|
|
411
|
+
],
|
|
480
412
|
);
|
|
481
413
|
|
|
482
|
-
const stop = useCallback(() => {
|
|
483
|
-
if (abortRef.current) {
|
|
484
|
-
abortRef.current.abort();
|
|
485
|
-
abortRef.current = null;
|
|
486
|
-
}
|
|
487
|
-
flushPendingUpdates();
|
|
488
|
-
if (flushTimerRef.current) {
|
|
489
|
-
clearTimeout(flushTimerRef.current);
|
|
490
|
-
flushTimerRef.current = null;
|
|
491
|
-
}
|
|
492
|
-
setIsStreaming(false);
|
|
493
|
-
}, [flushPendingUpdates]);
|
|
494
|
-
|
|
495
414
|
const clear = useCallback(() => {
|
|
496
415
|
stop();
|
|
416
|
+
messagesRef.current = [];
|
|
497
417
|
setMessages([]);
|
|
498
418
|
setError(null);
|
|
499
|
-
pendingContentRef.current = '';
|
|
500
|
-
pendingReasoningRef.current = '';
|
|
501
|
-
toolInvocationsRef.current = [];
|
|
502
419
|
}, [stop]);
|
|
503
420
|
|
|
504
421
|
return { messages, setMessages, send, isStreaming, stop, clear, error };
|