mirror of
http://88.130.71.182:3000/BlitTech/contexta_mb.git
synced 2026-06-13 10:21:31 +00:00
Initial commit
This commit is contained in:
306
src/components/ChatInterface.tsx
Normal file
306
src/components/ChatInterface.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
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 },
|
||||
});
|
||||
Reference in New Issue
Block a user