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:
@@ -2,41 +2,34 @@
|
|||||||
|
|
||||||
## 页面(pages/tool/ai-nutritionist)
|
## 页面(pages/tool/ai-nutritionist)
|
||||||
|
|
||||||
- 1. 请求后页面显示:"未能获取到有效回复。"
|
- 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},请求后页面显示:"未能获取到有效回复。"
|
||||||
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 修复:流式对话显示"未能获取到有效回复"
|
### 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` — `cozeChatStream()` 的 `success` 回调添加 `res.statusCode !== 200` 检查,提取后端 `message`/`msg` 字段作为错误信息调用 `_onError`;在 chunk 接收、SSE 解析、降级解析各环节添加 `console.log` / `console.warn` 诊断日志。
|
||||||
- `models-api.js`:增加 `_gotChunks` 标记,当 `onChunkReceived` 未触发时,在 `success` 回调中解析 `res.data` 作为降级处理;增加 `responseType: 'text'` 确保响应体为字符串。
|
- `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":"..."}` 则说明后端有问题需优先排查。
|
||||||
|
|
||||||
# 参考文档
|
# 参考文档
|
||||||
|
- 1. **coze官方api文档**:https://docs.coze.cn/developer_guides/chat_v3#AJThpr1GJe
|
||||||
- 3. /Users/a123/msh-system/.cursor/plans/optimize_ai_nutritionist_speed_b6e9a618.plan.md
|
- 2. /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
|
|
||||||
|
|||||||
32
docs/features.md
Normal file
32
docs/features.md
Normal 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
|
||||||
@@ -22,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
|||||||
import javax.servlet.http.HttpServletResponse;
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Coze API 控制器
|
* Coze API 控制器
|
||||||
@@ -132,4 +133,23 @@ public class CozeController {
|
|||||||
public CozeBaseResponse<Object> workflowResume(@RequestBody CozeWorkflowResumeRequest request) {
|
public CozeBaseResponse<Object> workflowResume(@RequestBody CozeWorkflowResumeRequest request) {
|
||||||
return toolCozeService.workflowResume(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ public class ResponseFilter implements Filter {
|
|||||||
@Override
|
@Override
|
||||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
|
||||||
throws IOException, ServletException {
|
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);//转换成代理类
|
ResponseWrapper wrapperResponse = new ResponseWrapper((HttpServletResponse) response);//转换成代理类
|
||||||
// 这里只拦截返回,直接让请求过去,如果在请求前有处理,可以在这里处理
|
// 这里只拦截返回,直接让请求过去,如果在请求前有处理,可以在这里处理
|
||||||
filterChain.doFilter(request, wrapperResponse);
|
filterChain.doFilter(request, wrapperResponse);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.zbkj.service.service.impl.tool;
|
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.CreateChatReq;
|
||||||
import com.coze.openapi.client.chat.CreateChatResp;
|
import com.coze.openapi.client.chat.CreateChatResp;
|
||||||
import com.coze.openapi.client.chat.RetrieveChatReq;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取访问令牌
|
* 获取访问令牌
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -79,4 +79,15 @@ public interface ToolCozeService {
|
|||||||
* @return 恢复结果
|
* @return 恢复结果
|
||||||
*/
|
*/
|
||||||
CozeBaseResponse<Object> workflowResume(CozeWorkflowResumeRequest request);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// API服务工具文件
|
// API服务工具文件
|
||||||
// 统一访问 crmeb-front 项目,基地址来自 config/app.js
|
// 统一访问 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
|
const API_BASE_URL = domain
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,12 +13,14 @@ const API_BASE_URL = domain
|
|||||||
*/
|
*/
|
||||||
function request(url, options = {}) {
|
function request(url, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const token = store.state && store.state.app && store.state.app.token
|
||||||
uni.request({
|
uni.request({
|
||||||
url: `${API_BASE_URL}${url}`,
|
url: `${API_BASE_URL}${url}`,
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
data: options.data || {},
|
data: options.data || {},
|
||||||
header: {
|
header: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { [TOKENNAME]: token } : {}),
|
||||||
...options.header
|
...options.header
|
||||||
},
|
},
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
@@ -318,6 +322,33 @@ function queryAsrStatus(taskId) {
|
|||||||
return request(`/api/front/tencent/asr/query-status/${taskId}`)
|
return request(`/api/front/tencent/asr/query-status/${taskId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== KieAI Gemini Chat(BUG-005:AI 营养师文本/多模态对话) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ====================
|
// ==================== 扣子Coze API ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,21 +362,6 @@ function queryAsrStatus(taskId) {
|
|||||||
* @param {object} data.meta_data 元数据
|
* @param {object} data.meta_data 元数据
|
||||||
* @returns {Promise} 对话响应
|
* @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) {
|
function cozeChat(data) {
|
||||||
return request('/api/front/coze/chat', {
|
return request('/api/front/coze/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -390,14 +406,18 @@ function cozeChatStream(data) {
|
|||||||
const evt = JSON.parse(jsonStr)
|
const evt = JSON.parse(jsonStr)
|
||||||
_onMessage(evt)
|
_onMessage(evt)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// skip malformed JSON fragments
|
console.warn('[cozeChatStream] JSON parse failed in chunk, raw:', jsonStr.slice(0, 200))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseSseResponseBody = (body) => {
|
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')
|
const lines = body.split('\n')
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim()
|
const trimmed = line.trim()
|
||||||
@@ -409,24 +429,36 @@ function cozeChatStream(data) {
|
|||||||
const evt = JSON.parse(jsonStr)
|
const evt = JSON.parse(jsonStr)
|
||||||
_onMessage(evt)
|
_onMessage(evt)
|
||||||
} catch (e) {
|
} 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({
|
_task = uni.request({
|
||||||
url: `${API_BASE_URL}/api/front/coze/chat/stream`,
|
url: `${API_BASE_URL}/api/front/coze/chat/stream`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: data,
|
data: data,
|
||||||
header: {
|
header: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { 'Authori-zation': token } : {})
|
...(token ? { [TOKENNAME]: token } : {})
|
||||||
},
|
},
|
||||||
enableChunked: true,
|
enableChunked: true,
|
||||||
responseType: 'text',
|
responseType: 'text',
|
||||||
success: (res) => {
|
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()) {
|
if (_buffer.trim()) {
|
||||||
parseSseLines('\n')
|
parseSseLines('\n')
|
||||||
}
|
}
|
||||||
@@ -437,6 +469,7 @@ function cozeChatStream(data) {
|
|||||||
_onComplete()
|
_onComplete()
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
|
console.error('[cozeChatStream] request fail:', err)
|
||||||
_onError(err)
|
_onError(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -451,9 +484,10 @@ function cozeChatStream(data) {
|
|||||||
text += String.fromCharCode(bytes[i])
|
text += String.fromCharCode(bytes[i])
|
||||||
}
|
}
|
||||||
text = decodeURIComponent(escape(text))
|
text = decodeURIComponent(escape(text))
|
||||||
|
console.log('[cozeChatStream] chunk received, decoded length:', text.length)
|
||||||
parseSseLines(text)
|
parseSseLines(text)
|
||||||
} catch (e) {
|
} 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 {
|
export default {
|
||||||
request,
|
request,
|
||||||
getArticleById,
|
getArticleById,
|
||||||
@@ -585,5 +666,6 @@ export default {
|
|||||||
cozeWorkflowRun,
|
cozeWorkflowRun,
|
||||||
cozeWorkflowStream,
|
cozeWorkflowStream,
|
||||||
cozeWorkflowResume,
|
cozeWorkflowResume,
|
||||||
cozeUploadFile
|
cozeUploadFile,
|
||||||
|
cozeTextToSpeech
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,12 @@
|
|||||||
<text class="promo-subtitle">营养师专家入驻,在线答疑</text>
|
<text class="promo-subtitle">营养师专家入驻,在线答疑</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="promo-right">
|
<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">
|
<view class="clear-btn" @click="clearChat">
|
||||||
<text class="clear-icon">🗑️</text>
|
<text class="clear-icon">🗑️</text>
|
||||||
<text class="clear-text">清空</text>
|
|
||||||
</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>
|
<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>
|
||||||
@@ -78,6 +81,14 @@
|
|||||||
@click="previewImage(msg.imageUrl)"
|
@click="previewImage(msg.imageUrl)"
|
||||||
></image>
|
></image>
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
|
||||||
@@ -199,8 +210,8 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
botId: '7591133240535449654', //coze智能体机器人ID
|
botId: '7591133240535449654',
|
||||||
conversationId: '', // 存储会话ID,用于多轮对话
|
conversationId: '',
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
lastScrollTop: 0, // 用于动态滚动
|
lastScrollTop: 0, // 用于动态滚动
|
||||||
inputText: '',
|
inputText: '',
|
||||||
@@ -221,7 +232,12 @@ export default {
|
|||||||
• 解读检验报告
|
• 解读检验报告
|
||||||
|
|
||||||
有什么想问的吗?`,
|
有什么想问的吗?`,
|
||||||
messageList: []
|
messageList: [],
|
||||||
|
// TTS
|
||||||
|
ttsEnabled: false,
|
||||||
|
ttsPlaying: false,
|
||||||
|
ttsPlayingIndex: -1,
|
||||||
|
innerAudioContext: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLoad() {
|
onLoad() {
|
||||||
@@ -230,6 +246,7 @@ export default {
|
|||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
});
|
});
|
||||||
this.initRecorder();
|
this.initRecorder();
|
||||||
|
this.initAudioContext();
|
||||||
},
|
},
|
||||||
onUnload() {
|
onUnload() {
|
||||||
this.stopRecordTimer();
|
this.stopRecordTimer();
|
||||||
@@ -240,6 +257,10 @@ export default {
|
|||||||
this._streamCtrl.abort();
|
this._streamCtrl.abort();
|
||||||
this._streamCtrl = null;
|
this._streamCtrl = null;
|
||||||
}
|
}
|
||||||
|
if (this.innerAudioContext) {
|
||||||
|
this.innerAudioContext.destroy();
|
||||||
|
this.innerAudioContext = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 初始化录音管理器
|
// 初始化录音管理器
|
||||||
@@ -625,9 +646,26 @@ export default {
|
|||||||
this.scrollToBottom();
|
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) {
|
extractReplyContent(content) {
|
||||||
if (content == null) return '';
|
if (content == null) return '';
|
||||||
|
if (typeof content === 'number' || typeof content === 'boolean') return String(content);
|
||||||
if (typeof content === 'string') return content;
|
if (typeof content === 'string') return content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
|
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('');
|
return content.parts.map(part => (part && part.text) ? part.text : '').filter(Boolean).join('');
|
||||||
}
|
}
|
||||||
if (typeof content.text === 'string') return content.text;
|
if (typeof content.text === 'string') return content.text;
|
||||||
|
if (typeof content.content === 'string') return content.content;
|
||||||
}
|
}
|
||||||
return String(content);
|
return '';
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 工具方法:sleep ms 毫秒 */
|
/** 工具方法:sleep ms 毫秒 */
|
||||||
@@ -646,302 +685,118 @@ export default {
|
|||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
buildGeminiMessages(content, type) {
|
||||||
* 解包 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 = [];
|
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
messages.push({
|
return [{ role: 'user', content: typeof content === 'string' ? content : String(content) }];
|
||||||
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 parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
||||||
const textPart = parts.find(p => p && p.type === 'text');
|
return [{ role: 'user', content: parts }];
|
||||||
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 messages;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
supportsChunked() {
|
/** BUG-005:严格从 CommonResult.data.choices[0].message.content 读取回复 */
|
||||||
try {
|
getGeminiReplyFromResponse(response) {
|
||||||
const sysInfo = uni.getSystemInfoSync();
|
const content = response &&
|
||||||
if (sysInfo.SDKVersion) {
|
response.data &&
|
||||||
const parts = sysInfo.SDKVersion.split('.').map(Number);
|
Array.isArray(response.data.choices) &&
|
||||||
return (parts[0] > 2) || (parts[0] === 2 && parts[1] > 20) ||
|
response.data.choices[0] &&
|
||||||
(parts[0] === 2 && parts[1] === 20 && (parts[2] || 0) >= 1);
|
response.data.choices[0].message
|
||||||
}
|
? response.data.choices[0].message.content
|
||||||
} catch (e) { /* fallback */ }
|
: '';
|
||||||
return false;
|
return this.extractReplyContent(content);
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendToAI(content, type) {
|
async sendToAI(content, type) {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
|
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
|
||||||
this.messageList.push(aiMsg);
|
this.messageList.push(aiMsg);
|
||||||
this.scrollToBottom();
|
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 {
|
try {
|
||||||
const response = await api.cozeChat(requestData);
|
const response = await api.kieaiGeminiChat({
|
||||||
const cozeData = this.unwrapCozeResponse(response);
|
messages: this.buildGeminiMessages(content, type),
|
||||||
if (cozeData) {
|
stream: false
|
||||||
const chat = cozeData.chat || cozeData;
|
});
|
||||||
const conversationId = chat.conversation_id || chat.conversationID || chat.conversationId;
|
const reply = this.getGeminiReplyFromResponse(response);
|
||||||
const chatId = chat.id;
|
aiMsg.content = reply || '抱歉,未获取到模型回复。';
|
||||||
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('发起对话失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('KieAI Gemini 对话失败:', error);
|
||||||
this.isLoading = false;
|
|
||||||
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
||||||
|
} finally {
|
||||||
aiMsg.loading = false;
|
aiMsg.loading = false;
|
||||||
|
aiMsg.streaming = false;
|
||||||
|
this.isLoading = false;
|
||||||
this.messageList = [...this.messageList];
|
this.messageList = [...this.messageList];
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
|
if (this.ttsEnabled && aiMsg.content) {
|
||||||
|
const aiIdx = this.messageList.indexOf(aiMsg);
|
||||||
|
if (aiIdx !== -1) {
|
||||||
|
this.$nextTick(() => this.playTTS(aiIdx));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getPollInterval(attempt) {
|
// ---------- TTS 方法 ----------
|
||||||
if (attempt <= 10) return 500;
|
|
||||||
if (attempt <= 30) return 1000;
|
initAudioContext() {
|
||||||
return 1500;
|
// #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 pollChatStatus(conversationId, chatId, aiMsg) {
|
toggleTTS() {
|
||||||
const maxAttempts = 80;
|
this.ttsEnabled = !this.ttsEnabled
|
||||||
let attempts = 0;
|
if (!this.ttsEnabled && this.ttsPlaying) {
|
||||||
|
this.stopTTS()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
const checkStatus = async () => {
|
async playTTS(index) {
|
||||||
attempts++;
|
const msg = this.messageList[index]
|
||||||
if (attempts > maxAttempts) {
|
if (!msg || !msg.content) return
|
||||||
this.isLoading = false;
|
|
||||||
if (aiMsg) { aiMsg.content = '抱歉,AI 响应超时,请稍后再试。'; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
if (this.ttsPlaying) {
|
||||||
else { this.messageList.push({ role: 'ai', content: '抱歉,AI 响应超时,请稍后再试。' }); }
|
this.stopTTS()
|
||||||
this.scrollToBottom();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.cozeRetrieveChat({
|
const tempPath = await api.cozeTextToSpeech({ input: msg.content })
|
||||||
conversationId,
|
if (!this.innerAudioContext) {
|
||||||
chatId
|
console.warn('[TTS] innerAudioContext 未初始化')
|
||||||
});
|
return
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
this.ttsPlayingIndex = index
|
||||||
|
this.ttsPlaying = true
|
||||||
|
this.innerAudioContext.src = tempPath
|
||||||
|
this.innerAudioContext.play()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('查询对话状态失败:', e);
|
console.error('[TTS] 合成失败', e)
|
||||||
setTimeout(checkStatus, this.getPollInterval(attempts));
|
uni.showToast({ title: '语音合成失败', icon: 'none' })
|
||||||
|
this.ttsPlaying = false
|
||||||
|
this.ttsPlayingIndex = -1
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
checkStatus();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getChatMessages(conversationId, chatId, aiMsg) {
|
stopTTS() {
|
||||||
try {
|
if (this.innerAudioContext) {
|
||||||
const res = await api.cozeMessageList({
|
this.innerAudioContext.stop()
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
this.ttsPlaying = false
|
||||||
|
this.ttsPlayingIndex = -1
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- 滚动 ----------
|
||||||
|
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
// 动态切换 scrollTop 值以触发滚动更新
|
// 动态切换 scrollTop 值以触发滚动更新
|
||||||
@@ -1085,6 +940,60 @@ export default {
|
|||||||
font-weight: 500;
|
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 {
|
.promo-avatar {
|
||||||
width: 180rpx;
|
width: 180rpx;
|
||||||
height: 180rpx;
|
height: 180rpx;
|
||||||
|
|||||||
Reference in New Issue
Block a user