# 语音识别转文字 — 技术方案设计 > **项目**: IT 智能服务台(税友集团企微内嵌应用) > **功能**: 语音输入转文字,填入聊天输入框(非发送语音消息) > **日期**: 2026-07-15 > **架构师**: 高见远(Bob) --- ## 1. 实现方案概述 ### 1.1 方案C — H5端(企微 JS-SDK) H5 端运行在企微客户端 WebView 内,利用企微 JS-SDK 提供的录音和语音识别能力: | 步骤 | API | 说明 | |------|-----|------| | 1. 鉴权 | `wx.config()` | 用后端签名初始化 JS-SDK | | 2. 开始录音 | `wx.startRecord()` | 用户按下语音按钮时调用 | | 3. 停止录音 | `wx.stopRecord()` | 用户松开按钮时调用,返回 `localId` | | 4. 语音转文字 | `wx.translateVoice()` | 将录音识别为文字,返回 `translateResult` | | 5. 填入输入框 | `inputText.value += result` | 识别文字追加到 van-field | **核心优势**:企微原生能力,识别精度高,支持中英文混合,无需额外服务器资源。 **关键发现**:后端 **已存在** JS-SDK 签名接口 `GET /api/wecom/jsapi-config?url=xxx`(位于 `backend/app/api/wecom_jsapi.py`),且 `WecomService` 已实现 `get_jsapi_ticket()` 和 `generate_jsapi_signature()`。**后端无需任何改动。** ### 1.2 方案A — 坐席端(Web Speech API) 坐席端运行在桌面 Chrome/Edge 浏览器,利用浏览器原生 Web Speech API: | 步骤 | API | 说明 | |------|-----|------| | 1. 创建识别器 | `new SpeechRecognition()` | 创建识别实例 | | 2. 配置参数 | `continuous=true, interimResults=true` | 连续识别 + 实时中间结果 | | 3. 启动识别 | `recognition.start()` | 用户点击语音按钮 | | 4. 实时回调 | `onresult` 事件 | 边说边出字,实时更新 textarea | | 5. 停止识别 | `recognition.stop()` | 用户再次点击或自动停止 | | 6. 填入输入框 | `inputText.value = finalTranscript` | 最终文字填入 textarea | **核心优势**:纯前端实现,零后端依赖,实时转写(边说边出字),体验流畅。 ### 1.3 框架选型与依赖 | 端 | 技术 | 新增依赖 | 说明 | |----|------|----------|------| | H5端 | 企微 JS-SDK (`jweixin-1.2.0.js`) | 无(CDN 引入) | 已在 EmergencyDispatcher.vue 中使用 | | 坐席端 | Web Speech API(浏览器原生) | 无 | Chrome/Edge 内置支持 | **无需新增任何 npm 依赖。** 两个方案都使用平台原生能力,通过 CDN script 标签(H5)或浏览器内置 API(Agent)实现。 --- ## 2. 文件列表及路径 ### 2.1 H5端(frontend-h5) | # | 操作 | 文件路径 | 说明 | |---|------|----------|------| | 1 | [新建] | `src/types/wecom-jssdk.d.ts` | 企微 JS-SDK TypeScript 类型声明 | | 2 | [新建] | `src/api/wecom.ts` | JS-SDK 签名 API 封装(调 `/api/wecom/jsapi-config`) | | 3 | [新建] | `src/composables/useWecomVoice.ts` | 语音识别 composable(录音+转文字+状态管理) | | 4 | [修改] | `src/components/chat/InputBar.vue` | 工具栏添加语音按钮,集成 composable | ### 2.2 坐席端(frontend-agent) | # | 操作 | 文件路径 | 说明 | |---|------|----------|------| | 5 | [新建] | `src/types/speech-recognition.d.ts` | Web Speech API TypeScript 类型声明 | | 6 | [新建] | `src/composables/useSpeechRecognition.ts` | 语音识别 composable(实时转写+状态管理) | | 7 | [修改] | `src/components/chat/ReplyBox.vue` | 工具栏添加语音按钮,替换占位代码 | ### 2.3 后端(backend) | # | 操作 | 文件路径 | 说明 | |---|------|----------|------| | — | 无改动 | — | JS-SDK 签名接口已存在(`app/api/wecom_jsapi.py`) | ### 2.4 文件关系图 ``` frontend-h5/src/ ├── types/ │ └── wecom-jssdk.d.ts [新建] 类型声明(Wx, WxConfig, WxError 等) ├── api/ │ └── wecom.ts [新建] getJsapiConfig(url) → {corp_id, signature, ...} ├── composables/ │ └── useWecomVoice.ts [新建] useWecomVoice() → { isRecording, start, stop, ... } └── components/chat/ └── InputBar.vue [修改] +语音按钮 🎤, 集成 useWecomVoice frontend-agent/src/ ├── types/ │ └── speech-recognition.d.ts [新建] 类型声明(SpeechRecognition, SpeechRecognitionEvent 等) ├── composables/ │ └── useSpeechRecognition.ts [新建] useSpeechRecognition() → { isListening, start, stop, ... } └── components/chat/ └── ReplyBox.vue [修改] +语音按钮 🎤, 替换 voice 占位代码 ``` --- ## 3. 接口设计 ### 3.1 H5端 TypeScript 接口 #### 3.1.1 企微 JS-SDK 类型声明(`wecom-jssdk.d.ts`) ```typescript /** 企微 JS-SDK 全局对象 */ interface Wx { config(config: WxConfigOptions): void ready(callback: () => void): void error(callback: (res: WxError) => void): void // 录音相关 startRecord(): void stopRecord(options: WxStopRecordOptions): void translateVoice(options: WxTranslateVoiceOptions): void // 工具 checkJsApi(options: WxCheckJsApiOptions): void } interface WxConfigOptions { beta?: boolean debug?: boolean appId: string // corp_id timestamp: number nonceStr: string signature: string jsApiList: string[] // ['startRecord', 'stopRecord', 'translateVoice'] } interface WxError { errMsg: string } interface WxStopRecordOptions { success: (res: { localId: string }) => void fail: (res: WxError) => void } interface WxTranslateVoiceOptions { localId: string isShowProgressTips?: number // 0=不显示, 1=显示 success: (res: { translateResult: string }) => void fail: (res: WxError) => void } interface WxCheckJsApiOptions { jsApiList: string[] success: (res: { checkResult: Record }) => void } interface Window { wx: Wx } ``` #### 3.1.2 JS-SDK 签名 API 封装(`wecom.ts`) ```typescript /** JS-SDK 签名配置响应 */ interface JsapiConfig { corp_id: string agent_id: string timestamp: number nonce_str: string signature: string } /** * 获取企微 JS-SDK 签名配置 * @param url 当前页面 URL(不含 # 及其后) * @returns 签名配置 */ async function getJsapiConfig(url: string): Promise ``` #### 3.1.3 语音识别 composable(`useWecomVoice.ts`) ```typescript /** 语音识别状态 */ interface VoiceState { isReady: boolean // JS-SDK 是否已初始化 isRecording: boolean // 是否正在录音 isTranslating: boolean // 是否正在转文字 error: string | null // 错误信息 } /** useWecomVoice 返回值 */ interface UseWecomVoiceReturn { state: VoiceState /** 初始化 JS-SDK(幂等,多次调用安全) */ init(): Promise /** 开始录音 */ startRecording(): Promise /** 停止录音并转文字 */ stopAndTranslate(): Promise /** 检查是否支持语音识别 */ isSupported(): boolean } /** * 企微语音识别 composable * 封装 JS-SDK 录音+转文字的完整流程 */ function useWecomVoice(): UseWecomVoiceReturn ``` ### 3.2 坐席端 TypeScript 接口 #### 3.2.1 Web Speech API 类型声明(`speech-recognition.d.ts`) ```typescript interface SpeechRecognition extends EventTarget { lang: string continuous: boolean interimResults: boolean maxAlternatives: number start(): void stop(): void abort(): void onresult: ((event: SpeechRecognitionEvent) => void) | null onerror: ((event: SpeechRecognitionErrorEvent) => void) | null onend: (() => void) | null onstart: (() => void) | null } interface SpeechRecognitionEvent extends Event { resultIndex: number results: SpeechRecognitionResultList } interface SpeechRecognitionResultList { length: number item(index: number): SpeechRecognitionResult [index: number]: SpeechRecognitionResult } interface SpeechRecognitionResult { length: number isFinal: boolean item(index: number): SpeechRecognitionAlternative [index: number]: SpeechRecognitionAlternative } interface SpeechRecognitionAlternative { transcript: string confidence: number } interface SpeechRecognitionErrorEvent extends Event { error: string message: string } interface SpeechRecognitionStatic { new (): SpeechRecognition } interface Window { SpeechRecognition?: SpeechRecognitionStatic webkitSpeechRecognition?: SpeechRecognitionStatic } ``` #### 3.2.2 语音识别 composable(`useSpeechRecognition.ts`) ```typescript /** 语音识别状态 */ interface SpeechState { isListening: boolean // 是否正在监听 interimText: string // 实时中间结果(未确定) finalText: string // 已确定的最终结果 error: string | null // 错误信息 isSupported: boolean // 浏览器是否支持 } /** useSpeechRecognition 返回值 */ interface UseSpeechRecognitionReturn { state: SpeechState /** 开始语音识别 */ start(): void /** 停止语音识别 */ stop(): void /** 重置状态 */ reset(): void } /** * Web Speech API 语音识别 composable * 支持实时转写(边说边出字) * @param lang 识别语言,默认 'zh-CN' */ function useSpeechRecognition(lang?: string): UseSpeechRecognitionReturn ``` ### 3.3 后端 API 接口(已存在,无需修改) ```python # backend/app/api/wecom_jsapi.py — 已存在 @router.get("/wecom/jsapi-config") async def get_jsapi_config(url: str = Query(...)) -> dict: """返回 JS-SDK 签名配置 Response: { code: 0, data: { corp_id, agent_id, timestamp, nonce_str, signature } } """ ``` --- ## 4. 时序图 ### 4.1 方案C — H5端企微 JS-SDK 录音转文字 ```mermaid sequenceDiagram participant U as 用户 participant IB as InputBar.vue participant UWV as useWecomVoice participant API as /api/wecom/jsapi-config participant WX as 企微服务器 participant WV as 企微WebView Note over U,WV: 阶段1:初始化(页面加载时,幂等) IB->>UWV: init() UWV->>WV: 检查 window.wx 是否存在 alt JS-SDK 未加载 UWV->>WV: 动态加载 jweixin-1.2.0.js end UWV->>API: GET /api/wecom/jsapi-config?url=当前页面URL API->>API: get_jsapi_ticket() + sha1 签名 API-->>UWV: { corp_id, agent_id, timestamp, nonce_str, signature } UWV->>WV: wx.config({ appId, timestamp, ..., jsApiList: ['startRecord','stopRecord','translateVoice'] }) WV-->>UWV: wx.ready() 回调 UWV-->>IB: state.isReady = true Note over U,WV: 阶段2:录音 U->>IB: 按下语音按钮 🎤 IB->>UWV: startRecording() UWV->>WV: wx.startRecord() UWV-->>IB: state.isRecording = true IB->>IB: UI 切换为录音状态(按钮变红+提示"松开发送") Note over U,WV: 阶段3:停止录音+转文字 U->>IB: 松开语音按钮 IB->>UWV: stopAndTranslate() UWV->>WV: wx.stopRecord() WV-->>UWV: { localId: "wxLocalId_xxx" } UWV-->>IB: state.isTranslating = true UWV->>WV: wx.translateVoice({ localId, isShowProgressTips: 1 }) WV->>WX: 上传录音+请求语音识别 WX-->>WV: 返回识别文字 WV-->>UWV: { translateResult: "帮我重置密码" } UWV-->>IB: 返回识别文字 IB->>IB: inputText.value += translateResult IB->>IB: state.isTranslating = false, state.isRecording = false Note over U,WV: 异常分支 alt 录音超过60秒 WV-->>UWV: 自动触发 stopRecord 回调 UWV->>UWV: 自动执行 translateVoice end alt 转文字失败 WV-->>UWV: fail 回调 { errMsg } UWV-->>IB: showToast("语音识别失败") end ``` ### 4.2 方案A — 坐席端 Web Speech API 实时转写 ```mermaid sequenceDiagram participant U as 坐席 participant RB as ReplyBox.vue participant USR as useSpeechRecognition participant SR as SpeechRecognition Note over U,SR: 阶段1:初始化 RB->>USR: useSpeechRecognition('zh-CN') USR->>USR: 检查 window.SpeechRecognition || webkitSpeechRecognition alt 浏览器不支持 USR-->>RB: state.isSupported = false RB->>RB: 语音按钮显示为禁用/隐藏 else 浏览器支持 USR->>USR: state.isSupported = true end Note over U,SR: 阶段2:开始识别 U->>RB: 点击语音按钮 🎤 RB->>USR: start() USR->>SR: new SpeechRecognition() USR->>SR: lang='zh-CN', continuous=true, interimResults=true USR->>SR: 注册 onresult / onerror / onend 回调 USR->>SR: start() SR-->>USR: onstart 触发 USR-->>RB: state.isListening = true RB->>RB: 按钮变红 + textarea 显示光标 + 提示"正在聆听..." Note over U,SR: 阶段3:实时转写(边说边出字) U->>SR: 说话:"帮我查一下" SR-->>USR: onresult(event) — interimResults (isFinal=false) USR->>USR: state.interimText = "帮我查一下" USR-->>RB: 实时更新 textarea(灰色临时文字) RB->>RB: inputText.value = finalText + interimText U->>SR: 继续说话:"帮我查一下密码" SR-->>USR: onresult(event) — final result (isFinal=true) USR->>USR: state.finalText += "帮我查一下密码" USR-->>RB: 确定文字追加到 textarea RB->>RB: inputText.value = finalText Note over U,SR: 阶段4:停止识别 U->>RB: 再次点击语音按钮 RB->>USR: stop() USR->>SR: stop() SR-->>USR: onend 触发 USR-->>RB: state.isListening = false RB->>RB: 按钮恢复 + textarea 保持已识别文字 Note over U,SR: 异常分支 alt 识别出错 SR-->>USR: onerror(event) — error='not-allowed' USR-->>RB: showToast("麦克风权限被拒绝") end alt 用户长时间不说话 SR-->>USR: onend 自动触发(超时停止) USR-->>RB: state.isListening = false end ``` --- ## 5. 任务列表 ### T01: H5端语音识别功能(企微JS-SDK方案) | 属性 | 值 | |------|-----| | **Task ID** | T01 | | **任务名** | H5端语音识别功能(企微JS-SDK方案) | | **源文件** | `frontend-h5/src/types/wecom-jssdk.d.ts` [新建]
`frontend-h5/src/api/wecom.ts` [新建]
`frontend-h5/src/composables/useWecomVoice.ts` [新建]
`frontend-h5/src/components/chat/InputBar.vue` [修改] | | **依赖** | 无 | | **优先级** | P0 | | **复杂度** | 中等 | | **预估工时** | 4-6 小时 | **实现要点**: 1. 创建 `wecom-jssdk.d.ts`:声明 `Wx` 接口及 `startRecord`/`stopRecord`/`translateVoice` 等方法类型 2. 创建 `wecom.ts`:封装 `getJsapiConfig(url)` 调用后端 `/api/wecom/jsapi-config` 3. 创建 `useWecomVoice.ts`:核心 composable,封装 JS-SDK 加载、wx.config 鉴权、录音、转文字完整流程 - 参考 `EmergencyDispatcher.vue` 中已有的 `loadWeworkSDK()` / `wxConfig()` 模式 - `init()` 幂等设计:多次调用安全,避免重复加载 SDK - `jsApiList` 需包含 `['startRecord', 'stopRecord', 'translateVoice']` 4. 修改 `InputBar.vue`: - 工具栏添加语音按钮(🎤),放在文件按钮后 - 按下开始录音,松开停止+转文字 - 录音中 UI 状态:按钮变红 + 底部提示"松开识别文字" - 识别结果追加到 `inputText.value` ### T02: 坐席端语音识别功能(Web Speech API方案) | 属性 | 值 | |------|-----| | **Task ID** | T02 | | **任务名** | 坐席端语音识别功能(Web Speech API方案) | | **源文件** | `frontend-agent/src/types/speech-recognition.d.ts` [新建]
`frontend-agent/src/composables/useSpeechRecognition.ts` [新建]
`frontend-agent/src/components/chat/ReplyBox.vue` [修改] | | **依赖** | 无(与 T01 完全独立) | | **优先级** | P0 | | **复杂度** | 中等 | | **预估工时** | 3-4 小时 | **实现要点**: 1. 创建 `speech-recognition.d.ts`:声明 `SpeechRecognition` 接口及事件类型 2. 创建 `useSpeechRecognition.ts`:核心 composable - `continuous=true`:连续识别模式 - `interimResults=true`:启用实时中间结果 - `lang='zh-CN'`:中文识别 - `onresult` 回调中区分 `isFinal`(最终结果)和 interim(中间结果) - 最终结果追加到 `finalText`,中间结果实时更新 `interimText` 3. 修改 `ReplyBox.vue`: - 工具栏添加语音按钮(🎤),放在文件按钮和分隔线之间 - 替换现有占位代码 `voice: '语音消息功能开发中'` - 点击开始识别,再次点击停止 - 实时转写:textarea 中显示 `finalText + interimText` - 识别中 UI 状态:按钮变红 + 麦克风动画 ### 任务依赖图 ```mermaid graph LR T01[T01: H5端语音识别
企微JS-SDK方案] T02[T02: 坐席端语音识别
Web Speech API方案] T01 --- T02 style T01 fill:#e1f5fe,stroke:#0288d1,stroke-width:2px style T02 fill:#e8f5e9,stroke:#388e3c,stroke-width:2px ``` > **说明**:T01 和 T02 完全独立,可并行开发。两者无文件交叉,无接口依赖。 --- ## 6. 后端改动 ### 6.1 JS-SDK 签名接口 — 已存在,无需修改 **已有接口**:`GET /api/wecom/jsapi-config?url=xxx` **文件位置**:`backend/app/api/wecom_jsapi.py` **返回格式**: ```json { "code": 0, "data": { "corp_id": "wwa8c87970b2011f41", "agent_id": "1000133", "timestamp": 1718500000, "nonce_str": "5K8264ILTKCH...", "signature": "f7c8e9..." } } ``` **底层实现**: - `WecomService.get_jsapi_ticket()`:从 Redis 缓存或企微 API 获取 jsapi_ticket(TTL 6900s) - `WecomService.generate_jsapi_signature()`:`sha1(jsapi_ticket={ticket}&noncestr={nonce}×tamp={ts}&url={url})` **已在路由注册**:`router.py` 第 224 行 `api_router.include_router(wecom_jsapi_router, tags=["企微JS-SDK"])` ### 6.2 企微配置 — 已存在 以下配置已在 `backend/app/config.py` 中定义,通过环境变量注入: | 配置项 | 环境变量 | 说明 | |--------|----------|------| | `wecom_corp_id` | `WECOM_CORP_ID` | 企业ID | | `wecom_agent_id` | `WECOM_AGENT_ID` | 应用AgentId | | `wecom_secret` | `WECOM_SECRET` | 应用Secret(用于获取 access_token → jsapi_ticket) | ### 6.3 上传接口白名单 — 无需修改 **原因**:企微 `translateVoice()` 在内部完成录音上传和识别,音频数据不经过我们的后端上传接口。识别结果直接以文字形式返回前端。因此 **不需要** 在 `ALLOWED_EXTENSIONS` 中添加音频格式。 > 如果未来需要将录音文件保存到服务器(如审计需求),才需要在 `upload.py` 的 `ALLOWED_EXTENSIONS` 中添加 `mp3`、`amr`、`wav` 等格式。 ### 6.4 企微管理后台配置 — 需确认 在企微管理后台(work.weixin.qq.com)需要确保以下配置: 1. **应用可信域名**:`itsupport.servyou.com.cn` 已配置(OAuth2 已用) 2. **JS-SDK 权限**:应用管理 → 自建应用 → 开发者接口 → 确认已开启"语音识别"权限 3. **可信域名验证**:域名根目录需放置企微验证文件(如 `WW_verify_xxx.txt`)— 应已配置 > ⚠️ **需确认**:企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限。部分接口需要在应用详情页单独申请。 --- ## 7. 错误处理与降级策略 ### 7.1 H5端(企微 JS-SDK) | 场景 | 检测方式 | 降级策略 | |------|----------|----------| | **非企微环境** | `navigator.userAgent` 不含 `wxwork` | 语音按钮隐藏或显示 Toast"请在企业微信中打开"(index.html 已有环境拦截) | | **JS-SDK 加载失败** | `script.onerror` 回调 | Toast"企微 SDK 加载失败",语音按钮禁用 | | **wx.config 鉴权失败** | `wx.error()` 回调 | Toast"企微授权失败",记录 errMsg 到日志,语音按钮禁用 | | **录音权限被拒绝** | `wx.startRecord()` 无响应或 `wx.error` | Toast"请允许麦克风权限" | | **translateVoice 失败** | `fail` 回调 `{ errMsg }` | Toast"语音识别失败,请重试",保留输入框已有内容 | | **录音超 60 秒** | 企微自动触发 `stopRecord` | composable 内监听自动停止,自动执行转文字 | | **网络错误** | API 请求超时/失败 | Toast"网络异常,请检查网络后重试" | | **jsapi_ticket 获取失败** | 后端返回 code=5001 | Toast"签名服务暂时不可用",语音按钮禁用 | ### 7.2 坐席端(Web Speech API) | 场景 | 检测方式 | 降级策略 | |------|----------|----------| | **浏览器不支持** | `!window.SpeechRecognition && !window.webkitSpeechRecognition` | 语音按钮隐藏,控制台 warning | | **麦克风权限被拒绝** | `onerror` 事件 `error='not-allowed'` | ElMessage.error"请允许麦克风权限后重试" | | **网络错误**(Web Speech API 依赖网络) | `onerror` 事件 `error='network'` | ElMessage.error"网络异常,语音识别不可用" | | **未检测到语音** | `onerror` 事件 `error='no-speech'` | ElMessage.info"未检测到语音,请重试" | | **识别中断** | `onerror` 事件 `error='aborted'` | 静默处理,不提示(用户主动中断) | | **识别服务不可用** | `onerror` 事件 `error='service-not-allowed'` | ElMessage.error"语音识别服务不可用" | | **长时间无语音输入** | `onend` 自动触发(超时停止) | 保留已识别文字,按钮恢复初始状态 | ### 7.3 通用降级原则 1. **语音功能是辅助输入手段**,不是核心功能 — 降级不影响正常文字输入 2. **所有错误都 Toast/ElMessage 提示**,不阻塞 UI 3. **语音按钮在不支持时隐藏而非禁用**,避免用户困惑 4. **识别失败时保留输入框已有内容**,不清空 --- ## 8. 关键实现细节与陷阱 ### 8.1 企微 JS-SDK 陷阱 #### 8.1.1 JS-SDK 文件加载顺序 ``` EmergencyDispatcher.vue 现有加载方式(可参考): 1. 先加载 wwLogin-1.2.7.js(企微登录SDK) 2. 再加载 jweixin-1.2.0.js(企微JS-SDK) ``` **陷阱**:`jweixin-1.2.0.js` 文件名含"1.2.0"但实际是企微最新版 JS-SDK。不要被版本号误导。 **建议**:`useWecomVoice.ts` 的 `init()` 中只需加载 `jweixin-1.2.0.js`,不需要加载 `wwLogin-1.2.7.js`(那是登录用的)。 #### 8.1.2 wx.config 的 jsApiList ```javascript wx.config({ beta: true, // 必须开启,否则部分接口不可用 debug: false, appId: config.corp_id, // 注意:企微用 corp_id,不是 agent_id timestamp: config.timestamp, nonceStr: config.nonce_str, // 注意大小写:nonceStr(驼峰) signature: config.signature, jsApiList: [ 'startRecord', 'stopRecord', 'translateVoice', // 如果已有 agentConfig 需求,也加上 'agentConfig' ] }) ``` **陷阱**: - `appId` 字段传 `corp_id`(企业ID),不是 `agent_id` - `nonceStr` 是驼峰命名,后端返回的是 `nonce_str`(下划线),前端需要映射 - `beta: true` 必须设置,否则 `translateVoice` 等接口可能不可用 #### 8.1.3 translateVoice 的 60 秒限制 企微 `startRecord()` 最长录音时间为 **60 秒**。超过 60 秒,企微会自动触发 `stopRecord` 回调。 **处理方案**: ```typescript // 在 startRecording() 中设置定时器 let autoStopTimer: ReturnType | null = null function startRecording() { wx.startRecord() // 59 秒自动停止(留 1 秒余量) autoStopTimer = setTimeout(() => { if (state.isRecording) { stopAndTranslate() } }, 59000) } function stopAndTranslate() { if (autoStopTimer) { clearTimeout(autoStopTimer) autoStopTimer = null } // ... stopRecord + translateVoice } ``` #### 8.1.4 translateVoice 返回格式 `translateVoice` 的 `success` 回调返回 `{ translateResult: string }`。 **陷阱**:`translateResult` 可能包含换行符或前后空格,需要 `trim()` 处理。 #### 8.1.5 页面 URL 传参 签名接口需要传入当前页面 URL(不含 `#` 及其后): ```typescript const url = window.location.href.split('#')[0] ``` **陷阱**:Vue Router 使用 hash 模式时,URL 格式为 `https://xxx/#/h5/chat`。必须去掉 `#` 后面的部分,否则签名不匹配。 > **需确认**:H5 端 Vue Router 使用的是 hash 模式还是 history 模式。如果是 history 模式,URL 本身不含 `#`,但仍需确保传入的是完整 URL。 #### 8.1.6 多次 init 幂等性 用户可能多次进入聊天页面,`init()` 会被多次调用。需要确保: - JS-SDK script 只加载一次(检查 `window.wx` 是否存在) - `wx.config()` 可以多次调用(企微允许重复 config) - 使用 `Promise` 缓存避免并发 init ### 8.2 Web Speech API 陷阱 #### 8.2.1 continuous 和 interimResults 参数 ```typescript recognition.continuous = true // 连续识别,不会在第一个结果后自动停止 recognition.interimResults = true // 返回中间结果(未确定的文字) recognition.lang = 'zh-CN' // 中文识别 recognition.maxAlternatives = 1 // 只返回最佳结果 ``` **陷阱**: - `continuous=false`(默认)时,识别到第一个停顿就自动停止 — 不适合长文本输入 - `interimResults=false` 时,只有最终结果才回调 — 用户体验差(说完才出字) - 两个参数必须同时为 `true` 才能实现"边说边出字" #### 8.2.2 onresult 事件处理 ```typescript recognition.onresult = (event: SpeechRecognitionEvent) => { let interimText = '' let finalText = '' // 从 resultIndex 开始遍历新的结果 for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i] if (result.isFinal) { finalText += result[0].transcript } else { interimText += result[0].transcript } } // 更新状态 state.interimText = interimText if (finalText) { state.finalText += finalText } } ``` **陷阱**: - 必须从 `event.resultIndex` 开始遍历,而不是从 0,否则会重复处理旧结果 - `result[0]` 是最佳候选,`result[1]` 是次佳候选(如果 `maxAlternatives > 1`) - `isFinal=true` 的结果不会改变,可以安全追加 #### 8.2.3 onend 自动重启 Web Speech API 在以下情况会自动触发 `onend`: - 用户长时间不说话(约 10-15 秒静默) - 网络中断 - 浏览器内部超时 **处理方案**:如果用户仍然处于"聆听"状态(未主动停止),自动重启识别: ```typescript recognition.onend = () => { if (shouldKeepListening) { // 自动重启(短暂延迟避免竞态) setTimeout(() => { try { recognition.start() } catch (e) { // 可能报错 "recognition has already started" console.warn('重启识别失败:', e) } }, 100) } else { state.isListening = false } } ``` #### 8.2.4 HTTPS 要求 Web Speech API 要求页面必须在 **HTTPS** 或 `localhost` 下运行。 **当前环境**: - 正式地址 `https://itsupport.servyou.com.cn` — ✅ HTTPS - 本地开发 `http://localhost:5174` — ✅ localhost 豁免 - 坐席端 `http://localhost:5175` — ✅ localhost 豁免 **陷阱**:如果坐席端通过 HTTP IP 访问(如 `http://10.80.0.x:5175`),Web Speech API 不可用。 #### 8.2.5 Chrome 隐身模式限制 Chrome 隐身模式下 Web Speech API 可能不可用(取决于 Chrome 版本和隐私设置)。 **处理方案**:`onerror` 中检测 `error='service-not-allowed'`,提示用户使用正常模式。 #### 8.2.6 识别器实例管理 **陷阱**:`SpeechRecognition` 实例不能重复调用 `start()`。如果在 `onend` 后需要重启,必须确保上一个实例已完全停止。 ```typescript // 错误:重复 start recognition.start() recognition.start() // 抛出 InvalidStateError // 正确:等待 onend 后再 start recognition.stop() recognition.onend = () => { recognition.start() // 安全 } ``` ### 8.3 CSP 安全策略 H5 端 `index.html` 已有 CSP 配置: ```html ``` **确认**: - `script-src` 已包含 `https://res.wx.qq.com` — ✅ 可加载企微 JS-SDK - `connect-src` 已包含 `https://qyapi.weixin.qq.com` — ✅ JS-SDK 可与企微服务器通信 - 坐席端无 CSP 限制(或限制较宽松)— ✅ Web Speech API 不受 CSP 影响 **无需修改 CSP 配置。** --- ## 9. 待明确事项 ### 9.1 需用户确认的技术决策 | # | 问题 | 影响 | 默认假设 | |---|------|------|----------| | 1 | 企微管理后台是否已为当前应用开启 `startRecord`/`stopRecord`/`translateVoice` 的 JS-SDK 权限? | H5 端语音功能是否可用 | 已开启(因为 EmergencyDispatcher 已在使用 agentConfig) | | 2 | H5 端 Vue Router 使用 hash 模式还是 history 模式? | 签名 URL 传参方式 | hash 模式(需去掉 `#` 后部分) | | 3 | 坐席端是否需要支持 Firefox? | Firefox 使用 `mozSpeechRecognition`(实验性) | 不需要,坐席端限定 Chrome/Edge | | 4 | 是否需要将语音识别的文字自动发送,还是仅填入输入框等待用户确认? | 交互设计 | 仅填入输入框(用户确认后手动发送) | | 5 | H5 端语音按钮交互方式:按住说话(press-to-talk)还是点击开始/点击结束? | UX 交互设计 | 按住说话(移动端常见交互) | | 6 | 坐席端语音按钮交互方式:点击开始/再次点击结束? | UX 交互设计 | 点击开始/再次点击结束(桌面端常见交互) | ### 9.2 假设说明 1. **后端 JS-SDK 签名接口稳定可用**:已验证 `wecom_jsapi.py` 代码完整,`WecomService` 的 `get_jsapi_ticket()` 有 Redis 缓存+降级机制 2. **企微应用已配置可信域名**:`itsupport.servyou.com.cn` 已用于 OAuth2 登录,JS-SDK 共用同一域名配置 3. **坐席端浏览器版本足够新**:Chrome 25+ / Edge 79+ 支持 Web Speech API(当前主流版本均满足) 4. **企微客户端版本支持 translateVoice**:企微 iOS/Android 客户端 2.4+ 支持(当前版本远超) --- ## 附录:类图 ```mermaid classDiagram class Wx { <> +config(options: WxConfigOptions) void +ready(callback: Function) void +error(callback: Function) void +startRecord() void +stopRecord(options: object) void +translateVoice(options: object) void } class JsapiConfig { +corp_id: string +agent_id: string +timestamp: number +nonce_str: string +signature: string } class UseWecomVoiceReturn { +state: VoiceState +init() Promise~void~ +startRecording() Promise~void~ +stopAndTranslate() Promise~string~ +isSupported() boolean } class VoiceState { +isReady: boolean +isRecording: boolean +isTranslating: boolean +error: string|null } class SpeechRecognition { <> +lang: string +continuous: boolean +interimResults: boolean +start() void +stop() void +abort() void +onresult: Function +onerror: Function +onend: Function } class UseSpeechRecognitionReturn { +state: SpeechState +start() void +stop() void +reset() void } class SpeechState { +isListening: boolean +interimText: string +finalText: string +error: string|null +isSupported: boolean } class InputBar { +inputText: string +handleVoiceStart() void +handleVoiceEnd() void } class ReplyBox { +inputText: string +toggleVoice() void } UseWecomVoiceReturn --> VoiceState UseWecomVoiceReturn ..> Wx : uses UseWecomVoiceReturn ..> JsapiConfig : fetches InputBar --> UseWecomVoiceReturn : composes UseSpeechRecognitionReturn --> SpeechState UseSpeechRecognitionReturn ..> SpeechRecognition : wraps ReplyBox --> UseSpeechRecognitionReturn : composes ```