@alia.onl/sdk 5.1.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 +93 -0
- package/LICENSE +202 -0
- package/NOTICE +15 -0
- package/package.json +13 -6
- package/src/components/AliaChatContent.tsx +11 -3
- package/src/components/AliaChatScreen.tsx +3 -3
- package/src/components/AliaChatSheet.tsx +17 -5
- package/src/components/AliaWelcomeMessage.tsx +2 -2
- package/src/components/{AliaMark.tsx → IdentityMark.tsx} +87 -19
- package/src/components/Markdown.tsx +31 -6
- package/src/components/ThinkingIndicator.tsx +2 -2
- package/src/components/ui/button.tsx +2 -2
- package/src/components/ui/chat-text-input.tsx +27 -11
- package/src/components/ui/prompt-input/textarea.tsx +19 -3
- package/src/hooks/useAliaChat.ts +309 -352
- package/src/hooks/useAmbientWave.ts +14 -2
- package/src/hooks/useSpeechToText.ts +4 -2
- package/src/hooks/useTTS.ts +162 -65
- package/src/hooks/useVoiceRoom.ts +26 -2
- package/src/index.ts +15 -2
- package/src/lib/audio-level.ts +129 -0
- package/src/lib/catalogue.ts +209 -0
- package/src/lib/chat-stream.ts +560 -0
- package/src/lib/chat-transport.ts +55 -0
- package/src/lib/config.ts +38 -0
- package/src/lib/tool-registry.ts +1 -1
- package/tsconfig.json +1 -1
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -1,18 +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
|
+
import { resolveModelId } from '../lib/catalogue';
|
|
6
|
+
import { PREFERRED_CHAT_MODEL_ID } from '../lib/config';
|
|
7
|
+
import { streamAliaChat } from '../lib/chat-transport';
|
|
8
|
+
import type { AliaChatStreamEvent } from '../lib/chat-stream';
|
|
5
9
|
|
|
6
10
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
7
11
|
|
|
8
12
|
export interface UseAliaChatOptions {
|
|
9
13
|
/** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
|
|
10
14
|
apiUrl?: string;
|
|
11
|
-
/** Alia model to use
|
|
15
|
+
/** Alia model or routing profile to use. */
|
|
12
16
|
model?: string;
|
|
13
|
-
/** 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. */
|
|
14
18
|
clientContext?: string;
|
|
15
|
-
/**
|
|
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
|
+
*/
|
|
16
24
|
accessToken?: string;
|
|
17
25
|
}
|
|
18
26
|
|
|
@@ -26,6 +34,19 @@ export interface UseAliaChatReturn {
|
|
|
26
34
|
error: string | null;
|
|
27
35
|
}
|
|
28
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
|
+
|
|
29
50
|
function generateId(): string {
|
|
30
51
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
31
52
|
return `msg-${crypto.randomUUID()}`;
|
|
@@ -33,425 +54,361 @@ function generateId(): string {
|
|
|
33
54
|
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
34
55
|
}
|
|
35
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
|
+
|
|
36
85
|
/**
|
|
37
86
|
* SSE streaming chat hook for Alia.
|
|
38
87
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
42
92
|
*/
|
|
43
93
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
44
94
|
const {
|
|
45
95
|
apiUrl = API_URL,
|
|
46
|
-
model =
|
|
96
|
+
model = PREFERRED_CHAT_MODEL_ID,
|
|
47
97
|
clientContext,
|
|
48
98
|
accessToken: accessTokenProp,
|
|
49
99
|
} = options;
|
|
100
|
+
const requestUrl = '/v1/chat/completions';
|
|
50
101
|
|
|
51
102
|
const { oxyServices } = useOxy();
|
|
52
103
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
53
104
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
54
105
|
const [error, setError] = useState<string | null>(null);
|
|
55
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
56
|
-
|
|
57
|
-
// Use a ref to read current messages without putting it in send's dep array
|
|
58
106
|
const messagesRef = useRef<ChatMessage[]>([]);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const pendingContentRef = useRef('');
|
|
63
|
-
const pendingReasoningRef = useRef('');
|
|
64
|
-
const toolInvocationsRef = useRef<ToolInvocation[]>([]);
|
|
65
|
-
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
107
|
+
const activeRequestRef = useRef<ActiveRequest | null>(null);
|
|
108
|
+
const pendingRef = useRef<PendingUpdates | null>(null);
|
|
109
|
+
const mountedRef = useRef(true);
|
|
66
110
|
|
|
67
|
-
const flushPendingUpdates = useCallback(() => {
|
|
68
|
-
const content = pendingContentRef.current;
|
|
69
|
-
const reasoning = pendingReasoningRef.current;
|
|
70
|
-
const tools = toolInvocationsRef.current;
|
|
71
|
-
if (!content && !reasoning && tools.length === 0) return;
|
|
72
|
-
|
|
73
|
-
pendingContentRef.current = '';
|
|
74
|
-
pendingReasoningRef.current = '';
|
|
75
|
-
|
|
76
|
-
setMessages((prev) => {
|
|
77
|
-
const updated = [...prev];
|
|
78
|
-
const last = updated[updated.length - 1];
|
|
79
|
-
if (last?.role === 'assistant') {
|
|
80
|
-
const changes: Partial<ChatMessage> = {};
|
|
81
|
-
if (content) changes.content = last.content + content;
|
|
82
|
-
if (reasoning) changes.thinking = (last.thinking || '') + reasoning;
|
|
83
|
-
if (tools.length > 0) changes.toolInvocations = [...tools];
|
|
84
|
-
updated[updated.length - 1] = { ...last, ...changes };
|
|
85
|
-
}
|
|
86
|
-
return updated;
|
|
87
|
-
});
|
|
88
|
-
}, []);
|
|
89
|
-
|
|
90
|
-
const scheduleFlush = useCallback(() => {
|
|
91
|
-
if (flushTimerRef.current) return;
|
|
92
|
-
flushTimerRef.current = setTimeout(() => {
|
|
93
|
-
flushTimerRef.current = null;
|
|
94
|
-
flushPendingUpdates();
|
|
95
|
-
}, 50);
|
|
96
|
-
}, [flushPendingUpdates]);
|
|
97
|
-
|
|
98
|
-
// Cleanup flush timer on unmount
|
|
99
111
|
useEffect(() => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (flushTimerRef.current) {
|
|
103
|
-
clearTimeout(flushTimerRef.current);
|
|
104
|
-
flushTimerRef.current = null;
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
}, [flushPendingUpdates]);
|
|
108
|
-
|
|
109
|
-
const getToken = useCallback((): string | null => {
|
|
110
|
-
if (accessTokenProp) return accessTokenProp;
|
|
111
|
-
return oxyServices.httpService.getAccessToken();
|
|
112
|
-
}, [accessTokenProp, oxyServices]);
|
|
112
|
+
messagesRef.current = messages;
|
|
113
|
+
}, [messages]);
|
|
113
114
|
|
|
114
|
-
/** Update the last assistant message with partial changes (callback receives current message) */
|
|
115
115
|
const updateAssistant = useCallback(
|
|
116
|
-
(updater: (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
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;
|
|
123
126
|
return updated;
|
|
124
127
|
});
|
|
125
128
|
},
|
|
126
129
|
[],
|
|
127
130
|
);
|
|
128
131
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}));
|
|
145
164
|
},
|
|
146
165
|
[updateAssistant],
|
|
147
166
|
);
|
|
148
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
|
+
|
|
149
212
|
const send = useCallback(
|
|
150
213
|
async (text: string) => {
|
|
151
214
|
const trimmed = text.trim();
|
|
152
|
-
if (!trimmed
|
|
215
|
+
if (!trimmed) return;
|
|
153
216
|
|
|
154
|
-
const
|
|
155
|
-
if (
|
|
156
|
-
setError('
|
|
217
|
+
const activeToken = oxyServices.getAccessToken();
|
|
218
|
+
if (accessTokenProp !== undefined && accessTokenProp !== activeToken) {
|
|
219
|
+
setError('The supplied token is not the active Oxy session.');
|
|
157
220
|
return;
|
|
158
221
|
}
|
|
159
222
|
|
|
160
|
-
|
|
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
|
+
}
|
|
161
234
|
|
|
162
|
-
|
|
163
|
-
const userMsg: ChatMessage = {
|
|
235
|
+
const userMessage: ChatMessage = {
|
|
164
236
|
id: generateId(),
|
|
165
237
|
role: 'user',
|
|
166
238
|
content: trimmed,
|
|
167
239
|
createdAt: Date.now(),
|
|
168
240
|
};
|
|
169
|
-
|
|
170
|
-
// Prepare assistant placeholder
|
|
171
|
-
const assistantMsg: ChatMessage = {
|
|
241
|
+
const assistantMessage: ChatMessage = {
|
|
172
242
|
id: generateId(),
|
|
173
243
|
role: 'assistant',
|
|
174
244
|
content: '',
|
|
175
245
|
toolInvocations: [],
|
|
176
246
|
createdAt: Date.now(),
|
|
177
247
|
};
|
|
178
|
-
|
|
179
|
-
|
|
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);
|
|
180
261
|
setIsStreaming(true);
|
|
181
262
|
|
|
182
|
-
// Build messages payload for the API (read from ref, not closure)
|
|
183
263
|
const apiMessages: Array<{ role: string; content: string }> = [];
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (msg.toolInvocations?.length) {
|
|
195
|
-
for (const tool of msg.toolInvocations) {
|
|
196
|
-
if (tool.state === 'result' && tool.result != null) {
|
|
197
|
-
apiMessages.push({
|
|
198
|
-
role: 'system',
|
|
199
|
-
content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
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
|
+
});
|
|
202
274
|
}
|
|
203
275
|
}
|
|
204
276
|
}
|
|
205
|
-
|
|
206
277
|
apiMessages.push({ role: 'user', content: trimmed });
|
|
207
278
|
|
|
279
|
+
const pending: PendingUpdates = {
|
|
280
|
+
assistantId: assistantMessage.id,
|
|
281
|
+
content: '',
|
|
282
|
+
reasoning: '',
|
|
283
|
+
tools: [],
|
|
284
|
+
timer: null,
|
|
285
|
+
};
|
|
286
|
+
pendingRef.current = pending;
|
|
208
287
|
const controller = new AbortController();
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
try {
|
|
251
|
-
while (true) {
|
|
252
|
-
const { done, value } = await reader.read();
|
|
253
|
-
if (done) break;
|
|
254
|
-
|
|
255
|
-
buffer += decoder.decode(value, { stream: true });
|
|
256
|
-
const lines = buffer.split('\n');
|
|
257
|
-
buffer = lines.pop() ?? '';
|
|
258
|
-
|
|
259
|
-
for (const line of lines) {
|
|
260
|
-
// Track named SSE event type
|
|
261
|
-
if (line.startsWith('event: ')) {
|
|
262
|
-
currentEventType = line.slice(7).trim();
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Reset event type on empty line (SSE event boundary)
|
|
267
|
-
if (line === '') {
|
|
268
|
-
currentEventType = '';
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const trimmedLine = line.trim();
|
|
273
|
-
if (!trimmedLine.startsWith('data: ')) continue;
|
|
274
|
-
const data = trimmedLine.slice(6);
|
|
275
|
-
if (data === '[DONE]') { currentEventType = ''; continue; }
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
const parsed = JSON.parse(data);
|
|
279
|
-
|
|
280
|
-
// ── Named SSE events (Alia extensions) ──
|
|
281
|
-
if (currentEventType) {
|
|
282
|
-
switch (currentEventType) {
|
|
283
|
-
case 'alia.reasoning': {
|
|
284
|
-
const content = parsed.content;
|
|
285
|
-
if (content) {
|
|
286
|
-
pendingReasoningRef.current += content;
|
|
287
|
-
scheduleFlush();
|
|
288
|
-
}
|
|
289
|
-
currentEventType = '';
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
case 'alia.tool_result': {
|
|
293
|
-
const { tool_call_id, name, output } = parsed;
|
|
294
|
-
if (tool_call_id) {
|
|
295
|
-
applyToolResult(tool_call_id, name, output);
|
|
296
|
-
}
|
|
297
|
-
currentEventType = '';
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
case 'alia.research_progress': {
|
|
301
|
-
updateAssistant((last) => ({
|
|
302
|
-
researchProgress: {
|
|
303
|
-
phase: parsed.phase,
|
|
304
|
-
message: parsed.message,
|
|
305
|
-
subQuestions: parsed.subQuestions || last.researchProgress?.subQuestions,
|
|
306
|
-
sourcesFound: parsed.sourcesFound,
|
|
307
|
-
currentQuery: parsed.currentQuery,
|
|
308
|
-
iteration: parsed.iteration,
|
|
309
|
-
},
|
|
310
|
-
}));
|
|
311
|
-
currentEventType = '';
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
case 'alia.plan_preview': {
|
|
315
|
-
updateAssistant(() => ({
|
|
316
|
-
pendingPlan: {
|
|
317
|
-
planId: parsed.planId,
|
|
318
|
-
steps: parsed.steps || [],
|
|
319
|
-
approved: false,
|
|
320
|
-
rejected: false,
|
|
321
|
-
},
|
|
322
|
-
}));
|
|
323
|
-
currentEventType = '';
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
default:
|
|
327
|
-
// Unknown named event — skip
|
|
328
|
-
currentEventType = '';
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// ── Standard OpenAI data events ──
|
|
334
|
-
|
|
335
|
-
const choice = parsed.choices?.[0];
|
|
336
|
-
if (!choice) {
|
|
337
|
-
// Legacy alia.tool_call event (type-based, not named SSE)
|
|
338
|
-
if (parsed.type === 'alia.tool_call') {
|
|
339
|
-
toolInvocationsRef.current = [
|
|
340
|
-
...toolInvocationsRef.current,
|
|
341
|
-
{
|
|
342
|
-
toolName: parsed.tool || 'unknown',
|
|
343
|
-
state: 'call',
|
|
344
|
-
args: parsed.args,
|
|
345
|
-
},
|
|
346
|
-
];
|
|
347
|
-
flushPendingUpdates();
|
|
348
|
-
}
|
|
349
|
-
// Legacy format matches by toolName (no toolCallId), so can't use applyToolResult
|
|
350
|
-
if (parsed.type === 'alia.tool_result') {
|
|
351
|
-
toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
|
|
352
|
-
t.toolName === parsed.tool && t.state === 'call'
|
|
353
|
-
? { ...t, state: 'result' as const, result: parsed.result }
|
|
354
|
-
: t,
|
|
355
|
-
);
|
|
356
|
-
flushPendingUpdates();
|
|
357
|
-
}
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const delta = choice.delta;
|
|
362
|
-
if (!delta) continue;
|
|
363
|
-
|
|
364
|
-
// Reasoning/thinking content (batched)
|
|
365
|
-
if (delta.reasoning) {
|
|
366
|
-
pendingReasoningRef.current += delta.reasoning;
|
|
367
|
-
scheduleFlush();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Text content (batched)
|
|
371
|
-
if (delta.content) {
|
|
372
|
-
pendingContentRef.current += delta.content;
|
|
373
|
-
scheduleFlush();
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Tool calls (OpenAI format: delta.tool_calls)
|
|
377
|
-
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
378
|
-
for (const tc of delta.tool_calls) {
|
|
379
|
-
const toolCallId = tc.id;
|
|
380
|
-
const toolName = tc.function?.name;
|
|
381
|
-
if (!toolCallId || !toolName) continue;
|
|
382
|
-
|
|
383
|
-
let args: Record<string, unknown> | undefined;
|
|
384
|
-
if (tc.function?.arguments) {
|
|
385
|
-
try { args = JSON.parse(tc.function.arguments) as Record<string, unknown>; } catch { args = { _raw: tc.function.arguments }; }
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const invocation: ToolInvocation = { toolCallId, toolName, state: 'call', args };
|
|
389
|
-
const idx = toolInvocationsRef.current.findIndex((t) => t.toolCallId === toolCallId);
|
|
390
|
-
if (idx >= 0) {
|
|
391
|
-
toolInvocationsRef.current[idx] = invocation;
|
|
392
|
-
} else {
|
|
393
|
-
toolInvocationsRef.current = [...toolInvocationsRef.current, invocation];
|
|
394
|
-
}
|
|
395
|
-
flushPendingUpdates();
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Tool results (delta.tool_result)
|
|
400
|
-
if (delta.tool_result) {
|
|
401
|
-
const { tool_call_id, name, output } = delta.tool_result;
|
|
402
|
-
if (tool_call_id) {
|
|
403
|
-
applyToolResult(tool_call_id, name, output);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
} catch {
|
|
407
|
-
// Skip malformed chunks
|
|
408
|
-
}
|
|
409
|
-
}
|
|
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;
|
|
410
328
|
}
|
|
411
|
-
|
|
412
|
-
|
|
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;
|
|
413
353
|
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const effectiveModel = await waitWithAbort(
|
|
358
|
+
resolveModelId(apiUrl, model, undefined, PREFERRED_CHAT_MODEL_ID),
|
|
359
|
+
controller.signal,
|
|
360
|
+
);
|
|
361
|
+
await streamAliaChat(
|
|
362
|
+
linked.client,
|
|
363
|
+
{ url: requestUrl, model: effectiveModel, messages: apiMessages },
|
|
364
|
+
controller.signal,
|
|
365
|
+
applyEvent,
|
|
420
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
|
+
}
|
|
421
381
|
} finally {
|
|
422
|
-
|
|
423
|
-
flushPendingUpdates();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
|
427
389
|
}
|
|
428
|
-
setIsStreaming(false);
|
|
429
|
-
abortRef.current = null;
|
|
430
390
|
}
|
|
431
391
|
},
|
|
432
|
-
[
|
|
392
|
+
[
|
|
393
|
+
accessTokenProp,
|
|
394
|
+
apiUrl,
|
|
395
|
+
cancelPendingTimer,
|
|
396
|
+
clientContext,
|
|
397
|
+
flushPendingUpdates,
|
|
398
|
+
model,
|
|
399
|
+
oxyServices,
|
|
400
|
+
requestUrl,
|
|
401
|
+
removeEmptyAssistant,
|
|
402
|
+
scheduleFlush,
|
|
403
|
+
updateAssistant,
|
|
404
|
+
],
|
|
433
405
|
);
|
|
434
406
|
|
|
435
|
-
const stop = useCallback(() => {
|
|
436
|
-
if (abortRef.current) {
|
|
437
|
-
abortRef.current.abort();
|
|
438
|
-
abortRef.current = null;
|
|
439
|
-
}
|
|
440
|
-
flushPendingUpdates();
|
|
441
|
-
if (flushTimerRef.current) {
|
|
442
|
-
clearTimeout(flushTimerRef.current);
|
|
443
|
-
flushTimerRef.current = null;
|
|
444
|
-
}
|
|
445
|
-
setIsStreaming(false);
|
|
446
|
-
}, [flushPendingUpdates]);
|
|
447
|
-
|
|
448
407
|
const clear = useCallback(() => {
|
|
449
408
|
stop();
|
|
409
|
+
messagesRef.current = [];
|
|
450
410
|
setMessages([]);
|
|
451
411
|
setError(null);
|
|
452
|
-
pendingContentRef.current = '';
|
|
453
|
-
pendingReasoningRef.current = '';
|
|
454
|
-
toolInvocationsRef.current = [];
|
|
455
412
|
}, [stop]);
|
|
456
413
|
|
|
457
414
|
return { messages, setMessages, send, isStreaming, stop, clear, error };
|