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)
|
||||
|
||||
- 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
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取访问令牌
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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 ====================
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user