From 6bdb12678af26a2e16ecc7aeca595b15fc1f6f3a Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 2 May 2026 06:39:23 +0800 Subject: [PATCH] Forward speech proxy text messages as strings - Convert non-binary upstream Xunfei WebSocket messages to UTF-8 strings before forwarding them to browser clients. - Export and test raw WebSocket data to text conversion for speech proxy forwarding. - Log unparseable speech responses in the report editor instead of silently swallowing them. - Update report editor, progress, and testing documentation for text-message forwarding. --- docs/modules/report-editor.md | 2 +- docs/progress.md | 1 + docs/testing.md | 4 ++-- server/src/speech/speech.service.ts | 6 +++--- server/src/speech/xf-frame.test.ts | 7 ++++++- server/src/speech/xf-frame.ts | 2 +- src/pages/ReportEditor.tsx | 4 +++- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/modules/report-editor.md b/docs/modules/report-editor.md index 4f0956f..65ea93e 100644 --- a/docs/modules/report-editor.md +++ b/docs/modules/report-editor.md @@ -89,7 +89,7 @@ AI 面板支持两种模式: - 浏览器采集麦克风音频后按实际 `AudioContext.sampleRate` 重采样为 16k、16bit、单声道 PCM,并按小帧发送音频帧。 - 启动前会检查浏览器是否支持 `navigator.mediaDevices.getUserMedia` 和 `AudioContext`;如果不是 `localhost` 或 HTTPS 等安全上下文,浏览器会禁止麦克风能力。Docker 演示环境可使用 `https://localhost:4443`,局域网普通 HTTP 只能通过 Chrome/Edge 演示启动参数临时标记为可信来源。 - 后端读取 Settings API 中的 `xfSpeechConfig`,连接讯飞 IAT,上游首帧由后端补齐 `common.app_id` 和默认 `business` 参数。 -- 识别结果由后端转发回前端,并追加到 AI 输入框。 +- 识别结果由后端按字符串转发回前端,并追加到 AI 输入框。 ## 保存规则 diff --git a/docs/progress.md b/docs/progress.md index dd67bc6..0c18b5d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -95,3 +95,4 @@ | 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 | | 2026-05-02 | 加固报告编辑器语音采集,保留 Web Audio 节点引用、显式恢复 AudioContext,并在无识别文本时给出提示。 | | 2026-05-02 | 对齐讯飞 IAT 音频帧协议,前端按实际采样率重采样到 16k PCM、按小帧发送,并使用标准结束帧。 | +| 2026-05-02 | 修复讯飞代理返回方向的消息类型,确保上游 TextMessage 以字符串转发到浏览器,避免前端静默解析失败。 | diff --git a/docs/testing.md b/docs/testing.md index 938d1ac..562b48e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,7 +33,7 @@ npm run build | 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 | | 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 | | 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 | -| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容。 | +| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容,并把上游文本消息按字符串转发给浏览器。 | | 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 | | 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 | | 后端真实数据库集成 | 使用 Prisma/PostgreSQL 覆盖 Auth、Dashboard、Reports、ReportMedia、Templates、Files、HTML 清洗和审计核心服务。 | @@ -114,7 +114,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视 | 报告编辑器完整流程 | 部分覆盖 | 已覆盖保存修订版本、个人模板和后端草稿/完成报告 schema;模板切换、字段同步仍待补。 | | 视频抽帧 | 待 E2E/人工 | 依赖真实视频解码和 canvas。 | | AI 撰写 | 待集成测试 | 需要隔离外部模型服务。 | -| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖前端 16k PCM 处理、后端首帧处理和 WebSocket 地址生成;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 | +| 讯飞语音听写 | 部分覆盖/待集成测试 | 已覆盖前端 16k PCM 处理、后端首帧处理、上游文本消息转发和 WebSocket 地址生成;完整链路仍需要 WebSocket 集成测试、麦克风权限和测试凭证。 | ## Playwright 说明 diff --git a/server/src/speech/speech.service.ts b/server/src/speech/speech.service.ts index 8805c3c..aa635c7 100644 --- a/server/src/speech/speech.service.ts +++ b/server/src/speech/speech.service.ts @@ -4,7 +4,7 @@ import type { RawData, WebSocket } from 'ws'; import Ws from 'ws'; import type { SafeUser } from '../auth/auth.types.js'; import { SettingsService } from '../settings/settings.service.js'; -import { prepareXfIatFrame } from './xf-frame.js'; +import { prepareXfIatFrame, rawDataToText } from './xf-frame.js'; @Injectable() export class SpeechService { @@ -24,9 +24,9 @@ export class SpeechService { } }); - upstream.on('message', (data) => { + upstream.on('message', (data, isBinary) => { if (client.readyState === Ws.OPEN) { - client.send(data); + client.send(isBinary ? data : rawDataToText(data)); } }); diff --git a/server/src/speech/xf-frame.test.ts b/server/src/speech/xf-frame.test.ts index e4f24c7..d60d546 100644 --- a/server/src/speech/xf-frame.test.ts +++ b/server/src/speech/xf-frame.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { prepareXfIatFrame } from './xf-frame.js'; +import { prepareXfIatFrame, rawDataToText } from './xf-frame.js'; describe('prepareXfIatFrame', () => { it('adds server-side app id and default business options to the first frame', () => { @@ -28,4 +28,9 @@ describe('prepareXfIatFrame', () => { it('keeps non-json payloads unchanged', () => { expect(prepareXfIatFrame('not-json', 'test-app-id')).toBe('not-json'); }); + + it('converts websocket raw data to text for browser forwarding', () => { + expect(rawDataToText(Buffer.from('{"code":0}', 'utf8'))).toBe('{"code":0}'); + expect(rawDataToText([Buffer.from('{"code"'), Buffer.from(':0}')])).toBe('{"code":0}'); + }); }); diff --git a/server/src/speech/xf-frame.ts b/server/src/speech/xf-frame.ts index 77e77d6..99b8055 100644 --- a/server/src/speech/xf-frame.ts +++ b/server/src/speech/xf-frame.ts @@ -29,7 +29,7 @@ export const prepareXfIatFrame = (data: RawData | string, appId: string) => { } }; -const rawDataToText = (data: RawData | string) => { +export const rawDataToText = (data: RawData | string) => { if (typeof data === 'string') return data; if (Buffer.isBuffer(data)) return data.toString('utf8'); if (Array.isArray(data)) return Buffer.concat(data).toString('utf8'); diff --git a/src/pages/ReportEditor.tsx b/src/pages/ReportEditor.tsx index 7395cff..b98bf39 100644 --- a/src/pages/ReportEditor.tsx +++ b/src/pages/ReportEditor.tsx @@ -1282,7 +1282,9 @@ export default function ReportEditor() { setIsListening(false); stopMicrophone(); } - } catch {} + } catch (error) { + console.warn('无法解析讯飞语音返回', error, event.data); + } }; ws.onerror = () => { alert('讯飞语音连接失败,请确认已登录且超级管理员已配置语音参数'); setIsListening(false); stopMicrophone(); };