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

2486 lines
95 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, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, 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 GenerationTrace from '../components/GenerationTrace.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import {
getVoiceSessionNextAction,
getVoiceSessionNextStep,
} from '../utils/voiceSession'
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
}
interface VoiceStoryStateSummary {
premise: string | null
latestDirection: string | null
latestSegment: string | null
segmentCount: number
safetyFlags: string[]
coverPromptReady: boolean
}
interface VoiceStoryActionStep {
key: 'confirm' | 'continue' | 'finalize' | 'review_result'
title: string
description: string
status: 'done' | 'current' | 'upcoming'
contextLabel?: string
contextValue?: string | null
}
type SessionFilter = 'active' | 'attention' | 'recent'
type AttentionReasonFilter = 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
const router = useRouter()
const route = useRoute()
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<SessionFilter>('active')
const attentionReasonFilter = ref<AttentionReasonFilter>('all')
const analyticsWindow = ref<'7' | '30' | 'all'>('30')
const analyticsProviderFilter = ref('')
const analyticsStatusFilter = ref('')
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
let autoAdvanceNoticeTimer: number | null = null
let attentionCompletionNoticeTimer: 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 analyticsProviderOptions = [
{ value: 'fallback', label: '文本 fallback' },
{ value: 'demo', label: 'Demo ASR' },
{ value: 'openai', label: 'OpenAI ASR' },
{ value: 'openai_asr', label: 'OpenAI ASR Adapter' },
]
const analyticsStatusOptions = [
{ value: 'draft', label: '草稿' },
{ value: 'active', label: '进行中' },
{ value: 'waiting_user', label: '等待用户' },
{ value: 'completed', label: '已完成' },
{ value: 'abandoned', label: '已放弃' },
]
const filteredSessions = computed(() => {
return resolveDisplayedSessions(sessions.value)
})
const getSessionInputModeSummary = (session: VoiceSessionSummary) => {
if (session.latest_requires_confirmation) return '上一轮待确认'
if (session.latest_safety_flags.length) return `安全介入 ${session.latest_safety_flags.length}`
if (session.latest_detected_intent) return `最近意图:${formatIntent(session.latest_detected_intent)}`
return '等待输入'
}
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
const hasPendingConfirmation = computed(() => activeSession.value?.latest_requires_confirmation ?? false)
const latestPendingConfirmationTurn = computed(
() => [...activeTurnList.value].reverse().find((turn) => turn.requires_confirmation) ?? null,
)
const hasFailedAttention = computed(
() => activeSession.value?.attention_reasons.includes('failed_turn') ?? false,
)
const latestFailedTurn = computed(
() => [...activeTurnList.value].reverse().find((turn) => turn.status === 'failed') ?? null,
)
const latestSafetyTurn = computed(
() => [...activeTurnList.value].reverse().find((turn) => Boolean(turn.safety_message)) ?? null,
)
const requestedSessionId = computed(() =>
typeof route.query.session === 'string' ? route.query.session : null,
)
const finalStorySummary = computed(() => {
const value = activeSession.value?.story_state?.final_summary
return typeof value === 'string' ? value : null
})
const storyStateSummary = computed<VoiceStoryStateSummary | null>(() => {
if (!activeSession.value) return null
const storyState = activeSession.value.story_state ?? {}
const narrativeSegments = Array.isArray(storyState.narrative_segments)
? storyState.narrative_segments.filter((segment): segment is string => typeof segment === 'string' && segment.trim().length > 0)
: []
const safetyFlags = Array.isArray(storyState.safety_flags)
? storyState.safety_flags.filter((flag): flag is string => typeof flag === 'string' && flag.trim().length > 0)
: []
return {
premise: typeof storyState.premise === 'string' && storyState.premise.trim().length > 0
? storyState.premise.trim()
: null,
latestDirection: typeof storyState.latest_direction === 'string' && storyState.latest_direction.trim().length > 0
? storyState.latest_direction.trim()
: null,
latestSegment: narrativeSegments.length > 0 ? narrativeSegments[narrativeSegments.length - 1] : null,
segmentCount: narrativeSegments.length,
safetyFlags,
coverPromptReady: typeof storyState.cover_prompt === 'string' && storyState.cover_prompt.trim().length > 0,
}
})
const storyActionSteps = computed<VoiceStoryActionStep[]>(() => {
if (!activeSession.value || !storyStateSummary.value) return []
const steps: VoiceStoryActionStep[] = []
const isFinalized = Boolean(activeSession.value.final_story_id)
steps.push({
key: 'confirm',
title: '确认这一轮理解',
description: hasPendingConfirmation.value
? '这一轮还在等待家长确认,建议先确认系统理解后再继续。'
: activeSession.value.total_turns > 0
? '当前没有待确认回合,系统已经可以按最近理解继续往下讲。'
: '等第一轮输入进来后,这里会显示系统是否需要家长确认。',
status: hasPendingConfirmation.value
? 'current'
: activeSession.value.total_turns > 0
? 'done'
: 'upcoming',
contextLabel: hasPendingConfirmation.value ? '当前系统理解' : undefined,
contextValue: hasPendingConfirmation.value
? activeSession.value.latest_understanding_summary || activeSession.value.latest_confirmation_message
: null,
})
steps.push({
key: 'continue',
title: '继续推进故事',
description: activeSession.value.latest_safety_message
? '建议先换一种更温和的表达,再继续补下一段故事。'
: storyStateSummary.value.segmentCount > 0
? `当前已经生成 ${storyStateSummary.value.segmentCount} 段故事,可以继续补下一轮或修正走向。`
: '第一段故事生成后,这里会提示可以继续补下一轮。',
status: isFinalized
? 'done'
: !hasPendingConfirmation.value && activeSession.value.can_continue
? 'current'
: storyStateSummary.value.segmentCount > 0
? 'done'
: 'upcoming',
contextLabel:
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
? activeSession.value.latest_safety_message
? '建议改写方向'
: storyStateSummary.value.latestDirection
? '最新改写方向'
: storyStateSummary.value.latestSegment
? '最近一段故事'
: undefined
: undefined,
contextValue:
!isFinalized && !hasPendingConfirmation.value && activeSession.value.can_continue
? activeSession.value.latest_safety_message
|| storyStateSummary.value.latestDirection
|| truncateNarrative(storyStateSummary.value.latestSegment, 110)
: null,
})
steps.push({
key: 'finalize',
title: '保存当前版本',
description: isFinalized
? '当前版本已经沉淀为正式故事。'
: activeSession.value.can_finalize
? '当前内容已经足够完整,可以随时保存成正式故事。'
: '再多完成一段稳定内容后,就可以保存为正式故事。',
status: isFinalized
? 'done'
: activeSession.value.can_finalize
? 'current'
: 'upcoming',
contextLabel:
!isFinalized && activeSession.value.can_finalize
? '保存后内容预览'
: undefined,
contextValue:
!isFinalized && activeSession.value.can_finalize
? finalStorySummary.value || truncateNarrative(storyStateSummary.value.latestSegment, 110)
: null,
})
steps.push({
key: 'review_result',
title: '查看正式故事与后续资源',
description: isFinalized
? '现在可以查看正式故事回看结果,也可以等待封面等后续资源继续补齐。'
: '保存完成后,这里会接上正式故事查看和资源补全过程。',
status: isFinalized ? 'current' : 'upcoming',
contextLabel: isFinalized ? '正式故事状态' : undefined,
contextValue: isFinalized
? finalStorySummary.value
? `故事 #${activeSession.value.final_story_id} · ${finalStorySummary.value}`
: `故事 #${activeSession.value.final_story_id}`
: null,
})
return steps
})
const finalStoryId = computed(() => activeSession.value?.final_story_id ?? null)
const finalStoryHasAssetWork = computed(() => Boolean(finalStoryId.value))
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 confirmationRequestRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.confirmation_request_rate * 100)}%`
})
const userAudioTurnRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.user_audio_turn_rate * 100)}%`
})
const assistantAudioReadyRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.assistant_audio_ready_rate * 100)}%`
})
const asrSuccessRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.asr_success_rate * 100)}%`
})
const ttsSuccessRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.tts_success_rate * 100)}%`
})
const avgConfidenceSummary = computed(() => {
if (!voiceAnalytics.value) return '转写 0%,意图 0%'
const transcript = Math.round(voiceAnalytics.value.avg_transcript_confidence * 100)
const intent = Math.round(voiceAnalytics.value.avg_intent_confidence * 100)
return `转写 ${transcript}%,意图 ${intent}%`
})
const avgUserAudioDurationLabel = computed(() => {
if (!voiceAnalytics.value || !voiceAnalytics.value.avg_user_audio_duration_ms) return '0.0 秒'
return `${(voiceAnalytics.value.avg_user_audio_duration_ms / 1000).toFixed(1)}`
})
const avgAssistantAudioDurationLabel = computed(() => {
if (!voiceAnalytics.value || !voiceAnalytics.value.avg_assistant_audio_duration_ms) return '0.0 秒'
return `${(voiceAnalytics.value.avg_assistant_audio_duration_ms / 1000).toFixed(1)}`
})
const formatDurationMs = (durationMs: number | null | undefined) => {
if (!durationMs) return '0.0 秒'
return `${(durationMs / 1000).toFixed(1)}`
}
const transcriptionProviderSummary = computed(() => {
const counts = voiceAnalytics.value?.transcription_provider_counts ?? {}
const entries = Object.entries(counts).sort((left, right) => right[1] - left[1])
if (!entries.length) return '暂无转写来源'
return entries.map(([provider, count]) => `${provider} ${count}`).join('')
})
const analyticsWindowLabel = computed(() =>
formatAnalyticsWindowLabel(voiceAnalytics.value?.window_days ?? null),
)
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',
)
const pendingFocusTarget = ref<VoiceStudioFocusTarget | null>(null)
const suppressRouteReload = ref(false)
const suppressAutoAdvanceNotice = ref(false)
const autoAdvanceNotice = ref<{
fromTitle: string
toTitle: string
reasonLabel: string
} | null>(null)
const attentionCompletionNotice = ref<{
completedReason: AttentionReasonFilter
completedReasonLabel: string
} | null>(null)
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 truncateNarrative(value: string | null, maxLength = 90) {
if (!value) return null
const normalized = value.replace(/\s+/g, ' ').trim()
if (normalized.length <= maxLength) {
return normalized
}
return `${normalized.slice(0, maxLength).trim()}...`
}
function getStoryActionStepClass(status: VoiceStoryActionStep['status']) {
switch (status) {
case 'done':
return {
badge: 'bg-emerald-100 text-emerald-700 border-emerald-200',
dot: 'bg-emerald-500',
}
case 'current':
return {
badge: 'bg-amber-100 text-amber-700 border-amber-200',
dot: 'bg-amber-500',
}
default:
return {
badge: 'bg-gray-100 text-gray-600 border-gray-200',
dot: 'bg-gray-300',
}
}
}
function scrollToElement(elementId: string) {
void nextTick(() => {
document.getElementById(elementId)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
function handleStoryActionStep(step: VoiceStoryActionStep) {
if (step.status !== 'current') return
switch (step.key) {
case 'confirm':
scrollToElement('voice-attention-confirmation-card')
return
case 'continue':
jumpToTextTurnComposer('')
return
case 'finalize':
void finalizeSession()
return
case 'review_result':
viewFinalStory()
return
default:
return
}
}
function getStoryActionStepButtonLabel(step: VoiceStoryActionStep) {
if (step.status !== 'current') return null
switch (step.key) {
case 'confirm':
return '确认'
case 'continue':
return activeSession.value?.latest_safety_message ? '改写' : '继续'
case 'finalize':
return '保存当前版本'
case 'review_result':
return '查看正式故事'
default:
return null
}
}
function formatAnalyticsWindowLabel(windowDays: number | null | undefined) {
if (typeof windowDays === 'number') {
return `最近 ${windowDays}`
}
return '全部历史'
}
function sessionNeedsAttention(session: VoiceSessionSummary) {
return session.attention_reasons.length > 0
}
function formatAttentionReason(reason: string) {
switch (reason) {
case 'pending_confirmation':
return {
label: '待确认',
className: 'bg-amber-100 text-amber-700',
}
case 'safety_intervention':
return {
label: '安全介入',
className: 'bg-rose-100 text-rose-700',
}
case 'failed_turn':
return {
label: '失败待处理',
className: 'bg-slate-100 text-slate-700',
}
default:
return {
label: reason,
className: 'bg-gray-100 text-gray-700',
}
}
}
function getSessionUpdatedAtValue(session: VoiceSessionSummary) {
const value = Date.parse(session.updated_at)
return Number.isNaN(value) ? 0 : value
}
function getAttentionPriority(session: VoiceSessionSummary) {
if (session.attention_reasons.includes('pending_confirmation')) {
return 0
}
if (session.attention_reasons.includes('failed_turn')) {
return 1
}
if (session.attention_reasons.includes('safety_intervention')) {
return 2
}
return 3
}
function sortDisplayedSessions(list: VoiceSessionSummary[]) {
return [...list].sort((left, right) => {
if (sessionFilter.value === 'attention') {
const priorityDiff = getAttentionPriority(left) - getAttentionPriority(right)
if (priorityDiff !== 0) {
return priorityDiff
}
}
return getSessionUpdatedAtValue(right) - getSessionUpdatedAtValue(left)
})
}
function resolveDisplayedSessions(sourceSessions: VoiceSessionSummary[]) {
let visibleSessions: VoiceSessionSummary[]
switch (sessionFilter.value) {
case 'active':
visibleSessions = sourceSessions.filter((session) => session.can_continue)
break
case 'attention':
visibleSessions = sourceSessions.filter(
(session) =>
sessionNeedsAttention(session)
&& (
attentionReasonFilter.value === 'all'
|| session.attention_reasons.includes(attentionReasonFilter.value)
),
)
break
default:
visibleSessions = sourceSessions
break
}
return sortDisplayedSessions(visibleSessions)
}
function isSessionVisibleInCurrentFilter(sessionId: string) {
return resolveDisplayedSessions(sessions.value).some((session) => session.id === sessionId)
}
function parseSessionFilter(value: unknown): SessionFilter | null {
if (value === 'active' || value === 'attention' || value === 'recent') {
return value
}
return null
}
function parseAttentionReasonFilter(value: unknown): AttentionReasonFilter | null {
if (
value === 'all'
|| value === 'pending_confirmation'
|| value === 'safety_intervention'
|| value === 'failed_turn'
) {
return value
}
return null
}
function parseFocusTarget(value: unknown): VoiceStudioFocusTarget | null {
if (value === 'confirmation' || value === 'safety' || value === 'failed' || value === 'text') {
return value
}
return null
}
function applyRouteState() {
suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
sessionFilter.value = parseSessionFilter(route.query.filter) ?? 'active'
attentionReasonFilter.value = parseAttentionReasonFilter(route.query.reason) ?? 'all'
pendingFocusTarget.value = parseFocusTarget(route.query.focus)
}
function buildVoiceStudioRouteQuery(options?: {
sessionId?: string | null
focus?: VoiceStudioFocusTarget | null
}) {
const query: Record<string, string> = {}
if (sessionFilter.value !== 'active') {
query.filter = sessionFilter.value
}
if (sessionFilter.value === 'attention' && attentionReasonFilter.value !== 'all') {
query.reason = attentionReasonFilter.value
}
if (options?.sessionId) {
query.session = options.sessionId
}
if (options?.focus) {
query.focus = options.focus
}
return query
}
function routeQueryMatches(nextQuery: Record<string, string>) {
const currentEntries = Object.entries(route.query).filter(([, value]) => typeof value === 'string')
if (currentEntries.length !== Object.keys(nextQuery).length) {
return false
}
return currentEntries.every(([key, value]) => nextQuery[key] === value)
}
async function syncVoiceStudioRouteState(options?: {
sessionId?: string | null
focus?: VoiceStudioFocusTarget | null
}) {
const nextQuery = buildVoiceStudioRouteQuery(options)
if (routeQueryMatches(nextQuery)) {
return
}
suppressRouteReload.value = true
await router.replace({ path: '/voice-studio', query: nextQuery })
}
function buildVoiceAnalyticsPath() {
const params = new URLSearchParams()
if (analyticsWindow.value !== 'all') {
params.set('days', analyticsWindow.value)
}
if (analyticsProviderFilter.value) {
params.set('provider', analyticsProviderFilter.value)
}
if (analyticsStatusFilter.value) {
params.set('session_status', analyticsStatusFilter.value)
}
const query = params.toString()
const path = '/api/voice-sessions/analytics'
return query ? `${path}?${query}` : path
}
function buildVoiceSessionListPath() {
const params = new URLSearchParams({
limit: '12',
active_first: 'true',
})
if (sessionFilter.value === 'active') {
params.set('active_only', 'true')
}
if (sessionFilter.value === 'attention') {
params.set('needs_attention', 'true')
if (attentionReasonFilter.value !== 'all') {
params.set('attention_reason', attentionReasonFilter.value)
}
}
return `/api/voice-sessions?${params.toString()}`
}
function formatAttentionReasonTitle(reason: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn') {
switch (reason) {
case 'pending_confirmation':
return '待确认'
case 'safety_intervention':
return '安全介入'
case 'failed_turn':
return '失败待处理'
default:
return '全部需关注'
}
}
function formatAttentionEmptyDescription(
reason: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
) {
if (reason === 'all') {
return '当前没有待确认、安全介入或失败待处理的会话。'
}
return `当前筛选下没有“${formatAttentionReasonTitle(reason)}”相关会话。`
}
function revokeRecordedAudioUrl() {
if (recordedAudioUrl.value) {
URL.revokeObjectURL(recordedAudioUrl.value)
recordedAudioUrl.value = null
}
}
function clearRecordedAudio() {
revokeRecordedAudioUrl()
recordedBlob.value = null
recordingDurationMs.value = 0
}
function clearAutoAdvanceNotice() {
if (autoAdvanceNoticeTimer) {
window.clearTimeout(autoAdvanceNoticeTimer)
autoAdvanceNoticeTimer = null
}
autoAdvanceNotice.value = null
}
function clearAttentionCompletionNotice() {
if (attentionCompletionNoticeTimer) {
window.clearTimeout(attentionCompletionNoticeTimer)
attentionCompletionNoticeTimer = null
}
attentionCompletionNotice.value = null
}
function showAutoAdvanceNotice(fromTitle: string, toTitle: string) {
clearAutoAdvanceNotice()
autoAdvanceNotice.value = {
fromTitle,
toTitle,
reasonLabel:
attentionReasonFilter.value === 'all'
? '需关注'
: formatAttentionReasonTitle(attentionReasonFilter.value),
}
autoAdvanceNoticeTimer = window.setTimeout(() => {
autoAdvanceNotice.value = null
autoAdvanceNoticeTimer = null
}, 5000)
}
function showAttentionCompletionNotice(completedReason: AttentionReasonFilter) {
clearAttentionCompletionNotice()
attentionCompletionNotice.value = {
completedReason,
completedReasonLabel: formatAttentionReasonTitle(completedReason),
}
attentionCompletionNoticeTimer = window.setTimeout(() => {
attentionCompletionNotice.value = null
attentionCompletionNoticeTimer = null
}, 6000)
}
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 previousActiveSession = activeSession.value
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
const displayedSessions = resolveDisplayedSessions(sessions.value)
const hiddenRequestedSession = requestedSessionId.value
? sessions.value.find((item) => item.id === requestedSessionId.value) ?? null
: null
const hiddenCurrentSession = previousActiveSession
? sessions.value.find((item) => item.id === previousActiveSession.id) ?? null
: null
if (
(requestedSessionId.value || pendingFocusTarget.value)
&& requestedSessionId.value
&& !displayedSessions.some((item) => item.id === requestedSessionId.value)
&& !hiddenRequestedSession
) {
void syncVoiceStudioRouteState()
}
const preferredSession = (
requestedSessionId.value
? displayedSessions.find((item) => item.id === requestedSessionId.value)
: null
) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0] ?? hiddenRequestedSession ?? hiddenCurrentSession
const currentSessionStillAvailable = activeSession.value
? sessions.value.some((item) => item.id === activeSession.value?.id)
: false
const currentSessionStillVisible = activeSession.value
? displayedSessions.some((item) => item.id === activeSession.value?.id)
: false
if (
!activeSession.value
|| (!currentSessionStillVisible && preferredSession)
|| (
requestedSessionId.value
&& preferredSession?.id === requestedSessionId.value
&& activeSession.value.id !== requestedSessionId.value
)
) {
if (preferredSession) {
if (
sessionFilter.value === 'attention'
&& previousActiveSession
&& previousActiveSession.id !== preferredSession.id
&& !currentSessionStillVisible
&& !suppressAutoAdvanceNotice.value
) {
showAutoAdvanceNotice(
previousActiveSession.working_title || '未命名语音会话',
preferredSession.working_title || '未命名语音会话',
)
}
await loadSessionDetail(preferredSession.id)
} else if (sessionFilter.value !== 'recent') {
if (currentSessionStillAvailable) {
return
}
if (
sessionFilter.value === 'attention'
&& previousActiveSession
&& !currentSessionStillVisible
&& !suppressAutoAdvanceNotice.value
) {
showAttentionCompletionNotice(attentionReasonFilter.value)
}
activeSession.value = null
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : '会话列表加载失败'
} finally {
suppressAutoAdvanceNotice.value = false
loadingSessions.value = false
}
}
async function loadVoiceAnalytics() {
if (!userStore.user) return
try {
voiceAnalytics.value = await api.get<VoiceSessionAnalytics>(buildVoiceAnalyticsPath())
} 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) {
if (
!requestedSessionId.value
&& !route.query.filter
&& session.attention_reasons.length > 0
) {
const action = getVoiceSessionNextAction(session)
sessionFilter.value = 'attention'
attentionReasonFilter.value = action.reason ?? 'all'
pendingFocusTarget.value = action.focus ?? pendingFocusTarget.value
}
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}`)
await focusRequestedTarget(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(() => {
const sessionId = activeSession.value?.id
if (sessionId) {
void refreshVisibleSessionState(sessionId)
}
}, sessionPollIntervalMs)
}
async function refreshVisibleSessionState(sessionId: string) {
await loadSessionDetail(sessionId)
await loadSessions()
}
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
const sessionId = activeSession.value.id
finalizing.value = true
error.value = ''
try {
const result = await api.post<VoiceSessionFinalizeResponse>(
`/api/voice-sessions/${sessionId}/finalize`,
{
save_story: true,
generate_cover: true,
generate_final_audio: false,
},
)
await loadSessions()
if (isSessionVisibleInCurrentFilter(sessionId)) {
await loadSessionDetail(sessionId)
} else if (result.story_id) {
router.push(`/story/${result.story_id}`)
return
}
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
const sessionId = activeSession.value.id
abandoning.value = true
error.value = ''
try {
await api.post<VoiceSessionSummary>(
`/api/voice-sessions/${sessionId}/abandon`,
{ reason: '用户在语音共创页主动结束会话' },
)
await loadSessions()
if (isSessionVisibleInCurrentFilter(sessionId)) {
await loadSessionDetail(sessionId)
}
} 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}`)
}
async function openSessionQuickAction(session: VoiceSessionSummary) {
const action = getVoiceSessionNextAction(session)
if (action.storyId) {
router.push(`/story/${action.storyId}`)
return
}
pendingFocusTarget.value = action.focus ?? null
await loadSessionDetail(session.id)
}
function setAnalyticsWindow(value: '7' | '30' | 'all') {
analyticsWindow.value = value
}
function setAnalyticsProviderFilter(value: string | number) {
analyticsProviderFilter.value = String(value)
}
function setAnalyticsStatusFilter(value: string | number) {
analyticsStatusFilter.value = String(value)
}
function setSessionFilter(value: SessionFilter) {
suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
sessionFilter.value = value
if (value !== 'attention') {
attentionReasonFilter.value = 'all'
}
void syncVoiceStudioRouteState()
}
function jumpToTextTurnComposer(presetText = '') {
textTurnInput.value = presetText
clearRecordedAudio()
void nextTick(() => {
document.getElementById('voice-text-composer')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
function setAttentionReasonFilter(
value: 'all' | 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
) {
suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
attentionReasonFilter.value = value
void syncVoiceStudioRouteState()
}
function focusAttentionReason(
value: 'pending_confirmation' | 'safety_intervention' | 'failed_turn',
) {
setSessionFilter('attention')
attentionReasonFilter.value = value
void syncVoiceStudioRouteState()
}
function getSuggestedAttentionReasons(
completedReason: AttentionReasonFilter,
): Exclude<AttentionReasonFilter, 'all'>[] {
const orderedReasons: Exclude<AttentionReasonFilter, 'all'>[] = [
'pending_confirmation',
'safety_intervention',
'failed_turn',
]
return orderedReasons.filter((reason) => reason !== completedReason)
}
async function focusRequestedTarget(sessionId: string) {
const target = pendingFocusTarget.value
if (!target) return
if (requestedSessionId.value && requestedSessionId.value !== sessionId) return
await nextTick()
const targetId = (() => {
switch (target) {
case 'confirmation':
return latestPendingConfirmationTurn.value
? `voice-turn-${latestPendingConfirmationTurn.value.id}`
: 'voice-attention-confirmation-card'
case 'safety':
return latestSafetyTurn.value
? `voice-turn-${latestSafetyTurn.value.id}`
: 'voice-attention-safety-card'
case 'failed':
return latestFailedTurn.value
? `voice-turn-${latestFailedTurn.value.id}`
: 'voice-attention-failed-card'
case 'text':
return 'voice-text-composer'
default:
return null
}
})()
if (targetId) {
document.getElementById(targetId)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
}
pendingFocusTarget.value = null
void syncVoiceStudioRouteState()
}
watch(selectedProfileId, (newId) => {
if (newId) {
void fetchUniverses(newId)
} else {
universes.value = []
selectedUniverseId.value = ''
}
})
watch(analyticsWindow, () => {
void loadVoiceAnalytics()
})
watch([analyticsProviderFilter, analyticsStatusFilter], () => {
void loadVoiceAnalytics()
})
watch(sessionFilter, () => {
void loadSessions()
})
watch(attentionReasonFilter, () => {
if (sessionFilter.value === 'attention') {
void loadSessions()
}
})
watch(
() => route.query,
() => {
applyRouteState()
if (suppressRouteReload.value) {
suppressRouteReload.value = false
return
}
if (userStore.user) {
void loadSessions()
void loadVoiceAnalytics()
}
},
)
watch(
() => isSessionProcessing.value,
(processing) => {
if (processing) {
startSessionPolling()
} else {
stopSessionPolling()
}
},
{ immediate: true },
)
onMounted(async () => {
applyRouteState()
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()
clearAutoAdvanceNotice()
clearAttentionCompletionNotice()
})
</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">{{ filteredSessions.length }} </span>
</div>
<div class="mt-4 flex flex-wrap 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="setSessionFilter('active')"
>
活跃会话
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="sessionFilter === 'attention'
? 'border-amber-500 bg-amber-500 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setSessionFilter('attention')"
>
需关注
</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="setSessionFilter('recent')"
>
最近全部
</button>
</div>
<div
v-if="sessionFilter === 'attention'"
class="mt-3 flex flex-wrap gap-2"
>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="attentionReasonFilter === 'all'
? 'border-amber-600 bg-amber-50 text-amber-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAttentionReasonFilter('all')"
>
全部需关注
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="attentionReasonFilter === 'pending_confirmation'
? 'border-amber-600 bg-amber-50 text-amber-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAttentionReasonFilter('pending_confirmation')"
>
待确认
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="attentionReasonFilter === 'safety_intervention'
? 'border-rose-500 bg-rose-50 text-rose-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAttentionReasonFilter('safety_intervention')"
>
安全介入
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="attentionReasonFilter === 'failed_turn'
? 'border-slate-500 bg-slate-100 text-slate-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAttentionReasonFilter('failed_turn')"
>
失败待处理
</button>
</div>
<div v-if="loadingSessions" class="py-8">
<LoadingSpinner text="加载会话中..." />
</div>
<div v-else-if="filteredSessions.length === 0" class="pt-6">
<EmptyState
:icon="SparklesIcon"
:title="sessionFilter === 'attention'
? '当前没有待处理会话'
: sessionFilter === 'active'
? '当前没有活跃会话'
: '还没有语音共创会话'"
:description="sessionFilter === 'attention'
? formatAttentionEmptyDescription(attentionReasonFilter)
: sessionFilter === 'active'
? '最近的会话都已经完成或放弃了,可以新建一个继续共创。'
: '先创建一个会话,再通过文本或录音开始第一轮故事。'"
/>
</div>
<div v-else class="mt-4 space-y-3">
<div
v-for="session in filteredSessions"
:key="session.id"
class="w-full rounded-2xl border transition-all"
:class="activeSession?.id === session.id
? 'border-purple-300 bg-purple-50'
: 'border-gray-100 bg-white hover:border-gray-300'"
>
<button
type="button"
class="w-full px-4 pt-3 text-left"
@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-2 flex flex-wrap gap-2">
<span
v-for="reason in session.attention_reasons"
:key="`${session.id}-${reason}`"
class="rounded-full px-2.5 py-1 text-[11px] font-medium"
:class="formatAttentionReason(reason).className"
>
{{ formatAttentionReason(reason).label }}
</span>
<span
v-if="!session.attention_reasons.length && session.can_continue"
class="rounded-full bg-amber-100 px-2.5 py-1 text-[11px] font-medium text-amber-700"
>
可继续
</span>
<span
v-if="session.final_story_id"
class="rounded-full bg-emerald-100 px-2.5 py-1 text-[11px] font-medium text-emerald-700"
>
已保存
</span>
</div>
<div class="mt-1 text-xs text-gray-500">
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }}
</div>
<div class="mt-1 text-xs text-gray-400">
{{ getSessionInputModeSummary(session) }}
</div>
<div
class="mt-3 rounded-xl border px-3 py-2 text-xs leading-5"
:class="getVoiceSessionNextStep(session).toneClass"
>
<div class="font-medium">
下一步{{ getVoiceSessionNextStep(session).label }}
</div>
<div class="mt-1 opacity-90">
{{ getVoiceSessionNextStep(session).description }}
</div>
</div>
<div
v-if="session.latest_understanding_summary || session.latest_user_transcript || session.latest_safety_message"
class="mt-2 line-clamp-2 text-xs leading-5 text-gray-500"
>
{{ session.latest_understanding_summary || session.latest_safety_message || session.latest_user_transcript }}
</div>
</div>
<div class="text-right text-xs text-gray-400">
{{ formatDate(session.updated_at) }}
</div>
</div>
</button>
<div class="flex justify-end px-4 pb-3">
<BaseButton
size="sm"
variant="ghost"
@click="openSessionQuickAction(session)"
>
{{ getVoiceSessionNextAction(session).label }}
</BaseButton>
</div>
</div>
</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="autoAdvanceNotice"
class="border border-sky-100 bg-sky-50 text-sky-700"
>
<div class="flex items-start justify-between gap-4">
<div class="text-sm leading-6">
已处理上一条{{ autoAdvanceNotice.reasonLabel }}会话{{ autoAdvanceNotice.fromTitle }}
已自动切换到下一条{{ autoAdvanceNotice.toTitle }}
</div>
<button
type="button"
class="rounded-lg p-1 text-sky-500 transition-colors hover:bg-sky-100 hover:text-sky-700"
@click="clearAutoAdvanceNotice"
>
<XMarkIcon class="h-4 w-4" />
</button>
</div>
</BaseCard>
<BaseCard
v-if="attentionCompletionNotice"
class="border border-emerald-100 bg-emerald-50 text-emerald-800"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="text-sm leading-6">
{{ attentionCompletionNotice.completedReasonLabel }} 已处理完
你可以继续切到下一类 attention或先回到最近会话总览
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="reason in getSuggestedAttentionReasons(attentionCompletionNotice.completedReason)"
:key="`completion-${reason}`"
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="formatAttentionReason(reason).className"
@click="focusAttentionReason(reason)"
>
{{ formatAttentionReason(reason).label }}
</button>
<button
type="button"
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-600 transition-colors hover:border-gray-400"
@click="setSessionFilter('recent')"
>
回到最近全部
</button>
<button
type="button"
class="rounded-lg p-1 text-emerald-500 transition-colors hover:bg-emerald-100 hover:text-emerald-700"
@click="clearAttentionCompletionNotice"
>
<XMarkIcon class="h-4 w-4" />
</button>
</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">{{ analyticsWindowLabel }} 的会话质量概览</p>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '7'
? 'border-slate-900 bg-slate-900 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAnalyticsWindow('7')"
>
最近 7
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === '30'
? 'border-slate-900 bg-slate-900 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAnalyticsWindow('30')"
>
最近 30
</button>
<button
type="button"
class="rounded-lg border px-3 py-1.5 text-sm transition-colors"
:class="analyticsWindow === 'all'
? 'border-slate-900 bg-slate-900 text-white'
: 'border-gray-200 bg-white text-gray-600 hover:border-gray-400'"
@click="setAnalyticsWindow('all')"
>
全部
</button>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<BaseSelect
v-model="analyticsProviderFilter"
label="转写来源筛选"
:options="analyticsProviderOptions"
placeholder="全部来源"
@update:modelValue="setAnalyticsProviderFilter"
/>
<BaseSelect
v-model="analyticsStatusFilter"
label="会话状态筛选"
:options="analyticsStatusOptions"
placeholder="全部状态"
@update:modelValue="setAnalyticsStatusFilter"
/>
</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 class="mt-1 text-xs text-gray-400">确认率 {{ confirmationRequestRateLabel }}</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 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-sky-700">{{ userAudioTurnRateLabel }}</div>
<div class="mt-1 text-xs text-gray-400">上传 {{ voiceAnalytics.uploaded_audio_turns }} / 文本 {{ voiceAnalytics.text_fallback_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-purple-700">{{ assistantAudioReadyRateLabel }}</div>
<div class="mt-1 text-xs text-gray-400">{{ voiceAnalytics.assistant_audio_ready_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">ASR 成功率</div>
<div class="mt-1 text-lg font-semibold text-indigo-700">{{ asrSuccessRateLabel }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">TTS 成功率</div>
<div class="mt-1 text-lg font-semibold text-fuchsia-700">{{ ttsSuccessRateLabel }}</div>
</div>
</div>
<p class="mt-4 text-sm text-gray-500">
ASR 失败 {{ voiceAnalytics.asr_failures }} TTS 失败 {{ voiceAnalytics.tts_failures }}
当前共有 {{ voiceAnalytics.total_sessions }} 个会话其中 {{ voiceAnalytics.attention_sessions }} 个仍需处理
已完成 {{ voiceAnalytics.finalized_sessions }}
</p>
<p class="mt-2 text-sm text-gray-500">
平均用户语音 {{ avgUserAudioDurationLabel }}平均助手语音 {{ avgAssistantAudioDurationLabel }}转写来源{{ transcriptionProviderSummary }}
</p>
<p class="mt-2 text-sm text-gray-500">
平均置信度{{ avgConfidenceSummary }}安全介入率 {{ Math.round(voiceAnalytics.safety_intervention_rate * 100) }}%
</p>
<p
v-if="voiceAnalytics.attention_sessions"
class="mt-2 text-sm text-gray-500"
>
待确认 {{ voiceAnalytics.confirmation_attention_sessions }}
安全介入 {{ voiceAnalytics.safety_attention_sessions }}
失败待处理 {{ voiceAnalytics.failed_attention_sessions }}
</p>
<div
v-if="voiceAnalytics.attention_sessions"
class="mt-3 flex flex-wrap gap-2"
>
<button
v-if="voiceAnalytics.confirmation_attention_sessions"
type="button"
class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-sm text-amber-700 transition-colors hover:border-amber-400"
@click="focusAttentionReason('pending_confirmation')"
>
查看待确认
</button>
<button
v-if="voiceAnalytics.safety_attention_sessions"
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm text-rose-700 transition-colors hover:border-rose-400"
@click="focusAttentionReason('safety_intervention')"
>
查看安全介入
</button>
<button
v-if="voiceAnalytics.failed_attention_sessions"
type="button"
class="rounded-lg border border-slate-200 bg-slate-100 px-3 py-1.5 text-sm text-slate-700 transition-colors hover:border-slate-400"
@click="focusAttentionReason('failed_turn')"
>
查看失败待处理
</button>
</div>
</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="storyStateSummary"
class="rounded-2xl border border-slate-100 bg-slate-50/80 p-4"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="font-semibold text-gray-900">当前故事状态</h3>
<p class="mt-1 text-sm text-gray-500">
这块是把会话里的 `story_state` 翻成家长可读摘要方便确认故事现在讲到哪了
</p>
</div>
<span
class="rounded-full px-3 py-1 text-xs font-medium"
:class="activeSession.can_finalize
? 'bg-emerald-100 text-emerald-700'
: hasPendingConfirmation
? 'bg-amber-100 text-amber-700'
: activeSession.can_continue
? 'bg-sky-100 text-sky-700'
: 'bg-gray-100 text-gray-700'"
>
{{ activeSession.can_finalize
? '当前版本可保存'
: hasPendingConfirmation
? '需先确认这一轮'
: activeSession.can_continue
? '可以继续讲下一轮'
: '先查看当前状态' }}
</span>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl border border-white bg-white px-4 py-3">
<div class="text-xs text-gray-500">当前主题</div>
<div class="mt-1 text-sm font-medium text-gray-800">
{{ storyStateSummary.premise || '还在等待第一轮主题' }}
</div>
</div>
<div class="rounded-xl border border-white bg-white px-4 py-3">
<div class="text-xs text-gray-500">最新改写方向</div>
<div class="mt-1 text-sm font-medium text-gray-800">
{{ storyStateSummary.latestDirection || activeSession.latest_user_transcript || '暂未收到新的剧情修正' }}
</div>
</div>
<div class="rounded-xl border border-white bg-white px-4 py-3">
<div class="text-xs text-gray-500">已生成段数</div>
<div class="mt-1 text-sm font-medium text-gray-800">
{{ storyStateSummary.segmentCount }}
</div>
</div>
<div class="rounded-xl border border-white bg-white px-4 py-3">
<div class="text-xs text-gray-500">最近意图</div>
<div class="mt-1 text-sm font-medium text-gray-800">
{{ formatIntent(activeSession.latest_detected_intent) }}
</div>
<div class="mt-1 text-xs text-gray-500">
{{ storyStateSummary.coverPromptReady ? '封面提示已准备' : '封面提示会在后续补齐' }}
</div>
</div>
</div>
<div
v-if="storyStateSummary.latestSegment"
class="mt-4 rounded-xl border border-white bg-white px-4 py-3"
>
<div class="text-xs text-gray-500">最近一段故事</div>
<div class="mt-1 text-sm leading-6 text-gray-700">
{{ truncateNarrative(storyStateSummary.latestSegment, 140) }}
</div>
</div>
<div
v-if="storyActionSteps.length"
class="mt-4 rounded-xl border border-white bg-white px-4 py-4"
>
<div class="text-xs text-gray-500">当前可执行路径</div>
<div class="mt-3 space-y-3">
<div
v-for="(step, index) in storyActionSteps"
:key="step.key"
class="flex items-start gap-3"
>
<div class="mt-1 flex flex-col items-center">
<span
class="h-2.5 w-2.5 rounded-full"
:class="getStoryActionStepClass(step.status).dot"
></span>
<span
v-if="index < storyActionSteps.length - 1"
class="mt-1 h-8 w-px bg-gray-200"
></span>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-gray-800">{{ step.title }}</span>
<span
class="rounded-full border px-2.5 py-0.5 text-[11px] font-medium"
:class="getStoryActionStepClass(step.status).badge"
>
{{ step.status === 'done' ? '已完成' : step.status === 'current' ? '当前建议' : '后续步骤' }}
</span>
</div>
<div class="mt-1 text-sm leading-6 text-gray-600">
{{ step.description }}
</div>
<div
v-if="step.status === 'current' && step.contextValue"
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-3 py-3"
>
<div class="text-xs text-gray-500">
{{ step.contextLabel }}
</div>
<div class="mt-1 text-sm leading-6 text-slate-700">
{{ step.contextValue }}
</div>
</div>
<div
v-if="getStoryActionStepButtonLabel(step)"
class="mt-3"
>
<BaseButton
size="sm"
variant="secondary"
@click="handleStoryActionStep(step)"
:disabled="
sendingTurn
|| finalizing
|| (step.key === 'finalize' && !activeSession.can_finalize)
|| (step.key === 'review_result' && !activeSession.final_story_id)
"
>
{{ getStoryActionStepButtonLabel(step) }}
</BaseButton>
</div>
</div>
</div>
</div>
</div>
<div
v-if="activeSession.latest_understanding_summary"
class="mt-3 rounded-xl border border-dashed border-slate-200 bg-white px-4 py-3"
>
<div class="text-xs text-gray-500">当前系统理解</div>
<div class="mt-1 text-sm leading-6 text-slate-700">
{{ activeSession.latest_understanding_summary }}
</div>
</div>
<div
v-if="storyStateSummary.safetyFlags.length"
class="mt-3 rounded-xl border border-rose-100 bg-rose-50 px-4 py-3 text-rose-700"
>
<div class="text-xs font-medium">当前故事仍带有安全标记</div>
<div class="mt-1 text-sm">
{{ storyStateSummary.safetyFlags.join(' / ') }}
</div>
</div>
</div>
<div
v-if="activeSession.latest_requires_confirmation && latestPendingConfirmationTurn"
id="voice-attention-confirmation-card"
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 class="mt-3 flex flex-wrap gap-2">
<BaseButton
size="sm"
variant="secondary"
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'accept')"
:disabled="sendingTurn"
>
按这个理解继续
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'retry_recording')"
:disabled="sendingTurn"
>
重说一遍
</BaseButton>
<BaseButton
size="sm"
variant="ghost"
@click="resolveTurnConfirmation(latestPendingConfirmationTurn, 'switch_to_text')"
:disabled="sendingTurn"
>
改成文本输入
</BaseButton>
</div>
</div>
<div
v-if="activeSession.latest_safety_message"
id="voice-attention-safety-card"
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 class="mt-3 flex flex-wrap gap-2">
<BaseButton
size="sm"
variant="secondary"
@click="jumpToTextTurnComposer('')"
:disabled="sendingTurn || !activeSession.can_continue"
>
用文本继续改写
</BaseButton>
<span
v-if="latestSafetyTurn?.user_transcript"
class="text-xs text-rose-600"
>
建议换一种更温和的表达再继续
</span>
</div>
</div>
<div
v-if="hasFailedAttention"
id="voice-attention-failed-card"
class="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-slate-700"
>
<div class="text-sm font-semibold">有一轮失败待处理</div>
<p class="mt-2 text-sm">
<template v-if="latestFailedTurn">
{{ latestFailedTurn.turn_index }} 轮没有成功完成
{{ latestFailedTurn.error_message || activeSession.last_error || '建议优先补处理后再继续' }}
</template>
<template v-else>
{{ activeSession.last_error || '最近一轮失败待处理建议优先补处理后再继续' }}
</template>
</p>
<div class="mt-3 flex flex-wrap gap-2">
<BaseButton
v-if="latestFailedTurn"
size="sm"
variant="secondary"
@click="retryFailedTurn(latestFailedTurn.id)"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
>
重试本轮
</BaseButton>
<BaseButton
v-if="latestFailedTurn"
size="sm"
variant="ghost"
@click="jumpToTextTurnComposer(latestFailedTurn.user_transcript || '')"
:disabled="sendingTurn || !activeSession.can_continue || hasPendingConfirmation"
>
改成文本重发
</BaseButton>
</div>
</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>
<p class="mt-2 text-sm text-emerald-700">
保存后的封面补全与后续资源任务会继续挂到正式故事的生成轨迹里
</p>
<div class="mt-3">
<BaseButton size="sm" variant="secondary" @click="viewFinalStory">
<BookOpenIcon class="h-4 w-4" />
查看正式故事
</BaseButton>
</div>
</div>
<div id="voice-text-composer" 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"
:id="`voice-turn-${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>
<span v-if="turn.user_audio_duration_ms">· 用户语音 {{ formatDurationMs(turn.user_audio_duration_ms) }}</span>
<span v-if="turn.assistant_audio_duration_ms">· 助手语音 {{ formatDurationMs(turn.assistant_audio_duration_ms) }}</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">
<GenerationTrace
v-if="finalStoryHasAssetWork"
:story-id="finalStoryId"
title="正式故事资产轨迹"
description="语音共创保存后触发的封面补全、资源生成与后续恢复任务,会继续记录在这里。"
/>
<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>