learning_ai_invt_trdg/mobile/app/chat.tsx

381 lines
10 KiB
TypeScript

import React, { useState, useRef } from 'react';
import {
View,
Text,
ScrollView,
TextInput,
Pressable,
StyleSheet,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { X, ArrowUp, Cpu } from 'lucide-react-native';
import Animated, { FadeIn, SlideInDown } from 'react-native-reanimated';
import { Colors, Fonts, FontSize, BorderRadius, Spacing } from '@/constants/theme';
import { chatSuggestions } from '@/constants/mockData';
import PressableScale from '@/components/PressableScale';
import { useMobileAuth } from '@/providers/MobileAuthProvider';
import { mobileRuntime } from '@/lib/runtime';
import { createRequestId } from '../../shared/request-id.js';
interface ChatMessage {
id: string;
role: 'user' | 'bot';
text: string;
}
function MessageBubble({ message }: { message: ChatMessage }) {
const isBot = message.role === 'bot';
return (
<Animated.View
entering={FadeIn.duration(250)}
style={[styles.msgRow, isBot ? styles.msgRowBot : styles.msgRowUser]}
>
{isBot && (
<View style={styles.botAvatar}>
<Cpu size={16} color={Colors.accent.green} />
</View>
)}
<LinearGradient
colors={isBot
? ['rgba(255,255,255,0.04)', 'rgba(255,255,255,0.015)']
: ['rgba(59,130,246,0.15)', 'rgba(59,130,246,0.08)']
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[
styles.bubble,
isBot ? styles.botBubble : styles.userBubble,
]}
>
<Text style={[styles.msgText, isBot ? styles.botText : styles.userText]}>
{message.text}
</Text>
</LinearGradient>
</Animated.View>
);
}
export default function ChatScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { accessToken } = useMobileAuth();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputText, setInputText] = useState('');
const [sending, setSending] = useState(false);
const scrollRef = useRef<ScrollView>(null);
const sendMessage = async (text: string) => {
if (!text.trim() || sending) return;
const userMsg: ChatMessage = { id: `user-${Date.now()}`, role: 'user', text: text.trim() };
setMessages(prev => [...prev, userMsg]);
setInputText('');
setSending(true);
try {
const res = await fetch(`${mobileRuntime.tradingApiUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
'x-request-id': createRequestId('mobile-chat'),
},
body: JSON.stringify({ message: text.trim(), context: [] }),
});
if (!res.ok) throw new Error(`Chat request failed (${res.status})`);
const body = await res.json();
const reply = body.summary || body.message || body.response || 'No response from assistant.';
const botMsg: ChatMessage = { id: `bot-${Date.now()}`, role: 'bot', text: reply };
setMessages(prev => [...prev, botMsg]);
} catch (e) {
const errMsg: ChatMessage = {
id: `bot-err-${Date.now()}`,
role: 'bot',
text: `Error: ${e instanceof Error ? e.message : 'Failed to reach assistant'}`,
};
setMessages(prev => [...prev, errMsg]);
} finally {
setSending(false);
}
};
return (
<Animated.View
entering={SlideInDown.duration(300).springify().damping(20)}
style={[styles.container, { paddingTop: insets.top }]}
>
<LinearGradient
colors={['#14151f', '#0f1017']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.header}
>
<View style={styles.headerAvatar}>
<Cpu size={20} color={Colors.accent.green} />
</View>
<View style={styles.headerInfo}>
<Text style={styles.headerTitle}>Bytelyst AI</Text>
<Text style={styles.headerSubtitle}>Trading Assistant</Text>
</View>
<Pressable style={styles.closeBtn} onPress={() => router.back()}>
<X size={20} color={Colors.text.secondary} />
</Pressable>
</LinearGradient>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={0}
>
<ScrollView
ref={scrollRef}
style={styles.messages}
contentContainerStyle={styles.messagesContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
>
{messages.length === 0 && (
<View style={styles.welcomeMsg}>
<Cpu size={28} color={Colors.accent.green} />
<Text style={styles.welcomeTitle}>Bytelyst AI</Text>
<Text style={styles.welcomeText}>Ask about your strategies, market conditions, or get trade recommendations.</Text>
</View>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{messages.length === 0 && (
<View style={styles.suggestions}>
{chatSuggestions.map((s) => (
<PressableScale
key={s}
style={styles.suggestionChip}
onPress={() => void sendMessage(s)}
>
<Text style={styles.suggestionText}>{s}</Text>
</PressableScale>
))}
</View>
)}
</ScrollView>
<View style={[styles.inputBar, { paddingBottom: Math.max(insets.bottom, 12) }]}>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="Ask anything..."
placeholderTextColor={Colors.text.ultraDim}
value={inputText}
onChangeText={setInputText}
onSubmitEditing={() => void sendMessage(inputText)}
selectionColor={Colors.accent.green}
editable={!sending}
/>
<PressableScale
haptic="medium"
style={[styles.sendBtnWrapper, sending && styles.sendBtnDisabled]}
onPress={() => void sendMessage(inputText)}
>
<LinearGradient
colors={sending ? ['#333', '#222'] : ['#00ff88', '#00cc6a']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.sendBtn}
>
<ArrowUp size={18} color={sending ? '#666' : '#000'} strokeWidth={3} />
</LinearGradient>
</PressableScale>
</View>
</View>
</KeyboardAvoidingView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.background.primary,
},
flex: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: Colors.border.default,
gap: 12,
},
headerAvatar: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: 'rgba(0,255,136,0.1)',
borderWidth: 1,
borderColor: 'rgba(0,255,136,0.2)',
alignItems: 'center',
justifyContent: 'center',
},
headerInfo: {
flex: 1,
},
headerTitle: {
fontFamily: Fonts.inter.extraBold,
fontSize: FontSize.body,
color: Colors.text.primary,
},
headerSubtitle: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.micro,
color: Colors.text.secondary,
},
closeBtn: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: 'rgba(255,255,255,0.05)',
alignItems: 'center',
justifyContent: 'center',
},
messages: {
flex: 1,
},
messagesContent: {
padding: Spacing.screenPadding,
gap: 14,
paddingBottom: 20,
},
welcomeMsg: {
alignItems: 'center',
gap: 12,
paddingVertical: 32,
paddingHorizontal: 24,
},
welcomeTitle: {
fontFamily: Fonts.inter.black,
fontSize: FontSize.subheading,
color: Colors.text.primary,
},
welcomeText: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
color: Colors.text.secondary,
textAlign: 'center',
lineHeight: 22,
},
msgRow: {
flexDirection: 'row',
gap: 10,
maxWidth: '85%',
},
msgRowBot: {
alignSelf: 'flex-start',
},
msgRowUser: {
alignSelf: 'flex-end',
},
botAvatar: {
width: 30,
height: 30,
borderRadius: 10,
backgroundColor: 'rgba(0,255,136,0.1)',
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
},
bubble: {
padding: 14,
paddingHorizontal: 16,
borderRadius: 16,
borderWidth: 1,
flexShrink: 1,
},
botBubble: {
borderTopLeftRadius: 4,
borderColor: 'rgba(255,255,255,0.06)',
},
userBubble: {
borderTopRightRadius: 4,
borderColor: 'rgba(59,130,246,0.2)',
},
msgText: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
lineHeight: 20,
},
botText: {
color: '#d4d4d8',
},
userText: {
color: '#93c5fd',
},
suggestions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 8,
},
suggestionChip: {
backgroundColor: 'rgba(255,255,255,0.05)',
borderWidth: 1,
borderColor: Colors.border.medium,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
},
suggestionText: {
fontFamily: Fonts.inter.medium,
fontSize: FontSize.bodySmall,
color: Colors.text.secondary,
},
inputBar: {
paddingHorizontal: Spacing.screenPadding,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: Colors.border.default,
backgroundColor: Colors.background.primary,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#161722',
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
paddingLeft: 16,
paddingRight: 6,
gap: 8,
},
input: {
flex: 1,
fontFamily: Fonts.inter.medium,
fontSize: FontSize.body,
color: Colors.text.primary,
paddingVertical: 12,
},
sendBtnWrapper: {
borderRadius: 10,
},
sendBtnDisabled: {
opacity: 0.5,
},
sendBtn: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
});