@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,233 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useSharedValue,
|
|
4
|
+
withRepeat,
|
|
5
|
+
withSequence,
|
|
6
|
+
withTiming,
|
|
7
|
+
cancelAnimation,
|
|
8
|
+
Easing,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
import { useOxy } from '@oxyhq/services';
|
|
11
|
+
import { create } from 'zustand';
|
|
12
|
+
|
|
13
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
14
|
+
|
|
15
|
+
// ============== OPTIONS ==============
|
|
16
|
+
|
|
17
|
+
export interface UseTTSOptions {
|
|
18
|
+
apiUrl?: string;
|
|
19
|
+
accessToken?: string;
|
|
20
|
+
voice?: 'male' | 'female';
|
|
21
|
+
tone?: 'brief' | 'chill' | 'default';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============== INLINE TTS STORE ==============
|
|
25
|
+
|
|
26
|
+
type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
|
27
|
+
|
|
28
|
+
interface TTSStore {
|
|
29
|
+
activeMessageId: string | null;
|
|
30
|
+
playbackState: PlaybackState;
|
|
31
|
+
error: string | null;
|
|
32
|
+
setActiveMessage: (id: string | null) => void;
|
|
33
|
+
setPlaybackState: (state: PlaybackState) => void;
|
|
34
|
+
setError: (error: string | null) => void;
|
|
35
|
+
reset: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const useTTSStore = create<TTSStore>((set) => ({
|
|
39
|
+
activeMessageId: null,
|
|
40
|
+
playbackState: 'idle',
|
|
41
|
+
error: null,
|
|
42
|
+
setActiveMessage: (id) => set({ activeMessageId: id }),
|
|
43
|
+
setPlaybackState: (playbackState) => set({ playbackState }),
|
|
44
|
+
setError: (error) => set({ error, playbackState: error ? 'error' : 'idle' }),
|
|
45
|
+
reset: () => set({ activeMessageId: null, playbackState: 'idle', error: null }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// ============== HOOK ==============
|
|
49
|
+
|
|
50
|
+
export function useTTS(options: UseTTSOptions = {}) {
|
|
51
|
+
const apiUrl = options.apiUrl || API_URL;
|
|
52
|
+
const voicePref = options.voice ?? 'female';
|
|
53
|
+
const tonePref = options.tone ?? 'default';
|
|
54
|
+
|
|
55
|
+
const { oxyServices } = useOxy();
|
|
56
|
+
const {
|
|
57
|
+
activeMessageId,
|
|
58
|
+
playbackState,
|
|
59
|
+
error,
|
|
60
|
+
setActiveMessage,
|
|
61
|
+
setPlaybackState,
|
|
62
|
+
setError,
|
|
63
|
+
reset,
|
|
64
|
+
} = useTTSStore();
|
|
65
|
+
|
|
66
|
+
const playerRef = useRef<any>(null);
|
|
67
|
+
|
|
68
|
+
// Simulated wave amplitude for visualization
|
|
69
|
+
const ttsWaveAmplitude = useSharedValue(0);
|
|
70
|
+
|
|
71
|
+
// ============== AUTH ==============
|
|
72
|
+
|
|
73
|
+
const getToken = useCallback((): string | null => {
|
|
74
|
+
if (options.accessToken) return options.accessToken;
|
|
75
|
+
return oxyServices.httpService.getAccessToken();
|
|
76
|
+
}, [options.accessToken, oxyServices]);
|
|
77
|
+
|
|
78
|
+
// Animate wave when playing
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (playbackState === 'playing') {
|
|
81
|
+
ttsWaveAmplitude.value = withRepeat(
|
|
82
|
+
withSequence(
|
|
83
|
+
withTiming(0.5, { duration: 800, easing: Easing.inOut(Easing.sin) }),
|
|
84
|
+
withTiming(0.2, { duration: 600, easing: Easing.inOut(Easing.sin) }),
|
|
85
|
+
withTiming(0.6, { duration: 700, easing: Easing.inOut(Easing.sin) }),
|
|
86
|
+
withTiming(0.15, { duration: 500, easing: Easing.inOut(Easing.sin) }),
|
|
87
|
+
),
|
|
88
|
+
-1,
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
cancelAnimation(ttsWaveAmplitude);
|
|
92
|
+
ttsWaveAmplitude.value = withTiming(0, { duration: 300 });
|
|
93
|
+
}
|
|
94
|
+
}, [playbackState]);
|
|
95
|
+
|
|
96
|
+
const getTTSVoice = useCallback(() => {
|
|
97
|
+
return voicePref === 'male' ? 'echo' : 'nova';
|
|
98
|
+
}, [voicePref]);
|
|
99
|
+
|
|
100
|
+
const getTTSSpeed = useCallback(() => {
|
|
101
|
+
if (tonePref === 'brief') return 1.15;
|
|
102
|
+
if (tonePref === 'chill') return 0.9;
|
|
103
|
+
return 1.0;
|
|
104
|
+
}, [tonePref]);
|
|
105
|
+
|
|
106
|
+
const releasePlayer = useCallback(() => {
|
|
107
|
+
try {
|
|
108
|
+
playerRef.current?.remove();
|
|
109
|
+
} catch {}
|
|
110
|
+
playerRef.current = null;
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const stop = useCallback(() => {
|
|
114
|
+
releasePlayer();
|
|
115
|
+
reset();
|
|
116
|
+
}, [reset, releasePlayer]);
|
|
117
|
+
|
|
118
|
+
const playFromUrl = useCallback((audioUrl: string, _messageId: string) => {
|
|
119
|
+
releasePlayer();
|
|
120
|
+
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const { createAudioPlayer } = await import('expo-audio');
|
|
124
|
+
const player = createAudioPlayer({ uri: audioUrl });
|
|
125
|
+
playerRef.current = player;
|
|
126
|
+
|
|
127
|
+
player.addListener('playbackStatusUpdate', (status: any) => {
|
|
128
|
+
if (status.didJustFinish) {
|
|
129
|
+
releasePlayer();
|
|
130
|
+
reset();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
player.play();
|
|
135
|
+
setPlaybackState('playing');
|
|
136
|
+
} catch {
|
|
137
|
+
setError('Audio playback not available');
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
}, [releasePlayer, reset, setPlaybackState, setError]);
|
|
141
|
+
|
|
142
|
+
const readAloud = useCallback(async (
|
|
143
|
+
messageId: string,
|
|
144
|
+
text: string,
|
|
145
|
+
conversationId?: string,
|
|
146
|
+
audioUrl?: string,
|
|
147
|
+
) => {
|
|
148
|
+
// If same message is playing, toggle pause/play
|
|
149
|
+
if (activeMessageId === messageId) {
|
|
150
|
+
if (playbackState === 'playing') {
|
|
151
|
+
playerRef.current?.pause();
|
|
152
|
+
setPlaybackState('paused');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (playbackState === 'paused') {
|
|
156
|
+
playerRef.current?.play();
|
|
157
|
+
setPlaybackState('playing');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Stop any current playback
|
|
163
|
+
if (activeMessageId) {
|
|
164
|
+
stop();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
setActiveMessage(messageId);
|
|
169
|
+
setPlaybackState('loading');
|
|
170
|
+
|
|
171
|
+
// If cached audioUrl exists, play directly
|
|
172
|
+
if (audioUrl) {
|
|
173
|
+
playFromUrl(audioUrl, messageId);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Call backend TTS API
|
|
178
|
+
const token = getToken();
|
|
179
|
+
if (!token) {
|
|
180
|
+
throw new Error('Not authenticated');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = await fetch(`${apiUrl}/v1/audio/speech`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/json',
|
|
187
|
+
Authorization: `Bearer ${token}`,
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
model: 'alia-v1-voice',
|
|
191
|
+
input: text,
|
|
192
|
+
voice: getTTSVoice(),
|
|
193
|
+
speed: getTTSSpeed(),
|
|
194
|
+
conversationId,
|
|
195
|
+
messageId,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
if (response.status === 504) {
|
|
201
|
+
throw new Error('Request timed out — please try again');
|
|
202
|
+
}
|
|
203
|
+
const errData = await response.json().catch(() => ({}));
|
|
204
|
+
throw new Error(errData.error?.message || errData.error || 'TTS failed');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const data = await response.json();
|
|
208
|
+
playFromUrl(data.audioUrl, messageId);
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
console.error('[TTS] Error:', e);
|
|
211
|
+
setError(e.message || 'Failed to read aloud');
|
|
212
|
+
}
|
|
213
|
+
}, [activeMessageId, playbackState, getToken, apiUrl, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
|
|
214
|
+
|
|
215
|
+
// Cleanup on unmount
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
return () => {
|
|
218
|
+
releasePlayer();
|
|
219
|
+
};
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
readAloud,
|
|
224
|
+
stop,
|
|
225
|
+
activeMessageId,
|
|
226
|
+
playbackState,
|
|
227
|
+
error,
|
|
228
|
+
ttsWaveAmplitude,
|
|
229
|
+
isPlaying: playbackState === 'playing',
|
|
230
|
+
isPaused: playbackState === 'paused',
|
|
231
|
+
isLoading: playbackState === 'loading',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for real-time voice conversations via LiveKit rooms.
|
|
3
|
+
*
|
|
4
|
+
* Connects to a LiveKit room (created by POST /v1/voice/token),
|
|
5
|
+
* publishes the user's microphone, and receives agent audio + data
|
|
6
|
+
* messages (transcripts, state, cohost events) over WebRTC.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
Room,
|
|
12
|
+
RoomEvent,
|
|
13
|
+
Track,
|
|
14
|
+
DisconnectReason,
|
|
15
|
+
type RemoteTrackPublication,
|
|
16
|
+
type RemoteParticipant,
|
|
17
|
+
type DataPublishOptions,
|
|
18
|
+
} from 'livekit-client';
|
|
19
|
+
import { useOxy } from '@oxyhq/services';
|
|
20
|
+
import type { RoomState, AgentState, VoiceMessage, VoiceToolInvocation } from '../types';
|
|
21
|
+
|
|
22
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
23
|
+
|
|
24
|
+
// ============== OPTIONS ==============
|
|
25
|
+
|
|
26
|
+
export interface UseVoiceRoomOptions {
|
|
27
|
+
apiUrl?: string;
|
|
28
|
+
voicePreference?: 'male' | 'female';
|
|
29
|
+
accessToken?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ============== TITLE TAG UTILITIES ==============
|
|
33
|
+
|
|
34
|
+
const TAG = String.raw`ALIA_TITLE|TITLE|TÍTULO|TITRE|TITOLO|TITEL|ЗАГОЛОВОК`;
|
|
35
|
+
|
|
36
|
+
const TITLE_STRIP_RE = new RegExp(
|
|
37
|
+
String.raw`\[(${TAG})\].*?\[\/\1\]|<(${TAG})>.*?<\/\2>`, 'gi',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const TITLE_PARTIAL_RE = new RegExp(
|
|
41
|
+
String.raw`\[(${TAG})\].*?(\[\/\1\])?$|<(${TAG})>.*?(<\/\3>)?$`, 'si',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function stripTitleTags(content: string): string {
|
|
45
|
+
return content.replace(TITLE_STRIP_RE, '').trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stripTitleTagsPartial(content: string): string {
|
|
49
|
+
return content.replace(TITLE_STRIP_RE, '').replace(TITLE_PARTIAL_RE, '').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============== INTERNAL DATA MESSAGE TYPES ==============
|
|
53
|
+
|
|
54
|
+
interface AgentStateMsg { type: 'agent.state'; state: 'listening' | 'thinking' | 'speaking'; speaker: 'primary' | 'cohost' }
|
|
55
|
+
interface TranscriptDeltaMsg { type: 'transcript.delta'; delta: string; speaker: 'primary' | 'cohost' }
|
|
56
|
+
interface TranscriptDoneMsg { type: 'transcript.done'; transcript: string; speaker: 'primary' | 'cohost' }
|
|
57
|
+
interface TranscriptUserMsg { type: 'transcript.user'; transcript: string }
|
|
58
|
+
interface CohostEnabledMsg { type: 'cohost.enabled' }
|
|
59
|
+
interface CohostDisabledMsg { type: 'cohost.disabled' }
|
|
60
|
+
interface CohostTurnMsg { type: 'cohost.turn_changed'; speaker: 'primary' | 'cohost' | 'user' }
|
|
61
|
+
interface CohostRoundMsg { type: 'cohost.round_complete'; turns: number }
|
|
62
|
+
interface ToolCallMsg { type: 'tool.call'; toolName: string; callId: string; args?: any; speaker: 'primary' | 'cohost' }
|
|
63
|
+
interface ToolResultMsg { type: 'tool.result'; callId: string; speaker: 'primary' | 'cohost' }
|
|
64
|
+
interface SessionEndedMsg { type: 'session.ended'; reason: string }
|
|
65
|
+
interface ErrorMsg { type: 'error'; code: string; message: string }
|
|
66
|
+
|
|
67
|
+
type AgentDataMessage =
|
|
68
|
+
| AgentStateMsg | TranscriptDeltaMsg | TranscriptDoneMsg | TranscriptUserMsg
|
|
69
|
+
| CohostEnabledMsg | CohostDisabledMsg | CohostTurnMsg | CohostRoundMsg
|
|
70
|
+
| ToolCallMsg | ToolResultMsg | SessionEndedMsg | ErrorMsg;
|
|
71
|
+
|
|
72
|
+
// ============== HOOK ==============
|
|
73
|
+
|
|
74
|
+
export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
|
|
75
|
+
const apiUrl = options.apiUrl || API_URL;
|
|
76
|
+
const voicePref = options.voicePreference ?? 'female';
|
|
77
|
+
|
|
78
|
+
const [roomState, setRoomState] = useState<RoomState>('disconnected');
|
|
79
|
+
const [agentState, setAgentState] = useState<AgentState>('idle');
|
|
80
|
+
const [isMuted, setIsMuted] = useState(false);
|
|
81
|
+
const [error, setError] = useState<string | null>(null);
|
|
82
|
+
const [messages, setMessages] = useState<VoiceMessage[]>([]);
|
|
83
|
+
const [cohostActive, setCohostActive] = useState(false);
|
|
84
|
+
const [currentSpeaker, setCurrentSpeaker] = useState<'primary' | 'cohost' | 'user' | null>(null);
|
|
85
|
+
const [roundComplete, setRoundComplete] = useState(false);
|
|
86
|
+
|
|
87
|
+
const roomRef = useRef<Room | null>(null);
|
|
88
|
+
const sessionIdRef = useRef<string | null>(null);
|
|
89
|
+
const msgIdRef = useRef(0);
|
|
90
|
+
const sessionPrefixRef = useRef(`vm-${Date.now().toString(36)}`);
|
|
91
|
+
|
|
92
|
+
// Per-speaker streaming transcript refs
|
|
93
|
+
const primaryTextRef = useRef('');
|
|
94
|
+
const cohostTextRef = useRef('');
|
|
95
|
+
|
|
96
|
+
const { oxyServices } = useOxy();
|
|
97
|
+
|
|
98
|
+
// ============== AUTH ==============
|
|
99
|
+
|
|
100
|
+
const getToken = useCallback((): string | null => {
|
|
101
|
+
if (options.accessToken) return options.accessToken;
|
|
102
|
+
return oxyServices.httpService.getAccessToken();
|
|
103
|
+
}, [options.accessToken, oxyServices]);
|
|
104
|
+
|
|
105
|
+
// ============== CLEANUP ==============
|
|
106
|
+
|
|
107
|
+
const cleanup = useCallback(() => {
|
|
108
|
+
if (roomRef.current) {
|
|
109
|
+
// Detach all remote audio tracks before disconnecting
|
|
110
|
+
roomRef.current.remoteParticipants.forEach((p) => {
|
|
111
|
+
p.trackPublications.forEach((pub) => {
|
|
112
|
+
pub.track?.detach();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
roomRef.current.disconnect();
|
|
116
|
+
roomRef.current = null;
|
|
117
|
+
}
|
|
118
|
+
sessionIdRef.current = null;
|
|
119
|
+
primaryTextRef.current = '';
|
|
120
|
+
cohostTextRef.current = '';
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
// ============== DATA MESSAGE HANDLER ==============
|
|
124
|
+
|
|
125
|
+
const handleDataMessage = useCallback((payload: Uint8Array) => {
|
|
126
|
+
try {
|
|
127
|
+
const msg: AgentDataMessage = JSON.parse(new TextDecoder().decode(payload));
|
|
128
|
+
|
|
129
|
+
switch (msg.type) {
|
|
130
|
+
case 'agent.state': {
|
|
131
|
+
// Always update agentState so both primary and cohost drive wave animation
|
|
132
|
+
setAgentState(msg.state);
|
|
133
|
+
setCurrentSpeaker(msg.speaker);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'transcript.delta': {
|
|
138
|
+
const textRef = msg.speaker === 'cohost' ? cohostTextRef : primaryTextRef;
|
|
139
|
+
textRef.current += msg.delta;
|
|
140
|
+
const text = stripTitleTagsPartial(textRef.current);
|
|
141
|
+
setMessages(prev => {
|
|
142
|
+
const last = prev[prev.length - 1];
|
|
143
|
+
if (last && last.role === 'assistant' && last.isStreaming && last.speaker === msg.speaker) {
|
|
144
|
+
return [...prev.slice(0, -1), { ...last, content: text }];
|
|
145
|
+
}
|
|
146
|
+
msgIdRef.current++;
|
|
147
|
+
return [...prev, {
|
|
148
|
+
id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
|
|
149
|
+
role: 'assistant',
|
|
150
|
+
speaker: msg.speaker,
|
|
151
|
+
content: text,
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
isStreaming: true,
|
|
154
|
+
}];
|
|
155
|
+
});
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case 'transcript.done': {
|
|
160
|
+
const textRef = msg.speaker === 'cohost' ? cohostTextRef : primaryTextRef;
|
|
161
|
+
textRef.current = '';
|
|
162
|
+
const clean = stripTitleTags(msg.transcript);
|
|
163
|
+
setMessages(prev => {
|
|
164
|
+
const last = prev[prev.length - 1];
|
|
165
|
+
if (last && last.role === 'assistant' && last.isStreaming && last.speaker === msg.speaker) {
|
|
166
|
+
return [...prev.slice(0, -1), { ...last, content: clean, isStreaming: false }];
|
|
167
|
+
}
|
|
168
|
+
return prev;
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'transcript.user': {
|
|
174
|
+
if (!msg.transcript.trim()) break;
|
|
175
|
+
const transcript = msg.transcript.trim();
|
|
176
|
+
setMessages(prev => {
|
|
177
|
+
const last = prev[prev.length - 1];
|
|
178
|
+
// Update existing user message instead of creating duplicates
|
|
179
|
+
if (last && last.role === 'user') {
|
|
180
|
+
return [...prev.slice(0, -1), { ...last, content: transcript }];
|
|
181
|
+
}
|
|
182
|
+
msgIdRef.current++;
|
|
183
|
+
return [...prev, {
|
|
184
|
+
id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
|
|
185
|
+
role: 'user',
|
|
186
|
+
content: transcript,
|
|
187
|
+
timestamp: Date.now(),
|
|
188
|
+
isStreaming: false,
|
|
189
|
+
}];
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
case 'cohost.enabled':
|
|
195
|
+
setCohostActive(true);
|
|
196
|
+
break;
|
|
197
|
+
|
|
198
|
+
case 'cohost.disabled':
|
|
199
|
+
setCohostActive(false);
|
|
200
|
+
setCurrentSpeaker(null);
|
|
201
|
+
break;
|
|
202
|
+
|
|
203
|
+
case 'cohost.turn_changed':
|
|
204
|
+
setCurrentSpeaker(msg.speaker);
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case 'cohost.round_complete':
|
|
208
|
+
setRoundComplete(true);
|
|
209
|
+
break;
|
|
210
|
+
|
|
211
|
+
case 'tool.call': {
|
|
212
|
+
const tool: VoiceToolInvocation = { toolCallId: msg.callId, toolName: msg.toolName, state: 'call', args: msg.args };
|
|
213
|
+
setMessages(prev => {
|
|
214
|
+
const last = prev[prev.length - 1];
|
|
215
|
+
if (last && last.role === 'assistant' && last.speaker === msg.speaker) {
|
|
216
|
+
return [...prev.slice(0, -1), {
|
|
217
|
+
...last,
|
|
218
|
+
toolInvocations: [...(last.toolInvocations || []), tool],
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
msgIdRef.current++;
|
|
222
|
+
return [...prev, {
|
|
223
|
+
id: `${sessionPrefixRef.current}-${msgIdRef.current}`,
|
|
224
|
+
role: 'assistant' as const,
|
|
225
|
+
speaker: msg.speaker,
|
|
226
|
+
content: '',
|
|
227
|
+
timestamp: Date.now(),
|
|
228
|
+
isStreaming: true,
|
|
229
|
+
toolInvocations: [tool],
|
|
230
|
+
}];
|
|
231
|
+
});
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
case 'tool.result': {
|
|
236
|
+
setMessages(prev => prev.map(m => {
|
|
237
|
+
if (!m.toolInvocations) return m;
|
|
238
|
+
const updated = m.toolInvocations.map(t =>
|
|
239
|
+
t.toolCallId === msg.callId ? { ...t, state: 'result' as const } : t
|
|
240
|
+
);
|
|
241
|
+
return { ...m, toolInvocations: updated };
|
|
242
|
+
}));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'session.ended': {
|
|
247
|
+
cleanup();
|
|
248
|
+
setRoomState('disconnected');
|
|
249
|
+
setAgentState('idle');
|
|
250
|
+
const reason = msg.reason;
|
|
251
|
+
setError(
|
|
252
|
+
reason === 'user_silent' ? 'Call ended due to inactivity'
|
|
253
|
+
: reason === 'user_unresponsive' ? 'Call ended — no response'
|
|
254
|
+
: reason === 'max_duration_exceeded' ? 'Voice minutes limit reached. Upgrade for more.'
|
|
255
|
+
: reason === 'credits_exhausted' ? 'Not enough credits to continue. Add more or upgrade your plan.'
|
|
256
|
+
: 'Voice session ended'
|
|
257
|
+
);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case 'error':
|
|
262
|
+
setError(msg.message);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Ignore non-JSON data
|
|
267
|
+
}
|
|
268
|
+
}, [cleanup]);
|
|
269
|
+
|
|
270
|
+
// ============== CONNECT ==============
|
|
271
|
+
|
|
272
|
+
const connect = useCallback(async () => {
|
|
273
|
+
try {
|
|
274
|
+
setError(null);
|
|
275
|
+
setRoomState('connecting');
|
|
276
|
+
|
|
277
|
+
const token = getToken();
|
|
278
|
+
if (!token) {
|
|
279
|
+
setError('Not authenticated');
|
|
280
|
+
setRoomState('error');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Request session creation from backend
|
|
285
|
+
const resp = await fetch(`${apiUrl}/v1/voice/token`, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers: {
|
|
288
|
+
'Content-Type': 'application/json',
|
|
289
|
+
Authorization: `Bearer ${token}`,
|
|
290
|
+
},
|
|
291
|
+
body: JSON.stringify({
|
|
292
|
+
model: 'alia-v1-voice',
|
|
293
|
+
voice: voicePref === 'male' ? 'echo' : 'nova',
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (!resp.ok) {
|
|
298
|
+
const body = await resp.json().catch(() => ({}));
|
|
299
|
+
const errMsg = body?.error?.message || body?.error || `Voice session failed (${resp.status})`;
|
|
300
|
+
setError(errMsg);
|
|
301
|
+
setRoomState('error');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const { token: livekitToken, url, roomName, sessionId } = await resp.json();
|
|
306
|
+
sessionIdRef.current = sessionId;
|
|
307
|
+
|
|
308
|
+
// Create LiveKit room and connect
|
|
309
|
+
const room = new Room();
|
|
310
|
+
roomRef.current = room;
|
|
311
|
+
|
|
312
|
+
// Event: remote audio tracks (agent speaking) — must attach to play
|
|
313
|
+
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
|
314
|
+
if (track.kind === Track.Kind.Audio) {
|
|
315
|
+
track.attach();
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
320
|
+
track.detach();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Event: data messages from agent
|
|
324
|
+
room.on(RoomEvent.DataReceived, (payload: Uint8Array) => {
|
|
325
|
+
handleDataMessage(payload);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Event: disconnected
|
|
329
|
+
room.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
|
|
330
|
+
setRoomState('disconnected');
|
|
331
|
+
setAgentState('idle');
|
|
332
|
+
setCohostActive(false);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Connect to the room
|
|
336
|
+
await room.connect(url, livekitToken, { autoSubscribe: true });
|
|
337
|
+
|
|
338
|
+
// Enable microphone — audio goes to LiveKit, agent picks it up server-side
|
|
339
|
+
await room.localParticipant.setMicrophoneEnabled(true);
|
|
340
|
+
|
|
341
|
+
setRoomState('connected');
|
|
342
|
+
setAgentState('listening');
|
|
343
|
+
|
|
344
|
+
} catch (e: any) {
|
|
345
|
+
console.error('[useVoiceRoom] Connection error:', e);
|
|
346
|
+
setError(e.message || 'Failed to connect');
|
|
347
|
+
setRoomState('error');
|
|
348
|
+
cleanup();
|
|
349
|
+
}
|
|
350
|
+
}, [getToken, cleanup, handleDataMessage, apiUrl, voicePref]);
|
|
351
|
+
|
|
352
|
+
// ============== DISCONNECT ==============
|
|
353
|
+
|
|
354
|
+
const disconnect = useCallback(() => {
|
|
355
|
+
cleanup();
|
|
356
|
+
setRoomState('disconnected');
|
|
357
|
+
setAgentState('idle');
|
|
358
|
+
setError(null);
|
|
359
|
+
setIsMuted(false);
|
|
360
|
+
setMessages([]);
|
|
361
|
+
setCohostActive(false);
|
|
362
|
+
setCurrentSpeaker(null);
|
|
363
|
+
setRoundComplete(false);
|
|
364
|
+
}, [cleanup]);
|
|
365
|
+
|
|
366
|
+
// ============== MUTE ==============
|
|
367
|
+
|
|
368
|
+
const toggleMute = useCallback(() => {
|
|
369
|
+
const room = roomRef.current;
|
|
370
|
+
if (!room) return;
|
|
371
|
+
const next = !isMuted;
|
|
372
|
+
room.localParticipant.setMicrophoneEnabled(!next);
|
|
373
|
+
setIsMuted(next);
|
|
374
|
+
}, [isMuted]);
|
|
375
|
+
|
|
376
|
+
// ============== COHOST CONTROLS ==============
|
|
377
|
+
|
|
378
|
+
const sendClientData = useCallback((data: object) => {
|
|
379
|
+
const room = roomRef.current;
|
|
380
|
+
if (!room?.localParticipant) return;
|
|
381
|
+
const encoded = new TextEncoder().encode(JSON.stringify(data));
|
|
382
|
+
room.localParticipant.publishData(encoded, { reliable: true } as DataPublishOptions);
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
const enableCohost = useCallback(() => {
|
|
386
|
+
sendClientData({ type: 'cohost.enable' });
|
|
387
|
+
}, [sendClientData]);
|
|
388
|
+
|
|
389
|
+
const disableCohost = useCallback(() => {
|
|
390
|
+
sendClientData({ type: 'cohost.disable' });
|
|
391
|
+
}, [sendClientData]);
|
|
392
|
+
|
|
393
|
+
const continueCohost = useCallback(() => {
|
|
394
|
+
setRoundComplete(false);
|
|
395
|
+
sendClientData({ type: 'cohost.continue' });
|
|
396
|
+
}, [sendClientData]);
|
|
397
|
+
|
|
398
|
+
// ============== CLEANUP ON UNMOUNT ==============
|
|
399
|
+
|
|
400
|
+
useEffect(() => cleanup, [cleanup]);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
room: roomRef.current,
|
|
404
|
+
roomState,
|
|
405
|
+
agentState,
|
|
406
|
+
isMuted,
|
|
407
|
+
error,
|
|
408
|
+
messages,
|
|
409
|
+
cohostActive,
|
|
410
|
+
currentSpeaker,
|
|
411
|
+
roundComplete,
|
|
412
|
+
connect,
|
|
413
|
+
disconnect,
|
|
414
|
+
toggleMute,
|
|
415
|
+
enableCohost,
|
|
416
|
+
disableCohost,
|
|
417
|
+
continueCohost,
|
|
418
|
+
isConnected: roomState === 'connected',
|
|
419
|
+
};
|
|
420
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,51 @@
|
|
|
1
|
+
/// <reference path="./optional-deps.d.ts" />
|
|
1
2
|
// @alia.onl/sdk — Alia AI Chat SDK
|
|
2
|
-
// Reusable
|
|
3
|
+
// Reusable chat UI, voice, and streaming components for Oxy apps
|
|
3
4
|
|
|
4
|
-
// Main component
|
|
5
|
+
// ── Main component ──
|
|
5
6
|
export { AliaChatSheet } from './components/AliaChatSheet';
|
|
6
7
|
export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
|
|
7
8
|
|
|
8
|
-
//
|
|
9
|
+
// ── Chat UI components ──
|
|
10
|
+
export { AliaFace } from './components/AliaFace';
|
|
11
|
+
export type { AliaExpression, AliaFaceProps } from './components/AliaFace';
|
|
12
|
+
export { ThinkingIndicator } from './components/ThinkingIndicator';
|
|
13
|
+
export { AliaMarkdown } from './components/Markdown';
|
|
14
|
+
export { Reasoning, ReasoningTrigger, ReasoningContent } from './components/Reasoning';
|
|
15
|
+
|
|
16
|
+
// ── Voice components ──
|
|
17
|
+
export { AudioWaveVisualizer } from './components/voice/AudioWaveVisualizer';
|
|
18
|
+
export { VoiceOverlay } from './components/voice/VoiceOverlay';
|
|
19
|
+
export { VoiceControls } from './components/voice/VoiceControls';
|
|
20
|
+
|
|
21
|
+
// ── Chat hook ──
|
|
9
22
|
export { useAliaChat } from './hooks/useAliaChat';
|
|
10
23
|
export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat';
|
|
11
24
|
|
|
12
|
-
//
|
|
13
|
-
export
|
|
25
|
+
// ── Voice hooks ──
|
|
26
|
+
export { useVoiceRoom } from './hooks/useVoiceRoom';
|
|
27
|
+
export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
|
|
28
|
+
export { useAudioLevels } from './hooks/useAudioLevels';
|
|
29
|
+
export { useTTS } from './hooks/useTTS';
|
|
30
|
+
export type { UseTTSOptions } from './hooks/useTTS';
|
|
31
|
+
export { useSpeechToText } from './hooks/useSpeechToText';
|
|
32
|
+
export type { UseSTTOptions } from './hooks/useSpeechToText';
|
|
33
|
+
export { useSoundEffects, useVoiceSoundEffects } from './hooks/useSoundEffects';
|
|
34
|
+
export type { SoundName, SoundSources } from './hooks/useSoundEffects';
|
|
35
|
+
|
|
36
|
+
// ── Types ──
|
|
37
|
+
export type {
|
|
38
|
+
ChatMessage,
|
|
39
|
+
ToolInvocation,
|
|
40
|
+
AliaChatSuggestion,
|
|
41
|
+
ResearchProgress,
|
|
42
|
+
PendingPlan,
|
|
43
|
+
RoomState,
|
|
44
|
+
AgentState,
|
|
45
|
+
VoiceMessage,
|
|
46
|
+
VoiceToolInvocation,
|
|
47
|
+
} from './types';
|
|
14
48
|
|
|
15
|
-
// Theme
|
|
49
|
+
// ── Theme ──
|
|
16
50
|
export { useAliaColors } from './theme';
|
|
17
51
|
export type { AliaColors } from './theme';
|