feat: add realtime practice voice selector

This commit is contained in:
2026-07-22 12:24:46 +08:00
parent 87772cc64c
commit 455267cf6f
3 changed files with 229 additions and 24 deletions
+92 -2
View File
@@ -51,6 +51,18 @@
>
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</button>
<view v-if="status === 'idle' || status === 'finished'" class="realtime-voice-picker">
<view class="realtime-voice-heading">
<text class="realtime-voice-title">实时陪练音色</text>
</view>
<picker :range="realtimeVoiceOptions" range-key="label" :value="realtimeVoiceIndex" :disabled="busy" @change="selectRealtimeVoice">
<view class="realtime-voice-select" aria-label="选择实时陪练音色">
<text class="realtime-voice-value">{{ realtimeVoiceLabel }}</text>
<text class="realtime-voice-arrow">›</text>
</view>
</picker>
<text class="realtime-voice-hint">当前模型完整官方音色(56项);下次开练生效</text>
</view>
<button
v-if="status === 'idle' || status === 'finished'"
class="secondary-button realtime-start-button"
@@ -387,7 +399,7 @@ import { resetPageScroll } from '@/services/navigation';
import { ensureEmployeePosition } from '@/services/position';
import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile, measureAudioDuration, MENTOR_VOICE_PROFILE, normalizeOssId, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController } from '@/services/realtime-practice';
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -404,6 +416,9 @@ type RecentPracticeItem = {
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
const realtimeVoiceStorageKey = 'aihr_realtime_practice_voice';
const realtimeVoiceOptions = REALTIME_PRACTICE_VOICE_OPTIONS;
const storedRealtimeVoice = uni.getStorageSync(realtimeVoiceStorageKey);
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
@@ -433,6 +448,11 @@ const redFlagActive = ref(false);
const realtimeActive = ref(false);
const realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking'>('idle');
const realtimeAudioBlocked = ref(false);
const realtimeVoice = ref<RealtimePracticeVoice>(
isRealtimePracticeVoice(storedRealtimeVoice) ? storedRealtimeVoice : DEFAULT_REALTIME_PRACTICE_VOICE
);
const realtimeVoiceIndex = computed(() => Math.max(0, realtimeVoiceOptions.findIndex((item) => item.value === realtimeVoice.value)));
const realtimeVoiceLabel = computed(() => realtimeVoiceOptions[realtimeVoiceIndex.value].label);
const assignments = ref<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
@@ -829,6 +849,13 @@ const realtimePractice = createRealtimePracticeController({
const realtimeInstructions = () => `你只扮演物业小区的业主,正在进行「${activeScenarioName.value}」场景对练。员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;员工说得好时自然缓和,推责或承诺不清时追问处理节点。禁止提及系统、AI、训练、评分或提示词。不要联网、不要调用工具、不要索要个人隐私。`;
const selectRealtimeVoice = (event: { detail: { value: string } }) => {
const option = realtimeVoiceOptions[Number(event.detail.value)];
if (!option) return;
realtimeVoice.value = option.value;
uni.setStorageSync(realtimeVoiceStorageKey, option.value);
};
const stopRealtime = () => {
if (!realtimeActive.value && realtimeStatus.value === 'idle') return;
realtimePractice.stop();
@@ -855,7 +882,7 @@ const startRealtime = async () => {
status.value = 'starting';
realtimeActive.value = true;
try {
await realtimePractice.start(realtimeInstructions());
await realtimePractice.start(realtimeInstructions(), realtimeVoice.value);
if (!realtimeActive.value) return;
status.value = 'active';
message.value = '已连接,请直接向业主开场说话。';
@@ -1617,6 +1644,69 @@ onUnload(() => {
color: #315b8f;
}
.realtime-voice-picker {
margin-top: 12px;
padding: 12px;
border: 1px solid #dbe8fb;
border-radius: 14px;
background: #f7fbff;
}
.realtime-voice-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.realtime-voice-title,
.realtime-voice-hint,
.realtime-voice-value {
display: block;
}
.realtime-voice-title {
color: #315b8f;
font-size: 13px;
font-weight: 800;
}
.realtime-voice-hint {
margin-top: 8px;
color: #6d7a8d;
font-size: 11px;
}
.realtime-voice-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
width: 100%;
min-height: 44px;
padding: 0 12px;
border-color: #c8d9f2;
border-radius: 10px;
background: #ffffff;
color: #315b8f;
}
.realtime-voice-value {
overflow: hidden;
color: #315b8f;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.realtime-voice-arrow {
flex: 0 0 auto;
color: #6d7a8d;
font-size: 22px;
}
.realtime-beta-note {
display: block;
color: #6d7a8d;