feat: 扩展饮食记录确认流程,支持选择选项和响应处理
- 在教练页面中新增AI选择选项和食物确认选项的数据结构 - 扩展消息结构以支持选择选项和交互类型 - 实现非流式JSON响应的处理逻辑,支持用户确认选择 - 添加选择选项的UI组件,提升用户交互体验 - 更新样式以适应新功能,确保视觉一致性
This commit is contained in:
@@ -54,12 +54,52 @@ type MessageAttachment = {
|
||||
uploadError?: string; // 上传错误信息
|
||||
};
|
||||
|
||||
// AI选择选项数据结构
|
||||
type AiChoiceOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: any;
|
||||
recommended?: boolean;
|
||||
};
|
||||
|
||||
// 餐次类型
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
// 食物确认选项数据结构
|
||||
type FoodConfirmationOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
foodName: string;
|
||||
portion: string;
|
||||
calories: number;
|
||||
mealType: MealType;
|
||||
nutritionData: {
|
||||
proteinGrams?: number;
|
||||
carbohydrateGrams?: number;
|
||||
fatGrams?: number;
|
||||
fiberGrams?: number;
|
||||
};
|
||||
};
|
||||
|
||||
// AI响应数据结构
|
||||
type AiResponseData = {
|
||||
content: string;
|
||||
choices?: AiChoiceOption[];
|
||||
interactionType?: 'text' | 'food_confirmation' | 'selection';
|
||||
pendingData?: any;
|
||||
context?: any;
|
||||
};
|
||||
|
||||
// 重构后的消息数据结构
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: Role;
|
||||
content: string; // 文本内容
|
||||
attachments?: MessageAttachment[]; // 附件列表
|
||||
choices?: AiChoiceOption[]; // 选择选项(仅用于assistant消息)
|
||||
interactionType?: string; // 交互类型
|
||||
pendingData?: any; // 待确认数据
|
||||
context?: any; // 上下文信息
|
||||
};
|
||||
|
||||
// 卡片类型常量定义
|
||||
@@ -115,6 +155,7 @@ export default function CoachScreen() {
|
||||
const [weightInputs, setWeightInputs] = useState<Record<string, string>>({});
|
||||
const [showDietPhotoActionSheet, setShowDietPhotoActionSheet] = useState(false);
|
||||
const [currentCardId, setCurrentCardId] = useState<string | null>(null);
|
||||
const [pendingChoiceData, setPendingChoiceData] = useState<Record<string, any>>({});
|
||||
|
||||
const planDraft = useAppSelector((s) => s.trainingPlan?.draft);
|
||||
const checkin = useAppSelector((s) => s.checkin || {});
|
||||
@@ -529,18 +570,22 @@ export default function CoachScreen() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) {
|
||||
// 发送确认选择的特殊请求
|
||||
const historyForServer = convertToServerMessages(messages);
|
||||
const cid = ensureConversationId();
|
||||
const body = {
|
||||
conversationId: cid,
|
||||
messages: [...historyForServer, { role: 'user' as const, content: text }],
|
||||
selectedChoiceId,
|
||||
confirmationData,
|
||||
stream: false, // 确认阶段使用非流式
|
||||
};
|
||||
|
||||
await sendRequestInternal(body, text);
|
||||
}
|
||||
|
||||
async function sendStream(text: string, imageUrls: string[] = []) {
|
||||
const tokenExists = !!getAuthToken();
|
||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls }); } catch { }
|
||||
|
||||
// 终止上一次未完成的流
|
||||
if (streamAbortRef.current) {
|
||||
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
|
||||
try { streamAbortRef.current.abort(); } catch { }
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
|
||||
// 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文
|
||||
const historyForServer = convertToServerMessages(messages);
|
||||
const cid = ensureConversationId();
|
||||
const body = {
|
||||
@@ -550,6 +595,20 @@ export default function CoachScreen() {
|
||||
stream: true,
|
||||
};
|
||||
|
||||
await sendRequestInternal(body, text, imageUrls);
|
||||
}
|
||||
|
||||
async function sendRequestInternal(body: any, text: string, imageUrls: string[] = []) {
|
||||
const tokenExists = !!getAuthToken();
|
||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls, stream: body.stream }); } catch { }
|
||||
|
||||
// 终止上一次未完成的流
|
||||
if (streamAbortRef.current) {
|
||||
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
|
||||
try { streamAbortRef.current.abort(); } catch { }
|
||||
streamAbortRef.current = null;
|
||||
}
|
||||
|
||||
// 在 UI 中先放置占位回答,随后持续增量更新
|
||||
const assistantId = `a_${Date.now()}`;
|
||||
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -574,6 +633,54 @@ export default function CoachScreen() {
|
||||
setIsSending(true);
|
||||
setIsStreaming(true);
|
||||
|
||||
// 如果是非流式请求,直接调用API并处理响应
|
||||
if (!body.stream) {
|
||||
try {
|
||||
const response = await api.post<{ conversationId?: string; data?: AiResponseData; text?: string }>('/api/ai-coach/chat', body);
|
||||
|
||||
setIsSending(false);
|
||||
setIsStreaming(false);
|
||||
|
||||
// 处理响应
|
||||
if (response.data) {
|
||||
// 结构化响应(可能包含选择选项)
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: response.data.content,
|
||||
choices: response.data.choices,
|
||||
interactionType: response.data.interactionType,
|
||||
pendingData: response.data.pendingData,
|
||||
context: response.data.context,
|
||||
};
|
||||
|
||||
setMessages((prev) => prev.map((msg) =>
|
||||
msg.id === assistantId ? assistantMsg : msg
|
||||
));
|
||||
} else if (response.text) {
|
||||
// 简单文本响应
|
||||
setMessages((prev) => prev.map((msg) =>
|
||||
msg.id === assistantId ? { ...msg, content: response.text || '(空响应)' } : msg
|
||||
));
|
||||
}
|
||||
|
||||
if (response.conversationId && !conversationId) {
|
||||
setConversationId(response.conversationId);
|
||||
}
|
||||
|
||||
pendingAssistantIdRef.current = null;
|
||||
return;
|
||||
} catch (e: any) {
|
||||
setIsSending(false);
|
||||
setIsStreaming(false);
|
||||
pendingAssistantIdRef.current = null;
|
||||
setMessages((prev) => prev.map((msg) =>
|
||||
msg.id === assistantId ? { ...msg, content: '抱歉,请求失败,请稍后再试。' } : msg
|
||||
));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
let receivedAnyChunk = false;
|
||||
|
||||
const updateAssistantContent = (delta: string) => {
|
||||
@@ -591,6 +698,42 @@ export default function CoachScreen() {
|
||||
const onChunk = (chunk: string) => {
|
||||
receivedAnyChunk = true;
|
||||
const atBottomNow = isAtBottom;
|
||||
|
||||
// 尝试解析是否为JSON结构化数据(可能是确认选项)
|
||||
try {
|
||||
const parsed = JSON.parse(chunk);
|
||||
if (parsed && parsed.data && parsed.data.choices) {
|
||||
// 处理结构化响应(包含选择选项)
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: parsed.data.content,
|
||||
choices: parsed.data.choices,
|
||||
interactionType: parsed.data.interactionType,
|
||||
pendingData: parsed.data.pendingData,
|
||||
context: parsed.data.context,
|
||||
};
|
||||
|
||||
setMessages((prev) => prev.map((msg) =>
|
||||
msg.id === assistantId ? assistantMsg : msg
|
||||
));
|
||||
|
||||
// 处理conversationId
|
||||
if (parsed.conversationId && !conversationId) {
|
||||
setConversationId(parsed.conversationId);
|
||||
}
|
||||
|
||||
// 结束流式状态
|
||||
setIsSending(false);
|
||||
setIsStreaming(false);
|
||||
streamAbortRef.current = null;
|
||||
pendingAssistantIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 不是JSON,继续作为普通文本处理
|
||||
}
|
||||
|
||||
updateAssistantContent(chunk);
|
||||
if (atBottomNow) {
|
||||
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
|
||||
@@ -994,6 +1137,44 @@ export default function CoachScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否有选择选项需要显示
|
||||
if (item.choices && item.choices.length > 0 && item.interactionType === 'food_confirmation') {
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Markdown style={markdownStyles} mergeStyle>
|
||||
{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>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Markdown style={markdownStyles} mergeStyle>
|
||||
{item.content || ''}
|
||||
@@ -1168,6 +1349,25 @@ export default function CoachScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) {
|
||||
try {
|
||||
console.log('[CHOICE] Selection:', { choiceId: choice.id, messageId: message.id });
|
||||
|
||||
// 构建确认请求
|
||||
const confirmationText = `我选择记录${choice.label}`;
|
||||
|
||||
// 发送确认消息,包含选择的数据
|
||||
await sendStreamWithConfirmation(confirmationText, choice.id, {
|
||||
selectedOption: choice.value,
|
||||
imageUrl: message.pendingData?.imageUrl
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
console.error('[CHOICE] Selection failed:', e);
|
||||
Alert.alert('选择失败', e?.message || '选择失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
||||
@@ -1873,6 +2073,47 @@ const styles = StyleSheet.create({
|
||||
fontSize: 14,
|
||||
color: '#192126',
|
||||
},
|
||||
// 选择选项相关样式
|
||||
choicesContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
choiceButton: {
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(187,242,70,0.3)',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
},
|
||||
choiceButtonRecommended: {
|
||||
borderColor: 'rgba(187,242,70,0.6)',
|
||||
backgroundColor: 'rgba(187,242,70,0.1)',
|
||||
},
|
||||
choiceContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
choiceLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#192126',
|
||||
flex: 1,
|
||||
},
|
||||
choiceLabelRecommended: {
|
||||
color: '#2D5016',
|
||||
},
|
||||
recommendedBadge: {
|
||||
backgroundColor: 'rgba(187,242,70,0.8)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
recommendedText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: '#2D5016',
|
||||
},
|
||||
});
|
||||
|
||||
const markdownStyles = {
|
||||
|
||||
Reference in New Issue
Block a user