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