feat: add qwen realtime practice beta
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user