fix(practice): generate replies from coach hints

This commit is contained in:
2026-07-25 15:44:45 +08:00
parent 9a34a26707
commit acd5fd3a6d
10 changed files with 172 additions and 15 deletions
+38 -11
View File
@@ -344,7 +344,13 @@
>
{{ speechButtonLabel(`turn-${index}`) }}
</button>
<button class="bubble-action" @click="adoptCoachHint(turn.text)">用这句话回复</button>
<button
class="bubble-action"
:disabled="replySuggestionLoading !== null || busy"
@click="adoptCoachHint(turn, index)"
>
{{ replySuggestionLoading === index ? '生成中…' : '用这句话回复' }}
</button>
</view>
</view>
</view>
@@ -556,6 +562,7 @@ const helpRefs = ref<string[]>([]);
const lastTraineeText = ref('');
const lastCoachRoundIndex = ref<number | null>(null);
const adjustingStyle = ref('');
const replySuggestionLoading = ref<number | null>(null);
const emotionScore = ref(0);
const trustScore = ref(0);
const redFlagActive = ref(false);
@@ -808,12 +815,30 @@ const handleComposerSend = (text: string) => {
void submit();
};
/** 旧 H5「建议回复」逻辑的会话版:把老师傅建议填入输入框,员工可改后发送。 */
const adoptCoachHint = (text: string) => {
const value = (text || '').trim();
if (!value) return;
composerRef.value?.setDraft(value);
uni.showToast({ title: '已填入输入框,可修改后发送', icon: 'none' });
const adoptCoachHint = async (turn: PracticeTurn, index: number) => {
if (busy.value || replySuggestionLoading.value !== null || !sessionId.value || !Number.isInteger(turn.roundIndex)) return;
const request = requests.begin('reply-suggestion');
replySuggestionLoading.value = index;
message.value = '';
try {
const data = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: turn.roundIndex as number,
traineeText: '',
suggestReply: true
});
if (!requests.isCurrent(request)) return;
const value = (data.suggestedReply || '').trim();
if (!value) throw new Error(data.message || '未生成可用回复');
composerRef.value?.setDraft(value);
uni.showToast({ title: '已生成回复,可修改后发送', icon: 'none' });
} catch (error) {
if (!requests.isCurrent(request)) return;
handleError(error, '回复生成失败');
uni.showToast({ title: message.value || '回复生成失败', icon: 'none' });
} finally {
if (requests.isCurrent(request)) replySuggestionLoading.value = null;
}
};
const submitTranscribed = async (text: string, audioUrl: string, ossId?: string) => {
@@ -953,6 +978,7 @@ const clearPracticeData = () => {
lastTraineeText.value = '';
lastCoachRoundIndex.value = null;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
emotionScore.value = 0;
trustScore.value = 0;
redFlagActive.value = false;
@@ -975,9 +1001,9 @@ const requireLogin = () => {
return false;
};
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number) => {
const appendTurn = (role: PracticeRole, text?: string, emotion = 0, synthesizeCustomerVoice = true, customerTurnIndex?: number, coachRoundIndex?: number) => {
if (!text) return;
turns.value.push({ role, text });
turns.value.push({ role, text, roundIndex: coachRoundIndex });
if (role === 'customer' && synthesizeCustomerVoice) {
void prepareTurnVoice(turns.value.length - 1, text, emotion, customerTurnIndex);
}
@@ -1586,7 +1612,7 @@ const replaceLastCoachHint = (text?: string) => {
if (!text) return;
for (let index = turns.value.length - 1; index >= 0; index -= 1) {
if (turns.value[index].role === 'coach') {
turns.value[index] = { role: 'coach', text };
turns.value[index] = { ...turns.value[index], text };
return;
}
}
@@ -1661,7 +1687,7 @@ const submit = async () => {
emotionScore.value = data.emotion ?? emotionScore.value;
trustScore.value = data.trust ?? trustScore.value;
redFlagActive.value = Boolean(data.redFlag);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint);
appendTurn('coach', data.redFlag ? data.coachHint || '先承接诉求,再给处理节点。' : data.coachHint, 0, true, undefined, submittedRound);
if (data.redFlag) {
message.value = '命中红线,请按老师傅建议重说本轮';
status.value = 'active';
@@ -1757,6 +1783,7 @@ const invalidateRequests = () => {
feedbackSubmittingId.value = null;
helpLoading.value = false;
adjustingStyle.value = '';
replySuggestionLoading.value = null;
satisfactionBusy.value = false;
if (status.value === 'starting') status.value = 'idle';
if (status.value === 'submitting') status.value = sessionId.value ? 'active' : 'idle';
+1
View File
@@ -80,6 +80,7 @@ export const submitPracticeTurn = (params: {
traineeAudioOssId?: number | string;
regenerate?: boolean;
style?: string;
suggestReply?: boolean;
}) =>
apiRequest<PracticeTurnResponse>({
url: '/api/train/practice/turn',
+2
View File
@@ -510,6 +510,7 @@ export type PracticeRole = 'customer' | 'trainee' | 'coach';
export interface PracticeTurn {
role: PracticeRole;
text: string;
roundIndex?: number;
}
export interface PracticeStartResponse {
@@ -548,6 +549,7 @@ export interface PracticeTurnResponse {
roundIndex: number;
finished: boolean;
message?: string;
suggestedReply?: string;
}
export interface PracticeDimensionScore {
@@ -341,6 +341,19 @@ test('训练页保留任务卡、对话区、回复区和结果区并互斥切
assert.match(source, /result\.mentorRewrite/);
});
test('老师傅提示由 LLM 生成可发送回复后再填入输入框', async () => {
const [source, service] = await Promise.all([
pageSource('../src/pages/user/practice/index.vue'),
pageSource('../src/services/practice.ts')
]);
assert.match(source, /suggestReply:\s*true/);
assert.match(source, /data\.suggestedReply/);
assert.match(source, /setDraft\(value\)/);
assert.doesNotMatch(source, /const adoptCoachHint[\s\S]{0,300}setDraft\(\(turn\.text/);
assert.match(service, /suggestReply\?:\s*boolean/);
});
test('底部固定导航页面复用安全操作区', async () => {
const [base, practice, assign, review] = await Promise.all([
pageSource('../src/styles/base.css'),