diff --git a/README.md b/README.md index 259644c..391f4fe 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ npm run build | POST | `/api/generations` | 统一生成故事或绘本 | | GET | `/api/generations/{story_id}` | 统一读取生成结果 | | POST | `/api/generations/{story_id}/retry-assets` | 统一重试封面/语音资源 | +| GET | `/api/generations/jobs/{job_id}` | 查询生成任务事件流 | +| GET | `/api/generations/{story_id}/jobs` | 查询故事生成与重试历史 | +| GET | `/api/generations/{story_id}/provider-stats` | 查询 Provider 调用聚合指标 | | GET | `/api/stories` | 故事列表 | | GET | `/api/stories/{story_id}` | 故事详情 | | DELETE | `/api/stories/{story_id}` | 删除故事 | diff --git a/admin-frontend/src/components/GenerationTrace.vue b/admin-frontend/src/components/GenerationTrace.vue new file mode 100644 index 0000000..4fbb487 --- /dev/null +++ b/admin-frontend/src/components/GenerationTrace.vue @@ -0,0 +1,358 @@ + + + diff --git a/admin-frontend/src/stores/storybook.ts b/admin-frontend/src/stores/storybook.ts index eab8ef2..781c69a 100644 --- a/admin-frontend/src/stores/storybook.ts +++ b/admin-frontend/src/stores/storybook.ts @@ -18,6 +18,7 @@ export interface Storybook { cover_prompt: string cover_url?: string generation_status?: string + text_status?: string image_status?: string audio_status?: string last_error?: string | null diff --git a/admin-frontend/src/utils/storyStatus.ts b/admin-frontend/src/utils/storyStatus.ts index 363a279..b802dac 100644 --- a/admin-frontend/src/utils/storyStatus.ts +++ b/admin-frontend/src/utils/storyStatus.ts @@ -1,5 +1,6 @@ export type StoryGenerationStatus = | 'narrative_ready' + | 'partial_ready' | 'assets_generating' | 'completed' | 'degraded_completed' @@ -23,6 +24,11 @@ const generationStatusMetaMap: Record = { description: '故事内容已经生成,可以继续补充封面或音频。', badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100', }, + partial_ready: { + label: '可先阅读', + description: '主内容已经可用,仍有封面、插图或音频可以继续补全。', + badgeClass: 'bg-cyan-50 text-cyan-700 border border-cyan-100', + }, assets_generating: { label: '资源生成中', description: '封面或音频正在生成中,请稍候查看结果。', @@ -77,3 +83,13 @@ export function getAssetStatusMeta(status?: string): StatusMeta { return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus] ?? assetStatusMetaMap.not_requested } + +export function isReadableGenerationStatus(status?: string) { + return ['narrative_ready', 'partial_ready', 'completed', 'degraded_completed'] + .includes(status ?? '') +} + +export function needsGenerationAttention(status?: string) { + return ['partial_ready', 'assets_generating', 'degraded_completed', 'failed'] + .includes(status ?? '') +} diff --git a/admin-frontend/src/views/MyStories.vue b/admin-frontend/src/views/MyStories.vue index 2078003..2b33caa 100644 --- a/admin-frontend/src/views/MyStories.vue +++ b/admin-frontend/src/views/MyStories.vue @@ -1,5 +1,5 @@ + + diff --git a/frontend/src/stores/storybook.ts b/frontend/src/stores/storybook.ts index eab8ef2..781c69a 100644 --- a/frontend/src/stores/storybook.ts +++ b/frontend/src/stores/storybook.ts @@ -18,6 +18,7 @@ export interface Storybook { cover_prompt: string cover_url?: string generation_status?: string + text_status?: string image_status?: string audio_status?: string last_error?: string | null diff --git a/frontend/src/types/generation.ts b/frontend/src/types/generation.ts new file mode 100644 index 0000000..c0b1d68 --- /dev/null +++ b/frontend/src/types/generation.ts @@ -0,0 +1,51 @@ +export interface GenerationJobSummary { + id: string + story_id: number | null + output_mode: string + input_type: string + status: string + current_step: string + progress_percent: number + progress_label: string + is_terminal: boolean + result_snapshot: Record + error_message: string | null + created_at: string + updated_at: string +} + +export interface GenerationJobEvent { + id: number + job_id: string + story_id: number | null + event_type: string + status: string + message: string | null + event_metadata: Record + created_at: string +} + +export interface GenerationJobDetail extends GenerationJobSummary { + request_payload: Record + events: GenerationJobEvent[] +} + +export interface GenerationProviderStat { + capability: string + adapter: string + call_count: number + success_count: number + failure_count: number + avg_latency_ms: number | null + estimated_cost_usd: number +} + +export interface GenerationProviderStats { + story_id: number + total_calls: number + successful_calls: number + failed_calls: number + avg_latency_ms: number | null + estimated_cost_usd: number + by_provider: GenerationProviderStat[] +} diff --git a/frontend/src/utils/storyStatus.ts b/frontend/src/utils/storyStatus.ts index 363a279..b802dac 100644 --- a/frontend/src/utils/storyStatus.ts +++ b/frontend/src/utils/storyStatus.ts @@ -1,5 +1,6 @@ export type StoryGenerationStatus = | 'narrative_ready' + | 'partial_ready' | 'assets_generating' | 'completed' | 'degraded_completed' @@ -23,6 +24,11 @@ const generationStatusMetaMap: Record = { description: '故事内容已经生成,可以继续补充封面或音频。', badgeClass: 'bg-sky-50 text-sky-700 border border-sky-100', }, + partial_ready: { + label: '可先阅读', + description: '主内容已经可用,仍有封面、插图或音频可以继续补全。', + badgeClass: 'bg-cyan-50 text-cyan-700 border border-cyan-100', + }, assets_generating: { label: '资源生成中', description: '封面或音频正在生成中,请稍候查看结果。', @@ -77,3 +83,13 @@ export function getAssetStatusMeta(status?: string): StatusMeta { return assetStatusMetaMap[(status ?? 'not_requested') as StoryAssetStatus] ?? assetStatusMetaMap.not_requested } + +export function isReadableGenerationStatus(status?: string) { + return ['narrative_ready', 'partial_ready', 'completed', 'degraded_completed'] + .includes(status ?? '') +} + +export function needsGenerationAttention(status?: string) { + return ['partial_ready', 'assets_generating', 'degraded_completed', 'failed'] + .includes(status ?? '') +} diff --git a/frontend/src/views/MyStories.vue b/frontend/src/views/MyStories.vue index 6d12a3a..691a40f 100644 --- a/frontend/src/views/MyStories.vue +++ b/frontend/src/views/MyStories.vue @@ -7,7 +7,12 @@ import BaseButton from '../components/ui/BaseButton.vue' import BaseCard from '../components/ui/BaseCard.vue' import EmptyState from '../components/ui/EmptyState.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue' -import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus' +import { + getAssetStatusMeta, + getGenerationStatusMeta, + isReadableGenerationStatus, + needsGenerationAttention, +} from '../utils/storyStatus' import { BookOpenIcon, ChevronRightIcon, @@ -24,6 +29,7 @@ interface StoryItem { created_at: string mode: string generation_status: string + text_status: string image_status: string audio_status: string last_error: string | null @@ -35,14 +41,12 @@ const loading = ref(true) const error = ref('') const showCreateModal = ref(false) -const completedCount = computed(() => - stories.value.filter((story) => story.generation_status === 'completed').length, +const readableCount = computed(() => + stories.value.filter((story) => isReadableGenerationStatus(story.generation_status)).length, ) const attentionCount = computed(() => - stories.value.filter((story) => - ['degraded_completed', 'failed'].includes(story.generation_status), - ).length, + stories.value.filter((story) => needsGenerationAttention(story.generation_status)).length, ) async function fetchStories() { @@ -144,12 +148,12 @@ onMounted(() => {
绘本数量
-
{{ completedCount }}
-
完整可用
+
{{ readableCount }}
+
可阅读
{{ attentionCount }}
-
待补资源
+
需关注
diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue index 415ec7e..7a4864c 100644 --- a/frontend/src/views/StoryDetail.vue +++ b/frontend/src/views/StoryDetail.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { api } from '../api/client' import BaseButton from '../components/ui/BaseButton.vue' import ConfirmModal from '../components/ui/ConfirmModal.vue' +import GenerationTrace from '../components/GenerationTrace.vue' import LoadingSpinner from '../components/ui/LoadingSpinner.vue' import { getAssetStatusMeta, getGenerationStatusMeta } from '../utils/storyStatus' import { @@ -23,6 +24,7 @@ interface Story { image_url: string | null mode: string generation_status: string + text_status: string image_status: string audio_status: string last_error: string | null @@ -49,6 +51,7 @@ const audioProgress = ref(0) const audioDuration = ref(0) const error = ref('') const showDeleteConfirm = ref(false) +const generationTraceRef = ref | null>(null) const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? []) const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) @@ -62,6 +65,10 @@ const assetGuidance = computed(() => { return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。' } + if (story.value?.generation_status === 'partial_ready') { + return '正文已经可读,仍可继续补全封面或音频。' + } + if (story.value?.generation_status === 'assets_generating') { return '资源正在处理中,可以稍后刷新查看最新状态。' } @@ -103,6 +110,7 @@ async function generateImage() { story.value = await api.post(`/api/generations/${story.value.id}/retry-assets`, { assets: ['image'], }) + await generationTraceRef.value?.refresh() } catch (e) { error.value = e instanceof Error ? e.message : '图片生成失败' await refreshStorySnapshot().catch(() => undefined) @@ -151,6 +159,7 @@ async function retryAudio() { audioUrl.value = null await loadAudio() } + await generationTraceRef.value?.refresh() } catch (e) { error.value = e instanceof Error ? e.message : '音频生成失败' await refreshStorySnapshot().catch(() => undefined) @@ -338,6 +347,12 @@ onUnmounted(() => {

{{ assetGuidance }}

+ +

| null>(null) const totalPages = computed(() => storybook.value?.pages.length || 0) const isCover = computed(() => currentPageIndex.value === -1) @@ -83,6 +86,7 @@ const currentStoryId = computed(() => { const parsed = Number(normalized) return Number.isFinite(parsed) ? parsed : null }) +const storybookTraceId = computed(() => storybook.value?.id ?? currentStoryId.value) function goHome() { store.clearStorybook() @@ -122,6 +126,7 @@ async function loadStorybook() { if (cachedStorybook?.id === storyId) { loading.value = false + await generationTraceRef.value?.refresh() return } @@ -150,6 +155,7 @@ async function loadStorybook() { last_error: detail.last_error, retryable_assets: detail.retryable_assets, }) + await generationTraceRef.value?.refresh() } catch (e) { error.value = e instanceof Error ? e.message : '绘本加载失败' } finally { @@ -186,6 +192,7 @@ async function retryStorybookImages() { last_error: detail.last_error, retryable_assets: detail.retryable_assets, }) + await generationTraceRef.value?.refresh() } catch (e) { error.value = e instanceof Error ? e.message : '插图补全失败' await loadStorybook().catch(() => undefined) @@ -394,6 +401,17 @@ watch( 读完了,再来一本

+ +
+ +
diff --git a/scripts/demo_smoke.sh b/scripts/demo_smoke.sh index 95159c8..b629b7c 100755 --- a/scripts/demo_smoke.sh +++ b/scripts/demo_smoke.sh @@ -71,13 +71,28 @@ story_json="$(post_json "$APP_URL/api/generations" '{ "generate_images": false }')" story_id="$(jq -r '.id' <<<"$story_json")" -assert_jq "$story_json" '.mode == "generated" and .generation_status == "narrative_ready"' "story should be readable before assets" +story_job_id="$(jq -r '.generation_job_id' <<<"$story_json")" +assert_jq "$story_json" '.mode == "generated" and .generation_status == "partial_ready" and .text_status == "ready"' "story should be readable before assets" +assert_jq "$story_json" '.generation_job_id != null and .generation_job_id != ""' "story generation should expose a job id" assert_jq "$story_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) != null' "story should expose image/audio as retryable assets" echo "$story_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets}' +say "Checking story generation job events" +story_job_json="$(get_json "$APP_URL/api/generations/jobs/$story_job_id")" +assert_jq "$story_job_json" '.id == "'"$story_job_id"'" and .story_id == '"$story_id"'' "story generation job should be queryable" +assert_jq "$story_job_json" '.progress_percent == 100 and .is_terminal == true' "story generation job should expose progress summary" +assert_jq "$story_job_json" '([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "story generation job should include workflow events" +assert_jq "$story_job_json" '([.events[].event_type] | index("provider_call_succeeded")) != null' "story generation job should include provider call events" +echo "$story_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}' + +say "Checking story provider stats" +story_provider_stats_json="$(get_json "$APP_URL/api/generations/$story_id/provider-stats")" +assert_jq "$story_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "story provider stats should summarize provider calls" +echo "$story_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}' + say "Retrying story cover image" story_image_json="$(post_json "$APP_URL/api/generations/$story_id/retry-assets" '{"assets":["image"]}')" -assert_jq "$story_image_json" '.image_status == "ready" and (.image_url != null)' "story cover should be ready after retry" +assert_jq "$story_image_json" '.generation_status == "partial_ready" and .image_status == "ready" and (.image_url != null)' "story cover should be ready after retry" assert_jq "$story_image_json" '(.retryable_assets | index("image")) == null and (.retryable_assets | index("audio")) != null' "story image retry should leave only audio retryable" echo "$story_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets}' @@ -106,16 +121,38 @@ storybook_json="$(post_json "$APP_URL/api/generations" '{ "page_count": 6 }')" storybook_id="$(jq -r '.id' <<<"$storybook_json")" -assert_jq "$storybook_json" '.mode == "storybook" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images" +storybook_job_id="$(jq -r '.generation_job_id' <<<"$storybook_json")" +assert_jq "$storybook_json" '.mode == "storybook" and .generation_status == "partial_ready" and .text_status == "ready" and .image_status == "not_requested" and (.pages | length) >= 4' "storybook should be readable before images" +assert_jq "$storybook_json" '.generation_job_id != null and .generation_job_id != ""' "storybook generation should expose a job id" assert_jq "$storybook_json" '(.retryable_assets | index("image")) != null and (.retryable_assets | index("audio")) == null' "storybook should expose images as retryable assets" echo "$storybook_json" | jq '{id,title,mode,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length)}' +say "Checking storybook generation job events" +storybook_job_json="$(get_json "$APP_URL/api/generations/jobs/$storybook_job_id")" +assert_jq "$storybook_job_json" '.id == "'"$storybook_job_id"'" and .story_id == '"$storybook_id"'' "storybook generation job should be queryable" +assert_jq "$storybook_job_json" '.progress_percent == 100 and .is_terminal == true' "storybook generation job should expose progress summary" +assert_jq "$storybook_job_json" '([.events[].event_type] | index("context_prepared")) != null and ([.events[].event_type] | index("narrative_generated")) != null and ([.events[].event_type] | index("story_saved")) != null' "storybook generation job should include workflow events" +echo "$storybook_job_json" | jq '{id,status,current_step,events:([.events[].event_type] | unique)}' + +say "Checking storybook provider stats" +storybook_provider_stats_json="$(get_json "$APP_URL/api/generations/$storybook_id/provider-stats")" +assert_jq "$storybook_provider_stats_json" '.total_calls >= 1 and .successful_calls >= 1 and (.by_provider | length) >= 1' "storybook provider stats should summarize provider calls" +echo "$storybook_provider_stats_json" | jq '{story_id,total_calls,successful_calls,failed_calls,avg_latency_ms,estimated_cost_usd}' + say "Retrying storybook images" storybook_image_json="$(post_json "$APP_URL/api/generations/$storybook_id/retry-assets" '{"assets":["image"]}')" assert_jq "$storybook_image_json" '.image_status == "ready" and (.pages | length) >= 4 and ([.pages[] | select(.image_url != null)] | length) == (.pages | length)' "storybook images should be ready after retry" assert_jq "$storybook_image_json" '(.retryable_assets | length) == 0' "storybook should have no retryable assets after images are ready" echo "$storybook_image_json" | jq '{id,title,generation_status,image_status,audio_status,retryable_assets,pages:(.pages | length), ready_pages:([.pages[] | select(.image_url != null)] | length)}' +say "Checking story job history" +story_jobs_json="$(get_json "$APP_URL/api/generations/$story_id/jobs")" +storybook_jobs_json="$(get_json "$APP_URL/api/generations/$storybook_id/jobs")" +assert_jq "$story_jobs_json" 'length >= 2 and (map(.id) | index("'"$story_job_id"'")) != null' "story job history should include generation and retry jobs" +assert_jq "$storybook_jobs_json" 'length >= 2 and (map(.id) | index("'"$storybook_job_id"'")) != null' "storybook job history should include generation and retry jobs" +echo "$story_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]' +echo "$storybook_jobs_json" | jq '[.[] | {id,output_mode,status,current_step}]' + say "Checking story list" list_json="$(get_json "$APP_URL/api/stories?limit=5")" assert_jq "$list_json" "map(.id) | index($story_id) != null" "story list should include generated story"