240 lines
7.8 KiB
TypeScript
240 lines
7.8 KiB
TypeScript
|
|
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>
|
|||
|
|
);
|
|||
|
|
}
|