@alia.onl/sdk 1.0.0 → 3.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.
- package/package.json +37 -3
- package/src/components/AliaChatContent.tsx +313 -0
- package/src/components/AliaChatInput.tsx +174 -27
- package/src/components/AliaChatMessageList.tsx +220 -34
- package/src/components/AliaChatScreen.tsx +132 -0
- package/src/components/AliaChatSheet.tsx +36 -49
- package/src/components/AliaChatSuggestions.tsx +1 -1
- package/src/components/AliaFace.tsx +402 -0
- package/src/components/Markdown.tsx +288 -0
- package/src/components/Reasoning.tsx +154 -0
- package/src/components/ThinkingIndicator.tsx +134 -0
- package/src/components/ui/button.tsx +104 -0
- package/src/components/ui/chat-text-input.tsx +164 -0
- package/src/components/ui/dropdown-menu.tsx +2 -0
- package/src/components/ui/dropdown-menu.web.tsx +355 -0
- package/src/components/ui/prompt-input/actions.tsx +28 -0
- package/src/components/ui/prompt-input/add-menu.tsx +83 -0
- package/src/components/ui/prompt-input/attachments.tsx +183 -0
- package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
- package/src/components/ui/prompt-input/context.tsx +71 -0
- package/src/components/ui/prompt-input/mic-button.tsx +53 -0
- package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
- package/src/components/ui/prompt-input/submit-button.tsx +49 -0
- package/src/components/ui/prompt-input/textarea.tsx +64 -0
- package/src/components/ui/text.tsx +29 -0
- package/src/components/voice/AudioWaveVisualizer.tsx +377 -0
- package/src/components/voice/VoiceControls.tsx +180 -0
- package/src/components/voice/VoiceOverlay.tsx +43 -0
- package/src/hooks/useAliaChat.ts +278 -91
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +182 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +71 -6
- package/src/lib/keyboard.native.tsx +6 -0
- package/src/lib/keyboard.tsx +25 -0
- package/src/lib/utils.ts +14 -0
- package/src/optional-deps.d.ts +86 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +63 -1
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { useState, useRef, useCallback } from 'react';
|
|
1
|
+
import React, { 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.
|
|
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:
|
|
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;
|
|
@@ -17,22 +17,27 @@ export interface UseAliaChatOptions {
|
|
|
17
17
|
|
|
18
18
|
export interface UseAliaChatReturn {
|
|
19
19
|
messages: ChatMessage[];
|
|
20
|
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
20
21
|
send: (text: string) => void;
|
|
21
22
|
isStreaming: boolean;
|
|
23
|
+
stop: () => void;
|
|
22
24
|
clear: () => void;
|
|
23
25
|
error: string | null;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
let nextId = 0;
|
|
27
28
|
function generateId(): string {
|
|
28
|
-
|
|
29
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
30
|
+
return `msg-${crypto.randomUUID()}`;
|
|
31
|
+
}
|
|
32
|
+
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* SSE streaming chat hook for Alia.
|
|
33
37
|
*
|
|
34
38
|
* Sends messages to Alia's /v1/chat/completions endpoint and streams
|
|
35
|
-
* responses back, including tool invocations
|
|
39
|
+
* responses back, including tool invocations, reasoning, research progress,
|
|
40
|
+
* and plan previews.
|
|
36
41
|
*/
|
|
37
42
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
38
43
|
const {
|
|
@@ -48,11 +53,98 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
48
53
|
const [error, setError] = useState<string | null>(null);
|
|
49
54
|
const abortRef = useRef<AbortController | null>(null);
|
|
50
55
|
|
|
56
|
+
// Use a ref to read current messages without putting it in send's dep array
|
|
57
|
+
const messagesRef = useRef<ChatMessage[]>([]);
|
|
58
|
+
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
|
59
|
+
|
|
60
|
+
// Batching: accumulate streaming content and flush at ~20fps
|
|
61
|
+
const pendingContentRef = useRef('');
|
|
62
|
+
const pendingReasoningRef = useRef('');
|
|
63
|
+
const toolInvocationsRef = useRef<ToolInvocation[]>([]);
|
|
64
|
+
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
65
|
+
|
|
66
|
+
const flushPendingUpdates = useCallback(() => {
|
|
67
|
+
const content = pendingContentRef.current;
|
|
68
|
+
const reasoning = pendingReasoningRef.current;
|
|
69
|
+
const tools = toolInvocationsRef.current;
|
|
70
|
+
if (!content && !reasoning && tools.length === 0) return;
|
|
71
|
+
|
|
72
|
+
pendingContentRef.current = '';
|
|
73
|
+
pendingReasoningRef.current = '';
|
|
74
|
+
|
|
75
|
+
setMessages((prev) => {
|
|
76
|
+
const updated = [...prev];
|
|
77
|
+
const last = updated[updated.length - 1];
|
|
78
|
+
if (last?.role === 'assistant') {
|
|
79
|
+
const changes: Partial<ChatMessage> = {};
|
|
80
|
+
if (content) changes.content = last.content + content;
|
|
81
|
+
if (reasoning) changes.thinking = (last.thinking || '') + reasoning;
|
|
82
|
+
if (tools.length > 0) changes.toolInvocations = [...tools];
|
|
83
|
+
updated[updated.length - 1] = { ...last, ...changes };
|
|
84
|
+
}
|
|
85
|
+
return updated;
|
|
86
|
+
});
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const scheduleFlush = useCallback(() => {
|
|
90
|
+
if (flushTimerRef.current) return;
|
|
91
|
+
flushTimerRef.current = setTimeout(() => {
|
|
92
|
+
flushTimerRef.current = null;
|
|
93
|
+
flushPendingUpdates();
|
|
94
|
+
}, 50);
|
|
95
|
+
}, [flushPendingUpdates]);
|
|
96
|
+
|
|
97
|
+
// Cleanup flush timer on unmount
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
return () => {
|
|
100
|
+
flushPendingUpdates();
|
|
101
|
+
if (flushTimerRef.current) {
|
|
102
|
+
clearTimeout(flushTimerRef.current);
|
|
103
|
+
flushTimerRef.current = null;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}, [flushPendingUpdates]);
|
|
107
|
+
|
|
51
108
|
const getToken = useCallback((): string | null => {
|
|
52
109
|
if (accessTokenProp) return accessTokenProp;
|
|
53
110
|
return oxyServices.httpService.getAccessToken();
|
|
54
111
|
}, [accessTokenProp, oxyServices]);
|
|
55
112
|
|
|
113
|
+
/** Update the last assistant message with partial changes (callback receives current message) */
|
|
114
|
+
const updateAssistant = useCallback(
|
|
115
|
+
(updater: (msg: ChatMessage) => Partial<ChatMessage>) => {
|
|
116
|
+
setMessages((prev) => {
|
|
117
|
+
const updated = [...prev];
|
|
118
|
+
const last = updated[updated.length - 1];
|
|
119
|
+
if (last?.role === 'assistant') {
|
|
120
|
+
updated[updated.length - 1] = { ...last, ...updater(last) };
|
|
121
|
+
}
|
|
122
|
+
return updated;
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
[],
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
/** Apply a tool result to the last assistant message and sync the ref */
|
|
129
|
+
const applyToolResult = useCallback(
|
|
130
|
+
(toolCallId: string, name: string | undefined, output: any) => {
|
|
131
|
+
updateAssistant((last) => {
|
|
132
|
+
const invocations = [...(last.toolInvocations || [])];
|
|
133
|
+
const idx = invocations.findIndex((t) => t.toolCallId === toolCallId);
|
|
134
|
+
if (idx >= 0) {
|
|
135
|
+
invocations[idx] = { ...invocations[idx], state: 'result', result: output };
|
|
136
|
+
} else {
|
|
137
|
+
invocations.push({ toolCallId, toolName: name || 'unknown', state: 'result', result: output });
|
|
138
|
+
}
|
|
139
|
+
return { toolInvocations: invocations };
|
|
140
|
+
});
|
|
141
|
+
toolInvocationsRef.current = toolInvocationsRef.current.map((t) =>
|
|
142
|
+
t.toolCallId === toolCallId ? { ...t, state: 'result' as const, result: output } : t
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
[updateAssistant],
|
|
146
|
+
);
|
|
147
|
+
|
|
56
148
|
const send = useCallback(
|
|
57
149
|
async (text: string) => {
|
|
58
150
|
const trimmed = text.trim();
|
|
@@ -86,26 +178,40 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
86
178
|
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
|
87
179
|
setIsStreaming(true);
|
|
88
180
|
|
|
89
|
-
// Build messages payload for the API
|
|
181
|
+
// Build messages payload for the API (read from ref, not closure)
|
|
90
182
|
const apiMessages: Array<{ role: string; content: string }> = [];
|
|
91
183
|
|
|
92
|
-
// System context (tells Alia which app the user is in)
|
|
93
184
|
if (clientContext) {
|
|
94
185
|
apiMessages.push({ role: 'system', content: clientContext });
|
|
95
186
|
}
|
|
96
187
|
|
|
97
|
-
//
|
|
98
|
-
for (const msg of
|
|
188
|
+
// Include full conversation history with tool context
|
|
189
|
+
for (const msg of messagesRef.current) {
|
|
99
190
|
if (msg.role === 'system') continue;
|
|
100
191
|
apiMessages.push({ role: msg.role, content: msg.content });
|
|
192
|
+
// Include tool results so the AI has full context
|
|
193
|
+
if (msg.toolInvocations?.length) {
|
|
194
|
+
for (const tool of msg.toolInvocations) {
|
|
195
|
+
if (tool.state === 'result' && tool.result != null) {
|
|
196
|
+
apiMessages.push({
|
|
197
|
+
role: 'system',
|
|
198
|
+
content: `[Tool result from ${tool.toolName}: ${JSON.stringify(tool.result).slice(0, 500)}]`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
101
203
|
}
|
|
102
204
|
|
|
103
|
-
// New user message
|
|
104
205
|
apiMessages.push({ role: 'user', content: trimmed });
|
|
105
206
|
|
|
106
207
|
const controller = new AbortController();
|
|
107
208
|
abortRef.current = controller;
|
|
108
209
|
|
|
210
|
+
// Reset batching state
|
|
211
|
+
pendingContentRef.current = '';
|
|
212
|
+
pendingReasoningRef.current = '';
|
|
213
|
+
toolInvocationsRef.current = [];
|
|
214
|
+
|
|
109
215
|
try {
|
|
110
216
|
const response = await fetch(`${apiUrl}/v1/chat/completions`, {
|
|
111
217
|
method: 'POST',
|
|
@@ -130,23 +236,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
130
236
|
if (!response.body || typeof response.body.getReader !== 'function') {
|
|
131
237
|
const json = await response.json();
|
|
132
238
|
const content = json.choices?.[0]?.message?.content ?? '';
|
|
133
|
-
|
|
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
|
-
});
|
|
239
|
+
updateAssistant(() => ({ content }));
|
|
141
240
|
return;
|
|
142
241
|
}
|
|
143
242
|
|
|
144
|
-
// Stream SSE
|
|
243
|
+
// Stream SSE (supports named events: event: X\ndata: Y)
|
|
145
244
|
const reader = response.body.getReader();
|
|
146
245
|
const decoder = new TextDecoder();
|
|
147
246
|
let buffer = '';
|
|
148
|
-
let
|
|
149
|
-
const toolInvocations: ToolInvocation[] = [];
|
|
247
|
+
let currentEventType = '';
|
|
150
248
|
|
|
151
249
|
try {
|
|
152
250
|
while (true) {
|
|
@@ -158,72 +256,151 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
158
256
|
buffer = lines.pop() ?? '';
|
|
159
257
|
|
|
160
258
|
for (const line of lines) {
|
|
259
|
+
// Track named SSE event type
|
|
260
|
+
if (line.startsWith('event: ')) {
|
|
261
|
+
currentEventType = line.slice(7).trim();
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Reset event type on empty line (SSE event boundary)
|
|
266
|
+
if (line === '') {
|
|
267
|
+
currentEventType = '';
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
161
271
|
const trimmedLine = line.trim();
|
|
162
|
-
if (!trimmedLine
|
|
272
|
+
if (!trimmedLine.startsWith('data: ')) continue;
|
|
163
273
|
const data = trimmedLine.slice(6);
|
|
164
|
-
if (data === '[DONE]') continue;
|
|
274
|
+
if (data === '[DONE]') { currentEventType = ''; continue; }
|
|
165
275
|
|
|
166
276
|
try {
|
|
167
277
|
const parsed = JSON.parse(data);
|
|
168
278
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
279
|
+
// ── Named SSE events (Alia extensions) ──
|
|
280
|
+
if (currentEventType) {
|
|
281
|
+
switch (currentEventType) {
|
|
282
|
+
case 'alia.reasoning': {
|
|
283
|
+
const content = parsed.content;
|
|
284
|
+
if (content) {
|
|
285
|
+
pendingReasoningRef.current += content;
|
|
286
|
+
scheduleFlush();
|
|
287
|
+
}
|
|
288
|
+
currentEventType = '';
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
case 'alia.tool_result': {
|
|
292
|
+
const { tool_call_id, name, output } = parsed;
|
|
293
|
+
if (tool_call_id) {
|
|
294
|
+
applyToolResult(tool_call_id, name, output);
|
|
295
|
+
}
|
|
296
|
+
currentEventType = '';
|
|
297
|
+
continue;
|
|
182
298
|
}
|
|
183
|
-
|
|
184
|
-
|
|
299
|
+
case 'alia.research_progress': {
|
|
300
|
+
updateAssistant((last) => ({
|
|
301
|
+
researchProgress: {
|
|
302
|
+
phase: parsed.phase,
|
|
303
|
+
message: parsed.message,
|
|
304
|
+
subQuestions: parsed.subQuestions || last.researchProgress?.subQuestions,
|
|
305
|
+
sourcesFound: parsed.sourcesFound,
|
|
306
|
+
currentQuery: parsed.currentQuery,
|
|
307
|
+
iteration: parsed.iteration,
|
|
308
|
+
},
|
|
309
|
+
}));
|
|
310
|
+
currentEventType = '';
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
case 'alia.plan_preview': {
|
|
314
|
+
updateAssistant(() => ({
|
|
315
|
+
pendingPlan: {
|
|
316
|
+
planId: parsed.planId,
|
|
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();
|
|
185
367
|
}
|
|
186
368
|
|
|
187
|
-
//
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
369
|
+
// Text content (batched)
|
|
370
|
+
if (delta.content) {
|
|
371
|
+
pendingContentRef.current += delta.content;
|
|
372
|
+
scheduleFlush();
|
|
373
|
+
}
|
|
374
|
+
|
|
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 }; }
|
|
202
385
|
}
|
|
203
|
-
|
|
204
|
-
|
|
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];
|
|
393
|
+
}
|
|
394
|
+
flushPendingUpdates();
|
|
395
|
+
}
|
|
205
396
|
}
|
|
206
397
|
|
|
207
|
-
//
|
|
208
|
-
if (
|
|
209
|
-
const
|
|
210
|
-
|
|
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,34 +414,44 @@ 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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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,
|
|
431
|
+
[isStreaming, getToken, apiUrl, model, clientContext, scheduleFlush, flushPendingUpdates, updateAssistant, applyToolResult],
|
|
257
432
|
);
|
|
258
433
|
|
|
259
|
-
const
|
|
434
|
+
const stop = useCallback(() => {
|
|
260
435
|
if (abortRef.current) {
|
|
261
436
|
abortRef.current.abort();
|
|
262
437
|
abortRef.current = null;
|
|
263
438
|
}
|
|
264
|
-
|
|
439
|
+
flushPendingUpdates();
|
|
440
|
+
if (flushTimerRef.current) {
|
|
441
|
+
clearTimeout(flushTimerRef.current);
|
|
442
|
+
flushTimerRef.current = null;
|
|
443
|
+
}
|
|
265
444
|
setIsStreaming(false);
|
|
445
|
+
}, [flushPendingUpdates]);
|
|
446
|
+
|
|
447
|
+
const clear = useCallback(() => {
|
|
448
|
+
stop();
|
|
449
|
+
setMessages([]);
|
|
266
450
|
setError(null);
|
|
267
|
-
|
|
451
|
+
pendingContentRef.current = '';
|
|
452
|
+
pendingReasoningRef.current = '';
|
|
453
|
+
toolInvocationsRef.current = [];
|
|
454
|
+
}, [stop]);
|
|
268
455
|
|
|
269
|
-
return { messages, send, isStreaming, clear, error };
|
|
456
|
+
return { messages, setMessages, send, isStreaming, stop, clear, error };
|
|
270
457
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-time audio level monitoring using Web Audio API.
|
|
3
|
+
*
|
|
4
|
+
* Extracts RMS levels from LiveKit audio tracks (local mic + remote agents)
|
|
5
|
+
* and returns them at ~20fps for driving wave animations.
|
|
6
|
+
*
|
|
7
|
+
* Supports multiple remote audio tracks (primary + cohost) by taking
|
|
8
|
+
* the max RMS level across all subscribed remote tracks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useEffect, useRef } from 'react';
|
|
12
|
+
import { Room, RoomEvent, Track } from 'livekit-client';
|
|
13
|
+
|
|
14
|
+
/** Compute RMS (root mean square) amplitude from an AnalyserNode. Returns 0-1. */
|
|
15
|
+
function getRMS(analyser: AnalyserNode, buffer: any): number {
|
|
16
|
+
analyser.getFloatTimeDomainData(buffer);
|
|
17
|
+
let sum = 0;
|
|
18
|
+
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
|
|
19
|
+
return Math.min(1, Math.sqrt(sum / buffer.length) * 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAudioLevelMonitor(room: Room | null, isConnected: boolean) {
|
|
23
|
+
const [captureLevel, setCaptureLevel] = useState(0);
|
|
24
|
+
const [playbackLevel, setPlaybackLevel] = useState(0);
|
|
25
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Tear down previous monitors
|
|
29
|
+
cleanupRef.current?.();
|
|
30
|
+
cleanupRef.current = null;
|
|
31
|
+
|
|
32
|
+
if (!room || !isConnected) {
|
|
33
|
+
setCaptureLevel(0);
|
|
34
|
+
setPlaybackLevel(0);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Guard: Web Audio API required
|
|
39
|
+
if (typeof AudioContext === 'undefined') return;
|
|
40
|
+
|
|
41
|
+
const audioCtx = new AudioContext();
|
|
42
|
+
// Resume AudioContext (may be suspended until user gesture on mobile/WebView)
|
|
43
|
+
if (audioCtx.state === 'suspended') {
|
|
44
|
+
audioCtx.resume().catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let localAnalyser: AnalyserNode | null = null;
|
|
48
|
+
let localBuffer: Float32Array | null = null;
|
|
49
|
+
const remoteAnalysers = new Map<string, { analyser: AnalyserNode; buffer: Float32Array }>();
|
|
50
|
+
let rafId = 0;
|
|
51
|
+
let disposed = false;
|
|
52
|
+
|
|
53
|
+
// ---------- Local mic ----------
|
|
54
|
+
const localTrack = room.localParticipant?.getTrackPublication(Track.Source.Microphone)?.track;
|
|
55
|
+
if (localTrack?.mediaStreamTrack) {
|
|
56
|
+
try {
|
|
57
|
+
const source = audioCtx.createMediaStreamSource(new MediaStream([localTrack.mediaStreamTrack]));
|
|
58
|
+
localAnalyser = audioCtx.createAnalyser();
|
|
59
|
+
localAnalyser.fftSize = 256;
|
|
60
|
+
source.connect(localAnalyser);
|
|
61
|
+
localBuffer = new Float32Array(localAnalyser.fftSize);
|
|
62
|
+
} catch {
|
|
63
|
+
// MediaStream creation can fail in some environments
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------- Remote agent audio (supports multiple tracks) ----------
|
|
68
|
+
const setupRemoteAnalyser = (mediaStreamTrack: MediaStreamTrack, key: string) => {
|
|
69
|
+
if (remoteAnalysers.has(key) || disposed) return;
|
|
70
|
+
try {
|
|
71
|
+
const source = audioCtx.createMediaStreamSource(new MediaStream([mediaStreamTrack]));
|
|
72
|
+
const analyser = audioCtx.createAnalyser();
|
|
73
|
+
analyser.fftSize = 256;
|
|
74
|
+
source.connect(analyser);
|
|
75
|
+
const buffer = new Float32Array(analyser.fftSize);
|
|
76
|
+
remoteAnalysers.set(key, { analyser, buffer });
|
|
77
|
+
} catch {
|
|
78
|
+
// Ignore errors
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Check already-subscribed remote tracks
|
|
83
|
+
for (const [, p] of room.remoteParticipants) {
|
|
84
|
+
for (const [, pub] of p.audioTrackPublications) {
|
|
85
|
+
if (pub.track?.mediaStreamTrack) {
|
|
86
|
+
setupRemoteAnalyser(pub.track.mediaStreamTrack, `${p.identity}-${pub.trackSid}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Listen for new remote audio tracks
|
|
92
|
+
const onTrackSubscribed = (track: any, publication: any, participant: any) => {
|
|
93
|
+
if (track.kind === Track.Kind.Audio && track.mediaStreamTrack) {
|
|
94
|
+
setupRemoteAnalyser(track.mediaStreamTrack, `${participant.identity}-${publication.trackSid}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
|
98
|
+
|
|
99
|
+
// Clean up stale analysers when tracks are unsubscribed
|
|
100
|
+
const onTrackUnsubscribed = (track: any, publication: any, participant: any) => {
|
|
101
|
+
const key = `${participant.identity}-${publication.trackSid}`;
|
|
102
|
+
remoteAnalysers.delete(key);
|
|
103
|
+
};
|
|
104
|
+
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
|
105
|
+
|
|
106
|
+
// ---------- Poll loop (~20fps) ----------
|
|
107
|
+
let lastUpdate = 0;
|
|
108
|
+
const poll = (time: number) => {
|
|
109
|
+
if (disposed) return;
|
|
110
|
+
if (time - lastUpdate > 50) {
|
|
111
|
+
lastUpdate = time;
|
|
112
|
+
if (localAnalyser && localBuffer) {
|
|
113
|
+
setCaptureLevel(getRMS(localAnalyser, localBuffer));
|
|
114
|
+
}
|
|
115
|
+
// Take max RMS across all remote tracks (primary + cohost)
|
|
116
|
+
let maxRemoteLevel = 0;
|
|
117
|
+
for (const [, { analyser, buffer }] of remoteAnalysers) {
|
|
118
|
+
const level = getRMS(analyser, buffer);
|
|
119
|
+
if (level > maxRemoteLevel) maxRemoteLevel = level;
|
|
120
|
+
}
|
|
121
|
+
setPlaybackLevel(maxRemoteLevel);
|
|
122
|
+
}
|
|
123
|
+
rafId = requestAnimationFrame(poll);
|
|
124
|
+
};
|
|
125
|
+
rafId = requestAnimationFrame(poll);
|
|
126
|
+
|
|
127
|
+
// ---------- Cleanup ----------
|
|
128
|
+
const dispose = () => {
|
|
129
|
+
disposed = true;
|
|
130
|
+
cancelAnimationFrame(rafId);
|
|
131
|
+
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
|
132
|
+
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
|
133
|
+
remoteAnalysers.clear();
|
|
134
|
+
audioCtx.close().catch(() => {});
|
|
135
|
+
};
|
|
136
|
+
cleanupRef.current = dispose;
|
|
137
|
+
|
|
138
|
+
return dispose;
|
|
139
|
+
}, [room, isConnected]);
|
|
140
|
+
|
|
141
|
+
return { captureLevel, playbackLevel };
|
|
142
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
|
|
3
|
+
import type { AgentState } from '../types';
|
|
4
|
+
|
|
5
|
+
interface UseAudioLevelsOptions {
|
|
6
|
+
captureLevel: number;
|
|
7
|
+
playbackLevel: number;
|
|
8
|
+
agentState: AgentState;
|
|
9
|
+
isConnected: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useAudioLevels({
|
|
13
|
+
captureLevel,
|
|
14
|
+
playbackLevel,
|
|
15
|
+
agentState,
|
|
16
|
+
isConnected,
|
|
17
|
+
}: UseAudioLevelsOptions) {
|
|
18
|
+
const waveAmplitude = useSharedValue(0);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!isConnected) {
|
|
22
|
+
waveAmplitude.value = withTiming(0, { duration: 300 });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const amplifiedPlayback = Math.min(1, playbackLevel * 3);
|
|
27
|
+
|
|
28
|
+
let target = 0;
|
|
29
|
+
if (agentState === 'thinking') {
|
|
30
|
+
target = Math.max(0.15, captureLevel);
|
|
31
|
+
} else if (agentState === 'speaking') {
|
|
32
|
+
// 0.4 baseline ensures visible waves while AI speaks
|
|
33
|
+
target = Math.max(0.4, amplifiedPlayback, captureLevel * 0.5);
|
|
34
|
+
} else if (agentState === 'listening') {
|
|
35
|
+
// Subtle pulse while listening; mic level takes over when available
|
|
36
|
+
target = Math.max(0.08, captureLevel, amplifiedPlayback * 0.5);
|
|
37
|
+
} else {
|
|
38
|
+
// Idle — calm, no minimum
|
|
39
|
+
target = Math.max(captureLevel, amplifiedPlayback * 0.5);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fast attack, slower decay for natural VU-meter feel
|
|
43
|
+
const duration = target > waveAmplitude.value ? 60 : 200;
|
|
44
|
+
waveAmplitude.value = withTiming(target, {
|
|
45
|
+
duration,
|
|
46
|
+
easing: Easing.bezier(0.33, 1, 0.68, 1),
|
|
47
|
+
});
|
|
48
|
+
}, [captureLevel, playbackLevel, agentState, isConnected]);
|
|
49
|
+
|
|
50
|
+
return { waveAmplitude };
|
|
51
|
+
}
|