@alia.onl/sdk 10.1.0 → 11.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/CHANGELOG.md +62 -0
- package/README.md +24 -0
- package/package.json +5 -7
- package/src/components/AliaAvatar.tsx +66 -0
- package/src/components/AliaChatContent.tsx +235 -112
- package/src/components/AliaChatHeader.tsx +118 -0
- package/src/components/AliaChatMessageList.tsx +171 -260
- package/src/components/AliaChatScreen.tsx +23 -24
- package/src/components/AliaChatSheet.tsx +70 -316
- package/src/components/AliaWelcomeMessage.tsx +47 -50
- package/src/components/AmbientField.tsx +577 -0
- package/src/components/IdentityMark.tsx +10 -2
- package/src/components/Markdown.tsx +116 -40
- package/src/components/PlanPreviewCard.tsx +71 -55
- package/src/components/ThinkingBlock.tsx +47 -0
- package/src/components/VoiceModeIcon.tsx +10 -0
- package/src/components/voice/VoiceControls.tsx +22 -73
- package/src/components/voice/VoiceSession.tsx +2 -1
- package/src/hooks/useAliaChat.ts +22 -15
- package/src/hooks/useAmbientWave.ts +35 -5
- package/src/hooks/useSoundEffects.ts +14 -11
- package/src/hooks/useSpeechToText.ts +9 -2
- package/src/hooks/useTTS.ts +163 -156
- package/src/hooks/useVoiceRoom.ts +228 -170
- package/src/index.ts +18 -6
- package/src/lib/catalogue.ts +17 -4
- package/src/lib/chat-stream.ts +25 -7
- package/src/lib/chat-transport.ts +2 -1
- package/src/lib/citations.ts +63 -0
- package/src/lib/content-utils.ts +3 -2
- package/src/lib/image-data-url.ts +15 -0
- package/src/lib/keyboard.tsx +1 -1
- package/src/lib/labels.tsx +81 -0
- package/src/lib/speech-recognition.native.ts +15 -4
- package/src/lib/speech-recognition.ts +27 -7
- package/src/lib/task-labels.ts +67 -0
- package/src/lib/tool-call-status.ts +28 -0
- package/src/lib/tool-registry.ts +14 -14
- package/src/lib/voice-text.ts +12 -6
- package/src/lib/voice-turn.ts +4 -1
- package/src/lib/work-log-copy.ts +61 -0
- package/src/lib/work-log.ts +378 -0
- package/src/tools.ts +11 -0
- package/src/types.ts +5 -2
- package/src/voice.ts +12 -4
- package/src/components/Reasoning.tsx +0 -152
- package/src/components/ResearchProgressCard.tsx +0 -159
- package/src/components/ThinkingIndicator.tsx +0 -98
- package/src/components/ui/button.tsx +0 -104
- package/src/components/ui/chat-text-input.tsx +0 -185
- package/src/components/ui/dropdown-menu.tsx +0 -2
- package/src/components/ui/dropdown-menu.web.tsx +0 -383
- package/src/components/ui/prompt-input/actions.tsx +0 -28
- package/src/components/ui/prompt-input/add-menu.tsx +0 -83
- package/src/components/ui/prompt-input/attachments.tsx +0 -181
- package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
- package/src/components/ui/prompt-input/context.tsx +0 -71
- package/src/components/ui/prompt-input/mic-button.tsx +0 -53
- package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
- package/src/components/ui/prompt-input/submit-button.tsx +0 -50
- package/src/components/ui/prompt-input/textarea.tsx +0 -80
- package/src/components/ui/text.tsx +0 -29
- package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
- package/src/components/voice/VoiceOverlay.tsx +0 -75
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
import { View, Pressable } from 'react-native';
|
|
3
|
-
import { Text } from './ui/text';
|
|
4
|
-
import { cn } from '../lib/utils';
|
|
5
|
-
import Search from 'lucide-react-native/icons/search';
|
|
6
|
-
import BookOpen from 'lucide-react-native/icons/book-open';
|
|
7
|
-
import CircleCheckBig from 'lucide-react-native/icons/circle-check-big';
|
|
8
|
-
import ChevronDown from 'lucide-react-native/icons/chevron-down';
|
|
9
|
-
import ChevronUp from 'lucide-react-native/icons/chevron-up';
|
|
10
|
-
import ExternalLink from 'lucide-react-native/icons/external-link';
|
|
11
|
-
import LoaderCircle from 'lucide-react-native/icons/loader-circle';
|
|
12
|
-
import type { ResearchProgress } from '../types';
|
|
13
|
-
|
|
14
|
-
interface ResearchProgressCardProps {
|
|
15
|
-
progress: ResearchProgress;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const PHASE_LABELS: Record<string, string> = {
|
|
19
|
-
decomposing: 'Planning research',
|
|
20
|
-
searching: 'Searching the web',
|
|
21
|
-
reading: 'Reading sources',
|
|
22
|
-
synthesizing: 'Synthesizing report',
|
|
23
|
-
follow_up: 'Follow-up research',
|
|
24
|
-
finalizing: 'Polishing report',
|
|
25
|
-
complete: 'Research complete',
|
|
26
|
-
// The engine finished searching but could not write the final answer; the
|
|
27
|
-
// message carries what was found, and this label keeps the card honest.
|
|
28
|
-
failed: 'Research incomplete',
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const PHASE_ORDER = ['decomposing', 'searching', 'synthesizing', 'follow_up', 'finalizing', 'complete'];
|
|
32
|
-
|
|
33
|
-
export function ResearchProgressCard({ progress }: ResearchProgressCardProps) {
|
|
34
|
-
const [showSources, setShowSources] = useState(false);
|
|
35
|
-
const [showQuestions, setShowQuestions] = useState(true);
|
|
36
|
-
|
|
37
|
-
const phaseIndex = PHASE_ORDER.indexOf(progress.phase || '');
|
|
38
|
-
const isComplete = progress.phase === 'complete' || progress.isComplete;
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<View className="mx-3 my-2 rounded-xl border border-border bg-card p-4 gap-3">
|
|
42
|
-
{/* Header */}
|
|
43
|
-
<View className="flex-row items-center gap-2">
|
|
44
|
-
<View className="w-8 h-8 rounded-full bg-primary/10 items-center justify-center">
|
|
45
|
-
{isComplete ? (
|
|
46
|
-
<CircleCheckBig size={16} className="text-green-500" />
|
|
47
|
-
) : (
|
|
48
|
-
<Search size={16} className="text-primary" />
|
|
49
|
-
)}
|
|
50
|
-
</View>
|
|
51
|
-
<View className="flex-1">
|
|
52
|
-
<Text className="text-sm font-semibold text-foreground">
|
|
53
|
-
Deep Research
|
|
54
|
-
</Text>
|
|
55
|
-
<Text className="text-xs text-muted-foreground">
|
|
56
|
-
{progress.message || PHASE_LABELS[progress.phase || ''] || 'Researching...'}
|
|
57
|
-
</Text>
|
|
58
|
-
</View>
|
|
59
|
-
{progress.sourcesFound !== undefined && (
|
|
60
|
-
<View className="bg-muted rounded-full px-2 py-0.5">
|
|
61
|
-
<Text className="text-[10px] text-muted-foreground">
|
|
62
|
-
{progress.sourcesFound} sources
|
|
63
|
-
</Text>
|
|
64
|
-
</View>
|
|
65
|
-
)}
|
|
66
|
-
</View>
|
|
67
|
-
|
|
68
|
-
{/* Phase progress */}
|
|
69
|
-
<View className="flex-row gap-1">
|
|
70
|
-
{PHASE_ORDER.map((phase, i) => (
|
|
71
|
-
<View
|
|
72
|
-
key={phase}
|
|
73
|
-
className={cn(
|
|
74
|
-
'flex-1 h-1 rounded-full',
|
|
75
|
-
i <= phaseIndex
|
|
76
|
-
? (isComplete ? 'bg-green-500' : 'bg-primary')
|
|
77
|
-
: 'bg-muted',
|
|
78
|
-
)}
|
|
79
|
-
/>
|
|
80
|
-
))}
|
|
81
|
-
</View>
|
|
82
|
-
|
|
83
|
-
{/* Sub-questions */}
|
|
84
|
-
{progress.subQuestions && progress.subQuestions.length > 0 && (
|
|
85
|
-
<View>
|
|
86
|
-
<Pressable
|
|
87
|
-
onPress={() => setShowQuestions(!showQuestions)}
|
|
88
|
-
className="flex-row items-center gap-1 mb-1"
|
|
89
|
-
>
|
|
90
|
-
<BookOpen size={12} className="text-muted-foreground" />
|
|
91
|
-
<Text className="text-xs font-medium text-muted-foreground">
|
|
92
|
-
Research angles ({progress.subQuestions.length})
|
|
93
|
-
</Text>
|
|
94
|
-
{showQuestions ? (
|
|
95
|
-
<ChevronUp size={12} className="text-muted-foreground" />
|
|
96
|
-
) : (
|
|
97
|
-
<ChevronDown size={12} className="text-muted-foreground" />
|
|
98
|
-
)}
|
|
99
|
-
</Pressable>
|
|
100
|
-
{showQuestions && (
|
|
101
|
-
<View className="gap-1 pl-4">
|
|
102
|
-
{progress.subQuestions.map((q, i) => (
|
|
103
|
-
<Text key={i} className="text-xs text-muted-foreground">
|
|
104
|
-
{i + 1}. {q}
|
|
105
|
-
</Text>
|
|
106
|
-
))}
|
|
107
|
-
</View>
|
|
108
|
-
)}
|
|
109
|
-
</View>
|
|
110
|
-
)}
|
|
111
|
-
|
|
112
|
-
{/* Current search query indicator */}
|
|
113
|
-
{progress.currentQuery && !isComplete && (
|
|
114
|
-
<View className="flex-row items-center gap-2 bg-muted/50 rounded-lg px-3 py-1.5">
|
|
115
|
-
<LoaderCircle size={12} className="text-muted-foreground" />
|
|
116
|
-
<Text className="text-xs text-muted-foreground flex-1" numberOfLines={1}>
|
|
117
|
-
{progress.currentQuery}
|
|
118
|
-
</Text>
|
|
119
|
-
</View>
|
|
120
|
-
)}
|
|
121
|
-
|
|
122
|
-
{/* Sources list (collapsed by default) */}
|
|
123
|
-
{progress.sources && progress.sources.length > 0 && (
|
|
124
|
-
<View>
|
|
125
|
-
<Pressable
|
|
126
|
-
onPress={() => setShowSources(!showSources)}
|
|
127
|
-
className="flex-row items-center gap-1"
|
|
128
|
-
>
|
|
129
|
-
<ExternalLink size={12} className="text-muted-foreground" />
|
|
130
|
-
<Text className="text-xs font-medium text-muted-foreground">
|
|
131
|
-
Sources ({progress.sources.length})
|
|
132
|
-
</Text>
|
|
133
|
-
{showSources ? (
|
|
134
|
-
<ChevronUp size={12} className="text-muted-foreground" />
|
|
135
|
-
) : (
|
|
136
|
-
<ChevronDown size={12} className="text-muted-foreground" />
|
|
137
|
-
)}
|
|
138
|
-
</Pressable>
|
|
139
|
-
{showSources && (
|
|
140
|
-
<View className="gap-1 pl-4 mt-1">
|
|
141
|
-
{progress.sources.map((s) => (
|
|
142
|
-
<Text key={s.id} className="text-xs text-primary" numberOfLines={1}>
|
|
143
|
-
[{s.id}] {s.title}
|
|
144
|
-
</Text>
|
|
145
|
-
))}
|
|
146
|
-
</View>
|
|
147
|
-
)}
|
|
148
|
-
</View>
|
|
149
|
-
)}
|
|
150
|
-
|
|
151
|
-
{/* Search count */}
|
|
152
|
-
{progress.totalSearches !== undefined && isComplete && (
|
|
153
|
-
<Text className="text-[10px] text-muted-foreground text-right">
|
|
154
|
-
{progress.totalSearches} searches performed
|
|
155
|
-
</Text>
|
|
156
|
-
)}
|
|
157
|
-
</View>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { View, Text } from "react-native";
|
|
3
|
-
import Animated, {
|
|
4
|
-
useAnimatedStyle,
|
|
5
|
-
withRepeat,
|
|
6
|
-
withTiming,
|
|
7
|
-
withSequence,
|
|
8
|
-
} from "react-native-reanimated";
|
|
9
|
-
import { IdentityMark } from "./IdentityMark";
|
|
10
|
-
|
|
11
|
-
const thinkingPhrases = [
|
|
12
|
-
"Thinking...",
|
|
13
|
-
"Crafting...",
|
|
14
|
-
"Pondering...",
|
|
15
|
-
"Computing...",
|
|
16
|
-
"Processing...",
|
|
17
|
-
"Analyzing...",
|
|
18
|
-
"Reasoning...",
|
|
19
|
-
"Cooking...",
|
|
20
|
-
"Brewing...",
|
|
21
|
-
"Conjuring...",
|
|
22
|
-
];
|
|
23
|
-
|
|
24
|
-
const workingPhrases = [
|
|
25
|
-
"Working...",
|
|
26
|
-
"Executing...",
|
|
27
|
-
"Running...",
|
|
28
|
-
"Building...",
|
|
29
|
-
"Creating...",
|
|
30
|
-
"Doing the thing...",
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
export function ThinkingIndicator({ isWorking = false, statusText, color }: { isWorking?: boolean; statusText?: string; color?: string }) {
|
|
34
|
-
const phrases = isWorking ? workingPhrases : thinkingPhrases;
|
|
35
|
-
const [phraseIndex, setPhraseIndex] = useState(() =>
|
|
36
|
-
Math.floor(Math.random() * phrases.length)
|
|
37
|
-
);
|
|
38
|
-
const [displayText, setDisplayText] = useState("");
|
|
39
|
-
const [isTyping, setIsTyping] = useState(true);
|
|
40
|
-
|
|
41
|
-
// Reset phraseIndex when isWorking changes (arrays have different lengths)
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
setPhraseIndex(Math.floor(Math.random() * phrases.length));
|
|
44
|
-
}, [isWorking]);
|
|
45
|
-
|
|
46
|
-
// Pulsing cursor animation (effect-free, declarative)
|
|
47
|
-
const cursorStyle = useAnimatedStyle(() => ({
|
|
48
|
-
opacity: withRepeat(
|
|
49
|
-
withSequence(
|
|
50
|
-
withTiming(0.2, { duration: 400 }),
|
|
51
|
-
withTiming(1, { duration: 400 })
|
|
52
|
-
),
|
|
53
|
-
-1
|
|
54
|
-
),
|
|
55
|
-
}));
|
|
56
|
-
|
|
57
|
-
// Typewriter effect — skipped when statusText is provided (real-time status shown directly)
|
|
58
|
-
useEffect(() => {
|
|
59
|
-
if (statusText) return;
|
|
60
|
-
const phrase = phrases[phraseIndex % phrases.length];
|
|
61
|
-
let charIndex = 0;
|
|
62
|
-
setIsTyping(true);
|
|
63
|
-
setDisplayText("");
|
|
64
|
-
|
|
65
|
-
const typeInterval = setInterval(() => {
|
|
66
|
-
if (charIndex < phrase.length) {
|
|
67
|
-
setDisplayText(phrase.slice(0, charIndex + 1));
|
|
68
|
-
charIndex++;
|
|
69
|
-
} else {
|
|
70
|
-
clearInterval(typeInterval);
|
|
71
|
-
setIsTyping(false);
|
|
72
|
-
|
|
73
|
-
// Wait then switch to next phrase
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
setPhraseIndex((prev) => (prev + 1) % phrases.length);
|
|
76
|
-
}, 1500);
|
|
77
|
-
}
|
|
78
|
-
}, 40);
|
|
79
|
-
|
|
80
|
-
return () => clearInterval(typeInterval);
|
|
81
|
-
}, [phraseIndex, phrases, statusText]);
|
|
82
|
-
|
|
83
|
-
const shownText = statusText || displayText;
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<View className="flex-row items-center gap-2 py-2">
|
|
87
|
-
<IdentityMark size={16} state={isWorking ? 'working' : 'thinking'} color={color} />
|
|
88
|
-
<View className="flex-row items-center">
|
|
89
|
-
<Text className="text-base text-muted-foreground">{shownText}</Text>
|
|
90
|
-
{(statusText || isTyping) && (
|
|
91
|
-
<Animated.View style={cursorStyle}>
|
|
92
|
-
<Text className="text-base text-muted-foreground">|</Text>
|
|
93
|
-
</Animated.View>
|
|
94
|
-
)}
|
|
95
|
-
</View>
|
|
96
|
-
</View>
|
|
97
|
-
);
|
|
98
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
-
import * as React from "react";
|
|
3
|
-
import { Pressable, ActivityIndicator } from "react-native";
|
|
4
|
-
import { TextClassContext } from "./text";
|
|
5
|
-
import { cn } from "../../lib/utils";
|
|
6
|
-
|
|
7
|
-
const buttonVariants = cva(
|
|
8
|
-
"group flex items-center justify-center rounded-xl web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
|
9
|
-
{
|
|
10
|
-
variants: {
|
|
11
|
-
variant: {
|
|
12
|
-
default: "bg-primary web:hover:opacity-90 active:opacity-90",
|
|
13
|
-
destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
|
|
14
|
-
outline:
|
|
15
|
-
"border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
|
16
|
-
secondary: "bg-muted web:hover:opacity-80 active:opacity-80",
|
|
17
|
-
ghost:
|
|
18
|
-
"web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
|
|
19
|
-
link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
|
|
20
|
-
pill: "text-primary-foreground rounded-full bg-black web:hover:opacity-90 active:opacity-90",
|
|
21
|
-
},
|
|
22
|
-
size: {
|
|
23
|
-
default: "h-9 px-3.5 py-2 native:h-11 native:px-4",
|
|
24
|
-
sm: "h-8 rounded-lg px-2.5",
|
|
25
|
-
lg: "h-10 rounded-xl px-6 native:h-12",
|
|
26
|
-
icon: "h-9 w-9",
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
defaultVariants: {
|
|
30
|
-
variant: "default",
|
|
31
|
-
size: "default",
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
const buttonTextVariants = cva(
|
|
37
|
-
"web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors",
|
|
38
|
-
{
|
|
39
|
-
variants: {
|
|
40
|
-
variant: {
|
|
41
|
-
default: "text-primary-foreground",
|
|
42
|
-
destructive: "text-destructive-foreground",
|
|
43
|
-
outline: "group-active:text-accent-foreground",
|
|
44
|
-
secondary:
|
|
45
|
-
"text-muted-foreground group-active:text-muted-foreground",
|
|
46
|
-
ghost: "group-active:text-accent-foreground",
|
|
47
|
-
link: "text-primary group-active:underline",
|
|
48
|
-
pill: "text-primary-foreground",
|
|
49
|
-
},
|
|
50
|
-
size: {
|
|
51
|
-
default: "",
|
|
52
|
-
sm: "",
|
|
53
|
-
lg: "native:text-lg",
|
|
54
|
-
icon: "",
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
defaultVariants: {
|
|
58
|
-
variant: "default",
|
|
59
|
-
size: "default",
|
|
60
|
-
},
|
|
61
|
-
},
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
type ButtonProps = React.ComponentPropsWithoutRef<typeof Pressable> &
|
|
65
|
-
VariantProps<typeof buttonVariants> & {
|
|
66
|
-
isLoading?: boolean;
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const Button = React.forwardRef<
|
|
70
|
-
React.ElementRef<typeof Pressable>,
|
|
71
|
-
ButtonProps
|
|
72
|
-
>(({ className, variant, size, isLoading, children, ...props }, ref) => {
|
|
73
|
-
return (
|
|
74
|
-
<TextClassContext.Provider
|
|
75
|
-
value={cn(
|
|
76
|
-
props.disabled && "web:pointer-events-none",
|
|
77
|
-
buttonTextVariants({ variant, size }),
|
|
78
|
-
)}
|
|
79
|
-
>
|
|
80
|
-
<Pressable
|
|
81
|
-
className={cn(
|
|
82
|
-
props.disabled && "opacity-50 web:pointer-events-none",
|
|
83
|
-
isLoading && "web:pointer-events-none",
|
|
84
|
-
buttonVariants({ variant, size, className }),
|
|
85
|
-
)}
|
|
86
|
-
ref={ref}
|
|
87
|
-
role="button"
|
|
88
|
-
{...props}
|
|
89
|
-
>
|
|
90
|
-
{isLoading ? (
|
|
91
|
-
<ActivityIndicator
|
|
92
|
-
color={variant === "outline" ? "black" : "white"}
|
|
93
|
-
/>
|
|
94
|
-
) : (
|
|
95
|
-
children
|
|
96
|
-
)}
|
|
97
|
-
</Pressable>
|
|
98
|
-
</TextClassContext.Provider>
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
Button.displayName = "Button";
|
|
102
|
-
|
|
103
|
-
export { Button, buttonTextVariants, buttonVariants };
|
|
104
|
-
export type { ButtonProps };
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import {
|
|
3
|
-
TextInput,
|
|
4
|
-
Platform,
|
|
5
|
-
View,
|
|
6
|
-
type NativeSyntheticEvent,
|
|
7
|
-
type TextInputKeyPressEventData,
|
|
8
|
-
type NativeSyntheticEvent as RNSyntheticEvent,
|
|
9
|
-
type TextInputContentSizeChangeEventData,
|
|
10
|
-
type TextStyle,
|
|
11
|
-
} from "react-native";
|
|
12
|
-
import { cn } from "../../lib/utils";
|
|
13
|
-
|
|
14
|
-
// react-native-web forwards the DOM KeyboardEvent modifier flags on the key-press
|
|
15
|
-
// nativeEvent, but React Native's `TextInputKeyPressEventData` only declares `key`.
|
|
16
|
-
// Augment the type (web-only field, hence optional) so `shiftKey` is readable without a cast.
|
|
17
|
-
declare module "react-native" {
|
|
18
|
-
interface TextInputKeyPressEventData {
|
|
19
|
-
shiftKey?: boolean;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
|
|
24
|
-
unstyled?: boolean;
|
|
25
|
-
noFocus?: boolean;
|
|
26
|
-
onEnterPress?: () => void;
|
|
27
|
-
onCompletionKey?: (key: string) => boolean;
|
|
28
|
-
maxHeight?: number;
|
|
29
|
-
minHeight?: number;
|
|
30
|
-
onHeightChange?: (height: number) => void;
|
|
31
|
-
disableEnterToSubmit?: boolean;
|
|
32
|
-
disableAutoHeight?: boolean;
|
|
33
|
-
onImagePaste?: (files: File[]) => void;
|
|
34
|
-
fillContainer?: boolean;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
|
|
38
|
-
({
|
|
39
|
-
className,
|
|
40
|
-
unstyled = false,
|
|
41
|
-
noFocus = false,
|
|
42
|
-
onEnterPress,
|
|
43
|
-
onCompletionKey,
|
|
44
|
-
onKeyPress,
|
|
45
|
-
maxHeight = 200,
|
|
46
|
-
minHeight = 44,
|
|
47
|
-
onContentSizeChange,
|
|
48
|
-
onHeightChange,
|
|
49
|
-
style,
|
|
50
|
-
disableEnterToSubmit = false,
|
|
51
|
-
disableAutoHeight = false,
|
|
52
|
-
onImagePaste,
|
|
53
|
-
fillContainer = false,
|
|
54
|
-
...props
|
|
55
|
-
}, ref) => {
|
|
56
|
-
const inputRef = React.useRef<TextInput>(null);
|
|
57
|
-
const wrapperRef = React.useRef<View>(null);
|
|
58
|
-
|
|
59
|
-
// react-native-css observes `style` by reference. Memoizing the composed
|
|
60
|
-
// value prevents its own state update from manufacturing another changed
|
|
61
|
-
// style prop and recursively re-rendering this TextInput on native.
|
|
62
|
-
const inputStyle = React.useMemo(() => [
|
|
63
|
-
style,
|
|
64
|
-
!fillContainer && props.multiline && !disableAutoHeight && ({
|
|
65
|
-
minHeight,
|
|
66
|
-
maxHeight,
|
|
67
|
-
...(Platform.OS === 'web'
|
|
68
|
-
? { overflow: 'auto' as const, fieldSizing: 'content' }
|
|
69
|
-
: {}),
|
|
70
|
-
} as TextStyle & { fieldSizing?: string }),
|
|
71
|
-
fillContainer && { flex: 1, height: '100%' as const },
|
|
72
|
-
], [
|
|
73
|
-
disableAutoHeight,
|
|
74
|
-
fillContainer,
|
|
75
|
-
maxHeight,
|
|
76
|
-
minHeight,
|
|
77
|
-
props.multiline,
|
|
78
|
-
style,
|
|
79
|
-
]);
|
|
80
|
-
|
|
81
|
-
React.useImperativeHandle(ref, () => inputRef.current as TextInput);
|
|
82
|
-
|
|
83
|
-
// Attach paste event listener (web only)
|
|
84
|
-
React.useEffect(() => {
|
|
85
|
-
if (Platform.OS !== 'web' || !onImagePaste) return;
|
|
86
|
-
|
|
87
|
-
const handlePaste = (e: Event) => {
|
|
88
|
-
const clipboardEvent = e as ClipboardEvent;
|
|
89
|
-
|
|
90
|
-
const activeElement = document.activeElement;
|
|
91
|
-
const wrapper = wrapperRef.current;
|
|
92
|
-
// On web the wrapper ref resolves to its underlying DOM node — only react to
|
|
93
|
-
// pastes whose focused element lives inside this input.
|
|
94
|
-
const isContained = wrapper instanceof HTMLElement && wrapper.contains(activeElement);
|
|
95
|
-
|
|
96
|
-
if (!isContained) return;
|
|
97
|
-
|
|
98
|
-
const items = clipboardEvent.clipboardData?.items;
|
|
99
|
-
if (!items) return;
|
|
100
|
-
|
|
101
|
-
const imageFiles: File[] = [];
|
|
102
|
-
for (let i = 0; i < items.length; i++) {
|
|
103
|
-
const item = items[i];
|
|
104
|
-
if (item.type.indexOf('image') !== -1) {
|
|
105
|
-
const file = item.getAsFile();
|
|
106
|
-
if (file) {
|
|
107
|
-
imageFiles.push(file);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (imageFiles.length > 0) {
|
|
113
|
-
clipboardEvent.preventDefault();
|
|
114
|
-
onImagePaste(imageFiles);
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
document.addEventListener('paste', handlePaste);
|
|
119
|
-
|
|
120
|
-
return () => {
|
|
121
|
-
document.removeEventListener('paste', handlePaste);
|
|
122
|
-
};
|
|
123
|
-
}, [onImagePaste]);
|
|
124
|
-
|
|
125
|
-
const handleKeyPress = (
|
|
126
|
-
e: NativeSyntheticEvent<TextInputKeyPressEventData>
|
|
127
|
-
) => {
|
|
128
|
-
onKeyPress?.(e);
|
|
129
|
-
|
|
130
|
-
const key = e.nativeEvent.key;
|
|
131
|
-
|
|
132
|
-
if (onCompletionKey && (key === "ArrowUp" || key === "ArrowDown" || key === "Enter" || key === "Escape")) {
|
|
133
|
-
if (onCompletionKey(key)) {
|
|
134
|
-
e.preventDefault();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (key === "Enter" && !disableEnterToSubmit) {
|
|
140
|
-
if (Platform.OS !== 'web' || !e.nativeEvent.shiftKey) {
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
onEnterPress?.();
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
const handleContentSizeChange = (
|
|
148
|
-
e: RNSyntheticEvent<TextInputContentSizeChangeEventData>
|
|
149
|
-
) => {
|
|
150
|
-
onContentSizeChange?.(e);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
<View
|
|
155
|
-
ref={wrapperRef}
|
|
156
|
-
style={{ width: '100%', ...(fillContainer && { flex: 1 }) }}
|
|
157
|
-
onLayout={(e) => onHeightChange?.(e.nativeEvent.layout.height)}
|
|
158
|
-
>
|
|
159
|
-
<TextInput
|
|
160
|
-
ref={inputRef}
|
|
161
|
-
className={cn(
|
|
162
|
-
"native:text-md native:leading-[1.25] text-base text-foreground file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground web:flex web:w-full web:py-2 lg:text-sm",
|
|
163
|
-
!unstyled && "rounded-xl border border-input bg-background px-3.5",
|
|
164
|
-
"web:ring-offset-background web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
|
|
165
|
-
!fillContainer && !props.multiline && "h-9",
|
|
166
|
-
fillContainer && "h-full",
|
|
167
|
-
noFocus && "web:focus-visible:ring-0 web:focus-visible:ring-offset-0",
|
|
168
|
-
props.editable === false && "opacity-50 web:cursor-not-allowed",
|
|
169
|
-
className
|
|
170
|
-
)}
|
|
171
|
-
placeholderClassName={cn("text-muted-foreground", props.placeholderClassName)}
|
|
172
|
-
onKeyPress={handleKeyPress}
|
|
173
|
-
onContentSizeChange={handleContentSizeChange}
|
|
174
|
-
scrollEnabled={fillContainer || props.multiline}
|
|
175
|
-
style={inputStyle}
|
|
176
|
-
{...props}
|
|
177
|
-
/>
|
|
178
|
-
</View>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
ChatTextInput.displayName = "ChatTextInput";
|
|
184
|
-
|
|
185
|
-
export { ChatTextInput };
|