feat(aihr): complete M3 protected practice replay

This commit is contained in:
2026-07-23 22:21:43 +08:00
parent 83f775faec
commit 067cc30d80
11 changed files with 413 additions and 47 deletions
+25 -9
View File
@@ -387,6 +387,7 @@ import type {
PracticePrepCard,
PracticeAssignment,
PracticeRole,
PracticeTtsContext,
SpeechVoiceProfile,
SpeechPlaybackStatus,
PracticeTurn,
@@ -573,9 +574,12 @@ interface TurnVoice {
unread: boolean;
open: boolean;
profile: SpeechVoiceProfile;
practiceContext?: PracticeTtsContext;
requestKey?: string;
}
const turnVoices = ref<Record<number, TurnVoice>>({});
let turnVoiceGeneration = 0;
const turnVoice = (index: number): TurnVoice =>
turnVoices.value[index] || {
@@ -592,18 +596,26 @@ const patchTurnVoice = (index: number, patch: Partial<TurnVoice>) => {
/** Customer lines arrive as WeChat-style voice messages: pre-synthesize so the
* bubble shows a duration and an unread dot; fall back to text when TTS is off. */
const prepareTurnVoice = async (index: number, text: string, emotion = 0) => {
const prepareTurnVoice = async (index: number, text: string, emotion = 0, customerTurnIndex?: number) => {
const spoken = (text || '').trim();
if (!spoken) return;
const generation = turnVoiceGeneration;
const profile = customerVoiceProfile(emotion);
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile });
const source = await speechPlayback.preload(spoken, profile);
if (!turnVoices.value[index]) return;
const practiceContext: PracticeTtsContext | undefined = sessionId.value && Number.isInteger(customerTurnIndex)
? { sessionId: sessionId.value, turnIndex: Number(customerTurnIndex), role: 'customer' }
: undefined;
const requestKey = `${generation}:${index}:${spoken}:${practiceContext?.sessionId || ''}:${practiceContext?.turnIndex ?? ''}`;
const isCurrentVoiceRequest = () =>
turnVoiceGeneration === generation && turnVoices.value[index]?.requestKey === requestKey;
patchTurnVoice(index, { status: 'preparing', duration: null, unread: true, open: false, profile, practiceContext, requestKey });
const source = await speechPlayback.preload(spoken, profile, practiceContext);
if (!isCurrentVoiceRequest()) return;
if (!source) {
patchTurnVoice(index, { status: 'unavailable', unread: false });
return;
}
const duration = await measureAudioDuration(source);
if (!isCurrentVoiceRequest()) return;
patchTurnVoice(index, { status: 'ready', duration });
};
@@ -623,7 +635,7 @@ const playTurnVoice = (index: number, text: string) => {
return;
}
patchTurnVoice(index, { unread: false });
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile);
void speechPlayback.toggle(`turn-${index}`, (text || '').trim(), turnVoice(index).profile, turnVoice(index).practiceContext);
};
const toggleTurnTranscript = (index: number) => {
@@ -762,6 +774,7 @@ const refreshAuthState = () => {
};
const clearPracticeData = () => {
turnVoiceGeneration += 1;
stopRealtime();
speechPlayback.stop();
cancelRecording();
@@ -773,6 +786,7 @@ const clearPracticeData = () => {
prepCard.value = null;
prepLoading.value = false;
turns.value = [];
turnVoices.value = {};
result.value = null;
satisfactionScore.value = 0;
satisfactionComment.value = '';
@@ -812,11 +826,11 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number) => {
if (!text) return;
turns.value.push({ role, text });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion);
void prepareTurnVoice(turns.value.length - 1, text, emotion, customerTurnIndex);
}
scrollThreadToBottom();
};
@@ -873,6 +887,7 @@ const startRealtime = async () => {
if (!canStartPractice.value || !requireLogin()) return;
speechPlayback.stop();
cancelRecording();
turnVoiceGeneration += 1;
turns.value = [];
turnVoices.value = {};
result.value = null;
@@ -1254,6 +1269,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
satisfactionScore.value = 0;
satisfactionComment.value = '';
satisfactionSubmitted.value = false;
turnVoiceGeneration += 1;
turns.value = [];
turnVoices.value = {};
threadScrollTop.value = 0;
@@ -1276,7 +1292,7 @@ const start = async (scenarioId = defaultScenarioId, assignmentId?: number) => {
emotionScore.value = data.emotion ?? 0;
trustScore.value = data.trust ?? 0;
sessionId.value = data.sessionId;
appendTurn('customer', data.customerText, data.emotion);
appendTurn('customer', data.customerText, data.emotion, true, 0);
status.value = 'active';
} catch (error) {
if (!requests.isCurrent(request)) return;
@@ -1403,7 +1419,7 @@ const submit = async () => {
await finish(true);
return;
}
appendTurn('customer', data.customerText, data.emotion);
appendTurn('customer', data.customerText, data.emotion, true, data.roundIndex);
roundIndex.value = data.roundIndex;
status.value = 'active';
} catch (error) {