Files
digital-pilates/app/(tabs)/coach.tsx
richarjiang 5e3203f1ce feat: 添加历史会话模态框和更新组件
- 在 CoachScreen 中引入 HistoryModal 组件,优化历史会话展示
- 更新 NutritionRecordCard 组件,使用 Popover 替代 ActionSheet,提升操作体验
- 在 NutritionRecordsScreen 中引入 DateSelector 组件,简化日期选择逻辑
- 更新 package.json 和 package-lock.json,新增 react-native-popover-view 依赖
- 移除不再使用的历史会话模态框代码,提升代码整洁性
2025-08-27 08:49:56 +08:00

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