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 { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import * as Haptics from 'expo-haptics';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -65,21 +66,21 @@ type AiChoiceOption = {
// 餐次类型 // 餐次类型
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
// 食物确认选项数据结构 // 食物确认选项数据结构(暂未使用,预留给未来功能扩展)
type FoodConfirmationOption = { // type FoodConfirmationOption = {
id: string; // id: string;
label: string; // label: string;
foodName: string; // foodName: string;
portion: string; // portion: string;
calories: number; // calories: number;
mealType: MealType; // mealType: MealType;
nutritionData: { // nutritionData: {
proteinGrams?: number; // proteinGrams?: number;
carbohydrateGrams?: number; // carbohydrateGrams?: number;
fatGrams?: number; // fatGrams?: number;
fiberGrams?: number; // fiberGrams?: number;
}; // };
}; // };
// AI响应数据结构 // AI响应数据结构
type AiResponseData = { type AiResponseData = {
@@ -111,7 +112,7 @@ const CardType = {
type CardType = typeof CardType[keyof typeof 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() { export default function CoachScreen() {
const router = useRouter(); const router = useRouter();
@@ -130,8 +131,11 @@ export default function CoachScreen() {
const [historyVisible, setHistoryVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
const [historyPage, setHistoryPage] = useState(1); const [historyPage, setHistoryPage] = useState(1);
const [historyTotal, setHistoryTotal] = useState(0); const [historyTotal] = useState(0);
const [historyItems, setHistoryItems] = useState<AiConversationListItem[]>([]); const [historyItems, setHistoryItems] = useState<AiConversationListItem[]>([]);
// 添加请求序列号,用于防止过期响应
const requestSequenceRef = useRef<number>(0);
const activeRequestIdRef = useRef<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null); const listRef = useRef<FlatList<ChatMessage>>(null);
const [isAtBottom, setIsAtBottom] = useState(true); const [isAtBottom, setIsAtBottom] = useState(true);
const didInitialScrollRef = useRef(false); const didInitialScrollRef = useRef(false);
@@ -140,7 +144,7 @@ export default function CoachScreen() {
const [keyboardOffset, setKeyboardOffset] = useState(0); const [keyboardOffset, setKeyboardOffset] = useState(0);
const [headerHeight, setHeaderHeight] = useState<number>(60); const [headerHeight, setHeaderHeight] = useState<number>(60);
const pendingAssistantIdRef = useRef<string | null>(null); const pendingAssistantIdRef = useRef<string | null>(null);
const [selectedImages, setSelectedImages] = useState<Array<{ const [selectedImages, setSelectedImages] = useState<{
id: string; id: string;
localUri: string; localUri: string;
width?: number; width?: number;
@@ -149,13 +153,14 @@ export default function CoachScreen() {
uploadedKey?: string; uploadedKey?: string;
uploadedUrl?: string; uploadedUrl?: string;
error?: string; error?: string;
}>>([]); }[]>([]);
const [previewImageUri, setPreviewImageUri] = useState<string | null>(null); const [previewImageUri, setPreviewImageUri] = useState<string | null>(null);
const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({}); const [dietTextInputs, setDietTextInputs] = useState<Record<string, string>>({});
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({}); const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false); const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false);
const [currentCardId, setCurrentCardId] = useState<string | null>(null); 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 planDraft = useAppSelector((s) => s.trainingPlan?.draft);
const checkin = useAppSelector((s) => s.checkin || {}); const checkin = useAppSelector((s) => s.checkin || {});
@@ -446,7 +451,10 @@ export default function CoachScreen() {
useEffect(() => { useEffect(() => {
return () => { return () => {
try { try {
streamAbortRef.current?.abort(); if (streamAbortRef.current) {
streamAbortRef.current.abort();
streamAbortRef.current = null;
}
} catch (error) { } catch (error) {
console.warn('[AI_CHAT] Error aborting stream on unmount:', error); console.warn('[AI_CHAT] Error aborting stream on unmount:', error);
} }
@@ -460,13 +468,18 @@ export default function CoachScreen() {
function ensureConversationId(): string { function ensureConversationId(): string {
if (conversationId && conversationId.trim()) return conversationId; if (conversationId && conversationId.trim()) return conversationId;
// 延迟生成会话ID只在请求成功开始时才生成
return '';
}
function createNewConversationId(): string {
const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const cid = `mobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
setConversationId(cid); 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; 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 消息;系统提示由后端自动注入 // 仅映射 user/assistant 消息;系统提示由后端自动注入
return history return history
.filter((m) => m.role === 'user' || m.role === 'assistant') .filter((m) => m.role === 'user' || m.role === 'assistant')
@@ -474,8 +487,8 @@ export default function CoachScreen() {
} }
async function openHistory() { async function openHistory() {
if (isStreaming) { if (isStreaming || isSending) {
try { streamAbortRef.current?.abort(); } catch { } cancelCurrentRequest();
} }
setHistoryVisible(true); setHistoryVisible(true);
await refreshHistory(1); await refreshHistory(1);
@@ -486,7 +499,7 @@ export default function CoachScreen() {
setHistoryLoading(true); setHistoryLoading(true);
const resp = await listConversations(page, 20); const resp = await listConversations(page, 20);
setHistoryPage(resp.page); setHistoryPage(resp.page);
setHistoryTotal(resp.total); // setHistoryTotal(resp.total); // 临时注释,因为当前未使用
setHistoryItems(resp.items || []); setHistoryItems(resp.items || []);
} catch (e) { } catch (e) {
Alert.alert('错误', (e as any)?.message || '获取会话列表失败'); Alert.alert('错误', (e as any)?.message || '获取会话列表失败');
@@ -496,8 +509,8 @@ export default function CoachScreen() {
} }
function startNewConversation() { function startNewConversation() {
if (isStreaming) { if (isStreaming || isSending) {
try { streamAbortRef.current?.abort(); } catch { } cancelCurrentRequest();
} }
// 清理当前会话状态 // 清理当前会话状态
@@ -505,6 +518,8 @@ export default function CoachScreen() {
setSelectedImages([]); setSelectedImages([]);
setDietTextInputs({}); setDietTextInputs({});
setWeightInputs({}); setWeightInputs({});
setSelectedChoices({});
setPendingChoiceConfirmation({});
// 创建新的欢迎消息 // 创建新的欢迎消息
initializeWelcomeMessage(); initializeWelcomeMessage();
@@ -523,8 +538,8 @@ export default function CoachScreen() {
async function handleSelectConversation(id: string) { async function handleSelectConversation(id: string) {
try { try {
if (isStreaming) { if (isStreaming || isSending) {
try { streamAbortRef.current?.abort(); } catch { } cancelCurrentRequest();
} }
const detail = await getConversationDetail(id); const detail = await getConversationDetail(id);
if (!detail || !(detail as any).messages) { if (!detail || !(detail as any).messages) {
@@ -575,7 +590,7 @@ export default function CoachScreen() {
const historyForServer = convertToServerMessages(messages); const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId(); const cid = ensureConversationId();
const body = { const body = {
conversationId: cid, conversationId: cid || undefined,
messages: [...historyForServer, { role: 'user' as const, content: text }], messages: [...historyForServer, { role: 'user' as const, content: text }],
selectedChoiceId, selectedChoiceId,
confirmationData, confirmationData,
@@ -587,9 +602,9 @@ export default function CoachScreen() {
async function sendStream(text: string, imageUrls: string[] = []) { async function sendStream(text: string, imageUrls: string[] = []) {
const historyForServer = convertToServerMessages(messages); const historyForServer = convertToServerMessages(messages);
const cid = ensureConversationId(); const cid = ensureConversationId(); // 可能返回空字符串
const body = { const body = {
conversationId: cid, conversationId: cid || undefined, // 如果没有现有会话ID传undefined让服务端生成
messages: [...historyForServer, { role: 'user' as const, content: text }], messages: [...historyForServer, { role: 'user' as const, content: text }],
imageUrls: imageUrls.length > 0 ? imageUrls : undefined, imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
stream: true, stream: true,
@@ -598,9 +613,75 @@ export default function CoachScreen() {
await sendRequestInternal(body, text, imageUrls); 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[] = []) { async function sendRequestInternal(body: any, text: string, imageUrls: string[] = []) {
const tokenExists = !!getAuthToken(); 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) { if (streamAbortRef.current) {
@@ -696,6 +777,12 @@ export default function CoachScreen() {
}; };
const onChunk = (chunk: string) => { const onChunk = (chunk: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring chunk from invalidated request');
return;
}
receivedAnyChunk = true; receivedAnyChunk = true;
const atBottomNow = isAtBottom; const atBottomNow = isAtBottom;
@@ -703,6 +790,9 @@ export default function CoachScreen() {
try { try {
const parsed = JSON.parse(chunk); const parsed = JSON.parse(chunk);
if (parsed && parsed.data && parsed.data.choices) { if (parsed && parsed.data && parsed.data.choices) {
// 再次验证请求有效性
if (!isRequestValid()) return;
// 处理结构化响应(包含选择选项) // 处理结构化响应(包含选择选项)
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
id: assistantId, id: assistantId,
@@ -718,9 +808,10 @@ export default function CoachScreen() {
msg.id === assistantId ? assistantMsg : msg msg.id === assistantId ? assistantMsg : msg
)); ));
// 处理conversationId // 处理conversationId - 只在请求有效时设置
if (parsed.conversationId && !conversationId) { if (parsed.conversationId && !conversationId) {
setConversationId(parsed.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); setIsStreaming(false);
streamAbortRef.current = null; streamAbortRef.current = null;
pendingAssistantIdRef.current = null; pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
return; return;
} }
} catch { } catch {
@@ -740,37 +832,76 @@ export default function CoachScreen() {
shouldAutoScrollRef.current = true; shouldAutoScrollRef.current = true;
setTimeout(scrollToEnd, 0); 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) => { const onEnd = (cidFromHeader?: string) => {
// 验证请求是否仍然有效
if (!isRequestValid()) {
console.log('[AI_CHAT][api] Ignoring end from invalidated request');
return;
}
setIsSending(false); setIsSending(false);
setIsStreaming(false); setIsStreaming(false);
streamAbortRef.current = null; 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; 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) => { 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); setIsSending(false);
setIsStreaming(false); setIsStreaming(false);
streamAbortRef.current = null; streamAbortRef.current = null;
pendingAssistantIdRef.current = null; pendingAssistantIdRef.current = null;
activeRequestIdRef.current = null;
// 流式失败时的降级:尝试一次性非流式 // 流式失败时的降级:尝试一次性非流式
try { try {
// 再次验证请求有效性
if (!isRequestValid()) return;
const bodyNoStream = { ...body, stream: false }; 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); const resp = await api.post<{ conversationId?: string; text: string }>('/api/ai-coach/chat', bodyNoStream);
// 最终验证请求有效性
if (!isRequestValid()) return;
const textCombined = (resp as any)?.text ?? ''; const textCombined = (resp as any)?.text ?? '';
if ((resp as any)?.conversationId && !conversationId) { if ((resp as any)?.conversationId && !conversationId) {
setConversationId((resp as any).conversationId); setConversationId((resp as any).conversationId);
} }
setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg)); setMessages((prev) => prev.map((msg) => msg.id === assistantId ? { ...msg, content: textCombined || '(空响应)' } : msg));
} catch (e2: any) { } 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)); 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) { function renderBubbleContent(item: ChatMessage) {
if (!item.content?.trim() && isStreaming && pendingAssistantIdRef.current === item.id) { 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)) { 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') { if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') {
return ( return (
@@ -1145,31 +1307,58 @@ export default function CoachScreen() {
{item.content || ''} {item.content || ''}
</Markdown> </Markdown>
<View style={styles.choicesContainer}> <View style={styles.choicesContainer}>
{item.choices.map((choice) => ( {item.choices.map((choice) => {
<TouchableOpacity const isSelected = selectedChoices[item.id] === choice.id;
key={choice.id} const isAnySelected = selectedChoices[item.id] != null;
accessibilityRole="button" const isPending = pendingChoiceConfirmation[item.id];
style={[ const isDisabled = isAnySelected && !isSelected;
styles.choiceButton,
choice.recommended && styles.choiceButtonRecommended return (
]} <TouchableOpacity
onPress={() => handleChoiceSelection(choice, item)} key={choice.id}
> accessibilityRole="button"
<View style={styles.choiceContent}> disabled={isDisabled || isPending}
<Text style={[ style={[
styles.choiceLabel, styles.choiceButton,
choice.recommended && styles.choiceLabelRecommended choice.recommended && styles.choiceButtonRecommended,
]}> isSelected && styles.choiceButtonSelected,
{choice.label} isDisabled && styles.choiceButtonDisabled,
</Text> ]}
{choice.recommended && ( onPress={() => {
<View style={styles.recommendedBadge}> if (!isDisabled && !isPending) {
<Text style={styles.recommendedText}></Text> 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>
)} </View>
</View> </TouchableOpacity>
</TouchableOpacity> );
))} })}
</View> </View>
</View> </View>
); );
@@ -1353,14 +1542,50 @@ export default function CoachScreen() {
try { try {
console.log('[CHOICE] Selection:', { choiceId: choice.id, messageId: message.id }); 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}`; const confirmationText = `我选择记录${choice.label}`;
// 发送确认消息,包含选择的数据 try {
await sendStreamWithConfirmation(confirmationText, choice.id, { // 发送确认消息,包含选择的数据
selectedOption: choice.value, await sendStreamWithConfirmation(confirmationText, choice.id, {
imageUrl: message.pendingData?.imageUrl 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) { } catch (e: any) {
console.error('[CHOICE] Selection failed:', e); console.error('[CHOICE] Selection failed:', e);
@@ -1517,15 +1742,26 @@ export default function CoachScreen() {
/> />
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
disabled={(!input.trim() && selectedImages.length === 0) || isSending} disabled={(!input.trim() && selectedImages.length === 0) && !isSending}
onPress={() => send(input)} onPress={() => {
if (isSending || isStreaming) {
cancelCurrentRequest();
} else {
send(input);
}
}}
style={[ style={[
styles.sendBtn, 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 ? ( {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} /> <Ionicons name="arrow-up" size={18} color={theme.onPrimary} />
)} )}
@@ -2088,6 +2324,16 @@ const styles = StyleSheet.create({
borderColor: 'rgba(187,242,70,0.6)', borderColor: 'rgba(187,242,70,0.6)',
backgroundColor: 'rgba(187,242,70,0.1)', 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: { choiceContent: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -2102,18 +2348,63 @@ const styles = StyleSheet.create({
choiceLabelRecommended: { choiceLabelRecommended: {
color: '#2D5016', color: '#2D5016',
}, },
choiceLabelSelected: {
color: '#2D5016',
fontWeight: '700',
},
choiceLabelDisabled: {
color: '#687076',
},
choiceStatusContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
recommendedBadge: { recommendedBadge: {
backgroundColor: 'rgba(187,242,70,0.8)', backgroundColor: 'rgba(187,242,70,0.8)',
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 2, paddingVertical: 2,
marginLeft: 8,
}, },
recommendedText: { recommendedText: {
fontSize: 12, fontSize: 12,
fontWeight: '700', fontWeight: '700',
color: '#2D5016', 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 = { const markdownStyles = {

View File

@@ -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 状态切换
- 完善的取消逻辑处理
- 良好的用户反馈机制
- 健壮的错误处理
- 一致的设计语言
功能实现既保证了技术的正确性,也注重了用户体验的完整性。

View File

@@ -0,0 +1,234 @@
# 完整取消功能解决方案
## 问题诊断
您遇到的问题确实需要前后端协作解决:
1. **客户端问题**: XMLHttpRequest.abort() 只能终止网络传输,无法停止服务端处理
2. **会话持久化问题**: 服务端可能已经开始处理并保存会话记录
3. **延迟响应问题**: 服务端处理完成后,响应仍会到达客户端
## 已实现的客户端增强方案
### 1. 请求序列号机制
```typescript
// 防止延迟响应影响当前状态
const requestSequenceRef = useRef<number>(0);
const activeRequestIdRef = useRef<string | null>(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. **状态恢复**: 取消后是否能正常发送新消息
通过这个完整方案,可以显著改善取消功能的用户体验,即使在服务端暂未配合的情况下,客户端增强也能解决大部分问题。

View File

@@ -247,4 +247,90 @@ choiceButtonRecommended: {
4. **错误处理**:完整的错误处理机制,确保用户体验流畅 4. **错误处理**:完整的错误处理机制,确保用户体验流畅
5. **性能优化**:避免不必要的重渲染,保持界面响应性 5. **性能优化**:避免不必要的重渲染,保持界面响应性
这个实现完全符合API文档的要求支持饮食记录的两阶段确认流程并保持了与现有功能的兼容性。 ## 选择状态管理与用户体验优化
### 防重复点击机制
为了防止用户重复发送消息,实现了完善的状态管理:
```typescript
const [selectedChoices, setSelectedChoices] = useState<Record<string, string>>({}); // messageId -> choiceId
const [pendingChoiceConfirmation, setPendingChoiceConfirmation] = useState<Record<string, boolean>>({}); // 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文档的要求支持饮食记录的两阶段确认流程并保持了与现有功能的兼容性。同时提供了优秀的用户体验包括防重复点击、状态反馈、触觉反馈等功能。

View File

@@ -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<string, string> = {
'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. 定期清理机制
通过以上客户端和服务端的协作改进,可以彻底解决取消功能的问题,确保用户取消操作的即时性和有效性。

View File

@@ -102,8 +102,13 @@ export type TextStreamOptions = {
export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) { export function postTextStream(path: string, body: any, callbacks: TextStreamCallbacks, options: TextStreamOptions = {}) {
const url = buildApiUrl(path); const url = buildApiUrl(path);
const token = getAuthToken(); const token = getAuthToken();
// 生成请求ID用于追踪和取消
const requestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const requestHeaders: Record<string, string> = { const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-Id': requestId,
...(options.headers || {}), ...(options.headers || {}),
}; };
if (token) { if (token) {
@@ -227,7 +232,7 @@ export function postTextStream(path: string, body: any, callbacks: TextStreamCal
} }
} }
return { abort }; return { abort, requestId };
} }