feat: 实现聊天取消功能,提升用户交互体验
- 在教练页面中添加用户取消发送或终止回复的能力 - 更新发送按钮状态,支持发送和取消状态切换 - 在流式回复中显示取消按钮,允许用户中断助手的生成 - 增强请求管理,添加请求序列号和有效性验证,防止延迟响应影响用户体验 - 优化错误处理,区分用户主动取消和网络错误,提升系统稳定性 - 更新相关文档,详细描述取消功能的实现和用户体验设计
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user