@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.
@@ -1,18 +1,26 @@
1
- import React, { useState, useRef, useCallback, useEffect } from '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 (default: 'alia-v1') */
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
- /** Access token override — if not provided, fetched from useOxy() */
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
- * Sends messages to Alia's /v1/chat/completions endpoint and streams
40
- * responses back, including tool invocations, reasoning, research progress,
41
- * and plan previews.
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 = 'alia-v1',
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
- useEffect(() => { messagesRef.current = messages; }, [messages]);
60
-
61
- // Batching: accumulate streaming content and flush at ~20fps
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
- return () => {
101
- flushPendingUpdates();
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: (msg: ChatMessage) => Partial<ChatMessage>) => {
117
- setMessages((prev) => {
118
- const updated = [...prev];
119
- const last = updated[updated.length - 1];
120
- if (last?.role === 'assistant') {
121
- updated[updated.length - 1] = { ...last, ...updater(last) };
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
- /** Apply a tool result to the last assistant message and sync the ref */
130
- const applyToolResult = useCallback(
131
- (toolCallId: string, name: string | undefined, output: unknown) => {
132
- updateAssistant((last) => {
133
- const invocations = [...(last.toolInvocations || [])];
134
- const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
135
- if (idx >= 0) {
136
- invocations[idx] = { ...invocations[idx], state: 'result', result: output };
137
- } else {
138
- invocations.push({ toolCallId, toolName: name || 'unknown', state: 'result', result: output });
139
- }
140
- return { toolInvocations: invocations };
141
- });
142
- toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
143
- t.toolCallId === toolCallId ? { ...t, state: 'result' as const, result: output } : t
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 || isStreaming) return;
215
+ if (!trimmed) return;
153
216
 
154
- const token = getToken();
155
- if (!token) {
156
- setError('Not authenticated');
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
- setError(null);
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
- // Add user message
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
- setMessages((prev) => [...prev, userMsg, assistantMsg]);
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
- if (clientContext) {
186
- apiMessages.push({ role: 'system', content: clientContext });
187
- }
188
-
189
- // Include full conversation history with tool context
190
- for (const msg of messagesRef.current) {
191
- if (msg.role === 'system') continue;
192
- apiMessages.push({ role: msg.role, content: getTextFromContent(msg.content) });
193
- // Include tool results so the AI has full context
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
- abortRef.current = controller;
210
-
211
- // Reset batching state
212
- pendingContentRef.current = '';
213
- pendingReasoningRef.current = '';
214
- toolInvocationsRef.current = [];
215
-
216
- try {
217
- const response = await fetch(`${apiUrl}/v1/chat/completions`, {
218
- method: 'POST',
219
- headers: {
220
- 'Content-Type': 'application/json',
221
- Authorization: `Bearer ${token}`,
222
- },
223
- body: JSON.stringify({
224
- model,
225
- messages: apiMessages,
226
- stream: true,
227
- }),
228
- signal: controller.signal,
229
- });
230
-
231
- if (!response.ok) {
232
- const body = await response.text().catch(() => '');
233
- throw new Error(`API error ${response.status}: ${body.slice(0, 200)}`);
234
- }
235
-
236
- // Non-streaming fallback
237
- if (!response.body || typeof response.body.getReader !== 'function') {
238
- const json = await response.json();
239
- const content = json.choices?.[0]?.message?.content ?? '';
240
- updateAssistant(() => ({ content }));
241
- return;
242
- }
243
-
244
- // Stream SSE (supports named events: event: X\ndata: Y)
245
- const reader = response.body.getReader();
246
- const decoder = new TextDecoder();
247
- let buffer = '';
248
- let currentEventType = '';
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
- } finally {
412
- reader.releaseLock();
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
- } catch (err: unknown) {
415
- if (err instanceof Error && err.name === 'AbortError') return;
416
- const errorMessage = err instanceof Error ? err.message : 'Something went wrong';
417
- setError(errorMessage);
418
- updateAssistant((last) =>
419
- !last.content ? { content: "I'm having trouble connecting right now. Please try again." } : {}
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
- // Flush any remaining batched content
423
- flushPendingUpdates();
424
- if (flushTimerRef.current) {
425
- clearTimeout(flushTimerRef.current);
426
- flushTimerRef.current = null;
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
- [isStreaming, getToken, apiUrl, model, clientContext, scheduleFlush, flushPendingUpdates, updateAssistant, applyToolResult],
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 };