@alia.onl/sdk 7.0.0 → 7.2.0

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