@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.
@@ -1,27 +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
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
- /** 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
+ */
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
- * Sends messages to Alia's /v1/chat/completions endpoint and streams
49
- * responses back, including tool invocations, reasoning, research progress,
50
- * 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.
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
- useEffect(() => { messagesRef.current = messages; }, [messages]);
69
-
70
- // Batching: accumulate streaming content and flush at ~20fps
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
- return () => {
110
- flushPendingUpdates();
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: (msg: ChatMessage) => Partial<ChatMessage>) => {
126
- setMessages((prev) => {
127
- const updated = [...prev];
128
- const last = updated[updated.length - 1];
129
- if (last?.role === 'assistant') {
130
- updated[updated.length - 1] = { ...last, ...updater(last) };
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
- /** Apply a tool result to the last assistant message and sync the ref */
139
- const applyToolResult = useCallback(
140
- (toolCallId: string, name: string | undefined, output: unknown) => {
141
- updateAssistant((last) => {
142
- const invocations = [...(last.toolInvocations || [])];
143
- const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
144
- if (idx >= 0) {
145
- invocations[idx] = { ...invocations[idx], state: 'result', result: output };
146
- } else {
147
- invocations.push({ toolCallId, toolName: name || 'unknown', state: 'result', result: output });
148
- }
149
- return { toolInvocations: invocations };
150
- });
151
- toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
152
- t.toolCallId === toolCallId ? { ...t, state: 'result' as const, result: output } : t
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 || isStreaming) return;
215
+ if (!trimmed) return;
162
216
 
163
- const token = getToken();
164
- if (!token) {
165
- 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.');
166
220
  return;
167
221
  }
168
222
 
169
- 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
+ }
170
234
 
171
- // Add user message
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
- 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);
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
- if (clientContext) {
195
- apiMessages.push({ role: 'system', content: clientContext });
196
- }
197
-
198
- // Include full conversation history with tool context
199
- for (const msg of messagesRef.current) {
200
- if (msg.role === 'system') continue;
201
- apiMessages.push({ role: msg.role, content: getTextFromContent(msg.content) });
202
- // Include tool results so the AI has full context
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
- abortRef.current = controller;
219
-
220
- // Reset batching state
221
- pendingContentRef.current = '';
222
- pendingReasoningRef.current = '';
223
- toolInvocationsRef.current = [];
224
-
225
- try {
226
- /**
227
- * This SDK stays on `/v1/chat/completions`, and that is a decision
228
- * rather than an oversight — epic #139 workstream 6.
229
- *
230
- * Every other first-party Alia client moved to the product runtime at
231
- * `POST /alia/chat`, which serves the identical handler. This one cannot
232
- * follow, for two reasons that are independent of each other.
233
- *
234
- * **CORS.** `/v1` is the only surface with the public wildcard policy;
235
- * everything else falls to the Oxy allowlist
236
- * (`packages/api/src/index.ts`). Measured 2026-08-19 against the running
237
- * service: an `OPTIONS /alia/chat` preflight from
238
- * `https://alia.onl` and `https://console.alia.onl` is answered with a
239
- * matching `access-control-allow-origin`, and one from an unlisted
240
- * origin comes back with NO such header, so a browser blocks the
241
- * request. `OPTIONS /v1/chat/completions` answers `*` for all three.
242
- * This package is installed by apps on origins Alia does not enumerate,
243
- * so the product surface would refuse them. Widening the allowlist is
244
- * not the repair: the narrow policy is what makes `/alia/chat` a product
245
- * surface, and it is one of the three recorded differences in
246
- * `packages/api/src/routes/__tests__/v1-compatibility-surface.test.ts`.
247
- *
248
- * **Raw source.** `@alia.onl/sdk` publishes `src/`, so consumers' own
249
- * Metro and tsc compile this line and it changes what every installed
250
- * copy sends on its NEXT build — not on a release this repository
251
- * controls. `docs/migration/compatibility-window.md` records the same
252
- * property for the aliases: installed copies resume sending whatever
253
- * their source says the moment the service is reachable.
254
- *
255
- * The consequence is recorded rather than hidden: `/v1/chat/completions`
256
- * cannot reach its removal gate while this SDK is a consumer, so
257
- * migrating it is a browser-CORS decision on the product surface, taken
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
- } finally {
459
- 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;
460
353
  }
461
- } catch (err: unknown) {
462
- if (err instanceof Error && err.name === 'AbortError') return;
463
- const errorMessage = err instanceof Error ? err.message : 'Something went wrong';
464
- setError(errorMessage);
465
- updateAssistant((last) =>
466
- !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,
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
- // Flush any remaining batched content
470
- flushPendingUpdates();
471
- if (flushTimerRef.current) {
472
- clearTimeout(flushTimerRef.current);
473
- 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);
474
389
  }
475
- setIsStreaming(false);
476
- abortRef.current = null;
477
390
  }
478
391
  },
479
- [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
+ ],
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 };