feat(aihr): complete M3 protected practice replay
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user