1130 lines
42 KiB
Vue
1130 lines
42 KiB
Vue
<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>
|