feat: improve voice studio alpha recovery flow
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user