@alia.onl/sdk 7.0.0 → 7.1.1
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 +1 -1
- package/src/components/AliaChatSheet.tsx +15 -3
- package/src/components/AliaWelcomeMessage.tsx +1 -1
- package/src/components/IdentityMark.tsx +38 -12
- package/src/hooks/useAliaChat.ts +306 -396
- 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/chat-stream.ts +560 -0
- package/src/lib/chat-transport.ts +55 -0
- package/tsconfig.json +1 -1
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
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
|
|
|
10
12
|
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
|
-
/**
|
|
14
|
-
* Alia model to use.
|
|
15
|
-
*
|
|
16
|
-
* Optional, and checked against `GET /catalogue` before a request carries it:
|
|
17
|
-
* an identifier the server no longer offers is replaced rather than sent, so a
|
|
18
|
-
* retirement does not turn into a 400 inside a consumer's app. Omitted, the
|
|
19
|
-
* build's `PREFERRED_CHAT_MODEL_ID` is what gets checked.
|
|
20
|
-
*/
|
|
15
|
+
/** Alia model or routing profile to use. */
|
|
21
16
|
model?: string;
|
|
22
|
-
/** App context injected as system message so Alia knows which app the user is in */
|
|
17
|
+
/** App context injected as system message so Alia knows which app the user is in. */
|
|
23
18
|
clientContext?: string;
|
|
24
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Optional assertion for callers that already hold the surrounding
|
|
21
|
+
* OxyProvider's active token. A different token is refused; the SDK never
|
|
22
|
+
* writes this value to an Authorization header itself.
|
|
23
|
+
*/
|
|
25
24
|
accessToken?: string;
|
|
26
25
|
}
|
|
27
26
|
|
|
@@ -35,6 +34,19 @@ export interface UseAliaChatReturn {
|
|
|
35
34
|
error: string | null;
|
|
36
35
|
}
|
|
37
36
|
|
|
37
|
+
interface ActiveRequest {
|
|
38
|
+
readonly key: symbol;
|
|
39
|
+
readonly controller: AbortController;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PendingUpdates {
|
|
43
|
+
readonly assistantId: string;
|
|
44
|
+
content: string;
|
|
45
|
+
reasoning: string;
|
|
46
|
+
tools: ToolInvocation[];
|
|
47
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
function generateId(): string {
|
|
39
51
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
40
52
|
return `msg-${crypto.randomUUID()}`;
|
|
@@ -42,12 +54,41 @@ function generateId(): string {
|
|
|
42
54
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
43
55
|
}
|
|
44
56
|
|
|
57
|
+
function isAbort(error: unknown, signal: AbortSignal): boolean {
|
|
58
|
+
return signal.aborted || (error instanceof Error && error.name === 'AbortError');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function abortError(): Error {
|
|
62
|
+
const error = new Error('The Alia request was aborted.');
|
|
63
|
+
error.name = 'AbortError';
|
|
64
|
+
return error;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
68
|
+
if (signal.aborted) return Promise.reject(abortError());
|
|
69
|
+
return new Promise<T>((resolve, reject) => {
|
|
70
|
+
const onAbort = (): void => reject(abortError());
|
|
71
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
72
|
+
promise.then(
|
|
73
|
+
(value) => {
|
|
74
|
+
signal.removeEventListener('abort', onAbort);
|
|
75
|
+
resolve(value);
|
|
76
|
+
},
|
|
77
|
+
(error: unknown) => {
|
|
78
|
+
signal.removeEventListener('abort', onAbort);
|
|
79
|
+
reject(error);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* SSE streaming chat hook for Alia.
|
|
47
87
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
88
|
+
* The SDK deliberately stays on `/v1/chat/completions`: unlike the product
|
|
89
|
+
* origin, this raw-source package is embedded by apps whose browser origins are
|
|
90
|
+
* not all on Alia's narrow CORS allowlist. The endpoint is still Alia's product
|
|
91
|
+
* handler during its recorded compatibility window.
|
|
51
92
|
*/
|
|
52
93
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
53
94
|
const {
|
|
@@ -56,449 +97,318 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
56
97
|
clientContext,
|
|
57
98
|
accessToken: accessTokenProp,
|
|
58
99
|
} = options;
|
|
100
|
+
const requestUrl = '/v1/chat/completions';
|
|
59
101
|
|
|
60
102
|
const { oxyServices } = useOxy();
|
|
61
103
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
62
104
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
63
105
|
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
106
|
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
|
-
}, []);
|
|
98
|
-
|
|
99
|
-
const scheduleFlush = useCallback(() => {
|
|
100
|
-
if (flushTimerRef.current) return;
|
|
101
|
-
flushTimerRef.current = setTimeout(() => {
|
|
102
|
-
flushTimerRef.current = null;
|
|
103
|
-
flushPendingUpdates();
|
|
104
|
-
}, 50);
|
|
105
|
-
}, [flushPendingUpdates]);
|
|
107
|
+
const activeRequestRef = useRef<ActiveRequest | null>(null);
|
|
108
|
+
const pendingRef = useRef<PendingUpdates | null>(null);
|
|
109
|
+
const mountedRef = useRef(true);
|
|
106
110
|
|
|
107
|
-
// Cleanup flush timer on unmount
|
|
108
111
|
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]);
|
|
112
|
+
messagesRef.current = messages;
|
|
113
|
+
}, [messages]);
|
|
122
114
|
|
|
123
|
-
/** Update the last assistant message with partial changes (callback receives current message) */
|
|
124
115
|
const updateAssistant = useCallback(
|
|
125
|
-
(updater: (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
(assistantId: string, updater: (message: ChatMessage) => Partial<ChatMessage>) => {
|
|
117
|
+
if (!mountedRef.current) return;
|
|
118
|
+
setMessages((previous) => {
|
|
119
|
+
const index = previous.findIndex((message) => message.id === assistantId);
|
|
120
|
+
if (index < 0 || previous[index]?.role !== 'assistant') return previous;
|
|
121
|
+
const updated = [...previous];
|
|
122
|
+
const current = updated[index];
|
|
123
|
+
if (!current) return previous;
|
|
124
|
+
updated[index] = { ...current, ...updater(current) };
|
|
125
|
+
messagesRef.current = updated;
|
|
132
126
|
return updated;
|
|
133
127
|
});
|
|
134
128
|
},
|
|
135
129
|
[],
|
|
136
130
|
);
|
|
137
131
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
132
|
+
const removeEmptyAssistant = useCallback((assistantId: string) => {
|
|
133
|
+
if (!mountedRef.current) return;
|
|
134
|
+
setMessages((previous) => {
|
|
135
|
+
const target = previous.find((message) => message.id === assistantId);
|
|
136
|
+
if (
|
|
137
|
+
target?.role !== 'assistant' ||
|
|
138
|
+
getTextFromContent(target.content).trim().length > 0 ||
|
|
139
|
+
(target.thinking?.trim().length ?? 0) > 0 ||
|
|
140
|
+
(target.toolInvocations?.length ?? 0) > 0
|
|
141
|
+
) {
|
|
142
|
+
return previous;
|
|
143
|
+
}
|
|
144
|
+
const updated = previous.filter((message) => message.id !== assistantId);
|
|
145
|
+
messagesRef.current = updated;
|
|
146
|
+
return updated;
|
|
147
|
+
});
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
const flushPendingUpdates = useCallback(
|
|
151
|
+
(pending: PendingUpdates) => {
|
|
152
|
+
const content = pending.content;
|
|
153
|
+
const reasoning = pending.reasoning;
|
|
154
|
+
const tools = [...pending.tools];
|
|
155
|
+
if (!content && !reasoning && tools.length === 0) return;
|
|
156
|
+
|
|
157
|
+
pending.content = '';
|
|
158
|
+
pending.reasoning = '';
|
|
159
|
+
updateAssistant(pending.assistantId, (last) => ({
|
|
160
|
+
...(content ? { content: getTextFromContent(last.content) + content } : {}),
|
|
161
|
+
...(reasoning ? { thinking: (last.thinking ?? '') + reasoning } : {}),
|
|
162
|
+
...(tools.length > 0 ? { toolInvocations: tools } : {}),
|
|
163
|
+
}));
|
|
154
164
|
},
|
|
155
165
|
[updateAssistant],
|
|
156
166
|
);
|
|
157
167
|
|
|
168
|
+
const cancelPendingTimer = useCallback((pending: PendingUpdates) => {
|
|
169
|
+
if (pending.timer !== null) {
|
|
170
|
+
clearTimeout(pending.timer);
|
|
171
|
+
pending.timer = null;
|
|
172
|
+
}
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const scheduleFlush = useCallback(
|
|
176
|
+
(pending: PendingUpdates) => {
|
|
177
|
+
if (pending.timer !== null) return;
|
|
178
|
+
pending.timer = setTimeout(() => {
|
|
179
|
+
pending.timer = null;
|
|
180
|
+
flushPendingUpdates(pending);
|
|
181
|
+
}, 50);
|
|
182
|
+
},
|
|
183
|
+
[flushPendingUpdates],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const stop = useCallback(() => {
|
|
187
|
+
const active = activeRequestRef.current;
|
|
188
|
+
activeRequestRef.current = null;
|
|
189
|
+
active?.controller.abort();
|
|
190
|
+
|
|
191
|
+
const pending = pendingRef.current;
|
|
192
|
+
if (pending !== null) {
|
|
193
|
+
cancelPendingTimer(pending);
|
|
194
|
+
flushPendingUpdates(pending);
|
|
195
|
+
pendingRef.current = null;
|
|
196
|
+
}
|
|
197
|
+
if (mountedRef.current) setIsStreaming(false);
|
|
198
|
+
}, [cancelPendingTimer, flushPendingUpdates]);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
mountedRef.current = true;
|
|
202
|
+
return () => {
|
|
203
|
+
mountedRef.current = false;
|
|
204
|
+
activeRequestRef.current?.controller.abort();
|
|
205
|
+
activeRequestRef.current = null;
|
|
206
|
+
const pending = pendingRef.current;
|
|
207
|
+
if (pending !== null) cancelPendingTimer(pending);
|
|
208
|
+
pendingRef.current = null;
|
|
209
|
+
};
|
|
210
|
+
}, [cancelPendingTimer]);
|
|
211
|
+
|
|
158
212
|
const send = useCallback(
|
|
159
213
|
async (text: string) => {
|
|
160
214
|
const trimmed = text.trim();
|
|
161
|
-
if (!trimmed
|
|
215
|
+
if (!trimmed) return;
|
|
162
216
|
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
setError('
|
|
217
|
+
const activeToken = oxyServices.getAccessToken();
|
|
218
|
+
if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
|
|
219
|
+
setError('The supplied token is not the active Oxy session.');
|
|
166
220
|
return;
|
|
167
221
|
}
|
|
168
222
|
|
|
169
|
-
|
|
223
|
+
// A new turn supersedes the previous one. Scope every later update to its
|
|
224
|
+
// assistant id and request key so an abort racing with the replacement
|
|
225
|
+
// cannot mutate the new placeholder or clear its streaming state.
|
|
226
|
+
const previousRequest = activeRequestRef.current;
|
|
227
|
+
activeRequestRef.current = null;
|
|
228
|
+
previousRequest?.controller.abort();
|
|
229
|
+
const previousPending = pendingRef.current;
|
|
230
|
+
if (previousPending !== null) {
|
|
231
|
+
cancelPendingTimer(previousPending);
|
|
232
|
+
flushPendingUpdates(previousPending);
|
|
233
|
+
}
|
|
170
234
|
|
|
171
|
-
|
|
172
|
-
const userMsg: ChatMessage = {
|
|
235
|
+
const userMessage: ChatMessage = {
|
|
173
236
|
id: generateId(),
|
|
174
237
|
role: 'user',
|
|
175
238
|
content: trimmed,
|
|
176
239
|
createdAt: Date.now(),
|
|
177
240
|
};
|
|
178
|
-
|
|
179
|
-
// Prepare assistant placeholder
|
|
180
|
-
const assistantMsg: ChatMessage = {
|
|
241
|
+
const assistantMessage: ChatMessage = {
|
|
181
242
|
id: generateId(),
|
|
182
243
|
role: 'assistant',
|
|
183
244
|
content: '',
|
|
184
245
|
toolInvocations: [],
|
|
185
246
|
createdAt: Date.now(),
|
|
186
247
|
};
|
|
187
|
-
|
|
188
|
-
|
|
248
|
+
const history = messagesRef.current.filter(
|
|
249
|
+
(message) =>
|
|
250
|
+
message.role !== 'assistant' ||
|
|
251
|
+
getTextFromContent(message.content).trim().length > 0 ||
|
|
252
|
+
(message.thinking?.trim().length ?? 0) > 0 ||
|
|
253
|
+
(message.toolInvocations?.length ?? 0) > 0,
|
|
254
|
+
);
|
|
255
|
+
setMessages((previous) => {
|
|
256
|
+
const updated = [...previous, userMessage, assistantMessage];
|
|
257
|
+
messagesRef.current = updated;
|
|
258
|
+
return updated;
|
|
259
|
+
});
|
|
260
|
+
setError(null);
|
|
189
261
|
setIsStreaming(true);
|
|
190
262
|
|
|
191
|
-
// Build messages payload for the API (read from ref, not closure)
|
|
192
263
|
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
|
-
}
|
|
264
|
+
if (clientContext) apiMessages.push({ role: 'system', content: clientContext });
|
|
265
|
+
for (const message of history) {
|
|
266
|
+
if (message.role === 'system') continue;
|
|
267
|
+
apiMessages.push({ role: message.role, content: getTextFromContent(message.content) });
|
|
268
|
+
for (const tool of message.toolInvocations ?? []) {
|
|
269
|
+
if (tool.state === 'result' && tool.result !== undefined && tool.result !== null) {
|
|
270
|
+
apiMessages.push({
|
|
271
|
+
role: 'system',
|
|
272
|
+
content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
|
|
273
|
+
});
|
|
211
274
|
}
|
|
212
275
|
}
|
|
213
276
|
}
|
|
214
|
-
|
|
215
277
|
apiMessages.push({ role: 'user', content: trimmed });
|
|
216
278
|
|
|
279
|
+
const pending: PendingUpdates = {
|
|
280
|
+
assistantId: assistantMessage.id,
|
|
281
|
+
content: '',
|
|
282
|
+
reasoning: '',
|
|
283
|
+
tools: [],
|
|
284
|
+
timer: null,
|
|
285
|
+
};
|
|
286
|
+
pendingRef.current = pending;
|
|
217
287
|
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
|
-
}
|
|
288
|
+
const request: ActiveRequest = { key: Symbol('alia-chat-request'), controller };
|
|
289
|
+
activeRequestRef.current = request;
|
|
290
|
+
const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
291
|
+
|
|
292
|
+
const applyEvent = (event: AliaChatStreamEvent): void => {
|
|
293
|
+
switch (event.kind) {
|
|
294
|
+
case 'content':
|
|
295
|
+
pending.content += event.content;
|
|
296
|
+
scheduleFlush(pending);
|
|
297
|
+
return;
|
|
298
|
+
case 'reasoning':
|
|
299
|
+
pending.reasoning += event.content;
|
|
300
|
+
scheduleFlush(pending);
|
|
301
|
+
return;
|
|
302
|
+
case 'tool_call': {
|
|
303
|
+
const invocation: ToolInvocation = {
|
|
304
|
+
toolCallId: event.toolCallId,
|
|
305
|
+
toolName: event.toolName,
|
|
306
|
+
state: 'call',
|
|
307
|
+
args: event.args,
|
|
308
|
+
};
|
|
309
|
+
const index = pending.tools.findIndex((tool) => tool.toolCallId === event.toolCallId);
|
|
310
|
+
if (index < 0) pending.tools = [...pending.tools, invocation];
|
|
311
|
+
else pending.tools[index] = invocation;
|
|
312
|
+
flushPendingUpdates(pending);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case 'tool_result': {
|
|
316
|
+
const index = pending.tools.findIndex((tool) => tool.toolCallId === event.toolCallId);
|
|
317
|
+
const result: ToolInvocation = {
|
|
318
|
+
toolCallId: event.toolCallId,
|
|
319
|
+
toolName: event.toolName,
|
|
320
|
+
state: 'result',
|
|
321
|
+
result: event.output,
|
|
322
|
+
...(index < 0 ? {} : { args: pending.tools[index]?.args }),
|
|
323
|
+
};
|
|
324
|
+
if (index < 0) pending.tools = [...pending.tools, result];
|
|
325
|
+
else pending.tools[index] = result;
|
|
326
|
+
flushPendingUpdates(pending);
|
|
327
|
+
return;
|
|
457
328
|
}
|
|
458
|
-
|
|
459
|
-
|
|
329
|
+
case 'research_progress':
|
|
330
|
+
updateAssistant(pending.assistantId, (last) => ({
|
|
331
|
+
researchProgress: {
|
|
332
|
+
...event.progress,
|
|
333
|
+
subQuestions:
|
|
334
|
+
event.progress.subQuestions ?? last.researchProgress?.subQuestions,
|
|
335
|
+
},
|
|
336
|
+
}));
|
|
337
|
+
return;
|
|
338
|
+
case 'plan_preview':
|
|
339
|
+
updateAssistant(pending.assistantId, () => ({
|
|
340
|
+
pendingPlan: {
|
|
341
|
+
planId: event.planId,
|
|
342
|
+
steps: event.steps,
|
|
343
|
+
approved: false,
|
|
344
|
+
rejected: false,
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
return;
|
|
348
|
+
case 'agent_answer':
|
|
349
|
+
pending.content += event.content;
|
|
350
|
+
updateAssistant(pending.assistantId, () => ({ agentInfo: event.agent }));
|
|
351
|
+
scheduleFlush(pending);
|
|
352
|
+
return;
|
|
460
353
|
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const effectiveModel = await waitWithAbort(
|
|
358
|
+
resolveModelId(apiUrl, model, undefined, PREFERRED_CHAT_MODEL_ID),
|
|
359
|
+
controller.signal,
|
|
467
360
|
);
|
|
361
|
+
await streamAliaChat(
|
|
362
|
+
linked.client,
|
|
363
|
+
{ url: requestUrl, model: effectiveModel, messages: apiMessages },
|
|
364
|
+
controller.signal,
|
|
365
|
+
applyEvent,
|
|
366
|
+
);
|
|
367
|
+
} catch (caught: unknown) {
|
|
368
|
+
cancelPendingTimer(pending);
|
|
369
|
+
flushPendingUpdates(pending);
|
|
370
|
+
if (isAbort(caught, controller.signal)) {
|
|
371
|
+
removeEmptyAssistant(pending.assistantId);
|
|
372
|
+
} else if (mountedRef.current) {
|
|
373
|
+
const message = caught instanceof Error ? caught.message : 'Something went wrong.';
|
|
374
|
+
setError(message);
|
|
375
|
+
updateAssistant(pending.assistantId, (last) =>
|
|
376
|
+
getTextFromContent(last.content).trim().length === 0
|
|
377
|
+
? { content: "I'm having trouble connecting right now. Please try again." }
|
|
378
|
+
: {},
|
|
379
|
+
);
|
|
380
|
+
}
|
|
468
381
|
} finally {
|
|
469
|
-
|
|
470
|
-
flushPendingUpdates();
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
382
|
+
cancelPendingTimer(pending);
|
|
383
|
+
flushPendingUpdates(pending);
|
|
384
|
+
linked.dispose();
|
|
385
|
+
if (pendingRef.current === pending) pendingRef.current = null;
|
|
386
|
+
if (activeRequestRef.current?.key === request.key) {
|
|
387
|
+
activeRequestRef.current = null;
|
|
388
|
+
if (mountedRef.current) setIsStreaming(false);
|
|
474
389
|
}
|
|
475
|
-
setIsStreaming(false);
|
|
476
|
-
abortRef.current = null;
|
|
477
390
|
}
|
|
478
391
|
},
|
|
479
|
-
[
|
|
392
|
+
[
|
|
393
|
+
accessTokenProp,
|
|
394
|
+
apiUrl,
|
|
395
|
+
cancelPendingTimer,
|
|
396
|
+
clientContext,
|
|
397
|
+
flushPendingUpdates,
|
|
398
|
+
model,
|
|
399
|
+
oxyServices,
|
|
400
|
+
requestUrl,
|
|
401
|
+
removeEmptyAssistant,
|
|
402
|
+
scheduleFlush,
|
|
403
|
+
updateAssistant,
|
|
404
|
+
],
|
|
480
405
|
);
|
|
481
406
|
|
|
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
407
|
const clear = useCallback(() => {
|
|
496
408
|
stop();
|
|
409
|
+
messagesRef.current = [];
|
|
497
410
|
setMessages([]);
|
|
498
411
|
setError(null);
|
|
499
|
-
pendingContentRef.current = '';
|
|
500
|
-
pendingReasoningRef.current = '';
|
|
501
|
-
toolInvocationsRef.current = [];
|
|
502
412
|
}, [stop]);
|
|
503
413
|
|
|
504
414
|
return { messages, setMessages, send, isStreaming, stop, clear, error };
|