Files
digital-pilates/app/ai-coach-chat.tsx
richarjiang dacbee197c feat: 更新训练计划和打卡功能
- 在训练计划中新增训练项目的添加、更新和删除功能,支持用户灵活管理训练内容
- 优化训练计划排课界面,提升用户体验
- 更新打卡功能,支持按日期加载和展示打卡记录
- 删除不再使用的打卡相关页面,简化代码结构
- 新增今日训练页面,集成今日训练计划和动作展示
- 更新样式以适应新功能的展示和交互
2025-08-15 17:01:33 +08:00

1144 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import { 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<string | undefined>(undefined);
const [messages, setMessages] = useState<ChatMessage[]>([{
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<AiConversationListItem[]>([]);
const listRef = useRef<FlatList<ChatMessage>>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const didInitialScrollRef = useRef(false);
const [composerHeight, setComposerHeight] = useState<number>(80);
const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
id: string;
localUri: string;
width?: number;
height?: number;
progress: number;
uploadedKey?: string;
uploadedUrl?: string;
error?: string;
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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); }
});
hideSub = Keyboard.addListener('keyboardWillHide', () => 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<string[]> {
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<string, string> = {
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<string, number> = {};
const exerciseCount: Record<string, number> = {};
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 (
<Animated.View
entering={isUser ? FadeInUp.springify().damping(18) : FadeInDown.springify().damping(18)}
layout={Layout.springify().damping(18)}
style={[styles.row, { justifyContent: isUser ? 'flex-end' : 'flex-start' }]}
>
{!isUser && (
<Image source={{ uri: COACH_AVATAR }} style={styles.avatar} />
)}
<View
style={[
styles.bubble,
{
backgroundColor: isUser ? theme.primary : 'rgba(187,242,70,0.16)',
borderTopLeftRadius: isUser ? 16 : 6,
borderTopRightRadius: isUser ? 6 : 16,
},
]}
>
{renderBubbleContent(item)}
</View>
{false}
</Animated.View>
);
}
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
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 (
<View style={{ gap: 8 }}>
<Text style={[styles.bubbleText, { color: '#192126', fontWeight: '700' }]}></Text>
<View style={styles.weightRow}>
<TextInput
placeholder="例如 60.5"
keyboardType="decimal-pad"
defaultValue={preset}
placeholderTextColor={'#687076'}
style={styles.weightInput}
onSubmitEditing={(e) => handleSubmitWeight(e.nativeEvent.text)}
returnKeyType="done"
submitBehavior="blurAndSubmit"
/>
<Text style={styles.weightUnit}>kg</Text>
<TouchableOpacity accessibilityRole="button" style={styles.weightSaveBtn} onPress={() => handleSubmitWeight((preset || '').toString())}>
<Text style={{ color: '#192126', fontWeight: '700' }}></Text>
</TouchableOpacity>
</View>
<Text style={{ color: '#687076', fontSize: 12 }}></Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
);
}
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 (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
<HeaderBar
title={`教练 ${coachName}`}
onBack={() => router.back()}
tone="light"
transparent
right={(
<TouchableOpacity accessibilityRole="button" onPress={openHistory} style={[styles.backButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}>
<Ionicons name="time-outline" size={18} color={theme.onPrimary} />
</TouchableOpacity>
)}
/>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
onLayout={() => {
// 确保首屏布局后也尝试滚动
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
setTimeout(scrollToEnd, 0);
requestAnimationFrame(scrollToEnd);
}
}}
contentContainerStyle={{ paddingHorizontal: 14, paddingTop: 8 }}
ListFooterComponent={() => (
<View style={{ height: insets.bottom + keyboardOffset + composerHeight + (isAtBottom ? 0 : 56) + 16 }} />
)}
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}
/>
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: insets.bottom + 10, bottom: keyboardOffset }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
>
<Ionicons name="image-outline" size={18} color={'#192126'} />
</TouchableOpacity>
<TextInput
placeholder="问我任何与普拉提相关的问题..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
blurOnSubmit={false}
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
{!isAtBottom && (
<TouchableOpacity
accessibilityRole="button"
onPress={scrollToEnd}
style={[styles.scrollToBottomFab, { bottom: insets.bottom + composerHeight + 16, backgroundColor: theme.primary }]}
>
<Ionicons name="chevron-down" size={18} color={theme.onPrimary} />
</TouchableOpacity>
)}
<Modal transparent visible={historyVisible} animationType="fade" onRequestClose={() => setHistoryVisible(false)}>
<TouchableOpacity activeOpacity={1} style={styles.modalBackdrop} onPress={() => setHistoryVisible(false)}>
<View style={[styles.modalSheet, { backgroundColor: '#FFFFFF' }]}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}></Text>
<TouchableOpacity accessibilityRole="button" onPress={() => refreshHistory(historyPage)} style={styles.modalRefreshBtn}>
<Ionicons name="refresh" size={16} color="#192126" />
</TouchableOpacity>
</View>
{historyLoading ? (
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
<ActivityIndicator />
<Text style={{ marginTop: 8, color: '#687076' }}>...</Text>
</View>
) : (
<ScrollView style={{ maxHeight: 360 }}>
{historyItems.length === 0 ? (
<Text style={{ padding: 16, color: '#687076' }}></Text>
) : (
historyItems.map((it) => (
<View key={it.conversationId} style={styles.historyRow}>
<TouchableOpacity
style={{ flex: 1 }}
onPress={() => handleSelectConversation(it.conversationId)}
>
<Text style={styles.historyTitle} numberOfLines={1}>{it.title || '未命名会话'}</Text>
<Text style={styles.historyMeta}>
{dayjs(it.lastMessageAt || it.createdAt).format('YYYY/MM/DD HH:mm')}
</Text>
</TouchableOpacity>
<TouchableOpacity accessibilityRole="button" onPress={() => confirmDeleteConversation(it.conversationId)} style={styles.historyDeleteBtn}>
<Ionicons name="trash-outline" size={16} color="#FF4444" />
</TouchableOpacity>
</View>
))
)}
</ScrollView>
)}
<View style={styles.modalFooter}>
<TouchableOpacity accessibilityRole="button" onPress={() => setHistoryVisible(false)} style={styles.modalCloseBtn}>
<Text style={{ color: '#192126', fontWeight: '600' }}></Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
<Modal transparent visible={!!previewImageUri} animationType="fade" onRequestClose={() => setPreviewImageUri(null)}>
<TouchableOpacity activeOpacity={1} style={styles.previewBackdrop} onPress={() => setPreviewImageUri(null)}>
<View style={styles.previewBox}>
{previewImageUri ? (
<Image source={{ uri: previewImageUri }} style={styles.previewImage} resizeMode="contain" />
) : null}
</View>
</TouchableOpacity>
</Modal>
</View>
);
}
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;