feat(ai-nutritionist): Coze TTS and streaming robustness

- Add Coze TTS endpoint and service; expose binary MP3 from controller.
- Bypass ResponseFilter for /audio/speech so MP3 bodies are not UTF-8 wrapped.
- UniApp: cozeTextToSpeech, TTS UI and play flow; SSE HTTP errors and diagnostics.
- Document TTS in docs/features.md; extend test-0325-1 with curl verification.

Made-with: Cursor
This commit is contained in:
msh-agent
2026-03-31 07:07:21 +08:00
parent 35052d655f
commit 2facd355ab
8 changed files with 433 additions and 351 deletions

View File

@@ -2,41 +2,34 @@
## 页面pages/tool/ai-nutritionist
- 1. 请求后页面显示:"未能获取到有效回复。"
fetch("http://127.0.0.1:20822/api/front/coze/chat/stream", {
"headers": {
"accept": "*/*",
"authori-zation": "6f6767b2edc64949b0e4888c199ac0bb",
"content-type": "application/json",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site"
},
"referrer": "https://servicewechat.com/wx7ecf3e3699353c69/devtools/page-frame.html",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "{\"botId\":\"7591133240535449654\",\"userId\":11,\"additionalMessages\":[{\"role\":\"user\",\"content\":\"透析患者可以喝牛奶吗?\",\"content_type\":\"text\"}],\"stream\":true,\"autoSaveHistory\":true}",
"method": "POST",
"mode": "cors",
"credentials": "omit"
});
- 1. 请求Request URL: http://127.0.0.1:20822/api/front/coze/chat/stream, 参数:{"botId":"7591133240535449654","userId":11,"additionalMessages":[{"role":"user","content":"透析患者可以喝牛奶吗?","content_type":"text"}],"stream":true,"autoSaveHistory":true},请求后页面显示:"未能获取到有效回复。"
## 修复记录
### 问题 1 修复:流式对话显示"未能获取到有效回复"
### 1. 流式对话显示"未能获取到有效回复"
**根因分析**:两个问题导致前端无法正确接收流式数据
**根因分析**(三个层次)
1. **Delta 事件过滤条件过严**`ai-nutritionist.vue``sendToAIStream()` `conversation.message.delta` 事件要求 `evt.role === 'assistant' && evt.type === 'answer'`。但 Coze SDK 在流式增量事件中可能不返回 `role``type` 字段(后端发送的精简 JSON 仅在字段非 null 时才包含),导致所有增量内容被静默丢弃
1. **HTTP 状态码未检查**`cozeChatStream()` `success` 回调不检查 `res.statusCode`。后端发生异常时(如 `botID is marked non-null but is null`Spring `GlobalExceptionHandler` 返回 HTTP 500 + JSON 错误体,前端 `parseSseResponseBody` 找不到 `data:` 行后静默忽略,直接触发 `_onComplete()`,最终展示"未能获取到有效回复"
2. **未处理非分块响应降级**`cozeChatStream()` `success` 回调未处理响应体。在微信开发者工具或某些不支持 `onChunkReceived` 的环境下,流式数据仅在 `res.data` 中一次性返回,但被完全忽略
2. **SSE 解析错误全部静默吞噬**`parseSseLines()` / `parseSseResponseBody()` 的 catch 块为空JSON 解析失败时无任何输出,排查困难
3. **错误提示固定话术,掩盖真实原因**`onError` 回调展示固定文字"抱歉,处理您的请求时出现错误",而非后端实际错误信息。
**修复内容**
- `ai-nutritionist.vue`:将 delta 过滤改为 `const role = evt.role || 'assistant'`,缺失字段时默认为预期值
- `models-api.js`:增加 `_gotChunks` 标记,当 `onChunkReceived` 未触发时,在 `success` 回调中解析 `res.data` 作为降级处理;增加 `responseType: 'text'` 确保响应体为字符串
- `models-api.js``cozeChatStream()``success` 回调添加 `res.statusCode !== 200` 检查,提取后端 `message`/`msg` 字段作为错误信息调用 `_onError`;在 chunk 接收、SSE 解析、降级解析各环节添加 `console.log` / `console.warn` 诊断日志
- `ai-nutritionist.vue``sendToAIStream()``onError` 改为展示 `err.message` 而非固定话术
**后端独立验证命令**(用于确认 SSE 是否正常):
```bash
curl -N -X POST 'http://127.0.0.1:20822/api/front/coze/chat/stream' \
-H "Content-Type: application/json" \
-d '{"botId":"7591133240535449654","userId":"11","additionalMessages":[{"role":"user","content":"透析患者可以喝牛奶吗?"}]}'
```
若后端正常,应逐行输出 `data:{"event":"conversation.message.delta","content":"..."}` 流;若返回 `{"code":500,"message":"..."}` 则说明后端有问题需优先排查。
# 参考文档
- 3. /Users/a123/msh-system/.cursor/plans/optimize_ai_nutritionist_speed_b6e9a618.plan.md
- 1. /Users/a123/msh-system/docs/测试问题分析报告_2026-03-22.md
- 2. /Users/a123/msh-system/docs/功能开发详细设计_2026-03-25.md
- 1. **coze官方api文档**https://docs.coze.cn/developer_guides/chat_v3#AJThpr1GJe
- 2. /Users/a123/msh-system/.cursor/plans/optimize_ai_nutritionist_speed_b6e9a618.plan.md

32
docs/features.md Normal file
View File

@@ -0,0 +1,32 @@
# 新功能增加
## 页面pages/tool/ai-nutritionist
### 语音合成TTS✅ 已实现
- 增加"语音合成"功能,通过 Coze TTS API 将 AI 回复的文本内容合成为自然流畅的音频播放出来
- Header 区域增加 **播报开/关** 切换按钮(🔊),默认关闭
- 每条 AI 回复气泡底部有 **▶ 播放** / **⏹ 停止** 按钮,可单独播放任意一条消息
- 当语音播报开关打开时AI 回复完成后**自动播报**最新一条消息
- 音频由后端调用 Coze SDK `audio().speech().create()` 合成,以 MP3 格式返回,前端通过 `innerAudioContext` 播放
#### 涉及文件
| 文件 | 变更说明 |
|---|---|
| `msh_crmeb_22/.../ToolCozeService.java` | 新增 `textToSpeech` 接口方法 |
| `msh_crmeb_22/.../ToolCozeServiceImpl.java` | 实现 TTS调用 Coze SDK |
| `msh_crmeb_22/.../CozeController.java` | 新增 `POST /api/front/coze/audio/speech` 端点 |
| `msh_single_uniapp/api/models-api.js` | 新增 `cozeTextToSpeech()` 函数 |
| `msh_single_uniapp/pages/tool/ai-nutritionist.vue` | 集成 TTS 开关、播放按钮、自动播报逻辑 |
#### voiceId 说明
- 后端默认音色 ID`7468518753626652709`(中文女声,需确认可用性)
- 可通过调用 `client.audio().voices().list()` 获取平台所有可用音色
- 前端调用 `cozeTextToSpeech()` 时可传 `voiceId` 字段覆盖默认值
## 相关文档
api地址 https://docs.coze.cn/developer_guides/text_to_speech

View File

@@ -22,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.util.Map;
/**
* Coze API 控制器
@@ -132,4 +133,23 @@ public class CozeController {
public CozeBaseResponse<Object> workflowResume(@RequestBody CozeWorkflowResumeRequest request) {
return toolCozeService.workflowResume(request);
}
/**
* 文本转语音 (TTS)
*/
@ApiOperation(value = "文本转语音", notes = "调用 Coze TTS 将文本合成为 MP3 音频并直接返回二进制流")
@PostMapping("/audio/speech")
public void textToSpeech(@RequestBody Map<String, Object> params, HttpServletResponse response) throws IOException {
String input = (String) params.get("input");
String voiceId = (String) params.get("voiceId");
String format = params.get("format") != null ? (String) params.get("format") : "mp3";
Float speed = params.get("speed") != null ? ((Number) params.get("speed")).floatValue() : null;
byte[] audioData = toolCozeService.textToSpeech(input, voiceId, format, speed);
response.setContentType("audio/mpeg");
response.setHeader("Content-Disposition", "inline; filename=speech.mp3");
response.setContentLength(audioData.length);
response.getOutputStream().write(audioData);
response.getOutputStream().flush();
}
}

View File

@@ -27,6 +27,17 @@ public class ResponseFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String uri = httpRequest.getRequestURI();
String accept = httpRequest.getHeader("Accept");
// SSE 流式响应和二进制音频响应不能被缓冲,直接透传
boolean isSseStream = uri != null && uri.contains("/stream");
boolean acceptsSse = accept != null && accept.contains("text/event-stream");
boolean isAudioResponse = uri != null && uri.contains("/audio/speech");
if (isSseStream || acceptsSse || isAudioResponse) {
filterChain.doFilter(request, response);
return;
}
ResponseWrapper wrapperResponse = new ResponseWrapper((HttpServletResponse) response);//转换成代理类
// 这里只拦截返回,直接让请求过去,如果在请求前有处理,可以在这里处理
filterChain.doFilter(request, wrapperResponse);

View File

@@ -1,5 +1,8 @@
package com.zbkj.service.service.impl.tool;
import com.coze.openapi.client.audio.common.AudioFormat;
import com.coze.openapi.client.audio.speech.CreateSpeechReq;
import com.coze.openapi.client.audio.speech.CreateSpeechResp;
import com.coze.openapi.client.chat.CreateChatReq;
import com.coze.openapi.client.chat.CreateChatResp;
import com.coze.openapi.client.chat.RetrieveChatReq;
@@ -437,6 +440,27 @@ public class ToolCozeServiceImpl implements ToolCozeService {
}
}
private static final String DEFAULT_VOICE_ID = "7468518753626652709";
@Override
public byte[] textToSpeech(String input, String voiceId, String format, Float speed) {
try {
CozeAPI client = getClient();
AudioFormat audioFormat = (format != null) ? AudioFormat.fromString(format) : AudioFormat.MP3;
CreateSpeechReq req = CreateSpeechReq.builder()
.input(input)
.voiceID(voiceId != null ? voiceId : DEFAULT_VOICE_ID)
.responseFormat(audioFormat)
.speed(speed != null ? speed : 1.0f)
.build();
CreateSpeechResp resp = client.audio().speech().create(req);
return resp.getResponse().bytes();
} catch (Exception e) {
logger.error("Coze TTS error", e);
throw new RuntimeException("语音合成失败: " + e.getMessage(), e);
}
}
/**
* 获取访问令牌
*/

View File

@@ -79,4 +79,15 @@ public interface ToolCozeService {
* @return 恢复结果
*/
CozeBaseResponse<Object> workflowResume(CozeWorkflowResumeRequest request);
/**
* 文本转语音 (TTS)
*
* @param input 要合成的文本
* @param voiceId 音色ID为 null 时使用默认中文音色
* @param format 音频格式,如 "mp3",为 null 时默认 mp3
* @param speed 语速1.0 为正常速度,为 null 时使用默认值
* @return 音频二进制数据
*/
byte[] textToSpeech(String input, String voiceId, String format, Float speed);
}

View File

@@ -1,6 +1,8 @@
// API服务工具文件
// 统一访问 crmeb-front 项目,基地址来自 config/app.js
import { domain } from '@/config/app.js'
import { domain, TOKENNAME } from '@/config/app.js'
import store from '@/store'
const API_BASE_URL = domain
/**
@@ -11,12 +13,14 @@ const API_BASE_URL = domain
*/
function request(url, options = {}) {
return new Promise((resolve, reject) => {
const token = store.state && store.state.app && store.state.app.token
uni.request({
url: `${API_BASE_URL}${url}`,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
...(token ? { [TOKENNAME]: token } : {}),
...options.header
},
success: (res) => {
@@ -318,6 +322,33 @@ function queryAsrStatus(taskId) {
return request(`/api/front/tencent/asr/query-status/${taskId}`)
}
// ==================== KieAI Gemini ChatBUG-005AI 营养师文本/多模态对话) ====================
/**
* KieAI Gemini 对话
* POST /api/front/kieai/gemini/chat
* 文本对话请求体: { messages: [{ role: 'user', content: 用户输入 }], stream: false }
* 多模态时 content 可为 OpenAI 风格 parts 数组。
* 成功时 HTTP 体为 CommonResult模型结果为 data 字段OpenAI 形态data.choices[0].message.content
* 页面展示必须从该 content 读取,禁止用语义无关的本地固定话术冒充模型输出。
* @param {object} data 请求体
* @param {Array<{role: string, content: string|Array}>} data.messages 消息列表
* @param {boolean} [data.stream=false] 是否流式
* @returns {Promise<{code: number, data: {choices?: Array<{message?: {content?: unknown}}>}}>}
*/
function kieaiGeminiChat(data) {
const messages = data && data.messages
if (!messages || !Array.isArray(messages) || messages.length === 0) {
return Promise.reject(new Error('messages 不能为空'))
}
// BUG-005仅传 messages + stream文本对话为 { messages: [{ role: 'user', content: 用户输入 }], stream: false }
const stream = data && typeof data.stream === 'boolean' ? data.stream : false
return request('/api/front/kieai/gemini/chat', {
method: 'POST',
data: { messages, stream }
})
}
// ==================== 扣子Coze API ====================
/**
@@ -331,21 +362,6 @@ function queryAsrStatus(taskId) {
* @param {object} data.meta_data 元数据
* @returns {Promise} 对话响应
*/
/**
* KieAI Gemini 对话POST /api/front/kieai/gemini/chat
* 文本对话请求体: { messages: [{ role: 'user', content: 用户输入 }], stream: false }
* @param {object} data 请求体
* @param {Array} data.messages 消息列表 [{ role: 'user'|'assistant'|'system', content: string|Array }]
* @param {boolean} data.stream 是否流式,默认 false
* @returns {Promise} 响应 data 为 Gemini 格式 { choices: [{ message: { content } }] },回复取 data.choices[0].message.content
*/
function kieaiGeminiChat(data) {
return request('/api/front/kieai/gemini/chat', {
method: 'POST',
data: data
})
}
function cozeChat(data) {
return request('/api/front/coze/chat', {
method: 'POST',
@@ -390,14 +406,18 @@ function cozeChatStream(data) {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
// skip malformed JSON fragments
console.warn('[cozeChatStream] JSON parse failed in chunk, raw:', jsonStr.slice(0, 200))
}
}
}
}
const parseSseResponseBody = (body) => {
if (!body || typeof body !== 'string') return
if (!body || typeof body !== 'string') {
console.warn('[cozeChatStream] parseSseResponseBody: body is not a string, type:', typeof body)
return
}
console.log('[cozeChatStream] parseSseResponseBody: body length', body.length, 'preview:', body.slice(0, 200))
const lines = body.split('\n')
for (const line of lines) {
const trimmed = line.trim()
@@ -409,24 +429,36 @@ function cozeChatStream(data) {
const evt = JSON.parse(jsonStr)
_onMessage(evt)
} catch (e) {
// skip malformed JSON fragments
console.warn('[cozeChatStream] JSON parse failed in body fallback, raw:', jsonStr.slice(0, 200))
}
}
}
}
const token = uni.getStorageSync('LOGIN_STATUS_TOKEN') || ''
const token = store.state && store.state.app && store.state.app.token
_task = uni.request({
url: `${API_BASE_URL}/api/front/coze/chat/stream`,
method: 'POST',
data: data,
header: {
'Content-Type': 'application/json',
...(token ? { 'Authori-zation': token } : {})
...(token ? { [TOKENNAME]: token } : {})
},
enableChunked: true,
responseType: 'text',
success: (res) => {
console.log('[cozeChatStream] success: statusCode=', res.statusCode, '_gotChunks=', _gotChunks)
if (res.statusCode !== 200) {
let errMsg = '请求失败: ' + res.statusCode
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (body && body.message) errMsg = body.message
else if (body && body.msg) errMsg = body.msg
} catch (e) { /* keep default message */ }
console.error('[cozeChatStream] HTTP error', res.statusCode, errMsg)
_onError(new Error(errMsg))
return
}
if (_buffer.trim()) {
parseSseLines('\n')
}
@@ -437,6 +469,7 @@ function cozeChatStream(data) {
_onComplete()
},
fail: (err) => {
console.error('[cozeChatStream] request fail:', err)
_onError(err)
}
})
@@ -451,9 +484,10 @@ function cozeChatStream(data) {
text += String.fromCharCode(bytes[i])
}
text = decodeURIComponent(escape(text))
console.log('[cozeChatStream] chunk received, decoded length:', text.length)
parseSseLines(text)
} catch (e) {
// chunk decode error, skip
console.warn('[cozeChatStream] chunk decode error:', e)
}
})
}
@@ -564,6 +598,53 @@ function cozeUploadFile(filePath) {
})
}
/**
* Coze - 文本转语音 (TTS)
* POST /api/front/coze/audio/speech
* 返回 Promise<string> 临时文件路径,可直接赋给 innerAudioContext.src 播放
* @param {object} data 请求参数
* @param {string} data.input 要合成的文本
* @param {string} [data.voiceId] 音色ID不传时后端使用默认中文音色
* @param {string} [data.format] 音频格式,默认 "mp3"
* @param {number} [data.speed] 语速,默认 1.0
* @returns {Promise<string>} 临时音频文件路径
*/
function cozeTextToSpeech(data) {
return new Promise((resolve, reject) => {
const token = store.state && store.state.app && store.state.app.token
uni.request({
url: `${API_BASE_URL}/api/front/coze/audio/speech`,
method: 'POST',
data: data,
header: {
'Content-Type': 'application/json',
...(token ? { [TOKENNAME]: token } : {})
},
responseType: 'arraybuffer',
success: (res) => {
if (res.statusCode === 200) {
const fs = uni.getFileSystemManager()
const tempPath = `${uni.env.USER_DATA_PATH}/tts_${Date.now()}.mp3`
fs.writeFile({
filePath: tempPath,
data: res.data, // ArrayBuffer — 不传 encoding否则数据会损坏
success: () => resolve(tempPath),
fail: (err) => reject(new Error('TTS 音频写入失败: ' + JSON.stringify(err)))
})
} else {
let errMsg = 'TTS 请求失败: ' + res.statusCode
try {
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
if (body && body.message) errMsg = body.message
} catch (e) { /* keep default */ }
reject(new Error(errMsg))
}
},
fail: (err) => reject(new Error('TTS 网络请求失败: ' + JSON.stringify(err)))
})
})
}
export default {
request,
getArticleById,
@@ -585,5 +666,6 @@ export default {
cozeWorkflowRun,
cozeWorkflowStream,
cozeWorkflowResume,
cozeUploadFile
cozeUploadFile,
cozeTextToSpeech
}

View File

@@ -11,13 +11,16 @@
</view>
<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 class="promo-right">
<view class="tts-toggle-btn" @click="toggleTTS" :class="{ active: ttsEnabled }">
<text class="tts-toggle-icon">🔊</text>
<!--<text class="tts-toggle-text">{{ ttsEnabled ? '播报开' : '播报关' }}</text>-->
</view>
<view class="clear-btn" @click="clearChat">
<text class="clear-icon">🗑</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>
@@ -61,25 +64,33 @@
<text class="message-sender">AI营养师</text>
</view>
<!-- 消息气泡 -->
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
<!-- AI 消息 loading 占位等待回复时显示打字动画-->
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}<text v-if="msg.streaming" class="streaming-cursor">|</text></text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
</view>
<!-- 消息气泡 -->
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
<!-- AI 消息 loading 占位等待回复时显示打字动画-->
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
<view class="typing-dot"></view>
<view class="typing-dot"></view>
<view class="typing-dot"></view>
</view>
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}<text v-if="msg.streaming" class="streaming-cursor">|</text></text>
<image
v-else
:src="msg.imageUrl"
mode="widthFix"
class="message-image"
@click="previewImage(msg.imageUrl)"
></image>
</view>
<!-- AI 回复播放按钮 -->
<view
v-if="msg.role === 'ai' && !msg.loading && !msg.streaming && msg.content && msg.type !== 'image'"
class="tts-play-btn"
@click="ttsPlayingIndex === index ? stopTTS() : playTTS(index)"
>
<text class="tts-play-icon">{{ ttsPlayingIndex === index ? '⏹' : '▶' }}</text>
</view>
</view>
</view>
<!-- 加载中提示仅在没有流式占位消息时显示 -->
<view v-if="isLoading && !messageList.some(m => m.loading || m.streaming)" class="message-item ai-message">
@@ -199,8 +210,8 @@ export default {
},
data() {
return {
botId: '7591133240535449654', //coze智能体机器人ID
conversationId: '', // 存储会话ID用于多轮对话
botId: '7591133240535449654',
conversationId: '',
scrollTop: 0,
lastScrollTop: 0, // 用于动态滚动
inputText: '',
@@ -221,7 +232,12 @@ export default {
• 解读检验报告
有什么想问的吗?`,
messageList: []
messageList: [],
// TTS
ttsEnabled: false,
ttsPlaying: false,
ttsPlayingIndex: -1,
innerAudioContext: null
}
},
onLoad() {
@@ -230,6 +246,7 @@ export default {
this.scrollToBottom()
});
this.initRecorder();
this.initAudioContext();
},
onUnload() {
this.stopRecordTimer();
@@ -240,6 +257,10 @@ export default {
this._streamCtrl.abort();
this._streamCtrl = null;
}
if (this.innerAudioContext) {
this.innerAudioContext.destroy();
this.innerAudioContext = null;
}
},
methods: {
// 初始化录音管理器
@@ -625,9 +646,26 @@ export default {
this.scrollToBottom();
},
/** 从 Gemini 响应 data.choices[0].message.content 提取展示文本(支持 string / parts 数组 / { parts } / { text } */
/**
* 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('');
@@ -637,8 +675,9 @@ export default {
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 String(content);
return '';
},
/** 工具方法sleep ms 毫秒 */
@@ -646,302 +685,118 @@ export default {
return new Promise(resolve => setTimeout(resolve, ms));
},
/**
* 解包 Coze API 响应:后端返回双层包装 { code, data: { code, data: actualPayload } }
* 此方法统一提取最内层的业务数据
*/
unwrapCozeResponse(response) {
if (!response) return null;
let data = response.data;
if (data && typeof data === 'object' && data.code !== undefined && data.data !== undefined) {
data = data.data;
}
return data;
},
buildCozeMessages(content, type) {
const messages = [];
buildGeminiMessages(content, type) {
if (type === 'text') {
messages.push({
role: 'user',
content: typeof content === 'string' ? content : JSON.stringify(content),
content_type: 'text'
});
} else if (type === 'multimodal') {
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
const textPart = parts.find(p => p && p.type === 'text');
const imgPart = parts.find(p => p && (p.type === 'image_url' || p.type === 'image'));
if (imgPart) {
const fileId = imgPart.file_id || (imgPart.image_url && imgPart.image_url.url) || '';
messages.push({
role: 'user',
content: JSON.stringify([
...(textPart ? [{ type: 'text', text: textPart.text }] : []),
{ type: 'image', file_id: fileId }
]),
content_type: 'object_string'
});
} else {
messages.push({
role: 'user',
content: textPart ? textPart.text : JSON.stringify(content),
content_type: 'text'
});
}
} else {
let fileInfo = content;
if (typeof fileInfo === 'string') {
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* ignore */ }
}
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'
});
}
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }];
}
return messages;
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
return [{ role: 'user', content: parts }];
},
supportsChunked() {
try {
const sysInfo = uni.getSystemInfoSync();
if (sysInfo.SDKVersion) {
const parts = sysInfo.SDKVersion.split('.').map(Number);
return (parts[0] > 2) || (parts[0] === 2 && parts[1] > 20) ||
(parts[0] === 2 && parts[1] === 20 && (parts[2] || 0) >= 1);
}
} catch (e) { /* fallback */ }
return false;
/** BUG-005严格从 CommonResult.data.choices[0].message.content 读取回复 */
getGeminiReplyFromResponse(response) {
const content = response &&
response.data &&
Array.isArray(response.data.choices) &&
response.data.choices[0] &&
response.data.choices[0].message
? response.data.choices[0].message.content
: '';
return this.extractReplyContent(content);
},
async sendToAI(content, type) {
this.isLoading = true;
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
this.messageList.push(aiMsg);
this.scrollToBottom();
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
const messages = this.buildCozeMessages(content, type);
const requestData = {
botId: this.botId,
userId: userId,
additionalMessages: messages,
stream: true,
autoSaveHistory: true
};
if (this.conversationId) requestData.conversationId = this.conversationId;
if (this.supportsChunked()) {
this.sendToAIStream(requestData, aiMsg);
} else {
this.sendToAIPoll(requestData, aiMsg);
}
},
sendToAIStream(requestData, aiMsg) {
const ctrl = api.cozeChatStream(requestData)
.onMessage((evt) => {
const eventType = evt.event || '';
if (eventType === 'conversation.chat.created') {
if (evt.conversation_id) {
this.conversationId = evt.conversation_id;
}
} else if (eventType === 'conversation.message.delta') {
const role = evt.role || 'assistant';
const type = evt.type || 'answer';
if (role === 'assistant' && type === 'answer') {
if (aiMsg.loading) {
aiMsg.loading = false;
aiMsg.streaming = true;
}
aiMsg.content += (evt.content || '');
this.messageList = [...this.messageList];
this.scrollToBottom();
}
} else if (eventType === 'conversation.chat.completed') {
aiMsg.streaming = false;
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
} else if (eventType === 'conversation.chat.failed') {
aiMsg.loading = false;
aiMsg.streaming = false;
if (!aiMsg.content) {
aiMsg.content = '抱歉AI 对话失败,请稍后再试。';
}
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
}
})
.onError((err) => {
console.error('SSE 流式对话失败:', err);
aiMsg.loading = false;
aiMsg.streaming = false;
if (!aiMsg.content) {
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
}
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
})
.onComplete(() => {
aiMsg.loading = false;
aiMsg.streaming = false;
if (!aiMsg.content) {
aiMsg.content = '未能获取到有效回复。';
}
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
});
this._streamCtrl = ctrl;
},
async sendToAIPoll(requestData, aiMsg) {
requestData.stream = false;
try {
const response = await api.cozeChat(requestData);
const cozeData = this.unwrapCozeResponse(response);
if (cozeData) {
const chat = cozeData.chat || cozeData;
const conversationId = chat.conversation_id || chat.conversationID || chat.conversationId;
const chatId = chat.id;
if (conversationId && chatId) {
this.conversationId = conversationId;
await this.pollChatStatus(conversationId, chatId, aiMsg);
} else {
console.error('Coze chat response structure:', JSON.stringify(response));
throw new Error('发起对话失败未返回会话或对话ID');
}
} else {
throw new Error('发起对话失败');
}
const response = await api.kieaiGeminiChat({
messages: this.buildGeminiMessages(content, type),
stream: false
});
const reply = this.getGeminiReplyFromResponse(response);
aiMsg.content = reply || '抱歉,未获取到模型回复。';
} catch (error) {
console.error('发送消息失败:', error);
this.isLoading = false;
console.error('KieAI Gemini 对话失败:', error);
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
} finally {
aiMsg.loading = false;
aiMsg.streaming = false;
this.isLoading = false;
this.messageList = [...this.messageList];
this.scrollToBottom();
if (this.ttsEnabled && aiMsg.content) {
const aiIdx = this.messageList.indexOf(aiMsg);
if (aiIdx !== -1) {
this.$nextTick(() => this.playTTS(aiIdx));
}
}
}
},
getPollInterval(attempt) {
if (attempt <= 10) return 500;
if (attempt <= 30) return 1000;
return 1500;
},
async pollChatStatus(conversationId, chatId, aiMsg) {
const maxAttempts = 80;
let attempts = 0;
const checkStatus = async () => {
attempts++;
if (attempts > maxAttempts) {
this.isLoading = false;
if (aiMsg) { aiMsg.content = '抱歉AI 响应超时,请稍后再试。'; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: '抱歉AI 响应超时,请稍后再试。' }); }
this.scrollToBottom();
return;
}
try {
const res = await api.cozeRetrieveChat({
conversationId,
chatId
});
const retrieveData = this.unwrapCozeResponse(res);
if (retrieveData) {
const chatObj = retrieveData.chat || retrieveData;
const status = chatObj && chatObj.status;
if (status === 'completed') {
await this.getChatMessages(conversationId, chatId, aiMsg);
} else if (status === 'failed' || status === 'canceled') {
this.isLoading = false;
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}`;
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: failMsg }); }
this.scrollToBottom();
} else {
setTimeout(checkStatus, this.getPollInterval(attempts));
}
} else {
setTimeout(checkStatus, this.getPollInterval(attempts));
}
} catch (e) {
console.error('查询对话状态失败:', e);
setTimeout(checkStatus, this.getPollInterval(attempts));
}
};
checkStatus();
// ---------- TTS 方法 ----------
initAudioContext() {
// #ifdef MP-WEIXIN || APP-PLUS
const ctx = uni.createInnerAudioContext()
ctx.onEnded(() => {
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
ctx.onError((e) => {
console.error('[TTS] 播放出错', e)
this.ttsPlaying = false
this.ttsPlayingIndex = -1
})
this.innerAudioContext = ctx
// #endif
},
async getChatMessages(conversationId, chatId, aiMsg) {
try {
const res = await api.cozeMessageList({
conversationId,
chatId
});
this.isLoading = false;
const msgData = this.unwrapCozeResponse(res);
console.log("====api.cozeMessageList response====", msgData);
const rawMessages = msgData && (Array.isArray(msgData.messages) ? msgData.messages : (Array.isArray(msgData) ? msgData : null));
if (rawMessages && rawMessages.length > 0) {
// 过滤出 type='answer' 且 role='assistant' 的消息
const answerMsgs = rawMessages.filter(msg => msg.role === 'assistant' && msg.type === 'answer');
if (answerMsgs.length > 0) {
// 用第一条 answer 消息填充占位气泡,多条追加新气泡
if (aiMsg) {
aiMsg.content = answerMsgs[0].content;
aiMsg.loading = false;
for (let i = 1; i < answerMsgs.length; i++) {
this.messageList.push({ role: 'ai', content: answerMsgs[i].content });
}
this.messageList = [...this.messageList]; // 触发响应式更新
} else {
for (const msg of answerMsgs) { this.messageList.push({ role: 'ai', content: msg.content }); }
}
} else {
// 尝试查找其他类型的回复
const otherMsgs = rawMessages.filter(msg => msg.role === 'assistant');
const fallback = otherMsgs.length > 0 ? otherMsgs[0].content : '未能获取到有效回复。';
if (aiMsg) { aiMsg.content = fallback; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: fallback }); }
}
this.scrollToBottom();
} else {
throw new Error('获取消息列表失败');
}
} catch (e) {
console.error('获取消息详情失败:', e);
this.isLoading = false;
const errContent = '获取回复内容失败。';
if (aiMsg) { aiMsg.content = errContent; aiMsg.loading = false; this.messageList = [...this.messageList]; }
else { this.messageList.push({ role: 'ai', content: errContent }); }
this.scrollToBottom();
toggleTTS() {
this.ttsEnabled = !this.ttsEnabled
if (!this.ttsEnabled && this.ttsPlaying) {
this.stopTTS()
}
},
async playTTS(index) {
const msg = this.messageList[index]
if (!msg || !msg.content) return
if (this.ttsPlaying) {
this.stopTTS()
}
try {
const tempPath = await api.cozeTextToSpeech({ input: msg.content })
if (!this.innerAudioContext) {
console.warn('[TTS] innerAudioContext 未初始化')
return
}
this.ttsPlayingIndex = index
this.ttsPlaying = true
this.innerAudioContext.src = tempPath
this.innerAudioContext.play()
} catch (e) {
console.error('[TTS] 合成失败', e)
uni.showToast({ title: '语音合成失败', icon: 'none' })
this.ttsPlaying = false
this.ttsPlayingIndex = -1
}
},
stopTTS() {
if (this.innerAudioContext) {
this.innerAudioContext.stop()
}
this.ttsPlaying = false
this.ttsPlayingIndex = -1
},
// ---------- 滚动 ----------
scrollToBottom() {
this.$nextTick(() => {
// 动态切换 scrollTop 值以触发滚动更新
@@ -1085,6 +940,60 @@ export default {
font-weight: 500;
}
.tts-toggle-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;
margin-right: 12rpx;
&.active {
background: rgba(76, 175, 80, 0.15);
box-shadow: 0 2rpx 8rpx rgba(76, 175, 80, 0.3);
}
}
.tts-toggle-btn:active {
transform: scale(0.95);
}
.tts-toggle-icon {
font-size: 32rpx;
margin-bottom: 4rpx;
}
.tts-toggle-text {
font-size: 20rpx;
color: #4caf50;
font-weight: 500;
}
.tts-play-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 8rpx;
width: 48rpx;
height: 48rpx;
background: rgba(76, 175, 80, 0.12);
border-radius: 50%;
cursor: pointer;
}
.tts-play-btn:active {
transform: scale(0.9);
}
.tts-play-icon {
font-size: 24rpx;
color: #4caf50;
}
.promo-avatar {
width: 180rpx;
height: 180rpx;