- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
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>
|
||
);
|
||
}
|