feat: improve voice studio alpha recovery flow
This commit is contained in:
@@ -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