feat: polish generation demo workflow

This commit is contained in:
2026-04-18 14:06:38 +08:00
parent 5d8fb1ed50
commit 0f260f649c
15 changed files with 569 additions and 74 deletions

View File

@@ -144,7 +144,11 @@ npm run build
- `docs/planning/week-1-execution-backlog.md`:短期执行 backlog - `docs/planning/week-1-execution-backlog.md`:短期执行 backlog
- `docs/planning/week-2-execution-backlog.md`:下一阶段执行 backlog - `docs/planning/week-2-execution-backlog.md`:下一阶段执行 backlog
- `docs/planning/demo-checklist.md`:求职演示检查清单 - `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/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/memory-system-dev.md`:记忆系统技术说明
- `docs/technical/provider-routing.md`Provider 能力与路由策略说明 - `docs/technical/provider-routing.md`Provider 能力与路由策略说明

View File

@@ -85,7 +85,7 @@ async function generateImage() {
error.value = '' error.value = ''
try { try {
story.value = await api.post<Story>(`/api/stories/${story.value.id}/assets/retry`, { story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
assets: ['image'], assets: ['image'],
}) })
} catch (e) { } catch (e) {

View File

@@ -38,10 +38,22 @@ from app.services.story_status import StoryAssetStatus, sync_story_status
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
RATE_LIMIT_WINDOW = 60 # seconds RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_REQUESTS = 10 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) @router.post("/generations", response_model=GenerationResponse)
async def create_generation( async def create_generation(
request: GenerationRequest, request: GenerationRequest,
@@ -77,23 +89,27 @@ async def retry_generation_assets(
@router.post("/stories/generate", response_model=StoryResponse) @router.post("/stories/generate", response_model=StoryResponse)
async def generate_story( async def generate_story(
request: GenerateRequest, request: GenerateRequest,
response: Response,
user: User = Depends(require_user), user: User = Depends(require_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Generate or enhance a story.""" """Generate or enhance a story."""
await check_rate_limit(f"story:{user.id}", RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW) _mark_legacy_generation_endpoint(response, "/api/generations")
return await story_service.generate_and_save_story(request, user.id, db) 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) @router.post("/stories/generate/full", response_model=FullStoryResponse)
async def generate_story_full( async def generate_story_full(
request: GenerateRequest, request: GenerateRequest,
user: User = Depends(require_user), response: Response,
db: AsyncSession = Depends(get_db), 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) """Generate complete story (story + parallel image/audio generation)."""
return await story_service.generate_full_story_service(request, user.id, db) _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") @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) @router.post("/storybook/generate", response_model=StorybookResponse)
async def generate_storybook_api( async def generate_storybook_api(
request: StorybookRequest, request: StorybookRequest,
user: User = Depends(require_user), response: Response,
db: AsyncSession = Depends(get_db), 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) """Generate storybook."""
return await story_service.generate_storybook_service(request, user.id, db) _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) ==================== # ==================== Missing Endpoints (Issue #5) ====================
@@ -263,10 +284,15 @@ async def delete_story(
@router.post("/image/generate/{story_id}", response_model=StoryImageResponse) @router.post("/image/generate/{story_id}", response_model=StoryImageResponse)
async def generate_story_image( async def generate_story_image(
story_id: int, story_id: int,
response: Response,
user: User = Depends(require_user), user: User = Depends(require_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Generate cover image for story.""" """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) url = await story_service.generate_story_cover(story_id, user.id, db)
story = await story_service.get_story_detail(story_id, user.id, db) story = await story_service.get_story_detail(story_id, user.id, db)
return { return {
@@ -282,10 +308,15 @@ async def generate_story_image(
async def retry_story_assets( async def retry_story_assets(
story_id: int, story_id: int,
payload: StoryAssetRetryRequest, payload: StoryAssetRetryRequest,
response: Response,
user: User = Depends(require_user), user: User = Depends(require_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Retry selected generated assets for a story.""" """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) return await story_service.retry_story_assets(story_id, user.id, payload.assets, db)

View File

@@ -9,6 +9,14 @@ from app.core.config import settings
from app.services.adapters.storybook.primary import Storybook, StorybookPage 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: def build_storybook_output() -> Storybook:
"""Create a reusable mocked storybook payload.""" """Create a reusable mocked storybook payload."""
@@ -62,6 +70,7 @@ class TestStoryGenerate:
json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert_legacy_generation_headers(response, "/api/generations")
data = response.json() data = response.json()
assert "id" in data assert "id" in data
assert "title" in data assert "title" in data
@@ -317,6 +326,7 @@ class TestGenerateFull:
json={"type": "keywords", "data": "小兔子, 森林, 勇气"}, json={"type": "keywords", "data": "小兔子, 森林, 勇气"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert_legacy_generation_headers(response, "/api/generations")
data = response.json() data = response.json()
assert "id" in data assert "id" in data
assert "title" in data assert "title" in data
@@ -394,6 +404,7 @@ class TestUnifiedGenerations:
) )
assert response.status_code == 200 assert response.status_code == 200
assert "Deprecation" not in response.headers
data = response.json() data = response.json()
assert data["id"] is not None assert data["id"] is not None
assert data["mode"] == "generated" assert data["mode"] == "generated"
@@ -534,6 +545,10 @@ class TestImageGenerateSuccess:
): ):
response = auth_client.post(f"/api/image/generate/{test_story.id}") response = auth_client.post(f"/api/image/generate/{test_story.id}")
assert response.status_code == 200 assert response.status_code == 200
assert_legacy_generation_headers(
response,
f"/api/generations/{test_story.id}/retry-assets",
)
data = response.json() data = response.json()
assert data["image_url"] == "https://example.com/image.png" assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed" assert data["generation_status"] == "completed"
@@ -557,6 +572,10 @@ class TestAssetRetry:
) )
assert response.status_code == 200 assert response.status_code == 200
assert_legacy_generation_headers(
response,
f"/api/generations/{degraded_story_with_text.id}/retry-assets",
)
data = response.json() data = response.json()
assert data["image_url"] == "https://example.com/image.png" assert data["image_url"] == "https://example.com/image.png"
assert data["generation_status"] == "completed" assert data["generation_status"] == "completed"
@@ -585,6 +604,10 @@ class TestAssetRetry:
) )
assert response.status_code == 200 assert response.status_code == 200
assert_legacy_generation_headers(
response,
f"/api/generations/{storybook_story.id}/retry-assets",
)
data = response.json() data = response.json()
assert data["generation_status"] == "completed" assert data["generation_status"] == "completed"
assert data["image_status"] == "ready" assert data["image_status"] == "ready"

View File

@@ -21,6 +21,12 @@
- `planning/demo-checklist.md` - `planning/demo-checklist.md`
求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。 求职演示检查清单。用于演示前确认 Docker 环境、核心链路、话术和风险预案。
- `planning/demo-validation-log.md`
本地 Docker 演示验证记录。用于说明最近一次构建、smoke 和手动检查结果。
- `planning/interview-pitch.md`
3 分钟项目讲解稿。用于面试中说明产品定位、生成工作流、Provider 分层和取舍。
- `planning/week-1-sprint-review.md` - `planning/week-1-sprint-review.md`
Week 1 复盘总结。用于沉淀已完成成果、剩余缺口和下一阶段建议。 Week 1 复盘总结。用于沉淀已完成成果、剩余缺口和下一阶段建议。
@@ -29,6 +35,12 @@
- `technical/memory-system-dev.md` - `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` - `technical/provider-routing.md`
Provider Routing 技术说明。用于解释 Capability / Provider / Adapter / Routing Policy 的职责边界。 Provider Routing 技术说明。用于解释 Capability / Provider / Adapter / Routing Policy 的职责边界。

View File

@@ -57,6 +57,7 @@ SMOKE_AUDIO=1 ./scripts/demo_smoke.sh
- [ ] 绘本图片 retry 后 `image_status=ready` - [ ] 绘本图片 retry 后 `image_status=ready`
- [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook` - [ ] `/admin/providers/capabilities` 返回 `text/image/tts/storybook`
- [ ] 如果启用 `SMOKE_AUDIO=1`,音频 retry 后 `audio_status=ready` - [ ] 如果启用 `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 分钟讲解结构 ## 4. 3 分钟讲解结构
详细稿见 `docs/planning/interview-pitch.md`。现场建议背结构,不逐字背。
### 0:00 - 0:40 产品定位 ### 0:00 - 0:40 产品定位
DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲述产品。它不是只生成一次性故事,而是围绕孩子档案、成长主题和故事宇宙,生成可回看、可补全、可聆听的故事体验。 DreamWeaver 是面向 3-8 岁亲子场景的个性化 AI 绘本与陪伴式讲述产品。它不是只生成一次性故事,而是围绕孩子档案、成长主题和故事宇宙,生成可回看、可补全、可聆听的故事体验。

View File

@@ -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 截图。

View File

@@ -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 是否落库。生产上线前还需要补真实用户鉴权配置、密钥管理、监控告警和部署策略。

View File

@@ -76,13 +76,13 @@ Week 2 的目标不是做“完整商业产品”,而是做出一个面试时
| W2-01 | Demo | 固化本地 Docker smoke 脚本 | `scripts/demo_smoke.sh` | P0 | 0.5d | Done | | 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-02 | Demo | 形成求职演示 checklist | `docs/planning/demo-checklist.md` | P0 | 0.5d | Done |
| W2-03 | Planning | 输出 Week 2 执行 backlog | 当前文档 | P0 | 0.5d | Done | | W2-03 | Planning | 输出 Week 2 执行 backlog | 当前文档 | P0 | 0.5d | Done |
| W2-04 | Product | 写 3 分钟项目讲解稿 | 面试口径产品、工作流、Provider、取舍 | P0 | 0.5d | Pending | | W2-04 | Product | 写 3 分钟项目讲解稿 | 面试口径产品、工作流、Provider、取舍 | P0 | 0.5d | Done |
| W2-05 | Frontend | 打磨创建弹窗的状态文案 | 用户知道正在生成故事/绘本/资产 | P0 | 0.5d | Pending | | W2-05 | Frontend | 打磨创建弹窗的状态文案 | 用户知道正在生成故事/绘本/资产 | P0 | 0.5d | Done |
| W2-06 | Frontend | 强化故事详情页资产状态与重试 CTA | 图片/音频失败时可理解、可操作 | P0 | 1.0d | Pending | | W2-06 | Frontend | 强化故事详情页资产状态与重试 CTA | 图片/音频失败时可理解、可操作 | P0 | 1.0d | Done |
| W2-07 | Frontend | 强化绘本阅读器降级态 | 缺图、失败、加载中不出现空白体验 | P0 | 1.0d | Pending | | W2-07 | Frontend | 强化绘本阅读器降级态 | 缺图、失败、加载中不出现空白体验 | P0 | 1.0d | Done |
| W2-08 | Backend | 梳理旧生成 API 兼容层策略 | 保留/标记 deprecated/迁移计划 | P1 | 0.5d | Pending | | W2-08 | Backend | 梳理旧生成 API 兼容层策略 | 保留/标记 deprecated/迁移计划 | P1 | 0.5d | Done |
| W2-09 | Backend | 判断 generation job 是否需要落库 | ADR 或技术说明 | P1 | 0.5d | Pending | | W2-09 | Backend | 判断 generation job 是否需要落库 | ADR 或技术说明 | P1 | 0.5d | Done |
| W2-10 | QA | 补前端关键路径构建与 smoke 验证记录 | Docker build + smoke 输出 | P1 | 0.5d | Pending | | 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-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 | | W2-12 | Docs | 更新 README 的演示前检查流程 | README 本地演示说明 | P1 | 0.5d | Done |

View File

@@ -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: <target>`
- `Link: <target>; rel="successor-version"`
4. 新前端不再新增对旧生成接口的依赖。
## 删除条件
旧接口可以在同时满足以下条件后删除:
- 用户端和管理端都已切到 `/api/generations`
- smoke 脚本只依赖统一入口。
- 后端测试覆盖统一入口的故事、绘本、资产重试和失败降级。
- 至少一个迭代周期没有发现旧接口新增调用。
## 面试表达
我不会为了“代码看起来干净”直接删掉旧接口,因为这会引入不可见风险。更稳妥的做法是先定义目标 API再把旧入口降级为带迁移信号的兼容层最后通过测试和调用方收敛来决定删除时机。

View File

@@ -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 表,会比现在提前设计复杂表结构更稳。

View File

@@ -73,12 +73,37 @@ const themes: ThemeOption[] = [
{ icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' }, { icon: HeartIcon, label: t('home.themeTolerance'), value: '包容' },
] ]
const profileOptions = computed(() => const profileOptions = computed(() =>
profiles.value.map(profile => ({ value: profile.id, label: profile.name })), profiles.value.map(profile => ({ value: profile.id, label: profile.name })),
) )
const universeOptions = computed(() => const universeOptions = computed(() =>
universes.value.map(universe => ({ value: universe.id, label: universe.name })), 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 // Methods
function close() { function close() {
@@ -137,10 +162,8 @@ async function generateStory() {
error.value = '' error.value = ''
try { try {
const requestedOutputMode =
inputType.value === 'full_story' ? 'story' : outputMode.value === 'storybook' ? 'storybook' : 'story'
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
output_mode: requestedOutputMode, output_mode: requestedOutputMode.value,
type: inputType.value, type: inputType.value,
data: inputData.value, data: inputData.value,
education_theme: educationTheme.value || undefined, education_theme: educationTheme.value || undefined,
@@ -150,7 +173,7 @@ async function generateStory() {
if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value if (selectedProfileId.value) payload.child_profile_id = selectedProfileId.value
if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value if (selectedUniverseId.value) payload.universe_id = selectedUniverseId.value
if (requestedOutputMode === 'storybook') { if (requestedOutputMode.value === 'storybook') {
const response = await api.post<any>('/api/generations', payload) const response = await api.post<any>('/api/generations', payload)
storybookStore.setStorybook(response) storybookStore.setStorybook(response)
@@ -191,7 +214,11 @@ async function generateStory() {
></div> ></div>
<!-- 全屏加载动画 --> <!-- 全屏加载动画 -->
<AnalysisAnimation v-if="loading" /> <AnalysisAnimation
v-if="loading"
:title="generationTitle"
:steps="generationSteps"
/>
<!-- 模态框内容 --> <!-- 模态框内容 -->
<div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8"> <div v-else class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[#1C2035] border border-gray-700/50 rounded-3xl shadow-2xl p-6 md:p-8">
@@ -344,8 +371,19 @@ async function generateStory() {
</div> </div>
</Transition> </Transition>
<!-- 提交按钮 --> <!-- 提交按钮 -->
<BaseButton <div class="mb-4 rounded-lg border border-amber-400/20 bg-amber-300/10 px-4 py-3 text-sm text-amber-100 leading-6">
<div class="font-semibold mb-1">
{{ requestedOutputMode === 'storybook' ? '绘本会先保存,再补全插图' : '故事会先可读,再补全封面' }}
</div>
<p>
{{ requestedOutputMode === 'storybook'
? '即使部分插图暂时失败,绘本文字也会保留在故事库,稍后可以继续补全。'
: '封面或语音失败不会影响正文阅读,结果页会给出状态和重试入口。' }}
</p>
</div>
<BaseButton
class="w-full" class="w-full"
size="lg" size="lg"
:loading="loading" :loading="loading"

View File

@@ -1,23 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
const steps = [ const props = withDefaults(defineProps<{
'正在接收梦境信号...', title?: string
'编织故事脉络...', steps?: string[]
'绘制精美插画 (需要一点点魔法时间)...', }>(), {
'撒上一些星光粉...', title: '梦境编织中...',
'即将完成独一无二的绘本!' })
]
const defaultSteps = [
const currentStepIndex = ref(0) '正在接收梦境信号...',
let stepInterval: number | undefined '编织故事脉络...',
'绘制精美插画 (需要一点点魔法时间)...',
onMounted(() => { '撒上一些星光粉...',
stepInterval = window.setInterval(() => { '即将完成独一无二的绘本!'
if (currentStepIndex.value < steps.length - 1) { ]
currentStepIndex.value++
} const currentStepIndex = ref(0)
}, 2500) 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(() => { onUnmounted(() => {
@@ -64,14 +72,14 @@ onUnmounted(() => {
<!-- 文字提示 --> <!-- 文字提示 -->
<div class="z-10 text-center space-y-4"> <div class="z-10 text-center space-y-4">
<h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x"> <h3 class="text-3xl font-bold bg-gradient-to-r from-amber-200 to-orange-100 bg-clip-text text-transparent animate-gradient-x">
梦境编织中... {{ props.title }}
</h3> </h3>
<Transition mode="out-in" name="fade-slide"> <Transition mode="out-in" name="fade-slide">
<p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8"> <p :key="currentStepIndex" class="text-stone-300 text-lg font-medium tracking-wide h-8">
{{ steps[currentStepIndex] }} {{ steps[currentStepIndex] }}
</p> </p>
</Transition> </Transition>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -53,6 +53,27 @@ const storyParagraphs = computed(() => story.value?.story_text?.split('\n\n') ??
const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(story.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(story.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(story.value?.audio_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() { async function refreshStorySnapshot() {
const data = await api.get<Story>(`/api/stories/${route.params.id}`) const data = await api.get<Story>(`/api/stories/${route.params.id}`)
@@ -85,7 +106,7 @@ async function generateImage() {
error.value = '' error.value = ''
try { try {
story.value = await api.post<Story>(`/api/stories/${story.value.id}/assets/retry`, { story.value = await api.post<Story>(`/api/generations/${story.value.id}/retry-assets`, {
assets: ['image'], assets: ['image'],
}) })
} catch (e) { } 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<Story>(`/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() { function togglePlay() {
if (!audioRef.value) return if (!audioRef.value) return
@@ -266,6 +309,16 @@ onUnmounted(() => {
<div class="text-sm text-gray-500 mb-2">封面资源</div> <div class="text-sm text-gray-500 mb-2">封面资源</div>
<div class="font-semibold text-gray-800 mb-2">{{ imageMeta.label }}</div> <div class="font-semibold text-gray-800 mb-2">{{ imageMeta.label }}</div>
<p class="text-sm text-gray-500 leading-6">{{ imageMeta.description }}</p> <p class="text-sm text-gray-500 leading-6">{{ imageMeta.description }}</p>
<BaseButton
v-if="canRetryImage"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4 w-full"
@click="generateImage"
>
{{ story.image_status === 'failed' ? '重试封面' : '补全封面' }}
</BaseButton>
</div> </div>
<div class="rounded-2xl border border-gray-100 bg-white/80 p-5"> <div class="rounded-2xl border border-gray-100 bg-white/80 p-5">
<div class="text-sm text-gray-500 mb-2">音频资源</div> <div class="text-sm text-gray-500 mb-2">音频资源</div>
@@ -273,9 +326,24 @@ onUnmounted(() => {
<p class="text-sm text-gray-500 leading-6"> <p class="text-sm text-gray-500 leading-6">
{{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果 {{ audioMeta.description }} 音频首次生成后会缓存复用状态记录的是当前可播放结果
</p> </p>
<BaseButton
v-if="canRetryAudio"
size="sm"
variant="secondary"
:loading="audioLoading"
class="mt-4 w-full"
@click="retryAudio"
>
{{ story.audio_status === 'failed' ? '重试音频' : '生成音频' }}
</BaseButton>
</div> </div>
</div> </div>
<div class="mb-10 rounded-lg border border-emerald-100 bg-emerald-50/80 p-4 text-sm text-emerald-800 leading-6">
<div class="font-semibold mb-1">资源补全策略</div>
<p>{{ assetGuidance }}</p>
</div>
<div class="prose prose-lg max-w-none mb-10"> <div class="prose prose-lg max-w-none mb-10">
<p <p
v-for="(paragraph, index) in storyParagraphs" v-for="(paragraph, index) in storyParagraphs"
@@ -290,13 +358,13 @@ onUnmounted(() => {
<div v-if="!audioUrl" class="text-center"> <div v-if="!audioUrl" class="text-center">
<BaseButton <BaseButton
:loading="audioLoading" :loading="audioLoading"
@click="loadAudio" @click="story.audio_status === 'ready' ? loadAudio() : retryAudio()"
class="mx-auto" class="mx-auto"
> >
<template v-if="audioLoading">正在准备音频...</template> <template v-if="audioLoading">正在准备音频...</template>
<template v-else> <template v-else>
<SpeakerWaveIcon class="h-5 w-5" /> <SpeakerWaveIcon class="h-5 w-5" />
试听故事 {{ story.audio_status === 'ready' ? '试听故事' : '生成并试听故事' }}
</template> </template>
</BaseButton> </BaseButton>
</div> </div>

View File

@@ -41,6 +41,7 @@ const store = useStorybookStore()
const storybook = computed(() => store.currentStorybook) const storybook = computed(() => store.currentStorybook)
const loading = ref(true) const loading = ref(true)
const imageLoading = ref(false)
const error = ref('') const error = ref('')
const currentPageIndex = ref(-1) const currentPageIndex = ref(-1)
@@ -50,6 +51,11 @@ const isLastPage = computed(() => currentPageIndex.value === totalPages.value -
const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status)) const generationMeta = computed(() => getGenerationStatusMeta(storybook.value?.generation_status))
const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status)) const imageMeta = computed(() => getAssetStatusMeta(storybook.value?.image_status))
const audioMeta = computed(() => getAssetStatusMeta(storybook.value?.audio_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(() => { const currentPage = computed(() => {
if (!storybook.value || isCover.value) return null if (!storybook.value || isCover.value) return null
return storybook.value.pages[currentPageIndex.value] 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<StoryDetailResponse>(
`/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( watch(
() => route.params.id, () => route.params.id,
() => { () => {
@@ -206,6 +248,16 @@ watch(
<div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white"> <div v-else class="w-full h-full flex flex-col items-center justify-center p-8 text-center bg-gradient-to-br from-indigo-900 to-purple-900 text-white">
<SparklesIcon class="w-20 h-20 mb-4 opacity-50" /> <SparklesIcon class="w-20 h-20 mb-4 opacity-50" />
<p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p> <p class="text-white/70 text-sm max-w-xs leading-6">{{ imageMeta.description }}</p>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-5"
@click="retryStorybookImages"
>
{{ storybook.image_status === 'failed' ? '重试插图' : '补全插图' }}
</BaseButton>
</div> </div>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent md:bg-gradient-to-r"></div> <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent md:bg-gradient-to-r"></div>
<div class="absolute bottom-6 left-6 text-white md:hidden"> <div class="absolute bottom-6 left-6 text-white md:hidden">
@@ -249,6 +301,24 @@ watch(
<p class="leading-6">{{ storybook.last_error }}</p> <p class="leading-6">{{ storybook.last_error }}</p>
</div> </div>
<div
v-if="canRetryImages"
class="mb-8 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"
>
<div class="font-semibold mb-1">插图可稍后补全</div>
<p class="leading-6 mb-3">
绘本文字已经保存可以先阅读补全插图会更新封面和缺失页面
</p>
<BaseButton
size="sm"
variant="secondary"
:loading="imageLoading"
@click="retryStorybookImages"
>
{{ storybook.image_status === 'failed' ? '重试全部插图' : '补全全部插图' }}
</BaseButton>
</div>
<BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all"> <BaseButton size="lg" @click="nextPage" class="self-start shadow-xl hover:shadow-2xl hover:-translate-y-1 transition-all">
开始阅读 开始阅读
<BookOpenIcon class="w-5 h-5 ml-2" /> <BookOpenIcon class="w-5 h-5 ml-2" />
@@ -273,6 +343,16 @@ watch(
<p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6"> <p class="text-amber-900/50 text-sm max-w-xs mx-auto italic leading-6">
{{ pageImageMessage }} {{ pageImageMessage }}
</p> </p>
<BaseButton
v-if="canRetryImages"
size="sm"
variant="secondary"
:loading="imageLoading"
class="mt-4"
@click="retryStorybookImages"
>
补全插图
</BaseButton>
<p <p
v-if="storybook.last_error && storybook.image_status === 'failed'" v-if="storybook.last_error && storybook.image_status === 'failed'"
class="mt-3 text-xs font-medium text-amber-700" class="mt-3 text-xs font-medium text-amber-700"