diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index a936690..e7effff 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( { const routeName = route.name; const isSelected = (routeName === 'index' && pathname === ROUTES.TAB_HOME) || diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index 317bd1a..0350d85 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -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) => `![image](${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 ( + + setPreviewImageUri(imageUri)} + style={styles.imageAttachment} + > + + {uploadProgress !== undefined && uploadProgress < 1 && ( + + + {Math.round(uploadProgress * 100)}% + + + )} + {uploadError && ( + + 上传失败 + + )} + + + ); + } + + if (type === 'video') { + // 视频附件的实现 + return ( + + + + + {filename || '视频文件'} + + + + ); + } + + if (type === 'file') { + // 文件附件的实现 + return ( + + + + + {filename || 'unknown_file'} + + + + ); + } + + return null; + } + + // 渲染所有附件 + function renderAttachments(attachments: MessageAttachment[], isUser: boolean) { + if (!attachments || attachments.length === 0) return null; + + return ( + + {attachments.map(attachment => renderAttachment(attachment, isUser))} + + ); + } + 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)} @@ -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![食物照片](${url})`; - 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) } ]} /> @@ -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 = { diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json index f434400..3ab4f52 100644 --- a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "logo.png", + "filename" : "未命名项目 (1).jpeg", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png deleted file mode 100644 index 1f9eafc..0000000 Binary files a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/logo.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/未命名项目 (1).jpeg b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/未命名项目 (1).jpeg new file mode 100644 index 0000000..7bfe852 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/AppIcon.appiconset/未命名项目 (1).jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json index 4e12055..2eaf6ec 100644 --- a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "logo.png", + "filename" : "未命名项目 (1).jpeg", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "logo 1.png", + "filename" : "未命名项目.jpeg", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "logo 2.png", + "filename" : "未命名项目 1.jpeg", "idiom" : "universal", "scale" : "3x" } diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png deleted file mode 100644 index 1f9eafc..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 1.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png deleted file mode 100644 index 1f9eafc..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo 2.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png deleted file mode 100644 index 1f9eafc..0000000 Binary files a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/logo.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 (1).jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 (1).jpeg new file mode 100644 index 0000000..7bfe852 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 (1).jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 1.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 1.jpeg new file mode 100644 index 0000000..82e0954 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目 1.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目.jpeg b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目.jpeg new file mode 100644 index 0000000..82e0954 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/SplashScreenLogo.imageset/未命名项目.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json b/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json index 5f670ca..2eaf6ec 100644 --- a/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json +++ b/ios/digitalpilates/Images.xcassets/logo.imageset/Contents.json @@ -1,15 +1,17 @@ { "images" : [ { - "filename" : "logo.png", + "filename" : "未命名项目 (1).jpeg", "idiom" : "universal", "scale" : "1x" }, { + "filename" : "未命名项目.jpeg", "idiom" : "universal", "scale" : "2x" }, { + "filename" : "未命名项目 1.jpeg", "idiom" : "universal", "scale" : "3x" } diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png b/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png deleted file mode 100644 index d490ca6..0000000 Binary files a/ios/digitalpilates/Images.xcassets/logo.imageset/logo.png and /dev/null differ diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 (1).jpeg b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 (1).jpeg new file mode 100644 index 0000000..7bfe852 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 (1).jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 1.jpeg b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 1.jpeg new file mode 100644 index 0000000..82e0954 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目 1.jpeg differ diff --git a/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目.jpeg b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目.jpeg new file mode 100644 index 0000000..82e0954 Binary files /dev/null and b/ios/digitalpilates/Images.xcassets/logo.imageset/未命名项目.jpeg differ