feat: move unified generation to background worker
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useStorybookStore } from '../stores/storybook'
|
||||
import { api } from '../api/client'
|
||||
import type { Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { api } from '../api/client'
|
||||
import BaseButton from './ui/BaseButton.vue'
|
||||
import BaseInput from './ui/BaseInput.vue'
|
||||
import BaseSelect from './ui/BaseSelect.vue'
|
||||
@@ -34,10 +33,9 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const storybookStore = useStorybookStore()
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// State
|
||||
const inputType = ref<'keywords' | 'full_story'>('keywords')
|
||||
@@ -88,28 +86,63 @@ const generationTitle = computed(() =>
|
||||
const generationSteps = computed(() => {
|
||||
if (requestedOutputMode.value === 'storybook') {
|
||||
return [
|
||||
'正在整理主题和成长目标...',
|
||||
'生成绘本分镜和每页文字...',
|
||||
'保存绘本主记录,确保刷新也能找回...',
|
||||
'补全封面和分页插图...',
|
||||
'马上进入可翻页阅读模式。',
|
||||
'正在提交后台任务...',
|
||||
'Worker 会生成绘本分镜和每页文字...',
|
||||
'主记录一落库就能通过 ID 找回...',
|
||||
'插图会继续在后台补全...',
|
||||
'稍后自动进入可翻页阅读模式。',
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
'正在整理孩子档案和故事主题...',
|
||||
'生成可先阅读的故事正文...',
|
||||
'保存故事主记录,避免结果丢失...',
|
||||
'补全封面图,失败也可稍后重试...',
|
||||
'正在提交后台任务...',
|
||||
'Worker 会生成故事正文并保存主记录...',
|
||||
'主内容一可读就会自动跳转详情页...',
|
||||
'封面会继续在后台补全,失败也能重试...',
|
||||
'马上进入故事详情页。',
|
||||
]
|
||||
})
|
||||
|
||||
interface GenerationAcceptedResponse {
|
||||
id: number | null
|
||||
generation_job_id: string | null
|
||||
}
|
||||
|
||||
interface GenerationJobDetail {
|
||||
story_id: number | null
|
||||
is_terminal: boolean
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
const JOB_POLL_INTERVAL_MS = 1500
|
||||
const JOB_POLL_MAX_ATTEMPTS = 80
|
||||
|
||||
// Methods
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
error.value = ''
|
||||
}
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
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.story_id) {
|
||||
return detail.story_id
|
||||
}
|
||||
if (detail.is_terminal) {
|
||||
throw new Error(detail.error_message || '生成失败,请稍后重试')
|
||||
}
|
||||
await sleep(JOB_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
throw new Error('任务已提交,但主内容落库超时,请稍后到故事库查看最新结果')
|
||||
}
|
||||
|
||||
async function fetchProfiles() {
|
||||
if (!userStore.user) return
|
||||
@@ -173,25 +206,22 @@ async function generateStory() {
|
||||
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
|
||||
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
|
||||
|
||||
if (requestedOutputMode.value === 'storybook') {
|
||||
const response = await api.post<any>('/api/generations', payload)
|
||||
const accepted = await api.post<GenerationAcceptedResponse>('/api/generations', payload)
|
||||
const jobId = accepted.generation_job_id
|
||||
if (!jobId) {
|
||||
throw new Error('生成任务已创建,但缺少任务编号')
|
||||
}
|
||||
|
||||
storybookStore.setStorybook(response)
|
||||
close()
|
||||
const storybookPath = response.id ? `/storybook/view/${response.id}` : '/storybook/view'
|
||||
router.push(storybookPath)
|
||||
const storyId = accepted.id ?? await waitForStoryId(jobId)
|
||||
close()
|
||||
if (requestedOutputMode.value === 'storybook') {
|
||||
router.push(`/storybook/view/${storyId}`)
|
||||
} else {
|
||||
const result = await api.post<any>('/api/generations', payload)
|
||||
const query: Record<string, string> = {}
|
||||
if (result.errors && Object.keys(result.errors).length > 0) {
|
||||
if (result.errors.image) query.imageError = '1'
|
||||
}
|
||||
close()
|
||||
router.push({ path: `/story/${result.id}`, query })
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成失败'
|
||||
} finally {
|
||||
router.push(`/story/${storyId}`)
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : '生成失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ function statusLabel(status?: string) {
|
||||
function eventLabel(eventType: string) {
|
||||
const labels: Record<string, string> = {
|
||||
request_accepted: '请求接收',
|
||||
worker_started: '后台任务开始',
|
||||
context_prepared: '上下文准备',
|
||||
narrative_generated: '正文生成',
|
||||
story_saved: '故事保存',
|
||||
|
||||
@@ -67,6 +67,7 @@ const audioDuration = ref(0)
|
||||
const error = ref('')
|
||||
const showDeleteConfirm = ref(false)
|
||||
const generationTraceRef = ref<InstanceType<typeof GenerationTrace> | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? [])
|
||||
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
|
||||
@@ -75,6 +76,7 @@ const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status))
|
||||
const canRetryImage = computed(() => story.value?.retryable_assets.includes('image') ?? false)
|
||||
const canRetryAudio = computed(() => story.value?.retryable_assets.includes('audio') ?? false)
|
||||
const isAudioGenerating = computed(() => story.value?.audio_status === 'generating')
|
||||
const shouldAutoRefreshStory = computed(() => story.value?.generation_status === 'assets_generating')
|
||||
const audioCacheLabel = computed(() => {
|
||||
if (!audioCacheStatus.value?.cache_exists) return '暂无缓存'
|
||||
const size = audioCacheStatus.value.cache_size_bytes ?? 0
|
||||
@@ -109,6 +111,13 @@ async function refreshStorySnapshot() {
|
||||
await refreshAudioStatus().catch(() => undefined)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStory() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -283,7 +292,19 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(shouldAutoRefreshStory, (enabled) => {
|
||||
stopAutoRefresh()
|
||||
if (enabled) {
|
||||
refreshTimer = setInterval(() => {
|
||||
if (!loading.value && !imageLoading.value && !audioLoading.value) {
|
||||
void refreshStorySnapshot().catch(() => undefined)
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user