diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b9830b54e..72bddd803 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -40,6 +40,134 @@ - 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。 - 关联文档:`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/` 中建立团队共享记忆 - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index ffe8c85e8..315540844 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -77,6 +77,16 @@ - 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。 - 关联:`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 健康检查误超时 - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。 @@ -85,6 +95,73 @@ - 验证:冷启动时不再误杀仍在编译的 api-server。 - 关联:`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` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。 @@ -93,6 +170,14 @@ - 验证:查看 `server-rs/Cargo.toml` default-members,并按相关 SpacetimeDB 文档执行模块构建。 - 关联:`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 / 一体化脚本 - 现象:部署、回滚或 Jenkins Job 重建时参考旧发布文档,导致 systemd、Nginx、SpacetimeDB 自托管和生产包拆分不一致。 diff --git a/docs/README.md b/docs/README.md index df25e2cff..419fab837 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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)。 - [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。 - [运营查询](./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)。 @@ -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)。 +基于 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),快速建立这个项目的开发共识。 diff --git a/docs/audits/README.md b/docs/audits/README.md index d967f9550..b5a53b856 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -19,6 +19,8 @@ - [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_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_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` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 diff --git a/docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md b/docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md new file mode 100644 index 000000000..192a30ed2 --- /dev/null +++ b/docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md @@ -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 +``` + diff --git a/docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md b/docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md new file mode 100644 index 000000000..accd8ffa0 --- /dev/null +++ b/docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md @@ -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` 与编译 / 单测已通过。 +- 若接口路由或测试名称后续调整,需要同步更新本门禁脚本与报告模板。 + diff --git a/docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png b/docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png new file mode 100644 index 000000000..d2bac1fad Binary files /dev/null and b/docs/audits/VN12_VISUAL_NOVEL_DESKTOP_2026-05-07.png differ diff --git a/docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png b/docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png new file mode 100644 index 000000000..edad488ee Binary files /dev/null and b/docs/audits/VN12_VISUAL_NOVEL_MOBILE_2026-05-07.png differ diff --git a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md index 6a034833b..0911539fd 100644 --- a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md +++ b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md @@ -28,3 +28,12 @@ - 锁定的模板卡统一以“敬请期待”作为状态标注,不再显示“锁定”。 - RPG 入口展示为“角色扮演 / 剧情演绎,冒险成长”,拼图入口展示为“拼图 / 创意礼物,生活分享”。 - 忙碌状态仅保留在模块标题行的轻量状态中,避免占用每张可用卡片的首要视觉层级。 + +## 2026-05-07 玩法参考图 + +1. 每个玩法入口都必须配置一张 `public/creation-type-references/` 下的参考图。 +2. 当前创作 Tab 顶部玩法卡带、旧创作中心卡带和玩法类型弹层都消费同一份 `PLATFORM_CREATION_TYPES.imageSrc`,避免多入口视觉漂移。 +3. 图片只承担玩法识别和氛围锚定,不在卡片上叠加规则说明文案。 +4. 移动端卡片仍以紧凑横滑为主,参考图使用暗色遮罩承接标题,文本不得溢出卡片。 +5. 当前创作 Tab 的可见玩法卡必须真实渲染 `img`,不能只在隐藏弹窗或旧入口中配置图片。 +6. 参考图卡片上的标题和副标题必须显式使用白色文字,并配合底部加深渐变与文字阴影;禁止依赖 `text-inherit`,避免黑字叠在暗蒙版上。 diff --git a/docs/design/PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md b/docs/design/PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md index 0479c045b..54f88f83d 100644 --- a/docs/design/PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md +++ b/docs/design/PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md @@ -6,8 +6,8 @@ - 新增“分类” Tab,用作品标签聚合所有公开发布作品。 - 强化“创作” Tab 的导航视觉权重,让它在底部导航中居中并更醒目。 -- 登录态底部导航顺序为:首页、分类、创作、存档、我的。 -- 未登录态底部导航只保留:首页、创作、分类,其中创作保持居中。 +- 登录态底部导航顺序为:推荐、发现、创作、草稿、我的。 +- 未登录态底部导航只保留:推荐、创作、发现,其中创作保持居中。 ## 2. 数据边界 @@ -24,10 +24,10 @@ 底部导航展示 5 个入口: -1. 首页 -2. 分类 +1. 推荐 +2. 发现 3. 创作 -4. 存档 +4. 草稿 5. 我的 创作入口位于第三位,视觉上使用更大的图标壳、轻微上浮、渐变高亮和阴影,保证它是主行动入口。 @@ -36,11 +36,11 @@ 底部导航展示 3 个入口: -1. 首页 +1. 推荐 2. 创作 -3. 分类 +3. 发现 -不展示“存档”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中。 +不展示“草稿”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中,并沿用原推荐 Tab 的星光图标;推荐 Tab 改用游戏手柄图标。 ### 3.3 桌面端 @@ -59,6 +59,6 @@ - 登录态移动端底部导航顺序准确,创作在 5 个 Tab 中居中。 - 未登录态移动端底部导航只显示 3 个 Tab,创作在中间。 -- 分类 Tab 能按标签切换并展示公开作品。 +- 发现 Tab 能按标签切换并展示公开作品。 - 创作 Tab 在移动端和桌面端都比普通 Tab 更醒目。 - 不修改 server-node,不新增后端逻辑。 diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md new file mode 100644 index 000000000..6e3bd4130 --- /dev/null +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -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. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。 diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md new file mode 100644 index 000000000..7d900162f --- /dev/null +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -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 改用游戏手柄图标,避免与创作图标重复。 diff --git a/docs/design/README.md b/docs/design/README.md index d90417464..d7e6dc9c5 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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_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_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_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 像素风模态窗口外壳、交互边界和迁移顺序。 diff --git a/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08-v2.png b/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08-v2.png new file mode 100644 index 000000000..e76470c26 Binary files /dev/null and b/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08-v2.png differ diff --git a/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08.png b/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08.png new file mode 100644 index 000000000..d28a7b858 Binary files /dev/null and b/docs/design/assets/puzzle-creation-mobile-ui-reference-gpt-image-2-2026-05-08.png differ diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index d594a7c09..6df46c04d 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -221,3 +221,8 @@ - 后续新增怪物资源时,先检查红圈标注的实际落点,再调整锚点分档或单怪物偏移,避免出现“悬在地面上方”的状态。 - 自定义世界里敌对角色已经先作为场景 NPC 存在,即使它同时携带 `characterId` 和 `monsterPresetId`,画布也不能直接沿用模板角色的 `groundOffsetY`;只要 encounter 自身有 `imageSrc` 或 `visual`,就按场景 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()` 拦截页面级捏合与快速双击缩放。 +- 不要在每个画布组件里重复注册缩放拦截;单指滚动、点击、拖拽应继续留给具体页面和玩法处理。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 9c15925a0..4ffd10a26 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -29,6 +29,7 @@ - [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 背景图不能进入普通布局流的修复经验。 - [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 空会话不应进入作品草稿列表的后端判定规则。 - [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 接口。 diff --git a/docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md b/docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md new file mode 100644 index 000000000..fecd70506 --- /dev/null +++ b/docs/experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md @@ -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 文档和这份交接说明往下走。 diff --git a/docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md new file mode 100644 index 000000000..78165b49c --- /dev/null +++ b/docs/prd/AI_NATIVE_2048_GAMEPLAY_TEMPLATE_PRD_2026-05-05.md @@ -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 +``` + +## 任务 C:SpacetimeDB 表与 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 +``` + +## 任务 D:API / 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 的滑动合并手感,服务端负责正式棋盘裁决、作品状态和成绩真相。 diff --git a/docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md new file mode 100644 index 000000000..82fed3888 --- /dev/null +++ b/docs/prd/AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md @@ -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. 服务端更严格的确定性重放校验。 + diff --git a/docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md new file mode 100644 index 000000000..414ebf08e --- /dev/null +++ b/docs/prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md @@ -0,0 +1,1277 @@ +# AI 原生幕间文字游戏模板 PRD:参考 MOKU 的剧本模拟器闭环 + +更新时间:`2026-05-05` + +## 0. 文档目的 + +这份 PRD 用于设计 `Genarrative / 百梦` 内的 AI 文字游戏模板,暂定工程 ID 为 `text-game`,展示名可用“幕间”或“幕间文字”。它参考 MOKU / 幕间类 AI 互动平台的剧本游乐场、低门槛创作、自由行动、AI 记忆和模拟器强反馈经验,但不迁入任何外部平台工程、账号体系、社区、支付、榜单、论坛或私有存档。 + +外部参考只用于提炼产品模式: + +1. MOKU 墨盒公开应用介绍页体现了剧本库、论坛、我的剧本、创建新剧情、题材分类和搜索入口。 +2. AI 文游公开介绍与同类产品材料共同指向一个核心判断:产品不应只是陪聊,而应提供“输入改变世界”的正反馈和可持续互动的模拟器结构。 +3. TextGame.ai 体现 AI Game Master、模板即玩、自定义世界、AI 队友和持久世界状态。 +4. Aventura 体现 lorebook、智能记忆、分支故事和本地/隐私优先的创作辅助理念。 + +本文只冻结百梦自己的模板落地口径。若后续实现与旧 TXT / visual novel 文档冲突,应按本文与当前后端 DDD 总纲重新修正文档后再编码。 + +--- + +## 1. 一句话定义 + +`text-game` 是一个 AI 原生文字游戏模板:百梦主可以用一句话、文档、空白或改作入口创建一个可玩的文字互动剧本,系统把题材、玩家身份、角色、地点、目标、机制、记忆和 GM 规则编译成结构化草稿;玩家进入后通过自由输入或快捷选项推动剧情,后端 AI GM 根据世界状态、长期记忆和阶段目标生成下一轮叙事、状态变化、关系变化和可继续行动。 + +--- + +## 2. 与现有玩法的边界 + +### 2.1 不等同于 `visual-novel` + +`visual-novel` 以背景、角色立绘、旁白、对白和场景 step 为核心,更接近视觉小说 / Galgame。 + +`text-game` 以文字互动、模拟器规则、状态反馈和 AI GM 为核心,首版可以没有立绘和分镜,不要求每轮输出视觉小说 step。 + +二者可以共享平台入口、作品架、资产、钱包、存档和部分 Agent 能力,但不能共用同一份运行时契约。 + +### 2.2 不等同于 RPG 主运行时 + +RPG 主运行时承载百梦现有视觉 RPG、场景探索、战斗、NPC、物品和章节系统。 + +`text-game` 首版是轻量互动剧本模板,不接入完整地图移动、战斗演出、装备背包、NPC 商店和 RPG 章节任务系统。它可以在以后把成熟剧本升级为 RPG 世界,但 V1 不做自动升级。 + +### 2.3 不迁入外部平台 + +以下能力明确不做: + +1. 不迁入 MOKU 或其他平台的账号、论坛、社区、支付、创作者分成、推广位或后台。 +2. 不新增第二套作品市场、浏览历史、私有存档、钱包或点数系统。 +3. 不把外部平台剧本内容、标题、分类排序和热度数据复制进仓库。 +4. 不实现回放、录屏、可分享播放包或 replay 表。 +5. 不绕过 `server-rs + Axum + SpacetimeDB` 主线。 + +--- + +## 3. 产品目标 + +### 3.1 核心目标 + +1. 让百梦主在 3 分钟内从一个想法生成可试玩的文字游戏。 +2. 让玩家每一轮行动都看到世界状态、关系或目标推进的反馈。 +3. 让模板支持高复用题材:恋爱养成、古代言情、宫斗宅斗、修仙仙侠、末日生存、灵异怪谈、科幻未来、经营养成、校园都市、娱乐圈。 +4. 让剧本不只是聊天对象,而是一个有目标、规则、记忆和结局的轻量模拟器。 +5. 让作品可以进入百梦作品架、草稿、发布、广场、存档和继续体验链路。 + +### 3.2 首版不追求 + +1. 不追求无限开放世界。 +2. 不追求多人实时同房。 +3. 不追求复杂数值战斗。 +4. 不追求自动生成图片、音乐和角色语音。 +5. 不追求创作者收益、社区论坛和平台运营工具。 + +--- + +## 4. 参考经验提炼 + +### 4.1 从 MOKU / 幕间提炼 + +可吸收的经验: + +1. 首页就是剧本发现,不先做长篇说明页。 +2. 剧本按题材分类,玩家先被题材和设定吸引。 +3. “模拟器”比“聊天机器人”更适合形成持续游玩欲望。 +4. 创作者不需要会写代码,只要能描述核心设定,AI 就应补齐初始剧情和机制。 +5. 自由行动必须受到剧本规则约束,否则玩家会很快疲惫。 +6. 每轮反馈要明确告诉玩家:刚才的输入让世界发生了什么变化。 + +不能吸收的内容: + +1. 外部平台社区结构。 +2. 外部作品热度数据。 +3. 外部商业化、充值、会员或收益体系。 +4. 外部具体剧本文案。 + +### 4.2 从 TextGame.ai 提炼 + +可吸收的经验: + +1. AI GM 负责叙事、NPC、遭遇和规则推进。 +2. 模板一键开玩,自定义世界也能通过描述生成。 +3. AI 可以填补空位,作为队友、对手或观众角色参与。 +4. 世界状态、队伍、库存、任务进度需要持久化。 + +百梦落点: + +1. V1 支持单人玩家 + AI 角色群。 +2. AI 角色群只作为剧本内角色,不做多人房间。 +3. 持久化使用 SpacetimeDB 表和平台 save archive。 + +### 4.3 从 Aventura 提炼 + +可吸收的经验: + +1. Lorebook 用于整理角色、地点、世界规则。 +2. 智能记忆用于长线剧情一致性。 +3. 分支故事需要保存 alternate timeline 的结构,而不是只保存最终文本。 +4. 创作者需要能查看和修正 AI 记住了什么。 + +百梦落点: + +1. V1 只做当前主线历史与局部重生成,不做多时间线编辑器。 +2. 记忆分为剧本静态设定、运行时事件摘要、角色记忆、玩家画像和未解决伏笔。 +3. 记忆写入需要经过领域校验,不允许 AI 任意膨胀长期记忆。 + +--- + +## 5. 用户角色 + +### 5.1 百梦主 + +百梦主负责创建、编辑、试玩和发布文字游戏。核心诉求是: + +1. 用少量输入生成可玩的剧本。 +2. 控制题材、关系张力、目标、结局和禁区。 +3. 可以手动修正角色、地点、机制和开场。 +4. 发布前能试玩并修正“不好玩”的地方。 + +### 5.2 玩家 + +玩家负责进入剧本、扮演角色、输入行动并推进故事。核心诉求是: + +1. 快速明白自己是谁、想要什么、当前局面是什么。 +2. 输入自由行动后获得有意义反馈。 +3. 感到 NPC 和世界记得自己做过的事。 +4. 可以存档、继续、重试最近一轮。 + +### 5.3 平台运营 + +平台运营首版只需要读取发布作品和基础数据,不新增独立运营后台。后续如要做推荐、审核、榜单和创作者运营,应接入现有后台管理主线。 + +--- + +## 6. 主流程 + +### 6.1 创作流程 + +```text +平台创作中心 +-> 选择“幕间文字 / AI文字游戏” +-> 选择创建方式:一句话 / 文档 / 空白 / 改作 +-> 进入 text-game-agent-workspace +-> Agent 生成剧本草稿 +-> 进入 text-game-result +-> 编辑题材、玩家身份、角色、地点、机制、章节目标和 GM 规则 +-> 点击试玩 +-> 进入 text-game-runtime 测试 run +-> 返回结果页继续调整 +-> 保存 / 发布到平台作品体系 +``` + +### 6.2 游玩流程 + +```text +平台作品入口 +-> 打开文字游戏作品 +-> 创建 runtime run +-> 阅读开场局面 +-> 选择快捷行动或输入自由行动 +-> 后端流式返回叙事、角色回应、状态变化和下一步选项 +-> 查看状态 / 记忆 / 历史 / 存档 +-> 继续游玩或达成结局 +``` + +### 6.3 恢复流程 + +```text +草稿 / 作品架 / 平台统一存档 +-> 读取 text-game work profile 或 save archive +-> 恢复 text-game-result 或 text-game-runtime +-> 继续编辑或继续游玩 +``` + +恢复只接平台统一状态,不新增外部私有存档接口。 + +--- + +## 7. 创作入口 + +### 7.1 一句话创建 `idea` + +输入:百梦主写一句或几句话。 + +示例语义: + +1. “我想做一个古代纨绔贵女逆袭的模拟器。” +2. “做一个末日小卖部经营文游,顾客会动态进店。” +3. “玩家是娱乐圈新人,要在黑红、资源和关系里周旋。” + +输出至少包括: + +1. 作品标题。 +2. 一句话简介。 +3. 题材分类。 +4. 玩家身份。 +5. 开场局面。 +6. 3 到 8 个核心角色。 +7. 3 到 8 个地点或场域。 +8. 3 到 6 个章节目标。 +9. 3 到 8 个状态指标。 +10. GM 规则和禁止事项。 +11. 第一轮快捷行动。 + +### 7.2 文档创建 `document` + +输入:上传 `.txt`、`.md`、`.docx` 或平台已有文档资产。 + +要求: + +1. 文档先走平台资产接口。 +2. 表内只保存资产引用、摘要和结构化提取结果。 +3. 长文档先做摘要、角色表和伏笔表,不把整篇文档塞进每次 prompt。 +4. Agent 补齐缺失机制时必须标记 `source = 'agent_inferred'`。 + +### 7.3 空白创建 `blank` + +输入:无。 + +输出:空白草稿。 + +默认值: + +1. 标题为空。 +2. 题材为空。 +3. 角色、地点、状态指标、章节目标为空数组。 +4. GM 配置使用模板默认值。 +5. 发布前必须通过完整校验。 + +### 7.4 改作创建 `remix` + +输入:一个已发布作品或自己的草稿。 + +要求: + +1. 只能改作允许 remix 的作品。 +2. 必须记录来源作品 ID。 +3. 默认不复制原作者私有草稿、私有运行历史和未公开素材。 +4. 发布时需要保留平台作品来源链。 + +V1 可以先保留契约字段,入口暂不开放。 + +--- + +## 8. 草稿契约 + +### 8.1 `TextGameResultDraft` + +```ts +export interface TextGameResultDraft { + profileId: string | null; + workTitle: string; + workDescription: string; + workTags: string[]; + coverImageSrc: string | null; + sourceMode: 'idea' | 'document' | 'blank' | 'remix'; + sourceAssetIds: string[]; + sourceWorkId: string | null; + category: TextGameCategory; + playerRole: TextGamePlayerRoleDraft; + world: TextGameWorldDraft; + cast: TextGameCharacterDraft[]; + locations: TextGameLocationDraft[]; + mechanics: TextGameMechanicDraft[]; + chapterPlan: TextGameChapterDraft[]; + opening: TextGameOpeningDraft; + gmConfig: TextGameGmConfigDraft; + memoryPolicy: TextGameMemoryPolicyDraft; + publishReady: boolean; + validationIssues: TextGameValidationIssue[]; + updatedAt: string; +} +``` + +### 8.2 题材分类 + +```ts +export type TextGameCategory = + | 'romance' + | 'xianxia' + | 'court' + | 'ancient_drama' + | 'apocalypse' + | 'horror' + | 'sci_fi' + | 'business' + | 'campus' + | 'urban' + | 'entertainment' + | 'custom'; +``` + +要求: + +1. 分类只用于发现、模板默认值和推荐,不写死运行规则。 +2. 用户可以在 `custom` 下自定义题材。 +3. 前端展示中文文案,契约字段保持稳定英文枚举。 + +### 8.3 玩家身份 + +```ts +export interface TextGamePlayerRoleDraft { + name: string; + identity: string; + desire: string; + weakness: string; + startingSituation: string; + allowedActionStyle: 'free' | 'grounded' | 'choice_first'; +} +``` + +校验: + +1. `identity` 不能为空。 +2. `desire` 必须说明玩家短期目标。 +3. `allowedActionStyle = 'free'` 仍然要受 GM 规则和安全策略约束。 + +### 8.4 世界设定 + +```ts +export interface TextGameWorldDraft { + title: string; + summary: string; + premise: string; + tone: string; + socialRules: string; + powerRules: string; + tabooRules: string[]; +} +``` + +要求: + +1. `summary` 用于作品卡,建议 40 到 120 字。 +2. `premise` 用于 GM prompt,必须说明核心冲突。 +3. `socialRules` 表达关系和社会环境约束。 +4. `powerRules` 表达超自然、资源、经营或能力边界。 +5. `tabooRules` 用于剧本内禁区,不等同于平台安全策略。 + +### 8.5 角色 + +```ts +export interface TextGameCharacterDraft { + characterId: string; + name: string; + role: 'ally' | 'rival' | 'love_interest' | 'authority' | 'customer' | 'villain' | 'bystander'; + publicProfile: string; + privateAgenda: string; + relationshipToPlayer: string; + speechStyle: string; + initialAffinity: number; + memoryVisibility: 'public' | 'private' | 'hidden'; +} +``` + +校验: + +1. 至少 2 个可互动角色。 +2. `name` 不能为空。 +3. `initialAffinity` 范围为 `-100` 到 `100`。 +4. `privateAgenda` 不直接展示给玩家,只进入 GM 上下文。 + +### 8.6 地点 + +```ts +export interface TextGameLocationDraft { + locationId: string; + name: string; + description: string; + availableFromChapterId: string | null; + tags: string[]; + defaultNpcIds: string[]; + hiddenHooks: string[]; +} +``` + +校验: + +1. 至少 1 个开场地点。 +2. 地点名不能为空。 +3. `defaultNpcIds` 必须指向有效角色。 + +### 8.7 机制 + +```ts +export interface TextGameMechanicDraft { + mechanicId: string; + label: string; + kind: 'meter' | 'flag' | 'inventory' | 'quest' | 'relationship' | 'ending_condition'; + description: string; + initialValue: string | number | boolean; + visibleToPlayer: boolean; + minValue?: number; + maxValue?: number; +} +``` + +V1 推荐机制: + +1. 至少 1 个主目标进度。 +2. 至少 1 个风险或压力值。 +3. 关系类剧本至少 1 个关系指标。 +4. 经营类剧本至少 1 个资源指标。 + +### 8.8 章节计划 + +```ts +export interface TextGameChapterDraft { + chapterId: string; + title: string; + goal: string; + entryCondition: string; + exitCondition: string; + pressureHint: string; + suggestedEvents: string[]; + endingCandidateIds: string[]; +} +``` + +要求: + +1. 至少 1 个章节。 +2. 第一章节必须能从开场进入。 +3. 每个章节的 `goal` 必须可通过运行态状态判断推进。 + +### 8.9 开场 + +```ts +export interface TextGameOpeningDraft { + locationId: string | null; + narration: string; + visibleCharacterIds: string[]; + initialState: Record; + initialChoices: TextGameChoiceDraft[]; +} +``` + +```ts +export interface TextGameChoiceDraft { + choiceId: string; + text: string; + actionHint: string; +} +``` + +发布前要求: + +1. `locationId` 必须指向有效地点。 +2. `narration` 不能为空。 +3. `initialChoices` 至少 2 个,至多 4 个。 +4. 所有 `visibleCharacterIds` 必须指向有效角色。 + +### 8.10 GM 配置 + +```ts +export interface TextGameGmConfigDraft { + responseStyle: 'concise' | 'novelistic' | 'dramatic' | 'chatty'; + maxAssistantParagraphsPerTurn: number; + choiceCount: number; + allowFreeTextAction: boolean; + allowOutOfCharacterCommand: boolean; + consequenceVisibility: 'hidden' | 'summary' | 'detailed'; + endingMode: 'open_ended' | 'chapter_endings' | 'fixed_endings'; + retryLastTurnEnabled: boolean; + saveArchiveEnabled: boolean; +} +``` + +默认值: + +1. `responseStyle = 'dramatic'` +2. `maxAssistantParagraphsPerTurn = 5` +3. `choiceCount = 3` +4. `allowFreeTextAction = true` +5. `allowOutOfCharacterCommand = false` +6. `consequenceVisibility = 'summary'` +7. `endingMode = 'chapter_endings'` +8. `retryLastTurnEnabled = true` +9. `saveArchiveEnabled = true` + +### 8.11 记忆策略 + +```ts +export interface TextGameMemoryPolicyDraft { + maxTimelineEvents: number; + maxCharacterMemoriesPerCharacter: number; + maxUnresolvedHooks: number; + summarizeEveryTurnCount: number; + allowPlayerProfileMemory: boolean; + allowHiddenMemory: boolean; +} +``` + +默认值: + +1. `maxTimelineEvents = 80` +2. `maxCharacterMemoriesPerCharacter = 20` +3. `maxUnresolvedHooks = 12` +4. `summarizeEveryTurnCount = 6` +5. `allowPlayerProfileMemory = true` +6. `allowHiddenMemory = true` + +--- + +## 9. 创作 Agent 契约 + +### 9.1 Session Snapshot + +```ts +export interface TextGameAgentSessionSnapshot { + sessionId: string; + ownerUserId: string; + sourceMode: 'idea' | 'document' | 'blank' | 'remix'; + status: 'collecting' | 'drafting' | 'ready' | 'failed'; + messages: TextGameAgentMessage[]; + draft: TextGameResultDraft | null; + pendingAction: TextGameAgentPendingAction | null; + createdAt: string; + updatedAt: string; +} +``` + +### 9.2 Action + +```ts +export type TextGameAgentActionKind = + | 'generate_draft' + | 'patch_world' + | 'patch_player_role' + | 'patch_character' + | 'patch_location' + | 'patch_mechanic' + | 'patch_chapter' + | 'compile_work_profile'; +``` + +约束: + +1. `compile_work_profile` 只生成平台 work profile 草稿,不发布。 +2. 发布必须走平台 works publish 接口。 +3. Agent patch 必须写入同一份 `TextGameResultDraft`。 +4. Agent 不允许创建论坛帖子、平台热榜、回放或私有存档。 + +### 9.3 流式事件 + +```ts +export type TextGameAgentStreamEvent = + | { type: 'start'; sessionId: string } + | { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' } + | { type: 'text_delta'; text: string } + | { type: 'draft_patch'; patch: TextGameDraftPatch } + | { type: 'action_required'; action: TextGameAgentPendingAction } + | { type: 'complete'; session: TextGameAgentSessionSnapshot } + | { type: 'error'; message: string; retryable: boolean } + | { type: 'done' }; +``` + +--- + +## 10. 运行时契约 + +### 10.1 Run Snapshot + +```ts +export interface TextGameRunSnapshot { + runId: string; + ownerUserId: string; + profileId: string; + mode: 'test' | 'play'; + status: 'active' | 'completed' | 'failed'; + currentChapterId: string | null; + currentLocationId: string | null; + turnIndex: number; + visibleCharacterIds: string[]; + state: Record; + relationshipState: Record; + inventory: TextGameInventoryItem[]; + timelineSummary: string; + unresolvedHooks: string[]; + recentTurns: TextGameTurnEntry[]; + availableChoices: TextGameChoiceDraft[]; + createdAt: string; + updatedAt: string; +} +``` + +### 10.2 玩家行动 + +```ts +export interface TextGameRuntimeActionRequest { + actionKind: 'choice' | 'free_text' | 'continue' | 'retry_last'; + choiceId?: string; + text?: string; + clientEventId: string; +} +``` + +校验: + +1. `choice` 必须传有效 `choiceId`。 +2. `free_text` 必须在作品配置允许时可用。 +3. `continue` 用于无须玩家输入的过渡轮。 +4. `retry_last` 只允许重试最近一次可重试 AI 回合,不删除已提交的审计事件。 +5. `clientEventId` 用于幂等,重复请求不得重复扣费或重复写 turn。 + +### 10.3 运行时输出 + +```ts +export interface TextGameTurnEntry { + entryId: string; + runId: string; + turnIndex: number; + playerAction: string | null; + narration: string; + dialogue: TextGameDialogueLine[]; + consequences: TextGameConsequence[]; + choices: TextGameChoiceDraft[]; + memoryPatch: TextGameMemoryPatch[]; + statePatch: TextGameStatePatch[]; + createdAt: string; +} +``` + +```ts +export interface TextGameDialogueLine { + characterId: string; + characterName: string; + text: string; +} + +export interface TextGameConsequence { + label: string; + kind: 'progress' | 'relationship' | 'risk' | 'resource' | 'flag' | 'ending'; + visibility: 'hidden' | 'summary' | 'detailed'; + valueText: string | null; +} +``` + +正式 UI 状态必须来自结构化 `TextGameTurnEntry` 和 `TextGameRunSnapshot`,不能依赖原始模型文本。 + +### 10.4 记忆 Patch + +```ts +export type TextGameMemoryPatch = + | { type: 'timeline_event'; text: string; importance: number } + | { type: 'character_memory'; characterId: string; text: string; importance: number } + | { type: 'player_profile'; text: string; importance: number } + | { type: 'unresolved_hook'; text: string; importance: number }; +``` + +校验: + +1. `importance` 范围为 `1` 到 `5`。 +2. 低重要度记忆不进入长期记忆表,只留在 turn history。 +3. `character_memory` 必须指向有效角色。 +4. `hidden` 记忆不可展示给玩家。 + +### 10.5 SSE Envelope + +```ts +export type TextGameRuntimeStreamEvent = + | { type: 'start'; runId: string } + | { type: 'raw_text'; text: string } + | { type: 'turn_delta'; text: string } + | { type: 'turn'; turn: TextGameTurnEntry } + | { type: 'snapshot'; run: TextGameRunSnapshot } + | { type: 'complete'; run: TextGameRunSnapshot } + | { type: 'error'; message: string; retryable: boolean } + | { type: 'done' }; +``` + +要求: + +1. `turn` 和 `snapshot` 是正式业务状态。 +2. `raw_text`、`turn_delta` 只用于流式观感。 +3. 错误时保留已确认 turn,并允许重试。 +4. SSE 结束时必须返回后端落库后的 snapshot。 + +--- + +## 11. 平台 API 设计 + +路由遵循现有玩法风格: + +### 11.1 创作 session + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `POST` | `/api/creation/text-game/sessions` | 创建文字游戏创作 session | +| `GET` | `/api/creation/text-game/sessions/{session_id}` | 读取 session snapshot | +| `POST` | `/api/creation/text-game/sessions/{session_id}/messages` | 非流式发送创作消息 | +| `POST` | `/api/creation/text-game/sessions/{session_id}/messages/stream` | 流式发送创作消息 | +| `POST` | `/api/creation/text-game/sessions/{session_id}/actions` | 执行结构化创作 action | +| `POST` | `/api/creation/text-game/sessions/{session_id}/compile` | 编译为 work profile 草稿 | + +### 11.2 作品草稿与发布 + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `GET` | `/api/creation/text-game/works` | 读取当前用户文字游戏草稿列表 | +| `GET` | `/api/creation/text-game/works/{profile_id}` | 读取作品详情 | +| `PUT/PATCH` | `/api/creation/text-game/works/{profile_id}` | 更新作品草稿 | +| `DELETE` | `/api/creation/text-game/works/{profile_id}` | 删除未发布草稿或用户自有作品 | +| `POST` | `/api/creation/text-game/works/{profile_id}/publish` | 发布到平台作品体系 | + +### 11.3 运行时 + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `GET` | `/api/runtime/text-game/gallery` | 读取平台聚合后的公开作品列表 | +| `POST` | `/api/runtime/text-game/works/{profile_id}/runs` | 创建测试或正式 run | +| `GET` | `/api/runtime/text-game/runs/{run_id}` | 读取 run snapshot | +| `POST` | `/api/runtime/text-game/runs/{run_id}/actions/stream` | 提交行动并流式推进 | +| `GET` | `/api/runtime/text-game/runs/{run_id}/history` | 读取当前 run 历史 | +| `POST` | `/api/runtime/text-game/runs/{run_id}/retry-last` | 重试最近一轮 | + +### 11.4 平台统一存档 + +文字游戏写入 `archiveState.runtimeKind = 'text-game'`,并保存: + +1. `runId` +2. `profileId` +3. `currentChapterId` +4. `currentLocationId` +5. `turnIndex` +6. `snapshotHash` +7. `lastTurnSummary` + +存档接口仍使用平台统一 save archive,不新增 `/api/runtime/text-game/saves` 私有存档接口。 + +--- + +## 12. 后端 DDD 落点 + +| 层 | 职责 | +| --- | --- | +| `module-text-game` | 草稿校验、运行态状态推进、turn 解析、记忆 patch 校验、重试边界 | +| `shared-contracts` | Rust DTO、请求响应、SSE envelope、运行时 typed 结构 | +| `spacetime-module` | 表、reducer/procedure、事务编排、migration | +| `spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | +| `api-server` | Axum 路由、鉴权、SSE、LLM 编排、钱包与平台存档接线 | +| `platform-llm` / `platform-agent` | 创作 Agent、运行时 GM、repair 和模型调用 | +| `platform-oss` | 文档、封面和可选附件资产 | + +### 12.1 领域能力 + +`module-text-game` 至少提供: + +1. `validate_text_game_draft(draft) -> ValidationResult` +2. `compile_opening_run(draft, mode) -> TextGameRunSnapshot` +3. `validate_runtime_action(run, request) -> Result` +4. `apply_text_game_turn(run, turn) -> TextGameRunSnapshot` +5. `validate_memory_patch(policy, patch) -> Result` +6. `build_text_game_prompt_context(run, draft, memory) -> PromptContext` +7. `build_save_archive_state(run) -> SaveArchiveState` + +### 12.2 SpacetimeDB 表建议 + +首批建议表: + +1. `text_game_agent_session` +2. `text_game_agent_message` +3. `text_game_work_profile` +4. `text_game_runtime_run` +5. `text_game_runtime_turn` +6. `text_game_runtime_memory` +7. `text_game_runtime_event` + +表用途: + +| 表 | 用途 | +| --- | --- | +| `text_game_agent_session` | 创作 Agent session 快照 | +| `text_game_agent_message` | 创作消息和 action 结果 | +| `text_game_work_profile` | 作品草稿和发布 profile | +| `text_game_runtime_run` | 当前 run snapshot | +| `text_game_runtime_turn` | 运行时历史 turn,用于继续体验和最近重试 | +| `text_game_runtime_memory` | 按 run / character / memory kind 存长期记忆摘要 | +| `text_game_runtime_event` | public event 审计事件,不是 replay 数据源 | + +约束: + +1. 表结构变化必须同步 `migration.rs`、表目录和 bindings。 +2. reducer 不做网络请求、模型调用或文件 I/O。 +3. AI 调用在 `api-server` / `platform-llm`,落库通过 `spacetime-client` facade。 +4. 大文档、大图和二进制不写入 SpacetimeDB。 + +--- + +## 13. Prompt 与 AI GM + +### 13.1 创作 Prompt 输出 + +创作 Prompt 必须输出 `TextGameResultDraft`,不能只输出自然语言剧本。 + +生成原则: + +1. 先判断题材和玩家欲望。 +2. 再生成玩家身份、世界冲突、角色关系和机制。 +3. 最后生成开场、章节目标和快捷行动。 +4. 输出后由领域校验器裁剪、补齐和报错。 + +### 13.2 运行时 GM Prompt 输入 + +运行时每轮输入至少包含: + +1. 作品静态设定摘要。 +2. 当前章节目标。 +3. 当前地点和可见角色。 +4. 当前状态指标。 +5. 最近 6 到 12 个 turn 摘要。 +6. 相关长期记忆。 +7. 玩家本轮行动。 +8. GM 禁区和输出 schema。 + +### 13.3 运行时 GM 输出 + +运行时 GM 必须输出: + +1. 叙事文本。 +2. 角色对白。 +3. 状态变化。 +4. 关系变化。 +5. 记忆 patch。 +6. 下一步选择。 +7. 是否进入结局或章节结束。 + +### 13.4 Repair + +如果模型输出结构损坏: + +1. 先尝试 repair 为 `TextGameTurnEntry`。 +2. repair 失败时返回可重试错误。 +3. 不把损坏原文写入正式历史。 +4. 可将错误写入 `text_game_runtime_event` 用于排障。 + +--- + +## 14. 前端体验要求 + +### 14.1 创作入口 + +入口配置建议: + +```ts +{ + id: 'text-game', + title: '幕间文字', + subtitle: 'AI文字互动', + badge: '敬请期待', + visible: true, + open: false +} +``` + +实现完成后再切为: + +```ts +{ + id: 'text-game', + title: '幕间文字', + subtitle: 'AI文字互动', + badge: '可创建', + visible: true, + open: true +} +``` + +### 14.2 Selection Stage + +需要新增: + +```ts +| 'text-game-agent-workspace' +| 'text-game-result' +| 'text-game-runtime' +| 'text-game-gallery-detail' +``` + +可选: + +```ts +| 'text-game-generating' +``` + +### 14.3 工作台 + +组件建议: + +```text +src/components/text-game-creation/TextGameAgentWorkspace.tsx +src/components/text-game-result/TextGameResultView.tsx +src/components/text-game-runtime/TextGameRuntimeShell.tsx +``` + +工作台布局要求: + +1. 移动端优先。 +2. 顶部不展示规则长说明。 +3. 起点选择使用紧凑控件。 +4. 复杂编辑通过独立面板 / 抽屉打开。 +5. 不在当前面板下方展开大型配置区。 + +### 14.4 结果页 + +结果页至少包含: + +1. 基本信息。 +2. 玩家身份。 +3. 世界设定。 +4. 角色。 +5. 地点。 +6. 机制。 +7. 章节目标。 +8. 开场。 +9. GM 配置。 +10. 记忆策略。 +11. 发布校验。 + +每个模块使用列表 + 编辑面板,不把所有字段铺成超长表单。 + +### 14.5 运行时 + +运行时首版屏幕结构: + +1. 顶部:作品名、当前章节、状态入口。 +2. 主体:文字 turn 流。 +3. 反馈区:本轮后果摘要,可折叠。 +4. 底部:快捷选项和自由输入。 +5. 独立面板:状态、角色、记忆、历史、存档、设置。 + +移动端约束: + +1. 底部输入不能遮挡最新 turn。 +2. 快捷选项长文案自动换行,不撑破按钮。 +3. 状态和历史必须用独立抽屉。 +4. 不默认展示大段规则说明。 + +--- + +## 15. 作品架与发现 + +### 15.1 草稿和作品架 + +`text-game` work summary 接入平台作品架,字段至少包括: + +1. `runtimeKind = 'text-game'` +2. `profileId` +3. `title` +4. `description` +5. `coverImageSrc` +6. `category` +7. `tags` +8. `publishStatus` +9. `playCount` +10. `updatedAt` + +### 15.2 公开发现 + +公开发现首版复用平台广场,不新增独立论坛。 + +分类筛选可以使用 `TextGameCategory`,但分类 UI 需要与平台发现页统一。 + +### 15.3 改作 + +改作能力 V1 可暂不开放,但数据结构预留: + +1. `sourceWorkId` +2. `remixAllowed` +3. `remixSourceTitle` +4. `remixSourceAuthorDisplayName` + +--- + +## 16. 内容安全与质量约束 + +### 16.1 安全约束 + +1. 创作和运行时都必须经过平台安全策略。 +2. 剧本内 `tabooRules` 不能覆盖平台安全策略。 +3. 运行时不得让玩家通过自由输入越权读取隐藏系统 prompt。 +4. 私密角色设定只能作为 GM 上下文,不直接展示。 +5. 未成年人、违法、极端和明显违规内容按平台统一策略处理。 + +### 16.2 质量约束 + +1. 每轮必须有可见推进:剧情推进、关系变化、状态变化或信息揭示至少一种。 +2. 不允许连续三轮只复述玩家输入。 +3. 快捷行动必须贴合当前局面。 +4. 结局必须引用玩家已发生的关键行动。 +5. 长期记忆必须具体,不写“玩家很特别”这类空泛记忆。 + +--- + +## 17. 计费与消耗 + +V1 默认不新增第二套消费单位。若运行时需要扣费: + +1. 使用平台“光点”钱包。 +2. 扣费由 `api-server` 编排,不能在前端自行计算。 +3. 幂等依赖 `clientEventId`。 +4. 测试 run 是否扣费需要产品确认。 + +本文不冻结具体光点数。 + +--- + +## 18. 埋点 + +首版建议事件: + +1. `text_game_create_entry_click` +2. `text_game_session_created` +3. `text_game_draft_generated` +4. `text_game_work_saved` +5. `text_game_work_published` +6. `text_game_run_started` +7. `text_game_action_submitted` +8. `text_game_turn_completed` +9. `text_game_retry_last_turn` +10. `text_game_save_archive_created` +11. `text_game_run_completed` + +埋点原始事实走 `tracking_event`,聚合投影走现有 analytics 主线,不新增玩法私有统计表。 + +--- + +## 19. 并行任务拆分 + +### TG-00:口径冻结 + +负责范围: + +1. 冻结 `text-game` 与 `visual-novel` 的差异。 +2. 冻结不迁入外部平台功能。 +3. 冻结不做 replay。 + +交付物: + +1. 本 PRD。 +2. `docs/README.md`、`docs/prd/README.md` 索引更新。 +3. Hermes 决策记录。 + +### TG-01:共享契约与领域 + +负责范围: + +1. TypeScript contracts。 +2. Rust shared-contracts。 +3. `module-text-game` 纯领域 crate。 +4. 草稿校验、turn 应用、记忆 patch 校验。 + +验收: + +```bash +npm run typecheck + +cd server-rs +cargo test -p shared-contracts text_game +cargo test -p module-text-game +``` + +### TG-02:SpacetimeDB 表与 facade + +负责范围: + +1. `spacetime-module/src/text_game.rs` +2. `migration.rs` +3. `SPACETIMEDB_TABLE_CATALOG.md` +4. `spacetime-client/src/text_game.rs` +5. bindings 生成。 + +验收: + +```bash +npm run spacetime:generate -- --rust-only + +cd server-rs +cargo check -p spacetime-client +``` + +### TG-03:Prompt 与 LLM 工具 + +负责范围: + +1. 创作 prompt。 +2. 运行时 GM prompt。 +3. repair prompt。 +4. prompt fixture。 + +验收: + +1. 创作 fixture 能解析为 `TextGameResultDraft`。 +2. 运行 fixture 能解析为 `TextGameTurnEntry`。 +3. 错误 fixture 能 repair 或返回可重试错误。 + +### TG-04:API Server + +负责范围: + +1. `/api/creation/text-game/*` +2. `/api/runtime/text-game/*` +3. SSE。 +4. 钱包、平台存档和资产引用接线。 + +验收: + +```bash +cd server-rs +cargo check -p api-server +cargo test -p api-server text_game +``` + +涉及 API smoke 时必须使用: + +```bash +npm run api-server +``` + +并检查 `/healthz`。 + +### TG-05:前端入口与 service client + +负责范围: + +1. 入口配置。 +2. selection stage。 +3. platform shell 分流。 +4. creation / works / runtime service client。 +5. 登录态清理和刷新恢复。 + +验收: + +```bash +npm run typecheck +``` + +### TG-06:创作工作台与结果页 + +负责范围: + +1. 一句话 / 文档 / 空白入口。 +2. Agent 对话和草稿生成。 +3. 结果页编辑模块。 +4. 试玩入口。 + +验收: + +1. 可生成可编辑草稿。 +2. 发布前校验可阻止缺失开场、角色、目标和快捷行动。 +3. 移动端无文本溢出和面板错位。 + +### TG-07:运行时 + +负责范围: + +1. `TextGameRuntimeShell`。 +2. SSE 消费。 +3. turn 流渲染。 +4. 快捷选项和自由输入。 +5. 状态、角色、记忆、历史、存档独立面板。 + +验收: + +1. 每轮正式 UI 状态来自 `turn` 和 `snapshot`。 +2. `raw_text` 不写入业务真相。 +3. 最近一轮可重试。 +4. 平台存档可继续体验。 + +### TG-08:作品架与广场聚合 + +负责范围: + +1. 草稿作品架。 +2. 公开作品聚合。 +3. public work code。 +4. 从公开作品启动 run。 + +验收: + +1. 发布后作品架刷新可见。 +2. 公开作品可启动正式 run。 +3. 不新增独立文字游戏市场。 + +### TG-09:负向扫描与验收 + +扫描范围: + +```bash +rg -n "replay|Replay|回放|分享回放|录制|复盘" src packages server-rs docs .hermes +``` + +验收: + +1. 新增工程代码不得出现 replay 能力。 +2. 文档命中只能在“禁止回放”的语境中出现。 +3. 不出现外部平台账号、论坛、会员、促销、订单、私有存档。 + +--- + +## 20. 验收标准 + +### 20.1 正向验收 + +1. `text-game` 入口可见。 +2. 实现完成后可点击创建。 +3. 一句话创建能生成可编辑剧本草稿。 +4. 文档创建能读取平台资产并生成草稿。 +5. 空白创建能进入结果页。 +6. 结果页可编辑玩家身份、世界、角色、地点、机制、章节和 GM 配置。 +7. 发布前校验能阻止缺少核心字段的作品。 +8. 测试 run 能启动并渲染开场。 +9. 玩家可通过快捷选项或自由输入推进。 +10. 每轮能看到后果摘要。 +11. 运行时记忆能影响后续回合。 +12. 最近一轮可重试。 +13. 存档和继续体验走平台统一 save archive。 +14. 发布后进入平台作品体系。 +15. 移动端和桌面端无文本溢出、遮挡和面板错位。 + +### 20.2 负向验收 + +1. 不新增 replay 路由、DTO、表、按钮或文案。 +2. 不新增外部平台论坛、热榜、会员、充值、订单和运营后台。 +3. 不新增文字游戏私有存档系统。 +4. 不把剧本运行规则写到前端作为正式业务真相。 +5. 不把大文档、大图或二进制写入 SpacetimeDB。 +6. 不使用 `server-node`、Express 或 PostgreSQL 作为新落点。 + +### 20.3 建议验证命令 + +按实际改动范围执行: + +```bash +npm run check:encoding +npm run typecheck + +cd server-rs +cargo test -p shared-contracts +cargo test -p module-text-game +cargo check -p api-server +``` + +涉及 SpacetimeDB schema、reducer、procedure 或 bindings 时,必须执行当前 SpacetimeDB 生成和迁移验证。 + +--- + +## 21. 后续实现前需要确认的问题 + +这些问题不阻塞 PRD 冻结,但进入编码前需要产品确认: + +1. 展示名最终用“幕间”“幕间文字”还是“AI文字游戏”。 +2. V1 是否开放 `remix`。 +3. V1 测试 run 是否消耗光点。 +4. V1 是否允许创作者配置封面图。 +5. V1 是否开放 `allowOutOfCharacterCommand`。 +6. V1 是否需要接入内容审核队列后才允许公开发布。 +7. 作品发现页是否单独展示文字游戏分类入口。 + +无论这些默认值如何选择,三条硬边界不变:只做百梦内的 AI 文字游戏模板,复用平台接口,不迁入外部平台和 replay。 diff --git a/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md new file mode 100644 index 000000000..dae3ca55d --- /dev/null +++ b/docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md @@ -0,0 +1,1787 @@ +# AI 原生视觉小说模板 PRD:TXT 玩法平台化接入 + +更新时间:`2026-05-05` + +## 0. 文档目的 + +这份 PRD 用于把 `E:\Repos\Interactive-fiction-backend` 与 `E:\Repos\Interactive-fiction-frontend` 中已经跑通的 TXT / visual novel 玩法经验,整理成 `Genarrative` 平台内的 `visual-novel` 视觉小说模板落地口径。 + +本次不是迁移外部两个仓库的平台工程,也不是复刻其账号、商城、后台、公开市场、推广、回放或独立存档系统。外部仓库只作为“视觉小说模板玩法”的参考实现来源;所有入口、账号、作品、资产、钱包、存档、发布、广场、任务和运行时访问,都必须使用 `Genarrative` 当前平台接口与 `server-rs + Axum + SpacetimeDB` 后端基线。 + +若本文与历史 TXT 文档冲突,以本文为准: + +- `docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md` +- `docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md` + +--- + +## 1. 一句话定义 + +`visual-novel` 是一个 AI 原生视觉小说模板:创作者可以用想法、文档或空白底稿生成并编辑世界观、角色、场景、剧情阶段和视觉资产,发布后玩家在平台运行时中通过对话、旁白、选项、场景切换和历史重生成体验故事;它只提供视觉小说模板玩法,不新增外部仓库里的平台类能力,并且彻底删除回放功能。 + +--- + +## 2. 参考仓库提炼结论 + +### 2.1 可吸收的玩法经验 + +从外部仓库中保留这些“模板玩法”能力: + +1. `idea` / `document` / `blank` 三种创作起点。 +2. 世界观、玩家身份、角色、场景、剧情阶段、初始剧情和文风配置。 +3. 视觉小说运行时 step 协议:场景切换、旁白、角色对白、转场、选择项、标记与指标变化。 +4. 流式运行时:开始、模型文本片段、结构化 step、完成、错误、结束。 +5. 文本模式、背景图、角色立绘、场景音乐和移动端适配的视觉小说阅读界面。 +6. 历史记录查看和基于历史节点的重生成。 +7. 存档 / 继续体验能力,但只能接入平台统一存档,不保留 TXT 私有存档接口。 +8. 属性面板作为模板可选能力,只能由平台配置或白名单开启,不默认扩散为所有作品规则。 + +### 2.2 必须删除或禁止迁入的内容 + +这些内容不得进入本模板 PRD 和后续实现: + +1. 回放、分享回放、回放列表、回放编译、回放进度条、回放模式、回放存储目录和任何 `/replay` API。 +2. 外部仓库的 D1 / R2 平台表、平台账户体系、订单、会员、订阅、促销、banner、标签管理、后台管理、小游戏平台分发、公开游戏市场、活动配置和独立支付逻辑。 +3. 外部仓库的独立作品库、独立浏览历史、独立云存档页、独立分享页和独立平台详情页。 +4. 旧 `server-node`、Express、PostgreSQL 或 Cloudflare Worker 平台逻辑作为新实现目标。 +5. 把视觉小说玩法做成第二套平台壳层,绕过现有创作中心、作品架、广场、钱包、资产和存档接口。 + +### 2.3 参考文件映射 + +外部仓库文件只作为玩法语义参考,不能作为目录结构迁入依据: + +| 外部参考 | 可参考内容 | Genarrative 落点 | +| --- | --- | --- | +| `interface/routes/visual.js` | 视觉小说创作和运行时路由语义 | `server-rs/crates/api-server/src/visual_novel.rs` | +| `interface/handlers/visual/sendActionStream.js` | 流式动作推进事件 | Axum SSE handler + `shared-contracts` typed envelope | +| `interface/handlers/visual/getHistory.js` | 历史记录读取 | 平台 runtime history API | +| `interface/handlers/visual/saves.js` | 存档语义 | 平台统一 `profile/save-archives` | +| `services/visual/gameLogic.js` | step 解析、会话状态推进 | `server-rs/crates/module-visual-novel` | +| `services/visual/storyGeneration.js` | 创作底稿生成语义 | `platform-agent` / `platform-llm` + visual novel Tool | +| `prompts/visual.gm.system/1.0.1/body.js` | 视觉小说 GM 输出结构经验 | 新 prompt 必须适配 Genarrative 契约,不原样照搬平台规则 | +| `src/page/Galgame.tsx` | 运行时界面结构 | `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` | +| `src/hooks/galgame/useGalgameController.ts` | 前端运行时状态组织 | visual novel runtime hooks | +| `src/page/GameSettingsEditor.tsx` | 创作编辑器模块划分 | visual novel result / workspace 组件 | + +--- + +## 3. 产品边界 + +### 3.1 必须完成的模板闭环 + +1. 平台创作中心展示 `视觉小说` 入口,并在实现完成后从 `open: false` 改为 `open: true`。 +2. 创作者可选择 `idea`、`document`、`blank` 三种起点创建视觉小说底稿。 +3. Agent 或表单工作台生成 / 编辑同一份 `VisualNovelResultDraft`。 +4. 结果页可编辑世界观、角色、场景、剧情阶段、资产和运行时配置。 +5. 结果页可进入测试运行时。 +6. 作品可保存为平台 work profile,并通过现有发布链路进入平台作品体系。 +7. 玩家从平台作品入口启动视觉小说 run。 +8. 玩家可提交自由行动或选择项,后端流式返回结构化 step。 +9. 玩家可查看历史、从最近历史节点重生成、保存和继续体验。 +10. 全链路不出现回放入口、回放接口、回放表、回放字段或分享回放能力。 + +### 3.2 明确不做 + +1. 不迁移外部平台首页、详情页、作品市场、浏览历史、账号、钱包、运营、后台和推广能力。 +2. 不新增第二套支付点数系统;如产生消耗,走平台钱包流水。 +3. 不新增第二套资产系统;图片、音乐、文档附件走平台资产接口。 +4. 不新增第二套存档系统;视觉小说存档走平台统一 save archive。 +5. 不新增第二套发布、广场、作品架或分享体系。 +6. 不为视觉小说模板设计排行榜、赛季、活动任务、抽卡、会员权益等非模板玩法功能。 +7. 不把外部仓库的回放功能改名为历史、录屏、分享片段或故事录像。 +8. 不在 UI 面板默认展示大段规则说明、字段解释或模板内部协议。 + +### 3.3 历史 TXT 文档口径修正 + +历史文档中“原样迁入”“完整复制外部 TXT 模式”“替换为 server-node 能力”等表述全部降级为历史背景。后续实现必须遵循本文: + +1. 保留视觉小说玩法经验,不保留外部平台工程。 +2. 使用 `server-rs + Axum + SpacetimeDB`。 +3. 使用 Genarrative shared contracts。 +4. 删除回放。 +5. 存档、发布、资产、钱包和作品入口完全接平台能力。 + +--- + +## 4. 用户主流程 + +### 4.1 创作者流程 + +```text +平台创作中心 +-> 选择视觉小说 +-> 选择创作起点:一句话 / 文档 / 空白 +-> 进入 visual-novel-agent-workspace +-> Agent 生成或补齐底稿 +-> 进入 visual-novel-result +-> 编辑世界观、角色、场景、剧情阶段、资产和运行时配置 +-> 点击试玩 +-> 进入 visual-novel-runtime 测试 run +-> 返回结果页继续调整 +-> 保存 / 发布到平台作品体系 +``` + +### 4.2 玩家流程 + +```text +平台作品入口 +-> 打开视觉小说作品 +-> 创建 runtime run +-> 阅读背景、旁白、对白 +-> 点击选项或输入行动 +-> 后端流式生成下一段故事 step +-> 查看历史 / 重生成 / 存档 / 继续体验 +``` + +### 4.3 恢复流程 + +```text +平台草稿 / 作品架 / 统一存档 +-> 读取 visual-novel work profile 或 save archive +-> 恢复 visual-novel-result 或 visual-novel-runtime +-> 继续编辑或游玩 +``` + +恢复入口只负责读取平台统一状态,不引入 TXT 私有 `saveId` 路由。 + +--- + +## 5. 创作模式 + +### 5.1 一句话创建 `idea` + +用户输入一段自然语言,Agent 生成完整底稿。首轮至少产出: + +1. 作品标题。 +2. 一句话简介。 +3. 世界观摘要。 +4. 玩家身份。 +5. 3 到 6 个主要角色。 +6. 3 到 8 个可用场景。 +7. 3 到 6 个剧情阶段。 +8. 初始场景、初始旁白和第一轮可选行动。 + +### 5.2 文档创建 `document` + +用户上传 `.txt`、`.md`、`.docx` 或平台已有文档资产后创建底稿。 + +要求: + +1. 文档必须先走平台资产上传。 +2. 后端只持久化资产引用、摘要和结构化提取结果,不把原文大段重复写入玩法表。 +3. Agent 从文档中提取人物、场景、核心冲突、章节节奏和文风。 +4. 文档中缺失的角色视觉描述、场景视觉描述可以由 Agent 补齐,但必须标记来源为 `agent_inferred`。 +5. 文档过长时先做摘要和分段索引,不把整篇文档塞入运行时 prompt。 + +### 5.3 空白创建 `blank` + +用户直接进入结果页,用空白底稿手动编辑。 + +空白底稿默认值: + +1. 标题:空字符串。 +2. 世界观、角色、场景、剧情阶段均为空数组或空字段。 +3. 运行时配置使用模板默认值。 +4. 发布前必须通过完整校验。 + +--- + +## 6. 草稿契约 + +### 6.1 `VisualNovelResultDraft` + +新增 TypeScript 契约建议: + +```ts +export interface VisualNovelResultDraft { + profileId: string | null; + workTitle: string; + workDescription: string; + workTags: string[]; + coverImageSrc: string | null; + sourceMode: 'idea' | 'document' | 'blank'; + sourceAssetIds: string[]; + world: VisualNovelWorldDraft; + characters: VisualNovelCharacterDraft[]; + scenes: VisualNovelSceneDraft[]; + storyPhases: VisualNovelStoryPhaseDraft[]; + opening: VisualNovelOpeningDraft; + runtimeConfig: VisualNovelRuntimeConfigDraft; + publishReady: boolean; + validationIssues: VisualNovelValidationIssue[]; + updatedAt: string; +} +``` + +### 6.2 世界观 + +```ts +export interface VisualNovelWorldDraft { + title: string; + summary: string; + background: string; + premise: string; + literaryStyle: string; + playerRole: string; + defaultTone: string; +} +``` + +字段要求: + +1. `summary` 用于作品卡和运行时上下文摘要,建议 40 到 120 字。 +2. `background` 可长一些,用于 GM prompt,但发布前应控制在后端配置上限内。 +3. `playerRole` 必须说明玩家在故事中的身份,不允许为空。 +4. `literaryStyle` 只描述叙事风格,不写平台规则。 + +### 6.3 角色 + +```ts +export interface VisualNovelCharacterDraft { + characterId: string; + name: string; + gender: string | null; + role: 'protagonist' | 'main' | 'supporting' | 'antagonist' | 'background'; + appearance: string; + personality: string; + tone: string; + background: string; + relationshipToPlayer: string; + imageAssets: VisualNovelCharacterImageAsset[]; + defaultExpression: string | null; + isPlayerVisible: boolean; +} +``` + +校验: + +1. 至少 `1` 个非玩家主要角色。 +2. 角色 `name` 不能为空。 +3. 角色立绘可为空,但若填写图片引用,必须是平台资产或受控生成图 URL。 +4. `role = protagonist` 只能用于玩家或核心视角角色。 + +### 6.4 场景 + +```ts +export interface VisualNovelSceneDraft { + sceneId: string; + name: string; + description: string; + backgroundImageSrc: string | null; + musicSrc: string | null; + ambientSoundSrc: string | null; + availability: 'opening' | 'always' | 'phase_locked'; + phaseIds: string[]; +} +``` + +校验: + +1. 至少 `1` 个 opening 场景。 +2. 场景名不能为空。 +3. 背景图和音乐走平台资产引用。 +4. `phase_locked` 场景必须绑定至少一个 `phaseId`。 + +### 6.5 剧情阶段 + +```ts +export interface VisualNovelStoryPhaseDraft { + phaseId: string; + title: string; + goal: string; + summary: string; + entryCondition: string; + exitCondition: string; + sceneIds: string[]; + characterIds: string[]; + suggestedChoices: string[]; +} +``` + +校验: + +1. 至少 `1` 个剧情阶段。 +2. 第一阶段必须能从 opening 进入。 +3. 每个阶段至少绑定一个场景或角色。 +4. `suggestedChoices` 只作为 GM 候选,不强制每轮展示。 + +### 6.6 开场 + +```ts +export interface VisualNovelOpeningDraft { + sceneId: string | null; + narration: string; + speakerCharacterId: string | null; + firstDialogue: string | null; + initialChoices: VisualNovelChoiceDraft[]; +} +``` + +发布前要求: + +1. `sceneId` 必须指向有效场景。 +2. `narration` 不能为空。 +3. `initialChoices` 至少 `2` 个,至多 `4` 个。 + +### 6.7 运行配置 + +```ts +export interface VisualNovelRuntimeConfigDraft { + textModeEnabled: boolean; + defaultTextMode: boolean; + maxHistoryEntries: number; + maxAssistantStepCountPerTurn: number; + allowFreeTextAction: boolean; + allowHistoryRegeneration: boolean; + attributePanelMode: 'off' | 'platform_whitelist' | 'template_config'; + saveArchiveEnabled: boolean; +} +``` + +默认值: + +1. `textModeEnabled = true` +2. `defaultTextMode = false` +3. `maxHistoryEntries = 80` +4. `maxAssistantStepCountPerTurn = 8` +5. `allowFreeTextAction = true` +6. `allowHistoryRegeneration = true` +7. `attributePanelMode = 'off'` +8. `saveArchiveEnabled = true` + +--- + +## 7. 创作 Agent 契约 + +视觉小说可以接入通用创意 Agent,但目标模板必须明确为 `visual-novel`。若复用 `creative-agent`,其模板选择阶段必须只把视觉小说作为当前入口目标,不自动扩展到外部平台功能。 + +### 7.1 Session Snapshot + +```ts +export interface VisualNovelAgentSessionSnapshot { + sessionId: string; + ownerUserId: string; + sourceMode: 'idea' | 'document' | 'blank'; + status: 'collecting' | 'drafting' | 'ready' | 'failed'; + messages: VisualNovelAgentMessage[]; + draft: VisualNovelResultDraft | null; + pendingAction: VisualNovelAgentPendingAction | null; + createdAt: string; + updatedAt: string; +} +``` + +### 7.2 Action + +```ts +export type VisualNovelAgentActionKind = + | 'generate_draft' + | 'patch_world' + | 'patch_character' + | 'patch_scene' + | 'patch_story_phase' + | 'generate_scene_image' + | 'generate_character_image' + | 'compile_work_profile'; +``` + +约束: + +1. `compile_work_profile` 只能生成平台 work profile,不发布作品。 +2. 发布必须走平台 works publish 接口。 +3. 图片生成 action 必须写回平台资产引用。 +4. action 不允许创建回放或分享片段。 + +### 7.3 流式事件 + +```ts +export type VisualNovelAgentStreamEvent = + | { type: 'start'; sessionId: string } + | { type: 'phase'; phase: 'perception' | 'reasoning' | 'drafting' | 'reflection' | 'finalizing' } + | { 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' }; +``` + +--- + +## 8. 运行时契约 + +### 8.1 Run Snapshot + +```ts +export interface VisualNovelRunSnapshot { + runId: string; + ownerUserId: string; + profileId: string; + mode: 'test' | 'play'; + status: 'active' | 'completed' | 'failed'; + currentSceneId: string | null; + currentPhaseId: string | null; + visibleCharacterIds: string[]; + flags: Record; + metrics: Record; + history: VisualNovelHistoryEntry[]; + availableChoices: VisualNovelChoiceDraft[]; + textModeEnabled: boolean; + createdAt: string; + updatedAt: string; +} +``` + +### 8.2 Action Request + +```ts +export interface VisualNovelRuntimeActionRequest { + actionKind: 'choice' | 'free_text' | 'continue'; + choiceId?: string; + text?: string; + clientEventId: string; +} +``` + +校验: + +1. `choice` 必须传有效 `choiceId`。 +2. `free_text` 必须在作品配置允许时可用。 +3. `continue` 用于不需要玩家输入的下一段推进。 +4. `clientEventId` 用于幂等,重复请求不得重复扣费或重复写历史。 + +### 8.3 Runtime Step + +```ts +export type VisualNovelRuntimeStep = + | VisualNovelSceneChangeStep + | VisualNovelNarrationStep + | VisualNovelDialogueStep + | VisualNovelTransitionStep + | VisualNovelChoiceStep + | VisualNovelFlagStep + | VisualNovelMetricStep; +``` + +```ts +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: 'fade' | 'cut' | 'flash' | 'none'; + text: string | null; +} + +export interface VisualNovelChoiceStep { + type: 'choice'; + choices: VisualNovelChoiceDraft[]; +} + +export interface VisualNovelFlagStep { + type: 'flag'; + key: string; + value: string | number | boolean; +} + +export interface VisualNovelMetricStep { + type: 'metric'; + key: string; + delta: number; +} +``` + +### 8.4 History Entry + +```ts +export interface VisualNovelHistoryEntry { + entryId: string; + runId: string; + turnIndex: number; + source: 'player' | 'assistant' | 'system'; + actionText: string | null; + steps: VisualNovelRuntimeStep[]; + snapshotBeforeHash: string | null; + snapshotAfterHash: string | null; + createdAt: string; +} +``` + +历史用于查看和重生成,不是回放。历史不提供可分享播放页,不保存可独立播放的回放包。 + +### 8.5 SSE Envelope + +运行时动作接口返回 SSE: + +```ts +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' }; +``` + +要求: + +1. 前端只能以 `step` 作为正式 UI 状态来源。 +2. `raw_text` 只用于流式观感和调试,不作为最终历史真相。 +3. `complete` 必须返回后端落库后的 run snapshot。 +4. 错误时保留已展示的可确认 step,并允许用户重试当前动作。 + +--- + +## 9. 平台 API 设计 + +路由必须遵循现有玩法接口风格:创作链走 `/api/creation/visual-novel/*`,运行链走 `/api/runtime/visual-novel/*`。不得新增 `/visual/replay`、`/galgame/replay`、`/txt/replay` 或任何同义回放路由。 + +### 9.1 创作 session + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `POST` | `/api/creation/visual-novel/sessions` | 创建视觉小说创作 session | +| `GET` | `/api/creation/visual-novel/sessions/{session_id}` | 读取 session snapshot | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages` | 非流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/messages/stream` | 流式发送创作消息 | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/actions` | 执行结构化创作 action | +| `POST` | `/api/creation/visual-novel/sessions/{session_id}/compile` | 编译为 work profile 草稿 | + +### 9.2 作品草稿与发布 + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `GET` | `/api/creation/visual-novel/works` | 读取当前用户视觉小说作品草稿列表 | +| `GET` | `/api/creation/visual-novel/works/{profile_id}` | 读取作品详情 | +| `PUT/PATCH` | `/api/creation/visual-novel/works/{profile_id}` | 更新作品草稿 | +| `DELETE` | `/api/creation/visual-novel/works/{profile_id}` | 删除未发布草稿或用户自有作品 | +| `POST` | `/api/creation/visual-novel/works/{profile_id}/publish` | 发布到平台作品体系 | + +### 9.3 运行时 + +| 方法 | 路由 | 用途 | +| --- | --- | --- | +| `GET` | `/api/runtime/visual-novel/gallery` | 读取平台聚合后的视觉小说公开作品列表 | +| `POST` | `/api/runtime/visual-novel/works/{profile_id}/runs` | 创建测试或正式 run | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}` | 读取 run snapshot | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/actions/stream` | 提交选择 / 自由行动并流式推进 | +| `GET` | `/api/runtime/visual-novel/runs/{run_id}/history` | 读取当前 run 历史 | +| `POST` | `/api/runtime/visual-novel/runs/{run_id}/regenerate` | 从历史节点重生成 | + +### 9.4 存档 + +视觉小说不设计私有 save API。统一使用平台 save archive: + +```text +POST /api/runtime/profile/save-archives +POST /api/runtime/profile/save-archives/{world_key} +GET /api/runtime/profile/save-archives +``` + +视觉小说写入 `archiveState.runtimeKind = 'visual-novel'`,并保存: + +1. `profileId` +2. `runId` +3. `currentSceneId` +4. `currentPhaseId` +5. `historyCursor` +6. `snapshotHash` +7. 必要的继续体验状态 + +### 9.5 明确禁止的接口 + +后续实现和测试必须确认不存在: + +```text +/api/creation/visual-novel/replay +/api/runtime/visual-novel/replay +/api/runtime/visual-novel/replays +/api/visual/replay +/api/galgame/replay +/api/txt/replay +``` + +--- + +## 10. 后端 DDD 落点 + +### 10.1 crate 职责 + +| crate / 层 | 职责 | +| --- | --- | +| `server-rs/crates/module-visual-novel` | 纯领域规则:草稿校验、step 解析、run 状态推进、历史重生成边界 | +| `server-rs/crates/shared-contracts` | DTO、请求响应、SSE envelope、草稿和运行时契约 | +| `server-rs/crates/spacetime-module` | 表、reducer、procedure、migration 和事务编排 | +| `server-rs/crates/spacetime-client` | api-server 到 SpacetimeDB 的 typed facade | +| `server-rs/crates/api-server` | Axum 路由、鉴权、SSE、LLM 编排、资产和钱包 facade 调用 | +| `server-rs/crates/platform-llm` | 视觉小说创作和运行时 GM 的模型调用 | +| `server-rs/crates/platform-oss` | 文档、图片、音乐等资产对象读写 | +| `server-rs/crates/platform-agent` | 如复用创意 Agent,负责 Agent 编排和工具注册 | + +### 10.2 领域规则 + +`module-visual-novel` 至少提供: + +1. `validate_visual_novel_draft(draft)`:发布前校验。 +2. `compile_visual_novel_profile(draft)`:草稿转 work profile 领域结构。 +3. `parse_runtime_steps(model_output)`:把模型输出解析为 typed steps。 +4. `apply_runtime_steps(snapshot, steps)`:推进 run snapshot。 +5. `build_runtime_prompt_context(profile, snapshot, action)`:构造最小 GM 上下文。 +6. `regenerate_from_history(snapshot, history_entry_id)`:历史重生成状态回滚。 +7. `build_save_archive_state(snapshot)`:构造平台存档状态。 + +### 10.3 SpacetimeDB 表建议 + +新增表必须同步 `migration.rs`、表目录和 bindings: + +| 表 | 用途 | +| --- | --- | +| `visual_novel_agent_session` | 创作 session 主表 | +| `visual_novel_agent_message` | 创作消息和模型回复 | +| `visual_novel_work_profile` | 视觉小说作品草稿 / 发布 profile | +| `visual_novel_runtime_run` | 玩家或测试 run | +| `visual_novel_runtime_history_entry` | 运行时历史 | +| `visual_novel_runtime_event` | 可审计事件,不用于回放 | + +不得新增: + +1. `visual_novel_replay` +2. `visual_novel_replay_segment` +3. `visual_replay` +4. `galgame_replay` +5. 任意 `*_private_save` 私有存档表 + +### 10.4 LLM 与副作用 + +1. 创作底稿生成和运行时 GM 输出由 `api-server` 编排 `platform-llm`,SpacetimeDB reducer 不直接调用 LLM。 +2. 图片和音乐生成 / 上传由 `api-server` 编排平台资产服务,不把二进制写入 SpacetimeDB。 +3. 消耗光点时走平台钱包流水;不得迁入外部仓库积分表。 +4. prompt 必须输出可解析 JSON step 或工具调用结果;解析失败时由后端 repair,不让前端猜测业务状态。 + +--- + +## 11. 前端接入 + +### 11.1 入口配置 + +当前 `src/config/newWorkEntryConfig.ts` 已存在: + +```ts +{ + id: 'visual-novel', + title: '视觉小说', + subtitle: '敬请期待', + badge: '敬请期待', + visible: true, + open: false, +} +``` + +实现完成后更新为: + +```ts +{ + id: 'visual-novel', + title: '视觉小说', + subtitle: 'AI 生成可玩的视觉小说', + badge: '新玩法', + visible: true, + open: true, +} +``` + +文案可在上线前按平台风格微调,但 UI 不展示大段说明。 + +### 11.2 SelectionStage + +在 `src/components/platform-entry/platformEntryTypes.ts` 增加: + +```ts +| 'visual-novel-agent-workspace' +| 'visual-novel-result' +| 'visual-novel-runtime' +| 'visual-novel-gallery-detail' +``` + +如创建过程需要独立生成页,可增加: + +```ts +| 'visual-novel-generating' +``` + +### 11.3 流程分流 + +在 `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`: + +1. 删除或替换当前 `visual-novel` 的 early return。 +2. `handleCreationHubCreateType('visual-novel')` 进入 `visual-novel-agent-workspace`。 +3. 新增 `openVisualNovelAgentWorkspace()`。 +4. 新增 `leaveVisualNovelFlow()`。 +5. 新增 `submitVisualNovelMessage()`。 +6. 新增 `executeVisualNovelAction()`。 +7. 新增 result / runtime / gallery detail 渲染分支。 +8. 退出登录时清空视觉小说私有 session、draft、run 状态。 + +### 11.4 组件建议 + +```text +src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx +src/components/visual-novel-result/VisualNovelResultView.tsx +src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx +src/components/visual-novel-runtime/useVisualNovelRuntimeController.ts +src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx +src/components/visual-novel-runtime/VisualNovelSavePanel.tsx +src/components/visual-novel-runtime/VisualNovelSettingsPanel.tsx +src/components/visual-novel-runtime/VisualNovelAttributePanel.tsx +``` + +### 11.5 service client + +```text +src/services/visual-novel-creation/index.ts +src/services/visual-novel-creation/visualNovelCreationClient.ts +src/services/visual-novel-works/index.ts +src/services/visual-novel-works/visualNovelWorksClient.ts +src/services/visual-novel-runtime/index.ts +src/services/visual-novel-runtime/visualNovelRuntimeClient.ts +``` + +service client 要复用现有请求封装、鉴权和错误提示风格,不引入外部仓库 API client。 + +--- + +## 12. UI 与交互要求 + +### 12.1 工作台 + +工作台只展示创作需要的输入和状态: + +1. 创作起点选择:一句话、文档、空白。 +2. 文本输入框和文档上传。 +3. Agent 对话区域。 +4. 底稿生成进度。 +5. 进入结果页按钮。 + +不展示: + +1. 平台规则说明。 +2. 外部仓库平台功能介绍。 +3. 回放、分享回放、录像、复盘入口。 +4. 大段字段解释。 + +### 12.2 结果页 + +结果页按模块编辑: + +1. 基本信息:标题、简介、标签、封面。 +2. 世界观:摘要、背景、玩家身份、文风。 +3. 角色:名称、身份、性格、外貌、语气、立绘。 +4. 场景:名称、描述、背景图、音乐、可用阶段。 +5. 剧情阶段:目标、进入条件、退出条件、候选选项。 +6. 运行配置:文本模式、自由输入、历史重生成、属性面板模式。 + +按钮打开历史、设置、角色编辑、场景编辑等复杂内容时,必须使用独立弹层 / 抽屉 / 全屏面板,不在当前卡片下方展开长内容。 + +### 12.3 运行时 + +运行时首屏是视觉小说体验,不是说明页。 + +必须具备: + +1. 背景区域。 +2. 角色立绘区域。 +3. 文本框。 +4. 选择项区域。 +5. 自由输入区域。 +6. 历史按钮。 +7. 存档按钮。 +8. 设置按钮。 +9. 文本模式切换。 + +移动端要求: + +1. 文本框和选择项不遮挡关键按钮。 +2. 历史、存档、设置使用底部抽屉或全屏面板。 +3. 横屏和竖屏都可读。 +4. 长角色名、长选项文本必须换行或截断,不撑破按钮。 +5. 立绘缺失时用安静占位,不写说明文案。 + +### 12.4 回放删除验收 + +UI 中不得出现: + +1. 回放。 +2. 分享回放。 +3. 录制。 +4. 播放记录。 +5. 回放列表。 +6. 回放进度条。 +7. 复制回放链接。 + +历史记录只叫“历史”,用于当前 run 内查看和重生成,不生成可分享播放页面。 + +--- + +## 13. 平台能力复用 + +### 13.1 作品体系 + +视觉小说作品必须接入平台现有 work profile / works / gallery 聚合方式。PRD 不新增平台作品系统,只要求视觉小说 profile 提供平台聚合所需的最小摘要: + +```ts +export interface VisualNovelWorkSummary { + runtimeKind: 'visual-novel'; + profileId: string; + ownerUserId: string; + title: string; + description: string; + coverImageSrc: string | null; + tags: string[]; + publishStatus: 'draft' | 'published'; + updatedAt: string; +} +``` + +### 13.2 资产 + +1. 文档、封面、场景图、角色立绘、音乐都必须进入平台资产系统。 +2. 视觉小说表只保存 `assetId`、`readUrl`、用途和元数据。 +3. 前端不把大 Data URL 写入长期状态。 + +### 13.3 钱包 + +V1 消耗口径: + +1. 创作草稿生成可配置为免费或按平台统一创作 Agent 计费。 +2. 运行时每次 AI 推进如需扣费,走平台 `profile_wallet_ledger`。 +3. 幂等键使用 `clientEventId` 或后端 action id。 +4. 不迁入外部仓库积分、订单、会员或订阅表。 + +具体单价由运营配置决定,本文不新增视觉小说私有计费系统。 + +### 13.4 统一存档 + +视觉小说存档接入平台统一 save archive。作品内可展示“存档”按钮,但背后读写平台存档接口,不保留外部 TXT 五槽私有 save API。 + +V1 默认提供平台统一存档能力;如果平台存档 UI 当前按槽位展示,则视觉小说可在平台槽位中写入 `runtimeKind = 'visual-novel'` 的 archive state。 + +--- + +## 14. Prompt 与模型输出约束 + +### 14.1 创作 Prompt + +创作 prompt 目标是生成 `VisualNovelResultDraft`,不能输出外部平台配置。 + +必须要求模型: + +1. 输出中文视觉小说内容。 +2. 补齐世界、角色、场景、剧情阶段和开场。 +3. 每个角色有可生成立绘的外貌描述。 +4. 每个场景有可生成背景图的视觉描述。 +5. 不输出回放、分享、商城、平台活动或运营字段。 +6. 不输出 UI 规则说明。 + +### 14.2 运行时 GM Prompt + +运行时 prompt 目标是生成 `VisualNovelRuntimeStep[]`。 + +必须要求模型: + +1. 每轮最多输出 `maxAssistantStepCountPerTurn` 个 step。 +2. 关键剧情变化用 `flag` 或 `metric` 明确表达。 +3. 有选择时输出 `choice` step。 +4. 场景变化必须输出 `scene_change`。 +5. 不输出 Markdown 规则说明。 +6. 不输出回放元数据。 + +### 14.3 Repair + +解析失败时: + +1. 后端先进行结构化 repair。 +2. repair 仍失败则返回可重试错误。 +3. 不让前端根据 raw text 自行生成业务 step。 + +--- + +## 15. 分阶段落地 + +### Phase 0:文档和口径冻结 + +1. 新增本文。 +2. 在旧 TXT 文档顶部标注冲突时以本文为准。 +3. 文档索引加入视觉小说模板 PRD。 + +### Phase 1:创作入口和底稿 + +1. 打开 `visual-novel` 入口。 +2. 增加 selection stage。 +3. 新增 shared contracts。 +4. 新增创作 session API。 +5. 支持 `idea` / `document` / `blank` 创建底稿。 +6. 新增 `VisualNovelResultView`。 + +### Phase 2:作品与测试运行 + +1. 草稿保存为 visual novel work profile。 +2. 接平台作品架。 +3. 支持测试 run。 +4. 支持场景、旁白、对白、选项 step 渲染。 + +### Phase 3:正式运行闭环 + +1. 正式 run。 +2. 动作流式推进。 +3. 历史记录。 +4. 历史重生成。 +5. 平台统一存档 / 继续体验。 + +### Phase 4:发布收口 + +1. 发布到平台作品体系。 +2. 接平台公开聚合。 +3. 移动端 UI 验收。 +4. 回放和外部平台功能负向扫描。 + +--- + +## 16. 可并行任务拆分 + +本节把视觉小说模板落地拆成可多人并行推进的任务包。每个任务包必须遵守同一条硬边界:只做 `visual-novel` 模板玩法,复用 Genarrative 平台接口,不迁入外部平台功能,不实现回放。 + +### 16.1 并行批次总览 + +| 批次 | 可并行任务 | 进入条件 | 汇合点 | +| --- | --- | --- | --- | +| Batch 0 | `VN-00` | PRD 已冻结 | 所有人以本文为唯一口径 | +| Batch 1 | `VN-01`、`VN-02`、`VN-03`、`VN-04` | `VN-00` 完成 | 契约、领域、UI 骨架、Prompt 口径可对齐 | +| Batch 2 | `VN-05`、`VN-06`、`VN-07`、`VN-08` | `VN-01` 产出契约初稿;`VN-02` 产出表草案 | 后端 API、前端创作、前端运行时可联调 | +| Batch 3 | `VN-09`、`VN-10`、`VN-11` | `VN-05`、`VN-06`、`VN-07`、`VN-08` 主链路可跑 | 发布、存档、广场、负向扫描收口 | +| Batch 4 | `VN-12`、`VN-13` | 主链路完成 | 全链路验收和文档同步 | + +并行规则: + +1. 同一批次任务可以同时开工。 +2. 不同任务必须尽量使用不重叠写入边界;确需改同一文件时,先约定编辑区域。 +3. `VN-01` 是所有工程任务的契约源头;若契约发生破坏性变更,需要通知 `VN-02` 到 `VN-09`。 +4. `VN-11` 负向扫描不是最后才做,Batch 1 开始后每个任务都要自查,但最终由 `VN-11` 统一验收。 + +### 16.2 并行任务具体要求速查表 + +| 任务 | 可并行窗口 | 输入依赖 | 必须完成 | 禁止事项 | 验收口径 | +| --- | --- | --- | --- | --- | --- | +| `VN-00` 口径冻结 | Batch 0 | 本 PRD、旧 TXT 文档、Hermes 决策记录 | 冻结 `visual-novel` 只做模板玩法、平台接口、删除回放;标注旧文档冲突口径 | 不再使用“原样迁入外部平台工程”作为实现目标 | 文档和 Hermes 记录明确三条硬边界;`npm run check:encoding` 通过 | +| `VN-01` 契约与领域 | Batch 1 | PRD 第 6 到 8 章契约 | TS contracts、Rust shared-contracts、`module-visual-novel`、领域单测 | 不写 HTTP、DB reducer、LLM、UI;不出现 replay 类型 | TS/Rust 契约一致;草稿校验、step 解析、状态推进、重生成测试通过 | +| `VN-02` SpacetimeDB 与 facade | Batch 1 | `VN-01` 契约草案、SpacetimeDB 约束文档 | 表、reducer/procedure、migration、表目录、bindings、`spacetime-client` facade | 不手改 bindings 绕过 schema;不建 replay 表或私有 save 表 | schema 可生成;表目录写明 event 不是回放;`cargo check -p spacetime-client` 通过 | +| `VN-03` Prompt / LLM 工具 | Batch 1 | `VN-01` draft 与 step 契约 | 创作 prompt、运行 GM prompt、repair prompt、工具参数 | 不照搬外部平台规则、扣费规则、回放规则;不让前端猜业务 step | fixture 输出可解析;坏格式进入 repair 或可重试错误 | +| `VN-04` 前端 UI 骨架 | Batch 1 | PRD 第 12 章 UI 要求 | workspace、result、runtime mock 骨架;移动端优先布局;独立面板模式 | 不接真实 API;不写规则长文;不出现回放入口 | 桌面 / 移动基础布局可检查;长文本不撑破按钮 | +| `VN-05` API Server | Batch 2 | `VN-01`、`VN-02`,真实 LLM 依赖 `VN-03` | creation、works、runtime、history、regenerate 路由;SSE;平台 save archive 接入 | 不把领域规则塞 handler;不新增 replay 路由;不新增私有 save API | `/api/creation/visual-novel/*` 和 `/api/runtime/visual-novel/*` 可 smoke;`cargo check -p api-server` 通过 | +| `VN-06` 前端 service 与入口 | Batch 2 | `VN-01` TS 契约草案;mock 可先行 | service client、selection stage、入口分流、壳层挂载、登录态清理 | 不设计表单细节;不写 runtime UI 细节;不绕过平台入口 | 创作中心可进入 workspace;service 路由与 PRD 一致;`npm run typecheck` 通过 | +| `VN-07` 创作工作台与结果页 | Batch 2 | `VN-04`、`VN-06`,真实生成依赖 `VN-05` | `idea` / `document` / `blank`、Agent 进度、结果页表单、保存草稿、测试 run 入口 | 不做正式玩家 runtime;不新增说明页;复杂内容不在卡片下展开 | 三种创建起点可用;草稿写入 `VisualNovelResultDraft`;发布校验 issue 可见 | +| `VN-08` 前端运行时 | Batch 2 | `VN-04`、`VN-06`,真实运行依赖 `VN-05` | runtime shell、SSE 消费、step 渲染、历史、设置、存档、重生成 | 不做回放 UI;历史不生成分享播放页;`raw_text` 不作为业务真相 | typed step 可渲染;历史与重生成可用;save archive 可继续体验 | +| `VN-09` 作品架 / 广场 / 发布 | Batch 3 | `VN-05` works/gallery API、`VN-06` 壳层 | work summary 聚合、作品架、公开聚合、public work code、详情跳转 | 不新增独立视觉小说市场;不迁入外部平台详情页;不做分享回放 | 发布后作品架可见;公开作品可启动 run;聚合 key 不冲突 | +| `VN-10` 资产与文档输入 | Batch 3 | `VN-05` action/API、平台资产接口 | 文档资产读取、封面/场景/角色/音乐资产引用、可选图片生成 action | 不迁入外部 R2;不存大 Data URL;不绕过资产鉴权 | 文档创建使用资产引用;SpacetimeDB 不保存二进制或大 base64 | +| `VN-11` 负向扫描 | Batch 1 到发布前 | 所有任务增量改动 | 扫描 replay 和外部平台功能;补负向测试或脚本清单 | 不把历史误判为回放;不做大范围重构 | 新工程代码无 replay;外部平台账号/订单/会员/后台等未误入 | +| `VN-12` 全链路验收 | Batch 4 | `VN-05` 到 `VN-10` 主链路完成 | 创作、结果页、测试 run、发布、正式 run、历史、重生成、存档联调 | 不跳过移动端;不只测 mock;不忽略负向验收 | 全链路可跑;前端 typecheck、后端相关 cargo 检查、编码检查通过 | +| `VN-13` 文档与交接 | Batch 4 | 实际工程落地结果 | PRD、技术方案、表目录、Hermes 决策 / 踩坑同步 | 不让旧 TXT 文档重新成为实现口径;不留下过期接口描述 | 新开发者可按最新文档维护;文档与代码一致 | + +每个任务的交付回复必须包含: + +1. 任务编号和 owner。 +2. 改动文件清单。 +3. 已完成的“必须完成”项。 +4. 明确声明未触碰的“禁止事项”。 +5. 已执行验证命令和结果。 +6. 剩余风险或依赖其他任务的联调点。 + +### 16.3 任务详情 + +### VN-00:任务准备与口径冻结 + +负责范围: + +1. 确认本文、旧 TXT PRD 顶部提示和 Hermes 决策记录都已同步。 +2. 确认 `visual-novel` 的产品边界是模板玩法,不是迁移外部平台。 +3. 确认回放删除是全链路强约束。 + +主要文件: + +1. `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` +2. `docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md` +3. `docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md` +4. `.hermes/shared-memory/decision-log.md` + +交付物: + +1. 本 PRD 保持最新。 +2. 旧文档冲突口径已经标注。 +3. 后续任务不再引用“原样迁入外部平台工程”。 + +验收: + +1. 文档中明确写出“不迁入外部平台功能”和“删除回放”。 +2. `npm run check:encoding` 通过。 + +### VN-01:共享契约与领域模型 + +负责范围: + +1. 新增前端 TypeScript shared contracts。 +2. 新增 Rust `shared-contracts` DTO。 +3. 新增 `module-visual-novel` 纯领域 crate。 +4. 实现草稿校验、运行时 step 解析、run snapshot 推进、历史重生成边界和存档状态构造。 + +主要文件: + +1. `packages/shared/src/contracts/visualNovel*.ts` +2. `packages/shared/src/contracts/index.ts` +3. `packages/shared/src/index.ts` +4. `server-rs/crates/shared-contracts/src/visual_novel*.rs` +5. `server-rs/crates/shared-contracts/src/lib.rs` +6. `server-rs/crates/module-visual-novel/` +7. `server-rs/Cargo.toml` + +不负责: + +1. 不写 HTTP handler。 +2. 不写 SpacetimeDB reducer。 +3. 不调用 LLM 或 OSS。 +4. 不做 UI。 + +交付物: + +1. `VisualNovelResultDraft`、`VisualNovelAgentSessionSnapshot`、`VisualNovelRunSnapshot`、`VisualNovelRuntimeStep`、`VisualNovelRuntimeStreamEvent` 等契约。 +2. Rust 与 TS 字段语义一致。 +3. 领域单测覆盖发布校验、step 解析失败、状态推进、历史重生成和 save archive state。 + +验收命令: + +```bash +npm run typecheck + +cd server-rs +cargo test -p shared-contracts +cargo test -p module-visual-novel +``` + +阻塞关系: + +1. 阻塞 `VN-02`、`VN-05`、`VN-06`、`VN-07`、`VN-08` 的正式联调。 +2. 可以先输出契约草案,让前后端用 mock 并行开发。 + +VN-01 实施收口记录(2026-05-05): + +1. 已新增前端共享契约 `packages/shared/src/contracts/visualNovel.ts`,并从 `packages/shared/src/contracts/index.ts` 与 `packages/shared/src/index.ts` 导出。 +2. 已新增 Rust DTO `server-rs/crates/shared-contracts/src/visual_novel.rs`,字段命名与 TypeScript 契约保持 camelCase / snake_case / lowercase 语义一致。 +3. 已新增纯领域 crate `server-rs/crates/module-visual-novel/`,只覆盖草稿发布校验、runtime step 解析、run snapshot 推进、历史重生成边界、runtime prompt context 构造和平台 save archive state 构造。 +4. 本阶段未新增 HTTP handler、SpacetimeDB reducer / table / migration、LLM / OSS 调用、前端入口或 UI 能力;后续 VN-02 / VN-05 / VN-06 仍需基于本契约继续接入。 +5. 已补领域单测覆盖发布校验、step 解析失败、状态推进、历史重生成和 save archive state;验证命令以本节验收命令为准。 + +### VN-02:SpacetimeDB 表、迁移与 facade + +负责范围: + +1. 新增视觉小说创作 session、message、work profile、runtime run、history entry、runtime event 表。 +2. 新增 reducer / procedure。 +3. 同步 `migration.rs` 和表目录。 +4. 生成 bindings。 +5. 新增 `spacetime-client` facade。 + +主要文件: + +1. `server-rs/crates/spacetime-module/src/visual_novel.rs` +2. `server-rs/crates/spacetime-module/src/lib.rs` +3. `server-rs/crates/spacetime-module/src/migration.rs` +4. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +5. `server-rs/crates/spacetime-client/src/visual_novel.rs` +6. `server-rs/crates/spacetime-client/src/lib.rs` +7. `server-rs/crates/spacetime-client/src/module_bindings/` + +不负责: + +1. 不写 Axum 路由。 +2. 不写 LLM prompt。 +3. 不手改生成 bindings 来绕过 schema 问题。 +4. 不新增 replay 或私有 save 表。 + +交付物: + +1. 表和 procedure 能支撑创作、作品、运行、历史和审计事件。 +2. `spacetime-client` 暴露 typed facade 给 `api-server`。 +3. 表目录写明每张表用途,明确 runtime event 不是回放。 + +验收命令: + +```bash +npm run spacetime:generate -- --rust-only + +cd server-rs +cargo check -p spacetime-client +``` + + 阻塞关系: + + 1. 依赖 `VN-01` 的 Rust 契约。 + 2. 阻塞 `VN-05` 的真实数据库联调。 + + VN-02 实施收口记录(2026-05-05): + + 1. 已新增 `server-rs/crates/spacetime-module/src/visual_novel.rs`,落地 `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` 六张表及对应 procedure。 + 2. 已同步 `server-rs/crates/spacetime-module/src/lib.rs`、`server-rs/crates/spacetime-module/src/migration.rs`、`docs/technical/SPACETIMEDB_TABLE_CATALOG.md` 和 Rust bindings。 + 3. 已新增 `server-rs/crates/spacetime-client/src/visual_novel.rs` typed facade,并从 `server-rs/crates/spacetime-client/src/lib.rs` 导出视觉小说 record / input 类型。 + 4. `visual_novel_runtime_event` 只作为 `public event` 审计事件表使用;`visual_novel_runtime_history_entry` 只保存继续体验与历史重生成所需 step 和快照哈希,二者均不是 replay 数据源。 + 5. 本阶段未新增 Axum 路由、LLM prompt、前端 UI、replay 表、replay 路由或私有 save 表;后续 VN-05 只应通过本阶段 facade 接入真实数据库。 + + ### VN-03:创作与运行 Prompt / LLM 工具 + +负责范围: + +1. 设计视觉小说底稿生成 prompt。 +2. 设计视觉小说运行时 GM prompt。 +3. 设计结构化 repair prompt。 +4. 定义创作 action 与图片生成 action 的工具参数。 +5. 接入 `platform-llm` 和可选 `platform-agent` 的 adapter 口径。 + +主要文件: + +1. `server-rs/crates/api-server/src/prompt/visual_novel.rs` +2. `server-rs/crates/platform-llm/src/` +3. `server-rs/crates/platform-agent/src/`,仅当复用 Agent 编排时修改 +4. `server-rs/crates/module-visual-novel/src/`,仅补 prompt 输入结构时修改 + +不负责: + +1. 不保存数据。 +2. 不写前端 UI。 +3. 不照搬外部 prompt 中的平台规则、扣费规则或回放规则。 + +交付物: + +1. 创作 prompt 输出 `VisualNovelResultDraft`。 +2. 运行 prompt 输出 `VisualNovelRuntimeStep[]`。 +3. repair 能把坏格式修成 typed step。 +4. prompt 明确禁止输出回放、商城、会员、后台、平台活动字段。 + +验收: + +1. prompt fixture 能解析为契约结构。 +2. 错误样例能进入 repair 或返回可重试错误。 + +阻塞关系: + +1. 依赖 `VN-01` 的 step 和 draft 契约。 +2. 与 `VN-05` 并行,但 `VN-05` 的真实 LLM 输出依赖本任务。 + +### VN-04:前端视觉规范与组件骨架 + +负责范围: + +1. 定义视觉小说 workspace、result、runtime 的前端组件骨架。 +2. 先用 mock data 搭出移动端优先布局。 +3. 建立历史、存档、设置、属性面板的独立弹层 / 抽屉模式。 +4. 确认 UI 中不出现规则长文和回放入口。 + +主要文件: + +1. `src/components/visual-novel-creation/` +2. `src/components/visual-novel-result/` +3. `src/components/visual-novel-runtime/` +4. `src/index.css`,仅在需要公共样式 token 时局部修改 + +不负责: + +1. 不接真实 API。 +2. 不修改平台入口分流。 +3. 不实现后端逻辑。 + +交付物: + +1. `VisualNovelAgentWorkspace` mock 版。 +2. `VisualNovelResultView` mock 版。 +3. `VisualNovelRuntimeShell` mock 版。 +4. 移动端和桌面端基础布局可检查。 + +验收: + +1. UI 无长说明文案。 +2. 按钮打开复杂内容时使用独立面板,不在当前面板下方展开。 +3. 长角色名、长选项文本不撑破按钮。 + +阻塞关系: + +1. 可与 `VN-01` 并行,用临时本地类型;合并前必须切回 shared contracts。 +2. 为 `VN-07`、`VN-08` 提供 UI 基础。 + +### VN-05:API Server 创作、作品与运行路由 + +负责范围: + +1. 新增 `api-server` 视觉小说路由模块。 +2. 接创作 session、message、stream、action、compile。 +3. 接 works CRUD 和 publish。 +4. 接 runtime run、action stream、history、regenerate。 +5. 编排 `spacetime-client`、`platform-llm`、`platform-oss`、钱包和 save archive。 + +主要文件: + +1. `server-rs/crates/api-server/src/visual_novel.rs` +2. `server-rs/crates/api-server/src/app.rs` +3. `server-rs/crates/api-server/src/main.rs` +4. `server-rs/crates/api-server/src/prompt/visual_novel.rs`,如 `VN-03` 已建则只接入 +5. `server-rs/crates/api-server/tests/`,按现有测试结构补 smoke + +不负责: + +1. 不新增表结构。 +2. 不把领域规则写进 handler。 +3. 不新增 replay 路由。 +4. 不新增视觉小说私有 save API。 + +交付物: + +1. `/api/creation/visual-novel/*` 路由可用。 +2. `/api/runtime/visual-novel/*` 路由可用。 +3. SSE 事件使用 `shared-contracts` envelope。 +4. 存档调用平台统一 save archive。 + +验收命令: + +```bash +cd server-rs +cargo check -p api-server +cargo test -p api-server visual_novel +``` + +实现状态(2026-05-05): + +1. 已新增 `server-rs/crates/api-server/src/visual_novel.rs`,通过 `spacetime-client` facade 编排 creation、works、gallery、runtime、history、regenerate 路由。 +2. 已在 `app.rs` 挂载 `/api/creation/visual-novel/*` 与 `/api/runtime/visual-novel/*`;除公开 gallery 外均使用 bearer auth。 +3. SSE 输出使用 `shared-contracts` 中的视觉小说事件契约;LLM 不可用或输出不可解析时只生成最小可运行 fallback 草稿 / step。 +4. 本阶段未新增 SpacetimeDB schema、replay 路由或视觉小说私有 save API;平台统一 save archive 的正式 UI / 前端接入留给后续任务。 + +阻塞关系: + +1. 依赖 `VN-01`、`VN-02`。 +2. 真实 LLM 生成依赖 `VN-03`。 +3. 阻塞 `VN-07`、`VN-08` 的真实接口联调。 + +### VN-06:前端 service client 与平台入口分流 + +负责范围: + +1. 新增视觉小说 creation、works、runtime service client。 +2. 增加 `visual-novel` selection stage。 +3. 打开平台创作入口分流。 +4. 在平台壳层挂载 workspace、result、runtime、gallery detail。 +5. 处理登录态清理、刷新恢复和错误回退。 + +主要文件: + +1. `src/services/visual-novel-creation/` +2. `src/services/visual-novel-works/` +3. `src/services/visual-novel-runtime/` +4. `src/components/platform-entry/platformEntryTypes.ts` +5. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +6. `src/config/newWorkEntryConfig.ts` + +不负责: + +1. 不设计 result 表单细节。 +2. 不设计 runtime UI 细节。 +3. 不写后端 API。 + +交付物: + +1. 入口从创作中心能进入 `visual-novel-agent-workspace`。 +2. service client 方法与 PRD 路由一致。 +3. 平台壳层能在 mock 或真实 snapshot 下切换 workspace / result / runtime。 +4. 退出登录清空视觉小说私有状态。 + +验收命令: + +```bash +npm run typecheck +``` + +阻塞关系: + +1. 依赖 `VN-01` 的 TS contracts。 +2. 可用 mock 与 `VN-04` 并行。 +3. 真实联调依赖 `VN-05`。 + +VN-06 实施收口记录(2026-05-05): + +1. 已新增并对齐视觉小说 creation、works、runtime service client,覆盖 `/api/creation/visual-novel/sessions`、`/api/creation/visual-novel/works`、`/api/runtime/visual-novel/gallery`、run、history、regenerate 与 action stream 路由。 +2. 已在平台入口打开 `visual-novel`,新增 `visual-novel-agent-workspace`、`visual-novel-result`、`visual-novel-gallery-detail`、`visual-novel-runtime` 阶段和对应前端路由。 +3. 已在 `PlatformEntryFlowShellImpl` 挂载 `VisualNovelAgentWorkspace`、`VisualNovelResultView`、`VisualNovelRuntimeShell`,并接入创建 session、消息流、结构化 action、草稿编译、保存、发布和测试 run 的最小前端闭环。 +4. 退出登录或受保护数据不可读时会清空视觉小说 session、work、run、错误与 return stage;刷新或直达阶段缺少必要 snapshot 时会回退到 workspace、result 或平台首页。 +5. 本阶段未新增后端 handler、SpacetimeDB schema、平台作品聚合、公开号、分享回放或外部平台功能;gallery detail 仅做壳层最小挂载,完整作品架 / 广场聚合仍归 `VN-09`。 +6. 已执行 `npm run typecheck`、视觉小说相关组件单测、平台入口类型单测和 `npm run check:encoding`。 + +### VN-07:前端创作工作台与结果页 + +负责范围: + +1. 实现 `idea` / `document` / `blank` 三种创作起点。 +2. 实现 Agent 对话、草稿生成进度和 action 执行。 +3. 实现 `VisualNovelResultView` 的编辑表单。 +4. 支持世界观、角色、场景、剧情阶段、开场和运行配置编辑。 +5. 支持保存草稿、编译 work profile、进入测试 runtime。 + +主要文件: + +1. `src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx` +2. `src/components/visual-novel-result/VisualNovelResultView.tsx` +3. `src/components/visual-novel-result/` 下的编辑面板组件 +4. `src/services/visual-novel-creation/` +5. `src/services/visual-novel-works/` + +不负责: + +1. 不实现正式玩家 runtime。 +2. 不改平台作品聚合。 +3. 不新增平台说明页。 + +交付物: + +1. 一句话创建能生成草稿。 +2. 文档创建能使用平台资产引用生成草稿。 +3. 空白创建能进入可编辑结果页。 +4. 发布前校验 issue 能在结果页清晰呈现。 +5. 结果页可启动测试 run。 + +验收: + +1. 草稿字段写入 `VisualNovelResultDraft`。 +2. UI 不展示大段规则说明。 +3. 所有复杂编辑使用独立面板。 + +阻塞关系: + +1. 依赖 `VN-04` 骨架、`VN-06` 分流和 service client。 +2. 真实生成依赖 `VN-05`。 + +VN-07 实施收口记录(2026-05-05): + +1. 已接通平台创作入口到 `visual-novel-agent-workspace`、`visual-novel-result` 和测试 `visual-novel-runtime` 的前端分流。 +2. `VisualNovelAgentWorkspace` 已支持 `idea`、`document`、`blank` 三种起点;`blank` 直接创建本地空白 `VisualNovelResultDraft` 并进入结果页,`idea` / `document` 继续调用 `/api/creation/visual-novel/sessions`。 +3. `VisualNovelResultView` 已支持作品信息、世界观、开场、角色、场景、剧情阶段和运行配置编辑;角色、场景、剧情阶段、运行配置均使用独立弹层编辑。 +4. 结果页已支持保存当前 session 草稿、显式编译 work profile、发布前结构校验展示和进入测试 run;真实 runtime 接口不可用时仅降级为本地 test run,不扩展正式玩家运行链路。 +5. 本阶段未实现正式玩家 runtime、作品架 / 广场聚合、独立说明页、回放能力或外部平台功能;真实生成、真实保存和正式运行仍以 `VN-05` 路由完成情况为准。 + +### VN-08:前端运行时、历史、存档与重生成 + +负责范围: + +1. 实现 `VisualNovelRuntimeShell`。 +2. 实现 SSE action stream 消费。 +3. 渲染背景、角色立绘、旁白、对白、转场、选项、文本模式。 +4. 实现历史面板、设置面板、存档面板和可选属性面板。 +5. 实现历史重生成和平台 save archive 继续体验。 + +主要文件: + +1. `src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx` +2. `src/components/visual-novel-runtime/useVisualNovelRuntimeController.ts` +3. `src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx` +4. `src/components/visual-novel-runtime/VisualNovelSavePanel.tsx` +5. `src/components/visual-novel-runtime/VisualNovelSettingsPanel.tsx` +6. `src/services/visual-novel-runtime/` + +不负责: + +1. 不实现创作 result 表单。 +2. 不新增回放 UI。 +3. 不把历史做成可分享播放页。 + +交付物: + +1. 测试 run 和正式 run 都能渲染 typed step。 +2. 自由输入、选择项和继续推进可用。 +3. 历史查看和重生成可用。 +4. 存档按钮写入平台统一 save archive。 +5. 移动端竖屏可读、可操作。 + +验收: + +1. SSE `step` 是正式 UI 状态来源。 +2. `raw_text` 不写入最终业务状态。 +3. 历史面板不出现回放、分享、录制文案。 + +阻塞关系: + +1. 依赖 `VN-04` 骨架、`VN-06` service client。 +2. 真实运行依赖 `VN-05`。 + +VN-08 实施收口记录(2026-05-05): + +1. 已补齐 `src/services/visual-novel-runtime/` 的运行时 client 与 SSE 消费:运行时 action stream 支持 `raw_text`、`step`、`snapshot`、`complete`,最终 run 只取 typed snapshot / complete。 +2. 已新增 `useVisualNovelRuntimeController`,收口 run 读取 / 启动、选择 / 自由输入 / 继续推进、历史重生成、平台 save archive 列表 / 恢复和 checkpoint 保存。 +3. 已升级 `VisualNovelRuntimeShell`:按 typed step 渲染背景、角色立绘 / 占位立绘、旁白、对白、转场、选项和文本模式;`raw_text` 仅作为临时流式文本展示,不写入 history 或文本模式最终内容。 +4. 已拆分历史、存档、设置、属性独立面板;历史面板只提供查看与重生成,存档面板只使用平台统一 archive 列表和 checkpoint 保存,不新增私有槽位语义。 +5. 本阶段未实现创作 result 表单、作品架 / 广场聚合、后端 VN-05 路由、回放 UI 或历史分享播放页;真实运行效果仍依赖 VN-05 API 可用性。 + +### VN-09:作品架、广场与发布聚合 + +负责范围: + +1. 把视觉小说 work summary 接入平台作品架。 +2. 把视觉小说公开作品接入平台 gallery 聚合。 +3. 补 public work code 和详情跳转。 +4. 发布后刷新作品架和公开聚合。 + +主要文件: + +1. `src/components/custom-world-home/creationWorkShelf.ts` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` +4. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` +5. `src/services/publicWorkCode.ts` +6. `src/services/visual-novel-works/` + +不负责: + +1. 不新增独立视觉小说市场。 +2. 不实现外部仓库平台详情页。 +3. 不新增回放分享。 + +交付物: + +1. 视觉小说草稿能出现在平台草稿 / 作品架。 +2. 发布后能进入平台公开聚合。 +3. 从公开作品能启动 visual novel run。 + +验收: + +1. 发布后刷新作品架能看到作品。 +2. 平台聚合 key 不与 RPG、Puzzle、Match3D、Square Hole 冲突。 + +阻塞关系: + +1. 依赖 `VN-05` works / gallery API。 +2. 依赖 `VN-06` 平台壳层分流。 + +实施收口记录: + +1. 已把 `VisualNovelWorkSummary` 接入平台作品架聚合,视觉小说草稿和已发布作品会与 RPG、Puzzle、Match3D、Square Hole、Big Fish 一起按更新时间展示。 +2. 已新增 `VN-` public work code,并把视觉小说公开作品接入平台公开广场卡片、搜索、详情与分享路径;聚合 key 使用 `visual-novel:{ownerUserId}:{profileId}`,避免与现有玩法冲突。 +3. 已在平台壳层接入视觉小说 works/gallery 刷新、删除、发布后刷新和分享弹窗;发布完成后会刷新作品架和公开聚合。 +4. 已支持从平台公开详情启动 `mode = 'play'` 的 visual novel run;自有视觉小说作品可回到原 work detail / result 草稿继续编辑。 +5. 本阶段未新增独立视觉小说市场、外部平台详情页、分享回放、点赞接口或改造接口;点赞 / 改造按钮仅提示后续开放。 + +### VN-10:资产、文档输入、图片与音乐生成接入 + +负责范围: + +1. 文档上传和读取走平台资产接口。 +2. 场景背景图、角色立绘、封面、音乐统一使用平台资产引用。 +3. 可选接入图片生成 action。 +4. 确保 SpacetimeDB 不保存大 Data URL 或二进制对象。 + +主要文件: + +1. `src/components/visual-novel-creation/` +2. `src/components/visual-novel-result/` +3. `src/services/visual-novel-creation/` +4. `server-rs/crates/api-server/src/visual_novel.rs` +5. `server-rs/crates/platform-oss/src/` +6. `server-rs/crates/platform-llm/src/`,仅当图片生成 prompt 需要扩展时修改 + +不负责: + +1. 不新增独立对象存储。 +2. 不把外部 R2 路径迁入。 +3. 不绕过平台资产鉴权。 + +交付物: + +1. 文档创建可以读取平台资产。 +2. result 页可上传或生成场景 / 角色资产。 +3. work profile 只保存资产引用和 read URL。 + +验收: + +1. 长文档不会整篇重复写入玩法表。 +2. 大图不会写入长期前端状态或 SpacetimeDB。 + +阻塞关系: + +1. 可与 `VN-07` 并行,但最终需要在 result 页汇合。 +2. 后端资产 action 依赖 `VN-05`。 + +VN-10 实施收口记录(2026-05-07): + +1. 已新增视觉小说资产客户端,统一通过 `/api/assets/direct-upload-tickets`、OSS 直传、`/api/assets/objects/confirm` 写入平台资产对象,不新增独立对象存储。 +2. 文档创建入口已改为上传平台文档资产后再解析文本,`sourceAssetIds` 写入平台 `assetObjectId`,`seedText` 仅保留截断后的摘要,避免长文档整篇进入玩法表。 +3. 结果页封面、场景背景、角色立绘、音乐均通过平台资产上传 / 历史素材选择写回 `/generated-*` 引用;角色立绘写入 `imageAssets[].assetId / imageSrc / source = platform_asset`。 +4. 运行时背景图与角色立绘改用 `ResolvedAssetImage`,私有 generated 路径通过 `/api/assets/read-url` 换签后渲染。 +5. 本阶段未接入可选图片生成 action,保留既有 action 契约;未改 SpacetimeDB schema,未保存 Data URL、二进制对象或外部 R2 路径。 + +### VN-11:回放删除与外部平台功能负向扫描 + +负责范围: + +1. 扫描前端、后端、契约、表、文档和测试,确认无回放能力。 +2. 扫描外部平台功能是否被误迁入。 +3. 建立负向测试或脚本清单。 + +扫描范围: + +1. `src/` +2. `packages/shared/src/` +3. `server-rs/crates/` +4. `docs/` +5. `.hermes/shared-memory/` + +禁止项: + +1. `replay` / `Replay` 类型、路由、组件、按钮、表、目录和文案。 +2. 分享回放、回放列表、回放编译、回放进度条。 +3. 外部平台账号、订单、会员、促销、banner、后台、公开市场、私有存档。 +4. 新增 `server-node` 或 Express 落点。 + +交付物: + +1. 负向扫描报告。 +2. 必要时补单元测试、路由测试或简单脚本。 +3. 发现误入范围时直接提修复任务,不在本任务中大范围重构。 + +验收命令: + +```bash +rg -n "replay|Replay|回放|分享回放|录制|复盘" src packages server-rs docs .hermes +``` + +验收: + +1. 仅允许历史旧文档和本文“禁止回放”的语境命中。 +2. 新增工程代码不得命中 replay。 + +VN-11 实施收口记录(2026-05-07): + +1. 已新增 `scripts/check-visual-novel-vn11-negative-scan.mjs` 与 `npm run check:visual-novel-vn11`,把工程代码回放类直出命中、文档 / 共享记忆历史命中、视觉小说实现路径外部平台能力误入分开扫描。 +2. 已生成 `docs/audits/VN11_NEGATIVE_SCAN_REPORT_2026-05-07.md`,记录扫描范围、处理记录、门禁命令和当前结论。 +3. 已将 `src/services/storyEngine/narrativeRegressionReplay.ts` / `.test.ts` 收口为 `narrativeRegressionRerun.ts` / `.test.ts`,避免非视觉小说回归工具继续使用 replay 语义。 +4. 已将 `src/components/SkillEffectPreview.tsx` 的内部 `replayTick` 与“重播预览”文案收口为重新预览语义,保留原有预览重启动作,不新增回放能力。 +5. 已确认视觉小说实现路径未新增 replay 路由、DTO、表、按钮、文案,也未误入外部平台账号、订单、会员、促销、后台、公开市场或私有存档能力。 + +阻塞关系: + +1. 可以从 Batch 1 开始持续执行。 +2. 最终发布前必须完成。 + +### VN-12:全链路联调与自动化验收 + +负责范围: + +1. 串起创作、结果页、测试运行、发布、正式运行、历史、重生成、存档和继续体验。 +2. 补前端 typecheck、后端 cargo test、API smoke 和必要的浏览器截图检查。 +3. 验证移动端和桌面端核心路径。 + +主要文件: + +1. `tests/` 或现有前端测试目录,按项目实际结构补。 +2. `server-rs/crates/api-server/tests/` +3. `server-rs/crates/module-visual-novel/tests/` +4. `src/components/visual-novel-*/`,仅修复联调发现的问题。 + +交付物: + +1. 自动化验收清单。 +2. API smoke。 +3. 前端关键路径检查。 +4. 移动端和桌面端截图记录。 + +验收命令: + +```bash +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 +``` + +阻塞关系: + +1. 依赖 `VN-05` 到 `VN-10` 主链路完成。 +2. 发现问题后回流到对应任务 owner。 + +VN-12 实施收口记录(2026-05-07): + +1. 已新增 `scripts/check-visual-novel-vn12-acceptance.mjs` 与 `npm run check:visual-novel-vn12`,固定检查 PRD、VN-11 报告、关键前端测试、运行时 service client、`api-server` 路由、`shared-contracts` 和 `module-visual-novel` 文件存在与命中。 +2. 已补 `src/services/visual-novel-runtime/visualNovelRuntimeClient.test.ts`,覆盖公开广场、run 启动、action SSE、历史重生成、平台 save archive 恢复、checkpoint 写入和 archive state 结构。 +3. 已将视觉小说公开广场读取设置为 `skipAuth: true` / `skipRefresh: true`,避免未登录公开列表先触发登录 refresh。 +4. 已生成 `docs/audits/VN12_FULL_CHAIN_ACCEPTANCE_REPORT_2026-05-07.md`,记录自动化验收清单、API smoke、前端关键路径、桌面 / 移动端检查说明、执行命令和未覆盖风险。 +5. 本阶段未新增玩法、未改 SpacetimeDB schema、未新增回放、外部平台功能或私有存档系统;若后续联调发现缺口,回流到对应 VN-05 到 VN-10 owner。 + +### VN-13:实现文档、表目录和交接收口 + +负责范围: + +1. 根据实际落地更新 PRD、技术方案、表目录和经验文档。 +2. 记录视觉小说模板接入中的长期约定和排障经验。 +3. 确认旧 TXT 文档不会重新成为实现口径。 + +主要文件: + +1. `docs/prd/AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md` +2. `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` +3. `docs/technical/` 下新增或更新视觉小说实现方案,若工程落地出现新技术细节 +4. `.hermes/shared-memory/decision-log.md` +5. `.hermes/shared-memory/pitfalls.md` + +交付物: + +1. 实现后的文档差异已同步。 +2. 表结构、API、契约和验收命令与代码一致。 +3. 长期有效的坑和协作约定进入 Hermes 共享记忆。 + +验收: + +1. 新开发者只读本文和相关技术文档即可继续维护。 +2. 文档中没有把视觉小说描述成外部平台迁移。 +3. 编码检查通过。 + +VN-13 实施收口记录(2026-05-07): + +1. 已新增 [视觉小说模板实现收口与交接说明](../technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md),把视觉小说当前正式入口、表目录、路由、作品 / 运行 / 资产和负向扫描口径收进一页式交接文档。 +2. 已新增 [视觉小说模板交接与维护经验](../experience/VISUAL_NOVEL_HANDOFF_AND_MAINTENANCE_2026-05-07.md),沉淀新开发者最小阅读顺序、常见坑和验证命令。 +3. 已将 `docs/technical/README.md`、`docs/experience/README.md`、`.hermes/shared-memory/decision-log.md` 和 `.hermes/shared-memory/pitfalls.md` 同步到视觉小说最终收口口径。 +4. 旧 TXT 迁移方案继续只作为历史参考,不再作为视觉小说实现目标。 +5. 本次收口只做文档和共享记忆同步,没有新增回放能力、外部平台功能或新的 SpacetimeDB schema。 + +### 16.4 推荐认领方式 + +最小并行组合: + +1. 后端领域开发认领 `VN-01`。 +2. SpacetimeDB 开发认领 `VN-02`。 +3. API 开发认领 `VN-05`。 +4. 前端平台开发认领 `VN-06`。 +5. 前端创作开发认领 `VN-07`。 +6. 前端运行时开发认领 `VN-08`。 +7. QA / 守门任务认领 `VN-11` 和 `VN-12`。 + +如果只有 2 名开发者: + +1. 开发者 A:`VN-01`、`VN-02`、`VN-05`、`VN-10`。 +2. 开发者 B:`VN-04`、`VN-06`、`VN-07`、`VN-08`、`VN-09`。 +3. 两人共同执行 `VN-11`、`VN-12`、`VN-13`。 + +如果有 3 名开发者: + +1. 开发者 A:契约、领域、SpacetimeDB、API,即 `VN-01`、`VN-02`、`VN-05`。 +2. 开发者 B:平台入口、创作工作台、结果页,即 `VN-04`、`VN-06`、`VN-07`、`VN-09`。 +3. 开发者 C:运行时、资产、负向扫描、验收,即 `VN-08`、`VN-10`、`VN-11`、`VN-12`。 + +### 16.5 阻塞关系明细 + +```text +VN-00 + -> VN-01 + -> VN-02 + -> VN-05 + -> VN-07 / VN-08 / VN-09 / VN-10 + -> VN-12 + -> VN-13 + +VN-03 依赖 VN-01,可与 VN-02 / VN-04 并行。 +VN-04 依赖 VN-00,可与 VN-01 / VN-02 / VN-03 并行。 +VN-06 依赖 VN-01 契约草案,可与 VN-05 并行 mock。 +VN-11 从 Batch 1 开始持续运行,最终阻塞发布。 +``` + +### 16.6 拆分验收口径 + +每个任务完成时必须留下: + +1. 变更文件清单。 +2. 已执行的验证命令。 +3. 未覆盖风险。 +4. 是否触碰 SpacetimeDB schema。 +5. 是否确认无 replay / 外部平台功能误入。 + +任何任务如果需要修改 `VisualNovelResultDraft`、`VisualNovelRuntimeStep` 或 API 路由,必须先更新 `VN-01` 契约并通知所有依赖任务。 + +--- + +## 17. 验收标准 + +### 17.1 正向验收 + +1. `visual-novel` 入口可见并可点击创建。 +2. 一句话创建能生成可编辑底稿。 +3. 文档创建能读取平台文档资产并生成底稿。 +4. 空白创建能进入结果页。 +5. 结果页能编辑世界观、角色、场景、剧情阶段和运行时配置。 +6. 发布前校验能阻止缺少 opening 场景、角色或初始选项的作品。 +7. 测试 run 能渲染背景、角色、旁白、对白和选项。 +8. 正式 run 能通过 SSE 返回 typed step。 +9. 文本模式可切换。 +10. 历史面板可查看当前 run 历史。 +11. 历史重生成能从指定节点继续,且不会污染旧历史。 +12. 存档和继续体验走平台统一 save archive。 +13. 发布后作品可被平台作品体系读取。 +14. 移动端和桌面端无文本溢出、按钮遮挡和面板错位。 + +### 17.2 负向验收 + +1. 前端不存在回放按钮、回放列表、分享回放弹窗和回放进度条。 +2. 后端不存在 replay 路由、replay DTO、replay 表和 replay 存储目录。 +3. shared contracts 不出现 `Replay` / `replay` 类型。 +4. 视觉小说不新增外部平台账号、订单、会员、促销、banner、后台、公开市场或浏览历史能力。 +5. 视觉小说不新增私有存档系统。 +6. 视觉小说不绕过平台资产系统保存图片、音乐或文档。 +7. 视觉小说不使用 `server-node` 或 Express 作为新功能落点。 + +### 17.3 建议验证命令 + +按实际改动范围执行: + +```bash +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 schema、reducer、procedure 或 bindings 时,必须按项目当前 SpacetimeDB 文档执行生成和迁移验证。 + +--- + +## 18. 后续实现前必须确认的问题 + +这些问题不阻塞本文冻结边界,但进入编码前需要产品确认默认值: + +1. 视觉小说运行时 AI 推进是否首版收费,以及单次推进默认光点数。 +2. 文档上传首版支持的最大大小和格式。 +3. 角色立绘、场景图是否首版自动生成,还是仅提供手动上传和后续生成按钮。 +4. 属性面板是否首版关闭,还是只对内部白名单开启。 +5. 平台统一存档 UI 是否按槽位展示视觉小说 archive state。 + +无论上述默认值如何选择,都不能改变三个硬边界:只做视觉小说模板、完全使用平台接口、删除回放。 diff --git a/docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md b/docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md new file mode 100644 index 000000000..07b3366b6 --- /dev/null +++ b/docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md @@ -0,0 +1,1181 @@ +# 创意互动内容生成 Agent Phase 1 PRD:LangChain-Rust PoC + 拼图闭环 + +更新时间:`2026-05-05` + +## 0. 文档目的 + +这份 PRD 用于把《创意互动内容生成 Agent 技术方案》中的 Phase 1 独立拆成可编码、可并行、可验收的产品需求和工程任务。 + +Phase 1 的目标不是重做现有拼图创作系统,也不是把 Agent 降级成规则 workflow。它要在当前 `server-rs + Axum + SpacetimeDB` 基线下,新增一个基于 LangChain-Rust 的创意互动内容 Agent PoC:用户输入文字和图片后,Agent 以模型为主体完成理解、显式选择拼图模板、展示积分范围、填充拼图草稿字段、生成单关卡或多关卡图片,并让创建好的拼图点击后立即进入可玩的 `puzzle-runtime`。 + +--- + +## 1. 一句话定义 + +Phase 1 是一个“只开放拼图模板的创意互动内容生成 Agent”:它必须先展示多个拼图模板候选,用户选择某个模板后,再确认该模板下的关卡模式、关卡数和预计光点范围;确认后才通过拼图模块 Tool 填充同一份 `PuzzleResultDraft` 草稿字段,并复用现有 `puzzle-result` 和 `puzzle-runtime` 完成表单编辑、自然语言修订和立即试玩闭环。 + +--- + +## 2. 关联文档与现有代码 + +必须先对齐: + +- 技术总方案:`docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md` +- 拼图玩法 PRD:`docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md` +- 后端 DDD 总纲:`docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md` +- SpacetimeDB schema 约束:`docs/technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md` +- 拼图 TS 草稿契约:`packages/shared/src/contracts/puzzleAgentDraft.ts` +- 拼图 TS action 契约:`packages/shared/src/contracts/puzzleAgentActions.ts` +- 拼图 Rust shared contract:`server-rs/crates/shared-contracts/src/puzzle_agent.rs` +- 拼图领域:`server-rs/crates/module-puzzle/src/domain.rs` +- 拼图领域编排:`server-rs/crates/module-puzzle/src/application.rs` +- 拼图 HTTP facade:`server-rs/crates/api-server/src/puzzle.rs` +- 拼图 SpacetimeDB 表与 procedure:`server-rs/crates/spacetime-module/src/puzzle.rs` +- 拼图 facade client:`server-rs/crates/spacetime-client/src/puzzle.rs` +- 前端拼图结果页:`src/components/puzzle-result/PuzzleResultView.tsx` +- 前端拼图运行态:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx` +- 平台入口:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` + +LangChain-Rust PoC 参考: + +- `langchainrust` crate 页面:`https://docs.rs/crate/langchainrust/latest` +- `langchainrust` 仓库:`https://github.com/atliliw/langchainrust` +- APIMart OpenAI Responses 多模态接口:`https://docs.apimart.ai/cn/api-reference/texts/openai/responses` + +落地时以本地 Cargo 编译通过的 API 为准。若 `langchainrust` 实际 API 与公开文档不一致,必须封装在 `platform-agent` adapter 内,不允许把第三方 API 形状扩散到业务 crate。 + +### 2.1 Agent 模型与多模态输入硬约束 + +Phase 1 创意 Agent 的感知、思考、模板选择、草稿字段规划、反思和自然语言字段修订统一使用 APIMart OpenAI 兼容 Responses API 的 `gpt-5`。拼图图片生成仍使用拼图模块已有图片工具和 `gpt-image-2`,不要把图片理解模型与图片生成模型混成一个配置。 + +APIMart Responses 请求必须按多模态内容块组织: + +```json +{ + "model": "gpt-5", + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "用户输入的创作意图" + }, + { + "type": "input_image", + "image_url": "https://example.com/image.png" + } + ] + } + ], + "stream": true +} +``` + +实现要求: + +1. `platform-agent` 调用模型时必须显式传 `model = "gpt-5"`。 +2. Agent 输入必须支持文本和图像内容块,首版 UI 至少支持 `1` 张图片,后端协议预留多图。 +3. 图片先走资产上传,模型请求使用资产 `readUrl`;只有调试和小图兜底允许 Data URI,且不得写入 SpacetimeDB。 +4. `platform-llm` 需要把 Responses content part 从当前纯文本结构扩展为 `input_text` / `input_image`,并支持 `image_url` 字段。 +5. `CREATION_TEMPLATE_LLM_MODEL`、`deepseek-v3-2-251201` 等旧文本创作模型不能作为本 Agent 的默认模型。 +6. 如果 LangChain-Rust 暂时不能直接表达 APIMart 多模态 Responses 请求,`platform-agent` 必须通过项目内 adapter 调用 `platform-llm` 多模态 Responses client,不能把图片先用规则或占位文案摘要后再走纯文本 Agent。 +7. APIMart Responses 的 `tools` 字段可用于函数调用描述,但工具执行权仍在后端 Tool registry,模型不能直接写库或绕过 typed tool。 + +--- + +## 3. Phase 1 范围 + +### 3.1 必须完成 + +1. 新增 `platform-agent` PoC crate,封装 LangChain-Rust 的 Function Calling / AgentExecutor 能力。 +2. 新增创意 Agent shared contract,支持阶段、消息、模板选择、积分范围、关卡图片计划、目标拼图 session 绑定。 +3. 新增拼图模板协议,当前只提供一个可用拼图模板,但仍必须显式展示选择步骤。 +4. 拼图模板卡必须展示模板标题、预览、选择理由、单关卡/多关卡能力、计划关卡数、预计光点范围。 +5. 用户确认模板后,Agent 才能创建或复用拼图 session,并填充 `PuzzleResultDraft`。 +6. Agent 可写草稿字段只允许: + - `PuzzleResultDraft.workTitle` + - `PuzzleResultDraft.workDescription` + - `PuzzleResultDraft.workTags` + - `PuzzleResultDraft.levels[].levelName` + - `PuzzleResultDraft.levels[].pictureDescription` + - `PuzzleResultDraft.levels[].pictureReference` +7. 当前代码中 `themeTags` 是既有契约字段,Phase 1 若未完成底层重命名,必须在 adapter 中把 `workTags` 映射到 `themeTags`,对外 Agent PRD 和新契约仍使用 `workTags`。 +8. `pictureReference` 采用正式字段方案:扩展 `PuzzleDraftLevel.pictureReference?: string | null` / Rust `picture_reference: Option`。当前拼图 draft/levels 通过 JSON 字段持久化,不新增 SpacetimeDB 表列;后续若生成 bindings 或前端结果页读取该字段,必须继续透传同一字段,不再采用 `metadata.levelPictureReferences` 过渡方案。 +9. 支持单关卡图片生成计划,也支持多关卡图片生成计划。 +10. 单关卡/多关卡图片生成计划、prompt 派生、候选图写回必须封装在拼图模块工具中,通用 Agent 只调用工具。 +11. 生成完成后进入现有 `puzzle-result`,结果页保持正常表单化创作能力。 +12. 结果页里点击“立即玩”必须直接进入 `puzzle-runtime`,复用现有 `startLocalPuzzleRun` 或 `startPuzzleRun` 链路。 +13. 用户在 Agent 对话中提出自然语言修改时,必须转成结构化字段 patch,再通过拼图模块 Tool 写回同一份草稿。 +14. SSE 必须推送六模块阶段:感知、思考、记忆、行动、反思、协作,以及模板选择、积分范围、关卡计划、目标 session。 +15. 非拼图模板必须在 Agent 可见能力中标记为 `unsupported`,不能创建 RPG、Match3D、大鱼、方洞等 session。 + +### 3.2 明确不做 + +1. 不开放非拼图模板创建。 +2. 不做多模板 AB 测试,当前只展示一个可用拼图模板。 +3. 不把模板选择改成关键词规则识别。 +4. 不重写 `PuzzleRuntimeShell` 的核心玩法。 +5. 不重做作品广场、推荐流和排行榜。 +6. 不把 LLM 调用、生图、OSS 上传放进 SpacetimeDB reducer。 +7. 不在 UI 里展示大段规则说明、字段说明或内部 JSON。 +8. 不要求 Phase 1 完成长期记忆和向量检索;只保留短期会话摘要和审计快照。 + +--- + +## 4. 用户主流程 + +```text +平台创作中心 +-> 创意互动 Agent 输入框 +-> 用户输入文字和可选图片 +-> Agent 感知素材 +-> Agent 思考并选择拼图模板 +-> 前端展示多个拼图模板卡 +-> 用户选择一个模板 +-> 前端弹出该模板的关卡模式、关卡数和预计光点范围确认面板 +-> Agent 调用拼图模块 Tool 生成草稿字段和图片计划 +-> 拼图模块创建 / 更新 puzzle_agent_session +-> 拼图模块生成单关卡或多关卡图片候选 +-> 前端进入 puzzle-result +-> 用户可表单编辑或用 Agent 自然语言编辑字段 +-> 用户点击立即玩 +-> 进入 puzzle-runtime +-> 返回结果页时仍保留同一草稿上下文 +``` + +--- + +## 5. 页面与交互要求 + +### 5.1 创意 Agent 入口 + +入口位置: + +1. 优先复用平台创作中心现有入口,不新建独立站点。 +2. 在玩法类型选择里新增或复用“智能创作 Agent”入口。 +3. 当前入口进入后只允许生成拼图,其他模板显示为暂不支持能力,不可点击创建。 + +输入能力: + +1. 文本输入必填或可由图片补足。 +2. 支持上传 1 张图片作为创作参考。 +3. 图片上传后前端只展示缩略图,不把大 data URL 写入长期状态。 +4. 发送后按钮进入 busy 状态,SSE 阶段推进。 + +### 5.2 模板选择确认卡 + +Agent 生成草稿前必须展示独立确认卡。确认卡字段: + +| UI 字段 | 数据来源 | 要求 | +| --- | --- | --- | +| 模板标题 | `PuzzleCreativeTemplateSelection.title` | 当前为拼图模板名称。 | +| 模板预览 | `previewImageSrc` | 可为空;为空时用现有拼图封面样式占位,不写说明长文。 | +| 选择理由 | `reason` | 一到两句中文,不展示内部推理链。 | +| 关卡模式 | `selectedLevelMode` | 单关卡 / 多关卡。 | +| 计划关卡数 | `plannedLevelCount` | 在模板 min/max 内。 | +| 预计光点 | `costRange` | 显示 `minPoints` 到 `maxPoints` 光点。 | + +交互: + +1. 用户点“确认”后调用 `confirm_puzzle_template`。 +2. 用户点“调整”时弹出独立面板修改关卡模式和关卡数。 +3. 用户点“取消”回到 Agent 输入态。 +4. 未确认模板前不允许创建拼图草稿。 + +### 5.3 拼图结果页 + +Phase 1 不新建新结果页,复用 `PuzzleResultView`。 + +必须保证: + +1. 结果页显示作品标题、作品描述、作品标签、关卡名称、关卡图面描述、关卡图面参考。 +2. 现有表单化修改是正常创作流程,不是补丁模式。 +3. Agent 自然语言修改与表单修改写入同一份草稿。 +4. 多关卡草稿每关都能生成、选图、试玩。 +5. “立即玩”入口对有正式图的草稿可用。 + +### 5.4 立即玩与返回 + +当前前端已有 `startPuzzleTestRunFromDraft`,会把草稿同步到 work profile,再调用 `startLocalPuzzleRun(item)` 进入本地运行态。Phase 1 可以复用这条链路。 + +验收要求: + +1. 从 Agent 生成草稿进入结果页后,用户点击关卡的试玩按钮直接进入 `puzzle-runtime`。 +2. 运行态返回时回到同一个 `puzzle-result`。 +3. 返回后表单草稿状态、已选图片和多关卡列表不丢失。 + +--- + +## 6. 数据契约 + +### 6.1 新增 TypeScript contract + +新增文件: + +```text +packages/shared/src/contracts/creativeAgent.ts +packages/shared/src/contracts/puzzleCreativeTemplate.ts +``` + +`creativeAgent.ts` 至少包含: + +```ts +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 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 CreativeTargetSessionBinding { + playType: 'puzzle'; + targetSessionId: string; + targetStage: 'puzzle-agent-workspace' | 'puzzle-result' | 'puzzle-runtime'; + resultProfileId: string | null; +} +``` + +`puzzleCreativeTemplate.ts` 至少包含: + +```ts +export interface PuzzleTemplateCostRange { + minPoints: number; + maxPoints: number; + pricingUnit: 'point'; + reason: string; +} + +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; + requiredDraftFields: PuzzleDraftEditableFieldPath[]; + imagePolicy: PuzzleTemplateImageGenerationPolicy; +} + +export type PuzzleDraftEditableFieldPath = + | 'workTitle' + | 'workDescription' + | 'workTags' + | 'levels[].levelName' + | 'levels[].pictureDescription' + | 'levels[].pictureReference'; + +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; +} +``` + +### 6.2 Rust shared-contracts + +新增文件: + +```text +server-rs/crates/shared-contracts/src/creative_agent.rs +server-rs/crates/shared-contracts/src/puzzle_creative_template.rs +``` + +同步在 `server-rs/crates/shared-contracts/src/lib.rs` 导出。 + +Rust DTO 要使用 `#[serde(rename_all = "camelCase")]`,与前端字段一致。 + +### 6.3 拼图字段映射 + +Agent 对外字段与现有拼图字段的 Phase 1 映射: + +| Agent 字段 | 目标字段 | 当前代码兼容说明 | +| --- | --- | --- | +| `workTitle` | `PuzzleResultDraft.workTitle` | 直接写入。 | +| `workDescription` | `PuzzleResultDraft.workDescription` | 同步给现有 `summary` 作为兼容派生。 | +| `workTags` | `PuzzleResultDraft.workTags` | 若底层仍是 `themeTags`,adapter 映射到 `themeTags`。 | +| `levels[].levelName` | `PuzzleResultDraft.levels[].levelName` | 多关卡逐关写入。 | +| `levels[].pictureDescription` | `PuzzleResultDraft.levels[].pictureDescription` | 直接写入并用于生成图片 prompt。 | +| `levels[].pictureReference` | `PuzzleResultDraft.levels[].pictureReference` | 已采用正式字段方案,空值表示仅按图面描述生成。 | + +派生字段: + +1. `summary` 由 `workDescription` 派生。 +2. `themeTags` 由 `workTags` 兼容映射。 +3. `anchorPack`、`creatorIntent`、`forbiddenDirectives` 由拼图模块内部生成或兜底。 +4. `imagePrompt` 只属于图片生成计划,不是草稿表单字段。 +5. `candidates`、`coverImageSrc`、`coverAssetId` 是图片生成和选图结果。 + +--- + +## 7. 后端实现细节 + +### 7.1 新增 `platform-agent` crate + +路径: + +```text +server-rs/crates/platform-agent +``` + +建议文件: + +```text +Cargo.toml +src/lib.rs +src/error.rs +src/langchain_adapter.rs +src/apimart_gpt5_adapter.rs +src/function_agent.rs +src/tool_registry.rs +src/callbacks.rs +src/output_parser.rs +src/puzzle_phase1_agent.rs +``` + +职责: + +1. 依赖 `langchainrust = "0.2.18"` 或本地验证可用版本。 +2. 对外只暴露项目自己的 trait 和 struct。 +3. 封装 FunctionCallingAgent / AgentExecutor 细节。 +4. 通过 APIMart Responses API 显式使用 `gpt-5`。 +5. 支持 `input_text` / `input_image` 多模态内容块。 +6. 支持注册 typed tool。 +7. 支持回调阶段事件,用于 SSE。 +8. 支持 JSON typed output parser。 +9. 控制最大迭代次数、超时和工具调用次数。 + +`platform-agent` 对外 trait: + +```rust +#[async_trait::async_trait] +pub trait CreativeAgentExecutor { + async fn run_puzzle_phase1( + &self, + input: PuzzlePhase1AgentInput, + callbacks: CreativeAgentCallbacks, + ) -> Result; +} +``` + +如果 LangChain-Rust PoC 编译受阻: + +1. `platform-agent` 仍保留 crate 和 trait。 +2. 可以提供 `MockLangChainRustAgentExecutor` 用于 API 与前端联调。 +3. 可以通过 `platform-llm` 多模态 Responses client 桥接 APIMart `gpt-5`,保持同一套 typed Tool registry。 +4. PRD 验收时必须有一个测试证明 LangChain-Rust adapter 能构造并执行最小工具调用,同时有一个测试证明 APIMart `gpt-5` 请求体包含 `input_text` 和 `input_image`;不能只保留 mock。 + +### 7.2 新增 `module-creative-agent` + +路径: + +```text +server-rs/crates/module-creative-agent +``` + +建议文件: + +```text +Cargo.toml +src/lib.rs +src/domain.rs +src/application.rs +src/commands.rs +src/errors.rs +``` + +职责: + +1. 定义创意 Agent 会话阶段、消息、模板确认状态、target binding。 +2. 校验状态迁移: + - `perceiving -> selecting_puzzle_template` + - `selecting_puzzle_template -> waiting_template_confirmation` + - `waiting_template_confirmation -> planning_puzzle_levels` + - `planning_puzzle_levels -> acting` + - `acting -> target_ready` +3. 拒绝未确认模板就创建草稿。 +4. 拒绝非拼图 target binding。 +5. 校验模板积分范围存在。 + +不做: + +1. 不调用 LLM。 +2. 不生成图片。 +3. 不访问 OSS。 +4. 不写 SpacetimeDB。 + +### 7.3 拼图模板协议与工具 + +新增或扩展: + +```text +server-rs/crates/module-puzzle/src/creative_templates.rs +server-rs/crates/module-puzzle/src/creative_tools.rs +``` + +在 `server-rs/crates/module-puzzle/src/lib.rs` 导出。 + +首版模板: + +```rust +pub const PUZZLE_PHASE1_TEMPLATE_ID: &str = "puzzle.default-creative"; +``` + +模板配置: + +1. `title = "创意拼图"` +2. `supportedLevelMode = single_or_multi` +3. `minLevelCount = 1` +4. `maxLevelCount = 6` +5. `defaultLevelCount = 1` +6. `costRange.minPoints = 2` +7. `costRange.maxPoints = 12` +8. `reason = "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准"` + +工具函数: + +```rust +pub fn retrieve_puzzle_template_catalog() -> Vec; +pub fn validate_puzzle_template_selection(selection: &PuzzleCreativeTemplateSelection) -> Result<(), PuzzleFieldError>; +pub fn build_puzzle_draft_from_creative_fields(input: CreativePuzzleDraftToolInput) -> Result; +pub fn plan_puzzle_level_images(input: PuzzleLevelImagePlanInput) -> Result; +pub fn apply_puzzle_draft_field_patch(draft: PuzzleResultDraft, patch: PuzzleDraftFieldPatch) -> Result; +``` + +草稿构建规则: + +1. `workTitle` 必须非空,最多沿用现有拼图标题长度限制。 +2. `workDescription` 必须非空,写入 `work_description`,并同步派生 `summary`。 +3. `workTags` 归一化、去重、裁剪到现有标签数量限制;底层兼容写入 `theme_tags`。 +4. `levels` 长度必须在模板 min/max 内。 +5. 每个 level 的 `levelId` 由拼图模块生成,不信任模型传入。 +6. `pictureReference` 如果是 URL 或 asset id,只作为参考,不直接当正式图,除非图片工具明确产出 uploaded candidate。 +7. 所有草稿必须再经过现有 `normalize_puzzle_draft` / preview 校验。 + +### 7.4 API facade + +新增文件: + +```text +server-rs/crates/api-server/src/creative_agent.rs +server-rs/crates/api-server/src/creative_agent_sse.rs +``` + +在 `main.rs` 增加 `mod creative_agent;`,在 `app.rs` 挂路由。 + +路由: + +```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-template +POST /api/runtime/creative-agent/sessions/{sessionId}/draft-edits/stream +POST /api/runtime/creative-agent/sessions/{sessionId}/cancel +``` + +鉴权: + +1. 全部路由必须 `require_bearer_auth`。 +2. `ownerUserId` 只取 authenticated token,不信任 payload。 +3. 访问 session 必须校验 owner。 + +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 +session +error +done +``` + +事件要求: + +1. `messages/stream` 首轮必须先返回 `puzzle_template_catalog`,并把 `puzzleTemplateCatalog` 写入 session,`puzzleTemplateSelection` 保持为空。 +2. 用户点击某个模板后,前端基于模板协议打开独立配置确认面板,确认关卡模式、关卡数和预计光点范围。 +3. `confirm-template` 后才允许写入 `puzzle_template_selection`、`puzzle_level_plan` 并创建拼图草稿。 +4. `target_session` 必须带 `targetSessionId` 和可跳转 stage。 +5. 流式失败时保留已显示文字,并发送结构化 `error`。 +6. `done` 前必须发送最终 `session` 快照。 + +### 7.5 与现有拼图 facade 的关系 + +Phase 1 的创意 Agent 不直接写 `puzzle_agent_session` 表。它必须通过现有 `spacetime-client` facade 调用拼图 procedure 或新增拼图 procedure。 + +复用: + +1. `create_puzzle_agent_session` +2. `compile_puzzle_agent_draft` +3. `save_puzzle_form_draft` +4. `save_puzzle_generated_images` +5. `select_puzzle_cover_image` +6. `publish_puzzle_work` + +新增优先: + +```text +compile_puzzle_creative_draft +save_puzzle_creative_level_plan +apply_puzzle_draft_field_patch +``` + +如果为了减少 schema 变动不新增 procedure,必须在 `api-server` 层通过现有 action 组合完成,但组合逻辑仍要调用 `module-puzzle::creative_tools`,不能把字段拼接规则散落在 `creative_agent.rs`。 + +### 7.6 SpacetimeDB 持久化 + +Phase 1 最小新增表: + +```text +creative_agent_session +creative_agent_message +creative_agent_target_binding +``` + +建议表字段: + +`creative_agent_session`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `session_id` | `String` | 主键,`creative-session-*`。 | +| `owner_user_id` | `String` | 鉴权用户。 | +| `stage` | enum/string | 当前阶段。 | +| `input_summary_json` | `String` | 文本、图片摘要,不存大图。 | +| `template_selection_json` | `String` | 已展示或已确认的模板选择快照。 | +| `level_plan_json` | `String` | 单关卡 / 多关卡图片计划。 | +| `target_binding_json` | `String` | 拼图 session 绑定。 | +| `created_at_micros` | `i64` | 创建时间。 | +| `updated_at_micros` | `i64` | 更新时间。 | + +`creative_agent_message`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `message_id` | `String` | 主键。 | +| `session_id` | `String` | creative session。 | +| `role` | enum/string | user / assistant / system。 | +| `kind` | enum/string | chat / stage / action_result / warning。 | +| `text` | `String` | 展示文本。 | +| `created_at_micros` | `i64` | 创建时间。 | + +`creative_agent_target_binding`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `binding_id` | `String` | 主键。 | +| `session_id` | `String` | creative session。 | +| `play_type` | `String` | Phase 1 固定 puzzle。 | +| `target_session_id` | `String` | puzzle agent session。 | +| `result_profile_id` | `String` | 可为空。 | +| `created_at_micros` | `i64` | 创建时间。 | + +SpacetimeDB 约束: + +1. reducer / procedure 只做确定性状态写入。 +2. 不在 reducer 内调用 LLM、OSS、生图、HTTP、随机外部服务。 +3. 使用 `ctx.sender()` 做授权,不信任 payload 身份。 +4. 表结构变更必须同步: + - `server-rs/crates/spacetime-module/src/migration.rs` + - `docs/technical/SPACETIMEDB_TABLE_CATALOG.md` + - `server-rs/crates/spacetime-client/src/module_bindings` +5. 若采用过渡 JSON 字段承载 `pictureReference`,需要在表目录中标注迁移计划。 + +--- + +## 8. 前端实现细节 + +### 8.1 新增服务层 + +新增: + +```text +src/services/creative-agent/creativeAgentClient.ts +src/services/creative-agent/index.ts +``` + +客户端方法: + +```ts +createCreativeAgentSession(payload) +getCreativeAgentSession(sessionId) +streamCreativeAgentMessage(sessionId, payload, options) +confirmCreativePuzzleTemplate(sessionId, payload) +streamCreativeDraftEdit(sessionId, payload, options) +cancelCreativeAgentSession(sessionId) +``` + +优先复用 `createCreationAgentClient` 的 SSE 处理能力;如果事件类型不兼容,新增轻量 typed SSE parser,不改旧拼图 agent client 的行为。 + +### 8.2 新增或扩展 UI 组件 + +建议新增: + +```text +src/components/creative-agent/CreativeAgentWorkspace.tsx +src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx +src/components/creative-agent/CreativeAgentStageTimeline.tsx +src/components/creative-agent/CreativeAgentInputComposer.tsx +``` + +平台入口接入: + +```text +src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +src/components/platform-entry/platformEntryCreationTypes.ts +src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +``` + +UI 约束: + +1. 移动端优先。 +2. 确认模板使用独立底部抽屉或弹窗,不在当前卡片下方展开大块内容。 +3. 不展示规则说明长文。 +4. 按钮使用图标或简短动词。 +5. 阶段文本保持短句,例如“正在理解素材”“正在选择拼图模板”“正在生成关卡图片”。 +6. 非拼图模板只作为暂不支持能力展示,不出现可创建按钮。 + +### 8.3 状态接入 + +新增 selection stage: + +```ts +'creative-agent-workspace' +``` + +跳转规则: + +1. 创意 Agent target ready 后,如果 `targetBinding.targetStage === 'puzzle-result'`,读取目标 puzzle session,设置 `puzzleSession`,进入 `selectionStage = 'puzzle-result'`。 +2. 如果目标还在 workspace,进入现有 `puzzle-agent-workspace`。 +3. 点击立即玩沿用 `startPuzzleTestRunFromDraft`。 +4. 从 `puzzle-runtime` 返回沿用 `puzzleRuntimeReturnStage = 'puzzle-result'`。 + +### 8.4 自然语言修改表单内容 + +结果页内 Agent 对话区可以 Phase 1 简化为: + +1. 用户在结果页输入“把标题轻松一点”“第二关参考图换成这张”。 +2. 前端调用 `streamCreativeDraftEdit`,传 `creativeSessionId`、`targetPuzzleSessionId`、`currentDraft`、`instruction`。 +3. 后端返回 `editInstructions` 和最新 puzzle session。 +4. 前端更新 `puzzleSession`,结果页表单自动显示新值。 + +字段 patch 只允许: + +```text +workTitle +workDescription +workTags +levels[].levelName +levels[].pictureDescription +levels[].pictureReference +``` + +若用户要求修改图片候选、发布状态、作者、积分等,不走字段 patch,Agent 返回需要使用现有按钮或暂不支持。 + +--- + +## 9. LangChain-Rust Agent 行为要求 + +### 9.1 系统提示词边界 + +Agent 必须被提示为: + +1. 你是创意互动内容生成 Agent,不是规则分类器。 +2. 当前产品只开放拼图模板。 +3. 你要相信自己的图文理解能力,主动形成拼图创意。 +4. 即使只有拼图可用,也必须显式选择模板并展示预计光点范围。 +5. 未经用户确认模板,不得创建草稿。 +6. 你只能调用已注册工具。 +7. 可写草稿字段只限 `workTitle`、`workDescription`、`workTags`、`levels[].levelName`、`levels[].pictureDescription`、`levels[].pictureReference`。 +8. 当不确定时只问一个关键问题。 +9. 非拼图模板不能创建 session,只能说明当前暂不支持并尝试转成拼图方案。 + +### 9.2 Tool 注册 + +Phase 1 注册工具: + +```text +retrieve_puzzle_template_catalog +select_puzzle_template +confirm_puzzle_template +create_puzzle_agent_session +compile_puzzle_creative_draft +plan_puzzle_level_images +generate_puzzle_level_images +apply_puzzle_draft_natural_language_edit +validate_puzzle_result_preview +start_puzzle_draft_test_run +``` + +不注册: + +```text +create_rpg_session +create_match3d_session +create_big_fish_session +create_square_hole_session +``` + +这些非拼图能力只能作为 `unsupportedCapabilities` 出现在模板目录里。 + +### 9.3 六模块阶段在 Phase 1 的最小实现 + +| 模块 | Phase 1 实现 | +| --- | --- | +| 感知 | 总结用户文本和单图参考,形成 `inputSummary`。 | +| 思考 | 模型选择拼图模板、关卡模式和关卡数。 | +| 记忆 | 只保留当前会话短期消息和草稿快照,不做长期检索。 | +| 行动 | 通过 Tool 调拼图模板、草稿、图片生成和 session 绑定。 | +| 反思 | 检查是否已展示积分范围、是否已确认模板、字段是否完整、关卡数是否越界。 | +| 协作 | Phase 1 可用内部子任务名模拟协作阶段,不强制多 Agent 并发。 | + +--- + +## 10. 并行任务拆分 + +下面任务可以并行执行。每个任务写入范围尽量不重叠,减少冲突。 + +### 任务 A:共享契约与类型 + +负责人类型:前后端契约开发。 + +写入范围: + +```text +packages/shared/src/contracts/creativeAgent.ts +packages/shared/src/contracts/puzzleCreativeTemplate.ts +packages/shared/src/contracts/index.ts +server-rs/crates/shared-contracts/src/creative_agent.rs +server-rs/crates/shared-contracts/src/puzzle_creative_template.rs +server-rs/crates/shared-contracts/src/lib.rs +``` + +实现细节: + +1. 定义 CreativeAgentStage、session、message、SSE event、template selection、level plan、target binding。 +2. 定义 PuzzleCreativeTemplateProtocol、PuzzleTemplateCostRange、PuzzleDraftEditableFieldPath。 +3. 保证 TS/Rust 字段 camelCase 对齐。 +4. 增加 contract 单测或 serde roundtrip 测试。 + +验收: + +```bash +cd server-rs +cargo test -p shared-contracts creative_agent +npm run typecheck +``` + +落地记录: + +1. 任务 A 已冻结 `pictureReference` 为正式字段方案:`levels[].pictureReference` 已进入 TS/Rust 新契约,任务 B/D/F 后续应补齐拼图草稿正式字段、迁移和前端读取,不再采用 `metadata.levelPictureReferences` 过渡方案。 + +### 任务 B:拼图模块模板协议与草稿工具 + +负责人类型:拼图领域开发。 + +写入范围: + +```text +server-rs/crates/module-puzzle/src/creative_templates.rs +server-rs/crates/module-puzzle/src/creative_tools.rs +server-rs/crates/module-puzzle/src/application.rs +server-rs/crates/module-puzzle/src/domain.rs +server-rs/crates/module-puzzle/src/lib.rs +``` + +实现细节: + +1. 提供首版 `puzzle.default-creative` 模板。 +2. 实现模板选择校验。 +3. 实现 `CreativePuzzleDraftToolInput -> PuzzleResultDraft`。 +4. 实现 `workTags -> themeTags` 兼容映射。 +5. 实现 `pictureReference` 正式字段写入。 +6. 实现多关卡 plan 校验。 +7. 实现字段 patch 校验,禁止修改非可编辑字段。 + +验收: + +```bash +cd server-rs +cargo test -p module-puzzle creative +cargo test -p module-puzzle puzzle_draft +``` + +### 任务 C:LangChain-Rust `platform-agent` PoC + +负责人类型:Rust 平台开发。 + +写入范围: + +```text +server-rs/Cargo.toml +server-rs/crates/platform-llm/src/lib.rs +server-rs/crates/platform-agent/Cargo.toml +server-rs/crates/platform-agent/src/* +``` + +实现细节: + +1. 新增 workspace member。 +2. 封装 LangChain-Rust adapter。 +3. 扩展 `platform-llm` Responses 请求体,支持 `input_text` / `input_image` 和 `image_url`。 +4. 固定创意 Agent 模型为 `gpt-5`,不走旧 `CREATION_TEMPLATE_LLM_MODEL`。 +5. 定义 tool registry 和 callback event。 +6. 实现最小 FunctionCallingAgent / AgentExecutor 调用。 +7. 提供 mock executor 便于 API 测试。 +8. 限制最大工具调用次数和超时。 + +验收: + +```bash +cd server-rs +cargo check -p platform-agent +cargo test -p platform-agent +cargo test -p platform-llm responses_multimodal +``` + +### 任务 D:创意 Agent 领域与 SpacetimeDB 持久化 + +负责人类型:后端 DDD / SpacetimeDB 开发。 + +写入范围: + +```text +server-rs/Cargo.toml +server-rs/crates/module-creative-agent/Cargo.toml +server-rs/crates/module-creative-agent/src/* +server-rs/crates/spacetime-module/src/creative_agent.rs +server-rs/crates/spacetime-module/src/lib.rs +server-rs/crates/spacetime-module/src/migration.rs +server-rs/crates/spacetime-client/src/creative_agent.rs +server-rs/crates/spacetime-client/src/mapper.rs +docs/technical/SPACETIMEDB_TABLE_CATALOG.md +``` + +实现细节: + +1. 新增 creative agent session/message/binding 表。 +2. 新增 create/get/append/finalize/confirm/bind procedure。 +3. reducer 使用 `ctx.sender()` 鉴权。 +4. procedure 不调用任何外部副作用。 +5. 更新 migration 和表目录。 +6. 生成 bindings。 + +验收: + +```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 +``` + +### 任务 E:API / SSE facade + +负责人类型:Axum 后端开发。 + +写入范围: + +```text +server-rs/crates/api-server/src/main.rs +server-rs/crates/api-server/src/app.rs +server-rs/crates/api-server/src/creative_agent.rs +server-rs/crates/api-server/src/creative_agent_sse.rs +server-rs/crates/api-server/src/state.rs +server-rs/crates/api-server/Cargo.toml +``` + +实现细节: + +1. 挂载 creative-agent 路由。 +2. 创建 session 写 SpacetimeDB。 +3. message stream 调 `platform-agent`,模型请求必须使用 APIMart Responses `gpt-5` 多模态输入。 +4. 工具调用通过 `module-puzzle::creative_tools` 和 `spacetime-client` 执行。 +5. confirm-template 后才创建/编译 puzzle session。 +6. draft edit stream 转字段 patch 并写回 puzzle session。 +7. SSE 推送阶段和最终 session。 +8. 错误使用现有 `AppError` / `json_success_body` 风格。 + +验收: + +```bash +cd server-rs +cargo check -p api-server +npm run api-server +``` + +启动后检查: + +```text +GET /healthz +POST /api/runtime/creative-agent/sessions +POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream +``` + +落地记录(2026-05-05): + +1. 任务 E 已按 Phase 1 facade 范围挂载 creative-agent API 与 SSE 事件;首轮会话快照暂存在 `api-server` 运行态并按 authenticated user 做 owner 校验。 +2. 当前不新增 `creative_agent_*` SpacetimeDB 表;确认模板后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session`,避免把任务 D 的持久化范围并入任务 E。 +3. 后续任务 D 收口 creative session 表后,应将运行态会话存储替换为 SpacetimeDB facade,保留现有 API/SSE 契约不变。 +4. 2026-05-05 补充:`messages/stream` 已从单个大型 `async_stream` 改为 `tokio::spawn` 后台任务通过 `mpsc` / `UnboundedReceiverStream` 发送 SSE,降低 Windows debug worker 线程栈压力;回归测试已实际消费 SSE body 并覆盖模板目录、等待确认和 done 事件,避免只测路由状态遗漏断流问题。 + +### 任务 F:前端创意 Agent 工作区与拼图跳转 + +负责人类型:前端开发。 + +写入范围: + +```text +src/services/creative-agent/* +src/components/creative-agent/* +src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +src/components/platform-entry/platformEntryCreationTypes.ts +src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +``` + +实现细节: + +1. 新增 creative agent client。 +2. 新增输入工作区。 +3. 新增模板确认面板。 +4. 渲染 SSE 阶段。 +5. target ready 后拉取 puzzle session 并进入 `puzzle-result`。 +6. 结果页自然语言修改入口调用 draft edit stream。 +7. 立即玩沿用现有拼图试玩链路。 +8. 移动端优先适配。 + +验收: + +```bash +npm run typecheck +npm run test -- PuzzleResultView +``` + +落地记录: + +1. 任务 F 已新增前端 `creative-agent` client、SSE 解析器与移动端优先的 `CreativeAgentWorkspace`,按 PRD 路由接入 `/api/runtime/creative-agent/sessions`、`messages/stream`、`confirm-template`、`draft-edits/stream` 和 `cancel`;模板确认卡已补模板预览兜底,并把“调整”收进独立弹层,避免在确认卡下方展开内嵌内容。 +2. 平台创作入口新增“智能创作”,当前只进入创意 Agent 工作区;原“拼图”入口继续保留旧拼图表单工作区,避免破坏既有创作链路。 +3. `creative-agent-workspace` 在 target ready 后拉取目标 `puzzle_agent_session`,按 `targetStage` 进入 `puzzle-result` 或旧拼图工作区;`puzzle-result` 的试玩仍复用现有 `startPuzzleTestRunFromDraft -> startLocalPuzzleRun` 链路。 +4. `PuzzleResultView` 已显示并编辑 `levels[].pictureReference`,并在智能创作链路进入结果页时显示自然语言修订输入条,调用 `streamCreativeDraftEdit` 后回写同一份拼图 session。 +5. 前端定向验证已通过:`npm run typecheck`、`npm run check:encoding`、`npx eslint src/services/creative-agent/creativeAgentClient.ts src/services/creative-agent/creativeAgentSse.ts src/services/creative-agent/index.ts src/components/creative-agent/CreativeAgentWorkspace.tsx src/components/creative-agent/CreativeAgentInputComposer.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.tsx src/components/creative-agent/CreativeAgentStageTimeline.tsx src/components/creative-agent/creativeAgentViewModel.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/PlatformEntryCreationTypeModal.tsx src/components/puzzle-result/PuzzleResultView.tsx --max-warnings 0`、`npm run test -- src/services/creative-agent/creativeAgentSse.test.ts`、`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create hub hides RPG"`。 + +6. 2026-05-05 补充:智能创作工作区的过程区不再只展示最近 6 个短进度标签,改为基于 SSE 事件生成详细过程项,覆盖阶段切换、工具开始/完成、模板选择、预计光点、关卡计划、反思、目标绑定、错误和完成事件;刷新或非流式恢复时会从 session 快照补模板、关卡计划和目标绑定,移动端使用紧凑可滚动列表。 + +7. 2026-05-05 补充:流式过程新增 `thought_summary_delta` 事件,只展示可给用户看的“思考摘要”,例如素材理解、模板选择摘要和关卡规划摘要;该事件不承载模型内部推理链,前端按 `thoughtId` 合并多段 delta 后在过程面板中显示为一条持续更新的“思考摘要”。 +8. 2026-05-06 补充:智能创作 Agent 的模板步骤拆成两段,`messages/stream` 只返回 `puzzle_template_catalog` 和 session 里的 `puzzleTemplateCatalog`;前端先展示多个模板卡,用户选中后才打开独立确认面板并提交 `confirm-template`。`puzzle_template_selection`、`puzzle_cost_range` 和 `puzzle_level_plan` 不再由首轮消息流自动发送。工作区在 `sessionId` 变化时必须清空本地待确认模板,避免上一会话已点开的确认面板残留到新会话。 + +8. 2026-05-05 补充:过程项的运行态与完成态要严格区分,只有当前正在执行的阶段或未结束的思考摘要可以显示 active 样式;`waiting_template_confirmation`、`target_ready` 和已完成的工具调用都应渲染为静态状态,不得从一开始一路转到结束。工具开始/完成事件必须使用同一个 `toolCallId`,前端才可稳定收口为完成态。 + +手工 smoke: + +1. 输入一句文字生成拼图。 +2. 确认模板。 +3. 进入结果页。 +4. 修改表单字段。 +5. 用自然语言改标题。 +6. 点击立即玩。 +7. 返回结果页。 + +### 任务 G:端到端测试与回归 + +负责人类型:测试 / 全栈收口。 + +写入范围: + +```text +server-rs/crates/api-server/tests/* +src/components/creative-agent/*.test.tsx +src/services/creative-agent/*.test.ts +docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md +``` + +实现细节: + +1. API 测试覆盖未确认模板不能生成草稿。 +2. API 测试覆盖非拼图模板不能创建 session。 +3. 拼图模块测试覆盖单关卡和多关卡 draft。 +4. 前端测试覆盖模板确认卡必须出现积分范围。 +5. 前端测试覆盖 target ready 跳转 puzzle-result。 +6. 手工验证移动端视口。 + +验收: + +```bash +npm run check:encoding +npm run typecheck +npm run test +cd server-rs +cargo test -p module-puzzle creative +cargo test -p shared-contracts creative_agent +cargo check -p api-server +``` + +落地记录(2026-05-05): + +1. 任务 G 已补 API facade 回归:`cargo test -p api-server creative_agent` 覆盖未确认模板时 `draft-edits/stream` 返回错误且不绑定 `targetBinding`,以及 `rpg.unsupported` 非拼图模板确认被拒绝且不创建目标 session。 +2. 任务 G 已补拼图模块回归:`cargo test -p module-puzzle creative` 同时覆盖单关卡、多关卡 draft 和 `pictureReference` / `workDescription -> summary` / `workTags -> themeTags` 的 Phase 1 映射。 +3. 任务 G 已补前端回归:`CreativeAgentTemplateConfirmPanel.test.tsx` 覆盖模板确认卡光点范围,`CreativeAgentWorkspace.test.tsx` 覆盖 target ready 后展示打开草稿入口,并通过 `resolveCreativeAgentTargetSelectionStage` 固定 `puzzle-result` 跳转落点;`creativeAgentSse.test.ts` 覆盖 creative Agent SSE 事件与 draft edit payload。 +4. 定向验收已通过:`npm run check:encoding`、`npm run test -- src/components/creative-agent/CreativeAgentWorkspace.test.tsx src/components/creative-agent/CreativeAgentTemplateConfirmPanel.test.tsx src/services/creative-agent/creativeAgentSse.test.ts`、`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`、`cargo test -p module-puzzle creative`、`cargo test -p module-puzzle puzzle_draft`、`cargo test -p shared-contracts creative_agent`、`cargo test -p api-server creative_agent`、`cargo check -p api-server`。 +5. 全量 `npm run typecheck` 当前被视觉小说链路既有类型错误阻塞,包含 `packages/shared/src/contracts/visualNovel.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 的 `onSelectVisualNovel` 缺失和 `src/components/visual-novel-result/VisualNovelResultView.tsx`;全量 `npm run test` 当前被既有 RPG 首页搜索 / 拼图 workspace 旧断言等非本任务用例阻塞。上述阻塞不来自本次 creative-agent Task G 回归补测。 + +--- + +## 11. 串行依赖关系 + +推荐排期: + +```text +第 1 批并行: +任务 A 共享契约 +任务 B 拼图模块工具 +任务 C platform-agent PoC + +第 2 批并行: +任务 D SpacetimeDB 持久化 +任务 F 前端工作区静态 UI 与 client mock + +第 3 批: +任务 E API / SSE facade +任务 F 接真实 API + +第 4 批: +任务 G 测试与回归 +``` + +阻塞关系: + +1. 任务 E 依赖 A、B、C 的基础类型和 trait。 +2. 任务 F 可先用 A 的 TS contract 和 mock 数据开发,不必等 E 完成。 +3. 任务 D 如果决定不新增表,E 可先用现有 puzzle session 做最小闭环,但必须在 PRD 和技术债记录中注明。 +4. `pictureReference` 的正式字段方案会影响 B、D、F;必须在任务 A 完成时冻结。 + +--- + +## 12. 验收标准 + +功能验收: + +1. 用户从创作中心进入创意 Agent。 +2. 用户输入文字和 1 张图片后,SSE 显示感知和思考阶段。 +3. Agent 显式展示拼图模板确认卡。 +4. 确认卡显示预计光点范围。 +5. 未确认模板时不会创建拼图草稿。 +6. 确认模板后进入草稿生成。 +7. 生成的草稿字段对齐 `workTitle`、`workDescription`、`workTags`、`levels[].levelName`、`levels[].pictureDescription`、`levels[].pictureReference`。 +8. 单关卡计划能生成一关并进入结果页。 +9. 多关卡计划能生成多关卡,每关有独立名称、图面描述和图面参考。 +10. 结果页表单修改和 Agent 自然语言修改写回同一份草稿。 +11. 点击立即玩进入 `puzzle-runtime`。 +12. 从 `puzzle-runtime` 返回后仍回到同一 `puzzle-result`。 +13. 输入明显非拼图玩法需求时,Agent 不创建其它玩法 session,只能转成拼图方案或提示暂不支持。 + +技术验收: + +1. `platform-agent` crate 可独立 `cargo check`。 +2. LangChain-Rust adapter 有最小工具调用测试。 +3. APIMart `gpt-5` Responses 请求体支持文本和图像内容块。 +4. 拼图模板协议和草稿工具在 `module-puzzle` 内。 +5. `api-server` 不散落拼图字段推导规则。 +6. SpacetimeDB reducer 不包含外部副作用。 +7. schema 变更同步 migration、表目录和 bindings。 +8. 前端不直接承担正式业务真相。 + +--- + +## 13. 风险与降级 + +### 13.1 LangChain-Rust API 风险 + +风险:crate API 与文档不一致或缺少预期 Function Calling 能力。 + +降级: + +1. 保持 `platform-agent` trait 不变。 +2. adapter 内部先用 `platform-llm` 的 APIMart Responses `gpt-5` 多模态 client 实现 tool-call JSON PoC。 +3. 保留 LangChain-Rust 最小编译测试和后续替换点。 +4. 不允许 API 层直接依赖第三方 crate 类型。 +5. 降级实现仍必须支持 `input_text` / `input_image`,不能退回纯文本。 + +### 13.2 `workTags` 与现有 `themeTags` 不一致 + +风险:当前 `PuzzleResultDraft` 仍使用 `themeTags`。 + +降级: + +1. 新 Agent contract 使用 `workTags`。 +2. adapter 兼容映射到 `themeTags`。 +3. 文档和测试明确这是 Phase 1 兼容层。 +4. 后续统一契约时再迁移底层字段。 + +### 13.3 `pictureReference` 字段缺失 + +风险:当前 level 只有 `pictureDescription`。 + +降级: + +1. Phase 1 已补正式字段并在 Rust/TS 草稿契约中透传。 +2. 不再新增 `metadata.levelPictureReferences` 过渡读取。 +3. 前端后续只读取 `levels[].pictureReference`,不要散落 metadata path。 + +### 13.4 多关卡图片生成成本 + +风险:一次生成多关卡可能消耗过高。 + +降级: + +1. 模板确认卡展示估算范围。 +2. 默认单关卡,用户确认多关卡后才执行。 +3. 每关 candidateCount 默认 1。 +4. 后端最终扣费仍由钱包 / 任务系统结算。 + +--- + +## 14. 最小上线清单 + +1. 契约文件合并。 +2. `platform-agent` PoC 可编译。 +3. 拼图模板协议返回一个可用模板和积分范围。 +4. 创意 Agent 路由可创建 session。 +5. SSE 可推模板确认卡。 +6. 用户确认后可创建 puzzle session。 +7. 草稿能进入 `puzzle-result`。 +8. 至少单关卡图片能生成并选为正式图。 +9. 结果页能立即试玩。 +10. 编码检查、typecheck、关键 cargo check 通过。 diff --git a/docs/prd/README.md b/docs/prd/README.md new file mode 100644 index 000000000..d87325a14 --- /dev/null +++ b/docs/prd/README.md @@ -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 原生视觉小说模板 PRD:TXT 玩法平台化接入](./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 PRD:LangChain-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。 diff --git a/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md index c67c82d84..917f5f264 100644 --- a/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md +++ b/docs/prd/TXT_MODE_CORE_GAMEPLAY_PRD_2026-04-20.md @@ -2,6 +2,8 @@ 更新时间:`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. 文档目的 这份 PRD 只定义 `Interactive-fiction-frontend` + `Interactive-fiction-backend` 中 TXT 模式在 `Genarrative` 落地时的**核心玩法闭环**。 diff --git a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md index 789acbe59..987c113b4 100644 --- a/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md +++ b/docs/technical/AUTH_LOGIN_OPTIONS_DESIGN_2026-04-21.md @@ -115,3 +115,24 @@ 2. 该失败只代表登录方式配置探测失败,不代表登录功能不可用,因此不把 `读取登录方式失败` 写入登录弹窗错误条。 3. 登录弹窗仍展示密码登录表单,玩家可继续登录后进入创作链路。 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` 正在监听。 diff --git a/docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md b/docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md new file mode 100644 index 000000000..f6e7caaf7 --- /dev/null +++ b/docs/technical/CREATIVE_INTERACTIVE_CONTENT_AGENT_TECHNICAL_SOLUTION_2026-05-05.md @@ -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 和 LangGraph,Memory 层包括 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 URI;SpacetimeDB 不保存大图 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, + pub memory: Option, + pub reasoning: Option, + pub tool_results: Vec, + pub reflection: Option, + pub target_binding: Option, + pub final_response: Option, + 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 1:LangChain-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. 规则只保留为安全、契约、成本和权限边界。 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md index 7194e9d25..1b91d4651 100644 --- a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -2,17 +2,18 @@ ## 背景 -创作中心顶部“新建作品”入口和平台创作类型弹层都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 +创作中心的模板 Tab、平台创作类型弹层和旧“新建作品”卡片配置都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 ## 落地规则 1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`。 -2. `visible` 控制玩法是否展示在新建作品入口和创作类型弹层中。 +2. `visible` 控制玩法是否展示在创作 Tab 模板入口、新建作品入口和创作类型弹层中。 3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 -5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案。 +5. `startCard` 控制旧创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案;当前创作 Tab 首屏标题固定在 `PlatformEntryFlowShellImpl.tsx`,不再由 `startCard` 控制。 6. `typeModal` 控制平台创作类型弹层标题和描述。 7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 +8. `creative-agent` 可以继续保留运行链路,但默认 `visible: false`,不出现在创作 Tab 模板入口。 ## 当前状态 @@ -20,16 +21,19 @@ | --- | --- | --- | --- | | 角色扮演 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | -| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 | +| 拼图 | 是 | 是 | 创作 Tab 默认选中并内嵌展示拼图创作表单,提交后进入拼图草稿生成 | | 抓大鹅 | 否 | 是 | 暂时从创作端入口下线,既有链路与作品能力保留 | | 方洞挑战 | 是 | 是 | 点击后进入方洞挑战 Agent 共创工作台,支持草稿、结果页、发布、试玩、作品架与广场 | | AIRP | 是 | 否 | 保留入口,显示敬请期待 | -| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 | +| 视觉小说 | 是 | 是 | 点击后进入视觉小说创作工作台 | +| 智能创作 | 否 | 是 | 入口隐藏,既有 `creative-agent` 链路保留 | ## 验收 -1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作中心顶部卡带和平台创作类型弹层应同步变化。 +1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作 Tab 模板入口、创作中心顶部卡带和平台创作类型弹层应同步变化。 2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 -5. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。 +5. 创作 Tab 首屏应显示“10分钟创作一个精品互动玩法”,并默认展示拼图创作表单。 +6. 智能创作入口隐藏后,不应出现“Hi, 朋友”“问一问百梦”或“一句话生成闪应用”等旧首页入口。 +7. 方洞挑战作品发布后应生成 `SH-` 作品号,并能从作品架、广场详情和试玩 runtime 回到同一作品详情。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index df4aa5a35..f09953e0d 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -408,7 +408,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 并发与清理规则: - 同一个 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//` 或 Jenkins 归档产物,不允许从共享 `cargo-target` 目录直接发布。 - `SCCACHE_CACHE_SIZE` 必须设置上限,避免编译缓存无限增长。 - 对 `/var/cache/genarrative-build/*/cargo-target` 或数据盘对应目录配置定期清理,建议保留最近 14 到 30 天。 diff --git a/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md index df682190b..cb7882d12 100644 --- a/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md +++ b/docs/technical/PUZZLE_TEMPLATE_FORM_AND_GPT_IMAGE_SKILL_2026-05-03.md @@ -2,44 +2,53 @@ ## 背景 -拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:先选创作模板,再补充提示词,最后直接生成首关草稿与首张拼图图。 +拼图创作入口已经从对话式 Agent 收口为填表式表单。本次改版目标是让“点击拼图创作”后的表单更接近图像创作工具的单屏体验:优先上传参考图或填写画面描述,然后直接生成首关草稿与首张拼图图。2026-05-07 起,入口表单删除 Template 模块,模板参考图只保留为历史资产,不再作为表单首屏交互。 ## 落地范围 1. `src/components/puzzle-agent/PuzzleAgentWorkspace.tsx` - - 改为顶部标题、模板横滑区、大输入框、底部操作区的布局。 + - 改为顶部标题、超大参考图上传区、大输入框、底部操作区的布局。 - 保留参考图上传、模型切换和生成草稿。 + - 删除 Template 模块与模板卡切换入口。 - 不再提供输入框底部的 `try` 示例入口。 2. `src/components/puzzle-agent/puzzleCreationTemplates.ts` - - 新增拼图创作模板数据。 - - 模板来源按社交、热点、职场学习、电商、治愈、营销、儿童教育等场景抽样。 - - 点击模板后把模板提示词写入画面描述。 + - 保留历史拼图创作模板数据,当前表单不再消费。 + - 后续若重新开放模板入口,必须先更新本文档和移动端首屏验收。 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 兼容调用流程。 - Skill 默认读取本地环境变量,不把密钥写入代码、文档或前端。 ## UI 规则 -1. 顶部只展示“创建拼图”和轻量状态标识,不写玩法规则说明。 -2. 模板区横向滚动,移动端优先;每个模板卡包含样例图、短标题和选中态。 -3. 点击模板时: - - 立即选中该模板。 - - 如果输入框为空,直接填入模板提示词。 - - 如果输入框已有内容,替换为该模板提示词,避免追加后变得冗长。 +1. 顶部主标题展示“想做个什么玩法?”和轻量状态标识,不写玩法规则说明。 +2. 上传拼图图片区优先占据首屏左侧/上方的大块区域,移动端也必须完整露出。 + - 上传区自身就是图片卡片,不再额外封装为 `platform-subpanel` 模块壳。 + - 亮色主题下上传卡片必须使用白色或暖浅色卡面,不得显示整块黑色底。 + - 上传卡片固定为 1:1 正方形,避免拼图主画面在首屏出现非正方形预期。 + - 上传卡片底部不再叠加文件名 bar;卡片下方只保留 `点击上传拼图图片` 纯文字入口。 + - 上传卡片上方固定展示 `拼图画面` 标题。 + - 叠在上传卡片上的 `AI重绘` 和图标必须和卡面保持足够对比,避免浅色主题重映射后不可读。 +3. 画面描述输入框高度约为旧版大输入框的 1/2,避免移动端把上传参考图和提交区挤出首屏。 4. 输入区保留: - - 参考图上传按钮。 + - 上传拼图图片按钮。 - 图片模型切换按钮。 5. 输入区不保留: - `try` 文本。 - 示例 prompt chip。 + - 画面描述输入框默认提示词或占位示例。 - 玩法规则说明。 + - Template 模块和模板卡切换。 -## 模板抽样 +## 历史模板抽样 -首批模板不追求覆盖图二所有条目,而是选择高频且适合拼图主图的代表项: +以下模板曾用于表单 Template 模块。2026-05-07 后入口表单不再展示这些模板,列表仅作为历史资产索引: 1. 情侣合照拼图 2. 家庭纪念拼图 @@ -76,12 +85,45 @@ n = 1 本次 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` 并按 camelCase 序列化为 `aiRedraw`。 + ## 验收 -1. 点击拼图创作后,表单首屏呈现模板横滑区和大输入框。 -2. 点击任一模板后,输入框填入该模板提示词。 -3. 输入框里没有 `try` 示例功能。 -4. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`。 -5. 模板样例图文件存在,并能在创作表单缩略图中显示。 -6. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。 -7. `npm run check:encoding` 通过。 +1. 点击拼图创作后,表单首屏呈现大参考图区和半高文本输入框。 +2. 输入框里没有 `try` 示例功能。 +3. 图片模型切换仍可打开并选择 `gpt-image-2` / `nanobanana2`。 +4. 历史模板样例图文件可保留,但不出现在拼图入口表单。 +5. 当前创作 Tab 顶部的拼图、方洞挑战、视觉小说和 AIRP 卡片能看到对应 `creation-type-references` 图片。 +6. 默认 `AI重绘` 打开时,无图状态展示 `画面描述` 与 `消耗2光点`;上传图片后输入框标题改为 `画面AI重绘要求(提示词)`。 +7. 关闭 `AI重绘` 后隐藏画面描述输入框,生成按钮不展示 `消耗2光点`,后端直接应用上传图片为第一关图片。 +8. 上传非 1:1 图片时必须先完成正方形裁剪。 +9. gpt-image-2 Skill 校验通过,且脚本 dry-run 能输出计划请求而不泄露密钥。 +10. `npm run check:encoding` 通过。 diff --git a/docs/technical/README.md b/docs/technical/README.md index df7aaf037..76fe422ef 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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 差异。 - [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`、任务进度、领奖记录和光点钱包流水的边界。 - [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_*` 和相关测试/文档口径。 @@ -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_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_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_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):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。 @@ -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_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 时被旧字段压缩的根因、修复和后续约束。 -- [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 生成角色形象与角色动画的技术路线。 - [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 产品形态与能力拆解。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index b2fd99a08..11c07bb3a 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -30,6 +30,7 @@ spacetime sql "SELECT * FROM custom_world_gallery_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` | | 方洞挑战 | `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` | | 资产 | `asset_object`, `asset_entity_binding`, `asset_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 = '' ORDER BY SELECT * FROM square_hole_runtime_run WHERE 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 = ''; +SELECT * FROM visual_novel_agent_session WHERE owner_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 = '' 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`。 +- 索引:`owner_user_id`, `publication_status`。 + +```sql +SELECT * FROM visual_novel_work_profile WHERE profile_id = ''; +SELECT * FROM visual_novel_work_profile WHERE owner_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 = ''; +SELECT * FROM visual_novel_runtime_run WHERE owner_user_id = '' ORDER BY updated_at DESC; +SELECT * FROM visual_novel_runtime_run WHERE 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 = '' ORDER BY turn_index ASC; +SELECT * FROM visual_novel_runtime_history_entry WHERE owner_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 = '' ORDER BY occurred_at ASC; +SELECT * FROM visual_novel_runtime_event WHERE owner_user_id = '' ORDER BY occurred_at DESC; +``` + ## 大鱼吃小鱼表 ### `big_fish_creation_session` diff --git a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md index d0a044199..6356670f0 100644 --- a/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md +++ b/docs/technical/TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md @@ -2,6 +2,8 @@ 更新时间:`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. 文档目的 这份执行方案用于指导 `Genarrative` 在**不改动外部 TXT 模式提示词正文、不改动外部 TXT 模式功能需求**的前提下,把下面两个仓库中已经跑通的 TXT 模式创作流程与运行机制完整迁入当前项目: diff --git a/docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md b/docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md new file mode 100644 index 000000000..917fb3530 --- /dev/null +++ b/docs/technical/VISUAL_NOVEL_IMPLEMENTATION_HANDOFF_2026-05-07.md @@ -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 的文档收口已经站稳。 diff --git a/docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md b/docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md new file mode 100644 index 000000000..6c0bb9749 --- /dev/null +++ b/docs/technical/VISUAL_NOVEL_PROMPT_AND_LLM_TOOLS_VN03_2026-05-05.md @@ -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 和解析测试。 diff --git a/index.html b/index.html index ac5ed5065..800b01ced 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,10 @@ - + 百梦 diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index c551e1a2a..da5ffbb14 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -16,7 +16,7 @@ pipeline { CARGO_INCREMENTAL = '0' RUSTC_WRAPPER = 'sccache' SCCACHE_DIR = '${env.USERPROFILE}\\.cache\\sccache-stdb-module' - SCCACHE_CACHE_SIZE = '30G'o + SCCACHE_CACHE_SIZE = '30G' } parameters { @@ -88,8 +88,19 @@ pipeline { if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { throw '[stdb-build] 缺少 cargo。请先在 Windows 构建节点安装 Rust 工具链,并确保 cargo 在 PATH 中。' } - if (-not (Get-Command sccache -ErrorAction SilentlyContinue)) { - Write-Host '[stdb-build] 未找到 sccache,改用 rustc 直接构建。' + # sccache 只是可选缓存;PATH 中存在但不可执行时必须回退到 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 } npm run build:production-release -- --component spacetime-module --name "$env:EFFECTIVE_BUILD_VERSION" diff --git a/package.json b/package.json index 75c82caf0..2a7fdd57e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "preview": "node scripts/vite-cli.mjs preview", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "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", "lint:eslint": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --max-warnings 0", "lint:guardrails": "npm run lint:eslint", diff --git a/packages/shared/src/contracts/creationAgentDocumentInput.ts b/packages/shared/src/contracts/creationAgentDocumentInput.ts index 00ae745bb..89196d0c5 100644 --- a/packages/shared/src/contracts/creationAgentDocumentInput.ts +++ b/packages/shared/src/contracts/creationAgentDocumentInput.ts @@ -9,6 +9,7 @@ export interface CreationAgentDocumentInputPayload { contentType?: string | null; sizeBytes: number; text: string; + sourceAssetId?: string | null; } export interface ParseCreationAgentDocumentInputResponse { diff --git a/packages/shared/src/contracts/creativeAgent.ts b/packages/shared/src/contracts/creativeAgent.ts new file mode 100644 index 000000000..16a3855c7 --- /dev/null +++ b/packages/shared/src/contracts/creativeAgent.ts @@ -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 }; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts new file mode 100644 index 000000000..29c3b604b --- /dev/null +++ b/packages/shared/src/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './creativeAgent'; +export type * from './puzzleCreativeTemplate'; +export type * from './visualNovel'; diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 9b9cc3724..42c4b8bf5 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -48,7 +48,9 @@ export type PuzzleAgentActionRequest = workTitle?: string; workDescription?: string; pictureDescription?: string; + referenceImageSrc?: string | null; imageModel?: string | null; + aiRedraw?: boolean; } | { action: 'compile_puzzle_draft'; @@ -58,6 +60,7 @@ export type PuzzleAgentActionRequest = pictureDescription?: string; referenceImageSrc?: string | null; imageModel?: string | null; + aiRedraw?: boolean; candidateCount?: number; } | { diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 2bf925b9a..203ace00e 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -46,6 +46,7 @@ export interface PuzzleDraftLevel { levelId: string; levelName: string; pictureDescription: string; + pictureReference?: string | null; candidates: PuzzleGeneratedImageCandidate[]; selectedCandidateId: string | null; coverImageSrc: string | null; diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index fe53c2412..779fd19dd 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -51,6 +51,7 @@ export interface CreatePuzzleAgentSessionRequest { pictureDescription?: string; referenceImageSrc?: string | null; imageModel?: string | null; + aiRedraw?: boolean; } export interface CreatePuzzleAgentSessionResponse { diff --git a/packages/shared/src/contracts/puzzleCreativeTemplate.ts b/packages/shared/src/contracts/puzzleCreativeTemplate.ts new file mode 100644 index 000000000..5656c363b --- /dev/null +++ b/packages/shared/src/contracts/puzzleCreativeTemplate.ts @@ -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; +} diff --git a/packages/shared/src/contracts/visualNovel.ts b/packages/shared/src/contracts/visualNovel.ts new file mode 100644 index 000000000..1ad3e0a2d --- /dev/null +++ b/packages/shared/src/contracts/visualNovel.ts @@ -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; + metrics: Record; + 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' }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 765cc3cb9..f679fc556 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export * from './contracts/auth'; export type * from './contracts/bigFish'; export * from './contracts/common'; export type * from './contracts/creationAgentDocumentInput'; +export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; @@ -11,6 +12,7 @@ export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleOnboarding'; +export type * from './contracts/puzzleCreativeTemplate'; export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleWorkSummary'; @@ -42,6 +44,7 @@ export type { SquareHoleWorkSummary, } from './contracts/squareHoleWorks'; export type * from './contracts/story'; +export type * from './contracts/visualNovel'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; diff --git a/public/creation-type-references/airp.webp b/public/creation-type-references/airp.webp new file mode 100644 index 000000000..eb340fefc Binary files /dev/null and b/public/creation-type-references/airp.webp differ diff --git a/public/creation-type-references/big-fish.webp b/public/creation-type-references/big-fish.webp new file mode 100644 index 000000000..ae7842aad Binary files /dev/null and b/public/creation-type-references/big-fish.webp differ diff --git a/public/creation-type-references/creative-agent.webp b/public/creation-type-references/creative-agent.webp new file mode 100644 index 000000000..824b6771d Binary files /dev/null and b/public/creation-type-references/creative-agent.webp differ diff --git a/public/creation-type-references/match3d.webp b/public/creation-type-references/match3d.webp new file mode 100644 index 000000000..982a8384d Binary files /dev/null and b/public/creation-type-references/match3d.webp differ diff --git a/public/creation-type-references/puzzle.webp b/public/creation-type-references/puzzle.webp new file mode 100644 index 000000000..20edf15ee Binary files /dev/null and b/public/creation-type-references/puzzle.webp differ diff --git a/public/creation-type-references/rpg.webp b/public/creation-type-references/rpg.webp new file mode 100644 index 000000000..f52417d82 Binary files /dev/null and b/public/creation-type-references/rpg.webp differ diff --git a/public/creation-type-references/square-hole.webp b/public/creation-type-references/square-hole.webp new file mode 100644 index 000000000..bf0a880da Binary files /dev/null and b/public/creation-type-references/square-hole.webp differ diff --git a/public/creation-type-references/visual-novel.webp b/public/creation-type-references/visual-novel.webp new file mode 100644 index 000000000..bd7f7bb5b Binary files /dev/null and b/public/creation-type-references/visual-novel.webp differ diff --git a/scripts/api-server-dev.mjs b/scripts/api-server-dev.mjs index c90de96f0..6dfa7342d 100644 --- a/scripts/api-server-dev.mjs +++ b/scripts/api-server-dev.mjs @@ -7,6 +7,7 @@ const apiServerExePath = resolve( repoRoot, 'server-rs/target/debug/api-server.exe', ); +const shellEnvKeys = new Set(Object.keys(process.env)); function loadEnvFile(path, target) { if (!existsSync(path)) { @@ -26,7 +27,9 @@ function loadEnvFile(path, target) { } const [, key, rawValue] = match; - if (target[key] !== undefined) { + // 只保留启动命令行和外层 shell 已显式传入的环境变量优先级; + // `.env.local` 需要能覆盖 `.env`,否则本地短信登录会被默认 false 压住。 + if (shellEnvKeys.has(key)) { continue; } diff --git a/scripts/check-visual-novel-vn11-negative-scan.mjs b/scripts/check-visual-novel-vn11-negative-scan.mjs new file mode 100644 index 000000000..9b60823d2 --- /dev/null +++ b/scripts/check-visual-novel-vn11-negative-scan.mjs @@ -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)}`); +} diff --git a/scripts/check-visual-novel-vn12-acceptance.mjs b/scripts/check-visual-novel-vn12-acceptance.mjs new file mode 100644 index 000000000..23b6f30b9 --- /dev/null +++ b/scripts/check-visual-novel-vn12-acceptance.mjs @@ -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)}`); +} diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 98dcd5100..d1907b5c9 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -43,6 +43,61 @@ resolve_client_host() { 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() { local index @@ -535,6 +590,9 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then fi 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}" GENARRATIVE_API_HOST="${API_HOST}" \ diff --git a/scripts/dev-web-rust.mjs b/scripts/dev-web-rust.mjs index 3039887f7..2a92a4a80 100644 --- a/scripts/dev-web-rust.mjs +++ b/scripts/dev-web-rust.mjs @@ -1,15 +1,52 @@ 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 = { - ...process.env, + ...fileEnv, RUST_SERVER_TARGET: - process.env.RUST_SERVER_TARGET || - process.env.GENARRATIVE_API_TARGET || - `http://127.0.0.1:${process.env.GENARRATIVE_API_PORT || '3100'}`, + fileEnv.RUST_SERVER_TARGET || + fileEnv.GENARRATIVE_API_TARGET || + `http://127.0.0.1:${fileEnv.GENARRATIVE_API_PORT || '3100'}`, }; 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}`); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 1475ea092..d9833b19b 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + [[package]] name = "ahash" version = "0.8.12" @@ -81,6 +90,7 @@ dependencies = [ "module-auth", "module-big-fish", "module-combat", + "module-creative-agent", "module-custom-world", "module-inventory", "module-match3d", @@ -91,10 +101,12 @@ dependencies = [ "module-runtime-story", "module-square-hole", "module-story", + "module-visual-novel", + "platform-agent", "platform-auth", "platform-llm", "platform-oss", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -112,6 +124,7 @@ dependencies = [ "urlencoding", "uuid", "webp", + "zip", ] [[package]] @@ -123,6 +136,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -169,6 +191,17 @@ dependencies = [ "syn", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -191,10 +224,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit", @@ -206,7 +239,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower", "tower-layer", @@ -222,12 +255,12 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tower-layer", "tower-service", "tracing", @@ -251,6 +284,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -384,8 +432,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -508,6 +558,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.23.0" @@ -568,6 +639,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -627,6 +709,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -684,6 +775,15 @@ dependencies = [ "serde", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -901,6 +1001,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -970,6 +1089,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -980,6 +1110,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -987,7 +1128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -998,8 +1139,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1021,6 +1162,30 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1031,8 +1196,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1048,8 +1213,8 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.9.0", "hyper-util", "rustls", "tokio", @@ -1058,6 +1223,19 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1068,14 +1246,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1362,6 +1540,28 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "langchainrust" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e783bd3734eb269382df16f3018d3b5e31827c6f8a2bdf7d4bf3ddb7b9bb6039" +dependencies = [ + "async-trait", + "bincode", + "chrono", + "csv", + "futures-util", + "pdf-extract", + "regex", + "reqwest 0.11.27", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "uuid", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1428,6 +1628,24 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +dependencies = [ + "encoding_rs", + "flate2", + "indexmap 2.14.0", + "itoa", + "log", + "md-5", + "nom", + "rangemap", + "time", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1449,6 +1667,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1471,6 +1699,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1506,7 +1740,7 @@ name = "module-assets" version = "0.1.0" dependencies = [ "platform-oss", - "reqwest", + "reqwest 0.12.28", "serde", "shared-kernel", "spacetimedb", @@ -1545,6 +1779,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-creative-agent" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-custom-world" version = "0.1.0" @@ -1595,6 +1839,7 @@ name = "module-puzzle" version = "0.1.0" dependencies = [ "serde", + "serde_json", "shared-kernel", "spacetimedb", ] @@ -1662,6 +1907,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-visual-novel" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1695,6 +1950,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1750,7 +2015,7 @@ version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -1828,6 +2093,21 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pdf-extract" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +dependencies = [ + "adobe-cmap-parser", + "encoding_rs", + "euclid", + "lopdf", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "pem" version = "3.0.6" @@ -1866,6 +2146,18 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "platform-agent" +version = "0.1.0" +dependencies = [ + "async-trait", + "langchainrust", + "platform-llm", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "platform-auth" version = "0.1.0" @@ -1874,7 +2166,7 @@ dependencies = [ "hmac", "jsonwebtoken", "rand_core 0.6.4", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_urlencoded", @@ -1892,7 +2184,7 @@ name = "platform-llm" version = "0.1.0" dependencies = [ "log", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tokio", @@ -1904,7 +2196,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "hmac", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "sha2", @@ -1918,13 +2210,25 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", "miniz_oxide", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.5" @@ -2028,7 +2332,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2065,7 +2369,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.52.0", ] @@ -2150,6 +2454,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + [[package]] name = "rayon" version = "1.12.0" @@ -2176,7 +2486,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2228,6 +2538,49 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -2238,10 +2591,10 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-rustls", "hyper-util", "js-sys", @@ -2255,7 +2608,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls", "tokio-util", @@ -2305,7 +2658,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2326,6 +2679,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2368,6 +2730,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2392,6 +2766,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -2416,7 +2802,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -2469,6 +2855,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -2662,6 +3059,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -2690,6 +3097,7 @@ dependencies = [ "module-runtime-story", "module-square-hole", "module-story", + "module-visual-novel", "serde", "serde_json", "shared-contracts", @@ -2736,7 +3144,7 @@ dependencies = [ "bytes", "derive_more", "getrandom 0.2.17", - "http", + "http 1.4.0", "log", "rand 0.8.6", "scoped-tls", @@ -2815,7 +3223,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5612611d09d358f535438275d2a0d6a5e2fa56fa583dcfdbeddd623974df1d5e" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "blake3", "chrono", "derive_more", @@ -2860,7 +3268,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b668b51e7318207ae7eebcd4cae0c5d43bf713e7f229ac309ea2614a486ffde" dependencies = [ - "bitflags", + "bitflags 2.11.1", "either", "enum-as-inner", "itertools", @@ -2885,7 +3293,7 @@ checksum = "11780ed69f178bf3784b7599da5171450e4b7ac6fd66b79e2e1861c867cef1a6" dependencies = [ "anyhow", "arrayvec", - "bitflags", + "bitflags 2.11.1", "bytemuck", "bytes", "bytestring", @@ -2955,7 +3363,7 @@ dependencies = [ "futures", "futures-channel", "home", - "http", + "http 1.4.0", "log", "native-tls", "once_cell", @@ -3045,6 +3453,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3065,6 +3479,27 @@ dependencies = [ "syn", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3208,7 +3643,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3330,7 +3765,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -3343,11 +3778,11 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3444,7 +3879,7 @@ checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "native-tls", @@ -3455,6 +3890,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + [[package]] name = "typenum" version = "1.20.0" @@ -3538,6 +3982,7 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -3688,7 +4133,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -3733,6 +4178,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3801,13 +4252,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3819,34 +4279,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3859,24 +4352,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3892,6 +4409,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3956,7 +4483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -4095,12 +4622,41 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 74af3855a..c85b4ff5c 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/module-auth", "crates/module-big-fish", "crates/module-combat", + "crates/module-creative-agent", "crates/module-inventory", "crates/module-custom-world", "crates/module-match3d", @@ -25,9 +26,11 @@ members = [ "crates/module-runtime-item", "crates/module-square-hole", "crates/module-story", + "crates/module-visual-novel", "crates/platform-oss", "crates/platform-auth", "crates/platform-llm", + "crates/platform-agent", "crates/shared-contracts", "crates/shared-kernel", "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-big-fish = { path = "crates/module-big-fish", 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-inventory = { path = "crates/module-inventory", 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-square-hole = { path = "crates/module-square-hole", 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-llm = { path = "crates/platform-llm", 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" async-stream = "0.3" +async-trait = "0.1" axum = "0.8" base64 = "0.22" dotenvy = "0.15" @@ -77,6 +84,7 @@ hmac = "0.12" http-body-util = "0.1" image = { version = "0.25", default-features = false } jsonwebtoken = "9" +langchainrust = "0.2.18" log = "0.4" rand_core = "0.6" reqwest = { version = "0.12", default-features = false } @@ -98,6 +106,7 @@ url = "2" urlencoding = "2" uuid = "1" webp = "0.3" +zip = { version = "2", default-features = false } [profile.dev] opt-level = 0 # 默认 0,有人手滑改 1/2 会慢 diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 6d190c196..1d987c590 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -17,6 +17,7 @@ module-assets = { workspace = true, features = ["server-service"] } module-auth = { workspace = true } module-big-fish = { workspace = true } module-combat = { workspace = true } +module-creative-agent = { workspace = true } module-custom-world = { workspace = true } module-inventory = { workspace = true } module-match3d = { workspace = true } @@ -27,6 +28,8 @@ module-runtime-story = { workspace = true } module-runtime-item = { workspace = true } module-square-hole = { workspace = true } module-story = { workspace = true } +module-visual-novel = { workspace = true } +platform-agent = { workspace = true } platform-auth = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } @@ -44,6 +47,7 @@ tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true, features = ["v4"] } +zip = { workspace = true, features = ["deflate"] } [dev-dependencies] base64 = { workspace = true } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4abf378e7..8941d5516 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -49,6 +49,11 @@ use crate::{ generate_character_visual, get_character_visual_job, publish_character_visual, }, 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::{ create_custom_world_agent_session, delete_custom_world_agent_session, 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, 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}, }; @@ -1014,6 +1027,8 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .merge(creative_agent_router(state.clone())) + .merge(visual_novel_router(state.clone())) .route( "/api/runtime/puzzle/onboarding/generate", post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max( @@ -1428,6 +1443,173 @@ pub fn build_router(state: AppState) -> Router { .with_state(state) } +fn creative_agent_router(state: AppState) -> Router { + 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 { + 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)] mod tests { use axum::{ @@ -1450,6 +1632,8 @@ mod tests { use super::build_router; const TEST_PASSWORD: &str = "secret123"; + const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; + async fn seed_phone_user_with_password( state: &AppState, phone_number: &str, @@ -1507,6 +1691,43 @@ mod tests { .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 { + 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] async fn healthz_returns_legacy_compatible_payload_and_headers() { 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::(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::(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] async fn runtime_story_legacy_routes_are_not_mounted() { 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); } + + #[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}"); + } + } } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 080b617c5..3500961e3 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -191,6 +191,7 @@ fn allows_internal_forwarded_auth(path: &str) -> bool { // Node 代理已经完成平台账号 JWT 校验,Rust 运行时只信任这些明确的内部转发路径。 path.starts_with("/api/runtime/big-fish/") || path.starts_with("/api/runtime/chat/") + || path.starts_with("/api/runtime/creative-agent/") || path.starts_with("/api/runtime/puzzle/") } @@ -287,6 +288,9 @@ mod tests { assert!(allows_internal_forwarded_auth( "/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/auth/me")); } diff --git a/server-rs/crates/api-server/src/creation_agent_document_input.rs b/server-rs/crates/api-server/src/creation_agent_document_input.rs index 75ad7b602..43ccc5d96 100644 --- a/server-rs/crates/api-server/src/creation_agent_document_input.rs +++ b/server-rs/crates/api-server/src/creation_agent_document_input.rs @@ -1,3 +1,5 @@ +use std::io::{Cursor, Read}; + use axum::{Json, extract::Extension, http::StatusCode}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use serde_json::{Value, json}; @@ -12,7 +14,7 @@ use crate::{ const MAX_DOCUMENT_INPUT_BYTES: usize = 256 * 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( Extension(request_context): Extension, @@ -58,12 +60,8 @@ pub async fn parse_creation_agent_document_input( ); } - let text = String::from_utf8(decoded.clone()).map_err(|_| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "message": "暂时只支持 UTF-8 文本文档,请转换编码后再上传。", - "field": "contentBase64", - })) - })?; + let extension = document_extension(&file_name)?; + let text = decode_document_text(&decoded, extension.as_str())?; let normalized_text = normalize_document_text(&text); if normalized_text.trim().is_empty() { @@ -88,6 +86,7 @@ pub async fn parse_creation_agent_document_input( .map(str::to_string), size_bytes: decoded.len(), text: normalized_text, + source_asset_id: None, }, }, )) @@ -115,11 +114,7 @@ fn normalize_file_name(value: &str) -> Result { } fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> { - let 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))?; + let extension = document_extension(file_name)?; if !SUPPORTED_DOCUMENT_EXTENSIONS.contains(&extension.as_str()) { return Err(unsupported_document_error(file_name)); @@ -128,15 +123,100 @@ fn ensure_supported_extension(file_name: &str) -> Result<(), AppError> { Ok(()) } +fn document_extension(file_name: &str) -> Result { + 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 { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "message": "暂时只支持 txt、md、csv、json 文本文档。", + "message": "暂时只支持 txt、md、docx、csv、json 文档。", "field": "fileName", "fileName": file_name, "supportedExtensions": SUPPORTED_DOCUMENT_EXTENSIONS, })) } +fn decode_document_text(bytes: &[u8], extension: &str) -> Result { + 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 { + 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("') else { + break; + }; + let text_start = start + tag_end_offset + 1; + let Some(end_offset) = xml[text_start..].find("") else { + break; + }; + let text_end = text_start + end_offset; + output.push_str(&decode_xml_text(&xml[text_start..text_end])); + cursor = text_end + "".len(); + + if let Some(next_break) = xml[cursor..].find(" String { + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + fn normalize_document_text(value: &str) -> String { value .trim_start_matches('\u{feff}') diff --git a/server-rs/crates/api-server/src/creative_agent.rs b/server-rs/crates/api-server/src/creative_agent.rs new file mode 100644 index 000000000..ede51382e --- /dev/null +++ b/server-rs/crates/api-server/src/creative_agent.rs @@ -0,0 +1,1434 @@ +use std::{ + convert::Infallible, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::StatusCode, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_puzzle::{ + CreativePuzzleDraftToolInput as DomainCreativePuzzleDraftToolInput, + CreativePuzzleLevelDraftInput as DomainCreativePuzzleLevelDraftInput, + PuzzleCreativeDraftEditableFieldPath as DomainPuzzleDraftEditableFieldPath, + PuzzleCreativeImageGenerationPolicy as DomainPuzzleImageGenerationPolicy, + PuzzleCreativeLevelGenerationMode as DomainPuzzleLevelGenerationMode, + PuzzleCreativePricingUnit as DomainPuzzlePricingUnit, + PuzzleCreativeSupportedLevelMode as DomainPuzzleSupportedLevelMode, + PuzzleCreativeTemplateProtocol as DomainPuzzleTemplateProtocol, + PuzzleCreativeTemplateSelection as DomainPuzzleTemplateSelection, + PuzzleLevelImagePlanInput as DomainPuzzleLevelImagePlanInput, + build_puzzle_draft_from_creative_fields, plan_puzzle_level_images, + retrieve_puzzle_template_catalog, validate_puzzle_template_selection, +}; +use platform_agent::{ + CreativeAgentCallbackKind, CreativeAgentCallbacks, CreativeAgentExecutor, FunctionAgentLimits, + Gpt5ResponsesAgentClient, PuzzlePhase1AgentInput, +}; +use serde_json::{Value, json}; +use shared_contracts::{ + creative_agent::{ + ConfirmCreativePuzzleTemplateRequest, CreateCreativeAgentSessionRequest, + CreativeAgentDoneEvent, CreativeAgentEntryContext, CreativeAgentInputPart, + CreativeAgentInputPartType, CreativeAgentMessage, CreativeAgentMessageDeltaEvent, + CreativeAgentMessageKind, CreativeAgentMessageRole, CreativeAgentSessionResponse, + CreativeAgentSessionSnapshot, CreativeAgentSseEventType, CreativeAgentStage, + CreativeAgentStageEvent, CreativeAgentTemplateCatalogEvent, + CreativeAgentThoughtSummaryDeltaEvent, CreativeAgentToolEvent, CreativeCapabilityStatus, + CreativeDraftEditResult, CreativeDraftEditStreamRequest, CreativeImageSummary, + CreativeInputSummary, CreativeTargetPlayType, CreativeTargetSessionBinding, + CreativeTargetStage, CreativeUnsupportedCapability, CreativeUnsupportedPlayType, + StreamCreativeAgentMessageRequest, + }, + puzzle_creative_template::{ + PuzzleCreativeTemplateProtocol, PuzzleCreativeTemplateSelection, + PuzzleDraftEditableFieldPath, PuzzleDraftFieldPatch, PuzzleDraftFieldPatchOperation, + PuzzleImageGenerationPlan, PuzzleImageGenerationPlanLevel, PuzzleLevelGenerationMode, + PuzzleSupportedLevelMode, PuzzleTemplateCostRange, PuzzleTemplateImageGenerationPolicy, + PuzzleTemplatePricingUnit, + }, +}; +use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros, normalize_required_string}; +use spacetime_client::{PuzzleAgentSessionCreateRecordInput, PuzzleWorkUpsertRecordInput}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::UnboundedReceiverStream; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + creative_agent_sse::{ + creative_sse_error_event, creative_sse_json_event, creative_sse_json_value_event, + }, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +const CREATIVE_AGENT_PROVIDER: &str = "creative-agent"; +type CreativeSseItem = Result; +type CreativeSseSender = mpsc::UnboundedSender; + +pub async fn create_creative_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + creative_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + + let now = current_utc_micros(); + let session = CreativeAgentSessionSnapshot { + session_id: build_prefixed_uuid_id("creative-session-"), + stage: CreativeAgentStage::Idle, + input_summary: build_input_summary_from_session_request(&payload), + messages: Vec::new(), + puzzle_template_catalog: Vec::new(), + puzzle_template_selection: None, + puzzle_image_generation_plan: None, + target_binding: None, + updated_at: format_timestamp_micros(now), + }; + state.put_creative_agent_session( + authenticated.claims().user_id().to_string(), + session.clone(), + ); + + Ok(json_success_body( + Some(&request_context), + CreativeAgentSessionResponse { session }, + )) +} + +pub async fn get_creative_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&session_id, "sessionId").map_err(|error| { + creative_error_response( + &request_context, + error.with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "sessionId is required", + })), + ) + })?; + + let session = state + .get_creative_agent_session(&session_id, authenticated.claims().user_id()) + .ok_or_else(|| { + creative_error_response( + &request_context, + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "创意 Agent 会话不存在", + })), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + CreativeAgentSessionResponse { session }, + )) +} + +pub async fn stream_creative_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = payload.map_err(|error| { + creative_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + ensure_non_empty(&session_id, "sessionId") + .map_err(|error| creative_error_response(&request_context, error))?; + if payload.client_message_id.trim().is_empty() { + return Err(creative_bad_request( + &request_context, + "clientMessageId is required", + )); + } + + let owner_user_id = authenticated.claims().user_id().to_string(); + let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else { + return Err(creative_not_found( + &request_context, + "创意 Agent 会话不存在", + )); + }; + let (user_text, image_urls) = normalize_message_content(&payload.content) + .map_err(|error| creative_error_response(&request_context, error))?; + if user_text.trim().is_empty() && image_urls.is_empty() { + return Err(creative_bad_request( + &request_context, + "创意 Agent 输入文本和图片不能同时为空", + )); + } + + let now = current_utc_micros(); + session.stage = CreativeAgentStage::Perceiving; + session.input_summary = + build_input_summary_from_message(&session.input_summary, &payload.content); + session.messages.push(CreativeAgentMessage { + id: payload.client_message_id.clone(), + role: CreativeAgentMessageRole::User, + kind: CreativeAgentMessageKind::Chat, + text: user_text.clone(), + created_at: format_timestamp_micros(now), + }); + session.updated_at = format_timestamp_micros(now); + state.put_creative_agent_session(owner_user_id.clone(), session.clone()); + + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + tokio::spawn(run_creative_agent_message_stream( + state, + session_id, + owner_user_id, + session, + user_text, + image_urls, + event_tx, + )); + + Ok(Sse::new(UnboundedReceiverStream::new(event_rx)).into_response()) +} + +async fn run_creative_agent_message_stream( + stream_state: AppState, + stream_session_id: String, + stream_owner_user_id: String, + initial_session: CreativeAgentSessionSnapshot, + user_text: String, + image_urls: Vec, + event_tx: CreativeSseSender, +) { + if !send_creative_sse_event( + &event_tx, + creative_sse_json_event( + CreativeAgentSseEventType::Stage, + CreativeAgentStageEvent { + session_id: stream_session_id.clone(), + stage: CreativeAgentStage::Perceiving, + }, + ), + ) { + return; + } + + let (callback_tx, mut callback_rx) = mpsc::unbounded_channel::(); + let callbacks = CreativeAgentCallbacks::new(move |event| { + if matches!(event.kind, CreativeAgentCallbackKind::Stage) { + let _ = callback_tx.send(event.label); + } + }); + let executor = stream_state.creative_agent_executor(); + let agent_input = PuzzlePhase1AgentInput { + session_id: stream_session_id.clone(), + user_text: user_text.clone(), + image_urls: image_urls.clone(), + limits: FunctionAgentLimits::default(), + }; + let agent_result = executor.run_puzzle_phase1(agent_input, callbacks).await; + + while let Ok(label) = callback_rx.try_recv() { + if let Some(stage) = parse_creative_stage_label(&label) { + if !send_creative_sse_event( + &event_tx, + creative_sse_json_event( + CreativeAgentSseEventType::Stage, + CreativeAgentStageEvent { + session_id: stream_session_id.clone(), + stage, + }, + ), + ) { + return; + } + } + } + + if let Some(llm_client) = stream_state.creative_agent_gpt5_client().cloned() { + let gpt5_client = Gpt5ResponsesAgentClient::new(llm_client); + if let Err(error) = gpt5_client + .request( + build_creative_agent_system_prompt(), + user_text.clone(), + image_urls.clone(), + ) + .await + { + mark_creative_agent_stream_failed( + &stream_state, + &stream_session_id, + &stream_owner_user_id, + &initial_session, + ); + let _ = send_creative_sse_event( + &event_tx, + creative_sse_error_event( + Some(stream_session_id), + "GPT5_REQUEST_FAILED", + error.to_string(), + ), + ); + return; + } + } + + if let Err(error) = agent_result { + mark_creative_agent_stream_failed( + &stream_state, + &stream_session_id, + &stream_owner_user_id, + &initial_session, + ); + let _ = send_creative_sse_event( + &event_tx, + creative_sse_error_event( + Some(stream_session_id), + "AGENT_EXECUTION_FAILED", + error.to_string(), + ), + ); + return; + } + + let catalog = retrieve_puzzle_template_catalog() + .into_iter() + .map(from_domain_template_protocol) + .collect::>(); + let assistant_text = if catalog.is_empty() { + "我先整理一下拼图模板。".to_string() + } else { + format!("我先给你准备了 {} 个拼图模板,请先选一个。", catalog.len()) + }; + let mut next_session = stream_state + .get_creative_agent_session(&stream_session_id, &stream_owner_user_id) + .unwrap_or(initial_session); + next_session.stage = CreativeAgentStage::WaitingTemplateConfirmation; + next_session.puzzle_template_catalog = catalog.clone(); + next_session.messages.push(CreativeAgentMessage { + id: format!("assistant-{}-{}", stream_session_id, current_utc_micros()), + role: CreativeAgentMessageRole::Assistant, + kind: CreativeAgentMessageKind::Chat, + text: assistant_text.clone(), + created_at: format_timestamp_micros(current_utc_micros()), + }); + next_session.updated_at = format_timestamp_micros(current_utc_micros()); + stream_state.put_creative_agent_session(stream_owner_user_id.clone(), next_session.clone()); + + send_creative_message_stream_success_events( + &event_tx, + stream_session_id, + catalog, + assistant_text, + next_session, + &user_text, + image_urls.len(), + ); +} + +fn send_creative_message_stream_success_events( + event_tx: &CreativeSseSender, + stream_session_id: String, + catalog: Vec, + assistant_text: String, + next_session: CreativeAgentSessionSnapshot, + user_text: &str, + image_count: usize, +) { + let thought_id = format!("thought-{}", current_utc_micros()); + let catalog_tool_call_id = format!("tool-{}", current_utc_micros()); + let events = [ + creative_sse_json_event( + CreativeAgentSseEventType::ThoughtSummaryDelta, + CreativeAgentThoughtSummaryDeltaEvent { + session_id: stream_session_id.clone(), + thought_id: thought_id.clone(), + text_delta: build_perception_thought_summary(user_text, image_count), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::ToolStarted, + CreativeAgentToolEvent { + session_id: stream_session_id.clone(), + tool_call_id: catalog_tool_call_id.clone(), + tool_name: "retrieve_puzzle_template_catalog".to_string(), + summary: Some("读取拼图模板".to_string()), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::ToolCompleted, + CreativeAgentToolEvent { + session_id: stream_session_id.clone(), + tool_call_id: catalog_tool_call_id, + tool_name: "retrieve_puzzle_template_catalog".to_string(), + summary: Some("已读取拼图模板".to_string()), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::PuzzleTemplateCatalog, + CreativeAgentTemplateCatalogEvent { + session_id: stream_session_id.clone(), + templates: catalog.clone(), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::ThoughtSummaryDelta, + CreativeAgentThoughtSummaryDeltaEvent { + session_id: stream_session_id.clone(), + thought_id: thought_id.clone(), + text_delta: build_catalog_thought_summary(&catalog), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::ThoughtSummaryDelta, + CreativeAgentThoughtSummaryDeltaEvent { + session_id: stream_session_id.clone(), + thought_id, + text_delta: "先选一个模板,再确认关卡模式和关卡数。".to_string(), + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::AgentMessageDelta, + CreativeAgentMessageDeltaEvent { + session_id: stream_session_id.clone(), + message_id: format!("assistant-{}", stream_session_id), + role: CreativeAgentMessageRole::Assistant, + kind: CreativeAgentMessageKind::Chat, + text_delta: assistant_text, + }, + ), + creative_sse_json_event( + CreativeAgentSseEventType::Session, + json!({ "session": next_session }), + ), + creative_sse_json_event( + CreativeAgentSseEventType::Done, + CreativeAgentDoneEvent { + session_id: stream_session_id, + }, + ), + ]; + + for event in events { + if !send_creative_sse_event(event_tx, event) { + return; + } + } +} + +fn mark_creative_agent_stream_failed( + state: &AppState, + session_id: &str, + owner_user_id: &str, + fallback_session: &CreativeAgentSessionSnapshot, +) { + let mut failed = state + .get_creative_agent_session(session_id, owner_user_id) + .unwrap_or_else(|| fallback_session.clone()); + failed.stage = CreativeAgentStage::Failed; + failed.updated_at = format_timestamp_micros(current_utc_micros()); + state.put_creative_agent_session(owner_user_id.to_string(), failed); +} + +fn send_creative_sse_event(sender: &CreativeSseSender, event: Event) -> bool { + sender.send(Ok(event)).is_ok() +} + +fn build_perception_thought_summary(user_text: &str, image_count: usize) -> String { + let text = normalize_required_string(user_text).unwrap_or_default(); + let text_summary = if text.is_empty() { + "正在理解参考素材".to_string() + } else { + format!("正在理解用户输入:{text}") + }; + match image_count { + 0 => text_summary, + 1 => format!("{text_summary},并结合 1 张参考图。"), + count => format!("{text_summary},并结合 {count} 张参考图。"), + } +} + +fn build_catalog_thought_summary(catalog: &[PuzzleCreativeTemplateProtocol]) -> String { + let template_titles = catalog + .iter() + .take(3) + .map(|template| template.title.as_str()) + .collect::>() + .join("、"); + if template_titles.is_empty() { + "正在整理拼图模板目录。".to_string() + } else { + format!("已整理出拼图模板目录:{template_titles}。") + } +} + +pub async fn confirm_creative_puzzle_template( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = payload.map_err(|error| { + creative_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else { + return Err(creative_not_found( + &request_context, + "创意 Agent 会话不存在", + )); + }; + let domain_selection = to_domain_template_selection(&payload.selection).map_err(|error| { + creative_error_response(&request_context, map_puzzle_field_error(error)) + })?; + validate_puzzle_template_selection(&domain_selection).map_err(|error| { + creative_error_response(&request_context, map_puzzle_field_error(error)) + })?; + + let creative_levels = + build_creative_levels_from_selection(&payload.selection, &session.input_summary); + let domain_cost_range = to_domain_cost_range(&payload.selection.cost_range); + let creative_draft = + build_puzzle_draft_from_creative_fields(DomainCreativePuzzleDraftToolInput { + template_id: payload.selection.template_id.clone(), + template_cost_range: domain_cost_range.clone(), + work_title: build_work_title_from_input(&session.input_summary), + work_description: build_work_description_from_input(&session.input_summary), + work_tags: build_work_tags_from_input(&session.input_summary), + levels: creative_levels.clone(), + }) + .map_err(|error| { + creative_error_response(&request_context, map_puzzle_field_error(error)) + })?; + let plan = plan_puzzle_level_images(DomainPuzzleLevelImagePlanInput { + template_id: payload.selection.template_id.clone(), + selected_level_mode: to_domain_level_generation_mode( + &payload.selection.selected_level_mode, + ), + levels: creative_levels, + cost_range: domain_cost_range, + candidate_count_per_level: Some(1), + }) + .map_err(|error| creative_error_response(&request_context, map_puzzle_field_error(error)))?; + let plan = from_domain_image_plan(plan); + + let now = current_utc_micros(); + let target_session_id = build_prefixed_uuid_id("puzzle-session-"); + let seed_text = build_puzzle_form_seed_from_creative_draft(&creative_draft); + let welcome_message_text = "已按智能创作模板准备拼图草稿。".to_string(); + state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: target_session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text, + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text, + created_at_micros: now, + }) + .await + .map_err(|error| { + creative_error_response( + &request_context, + map_spacetime_error(error.to_string(), "创建拼图草稿失败"), + ) + })?; + state + .spacetime_client() + .compile_puzzle_agent_draft(target_session_id.clone(), owner_user_id.clone(), now) + .await + .map_err(|error| { + creative_error_response( + &request_context, + map_spacetime_error(error.to_string(), "编译拼图草稿失败"), + ) + })?; + + let target_binding = CreativeTargetSessionBinding { + play_type: CreativeTargetPlayType::Puzzle, + target_session_id: target_session_id.clone(), + target_stage: CreativeTargetStage::PuzzleResult, + result_profile_id: Some(build_puzzle_result_profile_id(&target_session_id)), + }; + session.stage = CreativeAgentStage::TargetReady; + session.puzzle_template_selection = Some(payload.selection); + session.puzzle_image_generation_plan = Some(plan); + session.target_binding = Some(target_binding); + session.updated_at = format_timestamp_micros(current_utc_micros()); + state.put_creative_agent_session(owner_user_id, session.clone()); + + Ok(json_success_body( + Some(&request_context), + CreativeAgentSessionResponse { session }, + )) +} + +pub async fn stream_creative_draft_edit( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = payload.map_err(|error| { + creative_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": error.body_text(), + })), + ) + })?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else { + return Err(creative_not_found( + &request_context, + "创意 Agent 会话不存在", + )); + }; + let Some(binding) = session.target_binding.clone() else { + return Err(creative_bad_request(&request_context, "尚未绑定拼图草稿")); + }; + if binding.target_session_id != payload.target_puzzle_session_id { + return Err(creative_bad_request( + &request_context, + "目标拼图 session 不匹配", + )); + } + let instruction = normalize_required_string(&payload.instruction) + .ok_or_else(|| creative_bad_request(&request_context, "instruction is required"))?; + let (patch, patched_draft) = + build_draft_edit_patch(&instruction, payload.current_draft.clone()) + .map_err(|error| creative_error_response(&request_context, error))?; + if let Some(profile_id) = binding.result_profile_id.clone() { + let update_input = + build_puzzle_work_update_from_draft(profile_id, owner_user_id.clone(), &patched_draft) + .map_err(|error| creative_error_response(&request_context, error))?; + state + .spacetime_client() + .update_puzzle_work(update_input) + .await + .map_err(|error| { + creative_error_response( + &request_context, + map_spacetime_error(error.to_string(), "写回拼图草稿失败"), + ) + })?; + } + + session.stage = CreativeAgentStage::TargetReady; + session.messages.push(CreativeAgentMessage { + id: payload.client_message_id, + role: CreativeAgentMessageRole::User, + kind: CreativeAgentMessageKind::Chat, + text: instruction, + created_at: format_timestamp_micros(current_utc_micros()), + }); + session.updated_at = format_timestamp_micros(current_utc_micros()); + state.put_creative_agent_session(owner_user_id, session.clone()); + let result = CreativeDraftEditResult { + edit_instructions: vec![patch], + session: session.clone(), + puzzle_session: json!({ + "sessionId": binding.target_session_id, + "stage": "draft_ready", + "draft": patched_draft, + "updatedAt": session.updated_at, + }), + }; + + let stream = async_stream::stream! { + yield Ok::(creative_sse_json_event( + CreativeAgentSseEventType::Stage, + CreativeAgentStageEvent { + session_id: session.session_id.clone(), + stage: CreativeAgentStage::Reflecting, + }, + )); + yield Ok::(creative_sse_json_value_event( + "draft_edit_result", + serde_json::to_value(&result).unwrap_or_else(|_| json!({ "session": session })), + )); + yield Ok::(creative_sse_json_event( + CreativeAgentSseEventType::Session, + json!({ + "editInstructions": result.edit_instructions, + "session": result.session, + "puzzleSession": result.puzzle_session, + }), + )); + yield Ok::(creative_sse_json_event( + CreativeAgentSseEventType::Done, + CreativeAgentDoneEvent { + session_id: session_id, + }, + )); + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn cancel_creative_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let Some(mut session) = state.get_creative_agent_session(&session_id, &owner_user_id) else { + return Err(creative_not_found( + &request_context, + "创意 Agent 会话不存在", + )); + }; + session.stage = CreativeAgentStage::WaitingUser; + session.updated_at = format_timestamp_micros(current_utc_micros()); + state.put_creative_agent_session(owner_user_id, session.clone()); + + Ok(json_success_body( + Some(&request_context), + CreativeAgentSessionResponse { session }, + )) +} + +fn build_input_summary_from_session_request( + payload: &CreateCreativeAgentSessionRequest, +) -> CreativeInputSummary { + CreativeInputSummary { + text: payload.text.as_ref().and_then(normalize_required_string), + entry_context: payload + .entry_context + .clone() + .unwrap_or(CreativeAgentEntryContext::CreationHome), + images: payload + .images + .iter() + .map(|image| CreativeImageSummary { + asset_id: Some(image.asset_id.clone()), + read_url: Some(image.read_url.clone()), + thumbnail_url: image.thumbnail_url.clone(), + width: image.width, + height: image.height, + summary: None, + }) + .collect(), + material_summary: payload.text.as_ref().and_then(normalize_required_string), + unsupported_capabilities: unsupported_capabilities(), + } +} + +fn build_input_summary_from_message( + previous: &CreativeInputSummary, + content: &[CreativeAgentInputPart], +) -> CreativeInputSummary { + let text = content + .iter() + .filter(|part| part.part_type == CreativeAgentInputPartType::InputText) + .filter_map(|part| part.text.as_deref()) + .filter_map(normalize_required_string) + .collect::>() + .join("\n"); + let images = content + .iter() + .filter(|part| part.part_type == CreativeAgentInputPartType::InputImage) + .map(|part| CreativeImageSummary { + asset_id: part.asset_id.clone(), + read_url: part.image_url.clone(), + thumbnail_url: part.thumbnail_url.clone(), + width: None, + height: None, + summary: None, + }) + .collect::>(); + + CreativeInputSummary { + text: normalize_required_string(&text).or_else(|| previous.text.clone()), + entry_context: previous.entry_context.clone(), + images: if images.is_empty() { + previous.images.clone() + } else { + images + }, + material_summary: normalize_required_string(&text) + .or_else(|| previous.material_summary.clone()), + unsupported_capabilities: unsupported_capabilities(), + } +} + +fn unsupported_capabilities() -> Vec { + [ + (CreativeUnsupportedPlayType::Rpg, "RPG 世界"), + (CreativeUnsupportedPlayType::Match3d, "抓大鹅"), + (CreativeUnsupportedPlayType::BigFish, "大鱼吃小鱼"), + (CreativeUnsupportedPlayType::SquareHole, "方洞挑战"), + ] + .into_iter() + .map(|(play_type, title)| CreativeUnsupportedCapability { + play_type, + title: title.to_string(), + status: CreativeCapabilityStatus::Unsupported, + reason: "Phase 1 只开放拼图模板".to_string(), + }) + .collect() +} + +fn normalize_message_content( + content: &[CreativeAgentInputPart], +) -> Result<(String, Vec), AppError> { + let text = content + .iter() + .filter(|part| part.part_type == CreativeAgentInputPartType::InputText) + .filter_map(|part| part.text.as_deref()) + .filter_map(normalize_required_string) + .collect::>() + .join("\n"); + let image_urls = content + .iter() + .filter(|part| part.part_type == CreativeAgentInputPartType::InputImage) + .filter_map(|part| part.image_url.as_deref()) + .filter_map(normalize_required_string) + .collect::>(); + + if image_urls.len() > 6 { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "图片数量超出 Phase 1 限制", + })), + ); + } + + Ok((text, image_urls)) +} + +fn build_creative_levels_from_selection( + selection: &PuzzleCreativeTemplateSelection, + input: &CreativeInputSummary, +) -> Vec { + let reference = input + .images + .first() + .and_then(|image| image.read_url.clone().or_else(|| image.asset_id.clone())); + let description = build_picture_description_from_input(input); + (0..selection.planned_level_count.max(1)) + .map(|index| DomainCreativePuzzleLevelDraftInput { + level_name: if selection.planned_level_count > 1 { + format!("第{}关", index + 1) + } else { + "第一关".to_string() + }, + picture_description: description.clone(), + picture_reference: reference.clone(), + }) + .collect() +} + +fn build_work_title_from_input(input: &CreativeInputSummary) -> String { + input + .text + .as_deref() + .and_then(|text| { + let title = text + .chars() + .take(16) + .collect::() + .trim_matches([',', '。', ',', '.', ' ']) + .to_string(); + normalize_required_string(title) + }) + .unwrap_or_else(|| "创意拼图".to_string()) +} + +fn build_work_description_from_input(input: &CreativeInputSummary) -> String { + input + .text + .clone() + .or_else(|| input.material_summary.clone()) + .unwrap_or_else(|| "根据图文素材生成的拼图作品。".to_string()) +} + +fn build_picture_description_from_input(input: &CreativeInputSummary) -> String { + input + .material_summary + .clone() + .or_else(|| input.text.clone()) + .unwrap_or_else(|| "根据参考素材生成的拼图画面。".to_string()) +} + +fn build_work_tags_from_input(input: &CreativeInputSummary) -> Vec { + let source = input.text.as_deref().unwrap_or_default(); + let mut tags = vec!["创意".to_string(), "拼图".to_string(), "灵感".to_string()]; + for tag in ["旅行", "家庭", "节日", "角色", "风景", "纪念"] { + if source.contains(tag) && !tags.iter().any(|item| item == tag) { + tags.push(tag.to_string()); + } + } + tags.truncate(6); + tags +} + +fn build_puzzle_form_seed_from_creative_draft(draft: &module_puzzle::PuzzleResultDraft) -> String { + let first_level = draft.levels.first(); + [ + ("作品名称", normalize_required_string(&draft.work_title)), + ( + "作品描述", + normalize_required_string(&draft.work_description), + ), + ( + "画面描述", + first_level.and_then(|level| normalize_required_string(&level.picture_description)), + ), + ] + .into_iter() + .filter_map(|(label, value)| value.map(|value| format!("{label}:{value}"))) + .collect::>() + .join("\n") +} + +fn build_draft_edit_patch( + instruction: &str, + mut current_draft: Value, +) -> Result<(PuzzleDraftFieldPatch, Value), AppError> { + let (field_path, value, level_id) = + if instruction.contains("标题") || instruction.contains("名称") { + ( + PuzzleDraftEditableFieldPath::WorkTitle, + json!(clean_instruction_value(instruction)), + None, + ) + } else if instruction.contains("标签") { + ( + PuzzleDraftEditableFieldPath::WorkTags, + json!(["创意", "拼图", "灵感"]), + None, + ) + } else if instruction.contains("参考") { + ( + PuzzleDraftEditableFieldPath::LevelPictureReference, + json!(clean_instruction_value(instruction)), + current_draft["levels"] + .as_array() + .and_then(|levels| levels.first()) + .and_then(|level| level["levelId"].as_str()) + .map(ToString::to_string), + ) + } else { + ( + PuzzleDraftEditableFieldPath::LevelPictureDescription, + json!(clean_instruction_value(instruction)), + current_draft["levels"] + .as_array() + .and_then(|levels| levels.first()) + .and_then(|level| level["levelId"].as_str()) + .map(ToString::to_string), + ) + }; + + apply_patch_to_camel_draft( + &mut current_draft, + &field_path, + value.clone(), + level_id.as_deref(), + )?; + + Ok(( + PuzzleDraftFieldPatch { + field_path, + operation: PuzzleDraftFieldPatchOperation::Set, + level_id, + value, + rationale: "根据用户自然语言要求生成的 Phase 1 字段 patch".to_string(), + }, + current_draft, + )) +} + +fn apply_patch_to_camel_draft( + draft: &mut Value, + field_path: &PuzzleDraftEditableFieldPath, + value: Value, + level_id: Option<&str>, +) -> Result<(), AppError> { + match field_path { + PuzzleDraftEditableFieldPath::WorkTitle => draft["workTitle"] = value, + PuzzleDraftEditableFieldPath::WorkDescription => { + draft["workDescription"] = value.clone(); + draft["summary"] = value; + } + PuzzleDraftEditableFieldPath::WorkTags => draft["themeTags"] = value, + PuzzleDraftEditableFieldPath::LevelName + | PuzzleDraftEditableFieldPath::LevelPictureDescription + | PuzzleDraftEditableFieldPath::LevelPictureReference => { + let Some(levels) = draft["levels"].as_array_mut() else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "currentDraft.levels is required", + })), + ); + }; + let level_index = level_id + .and_then(|target_id| { + levels + .iter() + .position(|level| level["levelId"].as_str() == Some(target_id)) + }) + .unwrap_or(0); + let Some(level) = levels.get_mut(level_index) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "currentDraft.levels is required", + })), + ); + }; + match field_path { + PuzzleDraftEditableFieldPath::LevelName => level["levelName"] = value, + PuzzleDraftEditableFieldPath::LevelPictureDescription => { + level["pictureDescription"] = value + } + PuzzleDraftEditableFieldPath::LevelPictureReference => { + level["pictureReference"] = value + } + _ => {} + } + } + } + Ok(()) +} + +fn build_puzzle_work_update_from_draft( + profile_id: String, + owner_user_id: String, + draft: &Value, +) -> Result { + let levels = draft["levels"] + .as_array() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": "currentDraft.levels is required", + })) + })? + .iter() + .map(camel_level_to_module_json) + .collect::>(); + + Ok(PuzzleWorkUpsertRecordInput { + profile_id, + owner_user_id, + work_title: draft["workTitle"] + .as_str() + .unwrap_or("创意拼图") + .to_string(), + work_description: draft["workDescription"] + .as_str() + .or_else(|| draft["summary"].as_str()) + .unwrap_or("拼图作品") + .to_string(), + level_name: levels + .first() + .and_then(|level| level["level_name"].as_str()) + .unwrap_or("第一关") + .to_string(), + summary: draft["summary"] + .as_str() + .or_else(|| draft["workDescription"].as_str()) + .unwrap_or("拼图作品") + .to_string(), + theme_tags: draft["themeTags"] + .as_array() + .map(|tags| { + tags.iter() + .filter_map(|tag| tag.as_str().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_else(|| vec!["创意".to_string(), "拼图".to_string(), "灵感".to_string()]), + cover_image_src: draft["coverImageSrc"].as_str().map(ToString::to_string), + cover_asset_id: draft["coverAssetId"].as_str().map(ToString::to_string), + levels_json: Some(serde_json::to_string(&levels).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": format!("拼图关卡序列化失败:{error}"), + })) + })?), + updated_at_micros: current_utc_micros(), + }) +} + +fn camel_level_to_module_json(level: &Value) -> Value { + json!({ + "level_id": level["levelId"].as_str().unwrap_or("puzzle-level-1"), + "level_name": level["levelName"].as_str().unwrap_or("第一关"), + "picture_description": level["pictureDescription"].as_str().unwrap_or("拼图画面"), + "picture_reference": level["pictureReference"].as_str(), + "candidates": level["candidates"].as_array().cloned().unwrap_or_default().into_iter().map(|candidate| { + json!({ + "candidate_id": candidate["candidateId"].as_str().unwrap_or("candidate-1"), + "image_src": candidate["imageSrc"].as_str().unwrap_or(""), + "asset_id": candidate["assetId"].as_str().unwrap_or(""), + "prompt": candidate["prompt"].as_str().unwrap_or(""), + "actual_prompt": candidate["actualPrompt"].as_str(), + "source_type": candidate["sourceType"].as_str().unwrap_or("generated"), + "selected": candidate["selected"].as_bool().unwrap_or(false), + }) + }).collect::>(), + "selected_candidate_id": level["selectedCandidateId"].as_str(), + "cover_image_src": level["coverImageSrc"].as_str(), + "cover_asset_id": level["coverAssetId"].as_str(), + "generation_status": level["generationStatus"].as_str().unwrap_or("idle"), + }) +} + +fn clean_instruction_value(instruction: &str) -> String { + instruction + .replace("把", "") + .replace("改成", "") + .replace("修改为", "") + .replace("标题", "") + .replace("名称", "") + .trim_matches([':', ':', ',', '。', ' ']) + .trim() + .to_string() +} + +fn parse_creative_stage_label(label: &str) -> Option { + match label { + "perceiving" => Some(CreativeAgentStage::Perceiving), + "thinking" => Some(CreativeAgentStage::Thinking), + "remembering" => Some(CreativeAgentStage::Remembering), + "selecting_puzzle_template" => Some(CreativeAgentStage::SelectingPuzzleTemplate), + "waiting_template_confirmation" => Some(CreativeAgentStage::WaitingTemplateConfirmation), + "planning_puzzle_levels" => Some(CreativeAgentStage::PlanningPuzzleLevels), + "acting" => Some(CreativeAgentStage::Acting), + "reflecting" => Some(CreativeAgentStage::Reflecting), + "collaborating" => Some(CreativeAgentStage::Collaborating), + "target_ready" => Some(CreativeAgentStage::TargetReady), + _ => None, + } +} + +fn from_domain_template_protocol( + template: DomainPuzzleTemplateProtocol, +) -> PuzzleCreativeTemplateProtocol { + PuzzleCreativeTemplateProtocol { + template_id: template.template_id, + title: template.title, + summary: template.summary, + preview_image_src: template.preview_image_src, + supported_level_mode: from_domain_supported_level_mode(template.supported_level_mode), + min_level_count: template.min_level_count, + max_level_count: template.max_level_count, + default_level_count: template.default_level_count, + cost_range: PuzzleTemplateCostRange { + min_points: template.cost_range.min_points, + max_points: template.cost_range.max_points, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: template.cost_range.reason, + }, + required_draft_fields: template + .required_draft_fields + .into_iter() + .map(from_domain_editable_field_path) + .collect(), + image_policy: from_domain_image_policy(template.image_policy), + } +} + +fn from_domain_supported_level_mode( + mode: DomainPuzzleSupportedLevelMode, +) -> PuzzleSupportedLevelMode { + match mode { + DomainPuzzleSupportedLevelMode::Single => PuzzleSupportedLevelMode::Single, + DomainPuzzleSupportedLevelMode::Multi => PuzzleSupportedLevelMode::Multi, + DomainPuzzleSupportedLevelMode::SingleOrMulti => PuzzleSupportedLevelMode::SingleOrMulti, + } +} + +fn from_domain_editable_field_path( + field_path: DomainPuzzleDraftEditableFieldPath, +) -> PuzzleDraftEditableFieldPath { + match field_path { + DomainPuzzleDraftEditableFieldPath::WorkTitle => PuzzleDraftEditableFieldPath::WorkTitle, + DomainPuzzleDraftEditableFieldPath::WorkDescription => { + PuzzleDraftEditableFieldPath::WorkDescription + } + DomainPuzzleDraftEditableFieldPath::WorkTags => PuzzleDraftEditableFieldPath::WorkTags, + DomainPuzzleDraftEditableFieldPath::LevelName => PuzzleDraftEditableFieldPath::LevelName, + DomainPuzzleDraftEditableFieldPath::LevelPictureDescription => { + PuzzleDraftEditableFieldPath::LevelPictureDescription + } + DomainPuzzleDraftEditableFieldPath::LevelPictureReference => { + PuzzleDraftEditableFieldPath::LevelPictureReference + } + } +} + +fn from_domain_image_policy( + image_policy: DomainPuzzleImageGenerationPolicy, +) -> PuzzleTemplateImageGenerationPolicy { + PuzzleTemplateImageGenerationPolicy { + allow_uploaded_image_directly: image_policy.allow_uploaded_image_directly, + allow_generated_images: image_policy.allow_generated_images, + allow_per_level_reference_image: image_policy.allow_per_level_reference_image, + default_candidate_count_per_level: image_policy.default_candidate_count_per_level, + } +} + +fn to_domain_template_selection( + selection: &PuzzleCreativeTemplateSelection, +) -> Result { + retrieve_puzzle_template_catalog() + .into_iter() + .find(|template| template.template_id == selection.template_id) + .ok_or(module_puzzle::PuzzleFieldError::InvalidOperation)?; + Ok(DomainPuzzleTemplateSelection { + template_id: selection.template_id.clone(), + title: selection.title.clone(), + reason: selection.reason.clone(), + cost_range: to_domain_cost_range(&selection.cost_range), + supported_level_mode: to_domain_supported_level_mode(&selection.supported_level_mode), + selected_level_mode: to_domain_level_generation_mode(&selection.selected_level_mode), + planned_level_count: selection.planned_level_count, + requires_user_confirmation: selection.requires_user_confirmation, + }) +} + +fn to_domain_cost_range( + cost_range: &PuzzleTemplateCostRange, +) -> module_puzzle::PuzzleCreativeCostRange { + module_puzzle::PuzzleCreativeCostRange { + min_points: cost_range.min_points, + max_points: cost_range.max_points, + pricing_unit: DomainPuzzlePricingUnit::Point, + reason: cost_range.reason.clone(), + } +} + +fn to_domain_level_generation_mode( + mode: &PuzzleLevelGenerationMode, +) -> DomainPuzzleLevelGenerationMode { + match mode { + PuzzleLevelGenerationMode::SingleLevel => DomainPuzzleLevelGenerationMode::SingleLevel, + PuzzleLevelGenerationMode::MultiLevel => DomainPuzzleLevelGenerationMode::MultiLevel, + } +} + +fn to_domain_supported_level_mode( + mode: &PuzzleSupportedLevelMode, +) -> DomainPuzzleSupportedLevelMode { + match mode { + PuzzleSupportedLevelMode::Single => DomainPuzzleSupportedLevelMode::Single, + PuzzleSupportedLevelMode::Multi => DomainPuzzleSupportedLevelMode::Multi, + PuzzleSupportedLevelMode::SingleOrMulti => DomainPuzzleSupportedLevelMode::SingleOrMulti, + } +} + +fn from_domain_image_plan( + plan: module_puzzle::PuzzleImageGenerationPlan, +) -> PuzzleImageGenerationPlan { + PuzzleImageGenerationPlan { + mode: match plan.mode { + DomainPuzzleLevelGenerationMode::SingleLevel => PuzzleLevelGenerationMode::SingleLevel, + DomainPuzzleLevelGenerationMode::MultiLevel => PuzzleLevelGenerationMode::MultiLevel, + }, + template_id: plan.template_id, + estimated_cost_range: PuzzleTemplateCostRange { + min_points: plan.estimated_cost_range.min_points, + max_points: plan.estimated_cost_range.max_points, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: plan.estimated_cost_range.reason, + }, + levels: plan + .levels + .into_iter() + .map(|level| PuzzleImageGenerationPlanLevel { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + image_prompt: level.image_prompt, + picture_reference: level.picture_reference, + candidate_count: level.candidate_count, + }) + .collect(), + } +} + +fn build_puzzle_result_profile_id(session_id: &str) -> String { + let suffix = session_id + .strip_prefix("puzzle-session-") + .unwrap_or(session_id); + format!("puzzle-profile-{suffix}") +} + +fn build_creative_agent_system_prompt() -> &'static str { + "你是创意互动内容生成 Agent。当前只开放拼图模板;必须显式展示模板选择、选择理由和预计光点范围,用户确认后才能创建草稿。" +} + +fn map_puzzle_field_error(error: module_puzzle::PuzzleFieldError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "module-puzzle", + "message": error.to_string(), + })) +} + +fn map_spacetime_error(error: String, fallback: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": if error.trim().is_empty() { fallback.to_string() } else { error }, + })) +} + +fn ensure_non_empty(value: &str, _field_name: &str) -> Result<(), AppError> { + if value.trim().is_empty() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST)); + } + Ok(()) +} + +fn creative_bad_request(request_context: &RequestContext, message: &str) -> Response { + creative_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": message, + })), + ) +} + +fn creative_not_found(request_context: &RequestContext, message: &str) -> Response { + creative_error_response( + request_context, + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": CREATIVE_AGENT_PROVIDER, + "message": message, + })), + ) +} + +fn creative_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn current_utc_micros() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + i64::try_from(duration.as_micros()).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use module_puzzle::PUZZLE_PHASE1_TEMPLATE_ID; + + fn cost_range() -> PuzzleTemplateCostRange { + PuzzleTemplateCostRange { + min_points: 2, + max_points: 12, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: "按关卡数和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(), + } + } + + #[test] + fn selection_maps_to_domain_and_rejects_non_puzzle_template() { + let mut selection = PuzzleCreativeTemplateSelection { + template_id: PUZZLE_PHASE1_TEMPLATE_ID.to_string(), + title: "创意拼图".to_string(), + reason: "适合拼图".to_string(), + cost_range: cost_range(), + supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, + selected_level_mode: PuzzleLevelGenerationMode::SingleLevel, + planned_level_count: 1, + requires_user_confirmation: true, + }; + + assert!(to_domain_template_selection(&selection).is_ok()); + selection.template_id = "rpg.unsupported".to_string(); + assert!(to_domain_template_selection(&selection).is_err()); + } + + #[test] + fn selection_maps_catalog_subtemplate_to_domain() { + let selection = PuzzleCreativeTemplateSelection { + template_id: module_puzzle::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID.to_string(), + title: "旅行记忆拼图".to_string(), + reason: "适合旅行素材".to_string(), + cost_range: PuzzleTemplateCostRange { + min_points: 4, + max_points: 16, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: "按旅行节点和每关图片生成次数估算,实际扣费以后端任务结算为准".to_string(), + }, + supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, + selected_level_mode: PuzzleLevelGenerationMode::MultiLevel, + planned_level_count: 3, + requires_user_confirmation: true, + }; + + let domain_selection = + to_domain_template_selection(&selection).expect("subtemplate should map"); + + assert_eq!( + domain_selection.template_id, + module_puzzle::PUZZLE_TRAVEL_MEMORY_TEMPLATE_ID + ); + } + + #[test] + fn draft_edit_patch_only_updates_allowed_camel_field() { + let draft = json!({ + "workTitle": "旧标题", + "workDescription": "旧描述", + "summary": "旧描述", + "themeTags": ["创意", "拼图", "灵感"], + "levels": [{ + "levelId": "puzzle-level-1", + "levelName": "第一关", + "pictureDescription": "旧图面", + "generationStatus": "idle", + "candidates": [] + }] + }); + + let (patch, next) = + build_draft_edit_patch("把标题改成轻松家庭拼图", draft).expect("patch should build"); + + assert_eq!(patch.field_path, PuzzleDraftEditableFieldPath::WorkTitle); + assert_eq!(next["workTitle"], json!("轻松家庭拼图")); + assert_eq!(next["levels"][0]["pictureDescription"], json!("旧图面")); + } +} diff --git a/server-rs/crates/api-server/src/creative_agent_sse.rs b/server-rs/crates/api-server/src/creative_agent_sse.rs new file mode 100644 index 000000000..81a68b0bc --- /dev/null +++ b/server-rs/crates/api-server/src/creative_agent_sse.rs @@ -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(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, + code: impl Into, + message: impl Into, +) -> 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", + } +} diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index d9e08bef8..ae9368510 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -89,6 +89,12 @@ impl IntoResponse for AppError { } } +impl From for Response { + fn from(error: AppError) -> Self { + error.into_response() + } +} + fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) { match status_code { StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"), diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 96d76558e..74463ae81 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -23,6 +23,8 @@ mod creation_agent_anchor_templates; mod creation_agent_chat; mod creation_agent_document_input; mod creation_agent_llm_turn; +mod creative_agent; +mod creative_agent_sse; mod custom_world; mod custom_world_agent_entities; mod custom_world_agent_turn; @@ -66,11 +68,13 @@ mod square_hole_agent_turn; mod state; mod story_battles; mod story_sessions; +mod visual_novel; mod wechat_auth; mod wechat_provider; mod work_author; use shared_logging::init_tracing; +use std::{collections::HashSet, env, fs, io, panic, thread}; use tokio::net::TcpListener; use tokio::runtime::Builder as TokioRuntimeBuilder; 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; -fn main() -> Result<(), std::io::Error> { +fn main() -> Result<(), io::Error> { // Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。 - std::thread::Builder::new() + let server_thread = thread::Builder::new() .name("api-server-bootstrap".to_string()) .stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES) - .spawn(run_api_server_with_runtime)? - .join() - .map_err(|_| std::io::Error::other("api-server 启动线程异常退出"))? + .spawn(|| { + TokioRuntimeBuilder::new_multi_thread() + .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> { - TokioRuntimeBuilder::new_multi_thread() - .enable_all() - .thread_name("api-server-worker") - .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"); +async fn run_server() -> Result<(), io::Error> { + // 运行本地开发与联调时,优先从仓库根目录加载本地变量。 + // 只尊重外层 shell 先注入的变量;.env.local 需要能覆盖 .env。 + load_local_env_files(); // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 let config = AppConfig::from_env(); @@ -120,3 +124,92 @@ async fn run_api_server() -> Result<(), std::io::Error> { axum::serve(listener, router).await } + +fn load_local_env_files() { + let shell_env_keys = env::vars().map(|(key, _)| key).collect::>(); + + 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) { + 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")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs index ed6eb86cf..cdfb20981 100644 --- a/server-rs/crates/api-server/src/prompt/mod.rs +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod puzzle; pub(crate) mod rpg; pub(crate) mod scene_background; pub(crate) mod square_hole; +pub(crate) mod visual_novel; pub(crate) use rpg::agent_chat; pub(crate) use rpg::foundation_draft; diff --git a/server-rs/crates/api-server/src/prompt/visual_novel.rs b/server-rs/crates/api-server/src/prompt/visual_novel.rs new file mode 100644 index 000000000..242897178 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/visual_novel.rs @@ -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. 需要玩家选择时必须输出 choice,choice 内每项必须有 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 { + 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 { + 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, 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 { + let trimmed = strip_json_code_fence(text.trim()); + if let Ok(value) = serde_json::from_str::(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::(&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 { + 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::>() + )) + .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())); + } +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 9496215ae..97f2c24ea 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -219,6 +219,7 @@ pub async fn generate_puzzle_onboarding_work( level_id: "onboarding-level-1".to_string(), level_name: level_name.clone(), picture_description: prompt_text.clone(), + picture_reference: None, candidates, selected_candidate_id: Some(selected.candidate_id.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() { "compile_puzzle_draft" => { + let ai_redraw = payload.ai_redraw.unwrap_or(true); let prompt_text = payload .picture_description .as_deref() @@ -725,33 +727,49 @@ pub async fn execute_puzzle_agent_action( Ok(next_session_id) => next_session_id, Err(response) => return Err(response), }; - let session = execute_billable_asset_operation_with_cost( - &state, - &owner_user_id, - "puzzle_initial_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - compile_puzzle_draft_with_initial_cover( - &state, - compile_session_id.clone(), - owner_user_id.clone(), - prompt_text, - payload.reference_image_src.as_deref(), - payload.image_model.as_deref(), - now, - ) - .await - }, - ) - .await + let session = if ai_redraw { + execute_billable_asset_operation_with_cost( + &state, + &owner_user_id, + "puzzle_initial_image", + &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + compile_puzzle_draft_with_initial_cover( + &state, + compile_session_id.clone(), + owner_user_id.clone(), + prompt_text, + payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), + now, + ) + .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| { puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) }); ( "compile_puzzle_draft", "首关拼图草稿", - "已编译首关草稿、生成首关画面并写入正式草稿。", + if ai_redraw { + "已编译首关草稿、生成首关画面并写入正式草稿。" + } else { + "已编译首关草稿,并直接应用上传图片为第一关图片。" + }, session, ) } @@ -1980,6 +1998,7 @@ fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraft level_id: level.level_id, level_name: level.level_name, picture_description: level.picture_description, + picture_reference: level.picture_reference, candidates: level .candidates .into_iter() @@ -2519,6 +2538,7 @@ fn parse_puzzle_level_records_from_module_json( level_id: level.level_id, level_name: level.level_name, picture_description: level.picture_description, + picture_reference: level.picture_reference, candidates: level .candidates .into_iter() @@ -2685,6 +2705,7 @@ fn serialize_puzzle_levels_response( "level_id": level.level_id, "level_name": level.level_name, "picture_description": level.picture_description, + "picture_reference": level.picture_reference, "candidates": level .candidates .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 { + 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( mut session: PuzzleAgentSessionRecord, target_level_id: &str, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 19cc3d154..2848178d2 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1,7 +1,9 @@ -use std::{error::Error, fmt, sync::Arc}; - -#[cfg(test)] -use std::{collections::HashMap, sync::Mutex}; +use std::{ + collections::HashMap, + error::Error, + fmt, + sync::{Arc, Mutex}, +}; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ @@ -11,14 +13,16 @@ use module_auth::{ use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; +use platform_agent::MockLangChainRustAgentExecutor; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider, 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 serde_json::Value; +use shared_contracts::creative_agent::CreativeAgentSessionSnapshot; use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError}; use time::OffsetDateTime; use tracing::{info, warn}; @@ -51,11 +55,22 @@ pub struct AppState { ai_task_service: AiTaskService, spacetime_client: SpacetimeClient, llm_client: Option, + creative_agent_gpt5_client: Option, + creative_agent_executor: Arc, + // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 + // creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。 + creative_agent_sessions: Arc>>, #[cfg(test)] // 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。 test_runtime_snapshot_store: Arc>>, } +#[derive(Clone, Debug)] +struct CreativeAgentSessionRuntimeRecord { + owner_user_id: String, + snapshot: CreativeAgentSessionSnapshot, +} + // 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。 #[derive(Clone, Debug)] pub struct AdminRuntime { @@ -167,6 +182,7 @@ impl AppState { procedure_timeout: config.spacetime_procedure_timeout, }); let llm_client = build_llm_client(&config)?; + let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?; Ok(Self { config, @@ -185,6 +201,9 @@ impl AppState { ai_task_service, spacetime_client, llm_client, + creative_agent_gpt5_client, + creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), + creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())), #[cfg(test)] test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())), }) @@ -336,6 +355,44 @@ impl AppState { 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 { + self.creative_agent_executor.clone() + } + + pub fn get_creative_agent_session( + &self, + session_id: &str, + owner_user_id: &str, + ) -> Option { + 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( &self, user_id: String, @@ -710,6 +767,31 @@ fn build_llm_client(config: &AppConfig) -> Result, AppStateIni Ok(Some(LlmClient::new(llm_config)?)) } +fn build_creative_agent_gpt5_client( + config: &AppConfig, +) -> Result, 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( config: &AppConfig, @@ -783,5 +865,28 @@ mod tests { let state = AppState::new(AppConfig::default()).expect("state should build"); 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" + ); } } diff --git a/server-rs/crates/api-server/src/visual_novel.rs b/server-rs/crates/api-server/src/visual_novel.rs new file mode 100644 index 000000000..e74684658 --- /dev/null +++ b/server-rs/crates/api-server/src/visual_novel.rs @@ -0,0 +1,1767 @@ +use std::{collections::BTreeMap, convert::Infallible}; + +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::StatusCode, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_visual_novel as domain; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use shared_contracts::visual_novel as contract; +use shared_kernel::{ + build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros, normalize_required_string, + offset_datetime_to_unix_micros, +}; +use spacetime_client::{ + SpacetimeClientError, VisualNovelAgentMessageFinalizeRecordInput, + VisualNovelAgentMessageRecord, VisualNovelAgentMessageSubmitRecordInput, + VisualNovelAgentSessionCreateRecordInput, VisualNovelAgentSessionRecord, + VisualNovelHistoryEntryRecord, VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, + VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, + VisualNovelRuntimeEventRecordInput, VisualNovelWorkCompileRecordInput, + VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, +}; +use time::OffsetDateTime; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + prompt::visual_novel as vn_prompt, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, +}; + +const VISUAL_NOVEL_PROVIDER: &str = "visual-novel"; +const VISUAL_NOVEL_RUNTIME_KIND: &str = "visual-novel"; +const VISUAL_NOVEL_SESSION_ID_PREFIX: &str = "vn-session-"; +const VISUAL_NOVEL_MESSAGE_ID_PREFIX: &str = "vn-message-"; +const VISUAL_NOVEL_WORK_ID_PREFIX: &str = "vn-work-"; +const VISUAL_NOVEL_EVENT_ID_PREFIX: &str = "vn-event-"; +const VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS: usize = 4_000; + +pub async fn create_visual_novel_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let now_micros = current_utc_micros(); + let seed_text = payload.seed_text.unwrap_or_default(); + let source_mode = source_mode_to_wire(&payload.source_mode).to_string(); + + let session = state + .spacetime_client() + .create_visual_novel_agent_session(VisualNovelAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id(VISUAL_NOVEL_SESSION_ID_PREFIX), + owner_user_id, + source_mode, + seed_text, + source_asset_ids_json: to_json_string(&payload.source_asset_ids)?, + welcome_message_id: build_prefixed_uuid_id(VISUAL_NOVEL_MESSAGE_ID_PREFIX), + welcome_message_text: "已创建视觉小说创作会话。".to_string(), + draft_json: None, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelSessionResponse { + session: map_session_record(session)?, + }, + )) +} + +pub async fn get_visual_novel_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&session_id, "sessionId")?; + let session = state + .spacetime_client() + .get_visual_novel_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelSessionResponse { + session: map_session_record(session)?, + }, + )) +} + +pub async fn submit_visual_novel_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = submit_visual_novel_message_turn( + &state, + owner_user_id, + session_id, + payload.client_message_id, + payload.text, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelSessionResponse { session }, + )) +} + +pub async fn stream_visual_novel_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&session_id, "sessionId")?; + ensure_non_empty(&payload.client_message_id, "clientMessageId")?; + ensure_non_empty(&payload.text, "text")?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let stream = async_stream::stream! { + yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Start { + session_id: session_id.clone(), + })); + yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Phase { + phase: contract::VisualNovelAgentPhase::Perception, + })); + + match submit_visual_novel_message_turn( + &state, + owner_user_id, + session_id, + payload.client_message_id, + payload.text, + ) + .await + { + Ok(session) => { + if let Some(message) = session.messages.last() { + yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::TextDelta { + text: message.text.clone(), + })); + } + yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Complete { session })); + yield Ok::(sse_contract_event(&contract::VisualNovelAgentStreamEvent::Done {})); + } + Err(_error) => { + yield Ok::(sse_error_event("视觉小说流式创作失败".to_string())); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_visual_novel_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&session_id, "sessionId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + + if payload.kind == contract::VisualNovelAgentActionKind::CompileWorkProfile { + return compile_visual_novel_session_inner( + &state, + &request_context, + owner_user_id, + session_id, + ) + .await + .map(|payload| json_success_body(Some(&request_context), payload)); + } + + let current = state + .spacetime_client() + .get_visual_novel_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + let draft = resolve_action_draft(¤t, &payload)?; + let assistant_reply = if matches!( + payload.kind, + contract::VisualNovelAgentActionKind::GenerateDraft + | contract::VisualNovelAgentActionKind::PatchWorld + | contract::VisualNovelAgentActionKind::PatchCharacter + | contract::VisualNovelAgentActionKind::PatchScene + | contract::VisualNovelAgentActionKind::PatchStoryPhase + ) { + "视觉小说底稿已更新。" + } else { + "该视觉小说 action 已记录,后续资产生成由 VN-10 接入。" + }; + let finalized = + finalize_creation_session(&state, owner_user_id, session_id, draft, assistant_reply) + .await?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelSessionResponse { session: finalized }, + )) +} + +pub async fn compile_visual_novel_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&session_id, "sessionId")?; + let payload = compile_visual_novel_session_inner( + &state, + &request_context, + authenticated.claims().user_id().to_string(), + session_id, + ) + .await?; + + Ok(json_success_body(Some(&request_context), payload)) +} + +pub async fn list_visual_novel_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let works = state + .spacetime_client() + .list_visual_novel_works(owner_user_id) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorksResponse { + works: works + .into_iter() + .map(map_work_summary) + .collect::, _>>() + .map_err(|error| visual_novel_error_response(&request_context, error))?, + }, + )) +} + +pub async fn get_visual_novel_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&profile_id, "profileId")?; + let work = state + .spacetime_client() + .get_visual_novel_work_detail(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorkResponse { + work: map_work_detail(&state, work)?, + }, + )) +} + +pub async fn update_visual_novel_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&profile_id, "profileId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let mut draft = payload.draft; + prepare_draft_for_session( + &mut draft, + Some(profile_id.clone()), + current_utc_iso().as_str(), + ); + let projection = project_draft_for_work(&draft, &profile_id)?; + state + .spacetime_client() + .update_visual_novel_work(VisualNovelWorkUpdateRecordInput { + profile_id: profile_id.clone(), + owner_user_id: owner_user_id.clone(), + work_title: projection.work_title, + work_description: projection.work_description, + tags_json: projection.tags_json, + cover_image_src: projection.cover_image_src, + source_asset_ids_json: to_json_string(&draft.source_asset_ids)?, + draft_json: to_json_string(&projection.draft)?, + publish_ready: projection.publish_ready, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + let work = state + .spacetime_client() + .get_visual_novel_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorkResponse { + work: map_work_detail(&state, work)?, + }, + )) +} + +pub async fn delete_visual_novel_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&profile_id, "profileId")?; + let works = state + .spacetime_client() + .delete_visual_novel_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorksResponse { + works: works + .into_iter() + .map(map_work_summary) + .collect::, _>>() + .map_err(|error| visual_novel_error_response(&request_context, error))?, + }, + )) +} + +pub async fn publish_visual_novel_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&profile_id, "profileId")?; + let work = state + .spacetime_client() + .publish_visual_novel_work( + profile_id, + authenticated.claims().user_id().to_string(), + current_utc_micros(), + ) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorkResponse { + work: map_work_detail(&state, work)?, + }, + )) +} + +pub async fn list_visual_novel_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let works = state + .spacetime_client() + .list_visual_novel_gallery() + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelWorksResponse { + works: works + .into_iter() + .map(map_work_summary) + .collect::, _>>() + .map_err(|error| visual_novel_error_response(&request_context, error))?, + }, + )) +} + +pub async fn start_visual_novel_run( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&profile_id, "profileId")?; + if normalize_required_string(&payload.profile_id) + .as_deref() + .is_some_and(|value| value != profile_id) + { + return Err(visual_novel_bad_request( + &request_context, + "path 和 body 的 profileId 必须一致", + )); + } + + let run = state + .spacetime_client() + .start_visual_novel_run(VisualNovelRunStartRecordInput { + run_id: build_prefixed_uuid_id(domain::VISUAL_NOVEL_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id, + mode: run_mode_to_wire(&payload.mode).to_string(), + snapshot_json: None, + started_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelRunResponse { + run: map_run_record(run)?, + }, + )) +} + +pub async fn get_visual_novel_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&run_id, "runId")?; + let run = state + .spacetime_client() + .get_visual_novel_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelRunResponse { + run: map_run_record(run)?, + }, + )) +} + +pub async fn stream_visual_novel_action( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&run_id, "runId")?; + ensure_non_empty(&payload.client_event_id, "clientEventId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let run = state + .spacetime_client() + .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + let work = state + .spacetime_client() + .get_visual_novel_work_detail(run.profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + let work = map_work_detail(&state, work)?; + let stream = async_stream::stream! { + yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Start { + run_id: run_id.clone(), + })); + + match produce_runtime_turn(&state, &work, run, payload).await { + Ok((snapshot, steps)) => { + yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Step { + step: steps.first().cloned().unwrap_or(contract::VisualNovelRuntimeStep::Narration { text: "已推进视觉小说运行时。".to_string() }), + })); + yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Snapshot { + run: snapshot.clone(), + })); + yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Complete { + run: snapshot, + })); + yield Ok::(sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Done {})); + } + Err(_error) => { + yield Ok::(sse_error_event("视觉小说运行时推进失败".to_string())); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn list_visual_novel_history( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&run_id, "runId")?; + let history = state + .spacetime_client() + .list_visual_novel_runtime_history(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelHistoryResponse { + history: history + .into_iter() + .map(map_history_entry) + .collect::, _>>() + .map_err(|error| visual_novel_error_response(&request_context, error))?, + }, + )) +} + +pub async fn regenerate_visual_novel_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = parse_json_payload(&request_context, payload)?; + ensure_non_empty(&run_id, "runId")?; + ensure_non_empty(&payload.history_entry_id, "historyEntryId")?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let run = state + .spacetime_client() + .get_visual_novel_run(run_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + let work_record = state + .spacetime_client() + .get_visual_novel_work_detail(run.profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(&request_context, map_spacetime_error(error)) + })?; + let snapshot = map_run_record(run.clone())?; + let work = map_work_detail(&state, work_record)?; + let regenerated = domain::regenerate_from_history( + &snapshot_to_domain_run(&snapshot), + &payload.history_entry_id, + work.draft.runtime_config.allow_history_regeneration, + &format_timestamp_micros(current_utc_micros()), + ) + .map_err(|error| { + visual_novel_error_response(&request_context, domain_error_to_app_error(error)) + })?; + let next = + persist_runtime_snapshot(&state, owner_user_id, &run.profile_id, regenerated).await?; + + Ok(json_success_body( + Some(&request_context), + contract::VisualNovelRunResponse { run: next }, + )) +} + +#[derive(Clone, Debug)] +struct DraftWorkProjection { + work_title: String, + work_description: String, + tags_json: String, + cover_image_src: Option, + publish_ready: bool, + draft: contract::VisualNovelResultDraft, +} + +async fn produce_runtime_turn( + state: &AppState, + work: &contract::VisualNovelWorkDetail, + run: VisualNovelRunRecord, + payload: contract::VisualNovelRuntimeActionRequest, +) -> Result< + ( + contract::VisualNovelRunSnapshot, + Vec, + ), + Response, +> { + let snapshot = map_run_record(run.clone())?; + let profile = work_detail_to_domain_profile(work)?; + let domain_snapshot = snapshot_to_domain_run(&snapshot); + let action = runtime_request_to_domain_action(payload.clone())?; + domain::build_runtime_prompt_context(&profile, &domain_snapshot, &action) + .map_err(|error| domain_error_to_app_error(error).into_response())?; + + let steps = generate_runtime_steps(state, work, &snapshot, &payload).await?; + let domain_steps = contract_to_domain::<_, Vec>(&steps) + .map_err(IntoResponse::into_response)?; + let history_entry_id = build_prefixed_uuid_id(domain::VISUAL_NOVEL_HISTORY_ID_PREFIX); + let now = format_timestamp_micros(current_utc_micros()); + let next = domain::apply_runtime_steps( + &domain_snapshot, + domain_steps.as_slice(), + history_entry_id.as_str(), + now.as_str(), + ) + .map_err(|error| domain_error_to_app_error(error).into_response())?; + + let history = next + .history + .last() + .cloned() + .ok_or_else(|| visual_novel_internal_error("运行时历史写入前快照缺少新增节点"))?; + state + .spacetime_client() + .append_visual_novel_runtime_history_entry(VisualNovelHistoryEntryRecordInput { + entry_id: history.entry_id.clone(), + run_id: history.run_id.clone(), + owner_user_id: next.owner_user_id.clone(), + turn_index: history.turn_index, + source: history_source_to_wire(&history.source).to_string(), + action_text: history.action_text.clone(), + steps_json: to_json_string(&steps).map_err(IntoResponse::into_response)?, + snapshot_before_hash: history.snapshot_before_hash.clone(), + snapshot_after_hash: history.snapshot_after_hash.clone(), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| map_spacetime_error(error).into_response())?; + let next_profile_id = next.profile_id.clone(); + let persisted = + persist_runtime_snapshot(state, next.owner_user_id.clone(), &next_profile_id, next).await?; + let _ = record_runtime_event( + state, + &persisted, + "action", + Some(payload.client_event_id), + Some(history.entry_id), + json!({ "runtimeKind": VISUAL_NOVEL_RUNTIME_KIND }), + ) + .await; + + Ok((persisted, steps)) +} + +async fn generate_runtime_steps( + state: &AppState, + work: &contract::VisualNovelWorkDetail, + snapshot: &contract::VisualNovelRunSnapshot, + payload: &contract::VisualNovelRuntimeActionRequest, +) -> Result, Response> { + if let Some(llm_client) = state.llm_client() { + let request = vn_prompt::build_visual_novel_runtime_llm_request( + vn_prompt::VisualNovelRuntimePromptParams { + work_profile: &to_value(work).map_err(IntoResponse::into_response)?, + run_snapshot: &to_value(snapshot).map_err(IntoResponse::into_response)?, + runtime_action: &to_value(payload).map_err(IntoResponse::into_response)?, + recent_history: &snapshot + .history + .iter() + .rev() + .take(6) + .rev() + .map(to_value) + .collect::, _>>() + .map_err(IntoResponse::into_response)?, + max_assistant_step_count_per_turn: work + .draft + .runtime_config + .max_assistant_step_count_per_turn, + }, + ); + if let Ok(response) = llm_client.request_text(request).await { + if let Ok(steps) = + vn_prompt::parse_visual_novel_runtime_steps_fixture(response.content.as_str()) + { + return Ok(steps); + } + } + } + + Ok(fallback_runtime_steps(work, payload)) +} + +async fn persist_runtime_snapshot( + state: &AppState, + owner_user_id: String, + profile_id: &str, + snapshot: domain::VisualNovelRunSnapshot, +) -> Result { + let contract_snapshot = domain_to_contract::<_, contract::VisualNovelRunSnapshot>(&snapshot) + .map_err(IntoResponse::into_response)?; + let persisted = state + .spacetime_client() + .upsert_visual_novel_run_snapshot(VisualNovelRunSnapshotRecordInput { + run_id: snapshot.run_id, + owner_user_id, + status: run_status_to_wire(&snapshot.status).to_string(), + current_scene_id: snapshot.current_scene_id, + current_phase_id: snapshot.current_phase_id, + visible_character_ids_json: to_json_string(&snapshot.visible_character_ids) + .map_err(IntoResponse::into_response)?, + flags_json: to_json_string(&contract_snapshot.flags) + .map_err(IntoResponse::into_response)?, + metrics_json: to_json_string(&contract_snapshot.metrics) + .map_err(IntoResponse::into_response)?, + available_choices_json: to_json_string(&contract_snapshot.available_choices) + .map_err(IntoResponse::into_response)?, + text_mode_enabled: snapshot.text_mode_enabled, + snapshot_json: Some( + to_json_string(&contract_snapshot).map_err(IntoResponse::into_response)?, + ), + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| map_spacetime_error(error).into_response())?; + let mut next = map_run_record(persisted)?; + // 中文注释:procedure 返回 run 时 history 来自表;刚写入的 contract snapshot 保留更完整的本轮历史。 + if next.history.is_empty() && !contract_snapshot.history.is_empty() { + next.history = contract_snapshot.history; + } + if next.profile_id.trim().is_empty() { + next.profile_id = profile_id.to_string(); + } + Ok(next) +} + +async fn record_runtime_event( + state: &AppState, + snapshot: &contract::VisualNovelRunSnapshot, + event_kind: &str, + client_event_id: Option, + history_entry_id: Option, + payload: Value, +) -> Result<(), AppError> { + state + .spacetime_client() + .record_visual_novel_runtime_event(VisualNovelRuntimeEventRecordInput { + event_id: build_prefixed_uuid_id(VISUAL_NOVEL_EVENT_ID_PREFIX), + run_id: snapshot.run_id.clone(), + owner_user_id: snapshot.owner_user_id.clone(), + profile_id: Some(snapshot.profile_id.clone()), + event_kind: event_kind.to_string(), + client_event_id, + history_entry_id, + payload_json: to_json_string(&payload)?, + occurred_at_micros: current_utc_micros(), + }) + .await + .map_err(map_spacetime_error)?; + Ok(()) +} + +fn map_session_record( + record: VisualNovelAgentSessionRecord, +) -> Result { + Ok(contract::VisualNovelAgentSessionSnapshot { + session_id: record.session_id, + owner_user_id: record.owner_user_id, + source_mode: parse_source_mode(record.source_mode.as_str()), + status: parse_agent_status(record.status.as_str()), + messages: record + .messages + .into_iter() + .map(map_agent_message) + .collect::>(), + draft: record.draft.map(parse_value).transpose()?, + pending_action: record.pending_action.map(parse_value).transpose()?, + created_at: record.created_at, + updated_at: record.updated_at, + }) +} + +fn map_agent_message(record: VisualNovelAgentMessageRecord) -> contract::VisualNovelAgentMessage { + contract::VisualNovelAgentMessage { + id: record.message_id, + role: parse_message_role(record.role.as_str()), + kind: parse_message_kind(record.kind.as_str()), + text: record.text, + created_at: record.created_at, + } +} + +fn map_work_summary( + record: VisualNovelWorkProfileRecord, +) -> Result { + Ok(contract::VisualNovelWorkSummary { + runtime_kind: VISUAL_NOVEL_RUNTIME_KIND.to_string(), + profile_id: record.profile_id, + owner_user_id: record.owner_user_id, + title: record.work_title, + description: record.work_description, + cover_image_src: record.cover_image_src, + tags: record.tags, + publish_status: record.publication_status, + publish_ready: record.publish_ready, + play_count: record.play_count, + updated_at: record.updated_at, + published_at: record.published_at, + }) +} + +fn map_work_detail( + state: &AppState, + record: VisualNovelWorkProfileRecord, +) -> Result { + let author = resolve_work_author_by_user_id( + state, + record.owner_user_id.as_str(), + Some(record.author_display_name.as_str()), + None, + ); + let summary = map_work_summary(record.clone())?; + Ok(contract::VisualNovelWorkDetail { + work_id: record.work_id, + summary, + source_session_id: record.source_session_id, + author_display_name: author.display_name, + source_asset_ids: record.source_asset_ids, + draft: parse_value(record.draft)?, + created_at: record.created_at, + }) +} + +fn map_run_record( + record: VisualNovelRunRecord, +) -> Result { + Ok(contract::VisualNovelRunSnapshot { + run_id: record.run_id, + owner_user_id: record.owner_user_id, + profile_id: record.profile_id, + mode: parse_run_mode(record.mode.as_str()), + status: parse_run_status(record.status.as_str()), + current_scene_id: record.current_scene_id, + current_phase_id: record.current_phase_id, + visible_character_ids: record.visible_character_ids, + flags: value_to_map(record.flags)?, + metrics: value_to_f64_map(record.metrics)?, + history: record + .history + .into_iter() + .map(map_history_entry) + .collect::, _>>()?, + available_choices: parse_value(record.available_choices)?, + text_mode_enabled: record.text_mode_enabled, + created_at: record.created_at, + updated_at: record.updated_at, + }) +} + +fn map_history_entry( + record: VisualNovelHistoryEntryRecord, +) -> Result { + Ok(contract::VisualNovelHistoryEntry { + entry_id: record.entry_id, + run_id: record.run_id, + turn_index: record.turn_index, + source: parse_history_source(record.source.as_str()), + action_text: record.action_text, + steps: parse_value(record.steps)?, + snapshot_before_hash: record.snapshot_before_hash, + snapshot_after_hash: record.snapshot_after_hash, + created_at: record.created_at, + }) +} + +fn project_draft_for_work( + draft: &contract::VisualNovelResultDraft, + profile_id: &str, +) -> Result { + let mut projected = draft.clone(); + projected.profile_id = Some(profile_id.to_string()); + let domain_draft = contract_to_domain::<_, domain::VisualNovelResultDraft>(&projected)?; + let profile = + domain::compile_visual_novel_profile(&domain_draft).map_err(domain_error_to_app_error)?; + let normalized_draft = + domain_to_contract::<_, contract::VisualNovelResultDraft>(&profile.draft)?; + Ok(DraftWorkProjection { + work_title: profile.work_title, + work_description: profile.work_description, + tags_json: to_json_string(&profile.work_tags)?, + cover_image_src: profile.cover_image_src, + publish_ready: normalized_draft.publish_ready, + draft: normalized_draft, + }) +} + +fn prepare_draft_for_session( + draft: &mut contract::VisualNovelResultDraft, + profile_id: Option, + updated_at: &str, +) { + if profile_id.is_some() { + draft.profile_id = profile_id; + } + draft.updated_at = updated_at.to_string(); + let issues = domain::validate_visual_novel_draft( + &contract_to_domain::<_, domain::VisualNovelResultDraft>(draft.clone()) + .unwrap_or_else(|_| fallback_domain_draft(updated_at)), + ); + draft.validation_issues = issues + .into_iter() + .filter_map(|issue| domain_to_contract(issue).ok()) + .collect(); + draft.publish_ready = draft.validation_issues.is_empty(); +} + +fn fallback_result_draft( + session: &VisualNovelAgentSessionRecord, + latest_user_text: Option<&str>, + updated_at: &str, +) -> contract::VisualNovelResultDraft { + let seed = latest_user_text + .and_then(normalize_required_string) + .or_else(|| normalize_required_string(session.seed_text.as_str())) + .unwrap_or_else(|| "一段尚未命名的视觉小说".to_string()); + let title = seed.chars().take(14).collect::(); + let mut draft = contract::VisualNovelResultDraft { + profile_id: None, + work_title: if title.is_empty() { + "未命名视觉小说".to_string() + } else { + title + }, + work_description: format!("{seed}。"), + work_tags: vec!["视觉小说".to_string()], + cover_image_src: None, + source_mode: parse_source_mode(session.source_mode.as_str()), + source_asset_ids: session.source_asset_ids.clone(), + world: contract::VisualNovelWorldDraft { + title: "未命名世界".to_string(), + summary: seed.clone(), + background: "故事发生在一个等待创作者继续补完的世界。".to_string(), + premise: seed.clone(), + literary_style: "细腻、对话驱动".to_string(), + player_role: "故事的亲历者".to_string(), + default_tone: "温柔而带有悬念".to_string(), + }, + characters: vec![contract::VisualNovelCharacterDraft { + character_id: "char-main-1".to_string(), + name: "引路人".to_string(), + gender: None, + role: contract::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![contract::VisualNovelSceneDraft { + scene_id: "scene-opening".to_string(), + name: "开场场景".to_string(), + description: "适合生成视觉小说背景图的开场空间。".to_string(), + background_image_src: None, + music_src: None, + ambient_sound_src: None, + availability: contract::VisualNovelSceneAvailability::Opening, + phase_ids: vec!["phase-opening".to_string()], + }], + story_phases: vec![contract::VisualNovelStoryPhaseDraft { + phase_id: "phase-opening".to_string(), + title: "故事开端".to_string(), + goal: "让玩家理解当前处境。".to_string(), + summary: seed.clone(), + entry_condition: "opening".to_string(), + exit_condition: "玩家做出第一轮选择。".to_string(), + scene_ids: vec!["scene-opening".to_string()], + character_ids: vec!["char-main-1".to_string()], + suggested_choices: vec!["询问发生了什么".to_string(), "观察四周".to_string()], + }], + opening: contract::VisualNovelOpeningDraft { + scene_id: Some("scene-opening".to_string()), + narration: format!("{seed}。故事从这里开始。"), + speaker_character_id: Some("char-main-1".to_string()), + first_dialogue: Some("先别急,我们还有时间把一切讲清楚。".to_string()), + initial_choices: vec![ + contract::VisualNovelChoiceDraft { + choice_id: "choice-opening-1".to_string(), + text: "询问发生了什么".to_string(), + action_hint: None, + }, + contract::VisualNovelChoiceDraft { + choice_id: "choice-opening-2".to_string(), + text: "观察四周".to_string(), + action_hint: None, + }, + ], + }, + runtime_config: contract::VisualNovelRuntimeConfigDraft { + text_mode_enabled: true, + default_text_mode: false, + max_history_entries: domain::VISUAL_NOVEL_DEFAULT_MAX_HISTORY_ENTRIES, + max_assistant_step_count_per_turn: + domain::VISUAL_NOVEL_DEFAULT_MAX_ASSISTANT_STEP_COUNT_PER_TURN, + allow_free_text_action: true, + allow_history_regeneration: true, + attribute_panel_mode: contract::VisualNovelAttributePanelMode::Off, + save_archive_enabled: true, + }, + publish_ready: false, + validation_issues: Vec::new(), + updated_at: updated_at.to_string(), + }; + prepare_draft_for_session(&mut draft, None, updated_at); + draft +} + +fn fallback_domain_draft(updated_at: &str) -> domain::VisualNovelResultDraft { + domain::VisualNovelResultDraft { + profile_id: Some("vn-profile-fallback".to_string()), + work_title: "未命名视觉小说".to_string(), + work_description: "视觉小说草稿".to_string(), + work_tags: vec!["视觉小说".to_string()], + cover_image_src: None, + source_mode: domain::VisualNovelSourceMode::Idea, + source_asset_ids: Vec::new(), + world: domain::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::new(), + scenes: Vec::new(), + story_phases: Vec::new(), + opening: domain::VisualNovelOpeningDraft { + scene_id: None, + narration: "故事从这里开始。".to_string(), + speaker_character_id: None, + first_dialogue: None, + initial_choices: Vec::new(), + }, + runtime_config: domain::VisualNovelRuntimeConfigDraft::default(), + publish_ready: false, + validation_issues: Vec::new(), + updated_at: updated_at.to_string(), + } +} + +fn fallback_runtime_steps( + work: &contract::VisualNovelWorkDetail, + payload: &contract::VisualNovelRuntimeActionRequest, +) -> Vec { + let action_text = payload + .text + .as_deref() + .and_then(normalize_required_string) + .or_else(|| { + payload.choice_id.as_ref().and_then(|choice_id| { + work.draft + .opening + .initial_choices + .iter() + .find(|choice| choice.choice_id == *choice_id) + .map(|choice| choice.text.clone()) + }) + }) + .unwrap_or_else(|| "继续".to_string()); + let character = work.draft.characters.first(); + let character_id = character + .map(|character| character.character_id.clone()) + .unwrap_or_else(|| "char-main-1".to_string()); + let character_name = character + .map(|character| character.name.clone()) + .unwrap_or_else(|| "引路人".to_string()); + + vec![ + contract::VisualNovelRuntimeStep::Narration { + text: format!("你选择了:{action_text}。"), + }, + contract::VisualNovelRuntimeStep::Dialogue { + character_id, + character_name, + expression: None, + text: "故事继续向前推进。".to_string(), + }, + contract::VisualNovelRuntimeStep::Choice { + choices: vec![ + contract::VisualNovelChoiceDraft { + choice_id: build_prefixed_uuid_id("choice-vn-"), + text: "继续追问".to_string(), + action_hint: None, + }, + contract::VisualNovelChoiceDraft { + choice_id: build_prefixed_uuid_id("choice-vn-"), + text: "观察变化".to_string(), + action_hint: None, + }, + ], + }, + ] +} + +fn work_detail_to_domain_profile( + work: &contract::VisualNovelWorkDetail, +) -> Result { + Ok(domain::VisualNovelWorkProfile { + profile_id: work.summary.profile_id.clone(), + work_title: work.summary.title.clone(), + work_description: work.summary.description.clone(), + work_tags: work.summary.tags.clone(), + cover_image_src: work.summary.cover_image_src.clone(), + source_mode: contract_to_domain(work.draft.source_mode.clone()) + .map_err(IntoResponse::into_response)?, + draft: contract_to_domain(work.draft.clone()).map_err(IntoResponse::into_response)?, + }) +} + +fn snapshot_to_domain_run( + snapshot: &contract::VisualNovelRunSnapshot, +) -> domain::VisualNovelRunSnapshot { + contract_to_domain(snapshot.clone()).unwrap_or_else(|_| domain::VisualNovelRunSnapshot { + run_id: snapshot.run_id.clone(), + owner_user_id: snapshot.owner_user_id.clone(), + profile_id: snapshot.profile_id.clone(), + mode: domain::VisualNovelRunMode::Test, + status: domain::VisualNovelRunStatus::Active, + current_scene_id: snapshot.current_scene_id.clone(), + current_phase_id: snapshot.current_phase_id.clone(), + visible_character_ids: snapshot.visible_character_ids.clone(), + flags: BTreeMap::new(), + metrics: BTreeMap::new(), + history: Vec::new(), + available_choices: Vec::new(), + text_mode_enabled: snapshot.text_mode_enabled, + created_at: snapshot.created_at.clone(), + updated_at: snapshot.updated_at.clone(), + }) +} + +fn runtime_request_to_domain_action( + payload: contract::VisualNovelRuntimeActionRequest, +) -> Result { + contract_to_domain(payload).map_err(IntoResponse::into_response) +} + +fn source_mode_to_wire(value: &contract::VisualNovelSourceMode) -> &'static str { + match value { + contract::VisualNovelSourceMode::Idea => "idea", + contract::VisualNovelSourceMode::Document => "document", + contract::VisualNovelSourceMode::Blank => "blank", + } +} + +fn run_mode_to_wire(value: &contract::VisualNovelRunMode) -> &'static str { + match value { + contract::VisualNovelRunMode::Test => "test", + contract::VisualNovelRunMode::Play => "play", + } +} + +fn run_status_to_wire(value: &domain::VisualNovelRunStatus) -> &'static str { + match value { + domain::VisualNovelRunStatus::Active => "active", + domain::VisualNovelRunStatus::Completed => "completed", + domain::VisualNovelRunStatus::Failed => "failed", + } +} + +fn history_source_to_wire(value: &domain::VisualNovelHistorySource) -> &'static str { + match value { + domain::VisualNovelHistorySource::Player => "player", + domain::VisualNovelHistorySource::Assistant => "assistant", + domain::VisualNovelHistorySource::System => "system", + } +} + +fn parse_source_mode(value: &str) -> contract::VisualNovelSourceMode { + match value { + "document" => contract::VisualNovelSourceMode::Document, + "blank" => contract::VisualNovelSourceMode::Blank, + _ => contract::VisualNovelSourceMode::Idea, + } +} + +fn parse_agent_status(value: &str) -> contract::VisualNovelAgentStatus { + match value { + "drafting" => contract::VisualNovelAgentStatus::Drafting, + "ready" => contract::VisualNovelAgentStatus::Ready, + "failed" => contract::VisualNovelAgentStatus::Failed, + _ => contract::VisualNovelAgentStatus::Collecting, + } +} + +fn parse_message_role(value: &str) -> contract::VisualNovelAgentMessageRole { + match value { + "assistant" => contract::VisualNovelAgentMessageRole::Assistant, + "system" => contract::VisualNovelAgentMessageRole::System, + _ => contract::VisualNovelAgentMessageRole::User, + } +} + +fn parse_message_kind(value: &str) -> contract::VisualNovelAgentMessageKind { + match value { + "summary" => contract::VisualNovelAgentMessageKind::Summary, + "action_result" => contract::VisualNovelAgentMessageKind::ActionResult, + "warning" => contract::VisualNovelAgentMessageKind::Warning, + _ => contract::VisualNovelAgentMessageKind::Chat, + } +} + +fn parse_run_mode(value: &str) -> contract::VisualNovelRunMode { + match value { + "play" => contract::VisualNovelRunMode::Play, + _ => contract::VisualNovelRunMode::Test, + } +} + +fn parse_run_status(value: &str) -> contract::VisualNovelRunStatus { + match value { + "completed" => contract::VisualNovelRunStatus::Completed, + "failed" => contract::VisualNovelRunStatus::Failed, + _ => contract::VisualNovelRunStatus::Active, + } +} + +fn parse_history_source(value: &str) -> contract::VisualNovelHistorySource { + match value { + "assistant" => contract::VisualNovelHistorySource::Assistant, + "system" => contract::VisualNovelHistorySource::System, + _ => contract::VisualNovelHistorySource::Player, + } +} + +fn message_record_to_prompt_value(record: &VisualNovelAgentMessageRecord) -> Value { + json!({ + "role": record.role, + "kind": record.kind, + "text": record.text, + "createdAt": record.created_at, + }) +} + +fn value_to_map( + value: Value, +) -> Result, AppError> { + parse_value(value) +} + +fn value_to_f64_map(value: Value) -> Result, AppError> { + parse_value(value) +} + +fn parse_value(value: Value) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_value(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("视觉小说数据结构解析失败:{error}"), + })) + }) +} + +fn to_json_string(value: &T) -> Result +where + T: Serialize, +{ + serde_json::to_string(value).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("视觉小说 JSON 序列化失败:{error}"), + })) + }) +} + +fn to_value(value: T) -> Result +where + T: Serialize, +{ + serde_json::to_value(value).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("视觉小说 JSON 序列化失败:{error}"), + })) + }) +} + +fn contract_to_domain(value: T) -> Result +where + T: Serialize, + U: DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("视觉小说契约到领域模型转换失败:{error}"), + })) + }) +} + +fn domain_to_contract(value: T) -> Result +where + T: Serialize, + U: DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("视觉小说领域模型到契约转换失败:{error}"), + })) + }) +} + +fn parse_json_payload( + request_context: &RequestContext, + payload: Result, JsonRejection>, +) -> Result, Response> { + payload.map_err(|error| { + visual_novel_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": error.body_text(), + })), + ) + }) +} + +fn ensure_non_empty(value: &str, label: &str) -> Result<(), Response> { + if normalize_required_string(value).is_some() { + Ok(()) + } else { + Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": format!("{label} 不能为空"), + })) + .into_response()) + } +} + +fn visual_novel_bad_request(request_context: &RequestContext, message: &str) -> Response { + visual_novel_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": message, + })), + ) +} + +fn visual_novel_internal_error(message: &str) -> Response { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": message, + })) + .into_response() +} + +fn visual_novel_error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +fn map_spacetime_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("Not found") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("无权") + || message.contains("forbidden") + || message.contains("Forbidden") => + { + StatusCode::FORBIDDEN + } + SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT, + SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": error.to_string(), + })) +} + +fn domain_error_to_app_error(error: domain::VisualNovelDomainError) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": VISUAL_NOVEL_PROVIDER, + "message": error.to_string(), + })) +} + +fn sse_contract_event(payload: &T) -> Event +where + T: Serialize, +{ + Event::default().json_data(payload).unwrap_or_else(|_| { + Event::default() + .event("error") + .data("{\"type\":\"error\",\"message\":\"SSE payload 序列化失败\",\"retryable\":false}") + }) +} + +fn sse_error_event(message: String) -> Event { + sse_contract_event(&contract::VisualNovelRuntimeStreamEvent::Error { + message, + retryable: true, + }) +} + +fn current_utc_micros() -> i64 { + offset_datetime_to_unix_micros(OffsetDateTime::now_utc()) +} + +fn current_utc_iso() -> String { + format_rfc3339(OffsetDateTime::now_utc()) + .unwrap_or_else(|_| format_timestamp_micros(current_utc_micros())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fallback_draft_is_publishable_enough_for_creation_preview() { + let record = VisualNovelAgentSessionRecord { + session_id: "vn-session-test".to_string(), + owner_user_id: "user-test".to_string(), + source_mode: "idea".to_string(), + status: "collecting".to_string(), + seed_text: "雨夜书店".to_string(), + source_asset_ids: Vec::new(), + current_turn: 0, + progress_percent: 0, + messages: Vec::new(), + draft: None, + pending_action: None, + last_assistant_reply: None, + published_profile_id: None, + created_at: "0.000000Z".to_string(), + updated_at: "0.000000Z".to_string(), + }; + + let draft = fallback_result_draft(&record, None, "2026-05-05T00:00:00Z"); + + assert_eq!(draft.source_mode, contract::VisualNovelSourceMode::Idea); + assert_eq!(draft.characters.len(), 1); + assert_eq!(draft.opening.initial_choices.len(), 2); + assert!(draft.publish_ready); + } + + #[test] + fn visual_novel_runtime_kind_uses_platform_archive_namespace() { + assert_eq!(VISUAL_NOVEL_RUNTIME_KIND, "visual-novel"); + } + + #[test] + fn document_creation_prompt_uses_bounded_summary() { + let record = VisualNovelAgentSessionRecord { + session_id: "vn-session-doc".to_string(), + owner_user_id: "user-test".to_string(), + source_mode: "document".to_string(), + status: "collecting".to_string(), + seed_text: "旧书店".repeat(2_000), + source_asset_ids: vec!["asset-doc-1".to_string()], + current_turn: 0, + progress_percent: 0, + messages: Vec::new(), + draft: None, + pending_action: None, + last_assistant_reply: None, + published_profile_id: None, + created_at: "0.000000Z".to_string(), + updated_at: "0.000000Z".to_string(), + }; + + let summary = resolve_document_summary_for_prompt(&record, None) + .expect("document session should build summary"); + + assert_eq!(summary.chars().count(), VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS); + assert!(summary.contains("旧书店")); + } + + #[test] + fn idea_creation_prompt_omits_document_summary() { + let record = VisualNovelAgentSessionRecord { + session_id: "vn-session-idea".to_string(), + owner_user_id: "user-test".to_string(), + source_mode: "idea".to_string(), + status: "collecting".to_string(), + seed_text: "雨夜书店".to_string(), + source_asset_ids: Vec::new(), + current_turn: 0, + progress_percent: 0, + messages: Vec::new(), + draft: None, + pending_action: None, + last_assistant_reply: None, + published_profile_id: None, + created_at: "0.000000Z".to_string(), + updated_at: "0.000000Z".to_string(), + }; + + assert!(resolve_document_summary_for_prompt(&record, None).is_none()); + } +} + +async fn submit_visual_novel_message_turn( + state: &AppState, + owner_user_id: String, + session_id: String, + client_message_id: String, + text: String, +) -> Result { + ensure_non_empty(&session_id, "sessionId")?; + ensure_non_empty(&client_message_id, "clientMessageId")?; + ensure_non_empty(&text, "text")?; + let submitted = state + .spacetime_client() + .submit_visual_novel_agent_message(VisualNovelAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + user_message_id: client_message_id, + user_message_text: text.clone(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| map_spacetime_error(error).into_response())?; + let draft = create_or_update_creation_draft(state, &submitted, Some(text)).await?; + finalize_creation_session( + state, + owner_user_id, + session_id, + draft, + "已根据输入更新视觉小说底稿。", + ) + .await +} + +async fn create_or_update_creation_draft( + state: &AppState, + session: &VisualNovelAgentSessionRecord, + latest_user_text: Option, +) -> Result { + let now_iso = current_utc_iso(); + let document_summary = resolve_document_summary_for_prompt(session, latest_user_text.as_deref()); + if let Some(llm_client) = state.llm_client() { + let current_draft = session.draft.as_ref(); + let recent_messages = session + .messages + .iter() + .rev() + .take(8) + .rev() + .map(message_record_to_prompt_value) + .collect::>(); + let request = vn_prompt::build_visual_novel_creation_llm_request( + vn_prompt::VisualNovelCreationPromptParams { + source_mode: session.source_mode.as_str(), + seed_text: latest_user_text + .as_deref() + .or_else(|| Some(session.seed_text.as_str())), + source_asset_ids: &session.source_asset_ids, + document_summary: document_summary.as_deref(), + current_draft, + recent_messages: &recent_messages, + now_iso: now_iso.as_str(), + }, + false, + ); + if let Ok(response) = llm_client.request_text(request).await { + if let Ok(mut draft) = + vn_prompt::parse_visual_novel_result_draft_fixture(response.content.as_str()) + { + prepare_draft_for_session(&mut draft, None, &now_iso); + return Ok(draft); + } + } + } + + Ok(fallback_result_draft( + session, + latest_user_text.as_deref(), + now_iso.as_str(), + )) +} + +async fn finalize_creation_session( + state: &AppState, + owner_user_id: String, + session_id: String, + draft: contract::VisualNovelResultDraft, + assistant_reply: &str, +) -> Result { + let finalized = state + .spacetime_client() + .finalize_visual_novel_agent_message(VisualNovelAgentMessageFinalizeRecordInput { + session_id, + owner_user_id, + assistant_message_id: Some(build_prefixed_uuid_id(VISUAL_NOVEL_MESSAGE_ID_PREFIX)), + assistant_reply_text: Some(assistant_reply.to_string()), + draft_json: Some(to_json_string(&draft).map_err(IntoResponse::into_response)?), + pending_action_json: None, + status: "ready".to_string(), + progress_percent: 70, + updated_at_micros: current_utc_micros(), + error_message: None, + }) + .await + .map_err(|error| map_spacetime_error(error).into_response())?; + map_session_record(finalized).map_err(IntoResponse::into_response) +} + +fn resolve_document_summary_for_prompt( + session: &VisualNovelAgentSessionRecord, + latest_user_text: Option<&str>, +) -> Option { + if session.source_mode.as_str() != "document" { + return None; + } + + let source = latest_user_text + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + let seed_text = session.seed_text.trim(); + (!seed_text.is_empty()).then_some(seed_text) + })?; + + Some(source.chars().take(VISUAL_NOVEL_DOCUMENT_SUMMARY_MAX_CHARS).collect()) +} + +async fn compile_visual_novel_session_inner( + state: &AppState, + request_context: &RequestContext, + owner_user_id: String, + session_id: String, +) -> Result { + let session = state + .spacetime_client() + .get_visual_novel_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + visual_novel_error_response(request_context, map_spacetime_error(error)) + })?; + let draft = session.draft.clone().ok_or_else(|| { + visual_novel_bad_request(request_context, "视觉小说 session 尚未生成底稿") + })?; + let mut draft = parse_value::(draft)?; + let profile_id = draft + .profile_id + .clone() + .unwrap_or_else(|| build_prefixed_uuid_id(domain::VISUAL_NOVEL_PROFILE_ID_PREFIX)); + prepare_draft_for_session( + &mut draft, + Some(profile_id.clone()), + current_utc_iso().as_str(), + ); + let projection = project_draft_for_work(&draft, &profile_id)?; + let author = resolve_work_author_by_user_id(state, &owner_user_id, None, None); + let compiled_session = state + .spacetime_client() + .compile_visual_novel_work_profile(VisualNovelWorkCompileRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + profile_id: profile_id.clone(), + work_id: Some(build_prefixed_uuid_id(VISUAL_NOVEL_WORK_ID_PREFIX)), + author_display_name: author.display_name, + work_title: Some(projection.work_title), + work_description: Some(projection.work_description), + tags_json: Some(projection.tags_json), + cover_image_src: projection.cover_image_src, + compiled_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + visual_novel_error_response(request_context, map_spacetime_error(error)) + })?; + let work = state + .spacetime_client() + .get_visual_novel_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + visual_novel_error_response(request_context, map_spacetime_error(error)) + })?; + + Ok(contract::VisualNovelCompileResponse { + session: map_session_record(compiled_session)?, + work: map_work_detail(state, work)?, + }) +} + +fn resolve_action_draft( + session: &VisualNovelAgentSessionRecord, + payload: &contract::ExecuteVisualNovelAgentActionRequest, +) -> Result { + if let Some(draft_value) = payload + .payload + .as_ref() + .and_then(|payload| payload.get("draft").cloned()) + { + return parse_value(draft_value).map_err(IntoResponse::into_response); + } + if let Some(draft) = session.draft.clone() { + return parse_value(draft).map_err(IntoResponse::into_response); + } + Ok(fallback_result_draft( + session, + session.seed_text.as_str().into(), + current_utc_iso().as_str(), + )) +} diff --git a/server-rs/crates/module-creative-agent/Cargo.toml b/server-rs/crates/module-creative-agent/Cargo.toml new file mode 100644 index 000000000..27eaa586c --- /dev/null +++ b/server-rs/crates/module-creative-agent/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-creative-agent/src/application.rs b/server-rs/crates/module-creative-agent/src/application.rs new file mode 100644 index 000000000..8dc3995ba --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/application.rs @@ -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() + ); + } +} diff --git a/server-rs/crates/module-creative-agent/src/commands.rs b/server-rs/crates/module-creative-agent/src/commands.rs new file mode 100644 index 000000000..9af0494b4 --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/commands.rs @@ -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, + pub welcome_message_text: Option, + 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, + 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, + pub assistant_message_text: Option, + pub updated_at_micros: i64, +} diff --git a/server-rs/crates/module-creative-agent/src/domain.rs b/server-rs/crates/module-creative-agent/src/domain.rs new file mode 100644 index 000000000..f1a1e6232 --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/domain.rs @@ -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, + pub read_url: Option, + pub thumbnail_url: Option, + pub width: Option, + pub height: Option, + pub summary: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CreativeInputSummarySnapshot { + pub text: Option, + pub entry_context: String, + pub images: Vec, + pub material_summary: Option, + 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, + 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, + pub puzzle_template_selection_json: Option, + pub puzzle_image_generation_plan_json: Option, + pub target_binding: Option, + 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, + pub error_message: Option, +} + +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", + } + } +} diff --git a/server-rs/crates/module-creative-agent/src/errors.rs b/server-rs/crates/module-creative-agent/src/errors.rs new file mode 100644 index 000000000..4d2e58737 --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-creative-agent/src/lib.rs b/server-rs/crates/module-creative-agent/src/lib.rs new file mode 100644 index 000000000..b68fa5247 --- /dev/null +++ b/server-rs/crates/module-creative-agent/src/lib.rs @@ -0,0 +1,9 @@ +mod application; +mod commands; +mod domain; +mod errors; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index 80517a9fc..39793eb51 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -5,10 +5,10 @@ use crate::{ MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, 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_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, - Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, - Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, - Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, + MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, + Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, + Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, + Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, }; #[derive(Clone, Copy)] @@ -358,10 +358,7 @@ fn resolve_size_tier_plan(item_type_count: usize) -> Vec { (exact_count.floor() as usize, exact_count.fract(), *rule) }) .collect::>(); - let mut assigned_count = plans - .iter() - .map(|(count, _, _)| *count) - .sum::(); + let mut assigned_count = plans.iter().map(|(count, _, _)| *count).sum::(); let mut remainder_order = (0..plans.len()).collect::>(); remainder_order.sort_by(|left, right| { plans[*right] @@ -802,9 +799,11 @@ mod tests { } assert_eq!(radii_by_visual_key.len(), 25); - assert!(radii_by_visual_key.values().all(|radii| { - radii.iter().all(|radius| radius == &radii[0]) - })); + assert!( + radii_by_visual_key + .values() + .all(|radii| { radii.iter().all(|radius| radius == &radii[0]) }) + ); } #[test] @@ -824,7 +823,11 @@ mod tests { .iter() .map(|item| item.visual_key.as_str()) .collect::>(); - 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 { let dx = item.x - MATCH3D_BOARD_CENTER; diff --git a/server-rs/crates/module-puzzle/Cargo.toml b/server-rs/crates/module-puzzle/Cargo.toml index 90be0dc8b..583768a91 100644 --- a/server-rs/crates/module-puzzle/Cargo.toml +++ b/server-rs/crates/module-puzzle/Cargo.toml @@ -10,5 +10,6 @@ spacetime-types = ["dep:spacetimedb"] [dependencies] serde = { workspace = true } +serde_json = { workspace = true } shared-kernel = { workspace = true } spacetimedb = { workspace = true, optional = true } diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index 64dcd8de7..33a1234f9 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -185,6 +185,7 @@ pub fn compile_result_draft_from_seed( level_id: "puzzle-level-1".to_string(), level_name: level_name.clone(), picture_description, + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, @@ -240,6 +241,7 @@ pub fn build_form_draft_from_parts( level_id: "puzzle-level-1".to_string(), level_name: String::new(), picture_description: picture_description.clone().unwrap_or_default(), + picture_reference: None, candidates: Vec::new(), selected_candidate_id: 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.summary, ), + picture_reference: None, candidates: draft.candidates.clone(), selected_candidate_id: draft.selected_candidate_id.clone(), cover_image_src: draft.cover_image_src.clone(), @@ -429,6 +432,7 @@ pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft next_index, ), picture_description, + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: None, @@ -671,10 +675,12 @@ pub fn normalize_puzzle_levels( .unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); let picture_description = normalize_required_string(&level.picture_description) .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(); level.level_id = level_id; level.level_name = level_name; level.picture_description = picture_description; + level.picture_reference = picture_reference; level.generation_status = normalize_required_string(&level.generation_status) .unwrap_or_else(|| "idle".to_string()); normalized_levels.push(level); @@ -2791,6 +2797,7 @@ mod tests { level_id: "puzzle-level-1".to_string(), level_name: format!("{profile_id} 关"), picture_description: "summary".to_string(), + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/cover.png".to_string()), @@ -3004,6 +3011,7 @@ mod tests { level_id: "puzzle-level-1".to_string(), level_name: "第一关".to_string(), picture_description: "第一关画面".to_string(), + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/level-1.png".to_string()), @@ -3014,6 +3022,7 @@ mod tests { level_id: "puzzle-level-2".to_string(), level_name: "第二关".to_string(), picture_description: "第二关画面".to_string(), + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: Some("/level-2.png".to_string()), diff --git a/server-rs/crates/module-puzzle/src/creative_templates.rs b/server-rs/crates/module-puzzle/src/creative_templates.rs new file mode 100644 index 000000000..06dc71859 --- /dev/null +++ b/server-rs/crates/module-puzzle/src/creative_templates.rs @@ -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, + 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, + pub image_policy: PuzzleCreativeImageGenerationPolicy, +} + +fn shared_required_draft_fields() -> Vec { + 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 { + 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) + ); + } +} diff --git a/server-rs/crates/module-puzzle/src/creative_tools.rs b/server-rs/crates/module-puzzle/src/creative_tools.rs new file mode 100644 index 000000000..dd0c9e00e --- /dev/null +++ b/server-rs/crates/module-puzzle/src/creative_tools.rs @@ -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, +} + +#[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, + pub levels: Vec, +} + +#[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, + pub cost_range: PuzzleCreativeCostRange, + #[serde(default)] + pub candidate_count_per_level: Option, +} + +#[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, + 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, +} + +#[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, + 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 { + 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::, 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 { + 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::, 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 { + 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 { + 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) -> Result, 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 { + value + .as_str() + .and_then(normalize_required_string) + .ok_or(PuzzleFieldError::MissingText) +} + +fn value_as_optional_string(value: &Value) -> Option { + value.as_str().and_then(normalize_required_string) +} + +fn value_as_string_list(value: &Value) -> Result, PuzzleFieldError> { + value + .as_array() + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect::>() + }) + .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); + } +} diff --git a/server-rs/crates/module-puzzle/src/domain.rs b/server-rs/crates/module-puzzle/src/domain.rs index 9ca7ac267..1ae4e83ca 100644 --- a/server-rs/crates/module-puzzle/src/domain.rs +++ b/server-rs/crates/module-puzzle/src/domain.rs @@ -129,6 +129,8 @@ pub struct PuzzleDraftLevel { pub level_id: String, pub level_name: String, pub picture_description: String, + #[serde(default)] + pub picture_reference: Option, pub candidates: Vec, pub selected_candidate_id: Option, pub cover_image_src: Option, diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 6acdc7c7b..09b647691 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -1,11 +1,15 @@ mod application; mod commands; +mod creative_templates; +mod creative_tools; mod domain; mod errors; mod events; pub use application::*; pub use commands::*; +pub use creative_templates::*; +pub use creative_tools::*; pub use domain::*; pub use errors::*; pub use events::*; diff --git a/server-rs/crates/module-runtime/tests/analytics_granularity.rs b/server-rs/crates/module-runtime/tests/analytics_granularity.rs index 0543f64aa..f4ba6e6f3 100644 --- a/server-rs/crates/module-runtime/tests/analytics_granularity.rs +++ b/server-rs/crates/module-runtime/tests/analytics_granularity.rs @@ -3,7 +3,12 @@ use module_runtime::{ 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 { event_key: event_key.to_string(), scope_kind: RuntimeTrackingScopeKind::User, @@ -55,7 +60,17 @@ fn aggregates_daily_stats_by_month_quarter_and_year_bucket() { "user-1", AnalyticsGranularity::Month, ); - assert_eq!(month.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::>(), 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![ + (&"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_end_date_key, 20_573); @@ -66,7 +81,13 @@ fn aggregates_daily_stats_by_month_quarter_and_year_bucket() { "user-1", AnalyticsGranularity::Quarter, ); - assert_eq!(quarter.iter().map(|bucket| (&bucket.bucket_key, bucket.value)).collect::>(), vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)]); + assert_eq!( + quarter + .iter() + .map(|bucket| (&bucket.bucket_key, bucket.value)) + .collect::>(), + vec![(&"20262".to_string(), 10), (&"20264".to_string(), 7)] + ); let year = aggregate_runtime_tracking_daily_stats( stats, diff --git a/server-rs/crates/module-visual-novel/Cargo.toml b/server-rs/crates/module-visual-novel/Cargo.toml new file mode 100644 index 000000000..c3513cc07 --- /dev/null +++ b/server-rs/crates/module-visual-novel/Cargo.toml @@ -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 } diff --git a/server-rs/crates/module-visual-novel/src/application.rs b/server-rs/crates/module-visual-novel/src/application.rs new file mode 100644 index 000000000..7af9dde04 --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/application.rs @@ -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 { + 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::>(); + let phase_ids = draft + .story_phases + .iter() + .map(|phase| phase.phase_id.as_str()) + .collect::>(); + + 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 { + 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, VisualNovelDomainError> { + let text = + normalize_required_string(model_output).ok_or(VisualNovelDomainError::InvalidJson)?; + let steps = serde_json::from_str::>(&text) + .or_else(|_| serde_json::from_str::(&text).map(|value| value.steps)) + .map_err(|_| VisualNovelDomainError::InvalidJson)?; + let parsed = steps + .into_iter() + .map(RuntimeStepInput::try_into) + .collect::, _>>()?; + 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 { + 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 { + 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 { + 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::>(); + 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 { + 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, + 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, 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, +} + +#[derive(Debug, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +enum RuntimeStepInput { + SceneChange { + scene_id: String, + background_image_src: Option, + music_src: Option, + }, + Narration { + text: String, + }, + Dialogue { + character_id: String, + character_name: String, + expression: Option, + text: String, + }, + Transition { + transition_kind: VisualNovelTransitionKind, + text: Option, + }, + Choice { + choices: Vec, + }, + Flag { + key: String, + value: VisualNovelFlagValue, + }, + Metric { + key: String, + delta: f64, + }, +} + +impl TryFrom for VisualNovelRuntimeStep { + type Error = VisualNovelDomainError; + + fn try_from(value: RuntimeStepInput) -> Result { + 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")); + } +} diff --git a/server-rs/crates/module-visual-novel/src/domain.rs b/server-rs/crates/module-visual-novel/src/domain.rs new file mode 100644 index 000000000..e64d0259b --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/domain.rs @@ -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, +} + +#[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, + 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, + pub role: VisualNovelCharacterRole, + pub appearance: String, + pub personality: String, + pub tone: String, + pub background: String, + pub relationship_to_player: String, + pub image_assets: Vec, + pub default_expression: Option, + 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, + pub music_src: Option, + pub ambient_sound_src: Option, + pub availability: VisualNovelSceneAvailability, + pub phase_ids: Vec, +} + +#[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, + pub character_ids: Vec, + pub suggested_choices: Vec, +} + +#[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, + pub narration: String, + pub speaker_character_id: Option, + pub first_dialogue: Option, + pub initial_choices: Vec, +} + +#[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, + pub work_title: String, + pub work_description: String, + pub work_tags: Vec, + pub cover_image_src: Option, + pub source_mode: VisualNovelSourceMode, + pub source_asset_ids: Vec, + pub world: VisualNovelWorldDraft, + pub characters: Vec, + pub scenes: Vec, + pub story_phases: Vec, + pub opening: VisualNovelOpeningDraft, + pub runtime_config: VisualNovelRuntimeConfigDraft, + pub publish_ready: bool, + pub validation_issues: Vec, + 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, + pub cover_image_src: Option, + 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, + music_src: Option, + }, + Narration { + text: String, + }, + Dialogue { + character_id: String, + character_name: String, + expression: Option, + text: String, + }, + Transition { + transition_kind: VisualNovelTransitionKind, + text: Option, + }, + Choice { + choices: Vec, + }, + 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, + pub steps: Vec, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + 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, + pub current_phase_id: Option, + pub visible_character_ids: Vec, + pub flags: BTreeMap, + pub metrics: BTreeMap, + pub history: Vec, + pub available_choices: Vec, + 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, + pub text: Option, + 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, + pub current_phase_id: Option, + pub history_cursor: u32, + pub snapshot_hash: Option, +} + +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, + } + } +} diff --git a/server-rs/crates/module-visual-novel/src/errors.rs b/server-rs/crates/module-visual-novel/src/errors.rs new file mode 100644 index 000000000..0637b63a7 --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/errors.rs @@ -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 {} diff --git a/server-rs/crates/module-visual-novel/src/lib.rs b/server-rs/crates/module-visual-novel/src/lib.rs new file mode 100644 index 000000000..290d744e3 --- /dev/null +++ b/server-rs/crates/module-visual-novel/src/lib.rs @@ -0,0 +1,7 @@ +mod application; +mod domain; +mod errors; + +pub use application::*; +pub use domain::*; +pub use errors::*; diff --git a/server-rs/crates/platform-agent/Cargo.toml b/server-rs/crates/platform-agent/Cargo.toml new file mode 100644 index 000000000..26baee2eb --- /dev/null +++ b/server-rs/crates/platform-agent/Cargo.toml @@ -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"] } diff --git a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs new file mode 100644 index 000000000..ee528203d --- /dev/null +++ b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs @@ -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, + user_text: impl Into, + image_urls: Vec, + ) -> Result { + 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, + user_text: impl Into, + image_urls: Vec, +) -> 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, + } +} diff --git a/server-rs/crates/platform-agent/src/callbacks.rs b/server-rs/crates/platform-agent/src/callbacks.rs new file mode 100644 index 000000000..b94905716 --- /dev/null +++ b/server-rs/crates/platform-agent/src/callbacks.rs @@ -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, +} + +type CreativeAgentCallbackFn = Arc; + +#[derive(Clone, Default)] +pub struct CreativeAgentCallbacks { + on_event: Option, +} + +impl CreativeAgentCallbacks { + pub fn new(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) { + self.emit(CreativeAgentCallbackEvent { + kind: CreativeAgentCallbackKind::Stage, + label: label.into(), + detail: None, + }); + } +} diff --git a/server-rs/crates/platform-agent/src/error.rs b/server-rs/crates/platform-agent/src/error.rs new file mode 100644 index 000000000..610ca22a9 --- /dev/null +++ b/server-rs/crates/platform-agent/src/error.rs @@ -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 for PlatformAgentError { + fn from(error: platform_llm::LlmError) -> Self { + Self::Llm(error.to_string()) + } +} diff --git a/server-rs/crates/platform-agent/src/function_agent.rs b/server-rs/crates/platform-agent/src/function_agent.rs new file mode 100644 index 000000000..42acdecc7 --- /dev/null +++ b/server-rs/crates/platform-agent/src/function_agent.rs @@ -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(&self, future: F) -> Result + where + F: Future>, + { + self.validate()?; + timeout(Duration::from_millis(self.timeout_ms), future) + .await + .map_err(|_| PlatformAgentError::Timeout { + timeout_ms: self.timeout_ms, + })? + } +} diff --git a/server-rs/crates/platform-agent/src/langchain_adapter.rs b/server-rs/crates/platform-agent/src/langchain_adapter.rs new file mode 100644 index 000000000..04b870984 --- /dev/null +++ b/server-rs/crates/platform-agent/src/langchain_adapter.rs @@ -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 { + limits.validate()?; + Ok(Self { limits }) + } + + pub fn limits(&self) -> &FunctionAgentLimits { + &self.limits + } + + pub fn build_function_calling_agent( + &self, + api_key: impl Into, + base_url: impl Into, + tools: Vec>, + system_prompt: Option, + ) -> 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, + input: serde_json::Value, + ) -> Result { + let tool_name = tool_name.into(); + let tools: Vec> = + 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, + ) -> Result { + 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> { + 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 { + 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("系统提示")); + } +} diff --git a/server-rs/crates/platform-agent/src/lib.rs b/server-rs/crates/platform-agent/src/lib.rs new file mode 100644 index 000000000..36018bdae --- /dev/null +++ b/server-rs/crates/platform-agent/src/lib.rs @@ -0,0 +1,23 @@ +pub mod apimart_gpt5_adapter; +pub mod callbacks; +pub mod error; +pub mod function_agent; +pub mod langchain_adapter; +pub mod output_parser; +pub mod puzzle_phase1_agent; +pub mod tool_registry; + +pub use apimart_gpt5_adapter::{ + CREATIVE_AGENT_GPT5_MODEL, Gpt5ResponsesAgentClient, build_gpt5_multimodal_request, +}; +pub use callbacks::{ + CreativeAgentCallbackEvent, CreativeAgentCallbackKind, CreativeAgentCallbacks, +}; +pub use error::PlatformAgentError; +pub use function_agent::FunctionAgentLimits; +pub use langchain_adapter::LangChainRustAdapter; +pub use puzzle_phase1_agent::{ + CreativeAgentExecutor, MockLangChainRustAgentExecutor, PuzzlePhase1AgentInput, + PuzzlePhase1AgentOutput, +}; +pub use tool_registry::{CreativeAgentTool, CreativeAgentToolRegistry, ToolExecutionBudget}; diff --git a/server-rs/crates/platform-agent/src/output_parser.rs b/server-rs/crates/platform-agent/src/output_parser.rs new file mode 100644 index 000000000..c444daf41 --- /dev/null +++ b/server-rs/crates/platform-agent/src/output_parser.rs @@ -0,0 +1,12 @@ +use serde::de::DeserializeOwned; + +use crate::error::PlatformAgentError; + +pub fn parse_json_output(raw_text: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_str(raw_text).map_err(|error| { + PlatformAgentError::OutputParse(format!("解析 Agent JSON 输出失败:{error}")) + }) +} diff --git a/server-rs/crates/platform-agent/src/puzzle_phase1_agent.rs b/server-rs/crates/platform-agent/src/puzzle_phase1_agent.rs new file mode 100644 index 000000000..86126290b --- /dev/null +++ b/server-rs/crates/platform-agent/src/puzzle_phase1_agent.rs @@ -0,0 +1,112 @@ +use async_trait::async_trait; + +use crate::{ + callbacks::CreativeAgentCallbacks, error::PlatformAgentError, + function_agent::FunctionAgentLimits, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePhase1AgentInput { + pub session_id: String, + pub user_text: String, + pub image_urls: Vec, + pub limits: FunctionAgentLimits, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePhase1AgentOutput { + pub session_id: String, + pub model: String, + pub final_message: String, + pub tool_call_count: usize, +} + +#[async_trait] +pub trait CreativeAgentExecutor: Send + Sync { + async fn run_puzzle_phase1( + &self, + input: PuzzlePhase1AgentInput, + callbacks: CreativeAgentCallbacks, + ) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct MockLangChainRustAgentExecutor; + +#[async_trait] +impl CreativeAgentExecutor for MockLangChainRustAgentExecutor { + async fn run_puzzle_phase1( + &self, + input: PuzzlePhase1AgentInput, + callbacks: CreativeAgentCallbacks, + ) -> Result { + input.limits.validate()?; + if input.session_id.trim().is_empty() { + return Err(PlatformAgentError::InvalidInput( + "creative session_id 不能为空".to_string(), + )); + } + if input.user_text.trim().is_empty() && input.image_urls.is_empty() { + return Err(PlatformAgentError::InvalidInput( + "创意 Agent 输入文本和图片不能同时为空".to_string(), + )); + } + + callbacks.stage("perceiving"); + callbacks.stage("thinking"); + callbacks.stage("remembering"); + callbacks.stage("selecting_puzzle_template"); + callbacks.stage("waiting_template_confirmation"); + + Ok(PuzzlePhase1AgentOutput { + session_id: input.session_id, + model: crate::apimart_gpt5_adapter::CREATIVE_AGENT_GPT5_MODEL.to_string(), + final_message: "已完成创意 Agent Phase 1 mock 执行,可进入模板确认。".to_string(), + tool_call_count: 0, + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[tokio::test] + async fn mock_executor_keeps_gpt5_model_and_emits_stages() { + let events = Arc::new(Mutex::new(Vec::new())); + let events_clone = events.clone(); + let callbacks = CreativeAgentCallbacks::new(move |event| { + events_clone.lock().expect("events lock").push(event.label); + }); + + let output = MockLangChainRustAgentExecutor + .run_puzzle_phase1( + PuzzlePhase1AgentInput { + session_id: "creative-session-test".to_string(), + user_text: "做一个家庭纪念拼图".to_string(), + image_urls: vec!["https://example.com/ref.png".to_string()], + limits: FunctionAgentLimits::default(), + }, + callbacks, + ) + .await + .expect("mock executor should succeed"); + + assert_eq!( + output.model, + crate::apimart_gpt5_adapter::CREATIVE_AGENT_GPT5_MODEL + ); + assert_eq!( + events.lock().expect("events lock").as_slice(), + [ + "perceiving", + "thinking", + "remembering", + "selecting_puzzle_template", + "waiting_template_confirmation", + ] + ); + } +} diff --git a/server-rs/crates/platform-agent/src/tool_registry.rs b/server-rs/crates/platform-agent/src/tool_registry.rs new file mode 100644 index 000000000..acd5842d3 --- /dev/null +++ b/server-rs/crates/platform-agent/src/tool_registry.rs @@ -0,0 +1,116 @@ +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +use serde_json::Value; + +use crate::{ + callbacks::{CreativeAgentCallbackEvent, CreativeAgentCallbackKind, CreativeAgentCallbacks}, + error::PlatformAgentError, +}; + +#[async_trait] +pub trait CreativeAgentTool: Send + Sync { + fn name(&self) -> &str; + fn description(&self) -> &str; + fn input_schema(&self) -> Option { + None + } + async fn call(&self, input: Value) -> Result; +} + +#[derive(Debug)] +pub struct ToolExecutionBudget { + max_tool_calls: usize, + used_tool_calls: AtomicUsize, +} + +impl ToolExecutionBudget { + pub fn new(max_tool_calls: usize) -> Result { + if max_tool_calls == 0 { + return Err(PlatformAgentError::InvalidInput( + "Agent max_tool_calls 必须大于 0".to_string(), + )); + } + + Ok(Self { + max_tool_calls, + used_tool_calls: AtomicUsize::new(0), + }) + } + + pub fn reserve_call(&self) -> Result<(), PlatformAgentError> { + let previous = self.used_tool_calls.fetch_add(1, Ordering::SeqCst); + if previous >= self.max_tool_calls { + return Err(PlatformAgentError::ToolBudgetExceeded { + max_tool_calls: self.max_tool_calls, + }); + } + + Ok(()) + } +} + +#[derive(Default)] +pub struct CreativeAgentToolRegistry { + tools: HashMap>, +} + +impl CreativeAgentToolRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, tool: T) + where + T: CreativeAgentTool + 'static, + { + self.tools.insert(tool.name().to_string(), Arc::new(tool)); + } + + pub fn len(&self) -> usize { + self.tools.len() + } + + pub fn is_empty(&self) -> bool { + self.tools.is_empty() + } + + pub async fn execute( + &self, + name: &str, + input: Value, + budget: &ToolExecutionBudget, + callbacks: &CreativeAgentCallbacks, + ) -> Result { + budget.reserve_call()?; + let tool = self + .tools + .get(name) + .ok_or_else(|| PlatformAgentError::ToolNotFound(name.to_string()))?; + + callbacks.emit(CreativeAgentCallbackEvent { + kind: CreativeAgentCallbackKind::ToolStarted, + label: name.to_string(), + detail: None, + }); + + let result = tool.call(input).await; + callbacks.emit(CreativeAgentCallbackEvent { + kind: if result.is_ok() { + CreativeAgentCallbackKind::ToolCompleted + } else { + CreativeAgentCallbackKind::Error + }, + label: name.to_string(), + detail: result.as_ref().err().map(ToString::to_string), + }); + + result + } +} diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index ad1a6e72a..aae988830 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -16,7 +16,7 @@ 当前实现只覆盖“文本 chat completion”主链,不提前混入媒体生成和业务编排: 1. 支持 OpenAI 兼容格式的 JSON 请求与 SSE 增量响应 -2. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回放进本 crate +2. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate 3. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API 4. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 2b296f7a1..a4a9a4f6a 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -57,7 +57,18 @@ pub enum LlmMessageRole { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LlmMessage { pub role: LlmMessageRole, + // 中文注释:保留纯文本字段兼容 Chat Completions 和既有调用;Responses 多模态请求读取 content_parts。 pub content: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub content_parts: Vec, +} + +// Responses 多模态内容块。字段名按上游 OpenAI 兼容协议保持 snake_case。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LlmMessageContentPart { + InputText { text: String }, + InputImage { image_url: String }, } // 文本补全请求冻结为“消息列表 + 可选模型覆盖 + 可选 max_tokens”最小闭环。 @@ -179,10 +190,10 @@ struct ResponsesInputMessage { } #[derive(Serialize)] -struct ResponsesInputContentPart { - #[serde(rename = "type")] - part_type: &'static str, - text: String, +#[serde(tag = "type", rename_all = "snake_case")] +enum ResponsesInputContentPart { + InputText { text: String }, + InputImage { image_url: String }, } #[derive(Serialize)] @@ -398,6 +409,7 @@ impl LlmMessage { Self { role, content: content.into(), + content_parts: Vec::new(), } } @@ -412,6 +424,39 @@ impl LlmMessage { pub fn assistant(content: impl Into) -> Self { Self::new(LlmMessageRole::Assistant, content) } + + pub fn multimodal(role: LlmMessageRole, content_parts: Vec) -> Self { + let content = content_parts + .iter() + .filter_map(|part| match part { + LlmMessageContentPart::InputText { text } => Some(text.as_str()), + LlmMessageContentPart::InputImage { .. } => None, + }) + .collect::>() + .join("\n"); + + Self { + role, + content, + content_parts, + } + } + + pub fn user_multimodal(content_parts: Vec) -> Self { + Self::multimodal(LlmMessageRole::User, content_parts) + } + + pub fn with_image_url(mut self, image_url: impl Into) -> Self { + if self.content_parts.is_empty() && !self.content.trim().is_empty() { + self.content_parts.push(LlmMessageContentPart::InputText { + text: self.content.clone(), + }); + } + self.content_parts.push(LlmMessageContentPart::InputImage { + image_url: image_url.into(), + }); + self + } } impl LlmTextRequest { @@ -466,9 +511,27 @@ impl LlmTextRequest { } for message in &self.messages { - if message.content.trim().is_empty() { + let has_text = !message.content.trim().is_empty() + || message.content_parts.iter().any(|part| match part { + LlmMessageContentPart::InputText { text } => !text.trim().is_empty(), + LlmMessageContentPart::InputImage { .. } => false, + }); + let has_image = message.content_parts.iter().any(|part| match part { + LlmMessageContentPart::InputImage { image_url } => !image_url.trim().is_empty(), + LlmMessageContentPart::InputText { .. } => false, + }); + if !has_text && !has_image { return Err(LlmError::InvalidRequest( - "LLM message.content 不能为空".to_string(), + "LLM message content 不能为空".to_string(), + )); + } + + if message.content_parts.iter().any(|part| match part { + LlmMessageContentPart::InputText { text } => text.trim().is_empty(), + LlmMessageContentPart::InputImage { image_url } => image_url.trim().is_empty(), + }) { + return Err(LlmError::InvalidRequest( + "LLM message content part 不能为空".to_string(), )); } } @@ -1073,10 +1136,30 @@ fn map_responses_input_messages(messages: &[LlmMessage]) -> Vec "user", LlmMessageRole::Assistant => "assistant", }, - content: vec![ResponsesInputContentPart { - part_type: "input_text", - text: message.content.clone(), - }], + content: map_responses_content_parts(message), + }) + .collect() +} + +fn map_responses_content_parts(message: &LlmMessage) -> Vec { + if message.content_parts.is_empty() { + return vec![ResponsesInputContentPart::InputText { + text: message.content.clone(), + }]; + } + + message + .content_parts + .iter() + .map(|part| match part { + LlmMessageContentPart::InputText { text } => { + ResponsesInputContentPart::InputText { text: text.clone() } + } + LlmMessageContentPart::InputImage { image_url } => { + ResponsesInputContentPart::InputImage { + image_url: image_url.clone(), + } + } }) .collect() } @@ -1764,6 +1847,64 @@ mod tests { ); } + #[tokio::test] + async fn responses_multimodal_request_sends_input_text_and_input_image() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_multimodal","model":"gpt-5","output_text":"多模态成功","status":"completed"}"#.to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system("你是创意互动内容生成 Agent"), + LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { + text: "把这张图做成拼图".to_string(), + }, + LlmMessageContentPart::InputImage { + image_url: "https://example.com/ref.png".to_string(), + }, + ]), + ]) + .with_model("gpt-5") + .with_responses_api(), + ) + .await + .expect("responses multimodal request_text should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert_eq!(response.model, "gpt-5"); + assert_eq!(request_json["model"], serde_json::json!("gpt-5")); + assert_eq!( + request_json["input"][1]["content"], + serde_json::json!([ + { "type": "input_text", "text": "把这张图做成拼图" }, + { "type": "input_image", "image_url": "https://example.com/ref.png" } + ]) + ); + } + #[tokio::test] async fn stream_text_accumulates_sse_response() { let server_url = spawn_mock_server(vec![MockResponse { diff --git a/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs b/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs index 1f0b335f6..34dba0902 100644 --- a/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs +++ b/server-rs/crates/shared-contracts/src/creation_agent_document_input.rs @@ -17,6 +17,8 @@ pub struct CreationAgentDocumentInputPayload { pub content_type: Option, pub size_bytes: usize, pub text: String, + #[serde(default)] + pub source_asset_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/shared-contracts/src/creative_agent.rs b/server-rs/crates/shared-contracts/src/creative_agent.rs new file mode 100644 index 000000000..5b61a1485 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/creative_agent.rs @@ -0,0 +1,563 @@ +use crate::puzzle_creative_template::{ + PuzzleCreativeTemplateProtocol, PuzzleCreativeTemplateSelection, PuzzleDraftFieldPatch, + PuzzleImageGenerationPlan, PuzzleTemplateCostRange, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeAgentStage { + Idle, + Perceiving, + Thinking, + Remembering, + SelectingPuzzleTemplate, + WaitingTemplateConfirmation, + PlanningPuzzleLevels, + Acting, + Reflecting, + Collaborating, + TargetReady, + WaitingUser, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeAgentEntryContext { + CreationHome, + PuzzleWorkspace, + GalleryRemix, + DraftRestore, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CreativeAgentMessageRole { + User, + Assistant, + System, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeAgentMessageKind { + Chat, + Stage, + ActionResult, + Warning, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeAgentInputPartType { + InputText, + InputImage, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentInputPart { + #[serde(rename = "type")] + pub part_type: CreativeAgentInputPartType, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub image_url: Option, + #[serde(default)] + pub asset_id: Option, + #[serde(default)] + pub thumbnail_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeImageInput { + pub asset_id: String, + pub read_url: String, + #[serde(default)] + pub thumbnail_url: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeImageSummary { + #[serde(default)] + pub asset_id: Option, + #[serde(default)] + pub read_url: Option, + #[serde(default)] + pub thumbnail_url: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, + #[serde(default)] + pub summary: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeUnsupportedPlayType { + Rpg, + #[serde(rename = "match3d")] + Match3d, + BigFish, + SquareHole, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CreativeCapabilityStatus { + Unsupported, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeUnsupportedCapability { + pub play_type: CreativeUnsupportedPlayType, + pub title: String, + pub status: CreativeCapabilityStatus, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeInputSummary { + #[serde(default)] + pub text: Option, + pub entry_context: CreativeAgentEntryContext, + pub images: Vec, + #[serde(default)] + pub material_summary: Option, + pub unsupported_capabilities: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentMessage { + pub id: String, + pub role: CreativeAgentMessageRole, + pub kind: CreativeAgentMessageKind, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum CreativeTargetPlayType { + Puzzle, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum CreativeTargetStage { + PuzzleAgentWorkspace, + PuzzleResult, + PuzzleRuntime, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeTargetSessionBinding { + pub play_type: CreativeTargetPlayType, + pub target_session_id: String, + pub target_stage: CreativeTargetStage, + #[serde(default)] + pub result_profile_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentSessionSnapshot { + pub session_id: String, + pub stage: CreativeAgentStage, + pub input_summary: CreativeInputSummary, + pub messages: Vec, + #[serde(default)] + pub puzzle_template_catalog: Vec, + #[serde(default)] + pub puzzle_template_selection: Option, + #[serde(default)] + pub puzzle_image_generation_plan: Option, + #[serde(default)] + pub target_binding: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateCreativeAgentSessionRequest { + #[serde(default)] + pub text: Option, + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub entry_context: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentSessionResponse { + pub session: CreativeAgentSessionSnapshot, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StreamCreativeAgentMessageRequest { + pub client_message_id: String, + pub content: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmCreativePuzzleTemplateRequest { + pub selection: PuzzleCreativeTemplateSelection, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeDraftEditStreamRequest { + pub client_message_id: String, + pub instruction: String, + pub target_puzzle_session_id: String, + pub current_draft: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeDraftEditResult { + pub edit_instructions: Vec, + pub session: CreativeAgentSessionSnapshot, + pub puzzle_session: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum CreativeAgentSseEventType { + Stage, + AgentMessageDelta, + ThoughtSummaryDelta, + PuzzleTemplateCatalog, + PuzzleTemplateSelection, + PuzzleCostRange, + PuzzleLevelPlan, + ToolStarted, + ToolCompleted, + Reflection, + TargetSession, + Session, + Error, + Done, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentSseEnvelope { + pub event: CreativeAgentSseEventType, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentStageEvent { + pub session_id: String, + pub stage: CreativeAgentStage, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentMessageDeltaEvent { + pub session_id: String, + pub message_id: String, + pub role: CreativeAgentMessageRole, + pub kind: CreativeAgentMessageKind, + pub text_delta: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentThoughtSummaryDeltaEvent { + pub session_id: String, + pub thought_id: String, + pub text_delta: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentTemplateCatalogEvent { + pub session_id: String, + pub templates: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentTemplateSelectionEvent { + pub session_id: String, + pub selection: PuzzleCreativeTemplateSelection, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentCostRangeEvent { + pub session_id: String, + pub cost_range: PuzzleTemplateCostRange, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentLevelPlanEvent { + pub session_id: String, + pub plan: PuzzleImageGenerationPlan, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentToolEvent { + pub session_id: String, + pub tool_call_id: String, + pub tool_name: String, + #[serde(default)] + pub summary: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentReflectionEvent { + pub session_id: String, + pub pass: bool, + pub summary: String, + pub warnings: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentTargetSessionEvent { + pub session_id: String, + pub binding: CreativeTargetSessionBinding, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentErrorEvent { + #[serde(default)] + pub session_id: Option, + pub code: String, + pub message: String, + pub recoverable: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativeAgentDoneEvent { + pub session_id: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::puzzle_creative_template::{ + PuzzleCreativeTemplateProtocol, PuzzleDraftEditableFieldPath, PuzzleLevelGenerationMode, + PuzzleSupportedLevelMode, PuzzleTemplateImageGenerationPolicy, PuzzleTemplatePricingUnit, + }; + use serde_json::json; + + fn cost_range() -> PuzzleTemplateCostRange { + PuzzleTemplateCostRange { + min_points: 2, + max_points: 12, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: "按关卡数和每关图片生成次数估算".to_string(), + } + } + + fn template_selection() -> PuzzleCreativeTemplateSelection { + PuzzleCreativeTemplateSelection { + template_id: "puzzle.default-creative".to_string(), + title: "创意拼图".to_string(), + reason: "素材适合拆成可试玩的拼图关卡".to_string(), + cost_range: cost_range(), + supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, + selected_level_mode: PuzzleLevelGenerationMode::SingleLevel, + planned_level_count: 1, + requires_user_confirmation: true, + } + } + + fn template_protocol() -> PuzzleCreativeTemplateProtocol { + PuzzleCreativeTemplateProtocol { + template_id: "puzzle.default-creative".to_string(), + title: "创意拼图".to_string(), + summary: "把图文灵感做成拼图".to_string(), + preview_image_src: None, + supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, + min_level_count: 1, + max_level_count: 6, + default_level_count: 1, + cost_range: cost_range(), + required_draft_fields: vec![PuzzleDraftEditableFieldPath::WorkTitle], + image_policy: PuzzleTemplateImageGenerationPolicy { + allow_uploaded_image_directly: true, + allow_generated_images: true, + allow_per_level_reference_image: true, + default_candidate_count_per_level: 1, + }, + } + } + + #[test] + fn creative_agent_session_snapshot_uses_camel_case() { + let snapshot = CreativeAgentSessionSnapshot { + session_id: "creative-session-1".to_string(), + stage: CreativeAgentStage::WaitingTemplateConfirmation, + input_summary: CreativeInputSummary { + text: Some("把这张旅行照做成拼图".to_string()), + entry_context: CreativeAgentEntryContext::CreationHome, + images: vec![CreativeImageSummary { + asset_id: Some("asset-1".to_string()), + read_url: Some("https://example.test/image.png".to_string()), + thumbnail_url: None, + width: Some(1024), + height: Some(768), + summary: Some("一张旅行照片".to_string()), + }], + material_summary: Some("旅行纪念素材".to_string()), + unsupported_capabilities: vec![CreativeUnsupportedCapability { + play_type: CreativeUnsupportedPlayType::BigFish, + title: "大鱼吃小鱼".to_string(), + status: CreativeCapabilityStatus::Unsupported, + reason: "Phase 1 只开放拼图模板".to_string(), + }], + }, + messages: vec![CreativeAgentMessage { + id: "message-1".to_string(), + role: CreativeAgentMessageRole::Assistant, + kind: CreativeAgentMessageKind::Chat, + text: "我会先选择拼图模板。".to_string(), + created_at: "2026-05-05T00:00:00Z".to_string(), + }], + puzzle_template_catalog: vec![template_protocol()], + puzzle_template_selection: Some(template_selection()), + puzzle_image_generation_plan: None, + target_binding: Some(CreativeTargetSessionBinding { + play_type: CreativeTargetPlayType::Puzzle, + target_session_id: "puzzle-session-1".to_string(), + target_stage: CreativeTargetStage::PuzzleResult, + result_profile_id: None, + }), + updated_at: "2026-05-05T00:00:01Z".to_string(), + }; + + let payload = serde_json::to_value(&snapshot).expect("snapshot should serialize"); + assert_eq!(payload["sessionId"], json!("creative-session-1")); + assert_eq!(payload["stage"], json!("waiting_template_confirmation")); + assert_eq!( + payload["inputSummary"]["entryContext"], + json!("creation_home") + ); + assert_eq!( + payload["inputSummary"]["unsupportedCapabilities"][0]["playType"], + json!("big_fish") + ); + assert_eq!( + payload["puzzleTemplateCatalog"][0]["templateId"], + json!("puzzle.default-creative") + ); + assert_eq!( + payload["puzzleTemplateSelection"]["selectedLevelMode"], + json!("single_level") + ); + assert_eq!( + payload["targetBinding"]["targetStage"], + json!("puzzle-result") + ); + + let decoded: CreativeAgentSessionSnapshot = + serde_json::from_value(payload).expect("snapshot should deserialize"); + assert_eq!(decoded, snapshot); + } + + #[test] + fn creative_agent_sse_events_serialize_event_names() { + let event = CreativeAgentSseEnvelope { + event: CreativeAgentSseEventType::PuzzleTemplateSelection, + data: serde_json::to_value(CreativeAgentTemplateSelectionEvent { + session_id: "creative-session-1".to_string(), + selection: template_selection(), + }) + .expect("event data should serialize"), + }; + + let payload = serde_json::to_value(event).expect("event should serialize"); + assert_eq!(payload["event"], json!("puzzle_template_selection")); + assert_eq!( + payload["data"]["selection"]["costRange"]["pricingUnit"], + json!("point") + ); + + let thought_event = CreativeAgentSseEnvelope { + event: CreativeAgentSseEventType::ThoughtSummaryDelta, + data: serde_json::to_value(CreativeAgentThoughtSummaryDeltaEvent { + session_id: "creative-session-1".to_string(), + thought_id: "thought-1".to_string(), + text_delta: "正在理解素材".to_string(), + }) + .expect("event data should serialize"), + }; + let thought_payload = serde_json::to_value(thought_event).expect("event should serialize"); + assert_eq!(thought_payload["event"], json!("thought_summary_delta")); + assert_eq!(thought_payload["data"]["thoughtId"], json!("thought-1")); + + let catalog_event = CreativeAgentSseEnvelope { + event: CreativeAgentSseEventType::PuzzleTemplateCatalog, + data: serde_json::to_value(CreativeAgentTemplateCatalogEvent { + session_id: "creative-session-1".to_string(), + templates: vec![template_protocol()], + }) + .expect("event data should serialize"), + }; + let catalog_payload = + serde_json::to_value(catalog_event).expect("event should serialize"); + assert_eq!(catalog_payload["event"], json!("puzzle_template_catalog")); + assert_eq!( + catalog_payload["data"]["templates"][0]["templateId"], + json!("puzzle.default-creative") + ); + } + + #[test] + fn creative_agent_multimodal_parts_keep_image_url_camel_case() { + let request = StreamCreativeAgentMessageRequest { + client_message_id: "client-message-1".to_string(), + content: vec![ + CreativeAgentInputPart { + part_type: CreativeAgentInputPartType::InputText, + text: Some("做一张拼图".to_string()), + image_url: None, + asset_id: None, + thumbnail_url: None, + }, + CreativeAgentInputPart { + part_type: CreativeAgentInputPartType::InputImage, + text: None, + image_url: Some("https://example.test/image.png".to_string()), + asset_id: Some("asset-1".to_string()), + thumbnail_url: None, + }, + ], + }; + + let payload = serde_json::to_value(request).expect("request should serialize"); + assert_eq!(payload["clientMessageId"], json!("client-message-1")); + assert_eq!(payload["content"][0]["type"], json!("input_text")); + assert_eq!(payload["content"][1]["type"], json!("input_image")); + assert_eq!( + payload["content"][1]["imageUrl"], + json!("https://example.test/image.png") + ); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index e9d08daad..26f69f2c2 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -6,11 +6,13 @@ pub mod auth; pub mod big_fish; pub mod big_fish_works; pub mod creation_agent_document_input; +pub mod creative_agent; pub mod llm; pub mod match3d_agent; pub mod match3d_runtime; pub mod match3d_works; pub mod puzzle_agent; +pub mod puzzle_creative_template; pub mod puzzle_gallery; pub mod puzzle_runtime; pub mod puzzle_works; @@ -20,3 +22,4 @@ pub mod square_hole_agent; pub mod square_hole_runtime; pub mod square_hole_works; pub mod story; +pub mod visual_novel; diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 89dfab68b..bf45fbfc8 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -15,6 +15,8 @@ pub struct CreatePuzzleAgentSessionRequest { pub reference_image_src: Option, #[serde(default)] pub image_model: Option, + #[serde(default)] + pub ai_redraw: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -37,6 +39,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub image_model: Option, #[serde(default)] + pub ai_redraw: Option, + #[serde(default)] pub candidate_count: Option, #[serde(default)] pub candidate_id: Option, @@ -145,6 +149,8 @@ pub struct PuzzleDraftLevelResponse { pub level_id: String, pub level_name: String, pub picture_description: String, + #[serde(default)] + pub picture_reference: Option, pub candidates: Vec, #[serde(default)] pub selected_candidate_id: Option, diff --git a/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs b/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs new file mode 100644 index 000000000..eebccec1e --- /dev/null +++ b/server-rs/crates/shared-contracts/src/puzzle_creative_template.rs @@ -0,0 +1,230 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum PuzzleTemplatePricingUnit { + Point, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PuzzleSupportedLevelMode { + Single, + Multi, + SingleOrMulti, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PuzzleLevelGenerationMode { + SingleLevel, + MultiLevel, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleTemplateCostRange { + pub min_points: u32, + pub max_points: u32, + pub pricing_unit: PuzzleTemplatePricingUnit, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum PuzzleDraftEditableFieldPath { + #[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, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleTemplateImageGenerationPolicy { + 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, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleCreativeTemplateProtocol { + pub template_id: String, + pub title: String, + pub summary: String, + #[serde(default)] + pub preview_image_src: Option, + pub supported_level_mode: PuzzleSupportedLevelMode, + pub min_level_count: u32, + pub max_level_count: u32, + pub default_level_count: u32, + pub cost_range: PuzzleTemplateCostRange, + pub required_draft_fields: Vec, + pub image_policy: PuzzleTemplateImageGenerationPolicy, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleCreativeTemplateSelection { + pub template_id: String, + pub title: String, + pub reason: String, + pub cost_range: PuzzleTemplateCostRange, + pub supported_level_mode: PuzzleSupportedLevelMode, + pub selected_level_mode: PuzzleLevelGenerationMode, + pub planned_level_count: u32, + pub requires_user_confirmation: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativePuzzleLevelDraftInput { + pub level_name: String, + pub picture_description: String, + /// 任务 A 冻结:Phase 1 采用正式字段方案,后续拼图草稿落地需补正式 pictureReference 字段。 + #[serde(default)] + pub picture_reference: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreativePuzzleDraftToolInput { + pub template_id: String, + pub template_cost_range: PuzzleTemplateCostRange, + pub work_title: String, + pub work_description: String, + pub work_tags: Vec, + pub levels: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[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, + pub candidate_count: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleImageGenerationPlan { + pub mode: PuzzleLevelGenerationMode, + pub template_id: String, + pub estimated_cost_range: PuzzleTemplateCostRange, + pub levels: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PuzzleDraftFieldPatchOperation { + Set, + Append, + Replace, + Remove, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleDraftFieldPatch { + pub field_path: PuzzleDraftEditableFieldPath, + pub operation: PuzzleDraftFieldPatchOperation, + #[serde(default)] + pub level_id: Option, + pub value: serde_json::Value, + pub rationale: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn cost_range() -> PuzzleTemplateCostRange { + PuzzleTemplateCostRange { + min_points: 2, + max_points: 12, + pricing_unit: PuzzleTemplatePricingUnit::Point, + reason: "按关卡数和每关图片生成次数估算".to_string(), + } + } + + #[test] + fn creative_agent_puzzle_template_protocol_uses_camel_case() { + let payload = serde_json::to_value(PuzzleCreativeTemplateProtocol { + template_id: "puzzle.default-creative".to_string(), + title: "创意拼图".to_string(), + summary: "把图文灵感做成拼图".to_string(), + preview_image_src: None, + supported_level_mode: PuzzleSupportedLevelMode::SingleOrMulti, + min_level_count: 1, + max_level_count: 6, + default_level_count: 1, + cost_range: cost_range(), + required_draft_fields: vec![ + PuzzleDraftEditableFieldPath::WorkTitle, + PuzzleDraftEditableFieldPath::LevelPictureReference, + ], + image_policy: PuzzleTemplateImageGenerationPolicy { + allow_uploaded_image_directly: true, + allow_generated_images: true, + allow_per_level_reference_image: true, + default_candidate_count_per_level: 1, + }, + }) + .expect("template should serialize"); + + assert_eq!(payload["templateId"], json!("puzzle.default-creative")); + assert_eq!(payload["previewImageSrc"], json!(null)); + assert_eq!(payload["supportedLevelMode"], json!("single_or_multi")); + assert_eq!(payload["costRange"]["pricingUnit"], json!("point")); + assert_eq!( + payload["requiredDraftFields"], + json!(["workTitle", "levels[].pictureReference"]) + ); + assert_eq!( + payload["imagePolicy"]["allowPerLevelReferenceImage"], + json!(true) + ); + } + + #[test] + fn creative_agent_puzzle_image_plan_roundtrips() { + let plan = PuzzleImageGenerationPlan { + mode: PuzzleLevelGenerationMode::MultiLevel, + template_id: "puzzle.default-creative".to_string(), + estimated_cost_range: cost_range(), + levels: vec![PuzzleImageGenerationPlanLevel { + level_id: "level-1".to_string(), + level_name: "第一关".to_string(), + picture_description: "温暖的家庭照片".to_string(), + image_prompt: "pixel puzzle, warm family photo".to_string(), + picture_reference: Some("asset-ref-1".to_string()), + candidate_count: 1, + }], + }; + + let payload = serde_json::to_value(&plan).expect("plan should serialize"); + assert_eq!(payload["mode"], json!("multi_level")); + assert_eq!( + payload["levels"][0]["pictureReference"], + json!("asset-ref-1") + ); + + let decoded: PuzzleImageGenerationPlan = + serde_json::from_value(payload).expect("plan should deserialize"); + assert_eq!(decoded, plan); + } +} diff --git a/server-rs/crates/shared-contracts/src/visual_novel.rs b/server-rs/crates/shared-contracts/src/visual_novel.rs new file mode 100644 index 000000000..43065e331 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/visual_novel.rs @@ -0,0 +1,687 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelSourceMode { + Idea, + Document, + Blank, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelCharacterRole { + Protagonist, + Main, + Supporting, + Antagonist, + Background, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelAssetSource { + PlatformAsset, + Generated, + External, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelSceneAvailability { + Opening, + Always, + PhaseLocked, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelAttributePanelMode { + Off, + PlatformWhitelist, + TemplateConfig, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelValidationSeverity { + Error, + Warning, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelAgentStatus { + Collecting, + Drafting, + Ready, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelAgentMessageRole { + User, + Assistant, + System, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelAgentMessageKind { + Chat, + Summary, + ActionResult, + Warning, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelAgentActionKind { + GenerateDraft, + PatchWorld, + PatchCharacter, + PatchScene, + PatchStoryPhase, + GenerateSceneImage, + GenerateCharacterImage, + CompileWorkProfile, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelAgentPhase { + Perception, + Reasoning, + Drafting, + Reflection, + Finalizing, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelRunMode { + Test, + Play, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelRunStatus { + Active, + Completed, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum VisualNovelRuntimeActionKind { + Choice, + FreeText, + Continue, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelTransitionKind { + Fade, + Cut, + Flash, + None, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum VisualNovelHistorySource { + Player, + Assistant, + System, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum VisualNovelFlagValue { + String(String), + Number(f64), + Bool(bool), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelDraftPatch { + pub path: String, + pub op: String, + #[serde(default)] + pub value: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelValidationIssue { + pub issue_id: String, + pub code: String, + pub severity: VisualNovelValidationSeverity, + pub path: String, + pub message: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelChoiceDraft { + pub choice_id: String, + pub text: String, + #[serde(default)] + pub action_hint: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelCharacterImageAsset { + pub asset_id: String, + pub image_src: String, + #[serde(default)] + pub expression: Option, + pub source: VisualNovelAssetSource, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[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, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelCharacterDraft { + pub character_id: String, + pub name: String, + #[serde(default)] + pub gender: Option, + pub role: VisualNovelCharacterRole, + pub appearance: String, + pub personality: String, + pub tone: String, + pub background: String, + pub relationship_to_player: String, + pub image_assets: Vec, + #[serde(default)] + pub default_expression: Option, + pub is_player_visible: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelSceneDraft { + pub scene_id: String, + pub name: String, + pub description: String, + #[serde(default)] + pub background_image_src: Option, + #[serde(default)] + pub music_src: Option, + #[serde(default)] + pub ambient_sound_src: Option, + pub availability: VisualNovelSceneAvailability, + pub phase_ids: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[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, + pub character_ids: Vec, + pub suggested_choices: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelOpeningDraft { + #[serde(default)] + pub scene_id: Option, + pub narration: String, + #[serde(default)] + pub speaker_character_id: Option, + #[serde(default)] + pub first_dialogue: Option, + pub initial_choices: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[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, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelResultDraft { + #[serde(default)] + pub profile_id: Option, + pub work_title: String, + pub work_description: String, + pub work_tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + pub source_mode: VisualNovelSourceMode, + pub source_asset_ids: Vec, + pub world: VisualNovelWorldDraft, + pub characters: Vec, + pub scenes: Vec, + pub story_phases: Vec, + pub opening: VisualNovelOpeningDraft, + pub runtime_config: VisualNovelRuntimeConfigDraft, + pub publish_ready: bool, + pub validation_issues: Vec, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelAgentMessage { + pub id: String, + pub role: VisualNovelAgentMessageRole, + pub kind: VisualNovelAgentMessageKind, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelAgentPendingAction { + pub action_id: String, + pub kind: VisualNovelAgentActionKind, + pub label: String, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub payload: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: VisualNovelSourceMode, + pub status: VisualNovelAgentStatus, + pub messages: Vec, + #[serde(default)] + pub draft: Option, + #[serde(default)] + pub pending_action: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateVisualNovelSessionRequest { + pub source_mode: VisualNovelSourceMode, + #[serde(default)] + pub seed_text: Option, + #[serde(default)] + pub source_asset_ids: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelSessionResponse { + pub session: VisualNovelAgentSessionSnapshot, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelWorkSummary { + pub runtime_kind: String, + pub profile_id: String, + pub owner_user_id: String, + pub title: String, + pub description: String, + #[serde(default)] + pub cover_image_src: Option, + pub tags: Vec, + pub publish_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub updated_at: String, + #[serde(default)] + pub published_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelWorkDetail { + pub work_id: String, + pub summary: VisualNovelWorkSummary, + #[serde(default)] + pub source_session_id: Option, + pub author_display_name: String, + pub source_asset_ids: Vec, + pub draft: VisualNovelResultDraft, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelWorksResponse { + pub works: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelWorkResponse { + pub work: VisualNovelWorkDetail, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateVisualNovelWorkRequest { + pub draft: VisualNovelResultDraft, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelCompileResponse { + pub session: VisualNovelAgentSessionSnapshot, + pub work: VisualNovelWorkDetail, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SendVisualNovelMessageRequest { + pub client_message_id: String, + pub text: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ExecuteVisualNovelAgentActionRequest { + #[serde(default)] + pub action_id: Option, + pub kind: VisualNovelAgentActionKind, + #[serde(default)] + pub target_id: Option, + #[serde(default)] + pub payload: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum VisualNovelAgentStreamEvent { + Start { + session_id: String, + }, + Phase { + phase: VisualNovelAgentPhase, + }, + TextDelta { + text: String, + }, + DraftPatch { + patch: VisualNovelDraftPatch, + }, + ActionRequired { + action: VisualNovelAgentPendingAction, + }, + Complete { + session: VisualNovelAgentSessionSnapshot, + }, + Error { + message: String, + retryable: bool, + }, + Done {}, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum VisualNovelRuntimeStep { + SceneChange { + scene_id: String, + #[serde(default)] + background_image_src: Option, + #[serde(default)] + music_src: Option, + }, + Narration { + text: String, + }, + Dialogue { + character_id: String, + character_name: String, + #[serde(default)] + expression: Option, + text: String, + }, + Transition { + transition_kind: VisualNovelTransitionKind, + #[serde(default)] + text: Option, + }, + Choice { + choices: Vec, + }, + Flag { + key: String, + value: VisualNovelFlagValue, + }, + Metric { + key: String, + delta: f64, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelHistoryEntry { + pub entry_id: String, + pub run_id: String, + pub turn_index: u32, + pub source: VisualNovelHistorySource, + #[serde(default)] + pub action_text: Option, + pub steps: Vec, + #[serde(default)] + pub snapshot_before_hash: Option, + #[serde(default)] + pub snapshot_after_hash: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[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, + #[serde(default)] + pub current_scene_id: Option, + #[serde(default)] + pub current_phase_id: Option, + pub visible_character_ids: Vec, + pub flags: BTreeMap, + pub metrics: BTreeMap, + pub history: Vec, + pub available_choices: Vec, + pub text_mode_enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRuntimeActionRequest { + pub action_kind: VisualNovelRuntimeActionKind, + #[serde(default)] + pub choice_id: Option, + #[serde(default)] + pub text: Option, + pub client_event_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelStartRunRequest { + pub profile_id: String, + pub mode: VisualNovelRunMode, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRunResponse { + pub run: VisualNovelRunSnapshot, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelHistoryResponse { + pub history: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRegenerateRequest { + pub history_entry_id: String, + pub client_event_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelSaveArchiveState { + pub runtime_kind: String, + pub profile_id: String, + pub run_id: String, + #[serde(default)] + pub current_scene_id: Option, + #[serde(default)] + pub current_phase_id: Option, + pub history_cursor: u32, + #[serde(default)] + pub snapshot_hash: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum VisualNovelRuntimeStreamEvent { + Start { run_id: String }, + RawText { text: String }, + Step { step: VisualNovelRuntimeStep }, + Snapshot { run: VisualNovelRunSnapshot }, + Complete { run: VisualNovelRunSnapshot }, + Error { message: String, retryable: bool }, + Done {}, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn result_draft_and_runtime_step_use_contract_case() { + let draft = VisualNovelResultDraft { + profile_id: Some("vn-profile-1".to_string()), + work_title: "雨夜书店".to_string(), + work_description: "一段视觉小说测试底稿".to_string(), + work_tags: vec!["悬疑".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::new(), + scenes: Vec::new(), + story_phases: Vec::new(), + opening: VisualNovelOpeningDraft { + scene_id: None, + narration: "雨声落下。".to_string(), + speaker_character_id: None, + first_dialogue: None, + initial_choices: Vec::new(), + }, + 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(), + }; + + let payload = serde_json::to_value(draft).expect("draft should serialize"); + assert_eq!(payload["profileId"], json!("vn-profile-1")); + assert_eq!(payload["sourceMode"], json!("idea")); + assert_eq!(payload["runtimeConfig"]["attributePanelMode"], json!("off")); + + let step = VisualNovelRuntimeStep::SceneChange { + scene_id: "scene-1".to_string(), + background_image_src: None, + music_src: None, + }; + let step_payload = serde_json::to_value(step).expect("step should serialize"); + assert_eq!(step_payload["type"], json!("scene_change")); + assert_eq!(step_payload["sceneId"], json!("scene-1")); + } + + #[test] + fn runtime_stream_event_uses_tagged_envelope() { + let event = VisualNovelRuntimeStreamEvent::Step { + step: VisualNovelRuntimeStep::Narration { + text: "门铃响了。".to_string(), + }, + }; + + let payload = serde_json::to_value(event).expect("event should serialize"); + assert_eq!(payload["type"], json!("step")); + assert_eq!(payload["step"]["type"], json!("narration")); + assert_eq!(payload["step"]["text"], json!("门铃响了。")); + } +} diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index 26b0b849e..2727f0d85 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -19,6 +19,7 @@ module-runtime-story = { workspace = true } module-runtime-item = { workspace = true } module-square-hole = { workspace = true } module-story = { workspace = true } +module-visual-novel = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } shared-contracts = { workspace = true } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1829a452b..538ce1ca2 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -60,6 +60,13 @@ pub use mapper::{ SquareHoleRunRecord, SquareHoleRunRestartRecordInput, SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, SquareHoleShapeOptionRecord, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, SquareHoleWorkUpdateRecordInput, + VisualNovelAgentMessageFinalizeRecordInput, VisualNovelAgentMessageRecord, + VisualNovelAgentMessageSubmitRecordInput, VisualNovelAgentSessionCreateRecordInput, + VisualNovelAgentSessionRecord, VisualNovelHistoryEntryRecord, + VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, VisualNovelRunSnapshotRecordInput, + VisualNovelRunStartRecordInput, VisualNovelRuntimeEventRecord, + VisualNovelRuntimeEventRecordInput, VisualNovelWorkCompileRecordInput, + VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, }; pub mod ai; @@ -76,6 +83,7 @@ pub mod runtime; pub mod square_hole; pub mod story; pub mod story_runtime; +pub mod visual_novel; use std::{ error::Error, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 2359bf4ca..d42788332 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1672,6 +1672,118 @@ pub(crate) fn map_square_hole_drop_shape_procedure_result( }) } +pub(crate) fn map_visual_novel_agent_session_procedure_result( + result: VisualNovelAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session_json = result + .session_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel agent session 快照"))?; + let session = + serde_json::from_str::(&session_json).map_err( + |error| { + SpacetimeClientError::Runtime(format!( + "visual novel session_json 非法: {error}" + )) + }, + )?; + + Ok(map_visual_novel_agent_session_snapshot(session)) +} + +pub(crate) fn map_visual_novel_work_procedure_result( + result: VisualNovelWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let work_json = result + .work_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel work 快照"))?; + let work = serde_json::from_str::(&work_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("visual novel work_json 非法: {error}")) + })?; + + Ok(map_visual_novel_work_snapshot(work)) +} + +pub(crate) fn map_visual_novel_works_procedure_result( + result: VisualNovelWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let items_json = result + .items_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel works 快照"))?; + let items = serde_json::from_str::>(&items_json).map_err( + |error| { + SpacetimeClientError::Runtime(format!("visual novel works items_json 非法: {error}")) + }, + )?; + + Ok(items.into_iter().map(map_visual_novel_work_snapshot).collect()) +} + +pub(crate) fn map_visual_novel_run_procedure_result( + result: VisualNovelRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run_json = result + .run_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel run 快照"))?; + let run = serde_json::from_str::(&run_json).map_err(|error| { + SpacetimeClientError::Runtime(format!("visual novel run_json 非法: {error}")) + })?; + + Ok(map_visual_novel_run_snapshot(run)) +} + +pub(crate) fn map_visual_novel_history_procedure_result( + result: VisualNovelHistoryProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let items_json = result + .items_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel history 快照"))?; + let items = serde_json::from_str::>(&items_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!("visual novel history items_json 非法: {error}")) + })?; + + Ok(items.into_iter().map(map_visual_novel_history_entry).collect()) +} + +pub(crate) fn map_visual_novel_runtime_event_procedure_result( + result: VisualNovelRuntimeEventProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let event_json = result + .event_json + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel runtime event 快照"))?; + let event = serde_json::from_str::(&event_json).map_err( + |error| { + SpacetimeClientError::Runtime(format!("visual novel event_json 非法: {error}")) + }, + )?; + + Ok(map_visual_novel_runtime_event(event)) +} + pub(crate) fn map_story_session_procedure_result( result: StorySessionProcedureResult, ) -> Result { @@ -2667,6 +2779,7 @@ pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> Puzzle level_id: snapshot.level_id, level_name: snapshot.level_name, picture_description: snapshot.picture_description, + picture_reference: snapshot.picture_reference, candidates: snapshot .candidates .into_iter() @@ -3175,6 +3288,127 @@ fn build_square_hole_anchor_item( } } +fn map_visual_novel_agent_session_snapshot( + snapshot: VisualNovelAgentSessionJsonRecord, +) -> VisualNovelAgentSessionRecord { + VisualNovelAgentSessionRecord { + session_id: snapshot.session_id, + owner_user_id: snapshot.owner_user_id, + source_mode: snapshot.source_mode, + status: snapshot.status, + seed_text: snapshot.seed_text, + source_asset_ids: snapshot.source_asset_ids, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + messages: snapshot + .messages + .into_iter() + .map(map_visual_novel_agent_message) + .collect(), + draft: snapshot.draft, + pending_action: snapshot.pending_action, + last_assistant_reply: snapshot.last_assistant_reply, + published_profile_id: snapshot.published_profile_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_visual_novel_agent_message( + snapshot: VisualNovelAgentMessageJsonRecord, +) -> VisualNovelAgentMessageRecord { + VisualNovelAgentMessageRecord { + message_id: snapshot.message_id, + session_id: snapshot.session_id, + role: snapshot.role, + kind: snapshot.kind, + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_visual_novel_work_snapshot( + snapshot: VisualNovelWorkJsonRecord, +) -> VisualNovelWorkProfileRecord { + VisualNovelWorkProfileRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + tags: snapshot.tags, + cover_image_src: snapshot.cover_image_src, + source_asset_ids: snapshot.source_asset_ids, + draft: snapshot.draft, + publication_status: snapshot.publication_status, + publish_ready: snapshot.publish_ready, + play_count: snapshot.play_count, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + } +} + +fn map_visual_novel_run_snapshot(snapshot: VisualNovelRunJsonRecord) -> VisualNovelRunRecord { + VisualNovelRunRecord { + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + mode: snapshot.mode, + status: snapshot.status, + current_scene_id: snapshot.current_scene_id, + current_phase_id: snapshot.current_phase_id, + visible_character_ids: snapshot.visible_character_ids, + flags: snapshot.flags, + metrics: snapshot.metrics, + history: snapshot + .history + .into_iter() + .map(map_visual_novel_history_entry) + .collect(), + available_choices: snapshot.available_choices, + text_mode_enabled: snapshot.text_mode_enabled, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_visual_novel_history_entry( + snapshot: VisualNovelHistoryEntryJsonRecord, +) -> VisualNovelHistoryEntryRecord { + VisualNovelHistoryEntryRecord { + entry_id: snapshot.entry_id, + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + turn_index: snapshot.turn_index, + source: snapshot.source, + action_text: snapshot.action_text, + steps: snapshot.steps, + snapshot_before_hash: snapshot.snapshot_before_hash, + snapshot_after_hash: snapshot.snapshot_after_hash, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_visual_novel_runtime_event( + snapshot: VisualNovelRuntimeEventJsonRecord, +) -> VisualNovelRuntimeEventRecord { + VisualNovelRuntimeEventRecord { + event_id: snapshot.event_id, + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + event_kind: snapshot.event_kind, + client_event_id: snapshot.client_event_id, + history_entry_id: snapshot.history_entry_id, + payload: snapshot.payload, + occurred_at: format_timestamp_micros(snapshot.occurred_at_micros), + } +} + fn normalize_match3d_stage(value: &str) -> &str { match value { "Collecting" | "collecting" | "collecting_config" => "collecting_config", @@ -6105,6 +6339,323 @@ pub struct SquareHoleRunTimeUpRecordInput { pub finished_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub seed_text: String, + pub source_asset_ids_json: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub draft_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub draft_json: Option, + pub pending_action_json: Option, + pub status: String, + pub progress_percent: u32, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelWorkCompileRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub work_id: Option, + pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelWorkUpdateRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub tags_json: String, + pub cover_image_src: Option, + pub source_asset_ids_json: String, + pub draft_json: String, + pub publish_ready: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub snapshot_json: Option, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRunSnapshotRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids_json: String, + pub flags_json: String, + pub metrics_json: String, + pub available_choices_json: String, + pub text_mode_enabled: bool, + pub snapshot_json: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelHistoryEntryRecordInput { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps_json: String, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRuntimeEventRecordInput { + pub event_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload_json: String, + pub occurred_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelAgentMessageRecord { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelAgentSessionRecord { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub status: String, + pub seed_text: String, + pub source_asset_ids: Vec, + pub current_turn: u32, + pub progress_percent: u32, + pub messages: Vec, + pub draft: Option, + pub pending_action: Option, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub tags: Vec, + pub cover_image_src: Option, + pub source_asset_ids: Vec, + pub draft: serde_json::Value, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelHistoryEntryRecord { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps: serde_json::Value, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelRunRecord { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids: Vec, + pub flags: serde_json::Value, + pub metrics: serde_json::Value, + pub history: Vec, + pub available_choices: serde_json::Value, + pub text_mode_enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelRuntimeEventRecord { + pub event_id: String, + pub run_id: Option, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload: serde_json::Value, + pub occurred_at: String, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelAgentMessageJsonRecord { + message_id: String, + session_id: String, + role: String, + kind: String, + text: String, + created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelAgentSessionJsonRecord { + session_id: String, + owner_user_id: String, + source_mode: String, + status: String, + seed_text: String, + source_asset_ids: Vec, + current_turn: u32, + progress_percent: u32, + messages: Vec, + draft: Option, + pending_action: Option, + last_assistant_reply: Option, + published_profile_id: Option, + created_at_micros: i64, + updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelWorkJsonRecord { + work_id: String, + profile_id: String, + owner_user_id: String, + source_session_id: Option, + author_display_name: String, + work_title: String, + work_description: String, + tags: Vec, + cover_image_src: Option, + source_asset_ids: Vec, + draft: serde_json::Value, + publication_status: String, + publish_ready: bool, + play_count: u32, + created_at_micros: i64, + updated_at_micros: i64, + published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelHistoryEntryJsonRecord { + entry_id: String, + run_id: String, + owner_user_id: String, + profile_id: String, + turn_index: u32, + source: String, + action_text: Option, + steps: serde_json::Value, + snapshot_before_hash: Option, + snapshot_after_hash: Option, + created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelRunJsonRecord { + run_id: String, + owner_user_id: String, + profile_id: String, + mode: String, + status: String, + current_scene_id: Option, + current_phase_id: Option, + visible_character_ids: Vec, + flags: serde_json::Value, + metrics: serde_json::Value, + history: Vec, + available_choices: serde_json::Value, + text_mode_enabled: bool, + created_at_micros: i64, + updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct VisualNovelRuntimeEventJsonRecord { + event_id: String, + run_id: Option, + owner_user_id: String, + profile_id: Option, + event_kind: String, + client_event_id: Option, + history_entry_id: Option, + payload: serde_json::Value, + occurred_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct SquareHoleAnchorItemRecord { pub key: String, @@ -6552,6 +7103,7 @@ pub struct PuzzleDraftLevelRecord { pub level_id: String, pub level_name: String, pub picture_description: String, + pub picture_reference: Option, pub candidates: Vec, pub selected_candidate_id: Option, pub cover_image_src: Option, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs new file mode 100644 index 000000000..ad1099d0f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/append_visual_novel_runtime_history_entry_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_history_procedure_result_type::VisualNovelHistoryProcedureResult; +use super::visual_novel_runtime_history_append_input_type::VisualNovelRuntimeHistoryAppendInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AppendVisualNovelRuntimeHistoryEntryArgs { + pub input: VisualNovelRuntimeHistoryAppendInput, +} + +impl __sdk::InModule for AppendVisualNovelRuntimeHistoryEntryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `append_visual_novel_runtime_history_entry`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait append_visual_novel_runtime_history_entry { + fn append_visual_novel_runtime_history_entry( + &self, + input: VisualNovelRuntimeHistoryAppendInput, + ) { + self.append_visual_novel_runtime_history_entry_then(input, |_, _| {}); + } + + fn append_visual_novel_runtime_history_entry_then( + &self, + input: VisualNovelRuntimeHistoryAppendInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl append_visual_novel_runtime_history_entry for super::RemoteProcedures { + fn append_visual_novel_runtime_history_entry_then( + &self, + input: VisualNovelRuntimeHistoryAppendInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelHistoryProcedureResult>( + "append_visual_novel_runtime_history_entry", + AppendVisualNovelRuntimeHistoryEntryArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/compile_visual_novel_work_profile_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/compile_visual_novel_work_profile_procedure.rs new file mode 100644 index 000000000..e6d5f25bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/compile_visual_novel_work_profile_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; +use super::visual_novel_work_compile_input_type::VisualNovelWorkCompileInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompileVisualNovelWorkProfileArgs { + pub input: VisualNovelWorkCompileInput, +} + +impl __sdk::InModule for CompileVisualNovelWorkProfileArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `compile_visual_novel_work_profile`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait compile_visual_novel_work_profile { + fn compile_visual_novel_work_profile(&self, input: VisualNovelWorkCompileInput) { + self.compile_visual_novel_work_profile_then(input, |_, _| {}); + } + + fn compile_visual_novel_work_profile_then( + &self, + input: VisualNovelWorkCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl compile_visual_novel_work_profile for super::RemoteProcedures { + fn compile_visual_novel_work_profile_then( + &self, + input: VisualNovelWorkCompileInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>( + "compile_visual_novel_work_profile", + CompileVisualNovelWorkProfileArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_visual_novel_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_visual_novel_agent_session_procedure.rs new file mode 100644 index 000000000..c42cc766d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_visual_novel_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_session_create_input_type::VisualNovelAgentSessionCreateInput; +use super::visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateVisualNovelAgentSessionArgs { + pub input: VisualNovelAgentSessionCreateInput, +} + +impl __sdk::InModule for CreateVisualNovelAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_visual_novel_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_visual_novel_agent_session { + fn create_visual_novel_agent_session(&self, input: VisualNovelAgentSessionCreateInput) { + self.create_visual_novel_agent_session_then(input, |_, _| {}); + } + + fn create_visual_novel_agent_session_then( + &self, + input: VisualNovelAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_visual_novel_agent_session for super::RemoteProcedures { + fn create_visual_novel_agent_session_then( + &self, + input: VisualNovelAgentSessionCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>( + "create_visual_novel_agent_session", + CreateVisualNovelAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_visual_novel_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_visual_novel_work_procedure.rs new file mode 100644 index 000000000..eac6ceacd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_visual_novel_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_work_delete_input_type::VisualNovelWorkDeleteInput; +use super::visual_novel_works_procedure_result_type::VisualNovelWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct DeleteVisualNovelWorkArgs { + pub input: VisualNovelWorkDeleteInput, +} + +impl __sdk::InModule for DeleteVisualNovelWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_visual_novel_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_visual_novel_work { + fn delete_visual_novel_work(&self, input: VisualNovelWorkDeleteInput) { + self.delete_visual_novel_work_then(input, |_, _| {}); + } + + fn delete_visual_novel_work_then( + &self, + input: VisualNovelWorkDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl delete_visual_novel_work for super::RemoteProcedures { + fn delete_visual_novel_work_then( + &self, + input: VisualNovelWorkDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelWorksProcedureResult>( + "delete_visual_novel_work", + DeleteVisualNovelWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/finalize_visual_novel_agent_message_turn_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/finalize_visual_novel_agent_message_turn_procedure.rs new file mode 100644 index 000000000..08b475605 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/finalize_visual_novel_agent_message_turn_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_message_finalize_input_type::VisualNovelAgentMessageFinalizeInput; +use super::visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FinalizeVisualNovelAgentMessageTurnArgs { + pub input: VisualNovelAgentMessageFinalizeInput, +} + +impl __sdk::InModule for FinalizeVisualNovelAgentMessageTurnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `finalize_visual_novel_agent_message_turn`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait finalize_visual_novel_agent_message_turn { + fn finalize_visual_novel_agent_message_turn( + &self, + input: VisualNovelAgentMessageFinalizeInput, + ) { + self.finalize_visual_novel_agent_message_turn_then(input, |_, _| {}); + } + + fn finalize_visual_novel_agent_message_turn_then( + &self, + input: VisualNovelAgentMessageFinalizeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl finalize_visual_novel_agent_message_turn for super::RemoteProcedures { + fn finalize_visual_novel_agent_message_turn_then( + &self, + input: VisualNovelAgentMessageFinalizeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>( + "finalize_visual_novel_agent_message_turn", + FinalizeVisualNovelAgentMessageTurnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_agent_session_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_agent_session_procedure.rs new file mode 100644 index 000000000..a4b4b6d1a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_agent_session_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_session_get_input_type::VisualNovelAgentSessionGetInput; +use super::visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetVisualNovelAgentSessionArgs { + pub input: VisualNovelAgentSessionGetInput, +} + +impl __sdk::InModule for GetVisualNovelAgentSessionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_visual_novel_agent_session`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_visual_novel_agent_session { + fn get_visual_novel_agent_session(&self, input: VisualNovelAgentSessionGetInput) { + self.get_visual_novel_agent_session_then(input, |_, _| {}); + } + + fn get_visual_novel_agent_session_then( + &self, + input: VisualNovelAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_visual_novel_agent_session for super::RemoteProcedures { + fn get_visual_novel_agent_session_then( + &self, + input: VisualNovelAgentSessionGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>( + "get_visual_novel_agent_session", + GetVisualNovelAgentSessionArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_run_procedure.rs new file mode 100644 index 000000000..86bdff381 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_run_get_input_type::VisualNovelRunGetInput; +use super::visual_novel_run_procedure_result_type::VisualNovelRunProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetVisualNovelRunArgs { + pub input: VisualNovelRunGetInput, +} + +impl __sdk::InModule for GetVisualNovelRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_visual_novel_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_visual_novel_run { + fn get_visual_novel_run(&self, input: VisualNovelRunGetInput) { + self.get_visual_novel_run_then(input, |_, _| {}); + } + + fn get_visual_novel_run_then( + &self, + input: VisualNovelRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_visual_novel_run for super::RemoteProcedures { + fn get_visual_novel_run_then( + &self, + input: VisualNovelRunGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelRunProcedureResult>( + "get_visual_novel_run", + GetVisualNovelRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_work_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_work_detail_procedure.rs new file mode 100644 index 000000000..64864f299 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_visual_novel_work_detail_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_work_get_input_type::VisualNovelWorkGetInput; +use super::visual_novel_work_procedure_result_type::VisualNovelWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetVisualNovelWorkDetailArgs { + pub input: VisualNovelWorkGetInput, +} + +impl __sdk::InModule for GetVisualNovelWorkDetailArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_visual_novel_work_detail`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_visual_novel_work_detail { + fn get_visual_novel_work_detail(&self, input: VisualNovelWorkGetInput) { + self.get_visual_novel_work_detail_then(input, |_, _| {}); + } + + fn get_visual_novel_work_detail_then( + &self, + input: VisualNovelWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_visual_novel_work_detail for super::RemoteProcedures { + fn get_visual_novel_work_detail_then( + &self, + input: VisualNovelWorkGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelWorkProcedureResult>( + "get_visual_novel_work_detail", + GetVisualNovelWorkDetailArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_runtime_history_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_runtime_history_procedure.rs new file mode 100644 index 000000000..fdc06a222 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_runtime_history_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_history_procedure_result_type::VisualNovelHistoryProcedureResult; +use super::visual_novel_runtime_history_list_input_type::VisualNovelRuntimeHistoryListInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListVisualNovelRuntimeHistoryArgs { + pub input: VisualNovelRuntimeHistoryListInput, +} + +impl __sdk::InModule for ListVisualNovelRuntimeHistoryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_visual_novel_runtime_history`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_visual_novel_runtime_history { + fn list_visual_novel_runtime_history(&self, input: VisualNovelRuntimeHistoryListInput) { + self.list_visual_novel_runtime_history_then(input, |_, _| {}); + } + + fn list_visual_novel_runtime_history_then( + &self, + input: VisualNovelRuntimeHistoryListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_visual_novel_runtime_history for super::RemoteProcedures { + fn list_visual_novel_runtime_history_then( + &self, + input: VisualNovelRuntimeHistoryListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelHistoryProcedureResult>( + "list_visual_novel_runtime_history", + ListVisualNovelRuntimeHistoryArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_works_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_works_procedure.rs new file mode 100644 index 000000000..1920ee797 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_visual_novel_works_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_works_list_input_type::VisualNovelWorksListInput; +use super::visual_novel_works_procedure_result_type::VisualNovelWorksProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListVisualNovelWorksArgs { + pub input: VisualNovelWorksListInput, +} + +impl __sdk::InModule for ListVisualNovelWorksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_visual_novel_works`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_visual_novel_works { + fn list_visual_novel_works(&self, input: VisualNovelWorksListInput) { + self.list_visual_novel_works_then(input, |_, _| {}); + } + + fn list_visual_novel_works_then( + &self, + input: VisualNovelWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_visual_novel_works for super::RemoteProcedures { + fn list_visual_novel_works_then( + &self, + input: VisualNovelWorksListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelWorksProcedureResult>( + "list_visual_novel_works", + ListVisualNovelWorksArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b4ffcbd4d..cc5fe6691 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.1.0 (commit 10a4779b1338eff3708493d87496b51842a7c412). +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -57,6 +57,7 @@ pub mod analytics_granularity_type; pub mod analytics_metric_query_input_type; pub mod analytics_metric_query_procedure_result_type; pub mod append_ai_text_chunk_and_return_procedure; +pub mod append_visual_novel_runtime_history_entry_procedure; pub mod apply_chapter_progression_ledger_entry_and_return_procedure; pub mod apply_chapter_progression_ledger_entry_reducer; pub mod apply_inventory_mutation_reducer; @@ -167,6 +168,7 @@ pub mod compile_custom_world_published_profile_procedure; pub mod compile_match_3_d_draft_procedure; pub mod compile_puzzle_agent_draft_procedure; pub mod compile_square_hole_draft_procedure; +pub mod compile_visual_novel_work_profile_procedure; pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; @@ -185,6 +187,7 @@ pub mod create_match_3_d_agent_session_procedure; pub mod create_profile_recharge_order_and_return_procedure; pub mod create_puzzle_agent_session_procedure; pub mod create_square_hole_agent_session_procedure; +pub mod create_visual_novel_agent_session_procedure; pub mod custom_world_agent_action_execute_input_type; pub mod custom_world_agent_action_execute_result_type; pub mod custom_world_agent_card_detail_get_input_type; @@ -268,6 +271,7 @@ pub mod delete_match_3_d_work_procedure; pub mod delete_puzzle_work_procedure; pub mod delete_runtime_snapshot_and_return_procedure; pub mod delete_square_hole_work_procedure; +pub mod delete_visual_novel_work_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod drop_square_hole_shape_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; @@ -281,6 +285,7 @@ pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_match_3_d_agent_message_turn_procedure; pub mod finalize_puzzle_agent_message_turn_procedure; pub mod finalize_square_hole_agent_message_turn_procedure; +pub mod finalize_visual_novel_agent_message_turn_procedure; pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod generate_big_fish_asset_procedure; @@ -315,6 +320,9 @@ pub mod get_square_hole_agent_session_procedure; pub mod get_square_hole_run_procedure; pub mod get_square_hole_work_detail_procedure; pub mod get_story_session_state_procedure; +pub mod get_visual_novel_agent_session_procedure; +pub mod get_visual_novel_run_procedure; +pub mod get_visual_novel_work_detail_procedure; pub mod grant_inventory_item_input_type; pub mod grant_new_user_registration_wallet_reward_procedure; pub mod grant_player_progression_experience_and_return_procedure; @@ -346,6 +354,8 @@ pub mod list_profile_wallet_ledger_procedure; pub mod list_puzzle_gallery_procedure; pub mod list_puzzle_works_procedure; pub mod list_square_hole_works_procedure; +pub mod list_visual_novel_runtime_history_procedure; +pub mod list_visual_novel_works_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_submit_input_type; @@ -434,6 +444,7 @@ pub mod publish_custom_world_world_procedure; pub mod publish_match_3_d_work_procedure; pub mod publish_puzzle_work_procedure; pub mod publish_square_hole_work_procedure; +pub mod publish_visual_novel_work_procedure; pub mod put_database_migration_import_chunk_procedure; pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; @@ -515,6 +526,7 @@ pub mod record_big_fish_play_procedure; pub mod record_custom_world_profile_like_procedure; pub mod record_custom_world_profile_play_procedure; pub mod record_puzzle_work_like_procedure; +pub mod record_visual_novel_runtime_event_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_table; @@ -682,6 +694,7 @@ pub mod start_big_fish_run_procedure; pub mod start_match_3_d_run_procedure; pub mod start_puzzle_run_procedure; pub mod start_square_hole_run_procedure; +pub mod start_visual_novel_run_procedure; pub mod stop_match_3_d_run_procedure; pub mod stop_square_hole_run_procedure; pub mod story_continue_input_type; @@ -704,6 +717,7 @@ pub mod submit_match_3_d_agent_message_procedure; pub mod submit_puzzle_agent_message_procedure; pub mod submit_puzzle_leaderboard_entry_procedure; pub mod submit_square_hole_agent_message_procedure; +pub mod submit_visual_novel_agent_message_procedure; pub mod swap_puzzle_pieces_procedure; pub mod tracking_daily_stat_table; pub mod tracking_daily_stat_type; @@ -723,6 +737,7 @@ pub mod update_match_3_d_work_procedure; pub mod update_puzzle_run_pause_procedure; pub mod update_puzzle_work_procedure; pub mod update_square_hole_work_procedure; +pub mod update_visual_novel_work_procedure; pub mod upsert_auth_store_snapshot_procedure; pub mod upsert_chapter_progression_and_return_procedure; pub mod upsert_chapter_progression_reducer; @@ -734,11 +749,46 @@ pub mod upsert_npc_state_reducer; pub mod upsert_platform_browse_history_and_return_procedure; pub mod upsert_runtime_setting_and_return_procedure; pub mod upsert_runtime_snapshot_and_return_procedure; +pub mod upsert_visual_novel_run_snapshot_procedure; pub mod use_puzzle_runtime_prop_procedure; pub mod user_account_table; pub mod user_account_type; pub mod user_browse_history_table; pub mod user_browse_history_type; +pub mod visual_novel_agent_message_finalize_input_type; +pub mod visual_novel_agent_message_row_type; +pub mod visual_novel_agent_message_submit_input_type; +pub mod visual_novel_agent_message_table; +pub mod visual_novel_agent_session_create_input_type; +pub mod visual_novel_agent_session_get_input_type; +pub mod visual_novel_agent_session_procedure_result_type; +pub mod visual_novel_agent_session_row_type; +pub mod visual_novel_agent_session_table; +pub mod visual_novel_history_procedure_result_type; +pub mod visual_novel_run_get_input_type; +pub mod visual_novel_run_procedure_result_type; +pub mod visual_novel_run_snapshot_upsert_input_type; +pub mod visual_novel_run_start_input_type; +pub mod visual_novel_runtime_event_procedure_result_type; +pub mod visual_novel_runtime_event_record_input_type; +pub mod visual_novel_runtime_event_table; +pub mod visual_novel_runtime_event_type; +pub mod visual_novel_runtime_history_append_input_type; +pub mod visual_novel_runtime_history_entry_row_type; +pub mod visual_novel_runtime_history_entry_table; +pub mod visual_novel_runtime_history_list_input_type; +pub mod visual_novel_runtime_run_row_type; +pub mod visual_novel_runtime_run_table; +pub mod visual_novel_work_compile_input_type; +pub mod visual_novel_work_delete_input_type; +pub mod visual_novel_work_get_input_type; +pub mod visual_novel_work_procedure_result_type; +pub mod visual_novel_work_profile_row_type; +pub mod visual_novel_work_profile_table; +pub mod visual_novel_work_publish_input_type; +pub mod visual_novel_work_update_input_type; +pub mod visual_novel_works_list_input_type; +pub mod visual_novel_works_procedure_result_type; pub use accept_quest_reducer::accept_quest; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; @@ -791,6 +841,7 @@ pub use analytics_granularity_type::AnalyticsGranularity; pub use analytics_metric_query_input_type::AnalyticsMetricQueryInput; pub use analytics_metric_query_procedure_result_type::AnalyticsMetricQueryProcedureResult; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; +pub use append_visual_novel_runtime_history_entry_procedure::append_visual_novel_runtime_history_entry; pub use apply_chapter_progression_ledger_entry_and_return_procedure::apply_chapter_progression_ledger_entry_and_return; pub use apply_chapter_progression_ledger_entry_reducer::apply_chapter_progression_ledger_entry; pub use apply_inventory_mutation_reducer::apply_inventory_mutation; @@ -901,6 +952,7 @@ pub use compile_custom_world_published_profile_procedure::compile_custom_world_p pub use compile_match_3_d_draft_procedure::compile_match_3_d_draft; pub use compile_puzzle_agent_draft_procedure::compile_puzzle_agent_draft; pub use compile_square_hole_draft_procedure::compile_square_hole_draft; +pub use compile_visual_novel_work_profile_procedure::compile_visual_novel_work_profile; pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; @@ -919,6 +971,7 @@ pub use create_match_3_d_agent_session_procedure::create_match_3_d_agent_session pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return; pub use create_puzzle_agent_session_procedure::create_puzzle_agent_session; pub use create_square_hole_agent_session_procedure::create_square_hole_agent_session; +pub use create_visual_novel_agent_session_procedure::create_visual_novel_agent_session; pub use custom_world_agent_action_execute_input_type::CustomWorldAgentActionExecuteInput; pub use custom_world_agent_action_execute_result_type::CustomWorldAgentActionExecuteResult; pub use custom_world_agent_card_detail_get_input_type::CustomWorldAgentCardDetailGetInput; @@ -1002,6 +1055,7 @@ pub use delete_match_3_d_work_procedure::delete_match_3_d_work; pub use delete_puzzle_work_procedure::delete_puzzle_work; pub use delete_runtime_snapshot_and_return_procedure::delete_runtime_snapshot_and_return; pub use delete_square_hole_work_procedure::delete_square_hole_work; +pub use delete_visual_novel_work_procedure::delete_visual_novel_work; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drop_square_hole_shape_procedure::drop_square_hole_shape; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; @@ -1015,6 +1069,7 @@ pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_worl pub use finalize_match_3_d_agent_message_turn_procedure::finalize_match_3_d_agent_message_turn; pub use finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn; pub use finalize_square_hole_agent_message_turn_procedure::finalize_square_hole_agent_message_turn; +pub use finalize_visual_novel_agent_message_turn_procedure::finalize_visual_novel_agent_message_turn; pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; @@ -1049,6 +1104,9 @@ pub use get_square_hole_agent_session_procedure::get_square_hole_agent_session; pub use get_square_hole_run_procedure::get_square_hole_run; pub use get_square_hole_work_detail_procedure::get_square_hole_work_detail; pub use get_story_session_state_procedure::get_story_session_state; +pub use get_visual_novel_agent_session_procedure::get_visual_novel_agent_session; +pub use get_visual_novel_run_procedure::get_visual_novel_run; +pub use get_visual_novel_work_detail_procedure::get_visual_novel_work_detail; pub use grant_inventory_item_input_type::GrantInventoryItemInput; pub use grant_new_user_registration_wallet_reward_procedure::grant_new_user_registration_wallet_reward; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; @@ -1080,6 +1138,8 @@ pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; pub use list_puzzle_gallery_procedure::list_puzzle_gallery; pub use list_puzzle_works_procedure::list_puzzle_works; pub use list_square_hole_works_procedure::list_square_hole_works; +pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history; +pub use list_visual_novel_works_procedure::list_visual_novel_works; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_submit_input_type::Match3DAgentMessageSubmitInput; @@ -1168,6 +1228,7 @@ pub use publish_custom_world_world_procedure::publish_custom_world_world; pub use publish_match_3_d_work_procedure::publish_match_3_d_work; pub use publish_puzzle_work_procedure::publish_puzzle_work; pub use publish_square_hole_work_procedure::publish_square_hole_work; +pub use publish_visual_novel_work_procedure::publish_visual_novel_work; pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk; pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; @@ -1249,6 +1310,7 @@ pub use record_big_fish_play_procedure::record_big_fish_play; pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like; pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; pub use record_puzzle_work_like_procedure::record_puzzle_work_like; +pub use record_visual_novel_runtime_event_procedure::record_visual_novel_runtime_event; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_table::*; @@ -1416,6 +1478,7 @@ pub use start_big_fish_run_procedure::start_big_fish_run; pub use start_match_3_d_run_procedure::start_match_3_d_run; pub use start_puzzle_run_procedure::start_puzzle_run; pub use start_square_hole_run_procedure::start_square_hole_run; +pub use start_visual_novel_run_procedure::start_visual_novel_run; pub use stop_match_3_d_run_procedure::stop_match_3_d_run; pub use stop_square_hole_run_procedure::stop_square_hole_run; pub use story_continue_input_type::StoryContinueInput; @@ -1438,6 +1501,7 @@ pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message; pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry; pub use submit_square_hole_agent_message_procedure::submit_square_hole_agent_message; +pub use submit_visual_novel_agent_message_procedure::submit_visual_novel_agent_message; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use tracking_daily_stat_table::*; pub use tracking_daily_stat_type::TrackingDailyStat; @@ -1457,6 +1521,7 @@ pub use update_match_3_d_work_procedure::update_match_3_d_work; pub use update_puzzle_run_pause_procedure::update_puzzle_run_pause; pub use update_puzzle_work_procedure::update_puzzle_work; pub use update_square_hole_work_procedure::update_square_hole_work; +pub use update_visual_novel_work_procedure::update_visual_novel_work; pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot; pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return; pub use upsert_chapter_progression_reducer::upsert_chapter_progression; @@ -1468,11 +1533,46 @@ pub use upsert_npc_state_reducer::upsert_npc_state; pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return; pub use upsert_runtime_setting_and_return_procedure::upsert_runtime_setting_and_return; pub use upsert_runtime_snapshot_and_return_procedure::upsert_runtime_snapshot_and_return; +pub use upsert_visual_novel_run_snapshot_procedure::upsert_visual_novel_run_snapshot; pub use use_puzzle_runtime_prop_procedure::use_puzzle_runtime_prop; pub use user_account_table::*; pub use user_account_type::UserAccount; pub use user_browse_history_table::*; pub use user_browse_history_type::UserBrowseHistory; +pub use visual_novel_agent_message_finalize_input_type::VisualNovelAgentMessageFinalizeInput; +pub use visual_novel_agent_message_row_type::VisualNovelAgentMessageRow; +pub use visual_novel_agent_message_submit_input_type::VisualNovelAgentMessageSubmitInput; +pub use visual_novel_agent_message_table::*; +pub use visual_novel_agent_session_create_input_type::VisualNovelAgentSessionCreateInput; +pub use visual_novel_agent_session_get_input_type::VisualNovelAgentSessionGetInput; +pub use visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; +pub use visual_novel_agent_session_row_type::VisualNovelAgentSessionRow; +pub use visual_novel_agent_session_table::*; +pub use visual_novel_history_procedure_result_type::VisualNovelHistoryProcedureResult; +pub use visual_novel_run_get_input_type::VisualNovelRunGetInput; +pub use visual_novel_run_procedure_result_type::VisualNovelRunProcedureResult; +pub use visual_novel_run_snapshot_upsert_input_type::VisualNovelRunSnapshotUpsertInput; +pub use visual_novel_run_start_input_type::VisualNovelRunStartInput; +pub use visual_novel_runtime_event_procedure_result_type::VisualNovelRuntimeEventProcedureResult; +pub use visual_novel_runtime_event_record_input_type::VisualNovelRuntimeEventRecordInput; +pub use visual_novel_runtime_event_table::*; +pub use visual_novel_runtime_event_type::VisualNovelRuntimeEvent; +pub use visual_novel_runtime_history_append_input_type::VisualNovelRuntimeHistoryAppendInput; +pub use visual_novel_runtime_history_entry_row_type::VisualNovelRuntimeHistoryEntryRow; +pub use visual_novel_runtime_history_entry_table::*; +pub use visual_novel_runtime_history_list_input_type::VisualNovelRuntimeHistoryListInput; +pub use visual_novel_runtime_run_row_type::VisualNovelRuntimeRunRow; +pub use visual_novel_runtime_run_table::*; +pub use visual_novel_work_compile_input_type::VisualNovelWorkCompileInput; +pub use visual_novel_work_delete_input_type::VisualNovelWorkDeleteInput; +pub use visual_novel_work_get_input_type::VisualNovelWorkGetInput; +pub use visual_novel_work_procedure_result_type::VisualNovelWorkProcedureResult; +pub use visual_novel_work_profile_row_type::VisualNovelWorkProfileRow; +pub use visual_novel_work_profile_table::*; +pub use visual_novel_work_publish_input_type::VisualNovelWorkPublishInput; +pub use visual_novel_work_update_input_type::VisualNovelWorkUpdateInput; +pub use visual_novel_works_list_input_type::VisualNovelWorksListInput; +pub use visual_novel_works_procedure_result_type::VisualNovelWorksProcedureResult; #[derive(Clone, PartialEq, Debug)] @@ -1817,6 +1917,12 @@ pub struct DbUpdate { treasure_record: __sdk::TableUpdate, user_account: __sdk::TableUpdate, user_browse_history: __sdk::TableUpdate, + visual_novel_agent_message: __sdk::TableUpdate, + visual_novel_agent_session: __sdk::TableUpdate, + visual_novel_runtime_event: __sdk::TableUpdate, + visual_novel_runtime_history_entry: __sdk::TableUpdate, + visual_novel_runtime_run: __sdk::TableUpdate, + visual_novel_work_profile: __sdk::TableUpdate, } impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { @@ -2040,6 +2146,26 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(user_browse_history_table::parse_table_update(table_update)?), + "visual_novel_agent_message" => db_update.visual_novel_agent_message.append( + visual_novel_agent_message_table::parse_table_update(table_update)?, + ), + "visual_novel_agent_session" => db_update.visual_novel_agent_session.append( + visual_novel_agent_session_table::parse_table_update(table_update)?, + ), + "visual_novel_runtime_event" => db_update.visual_novel_runtime_event.append( + visual_novel_runtime_event_table::parse_table_update(table_update)?, + ), + "visual_novel_runtime_history_entry" => { + db_update.visual_novel_runtime_history_entry.append( + visual_novel_runtime_history_entry_table::parse_table_update(table_update)?, + ) + } + "visual_novel_runtime_run" => db_update.visual_novel_runtime_run.append( + visual_novel_runtime_run_table::parse_table_update(table_update)?, + ), + "visual_novel_work_profile" => db_update.visual_novel_work_profile.append( + visual_novel_work_profile_table::parse_table_update(table_update)?, + ), unknown => { return Err(__sdk::InternalError::unknown_name( @@ -2415,6 +2541,37 @@ impl __sdk::DbUpdate for DbUpdate { &self.user_browse_history, ) .with_updates_by_pk(|row| &row.browse_history_id); + diff.visual_novel_agent_message = cache + .apply_diff_to_table::( + "visual_novel_agent_message", + &self.visual_novel_agent_message, + ) + .with_updates_by_pk(|row| &row.message_id); + diff.visual_novel_agent_session = cache + .apply_diff_to_table::( + "visual_novel_agent_session", + &self.visual_novel_agent_session, + ) + .with_updates_by_pk(|row| &row.session_id); + diff.visual_novel_runtime_event = self.visual_novel_runtime_event.into_event_diff(); + diff.visual_novel_runtime_history_entry = cache + .apply_diff_to_table::( + "visual_novel_runtime_history_entry", + &self.visual_novel_runtime_history_entry, + ) + .with_updates_by_pk(|row| &row.entry_id); + diff.visual_novel_runtime_run = cache + .apply_diff_to_table::( + "visual_novel_runtime_run", + &self.visual_novel_runtime_run, + ) + .with_updates_by_pk(|row| &row.run_id); + diff.visual_novel_work_profile = cache + .apply_diff_to_table::( + "visual_novel_work_profile", + &self.visual_novel_work_profile, + ) + .with_updates_by_pk(|row| &row.profile_id); diff } @@ -2635,6 +2792,24 @@ impl __sdk::DbUpdate for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_agent_message" => db_update + .visual_novel_agent_message + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_agent_session" => db_update + .visual_novel_agent_session + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_runtime_event" => db_update + .visual_novel_runtime_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_runtime_history_entry" => db_update + .visual_novel_runtime_history_entry + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_runtime_run" => db_update + .visual_novel_runtime_run + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "visual_novel_work_profile" => db_update + .visual_novel_work_profile + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -2861,6 +3036,24 @@ impl __sdk::DbUpdate for DbUpdate { "user_browse_history" => db_update .user_browse_history .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_agent_message" => db_update + .visual_novel_agent_message + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_agent_session" => db_update + .visual_novel_agent_session + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_runtime_event" => db_update + .visual_novel_runtime_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_runtime_history_entry" => db_update + .visual_novel_runtime_history_entry + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_runtime_run" => db_update + .visual_novel_runtime_run + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "visual_novel_work_profile" => db_update + .visual_novel_work_profile + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), unknown => { return Err( __sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(), @@ -2947,6 +3140,13 @@ pub struct AppliedDiff<'r> { treasure_record: __sdk::TableAppliedDiff<'r, TreasureRecord>, user_account: __sdk::TableAppliedDiff<'r, UserAccount>, user_browse_history: __sdk::TableAppliedDiff<'r, UserBrowseHistory>, + visual_novel_agent_message: __sdk::TableAppliedDiff<'r, VisualNovelAgentMessageRow>, + visual_novel_agent_session: __sdk::TableAppliedDiff<'r, VisualNovelAgentSessionRow>, + visual_novel_runtime_event: __sdk::TableAppliedDiff<'r, VisualNovelRuntimeEvent>, + visual_novel_runtime_history_entry: + __sdk::TableAppliedDiff<'r, VisualNovelRuntimeHistoryEntryRow>, + visual_novel_runtime_run: __sdk::TableAppliedDiff<'r, VisualNovelRuntimeRunRow>, + visual_novel_work_profile: __sdk::TableAppliedDiff<'r, VisualNovelWorkProfileRow>, __unused: std::marker::PhantomData<&'r ()>, } @@ -3295,6 +3495,36 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.user_browse_history, event, ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_agent_message", + &self.visual_novel_agent_message, + event, + ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_agent_session", + &self.visual_novel_agent_session, + event, + ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_runtime_event", + &self.visual_novel_runtime_event, + event, + ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_runtime_history_entry", + &self.visual_novel_runtime_history_entry, + event, + ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_runtime_run", + &self.visual_novel_runtime_run, + event, + ); + callbacks.invoke_table_row_callbacks::( + "visual_novel_work_profile", + &self.visual_novel_work_profile, + event, + ); } } @@ -4026,6 +4256,12 @@ impl __sdk::SpacetimeModule for RemoteModule { treasure_record_table::register_table(client_cache); user_account_table::register_table(client_cache); user_browse_history_table::register_table(client_cache); + visual_novel_agent_message_table::register_table(client_cache); + visual_novel_agent_session_table::register_table(client_cache); + visual_novel_runtime_event_table::register_table(client_cache); + visual_novel_runtime_history_entry_table::register_table(client_cache); + visual_novel_runtime_run_table::register_table(client_cache); + visual_novel_work_profile_table::register_table(client_cache); } const ALL_TABLE_NAMES: &'static [&'static str] = &[ "ai_result_reference", @@ -4099,5 +4335,11 @@ impl __sdk::SpacetimeModule for RemoteModule { "treasure_record", "user_account", "user_browse_history", + "visual_novel_agent_message", + "visual_novel_agent_session", + "visual_novel_runtime_event", + "visual_novel_runtime_history_entry", + "visual_novel_runtime_run", + "visual_novel_work_profile", ]; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/publish_visual_novel_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/publish_visual_novel_work_procedure.rs new file mode 100644 index 000000000..91c7eabde --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/publish_visual_novel_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_work_procedure_result_type::VisualNovelWorkProcedureResult; +use super::visual_novel_work_publish_input_type::VisualNovelWorkPublishInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PublishVisualNovelWorkArgs { + pub input: VisualNovelWorkPublishInput, +} + +impl __sdk::InModule for PublishVisualNovelWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `publish_visual_novel_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait publish_visual_novel_work { + fn publish_visual_novel_work(&self, input: VisualNovelWorkPublishInput) { + self.publish_visual_novel_work_then(input, |_, _| {}); + } + + fn publish_visual_novel_work_then( + &self, + input: VisualNovelWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl publish_visual_novel_work for super::RemoteProcedures { + fn publish_visual_novel_work_then( + &self, + input: VisualNovelWorkPublishInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelWorkProcedureResult>( + "publish_visual_novel_work", + PublishVisualNovelWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_visual_novel_runtime_event_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_visual_novel_runtime_event_procedure.rs new file mode 100644 index 000000000..39f3a4319 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_visual_novel_runtime_event_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_runtime_event_procedure_result_type::VisualNovelRuntimeEventProcedureResult; +use super::visual_novel_runtime_event_record_input_type::VisualNovelRuntimeEventRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordVisualNovelRuntimeEventArgs { + pub input: VisualNovelRuntimeEventRecordInput, +} + +impl __sdk::InModule for RecordVisualNovelRuntimeEventArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_visual_novel_runtime_event`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_visual_novel_runtime_event { + fn record_visual_novel_runtime_event(&self, input: VisualNovelRuntimeEventRecordInput) { + self.record_visual_novel_runtime_event_then(input, |_, _| {}); + } + + fn record_visual_novel_runtime_event_then( + &self, + input: VisualNovelRuntimeEventRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_visual_novel_runtime_event for super::RemoteProcedures { + fn record_visual_novel_runtime_event_then( + &self, + input: VisualNovelRuntimeEventRecordInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelRuntimeEventProcedureResult>( + "record_visual_novel_runtime_event", + RecordVisualNovelRuntimeEventArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/start_visual_novel_run_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/start_visual_novel_run_procedure.rs new file mode 100644 index 000000000..fabc598a1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/start_visual_novel_run_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_run_procedure_result_type::VisualNovelRunProcedureResult; +use super::visual_novel_run_start_input_type::VisualNovelRunStartInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct StartVisualNovelRunArgs { + pub input: VisualNovelRunStartInput, +} + +impl __sdk::InModule for StartVisualNovelRunArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `start_visual_novel_run`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait start_visual_novel_run { + fn start_visual_novel_run(&self, input: VisualNovelRunStartInput) { + self.start_visual_novel_run_then(input, |_, _| {}); + } + + fn start_visual_novel_run_then( + &self, + input: VisualNovelRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl start_visual_novel_run for super::RemoteProcedures { + fn start_visual_novel_run_then( + &self, + input: VisualNovelRunStartInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelRunProcedureResult>( + "start_visual_novel_run", + StartVisualNovelRunArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/submit_visual_novel_agent_message_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/submit_visual_novel_agent_message_procedure.rs new file mode 100644 index 000000000..5e9c93bb3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/submit_visual_novel_agent_message_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_agent_message_submit_input_type::VisualNovelAgentMessageSubmitInput; +use super::visual_novel_agent_session_procedure_result_type::VisualNovelAgentSessionProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SubmitVisualNovelAgentMessageArgs { + pub input: VisualNovelAgentMessageSubmitInput, +} + +impl __sdk::InModule for SubmitVisualNovelAgentMessageArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `submit_visual_novel_agent_message`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait submit_visual_novel_agent_message { + fn submit_visual_novel_agent_message(&self, input: VisualNovelAgentMessageSubmitInput) { + self.submit_visual_novel_agent_message_then(input, |_, _| {}); + } + + fn submit_visual_novel_agent_message_then( + &self, + input: VisualNovelAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl submit_visual_novel_agent_message for super::RemoteProcedures { + fn submit_visual_novel_agent_message_then( + &self, + input: VisualNovelAgentMessageSubmitInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelAgentSessionProcedureResult>( + "submit_visual_novel_agent_message", + SubmitVisualNovelAgentMessageArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_visual_novel_work_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_visual_novel_work_procedure.rs new file mode 100644 index 000000000..bb0ccc4e4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_visual_novel_work_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_work_procedure_result_type::VisualNovelWorkProcedureResult; +use super::visual_novel_work_update_input_type::VisualNovelWorkUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdateVisualNovelWorkArgs { + pub input: VisualNovelWorkUpdateInput, +} + +impl __sdk::InModule for UpdateVisualNovelWorkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_visual_novel_work`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_visual_novel_work { + fn update_visual_novel_work(&self, input: VisualNovelWorkUpdateInput) { + self.update_visual_novel_work_then(input, |_, _| {}); + } + + fn update_visual_novel_work_then( + &self, + input: VisualNovelWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_visual_novel_work for super::RemoteProcedures { + fn update_visual_novel_work_then( + &self, + input: VisualNovelWorkUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelWorkProcedureResult>( + "update_visual_novel_work", + UpdateVisualNovelWorkArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upsert_visual_novel_run_snapshot_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upsert_visual_novel_run_snapshot_procedure.rs new file mode 100644 index 000000000..8cc89e856 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upsert_visual_novel_run_snapshot_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::visual_novel_run_procedure_result_type::VisualNovelRunProcedureResult; +use super::visual_novel_run_snapshot_upsert_input_type::VisualNovelRunSnapshotUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpsertVisualNovelRunSnapshotArgs { + pub input: VisualNovelRunSnapshotUpsertInput, +} + +impl __sdk::InModule for UpsertVisualNovelRunSnapshotArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upsert_visual_novel_run_snapshot`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upsert_visual_novel_run_snapshot { + fn upsert_visual_novel_run_snapshot(&self, input: VisualNovelRunSnapshotUpsertInput) { + self.upsert_visual_novel_run_snapshot_then(input, |_, _| {}); + } + + fn upsert_visual_novel_run_snapshot_then( + &self, + input: VisualNovelRunSnapshotUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl upsert_visual_novel_run_snapshot for super::RemoteProcedures { + fn upsert_visual_novel_run_snapshot_then( + &self, + input: VisualNovelRunSnapshotUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, VisualNovelRunProcedureResult>( + "upsert_visual_novel_run_snapshot", + UpsertVisualNovelRunSnapshotArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_finalize_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_finalize_input_type.rs new file mode 100644 index 000000000..8385b7ed8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_finalize_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub draft_json: Option, + pub pending_action_json: Option, + pub status: String, + pub progress_percent: u32, + pub updated_at_micros: i64, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelAgentMessageFinalizeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_row_type.rs new file mode 100644 index 000000000..184705061 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_row_type.rs @@ -0,0 +1,66 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentMessageRow { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for VisualNovelAgentMessageRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelAgentMessageRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelAgentMessageRowCols { + pub message_id: __sdk::__query_builder::Col, + pub session_id: __sdk::__query_builder::Col, + pub role: __sdk::__query_builder::Col, + pub kind: __sdk::__query_builder::Col, + pub text: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for VisualNovelAgentMessageRow { + type Cols = VisualNovelAgentMessageRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelAgentMessageRowCols { + message_id: __sdk::__query_builder::Col::new(table_name, "message_id"), + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + role: __sdk::__query_builder::Col::new(table_name, "role"), + kind: __sdk::__query_builder::Col::new(table_name, "kind"), + text: __sdk::__query_builder::Col::new(table_name, "text"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelAgentMessageRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelAgentMessageRowIxCols { + pub message_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelAgentMessageRow { + type IxCols = VisualNovelAgentMessageRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelAgentMessageRowIxCols { + message_id: __sdk::__query_builder::IxCol::new(table_name, "message_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for VisualNovelAgentMessageRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_submit_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_submit_input_type.rs new file mode 100644 index 000000000..ded701b23 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_submit_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelAgentMessageSubmitInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_table.rs new file mode 100644 index 000000000..094652cf8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_message_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_agent_message_row_type::VisualNovelAgentMessageRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_agent_message`. +/// +/// Obtain a handle from the [`VisualNovelAgentMessageTableAccess::visual_novel_agent_message`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_agent_message()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_agent_message().on_insert(...)`. +pub struct VisualNovelAgentMessageTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_agent_message`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelAgentMessageTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelAgentMessageTableHandle`], which mediates access to the table `visual_novel_agent_message`. + fn visual_novel_agent_message(&self) -> VisualNovelAgentMessageTableHandle<'_>; +} + +impl VisualNovelAgentMessageTableAccess for super::RemoteTables { + fn visual_novel_agent_message(&self) -> VisualNovelAgentMessageTableHandle<'_> { + VisualNovelAgentMessageTableHandle { + imp: self + .imp + .get_table::("visual_novel_agent_message"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelAgentMessageInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelAgentMessageDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelAgentMessageTableHandle<'ctx> { + type Row = VisualNovelAgentMessageRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelAgentMessageInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentMessageInsertCallbackId { + VisualNovelAgentMessageInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelAgentMessageInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelAgentMessageDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentMessageDeleteCallbackId { + VisualNovelAgentMessageDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelAgentMessageDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct VisualNovelAgentMessageUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for VisualNovelAgentMessageTableHandle<'ctx> { + type UpdateCallbackId = VisualNovelAgentMessageUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentMessageUpdateCallbackId { + VisualNovelAgentMessageUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: VisualNovelAgentMessageUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `message_id` unique index on the table `visual_novel_agent_message`, +/// which allows point queries on the field of the same name +/// via the [`VisualNovelAgentMessageMessageIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_agent_message().message_id().find(...)`. +pub struct VisualNovelAgentMessageMessageIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> VisualNovelAgentMessageTableHandle<'ctx> { + /// Get a handle on the `message_id` unique index on the table `visual_novel_agent_message`. + pub fn message_id(&self) -> VisualNovelAgentMessageMessageIdUnique<'ctx> { + VisualNovelAgentMessageMessageIdUnique { + imp: self.imp.get_unique_constraint::("message_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> VisualNovelAgentMessageMessageIdUnique<'ctx> { + /// Find the subscribed row whose `message_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_agent_message"); + _table.add_unique_constraint::("message_id", |row| &row.message_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelAgentMessageRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_agent_messageQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelAgentMessageRow`. + fn visual_novel_agent_message( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl visual_novel_agent_messageQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_agent_message( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_agent_message") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_create_input_type.rs new file mode 100644 index 000000000..e0e62441e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_create_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub seed_text: String, + pub source_asset_ids_json: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub draft_json: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelAgentSessionCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_get_input_type.rs new file mode 100644 index 000000000..c257b6fbc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for VisualNovelAgentSessionGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs new file mode 100644 index 000000000..f04d06ebf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelAgentSessionProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_row_type.rs new file mode 100644 index 000000000..6d00cc9a0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_row_type.rs @@ -0,0 +1,102 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelAgentSessionRow { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub status: String, + pub seed_text: String, + pub source_asset_ids_json: String, + pub current_turn: u32, + pub progress_percent: u32, + pub draft_json: String, + pub pending_action_json: String, + pub last_assistant_reply: String, + pub published_profile_id: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for VisualNovelAgentSessionRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelAgentSessionRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelAgentSessionRowCols { + pub session_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_mode: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub seed_text: __sdk::__query_builder::Col, + pub source_asset_ids_json: __sdk::__query_builder::Col, + pub current_turn: __sdk::__query_builder::Col, + pub progress_percent: __sdk::__query_builder::Col, + pub draft_json: __sdk::__query_builder::Col, + pub pending_action_json: __sdk::__query_builder::Col, + pub last_assistant_reply: __sdk::__query_builder::Col, + pub published_profile_id: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for VisualNovelAgentSessionRow { + type Cols = VisualNovelAgentSessionRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelAgentSessionRowCols { + session_id: __sdk::__query_builder::Col::new(table_name, "session_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_mode: __sdk::__query_builder::Col::new(table_name, "source_mode"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + seed_text: __sdk::__query_builder::Col::new(table_name, "seed_text"), + source_asset_ids_json: __sdk::__query_builder::Col::new( + table_name, + "source_asset_ids_json", + ), + current_turn: __sdk::__query_builder::Col::new(table_name, "current_turn"), + progress_percent: __sdk::__query_builder::Col::new(table_name, "progress_percent"), + draft_json: __sdk::__query_builder::Col::new(table_name, "draft_json"), + pending_action_json: __sdk::__query_builder::Col::new( + table_name, + "pending_action_json", + ), + last_assistant_reply: __sdk::__query_builder::Col::new( + table_name, + "last_assistant_reply", + ), + published_profile_id: __sdk::__query_builder::Col::new( + table_name, + "published_profile_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelAgentSessionRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelAgentSessionRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub session_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelAgentSessionRow { + type IxCols = VisualNovelAgentSessionRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelAgentSessionRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + session_id: __sdk::__query_builder::IxCol::new(table_name, "session_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for VisualNovelAgentSessionRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_table.rs new file mode 100644 index 000000000..59c956c83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_agent_session_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_agent_session_row_type::VisualNovelAgentSessionRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_agent_session`. +/// +/// Obtain a handle from the [`VisualNovelAgentSessionTableAccess::visual_novel_agent_session`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_agent_session()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_agent_session().on_insert(...)`. +pub struct VisualNovelAgentSessionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_agent_session`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelAgentSessionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelAgentSessionTableHandle`], which mediates access to the table `visual_novel_agent_session`. + fn visual_novel_agent_session(&self) -> VisualNovelAgentSessionTableHandle<'_>; +} + +impl VisualNovelAgentSessionTableAccess for super::RemoteTables { + fn visual_novel_agent_session(&self) -> VisualNovelAgentSessionTableHandle<'_> { + VisualNovelAgentSessionTableHandle { + imp: self + .imp + .get_table::("visual_novel_agent_session"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelAgentSessionInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelAgentSessionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelAgentSessionTableHandle<'ctx> { + type Row = VisualNovelAgentSessionRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelAgentSessionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentSessionInsertCallbackId { + VisualNovelAgentSessionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelAgentSessionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelAgentSessionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentSessionDeleteCallbackId { + VisualNovelAgentSessionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelAgentSessionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct VisualNovelAgentSessionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for VisualNovelAgentSessionTableHandle<'ctx> { + type UpdateCallbackId = VisualNovelAgentSessionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> VisualNovelAgentSessionUpdateCallbackId { + VisualNovelAgentSessionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: VisualNovelAgentSessionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `session_id` unique index on the table `visual_novel_agent_session`, +/// which allows point queries on the field of the same name +/// via the [`VisualNovelAgentSessionSessionIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_agent_session().session_id().find(...)`. +pub struct VisualNovelAgentSessionSessionIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> VisualNovelAgentSessionTableHandle<'ctx> { + /// Get a handle on the `session_id` unique index on the table `visual_novel_agent_session`. + pub fn session_id(&self) -> VisualNovelAgentSessionSessionIdUnique<'ctx> { + VisualNovelAgentSessionSessionIdUnique { + imp: self.imp.get_unique_constraint::("session_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> VisualNovelAgentSessionSessionIdUnique<'ctx> { + /// Find the subscribed row whose `session_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_agent_session"); + _table.add_unique_constraint::("session_id", |row| &row.session_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelAgentSessionRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_agent_sessionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelAgentSessionRow`. + fn visual_novel_agent_session( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl visual_novel_agent_sessionQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_agent_session( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_agent_session") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs new file mode 100644 index 000000000..c5c5935c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_history_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelHistoryProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelHistoryProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_get_input_type.rs new file mode 100644 index 000000000..addd7dea5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for VisualNovelRunGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs new file mode 100644 index 000000000..66bbe4835 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelRunProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_upsert_input_type.rs new file mode 100644 index 000000000..af4c72c8f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_snapshot_upsert_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRunSnapshotUpsertInput { + pub run_id: String, + pub owner_user_id: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids_json: String, + pub flags_json: String, + pub metrics_json: String, + pub available_choices_json: String, + pub text_mode_enabled: bool, + pub snapshot_json: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRunSnapshotUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_start_input_type.rs new file mode 100644 index 000000000..d17a2f13d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_run_start_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub snapshot_json: Option, + pub started_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRunStartInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs new file mode 100644 index 000000000..58bdfbfb5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeEventProcedureResult { + pub ok: bool, + pub event_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelRuntimeEventProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_record_input_type.rs new file mode 100644 index 000000000..18807e2d3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_record_input_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeEventRecordInput { + pub event_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload_json: String, + pub occurred_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRuntimeEventRecordInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_table.rs new file mode 100644 index 000000000..07d71e9d1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_table.rs @@ -0,0 +1,101 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_runtime_event_type::VisualNovelRuntimeEvent; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_runtime_event`. +/// +/// Obtain a handle from the [`VisualNovelRuntimeEventTableAccess::visual_novel_runtime_event`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_runtime_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_runtime_event().on_insert(...)`. +pub struct VisualNovelRuntimeEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_runtime_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelRuntimeEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelRuntimeEventTableHandle`], which mediates access to the table `visual_novel_runtime_event`. + fn visual_novel_runtime_event(&self) -> VisualNovelRuntimeEventTableHandle<'_>; +} + +impl VisualNovelRuntimeEventTableAccess for super::RemoteTables { + fn visual_novel_runtime_event(&self) -> VisualNovelRuntimeEventTableHandle<'_> { + VisualNovelRuntimeEventTableHandle { + imp: self + .imp + .get_table::("visual_novel_runtime_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelRuntimeEventInsertCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::EventTable for VisualNovelRuntimeEventTableHandle<'ctx> { + type Row = VisualNovelRuntimeEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelRuntimeEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeEventInsertCallbackId { + VisualNovelRuntimeEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelRuntimeEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_runtime_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelRuntimeEvent`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_runtime_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelRuntimeEvent`. + fn visual_novel_runtime_event(&self) -> __sdk::__query_builder::Table; +} + +impl visual_novel_runtime_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_runtime_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_runtime_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_type.rs new file mode 100644 index 000000000..9bb480726 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_event_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeEvent { + pub event_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub event_kind: String, + pub client_event_id: String, + pub history_entry_id: String, + pub payload_json: String, + pub occurred_at: __sdk::Timestamp, +} + +impl __sdk::InModule for VisualNovelRuntimeEvent { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelRuntimeEvent`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelRuntimeEventCols { + pub event_id: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub event_kind: __sdk::__query_builder::Col, + pub client_event_id: __sdk::__query_builder::Col, + pub history_entry_id: __sdk::__query_builder::Col, + pub payload_json: __sdk::__query_builder::Col, + pub occurred_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for VisualNovelRuntimeEvent { + type Cols = VisualNovelRuntimeEventCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelRuntimeEventCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + event_kind: __sdk::__query_builder::Col::new(table_name, "event_kind"), + client_event_id: __sdk::__query_builder::Col::new(table_name, "client_event_id"), + history_entry_id: __sdk::__query_builder::Col::new(table_name, "history_entry_id"), + payload_json: __sdk::__query_builder::Col::new(table_name, "payload_json"), + occurred_at: __sdk::__query_builder::Col::new(table_name, "occurred_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelRuntimeEvent`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelRuntimeEventIxCols { + pub event_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelRuntimeEvent { + type IxCols = VisualNovelRuntimeEventIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelRuntimeEventIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_append_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_append_input_type.rs new file mode 100644 index 000000000..efb7033cc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_append_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeHistoryAppendInput { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps_json: String, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelRuntimeHistoryAppendInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_row_type.rs new file mode 100644 index 000000000..15eb7e2bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_row_type.rs @@ -0,0 +1,91 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeHistoryEntryRow { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: String, + pub steps_json: String, + pub snapshot_before_hash: String, + pub snapshot_after_hash: String, + pub created_at: __sdk::Timestamp, +} + +impl __sdk::InModule for VisualNovelRuntimeHistoryEntryRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelRuntimeHistoryEntryRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelRuntimeHistoryEntryRowCols { + pub entry_id: __sdk::__query_builder::Col, + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub turn_index: __sdk::__query_builder::Col, + pub source: __sdk::__query_builder::Col, + pub action_text: __sdk::__query_builder::Col, + pub steps_json: __sdk::__query_builder::Col, + pub snapshot_before_hash: + __sdk::__query_builder::Col, + pub snapshot_after_hash: __sdk::__query_builder::Col, + pub created_at: + __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for VisualNovelRuntimeHistoryEntryRow { + type Cols = VisualNovelRuntimeHistoryEntryRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelRuntimeHistoryEntryRowCols { + entry_id: __sdk::__query_builder::Col::new(table_name, "entry_id"), + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + turn_index: __sdk::__query_builder::Col::new(table_name, "turn_index"), + source: __sdk::__query_builder::Col::new(table_name, "source"), + action_text: __sdk::__query_builder::Col::new(table_name, "action_text"), + steps_json: __sdk::__query_builder::Col::new(table_name, "steps_json"), + snapshot_before_hash: __sdk::__query_builder::Col::new( + table_name, + "snapshot_before_hash", + ), + snapshot_after_hash: __sdk::__query_builder::Col::new( + table_name, + "snapshot_after_hash", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelRuntimeHistoryEntryRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelRuntimeHistoryEntryRowIxCols { + pub entry_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelRuntimeHistoryEntryRow { + type IxCols = VisualNovelRuntimeHistoryEntryRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelRuntimeHistoryEntryRowIxCols { + entry_id: __sdk::__query_builder::IxCol::new(table_name, "entry_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for VisualNovelRuntimeHistoryEntryRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_table.rs new file mode 100644 index 000000000..14fb35413 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_entry_table.rs @@ -0,0 +1,170 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_runtime_history_entry_row_type::VisualNovelRuntimeHistoryEntryRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_runtime_history_entry`. +/// +/// Obtain a handle from the [`VisualNovelRuntimeHistoryEntryTableAccess::visual_novel_runtime_history_entry`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_runtime_history_entry()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_runtime_history_entry().on_insert(...)`. +pub struct VisualNovelRuntimeHistoryEntryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_runtime_history_entry`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelRuntimeHistoryEntryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelRuntimeHistoryEntryTableHandle`], which mediates access to the table `visual_novel_runtime_history_entry`. + fn visual_novel_runtime_history_entry(&self) -> VisualNovelRuntimeHistoryEntryTableHandle<'_>; +} + +impl VisualNovelRuntimeHistoryEntryTableAccess for super::RemoteTables { + fn visual_novel_runtime_history_entry(&self) -> VisualNovelRuntimeHistoryEntryTableHandle<'_> { + VisualNovelRuntimeHistoryEntryTableHandle { + imp: self.imp.get_table::( + "visual_novel_runtime_history_entry", + ), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelRuntimeHistoryEntryInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelRuntimeHistoryEntryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelRuntimeHistoryEntryTableHandle<'ctx> { + type Row = VisualNovelRuntimeHistoryEntryRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelRuntimeHistoryEntryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeHistoryEntryInsertCallbackId { + VisualNovelRuntimeHistoryEntryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelRuntimeHistoryEntryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelRuntimeHistoryEntryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeHistoryEntryDeleteCallbackId { + VisualNovelRuntimeHistoryEntryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelRuntimeHistoryEntryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct VisualNovelRuntimeHistoryEntryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for VisualNovelRuntimeHistoryEntryTableHandle<'ctx> { + type UpdateCallbackId = VisualNovelRuntimeHistoryEntryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeHistoryEntryUpdateCallbackId { + VisualNovelRuntimeHistoryEntryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: VisualNovelRuntimeHistoryEntryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `entry_id` unique index on the table `visual_novel_runtime_history_entry`, +/// which allows point queries on the field of the same name +/// via the [`VisualNovelRuntimeHistoryEntryEntryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_runtime_history_entry().entry_id().find(...)`. +pub struct VisualNovelRuntimeHistoryEntryEntryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> VisualNovelRuntimeHistoryEntryTableHandle<'ctx> { + /// Get a handle on the `entry_id` unique index on the table `visual_novel_runtime_history_entry`. + pub fn entry_id(&self) -> VisualNovelRuntimeHistoryEntryEntryIdUnique<'ctx> { + VisualNovelRuntimeHistoryEntryEntryIdUnique { + imp: self.imp.get_unique_constraint::("entry_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> VisualNovelRuntimeHistoryEntryEntryIdUnique<'ctx> { + /// Find the subscribed row whose `entry_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::( + "visual_novel_runtime_history_entry", + ); + _table.add_unique_constraint::("entry_id", |row| &row.entry_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelRuntimeHistoryEntryRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_runtime_history_entryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelRuntimeHistoryEntryRow`. + fn visual_novel_runtime_history_entry( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl visual_novel_runtime_history_entryQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_runtime_history_entry( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_runtime_history_entry") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_list_input_type.rs new file mode 100644 index 000000000..6aba8b860 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_history_list_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeHistoryListInput { + pub run_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for VisualNovelRuntimeHistoryListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_row_type.rs new file mode 100644 index 000000000..a431fd5eb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_row_type.rs @@ -0,0 +1,101 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelRuntimeRunRow { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub status: String, + pub current_scene_id: String, + pub current_phase_id: String, + pub visible_character_ids_json: String, + pub flags_json: String, + pub metrics_json: String, + pub available_choices_json: String, + pub text_mode_enabled: bool, + pub snapshot_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for VisualNovelRuntimeRunRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelRuntimeRunRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelRuntimeRunRowCols { + pub run_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub mode: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub current_scene_id: __sdk::__query_builder::Col, + pub current_phase_id: __sdk::__query_builder::Col, + pub visible_character_ids_json: __sdk::__query_builder::Col, + pub flags_json: __sdk::__query_builder::Col, + pub metrics_json: __sdk::__query_builder::Col, + pub available_choices_json: __sdk::__query_builder::Col, + pub text_mode_enabled: __sdk::__query_builder::Col, + pub snapshot_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for VisualNovelRuntimeRunRow { + type Cols = VisualNovelRuntimeRunRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelRuntimeRunRowCols { + run_id: __sdk::__query_builder::Col::new(table_name, "run_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + mode: __sdk::__query_builder::Col::new(table_name, "mode"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + current_scene_id: __sdk::__query_builder::Col::new(table_name, "current_scene_id"), + current_phase_id: __sdk::__query_builder::Col::new(table_name, "current_phase_id"), + visible_character_ids_json: __sdk::__query_builder::Col::new( + table_name, + "visible_character_ids_json", + ), + flags_json: __sdk::__query_builder::Col::new(table_name, "flags_json"), + metrics_json: __sdk::__query_builder::Col::new(table_name, "metrics_json"), + available_choices_json: __sdk::__query_builder::Col::new( + table_name, + "available_choices_json", + ), + text_mode_enabled: __sdk::__query_builder::Col::new(table_name, "text_mode_enabled"), + snapshot_json: __sdk::__query_builder::Col::new(table_name, "snapshot_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelRuntimeRunRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelRuntimeRunRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub run_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelRuntimeRunRow { + type IxCols = VisualNovelRuntimeRunRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelRuntimeRunRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + run_id: __sdk::__query_builder::IxCol::new(table_name, "run_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for VisualNovelRuntimeRunRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_table.rs new file mode 100644 index 000000000..98a497532 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_runtime_run_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_runtime_run_row_type::VisualNovelRuntimeRunRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_runtime_run`. +/// +/// Obtain a handle from the [`VisualNovelRuntimeRunTableAccess::visual_novel_runtime_run`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_runtime_run()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_runtime_run().on_insert(...)`. +pub struct VisualNovelRuntimeRunTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_runtime_run`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelRuntimeRunTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelRuntimeRunTableHandle`], which mediates access to the table `visual_novel_runtime_run`. + fn visual_novel_runtime_run(&self) -> VisualNovelRuntimeRunTableHandle<'_>; +} + +impl VisualNovelRuntimeRunTableAccess for super::RemoteTables { + fn visual_novel_runtime_run(&self) -> VisualNovelRuntimeRunTableHandle<'_> { + VisualNovelRuntimeRunTableHandle { + imp: self + .imp + .get_table::("visual_novel_runtime_run"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelRuntimeRunInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelRuntimeRunDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelRuntimeRunTableHandle<'ctx> { + type Row = VisualNovelRuntimeRunRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelRuntimeRunInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeRunInsertCallbackId { + VisualNovelRuntimeRunInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelRuntimeRunInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelRuntimeRunDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeRunDeleteCallbackId { + VisualNovelRuntimeRunDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelRuntimeRunDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct VisualNovelRuntimeRunUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for VisualNovelRuntimeRunTableHandle<'ctx> { + type UpdateCallbackId = VisualNovelRuntimeRunUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> VisualNovelRuntimeRunUpdateCallbackId { + VisualNovelRuntimeRunUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: VisualNovelRuntimeRunUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `run_id` unique index on the table `visual_novel_runtime_run`, +/// which allows point queries on the field of the same name +/// via the [`VisualNovelRuntimeRunRunIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_runtime_run().run_id().find(...)`. +pub struct VisualNovelRuntimeRunRunIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> VisualNovelRuntimeRunTableHandle<'ctx> { + /// Get a handle on the `run_id` unique index on the table `visual_novel_runtime_run`. + pub fn run_id(&self) -> VisualNovelRuntimeRunRunIdUnique<'ctx> { + VisualNovelRuntimeRunRunIdUnique { + imp: self.imp.get_unique_constraint::("run_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> VisualNovelRuntimeRunRunIdUnique<'ctx> { + /// Find the subscribed row whose `run_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_runtime_run"); + _table.add_unique_constraint::("run_id", |row| &row.run_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelRuntimeRunRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_runtime_runQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelRuntimeRunRow`. + fn visual_novel_runtime_run(&self) -> __sdk::__query_builder::Table; +} + +impl visual_novel_runtime_runQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_runtime_run(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_runtime_run") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_compile_input_type.rs new file mode 100644 index 000000000..0e92f9e25 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_compile_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub work_id: Option, + pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub compiled_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelWorkCompileInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_delete_input_type.rs new file mode 100644 index 000000000..792d8386e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_delete_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for VisualNovelWorkDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_get_input_type.rs new file mode 100644 index 000000000..40a1b54ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for VisualNovelWorkGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs new file mode 100644 index 000000000..f7e63a1cb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelWorkProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs new file mode 100644 index 000000000..941c07c64 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_row_type.rs @@ -0,0 +1,111 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkProfileRow { + pub profile_id: String, + pub work_id: String, + pub owner_user_id: String, + pub source_session_id: String, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub tags_json: String, + pub cover_image_src: String, + pub source_asset_ids_json: String, + pub draft_json: String, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, + pub published_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for VisualNovelWorkProfileRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `VisualNovelWorkProfileRow`. +/// +/// Provides typed access to columns for query building. +pub struct VisualNovelWorkProfileRowCols { + pub profile_id: __sdk::__query_builder::Col, + pub work_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_session_id: __sdk::__query_builder::Col, + pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, + pub tags_json: __sdk::__query_builder::Col, + pub cover_image_src: __sdk::__query_builder::Col, + pub source_asset_ids_json: __sdk::__query_builder::Col, + pub draft_json: __sdk::__query_builder::Col, + pub publication_status: __sdk::__query_builder::Col, + pub publish_ready: __sdk::__query_builder::Col, + pub play_count: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub published_at: + __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for VisualNovelWorkProfileRow { + type Cols = VisualNovelWorkProfileRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + VisualNovelWorkProfileRowCols { + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), + author_display_name: __sdk::__query_builder::Col::new( + table_name, + "author_display_name", + ), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), + tags_json: __sdk::__query_builder::Col::new(table_name, "tags_json"), + cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), + source_asset_ids_json: __sdk::__query_builder::Col::new( + table_name, + "source_asset_ids_json", + ), + draft_json: __sdk::__query_builder::Col::new(table_name, "draft_json"), + publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), + publish_ready: __sdk::__query_builder::Col::new(table_name, "publish_ready"), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), + } + } +} + +/// Indexed column accessor struct for the table `VisualNovelWorkProfileRow`. +/// +/// Provides typed access to indexed columns for query building. +pub struct VisualNovelWorkProfileRowIxCols { + pub owner_user_id: __sdk::__query_builder::IxCol, + pub profile_id: __sdk::__query_builder::IxCol, + pub publication_status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for VisualNovelWorkProfileRow { + type IxCols = VisualNovelWorkProfileRowIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + VisualNovelWorkProfileRowIxCols { + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::IxCol::new(table_name, "profile_id"), + publication_status: __sdk::__query_builder::IxCol::new( + table_name, + "publication_status", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for VisualNovelWorkProfileRow {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs new file mode 100644 index 000000000..24682e11d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_profile_table.rs @@ -0,0 +1,165 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::visual_novel_work_profile_row_type::VisualNovelWorkProfileRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `visual_novel_work_profile`. +/// +/// Obtain a handle from the [`VisualNovelWorkProfileTableAccess::visual_novel_work_profile`] method on [`super::RemoteTables`], +/// like `ctx.db.visual_novel_work_profile()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_work_profile().on_insert(...)`. +pub struct VisualNovelWorkProfileTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `visual_novel_work_profile`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait VisualNovelWorkProfileTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`VisualNovelWorkProfileTableHandle`], which mediates access to the table `visual_novel_work_profile`. + fn visual_novel_work_profile(&self) -> VisualNovelWorkProfileTableHandle<'_>; +} + +impl VisualNovelWorkProfileTableAccess for super::RemoteTables { + fn visual_novel_work_profile(&self) -> VisualNovelWorkProfileTableHandle<'_> { + VisualNovelWorkProfileTableHandle { + imp: self + .imp + .get_table::("visual_novel_work_profile"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct VisualNovelWorkProfileInsertCallbackId(__sdk::CallbackId); +pub struct VisualNovelWorkProfileDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for VisualNovelWorkProfileTableHandle<'ctx> { + type Row = VisualNovelWorkProfileRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = VisualNovelWorkProfileInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelWorkProfileInsertCallbackId { + VisualNovelWorkProfileInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: VisualNovelWorkProfileInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = VisualNovelWorkProfileDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> VisualNovelWorkProfileDeleteCallbackId { + VisualNovelWorkProfileDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: VisualNovelWorkProfileDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct VisualNovelWorkProfileUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for VisualNovelWorkProfileTableHandle<'ctx> { + type UpdateCallbackId = VisualNovelWorkProfileUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> VisualNovelWorkProfileUpdateCallbackId { + VisualNovelWorkProfileUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: VisualNovelWorkProfileUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `profile_id` unique index on the table `visual_novel_work_profile`, +/// which allows point queries on the field of the same name +/// via the [`VisualNovelWorkProfileProfileIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.visual_novel_work_profile().profile_id().find(...)`. +pub struct VisualNovelWorkProfileProfileIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> VisualNovelWorkProfileTableHandle<'ctx> { + /// Get a handle on the `profile_id` unique index on the table `visual_novel_work_profile`. + pub fn profile_id(&self) -> VisualNovelWorkProfileProfileIdUnique<'ctx> { + VisualNovelWorkProfileProfileIdUnique { + imp: self.imp.get_unique_constraint::("profile_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> VisualNovelWorkProfileProfileIdUnique<'ctx> { + /// Find the subscribed row whose `profile_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("visual_novel_work_profile"); + _table.add_unique_constraint::("profile_id", |row| &row.profile_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `VisualNovelWorkProfileRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait visual_novel_work_profileQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `VisualNovelWorkProfileRow`. + fn visual_novel_work_profile(&self) + -> __sdk::__query_builder::Table; +} + +impl visual_novel_work_profileQueryTableAccess for __sdk::QueryTableAccessor { + fn visual_novel_work_profile( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("visual_novel_work_profile") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_publish_input_type.rs new file mode 100644 index 000000000..b2fb68b64 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_publish_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelWorkPublishInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_update_input_type.rs new file mode 100644 index 000000000..b3ddc31d1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_work_update_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub tags_json: String, + pub cover_image_src: Option, + pub source_asset_ids_json: String, + pub draft_json: String, + pub publish_ready: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for VisualNovelWorkUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_list_input_type.rs new file mode 100644 index 000000000..fabed4c96 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_list_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +impl __sdk::InModule for VisualNovelWorksListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs new file mode 100644 index 000000000..72558fcdf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/visual_novel_works_procedure_result_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct VisualNovelWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +impl __sdk::InModule for VisualNovelWorksProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index ab41b7b01..504a62851 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -361,14 +361,15 @@ impl SpacetimeClient { .into(); self.call_after_connect(move |connection, sender| { - connection - .procedures() - .query_analytics_metric_then(procedure_input, move |_, result| { + connection.procedures().query_analytics_metric_then( + procedure_input, + move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_analytics_metric_query_procedure_result); send_once(&sender, mapped); - }); + }, + ); }) .await } diff --git a/server-rs/crates/spacetime-client/src/visual_novel.rs b/server-rs/crates/spacetime-client/src/visual_novel.rs new file mode 100644 index 000000000..ad4b544a8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/visual_novel.rs @@ -0,0 +1,466 @@ +use super::*; +use crate::mapper::{ + VisualNovelAgentMessageFinalizeRecordInput, VisualNovelAgentMessageSubmitRecordInput, + VisualNovelAgentSessionCreateRecordInput, VisualNovelAgentSessionRecord, + VisualNovelHistoryEntryRecord, VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, + VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, + VisualNovelRuntimeEventRecord, VisualNovelWorkCompileRecordInput, VisualNovelWorkProfileRecord, + VisualNovelWorkUpdateRecordInput, map_visual_novel_agent_session_procedure_result, + map_visual_novel_history_procedure_result, map_visual_novel_run_procedure_result, + map_visual_novel_runtime_event_procedure_result, map_visual_novel_work_procedure_result, + map_visual_novel_works_procedure_result, +}; + +impl SpacetimeClient { + pub async fn create_visual_novel_agent_session( + &self, + input: VisualNovelAgentSessionCreateRecordInput, + ) -> Result { + let procedure_input = VisualNovelAgentSessionCreateInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + source_mode: input.source_mode, + seed_text: input.seed_text, + source_asset_ids_json: input.source_asset_ids_json, + welcome_message_id: input.welcome_message_id, + welcome_message_text: input.welcome_message_text, + draft_json: input.draft_json, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().create_visual_novel_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_visual_novel_agent_session( + &self, + session_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = VisualNovelAgentSessionGetInput { + session_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_visual_novel_agent_session_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn submit_visual_novel_agent_message( + &self, + input: VisualNovelAgentMessageSubmitRecordInput, + ) -> Result { + let procedure_input = VisualNovelAgentMessageSubmitInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + user_message_id: input.user_message_id, + user_message_text: input.user_message_text, + submitted_at_micros: input.submitted_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().submit_visual_novel_agent_message_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn finalize_visual_novel_agent_message( + &self, + input: VisualNovelAgentMessageFinalizeRecordInput, + ) -> Result { + let procedure_input = VisualNovelAgentMessageFinalizeInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + assistant_message_id: input.assistant_message_id, + assistant_reply_text: input.assistant_reply_text, + draft_json: input.draft_json, + pending_action_json: input.pending_action_json, + status: input.status, + progress_percent: input.progress_percent, + updated_at_micros: input.updated_at_micros, + error_message: input.error_message, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .finalize_visual_novel_agent_message_turn_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn compile_visual_novel_work_profile( + &self, + input: VisualNovelWorkCompileRecordInput, + ) -> Result { + let procedure_input = VisualNovelWorkCompileInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + work_id: input.work_id, + author_display_name: input.author_display_name, + work_title: input.work_title, + work_description: input.work_description, + tags_json: input.tags_json, + cover_image_src: input.cover_image_src, + compiled_at_micros: input.compiled_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().compile_visual_novel_work_profile_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn update_visual_novel_work( + &self, + input: VisualNovelWorkUpdateRecordInput, + ) -> Result { + let procedure_input = VisualNovelWorkUpdateInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + work_title: input.work_title, + work_description: input.work_description, + tags_json: input.tags_json, + cover_image_src: input.cover_image_src, + source_asset_ids_json: input.source_asset_ids_json, + draft_json: input.draft_json, + publish_ready: input.publish_ready, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().update_visual_novel_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn publish_visual_novel_work( + &self, + profile_id: String, + owner_user_id: String, + published_at_micros: i64, + ) -> Result { + let procedure_input = VisualNovelWorkPublishInput { + profile_id, + owner_user_id, + published_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().publish_visual_novel_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn list_visual_novel_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + self.list_visual_novel_works_with_input(VisualNovelWorksListInput { + owner_user_id, + published_only: false, + }) + .await + } + + pub async fn list_visual_novel_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.list_visual_novel_works_with_input(VisualNovelWorksListInput { + // 中文注释:公开列表只依赖 published_only,owner_user_id 用固定值满足 procedure 输入契约。 + owner_user_id: "visual-novel-public-gallery".to_string(), + published_only: true, + }) + .await + } + + async fn list_visual_novel_works_with_input( + &self, + procedure_input: VisualNovelWorksListInput, + ) -> Result, SpacetimeClientError> { + self.call_after_connect(move |connection, sender| { + connection.procedures().list_visual_novel_works_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn get_visual_novel_work_detail( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = VisualNovelWorkGetInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().get_visual_novel_work_detail_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn delete_visual_novel_work( + &self, + profile_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = VisualNovelWorkDeleteInput { + profile_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().delete_visual_novel_work_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_works_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn start_visual_novel_run( + &self, + input: VisualNovelRunStartRecordInput, + ) -> Result { + let procedure_input = VisualNovelRunStartInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + mode: input.mode, + snapshot_json: input.snapshot_json, + started_at_micros: input.started_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .start_visual_novel_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_visual_novel_run( + &self, + run_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = VisualNovelRunGetInput { + run_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .get_visual_novel_run_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_run_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn upsert_visual_novel_run_snapshot( + &self, + input: VisualNovelRunSnapshotRecordInput, + ) -> Result { + let procedure_input = VisualNovelRunSnapshotUpsertInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + status: input.status, + current_scene_id: input.current_scene_id, + current_phase_id: input.current_phase_id, + visible_character_ids_json: input.visible_character_ids_json, + flags_json: input.flags_json, + metrics_json: input.metrics_json, + available_choices_json: input.available_choices_json, + text_mode_enabled: input.text_mode_enabled, + snapshot_json: input.snapshot_json, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().upsert_visual_novel_run_snapshot_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_run_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn append_visual_novel_runtime_history_entry( + &self, + input: VisualNovelHistoryEntryRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = VisualNovelRuntimeHistoryAppendInput { + entry_id: input.entry_id, + run_id: input.run_id, + owner_user_id: input.owner_user_id, + turn_index: input.turn_index, + source: input.source, + action_text: input.action_text, + steps_json: input.steps_json, + snapshot_before_hash: input.snapshot_before_hash, + snapshot_after_hash: input.snapshot_after_hash, + created_at_micros: input.created_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .append_visual_novel_runtime_history_entry_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_history_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_visual_novel_runtime_history( + &self, + run_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = VisualNovelRuntimeHistoryListInput { + run_id, + owner_user_id, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().list_visual_novel_runtime_history_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_history_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + + pub async fn record_visual_novel_runtime_event( + &self, + input: crate::mapper::VisualNovelRuntimeEventRecordInput, + ) -> Result { + let procedure_input = crate::module_bindings::VisualNovelRuntimeEventRecordInput { + event_id: input.event_id, + run_id: input.run_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + event_kind: input.event_kind, + client_event_id: input.client_event_id, + history_entry_id: input.history_entry_id, + payload_json: input.payload_json, + occurred_at_micros: input.occurred_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().record_visual_novel_runtime_event_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_visual_novel_runtime_event_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 9d9da19ce..2f5c64ca2 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -33,6 +33,7 @@ mod migration; mod puzzle; mod runtime; mod square_hole; +mod visual_novel; pub use ai::*; pub use asset_metadata::*; @@ -46,3 +47,4 @@ pub use match3d::*; pub use migration::*; pub use runtime::*; pub use square_hole::*; +pub use visual_novel::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index b1a82e750..4e4a4374a 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -17,6 +17,10 @@ use crate::square_hole::tables::{ square_hole_agent_message, square_hole_agent_session, square_hole_runtime_run, square_hole_work_profile, }; +use crate::{ + visual_novel_agent_message, visual_novel_agent_session, visual_novel_runtime_event, + visual_novel_runtime_history_entry, visual_novel_runtime_run, visual_novel_work_profile, +}; const MIGRATION_SCHEMA_VERSION: u32 = 1; const MIGRATION_MAX_TABLE_NAME_LEN: usize = 96; @@ -216,6 +220,12 @@ macro_rules! migration_tables { 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, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index a7e60fb92..80d257026 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1065,6 +1065,7 @@ fn select_puzzle_cover_image_tx( level_id: target_level.level_id, level_name: target_level.level_name, picture_description: target_level.picture_description, + picture_reference: target_level.picture_reference, candidates: selected_level_draft.candidates, selected_candidate_id: selected_level_draft.selected_candidate_id, cover_image_src: selected_level_draft.cover_image_src, @@ -2316,6 +2317,7 @@ fn build_profile_levels_from_row( level_id: "puzzle-level-1".to_string(), level_name: row.level_name.clone(), picture_description: row.summary.clone(), + picture_reference: None, candidates: Vec::new(), selected_candidate_id: None, cover_image_src: row.cover_image_src.clone(), @@ -3318,6 +3320,7 @@ mod tests { .first() .map(|level| level.picture_description.clone()) .unwrap_or_default(), + picture_reference: None, candidates: candidates.clone(), selected_candidate_id: None, cover_image_src: None, diff --git a/server-rs/crates/spacetime-module/src/visual_novel.rs b/server-rs/crates/spacetime-module/src/visual_novel.rs new file mode 100644 index 000000000..d24032c57 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/visual_novel.rs @@ -0,0 +1,1899 @@ +use crate::*; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub const VISUAL_NOVEL_SOURCE_IDEA: &str = "idea"; +pub const VISUAL_NOVEL_SOURCE_DOCUMENT: &str = "document"; +pub const VISUAL_NOVEL_SOURCE_BLANK: &str = "blank"; + +pub const VISUAL_NOVEL_AGENT_STATUS_COLLECTING: &str = "collecting"; +pub const VISUAL_NOVEL_AGENT_STATUS_DRAFTING: &str = "drafting"; +pub const VISUAL_NOVEL_AGENT_STATUS_READY: &str = "ready"; +pub const VISUAL_NOVEL_AGENT_STATUS_FAILED: &str = "failed"; + +pub const VISUAL_NOVEL_ROLE_USER: &str = "user"; +pub const VISUAL_NOVEL_ROLE_ASSISTANT: &str = "assistant"; +pub const VISUAL_NOVEL_KIND_CHAT: &str = "chat"; + +pub const VISUAL_NOVEL_PUBLICATION_DRAFT: &str = "draft"; +pub const VISUAL_NOVEL_PUBLICATION_PUBLISHED: &str = "published"; + +pub const VISUAL_NOVEL_RUN_MODE_TEST: &str = "test"; +pub const VISUAL_NOVEL_RUN_MODE_PLAY: &str = "play"; +pub const VISUAL_NOVEL_RUN_STATUS_ACTIVE: &str = "active"; +pub const VISUAL_NOVEL_RUN_STATUS_COMPLETED: &str = "completed"; +pub const VISUAL_NOVEL_RUN_STATUS_FAILED: &str = "failed"; + +pub const VISUAL_NOVEL_HISTORY_SOURCE_PLAYER: &str = "player"; +pub const VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT: &str = "assistant"; +pub const VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM: &str = "system"; + +/// 视觉小说创作 session 主表。 +/// +/// 中文注释:复杂底稿使用 JSON 保存,表字段只保留查询、归属和阶段状态。 +#[spacetimedb::table( + accessor = visual_novel_agent_session, + index(accessor = by_visual_novel_agent_session_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct VisualNovelAgentSessionRow { + #[primary_key] + pub(crate) session_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_mode: String, + pub(crate) status: String, + pub(crate) seed_text: String, + pub(crate) source_asset_ids_json: String, + pub(crate) current_turn: u32, + pub(crate) progress_percent: u32, + pub(crate) draft_json: String, + pub(crate) pending_action_json: String, + pub(crate) last_assistant_reply: String, + pub(crate) published_profile_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +/// 视觉小说创作消息表。 +#[spacetimedb::table( + accessor = visual_novel_agent_message, + index(accessor = by_visual_novel_agent_message_session_id, btree(columns = [session_id])) +)] +pub struct VisualNovelAgentMessageRow { + #[primary_key] + pub(crate) message_id: String, + pub(crate) session_id: String, + pub(crate) role: String, + pub(crate) kind: String, + pub(crate) text: String, + pub(crate) created_at: Timestamp, +} + +/// 视觉小说作品草稿 / 发布 profile 表。 +#[spacetimedb::table( + accessor = visual_novel_work_profile, + index(accessor = by_visual_novel_work_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_visual_novel_work_publication_status, btree(columns = [publication_status])) +)] +pub struct VisualNovelWorkProfileRow { + #[primary_key] + pub(crate) profile_id: String, + pub(crate) work_id: String, + pub(crate) owner_user_id: String, + pub(crate) source_session_id: String, + pub(crate) author_display_name: String, + pub(crate) work_title: String, + pub(crate) work_description: String, + pub(crate) tags_json: String, + pub(crate) cover_image_src: String, + pub(crate) source_asset_ids_json: String, + pub(crate) draft_json: String, + pub(crate) publication_status: String, + pub(crate) publish_ready: bool, + pub(crate) play_count: u32, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, + pub(crate) published_at: Option, +} + +/// 视觉小说运行态 run 表。 +#[spacetimedb::table( + accessor = visual_novel_runtime_run, + index(accessor = by_visual_novel_run_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_visual_novel_run_profile_id, btree(columns = [profile_id])) +)] +pub struct VisualNovelRuntimeRunRow { + #[primary_key] + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) mode: String, + pub(crate) status: String, + pub(crate) current_scene_id: String, + pub(crate) current_phase_id: String, + pub(crate) visible_character_ids_json: String, + pub(crate) flags_json: String, + pub(crate) metrics_json: String, + pub(crate) available_choices_json: String, + pub(crate) text_mode_enabled: bool, + pub(crate) snapshot_json: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + +/// 视觉小说运行历史表。 +/// +/// 中文注释:历史只保存可重生成所需的 step 与快照哈希,不承担外部 TXT 播放片段能力。 +#[spacetimedb::table( + accessor = visual_novel_runtime_history_entry, + index(accessor = by_visual_novel_history_run_id, btree(columns = [run_id])), + index(accessor = by_visual_novel_history_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct VisualNovelRuntimeHistoryEntryRow { + #[primary_key] + pub(crate) entry_id: String, + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) turn_index: u32, + pub(crate) source: String, + pub(crate) action_text: String, + pub(crate) steps_json: String, + pub(crate) snapshot_before_hash: String, + pub(crate) snapshot_after_hash: String, + pub(crate) created_at: Timestamp, +} + +/// 视觉小说运行时审计事件表。 +/// +/// 中文注释:该表只记录 run 过程事实,不能作为旧 TXT 播放包或分享片段数据源使用。 +#[spacetimedb::table( + accessor = visual_novel_runtime_event, + public, + event, + index(accessor = by_visual_novel_runtime_event_run_id, btree(columns = [run_id])), + index(accessor = by_visual_novel_runtime_event_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct VisualNovelRuntimeEvent { + #[primary_key] + pub(crate) event_id: String, + pub(crate) run_id: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) event_kind: String, + pub(crate) client_event_id: String, + pub(crate) history_entry_id: String, + pub(crate) payload_json: String, + pub(crate) occurred_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelAgentSessionCreateInput { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub seed_text: String, + pub source_asset_ids_json: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub draft_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelAgentSessionGetInput { + pub session_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelAgentMessageSubmitInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelAgentMessageFinalizeInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub draft_json: Option, + pub pending_action_json: Option, + pub status: String, + pub progress_percent: u32, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkCompileInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub work_id: Option, + pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkUpdateInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub tags_json: String, + pub cover_image_src: Option, + pub source_asset_ids_json: String, + pub draft_json: String, + pub publish_ready: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkPublishInput { + pub profile_id: String, + pub owner_user_id: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorksListInput { + pub owner_user_id: String, + pub published_only: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkGetInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkDeleteInput { + pub profile_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRunStartInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub snapshot_json: Option, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRunGetInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRunSnapshotUpsertInput { + pub run_id: String, + pub owner_user_id: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids_json: String, + pub flags_json: String, + pub metrics_json: String, + pub available_choices_json: String, + pub text_mode_enabled: bool, + pub snapshot_json: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRuntimeHistoryAppendInput { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps_json: String, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRuntimeHistoryListInput { + pub run_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRuntimeEventRecordInput { + pub event_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload_json: String, + pub occurred_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelAgentSessionProcedureResult { + pub ok: bool, + pub session_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorkProcedureResult { + pub ok: bool, + pub work_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelWorksProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRunProcedureResult { + pub ok: bool, + pub run_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelHistoryProcedureResult { + pub ok: bool, + pub items_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct VisualNovelRuntimeEventProcedureResult { + pub ok: bool, + pub event_json: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelAgentMessageSnapshot { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelAgentSessionSnapshot { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub status: String, + pub seed_text: String, + pub source_asset_ids: Vec, + pub current_turn: u32, + pub progress_percent: u32, + pub messages: Vec, + pub draft: Option, + pub pending_action: Option, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelWorkSnapshot { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub tags: Vec, + pub cover_image_src: Option, + pub source_asset_ids: Vec, + pub draft: JsonValue, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub published_at_micros: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRuntimeHistoryEntrySnapshot { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps: JsonValue, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRunSnapshot { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids: Vec, + pub flags: JsonValue, + pub metrics: JsonValue, + pub history: Vec, + pub available_choices: JsonValue, + pub text_mode_enabled: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VisualNovelRuntimeEventSnapshot { + pub event_id: String, + pub run_id: Option, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload: JsonValue, + pub occurred_at_micros: i64, +} + +#[spacetimedb::procedure] +pub fn create_visual_novel_agent_session( + ctx: &mut ProcedureContext, + input: VisualNovelAgentSessionCreateInput, +) -> VisualNovelAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| create_visual_novel_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_visual_novel_agent_session( + ctx: &mut ProcedureContext, + input: VisualNovelAgentSessionGetInput, +) -> VisualNovelAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| get_visual_novel_agent_session_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn submit_visual_novel_agent_message( + ctx: &mut ProcedureContext, + input: VisualNovelAgentMessageSubmitInput, +) -> VisualNovelAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| submit_visual_novel_agent_message_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn finalize_visual_novel_agent_message_turn( + ctx: &mut ProcedureContext, + input: VisualNovelAgentMessageFinalizeInput, +) -> VisualNovelAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| finalize_visual_novel_agent_message_turn_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn compile_visual_novel_work_profile( + ctx: &mut ProcedureContext, + input: VisualNovelWorkCompileInput, +) -> VisualNovelAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| compile_visual_novel_work_profile_tx(tx, input.clone())) { + Ok(session) => session_result(session), + Err(message) => session_error(message), + } +} + +#[spacetimedb::procedure] +pub fn update_visual_novel_work( + ctx: &mut ProcedureContext, + input: VisualNovelWorkUpdateInput, +) -> VisualNovelWorkProcedureResult { + match ctx.try_with_tx(|tx| update_visual_novel_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn publish_visual_novel_work( + ctx: &mut ProcedureContext, + input: VisualNovelWorkPublishInput, +) -> VisualNovelWorkProcedureResult { + match ctx.try_with_tx(|tx| publish_visual_novel_work_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_visual_novel_works( + ctx: &mut ProcedureContext, + input: VisualNovelWorksListInput, +) -> VisualNovelWorksProcedureResult { + match ctx.try_with_tx(|tx| list_visual_novel_works_tx(tx, input.clone())) { + Ok(items) => VisualNovelWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => VisualNovelWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_visual_novel_work_detail( + ctx: &mut ProcedureContext, + input: VisualNovelWorkGetInput, +) -> VisualNovelWorkProcedureResult { + match ctx.try_with_tx(|tx| get_visual_novel_work_detail_tx(tx, input.clone())) { + Ok(work) => work_result(work), + Err(message) => work_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_visual_novel_work( + ctx: &mut ProcedureContext, + input: VisualNovelWorkDeleteInput, +) -> VisualNovelWorksProcedureResult { + match ctx.try_with_tx(|tx| delete_visual_novel_work_tx(tx, input.clone())) { + Ok(items) => VisualNovelWorksProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => VisualNovelWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn start_visual_novel_run( + ctx: &mut ProcedureContext, + input: VisualNovelRunStartInput, +) -> VisualNovelRunProcedureResult { + match ctx.try_with_tx(|tx| start_visual_novel_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn get_visual_novel_run( + ctx: &mut ProcedureContext, + input: VisualNovelRunGetInput, +) -> VisualNovelRunProcedureResult { + match ctx.try_with_tx(|tx| get_visual_novel_run_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn upsert_visual_novel_run_snapshot( + ctx: &mut ProcedureContext, + input: VisualNovelRunSnapshotUpsertInput, +) -> VisualNovelRunProcedureResult { + match ctx.try_with_tx(|tx| upsert_visual_novel_run_snapshot_tx(tx, input.clone())) { + Ok(run) => run_result(run), + Err(message) => run_error(message), + } +} + +#[spacetimedb::procedure] +pub fn append_visual_novel_runtime_history_entry( + ctx: &mut ProcedureContext, + input: VisualNovelRuntimeHistoryAppendInput, +) -> VisualNovelHistoryProcedureResult { + match ctx.try_with_tx(|tx| append_visual_novel_runtime_history_entry_tx(tx, input.clone())) { + Ok(items) => VisualNovelHistoryProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => VisualNovelHistoryProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_visual_novel_runtime_history( + ctx: &mut ProcedureContext, + input: VisualNovelRuntimeHistoryListInput, +) -> VisualNovelHistoryProcedureResult { + match ctx.try_with_tx(|tx| list_visual_novel_runtime_history_tx(tx, input.clone())) { + Ok(items) => VisualNovelHistoryProcedureResult { + ok: true, + items_json: Some(to_json_string(&items)), + error_message: None, + }, + Err(message) => VisualNovelHistoryProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn record_visual_novel_runtime_event( + ctx: &mut ProcedureContext, + input: VisualNovelRuntimeEventRecordInput, +) -> VisualNovelRuntimeEventProcedureResult { + match ctx.try_with_tx(|tx| record_visual_novel_runtime_event_tx(tx, input.clone())) { + Ok(event) => VisualNovelRuntimeEventProcedureResult { + ok: true, + event_json: Some(to_json_string(&event)), + error_message: None, + }, + Err(message) => VisualNovelRuntimeEventProcedureResult { + ok: false, + event_json: None, + error_message: Some(message), + }, + } +} + +fn create_visual_novel_agent_session_tx( + ctx: &ReducerContext, + input: VisualNovelAgentSessionCreateInput, +) -> Result { + require_non_empty(&input.session_id, "visual_novel session_id")?; + require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?; + require_non_empty(&input.welcome_message_id, "visual_novel welcome_message_id")?; + if ctx + .db + .visual_novel_agent_session() + .session_id() + .find(&input.session_id) + .is_some() + { + return Err("visual_novel_agent_session.session_id 已存在".to_string()); + } + if ctx + .db + .visual_novel_agent_message() + .message_id() + .find(&input.welcome_message_id) + .is_some() + { + return Err("visual_novel_agent_message.message_id 已存在".to_string()); + } + + let source_asset_ids = parse_string_vec_or_empty(&input.source_asset_ids_json)?; + let draft_json = input + .draft_json + .as_deref() + .map(parse_json_value) + .transpose()? + .map(|value| to_json_string(&value)) + .unwrap_or_default(); + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let welcome = input.welcome_message_text.trim().to_string(); + + ctx.db + .visual_novel_agent_session() + .insert(VisualNovelAgentSessionRow { + session_id: input.session_id.clone(), + owner_user_id: input.owner_user_id.clone(), + source_mode: normalize_source_mode(&input.source_mode).to_string(), + status: VISUAL_NOVEL_AGENT_STATUS_COLLECTING.to_string(), + seed_text: input.seed_text.trim().to_string(), + source_asset_ids_json: to_json_string(&source_asset_ids), + current_turn: 0, + progress_percent: 0, + draft_json, + pending_action_json: String::new(), + last_assistant_reply: welcome.clone(), + published_profile_id: String::new(), + created_at, + updated_at: created_at, + }); + ctx.db + .visual_novel_agent_message() + .insert(VisualNovelAgentMessageRow { + message_id: input.welcome_message_id, + session_id: input.session_id.clone(), + role: VISUAL_NOVEL_ROLE_ASSISTANT.to_string(), + kind: VISUAL_NOVEL_KIND_CHAT.to_string(), + text: welcome, + created_at, + }); + + get_visual_novel_agent_session_tx( + ctx, + VisualNovelAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn get_visual_novel_agent_session_tx( + ctx: &ReducerContext, + input: VisualNovelAgentSessionGetInput, +) -> Result { + let row = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + build_session_snapshot(ctx, &row) +} + +fn submit_visual_novel_agent_message_tx( + ctx: &ReducerContext, + input: VisualNovelAgentMessageSubmitInput, +) -> Result { + require_non_empty(&input.user_message_id, "visual_novel user_message_id")?; + require_non_empty(&input.user_message_text, "visual_novel user_message_text")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + if ctx + .db + .visual_novel_agent_message() + .message_id() + .find(&input.user_message_id) + .is_some() + { + return Err("visual_novel_agent_message.user_message_id 已存在".to_string()); + } + + let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros); + ctx.db + .visual_novel_agent_message() + .insert(VisualNovelAgentMessageRow { + message_id: input.user_message_id, + session_id: input.session_id.clone(), + role: VISUAL_NOVEL_ROLE_USER.to_string(), + kind: VISUAL_NOVEL_KIND_CHAT.to_string(), + text: input.user_message_text.trim().to_string(), + created_at: submitted_at, + }); + replace_session( + ctx, + &session, + VisualNovelAgentSessionRow { + status: VISUAL_NOVEL_AGENT_STATUS_DRAFTING.to_string(), + updated_at: submitted_at, + ..clone_session(&session) + }, + ); + + get_visual_novel_agent_session_tx( + ctx, + VisualNovelAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn finalize_visual_novel_agent_message_turn_tx( + ctx: &ReducerContext, + input: VisualNovelAgentMessageFinalizeInput, +) -> Result { + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + if let Some(message) = input + .error_message + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + replace_session( + ctx, + &session, + VisualNovelAgentSessionRow { + status: VISUAL_NOVEL_AGENT_STATUS_FAILED.to_string(), + updated_at, + ..clone_session(&session) + }, + ); + return Err(message.to_string()); + } + + let draft_json = input + .draft_json + .as_deref() + .map(parse_json_value) + .transpose()? + .map(|value| to_json_string(&value)) + .unwrap_or_else(|| session.draft_json.clone()); + let pending_action_json = input + .pending_action_json + .as_deref() + .map(parse_json_value) + .transpose()? + .map(|value| to_json_string(&value)) + .unwrap_or_else(|| session.pending_action_json.clone()); + let assistant_text = input + .assistant_reply_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&session.last_assistant_reply) + .to_string(); + + if let Some(message_id) = input + .assistant_message_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if ctx + .db + .visual_novel_agent_message() + .message_id() + .find(&message_id.to_string()) + .is_some() + { + return Err("visual_novel_agent_message.assistant_message_id 已存在".to_string()); + } + ctx.db + .visual_novel_agent_message() + .insert(VisualNovelAgentMessageRow { + message_id: message_id.to_string(), + session_id: input.session_id.clone(), + role: VISUAL_NOVEL_ROLE_ASSISTANT.to_string(), + kind: VISUAL_NOVEL_KIND_CHAT.to_string(), + text: assistant_text.clone(), + created_at: updated_at, + }); + } + + replace_session( + ctx, + &session, + VisualNovelAgentSessionRow { + current_turn: session.current_turn.saturating_add(1), + progress_percent: input.progress_percent.min(100), + status: normalize_agent_status(&input.status).to_string(), + draft_json, + pending_action_json, + last_assistant_reply: assistant_text, + updated_at, + ..clone_session(&session) + }, + ); + + get_visual_novel_agent_session_tx( + ctx, + VisualNovelAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn compile_visual_novel_work_profile_tx( + ctx: &ReducerContext, + input: VisualNovelWorkCompileInput, +) -> Result { + require_non_empty(&input.profile_id, "visual_novel profile_id")?; + let session = find_owned_session(ctx, &input.session_id, &input.owner_user_id)?; + let draft = parse_optional_json_value(&session.draft_json)? + .ok_or_else(|| "visual_novel session 尚未生成底稿".to_string())?; + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let tags = input + .tags_json + .as_deref() + .map(parse_string_vec) + .transpose()? + .filter(|items| !items.is_empty()) + .unwrap_or_else(|| draft_string_array(&draft, "workTags")); + let source_asset_ids = parse_string_vec_or_empty(&session.source_asset_ids_json)?; + let work_title = clean_optional(&input.work_title) + .or_else(|| draft_string_field(&draft, "workTitle")) + .unwrap_or_else(|| "未命名视觉小说".to_string()); + let work_description = clean_optional(&input.work_description) + .or_else(|| draft_string_field(&draft, "workDescription")) + .unwrap_or_else(|| "视觉小说草稿".to_string()); + let cover_image_src = clean_optional(&input.cover_image_src) + .or_else(|| draft_string_field(&draft, "coverImageSrc")) + .unwrap_or_default(); + let publish_ready = draft_bool_field(&draft, "publishReady"); + + let work = VisualNovelWorkProfileRow { + profile_id: input.profile_id.clone(), + work_id: clean_optional(&input.work_id).unwrap_or_else(|| input.profile_id.clone()), + owner_user_id: input.owner_user_id.clone(), + source_session_id: input.session_id.clone(), + author_display_name: clean_string(&input.author_display_name, "百梦主"), + work_title, + work_description, + tags_json: to_json_string(&normalize_tags(tags)), + cover_image_src, + source_asset_ids_json: to_json_string(&source_asset_ids), + draft_json: to_json_string(&draft), + publication_status: VISUAL_NOVEL_PUBLICATION_DRAFT.to_string(), + publish_ready, + play_count: 0, + created_at: compiled_at, + updated_at: compiled_at, + published_at: None, + }; + upsert_work(ctx, work); + replace_session( + ctx, + &session, + VisualNovelAgentSessionRow { + progress_percent: 80, + status: VISUAL_NOVEL_AGENT_STATUS_READY.to_string(), + published_profile_id: input.profile_id, + last_assistant_reply: "视觉小说底稿已编译为作品草稿。".to_string(), + updated_at: compiled_at, + ..clone_session(&session) + }, + ); + + get_visual_novel_agent_session_tx( + ctx, + VisualNovelAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn update_visual_novel_work_tx( + ctx: &ReducerContext, + input: VisualNovelWorkUpdateInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + let tags = parse_string_vec(&input.tags_json)?; + let source_asset_ids = parse_string_vec_or_empty(&input.source_asset_ids_json)?; + let draft = parse_json_value(&input.draft_json)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let next = VisualNovelWorkProfileRow { + work_title: clean_string(&input.work_title, "未命名视觉小说"), + work_description: clean_string(&input.work_description, "视觉小说草稿"), + tags_json: to_json_string(&normalize_tags(tags)), + cover_image_src: clean_optional(&input.cover_image_src).unwrap_or_default(), + source_asset_ids_json: to_json_string(&source_asset_ids), + draft_json: to_json_string(&draft), + publish_ready: input.publish_ready, + updated_at, + ..clone_work(¤t) + }; + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn publish_visual_novel_work_tx( + ctx: &ReducerContext, + input: VisualNovelWorkPublishInput, +) -> Result { + let current = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + validate_publishable_work(¤t)?; + let published_at = Timestamp::from_micros_since_unix_epoch(input.published_at_micros); + let next = VisualNovelWorkProfileRow { + publication_status: VISUAL_NOVEL_PUBLICATION_PUBLISHED.to_string(), + updated_at: published_at, + published_at: Some(published_at), + ..clone_work(¤t) + }; + if !next.source_session_id.is_empty() { + if let Some(session) = ctx + .db + .visual_novel_agent_session() + .session_id() + .find(&next.source_session_id) + .filter(|row| row.owner_user_id == input.owner_user_id) + { + replace_session( + ctx, + &session, + VisualNovelAgentSessionRow { + progress_percent: 100, + status: VISUAL_NOVEL_AGENT_STATUS_READY.to_string(), + published_profile_id: next.profile_id.clone(), + updated_at: published_at, + ..clone_session(&session) + }, + ); + } + } + let snapshot = build_work_snapshot(&next)?; + replace_work(ctx, ¤t, next); + Ok(snapshot) +} + +fn list_visual_novel_works_tx( + ctx: &ReducerContext, + input: VisualNovelWorksListInput, +) -> Result, String> { + let mut items = ctx + .db + .visual_novel_work_profile() + .iter() + .filter(|row| { + if input.published_only { + row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + } else { + row.owner_user_id == input.owner_user_id + } + }) + .map(|row| build_work_snapshot(&row)) + .collect::, _>>()?; + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.profile_id.cmp(&right.profile_id)) + }); + Ok(items) +} + +fn get_visual_novel_work_detail_tx( + ctx: &ReducerContext, + input: VisualNovelWorkGetInput, +) -> Result { + let row = ctx + .db + .visual_novel_work_profile() + .profile_id() + .find(&input.profile_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id + || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "visual_novel_work_profile 不存在".to_string())?; + build_work_snapshot(&row) +} + +fn delete_visual_novel_work_tx( + ctx: &ReducerContext, + input: VisualNovelWorkDeleteInput, +) -> Result, String> { + let work = find_owned_work(ctx, &input.profile_id, &input.owner_user_id)?; + ctx.db + .visual_novel_work_profile() + .profile_id() + .delete(&work.profile_id); + for run in ctx + .db + .visual_novel_runtime_run() + .iter() + .filter(|row| { + row.profile_id == input.profile_id && row.owner_user_id == input.owner_user_id + }) + .collect::>() + { + delete_run_children(ctx, &run.run_id, &input.owner_user_id); + ctx.db + .visual_novel_runtime_run() + .run_id() + .delete(&run.run_id); + } + list_visual_novel_works_tx( + ctx, + VisualNovelWorksListInput { + owner_user_id: input.owner_user_id, + published_only: false, + }, + ) +} + +fn start_visual_novel_run_tx( + ctx: &ReducerContext, + input: VisualNovelRunStartInput, +) -> Result { + require_non_empty(&input.run_id, "visual_novel run_id")?; + if ctx + .db + .visual_novel_runtime_run() + .run_id() + .find(&input.run_id) + .is_some() + { + return Err("visual_novel_runtime_run.run_id 已存在".to_string()); + } + let work = find_readable_work(ctx, &input.profile_id, &input.owner_user_id)?; + let started_at = Timestamp::from_micros_since_unix_epoch(input.started_at_micros); + let draft = parse_json_value(&work.draft_json)?; + let current_scene_id = draft_path_string(&draft, &["opening", "sceneId"]).unwrap_or_default(); + let current_phase_id = draft + .get("storyPhases") + .and_then(JsonValue::as_array) + .and_then(|items| items.first()) + .and_then(|phase| draft_path_string(phase, &["phaseId"])) + .unwrap_or_default(); + let available_choices = draft + .get("opening") + .and_then(|opening| opening.get("initialChoices")) + .cloned() + .unwrap_or_else(|| JsonValue::Array(Vec::new())); + let text_mode_enabled = draft_path_bool(&draft, &["runtimeConfig", "textModeEnabled"]) + .unwrap_or(true) + && draft_path_bool(&draft, &["runtimeConfig", "defaultTextMode"]).unwrap_or(false); + + let row = VisualNovelRuntimeRunRow { + run_id: input.run_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: input.profile_id.clone(), + mode: normalize_run_mode(&input.mode).to_string(), + status: VISUAL_NOVEL_RUN_STATUS_ACTIVE.to_string(), + current_scene_id, + current_phase_id, + visible_character_ids_json: "[]".to_string(), + flags_json: "{}".to_string(), + metrics_json: "{}".to_string(), + available_choices_json: to_json_string(&available_choices), + text_mode_enabled, + snapshot_json: input.snapshot_json.unwrap_or_default(), + created_at: started_at, + updated_at: started_at, + }; + let snapshot = build_run_snapshot(ctx, &row)?; + let row = VisualNovelRuntimeRunRow { + snapshot_json: to_json_string(&snapshot), + ..row + }; + ctx.db.visual_novel_runtime_run().insert(row); + + Ok(snapshot) +} + +fn get_visual_novel_run_tx( + ctx: &ReducerContext, + input: VisualNovelRunGetInput, +) -> Result { + let row = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + build_run_snapshot(ctx, &row) +} + +fn upsert_visual_novel_run_snapshot_tx( + ctx: &ReducerContext, + input: VisualNovelRunSnapshotUpsertInput, +) -> Result { + let current = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + parse_string_vec_or_empty(&input.visible_character_ids_json)?; + parse_json_value(&input.flags_json)?; + parse_json_value(&input.metrics_json)?; + parse_json_value(&input.available_choices_json)?; + if let Some(snapshot_json) = &input.snapshot_json { + parse_json_value(snapshot_json)?; + } + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let next = VisualNovelRuntimeRunRow { + status: normalize_run_status(&input.status).to_string(), + current_scene_id: clean_optional(&input.current_scene_id).unwrap_or_default(), + current_phase_id: clean_optional(&input.current_phase_id).unwrap_or_default(), + visible_character_ids_json: input.visible_character_ids_json, + flags_json: input.flags_json, + metrics_json: input.metrics_json, + available_choices_json: input.available_choices_json, + text_mode_enabled: input.text_mode_enabled, + snapshot_json: input.snapshot_json.unwrap_or_default(), + updated_at, + ..clone_run(¤t) + }; + let snapshot = build_run_snapshot(ctx, &next)?; + replace_run(ctx, ¤t, next); + Ok(snapshot) +} + +fn append_visual_novel_runtime_history_entry_tx( + ctx: &ReducerContext, + input: VisualNovelRuntimeHistoryAppendInput, +) -> Result, String> { + require_non_empty(&input.entry_id, "visual_novel history entry_id")?; + let run = find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + if ctx + .db + .visual_novel_runtime_history_entry() + .entry_id() + .find(&input.entry_id) + .is_some() + { + return Err("visual_novel_runtime_history_entry.entry_id 已存在".to_string()); + } + parse_json_value(&input.steps_json)?; + let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + ctx.db + .visual_novel_runtime_history_entry() + .insert(VisualNovelRuntimeHistoryEntryRow { + entry_id: input.entry_id, + run_id: input.run_id.clone(), + owner_user_id: input.owner_user_id.clone(), + profile_id: run.profile_id, + turn_index: input.turn_index, + source: normalize_history_source(&input.source).to_string(), + action_text: clean_optional(&input.action_text).unwrap_or_default(), + steps_json: input.steps_json, + snapshot_before_hash: clean_optional(&input.snapshot_before_hash).unwrap_or_default(), + snapshot_after_hash: clean_optional(&input.snapshot_after_hash).unwrap_or_default(), + created_at, + }); + list_visual_novel_runtime_history_tx( + ctx, + VisualNovelRuntimeHistoryListInput { + run_id: input.run_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn list_visual_novel_runtime_history_tx( + ctx: &ReducerContext, + input: VisualNovelRuntimeHistoryListInput, +) -> Result, String> { + find_owned_run(ctx, &input.run_id, &input.owner_user_id)?; + build_history_snapshots(ctx, &input.run_id, &input.owner_user_id) +} + +fn record_visual_novel_runtime_event_tx( + ctx: &ReducerContext, + input: VisualNovelRuntimeEventRecordInput, +) -> Result { + require_non_empty(&input.event_id, "visual_novel event_id")?; + require_non_empty(&input.owner_user_id, "visual_novel owner_user_id")?; + parse_json_value(&input.payload_json)?; + if ctx + .db + .visual_novel_runtime_event() + .event_id() + .find(&input.event_id) + .is_some() + { + return Err("visual_novel_runtime_event.event_id 已存在".to_string()); + } + let run = clean_string(&input.run_id, ""); + let profile_id = clean_optional(&input.profile_id) + .or_else(|| { + ctx.db + .visual_novel_runtime_run() + .run_id() + .find(&run) + .filter(|row| row.owner_user_id == input.owner_user_id) + .map(|row| row.profile_id) + }) + .unwrap_or_default(); + let occurred_at = Timestamp::from_micros_since_unix_epoch(input.occurred_at_micros); + let row = VisualNovelRuntimeEvent { + event_id: input.event_id, + run_id: run, + owner_user_id: input.owner_user_id, + profile_id, + event_kind: clean_string(&input.event_kind, "runtime_event"), + client_event_id: clean_optional(&input.client_event_id).unwrap_or_default(), + history_entry_id: clean_optional(&input.history_entry_id).unwrap_or_default(), + payload_json: input.payload_json, + occurred_at, + }; + let snapshot = build_event_snapshot(&row)?; + ctx.db.visual_novel_runtime_event().insert(row); + Ok(snapshot) +} + +fn find_owned_session( + ctx: &ReducerContext, + session_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(session_id, "visual_novel session_id")?; + require_non_empty(owner_user_id, "visual_novel owner_user_id")?; + ctx.db + .visual_novel_agent_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "visual_novel_agent_session 不存在".to_string()) +} + +fn find_owned_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(profile_id, "visual_novel profile_id")?; + require_non_empty(owner_user_id, "visual_novel owner_user_id")?; + ctx.db + .visual_novel_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "visual_novel_work_profile 不存在".to_string()) +} + +fn find_readable_work( + ctx: &ReducerContext, + profile_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(profile_id, "visual_novel profile_id")?; + ctx.db + .visual_novel_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| { + row.owner_user_id == owner_user_id + || row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED + }) + .ok_or_else(|| "visual_novel_work_profile 不存在".to_string()) +} + +fn find_owned_run( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result { + require_non_empty(run_id, "visual_novel run_id")?; + require_non_empty(owner_user_id, "visual_novel owner_user_id")?; + ctx.db + .visual_novel_runtime_run() + .run_id() + .find(&run_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .ok_or_else(|| "visual_novel_runtime_run 不存在".to_string()) +} + +fn build_session_snapshot( + ctx: &ReducerContext, + row: &VisualNovelAgentSessionRow, +) -> Result { + let mut messages = ctx + .db + .visual_novel_agent_message() + .iter() + .filter(|message| message.session_id == row.session_id) + .map(|message| VisualNovelAgentMessageSnapshot { + message_id: message.message_id, + session_id: message.session_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at_micros: message.created_at.to_micros_since_unix_epoch(), + }) + .collect::>(); + messages.sort_by(|left, right| { + left.created_at_micros + .cmp(&right.created_at_micros) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + + Ok(VisualNovelAgentSessionSnapshot { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_mode: row.source_mode.clone(), + status: row.status.clone(), + seed_text: row.seed_text.clone(), + source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?, + current_turn: row.current_turn, + progress_percent: row.progress_percent, + messages, + draft: parse_optional_json_value(&row.draft_json)?, + pending_action: parse_optional_json_value(&row.pending_action_json)?, + last_assistant_reply: empty_to_none(&row.last_assistant_reply), + published_profile_id: empty_to_none(&row.published_profile_id), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_work_snapshot(row: &VisualNovelWorkProfileRow) -> Result { + Ok(VisualNovelWorkSnapshot { + work_id: row.work_id.clone(), + profile_id: row.profile_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: empty_to_none(&row.source_session_id), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + tags: parse_string_vec_or_empty(&row.tags_json)?, + cover_image_src: empty_to_none(&row.cover_image_src), + source_asset_ids: parse_string_vec_or_empty(&row.source_asset_ids_json)?, + draft: parse_json_value(&row.draft_json)?, + publication_status: row.publication_status.clone(), + publish_ready: row.publish_ready, + play_count: row.play_count, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row + .published_at + .map(|value| value.to_micros_since_unix_epoch()), + }) +} + +fn build_run_snapshot( + ctx: &ReducerContext, + row: &VisualNovelRuntimeRunRow, +) -> Result { + Ok(VisualNovelRunSnapshot { + run_id: row.run_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + mode: row.mode.clone(), + status: row.status.clone(), + current_scene_id: empty_to_none(&row.current_scene_id), + current_phase_id: empty_to_none(&row.current_phase_id), + visible_character_ids: parse_string_vec_or_empty(&row.visible_character_ids_json)?, + flags: parse_json_value_or_object(&row.flags_json)?, + metrics: parse_json_value_or_object(&row.metrics_json)?, + history: build_history_snapshots(ctx, &row.run_id, &row.owner_user_id)?, + available_choices: parse_json_value_or_array(&row.available_choices_json)?, + text_mode_enabled: row.text_mode_enabled, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + }) +} + +fn build_history_snapshots( + ctx: &ReducerContext, + run_id: &str, + owner_user_id: &str, +) -> Result, String> { + let mut items = ctx + .db + .visual_novel_runtime_history_entry() + .iter() + .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) + .map(|row| build_history_snapshot(&row)) + .collect::, _>>()?; + items.sort_by(|left, right| { + left.turn_index + .cmp(&right.turn_index) + .then_with(|| left.created_at_micros.cmp(&right.created_at_micros)) + .then_with(|| left.entry_id.cmp(&right.entry_id)) + }); + Ok(items) +} + +fn build_history_snapshot( + row: &VisualNovelRuntimeHistoryEntryRow, +) -> Result { + Ok(VisualNovelRuntimeHistoryEntrySnapshot { + entry_id: row.entry_id.clone(), + run_id: row.run_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + turn_index: row.turn_index, + source: row.source.clone(), + action_text: empty_to_none(&row.action_text), + steps: parse_json_value_or_array(&row.steps_json)?, + snapshot_before_hash: empty_to_none(&row.snapshot_before_hash), + snapshot_after_hash: empty_to_none(&row.snapshot_after_hash), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + }) +} + +fn build_event_snapshot( + row: &VisualNovelRuntimeEvent, +) -> Result { + Ok(VisualNovelRuntimeEventSnapshot { + event_id: row.event_id.clone(), + run_id: empty_to_none(&row.run_id), + owner_user_id: row.owner_user_id.clone(), + profile_id: empty_to_none(&row.profile_id), + event_kind: row.event_kind.clone(), + client_event_id: empty_to_none(&row.client_event_id), + history_entry_id: empty_to_none(&row.history_entry_id), + payload: parse_json_value_or_object(&row.payload_json)?, + occurred_at_micros: row.occurred_at.to_micros_since_unix_epoch(), + }) +} + +fn upsert_work(ctx: &ReducerContext, work: VisualNovelWorkProfileRow) { + if ctx + .db + .visual_novel_work_profile() + .profile_id() + .find(&work.profile_id) + .is_some() + { + ctx.db + .visual_novel_work_profile() + .profile_id() + .delete(&work.profile_id); + } + ctx.db.visual_novel_work_profile().insert(work); +} + +fn replace_session( + ctx: &ReducerContext, + current: &VisualNovelAgentSessionRow, + next: VisualNovelAgentSessionRow, +) { + ctx.db + .visual_novel_agent_session() + .session_id() + .delete(¤t.session_id); + ctx.db.visual_novel_agent_session().insert(next); +} + +fn replace_work( + ctx: &ReducerContext, + current: &VisualNovelWorkProfileRow, + next: VisualNovelWorkProfileRow, +) { + ctx.db + .visual_novel_work_profile() + .profile_id() + .delete(¤t.profile_id); + ctx.db.visual_novel_work_profile().insert(next); +} + +fn replace_run( + ctx: &ReducerContext, + current: &VisualNovelRuntimeRunRow, + next: VisualNovelRuntimeRunRow, +) { + ctx.db + .visual_novel_runtime_run() + .run_id() + .delete(¤t.run_id); + ctx.db.visual_novel_runtime_run().insert(next); +} + +fn delete_run_children(ctx: &ReducerContext, run_id: &str, owner_user_id: &str) { + for history in ctx + .db + .visual_novel_runtime_history_entry() + .iter() + .filter(|row| row.run_id == run_id && row.owner_user_id == owner_user_id) + .collect::>() + { + ctx.db + .visual_novel_runtime_history_entry() + .entry_id() + .delete(&history.entry_id); + } +} + +fn clone_session(row: &VisualNovelAgentSessionRow) -> VisualNovelAgentSessionRow { + VisualNovelAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_mode: row.source_mode.clone(), + status: row.status.clone(), + seed_text: row.seed_text.clone(), + source_asset_ids_json: row.source_asset_ids_json.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent, + draft_json: row.draft_json.clone(), + pending_action_json: row.pending_action_json.clone(), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn clone_work(row: &VisualNovelWorkProfileRow) -> VisualNovelWorkProfileRow { + VisualNovelWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + tags_json: row.tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + source_asset_ids_json: row.source_asset_ids_json.clone(), + draft_json: row.draft_json.clone(), + publication_status: row.publication_status.clone(), + publish_ready: row.publish_ready, + play_count: row.play_count, + created_at: row.created_at, + updated_at: row.updated_at, + published_at: row.published_at, + } +} + +fn clone_run(row: &VisualNovelRuntimeRunRow) -> VisualNovelRuntimeRunRow { + VisualNovelRuntimeRunRow { + run_id: row.run_id.clone(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + mode: row.mode.clone(), + status: row.status.clone(), + current_scene_id: row.current_scene_id.clone(), + current_phase_id: row.current_phase_id.clone(), + visible_character_ids_json: row.visible_character_ids_json.clone(), + flags_json: row.flags_json.clone(), + metrics_json: row.metrics_json.clone(), + available_choices_json: row.available_choices_json.clone(), + text_mode_enabled: row.text_mode_enabled, + snapshot_json: row.snapshot_json.clone(), + created_at: row.created_at, + updated_at: row.updated_at, + } +} + +fn validate_publishable_work(row: &VisualNovelWorkProfileRow) -> Result<(), String> { + if row.work_title.trim().is_empty() { + return Err("visual_novel 发布需要填写作品标题".to_string()); + } + if row.work_description.trim().is_empty() { + return Err("visual_novel 发布需要填写作品简介".to_string()); + } + if parse_string_vec_or_empty(&row.tags_json)?.is_empty() { + return Err("visual_novel 发布需要至少 1 个标签".to_string()); + } + parse_json_value(&row.draft_json)?; + if !row.publish_ready { + return Err("visual_novel 发布前需要通过校验".to_string()); + } + Ok(()) +} + +fn normalize_source_mode(value: &str) -> &'static str { + match value.trim() { + VISUAL_NOVEL_SOURCE_DOCUMENT => VISUAL_NOVEL_SOURCE_DOCUMENT, + VISUAL_NOVEL_SOURCE_BLANK => VISUAL_NOVEL_SOURCE_BLANK, + _ => VISUAL_NOVEL_SOURCE_IDEA, + } +} + +fn normalize_agent_status(value: &str) -> &'static str { + match value.trim() { + VISUAL_NOVEL_AGENT_STATUS_DRAFTING => VISUAL_NOVEL_AGENT_STATUS_DRAFTING, + VISUAL_NOVEL_AGENT_STATUS_READY => VISUAL_NOVEL_AGENT_STATUS_READY, + VISUAL_NOVEL_AGENT_STATUS_FAILED => VISUAL_NOVEL_AGENT_STATUS_FAILED, + _ => VISUAL_NOVEL_AGENT_STATUS_COLLECTING, + } +} + +fn normalize_run_mode(value: &str) -> &'static str { + match value.trim() { + VISUAL_NOVEL_RUN_MODE_PLAY => VISUAL_NOVEL_RUN_MODE_PLAY, + _ => VISUAL_NOVEL_RUN_MODE_TEST, + } +} + +fn normalize_run_status(value: &str) -> &'static str { + match value.trim() { + VISUAL_NOVEL_RUN_STATUS_COMPLETED => VISUAL_NOVEL_RUN_STATUS_COMPLETED, + VISUAL_NOVEL_RUN_STATUS_FAILED => VISUAL_NOVEL_RUN_STATUS_FAILED, + _ => VISUAL_NOVEL_RUN_STATUS_ACTIVE, + } +} + +fn normalize_history_source(value: &str) -> &'static str { + match value.trim() { + VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT => VISUAL_NOVEL_HISTORY_SOURCE_ASSISTANT, + VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM => VISUAL_NOVEL_HISTORY_SOURCE_SYSTEM, + _ => VISUAL_NOVEL_HISTORY_SOURCE_PLAYER, + } +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|item: &String| item == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 8 { + break; + } + } + result +} + +fn parse_string_vec_or_empty(value: &str) -> Result, String> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + parse_string_vec(value) +} + +fn parse_string_vec(value: &str) -> Result, String> { + parse_json::>(value, "visual_novel string array").map(normalize_tags) +} + +fn parse_optional_json_value(value: &str) -> Result, String> { + if value.trim().is_empty() { + return Ok(None); + } + parse_json_value(value).map(Some) +} + +fn parse_json_value(value: &str) -> Result { + parse_json(value, "visual_novel json") +} + +fn parse_json_value_or_object(value: &str) -> Result { + if value.trim().is_empty() { + return Ok(JsonValue::Object(JsonMap::new())); + } + parse_json_value(value) +} + +fn parse_json_value_or_array(value: &str) -> Result { + if value.trim().is_empty() { + return Ok(JsonValue::Array(Vec::new())); + } + parse_json_value(value) +} + +fn draft_string_field(draft: &JsonValue, key: &str) -> Option { + draft + .get(key) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn draft_string_array(draft: &JsonValue, key: &str) -> Vec { + draft + .get(key) + .and_then(JsonValue::as_array) + .map(|items| { + items + .iter() + .filter_map(JsonValue::as_str) + .map(str::to_string) + .collect::>() + }) + .map(normalize_tags) + .unwrap_or_default() +} + +fn draft_bool_field(draft: &JsonValue, key: &str) -> bool { + draft.get(key).and_then(JsonValue::as_bool).unwrap_or(false) +} + +fn draft_path_string(value: &JsonValue, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn draft_path_bool(value: &JsonValue, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + current.as_bool() +} + +fn clean_optional(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn clean_string(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn empty_to_none(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn require_non_empty(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{label} 不能为空")) + } else { + Ok(()) + } +} + +fn parse_json(value: &str, label: &str) -> Result { + serde_json::from_str(value).map_err(|error| format!("{label} 非法: {error}")) +} + +fn to_json_string(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string()) +} + +fn session_result( + session: VisualNovelAgentSessionSnapshot, +) -> VisualNovelAgentSessionProcedureResult { + VisualNovelAgentSessionProcedureResult { + ok: true, + session_json: Some(to_json_string(&session)), + error_message: None, + } +} + +fn session_error(message: String) -> VisualNovelAgentSessionProcedureResult { + VisualNovelAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + } +} + +fn work_result(work: VisualNovelWorkSnapshot) -> VisualNovelWorkProcedureResult { + VisualNovelWorkProcedureResult { + ok: true, + work_json: Some(to_json_string(&work)), + error_message: None, + } +} + +fn work_error(message: String) -> VisualNovelWorkProcedureResult { + VisualNovelWorkProcedureResult { + ok: false, + work_json: None, + error_message: Some(message), + } +} + +fn run_result(run: VisualNovelRunSnapshot) -> VisualNovelRunProcedureResult { + VisualNovelRunProcedureResult { + ok: true, + run_json: Some(to_json_string(&run)), + error_message: None, + } +} + +fn run_error(message: String) -> VisualNovelRunProcedureResult { + VisualNovelRunProcedureResult { + ok: false, + run_json: None, + error_message: Some(message), + } +} diff --git a/src/components/SkillEffectPreview.tsx b/src/components/SkillEffectPreview.tsx index a2cc15fc1..dce3cbf21 100644 --- a/src/components/SkillEffectPreview.tsx +++ b/src/components/SkillEffectPreview.tsx @@ -102,7 +102,7 @@ export function SkillEffectPreview({ const [playerActionMode, setPlayerActionMode] = useState('idle'); const [sceneHostileNpcs, setSceneMonsters] = useState(initialMonsters); const [activeCombatEffects, setActiveCombatEffects] = useState([]); - const [replayTick, setReplayTick] = useState(0); + const [restartTick, setRestartTick] = useState(0); const [isPlaying, setIsPlaying] = useState(false); useEffect(() => { @@ -216,7 +216,7 @@ export function SkillEffectPreview({ active = false; timers.forEach(timerId => window.clearTimeout(timerId)); }; - }, [character, initialMonsters, mode, replayTick, scenePreset, skill]); + }, [character, initialMonsters, mode, restartTick, scenePreset, skill]); return (
@@ -229,12 +229,12 @@ export function SkillEffectPreview({
diff --git a/src/components/creation-agent/CreationAgentWorkspace.tsx b/src/components/creation-agent/CreationAgentWorkspace.tsx index 45538f734..e2c7935b5 100644 --- a/src/components/creation-agent/CreationAgentWorkspace.tsx +++ b/src/components/creation-agent/CreationAgentWorkspace.tsx @@ -88,7 +88,7 @@ type CreationAgentWorkspaceProps = { const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96; const DOCUMENT_INPUT_ACCEPT = - '.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json'; + '.txt,.md,.markdown,.docx,.csv,.json,text/plain,text/markdown,text/csv,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document'; const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp'; function uniqueRecommendedReplies(recommendedReplies: string[] = []) { diff --git a/src/components/creative-agent/CreativeAgentHome.tsx b/src/components/creative-agent/CreativeAgentHome.tsx new file mode 100644 index 000000000..d147e2979 --- /dev/null +++ b/src/components/creative-agent/CreativeAgentHome.tsx @@ -0,0 +1,423 @@ +import { + Bell, + Bookmark, + ChevronRight, + Gamepad2, + Menu, + MessageCircle, + Moon, + Music, + PanelLeftClose, + Settings, + Sparkles, + UserRound, +} from 'lucide-react'; +import { useMemo, useState } from 'react'; + +import type { CreativeAgentInputPart } from '../../../packages/shared/src/contracts/creativeAgent'; +import type { CreationWorkShelfItem } from '../custom-world-home/creationWorkShelf'; +import { RpgEntryBrandLogo } from '../rpg-entry/RpgEntryBrandLogo'; +import { CreativeAgentInputComposer } from './CreativeAgentInputComposer'; +import { createCreativeAgentClientMessageId } from './creativeAgentViewModel'; + +type CreativeAgentHomePrompt = { + id: string; + label: string; + prompt: string; + icon: typeof Sparkles; + tone: 'cool' | 'green' | 'warm' | 'purple' | 'rose'; + badge?: string; +}; + +export type CreativeAgentHistoryItem = { + id: string; + title: string; + groupLabel: string; + source: CreationWorkShelfItem; +}; + +type CreativeAgentHomeProps = { + recentItems: CreativeAgentHistoryItem[]; + isBusy: boolean; + error: string | null; + onStartNewChat: () => void; + onOpenHistoryItem: (item: CreationWorkShelfItem) => void; + onOpenDrafts: () => void; + onOpenAccount: () => void; + onOpenSettings: () => void; + onSubmitMessage: (payload: { + clientMessageId: string; + content: CreativeAgentInputPart[]; + }) => void; +}; + +const PROMPT_SUGGESTIONS: CreativeAgentHomePrompt[] = [ + { + id: 'identity', + label: '你是谁', + prompt: '介绍一下你能帮我创作什么。', + icon: Sparkles, + tone: 'cool', + }, + { + id: 'flash-app', + label: '一句话生成闪应用', + prompt: '帮我把一个灵感做成可互动的小应用。', + icon: Moon, + tone: 'green', + }, + { + id: 'mini-game', + label: '捏个小游戏', + prompt: '帮我做一个适合马上玩的创意小游戏。', + icon: Gamepad2, + tone: 'warm', + }, + { + id: 'world-model', + label: '体验世界模型', + prompt: '用一个世界设定帮我生成可体验的互动内容。', + icon: Bookmark, + tone: 'purple', + badge: 'Beta', + }, + { + id: 'music', + label: '音乐扭蛋', + prompt: '把一段音乐灵感做成互动拼图。', + icon: Music, + tone: 'rose', + }, +]; + +function buildCreativeHomeInputParts(payload: { + text: string; + image: { imageUrl: string; thumbnailUrl: string } | null; +}): CreativeAgentInputPart[] { + const content: CreativeAgentInputPart[] = []; + if (payload.text) { + content.push({ + type: 'input_text', + text: payload.text, + }); + } + if (payload.image) { + content.push({ + type: 'input_image', + imageUrl: payload.image.imageUrl, + thumbnailUrl: payload.image.thumbnailUrl, + assetId: null, + }); + } + return content; +} + +function groupRecentItemsByLabel(items: CreativeAgentHistoryItem[]) { + const groups: Array<{ label: string; items: CreativeAgentHistoryItem[] }> = []; + for (const item of items) { + const lastGroup = groups[groups.length - 1]; + if (lastGroup?.label === item.groupLabel) { + lastGroup.items.push(item); + continue; + } + groups.push({ + label: item.groupLabel, + items: [item], + }); + } + return groups; +} + +function CreativeAgentPromptButton({ + item, + disabled, + onClick, +}: { + item: CreativeAgentHomePrompt; + disabled: boolean; + onClick: () => void; +}) { + const Icon = item.icon; + + return ( + + ); +} + +function CreativeAgentDrawer({ + open, + recentItems, + onClose, + onStartNewChat, + onOpenHistoryItem, + onOpenDrafts, + onOpenAccount, + onOpenSettings, +}: { + open: boolean; + recentItems: CreativeAgentHistoryItem[]; + onClose: () => void; + onStartNewChat: () => void; + onOpenHistoryItem: (item: CreationWorkShelfItem) => void; + onOpenDrafts: () => void; + onOpenAccount: () => void; + onOpenSettings: () => void; +}) { + const groupedItems = useMemo( + () => groupRecentItemsByLabel(recentItems), + [recentItems], + ); + + return ( + <> +
+ + + ); +} + +export function CreativeAgentHome({ + recentItems, + isBusy, + error, + onStartNewChat, + onOpenHistoryItem, + onOpenDrafts, + onOpenAccount, + onOpenSettings, + onSubmitMessage, +}: CreativeAgentHomeProps) { + const [drawerOpen, setDrawerOpen] = useState(false); + const submitText = (text: string) => { + const trimmedText = text.trim(); + if (!trimmedText || isBusy) { + return; + } + onSubmitMessage({ + clientMessageId: createCreativeAgentClientMessageId(), + content: [ + { + type: 'input_text', + text: trimmedText, + }, + ], + }); + }; + + return ( +
+
+
+ + + +
+ +
+
+

Hi, 朋友

+

让复杂,变简单

+
+ +
+ {PROMPT_SUGGESTIONS.map((item) => ( + submitText(item.prompt)} + /> + ))} + +
+ + {error ? ( +
{error}
+ ) : null} +
+ +
+ { + const content = buildCreativeHomeInputParts(payload); + if (content.length === 0) { + return; + } + onSubmitMessage({ + clientMessageId: createCreativeAgentClientMessageId(), + content, + }); + }} + /> +
+ + setDrawerOpen(false)} + onStartNewChat={onStartNewChat} + onOpenHistoryItem={onOpenHistoryItem} + onOpenDrafts={onOpenDrafts} + onOpenAccount={onOpenAccount} + onOpenSettings={onOpenSettings} + /> +
+ ); +} + +export default CreativeAgentHome; diff --git a/src/components/creative-agent/CreativeAgentInputComposer.tsx b/src/components/creative-agent/CreativeAgentInputComposer.tsx new file mode 100644 index 000000000..56cb08079 --- /dev/null +++ b/src/components/creative-agent/CreativeAgentInputComposer.tsx @@ -0,0 +1,160 @@ +import { ArrowUp, ImagePlus, Loader2, Plus, X } from 'lucide-react'; +import { type ChangeEvent, useState } from 'react'; + +import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; + +export type CreativeAgentComposerImage = { + imageUrl: string; + thumbnailUrl: string; + label: string; +}; + +type CreativeAgentInputComposerProps = { + isBusy: boolean; + variant?: 'panel' | 'floating'; + placeholder?: string; + onSubmit: (payload: { + text: string; + image: CreativeAgentComposerImage | null; + }) => void; +}; + +export function CreativeAgentInputComposer({ + isBusy, + variant = 'panel', + placeholder = '想做成什么拼图?', + onSubmit, +}: CreativeAgentInputComposerProps) { + const [text, setText] = useState(''); + const [image, setImage] = useState(null); + const [imageError, setImageError] = useState(null); + const canSubmit = !isBusy && Boolean(text.trim() || image); + + const handleImageChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.currentTarget.value = ''; + if (!file) { + return; + } + + try { + const dataUrl = await readPuzzleReferenceImageAsDataUrl(file); + setImage({ + imageUrl: dataUrl, + thumbnailUrl: dataUrl, + label: file.name.trim() || '参考图', + }); + setImageError(null); + } catch (error) { + setImageError( + error instanceof Error ? error.message : '参考图读取失败,请重试。', + ); + } + }; + + const submit = () => { + if (!canSubmit) { + return; + } + onSubmit({ + text: text.trim(), + image, + }); + setText(''); + setImage(null); + setImageError(null); + }; + + const floating = variant === 'floating'; + + return ( +
+
+ + +