feat: 扩展饮食记录确认流程,支持选择选项和响应处理

- 在教练页面中新增AI选择选项和食物确认选项的数据结构
- 扩展消息结构以支持选择选项和交互类型
- 实现非流式JSON响应的处理逻辑,支持用户确认选择
- 添加选择选项的UI组件,提升用户交互体验
- 更新样式以适应新功能,确保视觉一致性
This commit is contained in:
richarjiang
2025-08-18 17:29:19 +08:00
parent f8730a90e9
commit 05a00236bc
3 changed files with 507 additions and 11 deletions

View File

@@ -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 = {