feat: improve voice studio alpha recovery flow

This commit is contained in:
2026-04-19 23:25:41 +08:00
parent 46d6201529
commit 4ecf0c09c0
9 changed files with 657 additions and 14 deletions

View File

@@ -45,6 +45,7 @@ export interface VoiceSessionSummary {
latest_detected_intent: string | null
latest_assistant_audio_ready: boolean
last_turn_status: string | null
transcription_mode_hint: string | null
can_continue: boolean
can_finalize: boolean
last_error: string | null

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '../stores/user'
import { api } from '../api/client'
import type { VoiceSessionSummary } from '../types/voiceSession'
import BaseButton from '../components/ui/BaseButton.vue'
import LoginDialog from '../components/ui/LoginDialog.vue'
import {
@@ -25,6 +27,7 @@ function switchLocale(lang: 'en' | 'zh') {
// ========== 登录对话框状态 ==========
const showLoginDialog = ref(false)
const activeVoiceSession = ref<VoiceSessionSummary | null>(null)
// ========== 创作入口 ==========
// 旧的创作变量已移除,现在只负责跳转
@@ -45,6 +48,26 @@ function openVoiceStudio() {
router.push('/voice-studio')
}
function continueVoiceStudio() {
if (!activeVoiceSession.value) {
openVoiceStudio()
return
}
router.push('/voice-studio')
}
async function loadActiveVoiceSession() {
if (!userStore.user) {
activeVoiceSession.value = null
return
}
try {
activeVoiceSession.value = await api.get<VoiceSessionSummary | null>('/api/voice-sessions/active')
} catch {
activeVoiceSession.value = null
}
}
function scrollToFeatures() {
document.getElementById('features')?.scrollIntoView({ behavior: 'smooth' })
}
@@ -54,6 +77,20 @@ function scrollToFeatures() {
// const familiesCount = 5000
// const satisfactionCount = 99
onMounted(async () => {
if (!userStore.user) {
await userStore.fetchSession()
}
await loadActiveVoiceSession()
})
watch(
() => userStore.user?.id,
() => {
void loadActiveVoiceSession()
},
)
</script>
<template>
@@ -152,6 +189,15 @@ function scrollToFeatures() {
<MicrophoneIcon class="h-5 w-5 mr-2" />
进入语音共创
</BaseButton>
<BaseButton
v-if="activeVoiceSession"
size="lg"
variant="ghost"
@click="continueVoiceStudio"
>
<ArrowRightOnRectangleIcon 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>

View File

@@ -8,6 +8,7 @@ import BaseCard from '../components/ui/BaseCard.vue'
import EmptyState from '../components/ui/EmptyState.vue'
import LoadingSpinner from '../components/ui/LoadingSpinner.vue'
import type { GenerationOpsSummary, GenerationProviderAnalytics } from '../types/generation'
import type { VoiceSessionSummary } from '../types/voiceSession'
import {
getAssetStatusMeta,
getGenerationStatusMeta,
@@ -40,6 +41,7 @@ const router = useRouter()
const stories = ref<StoryItem[]>([])
const providerAnalytics = ref<GenerationProviderAnalytics | null>(null)
const opsSummary = ref<GenerationOpsSummary | null>(null)
const activeVoiceSession = ref<VoiceSessionSummary | null>(null)
const loading = ref(true)
const error = ref('')
const showCreateModal = ref(false)
@@ -76,14 +78,16 @@ function buildProviderAnalyticsPath() {
async function fetchStories() {
try {
const [storyList, analytics, ops] = await Promise.all([
const [storyList, analytics, ops, activeSession] = await Promise.all([
api.get<StoryItem[]>('/api/stories'),
api.get<GenerationProviderAnalytics>(buildProviderAnalyticsPath()),
api.get<GenerationOpsSummary>('/api/generations/ops-summary'),
api.get<VoiceSessionSummary | null>('/api/voice-sessions/active').catch(() => null),
])
stories.value = storyList
providerAnalytics.value = analytics
opsSummary.value = ops
activeVoiceSession.value = activeSession
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
@@ -112,6 +116,10 @@ function goToCreate() {
showCreateModal.value = true
}
function goToVoiceStudio() {
router.push('/voice-studio')
}
function getStoryLink(story: StoryItem) {
return story.mode === 'storybook' ? `/storybook/view/${story.id}` : `/story/${story.id}`
}
@@ -200,6 +208,27 @@ watch([selectedWindow, selectedCapability], () => {
</div>
<template v-else>
<BaseCard
v-if="activeVoiceSession"
class="mb-8 border border-purple-100 bg-purple-50/60"
padding="lg"
>
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 class="text-xl font-bold text-gray-800">语音共创正在进行中</h2>
<p class="mt-2 text-sm leading-6 text-gray-600">
最近的语音共创会话仍可继续
{{ activeVoiceSession.working_title || '未命名语音会话' }}
当前状态 {{ activeVoiceSession.status }}已完成 {{ activeVoiceSession.total_turns }}
</p>
</div>
<BaseButton @click="goToVoiceStudio">
<SparklesIcon class="h-5 w-5 mr-2" />
继续语音共创
</BaseButton>
</div>
</BaseCard>
<BaseCard class="mb-8" padding="lg">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="text-center px-4 py-2">

View File

@@ -47,6 +47,7 @@ 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)
@@ -60,12 +61,14 @@ 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)
@@ -78,6 +81,22 @@ const universeOptions = computed(() =>
)
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
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) {
@@ -182,8 +201,13 @@ 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 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)
@@ -196,6 +220,18 @@ async function loadSessions() {
}
}
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 = ''
@@ -208,6 +244,23 @@ async function loadSessionDetail(sessionId: string) {
}
}
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 = ''
@@ -217,6 +270,7 @@ async function createSession() {
universe_id: selectedUniverseId.value || null,
target_mode: 'story',
})
sessionFilter.value = 'active'
await loadSessions()
await loadSessionDetail(session.id)
textTurnInput.value = ''
@@ -319,6 +373,39 @@ async function finalizeSession() {
}
}
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()
} catch (err) {
error.value = err instanceof Error ? err.message : '补发语音失败'
} finally {
sendingTurn.value = false
}
}
async function abandonSession() {
if (!activeSession.value) return
abandoning.value = true
@@ -404,16 +491,34 @@ watch(selectedProfileId, (newId) => {
}
})
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()
})
onBeforeUnmount(() => {
stopSessionPolling()
if (recording.value) {
stopRecording()
}
@@ -488,6 +593,29 @@ onBeforeUnmount(() => {
<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>
@@ -558,6 +686,13 @@ onBeforeUnmount(() => {
</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"
@@ -614,7 +749,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-gray-400">已支持上传音频 turn</span>
</div>
<p class="mt-2 text-sm text-gray-500">
当前默认是 demo 转写模式若本地未接真实 ASR可在下方填写转写提示辅助开发验证
{{ transcriptionModeDescription }}
</p>
<div class="mt-4 flex flex-wrap gap-3">
@@ -714,6 +849,28 @@ onBeforeUnmount(() => {
<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"
>
<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>