feat: complete voice session safety and confirmation flow
This commit is contained in:
@@ -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" />
|
||||
重试本轮
|
||||
|
||||
Reference in New Issue
Block a user