import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; 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 { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { buildCosKey, buildPublicUrl } from '@/constants/Cos'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach'; import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession'; import { api, getAuthToken, postTextStream } from '@/services/api'; import { uploadWithRetry } from '@/services/cos'; import { updateUser as updateUserApi } from '@/services/users'; import type { CheckinRecord } from '@/store/checkinSlice'; import { fetchMyProfile, updateProfile } from '@/store/userSlice'; import dayjs from 'dayjs'; type Role = 'user' | 'assistant'; type ChatMessage = { id: string; role: Role; content: string; }; const COACH_AVATAR = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/avatar/imageCoach01.jpeg'; export default function AICoachChatScreen() { const router = useRouter(); const params = useLocalSearchParams<{ name?: string }>(); const insets = useSafeAreaInsets(); const colorScheme = useColorScheme() ?? 'light'; // 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色 const theme = Colors.light; const coachName = (params?.name || 'Sarah').toString(); const [input, setInput] = useState(''); const [isSending, setIsSending] = useState(false); const [isStreaming, setIsStreaming] = useState(false); const [conversationId, setConversationId] = useState(undefined); const [messages, setMessages] = useState([{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~`, }]); const [historyVisible, setHistoryVisible] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyPage, setHistoryPage] = useState(1); const [historyTotal, setHistoryTotal] = useState(0); const [historyItems, setHistoryItems] = useState([]); 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 pendingAssistantIdRef = useRef(null); const [selectedImages, setSelectedImages] = useState>([]); const [previewImageUri, setPreviewImageUri] = useState(null); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => (s as any).checkin); const dispatch = useAppDispatch(); const userProfile = useAppSelector((s) => (s as any)?.user?.profile); 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() }, ], [router, planDraft, checkin]); const scrollToEnd = useCallback(() => { requestAnimationFrame(() => { listRef.current?.scrollToEnd({ animated: true }); }); }, []); const handleScroll = useCallback((e: any) => { try { const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {}; const paddingToBottom = 60; const distanceFromBottom = (contentSize?.height || 0) - ((layoutMeasurement?.height || 0) + (contentOffset?.y || 0)); setIsAtBottom(distanceFromBottom <= paddingToBottom); } catch { } }, []); useEffect(() => { // 初次进入或恢复时,保持最新消息可见 scrollToEnd(); }, [scrollToEnd]); // 启动页面时尝试恢复当次应用会话缓存 useEffect(() => { (async () => { try { const cached = await loadAiCoachSessionCache(); if (cached && Array.isArray(cached.messages) && cached.messages.length > 0) { setConversationId(cached.conversationId); setMessages(cached.messages as any); setTimeout(scrollToEnd, 0); } } catch { } })(); }, [scrollToEnd]); // 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空) const saveCacheTimerRef = useRef | null>(null); useEffect(() => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); saveCacheTimerRef.current = setTimeout(() => { saveAiCoachSessionCache({ conversationId, messages: messages as any, updatedAt: Date.now() }).catch(() => { }); }, 150); return () => { if (saveCacheTimerRef.current) clearTimeout(saveCacheTimerRef.current); }; // 仅在 messages 或 conversationId 变化时触发 }, [messages, conversationId]); // 取消对 messages.length 的全局监听滚动,改为在“消息实际追加完成”后再判断与滚动,避免突兀与多次触发 useEffect(() => { // 输入区高度变化时,若用户在底部则轻柔跟随一次 if (isAtBottom) { const id = setTimeout(scrollToEnd, 0); return () => clearTimeout(id); } }, [composerHeight, isAtBottom, scrollToEnd]); // 键盘事件:在键盘弹出时,将输入区与悬浮按钮一起上移,避免遮挡 useEffect(() => { let showSub: any = null; let hideSub: any = null; if (Platform.OS === 'ios') { showSub = Keyboard.addListener('keyboardWillChangeFrame', (e: any) => { try { const height = Math.max(0, (e.endCoordinates?.height ?? 0) - insets.bottom); setKeyboardOffset(height); } catch { setKeyboardOffset(0); } }); } else { showSub = Keyboard.addListener('keyboardDidShow', (e: any) => { try { setKeyboardOffset(Math.max(0, e.endCoordinates?.height ?? 0)); } catch { setKeyboardOffset(0); } }); hideSub = Keyboard.addListener('keyboardDidHide', () => setKeyboardOffset(0)); } return () => { try { showSub?.remove?.(); } catch { } try { hideSub?.remove?.(); } catch { } }; }, [insets.bottom]); const streamAbortRef = useRef<{ abort: () => void } | null>(null); useEffect(() => { return () => { try { streamAbortRef.current?.abort(); } catch { } }; }, []); function ensureConversationId(): string { if (conversationId && conversationId.trim()) return conversationId; const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; setConversationId(cid); try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { } return cid; } function convertToServerMessages(history: ChatMessage[]): Array<{ 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) { try { streamAbortRef.current?.abort(); } catch { } } 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); } } async function handleSelectConversation(id: string) { try { if (isStreaming) { try { streamAbortRef.current?.abort(); } catch { } } 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 || '' })); setConversationId(detail.conversationId); setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); 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: `你好,我是你的普拉提教练 ${coachName}。可以向我咨询训练、体态、康复、柔韧等问题~` }]); } await refreshHistory(historyPage); } catch (e) { Alert.alert('错误', (e as any)?.message || '删除失败'); } } } ]); } async function sendStream(text: string) { const tokenExists = !!getAuthToken(); try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { } // 终止上一次未完成的流 if (streamAbortRef.current) { try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { } try { streamAbortRef.current.abort(); } catch { } streamAbortRef.current = null; } // 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文 const historyForServer = convertToServerMessages(messages); const cid = ensureConversationId(); const body = { conversationId: cid, messages: [...historyForServer, { role: 'user' as const, content: text }], stream: true, }; // 在 UI 中先放置占位回答,随后持续增量更新 const assistantId = `a_${Date.now()}`; const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text }; shouldAutoScrollRef.current = isAtBottom; setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]); pendingAssistantIdRef.current = assistantId; setIsSending(true); setIsStreaming(true); 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) => { receivedAnyChunk = true; const atBottomNow = isAtBottom; updateAssistantContent(chunk); if (atBottomNow) { // 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况 shouldAutoScrollRef.current = true; setTimeout(scrollToEnd, 0); } try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { } }; const onEnd = (cidFromHeader?: string) => { setIsSending(false); setIsStreaming(false); streamAbortRef.current = null; if (cidFromHeader && !conversationId) setConversationId(cidFromHeader); pendingAssistantIdRef.current = null; try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } }; const onError = async (err: any) => { try { console.warn('[AI_CHAT][api] error', err); } catch { } setIsSending(false); setIsStreaming(false); streamAbortRef.current = null; pendingAssistantIdRef.current = null; // 流式失败时的降级:尝试一次性非流式 try { const bodyNoStream = { ...body, stream: false }; try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { } const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream); 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) { setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg)); try { console.warn('[AI_CHAT][fallback] non-stream 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 (isSending) return; const trimmed = text.trim(); if (!trimmed && selectedImages.length === 0) return; async function ensureImagesUploaded(): Promise { const urls: string[] = []; for (const img of selectedImages) { if (img.uploadedUrl) { urls.push(img.uploadedUrl); continue; } try { const resp = await fetch(img.localUri); const blob = await resp.blob(); const ext = (() => { const t = (blob.type || '').toLowerCase(); if (t.includes('png')) return 'png'; if (t.includes('webp')) return 'webp'; if (t.includes('heic')) return 'heic'; if (t.includes('heif')) return 'heif'; return 'jpg'; })(); const key = buildCosKey({ prefix: 'images/chat', ext }); const res = await uploadWithRetry({ key, body: blob, contentType: blob.type || 'image/jpeg', onProgress: ({ percent }: { percent?: number }) => { const p = typeof percent === 'number' ? percent : 0; setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, progress: p } : it)); }, } as any); const url = buildPublicUrl(res.key); urls.push(url); setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, uploadedKey: res.key, uploadedUrl: url, progress: 1 } : it)); } catch (e: any) { setSelectedImages((prev) => prev.map((it) => it.id === img.id ? { ...it, error: e?.message || '上传失败' } : it)); throw e; } } return urls; } try { const urls = await ensureImagesUploaded(); const mdImages = urls.map((u) => `![image](${u})`).join('\n\n'); const composed = [trimmed, mdImages].filter(Boolean).join('\n\n'); setInput(''); setSelectedImages([]); await sendStream(composed); } catch (e: any) { Alert.alert('上传失败', e?.message || '图片上传失败,请稍后重试'); } } function handleQuickPlan() { const goalMap: Record = { postpartum_recovery: '产后恢复', fat_loss: '减脂塑形', posture_correction: '体态矫正', core_strength: '核心力量', flexibility: '柔韧灵活', rehab: '康复保健', stress_relief: '释压放松', }; const goalText = planDraft?.goal ? goalMap[planDraft.goal] : '整体提升'; const freq = planDraft?.mode === 'sessionsPerWeek' ? `${planDraft?.sessionsPerWeek ?? 3}次/周` : (planDraft?.daysOfWeek?.length ? `${planDraft.daysOfWeek.length}次/周` : '3次/周'); const prefer = planDraft?.preferredTimeOfDay ? `偏好${planDraft.preferredTimeOfDay}` : '时间灵活'; const prompt = `请根据我的目标“${goalText}”、频率“${freq}”、${prefer},制定1周的普拉提训练计划,包含每次训练主题、时长、主要动作与注意事项,并给出恢复建议。`; send(prompt); } function buildTrainingSummary(): string { const entries = Object.values(checkin?.byDate || {}) as CheckinRecord[]; if (!entries.length) return ''; const recent = entries.sort((a: any, b: any) => String(b.date).localeCompare(String(a.date))).slice(0, 14); let totalSessions = 0; let totalExercises = 0; let totalCompleted = 0; const categoryCount: Record = {}; const exerciseCount: Record = {}; for (const rec of recent) { if (!rec?.items?.length) continue; totalSessions += 1; for (const it of rec.items) { totalExercises += 1; if (it.completed) totalCompleted += 1; categoryCount[it.category] = (categoryCount[it.category] || 0) + 1; exerciseCount[it.name] = (exerciseCount[it.name] || 0) + 1; } } const topCategories = Object.entries(categoryCount).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k}×${v}`); const topExercises = Object.entries(exerciseCount).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => `${k}×${v}`); return [ `统计周期:最近${recent.length}天(按有记录日计 ${totalSessions} 天)`, `记录条目:${totalExercises},完成标记:${totalCompleted}`, topCategories.length ? `高频类别:${topCategories.join(',')}` : '', topExercises.length ? `高频动作:${topExercises.join(',')}` : '', ].filter(Boolean).join('\n'); } function handleAnalyzeRecords() { const summary = buildTrainingSummary(); if (!summary) { send('我还没有可分析的打卡记录,请先在“每日打卡”添加并完成一些训练记录,然后帮我分析近期训练表现与改进建议。'); return; } const prompt = `请基于以下我的近期训练记录进行分析,输出:1)整体训练负荷与节奏;2)动作与肌群的均衡性(指出偏多/偏少);3)容易忽视的恢复与热身建议;4)后续一周的优化建议(频次/时长/动作方向)。\n\n${summary}`; send(prompt); } const pickImages = useCallback(async () => { try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsMultipleSelection: true, selectionLimit: 4, quality: 0.9, } as any); if ((result as any).canceled) return; const assets = (result as any).assets || []; const next = assets.map((a: any) => ({ id: `${a.assetId || a.fileName || a.uri}_${Math.random().toString(36).slice(2, 8)}`, localUri: a.uri, width: a.width, height: a.height, progress: 0, })); setSelectedImages((prev) => { const merged = [...prev, ...next]; return merged.slice(0, 4); }); setTimeout(scrollToEnd, 0); } catch (e: any) { Alert.alert('错误', e?.message || '选择图片失败'); } }, [scrollToEnd]); const removeSelectedImage = useCallback((id: string) => { setSelectedImages((prev) => prev.filter((it) => it.id !== id)); }, []); function renderItem({ item }: { item: ChatMessage }) { const isUser = item.role === 'user'; return ( {!isUser && ( )} {renderBubbleContent(item)} {false} ); } function renderBubbleContent(item: ChatMessage) { if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { return 正在思考…; } if (item.content?.startsWith('__WEIGHT_INPUT_CARD__')) { const preset = (() => { const m = item.content.split('\n')?.[1]; const v = parseFloat(m || ''); return isNaN(v) ? '' : String(v); })(); return ( 记录今日体重 handleSubmitWeight(e.nativeEvent.text)} returnKeyType="done" blurOnSubmit /> kg handleSubmitWeight((preset || '').toString())}> 保存 按回车或点击保存,即可将该体重同步到账户并发送到对话。 ); } return ( {item.content} ); } function insertWeightInputCard() { const id = `wcard_${Date.now()}`; const preset = userProfile?.weight ? Number(userProfile.weight) : undefined; const payload = `__WEIGHT_INPUT_CARD__\n${preset ?? ''}`; setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]); setTimeout(scrollToEnd, 0); } async function handleSubmitWeight(text?: string) { const val = parseFloat(String(text ?? '').trim()); if (isNaN(val) || val <= 0 || val > 500) { Alert.alert('请输入有效体重', '请填写合理的公斤数,例如 60.5'); return; } try { // 本地更新 dispatch(updateProfile({ weight: val })); // 后端同步(若有 userId 则更稳妥;后端实现容错) try { const userId = (userProfile as any)?.userId || (userProfile as any)?.id || (userProfile as any)?._id; if (userId) { await updateUserApi({ userId, weight: val }); await dispatch(fetchMyProfile() as any); } } catch (e) { // 不阻断对话体验 } // 在对话中插入“确认消息”并发送给教练 const textMsg = `我记录了今日体重:${val} kg。请基于这一变化给出训练/营养建议。`; await send(textMsg); } catch (e: any) { Alert.alert('保存失败', e?.message || '请稍后重试'); } } return ( router.back()} tone="light" transparent right={( )} /> m.id} renderItem={renderItem} onLayout={() => { // 确保首屏布局后也尝试滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; setTimeout(scrollToEnd, 0); requestAnimationFrame(scrollToEnd); } }} contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }} ListFooterComponent={() => ( )} onContentSizeChange={() => { // 首次内容变化强制滚底,其余仅在接近底部时滚动 if (!didInitialScrollRef.current) { didInitialScrollRef.current = true; setTimeout(scrollToEnd, 0); requestAnimationFrame(scrollToEnd); return; } if (shouldAutoScrollRef.current) { shouldAutoScrollRef.current = false; setTimeout(scrollToEnd, 0); } }} 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)}% )} removeSelectedImage(img.id)} style={styles.imageRemoveBtn}> ))} )} send(input)} blurOnSubmit={false} /> send(input)} style={[ styles.sendBtn, { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 } ]} > {isSending ? ( ) : ( )} {!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} ); } const styles = StyleSheet.create({ screen: { flex: 1, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingBottom: 10, }, backButton: { width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(255,255,255,0.06)' }, headerTitle: { fontSize: 20, fontWeight: '800', }, 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: { maxWidth: '82%', 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)' }, // 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' }, 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%', }, }); 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;