@alia.onl/sdk 1.0.0 → 2.0.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Bottom voice control bar that replaces PromptInput when voice mode is active.
3
+ * Contains status text, mute/cohost/end buttons, and "Continue" for cohost rounds.
4
+ */
5
+
6
+ import { View, Text, Pressable, ActivityIndicator, StyleSheet } from 'react-native';
7
+ import type { RoomState, AgentState } from '../../types';
8
+ import { useAliaColors } from '../../theme';
9
+
10
+ interface VoiceControlsProps {
11
+ roomState: RoomState;
12
+ agentState: AgentState;
13
+ isMuted: boolean;
14
+ cohostActive: boolean;
15
+ currentSpeaker: 'primary' | 'cohost' | 'user' | null;
16
+ roundComplete: boolean;
17
+ onToggleMute: () => void;
18
+ onEnableCohost: () => void;
19
+ onDisableCohost: () => void;
20
+ onContinueCohost: () => void;
21
+ onEnd: () => void;
22
+ }
23
+
24
+ function getStatusText(
25
+ roomState: RoomState,
26
+ agentState: AgentState,
27
+ isMuted: boolean,
28
+ cohostActive: boolean,
29
+ currentSpeaker: string | null,
30
+ ): string {
31
+ if (roomState === 'connecting') return 'Connecting...';
32
+ if (roomState === 'connected') {
33
+ if (cohostActive && currentSpeaker === 'cohost') return 'Cohost speaking...';
34
+ if (cohostActive && currentSpeaker === 'primary') return 'Alia speaking...';
35
+ if (agentState === 'listening') return isMuted ? 'Muted' : 'Listening...';
36
+ if (agentState === 'thinking') return 'Thinking...';
37
+ if (agentState === 'speaking') return 'Speaking...';
38
+ return 'Connected';
39
+ }
40
+ return '';
41
+ }
42
+
43
+ export function VoiceControls({
44
+ roomState,
45
+ agentState,
46
+ isMuted,
47
+ cohostActive,
48
+ currentSpeaker,
49
+ roundComplete,
50
+ onToggleMute,
51
+ onEnableCohost,
52
+ onDisableCohost,
53
+ onContinueCohost,
54
+ onEnd,
55
+ }: VoiceControlsProps) {
56
+ const colors = useAliaColors();
57
+ const statusText = getStatusText(roomState, agentState, isMuted, cohostActive, currentSpeaker);
58
+
59
+ return (
60
+ <View style={styles.container}>
61
+ {statusText ? (
62
+ <Text style={[styles.statusText, { color: colors.text }]}>
63
+ {statusText}
64
+ </Text>
65
+ ) : null}
66
+
67
+ {roundComplete && (
68
+ <Pressable
69
+ onPress={onContinueCohost}
70
+ style={styles.continueButton}
71
+ >
72
+ <Text style={styles.continueText}>Continue conversation</Text>
73
+ </Pressable>
74
+ )}
75
+
76
+ {roomState === 'connected' && (
77
+ <View style={styles.buttonRow}>
78
+ <View style={styles.buttonWrapper}>
79
+ <Pressable
80
+ onPress={onToggleMute}
81
+ style={[
82
+ styles.button,
83
+ { backgroundColor: isMuted ? '#ef4444' : 'rgba(255,255,255,0.15)' },
84
+ ]}
85
+ >
86
+ <Text style={styles.buttonIcon}>
87
+ {isMuted ? '\u{1F507}' : '\u{1F3A4}'}
88
+ </Text>
89
+ </Pressable>
90
+ <Text style={styles.buttonLabel}>
91
+ {isMuted ? 'Unmute' : 'Mute'}
92
+ </Text>
93
+ </View>
94
+
95
+ <View style={styles.buttonWrapper}>
96
+ <Pressable
97
+ onPress={cohostActive ? onDisableCohost : onEnableCohost}
98
+ style={[
99
+ styles.button,
100
+ { backgroundColor: cohostActive ? '#8b5cf6' : 'rgba(255,255,255,0.15)' },
101
+ ]}
102
+ >
103
+ <Text style={styles.buttonIcon}>{'\u{1F465}'}</Text>
104
+ </Pressable>
105
+ <Text style={styles.buttonLabel}>
106
+ {cohostActive ? 'Solo' : 'Cohost'}
107
+ </Text>
108
+ </View>
109
+
110
+ <View style={styles.buttonWrapper}>
111
+ <Pressable
112
+ onPress={onEnd}
113
+ style={[styles.button, { backgroundColor: '#ef4444' }]}
114
+ >
115
+ <Text style={styles.endIcon}>{'\u2715'}</Text>
116
+ </Pressable>
117
+ <Text style={styles.buttonLabel}>End</Text>
118
+ </View>
119
+ </View>
120
+ )}
121
+
122
+ {roomState === 'connecting' && (
123
+ <ActivityIndicator size="large" color="#38bdf8" />
124
+ )}
125
+ </View>
126
+ );
127
+ }
128
+
129
+ const styles = StyleSheet.create({
130
+ container: {
131
+ alignItems: 'center',
132
+ paddingBottom: 24,
133
+ paddingTop: 16,
134
+ },
135
+ statusText: {
136
+ fontSize: 18,
137
+ fontWeight: '500',
138
+ marginBottom: 16,
139
+ },
140
+ buttonRow: {
141
+ flexDirection: 'row',
142
+ alignItems: 'center',
143
+ gap: 32,
144
+ },
145
+ buttonWrapper: {
146
+ alignItems: 'center',
147
+ gap: 8,
148
+ },
149
+ button: {
150
+ width: 56,
151
+ height: 56,
152
+ borderRadius: 28,
153
+ alignItems: 'center',
154
+ justifyContent: 'center',
155
+ },
156
+ buttonIcon: {
157
+ fontSize: 24,
158
+ },
159
+ endIcon: {
160
+ fontSize: 24,
161
+ color: '#ffffff',
162
+ fontWeight: '700',
163
+ },
164
+ buttonLabel: {
165
+ color: '#8E8E93',
166
+ fontSize: 12,
167
+ },
168
+ continueButton: {
169
+ marginBottom: 16,
170
+ paddingHorizontal: 20,
171
+ paddingVertical: 8,
172
+ borderRadius: 24,
173
+ backgroundColor: 'rgba(139, 92, 246, 0.3)',
174
+ },
175
+ continueText: {
176
+ color: '#a78bfa',
177
+ fontSize: 14,
178
+ fontWeight: '500',
179
+ },
180
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Subtle voice mode overlay that renders the AudioWaveVisualizer
3
+ * behind the conversation message list. Preserves the normal chat theme
4
+ * with waves at reduced opacity for a seamless visual experience.
5
+ */
6
+
7
+ import { View } from 'react-native';
8
+ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
9
+ import { AudioWaveVisualizer } from './AudioWaveVisualizer';
10
+ import type { SharedValue } from 'react-native-reanimated';
11
+ import type { AgentState } from '../../types';
12
+
13
+ interface VoiceOverlayProps {
14
+ waveAmplitude: SharedValue<number>;
15
+ agentState: AgentState;
16
+ isConnected: boolean;
17
+ }
18
+
19
+ export function VoiceOverlay({ waveAmplitude, agentState, isConnected }: VoiceOverlayProps) {
20
+ return (
21
+ <Animated.View
22
+ entering={FadeIn.duration(400)}
23
+ exiting={FadeOut.duration(300)}
24
+ style={{
25
+ position: 'absolute',
26
+ bottom: 0,
27
+ left: 0,
28
+ right: 0,
29
+ height: 350,
30
+ zIndex: 5,
31
+ pointerEvents: 'none',
32
+ }}
33
+ >
34
+ <View style={{ opacity: 0.35 }}>
35
+ <AudioWaveVisualizer
36
+ waveAmplitude={waveAmplitude}
37
+ agentState={agentState}
38
+ isConnected={isConnected}
39
+ />
40
+ </View>
41
+ </Animated.View>
42
+ );
43
+ }
@@ -1,11 +1,11 @@
1
- import { useState, useRef, useCallback } from 'react';
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import { useOxy } from '@oxyhq/services';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
 
5
- const API_URL = process.env.EXPO_PUBLIC_API_URL;
5
+ const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
6
6
 
7
7
  export interface UseAliaChatOptions {
8
- /** Alia API base URL (default: EXPO_PUBLIC_API_URL) */
8
+ /** Alia API base URL (default: EXPO_PUBLIC_ALIA_API_URL or https://api.alia.onl) */
9
9
  apiUrl?: string;
10
10
  /** Alia model to use (default: 'alia-v1') */
11
11
  model?: string;
@@ -23,16 +23,19 @@ export interface UseAliaChatReturn {
23
23
  error: string | null;
24
24
  }
25
25
 
26
- let nextId = 0;
27
26
  function generateId(): string {
28
- return `msg-${Date.now()}-${nextId++}`;
27
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
28
+ return `msg-${crypto.randomUUID()}`;
29
+ }
30
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
29
31
  }
30
32
 
31
33
  /**
32
34
  * SSE streaming chat hook for Alia.
33
35
  *
34
36
  * Sends messages to Alia's /v1/chat/completions endpoint and streams
35
- * responses back, including tool invocations.
37
+ * responses back, including tool invocations, reasoning, research progress,
38
+ * and plan previews.
36
39
  */
37
40
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
38
41
  const {
@@ -48,11 +51,98 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
48
51
  const [error, setError] = useState<string | null>(null);
49
52
  const abortRef = useRef<AbortController | null>(null);
50
53
 
54
+ // Use a ref to read current messages without putting it in send's dep array
55
+ const messagesRef = useRef<ChatMessage[]>([]);
56
+ useEffect(() => { messagesRef.current = messages; }, [messages]);
57
+
58
+ // Batching: accumulate streaming content and flush at ~20fps
59
+ const pendingContentRef = useRef('');
60
+ const pendingReasoningRef = useRef('');
61
+ const toolInvocationsRef = useRef<ToolInvocation[]>([]);
62
+ const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
63
+
64
+ const flushPendingUpdates = useCallback(() => {
65
+ const content = pendingContentRef.current;
66
+ const reasoning = pendingReasoningRef.current;
67
+ const tools = toolInvocationsRef.current;
68
+ if (!content && !reasoning && tools.length === 0) return;
69
+
70
+ pendingContentRef.current = '';
71
+ pendingReasoningRef.current = '';
72
+
73
+ setMessages((prev) => {
74
+ const updated = [...prev];
75
+ const last = updated[updated.length - 1];
76
+ if (last?.role === 'assistant') {
77
+ const changes: Partial<ChatMessage> = {};
78
+ if (content) changes.content = last.content + content;
79
+ if (reasoning) changes.thinking = (last.thinking || '') + reasoning;
80
+ if (tools.length > 0) changes.toolInvocations = [...tools];
81
+ updated[updated.length - 1] = { ...last, ...changes };
82
+ }
83
+ return updated;
84
+ });
85
+ }, []);
86
+
87
+ const scheduleFlush = useCallback(() => {
88
+ if (flushTimerRef.current) return;
89
+ flushTimerRef.current = setTimeout(() => {
90
+ flushTimerRef.current = null;
91
+ flushPendingUpdates();
92
+ }, 50);
93
+ }, [flushPendingUpdates]);
94
+
95
+ // Cleanup flush timer on unmount
96
+ useEffect(() => {
97
+ return () => {
98
+ flushPendingUpdates();
99
+ if (flushTimerRef.current) {
100
+ clearTimeout(flushTimerRef.current);
101
+ flushTimerRef.current = null;
102
+ }
103
+ };
104
+ }, [flushPendingUpdates]);
105
+
51
106
  const getToken = useCallback((): string | null => {
52
107
  if (accessTokenProp) return accessTokenProp;
53
108
  return oxyServices.httpService.getAccessToken();
54
109
  }, [accessTokenProp, oxyServices]);
55
110
 
111
+ /** Update the last assistant message with partial changes (callback receives current message) */
112
+ const updateAssistant = useCallback(
113
+ (updater: (msg: ChatMessage) => Partial<ChatMessage>) => {
114
+ setMessages((prev) => {
115
+ const updated = [...prev];
116
+ const last = updated[updated.length - 1];
117
+ if (last?.role === 'assistant') {
118
+ updated[updated.length - 1] = { ...last, ...updater(last) };
119
+ }
120
+ return updated;
121
+ });
122
+ },
123
+ [],
124
+ );
125
+
126
+ /** Apply a tool result to the last assistant message and sync the ref */
127
+ const applyToolResult = useCallback(
128
+ (toolCallId: string, name: string | undefined, output: any) => {
129
+ updateAssistant((last) => {
130
+ const invocations = [...(last.toolInvocations || [])];
131
+ const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
132
+ if (idx >= 0) {
133
+ invocations[idx] = { ...invocations[idx], state: 'result', result: output };
134
+ } else {
135
+ invocations.push({ toolCallId, toolName: name || 'unknown', state: 'result', result: output });
136
+ }
137
+ return { toolInvocations: invocations };
138
+ });
139
+ toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
140
+ t.toolCallId === toolCallId ? { ...t, state: 'result' as const, result: output } : t
141
+ );
142
+ },
143
+ [updateAssistant],
144
+ );
145
+
56
146
  const send = useCallback(
57
147
  async (text: string) => {
58
148
  const trimmed = text.trim();
@@ -86,26 +176,40 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
86
176
  setMessages((prev) => [...prev, userMsg, assistantMsg]);
87
177
  setIsStreaming(true);
88
178
 
89
- // Build messages payload for the API
179
+ // Build messages payload for the API (read from ref, not closure)
90
180
  const apiMessages: Array<{ role: string; content: string }> = [];
91
181
 
92
- // System context (tells Alia which app the user is in)
93
182
  if (clientContext) {
94
183
  apiMessages.push({ role: 'system', content: clientContext });
95
184
  }
96
185
 
97
- // Previous conversation history
98
- for (const msg of messages) {
186
+ // Include full conversation history with tool context
187
+ for (const msg of messagesRef.current) {
99
188
  if (msg.role === 'system') continue;
100
189
  apiMessages.push({ role: msg.role, content: msg.content });
190
+ // Include tool results so the AI has full context
191
+ if (msg.toolInvocations?.length) {
192
+ for (const tool of msg.toolInvocations) {
193
+ if (tool.state === 'result' && tool.result != null) {
194
+ apiMessages.push({
195
+ role: 'system',
196
+ content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
197
+ });
198
+ }
199
+ }
200
+ }
101
201
  }
102
202
 
103
- // New user message
104
203
  apiMessages.push({ role: 'user', content: trimmed });
105
204
 
106
205
  const controller = new AbortController();
107
206
  abortRef.current = controller;
108
207
 
208
+ // Reset batching state
209
+ pendingContentRef.current = '';
210
+ pendingReasoningRef.current = '';
211
+ toolInvocationsRef.current = [];
212
+
109
213
  try {
110
214
  const response = await fetch(`${apiUrl}/v1/chat/completions`, {
111
215
  method: 'POST',
@@ -130,23 +234,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
130
234
  if (!response.body || typeof response.body.getReader !== 'function') {
131
235
  const json = await response.json();
132
236
  const content = json.choices?.[0]?.message?.content ?? '';
133
- setMessages((prev) => {
134
- const updated = [...prev];
135
- const last = updated[updated.length - 1];
136
- if (last?.role === 'assistant') {
137
- updated[updated.length - 1] = { ...last, content };
138
- }
139
- return updated;
140
- });
237
+ updateAssistant(() => ({ content }));
141
238
  return;
142
239
  }
143
240
 
144
- // Stream SSE
241
+ // Stream SSE (supports named events: event: X\ndata: Y)
145
242
  const reader = response.body.getReader();
146
243
  const decoder = new TextDecoder();
147
244
  let buffer = '';
148
- let accumulatedContent = '';
149
- const toolInvocations: ToolInvocation[] = [];
245
+ let currentEventType = '';
150
246
 
151
247
  try {
152
248
  while (true) {
@@ -158,72 +254,153 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
158
254
  buffer = lines.pop() ?? '';
159
255
 
160
256
  for (const line of lines) {
257
+ // Track named SSE event type
258
+ if (line.startsWith('event: ')) {
259
+ currentEventType = line.slice(7).trim();
260
+ continue;
261
+ }
262
+
263
+ // Reset event type on empty line (SSE event boundary)
264
+ if (line === '') {
265
+ currentEventType = '';
266
+ continue;
267
+ }
268
+
161
269
  const trimmedLine = line.trim();
162
- if (!trimmedLine || !trimmedLine.startsWith('data: ')) continue;
270
+ if (!trimmedLine.startsWith('data: ')) continue;
163
271
  const data = trimmedLine.slice(6);
164
- if (data === '[DONE]') continue;
272
+ if (data === '[DONE]') { currentEventType = ''; continue; }
165
273
 
166
274
  try {
167
275
  const parsed = JSON.parse(data);
168
276
 
169
- // Standard content delta
170
- const delta = parsed.choices?.[0]?.delta?.content;
171
- if (delta) {
172
- accumulatedContent += delta;
173
- setMessages((prev) => {
174
- const updated = [...prev];
175
- const last = updated[updated.length - 1];
176
- if (last?.role === 'assistant') {
177
- updated[updated.length - 1] = {
178
- ...last,
179
- content: accumulatedContent,
180
- toolInvocations: toolInvocations.length > 0 ? [...toolInvocations] : undefined,
181
- };
277
+ // ── Named SSE events (Alia extensions) ──
278
+ if (currentEventType) {
279
+ switch (currentEventType) {
280
+ case 'alia.reasoning': {
281
+ const content = parsed.content;
282
+ if (content) {
283
+ pendingReasoningRef.current += content;
284
+ scheduleFlush();
285
+ }
286
+ currentEventType = '';
287
+ continue;
288
+ }
289
+ case 'alia.tool_result': {
290
+ const { tool_call_id, name, output } = parsed;
291
+ if (tool_call_id) {
292
+ applyToolResult(tool_call_id, name, output);
293
+ }
294
+ currentEventType = '';
295
+ continue;
182
296
  }
183
- return updated;
184
- });
297
+ case 'alia.research_progress': {
298
+ updateAssistant((last) => ({
299
+ researchProgress: {
300
+ phase: parsed.phase,
301
+ message: parsed.message,
302
+ subQuestions: parsed.subQuestions || last.researchProgress?.subQuestions,
303
+ sourcesFound: parsed.sourcesFound,
304
+ currentQuery: parsed.currentQuery,
305
+ iteration: parsed.iteration,
306
+ },
307
+ }));
308
+ currentEventType = '';
309
+ continue;
310
+ }
311
+ case 'alia.plan_preview': {
312
+ updateAssistant(() => ({
313
+ pendingPlan: {
314
+ planId: parsed.planId,
315
+ intent: parsed.intent,
316
+ confidence: parsed.confidence,
317
+ steps: parsed.steps || [],
318
+ approved: false,
319
+ rejected: false,
320
+ },
321
+ }));
322
+ currentEventType = '';
323
+ continue;
324
+ }
325
+ default:
326
+ // Unknown named event — skip
327
+ currentEventType = '';
328
+ continue;
329
+ }
330
+ }
331
+
332
+ // ── Standard OpenAI data events ──
333
+
334
+ const choice = parsed.choices?.[0];
335
+ if (!choice) {
336
+ // Legacy alia.tool_call event (type-based, not named SSE)
337
+ if (parsed.type === 'alia.tool_call') {
338
+ toolInvocationsRef.current = [
339
+ ...toolInvocationsRef.current,
340
+ {
341
+ toolName: parsed.tool || 'unknown',
342
+ state: 'call',
343
+ args: parsed.args,
344
+ },
345
+ ];
346
+ flushPendingUpdates();
347
+ }
348
+ // Legacy format matches by toolName (no toolCallId), so can't use applyToolResult
349
+ if (parsed.type === 'alia.tool_result') {
350
+ toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
351
+ t.toolName === parsed.tool && t.state === 'call'
352
+ ? { ...t, state: 'result' as const, result: parsed.result }
353
+ : t,
354
+ );
355
+ flushPendingUpdates();
356
+ }
357
+ continue;
358
+ }
359
+
360
+ const delta = choice.delta;
361
+ if (!delta) continue;
362
+
363
+ // Reasoning/thinking content (batched)
364
+ if (delta.reasoning) {
365
+ pendingReasoningRef.current += delta.reasoning;
366
+ scheduleFlush();
367
+ }
368
+
369
+ // Text content (batched)
370
+ if (delta.content) {
371
+ pendingContentRef.current += delta.content;
372
+ scheduleFlush();
185
373
  }
186
374
 
187
- // Alia tool call event
188
- if (parsed.type === 'alia.tool_call') {
189
- toolInvocations.push({
190
- toolName: parsed.tool || 'unknown',
191
- state: 'call',
192
- args: parsed.args,
193
- });
194
- setMessages((prev) => {
195
- const updated = [...prev];
196
- const last = updated[updated.length - 1];
197
- if (last?.role === 'assistant') {
198
- updated[updated.length - 1] = {
199
- ...last,
200
- toolInvocations: [...toolInvocations],
201
- };
375
+ // Tool calls (OpenAI format: delta.tool_calls)
376
+ if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
377
+ for (const tc of delta.tool_calls) {
378
+ const toolCallId = tc.id;
379
+ const toolName = tc.function?.name;
380
+ if (!toolCallId || !toolName) continue;
381
+
382
+ let args: any;
383
+ if (tc.function?.arguments) {
384
+ try { args = JSON.parse(tc.function.arguments); } catch { args = { _raw: tc.function.arguments }; }
385
+ }
386
+
387
+ const invocation: ToolInvocation = { toolCallId, toolName, state: 'call', args };
388
+ const idx = toolInvocationsRef.current.findIndex((t) => t.toolCallId === toolCallId);
389
+ if (idx >= 0) {
390
+ toolInvocationsRef.current[idx] = invocation;
391
+ } else {
392
+ toolInvocationsRef.current = [...toolInvocationsRef.current, invocation];
202
393
  }
203
- return updated;
204
- });
394
+ flushPendingUpdates();
395
+ }
205
396
  }
206
397
 
207
- // Alia tool result event
208
- if (parsed.type === 'alia.tool_result') {
209
- const existing = toolInvocations.find(
210
- (t) => t.toolName === parsed.tool && t.state === 'call',
211
- );
212
- if (existing) {
213
- existing.state = 'result';
214
- existing.result = parsed.result;
398
+ // Tool results (delta.tool_result)
399
+ if (delta.tool_result) {
400
+ const { tool_call_id, name, output } = delta.tool_result;
401
+ if (tool_call_id) {
402
+ applyToolResult(tool_call_id, name, output);
215
403
  }
216
- setMessages((prev) => {
217
- const updated = [...prev];
218
- const last = updated[updated.length - 1];
219
- if (last?.role === 'assistant') {
220
- updated[updated.length - 1] = {
221
- ...last,
222
- toolInvocations: [...toolInvocations],
223
- };
224
- }
225
- return updated;
226
- });
227
404
  }
228
405
  } catch {
229
406
  // Skip malformed chunks
@@ -237,23 +414,21 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
237
414
  if (err?.name === 'AbortError') return;
238
415
  const errorMessage = err?.message || 'Something went wrong';
239
416
  setError(errorMessage);
240
- setMessages((prev) => {
241
- const updated = [...prev];
242
- const last = updated[updated.length - 1];
243
- if (last?.role === 'assistant' && !last.content) {
244
- updated[updated.length - 1] = {
245
- ...last,
246
- content: "I'm having trouble connecting right now. Please try again.",
247
- };
248
- }
249
- return updated;
250
- });
417
+ updateAssistant((last) =>
418
+ !last.content ? { content: "I'm having trouble connecting right now. Please try again." } : {}
419
+ );
251
420
  } finally {
421
+ // Flush any remaining batched content
422
+ flushPendingUpdates();
423
+ if (flushTimerRef.current) {
424
+ clearTimeout(flushTimerRef.current);
425
+ flushTimerRef.current = null;
426
+ }
252
427
  setIsStreaming(false);
253
428
  abortRef.current = null;
254
429
  }
255
430
  },
256
- [isStreaming, messages, getToken, apiUrl, model, clientContext],
431
+ [isStreaming, getToken, apiUrl, model, clientContext, scheduleFlush, flushPendingUpdates, updateAssistant, applyToolResult],
257
432
  );
258
433
 
259
434
  const clear = useCallback(() => {
@@ -264,6 +439,9 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
264
439
  setMessages([]);
265
440
  setIsStreaming(false);
266
441
  setError(null);
442
+ pendingContentRef.current = '';
443
+ pendingReasoningRef.current = '';
444
+ toolInvocationsRef.current = [];
267
445
  }, []);
268
446
 
269
447
  return { messages, send, isStreaming, clear, error };