@alia.onl/sdk 1.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -3
- package/src/components/AliaChatContent.tsx +313 -0
- package/src/components/AliaChatInput.tsx +174 -27
- package/src/components/AliaChatMessageList.tsx +220 -34
- package/src/components/AliaChatScreen.tsx +132 -0
- package/src/components/AliaChatSheet.tsx +36 -49
- package/src/components/AliaChatSuggestions.tsx +1 -1
- package/src/components/AliaFace.tsx +402 -0
- package/src/components/Markdown.tsx +288 -0
- package/src/components/Reasoning.tsx +154 -0
- package/src/components/ThinkingIndicator.tsx +134 -0
- package/src/components/ui/button.tsx +104 -0
- package/src/components/ui/chat-text-input.tsx +164 -0
- package/src/components/ui/dropdown-menu.tsx +2 -0
- package/src/components/ui/dropdown-menu.web.tsx +355 -0
- package/src/components/ui/prompt-input/actions.tsx +28 -0
- package/src/components/ui/prompt-input/add-menu.tsx +83 -0
- package/src/components/ui/prompt-input/attachments.tsx +183 -0
- package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
- package/src/components/ui/prompt-input/context.tsx +71 -0
- package/src/components/ui/prompt-input/mic-button.tsx +53 -0
- package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
- package/src/components/ui/prompt-input/submit-button.tsx +49 -0
- package/src/components/ui/prompt-input/textarea.tsx +64 -0
- package/src/components/ui/text.tsx +29 -0
- package/src/components/voice/AudioWaveVisualizer.tsx +377 -0
- package/src/components/voice/VoiceControls.tsx +180 -0
- package/src/components/voice/VoiceOverlay.tsx +43 -0
- package/src/hooks/useAliaChat.ts +278 -91
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +182 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +71 -6
- package/src/lib/keyboard.native.tsx +6 -0
- package/src/lib/keyboard.tsx +25 -0
- package/src/lib/utils.ts +14 -0
- package/src/optional-deps.d.ts +86 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +63 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Pressable,
|
|
5
|
+
type TextInput as RNTextInput,
|
|
6
|
+
} from "react-native";
|
|
7
|
+
import { KeyboardAvoidingView } from "../../../lib/keyboard";
|
|
8
|
+
import { Maximize2, Minimize2 } from "lucide-react-native";
|
|
9
|
+
import { cn } from "../../../lib/utils";
|
|
10
|
+
import { PromptInputContext, type Attachment, type Completion } from "./context";
|
|
11
|
+
import { PromptInputTextarea } from "./textarea";
|
|
12
|
+
import { PromptInputActions } from "./actions";
|
|
13
|
+
import { PromptInputMicButton } from "./mic-button";
|
|
14
|
+
import { PromptInputAutocomplete } from "./autocomplete";
|
|
15
|
+
import { PromptInputAttachments } from "./attachments";
|
|
16
|
+
import { PromptInputSubmitButton } from "./submit-button";
|
|
17
|
+
import { PromptInputAddMenu } from "./add-menu";
|
|
18
|
+
|
|
19
|
+
export type PromptInputProps = {
|
|
20
|
+
isLoading?: boolean;
|
|
21
|
+
value?: string;
|
|
22
|
+
onValueChange?: (value: string) => void;
|
|
23
|
+
maxHeight?: number;
|
|
24
|
+
onSubmit?: () => void;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
onImagePaste?: (files: File[]) => void;
|
|
29
|
+
// Simple mode props (when no children)
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
autocomplete?: boolean;
|
|
32
|
+
autocompletePosition?: "top" | "bottom";
|
|
33
|
+
// Shows the add menu as a standalone button to the left of the input box
|
|
34
|
+
leadingAddMenu?: boolean;
|
|
35
|
+
// Custom left-side actions (replaces default add menu in the actions bar)
|
|
36
|
+
actionsLeft?: React.ReactNode;
|
|
37
|
+
// Submit button props
|
|
38
|
+
onStop?: () => void;
|
|
39
|
+
emptyAction?: React.ReactNode;
|
|
40
|
+
// Controlled attachments (optional — uses internal state if omitted)
|
|
41
|
+
attachments?: Attachment[];
|
|
42
|
+
onAddAttachment?: (attachment: Attachment) => void;
|
|
43
|
+
onRemoveAttachment?: (id: string) => void;
|
|
44
|
+
onUpdateAttachment?: (id: string, updates: Partial<Attachment>) => void;
|
|
45
|
+
// Injectable autocomplete hooks
|
|
46
|
+
useSuggestions?: (query: string) => { data: Completion[] | undefined; isLoading: boolean };
|
|
47
|
+
onSuggestionUsed?: (suggestionId: string) => void;
|
|
48
|
+
// Injectable error handler (replaces toast)
|
|
49
|
+
onError?: (message: string) => void;
|
|
50
|
+
// Mic button API URL
|
|
51
|
+
apiUrl?: string;
|
|
52
|
+
} & Omit<React.ComponentProps<typeof View>, "children">;
|
|
53
|
+
|
|
54
|
+
export function PromptInput({
|
|
55
|
+
className,
|
|
56
|
+
isLoading = false,
|
|
57
|
+
maxHeight = 240,
|
|
58
|
+
value,
|
|
59
|
+
onValueChange,
|
|
60
|
+
onSubmit,
|
|
61
|
+
children,
|
|
62
|
+
disabled = false,
|
|
63
|
+
onImagePaste,
|
|
64
|
+
placeholder,
|
|
65
|
+
autocomplete = false,
|
|
66
|
+
autocompletePosition = "top",
|
|
67
|
+
leadingAddMenu = false,
|
|
68
|
+
actionsLeft,
|
|
69
|
+
onStop,
|
|
70
|
+
emptyAction,
|
|
71
|
+
attachments: controlledAttachments,
|
|
72
|
+
onAddAttachment,
|
|
73
|
+
onRemoveAttachment,
|
|
74
|
+
onUpdateAttachment,
|
|
75
|
+
useSuggestions,
|
|
76
|
+
onSuggestionUsed,
|
|
77
|
+
onError,
|
|
78
|
+
apiUrl,
|
|
79
|
+
...props
|
|
80
|
+
}: PromptInputProps) {
|
|
81
|
+
const [internalValue, setInternalValue] = useState(value || "");
|
|
82
|
+
const [currentHeight, setCurrentHeight] = useState(44);
|
|
83
|
+
const [showFullscreen, setShowFullscreen] = useState(false);
|
|
84
|
+
const [handleCompletionKey, setHandleCompletionKey] = useState<((key: string) => boolean) | null>(null);
|
|
85
|
+
const textareaRef = useRef<RNTextInput>(null);
|
|
86
|
+
|
|
87
|
+
// Internal attachment state (used when no controlled props)
|
|
88
|
+
const [internalAttachments, setInternalAttachments] = useState<Attachment[]>(
|
|
89
|
+
[]
|
|
90
|
+
);
|
|
91
|
+
const attachments = controlledAttachments ?? internalAttachments;
|
|
92
|
+
|
|
93
|
+
const addAttachment = useCallback(
|
|
94
|
+
(a: Attachment) => {
|
|
95
|
+
if (onAddAttachment) {
|
|
96
|
+
onAddAttachment(a);
|
|
97
|
+
} else {
|
|
98
|
+
setInternalAttachments((prev) => [...prev, a]);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
[onAddAttachment]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const removeAttachment = useCallback(
|
|
105
|
+
(id: string) => {
|
|
106
|
+
if (onRemoveAttachment) {
|
|
107
|
+
onRemoveAttachment(id);
|
|
108
|
+
} else {
|
|
109
|
+
setInternalAttachments((prev) => prev.filter((a) => a.id !== id));
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
[onRemoveAttachment]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const updateAttachment = useCallback(
|
|
116
|
+
(id: string, updates: Partial<Attachment>) => {
|
|
117
|
+
if (onUpdateAttachment) {
|
|
118
|
+
onUpdateAttachment(id, updates);
|
|
119
|
+
} else {
|
|
120
|
+
setInternalAttachments((prev) =>
|
|
121
|
+
prev.map((a) => (a.id === id ? { ...a, ...updates } : a))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
[onUpdateAttachment]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const handleChange = (newValue: string) => {
|
|
129
|
+
setInternalValue(newValue);
|
|
130
|
+
onValueChange?.(newValue);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleSubmit = () => {
|
|
134
|
+
onSubmit?.();
|
|
135
|
+
if (showFullscreen) setShowFullscreen(false);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!showFullscreen) setCurrentHeight(44);
|
|
140
|
+
}, [showFullscreen]);
|
|
141
|
+
|
|
142
|
+
const showExpandIcon = currentHeight > 100;
|
|
143
|
+
const isSimpleMode = !children;
|
|
144
|
+
|
|
145
|
+
const contextValue = {
|
|
146
|
+
isLoading,
|
|
147
|
+
value: value ?? internalValue,
|
|
148
|
+
setValue: onValueChange ?? handleChange,
|
|
149
|
+
maxHeight,
|
|
150
|
+
onSubmit: handleSubmit,
|
|
151
|
+
disabled,
|
|
152
|
+
textareaRef,
|
|
153
|
+
currentHeight,
|
|
154
|
+
setCurrentHeight,
|
|
155
|
+
isFullscreen: showFullscreen,
|
|
156
|
+
onImagePaste,
|
|
157
|
+
attachments,
|
|
158
|
+
addAttachment,
|
|
159
|
+
removeAttachment,
|
|
160
|
+
updateAttachment,
|
|
161
|
+
handleCompletionKey,
|
|
162
|
+
setHandleCompletionKey,
|
|
163
|
+
useSuggestions,
|
|
164
|
+
onSuggestionUsed,
|
|
165
|
+
onError,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const content = isSimpleMode ? (
|
|
169
|
+
<>
|
|
170
|
+
<PromptInputAttachments />
|
|
171
|
+
<PromptInputTextarea
|
|
172
|
+
placeholder={placeholder}
|
|
173
|
+
className="min-h-[44px] text-base py-3"
|
|
174
|
+
/>
|
|
175
|
+
<PromptInputActions className="flex-row items-center justify-between gap-2 mt-2 mb-1 px-3">
|
|
176
|
+
<View className="flex-row items-center gap-1.5">
|
|
177
|
+
{actionsLeft ?? <PromptInputAddMenu />}
|
|
178
|
+
</View>
|
|
179
|
+
<View className="flex-row items-center gap-1.5">
|
|
180
|
+
<PromptInputMicButton apiUrl={apiUrl} />
|
|
181
|
+
<PromptInputSubmitButton
|
|
182
|
+
isLoading={isLoading}
|
|
183
|
+
onStop={onStop}
|
|
184
|
+
emptyAction={emptyAction}
|
|
185
|
+
/>
|
|
186
|
+
</View>
|
|
187
|
+
</PromptInputActions>
|
|
188
|
+
</>
|
|
189
|
+
) : (
|
|
190
|
+
children
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const inputBox = (
|
|
194
|
+
<Pressable
|
|
195
|
+
onPress={() => {
|
|
196
|
+
if (!disabled) textareaRef.current?.focus();
|
|
197
|
+
}}
|
|
198
|
+
disabled={disabled}
|
|
199
|
+
>
|
|
200
|
+
<View
|
|
201
|
+
className={cn(
|
|
202
|
+
"rounded-[24px] border border-border bg-background relative overflow-hidden",
|
|
203
|
+
disabled && "opacity-60",
|
|
204
|
+
className
|
|
205
|
+
)}
|
|
206
|
+
{...props}
|
|
207
|
+
>
|
|
208
|
+
{showExpandIcon && !disabled && (
|
|
209
|
+
<Pressable
|
|
210
|
+
onPress={() => setShowFullscreen(true)}
|
|
211
|
+
className="absolute top-2 right-2 z-10 bg-background rounded-full p-1.5 border border-border active:opacity-70"
|
|
212
|
+
>
|
|
213
|
+
<Maximize2 size={16} className="text-muted-foreground" />
|
|
214
|
+
</Pressable>
|
|
215
|
+
)}
|
|
216
|
+
{content}
|
|
217
|
+
</View>
|
|
218
|
+
</Pressable>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<PromptInputContext.Provider value={contextValue}>
|
|
223
|
+
{autocomplete && autocompletePosition === "top" && !leadingAddMenu && (
|
|
224
|
+
<PromptInputAutocomplete position="top" />
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
<KeyboardAvoidingView behavior="padding">
|
|
228
|
+
{leadingAddMenu ? (
|
|
229
|
+
<View className="flex-row items-end gap-2">
|
|
230
|
+
<PromptInputAddMenu
|
|
231
|
+
iconSize={20}
|
|
232
|
+
className="h-10 w-10 rounded-full border"
|
|
233
|
+
/>
|
|
234
|
+
<View className="flex-1">
|
|
235
|
+
{autocomplete && autocompletePosition === "top" && (
|
|
236
|
+
<PromptInputAutocomplete position="top" />
|
|
237
|
+
)}
|
|
238
|
+
{inputBox}
|
|
239
|
+
{autocomplete && autocompletePosition === "bottom" && (
|
|
240
|
+
<PromptInputAutocomplete position="bottom" />
|
|
241
|
+
)}
|
|
242
|
+
</View>
|
|
243
|
+
</View>
|
|
244
|
+
) : (
|
|
245
|
+
inputBox
|
|
246
|
+
)}
|
|
247
|
+
</KeyboardAvoidingView>
|
|
248
|
+
|
|
249
|
+
{autocomplete && autocompletePosition === "bottom" && !leadingAddMenu && (
|
|
250
|
+
<PromptInputAutocomplete position="bottom" />
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{showFullscreen && (
|
|
254
|
+
<View
|
|
255
|
+
style={{
|
|
256
|
+
position: "fixed" as any,
|
|
257
|
+
top: 0,
|
|
258
|
+
left: 0,
|
|
259
|
+
right: 0,
|
|
260
|
+
bottom: 0,
|
|
261
|
+
zIndex: 9998,
|
|
262
|
+
}}
|
|
263
|
+
className="bg-background"
|
|
264
|
+
>
|
|
265
|
+
<Pressable
|
|
266
|
+
onPress={() => setShowFullscreen(false)}
|
|
267
|
+
className="absolute top-4 right-4 z-50 p-2 active:opacity-70 bg-background/80 rounded-full"
|
|
268
|
+
>
|
|
269
|
+
<Minimize2 size={20} className="text-foreground" />
|
|
270
|
+
</Pressable>
|
|
271
|
+
<View className="flex-1 flex-col">{content}</View>
|
|
272
|
+
</View>
|
|
273
|
+
)}
|
|
274
|
+
</PromptInputContext.Provider>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ArrowUp, Square } from "lucide-react-native";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { Button } from "../button";
|
|
5
|
+
import { usePromptInput } from "./context";
|
|
6
|
+
|
|
7
|
+
export type PromptInputSubmitButtonProps = {
|
|
8
|
+
isLoading?: boolean;
|
|
9
|
+
onStop?: () => void;
|
|
10
|
+
emptyAction?: React.ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function PromptInputSubmitButton({
|
|
15
|
+
isLoading,
|
|
16
|
+
onStop,
|
|
17
|
+
emptyAction,
|
|
18
|
+
className,
|
|
19
|
+
}: PromptInputSubmitButtonProps) {
|
|
20
|
+
const { onSubmit, value, attachments } = usePromptInput();
|
|
21
|
+
const hasContent = value.trim() || attachments.length > 0;
|
|
22
|
+
|
|
23
|
+
if (isLoading && onStop) {
|
|
24
|
+
return (
|
|
25
|
+
<Button
|
|
26
|
+
size="icon"
|
|
27
|
+
onPress={onStop}
|
|
28
|
+
className={cn("h-8 w-8 rounded-full", className)}
|
|
29
|
+
>
|
|
30
|
+
<Square size={12} color="white" className="fill-current" />
|
|
31
|
+
</Button>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!hasContent && emptyAction) {
|
|
36
|
+
return <>{emptyAction}</>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Button
|
|
41
|
+
size="icon"
|
|
42
|
+
onPress={onSubmit}
|
|
43
|
+
disabled={!hasContent}
|
|
44
|
+
className={cn("h-8 w-8 rounded-full", className)}
|
|
45
|
+
>
|
|
46
|
+
<ArrowUp size={16} color="white" />
|
|
47
|
+
</Button>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { ChatTextInput } from "../chat-text-input";
|
|
5
|
+
import { usePromptInput } from "./context";
|
|
6
|
+
|
|
7
|
+
export type PromptInputTextareaProps = {
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
} & React.ComponentProps<typeof ChatTextInput>;
|
|
11
|
+
|
|
12
|
+
export function PromptInputTextarea({
|
|
13
|
+
className,
|
|
14
|
+
placeholder,
|
|
15
|
+
style,
|
|
16
|
+
...props
|
|
17
|
+
}: PromptInputTextareaProps) {
|
|
18
|
+
const {
|
|
19
|
+
value,
|
|
20
|
+
setValue,
|
|
21
|
+
onSubmit,
|
|
22
|
+
disabled,
|
|
23
|
+
textareaRef,
|
|
24
|
+
setCurrentHeight,
|
|
25
|
+
isFullscreen,
|
|
26
|
+
maxHeight,
|
|
27
|
+
onImagePaste,
|
|
28
|
+
handleCompletionKey,
|
|
29
|
+
} = usePromptInput();
|
|
30
|
+
|
|
31
|
+
const textInput = (
|
|
32
|
+
<ChatTextInput
|
|
33
|
+
ref={textareaRef}
|
|
34
|
+
value={value}
|
|
35
|
+
onChangeText={setValue}
|
|
36
|
+
onSubmitEditing={onSubmit}
|
|
37
|
+
onEnterPress={onSubmit}
|
|
38
|
+
onHeightChange={setCurrentHeight}
|
|
39
|
+
onCompletionKey={handleCompletionKey ?? undefined}
|
|
40
|
+
disableEnterToSubmit={isFullscreen}
|
|
41
|
+
disableAutoHeight={isFullscreen}
|
|
42
|
+
maxHeight={isFullscreen ? 10000 : maxHeight}
|
|
43
|
+
onImagePaste={onImagePaste}
|
|
44
|
+
fillContainer={isFullscreen}
|
|
45
|
+
className={cn(
|
|
46
|
+
"w-full border-0 bg-transparent text-foreground shadow-none",
|
|
47
|
+
isFullscreen ? "px-4 pt-4" : "min-h-[44px] py-3",
|
|
48
|
+
className
|
|
49
|
+
)}
|
|
50
|
+
style={[style, isFullscreen && { paddingBottom: 100 }]}
|
|
51
|
+
placeholder={placeholder}
|
|
52
|
+
multiline
|
|
53
|
+
editable={!disabled}
|
|
54
|
+
noFocus={true}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (isFullscreen) {
|
|
60
|
+
return <View style={{ flex: 1 }}>{textInput}</View>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return textInput;
|
|
64
|
+
}
|