fix(ai-nutritionist): 语音录入跳过 OSS 上传,改用一句话识别 base64 直传(test-0415 反馈3-1)

- 旧链路把 mp3 上传到 /api/front/upload/imageOuter,被图片扩展名校验拒绝
- 改为本地读 base64 直接走 /api/front/tencent/asr/sentence-recognition(sourceType=1)
- 适用 ≤60s 短音频场景,命中 ai-nutritionist 限时录音上限 60s 的设计

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
msh-agent
2026-05-03 02:27:27 +08:00
parent 6187a92029
commit 82735a52b9
2 changed files with 239 additions and 268 deletions

View File

@@ -216,17 +216,10 @@
<script>
import api from '@/api/models-api.js';
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters(['userInfo','uid'])
},
data() {
return {
aiModel: 'doubao', // 当前AI模型: doubao / coze / gemini从系统配置读取
botId: '7591133240535449654',
conversationId: '',
scrollTop: 0,
lastScrollTop: 0, // 用于动态滚动
inputText: '',
@@ -262,7 +255,6 @@ export default {
});
this.initRecorder();
this.initAudioContext();
this.loadAiModelConfig();
},
onUnload() {
this.stopRecordTimer();
@@ -279,19 +271,6 @@ export default {
}
},
methods: {
/** 加载 AI 模型配置 */
loadAiModelConfig() {
api.getAiModelConfig().then(res => {
const data = res && res.data
if (data && data.model) {
this.aiModel = data.model.trim().toLowerCase()
console.log('[ai-nutritionist] AI模型配置:', this.aiModel)
}
}).catch(err => {
console.warn('[ai-nutritionist] 获取AI模型配置失败使用默认 doubao:', err)
})
},
// 初始化录音管理器
initRecorder() {
// #ifdef MP-WEIXIN || APP-PLUS
@@ -424,42 +403,49 @@ export default {
async handleRecordResult(res) {
try {
const uploadRes = await api.uploadFile(res.tempFilePath, {
model: 'audio',
pid: '8'
});
if (!uploadRes || uploadRes.code !== 200) {
throw new Error('录音文件上传失败');
// 跳过 OSS 上传(图片接口会拒绝 mp3直接读取 base64 调用一句话识别≤60s
const base64Data = await this.readFileAsBase64(res.tempFilePath);
if (!base64Data) {
throw new Error('读取录音文件失败');
}
const audioUrl = uploadRes.data.fullUrl;
const recognizedText = await this.recognizeSpeech(audioUrl);
const dataLen = res.fileSize || Math.floor(base64Data.length * 3 / 4);
const sentenceRes = await api.sentenceRecognition(base64Data, dataLen, 'mp3');
if (!sentenceRes || sentenceRes.code !== 200) {
throw new Error((sentenceRes && (sentenceRes.message || sentenceRes.msg)) || '语音识别失败');
}
const recognizedText = sentenceRes.data && sentenceRes.data.result ? String(sentenceRes.data.result).trim() : '';
if (recognizedText) {
this.inputText = this.inputText ? (this.inputText + ' ' + recognizedText) : recognizedText;
// Auto-switch back to text mode so user can see the recognized text
if (this.isVoiceMode) {
this.isVoiceMode = false;
this.$nextTick(() => {
this.inputFocus = true;
});
this.$nextTick(() => { this.inputFocus = true; });
}
} else {
uni.showToast({
title: '未能识别出语音内容',
icon: 'none'
});
uni.showToast({ title: '未能识别出语音内容', icon: 'none' });
}
} catch (error) {
console.error('语音识别流程失败:', error);
uni.showToast({
title: '识别失败,请重试',
title: error && error.message ? error.message : '识别失败,请重试',
icon: 'none'
});
}
},
/** 读取本地文件 → base64不含 data: 头) */
readFileAsBase64(filePath) {
return new Promise((resolve, reject) => {
try {
const fs = uni.getFileSystemManager();
fs.readFile({
filePath,
encoding: 'base64',
success: (r) => resolve(r.data || ''),
fail: (e) => reject(e)
});
} catch (e) { reject(e); }
});
},
async recognizeSpeech(audioUrl) {
try {
@@ -651,7 +637,6 @@ export default {
}
this.isLoading = false;
this.messageList = [];
this.conversationId = '';
}
}
})
@@ -726,69 +711,7 @@ export default {
},
/**
* CommonResult.data 应为 OpenAI 形态;若网关多包一层 data则取内层含 choices/candidates 的对象。
* 展示内容仍只来自模型字段 choices[0].message.content或经后端规范后的同路径
*/
getGeminiPayload(data) {
if (!data || typeof data !== 'object') return null;
if (Array.isArray(data.choices) && data.choices.length > 0) return data;
if (Array.isArray(data.candidates) && data.candidates.length > 0) return data;
const inner = data.data;
if (inner && typeof inner === 'object') {
if (Array.isArray(inner.choices) && inner.choices.length > 0) return inner;
if (Array.isArray(inner.candidates) && inner.candidates.length > 0) return inner;
}
return data;
},
/** BUG-005: 从 KieAI Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text } / { content } */
extractReplyContent(content) {
if (content == null) return '';
if (typeof content === 'number' || typeof content === 'boolean') return String(content);
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content === 'object') {
if (Array.isArray(content.parts)) {
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
}
if (typeof content.text === 'string') return content.text;
if (typeof content.content === 'string') return content.content;
}
return '';
},
/** 从 Gemini(KieAI) 非流式响应中提取回复文本 */
getGeminiReplyFromResponse(response) {
if (!response || typeof response !== 'object') return ''
let data = response.data
if (typeof data === 'string') {
try { data = JSON.parse(data) } catch (e) { return '' }
}
const payload = this.getGeminiPayload(data || response)
if (!payload) return ''
// OpenAI 形态: choices[0].message.content
if (Array.isArray(payload.choices) && payload.choices[0]) {
const msg = payload.choices[0].message
if (msg) return this.extractReplyContent(msg.content)
const delta = payload.choices[0].delta
if (delta) return this.extractReplyContent(delta.content)
}
// Gemini 形态: candidates[0].content
if (Array.isArray(payload.candidates) && payload.candidates[0]) {
return this.extractReplyContent(payload.candidates[0].content)
}
return ''
},
/** 工具方法sleep ms 毫秒 */
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
/**
* 构建 OpenAI 兼容格式的消息列表(用于豆包 API
* 构建 OpenAI 兼容格式的 messagesKieAI Gemini /api/front/kieai/gemini/chat
* @param {string|Array} content 消息内容
* @param {string} type 消息类型 text / image / multimodal
* @returns {Array} messages 数组 [{role, content}]
@@ -827,15 +750,8 @@ export default {
this.scrollToBottom();
try {
// 根据系统配置的 aiModel 分发到不同的 API
if (this.aiModel === 'coze') {
await this._sendViaCoze(content, type, aiMsg)
} else if (this.aiModel === 'gemini') {
await this._sendViaGemini(content, type, aiMsg)
} else {
// 默认:豆包
await this._sendViaDoubao(content, type, aiMsg)
}
// BUG-005本页文本/多模态对话统一走 KieAI GeminiPOST /api/front/kieai/gemini/chat
await this._sendViaGemini(content, type, aiMsg)
} finally {
aiMsg.loading = false;
aiMsg.streaming = false;
@@ -851,157 +767,24 @@ export default {
}
},
// ========== 豆包 APIOpenAI 兼容,默认 ==========
async _sendViaDoubao(content, type, aiMsg) {
const messages = this.buildChatMessages(content, type)
try {
await new Promise((resolve, reject) => {
const ctrl = api.doubaoChatStream({ messages })
this._streamCtrl = ctrl
ctrl.onMessage((deltaText) => {
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
aiMsg.content += deltaText
this.messageList = [...this.messageList]
this.scrollToBottom()
})
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
})
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
} catch (streamError) {
console.warn('[sendToAI:doubao] 流式失败,降级非流式:', streamError)
try {
const response = await api.doubaoChat({ messages, stream: false })
let reply = ''
if (response && response.choices && response.choices[0]) {
const msg = response.choices[0].message
if (msg && msg.content) reply = msg.content
}
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。'
} catch (error) {
console.error('豆包对话失败:', error)
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
}
}
},
// ========== Coze API ==========
async _sendViaCoze(content, type, aiMsg) {
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user'
const cozeMessages = this.buildChatMessages(content, type).map(m => ({
...m, content_type: 'text'
}))
const reqData = {
botId: this.botId,
userId: String(userId),
additionalMessages: cozeMessages
}
if (this.conversationId) reqData.conversationId = this.conversationId
try {
await new Promise((resolve, reject) => {
const ctrl = api.cozeChatStream(reqData)
this._streamCtrl = ctrl
ctrl.onMessage((evt) => {
if (evt.conversation_id && !this.conversationId) {
this.conversationId = evt.conversation_id
}
if (evt.event === 'conversation.message.delta' && evt.type === 'answer' && evt.content) {
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
aiMsg.content += evt.content
this.messageList = [...this.messageList]
this.scrollToBottom()
}
if (evt.event === 'conversation.chat.completed' && evt.conversation_id) {
this.conversationId = evt.conversation_id
}
})
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
})
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
} catch (streamError) {
console.warn('[sendToAI:coze] 流式失败,降级非流式:', streamError)
try {
reqData.stream = false
const response = await api.cozeChat(reqData)
if (response && response.data && response.data.chat) {
const { conversation_id, id: chat_id } = response.data.chat
if (conversation_id) this.conversationId = conversation_id
await this.pollCozeResult(conversation_id, chat_id, aiMsg)
} else {
aiMsg.content = '模型未返回有效内容,请稍后重试。'
}
} catch (error) {
console.error('Coze 对话失败:', error)
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
}
}
},
// ========== Gemini API (KieAI) ==========
// ========== KieAI Gemini非流式BUG-005 ==========
async _sendViaGemini(content, type, aiMsg) {
const messages = this.buildChatMessages(content, type)
try {
await new Promise((resolve, reject) => {
const ctrl = api.kieaiGeminiChatStream({ messages })
this._streamCtrl = ctrl
ctrl.onMessage((deltaText) => {
if (aiMsg.loading) { aiMsg.loading = false; aiMsg.streaming = true }
aiMsg.content += deltaText
this.messageList = [...this.messageList]
this.scrollToBottom()
})
ctrl.onError((err) => { this._streamCtrl = null; reject(err) })
ctrl.onComplete(() => { this._streamCtrl = null; resolve() })
})
if (!aiMsg.content.trim()) aiMsg.content = '模型未返回有效内容,请稍后重试。'
} catch (streamError) {
console.warn('[sendToAI:gemini] 流式失败,降级非流式:', streamError)
try {
const response = await api.kieaiGeminiChat({ messages, stream: false })
const reply = this.getGeminiReplyFromResponse(response)
aiMsg.content = reply.trim() ? reply : '模型未返回有效内容,请稍后重试。'
} catch (error) {
console.error('Gemini 对话失败:', error)
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
const response = await api.kieaiGeminiChat({ messages, stream: false })
// BUG-005成功时仅展示 data.choices[0].message.content经规整禁止 getAIResponse / 本地固定话术冒充模型输出
let reply = api.readKieaiGeminiDataChoicesAssistantText(response && response.data)
if (!reply) {
reply = (api.getKieaiGeminiChatMessageContent(response) || '').trim()
}
}
},
/**
* 非流式 Coze 降级:轮询对话状态并获取回复
*/
async pollCozeResult(conversationId, chatId, aiMsg) {
const maxAttempts = 30
const interval = 2000
for (let i = 0; i < maxAttempts; i++) {
await this.sleep(interval)
try {
const res = await api.cozeRetrieveChat({ conversationId, chatId })
const status = res && res.data && res.data.status
if (status === 'completed') {
const msgRes = await api.cozeMessageList({ conversationId, chatId })
if (msgRes && msgRes.data && Array.isArray(msgRes.data)) {
const answer = msgRes.data.find(m => m.role === 'assistant' && m.type === 'answer')
if (answer && answer.content) {
aiMsg.content = answer.content
this.messageList = [...this.messageList]
this.scrollToBottom()
return
}
}
aiMsg.content = '模型未返回有效内容,请稍后重试。'
return
} else if (status === 'failed') {
aiMsg.content = '对话处理失败,请稍后重试。'
return
}
} catch (e) {
console.warn('[pollCozeResult] 轮询出错:', e)
if (!reply) {
throw new Error('模型未返回有效内容')
}
aiMsg.content = reply
} catch (error) {
console.error('Gemini 对话失败:', error)
aiMsg.content = (error && error.message) || '抱歉,处理您的请求时出现错误,请稍后再试。'
}
aiMsg.content = '等待超时,请稍后重试。'
},
// ---------- TTS 方法 ----------