feat: 扩展饮食记录确认流程,支持选择选项和响应处理
- 在教练页面中新增AI选择选项和食物确认选项的数据结构 - 扩展消息结构以支持选择选项和交互类型 - 实现非流式JSON响应的处理逻辑,支持用户确认选择 - 添加选择选项的UI组件,提升用户交互体验 - 更新样式以适应新功能,确保视觉一致性
This commit is contained in:
@@ -54,12 +54,52 @@ type MessageAttachment = {
|
|||||||
uploadError?: string; // 上传错误信息
|
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 = {
|
type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
content: string; // 文本内容
|
content: string; // 文本内容
|
||||||
attachments?: MessageAttachment[]; // 附件列表
|
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 [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 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 || {});
|
||||||
@@ -529,18 +570,22 @@ export default function CoachScreen() {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendStream(text: string, imageUrls: string[] = []) {
|
async function sendStreamWithConfirmation(text: string, selectedChoiceId: string, confirmationData: any) {
|
||||||
const tokenExists = !!getAuthToken();
|
// 发送确认选择的特殊请求
|
||||||
try { console.log('[AI_CHAT][ui] send start', { tokenExists, conversationId, textPreview: text.slice(0, 50), imageUrls }); } catch { }
|
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);
|
||||||
if (streamAbortRef.current) {
|
|
||||||
try { console.log('[AI_CHAT][ui] abort previous stream'); } catch { }
|
|
||||||
try { streamAbortRef.current.abort(); } catch { }
|
|
||||||
streamAbortRef.current = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送 body:尽量提供历史消息,后端会优先使用 conversationId 关联上下文
|
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 = {
|
||||||
@@ -550,6 +595,20 @@ export default function CoachScreen() {
|
|||||||
stream: true,
|
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 中先放置占位回答,随后持续增量更新
|
// 在 UI 中先放置占位回答,随后持续增量更新
|
||||||
const assistantId = `a_${Date.now()}`;
|
const assistantId = `a_${Date.now()}`;
|
||||||
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
const userMsgId = `u_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
@@ -574,6 +633,54 @@ export default function CoachScreen() {
|
|||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
setIsStreaming(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;
|
let receivedAnyChunk = false;
|
||||||
|
|
||||||
const updateAssistantContent = (delta: string) => {
|
const updateAssistantContent = (delta: string) => {
|
||||||
@@ -591,6 +698,42 @@ export default function CoachScreen() {
|
|||||||
const onChunk = (chunk: string) => {
|
const onChunk = (chunk: string) => {
|
||||||
receivedAnyChunk = true;
|
receivedAnyChunk = true;
|
||||||
const atBottomNow = isAtBottom;
|
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);
|
updateAssistantContent(chunk);
|
||||||
if (atBottomNow) {
|
if (atBottomNow) {
|
||||||
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 onContentSizeChange 的情况
|
// 在底部时,持续开启自动滚动,并主动触发一次滚动以避免极小增量未触发 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 (
|
return (
|
||||||
<Markdown style={markdownStyles} mergeStyle>
|
<Markdown style={markdownStyles} mergeStyle>
|
||||||
{item.content || ''}
|
{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 (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
{/* 顶部标题区域,显示教练名称、新建会话和历史按钮 */}
|
||||||
@@ -1873,6 +2073,47 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#192126',
|
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 = {
|
const markdownStyles = {
|
||||||
|
|||||||
250
docs/food-confirmation-implementation.md
Normal file
250
docs/food-confirmation-implementation.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 饮食记录确认流程客户端实现
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
已完成对客户端的改动,支持饮食记录确认流程的新API结构。实现了对非流式JSON响应的处理,能够正确显示食物选择选项,并支持用户确认选择。
|
||||||
|
|
||||||
|
## 主要改动
|
||||||
|
|
||||||
|
### 1. 数据结构扩展
|
||||||
|
|
||||||
|
在 `app/(tabs)/coach.tsx` 中添加了新的类型定义:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 消息结构扩展
|
||||||
|
|
||||||
|
扩展了 `ChatMessage` 类型以支持新的字段:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
content: string;
|
||||||
|
attachments?: MessageAttachment[];
|
||||||
|
choices?: AiChoiceOption[]; // 新增:选择选项
|
||||||
|
interactionType?: string; // 新增:交互类型
|
||||||
|
pendingData?: any; // 新增:待确认数据
|
||||||
|
context?: any; // 新增:上下文信息
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 响应处理逻辑
|
||||||
|
|
||||||
|
#### 支持非流式JSON响应
|
||||||
|
|
||||||
|
修改了 `sendRequestInternal` 函数以检测和处理非流式JSON响应:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 如果是非流式请求,直接调用API并处理响应
|
||||||
|
if (!body.stream) {
|
||||||
|
try {
|
||||||
|
const response = await api.post<{ conversationId?: string; data?: AiResponseData; text?: string }>('/api/ai-coach/chat', body);
|
||||||
|
|
||||||
|
// 处理响应
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
// ... 更新消息状态
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 流式响应中的JSON检测
|
||||||
|
|
||||||
|
在流式响应处理中添加了JSON检测逻辑:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const onChunk = (chunk: string) => {
|
||||||
|
// 尝试解析是否为JSON结构化数据(可能是确认选项)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(chunk);
|
||||||
|
if (parsed && parsed.data && parsed.data.choices) {
|
||||||
|
// 处理结构化响应(包含选择选项)
|
||||||
|
// ... 创建带选择选项的消息
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 不是JSON,继续作为普通文本处理
|
||||||
|
}
|
||||||
|
// ... 正常的文本流处理
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 选择选项UI组件
|
||||||
|
|
||||||
|
在 `renderBubbleContent` 函数中添加了选择选项的渲染逻辑:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 检查是否有选择选项需要显示
|
||||||
|
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}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 选择确认逻辑
|
||||||
|
|
||||||
|
实现了 `handleChoiceSelection` 函数处理用户选择:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function handleChoiceSelection(choice: AiChoiceOption, message: ChatMessage) {
|
||||||
|
try {
|
||||||
|
// 构建确认请求
|
||||||
|
const confirmationText = `我选择记录${choice.label}`;
|
||||||
|
|
||||||
|
// 发送确认消息,包含选择的数据
|
||||||
|
await sendStreamWithConfirmation(confirmationText, choice.id, {
|
||||||
|
selectedOption: choice.value,
|
||||||
|
imageUrl: message.pendingData?.imageUrl
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert('选择失败', e?.message || '选择失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 确认数据发送
|
||||||
|
|
||||||
|
添加了 `sendStreamWithConfirmation` 函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. UI样式
|
||||||
|
|
||||||
|
添加了选择选项相关的样式:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
// ... 其他相关样式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. **用户发送饮食图片**:用户点击"#记饮食"并上传食物图片
|
||||||
|
2. **AI识别返回选项**:服务器返回食物识别结果和确认选项(非流式JSON)
|
||||||
|
3. **显示选择选项**:客户端渲染食物选择选项,推荐项目带有特殊标识
|
||||||
|
4. **用户选择确认**:用户点击某个选项
|
||||||
|
5. **发送确认数据**:客户端发送确认请求,包含选择的食物数据
|
||||||
|
6. **记录完成**:服务器记录饮食数据并返回确认消息
|
||||||
|
|
||||||
|
## 兼容性
|
||||||
|
|
||||||
|
- 保持对现有流式文本响应的完全兼容
|
||||||
|
- 自动检测响应类型(JSON vs 文本流)
|
||||||
|
- 旧的饮食记录方式(文字输入)继续正常工作
|
||||||
|
- 缓存和会话管理支持新的消息结构
|
||||||
|
|
||||||
|
## 技术要点
|
||||||
|
|
||||||
|
1. **响应类型检测**:客户端能够自动识别JSON结构化响应和普通文本流
|
||||||
|
2. **状态管理**:新增状态正确地保存到本地缓存
|
||||||
|
3. **用户体验**:选择选项有清晰的视觉反馈,推荐选项突出显示
|
||||||
|
4. **错误处理**:完整的错误处理机制,确保用户体验流畅
|
||||||
|
5. **性能优化**:避免不必要的重渲染,保持界面响应性
|
||||||
|
|
||||||
|
这个实现完全符合API文档的要求,支持饮食记录的两阶段确认流程,并保持了与现有功能的兼容性。
|
||||||
@@ -4,6 +4,11 @@ export type AiCoachChatMessage = {
|
|||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
attachments?: any[];
|
||||||
|
choices?: any[];
|
||||||
|
interactionType?: string;
|
||||||
|
pendingData?: any;
|
||||||
|
context?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiCoachSessionCache = {
|
export type AiCoachSessionCache = {
|
||||||
|
|||||||
Reference in New Issue
Block a user