import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; 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 { ActivityIndicator, Alert, FlatList, Keyboard, Modal, Platform, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; import Markdown from 'react-native-markdown-display'; 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'; type Role = 'user' | 'assistant'; // 附件类型枚举 type AttachmentType = 'image' | 'video' | 'file'; // 附件数据结构 type MessageAttachment = { id: string; type: AttachmentType; url: string; localUri?: string; // 本地URI,用于上传中的显示 filename?: string; size?: number; duration?: number; // 视频时长(秒) thumbnail?: string; // 视频缩略图 width?: number; height?: number; uploadProgress?: number; // 上传进度 0-1 uploadError?: string; // 上传错误信息 }; // AI选择选项数据结构 type AiChoiceOption = { id: string; label: string; value: any; recommended?: boolean; emoji?: string; }; // 餐次类型 type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; // 食物确认选项数据结构(暂未使用,预留给未来功能扩展) // type FoodConfirmationOption = { // id: string; // label: string; // foodName: string; // portion: string; // calories: number; // mealType: MealType; // nutritionData: { // proteinGrams?: number; // carbohydrateGrams?: number; // fatGrams?: number; // fiberGrams?: number; // }; // }; // AI响应数据结构 type AiResponseData = { content: string; choices?: AiChoiceOption[]; interactionType?: 'text' | 'food_confirmation' | 'selection'; pendingData?: any; context?: any; }; // 重构后的消息数据结构 type ChatMessage = { id: string; role: Role; content: string; // 文本内容 attachments?: MessageAttachment[]; // 附件列表 choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息) interactionType?: string; // 交互类型 pendingData?: any; // 待确认数据 context?: any; // 上下文信息 }; // 卡片类型常量定义 const CardType = { WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__', DIET_INPUT: '__DIET_INPUT_CARD__', DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__', DIET_PLAN: '__DIET_PLAN_CARD__', } as const; type CardType = typeof CardType[keyof typeof CardType]; // 定义路由参数类型 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(); const insets = useSafeAreaInsets(); const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 const colorScheme = useColorScheme(); const theme = Colors[colorScheme ?? 'light']; const botName = (params?.name || 'Seal').toString(); const [input, setInput] = useState(''); const [isSending, setIsSending] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [conversationId, setConversationId] = useState(undefined); const [messages, setMessages] = useState([]); const [historyVisible, setHistoryVisible] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyPage, setHistoryPage] = useState(1); const [historyTotal] = useState(0); const [historyItems, setHistoryItems] = useState([]); // 添加请求序列号,用于防止过期响应 const requestSequenceRef = useRef(0); const activeRequestIdRef = useRef(null); const listRef = useRef>(null); const [isAtBottom, setIsAtBottom] = useState(true); const didInitialScrollRef = useRef(false); const [composerHeight, setComposerHeight] = useState(80); const shouldAutoScrollRef = useRef(false); const [keyboardOffset, setKeyboardOffset] = useState(0); const [headerHeight, setHeaderHeight] = useState(60); const pendingAssistantIdRef = useRef(null); const [selectedImages, setSelectedImages] = useState<{ id: string; localUri: string; width?: number; height?: number; progress: number; uploadedKey?: string; uploadedUrl?: string; error?: string; }[]>([]); const [previewImageUri, setPreviewImageUri] = useState(null); const [dietTextInputs, setDietTextInputs] = useState>({}); const [weightInputs, setWeightInputs] = useState>({}); const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false); const [currentCardId, setCurrentCardId] = useState(null); const [selectedChoices, setSelectedChoices] = useState>({}); // messageId -> choiceId const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState>({}); // 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 = 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 | 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 = { 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 = 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 renderAttachment(attachment: MessageAttachment, isUser: boolean) { const { type, url, localUri, uploadProgress, uploadError, width, height, filename } = attachment; if (type === 'image') { const imageUri = url || localUri; if (!imageUri) return null; return ( setPreviewImageUri(imageUri)} style={styles.imageAttachment} > {uploadProgress !== undefined && uploadProgress < 1 && ( {Math.round(uploadProgress * 100)}% )} {uploadError && ( 上传失败 )} ); } if (type === 'video') { // 视频附件的实现 return ( {filename || '视频文件'} ); } if (type === 'file') { // 文件附件的实现 return ( {filename || 'unknown_file'} ); } return null; } // 渲染所有附件 function renderAttachments(attachments: MessageAttachment[], isUser: boolean) { if (!attachments || attachments.length === 0) return null; return ( {attachments.map(attachment => renderAttachment(attachment, isUser))} ); } function renderItem({ item }: { item: ChatMessage }) { const isUser = item.role === 'user'; return ( {renderBubbleContent(item)} {renderAttachments(item.attachments || [], isUser)} ); } function renderBubbleContent(item: ChatMessage) { if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { return ( 正在思考… 取消 ); } 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 ''; } })(); // 初始化输入值(如果还没有的话) const currentValue = weightInputs[cardId] ?? preset; return ( 记录今日体重 setWeightInputs(prev => ({ ...prev, [cardId]: text }))} onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text, cardId)} returnKeyType="done" submitBehavior="blurAndSubmit" /> kg handleSubmitWeight(currentValue, cardId)} > 记录 按回车或点击保存,即可将该体重同步到账户并发送到对话。 ); } if (item.content?.startsWith(CardType.DIET_INPUT)) { return ( 记录今日饮食 请选择记录方式: handleDietTextInput(item.id)} > 文字记录 输入吃了什么、大概多少克 handleDietPhotoInput(item.id)} > 拍照识别 拍摄食物照片进行AI分析 选择合适的方式记录您的饮食,Seal会根据您的饮食情况给出专业的营养建议。 ); } if (item.content?.startsWith(CardType.DIET_TEXT_INPUT)) { const cardId = item.content.split('\n')?.[1] || ''; const currentText = dietTextInputs[cardId] || ''; return ( 文字记录饮食 handleBackToDietOptions(cardId)} style={styles.dietBackBtn} > setDietTextInputs(prev => ({ ...prev, [cardId]: text }))} returnKeyType="done" /> handleSubmitDietText(currentText, cardId)} > 发送记录 详细描述您的饮食内容和分量,有助于Seal给出更精准的营养分析和建议。 ); } if (item.content?.startsWith(CardType.DIET_PLAN)) { const cardId = item.content.split('\n')?.[1] || ''; // 获取用户数据 const weight = userProfile?.weight ? Number(userProfile.weight) : 58; const height = userProfile?.height ? Number(userProfile.height) : 160; // 计算年龄 const calculateAge = (birthday?: string): number => { if (!birthday) return 25; // 默认年龄 try { const birthDate = new Date(birthday); const today = new Date(); let age = today.getFullYear() - birthDate.getFullYear(); const monthDiff = today.getMonth() - birthDate.getMonth(); if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { age--; } return age > 0 ? age : 25; } catch { return 25; } }; const age = calculateAge(userProfile?.birthDate); const gender = userProfile?.gender || 'female'; const name = userProfile?.name || '用户'; // 计算相关数据 const bmi = calculateBMI(weight, height); const bmiStatus = getBMIStatus(bmi); const dailyCalories = calculateDailyCalories(weight, height, age, gender); const nutrition = calculateNutritionDistribution(dailyCalories); return ( {/* 标题部分 */} 我的饮食方案 MY DIET PLAN {/* 我的档案数据 */} 我的档案数据 {name.charAt(0)} {age} 年龄/岁 {height.toFixed(1)} 身高/CM {weight.toFixed(1)} 体重/KG {/* BMI部分 */} 当前BMI {bmiStatus.status} {bmi.toFixed(1)} 偏瘦 正常 偏胖 肥胖 {/* 饮食目标 */} 饮食目标 {/* 目标体重 */} 目标体重 {/* 每日推荐摄入 */} 每日推荐摄入 {dailyCalories}千卡 {nutrition.carbs}g 碳水 {nutrition.protein}g 蛋白质 {nutrition.fat}g 脂肪 根据您的基础代谢率,这到理想体重所需要的热量缺口计算得出每日的热量推荐值。 {/* 底部按钮 */} { // 这里可以添加跳转到详细饮食方案页面的逻辑 console.log('跳转到饮食方案详情'); }} > 饮食方案 ); } // 在流式回复过程中显示取消按钮 if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) { return ( {item.content} 停止生成 ); } // 检查是否有选择选项需要显示 if (item.choices && item.choices.length > 0 && (item.interactionType === 'food_confirmation' || item.interactionType === 'selection')) { return ( {item.content || ''} {item.choices.map((choice) => { const isSelected = selectedChoices[item.id] === choice.id; const isAnySelected = selectedChoices[item.id] != null; const isPending = pendingChoiceConfirmation[item.id]; const isDisabled = isAnySelected && !isSelected; return ( { if (!isDisabled && !isPending) { Haptics.selectionAsync(); handleChoiceSelection(choice, item); } }} > {choice.emoji && ( {choice.emoji} )} {choice.label} {choice.recommended && !isSelected && ( 推荐 )} {isSelected && isPending && ( )} {isSelected && !isPending && ( 已选择 )} ); })} ); } return ( {item.content || ''} ); } 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 ( {/* 背景渐变 */} {/* 装饰性圆圈 */} {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} { const h = e.nativeEvent.layout.height; if (h && Math.abs(h - headerHeight) > 0.5) setHeaderHeight(h); }} > 海豹管家 {/* 使用次数显示 */} { }} > {userProfile?.isVip ? '不限' : `${userProfile?.freeUsageCount || 0}/${userProfile?.maxUsageCount || 0}`} {/* 消息列表容器 - 设置固定高度避免输入框重叠 */} 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} /> { const h = e.nativeEvent.layout.height; if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h); }} > {chips.map((c) => ( {c.label} ))} {!!selectedImages.length && ( {selectedImages.map((img) => ( setPreviewImageUri(img.uploadedUrl || img.localUri)}> {!!(img.progress > 0 && img.progress < 1) && ( {Math.round((img.progress || 0) * 100)}% )} {img.error && ( uploadImage(img)} style={styles.imageRetryBtn} > )} removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> ))} )} send(input)} submitBehavior="blurAndSubmit" /> { if (isSending || isStreaming) { cancelCurrentRequest(); } else { send(input); } }} style={[ styles.sendBtn, { backgroundColor: (isSending || isStreaming) ? theme.danger : theme.primary, opacity: ((input.trim() || selectedImages.length > 0) || (isSending || isStreaming)) ? 1 : 0.5 } ]} > {isSending ? ( ) : isStreaming ? ( ) : ( )} {!isAtBottom && ( )} setHistoryVisible(false)} historyLoading={historyLoading} historyItems={historyItems} historyPage={historyPage} onRefreshHistory={refreshHistory} onSelectConversation={handleSelectConversation} onDeleteConversation={confirmDeleteConversation} /> setPreviewImageUri(null)}> setPreviewImageUri(null)}> {previewImageUri ? ( ) : null} setShowDietPhotoActionSheet(false)} title="选择图片来源" options={[ { id: 'camera', title: '拍照', onPress: handleCameraPhoto }, { id: 'library', title: '从相册选择', onPress: handleLibraryPhoto }, ]} /> ); } 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;