mirror of
http://88.130.71.182:3000/BlitTech/contexta_mb.git
synced 2026-06-12 23:23:22 +00:00
307 lines
8.9 KiB
TypeScript
307 lines
8.9 KiB
TypeScript
import React, { useState, useRef, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
Animated,
|
|
} from 'react-native';
|
|
import { COLORS, FONT_SIZE, SPACING, RADIUS, TEXT, SHADOWS } from '../theme';
|
|
import { useTheme } from '../theme';
|
|
import { chatAPI } from '../services/api';
|
|
|
|
interface ChatInterfaceProps {
|
|
chatbotId: string;
|
|
welcomeMessage?: string;
|
|
primaryColor?: string;
|
|
}
|
|
|
|
interface LocalMessage {
|
|
id: string;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
pending?: boolean;
|
|
time?: string;
|
|
}
|
|
|
|
function getTime() {
|
|
return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
export function ChatInterface({ chatbotId, welcomeMessage, primaryColor }: ChatInterfaceProps) {
|
|
const { theme, isDark } = useTheme();
|
|
const accentColor = primaryColor ?? COLORS.primary;
|
|
const flatRef = useRef<FlatList>(null);
|
|
|
|
const [messages, setMessages] = useState<LocalMessage[]>(() =>
|
|
welcomeMessage
|
|
? [{ id: 'welcome', role: 'assistant', content: welcomeMessage, time: getTime() }]
|
|
: [],
|
|
);
|
|
const [input, setInput] = useState('');
|
|
const [sending, setSending] = useState(false);
|
|
const [sessionId, setSessionId] = useState<string | undefined>();
|
|
|
|
const scrollToBottom = useCallback(() => {
|
|
setTimeout(() => flatRef.current?.scrollToEnd({ animated: true }), 80);
|
|
}, []);
|
|
|
|
const sendMessage = async () => {
|
|
const text = input.trim();
|
|
if (!text || sending) return;
|
|
|
|
const userMsg: LocalMessage = { id: Date.now().toString(), role: 'user', content: text, time: getTime() };
|
|
const loadingMsg: LocalMessage = { id: 'loading', role: 'assistant', content: '', pending: true };
|
|
|
|
setMessages(prev => [...prev, userMsg, loadingMsg]);
|
|
setInput('');
|
|
setSending(true);
|
|
scrollToBottom();
|
|
|
|
try {
|
|
const data = await chatAPI.sendMessage(chatbotId, text, sessionId);
|
|
if (!sessionId) setSessionId(data.session_id);
|
|
setMessages(prev => [
|
|
...prev.filter(m => m.id !== 'loading'),
|
|
{ id: (Date.now() + 1).toString(), role: 'assistant', content: data.response, time: getTime() },
|
|
]);
|
|
} catch {
|
|
setMessages(prev => [
|
|
...prev.filter(m => m.id !== 'loading'),
|
|
{ id: (Date.now() + 1).toString(), role: 'assistant', content: "Sorry, I couldn't process your message. Please try again.", time: getTime() },
|
|
]);
|
|
} finally {
|
|
setSending(false);
|
|
scrollToBottom();
|
|
}
|
|
};
|
|
|
|
const renderMessage = ({ item }: { item: LocalMessage }) => {
|
|
const isUser = item.role === 'user';
|
|
return (
|
|
<MessageBubble
|
|
message={item}
|
|
isUser={isUser}
|
|
accentColor={accentColor}
|
|
theme={theme}
|
|
isDark={isDark}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={[styles.container, { backgroundColor: theme.bg }]}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}>
|
|
|
|
<FlatList
|
|
ref={flatRef}
|
|
data={messages}
|
|
keyExtractor={item => item.id}
|
|
renderItem={renderMessage}
|
|
contentContainerStyle={styles.messageList}
|
|
showsVerticalScrollIndicator={false}
|
|
onContentSizeChange={scrollToBottom}
|
|
/>
|
|
|
|
{/* Input bar */}
|
|
<View style={[styles.inputBar, { backgroundColor: theme.surface, borderTopColor: theme.border }]}>
|
|
<View style={[styles.inputWrapper, { backgroundColor: theme.bg, borderColor: theme.border }]}>
|
|
<TextInput
|
|
style={[styles.textInput, { color: theme.text }]}
|
|
value={input}
|
|
onChangeText={setInput}
|
|
placeholder="Message..."
|
|
placeholderTextColor={theme.placeholder}
|
|
multiline
|
|
maxLength={2000}
|
|
onSubmitEditing={sendMessage}
|
|
blurOnSubmit={false}
|
|
/>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.sendBtn,
|
|
{ backgroundColor: accentColor },
|
|
(sending || !input.trim()) && styles.sendBtnDisabled,
|
|
SHADOWS.primary,
|
|
]}
|
|
onPress={sendMessage}
|
|
disabled={sending || !input.trim()}>
|
|
<Text style={styles.sendIcon}>↑</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
function MessageBubble({
|
|
message,
|
|
isUser,
|
|
accentColor,
|
|
theme,
|
|
isDark,
|
|
}: {
|
|
message: LocalMessage;
|
|
isUser: boolean;
|
|
accentColor: string;
|
|
theme: any;
|
|
isDark: boolean;
|
|
}) {
|
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
const slideAnim = useRef(new Animated.Value(8)).current;
|
|
|
|
React.useEffect(() => {
|
|
Animated.parallel([
|
|
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
|
|
Animated.spring(slideAnim, { toValue: 0, useNativeDriver: true, speed: 20, bounciness: 2 }),
|
|
]).start();
|
|
}, []);
|
|
|
|
return (
|
|
<Animated.View
|
|
style={[
|
|
styles.msgRow,
|
|
isUser && styles.msgRowUser,
|
|
{ opacity: fadeAnim, transform: [{ translateY: slideAnim }] },
|
|
]}>
|
|
{!isUser && (
|
|
<View style={[styles.avatar, { backgroundColor: accentColor }]}>
|
|
<Text style={styles.avatarText}>AI</Text>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.bubbleWrapper}>
|
|
<View
|
|
style={[
|
|
styles.bubble,
|
|
isUser
|
|
? [styles.bubbleUser, { backgroundColor: accentColor }]
|
|
: [styles.bubbleBot, { backgroundColor: theme.surface, borderColor: theme.border }],
|
|
]}>
|
|
{message.pending ? (
|
|
<TypingIndicator />
|
|
) : (
|
|
<Text style={[styles.bubbleText, { color: isUser ? COLORS.white : theme.text }]}>
|
|
{message.content}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
{message.time && !message.pending ? (
|
|
<Text style={[styles.timestamp, { color: theme.textMuted, alignSelf: isUser ? 'flex-end' : 'flex-start' }]}>
|
|
{message.time}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
function TypingIndicator() {
|
|
const dot1 = useRef(new Animated.Value(0.3)).current;
|
|
const dot2 = useRef(new Animated.Value(0.3)).current;
|
|
const dot3 = useRef(new Animated.Value(0.3)).current;
|
|
|
|
React.useEffect(() => {
|
|
const pulse = (dot: Animated.Value, delay: number) =>
|
|
Animated.loop(
|
|
Animated.sequence([
|
|
Animated.delay(delay),
|
|
Animated.timing(dot, { toValue: 1, duration: 300, useNativeDriver: true }),
|
|
Animated.timing(dot, { toValue: 0.3, duration: 300, useNativeDriver: true }),
|
|
Animated.delay(600 - delay),
|
|
]),
|
|
);
|
|
|
|
const a1 = pulse(dot1, 0);
|
|
const a2 = pulse(dot2, 200);
|
|
const a3 = pulse(dot3, 400);
|
|
a1.start(); a2.start(); a3.start();
|
|
return () => { a1.stop(); a2.stop(); a3.stop(); };
|
|
}, []);
|
|
|
|
return (
|
|
<View style={typingStyles.row}>
|
|
{[dot1, dot2, dot3].map((dot, i) => (
|
|
<Animated.View key={i} style={[typingStyles.dot, { opacity: dot }]} />
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const typingStyles = StyleSheet.create({
|
|
row: { flexDirection: 'row', gap: 5, paddingVertical: 4, paddingHorizontal: 2 },
|
|
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: COLORS.primary },
|
|
});
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1 },
|
|
messageList: {
|
|
paddingHorizontal: SPACING.md,
|
|
paddingVertical: SPACING.lg,
|
|
gap: SPACING.md,
|
|
paddingBottom: SPACING.xl,
|
|
},
|
|
|
|
msgRow: { flexDirection: 'row', gap: SPACING.sm, alignItems: 'flex-end', maxWidth: '100%' },
|
|
msgRowUser: { flexDirection: 'row-reverse' },
|
|
|
|
avatar: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: RADIUS.full,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
},
|
|
avatarText: { color: COLORS.white, fontSize: 10, fontWeight: '800' },
|
|
|
|
bubbleWrapper: { flex: 1, gap: 4 },
|
|
bubble: { maxWidth: '80%', borderRadius: RADIUS.xl, padding: SPACING.md },
|
|
bubbleUser: { alignSelf: 'flex-end', borderBottomRightRadius: RADIUS.xs },
|
|
bubbleBot: {
|
|
alignSelf: 'flex-start',
|
|
borderWidth: 1,
|
|
borderBottomLeftRadius: RADIUS.xs,
|
|
},
|
|
bubbleText: { fontSize: FONT_SIZE.md, lineHeight: 22 },
|
|
timestamp: { fontSize: 10, paddingHorizontal: 2 },
|
|
|
|
inputBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-end',
|
|
paddingHorizontal: SPACING.md,
|
|
paddingVertical: SPACING.md,
|
|
borderTopWidth: 1,
|
|
gap: SPACING.sm,
|
|
},
|
|
inputWrapper: {
|
|
flex: 1,
|
|
borderRadius: RADIUS.xl,
|
|
borderWidth: 1.5,
|
|
paddingHorizontal: SPACING.md,
|
|
paddingVertical: Platform.OS === 'ios' ? SPACING.sm : 0,
|
|
maxHeight: 120,
|
|
},
|
|
textInput: {
|
|
fontSize: FONT_SIZE.md,
|
|
minHeight: 40,
|
|
paddingTop: Platform.OS === 'android' ? SPACING.sm : 0,
|
|
},
|
|
sendBtn: {
|
|
width: 46,
|
|
height: 46,
|
|
borderRadius: RADIUS.full,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexShrink: 0,
|
|
},
|
|
sendBtnDisabled: { opacity: 0.45 },
|
|
sendIcon: { color: COLORS.white, fontSize: 20, fontWeight: '700', marginTop: -2 },
|
|
});
|