diff --git a/app/(tabs)/coach.tsx b/app/(tabs)/coach.tsx index 43d3d03..36aeead 100644 --- a/app/(tabs)/coach.tsx +++ b/app/(tabs)/coach.tsx @@ -1,5 +1,6 @@ import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; +import * as Haptics from 'expo-haptics'; import * as ImagePicker from 'expo-image-picker'; import { useLocalSearchParams, useRouter } from 'expo-router'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -65,21 +66,21 @@ type AiChoiceOption = { // 餐次类型 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; - }; -}; +// 食物确认选项数据结构(暂未使用,预留给未来功能扩展) +// 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 = { @@ -111,7 +112,7 @@ const CardType = { type CardType = typeof CardType[keyof typeof CardType]; -const COACH_AVATAR = require('@/assets/images/logo.png'); +// const COACH_AVATAR = require('@/assets/images/logo.png'); export default function CoachScreen() { const router = useRouter(); @@ -130,8 +131,11 @@ export default function CoachScreen() { const [historyVisible, setHistoryVisible] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyPage, setHistoryPage] = useState(1); - const [historyTotal, setHistoryTotal] = useState(0); + const [historyTotal] = useState(0); const [historyItems, setHistoryItems] = useState([]); + // 添加请求序列号,用于防止过期响应 + const requestSequenceRef = useRef(0); + const activeRequestIdRef = useRef(null); const listRef = useRef>(null); const [isAtBottom, setIsAtBottom] = useState(true); const didInitialScrollRef = useRef(false); @@ -140,7 +144,7 @@ export default function CoachScreen() { const [keyboardOffset, setKeyboardOffset] = useState(0); const [headerHeight, setHeaderHeight] = useState(60); const pendingAssistantIdRef = useRef(null); - const [selectedImages, setSelectedImages] = useState>([]); + }[]>([]); const [previewImageUri, setPreviewImageUri] = useState(null); const [dietTextInputs, setDietTextInputs] = useState>({}); const [weightInputs, setWeightInputs] = useState>({}); const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false); const [currentCardId, setCurrentCardId] = useState(null); - const [pendingChoiceData, setPendingChoiceData] = useState>({}); + const [selectedChoices, setSelectedChoices] = useState>({}); // messageId -> choiceId + const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState>({}); // messageId -> loading const planDraft = useAppSelector((s) => s.trainingPlan?.draft); const checkin = useAppSelector((s) => s.checkin || {}); @@ -446,7 +451,10 @@ export default function CoachScreen() { useEffect(() => { return () => { try { - streamAbortRef.current?.abort(); + if (streamAbortRef.current) { + streamAbortRef.current.abort(); + streamAbortRef.current = null; + } } catch (error) { console.warn('[AI_CHAT] Error aborting stream on unmount:', error); } @@ -460,13 +468,18 @@ export default function CoachScreen() { 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 temp conversationId', cid); } catch { } + try { console.log('[AI_CHAT][ui] create new conversationId', cid); } catch { } return cid; } - function convertToServerMessages(history: ChatMessage[]): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> { + function convertToServerMessages(history: ChatMessage[]): { role: 'user' | 'assistant' | 'system'; content: string }[] { // 仅映射 user/assistant 消息;系统提示由后端自动注入 return history .filter((m) => m.role === 'user' || m.role === 'assistant') @@ -474,8 +487,8 @@ export default function CoachScreen() { } async function openHistory() { - if (isStreaming) { - try { streamAbortRef.current?.abort(); } catch { } + if (isStreaming || isSending) { + cancelCurrentRequest(); } setHistoryVisible(true); await refreshHistory(1); @@ -486,7 +499,7 @@ export default function CoachScreen() { setHistoryLoading(true); const resp = await listConversations(page, 20); setHistoryPage(resp.page); - setHistoryTotal(resp.total); + // setHistoryTotal(resp.total); // 临时注释,因为当前未使用 setHistoryItems(resp.items || []); } catch (e) { Alert.alert('错误', (e as any)?.message || '获取会话列表失败'); @@ -496,8 +509,8 @@ export default function CoachScreen() { } function startNewConversation() { - if (isStreaming) { - try { streamAbortRef.current?.abort(); } catch { } + if (isStreaming || isSending) { + cancelCurrentRequest(); } // 清理当前会话状态 @@ -505,6 +518,8 @@ export default function CoachScreen() { setSelectedImages([]); setDietTextInputs({}); setWeightInputs({}); + setSelectedChoices({}); + setPendingChoiceConfirmation({}); // 创建新的欢迎消息 initializeWelcomeMessage(); @@ -523,8 +538,8 @@ export default function CoachScreen() { async function handleSelectConversation(id: string) { try { - if (isStreaming) { - try { streamAbortRef.current?.abort(); } catch { } + if (isStreaming || isSending) { + cancelCurrentRequest(); } const detail = await getConversationDetail(id); if (!detail || !(detail as any).messages) { @@ -575,7 +590,7 @@ export default function CoachScreen() { const historyForServer = convertToServerMessages(messages); const cid = ensureConversationId(); const body = { - conversationId: cid, + conversationId: cid || undefined, messages: [...historyForServer, { role: 'user' as const, content: text }], selectedChoiceId, confirmationData, @@ -587,9 +602,9 @@ export default function CoachScreen() { async function sendStream(text: string, imageUrls: string[] = []) { const historyForServer = convertToServerMessages(messages); - const cid = ensureConversationId(); + const cid = ensureConversationId(); // 可能返回空字符串 const body = { - conversationId: cid, + conversationId: cid || undefined, // 如果没有现有会话ID,传undefined让服务端生成 messages: [...historyForServer, { role: 'user' as const, content: text }], imageUrls: imageUrls.length > 0 ? imageUrls : undefined, stream: true, @@ -598,9 +613,75 @@ export default function CoachScreen() { 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(); - try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls, stream: body.stream }); } catch { } + + // 生成当前请求的序列号和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) { @@ -696,6 +777,12 @@ export default function CoachScreen() { }; const onChunk = (chunk: string) => { + // 验证请求是否仍然有效 + if (!isRequestValid()) { + console.log('[AI_CHAT][api] Ignoring chunk from invalidated request'); + return; + } + receivedAnyChunk = true; const atBottomNow = isAtBottom; @@ -703,6 +790,9 @@ export default function CoachScreen() { try { const parsed = JSON.parse(chunk); if (parsed && parsed.data && parsed.data.choices) { + // 再次验证请求有效性 + if (!isRequestValid()) return; + // 处理结构化响应(包含选择选项) const assistantMsg: ChatMessage = { id: assistantId, @@ -718,9 +808,10 @@ export default function CoachScreen() { msg.id === assistantId ? assistantMsg : msg )); - // 处理conversationId + // 处理conversationId - 只在请求有效时设置 if (parsed.conversationId && !conversationId) { setConversationId(parsed.conversationId); + console.log('[AI_CHAT][ui] Set conversationId from structured response:', parsed.conversationId); } // 结束流式状态 @@ -728,6 +819,7 @@ export default function CoachScreen() { setIsStreaming(false); streamAbortRef.current = null; pendingAssistantIdRef.current = null; + activeRequestIdRef.current = null; return; } } catch { @@ -740,37 +832,76 @@ export default function CoachScreen() { shouldAutoScrollRef.current = true; setTimeout(scrollToEnd, 0); } - try { console.log('[AI_CHAT][api] chunk', { length: chunk.length, preview: chunk.slice(0, 40) }); } catch { } + 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); + if (cidFromHeader && !conversationId) { + setConversationId(cidFromHeader); + console.log('[AI_CHAT][ui] Set conversationId from header:', cidFromHeader); + } pendingAssistantIdRef.current = null; - try { console.log('[AI_CHAT][api] end', { cidFromHeader, hadChunks: receivedAnyChunk }); } catch { } + 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', err); } catch { } + 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'); } catch { } + 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', e2); } catch { } + try { console.warn('[AI_CHAT][fallback] non-stream error', { requestId, error: e2 }); } catch { } } }; @@ -1007,7 +1138,19 @@ export default function CoachScreen() { function renderBubbleContent(item: ChatMessage) { if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { - return 正在思考…; + return ( + + 正在思考… + + + 取消 + + + ); } if (item.content?.startsWith(CardType.WEIGHT_INPUT)) { @@ -1137,6 +1280,25 @@ export default function CoachScreen() { ); } + // 在流式回复过程中显示取消按钮 + if (isStreaming && pendingAssistantIdRef.current === item.id && item.content?.trim()) { + return ( + + + {item.content} + + + + 停止生成 + + + ); + } + // 检查是否有选择选项需要显示 if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') { return ( @@ -1145,31 +1307,58 @@ export default function CoachScreen() { {item.content || ''} - {item.choices.map((choice) => ( - handleChoiceSelection(choice, item)} - > - - - {choice.label} - - {choice.recommended && ( - - 推荐 + {item.choices.map((choice) => { + const isSelected = selectedChoices[item.id] === choice.id; + const isAnySelected = selectedChoices[item.id] != null; + const isPending = pendingChoiceConfirmation[item.id]; + const isDisabled = isAnySelected && !isSelected; + + return ( + { + if (!isDisabled && !isPending) { + Haptics.selectionAsync(); + handleChoiceSelection(choice, item); + } + }} + > + + + {choice.label} + + + {choice.recommended && !isSelected && ( + + 推荐 + + )} + {isSelected && isPending && ( + + )} + {isSelected && !isPending && ( + + 已选择 + + )} - )} - - - ))} + + + ); + })} ); @@ -1353,14 +1542,50 @@ export default function CoachScreen() { 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}`; - // 发送确认消息,包含选择的数据 - await sendStreamWithConfirmation(confirmationText, choice.id, { - selectedOption: choice.value, - imageUrl: message.pendingData?.imageUrl - }); + 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); @@ -1517,15 +1742,26 @@ export default function CoachScreen() { /> send(input)} + disabled={(!input.trim() && selectedImages.length === 0) && !isSending} + onPress={() => { + if (isSending || isStreaming) { + cancelCurrentRequest(); + } else { + send(input); + } + }} style={[ styles.sendBtn, - { backgroundColor: theme.primary, opacity: (input.trim() || selectedImages.length > 0) && !isSending ? 1 : 0.5 } + { + backgroundColor: (isSending || isStreaming) ? '#FF4444' : theme.primary, + opacity: ((input.trim() || selectedImages.length > 0) || (isSending || isStreaming)) ? 1 : 0.5 + } ]} > {isSending ? ( - + + ) : isStreaming ? ( + ) : ( )} @@ -2088,6 +2324,16 @@ const styles = StyleSheet.create({ borderColor: 'rgba(187,242,70,0.6)', backgroundColor: 'rgba(187,242,70,0.1)', }, + choiceButtonSelected: { + borderColor: '#2D5016', + backgroundColor: 'rgba(187,242,70,0.2)', + 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', @@ -2102,18 +2348,63 @@ const styles = StyleSheet.create({ choiceLabelRecommended: { color: '#2D5016', }, + choiceLabelSelected: { + color: '#2D5016', + fontWeight: '700', + }, + choiceLabelDisabled: { + color: '#687076', + }, + choiceStatusContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, recommendedBadge: { backgroundColor: 'rgba(187,242,70,0.8)', borderRadius: 6, paddingHorizontal: 8, paddingVertical: 2, - marginLeft: 8, }, recommendedText: { fontSize: 12, fontWeight: '700', color: '#2D5016', }, + selectedBadge: { + backgroundColor: '#2D5016', + 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', + }, }); const markdownStyles = { diff --git a/docs/cancel-chat-implementation.md b/docs/cancel-chat-implementation.md new file mode 100644 index 0000000..5703d25 --- /dev/null +++ b/docs/cancel-chat-implementation.md @@ -0,0 +1,137 @@ +# 取消聊天功能实现文档 + +## 功能概述 + +为 AI 教练聊天界面添加了用户可以取消发送或终止回复的能力,参照业内最佳实践(如 ChatGPT、Claude 等)实现。 + +## 主要功能 + +### 1. 发送按钮状态切换 +- **发送状态**: 绿色背景,显示上箭头图标 +- **取消状态**: 红色背景 (#FF4444),显示停止图标 +- 根据 `isSending` 或 `isStreaming` 状态自动切换 + +### 2. 流式回复中的取消按钮 +- 在助手正在思考时显示"正在思考…"和取消按钮 +- 在助手正在输出文本时显示"停止生成"按钮 +- 取消按钮采用红色主题,与停止操作语义一致 + +### 3. 取消逻辑处理 +- 调用 `streamAbortRef.current.abort()` 中断 XMLHttpRequest +- 清理状态:`isSending`、`isStreaming`、`pendingAssistantIdRef` +- 移除正在生成中的助手消息 +- 提供触觉反馈(Warning 类型) + +### 4. 错误处理优化 +- 区分用户主动取消和网络错误 +- 对于 AbortError 不进行错误提示和降级处理 +- 避免在取消操作后显示"请求失败"等错误信息 + +## 技术实现 + +### 关键函数 + +```typescript +function cancelCurrentRequest() { + // 中断流式请求 + 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); +} +``` + +### UI 状态管理 + +1. **发送按钮**: + - 背景色:发送时为主题色,取消时为红色 + - 图标:发送时为上箭头,取消时为停止图标 + - 禁用状态:只在无内容且非发送状态时禁用 + +2. **流式消息气泡**: + - 思考阶段:显示"正在思考…"和取消按钮 + - 输出阶段:在消息下方显示"停止生成"按钮 + +### 边界情况处理 + +1. **组件卸载**: 自动清理未完成的请求 +2. **新建会话**: 如果有进行中的请求,先取消再创建 +3. **切换历史会话**: 取消当前请求后再加载历史会话 +4. **多次点击**: 防止重复发送,支持随时取消 + +## 用户体验设计 + +### 视觉反馈 +- 按钮颜色变化(绿色→红色) +- 图标变化(发送→停止) +- 取消按钮采用一致的红色主题 + +### 交互反馈 +- 触觉反馈:取消时提供 Warning 类型震动 +- 即时响应:点击取消后立即停止生成 +- 状态清理:取消后界面回到可输入状态 + +### 语义化设计 +- "正在思考…" + "取消":在等待响应阶段 +- "停止生成":在文本输出阶段 +- 红色主题:与停止操作的通用语义保持一致 + +## 业内最佳实践对比 + +### ChatGPT +- ✅ 发送按钮变为停止按钮 +- ✅ 红色停止图标 +- ✅ 即时响应 + +### Claude +- ✅ 流式输出过程中显示停止按钮 +- ✅ 取消后清理当前消息 +- ✅ 明确的视觉区分 + +### 我们的实现 +- ✅ 发送/取消按钮一体化设计 +- ✅ 双重取消入口(发送按钮 + 消息内按钮) +- ✅ 完整的状态管理和错误处理 +- ✅ 触觉反馈增强用户体验 + +## 测试建议 + +1. **基本功能测试**: + - 发送消息后立即点击取消 + - 在流式输出过程中点击停止 + - 连续发送多条消息并取消 + +2. **边界情况测试**: + - 网络异常时的取消行为 + - 快速切换会话时的状态清理 + - 组件卸载时的资源清理 + +3. **用户体验测试**: + - 按钮状态变化的流畅性 + - 触觉反馈的适当性 + - 界面响应的即时性 + +## 总结 + +本实现参照业内主流 AI 聊天应用的最佳实践,提供了完整的取消/终止功能,包括: + +- 直观的 UI 状态切换 +- 完善的取消逻辑处理 +- 良好的用户反馈机制 +- 健壮的错误处理 +- 一致的设计语言 + +功能实现既保证了技术的正确性,也注重了用户体验的完整性。 diff --git a/docs/complete-cancel-solution.md b/docs/complete-cancel-solution.md new file mode 100644 index 0000000..0ce0c3b --- /dev/null +++ b/docs/complete-cancel-solution.md @@ -0,0 +1,234 @@ +# 完整取消功能解决方案 + +## 问题诊断 + +您遇到的问题确实需要前后端协作解决: + +1. **客户端问题**: XMLHttpRequest.abort() 只能终止网络传输,无法停止服务端处理 +2. **会话持久化问题**: 服务端可能已经开始处理并保存会话记录 +3. **延迟响应问题**: 服务端处理完成后,响应仍会到达客户端 + +## 已实现的客户端增强方案 + +### 1. 请求序列号机制 +```typescript +// 防止延迟响应影响当前状态 +const requestSequenceRef = useRef(0); +const activeRequestIdRef = useRef(null); + +function cancelCurrentRequest() { + // 增加序列号,使后续响应失效 + requestSequenceRef.current += 1; + activeRequestIdRef.current = null; + + // 中断网络请求 + streamAbortRef.current?.abort(); + + // 清理状态和UI + setIsSending(false); + setIsStreaming(false); + // ... +} +``` + +### 2. 请求有效性验证 +```typescript +const isRequestValid = () => { + return activeRequestIdRef.current === requestId && + requestSequenceRef.current === currentSequence; +}; + +// 在所有回调中验证 +const onChunk = (chunk: string) => { + if (!isRequestValid()) { + console.log('Ignoring chunk from invalidated request'); + return; + } + // 处理chunk... +}; +``` + +### 3. 延迟会话ID生成 +```typescript +// 修改前:立即生成会话ID +function ensureConversationId(): string { + const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + setConversationId(cid); // 立即设置 + return cid; +} + +// 修改后:延迟生成,只在请求成功时设置 +function ensureConversationId(): string { + if (conversationId && conversationId.trim()) return conversationId; + return ''; // 返回空,让服务端生成 +} +``` + +### 4. 请求追踪机制 +```typescript +// 在 api.ts 中添加请求ID +const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +const requestHeaders = { + 'Content-Type': 'application/json', + 'X-Request-Id': requestId, // 用于服务端追踪 + // ... +}; +``` + +## 需要服务端配合的方案 + +### 方案1: 连接断开检测(推荐) + +**原理**: 服务端检测客户端连接状态,断开时停止处理 + +```python +# 服务端伪代码 +async def ai_chat_stream(request): + conversation_id = request.json.get('conversationId') + is_new_conversation = not conversation_id + + try: + # 检查客户端连接 + if await request.is_disconnected(): + return + + # 延迟创建会话记录 + temp_conv_id = None + if is_new_conversation: + temp_conv_id = create_temp_conversation() + + async for chunk in ai_generate_stream(): + # 每次生成前检查连接 + if await request.is_disconnected(): + if temp_conv_id: + delete_temp_conversation(temp_conv_id) + return + + yield chunk + + # 只有成功完成才保存会话 + if temp_conv_id: + save_permanent_conversation(temp_conv_id) + + except ClientDisconnectedError: + if temp_conv_id: + delete_temp_conversation(temp_conv_id) +``` + +**优点**: +- 自动检测,无需额外API +- 资源清理及时 +- 实现相对简单 + +### 方案2: 主动取消端点(备选) + +**原理**: 提供专门的取消API,客户端主动通知 + +```python +@app.post("/api/ai-coach/cancel") +async def cancel_request(request): + request_id = request.json.get('requestId') + conversation_id = request.json.get('conversationId') + + # 标记请求取消 + cancel_request_processing(request_id) + + # 清理临时会话 + if is_temp_conversation(conversation_id): + delete_conversation(conversation_id) + + return {"status": "cancelled"} +``` + +**客户端调用**: +```typescript +function cancelCurrentRequest() { + // 现有逻辑... + + // 主动通知服务端 + if (activeRequestIdRef.current) { + notifyServerCancel(conversationId, activeRequestIdRef.current); + } +} + +async function notifyServerCancel(conversationId?: string, requestId?: string) { + try { + await api.post('/api/ai-coach/cancel', { + requestId, + conversationId + }); + } catch (error) { + console.warn('Failed to notify server:', error); + } +} +``` + +## 数据库设计建议 + +```sql +-- 会话状态管理 +CREATE TABLE conversations ( + id VARCHAR(50) PRIMARY KEY, + user_id INT, + status ENUM('temp', 'active', 'cancelled') DEFAULT 'temp', + created_at TIMESTAMP, + confirmed_at TIMESTAMP NULL, + messages JSON, + INDEX idx_status_created (status, created_at) +); + +-- 定期清理临时会话(防止内存泄漏) +-- 可以用定时任务或在新请求时清理 +DELETE FROM conversations +WHERE status = 'temp' +AND created_at < NOW() - INTERVAL 10 MINUTE; +``` + +## 立即可用的解决方案 + +### 当前已实现(无需服务端修改) + +客户端增强已经可以解决大部分问题: + +1. ✅ **防止延迟响应**: 请求序列号机制 +2. ✅ **严格状态管理**: 请求有效性验证 +3. ✅ **延迟会话创建**: 避免空会话记录 +4. ✅ **即时UI反馈**: 取消后立即清理界面 + +### 效果评估 + +**改进前**: +- 点击取消 → 网络中断 → 但服务端继续处理 → 稍后响应仍然到达 +- 会话记录已创建且保留 + +**改进后**: +- 点击取消 → 序列号增加 → 界面立即清理 → 延迟响应被忽略 +- 会话ID延迟生成,减少空记录 + +### 推荐实施步骤 + +**第一阶段(立即可用)**: +1. ✅ 使用当前客户端增强方案 +2. 测试取消功能的改善效果 + +**第二阶段(服务端配合)**: +1. 实现连接断开检测 +2. 临时会话管理机制 +3. 完善资源清理 + +**第三阶段(可选优化)**: +1. 主动取消通知端点 +2. 数据库优化和定期清理 +3. 监控和日志完善 + +## 测试验证 + +建议测试以下场景: + +1. **快速取消**: 发送后立即点击取消 +2. **生成中取消**: 在AI回复过程中取消 +3. **网络异常**: 弱网环境下的取消行为 +4. **连续操作**: 快速发送多条消息并取消 +5. **状态恢复**: 取消后是否能正常发送新消息 + +通过这个完整方案,可以显著改善取消功能的用户体验,即使在服务端暂未配合的情况下,客户端增强也能解决大部分问题。 diff --git a/docs/food-confirmation-implementation.md b/docs/food-confirmation-implementation.md index 0653aa5..2706cfe 100644 --- a/docs/food-confirmation-implementation.md +++ b/docs/food-confirmation-implementation.md @@ -247,4 +247,90 @@ choiceButtonRecommended: { 4. **错误处理**:完整的错误处理机制,确保用户体验流畅 5. **性能优化**:避免不必要的重渲染,保持界面响应性 -这个实现完全符合API文档的要求,支持饮食记录的两阶段确认流程,并保持了与现有功能的兼容性。 +## 选择状态管理与用户体验优化 + +### 防重复点击机制 + +为了防止用户重复发送消息,实现了完善的状态管理: + +```typescript +const [selectedChoices, setSelectedChoices] = useState>({}); // messageId -> choiceId +const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState>({}); // messageId -> loading +``` + +### 选择状态逻辑 + +1. **状态检查**:点击前检查是否已选择 +2. **立即锁定**:点击后立即设置选中状态,防止重复点击 +3. **视觉反馈**:选中项显示特殊样式,其他选项变为禁用状态 +4. **加载指示**:发送过程中显示loading动画 +5. **错误恢复**:发送失败时重置状态,允许重新选择 + +### UI状态展示 + +- **正常状态**:常规样式,可点击 +- **推荐状态**:特殊边框和背景色,显示"推荐"标签 +- **选中状态**:深色边框,绿色背景,显示"已选择"标签 +- **禁用状态**:灰色透明,不可点击 +- **加载状态**:显示旋转加载动画 + +### 触觉反馈 + +- **选择时**:`Haptics.selectionAsync()` - 轻微选择反馈 +- **成功时**:`Haptics.NotificationFeedbackType.Success` - 成功通知 +- **失败时**:`Haptics.NotificationFeedbackType.Error` - 错误通知 + +### 代码示例 + +```typescript +// 状态管理 +const isSelected = selectedChoices[item.id] === choice.id; +const isAnySelected = selectedChoices[item.id] != null; +const isPending = pendingChoiceConfirmation[item.id]; +const isDisabled = isAnySelected && !isSelected; + +// 点击处理 +onPress={() => { + if (!isDisabled && !isPending) { + Haptics.selectionAsync(); + handleChoiceSelection(choice, item); + } +}} + +// 选择处理逻辑 +async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) { + // 防重复检查 + if (selectedChoices[message.id] != null) { + return; + } + + // 立即设置状态 + setSelectedChoices(prev => ({ ...prev, [message.id]: choice.id })); + setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: true })); + + try { + // 发送确认 + await sendStreamWithConfirmation(confirmationText, choice.id, confirmationData); + + // 成功反馈 + setPendingChoiceConfirmation(prev => ({ ...prev, [message.id]: false })); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + } catch (e) { + // 失败时重置状态 + setSelectedChoices(prev => { + const { [message.id]: _, ...rest } = prev; + return rest; + }); + setPendingChoiceConfirmation(prev => { + const { [message.id]: _, ...rest } = prev; + return rest; + }); + + // 错误反馈 + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + Alert.alert('选择失败', e?.message || '选择失败,请重试'); + } +} +``` + +这个实现完全符合API文档的要求,支持饮食记录的两阶段确认流程,并保持了与现有功能的兼容性。同时提供了优秀的用户体验,包括防重复点击、状态反馈、触觉反馈等功能。 diff --git a/docs/server-side-cancel-support.md b/docs/server-side-cancel-support.md new file mode 100644 index 0000000..57c1f50 --- /dev/null +++ b/docs/server-side-cancel-support.md @@ -0,0 +1,266 @@ +# 服务端取消支持方案 + +## 问题分析 + +当前取消功能的问题: +1. 客户端 `XMLHttpRequest.abort()` 只能终止网络传输,无法停止服务端处理 +2. 服务端可能已经生成并保存了会话记录 +3. 延迟响应导致"取消"的内容稍后出现 + +## 服务端需要支持的功能 + +### 1. 请求中断检测 + +**方案A:连接断开检测(推荐)** +```python +# 伪代码示例 +async def ai_chat_stream(request): + conversation_id = request.json.get('conversationId') + messages = request.json.get('messages', []) + + # 如果是新会话且客户端断开,不创建会话记录 + is_new_conversation = not conversation_id + temp_conversation_id = None + + try: + # 检查客户端连接状态 + if await request.is_disconnected(): + print("Client disconnected before processing") + return + + # 只有在开始生成内容时才创建会话 + if is_new_conversation: + temp_conversation_id = create_conversation() + + async for chunk in ai_generate_stream(messages): + # 每次生成前检查连接状态 + if await request.is_disconnected(): + print("Client disconnected during generation") + # 如果是新会话,删除临时创建的会话 + if temp_conversation_id: + delete_conversation(temp_conversation_id) + return + + yield chunk + + # 成功完成,保存会话 + if temp_conversation_id: + save_conversation(temp_conversation_id, messages + [ai_response]) + + except ClientDisconnectedError: + # 清理未完成的会话 + if temp_conversation_id: + delete_conversation(temp_conversation_id) +``` + +**方案B:取消端点(备选)** +```python +# 添加专门的取消端点 +@app.post("/api/ai-coach/cancel") +async def cancel_request(request): + request_id = request.json.get('requestId') + conversation_id = request.json.get('conversationId') + + # 标记请求为已取消 + cancel_request_processing(request_id) + + # 如果会话是新创建的,删除它 + if is_new_conversation(conversation_id): + delete_conversation(conversation_id) + + return {"status": "cancelled"} + +# 在主处理函数中检查取消状态 +async def ai_chat_stream(request): + request_id = request.headers.get('X-Request-Id') + + while generating: + if is_request_cancelled(request_id): + cleanup_and_exit() + return + # 继续处理... +``` + +### 2. 会话管理优化 + +**延迟会话创建:** +```python +class ConversationManager: + def __init__(self): + self.temp_conversations = {} + + async def start_conversation(self, client_id): + """开始新会话,但不立即持久化""" + temp_id = f"temp_{uuid4()}" + self.temp_conversations[temp_id] = { + 'client_id': client_id, + 'messages': [], + 'created_at': datetime.now(), + 'confirmed': False + } + return temp_id + + async def confirm_conversation(self, temp_id, final_messages): + """确认会话并持久化""" + if temp_id in self.temp_conversations: + # 保存到数据库 + real_id = self.save_to_database(final_messages) + del self.temp_conversations[temp_id] + return real_id + + async def cancel_conversation(self, temp_id): + """取消临时会话""" + if temp_id in self.temp_conversations: + del self.temp_conversations[temp_id] + return True + return False +``` + +### 3. 流式响应优化 + +**分段提交策略:** +```python +async def ai_chat_stream(request): + try: + # 第一阶段:验证请求 + if await request.is_disconnected(): + return + + # 第二阶段:开始生成(创建临时会话) + temp_conv_id = await start_temp_conversation() + yield json.dumps({"temp_conversation_id": temp_conv_id}) + + # 第三阶段:流式生成内容 + full_response = "" + for chunk in ai_generate(): + if await request.is_disconnected(): + await cancel_temp_conversation(temp_conv_id) + return + + full_response += chunk + yield chunk + + # 第四阶段:确认并保存会话 + final_conv_id = await confirm_conversation(temp_conv_id, full_response) + yield json.dumps({"final_conversation_id": final_conv_id}) + + except ClientDisconnectedError: + await cancel_temp_conversation(temp_conv_id) +``` + +## 具体实现建议 + +### 1. 服务端 API 修改 + +**请求头支持:** +```http +POST /api/ai-coach/chat +X-Request-Id: req_1234567890_abcdef +Content-Type: application/json + +{ + "conversationId": "existing_conv_id", // 可选,新会话时为空 + "messages": [...], + "stream": true +} +``` + +**响应头:** +```http +HTTP/1.1 200 OK +X-Conversation-Id: mobile-1701234567-abcdef123 +X-Request-Id: req_1234567890_abcdef +Content-Type: text/plain; charset=utf-8 +``` + +### 2. 数据库设计优化 + +**会话状态管理:** +```sql +CREATE TABLE conversations ( + id VARCHAR(50) PRIMARY KEY, + user_id INT, + status ENUM('temp', 'active', 'cancelled') DEFAULT 'temp', + created_at TIMESTAMP, + confirmed_at TIMESTAMP NULL, + messages JSON, + INDEX idx_status_created (status, created_at) +); + +-- 定期清理临时会话 +DELETE FROM conversations +WHERE status = 'temp' +AND created_at < NOW() - INTERVAL 10 MINUTE; +``` + +### 3. 客户端配合改进 + +**添加请求ID头:** +```typescript +// 在 api.ts 中添加 +export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { + const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + 'X-Request-Id': requestId, + ...(options.headers || {}), + }; + + // ... 其余代码 +} +``` + +**主动取消通知(可选):** +```typescript +async function notifyServerCancel(conversationId?: string, requestId?: string) { + try { + if (requestId) { + await api.post('/api/ai-coach/cancel', { + requestId, + conversationId + }); + } + } catch (error) { + console.warn('Failed to notify server of cancellation:', error); + } +} +``` + +## 完整取消流程 + +### 客户端取消时: +1. 增加请求序列号(防止延迟响应) +2. 调用 `XMLHttpRequest.abort()` +3. 清理本地状态 +4. (可选)通知服务端取消 + +### 服务端检测到断开: +1. 立即停止 AI 内容生成 +2. 检查会话状态(临时/已确认) +3. 如果是临时会话,删除记录 +4. 清理相关资源 + +### 防止延迟响应: +1. 客户端使用请求序列号验证 +2. 服务端检查连接状态 +3. 分段式会话确认机制 + +## 优先级建议 + +**立即实现(高优先级):** +1. 客户端请求序列号验证 ✅ 已实现 +2. 延迟会话ID生成 ✅ 已实现 +3. 客户端状态严格管理 ✅ 已实现 + +**服务端配合(中优先级):** +1. 连接断开检测 +2. 临时会话管理 +3. 请求ID追踪 + +**可选优化(低优先级):** +1. 主动取消通知端点 +2. 会话状态数据库优化 +3. 定期清理机制 + +通过以上客户端和服务端的协作改进,可以彻底解决取消功能的问题,确保用户取消操作的即时性和有效性。 diff --git a/services/api.ts b/services/api.ts index fd8d9af..5a8c2b0 100644 --- a/services/api.ts +++ b/services/api.ts @@ -102,8 +102,13 @@ export type TextStreamOptions = { export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { const url = buildApiUrl(path); const token = getAuthToken(); + + // 生成请求ID用于追踪和取消 + const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const requestHeaders: Record = { 'Content-Type': 'application/json', + 'X-Request-Id': requestId, ...(options.headers || {}), }; if (token) { @@ -227,7 +232,7 @@ export function postTextStream(path: string, body: any, callbacks: TextStreamCal } } - return { abort }; + return { abort, requestId }; }