feat: 更新教练页面和消息结构,支持附件功能
- 在教练页面中引入附件类型,支持图片、视频和文件的上传和展示 - 重构消息数据结构,确保消息包含附件信息 - 优化消息发送逻辑,支持发送包含图片的消息 - 更新界面样式,提升附件展示效果 - 删除不再使用的旧图标,替换为新的应用图标和启动画面
This commit is contained in:
@@ -5,9 +5,9 @@ import { Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { TAB_BAR_BOTTOM_OFFSET, TAB_BAR_HEIGHT } from '@/constants/TabBar';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
|
||||
export default function TabLayout() {
|
||||
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||
@@ -16,6 +16,7 @@ export default function TabLayout() {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
initialRouteName="coach"
|
||||
screenOptions={({ route }) => {
|
||||
const routeName = route.name;
|
||||
const isSelected = (routeName === 'index' && pathname === ROUTES.TAB_HOME) ||
|
||||
|
||||
@@ -35,10 +35,31 @@ import { ActionSheet } from '../../components/ui/ActionSheet';
|
||||
|
||||
type Role = 'user' | 'assistant';
|
||||
|
||||
// 附件类型枚举
|
||||
type AttachmentType = 'image' | 'video' | 'file';
|
||||
|
||||
// 附件数据结构
|
||||
type MessageAttachment = {
|
||||
id: string;
|
||||
type: AttachmentType;
|
||||
url: string;
|
||||
localUri?: string; // 本地URI,用于上传中的显示
|
||||
filename?: string;
|
||||
size?: number;
|
||||
duration?: number; // 视频时长(秒)
|
||||
thumbnail?: string; // 视频缩略图
|
||||
width?: number;
|
||||
height?: number;
|
||||
uploadProgress?: number; // 上传进度 0-1
|
||||
uploadError?: string; // 上传错误信息
|
||||
};
|
||||
|
||||
// 重构后的消息数据结构
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: Role;
|
||||
content: string;
|
||||
content: string; // 文本内容
|
||||
attachments?: MessageAttachment[]; // 附件列表
|
||||
};
|
||||
|
||||
// 卡片类型常量定义
|
||||
@@ -256,7 +277,15 @@ export default function CoachScreen() {
|
||||
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[]);
|
||||
// 确保缓存的消息符合新的 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);
|
||||
@@ -463,7 +492,14 @@ export default function CoachScreen() {
|
||||
}
|
||||
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 || '' }));
|
||||
.map((m, idx) => ({
|
||||
id: `${m.role}_${idx}_${Date.now()}`,
|
||||
role: m.role as Role,
|
||||
content: m.content || '',
|
||||
// 对于历史消息,暂时不包含附件信息,因为服务器端可能还没有返回附件数据
|
||||
// 如果将来服务器支持返回附件信息,可以在这里添加映射逻辑
|
||||
attachments: undefined,
|
||||
}));
|
||||
setConversationId(detail.conversationId);
|
||||
setMessages(mapped.length ? mapped : [{ id: 'm_welcome', role: 'assistant', content: generateWelcomeMessage() }]);
|
||||
setHistoryVisible(false);
|
||||
@@ -493,9 +529,9 @@ export default function CoachScreen() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function sendStream(text: string) {
|
||||
async function sendStream(text: string, imageUrls: string[] = []) {
|
||||
const tokenExists = !!getAuthToken();
|
||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50) }); } catch { }
|
||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls }); } catch { }
|
||||
|
||||
// 终止上一次未完成的流
|
||||
if (streamAbortRef.current) {
|
||||
@@ -510,6 +546,7 @@ export default function CoachScreen() {
|
||||
const body = {
|
||||
conversationId: cid,
|
||||
messages: [...historyForServer, { role: 'user' as const, content: text }],
|
||||
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
@@ -517,7 +554,19 @@ export default function CoachScreen() {
|
||||
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 };
|
||||
// 构建包含附件的用户消息
|
||||
const attachments = imageUrls.map((url, index) => ({
|
||||
id: `img_${Date.now()}_${index}`,
|
||||
type: 'image' as AttachmentType,
|
||||
url,
|
||||
}));
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: userMsgId,
|
||||
role: 'user',
|
||||
content: text,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
shouldAutoScrollRef.current = isAtBottom;
|
||||
setMessages((m) => [...m, userMsg, { id: assistantId, role: 'assistant', content: '' }]);
|
||||
pendingAssistantIdRef.current = assistantId;
|
||||
@@ -614,12 +663,10 @@ export default function CoachScreen() {
|
||||
}
|
||||
|
||||
try {
|
||||
const urls = selectedImages.map(img => img.uploadedUrl).filter(Boolean);
|
||||
const mdImages = urls.map((u) => ``).join('\n\n');
|
||||
const composed = [trimmed, mdImages].filter(Boolean).join('\n\n');
|
||||
const imageUrls = selectedImages.map(img => img.uploadedUrl).filter(Boolean) as string[];
|
||||
setInput('');
|
||||
setSelectedImages([]);
|
||||
await sendStream(composed);
|
||||
await sendStream(trimmed, imageUrls);
|
||||
} catch (e: any) {
|
||||
Alert.alert('发送失败', e?.message || '消息发送失败,请稍后重试');
|
||||
}
|
||||
@@ -706,6 +753,88 @@ export default function CoachScreen() {
|
||||
setSelectedImages((prev) => prev.filter((it) => it.id !== id));
|
||||
}, []);
|
||||
|
||||
// 渲染单个附件
|
||||
function renderAttachment(attachment: MessageAttachment, isUser: boolean) {
|
||||
const { type, url, localUri, uploadProgress, uploadError, width, height, filename } = attachment;
|
||||
|
||||
if (type === 'image') {
|
||||
const imageUri = url || localUri;
|
||||
if (!imageUri) return null;
|
||||
|
||||
return (
|
||||
<View key={attachment.id} style={styles.attachmentContainer}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="imagebutton"
|
||||
onPress={() => setPreviewImageUri(imageUri)}
|
||||
style={styles.imageAttachment}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: imageUri }}
|
||||
style={[
|
||||
styles.attachmentImage,
|
||||
width && height ? { aspectRatio: width / height } : {}
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{uploadProgress !== undefined && uploadProgress < 1 && (
|
||||
<View style={styles.attachmentProgressOverlay}>
|
||||
<Text style={styles.attachmentProgressText}>
|
||||
{Math.round(uploadProgress * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{uploadError && (
|
||||
<View style={styles.attachmentErrorOverlay}>
|
||||
<Text style={styles.attachmentErrorText}>上传失败</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'video') {
|
||||
// 视频附件的实现
|
||||
return (
|
||||
<View key={attachment.id} style={styles.attachmentContainer}>
|
||||
<TouchableOpacity style={styles.videoAttachment}>
|
||||
<View style={styles.videoPlaceholder}>
|
||||
<Ionicons name="play-circle" size={48} color="rgba(255,255,255,0.9)" />
|
||||
<Text style={styles.videoFilename}>{filename || '视频文件'}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'file') {
|
||||
// 文件附件的实现
|
||||
return (
|
||||
<View key={attachment.id} style={styles.attachmentContainer}>
|
||||
<TouchableOpacity style={styles.fileAttachment}>
|
||||
<Ionicons name="document-outline" size={24} color="#687076" />
|
||||
<Text style={styles.fileFilename} numberOfLines={1}>
|
||||
{filename || 'unknown_file'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 渲染所有附件
|
||||
function renderAttachments(attachments: MessageAttachment[], isUser: boolean) {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.attachmentsContainer}>
|
||||
{attachments.map(attachment => renderAttachment(attachment, isUser))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem({ item }: { item: ChatMessage }) {
|
||||
const isUser = item.role === 'user';
|
||||
return (
|
||||
@@ -726,6 +855,7 @@ export default function CoachScreen() {
|
||||
]}
|
||||
>
|
||||
{renderBubbleContent(item)}
|
||||
{renderAttachments(item.attachments || [], isUser)}
|
||||
</View>
|
||||
|
||||
</Animated.View>
|
||||
@@ -900,7 +1030,7 @@ export default function CoachScreen() {
|
||||
|
||||
// 在对话中插入"确认消息"并发送给教练
|
||||
const textMsg = `#记体重:\n\n${val} kg`;
|
||||
await send(textMsg);
|
||||
await sendStream(textMsg);
|
||||
} catch (e: any) {
|
||||
console.error('[AI_CHAT] Error handling weight submission:', e);
|
||||
Alert.alert('保存失败', e?.message || '请稍后重试');
|
||||
@@ -992,9 +1122,9 @@ export default function CoachScreen() {
|
||||
// 移除饮食选择卡片
|
||||
setMessages((prev) => prev.filter(msg => msg.id !== currentCardId));
|
||||
|
||||
// 发送包含图片的饮食记录消息
|
||||
const dietMsg = `#记饮食:\n\n`;
|
||||
await send(dietMsg);
|
||||
// 发送包含图片的饮食记录消息,图片通过 imageUrls 参数传递
|
||||
const dietMsg = `#记饮食:请分析这张食物照片的营养成分和热量`;
|
||||
await sendStream(dietMsg, [url]);
|
||||
} catch (uploadError) {
|
||||
console.error('[DIET] 图片上传失败:', uploadError);
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
@@ -1031,7 +1161,7 @@ export default function CoachScreen() {
|
||||
|
||||
// 发送饮食记录消息
|
||||
const dietMsg = `记录了今日饮食:${trimmedText}`;
|
||||
await send(dietMsg);
|
||||
await sendStream(dietMsg);
|
||||
} catch (e: any) {
|
||||
console.error('[DIET] 提交饮食记录失败:', e);
|
||||
Alert.alert('提交失败', e?.message || '提交失败,请重试');
|
||||
@@ -1278,7 +1408,6 @@ export default function CoachScreen() {
|
||||
options={[
|
||||
{ id: 'camera', title: '拍照', onPress: handleCameraPhoto },
|
||||
{ id: 'library', title: '从相册选择', onPress: handleLibraryPhoto },
|
||||
{ id: 'cancel', title: '取消', onPress: () => setShowDietPhotoActionSheet(false) }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
@@ -1663,6 +1792,87 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
const markdownStyles = {
|
||||
|
||||
Reference in New Issue
Block a user