feat: add voice studio prototype flow
This commit is contained in:
@@ -7,7 +7,8 @@ import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import LoginDialog from '../components/ui/LoginDialog.vue'
|
||||
import {
|
||||
SparklesIcon,
|
||||
ArrowRightOnRectangleIcon
|
||||
ArrowRightOnRectangleIcon,
|
||||
MicrophoneIcon,
|
||||
} from '@heroicons/vue/24/outline'
|
||||
|
||||
const { locale } = useI18n()
|
||||
@@ -36,6 +37,14 @@ function openCreateModal() {
|
||||
router.push({ path: '/my-stories', query: { openCreate: 'true' } })
|
||||
}
|
||||
|
||||
function openVoiceStudio() {
|
||||
if (!userStore.user) {
|
||||
showLoginDialog.value = true
|
||||
return
|
||||
}
|
||||
router.push('/voice-studio')
|
||||
}
|
||||
|
||||
function scrollToFeatures() {
|
||||
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
@@ -139,6 +148,10 @@ function scrollToFeatures() {
|
||||
<SparklesIcon class="h-5 w-5 mr-2" />
|
||||
开始创作故事
|
||||
</BaseButton>
|
||||
<BaseButton size="lg" variant="secondary" @click="openVoiceStudio">
|
||||
<MicrophoneIcon class="h-5 w-5 mr-2" />
|
||||
进入语音共创
|
||||
</BaseButton>
|
||||
<button @click="scrollToFeatures" class="px-6 py-3 rounded-xl font-semibold text-stone-600 bg-white border border-stone-200 hover:border-amber-400 hover:text-amber-700 transition-all shadow-sm">
|
||||
了解更多功能
|
||||
</button>
|
||||
|
||||
771
frontend/src/views/VoiceStudio.vue
Normal file
771
frontend/src/views/VoiceStudio.vue
Normal file
@@ -0,0 +1,771 @@
|
||||
<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 {
|
||||
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 profiles = ref<ChildProfile[]>([])
|
||||
const universes = ref<StoryUniverse[]>([])
|
||||
const selectedProfileId = ref('')
|
||||
const selectedUniverseId = 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)
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null
|
||||
let mediaStream: MediaStream | null = null
|
||||
let recordingChunks: Blob[] = []
|
||||
let recordingTimer: number | null = null
|
||||
let recordingStartedAt = 0
|
||||
|
||||
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 ?? [])
|
||||
|
||||
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 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 {
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>('/api/voice-sessions?limit=8')
|
||||
if (!activeSession.value) {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
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 {
|
||||
const result = 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)
|
||||
if (result.story_id) {
|
||||
router.push(`/story/${result.story_id}`)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '保存语音共创故事失败'
|
||||
} finally {
|
||||
finalizing.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()
|
||||
}
|
||||
|
||||
watch(selectedProfileId, (newId) => {
|
||||
if (newId) {
|
||||
void fetchUniverses(newId)
|
||||
} else {
|
||||
universes.value = []
|
||||
selectedUniverseId.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!userStore.user) {
|
||||
await userStore.fetchSession()
|
||||
}
|
||||
if (!userStore.user) return
|
||||
await fetchProfiles()
|
||||
await loadSessions()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
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 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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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 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"
|
||||
/>
|
||||
<BaseButton
|
||||
@click="submitTextTurn"
|
||||
:loading="sendingTurn"
|
||||
:disabled="!activeSession.can_continue || !textTurnInput.trim()"
|
||||
>
|
||||
<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">
|
||||
当前默认是 demo 转写模式。若本地未接真实 ASR,可在下方填写转写提示辅助开发验证。
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<BaseButton
|
||||
v-if="!recording"
|
||||
variant="secondary"
|
||||
@click="startRecording"
|
||||
:disabled="sendingTurn || !activeSession.can_continue"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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.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 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>
|
||||
Reference in New Issue
Block a user