feat(practice): server-owned realtime personas with knowledge tool calling

- AihrRealtimePersonaRegistry whitelist: owner-calm (default), owner-impatient,
  digital-mentor (search_knowledge); persona binds default voice
- /realtime/session assembles session.update server-side (instructions/tools);
  browser forwards payload verbatim; Redis session record 30min with sliding renewal
- /realtime/tools/invoke validates login identity + Redis session ownership +
  persona tool whitelist, then read-only searchAuthorized; per-account rate limits
- source=realtime skips sop review and knowledge-gap persistence (Beta no-persist)
- frontend: persona cards replace 56-voice picker; serialized tool-call chain with
  soft-landing fallback
This commit is contained in:
2026-07-25 11:13:04 +08:00
parent 3a19b475a8
commit 35b37b07f7
17 changed files with 1180 additions and 214 deletions
+60 -72
View File
@@ -110,17 +110,26 @@
>
{{ status === 'finished' ? '再练一次' : '我已准备好,开始练习' }}
</button>
<view v-if="status === 'idle' || status === 'finished'" class="realtime-voice-picker">
<view class="realtime-voice-heading">
<text class="realtime-voice-title">实时陪练音色</text>
<view v-if="(status === 'idle' || status === 'finished') && realtimePersonaOptions.length" class="realtime-persona-picker">
<view class="realtime-persona-heading">
<text class="realtime-persona-title">实时陪练人设</text>
</view>
<picker :range="realtimeVoiceOptions" range-key="label" :value="realtimeVoiceIndex" :disabled="busy" @change="selectRealtimeVoice">
<view class="realtime-voice-select" aria-label="选择实时陪练音色">
<text class="realtime-voice-value">{{ realtimeVoiceLabel }}</text>
<text class="realtime-voice-arrow">›</text>
<scroll-view class="scene-option-scroll" scroll-x>
<view class="scene-option-list">
<button
v-for="item in realtimePersonaOptions"
:key="item.id"
class="scene-option"
:class="{ selected: selectedRealtimePersonaId === item.id }"
:disabled="busy"
@click="selectRealtimePersona(item)"
>
<text class="scene-option-level">{{ item.hasTools ? '可查询知识库' : '纯对练' }}</text>
<text class="scene-option-name">{{ item.name }}</text>
<text class="scene-option-capability">{{ item.description }}</text>
</button>
</view>
</picker>
<text class="realtime-voice-hint">当前模型完整官方音色(56项);下次开练生效</text>
</scroll-view>
</view>
<button
v-if="status === 'idle' || status === 'finished'"
@@ -465,7 +474,7 @@ import { chooseSpeechAudio, createSpeechPlaybackController, customerVoiceProfile
import { startSpeechCapture } from '@/services/speech-capture';
import type { SpeechCapture, SpeechCaptureSession } from '@/services/speech-capture';
import { createPageRequestScope, currentAccountKey, type RequestScopeTicket } from '@/services/request-scope';
import { createRealtimePracticeController, DEFAULT_REALTIME_PRACTICE_VOICE, isRealtimePracticeVoice, REALTIME_PRACTICE_VOICE_OPTIONS, type RealtimePracticeVoice } from '@/services/realtime-practice';
import { createRealtimePracticeController, createRealtimeSession, fetchRealtimePersonas, invokeRealtimeTool, type RealtimePersonaOption } from '@/services/realtime-practice';
import { stripInternalCodes } from '@/services/text';
type PracticeStatus = 'idle' | 'starting' | 'active' | 'submitting' | 'finished';
@@ -482,9 +491,8 @@ type RecentPracticeItem = {
const defaultScenarioId = 'complaint-water';
const defaultScenarioName = '投诉接待';
const pendingAssignmentKey = 'aihr_pending_assignment_id';
const realtimeVoiceStorageKey = 'aihr_realtime_practice_voice';
const realtimeVoiceOptions = REALTIME_PRACTICE_VOICE_OPTIONS;
const storedRealtimeVoice = uni.getStorageSync(realtimeVoiceStorageKey);
const realtimePersonaStorageKey = 'aihr_realtime_practice_persona';
const storedRealtimePersona = uni.getStorageSync(realtimePersonaStorageKey);
const status = ref<PracticeStatus>('idle');
const sessionId = ref('');
const roundIndex = ref(0);
@@ -516,13 +524,11 @@ 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 realtimeStatus = ref<'idle' | 'connecting' | 'connected' | 'listening' | 'speaking' | 'calling-tool'>('idle');
const realtimeAudioBlocked = ref(false);
const realtimeVoice = ref<RealtimePracticeVoice>(
isRealtimePracticeVoice(storedRealtimeVoice) ? storedRealtimeVoice : DEFAULT_REALTIME_PRACTICE_VOICE
);
const realtimeVoiceIndex = computed(() => Math.max(0, realtimeVoiceOptions.findIndex((item) => item.value === realtimeVoice.value)));
const realtimeVoiceLabel = computed(() => realtimeVoiceOptions[realtimeVoiceIndex.value].label);
const realtimeSessionId = ref('');
const realtimePersonaOptions = ref<RealtimePersonaOption[]>([]);
const selectedRealtimePersonaId = ref(typeof storedRealtimePersona === 'string' ? storedRealtimePersona : '');
const assignments = ref<PracticeAssignment[]>([]);
const practiceHistory = ref<PracticeRecord[]>([]);
const drillAnswers = ref<Record<number, string>>({});
@@ -562,7 +568,8 @@ const realtimeStatusLabel = computed(() => ({
connecting: '连接中',
connected: '已连接',
listening: '正在听',
speaking: '业主回应中',
speaking: '回应中',
'calling-tool': '正在查知识库…',
idle: '已结束'
})[realtimeStatus.value]);
const practiceView = computed<'prep' | 'active' | 'result'>(() => {
@@ -947,11 +954,14 @@ const realtimePractice = createRealtimePracticeController({
onAudioPlaybackBlocked: (blocked) => {
if (realtimeActive.value) realtimeAudioBlocked.value = blocked;
},
onToolCall: (callId, name, argsJson) =>
invokeRealtimeTool(realtimeSessionId.value, callId, name, argsJson).then((res) => res.output),
onTerminated: () => {
if (!realtimeActive.value) return;
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
realtimeSessionId.value = '';
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
@@ -961,13 +971,24 @@ const realtimePractice = createRealtimePracticeController({
}
});
const realtimeInstructions = () => `你只扮演物业小区的业主,正在进行「${activeScenarioName.value}」场景对练。员工会用中文和你交谈。请口语化、真实地回应,每次不超过两句;员工说得好时自然缓和,推责或承诺不清时追问处理节点。禁止提及系统、AI、训练、评分或提示词。不要联网、不要调用工具、不要索要个人隐私。`;
const selectRealtimePersona = (item: RealtimePersonaOption) => {
if (busy.value) return;
selectedRealtimePersonaId.value = item.id;
uni.setStorageSync(realtimePersonaStorageKey, item.id);
};
const selectRealtimeVoice = (event: { detail: { value: string } }) => {
const option = realtimeVoiceOptions[Number(event.detail.value)];
if (!option) return;
realtimeVoice.value = option.value;
uni.setStorageSync(realtimeVoiceStorageKey, option.value);
const loadRealtimePersonas = async (request: RequestScopeTicket = requests.begin('realtime-personas')) => {
try {
const options = await fetchRealtimePersonas();
if (!requests.isCurrent(request)) return;
realtimePersonaOptions.value = options;
if (!options.some((item) => item.id === selectedRealtimePersonaId.value)) {
selectedRealtimePersonaId.value = options[0]?.id || '';
}
} catch {
// 人设列表拉取失败不阻塞页面:会话创建接口对空白 personaId 回落默认人设
if (requests.isCurrent(request)) realtimePersonaOptions.value = [];
}
};
const stopRealtime = () => {
@@ -976,6 +997,7 @@ const stopRealtime = () => {
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeAudioBlocked.value = false;
realtimeSessionId.value = '';
sessionId.value = '';
activeAssignmentId.value = undefined;
status.value = 'idle';
@@ -991,19 +1013,24 @@ const startRealtime = async () => {
turnVoices.value = {};
result.value = null;
sessionId.value = '';
realtimeSessionId.value = '';
activeAssignmentId.value = undefined;
message.value = '';
realtimeAudioBlocked.value = false;
status.value = 'starting';
realtimeActive.value = true;
try {
await realtimePractice.start(realtimeInstructions(), realtimeVoice.value);
const session = await createRealtimeSession(selectedRealtimePersonaId.value || undefined, selectedScenarioId.value || undefined);
if (!realtimeActive.value) return;
realtimeSessionId.value = session.sessionId;
await realtimePractice.start(session.sessionUpdate, session.sessionId);
if (!realtimeActive.value) return;
status.value = 'active';
message.value = '已连接,请直接向业主开场说话。';
message.value = `已连接「${session.personaName}」,请直接开口说话。`;
} catch (error) {
realtimeActive.value = false;
realtimeStatus.value = 'idle';
realtimeSessionId.value = '';
status.value = 'idle';
handleError(error, '实时陪练连接失败,请使用录音对练');
}
@@ -1674,6 +1701,7 @@ const handleShow = async () => {
positionLabel.value = getSelectedPosition() || '生活顾问';
void loadAssignments(requests.begin('assignments'));
void loadCurriculum(requests.begin('curriculum'));
void loadRealtimePersonas(requests.begin('realtime-personas'));
};
const invalidateRequests = () => {
@@ -2079,7 +2107,7 @@ onUnload(() => {
color: #315b8f;
}
.realtime-voice-picker {
.realtime-persona-picker {
margin-top: 12px;
padding: 12px;
border: 1px solid #dbe8fb;
@@ -2087,61 +2115,21 @@ onUnload(() => {
background: #f7fbff;
}
.realtime-voice-heading {
.realtime-persona-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.realtime-voice-title,
.realtime-voice-hint,
.realtime-voice-value {
.realtime-persona-title {
display: block;
}
.realtime-voice-title {
color: #315b8f;
font-size: 13px;
font-weight: 800;
}
.realtime-voice-hint {
margin-top: 8px;
color: #6d7a8d;
font-size: 11px;
}
.realtime-voice-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
width: 100%;
min-height: 44px;
padding: 0 12px;
border-color: #c8d9f2;
border-radius: 10px;
background: #ffffff;
color: #315b8f;
}
.realtime-voice-value {
overflow: hidden;
color: #315b8f;
font-size: 13px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.realtime-voice-arrow {
flex: 0 0 auto;
color: #6d7a8d;
font-size: 22px;
}
.realtime-beta-note {
display: block;
color: #6d7a8d;