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 { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, FlatList, Image, 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 { 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 dayjs from 'dayjs'; 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; }; // 餐次类型 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__', } as const; type CardType = typeof CardType[keyof typeof CardType]; // const COACH_AVATAR = require('@/assets/images/logo.png'); export default function CoachScreen() { const router = useRouter(); const params = useLocalSearchParams<{ name?: string }>(); const insets = useSafeAreaInsets(); const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard(); // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 const theme = Colors.light; const botName = (params?.name || 'Bot').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 generateWelcomeMessage = useCallback(() => { const hour = new Date().getHours(); const name = userProfile?.name || '朋友'; const botName = (params?.name || 'Health Bot').toString(); // 时段问候 let timeGreeting = ''; if (hour >= 5 && hour < 9) { timeGreeting = '早上好'; } else if (hour >= 9 && hour < 12) { timeGreeting = '上午好'; } else if (hour >= 12 && hour < 14) { timeGreeting = '中午好'; } else if (hour >= 14 && hour < 18) { timeGreeting = '下午好'; } else if (hour >= 18 && hour < 22) { timeGreeting = '晚上好'; } else { timeGreeting = '夜深了'; } // 欢迎消息模板 const welcomeMessages = [ { condition: () => hour >= 5 && hour < 9, messages: [ `${timeGreeting},${name}!我是${botName},你的专属健康管理助手。新的一天开始了,让我们一起为你的健康目标努力吧!`, `${timeGreeting}!早晨是制定健康计划的最佳时机,我是${botName},可以帮你管理营养摄入、运动计划和生活作息。`, `${timeGreeting},${name}!作为你的Health Bot,我很高兴能陪伴你的健康之旅。无论是饮食营养、健身锻炼还是生活管理,我都能为你提供专业建议。` ] }, { condition: () => hour >= 9 && hour < 12, messages: [ `${timeGreeting},${name}!我是${botName},你的智能健康顾问。上午是身体代谢最活跃的时候,有什么健康目标需要我帮你规划吗?`, `${timeGreeting}!工作忙碌的上午,别忘了关注身体健康。我是${botName},可以为你提供营养建议、运动指导和压力管理方案。`, `${timeGreeting},${name}!作为你的健康伙伴${botName},我想说:每一个健康的选择都在塑造更好的你。今天想从哪个方面开始呢?` ] }, { condition: () => hour >= 12 && hour < 14, messages: [ `${timeGreeting},${name}!午餐时间很关键呢,合理的营养搭配能为下午提供充足能量。我是${botName},可以为你分析饮食营养和热量管理。`, `${timeGreeting}!忙碌的上午结束了,该关注一下身体需求啦。我是你的健康助手${botName},无论是饮食调整、运动安排还是休息建议,都可以找我。`, `${timeGreeting},${name}!午间是调整状态的好时机。作为你的Health Bot,我建议关注饮食均衡和适度放松~` ] }, { condition: () => hour >= 14 && hour < 18, messages: [ `${timeGreeting},${name}!下午是身体活动的黄金时段,适合安排一些运动。我是${botName},可以为你制定个性化的健身计划和身材管理方案。`, `${timeGreeting}!午后时光,正是关注整体健康的好时机。我是你的健康管家${botName},从营养摄入到运动锻炼,我都能为你提供科学指导。`, `${timeGreeting},${name}!下午时光,身心健康同样重要。作为你的智能健康顾问${botName},我在这里支持你的每一个健康目标。` ] }, { condition: () => hour >= 18 && hour < 22, messages: [ `${timeGreeting},${name}!忙碌了一天,现在是时候关注身心平衡了。我是${botName},可以为你提供放松建议、营养补充和恢复方案。`, `${timeGreeting}!夜幕降临,这是一天中最适合总结和调整的时刻。我是你的健康伙伴${botName},让我们一起回顾今天的健康表现,规划明天的目标。`, `${timeGreeting},${name}!晚间时光属于你自己,也是关爱身体的珍贵时间。作为你的Health Bot,我想陪你聊聊如何更好地管理健康生活。` ] }, { condition: () => hour >= 22 || hour < 5, messages: [ `${timeGreeting},${name}!优质睡眠是健康的基石呢。我是${botName},如果需要睡眠优化建议或放松技巧,随时可以问我。`, `夜深了,${name}。充足的睡眠对身体恢复和新陈代谢都很重要。我是你的健康助手${botName},有什么关于睡眠健康的问题都可以咨询我。`, `夜深了,愿你能拥有高质量的睡眠。我是${botName},明天我们继续在健康管理的路上同行。晚安,${name}!` ] } ]; // 特殊情况的消息 const specialMessages = [ { condition: () => !userProfile?.weight && !userProfile?.height, message: `你好,${name}!我是${botName},你的智能健康管理助手。我注意到你还没有完善健康档案,不如先聊聊你的健康目标和身体状况,这样我能为你制定更个性化的健康方案。` }, { condition: () => userProfile && (!userProfile.pilatesPurposes || userProfile.pilatesPurposes.length === 0), message: `${timeGreeting},${name}!作为你的Health Bot,我想更好地了解你的健康需求。告诉我你希望在营养摄入、身材管理、健身锻炼或生活管理方面实现什么目标吧~` } ]; // 检查特殊情况 for (const special of specialMessages) { if (special.condition()) { return special.message; } } // 根据时间选择合适的消息组 const timeGroup = welcomeMessages.find(group => group.condition()); if (timeGroup) { const messages = timeGroup.messages; return messages[Math.floor(Math.random() * messages.length)]; } // 默认消息 return `你好,我是${botName},你的智能健康管理助手。可以向我咨询营养摄入、身材管理、健身锻炼、生活管理等各方面的健康问题~`; }, [userProfile, params?.name]); 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() }, ], [router, 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); }, []); // 初始化欢迎消息 const initializeWelcomeMessage = useCallback(() => { const welcomeMessage: ChatMessage = { id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage(), }; setMessages([welcomeMessage]); }, [generateWelcomeMessage]); // 启动页面时尝试恢复当次应用会话缓存 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 - insets.bottom); 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]); 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); setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]); 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); setMessages([{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]); } 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[] = []) { 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, }; 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, })); 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分析 选择合适的方式记录您的饮食,Health Bot会根据您的饮食情况给出专业的营养建议。 ); } 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)} > 发送记录 详细描述您的饮食内容和分量,有助于Health Bot给出更精准的营养分析和建议。 ); } // 在流式回复过程中显示取消按钮 if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) { return ( {item.content} 停止生成 ); } // 检查是否有选择选项需要显示 if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') { 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.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); } 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 { // 上传图片 const { url } = await upload( { uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' }, { prefix: 'images/diet' } ); // 移除饮食选择卡片 setMessages((prev) => prev.filter(msg => msg.id !== currentCardId)); // 发送包含图片的饮食记录消息,图片通过 imageUrls 参数传递 const dietMsg = `#记饮食:请分析这张食物照片的营养成分和热量`; 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); }} > {botName} {/* 消息列表容器 - 设置固定高度避免输入框重叠 */} 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) ? '#FF4444' : theme.primary, opacity: ((input.trim() || selectedImages.length > 0) || (isSending || isStreaming)) ? 1 : 0.5 } ]} > {isSending ? ( ) : isStreaming ? ( ) : ( )} {!isAtBottom && ( )} setHistoryVisible(false)}> setHistoryVisible(false)}> 历史会话 refreshHistory(historyPage)} style={styles.modalRefreshBtn}> {historyLoading ? ( 加载中... ) : ( {historyItems.length === 0 ? ( 暂无会话 ) : ( historyItems.map((it) => ( handleSelectConversation(it.conversationId)} > {it.title || '未命名会话'} {dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')} confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}> )) )} )} setHistoryVisible(false)} style={styles.modalCloseBtn}> 关闭 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, }, headerTitle: { fontSize: 20, fontWeight: '800', }, headerActions: { flexDirection: 'row', alignItems: 'center', gap: 8, }, headerActionButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', }, 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: 'rgba(187,242,70,0.6)' }, dietOptionsContainer: { gap: 8, }, dietOptionBtn: { flexDirection: 'row', alignItems: 'center', padding: 12, borderRadius: 12, backgroundColor: 'rgba(255,255,255,0.9)', borderWidth: 1, borderColor: 'rgba(187,242,70,0.3)', }, dietOptionIconContainer: { width: 40, height: 40, borderRadius: 20, backgroundColor: 'rgba(187,242,70,0.2)', 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: 'rgba(187,242,70,0.6)', 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(0,0,0,0.06)' }, 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, borderWidth: 1, borderRadius: 16, backgroundColor: 'rgba(0,0,0,0.04)' }, 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, }, modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', padding: 16, justifyContent: 'flex-end', }, modalSheet: { borderTopLeftRadius: 16, borderTopRightRadius: 16, paddingHorizontal: 12, paddingTop: 10, paddingBottom: 12, }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, paddingBottom: 8, }, modalTitle: { fontSize: 16, fontWeight: '800', color: '#192126', }, modalRefreshBtn: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.06)' }, historyRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 8, borderRadius: 10, }, historyTitle: { fontSize: 15, color: '#192126', fontWeight: '600', }, historyMeta: { marginTop: 2, fontSize: 12, color: '#687076', }, historyDeleteBtn: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(255,68,68,0.08)' }, modalFooter: { paddingTop: 8, alignItems: 'flex-end', }, modalCloseBtn: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 10, backgroundColor: 'rgba(0,0,0,0.06)' }, 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, }, choiceButton: { backgroundColor: 'rgba(255,255,255,0.9)', borderWidth: 1, borderColor: 'rgba(187,242,70,0.3)', borderRadius: 12, padding: 12, }, choiceButtonRecommended: { borderColor: 'rgba(187,242,70,0.6)', backgroundColor: 'rgba(187,242,70,0.1)', }, choiceButtonSelected: { borderColor: '#2D5016', backgroundColor: 'rgba(187,242,70,0.2)', 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', }, choiceLabel: { fontSize: 15, fontWeight: '600', color: '#192126', flex: 1, }, choiceLabelRecommended: { color: '#2D5016', }, choiceLabelSelected: { color: '#2D5016', fontWeight: '700', }, choiceLabelDisabled: { color: '#687076', }, choiceStatusContainer: { flexDirection: 'row', alignItems: 'center', gap: 8, }, recommendedBadge: { backgroundColor: 'rgba(187,242,70,0.8)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 2, }, recommendedText: { fontSize: 12, fontWeight: '700', color: '#2D5016', }, selectedBadge: { backgroundColor: '#2D5016', 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', }, }); 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;