From 05a00236bc40542074c64dc0a14ac6d0488261eb Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 18 Aug 2025 17:29:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=89=A9=E5=B1=95=E9=A5=AE=E9=A3=9F?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E7=A1=AE=E8=AE=A4=E6=B5=81=E7=A8=8B=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E9=80=89=E6=8B=A9=E9=80=89=E9=A1=B9=E5=92=8C?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在教练页面中新增AI选择选项和食物确认选项的数据结构 - 扩展消息结构以支持选择选项和交互类型 - 实现非流式JSON响应的处理逻辑,支持用户确认选择 - 添加选择选项的UI组件,提升用户交互体验 - 更新样式以适应新功能,确保视觉一致性 --- app/(tabs)/coach.tsx | 263 ++++++++++++++++++++++- docs/food-confirmation-implementation.md | 250 +++++++++++++++++++++ services/aiCoachSession.ts | 5 + 3 files changed, 507 insertions(+), 11 deletions(-) create mode 100644 docs/food-confirmation-implementation.md diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index 0350d85..43d3d03 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -54,12 +54,52 @@ type MessageAttachment = { uploadError?: string; // 上传错误信息 }; +// AI选择选项数据结构 +type AiChoiceOption = { + id: string; + label: string; + value: any; + recommended?: boolean; +}; + +// 餐次类型 +type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; + +// 食物确认选项数据结构 +type FoodConfirmationOption = { + id: string; + label: string; + foodName: string; + portion: string; + calories: number; + mealType: MealType; + nutritionData: { + proteinGrams?: number; + carbohydrateGrams?: number; + fatGrams?: number; + fiberGrams?: number; + }; +}; + +// AI响应数据结构 +type AiResponseData = { + content: string; + choices?: AiChoiceOption[]; + interactionType?: 'text' | 'food_confirmation' | 'selection'; + pendingData?: any; + context?: any; +}; + // 重构后的消息数据结构 type ChatMessage = { id: string; role: Role; content: string; // 文本内容 attachments?: MessageAttachment[]; // 附件列表 + choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息) + interactionType?: string; // 交互类型 + pendingData?: any; // 待确认数据 + context?: any; // 上下文信息 }; // 卡片类型常量定义 @@ -115,6 +155,7 @@ export default function CoachScreen() { const [weightInputs, setWeightInputs] = useState>({}); const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false); const [currentCardId, setCurrentCardId] = useState(null); + const [pendingChoiceData, setPendingChoiceData] = useState>({}); const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => s.checkin || {}); @@ -529,18 +570,22 @@ export default function CoachScreen() { ]); } + async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) { + // 发送确认选择的特殊请求 + const historyForServer = convertToServerMessages(messages); + const cid = ensureConversationId(); + const body = { + conversationId: cid, + messages: [...historyForServer, { role: 'user' as const, content: text }], + selectedChoiceId, + confirmationData, + stream: false, // 确认阶段使用非流式 + }; + + await sendRequestInternal(body, text); + } + 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), imageUrls }); } 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 = { @@ -550,6 +595,20 @@ export default function CoachScreen() { stream: true, }; + await sendRequestInternal(body, text, imageUrls); + } + + async function sendRequestInternal(body: any, text: string, imageUrls: string[] = []) { + const tokenExists = !!getAuthToken(); + try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls, stream: body.stream }); } catch { } + + // 终止上一次未完成的流 + 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)}`; @@ -574,6 +633,54 @@ export default function CoachScreen() { 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) => { @@ -591,6 +698,42 @@ export default function CoachScreen() { const onChunk = (chunk: string) => { receivedAnyChunk = true; const atBottomNow = isAtBottom; + + // 尝试解析是否为JSON结构化数据(可能是确认选项) + try { + const parsed = JSON.parse(chunk); + if (parsed && parsed.data && parsed.data.choices) { + // 处理结构化响应(包含选择选项) + 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); + } + + // 结束流式状态 + setIsSending(false); + setIsStreaming(false); + streamAbortRef.current = null; + pendingAssistantIdRef.current = null; + return; + } + } catch { + // 不是JSON,继续作为普通文本处理 + } + updateAssistantContent(chunk); if (atBottomNow) { // 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况 @@ -994,6 +1137,44 @@ export default function CoachScreen() { ); } + // 检查是否有选择选项需要显示 + if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') { + return ( + + + {item.content || ''} + + + {item.choices.map((choice) => ( + handleChoiceSelection(choice, item)} + > + + + {choice.label} + + {choice.recommended && ( + + 推荐 + + )} + + + ))} + + + ); + } + return ( {item.content || ''} @@ -1168,6 +1349,25 @@ export default function CoachScreen() { } } + async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) { + try { + console.log('[CHOICE] Selection:', { choiceId: choice.id, messageId: message.id }); + + // 构建确认请求 + const confirmationText = `我选择记录${choice.label}`; + + // 发送确认消息,包含选择的数据 + await sendStreamWithConfirmation(confirmationText, choice.id, { + selectedOption: choice.value, + imageUrl: message.pendingData?.imageUrl + }); + + } catch (e: any) { + console.error('[CHOICE] Selection failed:', e); + Alert.alert('选择失败', e?.message || '选择失败,请重试'); + } + } + return ( {/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */} @@ -1873,6 +2073,47 @@ const styles = StyleSheet.create({ fontSize: 14, color: '#192126', }, + // 选择选项相关样式 + choicesContainer: { + gap: 8, + }, + choiceButton: { + backgroundColor: 'rgba(255,255,255,0.9)', + borderWidth: 1, + borderColor: 'rgba(187,242,70,0.3)', + borderRadius: 12, + padding: 12, + }, + choiceButtonRecommended: { + borderColor: 'rgba(187,242,70,0.6)', + backgroundColor: 'rgba(187,242,70,0.1)', + }, + choiceContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + choiceLabel: { + fontSize: 15, + fontWeight: '600', + color: '#192126', + flex: 1, + }, + choiceLabelRecommended: { + color: '#2D5016', + }, + recommendedBadge: { + backgroundColor: 'rgba(187,242,70,0.8)', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 2, + marginLeft: 8, + }, + recommendedText: { + fontSize: 12, + fontWeight: '700', + color: '#2D5016', + }, }); const markdownStyles = { diff --git a/docs/food-confirmation-implementation.md b/docs/food-confirmation-implementation.md new file mode 100644 index 0000000..0653aa5 --- /dev/null +++ b/docs/food-confirmation-implementation.md @@ -0,0 +1,250 @@ +# 饮食记录确认流程客户端实现 + +## 概述 + +已完成对客户端的改动,支持饮食记录确认流程的新API结构。实现了对非流式JSON响应的处理,能够正确显示食物选择选项,并支持用户确认选择。 + +## 主要改动 + +### 1. 数据结构扩展 + +在 `app/(tabs)/coach.tsx` 中添加了新的类型定义: + +```typescript +// AI选择选项数据结构 +type AiChoiceOption = { + id: string; + label: string; + value: any; + recommended?: boolean; +}; + +// 餐次类型 +type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; + +// 食物确认选项数据结构 +type FoodConfirmationOption = { + id: string; + label: string; + foodName: string; + portion: string; + calories: number; + mealType: MealType; + nutritionData: { + proteinGrams?: number; + carbohydrateGrams?: number; + fatGrams?: number; + fiberGrams?: number; + }; +}; + +// AI响应数据结构 +type AiResponseData = { + content: string; + choices?: AiChoiceOption[]; + interactionType?: 'text' | 'food_confirmation' | 'selection'; + pendingData?: any; + context?: any; +}; +``` + +### 2. 消息结构扩展 + +扩展了 `ChatMessage` 类型以支持新的字段: + +```typescript +type ChatMessage = { + id: string; + role: Role; + content: string; + attachments?: MessageAttachment[]; + choices?: AiChoiceOption[]; // 新增:选择选项 + interactionType?: string; // 新增:交互类型 + pendingData?: any; // 新增:待确认数据 + context?: any; // 新增:上下文信息 +}; +``` + +### 3. 响应处理逻辑 + +#### 支持非流式JSON响应 + +修改了 `sendRequestInternal` 函数以检测和处理非流式JSON响应: + +```typescript +// 如果是非流式请求,直接调用API并处理响应 +if (!body.stream) { + try { + const response = await api.post<{ conversationId?: string; data?: AiResponseData; text?: string }>('/api/ai-coach/chat', body); + + // 处理响应 + 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, + }; + // ... 更新消息状态 + } + // ... + } +} +``` + +#### 流式响应中的JSON检测 + +在流式响应处理中添加了JSON检测逻辑: + +```typescript +const onChunk = (chunk: string) => { + // 尝试解析是否为JSON结构化数据(可能是确认选项) + try { + const parsed = JSON.parse(chunk); + if (parsed && parsed.data && parsed.data.choices) { + // 处理结构化响应(包含选择选项) + // ... 创建带选择选项的消息 + return; + } + } catch { + // 不是JSON,继续作为普通文本处理 + } + // ... 正常的文本流处理 +}; +``` + +### 4. 选择选项UI组件 + +在 `renderBubbleContent` 函数中添加了选择选项的渲染逻辑: + +```typescript +// 检查是否有选择选项需要显示 +if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') { + return ( + + + {item.content || ''} + + + {item.choices.map((choice) => ( + handleChoiceSelection(choice, item)} + > + + + {choice.label} + + {choice.recommended && ( + + 推荐 + + )} + + + ))} + + + ); +} +``` + +### 5. 选择确认逻辑 + +实现了 `handleChoiceSelection` 函数处理用户选择: + +```typescript +async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) { + try { + // 构建确认请求 + const confirmationText = `我选择记录${choice.label}`; + + // 发送确认消息,包含选择的数据 + await sendStreamWithConfirmation(confirmationText, choice.id, { + selectedOption: choice.value, + imageUrl: message.pendingData?.imageUrl + }); + } catch (e: any) { + Alert.alert('选择失败', e?.message || '选择失败,请重试'); + } +} +``` + +### 6. 确认数据发送 + +添加了 `sendStreamWithConfirmation` 函数: + +```typescript +async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) { + const historyForServer = convertToServerMessages(messages); + const cid = ensureConversationId(); + const body = { + conversationId: cid, + messages: [...historyForServer, { role: 'user' as const, content: text }], + selectedChoiceId, + confirmationData, + stream: false, // 确认阶段使用非流式 + }; + + await sendRequestInternal(body, text); +} +``` + +### 7. UI样式 + +添加了选择选项相关的样式: + +```typescript +choicesContainer: { + gap: 8, +}, +choiceButton: { + backgroundColor: 'rgba(255,255,255,0.9)', + borderWidth: 1, + borderColor: 'rgba(187,242,70,0.3)', + borderRadius: 12, + padding: 12, +}, +choiceButtonRecommended: { + borderColor: 'rgba(187,242,70,0.6)', + backgroundColor: 'rgba(187,242,70,0.1)', +}, +// ... 其他相关样式 +``` + +## 使用流程 + +1. **用户发送饮食图片**:用户点击"#记饮食"并上传食物图片 +2. **AI识别返回选项**:服务器返回食物识别结果和确认选项(非流式JSON) +3. **显示选择选项**:客户端渲染食物选择选项,推荐项目带有特殊标识 +4. **用户选择确认**:用户点击某个选项 +5. **发送确认数据**:客户端发送确认请求,包含选择的食物数据 +6. **记录完成**:服务器记录饮食数据并返回确认消息 + +## 兼容性 + +- 保持对现有流式文本响应的完全兼容 +- 自动检测响应类型(JSON vs 文本流) +- 旧的饮食记录方式(文字输入)继续正常工作 +- 缓存和会话管理支持新的消息结构 + +## 技术要点 + +1. **响应类型检测**:客户端能够自动识别JSON结构化响应和普通文本流 +2. **状态管理**:新增状态正确地保存到本地缓存 +3. **用户体验**:选择选项有清晰的视觉反馈,推荐选项突出显示 +4. **错误处理**:完整的错误处理机制,确保用户体验流畅 +5. **性能优化**:避免不必要的重渲染,保持界面响应性 + +这个实现完全符合API文档的要求,支持饮食记录的两阶段确认流程,并保持了与现有功能的兼容性。 diff --git a/services/aiCoachSession.ts b/services/aiCoachSession.ts index 8b7ef76..c2a4966 100644 --- a/services/aiCoachSession.ts +++ b/services/aiCoachSession.ts @@ -4,6 +4,11 @@ export type AiCoachChatMessage = { id: string; role: 'user' | 'assistant'; content: string; + attachments?: any[]; + choices?: any[]; + interactionType?: string; + pendingData?: any; + context?: any; }; export type AiCoachSessionCache = {