Files
digital-pilates/app/coach.tsx
richarjiang e2597c1bc4 feat(challenges): 新增挑战模块与详情页,优化标签栏布局
- 新增挑战列表页 `app/(tabs)/challenges.tsx`,展示热门挑战卡片
- 新增挑战详情页 `app/challenges/[id].tsx`,支持排行榜、分享与参与
- 在标签栏中新增“挑战”入口,替换原有“发现”与“AI”页
- 调整标签栏间距与圆角,适配新布局
- 新增挑战相关路由常量 `TAB_CHALLENGES`
- 迁移 `coach.tsx` 与 `explore.tsx` 至根目录,保持结构清晰
2025-09-26 17:29:00 +08:00

2351 lines
69 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 { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
FlatList,
Keyboard,
Modal,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api';
import { selectLatestMoodRecordByDate } from '@/store/moodSlice';
import { generateWelcomeMessage, hasRecordedMoodToday } from '@/utils/welcomeMessage';
import { Image } from 'expo-image';
import { HistoryModal } from '../components/model/HistoryModal';
import { ActionSheet } from '../components/ui/ActionSheet';
// 导入新的 coach 组件
import {
CardType,
ChatComposer,
ChatMessage as ChatMessageComponent,
DietInputCard,
DietOptionsCard,
DietPlanCard,
WeightInputCard,
type QuickChip,
type SelectedImage
} from '@/components/coach';
import { AiChoiceOption, AttachmentType, ChatMessage, Role } from '@/components/coach/types';
// AI响应数据结构
type AiResponseData = {
content: string;
choices?: any[];
interactionType?: 'text' | 'food_confirmation' | 'selection';
pendingData?: any;
context?: any;
};
// 定义路由参数类型
type CoachScreenParams = {
name?: string;
action?: 'diet' | 'weight' | 'mood' | 'workout';
subAction?: 'record' | 'photo' | 'text' | 'card';
meal?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
message?: string;
};
export default function CoachScreen() {
const params = useLocalSearchParams<CoachScreenParams>();
const insets = useSafeAreaInsets();
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
const colorScheme = useColorScheme();
const theme = Colors[colorScheme ?? 'light'];
const [input, setInput] = useState('');
const [isSending, setIsSending] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId, setConversationId] = useState<string | undefined>(undefined);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [historyVisible, setHistoryVisible] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotal] = useState(0);
const [historyItems, setHistoryItems] = useState<AiConversationListItem[]>([]);
// 添加请求序列号,用于防止过期响应
const requestSequenceRef = useRef<number>(0);
const activeRequestIdRef = useRef<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const didInitialScrollRef = useRef(false);
const [composerHeight, setComposerHeight] = useState<number>(80);
const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const [headerHeight, setHeaderHeight] = useState<number>(60);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<SelectedImage[]>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false);
const [currentCardId, setCurrentCardId] = useState<string | null>(null);
const [selectedChoices, setSelectedChoices] = useState<Record<string, string>>({}); // messageId -> choiceId
const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState<Record<string, boolean>>({}); // messageId -> loading
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const checkin = useAppSelector((s) => s.checkin || {});
const userProfile = useAppSelector((s) => s.user?.profile);
const { upload } = useCosUpload();
// 获取今日心情记录
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const todayMoodRecord = useAppSelector(selectLatestMoodRecordByDate(today));
const hasRecordedMoodTodayValue = hasRecordedMoodToday(todayMoodRecord?.checkinDate);
// 转换用户配置文件数据类型以匹配欢迎消息函数的需求
const transformedUserProfile = userProfile ? {
name: userProfile.name,
weight: userProfile.weight ? Number(userProfile.weight) : undefined,
height: userProfile.height ? Number(userProfile.height) : undefined,
pilatesPurposes: userProfile.pilatesPurposes
} : undefined;
const chips: QuickChip[] = useMemo(() => [
// { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
// { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
// {
// key: 'mood',
// label: '#记心情',
// action: () => {
// if (Platform.OS === 'ios') {
// Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// }
// pushIfAuthedElseLogin('/mood/calendar');
// }
// },
], [planDraft, checkin]);
const scrollToEnd = useCallback(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd({ animated: true });
});
}, []);
const handleScroll = useCallback((e: any) => {
try {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {};
if (!contentOffset || !contentSize || !layoutMeasurement) return;
const paddingToBottom = 60;
const distanceFromBottom = (contentSize.height || 0) - ((layoutMeasurement.height || 0) + (contentOffset.y || 0));
setIsAtBottom(distanceFromBottom <= paddingToBottom);
} catch (error) {
console.warn('[AI_CHAT] Scroll handling error:', error);
}
}, []);
useEffect(() => {
// 初次进入或恢复时,保持最新消息可见
const timer = setTimeout(scrollToEnd, 100);
return () => clearTimeout(timer);
}, []);
// 使用 ref 存储最新值以避免依赖项导致的死循环
const latestValuesRef = useRef({
transformedUserProfile,
paramsName: params?.name || 'Seal',
hasRecordedMoodTodayValue
});
// 更新 ref 的值
useEffect(() => {
latestValuesRef.current = {
transformedUserProfile,
paramsName: params?.name || 'Seal',
hasRecordedMoodTodayValue
};
}, [transformedUserProfile, params?.name, hasRecordedMoodTodayValue]);
// 初始化欢迎消息
const initializeWelcomeMessage = useCallback(() => {
const { transformedUserProfile, paramsName, hasRecordedMoodTodayValue } = latestValuesRef.current;
const welcomeData = generateWelcomeMessage({
userProfile: transformedUserProfile,
hasRecordedMoodToday: hasRecordedMoodTodayValue
});
const welcomeMessage: ChatMessage = {
id: 'm_welcome',
role: 'assistant',
content: welcomeData.content,
choices: welcomeData.choices,
interactionType: welcomeData.interactionType,
};
setMessages([welcomeMessage]);
}, []); // 空依赖项,通过 ref 获取最新值
// 启动页面时尝试恢复当次应用会话缓存
useEffect(() => {
let isMounted = true;
(async () => {
try {
const cached = await loadAiCoachSessionCache();
if (isMounted && cached && Array.isArray(cached.messages) && cached.messages.length > 0) {
setConversationId(cached.conversationId);
// 确保缓存的消息符合新的 ChatMessage 结构
const validMessages = cached.messages
.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content)
.map(msg => ({
...msg,
// 确保 attachments 字段存在,对于旧的缓存消息可能没有这个字段
attachments: (msg as any).attachments || undefined,
})) as ChatMessage[];
setMessages(validMessages);
setTimeout(() => {
if (isMounted) scrollToEnd();
}, 100);
} else {
// 没有缓存时显示欢迎消息
if (isMounted) {
initializeWelcomeMessage();
}
}
} catch (error) {
console.warn('[AI_CHAT] Failed to load session cache:', error);
// 出错时也显示欢迎消息
if (isMounted) {
initializeWelcomeMessage();
}
}
})();
return () => {
isMounted = false;
};
}, [initializeWelcomeMessage]);
// 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空)
const saveCacheTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
}
// 只有在有实际消息内容时才保存缓存
if (messages.length > 1 || (messages.length === 1 && messages[0].id !== 'm_welcome')) {
saveCacheTimerRef.current = setTimeout(() => {
const validMessages = messages.filter(msg => msg && msg.role && msg.content);
saveAiCoachSessionCache({
conversationId,
messages: validMessages,
updatedAt: Date.now()
}).catch((error) => {
console.warn('[AI_CHAT] Failed to save session cache:', error);
});
}, 300);
}
return () => {
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
saveCacheTimerRef.current = null;
}
};
}, [messages, conversationId]);
// 取消对 messages.length 的全局监听滚动,改为在"消息实际追加完成"后再判断与滚动,避免突兀与多次触发
useEffect(() => {
// 输入区高度变化时,若用户在底部则轻柔跟随一次
if (isAtBottom) {
const id = setTimeout(scrollToEnd, 100);
return () => clearTimeout(id);
}
}, [composerHeight, isAtBottom]);
// 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡
useEffect(() => {
let showSub: any = null;
let hideSub: any = null;
if (Platform.OS === 'ios') {
showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => {
try {
if (e?.endCoordinates?.height) {
const height = Math.max(0, e.endCoordinates.height - 100);
setKeyboardOffset(height);
}
} catch (error) {
console.warn('[KEYBOARD] iOS keyboard event error:', error);
setKeyboardOffset(0);
}
});
hideSub = Keyboard.addListener('keyboardWillHide', () => {
setKeyboardOffset(0);
});
} else {
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
try {
if (e?.endCoordinates?.height) {
setKeyboardOffset(Math.max(0, e.endCoordinates.height));
}
} catch (error) {
console.warn('[KEYBOARD] Android keyboard event error:', error);
setKeyboardOffset(0);
}
});
hideSub = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardOffset(0);
});
}
return () => {
try {
showSub?.remove?.();
} catch (error) {
console.warn('[KEYBOARD] Error removing keyboard show listener:', error);
}
try {
hideSub?.remove?.();
} catch (error) {
console.warn('[KEYBOARD] Error removing keyboard hide listener:', error);
}
};
}, [insets.bottom]);
// 处理路由参数动作
useEffect(() => {
// 确保用户已登录且消息已加载
if (!isLoggedIn || messages.length === 0) return;
// 检查是否有动作参数
if (params.action) {
const executeAction = async () => {
try {
switch (params.action) {
case 'diet':
if (params.subAction === 'card') {
// 插入饮食记录卡片
insertDietInputCard();
} else if (params.subAction === 'record' && params.message) {
// 直接发送预设的饮食记录消息
const mealPrefix = params.meal ? `${getMealDisplayName(params.meal)}` : '';
const message = `#记饮食:${mealPrefix}${decodeURIComponent(params.message)}`;
await sendStream(message);
}
break;
case 'weight':
if (params.subAction === 'card') {
// 插入体重记录卡片
insertWeightInputCard();
} else if (params.subAction === 'record' && params.message) {
// 直接发送预设的体重记录消息
const message = `#记体重:${decodeURIComponent(params.message)}`;
await sendStream(message);
}
break;
case 'mood':
// 跳转到心情记录页面
pushIfAuthedElseLogin('/mood/calendar');
break;
default:
console.warn('未知的动作类型:', params.action);
}
} catch (error) {
console.error('执行路由动作失败:', error);
}
};
// 延迟执行,确保页面已完全加载
const timer = setTimeout(executeAction, 500);
return () => clearTimeout(timer);
}
}, [params.action, params.subAction, params.meal, params.message, isLoggedIn, messages.length]);
// 获取餐次显示名称
const getMealDisplayName = (meal: string): string => {
const mealNames: Record<string, string> = {
breakfast: '早餐',
lunch: '午餐',
dinner: '晚餐',
snack: '加餐'
};
return mealNames[meal] || '';
};
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
// 组件卸载时清理流式请求和定时器
useEffect(() => {
return () => {
try {
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
} catch (error) {
console.warn('[AI_CHAT] Error aborting stream on unmount:', error);
}
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
saveCacheTimerRef.current = null;
}
};
}, []);
function ensureConversationId(): string {
if (conversationId && conversationId.trim()) return conversationId;
// 延迟生成会话ID只在请求成功开始时才生成
return '';
}
function createNewConversationId(): string {
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
setConversationId(cid);
try { console.log('[AI_CHAT][ui] create new conversationId', cid); } catch { }
return cid;
}
function convertToServerMessages(history: ChatMessage[]): { role: 'user' | 'assistant' | 'system'; content: string }[] {
// 仅映射 user/assistant 消息;系统提示由后端自动注入
return history
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
}
async function openHistory() {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
setHistoryVisible(true);
await refreshHistory(1);
}
async function refreshHistory(page = 1) {
try {
setHistoryLoading(true);
const resp = await listConversations(page, 20);
setHistoryPage(resp.page);
// setHistoryTotal(resp.total); // 临时注释,因为当前未使用
setHistoryItems(resp.items || []);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '获取会话列表失败');
} finally {
setHistoryLoading(false);
}
}
function startNewConversation() {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
// 清理当前会话状态
setConversationId(undefined);
setSelectedImages([]);
setDietTextInputs({});
setWeightInputs({});
setSelectedChoices({});
setPendingChoiceConfirmation({});
// 创建新的欢迎消息
initializeWelcomeMessage();
// 清理本地缓存
saveAiCoachSessionCache({
conversationId: undefined,
messages: [],
updatedAt: Date.now()
}).catch((error) => {
console.warn('[AI_CHAT] Failed to clear session cache:', error);
});
setTimeout(scrollToEnd, 100);
}
async function handleSelectConversation(id: string) {
try {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
const detail = await getConversationDetail(id);
if (!detail || !(detail as any).messages) {
Alert.alert('提示', '会话不存在或已删除');
return;
}
const mapped: ChatMessage[] = (detail.messages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m, idx) => ({
id: `${m.role}_${idx}_${Date.now()}`,
role: m.role as Role,
content: m.content || '',
// 对于历史消息,暂时不包含附件信息,因为服务器端可能还没有返回附件数据
// 如果将来服务器支持返回附件信息,可以在这里添加映射逻辑
attachments: undefined,
}));
setConversationId(detail.conversationId);
if (mapped.length) {
setMessages(mapped);
} else {
const welcomeData = generateWelcomeMessage({
userProfile: transformedUserProfile,
hasRecordedMoodToday: hasRecordedMoodTodayValue
});
setMessages([{
id: 'm_welcome',
role: 'assistant',
content: welcomeData.content,
choices: welcomeData.choices,
interactionType: welcomeData.interactionType,
}]);
}
setHistoryVisible(false);
setTimeout(scrollToEnd, 0);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '加载会话失败');
}
}
function confirmDeleteConversation(id: string) {
Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive', onPress: async () => {
try {
await deleteConversation(id);
if (conversationId === id) {
setConversationId(undefined);
const welcomeData = generateWelcomeMessage({
userProfile: transformedUserProfile,
hasRecordedMoodToday: hasRecordedMoodTodayValue
});
setMessages([{
id: 'm_welcome',
role: 'assistant',
content: welcomeData.content,
choices: welcomeData.choices,
interactionType: welcomeData.interactionType,
}]);
}
await refreshHistory(historyPage);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '删除失败');
}
}
}
]);
}
async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) {
// 发送确认选择的特殊请求
const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId();
const body = {
conversationId: cid || undefined,
messages: [...historyForServer, { role: 'user' as const, content: text }],
selectedChoiceId,
confirmationData,
stream: false, // 确认阶段使用非流式
};
await sendRequestInternal(body, text);
}
async function sendStream(text: string, imageUrls: string[] = []) {
console.log('[SEND_STREAM] 开始发送消息:', { text, imageUrls });
const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId(); // 可能返回空字符串
const body = {
conversationId: cid || undefined, // 如果没有现有会话ID传undefined让服务端生成
messages: [...historyForServer, { role: 'user' as const, content: text }],
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
stream: true,
};
console.log('[SEND_STREAM] 请求体:', { body });
await sendRequestInternal(body, text, imageUrls);
}
function cancelCurrentRequest() {
try {
console.log('[AI_CHAT][ui] User cancelled request');
// 增加请求序列号,使后续响应失效
requestSequenceRef.current += 1;
// 清除活跃请求ID
activeRequestIdRef.current = null;
// 中断网络请求
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
// 清理状态
setIsSending(false);
setIsStreaming(false);
// 移除正在生成中的助手消息
if (pendingAssistantIdRef.current) {
setMessages((prev) => prev.filter(msg => msg.id !== pendingAssistantIdRef.current));
pendingAssistantIdRef.current = null;
}
// 触觉反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
console.log('[AI_CHAT][ui] Request cancelled, sequence incremented to:', requestSequenceRef.current);
} catch (error) {
console.warn('[AI_CHAT] Error cancelling request:', error);
}
}
async function sendRequestInternal(body: any, text: string, imageUrls: string[] = []) {
const tokenExists = !!getAuthToken();
// 生成当前请求的序列号和ID
const currentSequence = ++requestSequenceRef.current;
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
activeRequestIdRef.current = requestId;
try {
console.log('[AI_CHAT][ui] send start', {
requestId,
sequence: currentSequence,
tokenExists,
conversationId,
textPreview: text.slice(0, 50),
imageUrls,
stream: body.stream
});
} catch { }
// 验证请求是否仍然有效的函数
const isRequestValid = () => {
const isValid = activeRequestIdRef.current === requestId && requestSequenceRef.current === currentSequence;
if (!isValid) {
console.log('[AI_CHAT][ui] Request invalidated', {
requestId,
currentActive: activeRequestIdRef.current,
sequence: currentSequence,
currentSequence: requestSequenceRef.current
});
}
return isValid;
};
// 终止上一次未完成的流
if (streamAbortRef.current) {
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
try { streamAbortRef.current.abort(); } catch { }
streamAbortRef.current = null;
}
// 在 UI 中先放置占位回答,随后持续增量更新
const assistantId = `a_${Date.now()}`;
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// 构建包含附件的用户消息
const attachments = imageUrls.map((url, index) => ({
id: `img_${Date.now()}_${index}`,
type: 'image' as AttachmentType,
url,
}));
console.log('[AI_CHAT][ui] 构建用户消息:', {
userMsgId,
text,
imageUrls,
attachments: attachments.length > 0 ? attachments : undefined
});
const userMsg: ChatMessage = {
id: userMsgId,
role: 'user',
content: text,
attachments: attachments.length > 0 ? attachments : undefined,
};
shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId;
setIsSending(true);
setIsStreaming(true);
// 如果是非流式请求直接调用API并处理响应
if (!body.stream) {
try {
const response = await api.post<{ conversationId?: string; data?: AiResponseData; text?: string }>('/api/ai-coach/chat', body);
setIsSending(false);
setIsStreaming(false);
// 处理响应
if (response.data) {
// 结构化响应(可能包含选择选项)
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
content: response.data.content,
choices: response.data.choices,
interactionType: response.data.interactionType,
pendingData: response.data.pendingData,
context: response.data.context,
};
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? assistantMsg : msg
));
} else if (response.text) {
// 简单文本响应
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: response.text || '(空响应)' } : msg
));
}
if (response.conversationId && !conversationId) {
setConversationId(response.conversationId);
}
pendingAssistantIdRef.current = null;
return;
} catch (e: any) {
setIsSending(false);
setIsStreaming(false);
pendingAssistantIdRef.current = null;
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg
));
throw e;
}
}
let receivedAnyChunk = false;
const updateAssistantContent = (delta: string) => {
setMessages((prev) => {
const next = prev.map((msg) => {
if (msg.id === assistantId) {
return { ...msg, content: msg.content + delta };
}
return msg;
});
return next;
});
};
const onChunk = (chunk: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring chunk from invalidated request');
return;
}
receivedAnyChunk = true;
const atBottomNow = isAtBottom;
// 尝试解析是否为JSON结构化数据可能是确认选项
try {
const parsed = JSON.parse(chunk);
if (parsed && parsed.data && parsed.data.choices) {
// 再次验证请求有效性
if (!isRequestValid()) return;
// 处理结构化响应(包含选择选项)
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
content: parsed.data.content,
choices: parsed.data.choices,
interactionType: parsed.data.interactionType,
pendingData: parsed.data.pendingData,
context: parsed.data.context,
};
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? assistantMsg : msg
));
// 处理conversationId - 只在请求有效时设置
if (parsed.conversationId && !conversationId) {
setConversationId(parsed.conversationId);
console.log('[AI_CHAT][ui] Set conversationId from structured response:', parsed.conversationId);
}
// 结束流式状态
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
return;
}
} catch {
// 不是JSON继续作为普通文本处理
}
updateAssistantContent(chunk);
if (atBottomNow) {
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
shouldAutoScrollRef.current = true;
setTimeout(scrollToEnd, 0);
}
try { console.log('[AI_CHAT][api] chunk', { requestId, length: chunk.length, preview: chunk.slice(0, 40) }); } catch { }
};
const onEnd = (cidFromHeader?: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring end from invalidated request');
return;
}
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
if (cidFromHeader && !conversationId) {
setConversationId(cidFromHeader);
console.log('[AI_CHAT][ui] Set conversationId from header:', cidFromHeader);
}
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
try { console.log('[AI_CHAT][api] end', { requestId, cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
};
const onError = async (err: any) => {
try { console.warn('[AI_CHAT][api] error', { requestId, error: err }); } catch { }
// 如果是用户主动取消,不需要处理错误
if (err?.name === 'AbortError' || err?.message?.includes('abort')) {
console.log('[AI_CHAT][api] Request was aborted by user');
return;
}
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring error from invalidated request');
return;
}
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式
try {
// 再次验证请求有效性
if (!isRequestValid()) return;
const bodyNoStream = { ...body, stream: false };
try { console.log('[AI_CHAT][fallback] try non-stream', { requestId }); } catch { }
const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
// 最终验证请求有效性
if (!isRequestValid()) return;
const textCombined = (resp as any)?.text ?? '';
if ((resp as any)?.conversationId && !conversationId) {
setConversationId((resp as any).conversationId);
}
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
} catch (e2: any) {
// 如果也是取消操作,同样不处理
if (e2?.name === 'AbortError' || e2?.message?.includes('abort')) {
return;
}
// 验证请求有效性
if (!isRequestValid()) return;
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg));
try { console.warn('[AI_CHAT][fallback] non-stream error', { requestId, error: e2 }); } catch { }
}
};
try {
const controller = await postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 });
streamAbortRef.current = controller;
} catch (e) {
onError(e);
}
}
async function send(text: string) {
if (!isLoggedIn) {
pushIfAuthedElseLogin('/auth/login');
return;
}
if (isSending) return;
const trimmed = text.trim();
if (!trimmed && selectedImages.length === 0) return;
// 检查是否有图片还在上传中
const uploadingImages = selectedImages.filter(img => !img.uploadedUrl && !img.error);
if (uploadingImages.length > 0) {
Alert.alert('请稍等', '图片正在上传中,请等待上传完成后再发送');
return;
}
// 检查是否有上传失败的图片
const failedImages = selectedImages.filter(img => img.error);
if (failedImages.length > 0) {
Alert.alert('上传失败', '部分图片上传失败,请重新选择或删除失败的图片');
return;
}
try {
const imageUrls = selectedImages.map(img => img.uploadedUrl).filter(Boolean) as string[];
setInput('');
setSelectedImages([]);
await sendStream(trimmed, imageUrls);
} catch (e: any) {
Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试');
}
}
const uploadImage = useCallback(async (img: any) => {
if (!img?.localUri || !img?.id) {
console.warn('[AI_CHAT] Invalid image data for upload:', img);
return;
}
try {
const { url } = await upload(
{ uri: img.localUri, name: img.id, type: 'image/jpeg' },
{ prefix: 'images/chat' }
);
if (url) {
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, uploadedUrl: url, progress: 1, error: undefined } : it
));
} else {
throw new Error('上传返回空URL');
}
} catch (e: any) {
console.error('[AI_CHAT] Image upload failed:', e);
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, error: e?.message || '上传失败', progress: 0 } : it
));
}
}, [upload]);
const pickImages = useCallback(async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsMultipleSelection: true,
selectionLimit: 4,
quality: 0.9,
} as any);
if ((result as any).canceled) return;
const assets = (result as any).assets || [];
if (!Array.isArray(assets) || assets.length === 0) {
console.warn('[AI_CHAT] No valid assets returned from image picker');
return;
}
const next = assets
.filter(a => a && a.uri) // 过滤无效的资源
.map((a: any) => ({
id: `${a.assetId || a.fileName || Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
localUri: a.uri,
width: a.width,
height: a.height,
progress: 0,
}));
if (next.length === 0) {
Alert.alert('错误', '未选择有效的图片');
return;
}
setSelectedImages((prev) => {
const merged = [...prev, ...next];
return merged.slice(0, 4);
});
setTimeout(scrollToEnd, 100);
// 立即开始上传新选择的图片
for (const img of next) {
uploadImage(img);
}
} catch (e: any) {
console.error('[AI_CHAT] Image picker error:', e);
Alert.alert('错误', e?.message || '选择图片失败');
}
}, [scrollToEnd, uploadImage]);
const removeSelectedImage = useCallback((id: string) => {
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
}, []);
function renderItem({ item }: { item: ChatMessage }) {
const isUser = item.role === 'user';
// 处理特殊卡片类型
if (item.content?.startsWith(CardType.WEIGHT_INPUT)) {
const cardId = item.id;
const preset = (() => {
try {
const m = item.content.split('\n')?.[1];
const v = parseFloat(m || '');
return isNaN(v) ? '' : String(v);
} catch {
return '';
}
})();
return (
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<WeightInputCard
cardId={cardId}
weightInputs={weightInputs}
onWeightInputChange={(id, value) => setWeightInputs(prev => ({ ...prev, [id]: value }))}
onSaveWeight={(id) => handleSubmitWeight(weightInputs[id], id)}
/>
</Animated.View>
);
}
if (item.content?.startsWith(CardType.DIET_INPUT)) {
return (
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietOptionsCard
cardId={item.id}
onSelectOption={(cardId, optionId) => {
if (optionId === 'text') {
handleDietTextInput(cardId);
} else if (optionId === 'photo') {
handleDietPhotoInput(cardId);
}
}}
/>
</Animated.View>
);
}
if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) {
const cardId = item.content.split('\n')?.[1] || '';
return (
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietInputCard
cardId={cardId}
dietTextInputs={dietTextInputs}
onDietTextInputChange={(id, value) => setDietTextInputs(prev => ({ ...prev, [id]: value }))}
onSubmitDietText={(id) => handleSubmitDietText(dietTextInputs[id], id)}
onBackToDietOptions={handleBackToDietOptions}
onShowDietPhotoActionSheet={(id) => {
setCurrentCardId(id);
setShowDietPhotoActionSheet(true);
}}
/>
</Animated.View>
);
}
if (item.content?.startsWith(CardType.DIET_PLAN)) {
return (
<Animated.View
entering={FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: 'flex-start' }]}
>
<DietPlanCard
onGeneratePlan={() => {
console.log('跳转到饮食方案详情');
}}
/>
</Animated.View>
);
}
// 普通消息使用 ChatMessageComponent
return (
<Animated.View
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
>
<ChatMessageComponent
message={item}
onPreviewImage={setPreviewImageUri}
onChoiceSelect={(messageId, choiceId) => {
const choice = item.choices?.find(c => c.id === choiceId);
if (choice) {
handleChoiceSelection(choice, item);
}
}}
selectedChoices={selectedChoices}
pendingChoiceConfirmation={pendingChoiceConfirmation}
isStreaming={isStreaming && pendingAssistantIdRef.current === item.id}
onCancelStream={cancelCurrentRequest}
/>
</Animated.View>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `${CardType.WEIGHT_INPUT}\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
function insertDietPlanCard() {
const id = `dpcard_${Date.now()}`;
const payload = `${CardType.DIET_PLAN}\n${id}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
// 计算BMI
function calculateBMI(weight: number, height: number): number {
if (!weight || !height || weight <= 0 || height <= 0) return 0;
const heightInMeters = height / 100;
return Number((weight / (heightInMeters * heightInMeters)).toFixed(1));
}
// 获取BMI状态
function getBMIStatus(bmi: number): { status: string; color: string } {
if (bmi < 18.5) return { status: '偏瘦', color: '#87CEEB' };
if (bmi < 24) return { status: '正常', color: '#90EE90' };
if (bmi < 28) return { status: '偏胖', color: '#FFD700' };
return { status: '肥胖', color: '#FFA07A' };
}
// 计算每日推荐摄入热量
function calculateDailyCalories(weight: number, height: number, age: number, gender: string = 'female'): number {
if (!weight || !height || !age) return 1376; // 默认值
// 使用Harris-Benedict公式计算基础代谢率
let bmr: number;
if (gender === 'male') {
bmr = 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age);
} else {
bmr = 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age);
}
// 考虑活动水平这里使用轻度活动的系数1.375
return Math.round(bmr * 1.375);
}
// 计算营养素分配
function calculateNutritionDistribution(calories: number) {
// 碳水化合物 50%,蛋白质 15%,脂肪 35%
const carbCalories = calories * 0.5;
const proteinCalories = calories * 0.15;
const fatCalories = calories * 0.35;
return {
carbs: Math.round(carbCalories / 4), // 1g碳水 = 4卡路里
protein: Math.round(proteinCalories / 4), // 1g蛋白质 = 4卡路里
fat: Math.round(fatCalories / 9), // 1g脂肪 = 9卡路里
};
}
async function handleSubmitWeight(text?: string, cardId?: string) {
const val = parseFloat(String(text ?? '').trim());
if (isNaN(val) || val <= 0 || val > 500) {
Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5');
return;
}
try {
// 清理该卡片的输入状态
if (cardId) {
setWeightInputs(prev => {
const { [cardId]: _, ...rest } = prev;
return rest;
});
// 移除体重输入卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
}
// 在对话中插入"确认消息"并发送给教练
const textMsg = `#记体重:\n\n${val} kg`;
await sendStream(textMsg);
} catch (e: any) {
console.error('[AI_CHAT] Error handling weight submission:', e);
Alert.alert('保存失败', e?.message || '请稍后重试');
}
}
function insertDietInputCard() {
const id = `dcard_${Date.now()}`;
const payload = `${CardType.DIET_INPUT}\n${id}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
function handleDietTextInput(cardId: string) {
// 替换当前的饮食选择卡片为文字输入卡片
const payload = `${CardType.DIET_TEXT_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
setTimeout(scrollToEnd, 100);
}
function handleDietPhotoInput(cardId: string) {
console.log('[DIET] handleDietPhotoInput called with cardId:', cardId);
setCurrentCardId(cardId);
setShowDietPhotoActionSheet(true);
}
async function handleCameraPhoto() {
try {
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄食物照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
aspect: [4, 3],
});
if (!result.canceled && result.assets?.[0]) {
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 拍照失败:', e);
Alert.alert('拍照失败', e?.message || '拍照失败,请重试');
}
}
async function handleLibraryPhoto() {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择食物照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
aspect: [4, 3],
});
if (!result.canceled && result.assets?.[0]) {
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 选择照片失败:', e);
Alert.alert('选择照片失败', e?.message || '选择照片失败,请重试');
}
}
async function processSelectedImage(asset: ImagePicker.ImagePickerAsset) {
if (!currentCardId) return;
try {
console.log('[DIET] 开始上传图片:', { uri: asset.uri, name: asset.fileName });
// 上传图片
const { url } = await upload(
{ uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' },
{ prefix: 'images/diet' }
);
console.log('[DIET] 图片上传成功:', { url });
// 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
// 发送包含图片的饮食记录消息,图片通过 imageUrls 参数传递
const dietMsg = `#记饮食:请分析这张食物照片的营养成分和热量`;
console.log('[DIET] 发送饮食记录消息:', { dietMsg, imageUrls: [url] });
await sendStream(dietMsg, [url]);
} catch (uploadError) {
console.error('[DIET] 图片上传失败:', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
}
}
function handleBackToDietOptions(cardId: string) {
// 返回到饮食选择界面
const payload = `${CardType.DIET_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
setTimeout(scrollToEnd, 100);
}
async function handleSubmitDietText(text: string, cardId: string) {
const trimmedText = text.trim();
if (!trimmedText) {
Alert.alert('请输入饮食内容', '请描述您吃了什么食物和大概的分量');
return;
}
try {
// 移除饮食输入卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
// 清理输入状态
setDietTextInputs(prev => {
const { [cardId]: _, ...rest } = prev;
return rest;
});
// 发送饮食记录消息
const dietMsg = `#记饮食:${trimmedText}`;
await sendStream(dietMsg);
} catch (e: any) {
console.error('[DIET] 提交饮食记录失败:', e);
Alert.alert('提交失败', e?.message || '提交失败,请重试');
}
}
async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) {
try {
console.log('[CHOICE] Selection:', { choiceId: choice.id, messageId: message.id });
// 检查是否已经选择过
if (selectedChoices[message.id] != null) {
console.log('[CHOICE] Already selected, ignoring');
return;
}
// 立即设置选中状态,防止重复点击
setSelectedChoices(prev => ({ ...prev, [message.id]: choice.id }));
setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: true }));
// 构建确认请求
const confirmationText = `${choice.label}`;
try {
// 发送确认消息,包含选择的数据
await sendStreamWithConfirmation(confirmationText, choice.id, {
selectedOption: choice.value,
imageUrl: message.pendingData?.imageUrl
});
// 发送成功后清除pending状态但保持选中状态
setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: false }));
// 成功反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e: any) {
console.error('[CHOICE] Selection failed:', e);
// 发送失败时,重置状态允许重新选择
setSelectedChoices(prev => {
const { [message.id]: _, ...rest } = prev;
return rest;
});
setPendingChoiceConfirmation(prev => {
const { [message.id]: _, ...rest } = prev;
return rest;
});
Alert.alert('选择失败', e?.message || '选择失败,请重试');
// 失败反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
} catch (e: any) {
console.error('[CHOICE] Selection failed:', e);
Alert.alert('选择失败', e?.message || '选择失败,请重试');
}
}
return (
<View style={styles.screen}>
{/* 背景渐变 */}
<LinearGradient
colors={['#fafaff', '#f4f3ff']} // 使用紫色主题的浅色渐变
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* 装饰性圆圈 */}
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
<View
style={[styles.header, { paddingTop: insets.top + 10 }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - headerHeight) > 0.5) setHeaderHeight(h);
}}
>
<View style={styles.headerLeft}>
<Text style={[styles.headerTitle, { color: theme.text }]}></Text>
{/* 使用次数显示 */}
<TouchableOpacity
style={styles.usageCountContainer}
onPress={() => {
}}
>
<Image
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
style={styles.usageIcon}
cachePolicy="memory-disk"
/>
<Text style={styles.usageText}>
{userProfile?.isVip ? '不限' : `${userProfile?.freeUsageCount || 0}/${userProfile?.maxUsageCount || 0}`}
</Text>
</TouchableOpacity>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
accessibilityRole="button"
onPress={startNewConversation}
style={[styles.headerActionButton, { backgroundColor: `${theme.primary}20` }]} // 20% opacity
>
<Ionicons name="add-outline" size={18} color={theme.primary} />
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={openHistory}
style={[styles.headerActionButton, { backgroundColor: `${theme.primary}20` }]} // 20% opacity
>
<Ionicons name="time-outline" size={18} color={theme.primary} />
</TouchableOpacity>
</View>
</View>
{/* 消息列表容器 - 设置固定高度避免输入框重叠 */}
<View style={{
flex: 1,
marginBottom: composerHeight + keyboardOffset
}}>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
onLayout={() => {
// 确保首屏布局后也尝试滚动
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
setTimeout(scrollToEnd, 100);
}
}}
contentContainerStyle={{
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 16
}}
onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
setTimeout(scrollToEnd, 100);
return;
}
if (shouldAutoScrollRef.current) {
shouldAutoScrollRef.current = false;
setTimeout(scrollToEnd, 50);
}
}}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
/>
</View>
<View
style={[{
position: 'absolute',
left: 0,
right: 0,
bottom: keyboardOffset,
paddingBottom: getTabBarBottomPadding() + 10
}]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ChatComposer
input={input}
onInputChange={setInput}
onSend={() => send(input)}
onPickImages={pickImages}
onCancelRequest={cancelCurrentRequest}
selectedImages={selectedImages}
onRemoveImage={removeSelectedImage}
onPreviewImage={setPreviewImageUri}
isSending={isSending}
isStreaming={isStreaming}
chips={chips}
/>
</View>
{!isAtBottom && (
<TouchableOpacity
accessibilityRole="button"
onPress={scrollToEnd}
style={[styles.scrollToBottomFab, {
bottom: composerHeight + keyboardOffset + 10,
backgroundColor: theme.primary
}]}
>
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
</TouchableOpacity>
)}
<HistoryModal
visible={historyVisible}
onClose={() => setHistoryVisible(false)}
historyLoading={historyLoading}
historyItems={historyItems}
historyPage={historyPage}
onRefreshHistory={refreshHistory}
onSelectConversation={handleSelectConversation}
onDeleteConversation={confirmDeleteConversation}
/>
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
<View style={styles.previewBox}>
{previewImageUri ? (
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
) : null}
</View>
</TouchableOpacity>
</Modal>
<ActionSheet
visible={showDietPhotoActionSheet}
onClose={() => setShowDietPhotoActionSheet(false)}
title="选择图片来源"
options={[
{ id: 'camera', title: '拍照', onPress: handleCameraPhoto },
{ id: 'library', title: '从相册选择', onPress: handleLibraryPhoto },
]}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingBottom: 10,
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
opacity: 0.6,
},
decorativeCircle1: {
position: 'absolute',
top: -20,
right: -20,
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#7a5af8', // 紫色主题
opacity: 0.08,
},
decorativeCircle2: {
position: 'absolute',
bottom: -15,
left: -15,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#7a5af8', // 紫色主题
opacity: 0.04,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
headerTitle: {
fontSize: 20,
fontWeight: '800',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
headerActionButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#7a5af8',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
},
historyButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
row: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
marginVertical: 6,
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: '#192126',
fontSize: 12,
fontWeight: '800',
},
bubble: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 16,
},
bubbleText: {
fontSize: 15,
lineHeight: 22,
},
weightRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
weightInput: {
flex: 1,
height: 36,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
borderRadius: 8,
paddingHorizontal: 10,
backgroundColor: 'rgba(255,255,255,0.9)',
color: '#192126',
},
weightUnit: {
color: '#192126',
fontWeight: '700',
},
weightSaveBtn: {
height: 36,
paddingHorizontal: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#7a5af899' // 紫色主题 60% opacity
},
dietOptionsContainer: {
gap: 8,
},
dietOptionBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: '#7a5af84d', // 紫色主题 30% opacity
},
dietOptionIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#7a5af833', // 紫色主题 20% opacity
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
dietOptionTextContainer: {
flex: 1,
},
dietOptionTitle: {
fontSize: 15,
fontWeight: '700',
color: '#192126',
},
dietOptionDesc: {
fontSize: 13,
color: '#687076',
marginTop: 2,
},
dietInputHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
dietBackBtn: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.06)',
},
dietTextInput: {
minHeight: 80,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: 'rgba(255,255,255,0.9)',
color: '#192126',
fontSize: 15,
textAlignVertical: 'top',
},
dietSubmitBtn: {
height: 40,
paddingHorizontal: 16,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#7a5af899', // 紫色主题 60% opacity
alignSelf: 'flex-end',
},
// markdown 基础样式承载容器的字体尺寸保持与气泡一致
composerWrap: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingTop: 8,
paddingHorizontal: 10,
borderTopWidth: 0,
},
chipsRow: {
flexDirection: 'row',
gap: 8,
paddingHorizontal: 6,
marginBottom: 8,
},
chipsRowScroll: {
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
height: 34,
borderRadius: 18,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
chipText: {
fontSize: 13,
fontWeight: '600',
},
imagesRow: {
maxHeight: 92,
marginBottom: 8,
},
imageThumbWrap: {
width: 72,
height: 72,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: 'rgba(122,90,248,0.08)' // 使用紫色主题的浅色背景
},
imageThumb: {
width: '100%',
height: '100%'
},
imageRemoveBtn: {
position: 'absolute',
right: 4,
top: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.45)'
},
imageProgressOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageProgressText: {
color: '#fff',
fontWeight: '700'
},
imageErrorOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageRetryBtn: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)'
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
},
mediaBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
input: {
flex: 1,
fontSize: 15,
maxHeight: 120,
minHeight: 40,
paddingHorizontal: 8,
paddingVertical: 6,
textAlignVertical: 'center',
},
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
scrollToBottomFab: {
position: 'absolute',
right: 16,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
previewBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.85)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
previewBox: {
width: '100%',
height: '80%',
borderRadius: 12,
overflow: 'hidden',
},
previewImage: {
width: '100%',
height: '100%',
},
// 附件相关样式
attachmentsContainer: {
marginTop: 8,
gap: 6,
},
attachmentContainer: {
marginBottom: 4,
},
imageAttachment: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
attachmentImage: {
width: '100%',
minHeight: 120,
maxHeight: 200,
borderRadius: 12,
},
attachmentProgressOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentProgressText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
attachmentErrorOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentErrorText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
videoAttachment: {
borderRadius: 12,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.1)',
},
videoPlaceholder: {
height: 120,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
videoFilename: {
color: '#fff',
fontSize: 12,
marginTop: 4,
textAlign: 'center',
},
fileAttachment: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
gap: 8,
},
fileFilename: {
flex: 1,
fontSize: 14,
color: '#192126',
},
// 选择选项相关样式
choicesContainer: {
gap: 8,
width: '100%',
},
choiceButton: {
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: '#7a5af84d', // 紫色主题 30% opacity
borderRadius: 12,
padding: 12,
width: '100%',
minWidth: 0,
},
choiceButtonRecommended: {
borderColor: '#7a5af899', // 紫色主题 60% opacity
backgroundColor: '#7a5af81a', // 紫色主题 10% opacity
},
choiceButtonSelected: {
borderColor: '#19b36e', // success[500]
backgroundColor: '#19b36e33', // 20% opacity
borderWidth: 2,
},
choiceButtonDisabled: {
backgroundColor: 'rgba(0,0,0,0.05)',
borderColor: 'rgba(0,0,0,0.1)',
opacity: 0.5,
},
choiceContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
},
choiceLabel: {
fontSize: 15,
fontWeight: '600',
color: '#192126',
flex: 1,
flexWrap: 'wrap',
},
choiceLabelRecommended: {
color: '#19b36e', // success[500]
},
choiceLabelSelected: {
color: '#19b36e', // success[500]
fontWeight: '700',
},
choiceLabelDisabled: {
color: '#687076',
},
choiceStatusContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
recommendedBadge: {
backgroundColor: '#7a5af8cc', // 紫色主题 80% opacity
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 2,
},
recommendedText: {
fontSize: 12,
fontWeight: '700',
color: '#19b36e', // success[500]
},
selectedBadge: {
backgroundColor: '#19b36e', // success[500]
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 2,
},
selectedText: {
fontSize: 12,
fontWeight: '700',
color: '#FFFFFF',
},
// 流式回复相关样式
streamingContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
cancelStreamBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(255,68,68,0.1)',
borderWidth: 1,
borderColor: 'rgba(255,68,68,0.3)',
},
cancelStreamText: {
fontSize: 12,
fontWeight: '600',
color: '#FF4444',
},
// 饮食方案卡片样式
dietPlanContainer: {
backgroundColor: 'rgba(255,255,255,0.95)',
borderRadius: 16,
padding: 16,
gap: 16,
borderWidth: 1,
borderColor: '#7a5af833', // 紫色主题 20% opacity
},
dietPlanHeader: {
gap: 4,
},
dietPlanTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
dietPlanTitle: {
fontSize: 18,
fontWeight: '800',
color: '#192126',
},
dietPlanSubtitle: {
fontSize: 12,
fontWeight: '600',
color: '#687076',
letterSpacing: 1,
},
profileSection: {
gap: 12,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: '#192126',
},
profileDataRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
avatarContainer: {
alignItems: 'center',
},
profileStats: {
flexDirection: 'row',
flex: 1,
justifyContent: 'space-around',
},
statItem: {
alignItems: 'center',
gap: 4,
},
statValue: {
fontSize: 20,
fontWeight: '800',
color: '#192126',
},
statLabel: {
fontSize: 12,
color: '#687076',
},
bmiSection: {
gap: 12,
},
bmiHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
bmiStatusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
},
bmiStatusText: {
fontSize: 12,
fontWeight: '700',
color: '#FFFFFF',
},
bmiValue: {
fontSize: 32,
fontWeight: '800',
color: '#192126',
textAlign: 'center',
},
bmiScale: {
flexDirection: 'row',
height: 8,
borderRadius: 4,
overflow: 'hidden',
gap: 1,
},
bmiBar: {
flex: 1,
height: '100%',
},
bmiLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingHorizontal: 4,
},
bmiLabel: {
fontSize: 11,
color: '#687076',
},
collapsibleSection: {
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
},
collapsibleHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
caloriesSection: {
gap: 12,
},
caloriesHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
caloriesValue: {
fontSize: 18,
fontWeight: '800',
color: '#19b36e', // success[500]
},
nutritionGrid: {
flexDirection: 'row',
justifyContent: 'space-around',
gap: 16,
},
nutritionItem: {
alignItems: 'center',
gap: 8,
},
nutritionValue: {
fontSize: 24,
fontWeight: '800',
color: '#192126',
},
nutritionLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
nutritionLabel: {
fontSize: 12,
color: '#687076',
},
nutritionNote: {
fontSize: 12,
color: '#687076',
lineHeight: 16,
textAlign: 'center',
marginTop: 8,
},
dietPlanButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: '#19b36e', // success[500]
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 12,
marginTop: 8,
},
dietPlanButtonText: {
fontSize: 14,
fontWeight: '700',
color: '#FFFFFF',
},
usageCountContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
backgroundColor: 'rgba(122,90,248,0.08)', // 紫色主题浅色背景
},
usageIcon: {
width: 16,
height: 16,
},
usageText: {
fontSize: 12,
fontWeight: '600',
color: '#7a5af8', // 紫色主题文字颜色
},
});
const markdownStyles = {
body: {
color: '#192126',
fontSize: 15,
lineHeight: 22,
},
paragraph: {
marginTop: 2,
marginBottom: 2,
},
bullet_list: {
marginVertical: 4,
},
ordered_list: {
marginVertical: 4,
},
list_item: {
flexDirection: 'row',
},
code_inline: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
code_block: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
fence: {
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 6,
},
heading1: { fontSize: 20, fontWeight: '800', marginVertical: 6 },
heading2: { fontSize: 18, fontWeight: '800', marginVertical: 6 },
heading3: { fontSize: 16, fontWeight: '800', marginVertical: 6 },
link: { color: '#246BFD' },
} as const;