- 在心情日历中新增心情圆环展示,显示心情强度 - 修改心情记录编辑页面,支持使用图标替代表情 - 优化心情类型配置,使用图片资源替代原有表情 - 新增多种心情图标,丰富用户选择 - 更新相关样式,提升用户体验和界面美观性 - 更新文档,详细描述新功能和使用方法
2963 lines
95 KiB
TypeScript
2963 lines
95 KiB
TypeScript
import { Ionicons } from '@expo/vector-icons';
|
||
import { BlurView } from 'expo-blur';
|
||
import * as Haptics from 'expo-haptics';
|
||
import * as ImagePicker from 'expo-image-picker';
|
||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
FlatList,
|
||
Image,
|
||
Keyboard,
|
||
Modal,
|
||
Platform,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View
|
||
} from 'react-native';
|
||
import Markdown from 'react-native-markdown-display';
|
||
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
|
||
import { Colors } from '@/constants/Colors';
|
||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||
import { useAppSelector } from '@/hooks/redux';
|
||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||
import { 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 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 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 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}!🐋 小海豹${botName}来报到啦!今天想从哪个方面开始我们的健康之旅呢?营养、运动还是生活管理,我都可以帮你哦~`
|
||
]
|
||
},
|
||
{
|
||
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}!🐳 午间时光,小海豹${botName}建议你关注饮食均衡,也要适度放松一下呢~`
|
||
]
|
||
},
|
||
{
|
||
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}!🐳 晚间时光属于你自己,也是关爱身体的珍贵时间!作为你的小海豹${botName},我想陪你聊聊如何更好地管理健康生活呢~`
|
||
]
|
||
},
|
||
{
|
||
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}!🐋 作为你的小海豹${botName},我想更好地了解你的健康需求呢!告诉我你希望在营养摄入、身材管理、健身锻炼或生活管理方面实现什么目标吧~`
|
||
}
|
||
];
|
||
|
||
// 检查特殊情况
|
||
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() },
|
||
{
|
||
key: 'mood',
|
||
label: '#记心情',
|
||
action: () => {
|
||
if (Platform.OS === 'ios') {
|
||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||
}
|
||
router.push('/mood/calendar');
|
||
}
|
||
},
|
||
], [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[] = []) {
|
||
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') {
|
||
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={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 }]}>{botName}</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>
|
||
)}
|
||
|
||
<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,
|
||
},
|
||
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,
|
||
},
|
||
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: '#7a5af84d', // 紫色主题 30% opacity
|
||
borderRadius: 12,
|
||
padding: 12,
|
||
},
|
||
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',
|
||
},
|
||
choiceLabel: {
|
||
fontSize: 15,
|
||
fontWeight: '600',
|
||
color: '#192126',
|
||
flex: 1,
|
||
},
|
||
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;
|