fix: stabilize auth and generation workflows
This commit is contained in:
@@ -17,10 +17,19 @@ class ApiClient {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: '请求失败' }))
|
||||
throw new Error(error.detail || '请求失败')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 204 || response.status === 205) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
get<T>(url: string): Promise<T> {
|
||||
return this.request<T>(url)
|
||||
|
||||
@@ -145,12 +145,12 @@ function sleep(ms: number) {
|
||||
async function waitForStoryId(jobId: string) {
|
||||
for (let attempt = 0; attempt < JOB_POLL_MAX_ATTEMPTS; attempt += 1) {
|
||||
const detail = await api.get<GenerationJobDetail>(`/api/generations/jobs/${jobId}`)
|
||||
if (detail.status === 'canceled' || detail.current_step === 'generation_canceled') {
|
||||
return null
|
||||
}
|
||||
if (detail.story_id) {
|
||||
return detail.story_id
|
||||
}
|
||||
if (detail.status === 'canceled' || detail.current_step === 'generation_canceled') {
|
||||
return null
|
||||
}
|
||||
if (detail.is_terminal) {
|
||||
throw new Error(detail.error_message || '生成失败,请稍后重试')
|
||||
}
|
||||
|
||||
@@ -37,11 +37,7 @@ const latestJob = computed(() => jobHistory.value[0] ?? null)
|
||||
const activeJobEvents = computed(() => activeJob.value?.events.slice(-10) ?? [])
|
||||
const activeProgress = computed(() => activeJob.value?.progress_percent ?? latestJob.value?.progress_percent ?? 0)
|
||||
const activeProgressLabel = computed(() => activeJob.value?.progress_label ?? latestJob.value?.progress_label ?? '暂无进度')
|
||||
const shouldAutoRefresh = computed(() => {
|
||||
if (activeJob.value) return !activeJob.value.is_terminal
|
||||
if (latestJob.value) return !latestJob.value.is_terminal
|
||||
return false
|
||||
})
|
||||
const shouldAutoRefresh = computed(() => Boolean(latestJob.value && !latestJob.value.is_terminal))
|
||||
const providerSuccessRate = computed(() => {
|
||||
if (!providerStats.value?.total_calls) return null
|
||||
return Math.round((providerStats.value.successful_calls / providerStats.value.total_calls) * 100)
|
||||
@@ -186,6 +182,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
const selectedJobId = activeJob.value?.id ?? null
|
||||
|
||||
try {
|
||||
const [jobs, stats] = await Promise.all([
|
||||
@@ -194,7 +191,11 @@ async function refresh() {
|
||||
])
|
||||
jobHistory.value = jobs
|
||||
providerStats.value = stats
|
||||
const nextJobId = jobHistory.value[0]?.id
|
||||
const nextJobId = (
|
||||
selectedJobId
|
||||
? jobHistory.value.find((job) => job.id === selectedJobId)?.id
|
||||
: null
|
||||
) ?? jobHistory.value[0]?.id
|
||||
if (nextJobId) {
|
||||
await selectGenerationJob(nextJobId)
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
|
||||
<script setup lang="ts">
|
||||
import { XMarkIcon, CommandLineIcon } from '@heroicons/vue/24/outline'
|
||||
import { buildAuthSigninUrl } from '../../utils/auth'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -13,18 +14,18 @@ function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function loginWithGithub() {
|
||||
window.location.href = '/auth/github/signin'
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = '/auth/google/signin'
|
||||
}
|
||||
|
||||
function loginWithDev() {
|
||||
window.location.href = '/auth/dev/signin'
|
||||
}
|
||||
</script>
|
||||
function loginWithGithub() {
|
||||
window.location.href = buildAuthSigninUrl('github')
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = buildAuthSigninUrl('google')
|
||||
}
|
||||
|
||||
function loginWithDev() {
|
||||
window.location.href = buildAuthSigninUrl('dev')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '../api/client'
|
||||
import { buildAuthSigninUrl } from '../utils/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
@@ -25,13 +26,13 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function loginWithGithub() {
|
||||
window.location.href = '/auth/github/signin'
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = '/auth/google/signin'
|
||||
}
|
||||
function loginWithGithub() {
|
||||
window.location.href = buildAuthSigninUrl('github')
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
window.location.href = buildAuthSigninUrl('google')
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.post('/auth/signout')
|
||||
|
||||
8
frontend/src/utils/auth.ts
Normal file
8
frontend/src/utils/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
type AuthProvider = 'github' | 'google' | 'dev'
|
||||
|
||||
const DEFAULT_POST_LOGIN_PATH = '/my-stories'
|
||||
|
||||
export function buildAuthSigninUrl(provider: AuthProvider): string {
|
||||
const next = new URL(DEFAULT_POST_LOGIN_PATH, window.location.origin).toString()
|
||||
return `/auth/${provider}/signin?next=${encodeURIComponent(next)}`
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { api } from '../api/client'
|
||||
import type { VoiceSessionAnalytics, VoiceSessionSummary } from '../types/voiceSession'
|
||||
import { getVoiceSessionNextAction } from '../utils/voiceSession'
|
||||
import BaseButton from '../components/ui/BaseButton.vue'
|
||||
import LoginDialog from '../components/ui/LoginDialog.vue'
|
||||
import {
|
||||
@@ -30,6 +31,9 @@ const showLoginDialog = ref(false)
|
||||
const activeVoiceSession = ref<VoiceSessionSummary | null>(null)
|
||||
const voiceAnalytics = ref<VoiceSessionAnalytics | null>(null)
|
||||
|
||||
type VoiceAttentionReason = 'pending_confirmation' | 'safety_intervention' | 'failed_turn'
|
||||
type VoiceStudioFocusTarget = 'confirmation' | 'safety' | 'failed' | 'text'
|
||||
|
||||
// ========== 创作入口 ==========
|
||||
// 旧的创作变量已移除,现在只负责跳转
|
||||
function openCreateModal() {
|
||||
@@ -54,7 +58,23 @@ function continueVoiceStudio() {
|
||||
openVoiceStudio()
|
||||
return
|
||||
}
|
||||
router.push('/voice-studio')
|
||||
const action = getVoiceSessionNextAction(activeVoiceSession.value)
|
||||
if (action.storyId) {
|
||||
router.push(`/story/${action.storyId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {
|
||||
session: activeVoiceSession.value.id,
|
||||
}
|
||||
if (action.reason) {
|
||||
query.filter = 'attention'
|
||||
query.reason = action.reason as VoiceAttentionReason
|
||||
}
|
||||
if (action.focus) {
|
||||
query.focus = action.focus as VoiceStudioFocusTarget
|
||||
}
|
||||
router.push({ path: '/voice-studio', query })
|
||||
}
|
||||
|
||||
async function loadActiveVoiceSession() {
|
||||
|
||||
@@ -537,6 +537,10 @@ function resolveDisplayedSessions(sourceSessions: VoiceSessionSummary[]) {
|
||||
return sortDisplayedSessions(visibleSessions)
|
||||
}
|
||||
|
||||
function isSessionVisibleInCurrentFilter(sessionId: string) {
|
||||
return resolveDisplayedSessions(sessions.value).some((session) => session.id === sessionId)
|
||||
}
|
||||
|
||||
function parseSessionFilter(value: unknown): SessionFilter | null {
|
||||
if (value === 'active' || value === 'attention' || value === 'recent') {
|
||||
return value
|
||||
@@ -743,10 +747,17 @@ async function loadSessions() {
|
||||
const previousActiveSession = activeSession.value
|
||||
sessions.value = await api.get<VoiceSessionSummary[]>(buildVoiceSessionListPath())
|
||||
const displayedSessions = resolveDisplayedSessions(sessions.value)
|
||||
const hiddenRequestedSession = requestedSessionId.value
|
||||
? sessions.value.find((item) => item.id === requestedSessionId.value) ?? null
|
||||
: null
|
||||
const hiddenCurrentSession = previousActiveSession
|
||||
? sessions.value.find((item) => item.id === previousActiveSession.id) ?? null
|
||||
: null
|
||||
if (
|
||||
(requestedSessionId.value || pendingFocusTarget.value)
|
||||
&& requestedSessionId.value
|
||||
&& !displayedSessions.some((item) => item.id === requestedSessionId.value)
|
||||
&& !hiddenRequestedSession
|
||||
) {
|
||||
void syncVoiceStudioRouteState()
|
||||
}
|
||||
@@ -754,7 +765,10 @@ async function loadSessions() {
|
||||
requestedSessionId.value
|
||||
? displayedSessions.find((item) => item.id === requestedSessionId.value)
|
||||
: null
|
||||
) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0]
|
||||
) ?? displayedSessions.find((item) => item.can_continue) ?? displayedSessions[0] ?? hiddenRequestedSession ?? hiddenCurrentSession
|
||||
const currentSessionStillAvailable = activeSession.value
|
||||
? sessions.value.some((item) => item.id === activeSession.value?.id)
|
||||
: false
|
||||
const currentSessionStillVisible = activeSession.value
|
||||
? displayedSessions.some((item) => item.id === activeSession.value?.id)
|
||||
: false
|
||||
@@ -782,6 +796,9 @@ async function loadSessions() {
|
||||
}
|
||||
await loadSessionDetail(preferredSession.id)
|
||||
} else if (sessionFilter.value !== 'recent') {
|
||||
if (currentSessionStillAvailable) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
sessionFilter.value === 'attention'
|
||||
&& previousActiveSession
|
||||
@@ -815,6 +832,16 @@ async function loadLatestActiveSession() {
|
||||
try {
|
||||
const session = await api.get<VoiceSessionSummary | null>('/api/voice-sessions/active')
|
||||
if (session) {
|
||||
if (
|
||||
!requestedSessionId.value
|
||||
&& !route.query.filter
|
||||
&& session.attention_reasons.length > 0
|
||||
) {
|
||||
const action = getVoiceSessionNextAction(session)
|
||||
sessionFilter.value = 'attention'
|
||||
attentionReasonFilter.value = action.reason ?? 'all'
|
||||
pendingFocusTarget.value = action.focus ?? pendingFocusTarget.value
|
||||
}
|
||||
await loadSessionDetail(session.id)
|
||||
}
|
||||
} catch {
|
||||
@@ -845,13 +872,18 @@ function stopSessionPolling() {
|
||||
function startSessionPolling() {
|
||||
if (!activeSession.value?.id || sessionPollTimer) return
|
||||
sessionPollTimer = window.setInterval(() => {
|
||||
if (activeSession.value?.id) {
|
||||
void loadSessionDetail(activeSession.value.id)
|
||||
void loadSessions()
|
||||
const sessionId = activeSession.value?.id
|
||||
if (sessionId) {
|
||||
void refreshVisibleSessionState(sessionId)
|
||||
}
|
||||
}, sessionPollIntervalMs)
|
||||
}
|
||||
|
||||
async function refreshVisibleSessionState(sessionId: string) {
|
||||
await loadSessionDetail(sessionId)
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
creatingSession.value = true
|
||||
error.value = ''
|
||||
@@ -942,11 +974,12 @@ async function submitRecordedTurn() {
|
||||
|
||||
async function finalizeSession() {
|
||||
if (!activeSession.value) return
|
||||
const sessionId = activeSession.value.id
|
||||
finalizing.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.post<VoiceSessionFinalizeResponse>(
|
||||
`/api/voice-sessions/${activeSession.value.id}/finalize`,
|
||||
const result = await api.post<VoiceSessionFinalizeResponse>(
|
||||
`/api/voice-sessions/${sessionId}/finalize`,
|
||||
{
|
||||
save_story: true,
|
||||
generate_cover: true,
|
||||
@@ -954,7 +987,12 @@ async function finalizeSession() {
|
||||
},
|
||||
)
|
||||
await loadSessions()
|
||||
await loadSessionDetail(activeSession.value.id)
|
||||
if (isSessionVisibleInCurrentFilter(sessionId)) {
|
||||
await loadSessionDetail(sessionId)
|
||||
} else if (result.story_id) {
|
||||
router.push(`/story/${result.story_id}`)
|
||||
return
|
||||
}
|
||||
await loadVoiceAnalytics()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '保存语音共创故事失败'
|
||||
@@ -1026,17 +1064,17 @@ async function resolveTurnConfirmation(turn: VoiceTurnSummary, action: 'accept'
|
||||
|
||||
async function abandonSession() {
|
||||
if (!activeSession.value) return
|
||||
const sessionId = activeSession.value.id
|
||||
abandoning.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const summary = await api.post<VoiceSessionSummary>(
|
||||
`/api/voice-sessions/${activeSession.value.id}/abandon`,
|
||||
await api.post<VoiceSessionSummary>(
|
||||
`/api/voice-sessions/${sessionId}/abandon`,
|
||||
{ reason: '用户在语音共创页主动结束会话' },
|
||||
)
|
||||
await loadSessions()
|
||||
activeSession.value = {
|
||||
...(activeSession.value as VoiceSessionDetail),
|
||||
...summary,
|
||||
if (isSessionVisibleInCurrentFilter(sessionId)) {
|
||||
await loadSessionDetail(sessionId)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '放弃会话失败'
|
||||
|
||||
Reference in New Issue
Block a user