@alia.onl/sdk 2.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 +25 -3
- package/src/components/AliaChatContent.tsx +313 -0
- package/src/components/AliaChatInput.tsx +173 -26
- package/src/components/AliaChatMessageList.tsx +136 -3
- package/src/components/AliaChatScreen.tsx +132 -0
- package/src/components/AliaChatSheet.tsx +31 -40
- 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/hooks/useAliaChat.ts +16 -7
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSpeechToText.ts +4 -2
- package/src/index.ts +33 -2
- 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 +17 -0
- package/src/types.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alia.onl/sdk",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -25,12 +25,34 @@
|
|
|
25
25
|
"react-native-keyboard-controller": ">=1",
|
|
26
26
|
"react-native-svg": ">=13",
|
|
27
27
|
"livekit-client": ">=2.0.0",
|
|
28
|
-
"expo-audio": ">=0.1.0"
|
|
28
|
+
"expo-audio": ">=0.1.0",
|
|
29
|
+
"nativewind": ">=4",
|
|
30
|
+
"clsx": ">=2",
|
|
31
|
+
"tailwind-merge": ">=2",
|
|
32
|
+
"class-variance-authority": ">=0.7",
|
|
33
|
+
"zeego": ">=3",
|
|
34
|
+
"expo-image": ">=1",
|
|
35
|
+
"expo-image-picker": ">=15",
|
|
36
|
+
"expo-document-picker": ">=12",
|
|
37
|
+
"expo-clipboard": ">=6",
|
|
38
|
+
"lucide-react-native": ">=0.300",
|
|
39
|
+
"@radix-ui/react-dropdown-menu": ">=2"
|
|
29
40
|
},
|
|
30
41
|
"peerDependenciesMeta": {
|
|
31
42
|
"livekit-client": { "optional": true },
|
|
32
43
|
"expo-audio": { "optional": true },
|
|
33
|
-
"react-native-svg": { "optional": true }
|
|
44
|
+
"react-native-svg": { "optional": true },
|
|
45
|
+
"nativewind": { "optional": true },
|
|
46
|
+
"clsx": { "optional": true },
|
|
47
|
+
"tailwind-merge": { "optional": true },
|
|
48
|
+
"class-variance-authority": { "optional": true },
|
|
49
|
+
"zeego": { "optional": true },
|
|
50
|
+
"expo-image": { "optional": true },
|
|
51
|
+
"expo-image-picker": { "optional": true },
|
|
52
|
+
"expo-document-picker": { "optional": true },
|
|
53
|
+
"expo-clipboard": { "optional": true },
|
|
54
|
+
"lucide-react-native": { "optional": true },
|
|
55
|
+
"@radix-ui/react-dropdown-menu": { "optional": true }
|
|
34
56
|
},
|
|
35
57
|
"devDependencies": {
|
|
36
58
|
"typescript": "^5.9.3"
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chat content component used by both AliaChatSheet and AliaChatScreen.
|
|
3
|
+
*
|
|
4
|
+
* Owns all chat + voice state internally. Containers only handle
|
|
5
|
+
* layout chrome (sheet modal, full-screen SafeAreaView, headers).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React, {
|
|
9
|
+
forwardRef,
|
|
10
|
+
useImperativeHandle,
|
|
11
|
+
useState,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useRef,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { View, Pressable, StyleSheet } from 'react-native';
|
|
18
|
+
import { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
|
|
19
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
20
|
+
import { Volume2 } from 'lucide-react-native';
|
|
21
|
+
import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
|
|
22
|
+
import { useVoiceRoom } from '../hooks/useVoiceRoom';
|
|
23
|
+
import { useAudioLevelMonitor } from '../hooks/useAudioLevelMonitor';
|
|
24
|
+
import { useAudioLevels } from '../hooks/useAudioLevels';
|
|
25
|
+
import { useTTS } from '../hooks/useTTS';
|
|
26
|
+
import { useSTTStore } from '../hooks/useSpeechToText';
|
|
27
|
+
import { VoiceOverlay } from './voice/VoiceOverlay';
|
|
28
|
+
import { VoiceControls } from './voice/VoiceControls';
|
|
29
|
+
import { AliaChatMessageList } from './AliaChatMessageList';
|
|
30
|
+
import { PromptInput } from './ui/prompt-input/prompt-input';
|
|
31
|
+
import { Button } from './ui/button';
|
|
32
|
+
import { AliaChatSuggestions } from './AliaChatSuggestions';
|
|
33
|
+
import type { AliaExpression } from './AliaFace';
|
|
34
|
+
import type { AliaChatSuggestion } from '../types';
|
|
35
|
+
import type { ChatMessage } from '../types';
|
|
36
|
+
import type { Completion } from './ui/prompt-input/context';
|
|
37
|
+
|
|
38
|
+
export interface AliaChatContentProps {
|
|
39
|
+
clientContext?: string;
|
|
40
|
+
suggestions?: AliaChatSuggestion[];
|
|
41
|
+
model?: string;
|
|
42
|
+
apiUrl?: string;
|
|
43
|
+
/** Shared value for scroll offset (used by sheet for pan-to-dismiss) */
|
|
44
|
+
scrollOffsetY?: SharedValue<number>;
|
|
45
|
+
/** Injectable autocomplete hook */
|
|
46
|
+
useSuggestions?: (query: string) => { data: Completion[] | undefined; isLoading: boolean };
|
|
47
|
+
/** Called when a suggestion is selected from autocomplete */
|
|
48
|
+
onSuggestionUsed?: (suggestionId: string) => void;
|
|
49
|
+
/** Error handler (e.g. toast) */
|
|
50
|
+
onError?: (message: string) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AliaChatContentRef {
|
|
54
|
+
messages: ChatMessage[];
|
|
55
|
+
isStreaming: boolean;
|
|
56
|
+
isVoiceActive: boolean;
|
|
57
|
+
faceExpression: AliaExpression;
|
|
58
|
+
clear: () => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Adapt a voice message into the chat message format. */
|
|
62
|
+
function adaptVoiceMessage(vm: any): ChatMessage {
|
|
63
|
+
return {
|
|
64
|
+
id: vm.id,
|
|
65
|
+
role: vm.role,
|
|
66
|
+
content: vm.content,
|
|
67
|
+
toolInvocations: vm.toolInvocations,
|
|
68
|
+
createdAt: Date.now(),
|
|
69
|
+
source: 'voice' as const,
|
|
70
|
+
speaker: vm.speaker,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const AliaChatContent = forwardRef<AliaChatContentRef, AliaChatContentProps>(
|
|
75
|
+
({ clientContext, suggestions = [], model, apiUrl, scrollOffsetY: externalScrollOffsetY, useSuggestions, onSuggestionUsed, onError }, ref) => {
|
|
76
|
+
// ── Chat ──
|
|
77
|
+
const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext };
|
|
78
|
+
const { messages, setMessages, send, isStreaming, stop, clear } = useAliaChat(chatOptions);
|
|
79
|
+
|
|
80
|
+
// ── Input state ──
|
|
81
|
+
const [inputValue, setInputValue] = useState('');
|
|
82
|
+
|
|
83
|
+
const handleSubmit = useCallback(() => {
|
|
84
|
+
const text = inputValue.trim();
|
|
85
|
+
if (!text) return;
|
|
86
|
+
send(text);
|
|
87
|
+
setInputValue('');
|
|
88
|
+
}, [inputValue, send]);
|
|
89
|
+
|
|
90
|
+
// ── Scroll offset (use external if provided, else internal) ──
|
|
91
|
+
const internalScrollOffsetY = useSharedValue(0);
|
|
92
|
+
const scrollOffsetY = externalScrollOffsetY ?? internalScrollOffsetY;
|
|
93
|
+
|
|
94
|
+
// ── Voice room ──
|
|
95
|
+
const voiceRoom = useVoiceRoom({ apiUrl });
|
|
96
|
+
const { captureLevel, playbackLevel } = useAudioLevelMonitor(voiceRoom.room, voiceRoom.isConnected);
|
|
97
|
+
const { waveAmplitude } = useAudioLevels({
|
|
98
|
+
captureLevel,
|
|
99
|
+
playbackLevel,
|
|
100
|
+
agentState: voiceRoom.agentState,
|
|
101
|
+
isConnected: voiceRoom.isConnected,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── TTS ──
|
|
105
|
+
const tts = useTTS({ apiUrl });
|
|
106
|
+
|
|
107
|
+
// ── STT wave overlay ──
|
|
108
|
+
const sttIsRecording = useSTTStore((s) => s.isRecording);
|
|
109
|
+
const sttMetering = useSTTStore((s) => s.metering);
|
|
110
|
+
const sttWaveAmplitude = useSharedValue(0);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!sttIsRecording) {
|
|
114
|
+
sttWaveAmplitude.value = withTiming(0, { duration: 300 });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const target = Math.max(0.08, sttMetering);
|
|
118
|
+
const duration = target > sttWaveAmplitude.value ? 60 : 200;
|
|
119
|
+
sttWaveAmplitude.value = withTiming(target, {
|
|
120
|
+
duration,
|
|
121
|
+
easing: Easing.bezier(0.33, 1, 0.68, 1),
|
|
122
|
+
});
|
|
123
|
+
}, [sttIsRecording, sttMetering]);
|
|
124
|
+
|
|
125
|
+
// ── Voice mode state ──
|
|
126
|
+
const [isVoiceActive, setIsVoiceActive] = useState(false);
|
|
127
|
+
const textSnapshotRef = useRef<ChatMessage[]>([]);
|
|
128
|
+
|
|
129
|
+
// Merge voice messages into chat messages
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!isVoiceActive) return;
|
|
132
|
+
const adapted = voiceRoom.messages.map(adaptVoiceMessage);
|
|
133
|
+
setMessages([...textSnapshotRef.current, ...adapted]);
|
|
134
|
+
}, [voiceRoom.messages, isVoiceActive, setMessages]);
|
|
135
|
+
|
|
136
|
+
// Auto-deactivate on error
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!isVoiceActive) return;
|
|
139
|
+
if (voiceRoom.error) {
|
|
140
|
+
deactivateVoice();
|
|
141
|
+
} else if (voiceRoom.roomState === 'error') {
|
|
142
|
+
deactivateVoice();
|
|
143
|
+
}
|
|
144
|
+
}, [voiceRoom.error, voiceRoom.roomState, isVoiceActive]);
|
|
145
|
+
|
|
146
|
+
// Auto-deactivate on unexpected disconnection
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!isVoiceActive) return;
|
|
149
|
+
if (voiceRoom.roomState === 'disconnected' && textSnapshotRef.current.length > 0) {
|
|
150
|
+
deactivateVoice();
|
|
151
|
+
}
|
|
152
|
+
}, [voiceRoom.roomState, isVoiceActive]);
|
|
153
|
+
|
|
154
|
+
const activateVoice = useCallback(() => {
|
|
155
|
+
if (isVoiceActive || voiceRoom.roomState === 'connecting') return;
|
|
156
|
+
textSnapshotRef.current = [...messages];
|
|
157
|
+
setIsVoiceActive(true);
|
|
158
|
+
voiceRoom.connect();
|
|
159
|
+
}, [isVoiceActive, voiceRoom, messages]);
|
|
160
|
+
|
|
161
|
+
const deactivateVoice = useCallback(() => {
|
|
162
|
+
voiceRoom.disconnect();
|
|
163
|
+
setIsVoiceActive(false);
|
|
164
|
+
textSnapshotRef.current = [];
|
|
165
|
+
}, [voiceRoom]);
|
|
166
|
+
|
|
167
|
+
// ── TTS read aloud handler ──
|
|
168
|
+
const handleReadAloud = useCallback(
|
|
169
|
+
(messageId: string, text: string) => {
|
|
170
|
+
tts.readAloud(messageId, text);
|
|
171
|
+
},
|
|
172
|
+
[tts],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// ── Face expression ──
|
|
176
|
+
const faceExpression = useMemo<AliaExpression>(() => {
|
|
177
|
+
if (isVoiceActive) {
|
|
178
|
+
if (voiceRoom.agentState === 'speaking') return 'Writing E';
|
|
179
|
+
if (voiceRoom.agentState === 'thinking') return 'Thinking';
|
|
180
|
+
return 'Idle A';
|
|
181
|
+
}
|
|
182
|
+
if (!isStreaming) return 'Idle A';
|
|
183
|
+
const lastMsg = messages[messages.length - 1];
|
|
184
|
+
if (lastMsg?.role === 'assistant' && lastMsg.toolInvocations?.some((t) => t.state === 'call'))
|
|
185
|
+
return 'Searching A';
|
|
186
|
+
if (lastMsg?.thinking) return 'Thinking';
|
|
187
|
+
return 'Writing E';
|
|
188
|
+
}, [messages, isStreaming, isVoiceActive, voiceRoom.agentState]);
|
|
189
|
+
|
|
190
|
+
const showSuggestions = messages.length === 0 && suggestions.length > 0;
|
|
191
|
+
|
|
192
|
+
// ── Overlay state ──
|
|
193
|
+
const showVoiceOverlay = isVoiceActive;
|
|
194
|
+
const showTTSOverlay = !isVoiceActive && tts.playbackState === 'playing';
|
|
195
|
+
const showSTTOverlay = !isVoiceActive && tts.playbackState !== 'playing' && sttIsRecording;
|
|
196
|
+
|
|
197
|
+
// ── Voice activate button for empty submit ──
|
|
198
|
+
const voiceActivateButton = useMemo(
|
|
199
|
+
() => (
|
|
200
|
+
<Button
|
|
201
|
+
size="icon"
|
|
202
|
+
onPress={activateVoice}
|
|
203
|
+
className="h-8 w-8 rounded-full"
|
|
204
|
+
>
|
|
205
|
+
<Volume2 size={16} color="white" />
|
|
206
|
+
</Button>
|
|
207
|
+
),
|
|
208
|
+
[activateVoice],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// ── Expose state to container ──
|
|
212
|
+
useImperativeHandle(
|
|
213
|
+
ref,
|
|
214
|
+
() => ({
|
|
215
|
+
messages,
|
|
216
|
+
isStreaming,
|
|
217
|
+
isVoiceActive,
|
|
218
|
+
faceExpression,
|
|
219
|
+
clear,
|
|
220
|
+
}),
|
|
221
|
+
[messages, isStreaming, isVoiceActive, faceExpression, clear],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<View style={styles.container}>
|
|
226
|
+
{/* Voice/TTS/STT wave overlay */}
|
|
227
|
+
{showVoiceOverlay && (
|
|
228
|
+
<VoiceOverlay
|
|
229
|
+
waveAmplitude={waveAmplitude}
|
|
230
|
+
agentState={voiceRoom.agentState}
|
|
231
|
+
isConnected={voiceRoom.isConnected}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
{showTTSOverlay && (
|
|
235
|
+
<VoiceOverlay
|
|
236
|
+
waveAmplitude={tts.ttsWaveAmplitude}
|
|
237
|
+
agentState="speaking"
|
|
238
|
+
isConnected={true}
|
|
239
|
+
/>
|
|
240
|
+
)}
|
|
241
|
+
{showSTTOverlay && (
|
|
242
|
+
<VoiceOverlay
|
|
243
|
+
waveAmplitude={sttWaveAmplitude}
|
|
244
|
+
agentState="listening"
|
|
245
|
+
isConnected={true}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Suggestions or Messages */}
|
|
250
|
+
{showSuggestions ? (
|
|
251
|
+
<AliaChatSuggestions suggestions={suggestions} onSelect={send} />
|
|
252
|
+
) : (
|
|
253
|
+
<AliaChatMessageList
|
|
254
|
+
messages={messages}
|
|
255
|
+
isStreaming={isStreaming}
|
|
256
|
+
scrollOffsetY={scrollOffsetY}
|
|
257
|
+
onReadAloud={handleReadAloud}
|
|
258
|
+
ttsActiveMessageId={tts.activeMessageId}
|
|
259
|
+
ttsPlaybackState={tts.playbackState}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Input or Voice Controls */}
|
|
264
|
+
{isVoiceActive ? (
|
|
265
|
+
<VoiceControls
|
|
266
|
+
roomState={voiceRoom.roomState}
|
|
267
|
+
agentState={voiceRoom.agentState}
|
|
268
|
+
isMuted={voiceRoom.isMuted}
|
|
269
|
+
cohostActive={voiceRoom.cohostActive}
|
|
270
|
+
currentSpeaker={voiceRoom.currentSpeaker}
|
|
271
|
+
roundComplete={voiceRoom.roundComplete}
|
|
272
|
+
onToggleMute={voiceRoom.toggleMute}
|
|
273
|
+
onEnableCohost={voiceRoom.enableCohost}
|
|
274
|
+
onDisableCohost={voiceRoom.disableCohost}
|
|
275
|
+
onContinueCohost={voiceRoom.continueCohost}
|
|
276
|
+
onEnd={deactivateVoice}
|
|
277
|
+
/>
|
|
278
|
+
) : (
|
|
279
|
+
<View style={styles.inputContainer}>
|
|
280
|
+
<PromptInput
|
|
281
|
+
value={inputValue}
|
|
282
|
+
onValueChange={setInputValue}
|
|
283
|
+
onSubmit={handleSubmit}
|
|
284
|
+
isLoading={isStreaming}
|
|
285
|
+
onStop={stop}
|
|
286
|
+
emptyAction={voiceActivateButton}
|
|
287
|
+
placeholder="Ask Alia..."
|
|
288
|
+
apiUrl={apiUrl}
|
|
289
|
+
autocomplete={!!useSuggestions}
|
|
290
|
+
useSuggestions={useSuggestions}
|
|
291
|
+
onSuggestionUsed={onSuggestionUsed}
|
|
292
|
+
onError={onError}
|
|
293
|
+
/>
|
|
294
|
+
</View>
|
|
295
|
+
)}
|
|
296
|
+
</View>
|
|
297
|
+
);
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
AliaChatContent.displayName = 'AliaChatContent';
|
|
302
|
+
|
|
303
|
+
const styles = StyleSheet.create({
|
|
304
|
+
container: {
|
|
305
|
+
flex: 1,
|
|
306
|
+
position: 'relative',
|
|
307
|
+
},
|
|
308
|
+
inputContainer: {
|
|
309
|
+
paddingHorizontal: 12,
|
|
310
|
+
paddingTop: 8,
|
|
311
|
+
paddingBottom: 8,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
@@ -1,33 +1,56 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
TextInput,
|
|
5
|
-
|
|
5
|
+
Pressable,
|
|
6
6
|
StyleSheet,
|
|
7
7
|
ActivityIndicator,
|
|
8
8
|
Platform,
|
|
9
9
|
} from 'react-native';
|
|
10
10
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
11
|
+
import { useSpeechToText } from '../hooks/useSpeechToText';
|
|
11
12
|
import { useAliaColors } from '../theme';
|
|
12
13
|
|
|
13
14
|
interface AliaChatInputProps {
|
|
14
15
|
onSend: (text: string) => void;
|
|
15
16
|
isStreaming: boolean;
|
|
17
|
+
onStop: () => void;
|
|
18
|
+
onVoiceActivate: () => void;
|
|
19
|
+
apiUrl?: string;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
export function AliaChatInput({
|
|
22
|
+
export function AliaChatInput({
|
|
23
|
+
onSend,
|
|
24
|
+
isStreaming,
|
|
25
|
+
onStop,
|
|
26
|
+
onVoiceActivate,
|
|
27
|
+
apiUrl,
|
|
28
|
+
}: AliaChatInputProps) {
|
|
19
29
|
const [input, setInput] = useState('');
|
|
20
30
|
const colors = useAliaColors();
|
|
21
31
|
const insets = useSafeAreaInsets();
|
|
22
32
|
const isDark = colors.isDark;
|
|
33
|
+
const stt = useSpeechToText({ apiUrl });
|
|
23
34
|
|
|
24
|
-
const
|
|
35
|
+
const hasText = input.trim().length > 0;
|
|
36
|
+
const canSend = hasText && !isStreaming;
|
|
25
37
|
|
|
26
|
-
const handleSend = () => {
|
|
38
|
+
const handleSend = useCallback(() => {
|
|
27
39
|
if (!canSend) return;
|
|
28
40
|
onSend(input.trim());
|
|
29
41
|
setInput('');
|
|
30
|
-
};
|
|
42
|
+
}, [canSend, input, onSend]);
|
|
43
|
+
|
|
44
|
+
const handleMicPress = useCallback(async () => {
|
|
45
|
+
if (stt.isRecording) {
|
|
46
|
+
const text = await stt.stopAndTranscribe();
|
|
47
|
+
if (text) {
|
|
48
|
+
setInput((prev) => (prev ? `${prev} ${text}` : text));
|
|
49
|
+
}
|
|
50
|
+
} else if (!stt.isTranscribing) {
|
|
51
|
+
stt.startRecording();
|
|
52
|
+
}
|
|
53
|
+
}, [stt]);
|
|
31
54
|
|
|
32
55
|
return (
|
|
33
56
|
<View
|
|
@@ -55,39 +78,65 @@ export function AliaChatInput({ onSend, isStreaming }: AliaChatInputProps) {
|
|
|
55
78
|
maxLength={2000}
|
|
56
79
|
onSubmitEditing={handleSend}
|
|
57
80
|
blurOnSubmit={false}
|
|
58
|
-
editable={!isStreaming}
|
|
59
81
|
/>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
onPress={handleSend}
|
|
67
|
-
disabled={!canSend}
|
|
68
|
-
activeOpacity={0.7}
|
|
82
|
+
|
|
83
|
+
{/* STT mic button */}
|
|
84
|
+
<Pressable
|
|
85
|
+
onPress={handleMicPress}
|
|
86
|
+
disabled={stt.isTranscribing}
|
|
87
|
+
style={styles.micButton}
|
|
69
88
|
>
|
|
70
|
-
{
|
|
71
|
-
<ActivityIndicator size="small" color="#
|
|
89
|
+
{stt.isTranscribing ? (
|
|
90
|
+
<ActivityIndicator size="small" color="#6366f1" />
|
|
91
|
+
) : stt.isRecording ? (
|
|
92
|
+
<MicOffIcon />
|
|
72
93
|
) : (
|
|
73
|
-
<
|
|
94
|
+
<MicIcon color={colors.secondaryText} />
|
|
74
95
|
)}
|
|
75
|
-
</
|
|
96
|
+
</Pressable>
|
|
97
|
+
|
|
98
|
+
{/* Three-state submit button */}
|
|
99
|
+
{isStreaming ? (
|
|
100
|
+
<Pressable
|
|
101
|
+
style={[styles.actionButton, { backgroundColor: colors.tint }]}
|
|
102
|
+
onPress={onStop}
|
|
103
|
+
>
|
|
104
|
+
<StopIcon />
|
|
105
|
+
</Pressable>
|
|
106
|
+
) : hasText ? (
|
|
107
|
+
<Pressable
|
|
108
|
+
style={[
|
|
109
|
+
styles.actionButton,
|
|
110
|
+
{ backgroundColor: colors.tint },
|
|
111
|
+
!canSend && { opacity: 0.4 },
|
|
112
|
+
]}
|
|
113
|
+
onPress={handleSend}
|
|
114
|
+
disabled={!canSend}
|
|
115
|
+
>
|
|
116
|
+
<ArrowUpIcon />
|
|
117
|
+
</Pressable>
|
|
118
|
+
) : (
|
|
119
|
+
<Pressable
|
|
120
|
+
style={[styles.actionButton, { backgroundColor: colors.tint }]}
|
|
121
|
+
onPress={onVoiceActivate}
|
|
122
|
+
>
|
|
123
|
+
<SoundIcon />
|
|
124
|
+
</Pressable>
|
|
125
|
+
)}
|
|
76
126
|
</View>
|
|
77
127
|
);
|
|
78
128
|
}
|
|
79
129
|
|
|
80
|
-
|
|
130
|
+
// ── Icons (inline SVG on web, View-based on native) ──
|
|
131
|
+
|
|
81
132
|
function ArrowUpIcon() {
|
|
82
|
-
// Use a unicode character for simplicity — works cross-platform
|
|
83
133
|
return (
|
|
84
|
-
<View style={styles.
|
|
134
|
+
<View style={styles.iconCenter}>
|
|
85
135
|
{Platform.OS === 'web' ? (
|
|
86
136
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
87
137
|
<path d="M12 19V5M5 12l7-7 7 7" />
|
|
88
138
|
</svg>
|
|
89
139
|
) : (
|
|
90
|
-
// React Native fallback — simple Text arrow
|
|
91
140
|
<View style={styles.arrowIconInner}>
|
|
92
141
|
<View style={[styles.arrowStem, { backgroundColor: '#FFFFFF' }]} />
|
|
93
142
|
<View style={[styles.arrowHead, { borderColor: '#FFFFFF' }]} />
|
|
@@ -97,6 +146,74 @@ function ArrowUpIcon() {
|
|
|
97
146
|
);
|
|
98
147
|
}
|
|
99
148
|
|
|
149
|
+
function StopIcon() {
|
|
150
|
+
return (
|
|
151
|
+
<View style={styles.iconCenter}>
|
|
152
|
+
{Platform.OS === 'web' ? (
|
|
153
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="white">
|
|
154
|
+
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
155
|
+
</svg>
|
|
156
|
+
) : (
|
|
157
|
+
<View style={styles.stopSquare} />
|
|
158
|
+
)}
|
|
159
|
+
</View>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function SoundIcon() {
|
|
164
|
+
return (
|
|
165
|
+
<View style={styles.iconCenter}>
|
|
166
|
+
{Platform.OS === 'web' ? (
|
|
167
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
168
|
+
<path d="M11 5L6 9H2v6h4l5 4V5z" />
|
|
169
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
170
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
171
|
+
</svg>
|
|
172
|
+
) : (
|
|
173
|
+
<View style={styles.soundBars}>
|
|
174
|
+
<View style={[styles.soundBar, styles.soundBarShort]} />
|
|
175
|
+
<View style={[styles.soundBar, styles.soundBarMed]} />
|
|
176
|
+
<View style={[styles.soundBar, styles.soundBarTall]} />
|
|
177
|
+
</View>
|
|
178
|
+
)}
|
|
179
|
+
</View>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function MicIcon({ color }: { color: string }) {
|
|
184
|
+
return (
|
|
185
|
+
<View style={styles.iconCenter}>
|
|
186
|
+
{Platform.OS === 'web' ? (
|
|
187
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
188
|
+
<rect x="9" y="1" width="6" height="12" rx="3" />
|
|
189
|
+
<path d="M5 10a7 7 0 0 0 14 0" />
|
|
190
|
+
<line x1="12" y1="17" x2="12" y2="21" />
|
|
191
|
+
<line x1="8" y1="21" x2="16" y2="21" />
|
|
192
|
+
</svg>
|
|
193
|
+
) : (
|
|
194
|
+
<View style={[styles.micDot, { backgroundColor: color }]} />
|
|
195
|
+
)}
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function MicOffIcon() {
|
|
201
|
+
return (
|
|
202
|
+
<View style={styles.iconCenter}>
|
|
203
|
+
{Platform.OS === 'web' ? (
|
|
204
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
205
|
+
<rect x="9" y="1" width="6" height="12" rx="3" />
|
|
206
|
+
<path d="M5 10a7 7 0 0 0 14 0" />
|
|
207
|
+
<line x1="12" y1="17" x2="12" y2="21" />
|
|
208
|
+
<line x1="1" y1="1" x2="23" y2="23" />
|
|
209
|
+
</svg>
|
|
210
|
+
) : (
|
|
211
|
+
<View style={[styles.micDot, { backgroundColor: '#ef4444' }]} />
|
|
212
|
+
)}
|
|
213
|
+
</View>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
100
217
|
const styles = StyleSheet.create({
|
|
101
218
|
container: {
|
|
102
219
|
flexDirection: 'row',
|
|
@@ -114,14 +231,20 @@ const styles = StyleSheet.create({
|
|
|
114
231
|
borderRadius: 20,
|
|
115
232
|
maxHeight: 100,
|
|
116
233
|
},
|
|
117
|
-
|
|
234
|
+
micButton: {
|
|
235
|
+
width: 32,
|
|
236
|
+
height: 40,
|
|
237
|
+
alignItems: 'center',
|
|
238
|
+
justifyContent: 'center',
|
|
239
|
+
},
|
|
240
|
+
actionButton: {
|
|
118
241
|
width: 40,
|
|
119
242
|
height: 40,
|
|
120
243
|
borderRadius: 20,
|
|
121
244
|
alignItems: 'center',
|
|
122
245
|
justifyContent: 'center',
|
|
123
246
|
},
|
|
124
|
-
|
|
247
|
+
iconCenter: {
|
|
125
248
|
width: 18,
|
|
126
249
|
height: 18,
|
|
127
250
|
alignItems: 'center',
|
|
@@ -144,4 +267,28 @@ const styles = StyleSheet.create({
|
|
|
144
267
|
borderLeftWidth: 2.5,
|
|
145
268
|
transform: [{ rotate: '45deg' }],
|
|
146
269
|
},
|
|
270
|
+
stopSquare: {
|
|
271
|
+
width: 12,
|
|
272
|
+
height: 12,
|
|
273
|
+
borderRadius: 2,
|
|
274
|
+
backgroundColor: '#FFFFFF',
|
|
275
|
+
},
|
|
276
|
+
soundBars: {
|
|
277
|
+
flexDirection: 'row',
|
|
278
|
+
alignItems: 'flex-end',
|
|
279
|
+
gap: 2,
|
|
280
|
+
},
|
|
281
|
+
soundBar: {
|
|
282
|
+
width: 3,
|
|
283
|
+
backgroundColor: '#FFFFFF',
|
|
284
|
+
borderRadius: 1.5,
|
|
285
|
+
},
|
|
286
|
+
soundBarShort: { height: 6 },
|
|
287
|
+
soundBarMed: { height: 10 },
|
|
288
|
+
soundBarTall: { height: 14 },
|
|
289
|
+
micDot: {
|
|
290
|
+
width: 12,
|
|
291
|
+
height: 12,
|
|
292
|
+
borderRadius: 6,
|
|
293
|
+
},
|
|
147
294
|
});
|