Files
digital-pilates/app/(tabs)/coach.tsx
richarjiang 849447c5da feat: 引入路由常量并更新相关页面导航
- 新增 ROUTES 常量文件,集中管理应用路由
- 更新多个页面的导航逻辑,使用 ROUTES 常量替代硬编码路径
- 修改教练页面和今日训练页面的路由,提升代码可维护性
- 优化标签页和登录页面的导航,确保一致性和易用性
2025-08-18 10:05:22 +08:00

1710 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Alert,
FlatList,
Image,
Keyboard,
Modal,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import Markdown from 'react-native-markdown-display';
import Animated, { FadeInDown, FadeInUp, Layout } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Colors } from '@/constants/Colors';
import { getTabBarBottomPadding } from '@/constants/TabBar';
import { useAppSelector } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useCosUpload } from '@/hooks/useCosUpload';
import { deleteConversation, getConversationDetail, listConversations, type AiConversationListItem } from '@/services/aiCoach';
import { loadAiCoachSessionCache, saveAiCoachSessionCache } from '@/services/aiCoachSession';
import { api, getAuthToken, postTextStream } from '@/services/api';
import dayjs from 'dayjs';
import { ActionSheet } from '../../components/ui/ActionSheet';
type Role = 'user' | 'assistant';
type ChatMessage = {
id: string;
role: Role;
content: string;
};
// 卡片类型常量定义
const CardType = {
WEIGHT_INPUT: '__WEIGHT_INPUT_CARD__',
DIET_INPUT: '__DIET_INPUT_CARD__',
DIET_TEXT_INPUT: '__DIET_TEXT_INPUT__',
} as const;
type CardType = typeof CardType[keyof typeof CardType];
const COACH_AVATAR = require('@/assets/images/logo.png');
export default function CoachScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ name?: string }>();
const insets = useSafeAreaInsets();
const { isLoggedIn, pushIfAuthedElseLogin } = useAuthGuard();
// 为了让页面更贴近品牌主题与更亮的观感,这里使用亮色系配色
const theme = Colors.light;
const botName = (params?.name || 'Bot').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, setHistoryTotal] = useState(0);
const [historyItems, setHistoryItems] = useState<AiConversationListItem[]>([]);
const listRef = useRef<FlatList<ChatMessage>>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const didInitialScrollRef = useRef(false);
const [composerHeight, setComposerHeight] = useState<number>(80);
const shouldAutoScrollRef = useRef(false);
const [keyboardOffset, setKeyboardOffset] = useState(0);
const [headerHeight, setHeaderHeight] = useState<number>(60);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
id: string;
localUri: string;
width?: number;
height?: number;
progress: number;
uploadedKey?: string;
uploadedUrl?: string;
error?: string;
}>>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [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 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 || 'Health Bot').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}作为你的Health Bot我很高兴能陪伴你的健康之旅。无论是饮食营养、健身锻炼还是生活管理我都能为你提供专业建议。`
]
},
{
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}午间是调整状态的好时机。作为你的Health Bot我建议关注饮食均衡和适度放松`
]
},
{
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}晚间时光属于你自己也是关爱身体的珍贵时间。作为你的Health Bot我想陪你聊聊如何更好地管理健康生活。`
]
},
{
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}作为你的Health Bot我想更好地了解你的健康需求。告诉我你希望在营养摄入、身材管理、健身锻炼或生活管理方面实现什么目标吧`
}
];
// 检查特殊情况
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() },
], [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);
setMessages(cached.messages.filter(msg => msg && typeof msg === 'object' && msg.role && msg.content) as ChatMessage[]);
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 {
streamAbortRef.current?.abort();
} 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;
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
setConversationId(cid);
try { console.log('[AI_CHAT][ui] create temp conversationId', cid); } catch { }
return cid;
}
function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
// 仅映射 user/assistant 消息;系统提示由后端自动注入
return history
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
}
async function openHistory() {
if (isStreaming) {
try { streamAbortRef.current?.abort(); } catch { }
}
setHistoryVisible(true);
await refreshHistory(1);
}
async function refreshHistory(page = 1) {
try {
setHistoryLoading(true);
const resp = await listConversations(page, 20);
setHistoryPage(resp.page);
setHistoryTotal(resp.total);
setHistoryItems(resp.items || []);
} catch (e) {
Alert.alert('错误', (e as any)?.message || '获取会话列表失败');
} finally {
setHistoryLoading(false);
}
}
function startNewConversation() {
if (isStreaming) {
try { streamAbortRef.current?.abort(); } catch { }
}
// 清理当前会话状态
setConversationId(undefined);
setSelectedImages([]);
setDietTextInputs({});
setWeightInputs({});
// 创建新的欢迎消息
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) {
try { streamAbortRef.current?.abort(); } catch { }
}
const detail = await getConversationDetail(id);
if (!detail || !(detail as any).messages) {
Alert.alert('提示', '会话不存在或已删除');
return;
}
const mapped: ChatMessage[] = (detail.messages || [])
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m, idx) => ({ id: `${m.role}_${idx}_${Date.now()}`, role: m.role as Role, content: m.content || '' }));
setConversationId(detail.conversationId);
setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: 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 sendStream(text: string) {
const tokenExists = !!getAuthToken();
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { }
// 终止上一次未完成的流
if (streamAbortRef.current) {
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
try { streamAbortRef.current.abort(); } catch { }
streamAbortRef.current = null;
}
// 发送 body尽量提供历史消息后端会优先使用 conversationId 关联上下文
const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId();
const body = {
conversationId: cid,
messages: [...historyForServer, { role: 'user' as const, content: text }],
stream: true,
};
// 在 UI 中先放置占位回答,随后持续增量更新
const assistantId = `a_${Date.now()}`;
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const userMsg: ChatMessage = { id: userMsgId, role: 'user', content: text };
shouldAutoScrollRef.current = isAtBottom;
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
pendingAssistantIdRef.current = assistantId;
setIsSending(true);
setIsStreaming(true);
let receivedAnyChunk = false;
const updateAssistantContent = (delta: string) => {
setMessages((prev) => {
const next = prev.map((msg) => {
if (msg.id === assistantId) {
return { ...msg, content: msg.content + delta };
}
return msg;
});
return next;
});
};
const onChunk = (chunk: string) => {
receivedAnyChunk = true;
const atBottomNow = isAtBottom;
updateAssistantContent(chunk);
if (atBottomNow) {
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
shouldAutoScrollRef.current = true;
setTimeout(scrollToEnd, 0);
}
try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { }
};
const onEnd = (cidFromHeader?: string) => {
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
if (cidFromHeader && !conversationId) setConversationId(cidFromHeader);
pendingAssistantIdRef.current = null;
try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { }
};
const onError = async (err: any) => {
try { console.warn('[AI_CHAT][api] error', err); } catch { }
setIsSending(false);
setIsStreaming(false);
streamAbortRef.current = null;
pendingAssistantIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式
try {
const bodyNoStream = { ...body, stream: false };
try { console.log('[AI_CHAT][fallback] try non-stream'); } catch { }
const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
const textCombined = (resp as any)?.text ?? '';
if ((resp as any)?.conversationId && !conversationId) {
setConversationId((resp as any).conversationId);
}
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
} catch (e2: any) {
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg));
try { console.warn('[AI_CHAT][fallback] non-stream error', e2); } catch { }
}
};
try {
const controller = postTextStream('/api/ai-coach/chat', body, { onChunk, onEnd, onError }, { timeoutMs: 120000 });
streamAbortRef.current = controller;
} catch (e) {
onError(e);
}
}
async function send(text: string) {
if (!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 urls = selectedImages.map(img => img.uploadedUrl).filter(Boolean);
const mdImages = urls.map((u) => `![image](${u})`).join('\n\n');
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
setInput('');
setSelectedImages([]);
await sendStream(composed);
} catch (e: any) {
Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试');
}
}
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';
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: isUser ? theme.primary : 'rgba(187,242,70,0.16)',
borderTopLeftRadius: isUser ? 16 : 6,
borderTopRightRadius: isUser ? 6 : 16,
maxWidth: isUser ? '82%' : '90%',
},
]}
>
{renderBubbleContent(item)}
</View>
</Animated.View>
);
}
function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) {
return <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
}
if (item.content?.startsWith(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 }}>Health Bot会根据您的饮食情况给出专业的营养建议</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 }}>Health Bot给出更精准的营养分析和建议</Text>
</View>
);
}
return (
<Markdown style={markdownStyles} mergeStyle>
{item.content || ''}
</Markdown>
);
}
function insertWeightInputCard() {
const id = `wcard_${Date.now()}`;
const preset = userProfile?.weight ? Number(userProfile.weight) : undefined;
const payload = `${CardType.WEIGHT_INPUT}\n${preset ?? ''}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
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 send(textMsg);
} catch (e: any) {
console.error('[AI_CHAT] Error handling weight submission:', e);
Alert.alert('保存失败', e?.message || '请稍后重试');
}
}
function insertDietInputCard() {
const id = `dcard_${Date.now()}`;
const payload = `${CardType.DIET_INPUT}\n${id}`;
setMessages((prev) => [...prev, { id, role: 'assistant', content: payload }]);
setTimeout(scrollToEnd, 100);
}
function handleDietTextInput(cardId: string) {
// 替换当前的饮食选择卡片为文字输入卡片
const payload = `${CardType.DIET_TEXT_INPUT}\n${cardId}`;
setMessages((prev) => prev.map(msg =>
msg.id === cardId
? { ...msg, content: payload }
: msg
));
setTimeout(scrollToEnd, 100);
}
function handleDietPhotoInput(cardId: string) {
console.log('[DIET] handleDietPhotoInput called with cardId:', cardId);
setCurrentCardId(cardId);
setShowDietPhotoActionSheet(true);
}
async function handleCameraPhoto() {
try {
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄食物照片');
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
aspect: [4, 3],
});
if (!result.canceled && result.assets?.[0]) {
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 拍照失败:', e);
Alert.alert('拍照失败', e?.message || '拍照失败,请重试');
}
}
async function handleLibraryPhoto() {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permissionResult.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择食物照片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
quality: 0.9,
aspect: [4, 3],
});
if (!result.canceled && result.assets?.[0]) {
await processSelectedImage(result.assets[0]);
}
} catch (e: any) {
console.error('[DIET] 选择照片失败:', e);
Alert.alert('选择照片失败', e?.message || '选择照片失败,请重试');
}
}
async function processSelectedImage(asset: ImagePicker.ImagePickerAsset) {
if (!currentCardId) return;
try {
// 上传图片
const { url } = await upload(
{ uri: asset.uri, name: `diet-${Date.now()}.jpg`, type: 'image/jpeg' },
{ prefix: 'images/diet' }
);
// 移除饮食选择卡片
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
// 发送包含图片的饮食记录消息
const dietMsg = `#记饮食:\n\n![食物照片](${url})`;
await send(dietMsg);
} 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 send(dietMsg);
} catch (e: any) {
console.error('[DIET] 提交饮食记录失败:', e);
Alert.alert('提交失败', e?.message || '提交失败,请重试');
}
}
return (
<View style={[styles.screen, { backgroundColor: theme.background }]}>
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
<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);
}}
>
<Text style={[styles.headerTitle, { color: theme.text }]}>{botName}</Text>
<View style={styles.headerActions}>
<TouchableOpacity
accessibilityRole="button"
onPress={startNewConversation}
style={[styles.headerActionButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}
>
<Ionicons name="add-outline" size={18} color={theme.onPrimary} />
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={openHistory}
style={[styles.headerActionButton, { backgroundColor: 'rgba(187,242,70,0.2)' }]}
>
<Ionicons name="time-outline" size={18} color={theme.onPrimary} />
</TouchableOpacity>
</View>
</View>
{/* 消息列表容器 - 设置固定高度避免输入框重叠 */}
<View style={{
flex: 1,
marginBottom: composerHeight + keyboardOffset
}}>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={renderItem}
onLayout={() => {
// 确保首屏布局后也尝试滚动
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
setTimeout(scrollToEnd, 100);
}
}}
contentContainerStyle={{
paddingHorizontal: 14,
paddingTop: 8,
paddingBottom: 16
}}
onContentSizeChange={() => {
// 首次内容变化强制滚底,其余仅在接近底部时滚动
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true;
setTimeout(scrollToEnd, 100);
return;
}
if (shouldAutoScrollRef.current) {
shouldAutoScrollRef.current = false;
setTimeout(scrollToEnd, 50);
}
}}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
/>
</View>
<BlurView
intensity={18}
tint={'light'}
style={[styles.composerWrap, { paddingBottom: getTabBarBottomPadding() + 10, bottom: keyboardOffset }]}
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
if (h && Math.abs(h - composerHeight) > 0.5) setComposerHeight(h);
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToAlignment="start"
style={styles.chipsRowScroll}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{chips.map((c) => (
<TouchableOpacity key={c.key} style={[styles.chip, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.12)' }]} onPress={c.action}>
<Text style={[styles.chipText, { color: '#192126' }]}>{c.label}</Text>
</TouchableOpacity>
))}
</ScrollView>
{!!selectedImages.length && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.imagesRow}
contentContainerStyle={{ paddingHorizontal: 6, gap: 8 }}
>
{selectedImages.map((img) => (
<View key={img.id} style={styles.imageThumbWrap}>
<TouchableOpacity accessibilityRole="imagebutton" onPress={() => setPreviewImageUri(img.uploadedUrl || img.localUri)}>
<Image source={{ uri: img.uploadedUrl || img.localUri }} style={styles.imageThumb} />
</TouchableOpacity>
{!!(img.progress > 0 && img.progress < 1) && (
<View style={styles.imageProgressOverlay}>
<Text style={styles.imageProgressText}>{Math.round((img.progress || 0) * 100)}%</Text>
</View>
)}
{img.error && (
<View style={styles.imageErrorOverlay}>
<TouchableOpacity
accessibilityRole="button"
onPress={() => uploadImage(img)}
style={styles.imageRetryBtn}
>
<Ionicons name="refresh" size={12} color="#fff" />
</TouchableOpacity>
</View>
)}
<TouchableOpacity accessibilityRole="button" onPress={() => removeSelectedImage(img.id)} style={styles.imageRemoveBtn}>
<Ionicons name="close" size={12} color="#fff" />
</TouchableOpacity>
</View>
))}
</ScrollView>
)}
<View style={[styles.inputRow, { borderColor: 'rgba(187,242,70,0.35)', backgroundColor: 'rgba(187,242,70,0.08)' }]}>
<TouchableOpacity
accessibilityRole="button"
onPress={pickImages}
style={[styles.mediaBtn, { backgroundColor: 'rgba(187,242,70,0.16)' }]}
>
<Ionicons name="image-outline" size={18} color={'#192126'} />
</TouchableOpacity>
<TextInput
placeholder="问我任何健康相关的问题,如营养、健身、生活管理等..."
placeholderTextColor={theme.textMuted}
style={[styles.input, { color: '#192126' }]}
value={input}
onChangeText={setInput}
multiline
onSubmitEditing={() => send(input)}
submitBehavior="blurAndSubmit"
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => send(input)}
style={[
styles.sendBtn,
{ backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 }
]}
>
{isSending ? (
<ActivityIndicator color={theme.onPrimary} />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
</TouchableOpacity>
</View>
</BlurView>
{!isAtBottom && (
<TouchableOpacity
accessibilityRole="button"
onPress={scrollToEnd}
style={[styles.scrollToBottomFab, {
bottom: 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 },
{ id: 'cancel', title: '取消', onPress: () => setShowDietPhotoActionSheet(false) }
]}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingBottom: 10,
},
headerTitle: {
fontSize: 20,
fontWeight: '800',
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
headerActionButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
historyButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
},
row: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: 8,
marginVertical: 6,
},
avatar: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
color: '#192126',
fontSize: 12,
fontWeight: '800',
},
bubble: {
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 16,
},
bubbleText: {
fontSize: 15,
lineHeight: 22,
},
weightRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
weightInput: {
flex: 1,
height: 36,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.08)',
borderRadius: 8,
paddingHorizontal: 10,
backgroundColor: 'rgba(255,255,255,0.9)',
color: '#192126',
},
weightUnit: {
color: '#192126',
fontWeight: '700',
},
weightSaveBtn: {
height: 36,
paddingHorizontal: 12,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(187,242,70,0.6)'
},
dietOptionsContainer: {
gap: 8,
},
dietOptionBtn: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 12,
backgroundColor: 'rgba(255,255,255,0.9)',
borderWidth: 1,
borderColor: 'rgba(187,242,70,0.3)',
},
dietOptionIconContainer: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(187,242,70,0.2)',
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: 'rgba(187,242,70,0.6)',
alignSelf: 'flex-end',
},
// markdown 基础样式承载容器的字体尺寸保持与气泡一致
composerWrap: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
paddingTop: 8,
paddingHorizontal: 10,
borderTopWidth: 0,
},
chipsRow: {
flexDirection: 'row',
gap: 8,
paddingHorizontal: 6,
marginBottom: 8,
},
chipsRowScroll: {
marginBottom: 8,
},
chip: {
paddingHorizontal: 10,
height: 34,
borderRadius: 18,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
},
chipText: {
fontSize: 13,
fontWeight: '600',
},
imagesRow: {
maxHeight: 92,
marginBottom: 8,
},
imageThumbWrap: {
width: 72,
height: 72,
borderRadius: 12,
overflow: 'hidden',
position: 'relative',
backgroundColor: 'rgba(0,0,0,0.06)'
},
imageThumb: {
width: '100%',
height: '100%'
},
imageRemoveBtn: {
position: 'absolute',
right: 4,
top: 4,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.45)'
},
imageProgressOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageProgressText: {
color: '#fff',
fontWeight: '700'
},
imageErrorOverlay: {
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
backgroundColor: 'rgba(255,0,0,0.35)',
alignItems: 'center',
justifyContent: 'center',
},
imageRetryBtn: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.6)'
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
borderWidth: 1,
borderRadius: 16,
backgroundColor: 'rgba(0,0,0,0.04)'
},
mediaBtn: {
width: 40,
height: 40,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
marginRight: 6,
},
input: {
flex: 1,
fontSize: 15,
maxHeight: 120,
minHeight: 40,
paddingHorizontal: 8,
paddingVertical: 6,
textAlignVertical: 'center',
},
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
},
scrollToBottomFab: {
position: 'absolute',
right: 16,
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 2,
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
padding: 16,
justifyContent: 'flex-end',
},
modalSheet: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: 12,
paddingTop: 10,
paddingBottom: 12,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 4,
paddingBottom: 8,
},
modalTitle: {
fontSize: 16,
fontWeight: '800',
color: '#192126',
},
modalRefreshBtn: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.06)'
},
historyRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 8,
borderRadius: 10,
},
historyTitle: {
fontSize: 15,
color: '#192126',
fontWeight: '600',
},
historyMeta: {
marginTop: 2,
fontSize: 12,
color: '#687076',
},
historyDeleteBtn: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,68,68,0.08)'
},
modalFooter: {
paddingTop: 8,
alignItems: 'flex-end',
},
modalCloseBtn: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 10,
backgroundColor: 'rgba(0,0,0,0.06)'
},
previewBackdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.85)',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
previewBox: {
width: '100%',
height: '80%',
borderRadius: 12,
overflow: 'hidden',
},
previewImage: {
width: '100%',
height: '100%',
},
});
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;