Files
digital-pilates/app/(tabs)/coach.tsx
richarjiang b396a7d101 feat: 更新教练页面和布局,优化用户体验
- 将教练页面中的“Bot”名称更改为“Seal”,提升品牌一致性
- 在布局文件中调整标签标题和图标,确保与新名称一致
- 新增使用次数显示功能,优化用户对使用情况的了解
- 更新日期选择器样式,增强未来日期的禁用效果
- 优化压力分析模态框的颜色和文本,提升可读性
2025-08-21 10:29:12 +08:00

2905 lines
94 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, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Image,
Keyboard,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View
} from 'react-native';
import Markdown from 'react-native-markdown-display';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api';
import { updateProfile } from '@/store/userSlice';
import dayjs from 'dayjs';
import { LinearGradient } from 'expo-linear-gradient';
import { ActionSheet } from '../../components/ui/ActionSheet';
type Role = 'user' | 'assistant';
// 附件类型枚举
type AttachmentType = 'image' | 'video' | 'file';
// 附件数据结构
type MessageAttachment = {
id: string;
type: AttachmentType;
url: string;
localUri?: string; // 本地URI用于上传中的显示
filename?: string;
size?: number;
duration?: number; // 视频时长(秒)
thumbnail?: string; // 视频缩略图
width?: number;
height?: number;
uploadProgress?: number; // 上传进度 0-1
uploadError?: string; // 上传错误信息
};
// AI选择选项数据结构
type AiChoiceOption = {
id: string;
label: string;
value: any;
recommended?: boolean;
};
// 餐次类型
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
// 食物确认选项数据结构(暂未使用,预留给未来功能扩展)
// type FoodConfirmationOption = {
// id: string;
// label: string;
// foodName: string;
// portion: string;
// calories: number;
// mealType: MealType;
// nutritionData: {
// proteinGrams?: number;
// carbohydrateGrams?: number;
// fatGrams?: number;
// fiberGrams?: number;
// };
// };
// AI响应数据结构
type AiResponseData = {
content: string;
choices?: AiChoiceOption[];
interactionType?: 'text' | 'food_confirmation' | 'selection';
pendingData?: any;
context?: any;
};
// 重构后的消息数据结构
type ChatMessage = {
id: string;
role: Role;
content: string; // 文本内容
attachments?: MessageAttachment[]; // 附件列表
choices?: AiChoiceOption[]; // 选择选项仅用于assistant消息
interactionType?: string; // 交互类型
pendingData?: any; // 待确认数据
context?: any; // 上下文信息
};
// 卡片类型常量定义
const CardType = {
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
DIET_INPUT: '__DIET_INPUT_CARD__',
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
DIET_PLAN: '__DIET_PLAN_CARD__',
} as const;
type CardType = typeof CardType[keyof typeof CardType];
// const COACH_AVATAR = require('@/assets/images/logo.png');
export default function CoachScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ name?: string }>();
const insets = useSafeAreaInsets();
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
const theme = Colors.light;
const botName = (params?.name || '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 generateWelcomeMessage = useCallback(() => {
const hour = new Date().getHours();
const name = userProfile?.name || '朋友';
const botName = (params?.name || 'Seal').toString();
// 时段问候
let timeGreeting = '';
if (hour >= 5 && hour < 9) {
timeGreeting = '早上好';
} else if (hour >= 9 && hour < 12) {
timeGreeting = '上午好';
} else if (hour >= 12 && hour < 14) {
timeGreeting = '中午好';
} else if (hour >= 14 && hour < 18) {
timeGreeting = '下午好';
} else if (hour >= 18 && hour < 22) {
timeGreeting = '晚上好';
} else {
timeGreeting = '夜深了';
}
// 欢迎消息模板
const welcomeMessages = [
{
condition: () => hour >= 5 && hour < 9,
messages: [
`${timeGreeting}${name}!我是${botName},你的专属健康管理助手。新的一天开始了,让我们一起为你的健康目标努力吧!`,
`${timeGreeting}!早晨是制定健康计划的最佳时机,我是${botName},可以帮你管理营养摄入、运动计划和生活作息。`,
`${timeGreeting}${name}作为你的Seal我很高兴能陪伴你的健康之旅。无论是饮食营养、健身锻炼还是生活管理我都能为你提供专业建议。`
]
},
{
condition: () => hour >= 9 && hour < 12,
messages: [
`${timeGreeting}${name}!我是${botName},你的智能健康顾问。上午是身体代谢最活跃的时候,有什么健康目标需要我帮你规划吗?`,
`${timeGreeting}!工作忙碌的上午,别忘了关注身体健康。我是${botName},可以为你提供营养建议、运动指导和压力管理方案。`,
`${timeGreeting}${name}!作为你的健康伙伴${botName},我想说:每一个健康的选择都在塑造更好的你。今天想从哪个方面开始呢?`
]
},
{
condition: () => hour >= 12 && hour < 14,
messages: [
`${timeGreeting}${name}!午餐时间很关键呢,合理的营养搭配能为下午提供充足能量。我是${botName},可以为你分析饮食营养和热量管理。`,
`${timeGreeting}!忙碌的上午结束了,该关注一下身体需求啦。我是你的健康助手${botName},无论是饮食调整、运动安排还是休息建议,都可以找我。`,
`${timeGreeting}${name}午间是调整状态的好时机。作为你的Seal我建议关注饮食均衡和适度放松`
]
},
{
condition: () => hour >= 14 && hour < 18,
messages: [
`${timeGreeting}${name}!下午是身体活动的黄金时段,适合安排一些运动。我是${botName},可以为你制定个性化的健身计划和身材管理方案。`,
`${timeGreeting}!午后时光,正是关注整体健康的好时机。我是你的健康管家${botName},从营养摄入到运动锻炼,我都能为你提供科学指导。`,
`${timeGreeting}${name}!下午时光,身心健康同样重要。作为你的智能健康顾问${botName},我在这里支持你的每一个健康目标。`
]
},
{
condition: () => hour >= 18 && hour < 22,
messages: [
`${timeGreeting}${name}!忙碌了一天,现在是时候关注身心平衡了。我是${botName},可以为你提供放松建议、营养补充和恢复方案。`,
`${timeGreeting}!夜幕降临,这是一天中最适合总结和调整的时刻。我是你的健康伙伴${botName},让我们一起回顾今天的健康表现,规划明天的目标。`,
`${timeGreeting}${name}晚间时光属于你自己也是关爱身体的珍贵时间。作为你的Seal我想陪你聊聊如何更好地管理健康生活。`
]
},
{
condition: () => hour >= 22 || hour < 5,
messages: [
`${timeGreeting}${name}!优质睡眠是健康的基石呢。我是${botName},如果需要睡眠优化建议或放松技巧,随时可以问我。`,
`夜深了,${name}。充足的睡眠对身体恢复和新陈代谢都很重要。我是你的健康助手${botName},有什么关于睡眠健康的问题都可以咨询我。`,
`夜深了,愿你能拥有高质量的睡眠。我是${botName},明天我们继续在健康管理的路上同行。晚安,${name}`
]
}
];
// 特殊情况的消息
const specialMessages = [
{
condition: () => !userProfile?.weight && !userProfile?.height,
message: `你好,${name}!我是${botName},你的智能健康管理助手。我注意到你还没有完善健康档案,不如先聊聊你的健康目标和身体状况,这样我能为你制定更个性化的健康方案。`
},
{
condition: () => userProfile && (!userProfile.pilatesPurposes || userProfile.pilatesPurposes.length === 0),
message: `${timeGreeting}${name}作为你的Seal我想更好地了解你的健康需求。告诉我你希望在营养摄入、身材管理、健身锻炼或生活管理方面实现什么目标吧`
}
];
// 检查特殊情况
for (const special of specialMessages) {
if (special.condition()) {
return special.message;
}
}
// 根据时间选择合适的消息组
const timeGroup = welcomeMessages.find(group => group.condition());
if (timeGroup) {
const messages = timeGroup.messages;
return messages[Math.floor(Math.random() * messages.length)];
}
// 默认消息
return `你好,我是${botName},你的智能健康管理助手。可以向我咨询营养摄入、身材管理、健身锻炼、生活管理等各方面的健康问题~`;
}, [userProfile, params?.name]);
const chips = useMemo(() => [
// { key: 'posture', label: '体态评估', action: () => router.push('/ai-posture-assessment') },
// { key: 'plan', label: 'AI制定训练计划', action: () => handleQuickPlan() },
// { key: 'analyze', label: '分析运动记录', action: () => handleAnalyzeRecords() },
{ key: 'weight', label: '#记体重', action: () => insertWeightInputCard() },
{ key: 'diet', label: '#记饮食', action: () => insertDietInputCard() },
{ key: 'dietPlan', label: '#饮食方案', action: () => insertDietPlanCard() },
], [router, planDraft, checkin]);
const scrollToEnd = useCallback(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd({ animated: true });
});
}, []);
const handleScroll = useCallback((e: any) => {
try {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent || {};
if (!contentOffset || !contentSize || !layoutMeasurement) return;
const paddingToBottom = 60;
const distanceFromBottom = (contentSize.height || 0) - ((layoutMeasurement.height || 0) + (contentOffset.y || 0));
setIsAtBottom(distanceFromBottom <= paddingToBottom);
} catch (error) {
console.warn('[AI_CHAT] Scroll handling error:', error);
}
}, []);
useEffect(() => {
// 初次进入或恢复时,保持最新消息可见
const timer = setTimeout(scrollToEnd, 100);
return () => clearTimeout(timer);
}, []);
// 初始化欢迎消息
const initializeWelcomeMessage = useCallback(() => {
const welcomeMessage: ChatMessage = {
id: 'm_welcome',
role: 'assistant',
content: generateWelcomeMessage(),
};
setMessages([welcomeMessage]);
}, [generateWelcomeMessage]);
// 启动页面时尝试恢复当次应用会话缓存
useEffect(() => {
let isMounted = true;
(async () => {
try {
const cached = await loadAiCoachSessionCache();
if (isMounted && cached && Array.isArray(cached.messages) && cached.messages.length > 0) {
setConversationId(cached.conversationId);
// 确保缓存的消息符合新的 ChatMessage 结构
const validMessages = cached.messages
.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content)
.map(msg => ({
...msg,
// 确保 attachments 字段存在,对于旧的缓存消息可能没有这个字段
attachments: (msg as any).attachments || undefined,
})) as ChatMessage[];
setMessages(validMessages);
setTimeout(() => {
if (isMounted) scrollToEnd();
}, 100);
} else {
// 没有缓存时显示欢迎消息
if (isMounted) {
initializeWelcomeMessage();
}
}
} catch (error) {
console.warn('[AI_CHAT] Failed to load session cache:', error);
// 出错时也显示欢迎消息
if (isMounted) {
initializeWelcomeMessage();
}
}
})();
return () => {
isMounted = false;
};
}, [initializeWelcomeMessage]);
// 会话变动时,轻量防抖写入缓存(在本次应用生命周期内可跨页面恢复;下次冷启动会被根布局清空)
const saveCacheTimerRef = useRef<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 - insets.bottom);
setKeyboardOffset(height);
}
} catch (error) {
console.warn('[KEYBOARD] iOS keyboard event error:', error);
setKeyboardOffset(0);
}
});
hideSub = Keyboard.addListener('keyboardWillHide', () => {
setKeyboardOffset(0);
});
} else {
showSub = Keyboard.addListener('keyboardDidShow', (e: any) => {
try {
if (e?.endCoordinates?.height) {
setKeyboardOffset(Math.max(0, e.endCoordinates.height));
}
} catch (error) {
console.warn('[KEYBOARD] Android keyboard event error:', error);
setKeyboardOffset(0);
}
});
hideSub = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardOffset(0);
});
}
return () => {
try {
showSub?.remove?.();
} catch (error) {
console.warn('[KEYBOARD] Error removing keyboard show listener:', error);
}
try {
hideSub?.remove?.();
} catch (error) {
console.warn('[KEYBOARD] Error removing keyboard hide listener:', error);
}
};
}, [insets.bottom]);
const streamAbortRef = useRef<{ abort: () => void } | null>(null);
// 组件卸载时清理流式请求和定时器
useEffect(() => {
return () => {
try {
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
} catch (error) {
console.warn('[AI_CHAT] Error aborting stream on unmount:', error);
}
if (saveCacheTimerRef.current) {
clearTimeout(saveCacheTimerRef.current);
saveCacheTimerRef.current = null;
}
};
}, []);
function ensureConversationId(): string {
if (conversationId && conversationId.trim()) return conversationId;
// 延迟生成会话ID只在请求成功开始时才生成
return '';
}
function createNewConversationId(): string {
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
setConversationId(cid);
try { console.log('[AI_CHAT][ui] create new conversationId', cid); } catch { }
return cid;
}
function convertToServerMessages(history: ChatMessage[]): { role: 'user' | 'assistant' | 'system'; content: string }[] {
// 仅映射 user/assistant 消息;系统提示由后端自动注入
return history
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
}
async function openHistory() {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
setHistoryVisible(true);
await refreshHistory(1);
}
async function refreshHistory(page = 1) {
try {
setHistoryLoading(true);
const resp = await listConversations(page, 20);
setHistoryPage(resp.page);
// setHistoryTotal(resp.total); // 临时注释,因为当前未使用
setHistoryItems(resp.items || []);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '获取会话列表失败');
} finally {
setHistoryLoading(false);
}
}
function startNewConversation() {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
// 清理当前会话状态
setConversationId(undefined);
setSelectedImages([]);
setDietTextInputs({});
setWeightInputs({});
setSelectedChoices({});
setPendingChoiceConfirmation({});
// 创建新的欢迎消息
initializeWelcomeMessage();
// 清理本地缓存
saveAiCoachSessionCache({
conversationId: undefined,
messages: [],
updatedAt: Date.now()
}).catch((error) => {
console.warn('[AI_CHAT] Failed to clear session cache:', error);
});
setTimeout(scrollToEnd, 100);
}
async function handleSelectConversation(id: string) {
try {
if (isStreaming || isSending) {
cancelCurrentRequest();
}
const detail = await getConversationDetail(id);
if (!detail || !(detail as any).messages) {
Alert.alert('提示', '会话不存在或已删除');
return;
}
const mapped: ChatMessage[] = (detail.messages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m, idx) => ({
id: `${m.role}_${idx}_${Date.now()}`,
role: m.role as Role,
content: m.content || '',
// 对于历史消息,暂时不包含附件信息,因为服务器端可能还没有返回附件数据
// 如果将来服务器支持返回附件信息,可以在这里添加映射逻辑
attachments: undefined,
}));
setConversationId(detail.conversationId);
setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]);
setHistoryVisible(false);
setTimeout(scrollToEnd, 0);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '加载会话失败');
}
}
function confirmDeleteConversation(id: string) {
Alert.alert('删除会话', '删除后将无法恢复,确定要删除该会话吗?', [
{ text: '取消', style: 'cancel' },
{
text: '删除', style: 'destructive', onPress: async () => {
try {
await deleteConversation(id);
if (conversationId === id) {
setConversationId(undefined);
setMessages([{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]);
}
await refreshHistory(historyPage);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '删除失败');
}
}
}
]);
}
async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) {
// 发送确认选择的特殊请求
const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId();
const body = {
conversationId: cid || undefined,
messages: [...historyForServer, { role: 'user' as const, content: text }],
selectedChoiceId,
confirmationData,
stream: false, // 确认阶段使用非流式
};
await sendRequestInternal(body, text);
}
async function sendStream(text: string, imageUrls: string[] = []) {
const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId(); // 可能返回空字符串
const body = {
conversationId: cid || undefined, // 如果没有现有会话ID传undefined让服务端生成
messages: [...historyForServer, { role: 'user' as const, content: text }],
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
stream: true,
};
await sendRequestInternal(body, text, imageUrls);
}
function cancelCurrentRequest() {
try {
console.log('[AI_CHAT][ui] User cancelled request');
// 增加请求序列号,使后续响应失效
requestSequenceRef.current += 1;
// 清除活跃请求ID
activeRequestIdRef.current = null;
// 中断网络请求
if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
// 清理状态
setIsSending(false);
setIsStreaming(false);
// 移除正在生成中的助手消息
if (pendingAssistantIdRef.current) {
setMessages((prev) => prev.filter(msg => msg.id !== pendingAssistantIdRef.current));
pendingAssistantIdRef.current = null;
}
// 触觉反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
console.log('[AI_CHAT][ui] Request cancelled, sequence incremented to:', requestSequenceRef.current);
} catch (error) {
console.warn('[AI_CHAT] Error cancelling request:', error);
}
}
async function sendRequestInternal(body: any, text: string, imageUrls: string[] = []) {
const tokenExists = !!getAuthToken();
// 生成当前请求的序列号和ID
const currentSequence = ++requestSequenceRef.current;
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
activeRequestIdRef.current = requestId;
try {
console.log('[AI_CHAT][ui] send start', {
requestId,
sequence: currentSequence,
tokenExists,
conversationId,
textPreview: text.slice(0, 50),
imageUrls,
stream: body.stream
});
} catch { }
// 验证请求是否仍然有效的函数
const isRequestValid = () => {
const isValid = activeRequestIdRef.current === requestId && requestSequenceRef.current === currentSequence;
if (!isValid) {
console.log('[AI_CHAT][ui] Request invalidated', {
requestId,
currentActive: activeRequestIdRef.current,
sequence: currentSequence,
currentSequence: requestSequenceRef.current
});
}
return isValid;
};
// 终止上一次未完成的流
if (streamAbortRef.current) {
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
try { streamAbortRef.current.abort(); } catch { }
streamAbortRef.current = null;
}
// 在 UI 中先放置占位回答,随后持续增量更新
const assistantId = `a_${Date.now()}`;
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// 构建包含附件的用户消息
const attachments = imageUrls.map((url, index) => ({
id: `img_${Date.now()}_${index}`,
type: 'image' as AttachmentType,
url,
}));
const userMsg: ChatMessage = {
id: userMsgId,
role: 'user',
content: text,
attachments: attachments.length > 0 ? attachments : undefined,
};
shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId;
setIsSending(true);
setIsStreaming(true);
// 如果是非流式请求直接调用API并处理响应
if (!body.stream) {
try {
const response = await api.post<{ conversationId?: string; data?: AiResponseData; text?: string }>('/api/ai-coach/chat', body);
setIsSending(false);
setIsStreaming(false);
// 处理响应
if (response.data) {
// 结构化响应(可能包含选择选项)
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
content: response.data.content,
choices: response.data.choices,
interactionType: response.data.interactionType,
pendingData: response.data.pendingData,
context: response.data.context,
};
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? assistantMsg : msg
));
} else if (response.text) {
// 简单文本响应
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: response.text || '(空响应)' } : msg
));
}
if (response.conversationId && !conversationId) {
setConversationId(response.conversationId);
}
pendingAssistantIdRef.current = null;
return;
} catch (e: any) {
setIsSending(false);
setIsStreaming(false);
pendingAssistantIdRef.current = null;
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg
));
throw e;
}
}
let receivedAnyChunk = false;
const updateAssistantContent = (delta: string) => {
setMessages((prev) => {
const next = prev.map((msg) => {
if (msg.id === assistantId) {
return { ...msg, content: msg.content + delta };
}
return msg;
});
return next;
});
};
const onChunk = (chunk: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring chunk from invalidated request');
return;
}
receivedAnyChunk = true;
const atBottomNow = isAtBottom;
// 尝试解析是否为JSON结构化数据可能是确认选项
try {
const parsed = JSON.parse(chunk);
if (parsed && parsed.data && parsed.data.choices) {
// 再次验证请求有效性
if (!isRequestValid()) return;
// 处理结构化响应(包含选择选项)
const assistantMsg: ChatMessage = {
id: assistantId,
role: 'assistant',
content: parsed.data.content,
choices: parsed.data.choices,
interactionType: parsed.data.interactionType,
pendingData: parsed.data.pendingData,
context: parsed.data.context,
};
setMessages((prev) => prev.map((msg) =>
msg.id === assistantId ? assistantMsg : msg
));
// 处理conversationId - 只在请求有效时设置
if (parsed.conversationId && !conversationId) {
setConversationId(parsed.conversationId);
console.log('[AI_CHAT][ui] Set conversationId from structured response:', parsed.conversationId);
}
// 结束流式状态
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
return;
}
} catch {
// 不是JSON继续作为普通文本处理
}
updateAssistantContent(chunk);
if (atBottomNow) {
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
shouldAutoScrollRef.current = true;
setTimeout(scrollToEnd, 0);
}
try { console.log('[AI_CHAT][api] chunk', { requestId, length: chunk.length, preview: chunk.slice(0, 40) }); } catch { }
};
const onEnd = (cidFromHeader?: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring end from invalidated request');
return;
}
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
if (cidFromHeader && !conversationId) {
setConversationId(cidFromHeader);
console.log('[AI_CHAT][ui] Set conversationId from header:', cidFromHeader);
}
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
try { console.log('[AI_CHAT][api] end', { requestId, cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
};
const onError = async (err: any) => {
try { console.warn('[AI_CHAT][api] error', { requestId, error: err }); } catch { }
// 如果是用户主动取消,不需要处理错误
if (err?.name === 'AbortError' || err?.message?.includes('abort')) {
console.log('[AI_CHAT][api] Request was aborted by user');
return;
}
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring error from invalidated request');
return;
}
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式
try {
// 再次验证请求有效性
if (!isRequestValid()) return;
const bodyNoStream = { ...body, stream: false };
try { console.log('[AI_CHAT][fallback] try non-stream', { requestId }); } catch { }
const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
// 最终验证请求有效性
if (!isRequestValid()) return;
const textCombined = (resp as any)?.text ?? '';
if ((resp as any)?.conversationId && !conversationId) {
setConversationId((resp as any).conversationId);
}
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
} catch (e2: any) {
// 如果也是取消操作,同样不处理
if (e2?.name === 'AbortError' || e2?.message?.includes('abort')) {
return;
}
// 验证请求有效性
if (!isRequestValid()) return;
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg));
try { console.warn('[AI_CHAT][fallback] non-stream error', { requestId, error: e2 }); } catch { }
}
};
try {
const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 });
streamAbortRef.current = controller;
} catch (e) {
onError(e);
}
}
async function send(text: string) {
if (!isLoggedIn) {
pushIfAuthedElseLogin('/auth/login');
return;
}
if (isSending) return;
const trimmed = text.trim();
if (!trimmed && selectedImages.length === 0) return;
// 检查是否有图片还在上传中
const uploadingImages = selectedImages.filter(img => !img.uploadedUrl && !img.error);
if (uploadingImages.length > 0) {
Alert.alert('请稍等', '图片正在上传中,请等待上传完成后再发送');
return;
}
// 检查是否有上传失败的图片
const failedImages = selectedImages.filter(img => img.error);
if (failedImages.length > 0) {
Alert.alert('上传失败', '部分图片上传失败,请重新选择或删除失败的图片');
return;
}
try {
const imageUrls = selectedImages.map(img => img.uploadedUrl).filter(Boolean) as string[];
setInput('');
setSelectedImages([]);
await sendStream(trimmed, imageUrls);
} catch (e: any) {
Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试');
}
}
const uploadImage = useCallback(async (img: any) => {
if (!img?.localUri || !img?.id) {
console.warn('[AI_CHAT] Invalid image data for upload:', img);
return;
}
try {
const { url } = await upload(
{ uri: img.localUri, name: img.id, type: 'image/jpeg' },
{ prefix: 'images/chat' }
);
if (url) {
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, uploadedUrl: url, progress: 1, error: undefined } : it
));
} else {
throw new Error('上传返回空URL');
}
} catch (e: any) {
console.error('[AI_CHAT] Image upload failed:', e);
setSelectedImages((prev) => prev.map((it) =>
it.id === img.id ? { ...it, error: e?.message || '上传失败', progress: 0 } : it
));
}
}, [upload]);
const pickImages = useCallback(async () => {
try {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsMultipleSelection: true,
selectionLimit: 4,
quality: 0.9,
} as any);
if ((result as any).canceled) return;
const assets = (result as any).assets || [];
if (!Array.isArray(assets) || assets.length === 0) {
console.warn('[AI_CHAT] No valid assets returned from image picker');
return;
}
const next = assets
.filter(a => a && a.uri) // 过滤无效的资源
.map((a: any) => ({
id: `${a.assetId || a.fileName || Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
localUri: a.uri,
width: a.width,
height: a.height,
progress: 0,
}));
if (next.length === 0) {
Alert.alert('错误', '未选择有效的图片');
return;
}
setSelectedImages((prev) => {
const merged = [...prev, ...next];
return merged.slice(0, 4);
});
setTimeout(scrollToEnd, 100);
// 立即开始上传新选择的图片
for (const img of next) {
uploadImage(img);
}
} catch (e: any) {
console.error('[AI_CHAT] Image picker error:', e);
Alert.alert('错误', e?.message || '选择图片失败');
}
}, [scrollToEnd, uploadImage]);
const removeSelectedImage = useCallback((id: string) => {
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
}, []);
// 渲染单个附件
function renderAttachment(attachment: MessageAttachment, isUser: boolean) {
const { type, url, localUri, uploadProgress, uploadError, width, height, filename } = attachment;
if (type === 'image') {
const imageUri = url || localUri;
if (!imageUri) return null;
return (
<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={Colors.light.accentGreenDark} />
<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') {
return (
<View style={{ gap: 12 }}>
<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}>
<Text style={[
styles.choiceLabel,
choice.recommended && styles.choiceLabelRecommended,
isSelected && styles.choiceLabelSelected,
isDisabled && styles.choiceLabelDisabled,
]}>
{choice.label}
</Text>
<View style={styles.choiceStatusContainer}>
{choice.recommended && !isSelected && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
</View>
)}
{isSelected && isPending && (
<ActivityIndicator size="small" color={Colors.light.accentGreenDark} />
)}
{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 {
// 上传图片
const { url } = await upload(
{ uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' },
{ prefix: 'images/diet' }
);
// 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
// 发送包含图片的饮食记录消息,图片通过 imageUrls 参数传递
const dietMsg = `#记饮食:请分析这张食物照片的营养成分和热量`;
await sendStream(dietMsg, [url]);
} catch (uploadError) {
console.error('[DIET] 图片上传失败:', uploadError);
Alert.alert('上传失败', '图片上传失败,请重试');
}
}
function handleBackToDietOptions(cardId: string) {
// 返回到饮食选择界面
const payload = `${CardType.DIET_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
setTimeout(scrollToEnd, 100);
}
async function handleSubmitDietText(text: string, cardId: string) {
const trimmedText = text.trim();
if (!trimmedText) {
Alert.alert('请输入饮食内容', '请描述您吃了什么食物和大概的分量');
return;
}
try {
// 移除饮食输入卡片
setMessages((prev) => prev.filter(msg => msg.id !== cardId));
// 清理输入状态
setDietTextInputs(prev => {
const { [cardId]: _, ...rest } = prev;
return rest;
});
// 发送饮食记录消息
const dietMsg = `记录了今日饮食:${trimmedText}`;
await sendStream(dietMsg);
} catch (e: any) {
console.error('[DIET] 提交饮食记录失败:', e);
Alert.alert('提交失败', e?.message || '提交失败,请重试');
}
}
async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) {
try {
console.log('[CHOICE] Selection:', { choiceId: choice.id, messageId: message.id });
// 检查是否已经选择过
if (selectedChoices[message.id] != null) {
console.log('[CHOICE] Already selected, ignoring');
return;
}
// 立即设置选中状态,防止重复点击
setSelectedChoices(prev => ({ ...prev, [message.id]: choice.id }));
setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: true }));
// 构建确认请求
const confirmationText = `我选择记录${choice.label}`;
try {
// 发送确认消息,包含选择的数据
await sendStreamWithConfirmation(confirmationText, choice.id, {
selectedOption: choice.value,
imageUrl: message.pendingData?.imageUrl
});
// 发送成功后清除pending状态但保持选中状态
setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: false }));
// 成功反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} catch (e: any) {
console.error('[CHOICE] Selection failed:', e);
// 发送失败时,重置状态允许重新选择
setSelectedChoices(prev => {
const { [message.id]: _, ...rest } = prev;
return rest;
});
setPendingChoiceConfirmation(prev => {
const { [message.id]: _, ...rest } = prev;
return rest;
});
Alert.alert('选择失败', e?.message || '选择失败,请重试');
// 失败反馈
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
} catch (e: any) {
console.error('[CHOICE] Selection failed:', e);
Alert.alert('选择失败', e?.message || '选择失败,请重试');
}
}
return (
<View style={styles.screen}>
<LinearGradient
colors={[theme.backgroundGradientStart, theme.backgroundGradientEnd]}
style={styles.gradientBackground}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
<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 }]}>{botName}</Text>
{/* 使用次数显示 */}
<TouchableOpacity
style={styles.usageCountContainer}
onPress={() => {
// 临时测试切换VIP状态
const dispatch = useAppDispatch();
dispatch(updateProfile({
isVip: !userProfile?.isVip,
freeUsageCount: userProfile?.isVip ? 3 : 5,
maxUsageCount: userProfile?.isVip ? 5 : 10
}));
}}
>
<Image
source={require('@/assets/images/icons/iconFlash.png')}
style={styles.usageIcon}
/>
<Text style={[styles.usageText, { color: theme.text }]}>
{userProfile?.isVip ? '不限' : `${userProfile?.freeUsageCount || 0}/${userProfile?.maxUsageCount || 0}`}
</Text>
</TouchableOpacity>
</View>
<View style={styles.headerActions}>
<TouchableOpacity
accessibilityRole="button"
onPress={startNewConversation}
style={[styles.headerActionButton, { backgroundColor: `${Colors.light.accentGreen}33` }]} // 20% opacity
>
<Ionicons name="add-outline" size={18} color={theme.onPrimary} />
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={openHistory}
style={[styles.headerActionButton, { backgroundColor: `${Colors.light.accentGreen}33` }]} // 20% opacity
>
<Ionicons name="time-outline" size={18} color={theme.onPrimary} />
</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: `${Colors.light.accentGreen}59`, backgroundColor: `${Colors.light.accentGreen}1F` }]} 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>
)}
{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, { borderColor: `${Colors.light.accentGreen}59`, backgroundColor: `${Colors.light.accentGreen}14` }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: `${Colors.light.accentGreen}28` }]}
>
<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)}
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) ? '#FF4444' : 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>
)}
<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>
<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,
},
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',
},
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: `${Colors.light.accentGreen}99` // 60% opacity
},
dietOptionsContainer: {
gap: 8,
},
dietOptionBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: `${Colors.light.accentGreen}4D`, // 30% opacity
},
dietOptionIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: `${Colors.light.accentGreen}33`, // 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: `${Colors.light.accentGreen}99`, // 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(0,0,0,0.06)'
},
imageThumb: {
width: '100%',
height: '100%'
},
imageRemoveBtn: {
position: 'absolute',
right: 4,
top: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.45)'
},
imageProgressOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageProgressText: {
color: '#fff',
fontWeight: '700'
},
imageErrorOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageRetryBtn: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)'
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
borderWidth: 1,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)'
},
mediaBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
input: {
flex: 1,
fontSize: 15,
maxHeight: 120,
minHeight: 40,
paddingHorizontal: 8,
paddingVertical: 6,
textAlignVertical: 'center',
},
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
scrollToBottomFab: {
position: 'absolute',
right: 16,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
padding: 16,
justifyContent: 'flex-end',
},
modalSheet: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 12,
paddingTop: 10,
paddingBottom: 12,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 8,
},
modalTitle: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
modalRefreshBtn: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.06)'
},
historyRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 8,
borderRadius: 10,
},
historyTitle: {
fontSize: 15,
color: '#192126',
fontWeight: '600',
},
historyMeta: {
marginTop: 2,
fontSize: 12,
color: '#687076',
},
historyDeleteBtn: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,68,68,0.08)'
},
modalFooter: {
paddingTop: 8,
alignItems: 'flex-end',
},
modalCloseBtn: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
backgroundColor: 'rgba(0,0,0,0.06)'
},
previewBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.85)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
previewBox: {
width: '100%',
height: '80%',
borderRadius: 12,
overflow: 'hidden',
},
previewImage: {
width: '100%',
height: '100%',
},
// 附件相关样式
attachmentsContainer: {
marginTop: 8,
gap: 6,
},
attachmentContainer: {
marginBottom: 4,
},
imageAttachment: {
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
},
attachmentImage: {
width: '100%',
minHeight: 120,
maxHeight: 200,
borderRadius: 12,
},
attachmentProgressOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentProgressText: {
color: '#fff',
fontSize: 14,
fontWeight: '600',
},
attachmentErrorOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.4)',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
attachmentErrorText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
videoAttachment: {
borderRadius: 12,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.1)',
},
videoPlaceholder: {
height: 120,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)',
},
videoFilename: {
color: '#fff',
fontSize: 12,
marginTop: 4,
textAlign: 'center',
},
fileAttachment: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
backgroundColor: 'rgba(0,0,0,0.06)',
borderRadius: 8,
gap: 8,
},
fileFilename: {
flex: 1,
fontSize: 14,
color: '#192126',
},
// 选择选项相关样式
choicesContainer: {
gap: 8,
},
choiceButton: {
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: `${Colors.light.accentGreen}4D`, // 30% opacity
borderRadius: 12,
padding: 12,
},
choiceButtonRecommended: {
borderColor: `${Colors.light.accentGreen}99`, // 60% opacity
backgroundColor: `${Colors.light.accentGreen}1A`, // 10% opacity
},
choiceButtonSelected: {
borderColor: Colors.light.accentGreenDark,
backgroundColor: `${Colors.light.accentGreen}33`, // 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',
},
choiceLabel: {
fontSize: 15,
fontWeight: '600',
color: '#192126',
flex: 1,
},
choiceLabelRecommended: {
color: Colors.light.accentGreenDark,
},
choiceLabelSelected: {
color: Colors.light.accentGreenDark,
fontWeight: '700',
},
choiceLabelDisabled: {
color: '#687076',
},
choiceStatusContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
recommendedBadge: {
backgroundColor: `${Colors.light.accentGreen}CC`, // 80% opacity
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 2,
},
recommendedText: {
fontSize: 12,
fontWeight: '700',
color: Colors.light.accentGreenDark,
},
selectedBadge: {
backgroundColor: Colors.light.accentGreenDark,
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',
},
gradientBackground: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
// 饮食方案卡片样式
dietPlanContainer: {
backgroundColor: 'rgba(255,255,255,0.95)',
borderRadius: 16,
padding: 16,
gap: 16,
borderWidth: 1,
borderColor: `${Colors.light.accentGreen}33`, // 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: Colors.light.accentGreenDark,
},
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: Colors.light.accentGreenDark,
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(0,0,0,0.06)',
},
usageIcon: {
width: 16,
height: 16,
},
usageText: {
fontSize: 12,
fontWeight: '600',
color: '#687076',
},
});
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;