feat: add qwen realtime practice beta

This commit is contained in:
2026-07-22 09:29:39 +08:00
parent b7dd1bca27
commit 87772cc64c
7 changed files with 712 additions and 4 deletions
+174 -4
View File
@@ -51,6 +51,15 @@
>
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</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 v-if="loggedIn && practiceView === 'prep'" class="panel assignment-panel recent-panel">
@@ -159,7 +168,7 @@
<view class="chat-head">
<view class="chat-head-row">
<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 v-if="hasCoachState" class="coach-strip">
<view class="coach-metric">
@@ -253,7 +262,18 @@
</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
v-for="item in styleOptions"
:key="item.value"
@@ -266,6 +286,7 @@
</view>
<ChatComposer
v-if="!realtimeActive"
ref="composerRef"
:disabled="busy"
:transcribing="transcribing"
@@ -366,6 +387,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 { stripInternalCodes } from '@/services/text';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -408,6 +430,9 @@ const adjustingStyle = ref('');
const emotionScore = ref(0);
const trustScore = ref(0);
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 practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
@@ -444,6 +469,13 @@ const speechPlayback = createSpeechPlaybackController(
MENTOR_VOICE_PROFILE
);
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'>(() => {
if (result.value) return 'result';
return status.value === 'active' || status.value === 'submitting' ? 'active' : 'prep';
@@ -709,6 +741,7 @@ const refreshAuthState = () => {
};
const clearPracticeData = () => {
stopRealtime();
speechPlayback.stop();
cancelRecording();
assignments.value = [];
@@ -758,15 +791,90 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string, emotion = 0) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer') {
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion);
}
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 = () => {
draftAudioUrl.value = '';
draftAudioOssId.value = undefined;
@@ -1124,6 +1232,7 @@ const loadPrepCard = async (
const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
if (!canStartPractice.value) return;
if (!requireLogin()) return;
stopRealtime();
const request = requests.begin('session-start');
speechPlayback.stop();
cancelRecording();
@@ -1372,12 +1481,14 @@ onShow(() => {
});
onHide(() => {
stopRealtime();
invalidateRequests();
speechPlayback.stop();
cancelRecording();
});
onUnload(() => {
stopRealtime();
invalidateRequests();
speechPlayback.destroy();
cancelRecording();
@@ -1498,6 +1609,65 @@ onUnload(() => {
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,
.daily-answer {
display: flex;