Files
jaystar 28c4d7b3b4 feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力)
- 实现智能对话功能(集成大语言模型流式输出)
- 实现食物记录和卡路里管理功能
- 实现体重记录和统计功能
- 实现健康数据管理页面
- 配置数据库表结构(用户、食物记录、体重记录)
- 实现Express后端API路由
- 配置Tab导航和前端页面
- 采用健康运动配色方案
2026-02-02 15:17:50 +08:00

240 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}