From 0f260f649c2e893aebbbaaae21829cafa58d6292 Mon Sep 17 00:00:00 2001 From: Yuyan Date: Sat, 18 Apr 2026 14:06:38 +0800 Subject: [PATCH] feat: polish generation demo workflow --- README.md | 4 + admin-frontend/src/views/StoryDetail.vue | 2 +- backend/app/api/stories.py | 77 ++++++++++++----- backend/tests/test_stories.py | 23 +++++ docs/README.md | 12 +++ docs/planning/demo-checklist.md | 3 + docs/planning/demo-validation-log.md | 54 ++++++++++++ docs/planning/interview-pitch.md | 83 +++++++++++++++++++ docs/planning/week-2-execution-backlog.md | 14 ++-- docs/technical/api-compatibility.md | 48 +++++++++++ docs/technical/generation-job-state.md | 43 ++++++++++ frontend/src/components/CreateStoryModal.vue | 64 +++++++++++--- .../src/components/ui/AnalysisAnimation.vue | 62 ++++++++------ frontend/src/views/StoryDetail.vue | 74 ++++++++++++++++- frontend/src/views/StorybookViewer.vue | 80 ++++++++++++++++++ 15 files changed, 569 insertions(+), 74 deletions(-) create mode 100644 docs/planning/demo-validation-log.md create mode 100644 docs/planning/interview-pitch.md create mode 100644 docs/technical/api-compatibility.md create mode 100644 docs/technical/generation-job-state.md diff --git a/README.md b/README.md index 94864e8..acd2eb7 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,11 @@ npm run build - `docs/planning/week-1-execution-backlog.md`:短期执行 backlog - `docs/planning/week-2-execution-backlog.md`:下一阶段执行 backlog - `docs/planning/demo-checklist.md`:求职演示检查清单 +- `docs/planning/demo-validation-log.md`:本地 Docker 演示验证记录 +- `docs/planning/interview-pitch.md`:3 分钟项目讲解稿 - `docs/planning/week-1-sprint-review.md`:Week 1 复盘总结 +- `docs/technical/api-compatibility.md`:旧生成 API 兼容层策略 +- `docs/technical/generation-job-state.md`:Generation Job 状态落库决策 - `docs/technical/memory-system-dev.md`:记忆系统技术说明 - `docs/technical/provider-routing.md`:Provider 能力与路由策略说明 diff --git a/admin-frontend/src/views/StoryDetail.vue b/admin-frontend/src/views/StoryDetail.vue index d42f5b9..624297d 100644 --- a/admin-frontend/src/views/StoryDetail.vue +++ b/admin-frontend/src/views/StoryDetail.vue @@ -85,7 +85,7 @@ async function generateImage() { error.value = '' try { - story.value = await api.post(`/api/stories/${story.value.id}/assets/retry`, { + story.value = await api.post(`/api/generations/${story.value.id}/retry-assets`, { assets: ['image'], }) } catch (e) { diff --git a/backend/app/api/stories.py b/backend/app/api/stories.py index 033e7e5..4283fdc 100644 --- a/backend/app/api/stories.py +++ b/backend/app/api/stories.py @@ -38,10 +38,22 @@ from app.services.story_status import StoryAssetStatus, sync_story_status logger = get_logger(__name__) router = APIRouter() -RATE_LIMIT_WINDOW = 60 # seconds +RATE_LIMIT_WINDOW = 60 # seconds RATE_LIMIT_REQUESTS = 10 +def _legacy_generation_headers(successor: str) -> dict[str, str]: + return { + "Deprecation": "true", + "Link": f"<{successor}>; rel=\"successor-version\"", + "X-DreamWeaver-Successor-Endpoint": successor, + } + + +def _mark_legacy_generation_endpoint(response: Response, successor: str) -> None: + response.headers.update(_legacy_generation_headers(successor)) + + @router.post("/generations", response_model=GenerationResponse) async def create_generation( request: GenerationRequest, @@ -77,23 +89,27 @@ async def retry_generation_assets( @router.post("/stories/generate", response_model=StoryResponse) async def generate_story( request: GenerateRequest, + response: Response, user: User = Depends(require_user), - db: AsyncSession = Depends(get_db), -): - """Generate or enhance a story.""" - await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) - return await story_service.generate_and_save_story(request, user.id, db) + db: AsyncSession = Depends(get_db), +): + """Generate or enhance a story.""" + _mark_legacy_generation_endpoint(response, "/api/generations") + await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) + return await story_service.generate_and_save_story(request, user.id, db) @router.post("/stories/generate/full", response_model=FullStoryResponse) -async def generate_story_full( - request: GenerateRequest, - user: User = Depends(require_user), - db: AsyncSession = Depends(get_db), -): - """Generate complete story (story + parallel image/audio generation).""" - await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) - return await story_service.generate_full_story_service(request, user.id, db) +async def generate_story_full( + request: GenerateRequest, + response: Response, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Generate complete story (story + parallel image/audio generation).""" + _mark_legacy_generation_endpoint(response, "/api/generations") + await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) + return await story_service.generate_full_story_service(request, user.id, db) @router.post("/stories/generate/stream") @@ -212,18 +228,23 @@ async def generate_story_stream( ), } - return EventSourceResponse(event_generator()) + return EventSourceResponse( + event_generator(), + headers=_legacy_generation_headers("/api/generations"), + ) @router.post("/storybook/generate", response_model=StorybookResponse) -async def generate_storybook_api( - request: StorybookRequest, - user: User = Depends(require_user), - db: AsyncSession = Depends(get_db), -): - """Generate storybook.""" - await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) - return await story_service.generate_storybook_service(request, user.id, db) +async def generate_storybook_api( + request: StorybookRequest, + response: Response, + user: User = Depends(require_user), + db: AsyncSession = Depends(get_db), +): + """Generate storybook.""" + _mark_legacy_generation_endpoint(response, "/api/generations") + await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) + return await story_service.generate_storybook_service(request, user.id, db) # ==================== Missing Endpoints (Issue #5) ==================== @@ -263,10 +284,15 @@ async def delete_story( @router.post("/image/generate/{story_id}", response_model=StoryImageResponse) async def generate_story_image( story_id: int, + response: Response, user: User = Depends(require_user), db: AsyncSession = Depends(get_db), ): """Generate cover image for story.""" + _mark_legacy_generation_endpoint( + response, + f"/api/generations/{story_id}/retry-assets", + ) url = await story_service.generate_story_cover(story_id, user.id, db) story = await story_service.get_story_detail(story_id, user.id, db) return { @@ -282,10 +308,15 @@ async def generate_story_image( async def retry_story_assets( story_id: int, payload: StoryAssetRetryRequest, + response: Response, user: User = Depends(require_user), db: AsyncSession = Depends(get_db), ): """Retry selected generated assets for a story.""" + _mark_legacy_generation_endpoint( + response, + f"/api/generations/{story_id}/retry-assets", + ) return await story_service.retry_story_assets(story_id, user.id, payload.assets, db) diff --git a/backend/tests/test_stories.py b/backend/tests/test_stories.py index fc11a8b..f0d9d0a 100644 --- a/backend/tests/test_stories.py +++ b/backend/tests/test_stories.py @@ -9,6 +9,14 @@ from app.core.config import settings from app.services.adapters.storybook.primary import Storybook, StorybookPage +def assert_legacy_generation_headers(response, successor: str) -> None: + """Assert that compatibility generation endpoints point callers to the unified API.""" + + assert response.headers["Deprecation"] == "true" + assert response.headers["X-DreamWeaver-Successor-Endpoint"] == successor + assert response.headers["Link"] == f'<{successor}>; rel="successor-version"' + + def build_storybook_output() -> Storybook: """Create a reusable mocked storybook payload.""" @@ -62,6 +70,7 @@ class TestStoryGenerate: json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, ) assert response.status_code == 200 + assert_legacy_generation_headers(response, "/api/generations") data = response.json() assert "id" in data assert "title" in data @@ -317,6 +326,7 @@ class TestGenerateFull: json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, ) assert response.status_code == 200 + assert_legacy_generation_headers(response, "/api/generations") data = response.json() assert "id" in data assert "title" in data @@ -394,6 +404,7 @@ class TestUnifiedGenerations: ) assert response.status_code == 200 + assert "Deprecation" not in response.headers data = response.json() assert data["id"] is not None assert data["mode"] == "generated" @@ -534,6 +545,10 @@ class TestImageGenerateSuccess: ): response = auth_client.post(f"/api/image/generate/{test_story.id}") assert response.status_code == 200 + assert_legacy_generation_headers( + response, + f"/api/generations/{test_story.id}/retry-assets", + ) data = response.json() assert data["image_url"] == "https://example.com/image.png" assert data["generation_status"] == "completed" @@ -557,6 +572,10 @@ class TestAssetRetry: ) assert response.status_code == 200 + assert_legacy_generation_headers( + response, + f"/api/generations/{degraded_story_with_text.id}/retry-assets", + ) data = response.json() assert data["image_url"] == "https://example.com/image.png" assert data["generation_status"] == "completed" @@ -585,6 +604,10 @@ class TestAssetRetry: ) assert response.status_code == 200 + assert_legacy_generation_headers( + response, + f"/api/generations/{storybook_story.id}/retry-assets", + ) data = response.json() assert data["generation_status"] == "completed" assert data["image_status"] == "ready" diff --git a/docs/README.md b/docs/README.md index 3235799..34a0f6c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,12 @@ - `planning/demo-checklist.md` 求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。 +- `planning/demo-validation-log.md` + 本地 Docker 演示验证记录。用于说明最近一次构建、smoke 和手动检查结果。 + +- `planning/interview-pitch.md` + 3 分钟项目讲解稿。用于面试中说明产品定位、生成工作流、Provider 分层和取舍。 + - `planning/week-1-sprint-review.md` Week 1 复盘总结。用于沉淀已完成成果、剩余缺口和下一阶段建议。 @@ -29,6 +35,12 @@ - `technical/memory-system-dev.md` 记忆系统技术说明。用于后续继续做孩子档案、故事宇宙和个性化生成。 +- `technical/api-compatibility.md` + 旧生成 API 兼容层策略。用于说明历史接口如何迁移到统一 generation 入口。 + +- `technical/generation-job-state.md` + Generation Job 状态落库决策。用于说明当前为什么先复用 story 状态,何时再拆 job/event 表。 + - `technical/provider-routing.md` Provider Routing 技术说明。用于解释 Capability / Provider / Adapter / Routing Policy 的职责边界。 diff --git a/docs/planning/demo-checklist.md b/docs/planning/demo-checklist.md index 44cab2d..546b071 100644 --- a/docs/planning/demo-checklist.md +++ b/docs/planning/demo-checklist.md @@ -57,6 +57,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh - [ ] 绘本图片 retry 后 `image_status=ready` - [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook` - [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready` +- [ ] 验证结果已记录到 `docs/planning/demo-validation-log.md` --- @@ -100,6 +101,8 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh ## 4. 3 分钟讲解结构 +详细稿见 `docs/planning/interview-pitch.md`。现场建议背结构,不逐字背。 + ### 0:00 - 0:40 产品定位 DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲述产品。它不是只生成一次性故事,而是围绕孩子档案、成长主题和故事宇宙,生成可回看、可补全、可聆听的故事体验。 diff --git a/docs/planning/demo-validation-log.md b/docs/planning/demo-validation-log.md new file mode 100644 index 0000000..27ec82e --- /dev/null +++ b/docs/planning/demo-validation-log.md @@ -0,0 +1,54 @@ +# Demo 验证记录 + +这份记录用于演示前快速说明“当前本地 Docker 环境已经验证到什么程度”。新的验证记录按时间倒序追加。 + +## 2026-04-18 + +验证范围: + +- 用户前端 Docker 生产构建 +- 管理前端 Docker 生产构建 +- 后端 Docker 镜像构建与服务重启 +- 后端 lint 与测试 +- 后端统一生成接口 +- 故事封面资产补全 +- 故事音频资产补全 +- 绘本文字生成 +- 绘本封面和分页插图补全 +- 故事列表读取 +- Provider capability policy + +执行命令: + +```bash +docker compose build frontend +docker compose build frontend frontend-admin +docker compose build backend backend-admin worker celery-beat +docker compose up -d backend backend-admin worker celery-beat frontend frontend-admin +cd backend && .venv/bin/python -m ruff check app tests +cd backend && .venv/bin/python -m pytest -q +SMOKE_AUDIO=1 ./scripts/demo_smoke.sh +``` + +结果: + +- `vue-tsc` 通过。 +- 用户端与管理端 `vite build` 通过。 +- Docker 前端镜像 `dreamweaver-frontend:dev` 构建通过。 +- Docker 管理前端镜像 `dreamweaver-admin-frontend:dev` 构建通过。 +- Docker 后端镜像 `dreamweaver-backend:dev` 构建通过。 +- `ruff check app tests` 通过。 +- `pytest -q` 通过,71 个测试通过。 +- `SMOKE_AUDIO=1 ./scripts/demo_smoke.sh` 通过。 +- 本地用户端可通过 `http://localhost:52080` 访问。 + +已确认的演示能力: + +- 普通故事可以先生成可读正文。 +- 封面和音频可以作为资产单独重试。 +- 绘本可以生成 6 页文本并补全全部插图。 +- 故事列表能看到最新生成结果。 + +限制: + +- 本机浏览器自动化脚本默认寻找标准版 Chrome;当前电脑安装的是 Google Chrome Beta,所以本轮没有生成 CDP 截图。 diff --git a/docs/planning/interview-pitch.md b/docs/planning/interview-pitch.md new file mode 100644 index 0000000..b99761b --- /dev/null +++ b/docs/planning/interview-pitch.md @@ -0,0 +1,83 @@ +# DreamWeaver 3 分钟项目讲解稿 + +这份讲解稿用于 AI 产品经理面试中的项目介绍。建议先背结构,不要逐字背稿;现场根据面试官背景调整技术深度。 + +--- + +## 0:00 - 0:30 一句话定位 + +DreamWeaver 是一款面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲述产品。它不是简单生成一段故事,而是围绕孩子档案、成长主题和故事宇宙,生成可以保存、回看、补全封面和播放语音的亲子阅读体验。 + +--- + +## 0:30 - 1:05 为什么要重启这个项目 + +这个项目早期功能很多:故事生成、绘本、语音、Provider 管理、孩子档案、记忆系统都做过,但主线不够聚焦。求职版重启时,我把目标从“功能越多越好”改成“能否讲清楚一个 AI 产品闭环”。 + +我保留的核心闭环是: + +`选择孩子档案 -> 输入主题/教育目标 -> 生成故事或绘本 -> 补全封面/插图/语音 -> 保存到故事库 -> 可再次打开` + +这样面试官能快速理解用户价值,也能看到我对范围收敛的判断。 + +--- + +## 1:05 - 1:55 统一生成工作流 + +AI 生成产品最大的问题不是“能不能调模型”,而是结果不确定时,用户体验怎么保持稳定。所以我把普通故事、完整故事和绘本生成收敛成统一 Generation Workflow。 + +现在系统先保存主结果,让故事或绘本文字尽快可读;封面、绘本插图和语音作为可补全资产处理。即使图片或音频失败,主故事不会丢,用户可以继续阅读,也可以稍后重试。 + +后端通过统一状态字段表达结果: + +- `generation_status` +- `image_status` +- `audio_status` +- `last_error` + +服务层也抽出了 `AssetCompletionResult`,用来表达资产补全类型、状态、结果值、错误信息和是否阻塞主结果。 + +--- + +## 1:55 - 2:35 Provider 分层 + +另一个重点是 Provider 体系。早期 Provider Router 同时承担默认配置、Key 映射、路由策略、熔断、成本统计和执行入口,解释起来很乱。 + +我把它拆成四个概念: + +- Capability:产品需要的 AI 能力,例如文本、图片、语音、绘本结构 +- Provider:某个能力下的供应商配置,例如 Gemini、OpenAI、CQTAI、MiniMax +- Adapter:具体 API 调用实现 +- Routing Policy:如何按优先级、成本、延迟或轮询选择 Provider + +这样用户看到的是稳定的产品能力,系统内部再决定具体调用哪个模型或供应商。 + +--- + +## 2:35 - 3:00 当前成果和下一步 + +目前本地 Docker 可以跑通完整链路,并且有 smoke 脚本验证健康检查、登录、生成、资产重试、故事列表和 Provider 能力分层。 + +下一步我会继续打磨前端状态体验,让生成中、部分完成、失败重试这些 AI 产品特有状态更清楚;同时明确旧 API 兼容层和 generation job 是否需要落库。 + +我希望通过这个项目展示的是:我不只是会接 AI API,而是能把不确定的模型能力收敛成稳定、可解释、可恢复的产品体验。 + +--- + +## 面试官追问时的简短回答 + +### 为什么不是继续加更多功能? + +因为求职版的核心目标是展示产品判断和系统设计能力。功能越多不一定越好,闭环稳定、边界清楚、能解释取舍更重要。 + +### 为什么资产失败不直接让生成失败? + +儿童故事的主价值是可阅读内容。封面、插图、语音是增强资产,失败时应该降级而不是摧毁主结果。这是 AI 产品常见的不确定性处理。 + +### Provider 分层有什么产品价值? + +它让用户不需要理解模型供应链,只感知稳定能力;同时让产品拥有者能控制成本、失败降级和供应商切换。 + +### 这个项目下一步怎么上线? + +我会先完成演示级前端状态体验和旧 API 兼容策略,再决定 generation job 是否落库。生产上线前还需要补真实用户鉴权配置、密钥管理、监控告警和部署策略。 diff --git a/docs/planning/week-2-execution-backlog.md b/docs/planning/week-2-execution-backlog.md index 9222381..7928321 100644 --- a/docs/planning/week-2-execution-backlog.md +++ b/docs/planning/week-2-execution-backlog.md @@ -76,13 +76,13 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时 | W2-01 | Demo | 固化本地 Docker smoke 脚本 | `scripts/demo_smoke.sh` | P0 | 0.5d | Done | | W2-02 | Demo | 形成求职演示 checklist | `docs/planning/demo-checklist.md` | P0 | 0.5d | Done | | W2-03 | Planning | 输出 Week 2 执行 backlog | 当前文档 | P0 | 0.5d | Done | -| W2-04 | Product | 写 3 分钟项目讲解稿 | 面试口径:产品、工作流、Provider、取舍 | P0 | 0.5d | Pending | -| W2-05 | Frontend | 打磨创建弹窗的状态文案 | 用户知道正在生成故事/绘本/资产 | P0 | 0.5d | Pending | -| W2-06 | Frontend | 强化故事详情页资产状态与重试 CTA | 图片/音频失败时可理解、可操作 | P0 | 1.0d | Pending | -| W2-07 | Frontend | 强化绘本阅读器降级态 | 缺图、失败、加载中不出现空白体验 | P0 | 1.0d | Pending | -| W2-08 | Backend | 梳理旧生成 API 兼容层策略 | 保留/标记 deprecated/迁移计划 | P1 | 0.5d | Pending | -| W2-09 | Backend | 判断 generation job 是否需要落库 | ADR 或技术说明 | P1 | 0.5d | Pending | -| W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | Docker build + smoke 输出 | P1 | 0.5d | Pending | +| W2-04 | Product | 写 3 分钟项目讲解稿 | 面试口径:产品、工作流、Provider、取舍 | P0 | 0.5d | Done | +| W2-05 | Frontend | 打磨创建弹窗的状态文案 | 用户知道正在生成故事/绘本/资产 | P0 | 0.5d | Done | +| W2-06 | Frontend | 强化故事详情页资产状态与重试 CTA | 图片/音频失败时可理解、可操作 | P0 | 1.0d | Done | +| W2-07 | Frontend | 强化绘本阅读器降级态 | 缺图、失败、加载中不出现空白体验 | P0 | 1.0d | Done | +| W2-08 | Backend | 梳理旧生成 API 兼容层策略 | 保留/标记 deprecated/迁移计划 | P1 | 0.5d | Done | +| W2-09 | Backend | 判断 generation job 是否需要落库 | ADR 或技术说明 | P1 | 0.5d | Done | +| W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | Docker build + smoke 输出 | P1 | 0.5d | Done | | W2-11 | Docs | 输出 Week 1 Sprint Review | `docs/planning/week-1-sprint-review.md` | P1 | 0.5d | Done | | W2-12 | Docs | 更新 README 的演示前检查流程 | README 本地演示说明 | P1 | 0.5d | Done | diff --git a/docs/technical/api-compatibility.md b/docs/technical/api-compatibility.md new file mode 100644 index 0000000..b9cb623 --- /dev/null +++ b/docs/technical/api-compatibility.md @@ -0,0 +1,48 @@ +# API 兼容层策略 + +这份文档用于说明 DreamWeaver 求职版如何处理旧生成接口。目标不是立刻删除所有旧 API,而是把主线收敛到统一生成工作流,并让历史入口变成可迁移、可监控、可解释的兼容层。 + +## 背景 + +项目早期分别提供过普通故事、完整故事、绘本、封面生成和资产重试接口: + +- `POST /api/stories/generate` +- `POST /api/stories/generate/full` +- `POST /api/storybook/generate` +- `POST /api/image/generate/{story_id}` +- `POST /api/stories/{story_id}/assets/retry` + +这些接口都能工作,但对前端和面试讲解不够友好:故事、绘本和资产补全看起来像多条产品链路,容易掩盖真正的核心能力。 + +## 目标入口 + +求职版主入口统一为: + +- `POST /api/generations` +- `GET /api/generations/{story_id}` +- `POST /api/generations/{story_id}/retry-assets` + +前端创建弹窗、绘本阅读器、故事详情页的资产重试应优先使用这些入口。 + +## 当前策略 + +1. 保留旧接口,避免破坏已有测试和潜在调用方。 +2. 旧接口内部继续复用同一套 service workflow,不复制业务逻辑。 +3. 旧接口响应增加迁移提示 header: + - `Deprecation: true` + - `X-DreamWeaver-Successor-Endpoint: ` + - `Link: ; rel="successor-version"` +4. 新前端不再新增对旧生成接口的依赖。 + +## 删除条件 + +旧接口可以在同时满足以下条件后删除: + +- 用户端和管理端都已切到 `/api/generations`。 +- smoke 脚本只依赖统一入口。 +- 后端测试覆盖统一入口的故事、绘本、资产重试和失败降级。 +- 至少一个迭代周期没有发现旧接口新增调用。 + +## 面试表达 + +我不会为了“代码看起来干净”直接删掉旧接口,因为这会引入不可见风险。更稳妥的做法是先定义目标 API,再把旧入口降级为带迁移信号的兼容层,最后通过测试和调用方收敛来决定删除时机。 diff --git a/docs/technical/generation-job-state.md b/docs/technical/generation-job-state.md new file mode 100644 index 0000000..be3e6c3 --- /dev/null +++ b/docs/technical/generation-job-state.md @@ -0,0 +1,43 @@ +# Generation Job 状态落库决策 + +这份文档用于回答一个关键技术债问题:DreamWeaver 是否需要为每次 AI 生成单独建立 `generation_jobs` 表。 + +## 当前结论 + +短期不新增 `generation_jobs` 表,继续把求职版状态落在 `stories` 主记录上。 + +原因是当前 MVP 的生成方式仍然以同步请求为主:后端在一次请求中完成主内容保存,再补全封面、绘本插图或语音。用户最关心的是“这个故事现在能不能读、哪些资产可补全”,而不是一个独立 job 的生命周期。 + +## 现有状态模型 + +当前 `stories` 表已承载演示所需状态: + +- `generation_status`: 主流程状态,例如 `narrative_ready`、`assets_generating`、`completed`、`degraded_completed`、`failed` +- `image_status`: 封面或绘本插图状态 +- `audio_status`: 语音状态 +- `last_error`: 最近一次资产失败原因 + +这些字段足够支撑前端展示、smoke 检查、失败降级和资产重试。 + +## 什么时候需要落库 job + +如果后续进入真实生产化,需要重新评估 `generation_jobs`: + +- 生成流程改成真正异步,前端需要轮询 job 进度。 +- 单个故事会产生多次生成尝试,需要审计每次 provider 调用。 +- 需要展示更细颗粒度步骤,例如 prompt 构建、文本生成、封面生成、每页插图、TTS。 +- 需要按 provider、成本、延迟和失败原因做运营分析。 +- 需要断点续跑,避免 Worker 重启后丢失中间状态。 + +## 推荐未来结构 + +未来可以新增两层记录: + +- `generation_jobs`: 一次用户发起的生成任务,记录输入、状态、耗时、错误和关联 story。 +- `generation_job_events`: 任务事件流,记录每一步开始、成功、失败、provider、耗时和错误摘要。 + +这会把“用户可见结果”和“系统执行过程”分开,但目前还不是求职版的最高优先级。 + +## 面试表达 + +我现在没有急着加 job 表,是因为 MVP 首要目标是让故事结果稳定可读,并让资产失败可恢复。等生成链路变成真正异步、需要审计和运营指标时,再把执行过程拆到 job/event 表,会比现在提前设计复杂表结构更稳。 diff --git a/frontend/src/components/CreateStoryModal.vue b/frontend/src/components/CreateStoryModal.vue index 1537dab..3a784f3 100644 --- a/frontend/src/components/CreateStoryModal.vue +++ b/frontend/src/components/CreateStoryModal.vue @@ -73,12 +73,37 @@ const themes: ThemeOption[] = [ { icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' }, ] -const profileOptions = computed(() => - profiles.value.map(profile => ({ value: profile.id, label: profile.name })), -) -const universeOptions = computed(() => - universes.value.map(universe => ({ value: universe.id, label: universe.name })), -) +const profileOptions = computed(() => + profiles.value.map(profile => ({ value: profile.id, label: profile.name })), +) +const universeOptions = computed(() => + universes.value.map(universe => ({ value: universe.id, label: universe.name })), +) +const requestedOutputMode = computed<'story' | 'storybook'>(() => + inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story', +) +const generationTitle = computed(() => + requestedOutputMode.value === 'storybook' ? '绘本排版中...' : '故事编织中...', +) +const generationSteps = computed(() => { + if (requestedOutputMode.value === 'storybook') { + return [ + '正在整理主题和成长目标...', + '生成绘本分镜和每页文字...', + '保存绘本主记录,确保刷新也能找回...', + '补全封面和分页插图...', + '马上进入可翻页阅读模式。', + ] + } + + return [ + '正在整理孩子档案和故事主题...', + '生成可先阅读的故事正文...', + '保存故事主记录,避免结果丢失...', + '补全封面图,失败也可稍后重试...', + '马上进入故事详情页。', + ] +}) // Methods function close() { @@ -137,10 +162,8 @@ async function generateStory() { error.value = '' try { - const requestedOutputMode = - inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story' const payload: Record = { - output_mode: requestedOutputMode, + output_mode: requestedOutputMode.value, type: inputType.value, data: inputData.value, education_theme: educationTheme.value || undefined, @@ -150,7 +173,7 @@ async function generateStory() { if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value - if (requestedOutputMode === 'storybook') { + if (requestedOutputMode.value === 'storybook') { const response = await api.post('/api/generations', payload) storybookStore.setStorybook(response) @@ -191,7 +214,11 @@ async function generateStory() { > - +
@@ -344,8 +371,19 @@ async function generateStory() {
- - +
+
+ {{ requestedOutputMode === 'storybook' ? '绘本会先保存,再补全插图' : '故事会先可读,再补全封面' }} +
+

+ {{ requestedOutputMode === 'storybook' + ? '即使部分插图暂时失败,绘本文字也会保留在故事库,稍后可以继续补全。' + : '封面或语音失败不会影响正文阅读,结果页会给出状态和重试入口。' }} +

+
+ + -import { ref, onMounted, onUnmounted } from 'vue' - -const steps = [ - '正在接收梦境信号...', - '编织故事脉络...', - '绘制精美插画 (需要一点点魔法时间)...', - '撒上一些星光粉...', - '即将完成独一无二的绘本!' -] - -const currentStepIndex = ref(0) -let stepInterval: number | undefined - -onMounted(() => { - stepInterval = window.setInterval(() => { - if (currentStepIndex.value < steps.length - 1) { - currentStepIndex.value++ - } - }, 2500) +import { computed, ref, onMounted, onUnmounted } from 'vue' + +const props = withDefaults(defineProps<{ + title?: string + steps?: string[] +}>(), { + title: '梦境编织中...', +}) + +const defaultSteps = [ + '正在接收梦境信号...', + '编织故事脉络...', + '绘制精美插画 (需要一点点魔法时间)...', + '撒上一些星光粉...', + '即将完成独一无二的绘本!' +] + +const currentStepIndex = ref(0) +const steps = computed(() => props.steps?.length ? props.steps : defaultSteps) +let stepInterval: number | undefined + +onMounted(() => { + stepInterval = window.setInterval(() => { + if (currentStepIndex.value < steps.value.length - 1) { + currentStepIndex.value++ + } + }, 2500) }) onUnmounted(() => { @@ -64,14 +72,14 @@ onUnmounted(() => {
-

- 梦境编织中... -

- -

- {{ steps[currentStepIndex] }} -

-
+

+ {{ props.title }} +

+ +

+ {{ steps[currentStepIndex] }} +

+
diff --git a/frontend/src/views/StoryDetail.vue b/frontend/src/views/StoryDetail.vue index d43770b..a2ef2af 100644 --- a/frontend/src/views/StoryDetail.vue +++ b/frontend/src/views/StoryDetail.vue @@ -53,6 +53,27 @@ const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ?? const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_status)) +const canRetryImage = computed(() => + Boolean(story.value?.cover_prompt) + && story.value?.image_status !== 'ready' + && story.value?.image_status !== 'generating', +) +const canRetryAudio = computed(() => + Boolean(story.value?.story_text) + && story.value?.audio_status !== 'ready' + && story.value?.audio_status !== 'generating', +) +const assetGuidance = computed(() => { + if (story.value?.generation_status === 'degraded_completed') { + return '正文已经可读,失败的资源可以单独重试,不会覆盖当前故事。' + } + + if (story.value?.generation_status === 'assets_generating') { + return '资源正在处理中,可以稍后刷新查看最新状态。' + } + + return '封面和音频都是可补全资产,首次生成后会保存状态并复用结果。' +}) async function refreshStorySnapshot() { const data = await api.get(`/api/stories/${route.params.id}`) @@ -85,7 +106,7 @@ async function generateImage() { error.value = '' try { - story.value = await api.post(`/api/stories/${story.value.id}/assets/retry`, { + story.value = await api.post(`/api/generations/${story.value.id}/retry-assets`, { assets: ['image'], }) } catch (e) { @@ -122,6 +143,28 @@ async function loadAudio() { } } +async function retryAudio() { + if (!story.value) return + + audioLoading.value = true + error.value = '' + + try { + story.value = await api.post(`/api/generations/${story.value.id}/retry-assets`, { + assets: ['audio'], + }) + if (story.value.audio_status === 'ready') { + audioUrl.value = null + await loadAudio() + } + } catch (e) { + error.value = e instanceof Error ? e.message : '音频生成失败' + await refreshStorySnapshot().catch(() => undefined) + } finally { + audioLoading.value = false + } +} + function togglePlay() { if (!audioRef.value) return @@ -266,6 +309,16 @@ onUnmounted(() => {
封面资源
{{ imageMeta.label }}

{{ imageMeta.description }}

+ + {{ story.image_status === 'failed' ? '重试封面' : '补全封面' }} +
音频资源
@@ -273,9 +326,24 @@ onUnmounted(() => {

{{ audioMeta.description }} 音频首次生成后会缓存复用,状态记录的是当前可播放结果。

+ + {{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }} +
+
+
资源补全策略
+

{{ assetGuidance }}

+
+

{

diff --git a/frontend/src/views/StorybookViewer.vue b/frontend/src/views/StorybookViewer.vue index d43643d..78e4063 100644 --- a/frontend/src/views/StorybookViewer.vue +++ b/frontend/src/views/StorybookViewer.vue @@ -41,6 +41,7 @@ const store = useStorybookStore() const storybook = computed(() => store.currentStorybook) const loading = ref(true) +const imageLoading = ref(false) const error = ref('') const currentPageIndex = ref(-1) @@ -50,6 +51,11 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value - const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_status)) +const canRetryImages = computed(() => + Boolean(storybook.value?.id) + && storybook.value?.image_status !== 'ready' + && storybook.value?.image_status !== 'generating', +) const currentPage = computed(() => { if (!storybook.value || isCover.value) return null return storybook.value.pages[currentPageIndex.value] @@ -153,6 +159,42 @@ async function loadStorybook() { } } +async function retryStorybookImages() { + if (!storybook.value?.id) return + + imageLoading.value = true + error.value = '' + + try { + const detail = await api.post( + `/api/generations/${storybook.value.id}/retry-assets`, + { assets: ['image'] }, + ) + + store.setStorybook({ + id: detail.id, + title: detail.title, + main_character: storybook.value.main_character || '故事主角', + art_style: storybook.value.art_style || 'AI 绘本风格', + pages: (detail.pages ?? []).map((page) => ({ + ...page, + image_url: page.image_url ?? undefined, + })), + cover_prompt: detail.cover_prompt ?? '', + cover_url: detail.image_url ?? undefined, + generation_status: detail.generation_status, + image_status: detail.image_status, + audio_status: detail.audio_status, + last_error: detail.last_error, + }) + } catch (e) { + error.value = e instanceof Error ? e.message : '插图补全失败' + await loadStorybook().catch(() => undefined) + } finally { + imageLoading.value = false + } +} + watch( () => route.params.id, () => { @@ -206,6 +248,16 @@ watch(

{{ imageMeta.description }}

+ + {{ storybook.image_status === 'failed' ? '重试插图' : '补全插图' }} +
@@ -249,6 +301,24 @@ watch(

{{ storybook.last_error }}

+
+
插图可稍后补全
+

+ 绘本文字已经保存,可以先阅读;补全插图会更新封面和缺失页面。 +

+ + {{ storybook.image_status === 'failed' ? '重试全部插图' : '补全全部插图' }} + +
+ 开始阅读 @@ -273,6 +343,16 @@ watch(

{{ pageImageMessage }}

+ + 补全插图 +