Files
height_manager/client/screens/chat/index.tsx

240 lines
7.8 KiB
TypeScript
Raw Normal View History

import React, { useState, useMemo, useRef, useEffect } from 'react';
import { View, ScrollView, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
import RNSSE from 'react-native-sse';
import { useTheme } from '@/hooks/useTheme';
import { Screen } from '@/components/Screen';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { FontAwesome6 } from '@expo/vector-icons';
import { createStyles } from './styles';
interface Message {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
export default function ChatScreen() {
const { theme, isDark } = useTheme();
const styles = useMemo(() => createStyles(theme), [theme]);
const [messages, setMessages] = useState<Message[]>(() => [{
role: 'assistant',
content: '你好!我是你的健康饮食和减脂顾问助手。我可以帮助你:\n\n• 制定科学的饮食计划\n• 分析食物营养成分\n• 提供减脂建议\n• 解答健康问题\n\n请问有什么可以帮助你的吗',
timestamp: Date.now(),
}]);
const [inputText, setInputText] = useState('');
const [loading, setLoading] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const sseRef = useRef<RNSSE | null>(null);
useEffect(() => {
return () => {
if (sseRef.current) {
sseRef.current.close();
}
};
}, []);
const sendMessage = async () => {
if (!inputText.trim() || loading) return;
const userMessage: Message = {
role: 'user',
content: inputText.trim(),
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMessage]);
setInputText('');
setLoading(true);
// 构建对话历史
const conversationHistory = messages
.slice(1) // 跳过欢迎消息
.map((msg) => ({ role: msg.role, content: msg.content }));
try {
/**
* server/src/routes/ai-chat.ts
* POST /api/v1/ai-chat/chat
* Body message: string, conversationHistory?: Array<{role: string, content: string}>
*/
const url = `${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/ai-chat/chat`;
const sse = new RNSSE(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: inputText.trim(),
conversationHistory,
}),
});
sseRef.current = sse;
let assistantContent = '';
const assistantMessage: Message = {
role: 'assistant',
content: '',
timestamp: Date.now(),
};
// 添加空的助手消息
setMessages((prev) => [...prev, assistantMessage]);
sse.addEventListener('message', (event: any) => {
if (event.data === '[DONE]') {
sse.close();
setLoading(false);
return;
}
try {
const data = JSON.parse(event.data);
if (data.content) {
assistantContent += data.content;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: assistantContent,
};
return updated;
});
}
} catch (e) {
console.error('Parse error:', e);
}
});
sse.addEventListener('error', (error: any) => {
console.error('SSE error:', error);
sse.close();
setLoading(false);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
...updated[updated.length - 1],
content: assistantContent || '抱歉,我遇到了一些问题,请稍后再试。',
};
return updated;
});
});
} catch (error) {
console.error('Send message error:', error);
setLoading(false);
setMessages((prev) => [
...prev,
{
role: 'assistant',
content: '抱歉,发送消息失败,请检查网络连接。',
timestamp: Date.now(),
},
]);
}
};
return (
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
<ThemedView level="root" style={styles.header}>
<ThemedText variant="h4" color={theme.textPrimary}>
AI
</ThemedText>
<View style={styles.headerStatus}>
<View style={[styles.statusDot, { backgroundColor: loading ? '#10B981' : '#9CA3AF' }]} />
<ThemedText variant="caption" color={theme.textMuted}>
{loading ? '正在回复...' : '在线'}
</ThemedText>
</View>
</ThemedView>
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
contentContainerStyle={styles.messagesContent}
onContentSizeChange={() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}}
>
{messages.map((message, index) => (
<View
key={index}
style={[
styles.messageWrapper,
message.role === 'user' ? styles.userMessage : styles.assistantMessage,
]}
>
<ThemedView
level="default"
style={[
styles.messageBubble,
message.role === 'user'
? { backgroundColor: theme.primary }
: { backgroundColor: theme.backgroundTertiary },
]}
>
<ThemedText
variant="body"
color={message.role === 'user' ? theme.buttonPrimaryText : theme.textPrimary}
style={styles.messageText}
>
{message.content}
</ThemedText>
</ThemedView>
<ThemedText variant="tiny" color={theme.textMuted} style={styles.messageTime}>
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</ThemedText>
</View>
))}
{loading && messages[messages.length - 1]?.role === 'assistant' && (
<View style={[styles.messageWrapper, styles.assistantMessage]}>
<ThemedView level="default" style={[styles.messageBubble, { backgroundColor: theme.backgroundTertiary }]}>
<View style={styles.typingIndicator}>
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
</View>
</ThemedView>
</View>
)}
</ScrollView>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<ThemedView level="root" style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="输入你的问题..."
placeholderTextColor={theme.textMuted}
value={inputText}
onChangeText={setInputText}
multiline
maxLength={500}
editable={!loading}
/>
<TouchableOpacity
style={[styles.sendButton, { opacity: !inputText.trim() || loading ? 0.5 : 1 }]}
onPress={sendMessage}
disabled={!inputText.trim() || loading}
>
<FontAwesome6
name="paper-plane"
size={20}
color={theme.buttonPrimaryText}
/>
</TouchableOpacity>
</ThemedView>
</KeyboardAvoidingView>
</Screen>
);
}