feat(practice): run realtime practice on App-Plus through renderjs

App-Plus has no WebRTC or getUserMedia in the logic layer, so realtime
practice was H5-only and the native entry had to advertise a record-then-
transcribe downgrade.

Move the media/WebRTC engine into `realtime-browser-engine.js` and drive it
from `RealtimePracticeAppBridge.vue`, whose renderjs script runs inside the
system WebView where those APIs do exist. The logic layer keeps the
authenticated calls: SDP exchange and `search_knowledge` tool invocation stay
server-proxied, so the access token is never handed to the view layer. Both
sides talk over the renderjs bridge only in session payloads the server
already assembled.

Verified against a real App build (`app-renderjs.js` holds RTCPeerConnection
and getUserMedia; `app-service.js` holds the SDP endpoint, renderjs side has
zero hits). Microphone permission, live transcription, remote playback,
foreground/background switching and disconnect fallback still require Android
and iOS device verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:58:10 +08:00
co-authored by Claude Opus 5
parent 3055d2bec9
commit 5cf7ce5847
10 changed files with 673 additions and 390 deletions
+1 -1
View File
@@ -66,7 +66,7 @@
- 候选面试复用 `/api/recruit/interview/**`;补充资料走认证的移动端接口,先写 `sys_oss`/MinIO,再写 `aihr_candidate_material`,状态仅为 `待审核/已通过/已驳回`,由管理端 `/recruit/interview` 审核。上传接口不得放入 class-level `@SaIgnore`。 - 候选面试复用 `/api/recruit/interview/**`;补充资料走认证的移动端接口,先写 `sys_oss`/MinIO,再写 `aihr_candidate_material`,状态仅为 `待审核/已通过/已驳回`,由管理端 `/recruit/interview` 审核。上传接口不得放入 class-level `@SaIgnore`。
- 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。 - 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。
- 对练语音复用 `/api/ai/asr` 与 `/api/ai/tts`,TTS 保持旧 `voice` 兼容并支持角色音色。浏览器录音不可用或权限失败时,用 `audio/*` file input 继续走同一 ASR,不退回纯文字;新增 multipart 外发同样清洗 filename/contentType 的 CRLF。 - 对练语音复用 `/api/ai/asr` 与 `/api/ai/tts`,TTS 保持旧 `voice` 兼容并支持角色音色。浏览器录音不可用或权限失败时,用 `audio/*` file input 继续走同一 ASR,不退回纯文字;新增 multipart 外发同样清洗 filename/contentType 的 CRLF。
- 实时对练 Beta(WebRTC)的人设与工具声明只由服务端 `AihrRealtimePersonaRegistry` 白名单组装,浏览器原样转发 session.update,不得恢复前端自组 instructions 或音色 picker;工具执行按登录态 + Redis 会话归属 + 人设白名单三重校验,仅只读知识检索且 `source=realtime` 不落 SOP 评审/知识缺口表;实时 Beta 不计分、不持久化、仅 H5、需 APP 登录态,接口契约见 `docs/API_INTEGRATION.md`。 - 实时对练 Beta(WebRTC)的人设与工具声明只由服务端 `AihrRealtimePersonaRegistry` 白名单组装,客户端原样转发 session.update,不得恢复前端自组 instructions 或音色 picker;工具执行按登录态 + Redis 会话归属 + 人设白名单三重校验,仅只读知识检索且 `source=realtime` 不落 SOP 评审/知识缺口表;实时 Beta 不计分、不持久化,支持 H5 与 App-Plus(App 通过 renderjs WebView 执行媒体/WebRTC,并由逻辑层代理认证 SDP 和工具请求),需 APP 登录态,接口契约见 `docs/API_INTEGRATION.md`。
- 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。 - 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。
- AI 面试、三角色对练、案例、SOP 和三端首页保持真实 API/模型优先与既有降级。知识检索以 MySQL 为事实源,Qdrant/vector/rerank 是可选增强;向量维度不一致必须经状态与重建入口处理,不得静默换成本地向量。 - AI 面试、三角色对练、案例、SOP 和三端首页保持真实 API/模型优先与既有降级。知识检索以 MySQL 为事实源,Qdrant/vector/rerank 是可选增强;向量维度不一致必须经状态与重建入口处理,不得静默换成本地向量。
- 批量资料和所有视频只走 `/api/knowledge/doc/upload-async` 异步队列;同步 `/api/knowledge/doc/upload` 仅供 SOP 单文件即时预览,禁止把重加工塞回请求线程。格式、大小、超时、OCR/视频解析和重试契约以 `docs/API_INTEGRATION.md` 为准;图片无可用视觉结果时保持「待处理」,视频关键帧不得退回纯 OCR。 - 批量资料和所有视频只走 `/api/knowledge/doc/upload-async` 异步队列;同步 `/api/knowledge/doc/upload` 仅供 SOP 单文件即时预览,禁止把重加工塞回请求线程。格式、大小、超时、OCR/视频解析和重试契约以 `docs/API_INTEGRATION.md` 为准;图片无可用视觉结果时保持「待处理」,视频关键帧不得退回纯 OCR。
+1 -1
View File
@@ -13,7 +13,7 @@
| M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 | | M2 训练预习与求助 | `GET /api/train/practice/scenarios/{id}/prep-card`、`POST /api/train/practice/sessions/{sessionId}/help` | 均要求已登录;预习卡只使用当前租户已启用的 `prep_card/json_prep` 模板,模型返回必须严格为 3 条要点、3 条红线、2 条话术,否则回退场景卡。求助接口只允许 APP 员工访问其本人、同租户的活动训练会话;客户端可传 `roundIndex` 仅为兼容字段,服务端按会话实际进度落库,返回 `{sessionId,scenarioId,roundIndex,recordedAt}`。迁移表为 `aihr_practice_help_event`,不在请求路径执行 DDL。该表已随 2026-07-24 完整包发布并纳入远端 `62/62` 结构预检;完整训练写入与正式试点仍需另验。 |
| 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 | | 正式试点数据导出 | `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD` | 起止日期必填且包含结束日;只统计窗口内能通过唯一手机号或外部 ID 映射到在职组织快照的正式会话,排除重复手机号和身份碰撞。完训定义为每人至少 10 次,校准必须关联同一窗口内正式会话;CSV 同时给出校准命中数、SOP 可用数、满意度响应数/平均分,以及明细级 AI 分、人工校准分、校准人、校准时间、最终采用分、满意度分和意见,避免用四舍五入后的比率反推门禁状态;汇总和明细均携带正式人员及项目口径,不混入历史 seed/开发身份 |
| 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 | | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?,dialect?}, practiceContext?:{sessionId,turnIndex,role:'customer'}}`,`dialect` 仅接受 `mandarin/cantonese/sichuanese`,非法值返回业务码 `400`;旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速;dialect 作为语气提示传给支持表现力提示词的 TTS,不含克隆,也不等同于方言正式验收。`practiceContext` 仅允许已认证 APP 员工把本次服务端生成的业主 TTS 绑定到本人当前或刚完成会话的指定原话术回合;服务端重新校验租户、员工身份、回合和文本,不接受员工端传 OSS ID 或任意角色。设备语音降级按方言选择语言:粤语使用 `zh-HK`,四川话无法由设备语音可靠模拟时明确提示用户转用服务端语音或文字,不伪装成普通话。模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;移动端 OSS 下载只允许经本人或主管项目范围验证的 APP 身份,后台账号若需回放必须使用未来独立、受审计的管理端契约。 |
| 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | 浏览器 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,浏览器原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时浏览器回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;仅 H5(App-Plus 不支持),需 APP 员工登录态;不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 | | 实时对练 Beta(WebRTC) | `POST /api/train/practice/realtime/sdp`、`GET /realtime/personas`、`POST /realtime/session`、`POST /realtime/tools/invoke` | H5 浏览器及 App-Plus renderjs WebView 内的 WebRTC 直连阿里云(音频不过服务端),后端仅代理 SDP 交换(3 次/分/账号,sessionId 仅作日志关联且限字符)。人设为服务端 `AihrRealtimePersonaRegistry` 白名单:`owner-calm` 业主·常规(默认)、`owner-impatient` 业主·急躁、`digital-mentor` 数字师傅;人设绑定默认音色,客户端只选不编。`session.update`(instructions/音色/turn_detection/tools 声明)由 `/realtime/session` 组装下发,客户端原样转发;personaId 空白回落默认人设;会话记录写 Redis 30min(成功工具调用滑动续期),创建限流 6 次/分/账号。`digital-mentor` 声明 `search_knowledge` 工具:模型 function calling 命中(`response.function_call_arguments.done`)时客户端回传 `/realtime/tools/invoke`,服务端按登录态 + Redis 会话归属 + 人设工具白名单三重校验后走 `searchAuthorized` 浅层检索(不生成 LLM 答案、不落 `aihr_agent_run`;`source=realtime` 跳过 SOP 评审与知识缺口写入),output 拼接 ≤3 条片段并截断 1200 字;工具按账号限流 20 次/分,无授权/无结果/超时一律返回软着陆文案让模型如实作答,不中断语音会话。实时 Beta 不计分、不持久化训练数据;H5 与 App-Plus 均支持,App-Plus 在 renderjs 视图层执行媒体/WebRTC 并通过逻辑层复用受认证 SDP 与工具接口;需 APP 员工登录态,不启用模型 `enable_search`。配置 `AIHR_QWEN_REALTIME_ENDPOINT` / `AIHR_QWEN_REALTIME_API_KEY` / `AIHR_QWEN_REALTIME_MODEL`(模型白名单 `qwen3.5-omni-(flash|plus)-realtime`),密钥只放 `.env.local` 或外部环境变量。 |
| 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户必须先以认证手机号精确校验在职 `person_phone`,再由可信主体解析 `aihr_org_snapshot` 项目范围;案例 SQL 不得把 `person_phone` 与 `ext_party_id` 作为替代查询键。上传、整理、入库、列表和详情均按该项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 |
| 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL |
| 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 | | 问·数字师傅工作 Agent `/pages/user/sop/index` | `POST /api/aihr/agent/messages`、`POST /api/aihr/agent/messages/media`、`POST /api/aihr/agent/actions/{draftId}/confirm`、`POST /api/aihr/agent/actions/{draftId}/dismiss` | 移动端文字、ASR 转写和图片/视频统一进入 Agent。服务端根据消息规划 `KNOWLEDGE_QA/RESOURCE_DELIVERY/LIVE_MY_WORK/LIVE_TEAM_WORK/PRACTICE_COACHING/CAPTURE_FACT/MEDIA_UNDERSTANDING/WEB_RESEARCH/CLARIFY/SOCIAL`,再由固定策略校验当前登录身份和工具权限;客户端不提交 `toolCode`,模型也不能自由指定身份、SQL、URL 或任意工具。知识、训练概况、当前待办、确认式记忆、媒体分析和全网查询均复用现有领域服务;普通图片问答只做本次视觉分析,不要求先具备知识空间,用户明确问现场制度/流程时才校验授权空间并补充 SOP。全网查询先返回 `NEEDS_INPUT`,只有用户明确同意后才调用外部服务。统一响应使用结构化 `status/sourceSummary/citations/resources/actionDraft/clarification`;按钮不从答案文本推断。写入动作只接受 30 分钟有效的不透明 `draftId`,最终确认继续复用既有版本、幂等与可见性规则。每次运行只审计租户、主体、意图、工具、状态、来源类型、耗时和错误码,不保存原始问题、答案或附件。 |
+1 -1
View File
@@ -14,7 +14,7 @@
- [ ] 首次启动先显示原生隐私弹窗;两个协议链接可打开,拒绝后退出,同意后才进入登录。 - [ ] 首次启动先显示原生隐私弹窗;两个协议链接可打开,拒绝后退出,同意后才进入登录。
- [ ] 手机号登录、短信验证码错误、登录失效重登均符合预期。 - [ ] 手机号登录、短信验证码错误、登录失效重登均符合预期。
- [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。 - [ ] 首次录音允许麦克风后,可完成转写;拒绝麦克风后不循环申请,能选择本地音频继续。
- [ ] 对练入口在原生端明确降级为录音后转写,不出现不可用的浏览器 WebRTC 实时通话承诺。 - [ ] 原生实时对练可完成麦克风授权、实时转写与远端语音播放;WebRTC/WebView 不可用或断线时明确提示并保留录音对练。
- [ ] 图片、视频、文档和音频可选择并上传;受控附件下载后可打开,不泄露 OSS 地址。 - [ ] 图片、视频、文档和音频可选择并上传;受控附件下载后可打开,不泄露 OSS 地址。
- [ ] 前后台切换、网络断开/恢复、窄屏和底部手势区无白屏、遮挡或重复提交。 - [ ] 前后台切换、网络断开/恢复、窄屏和底部手势区无白屏、遮挡或重复提交。
- [ ] 再次安装同版本后,桌面图标、启动图和 Android 导航栏不闪白。 - [ ] 再次安装同版本后,桌面图标、启动图和 Android 导航栏不闪白。
+1 -1
View File
@@ -36,7 +36,7 @@ npm --prefix mobile-uni run build:app
- `androidPrivacy.json.example` 是**未启用的法务模板**,其中 `__TERMS_URL__` 与 `__PRIVACY_URL__` 必须替换为法务确认的 HTTPS 地址。复制为被 Git 忽略的 `androidPrivacy.json` 后,`npm --prefix mobile-uni run verify:app-release` 才会通过。 - `androidPrivacy.json.example` 是**未启用的法务模板**,其中 `__TERMS_URL__` 与 `__PRIVACY_URL__` 必须替换为法务确认的 HTTPS 地址。复制为被 Git 忽略的 `androidPrivacy.json` 后,`npm --prefix mobile-uni run verify:app-release` 才会通过。
- iOS 已按实际功能填写麦克风和照片库用途说明;不申请相机、定位、通讯录、蓝牙或广告追踪权限。 - iOS 已按实际功能填写麦克风和照片库用途说明;不申请相机、定位、通讯录、蓝牙或广告追踪权限。
- `build:app` 依赖 `@dcloudio/uni-app-plus`(须与其他 `@dcloudio` 包同版本)。缺少该编译器时命令**不报错**,只静默产出带 `/h5/` 基路径的 H5 空壳,产物里没有 `app-service.js` 与 `app-renderjs.js`,App 平台代码等于从未编译。判定 `build:app` 是否真的成功要看产物中是否存在 `app-service.js`、`app-renderjs.js`、`manifest.json`,不能只看 `DONE Build complete.`。 - `build:app` 依赖 `@dcloudio/uni-app-plus`(须与其他 `@dcloudio` 包同版本)。缺少该编译器时命令**不报错**,只静默产出带 `/h5/` 基路径的 H5 空壳,产物里没有 `app-service.js` 与 `app-renderjs.js`,App 平台代码等于从未编译。判定 `build:app` 是否真的成功要看产物中是否存在 `app-service.js`、`app-renderjs.js`、`manifest.json`,不能只看 `DONE Build complete.`。
- App-Plus 当前不启用浏览器 WebRTC 实时对练;界面必须降级为录音后转写,不能将该路径宣传为实时语音通话。 - App-Plus 实时对练通过 `renderjs` 在系统 WebView 中执行媒体/WebRTC;认证 SDP 与工具请求仍由逻辑层代理。发布前必须在 Android/iOS 真机验证麦克风授权、实时转写、远端语音播放、前后台切换和断线降级。
- 真机步骤和证据口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。资源预检通过不等于 Android/iOS 真机或应用商店验收通过。 - 真机步骤和证据口径见 `docs/MOBILE_APP_DEVICE_ACCEPTANCE.md`。资源预检通过不等于 Android/iOS 真机或应用商店验收通过。
## 更新策略 ## 更新策略
@@ -0,0 +1,220 @@
<template>
<view
class="realtime-app-bridge"
:bridge-command="bridgeCommand"
:change:bridge-command="realtimeBridge.onCommand"
/>
</template>
<script>
import { exchangeRealtimeSdp, invokeRealtimeTool } from '@/services/realtime-practice';
const errorMessage = (error, fallback) =>
error instanceof Error ? error.message : fallback;
export default {
emits: [
'status',
'trainee-transcript',
'customer-transcript',
'audio-playback-blocked',
'terminated',
'error'
],
data() {
return {
bridgeCommand: { id: 0, type: 'idle' },
commandId: 0,
activeSessionId: '',
pendingStart: null,
pendingResume: new Map()
};
},
methods: {
sendCommand(type, payload = {}) {
this.commandId += 1;
this.bridgeCommand = { id: this.commandId, type, ...payload };
},
start(sessionUpdate, sessionId) {
this.pendingStart?.resolve();
this.activeSessionId = sessionId || '';
return new Promise((resolve, reject) => {
this.pendingStart = { resolve, reject };
this.sendCommand('start', { sessionUpdate, sessionId: this.activeSessionId });
});
},
stop() {
this.pendingStart?.resolve();
this.pendingStart = null;
this.pendingResume.forEach((resolve) => resolve(false));
this.pendingResume.clear();
this.activeSessionId = '';
this.sendCommand('stop');
},
resumeAudio() {
const requestId = `resume_${Date.now()}_${this.commandId + 1}`;
return new Promise((resolve) => {
this.pendingResume.set(requestId, resolve);
this.sendCommand('resume-audio', { requestId });
});
},
async answerSdp(event) {
const sessionId = this.activeSessionId;
try {
const answerSdp = await exchangeRealtimeSdp(event.offerSdp, sessionId);
this.sendCommand('request-result', { requestId: event.requestId, value: answerSdp });
} catch (error) {
this.sendCommand('request-error', {
requestId: event.requestId,
message: errorMessage(error, '实时语音连接请求失败')
});
}
},
async answerToolCall(event) {
const sessionId = this.activeSessionId;
try {
const response = await invokeRealtimeTool(
sessionId,
event.callId,
event.name,
event.argsJson
);
this.sendCommand('request-result', { requestId: event.requestId, value: response.output });
} catch (error) {
this.sendCommand('request-error', {
requestId: event.requestId,
message: errorMessage(error, '实时工具调用失败')
});
}
},
onBridgeEvent(event) {
if (!event?.type) return;
if (event.type === 'sdp-offer') {
void this.answerSdp(event);
return;
}
if (event.type === 'tool-call') {
void this.answerToolCall(event);
return;
}
if (event.type === 'ready') {
this.pendingStart?.resolve();
this.pendingStart = null;
return;
}
if (event.type === 'fatal-error') {
this.pendingStart?.reject(new Error(event.message || '实时语音启动失败'));
this.pendingStart = null;
return;
}
if (event.type === 'resume-result') {
this.pendingResume.get(event.requestId)?.(Boolean(event.success));
this.pendingResume.delete(event.requestId);
return;
}
if (event.type === 'status') this.$emit('status', event.status);
if (event.type === 'trainee-transcript') this.$emit('trainee-transcript', event.text);
if (event.type === 'customer-transcript') this.$emit('customer-transcript', event.text);
if (event.type === 'audio-playback-blocked') this.$emit('audio-playback-blocked', Boolean(event.blocked));
if (event.type === 'terminated') this.$emit('terminated');
if (event.type === 'error') this.$emit('error', event.message);
}
}
};
</script>
<script module="realtimeBridge" lang="renderjs">
import { createRealtimeBrowserEngine } from '../../services/realtime-browser-engine.js';
export default {
mounted() {
this.ownerInstance = this.$ownerInstance;
this.pendingRequests = new Map();
this.requestSequence = 0;
this.startGeneration = 0;
},
methods: {
emit(type, payload = {}) {
const owner = this.ownerInstance || this.$ownerInstance;
owner?.callMethod('onBridgeEvent', { type, ...payload });
},
requestLogic(type, payload) {
this.requestSequence += 1;
const requestId = `render_${Date.now()}_${this.requestSequence}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(requestId, { resolve, reject });
this.emit(type, { requestId, ...payload });
});
},
ensureEngine() {
if (this.engine) return;
this.engine = createRealtimeBrowserEngine({
onStatus: (status) => this.emit('status', { status }),
onTraineeTranscript: (text) => this.emit('trainee-transcript', { text }),
onCustomerTranscript: (text) => this.emit('customer-transcript', { text }),
onAudioPlaybackBlocked: (blocked) => this.emit('audio-playback-blocked', { blocked }),
onToolCall: (callId, name, argsJson) =>
this.requestLogic('tool-call', { callId, name, argsJson }),
onTerminated: () => this.emit('terminated'),
onError: (message) => this.emit('error', { message })
}, (offerSdp, sessionId) =>
this.requestLogic('sdp-offer', { offerSdp, sessionId: sessionId || '' }));
},
settleRequest(command) {
const pending = this.pendingRequests.get(command.requestId);
if (!pending) return;
this.pendingRequests.delete(command.requestId);
if (command.type === 'request-error') {
pending.reject(new Error(command.message || '实时语音请求失败'));
} else {
pending.resolve(command.value);
}
},
cancelRequests() {
this.pendingRequests.forEach(({ reject }) => reject(new Error('实时陪练已结束')));
this.pendingRequests.clear();
},
onCommand(command, previous, ownerInstance) {
if (!command?.id || command.id === previous?.id) return;
this.ownerInstance = ownerInstance || this.ownerInstance || this.$ownerInstance;
if (command.type === 'request-result' || command.type === 'request-error') {
this.settleRequest(command);
return;
}
this.ensureEngine();
if (command.type === 'stop') {
this.startGeneration += 1;
this.cancelRequests();
this.engine.stop();
return;
}
if (command.type === 'resume-audio') {
void this.engine.resumeAudio()
.then((success) => this.emit('resume-result', { requestId: command.requestId, success }))
.catch(() => this.emit('resume-result', { requestId: command.requestId, success: false }));
return;
}
if (command.type !== 'start') return;
const generation = ++this.startGeneration;
void this.engine.start(command.sessionUpdate || {}, command.sessionId || '')
.then(() => {
if (generation === this.startGeneration) this.emit('ready');
})
.catch((error) => {
if (generation !== this.startGeneration) return;
this.emit('fatal-error', {
message: error instanceof Error ? error.message : '实时语音启动失败'
});
});
}
}
};
</script>
<style scoped>
.realtime-app-bridge {
width: 0;
height: 0;
overflow: hidden;
}
</style>
+47 -19
View File
@@ -485,6 +485,18 @@
<button class="secondary-button" @click="openPracticeHistory">查看训练记录</button> <button class="secondary-button" @click="openPracticeHistory">查看训练记录</button>
<button class="primary-button tabbar-safe-action" :disabled="!canStartPractice || busy" @click="start(selectedScenarioId)">再练一次</button> <button class="primary-button tabbar-safe-action" :disabled="!canStartPractice || busy" @click="start(selectedScenarioId)">再练一次</button>
</view> </view>
<!-- #ifdef APP-PLUS -->
<RealtimePracticeAppBridge
ref="realtimeAppBridge"
@status="handleRealtimeStatus"
@trainee-transcript="handleRealtimeTraineeTranscript"
@customer-transcript="handleRealtimeCustomerTranscript"
@audio-playback-blocked="handleRealtimeAudioPlaybackBlocked"
@terminated="handleRealtimeTerminated"
@error="handleRealtimeError"
/>
<!-- #endif -->
</view> </view>
</template> </template>
@@ -492,6 +504,7 @@
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import ChatComposer from '@/components/chat/ChatComposer.vue'; import ChatComposer from '@/components/chat/ChatComposer.vue';
import RealtimePracticeAppBridge from '@/components/practice/RealtimePracticeAppBridge.vue';
import type { import type {
PracticeFinishResponse, PracticeFinishResponse,
PracticePrepCard, PracticePrepCard,
@@ -513,7 +526,7 @@ import { searchKnowledge } from '@/services/knowledge';
import { resetPageScroll } from '@/services/navigation'; import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position'; import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech'; import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechCapture, transcribeSpeechFile } from '@/services/speech';
import { startSpeechCapture } from '@/services/speech-capture'; import { isNativeAppRuntime, startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture'; import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope'; import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice'; import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
@@ -572,6 +585,11 @@ const realtimeAudioBlocked = ref(false);
const realtimeSessionId = ref(''); const realtimeSessionId = ref('');
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]); const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : ''); const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
const realtimeAppBridge = ref<{
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
stop: () => void;
resumeAudio: () => Promise<boolean>;
} | null>(null);
const growthDetailsExpanded = ref(false); const growthDetailsExpanded = ref(false);
const assignments = ref<PracticeAssignment[]>([]); const assignments = ref<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]); const practiceHistory = ref<PracticeRecord[]>([]);
@@ -1010,22 +1028,19 @@ const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCu
scrollThreadToBottom(); scrollThreadToBottom();
}; };
const realtimePractice = createRealtimePracticeController({ const handleRealtimeStatus = (nextStatus: typeof realtimeStatus.value) => {
onStatus: (nextStatus) => {
realtimeStatus.value = nextStatus; realtimeStatus.value = nextStatus;
}, };
onTraineeTranscript: (text) => { const handleRealtimeTraineeTranscript = (text: string) => {
if (realtimeActive.value) appendTurn('trainee', text); if (realtimeActive.value) appendTurn('trainee', text);
}, };
onCustomerTranscript: (text) => { const handleRealtimeCustomerTranscript = (text: string) => {
if (realtimeActive.value) appendTurn('customer', text, 0, false); if (realtimeActive.value) appendTurn('customer', text, 0, false);
}, };
onAudioPlaybackBlocked: (blocked) => { const handleRealtimeAudioPlaybackBlocked = (blocked: boolean) => {
if (realtimeActive.value) realtimeAudioBlocked.value = blocked; if (realtimeActive.value) realtimeAudioBlocked.value = blocked;
}, };
onToolCall: (callId, name, argsJson) => const handleRealtimeTerminated = () => {
invokeRealtimeTool(realtimeSessionId.value, callId, name, argsJson).then((res) => res.output),
onTerminated: () => {
if (!realtimeActive.value) return; if (!realtimeActive.value) return;
realtimeActive.value = false; realtimeActive.value = false;
realtimeStatus.value = 'idle'; realtimeStatus.value = 'idle';
@@ -1034,10 +1049,19 @@ const realtimePractice = createRealtimePracticeController({
sessionId.value = ''; sessionId.value = '';
activeAssignmentId.value = undefined; activeAssignmentId.value = undefined;
status.value = 'idle'; status.value = 'idle';
}, };
onError: (text) => { const handleRealtimeError = (text: string) => {
if (realtimeActive.value) message.value = text; if (realtimeActive.value) message.value = text;
} };
const realtimePractice = createRealtimePracticeController({
onStatus: handleRealtimeStatus,
onTraineeTranscript: handleRealtimeTraineeTranscript,
onCustomerTranscript: handleRealtimeCustomerTranscript,
onAudioPlaybackBlocked: handleRealtimeAudioPlaybackBlocked,
onToolCall: (callId, name, argsJson) =>
invokeRealtimeTool(realtimeSessionId.value, callId, name, argsJson).then((res) => res.output),
onTerminated: handleRealtimeTerminated,
onError: handleRealtimeError
}); });
const selectRealtimePersona = (item: RealtimePersonaOption) => { const selectRealtimePersona = (item: RealtimePersonaOption) => {
@@ -1062,7 +1086,8 @@ const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin
const stopRealtime = () => { const stopRealtime = () => {
if (!realtimeActive.value && realtimeStatus.value === 'idle') return; if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
realtimePractice.stop(); if (isNativeAppRuntime()) realtimeAppBridge.value?.stop();
else realtimePractice.stop();
realtimeActive.value = false; realtimeActive.value = false;
realtimeStatus.value = 'idle'; realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false; realtimeAudioBlocked.value = false;
@@ -1093,7 +1118,9 @@ const startRealtime = async () => {
if (!realtimeActive.value) return; if (!realtimeActive.value) return;
realtimeSessionId.value = session.sessionId; realtimeSessionId.value = session.sessionId;
activeScenarioName.value = session.personaName || '实时陪练'; activeScenarioName.value = session.personaName || '实时陪练';
await realtimePractice.start(session.sessionUpdate, session.sessionId); const controller = isNativeAppRuntime() ? realtimeAppBridge.value : realtimePractice;
if (!controller) throw new Error('实时语音模块尚未就绪,请稍后重试');
await controller.start(session.sessionUpdate, session.sessionId);
if (!realtimeActive.value) return; if (!realtimeActive.value) return;
status.value = 'active'; status.value = 'active';
message.value = `已连接「${session.personaName}」,请直接开口说话。`; message.value = `已连接「${session.personaName}」,请直接开口说话。`;
@@ -1107,7 +1134,8 @@ const startRealtime = async () => {
}; };
const resumeRealtimeAudio = async () => { const resumeRealtimeAudio = async () => {
if (await realtimePractice.resumeAudio()) { const controller = isNativeAppRuntime() ? realtimeAppBridge.value : realtimePractice;
if (controller && await controller.resumeAudio()) {
message.value = '已恢复实时语音播放。'; message.value = '已恢复实时语音播放。';
} else { } else {
message.value = '仍无法播放实时语音,请确认设备媒体权限后重试。'; message.value = '仍无法播放实时语音,请确认设备媒体权限后重试。';
+30
View File
@@ -0,0 +1,30 @@
export type RealtimeBrowserStatus =
| 'idle'
| 'connecting'
| 'connected'
| 'listening'
| 'speaking'
| 'calling-tool';
export interface RealtimeBrowserCallbacks {
onStatus: (status: RealtimeBrowserStatus) => void;
onTraineeTranscript: (text: string) => void;
onCustomerTranscript: (text: string) => void;
onAudioPlaybackBlocked: (blocked: boolean) => void;
onToolCall: (callId: string, name: string, argsJson: string) => Promise<string>;
onTerminated: () => void;
onError: (message: string) => void;
}
export interface RealtimeBrowserEngine {
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
stop: () => void;
resumeAudio: () => Promise<boolean>;
}
export const isRealtimeBrowserSupported: () => boolean;
export const createRealtimeBrowserEngine: (
callbacks: RealtimeBrowserCallbacks,
exchangeSdp: (offerSdp: string, sessionId?: string) => Promise<string>
) => RealtimeBrowserEngine;
@@ -0,0 +1,302 @@
const trimTranscript = (value) => (value || '').trim();
const normalizeSdp = (sdp) => `${sdp.replace(/\r\n|\r|\n/g, '\r\n').replace(/\r\n$/, '')}\r\n`;
const TOOL_CALL_FALLBACK_OUTPUT = '工具暂时不可用,请直接回应员工';
export const isRealtimeBrowserSupported = () =>
typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof RTCPeerConnection !== 'undefined';
const waitForIceComplete = (connection) =>
new Promise((resolve) => {
if (connection.iceGatheringState === 'complete') {
resolve();
return;
}
const previous = connection.onicegatheringstatechange;
let timeout;
const done = () => {
clearTimeout(timeout);
connection.onicegatheringstatechange = previous;
resolve();
};
timeout = setTimeout(done, 3000);
connection.onicegatheringstatechange = () => {
previous?.call(connection, new Event('icegatheringstatechange'));
if (connection.iceGatheringState === 'complete') done();
};
});
export const createRealtimeBrowserEngine = (callbacks, exchangeSdp) => {
let activeAttempt = 0;
let connection = null;
let microphone = null;
let speaker = null;
let controlChannel = null;
let cancelSessionWait = null;
let sessionWaitAttempt = null;
let toolCallChain = Promise.resolve();
const isCurrent = (attempt) => activeAttempt === attempt;
const releaseResources = () => {
controlChannel?.close();
controlChannel = null;
microphone?.getTracks().forEach((track) => track.stop());
microphone = null;
if (speaker) {
speaker.pause();
speaker.srcObject = null;
speaker = null;
}
connection?.close();
connection = null;
};
const sendSessionUpdate = (channel, sessionUpdate) => {
if (channel.readyState !== 'open') return;
channel.send(JSON.stringify({ event_id: `event_${Date.now()}`, ...sessionUpdate }));
};
const handleToolCall = (attempt, event) => {
const callId = trimTranscript(event.call_id);
const name = trimTranscript(event.name);
if (!callId || !name) return;
const argsJson = typeof event.arguments === 'string' ? event.arguments : '{}';
toolCallChain = toolCallChain.then(async () => {
if (!isCurrent(attempt)) return;
const channel = controlChannel;
if (!channel || channel.readyState !== 'open') return;
callbacks.onStatus('calling-tool');
let output;
try {
output = await callbacks.onToolCall(callId, name, argsJson);
} catch {
output = TOOL_CALL_FALLBACK_OUTPUT;
}
if (!isCurrent(attempt) || channel.readyState !== 'open') return;
channel.send(JSON.stringify({
event_id: `event_${Date.now()}`,
type: 'conversation.item.create',
item: { type: 'function_call_output', call_id: callId, output }
}));
channel.send(JSON.stringify({ event_id: `event_${Date.now()}`, type: 'response.create' }));
callbacks.onStatus('connected');
}).catch(() => {
// 链内任何意外抛错不得毒化后续工具调用;本次调用软失败,对话继续
});
};
const handleEvent = (raw, attempt, provider) => {
if (!isCurrent(attempt)) return;
let event;
try {
event = JSON.parse(raw);
} catch {
return;
}
if (event.type === 'session.created') provider.onSessionCreated();
if (event.type === 'session.updated') provider.onSessionUpdated();
if (event.type === 'input_audio_buffer.speech_started') callbacks.onStatus('listening');
if (event.type === 'response.created') callbacks.onStatus('speaking');
if (event.type === 'response.done') callbacks.onStatus('connected');
if (event.type === 'conversation.item.input_audio_transcription.completed') {
const transcript = trimTranscript(event.transcript);
if (transcript) callbacks.onTraineeTranscript(transcript);
}
if (event.type === 'response.audio_transcript.done') {
const transcript = trimTranscript(event.transcript);
if (transcript) callbacks.onCustomerTranscript(transcript);
}
if (event.type === 'response.function_call_arguments.done') {
handleToolCall(attempt, event);
}
if (event.type === 'error') {
const error = new Error(event.error?.message || '实时陪练连接异常,请改用录音对练');
callbacks.onError(error.message);
provider.onSessionError(error);
}
};
const bindChannel = (channel, attempt, provider) => {
channel.onmessage = (event) => handleEvent(String(event.data || ''), attempt, provider);
channel.onerror = () => {
if (!isCurrent(attempt)) return;
const error = new Error('实时陪练数据通道异常,请改用录音对练');
callbacks.onError(error.message);
provider.onSessionError(error);
};
};
const stop = () => {
activeAttempt += 1;
cancelSessionWait?.();
cancelSessionWait = null;
sessionWaitAttempt = null;
toolCallChain = Promise.resolve();
releaseResources();
callbacks.onAudioPlaybackBlocked(false);
callbacks.onStatus('idle');
};
const terminate = (attempt, message) => {
if (!isCurrent(attempt)) return;
callbacks.onError(message);
stop();
callbacks.onTerminated();
};
const resumeAudio = async () => {
if (!speaker) return false;
try {
await speaker.play();
callbacks.onAudioPlaybackBlocked(false);
return true;
} catch {
callbacks.onAudioPlaybackBlocked(true);
return false;
}
};
const start = async (sessionUpdate, sessionId) => {
if (!isRealtimeBrowserSupported()) throw new Error('当前设备的 WebView 不支持实时语音,请更新系统 WebView 后重试');
stop();
const attempt = ++activeAttempt;
callbacks.onStatus('connecting');
let localMicrophone = null;
let localConnection = null;
let localChannel = null;
let localSpeaker = null;
let readyTimeout;
let disconnectTimeout;
let sessionUpdateSent = false;
let resolveReady;
let rejectReady;
let sessionReady = null;
const releaseLocalResources = () => {
if (disconnectTimeout) clearTimeout(disconnectTimeout);
localChannel?.close();
localMicrophone?.getTracks().forEach((track) => track.stop());
if (localSpeaker) {
localSpeaker.pause();
localSpeaker.srcObject = null;
}
localConnection?.close();
};
const ensureCurrent = () => {
if (isCurrent(attempt)) return true;
releaseLocalResources();
return false;
};
try {
localMicrophone = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!ensureCurrent()) return;
microphone = localMicrophone;
localConnection = new RTCPeerConnection({ iceServers: [] });
connection = localConnection;
const audioSender = localConnection.addTransceiver('audio', { direction: 'sendrecv' }).sender;
const provider = {
onSessionCreated: () => {
if (!isCurrent(attempt) || !localChannel || sessionUpdateSent) return;
sessionUpdateSent = true;
sendSessionUpdate(localChannel, sessionUpdate);
},
onSessionUpdated: () => {
if (!isCurrent(attempt) || !sessionUpdateSent) return;
const track = localMicrophone?.getAudioTracks()[0] || null;
void audioSender.replaceTrack(track)
.then(() => {
if (isCurrent(attempt)) resolveReady?.();
})
.catch((error) => rejectReady?.(error instanceof Error ? error : new Error('实时陪练麦克风启动失败')));
},
onSessionError: (error) => {
rejectReady?.(error);
terminate(attempt, error.message);
}
};
localChannel = localConnection.createDataChannel('oai-events');
controlChannel = localChannel;
bindChannel(localChannel, attempt, provider);
localConnection.ondatachannel = (event) => bindChannel(event.channel, attempt, provider);
localConnection.ontrack = (event) => {
if (!isCurrent(attempt)) return;
const stream = event.streams[0] || new MediaStream([event.track]);
localSpeaker = new Audio();
speaker = localSpeaker;
localSpeaker.autoplay = true;
localSpeaker.setAttribute('playsinline', 'true');
localSpeaker.srcObject = stream;
void localSpeaker.play()
.then(() => {
if (isCurrent(attempt)) callbacks.onAudioPlaybackBlocked(false);
})
.catch(() => {
if (isCurrent(attempt)) callbacks.onAudioPlaybackBlocked(true);
});
};
localConnection.onconnectionstatechange = () => {
if (!isCurrent(attempt)) return;
if (localConnection?.connectionState === 'failed' || localConnection?.connectionState === 'closed') {
terminate(attempt, '实时陪练已断开,请改用录音对练');
return;
}
if (localConnection?.connectionState === 'disconnected') {
disconnectTimeout ??= setTimeout(() => {
if (localConnection?.connectionState === 'disconnected') {
terminate(attempt, '实时陪练已断开,请改用录音对练');
}
}, 3000);
} else if (disconnectTimeout) {
clearTimeout(disconnectTimeout);
disconnectTimeout = undefined;
}
};
const offer = await localConnection.createOffer();
if (!ensureCurrent()) return;
await localConnection.setLocalDescription(offer);
if (!ensureCurrent()) return;
await waitForIceComplete(localConnection);
if (!ensureCurrent()) return;
const offerSdp = localConnection.localDescription?.sdp;
if (!offerSdp) throw new Error('实时陪练连接请求创建失败');
const answerSdp = await exchangeSdp(normalizeSdp(offerSdp), sessionId);
if (!ensureCurrent()) return;
sessionReady = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
readyTimeout = setTimeout(() => reject(new Error('实时陪练会话初始化超时,请使用录音对练')), 15000);
});
void sessionReady.catch(() => undefined);
sessionWaitAttempt = attempt;
cancelSessionWait = () => {
if (readyTimeout) clearTimeout(readyTimeout);
rejectReady?.(new Error('实时陪练已结束'));
};
await localConnection.setRemoteDescription({ type: 'answer', sdp: normalizeSdp(answerSdp) });
if (!ensureCurrent()) return;
await sessionReady;
if (!ensureCurrent()) return;
callbacks.onStatus('connected');
} catch (error) {
if (!isCurrent(attempt)) {
releaseLocalResources();
return;
}
stop();
throw error instanceof Error ? error : new Error('实时陪练连接失败,请改用录音对练');
} finally {
if (readyTimeout) clearTimeout(readyTimeout);
if (sessionWaitAttempt === attempt) {
sessionWaitAttempt = null;
cancelSessionWait = null;
}
}
};
return { start, stop, resumeAudio };
};
+25 -335
View File
@@ -1,7 +1,11 @@
import { apiRequest } from './api'; import { apiRequest } from './api';
import { isNativeAppRuntime } from './speech-capture'; import { isNativeAppRuntime } from './speech-capture';
import { createRealtimeBrowserEngine } from './realtime-browser-engine.js';
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking' | 'calling-tool'; import type {
RealtimeBrowserCallbacks,
RealtimeBrowserEngine,
RealtimeBrowserStatus
} from './realtime-browser-engine.js';
export interface RealtimePersonaOption { export interface RealtimePersonaOption {
id: string; id: string;
@@ -28,36 +32,11 @@ interface RealtimeSdpResponse {
model: string; model: string;
} }
interface RealtimeEvent { export interface RealtimePracticeCallbacks extends RealtimeBrowserCallbacks {
type?: string; onStatus: (status: RealtimeBrowserStatus) => void;
transcript?: string;
call_id?: string;
name?: string;
arguments?: string;
error?: { message?: string };
} }
interface ProviderEventHandlers { export interface RealtimePracticeController extends RealtimeBrowserEngine {}
onSessionCreated: () => void;
onSessionUpdated: () => void;
onSessionError: (error: Error) => void;
}
export interface RealtimePracticeCallbacks {
onStatus: (status: RealtimeStatus) => void;
onTraineeTranscript: (text: string) => void;
onCustomerTranscript: (text: string) => void;
onAudioPlaybackBlocked: (blocked: boolean) => void;
onToolCall: (callId: string, name: string, argsJson: string) => Promise<string>;
onTerminated: () => void;
onError: (message: string) => void;
}
export interface RealtimePracticeController {
start: (sessionUpdate: Record<string, unknown>, sessionId?: string) => Promise<void>;
stop: () => void;
resumeAudio: () => Promise<boolean>;
}
export const fetchRealtimePersonas = () => export const fetchRealtimePersonas = () =>
apiRequest<RealtimePersonaOption[]>({ apiRequest<RealtimePersonaOption[]>({
@@ -82,316 +61,27 @@ export const invokeRealtimeTool = (sessionId: string, callId: string, name: stri
timeout: 20000 timeout: 20000
}); });
const isSupported = () => export const exchangeRealtimeSdp = async (offerSdp: string, sessionId?: string) => {
!isNativeAppRuntime()
&& typeof navigator !== 'undefined'
&& Boolean(navigator.mediaDevices?.getUserMedia)
&& typeof RTCPeerConnection !== 'undefined';
const waitForIceComplete = (connection: RTCPeerConnection) =>
new Promise<void>((resolve) => {
if (connection.iceGatheringState === 'complete') {
resolve();
return;
}
const previous = connection.onicegatheringstatechange;
let timeout: ReturnType<typeof setTimeout>;
const done = () => {
clearTimeout(timeout);
connection.onicegatheringstatechange = previous;
resolve();
};
timeout = setTimeout(done, 3000);
connection.onicegatheringstatechange = () => {
previous?.call(connection, new Event('icegatheringstatechange'));
if (connection.iceGatheringState === 'complete') done();
};
});
const normalizeSdp = (sdp: string) => `${sdp.replace(/\r\n|\r|\n/g, '\r\n').replace(/\r\n$/, '')}\r\n`;
const trimTranscript = (value?: string) => (value || '').trim();
const TOOL_CALL_FALLBACK_OUTPUT = '工具暂时不可用,请直接回应员工';
export const createRealtimePracticeController = (callbacks: RealtimePracticeCallbacks): RealtimePracticeController => {
let activeAttempt = 0;
let connection: RTCPeerConnection | null = null;
let microphone: MediaStream | null = null;
let speaker: HTMLAudioElement | null = null;
let controlChannel: RTCDataChannel | null = null;
let cancelSessionWait: (() => void) | null = null;
let sessionWaitAttempt: number | null = null;
let toolCallChain: Promise<void> = Promise.resolve();
const isCurrent = (attempt: number) => activeAttempt === attempt;
const releaseResources = () => {
controlChannel?.close();
controlChannel = null;
microphone?.getTracks().forEach((track) => track.stop());
microphone = null;
if (speaker) {
speaker.pause();
speaker.srcObject = null;
speaker = null;
}
connection?.close();
connection = null;
};
// 服务端下发的 session.update 载荷原样转发,仅补 event_id
const sendSessionUpdate = (channel: RTCDataChannel, sessionUpdate: Record<string, unknown>) => {
if (channel.readyState !== 'open') return;
channel.send(JSON.stringify({ event_id: `event_${Date.now()}`, ...sessionUpdate }));
};
const handleToolCall = (attempt: number, event: RealtimeEvent) => {
const callId = trimTranscript(event.call_id);
const name = trimTranscript(event.name);
if (!callId || !name) return;
const argsJson = typeof event.arguments === 'string' ? event.arguments : '{}';
// 多次工具调用串行化,避免打断竞态下回传交错
toolCallChain = toolCallChain.then(async () => {
if (!isCurrent(attempt)) return;
const channel = controlChannel;
if (!channel || channel.readyState !== 'open') return;
callbacks.onStatus('calling-tool');
let output: string;
try {
output = await callbacks.onToolCall(callId, name, argsJson);
} catch {
output = TOOL_CALL_FALLBACK_OUTPUT;
}
if (!isCurrent(attempt) || channel.readyState !== 'open') return;
channel.send(JSON.stringify({
event_id: `event_${Date.now()}`,
type: 'conversation.item.create',
item: { type: 'function_call_output', call_id: callId, output }
}));
channel.send(JSON.stringify({ event_id: `event_${Date.now()}`, type: 'response.create' }));
callbacks.onStatus('connected');
}).catch(() => {
// 链内任何意外抛错不得毒化后续工具调用;本次调用软失败,对话继续
});
};
const handleEvent = (raw: string, attempt: number, provider: ProviderEventHandlers) => {
if (!isCurrent(attempt)) return;
let event: RealtimeEvent;
try {
event = JSON.parse(raw) as RealtimeEvent;
} catch {
return;
}
if (event.type === 'session.created') provider.onSessionCreated();
if (event.type === 'session.updated') provider.onSessionUpdated();
if (event.type === 'input_audio_buffer.speech_started') callbacks.onStatus('listening');
if (event.type === 'response.created') callbacks.onStatus('speaking');
if (event.type === 'response.done') callbacks.onStatus('connected');
if (event.type === 'conversation.item.input_audio_transcription.completed') {
const transcript = trimTranscript(event.transcript);
if (transcript) callbacks.onTraineeTranscript(transcript);
}
if (event.type === 'response.audio_transcript.done') {
const transcript = trimTranscript(event.transcript);
if (transcript) callbacks.onCustomerTranscript(transcript);
}
if (event.type === 'response.function_call_arguments.done') {
handleToolCall(attempt, event);
}
if (event.type === 'error') {
const error = new Error(event.error?.message || '实时陪练连接异常,请改用录音对练');
callbacks.onError(error.message);
provider.onSessionError(error);
}
};
const bindChannel = (channel: RTCDataChannel, attempt: number, provider: ProviderEventHandlers) => {
channel.onmessage = (event) => handleEvent(String(event.data || ''), attempt, provider);
channel.onerror = () => {
if (!isCurrent(attempt)) return;
const error = new Error('实时陪练数据通道异常,请改用录音对练');
callbacks.onError(error.message);
provider.onSessionError(error);
};
};
const stop = () => {
activeAttempt += 1;
cancelSessionWait?.();
cancelSessionWait = null;
sessionWaitAttempt = null;
toolCallChain = Promise.resolve();
releaseResources();
callbacks.onAudioPlaybackBlocked(false);
callbacks.onStatus('idle');
};
const terminate = (attempt: number, message: string) => {
if (!isCurrent(attempt)) return;
callbacks.onError(message);
stop();
callbacks.onTerminated();
};
const resumeAudio = async () => {
if (!speaker) return false;
try {
await speaker.play();
callbacks.onAudioPlaybackBlocked(false);
return true;
} catch {
callbacks.onAudioPlaybackBlocked(true);
return false;
}
};
const start = async (sessionUpdate: Record<string, unknown>, sessionId?: string) => {
if (!isSupported()) throw new Error('当前设备不支持实时语音,请使用录音对练');
stop();
const attempt = ++activeAttempt;
callbacks.onStatus('connecting');
let localMicrophone: MediaStream | null = null;
let localConnection: RTCPeerConnection | null = null;
let localChannel: RTCDataChannel | null = null;
let localSpeaker: HTMLAudioElement | null = null;
let readyTimeout: ReturnType<typeof setTimeout> | undefined;
let disconnectTimeout: ReturnType<typeof setTimeout> | undefined;
let sessionUpdateSent = false;
let resolveReady: (() => void) | undefined;
let rejectReady: ((error: Error) => void) | undefined;
let sessionReady: Promise<void> | null = null;
const releaseLocalResources = () => {
if (disconnectTimeout) clearTimeout(disconnectTimeout);
localChannel?.close();
localMicrophone?.getTracks().forEach((track) => track.stop());
if (localSpeaker) {
localSpeaker.pause();
localSpeaker.srcObject = null;
}
localConnection?.close();
};
const ensureCurrent = () => {
if (isCurrent(attempt)) return true;
releaseLocalResources();
return false;
};
try {
localMicrophone = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!ensureCurrent()) return;
microphone = localMicrophone;
localConnection = new RTCPeerConnection({ iceServers: [] });
connection = localConnection;
const audioSender = localConnection.addTransceiver('audio', { direction: 'sendrecv' }).sender;
const provider: ProviderEventHandlers = {
onSessionCreated: () => {
if (!isCurrent(attempt) || !localChannel || sessionUpdateSent) return;
sessionUpdateSent = true;
sendSessionUpdate(localChannel, sessionUpdate);
},
onSessionUpdated: () => {
if (!isCurrent(attempt) || !sessionUpdateSent) return;
const track = localMicrophone?.getAudioTracks()[0] || null;
void audioSender.replaceTrack(track)
.then(() => {
if (isCurrent(attempt)) resolveReady?.();
})
.catch((error: unknown) => rejectReady?.(error instanceof Error ? error : new Error('实时陪练麦克风启动失败')));
},
onSessionError: (error) => {
rejectReady?.(error);
terminate(attempt, error.message);
}
};
localChannel = localConnection.createDataChannel('oai-events');
controlChannel = localChannel;
bindChannel(localChannel, attempt, provider);
localConnection.ondatachannel = (event) => bindChannel(event.channel, attempt, provider);
localConnection.ontrack = (event) => {
if (!isCurrent(attempt)) return;
const stream = event.streams[0] || new MediaStream([event.track]);
localSpeaker = new Audio();
speaker = localSpeaker;
localSpeaker.autoplay = true;
localSpeaker.setAttribute('playsinline', 'true');
localSpeaker.srcObject = stream;
void localSpeaker.play()
.then(() => {
if (isCurrent(attempt)) callbacks.onAudioPlaybackBlocked(false);
})
.catch(() => {
if (isCurrent(attempt)) callbacks.onAudioPlaybackBlocked(true);
});
};
localConnection.onconnectionstatechange = () => {
if (!isCurrent(attempt)) return;
if (localConnection?.connectionState === 'failed' || localConnection?.connectionState === 'closed') {
terminate(attempt, '实时陪练已断开,请改用录音对练');
return;
}
if (localConnection?.connectionState === 'disconnected') {
disconnectTimeout ??= setTimeout(() => {
if (localConnection?.connectionState === 'disconnected') {
terminate(attempt, '实时陪练已断开,请改用录音对练');
}
}, 3000);
} else if (disconnectTimeout) {
clearTimeout(disconnectTimeout);
disconnectTimeout = undefined;
}
};
const offer = await localConnection.createOffer();
if (!ensureCurrent()) return;
await localConnection.setLocalDescription(offer);
if (!ensureCurrent()) return;
await waitForIceComplete(localConnection);
if (!ensureCurrent()) return;
const offerSdp = localConnection.localDescription?.sdp;
if (!offerSdp) throw new Error('实时陪练连接请求创建失败');
const response = await apiRequest<RealtimeSdpResponse>({ const response = await apiRequest<RealtimeSdpResponse>({
url: '/api/train/practice/realtime/sdp', url: '/api/train/practice/realtime/sdp',
method: 'POST', method: 'POST',
data: { offerSdp: normalizeSdp(offerSdp), sessionId: sessionId || null }, data: { offerSdp, sessionId: sessionId || null },
timeout: 30000 timeout: 30000
}); });
if (!ensureCurrent()) return; return response.answerSdp;
sessionReady = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
readyTimeout = setTimeout(() => reject(new Error('实时陪练会话初始化超时,请使用录音对练')), 15000);
});
void sessionReady.catch(() => undefined);
sessionWaitAttempt = attempt;
cancelSessionWait = () => {
if (readyTimeout) clearTimeout(readyTimeout);
rejectReady?.(new Error('实时陪练已结束'));
};
await localConnection.setRemoteDescription({ type: 'answer', sdp: normalizeSdp(response.answerSdp) });
if (!ensureCurrent()) return;
const waitForSessionReady = sessionReady;
if (!waitForSessionReady) throw new Error('实时陪练会话初始化失败');
await waitForSessionReady;
if (!ensureCurrent()) return;
callbacks.onStatus('connected');
} catch (error) {
if (!isCurrent(attempt)) {
releaseLocalResources();
return;
}
stop();
throw error instanceof Error ? error : new Error('实时陪练连接失败,请改用录音对练');
} finally {
if (readyTimeout) clearTimeout(readyTimeout);
if (sessionWaitAttempt === attempt) {
sessionWaitAttempt = null;
cancelSessionWait = null;
}
}
}; };
return { start, stop, resumeAudio }; export const createRealtimePracticeController = (
callbacks: RealtimePracticeCallbacks
): RealtimePracticeController => {
const engine = createRealtimeBrowserEngine(callbacks, exchangeRealtimeSdp);
return {
...engine,
start: async (sessionUpdate, sessionId) => {
if (isNativeAppRuntime()) {
throw new Error('APP 实时语音应由 WebView 桥接启动');
}
await engine.start(sessionUpdate, sessionId);
}
};
}; };
+32 -19
View File
@@ -5,39 +5,52 @@ import test from 'node:test';
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8'); const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
test('实时陪练通过受认证代理接入,前端不携带供应商密钥、不自组会话配置', async () => { test('实时陪练通过受认证代理接入,前端不携带供应商密钥、不自组会话配置', async () => {
const [service, page, backendService, backendSessionService, backendPersonaRegistry] = await Promise.all([ const [service, engine, appBridge, page, backendService, backendSessionService, backendPersonaRegistry] = await Promise.all([
source('../src/services/realtime-practice.ts'), source('../src/services/realtime-practice.ts'),
source('../src/services/realtime-browser-engine.js'),
source('../src/components/practice/RealtimePracticeAppBridge.vue'),
source('../src/pages/user/practice/index.vue'), source('../src/pages/user/practice/index.vue'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java'), source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java'), source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimeSessionService.java'),
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java') source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePersonaRegistry.java')
]); ]);
const transport = `${service}\n${engine}`;
// 传输与事件协议 // 传输与事件协议
assert.match(service, /\/api\/train\/practice\/realtime\/sdp/); assert.match(service, /\/api\/train\/practice\/realtime\/sdp/);
assert.match(service, /conversation\.item\.input_audio_transcription\.completed/); assert.match(transport, /conversation\.item\.input_audio_transcription\.completed/);
assert.match(service, /response\.audio_transcript\.done/); assert.match(transport, /response\.audio_transcript\.done/);
assert.match(service, /session\.created/); assert.match(transport, /session\.created/);
assert.match(service, /session\.updated/); assert.match(transport, /session\.updated/);
assert.match(service, /activeAttempt/); assert.match(transport, /activeAttempt/);
assert.match(service, /resumeAudio/); assert.match(transport, /resumeAudio/);
// 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端 // 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端
assert.match(service, /\/api\/train\/practice\/realtime\/session/); assert.match(service, /\/api\/train\/practice\/realtime\/session/);
assert.match(service, /sendSessionUpdate/); assert.match(transport, /sendSessionUpdate/);
assert.doesNotMatch(service, /instructions/); assert.doesNotMatch(transport, /instructions/);
assert.doesNotMatch(service, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/); assert.doesNotMatch(transport, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/);
assert.doesNotMatch(service, /semantic_vad/); assert.doesNotMatch(transport, /semantic_vad/);
assert.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/); assert.doesNotMatch(transport, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
// function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议 // function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议
assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/); assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/);
assert.match(service, /response\.function_call_arguments\.done/); assert.match(transport, /response\.function_call_arguments\.done/);
assert.match(service, /function_call_output/); assert.match(transport, /function_call_output/);
assert.match(service, /response\.create/); assert.match(transport, /response\.create/);
assert.match(service, /toolCallChain/); assert.match(transport, /toolCallChain/);
assert.match(service, /工具暂时不可用,请直接回应员工/); assert.match(transport, /工具暂时不可用,请直接回应员工/);
assert.match(service, /calling-tool/); assert.match(transport, /calling-tool/);
// App-Plus:WebRTC 在 renderjs/WebView 执行,鉴权 SDP 与工具调用回到逻辑层。
assert.match(appBridge, /lang="renderjs"/);
assert.match(appBridge, /createRealtimeBrowserEngine/);
assert.match(appBridge, /exchangeRealtimeSdp/);
assert.match(appBridge, /invokeRealtimeTool/);
assert.match(appBridge, /owner.*callMethod\('onBridgeEvent'/);
assert.match(page, /RealtimePracticeAppBridge/);
assert.match(page, /isNativeAppRuntime\(\) \? realtimeAppBridge\.value : realtimePractice/);
assert.doesNotMatch(transport, /!isNativeAppRuntime\(\)/);
// 服务端是会话配置的唯一组装点 // 服务端是会话配置的唯一组装点
assert.match(backendSessionService, /session\.update/); assert.match(backendSessionService, /session\.update/);
@@ -65,7 +78,7 @@ test('实时陪练通过受认证代理接入,前端不携带供应商密钥
}); });
test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => { test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => {
const service = await source('../src/services/realtime-practice.ts'); const service = await source('../src/services/realtime-browser-engine.js');
// 串行链必须基于上一次调用收尾,且带末端 catch 防毒化 // 串行链必须基于上一次调用收尾,且带末端 catch 防毒化
assert.match(service, /toolCallChain = toolCallChain\.then/); assert.match(service, /toolCallChain = toolCallChain\.then/);