Files
dreamweaver/frontend/src/views/VoiceStudio.vue

1130 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/client'
import { useUserStore } from '../stores/user'
import type {
VoiceSessionAnalytics,
VoiceSessionDetail,
VoiceSessionFinalizeResponse,
VoiceSessionSummary,
VoiceTurnAcceptedResponse,
VoiceTurnSummary,
VoiceTurnUploadAcceptedResponse,
} from '../types/voiceSession'
import BaseButton from '../components/ui/BaseButton.vue'
import BaseCard from '../components/ui/BaseCard.vue'
import BaseSelect from '../components/ui/BaseSelect.vue'
import BaseTextarea from '../components/ui/BaseTextarea.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import {
ArrowPathIcon,
BookOpenIcon,
ExclamationCircleIcon,
MicrophoneIcon,
PaperAirplaneIcon,
SparklesIcon,
StopIcon,
XMarkIcon,
} from '@heroicons/vue/24/outline'
interface ChildProfile {
id: string
name: string
}
interface StoryUniverse {
id: string
name: string
}
const router = useRouter()
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('')
const selectedUniverseId = ref('')
const sessionFilter = ref<'active' | 'recent'>('active')
const textTurnInput = ref('')
const uploadTranscriptHint = ref('')
const loadingSessions = ref(false)
const creatingSession = ref(false)
const loadingSessionDetail = ref(false)
const sendingTurn = ref(false)
const finalizing = ref(false)
const abandoning = ref(false)
const recording = ref(false)
const recordingDurationMs = ref(0)
const error = ref('')
const mediaError = ref('')
const recorderSupported = computed(() => typeof window !== 'undefined' && 'MediaRecorder' in window)
const sessionPollIntervalMs = 1500
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let recordingChunks: Blob[] = []
let recordingTimer: number | null = null
let recordingStartedAt = 0
let sessionPollTimer: number | null = null
const recordedBlob = ref<Blob | null>(null)
const recordedAudioUrl = ref<string | null>(null)
const profileOptions = computed(() =>
profiles.value.map((profile) => ({ value: profile.id, label: profile.name })),
)
const universeOptions = computed(() =>
universes.value.map((universe) => ({ value: universe.id, label: universe.name })),
)
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':
return '当前会尝试使用 OpenAI 语音转写,转写提示可以留空。'
case 'disabled':
return '当前环境禁用了真实语音转写,请先使用文本共创或填写开发转写提示。'
default:
return '当前默认是 demo 转写模式。若本地未接真实 ASR可在下方填写转写提示辅助开发验证。'
}
})
const isSessionProcessing = computed(
() =>
activeSession.value?.status === 'processing_turn'
|| activeSession.value?.last_turn_status === 'received'
|| activeSession.value?.last_turn_status === 'transcribing',
)
function formatSessionStatus(status: string) {
switch (status) {
case 'draft':
return '待开始'
case 'processing_turn':
return '处理中'
case 'waiting_user':
return '等待下一轮'
case 'finalizing_story':
return '保存中'
case 'completed':
return '已完成'
case 'abandoned':
return '已放弃'
case 'failed':
return '失败'
default:
return status
}
}
function formatTurnStatus(status: string) {
switch (status) {
case 'received':
return '已接收'
case 'transcribing':
return '转写中'
case 'narrative_ready':
return '文本已生成'
case 'audio_ready':
return '语音已生成'
case 'failed':
return '失败'
default:
return status
}
}
function formatIntent(intent: string | null | undefined) {
switch (intent) {
case 'start_story':
return '开启故事'
case 'continue_story':
return '继续讲述'
case 'correct_story':
return '修正走向'
case 'end_story':
return '结束本轮'
case 'save_story':
return '请求保存'
default:
return intent || '未知'
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatConfidence(value: number | null | undefined) {
if (typeof value !== 'number') {
return 'n/a'
}
return `${Math.round(value * 100)}%`
}
function revokeRecordedAudioUrl() {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value)
recordedAudioUrl.value = null
}
}
function clearRecordedAudio() {
revokeRecordedAudioUrl()
recordedBlob.value = null
recordingDurationMs.value = 0
}
async function fetchProfiles() {
if (!userStore.user) return
const data = await api.get<{ profiles: ChildProfile[] }>('/api/profiles')
profiles.value = data.profiles
if (!selectedProfileId.value && profiles.value.length > 0) {
selectedProfileId.value = profiles.value[0].id
}
}
async function fetchUniverses(profileId: string) {
selectedUniverseId.value = ''
if (!profileId) {
universes.value = []
return
}
const data = await api.get<{ universes: StoryUniverse[] }>(`/api/profiles/${profileId}/universes`)
universes.value = data.universes
if (universes.value.length > 0) {
selectedUniverseId.value = universes.value[0].id
}
}
async function loadSessions() {
if (!userStore.user) return
loadingSessions.value = true
try {
const params = new URLSearchParams({
limit: '8',
active_first: 'true',
active_only: sessionFilter.value === 'active' ? 'true' : 'false',
})
sessions.value = await api.get<VoiceSessionSummary[]>(`/api/voice-sessions?${params.toString()}`)
if (!activeSession.value && sessionFilter.value === 'recent') {
const resumable = sessions.value.find((item) => item.can_continue)
if (resumable) {
await loadSessionDetail(resumable.id)
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : '会话列表加载失败'
} finally {
loadingSessions.value = false
}
}
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 {
const session = await api.get<VoiceSessionSummary | null>('/api/voice-sessions/active')
if (session) {
await loadSessionDetail(session.id)
}
} catch {
// Ignore active-session bootstrap failures and fall back to normal listing.
}
}
async function loadSessionDetail(sessionId: string) {
loadingSessionDetail.value = true
error.value = ''
try {
activeSession.value = await api.get<VoiceSessionDetail>(`/api/voice-sessions/${sessionId}`)
} catch (err) {
error.value = err instanceof Error ? err.message : '会话详情加载失败'
} finally {
loadingSessionDetail.value = false
}
}
function stopSessionPolling() {
if (sessionPollTimer) {
window.clearInterval(sessionPollTimer)
sessionPollTimer = null
}
}
function startSessionPolling() {
if (!activeSession.value?.id || sessionPollTimer) return
sessionPollTimer = window.setInterval(() => {
if (activeSession.value?.id) {
void loadSessionDetail(activeSession.value.id)
void loadSessions()
}
}, sessionPollIntervalMs)
}
async function createSession() {
creatingSession.value = true
error.value = ''
try {
const session = await api.post<VoiceSessionSummary>('/api/voice-sessions', {
child_profile_id: selectedProfileId.value || null,
universe_id: selectedUniverseId.value || null,
target_mode: 'story',
})
sessionFilter.value = 'active'
await loadSessions()
await loadSessionDetail(session.id)
textTurnInput.value = ''
uploadTranscriptHint.value = ''
clearRecordedAudio()
} catch (err) {
error.value = err instanceof Error ? err.message : '创建语音共创会话失败'
} finally {
creatingSession.value = false
}
}
async function pollTurnResult(sessionId: string, turnId: string) {
const terminalStatuses = new Set(['audio_ready', 'narrative_ready', 'failed'])
for (let attempt = 0; attempt < 20; attempt += 1) {
const turn = await api.get<VoiceTurnSummary>(`/api/voice-sessions/${sessionId}/turns/${turnId}`)
if (terminalStatuses.has(turn.status)) {
return turn
}
await new Promise((resolve) => window.setTimeout(resolve, 600))
}
throw new Error('本轮语音共创处理超时,请刷新后查看最新状态')
}
async function refreshAfterTurn(sessionId: string, turnId: string) {
await pollTurnResult(sessionId, turnId)
await loadSessionDetail(sessionId)
await loadSessions()
await loadVoiceAnalytics()
}
async function submitTextTurn() {
if (!activeSession.value || !textTurnInput.value.trim()) return
sendingTurn.value = true
error.value = ''
try {
const result = await api.post<VoiceTurnAcceptedResponse>(
`/api/voice-sessions/${activeSession.value.id}/turns/fallback`,
{
transcript_text: textTurnInput.value.trim(),
},
)
textTurnInput.value = ''
await refreshAfterTurn(result.session_id, result.turn_id)
} catch (err) {
error.value = err instanceof Error ? err.message : '文本共创回合提交失败'
} finally {
sendingTurn.value = false
}
}
async function submitRecordedTurn() {
if (!activeSession.value || !recordedBlob.value) return
sendingTurn.value = true
error.value = ''
try {
const formData = new FormData()
formData.append('audio_file', recordedBlob.value, 'voice-turn.webm')
if (recordingDurationMs.value > 0) {
formData.append('duration_ms', String(recordingDurationMs.value))
}
if (uploadTranscriptHint.value.trim()) {
formData.append('transcript_hint', uploadTranscriptHint.value.trim())
}
const result = await api.postForm<VoiceTurnUploadAcceptedResponse>(
`/api/voice-sessions/${activeSession.value.id}/turns`,
formData,
)
clearRecordedAudio()
uploadTranscriptHint.value = ''
await refreshAfterTurn(result.session_id, result.turn_id)
} catch (err) {
error.value = err instanceof Error ? err.message : '录音回合提交失败'
} finally {
sendingTurn.value = false
}
}
async function finalizeSession() {
if (!activeSession.value) return
finalizing.value = true
error.value = ''
try {
await api.post<VoiceSessionFinalizeResponse>(
`/api/voice-sessions/${activeSession.value.id}/finalize`,
{
save_story: true,
generate_cover: true,
generate_final_audio: false,
},
)
await loadSessions()
await loadSessionDetail(activeSession.value.id)
await loadVoiceAnalytics()
} catch (err) {
error.value = err instanceof Error ? err.message : '保存语音共创故事失败'
} finally {
finalizing.value = false
}
}
async function retryFailedTurn(turnId: string) {
if (!activeSession.value) return
sendingTurn.value = true
error.value = ''
try {
const result = await api.post<VoiceTurnAcceptedResponse>(
`/api/voice-sessions/${activeSession.value.id}/turns/${turnId}/retry`,
)
await refreshAfterTurn(result.session_id, result.turn_id)
} catch (err) {
error.value = err instanceof Error ? err.message : '重试本轮故事失败'
} finally {
sendingTurn.value = false
}
}
async function retryAssistantAudio(turnId: string) {
if (!activeSession.value) return
sendingTurn.value = true
error.value = ''
try {
await api.post<VoiceTurnSummary>(
`/api/voice-sessions/${activeSession.value.id}/turns/${turnId}/retry-audio`,
)
await loadSessionDetail(activeSession.value.id)
await loadSessions()
await loadVoiceAnalytics()
} catch (err) {
error.value = err instanceof Error ? err.message : '补发语音失败'
} finally {
sendingTurn.value = false
}
}
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
error.value = ''
try {
const summary = await api.post<VoiceSessionSummary>(
`/api/voice-sessions/${activeSession.value.id}/abandon`,
{ reason: '用户在语音共创页主动结束会话' },
)
await loadSessions()
activeSession.value = {
...(activeSession.value as VoiceSessionDetail),
...summary,
}
} catch (err) {
error.value = err instanceof Error ? err.message : '放弃会话失败'
} finally {
abandoning.value = false
}
}
async function startRecording() {
mediaError.value = ''
if (!recorderSupported.value) {
mediaError.value = '当前浏览器不支持录音,请先使用文本共创模式。'
return
}
try {
clearRecordedAudio()
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(mediaStream)
recordingChunks = []
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordingChunks.push(event.data)
}
}
mediaRecorder.onstop = () => {
const blob = new Blob(recordingChunks, { type: mediaRecorder?.mimeType || 'audio/webm' })
recordedBlob.value = blob
revokeRecordedAudioUrl()
recordedAudioUrl.value = URL.createObjectURL(blob)
}
mediaRecorder.start()
recording.value = true
recordingStartedAt = Date.now()
recordingTimer = window.setInterval(() => {
recordingDurationMs.value = Date.now() - recordingStartedAt
}, 200)
} catch (err) {
mediaError.value = err instanceof Error ? err.message : '无法访问麦克风'
}
}
function stopRecording() {
if (!mediaRecorder || !recording.value) return
mediaRecorder.stop()
mediaRecorder = null
recording.value = false
if (recordingTimer) {
window.clearInterval(recordingTimer)
recordingTimer = null
}
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop())
mediaStream = null
}
}
function resetRecording() {
if (recording.value) {
stopRecording()
}
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)
} else {
universes.value = []
selectedUniverseId.value = ''
}
})
watch(sessionFilter, () => {
void loadSessions()
})
watch(
() => isSessionProcessing.value,
(processing) => {
if (processing) {
startSessionPolling()
} else {
stopSessionPolling()
}
},
{ immediate: true },
)
onMounted(async () => {
if (!userStore.user) {
await userStore.fetchSession()
}
if (!userStore.user) return
await fetchProfiles()
await loadLatestActiveSession()
await loadSessions()
await loadVoiceAnalytics()
})
onBeforeUnmount(() => {
stopSessionPolling()
if (recording.value) {
stopRecording()
}
clearRecordedAudio()
})
</script>
<template>
<div class="max-w-7xl mx-auto space-y-8">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 class="text-3xl font-bold gradient-text">语音共创工作台</h1>
<p class="mt-2 text-gray-500">
第一阶段先跑通回合式共创孩子说一句系统接一句再决定是否保存为正式故事
</p>
</div>
<div class="flex flex-wrap gap-3">
<BaseButton variant="secondary" @click="loadSessions" :loading="loadingSessions">
<ArrowPathIcon class="h-5 w-5" />
刷新会话
</BaseButton>
<BaseButton @click="createSession" :loading="creatingSession">
<SparklesIcon class="h-5 w-5" />
开始新会话
</BaseButton>
</div>
</div>
<div v-if="!userStore.user" class="py-12">
<EmptyState
:icon="BookOpenIcon"
title="需要先登录"
description="登录后才能使用语音共创工作台。"
/>
</div>
<template v-else>
<div class="grid grid-cols-1 gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
<div class="space-y-6">
<BaseCard>
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold text-gray-900">创建条件</h2>
<p class="mt-1 text-sm text-gray-500">
先选孩子档案与故事宇宙让共创更容易复用现有角色和世界观
</p>
</div>
<BaseSelect
v-model="selectedProfileId"
label="孩子档案"
:options="profileOptions"
placeholder="请选择孩子档案"
/>
<BaseSelect
v-model="selectedUniverseId"
label="故事宇宙"
:options="universeOptions"
placeholder="可选,默认不绑定宇宙"
/>
<BaseButton class="w-full" @click="createSession" :loading="creatingSession">
创建语音共创会话
</BaseButton>
</div>
</BaseCard>
<BaseCard>
<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">支持恢复最近还在等待下一轮的 session</p>
</div>
<span class="text-xs text-gray-400">{{ sessions.length }} </span>
</div>
<div class="mt-4 flex gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="sessionFilter === 'active'
? 'border-purple-600 bg-purple-600 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="sessionFilter = 'active'"
>
活跃会话
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="sessionFilter === 'recent'
? 'border-purple-600 bg-purple-600 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="sessionFilter = 'recent'"
>
最近全部
</button>
</div>
<div v-if="loadingSessions" class="py-8">
<LoadingSpinner text="加载会话中..." />
</div>
<div v-else-if="sessions.length === 0" class="pt-6">
<EmptyState
:icon="SparklesIcon"
title="还没有语音共创会话"
description="先创建一个会话,再通过文本或录音开始第一轮故事。"
/>
</div>
<div v-else class="mt-4 space-y-3">
<button
v-for="session in sessions"
:key="session.id"
type="button"
class="w-full rounded-2xl border px-4 py-3 text-left transition-all"
:class="activeSession?.id === session.id
? 'border-purple-300 bg-purple-50'
: 'border-gray-100 bg-white hover:border-gray-300'"
@click="loadSessionDetail(session.id)"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="truncate font-medium text-gray-900">
{{ session.working_title || '未命名语音会话' }}
</div>
<div class="mt-1 text-xs text-gray-500">
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }}
</div>
</div>
<div class="text-right text-xs text-gray-400">
{{ formatDate(session.updated_at) }}
</div>
</div>
</button>
</div>
</BaseCard>
</div>
<div class="space-y-6">
<BaseCard v-if="error" class="border border-rose-100 bg-rose-50 text-rose-600">
<div class="flex items-start gap-3">
<ExclamationCircleIcon class="mt-0.5 h-5 w-5 shrink-0" />
<div>{{ error }}</div>
</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>
<BaseCard v-else-if="activeSession">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-2xl font-semibold text-gray-900">
{{ activeSession.working_title || '语音共创会话' }}
</h2>
<span class="rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700">
{{ formatSessionStatus(activeSession.status) }}
</span>
</div>
<p class="mt-2 text-sm text-gray-500">
最近意图{{ 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
v-if="isSessionProcessing"
class="rounded-2xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-700"
>
当前会话仍在处理中页面会自动轮询刷新
</div>
<div class="flex flex-wrap gap-3">
<BaseButton
variant="secondary"
@click="finalizeSession"
:loading="finalizing"
:disabled="!activeSession.can_finalize"
>
<BookOpenIcon class="h-5 w-5" />
保存为正式故事
</BaseButton>
<BaseButton
variant="ghost"
class="text-rose-500 hover:bg-rose-50"
@click="abandonSession"
:loading="abandoning"
:disabled="activeSession.status === 'completed' || activeSession.status === 'abandoned'"
>
<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>
<div class="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<div class="space-y-6">
<div
v-if="activeSession.latest_requires_confirmation"
class="rounded-2xl border border-amber-200 bg-amber-50 p-4 text-amber-800"
>
<div class="text-sm font-semibold">建议先确认这一轮理解</div>
<p class="mt-2 text-sm">
{{ activeSession.latest_confirmation_message || '系统对这一轮的理解还不够确定,建议家长先确认后再继续。' }}
</p>
<p v-if="activeSession.latest_understanding_summary" class="mt-2 text-xs text-amber-700">
{{ activeSession.latest_understanding_summary }}
</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>
<span class="text-xs text-gray-400">最稳的 fallback 路径</span>
</div>
<div class="mt-4 space-y-4">
<BaseTextarea
v-model="textTurnInput"
label="本轮你想让故事怎么发展"
placeholder="例如:不要让它害怕,我想让它遇见一个新朋友。"
:rows="4"
:max-length="1000"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
/>
<BaseButton
@click="submitTextTurn"
:loading="sendingTurn"
:disabled="!activeSession.can_continue || !textTurnInput.trim() || hasPendingConfirmation"
>
<PaperAirplaneIcon class="h-5 w-5" />
发送文本回合
</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>
<span class="text-xs text-gray-400">已支持上传音频 turn</span>
</div>
<p class="mt-2 text-sm text-gray-500">
{{ transcriptionModeDescription }}
</p>
<div class="mt-4 flex flex-wrap gap-3">
<BaseButton
v-if="!recording"
variant="secondary"
@click="startRecording"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
>
<MicrophoneIcon class="h-5 w-5" />
开始录音
</BaseButton>
<BaseButton
v-else
variant="danger"
@click="stopRecording"
>
<StopIcon class="h-5 w-5" />
停止录音
</BaseButton>
<BaseButton
variant="ghost"
@click="resetRecording"
:disabled="(!recordedBlob && !recording) || sendingTurn"
>
<XMarkIcon class="h-5 w-5" />
清空录音
</BaseButton>
</div>
<p v-if="recording" class="mt-3 text-sm text-amber-600">
正在录音{{ Math.round(recordingDurationMs / 1000) }}s
</p>
<p v-if="mediaError" class="mt-3 text-sm text-rose-600">
{{ mediaError }}
</p>
<div v-if="recordedAudioUrl" class="mt-4 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<div class="text-sm font-medium text-gray-700">录音预览</div>
<audio class="mt-3 w-full" :src="recordedAudioUrl" controls></audio>
</div>
<div class="mt-4">
<BaseTextarea
v-model="uploadTranscriptHint"
label="开发转写提示(可选)"
placeholder="如果当前环境还是 demo 转写模式,可以把你刚才说的话写在这里。"
:rows="3"
:max-length="1000"
/>
</div>
<div class="mt-4">
<BaseButton
@click="submitRecordedTurn"
:loading="sendingTurn"
:disabled="!activeSession.can_continue || !recordedBlob || hasPendingConfirmation"
>
<SparklesIcon class="h-5 w-5" />
上传录音回合
</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>
<span class="text-xs text-gray-400">{{ activeTurnList.length }} 条最近 turn</span>
</div>
<div class="mt-4 space-y-4">
<div
v-for="turn in activeTurnList"
:key="turn.id"
class="rounded-2xl border border-gray-100 bg-gray-50 p-4"
>
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span> {{ turn.turn_index }} </span>
<span>·</span>
<span>{{ formatTurnStatus(turn.status) }}</span>
<span>·</span>
<span>{{ formatIntent(turn.detected_intent) }}</span>
<span v-if="turn.transcription_provider">· {{ turn.transcription_provider }}</span>
</div>
<div class="mt-3 text-sm text-gray-800">
<span class="font-medium text-gray-900">孩子</span>
{{ turn.user_transcript || '暂无转写内容' }}
</div>
<div v-if="turn.understanding_summary" class="mt-3 text-sm text-slate-600">
{{ turn.understanding_summary }}
</div>
<div
v-if="turn.requires_confirmation"
class="mt-3 rounded-2xl border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800"
>
<div class="font-medium">建议家长确认后再继续</div>
<p class="mt-1">
{{ turn.confirmation_message || '系统对这一轮的理解还不够确定,建议换一种说法再试一次。' }}
</p>
<p class="mt-2 text-xs text-amber-700">
转写置信度{{ 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>
</div>
<div v-if="turn.assistant_text" class="mt-4 text-sm text-gray-700">
<span class="font-medium text-purple-700">织机回应</span>
{{ turn.assistant_text }}
</div>
<div v-if="turn.assistant_audio_url" class="mt-3">
<audio class="w-full" :src="turn.assistant_audio_url" controls></audio>
</div>
<div class="mt-4 flex flex-wrap gap-3">
<BaseButton
v-if="turn.status === 'failed'"
size="sm"
variant="secondary"
@click="retryFailedTurn(turn.id)"
:disabled="sendingTurn || !activeSession?.can_continue || hasPendingConfirmation"
>
<ArrowPathIcon class="h-4 w-4" />
重试本轮
</BaseButton>
<BaseButton
v-if="turn.assistant_text && !turn.assistant_audio_ready"
size="sm"
variant="ghost"
@click="retryAssistantAudio(turn.id)"
:disabled="sendingTurn"
>
<ArrowPathIcon class="h-4 w-4" />
补发语音
</BaseButton>
</div>
<div v-if="turn.error_message" class="mt-3 text-sm text-rose-600">
{{ turn.error_message }}
</div>
</div>
<EmptyState
v-if="activeTurnList.length === 0"
:icon="MicrophoneIcon"
title="从第一轮开始"
description="先发送一句文本或录音,让这个会话拥有第一段故事。"
/>
</div>
</div>
</div>
<div class="space-y-6">
<div class="rounded-2xl border border-gray-100 bg-white p-4">
<h3 class="font-semibold text-gray-900">故事状态快照</h3>
<pre class="mt-4 overflow-x-auto rounded-xl bg-gray-950 p-4 text-xs leading-6 text-emerald-200">{{ JSON.stringify(activeSession.story_state, null, 2) }}</pre>
</div>
<div class="rounded-2xl border border-gray-100 bg-white p-4">
<h3 class="font-semibold text-gray-900">最近事件</h3>
<div class="mt-4 space-y-3">
<div
v-for="event in activeSession.events.slice(-10)"
:key="event.id"
class="rounded-xl border border-gray-100 bg-gray-50 px-3 py-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-gray-400">
<span>{{ event.event_type }}</span>
<span>{{ formatDate(event.created_at) }}</span>
</div>
<div class="mt-1 text-sm font-medium text-gray-800">
{{ event.message || event.status }}
</div>
</div>
</div>
</div>
</div>
</div>
</BaseCard>
<div v-else class="py-16">
<EmptyState
:icon="SparklesIcon"
title="创建或恢复一个语音共创会话"
description="左侧可以直接创建新会话,也可以恢复最近仍在等待下一轮的 session。"
/>
</div>
</div>
</div>
</template>
</div>
</template>