@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.
- package/package.json +15 -3
- package/src/components/AliaChatInput.tsx +1 -1
- package/src/components/AliaChatMessageList.tsx +85 -32
- package/src/components/AliaChatSheet.tsx +14 -18
- 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/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 +265 -87
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +180 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +40 -6
- package/src/optional-deps.d.ts +69 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +59 -1
|
@@ -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
|
+
}
|
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -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.
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
98
|
-
for (const msg of
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
}
|
|
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,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
|
-
|
|
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
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 };
|