feat: add qwen realtime practice beta
This commit is contained in:
+16
@@ -12,6 +12,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
|
|||||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
|
||||||
@@ -24,6 +26,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.StartResponse;
|
|||||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
|
||||||
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
|
import org.dromara.aihr.domain.AihrPracticeDto.TurnResponse;
|
||||||
import org.dromara.aihr.service.AihrPracticeSeedService;
|
import org.dromara.aihr.service.AihrPracticeSeedService;
|
||||||
|
import org.dromara.aihr.service.AihrRealtimePracticeService;
|
||||||
import org.dromara.common.core.constant.TenantConstants;
|
import org.dromara.common.core.constant.TenantConstants;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
import org.dromara.common.core.domain.model.LoginUser;
|
import org.dromara.common.core.domain.model.LoginUser;
|
||||||
@@ -57,6 +60,7 @@ public class AihrPracticeController {
|
|||||||
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
private static final String HR_OPERATOR_ROLE = "hr_operator";
|
||||||
|
|
||||||
private final AihrPracticeSeedService practiceSeedService;
|
private final AihrPracticeSeedService practiceSeedService;
|
||||||
|
private final AihrRealtimePracticeService realtimePracticeService;
|
||||||
|
|
||||||
@GetMapping("/scenarios")
|
@GetMapping("/scenarios")
|
||||||
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
|
public R<List<ScenarioResponse>> scenarios(@RequestParam(required = false) String position,
|
||||||
@@ -129,6 +133,18 @@ public class AihrPracticeController {
|
|||||||
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
|
return R.ok(practiceSeedService.start(bindMobileStartIdentity(request)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only proxies the WebRTC SDP handshake. Media flows directly between the H5 client and Model Studio.
|
||||||
|
*/
|
||||||
|
@PostMapping("/realtime/sdp")
|
||||||
|
public R<RealtimeSdpResponse> realtimeSdp(@RequestBody RealtimeSdpRequest request) {
|
||||||
|
String username = currentAppUsername();
|
||||||
|
if (username.isBlank()) {
|
||||||
|
return R.fail("请使用员工端账号开启实时陪练");
|
||||||
|
}
|
||||||
|
return R.ok(realtimePracticeService.exchangeSdp(request, username));
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/turn")
|
@PostMapping("/turn")
|
||||||
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
|
public R<TurnResponse> turn(@RequestBody TurnRequest request) {
|
||||||
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId()));
|
return R.ok(practiceSeedService.turn(request, currentAppUsername(), currentAppUserId()));
|
||||||
|
|||||||
+6
@@ -107,6 +107,12 @@ public final class AihrPracticeDto {
|
|||||||
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
|
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record RealtimeSdpRequest(String offerSdp) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RealtimeSdpResponse(String answerSdp, String model) {
|
||||||
|
}
|
||||||
|
|
||||||
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) {
|
public record TurnRequest(String sessionId, Integer roundIndex, String traineeText, String traineeAudioUrl, Long traineeAudioOssId, Boolean regenerate, String style) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package org.dromara.aihr.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
|
||||||
|
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
|
import org.redisson.api.RateType;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal WebRTC signaling boundary for the H5 realtime-practice beta.
|
||||||
|
* Audio is deliberately not proxied or persisted here.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class AihrRealtimePracticeService {
|
||||||
|
|
||||||
|
private static final int MAX_SDP_CHARS = 64 * 1024;
|
||||||
|
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Value("${aihr.realtime-practice.enabled:${AIHR_REALTIME_PRACTICE_ENABLED:false}}")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
@Value("${aihr.realtime-practice.endpoint:${AIHR_QWEN_REALTIME_ENDPOINT:}}")
|
||||||
|
private String endpoint;
|
||||||
|
|
||||||
|
@Value("${aihr.realtime-practice.api-key:${AIHR_QWEN_REALTIME_API_KEY:}}")
|
||||||
|
private String apiKey;
|
||||||
|
|
||||||
|
@Value("${aihr.realtime-practice.model:${AIHR_QWEN_REALTIME_MODEL:qwen3.5-omni-flash-realtime}}")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
public RealtimeSdpResponse exchangeSdp(RealtimeSdpRequest request, String username) {
|
||||||
|
if (!enabled) {
|
||||||
|
throw new ServiceException("实时陪练尚未启用,请使用录音对练");
|
||||||
|
}
|
||||||
|
String offer = request == null || request.offerSdp() == null ? "" : request.offerSdp();
|
||||||
|
String verifiedOffer = offer.trim();
|
||||||
|
if (verifiedOffer.isBlank() || offer.length() > MAX_SDP_CHARS || !verifiedOffer.contains("m=audio")) {
|
||||||
|
throw new ServiceException("实时陪练连接请求无效");
|
||||||
|
}
|
||||||
|
String account = username == null ? "" : username.trim();
|
||||||
|
if (account.isBlank()) {
|
||||||
|
throw new ServiceException("请使用员工端账号开启实时陪练");
|
||||||
|
}
|
||||||
|
enforceRateLimit(account);
|
||||||
|
|
||||||
|
URI signalingUri = signalingUri(endpoint, model);
|
||||||
|
if (apiKey == null || apiKey.isBlank()) {
|
||||||
|
throw new ServiceException("实时陪练尚未完成模型配置,请使用录音对练");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
HttpRequest httpRequest = HttpRequest.newBuilder()
|
||||||
|
.uri(signalingUri)
|
||||||
|
.timeout(Duration.ofSeconds(20))
|
||||||
|
.header("Content-Type", "application/sdp")
|
||||||
|
.header("Authorization", "Bearer " + apiKey.trim())
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(offer, StandardCharsets.UTF_8))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = HTTP_CLIENT.send(httpRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
String answer = response.body() == null ? "" : response.body();
|
||||||
|
String verifiedAnswer = answer.trim();
|
||||||
|
if (response.statusCode() != 200 || verifiedAnswer.isBlank() || answer.length() > MAX_SDP_CHARS || !verifiedAnswer.contains("m=audio")) {
|
||||||
|
log.warn("qwen realtime SDP exchange failed: status={}", response.statusCode());
|
||||||
|
throw new ServiceException("实时陪练暂时无法连接,请使用录音对练");
|
||||||
|
}
|
||||||
|
return new RealtimeSdpResponse(answer, normalizedModel(model));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("qwen realtime SDP connection failed", e);
|
||||||
|
throw new ServiceException("实时陪练网络连接失败,请使用录音对练");
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new ServiceException("实时陪练连接被中断,请重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static URI signalingUri(String configuredEndpoint, String configuredModel) {
|
||||||
|
String value = configuredEndpoint == null ? "" : configuredEndpoint.trim();
|
||||||
|
URI base;
|
||||||
|
try {
|
||||||
|
base = URI.create(value);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ServiceException("实时陪练模型地址配置无效");
|
||||||
|
}
|
||||||
|
String host = base.getHost() == null ? "" : base.getHost().toLowerCase(Locale.ROOT);
|
||||||
|
if (!"https".equalsIgnoreCase(base.getScheme()) || !host.endsWith(".maas.aliyuncs.com")
|
||||||
|
|| base.getUserInfo() != null || base.getQuery() != null || base.getFragment() != null) {
|
||||||
|
throw new ServiceException("实时陪练模型地址配置无效");
|
||||||
|
}
|
||||||
|
String basePath = base.getPath() == null ? "" : base.getPath().replaceAll("/+$", "");
|
||||||
|
if (!basePath.isEmpty()) {
|
||||||
|
throw new ServiceException("实时陪练模型地址配置无效");
|
||||||
|
}
|
||||||
|
String encodedModel = URLEncoder.encode(normalizedModel(configuredModel), StandardCharsets.UTF_8);
|
||||||
|
return URI.create(base.getScheme() + "://" + host + "/api/v1/webrtc/realtime?model=" + encodedModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizedModel(String configuredModel) {
|
||||||
|
String value = configuredModel == null ? "" : configuredModel.trim();
|
||||||
|
if (!value.matches("qwen3\\.5-omni-(flash|plus)-realtime")) {
|
||||||
|
throw new ServiceException("实时陪练模型配置无效");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void enforceRateLimit(String username) {
|
||||||
|
String key = "aihr:practice:realtime:sdp:" + LoginHelper.getTenantId() + ":" + username;
|
||||||
|
try {
|
||||||
|
if (RedisUtils.rateLimiter(key, RateType.OVERALL, 3, 60) < 0) {
|
||||||
|
throw new ServiceException("实时陪练连接过于频繁,请稍后重试", 429);
|
||||||
|
}
|
||||||
|
} catch (ServiceException known) {
|
||||||
|
throw known;
|
||||||
|
} catch (RuntimeException unavailable) {
|
||||||
|
log.warn("realtime practice rate limiter unavailable");
|
||||||
|
throw new ServiceException("实时陪练限流暂时不可用,请稍后重试", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package org.dromara.aihr.service;
|
||||||
|
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class AihrRealtimePracticeServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Tag("dev")
|
||||||
|
void signalingUriOnlyAllowsOfficialModelStudioHostsAndRealtimeModels() {
|
||||||
|
assertEquals(
|
||||||
|
"https://workspace.cn-beijing.maas.aliyuncs.com/api/v1/webrtc/realtime?model=qwen3.5-omni-flash-realtime",
|
||||||
|
AihrRealtimePracticeService.signalingUri(
|
||||||
|
"https://workspace.cn-beijing.maas.aliyuncs.com",
|
||||||
|
"qwen3.5-omni-flash-realtime"
|
||||||
|
).toString()
|
||||||
|
);
|
||||||
|
assertThrows(ServiceException.class, () -> AihrRealtimePracticeService.signalingUri(
|
||||||
|
"https://example.com", "qwen3.5-omni-flash-realtime"
|
||||||
|
));
|
||||||
|
assertThrows(ServiceException.class, () -> AihrRealtimePracticeService.signalingUri(
|
||||||
|
"https://workspace.cn-beijing.maas.aliyuncs.com", "other-model"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,15 @@
|
|||||||
>
|
>
|
||||||
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
|
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="status === 'idle' || status === 'finished'"
|
||||||
|
class="secondary-button realtime-start-button"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="startRealtime"
|
||||||
|
>
|
||||||
|
实时陪练 Beta
|
||||||
|
</button>
|
||||||
|
<text class="realtime-beta-note">自然打断、边听边说;本次体验不计入训练成绩。</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loggedIn && practiceView === 'prep'" class="panel assignment-panel recent-panel">
|
<view v-if="loggedIn && practiceView === 'prep'" class="panel assignment-panel recent-panel">
|
||||||
@@ -159,7 +168,7 @@
|
|||||||
<view class="chat-head">
|
<view class="chat-head">
|
||||||
<view class="chat-head-row">
|
<view class="chat-head-row">
|
||||||
<text class="chat-title">{{ activeScenarioName }}</text>
|
<text class="chat-title">{{ activeScenarioName }}</text>
|
||||||
<text class="chat-context">第 {{ Math.min(roundIndex + 1, 3) }}/3 轮</text>
|
<text class="chat-context">{{ realtimeActive ? `实时 · ${realtimeStatusLabel}` : `第 ${Math.min(roundIndex + 1, 3)}/3 轮` }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="hasCoachState" class="coach-strip">
|
<view v-if="hasCoachState" class="coach-strip">
|
||||||
<view class="coach-metric">
|
<view class="coach-metric">
|
||||||
@@ -253,7 +262,18 @@
|
|||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<view v-if="canAdjustCoach" class="style-chip-row">
|
<view v-if="realtimeActive" class="realtime-practice-panel">
|
||||||
|
<view>
|
||||||
|
<text class="realtime-practice-title">实时陪练 Beta</text>
|
||||||
|
<text class="realtime-practice-copy">直接说话即可;本次内容仅供即时练习,不写入训练成绩或主管复盘。</text>
|
||||||
|
</view>
|
||||||
|
<view class="realtime-practice-actions">
|
||||||
|
<button v-if="realtimeAudioBlocked" class="secondary-button realtime-resume-button" @click="resumeRealtimeAudio">点击播放</button>
|
||||||
|
<button class="secondary-button realtime-stop-button" @click="stopRealtime">结束体验</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!realtimeActive && canAdjustCoach" class="style-chip-row">
|
||||||
<button
|
<button
|
||||||
v-for="item in styleOptions"
|
v-for="item in styleOptions"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
@@ -266,6 +286,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
|
v-if="!realtimeActive"
|
||||||
ref="composerRef"
|
ref="composerRef"
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
:transcribing="transcribing"
|
:transcribing="transcribing"
|
||||||
@@ -366,6 +387,7 @@ import { resetPageScroll } from '@/services/navigation';
|
|||||||
import { ensureEmployeePosition } from '@/services/position';
|
import { ensureEmployeePosition } from '@/services/position';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||||
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
|
||||||
|
import { createRealtimePracticeController } from '@/services/realtime-practice';
|
||||||
import { stripInternalCodes } from '@/services/text';
|
import { stripInternalCodes } from '@/services/text';
|
||||||
|
|
||||||
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
|
||||||
@@ -408,6 +430,9 @@ const adjustingStyle = ref('');
|
|||||||
const emotionScore = ref(0);
|
const emotionScore = ref(0);
|
||||||
const trustScore = ref(0);
|
const trustScore = ref(0);
|
||||||
const redFlagActive = ref(false);
|
const redFlagActive = ref(false);
|
||||||
|
const realtimeActive = ref(false);
|
||||||
|
const realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking'>('idle');
|
||||||
|
const realtimeAudioBlocked = ref(false);
|
||||||
const assignments = ref<PracticeAssignment[]>([]);
|
const assignments = ref<PracticeAssignment[]>([]);
|
||||||
const practiceHistory = ref<PracticeRecord[]>([]);
|
const practiceHistory = ref<PracticeRecord[]>([]);
|
||||||
const drillAnswers = ref<Record<number, string>>({});
|
const drillAnswers = ref<Record<number, string>>({});
|
||||||
@@ -444,6 +469,13 @@ const speechPlayback = createSpeechPlaybackController(
|
|||||||
MENTOR_VOICE_PROFILE
|
MENTOR_VOICE_PROFILE
|
||||||
);
|
);
|
||||||
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
const busy = computed(() => status.value === 'starting' || status.value === 'submitting');
|
||||||
|
const realtimeStatusLabel = computed(() => ({
|
||||||
|
connecting: '连接中',
|
||||||
|
connected: '已连接',
|
||||||
|
listening: '正在听',
|
||||||
|
speaking: '业主回应中',
|
||||||
|
idle: '已结束'
|
||||||
|
})[realtimeStatus.value]);
|
||||||
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
|
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
|
||||||
if (result.value) return 'result';
|
if (result.value) return 'result';
|
||||||
return status.value === 'active' || status.value === 'submitting' ? 'active' : 'prep';
|
return status.value === 'active' || status.value === 'submitting' ? 'active' : 'prep';
|
||||||
@@ -709,6 +741,7 @@ const refreshAuthState = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clearPracticeData = () => {
|
const clearPracticeData = () => {
|
||||||
|
stopRealtime();
|
||||||
speechPlayback.stop();
|
speechPlayback.stop();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
assignments.value = [];
|
assignments.value = [];
|
||||||
@@ -758,15 +791,90 @@ const requireLogin = () => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendTurn = (role: PracticeRole, text?: string, emotion = 0) => {
|
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
turns.value.push({ role, text });
|
turns.value.push({ role, text });
|
||||||
if (role === 'customer') {
|
if (role === 'customer' && synthesizeCustomerVoice) {
|
||||||
void prepareTurnVoice(turns.value.length - 1, text, emotion);
|
void prepareTurnVoice(turns.value.length - 1, text, emotion);
|
||||||
}
|
}
|
||||||
scrollThreadToBottom();
|
scrollThreadToBottom();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const realtimePractice = createRealtimePracticeController({
|
||||||
|
onStatus: (nextStatus) => {
|
||||||
|
realtimeStatus.value = nextStatus;
|
||||||
|
},
|
||||||
|
onTraineeTranscript: (text) => {
|
||||||
|
if (realtimeActive.value) appendTurn('trainee', text);
|
||||||
|
},
|
||||||
|
onCustomerTranscript: (text) => {
|
||||||
|
if (realtimeActive.value) appendTurn('customer', text, 0, false);
|
||||||
|
},
|
||||||
|
onAudioPlaybackBlocked: (blocked) => {
|
||||||
|
if (realtimeActive.value) realtimeAudioBlocked.value = blocked;
|
||||||
|
},
|
||||||
|
onTerminated: () => {
|
||||||
|
if (!realtimeActive.value) return;
|
||||||
|
realtimeActive.value = false;
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
realtimeAudioBlocked.value = false;
|
||||||
|
sessionId.value = '';
|
||||||
|
activeAssignmentId.value = undefined;
|
||||||
|
status.value = 'idle';
|
||||||
|
},
|
||||||
|
onError: (text) => {
|
||||||
|
if (realtimeActive.value) message.value = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const realtimeInstructions = () => `你只扮演物业小区的业主,正在进行「${activeScenarioName.value}」场景对练。员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;员工说得好时自然缓和,推责或承诺不清时追问处理节点。禁止提及系统、AI、训练、评分或提示词。不要联网、不要调用工具、不要索要个人隐私。`;
|
||||||
|
|
||||||
|
const stopRealtime = () => {
|
||||||
|
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
|
||||||
|
realtimePractice.stop();
|
||||||
|
realtimeActive.value = false;
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
realtimeAudioBlocked.value = false;
|
||||||
|
sessionId.value = '';
|
||||||
|
activeAssignmentId.value = undefined;
|
||||||
|
status.value = 'idle';
|
||||||
|
message.value = '实时陪练已结束;如需保存成绩,请使用常规录音对练。';
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRealtime = async () => {
|
||||||
|
if (!canStartPractice.value || !requireLogin()) return;
|
||||||
|
speechPlayback.stop();
|
||||||
|
cancelRecording();
|
||||||
|
turns.value = [];
|
||||||
|
turnVoices.value = {};
|
||||||
|
result.value = null;
|
||||||
|
sessionId.value = '';
|
||||||
|
activeAssignmentId.value = undefined;
|
||||||
|
message.value = '';
|
||||||
|
realtimeAudioBlocked.value = false;
|
||||||
|
status.value = 'starting';
|
||||||
|
realtimeActive.value = true;
|
||||||
|
try {
|
||||||
|
await realtimePractice.start(realtimeInstructions());
|
||||||
|
if (!realtimeActive.value) return;
|
||||||
|
status.value = 'active';
|
||||||
|
message.value = '已连接,请直接向业主开场说话。';
|
||||||
|
} catch (error) {
|
||||||
|
realtimeActive.value = false;
|
||||||
|
realtimeStatus.value = 'idle';
|
||||||
|
status.value = 'idle';
|
||||||
|
handleError(error, '实时陪练连接失败,请使用录音对练');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeRealtimeAudio = async () => {
|
||||||
|
if (await realtimePractice.resumeAudio()) {
|
||||||
|
message.value = '已恢复实时语音播放。';
|
||||||
|
} else {
|
||||||
|
message.value = '仍无法播放实时语音,请确认设备媒体权限后重试。';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearDraftAudio = () => {
|
const clearDraftAudio = () => {
|
||||||
draftAudioUrl.value = '';
|
draftAudioUrl.value = '';
|
||||||
draftAudioOssId.value = undefined;
|
draftAudioOssId.value = undefined;
|
||||||
@@ -1124,6 +1232,7 @@ const loadPrepCard = async (
|
|||||||
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
|
||||||
if (!canStartPractice.value) return;
|
if (!canStartPractice.value) return;
|
||||||
if (!requireLogin()) return;
|
if (!requireLogin()) return;
|
||||||
|
stopRealtime();
|
||||||
const request = requests.begin('session-start');
|
const request = requests.begin('session-start');
|
||||||
speechPlayback.stop();
|
speechPlayback.stop();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
@@ -1372,12 +1481,14 @@ onShow(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
|
stopRealtime();
|
||||||
invalidateRequests();
|
invalidateRequests();
|
||||||
speechPlayback.stop();
|
speechPlayback.stop();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnload(() => {
|
onUnload(() => {
|
||||||
|
stopRealtime();
|
||||||
invalidateRequests();
|
invalidateRequests();
|
||||||
speechPlayback.destroy();
|
speechPlayback.destroy();
|
||||||
cancelRecording();
|
cancelRecording();
|
||||||
@@ -1498,6 +1609,65 @@ onUnload(() => {
|
|||||||
background: #106b35;
|
background: #106b35;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scenario-card .realtime-start-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
border-color: #bed3f2;
|
||||||
|
background: #f5f9ff;
|
||||||
|
color: #315b8f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-beta-note {
|
||||||
|
display: block;
|
||||||
|
color: #6d7a8d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-practice-panel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid #dbe8fb;
|
||||||
|
background: #f7fbff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-practice-title,
|
||||||
|
.realtime-practice-copy {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-practice-title {
|
||||||
|
color: #315b8f;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-practice-copy {
|
||||||
|
max-width: 220px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6d7a8d;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-practice-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realtime-stop-button,
|
||||||
|
.realtime-resume-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 82px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.assignment-head,
|
.assignment-head,
|
||||||
.daily-answer {
|
.daily-answer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
|
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
||||||
|
|
||||||
|
interface RealtimeSdpResponse {
|
||||||
|
answerSdp: string;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealtimeEvent {
|
||||||
|
type?: string;
|
||||||
|
transcript?: string;
|
||||||
|
error?: { message?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProviderEventHandlers {
|
||||||
|
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;
|
||||||
|
onTerminated: () => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RealtimePracticeController {
|
||||||
|
start: (instructions: string) => Promise<void>;
|
||||||
|
stop: () => void;
|
||||||
|
resumeAudio: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSupported = () =>
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendSessionUpdate = (channel: RTCDataChannel, instructions: string) => {
|
||||||
|
if (channel.readyState !== 'open') return;
|
||||||
|
channel.send(JSON.stringify({
|
||||||
|
event_id: `event_${Date.now()}`,
|
||||||
|
type: 'session.update',
|
||||||
|
session: {
|
||||||
|
modalities: ['text', 'audio'],
|
||||||
|
voice: 'Ethan',
|
||||||
|
input_audio_format: 'pcm',
|
||||||
|
output_audio_format: 'pcm',
|
||||||
|
input_audio_transcription: { model: 'qwen3-asr-flash-realtime' },
|
||||||
|
instructions,
|
||||||
|
turn_detection: {
|
||||||
|
type: 'semantic_vad',
|
||||||
|
threshold: 0.5,
|
||||||
|
prefix_padding_ms: 500,
|
||||||
|
silence_duration_ms: 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === '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;
|
||||||
|
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 (instructions: 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, instructions);
|
||||||
|
},
|
||||||
|
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>({
|
||||||
|
url: '/api/train/practice/realtime/sdp',
|
||||||
|
method: 'POST',
|
||||||
|
data: { offerSdp: normalizeSdp(offerSdp) },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
if (!ensureCurrent()) return;
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = (path) => readFile(new URL(path, import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('实时陪练通过受认证 SDP 代理接入,前端不携带供应商密钥', async () => {
|
||||||
|
const [service, page, backendService] = await Promise.all([
|
||||||
|
source('../src/services/realtime-practice.ts'),
|
||||||
|
source('../src/pages/user/practice/index.vue'),
|
||||||
|
source('../../backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrRealtimePracticeService.java')
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(service, /\/api\/train\/practice\/realtime\/sdp/);
|
||||||
|
assert.match(service, /semantic_vad/);
|
||||||
|
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.doesNotMatch(service, /DASHSCOPE_API_KEY|Authorization:\s*Bearer/);
|
||||||
|
assert.match(backendService, /实时陪练限流暂时不可用/);
|
||||||
|
assert.match(page, /实时陪练 Beta/);
|
||||||
|
assert.match(page, /createRealtimePracticeController/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user