feat(aihr): harden M0-M2 mobile training flows

This commit is contained in:
2026-07-23 21:18:13 +08:00
parent 06322c994b
commit 927458b9e0
44 changed files with 1674 additions and 119 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ export const searchKnowledge = async (
position = getSelectedPosition(),
conversation?: KnowledgeConversationState,
projectCode?: string,
source: 'mobile_uni_sop' | 'mobile_uni_voice' = 'mobile_uni_sop',
source: 'mobile_uni_sop' | 'mobile_uni_voice' | 'practice_help' = 'mobile_uni_sop',
broadcastMessageId?: number
) => {
const response = await apiRequest<UnifiedKnowledgeResponse>({
+9
View File
@@ -1,5 +1,6 @@
import type {
PracticeFinishResponse,
PracticeHelpEvent,
PracticePrepCard,
PracticeRecord,
PracticeMistake,
@@ -42,6 +43,14 @@ export const getPracticePrepCard = (scenarioId = defaultScenarioId) =>
timeout: 15000
});
export const recordPracticeHelp = (sessionId: string, roundIndex: number) =>
apiRequest<PracticeHelpEvent>({
url: `/api/train/practice/sessions/${encodeURIComponent(sessionId)}/help`,
method: 'POST',
data: { roundIndex },
timeout: 15000
});
export const getPracticeScenarios = (position = getSelectedPosition()) =>
apiRequest<PracticeScenarioOption[]>({
url: `/api/train/practice/scenarios?position=${encodeURIComponent(position || '生活顾问')}`,
+27 -12
View File
@@ -4,12 +4,15 @@ import type { SpeechCapture } from './speech-capture';
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88 };
export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96 };
export const customerVoiceProfile = (emotion = 0): SpeechVoiceProfile => {
if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12 };
if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04 };
return { role: 'customer', emotion: 'calm', speed: 0.98 };
export const MENTOR_VOICE_PROFILE: SpeechVoiceProfile = { role: 'mentor', emotion: 'warm', speed: 0.88, dialect: 'mandarin' };
export const INTERVIEWER_VOICE_PROFILE: SpeechVoiceProfile = { role: 'interviewer', emotion: 'professional', speed: 0.96, dialect: 'mandarin' };
export const customerVoiceProfile = (
emotion = 0,
dialect: SpeechVoiceProfile['dialect'] = 'mandarin'
): SpeechVoiceProfile => {
if (emotion >= 80) return { role: 'customer', emotion: 'intense', speed: 1.12, dialect };
if (emotion >= 70) return { role: 'customer', emotion: 'serious', speed: 1.04, dialect };
return { role: 'customer', emotion: 'calm', speed: 0.98, dialect };
};
// OSS 编号是雪花 ID,超出 JS 安全整数范围,必须按字符串透传,禁止 Number() 强转。
@@ -113,6 +116,12 @@ export interface SpeechPlaybackSnapshot {
status: SpeechPlaybackStatus;
}
const browserDialectLanguage = (dialect: SpeechVoiceProfile['dialect']) => {
if (dialect === 'cantonese') return 'zh-HK';
if (dialect === 'sichuanese') return '';
return 'zh-CN';
};
export const createSpeechPlaybackController = (
onState: (snapshot: SpeechPlaybackSnapshot) => void,
onError: (message: string) => void,
@@ -150,15 +159,20 @@ export const createSpeechPlaybackController = (
value: string,
currentGeneration: number,
profile?: SpeechVoiceProfile
) => {
): { played: boolean; message?: string } => {
if (
typeof window === 'undefined'
|| !window.speechSynthesis
|| typeof window.SpeechSynthesisUtterance !== 'function'
) return false;
) return { played: false };
const lang = browserDialectLanguage(profile?.dialect);
if (!lang) {
return { played: false, message: '当前设备语音不支持四川话,请使用服务端语音或文字查看' };
}
const utterance = new window.SpeechSynthesisUtterance(value.slice(0, 600));
utterance.lang = 'zh-CN';
utterance.lang = lang;
utterance.rate = profile?.speed || 0.95;
utterance.pitch = profile?.role === 'mentor' ? 1.06 : profile?.role === 'customer' ? 1.05 : 1;
utterance.onend = () => {
@@ -172,7 +186,7 @@ export const createSpeechPlaybackController = (
browserUtterance = utterance;
publish(key, 'playing');
window.speechSynthesis.speak(utterance);
return true;
return { played: true };
};
const playAudioSource = (key: string, source: string, cacheKey: string, currentGeneration: number) => {
@@ -231,9 +245,10 @@ export const createSpeechPlaybackController = (
playAudioSource(key, source, sourceKey, currentGeneration);
} catch (error) {
if (currentGeneration !== generation) return;
if (playWithBrowserSpeech(key, value, currentGeneration, profile)) return;
const browserFallback = playWithBrowserSpeech(key, value, currentGeneration, profile);
if (browserFallback.played) return;
stop();
onError(error instanceof Error ? error.message : '语音生成失败');
onError(browserFallback.message || (error instanceof Error ? error.message : '语音生成失败'));
}
};