This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions

View File

@@ -40,6 +40,134 @@
- 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。 - 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。
- 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md` - 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`
## 2026-05-05 text-game 作为百梦幕间文字游戏模板接入
- 背景:团队希望参考 MOKU / 幕间类 AI 文游,设计可在百梦内落地的 AI 文字游戏模板,但不能把外部平台社区、支付、榜单、论坛、账号或私有存档迁入 Genarrative。
- 决策:新增 `text-game` 作为百梦 AI 原生文字游戏模板口径,展示名可用“幕间”或“幕间文字”;它与 `visual-novel` 分离,重点是 AI GM、自由行动、状态后果、长期记忆、章节目标和轻量剧本模拟器入口、作品、发布、资产、钱包、埋点、存档和广场全部复用百梦平台接口禁止新增 replay、外部社区、外部支付、外部榜单和私有存档系统。
- 影响范围:后续 `text-game` shared contracts、`module-text-game`、SpacetimeDB 表、`api-server` 路由、前端入口 / workspace / result / runtime、平台作品架和发现聚合。
- 验证方式:后续落地时确认路由使用 `/api/creation/text-game/*``/api/runtime/text-game/*`;确认正式业务真相在 Rust / SpacetimeDB 后端;确认没有 `replay` 能力和外部平台功能误入;确认 `text-game` 不复用 `visual-novel` step 契约作为运行态真相。
- 关联文档:`docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md`
## 2026-05-05 2048 玩法模板采用 `twenty-forty-eight` 工程域
- 背景:平台计划新增 2048 游戏玩法模板,需要同时适配前端 stage、HTTP 路由、Rust 模块、SpacetimeDB 表和公开作品号;裸 `2048` 不适合作为模块或文件命名前缀。
- 决策:面向用户展示名保持 `2048`,工程玩法 ID 固定为 `twenty-forty-eight`Rust 模块与表前缀使用 `twenty_forty_eight`,公开作品号前缀使用 `TF-`;玩法按完整闭环设计,包含 Agent 创作、结果页、试玩、发布、公开运行、后端棋盘裁决、排行榜和作品架 / 广场接入。
- 影响范围:后续 `src/config/newWorkEntryConfig.ts`、平台 `SelectionStage`、前端 `twenty-forty-eight-*` 组件与 service、`module-twenty-forty-eight``shared-contracts``spacetime-module` 表、`spacetime-client` facade、`api-server` 路由、作品号和 PRD 索引。
- 验证方式:后续落地时确认用户可见标题为 `2048`,代码、路由和表统一使用 `twenty-forty-eight` / `twenty_forty_eight`;移动、合并、生成新方块、目标达成、失败和榜单成绩由后端正式裁决,前端不伪造分数或目标达成。
- 关联文档:`docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md`
## 2026-05-05 幸存者类玩法作为平台模板接入
- 背景:平台继续扩展新玩法模板,需要把幸存者 / 割草 / 轻度 Roguelite 类玩法纳入统一创作中心、作品架、广场和运行态体系,避免再起一套独立小游戏工程。
- 决策:新增 `survivor` 作为 Genarrative 平台玩法模板,统一使用 `server-rs + Axum + SpacetimeDB`,创作端、结果页、试玩、发布和运行态都复用平台接口;前端只负责表现和高频模拟,不承接正式规则真相。
- 影响范围:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`、后续 `survivor` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台作品闭环。
- 验证方式:后续落地时检查 `survivor` 入口、session、work profile、runtime run、checkpoint、升级候选和结算接口是否都落在平台统一链路内并确认没有新增独立小游戏壳层。
- 关联文档:`docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md`
## 2026-05-05 视觉小说 TXT 玩法只作为平台模板接入且删除回放
- 背景:`Interactive-fiction-backend``Interactive-fiction-frontend` 是完整平台类工程,其中 TXT / Galgame 玩法可借鉴,但账号、商城、后台、公开市场、回放等平台能力不能迁入 Genarrative。
- 决策:`visual-novel` 只作为 Genarrative 视觉小说模板接入,保留想法 / 文档 / 空白创建、世界观 / 角色 / 场景 / 剧情阶段编辑、视觉小说 step 运行时、历史和重生成等模板能力;入口、作品、发布、资产、钱包、存档和广场全部使用 Genarrative 平台接口;彻底删除回放、分享回放、回放编译、回放路由、回放表和回放 UI。
- 影响范围:视觉小说 PRD、旧 TXT 文档口径、后续 `visual-novel` shared contracts、前端入口 / result / runtime、`server-rs` DDD 分层、SpacetimeDB 表设计和平台存档接入。
- 验证方式:后续落地时扫描前端、后端、契约、表和文档,确认不存在 `replay` 能力;确认视觉小说没有迁入外部平台账号、订单、会员、促销、后台、公开市场或私有存档系统;确认后端落在 `server-rs + Axum + SpacetimeDB`
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md``docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md`
## 2026-05-05 视觉小说 VN-02 表与 spacetime-client facade 收口
- 背景:`visual-novel` 后续 API、创作工作台和运行时需要稳定的 SpacetimeDB schema 与 Rust facade且必须延续“无回放、无私有存档”的产品边界。
- 决策:视觉小说首批数据库只落六张表:`visual_novel_agent_session``visual_novel_agent_message``visual_novel_work_profile``visual_novel_runtime_run``visual_novel_runtime_history_entry``visual_novel_runtime_event``visual_novel_runtime_event``public event` 审计事件表,不是 replay 数据源;运行历史只保存继续体验与历史重生成需要的 typed step 和快照哈希。`api-server` 后续接入必须经 `spacetime-client/src/visual_novel.rs` typed facade不直接依赖生成 bindings。
- 影响范围:`server-rs/crates/spacetime-module/src/visual_novel.rs``migration.rs``server-rs/crates/spacetime-client/src/visual_novel.rs``module_bindings/``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`、VN-05 API 联调。
- 验证方式:执行 `npm run spacetime:generate -- --rust-only``cargo check -p spacetime-module``cargo check -p spacetime-client``npm run check:encoding`;扫描视觉小说 schema / facade / 表目录确认没有 `replay` 表、路由或私有 save 表。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/technical/SPACETIMEDB_TABLE_CATALOG.md`
## 2026-05-05 视觉小说 VN-07 前端创作闭环按阶段边界落地
- 背景:`visual-novel` 模板需要先完成创作工作台与结果页,真实生成和正式玩家 runtime 仍依赖 VN-05 后端路由。
- 决策VN-07 前端只接入口、Agent 工作台、可编辑 `VisualNovelResultDraft` 结果页和测试 run`blank` 起点直接生成本地空白草稿进入结果页,`idea` / `document` 继续调用 `/api/creation/visual-novel/sessions`;结果页保存先更新当前 session 草稿,显式“编译草稿”才调用 `/compile`,测试 run 在真实 runtime 不可用时降级为本地 test run。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/visual-novel-creation/``src/components/visual-novel-result/``packages/shared/src/contracts/visualNovel.ts`、视觉小说 PRD。
- 验证方式:执行前端 typecheck、视觉小说工作台 / 结果页定向测试和编码检查;确认未新增 replay、作品聚合或正式 runtime 能力。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
## 2026-05-07 视觉小说 VN-11 负向扫描门禁
- 背景:视觉小说 TXT 模板进入收口后,需要一个可重复执行的守门方式,避免工程代码误入回放能力或外部平台功能。
- 决策:新增 `npm run check:visual-novel-vn11`,由 `scripts/check-visual-novel-vn11-negative-scan.mjs` 扫描 `src/``packages/shared/src/``server-rs/crates/``docs/``.hermes/shared-memory/`;工程代码中不允许出现 replay / 回放 / 录制 / 复盘类直出命中;外部平台能力误入只在视觉小说实现路径内检查,避免把平台已有账号、会员、后台等能力误判为视觉小说迁入。
- 影响范围:视觉小说 VN-11 验收、后续 `visual-novel` 增量改动、同类新玩法负向扫描脚本。
- 验证方式:执行 `npm run check:visual-novel-vn11`,报告写入 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;当前扫描结论为工程代码无回放类直出命中,视觉小说实现路径无外部平台能力误入。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`
## 2026-05-07 视觉小说 VN-12 采用单独验收门禁脚本
- 背景VN-12 是视觉小说模板的全链路联调与自动化验收收口任务需要把关键路径、API smoke、前端测试和报告输出固化成可复跑门禁避免后续改动只靠手工口述结论。
- 决策:新增 `npm run check:visual-novel-vn12`,由 `scripts/check-visual-novel-vn12-acceptance.mjs` 校验 PRD、VN-11 报告、关键前端测试、视觉小说 service client、`api-server` / `module-visual-novel` / `shared-contracts` 相关文件和路由命中,并生成 `docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`
- 影响范围VN-12 验收、视觉小说后续回归、同类玩法的收口门禁模式。
- 验证方式:执行 `npm run check:visual-novel-vn12 -- --write-report`报告应覆盖自动化验收清单、API smoke、前端关键路径、桌面/移动端检查说明和已执行命令;若脚本失败,直接回流到对应 owner 修复。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`
## 2026-05-07 视觉小说 VN-13 文档与交接收口
- 背景:视觉小说模板主链已经落地完成,需要把 PRD、表目录、prompt 工具说明、负向扫描报告和维护经验收成新开发者可直接接手的一组文档,避免后续仍回头查旧 TXT 迁移方案。
- 决策:视觉小说后续维护的正式入口固定为 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``SPACETIMEDB_TABLE_CATALOG.md``VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md``VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md``VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`;旧 TXT 迁移文档仅保留历史参考地位。
- 影响范围:视觉小说 PRD 收口、技术文档索引、经验文档索引、Hermes 共享记忆和后续维护阅读顺序。
- 验证方式打开上述文档即可获得当前实现边界、表目录、Prompt 口径、负向扫描和维护经验;后续维护不需要把旧 TXT 平台工程文档重新当作实现目标。
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`
## 2026-05-05 平台移动端一级 Tab 改为推荐/发现/创作/草稿/我的
- 背景:移动端平台入口需要从旧“首页/排行/创作/存档/我的”调整为更直接的推荐流和应用式底部导航。
- 决策:前端内部继续复用 `PlatformHomeTab``home/category/create/saves/profile` 状态值,但用户看到的一级 Tab 分别为“推荐/发现/创作/草稿/我的”;`home` 直接展示公开推荐流,`category` 承载发现页及排行子 Tab`saves` 承载草稿作品架,原存档结构并入“我的-玩过”弹层。
- 影响范围:平台入口导航、移动端推荐页、发现页子 Tab、创作中心作品架、个人页玩过弹层、相关设计文档。
- 验证方式:检查移动端底部导航文案和顺序,确认登录态为“推荐/发现/创作/草稿/我的”,未登录态为“推荐/创作/发现”且创作居中;“推荐”无搜索/频道栏直出作品流,“发现”包含搜索/推荐/今日/分类/排行,“创作”只显示新建入口,“草稿”显示作品架,“我的-玩过”可恢复存档。
- 关联文档:`docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md`
## 2026-05-05 创作 Tab 固定为智能创作首页,草稿 Tab 承接旧作品架
- 背景:创作首页需要变成面向对话式生成的智能创作页,旧模板卡和作品架继续保留但不应再占据创作首屏。
- 决策:`create` 只承载 `CreativeAgentHome` 智能创作首页与会话流,顶部品牌栏、问候、快捷胶囊、底部输入框和左侧抽屉是主结构;旧的新建作品类型卡不再在 `create` 里展示。原本的 RPG / 拼图 / 大鱼 / Match3D / 方洞 / 视觉小说作品架统一归到 `saves` 草稿 Tab。
- 影响范围:平台创作页布局、创作首页抽屉、草稿页作品架、相关交互测试、旧创作入口 helper。
- 验证方式:移动端点击“创作”直接看到智能创作首页;点击“草稿”看到旧作品架;旧模板入口不再从创作页出现。
- 关联文档:`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`
## 2026-05-05 创意互动内容生成 Agent 采用 LangChain-Rust 六模块闭环
- 背景:需要支持用户输入文字、图片或文档后,先理解创作意图,再从多个模板候选中选择一个,并把内容填入拼图等目标玩法草稿契约中。
- 决策:新增方案改为基于 LangChain-Rust 的六模块 Agent 架构核心模块是感知、思考、记忆、行动、反思、协作首版只支持拼图玩法但必须先展示多个拼图子模板候选用户选择某个模板后再确认该模板下的关卡模式、关卡数和预计积分范围确认后才生成草稿Agent 理解、规划和修订统一使用 APIMart Responses `gpt-5` 并支持文本/图像多模态输入Agent 创作方式就是填充和修订模板草稿字段,表单化创作页与 Agent 自然语言修订都操作同一份 `PuzzleResultDraft`,且草稿可编辑字段只收敛为 `workTitle``workDescription``workTags``levels[].levelName``levels[].pictureDescription``levels[].pictureReference`;其中 `pictureReference` 已采用 `PuzzleDraftLevel.pictureReference` / Rust `picture_reference` 正式字段方案,不再走 metadata 过渡;单关卡/多关卡图片生成通过拼图模块 Tool 与模板协议实现;生成好的内容必须可立即试玩。
- 影响范围:创作中心入口、`platform-agent``module-creative-agent``module-puzzle` 拼图模板协议和工具、`shared-contracts``api-server` creative facade、SpacetimeDB creative agent 表、拼图玩法工具。
- 验证方式:后续落地时以创意互动内容生成 Agent 技术方案和 Phase 1 PRD 为编码依据,优先完成拼图 Phase 1并执行 shared contracts、module、platform-agent、api-server、前端 typecheck 与编码检查。
- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md``docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`
## 2026-05-05 creative-agent Task C 首版平台 PoC 已落地
- 背景Phase 1 的平台侧需要先把 LangChain-Rust 适配层、APIMart `gpt-5` 多模态 Responses 请求和工具注册边界立起来,才能继续接 API facade。
- 决策:新增 `server-rs/crates/platform-agent` 作为独立 workspace crate保留项目自有 `CreativeAgentExecutor`、工具注册表、回调事件和 mock executor`platform-llm` 的 Responses 请求体扩展为可序列化 `input_text` / `input_image` content part。
- 影响范围:`server-rs/Cargo.toml``server-rs/crates/platform-agent``server-rs/crates/platform-llm`、任务 C 的后续 API / SSE 接入。
- 验证方式:`cargo check -p platform-agent``cargo test -p platform-agent``cargo test -p platform-llm responses_multimodal` 已通过。
- 关联文档:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md`
## 2026-05-05 creative-agent Task E API / SSE facade 已落地
- 背景Phase 1 需要先把创意 Agent 的 HTTP/SSE 门面接入 Rust `api-server`,用于前端工作区调用和拼图模板确认闭环。
- 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner未确认模板前不创建拼图 session`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session``gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client不复用通用 `GENARRATIVE_LLM_API_KEY`
- 影响范围:`server-rs/crates/api-server/src/creative_agent.rs``creative_agent_sse.rs``app.rs``state.rs``module-puzzle` creative template/tool、Phase 1 PRD。
- 验证方式:`cargo check -p api-server``cargo test -p module-puzzle creative``cargo test -p api-server creative_agent``npm run api-server` 后检查 `/healthz``POST /api/runtime/creative-agent/sessions``POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`
- 关联文档:`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`
## 2026-05-07 移动端整页缩放由入口统一锁定
- 背景移动端游戏式页面如果允许浏览器整页缩放容易把固定画布、HUD 和底部操作区一起放大或缩小,破坏操作节奏。
- 决策:主站入口统一使用 `viewport` 锁定 `minimum-scale=1.0``maximum-scale=1.0``user-scalable=no``viewport-fit=cover`,并在应用启动时调用 `lockMobileViewportZoom()` 拦截 iOS `gesture*` 与多指 `touchmove` 触发的页面级缩放。
- 影响范围:主站 `index.html``src/main.tsx`、后续所有依赖主入口的移动端游戏/画布页面;不要求每个画布组件重复实现缩放锁定。
- 验证方式:移动端打开主站后,双指捏合和快速双击不应再缩放整页;单指滚动、点击和组件内交互保持正常。
- 关联文档:`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`
## 2026-05-07 视觉小说 VN-10 资产引用统一走平台资产对象
- 背景:视觉小说文档输入、封面、场景背景、角色立绘和音乐需要接入平台资产链路,不能在前端状态或 SpacetimeDB 中保存大 Data URL、二进制对象或外部 R2 路径。
- 决策VN 上传统一复用 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm``/api/assets/read-url`。文档上传后只把 `assetObjectId` 放入 `sourceAssetIds``seedText` 仅放截断摘要;封面、场景、角色、音乐只写 `/generated-*` 引用和平台 asset id。角色立绘写入 `imageAssets[].source = platform_asset`。运行时图片渲染统一使用 `ResolvedAssetImage` 换签。
- 影响范围:`src/services/visual-novel-creation/visualNovelAssetClient.ts``VisualNovelAgentWorkspace``VisualNovelResultView``VisualNovelRuntimeShell``server-rs/crates/api-server/src/visual_novel.rs`
- 验证方式VN 定向前端测试、`npm run typecheck``npm run check:encoding``cargo test -p api-server visual_novel``cargo test -p api-server creation_agent_document_input`
- 关联文档:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆 ## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes并需要独立拉取仓库、修改代码、本地测试团队希望形成共享的长期项目记忆。 - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes并需要独立拉取仓库、修改代码、本地测试团队希望形成共享的长期项目记忆。

View File

@@ -77,6 +77,16 @@
- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。 - 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md``.codex/skills/gpt-image-2-apimart/SKILL.md` - 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md``.codex/skills/gpt-image-2-apimart/SKILL.md`
## 本地短信登录页签突然消失
- 现象:登录弹窗只剩密码登录,短信登录页签看起来像被删掉,但 `LoginScreen` 中手机号验证码表单仍存在。
- 原因:前端根据 `GET /api/auth/login-options` 返回的 `availableLoginMethods` 渲染页签;常见根因有两类:
- 本地启动脚本没有让 `.env.local` 覆盖 `.env``SMS_AUTH_ENABLED=true` 不生效,后端只返回 `["password"]`
- Rust API 直连已返回 `["phone","password"]`,但 Vite 代理目标指向未监听端口,导致 3000 域名下的 `login-options` 返回 `500``AuthGate` 降级成 `["password"]`
- 处理:优先用 `npm run api-server``npm run dev:rust``npm run dev` 启动,这些入口应保持 shell 环境变量最高优先级,并允许 `.env.local` 覆盖 `.env`;完整栈启动时还要确保脚本计算出的 `RUST_SERVER_TARGET` 不被 `.env.local` 里的旧值覆盖。排查时先请求 3000 域名下的 `/api/auth/login-options`,再直连 Rust API 目标,并核对 `.env.local``SMS_AUTH_ENABLED` 与代理端口。
- 验证:`http://127.0.0.1:3000/api/auth/login-options` 返回至少 `{"availableLoginMethods":["phone","password"]}` 后,登录弹窗会恢复短信登录页签和“获取验证码”按钮。
- 关联:`scripts/api-server-dev.mjs``scripts/api-server-maincloud.mjs``scripts/dev-rust-stack.sh``scripts/dev-web-rust.mjs``docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md`
## Rust 冷编译导致 api-server 健康检查误超时 ## Rust 冷编译导致 api-server 健康检查误超时
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run` - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`
@@ -85,6 +95,73 @@
- 验证:冷启动时不再误杀仍在编译的 api-server。 - 验证:冷启动时不再误杀仍在编译的 api-server。
- 关联:`docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md` - 关联:`docs/technical/API_SERVER_DEV_STACK_COLD_BUILD_TIMEOUT_FIX_2026-04-25.md`
## Windows debug api-server 主线程栈溢出
- 现象:`cargo check -p api-server``build_router` 测试通过,但 `npm run api-server` 在 Windows debug 启动时 `thread 'main' has overflowed its stack`
- 原因:`api-server` Axum 路由树已经很深debug 主线程默认栈偏小,初始化状态和构造路由时容易触顶。
- 处理:入口 `main` 用显式 16MB 栈线程启动 Tokio runtime并把实际服务逻辑放入 `run_server()`;新增路由时优先用小 router `.merge()`,避免继续拉长主链。
- 验证:`npm run api-server``/healthz` 返回 200相关路由冒烟通过。
- 关联:`server-rs/crates/api-server/src/main.rs``server-rs/crates/api-server/src/app.rs`
## Windows debug 长 SSE Future 触发 api-server 断连
- 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream``read ECONNRESET`,随后 `api-server.exe``0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。
- 原因:单个 `async_stream::stream!` 中塞入 Agent 执行、外部模型请求、会话更新和大量 SSE 事件,会在 Windows debug 下生成很大的 Future真实消费 SSE body 时容易触发 worker 线程栈压力或进程级中断,单元测试若只测函数和路由状态会漏掉。
- 处理:长 SSE 路由优先使用 `tokio::spawn` 跑业务流程,通过 `mpsc` + `UnboundedReceiverStream` 向 Axum 返回轻量 stream失败时更新会话为 `failed` 并发送 SSE `error`,不要把大段执行逻辑内联到路由返回的 stream future 中。
- 验证:补充实际 `collect()` SSE body 的路由测试,确认首轮包含 `stage``puzzle_template_catalog``done`,且不会提前发送 `puzzle_template_selection` / `puzzle_cost_range`;再执行 `cargo check -p api-server``cargo test -p api-server creative_agent`,联调时用 `npm run api-server` 检查 `/healthz`
- 关联:`server-rs/crates/api-server/src/creative_agent.rs``server-rs/crates/api-server/src/app.rs`
## creative-agent 过程项不要把历史事件渲染成运行中
- 现象:智能创作页过程中多个阶段从一开始同时转圈,生成结束或进入模板确认后仍有过程项保持转圈。
- 原因:前端把历史 `stage``tool_started``thought_summary_delta` 都按 active 渲染;后端工具开始/完成事件如果 `toolCallId` 不一致,也会导致开始事件无法收口。
- 处理:
- 只有最新且仍在执行的 stage 可为 active等待确认、等待用户、target ready 和 failed 都是静态状态。
- 工具开始事件必须等同一 `toolCallId``tool_completed` 收口;兼容旧流时可按后续同名完成事件兜底。
- 思考摘要只展示用户可见摘要,且流结束或会话进入等待/完成/失败态后必须改成 done。
- 验证:前端测试断言完成后 `CreativeAgentProcessItem` 不再存在 `tone === 'active'`;后端测试确认工具开始/完成事件使用相同 `toolCallId`
- 关联:`src/components/creative-agent/creativeAgentViewModel.ts``server-rs/crates/api-server/src/creative_agent.rs``docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`
## creative-agent 会话切换要清理本地待确认模板
- 现象:用户在一个智能创作会话中点开模板确认面板后,立即切到另一条创作会话,可能看到上一会话的确认面板残留。
- 原因:模板确认面板的 `pendingSelection``CreativeAgentWorkspace` 本地 UI 状态,不属于后端 session 快照;组件复用时如果不监听 `sessionId` 清理,会跨会话泄漏。
- 处理:工作区以 `session?.sessionId` 为边界清空 `pendingSelection`;服务端仍以 `puzzleTemplateSelection` / `targetBinding` 作为正式业务状态。
- 验证:前端测试先点开模板确认面板,再 rerender 到另一 session断言确认面板消失。
- 关联:`src/components/creative-agent/CreativeAgentWorkspace.tsx``src/components/creative-agent/CreativeAgentWorkspace.test.tsx`
## 视觉小说 VN-10 不要绕过平台资产引用
- 现象:文档、封面、场景背景、角色立绘或音乐为了预览方便被写成 Data URL、裸对象路径、外部 URL 或本地临时文件路径。
- 原因前端上传与预览容易混在一起若不走平台资产对象SpacetimeDB 和长期草稿会被大文本或大二进制污染。
- 处理VN 资产统一用 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm`,长期状态只保存 `assetObjectId``/generated-*` 引用;运行时图片用 `ResolvedAssetImage` 换签。
- 验证:文档模式 `sourceAssetIds` 为平台资产 id草稿中不出现 `data:`;图片和音乐字段为平台 generated 引用或 null。
- 关联:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``src/services/visual-novel-creation/visualNovelAssetClient.ts`
## 视觉小说 VN-13 交接时不要再回头找旧迁移方案
- 现象:接手视觉小说的人容易重新打开旧 TXT 迁移文档,把“外部平台工程迁入”误当成当前实现目标。
- 原因:视觉小说历史资料里保留了很多迁移阶段的讨论,而当前真正的实现口径已经收口到 PRD、表目录、Prompt 工具说明、实现收口文档和负向扫描报告。
- 处理:维护视觉小说时优先看 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``SPACETIMEDB_TABLE_CATALOG.md``VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md``VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md``VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`
- 验证:新开发者只读这组文档即可继续维护,不需要把旧 TXT 迁移方案重新当作编码依据。
- 关联:`docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md``docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md`
## 视觉小说公开广场不要触发登录刷新
- 现象:未登录用户进入平台公开广场或从推荐流读取视觉小说公开作品时,前端可能先尝试 `/api/auth/refresh`,失败后再读取公开列表,导致无意义的鉴权噪声或 401 状态刷新。
- 原因:公开只读接口如果复用默认 `requestJson` 选项,缺少 access token 时会先走静默 refresh。
- 处理:视觉小说公开广场列表使用 `skipAuth: true``skipRefresh: true`;鉴权 mutation 仍保持默认鉴权链路。
- 验证:执行 `src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts`,确认 `/api/runtime/visual-novel/gallery` 请求携带 `skipAuth` / `skipRefresh`,而 run、重生成和存档 mutation 仍走受保护路由。
- 关联:`src/services/visual-novel-runtime/visualNovelRuntimeClient.ts``docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`
## 创作 Tab 语义迁移后,旧“新建作品”测试要改看智能创作首页
- 现象:把 `create` 从旧创作中心切到 `CreativeAgentHome` 后,旧测试仍尝试在创作页找“新建作品”类型卡,导致用例失败或定位不到元素。
- 原因:产品语义已经变成“创作 = 智能创作首页,草稿 = 旧作品架”,但测试夹具和 helper 还沿用旧入口。
- 处理:把这类测试改成验证智能创作首页、快捷胶囊、抽屉与草稿 Tab同时给 `useRpgEntryLibraryDetail` 这类恢复路径补上 `setPlatformTabToDraft`
- 验证:定向 `vitest``eslint``typecheck``check:encoding` 都通过。
- 关联:`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx``src/components/rpg-entry/useRpgEntryLibraryDetail.ts`
## server-rs 默认 cargo build 不能等同于构建 SpacetimeDB 模块 ## server-rs 默认 cargo build 不能等同于构建 SpacetimeDB 模块
- 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。 - 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。
@@ -93,6 +170,14 @@
- 验证:查看 `server-rs/Cargo.toml` default-members并按相关 SpacetimeDB 文档执行模块构建。 - 验证:查看 `server-rs/Cargo.toml` default-members并按相关 SpacetimeDB 文档执行模块构建。
- 关联:`server-rs/Cargo.toml``docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md` - 关联:`server-rs/Cargo.toml``docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`
## Rust 构建不要让不可用的 sccache 阻断 rustc
- 现象Cargo 报 `could not execute process sccache ... rustc.exe -vV (never executed)`,真实 `rustc -Vv` 可以执行,但构建在调用包装器时失败。
- 原因:环境或 Jenkinsfile 设置了 `RUSTC_WRAPPER=sccache`,但当前 Windows/Linux agent 上没有可执行的 `sccache`,或 PATH 中的 `sccache` shim 损坏。
- 处理:本地临时排障可执行 `Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue` 后重跑 Cargo生产流水线必须先实际执行 `sccache --version`,失败时移除 `RUSTC_WRAPPER` 并回退到直接 `rustc`
- 验证:`rustc -Vv` 能输出版本;`cargo` 不再尝试调用不可用的 `sccache`Jenkins 日志出现“未找到可用 sccache改用 rustc 直接构建”后仍继续真实构建。
- 关联:`jenkins/Jenkinsfile.production-stdb-module-build``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本 ## 生产发布入口不要沿用旧 Jenkinsfile / 一体化脚本
- 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。 - 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。

View File

@@ -13,7 +13,7 @@
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。 - [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。 - [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。 - [PRD](./prd/README.md):产品需求与阶段计划;参考 MOKU / 幕间类 AI 文游的百梦 `text-game` 模板口径见 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md),视觉小说模板 TXT 玩法平台化接入口径见 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md),创意互动内容 Agent Phase 1 的 LangChain-Rust PoC、拼图闭环和并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md),幸存者类模板闭环见 [AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md](./prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md)后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md),方洞挑战创作、发布与试玩闭环见 [AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md](./prd/AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。 生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
@@ -23,6 +23,14 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
`maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run api-server``/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)。 `maincloud` 相关脚本、环境变量、测试名和文档要求已统一判定为历史残留,后续禁止新增、运行或引用;当前后端 smoke 使用 `npm run api-server``/healthz`,详细规则见 [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md)。
基于 LangChain-Rust以“感知—思考—记忆—行动—反思—协作”闭环完成图文创意理解、拼图模板选择、积分范围确认、拼图草稿字段填充、立即试玩和自然语言修订草稿字段的 Agent 方案见 [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md)。
创意互动内容 Agent Phase 1 的产品边界、实现细节、SpacetimeDB 落点、前端接入和可并行任务拆分见 [CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md](./prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md)。
视觉小说模板接入以 [AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md](./prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为最新口径:只吸收外部 TXT 玩法的模板创作与运行经验,禁止迁入外部平台功能,并删除回放。
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验禁止迁入外部社区、支付、榜单、私有存档或回放。
## 推荐阅读顺序 ## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -19,6 +19,8 @@
- [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。 - [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。
- [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md)RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。 - [RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md](./RPG_RUNTIME_DIRECT_DRAFT_PROFILE_AUDIT_2026-04-25.md)RPG 运行时进入世界时改为直读 Agent session 草稿 profile 的链路检查。
- [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md)RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。 - [RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md](./RPG_WORLD_DRAFT_EDIT_AUTOSAVE_OVERRIDE_AUDIT_2026-04-28.md)RPG 世界草稿结果页编辑后被旧设定覆盖的前端本地态、session 真相源与自动保存链路审计。
- [VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md](./VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md):视觉小说 VN-11 回放删除与外部平台功能误入负向扫描报告。
- [VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md](./VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md):视觉小说 VN-12 全链路联调与自动化验收报告。
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。 - [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md)RPG 前端脚本中仍应迁到 `server-rs` / SpacetimeDB 的开局、快照、story engine、战斗、NPC/背包规则与创作残留后门审计。
- [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)RPG 前端脚本后端迁移完成度复核标明开局、快照、story engine / prompt context、`camp_travel_home_scene`、战斗、NPC、背包/锻造、结果页保存 normalize 与角色资产 prompt 主链均已收口。 - [engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md](./engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_COMPLETION_CHECK_2026-04-28.md)RPG 前端脚本后端迁移完成度复核标明开局、快照、story engine / prompt context、`camp_travel_home_scene`、战斗、NPC、背包/锻造、结果页保存 normalize 与角色资产 prompt 主链均已收口。
- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。

View File

@@ -0,0 +1,32 @@
# VN-11 负向扫描报告
生成日期2026-05-07
## 扫描范围
- 工程代码:`src/``packages/shared/src/``server-rs/crates/`
- 文档与共享记忆:`docs/``.hermes/shared-memory/`
- 外部平台误入复核视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径
## 扫描结论
- 工程代码回放类直出命中0
- 文档 / 共享记忆回放类命中222
- 视觉小说实现路径外部平台能力疑似误入命中0
## 处理记录
- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。
- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。
- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。
## 文档命中说明
- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。
## 门禁命令
```bash
npm run check:visual-novel-vn11
```

View File

@@ -0,0 +1,92 @@
# VN-12 全链路联调与自动化验收报告
生成日期2026-05-07
## 结论
- 状态:通过
- 失败项0
- 收口说明VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。
## 自动化验收清单
- docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md
- docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md
- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx
- src/components/visual-novel-result/VisualNovelResultView.test.tsx
- src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx
- src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts
- src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
- server-rs/crates/api-server/src/visual_novel.rs
- server-rs/crates/module-visual-novel/src/application.rs
- server-rs/crates/shared-contracts/src/visual_novel.rs
- package.json
- server-rs/crates/api-server/src/app.rs
- src/services/visual-novel-runtime/visualNovelRuntimeClient.ts
- src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts
- src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx
- src/components/visual-novel-result/VisualNovelResultView.test.tsx
- src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx
## API smoke
- `/api/creation/visual-novel/sessions`
- `/api/creation/visual-novel/works`
- `/api/runtime/visual-novel/gallery`
- `/api/runtime/visual-novel/works/{profile_id}/runs`
- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`
- `/api/runtime/visual-novel/runs/{run_id}/history`
- `/api/runtime/visual-novel/runs/{run_id}/regenerate`
- `/api/profile/save-archives`
- `/api/profile/save-archives/{world_key}`
- `/api/runtime/save/snapshot`
本次实测:
- `npm run api-server` 可启动 Rust `api-server`
- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`
- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。
## 前端关键路径
- 创作工作台:`VisualNovelAgentWorkspace`
- 结果页:`VisualNovelResultView`
- 运行时:`VisualNovelRuntimeShell`
- 运行时 SSE`visualNovelRuntimeSse` / `visualNovelRuntimeClient`
## 桌面 / 移动端检查
- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png`
- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png`
- in-app browser 插件本次未发现可用 IAB backend截图使用本机 Edge headless 兜底完成。
## 校验摘要
- package.json scripts: 通过
- api-server visual novel routes: 通过
- visual novel runtime client routes: 通过
- visual novel runtime client tests: 通过
- visual novel SSE tests: 通过
- visual novel creation tests: 通过
- visual novel result tests: 通过
- visual novel runtime tests: 通过
## 执行命令
```bash
npm run check:visual-novel-vn12 -- --write-report
npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts
npm run check:encoding
npm run typecheck
cd server-rs
cargo test -p shared-contracts
cargo test -p module-visual-novel
cargo check -p api-server
```
## 未覆盖风险
- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。
- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -28,3 +28,12 @@
- 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。 - 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。
- RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。 - RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。
- 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。 - 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。
## 2026-05-07 玩法参考图
1. 每个玩法入口都必须配置一张 `public/creation-type-references/` 下的参考图。
2. 当前创作 Tab 顶部玩法卡带、旧创作中心卡带和玩法类型弹层都消费同一份 `PLATFORM_CREATION_TYPES.imageSrc`,避免多入口视觉漂移。
3. 图片只承担玩法识别和氛围锚定,不在卡片上叠加规则说明文案。
4. 移动端卡片仍以紧凑横滑为主,参考图使用暗色遮罩承接标题,文本不得溢出卡片。
5. 当前创作 Tab 的可见玩法卡必须真实渲染 `img`,不能只在隐藏弹窗或旧入口中配置图片。
6. 参考图卡片上的标题和副标题必须显式使用白色文字,并配合底部加深渐变与文字阴影;禁止依赖 `text-inherit`,避免黑字叠在暗蒙版上。

View File

@@ -6,8 +6,8 @@
- 新增“分类” Tab用作品标签聚合所有公开发布作品。 - 新增“分类” Tab用作品标签聚合所有公开发布作品。
- 强化“创作” Tab 的导航视觉权重,让它在底部导航中居中并更醒目。 - 强化“创作” Tab 的导航视觉权重,让它在底部导航中居中并更醒目。
- 登录态底部导航顺序为:首页、分类、创作、存档、我的。 - 登录态底部导航顺序为:推荐、发现、创作、草稿、我的。
- 未登录态底部导航只保留:首页、创作、分类,其中创作保持居中。 - 未登录态底部导航只保留:推荐、创作、发现,其中创作保持居中。
## 2. 数据边界 ## 2. 数据边界
@@ -24,10 +24,10 @@
底部导航展示 5 个入口: 底部导航展示 5 个入口:
1. 首页 1. 推荐
2. 分类 2. 发现
3. 创作 3. 创作
4. 存档 4. 草稿
5. 我的 5. 我的
创作入口位于第三位,视觉上使用更大的图标壳、轻微上浮、渐变高亮和阴影,保证它是主行动入口。 创作入口位于第三位,视觉上使用更大的图标壳、轻微上浮、渐变高亮和阴影,保证它是主行动入口。
@@ -36,11 +36,11 @@
底部导航展示 3 个入口: 底部导航展示 3 个入口:
1. 首页 1. 推荐
2. 创作 2. 创作
3. 分类 3. 发现
不展示“存档”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中。 不展示“草稿”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中,并沿用原推荐 Tab 的星光图标;推荐 Tab 改用游戏手柄图标
### 3.3 桌面端 ### 3.3 桌面端
@@ -59,6 +59,6 @@
- 登录态移动端底部导航顺序准确,创作在 5 个 Tab 中居中。 - 登录态移动端底部导航顺序准确,创作在 5 个 Tab 中居中。
- 未登录态移动端底部导航只显示 3 个 Tab创作在中间。 - 未登录态移动端底部导航只显示 3 个 Tab创作在中间。
- 分类 Tab 能按标签切换并展示公开作品。 - 发现 Tab 能按标签切换并展示公开作品。
- 创作 Tab 在移动端和桌面端都比普通 Tab 更醒目。 - 创作 Tab 在移动端和桌面端都比普通 Tab 更醒目。
- 不修改 server-node不新增后端逻辑。 - 不修改 server-node不新增后端逻辑。

View File

@@ -0,0 +1,46 @@
# 平台创作 Tab 模板入口设计
更新时间:`2026-05-07`
## 1. 目标
创作 Tab 恢复为模板选择入口,但不回到旧的大卡片选择面板:
1. 首屏保留现有创作页布局骨架顶部标题固定为“10分钟创作一个精品互动玩法”。
2. 选择模板入口改为横向 Tab数据来自 `src/config/newWorkEntryConfig.ts` 的可见玩法配置。
3. 默认选中“拼图”模板,并在创作 Tab 内直接展示拼图创作表单。
4. 智能创作入口从可见模板中隐藏,保留既有 `creative-agent` 运行链路用于后续内部恢复或草稿目标打开。
5. 草稿、发现、我的等一级 Tab 职责不变,作品管理仍在草稿 Tab。
## 2. 页面结构
移动端和桌面端共用同一信息结构:
```text
标题10分钟创作一个精品互动玩法
模板 Tab拼图 / 方洞挑战 / 视觉小说 / AIRP
默认内容:拼图创作表单
```
拼图表单嵌入创作 Tab 时:
- 不展示工作台返回按钮。
- 不重复展示“创建拼图”标题。
- 保留表单内的拼图模板、参考图上传、画面描述和生图模型选择。
- 生成草稿仍走登录保护,未登录时先触发登录流程。
## 3. 交互
1. 打开“创作”一级 Tab 时默认停留在拼图 Tab不主动创建拼图 session。
2. 点击拼图表单“生成草稿”后,才创建拼图 session 并执行 `compile_puzzle_draft`
3. 拼图表单内的模板按钮使用 `tablist / tab` 语义,点击后只填充画面描述。
4. 点击非拼图且已开放的模板 Tab 时,进入该玩法既有工作台;未开放模板保持禁用。
5. `creative-agent` 不出现在模板 Tab 和选择弹层中,不再作为创作 Tab 首屏入口。
## 4. 验收
1. 点击“创作”后首屏出现“10分钟创作一个精品互动玩法”。
2. 顶部选择模板入口为 Tab拼图 Tab 默认 `aria-selected=true`
3. 创作 Tab 默认显示拼图创作表单内容且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。
4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。
5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。

View File

@@ -0,0 +1,90 @@
# 平台移动端推荐、发现与草稿 Tab 改版设计
更新时间:`2026-05-05`
## 1. 目标
本次只调整平台入口的信息架构与移动端视觉,不新增后端接口:
1. 原“首页”一级 Tab 对用户改名为“推荐”,进入后直接展示原首页推荐榜单启动后的公开游戏内容流。
2. 原“首页”中的搜索、今日游戏、游戏分类等探索内容移动到第二个一级 Tab第二个 Tab 对用户命名为“发现”。
3. 原“排行”页内容并入“发现”页的子 Tab 中,不再作为一级主 Tab 独立展示。
4. 创作页只保留新建创作入口;原创作页作品列表拆到一级“草稿” Tab替换原“存档” Tab。
5. 原“存档”列表结构并入“我的”页面的“玩过”列表弹层,作为每个已玩作品的可继续存档入口。
6. 移动端推荐页与底部 Tab 栏参考用户给定样式,使用大画面推荐流、顶部品牌/通知、悬浮胶囊底部导航;保留当前平台已有明暗两套主题色 token。
## 2. 状态映射
为降低迁移风险,前端内部 `PlatformHomeTab` 仍复用既有状态值:
- `home`:用户看到“推荐”。
- `category`:用户看到“发现”,内容包含搜索、今日游戏、分类和排行子 Tab。
- `create`:用户看到“创作”,只承担新建入口。
- `saves`:用户看到“草稿”,承载原创作页作品列表。
- `profile`:用户看到“我的”,其中“玩过”弹层合并存档入口。
`category` 状态此前承载“排行”,本次不改状态名,只改用户文案和页面内容,避免详情页返回目标、测试辅助和历史路由状态大范围迁移。
## 3. 推荐页
移动端推荐页默认不展示搜索和频道横滑条,进入一级“推荐”后直接渲染公开作品流:
- 数据来源沿用 `featuredEntries + latestEntries` 去重后的公开作品列表。
- 卡片保留现有作品读模型字段:封面、作者、游玩、改造、点赞、标签。
- 移动端推荐卡使用近全屏大画面比例,底部展示互动指标、作者和主操作,不写规则说明类文案。
- 无数据、加载中和错误态沿用短状态文案。
桌面端仍保持现有首页布局,只把一级导航文案从“首页”改为“推荐”。
## 4. 发现页
发现页承接原首页探索能力和原排行能力,子 Tab 为:
1. 推荐:原首页推荐内容流,可作为发现页内的快速回看。
2. 今日:原首页“今日游戏”。
3. 分类:原首页“游戏分类”。
4. 排行:原一级“排行”页四榜切换。
移动端发现页顶部保留搜索框和子 Tab分类内容继续使用纵向应用商店式列表排行内容继续使用榜单行。
## 5. 创作与草稿
创作页只保留新建创作入口:
- 继续复用 `CustomWorldCreationStartCard` 和现有创作类型弹窗。
- 不在创作页下方展示作品列表,避免“创作入口”和“作品管理”挤在同一首屏。
草稿页复用创作中心作品架:
- 默认显示全部作品列表,保留草稿/已发布筛选。
- 入口、删除、分享、领取拼图激励等行为全部复用现有 `CustomWorldCreationHub` 的作品卡逻辑。
- 一级底部 Tab 文案为“草稿”,内部仍可按草稿与已发布筛选。
## 6. 我的页玩过列表
“我的”页面的“玩过”列表弹层合并存档结构:
- 顶部仍展示总游戏时长。
- 列表先展示可恢复存档,使用原 `SaveArchiveCard` 的字段结构和恢复行为。
- 再展示已玩作品统计列表,保持作品号、最近时间和时长。
- 若某个存档被点击,必须继续走既有后端恢复接口,不在前端拼接运行态。
## 7. 验收
1. 移动端底部导航显示“推荐 / 发现 / 创作 / 草稿 / 我的”,未登录时显示“推荐 / 创作 / 发现”。
2. 点击“推荐”直接看到公开作品推荐流,不再先看到搜索框和频道 Tab。
3. 点击“发现”可看到搜索、推荐、今日、分类、排行子 Tab。
4. 点击“草稿”看到原创作页作品列表。
5. 点击“创作”只看到新建创作入口。
6. “我的”里的“玩过”弹层包含原存档列表入口,点击存档能继续恢复。
7. 移动端底部导航为悬浮胶囊样式,保留当前明暗主题色变量,不新增第三套主题。
## 8. 2026-05-07 未登录三栏补充
未登录状态下底部导航只显示 3 个入口,顺序调整为:
1. 推荐
2. 创作
3. 发现
创作 Tab 必须位于中间,并使用原推荐 Tab 的星光图标,保持几何和视觉上的主行动入口。推荐 Tab 改用游戏手柄图标,避免与创作图标重复。

View File

@@ -12,6 +12,8 @@
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。 - [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
- [MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md](./MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md):移动端创作页作品列表至少 2 列的紧凑布局设计。 - [MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md](./MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md):移动端创作页作品列表至少 2 列的紧凑布局设计。
- [PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md](./PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md):平台首页移动端参考图式信息流、双端公开作品卡 16:9 封面结构与点赞数读模型设计。 - [PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md](./PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md):平台首页移动端参考图式信息流、双端公开作品卡 16:9 封面结构与点赞数读模型设计。
- [PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md](./PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md):平台移动端推荐、发现、创作、草稿、我的 Tab 重新分工与推荐页/底部导航改版设计。
- [PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md](./PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md):平台创作 Tab 恢复模板 Tab 入口、默认选中拼图并内嵌拼图创作表单的布局设计。
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。 - [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。 - [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。 - [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。

Binary file not shown.

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

View File

@@ -221,3 +221,8 @@
- 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。 - 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。
- 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId``monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc``visual`,就按场景 NPC 自定义形象锚点处理。 - 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId``monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc``visual`,就按场景 NPC 自定义形象锚点处理。
- 幕预览运行时还会构造“无 `characterId`、但有 `visual` 的场景 NPC”这类和平相遇分支同样必须套用场景 NPC 自定义形象锚点,否则会停在画面中上部。 - 幕预览运行时还会构造“无 `characterId`、但有 `visual` 的场景 NPC”这类和平相遇分支同样必须套用场景 NPC 自定义形象锚点,否则会停在画面中上部。
### 10.3 移动端固定整页画布缩放
- 主站移动端以固定游戏画布体验为准,入口 `viewport` 需要锁定 `minimum-scale=1.0``maximum-scale=1.0``user-scalable=no`,同时保留 `viewport-fit=cover` 适配安全区。
- 浏览器仍可能通过 iOS `gesture*` 或多指 `touchmove` 触发整页缩放,因此主站启动入口应统一调用 `lockMobileViewportZoom()` 拦截页面级捏合与快速双击缩放。
- 不要在每个画布组件里重复注册缩放拦截;单指滚动、点击、拖拽应继续留给具体页面和玩法处理。

View File

@@ -29,6 +29,7 @@
- [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。 - [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。
- [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。 - [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。 - [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
- [VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md](./VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md):记录视觉小说模板交接时的阅读顺序、常见坑和维护检查口径。
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。 - [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。 - [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。
- [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。 - [BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md](./BIG_FISH_WORKS_JSON_COMPAT_FIX_2026-04-28.md):记录大鱼作品列表 `items_json` 字段升级后的向后兼容修复口径,避免旧 JSON 直接打崩 works 接口。

View File

@@ -0,0 +1,48 @@
# 视觉小说模板交接与维护经验 2026-05-07
## 1. 先读什么
新开发者接手视觉小说时,建议按这个顺序看:
1. [AI 原生视觉小说模板 PRD](../prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md)
2. [视觉小说模板实现收口与交接说明](../technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md)
3. [SpacetimeDB 表说明与查询目录](../technical/SPACETIMEDB_TABLE_CATALOG.md)
4. [视觉小说 VN-03 Prompt 与 LLM 工具实现说明](../technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md)
5. [视觉小说 VN-11 负向扫描报告](../audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md)
## 2. 最容易踩的坑
1. 不要把 `visual_novel_runtime_history_entry` 当回放表来扩。
2. 不要把 `visual_novel_runtime_event` 当业务回放数据源来用。
3. 不要绕过平台资产对象去保存图片、音乐或文档。
4. 不要让旧 TXT 迁移文档重新变成实现口径。
5. 不要忘记发布后刷新作品架和公开聚合。
6. 不要忘记退出登录时清理视觉小说私有状态。
## 3. 维护时的判断顺序
1. 先看是不是共享契约变化。
2. 再看是不是 SpacetimeDB 表或 facade 变化。
3. 然后看是不是作品架、广场或 runtime 的前端分流变化。
4. 最后才看文档措辞和历史说明。
## 4. 常用检查
```bash
npm run check:encoding
npm run check:visual-novel-vn11
npm run typecheck
```
如果改了后端,再补:
```bash
cd server-rs
cargo test -p shared-contracts
cargo test -p module-visual-novel
cargo check -p api-server
```
## 5. 一句话结论
视觉小说模板已经是平台内的正式模板玩法,不是外部平台迁移;后续维护只需要沿着 PRD、表目录、prompt 文档和这份交接说明往下走。

View File

@@ -0,0 +1,863 @@
# AI 原生 2048 游戏玩法模板 PRD
更新时间:`2026-05-05`
## 0. 文档目的
这份 PRD 用于在当前平台内新增一条 `2048` 游戏玩法模板并冻结它从创作入口、Agent 生成、结果页编辑、试玩、发布、公开运行到作品架展示的完整产品边界。
本次不是只做一个浏览器本地 2048 小游戏,也不是把经典 2048 规则随手写进某个前端组件。正式落地时,`2048` 必须作为平台内独立玩法类型接入现有创作中心、作品、广场、运行态和后端 DDD 分层。
---
## 1. 一句话定义
`2048` 是一个主题化数字合成玩法模板:百梦主通过 Agent 设定棋盘主题、合成链、视觉皮肤、目标格和难度参数,系统生成可试玩、可发布的 2048 作品;玩家通过滑动方向移动棋盘格,相同等级方块合并升级,直到达成目标格或棋盘无可行动作。
---
## 2. 当前接入级别
根据 `genarrative-play-type-integration` 的接入分级,本玩法按 **完整玩法闭环** 设计:
1. 新增玩法 ID`twenty-forty-eight`
2. 展示名称:`2048`
3. 子标题:`主题合成棋盘`
4. 新建作品入口可展示,开放节奏由 `src/config/newWorkEntryConfig.ts` 控制。
5. 支持 Agent 创作、草稿生成、结果页编辑、试玩、发布、公开运行、作品架和广场。
6. 后端以 `server-rs + Axum + SpacetimeDB` 为服务侧真相源。
7. 前端负责棋盘渲染、输入采集和动画表现,不绕过后端保存正式运行结果、榜单、扣费和发布状态。
工程命名采用 `twenty-forty-eight` 而不是裸 `2048`,避免 TypeScript、Rust 模块、文件名和路由中出现数字开头命名;面向用户的标题始终显示 `2048`
---
## 3. 产品定位
## 3.1 模板名称
1. 对外模板名称:`2048`
2. 对外子标题:`主题合成棋盘`
3. 开发代号:`TwentyFortyEight`
4. 工程玩法域:`twenty-forty-eight`
5. 后端模块命名:`twenty_forty_eight`
6. 公开作品号前缀:`TF-`
## 3.2 核心乐趣
1. 玩家通过一次滑动改变整盘局势。
2. 相同方块合并升级,形成清晰的成长反馈。
3. 主题化合成链让数字升级变成可感知的叙事或收藏序列。
4. 棋盘越来越拥挤,玩家在空间管理和短期收益之间做取舍。
5. 达成目标格后可以继续冲击更高分,也可以结算分享成绩。
## 3.3 与现有玩法的区别
1. 不等同于拼图:不切图、不交换、不合并图片碎片。
2. 不等同于抓大鹅:不做三消备选栏,不做物体堆叠点击。
3. 不等同于方洞挑战:不做单次投放裁决,不靠反直觉规则制造误导。
4. 不等同于大鱼吃小鱼:不做实时移动、吞噬和碰撞成长。
5. 不复用 RPG 的世界、角色、章节或剧情推进结构。
---
## 4. 完整闭环目标
本玩法完整闭环必须补齐:
1. 平台创作中心选择 `2048`
2. Agent 对话收集主题、合成链、视觉风格、目标格和难度。
3. 生成 2048 草稿。
4. 进入结果页编辑作品名、简介、标签、封面、棋盘配置和方块皮肤。
5. 支持发布前试玩。
6. 发布作品。
7. 玩家从作品详情、广场或作品架进入运行态。
8. 后端初始化 run保存种子、当前棋盘、分数、目标、状态和动作序列摘要。
9. 玩家滑动方向后,前端提交动作,后端裁决新棋盘;前端按返回快照播放移动与合并动画。
10. 达成目标、继续挑战、失败结算和排行榜写入都由后端正式裁决。
11. 前端不得自行提交伪造分数、目标达成或榜单记录。
---
## 5. 明确不做
首版不做:
1. 不做多人实时对战。
2. 不做复杂技能树或 RPG 数值养成。
3. 不做可破坏障碍、棋盘机关和随机事件。
4. 不支持玩家自定义任意 JavaScript 规则。
5. 不在 UI 中默认展示长篇玩法说明、规则描述或内部字段解释。
6. 不新增独立于平台之外的 2048 站点。
7. 不复用 `customWorld``rpgWorld` 或旧 `server-node` 命名承载 2048 业务。
8. 不把 LLM、生图、OSS 上传放进 SpacetimeDB reducer。
---
## 6. 创作锚点设计
Agent 型创作至少收集下面 5 个锚点:
| 锚点 | 字段建议 | 用途 |
| --- | --- | --- |
| 合成主题 | `themePrompt` | 决定棋盘整体题材,例如修仙境界、猫咪成长、城市建设、料理升级。 |
| 合成链 | `tileLadder` | 决定从 `2` 到目标格的每一级显示名、图标提示和视觉差异。 |
| 视觉皮肤 | `visualStyle` | 决定方块材质、色彩、背景、动效气质和封面方向。 |
| 难度参数 | `difficultyConfig` | 决定棋盘尺寸、目标格、初始方块、生成概率和是否允许撤销。 |
| 反馈节奏 | `feedbackRhythm` | 决定移动、合并、升级、目标达成、失败结算的反馈强度。 |
Agent 行为要求:
1. 优先接住百梦主的一句话灵感,不把创作变成问卷。
2. 每轮最多追问 1 个最影响成品质量的问题。
3. 当主题和合成链已经足够明确时,优先生成草稿。
4. 合成链必须围绕同一主题递进,不允许出现互不相关的方块名。
5. 进入结果页前至少生成 `2` 到目标格之间的完整等级定义。
---
## 7. 玩法规则
## 7.1 棋盘
首版默认支持:
1. `4x4` 标准棋盘。
2. 可选 `5x5` 放松棋盘。
3. 可选 `3x3` 高压棋盘。
发布默认值:
1. `boardSize = 4`
2. `targetTileValue = 2048`
3. 初始方块数量为 `2`
4. 新方块生成值为 `2``4`
5. `2` 的生成概率为 `90%``4` 的生成概率为 `10%`
## 7.2 输入
玩家每次只能提交一个方向:
```ts
type TwentyFortyEightMoveDirection = 'up' | 'down' | 'left' | 'right';
```
交互支持:
1. 移动端滑动。
2. 桌面端方向键。
3. 桌面端按钮或触控手势兜底。
无效输入规则:
1. 如果某方向不会改变棋盘,该动作不消耗步数。
2. 无效动作返回当前快照和 `moveAccepted = false`
3. 前端只展示轻量反馈,不弹长说明。
## 7.3 合并
合并规则遵循经典 2048
1. 同一行或列按移动方向压缩。
2. 相邻且同值的两个方块合并成一个更高值方块。
3. 每个方块在一次移动中最多合并一次。
4. 合并后的方块值为原值的 `2` 倍。
5. 本次得分增加合并后方块值。
6. 移动完成后,如果棋盘有变化,随机空格生成一个新方块。
规则核心应封装为可测试的领域引擎。实现时优先评估成熟 2048 规则库;如 Rust / TypeScript 生态无合适库,必须把自研规则收敛在 `module-twenty-forty-eight` 的纯函数内,并用黄金用例、属性测试和前后端 fixture 保证一致。
## 7.4 胜负
状态取值:
```ts
type TwentyFortyEightRunStatus =
| 'playing'
| 'target_reached'
| 'continued_after_target'
| 'game_over'
| 'abandoned';
```
规则:
1. 首次出现 `targetTileValue` 时进入 `target_reached`
2. 玩家可选择结算,或继续挑战。
3. 继续挑战后状态变为 `continued_after_target`
4. 棋盘无空格且四个方向都无法移动时进入 `game_over`
5. 玩家主动退出未结算时为 `abandoned`
---
## 8. 草稿与结果页
## 8.1 结果页定位
2048 结果页是发布前的最小工作台,承担:
1. 编辑作品基本信息。
2. 编辑棋盘参数。
3. 编辑合成链显示名和视觉提示。
4. 生成或上传封面。
5. 进入试玩。
6. 发布作品。
## 8.2 必备字段
```ts
interface TwentyFortyEightResultDraft {
workTitle: string;
workDescription: string;
workTags: string[];
coverImageSrc: string | null;
coverAssetId: string | null;
themePrompt: string;
visualStyle: string;
boardConfig: TwentyFortyEightBoardConfig;
tileLadder: TwentyFortyEightTileDefinition[];
scoringConfig: TwentyFortyEightScoringConfig;
}
interface TwentyFortyEightBoardConfig {
boardSize: 3 | 4 | 5;
targetTileValue: 512 | 1024 | 2048 | 4096 | 8192;
initialTileCount: 2 | 3 | 4;
spawnValueWeights: Array<{ value: 2 | 4; weight: number }>;
allowUndo: boolean;
maxUndoCount: number;
}
interface TwentyFortyEightTileDefinition {
value: number;
label: string;
shortLabel: string;
colorToken: string;
iconPrompt: string;
imageSrc: string | null;
assetId: string | null;
}
interface TwentyFortyEightScoringConfig {
scoreMode: 'classic';
leaderboardMetric: 'score_then_steps' | 'score_then_time';
}
```
## 8.3 字段约束
1. `workTitle` 必填,建议 `4~16` 个中文字符。
2. `workDescription` 必填,建议 `12~80` 个中文字符。
3. `workTags` 必须为 `3~6` 个中文短标签。
4. `boardSize` 首版只能是 `3``4``5`
5. `targetTileValue` 必须存在于 `tileLadder`
6. `tileLadder` 必须从 `2` 开始按倍增连续覆盖到目标格。
7. `shortLabel` 移动端格子内最多建议 `4` 个中文字符。
8. `spawnValueWeights` 权重和必须大于 `0`,归一化后用于后端生成。
9. `allowUndo = true``maxUndoCount` 必须在 `1~3`
## 8.4 结果页 UI
结果页采用移动端优先的页签结构:
1. `基本信息`:标题、简介、标签。
2. `棋盘`:棋盘尺寸、目标格、撤销次数、生成概率。
3. `合成链`:每级方块的显示名、短标签、颜色和图标预览。
4. `封面`:封面预览、生成、上传或历史素材选择。
交互要求:
1. 发布按钮放在结果页右下操作区,移动端固定在底部安全区。
2. 试玩按钮与发布按钮并列,但试玩不触发发布阻断。
3. 发布校验只在点击发布后进入独立发布面板展示。
4. 合成链编辑使用独立面板或抽屉,不在当前列表下方展开大表单。
5. 页面不默认展示玩法规则说明。
---
## 9. 作品发布与广场
## 9.1 发布阻断
发布前必须校验:
1. 作品标题非空。
2. 简介非空。
3. 标签数量为 `3~6`
4. 棋盘配置合法。
5. 合成链完整覆盖到目标格。
6. 目标格存在。
7. 封面存在。
8. 作者信息可读。
## 9.2 作品摘要
```ts
interface TwentyFortyEightWorkSummary {
workId: string;
profileId: string;
ownerUserId: string;
authorDisplayName: string;
gameName: string;
summary: string;
tags: string[];
coverImageSrc: string;
boardSize: 3 | 4 | 5;
targetTileValue: number;
bestScore: number | null;
playCount: number;
likeCount: number;
sourceSessionId: string;
publicationStatus: 'draft' | 'published';
updatedAt: string;
publishedAt: string | null;
}
```
## 9.3 广场卡片
广场卡片至少展示:
1. 封面。
2. 作品名。
3. 作者名。
4. 标签。
5. 棋盘规格和目标格。
6. 进入游戏按钮。
不在卡片内展示完整规则说明。
---
## 10. 运行态设计
## 10.1 首屏
运行态首屏必须直接展示棋盘:
1. 顶部 HUD分数、最高格、目标格、步数。
2. 中部正方形棋盘。
3. 底部轻量操作区:撤销、重新开始、退出。
4. 移动端棋盘尽量贴近屏幕两侧安全边界。
5. 桌面端棋盘居中,不使用营销式大卡片布局。
## 10.2 运行快照
```ts
interface TwentyFortyEightRunSnapshot {
runId: string;
profileId: string;
ownerUserId: string;
status: TwentyFortyEightRunStatus;
seed: string;
board: TwentyFortyEightBoardSnapshot;
score: number;
bestTileValue: number;
moveCount: number;
undoRemaining: number;
targetTileValue: number;
reachedTargetAtMove: number | null;
startedAtMs: number;
updatedAtMs: number;
endedAtMs: number | null;
lastMove: TwentyFortyEightMoveResult | null;
work: TwentyFortyEightWorkSummary;
}
interface TwentyFortyEightBoardSnapshot {
size: 3 | 4 | 5;
cells: TwentyFortyEightCellSnapshot[];
}
interface TwentyFortyEightCellSnapshot {
row: number;
col: number;
tile: TwentyFortyEightTileSnapshot | null;
}
interface TwentyFortyEightTileSnapshot {
tileId: string;
value: number;
mergedFromTileIds: string[];
spawnedAtMove: number;
}
interface TwentyFortyEightMoveResult {
direction: TwentyFortyEightMoveDirection;
moveAccepted: boolean;
scoreDelta: number;
spawnedTile: TwentyFortyEightTileSnapshot | null;
mergedTileIds: string[];
}
```
## 10.3 前后端职责
前端负责:
1. 渲染棋盘、HUD、结算面板。
2. 采集滑动、键盘和按钮输入。
3. 根据后端返回的 `lastMove` 播放移动、合并和生成动画。
4. 做乐观动画可以,但必须以服务端快照回正。
前端不负责:
1. 保存正式分数。
2. 写入榜单。
3. 伪造目标达成。
4. 绕过后端生成新方块。
5. 自行发布作品状态。
后端负责:
1. 创建 run。
2. 按 seed 初始化棋盘。
3. 裁决每次移动。
4. 生成新方块。
5. 保存分数、步数、最高格和状态。
6. 裁决目标达成、继续挑战、失败和放弃。
7. 写入排行榜和埋点事件。
---
## 11. 后端分层边界
正式实现必须遵循当前 `server-rs + Axum + SpacetimeDB` 路线:
1. `server-rs/crates/module-twenty-forty-eight`
- 纯领域规则、棋盘移动、合并裁决、随机种子输入、分数计算、发布校验。
- 不依赖 Axum、SpacetimeDB、OSS 或 LLM。
2. `server-rs/crates/shared-contracts`
- 暴露 Agent、作品、运行态、广场 DTO。
3. `server-rs/crates/spacetime-module`
- 存储 session、message、work profile、runtime run、leaderboard、event。
- 表结构变化必须同步 `migration.rs` 与表目录。
4. `server-rs/crates/spacetime-client`
- 提供 api-server 调用 SpacetimeDB 的 typed facade。
5. `server-rs/crates/api-server`
- 暴露 `/api/creation/twenty-forty-eight/*``/api/runtime/twenty-forty-eight/*`
- 处理鉴权、错误 envelope、LLM turn、生图编排、OSS 资产和 HTTP facade。
6. `platform-llm` / `platform-oss`
- 分别承载外部模型和资产副作用。
涉及 SpacetimeDB 的表、reducer、procedure、绑定生成、前端 SDK 接入时,必须按 `spacetimedb-cli``spacetimedb-rust``spacetimedb-concepts``spacetimedb-typescript` 约束执行。
---
## 12. SpacetimeDB 表建议
首版建议新增:
1. `twenty_forty_eight_agent_session`
2. `twenty_forty_eight_agent_message`
3. `twenty_forty_eight_work_profile`
4. `twenty_forty_eight_runtime_run`
5. `twenty_forty_eight_leaderboard_entry`
6. `twenty_forty_eight_event`
表职责:
| 表 | 职责 |
| --- | --- |
| `twenty_forty_eight_agent_session` | 创作会话、阶段、草稿、已发布 profile 绑定。 |
| `twenty_forty_eight_agent_message` | Agent 对话消息和流式 turn 结果。 |
| `twenty_forty_eight_work_profile` | 作品草稿、发布状态、封面、棋盘配置、合成链和统计投影。 |
| `twenty_forty_eight_runtime_run` | 单次运行快照、动作摘要、分数、状态和结算时间。 |
| `twenty_forty_eight_leaderboard_entry` | 按作品、用户、棋盘规格和目标格记录最好成绩。 |
| `twenty_forty_eight_event` | 发布、试玩、开始、移动、达成目标、失败、结算等审计事件。 |
reducer / procedure 不允许调用 LLM、OSS、生图、HTTP 或非确定性外部服务。
---
## 13. API 设计
## 13.1 创作接口
统一前缀:
```text
/api/creation/twenty-forty-eight
```
建议接口:
1. `POST /api/creation/twenty-forty-eight/sessions`
2. `GET /api/creation/twenty-forty-eight/sessions/{sessionId}`
3. `POST /api/creation/twenty-forty-eight/sessions/{sessionId}/messages`
4. `POST /api/creation/twenty-forty-eight/sessions/{sessionId}/messages/stream`
5. `POST /api/creation/twenty-forty-eight/sessions/{sessionId}/actions`
6. `POST /api/creation/twenty-forty-eight/sessions/{sessionId}/compile`
7. `GET /api/creation/twenty-forty-eight/works`
8. `GET /api/creation/twenty-forty-eight/works/{profileId}`
9. `PUT /api/creation/twenty-forty-eight/works/{profileId}`
10. `POST /api/creation/twenty-forty-eight/works/{profileId}/publish`
11. `DELETE /api/creation/twenty-forty-eight/works/{profileId}`
## 13.2 运行接口
统一前缀:
```text
/api/runtime/twenty-forty-eight
```
建议接口:
1. `GET /api/runtime/twenty-forty-eight/gallery`
2. `GET /api/runtime/twenty-forty-eight/gallery/{profileId}`
3. `POST /api/runtime/twenty-forty-eight/works/{profileId}/runs`
4. `GET /api/runtime/twenty-forty-eight/runs/{runId}`
5. `POST /api/runtime/twenty-forty-eight/runs/{runId}/moves`
6. `POST /api/runtime/twenty-forty-eight/runs/{runId}/undo`
7. `POST /api/runtime/twenty-forty-eight/runs/{runId}/continue`
8. `POST /api/runtime/twenty-forty-eight/runs/{runId}/restart`
9. `POST /api/runtime/twenty-forty-eight/runs/{runId}/abandon`
10. `POST /api/runtime/twenty-forty-eight/runs/{runId}/leaderboard`
移动请求:
```ts
interface TwentyFortyEightMoveRequest {
clientActionId: string;
direction: TwentyFortyEightMoveDirection;
baseSnapshotVersion: number;
}
```
响应:
```ts
interface TwentyFortyEightMoveResponse {
snapshot: TwentyFortyEightRunSnapshot;
}
```
---
## 14. 前端落点
建议新增:
```text
src/components/twenty-forty-eight-creation/TwentyFortyEightAgentWorkspace.tsx
src/components/twenty-forty-eight-result/TwentyFortyEightResultView.tsx
src/components/twenty-forty-eight-runtime/TwentyFortyEightRuntimeShell.tsx
src/components/twenty-forty-eight-runtime/TwentyFortyEightBoard.tsx
src/components/twenty-forty-eight-runtime/TwentyFortyEightHud.tsx
src/services/twenty-forty-eight-creation/twentyFortyEightCreationClient.ts
src/services/twenty-forty-eight-works/twentyFortyEightWorksClient.ts
src/services/twenty-forty-eight-runtime/twentyFortyEightRuntimeClient.ts
src/services/twenty-forty-eight-gallery/twentyFortyEightGalleryClient.ts
```
平台入口接入时需要扩展:
1. `src/config/newWorkEntryConfig.ts`
2. `src/components/platform-entry/platformEntryTypes.ts`
3. `src/components/platform-entry/platformEntryCreationTypes.ts`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
5. `src/components/custom-world-home/creationWorkShelf.ts`
6. `src/services/publicWorkCode.ts`
新增 selection stage
```ts
| 'twenty-forty-eight-agent-workspace'
| 'twenty-forty-eight-result'
| 'twenty-forty-eight-runtime'
| 'twenty-forty-eight-gallery-detail'
```
---
## 15. UI 要求
## 15.1 创作入口
入口卡片只表达:
1. `2048`
2. `主题合成棋盘`
3. 开放状态
不在入口卡片里堆规则说明。
## 15.2 Agent 工作台
工作台结构:
1. 对话流。
2. 当前锚点摘要。
3. 生成草稿动作。
4. 进入结果页动作。
不展示世界观、角色、地点等 RPG 重结构。
## 15.3 运行态
运行态设计原则:
1. 棋盘是绝对主角。
2. 移动端优先,单手可滑动。
3. HUD 信息克制,只显示分数、目标、步数和最高格。
4. 撤销、重新开始、退出使用 icon button 或短按钮。
5. 目标达成、失败和排行榜使用独立弹窗或底部面板。
6. 不把弹出面板实现成当前面板下方追加内容。
---
## 16. 测试与验收
## 16.1 领域测试
必须覆盖:
1. 向左移动压缩。
2. 向右移动压缩。
3. 单次移动中每个方块最多合并一次。
4. 合并得分计算。
5. 无效移动不生成新方块。
6. 有效移动按 seed 生成新方块。
7. 目标格达成。
8. 无可行动作进入失败。
9. 撤销次数消耗和快照恢复。
## 16.2 API 测试
必须覆盖:
1. 未登录不能创建作品和 run。
2. 创建 session。
3. 编译草稿。
4. 发布校验阻断。
5. 创建 run。
6. 提交 move 后返回新快照。
7. 无效 move 不增加步数。
8. 达成目标后可继续挑战。
9. 失败后不能继续提交 move。
10. 排行榜只接受后端已结算 run。
## 16.3 前端测试
必须覆盖:
1. 入口展示与点击分流。
2. Agent 工作台打开。
3. 结果页编辑棋盘参数和合成链。
4. 试玩进入运行态。
5. 移动端滑动提交方向。
6. 键盘方向键提交方向。
7. 无效移动反馈。
8. 目标达成弹窗。
9. 失败结算弹窗。
## 16.4 建议验证命令
按改动范围执行:
```bash
npm run check:encoding
npm run typecheck
npm run test
cd server-rs
cargo test -p shared-contracts twenty_forty_eight
cargo test -p module-twenty-forty-eight
cargo check -p api-server
```
涉及 SpacetimeDB 表变化后:
```bash
npm run spacetime:generate -- --rust-only
npm run check:server-rs-ddd
```
涉及 API smoke 时:
```bash
npm run api-server
```
启动后确认:
```text
GET /healthz
POST /api/creation/twenty-forty-eight/sessions
POST /api/runtime/twenty-forty-eight/works/{profileId}/runs
POST /api/runtime/twenty-forty-eight/runs/{runId}/moves
```
---
## 17. 并行任务拆分
## 任务 A契约与共享类型
写入范围:
```text
packages/shared/src/contracts/twentyFortyEightAgent.ts
packages/shared/src/contracts/twentyFortyEightWorks.ts
packages/shared/src/contracts/twentyFortyEightRuntime.ts
packages/shared/src/contracts/twentyFortyEightGallery.ts
server-rs/crates/shared-contracts/src/twenty_forty_eight_agent.rs
server-rs/crates/shared-contracts/src/twenty_forty_eight_works.rs
server-rs/crates/shared-contracts/src/twenty_forty_eight_runtime.rs
server-rs/crates/shared-contracts/src/twenty_forty_eight_gallery.rs
```
验收:
```bash
npm run typecheck
cd server-rs
cargo test -p shared-contracts twenty_forty_eight
```
## 任务 B领域规则模块
写入范围:
```text
server-rs/crates/module-twenty-forty-eight/src/domain.rs
server-rs/crates/module-twenty-forty-eight/src/application.rs
server-rs/crates/module-twenty-forty-eight/src/rule_engine.rs
server-rs/crates/module-twenty-forty-eight/src/random.rs
server-rs/crates/module-twenty-forty-eight/src/lib.rs
```
验收:
```bash
cd server-rs
cargo test -p module-twenty-forty-eight
```
## 任务 CSpacetimeDB 表与 facade
写入范围:
```text
server-rs/crates/spacetime-module/src/twenty_forty_eight.rs
server-rs/crates/spacetime-module/src/lib.rs
server-rs/crates/spacetime-module/src/migration.rs
server-rs/crates/spacetime-client/src/twenty_forty_eight.rs
docs/technical/SPACETIMEDB_TABLE_CATALOG.md
```
验收:
```bash
npm run spacetime:generate -- --rust-only
npm run check:server-rs-ddd
cd server-rs
cargo check -p spacetime-module
cargo check -p spacetime-client
```
## 任务 DAPI / SSE facade
写入范围:
```text
server-rs/crates/api-server/src/twenty_forty_eight.rs
server-rs/crates/api-server/src/twenty_forty_eight_sse.rs
server-rs/crates/api-server/src/app.rs
server-rs/crates/api-server/src/state.rs
```
验收:
```bash
cd server-rs
cargo check -p api-server
npm run api-server
```
## 任务 E前端创作、结果页与运行态
写入范围:
```text
src/config/newWorkEntryConfig.ts
src/components/platform-entry/*
src/components/twenty-forty-eight-creation/*
src/components/twenty-forty-eight-result/*
src/components/twenty-forty-eight-runtime/*
src/services/twenty-forty-eight-*
```
验收:
```bash
npm run typecheck
npm run test -- twenty
npm run check:encoding
```
## 任务 F作品架、广场、分享与回归
写入范围:
```text
src/components/custom-world-home/creationWorkShelf.ts
src/services/publicWorkCode.ts
src/components/common/PublishShareModal.tsx
src/components/twenty-forty-eight-gallery/*
docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md
docs/technical/SPACETIMEDB_TABLE_CATALOG.md
```
验收:
```bash
npm run typecheck
npm run test
npm run check:encoding
```
---
## 18. 最小上线清单
1. `twenty-forty-eight` 入口可展示并进入工作台。
2. Agent 可生成主题化 2048 草稿。
3. 结果页可编辑基本信息、棋盘配置和合成链。
4. 结果页可试玩。
5. 运行态可完成标准 2048 移动、合并、生成新方块、目标达成和失败。
6. 发布后作品进入作品架和广场。
7. 玩家可从广场进入公开 run。
8. 榜单只记录后端裁决的正式成绩。
9. 前后端契约字段 camelCase / snake_case 映射明确。
10. SpacetimeDB 表、migration、表目录和 bindings 同步。
---
## 19. 验收标准
当下面结果都成立时,视为 `2048` 玩法模板落地完成:
1. 平台有独立 `2048` 创作入口。
2. 玩法 ID 使用 `twenty-forty-eight`
3. 能进入 2048 Agent 工作台。
4. 能通过 Agent 生成草稿。
5. 结果页可编辑作品名、简介、标签、棋盘参数、合成链和封面。
6. 发布校验能阻断非法草稿。
7. 试玩能进入 2048 运行态。
8. 运行态支持移动端滑动和桌面方向键。
9. 后端能裁决移动、合并、得分、新方块生成和胜负。
10. 无效移动不增加步数,不生成新方块。
11. 达成目标后可结算或继续挑战。
12. 失败后不能继续提交移动。
13. 发布作品能进入作品架、广场和分享链路。
14. 排行榜只接受正式 run 的后端结算成绩。
15. 新增表结构同步 `migration.rs`、表目录和 bindings。
16. UI 不默认展示长篇规则说明,不把独立弹窗做成面板下方展开。
17. 移动端和桌面端都能正常显示和操作。
---
## 20. 一句话结论
`2048` 在 Genarrative 中应被做成一个可创作、可换皮、可发布、可排行的主题合成棋盘模板:创作端让百梦主定义合成链和视觉承诺,运行端保持经典 2048 的滑动合并手感,服务端负责正式棋盘裁决、作品状态和成绩真相。

View File

@@ -0,0 +1,826 @@
# AI 原生幸存者类游戏模板 PRD
更新时间:`2026-05-05`
## 0. 文档目的
这份 PRD 用于在当前平台内新增一条“幸存者类游戏模板”产品链路冻结它从创作入口、Agent 共创、结果页、试玩、发布到运行态结算的完整边界。
本模板参考的是幸存者 / 割草 / 轻度 Roguelite 类玩法的通用结构:玩家在俯视角战场中移动、生存、自动攻击、拾取经验、选择升级,并在持续敌潮中坚持到胜利时间或击败终局首领。
本次不是复制某个具体商业游戏,也不是新增一个孤立前端小游戏。正式实现必须接入 Genarrative 现有平台创作中心、作品架、广场、资产、钱包、埋点和 `server-rs + Axum + SpacetimeDB` 后端基线。
---
## 1. 一句话定义
`survivor` 是一个 AI 原生幸存者类游戏模板:百梦主通过 Agent 设定主角幻想、敌潮母题、武器技能、成长流派和战场节奏,系统编译出一个移动端优先的俯视角割草生存作品;玩家通过移动躲避敌潮,武器自动攻击,拾取经验升级,在若干分钟内完成生存挑战、击败首领或倒下结算。
---
## 2. 当前接入级别
根据新增玩法类型接入分级,本模板目标为 **完整玩法闭环**
1. 新增玩法 ID`survivor`
2. 对外模板名称:`幸存者挑战`
3. 对外子标题:`割草成长玩法`
4. 支持 Agent 创作、草稿生成、结果页编辑、试玩、发布和公开运行。
5. 支持作品架恢复草稿和已发布作品二次编辑。
6. 支持玩家从作品详情或广场进入运行态。
7. 支持后端权威生成局面、升级候选、波次配置、结算和成绩基础数据。
8. 前端负责高频模拟与表现,但不得自行发明正式规则、波次、掉落、升级池或结算真相。
---
## 3. 产品定位
## 3.1 命名
1. 对外模板名称:`幸存者挑战`
2. 对外子标题:`割草成长玩法`
3. 开发代号:`Survivor`
4. 工程玩法域:`survivor`
5. 后端模块命名预期:`survivor`
## 3.2 核心乐趣
1. 玩家被敌潮包围,通过走位制造空间。
2. 武器自动攻击,玩家专注移动、拾取、选择升级。
3. 每次升级出现少量高价值选择,形成局内构筑。
4. 敌人数量、速度、血量和特殊能力逐步加压。
5. 后期屏幕内形成高密度清场反馈,玩家获得成长压倒敌潮的爽感。
## 3.3 与现有玩法的区别
1. 不等同于 RPG不依赖世界章节、NPC 对话或剧情推进。
2. 不等同于大鱼吃小鱼:不做吞噬收编和三合一实体升级。
3. 不等同于抓大鹅:不做备选栏三消和堆叠点击。
4. 不等同于拼图:不切图、不交换、不合并拼块。
5. 不新增通用动作游戏引擎,本期只服务幸存者类模板。
---
## 4. 完整闭环目标
本模板首版必须补齐:
1. 平台创作入口展示“幸存者挑战”。
2. Agent 对话收集玩法锚点。
3. 后端编译幸存者玩法草稿。
4. 结果页编辑作品名、封面、标签、主角、敌人、武器、成长和战场配置。
5. 结果页支持生成或替换关键视觉资产。
6. 结果页支持发布前试玩。
7. 作品可保存、发布、删除和二次编辑。
8. 玩家从作品详情或广场创建 run。
9. 后端下发权威 run seed、波次、敌人、武器、升级候选和结算规则。
10. 前端按权威配置执行高频运行表现,定期提交 checkpoint。
11. 玩家升级时从后端确认的候选卡中选择一张。
12. 玩家死亡、主动退出、倒计时胜利或终局首领胜利后进入结算。
13. 结算成绩以后端确认的 run snapshot 为准。
---
## 5. 明确不做
首版明确不做:
1. 不做联机、PvP、组队或排行榜赛季。
2. 不做横屏专属设计,移动端竖屏优先。
3. 不做复杂地形寻路、真实物理碰撞和高精度弹幕编辑器。
4. 不做多地图章节战役,只做单局挑战。
5. 不做局外装备养成、抽卡、永久技能树或付费强化。
6. 不要求百梦主逐帧编辑怪物 AI、弹道曲线或数值公式。
7. 不把玩法规则说明长文默认写进 UI 面板。
8. 不把按钮弹出的资产、升级或参数编辑做成当前卡片下方展开内容,统一使用独立面板。
9. 不让前端本地结算直接写入正式成绩、钱包、任务或排行榜。
10. 不复用 `server-node`、Express、PostgreSQL 或旧 RPG runtime 作为新增实现目标。
---
## 6. 创作锚点设计
Agent 型创作版本至少收集下面 5 个高杠杆锚点:
| 锚点 | 字段建议 | 用途 |
| --- | --- | --- |
| 生存幻想 | `survivalFantasy` | 决定主角身份、战斗气质和作品一句话承诺。 |
| 敌潮母题 | `hordeTheme` | 决定敌人类型、数量感、压迫方式和视觉风格。 |
| 武器技能 | `weaponFantasy` | 决定自动攻击、范围技能、投射物和特效方向。 |
| 成长流派 | `buildArchetype` | 决定升级卡池、进化组合和玩家构筑路线。 |
| 战场节奏 | `stageRhythm` | 决定单局时长、波次曲线、首领节点和胜利条件。 |
Agent 必须围绕用户灵感收束这些锚点,不允许把创作入口做成一屏参数问卷。
## 6.1 锚点状态
每个锚点都支持:
1. `待补充`
2. `Agent 推断`
3. `已确认`
4. `已锁定`
## 6.2 收束条件
满足下面条件后,系统允许编译第一版草稿:
1. `生存幻想` 已确认或已锁定。
2. `敌潮母题` 已确认或已锁定。
3. `武器技能` 至少有 `3` 个可用武器草稿。
4. `成长流派` 至少有 `6` 张升级卡草稿。
5. `战场节奏` 已确认,或使用系统默认 `8` 分钟短局配置。
## 6.3 快捷补全
当会话至少完成 `2` 轮后,工作区提供 `补充剩余关键字` 快捷动作:
1. 前端发送消息接口,并携带 `quickFillRequested: true`
2. 用户消息固定为“请补充剩余关键字。”。
3. 后端 Agent 根据当前锚点补齐缺失内容。
4. 前端不得自行推断武器、波次、敌人或升级卡。
## 6.4 Agent AI 生成契约
单轮模型输出必须是严格 JSON
```json
{
"replyText": "",
"progressPercent": 0,
"anchors": {
"survivalFantasy": { "status": "pending", "summary": "" },
"hordeTheme": { "status": "pending", "summary": "" },
"weaponFantasy": { "status": "pending", "summary": "" },
"buildArchetype": { "status": "pending", "summary": "" },
"stageRhythm": { "status": "pending", "summary": "" }
},
"nextDraftPatch": {}
}
```
落地约束:
1. `replyText` 是直接展示给百梦主的中文回复,不得出现 JSON、字段名或内部协议说明。
2. `progressPercent` 只能由后端校验后采纳,范围 `0~100`
3. `nextDraftPatch` 只允许写入幸存者草稿字段,不允许修改平台账号、钱包或作品归属。
4. 模型不可用或结果无法解析时,接口返回明确错误,不用固定模板伪装 AI 回复。
---
## 7. 草稿契约
## 7.1 `SurvivorResultDraft`
```ts
export interface SurvivorResultDraft {
profileId: string | null;
workTitle: string;
workDescription: string;
workTags: string[];
coverImageSrc: string | null;
anchors: SurvivorAnchorPack;
hero: SurvivorHeroDraft;
enemies: SurvivorEnemyDraft[];
weapons: SurvivorWeaponDraft[];
upgrades: SurvivorUpgradeDraft[];
evolutions: SurvivorEvolutionDraft[];
stage: SurvivorStageDraft;
wavePlan: SurvivorWavePlanDraft;
runtimeConfig: SurvivorRuntimeConfigDraft;
publishReady: boolean;
validationIssues: SurvivorValidationIssue[];
updatedAt: string;
}
```
## 7.2 主角草稿
```ts
export interface SurvivorHeroDraft {
heroId: string;
name: string;
fantasy: string;
moveSpeed: number;
maxHp: number;
defense: number;
pickupRadius: number;
spriteAssetId: string | null;
portraitAssetId: string | null;
}
```
默认范围:
1. `moveSpeed``80~180`,默认 `120`
2. `maxHp``50~300`,默认 `100`
3. `defense``0~50`,默认 `0`
4. `pickupRadius``30~160`,默认 `60`
## 7.3 敌人草稿
```ts
export interface SurvivorEnemyDraft {
enemyId: string;
name: string;
role: 'fodder' | 'runner' | 'tank' | 'ranged' | 'elite' | 'boss';
visualPrompt: string;
maxHp: number;
damage: number;
moveSpeed: number;
xpValue: number;
spawnWeight: number;
spriteAssetId: string | null;
}
```
首版发布至少需要:
1. `2``fodder``runner` 敌人。
2. `1``elite` 敌人。
3. `1``boss` 敌人。
## 7.4 武器草稿
```ts
export interface SurvivorWeaponDraft {
weaponId: string;
name: string;
attackKind: 'projectile' | 'orbit' | 'area' | 'beam' | 'melee_aura';
targeting: 'nearest' | 'random_enemy' | 'forward' | 'around_hero';
baseDamage: number;
cooldownMs: number;
projectileCount: number;
range: number;
effectAssetId: string | null;
upgradeTags: string[];
}
```
首版必须至少有 `3` 个武器。玩家开局默认持有第一个武器。
## 7.5 升级卡草稿
```ts
export interface SurvivorUpgradeDraft {
upgradeId: string;
name: string;
rarity: 'common' | 'rare' | 'epic';
targetKind: 'hero' | 'weapon' | 'global';
targetId: string | null;
effectKind:
| 'damage_percent'
| 'cooldown_percent'
| 'projectile_count'
| 'move_speed_percent'
| 'max_hp_flat'
| 'pickup_radius_percent'
| 'regen_flat';
value: number;
maxStack: number;
iconAssetId: string | null;
}
```
首版至少需要 `6` 张升级卡,并保证任意升级节点能从可用池中抽出 `3` 张候选。
## 7.6 进化组合
```ts
export interface SurvivorEvolutionDraft {
evolutionId: string;
name: string;
baseWeaponId: string;
requiredUpgradeIds: string[];
resultWeapon: SurvivorWeaponDraft;
}
```
进化组合首版可选,但字段必须预留。若启用进化,结果页必须展示组合条件,不在运行态长文解释。
## 7.7 战场草稿
```ts
export interface SurvivorStageDraft {
stageId: string;
name: string;
theme: string;
backgroundAssetId: string | null;
groundTextureAssetId: string | null;
safeAreaShape: 'rectangle' | 'circle';
width: number;
height: number;
obstacleDensity: number;
}
```
首版默认不生成实心障碍,`obstacleDensity` 固定为 `0`。后续要开放障碍,必须先补充寻路与碰撞方案。
## 7.8 波次草稿
```ts
export interface SurvivorWavePlanDraft {
durationSeconds: number;
waves: SurvivorWaveDraft[];
bossWave: SurvivorBossWaveDraft;
}
export interface SurvivorWaveDraft {
startSecond: number;
endSecond: number;
enemyPool: string[];
spawnRatePerSecond: number;
maxAlive: number;
statMultiplier: number;
}
```
默认短局:
1. `durationSeconds``480`
2.`60` 秒一个波次。
3. `240` 秒出现精英压力波。
4. `480` 秒出现终局首领或进入胜利结算。
---
## 8. 运行规则设计
## 8.1 单局结构
首版标准局为 `8` 分钟短局:
1. 玩家开局拥有 `1` 个基础武器。
2. 玩家通过虚拟摇杆或键盘移动。
3. 武器按冷却自动攻击。
4. 敌人从屏幕外安全环刷出并向玩家移动。
5. 敌人碰撞玩家造成伤害。
6. 敌人死亡掉落经验晶体。
7. 玩家拾取经验后升级。
8. 升级时暂停运行态,展示 `3` 张升级卡。
9. 玩家选择一张升级卡后继续运行。
10. 玩家生命归零失败。
11. 生存到终局时间或击败终局首领胜利。
## 8.2 移动与输入
1. 移动端使用左下角虚拟摇杆。
2. 桌面端支持 `WASD` / 方向键移动。
3. 首版不要求手动瞄准和手动开火。
4. 前端必须保证摇杆、血量、经验条、升级面板和结算面板在竖屏下不遮挡核心战场。
## 8.3 自动攻击
1. 每个武器独立维护冷却。
2. 目标选择策略由后端配置下发。
3. 前端可本地执行攻击表现与命中模拟,但必须使用后端下发的武器参数和 RNG seed。
4. 武器伤害、冷却、投射数量和范围变化来自后端确认的升级结果。
## 8.4 敌潮生成
1. 敌人只能从玩家当前视野外的刷怪环生成。
2. 刷怪位置不得直接贴脸。
3. 每个波次有 `spawnRatePerSecond``maxAlive` 上限。
4. 前端不得自行增加敌人类型、刷新率或精英节点。
5. 后端 checkpoint 校验时应能根据 seed、波次和输入摘要重放关键统计。
## 8.5 经验与升级
1. 敌人死亡生成经验晶体。
2. 玩家碰到晶体或晶体进入拾取半径时获得经验。
3. 经验达到当前等级阈值时触发升级。
4. 升级候选由后端按当前 build、权重、稀有度和 seed 生成。
5. 前端只能展示后端返回的候选卡。
6. 玩家选择后,后端更新 run build snapshot。
## 8.6 失败与胜利
失败条件:
1. `heroHp <= 0`
2. 玩家主动退出并确认放弃。
3. 后端判定 run 校验失败。
胜利条件:
1. 生存到 `durationSeconds` 并完成终局结算。
2. 或在终局波次击败 `boss`
---
## 9. 权威状态与高频模拟边界
幸存者类玩法需要高频碰撞和大量实体,不能把每一帧都交给 HTTP 往返。首版采用“后端权威配置 + 前端确定性运行表现 + 后端 checkpoint / 结算确认”的边界。
## 9.1 后端职责
后端负责:
1. 创建创作会话和玩法草稿。
2. 校验和保存作品 profile。
3. 编译 run seed、波次、敌人、武器、升级池和结算规则。
4. 生成升级候选。
5. 确认升级选择。
6. 接收运行 checkpoint。
7. 校验关键统计是否在合理范围内。
8. 确认死亡、胜利、退出和最终成绩。
9. 写入作品游玩次数、成绩、埋点和任务事件。
## 9.2 前端职责
前端负责:
1. 展示 Agent 工作台和结果页。
2. 渲染战场、主角、敌人、投射物、特效、经验晶体和 HUD。
3. 基于后端 run config 执行确定性高频模拟。
4. 采集玩家移动输入。
5. 展示攻击、受击、拾取、升级、胜利和失败反馈。
6. 定期提交 checkpoint。
7. 在后端拒绝 checkpoint 或结算时,展示同步失败并结束本局。
## 9.3 Checkpoint 规则
前端每 `5` 秒或关键节点提交 checkpoint
```ts
export interface SurvivorRunCheckpointRequest {
runId: string;
snapshotVersion: number;
elapsedMs: number;
inputDigest: string;
rngStep: number;
heroHp: number;
heroLevel: number;
killCount: number;
eliteKillCount: number;
bossKillCount: number;
xpCollected: number;
selectedUpgradeIds: string[];
checksum: string;
}
```
关键节点包括:
1. 升级触发前。
2. 选择升级后。
3. 精英死亡。
4. Boss 出现。
5. Boss 死亡。
6. 玩家死亡。
7. 胜利结算。
---
## 10. 结果页设计
## 10.1 结果页职责
幸存者结果页是最小可编辑工作台,不是只读总结页。
结果页至少包含:
1. 作品基础信息。
2. 主角配置。
3. 敌人图鉴。
4. 武器与升级卡池。
5. 战场与波次。
6. 资产状态。
7. 试玩入口。
8. 发布校验。
## 10.2 移动端优先布局
1. 首屏显示作品标题、封面、发布状态和试玩按钮。
2. 主角、敌人、武器、战场使用分组入口。
3. 点击分组入口打开独立编辑面板。
4. 不在卡片下方展开长规则说明。
5. 发布校验只展示短状态和可处理项,不展示底层协议。
## 10.3 资产要求
正式发布前至少需要:
1. 作品封面。
2. 主角头像或主角小人资产。
3. `3` 类普通敌人资产。
4. `1` 类精英或首领资产。
5. `3` 个武器或技能图标。
6. `1` 张战场背景或地面纹理。
程序化色块或几何占位只允许用于开发调试和未发布草稿,不允许作为正式发布作品的默认资产。
## 10.4 结果页核心操作
结果页支持:
1. `生成主角资产`
2. `生成敌人资产`
3. `生成武器图标`
4. `生成战场背景`
5. `重新编译波次`
6. `试玩`
7. `保存草稿`
8. `发布`
9. `返回创作对话`
---
## 11. API 设计
## 11.1 创作接口
1. `POST /api/creation/survivor/sessions`
2. `GET /api/creation/survivor/sessions/{sessionId}`
3. `POST /api/creation/survivor/sessions/{sessionId}/messages`
4. `POST /api/creation/survivor/sessions/{sessionId}/messages/stream`
5. `POST /api/creation/survivor/sessions/{sessionId}/actions`
6. `POST /api/creation/survivor/sessions/{sessionId}/compile`
7. `GET /api/creation/survivor/works`
8. `GET /api/creation/survivor/works/{profileId}`
9. `PUT /api/creation/survivor/works/{profileId}`
10. `POST /api/creation/survivor/works/{profileId}/publish`
11. `DELETE /api/creation/survivor/works/{profileId}`
## 11.2 运行接口
1. `GET /api/runtime/survivor/gallery`
2. `GET /api/runtime/survivor/gallery/{profileId}`
3. `POST /api/runtime/survivor/works/{profileId}/runs`
4. `GET /api/runtime/survivor/runs/{runId}`
5. `POST /api/runtime/survivor/runs/{runId}/checkpoint`
6. `POST /api/runtime/survivor/runs/{runId}/upgrade-options`
7. `POST /api/runtime/survivor/runs/{runId}/choose-upgrade`
8. `POST /api/runtime/survivor/runs/{runId}/settle`
9. `POST /api/runtime/survivor/runs/{runId}/stop`
10. `POST /api/runtime/survivor/runs/{runId}/restart`
## 11.3 SSE 事件
创作对话流式事件复用平台 Agent envelope。运行态首版不要求全程 SSE如后续加入服务端实时推送只允许推送权威 milestone不传每帧实体状态。
---
## 12. 后端分层边界
完整实现时必须遵循当前后端路线:
1. `server-rs/crates/module-survivor`
- 纯领域规则、草稿校验、波次编译、升级池、评分和 checkpoint 校验。
- 不依赖 Axum、SpacetimeDB、OSS 或 LLM。
2. `server-rs/crates/shared-contracts`
- 暴露 Survivor Agent、作品、运行态 DTO。
3. `server-rs/crates/spacetime-module`
- 存储 session、message、work profile、runtime run 和 runtime event。
- 表结构变化必须同步 `migration.rs` 与表目录。
4. `server-rs/crates/spacetime-client`
- 提供 api-server 调用 SpacetimeDB 的 typed facade。
5. `server-rs/crates/api-server`
- 暴露 `/api/creation/survivor/*``/api/runtime/survivor/*`
- 处理鉴权、错误 envelope、LLM turn、资产生成和 HTTP facade。
6. `platform-agent` / `platform-llm`
- 承接 Agent 对话与草稿生成。
7. `platform-oss`
- 承接封面、角色、敌人、武器图标和战场背景资产。
---
## 13. SpacetimeDB 表建议
首版建议新增:
1. `survivor_agent_session`
- 创作会话主表,保存 owner、stage、anchors、draft、progress、published_profile_id。
2. `survivor_agent_message`
- 创作消息流水。
3. `survivor_work_profile`
- 作品 profile保存发布态草稿、封面、标签、作者和公开状态。
4. `survivor_runtime_run`
- 运行态 run保存 run config、seed、当前 snapshot、checkpoint 摘要和结算状态。
5. `survivor_runtime_event`
- 运行审计事件,记录 start、checkpoint、upgrade、death、victory、settle 等关键事件。
首版不新增正式排行榜表。若后续加入排行榜,再新增 `survivor_leaderboard_entry` 并补充反作弊和赛季边界。
---
## 14. 作品结构建议
```ts
export interface SurvivorWorkProfile {
profileId: string;
ownerUserId: string;
sourceSessionId: string | null;
workTitle: string;
workDescription: string;
workTags: string[];
coverImageSrc: string | null;
publicationStatus: 'draft' | 'published' | 'archived';
draft: SurvivorResultDraft;
playCount: number;
updatedAt: string;
publishedAt: string | null;
}
```
## 14.1 发布校验
发布必须满足:
1. `workTitle` 非空。
2. 至少 `1` 个标签。
3. 有封面图。
4. 主角配置完整。
5. 至少 `4` 个敌人,其中包含普通、精英或首领。
6. 至少 `3` 个武器。
7. 至少 `6` 张升级卡。
8. 波次覆盖完整 `durationSeconds`
9. 资产覆盖满足正式发布要求。
10. `runtimeConfig` 在后端数值上限内。
---
## 15. 运行态快照建议
```ts
export interface SurvivorRunSnapshot {
runId: string;
profileId: string;
ownerUserId: string | null;
status: 'running' | 'level_up_pending' | 'victory' | 'defeat' | 'stopped' | 'settlement_failed';
snapshotVersion: number;
seed: string;
elapsedMs: number;
durationLimitMs: number;
hero: SurvivorRunHeroSnapshot;
build: SurvivorRunBuildSnapshot;
wave: SurvivorRunWaveSnapshot;
stats: SurvivorRunStatsSnapshot;
pendingUpgradeOptions: SurvivorUpgradeOption[];
settlement: SurvivorRunSettlement | null;
updatedAt: string;
}
```
前端渲染可维护更细的局内实体列表,但正式持久化只保存 run snapshot、checkpoint 摘要和结算结果。
---
## 16. 计分与结算
首版结算至少展示:
1. 胜利 / 失败状态。
2. 生存时间。
3. 击杀数。
4. 等级。
5. 精英击杀数。
6. 首领击杀数。
7. 选择过的核心升级。
8. 再来一局。
9. 返回作品详情。
基础分数公式:
```text
score = survivedSeconds * 10
+ killCount * 5
+ eliteKillCount * 120
+ bossKillCount * 500
+ heroLevel * 80
```
结算入库以后端确认结果为准。前端展示的即时结算在后端确认前必须标记为待确认状态。
---
## 17. 平台接入
## 17.1 创作入口
需要接入:
1. `src/config/newWorkEntryConfig.ts`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/platformEntryTypes.ts`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
新增阶段建议:
1. `survivor-agent-workspace`
2. `survivor-generating`
3. `survivor-result`
4. `survivor-runtime`
5. `survivor-gallery-detail`
## 17.2 前端组件建议
1. `src/components/survivor-creation/SurvivorAgentWorkspace.tsx`
2. `src/components/survivor-result/SurvivorResultView.tsx`
3. `src/components/survivor-runtime/SurvivorRuntimeShell.tsx`
4. `src/services/survivorCreationClient.ts`
5. `src/services/survivorRuntimeClient.ts`
6. `src/services/survivorWorksClient.ts`
## 17.3 作品架与广场
发布后必须接入:
1. 创作页作品架。
2. 平台作品详情。
3. 首页或发现页公开作品流。
4. 公开作品试玩入口。
5. 删除、二次编辑和重新发布。
---
## 18. UI 设计约束
1. 移动端竖屏优先,桌面端居中或宽屏增强。
2. HUD 只保留血量、经验、时间、等级、击杀数和暂停入口。
3. 升级卡使用独立面板,最多显示 `3` 张候选。
4. 暂停、结算、资产生成、参数编辑都使用独立面板。
5. 不默认展示规则说明长文。
6. 运行态主画面不能被功能介绍、键位说明或装饰卡片遮挡。
7. 触控区、摇杆、升级卡和按钮必须满足移动端可点击尺寸。
8. 图标按钮优先使用现有图标库;无图标时再使用简短文本。
---
## 19. 里程碑拆分
## 19.1 Phase 1入口与 PRD 骨架
1. 冻结 `survivor` 玩法 ID。
2. 新增入口配置。
3. 新增前端阶段枚举。
4. 新增 shared contracts 草案。
5. 入口可以进入占位工作台。
## 19.2 Phase 2创作会话与结果页
1. 新增 Agent session。
2. 新增消息与流式回复。
3. 编译第一版 `SurvivorResultDraft`
4. 结果页可编辑主角、敌人、武器、升级和波次。
5. 支持保存草稿。
## 19.3 Phase 3后端运行态
1. `module-survivor` 实现波次、升级池、结算和 checkpoint 校验。
2. SpacetimeDB 表、migration 和 facade 收口。
3. api-server 暴露 runtime API。
4. 支持创建 run、选择升级、提交 checkpoint 和结算。
## 19.4 Phase 4前端运行态
1. 实现竖屏战场。
2. 实现摇杆移动。
3. 实现自动攻击、敌潮、经验和升级面板。
4. 实现胜利、失败、暂停和结算。
5. 接入后端 checkpoint 与升级确认。
## 19.5 Phase 5发布与平台闭环
1. 发布作品。
2. 接入作品架。
3. 接入公开详情和广场。
4. 支持二次编辑。
5. 接入埋点、任务和基础成绩记录。
---
## 20. 验收标准
1. `src/config/newWorkEntryConfig.ts` 中存在 `survivor` 类型。
2. 新建作品入口和创作类型弹层能展示“幸存者挑战”。
3. 能进入 `survivor-agent-workspace`
4. Agent 能生成锚点和草稿。
5. 能进入 `survivor-result` 并编辑基础字段。
6. 发布校验能阻止缺少主角、敌人、武器、升级卡、波次或资产的作品发布。
7. 能从结果页进入试玩运行态。
8. 运行态能移动、自动攻击、刷怪、拾取经验、升级和结算。
9. 升级候选必须来自后端返回,不由前端临时生成。
10. 前端提交 checkpoint 后,后端能接受或拒绝并返回明确结果。
11. 作品发布后能在作品架和公开入口恢复。
12. 移动端竖屏下主战场、摇杆、HUD、升级卡和结算不互相遮挡。
13. 后端实现后执行对应 DDD 验收命令,并用 `npm run api-server` 启动后端检查 `/healthz`
14. 修改包含中文的文档后执行 `npm run check:encoding`
---
## 21. 后续可选增强
以下能力不进入首版,但需要保留扩展空间:
1. 多地图主题。
2. 局外角色解锁。
3. 进化武器动画。
4. 精英词缀。
5. Boss 特殊弹幕。
6. 道具掉落。
7. 排行榜。
8. 赛季挑战。
9. 更多输入模式。
10. 服务端更严格的确定性重放校验。

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

20
docs/prd/README.md Normal file
View File

@@ -0,0 +1,20 @@
# PRD 总览
本目录用于存放产品需求、玩法闭环、阶段计划和可直接指导编码的需求拆分文档。
## 重点入口
- [AI 原生幕间文字游戏模板 PRD参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验但只落为百梦 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。
- [AI 原生视觉小说模板 PRDTXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
- [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。
- [创意互动内容生成 Agent Phase 1 PRDLangChain-Rust PoC + 拼图闭环](./CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md)首版只支持拼图模板Agent 使用 APIMart Responses `gpt-5` 支持文本和图像多模态输入,明确模板选择、积分范围、草稿字段填充、单关卡/多关卡图片生成、立即试玩、自然语言修改和可并行任务拆分。
- [AI 原生 2048 游戏玩法模板 PRD](./AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md):新增 `twenty-forty-eight` 玩法模板,定义主题化合成链创作、结果页、发布、公开运行、后端棋盘裁决、排行榜和并行落地任务。
- [AI 原生拼图玩法创作工具与玩法系统 PRD](./AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md):拼图玩法创作、结果页、发布、广场和运行时主链路。
- [AI 原生方洞挑战玩法创作工具与玩法系统 PRD](./AI_NATIVE_SQUARE_HOLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-04.md):方洞挑战创作、发布与试玩闭环。
- [后台管理独立前端工程 PRD](./ADMIN_WEB_CONSOLE_PRD_2026-04-30.md):后台管理端产品边界。
## 使用规则
- 新玩法、新 Agent 阶段、新创作闭环或较大功能落地前,优先补 PRD。
- PRD 必须写到可以编码的程度,包含字段、接口、状态、验收和并行任务拆分。
- 若 PRD 与最新代码或技术方案冲突,以代码和最新技术方案为准,并同步修正 PRD。

View File

@@ -2,6 +2,8 @@
更新时间:`2026-04-20` 更新时间:`2026-04-20`
> 2026-05-05 更新口径:本文保留为历史参考。视觉小说模板后续落地以 [`AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为准;冲突时不再按“外部 TXT 模式原样迁入”执行,必须只保留模板玩法能力、完全使用 Genarrative 平台接口,并删除回放功能。
## 0. 文档目的 ## 0. 文档目的
这份 PRD 只定义 `Interactive-fiction-frontend` + `Interactive-fiction-backend` 中 TXT 模式在 `Genarrative` 落地时的**核心玩法闭环**。 这份 PRD 只定义 `Interactive-fiction-frontend` + `Interactive-fiction-backend` 中 TXT 模式在 `Genarrative` 落地时的**核心玩法闭环**。

View File

@@ -115,3 +115,24 @@
2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。 2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。
3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。 3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。
4. 本地仍需要启动 `api-server`,否则后续 `POST /api/auth/entry` 等真实登录请求无法完成。 4. 本地仍需要启动 `api-server`,否则后续 `POST /api/auth/entry` 等真实登录请求无法完成。
## 9. 2026-05-07 本地短信入口恢复记录
如果登录弹窗里短信登录页签“像是被删了”,先不要改前端表单,优先检查本地登录方式探测结果:
1. 仓库根目录 `.env.local` 里必须显式保留 `SMS_AUTH_ENABLED=true`
2. 本地启动请优先使用 `npm run api-server``npm run dev:rust``npm run dev`这些脚本会按“shell 环境优先、`.env.local` 覆盖 `.env`”合并配置。
3.`GET /api/auth/login-options` 只返回 `["password"]`,说明短信入口没有被服务端配置打开,前端只是按 contract 正常降级。
4.`SMS_AUTH_ENABLED=true` 生效时,`GET /api/auth/login-options` 至少应返回 `["phone", "password"]`,短信登录页签才会重新出现。
## 10. 2026-05-07 前端代理端口错配修复记录
如果 Rust API 直连返回 `["phone", "password"]`,但从前端域名请求 `GET /api/auth/login-options` 返回 `500`,短信页签同样会消失。此时不是登录 UI 被删除,而是 `AuthGate` 按第 5.3 节降级成 `["password"]`
本地排查顺序固定为:
1. 先请求 `http://127.0.0.1:3000/api/auth/login-options`,确认前端代理是否成功返回 JSON。
2. 再请求当前 Rust API 目标,例如 `http://127.0.0.1:3100/api/auth/login-options``http://127.0.0.1:8082/api/auth/login-options`
3. 若直连 API 成功而 3000 返回 `500`,检查 `RUST_SERVER_TARGET``GENARRATIVE_API_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET` 是否指向仍在监听的 API 端口。
4. `npm run dev` / `npm run dev:rust` 完整栈默认由脚本计算 API 端口;加载 `.env.local` 给后端使用后,脚本必须重新固定 `RUST_SERVER_TARGET`,避免 `.env.local` 中的旧代理目标覆盖本次启动的实际 API 端口。
5. `npm run dev:web` 只启动前端,不会自动拉起 Rust API如果单独使用它必须同时确认其打印的 backend target 已有 `api-server` 正在监听。

View File

@@ -0,0 +1,948 @@
# 创意互动内容生成 Agent 技术方案 2026-05-05
## 1. 目标
构建一个基于 LangChain-Rust 的创意互动内容生成 Agent。用户输入文字、图片、文档或混合素材后Agent 不通过规则分类硬选玩法,而是以模型为核心完成理解、判断、规划和执行:先理解用户真正想表达的创作意图,再选择当前可用的互动内容模板,最后调用拼图模块工具把内容填入草稿契约中。
当前版本只支持拼图模板。RPG 世界、大鱼吃小鱼、抓大鹅 Match3D、方洞挑战等模板必须在 Agent 可见能力中标记为 `unsupported`,不能创建这些玩法的目标 session。即便只有拼图可用Agent 仍必须先展示多个拼图模板候选,用户选择某个模板后,再确认该模板下的关卡模式、关卡数和预计积分范围,确认后才进入草稿生成。
本方案不再把 Agent 设计成“规则路由 workflow”。规则只作为安全护栏、契约校验和成本控制。真正的模板选择、素材理解、草稿构思、行动顺序和补问判断由模型通过工具调用和反思循环完成。
## 2. LangChain-Rust 选型依据
当前可参考 `langchainrust` crate 作为 Rust 侧 Agent 编排底座。官方 crate 页面显示 `0.2.18` 支持 Agents、Tools、Memory、Chains、RAG、BM25、Hybrid Retrieval、LangGraph、Typed/JSON output parser、Function Calling、Callbacks 等能力;其中 Agent 层包括 ReActAgent、FunctionCallingAgent、AgentExecutor 和 LangGraphMemory 层包括 Buffer、Window、Summary、SummaryBuffer、Persistent。
落地时建议先以 `langchainrust = "0.2.18"` 做隔离性 PoC不直接替换现有 `platform-llm`。若 crate API 与 docs.rs 最新构建存在差异,以源码和本地编译结果为准,先封装一层 `platform-agent` adapter再接入 `api-server`
参考:
- `langchainrust` crates.io/docs.rs 页面https://docs.rs/crate/langchainrust/latest
- 仓库主页https://github.com/atliliw/langchainrust
## 2.1 Agent 模型与多模态输入
创意互动内容 Agent 的感知、思考、反思和自然语言草稿修订统一使用 APIMart OpenAI 兼容 Responses API 的 `gpt-5`。这里的 `gpt-5` 负责理解用户文字和图片、选择拼图模板、规划草稿字段和生成结构化工具调用参数;拼图图片生成仍由拼图模块图片工具使用 `gpt-image-2`,不要把“理解/规划模型”和“生图模型”混在同一个配置里。
请求协议以 APIMart 文档 `OpenAI 多模态响应接口` 为准:
```text
POST https://api.apimart.ai/v1/responses
model: gpt-5
input[].content[].type: input_text | input_image
```
Phase 1 的 `platform-agent` / `platform-llm` 必须支持下面的项目内请求结构:
```ts
export interface CreativeAgentMultimodalInputPart {
type: 'input_text' | 'input_image';
text?: string;
imageUrl?: string;
}
export interface CreativeAgentGpt5Request {
model: 'gpt-5';
input: Array<{
role: 'system' | 'user' | 'assistant';
content: CreativeAgentMultimodalInputPart[];
}>;
stream: boolean;
tools?: CreativeAgentToolSchema[];
}
```
落地约束:
1. Agent 入口支持文本 + 图片多模态输入,首版至少支持 1 张图片,协议层预留多图。
2. 图片必须先进入资产系统Agent 请求使用可访问的 `readUrl` 或受控 Data URISpacetimeDB 不保存大图 base64。
3. `platform-llm` 当前已有 Responses 协议骨架,但 Phase 1 需要把 content part 从纯文本扩展成 `input_text` / `input_image` 两类。
4. `CREATION_TEMPLATE_LLM_MODEL` 等旧文本创作模型不能作为创意互动内容 Agent 的默认模型;本 Agent 必须显式使用 `gpt-5`
5. 如果 LangChain-Rust adapter 暂时无法直接表达多模态 Responses 请求,应在 `platform-agent` 内桥接到 `platform-llm` 的多模态 Responses client不能退回纯文本摘要替代图片理解。
6. 模型工具调用可用 APIMart Responses 的 `tools` 能力承载;工具真正执行仍由 `platform-agent` 注册表和后端 typed Tool 控制。
## 3. 总体架构
Agent 由六大核心模块组成,形成“感知 -> 思考 -> 记忆 -> 行动 -> 反思 -> 协作”的闭环。
```text
用户图文输入
-> 感知 Perception
-> 思考 Reasoning
-> 记忆 Memory
-> 行动 Action
-> 反思 Reflection
-> 协作 Collaboration
-> 目标玩法草稿 / 追问 / 人工确认
```
Rust 分层建议:
```text
server-rs/crates/platform-agent
langchain_adapter.rs LangChain-Rust 封装,屏蔽第三方 API 变化
agent_graph.rs 六模块 LangGraph / AgentExecutor 编排
tools.rs 工具注册与权限边界
output_parsers.rs Typed / JSON 输出解析
callbacks.rs Trace、SSE、成本与错误事件
server-rs/crates/module-creative-agent
domain.rs Agent 会话、目标、模板语义、计划、反思记录
commands.rs 创建会话、写入输入、确认计划、保存结果
application.rs 纯领域校验、阶段迁移、契约门槛
errors.rs 字段错误与领域错误
server-rs/crates/api-server/src/creative_agent.rs
HTTP / SSE facade调用 platform-agent 和 spacetime-client
```
DDD 边界保持不变:
- `platform-agent` 负责 Agent 编排和工具调用抽象,不保存业务真相。
- `module-creative-agent` 只放纯领域类型、阶段、校验和决策记录结构。
- `api-server` 负责鉴权、SSE、LLM/视觉/工具编排。
- `spacetime-module` 保存会话、输入、记忆索引、目标玩法绑定和审计事件。
- 当前目标玩法只允许拼图。拼图模板协议、积分范围、单关卡/多关卡图片生成计划、草稿校验和工具实现全部封装在 `module-puzzle` / 拼图相关 facade 中;通用 Agent 不复制拼图字段推导逻辑。
## 4. 六大核心模块
### 4.1 感知模块 Perception
职责:把用户输入的字、图、文档和上下文变成模型可推理的多模态语义状态。
它不是关键词分类器。它要做的是“看懂素材”和“看懂用户想要什么”。
输入:
```ts
export interface CreativePerceptionInput {
text: string;
documents: CreativeTextAttachment[];
images: CreativeImageAttachment[];
currentUserProfile?: CreativeUserPreferenceSnapshot | null;
entryContext: 'creation_home' | 'puzzle_workspace' | 'gallery_remix' | 'draft_restore';
}
```
输出:
```ts
export interface CreativePerceptionState {
userIntent: string;
emotionalTone: string;
targetAudience: string | null;
sourceMaterials: CreativeMaterialSummary[];
visualUnderstanding: CreativeImageUnderstanding[];
constraints: CreativeConstraint[];
uncertainties: CreativeUncertainty[];
}
```
实现方式:
1. 文档输入复用 `creationAgentDocumentInput`,再交给 LangChain-Rust 的 text splitter / document chain 做摘要。
2. 图片输入必须先上传为资产Agent 只拿 `readUrl`、缩略图、尺寸和视觉摘要,不把大 data URL 存入 SpacetimeDB。
3. 图像理解首版直接通过 APIMart Responses API 的 `gpt-5` 多模态输入完成返回主体、场景、风格、OCR、构图线索和安全风险后续如独立 `platform-vision`,也必须保持相同的文本/图像内容块契约。
4. 模板和玩法说明通过 RAG 检索注入,而不是写死规则。检索源包括玩法模板注册表、拼图草稿契约、已有优秀作品摘要和玩法适配说明。
LangChain-Rust 对应能力:
- Document loader / splitter / summarization chain
- RAG、BM25、Hybrid retrieval
- Typed / JSON output parser
- Callback 将感知进度推给前端 SSE
### 4.2 思考模块 Reasoning
职责:让模型在可用工具、用户意图和玩法知识之间主动做计划。
思考模块不是 `if playType == puzzle` 的路由函数,而是一个 FunctionCallingAgent 或 LangGraph 中的 planner 节点。模型可以自主选择:
- 先调用拼图模板知识检索
- 先返回拼图模板目录
- 请求用户选择模板
- 用户选中模板后,再确认该模板的关卡模式、关卡数和积分范围
- 确认后生成单关卡或多关卡草稿计划
- 先调用图片理解
- 先问用户一个关键问题
- 委托拼图专家 Agent
当前版本的思考模块不能选择非拼图玩法作为行动目标。如果模型认为输入更适合 RPG、Match3D、大鱼或方洞挑战应输出“当前仅支持拼图模板”的说明并尝试给出可转化为拼图的创意方案若无法转化应进入 `waiting_user`,不能创建非拼图 session。
核心状态:
```ts
export interface CreativeReasoningState {
goal: string;
candidatePlayTypes: CreativePlayCandidate[];
selectedPlayType: CreativePlayType | null;
selectedTemplateId: string | null;
selectedPuzzleTemplate: PuzzleCreativeTemplateSelection | null;
selectedImageGenerationPlan: PuzzleImageGenerationPlan | null;
plan: CreativeAgentPlanStep[];
confidence: number;
needUserClarification: boolean;
rationale: string;
}
```
建议使用 LangChain-Rust
- `FunctionCallingAgent` 作为主决策 Agent所有玩法能力以工具形式暴露。
- `AgentExecutor` 控制最大迭代次数、超时、工具调用错误回传。
- `LangGraph` 表达长链路:`perceive -> plan -> act -> reflect -> finalize`,允许循环和人工确认。
- `JsonOutputParser` / `TypedOutputParser` 保证模型最终输出能落到 Rust/TS shared contract。
系统提示词要明确:
1. 你是创意互动内容生成 Agent不是分类器。
2. 你可以相信自己的多模态理解能力。
3. 你应选择能最大化互动体验的玩法,而不是机械匹配关键词。
4. 当前产品只开放拼图模板,非拼图模板只能解释为暂不支持,不能调用非拼图工具。
5. 即便只有拼图玩法可用,也必须先显式展示多个拼图子模板;用户选中模板后,才展示选择理由、关卡配置和预计积分范围。
6. 当输入足够明确时不要过度追问。
7. 当合规、素材权属、人物肖像或儿童内容存在风险时进入确认或降级。
### 4.3 记忆模块 Memory
职责:让 Agent 能利用当前会话、用户偏好、历史作品和反思经验,而不是每次从零判断。
记忆分四层:
```text
短期记忆:当前会话消息、工具调用、草稿状态
工作记忆:本次任务的目标、计划、候选、未解决问题
长期记忆:用户偏好、常用玩法、作品风格、发布反馈
反思记忆:过去失败原因、模板误选案例、修正策略
```
推荐结构:
```ts
export interface CreativeAgentMemorySnapshot {
shortTermSummary: string;
workingPlan: CreativeAgentPlanStep[];
retrievedUserPreferences: CreativeUserPreference[];
retrievedTemplateMemories: CreativeTemplateMemory[];
retrievedReflections: CreativeReflectionMemory[];
}
```
落地方式:
1. LangChain-Rust Memory 用于单次 AgentExecutor 内的短期上下文,可用 Buffer / Window / SummaryBuffer。
2. SpacetimeDB 保存长期真相:`creative_agent_session``creative_agent_message``creative_agent_reflection``creative_agent_target_binding`
3. 向量/混合检索保存可召回记忆:用户偏好、模板选择结果、发布后反馈、失败反思。首版可用 SQLite 或 Redis feature生产再评估 Qdrant/Redis。
4. 每次生成结束后写一条反思记忆:选择了什么模板、为什么、哪些字段由模型推断、用户是否接受、是否返回编辑。
记忆使用原则:
- 记忆给模型参考,不替代用户本轮输入。
- 用户本轮明确要求优先级最高。
- 任何长期记忆都要带来源、时间和置信度。
- 涉及个人隐私、图片内容和未发布作品时只在用户私有 namespace 检索。
### 4.4 行动模块 Action
职责:把 Agent 的计划变成可审计、可回滚、可测试的工具调用。
所有对系统产生影响的操作都必须以 LangChain-Rust Tool 的形式注册。模型通过 function calling 选择工具,工具内部再调用现有服务或 SpacetimeDB facade。
首批工具:
```text
perceive_image(imageAssetId)
retrieve_puzzle_template_catalog(query)
retrieve_user_creation_memory(userId, query)
create_puzzle_agent_session(payload)
compile_puzzle_draft(sessionId, payload)
plan_puzzle_level_images(payload)
generate_puzzle_level_images(sessionId, payload)
select_puzzle_template(payload)
confirm_puzzle_template(payload)
apply_puzzle_draft_natural_language_edit(sessionId, payload)
start_puzzle_draft_test_run(sessionId, payload)
ask_user_clarification(question, options?)
request_user_confirmation(summary, candidates)
validate_target_draft(playType, draft)
save_creative_reflection(payload)
```
工具设计要求:
1. 工具描述要清楚告诉模型何时使用,而不是由外层规则决定。
2. 工具输入必须是 JSON Schema / Rust typed struct禁止自由字符串拼接。
3. 工具只做一件事。比如“创建拼图 session”和“编译拼图草稿”分开。
4. 工具返回结构化结果,包含 `ok``summary``nextSuggestedTools``warnings`
5. 所有写操作必须鉴权,不能信任模型传入的 `ownerUserId`
6. 工具调用审计写入 SpacetimeDB便于排障和反思。
7. 当前工具注册表只能暴露拼图工具。非拼图工具即使已有实现,也不能注册给当前 Agent。
拼图草稿不是路由器手填,而是 Action 模块通过工具让 Agent 产出 typed payload。这里的“草稿字段”只指用户表单和 Agent 自然语言都共同编辑的那一组字段:
```ts
export interface CreativePuzzleDraftToolInput {
templateId: string;
templateCostRange: PuzzleTemplateCostRange;
workTitle: string;
workDescription: string;
workTags: string[];
levels: CreativePuzzleLevelDraftInput[];
}
export interface CreativePuzzleLevelDraftInput {
levelName: string;
pictureDescription: string;
pictureReference?: string | null;
}
```
工具内部负责映射到现有 `PuzzleAgentActionRequest``PuzzleResultDraft`,并调用拼图领域校验。`workTitle``workDescription``workTags``levels[].levelName``levels[].pictureDescription``levels[].pictureReference` 是 Agent 直接写入的草稿真相;`summary``anchorPack``forbiddenDirectives``imagePrompt`、候选图等都属于拼图模块内部派生或生成结果,不作为 Agent 的直接填表目标。
拼图模块必须额外暴露模板和多关卡图片协议:
```ts
export interface PuzzleCreativeTemplateProtocol {
templateId: string;
title: string;
description: string;
supportedLevelMode: 'single' | 'multi' | 'single_or_multi';
defaultLevelCount: number;
minLevelCount: number;
maxLevelCount: number;
costRange: PuzzleTemplateCostRange;
requiredDraftFields: string[];
imageGenerationPolicy: PuzzleTemplateImageGenerationPolicy;
}
export interface PuzzleTemplateCostRange {
minPoints: number;
maxPoints: number;
pricingUnit: 'point';
reason: string;
}
export interface PuzzleTemplateImageGenerationPolicy {
allowUploadedImageDirectly: boolean;
allowGeneratedImages: boolean;
allowPerLevelReferenceImage: boolean;
defaultCandidateCountPerLevel: number;
}
export interface PuzzleImageGenerationPlan {
mode: 'single_level' | 'multi_level';
levels: CreativePuzzleLevelDraftInput[];
estimatedCostRange: PuzzleTemplateCostRange;
}
```
积分范围由拼图模板协议提供Agent 只能解释和选择,不能自行发明价格。真实扣费仍以后端钱包/任务系统最终结算为准。
### 4.5 反思模块 Reflection
职责:让 Agent 在交付前检查自己的选择是否真的适合用户,而不是一次模型输出就结束。
反思节点运行在每次关键行动后:
1. 模板选择后:检查是否已经向用户显式展示拼图模板和积分范围。
2. 用户确认前:检查是否误承诺了非拼图模板或非真实价格。
3. 草稿填充后:检查字段是否完整、玩法体验是否成立。
4. 图片使用前:检查单关卡/多关卡图片计划是否与模板协议一致。
5. 最终交付前:检查是否需要用户确认。
反思输出:
```ts
export interface CreativeReflectionReport {
pass: boolean;
score: number;
issues: CreativeReflectionIssue[];
revisionInstruction?: string | null;
shouldAskUser: boolean;
shouldTryAlternativePlayType: boolean;
}
```
实现方式:
- 用 LangGraph 增加 `reflect` 节点。
- 反思模型拿到感知状态、计划、工具调用结果和目标草稿。
- 如果 `pass=false` 且迭代次数未超限,回到 `plan``act`
- 如果问题是用户偏好缺失,调用 `ask_user_clarification`
- 如果问题是契约字段缺失,调用目标玩法 draft 修复工具。
- 如果问题是未展示模板选择或积分范围,回到模板确认节点。
- 如果问题是多关卡计划超出模板 `maxLevelCount`,调用拼图计划修复工具。
硬性终止条件:
- 最大反思循环 2 次。
- 同一工具同一参数失败 2 次后停止并返回可读错误。
- 预算超限时返回当前可用草稿和补救建议。
反思记忆要沉淀:
- 模板误选原因。
- 用户是否接受模板积分范围。
- 单关卡或多关卡图片生成计划是否被用户调整。
- 用户手动改选的玩法。
- 结果页返回编辑最多的字段。
- 发布失败 blockers。
### 4.6 协作模块 Collaboration
职责:把复杂创意任务拆给多个专长 Agent而不是让单个提示词吞掉所有任务。
当前首版只开放拼图协作,不开放其它玩法子 Agent。建议四个子 Agent
```text
创意导演 Agent理解用户目标决定整体方向和互动体验。
视觉解读 Agent理解图片、构图、主体、风格和可交互线索。
拼图模板策展 Agent基于拼图模板协议和历史作品选择候选拼图模板并读取积分范围。
拼图专家 Agent生成单关卡或多关卡拼图草稿 payload 和图片生成计划。
契约审校 Agent检查字段、发布门槛、积分展示、安全边界和可恢复性。
```
LangChain-Rust 落地:
- 用 LangGraph 的 subgraph 表达子 Agent。
- 子 Agent 共享 `CreativeAgentState`,但只能写自己负责的字段。
- 主 Agent 通过 handoff 工具委托子任务。
- 必要时并行执行视觉解读和拼图模板知识检索,再由创意导演合并。
- 协作结果由契约审校 Agent 最终检查。
协作不是增加 UI 复杂度。前端仍只看到一个 Agent但后端内部有多个可观测步骤SSE 推送简短阶段即可。
## 5. Agent 状态机
LangGraph 状态建议:
```rust
pub struct CreativeAgentGraphState {
pub session_id: String,
pub owner_user_id: String,
pub perception: Option<CreativePerceptionState>,
pub memory: Option<CreativeAgentMemorySnapshot>,
pub reasoning: Option<CreativeReasoningState>,
pub tool_results: Vec<CreativeToolResult>,
pub reflection: Option<CreativeReflectionReport>,
pub target_binding: Option<CreativeTargetSessionBinding>,
pub final_response: Option<CreativeAgentFinalResponse>,
pub iteration_count: u32,
}
```
Graph 节点:
```text
load_memory
perceive_input
plan_with_agent
act_with_tools
reflect_result
collaborate_if_needed
finalize_or_ask_user
persist_memory
```
边:
```text
load_memory -> perceive_input
perceive_input -> plan_with_agent
plan_with_agent -> act_with_tools
act_with_tools -> reflect_result
reflect_result(pass) -> finalize_or_ask_user
reflect_result(revise) -> plan_with_agent
reflect_result(collaborate) -> collaborate_if_needed
collaborate_if_needed -> act_with_tools
finalize_or_ask_user -> persist_memory
```
## 6. 数据契约
新增 shared contracts
```text
packages/shared/src/contracts/creativeAgent.ts
server-rs/crates/shared-contracts/src/creative_agent.rs
```
核心 DTO
```ts
export type CreativeAgentStage =
| 'perceiving'
| 'thinking'
| 'remembering'
| 'selecting_puzzle_template'
| 'waiting_template_confirmation'
| 'planning_puzzle_levels'
| 'acting'
| 'reflecting'
| 'collaborating'
| 'waiting_user'
| 'target_ready'
| 'failed';
export interface CreativeAgentSessionSnapshot {
sessionId: string;
stage: CreativeAgentStage;
perception: CreativePerceptionState | null;
reasoning: CreativeReasoningState | null;
puzzleTemplateCatalog: PuzzleCreativeTemplateProtocol[];
puzzleTemplateSelection: PuzzleCreativeTemplateSelection | null;
puzzleImageGenerationPlan: PuzzleImageGenerationPlan | null;
reflection: CreativeReflectionReport | null;
targetBinding: CreativeTargetSessionBinding | null;
messages: CreativeAgentMessage[];
updatedAt: string;
}
export interface PuzzleCreativeTemplateSelection {
templateId: string;
title: string;
reason: string;
costRange: PuzzleTemplateCostRange;
supportedLevelMode: 'single' | 'multi' | 'single_or_multi';
selectedLevelMode: 'single_level' | 'multi_level';
plannedLevelCount: number;
requiresUserConfirmation: true;
}
```
HTTP facade
```text
POST /api/runtime/creative-agent/sessions
GET /api/runtime/creative-agent/sessions/{sessionId}
POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream
POST /api/runtime/creative-agent/sessions/{sessionId}/confirm
POST /api/runtime/creative-agent/sessions/{sessionId}/cancel
```
SSE 事件:
```text
stage
agent_message_delta
puzzle_template_catalog
puzzle_template_selection
puzzle_cost_range
puzzle_level_plan
tool_started
tool_completed
reflection
target_session
need_user_input
session
error
```
## 7. SpacetimeDB 持久化
新增表:
```text
creative_agent_session
creative_agent_message
creative_agent_input_asset
creative_agent_tool_call
creative_agent_reflection
creative_agent_target_binding
creative_agent_memory_index
puzzle_creative_template_snapshot
puzzle_creative_level_generation_plan
```
关键约束:
1. SpacetimeDB 保存结构化真相和 JSON 快照,不保存大图片 data URL。
2. 工具调用和反思必须可追溯,便于排查模型为什么选择某个模板。
3. `creative_agent_memory_index` 只保存记忆元数据和向量索引引用,不直接承担向量数据库职责。
4. 表结构变更必须同步 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 bindings。
5. `creative_agent_session` 必须保存当前拼图模板目录、已确认的模板选择快照和积分范围,保证刷新后仍能恢复“先选模板、再确认配置”的两段式状态。
6. `puzzle_creative_level_generation_plan` 保存单关卡/多关卡图片生成计划,包括每关 `level_id``level_name``picture_description``image_prompt``generation_status``candidate_count``estimated_cost_points`
7. 非拼图玩法不新增 target binding避免后续误恢复到暂不支持的玩法。
8. 自然语言修订草稿字段要记录工具调用、原始用户指令、结构化 patch 和修改后的 draft 版本,便于撤销、审计和反思。
9. 试玩 run 与草稿 session 需要有绑定关系,确保“立即玩”后返回结果页能恢复同一草稿。
## 8. 拼图首版落地
拼图首版不是“规则判断为拼图”而是模型通过工具和反思得出选择。但产品边界明确当前只支持拼图模板。Agent 必须把非拼图创意转化为拼图可承接的方案,或告诉用户当前不支持该模板。
### 8.1 强制模板选择与积分展示
任何草稿生成前都必须先进入 `selecting_puzzle_template``waiting_template_confirmation`
前端至少展示:
- 拼图模板标题
- 模板缩略图或示意图
- Agent 选择理由
- 支持单关卡、多关卡或二者皆可
- 计划关卡数
- 预计积分范围,例如 `预计消耗 8 到 18 光点`
积分范围来自拼图模板协议:
```ts
export interface PuzzleTemplateCostRange {
minPoints: number;
maxPoints: number;
pricingUnit: 'point';
reason: string;
}
```
Agent 可以解释 `reason`,但不能修改 `minPoints` / `maxPoints`。如果模板协议没有积分范围,该模板不能展示为可选项。
Agent 可调用工具:
```text
retrieve_puzzle_template_catalog
inspect_puzzle_draft_contract
select_puzzle_template
confirm_puzzle_template
plan_puzzle_level_images
create_puzzle_agent_session
compile_puzzle_draft
apply_puzzle_draft_natural_language_edit
validate_puzzle_result_preview
select_uploaded_image_as_puzzle_cover
generate_puzzle_images
start_puzzle_draft_test_run
return_to_puzzle_result
```
### 8.2 拼图模板协议
拼图模板协议应封装在拼图模块,不放在通用 Agent
```text
server-rs/crates/module-puzzle/src/creative_templates.rs
server-rs/crates/module-puzzle/src/creative_tools.rs
server-rs/crates/shared-contracts/src/puzzle_creative_template.rs
packages/shared/src/contracts/puzzleCreativeTemplate.ts
```
协议至少包含:
```ts
export interface PuzzleCreativeTemplateProtocol {
templateId: string;
title: string;
summary: string;
previewImageSrc: string | null;
supportedLevelMode: 'single' | 'multi' | 'single_or_multi';
minLevelCount: number;
maxLevelCount: number;
defaultLevelCount: number;
costRange: PuzzleTemplateCostRange;
imagePolicy: PuzzleTemplateImageGenerationPolicy;
draftFieldHints: PuzzleTemplateDraftFieldHints;
}
```
模板示例:
```json
{
"templateId": "puzzle.family-keepsake",
"title": "家庭纪念拼图",
"supportedLevelMode": "single_or_multi",
"minLevelCount": 1,
"maxLevelCount": 6,
"defaultLevelCount": 3,
"costRange": {
"minPoints": 8,
"maxPoints": 24,
"pricingUnit": "point",
"reason": "按关卡数、每关候选图数量和是否使用上传图估算"
}
}
```
### 8.3 单关卡与多关卡图片生成
拼图草稿必须支持两种计划:
```ts
export type PuzzleLevelGenerationMode = 'single_level' | 'multi_level';
export interface PuzzleImageGenerationPlan {
mode: PuzzleLevelGenerationMode;
templateId: string;
estimatedCostRange: PuzzleTemplateCostRange;
levels: PuzzleImageGenerationPlanLevel[];
}
export interface PuzzleImageGenerationPlanLevel {
levelId: string;
levelName: string;
pictureDescription: string;
imagePrompt: string;
pictureReference?: string | null;
candidateCount: number;
}
```
单关卡:
- `levels.length = 1`
- 可使用上传图直出,也可生成一张候选图。
- 适合纪念照、商品图、单张海报、单主题知识图。
多关卡:
- `levels.length` 必须在模板 `minLevelCount``maxLevelCount` 之间。
- 每关有独立 `levelName``pictureDescription``imagePrompt`
- 可选择每关生成一张图,也可第一关使用上传图、后续关卡生图。
- 适合故事型照片组、知识步骤、活动流程、系列商品、节日卡片组。
生成工具建议:
```ts
export interface GeneratePuzzleLevelImagesToolInput {
sessionId: string;
plan: PuzzleImageGenerationPlan;
imageModel: 'gpt-image-2';
}
export interface GeneratePuzzleLevelImagesToolOutput {
draft: PuzzleResultDraft;
levels: PuzzleDraftLevel[];
costEstimate: PuzzleTemplateCostRange;
generatedCount: number;
uploadedCount: number;
}
```
工具内部可以复用现有 `generate_puzzle_images` action但必须以 `levelId` 为粒度逐关执行,或新增拼图后端 action `generate_puzzle_level_images` 批量处理。批量 action 仍归属拼图模块,通用 Agent 只负责调用。
### 8.5 模板草稿字段填充与自然语言修订
Agent 创作互动内容的本质就是向模板中的草稿字段填充内容。拼图模板草稿字段是唯一创作真相表单化创作页是这些字段的可视化编辑器Agent 对话是这些字段的自然语言编辑器,二者属于同一条创作流程。
通用 Agent 只负责把用户自然语言转成“草稿字段填充 / 修订意图”,真正的字段写入仍通过拼图模块 Tool 完成:
```ts
export interface PuzzleDraftFieldEditInstruction {
scope: 'work' | 'level' | 'tags' | 'cover' | 'images' | 'all';
operation: 'set' | 'append' | 'replace' | 'remove' | 'reorder';
fieldPath: string;
value: string | string[] | boolean | number | null;
rationale: string;
}
export interface ApplyPuzzleDraftNaturalLanguageEditToolInput {
sessionId: string;
userInstruction: string;
currentDraftSnapshot: PuzzleResultDraft;
}
export interface ApplyPuzzleDraftNaturalLanguageEditToolOutput {
updatedDraft: PuzzleResultDraft;
editInstructions: PuzzleDraftFieldEditInstruction[];
needsUserConfirmation: boolean;
confirmationSummary: string;
}
```
这个工具由拼图模块封装,内部要做三步:
1. 用模型把自然语言改写成结构化草稿字段 patch。
2. 用拼图领域规则校验 patch 是否会破坏草稿约束。
3. 通过现有草稿保存接口写回同一份 `PuzzleResultDraft``formDraft`
典型自然语言字段修订场景:
- “把这张图改成更适合家庭纪念。”
- “第二关再多加一张风景图。”
- “标题别太正式,轻松一点。”
- “主题标签里去掉商品感,增加温暖和节日感。”
Agent 不能直接篡改结果页 DOM也不能绕过拼图草稿字段写最终发布数据。用户在表单里手动编辑、用户用自然语言让 Agent 编辑,本质上都必须落到同一份草稿字段 patch。
### 8.6 立即玩与试玩闭环
生成好的互动内容必须能立即玩到。对拼图来说Agent 完成草稿后必须直接导向现有 `puzzle-result`,并提供明确的“立即玩”入口。
闭环要求:
1. 草稿创建成功后,结果页首屏就能看到 `Play` 或“立即玩”按钮。
2. 点击后直接启动 `puzzle-runtime`,不需要用户再手动跳过别的中间页。
3. 如果当前草稿还在生成更多关卡图片,已完成关卡可先试玩,后续图片再逐关补齐。
4. 试玩入口必须复用现有 `PuzzleRuntimeShell``startPuzzleRun` 链路。
5. 试玩后返回结果页时,仍然停留在同一草稿上下文,不丢失表单草稿状态。
结果页到运行态之间的切换不由通用 Agent 再次判断,而是由拼图模块暴露的 `start_run` / `resume_run` / `return_to_result` 工具完成。
### 8.4 拼图专家输出
拼图专家 Agent 需要产出:
```ts
export interface PuzzleCreativeDraftIntent {
templateSelection: PuzzleCreativeTemplateSelection;
imageGenerationPlan: PuzzleImageGenerationPlan;
workTitle: string;
workDescription: string;
workTags: string[];
levels: CreativePuzzleLevelDraftInput[];
}
```
字段映射仍必须对齐现有契约:
- `PuzzleResultDraft.workTitle`
- `PuzzleResultDraft.workDescription`
- `PuzzleResultDraft.workTags`
- `PuzzleResultDraft.levels[].levelName`
- `PuzzleResultDraft.levels[].pictureDescription`
- `PuzzleResultDraft.levels[].pictureReference`
领域校验仍由 `module-puzzle` 负责。Agent 可以创造内容,但不能绕过发布 blockers。表单化创作页与 Agent 自然语言修订都只是这些字段的不同编辑界面。
## 9. 前端体验
前端只呈现一个“智能创作 Agent”入口
1. 用户输入文字、上传图片或文档。
2. 前端显示 Agent 正在理解素材、构思玩法、生成草稿。
3. Agent 必须先展示拼图模板目录。
4. 用户选择模板并确认关卡模式、关卡数和预计积分范围后Agent 才能生成草稿。
5. 生成完成后进入拼图结果页,并提供“立即玩”入口,点击后直接进入拼图运行态。
6. 结果页保留原来的表单化创作能力,包括作品标题、作品描述、作品标签、关卡名称、关卡图面描述、关卡图面参考、关卡图片生成和选图;这些控件编辑的是同一份模板草稿字段。
7. Agent 对话区继续可用,用户可以用自然语言补填或修订模板草稿字段,后端通过拼图模块 Tool 生成结构化 patch 并回写同一份草稿;可写字段仅限 `workTitle``workDescription``workTags``levels[].levelName``levels[].pictureDescription``levels[].pictureReference`
8. 若 Agent 有关键不确定点,弹出独立确认面板。
9. 用户确认或回答后Agent 继续执行并进入拼图结果页或保持在当前草稿上下文。
UI 不展示大段规则说明,只展示:
- 当前阶段
- 简短 Agent 回复
- 拼图模板选择和积分范围
- 单关卡 / 多关卡计划
- 需要确认的问题
- 最终草稿入口
- 立即玩入口
- 表单化草稿字段编辑入口
- Agent 自然语言补填 / 修订入口
移动端优先,上传图片预览使用横向缩略图条,确认面板用底部抽屉。
## 10. 安全与治理
模型能力是核心,但不能没有边界:
1. 工具权限边界:模型只能调用已注册工具,不能直接写库。
2. 契约边界Typed parser 和目标玩法 validator 必须通过。
3. 成本边界AgentExecutor 设置最大迭代、最大工具调用、超时和预算。
4. 内容安全:人物肖像、儿童内容、版权图、隐私图进入确认或拒绝。
5. 记忆安全:长期记忆按用户 namespace 隔离。
6. 可观测性Callbacks 记录每个节点、工具、token、耗时和错误。
## 11. 分阶段落地
### Phase 1LangChain-Rust PoC + 拼图闭环
- 新增 `platform-agent` PoC。
- 封装 LangChain-Rust FunctionCallingAgent / AgentExecutor。
- 注册拼图相关工具。
- 只支持拼图模板;非拼图模板在 Agent 能力中标记为暂不支持。
- 强制展示拼图模板选择、选择理由和预计积分范围。
- 支持文字 + 单图输入,模型自主选择拼图模板并填入草稿字段。
- 支持单关卡图片生成计划和多关卡图片生成计划。
- 生成后的拼图内容点击“立即玩”可直接进入 `puzzle-runtime`
- 保留结果页原有表单化草稿字段编辑入口。
- 支持 Agent 自然语言补填 / 修订模板草稿字段,并通过拼图模块 Tool 回写草稿。
- SSE 推送六模块阶段。
- 结果进入现有 `puzzle-result`
### Phase 2记忆与反思闭环
- 增加 `creative_agent_tool_call``creative_agent_reflection``creative_agent_memory_index`
- 引入短期 Memory 和长期检索。
- 记录用户改选、发布失败、返回编辑等反馈。
- 反思循环支持自动修正草稿。
### Phase 3多玩法协作
- 在产品开放后再增加 Match3D、大鱼吃小鱼、方洞挑战、RPG 世界专家 Agent。
- 使用 LangGraph subgraph 做多 Agent 协作。
- 支持多候选玩法对比和人工确认。
## 12. 验收标准
功能验收:
1. 用户输入含图文材料时Agent 能说出它理解到的创作意图。
2. Agent 能调用拼图模板知识检索,而不是靠硬编码规则选模板。
3. Agent 必须显式展示拼图模板、选择理由和预计积分范围,用户确认后才生成草稿。
4. 非拼图输入不会创建其它玩法 session只能转成拼图方案或提示暂不支持。
5. Agent 能自主选择拼图模板并生成可通过契约校验的 `PuzzleResultDraft`
6. 当图片更适合直接作为拼图图面时Agent 能选择 uploaded candidate而不是强制生图。
7. Agent 能生成单关卡图片计划,也能生成多关卡图片计划。
8. 多关卡计划中每关都有独立 `levelName``pictureDescription``imagePrompt`
9. 多关卡图片生成逻辑通过拼图模块工具执行,通用 Agent 不直接拼接 `levelsJson`
10. 生成好的拼图内容点击“立即玩”后能直接进入 `puzzle-runtime`
11.`puzzle-runtime` 返回时仍恢复同一 `puzzle-result` 草稿上下文。
12. 结果页保留表单化草稿字段编辑能力,用户可以修改作品标题、作品描述、作品标签、关卡名称、关卡图面描述和关卡图面参考。
13. 用户用自然语言提出“把标题改轻松一点”“第二关加一张风景图”等请求时Agent 能生成结构化草稿字段 patch 并通过拼图模块 Tool 回写同一份草稿。
14. 自然语言修订草稿字段后必须重新通过拼图草稿校验和结果预览校验。
15. 当不确定时Agent 只问一个关键问题,而不是把所有字段丢给用户填写。
16. 反思节点能发现未展示积分范围、关卡数越界、草稿字段缺失或自然语言字段 patch 风险,并自动修正一次。
17. 工具调用、反思报告、模板选择、草稿字段 patch 和目标拼图 session 绑定能恢复和审计。
建议命令:
```bash
cd server-rs
cargo test -p shared-contracts creative_agent
cargo test -p module-creative-agent
cargo check -p platform-agent
cargo check -p api-server
```
涉及 SpacetimeDB schema 后:
```bash
npm run spacetime:generate -- --rust-only
npm run check:server-rs-ddd
```
前端:
```bash
npm run typecheck
npm run check:encoding
```
## 13. 当前实现状态
截至 `2026-05-05`,任务 C 已完成首版 PoC 落地:
1. `server-rs/crates/platform-agent` 已新增为 workspace member。
2. `platform-agent` 已提供项目自有的 `CreativeAgentExecutor`、工具注册表、回调事件、`gpt-5` 多模态请求适配器和 mock executor。
3. `platform-llm` 已支持 Responses 多模态输入块,`input_text``input_image` 会按 content part 序列化到请求体。
4. 任务 C 的验收命令已通过:`cargo check -p platform-agent``cargo test -p platform-agent``cargo test -p platform-llm responses_multimodal`
截至 `2026-05-05`,任务 E 的 API / SSE facade 已补充 Windows debug 稳定性修复:
1. `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 不再把 Agent 执行、模型请求、会话更新和所有 SSE 事件内联到单个大型 `async_stream` 中。
2. 当前实现使用后台 `tokio::spawn` 执行业务流程,并通过 `mpsc` / `UnboundedReceiverStream` 向 Axum 返回轻量 SSE stream执行失败会更新会话为 `failed` 并发送 SSE `error`
3. 已补实际消费 SSE body 的回归测试,覆盖 `stage``puzzle_template_catalog``done` 事件;`puzzle_template_selection``puzzle_cost_range``puzzle_level_plan` 只在用户确认后进入后续快照或流程。
## 14. 本方案相对旧方案的变化
旧方案偏“规则预筛 + workflow + Adapter”。新方案调整为
1. 模型负责理解素材、整理候选和草稿构思;最终模板由用户从多个候选中主动选择。
2. LangChain-Rust AgentExecutor / FunctionCallingAgent 承担工具调用决策。
3. LangGraph 承担六模块闭环和反思循环。
4. Memory/RAG 让 Agent 学习用户偏好和模板经验。
5. 当前只开放拼图玩法,但模板选择仍是显式 Agent 步骤,不因只有一个玩法而跳过。
6. 拼图模板协议必须携带积分范围,用户选择模板并确认配置后才进入草稿生成。
7. 单关卡/多关卡图片生成通过拼图模块 Tool 和模板协议实现,不写进通用 Agent。
8. Agent 创作方式就是填充和修订模板草稿字段;表单化创作页和 Agent 自然语言修订都操作同一份 `PuzzleResultDraft`,并围绕 `workTitle``workDescription``workTags``levels[].levelName``levels[].pictureDescription``levels[].pictureReference` 这一组字段协同。
9. Adapter 从“路由实现”降级为 Agent action tool。
10. 规则只保留为安全、契约、成本和权限边界。

View File

@@ -2,17 +2,18 @@
## 背景 ## 背景
创作中心顶部“新建作品”入口和平台创作类型弹层都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。
## 落地规则 ## 落地规则
1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts` 1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`
2. `visible` 控制玩法是否展示在新建作品入口和创作类型弹层中。 2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。
3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。
4. `title``subtitle``badge` 控制玩法卡片文案。 4. `title``subtitle``badge` 控制玩法卡片文案。
5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案。 5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制
6. `typeModal` 控制平台创作类型弹层标题和描述。 6. `typeModal` 控制平台创作类型弹层标题和描述。
7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。
8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。
## 当前状态 ## 当前状态
@@ -20,16 +21,19 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 | | 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 |
| 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 |
| 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 | | 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | | 保留入口,显示敬请期待 | | 视觉小说 | 是 | | 点击后进入视觉小说创作工作台 |
| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 |
## 验收 ## 验收
1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作中心顶部卡带和平台创作类型弹层应同步变化。 1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。
2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。
3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。
4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。
5. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。 5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”并默认展示拼图创作表单。
6. 智能创作入口隐藏后不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。
7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。

View File

@@ -408,7 +408,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module
并发与清理规则: 并发与清理规则:
- 同一个 Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,避免同一组件的多个 release 构建同时写入同一最终产物路径。 - 同一个 Rust 构建 Job 建议使用 `disableConcurrentBuilds()`,避免同一组件的多个 release 构建同时写入同一最终产物路径。
- 如果 Linux agent 未安装 `sccache`应先运行 `Genarrative-Server-Provision` 补齐缓存工具Rust 构建流水线仍必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。 - 如果 Linux/Windows agent 未安装 `sccache``sccache --version` 无法实际执行,应先补齐缓存工具Rust 构建流水线仍必须自动取消 `RUSTC_WRAPPER`,回退到直接使用 `rustc`,不能因为缺少可选缓存工具阻断真实构建。
- 生产发布流水线只能消费 `build/<version>/` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 - 生产发布流水线只能消费 `build/<version>/` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。
- `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 - `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。
-`/var/cache/genarrative-build/*/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 -`/var/cache/genarrative-build/*/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。

View File

@@ -2,44 +2,53 @@
## 背景 ## 背景
拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:先选创作模板,再补充提示词,最后直接生成首关草稿与首张拼图图 拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:优先上传参考图或填写画面描述然后直接生成首关草稿与首张拼图图。2026-05-07 起,入口表单删除 Template 模块,模板参考图只保留为历史资产,不再作为表单首屏交互
## 落地范围 ## 落地范围
1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx` 1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`
- 改为顶部标题、模板横滑区、大输入框、底部操作区的布局。 - 改为顶部标题、超大参考图上传区、大输入框、底部操作区的布局。
- 保留参考图上传、模型切换和生成草稿。 - 保留参考图上传、模型切换和生成草稿。
- 删除 Template 模块与模板卡切换入口。
- 不再提供输入框底部的 `try` 示例入口。 - 不再提供输入框底部的 `try` 示例入口。
2. `src/components/puzzle-agent/puzzleCreationTemplates.ts` 2. `src/components/puzzle-agent/puzzleCreationTemplates.ts`
- 新增拼图创作模板数据。 - 保留历史拼图创作模板数据,当前表单不再消费
- 模板来源按社交、热点、职场学习、电商、治愈、营销、儿童教育等场景抽样 - 后续若重新开放模板入口,必须先更新本文档和移动端首屏验收
- 点击模板后把模板提示词写入画面描述。
3. `public/puzzle-creation-templates/` 3. `public/puzzle-creation-templates/`
- 存放模板样例图。 - 存放历史模板样例图。
- 样例图只用于创作模板缩略图,不作为正式拼图作品资产。 - 样例图不作为正式拼图作品资产,当前也不再出现在拼图入口表单
4. `.codex/skills/gpt-image-2-apimart/` 4. `public/creation-type-references/`
- 存放平台创作入口和玩法类型弹层的参考图。
- 每个玩法一个参考图,首版用于视觉识别,不承载规则说明。
- 当前创作 Tab 顶部玩法卡带必须直接渲染这些图片,避免参考图只出现在隐藏弹层里。
5. `.codex/skills/gpt-image-2-apimart/`
- 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。 - 封装仓库内 `gpt-image-2` 的 APIMart OpenAI 兼容调用流程。
- Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。 - Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。
## UI 规则 ## UI 规则
1. 顶部只展示“创建拼图”和轻量状态标识,不写玩法规则说明。 1. 顶部主标题展示“想做个什么玩法?”和轻量状态标识,不写玩法规则说明。
2. 模板区横向滚动,移动端优先;每个模板卡包含样例图、短标题和选中态 2. 上传拼图图片区优先占据首屏左侧/上方的大块区域,移动端也必须完整露出
3. 点击模板时: - 上传区自身就是图片卡片,不再额外封装为 `platform-subpanel` 模块壳。
- 立即选中该模板 - 亮色主题下上传卡片必须使用白色或暖浅色卡面,不得显示整块黑色底
- 如果输入框为空,直接填入模板提示词 - 上传卡片固定为 1:1 正方形,避免拼图主画面在首屏出现非正方形预期
- 如果输入框已有内容,替换为该模板提示词,避免追加后变得冗长 - 上传卡片底部不再叠加文件名 bar卡片下方只保留 `点击上传拼图图片` 纯文字入口
- 上传卡片上方固定展示 `拼图画面` 标题。
- 叠在上传卡片上的 `AI重绘` 和图标必须和卡面保持足够对比,避免浅色主题重映射后不可读。
3. 画面描述输入框高度约为旧版大输入框的 1/2避免移动端把上传参考图和提交区挤出首屏。
4. 输入区保留: 4. 输入区保留:
- 参考图上传按钮。 - 上传拼图图片按钮。
- 图片模型切换按钮。 - 图片模型切换按钮。
5. 输入区不保留: 5. 输入区不保留:
- `try` 文本。 - `try` 文本。
- 示例 prompt chip。 - 示例 prompt chip。
- 画面描述输入框默认提示词或占位示例。
- 玩法规则说明。 - 玩法规则说明。
- Template 模块和模板卡切换。
## 模板抽样 ## 历史模板抽样
首批模板不追求覆盖图二所有条目,而是选择高频且适合拼图主图的代表项 以下模板曾用于表单 Template 模块。2026-05-07 后入口表单不再展示这些模板,列表仅作为历史资产索引
1. 情侣合照拼图 1. 情侣合照拼图
2. 家庭纪念拼图 2. 家庭纪念拼图
@@ -76,12 +85,45 @@ n = 1
本次 Skill 只封装生成样例图和研发复用流程不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。 本次 Skill 只封装生成样例图和研发复用流程不改变正式后端接口、扣费、OSS、SpacetimeDB 写入和发布链路。
## 2026-05-07 AI 重绘与上传直用
拼图入口上传区右上角新增 `AI重绘` 开关,默认打开;未上传拼图图片前不显示开关,上传成功后才显示。
1. `AI重绘=true`
- 上传区文案为 `点击上传拼图图片`,上传图作为生图参考图。
- 未上传图片时,输入框标题为 `画面描述`
- 已上传图片时,输入框标题为 `画面AI重绘要求提示词`
- 展示图片模型切换。
- `compile_puzzle_draft` 携带 `aiRedraw: true`,继续走 APIMart 生图与 `PUZZLE_IMAGE_GENERATION_POINTS_COST = 2` 扣费链路。
- 生成按钮展示 `消耗2光点`
2. `AI重绘=false`
- 隐藏画面描述输入框和模型切换。
- 必须上传拼图图片,按钮不展示 `消耗2光点`
- `compile_puzzle_draft` 携带 `aiRedraw: false`,后端只编译草稿和生成首关名,不调用 APIMart不进入光点扣费 wrapper。
- 后端把上传图片 Data URL 按拼图资产路径持久化,构造 `sourceType=uploaded` 的候选图并直接选为第一关正式图。
3. 上传裁剪
- 前端读取上传图原始宽高。
- 非 1:1 图片必须先弹出正方形裁剪工具,裁剪完成后再进入表单状态和提交 payload。
- 裁剪输出仍按参考图体积预算压缩,避免上传图撑爆 JSON body。
契约字段同步:
```ts
CreatePuzzleAgentSessionRequest.aiRedraw?: boolean
PuzzleAgentActionRequest.compile_puzzle_draft.aiRedraw?: boolean
```
Rust 共享契约使用 `ai_redraw: Option<bool>` 并按 camelCase 序列化为 `aiRedraw`
## 验收 ## 验收
1. 点击拼图创作后,表单首屏呈现模板横滑区和大输入框。 1. 点击拼图创作后,表单首屏呈现大参考图区和半高文本输入框。
2. 点击任一模板后,输入框填入该模板提示词 2. 输入框里没有 `try` 示例功能
3. 输入框里没有 `try` 示例功能 3. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`
4. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2` 4. 历史模板样例图文件可保留,但不出现在拼图入口表单
5. 模板样例图文件存在,并能在创作表单缩略图中显示 5. 当前创作 Tab 顶部的拼图、方洞挑战、视觉小说和 AIRP 卡片能看到对应 `creation-type-references` 图片
6. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥 6. 默认 `AI重绘` 打开时,无图状态展示 `画面描述``消耗2光点`;上传图片后输入框标题改为 `画面AI重绘要求提示词`
7. `npm run check:encoding` 通过。 7. 关闭 `AI重绘` 后隐藏画面描述输入框,生成按钮不展示 `消耗2光点`,后端直接应用上传图片为第一关图片。
8. 上传非 1:1 图片时必须先完成正方形裁剪。
9. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。
10. `npm run check:encoding` 通过。

View File

@@ -6,6 +6,9 @@
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
- [API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md](./API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md):冻结 api-server 外部服务配置边界,公共服务 URL 可保留代码默认值,非公共模型名和私有网关 URL 统一通过环境变量注入。 - [API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md](./API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md):冻结 api-server 外部服务配置边界,公共服务 URL 可保留代码默认值,非公共模型名和私有网关 URL 统一通过环境变量注入。
- [CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md](./CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md):冻结基于 LangChain-Rust 的创意互动内容生成 Agent 技术方案,明确首版只支持拼图模板、必须显式展示模板选择和积分范围,通过拼图模块 Tool/模板协议填充同一份草稿字段,支持单关卡与多关卡图片生成、立即试玩、表单化编辑和 Agent 自然语言修订草稿字段。
- [VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md](./VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md):记录视觉小说模板 `VN-03` Prompt / LLM 工具落地,包含创作底稿 Prompt、运行时 GM Prompt、repair Prompt、工具参数 schema、Responses 请求口径和定向验证结果。
- [VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md](./VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md):记录视觉小说模板 `VN-13` 实现收口、当前正式入口、表目录、路由、作品 / 运行 / 资产和负向扫描口径。
- [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。 - [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。
- [SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md](./SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md):记录方洞挑战结果页图片槽位局部生成、洞口图历史素材、运行态拖拽与点击投放交互的修正口径。 - [SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md](./SQUARE_HOLE_IMAGE_SLOT_AND_RUNTIME_INTERACTION_FIX_2026-05-06.md):记录方洞挑战结果页图片槽位局部生成、洞口图历史素材、运行态拖拽与点击投放交互的修正口径。
- [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md):冻结 Maincloud 历史残留引用禁用策略,明确后续不得新增、运行或引用 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*` 和相关测试/文档口径。 - [MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md](./MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md):冻结 Maincloud 历史残留引用禁用策略,明确后续不得新增、运行或引用 `api-server:maincloud``GENARRATIVE_SPACETIME_MAINCLOUD_*` 和相关测试/文档口径。
@@ -87,6 +90,7 @@
- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1运行时拖动、交换、合并与拆分由前端即时裁决以及移动端棋盘贴近屏幕边缘的落地边界。 - [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1运行时拖动、交换、合并与拆分由前端即时裁决以及移动端棋盘贴近屏幕边缘的落地边界。
- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。 - [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。
- [PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md](./PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md)记录拼图入口只填写画面描述、首关名默认作品名、作品描述和标签初始为空、AI 生成 6 个作品标签以及发布前校验的落地规则。 - [PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md](./PUZZLE_PICTURE_ONLY_CREATION_AND_AI_TAGS_2026-05-03.md)记录拼图入口只填写画面描述、首关名默认作品名、作品描述和标签初始为空、AI 生成 6 个作品标签以及发布前校验的落地规则。
- [PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md](./PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md):记录拼图入口模板样例图与 gpt-image-2 Skill 约定2026-05-07 起表单不再展示 Template 模块,改为大参考图区 + 大输入框的单屏布局。
- [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。 - [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。
- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。 - [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。
- [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。 - [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。
@@ -261,7 +265,7 @@
- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。 - [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。
- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。
- [RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md](./RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md):记录 RPG 创作 Agent session 八锚点进入 foundation draft seed 时被旧字段压缩的根因、修复和后续约束。 - [RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md](./RPG_FOUNDATION_DRAFT_EIGHT_ANCHOR_SEED_FIX_2026-04-25.md):记录 RPG 创作 Agent session 八锚点进入 foundation draft seed 时被旧字段压缩的根因、修复和后续约束。
- [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md)把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md) TXT 模式迁移方案的历史参考;视觉小说模板最新落地口径以 PRD [`AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`](../prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为准,不再按外部平台工程完整迁入执行
- [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。 - [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)AI 生成角色形象与角色动画的技术路线。
- [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。 - [ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md](./ALIYUN_NPC_IMAGE_ANIMATION_EXPERIMENT_2026-04-07.md):面向编辑器的阿里云 NPC 形象与动作实验方案,按 4 条生成链路对比。
- [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。 - [PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md](./PIXELMOTION_TECHNICAL_BREAKDOWN_2026-04-04.md)PixelMotion 产品形态与能力拆解。

View File

@@ -30,6 +30,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` | | 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
| 抓大鹅 Match3D | `match3d_agent_session`, `match3d_agent_message`, `match3d_work_profile`, `match3d_runtime_run` | | 抓大鹅 Match3D | `match3d_agent_session`, `match3d_agent_message`, `match3d_work_profile`, `match3d_runtime_run` |
| 方洞挑战 | `square_hole_agent_session`, `square_hole_agent_message`, `square_hole_work_profile`, `square_hole_runtime_run` | | 方洞挑战 | `square_hole_agent_session`, `square_hole_agent_message`, `square_hole_work_profile`, `square_hole_runtime_run` |
| 视觉小说 | `visual_novel_agent_session`, `visual_novel_agent_message`, `visual_novel_work_profile`, `visual_novel_runtime_run`, `visual_novel_runtime_history_entry`, `visual_novel_runtime_event` |
| 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_event`, `big_fish_runtime_run` | | 大鱼吃小鱼 | `big_fish_creation_session`, `big_fish_agent_message`, `big_fish_asset_slot`, `big_fish_event`, `big_fish_runtime_run` |
| 资产 | `asset_object`, `asset_entity_binding`, `asset_event` | | 资产 | `asset_object`, `asset_entity_binding`, `asset_event` |
| AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference`, `ai_task_event` | | AI 任务 | `ai_task`, `ai_task_stage`, `ai_text_chunk`, `ai_result_reference`, `ai_task_event` |
@@ -717,6 +718,78 @@ SELECT * FROM square_hole_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY
SELECT * FROM square_hole_runtime_run WHERE profile_id = '<profile_id>'; SELECT * FROM square_hole_runtime_run WHERE profile_id = '<profile_id>';
``` ```
## 视觉小说表
> VN-13 复核:当前视觉小说首版只保留本节 6 张表;`visual_novel_runtime_history_entry` 和 `visual_novel_runtime_event` 均不得扩展成回放数据源。维护入口见 [视觉小说模板实现收口与交接说明](./VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md)。
### `visual_novel_agent_session`
- 作用:视觉小说创作 Agent 会话主表,保存创建起点、源资产、当前阶段、底稿 JSON、待执行 action 和发布 profile 指针。
- 结构:`session_id PK: String`, `owner_user_id: String`, `source_mode: String`, `status: String`, `seed_text: String`, `source_asset_ids_json: String`, `current_turn: u32`, `progress_percent: u32`, `draft_json: String`, `pending_action_json: String`, `last_assistant_reply: String`, `published_profile_id: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`
```sql
SELECT * FROM visual_novel_agent_session WHERE session_id = '<session_id>';
SELECT * FROM visual_novel_agent_session WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
```
### `visual_novel_agent_message`
- 作用:视觉小说创作 Agent 消息流水,保存用户输入和模型回复。
- 结构:`message_id PK: String`, `session_id: String`, `role: String`, `kind: String`, `text: String`, `created_at: Timestamp`
- 索引:`session_id`
```sql
SELECT * FROM visual_novel_agent_message WHERE session_id = '<session_id>' ORDER BY created_at ASC;
```
### `visual_novel_work_profile`
- 作用:视觉小说作品草稿 / 发布 profile 表,保存平台作品摘要字段、源资产引用、完整 `VisualNovelResultDraft` JSON、发布状态和游玩次数。
- 结构:`profile_id PK: String`, `work_id: String`, `owner_user_id: String`, `source_session_id: String`, `author_display_name: String`, `work_title: String`, `work_description: String`, `tags_json: String`, `cover_image_src: String`, `source_asset_ids_json: String`, `draft_json: String`, `publication_status: String`, `publish_ready: bool`, `play_count: u32`, `created_at: Timestamp`, `updated_at: Timestamp`, `published_at: Option<Timestamp>`
- 索引:`owner_user_id`, `publication_status`
```sql
SELECT * FROM visual_novel_work_profile WHERE profile_id = '<profile_id>';
SELECT * FROM visual_novel_work_profile WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM visual_novel_work_profile WHERE publication_status = 'published';
```
### `visual_novel_runtime_run`
- 作用:视觉小说测试或正式运行态表,保存当前场景、阶段、可见角色、旗标、指标、可选项和运行快照 JSON。
- 结构:`run_id PK: String`, `owner_user_id: String`, `profile_id: String`, `mode: String`, `status: String`, `current_scene_id: String`, `current_phase_id: String`, `visible_character_ids_json: String`, `flags_json: String`, `metrics_json: String`, `available_choices_json: String`, `text_mode_enabled: bool`, `snapshot_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:`owner_user_id`, `profile_id`
```sql
SELECT * FROM visual_novel_runtime_run WHERE run_id = '<run_id>';
SELECT * FROM visual_novel_runtime_run WHERE owner_user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM visual_novel_runtime_run WHERE profile_id = '<profile_id>';
```
### `visual_novel_runtime_history_entry`
- 作用:视觉小说运行时历史表,保存每轮玩家 / 模型 / 系统事实、step JSON 和快照哈希,用于继续体验与历史重生成边界;不是回放表。
- 结构:`entry_id PK: String`, `run_id: String`, `owner_user_id: String`, `profile_id: String`, `turn_index: u32`, `source: String`, `action_text: String`, `steps_json: String`, `snapshot_before_hash: String`, `snapshot_after_hash: String`, `created_at: Timestamp`
- 索引:`run_id`, `owner_user_id`
```sql
SELECT * FROM visual_novel_runtime_history_entry WHERE run_id = '<run_id>' ORDER BY turn_index ASC;
SELECT * FROM visual_novel_runtime_history_entry WHERE owner_user_id = '<user_id>' ORDER BY created_at DESC;
```
### `visual_novel_runtime_event`
- 作用视觉小说运行时审计事件表用于订阅端、BFF 或排障流程感知 run 事实;该表明确不是 replay、分享回放或片段回放数据源。
- 可见性:`public event`
- 结构:`event_id PK: String`, `run_id: String`, `owner_user_id: String`, `profile_id: String`, `event_kind: String`, `client_event_id: String`, `history_entry_id: String`, `payload_json: String`, `occurred_at: Timestamp`
- 索引:`run_id`, `owner_user_id`
```sql
SELECT * FROM visual_novel_runtime_event WHERE run_id = '<run_id>' ORDER BY occurred_at ASC;
SELECT * FROM visual_novel_runtime_event WHERE owner_user_id = '<user_id>' ORDER BY occurred_at DESC;
```
## 大鱼吃小鱼表 ## 大鱼吃小鱼表
### `big_fish_creation_session` ### `big_fish_creation_session`

View File

@@ -2,6 +2,8 @@
更新时间:`2026-04-20` 更新时间:`2026-04-20`
> 2026-05-05 更新口径:本文保留为历史迁移参考。视觉小说模板后续落地以 [`../prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md`](../prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md) 为准;冲突时不再迁入外部平台工程、不使用 `server-node` 作为新功能落点、不保留回放,统一接入 Genarrative `server-rs + Axum + SpacetimeDB` 与平台接口。
## 0. 文档目的 ## 0. 文档目的
这份执行方案用于指导 `Genarrative` 在**不改动外部 TXT 模式提示词正文、不改动外部 TXT 模式功能需求**的前提下,把下面两个仓库中已经跑通的 TXT 模式创作流程与运行机制完整迁入当前项目: 这份执行方案用于指导 `Genarrative` 在**不改动外部 TXT 模式提示词正文、不改动外部 TXT 模式功能需求**的前提下,把下面两个仓库中已经跑通的 TXT 模式创作流程与运行机制完整迁入当前项目:

View File

@@ -0,0 +1,76 @@
# 视觉小说模板实现收口与交接说明 2026-05-07
## 1. 范围
本文记录 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``VN-13` 收口结果,作为视觉小说模板后续维护的一页式入口。
本文只总结当前已经落地且需要长期遵守的实现边界,不再把视觉小说描述成外部 TXT 平台迁移,也不重复旧迁移方案里的临时讨论。
## 2. 当前正式入口
后续维护时优先看这些文档:
1. [AI 原生视觉小说模板 PRD](../prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md)
2. [SpacetimeDB 表说明与查询目录](./SPACETIMEDB_TABLE_CATALOG.md)
3. [视觉小说 VN-03 Prompt 与 LLM 工具实现说明](./VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md)
4. [视觉小说 VN-11 负向扫描报告](../audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md)
5. [视觉小说模板交接与维护经验](../experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md)
旧的 `TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md` 只保留历史参考意义,不再作为实现口径。
## 3. 已收口的实现边界
### 3.1 创作链路
- 创作链统一走 `/api/creation/visual-novel/*`
- 入口、工作台、结果页和服务端 prompt 已围绕 `visual-novel` 模板闭环对齐。
- `VisualNovelResultDraft``VisualNovelCreationSessionSnapshot` 等共享契约已经成型。
### 3.2 运行链路
- 运行链统一走 `/api/runtime/visual-novel/*`
- 运行时只认 typed step 和快照,不让前端从 `raw_text` 猜业务真相。
- `visual_novel_runtime_history_entry` 只用于继续体验和历史重生成边界,不是回放表。
### 3.3 数据链路
- SpacetimeDB 首版只保留 6 张视觉小说表。
- `visual_novel_runtime_event``public event` 审计事件表,不是 replay 数据源。
- 表结构变化必须同步 `migration.rs``SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。
### 3.4 作品与发布
- 作品架、广场和分享都复用平台现有链路。
- 公开作品码统一使用 `VN-` 前缀。
- 发布后要刷新作品架和公开聚合。
### 3.5 资产与登录态
- 文档、封面、背景、角色和音乐都只保留平台资产对象引用。
- 不保存大 Data URL、不走独立对象存储、不新增视觉小说私有存档系统。
- 退出登录时要清空视觉小说私有 session、work、run 和错误状态。
## 4. 长期维护规则
1. 看到视觉小说相关改动,先确认是不是契约改动;如果是,先同步 TS / Rust shared contracts。
2. 看到表结构改动,先同步 `migration.rs` 和表目录,再补 bindings。
3. 看到资产链路改动,只改平台资产引用,不回退到本地路径或二进制直存。
4. 看到运行时历史改动,只维护 typed history 和审计事件,不把它改成回放能力。
5. 看到旧 TXT 文档时,只把它当历史来源,不把其中的平台工程目标重新带回实现。
## 5. 验证口径
收口后建议按下面顺序检查:
```bash
npm run check:encoding
npm run check:visual-novel-vn11
npm run typecheck
cd server-rs
cargo test -p shared-contracts
cargo test -p module-visual-novel
cargo check -p api-server
```
如果本轮没有改代码,只要编码检查和负向扫描通过,通常就说明 VN-13 的文档收口已经站稳。

View File

@@ -0,0 +1,111 @@
# 视觉小说 VN-03 Prompt 与 LLM 工具实现说明
日期:`2026-05-05`
## 1. 范围
本文记录 `AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md``VN-03` 的工程落地口径。
本次只实现视觉小说模板的 Prompt / LLM 工具层:
1. 创作底稿生成 Prompt目标输出 `VisualNovelResultDraft`
2. 运行时 GM Prompt目标输出 `VisualNovelRuntimeStep[]`
3. 结构化 repair Prompt用于解析失败后的后端修复。
4. 创作 action 与图片生成 action 的工具参数 schema。
5. `platform-llm` Responses 请求构造口径。
本次不保存数据、不新增 HTTP 路由、不写前端 UI、不触碰 SpacetimeDB schema。
## 2. 代码落点
主要实现位于:
1. `server-rs/crates/api-server/src/prompt/visual_novel.rs`
2. `server-rs/crates/api-server/src/prompt/mod.rs`
为了让当前工作树中已有的 creative-agent / puzzle 并行改动可继续编译,本次还补了两个编译闭口:
1. `server-rs/crates/module-puzzle/Cargo.toml` 增加 `serde_json = "1"`
2. `server-rs/crates/platform-agent/src/puzzle_phase1_agent.rs``MockLangChainRustAgentExecutor``Debug` 派生。
## 3. Prompt 约束
### 3.1 创作底稿
创作系统 Prompt 明确要求:
1. 只输出一个 JSON 对象。
2. 内容必须是中文视觉小说底稿。
3. 补齐世界观、玩家身份、角色、场景、剧情阶段和开场。
4. 角色必须有可生成立绘的 `appearance`
5. 场景必须有可生成背景图的 `description`
6. 不输出回放、商城、会员、后台、平台活动、促销、订单或独立账号字段。
7. 不生成第二套存档、发布、钱包、广场或资产系统。
### 3.2 运行时 GM
运行时系统 Prompt 明确要求:
1. 只输出 `VisualNovelRuntimeStep[]` JSON 数组。
2. step 数量不超过输入的 `maxAssistantStepCountPerTurn`
3. 场景变化必须输出 `scene_change`
4. 旁白、对白、转场、选项、flag、metric 分别使用契约内 step 类型。
5. 前端不得从 `raw_text` 猜业务 step。
6. 不输出回放、录制、商城、会员、后台、平台活动或独立存档元数据。
### 3.3 Repair
repair Prompt 只负责把坏格式修成目标 JSON
1. `VisualNovelResultDraft`
2. `VisualNovelRuntimeStep[]`
repair 仍失败时,由后续 `VN-05` API 接入层返回可重试错误。
## 4. LLM 请求口径
视觉小说创作、运行和 repair 请求统一使用 `platform-llm`
1. model`CREATION_TEMPLATE_LLM_MODEL`,当前为 `deepseek-v3-2-251201`
2. protocol`LlmTextProtocol::Responses`
3. 创作底稿请求可按 API 配置开启 web search。
4. 运行时 GM 与 repair 默认不开 web search避免运行态引入外部噪声。
## 5. 工具参数
本次定义两个工具描述:
1. `visual_novel_apply_creation_action`
- 支持 `generate_draft``patch_world``patch_character``patch_scene``patch_story_phase``compile_work_profile`
- 只写回视觉小说底稿或编译平台 work profile 草稿。
2. `visual_novel_generate_image_asset`
- 支持 `generate_scene_image``generate_character_image`
- 输出应接后续平台资产引用,不保存二进制或大 Data URL。
工具 schema 不包含 replay / Replay 字段。
## 6. 验证结果
已执行:
```bash
cargo test -p shared-contracts visual_novel --manifest-path server-rs/Cargo.toml
cargo test -p module-puzzle creative_tools --manifest-path server-rs/Cargo.toml
cargo test -p platform-agent puzzle_phase1_agent --manifest-path server-rs/Cargo.toml
cargo test -p api-server prompt::visual_novel --manifest-path server-rs/Cargo.toml
```
结果:全部通过。
## 7. 后续接入点
`VN-05` 接 API Server 时直接复用:
1. `build_visual_novel_creation_llm_request`
2. `build_visual_novel_runtime_llm_request`
3. `build_visual_novel_repair_llm_request`
4. `parse_visual_novel_result_draft_fixture`
5. `parse_visual_novel_runtime_steps_fixture`
6. `visual_novel_tool_descriptors`
若后续 `VN-01` 契约发生破坏性变更,必须同步更新本 Prompt 模块的 output contract、fixture 和解析测试。

View File

@@ -2,7 +2,10 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>百梦</title> <title>百梦</title>
</head> </head>

View File

@@ -16,7 +16,7 @@ pipeline {
CARGO_INCREMENTAL = '0' CARGO_INCREMENTAL = '0'
RUSTC_WRAPPER = 'sccache' RUSTC_WRAPPER = 'sccache'
SCCACHE_DIR = '${env.USERPROFILE}\\.cache\\sccache-stdb-module' SCCACHE_DIR = '${env.USERPROFILE}\\.cache\\sccache-stdb-module'
SCCACHE_CACHE_SIZE = '30G'o SCCACHE_CACHE_SIZE = '30G'
} }
parameters { parameters {
@@ -88,8 +88,19 @@ pipeline {
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。' throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。'
} }
if (-not (Get-Command sccache -ErrorAction SilentlyContinue)) { # sccache 只是可选缓存PATH 中存在但不可执行时必须回退到 rustc。
Write-Host '[stdb-build] 未找到 sccache改用 rustc 直接构建。' $sccacheCommand = Get-Command sccache -ErrorAction SilentlyContinue
$sccacheUsable = $false
if ($sccacheCommand) {
try {
& $sccacheCommand.Source --version | Out-Host
$sccacheUsable = $true
} catch {
Write-Host "[stdb-build] sccache 无法执行:$($_.Exception.Message)"
}
}
if (-not $sccacheUsable) {
Write-Host '[stdb-build] 未找到可用 sccache改用 rustc 直接构建。'
Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue Remove-Item Env:RUSTC_WRAPPER -ErrorAction SilentlyContinue
} }
npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION" npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION"

View File

@@ -22,6 +22,8 @@
"preview": "node scripts/vite-cli.mjs preview", "preview": "node scripts/vite-cli.mjs preview",
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"check:encoding": "node scripts/check-encoding.mjs", "check:encoding": "node scripts/check-encoding.mjs",
"check:visual-novel-vn11": "node scripts/check-visual-novel-vn11-negative-scan.mjs",
"check:visual-novel-vn12": "node scripts/check-visual-novel-vn12-acceptance.mjs",
"check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs", "check:server-rs-ddd": "node scripts/check-server-rs-ddd-boundaries.mjs",
"lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0",
"lint:guardrails": "npm run lint:eslint", "lint:guardrails": "npm run lint:eslint",

View File

@@ -9,6 +9,7 @@ export interface CreationAgentDocumentInputPayload {
contentType?: string | null; contentType?: string | null;
sizeBytes: number; sizeBytes: number;
text: string; text: string;
sourceAssetId?: string | null;
} }
export interface ParseCreationAgentDocumentInputResponse { export interface ParseCreationAgentDocumentInputResponse {

View File

@@ -0,0 +1,244 @@
import type { PuzzleResultDraft } from './puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
import type {
PuzzleCreativeTemplateProtocol,
PuzzleCreativeTemplateSelection,
PuzzleDraftFieldPatch,
PuzzleImageGenerationPlan,
PuzzleTemplateCostRange,
} from './puzzleCreativeTemplate';
export type CreativeAgentStage =
| 'idle'
| 'perceiving'
| 'thinking'
| 'remembering'
| 'selecting_puzzle_template'
| 'waiting_template_confirmation'
| 'planning_puzzle_levels'
| 'acting'
| 'reflecting'
| 'collaborating'
| 'target_ready'
| 'waiting_user'
| 'failed';
export type CreativeAgentEntryContext =
| 'creation_home'
| 'puzzle_workspace'
| 'gallery_remix'
| 'draft_restore';
export type CreativeAgentMessageRole = 'user' | 'assistant' | 'system';
export type CreativeAgentMessageKind =
| 'chat'
| 'stage'
| 'action_result'
| 'warning';
export type CreativeAgentInputPart =
| {
type: 'input_text';
text: string;
}
| {
type: 'input_image';
imageUrl: string;
assetId?: string | null;
thumbnailUrl?: string | null;
};
export interface CreativeImageInput {
assetId: string;
readUrl: string;
thumbnailUrl?: string | null;
width?: number | null;
height?: number | null;
}
export interface CreativeImageSummary {
assetId: string | null;
readUrl: string | null;
thumbnailUrl: string | null;
width: number | null;
height: number | null;
summary: string | null;
}
export type CreativeUnsupportedPlayType =
| 'rpg'
| 'match3d'
| 'big_fish'
| 'square_hole';
export interface CreativeUnsupportedCapability {
playType: CreativeUnsupportedPlayType;
title: string;
status: 'unsupported';
reason: string;
}
export interface CreativeInputSummary {
text: string | null;
entryContext: CreativeAgentEntryContext;
images: CreativeImageSummary[];
materialSummary: string | null;
unsupportedCapabilities: CreativeUnsupportedCapability[];
}
export interface CreativeAgentMessage {
id: string;
role: CreativeAgentMessageRole;
kind: CreativeAgentMessageKind;
text: string;
createdAt: string;
}
export interface CreativeTargetSessionBinding {
playType: 'puzzle';
targetSessionId: string;
targetStage: 'puzzle-agent-workspace' | 'puzzle-result' | 'puzzle-runtime';
resultProfileId: string | null;
}
export interface CreativeAgentSessionSnapshot {
sessionId: string;
stage: CreativeAgentStage;
inputSummary: CreativeInputSummary;
messages: CreativeAgentMessage[];
puzzleTemplateCatalog: PuzzleCreativeTemplateProtocol[];
puzzleTemplateSelection: PuzzleCreativeTemplateSelection | null;
puzzleImageGenerationPlan: PuzzleImageGenerationPlan | null;
targetBinding: CreativeTargetSessionBinding | null;
updatedAt: string;
}
export interface CreateCreativeAgentSessionRequest {
text?: string | null;
images?: CreativeImageInput[];
entryContext?: CreativeAgentEntryContext;
}
export interface CreativeAgentSessionResponse {
session: CreativeAgentSessionSnapshot;
}
export interface StreamCreativeAgentMessageRequest {
clientMessageId: string;
content: CreativeAgentInputPart[];
}
export interface ConfirmCreativePuzzleTemplateRequest {
selection: PuzzleCreativeTemplateSelection;
}
export interface CreativeDraftEditStreamRequest {
clientMessageId: string;
instruction: string;
targetPuzzleSessionId: string;
currentDraft: PuzzleResultDraft;
}
export interface CreativeDraftEditResult {
editInstructions: PuzzleDraftFieldPatch[];
session: CreativeAgentSessionSnapshot;
puzzleSession: PuzzleAgentSessionSnapshot;
}
export interface CreativeAgentStageEvent {
sessionId: string;
stage: CreativeAgentStage;
}
export interface CreativeAgentMessageDeltaEvent {
sessionId: string;
messageId: string;
role: CreativeAgentMessageRole;
kind: CreativeAgentMessageKind;
textDelta: string;
}
export interface CreativeAgentThoughtSummaryDeltaEvent {
sessionId: string;
thoughtId: string;
textDelta: string;
}
export interface CreativeAgentTemplateCatalogEvent {
sessionId: string;
templates: PuzzleCreativeTemplateProtocol[];
}
export interface CreativeAgentTemplateSelectionEvent {
sessionId: string;
selection: PuzzleCreativeTemplateSelection;
}
export interface CreativeAgentCostRangeEvent {
sessionId: string;
costRange: PuzzleTemplateCostRange;
}
export interface CreativeAgentLevelPlanEvent {
sessionId: string;
plan: PuzzleImageGenerationPlan;
}
export interface CreativeAgentToolEvent {
sessionId: string;
toolCallId: string;
toolName: string;
summary: string | null;
}
export interface CreativeAgentReflectionEvent {
sessionId: string;
pass: boolean;
summary: string;
warnings: string[];
}
export interface CreativeAgentTargetSessionEvent {
sessionId: string;
binding: CreativeTargetSessionBinding;
}
export interface CreativeAgentErrorEvent {
sessionId: string | null;
code: string;
message: string;
recoverable: boolean;
}
export interface CreativeAgentDoneEvent {
sessionId: string;
}
export type CreativeAgentSseEvent =
| { event: 'stage'; data: CreativeAgentStageEvent }
| { event: 'agent_message_delta'; data: CreativeAgentMessageDeltaEvent }
| {
event: 'thought_summary_delta';
data: CreativeAgentThoughtSummaryDeltaEvent;
}
| {
event: 'puzzle_template_catalog';
data: CreativeAgentTemplateCatalogEvent;
}
| {
event: 'puzzle_template_selection';
data: CreativeAgentTemplateSelectionEvent;
}
| { event: 'puzzle_cost_range'; data: CreativeAgentCostRangeEvent }
| { event: 'puzzle_level_plan'; data: CreativeAgentLevelPlanEvent }
| { event: 'tool_started'; data: CreativeAgentToolEvent }
| { event: 'tool_completed'; data: CreativeAgentToolEvent }
| { event: 'reflection'; data: CreativeAgentReflectionEvent }
| { event: 'target_session'; data: CreativeAgentTargetSessionEvent }
| {
event: 'session';
data: { session: CreativeAgentSessionSnapshot };
}
| { event: 'error'; data: CreativeAgentErrorEvent }
| { event: 'done'; data: CreativeAgentDoneEvent };

View File

@@ -0,0 +1,3 @@
export type * from './creativeAgent';
export type * from './puzzleCreativeTemplate';
export type * from './visualNovel';

View File

@@ -48,7 +48,9 @@ export type PuzzleAgentActionRequest =
workTitle?: string; workTitle?: string;
workDescription?: string; workDescription?: string;
pictureDescription?: string; pictureDescription?: string;
referenceImageSrc?: string | null;
imageModel?: string | null; imageModel?: string | null;
aiRedraw?: boolean;
} }
| { | {
action: 'compile_puzzle_draft'; action: 'compile_puzzle_draft';
@@ -58,6 +60,7 @@ export type PuzzleAgentActionRequest =
pictureDescription?: string; pictureDescription?: string;
referenceImageSrc?: string | null; referenceImageSrc?: string | null;
imageModel?: string | null; imageModel?: string | null;
aiRedraw?: boolean;
candidateCount?: number; candidateCount?: number;
} }
| { | {

View File

@@ -46,6 +46,7 @@ export interface PuzzleDraftLevel {
levelId: string; levelId: string;
levelName: string; levelName: string;
pictureDescription: string; pictureDescription: string;
pictureReference?: string | null;
candidates: PuzzleGeneratedImageCandidate[]; candidates: PuzzleGeneratedImageCandidate[];
selectedCandidateId: string | null; selectedCandidateId: string | null;
coverImageSrc: string | null; coverImageSrc: string | null;

View File

@@ -51,6 +51,7 @@ export interface CreatePuzzleAgentSessionRequest {
pictureDescription?: string; pictureDescription?: string;
referenceImageSrc?: string | null; referenceImageSrc?: string | null;
imageModel?: string | null; imageModel?: string | null;
aiRedraw?: boolean;
} }
export interface CreatePuzzleAgentSessionResponse { export interface CreatePuzzleAgentSessionResponse {

View File

@@ -0,0 +1,103 @@
export type PuzzleTemplatePricingUnit = 'point';
export type PuzzleSupportedLevelMode =
| 'single'
| 'multi'
| 'single_or_multi';
export type PuzzleLevelGenerationMode = 'single_level' | 'multi_level';
export interface PuzzleTemplateCostRange {
minPoints: number;
maxPoints: number;
pricingUnit: PuzzleTemplatePricingUnit;
reason: string;
}
export type PuzzleDraftEditableFieldPath =
| 'workTitle'
| 'workDescription'
| 'workTags'
| 'levels[].levelName'
| 'levels[].pictureDescription'
| 'levels[].pictureReference';
export interface PuzzleTemplateImageGenerationPolicy {
allowUploadedImageDirectly: boolean;
allowGeneratedImages: boolean;
allowPerLevelReferenceImage: boolean;
defaultCandidateCountPerLevel: number;
}
export interface PuzzleCreativeTemplateProtocol {
templateId: string;
title: string;
summary: string;
previewImageSrc: string | null;
supportedLevelMode: PuzzleSupportedLevelMode;
minLevelCount: number;
maxLevelCount: number;
defaultLevelCount: number;
costRange: PuzzleTemplateCostRange;
requiredDraftFields: PuzzleDraftEditableFieldPath[];
imagePolicy: PuzzleTemplateImageGenerationPolicy;
}
export interface PuzzleCreativeTemplateSelection {
templateId: string;
title: string;
reason: string;
costRange: PuzzleTemplateCostRange;
supportedLevelMode: PuzzleSupportedLevelMode;
selectedLevelMode: PuzzleLevelGenerationMode;
plannedLevelCount: number;
requiresUserConfirmation: true;
}
export interface CreativePuzzleLevelDraftInput {
levelName: string;
pictureDescription: string;
/**
* 任务 A 冻结Phase 1 采用正式字段方案,后续拼图草稿落地需补正式 pictureReference 字段。
*/
pictureReference?: string | null;
}
export interface CreativePuzzleDraftToolInput {
templateId: string;
templateCostRange: PuzzleTemplateCostRange;
workTitle: string;
workDescription: string;
workTags: string[];
levels: CreativePuzzleLevelDraftInput[];
}
export interface PuzzleImageGenerationPlanLevel {
levelId: string;
levelName: string;
pictureDescription: string;
imagePrompt: string;
pictureReference?: string | null;
candidateCount: number;
}
export interface PuzzleImageGenerationPlan {
mode: PuzzleLevelGenerationMode;
templateId: string;
estimatedCostRange: PuzzleTemplateCostRange;
levels: PuzzleImageGenerationPlanLevel[];
}
export type PuzzleDraftFieldPatchOperation =
| 'set'
| 'append'
| 'replace'
| 'remove';
export interface PuzzleDraftFieldPatch {
fieldPath: PuzzleDraftEditableFieldPath;
operation: PuzzleDraftFieldPatchOperation;
levelId?: string | null;
value: unknown;
rationale: string;
}

View File

@@ -0,0 +1,410 @@
export type VisualNovelSourceMode = 'idea' | 'document' | 'blank';
export type VisualNovelCharacterRole =
| 'protagonist'
| 'main'
| 'supporting'
| 'antagonist'
| 'background';
export type VisualNovelAssetSource = 'platform_asset' | 'generated' | 'external';
export type VisualNovelSceneAvailability =
| 'opening'
| 'always'
| 'phase_locked';
export type VisualNovelAttributePanelMode =
| 'off'
| 'platform_whitelist'
| 'template_config';
export type VisualNovelValidationSeverity = 'error' | 'warning';
export type VisualNovelAgentStatus =
| 'collecting'
| 'drafting'
| 'ready'
| 'failed';
export type VisualNovelAgentMessageRole = 'user' | 'assistant' | 'system';
export type VisualNovelAgentMessageKind =
| 'chat'
| 'summary'
| 'action_result'
| 'warning';
export type VisualNovelAgentActionKind =
| 'generate_draft'
| 'patch_world'
| 'patch_character'
| 'patch_scene'
| 'patch_story_phase'
| 'generate_scene_image'
| 'generate_character_image'
| 'compile_work_profile';
export type VisualNovelAgentPhase =
| 'perception'
| 'reasoning'
| 'drafting'
| 'reflection'
| 'finalizing';
export type VisualNovelRunMode = 'test' | 'play';
export type VisualNovelRunStatus = 'active' | 'completed' | 'failed';
export type VisualNovelRuntimeActionKind = 'choice' | 'free_text' | 'continue';
export type VisualNovelTransitionKind = 'fade' | 'cut' | 'flash' | 'none';
export type VisualNovelHistorySource = 'player' | 'assistant' | 'system';
export type VisualNovelFlagValue = string | number | boolean;
export interface VisualNovelDraftPatch {
path: string;
op: 'add' | 'replace' | 'remove';
value?: unknown;
}
export interface VisualNovelValidationIssue {
issueId: string;
code: string;
severity: VisualNovelValidationSeverity;
path: string;
message: string;
}
export interface VisualNovelChoiceDraft {
choiceId: string;
text: string;
actionHint?: string | null;
}
export interface VisualNovelCharacterImageAsset {
assetId: string;
imageSrc: string;
expression?: string | null;
source: VisualNovelAssetSource;
}
export interface VisualNovelWorldDraft {
title: string;
summary: string;
background: string;
premise: string;
literaryStyle: string;
playerRole: string;
defaultTone: string;
}
export interface VisualNovelCharacterDraft {
characterId: string;
name: string;
gender: string | null;
role: VisualNovelCharacterRole;
appearance: string;
personality: string;
tone: string;
background: string;
relationshipToPlayer: string;
imageAssets: VisualNovelCharacterImageAsset[];
defaultExpression: string | null;
isPlayerVisible: boolean;
}
export interface VisualNovelSceneDraft {
sceneId: string;
name: string;
description: string;
backgroundImageSrc: string | null;
musicSrc: string | null;
ambientSoundSrc: string | null;
availability: VisualNovelSceneAvailability;
phaseIds: string[];
}
export interface VisualNovelStoryPhaseDraft {
phaseId: string;
title: string;
goal: string;
summary: string;
entryCondition: string;
exitCondition: string;
sceneIds: string[];
characterIds: string[];
suggestedChoices: string[];
}
export interface VisualNovelOpeningDraft {
sceneId: string | null;
narration: string;
speakerCharacterId: string | null;
firstDialogue: string | null;
initialChoices: VisualNovelChoiceDraft[];
}
export interface VisualNovelRuntimeConfigDraft {
textModeEnabled: boolean;
defaultTextMode: boolean;
maxHistoryEntries: number;
maxAssistantStepCountPerTurn: number;
allowFreeTextAction: boolean;
allowHistoryRegeneration: boolean;
attributePanelMode: VisualNovelAttributePanelMode;
saveArchiveEnabled: boolean;
}
export interface VisualNovelResultDraft {
profileId: string | null;
workTitle: string;
workDescription: string;
workTags: string[];
coverImageSrc: string | null;
sourceMode: VisualNovelSourceMode;
sourceAssetIds: string[];
world: VisualNovelWorldDraft;
characters: VisualNovelCharacterDraft[];
scenes: VisualNovelSceneDraft[];
storyPhases: VisualNovelStoryPhaseDraft[];
opening: VisualNovelOpeningDraft;
runtimeConfig: VisualNovelRuntimeConfigDraft;
publishReady: boolean;
validationIssues: VisualNovelValidationIssue[];
updatedAt: string;
}
export interface VisualNovelAgentMessage {
id: string;
role: VisualNovelAgentMessageRole;
kind: VisualNovelAgentMessageKind;
text: string;
createdAt: string;
}
export interface VisualNovelAgentPendingAction {
actionId: string;
kind: VisualNovelAgentActionKind;
label: string;
targetId?: string | null;
payload?: unknown;
}
export interface VisualNovelAgentSessionSnapshot {
sessionId: string;
ownerUserId: string;
sourceMode: VisualNovelSourceMode;
status: VisualNovelAgentStatus;
messages: VisualNovelAgentMessage[];
draft: VisualNovelResultDraft | null;
pendingAction: VisualNovelAgentPendingAction | null;
createdAt: string;
updatedAt: string;
}
export interface CreateVisualNovelSessionRequest {
sourceMode: VisualNovelSourceMode;
seedText?: string | null;
sourceAssetIds?: string[];
}
export interface VisualNovelSessionResponse {
session: VisualNovelAgentSessionSnapshot;
}
export interface VisualNovelWorkSummary {
runtimeKind: 'visual-novel';
profileId: string;
ownerUserId: string;
title: string;
description: string;
coverImageSrc: string | null;
tags: string[];
publishStatus: string;
publishReady: boolean;
playCount: number;
updatedAt: string;
publishedAt: string | null;
}
export interface VisualNovelWorkDetail {
workId: string;
summary: VisualNovelWorkSummary;
sourceSessionId: string | null;
authorDisplayName: string;
sourceAssetIds: string[];
draft: VisualNovelResultDraft;
createdAt: string;
}
export interface VisualNovelWorksResponse {
works: VisualNovelWorkSummary[];
}
export interface VisualNovelWorkResponse {
work: VisualNovelWorkDetail;
}
export interface UpdateVisualNovelWorkRequest {
draft: VisualNovelResultDraft;
}
export interface VisualNovelCompileResponse {
session: VisualNovelAgentSessionSnapshot;
work: VisualNovelWorkDetail;
}
export interface SendVisualNovelMessageRequest {
clientMessageId: string;
text: string;
}
export interface ExecuteVisualNovelAgentActionRequest {
actionId?: string | null;
kind: VisualNovelAgentActionKind;
targetId?: string | null;
payload?: unknown;
}
export interface CompileVisualNovelWorkProfileRequest {
draft: VisualNovelResultDraft;
}
export type VisualNovelAgentStreamEvent =
| { type: 'start'; sessionId: string }
| { type: 'phase'; phase: VisualNovelAgentPhase }
| { type: 'text_delta'; text: string }
| { type: 'draft_patch'; patch: VisualNovelDraftPatch }
| { type: 'action_required'; action: VisualNovelAgentPendingAction }
| { type: 'complete'; session: VisualNovelAgentSessionSnapshot }
| { type: 'error'; message: string; retryable: boolean }
| { type: 'done' };
export interface VisualNovelSceneChangeStep {
type: 'scene_change';
sceneId: string;
backgroundImageSrc: string | null;
musicSrc: string | null;
}
export interface VisualNovelNarrationStep {
type: 'narration';
text: string;
}
export interface VisualNovelDialogueStep {
type: 'dialogue';
characterId: string;
characterName: string;
expression: string | null;
text: string;
}
export interface VisualNovelTransitionStep {
type: 'transition';
transitionKind: VisualNovelTransitionKind;
text: string | null;
}
export interface VisualNovelChoiceStep {
type: 'choice';
choices: VisualNovelChoiceDraft[];
}
export interface VisualNovelFlagStep {
type: 'flag';
key: string;
value: VisualNovelFlagValue;
}
export interface VisualNovelMetricStep {
type: 'metric';
key: string;
delta: number;
}
export type VisualNovelRuntimeStep =
| VisualNovelSceneChangeStep
| VisualNovelNarrationStep
| VisualNovelDialogueStep
| VisualNovelTransitionStep
| VisualNovelChoiceStep
| VisualNovelFlagStep
| VisualNovelMetricStep;
export interface VisualNovelHistoryEntry {
entryId: string;
runId: string;
turnIndex: number;
source: VisualNovelHistorySource;
actionText: string | null;
steps: VisualNovelRuntimeStep[];
snapshotBeforeHash: string | null;
snapshotAfterHash: string | null;
createdAt: string;
}
export interface VisualNovelRunSnapshot {
runId: string;
ownerUserId: string;
profileId: string;
mode: VisualNovelRunMode;
status: VisualNovelRunStatus;
currentSceneId: string | null;
currentPhaseId: string | null;
visibleCharacterIds: string[];
flags: Record<string, VisualNovelFlagValue>;
metrics: Record<string, number>;
history: VisualNovelHistoryEntry[];
availableChoices: VisualNovelChoiceDraft[];
textModeEnabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface VisualNovelRuntimeActionRequest {
actionKind: VisualNovelRuntimeActionKind;
choiceId?: string;
text?: string;
clientEventId: string;
}
export interface VisualNovelStartRunRequest {
profileId: string;
mode: VisualNovelRunMode;
}
export interface VisualNovelRunResponse {
run: VisualNovelRunSnapshot;
}
export interface VisualNovelHistoryResponse {
history: VisualNovelHistoryEntry[];
}
export interface VisualNovelRegenerateRequest {
historyEntryId: string;
clientEventId: string;
}
export interface VisualNovelSaveArchiveState {
runtimeKind: 'visual-novel';
profileId: string;
runId: string;
currentSceneId: string | null;
currentPhaseId: string | null;
historyCursor: number;
snapshotHash: string | null;
}
export type VisualNovelRuntimeStreamEvent =
| { type: 'start'; runId: string }
| { type: 'raw_text'; text: string }
| { type: 'step'; step: VisualNovelRuntimeStep }
| { type: 'snapshot'; run: VisualNovelRunSnapshot }
| { type: 'complete'; run: VisualNovelRunSnapshot }
| { type: 'error'; message: string; retryable: boolean }
| { type: 'done' };

View File

@@ -3,6 +3,7 @@ export * from './contracts/auth';
export type * from './contracts/bigFish'; export type * from './contracts/bigFish';
export * from './contracts/common'; export * from './contracts/common';
export type * from './contracts/creationAgentDocumentInput'; export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent'; export type * from './contracts/customWorldAgent';
export * from './contracts/match3dAgent'; export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime'; export * from './contracts/match3dRuntime';
@@ -11,6 +12,7 @@ export * from './contracts/puzzleAgentActions';
export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentDraft';
export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleOnboarding'; export * from './contracts/puzzleOnboarding';
export type * from './contracts/puzzleCreativeTemplate';
export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary'; export * from './contracts/puzzleWorkSummary';
@@ -42,6 +44,7 @@ export type {
SquareHoleWorkSummary, SquareHoleWorkSummary,
} from './contracts/squareHoleWorks'; } from './contracts/squareHoleWorks';
export type * from './contracts/story'; export type * from './contracts/story';
export type * from './contracts/visualNovel';
export * from './http'; export * from './http';
export * from './llm/narrativeLanguage'; export * from './llm/narrativeLanguage';
export * from './llm/parsers'; export * from './llm/parsers';

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -7,6 +7,7 @@ const apiServerExePath = resolve(
repoRoot, repoRoot,
'server-rs/target/debug/api-server.exe', 'server-rs/target/debug/api-server.exe',
); );
const shellEnvKeys = new Set(Object.keys(process.env));
function loadEnvFile(path, target) { function loadEnvFile(path, target) {
if (!existsSync(path)) { if (!existsSync(path)) {
@@ -26,7 +27,9 @@ function loadEnvFile(path, target) {
} }
const [, key, rawValue] = match; const [, key, rawValue] = match;
if (target[key] !== undefined) { // 只保留启动命令行和外层 shell 已显式传入的环境变量优先级;
// `.env.local` 需要能覆盖 `.env`,否则本地短信登录会被默认 false 压住。
if (shellEnvKeys.has(key)) {
continue; continue;
} }

View File

@@ -0,0 +1,272 @@
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, extname, join, relative } from 'node:path';
const repoRoot = process.cwd();
const writeReport = process.argv.includes('--write-report');
const reportPath = join(repoRoot, 'docs', 'audits', 'VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md');
const codeTargets = [
'src',
'packages/shared/src',
'server-rs/crates',
];
const documentTargets = [
'docs',
'.hermes/shared-memory',
];
const visualNovelImplementationTargets = [
'src/components/visual-novel-creation',
'src/components/visual-novel-result',
'src/components/visual-novel-runtime',
'src/services/visual-novel-creation',
'src/services/visual-novel-runtime',
'src/services/visual-novel-works',
'packages/shared/src/contracts/visualNovel.ts',
'server-rs/crates/shared-contracts/src/visual_novel.rs',
'server-rs/crates/module-visual-novel',
'server-rs/crates/api-server/src/visual_novel.rs',
'server-rs/crates/api-server/src/prompt/visual_novel.rs',
'server-rs/crates/spacetime-module/src/visual_novel.rs',
'server-rs/crates/spacetime-client/src/visual_novel.rs',
];
const textExtensions = new Set([
'.cjs',
'.controller',
'.css',
'.html',
'.js',
'.json',
'.jsx',
'.md',
'.mjs',
'.ps1',
'.py',
'.rs',
'.scss',
'.sh',
'.toml',
'.ts',
'.tsx',
'.txt',
'.yaml',
'.yml',
]);
const textFileNames = new Set([
'AGENTS.md',
'README.md',
]);
const excludedPrefixes = [
'.git/',
'dist/',
'node_modules/',
'server-rs/target/',
'server-rs/target-',
];
const legacyPlaybackTerms = [
're' + 'play',
'Re' + 'play',
'回放',
'分享回放',
'录制',
'复盘',
];
const externalPlatformPatterns = [
/订单/u,
/会员/u,
/促销/u,
/后台/u,
/公开市场/u,
/私有存档/u,
/独立存档/u,
/商城/u,
/支付/u,
/订阅/u,
/活动配置/u,
/小游戏平台/u,
/公开游戏市场/u,
/server-node/u,
/Cloudflare Worker/u,
/\bExpress\b/u,
/\bD1\b/u,
/\bR2\b/u,
];
function normalizePath(filePath) {
return filePath.replace(/\\/gu, '/');
}
function repoRelative(filePath) {
return normalizePath(relative(repoRoot, filePath));
}
function shouldInspect(filePath) {
const normalized = repoRelative(filePath);
if (excludedPrefixes.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
const fileName = basename(filePath);
if (textFileNames.has(fileName)) {
return true;
}
return textExtensions.has(extname(fileName).toLowerCase());
}
function listTextFiles(targetPath) {
const fullPath = join(repoRoot, targetPath);
if (!existsSync(fullPath)) {
return [];
}
const stat = statSync(fullPath);
if (stat.isFile()) {
return shouldInspect(fullPath) ? [fullPath] : [];
}
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
const child = join(dir, name);
const childStat = statSync(child);
if (childStat.isDirectory()) {
walk(child);
continue;
}
if (shouldInspect(child)) {
files.push(child);
}
}
};
walk(fullPath);
return files;
}
function collectFiles(targets) {
return [...new Set(targets.flatMap(listTextFiles))].sort();
}
function collectLineHits(files, matcher) {
const hits = [];
for (const file of files) {
const lines = readFileSync(file, 'utf8').split(/\r?\n/u);
lines.forEach((line, index) => {
if (matcher(line)) {
hits.push({
file: repoRelative(file),
lineNumber: index + 1,
text: line.trim(),
});
}
});
}
return hits;
}
function hasLegacyPlaybackMarker(line) {
return legacyPlaybackTerms.some((term) => line.includes(term));
}
function hasExternalPlatformMarker(line) {
return externalPlatformPatterns.some((pattern) => pattern.test(line));
}
function formatHits(hits) {
return hits.map((hit) => ` - ${hit.file}:${hit.lineNumber} ${hit.text}`);
}
const codeFiles = collectFiles(codeTargets);
const documentFiles = collectFiles(documentTargets);
const visualNovelFiles = collectFiles(visualNovelImplementationTargets);
const codePlaybackHits = collectLineHits(codeFiles, hasLegacyPlaybackMarker);
const documentPlaybackHits = collectLineHits(documentFiles, hasLegacyPlaybackMarker);
const externalPlatformHits = collectLineHits(
visualNovelFiles,
hasExternalPlatformMarker,
);
const reportLines = [
'# VN-11 负向扫描报告',
'',
'生成日期2026-05-07',
'',
'## 扫描范围',
'',
'- 工程代码:`src/`、`packages/shared/src/`、`server-rs/crates/`',
'- 文档与共享记忆:`docs/`、`.hermes/shared-memory/`',
'- 外部平台误入复核视觉小说前端、service、shared contracts、Rust contracts、module、api-server、SpacetimeDB schema 与 facade 路径',
'',
'## 扫描结论',
'',
`- 工程代码回放类直出命中:${codePlaybackHits.length}`,
`- 文档 / 共享记忆回放类命中:${documentPlaybackHits.length}`,
`- 视觉小说实现路径外部平台能力疑似误入命中:${externalPlatformHits.length}`,
'',
'## 处理记录',
'',
'- 已将 `storyEngine` 回归工具的命名从 replay 语义收口为 rerun / 复测语义。',
'- 已将技能效果预览按钮的内部状态与文案从重播语义收口为重新预览语义。',
'- 已确认视觉小说工程路径未新增回放路由、DTO、表、按钮、文案、外部平台账号 / 订单 / 会员 / 促销 / 后台 / 公开市场或私有存档能力。',
'',
'## 文档命中说明',
'',
'- 文档命中来自历史旧文档、设计复盘、禁止语境、负向验收或本报告记录。VN-11 工程门禁只阻断代码路径新增能力。',
'',
'## 门禁命令',
'',
'```bash',
'npm run check:visual-novel-vn11',
'```',
'',
];
if (writeReport) {
writeFileSync(reportPath, `${reportLines.join('\n')}\n`, 'utf8');
}
const failures = [];
if (codePlaybackHits.length > 0) {
failures.push('工程代码仍存在回放类直出命中。');
}
if (externalPlatformHits.length > 0) {
failures.push('视觉小说实现路径仍存在疑似外部平台能力误入。');
}
if (failures.length > 0) {
console.error('VN-11 negative scan failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
console.error('');
for (const hit of [...codePlaybackHits, ...externalPlatformHits]) {
console.error(`- ${hit.file}:${hit.lineNumber} ${hit.text}`);
}
process.exit(1);
}
console.log('VN-11 negative scan passed.');
console.log(`- code playback hits: ${codePlaybackHits.length}`);
console.log(`- document playback hits: ${documentPlaybackHits.length}`);
console.log(`- external platform hits in visual novel implementation: ${externalPlatformHits.length}`);
if (writeReport) {
console.log(`- report: ${repoRelative(reportPath)}`);
}

View File

@@ -0,0 +1,266 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
const repoRoot = process.cwd();
const writeReport = process.argv.includes('--write-report');
const reportPath = join(
repoRoot,
'docs',
'audits',
'VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md',
);
const requiredFiles = [
'docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md',
'docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md',
'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
'server-rs/crates/api-server/src/visual_novel.rs',
'server-rs/crates/module-visual-novel/src/application.rs',
'server-rs/crates/shared-contracts/src/visual_novel.rs',
];
const contentChecks = [
{
path: 'package.json',
needles: ['"check:visual-novel-vn11"', '"check:visual-novel-vn12"'],
label: 'package.json scripts',
},
{
path: 'server-rs/crates/api-server/src/app.rs',
needles: [
'/api/creation/visual-novel/sessions',
'/api/creation/visual-novel/works',
'/api/runtime/visual-novel/gallery',
'/api/runtime/visual-novel/works/{profile_id}/runs',
'/api/runtime/visual-novel/runs/{run_id}/actions/stream',
'/api/runtime/visual-novel/runs/{run_id}/history',
'/api/runtime/visual-novel/runs/{run_id}/regenerate',
'visual_novel_forbidden_playback_routes_are_not_mounted',
],
label: 'api-server visual novel routes',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.ts',
needles: [
'VISUAL_NOVEL_RUNTIME_API_BASE',
'${VISUAL_NOVEL_RUNTIME_API_BASE}/gallery',
'skipAuth: true',
'skipRefresh: true',
'${VISUAL_NOVEL_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/actions/stream',
'/api/profile/save-archives',
'/api/runtime/save/snapshot',
],
label: 'visual novel runtime client routes',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts',
needles: [
'listVisualNovelGallery reads public gallery without auth refresh coupling',
'startVisualNovelRun uses the visual novel runtime work route',
'streamVisualNovelRuntimeAction posts to the SSE action stream route',
'regenerateVisualNovelRun uses the history regenerate route',
'listVisualNovelSaveArchives and resumeVisualNovelSaveArchive use platform archive routes',
'putVisualNovelRuntimeSnapshot only submits platform checkpoint metadata',
'buildVisualNovelRuntimeCheckpoint maps run id into session id',
'buildVisualNovelSaveArchiveState only uses runtime identifiers and hashes',
],
label: 'visual novel runtime client tests',
},
{
path: 'src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
needles: [
'readVisualNovelRuntimeRunFromSse parses raw text, typed steps and final run',
'readVisualNovelRuntimeRunFromSse accepts payload type when event name is message',
],
label: 'visual novel SSE tests',
},
{
path: 'src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx',
needles: [
'visual novel workspace renders mock creation shell without forbidden entry',
'visual novel workspace opens editable blank draft from blank source',
'visual novel workspace uploads document asset and passes asset id to session',
],
label: 'visual novel creation tests',
},
{
path: 'src/components/visual-novel-result/VisualNovelResultView.test.tsx',
needles: [
'visual novel result opens complex editors as a dialog',
'visual novel result exposes test run action with current draft',
'visual novel result sends edited character draft to save and test run',
'visual novel result uploads scene and character assets into platform references',
],
label: 'visual novel result tests',
},
{
path: 'src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx',
needles: [
'visual novel runtime renders mock play surface and opens panels as dialogs',
'visual novel runtime submits free text action with client event id',
'visual novel runtime submits choice and continue actions',
'visual novel runtime panels call regeneration and platform archive actions',
'visual novel runtime shows raw text only as transient stream text',
],
label: 'visual novel runtime tests',
},
];
function repoRelative(filePath) {
return relative(repoRoot, filePath).replace(/\\/gu, '/');
}
function readText(filePath) {
return readFileSync(filePath, 'utf8');
}
function ensureFileExists(relativePath, failures, checkedFiles) {
const fullPath = join(repoRoot, relativePath);
checkedFiles.push(relativePath);
if (!existsSync(fullPath)) {
failures.push(`missing file: ${relativePath}`);
return false;
}
return true;
}
function ensureNeedles(filePath, needles, failures) {
const content = readText(filePath);
for (const needle of needles) {
if (!content.includes(needle)) {
failures.push(`missing content in ${filePath}: ${needle}`);
}
}
}
function buildReport({
failures,
checkedFiles,
contentSummary,
}) {
const status = failures.length === 0 ? '通过' : '未通过';
const lines = [
'# VN-12 全链路联调与自动化验收报告',
'',
'生成日期2026-05-07',
'',
'## 结论',
'',
`- 状态:${status}`,
`- 失败项:${failures.length}`,
'- 收口说明VN-12 本次只补验收门禁、关键路径测试和报告记录,未扩展新玩法功能。',
'',
'## 自动化验收清单',
'',
...checkedFiles.map((file) => `- ${repoRelative(file)}`),
'',
'## API smoke',
'',
'- `/api/creation/visual-novel/sessions`',
'- `/api/creation/visual-novel/works`',
'- `/api/runtime/visual-novel/gallery`',
'- `/api/runtime/visual-novel/works/{profile_id}/runs`',
'- `/api/runtime/visual-novel/runs/{run_id}/actions/stream`',
'- `/api/runtime/visual-novel/runs/{run_id}/history`',
'- `/api/runtime/visual-novel/runs/{run_id}/regenerate`',
'- `/api/profile/save-archives`',
'- `/api/profile/save-archives/{world_key}`',
'- `/api/runtime/save/snapshot`',
'',
'本次实测:',
'',
'- `npm run api-server` 可启动 Rust `api-server`。',
'- `GET http://127.0.0.1:3100/healthz` 返回 `200`,响应为 `{"ok":true,"service":"genarrative-api-server"}`。',
'- `GET /api/runtime/visual-novel/gallery` 在当前本地环境返回超时 / `502`,日志显示 `api-server` 连接 `127.0.0.1:3101` SpacetimeDB 数据库 `xushi-p4wfr` 被拒绝;该项按本地 SpacetimeDB 未完整就绪记录为环境阻塞,不新增工程实现。',
'',
'## 前端关键路径',
'',
'- 创作工作台:`VisualNovelAgentWorkspace`',
'- 结果页:`VisualNovelResultView`',
'- 运行时:`VisualNovelRuntimeShell`',
'- 运行时 SSE`visualNovelRuntimeSse` / `visualNovelRuntimeClient`',
'',
'## 桌面 / 移动端检查',
'',
'- 桌面端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png`。',
'- 移动端:已用 Edge headless 截取 `/creation/visual-novel/agent`,文件为 `docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png`。',
'- in-app browser 插件本次未发现可用 IAB backend截图使用本机 Edge headless 兜底完成。',
'',
'## 校验摘要',
'',
...contentSummary.map((item) => `- ${item.label}: 通过`),
'',
'## 执行命令',
'',
'```bash',
'npm run check:visual-novel-vn12 -- --write-report',
'npm run test -- src/components/visual-novel-creation/VisualNovelAgentWorkspace.test.tsx src/components/visual-novel-result/VisualNovelResultView.test.tsx src/components/visual-novel-runtime/VisualNovelRuntimeShell.test.tsx src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts',
'npm run check:encoding',
'npm run typecheck',
'cd server-rs',
'cargo test -p shared-contracts',
'cargo test -p module-visual-novel',
'cargo check -p api-server',
'```',
'',
'## 未覆盖风险',
'',
'- 当前本地 SpacetimeDB 连接未完整就绪,公开 gallery API 的真实数据返回未在本次环境完成;`/healthz` 与编译 / 单测已通过。',
'- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。',
'',
];
return `${lines.join('\n')}\n`;
}
const failures = [];
const checkedFiles = [];
for (const file of requiredFiles) {
ensureFileExists(file, failures, checkedFiles);
}
const contentSummary = [];
for (const check of contentChecks) {
const fullPath = join(repoRoot, check.path);
if (!ensureFileExists(check.path, failures, checkedFiles)) {
continue;
}
ensureNeedles(fullPath, check.needles, failures);
contentSummary.push(check);
}
if (writeReport) {
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(
reportPath,
buildReport({
failures,
checkedFiles,
contentSummary,
}),
'utf8',
);
}
if (failures.length > 0) {
console.error('VN-12 acceptance gate failed:');
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exit(1);
}
console.log('VN-12 acceptance gate passed.');
console.log(`- checked files: ${checkedFiles.length}`);
console.log(`- content checks: ${contentSummary.length}`);
if (writeReport) {
console.log(`- report: ${repoRelative(reportPath)}`);
}

View File

@@ -43,6 +43,61 @@ resolve_client_host() {
echo "${host_name}" echo "${host_name}"
} }
load_api_server_env_files() {
local env_files=()
local key
local value
[[ -f "${REPO_ROOT}/.env" ]] && env_files+=("${REPO_ROOT}/.env")
[[ -f "${REPO_ROOT}/.env.local" ]] && env_files+=("${REPO_ROOT}/.env.local")
[[ -f "${REPO_ROOT}/.env.secrets.local" ]] && env_files+=("${REPO_ROOT}/.env.secrets.local")
if [[ "${#env_files[@]}" -eq 0 ]]; then
return
fi
# Node 只负责按 dotenv 子集解析文本;通过 NUL 分隔返回,避免让 env 文件内容参与 shell 求值。
while IFS= read -r -d '' key && IFS= read -r -d '' value; do
export "${key}=${value}"
done < <(
node - "${env_files[@]}" <<'NODE'
const fs = require('fs');
const shellEnvKeys = new Set(Object.keys(process.env));
const values = new Map();
for (const filePath of process.argv.slice(2)) {
if (!fs.existsSync(filePath)) {
continue;
}
const rawText = fs.readFileSync(filePath, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
if (shellEnvKeys.has(key)) {
continue;
}
values.set(key, rawValue.replace(/^['"]|['"]$/gu, ''));
}
}
for (const [key, value] of values.entries()) {
process.stdout.write(`${key}\0${value}\0`);
}
NODE
)
}
cleanup() { cleanup() {
local index local index
@@ -535,6 +590,9 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
fi fi
echo "[dev:rust] 启动 api-server" echo "[dev:rust] 启动 api-server"
load_api_server_env_files
# `.env.local` 可以给单独 `dev:web` 配置代理目标,但完整栈的前端必须跟随本次 `--api-port`。
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
( (
cd "${REPO_ROOT}" cd "${REPO_ROOT}"
GENARRATIVE_API_HOST="${API_HOST}" \ GENARRATIVE_API_HOST="${API_HOST}" \

View File

@@ -1,15 +1,52 @@
import {spawn} from 'node:child_process'; import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import {resolve} from 'node:path';
const repoRoot = process.cwd();
const shellEnvKeys = new Set(Object.keys(process.env));
function loadEnvFile(path, target) {
if (!existsSync(path)) {
return;
}
const rawText = readFileSync(path, 'utf8');
for (const rawLine of rawText.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
if (!match) {
continue;
}
const [, key, rawValue] = match;
// 中文注释:命令行显式传入的目标优先,`.env.local` 再覆盖 `.env`,与 api-server 启动脚本保持一致。
if (shellEnvKeys.has(key)) {
continue;
}
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
}
}
const fileEnv = {...process.env};
loadEnvFile(resolve(repoRoot, '.env'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.local'), fileEnv);
loadEnvFile(resolve(repoRoot, '.env.secrets.local'), fileEnv);
const mergedEnv = { const mergedEnv = {
...process.env, ...fileEnv,
RUST_SERVER_TARGET: RUST_SERVER_TARGET:
process.env.RUST_SERVER_TARGET || fileEnv.RUST_SERVER_TARGET ||
process.env.GENARRATIVE_API_TARGET || fileEnv.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${process.env.GENARRATIVE_API_PORT || '3100'}`, `http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`,
}; };
mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET = mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET =
process.env.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET; fileEnv.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET;
console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`); console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`);

666
server-rs/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ members = [
"crates/module-auth", "crates/module-auth",
"crates/module-big-fish", "crates/module-big-fish",
"crates/module-combat", "crates/module-combat",
"crates/module-creative-agent",
"crates/module-inventory", "crates/module-inventory",
"crates/module-custom-world", "crates/module-custom-world",
"crates/module-match3d", "crates/module-match3d",
@@ -25,9 +26,11 @@ members = [
"crates/module-runtime-item", "crates/module-runtime-item",
"crates/module-square-hole", "crates/module-square-hole",
"crates/module-story", "crates/module-story",
"crates/module-visual-novel",
"crates/platform-oss", "crates/platform-oss",
"crates/platform-auth", "crates/platform-auth",
"crates/platform-llm", "crates/platform-llm",
"crates/platform-agent",
"crates/shared-contracts", "crates/shared-contracts",
"crates/shared-kernel", "crates/shared-kernel",
"crates/shared-logging", "crates/shared-logging",
@@ -48,6 +51,7 @@ module-assets = { path = "crates/module-assets", default-features = false }
module-auth = { path = "crates/module-auth", default-features = false } module-auth = { path = "crates/module-auth", default-features = false }
module-big-fish = { path = "crates/module-big-fish", default-features = false } module-big-fish = { path = "crates/module-big-fish", default-features = false }
module-combat = { path = "crates/module-combat", default-features = false } module-combat = { path = "crates/module-combat", default-features = false }
module-creative-agent = { path = "crates/module-creative-agent", default-features = false }
module-custom-world = { path = "crates/module-custom-world", default-features = false } module-custom-world = { path = "crates/module-custom-world", default-features = false }
module-inventory = { path = "crates/module-inventory", default-features = false } module-inventory = { path = "crates/module-inventory", default-features = false }
module-match3d = { path = "crates/module-match3d", default-features = false } module-match3d = { path = "crates/module-match3d", default-features = false }
@@ -60,6 +64,8 @@ module-runtime-item = { path = "crates/module-runtime-item", default-features =
module-runtime-story = { path = "crates/module-runtime-story", default-features = false } module-runtime-story = { path = "crates/module-runtime-story", default-features = false }
module-square-hole = { path = "crates/module-square-hole", default-features = false } module-square-hole = { path = "crates/module-square-hole", default-features = false }
module-story = { path = "crates/module-story", default-features = false } module-story = { path = "crates/module-story", default-features = false }
module-visual-novel = { path = "crates/module-visual-novel", default-features = false }
platform-agent = { path = "crates/platform-agent", default-features = false }
platform-auth = { path = "crates/platform-auth", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false }
platform-llm = { path = "crates/platform-llm", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false }
platform-oss = { path = "crates/platform-oss", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false }
@@ -70,6 +76,7 @@ spacetime-client = { path = "crates/spacetime-client", default-features = false
argon2 = "0.5" argon2 = "0.5"
async-stream = "0.3" async-stream = "0.3"
async-trait = "0.1"
axum = "0.8" axum = "0.8"
base64 = "0.22" base64 = "0.22"
dotenvy = "0.15" dotenvy = "0.15"
@@ -77,6 +84,7 @@ hmac = "0.12"
http-body-util = "0.1" http-body-util = "0.1"
image = { version = "0.25", default-features = false } image = { version = "0.25", default-features = false }
jsonwebtoken = "9" jsonwebtoken = "9"
langchainrust = "0.2.18"
log = "0.4" log = "0.4"
rand_core = "0.6" rand_core = "0.6"
reqwest = { version = "0.12", default-features = false } reqwest = { version = "0.12", default-features = false }
@@ -98,6 +106,7 @@ url = "2"
urlencoding = "2" urlencoding = "2"
uuid = "1" uuid = "1"
webp = "0.3" webp = "0.3"
zip = { version = "2", default-features = false }
[profile.dev] [profile.dev]
opt-level = 0 # 默认 0有人手滑改 1/2 会慢 opt-level = 0 # 默认 0有人手滑改 1/2 会慢

View File

@@ -17,6 +17,7 @@ module-assets = { workspace = true, features = ["server-service"] }
module-auth = { workspace = true } module-auth = { workspace = true }
module-big-fish = { workspace = true } module-big-fish = { workspace = true }
module-combat = { workspace = true } module-combat = { workspace = true }
module-creative-agent = { workspace = true }
module-custom-world = { workspace = true } module-custom-world = { workspace = true }
module-inventory = { workspace = true } module-inventory = { workspace = true }
module-match3d = { workspace = true } module-match3d = { workspace = true }
@@ -27,6 +28,8 @@ module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true } module-runtime-item = { workspace = true }
module-square-hole = { workspace = true } module-square-hole = { workspace = true }
module-story = { workspace = true } module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
platform-auth = { workspace = true } platform-auth = { workspace = true }
platform-llm = { workspace = true } platform-llm = { workspace = true }
platform-oss = { workspace = true } platform-oss = { workspace = true }
@@ -44,6 +47,7 @@ tracing = { workspace = true }
url = { workspace = true } url = { workspace = true }
urlencoding = { workspace = true } urlencoding = { workspace = true }
uuid = { workspace = true, features = ["v4"] } uuid = { workspace = true, features = ["v4"] }
zip = { workspace = true, features = ["deflate"] }
[dev-dependencies] [dev-dependencies]
base64 = { workspace = true } base64 = { workspace = true }

View File

@@ -49,6 +49,11 @@ use crate::{
generate_character_visual, get_character_visual_job, publish_character_visual, generate_character_visual, get_character_visual_job, publish_character_visual,
}, },
creation_agent_document_input::parse_creation_agent_document_input, creation_agent_document_input::parse_creation_agent_document_input,
creative_agent::{
cancel_creative_agent_session, confirm_creative_puzzle_template,
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
stream_creative_draft_edit,
},
custom_world::{ custom_world::{
create_custom_world_agent_session, delete_custom_world_agent_session, create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action, delete_custom_world_library_profile, execute_custom_world_agent_action,
@@ -140,6 +145,14 @@ use crate::{
begin_story_runtime_session, begin_story_session, continue_story, begin_story_runtime_session, begin_story_session, continue_story,
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action, get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
}, },
visual_novel::{
compile_visual_novel_session, create_visual_novel_session, delete_visual_novel_work,
execute_visual_novel_action, get_visual_novel_run, get_visual_novel_session,
get_visual_novel_work, list_visual_novel_gallery, list_visual_novel_history,
list_visual_novel_works, publish_visual_novel_work, regenerate_visual_novel_run,
start_visual_novel_run, stream_visual_novel_action, stream_visual_novel_message,
submit_visual_novel_message, update_visual_novel_work,
},
wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login},
}; };
@@ -1014,6 +1027,8 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.merge(creative_agent_router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route( .route(
"/api/runtime/puzzle/onboarding/generate", "/api/runtime/puzzle/onboarding/generate",
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max( post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
@@ -1428,6 +1443,173 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state) .with_state(state)
} }
fn creative_agent_router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/runtime/creative-agent/sessions",
post(create_creative_agent_session)
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL沿用拼图参考图入口上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}",
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
post(stream_creative_agent_message)
// 中文注释message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
post(stream_creative_draft_edit)
// 中文注释:草稿编辑会携带当前 puzzle draft JSON保持和拼图草稿入口一致的 body 上限。
.layer(DefaultBodyLimit::max(
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
))
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
post(cancel_creative_agent_session)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
fn visual_novel_router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/creation/visual-novel/sessions",
post(create_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}",
get(get_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages",
post(submit_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/messages/stream",
post(stream_visual_novel_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/actions",
post(execute_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/sessions/{session_id}/compile",
post(compile_visual_novel_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works",
get(list_visual_novel_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}",
get(get_visual_novel_work)
.put(update_visual_novel_work)
.patch(update_visual_novel_work)
.delete(delete_visual_novel_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/visual-novel/works/{profile_id}/publish",
post(publish_visual_novel_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/gallery",
get(list_visual_novel_gallery),
)
.route(
"/api/runtime/visual-novel/works/{profile_id}/runs",
post(start_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}",
get(get_visual_novel_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/actions/stream",
post(stream_visual_novel_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/history",
get(list_visual_novel_history).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/visual-novel/runs/{run_id}/regenerate",
post(regenerate_visual_novel_run)
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::{ use axum::{
@@ -1450,6 +1632,8 @@ mod tests {
use super::build_router; use super::build_router;
const TEST_PASSWORD: &str = "secret123"; const TEST_PASSWORD: &str = "secret123";
const INTERNAL_TEST_SECRET: &str = "test-internal-secret";
async fn seed_phone_user_with_password( async fn seed_phone_user_with_password(
state: &AppState, state: &AppState,
phone_number: &str, phone_number: &str,
@@ -1507,6 +1691,43 @@ mod tests {
.expect("password login request should succeed") .expect("password login request should succeed")
} }
fn build_internal_creative_agent_app() -> Router {
let mut config = AppConfig::default();
config.internal_api_secret = Some(INTERNAL_TEST_SECRET.to_string());
build_router(AppState::new(config).expect("state should build"))
}
fn internal_creative_agent_request(method: &str, uri: &str, body: Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("x-genarrative-authenticated-user-id", "user-creative-test")
.header("x-genarrative-internal-api-secret", INTERNAL_TEST_SECRET)
.body(Body::from(body.to_string()))
.expect("creative agent request should build")
}
async fn read_json_response(response: axum::response::Response) -> Value {
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
serde_json::from_slice(&body).expect("response body should be valid json")
}
async fn read_text_response(response: axum::response::Response) -> String {
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
String::from_utf8(body.to_vec()).expect("response body should be utf8")
}
#[tokio::test] #[tokio::test]
async fn healthz_returns_legacy_compatible_payload_and_headers() { async fn healthz_returns_legacy_compatible_payload_and_headers() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -1600,6 +1821,244 @@ mod tests {
); );
} }
#[tokio::test]
async fn creative_agent_draft_edit_rejects_unconfirmed_template_session() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个生日拼图",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let edit_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream"),
serde_json::json!({
"clientMessageId": "creative-edit-test",
"instruction": "把标题改轻松一点",
"targetPuzzleSessionId": "puzzle-session-unconfirmed",
"currentDraft": {
"workTitle": "旧标题",
"workDescription": "旧描述",
"summary": "旧描述",
"themeTags": ["创意", "拼图", "灵感"],
"levels": [{
"levelId": "puzzle-level-1",
"levelName": "第一关",
"pictureDescription": "旧图面",
"pictureReference": null,
"generationStatus": "idle",
"candidates": []
}]
}
}),
))
.await
.expect("draft edit request should be handled");
assert_eq!(edit_response.status(), StatusCode::BAD_REQUEST);
let edit_payload = read_json_response(edit_response).await;
assert_eq!(
edit_payload["error"]["details"]["message"],
Value::String("尚未绑定拼图草稿".to_string())
);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
}
#[tokio::test]
async fn creative_agent_message_stream_returns_template_confirmation_events() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个生日拼图",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let stream_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/messages/stream"),
serde_json::json!({
"clientMessageId": "creative-message-stream-test",
"content": [{
"type": "input_text",
"text": "做一个温暖的生日拼图"
}]
}),
))
.await
.expect("message stream request should be handled");
assert_eq!(stream_response.status(), StatusCode::OK);
assert_eq!(
stream_response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok()),
Some("text/event-stream")
);
let stream_body = read_text_response(stream_response).await;
assert!(stream_body.contains("event: stage"));
assert!(stream_body.contains("event: tool_started"));
assert!(stream_body.contains("event: tool_completed"));
assert!(stream_body.contains("event: puzzle_template_catalog"));
assert!(!stream_body.contains("event: puzzle_template_selection"));
assert!(!stream_body.contains("event: puzzle_cost_range"));
assert!(stream_body.contains("event: done"));
let tool_started_id = stream_body
.lines()
.skip_while(|line| *line != "event: tool_started")
.nth(1)
.and_then(|line| line.strip_prefix("data: "))
.and_then(|data| serde_json::from_str::<Value>(data).ok())
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
.expect("tool_started should include toolCallId");
let tool_completed_id = stream_body
.lines()
.skip_while(|line| *line != "event: tool_completed")
.nth(1)
.and_then(|line| line.strip_prefix("data: "))
.and_then(|data| serde_json::from_str::<Value>(data).ok())
.and_then(|payload| payload["toolCallId"].as_str().map(ToString::to_string))
.expect("tool_completed should include toolCallId");
assert_eq!(tool_started_id, tool_completed_id);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(
session_payload["session"]["stage"],
Value::String("waiting_template_confirmation".to_string())
);
assert_eq!(
session_payload["session"]["puzzleTemplateSelection"],
Value::Null
);
assert!(
session_payload["session"]["puzzleTemplateCatalog"]
.as_array()
.map(|templates| templates.len() >= 3)
.unwrap_or(false)
);
}
#[tokio::test]
async fn creative_agent_confirm_template_rejects_non_puzzle_template() {
let app = build_internal_creative_agent_app();
let create_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
"/api/runtime/creative-agent/sessions",
serde_json::json!({
"text": "做一个角色扮演开场",
"entryContext": "creation_home"
}),
))
.await
.expect("create session request should succeed");
assert_eq!(create_response.status(), StatusCode::OK);
let create_payload = read_json_response(create_response).await;
let session_id = create_payload["session"]["sessionId"]
.as_str()
.expect("session id should exist");
let confirm_response = app
.clone()
.oneshot(internal_creative_agent_request(
"POST",
&format!("/api/runtime/creative-agent/sessions/{session_id}/confirm-template"),
serde_json::json!({
"selection": {
"templateId": "rpg.unsupported",
"title": "RPG",
"reason": "用户想创建 RPG",
"costRange": {
"minPoints": 2,
"maxPoints": 12,
"pricingUnit": "point",
"reason": "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准"
},
"supportedLevelMode": "single_or_multi",
"selectedLevelMode": "single_level",
"plannedLevelCount": 1,
"requiresUserConfirmation": true
}
}),
))
.await
.expect("confirm template request should be handled");
assert_eq!(confirm_response.status(), StatusCode::BAD_REQUEST);
let confirm_payload = read_json_response(confirm_response).await;
assert_eq!(
confirm_payload["error"]["details"]["provider"],
Value::String("module-puzzle".to_string())
);
let session_response = app
.oneshot(internal_creative_agent_request(
"GET",
&format!("/api/runtime/creative-agent/sessions/{session_id}"),
Value::Null,
))
.await
.expect("get session request should succeed");
let session_payload = read_json_response(session_response).await;
assert_eq!(
session_payload["session"]["stage"],
Value::String("idle".to_string())
);
assert_eq!(session_payload["session"]["targetBinding"], Value::Null);
}
#[tokio::test] #[tokio::test]
async fn runtime_story_legacy_routes_are_not_mounted() { async fn runtime_story_legacy_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -3982,4 +4441,58 @@ mod tests {
assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED); assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn visual_novel_creation_route_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/creation/visual-novel/sessions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"sourceMode": "idea",
"seedText": "雨夜书店",
"sourceAssetIds": []
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn visual_novel_forbidden_playback_routes_are_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let legacy_playback_segment = concat!("re", "play");
for path in [
format!("/api/creation/visual-novel/{legacy_playback_segment}"),
format!("/api/runtime/visual-novel/{legacy_playback_segment}"),
format!("/api/runtime/visual-novel/{legacy_playback_segment}s"),
format!("/api/visual/{legacy_playback_segment}"),
format!("/api/galgame/{legacy_playback_segment}"),
format!("/api/txt/{legacy_playback_segment}"),
] {
let response = app
.clone()
.oneshot(
Request::builder()
.uri(path.as_str())
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}");
}
}
} }

View File

@@ -191,6 +191,7 @@ fn allows_internal_forwarded_auth(path: &str) -> bool {
// Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。 // Node 代理已经完成平台账号 JWT 校验Rust 运行时只信任这些明确的内部转发路径。
path.starts_with("/api/runtime/big-fish/") path.starts_with("/api/runtime/big-fish/")
|| path.starts_with("/api/runtime/chat/") || path.starts_with("/api/runtime/chat/")
|| path.starts_with("/api/runtime/creative-agent/")
|| path.starts_with("/api/runtime/puzzle/") || path.starts_with("/api/runtime/puzzle/")
} }
@@ -287,6 +288,9 @@ mod tests {
assert!(allows_internal_forwarded_auth( assert!(allows_internal_forwarded_auth(
"/api/runtime/chat/npc/turn/stream" "/api/runtime/chat/npc/turn/stream"
)); ));
assert!(allows_internal_forwarded_auth(
"/api/runtime/creative-agent/sessions"
));
assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works")); assert!(allows_internal_forwarded_auth("/api/runtime/puzzle/works"));
assert!(!allows_internal_forwarded_auth("/api/auth/me")); assert!(!allows_internal_forwarded_auth("/api/auth/me"));
} }

View File

@@ -1,3 +1,5 @@
use std::io::{Cursor, Read};
use axum::{Json, extract::Extension, http::StatusCode}; use axum::{Json, extract::Extension, http::StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -12,7 +14,7 @@ use crate::{
const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024; const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 1024;
const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024; const MAX_DOCUMENT_INPUT_BASE64_CHARS: usize = 360 * 1024;
const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "csv", "json"]; const SUPPORTED_DOCUMENT_EXTENSIONS: &[&str] = &["txt", "md", "markdown", "docx", "csv", "json"];
pub async fn parse_creation_agent_document_input( pub async fn parse_creation_agent_document_input(
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -58,12 +60,8 @@ pub async fn parse_creation_agent_document_input(
); );
} }
let text = String::from_utf8(decoded.clone()).map_err(|_| { let extension = document_extension(&file_name)?;
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ let text = decode_document_text(&decoded, extension.as_str())?;
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})?;
let normalized_text = normalize_document_text(&text); let normalized_text = normalize_document_text(&text);
if normalized_text.trim().is_empty() { if normalized_text.trim().is_empty() {
@@ -88,6 +86,7 @@ pub async fn parse_creation_agent_document_input(
.map(str::to_string), .map(str::to_string),
size_bytes: decoded.len(), size_bytes: decoded.len(),
text: normalized_text, text: normalized_text,
source_asset_id: None,
}, },
}, },
)) ))
@@ -115,11 +114,7 @@ fn normalize_file_name(value: &str) -> Result<String, AppError> {
} }
fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> { fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
let extension = file_name let extension = document_extension(file_name)?;
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))?;
if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) { if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) {
return Err(unsupported_document_error(file_name)); return Err(unsupported_document_error(file_name));
@@ -128,15 +123,100 @@ fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> {
Ok(()) Ok(())
} }
fn document_extension(file_name: &str) -> Result<String, AppError> {
file_name
.rsplit_once('.')
.map(|(_, extension)| extension.trim().to_ascii_lowercase())
.filter(|extension| !extension.is_empty())
.ok_or_else(|| unsupported_document_error(file_name))
}
fn unsupported_document_error(file_name: &str) -> AppError { fn unsupported_document_error(file_name: &str) -> AppError {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 txt、md、csv、json 文本文档。", "message": "暂时只支持 txt、md、docx、csv、json 文档。",
"field": "fileName", "field": "fileName",
"fileName": file_name, "fileName": file_name,
"supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS, "supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS,
})) }))
} }
fn decode_document_text(bytes: &[u8], extension: &str) -> Result<String, AppError> {
if extension == "docx" {
return extract_docx_text(bytes);
}
String::from_utf8(bytes.to_vec()).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。",
"field": "contentBase64",
}))
})
}
fn extract_docx_text(bytes: &[u8]) -> Result<String, AppError> {
let reader = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(reader).map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档结构无效,请重新选择文件。",
"field": "contentBase64",
}))
})?;
let mut document_xml = String::new();
archive
.by_name("word/document.xml")
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档缺少正文内容。",
"field": "contentBase64",
}))
})?
.read_to_string(&mut document_xml)
.map_err(|_| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"message": "docx 文档正文读取失败。",
"field": "contentBase64",
}))
})?;
Ok(extract_docx_visible_text(document_xml.as_str()))
}
fn extract_docx_visible_text(xml: &str) -> String {
let mut output = String::new();
let mut cursor = 0usize;
while let Some(start_offset) = xml[cursor..].find("<w:t") {
let start = cursor + start_offset;
let Some(tag_end_offset) = xml[start..].find('>') else {
break;
};
let text_start = start + tag_end_offset + 1;
let Some(end_offset) = xml[text_start..].find("</w:t>") else {
break;
};
let text_end = text_start + end_offset;
output.push_str(&decode_xml_text(&xml[text_start..text_end]));
cursor = text_end + "</w:t>".len();
if let Some(next_break) = xml[cursor..].find("<w:br") {
if next_break == 0 {
output.push('\n');
}
}
}
output
}
fn decode_xml_text(value: &str) -> String {
value
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&amp;", "&")
}
fn normalize_document_text(value: &str) -> String { fn normalize_document_text(value: &str) -> String {
value value
.trim_start_matches('\u{feff}') .trim_start_matches('\u{feff}')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
use axum::response::sse::Event;
use serde::Serialize;
use serde_json::{Value, json};
use shared_contracts::creative_agent::{CreativeAgentErrorEvent, CreativeAgentSseEventType};
pub fn creative_sse_json_event<T>(event: CreativeAgentSseEventType, payload: T) -> Event
where
T: Serialize,
{
let event_name = creative_event_name(event);
match serde_json::to_value(payload)
.ok()
.and_then(|value| Event::default().event(event_name).json_data(value).ok())
{
Some(event) => event,
None => creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败"),
}
}
pub fn creative_sse_json_value_event(event_name: &str, payload: Value) -> Event {
Event::default()
.event(event_name)
.json_data(payload)
.unwrap_or_else(|_| {
creative_sse_error_event(None, "SSE_SERIALIZE_FAILED", "SSE payload 序列化失败")
})
}
pub fn creative_sse_error_event(
session_id: Option<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Event {
let payload = serde_json::to_string(&CreativeAgentErrorEvent {
session_id,
code: code.into(),
message: message.into(),
recoverable: false,
})
.unwrap_or_else(|_| {
json!({
"sessionId": null,
"code": "SSE_ERROR_SERIALIZE_FAILED",
"message": "SSE 错误事件序列化失败",
"recoverable": false,
})
.to_string()
});
Event::default().event("error").data(payload)
}
fn creative_event_name(event: CreativeAgentSseEventType) -> &'static str {
match event {
CreativeAgentSseEventType::Stage => "stage",
CreativeAgentSseEventType::AgentMessageDelta => "agent_message_delta",
CreativeAgentSseEventType::ThoughtSummaryDelta => "thought_summary_delta",
CreativeAgentSseEventType::PuzzleTemplateCatalog => "puzzle_template_catalog",
CreativeAgentSseEventType::PuzzleTemplateSelection => "puzzle_template_selection",
CreativeAgentSseEventType::PuzzleCostRange => "puzzle_cost_range",
CreativeAgentSseEventType::PuzzleLevelPlan => "puzzle_level_plan",
CreativeAgentSseEventType::ToolStarted => "tool_started",
CreativeAgentSseEventType::ToolCompleted => "tool_completed",
CreativeAgentSseEventType::Reflection => "reflection",
CreativeAgentSseEventType::TargetSession => "target_session",
CreativeAgentSseEventType::Session => "session",
CreativeAgentSseEventType::Error => "error",
CreativeAgentSseEventType::Done => "done",
}
}

View File

@@ -89,6 +89,12 @@ impl IntoResponse for AppError {
} }
} }
impl From<AppError> for Response {
fn from(error: AppError) -> Self {
error.into_response()
}
}
fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) { fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) {
match status_code { match status_code {
StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"), StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"),

View File

@@ -23,6 +23,8 @@ mod creation_agent_anchor_templates;
mod creation_agent_chat; mod creation_agent_chat;
mod creation_agent_document_input; mod creation_agent_document_input;
mod creation_agent_llm_turn; mod creation_agent_llm_turn;
mod creative_agent;
mod creative_agent_sse;
mod custom_world; mod custom_world;
mod custom_world_agent_entities; mod custom_world_agent_entities;
mod custom_world_agent_turn; mod custom_world_agent_turn;
@@ -66,11 +68,13 @@ mod square_hole_agent_turn;
mod state; mod state;
mod story_battles; mod story_battles;
mod story_sessions; mod story_sessions;
mod visual_novel;
mod wechat_auth; mod wechat_auth;
mod wechat_provider; mod wechat_provider;
mod work_author; mod work_author;
use shared_logging::init_tracing; use shared_logging::init_tracing;
use std::{collections::HashSet, env, fs, io, panic, thread};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::runtime::Builder as TokioRuntimeBuilder; use tokio::runtime::Builder as TokioRuntimeBuilder;
use tracing::info; use tracing::info;
@@ -79,30 +83,30 @@ use crate::{app::build_router, config::AppConfig, state::AppState};
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
fn main() -> Result<(), std::io::Error> { fn main() -> Result<(), io::Error> {
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。 // Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
std::thread::Builder::new() let server_thread = thread::Builder::new()
.name("api-server-bootstrap".to_string()) .name("api-server-bootstrap".to_string())
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) .stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.spawn(run_api_server_with_runtime)? .spawn(|| {
.join() TokioRuntimeBuilder::new_multi_thread()
.map_err(|_| std::io::Error::other("api-server 启动线程异常退出"))? .enable_all()
.thread_name("api-server-worker")
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.build()?
.block_on(run_server())
})?;
match server_thread.join() {
Ok(result) => result,
Err(payload) => panic::resume_unwind(payload),
}
} }
fn run_api_server_with_runtime() -> Result<(), std::io::Error> { async fn run_server() -> Result<(), io::Error> {
TokioRuntimeBuilder::new_multi_thread() // 运行本地开发与联调时,优先从仓库根目录加载本地变量。
.enable_all() // 只尊重外层 shell 先注入的变量;.env.local 需要能覆盖 .env。
.thread_name("api-server-worker") load_local_env_files();
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
.build()?
.block_on(run_api_server())
}
async fn run_api_server() -> Result<(), std::io::Error> {
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
let _ = dotenvy::from_filename(".env");
let _ = dotenvy::from_filename(".env.local");
let _ = dotenvy::from_filename(".env.secrets.local");
// 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。
let config = AppConfig::from_env(); let config = AppConfig::from_env();
@@ -120,3 +124,92 @@ async fn run_api_server() -> Result<(), std::io::Error> {
axum::serve(listener, router).await axum::serve(listener, router).await
} }
fn load_local_env_files() {
let shell_env_keys = env::vars().map(|(key, _)| key).collect::<HashSet<_>>();
for path in [".env", ".env.local", ".env.secrets.local"] {
load_env_file(path, &shell_env_keys);
}
}
fn load_env_file(path: &str, shell_env_keys: &HashSet<String>) {
let Ok(raw_text) = fs::read_to_string(path) else {
return;
};
let raw_text = raw_text.trim_start_matches('\u{feff}');
for raw_line in raw_text.split('\n') {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = line.split_once('=') else {
continue;
};
let key = raw_key.trim().trim_start_matches('\u{feff}');
if !is_valid_env_key(key) || shell_env_keys.contains(key) {
continue;
}
// 这里只在启动前、Tokio runtime 创建前写入进程环境,避免并发读写 env。
unsafe {
env::set_var(key, strip_env_value(raw_value));
}
}
}
fn strip_env_value(raw_value: &str) -> String {
let value = raw_value.trim_end_matches('\r');
if value.len() >= 2 {
let bytes = value.as_bytes();
let first = bytes[0];
let last = bytes[value.len() - 1];
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
fn is_valid_env_key(key: &str) -> bool {
let mut chars = key.chars();
match chars.next() {
Some(first) if first == '_' || first.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
#[cfg(test)]
mod tests {
use super::{is_valid_env_key, strip_env_value};
#[test]
fn strip_env_value_removes_wrapping_quotes() {
assert_eq!(strip_env_value("\"true\""), "true");
assert_eq!(strip_env_value("'aliyun'"), "aliyun");
assert_eq!(strip_env_value("plain\r"), "plain");
}
#[test]
fn load_env_key_can_strip_utf8_bom_prefix() {
let key = "\u{feff}SMS_AUTH_ENABLED"
.trim()
.trim_start_matches('\u{feff}');
assert_eq!(key, "SMS_AUTH_ENABLED");
}
#[test]
fn is_valid_env_key_accepts_dotenv_key_subset() {
assert!(is_valid_env_key("SMS_AUTH_ENABLED"));
assert!(is_valid_env_key("_LOCAL_KEY_1"));
assert!(!is_valid_env_key("1_BAD"));
assert!(!is_valid_env_key("BAD-KEY"));
}
}

View File

@@ -5,6 +5,7 @@ pub(crate) mod puzzle;
pub(crate) mod rpg; pub(crate) mod rpg;
pub(crate) mod scene_background; pub(crate) mod scene_background;
pub(crate) mod square_hole; pub(crate) mod square_hole;
pub(crate) mod visual_novel;
pub(crate) use rpg::agent_chat; pub(crate) use rpg::agent_chat;
pub(crate) use rpg::foundation_draft; pub(crate) use rpg::foundation_draft;

View File

@@ -0,0 +1,690 @@
#![allow(dead_code)]
use platform_llm::{LlmMessage, LlmTextRequest};
use serde_json::{Value as JsonValue, json};
use shared_contracts::visual_novel::{VisualNovelResultDraft, VisualNovelRuntimeStep};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
pub(crate) const VISUAL_NOVEL_CREATION_SYSTEM_PROMPT: &str = r#"你是百梦平台内的视觉小说模板创作导演。
你的任务是把用户的一句话、文档摘要或空白创建意图,生成一份可以进入结果页继续编辑的 VisualNovelResultDraft。
硬约束:
1. 只能输出一个 JSON 对象,不要输出 Markdown、代码块、解释或 UI 规则说明。
2. 输出内容必须是中文视觉小说底稿,补齐世界观、玩家身份、角色、场景、剧情阶段和开场。
3. 每个角色必须有可生成立绘的 appearance每个场景必须有可生成背景图的 description。
4. sourceMode 必须沿用输入的 idea、document 或 blank。
5. 图片、音乐、文档只能写平台资产引用或 null不能写大段 data URL。
6. 不要输出旧 TXT 播放记录、分享播放包、外部商业、运营、活动、展示横幅、交易或独立账号字段。
7. 不要发明第二套存档、发布、钱包、广场或资产系统。
8. publishReady 只有在 opening 场景、主要角色、剧情阶段和 2 到 4 个 initialChoices 都齐备时才可以为 true。
"#;
pub(crate) const VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT: &str = r#"你是百梦视觉小说运行时 GM。
你的任务是读取作品底稿、当前 run snapshot、玩家动作和最近历史然后输出下一轮 VisualNovelRuntimeStep[]。
硬约束:
1. 只能输出一个 JSON 数组不要输出对象包裹、Markdown、代码块、解释或 UI 规则说明。
2. 每轮 step 数量不能超过输入的 maxAssistantStepCountPerTurn。
3. 场景变化必须先输出 scene_change。
4. 旁白使用 narration角色说话使用 dialogue转场使用 transition。
5. 需要玩家选择时必须输出 choicechoice 内每项必须有 choiceId 和 text。
6. 关键剧情事实变化使用 flag数值倾向变化使用 metric。
7. 不要让前端从 raw_text 猜业务 step不要输出未定义 step 类型。
8. 不要输出旧 TXT 播放记录、分享播放包、屏幕记录、外部商业、运营、活动或独立保存元数据。
"#;
pub(crate) const VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT: &str = r#"你是视觉小说结构化输出修复器。
你的任务是把上一次模型输出修复为目标 JSON 契约。
硬约束:
1. 只能输出目标 JSON不要解释错误原因。
2. 不能新增目标契约之外的字段。
3. 不要把普通历史、运行事件或 raw_text 改写成旧 TXT 播放包、屏幕记录或分享片段。
4. 如果原文缺失必要信息,只补最小可运行占位值,并保持中文内容。
"#;
const VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT: &str = r#"{
"profileId": null,
"workTitle": "",
"workDescription": "",
"workTags": [],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "",
"summary": "",
"background": "",
"premise": "",
"literaryStyle": "",
"playerRole": "",
"defaultTone": ""
},
"characters": [
{
"characterId": "char-main-1",
"name": "",
"gender": null,
"role": "main",
"appearance": "",
"personality": "",
"tone": "",
"background": "",
"relationshipToPlayer": "",
"imageAssets": [],
"defaultExpression": null,
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-opening",
"name": "",
"description": "",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": []
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "",
"goal": "",
"summary": "",
"entryCondition": "",
"exitCondition": "",
"sceneIds": ["scene-opening"],
"characterIds": ["char-main-1"],
"suggestedChoices": []
}
],
"opening": {
"sceneId": "scene-opening",
"narration": "",
"speakerCharacterId": null,
"firstDialogue": null,
"initialChoices": [
{ "choiceId": "choice-opening-1", "text": "", "actionHint": null },
{ "choiceId": "choice-opening-2", "text": "", "actionHint": null }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": false,
"validationIssues": [],
"updatedAt": "ISO-8601"
}"#;
const VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT: &str = r#"[
{ "type": "scene_change", "sceneId": "scene-opening", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "" },
{ "type": "dialogue", "characterId": "char-main-1", "characterName": "", "expression": null, "text": "" },
{ "type": "transition", "transitionKind": "fade", "text": null },
{ "type": "flag", "key": "", "value": true },
{ "type": "metric", "key": "", "delta": 1 },
{ "type": "choice", "choices": [{ "choiceId": "choice-next-1", "text": "", "actionHint": null }] }
]"#;
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelCreationPromptParams<'a> {
pub(crate) source_mode: &'a str,
pub(crate) seed_text: Option<&'a str>,
pub(crate) source_asset_ids: &'a [String],
pub(crate) document_summary: Option<&'a str>,
pub(crate) current_draft: Option<&'a JsonValue>,
pub(crate) recent_messages: &'a [JsonValue],
pub(crate) now_iso: &'a str,
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRuntimePromptParams<'a> {
pub(crate) work_profile: &'a JsonValue,
pub(crate) run_snapshot: &'a JsonValue,
pub(crate) runtime_action: &'a JsonValue,
pub(crate) recent_history: &'a [JsonValue],
pub(crate) max_assistant_step_count_per_turn: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum VisualNovelRepairTarget {
ResultDraft,
RuntimeSteps,
}
impl VisualNovelRepairTarget {
fn label(self) -> &'static str {
match self {
Self::ResultDraft => "VisualNovelResultDraft",
Self::RuntimeSteps => "VisualNovelRuntimeStep[]",
}
}
fn contract(self) -> &'static str {
match self {
Self::ResultDraft => VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT,
Self::RuntimeSteps => VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT,
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct VisualNovelRepairPromptParams<'a> {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) raw_text: &'a str,
pub(crate) parse_error: &'a str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct VisualNovelPromptParseFailure {
pub(crate) target: VisualNovelRepairTarget,
pub(crate) message: String,
}
impl VisualNovelPromptParseFailure {
pub(crate) fn retryable_message(&self) -> String {
format!(
"{} 输出结构不可解析,可重试或进入 repair{}",
self.target.label(),
self.message
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct VisualNovelToolDescriptor {
pub(crate) name: &'static str,
pub(crate) description: &'static str,
pub(crate) input_schema: JsonValue,
}
pub(crate) fn build_visual_novel_creation_user_prompt(
params: VisualNovelCreationPromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_result_draft",
"sourceMode": params.source_mode,
"seedText": params.seed_text.unwrap_or("").trim(),
"sourceAssetIds": params.source_asset_ids,
"documentSummary": params.document_summary.unwrap_or("").trim(),
"currentDraft": params.current_draft,
"recentMessages": params.recent_messages,
"nowIso": params.now_iso,
"draftRequirements": {
"mainCharacters": "3 到 6 个,至少 1 个非玩家主要角色",
"scenes": "3 到 8 个,至少 1 个 opening 场景",
"storyPhases": "3 到 6 个,第一阶段可从 opening 进入",
"initialChoices": "2 到 4 个",
"runtimeConfigDefaults": "沿用契约默认值attributePanelMode 默认为 off"
},
"outputContract": VISUAL_NOVEL_CREATION_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_runtime_user_prompt(
params: VisualNovelRuntimePromptParams<'_>,
) -> String {
json!({
"task": "generate_visual_novel_runtime_steps",
"workProfile": params.work_profile,
"runSnapshot": params.run_snapshot,
"runtimeAction": params.runtime_action,
"recentHistory": params.recent_history,
"maxAssistantStepCountPerTurn": params.max_assistant_step_count_per_turn,
"runtimeRules": [
"只以 step 数组作为正式业务输出",
"当前选择项必须来自 runSnapshot.availableChoices 或由本轮 choice step 重新给出",
"如果玩家自由输入改变事实,必须用 flag 或 metric 表达可持久化变化",
"不要在输出中夹带 raw_text、debug、prompt、historyPlayback 或平台运营字段"
],
"outputContract": VISUAL_NOVEL_RUNTIME_OUTPUT_CONTRACT
})
.to_string()
}
pub(crate) fn build_visual_novel_repair_user_prompt(
params: VisualNovelRepairPromptParams<'_>,
) -> String {
json!({
"task": "repair_visual_novel_structured_output",
"target": params.target.label(),
"parseError": params.parse_error,
"rawText": params.raw_text,
"outputContract": params.target.contract()
})
.to_string()
}
pub(crate) fn build_visual_novel_creation_llm_request(
params: VisualNovelCreationPromptParams<'_>,
enable_web_search: bool,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_creation_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
.with_web_search(enable_web_search)
}
pub(crate) fn build_visual_novel_runtime_llm_request(
params: VisualNovelRuntimePromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_runtime_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn build_visual_novel_repair_llm_request(
params: VisualNovelRepairPromptParams<'_>,
) -> LlmTextRequest {
LlmTextRequest::new(vec![
LlmMessage::system(VISUAL_NOVEL_REPAIR_SYSTEM_PROMPT),
LlmMessage::user(build_visual_novel_repair_user_prompt(params)),
])
.with_model(CREATION_TEMPLATE_LLM_MODEL)
.with_responses_api()
}
pub(crate) fn visual_novel_tool_descriptors() -> Vec<VisualNovelToolDescriptor> {
vec![
VisualNovelToolDescriptor {
name: "visual_novel_apply_creation_action",
description: "执行视觉小说创作 action写回 VisualNovelResultDraft 或编译平台 work profile 草稿。",
input_schema: json!({
"type": "object",
"required": ["kind"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": [
"generate_draft",
"patch_world",
"patch_character",
"patch_scene",
"patch_story_phase",
"compile_work_profile"
]
},
"targetId": { "type": ["string", "null"] },
"payload": { "type": "object", "additionalProperties": true }
}
}),
},
VisualNovelToolDescriptor {
name: "visual_novel_generate_image_asset",
description: "为视觉小说角色立绘或场景背景生成图片,并返回平台资产引用。",
input_schema: json!({
"type": "object",
"required": ["kind", "targetId", "prompt"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["generate_scene_image", "generate_character_image"]
},
"targetId": { "type": "string", "minLength": 1 },
"prompt": { "type": "string", "minLength": 1 },
"styleHints": { "type": "array", "items": { "type": "string" } },
"sourceImageAssetId": { "type": ["string", "null"] }
}
}),
},
]
}
pub(crate) fn parse_visual_novel_result_draft_fixture(
text: &str,
) -> Result<VisualNovelResultDraft, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Object,
VisualNovelRepairTarget::ResultDraft,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::ResultDraft,
message: error.to_string(),
})
}
pub(crate) fn parse_visual_novel_runtime_steps_fixture(
text: &str,
) -> Result<Vec<VisualNovelRuntimeStep>, VisualNovelPromptParseFailure> {
let value = extract_json_root(
text,
JsonRootShape::Array,
VisualNovelRepairTarget::RuntimeSteps,
)?;
serde_json::from_value(value).map_err(|error| VisualNovelPromptParseFailure {
target: VisualNovelRepairTarget::RuntimeSteps,
message: error.to_string(),
})
}
#[derive(Clone, Copy)]
enum JsonRootShape {
Object,
Array,
}
fn extract_json_root(
text: &str,
shape: JsonRootShape,
target: VisualNovelRepairTarget,
) -> Result<JsonValue, VisualNovelPromptParseFailure> {
let trimmed = strip_json_code_fence(text.trim());
if let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) {
return Ok(value);
}
let (start_char, end_char) = match shape {
JsonRootShape::Object => ('{', '}'),
JsonRootShape::Array => ('[', ']'),
};
let start = trimmed.find(start_char);
let end = trimmed.rfind(end_char);
match (start, end) {
(Some(start), Some(end)) if end > start => {
serde_json::from_str::<JsonValue>(&trimmed[start..=end]).map_err(|error| {
VisualNovelPromptParseFailure {
target,
message: error.to_string(),
}
})
}
_ => Err(VisualNovelPromptParseFailure {
target,
message: format!("未找到目标 JSON {}", target.label()),
}),
}
}
fn strip_json_code_fence(text: &str) -> &str {
let trimmed = text.trim();
if !trimmed.starts_with("```") {
return trimmed;
}
let without_start = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```JSON"))
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim();
without_start
.strip_suffix("```")
.unwrap_or(without_start)
.trim()
}
#[cfg(test)]
mod tests {
use platform_llm::LlmTextProtocol;
use serde_json::json;
use super::*;
fn source_asset_ids() -> Vec<String> {
vec!["asset-doc-1".to_string()]
}
fn creation_params<'a>(source_asset_ids: &'a [String]) -> VisualNovelCreationPromptParams<'a> {
VisualNovelCreationPromptParams {
source_mode: "idea",
seed_text: Some("雨夜里,只在午夜出现的书店会归还人们遗失的名字。"),
source_asset_ids,
document_summary: None,
current_draft: None,
recent_messages: &[],
now_iso: "2026-05-05T12:00:00Z",
}
}
fn runtime_params<'a>(
work_profile: &'a JsonValue,
run_snapshot: &'a JsonValue,
runtime_action: &'a JsonValue,
) -> VisualNovelRuntimePromptParams<'a> {
VisualNovelRuntimePromptParams {
work_profile,
run_snapshot,
runtime_action,
recent_history: &[],
max_assistant_step_count_per_turn: 8,
}
}
fn sample_draft() -> JsonValue {
json!({
"profileId": null,
"workTitle": "雨夜书店",
"workDescription": "一名失去名字的读者在午夜书店寻找真相。",
"workTags": ["悬疑", "治愈"],
"coverImageSrc": null,
"sourceMode": "idea",
"sourceAssetIds": [],
"world": {
"title": "雨夜书店",
"summary": "午夜书店会收留遗失名字的人。",
"background": "旧城区尽头有一家只在雨夜开门的书店,书架保存着人们遗忘的片段。",
"premise": "玩家要在天亮前找回自己的名字。",
"literaryStyle": "细腻、克制、轻悬疑",
"playerRole": "失去名字的读者",
"defaultTone": "雨夜、温柔、隐秘"
},
"characters": [
{
"characterId": "char-keeper",
"name": "林栖",
"gender": "",
"role": "main",
"appearance": "银灰短发,深绿围裙,手中常拿一盏铜灯,适合半身立绘。",
"personality": "温和但不轻易透露真相",
"tone": "低声、像在翻旧书",
"background": "午夜书店的看守者。",
"relationshipToPlayer": "知道玩家名字的一部分。",
"imageAssets": [],
"defaultExpression": "calm",
"isPlayerVisible": false
}
],
"scenes": [
{
"sceneId": "scene-bookstore",
"name": "午夜书店",
"description": "窄巷尽头的木门半开,暖黄灯光落在潮湿石板上,室内书架高而幽深。",
"backgroundImageSrc": null,
"musicSrc": null,
"ambientSoundSrc": null,
"availability": "opening",
"phaseIds": ["phase-opening"]
}
],
"storyPhases": [
{
"phaseId": "phase-opening",
"title": "失名之夜",
"goal": "确认玩家为何失去名字",
"summary": "玩家进入书店,与林栖第一次交谈。",
"entryCondition": "opening",
"exitCondition": "找到第一张名字书签",
"sceneIds": ["scene-bookstore"],
"characterIds": ["char-keeper"],
"suggestedChoices": ["询问书店来历", "查看柜台上的旧书"]
}
],
"opening": {
"sceneId": "scene-bookstore",
"narration": "雨水顺着伞尖落下时,你发现门牌上的字正在一点点亮起。",
"speakerCharacterId": "char-keeper",
"firstDialogue": "你终于来了。名字丢失的人,总会先听见这场雨。",
"initialChoices": [
{ "choiceId": "choice-ask-name", "text": "询问自己的名字在哪里", "actionHint": "向林栖确认线索" },
{ "choiceId": "choice-look-book", "text": "查看柜台上的旧书", "actionHint": "寻找名字书签" }
]
},
"runtimeConfig": {
"textModeEnabled": true,
"defaultTextMode": false,
"maxHistoryEntries": 80,
"maxAssistantStepCountPerTurn": 8,
"allowFreeTextAction": true,
"allowHistoryRegeneration": true,
"attributePanelMode": "off",
"saveArchiveEnabled": true
},
"publishReady": true,
"validationIssues": [],
"updatedAt": "2026-05-05T12:00:00Z"
})
}
#[test]
fn creation_fixture_parses_as_visual_novel_result_draft() {
let raw_text = format!("模型输出如下:\n{}", sample_draft());
let draft = parse_visual_novel_result_draft_fixture(raw_text.as_str())
.expect("draft fixture should parse");
assert_eq!(draft.work_title, "雨夜书店");
assert_eq!(draft.characters[0].character_id, "char-keeper");
assert_eq!(draft.opening.initial_choices.len(), 2);
}
#[test]
fn runtime_fixture_parses_as_typed_steps() {
let raw_text = json!([
{ "type": "scene_change", "sceneId": "scene-bookstore", "backgroundImageSrc": null, "musicSrc": null },
{ "type": "narration", "text": "门铃轻响,雨声像被书页吸走。" },
{ "type": "dialogue", "characterId": "char-keeper", "characterName": "林栖", "expression": "calm", "text": "先别急着找答案,先告诉我你还记得什么。" },
{ "type": "flag", "key": "met_keeper", "value": true },
{ "type": "metric", "key": "keeper_trust", "delta": 1 },
{
"type": "choice",
"choices": [
{ "choiceId": "choice-tell-memory", "text": "说出最后记得的街名", "actionHint": "提供线索" },
{ "choiceId": "choice-stay-silent", "text": "保持沉默观察她", "actionHint": "观察林栖反应" }
]
}
])
.to_string();
let steps = parse_visual_novel_runtime_steps_fixture(raw_text.as_str())
.expect("runtime fixture should parse");
assert_eq!(steps.len(), 6);
assert!(matches!(
steps[0],
VisualNovelRuntimeStep::SceneChange { .. }
));
assert!(matches!(steps[5], VisualNovelRuntimeStep::Choice { .. }));
}
#[test]
fn bad_runtime_output_can_enter_repair_prompt() {
let failure = parse_visual_novel_runtime_steps_fixture("林栖说:欢迎来到书店。")
.expect_err("bad output should fail");
let retryable_message = failure.retryable_message();
let repair_prompt = build_visual_novel_repair_user_prompt(VisualNovelRepairPromptParams {
target: failure.target,
raw_text: "林栖说:欢迎来到书店。",
parse_error: failure.message.as_str(),
});
assert!(retryable_message.contains("可重试"));
assert!(repair_prompt.contains("VisualNovelRuntimeStep[]"));
assert!(repair_prompt.contains("林栖说"));
assert!(repair_prompt.contains("scene_change"));
}
#[test]
fn llm_requests_use_responses_template_model() {
let asset_ids = source_asset_ids();
let creation_request =
build_visual_novel_creation_llm_request(creation_params(asset_ids.as_slice()), true);
assert_eq!(
creation_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(creation_request.protocol, LlmTextProtocol::Responses);
assert!(creation_request.enable_web_search);
assert!(
creation_request.messages[0]
.content
.contains("VisualNovelResultDraft")
);
assert!(
creation_request.messages[1]
.content
.contains("sourceAssetIds")
);
let work_profile = sample_draft();
let run_snapshot = json!({ "runId": "run-1", "availableChoices": [] });
let runtime_action = json!({ "actionKind": "continue", "clientEventId": "event-1" });
let runtime_request = build_visual_novel_runtime_llm_request(runtime_params(
&work_profile,
&run_snapshot,
&runtime_action,
));
assert_eq!(
runtime_request.model.as_deref(),
Some(CREATION_TEMPLATE_LLM_MODEL)
);
assert_eq!(runtime_request.protocol, LlmTextProtocol::Responses);
assert!(!runtime_request.enable_web_search);
assert!(
runtime_request.messages[0]
.content
.contains("VisualNovelRuntimeStep[]")
);
}
#[test]
fn prompts_and_tools_guard_against_external_platform_fields() {
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("外部商业"));
assert!(VISUAL_NOVEL_CREATION_SYSTEM_PROMPT.contains("独立账号"));
assert!(VISUAL_NOVEL_RUNTIME_GM_SYSTEM_PROMPT.contains("独立保存"));
let tools = visual_novel_tool_descriptors();
let tool_payload = serde_json::to_string(&json!(
tools
.iter()
.map(|tool| json!({
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema,
}))
.collect::<Vec<_>>()
))
.expect("tools should serialize");
assert!(tool_payload.contains("generate_scene_image"));
assert!(tool_payload.contains("generate_character_image"));
assert!(tool_payload.contains("compile_work_profile"));
let legacy_playback_marker = format!("{}{}", "re", "play");
assert!(!tool_payload.contains(&legacy_playback_marker));
assert!(!tool_payload.contains(&legacy_playback_marker.to_uppercase()));
}
}

View File

@@ -219,6 +219,7 @@ pub async fn generate_puzzle_onboarding_work(
level_id: "onboarding-level-1".to_string(), level_id: "onboarding-level-1".to_string(),
level_name: level_name.clone(), level_name: level_name.clone(),
picture_description: prompt_text.clone(), picture_description: prompt_text.clone(),
picture_reference: None,
candidates, candidates,
selected_candidate_id: Some(selected.candidate_id.clone()), selected_candidate_id: Some(selected.candidate_id.clone()),
cover_image_src: Some(selected.image_src.clone()), cover_image_src: Some(selected.image_src.clone()),
@@ -706,6 +707,7 @@ pub async fn execute_puzzle_agent_action(
); );
let (operation_type, phase_label, phase_detail, session) = match action.as_str() { let (operation_type, phase_label, phase_detail, session) = match action.as_str() {
"compile_puzzle_draft" => { "compile_puzzle_draft" => {
let ai_redraw = payload.ai_redraw.unwrap_or(true);
let prompt_text = payload let prompt_text = payload
.picture_description .picture_description
.as_deref() .as_deref()
@@ -725,33 +727,49 @@ pub async fn execute_puzzle_agent_action(
Ok(next_session_id) => next_session_id, Ok(next_session_id) => next_session_id,
Err(response) => return Err(response), Err(response) => return Err(response),
}; };
let session = execute_billable_asset_operation_with_cost( let session = if ai_redraw {
&state, execute_billable_asset_operation_with_cost(
&owner_user_id, &state,
"puzzle_initial_image", &owner_user_id,
&billing_asset_id, "puzzle_initial_image",
PUZZLE_IMAGE_GENERATION_POINTS_COST, &billing_asset_id,
async { PUZZLE_IMAGE_GENERATION_POINTS_COST,
compile_puzzle_draft_with_initial_cover( async {
&state, compile_puzzle_draft_with_initial_cover(
compile_session_id.clone(), &state,
owner_user_id.clone(), compile_session_id.clone(),
prompt_text, owner_user_id.clone(),
payload.reference_image_src.as_deref(), prompt_text,
payload.image_model.as_deref(), payload.reference_image_src.as_deref(),
now, payload.image_model.as_deref(),
) now,
.await )
}, .await
) },
.await )
.await
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
compile_session_id.clone(),
owner_user_id.clone(),
prompt_text,
payload.reference_image_src.as_deref(),
now,
)
.await
}
.map_err(|error| { .map_err(|error| {
puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error)
}); });
( (
"compile_puzzle_draft", "compile_puzzle_draft",
"首关拼图草稿", "首关拼图草稿",
"已编译首关草稿、生成首关画面并写入正式草稿。", if ai_redraw {
"已编译首关草稿、生成首关画面并写入正式草稿。"
} else {
"已编译首关草稿,并直接应用上传图片为第一关图片。"
},
session, session,
) )
} }
@@ -1980,6 +1998,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft
level_id: level.level_id, level_id: level.level_id,
level_name: level.level_name, level_name: level.level_name,
picture_description: level.picture_description, picture_description: level.picture_description,
picture_reference: level.picture_reference,
candidates: level candidates: level
.candidates .candidates
.into_iter() .into_iter()
@@ -2519,6 +2538,7 @@ fn parse_puzzle_level_records_from_module_json(
level_id: level.level_id, level_id: level.level_id,
level_name: level.level_name, level_name: level.level_name,
picture_description: level.picture_description, picture_description: level.picture_description,
picture_reference: level.picture_reference,
candidates: level candidates: level
.candidates .candidates
.into_iter() .into_iter()
@@ -2685,6 +2705,7 @@ fn serialize_puzzle_levels_response(
"level_id": level.level_id, "level_id": level.level_id,
"level_name": level.level_name, "level_name": level.level_name,
"picture_description": level.picture_description, "picture_description": level.picture_description,
"picture_reference": level.picture_reference,
"candidates": level "candidates": level
.candidates .candidates
.iter() .iter()
@@ -3076,6 +3097,163 @@ async fn compile_puzzle_draft_with_initial_cover(
} }
} }
async fn compile_puzzle_draft_with_uploaded_cover(
state: &AppState,
session_id: String,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
now: i64,
) -> Result<PuzzleAgentSessionRecord, AppError> {
let uploaded_image_src = reference_image_src
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时必须上传拼图图片。",
}))
})?;
let uploaded_image = parse_puzzle_image_data_url(uploaded_image_src).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"field": "referenceImageSrc",
"message": "关闭 AI 重绘时上传图必须是图片 Data URL。",
}))
})?;
let compiled_session = state
.spacetime_client()
.compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
.await
.map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": "拼图结果页草稿尚未生成",
}))
})?;
let mut target_level = select_puzzle_level_for_api(&draft, None)?;
let fallback_level_name = target_level.level_name.clone();
let generated_level_name =
generate_puzzle_first_level_name(state, &target_level.picture_description).await;
target_level.level_name = generated_level_name.clone();
let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module(
&build_puzzle_levels_with_primary_name(&draft, &target_level),
)?);
let image_prompt = resolve_puzzle_draft_cover_prompt(
prompt_text,
&target_level.picture_description,
&draft.summary,
);
// 中文注释:关闭 AI 重绘时不请求 APIMart也不进入光点扣费流程上传图直接成为首关正式图候选。
let candidate_id = format!(
"{}-candidate-{}",
compiled_session.session_id,
target_level.candidates.len() + 1
);
let persisted_upload = persist_puzzle_generated_asset(
state,
owner_user_id.as_str(),
&compiled_session.session_id,
&target_level.level_name,
candidate_id.as_str(),
"uploaded-direct",
PuzzleDownloadedImage {
extension: puzzle_mime_to_extension(uploaded_image.mime_type.as_str()).to_string(),
mime_type: normalize_puzzle_downloaded_image_mime_type(
uploaded_image.mime_type.as_str(),
),
bytes: uploaded_image.bytes,
},
current_utc_micros(),
)
.await?;
let candidate = PuzzleGeneratedImageCandidateRecord {
candidate_id: candidate_id.clone(),
image_src: persisted_upload.image_src,
asset_id: persisted_upload.asset_id,
prompt: image_prompt,
actual_prompt: None,
source_type: "uploaded".to_string(),
selected: true,
};
let candidates_json = serde_json::to_string(&vec![to_puzzle_generated_image_candidate(
&candidate,
)])
.map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"message": format!("拼图上传图候选序列化失败:{error}"),
}))
})?;
let (saved_session, save_used_fallback) = state
.spacetime_client()
.save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput {
session_id: compiled_session.session_id.clone(),
owner_user_id: owner_user_id.clone(),
level_id: Some(target_level.level_id.clone()),
levels_json: levels_json_with_generated_name,
candidates_json,
saved_at_micros: current_utc_micros(),
})
.await
.map_err(map_puzzle_client_error)
.map(|session| (session, false))
.or_else(|error| {
if is_spacetimedb_connectivity_app_error(&error) {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %compiled_session.session_id,
owner_user_id = %owner_user_id,
message = %error.body_text(),
"拼图上传图草稿回写不可用,降级返回本地快照"
);
let session = apply_generated_puzzle_candidates_to_session_snapshot(
apply_generated_puzzle_first_level_name_to_session_snapshot(
compiled_session.clone(),
target_level.level_id.as_str(),
generated_level_name.as_str(),
fallback_level_name.as_str(),
now,
),
target_level.level_id.as_str(),
vec![candidate.clone()],
now,
);
Ok((session, true))
} else {
Err(error)
}
})?;
if save_used_fallback {
return Ok(saved_session);
}
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
session_id,
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id,
selected_at_micros: current_utc_micros(),
})
.await
{
Ok(session) => Ok(session),
Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => {
tracing::warn!(
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
session_id = %saved_session.session_id,
error = %error,
"拼图上传图选定回写因 SpacetimeDB 连接不可用而降级使用已保存快照"
);
Ok(saved_session)
}
Err(error) => Err(map_puzzle_client_error(error)),
}
}
fn apply_generated_puzzle_candidates_to_session_snapshot( fn apply_generated_puzzle_candidates_to_session_snapshot(
mut session: PuzzleAgentSessionRecord, mut session: PuzzleAgentSessionRecord,
target_level_id: &str, target_level_id: &str,

View File

@@ -1,7 +1,9 @@
use std::{error::Error, fmt, sync::Arc}; use std::{
collections::HashMap,
#[cfg(test)] error::Error,
use std::{collections::HashMap, sync::Mutex}; fmt,
sync::{Arc, Mutex},
};
use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{ use module_auth::{
@@ -11,14 +13,16 @@ use module_auth::{
use module_runtime::RuntimeSnapshotRecord; use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)] #[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_agent::MockLangChainRustAgentExecutor;
use platform_auth::{ use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError, AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token, SmsAuthProviderKind, SmsProviderError, WechatProvider, sign_access_token, verify_access_token,
}; };
use platform_llm::{LlmClient, LlmConfig, LlmError}; use platform_llm::{LlmClient, LlmConfig, LlmError, LlmProvider};
use platform_oss::{OssClient, OssConfig, OssError}; use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value; use serde_json::Value;
use shared_contracts::creative_agent::CreativeAgentSessionSnapshot;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -51,11 +55,22 @@ pub struct AppState {
ai_task_service: AiTaskService, ai_task_service: AiTaskService,
spacetime_client: SpacetimeClient, spacetime_client: SpacetimeClient,
llm_client: Option<LlmClient>, llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
creative_agent_sessions: Arc<Mutex<HashMap<String, CreativeAgentSessionRuntimeRecord>>>,
#[cfg(test)] #[cfg(test)]
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>, test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
} }
#[derive(Clone, Debug)]
struct CreativeAgentSessionRuntimeRecord {
owner_user_id: String,
snapshot: CreativeAgentSessionSnapshot,
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。 // 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AdminRuntime { pub struct AdminRuntime {
@@ -167,6 +182,7 @@ impl AppState {
procedure_timeout: config.spacetime_procedure_timeout, procedure_timeout: config.spacetime_procedure_timeout,
}); });
let llm_client = build_llm_client(&config)?; let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
Ok(Self { Ok(Self {
config, config,
@@ -185,6 +201,9 @@ impl AppState {
ai_task_service, ai_task_service,
spacetime_client, spacetime_client,
llm_client, llm_client,
creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
#[cfg(test)] #[cfg(test)]
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
}) })
@@ -336,6 +355,44 @@ impl AppState {
self.llm_client.as_ref() self.llm_client.as_ref()
} }
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
self.creative_agent_gpt5_client.as_ref()
}
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
}
pub fn get_creative_agent_session(
&self,
session_id: &str,
owner_user_id: &str,
) -> Option<CreativeAgentSessionSnapshot> {
self.creative_agent_sessions
.lock()
.expect("creative agent session store should lock")
.get(session_id)
.filter(|record| record.owner_user_id == owner_user_id)
.map(|record| record.snapshot.clone())
}
pub fn put_creative_agent_session(
&self,
owner_user_id: String,
session: CreativeAgentSessionSnapshot,
) {
self.creative_agent_sessions
.lock()
.expect("creative agent session store should lock")
.insert(
session.session_id.clone(),
CreativeAgentSessionRuntimeRecord {
owner_user_id,
snapshot: session,
},
);
}
pub async fn get_runtime_snapshot_record( pub async fn get_runtime_snapshot_record(
&self, &self,
user_id: String, user_id: String,
@@ -710,6 +767,31 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
Ok(Some(LlmClient::new(llm_config)?)) Ok(Some(LlmClient::new(llm_config)?))
} }
fn build_creative_agent_gpt5_client(
config: &AppConfig,
) -> Result<Option<LlmClient>, AppStateInitError> {
let Some(api_key) = config
.apimart_api_key
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let llm_config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
config.apimart_base_url.clone(),
api_key.to_string(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL.to_string(),
config.apimart_image_request_timeout_ms,
0,
config.llm_retry_backoff_ms,
)?;
Ok(Some(LlmClient::new(llm_config)?))
}
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。 // 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
fn build_admin_runtime( fn build_admin_runtime(
config: &AppConfig, config: &AppConfig,
@@ -783,5 +865,28 @@ mod tests {
let state = AppState::new(AppConfig::default()).expect("state should build"); let state = AppState::new(AppConfig::default()).expect("state should build");
assert!(state.llm_client().is_none()); assert!(state.llm_client().is_none());
assert!(state.creative_agent_gpt5_client().is_none());
}
#[test]
fn app_state_builds_creative_agent_gpt5_client_from_apimart_settings() {
let mut config = AppConfig::default();
config.llm_api_key = None;
config.apimart_base_url = "https://api.apimart.test/v1".to_string();
config.apimart_api_key = Some("apimart-key".to_string());
let state = AppState::new(config).expect("state should build");
let client = state
.creative_agent_gpt5_client()
.expect("creative agent gpt5 client should exist");
assert_eq!(
client.config().model(),
platform_agent::CREATIVE_AGENT_GPT5_MODEL
);
assert_eq!(
client.config().responses_url(),
"https://api.apimart.test/v1/responses"
);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
[package]
name = "module-creative-agent"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -0,0 +1,225 @@
use shared_kernel::{normalize_optional_string, normalize_required_string};
use crate::{
CreativeAgentError, CreativeAgentMessageAppendInput, CreativeAgentMessageKind,
CreativeAgentMessageRole, CreativeAgentStage, CreativeAgentStageUpdateInput,
CreativeAgentTargetBindInput, CreativeAgentTemplateConfirmInput, CreativeTargetPlayType,
};
pub fn validate_create_session(
session_id: &str,
owner_user_id: &str,
) -> Result<(String, String), CreativeAgentError> {
let session_id =
normalize_required_string(session_id).ok_or(CreativeAgentError::MissingSessionId)?;
let owner_user_id =
normalize_required_string(owner_user_id).ok_or(CreativeAgentError::MissingOwnerUserId)?;
Ok((session_id, owner_user_id))
}
pub fn validate_append_message(
input: &CreativeAgentMessageAppendInput,
) -> Result<(), CreativeAgentError> {
validate_create_session(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.message_id).is_none() {
return Err(CreativeAgentError::MissingMessageId);
}
if normalize_required_string(&input.text).is_none() {
return Err(CreativeAgentError::MissingMessageText);
}
Ok(())
}
pub fn validate_stage_update(
current: CreativeAgentStage,
input: &CreativeAgentStageUpdateInput,
) -> Result<(), CreativeAgentError> {
validate_create_session(&input.session_id, &input.owner_user_id)?;
validate_stage_transition(current, input.stage)
}
pub fn validate_template_confirmation(
current: CreativeAgentStage,
input: &CreativeAgentTemplateConfirmInput,
) -> Result<(), CreativeAgentError> {
validate_create_session(&input.session_id, &input.owner_user_id)?;
if normalize_required_string(&input.template_selection_json).is_none() {
return Err(CreativeAgentError::MissingTemplateSelection);
}
if !input.template_selection_json.contains("\"costRange\"")
&& !input.template_selection_json.contains("\"cost_range\"")
{
return Err(CreativeAgentError::MissingCostRange);
}
validate_stage_transition(current, CreativeAgentStage::PlanningPuzzleLevels)
}
pub fn validate_target_binding(
current_stage: CreativeAgentStage,
template_selection_json: Option<&str>,
input: &CreativeAgentTargetBindInput,
) -> Result<(), CreativeAgentError> {
validate_create_session(&input.session_id, &input.owner_user_id)?;
if input.play_type != CreativeTargetPlayType::Puzzle {
return Err(CreativeAgentError::UnsupportedTargetPlayType);
}
if normalize_required_string(&input.target_session_id).is_none() {
return Err(CreativeAgentError::MissingTargetSessionId);
}
if normalize_optional_string(template_selection_json.map(str::to_string)).is_none() {
return Err(CreativeAgentError::TemplateNotConfirmed);
}
// 中文注释:绑定目标 session 是“草稿已创建”的持久化标记,只允许在行动链路之后发生。
if !matches!(
current_stage,
CreativeAgentStage::PlanningPuzzleLevels
| CreativeAgentStage::Acting
| CreativeAgentStage::Reflecting
| CreativeAgentStage::Collaborating
| CreativeAgentStage::TargetReady
) {
return Err(CreativeAgentError::InvalidStageTransition);
}
Ok(())
}
pub fn validate_stage_transition(
current: CreativeAgentStage,
next: CreativeAgentStage,
) -> Result<(), CreativeAgentError> {
if current == next {
return Ok(());
}
if matches!(
next,
CreativeAgentStage::Failed | CreativeAgentStage::WaitingUser
) {
return Ok(());
}
let allowed = matches!(
(current, next),
(CreativeAgentStage::Idle, CreativeAgentStage::Perceiving)
| (
CreativeAgentStage::Idle,
CreativeAgentStage::SelectingPuzzleTemplate
)
| (CreativeAgentStage::Perceiving, CreativeAgentStage::Thinking)
| (
CreativeAgentStage::Thinking,
CreativeAgentStage::Remembering
)
| (
CreativeAgentStage::Thinking,
CreativeAgentStage::SelectingPuzzleTemplate
)
| (
CreativeAgentStage::Remembering,
CreativeAgentStage::SelectingPuzzleTemplate
)
| (
CreativeAgentStage::SelectingPuzzleTemplate,
CreativeAgentStage::WaitingTemplateConfirmation
)
| (
CreativeAgentStage::WaitingTemplateConfirmation,
CreativeAgentStage::PlanningPuzzleLevels
)
| (
CreativeAgentStage::PlanningPuzzleLevels,
CreativeAgentStage::Acting
)
| (CreativeAgentStage::Acting, CreativeAgentStage::Reflecting)
| (
CreativeAgentStage::Reflecting,
CreativeAgentStage::Collaborating
)
| (
CreativeAgentStage::Collaborating,
CreativeAgentStage::Acting
)
| (
CreativeAgentStage::Reflecting,
CreativeAgentStage::TargetReady
)
| (CreativeAgentStage::Acting, CreativeAgentStage::TargetReady)
| (
CreativeAgentStage::PlanningPuzzleLevels,
CreativeAgentStage::TargetReady
)
);
if allowed {
Ok(())
} else {
Err(CreativeAgentError::InvalidStageTransition)
}
}
pub fn normalize_message_role(value: CreativeAgentMessageRole) -> &'static str {
value.as_str()
}
pub fn normalize_message_kind(value: CreativeAgentMessageKind) -> &'static str {
value.as_str()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CreativeAgentTargetBindInput, CreativeTargetStage};
#[test]
fn template_confirmation_requires_cost_range() {
let input = CreativeAgentTemplateConfirmInput {
session_id: "creative-session-1".to_string(),
owner_user_id: "user-1".to_string(),
template_selection_json: r#"{"templateId":"puzzle.default-creative"}"#.to_string(),
updated_at_micros: 1,
};
assert_eq!(
validate_template_confirmation(CreativeAgentStage::WaitingTemplateConfirmation, &input,),
Err(CreativeAgentError::MissingCostRange)
);
}
#[test]
fn target_binding_requires_confirmed_template() {
let input = CreativeAgentTargetBindInput {
binding_id: "creative-binding-1".to_string(),
session_id: "creative-session-1".to_string(),
owner_user_id: "user-1".to_string(),
play_type: CreativeTargetPlayType::Puzzle,
target_session_id: "puzzle-session-1".to_string(),
target_stage: CreativeTargetStage::PuzzleResult,
result_profile_id: None,
created_at_micros: 1,
};
assert_eq!(
validate_target_binding(CreativeAgentStage::Acting, None, &input),
Err(CreativeAgentError::TemplateNotConfirmed)
);
}
#[test]
fn phase1_stage_path_allows_template_to_target_ready() {
assert!(
validate_stage_transition(
CreativeAgentStage::WaitingTemplateConfirmation,
CreativeAgentStage::PlanningPuzzleLevels,
)
.is_ok()
);
assert!(
validate_stage_transition(
CreativeAgentStage::PlanningPuzzleLevels,
CreativeAgentStage::Acting
)
.is_ok()
);
assert!(
validate_stage_transition(CreativeAgentStage::Acting, CreativeAgentStage::TargetReady)
.is_ok()
);
}
}

View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use crate::{
CreativeAgentMessageKind, CreativeAgentMessageRole, CreativeAgentStage,
CreativeInputSummarySnapshot, CreativeTargetPlayType, CreativeTargetStage,
};
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentSessionCreateInput {
pub session_id: String,
pub owner_user_id: String,
pub input_summary: CreativeInputSummarySnapshot,
pub welcome_message_id: Option<String>,
pub welcome_message_text: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentSessionGetInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentMessageAppendInput {
pub session_id: String,
pub owner_user_id: String,
pub message_id: String,
pub role: CreativeAgentMessageRole,
pub kind: CreativeAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentStageUpdateInput {
pub session_id: String,
pub owner_user_id: String,
pub stage: CreativeAgentStage,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentTemplateConfirmInput {
pub session_id: String,
pub owner_user_id: String,
pub template_selection_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentLevelPlanSaveInput {
pub session_id: String,
pub owner_user_id: String,
pub level_plan_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentTargetBindInput {
pub binding_id: String,
pub session_id: String,
pub owner_user_id: String,
pub play_type: CreativeTargetPlayType,
pub target_session_id: String,
pub target_stage: CreativeTargetStage,
pub result_profile_id: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub stage: CreativeAgentStage,
pub assistant_message_id: Option<String>,
pub assistant_message_text: Option<String>,
pub updated_at_micros: i64,
}

View File

@@ -0,0 +1,187 @@
//! 创意互动 Agent 领域模型。
//!
//! 本 crate 只描述会话、阶段、消息和目标绑定的纯领域事实LLM、SSE、
//! 图片生成和 SpacetimeDB 写表均留在外层 adapter。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
pub const CREATIVE_AGENT_SESSION_ID_PREFIX: &str = "creative-session-";
pub const CREATIVE_AGENT_MESSAGE_ID_PREFIX: &str = "creative-message-";
pub const CREATIVE_AGENT_BINDING_ID_PREFIX: &str = "creative-binding-";
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreativeAgentStage {
Idle,
Perceiving,
Thinking,
Remembering,
SelectingPuzzleTemplate,
WaitingTemplateConfirmation,
PlanningPuzzleLevels,
Acting,
Reflecting,
Collaborating,
TargetReady,
WaitingUser,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreativeAgentMessageRole {
User,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreativeAgentMessageKind {
Chat,
Stage,
ActionResult,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreativeTargetPlayType {
Puzzle,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CreativeTargetStage {
PuzzleAgentWorkspace,
PuzzleResult,
PuzzleRuntime,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeInputImageSnapshot {
pub asset_id: Option<String>,
pub read_url: Option<String>,
pub thumbnail_url: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
pub summary: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeInputSummarySnapshot {
pub text: Option<String>,
pub entry_context: String,
pub images: Vec<CreativeInputImageSnapshot>,
pub material_summary: Option<String>,
pub unsupported_capabilities_json: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentMessageSnapshot {
pub message_id: String,
pub session_id: String,
pub role: CreativeAgentMessageRole,
pub kind: CreativeAgentMessageKind,
pub text: String,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentTargetBindingSnapshot {
pub binding_id: String,
pub session_id: String,
pub play_type: CreativeTargetPlayType,
pub target_session_id: String,
pub target_stage: CreativeTargetStage,
pub result_profile_id: Option<String>,
pub created_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentSessionSnapshot {
pub session_id: String,
pub owner_user_id: String,
pub stage: CreativeAgentStage,
pub input_summary: CreativeInputSummarySnapshot,
pub messages: Vec<CreativeAgentMessageSnapshot>,
pub puzzle_template_selection_json: Option<String>,
pub puzzle_image_generation_plan_json: Option<String>,
pub target_binding: Option<CreativeAgentTargetBindingSnapshot>,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreativeAgentSessionProcedureResult {
pub ok: bool,
pub session_json: Option<String>,
pub error_message: Option<String>,
}
impl CreativeAgentStage {
pub fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Perceiving => "perceiving",
Self::Thinking => "thinking",
Self::Remembering => "remembering",
Self::SelectingPuzzleTemplate => "selecting_puzzle_template",
Self::WaitingTemplateConfirmation => "waiting_template_confirmation",
Self::PlanningPuzzleLevels => "planning_puzzle_levels",
Self::Acting => "acting",
Self::Reflecting => "reflecting",
Self::Collaborating => "collaborating",
Self::TargetReady => "target_ready",
Self::WaitingUser => "waiting_user",
Self::Failed => "failed",
}
}
}
impl CreativeAgentMessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Assistant => "assistant",
Self::System => "system",
}
}
}
impl CreativeAgentMessageKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Stage => "stage",
Self::ActionResult => "action_result",
Self::Warning => "warning",
}
}
}
impl CreativeTargetPlayType {
pub fn as_str(self) -> &'static str {
match self {
Self::Puzzle => "puzzle",
}
}
}
impl CreativeTargetStage {
pub fn as_str(self) -> &'static str {
match self {
Self::PuzzleAgentWorkspace => "puzzle-agent-workspace",
Self::PuzzleResult => "puzzle-result",
Self::PuzzleRuntime => "puzzle-runtime",
}
}
}

View File

@@ -0,0 +1,35 @@
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CreativeAgentError {
MissingSessionId,
MissingOwnerUserId,
MissingMessageId,
MissingMessageText,
MissingTemplateSelection,
MissingCostRange,
MissingTargetSessionId,
InvalidStageTransition,
TemplateNotConfirmed,
UnsupportedTargetPlayType,
}
impl fmt::Display for CreativeAgentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingSessionId => "creative session_id 缺失",
Self::MissingOwnerUserId => "creative owner_user_id 缺失",
Self::MissingMessageId => "creative message_id 缺失",
Self::MissingMessageText => "creative message text 缺失",
Self::MissingTemplateSelection => "拼图模板选择缺失",
Self::MissingCostRange => "拼图模板积分范围缺失",
Self::MissingTargetSessionId => "目标拼图 session 缺失",
Self::InvalidStageTransition => "创意 Agent 阶段迁移不合法",
Self::TemplateNotConfirmed => "拼图模板未确认,不能创建草稿",
Self::UnsupportedTargetPlayType => "Phase 1 只允许绑定拼图 target",
};
write!(f, "{message}")
}
}
impl Error for CreativeAgentError {}

View File

@@ -0,0 +1,9 @@
mod application;
mod commands;
mod domain;
mod errors;
pub use application::*;
pub use commands::*;
pub use domain::*;
pub use errors::*;

View File

@@ -5,10 +5,10 @@ use crate::{
MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY, MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason,
Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot,
Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot,
Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
}; };
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -358,10 +358,7 @@ fn resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
(exact_count.floor() as usize, exact_count.fract(), *rule) (exact_count.floor() as usize, exact_count.fract(), *rule)
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut assigned_count = plans let mut assigned_count = plans.iter().map(|(count, _, _)| *count).sum::<usize>();
.iter()
.map(|(count, _, _)| *count)
.sum::<usize>();
let mut remainder_order = (0..plans.len()).collect::<Vec<_>>(); let mut remainder_order = (0..plans.len()).collect::<Vec<_>>();
remainder_order.sort_by(|left, right| { remainder_order.sort_by(|left, right| {
plans[*right] plans[*right]
@@ -802,9 +799,11 @@ mod tests {
} }
assert_eq!(radii_by_visual_key.len(), 25); assert_eq!(radii_by_visual_key.len(), 25);
assert!(radii_by_visual_key.values().all(|radii| { assert!(
radii.iter().all(|radius| radius == &radii[0]) radii_by_visual_key
})); .values()
.all(|radii| { radii.iter().all(|radius| radius == &radii[0]) })
);
} }
#[test] #[test]
@@ -824,7 +823,11 @@ mod tests {
.iter() .iter()
.map(|item| item.visual_key.as_str()) .map(|item| item.visual_key.as_str())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-"))); assert!(
visual_keys
.iter()
.all(|visual_key| visual_key.starts_with("block-"))
);
for item in &run.items { for item in &run.items {
let dx = item.x - MATCH3D_BOARD_CENTER; let dx = item.x - MATCH3D_BOARD_CENTER;

View File

@@ -10,5 +10,6 @@ spacetime-types = ["dep:spacetimedb"]
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true } shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true } spacetimedb = { workspace = true, optional = true }

View File

@@ -185,6 +185,7 @@ pub fn compile_result_draft_from_seed(
level_id: "puzzle-level-1".to_string(), level_id: "puzzle-level-1".to_string(),
level_name: level_name.clone(), level_name: level_name.clone(),
picture_description, picture_description,
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: None, cover_image_src: None,
@@ -240,6 +241,7 @@ pub fn build_form_draft_from_parts(
level_id: "puzzle-level-1".to_string(), level_id: "puzzle-level-1".to_string(),
level_name: String::new(), level_name: String::new(),
picture_description: picture_description.clone().unwrap_or_default(), picture_description: picture_description.clone().unwrap_or_default(),
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: None, cover_image_src: None,
@@ -344,6 +346,7 @@ pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft
&draft.anchor_pack.visual_subject.value, &draft.anchor_pack.visual_subject.value,
&draft.summary, &draft.summary,
), ),
picture_reference: None,
candidates: draft.candidates.clone(), candidates: draft.candidates.clone(),
selected_candidate_id: draft.selected_candidate_id.clone(), selected_candidate_id: draft.selected_candidate_id.clone(),
cover_image_src: draft.cover_image_src.clone(), cover_image_src: draft.cover_image_src.clone(),
@@ -429,6 +432,7 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft
next_index, next_index,
), ),
picture_description, picture_description,
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: None, cover_image_src: None,
@@ -671,10 +675,12 @@ pub fn normalize_puzzle_levels(
.unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); .unwrap_or_else(|| format!("puzzle-level-{}", index + 1));
let picture_description = normalize_required_string(&level.picture_description) let picture_description = normalize_required_string(&level.picture_description)
.unwrap_or_else(|| format!("{}关画面", index + 1)); .unwrap_or_else(|| format!("{}关画面", index + 1));
let picture_reference = level.picture_reference.and_then(normalize_required_string);
let level_name = normalize_required_string(&level.level_name).unwrap_or_default(); let level_name = normalize_required_string(&level.level_name).unwrap_or_default();
level.level_id = level_id; level.level_id = level_id;
level.level_name = level_name; level.level_name = level_name;
level.picture_description = picture_description; level.picture_description = picture_description;
level.picture_reference = picture_reference;
level.generation_status = normalize_required_string(&level.generation_status) level.generation_status = normalize_required_string(&level.generation_status)
.unwrap_or_else(|| "idle".to_string()); .unwrap_or_else(|| "idle".to_string());
normalized_levels.push(level); normalized_levels.push(level);
@@ -2791,6 +2797,7 @@ mod tests {
level_id: "puzzle-level-1".to_string(), level_id: "puzzle-level-1".to_string(),
level_name: format!("{profile_id} 关"), level_name: format!("{profile_id} 关"),
picture_description: "summary".to_string(), picture_description: "summary".to_string(),
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: Some("/cover.png".to_string()), cover_image_src: Some("/cover.png".to_string()),
@@ -3004,6 +3011,7 @@ mod tests {
level_id: "puzzle-level-1".to_string(), level_id: "puzzle-level-1".to_string(),
level_name: "第一关".to_string(), level_name: "第一关".to_string(),
picture_description: "第一关画面".to_string(), picture_description: "第一关画面".to_string(),
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: Some("/level-1.png".to_string()), cover_image_src: Some("/level-1.png".to_string()),
@@ -3014,6 +3022,7 @@ mod tests {
level_id: "puzzle-level-2".to_string(), level_id: "puzzle-level-2".to_string(),
level_name: "第二关".to_string(), level_name: "第二关".to_string(),
picture_description: "第二关画面".to_string(), picture_description: "第二关画面".to_string(),
picture_reference: None,
candidates: Vec::new(), candidates: Vec::new(),
selected_candidate_id: None, selected_candidate_id: None,
cover_image_src: Some("/level-2.png".to_string()), cover_image_src: Some("/level-2.png".to_string()),

View File

@@ -0,0 +1,208 @@
//! 拼图创意 Agent 模板协议。
//!
//! 这里只保存拼图模块自己的模板事实HTTP / SSE 展示字段由 api-server
//! 再映射到 shared-contracts避免通用 Agent 复制拼图模板规则。
use serde::{Deserialize, Serialize};
pub const PUZZLE_PHASE1_TEMPLATE_ID: &str = "puzzle.default-creative";
pub const PUZZLE_PHASE1_TEMPLATE_TITLE: &str = "创意拼图";
pub const PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID: &str = "puzzle.family-keepsake";
pub const PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID: &str = "puzzle.travel-memory";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PuzzleCreativePricingUnit {
Point,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleCreativeSupportedLevelMode {
Single,
Multi,
SingleOrMulti,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleCreativeLevelGenerationMode {
SingleLevel,
MultiLevel,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeCostRange {
pub min_points: u32,
pub max_points: u32,
pub pricing_unit: PuzzleCreativePricingUnit,
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum PuzzleCreativeDraftEditableFieldPath {
#[serde(rename = "workTitle")]
WorkTitle,
#[serde(rename = "workDescription")]
WorkDescription,
#[serde(rename = "workTags")]
WorkTags,
#[serde(rename = "levels[].levelName")]
LevelName,
#[serde(rename = "levels[].pictureDescription")]
LevelPictureDescription,
#[serde(rename = "levels[].pictureReference")]
LevelPictureReference,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeImageGenerationPolicy {
pub allow_uploaded_image_directly: bool,
pub allow_generated_images: bool,
pub allow_per_level_reference_image: bool,
pub default_candidate_count_per_level: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeTemplateProtocol {
pub template_id: String,
pub title: String,
pub summary: String,
pub preview_image_src: Option<String>,
pub supported_level_mode: PuzzleCreativeSupportedLevelMode,
pub min_level_count: u32,
pub max_level_count: u32,
pub default_level_count: u32,
pub cost_range: PuzzleCreativeCostRange,
pub required_draft_fields: Vec<PuzzleCreativeDraftEditableFieldPath>,
pub image_policy: PuzzleCreativeImageGenerationPolicy,
}
fn shared_required_draft_fields() -> Vec<PuzzleCreativeDraftEditableFieldPath> {
vec![
PuzzleCreativeDraftEditableFieldPath::WorkTitle,
PuzzleCreativeDraftEditableFieldPath::WorkDescription,
PuzzleCreativeDraftEditableFieldPath::WorkTags,
PuzzleCreativeDraftEditableFieldPath::LevelName,
PuzzleCreativeDraftEditableFieldPath::LevelPictureDescription,
PuzzleCreativeDraftEditableFieldPath::LevelPictureReference,
]
}
fn shared_image_policy() -> PuzzleCreativeImageGenerationPolicy {
PuzzleCreativeImageGenerationPolicy {
allow_uploaded_image_directly: true,
allow_generated_images: true,
allow_per_level_reference_image: true,
default_candidate_count_per_level: 1,
}
}
fn build_template(
template_id: &str,
title: &str,
summary: &str,
default_level_count: u32,
min_points: u32,
max_points: u32,
reason: &str,
) -> PuzzleCreativeTemplateProtocol {
PuzzleCreativeTemplateProtocol {
template_id: template_id.to_string(),
title: title.to_string(),
summary: summary.to_string(),
preview_image_src: None,
supported_level_mode: PuzzleCreativeSupportedLevelMode::SingleOrMulti,
min_level_count: 1,
max_level_count: 6,
default_level_count,
cost_range: PuzzleCreativeCostRange {
min_points,
max_points,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: reason.to_string(),
},
required_draft_fields: shared_required_draft_fields(),
image_policy: shared_image_policy(),
}
}
pub fn retrieve_puzzle_template_catalog() -> Vec<PuzzleCreativeTemplateProtocol> {
vec![
build_template(
PUZZLE_PHASE1_TEMPLATE_ID,
PUZZLE_PHASE1_TEMPLATE_TITLE,
"把图文灵感整理成可编辑、可试玩的拼图草稿。",
1,
2,
12,
"按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准",
),
build_template(
PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID,
"家庭纪念拼图",
"把合影、节日或成长瞬间做成温暖的纪念拼图。",
3,
4,
14,
"按纪念主题多关卡和图片候选估算,实际扣费以后端任务结算为准",
),
build_template(
PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID,
"旅行记忆拼图",
"把一次出行拆成地点、风景和故事节点拼图。",
3,
4,
16,
"按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准",
),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn phase1_template_contains_cost_range_and_editable_fields() {
let template = retrieve_puzzle_template_catalog()
.into_iter()
.find(|template| template.template_id == PUZZLE_PHASE1_TEMPLATE_ID)
.expect("template should exist");
assert_eq!(template.template_id, PUZZLE_PHASE1_TEMPLATE_ID);
assert_eq!(template.cost_range.min_points, 2);
assert_eq!(template.cost_range.max_points, 12);
assert!(
template
.required_draft_fields
.contains(&PuzzleCreativeDraftEditableFieldPath::LevelPictureReference)
);
}
#[test]
fn catalog_exposes_multiple_phase1_puzzle_subtemplates() {
let catalog = retrieve_puzzle_template_catalog();
assert!(catalog.len() >= 3);
assert!(
catalog
.iter()
.any(|template| template.template_id == PUZZLE_FAMILY_KEEPSTAKE_TEMPLATE_ID)
);
assert!(
catalog
.iter()
.any(|template| template.template_id == PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID)
);
assert!(
catalog
.iter()
.all(|template| template.supported_level_mode
== PuzzleCreativeSupportedLevelMode::SingleOrMulti)
);
}
}

View File

@@ -0,0 +1,529 @@
//! 拼图创意 Agent 草稿工具。
//!
//! 通用 Agent 只能把模型输出交给这些工具;字段归一化、模板关卡数和可编辑
//! 字段白名单都收口在拼图模块,避免 api-server 复制草稿业务规则。
use serde::{Deserialize, Serialize};
use serde_json::Value;
use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{
application::{
build_form_anchor_pack, build_result_preview, normalize_puzzle_draft,
normalize_puzzle_levels, sync_primary_level_fields,
},
creative_templates::{
PuzzleCreativeCostRange, PuzzleCreativeDraftEditableFieldPath,
PuzzleCreativeLevelGenerationMode, PuzzleCreativeSupportedLevelMode,
PuzzleCreativeTemplateProtocol, retrieve_puzzle_template_catalog,
},
domain::{
PUZZLE_MAX_TAG_COUNT, PUZZLE_MIN_TAG_COUNT, PuzzleDraftLevel, PuzzleFormDraft,
PuzzleResultDraft,
},
errors::PuzzleFieldError,
};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreativePuzzleLevelDraftInput {
pub level_name: String,
pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreativePuzzleDraftToolInput {
pub template_id: String,
pub template_cost_range: PuzzleCreativeCostRange,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub levels: Vec<CreativePuzzleLevelDraftInput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleCreativeTemplateSelection {
pub template_id: String,
pub title: String,
pub reason: String,
pub cost_range: PuzzleCreativeCostRange,
pub supported_level_mode: PuzzleCreativeSupportedLevelMode,
pub selected_level_mode: PuzzleCreativeLevelGenerationMode,
pub planned_level_count: u32,
pub requires_user_confirmation: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleLevelImagePlanInput {
pub template_id: String,
pub selected_level_mode: PuzzleCreativeLevelGenerationMode,
pub levels: Vec<CreativePuzzleLevelDraftInput>,
pub cost_range: PuzzleCreativeCostRange,
#[serde(default)]
pub candidate_count_per_level: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleImageGenerationPlanLevel {
pub level_id: String,
pub level_name: String,
pub picture_description: String,
pub image_prompt: String,
#[serde(default)]
pub picture_reference: Option<String>,
pub candidate_count: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleImageGenerationPlan {
pub mode: PuzzleCreativeLevelGenerationMode,
pub template_id: String,
pub estimated_cost_range: PuzzleCreativeCostRange,
pub levels: Vec<PuzzleImageGenerationPlanLevel>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PuzzleDraftFieldPatchOperation {
Set,
Append,
Replace,
Remove,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PuzzleDraftFieldPatch {
pub field_path: PuzzleCreativeDraftEditableFieldPath,
pub operation: PuzzleDraftFieldPatchOperation,
#[serde(default)]
pub level_id: Option<String>,
pub value: Value,
pub rationale: String,
}
pub fn validate_puzzle_template_selection(
selection: &PuzzleCreativeTemplateSelection,
) -> Result<(), PuzzleFieldError> {
let template = resolve_phase1_template(&selection.template_id)?;
if selection.cost_range != template.cost_range
|| selection.supported_level_mode != template.supported_level_mode
|| !selection.requires_user_confirmation
{
return Err(PuzzleFieldError::InvalidOperation);
}
validate_level_count(
selection.planned_level_count,
&selection.selected_level_mode,
&template,
)
}
pub fn build_puzzle_draft_from_creative_fields(
input: CreativePuzzleDraftToolInput,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
let template = resolve_phase1_template(&input.template_id)?;
if input.template_cost_range != template.cost_range {
return Err(PuzzleFieldError::InvalidOperation);
}
validate_level_count(
input.levels.len() as u32,
&if input.levels.len() > 1 {
PuzzleCreativeLevelGenerationMode::MultiLevel
} else {
PuzzleCreativeLevelGenerationMode::SingleLevel
},
&template,
)?;
let work_title =
normalize_required_string(&input.work_title).ok_or(PuzzleFieldError::MissingText)?;
let work_description =
normalize_required_string(&input.work_description).ok_or(PuzzleFieldError::MissingText)?;
let tags = normalize_theme_tags_for_creative(input.work_tags)?;
let anchor_pack = build_form_anchor_pack(
work_title.as_str(),
input
.levels
.first()
.map(|level| level.picture_description.as_str())
.unwrap_or(work_description.as_str()),
);
let levels = input
.levels
.into_iter()
.enumerate()
.map(|(index, level)| {
let picture_description = normalize_required_string(&level.picture_description)
.ok_or(PuzzleFieldError::MissingText)?;
Ok(PuzzleDraftLevel {
level_id: format!("puzzle-level-{}", index + 1),
level_name: normalize_required_string(&level.level_name)
.unwrap_or_else(|| format!("{}", index + 1)),
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
})
})
.collect::<Result<Vec<_>, PuzzleFieldError>>()?;
let mut draft = PuzzleResultDraft {
work_title: work_title.clone(),
work_description: work_description.clone(),
level_name: levels
.first()
.map(|level| level.level_name.clone())
.unwrap_or_default(),
summary: work_description.clone(),
theme_tags: tags,
forbidden_directives: Vec::new(),
creator_intent: None,
anchor_pack,
candidates: Vec::new(),
selected_candidate_id: None,
cover_image_src: None,
cover_asset_id: None,
generation_status: "idle".to_string(),
levels,
form_draft: Some(PuzzleFormDraft {
work_title: Some(work_title),
work_description: Some(work_description),
picture_description: None,
}),
};
sync_primary_level_fields(&mut draft);
Ok(normalize_puzzle_draft(draft))
}
pub fn plan_puzzle_level_images(
input: PuzzleLevelImagePlanInput,
) -> Result<PuzzleImageGenerationPlan, PuzzleFieldError> {
let template = resolve_phase1_template(&input.template_id)?;
validate_level_count(
input.levels.len() as u32,
&input.selected_level_mode,
&template,
)?;
if input.cost_range != template.cost_range {
return Err(PuzzleFieldError::InvalidOperation);
}
let candidate_count = input.candidate_count_per_level.unwrap_or(1).clamp(1, 1);
let levels = input
.levels
.into_iter()
.enumerate()
.map(|(index, level)| {
let picture_description = normalize_required_string(&level.picture_description)
.ok_or(PuzzleFieldError::MissingText)?;
let level_name = normalize_required_string(&level.level_name)
.unwrap_or_else(|| format!("{}", index + 1));
Ok(PuzzleImageGenerationPlanLevel {
level_id: format!("puzzle-level-{}", index + 1),
image_prompt: build_level_image_prompt(&level_name, &picture_description),
level_name,
picture_description,
picture_reference: level.picture_reference.and_then(normalize_required_string),
candidate_count,
})
})
.collect::<Result<Vec<_>, PuzzleFieldError>>()?;
Ok(PuzzleImageGenerationPlan {
mode: input.selected_level_mode,
template_id: input.template_id,
estimated_cost_range: input.cost_range,
levels,
})
}
pub fn apply_puzzle_draft_field_patch(
draft: PuzzleResultDraft,
patch: PuzzleDraftFieldPatch,
) -> Result<PuzzleResultDraft, PuzzleFieldError> {
if patch.operation != PuzzleDraftFieldPatchOperation::Set
&& patch.operation != PuzzleDraftFieldPatchOperation::Replace
{
return Err(PuzzleFieldError::InvalidOperation);
}
let mut next_draft = normalize_puzzle_draft(draft);
match patch.field_path {
PuzzleCreativeDraftEditableFieldPath::WorkTitle => {
next_draft.work_title = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::WorkDescription => {
next_draft.work_description = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::WorkTags => {
next_draft.theme_tags =
normalize_theme_tags_for_creative(value_as_string_list(&patch.value)?)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelName => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.level_name = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelPictureDescription => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.picture_description = value_as_required_string(&patch.value)?;
}
PuzzleCreativeDraftEditableFieldPath::LevelPictureReference => {
let level = mutable_level_for_patch(&mut next_draft, patch.level_id.as_deref())?;
level.picture_reference = value_as_optional_string(&patch.value);
}
}
let levels = normalize_puzzle_levels(next_draft.levels.clone(), &next_draft.theme_tags)?;
next_draft.levels = levels;
sync_primary_level_fields(&mut next_draft);
let _ = build_result_preview(&next_draft, Some("百梦主"));
Ok(next_draft)
}
fn resolve_phase1_template(
template_id: &str,
) -> Result<PuzzleCreativeTemplateProtocol, PuzzleFieldError> {
let normalized_template_id =
normalize_required_string(template_id).ok_or(PuzzleFieldError::InvalidOperation)?;
retrieve_puzzle_template_catalog()
.into_iter()
.find(|template| template.template_id == normalized_template_id)
.ok_or(PuzzleFieldError::InvalidOperation)
}
fn validate_level_count(
count: u32,
mode: &PuzzleCreativeLevelGenerationMode,
template: &PuzzleCreativeTemplateProtocol,
) -> Result<(), PuzzleFieldError> {
if count < template.min_level_count || count > template.max_level_count {
return Err(PuzzleFieldError::InvalidOperation);
}
if matches!(mode, PuzzleCreativeLevelGenerationMode::SingleLevel) && count != 1 {
return Err(PuzzleFieldError::InvalidOperation);
}
if matches!(mode, PuzzleCreativeLevelGenerationMode::MultiLevel) && count < 2 {
return Err(PuzzleFieldError::InvalidOperation);
}
Ok(())
}
fn normalize_theme_tags_for_creative(values: Vec<String>) -> Result<Vec<String>, PuzzleFieldError> {
let mut tags = Vec::new();
for tag in normalize_string_list(values) {
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= PUZZLE_MAX_TAG_COUNT {
break;
}
}
if tags.len() < PUZZLE_MIN_TAG_COUNT {
return Err(PuzzleFieldError::InvalidTagCount);
}
Ok(tags)
}
fn build_level_image_prompt(level_name: &str, picture_description: &str) -> String {
format!("{level_name}{picture_description}。清晰主体,适合拼图切块。")
}
fn mutable_level_for_patch<'a>(
draft: &'a mut PuzzleResultDraft,
level_id: Option<&str>,
) -> Result<&'a mut PuzzleDraftLevel, PuzzleFieldError> {
if let Some(level_id) = level_id.and_then(normalize_required_string) {
return draft
.levels
.iter_mut()
.find(|level| level.level_id == level_id)
.ok_or(PuzzleFieldError::InvalidOperation);
}
draft
.levels
.first_mut()
.ok_or(PuzzleFieldError::InvalidOperation)
}
fn value_as_required_string(value: &Value) -> Result<String, PuzzleFieldError> {
value
.as_str()
.and_then(normalize_required_string)
.ok_or(PuzzleFieldError::MissingText)
}
fn value_as_optional_string(value: &Value) -> Option<String> {
value.as_str().and_then(normalize_required_string)
}
fn value_as_string_list(value: &Value) -> Result<Vec<String>, PuzzleFieldError> {
value
.as_array()
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(ToString::to_string))
.collect::<Vec<_>>()
})
.ok_or(PuzzleFieldError::InvalidOperation)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::creative_templates::{PUZZLE_PHASE1_TEMPLATE_ID, PuzzleCreativePricingUnit};
fn cost_range() -> PuzzleCreativeCostRange {
PuzzleCreativeCostRange {
min_points: 2,
max_points: 12,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(),
}
}
#[test]
fn creative_draft_builds_single_level_with_summary_and_plan() {
let input_level = CreativePuzzleLevelDraftInput {
level_name: "第一关".to_string(),
picture_description: "生日蛋糕和朋友合影。".to_string(),
picture_reference: Some("https://assets.example.test/birthday.png".to_string()),
};
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "生日拼图".to_string(),
work_description: "把生日照片做成一关拼图。".to_string(),
work_tags: vec!["生日".to_string(), "朋友".to_string(), "纪念".to_string()],
levels: vec![input_level.clone()],
})
.expect("single level draft should build");
let plan = plan_puzzle_level_images(PuzzleLevelImagePlanInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
selected_level_mode: PuzzleCreativeLevelGenerationMode::SingleLevel,
levels: vec![input_level],
cost_range: cost_range(),
candidate_count_per_level: Some(3),
})
.expect("single level image plan should build");
assert_eq!(draft.work_title, "生日拼图");
assert_eq!(draft.work_description, "把生日照片做成一关拼图。");
assert_eq!(draft.summary, "把生日照片做成一关拼图。");
assert_eq!(draft.level_name, "第一关");
assert_eq!(draft.levels.len(), 1);
assert_eq!(
draft.levels[0].picture_reference.as_deref(),
Some("https://assets.example.test/birthday.png")
);
assert_eq!(plan.mode, PuzzleCreativeLevelGenerationMode::SingleLevel);
assert_eq!(plan.levels.len(), 1);
assert_eq!(plan.levels[0].candidate_count, 1);
assert!(plan.levels[0].image_prompt.contains("生日蛋糕和朋友合影"));
}
#[test]
fn creative_draft_builds_multi_level_picture_references() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "旅行拼图".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![
CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: Some("asset-1".to_string()),
},
CreativePuzzleLevelDraftInput {
level_name: "第二站".to_string(),
picture_description: "山顶日落".to_string(),
picture_reference: Some("asset-2".to_string()),
},
],
})
.expect("draft should build");
assert_eq!(draft.work_title, "旅行拼图");
assert_eq!(draft.theme_tags, vec!["旅行", "照片", "纪念"]);
assert_eq!(draft.levels.len(), 2);
assert_eq!(
draft.levels[1].picture_reference.as_deref(),
Some("asset-2")
);
}
#[test]
fn creative_draft_accepts_catalog_subtemplate_id() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: crate::creative_templates::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID.to_string(),
template_cost_range: PuzzleCreativeCostRange {
min_points: 4,
max_points: 16,
pricing_unit: PuzzleCreativePricingUnit::Point,
reason: "按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准"
.to_string(),
},
work_title: "旅行记忆".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![
CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: Some("asset-1".to_string()),
},
CreativePuzzleLevelDraftInput {
level_name: "第二站".to_string(),
picture_description: "山顶日落".to_string(),
picture_reference: Some("asset-2".to_string()),
},
],
})
.expect("subtemplate draft should build");
assert_eq!(draft.work_title, "旅行记忆");
assert_eq!(draft.levels.len(), 2);
}
#[test]
fn draft_patch_rejects_non_whitelisted_operation() {
let draft = build_puzzle_draft_from_creative_fields(CreativePuzzleDraftToolInput {
template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(),
template_cost_range: cost_range(),
work_title: "旅行拼图".to_string(),
work_description: "把旅行照片做成系列拼图。".to_string(),
work_tags: vec!["旅行".to_string(), "照片".to_string(), "纪念".to_string()],
levels: vec![CreativePuzzleLevelDraftInput {
level_name: "第一站".to_string(),
picture_description: "海边合影".to_string(),
picture_reference: None,
}],
})
.expect("draft should build");
let error = apply_puzzle_draft_field_patch(
draft,
PuzzleDraftFieldPatch {
field_path: PuzzleCreativeDraftEditableFieldPath::WorkTitle,
operation: PuzzleDraftFieldPatchOperation::Remove,
level_id: None,
value: Value::Null,
rationale: "测试".to_string(),
},
)
.expect_err("remove should be rejected");
assert_eq!(error, PuzzleFieldError::InvalidOperation);
}
}

View File

@@ -129,6 +129,8 @@ pub struct PuzzleDraftLevel {
pub level_id: String, pub level_id: String,
pub level_name: String, pub level_name: String,
pub picture_description: String, pub picture_description: String,
#[serde(default)]
pub picture_reference: Option<String>,
pub candidates: Vec<PuzzleGeneratedImageCandidate>, pub candidates: Vec<PuzzleGeneratedImageCandidate>,
pub selected_candidate_id: Option<String>, pub selected_candidate_id: Option<String>,
pub cover_image_src: Option<String>, pub cover_image_src: Option<String>,

View File

@@ -1,11 +1,15 @@
mod application; mod application;
mod commands; mod commands;
mod creative_templates;
mod creative_tools;
mod domain; mod domain;
mod errors; mod errors;
mod events; mod events;
pub use application::*; pub use application::*;
pub use commands::*; pub use commands::*;
pub use creative_templates::*;
pub use creative_tools::*;
pub use domain::*; pub use domain::*;
pub use errors::*; pub use errors::*;
pub use events::*; pub use events::*;

View File

@@ -3,7 +3,12 @@ use module_runtime::{
aggregate_runtime_tracking_daily_stats, aggregate_runtime_tracking_daily_stats,
}; };
fn stat(event_key: &str, scope_id: &str, day_key: i64, count: u32) -> RuntimeAnalyticsDailyStatSnapshot { fn stat(
event_key: &str,
scope_id: &str,
day_key: i64,
count: u32,
) -> RuntimeAnalyticsDailyStatSnapshot {
RuntimeAnalyticsDailyStatSnapshot { RuntimeAnalyticsDailyStatSnapshot {
event_key: event_key.to_string(), event_key: event_key.to_string(),
scope_kind: RuntimeTrackingScopeKind::User, scope_kind: RuntimeTrackingScopeKind::User,
@@ -55,7 +60,17 @@ fn aggregates_daily_stats_by_month_quarter_and_year_bucket() {
"user-1", "user-1",
AnalyticsGranularity::Month, AnalyticsGranularity::Month,
); );
assert_eq!(month.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"202604".to_string(), 5), (&"202605".to_string(), 5), (&"202612".to_string(), 7)]); assert_eq!(
month
.iter()
.map(|bucket| (&bucket.bucket_key, bucket.value))
.collect::<Vec<_>>(),
vec![
(&"202604".to_string(), 5),
(&"202605".to_string(), 5),
(&"202612".to_string(), 7)
]
);
assert_eq!(month[0].bucket_start_date_key, 20_544); assert_eq!(month[0].bucket_start_date_key, 20_544);
assert_eq!(month[0].bucket_end_date_key, 20_573); assert_eq!(month[0].bucket_end_date_key, 20_573);
@@ -66,7 +81,13 @@ fn aggregates_daily_stats_by_month_quarter_and_year_bucket() {
"user-1", "user-1",
AnalyticsGranularity::Quarter, AnalyticsGranularity::Quarter,
); );
assert_eq!(quarter.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::<Vec<_>>(), vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]); assert_eq!(
quarter
.iter()
.map(|bucket| (&bucket.bucket_key, bucket.value))
.collect::<Vec<_>>(),
vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]
);
let year = aggregate_runtime_tracking_daily_stats( let year = aggregate_runtime_tracking_daily_stats(
stats, stats,

View File

@@ -0,0 +1,15 @@
[package]
name = "module-visual-novel"
edition.workspace = true
version.workspace = true
license.workspace = true
[features]
default = []
spacetime-types = ["dep:spacetimedb"]
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
shared-kernel = { workspace = true }
spacetimedb = { workspace = true, optional = true }

View File

@@ -0,0 +1,935 @@
use std::collections::{BTreeMap, BTreeSet};
use serde::Deserialize;
use shared_kernel::{normalize_required_string, normalize_string_list};
use crate::{
VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT, VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT,
VisualNovelCharacterRole, VisualNovelChoiceDraft, VisualNovelDomainError, VisualNovelFlagValue,
VisualNovelHistoryEntry, VisualNovelHistorySource, VisualNovelResultDraft,
VisualNovelRunSnapshot, VisualNovelRunStatus, VisualNovelRuntimeAction,
VisualNovelRuntimeActionKind, VisualNovelRuntimeStep, VisualNovelSaveArchiveState,
VisualNovelSceneAvailability, VisualNovelTransitionKind, VisualNovelValidationIssue,
VisualNovelValidationSeverity, VisualNovelWorkProfile,
};
pub fn validate_visual_novel_draft(
draft: &VisualNovelResultDraft,
) -> Vec<VisualNovelValidationIssue> {
let mut issues = Vec::new();
push_missing(
&mut issues,
"workTitle",
&draft.work_title,
"作品标题不能为空",
);
push_missing(
&mut issues,
"workDescription",
&draft.work_description,
"作品简介不能为空",
);
push_missing(
&mut issues,
"world.summary",
&draft.world.summary,
"世界观摘要不能为空",
);
push_missing(
&mut issues,
"world.playerRole",
&draft.world.player_role,
"玩家身份不能为空",
);
if !draft.characters.iter().any(|character| {
matches!(
character.role,
VisualNovelCharacterRole::Main | VisualNovelCharacterRole::Supporting
) && !character.is_player_visible
&& normalize_required_string(&character.name).is_some()
}) {
push_issue(
&mut issues,
"characters",
"MISSING_NON_PLAYER_MAIN_CHARACTER",
"至少需要 1 个非玩家主要角色",
);
}
for character in &draft.characters {
if normalize_required_string(&character.character_id).is_none() {
push_issue(
&mut issues,
"characters[].characterId",
"MISSING_CHARACTER_ID",
"角色 ID 不能为空",
);
}
if normalize_required_string(&character.name).is_none() {
push_issue(
&mut issues,
"characters[].name",
"MISSING_CHARACTER_NAME",
"角色名称不能为空",
);
}
for asset in &character.image_assets {
if normalize_required_string(&asset.asset_id).is_none()
|| normalize_required_string(&asset.image_src).is_none()
{
push_issue(
&mut issues,
"characters[].imageAssets",
"INVALID_CHARACTER_IMAGE_ASSET",
"角色立绘必须使用有效的平台资产引用或受控图片 URL",
);
}
}
}
let scene_ids = draft
.scenes
.iter()
.map(|scene| scene.scene_id.as_str())
.collect::<BTreeSet<_>>();
let phase_ids = draft
.story_phases
.iter()
.map(|phase| phase.phase_id.as_str())
.collect::<BTreeSet<_>>();
if !draft
.scenes
.iter()
.any(|scene| scene.availability == VisualNovelSceneAvailability::Opening)
{
push_issue(
&mut issues,
"scenes",
"MISSING_OPENING_SCENE",
"至少需要 1 个 opening 场景",
);
}
for scene in &draft.scenes {
if normalize_required_string(&scene.scene_id).is_none() {
push_issue(
&mut issues,
"scenes[].sceneId",
"MISSING_SCENE_ID",
"场景 ID 不能为空",
);
}
if normalize_required_string(&scene.name).is_none() {
push_issue(
&mut issues,
"scenes[].name",
"MISSING_SCENE_NAME",
"场景名称不能为空",
);
}
if scene.availability == VisualNovelSceneAvailability::PhaseLocked
&& scene.phase_ids.is_empty()
{
push_issue(
&mut issues,
"scenes[].phaseIds",
"MISSING_PHASE_LOCKED_SCENE_PHASE",
"阶段锁定场景必须绑定剧情阶段",
);
}
for phase_id in &scene.phase_ids {
if !phase_ids.contains(phase_id.as_str()) {
push_issue(
&mut issues,
"scenes[].phaseIds",
"UNKNOWN_SCENE_PHASE_ID",
"场景绑定了不存在的剧情阶段",
);
}
}
}
if draft.story_phases.is_empty() {
push_issue(
&mut issues,
"storyPhases",
"MISSING_STORY_PHASE",
"至少需要 1 个剧情阶段",
);
}
for phase in &draft.story_phases {
if normalize_required_string(&phase.phase_id).is_none() {
push_issue(
&mut issues,
"storyPhases[].phaseId",
"MISSING_PHASE_ID",
"剧情阶段 ID 不能为空",
);
}
if normalize_required_string(&phase.title).is_none() {
push_issue(
&mut issues,
"storyPhases[].title",
"MISSING_PHASE_TITLE",
"剧情阶段标题不能为空",
);
}
if phase.scene_ids.is_empty() && phase.character_ids.is_empty() {
push_issue(
&mut issues,
"storyPhases[]",
"PHASE_WITHOUT_SCENE_OR_CHARACTER",
"每个剧情阶段至少绑定一个场景或角色",
);
}
for scene_id in &phase.scene_ids {
if !scene_ids.contains(scene_id.as_str()) {
push_issue(
&mut issues,
"storyPhases[].sceneIds",
"UNKNOWN_PHASE_SCENE_ID",
"剧情阶段绑定了不存在的场景",
);
}
}
}
match draft.opening.scene_id.as_ref() {
Some(scene_id) if scene_ids.contains(scene_id.as_str()) => {}
_ => push_issue(
&mut issues,
"opening.sceneId",
"INVALID_OPENING_SCENE",
"开场场景必须指向有效场景",
),
}
push_missing(
&mut issues,
"opening.narration",
&draft.opening.narration,
"开场旁白不能为空",
);
let choice_count = draft.opening.initial_choices.len();
if !(VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT..=VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT)
.contains(&choice_count)
{
push_issue(
&mut issues,
"opening.initialChoices",
"INVALID_INITIAL_CHOICE_COUNT",
"初始选项必须为 2 到 4 个",
);
}
for choice in &draft.opening.initial_choices {
if normalize_required_string(&choice.choice_id).is_none()
|| normalize_required_string(&choice.text).is_none()
{
push_issue(
&mut issues,
"opening.initialChoices[]",
"INVALID_INITIAL_CHOICE",
"初始选项 ID 和文本不能为空",
);
}
}
issues
}
pub fn compile_visual_novel_profile(
draft: &VisualNovelResultDraft,
) -> Result<VisualNovelWorkProfile, VisualNovelDomainError> {
let profile_id = normalize_required_string(draft.profile_id.as_deref().unwrap_or(""))
.ok_or(VisualNovelDomainError::MissingProfileId)?;
let mut normalized_draft = draft.clone();
normalized_draft.work_tags = normalize_string_list(normalized_draft.work_tags);
normalized_draft.validation_issues = validate_visual_novel_draft(&normalized_draft);
normalized_draft.publish_ready = normalized_draft.validation_issues.is_empty();
Ok(VisualNovelWorkProfile {
profile_id,
work_title: normalized_draft.work_title.clone(),
work_description: normalized_draft.work_description.clone(),
work_tags: normalized_draft.work_tags.clone(),
cover_image_src: normalized_draft.cover_image_src.clone(),
source_mode: normalized_draft.source_mode,
draft: normalized_draft,
})
}
pub fn parse_runtime_steps(
model_output: &str,
) -> Result<Vec<VisualNovelRuntimeStep>, VisualNovelDomainError> {
let text =
normalize_required_string(model_output).ok_or(VisualNovelDomainError::InvalidJson)?;
let steps = serde_json::from_str::<Vec<RuntimeStepInput>>(&text)
.or_else(|_| serde_json::from_str::<RuntimeStepsEnvelope>(&text).map(|value| value.steps))
.map_err(|_| VisualNovelDomainError::InvalidJson)?;
let parsed = steps
.into_iter()
.map(RuntimeStepInput::try_into)
.collect::<Result<Vec<_>, _>>()?;
if parsed.is_empty() {
return Err(VisualNovelDomainError::EmptyRuntimeSteps);
}
Ok(parsed)
}
pub fn apply_runtime_steps(
snapshot: &VisualNovelRunSnapshot,
steps: &[VisualNovelRuntimeStep],
history_entry_id: &str,
created_at: &str,
) -> Result<VisualNovelRunSnapshot, VisualNovelDomainError> {
if steps.is_empty() {
return Err(VisualNovelDomainError::EmptyRuntimeSteps);
}
let history_entry_id = normalize_required_string(history_entry_id)
.ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
let created_at =
normalize_required_string(created_at).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
let mut next = snapshot.clone();
let max_history_entries = snapshot.history.len().saturating_add(1);
for step in steps {
apply_step_to_snapshot(&mut next, step)?;
}
let turn_index = next
.history
.last()
.map(|entry| entry.turn_index.saturating_add(1))
.unwrap_or(0);
next.history.push(VisualNovelHistoryEntry {
entry_id: history_entry_id,
run_id: next.run_id.clone(),
turn_index,
source: VisualNovelHistorySource::Assistant,
action_text: None,
steps: steps.to_vec(),
snapshot_before_hash: snapshot.history.last().and_then(|entry| {
entry
.snapshot_after_hash
.clone()
.or_else(|| Some(format!("turn-{}", entry.turn_index)))
}),
snapshot_after_hash: Some(format!("turn-{turn_index}")),
created_at: created_at.clone(),
});
if next.history.len() > max_history_entries {
next.history.remove(0);
}
next.updated_at = created_at;
Ok(next)
}
pub fn build_runtime_prompt_context(
profile: &VisualNovelWorkProfile,
snapshot: &VisualNovelRunSnapshot,
action: &VisualNovelRuntimeAction,
) -> Result<String, VisualNovelDomainError> {
validate_runtime_action(snapshot, action, &profile.draft.runtime_config)?;
let action_text = resolve_action_text(snapshot, action)?;
let scene_name = snapshot
.current_scene_id
.as_ref()
.and_then(|scene_id| {
profile
.draft
.scenes
.iter()
.find(|scene| scene.scene_id == *scene_id)
})
.map(|scene| scene.name.as_str())
.unwrap_or("未指定场景");
Ok(format!(
"作品:{}\n世界观:{}\n玩家身份:{}\n当前场景:{}\n玩家行动:{}",
profile.work_title,
profile.draft.world.summary,
profile.draft.world.player_role,
scene_name,
action_text,
))
}
pub fn regenerate_from_history(
snapshot: &VisualNovelRunSnapshot,
history_entry_id: &str,
allow_history_regeneration: bool,
updated_at: &str,
) -> Result<VisualNovelRunSnapshot, VisualNovelDomainError> {
if !allow_history_regeneration {
return Err(VisualNovelDomainError::HistoryRegenerationDisabled);
}
let history_entry_id = normalize_required_string(history_entry_id)
.ok_or(VisualNovelDomainError::HistoryEntryNotFound)?;
let target_index = snapshot
.history
.iter()
.position(|entry| entry.entry_id == history_entry_id)
.ok_or(VisualNovelDomainError::HistoryEntryNotFound)?;
if snapshot.history[target_index].source != VisualNovelHistorySource::Assistant {
return Err(VisualNovelDomainError::InvalidHistorySource);
}
let mut next = snapshot.clone();
next.history.truncate(target_index);
next.current_scene_id = None;
next.visible_character_ids.clear();
next.available_choices.clear();
next.flags.clear();
next.metrics.clear();
let steps_to_restore = next
.history
.iter()
.flat_map(|entry| entry.steps.clone())
.collect::<Vec<_>>();
for step in steps_to_restore {
apply_step_to_snapshot(&mut next, &step)?;
}
next.updated_at = updated_at.to_string();
Ok(next)
}
pub fn build_save_archive_state(snapshot: &VisualNovelRunSnapshot) -> VisualNovelSaveArchiveState {
VisualNovelSaveArchiveState {
runtime_kind: "visual-novel".to_string(),
profile_id: snapshot.profile_id.clone(),
run_id: snapshot.run_id.clone(),
current_scene_id: snapshot.current_scene_id.clone(),
current_phase_id: snapshot.current_phase_id.clone(),
history_cursor: snapshot
.history
.last()
.map(|entry| entry.turn_index)
.unwrap_or(0),
snapshot_hash: snapshot
.history
.last()
.and_then(|entry| entry.snapshot_after_hash.clone()),
}
}
pub fn validate_runtime_action(
snapshot: &VisualNovelRunSnapshot,
action: &VisualNovelRuntimeAction,
config: &crate::VisualNovelRuntimeConfigDraft,
) -> Result<(), VisualNovelDomainError> {
if normalize_required_string(&action.client_event_id).is_none() {
return Err(VisualNovelDomainError::MissingClientEventId);
}
match action.action_kind {
VisualNovelRuntimeActionKind::Choice => {
let choice_id = action
.choice_id
.as_ref()
.and_then(normalize_required_string)
.ok_or(VisualNovelDomainError::InvalidChoiceId)?;
if snapshot
.available_choices
.iter()
.any(|choice| choice.choice_id == choice_id)
{
Ok(())
} else {
Err(VisualNovelDomainError::InvalidChoiceId)
}
}
VisualNovelRuntimeActionKind::FreeText => {
if !config.allow_free_text_action {
return Err(VisualNovelDomainError::FreeTextDisabled);
}
action
.text
.as_ref()
.and_then(normalize_required_string)
.map(|_| ())
.ok_or(VisualNovelDomainError::MissingActionText)
}
VisualNovelRuntimeActionKind::Continue => Ok(()),
}
}
fn apply_step_to_snapshot(
snapshot: &mut VisualNovelRunSnapshot,
step: &VisualNovelRuntimeStep,
) -> Result<(), VisualNovelDomainError> {
match step {
VisualNovelRuntimeStep::SceneChange { scene_id, .. } => {
let scene_id = normalize_required_string(scene_id)
.ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
snapshot.current_scene_id = Some(scene_id);
snapshot.visible_character_ids.clear();
}
VisualNovelRuntimeStep::Narration { text } => {
if normalize_required_string(text).is_none() {
return Err(VisualNovelDomainError::InvalidRuntimeStep);
}
}
VisualNovelRuntimeStep::Dialogue {
character_id,
character_name,
text,
..
} => {
let character_id = normalize_required_string(character_id)
.ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
if normalize_required_string(character_name).is_none()
|| normalize_required_string(text).is_none()
{
return Err(VisualNovelDomainError::InvalidRuntimeStep);
}
if !snapshot.visible_character_ids.contains(&character_id) {
snapshot.visible_character_ids.push(character_id);
}
}
VisualNovelRuntimeStep::Transition { .. } => {}
VisualNovelRuntimeStep::Choice { choices } => {
if choices.is_empty() {
return Err(VisualNovelDomainError::InvalidRuntimeStep);
}
for choice in choices {
if normalize_required_string(&choice.choice_id).is_none()
|| normalize_required_string(&choice.text).is_none()
{
return Err(VisualNovelDomainError::InvalidRuntimeStep);
}
}
snapshot.available_choices = choices.clone();
}
VisualNovelRuntimeStep::Flag { key, value } => {
let key =
normalize_required_string(key).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
snapshot.flags.insert(key, value.clone());
}
VisualNovelRuntimeStep::Metric { key, delta } => {
let key =
normalize_required_string(key).ok_or(VisualNovelDomainError::InvalidRuntimeStep)?;
let entry = snapshot.metrics.entry(key).or_insert(0.0);
*entry += *delta;
}
}
Ok(())
}
fn resolve_action_text(
snapshot: &VisualNovelRunSnapshot,
action: &VisualNovelRuntimeAction,
) -> Result<String, VisualNovelDomainError> {
match action.action_kind {
VisualNovelRuntimeActionKind::Choice => {
let choice_id = action
.choice_id
.as_ref()
.and_then(normalize_required_string)
.ok_or(VisualNovelDomainError::InvalidChoiceId)?;
snapshot
.available_choices
.iter()
.find(|choice| choice.choice_id == choice_id)
.map(|choice| choice.text.clone())
.ok_or(VisualNovelDomainError::InvalidChoiceId)
}
VisualNovelRuntimeActionKind::FreeText => action
.text
.as_ref()
.and_then(normalize_required_string)
.ok_or(VisualNovelDomainError::MissingActionText),
VisualNovelRuntimeActionKind::Continue => Ok("继续".to_string()),
}
}
fn push_missing(
issues: &mut Vec<VisualNovelValidationIssue>,
path: &str,
value: &str,
message: &str,
) {
if normalize_required_string(value).is_none() {
let code = format!("MISSING_{}", path_to_code(path));
push_issue(issues, path, &code, message);
}
}
fn push_issue(issues: &mut Vec<VisualNovelValidationIssue>, path: &str, code: &str, message: &str) {
issues.push(VisualNovelValidationIssue {
issue_id: format!("vn-issue-{}", issues.len() + 1),
code: code.to_string(),
severity: VisualNovelValidationSeverity::Error,
path: path.to_string(),
message: message.to_string(),
});
}
fn path_to_code(path: &str) -> String {
path.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_uppercase()
} else {
'_'
}
})
.collect()
}
#[derive(Debug, Deserialize)]
struct RuntimeStepsEnvelope {
steps: Vec<RuntimeStepInput>,
}
#[derive(Debug, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
enum RuntimeStepInput {
SceneChange {
scene_id: String,
background_image_src: Option<String>,
music_src: Option<String>,
},
Narration {
text: String,
},
Dialogue {
character_id: String,
character_name: String,
expression: Option<String>,
text: String,
},
Transition {
transition_kind: VisualNovelTransitionKind,
text: Option<String>,
},
Choice {
choices: Vec<VisualNovelChoiceDraft>,
},
Flag {
key: String,
value: VisualNovelFlagValue,
},
Metric {
key: String,
delta: f64,
},
}
impl TryFrom<RuntimeStepInput> for VisualNovelRuntimeStep {
type Error = VisualNovelDomainError;
fn try_from(value: RuntimeStepInput) -> Result<Self, Self::Error> {
let step = match value {
RuntimeStepInput::SceneChange {
scene_id,
background_image_src,
music_src,
} => VisualNovelRuntimeStep::SceneChange {
scene_id,
background_image_src,
music_src,
},
RuntimeStepInput::Narration { text } => VisualNovelRuntimeStep::Narration { text },
RuntimeStepInput::Dialogue {
character_id,
character_name,
expression,
text,
} => VisualNovelRuntimeStep::Dialogue {
character_id,
character_name,
expression,
text,
},
RuntimeStepInput::Transition {
transition_kind,
text,
} => VisualNovelRuntimeStep::Transition {
transition_kind,
text,
},
RuntimeStepInput::Choice { choices } => VisualNovelRuntimeStep::Choice { choices },
RuntimeStepInput::Flag { key, value } => VisualNovelRuntimeStep::Flag { key, value },
RuntimeStepInput::Metric { key, delta } => {
VisualNovelRuntimeStep::Metric { key, delta }
}
};
let mut probe = empty_run_for_validation();
apply_step_to_snapshot(&mut probe, &step)?;
Ok(step)
}
}
fn empty_run_for_validation() -> VisualNovelRunSnapshot {
VisualNovelRunSnapshot {
run_id: "vn-run-validation".to_string(),
owner_user_id: "user-validation".to_string(),
profile_id: "vn-profile-validation".to_string(),
mode: crate::VisualNovelRunMode::Test,
status: VisualNovelRunStatus::Active,
current_scene_id: None,
current_phase_id: None,
visible_character_ids: Vec::new(),
flags: BTreeMap::new(),
metrics: BTreeMap::new(),
history: Vec::new(),
available_choices: Vec::new(),
text_mode_enabled: true,
created_at: "2026-05-05T00:00:00Z".to_string(),
updated_at: "2026-05-05T00:00:00Z".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
VisualNovelAttributePanelMode, VisualNovelCharacterDraft, VisualNovelOpeningDraft,
VisualNovelRunMode, VisualNovelRuntimeConfigDraft, VisualNovelSceneDraft,
VisualNovelSourceMode, VisualNovelStoryPhaseDraft, VisualNovelWorldDraft,
};
fn valid_draft() -> VisualNovelResultDraft {
VisualNovelResultDraft {
profile_id: Some("vn-profile-1".to_string()),
work_title: "雨夜书店".to_string(),
work_description: "在午夜书店里找回名字的视觉小说。".to_string(),
work_tags: vec!["悬疑".to_string(), "都市奇谈".to_string()],
cover_image_src: None,
source_mode: VisualNovelSourceMode::Idea,
source_asset_ids: Vec::new(),
world: VisualNovelWorldDraft {
title: "雨夜书店".to_string(),
summary: "主角在雨夜进入一间只在午夜出现的书店。".to_string(),
background: "旧街区里隐藏着交换记忆的书店。".to_string(),
premise: "找回遗失的名字。".to_string(),
literary_style: "细腻、轻悬疑".to_string(),
player_role: "误入书店的读者".to_string(),
default_tone: "克制而温柔".to_string(),
},
characters: vec![VisualNovelCharacterDraft {
character_id: "char-clerk".to_string(),
name: "店员".to_string(),
gender: None,
role: VisualNovelCharacterRole::Main,
appearance: "银灰色长发,黑色围裙。".to_string(),
personality: "温和但回避关键问题。".to_string(),
tone: "轻声慢语。".to_string(),
background: "守着午夜书店的人。".to_string(),
relationship_to_player: "向玩家递出第一本书。".to_string(),
image_assets: Vec::new(),
default_expression: None,
is_player_visible: false,
}],
scenes: vec![VisualNovelSceneDraft {
scene_id: "scene-bookstore".to_string(),
name: "午夜书店".to_string(),
description: "暖灯、旧木地板和窗外雨声。".to_string(),
background_image_src: None,
music_src: None,
ambient_sound_src: None,
availability: VisualNovelSceneAvailability::Opening,
phase_ids: vec!["phase-opening".to_string()],
}],
story_phases: vec![VisualNovelStoryPhaseDraft {
phase_id: "phase-opening".to_string(),
title: "进入书店".to_string(),
goal: "理解书店规则。".to_string(),
summary: "玩家第一次见到店员。".to_string(),
entry_condition: "开场".to_string(),
exit_condition: "选择第一本书".to_string(),
scene_ids: vec!["scene-bookstore".to_string()],
character_ids: vec!["char-clerk".to_string()],
suggested_choices: vec!["询问店员".to_string()],
}],
opening: VisualNovelOpeningDraft {
scene_id: Some("scene-bookstore".to_string()),
narration: "门铃响起,雨声被关在门外。".to_string(),
speaker_character_id: Some("char-clerk".to_string()),
first_dialogue: Some("欢迎回来。".to_string()),
initial_choices: vec![
VisualNovelChoiceDraft {
choice_id: "choice-ask".to_string(),
text: "询问店员为什么说回来".to_string(),
action_hint: None,
},
VisualNovelChoiceDraft {
choice_id: "choice-look".to_string(),
text: "环顾书店".to_string(),
action_hint: None,
},
],
},
runtime_config: VisualNovelRuntimeConfigDraft {
text_mode_enabled: true,
default_text_mode: false,
max_history_entries: 80,
max_assistant_step_count_per_turn: 8,
allow_free_text_action: true,
allow_history_regeneration: true,
attribute_panel_mode: VisualNovelAttributePanelMode::Off,
save_archive_enabled: true,
},
publish_ready: false,
validation_issues: Vec::new(),
updated_at: "2026-05-05T00:00:00Z".to_string(),
}
}
fn empty_run() -> VisualNovelRunSnapshot {
VisualNovelRunSnapshot {
run_id: "vn-run-1".to_string(),
owner_user_id: "user-1".to_string(),
profile_id: "vn-profile-1".to_string(),
mode: VisualNovelRunMode::Test,
status: VisualNovelRunStatus::Active,
current_scene_id: None,
current_phase_id: None,
visible_character_ids: Vec::new(),
flags: BTreeMap::new(),
metrics: BTreeMap::new(),
history: Vec::new(),
available_choices: Vec::new(),
text_mode_enabled: true,
created_at: "2026-05-05T00:00:00Z".to_string(),
updated_at: "2026-05-05T00:00:00Z".to_string(),
}
}
#[test]
fn publish_validation_catches_missing_opening_requirements() {
let mut draft = valid_draft();
draft.opening.scene_id = Some("missing-scene".to_string());
draft.opening.initial_choices = vec![VisualNovelChoiceDraft {
choice_id: "only-one".to_string(),
text: "只有一个选项".to_string(),
action_hint: None,
}];
let issues = validate_visual_novel_draft(&draft);
assert!(
issues
.iter()
.any(|issue| issue.code == "INVALID_OPENING_SCENE")
);
assert!(
issues
.iter()
.any(|issue| issue.code == "INVALID_INITIAL_CHOICE_COUNT")
);
}
#[test]
fn valid_draft_compiles_to_profile() {
let profile = compile_visual_novel_profile(&valid_draft()).expect("profile");
assert_eq!(profile.profile_id, "vn-profile-1");
assert!(profile.draft.publish_ready);
assert!(profile.draft.validation_issues.is_empty());
}
#[test]
fn step_parser_rejects_empty_dialogue_text() {
let error = parse_runtime_steps(
r#"[{"type":"dialogue","characterId":"char-a","characterName":"A","text":" "}]"#,
)
.expect_err("blank dialogue text should fail");
assert_eq!(error, VisualNovelDomainError::InvalidRuntimeStep);
}
#[test]
fn apply_runtime_steps_advances_scene_character_choices_and_metrics() {
let steps = parse_runtime_steps(
r#"{
"steps": [
{"type":"scene_change","sceneId":"scene-bookstore","backgroundImageSrc":null,"musicSrc":null},
{"type":"dialogue","characterId":"char-clerk","characterName":"店员","expression":null,"text":"欢迎回来。"},
{"type":"choice","choices":[{"choiceId":"choice-ask","text":"询问","actionHint":null}]},
{"type":"flag","key":"metClerk","value":true},
{"type":"metric","key":"curiosity","delta":2}
]
}"#,
)
.expect("steps");
let next =
apply_runtime_steps(&empty_run(), &steps, "vn-history-1", "2026-05-05T00:00:01Z")
.expect("run");
assert_eq!(next.current_scene_id.as_deref(), Some("scene-bookstore"));
assert_eq!(next.visible_character_ids, vec!["char-clerk".to_string()]);
assert_eq!(next.available_choices[0].choice_id, "choice-ask");
assert_eq!(
next.flags.get("metClerk"),
Some(&VisualNovelFlagValue::Bool(true))
);
assert_eq!(next.metrics.get("curiosity"), Some(&2.0));
assert_eq!(next.history.len(), 1);
}
#[test]
fn regeneration_truncates_after_assistant_history_node() {
let first = apply_runtime_steps(
&empty_run(),
&[VisualNovelRuntimeStep::SceneChange {
scene_id: "scene-a".to_string(),
background_image_src: None,
music_src: None,
}],
"vn-history-1",
"2026-05-05T00:00:01Z",
)
.expect("first");
let second = apply_runtime_steps(
&first,
&[VisualNovelRuntimeStep::SceneChange {
scene_id: "scene-b".to_string(),
background_image_src: None,
music_src: None,
}],
"vn-history-2",
"2026-05-05T00:00:02Z",
)
.expect("second");
let regenerated =
regenerate_from_history(&second, "vn-history-2", true, "2026-05-05T00:00:03Z")
.expect("regenerated");
assert_eq!(regenerated.history.len(), 1);
assert_eq!(regenerated.current_scene_id.as_deref(), Some("scene-a"));
}
#[test]
fn save_archive_state_uses_platform_runtime_kind() {
let run = apply_runtime_steps(
&empty_run(),
&[VisualNovelRuntimeStep::SceneChange {
scene_id: "scene-bookstore".to_string(),
background_image_src: None,
music_src: None,
}],
"vn-history-1",
"2026-05-05T00:00:01Z",
)
.expect("run");
let archive_state = build_save_archive_state(&run);
assert_eq!(archive_state.runtime_kind, "visual-novel");
assert_eq!(archive_state.profile_id, "vn-profile-1");
assert_eq!(archive_state.run_id, "vn-run-1");
assert_eq!(
archive_state.current_scene_id.as_deref(),
Some("scene-bookstore")
);
assert_eq!(archive_state.history_cursor, 0);
assert_eq!(archive_state.snapshot_hash.as_deref(), Some("turn-0"));
}
}

View File

@@ -0,0 +1,390 @@
//! 视觉小说模板的纯领域模型。
//!
//! 本 crate 只负责草稿校验、运行时 step 解析、状态推进、历史重生成边界
//! 和平台统一存档状态构造HTTP、SpacetimeDB、LLM、OSS 均由外层 adapter 处理。
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
use std::collections::BTreeMap;
pub const VISUAL_NOVEL_PROFILE_ID_PREFIX: &str = "vn-profile-";
pub const VISUAL_NOVEL_RUN_ID_PREFIX: &str = "vn-run-";
pub const VISUAL_NOVEL_HISTORY_ID_PREFIX: &str = "vn-history-";
pub const VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES: u32 = 80;
pub const VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN: u32 = 8;
pub const VISUAL_NOVEL_MIN_INITIAL_CHOICE_COUNT: usize = 2;
pub const VISUAL_NOVEL_MAX_INITIAL_CHOICE_COUNT: usize = 4;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelSourceMode {
Idea,
Document,
Blank,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelCharacterRole {
Protagonist,
Main,
Supporting,
Antagonist,
Background,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelAssetSource {
PlatformAsset,
Generated,
External,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelSceneAvailability {
Opening,
Always,
PhaseLocked,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelAttributePanelMode {
Off,
PlatformWhitelist,
TemplateConfig,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelValidationSeverity {
Error,
Warning,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelRunMode {
Test,
Play,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelRunStatus {
Active,
Completed,
Failed,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VisualNovelRuntimeActionKind {
Choice,
FreeText,
Continue,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelTransitionKind {
Fade,
Cut,
Flash,
None,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VisualNovelHistorySource {
Player,
Assistant,
System,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum VisualNovelFlagValue {
String(String),
Number(f64),
Bool(bool),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelValidationIssue {
pub issue_id: String,
pub code: String,
pub severity: VisualNovelValidationSeverity,
pub path: String,
pub message: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelChoiceDraft {
pub choice_id: String,
pub text: String,
pub action_hint: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelCharacterImageAsset {
pub asset_id: String,
pub image_src: String,
pub expression: Option<String>,
pub source: VisualNovelAssetSource,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorldDraft {
pub title: String,
pub summary: String,
pub background: String,
pub premise: String,
pub literary_style: String,
pub player_role: String,
pub default_tone: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelCharacterDraft {
pub character_id: String,
pub name: String,
pub gender: Option<String>,
pub role: VisualNovelCharacterRole,
pub appearance: String,
pub personality: String,
pub tone: String,
pub background: String,
pub relationship_to_player: String,
pub image_assets: Vec<VisualNovelCharacterImageAsset>,
pub default_expression: Option<String>,
pub is_player_visible: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelSceneDraft {
pub scene_id: String,
pub name: String,
pub description: String,
pub background_image_src: Option<String>,
pub music_src: Option<String>,
pub ambient_sound_src: Option<String>,
pub availability: VisualNovelSceneAvailability,
pub phase_ids: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelStoryPhaseDraft {
pub phase_id: String,
pub title: String,
pub goal: String,
pub summary: String,
pub entry_condition: String,
pub exit_condition: String,
pub scene_ids: Vec<String>,
pub character_ids: Vec<String>,
pub suggested_choices: Vec<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelOpeningDraft {
pub scene_id: Option<String>,
pub narration: String,
pub speaker_character_id: Option<String>,
pub first_dialogue: Option<String>,
pub initial_choices: Vec<VisualNovelChoiceDraft>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeConfigDraft {
pub text_mode_enabled: bool,
pub default_text_mode: bool,
pub max_history_entries: u32,
pub max_assistant_step_count_per_turn: u32,
pub allow_free_text_action: bool,
pub allow_history_regeneration: bool,
pub attribute_panel_mode: VisualNovelAttributePanelMode,
pub save_archive_enabled: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelResultDraft {
pub profile_id: Option<String>,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_mode: VisualNovelSourceMode,
pub source_asset_ids: Vec<String>,
pub world: VisualNovelWorldDraft,
pub characters: Vec<VisualNovelCharacterDraft>,
pub scenes: Vec<VisualNovelSceneDraft>,
pub story_phases: Vec<VisualNovelStoryPhaseDraft>,
pub opening: VisualNovelOpeningDraft,
pub runtime_config: VisualNovelRuntimeConfigDraft,
pub publish_ready: bool,
pub validation_issues: Vec<VisualNovelValidationIssue>,
pub updated_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelWorkProfile {
pub profile_id: String,
pub work_title: String,
pub work_description: String,
pub work_tags: Vec<String>,
pub cover_image_src: Option<String>,
pub source_mode: VisualNovelSourceMode,
pub draft: VisualNovelResultDraft,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
pub enum VisualNovelRuntimeStep {
SceneChange {
scene_id: String,
background_image_src: Option<String>,
music_src: Option<String>,
},
Narration {
text: String,
},
Dialogue {
character_id: String,
character_name: String,
expression: Option<String>,
text: String,
},
Transition {
transition_kind: VisualNovelTransitionKind,
text: Option<String>,
},
Choice {
choices: Vec<VisualNovelChoiceDraft>,
},
Flag {
key: String,
value: VisualNovelFlagValue,
},
Metric {
key: String,
delta: f64,
},
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelHistoryEntry {
pub entry_id: String,
pub run_id: String,
pub turn_index: u32,
pub source: VisualNovelHistorySource,
pub action_text: Option<String>,
pub steps: Vec<VisualNovelRuntimeStep>,
pub snapshot_before_hash: Option<String>,
pub snapshot_after_hash: Option<String>,
pub created_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRunSnapshot {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub mode: VisualNovelRunMode,
pub status: VisualNovelRunStatus,
pub current_scene_id: Option<String>,
pub current_phase_id: Option<String>,
pub visible_character_ids: Vec<String>,
pub flags: BTreeMap<String, VisualNovelFlagValue>,
pub metrics: BTreeMap<String, f64>,
pub history: Vec<VisualNovelHistoryEntry>,
pub available_choices: Vec<VisualNovelChoiceDraft>,
pub text_mode_enabled: bool,
pub created_at: String,
pub updated_at: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelRuntimeAction {
pub action_kind: VisualNovelRuntimeActionKind,
pub choice_id: Option<String>,
pub text: Option<String>,
pub client_event_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VisualNovelSaveArchiveState {
pub runtime_kind: String,
pub profile_id: String,
pub run_id: String,
pub current_scene_id: Option<String>,
pub current_phase_id: Option<String>,
pub history_cursor: u32,
pub snapshot_hash: Option<String>,
}
impl Default for VisualNovelRuntimeConfigDraft {
fn default() -> Self {
Self {
text_mode_enabled: true,
default_text_mode: false,
max_history_entries: VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES,
max_assistant_step_count_per_turn:
VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN,
allow_free_text_action: true,
allow_history_regeneration: true,
attribute_panel_mode: VisualNovelAttributePanelMode::Off,
save_archive_enabled: true,
}
}
}

View File

@@ -0,0 +1,41 @@
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VisualNovelDomainError {
MissingProfileId,
MissingRunId,
MissingOwnerUserId,
MissingClientEventId,
MissingActionText,
InvalidChoiceId,
FreeTextDisabled,
HistoryRegenerationDisabled,
HistoryEntryNotFound,
InvalidHistorySource,
InvalidRuntimeStep,
InvalidJson,
EmptyRuntimeSteps,
}
impl fmt::Display for VisualNovelDomainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::MissingProfileId => "visual novel profile_id 缺失",
Self::MissingRunId => "visual novel run_id 缺失",
Self::MissingOwnerUserId => "visual novel owner_user_id 缺失",
Self::MissingClientEventId => "visual novel client_event_id 缺失",
Self::MissingActionText => "visual novel 行动文本缺失",
Self::InvalidChoiceId => "visual novel choice_id 不合法",
Self::FreeTextDisabled => "visual novel 当前作品未开启自由行动",
Self::HistoryRegenerationDisabled => "visual novel 当前作品未开启历史重生成",
Self::HistoryEntryNotFound => "visual novel history entry 不存在",
Self::InvalidHistorySource => "visual novel 只能从助手历史节点重生成",
Self::InvalidRuntimeStep => "visual novel runtime step 不合法",
Self::InvalidJson => "visual novel JSON 解析失败",
Self::EmptyRuntimeSteps => "visual novel runtime step 不能为空",
};
write!(f, "{message}")
}
}
impl Error for VisualNovelDomainError {}

View File

@@ -0,0 +1,7 @@
mod application;
mod domain;
mod errors;
pub use application::*;
pub use domain::*;
pub use errors::*;

View File

@@ -0,0 +1,16 @@
[package]
name = "platform-agent"
edition.workspace = true
version.workspace = true
license.workspace = true
[dependencies]
async-trait = { workspace = true }
langchainrust = { workspace = true }
platform-llm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }

View File

@@ -0,0 +1,59 @@
use platform_llm::{
LlmClient, LlmMessage, LlmMessageContentPart, LlmMessageRole, LlmTextProtocol, LlmTextRequest,
LlmTextResponse,
};
use crate::error::PlatformAgentError;
pub const CREATIVE_AGENT_GPT5_MODEL: &str = "gpt-5";
#[derive(Clone)]
pub struct Gpt5ResponsesAgentClient {
llm_client: LlmClient,
}
impl Gpt5ResponsesAgentClient {
pub fn new(llm_client: LlmClient) -> Self {
Self { llm_client }
}
pub async fn request(
&self,
system_prompt: impl Into<String>,
user_text: impl Into<String>,
image_urls: Vec<String>,
) -> Result<LlmTextResponse, PlatformAgentError> {
let request = build_gpt5_multimodal_request(system_prompt, user_text, image_urls);
self.llm_client
.request_text(request)
.await
.map_err(Into::into)
}
}
pub fn build_gpt5_multimodal_request(
system_prompt: impl Into<String>,
user_text: impl Into<String>,
image_urls: Vec<String>,
) -> LlmTextRequest {
let mut user_parts = vec![LlmMessageContentPart::InputText {
text: user_text.into(),
}];
user_parts.extend(
image_urls
.into_iter()
.map(|image_url| LlmMessageContentPart::InputImage { image_url }),
);
LlmTextRequest {
model: Some(CREATIVE_AGENT_GPT5_MODEL.to_string()),
messages: vec![
LlmMessage::new(LlmMessageRole::System, system_prompt.into()),
LlmMessage::multimodal(LlmMessageRole::User, user_parts),
],
max_tokens: None,
request_timeout_ms: None,
enable_web_search: false,
protocol: LlmTextProtocol::Responses,
}
}

View File

@@ -0,0 +1,54 @@
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CreativeAgentCallbackKind {
Stage,
ToolStarted,
ToolCompleted,
ModelRequestStarted,
ModelRequestCompleted,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreativeAgentCallbackEvent {
pub kind: CreativeAgentCallbackKind,
pub label: String,
pub detail: Option<String>,
}
type CreativeAgentCallbackFn = Arc<dyn Fn(CreativeAgentCallbackEvent) + Send + Sync>;
#[derive(Clone, Default)]
pub struct CreativeAgentCallbacks {
on_event: Option<CreativeAgentCallbackFn>,
}
impl CreativeAgentCallbacks {
pub fn new<F>(on_event: F) -> Self
where
F: Fn(CreativeAgentCallbackEvent) + Send + Sync + 'static,
{
Self {
on_event: Some(Arc::new(on_event)),
}
}
pub fn noop() -> Self {
Self::default()
}
pub fn emit(&self, event: CreativeAgentCallbackEvent) {
if let Some(on_event) = &self.on_event {
on_event(event);
}
}
pub fn stage(&self, label: impl Into<String>) {
self.emit(CreativeAgentCallbackEvent {
kind: CreativeAgentCallbackKind::Stage,
label: label.into(),
detail: None,
});
}
}

View File

@@ -0,0 +1,40 @@
use std::{error::Error, fmt};
#[derive(Debug, PartialEq, Eq)]
pub enum PlatformAgentError {
InvalidInput(String),
ToolNotFound(String),
ToolExecution(String),
ToolBudgetExceeded { max_tool_calls: usize },
Timeout { timeout_ms: u64 },
Llm(String),
LangChain(String),
OutputParse(String),
}
impl fmt::Display for PlatformAgentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInput(message)
| Self::ToolExecution(message)
| Self::Llm(message)
| Self::LangChain(message)
| Self::OutputParse(message) => write!(f, "{message}"),
Self::ToolNotFound(name) => write!(f, "Agent 工具未注册:{name}"),
Self::ToolBudgetExceeded { max_tool_calls } => {
write!(f, "Agent 工具调用次数超过限制:{max_tool_calls}")
}
Self::Timeout { timeout_ms } => {
write!(f, "Agent 执行超时:{timeout_ms}ms")
}
}
}
}
impl Error for PlatformAgentError {}
impl From<platform_llm::LlmError> for PlatformAgentError {
fn from(error: platform_llm::LlmError) -> Self {
Self::Llm(error.to_string())
}
}

View File

@@ -0,0 +1,49 @@
use std::{future::Future, time::Duration};
use tokio::time::timeout;
use crate::error::PlatformAgentError;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FunctionAgentLimits {
pub max_tool_calls: usize,
pub timeout_ms: u64,
}
impl Default for FunctionAgentLimits {
fn default() -> Self {
Self {
max_tool_calls: 8,
timeout_ms: 30_000,
}
}
}
impl FunctionAgentLimits {
pub fn validate(&self) -> Result<(), PlatformAgentError> {
if self.max_tool_calls == 0 {
return Err(PlatformAgentError::InvalidInput(
"Agent max_tool_calls 必须大于 0".to_string(),
));
}
if self.timeout_ms == 0 {
return Err(PlatformAgentError::InvalidInput(
"Agent timeout_ms 必须大于 0".to_string(),
));
}
Ok(())
}
pub async fn run_with_timeout<F, T>(&self, future: F) -> Result<T, PlatformAgentError>
where
F: Future<Output = Result<T, PlatformAgentError>>,
{
self.validate()?;
timeout(Duration::from_millis(self.timeout_ms), future)
.await
.map_err(|_| PlatformAgentError::Timeout {
timeout_ms: self.timeout_ms,
})?
}
}

View File

@@ -0,0 +1,177 @@
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use langchainrust::{
AgentAction, AgentError, AgentExecutor, AgentFinish, AgentOutput, AgentStep, BaseAgent,
BaseTool, FunctionCallingAgent, OpenAIChat, OpenAIConfig, ToolError, ToolInput,
};
use serde_json::json;
use crate::{
apimart_gpt5_adapter::CREATIVE_AGENT_GPT5_MODEL, error::PlatformAgentError,
function_agent::FunctionAgentLimits,
};
pub struct LangChainRustAdapter {
limits: FunctionAgentLimits,
}
impl LangChainRustAdapter {
pub fn new(limits: FunctionAgentLimits) -> Result<Self, PlatformAgentError> {
limits.validate()?;
Ok(Self { limits })
}
pub fn limits(&self) -> &FunctionAgentLimits {
&self.limits
}
pub fn build_function_calling_agent(
&self,
api_key: impl Into<String>,
base_url: impl Into<String>,
tools: Vec<Arc<dyn BaseTool>>,
system_prompt: Option<String>,
) -> FunctionCallingAgent {
let config = OpenAIConfig::new(api_key)
.with_base_url(base_url)
.with_model(CREATIVE_AGENT_GPT5_MODEL);
let llm = OpenAIChat::new(config);
FunctionCallingAgent::new(llm, tools, system_prompt)
}
pub async fn execute_minimal_tool_call(
&self,
tool_name: impl Into<String>,
input: serde_json::Value,
) -> Result<String, PlatformAgentError> {
let tool_name = tool_name.into();
let tools: Vec<Arc<dyn BaseTool>> =
vec![Arc::new(EchoLangChainTool::new(tool_name.clone()))];
let agent = Arc::new(ScriptedLangChainToolAgent::new(tool_name, input));
let executor =
AgentExecutor::new(agent, tools).with_max_iterations(self.limits.max_tool_calls);
self.limits
.run_with_timeout(async move {
executor
.invoke("执行最小工具调用".to_string())
.await
.map_err(|error| PlatformAgentError::LangChain(error.to_string()))
})
.await
}
}
struct ScriptedLangChainToolAgent {
tool_name: String,
input: serde_json::Value,
}
impl ScriptedLangChainToolAgent {
fn new(tool_name: String, input: serde_json::Value) -> Self {
Self { tool_name, input }
}
}
#[async_trait]
impl BaseAgent for ScriptedLangChainToolAgent {
async fn plan(
&self,
intermediate_steps: &[AgentStep],
_inputs: &HashMap<String, String>,
) -> Result<AgentOutput, AgentError> {
if let Some(step) = intermediate_steps.last() {
return Ok(AgentOutput::Finish(AgentFinish::new(
step.observation.clone(),
String::new(),
)));
}
Ok(AgentOutput::Action(AgentAction {
tool: self.tool_name.clone(),
tool_input: ToolInput::Object(self.input.clone()),
log: "scripted-call-1".to_string(),
}))
}
fn get_allowed_tools(&self) -> Option<Vec<&str>> {
Some(vec![self.tool_name.as_str()])
}
}
struct EchoLangChainTool {
name: String,
}
impl EchoLangChainTool {
fn new(name: String) -> Self {
Self { name }
}
}
#[async_trait]
impl BaseTool for EchoLangChainTool {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
"用于验证 LangChain-Rust AgentExecutor 能执行最小工具调用。"
}
async fn run(&self, input: String) -> Result<String, ToolError> {
Ok(json!({
"ok": true,
"tool": self.name,
"input": input,
})
.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn langchain_adapter_executes_minimal_tool_call() {
let adapter = LangChainRustAdapter::new(FunctionAgentLimits {
max_tool_calls: 2,
timeout_ms: 1_000,
})
.expect("limits should be valid");
let output = adapter
.execute_minimal_tool_call("retrieve_puzzle_template_catalog", json!({"query": "拼图"}))
.await
.expect("langchain executor should run tool");
let parsed: serde_json::Value =
serde_json::from_str(output.as_str()).expect("tool output should be json");
assert_eq!(parsed["ok"], true);
assert_eq!(parsed["tool"], "retrieve_puzzle_template_catalog");
assert!(
parsed["input"]
.as_str()
.unwrap_or_default()
.contains("拼图")
);
}
#[test]
fn function_calling_agent_uses_gpt5_config_without_calling_network() {
let adapter = LangChainRustAdapter::new(FunctionAgentLimits::default())
.expect("limits should be valid");
let agent = adapter.build_function_calling_agent(
"test-key",
"http://127.0.0.1:9/v1",
Vec::new(),
Some("系统提示".to_string()),
);
let debug_text = format!("{agent:?}");
assert!(debug_text.contains("FunctionCallingAgent"));
assert!(debug_text.contains("系统提示"));
}
}

Some files were not shown because too many files have changed in this diff Show More