feat: 实现聊天取消功能,提升用户交互体验

- 在教练页面中添加用户取消发送或终止回复的能力
- 更新发送按钮状态,支持发送和取消状态切换
- 在流式回复中显示取消按钮,允许用户中断助手的生成
- 增强请求管理,添加请求序列号和有效性验证,防止延迟响应影响用户体验
- 优化错误处理,区分用户主动取消和网络错误,提升系统稳定性
- 更新相关文档,详细描述取消功能的实现和用户体验设计
This commit is contained in:
richarjiang
2025-08-18 18:59:23 +08:00
parent 05a00236bc
commit d52981ab29
6 changed files with 1097 additions and 78 deletions

View File

@@ -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<AiConversationListItem[]>([]);
// 添加请求序列号,用于防止过期响应
const requestSequenceRef = useRef<number>(0);
const activeRequestIdRef = useRef<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(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<number>(60);
const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{
const [selectedImages, setSelectedImages] = useState<{
id: string;
localUri: string;
width?: number;
@@ -149,13 +153,14 @@ export default function CoachScreen() {
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 [pendingChoiceData, setPendingChoiceData] = useState<Record<string, any>>({});
const [selectedChoices, setSelectedChoices] = useState<Record<string, string>>({}); // messageId -> choiceId
const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState<Record<string, boolean>>({}); // 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 <Text style={[styles.bubbleText, { color: '#687076' }]}></Text>;
return (
<View style={styles.streamingContainer}>
<Text style={[styles.bubbleText, { color: '#687076' }]}></Text>
<TouchableOpacity
accessibilityRole="button"
onPress={cancelCurrentRequest}
style={styles.cancelStreamBtn}
>
<Ionicons name="stop-circle" size={20} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
);
}
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 (
<View style={{ gap: 8 }}>
<Markdown style={markdownStyles} mergeStyle>
{item.content}
</Markdown>
<TouchableOpacity
accessibilityRole="button"
onPress={cancelCurrentRequest}
style={styles.cancelStreamBtn}
>
<Ionicons name="stop-circle" size={16} color="#FF4444" />
<Text style={styles.cancelStreamText}></Text>
</TouchableOpacity>
</View>
);
}
// 检查是否有选择选项需要显示
if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') {
return (
@@ -1145,31 +1307,58 @@ export default function CoachScreen() {
{item.content || ''}
</Markdown>
<View style={styles.choicesContainer}>
{item.choices.map((choice) => (
<TouchableOpacity
key={choice.id}
accessibilityRole="button"
style={[
styles.choiceButton,
choice.recommended && styles.choiceButtonRecommended
]}
onPress={() => handleChoiceSelection(choice, item)}
>
<View style={styles.choiceContent}>
<Text style={[
styles.choiceLabel,
choice.recommended && styles.choiceLabelRecommended
]}>
{choice.label}
</Text>
{choice.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
{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 (
<TouchableOpacity
key={choice.id}
accessibilityRole="button"
disabled={isDisabled || isPending}
style={[
styles.choiceButton,
choice.recommended && styles.choiceButtonRecommended,
isSelected && styles.choiceButtonSelected,
isDisabled && styles.choiceButtonDisabled,
]}
onPress={() => {
if (!isDisabled && !isPending) {
Haptics.selectionAsync();
handleChoiceSelection(choice, item);
}
}}
>
<View style={styles.choiceContent}>
<Text style={[
styles.choiceLabel,
choice.recommended && styles.choiceLabelRecommended,
isSelected && styles.choiceLabelSelected,
isDisabled && styles.choiceLabelDisabled,
]}>
{choice.label}
</Text>
<View style={styles.choiceStatusContainer}>
{choice.recommended && !isSelected && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}></Text>
</View>
)}
{isSelected && isPending && (
<ActivityIndicator size="small" color="#2D5016" />
)}
{isSelected && !isPending && (
<View style={styles.selectedBadge}>
<Text style={styles.selectedText}></Text>
</View>
)}
</View>
)}
</View>
</TouchableOpacity>
))}
</View>
</TouchableOpacity>
);
})}
</View>
</View>
);
@@ -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() {
/>
<TouchableOpacity
accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending}
onPress={() => 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 ? (
<ActivityIndicator color={theme.onPrimary} />
<Ionicons name="stop" size={18} color="#fff" />
) : isStreaming ? (
<Ionicons name="stop" size={18} color="#fff" />
) : (
<Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)}
@@ -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 = {