@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.
@@ -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
+ }