Files
wecom_it_smart_desk/docs/02-技术文档/01-架构设计/voice-stt-system-design.md
T
Simon facc04aa65 chore: docs 结构整改 + compose 双目录对齐(合并重建提交)
本提交为 .git 对象库损坏后的重建提交,内容等价于原先三个本地提交
(5e2fd4c2 / 57a53c98 / 5d7e1873)的累积结果,未做任何额外改动。

一、docs 结构整改(整改 #14)
根因:重构时新结构为 untracked 文件,执行 git stash(未带 -u)未纳入,
随后 git reset 拉回 HEAD 旧 tracked 树,导致旧树复活、新旧两棵目录
树并存于 docs/,共 791 文件、双分类体系冲突。

修复动作:
- b2 同名异主题文件改名迁移保全 9 个
- C 类 39 个孤立文件按主题正确归类
- A/B1 类 222 个重复文件删除(新结构已有内容副本)
- 9 个旧独有空目录删除
- 270 处内部引用按 verified 映射改写
- 整改记录 #14 登记于 04-运维文档/部署运维

结果:docs 791 → 569 文件,顶层仅规范 8 类 + 治理文件,单树恢复。
残留:约 20 处指向从未存在文件的陈旧死链,归入独立文档卫生任务。

二、compose 双目录对齐(消除踩坑 A)
- docker-compose.yml:nginx 前端挂载全部由根目录 frontend-*/dist
  改为 src/frontend-*/dist(h5 / agent / admin / terminal)
- docker-compose.dev.yml:dev 服务 build context 与卷同步改 src/
- 效果:本地 docker compose up 不再把根目录 stale dist 挂回,
  与线上一致,分叉隐患消除(已 docker compose config 校验通过)

防复发铁律:
- 重构须提交;仓库修复须 git stash -u 或先 commit
- 新结构须 git add 并提交,避免再次 untracked 复活
- H5 改动只动 src/frontend-h5/,禁改根目录遗留 frontend-*/
2026-08-07 22:31:32 +08:00

32 KiB
Raw Blame History

语音识别转文字 — 技术方案设计

项目: 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

/** 企微 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<string, boolean> }) => void
}

interface Window {
  wx: Wx
}

3.1.2 JS-SDK 签名 API 封装(wecom.ts

/** 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<JsapiConfig>

3.1.3 语音识别 composableuseWecomVoice.ts

/** 语音识别状态 */
interface VoiceState {
  isReady: boolean          // JS-SDK 是否已初始化
  isRecording: boolean      // 是否正在录音
  isTranslating: boolean    // 是否正在转文字
  error: string | null      // 错误信息
}

/** useWecomVoice 返回值 */
interface UseWecomVoiceReturn {
  state: VoiceState
  /** 初始化 JS-SDK(幂等,多次调用安全) */
  init(): Promise<void>
  /** 开始录音 */
  startRecording(): Promise<void>
  /** 停止录音并转文字 */
  stopAndTranslate(): Promise<string>
  /** 检查是否支持语音识别 */
  isSupported(): boolean
}

/**
 * 企微语音识别 composable
 * 封装 JS-SDK 录音+转文字的完整流程
 */
function useWecomVoice(): UseWecomVoiceReturn

3.2 坐席端 TypeScript 接口

3.2.1 Web Speech API 类型声明(speech-recognition.d.ts

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 语音识别 composableuseSpeechRecognition.ts

/** 语音识别状态 */
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 接口(已存在,无需修改)

# 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 录音转文字

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 实时转写

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 状态:按钮变红 + 麦克风动画

任务依赖图

graph LR
    T01[T01: H5端语音识别<br>企微JS-SDK方案]
    T02[T02: 坐席端语音识别<br>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

返回格式

{
  "code": 0,
  "data": {
    "corp_id": "wwa8c87970b2011f41",
    "agent_id": "1000133",
    "timestamp": 1718500000,
    "nonce_str": "5K8264ILTKCH...",
    "signature": "f7c8e9..."
  }
}

底层实现

  • WecomService.get_jsapi_ticket():从 Redis 缓存或企微 API 获取 jsapi_ticketTTL 6900s
  • WecomService.generate_jsapi_signature()sha1(jsapi_ticket={ticket}&noncestr={nonce}&timestamp={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.pyALLOWED_EXTENSIONS 中添加 mp3amrwav 等格式。

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.tsinit() 中只需加载 jweixin-1.2.0.js,不需要加载 wwLogin-1.2.7.js(那是登录用的)。

8.1.2 wx.config 的 jsApiList

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 回调。

处理方案

// 在 startRecording() 中设置定时器
let autoStopTimer: ReturnType<typeof setTimeout> | 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 返回格式

translateVoicesuccess 回调返回 { translateResult: string }

陷阱translateResult 可能包含换行符或前后空格,需要 trim() 处理。

8.1.5 页面 URL 传参

签名接口需要传入当前页面 URL(不含 # 及其后):

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 参数

recognition.continuous = true      // 连续识别,不会在第一个结果后自动停止
recognition.interimResults = true  // 返回中间结果(未确定的文字)
recognition.lang = 'zh-CN'        // 中文识别
recognition.maxAlternatives = 1    // 只返回最佳结果

陷阱

  • continuous=false(默认)时,识别到第一个停顿就自动停止 — 不适合长文本输入
  • interimResults=false 时,只有最终结果才回调 — 用户体验差(说完才出字)
  • 两个参数必须同时为 true 才能实现"边说边出字"

8.2.2 onresult 事件处理

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 秒静默)
  • 网络中断
  • 浏览器内部超时

处理方案:如果用户仍然处于"聆听"状态(未主动停止),自动重启识别:

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 要求页面必须在 HTTPSlocalhost 下运行。

当前环境

  • 正式地址 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 后需要重启,必须确保上一个实例已完全停止。

// 错误:重复 start
recognition.start()
recognition.start()  // 抛出 InvalidStateError

// 正确:等待 onend 后再 start
recognition.stop()
recognition.onend = () => {
  recognition.start()  // 安全
}

8.3 CSP 安全策略

H5 端 index.html 已有 CSP 配置:

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self' 'unsafe-eval' 'unsafe-inline' https://res.wx.qq.com;
  ...
  connect-src 'self' https://qyapi.weixin.qq.com wss://* ...;
" />

确认

  • 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 代码完整,WecomServiceget_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+ 支持(当前版本远超)

附录:类图

classDiagram
    class Wx {
        <<interface>>
        +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 {
        <<interface>>
        +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