feat: move unified generation to background worker

This commit is contained in:
2026-04-19 17:29:37 +08:00
parent 5318de670f
commit 6fb128955f
15 changed files with 632 additions and 285 deletions

View File

@@ -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
}
}

View File

@@ -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: '故事保存',