@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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Pressable, ScrollView, ActivityIndicator } from "react-native";
|
|
3
|
+
import { Image } from "expo-image";
|
|
4
|
+
import {
|
|
5
|
+
FileText,
|
|
6
|
+
FileSpreadsheet,
|
|
7
|
+
FileCode,
|
|
8
|
+
FileArchive,
|
|
9
|
+
FileAudio,
|
|
10
|
+
File,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react-native";
|
|
13
|
+
import { Text } from "../text";
|
|
14
|
+
import { formatFileSize } from "../../../lib/utils";
|
|
15
|
+
import { usePromptInput, type Attachment } from "./context";
|
|
16
|
+
|
|
17
|
+
function getDocumentIcon(mimeType: string, name: string) {
|
|
18
|
+
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
19
|
+
|
|
20
|
+
if (mimeType === "application/pdf" || ext === "pdf")
|
|
21
|
+
return { Icon: FileText, color: "#EF4444", bgColor: "#EF444418" };
|
|
22
|
+
if (mimeType.includes("word") || ["doc", "docx"].includes(ext))
|
|
23
|
+
return { Icon: FileText, color: "#3B82F6", bgColor: "#3B82F618" };
|
|
24
|
+
if (
|
|
25
|
+
mimeType.includes("spreadsheet") ||
|
|
26
|
+
mimeType.includes("excel") ||
|
|
27
|
+
["xls", "xlsx", "csv"].includes(ext)
|
|
28
|
+
)
|
|
29
|
+
return {
|
|
30
|
+
Icon: FileSpreadsheet,
|
|
31
|
+
color: "#22C55E",
|
|
32
|
+
bgColor: "#22C55E18",
|
|
33
|
+
};
|
|
34
|
+
if (
|
|
35
|
+
[
|
|
36
|
+
"js", "ts", "tsx", "jsx", "py", "rb", "go", "rs", "java", "c", "cpp",
|
|
37
|
+
"h", "json", "xml", "yaml", "yml", "html", "css", "scss", "sh", "sql",
|
|
38
|
+
].includes(ext)
|
|
39
|
+
)
|
|
40
|
+
return { Icon: FileCode, color: "#8B5CF6", bgColor: "#8B5CF618" };
|
|
41
|
+
if (
|
|
42
|
+
mimeType.includes("zip") ||
|
|
43
|
+
mimeType.includes("archive") ||
|
|
44
|
+
["zip", "rar", "tar", "gz", "7z"].includes(ext)
|
|
45
|
+
)
|
|
46
|
+
return { Icon: FileArchive, color: "#EAB308", bgColor: "#EAB30818" };
|
|
47
|
+
if (
|
|
48
|
+
mimeType.startsWith("audio/") ||
|
|
49
|
+
["mp3", "wav", "ogg", "flac", "aac"].includes(ext)
|
|
50
|
+
)
|
|
51
|
+
return { Icon: FileAudio, color: "#EC4899", bgColor: "#EC489918" };
|
|
52
|
+
if (
|
|
53
|
+
mimeType === "text/plain" ||
|
|
54
|
+
["txt", "md", "rtf"].includes(ext)
|
|
55
|
+
)
|
|
56
|
+
return { Icon: FileText, color: "#6B7280", bgColor: "#6B728018" };
|
|
57
|
+
return { Icon: File, color: "#9CA3AF", bgColor: "#9CA3AF18" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function truncateFilename(name: string, maxLength = 20): string {
|
|
61
|
+
if (name.length <= maxLength) return name;
|
|
62
|
+
const lastDot = name.lastIndexOf(".");
|
|
63
|
+
if (lastDot < 0) return name.slice(0, maxLength - 3) + "...";
|
|
64
|
+
const ext = name.slice(lastDot);
|
|
65
|
+
const base = name.slice(0, lastDot);
|
|
66
|
+
const available = maxLength - ext.length - 3;
|
|
67
|
+
if (available <= 0) return name.slice(0, maxLength - 3) + "...";
|
|
68
|
+
return base.slice(0, available) + "..." + ext;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function AttachmentItem({
|
|
72
|
+
attachment,
|
|
73
|
+
onRemove,
|
|
74
|
+
}: {
|
|
75
|
+
attachment: Attachment;
|
|
76
|
+
onRemove: () => void;
|
|
77
|
+
}) {
|
|
78
|
+
if (attachment.type === "image") {
|
|
79
|
+
return (
|
|
80
|
+
<View
|
|
81
|
+
className="relative rounded-2xl overflow-hidden bg-muted border border-border"
|
|
82
|
+
style={{ width: 120, height: 120 }}
|
|
83
|
+
>
|
|
84
|
+
{!attachment.isLoading && attachment.uri ? (
|
|
85
|
+
<Image
|
|
86
|
+
source={{ uri: attachment.uri }}
|
|
87
|
+
className="w-full h-full"
|
|
88
|
+
contentFit="cover"
|
|
89
|
+
/>
|
|
90
|
+
) : (
|
|
91
|
+
<View className="absolute inset-0 items-center justify-center bg-muted">
|
|
92
|
+
<ActivityIndicator size="small" />
|
|
93
|
+
</View>
|
|
94
|
+
)}
|
|
95
|
+
{attachment.name && !attachment.isLoading && (
|
|
96
|
+
<View
|
|
97
|
+
className="absolute bottom-0 left-0 right-0 px-2 py-1"
|
|
98
|
+
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
|
99
|
+
>
|
|
100
|
+
<Text className="text-[11px] text-white" numberOfLines={1}>
|
|
101
|
+
{truncateFilename(attachment.name)}
|
|
102
|
+
</Text>
|
|
103
|
+
</View>
|
|
104
|
+
)}
|
|
105
|
+
<Pressable
|
|
106
|
+
onPress={onRemove}
|
|
107
|
+
className="absolute top-1.5 right-1.5 w-6 h-6 rounded-full items-center justify-center active:opacity-70"
|
|
108
|
+
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
|
109
|
+
accessibilityRole="button"
|
|
110
|
+
accessibilityLabel={`Remove ${attachment.name || "image"}`}
|
|
111
|
+
>
|
|
112
|
+
<X size={14} color="white" />
|
|
113
|
+
</Pressable>
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { Icon, color, bgColor } = getDocumentIcon(
|
|
119
|
+
attachment.mimeType,
|
|
120
|
+
attachment.name
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<View
|
|
125
|
+
className="relative rounded-2xl border border-border overflow-hidden bg-muted/30"
|
|
126
|
+
style={{ width: 180, height: 80 }}
|
|
127
|
+
>
|
|
128
|
+
<View className="flex-1 flex-row items-center px-3 gap-3">
|
|
129
|
+
<View
|
|
130
|
+
className="w-10 h-10 rounded-xl items-center justify-center"
|
|
131
|
+
style={{ backgroundColor: bgColor }}
|
|
132
|
+
>
|
|
133
|
+
<Icon size={20} color={color} />
|
|
134
|
+
</View>
|
|
135
|
+
<View className="flex-1 pr-4">
|
|
136
|
+
<Text
|
|
137
|
+
className="text-xs font-medium text-foreground"
|
|
138
|
+
numberOfLines={1}
|
|
139
|
+
>
|
|
140
|
+
{truncateFilename(attachment.name)}
|
|
141
|
+
</Text>
|
|
142
|
+
{attachment.size > 0 && (
|
|
143
|
+
<Text className="text-[11px] text-muted-foreground mt-0.5">
|
|
144
|
+
{formatFileSize(attachment.size)}
|
|
145
|
+
</Text>
|
|
146
|
+
)}
|
|
147
|
+
</View>
|
|
148
|
+
</View>
|
|
149
|
+
<Pressable
|
|
150
|
+
onPress={onRemove}
|
|
151
|
+
className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-muted items-center justify-center active:opacity-70 border border-border"
|
|
152
|
+
accessibilityRole="button"
|
|
153
|
+
accessibilityLabel={`Remove ${attachment.name || "document"}`}
|
|
154
|
+
>
|
|
155
|
+
<X size={12} className="text-foreground" />
|
|
156
|
+
</Pressable>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function PromptInputAttachments() {
|
|
162
|
+
const { attachments, removeAttachment } = usePromptInput();
|
|
163
|
+
|
|
164
|
+
if (attachments.length === 0) return null;
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<ScrollView
|
|
168
|
+
horizontal
|
|
169
|
+
showsHorizontalScrollIndicator={false}
|
|
170
|
+
className="mb-2 pt-2 flex-none"
|
|
171
|
+
contentContainerClassName="gap-2.5 px-3"
|
|
172
|
+
>
|
|
173
|
+
{attachments.map((attachment) => (
|
|
174
|
+
<View key={attachment.id} className="relative">
|
|
175
|
+
<AttachmentItem
|
|
176
|
+
attachment={attachment}
|
|
177
|
+
onRemove={() => removeAttachment(attachment.id)}
|
|
178
|
+
/>
|
|
179
|
+
</View>
|
|
180
|
+
))}
|
|
181
|
+
</ScrollView>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { Text } from "../text";
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
import { usePromptInput, type Completion } from "./context";
|
|
6
|
+
|
|
7
|
+
export type PromptInputAutocompleteProps = {
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
position?: "top" | "bottom";
|
|
10
|
+
className?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function PromptInputAutocomplete({
|
|
14
|
+
enabled = true,
|
|
15
|
+
position = "top",
|
|
16
|
+
className,
|
|
17
|
+
}: PromptInputAutocompleteProps) {
|
|
18
|
+
const { value, setValue, setHandleCompletionKey, useSuggestions, onSuggestionUsed } = usePromptInput();
|
|
19
|
+
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
20
|
+
const selectedIndexRef = useRef(-1);
|
|
21
|
+
const completionsRef = useRef<Completion[]>([]);
|
|
22
|
+
const [debouncedQuery, setDebouncedQuery] = useState('');
|
|
23
|
+
|
|
24
|
+
// If no useSuggestions hook is injected, autocomplete is disabled
|
|
25
|
+
const suggestionsResult = useSuggestions?.(debouncedQuery);
|
|
26
|
+
const apiResults = suggestionsResult?.data;
|
|
27
|
+
|
|
28
|
+
// Debounce the search query (200ms)
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!enabled || !useSuggestions) {
|
|
31
|
+
setDebouncedQuery('');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const trimmed = value.trim();
|
|
35
|
+
if (!trimmed || trimmed.length < 2) {
|
|
36
|
+
setDebouncedQuery('');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const timer = setTimeout(() => setDebouncedQuery(trimmed), 200);
|
|
40
|
+
return () => clearTimeout(timer);
|
|
41
|
+
}, [value, enabled, useSuggestions]);
|
|
42
|
+
|
|
43
|
+
// Map API results to completions with match highlighting
|
|
44
|
+
const completions = useMemo<Completion[]>(() => {
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
if (!trimmed || trimmed.length < 2 || !apiResults?.length) return [];
|
|
47
|
+
|
|
48
|
+
const lower = trimmed.toLowerCase();
|
|
49
|
+
const results: Completion[] = [];
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const s of apiResults) {
|
|
53
|
+
if (results.length >= 6) break;
|
|
54
|
+
const textLower = s.text.toLowerCase();
|
|
55
|
+
if (seen.has(textLower)) continue;
|
|
56
|
+
seen.add(textLower);
|
|
57
|
+
|
|
58
|
+
const idx = textLower.indexOf(lower);
|
|
59
|
+
results.push({
|
|
60
|
+
text: s.text,
|
|
61
|
+
matchStart: idx !== -1 ? idx : 0,
|
|
62
|
+
matchEnd: idx !== -1 ? idx + trimmed.length : 0,
|
|
63
|
+
suggestionId: s.suggestionId,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return results;
|
|
68
|
+
}, [apiResults, value]);
|
|
69
|
+
|
|
70
|
+
// Keep refs in sync
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
completionsRef.current = completions;
|
|
73
|
+
}, [completions]);
|
|
74
|
+
|
|
75
|
+
// Reset selection when completions change
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
selectedIndexRef.current = -1;
|
|
78
|
+
setSelectedIndex(-1);
|
|
79
|
+
}, [completions]);
|
|
80
|
+
|
|
81
|
+
// Arrow key handler — stable callback using refs
|
|
82
|
+
const handleKey = useCallback((key: string): boolean => {
|
|
83
|
+
const items = completionsRef.current;
|
|
84
|
+
if (items.length === 0) return false;
|
|
85
|
+
|
|
86
|
+
if (key === "ArrowDown") {
|
|
87
|
+
const next = selectedIndexRef.current < items.length - 1
|
|
88
|
+
? selectedIndexRef.current + 1
|
|
89
|
+
: 0;
|
|
90
|
+
selectedIndexRef.current = next;
|
|
91
|
+
setSelectedIndex(next);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (key === "ArrowUp") {
|
|
96
|
+
const next = selectedIndexRef.current > 0
|
|
97
|
+
? selectedIndexRef.current - 1
|
|
98
|
+
: items.length - 1;
|
|
99
|
+
selectedIndexRef.current = next;
|
|
100
|
+
setSelectedIndex(next);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (key === "Enter") {
|
|
105
|
+
if (selectedIndexRef.current < 0) return false;
|
|
106
|
+
const item = items[selectedIndexRef.current];
|
|
107
|
+
if (item.suggestionId) onSuggestionUsed?.(item.suggestionId);
|
|
108
|
+
setValue(item.text);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (key === "Escape") {
|
|
113
|
+
if (selectedIndexRef.current < 0) return false;
|
|
114
|
+
selectedIndexRef.current = -1;
|
|
115
|
+
setSelectedIndex(-1);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return false;
|
|
120
|
+
}, [setValue, onSuggestionUsed]);
|
|
121
|
+
|
|
122
|
+
// Register/unregister the key handler based on completions
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (completions.length > 0) {
|
|
125
|
+
(setHandleCompletionKey as any)(() => handleKey);
|
|
126
|
+
} else {
|
|
127
|
+
setHandleCompletionKey(null);
|
|
128
|
+
}
|
|
129
|
+
return () => setHandleCompletionKey(null);
|
|
130
|
+
}, [completions.length, handleKey, setHandleCompletionKey]);
|
|
131
|
+
|
|
132
|
+
// If no suggestions hook or no completions, render nothing
|
|
133
|
+
if (!useSuggestions || completions.length === 0) return null;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<View className={className}>
|
|
137
|
+
<View className={position === "bottom" ? "pt-0.5" : "pb-0.5"}>
|
|
138
|
+
{completions.map((item, index) => (
|
|
139
|
+
<Pressable
|
|
140
|
+
key={item.suggestionId || item.text}
|
|
141
|
+
onPress={() => {
|
|
142
|
+
if (item.suggestionId) {
|
|
143
|
+
onSuggestionUsed?.(item.suggestionId);
|
|
144
|
+
}
|
|
145
|
+
setValue(item.text);
|
|
146
|
+
}}
|
|
147
|
+
className={cn(
|
|
148
|
+
"px-3 py-1.5 rounded-lg active:bg-muted/50",
|
|
149
|
+
index === selectedIndex && "bg-muted"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
<Text className="text-sm leading-5" numberOfLines={1}>
|
|
153
|
+
<Text className="text-foreground">
|
|
154
|
+
{item.text.slice(0, item.matchStart)}
|
|
155
|
+
</Text>
|
|
156
|
+
<Text className="text-primary font-medium">
|
|
157
|
+
{item.text.slice(item.matchStart, item.matchEnd)}
|
|
158
|
+
</Text>
|
|
159
|
+
<Text className="text-foreground">
|
|
160
|
+
{item.text.slice(item.matchEnd)}
|
|
161
|
+
</Text>
|
|
162
|
+
</Text>
|
|
163
|
+
</Pressable>
|
|
164
|
+
))}
|
|
165
|
+
</View>
|
|
166
|
+
</View>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { createContext, useContext } from "react";
|
|
2
|
+
import type { TextInput as RNTextInput } from "react-native";
|
|
3
|
+
|
|
4
|
+
export interface Attachment {
|
|
5
|
+
id: string;
|
|
6
|
+
uri: string;
|
|
7
|
+
type: "image" | "document";
|
|
8
|
+
name: string;
|
|
9
|
+
size: number;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Completion {
|
|
15
|
+
text: string;
|
|
16
|
+
matchStart: number;
|
|
17
|
+
matchEnd: number;
|
|
18
|
+
suggestionId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PromptInputContextType = {
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
value: string;
|
|
24
|
+
setValue: (value: string) => void;
|
|
25
|
+
maxHeight: number;
|
|
26
|
+
onSubmit?: () => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
textareaRef: React.RefObject<RNTextInput | null>;
|
|
29
|
+
currentHeight: number;
|
|
30
|
+
setCurrentHeight: (height: number) => void;
|
|
31
|
+
isFullscreen: boolean;
|
|
32
|
+
onImagePaste?: (files: File[]) => void;
|
|
33
|
+
attachments: Attachment[];
|
|
34
|
+
addAttachment: (attachment: Attachment) => void;
|
|
35
|
+
removeAttachment: (id: string) => void;
|
|
36
|
+
updateAttachment: (id: string, updates: Partial<Attachment>) => void;
|
|
37
|
+
handleCompletionKey: ((key: string) => boolean) | null;
|
|
38
|
+
setHandleCompletionKey: (fn: ((key: string) => boolean) | null) => void;
|
|
39
|
+
// Injectable autocomplete hooks
|
|
40
|
+
useSuggestions?: (query: string) => { data: Completion[] | undefined; isLoading: boolean };
|
|
41
|
+
onSuggestionUsed?: (suggestionId: string) => void;
|
|
42
|
+
// Injectable error handler (replaces toast)
|
|
43
|
+
onError?: (message: string) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const PromptInputContext = createContext<PromptInputContextType>({
|
|
47
|
+
isLoading: false,
|
|
48
|
+
value: "",
|
|
49
|
+
setValue: () => {},
|
|
50
|
+
maxHeight: 240,
|
|
51
|
+
onSubmit: undefined,
|
|
52
|
+
disabled: false,
|
|
53
|
+
textareaRef: React.createRef<RNTextInput>(),
|
|
54
|
+
currentHeight: 44,
|
|
55
|
+
setCurrentHeight: () => {},
|
|
56
|
+
isFullscreen: false,
|
|
57
|
+
attachments: [],
|
|
58
|
+
addAttachment: () => {},
|
|
59
|
+
removeAttachment: () => {},
|
|
60
|
+
updateAttachment: () => {},
|
|
61
|
+
handleCompletionKey: null,
|
|
62
|
+
setHandleCompletionKey: () => {},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export function usePromptInput() {
|
|
66
|
+
return useContext(PromptInputContext);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useIsFullscreen() {
|
|
70
|
+
return useContext(PromptInputContext).isFullscreen;
|
|
71
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { Pressable, ActivityIndicator } from "react-native";
|
|
3
|
+
import { Mic, MicOff } from "lucide-react-native";
|
|
4
|
+
import { cn } from "../../../lib/utils";
|
|
5
|
+
import { useSpeechToText } from "../../../hooks/useSpeechToText";
|
|
6
|
+
import { usePromptInput } from "./context";
|
|
7
|
+
|
|
8
|
+
export type PromptInputMicButtonProps = {
|
|
9
|
+
className?: string;
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function PromptInputMicButton({ className, apiUrl }: PromptInputMicButtonProps) {
|
|
14
|
+
const { value, setValue, onError } = usePromptInput();
|
|
15
|
+
const stt = useSpeechToText({ apiUrl });
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (stt.error) {
|
|
19
|
+
if (onError) onError(stt.error);
|
|
20
|
+
else console.warn('[PromptInput] STT error:', stt.error);
|
|
21
|
+
}
|
|
22
|
+
}, [stt.error, onError]);
|
|
23
|
+
|
|
24
|
+
const handlePress = async () => {
|
|
25
|
+
if (stt.isRecording) {
|
|
26
|
+
const text = await stt.stopAndTranscribe();
|
|
27
|
+
if (text) {
|
|
28
|
+
setValue(value ? `${value} ${text}` : text);
|
|
29
|
+
}
|
|
30
|
+
} else if (!stt.isTranscribing) {
|
|
31
|
+
stt.startRecording();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Pressable
|
|
37
|
+
onPress={handlePress}
|
|
38
|
+
disabled={stt.isTranscribing}
|
|
39
|
+
className={cn(
|
|
40
|
+
"h-8 w-8 rounded-full items-center justify-center active:opacity-70",
|
|
41
|
+
className
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
{stt.isTranscribing ? (
|
|
45
|
+
<ActivityIndicator size="small" color="#6366f1" />
|
|
46
|
+
) : stt.isRecording ? (
|
|
47
|
+
<MicOff size={16} color="#ef4444" />
|
|
48
|
+
) : (
|
|
49
|
+
<Mic size={16} className="text-muted-foreground" />
|
|
50
|
+
)}
|
|
51
|
+
</Pressable>
|
|
52
|
+
);
|
|
53
|
+
}
|