feat(ai-nutritionist): 图片与文字合并为一次 KieAI 多模态请求

- 后端: buildGeminiRequestBody 支持 content[] 中 Map 形式的多模态项
- 前端: sendMessage 将多图+文字合并为一条 content 数组,一次 sendToAI(multimodal)
- 仅发图时补默认文案「请描述或分析这张图片」,统一走 KieAI

Made-with: Cursor
This commit is contained in:
2026-03-03 00:36:28 +08:00
parent 51d2016988
commit 1ddb051977
2 changed files with 186 additions and 97 deletions

View File

@@ -409,9 +409,9 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
List<?> list = (List<?>) content; List<?> list = (List<?>) content;
List<Map<String, Object>> parts = new ArrayList<>(); List<Map<String, Object>> parts = new ArrayList<>();
for (Object item : list) { for (Object item : list) {
Map<String, Object> part = new HashMap<>();
if (item instanceof KieAIGeminiChatRequest.ContentItem) { if (item instanceof KieAIGeminiChatRequest.ContentItem) {
KieAIGeminiChatRequest.ContentItem ci = (KieAIGeminiChatRequest.ContentItem) item; KieAIGeminiChatRequest.ContentItem ci = (KieAIGeminiChatRequest.ContentItem) item;
Map<String, Object> part = new HashMap<>();
part.put("type", ci.getType()); part.put("type", ci.getType());
if ("text".equals(ci.getType())) { if ("text".equals(ci.getType())) {
part.put("text", ci.getText()); part.put("text", ci.getText());
@@ -421,6 +421,27 @@ public class ToolKieAIServiceImpl implements ToolKieAIService {
part.put("image_url", iu); part.put("image_url", iu);
} }
parts.add(part); parts.add(part);
} else if (item instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) item;
String t = (String) map.get("type");
if ("text".equals(t)) {
part.put("type", "text");
part.put("text", map.get("text"));
parts.add(part);
} else if ("image_url".equals(t)) {
Object iu = map.get("image_url");
if (iu instanceof Map) {
String url = (String) ((Map<?, ?>) iu).get("url");
if (url != null) {
part.put("type", "image_url");
Map<String, Object> iuOut = new HashMap<>();
iuOut.put("url", url);
part.put("image_url", iuOut);
parts.add(part);
}
}
}
} }
} }
m.put("content", parts); m.put("content", parts);

View File

@@ -10,9 +10,15 @@
<text class="promo-sparkle"></text> <text class="promo-sparkle"></text>
</view> </view>
<text class="promo-subtitle">营养师专家入驻在线答疑</text> <text class="promo-subtitle">营养师专家入驻在线答疑</text>
</view>
<view class="promo-right">
<view class="clear-btn" @click="clearChat">
<text class="clear-icon">🗑</text>
<text class="clear-text">清空</text>
</view>
<image class="promo-avatar" src="https://uthink2025.oss-cn-shanghai.aliyuncs.com//crmebimage/public/content/2026/01/11/afcaba68d00b4fccaa49ad2a42c78e7fkk03hqv5vl.png" mode="aspectFit"></image>
</view>
</view> </view>
<image class="promo-avatar" src="https://uthink2025.oss-cn-shanghai.aliyuncs.com//crmebimage/public/content/2026/01/11/afcaba68d00b4fccaa49ad2a42c78e7fkk03hqv5vl.png" mode="aspectFit"></image>
</view>
</view> </view>
<!-- 聊天消息列表 --> <!-- 聊天消息列表 -->
@@ -36,7 +42,7 @@
<text class="message-text">{{ welcomeMessage }}</text> <text class="message-text">{{ welcomeMessage }}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 消息列表 --> <!-- 消息列表 -->
<view <view
@@ -67,7 +73,7 @@
></image> ></image>
</view> </view>
</view> </view>
</view> </view>
<!-- 加载中提示 --> <!-- 加载中提示 -->
<view v-if="isLoading" class="message-item ai-message"> <view v-if="isLoading" class="message-item ai-message">
@@ -86,18 +92,18 @@
</view> </view>
</view> </view>
</view> </view>
</view>
</view> </view>
</view>
</scroll-view> </scroll-view>
<!-- 快捷问题区域 --> <!-- 快捷问题区域 -->
<view class="quick-questions"> <view class="quick-questions">
<view class="quick-btn" @click="sendQuickQuestion('透析患者可以喝牛奶吗?')"> <view class="quick-btn" @click="sendQuickQuestion('透析患者可以喝牛奶吗?')">
<text>透析患者可以喝牛奶吗</text> <text>透析患者可以喝牛奶吗</text>
</view> </view>
<view class="quick-btn" @click="sendQuickQuestion('什么食物含磷比较低?')"> <view class="quick-btn" @click="sendQuickQuestion('什么食物含磷比较低?')">
<text>什么食物含磷比较低</text> <text>什么食物含磷比较低</text>
</view> </view>
</view> </view>
<!-- 输入区域 --> <!-- 输入区域 -->
@@ -109,24 +115,24 @@
<view class="delete-btn" @click="removeImage(index)"> <view class="delete-btn" @click="removeImage(index)">
<text class="delete-icon">×</text> <text class="delete-icon">×</text>
</view> </view>
</view> </view>
<!-- 继续上传按钮 --> <!-- 继续上传按钮 -->
<view class="preview-item add-btn" @click="chooseImage" v-if="pendingImages.length < 3"> <view class="preview-item add-btn" @click="chooseImage" v-if="pendingImages.length < 3">
<text class="add-icon">+</text> <text class="add-icon">+</text>
</view>
</view> </view>
</view>
<view class="input-wrapper"> <view class="input-wrapper">
<!-- 照片上传按钮 --> <!-- 照片上传按钮 -->
<view class="action-btn" @click="chooseImage"> <view class="action-btn" @click="chooseImage">
<image class="action-icon-svg" src="/static/images/icon-camera.svg" mode="aspectFit"></image> <image class="action-icon-svg" src="/static/images/icon-camera.svg" mode="aspectFit"></image>
</view> </view>
<!-- 语音切换按钮 --> <!-- 语音切换按钮 -->
<view class="action-btn" @click="toggleVoiceMode"> <view class="action-btn" @click="toggleVoiceMode">
<image v-if="!isVoiceMode" class="action-icon-svg mic" src="/static/images/icon-mic.svg" mode="aspectFit"></image> <image v-if="!isVoiceMode" class="action-icon-svg mic" src="/static/images/icon-mic.svg" mode="aspectFit"></image>
<text v-else class="action-icon"></text> <text v-else class="action-icon"></text>
</view> </view>
<!-- 文本输入框 --> <!-- 文本输入框 -->
<input <input
@@ -151,7 +157,7 @@
@touchend="stopRecord" @touchend="stopRecord"
> >
<text>{{ isRecording ? '松开 结束' : '按住 说话' }}</text> <text>{{ isRecording ? '松开 结束' : '按住 说话' }}</text>
</view> </view>
<!-- 发送按钮 --> <!-- 发送按钮 -->
<view <view
@@ -171,8 +177,8 @@
src="/static/images/icon-send.svg" src="/static/images/icon-send.svg"
mode="aspectFit" mode="aspectFit"
></image> ></image>
</view>
</view> </view>
</view>
</view> </view>
</view> </view>
</template> </template>
@@ -490,6 +496,23 @@ export default {
this.pendingImages.splice(index, 1); this.pendingImages.splice(index, 1);
}, },
/** 将本地图片转为 data URL用于 KieAI 多模态合并请求 */
readFileAsDataUrl(filePath) {
return new Promise((resolve, reject) => {
const fs = uni.getFileSystemManager();
const isJpeg = /\.(jpe?g|jfif)$/i.test(filePath);
fs.readFile({
filePath,
encoding: 'base64',
success: (res) => {
const mime = isJpeg ? 'image/jpeg' : 'image/png';
resolve(`data:${mime};base64,${res.data}`);
},
fail: reject
});
});
},
sendImageMessage(imageUrl, fileInfo) { sendImageMessage(imageUrl, fileInfo) {
// 该方法已废弃,逻辑合并到 sendMessage // 该方法已废弃,逻辑合并到 sendMessage
}, },
@@ -539,89 +562,106 @@ export default {
return return
} }
// 先处理图片消息
const imagesToSend = [...this.pendingImages]; const imagesToSend = [...this.pendingImages];
this.pendingImages = []; // 清空待发送图片 const text = this.inputText.trim();
this.pendingImages = [];
this.inputText = '';
// 先展示用户消息到界面
for (const img of imagesToSend) { for (const img of imagesToSend) {
// 添加用户图片消息到界面
this.messageList.push({ this.messageList.push({
role: 'user', role: 'user',
type: 'image', type: 'image',
imageUrl: img.path, imageUrl: img.path,
content: '[图片]' content: '[图片]'
}); });
}
// 发送图片给AI if (text) {
// 注意:这里我们不等待图片发送完成,而是让它并行处理, this.messageList.push({ role: 'user', content: text, type: 'text' });
// 或者如果需要严格顺序,可以 await this.sendToAI(...) }
// 但考虑到用户体验,我们先显示,后台发送 this.scrollToBottom();
await this.sendToAI(img.fileInfo || img.path, 'image');
// 合并为一次多模态请求:图片(base64) + 文字,统一走 KieAI Gemini
const contentParts = [];
try {
for (const img of imagesToSend) {
const dataUrl = await this.readFileAsDataUrl(img.path);
contentParts.push({ type: 'image_url', image_url: { url: dataUrl } });
}
if (text) {
contentParts.push({ type: 'text', text });
} else if (contentParts.length > 0) {
contentParts.push({ type: 'text', text: '请描述或分析这张图片' });
}
} catch (e) {
console.error('读取图片失败:', e);
this.messageList.push({ role: 'ai', content: '读取图片失败,请重试。' });
this.scrollToBottom();
return;
} }
// 处理文本消息 if (contentParts.length === 0) return;
if (this.inputText.trim()) { // 仅文字时走纯文字接口;否则走多模态(图+文)一次请求
const text = this.inputText.trim(); if (contentParts.length === 1 && contentParts[0].type === 'text') {
// 添加用户文本消息到界面 await this.sendToAI(contentParts[0].text, 'text');
const userMessage = { } else {
role: 'user', await this.sendToAI(contentParts, 'multimodal');
content: text,
type: 'text'
};
this.messageList.push(userMessage);
// 清空输入框
this.inputText = ''
// 滚动到底部
this.scrollToBottom()
await this.sendToAI(text, 'text');
} }
this.scrollToBottom(); this.scrollToBottom();
}, },
async sendToAI(content, type) { async sendToAI(content, type) {
// 显示加载中 this.isLoading = true;
this.isLoading = true
// 获取用户信息,提供 fallback
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
// 纯文字 或 多模态(图+文) 均走 KieAI Gemini一次请求
if (type === 'text' || type === 'multimodal') {
try {
const messages = [{ role: 'user', content: content }];
const response = await api.kieaiGeminiChat({ messages, stream: false });
this.isLoading = false;
if (response && response.code === 200 && response.data) {
const data = response.data;
const choice = data.choices && data.choices[0];
const text = choice && choice.message && (choice.message.content || choice.message.text);
const reply = (typeof text === 'string' ? text : (text && String(text))) || '未能获取到有效回复。';
this.messageList.push({ role: 'ai', content: reply });
} else {
const msg = (response && response.message) || '发起对话失败';
this.messageList.push({ role: 'ai', content: '请求失败:' + msg });
}
this.scrollToBottom();
} catch (error) {
console.error('KieAI 对话失败:', error);
this.isLoading = false;
const errMsg = (error && (error.message || error.msg)) || '请稍后再试';
this.messageList.push({ role: 'ai', content: '请求失败:' + errMsg });
this.scrollToBottom();
}
return;
}
// 仅图片且未合并(旧 Coze 路径,保留兼容)
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
try { try {
// 构建 Coze 接口参数
const messages = []; const messages = [];
if (type === 'text') { let fileInfo = content;
if (typeof fileInfo === 'string') {
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* 非JSON */ }
}
const fileId = (fileInfo && fileInfo.id) || (fileInfo && fileInfo.file_id) || '';
if (fileId) {
messages.push({ messages.push({
role: 'user', role: 'user',
type: 'question', content: JSON.stringify([{ type: 'image', file_id: fileId }]),
content: content, content_type: 'object_string'
});
} else {
messages.push({
role: 'user',
content: '我发送了一张图片,请帮我分析',
content_type: 'text' content_type: 'text'
}); });
} else if (type === 'image') {
// 解析 fileInfo可能是对象或 JSON 字符串
let fileInfo = content;
if (typeof fileInfo === 'string') {
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* 非JSON保持原值 */ }
}
const fileId = (fileInfo && fileInfo.id) || (fileInfo && fileInfo.file_id) || '';
if (fileId) {
messages.push({
role: 'user',
content: JSON.stringify([{ type: 'image', file_id: fileId }]),
content_type: 'object_string'
});
} else {
// 降级处理
messages.push({
role: 'user',
content: '我发送了一张图片,请帮我分析',
content_type: 'text'
});
}
} }
const requestData = { const requestData = {
botId: this.botId, botId: this.botId,
userId: userId, userId: userId,
@@ -629,25 +669,18 @@ export default {
stream: false, stream: false,
autoSaveHistory: true autoSaveHistory: true
}; };
// 如果有已存在的会话ID传入以保持多轮对话上下文 if (this.conversationId) requestData.conversationId = this.conversationId;
if (this.conversationId) {
requestData.conversationId = this.conversationId;
}
// 调用 Coze Chat 接口
const response = await api.cozeChat(requestData); const response = await api.cozeChat(requestData);
// 处理响应
// Coze 非流式返回会包含 data 字段,其中有 conversation_id 和 id (chat_id)
// 以及 status (created, in_progress, completed, failed, requires_action)
if (response && response.data) { if (response && response.data) {
console.log("====api.cozeChat response====", response.data); const chat = response.data.chat || response.data;
const { conversation_id, id: chat_id } = response.data.chat; const conversationId = chat.conversation_id || chat.conversationID || chat.conversationId;
// 存储会话ID用于后续多轮对话 const chatId = chat.id;
if (conversation_id) { if (conversationId && chatId) {
this.conversationId = conversation_id; this.conversationId = conversationId;
await this.pollChatStatus(conversationId, chatId);
} else {
throw new Error('发起对话失败未返回会话或对话ID');
} }
// 轮询查询对话状态
await this.pollChatStatus(conversation_id, chat_id);
} else { } else {
throw new Error('发起对话失败'); throw new Error('发起对话失败');
} }
@@ -656,7 +689,7 @@ export default {
this.isLoading = false; this.isLoading = false;
this.messageList.push({ this.messageList.push({
role: 'ai', role: 'ai',
content: type === 'text' ? this.getAIResponse(content) : '抱歉,处理您的请求时出现错误,请稍后再试。' content: '抱歉,处理您的请求时出现错误,请稍后再试。'
}); });
this.scrollToBottom(); this.scrollToBottom();
} }
@@ -686,7 +719,8 @@ export default {
if (res && res.data) { if (res && res.data) {
console.log("====api.cozeRetrieveChat response====", res.data); console.log("====api.cozeRetrieveChat response====", res.data);
const status = res.data.chat.status; const chatObj = res.data.chat || res.data;
const status = chatObj && chatObj.status;
if (status === 'completed') { if (status === 'completed') {
// 对话完成,获取消息详情 // 对话完成,获取消息详情
@@ -725,9 +759,10 @@ export default {
this.isLoading = false; this.isLoading = false;
console.log("====api.cozeMessageList response====", res.data); console.log("====api.cozeMessageList response====", res.data);
if (res && res.data && Array.isArray(res.data.messages)) { const rawMessages = res && res.data && (Array.isArray(res.data.messages) ? res.data.messages : (Array.isArray(res.data) ? res.data : null));
if (rawMessages && rawMessages.length > 0) {
// 过滤出 type='answer' 且 role='assistant' 的消息 // 过滤出 type='answer' 且 role='assistant' 的消息
const answerMsgs = res.data.messages.filter(msg => msg.role === 'assistant' && msg.type === 'answer'); const answerMsgs = rawMessages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
if (answerMsgs.length > 0) { if (answerMsgs.length > 0) {
// 可能有多条回复,依次显示 // 可能有多条回复,依次显示
@@ -739,7 +774,7 @@ export default {
} }
} else { } else {
// 尝试查找其他类型的回复 // 尝试查找其他类型的回复
const otherMsgs = res.data.messages.filter(msg => msg.role === 'assistant'); const otherMsgs = rawMessages.filter(msg => msg.role === 'assistant');
if (otherMsgs.length > 0) { if (otherMsgs.length > 0) {
for (const msg of otherMsgs) { for (const msg of otherMsgs) {
this.messageList.push({ this.messageList.push({
@@ -895,12 +930,45 @@ export default {
font-weight: 400; font-weight: 400;
} }
.promo-right {
display: flex;
align-items: center;
gap: 20rpx;
z-index: 1;
}
.clear-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.clear-btn:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.clear-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.clear-text {
font-size: 22rpx;
color: #ff6b35;
font-weight: 500;
}
.promo-avatar { .promo-avatar {
width: 180rpx; width: 180rpx;
height: 180rpx; height: 180rpx;
position: absolute;
right: 20rpx;
bottom: 0;
} }
/* 聊天容器 */ /* 聊天容器 */