feat: complete voice session safety and confirmation flow

This commit is contained in:
2026-04-20 16:10:15 +08:00
parent dbb512719d
commit fab2094e34
9 changed files with 1256 additions and 28 deletions

View File

@@ -10,8 +10,12 @@ export interface VoiceTurnSummary {
intent_confidence: number | null
understanding_summary: string | null
requires_confirmation: boolean
confirmation_state: string
confirmation_reason: string | null
confirmation_message: string | null
safety_flags: string[]
safety_blocked: boolean
safety_message: string | null
assistant_text: string | null
assistant_audio_ready: boolean
assistant_audio_url: string | null
@@ -49,7 +53,10 @@ export interface VoiceSessionSummary {
latest_detected_intent: string | null
latest_understanding_summary: string | null
latest_requires_confirmation: boolean
latest_confirmation_state: string | null
latest_confirmation_message: string | null
latest_safety_flags: string[]
latest_safety_message: string | null
latest_assistant_audio_ready: boolean
last_turn_status: string | null
transcription_mode_hint: string | null
@@ -71,6 +78,23 @@ export interface VoiceTurnAcceptedResponse {
status: string
}
export interface VoiceSessionAnalytics {
window_days: number | null
total_sessions: number
active_sessions: number
finalized_sessions: number
abandoned_sessions: number
total_turns: number
successful_turns: number
failed_turns: number
asr_failures: number
tts_failures: number
low_confidence_turns: number
safety_interventions: number
turn_success_rate: number
finalize_conversion_rate: number
}
export interface VoiceTurnUploadAcceptedResponse extends VoiceTurnAcceptedResponse {
transcription_provider: string | null
}

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { useUserStore } from '../stores/user'
import type {
VoiceSessionAnalytics,
VoiceSessionDetail,
VoiceSessionFinalizeResponse,
VoiceSessionSummary,
@@ -43,6 +44,7 @@ const userStore = useUserStore()
const sessions = ref<VoiceSessionSummary[]>([])
const activeSession = ref<VoiceSessionDetail | null>(null)
const voiceAnalytics = ref<VoiceSessionAnalytics | null>(null)
const profiles = ref<ChildProfile[]>([])
const universes = ref<StoryUniverse[]>([])
const selectedProfileId = ref('')
@@ -81,6 +83,19 @@ const universeOptions = computed(() =>
)
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
const hasPendingConfirmation = computed(() => activeSession.value?.latest_requires_confirmation ?? false)
const finalStorySummary = computed(() => {
const value = activeSession.value?.story_state?.final_summary
return typeof value === 'string' ? value : null
})
const turnSuccessRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.turn_success_rate * 100)}%`
})
const finalizeConversionRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)}%`
})
const transcriptionModeDescription = computed(() => {
switch (activeSession.value?.transcription_mode_hint) {
case 'openai':
@@ -227,6 +242,15 @@ async function loadSessions() {
}
}
async function loadVoiceAnalytics() {
if (!userStore.user) return
try {
voiceAnalytics.value = await api.get<VoiceSessionAnalytics>('/api/voice-sessions/analytics?days=30')
} catch {
// Ignore analytics failures so the main editor stays usable.
}
}
async function loadLatestActiveSession() {
if (!userStore.user) return
try {
@@ -306,6 +330,7 @@ async function refreshAfterTurn(sessionId: string, turnId: string) {
await pollTurnResult(sessionId, turnId)
await loadSessionDetail(sessionId)
await loadSessions()
await loadVoiceAnalytics()
}
async function submitTextTurn() {
@@ -360,7 +385,7 @@ async function finalizeSession() {
finalizing.value = true
error.value = ''
try {
const result = await api.post<VoiceSessionFinalizeResponse>(
await api.post<VoiceSessionFinalizeResponse>(
`/api/voice-sessions/${activeSession.value.id}/finalize`,
{
save_story: true,
@@ -370,9 +395,7 @@ async function finalizeSession() {
)
await loadSessions()
await loadSessionDetail(activeSession.value.id)
if (result.story_id) {
router.push(`/story/${result.story_id}`)
}
await loadVoiceAnalytics()
} catch (err) {
error.value = err instanceof Error ? err.message : '保存语音共创故事失败'
} finally {
@@ -406,6 +429,7 @@ async function retryAssistantAudio(turnId: string) {
)
await loadSessionDetail(activeSession.value.id)
await loadSessions()
await loadVoiceAnalytics()
} catch (err) {
error.value = err instanceof Error ? err.message : '补发语音失败'
} finally {
@@ -413,6 +437,33 @@ async function retryAssistantAudio(turnId: string) {
}
}
async function resolveTurnConfirmation(turn: VoiceTurnSummary, action: 'accept' | 'retry_recording' | 'switch_to_text') {
if (!activeSession.value) return
sendingTurn.value = true
error.value = ''
try {
await api.post<VoiceTurnSummary>(
`/api/voice-sessions/${activeSession.value.id}/turns/${turn.id}/confirm`,
{ action },
)
if (action === 'switch_to_text') {
textTurnInput.value = turn.user_transcript || ''
clearRecordedAudio()
}
if (action === 'retry_recording') {
uploadTranscriptHint.value = turn.user_transcript || ''
clearRecordedAudio()
}
await loadSessionDetail(activeSession.value.id)
await loadSessions()
await loadVoiceAnalytics()
} catch (err) {
error.value = err instanceof Error ? err.message : '确认当前理解失败'
} finally {
sendingTurn.value = false
}
}
async function abandonSession() {
if (!activeSession.value) return
abandoning.value = true
@@ -489,6 +540,11 @@ function resetRecording() {
clearRecordedAudio()
}
function viewFinalStory() {
if (!activeSession.value?.final_story_id) return
router.push(`/story/${activeSession.value.final_story_id}`)
}
watch(selectedProfileId, (newId) => {
if (newId) {
void fetchUniverses(newId)
@@ -522,6 +578,7 @@ onMounted(async () => {
await fetchProfiles()
await loadLatestActiveSession()
await loadSessions()
await loadVoiceAnalytics()
})
onBeforeUnmount(() => {
@@ -672,6 +729,37 @@ onBeforeUnmount(() => {
</div>
</BaseCard>
<BaseCard v-if="voiceAnalytics" class="border border-slate-100 bg-white/90">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900">语音共创观测</h2>
<p class="mt-1 text-sm text-gray-500">最近 {{ voiceAnalytics.window_days ?? 30 }} 天的会话质量概览</p>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">Turn 成功率</div>
<div class="mt-1 text-lg font-semibold text-gray-900">{{ turnSuccessRateLabel }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">低置信度触发</div>
<div class="mt-1 text-lg font-semibold text-amber-700">{{ voiceAnalytics.low_confidence_turns }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">安全介入</div>
<div class="mt-1 text-lg font-semibold text-rose-700">{{ voiceAnalytics.safety_interventions }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">Finalize 转化率</div>
<div class="mt-1 text-lg font-semibold text-emerald-700">{{ finalizeConversionRateLabel }}</div>
</div>
</div>
<p class="mt-4 text-sm text-gray-500">
ASR 失败 {{ voiceAnalytics.asr_failures }} TTS 失败 {{ voiceAnalytics.tts_failures }}
当前共有 {{ voiceAnalytics.total_sessions }} 个会话已完成 {{ voiceAnalytics.finalized_sessions }}
</p>
</BaseCard>
<div v-if="loadingSessionDetail" class="py-16">
<LoadingSpinner text="正在加载会话详情..." />
</div>
@@ -691,6 +779,9 @@ onBeforeUnmount(() => {
最近意图{{ formatIntent(activeSession.latest_detected_intent) }} ·
已完成 {{ activeSession.total_turns }}
</p>
<p v-if="activeSession.final_story_id" class="mt-2 text-sm text-emerald-700">
已沉淀为正式故事 #{{ activeSession.final_story_id }}
</p>
</div>
<div
@@ -720,6 +811,14 @@ onBeforeUnmount(() => {
<XMarkIcon class="h-5 w-5" />
放弃会话
</BaseButton>
<BaseButton
v-if="activeSession.final_story_id"
variant="ghost"
@click="viewFinalStory"
>
<BookOpenIcon class="h-5 w-5" />
查看正式故事
</BaseButton>
</div>
</div>
@@ -738,6 +837,36 @@ onBeforeUnmount(() => {
</p>
</div>
<div
v-if="activeSession.latest_safety_message"
class="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-700"
>
<div class="text-sm font-semibold">已触发儿童内容安全兜底</div>
<p class="mt-2 text-sm">{{ activeSession.latest_safety_message }}</p>
<p v-if="activeSession.latest_safety_flags.length" class="mt-2 text-xs text-rose-600">
安全标记{{ activeSession.latest_safety_flags.join(' / ') }}
</p>
</div>
<div
v-if="activeSession.final_story_id"
class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-800"
>
<div class="text-sm font-semibold">正式故事已生成</div>
<p class="mt-2 text-sm">
当前语音共创已经沉淀为正式故事{{ activeSession.working_title || '未命名故事' }}
</p>
<p v-if="finalStorySummary" class="mt-2 text-sm text-emerald-700">
摘要{{ finalStorySummary }}
</p>
<div class="mt-3">
<BaseButton size="sm" variant="secondary" @click="viewFinalStory">
<BookOpenIcon class="h-4 w-4" />
打开正式故事
</BaseButton>
</div>
</div>
<div class="rounded-2xl border border-gray-100 bg-white p-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold text-gray-900">文本共创回合</h3>
@@ -750,12 +879,12 @@ onBeforeUnmount(() => {
placeholder="例如:不要让它害怕,我想让它遇见一个新朋友。"
:rows="4"
:max-length="1000"
:disabled="sendingTurn || !activeSession.can_continue"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
/>
<BaseButton
@click="submitTextTurn"
:loading="sendingTurn"
:disabled="!activeSession.can_continue || !textTurnInput.trim()"
:disabled="!activeSession.can_continue || !textTurnInput.trim() || hasPendingConfirmation"
>
<PaperAirplaneIcon class="h-5 w-5" />
发送文本回合
@@ -777,7 +906,7 @@ onBeforeUnmount(() => {
v-if="!recording"
variant="secondary"
@click="startRecording"
:disabled="sendingTurn || !activeSession.can_continue"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
>
<MicrophoneIcon class="h-5 w-5" />
开始录音
@@ -827,7 +956,7 @@ onBeforeUnmount(() => {
<BaseButton
@click="submitRecordedTurn"
:loading="sendingTurn"
:disabled="!activeSession.can_continue || !recordedBlob"
:disabled="!activeSession.can_continue || !recordedBlob || hasPendingConfirmation"
>
<SparklesIcon class="h-5 w-5" />
上传录音回合
@@ -874,6 +1003,42 @@ onBeforeUnmount(() => {
转写置信度{{ formatConfidence(turn.transcript_confidence) }} ·
意图置信度{{ formatConfidence(turn.intent_confidence) }}
</p>
<div class="mt-3 flex flex-wrap gap-2">
<BaseButton
size="sm"
variant="secondary"
@click="resolveTurnConfirmation(turn, 'accept')"
:disabled="sendingTurn"
>
按这个理解继续
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
@click="resolveTurnConfirmation(turn, 'retry_recording')"
:disabled="sendingTurn"
>
不对重说一遍
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
@click="resolveTurnConfirmation(turn, 'switch_to_text')"
:disabled="sendingTurn"
>
改成文本输入
</BaseButton>
</div>
</div>
<div
v-if="turn.safety_message"
class="mt-3 rounded-2xl border border-rose-200 bg-rose-50 px-3 py-3 text-sm text-rose-700"
>
<div class="font-medium">儿童内容安全已介入</div>
<p class="mt-1">{{ turn.safety_message }}</p>
<p v-if="turn.safety_flags.length" class="mt-2 text-xs text-rose-600">
安全标记{{ turn.safety_flags.join(' / ') }}
</p>
</div>
<div v-if="turn.user_audio_url" class="mt-3">
<audio class="w-full" :src="turn.user_audio_url" controls></audio>
@@ -891,7 +1056,7 @@ onBeforeUnmount(() => {
size="sm"
variant="secondary"
@click="retryFailedTurn(turn.id)"
:disabled="sendingTurn || !activeSession?.can_continue"
:disabled="sendingTurn || !activeSession?.can_continue || hasPendingConfirmation"
>
<ArrowPathIcon class="h-4 w-4" />
重试本轮