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:
@@ -5,39 +5,52 @@ import test from 'node:test';
|
||||
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
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-browser-engine.js'),
|
||||
source('../src/components/practice/RealtimePracticeAppBridge.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/AihrRealtimeSessionService.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, /conversation\.item\.input_audio_transcription\.completed/);
|
||||
assert.match(service, /response\.audio_transcript\.done/);
|
||||
assert.match(service, /session\.created/);
|
||||
assert.match(service, /session\.updated/);
|
||||
assert.match(service, /activeAttempt/);
|
||||
assert.match(service, /resumeAudio/);
|
||||
assert.match(transport, /conversation\.item\.input_audio_transcription\.completed/);
|
||||
assert.match(transport, /response\.audio_transcript\.done/);
|
||||
assert.match(transport, /session\.created/);
|
||||
assert.match(transport, /session\.updated/);
|
||||
assert.match(transport, /activeAttempt/);
|
||||
assert.match(transport, /resumeAudio/);
|
||||
|
||||
// 方案B 架构:session.update 由服务端组装,浏览器只原样转发;instructions/音色不下放前端
|
||||
assert.match(service, /\/api\/train\/practice\/realtime\/session/);
|
||||
assert.match(service, /sendSessionUpdate/);
|
||||
assert.doesNotMatch(service, /instructions/);
|
||||
assert.doesNotMatch(service, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/);
|
||||
assert.doesNotMatch(service, /semantic_vad/);
|
||||
assert.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
|
||||
assert.match(transport, /sendSessionUpdate/);
|
||||
assert.doesNotMatch(transport, /instructions/);
|
||||
assert.doesNotMatch(transport, /REALTIME_PRACTICE_VOICES|createRealtimePracticeSessionUpdate|normalizeRealtimePracticeVoice/);
|
||||
assert.doesNotMatch(transport, /semantic_vad/);
|
||||
assert.doesNotMatch(transport, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
|
||||
|
||||
// function calling 工具桥:事件分支、串行链、软着陆兜底、回传协议
|
||||
assert.match(service, /\/api\/train\/practice\/realtime\/tools\/invoke/);
|
||||
assert.match(service, /response\.function_call_arguments\.done/);
|
||||
assert.match(service, /function_call_output/);
|
||||
assert.match(service, /response\.create/);
|
||||
assert.match(service, /toolCallChain/);
|
||||
assert.match(service, /工具暂时不可用,请直接回应员工/);
|
||||
assert.match(service, /calling-tool/);
|
||||
assert.match(transport, /response\.function_call_arguments\.done/);
|
||||
assert.match(transport, /function_call_output/);
|
||||
assert.match(transport, /response\.create/);
|
||||
assert.match(transport, /toolCallChain/);
|
||||
assert.match(transport, /工具暂时不可用,请直接回应员工/);
|
||||
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/);
|
||||
@@ -65,7 +78,7 @@ test('实时陪练通过受认证代理接入,前端不携带供应商密钥
|
||||
});
|
||||
|
||||
test('工具调用串行化与竞态防护:链式执行、attempt 双检、stop 重置', async () => {
|
||||
const service = await source('../src/services/realtime-practice.ts');
|
||||
const service = await source('../src/services/realtime-browser-engine.js');
|
||||
|
||||
// 串行链必须基于上一次调用收尾,且带末端 catch 防毒化
|
||||
assert.match(service, /toolCallChain = toolCallChain\.then/);
|
||||
|
||||
Reference in New Issue
Block a user