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.
This commit is contained in:
2026-05-02 06:39:23 +08:00
parent 13d8853532
commit 6bdb12678a
7 changed files with 17 additions and 9 deletions

View File

@@ -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 输入框。
## 保存规则

View File

@@ -95,3 +95,4 @@
| 2026-05-02 | 调整抽帧百分比为两位小数保序保存;自动截图按时间顺序执行,自动插入按配置顺序执行。 |
| 2026-05-02 | 加固报告编辑器语音采集,保留 Web Audio 节点引用、显式恢复 AudioContext并在无识别文本时给出提示。 |
| 2026-05-02 | 对齐讯飞 IAT 音频帧协议,前端按实际采样率重采样到 16k PCM、按小帧发送并使用标准结束帧。 |
| 2026-05-02 | 修复讯飞代理返回方向的消息类型,确保上游 TextMessage 以字符串转发到浏览器,避免前端静默解析失败。 |

View File

@@ -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 说明

View File

@@ -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));
}
});

View File

@@ -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}');
});
});

View File

@@ -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');

View File

@@ -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(); };