init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
# 后台管理服务设计
日期:`2026-04-23`
## 1. 目标
为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标:
1. 支持管理员用户名密码登录。
2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。
3. 支持在后台查看当前服务与数据库概览信息。
4. 支持在后台测试当前 `api-server` 已挂载接口。
5. 保持首版工程足够轻量,不新建额外独立服务进程,不引入第二套前端工程。
## 2. 背景与约束
当前仓库已具备:
1. Rust `api-server` 主链。
2. 基于 JWT + refresh session 的普通用户登录体系。
3. `SpacetimeDB + spacetime-client` 的主数据面。
本次后台管理服务必须继续遵守:
1. 后端统一落在 `server-rs`,不回退到 `server-node`
2. 不额外新起独立管理服务进程。
3. 首版以“一个受保护管理域 + 一个同源后台页面”为落地形态。
4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。
## 3. 首版范围
### 3.1 包含
1. `GET /admin`:后台管理页面入口。
2. `POST /admin/api/login`:管理员用户名密码登录。
3. `GET /admin/api/me`:当前管理员会话信息。
4. `GET /admin/api/overview`:服务与数据库概览。
5. `POST /admin/api/debug/http`:受控 HTTP 接口调试。
6. 基于 Bearer JWT 的管理员鉴权中间件。
### 3.2 不包含
1. 多角色管理员体系。
2. 管理员 refresh cookie / 多端会话管理。
3. 后台直接写库、删库、执行 reducer。
4. 任意 SQL 执行器。
5. 新建独立 React/Vite 管理端工程。
## 4. 总体方案
### 4.1 部署形态
后台管理服务直接挂载在现有 `server-rs/crates/api-server` 内,作为同一个 Axum 进程的一部分。
原因:
1. 当前 `api-server` 已具备配置、JWT、错误包裹、日志与同源路由能力。
2. 后台本质上是服务运维与调试面,不值得单独再起一个网关或 BFF。
3. 同源可以避免开发期额外 CORS 和 cookie 域问题。
### 4.2 页面形态
后台管理页面采用 `api-server` 直接返回一份内嵌 HTML/CSS/JS 的管理页。
原因:
1. 首版目标是“可用的后台能力”,不是新建一套复杂前端基建。
2. 管理页面交互相对简单,直接内嵌更易随服务端一起部署。
3. 可以避免新增构建链和静态资源发布路径。
### 4.3 数据库信息来源
数据库概览不走本地 CLI shell也不依赖前端直接访问数据库。
首版采用两类信息源:
1. 服务端配置与连接信息:来自 `api-server` 当前 `AppConfig`
2. SpacetimeDB 真正的数据库元信息与表行数:由 `api-server` 通过 SpacetimeDB 官方 HTTP API 读取。
读取口径:
1. `/v1/database/{database}`:读取数据库基础信息。
2. `/v1/database/{database}/schema`:读取 schema 信息。
3. `/v1/database/{database}/sql`:对受控表执行 `SELECT COUNT(*)` 统计。
说明:
1. 首版只做只读概览,不暴露任意 SQL 输入。
2. 表清单由后端显式维护,避免用户在后台拼接任意查询。
## 5. 管理员鉴权设计
### 5.1 管理员账号来源
首版不复用普通玩家账号仓储,不把管理员账号混进 `module-auth` 用户表。
管理员账号来自环境变量:
1. `GENARRATIVE_ADMIN_USERNAME`
2. `GENARRATIVE_ADMIN_PASSWORD`
原因:
1. 管理员是平台运维身份,不等于玩家账号。
2. 首版目标是尽快落地可靠后台,不引入额外管理员表迁移。
3. 环境变量方案最适合当前阶段的单后台入口。
### 5.2 管理员 JWT
后台登录成功后签发独立管理员 Bearer JWT。
claims 设计:
1. 继续复用 `platform-auth::AccessTokenClaims`
2. `roles` 固定包含 `admin`
3. `sub` 使用稳定管理员主体,例如 `admin:<username>`
4. `sid` 使用后台会话 ID。
5. 不写 refresh cookie。
### 5.3 权限校验
新增 `require_admin_auth` 中间件,校验规则如下:
1. Bearer token 必须可被当前 JWT 配置正确验签。
2. `roles` 中必须包含 `admin`
3. `sub` 必须匹配当前管理员配置主体。
普通用户 token 即使同样由本服务签发,只要不带 `admin` 角色,也一律拒绝访问后台接口。
## 6. 后台页面设计
首版页面包含三个主区域:
1. 登录卡片。
2. 数据库概览面板。
3. API 调试面板。
交互原则:
1. 页面简洁,不默认塞说明性长文案。
2. 移动端优先,窄屏下卡片改纵向堆叠。
3. API 调试结果在独立结果面板展示,不在按钮下方临时插一段文本。
## 7. 数据库概览设计
`GET /admin/api/overview` 返回以下信息:
1. 当前服务监听信息。
2. 当前 `SpacetimeDB server/database` 配置。
3. `SpacetimeDB` 数据库基础信息。
4. 当前 schema 表清单。
5. 首批关键表的行数统计。
首批关键表固定覆盖:
1. `runtime_setting`
2. `runtime_snapshot`
3. `user_browse_history`
4. `profile_dashboard_state`
5. `profile_wallet_ledger`
6. `profile_played_world`
7. `profile_save_archive`
8. `story_session`
9. `story_event`
10. `battle_state`
11. `inventory_slot`
12. `quest_record`
13. `quest_log`
14. `treasure_record`
15. `npc_state`
16. `custom_world_profile`
17. `custom_world_gallery_entry`
18. `custom_world_agent_session`
19. `custom_world_agent_message`
20. `custom_world_agent_operation`
21. `custom_world_draft_card`
22. `big_fish_creation_session`
23. `big_fish_agent_message`
24. `big_fish_asset_slot`
25. `big_fish_runtime_run`
26. `puzzle_work_profile`
27. `puzzle_agent_session`
28. `puzzle_agent_message`
29. `puzzle_runtime_run`
30. `ai_task`
31. `ai_task_stage`
32. `ai_text_chunk`
33. `ai_result_reference`
34. `asset_object`
35. `asset_entity_binding`
返回中的计数失败项必须带错误信息,不能静默吞掉。
## 8. API 调试设计
`POST /admin/api/debug/http` 提供一个受控 HTTP 调试代理。
请求参数:
1. `method`
2. `path`
3. `headers`
4. `body`
限制:
1. 只允许访问当前服务同源相对路径。
2. 调试回环地址由服务端按当前 `bind_host` 解析;若服务监听在 `0.0.0.0``::`,后台自动改走 loopback避免把通配监听地址直接当成调试目标。
2. 禁止调 `/admin/api/login` 本身,避免自套娃。
3. 禁止覆盖 `host``content-length` 等危险头。
4. 请求超时固定收口。
5. 返回调试结果时回显状态码、响应头、响应文本预览。
该能力用于验证当前服务端接口,不等价于通用代理工具。
## 9. 配置项
新增以下环境变量:
1. `GENARRATIVE_ADMIN_USERNAME`
2. `GENARRATIVE_ADMIN_PASSWORD`
3. `GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS`
默认策略:
1. 若未配置用户名或密码,则后台登录接口返回 `503`,后台页面显示“后台未启用”。
2. 默认管理员 token TTL 为 `4` 小时。
## 10. 测试要求
至少覆盖:
1. 管理员登录成功。
2. 管理员密码错误返回 `401`
3. 普通用户 token 访问后台接口返回 `403`
4. 未登录访问后台接口返回 `401`
5. 后台概览接口在未启用管理员配置时返回 `503`
6. API 调试接口能成功访问 `/healthz`
7. API 调试接口拒绝绝对 URL 和后台自身登录接口。
## 11. 路由清单
首版新增路由:
1. `GET /admin`
2. `POST /admin/api/login`
3. `GET /admin/api/me`
4. `GET /admin/api/overview`
5. `POST /admin/api/debug/http`
## 12. 完成定义
满足以下条件时,本任务视为完成:
1. `api-server` 内存在受保护后台管理域。
2. 管理员用户名密码可登录。
3. 普通用户 token 无法访问后台接口。
4. 后台能看到服务和数据库真实概览。
5. 后台能调试当前服务 HTTP 接口。
6. 路由索引与技术文档已同步更新。

View File

@@ -0,0 +1,45 @@
# 冒险实体详情 NPC 预览修复记录2026-04-26
## 背景
RPG 运行态点击画面中的对面 NPC 角色形象时,详情弹窗的立绘与画布上实际显示的 NPC 不一致,并伴随 React 报错:
`Encountered two children with the same key, ``.`
## 问题定位
1. 画布层 `GameCanvasEntityLayer` 渲染 NPC 时,会优先使用当前 `Encounter` 实例上的 `visual``imageSrc``monsterPresetId`,再回退到 `characterId` 对应的预设角色。
2. 详情弹窗 `AdventureEntityModal` 原本优先按 `characterId` 渲染预设角色,导致运行时遭遇已经携带独立形象时,点击后弹窗显示成另一个角色内容。
3. `AdventureEntityModal` 内部存在多个浮层共用同一个 `AnimatePresence`,直系子节点没有显式稳定 key同时 NPC 运行时背包物品如果传入空 `id`,会把空字符串直接交给物品格列表作为 React key。
## 落地约束
1. NPC 详情立绘必须与画布点击对象一致:
- `encounter.visual`
- `encounter.imageSrc`
- `encounter.monsterPresetId`
- `encounter.characterId`
- 通用 NPC 生成形象
2. 前端只做展示优先级和 key 稳定性处理,不新增剧情规则、不改写运行时 NPC 数据来源。
3. 所有列表和并列浮层都必须具备稳定、非空、可区分的渲染 key。
## 本次修改
1. `src/components/AdventureEntityModal.tsx`
- 新增 `NpcEncounterPortrait`,让弹窗立绘优先使用遭遇实例形象,与画布渲染策略对齐。
- 新增 `selectionRenderKey`,给实体详情、标签详情、技能详情浮层提供稳定 key。
- 新增 NPC 背包物品渲染 id 规范化,避免空 id 或重复 id 触发 React key 冲突,并避免点击物品时选中错误项。
- 技能附带状态标签 key 增加兜底字段,避免空 buff id 冲突。
2. `src/components/AdventureEntityModal.test.tsx`
- 覆盖“有 `characterId` 但遭遇实例提供 `imageSrc` 时,详情立绘必须显示遭遇图像”。
- 覆盖“NPC 背包物品空 id 不再触发重复 key 警告”。
## 验证
已执行:
```bash
npm run test -- AdventureEntityModal.test.tsx CharacterInfoShared.test.tsx
```
结果5 个测试全部通过。

View File

@@ -0,0 +1,99 @@
# Agent 对话框与结果页精修职责边界修正
更新时间:`2026-04-21`
## 1. 结论
本次修正把“Agent 对话框”和“结果页精修”重新拆清楚:
1. `CustomWorldAgentWorkspace` 只负责八锚点信息收集、八锚点进度展示、八锚点完成后的“整理世界底稿”动作。
2. “精修”不是 Agent 对话框里的概念,不再通过 Agent 建议动作进入角色、地点、世界总卡的局部修整。
3. 已经生成底稿的草稿,从创作中心点击后进入结果页继续完善。
4. 尚未生成底稿的草稿,从创作中心点击后才恢复 Agent 对话框继续补齐八锚点。
5. 结果页负责成稿后的编辑、补全、进入世界前确认和自动保存,并通过 `sync_result_profile` 回写到当前 Agent session。
一句话:
**Agent 收八锚点,结果页做精修。**
---
## 2. 为什么要修正
旧实现把 `object_refining` 草稿卡片显示成“继续精修”,但点击后直接恢复 Agent 工作区。
这个行为会让用户产生两个误解:
1. 以为精修是 Agent 对话框里的下一阶段。
2. 以为 Agent 对话框不仅负责收集八锚点,还负责后续对象级编辑。
这和当前产品边界不一致。Agent 对话框应该保持轻量,只用于拿到足够稳定的八锚点输入;对象、场景、封面、世界档案的修整都应该在结果页完成。
---
## 3. 当前落地规则
### 3.1 创作中心草稿点击分流
`custom-world/works` 返回 `agent_session` 草稿后,前端按草稿是否已有底稿内容分流:
1. `playableNpcCount <= 0 && landmarkCount <= 0`
- 视为八锚点仍未整理成底稿。
- 点击进入 `agent-workspace`
2. `playableNpcCount > 0 || landmarkCount > 0`
- 视为已有可编辑底稿。
- 点击读取对应 Agent session编译为 `CustomWorldProfile`,进入 `custom-world-result`
### 3.2 Agent 对话框动作边界
Agent 会话建议动作只保留:
1. 总结当前设定 / 总结当前世界底稿。
2. 八锚点准备完成后的“整理一版世界底稿”。
不再在 Agent 会话快照里继续生成或兼容展示:
1. `refine_focus_target`
2. “精修角色”
3. “继续补地点”
4. “先看世界总卡”
旧 session 快照如果仍带有 `refine_focus_target`,服务端兼容层会过滤掉,避免旧数据把精修入口重新塞回 Agent 对话框。
### 3.3 结果页精修边界
Agent 来源结果页不再是冻结预览态。
当前允许在结果页继续进行成稿精修,包括:
1. 编辑世界信息。
2. 编辑角色、场景、封面等对象档案。
3. 删除或调整已有对象。
4. 自动保存到作品草稿。
5. 进入世界前通过 `sync_result_profile` 写回 Agent session。
为了保持主链简洁Agent 来源结果页仍不重新打开“通过 Agent 对话精修对象”的入口。
---
## 4. 对历史文档口径的覆盖
这份文档覆盖 [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md) 中“Agent 来源结果页冻结为预览态”的阶段性口径。
新的主口径是:
1. Agent 来源结果页可以编辑,因为精修本来就应该发生在结果页。
2. 需要收紧的是 Agent 对话框,不是结果页。
3. 结果页编辑后仍必须同步回 Agent session保持进入世界前的数据真相源一致。
---
## 5. 验收标准
本次修正完成后应满足:
1. 创作中心已有底稿草稿按钮文案为“继续完善”,点击进入结果页。
2. 创作中心未成稿草稿按钮仍为“继续创作”,点击进入 Agent 对话框。
3. Agent 对话框不出现“精修角色 / 补地点 / 看世界总卡”类对象精修入口。
4. Agent 来源结果页可以打开编辑弹窗进行精修。
5. 返回创作从结果页回到创作中心,不回到 Agent 对话框。

View File

@@ -0,0 +1,109 @@
# Agent 草稿编译后重复生成草稿修复 2026-04-23
更新时间:`2026-04-23`
## 1. 问题现象
当前创作链里,用户打开一个已有 Agent 草稿,继续编辑并触发结果页编译或自动保存后,作品库里会新增一份 draft而不是更新原来的那一份。
这会带来两个直接问题:
1. 同一个会话在作品库里出现多份草稿
2. 原草稿状态没有被正确推进,导致发布、恢复、继续创作都可能命中旧条目
## 2. 根因拆解
本次问题不是单点,而是两段身份链没有收口到同一套规则:
### 2.1 `sync_result_profile` 会直接覆盖 session 里的 `draft_profile_json.id`
当前 `sync_result_profile` 会把结果页传回来的 `profile` 直接写回 `draft_profile_json`
如果结果页上的 `profile.id` 已经不是原草稿 id那么
1. session 主链中的 `draft_profile_json.id` 会被改成新 id
2. `resultPreview.preview.id` 也会跟着变成新 id
3. 前端 autosave 会拿着这个新 id 去调作品库 `PUT /custom-world-library/:profileId`
### 2.2 作品库 upsert 只按 `profile_id` 命中,不按 `source_agent_session_id` 兜底
当前作品库落库路径:
`结果页 autosave -> PUT /custom-world-library/:profileId -> upsert_custom_world_profile_record`
其中普通 autosave 路径此前存在两个问题:
1. 前端没有透传 `sourceAgentSessionId`
2. 后端普通 upsert 只按 `(owner_user_id, profile_id)` 查旧记录
所以一旦 `profile.id` 漂移,后端就会把它当作一条新的 draft 插入。
## 3. 修复目标
本轮修复要求同时满足下面两条:
1. Agent 草稿结果页继续编辑时,必须优先继承当前 session 已有的稳定 `profileId`
2. 即使前端传来的 `profile.id` 已经漂移,作品库 upsert 也要能按 `source_agent_session_id` 命中同一份 draft 并更新
## 4. 本轮落地策略
### 4.1 session 主链侧:稳定保留草稿 id
`sync_result_profile` 中,若当前 session 已经存在草稿身份:
1. 优先读取 `draft_profile_json.legacyResultProfile.id`
2. 其次读取 `draft_profile_json.id`
3. 若命中稳定 id则把传入 profile 的:
- 顶层 `id`
- `legacyResultProfile.id`
都强制回写为这个稳定 id
这样可以保证:
1. `draft_profile_json.id` 不会被结果页里的漂移 id 覆盖
2. `resultPreview.preview.id` 会持续稳定
3. 前端后续 autosave 会继续更新原草稿
### 4.2 作品库保存侧:透传 `sourceAgentSessionId`
前端 `upsertRpgWorldProfile(...)` 新增可选参数:
`sourceAgentSessionId?: string | null`
结果页属于 Agent 草稿视图时autosave 会把 `activeAgentSessionId` 一并传给作品库接口。
### 4.3 后端 upsert 侧:按 session 命中旧 draft
普通作品库 `PUT /custom-world-library/:profileId` 接口新增读取 `sourceAgentSessionId`
Spacetime `upsert_custom_world_profile_record(...)` 在按 `profile_id` 未命中时,新增二级兜底:
1. `owner_user_id` 相同
2. `publication_status == draft`
3. `deleted_at == None`
4. `source_agent_session_id == input.source_agent_session_id`
若命中这条旧 draft
1. 删除旧 row
2. 使用旧 row 的 `profile_id`
3. 更新 payload / metadata / updated_at
这样即使前端 path 参数已经是新 id也仍然会命中原草稿并更新而不是再插入第二份草稿。
## 5. 验收标准
修复后应满足:
1. 打开已有 Agent draft 后继续编译,不会新增第二份 draft
2. 原 draft 的 `profileId` 保持不变
3. `resultPreview.preview.id` 与作品库 `profileId` 一致
4. 自动保存、继续创作、进入世界、发布前检查都围绕同一份草稿身份工作
## 6. 结论
这次问题的本质不是“自动保存重复调用”,而是:
**Agent 草稿在 session 主链和作品库 upsert 两端都缺少稳定身份约束。**
本轮通过“session 保 id + 作品库按 session 兜底命中”双保险,把同一份草稿重新收口为单一身份。

View File

@@ -0,0 +1,119 @@
# Agent 草稿结果页资产合并修复 2026-04-21
更新时间:`2026-04-21`
## 1. 问题现象
当前创作流程里,用户在“生成草稿”后反馈:
1. 角色主图没有稳定出现在结果页
2. 场景背景图有时可见,有时角色图缺失
3. 自动保存后的作品库条目里,分幕图可能已经存在,但场景角色主图仍为空
## 2. 本次真实排查结论
本轮不是单一的“没写数据库”问题,而是 `agent draft -> result profile` 桥接层存在一类更隐蔽的集合漂移问题。
排查后确认:
1. 最新 `custom_world_sessions.payload_json` 里的 `draftProfile.storyNpcs[].imageSrc` 已经存在
2. 最新 `draftProfile.sceneChapters[].acts[].backgroundImageSrc` 也已经存在
3. 对应图片文件也真实存在于仓库根 `public/`
4. 最新 `custom_world_profiles.payload_json` 里,分幕图通常已保存成功
5. 但场景角色主图可能仍为空
根因在于:
1. 结果页桥接层在 `draftProfile.legacyResultProfile` 存在时,仍把 `legacyResultProfile` 视为主列表
2. 旧逻辑只会按 `id``draftProfile` 里的图片字段回贴到 `legacyResultProfile`
3. 一旦后续草稿精修导致 `draftProfile` 的角色集合、角色 id 或角色命名发生漂移
4.`legacyResultProfile` 就会继续主导结果页和自动保存对象列表
5. 最新角色主图虽然已在 `draftProfile` 里生成完成,但会因为匹配失败而被整批吞掉
这类问题在场景角色上最明显,因为角色集合最容易在后续精修中替换。
## 3. 修复策略
本轮在:
- `src/services/customWorldAgentDraftResult.ts`
调整桥接规则:
1. `legacyResultProfile` 仍保留,继续提供运行时富字段
2. 但角色、场景、分幕等对象集合不再默认由 `legacyResultProfile` 主导
3. 最新 `draftProfile` 成为结果页对象列表的主来源
4. `legacyResultProfile` 只负责给命中的对象补运行时富字段
5. 匹配优先级为:
- 先按 `id`
- 再按名称兜底
具体规则:
1. `playableNpcs`:以最新 draft 集合为主legacy 只补富字段与旧运行时字段
2. `storyNpcs`:同上,避免旧角色列表吞掉新角色主图
3. `sceneChapterBlueprints`:以最新 draft 幕列表为主legacy 只补章节/幕已有运行时字段
4. `landmarks`:优先更新最新 draft 命中的场景对象,但保留 legacy 中未被命中的剩余运行时场景,避免丢连接与残留信息
5. `camp`:保留 legacy 基础信息,但优先取 draft 最新图片字段
## 4. 修复后的链路意义
修复后:
1. 草稿自动资产服务生成的角色主图不会再因为旧 `legacyResultProfile` 的角色集合过时而丢失
2. 分幕图继续可以稳定进入结果页与自动保存
3. 作品库自动保存时,结果页编译出的 profile 更接近“当前草稿真实快照”,而不是历史 legacy 快照
## 5. 新增验证
本轮补了前端桥接测试:
- `src/services/customWorldAgentDraftResult.test.ts`
新增验证点:
1.`draftProfile.storyNpcs``legacyResultProfile.storyNpcs` 集合漂移时
2. 结果页仍应优先展示最新 draft 角色
3. 最新角色主图与最新分幕图不能被旧 legacy 快照吞掉
## 6. 当前状态
本轮修复后,本地已验证:
1. `src/services/customWorldAgentDraftResult.test.ts`
2. `src/components/CustomWorldResultView.test.tsx`
3. `src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
4. `npm run check:encoding`
均通过。
## 7. 后续建议
这次问题再次说明:
1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高
2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳
3. 后续如果继续推进后端单一真相源,应优先把结果页对象集合完全改为服务端最新 compile/preview 结果,而不是继续让前端桥接层承担最终裁决
---
## 8. 2026-04-21 补充:新建共创会话 500 根因
后续联调里又发现一条与资产合并无关、但会直接阻断创作入口的后端问题:
1. 点击“创建新 RPG 游戏”时,`POST /api/runtime/custom-world/agent/sessions` 返回 `500`
2. 表面上前端只看到“服务器内部错误”,实际根因在路由层
本次补查确认:
1. `server-node/src/routes/customWorldAgent.ts` 这组路由内部直接使用 `request.userId!`
2. 但路由文件本身在 `2026-04-21` 修复前没有挂 `requireJwtAuth(...)`
3. 结果是 HTTP 请求虽然带了登录 token`request.userId` 并不会被注入
4. 后端继续拿 `undefined` 作为 `userId` 创建 / 读取 agent session最终在仓储写库阶段触发 `500`
修复方式:
1.`createCustomWorldAgentRoutes(...)` 顶部统一补上 `router.use(requireJwtAuth(context.config, context.userRepository))`
2. 让 session 创建、读取、发消息、执行 action、读取 operation / card 详情全部走同一层鉴权注入
这次补丁的意义不是新增功能,而是把 `custom-world agent` 路由和其他 `rpg-entry / rpg-profile / rpg-runtime` 受保护接口重新对齐,避免再出现“路由里依赖 `request.userId`,但入口没挂鉴权”的低层断线问题。

View File

@@ -0,0 +1,119 @@
# 创作流程草稿/图片/动作自动保存数据库检查 2026-04-21
更新时间:`2026-04-21`
## 1. 本次检查范围
本次检查只聚焦当前创作流程里下面这条链路:
`结果页前端编辑 -> 自动保存 -> Agent session 主链同步 -> 作品库落库`
重点核对三类内容:
1. 草稿文本类修改
2. 生成后的角色图片、地点图片、分幕图
3. 角色动作相关资产字段
## 2. 当前实际自动保存链路
当前前端主入口在:
- `src/components/game-shell/PreGameSelectionFlow.tsx`
实际行为如下:
1. 结果页编辑统一通过 `onProfileChange` 更新 `generatedCustomWorldProfile`
2. 当结果页停留在 `custom-world-result` 阶段时,前端会对 profile 做防抖自动保存
3. 如果当前结果页来源是 `agent-draft`,自动保存前会先执行 `sync_result_profile`
4. `sync_result_profile` 完成后,前端不直接保存旧内存 profile而是优先保存“从最新 session 重编译出的 profile”
5. 作品库保存最终走 `PUT /api/runtime/custom-world-library/:profileId`
6. Express 后端通过 `runtimeRepository.upsertCustomWorldProfile(...)` 把 profile 写入 `custom_world_profiles.payload_json`
所以数据库层本身是有正常落库能力的。
## 3. 本次检查前确认成立的部分
以下能力在本次检查前已经成立:
1. 结果页普通草稿字段编辑会触发自动保存
2. 自动保存会真正调用后端作品库接口并更新数据库
3. 返回创作、进入世界两条路径也会优先同步 Agent session
4. `legacyResultProfile` 已作为阶段一桥接快照保留在 session 中
## 4. 本次发现的真实风险
风险不在数据库写入本身,而在:
`sync_result_profile -> session 重编译结果页 profile`
此前 `sync_result_profile` 只回写:
1. 基础摘要字段
2. `legacyResultProfile`
但没有把结果页里已经确认过的资产字段同步回 foundation draft 对应节点。
这会导致一个阶段性风险:
1. 用户在结果页换了新的角色图
2. 或者结果页里刚确认了新的动作资产字段
3. 或者结果页里刚确认了新的地点图、分幕图
4. 自动保存前前端先做一次 session 同步
5. 同步完成后又从 session 重编译结果页 profile
6. 重编译过程会把 draft 层旧资产字段再次并入结果 profile
这样就可能出现:
**数据库自动保存成功了,但保存进去的是“被旧 draft 资产字段回退过的版本”,不是用户刚在结果页看到的最新图/动作。**
## 5. 本轮修复
本轮在:
- `server-node/src/services/customWorldAgentOrchestrator.ts`
补了一个收窄修复:
1. `sync_result_profile` 仍然保持阶段一边界,不做整套 runtime -> foundation draft 反解
2. 但会按相同 id把结果页里已确认的资产字段同步回 draft 层已有对象
3. 同步范围包括:
- 角色 `imageSrc`
- 角色 `generatedVisualAssetId`
- 角色 `generatedAnimationSetId`
- 角色 `animationMap`
- 地点 `imageSrc`
- 分幕 `backgroundImageSrc`
- 分幕 `backgroundAssetId`
这样后续再从 session 重编译结果页 profile 时,最新资产字段不会再被旧 draft 值回退。
## 6. 验证补充
本轮补了服务端测试:
- `server-node/src/services/customWorldAgentPhase4.test.ts`
新增验证点:
1. `sync_result_profile` 后,最新角色主图会写回 draft
2. 最新角色动作资产字段会写回 draft
3. 最新地点图会写回 draft
4. 最新分幕图会写回 draft
## 7. 结论
截至本轮修复后,当前创作流程里:
1. 草稿文本修改可以自动保存到数据库
2. 结果页中确认后的角色图、地点图、分幕图可以随自动保存稳定进入数据库
3. 角色动作相关资产字段可以随 session 同步和自动保存稳定保留
但仍需注意:
1. 当前仍是阶段一兼容链路,核心桥接字段仍然是 `legacyResultProfile`
2. 正式发布链 `publish_world` 还没有在当前阶段打通
3. 前端仍依赖 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页兼容编译层
因此本轮结论是:
**当前“前端修改 -> 自动保存 -> 数据库”主链可用;本次已补上图片与动作资产在 session 重编译阶段的回退风险。**

View File

@@ -0,0 +1,212 @@
# Agent 结果页深度编辑回写主链方案(阶段一)
更新时间:`2026-04-20`
## 1. 这次阶段一先改什么
这次阶段一不做结果页只读化。
结果页继续保留当前已经可用、而且用户已经满意的这些能力:
1. 结果页继续允许深度编辑世界设定
2. 结果页继续允许编辑角色、场景、营地、封面
3. 结果页继续允许直接新增角色与地点
4. 结果页继续保留当前已有的浏览、自动保存、进入世界体验
这次真正要补的是:
**把结果页里产出的完整 `CustomWorldProfile`,同步回 `Agent session`,让结果页编辑不再游离在主链之外。**
---
## 2. 当前真正的问题
当前链路里,结果页虽然还能深度编辑,但数据职责是分裂的:
```text
Agent session
-> 前端 buildCustomWorldProfileFromAgentDraft()
-> 结果页本地 profile
-> 结果页继续深度编辑
-> 自动保存到 custom-world-library
-> 进入世界
```
这里最大的问题不是“结果页能编辑”,而是:
1. 结果页编辑后的最新世界结构,没有稳定回写到 `Agent session`
2. 用户从结果页返回 Agent 工作区后session 侧仍可能停留在较旧的草稿状态
3. “结果页当前看到的世界”“Agent session 当前保存的草稿”“作品库里自动保存的 profile”可能不是同一份东西
4. 进入世界时如果直接吃当前前端内存态,也会继续放大这个分叉
所以阶段一要解决的是:
**结果页仍然是深度编辑器,但它编辑的是 Agent 主链里的当前结果快照,不是脱链的本地副本。**
---
## 3. 阶段一目标状态
阶段一把链路先收成下面这样:
```text
Agent session
-> 前端 buildCustomWorldProfileFromAgentDraft() 生成结果页初始 profile
-> 用户在结果页继续深度编辑 profile
-> 前端调用新的 Agent action把完整结果 profile 同步回 session
-> session 保留:
- 当前 foundation draft
- 当前 legacyResultProfile 结果快照
- 重编译后的 draftCards / assetCoverage / suggestedActions
-> 自动保存与进入世界都优先基于已同步的 session 结果快照执行
```
这一步仍然是过渡态,不是最终态。
因为:
1. 阶段一还不打通 `publish_world`
2. 阶段一也不把结果页改造成完全原生的 draft 编辑器
3. 阶段一允许继续保留 `draftProfile.legacyResultProfile` 作为兼容桥接字段
但至少要做到:
**结果页的深度编辑,必须进入 Agent session 的单一主链。**
---
## 4. 阶段一具体实现边界
## 4.1 新增 Agent action`sync_result_profile`
阶段一新增一个面向结果页的 Agent action
```ts
{ action: 'sync_result_profile'; profile: CustomWorldProfileRecord }
```
用途只有一个:
把结果页当前完整 `CustomWorldProfile` 快照同步回 `CustomWorldAgentSessionRecord`
它不是发布动作,也不是世界编译动作。
它只是把结果页当前编辑结果认回主链。
---
## 4.2 服务端写回策略
服务端接到 `sync_result_profile` 后,按下面规则处理:
1. 读取当前 session
2. 取当前 `draftProfile`
3. 保留当前 draft 层已有的结构化字段:
- `playableNpcs / storyNpcs / landmarks / camp`
- `factions / threads / chapters / sceneChapters`
- `worldHook / playerPremise / openingSituation / iconicElements`
- 以及现有资产、scene chapter 等字段
4. 把结果页传来的完整 `CustomWorldProfile` 写入 `draftProfile.legacyResultProfile`
5. 对于 draft 层里本来就和结果页一一对应、且结果页已经改动的字段,同步覆盖基础摘要字段:
- `name`
- `subtitle`
- `summary`
- `tone`
- `playerGoal`
- `majorFactions`
- `coreConflicts`
6. 重新编译 `draftCards`
7. 重建 `assetCoverage`
8. 刷新 `suggestedActions`
9. 写入 action result message 和 checkpoint
这里故意不在阶段一做“把完整 runtime profile 反解成一整套全量 foundation draft 结构”的大重构。
原因是:
1. 结果页当前已经支持很多深度编辑字段
2. 如果现在硬做全量反编译,最容易把场景章节、多幕、资产字段写坏
3. 阶段一应该先保证“结果页编辑不脱链”,而不是一次性重做所有模型映射
---
## 4.3 前端触发策略
前端只在 `customWorldResultViewSource === 'agent-draft'` 时走这条同步链。
具体规则:
1. 结果页 profile 每次发生变化时,继续允许本地即时更新
2. 但在自动保存前,先把 profile 通过 `sync_result_profile` 同步到 Agent session
3. 返回创作时,如果要重新读 Agent 草稿,也应优先以最新 session 为准
4. 点击“进入世界”时,先拉取最新 session再重新 `buildCustomWorldProfileFromAgentDraft()`,避免吃到旧的前端缓存 profile
这样阶段一就能做到:
1. 结果页编辑体验不变
2. Agent session 成为结果页编辑后的可恢复真相源
3. 自动保存、返回创作、进入世界三条路都围绕同一份 session-backed 结果快照
---
## 5. 阶段一明确不做什么
这次阶段一明确不做:
1. 不关闭结果页当前已有的编辑器能力
2. 不删除结果页当前已有的 AI 新增角色/地点能力
3. 不打通 `publish_world`
4. 不把 `legacyResultProfile` 直接删掉
5. 不把结果页整个改写成只操作 draft card 的新系统
6. 不把旧 `custom-world/sessions` 链在本阶段直接物理移除
---
## 6. 验收标准
阶段一做完后,至少要满足下面这些结果:
1. Agent 草稿结果页继续保持当前深度编辑体验不变
2. 结果页发生编辑后Agent session 中能看到同步后的最新结果快照
3. 从结果页返回创作后,不会明显回退到较旧的草稿态
4. 点击“进入世界”时,会优先使用最新 session 重新编译结果,而不是只依赖前端旧内存态
5. 自动保存到作品库的 profile 与当前 session 结果快照保持一致
---
## 7. 一句话结论
阶段一不是收掉结果页,而是把结果页继续保留为深度编辑器,同时补上一条正式的 session 回写链,让它不再游离在 Agent 主链之外。
---
## 8. 2026-04-20 实际落地结果
本轮已经按阶段一目标完成下面这些收口:
1. 前端结果页自动保存时,若当前来源是 `agent-draft`,会先执行 `sync_result_profile`
2. `sync_result_profile` 完成后,自动保存不再直接写旧的前端内存 profile而是优先保存从最新 session 重新 `buildCustomWorldProfileFromAgentDraft()` 得到的结果快照
3. 点击“进入世界”时,仍会先同步 session再基于最新 session 重编译 profile 后进入世界
4. 点击“返回创作”时,也会先做一次结果页到 session 的同步兜底,再返回 Agent 工作区
5. 为避免用户刚从结果页返回工作区又被自动重开逻辑顶回结果页,前端补了一层显式返回抑制标记
6. 服务端 `sync_result_profile` 现已按阶段一边界收窄为“保留 foundation draft 结构,只更新基础摘要字段和 `legacyResultProfile`”,没有提前做整套 runtime -> draft 反解
这意味着阶段一当前已经把下面三条路径收回到同一条 session 主链:
1. 自动保存到作品库
2. 返回 Agent 工作区继续创作
3. 从结果页直接进入世界
## 9. 本轮仍然保留的阶段性边界
这次落地后,仍然保留文档原先约定的过渡边界:
1. 结果页深度编辑能力不做收缩
2. `draftProfile.legacyResultProfile` 继续作为兼容桥接字段保留
3. `publish_world` 仍未在这一轮打通
4. 前端仍然使用 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页的兼容编译层
所以下一阶段如果要继续推进,重点应转向:
1. 降低前端对 legacy profile 编译桥接的依赖
2. 继续把发布链路收口到 Agent session / service 侧
3. 逐步缩减结果页直改 legacy profile 的历史职责

View File

@@ -0,0 +1,74 @@
# Agent 结果页与平台入口收口方案(阶段二)
更新时间:`2026-04-20`
## 1. 阶段二目标
阶段一已经把 Agent 结果页编辑快照同步回 session 主链。阶段二不继续扩大结果页编辑能力,而是把入口和职责继续收紧:
1. 平台“创作”入口统一读取 `custom-world/works` 聚合列表
2. Agent 草稿和已保存作品在同一个入口里展示
3. 草稿点击后恢复 Agent session已保存作品点击后进入作品详情
4. Agent 结果页不再暴露“继续在结果页补世界结构”的新增入口
一句话目标:
**让用户从平台创作入口能稳定找回草稿和作品,同时让结果页更像收口预览,而不是另一套编辑器。**
---
## 2. 本阶段不做什么
阶段二明确不做:
1. 不物理删除旧 `custom-world/sessions`
2. 不打通 `publish_world`
3. 不重做结果页 UI
4. 不删除已保存作品的继续编辑入口
5. 不把结果页整体改成只读
这些事项留给后续阶段继续拆。
---
## 3. 平台入口落地规则
平台“创作”Tab 改为优先展示 `listCustomWorldWorks()` 的聚合结果:
1. `agent_session` 类型展示为草稿,可点击恢复 Agent 工作区
2. `published_profile` 类型展示为作品,可点击进入作品详情
3. 聚合接口失败时保留现有作品库 `myEntries` 兜底
4. 不新增平行页面,复用已有 `CustomWorldCreationHub`
这样用户不再需要依赖隐藏 sessionId 或旧作品库入口才能找回创作。
---
## 4. 结果页职责收口规则
Agent 来源结果页继续保留:
1. 浏览世界、角色、场景
2. 自动保存
3. 返回 Agent 工作区
4. 进入世界
Agent 来源结果页本阶段收紧:
1. 不再显示直接新增可扮演角色、场景角色、场景的入口
2. 不再把“去 Agent 调整设定”设计成结果页内部继续补世界结构
3. 如需继续调整,返回 Agent 工作区
已保存作品的结果页仍保持现有编辑能力,避免破坏作品库已有体验。
---
## 5. 验收标准
阶段二完成后应满足:
1. 平台“创作”Tab 能看到 Agent 草稿和已保存作品的统一列表
2. 点击 Agent 草稿能恢复对应 Agent 工作区
3. 点击已保存作品能进入原有作品详情
4. Agent 结果页不再显示直接新增角色/地点的入口
5. 已保存作品的结果页编辑能力不受影响

View File

@@ -0,0 +1,148 @@
# Agent 结果页旧链降级与预览冻结方案(阶段三)
更新时间:`2026-04-20`
## 1. 阶段三目标
阶段一已经把结果页编辑同步回 Agent session 主链。
阶段二已经把平台“创作”入口统一到 `custom-world/works` 聚合列表,并收紧了 Agent 结果页里的新增入口。
阶段三不继续扩功能,而是继续做两件事:
1. 让旧 pipeline 在主入口里进一步降级,不再和 Agent 主链抢“草稿”职责
2. 让 Agent 来源结果页进一步冻结为“预览/收口层”,不再继续承担 legacy profile 直改编辑器职责
一句话目标:
**把还在和 Agent 主链并行的旧职责继续降级,避免系统自己和自己打架。**
---
## 2. 当前剩余问题
虽然阶段一、二已经把主链收紧了不少,但当前还保留两个明显的并行口:
### 2.1 创作中心里旧 library 草稿仍可能继续冒充主草稿
当前 `listCustomWorldWorkSummaries()` 会把 runtime library 里的所有 profile 都折成 `published_profile` 类型返回。
这意味着:
1. `visibility = 'draft'` 的 library 草稿仍会继续出现在创作中心
2. 创作中心里同时存在:
- Agent session 草稿
- library 草稿
- 已发布作品
3. 用户看到的“草稿”概念仍然可能混成两套
阶段三需要明确:
**创作中心主入口只认 Agent session 草稿 和 已发布作品,不再继续把 library draft 当主草稿展示。**
---
### 2.2 Agent 结果页仍能继续打开旧 legacy 编辑器
当前 Agent 来源结果页虽然已经不再暴露“新增角色/新增地点”入口,但仍然保留下面这些旧编辑链:
1. 点击世界概述/基本设定仍能打开 legacy world editor
2. 点击角色、场景、封面仍能继续进入旧 profile 编辑弹窗
3. 这些编辑器本质上仍然是在改 legacy `CustomWorldProfile`
这会带来两个问题:
1. Agent 结果页继续像一套“旧编辑器”
2. “去 Agent 调整设定”和“结果页直接改 legacy profile”两条路仍然并行存在
阶段三需要明确:
**Agent 来源结果页继续保留浏览、自动保存、返回创作、进入世界,但不再继续承担 legacy profile 深编辑职责。**
---
## 3. 阶段三落地规则
## 3.1 创作中心只展示两类主入口内容
`custom-world/works` 在阶段三只保留下面两类条目:
1. `agent_session`
- 统一视为草稿
- 点击后恢复 Agent 工作区
2. `published_profile`
- 统一视为已发布作品
- 点击后进入现有作品详情
明确不再把下面这类内容继续塞进创作中心主入口:
1. library 中 `visibility = 'draft'` 的兼容草稿
这些兼容草稿仍然保留在作品库/详情链路里,不在本阶段物理删除,但不再继续占创作中心“草稿主入口”。
---
## 3.2 Agent 来源结果页冻结为预览态
`customWorldResultViewSource === 'agent-draft'` 时,结果页阶段三继续保留:
1. 浏览世界信息
2. 浏览角色、地点、场景结构
3. 自动保存
4. 返回 Agent 工作区
5. 进入世界
同时阶段三进一步收紧:
1. 不再打开世界/角色/场景/封面的 legacy 编辑弹窗
2. 不再提供删除角色、删除场景等旧 profile 直改入口
3. Agent 来源结果页上的对象卡统一作为“查看详情”预览卡使用
已保存作品的结果页编辑能力继续保留,不在本阶段收缩,避免破坏已有作品库编辑体验。
---
## 3.3 结果页同步动作只在真的发生差异时执行
阶段一补的 `sync_result_profile` 仍然保留,但阶段三补一个行为约束:
1. 如果当前 Agent 结果页 profile 和最新 session 重编译结果签名一致
2. 那么返回创作、进入世界、自动保存前不再重复触发一次 `sync_result_profile`
目的不是省接口,而是明确:
**结果页同步是“有改动才回写”的主链动作,不是每次离开页面都机械重放。**
---
## 4. 阶段三明确不做什么
这次阶段三明确不做:
1. 不物理删除旧 `custom-world/sessions` 相关服务与兼容代码
2. 不打通 `publish_world`
3. 不把前端 `buildCustomWorldProfileFromAgentDraft()` 兼容编译层移除
4. 不删除 `draftProfile.legacyResultProfile`
5. 不收缩已保存作品的 legacy 编辑器能力
阶段三只做主入口降级与 Agent 结果页职责冻结,不做更大的模型替换。
---
## 5. 验收标准
阶段三完成后应满足:
1. 创作中心不再把 library draft 兼容作品继续显示为“草稿主入口”
2. 创作中心里只保留 Agent 草稿和已发布作品两类主入口内容
3. Agent 来源结果页不再能继续打开 legacy 世界/角色/场景编辑弹窗
4. 已保存作品结果页编辑能力不受影响
5. Agent 结果页在未发生改动时,返回创作/进入世界/自动保存不会重复触发无意义的 `sync_result_profile`
---
## 6. 一句话结论
阶段三不是删除兼容层,而是把它们继续降级到不会抢主流程职责的位置上:
**创作中心只认 Agent 草稿和已发布作品Agent 结果页只负责预览与收口,不再继续充当旧编辑器。**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
# AI 生成过程草稿持久化设计2026-04-24
## 1. 背景
当前创作类模板已经具备 session / message / operation 级别的最终态落库能力,但部分流式生成只把模型增量推给前端。若 HTTP/SSE 连接、浏览器页面或 LLM 请求在最终解析前中断,用户只能看到短暂流式文本,服务端缺少可恢复的生成中间态。
本设计补齐“生成过程中已经生成的内容必须持续持久化”的机制,并要求该机制对所有创作模板统一生效。
## 2. 目标
1. 每次模板生成开始前创建或绑定一个 `ai_task`
2. 模型每次产出可见文本增量时,写入 `ai_text_chunk`,并同步更新 `ai_task.latest_text_output` 与对应 stage 的 `text_output`
3. 生成失败或连接中断时,不丢弃已经落库的 chunk后续可用 `ai_task.latest_text_output` 作为续写上下文。
4. 成功解析并 finalize 后,将最终结构化结果继续写回各模板原有 session 表,保持现有业务快照不变。
## 3. 统一落库边界
### 3.1 真相表
- `ai_task`:记录一次模板生成任务的业务来源、状态、最新聚合文本、结构化结果。
- `ai_task_stage`:记录模板生成阶段状态;当前创作对话统一使用 `DraftGeneration`
- `ai_text_chunk`:按 `sequence` 追加保存模型增量文本,是断点恢复的最小粒度。
### 3.2 适用模板
- 自定义世界创作 Agent。
- 解谜游戏创作 Agent。
- 大鱼吃小鱼创作 Agent。
- 后续新增模板必须复用同一生成草稿持久化工具,不允许只在 UI 内存保存流式文本。
## 4. 续写策略
1. 发起生成时,后端根据 `template_key + session_id + operation_id` 创建稳定 `task_id`
2. LLM 流式回调收到 `replyText` 的最新可见文本后,计算相对上一次文本的增量;只有非空增量写入 `ai_text_chunk`
3. 写入失败不应阻断当前生成主流程,但必须记录 warn 日志,避免因持久化瞬时失败导致用户生成直接失败。
4. 若最终解析失败,`ai_task` 保持 `Running` 或显式 `Failed`,已写入的 `latest_text_output` 仍可作为下一轮 prompt 的“已生成草稿”。
5. 下一轮续写 prompt 应优先带上最近未完成任务的 `latest_text_output`;本次先落地服务端 chunk 持久化能力,后续模板 prompt 可逐步消费该草稿。
## 5. 编码要求
1. 持久化逻辑放在 `server-rs/crates/api-server` 的通用工具中,由各模板路由接入。
2. 不引入 `server-node` 兼容分支。
3. SpacetimeDB 写入必须通过 `spacetime-client` 已生成绑定,不在 reducer 中访问网络或文件系统。
4. 所有新增 Rust 代码保留中文注释,且只做局部修改,避免重写包含中文的大文件。
## 6. 失败排查原文日志
1. RPG 草稿生成链路的模型输入与模型输出原文日志统一收口在 `platform-llm` 网关层,避免每个模板调用点重复实现。
2. 只有发生请求失败、上游非 2xx、响应读取失败、JSON/SSE 解析失败或空响应时,才将本次模型输入与已拿到的模型输出原文分别写入文件;正常成功生成不默认落盘原文,避免日志体积不可控。
3. 日志目录默认使用仓库运行目录下的 `logs/llm-raw`,可通过 `LLM_RAW_LOG_DIR` 覆盖;每次失败写成同一 trace 前缀下的 `*.input.json``*.output.txt` 两个 UTF-8 文件。
4. `*.input.json` 记录 provider、model、stream、attempt、maxTokens 与完整 messages`*.output.txt` 记录上游 HTTP 原文、非流式响应原文、SSE 原始事件文本,或请求尚未到达上游时的错误摘要。
5. 文件名只使用时间戳、进程号、递增序号与安全化错误阶段不包含用户输入、sessionId 或 API key输入 JSON 不写入 API key。
6. 文件日志失败只写 warn不影响草稿生成主错误返回该日志仅用于本地开发与排障不作为 SpacetimeDB 真相态。

View File

@@ -0,0 +1,647 @@
# 阿里云 NPC 角色形象与动作动画编辑器实验方案2026-04-07
## 1. 文档目的
本文不是再写一份泛化的“AI 角色动画大方案”,而是专门回答当前编辑器里要怎么实验这条链路:
- 接入阿里云百炼的文生图、图生图、图生视频、参考视频动作模型
-**NPC 角色形象 + 动作动画资产化** 为目标
- 最终产物仍然要落回当前项目的 `CharacterAssetPanel -> publish -> CharacterAnimator`
本文把方案拆成 4 条实验线:
1. 先文生角色形象图,再图生动作序列帧图并解析
2. 先文生角色形象图,再图生视频
3. 先文生角色形象图,再走“参考视频驱动”的动作模板链
4. 先文生角色形象图,再走“参考生视频 / 剧情演出”链
查阅与核对时间:`2026-04-07`
---
## 1.1 当前实现状态2026-04-07
当前仓库已经把下面这些能力接进 `CharacterAssetPanel`
- 阶段 A`wan2.7-image-pro / wan2.7-image` 主形象候选生成
- 阶段 B4 条动作方案都已接入真实模型
- 阶段 C方案四单独拆成“演出片段”预览区
- 方案三增加了“内置模板库”入口,可直接把项目现有角色序列帧合成为参考视频
- 最近一次主形象任务 / 动作任务状态会回显到编辑器
- 已补动作模板列表接口与视频导入接口
当前实现的本地接口为:
- `POST /api/character-visual/generate`
- `GET /api/character-visual/jobs/:id`
- `POST /api/character-visual/publish`
- `POST /api/animation/generate`
- `GET /api/animation/jobs/:id`
- `GET /api/animation/templates`
- `POST /api/animation/import-video`
- `POST /api/animation/publish`
当前视频后处理采用:
- 模型端生成真实视频
- 浏览器端抽帧、缩放、简单绿幕抠像
- 发布阶段再写入 `public/generated-animations`
也就是说,这份文档里原先一些“推荐下一步”已经落地,但还有一部分“更重的任务化路由”尚未继续拆开。
---
## 2. 当前仓库里的可复用基础
这次实验不应该另起炉灶,因为仓库里已经有 3 个很关键的基础。
### 2.1 编辑器入口已经存在
- 路由 `/character-asset-studio` 已经接到 `PresetEditor`,说明“角色资产工坊”入口是现成的。
- 当前核心页面是 `src/components/preset-editor/CharacterAssetPanel.tsx`
### 2.2 主形象 / 动作两段式 UI 已经存在
当前 `CharacterAssetPanel` 已经分成:
- 阶段 A主形象
- 阶段 B基础动作
- 阶段 C演出片段
旧版本里生成逻辑确实是本地 mock
- 主形象候选来自 `buildVisualCandidatesFromSource`
- 动作草稿来自 `buildAnimationClipFromMaster`
现在这层已经被真实模型链路替换,但仍然保留了这些本地能力作为后处理工具:
- 参考视频模板合成
- 视频抽帧
- 简单绿幕抠像
- 生成发布用帧集
### 2.3 本地 API 插件里已经有 DashScope 接入样板
本文撰写时,旧 `scripts/dev-server/localApiPlugins.ts` 里已经接了自定义世界场景图。
截至 `2026-04-19`,该文件已从仓库删除,对应样板能力应改为参考 `server-node/src/modules/assets/**``server-node/src/modules/ai/**`
- 默认 DashScope base URL 已经存在
- 已经有异步任务创建、轮询、下载、落盘、写 manifest 的完整样板
这意味着这次实验最合理的做法是:
- 继续沿用 `/api/*` 本地代理模式
- 新增角色图 / 角色动作的 job 路由
- 复用现有的任务轮询和文件落盘思路
---
## 3. 阿里云当前可直接利用的模型能力
基于 2026-04-07 查阅的阿里云官方文档,当前和本实验最相关的是下面几类能力。
| 能力 | 推荐模型 | 适合用途 | 备注 |
| --- | --- | --- | --- |
| 文生图 / 图生图 / 图像编辑 | `wan2.7-image-pro``wan2.7-image` | 生成 NPC 主形象图、做风格统一、生成组图候选 | 官方文档明确支持多图参考与组图输出 |
| 图生视频 | `wan2.7-i2v` | 单角色主形象转动作视频 | 支持首帧、首尾帧、续写片段 |
| 参考生视频 | `wan2.7-r2v``wan2.6-r2v-flash` | 多参考图/参考视频驱动剧情演出 | 更适合演出,不是最优基础动作线 |
| 图生动作 | `wan2.2-animate-move` | 主形象 + 参考动作视频 -> 标准动作视频 | 动作控制更强,适合模板动作库 |
| 视频换人 | `wan2.2-animate-mix` | 模板视频里的角色替换成 NPC 形象 | 适合动作模板“复刻” |
需要特别说明:
- 方案一会用到 `wan2.7-image-pro` 的组图 / 顺序组图能力,但 **官方并没有把它定义为“动作逐帧模型”**
- 所以方案一是“利用图像模型能力去逼近动作帧生产”的实验线,不是官方标准动作生产线。
- 方案二、三、四更贴近阿里云官方为视频生成准备的主线能力。
---
## 4. 方案一:文生角色形象图 -> 图生动作序列帧图 -> 解析成动画
## 4.1 目标
直接得到 `png` 帧集,尽量少碰视频编解码。
## 4.2 模型链路
1.`wan2.7-image-pro` 生成 NPC 主形象图
2. 再把主形象图作为参考图输入 `wan2.7-image-pro`
3. 对每个动作槽位生成一组候选图片
4. 打开组图输出,必要时启用 `enable_sequential`
5. 本地按动作顺序解析这些图,写回帧序列
## 4.3 为什么它成立
阿里云图像生成与编辑 API 当前明确支持:
- 文生图
- 图生图
- 多图参考
- 一次输出多张图
- 顺序组图输出 `enable_sequential`
因此可以在编辑器里做这样的实验:
- 输入:主形象图 + 动作描述 + 固定 seed
- 输出:同一动作的一组关键帧候选
- 后处理:按姿态差异、角色一致性、武器完整度排序,补成帧集
## 4.4 编辑器里的具体玩法
建议在当前“阶段 B基础动作”里加一个策略选项
- `帧序列实验(图像组图)`
每次动作生成时:
1. 选择动作槽位,如 `idle / run / attack / hurt`
2. 选择目标帧数,如 `4 / 6 / 8`
3. 传入主形象图
4. 拼出动作提示词,例如“同一角色,侧身朝右,单人,全身,武器完整,连续 6 帧,跑步动作,从预备到迈步再到回收”
5. 请求组图结果
6. 本地做帧序评分
7. 生成 `frames/*.png + manifest.json`
## 4.5 优点
- 直接产出图片,天然适合当前项目的帧资产结构
- 不需要先生成视频再解帧
- 某些短动作可以直接人工挑帧,编辑器可控性高
-`idle``acquire``hurt` 这种短动作实验门槛较低
## 4.6 风险
- 最大风险是帧间一致性,特别容易出现衣摆、武器、手部、头发抖动
- 组图的“顺序性”不等于真正的视频时序连续性
- `run``jump``dash` 这类长动作很可能不稳定
- 如果没有额外姿态评分和人工筛选,最后帧序会很跳
## 4.7 结论
这是 **低基础设施成本、高人工筛选成本** 的方案。
适合:
- 编辑器里先做原型实验
- 验证 NPC 主形象一致性能不能维持到多帧
- 生成短动作关键帧
不适合直接作为第一版唯一主线。
---
## 5. 方案二:文生角色形象图 -> 图生视频 -> 解帧资产化
## 5.1 目标
先让视频模型负责动作连续性,再由本地后处理把视频转成项目动画资产。
## 5.2 模型链路
1.`wan2.7-image-pro` 生成 NPC 主形象图
2.`wan2.7-i2v` 基于主形象图生成动作视频
3. 下载视频结果
4. 本地抽帧
5. 做裁切、稳帧、像素化、去闪烁
6. 输出序列帧、Sprite Sheet、manifest
## 5.3 方案二里的两种子模式
### A. 首帧生视频
适合:
- `attack`
- `hurt`
- `die`
- `cast`
特点:
- 主形象图作为 `first_frame`
- 文本控制动作
- 最快接入,链路最短
### B. 首尾帧生视频
适合:
- `idle`
- `run`
- 循环站姿
特点:
- `first_frame` 是起始站姿
- `last_frame` 是回正后的收尾姿态
- 更利于做循环动作和回到可衔接状态
## 5.4 编辑器里的具体玩法
建议在“阶段 B基础动作”里加
- `图生视频(首帧)`
- `图生视频(首尾帧)`
参数建议:
- 时长:`2s / 3s / 4s`
- 目标 FPS先统一导入到本地后再重采样
- 循环动作:是否要求首尾近似
- 提示词模板:按动作槽位固化
## 5.5 优点
- 动作连续性通常明显强于方案一
- `wan2.7-i2v` 是官方主线能力,兼容性和迭代空间更好
- 很适合作为当前编辑器的第一条“真实动作生成”主线
- 本地后处理完成后,仍然能回到当前项目的帧资源体系
## 5.6 风险
- 需要稳定的视频后处理链
- 解帧后仍要处理轮廓闪烁、脚底漂移、武器变形
- 主形象复杂时,单图生视频可能会有角色漂移
- 相比方案一I/O 和处理耗时更高
## 5.7 结论
这是 **最适合作为编辑器第一版正式实验主线** 的方案。
原因:
- 模型能力更贴近官方主线
- 动作连续性通常更稳定
- 生成结果仍可资产化
---
## 6. 方案三:文生角色形象图 -> 参考视频驱动动作模板链
## 6.1 目标
不是只靠文本“想象动作”,而是给动作一个明确模板视频,让模型做可控迁移。
## 6.2 模型链路
推荐两条可选子线:
### A. `wan2.2-animate-move`
输入:
- NPC 主形象图
- 参考动作视频
输出:
- NPC 执行该动作的视频
### B. `wan2.2-animate-mix`
输入:
- NPC 主形象图
- 模板视频
输出:
- 保留模板视频场景/动作,但把角色替换成 NPC
## 6.3 它和方案二的本质区别
方案二是:
- 主形象图 + 文本描述 -> 视频
方案三是:
- 主形象图 + 模板动作视频 -> 视频
因此方案三最大的价值不是“更自由”,而是“更可控”。
## 6.4 编辑器里的具体玩法
在“阶段 B基础动作”里新增
- `动作模板库`
每个动作槽位先配一份官方/自制模板:
- `idle_loop`
- `run_side`
- `attack_slash`
- `hurt_back`
- `die_fall`
工作流:
1. 先锁定 NPC 主形象
2. 选择动作槽位
3. 选择一个模板视频
4. 调用 `animate-move``animate-mix`
5. 下载视频
6. 解帧、稳帧、裁切
7. 发布为该动作槽位正式资产
## 6.5 优点
- 可控性明显高于纯文本图生视频
- 非常适合做“基础动作槽位不能为空”的项目要求
- 一旦模板库建立起来,多角色批量复用效率很高
-`run``attack``hurt` 这种标准动作尤其友好
## 6.6 风险
- 要先建设动作模板库
- `wan2.2-animate-move` 官方输入更偏“单人清晰主体”,对严格侧视游戏素材要额外测试
- 模板视频如果镜头、背景、构图不统一,后处理成本会增加
- 模板库前期准备成本高于方案二
## 6.7 结论
这是 **最适合做战斗基础动作标准化生产** 的方案。
如果只看“当前项目需要补齐 `idle / run / attack / hurt / die` 这些基础槽位”,方案三的长期价值甚至高于方案二。
建议排序:
- 第一阶段先做方案二跑通链路
- 第二阶段尽快把方案三补成稳定模板库主线
---
## 7. 方案四:文生角色形象图 -> 参考生视频 / 剧情演出链
## 7.1 目标
这条线不是优先服务“战斗基础动作”,而是服务:
- 剧情演出
- 招募演出
- NPC 说话/表态
- 立绘转小段表演视频
## 7.2 模型链路
推荐:
- `wan2.7-r2v`
- 成本敏感或无声短片可考虑 `wan2.6-r2v-flash`
参考生视频支持把图片、视频作为参考条件输入,再结合文本生成视频。
## 7.3 它和方案三的区别
方案三更像:
- 我已经知道动作模板,就要把它迁过去
方案四更像:
- 我给你角色参考和演出参考,请你生成一段新的镜头表达
所以它更适合:
- NPC 出场特写
- 对话演出
- 剧情镜头
- 情绪表演
不适合优先用于:
- 项目所有基础战斗动作槽位
## 7.4 编辑器里的具体玩法
当前已单独拆成:
- `演出片段`
字段建议:
- 角色主形象
- 参考图最多若干张
- 参考视频片段
- 台词或情绪提示
- 是否保留音频
输出:
- `preview.mp4`
- 关键帧截图
- 可选封面图
## 7.5 优点
- 角色一致性上限更高
- 更适合做剧情演出而不是纯动作片段
- 后续和 `CharacterChatModal`、NPC 招募、事件特写更容易联动
## 7.6 风险
- 对当前战斗帧资产体系帮助没有前三条直接
- 更容易产出“好看的视频”,但不一定容易切成稳定序列帧
- 这条线如果过早投入,会稀释基础动作资产生产的主线
## 7.7 结论
这是 **剧情演出增强线**,不建议抢在方案二、三之前做。
---
## 8. 四种方案横向对比
| 方案 | 动作连续性 | 可控性 | 资产化难度 | 适合基础动作 | 适合剧情演出 | 推荐阶段 |
| --- | --- | --- | --- | --- | --- | --- |
| 方案一:组图帧序列 | 低到中 | 中 | 低到中 | 中 | 低 | 研究线 |
| 方案二:图生视频 | 中到高 | 中 | 中到高 | 高 | 中 | 第一阶段主线 |
| 方案三:模板视频驱动 | 高 | 高 | 中到高 | 很高 | 中 | 第一阶段后半 / 第二阶段主线 |
| 方案四:参考生视频 | 中到高 | 中到高 | 高 | 中 | 很高 | 第三阶段增强 |
一句话总结:
- 要最快落地:先做 **方案二**
- 要把基础动作做稳:尽快补 **方案三**
- 要低成本试帧:可以并行试 **方案一**
- 要做剧情镜头:后续再做 **方案四**
---
## 9. 面向当前编辑器的落地状态与下一步
## 9.1 第一轮
这一轮已经完成:
- 阶段 A`wan2.7-image-pro` 主形象生成
- 阶段 B`wan2.7-i2v` 图生视频
原因:
- 最少改 UI
- 最快复用当前 `CharacterAssetPanel`
- 最容易复用现已迁入 `server-node` 的 DashScope 异步任务模式
## 9.2 第二轮
这一轮已经完成:
- `图生视频`
- `模板视频驱动`
- `帧序列实验`
并且已经补上:
- 方案三的内置模板库入口
- 方案四的独立“演出片段”区
## 9.3 第三轮
下一步仍然值得继续做的是:
- 把当前同步 `generate` 继续拆成显式 `jobs`
- 把视频导入后处理继续拆成独立 `import-video`
- 给方案三补更多正式模板素材与模板清单管理
- 给方案四补关键帧归档、封面和片段列表
---
## 10. 推荐的编辑器任务路由
当前已落地接口:
- `POST /api/character-visual/generate`
- `GET /api/character-visual/jobs/:id`
- `POST /api/character-visual/publish`
- `POST /api/animation/generate`
- `GET /api/animation/jobs/:id`
- `GET /api/animation/templates`
- `POST /api/animation/import-video`
- `POST /api/animation/publish`
当前职责:
### `POST /api/character-visual/generate`
负责:
-`wan2.7-image-pro`
- 生成主形象候选
- 下载并落盘
- 返回草稿图路径
### `GET /api/character-visual/jobs/:id`
负责:
- 返回最近一次主形象任务状态
- 返回模型、提示词、结果草稿等任务记录
### `POST /api/animation/generate`
负责:
- 按策略调不同模型
- `i2v`
- `animate-move`
- `animate-mix`
- `r2v`
- 返回顺序组图或视频草稿
### `GET /api/animation/jobs/:id`
负责:
- 返回最近一次动作任务状态
- 返回策略、模型、输出草稿路径和错误信息
### `GET /api/animation/templates`
负责:
- 返回方案三内置模板库清单
- 供编辑器选择 `idle_loop / run_side / attack_slash / hurt_back / die_fall`
### `POST /api/animation/import-video`
负责:
- 把浏览器侧生成或上传的视频导入本地草稿目录
- 返回可复用的本地视频路径
### `POST /api/animation/publish`
负责:
- 把草稿帧写入 `public/generated-animations`
- 生成动作 manifest
- 更新 `characterOverrides.json`
### 仍建议后续继续加强的部分
- 把当前“同步 generate + 立即返回结果”继续拆成更完整的异步 job 生命周期
-`import-video` 增加更重的服务端后处理,而不只是导入草稿
- 给模板库补正式素材管理与模板清单编辑
---
## 11. 第一批建议验证的动作
不要一上来就跑全量 12 个基础动作,先验证 4 个最关键动作:
1. `idle`
2. `run`
3. `attack`
4. `hurt`
原因:
- 这 4 个已经能覆盖循环动作、位移动作、攻击动作、受击动作
- 最容易测出“主形象一致性 + 动作连续性 + 贴地稳定性”
---
## 12. 具体推荐结论
如果只给当前编辑器实验一个最务实的建议:
1. **主形象统一先接 `wan2.7-image-pro`**
2. **动作第一条真链路先接方案二:`wan2.7-i2v`**
3. **基础动作标准化的主线尽快切到方案三:`wan2.2-animate-move / animate-mix`**
4. **方案一保留为低成本帧序实验线,方案四保留为剧情演出增强线**
换句话说:
- 方案二负责“尽快跑通”
- 方案三负责“真正稳定生产”
- 方案一负责“低成本试错”
- 方案四负责“后续演出升级”
---
## 13. 资料来源
阿里云官方文档:
- 图像生成与编辑 API 参考:
[https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference](https://help.aliyun.com/zh/model-studio/wan-image-generation-and-editing-api-reference)
- 图生视频 API 参考:
[https://help.aliyun.com/zh/model-studio/image-to-video-api-reference/](https://help.aliyun.com/zh/model-studio/image-to-video-api-reference/)
- 参考生视频 API 参考:
[https://help.aliyun.com/zh/model-studio/reference-to-video-api-reference/](https://help.aliyun.com/zh/model-studio/reference-to-video-api-reference/)
- 视频生成总览:
[https://help.aliyun.com/zh/model-studio/use-video-generation](https://help.aliyun.com/zh/model-studio/use-video-generation)
- 图生动作 API 参考:
[https://help.aliyun.com/zh/model-studio/wan-video-to-video-api-reference](https://help.aliyun.com/zh/model-studio/wan-video-to-video-api-reference)
仓库内相关代码与文档:
- `src/components/preset-editor/CharacterAssetPanel.tsx`
- `src/components/preset-editor/characterAssetStudioModel.ts`
- `src/components/preset-editor/characterAssetStudioPersistence.ts`
- `src/routing/appRoutes.tsx`
- `src/services/ai.ts`
- `server-node/src/modules/assets/**`
- `server-node/src/modules/ai/**`
- `docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md`

View File

@@ -0,0 +1,37 @@
# API 错误 details.message 展示修复
## 背景
`POST /api/runtime/big-fish/agent/sessions/{sessionId}/actions` 在执行 `big_fish_publish_game`Rust `api-server` 会把 SpacetimeDB 发布校验失败映射为统一 API envelope
```json
{
"ok": false,
"data": null,
"error": {
"code": "BAD_REQUEST",
"message": "请求参数不合法",
"details": {
"message": "big_fish 发布校验未通过:还缺少 16 个基础动作",
"provider": "spacetimedb"
}
}
}
```
其中 `error.message` 是通用错误分类文案,`error.details.message` 才是当前业务动作的可定位失败原因。前端通用请求解析此前只读取 `error.message`,导致界面只显示“请求参数不合法”。
## 落地口径
1. 所有通过 `parseApiErrorMessage(...)` 解析的 API 错误,优先展示 `error.details.message`
2.`error.details.message` 不存在或为空时,再回退到 `error.message`
3. 当 envelope 外层也不存在有效文案时,继续沿用原有的顶层 `message`、错误码和原始响应兜底逻辑。
4. `unwrapApiResponse(...)` 处理 `ok: false` envelope 时也复用同一优先级,避免成功响应解析路径和 HTTP 非 2xx 路径展示不一致。
5. Big Fish 结果页发布失败属于阻断性动作错误,展示为独立模态窗口,不再挤在结果页内容流里,关闭后只清掉当前错误状态,不改变草稿与资源数据。
## 验收
1. `big_fish_publish_game` 返回发布校验失败时,界面应显示 `big_fish 发布校验未通过:还缺少 16 个基础动作`
2. 没有 `details.message` 的旧错误响应仍显示原 `error.message`
3. 非 JSON 错误响应仍显示原始响应文本。
4. Big Fish 结果页错误以居中模态窗口展示,并可通过关闭按钮回到结果页继续补资源。

View File

@@ -0,0 +1,124 @@
# API Server 角色主形象真实外部生成运行修复记录
日期:`2026-04-23`
## 1. 文档目的
这份文档用于记录本次为了恢复 `api-server` 角色主形象真实外部生成链路而做的最小修复项,避免后续再次出现“源码已切到真实 DashScope + OSS但实际运行的仍是旧二进制占位链”的误判。
## 2. 背景
在人工验证 `POST /api/assets/character-visual/generate` 时,运行中的本地 `api-server` 返回了 `.svg` 候选图,这与当前 `server-rs/crates/api-server/src/character_visual_assets.rs` 已切到 DashScope 真实图片生成的源码状态不一致。
进一步核查发现,问题不在角色主形象实现本身,而在于当前工作区存在若干增量改动没有补齐编译链,导致 Rust `api-server` 无法重新编译启动,本地仍在运行旧版本二进制。
## 3. 本次最小修复项
### 3.1 `spacetime-client` 缺少 `serde` 依赖
文件:
`server-rs/crates/spacetime-client/Cargo.toml`
现象:
1. 新增 `BigFishWorkSummaryRecord` 时使用了 `serde::Serialize / serde::Deserialize`
2. `Cargo.toml` 未声明 `serde`
3. 导致 `cargo check -p api-server --bin api-server` 在依赖阶段直接失败
修复:
1.`spacetime-client` 补充 `serde = { version = "1", features = ["derive"] }`
### 3.2 `password_entry` 错误映射漏掉 `InvalidPublicUserCode`
文件:
`server-rs/crates/api-server/src/password_entry.rs`
现象:
1. `module-auth``PasswordEntryError` 新增了 `InvalidPublicUserCode`
2. `api-server` 侧的错误映射 `match` 未覆盖该分支
3. 导致 `api-server` 编译失败
修复:
1.`map_password_entry_error(...)` 中补充 `InvalidPublicUserCode`
2. 返回中文错误文案 `叙世号格式不正确`
### 3.3 `module-custom-world` 的 `Display` 分支未覆盖新字段错误
文件:
`server-rs/crates/module-custom-world/src/lib.rs`
现象:
1. `CustomWorldFieldError` 新增了 `MissingPublicWorkCode`
2. `impl fmt::Display for CustomWorldFieldError` 未覆盖该枚举分支
3. 导致依赖 `module-custom-world``api-server` 编译链继续失败
修复:
1.`MissingPublicWorkCode` 补充显示文案
2. 文案口径为 `custom_world_gallery_detail.public_work_code 不能为空`
### 3.4 `spacetime-module / spacetime-client` 绑定链路需要重新同步
文件:
1. `server-rs/crates/spacetime-module/src/lib.rs`
2. `server-rs/crates/spacetime-module/src/big_fish/*.rs`
3. `server-rs/crates/spacetime-client/src/lib.rs`
4. `server-rs/crates/spacetime-client/src/module_bindings/*`
现象:
1. `custom_world` 新增 `public_work_code / author_public_user_code` 后,`spacetime-module``spacetime-client` 的手写 facade / 自动生成 bindings 不一致
2. `spacetime generate` 无法顺利完成,导致 `spacetime-client` 继续引用过期 schema
3. `Big Fish` 子模块拆分后,子文件缺少表 accessor trait 导入,阻断 wasm 构建与 bindings 生成
修复:
1. 补齐 `Big Fish` 子模块对表 accessor trait 的导入
2. 补齐 `CustomWorldPublishWorldInput` 在 agent 发布动作中的新字段
3. 补齐 `spacetime-client``publish_custom_world_profile``get_custom_world_gallery_detail_by_code` 的 facade 映射
4. 重新执行:
```powershell
spacetime generate --no-config --lang rust --out-dir D:\Genarrative\server-rs\crates\spacetime-client\src\module_bindings --module-path D:\Genarrative\server-rs\crates\spacetime-module --include-private --yes
```
说明:
1. 这一步完成后,`spacetime-client` 已重新拿到最新 `custom_world_*` / `big_fish_*` bindings
2. `wasm-opt` 缺失只影响优化,不影响 bindings 生成与本地运行验证
## 4. 修复后结论
修复完成后,执行:
```powershell
cargo check -p api-server --bin api-server
```
已通过。
## 5. 新运行结果
在新的本地 `api-server` 实例上执行:
`POST /api/assets/character-visual/generate`
返回结果已经从旧 `.svg` 候选切换为真实 `.png`
`/generated-character-drafts/codex-direct-test-character-v2/visual/aitask_6501f99c694c3/candidate-01.png`
同时通过 OSS 签名读取再次确认:
1. `HTTP 200`
2. `Content-Type: image/png`
3. PNG 文件头校验通过
这说明当前源码级的 Rust `api-server` 已具备重新启动并承载角色主形象真实外部图片生成链的条件,本地旧 SVG 返回问题的根因就是运行进程落后于当前源码与 bindings 状态。

View File

@@ -0,0 +1,56 @@
# api-server 本地 Rust 栈冷编译等待修复记录
日期:`2026-04-25`
## 1. 背景
本地执行 `npm run dev:rust` 时,日志出现:
```text
[dev:rust] 等待 api-server 就绪
Compiling api-server v0.1.0
[dev:rust] 等待 api-server 就绪超时: http://127.0.0.1:8082/healthz
[dev:rust] 停止 api-server
error: linking with `link.exe` failed: exit code: 143
```
这类失败发生在 `api-server` 仍处于 `cargo run` 的冷编译或链接阶段时,`/healthz` 还没有机会监听端口。
## 2. 根因
根目录 `scripts/dev-rust-stack.sh` 同时使用 `SPACETIME_TIMEOUT_SECONDS=60` 控制:
1. SpacetimeDB standalone 的启动等待。
2. Rust `api-server``/healthz` 就绪等待。
SpacetimeDB 的本地启动通常较快,但 `api-server` 在 Windows MSVC 链接、依赖增量失效、首次构建或新增大依赖后可能超过 60 秒。脚本在超时后执行清理逻辑,主动杀掉仍在运行的 `cargo run` 子进程,因此 `link.exe exit code: 143` 是被本地栈脚本中断后的表现,不应优先判断为 Visual Studio Build Tools 损坏。
## 3. 修复口径
`scripts/dev-rust-stack.sh` 将 SpacetimeDB 与 `api-server` 的等待窗口拆开:
1. `SPACETIME_TIMEOUT_SECONDS` 继续只控制 SpacetimeDB 就绪等待,默认 `60` 秒。
2. 新增 `API_SERVER_TIMEOUT_SECONDS` 控制 `api-server` `/healthz` 就绪等待,默认 `300` 秒。
3. 新增命令行参数 `--api-timeout-seconds <seconds>` 便于本地低性能机器或全量重编译时临时放宽。
4. `api-server` 进程如果在等待窗口内自行退出,仍立即报错,不吞掉真实编译错误。
## 4. 使用方式
常规本地启动继续使用:
```bash
npm run dev:rust
```
如本地需要更长冷编译窗口,可执行:
```bash
npm run dev:rust -- --api-timeout-seconds 600
```
## 5. 验收标准
1. 冷编译期间脚本不会在 60 秒时误杀 `cargo run -p api-server`
2. `/healthz` 真正可访问后,脚本继续启动 Vite。
3. 如果 `api-server` 编译失败或运行时提前退出,脚本仍能快速停止并输出原始错误。
4. SpacetimeDB 启动异常仍使用独立的 `--spacetime-timeout-seconds` 判断。

View File

@@ -0,0 +1,122 @@
# `api-server` 接入 `platform-llm` 最小代理设计2026-04-21
## 1. 目标
`platform-llm` 已落成真实 Rust crate 后,`api-server` 需要尽快拥有一条可正式消费的平台接线面,避免平台层只停留在“可编译但未接入”状态。
本次目标只做最小闭环:
1.`api-server` 配置层补齐 LLM 文本网关环境变量
2.`AppState` 注入 `platform-llm::LlmClient`
3. 提供 `/api/llm/chat/completions` 非流式兼容代理
4. 保持与旧 Node 路由的鉴权位置和基本请求形态一致
## 2. 本次范围
### 2.1 本次实现
1. `AppConfig` 新增 LLM provider / base url / api key / model / timeout / retry 配置
2. `AppState` 初始化 `LlmClient`
3. 新增 `shared-contracts::llm`
4. 新增 `api-server/src/llm.rs`
5. 路由挂载到 `/api/llm/chat/completions`
### 2.2 本次不实现
1. 不实现 SSE 流式透传
2. 不实现通用原样 body 转发
3. 不实现媒体模型路由
4. 不把 `module-ai` 编排接进来
## 3. 兼容口径
保持与旧 Node `POST /api/llm/chat/completions` 一致的基本语义:
1. 需要登录态
2. 接收 `model? + stream + messages[]`
3. 当前 `stream=true` 明确返回 `501`,避免伪装支持
4. 非流式返回统一后的文本结果,而不是原样上游 JSON
## 4. 返回结构
Rust 首版返回:
1. `id`
2. `model`
3. `content`
4. `finishReason`
原因:
1. 当前 Rust 平台层已经把上游 `choices[0].message.content` 归一完成
2. `api-server` 首版先保持稳定、可消费的文本结果接口
3. 真正需要 OpenAI 完全兼容响应体时,再单独补“原样代理模式”
## 5. 验收
1. `api-server` 能在配置合法时成功构建 `AppState`
2. `/api/llm/chat/completions` 能通过测试打到 mock 上游
3. `stream=true` 返回明确错误
4. crate 级 `check/test` 通过
## 6. 环境变量与默认值
`api-server` 首版按以下优先级解析 LLM 配置,保证兼容仓库现有 `.env` 口径:
1. provider`GENARRATIVE_LLM_PROVIDER` -> `LLM_PROVIDER`
2. base url`GENARRATIVE_LLM_BASE_URL` -> `LLM_BASE_URL`
3. api key`GENARRATIVE_LLM_API_KEY` -> `LLM_API_KEY` -> `ARK_API_KEY`
4. model`GENARRATIVE_LLM_MODEL` -> `LLM_MODEL` -> `VITE_LLM_MODEL`
5. timeout`GENARRATIVE_LLM_REQUEST_TIMEOUT_MS` -> `LLM_REQUEST_TIMEOUT_MS`
6. max retries`GENARRATIVE_LLM_MAX_RETRIES` -> `LLM_MAX_RETRIES`
7. retry backoff`GENARRATIVE_LLM_RETRY_BACKOFF_MS` -> `LLM_RETRY_BACKOFF_MS`
默认值统一对齐 `platform-llm`
1. provider`ark`
2. base url`https://ark.cn-beijing.volces.com/api/v3`
3. model`doubao-1-5-pro-32k-character-250715`
4. request timeout`30000`
5. max retries`1`
6. retry backoff`500`
补充约束:
1. 如果 `api key` 未配置,`api-server` 允许继续启动,但 `/api/llm/chat/completions` 返回 `503`
2. 如果 provider 字符串非法,回退到默认 `ark`,避免因为环境变量拼写问题阻断开发态服务
## 7. 错误映射
`platform-llm` 到 HTTP 的错误映射固定如下:
1. `InvalidRequest` -> `400 BAD_REQUEST`
2. `InvalidConfig` -> `503 SERVICE_UNAVAILABLE`
3. `Timeout` / `Connectivity` / `Transport` / `Deserialize` / `EmptyResponse` / `StreamUnavailable` -> `502 BAD_GATEWAY`
4. `Upstream(status=429)` -> `429 TOO_MANY_REQUESTS`
5. 其他 `Upstream` -> `502 BAD_GATEWAY`
6. `stream=true` 首版直接返回 `501 NOT_IMPLEMENTED`
## 8. 角色扮演模型联网搜索补充2026-04-25
### 8.1 目标
角色扮演运行时调用文本模型生成剧情正文、NPC 对话、战斗演出文本时,需要默认允许模型使用上游联网搜索能力,提升现实题材、时代背景、地名器物、文化细节的准确度。
### 8.2 落地范围
1. `platform-llm``LlmTextRequest` 增加 `enable_web_search` 布尔开关,默认 `false`,避免影响普通平台代理和非剧情调用。
2. `api-server` 配置增加 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` / `RPG_LLM_WEB_SEARCH_ENABLED`,默认 `true`
3.`runtime_story` 兼容链路中的角色扮演剧情文本请求按配置开启联网搜索。
4. `/api/llm/chat/completions` 通用代理不默认开启联网搜索,避免外部调用方在无感情况下产生额外成本或不可预期内容来源。
### 8.3 上游请求口径
1. 当前默认文本模型走火山方舟 OpenAI 兼容 Chat Completions 路由。
2. 联网搜索开启时,请求体追加 `web_search_options: {}`;关闭时不序列化该字段。
3. 若后续迁移到 Responses API 或更换 provider`platform-llm` 统一收口字段映射,业务层仍只使用 `enable_web_search` 语义开关。
### 8.4 验收
1. `platform-llm` 单测能捕获开启搜索时上游 JSON 包含 `web_search_options`
2. `api-server` 配置单测能验证角色扮演搜索开关默认开启、环境变量可关闭。
3. 角色扮演剧情、NPC 对话、推理战斗文本请求都通过同一辅助函数设置搜索开关,避免漏接。

View File

@@ -0,0 +1,141 @@
# api-server SpacetimeClient 连接池化设计 2026-04-23
更新时间:`2026-04-23`
## 1. 背景
当前 `api-server` 虽然在 `AppState` 中只持有一个 `SpacetimeClient` 实例,但 `spacetime-client` 内部仍然是:
1. 每次 procedure / reducer 调用都执行一次 `DbConnection::builder().build()`
2. 建连后立即 `run_threaded()`
3. 拿到结果后立刻 `disconnect()`
也就是说,当前问题不是 `api-server` 每次请求都 new 一个 client而是
**每次 client 调用都新建并销毁一条 SpacetimeDB 连接。**
## 2. 本轮目标
本轮不继续维持“每次 HTTP 请求一条短连接”的阶段性策略。
本轮目标改为:
1. `api-server` 进程内预热并持有一组可复用的 SpacetimeDB 连接
2. 每次 HTTP 请求只从池里借一个可用连接执行 procedure / reducer
3. 请求完成后归还连接,不主动断开
4. 连接失效时自动剔除并按需重建
## 3. 为什么不直接引第三方池库
当前仓库使用的是 `spacetimedb-sdk` 生成的 `DbConnection`,不是传统 SQL client。
它的连接模型包含:
1. `on_connect`
2. `on_disconnect`
3. `run_threaded`
4. reducer / procedure callback
这类对象不是标准的 `bb8` / `deadpool` 资源接口。
当前仓库也没有已经接入的通用资源池库,因此本轮优先在 `spacetime-client` 内实现最小可控池化层,而不是强行套第三方 SQL 风格池库。
## 4. 池化设计
## 4.1 结构
`SpacetimeClient` 内新增一个共享池状态:
1. `pool_size`
2. `Semaphore`
3. `Vec<Mutex<Option<PooledConnection>>>`
其中 `PooledConnection` 持有:
1. `DbConnection`
2. `run_threaded` 返回的后台线程句柄
3. 连接唯一 id
## 4.2 借还模型
每次调用 procedure / reducer 时:
1. 先获取 `Semaphore permit`
2. 选取一个空闲槽位
3. 若槽位已有健康连接,则直接复用
4. 若槽位为空或连接已坏,则现场重建
5. 调用完成后归还槽位,但不主动断开连接
## 4.3 健康判断
当前阶段不做复杂心跳表。
最小健康策略如下:
1. procedure / reducer callback 正常完成:连接保持在池中
2. 连接在调用期间触发 `on_disconnect`:标记该槽位失效
3. 下次借用该槽位时重建连接
## 4.4 并发策略
不共享同一个 `DbConnection` 给多个并发请求同时发 procedure。
原因:
1. SDK callback 是异步回调模型
2. 当前仓库调用层没有 request id 级别的统一 dispatcher
3. 多请求共用一条连接容易把回调和调用方绑定关系搞乱
所以本轮采取:
**一个池槽位同一时刻只服务一个请求。**
这本质上是“连接池”,不是“多路复用单连接”。
## 5. 默认规模
默认池大小取小值,避免本地开发和轻量部署浪费连接:
1. 默认 `4`
2. 允许通过环境变量覆盖,例如 `GENARRATIVE_SPACETIME_POOL_SIZE`
## 6. 错误与超时策略
沿用现有 `SpacetimeClientError` 口径:
1. 建连失败:`Build` / `Runtime`
2. 连接在返回前断开:`ConnectDropped``Procedure`
3. 调用超时:`Timeout`
新增规则:
1. 借用池槽位超时,也映射为 `Timeout`
2. 某槽位一旦确认断线,必须在池中清空,不能继续复用脏连接
3. procedure / reducer 等待结果无论成功、失败还是超时,都必须先归还租约再向上层返回,避免槽位泄漏把池卡死
4. 调用期间若连接先收到 `on_disconnect`,当前阶段只标记坏连接;若业务回调未及时返回,则最终由调用超时路径统一清槽并回传错误
## 7. 与现有文档的关系
之前 [`AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md`](D:/Genarrative/docs/technical/AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md)
中写明“当前阶段每次 HTTP 请求可以建立一条短连接,待真实链路验证稳定后再评估连接池或长连接复用”。
本轮就是进入这个“下一阶段”:
1. 保留 `on_connect` 后再发请求的约束
2. 去掉“请求完成立即断开”的短连接策略
3. 改成 `spacetime-client` 进程内连接池
## 8. 验收标准
落地后至少满足:
1. `api-server` 启动后,`SpacetimeClient` 不再为每次调用单独建连
2. 同一进程内连续多个 API 请求可以复用池中连接
3. 单个连接断开后不会污染后续请求
4. `api-server` 调用侧无需修改业务 handler
## 9. 一句话结论
本轮不引第三方 SQL 风格池库,而是在 `spacetime-client` 内实现一层:
**面向 `DbConnection` 的最小连接池,让 `api-server` 复用长活连接,而不是每次调用都单独建连。**

View File

@@ -0,0 +1,140 @@
# 资产对象业务实体绑定 reducer 设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 `M6` 中“对象绑定业务实体 reducer”的首版落地方案。
当前已经完成:
1. 浏览器可通过 `PostObject` 把文件直传到私有 OSS。
2. `POST /api/assets/objects/confirm` 已能确认对象存在。
3. `asset_object` 已按 `bucket + object_key` 写入 SpacetimeDB。
下一步要补上的最小闭环是:
1. 已确认的 `asset_object` 能绑定到某个业务实体。
2. 绑定关系由 SpacetimeDB 持久化。
3. Axum 提供最小 HTTP facade避免前端直接拼 SpacetimeDB reducer 参数。
## 2. 当前阶段不直接创建强业务表的原因
当前先落通用 `asset_entity_binding`,不直接创建 `character_visual_asset / scene_image_asset / sprite_sheet_asset`
原因固定如下:
1. 角色、场景、精灵等强业务表的完整字段还没有冻结。
2. 当前最紧急的工程闭环是“确认后的对象能被实体引用”,不是完整发布模型。
3. 通用绑定表可以先承接旧接口迁移中的 `entityId + slot` 关系,后续再由强业务表逐步替换或派生。
## 3. 表设计
首版新增 private table
1. `asset_entity_binding`
字段如下:
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `binding_id` | `String` | 是 | 主键,固定 `assetbind_` 前缀。 |
| `asset_object_id` | `String` | 是 | 被绑定的 `asset_object.asset_object_id`。 |
| `entity_kind` | `String` | 是 | 业务实体类型,例如 `character``scene``profile`。 |
| `entity_id` | `String` | 是 | 业务实体 ID。 |
| `slot` | `String` | 是 | 实体上的资产槽位,例如 `primary_visual``cover``sprite_sheet`。 |
| `asset_kind` | `String` | 是 | 资产类型,例如 `character_visual`。 |
| `owner_user_id` | `Option<String>` | 否 | 归属用户,当前仅作为服务端传入的记录字段。 |
| `profile_id` | `Option<String>` | 否 | 归属 profile。 |
| `created_at` | `Timestamp` | 是 | 首次绑定时间。 |
| `updated_at` | `Timestamp` | 是 | 最近绑定更新时间。 |
索引如下:
1. `entity_kind + entity_id + slot`
用于按实体槽位查当前绑定。
2. `asset_object_id`
用于按对象反查被哪些业务实体引用。
## 4. 幂等规则
绑定写入按以下规则执行:
1. `asset_object_id` 必须已存在于 `asset_object`
2. `entity_kind + entity_id + slot` 作为首版幂等定位键。
3. 同一实体槽位重复绑定时,不新增第二行。
4. 重复绑定会复用原 `binding_id``created_at`,更新 `asset_object_id / asset_kind / owner_user_id / profile_id / updated_at`
5. 不同槽位可以绑定同一个 `asset_object_id`
## 5. reducer / procedure 设计
SpacetimeDB 新增:
1. `bind_asset_object_to_entity`
reducer只返回 `Result<(), String>`,供后续模块内部复用。
2. `bind_asset_object_to_entity_and_return`
procedure面向 Axum 同步接口返回最终绑定快照。
procedure 返回结构采用:
1. `ok`
2. `record`
3. `error_message`
`asset_object` 确认 procedure 保持一致,便于 `spacetime-client` 做统一错误映射。
## 6. Axum HTTP facade
首版新增接口:
`POST /api/assets/objects/bind`
请求体:
| 字段名 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `assetObjectId` | `String` | 是 | 已确认对象 ID。 |
| `entityKind` | `String` | 是 | 业务实体类型。 |
| `entityId` | `String` | 是 | 业务实体 ID。 |
| `slot` | `String` | 是 | 资产槽位。 |
| `assetKind` | `String` | 是 | 资产类型。 |
| `ownerUserId` | `String` | 否 | 当前阶段由后端调用方显式传入。 |
| `profileId` | `String` | 否 | 当前阶段由后端调用方显式传入。 |
响应体核心字段:
1. `bindingId`
2. `assetObjectId`
3. `entityKind`
4. `entityId`
5. `slot`
6. `assetKind`
7. `ownerUserId`
8. `profileId`
9. `createdAt`
10. `updatedAt`
## 7. 当前阶段安全边界
当前接口是 Axum facade不是前端直接调用 SpacetimeDB reducer 的最终权限模型。
约束如下:
1. 当前不把长期 OSS AK/SK 下发给客户端。
2. 当前不让客户端直接写 private table。
3. `owner_user_id` 当前只作为记录字段,不作为可信授权依据。
4. 后续接入 SpacetimeDB 身份透传后,绑定 reducer 的授权必须改为基于可信身份,不信任客户端传入的用户 ID。
## 8. 完成定义
首版完成条件:
1. `module-assets` 提供绑定输入、快照、结果结构与字段校验。
2. `spacetime-module` 新增 `asset_entity_binding` 表与绑定 reducer/procedure。
3. `spacetime-client` 生成最新 Rust bindings 并封装绑定 procedure。
4. `api-server` 暴露 `POST /api/assets/objects/bind`
5. 本地测试覆盖字段错误与 “asset_object 不存在不能绑定”。
## 9. 一句话结论
当前阶段先用通用 `asset_entity_binding` 把已确认 OSS 对象绑定到业务实体槽位,强业务资产表等字段稳定后再继续拆分。

View File

@@ -0,0 +1,315 @@
# 图片、视频、动作外部生成手动验证运行手册
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结 `验证清单.md` 第四项“图片、视频、动作的生成要真实走到外部服务的生成服务上,而不是用占位符来敷衍”的验证口径。
本次先解决两个问题:
1. 当前仓库里“真实外部生成链”和“Stage 1 占位兼容链”同时存在,若不先写清楚,很容易把占位产物误记为通过。
2. 现有技术设计文档描述了多条资产链,但没有一份面向人工联调的统一运行手册,导致每次验证都要重新猜入口、猜日志、猜通过标准。
## 2. 当前结论总览
截至 `2026-04-23` 当前代码状态,第 4 项仍不能整体直接判定“已通过”,原因是不同资产链状态不同。
### 2.1 当前已经接入真实外部图片生成的入口
以下入口当前会真实请求外部图片生成服务,而不是只生成本地占位图:
1. `Big Fish` 结果页:
- `生成背景`
- `生成并应用正式图` -> `Lv.x 主图`
- `生成并应用正式图` -> `Lv.x 动作工坊`
2. `custom world / RPG 创作`
- 场景图生成
- 作品封面 AI 生成
这些入口当前统一会走 Rust `api-server`,并向 DashScope 图片生成接口发起请求,再落到 OSS 与兼容读路径。
### 2.2 当前仍未完全闭环的入口
以下入口当前仍不能直接判定为“动作资产全后端闭环”:
1. 角色资产工坊 `image-sequence`
- 当前生成的是服务端 SVG 帧,不是真实外部序列图模型结果。
2. 角色资产工坊 `motion-transfer / reference-to-video`
- 当前仍未接入真实外部模型主链。
3. 角色资产工坊 `image-to-video`
- 当前已真实请求 Ark 生成 OSS 草稿区 `preview.mp4`
- 但正式帧抽取和去绿幕仍在前端浏览器完成,再回传后端发布。
因此:
1. 第 4 项里“图片真实外部生成”目前可以做人工验证。
2. 第 4 项里“视频真实外部生成”已有 `image-to-video` 主链证据,但“动作正式资产全后端闭环”仍需要继续验证与收口,不能把前端抽帧回传链直接记成完全通过。
## 3. 代码级判定依据
### 3.1 已接真实外部图片服务的依据
#### 3.1.1 Big Fish 正式图片链
`server-rs/crates/api-server/src/big_fish.rs`
当前 `generate_big_fish_formal_asset(...)` 会执行:
1. 读取 Big Fish 草稿 prompt
2. 调用 `require_big_fish_dashscope_settings(...)`
3. 调用 `create_big_fish_text_to_image_generation(...)`
4. 向 DashScope `text2image/image-synthesis` 发起异步任务请求
5. 下载远端生成图片
6. 上传 OSS
7. 确认 `asset_object`
8. 绑定到 Big Fish 槽位
这条链已经不是占位图写盘。
#### 3.1.2 Custom World 场景图与封面图
`server-rs/crates/api-server/src/custom_world_ai.rs`
当前 `create_text_to_image_generation(...)``create_reference_image_generation(...)` 会:
1. 真实请求 DashScope 图片生成接口
2. 轮询任务状态或解析生成结果
3. 下载远端图片
4. 上传 OSS
5. 生成 `asset_object` 与实体绑定
因此场景图、AI 封面图当前属于“真实外部图片生成”。
### 3.2 仍未完全闭环的依据
#### 3.2.1 角色动作资产工坊
`server-rs/crates/api-server/src/character_animation_assets.rs`
当前链路现状:
1. `image-to-video` 已真实请求 Ark 生成视频
2. 成功结果会下载并写入 `generated-character-drafts/*/preview.mp4`
3. `publish` 当前仍读取前端传入的 `framesDataUrls`
4. 前端仍通过 `HTMLVideoElement + canvas` 自行抽帧并做去绿幕
因此当前状态应判定为“真实外部视频生成主链已完成,但正式动作资产后端闭环尚未完成”。
## 4. 本次验证范围
本次人工验证分成两部分。
### 4.1 可直接操作并验证通过/失败的范围
1. Big Fish 主图生成是否真实打到 DashScope
2. Big Fish 动作工坊静态关键帧图是否真实打到 DashScope
3. Big Fish 背景图是否真实打到 DashScope
4. Custom World 场景图是否真实打到 DashScope
5. Custom World AI 封面图是否真实打到 DashScope
### 4.2 本次要明确记录为“未通过”的范围
1. 角色资产工坊 `生成角色形象`
2. 角色资产工坊 `生成动作`
3. 任何依赖仓库内占位视频或 SVG 帧的动作生成入口
这些入口本次可以操作,但只能用于确认“当前仍未完全闭环”的具体断点,不能把前端抽帧回传链计入“动作资产全后端闭环”通过证据。
## 5. 前置条件
开始验证前,必须同时满足以下条件:
1. 仓库根目录 `.env.local` 已配置:
- `DASHSCOPE_API_KEY`
- `ALIYUN_OSS_BUCKET`
- `ALIYUN_OSS_ENDPOINT`
- `ALIYUN_OSS_ACCESS_KEY_ID`
- `ALIYUN_OSS_ACCESS_KEY_SECRET`
2. 本机已安装:
- `cargo`
- `node`
- `spacetime`
- `ffmpeg`
- `ffprobe`
3. 本地端口可用或已有可复用 Rust 栈:
- Web`3000`
- Rust API`8082`
- SpacetimeDB`3101`
4. 必须使用 Rust 栈,而不是旧 Node 栈。
说明:
1. 当前 Vite 前端必须指向 Rust `api-server`,否则会把验证结果混入旧链路。
2. 验证时必须能实时查看 Rust `api-server` 日志。
## 6. 启动方式
推荐统一使用:
```powershell
npm run dev:rust
```
该命令会完成以下动作:
1. 启动本地 `SpacetimeDB standalone`
2. 发布 `server-rs/crates/spacetime-module`
3. 启动 Rust `api-server`
4. 启动 Vite Web 开发服务器
若已有栈在运行,至少确认:
1. Web 可访问:`http://127.0.0.1:3000`
2. Rust API 为当前前端的实际代理目标
3. `api-server` 正在输出日志
## 7. 手动验证入口
### 7.1 Big Fish 正式图片链
前端路径:
1. 打开 `http://127.0.0.1:3000`
2. 进入平台创作入口
3. 选择 `Big Fish`
4. 先完成草稿编译
5. 进入结果页
6. 在结果页依次操作:
- `生成背景`
- 打开某个等级的 `主图工坊`,点击 `生成并应用正式图`
- 打开某个等级的 `动作工坊`,点击 `生成并应用正式图`
期望日志特征:
1. Rust `api-server` 中出现 `provider = dashscope`
2. 有 Big Fish 正式图片生成请求
3. 有 DashScope 任务创建或轮询相关日志
4. 生成成功后出现 OSS 写入或正式路径返回
前端期望结果:
1. 资源 URL 不再是 `/generated-big-fish/...`
2. 而是 `/generated-big-fish-assets/...`
3. 结果页状态显示为 `已生成`,而不是 `占位已生成`
### 7.2 Custom World 场景图
前端路径:
1. 进入 RPG / Custom World 创作流程
2. 打开场景或地标编辑入口
3. 点击场景图生成相关操作
期望日志特征:
1. Rust `api-server` 中出现 `provider = dashscope`
2. 有图片生成任务创建与轮询
3. 成功后有 OSS 对象写入和读取兼容路径
前端期望结果:
1. 返回图片不是本地 SVG 占位
2. 保存后场景主图可稳定显示
### 7.3 Custom World AI 封面图
前端路径:
1. 进入作品编辑页
2. 打开 `编辑作品封面`
3. 选择 `AI 生成作品封面`
4. 输入封面氛围提示词
5. 点击生成并保存
期望日志特征:
1. Rust `api-server` 中出现 `provider = dashscope`
2. 有封面图生成任务
3. 成功后有 OSS 上传与对象确认日志
前端期望结果:
1. 生成结果可预览
2. 保存后作品封面更新为正式图
### 7.4 角色资产工坊反向验证
前端路径:
1. 打开任一角色的 AI 资产工坊
2. 点击 `生成角色形象`
3. 再点击 `生成动作`
本入口的验证目标不是“通过”,而是确认它当前仍未接真实外部视频/图片服务。
期望证据:
1. `生成角色形象` 返回的是 SVG 草稿候选
2. `生成动作` 若未导入参考视频,会回退预置占位视频
3. 日志或结果模型字段不应被当作真实外部视频生成通过证据
## 8. 通过标准
第 4 项只有在以下条件全部满足时,才能勾成通过:
1. 至少一条图片生成入口已拿到真实外部服务调用证据。
2. 至少一条视频或动作生成入口已拿到真实外部服务调用证据。
3. 这些证据不能依赖 SVG 占位、仓库内预置视频或本地占位文件。
4. 前端结果能与日志中的正式链路一一对应。
换言之:
1. 仅图片链通过,不代表第 4 项整体通过。
2. 仅 Big Fish 动作工坊生成出一张静态图,也不等于“视频/动作真实生成”通过。
## 9. 当前预判结论
按当前代码基线,本次更可能得到以下结论:
1. 图片真实外部生成:可以拿到通过证据。
2. 视频、动作真实外部生成:`image-to-video` 主链已可拿到真实外部视频生成证据,但正式动作资产后端闭环仍需要继续收口。
因此本次人工验证完成后,建议把第 4 项拆成至少两条独立清单:
1. 图片生成真实外部服务验证
2. 视频生成真实外部服务验证
3. 动作正式资产后端闭环验证
否则会把“已完成的图片链 / 视频生成链”与“仍未完成的正式动作发布后端闭环”混成一个模糊状态。
## 10. 失败判定与排查
### 10.1 图片入口失败
优先看 Rust `api-server` 日志中的错误文本:
1. `dashscope api key 未配置`
- 说明环境变量缺失。
2. `构造 DashScope HTTP 客户端失败`
- 说明本地网络或 TLS 运行环境异常。
3. `读取生成响应失败`
- 说明上游请求已发出,但响应解析失败。
4. `下载远端图片失败`
- 说明上游已生成图片,但下载或签名读链出错。
5. OSS 相关错误
- 说明生成已成功,但落 OSS 或确认对象失败。
### 10.2 角色资产工坊“看起来成功”
若角色工坊前端看起来成功,不应立刻视为通过,需要先核对:
1. 当前策略是否是 `image-sequence / motion-transfer / reference-to-video`
2. 若是 `image-to-video``preview.mp4` 是否来自真实 Ark 生成
3. 正式发布是否仍要求前端回传 `framesDataUrls`
若只是“后端出真实视频、前端再抽帧回传”,则只能记为“视频生成主链通过,正式动作发布后端闭环未完成”,不能直接把整条动作资产链记为完全通过。
## 11. 关联文档
1. [BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md](./BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md)
2. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
3. [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
4. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md)
5. [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)
6. [M6_CHARACTER_ANIMATION_BACKEND_FRAME_EXTRACTION_AND_PUBLISH_STAGE3_2026-04-23.md](./M6_CHARACTER_ANIMATION_BACKEND_FRAME_EXTRACTION_AND_PUBLISH_STAGE3_2026-04-23.md)

View File

@@ -0,0 +1,185 @@
# 资产对象上传完成确认接口设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于把 `M6` 中“上传完成后的对象确认接口”冻结到可直接编码的级别。
当前要解决的不是完整资产发布链,而是最小闭环:
1. 浏览器先通过 `PostObject` 把文件上传到私有 OSS
2. 服务端确认对象真实存在
3. 服务端把对象元数据写入当前阶段的 `asset_object` 真相存储
4. 后续业务绑定与 SpacetimeDB reducer 再基于这条已确认对象继续扩展
## 2. 当前前提
已落地事实:
1. `POST /api/assets/direct-upload-tickets` 已能签发浏览器 `PostObject` 直传票据。
2. `platform-oss` 已能生成私有读签名 URL。
3. `asset_object` 已在 `spacetime-module` 中落下首版表骨架。
当前仍未落地:
1. 上传完成确认接口
2. 对象 HEAD 校验
3. `asset_object` 实际写入路径
4. 业务实体绑定
## 3. 接口职责
`POST /api/assets/objects/confirm` 当前阶段只负责三件事:
1. 校验请求给出的 `bucket + object_key` 是否合法
2. 调 OSS 做一次私有 `HEAD Object` 校验,确认对象真实存在
3. 把对象元数据写入当前阶段的 `asset_object` 进程内存储,并返回确认结果
当前阶段明确不做:
1. 不做业务实体绑定
2. 不做图片尺寸探测
3. 不做 hash 计算
4. 不做重复对象合并
5. 不直接调用 SpacetimeDB reducer
## 4. 请求体设计
请求路径:
`POST /api/assets/objects/confirm`
请求体:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `bucket` | `String` | 否 | 当前阶段允许不传;不传时默认回落到服务端 OSS bucket。 |
| `objectKey` | `String` | 是 | 正式对象路径真相字段。 |
| `contentType` | `String` | 否 | 客户端已知 MIME可回写到对象元数据。 |
| `contentLength` | `u64` | 否 | 客户端可传期望大小;当前仅用于一致性校验,不作为唯一真相来源。 |
| `contentHash` | `String` | 否 | 后续内容摘要预留字段。 |
| `assetKind` | `String` | 是 | 业务资产类型,例如 `character_visual`。 |
| `accessPolicy` | `String` | 否 | 默认 `private`。 |
| `sourceJobId` | `String` | 否 | 来源任务 ID。 |
| `ownerUserId` | `String` | 否 | 归属用户 ID。 |
| `profileId` | `String` | 否 | 归属 profile ID。 |
| `entityId` | `String` | 否 | 归属业务实体 ID。 |
补充约束:
1. `bucket` 当前若传入,必须与服务端已配置 bucket 一致。
2. `objectKey` 必须落在受支持的 `generated-*` 前缀下。
3. `assetKind` 当前不能为空。
## 5. 校验顺序
接口校验顺序固定如下:
1. 检查 OSS 配置是否存在
2. 校验请求参数基础合法性
3. 校验 `bucket` 与服务端配置 bucket 是否一致
4. 调用 OSS `HEAD Object`
5. 若客户端传了 `contentLength`,则与 OSS 返回的真实 `Content-Length` 做一致性校验
6. 通过后写入 `asset_object`
## 6. OSS 校验结果口径
OSS `HEAD Object` 当前至少回填:
1. `content_length`
2. `content_type`
3. `last_modified_at`
4. `etag`
当前阶段以 OSS 返回值为准:
1. `content_length` 真相取 OSS
2. `content_type` 优先取 OSSOSS 未返回时再回退请求体
3. `content_hash` 暂不强行等于 `etag`
原因:
1. `etag` 对 multipart 上传和不同上传模式并不总等价于内容 hash
2. 当前阶段先留出字段,不把错误假设固化进 schema
## 7. 写入规则
确认成功后写入 `asset_object`
1. `asset_object_id` 由服务端生成,固定 `assetobj_` 前缀
2. `bucket``object_key` 按正式真相写入
3. `access_policy` 当前默认 `private`
4. `content_length` 以 OSS HEAD 为准
5. `content_type` 优先 OSS HEAD
6. `version` 当前固定为 `1`
7. `created_at` / `updated_at` 在确认时写当前 UTC 时间
当前阶段重复确认同一 `bucket + object_key` 的行为固定为:
1. 若已存在,则返回已存在记录并更新 `updated_at`
2. 不生成第二条重复对象记录
## 8. 响应体设计
成功响应核心字段:
1. `assetObjectId`
2. `bucket`
3. `objectKey`
4. `accessPolicy`
5. `contentType`
6. `contentLength`
7. `contentHash`
8. `assetKind`
9. `sourceJobId`
10. `ownerUserId`
11. `profileId`
12. `entityId`
13. `version`
14. `createdAt`
15. `updatedAt`
## 9. 错误口径
### 9.1 请求参数错误
返回 `400`
1. `objectKey` 为空
2. `objectKey` 不在受支持前缀下
3. `assetKind` 为空
4. `bucket` 与当前配置 bucket 不一致
5. 客户端声明的 `contentLength` 与 OSS HEAD 不一致
### 9.2 OSS 未配置
返回 `503`
### 9.3 OSS 对象不存在
返回 `404`
### 9.4 OSS 探测失败
返回 `502`
## 10. 当前阶段实现边界
当前阶段实现固定为:
1. `platform-oss` 增加服务端 `HEAD Object` helper
2. `module-assets` 提供进程内 `asset_object` 确认服务
3. `api-server` 接入 `POST /api/assets/objects/confirm`
下一阶段再继续:
1. 对接真实 SpacetimeDB reducer
2. 业务实体绑定 reducer
3. 更细的元数据探测
## 11. 关联文档
1. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
2. [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
3. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)

View File

@@ -0,0 +1,102 @@
# `/api/auth/login-options` 登录方式选项设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 Rust `api-server` 首版 `GET /api/auth/login-options` 的返回 contract、配置来源与当前阶段边界确保前端在登录页读取“当前可用登录方式”时不需要依赖硬编码开关。
## 2. 当前目标
当前阶段只解决一件事:
1.`Axum` 根据服务端配置,返回当前环境启用的登录方式列表。
本阶段明确不包含:
1. 短信或微信登录链路本身是否已经完整落地
2. 对前端返回更细粒度的 provider 配置
3. 第三方登录按钮文案、图标或 UI 布局规则
## 3. 接口 contract
### 3.1 请求
1. 方法:`GET`
2. 路径:`/api/auth/login-options`
3. 鉴权:不需要
4. 请求体:空
### 3.2 成功响应
```json
{
"availableLoginMethods": ["phone", "wechat"]
}
```
字段说明:
1. `availableLoginMethods` 为字符串数组
2. 当前阶段只允许出现:
- `phone`
- `wechat`
### 3.3 返回顺序
返回顺序固定为:
1.`phone`
2.`wechat`
这样可以保证前端按钮顺序稳定,不因配置解析顺序变化而漂移。
## 4. 配置来源
`api-server` 只读取以下布尔配置:
1. `SMS_AUTH_ENABLED`
2. `WECHAT_AUTH_ENABLED`
映射规则固定为:
1. `SMS_AUTH_ENABLED=true` 时返回 `phone`
2. `WECHAT_AUTH_ENABLED=true` 时返回 `wechat`
3. 两者都关闭时返回空数组
## 5. crate 边界
### 5.1 `api-server`
负责:
1. 读取 `AppState.config`
2. 组装 `availableLoginMethods`
3. 返回项目兼容的响应 envelope
### 5.2 `module-auth`
本接口当前阶段不依赖 `module-auth`
### 5.3 前端
负责:
1. 根据 `availableLoginMethods` 决定是否展示手机号 / 微信入口
2. 不再假设某种登录方式一定存在
## 6. 测试要求
至少覆盖:
1. 默认配置下返回空数组
2. 同时启用短信与微信时返回 `["phone", "wechat"]`
## 7. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 已提供 `GET /api/auth/login-options`
2. 响应字段命名与前端约定一致
3. 配置开关可稳定映射到返回数组
4. 文档、任务清单与测试已同步更新

View File

@@ -0,0 +1,177 @@
# `/api/auth/logout-all` 全端登出落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现全端登出` 的首版落地,冻结:
1. `POST /api/auth/logout-all` 的请求与响应 contract
2. 全部 refresh session 吊销与 `token_version` 递增的组合语义
3. Rust 首版在进程内鉴权真相中的最小实现边界
4.`/logout``/sessions/:sessionId/revoke` 的职责切分
## 2. 当前基线
当前 Node `/api/auth/logout-all` 已具备以下稳定语义:
1. 必须先通过 Bearer JWT 校验
2. 对当前用户执行 `token_version + 1`
3. 吊销该用户全部未吊销 refresh session
4. 响应成功时始终清理 refresh cookie
因此Node 的“退出全部设备”同样是两层组合动作:
1. 会话级:吊销同一账号全部 refresh session
2. 用户级:递增 `token_version`,让全部旧 access token 立即失效
Rust 首版必须保留这个语义。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加按 `user_id` 吊销全部 refresh session 的能力
2. `api-server` 暴露 `POST /api/auth/logout-all`
3. 成功场景统一清理 refresh cookie
本阶段明确不包含:
1. `/api/auth/sessions/:sessionId/revoke`
2. 审计日志正式落表
3. SpacetimeDB reducer 真正写表
## 4. contract
### 4.1 请求
1. 方法:`POST`
2. 路径:`/api/auth/logout-all`
3. 请求体:空
4. 鉴权:
- Bearer JWT 必填
- refresh cookie 选填
### 4.2 成功响应
```json
{
"ok": true
}
```
同时响应头必须写回清理后的 refresh cookie。
### 4.3 失败响应
以下情况返回 `401 UNAUTHORIZED`
1. Bearer JWT 缺失或非法
2. JWT 对应用户不存在
## 5. 固定语义
### 5.1 动作顺序
`POST /api/auth/logout-all` 固定按以下顺序执行:
1. 从 Bearer JWT 解析当前用户
2. 批量吊销当前用户全部 refresh session
3. 对当前用户执行 `token_version + 1`
4. 返回 `ok: true`
5. 始终清理 refresh cookie
### 5.2 `token_version` 只递增一次
无论当前用户存在多少会话:
1. `logout-all` 只递增一次 `token_version`
2. 不为每条 session 单独递增版本号
### 5.3 缺少 refresh cookie 不影响成功
`logout-all` 是账号级动作,不依赖当前 refresh cookie 命中:
1. 即使当前设备没有 refresh cookie也要允许完成全端登出
2. 成功响应仍然统一清理 cookie
## 6. 与其他接口的职责切分
### 6.1 `/api/auth/logout`
负责:
1. 当前设备退出
2. 当前 refresh session 尽力吊销
3. `token_version` 递增一次
### 6.2 `/api/auth/logout-all`
负责:
1. 全部设备退出
2. 当前用户全部 refresh session 吊销
3. `token_version` 递增一次
### 6.3 `/api/auth/sessions/:sessionId/revoke`
后续负责:
1. 只吊销指定远端设备 refresh session
2. 不递增 `token_version`
## 7. crate 边界
### 7.1 `module-auth`
负责:
1.`user_id` 吊销全部 refresh session
2. 递增当前用户 `token_version`
3. 返回最新用户快照
### 7.2 `platform-auth`
负责:
1. 构造清理 cookie 的 `Set-Cookie`
### 7.3 `api-server`
负责:
1. Bearer JWT 读取与校验
2. 调用 `module-auth` 执行全端登出
3. 始终回写清理 cookie
## 8. 进程内实现策略
当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力:
1. `revoke_all_sessions_by_user_id`
2. `logout_all_sessions`
其中:
1. 批量吊销只改 `revoked_at`
2. 用户版本递增继续直接修改内存用户快照
## 9. 测试策略
至少覆盖:
1. 登录两次后调用 `/api/auth/logout-all` 返回 `ok: true`
2. `/logout-all` 成功后清理 refresh cookie
3. `/logout-all` 成功后旧 Bearer token 访问 `/api/auth/me` 返回 `401`
4. `/logout-all` 成功后旧 refresh cookie 调用 `/api/auth/refresh` 返回 `401`
5. 缺少 refresh cookie 时,只要 Bearer token 有效,`/logout-all` 仍返回 `ok: true`
## 10. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `POST /api/auth/logout-all`
2. 同一用户全部 refresh session 可被吊销
3. 用户 `token_version` 会在全端登出时递增
4. `/logout-all` 总会清理 refresh cookie
5. 文档、任务清单与测试已同步更新

View File

@@ -0,0 +1,209 @@
# `/api/auth/logout` 当前会话吊销落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现会话吊销` 任务的第一段首版落地,冻结:
1. `POST /api/auth/logout` 的请求与响应 contract。
2. 当前设备退出时 refresh session 吊销与 `token_version` 递增的组合语义。
3. Rust 首版在进程内鉴权真相中的最小实现边界。
4. 与后续 `logout-all``sessions/:sessionId/revoke` 的职责切分。
## 2. 当前基线
当前 Node `/api/auth/logout` 已具备以下稳定语义:
1. 必须先通过 Bearer JWT 校验。
2. 从 cookie 中读取当前 refresh token并尝试吊销对应 refresh session。
3. 无论当前 refresh session 是否已存在,只要用户存在,仍继续执行“退出当前设备”。
4. 对当前用户执行 `token_version + 1`,使当前 access token 全局失效。
5. 响应成功时始终清理 refresh cookie。
因此Node 的“退出当前设备”实际是两层组合动作:
1. 设备级:吊销当前 refresh session
2. 用户级:递增 `token_version`,让当前 access token 立即失效
Rust 首版必须保留这个语义。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加当前 refresh session 吊销能力。
2. `module-auth` 增加用户 `token_version` 递增能力。
3. `api-server` 暴露 `POST /api/auth/logout`
4. 成功或已失效场景统一清理 refresh cookie。
本阶段明确不包含:
1. `/api/auth/logout-all`
2. `/api/auth/sessions`
3. `/api/auth/sessions/:sessionId/revoke`
4. 审计日志与风控日志正式落表
5. SpacetimeDB reducer 真正写表
## 4. contract
### 4.1 请求
1. 方法:`POST`
2. 路径:`/api/auth/logout`
3. 请求体:空
4. 鉴权:
- Bearer JWT 必填
- refresh cookie 选填但应尽量提供
### 4.2 成功响应
```json
{
"ok": true
}
```
同时响应头必须写回清理后的 refresh cookie。
### 4.3 失败响应
以下情况返回 `401 UNAUTHORIZED`
1. Bearer JWT 缺失或非法
2. JWT 对应用户不存在
说明:
1. 当前 refresh cookie 缺失本身不构成 `/logout` 失败。
2. 因为当前设备可能已经没有 refresh cookie但 access token 仍应允许执行显式退出。
## 5. 固定语义
### 5.1 当前设备退出的动作顺序
`POST /api/auth/logout` 固定按以下顺序执行:
1. 从 Bearer JWT 解析当前用户。
2. 尝试按当前 refresh cookie 吊销 refresh session。
3. 对当前用户执行 `token_version + 1`
4. 返回 `ok: true`
5. 始终清理 refresh cookie。
### 5.2 refresh session 吊销是“尽力而为”
当 refresh cookie 缺失、refresh token 无法命中 session、session 已吊销时:
1. 不把这些情况视为 `/logout` 失败。
2. 继续执行用户级 `token_version` 递增。
原因:
1. 当前设备退出的主目标是让“现在这份 access token”立刻失效。
2. refresh session 丢失不应该阻断显式退出。
### 5.3 `token_version` 必须递增
当前阶段固定规则:
1. `/logout` 必须递增 `user.token_version`
2. 后续 Bearer JWT 校验必须比对当前用户最新 `token_version`
说明:
1. 如果不递增,当前 access token 直到自然过期前仍可继续访问。
2. 这与 Node 当前行为不一致,也会让“退出登录”在用户感知上失真。
## 6. 与其他接口的职责切分
### 6.1 `/api/auth/logout`
负责:
1. 当前设备退出
2. 当前 access token 立即失效
3. 当前 refresh session 尽力吊销
### 6.2 `/api/auth/logout-all`
后续负责:
1. 吊销同一用户全部 refresh session
2. 递增一次 `token_version`
### 6.3 `/api/auth/sessions/:sessionId/revoke`
后续负责:
1. 只吊销指定远端设备 refresh session
2. 不递增 `token_version`
## 7. crate 边界
### 7.1 `module-auth`
负责:
1. 按 refresh token hash 吊销当前 session。
2. 递增当前用户 `token_version`
3. 返回退出后最新用户快照,供后续 access token 校验使用。
### 7.2 `platform-auth`
负责:
1. refresh token 哈希
2. 构造清理 cookie 的 `Set-Cookie`
### 7.3 `api-server`
负责:
1. Bearer JWT 与 refresh cookie 的读取
2. 调用 `module-auth` 组合执行当前设备退出
3. 始终回写清理 cookie
## 8. 进程内实现策略
当前阶段 `module-auth` 继续使用进程内真相,新增以下最小能力:
1. `revoke_session_by_refresh_token_hash`
2. `increment_user_token_version`
其中:
1. session 吊销要写入 `revoked_at`
2. 用户版本递增要直接修改内存中用户快照
## 9. Bearer JWT 校验补强
为了让 `/logout` 后“旧 access token 立即失效”真正成立,当前阶段需要补一条约束:
1. Bearer JWT 校验通过签名后,还必须比对 claims 里的 `ver`
2.`claims.ver != 当前用户 token_version`,返回 `401`
说明:
1. 这是当前 Rust 鉴权链路必须补上的一致性校验。
2. 否则 `logout` 虽然递增了用户版本,但旧 JWT 仍能继续访问。
## 10. 测试策略
至少覆盖:
1. 登录成功后调用 `/api/auth/logout` 返回 `ok: true`
2. `/logout` 成功后会清理 refresh cookie
3. `/logout` 成功后旧 Bearer token 再访问 `/api/auth/me` 返回 `401`
4. refresh cookie 缺失时,只要 Bearer token 有效,`/logout` 仍返回 `ok: true`
5. 用户不存在时 `/logout` 返回 `401`
## 11. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `POST /api/auth/logout`
2. 当前 refresh session 可按 cookie 对应关系被吊销
3. 用户 `token_version` 会在退出时递增
4. Bearer JWT 已补充版本比对
5. `/logout` 总会清理 refresh cookie
6. 文档、任务清单与测试已同步更新

View File

@@ -0,0 +1,121 @@
# `/api/auth/me` 查询落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现 me 查询` 任务的首版落地,冻结:
1. `GET /api/auth/me` 的请求与响应 contract。
2. 当前阶段 Bearer JWT 与用户快照读取的衔接方式。
3. `availableLoginMethods` 的返回口径。
4. JWT 有效但本地用户不存在时的错误处理语义。
## 2. 当前基线
当前 Node `/api/auth/me` 具备以下最小语义:
1. 必须先通过 Bearer JWT 校验。
2.`sub = user_id` 读取当前用户。
3. 返回 `user + availableLoginMethods`
4. `availableLoginMethods` 只返回当前对外开启的补充登录方式,不包含 `password`
Rust 首版需要保留这条最小 contract但当前阶段允许继续使用进程内仓储承接用户真相。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加按 `user_id` 查询当前用户能力。
2. `api-server` 暴露 `GET /api/auth/me`
3. 返回与当前前端兼容的 `user + availableLoginMethods`
本阶段不包含:
1. `refresh token` 轮换。
2. 会话列表、审计、风控等扩展信息。
3. `SpacetimeDB` 真正的身份表读取。
## 4. contract
### 4.1 请求
1. 方法:`GET`
2. 路径:`/api/auth/me`
3. 鉴权:`Authorization: Bearer <token>`
### 4.2 成功响应
```json
{
"user": {
"id": "user_00000001",
"username": "guest_001",
"displayName": "guest_001",
"phoneNumberMasked": null,
"loginMethod": "password",
"bindingStatus": "active",
"wechatBound": false
},
"availableLoginMethods": []
}
```
说明:
1. 当前阶段 `user` 字段固定返回当前登录用户快照,不返回 `null`
2. `availableLoginMethods` 只按当前对外配置返回:
- `SMS_AUTH_ENABLED=true` 时包含 `phone`
- `WECHAT_AUTH_ENABLED=true` 时包含 `wechat`
3. `password` 不进入 `availableLoginMethods`,保持和 Node 现状一致。
## 5. 错误语义
### 5.1 缺少或非法 Bearer token
1. 返回 `401 UNAUTHORIZED`
### 5.2 JWT 有效但用户不存在
1. 返回 `401 UNAUTHORIZED`
2. 语义视为“当前登录态已失效,需要重新登录”
说明:
1. 当前阶段不把这种情况返回为 `404`
2. 这样可以与后续 `token_version`、会话吊销和用户禁用策略保持同一类恢复路径。
## 6. crate 边界
### 6.1 `module-auth`
负责:
1. 提供按 `user_id` 查询当前用户快照的能力。
2. 继续复用密码登录阶段已经建立的同一份进程内用户真相。
### 6.2 `api-server`
负责:
1. 复用现有 Bearer JWT 中间件拿到 `sub`
2. 调用 `module-auth` 查询用户。
3. 组装 `AuthMeResponse`
## 7. 测试策略
至少覆盖:
1. 已登录用户可通过 `/api/auth/me` 取回当前用户。
2. 当短信/微信开关开启时,`availableLoginMethods` 返回对应值。
3. JWT 有效但用户不存在时返回 `401`
## 8. 后续衔接
这条任务完成后,下一步顺序固定为:
1. refresh token 轮换
2. 会话吊销
3. 手机验证码登录
微信登录继续按“暂缓执行”处理。

View File

@@ -0,0 +1,205 @@
# `/api/auth/refresh` 轮换落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现 refresh token 轮换` 任务的首版落地,冻结:
1. `POST /api/auth/refresh` 的请求与响应 contract。
2. refresh cookie、服务端 refresh session 与 access token 三者的职责边界。
3. “会话 ID 稳定、refresh token 可轮换”的固定语义。
4. Rust 首版在未切入 SpacetimeDB 前的临时进程内实现方式。
## 2. 当前基线
当前 Node `/api/auth/refresh` 已具备以下稳定语义:
1. 从 HttpOnly cookie 中读取原始 refresh token。
2. 服务端只按 `refresh_token_hash` 查找当前活跃会话。
3. refresh 成功后,不新建第二条会话,而是在原会话上轮换 refresh token。
4. 轮换时会更新 `expires_at``last_seen_at`
5. 成功后返回新的 access token并写回新的 refresh cookie。
6. 失败时会主动清空 refresh cookie要求前端重新登录。
Rust 首版必须保留以上语义。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加进程内 refresh session 真相与轮换服务。
2. `api-server` 暴露 `POST /api/auth/refresh`
3. 登录成功时创建 refresh session。
4. refresh 成功时在原 session 上轮换 refresh token。
5. access token 的 `sid` 固定改为稳定 `session_id`,不再直接复用 refresh token。
本阶段不包含:
1. `/api/auth/logout`
2. `/api/auth/logout-all`
3. `/api/auth/sessions`
4. `/api/auth/sessions/:sessionId/revoke`
5. SpacetimeDB reducer 真正写表
## 4. contract
### 4.1 请求
1. 方法:`POST`
2. 路径:`/api/auth/refresh`
3. 请求体:空
4. 鉴权来源refresh cookie
### 4.2 成功响应
```json
{
"token": "<access-token>"
}
```
同时响应头必须写回新的 refresh cookie。
### 4.3 失败响应
当 refresh token 缺失、会话不存在、会话已过期或用户不存在时:
1. 返回 `401 UNAUTHORIZED`
2. 同时清理 refresh cookie
## 5. 固定语义
### 5.1 session_id 与 refresh token 必须拆开
从本任务开始固定以下规则:
1. `session_id` 是稳定会话主键。
2. refresh token 是可轮换的会话凭证。
3. access token 的 `sid` 必须写入 `session_id`
4. refresh 轮换只更新 refresh token不更改 `session_id`
禁止继续把 refresh token 直接塞进 JWT `sid`
### 5.2 refresh 是“原会话轮换”
refresh 成功后:
1. 保留原 `session_id`
2. 生成新的原始 refresh token
3. 用新的 `refresh_token_hash` 覆盖旧值
4. 更新 `expires_at`
5. 更新 `last_seen_at`
不允许新建第二条 session。
## 6. crate 边界
### 6.1 `module-auth`
负责:
1. 管理 refresh session 进程内真相。
2. 提供创建 refresh session 与轮换 refresh session 的用例。
3. 提供按 `user_id` 查询用户快照的能力,供 refresh 成功后重新签发 access token。
不负责:
1. 生成原始 refresh token。
2. 读写 cookie。
3. 签发 JWT。
### 6.2 `platform-auth`
负责:
1. 生成原始 refresh token。
2. 对 refresh token 做哈希。
3. 构造 refresh cookie 的 `Set-Cookie` 头。
4. 从 cookie header 中读取 refresh token。
### 6.3 `api-server`
负责:
1. 从请求 cookie 中提取 refresh token。
2. 调用 `module-auth` 执行 refresh session 轮换。
3. 根据用户快照与稳定 `session_id` 重新签发 access token。
4. refresh 失败时清理 cookie。
## 7. 进程内存储模型
当前阶段 `module-auth` 继续使用进程内内存仓储承接 refresh session字段至少包括
1. `session_id`
2. `user_id`
3. `refresh_token_hash`
4. `issued_by_provider`
5. `expires_at`
6. `created_at`
7. `updated_at`
8. `last_seen_at`
9. `revoked_at`
说明:
1. 这只是 SpacetimeDB 正式落地前的阶段性实现。
2. 字段命名与语义继续对齐 [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)。
## 8. 流程
### 8.1 登录创建 session
密码登录成功后:
1. `api-server` 生成原始 refresh token。
2. `api-server` 计算 `refresh_token_hash`
3. `module-auth` 创建一条新 session并返回稳定 `session_id`
4. `api-server` 用该 `session_id` 写入 access token 的 `sid`
5. `api-server` 把原始 refresh token 写回 cookie。
### 8.2 refresh 轮换 session
当请求 `POST /api/auth/refresh` 时:
1. 从 cookie 中读取原始 refresh token。
2. 计算 `refresh_token_hash`
3. `module-auth` 查找当前活跃 session。
4. 校验 `expires_at > now``revoked_at == null`
5. 读取该 session 对应用户。
6. 生成新的原始 refresh token。
7. 用新 hash 更新同一条 session。
8. 返回新的 access token 与新的 refresh cookie。
## 9. 错误语义
以下情况统一返回 `401`
1. 缺少 refresh cookie
2. refresh token 命中不到 session
3. refresh session 已过期
4. refresh session 已吊销
5. session 对应用户不存在
错误文案统一保持中文,并沿用“当前登录态已失效,请重新登录”这类恢复导向语义。
## 10. 测试策略
至少覆盖:
1. 登录成功后可用 cookie 调用 `/api/auth/refresh`
2. refresh 成功会写回新的 cookie
3. refresh 成功返回新的 access token
4. refresh 后旧 refresh token 立即失效
5. 缺少 cookie 时返回 `401`
6. 无效 refresh token 时返回 `401` 且清理 cookie
## 11. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `POST /api/auth/refresh`
2. access token `sid` 已改为稳定 `session_id`
3. refresh token 轮换成功时不创建新会话。
4. refresh 失败时会清理 cookie。
5. 文档、任务清单与测试已同步更新。

View File

@@ -0,0 +1,66 @@
# Auth refresh session 持久化热修方案
日期:`2026-04-24`
## 1. 背景
当前 Rust 鉴权链路已经具备 refresh cookie 自动续签能力access token 过期后,前端会调用 `POST /api/auth/refresh`,后端轮换 refresh token 并返回新的 access token。
`module-auth` 当前仍使用进程内 `InMemoryAuthStore` 保存账号与 refresh session。只要 `server-rs` 在 access token 生命周期内发生重启,浏览器侧 HttpOnly cookie 仍然存在,服务端却找不到对应账号与 session最终表现为约 `JWT_EXPIRES_IN` 后需要重新登录。
## 2. 本次目标
本次先落一个低风险持久化闭环,解决“后端重启导致 2 小时后必须重新登录”的线上体验问题:
1. 为当前 `InMemoryAuthStore` 增加 UTF-8 JSON 快照文件。
2. 在账号、手机号索引、微信身份、refresh session 发生变更后自动保存快照。
3. `api-server` 启动时从配置路径恢复快照。
4. 保持现有 `/api/auth/refresh``logout``sessions` 语义不变。
## 3. 非目标
本次不把完整认证域一次性迁入 SpacetimeDB 表,原因是 refresh session 独立持久化不足以解决问题refresh 成功后还需要按 `user_id` 读取账号快照重新签发 access token因此账号主数据也必须同源恢复。
SpacetimeDB 正式表接管仍按以下既有文档继续推进:
1. `docs/technical/SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md`
2. `docs/technical/SPACETIMEDB_AUTH_IDENTITY_TABLE_DESIGN_2026-04-21.md`
3. `docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`
## 4. 配置
新增环境变量:
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `GENARRATIVE_AUTH_STORE_PATH` | `server-rs/.data/auth-store.json` | 当前 Rust 鉴权快照文件路径。相对路径按进程工作目录解析。 |
## 5. 数据边界
快照文件保存当前 Rust 鉴权服务已经在内存中维护的最小真相:
1. `next_user_id`
2. `users_by_username`
3. `phone_to_user_id`
4. `sessions_by_id`
5. `session_id_by_refresh_token_hash`
6. `wechat_identity_by_provider_uid`
7. `user_id_by_provider_union_id`
短信验证码和微信 OAuth state 不持久化,原因是它们是短生命周期挑战数据,重启后失效是可接受行为。
## 6. 安全约束
1. refresh token 原文仍只存在浏览器 HttpOnly cookie快照只保存 `sha256(refresh_token)`
2. 快照包含 `password_hash`、手机号映射和 refresh token hash部署时必须放在服务端私有目录不允许暴露到静态资源目录。
3. 快照写入必须使用 UTF-8并通过临时文件原子替换降低写坏风险。
## 7. 后续 SpacetimeDB 接管点
`user_account``auth_identity``refresh_session` 表及 reducer 全部落地后,替换策略如下:
1. 保留 `module-auth` 用例语义。
2. 把当前快照仓储替换为 SpacetimeDB 仓储适配器。
3. 启动时可提供一次性导入脚本,把 JSON 快照导入 SpacetimeDB 表。
4. 导入完成后禁用 `GENARRATIVE_AUTH_STORE_PATH` 快照写入。

View File

@@ -0,0 +1,184 @@
# `/api/auth/sessions` 会话列表与多端标识查询设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``兼容 /api/auth/sessions` 的首版落地,冻结:
1. `GET /api/auth/sessions` 的请求与响应 contract
2. 当前设备识别方式与 `isCurrent` 语义
3. 多端登录识别字段如何从 `refresh_session` 派生到 DTO
4. Rust 首版在 Axum + 进程内 `module-auth` 下的最小实现边界
## 2. 当前基线
当前 Node `/api/auth/sessions` 已具备以下稳定行为:
1. 依赖 Bearer JWT 确认用户身份
2. 从 refresh cookie 识别当前设备
3. 返回当前账号全部未吊销活跃会话
4. 每条记录给出端侧标签、最近活跃时间、到期时间、IP 脱敏信息与是否当前设备
当前问题是:
1. 旧实现只能粗略给出“网页端浏览器 / 移动端浏览器”
2. 无法稳定区分同设备不同浏览器
3. 无法区分微信内 H5 与微信小程序、小程序平台来源
因此本次 `/api/auth/sessions` 首版落地必须直接承接多端会话身份模型。
## 3. 设计输入
本任务直接受以下文档约束:
1. [MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md](./MULTI_DEVICE_SESSION_IDENTITY_DESIGN_2026-04-21.md)
2. [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)
3. [AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](./AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
4. [AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md](./AUTH_LOGOUT_CURRENT_SESSION_DESIGN_2026-04-21.md)
## 4. 首版落地范围
本阶段只落以下内容:
1. `module-auth` 提供按 `user_id` 读取活跃 refresh session 列表的能力
2. `api-server` 暴露 `GET /api/auth/sessions`
3. 登录创建 session 时落库结构化客户端身份字段
4. 会话列表返回多端识别所需字段,并兼容旧 `clientLabel`
本阶段明确不包含:
1. `/api/auth/sessions/:sessionId/revoke`
2. 前端完整消费全部新增字段
3. SpacetimeDB reducer / view 正式读表
## 5. 请求与响应 contract
### 5.1 请求
1. 方法:`GET`
2. 路径:`/api/auth/sessions`
3. 请求体:空
4. 鉴权:
- Bearer JWT 必填
- refresh cookie 选填但建议携带,用于判断 `isCurrent`
### 5.2 成功响应
```json
{
"sessions": [
{
"sessionId": "usess_xxx",
"clientType": "web_browser",
"clientRuntime": "chrome",
"clientPlatform": "windows",
"clientLabel": "Windows / Chrome",
"deviceDisplayName": "Windows / Chrome",
"miniProgramAppId": null,
"miniProgramEnv": null,
"userAgent": "Mozilla/5.0 ...",
"ipMasked": "203.0.*.*",
"isCurrent": true,
"createdAt": "2026-04-21T10:00:00Z",
"lastSeenAt": "2026-04-21T10:05:00Z",
"expiresAt": "2026-05-21T10:00:00Z"
}
]
}
```
字段说明:
1. `clientLabel` 当前阶段继续兼容旧前端字段,值固定与 `deviceDisplayName` 保持一致
2. `clientRuntime``clientPlatform``deviceDisplayName` 是多端识别首版最小新增字段
3. 小程序来源额外暴露 `miniProgramAppId``miniProgramEnv`
### 5.3 失败响应
以下情况返回 `401 UNAUTHORIZED`
1. Bearer JWT 缺失或非法
2. Bearer JWT 对应用户不存在
仓储读取失败返回 `500 INTERNAL_SERVER_ERROR`
## 6. 当前设备识别规则
`isCurrent` 固定按以下规则判断:
1. 从 refresh cookie 读取当前原始 refresh token
2. 在 Axum 侧计算 `sha256(refresh_token)`
3. 与会话列表中的 `refresh_token_hash` 比较
4. 命中则 `isCurrent = true`
说明:
1. 如果请求没有携带 refresh cookie本接口仍可返回会话列表
2. 此时全部会话的 `isCurrent` 都为 `false`
## 7. 多端标识派生规则
### 7.1 后端入库字段
登录创建会话时Axum 必须先解析并写入:
1. `client_type`
2. `client_runtime`
3. `client_platform`
4. `client_instance_id`
5. `device_fingerprint`
6. `device_display_name`
7. `mini_program_app_id`
8. `mini_program_env`
9. `user_agent`
10. `ip`
### 7.2 DTO 派生规则
会话列表返回时:
1. `clientType = client_type`
2. `clientRuntime = client_runtime`
3. `clientPlatform = client_platform`
4. `deviceDisplayName = device_display_name`
5. `clientLabel = device_display_name`
6. `miniProgramAppId = mini_program_app_id`
7. `miniProgramEnv = mini_program_env`
## 8. crate 边界
### 8.1 `module-auth`
负责:
1. 保存 refresh session 客户端身份快照
2.`user_id` 返回活跃会话列表
3. 保持 refresh 轮换后 `session_id` 稳定、客户端身份字段不漂移
### 8.2 `api-server`
负责:
1. 读取 Bearer JWT 与 refresh cookie
2. 把活跃会话映射成旧接口兼容 DTO
3. 派生 `ipMasked``isCurrent`
## 9. 测试策略
至少覆盖:
1. 同一账号在同平台不同浏览器登录后,会话列表能返回两条不同运行时记录
2. 微信内 H5 登录后,会话列表返回 `wechat_h5 + wechat_embedded_browser`
3. 显式小程序头优先于 `User-Agent` 判断
4. 请求携带当前 refresh cookie 时,只有当前会话 `isCurrent = true`
## 10. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `GET /api/auth/sessions`
2. 会话列表可区分普通浏览器、微信内 H5、小程序来源
3. 同设备不同浏览器可在会话列表中清晰区分
4. `clientLabel` 与新增多端字段都已稳定返回
5. 文档、任务清单与测试已同步更新

View File

@@ -0,0 +1,52 @@
# Auth SpacetimeDB 正式表恢复 Stage 3
## 1. 阶段目标
本阶段把认证持久化从“只依赖整包快照恢复”推进到“正式认证表优先恢复”。
落地口径:
- `user_account``auth_identity``refresh_session` 作为 SpacetimeDB 中的正式认证持久化表。
- API 启动时优先从正式表导出兼容 `module-auth` 的认证快照,再恢复到内存认证服务。
- 运行期认证变更仍先复用现有 `module-auth` 逻辑生成一致快照,随后同步快照并导入正式表,保证正式表与快照一致。
- 本阶段不重写登录、刷新、登出内部业务规则,避免在 JWT、refresh rotation、微信绑定合并等复杂语义中引入行为漂移。
## 2. 非目标
- 不在本阶段把 `PasswordEntryService``PhoneAuthService``RefreshSessionService` 改造成直接调用 SpacetimeDB reducer。
- 不在前端增加认证规则说明文案。
- 不删除 Stage 1 快照表;快照表继续作为导入载体与回滚兜底。
## 3. 运行流程
### 3.1 启动恢复
1. API 调用 `export_auth_store_snapshot_from_tables`
2. 若正式表已有用户、身份或会话数据,则返回兼容 `module-auth` 的 JSON 快照。
3. API 用 `InMemoryAuthStore::from_snapshot_json` 恢复认证服务。
4. 若正式表为空或调用失败,则回退到 Stage 1 的 `auth_store_snapshot`
5. 若 Stage 1 也不可用,则回退本地 JSON 热修复文件。
### 3.2 运行期同步
1. 登录、刷新、登出等路径继续调用当前内存认证服务。
2. 每次认证状态变更后调用 `upsert_auth_store_snapshot`
3. 快照写入成功后调用 `import_auth_store_snapshot`,覆盖导入正式表。
4. 导入失败时返回错误,避免用户误以为状态已经持久化。
## 4. 数据重建规则
- `users_by_username``user_account.username` 作为 key 重建。
- `phone_to_user_id` 由 provider 为 `phone``auth_identity` 重建。
- `wechat_identity_by_provider_uid` 由 provider 为 `wechat``auth_identity` 重建。
- `user_id_by_provider_union_id` 由微信身份中非空 `provider_union_id` 重建。
- `sessions_by_id``refresh_session.session_id` 重建。
- `session_id_by_refresh_token_hash``refresh_session.refresh_token_hash` 重建。
- `next_user_id` 取现有 `user_id``user_数字` 的最大值加一,若不存在则为 1。
## 5. 完成定义
- SpacetimeDB 模块能 wasm 编译。
- Rust bindings 已重新生成并包含正式表导出 procedure。
- `spacetime-client` 暴露正式表导出 facade。
- `api-server` 启动恢复优先正式表,认证变更同步后导入正式表。
- `module-auth` 测试保持通过。

View File

@@ -0,0 +1,83 @@
# Auth SpacetimeDB 快照迁移 Stage 1
日期:`2026-04-24`
## 1. 背景
`AUTH_REFRESH_SESSION_PERSISTENCE_HOTFIX_2026-04-24.md` 已把 Rust 鉴权内存态落到本地 JSON 快照,解决 `server-rs` 重启后 refresh session 丢失导致 `JWT_EXPIRES_IN` 到期后必须重新登录的问题。
本阶段继续把该快照迁入 SpacetimeDB作为正式 `user_account/auth_identity/refresh_session` 表完全拆分前的过渡真相源。
## 2. 阶段目标
1.`spacetime-module` 新增私有 `auth_store_snapshot` 表。
2. 表内只保存一条当前 Axum 鉴权快照 JSON主键固定为 `default`
3. 新增 `get_auth_store_snapshot``upsert_auth_store_snapshot` procedure`api-server` 同步读写。
4. `module-auth` 继续拥有鉴权业务语义SpacetimeDB 只承接当前阶段的持久化真相。
## 3. 为什么先做快照表
只迁 `refresh_session` 表无法恢复登录态,因为 refresh 成功后仍必须按 `user_id` 找到账号快照重新签发 access token。因此正式拆表必须同时完成账号、身份、会话三组 reducer。
本阶段先把已经验证过的 JSON 快照从本地文件迁到 SpacetimeDB收益是
1. 后端进程重启后可恢复登录态。
2. 多实例部署时可共享同一份鉴权快照。
3. 后续正式拆表时有统一导入来源。
## 4. 表设计
表名:`auth_store_snapshot`
访问级别private table
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `snapshot_id` | `String` | 主键,固定为 `default`。 |
| `snapshot_json` | `String` | `module-auth` 当前持久化快照 JSON。 |
| `updated_at` | `Timestamp` | 最近写入时间。 |
## 5. Procedure 设计
### 5.1 `get_auth_store_snapshot`
输入:无。
输出:
```json
{
"ok": true,
"snapshotJson": "...",
"updatedAtMicros": 123456789,
"errorMessage": null
}
```
当记录不存在时,`snapshotJson = null`
### 5.2 `upsert_auth_store_snapshot`
输入:
```json
{
"snapshotJson": "...",
"updatedAtMicros": 123456789
}
```
输出同 `get_auth_store_snapshot`
## 6. 后续拆表迁移点
Stage 2 再把 `auth_store_snapshot.snapshot_json` 导入并拆分为:
1. `user_account`
2. `auth_identity`
3. `refresh_session`
拆分完成后,`auth_store_snapshot` 只保留为迁移备份,不再作为运行时写入目标。

View File

@@ -0,0 +1,97 @@
# Auth SpacetimeDB 拆表 Stage 2
日期:`2026-04-24`
## 1. 阶段目标
Stage 1 已把 Rust 鉴权快照同步到 SpacetimeDB 的 `auth_store_snapshot` 表。本阶段继续把该快照导入正式认证表,建立后续运行时细粒度读写的表结构基础。
本阶段落地范围:
1. 新增 `user_account` 表。
2. 新增 `auth_identity` 表。
3. 新增 `refresh_session` 表。
4. 新增 `import_auth_store_snapshot` procedure把当前 `auth_store_snapshot.snapshot_json` 拆入三张表。
5. 保留 Stage 1 快照表作为导入来源与回滚备份。
## 2. 非目标
本阶段不立即把 `api-server` 的登录、refresh、logout 写入切换到细粒度 reducer。原因是要避免同时改动认证业务语义、导入逻辑和运行时写路径。
运行时切换放到 Stage 3
1. `POST /api/auth/refresh` 改写 `refresh_session` 表。
2. 登录成功写 `user_account/auth_identity/refresh_session`
3. `logout/logout-all/revoke-session` 改写细粒度表。
4. `auth_store_snapshot` 退化为迁移备份。
## 3. 表设计落地口径
### 3.1 `user_account`
字段先覆盖当前 `module-auth` 快照可提供的账号主数据:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键。 |
| `public_user_code` | `String` | 公开叙世号。 |
| `username` | `String` | 当前账号用户名。 |
| `display_name` | `String` | 展示名。 |
| `phone_number_masked` | `Option<String>` | 脱敏手机号。 |
| `phone_number_e164` | `Option<String>` | 内部手机号索引。 |
| `login_method` | `String` | `password/phone/wechat`。 |
| `binding_status` | `String` | `active/pending_bind_phone`。 |
| `wechat_bound` | `bool` | 是否绑定微信身份。 |
| `password_hash` | `String` | 密码哈希。 |
| `password_login_enabled` | `bool` | 是否允许密码登录。 |
| `token_version` | `u64` | access token 统一失效版本。 |
### 3.2 `auth_identity`
当前只导入已有快照中的微信身份与手机号身份:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `identity_id` | `String` | 主键。 |
| `user_id` | `String` | 归属账号。 |
| `provider` | `String` | `phone/wechat`。 |
| `provider_uid` | `String` | provider 主体键。 |
| `provider_union_id` | `Option<String>` | 微信 unionid。 |
| `phone_e164` | `Option<String>` | 手机号身份。 |
| `display_name` | `Option<String>` | provider 显示名。 |
| `avatar_url` | `Option<String>` | provider 头像。 |
### 3.3 `refresh_session`
字段对齐现有 refresh session 记录:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `session_id` | `String` | 主键。 |
| `user_id` | `String` | 归属账号。 |
| `refresh_token_hash` | `String` | 当前 refresh token hash。 |
| `issued_by_provider` | `String` | 创建来源。 |
| `client_info_json` | `String` | 当前客户端身份 JSON。 |
| `expires_at` | `String` | RFC3339。 |
| `revoked_at` | `Option<String>` | RFC3339。 |
| `created_at` | `String` | RFC3339。 |
| `updated_at` | `String` | RFC3339。 |
| `last_seen_at` | `String` | RFC3339。 |
## 4. 导入语义
`import_auth_store_snapshot` 固定行为:
1. 读取 `auth_store_snapshot/default`
2. JSON 解析失败返回 `ok=false` 和中文错误。
3. 导入前清空三张正式 auth 表,避免重复导入产生脏数据。
4. 按快照内容重建账号、身份、refresh session。
5. 返回导入计数,便于本地验证。
## 5. 完成定义
1. `spacetime-module` wasm check 通过。
2. Rust bindings 已刷新。
3. `spacetime-client` 暴露导入 procedure facade。
4. `api-server/spacetime-client/module-auth` 定向检查通过。

View File

@@ -0,0 +1,237 @@
# Axum 到 SpacetimeDB 的资产对象确认调用方案
日期:`2026-04-21`
## 1. 文档目的
这份文档用于冻结 `POST /api/assets/objects/confirm` 从当前“进程内确认写入”切换到“真实 SpacetimeDB 持久化”的最小落地方案。
当前要解决的问题只有一个:
1. `api-server` 在完成 OSS `HEAD Object` 校验后,如何把确认结果稳定写入 `spacetime-module.asset_object`
这份文档需要把以下信息冻结到可以直接编码的级别:
1. 本地 SpacetimeDB server 口径
2. 本地数据库名
3. `spacetime-module` 内部 reducer / procedure 的职责分工
4. `spacetime-client` 在当前阶段的最小实现方式
5. `api-server` 的切换边界
## 2. 当前约束
已确认事实如下:
1. 阿里云 OSS 当前按私有 bucket 接入。
2. `api-server` 当前已经完成:
- `POST /api/assets/direct-upload-tickets`
- `GET /api/assets/read-url`
- `POST /api/assets/objects/confirm`
3. `platform-oss` 已具备:
- `PostObject` 直传签名
- 私有 `GET` 签名 URL
- 私有 `HEAD Object` 探测
4. `spacetime-module` 当前已具备:
- `asset_object` 首版表骨架
- `bucket + object_key` 双列定位索引
5. 当前 `module-assets` 的对象确认仍然写入进程内 store不是正式数据库真相。
## 3. 当前阶段的职责拆分
### 3.1 Axum 负责的部分
`api-server` 当前阶段继续负责以下职责:
1. 接收 HTTP 请求
2. 校验请求体字段
3. 校验 `bucket` 与服务端配置的一致性
4. 调用 OSS `HEAD Object`
5. 组装“已确认对象元数据”
6. 调用 SpacetimeDB 持久化入口
7. 把持久化结果转换成当前 HTTP 响应 contract
### 3.2 SpacetimeDB 负责的部分
`spacetime-module` 当前阶段只负责以下纯状态职责:
1. 依据 `bucket + object_key` 查重
2. 已存在则复用原 `asset_object_id``created_at`
3. 已存在则更新 `updated_at` 与最新元数据
4. 不存在则插入新对象行
5. 返回持久化后的对象记录
### 3.3 不允许的职责漂移
当前阶段明确不允许:
1. 在 reducer / procedure 内直接访问 OSS
2. 在 reducer / procedure 内直接访问 HTTP 请求头、Cookie 或 Axum context
3.`api-server` 内重新实现第二套 `asset_object` 去重规则
4. 通过 CLI 文本解析做正式持久化主链
## 4. 本地开发口径冻结
### 4.1 本地 server 地址
从当前版本开始,`server-rs/scripts/spacetime-dev.ps1``server-rs/scripts/spacetime-dev.sh` 的默认监听口径统一为:
1. `127.0.0.1:3000`
原因固定如下:
1. `spacetime` CLI 的默认 `local` server 昵称当前指向 `http://127.0.0.1:3000`
2. 若脚本默认改到 `3001`,则 `publish / call / generate` 与本地调试口径会长期错位
3. `api-server` 默认占用 `3000` 仅限当前进程,不影响 SpacetimeDB 独立开发脚本通过单独终端启动
补充说明:
1. 若需要与本地 Axum 同时运行,可显式传参改端口。
2. 默认口径必须先回到 CLI 约定,避免文档、脚本和发布命令长期分叉。
### 4.2 本地数据库名
当前资产对象确认链路的本地数据库名固定为:
1. `genarrative-dev`
原因固定如下:
1. 名称满足 `spacetime publish` 的数据库命名规则
2. 后续 auth / runtime / asset 的 schema 可以先统一聚合到同一开发数据库
3. 当前仓库还没有按模块拆分多个独立数据库的明确方案
### 4.3 当前阶段的标准命令
本地开发标准命令固定如下:
```bash
spacetime start --listen-addr 127.0.0.1:3000
spacetime publish genarrative-dev --server local --yes --module-path server-rs/crates/spacetime-module
spacetime generate --lang rust --out-dir server-rs/crates/spacetime-client/src/module_bindings --module-path server-rs/crates/spacetime-module --include-private --yes
```
## 5. 为什么当前阶段不用 CLI 文本解析
当前阶段不采用 `spacetime call / spacetime sql` 的正式主链方案,原因固定如下:
1. CLI 输出更适合人工调试,不适合作为稳定业务协议层
2. `api-server` 若依赖命令行文本解析,会把错误处理、超时、返回值解析和平台兼容复杂度放大
3. 当前 `asset_object` 是 private table不适合继续叠加一层 CLI 查询拼装返回值
因此当前阶段改为:
1. 用 SpacetimeDB Rust SDK + codegen bindings 做正式调用
2.`procedure` 返回确认结果
3.`try_with_tx` 在 procedure 内完成原子 upsert
## 6. `spacetime-module` 的调用面设计
### 6.1 reducer
当前阶段保留一个内部 reducer
1. `confirm_asset_object`
职责:
1. 承载真实 upsert 规则
2. 便于后续被其他 reducer 或 scheduled logic 复用
3. 明确 `asset_object` 的唯一写入规则在模块内收口
返回规则:
1. reducer 只返回 `Result<(), String>`
2. 不直接返回对象记录
### 6.2 procedure
当前阶段新增一个对 Axum 友好的 procedure
1. `confirm_asset_object_and_return`
职责:
1. 接收 Axum 已经确认好的对象元数据
2.`try_with_tx` 中调用共享 upsert 逻辑
3. 返回持久化后的 `asset_object` DTO
原因固定如下:
1. `POST /api/assets/objects/confirm` 是同步确认接口,需要立即返回 `assetObjectId` 等字段
2. reducer 本身不返回业务数据
3. procedure 可以在不做外部 IO 的前提下返回 `SpacetimeType` 结果
## 7. `spacetime-client` 当前阶段设计
`spacetime-client` 当前阶段只实现一条最小链路:
1. 连接指定 SpacetimeDB server
2. 等待 SDK `on_connect` 回调确认连接已经收到 `IdentityToken`
3.`on_connect` 后调用 `confirm_asset_object_and_return`
4. 获取返回 DTO
实现约束固定如下:
1. 不允许在 `DbConnection::build()` 返回后立刻发 procedure因为 build 只代表 WebSocket 初始化完成,不代表 SpacetimeDB 身份握手已经完成。
2. procedure 调用、异步连接失败、断线和超时必须收口到同一个结果通道,避免 HTTP 请求在 SDK idle timeout 后才失败。
3. 当前阶段每次 HTTP 确认请求可以建立一条短连接,待真实链路验证稳定后再评估连接池或长连接复用。
当前阶段不做:
1. 通用订阅框架
2. 多数据库路由
3. 通用 reducer / procedure 适配器
4. 前端直连复用层
## 8. `api-server` 的切换规则
从当前版本开始,`POST /api/assets/objects/confirm` 的真实主链改为:
1. `api-server`
2. `platform-oss HEAD Object`
3. `spacetime-client`
4. `spacetime-module.confirm_asset_object_and_return`
5. `asset_object`
当前进程内 `AssetObjectService` 退化为:
1. 共享字段校验
2. 共享 Axum 侧对象确认编排
3. 本地无 SpacetimeDB 配置时的临时 fallback 不再作为默认主链
## 9. 环境变量口径
当前阶段新增以下环境变量:
1. `GENARRATIVE_SPACETIME_SERVER_URL`
默认 `http://127.0.0.1:3000`
2. `GENARRATIVE_SPACETIME_DATABASE`
默认 `genarrative-dev`
3. `GENARRATIVE_SPACETIME_TOKEN`
可选;未配置时默认匿名连接
补充说明:
1. 当前本地开发可以先匿名连接。
2. 后续若要做 Axum -> SpacetimeDB 的身份透传,再单独冻结 JWT / OIDC token 的传递策略。
## 10. 当前阶段验收标准
当以下条件满足时,本方案视为落地完成:
1. `[x]` `spacetime-module` 新增 `confirm_asset_object` reducer 与 `confirm_asset_object_and_return` procedure
2. `[x]` `spacetime-client` 能调用该 procedure 并拿到返回值
3. `[x]` `api-server` 默认不再依赖进程内对象 store 作为正式真相
4. `[x]` `server-rs/scripts/spacetime-dev.*` 默认端口回到 `3000`
5. `[x]` 本地可以通过 publish + API 测试跑通真实 `asset_object` 写入
`2026-04-21` 已完成验收:
1. `cargo test -p api-server confirm_asset_object_live_roundtrip_persists_confirmed_record --manifest-path server-rs/Cargo.toml -- --ignored --nocapture --test-threads=1` 通过。
2. `spacetime sql genarrative-dev --server local -y "SELECT asset_object_id, bucket, object_key, asset_kind, content_length FROM asset_object"` 可查到 `bucket = "xushi-dev"``generated-characters/confirm-live-test/.../master.txt` 的确认记录。
## 11. 一句话结论
当前阶段 `POST /api/assets/objects/confirm` 的正式主链应当是:
**Axum 完成 OSS 校验,再通过 Rust SDK 调用 SpacetimeDB procedure在模块内部用事务 upsert `asset_object` 并把最终对象记录同步返回。**

View File

@@ -0,0 +1,46 @@
# 后端创作 Agent LLM Turn 公共化 2026-04-25
## 背景
RPG、大鱼吃小鱼、拼图三条创作 Agent 后端 turn 已经统一使用 `platform-llm`,但在 `api-server` 内仍重复维护以下流程:
1. 检查 `LlmClient` 是否可用。
2. 构造 `system + user` 两段消息。
3. 调用 `stream_text` 并从增量 JSON 中抽取 `replyText` 给 SSE 前端。
4. 从模型最终文本中截取并解析 JSON。
5. 把模型调用失败、JSON 解析失败映射成中文业务错误。
这些逻辑属于 turn 级基础设施,不应散落在不同玩法文件里;但各玩法的 prompt、anchor pack 解析、stage 推进、SpacetimeDB finalize 写回仍是领域逻辑,不在本轮合并。
## 目标
1. 新增 `api-server` 内部公共模块 `creation_agent_llm_turn`
2. 公共化流式 JSON turn 调用、非流式 JSON turn 调用、`replyText` 增量解析、最终 JSON 截取解析。
3. 大鱼、拼图、RPG Agent turn 复用公共调用骨架。
4. 保留各玩法原有结果结构、中文错误文案和持久化写回契约。
## 非目标
1. 不统一 RPG、大鱼、拼图的 prompt。
2. 不统一三类 anchor pack / draft profile schema。
3. 不改变 SpacetimeDB reducer/procedure 或 session 表结构。
4. 不改变前端 SSE contract。
## 落地边界
1. `server-rs/crates/api-server/src/creation_agent_llm_turn.rs`
- 提供 `stream_creation_agent_json_turn(...)`
- 提供 `request_creation_agent_json_turn(...)`
- 提供 `parse_json_response_text(...)``extract_reply_text_from_partial_json(...)`
2. `custom_world_agent_turn.rs`
- 保留 RPG 动态状态判断、八锚点解析、结果写回。
- 将正式单轮生成和状态识别的 LLM 请求改走公共模块。
3. `big_fish_agent_turn.rs` / `puzzle_agent_turn.rs`
- 将 LLM 流式请求和 JSON 解析改走公共模块。
- 继续保留玩法自己的 anchor pack 解析和 quick fill 规则。
## 验收
1. `cargo test -p api-server` 中相关 turn 单测通过。
2. `cargo check -p api-server` 不引入新的编译错误。
3. 编码检查通过。

View File

@@ -0,0 +1,137 @@
# 后端重写横向治理规则2026-04-22
更新时间:`2026-04-22`
## 1. 文档目标
本文件冻结 `SpacetimeDB + Axum + OSS` 后端重写收口阶段的横向规则,覆盖:
1. 前端 TypeScript contract 与 Rust DTO 的映射策略。
2. SpacetimeDB table / reducer / procedure 的演进规则。
3. 大对象、manifest、workflow cache 的存储边界。
4. 阶段文档与 API 索引的维护规则。
这些规则用于减少 M4/M5/M6/M7 后续并行推进时的 contract 漂移。
## 2. Contract 与前端兼容
### 2.1 映射原则
1. `packages/shared/src/contracts/*` 是前端消费 contract 的现有事实来源。
2. `server-rs/crates/shared-contracts/src/*.rs` 是 Rust `api-server` 返回 DTO 的事实来源。
3. 两侧字段名必须继续使用当前前端已消费的 JSON 命名,不因 Rust 字段命名风格改变外部 shape。
4. Rust DTO 必须通过 `serde(rename_all = "camelCase")`、显式 `rename` 或兼容枚举值保持旧 contract。
5. 临时兼容字段只能标记为 optional不能在没有迁移说明和测试前直接删除。
### 2.2 当前映射面
| 前端 contract | Rust DTO 模块 | 当前用途 |
| --- | --- | --- |
| `packages/shared/src/contracts/auth.ts` | `shared-contracts::auth` | 登录方式、用户信息、会话、审计、验证码与微信登录 |
| `packages/shared/src/contracts/runtime.ts` | `shared-contracts::runtime` | profile dashboard、play stats、wallet ledger、browse history、settings、inventory |
| `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` | `shared-contracts::runtime_story` | runtime story action request / response、state resolve、view model |
| `packages/shared/src/contracts/rpgRuntimeStoryState.ts` | `shared-contracts::runtime_story` | runtime story state / presentation 兼容 |
| `packages/shared/src/contracts/rpgAgent*.ts` | `shared-contracts::runtime``custom_world` 相关 DTO | custom world agent session、message、operation、action |
| `packages/shared/src/contracts/rpgCreation*.ts` | `shared-contracts::runtime``custom_world` 相关 DTO | result preview、works、library、published profile |
| `packages/shared/src/contracts/common.ts` | `shared-contracts::api` | 统一 success / error envelope |
### 2.3 变更流程
1. 扩字段:先加 Rust optional 字段和 contract test再接前端消费。
2. 改字段语义:必须新增技术方案说明旧语义、新语义、迁移期兼容逻辑和回退方式。
3. 删字段或删枚举必须先证明前端调用、Node 兼容层、历史 fixture 和测试都不再消费。
4. breaking change 必须在任务清单和设计文档中显式标注,不允许只靠 PR diff 表达。
5. 所有 shared contract 变更至少运行 `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`
## 3. SpacetimeDB Schema 演进治理
本节按 SpacetimeDB 约束执行:
1. reducer 是事务性写入口,不依赖 reducer 返回值读取数据。
2. reducer 必须确定性执行,不做网络、文件系统、外部随机数或时间副作用。
3. 客户端读取依赖 table / subscription / procedure 返回的显式 DTO不把 Axum 进程内缓存当真相。
4. 用户身份以后续接入 SpacetimeDB 直连时的 `ctx.sender()` 为准,不信任客户端传入 owner 字段。
### 3.1 命名规则
1. table 使用稳定单数 snake_case 名称,例如 `story_session``asset_object``custom_world_agent_session`
2. reducer 使用动作动词 + 领域对象,例如 `upsert_runtime_snapshot``confirm_asset_object``turn_in_quest`
3. 需要同步返回 DTO 的 procedure 统一使用 `_and_return``get_ / list_ / compile_` 语义。
4. public table 只暴露客户端确实需要订阅或查询的状态内部审计、token、风控等默认不 public。
5. event table 只用于事件广播,不替代持久状态表。
### 3.2 列演进规则
1. 优先追加 optional 字段,不直接改名、改类型或删除列。
2. 必须删除语义时,先软废弃字段并让读模型停止依赖,再在独立迁移窗口清理。
3. 状态类枚举新增值时,前端必须有 unknown / fallback 处理。
4. 需要唯一约束或索引时,先补设计文档说明查询路径,再改 schema。
5. 大规模重排表结构必须拆成新表 + 双写 / 读模型迁移,不在原表上做破坏性变更。
### 3.3 软删除规则
1. 用户可见业务实体优先使用 `status``deleted_at``archived_at` 表达生命周期。
2. 会话、作品、资产绑定、审计和任务记录默认不物理删除。
3. 物理删除只用于临时草稿、过期验证码、过期 OAuth state 等明确可丢弃数据。
4. 删除 reducer 必须写清是否幂等,重复调用不能造成不可恢复错误。
## 4. 大对象与缓存治理
### 4.1 OSS 存储边界
必须进入 OSS
1. 图片、视频、动作帧、封面图、场景图。
2. 大型 JSON manifest。
3. 角色工作流缓存 JSON。
4. 导入视频和生成过程草稿资源。
只进入 SpacetimeDB 元数据:
1. `bucket``object_key``asset_kind``content_type``content_length``content_hash``version`
2. `asset_entity_binding` 的业务实体、槽位、owner 和 profile 绑定关系。
3. AI task、asset task、publish gate 等状态字段。
4. 可用于列表和权限判断的轻量 summary。
### 4.2 本地缓存边界
1. 生产主链不得把仓库 `public/generated-*` 作为资产真相。
2.`/generated-*` 仅作为同源代理兼容路径,读取私有 OSS 对象。
3. 测试环境允许使用 `#[cfg(test)]` 内存兜底,但必须在文档中注明不进入生产链。
4. workflow cache 当前真相是 OSS JSON 草稿对象,不落本地文件。
5. 临时生成文件如需存在,必须限制在进程临时目录,并在任务完成后清理。
### 4.3 Manifest 与版本
1. 多文件资产集合使用 OSS manifest 表达,不重复新增结构化表,除非已证明查询需求需要。
2. `asset_object.version` 当前默认 `1`,版本升级必须说明兼容读取规则。
3. `content_hash` 可为空,但一旦用于去重,必须先补冲突处理和重算策略。
4. 强业务资产表只有在需要领域查询、审核、回滚或权限策略时再新增。
## 5. 文档维护规则
1. 工程修改必须同步对应阶段任务清单。
2. 新增或改变接口时,同步更新 [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。
3. 仍存在旧能力差异时,同步更新 [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md) 或新增 Rust 侧补充索引。
4. M4 结构变更同步维护 RPG runtime 链路文档。
5. M5 结构变更同步维护 creation flow 链路文档。
6. M6 资产链路变更同步维护 OSS / asset_object / generated path 文档。
7. M7 切流相关变更同步维护部署、预检、smoke 与回滚文档。
## 6. 验收门禁
横向治理完成不等价于真实切流完成。当前可本地验收的门禁是:
1. `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
2. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
3. `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`
4. `cargo test -p api-server --manifest-path server-rs/Cargo.toml --no-run`
5. `node scripts/check-encoding.mjs ...`
真实切流前仍必须单独完成:
1. OSS 真实读写 smoke。
2. LLM / DashScope 真实生成 smoke。
3. 关键 SSE 接口联调。
4. SpacetimeDB publish / rollback 演练。
5. 灰度环境双跑对比。

View File

@@ -0,0 +1,134 @@
# 大鱼吃小鱼结果页主图占位预览修复说明 2026-04-23
日期:`2026-04-23`
## 1. 问题现象
在“深海谜境 / 大鱼吃小鱼”结果页中,等级卡片会显示:
1. `主图 已生成`
2. 操作后会出现“已应用主图”感知
但实际结果页看不到角色主图,卡片里只有一张蓝色底图。
## 2. 排查结论
本次沿着“结果页展示 -> API action -> SpacetimeDB procedure -> 资产路径”全链检查后确认:
1. 前端确实成功触发了 `big_fish_generate_level_main_image`
2. SpacetimeDB 侧确实把资产槽位状态写成了 `ready`
3. 但这条链路没有接真实图像模型,也没有接 OSS 真实资产对象
4. 旧实现只回写一个 `/generated-big-fish/...png` 占位 URL
5. 同时仓库里没有实际把这张占位图写到 `public/generated-big-fish/...`
6. 因此前端读到的是一个“看起来像图片地址、实际上没有真实文件”的路径
7. `<img>` 加载失败后,卡片底层蓝色渐变背景暴露出来,于是用户只能看到蓝色图块
## 3. 根因拆解
### 3.1 状态成功过早
`generate_big_fish_asset` 当前最小实现只负责:
1.`asset slot`
2.`prompt snapshot`
3. 标记 `status = ready`
它并不代表真实主图已经生成完成。
### 3.2 预览资源未真正落盘
旧实现会构造:
`/generated-big-fish/{asset_kind}/{level_part}/{seed}.png`
但没有同步在 `public/generated-big-fish/...` 写出对应文件。
### 3.3 结果页直接吃裸 `assetUrl`
Big Fish 结果页主图卡之前直接:
1.`slot.assetUrl`
2. 塞进 `<img src=...>`
一旦文件不存在,就只剩下卡片自己的蓝色渐变背景。
### 3.4 UI 文案误导
旧文案把当前阶段写成:
1. `已生成`
2. `已生成并设为正式资产`
这会让用户自然理解为“真实主图已经出来了”,与当前最小实现不一致。
## 4. 本次修复策略
本轮不直接接真实模型生成,而是先把最小可见闭环补完整。
### 4.1 API action 写出可预览占位图
在 Rust `api-server` 的 Big Fish action 处理中:
1. 复用拼图玩法“写本地可预览占位图”的方式
2. 在调用 `generate_big_fish_asset` 之前,先把 Big Fish 占位图真正写到:
`public/generated-big-fish/...`
3. 保证 SpacetimeDB 里写入的占位 URL 至少对应一个真实可访问文件
### 4.2 结果页改用统一图片渲染组件
Big Fish 结果页主图和背景预览改为:
1. 使用 `ResolvedAssetImage`
2. 统一走现有图片渲染链路
3. 避免后续接 OSS / 旧 generated 路径兼容时再重复返工
### 4.3 状态文案改准
当前还是占位资产阶段,因此把结果页状态文案改为:
1. `占位已生成`
2. `生成并应用占位图`
避免继续把“槽位 ready”误说成“真实主图已完成”。
### 4.4 结果页露出背景预览
除了等级主图卡,本轮顺手把场地背景卡也接上真实预览图渲染,避免出现:
1. 状态显示完成
2. 右侧仍只是一块纯渐变底图
## 5. 修复后的链路语义
修复后 Big Fish 当前资产链路语义明确为:
1. 点击生成
2. `api-server` 先写出本地可访问占位图
3. `spacetime-module` 写正式资产槽位和提示词快照
4. 前端读取 `assetUrl` 并真实渲染预览
也就是说:
1. 当前可以保证“用户看得见”
2. 但仍然不是“真实模型图像生成”
3. 后续真实模型 / OSS worker 接入后,再把占位图链替换成正式资产真相链
## 6. 验收标准
本次修复后需要满足:
1. 结果页等级卡在主图生成后能直接看到真实可加载图片,不再只剩蓝色底图
2. 场地背景生成后右侧卡片能看到真实可加载图片
3. Big Fish 结果页图片渲染统一走现有图片组件,不再直接裸 `<img>` 吃易失路径
4. 当前 UI 文案不再把占位图误称为真实主图
5. 若本地占位图写盘失败,则 action 不能继续回到“ready 成功”状态
## 7. 后续建议
下一阶段继续补 Big Fish 真实资产链时,建议按下面顺序推进:
1. 先引入 Big Fish `asset_object + asset_entity_binding` 正式槽位设计
2. 再接真实图片 / 动作生成 worker
3. 最后把当前 `/generated-big-fish/...` 本地占位路径迁为兼容层,而不是继续作为真相路径

View File

@@ -0,0 +1,279 @@
# 大鱼吃小鱼玩法创作与运行态最小落地技术方案
日期:`2026-04-22`
## 1. 文档目的
本文件承接 PRD《AI 原生 Agent-First 大鱼吃小鱼玩法创作工具与玩法系统》,冻结本轮工程落地的最小完整闭环。
本轮目标不是抽象一个通用街机玩法引擎,而是在现有平台内新增一个独立 `big_fish` 玩法域,跑通:
1. 平台创作入口选择大鱼吃小鱼玩法
2. Agent 会话创建、消息提交和 SSE 兼容返回
3. 基于 4 个高杠杆锚点编译玩法草稿
4. 结果页生成等级主图、等级动作、场地背景的正式资产槽位,并同步提供可预览资源
5. 发布校验
6. 启动测试运行态
7. 后端推进摇杆输入、刷怪、吞噬收编、三合一、屏外清理和胜负裁决
## 2. 本轮明确不做
1. 不在本文件内展开正式图片模型链、OSS 真相链和占位兼容层的细节;相关正式出图方案以 `BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md` 为准。
2. 不新增 WebSocket 依赖;首版运行态使用 `POST input + GET snapshot` 的有限 HTTP 辅助接口,后续再升级长连接。
3. 不把 Big Fish 写回 `custom_world``rpgCreation` 或 RPG runtime 旧语义。
4. 不新增作品市场、排行榜、复盘、局外成长、PvP。
5. 不要求前端本地模拟真相;前端只渲染后端 snapshot。
## 3. Rust crate 边界
新增:
1. `server-rs/crates/module-big-fish`
- 纯领域模型、输入校验、草稿编译、资产覆盖率、运行态规则推进。
- 可开启 `spacetime-types` feature`spacetime-module` 派生 SpacetimeDB 类型。
接入:
1. `server-rs/crates/spacetime-module`
- 新增 Big Fish 表与 procedure。
- 只存状态与结构化引用,不做 OSS / LLM 外部 IO。
2. `server-rs/crates/spacetime-client`
- 新增 Big Fish facade隐藏 generated bindings。
3. `server-rs/crates/shared-contracts`
- 新增 HTTP DTO。
4. `server-rs/crates/api-server`
- 新增 `big_fish_creation.rs``big_fish_assets.rs``big_fish_runtime.rs` 或最小合并的 `big_fish.rs`
## 4. SpacetimeDB 表
本轮只新增必要表,所有表主键使用 Axum 生成的显式业务 ID。
### 4.1 `big_fish_creation_session`
字段:
1. `session_id: String`
2. `owner_user_id: String`
3. `seed_text: String`
4. `current_turn: u32`
5. `progress_percent: u32`
6. `stage: BigFishCreationStage`
7. `anchor_pack_json: String`
8. `draft_json: Option<String>`
9. `asset_coverage_json: String`
10. `last_assistant_reply: Option<String>`
11. `publish_ready: bool`
12. `created_at: Timestamp`
13. `updated_at: Timestamp`
索引:
1. `by_big_fish_session_owner_user_id(owner_user_id)`
### 4.2 `big_fish_agent_message`
字段:
1. `message_id: String`
2. `session_id: String`
3. `role: BigFishAgentMessageRole`
4. `kind: BigFishAgentMessageKind`
5. `text: String`
6. `created_at: Timestamp`
索引:
1. `by_big_fish_message_session_id(session_id)`
### 4.3 `big_fish_asset_slot`
字段:
1. `slot_id: String`
2. `session_id: String`
3. `asset_kind: BigFishAssetKind`
4. `level: Option<u32>`
5. `motion_key: Option<String>`
6. `status: BigFishAssetStatus`
7. `asset_url: Option<String>`
8. `prompt_snapshot: String`
9. `updated_at: Timestamp`
索引:
1. `by_big_fish_asset_session_id(session_id)`
2. `by_big_fish_asset_slot(session_id, asset_kind, level, motion_key)`
### 4.4 `big_fish_runtime_run`
字段:
1. `run_id: String`
2. `session_id: String`
3. `owner_user_id: String`
4. `status: BigFishRunStatus`
5. `snapshot_json: String`
6. `last_input_x: f32`
7. `last_input_y: f32`
8. `tick: u64`
9. `created_at: Timestamp`
10. `updated_at: Timestamp`
索引:
1. `by_big_fish_run_owner_user_id(owner_user_id)`
2. `by_big_fish_run_session_id(session_id)`
## 5. SpacetimeDB procedure
本轮全部使用 procedure 同步返回快照,避免 Axum 额外拼读模型。
1. `create_big_fish_session(input) -> BigFishSessionProcedureResult`
2. `get_big_fish_session(input) -> BigFishSessionProcedureResult`
3. `submit_big_fish_message(input) -> BigFishSessionProcedureResult`
4. `compile_big_fish_draft(input) -> BigFishSessionProcedureResult`
5. `generate_big_fish_asset(input) -> BigFishSessionProcedureResult`
6. `publish_big_fish_game(input) -> BigFishSessionProcedureResult`
7. `start_big_fish_run(input) -> BigFishRunProcedureResult`
8. `submit_big_fish_input(input) -> BigFishRunProcedureResult`
9. `get_big_fish_run(input) -> BigFishRunProcedureResult`
说明:
1. `submit_big_fish_message` 只做 deterministic 锚点补全,不调用 LLM。
2. `generate_big_fish_asset` 的槽位写入语义允许 `api-server` 传入正式 `asset_url`;若未传则回退为占位路径,保证最小链与正式链共存。
3. `submit_big_fish_input` 每次至少推进 1 个后端 tick前端不能本地裁决。
4. 运行态所有“持续时间”语义按真实秒数累计,前端即使摇杆静止也要持续以当前输入心跳驱动后端推进,避免刷怪与屏外 `3` 秒清理依赖手速或提交频率。
## 6. HTTP contract
所有接口挂在 `/api/runtime/big-fish/*`,全部需要 Bearer 鉴权。
开发态本地链路补充约定:
1. 浏览器仍只请求同源 `/api/runtime/big-fish/*`
2. `vite -> Rust api-server:3100` 是默认开发链路,禁止把新运行态接口继续接回 `server-node`
3. Rust `api-server` 作为 Big Fish 真相后端,正式处理鉴权、会话、草稿、资产动作和运行态规则。
4. 若本机端口不同,只能通过 `RUST_SERVER_TARGET``GENARRATIVE_API_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET` 显式覆盖 Vite 代理目标。
5. 本地默认端口:
- `vite`: `3000`
- Rust `api-server`: `3100`
- `SpacetimeDB standalone`: `3001`
6. `GENARRATIVE_SPACETIME_DATABASE` 本地开发优先跟随仓库根目录 `spacetime.local.json``database` 字段,避免 `api-server` 默认连到错误数据库名。
- `.env.local` 或进程环境显式配置 `GENARRATIVE_SPACETIME_DATABASE` 时可覆盖本地配置。
- `.env.example` 只提供示例默认值,不得压过 `spacetime.local.json`
### 6.1 创作会话
1. `POST /api/runtime/big-fish/agent/sessions`
2. `GET /api/runtime/big-fish/agent/sessions/{sessionId}`
3. `POST /api/runtime/big-fish/agent/sessions/{sessionId}/messages/stream`
4. `POST /api/runtime/big-fish/agent/sessions/{sessionId}/actions`
`messages/stream` 首版兼容当前前端 SSE 解析方式,只输出:
1. `reply_delta`
2. `session`
3. `done`
4. `error`
`actions` 首版支持:
1. `big_fish_compile_draft`
2. `big_fish_generate_level_main_image`
3. `big_fish_generate_level_motion`
4. `big_fish_generate_stage_background`
5. `big_fish_publish_game`
### 6.2 运行态
1. `POST /api/runtime/big-fish/sessions/{sessionId}/runs`
2. `GET /api/runtime/big-fish/runs/{runId}`
3. `POST /api/runtime/big-fish/runs/{runId}/input`
### 6.3 作品列表
1. `GET /api/runtime/big-fish/works`
开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node`
`input` 请求体:
```json
{
"x": 0.4,
"y": -0.2
}
```
## 7. 运行态最小规则
后端推进规则固定:
1. 开局拥有 1 个 `level = 1` 己方实体。
2. 开局视野内生成至少 2 个同级野生实体。
3. 己方实体碰撞低于或等于自己等级的野生实体时收编。
4. 高于己方等级的野生实体碰撞己方实体时吃掉该己方实体。
5. 每次结算后从低等级开始做三合一连锁合成。
6. 野生实体池围绕玩家最高己方等级维持低 1~2 级与高 1~2 级。
7. 同等级、高 3 级及以上、低 3 级及以下的野生实体,屏外连续 3 秒后删除。
8. 玩家首次拥有最高等级实体时立即胜利。
9. 己方实体归零时失败。
## 8. 前端接入边界
新增目录:
1. `src/services/big-fish-creation/`
2. `src/components/big-fish-creation/`
3. `src/components/big-fish-result/`
4. `src/components/big-fish-runtime/`
复用现有平台入口壳层,但入口脚本必须使用通用平台命名,禁止把 Big Fish 业务状态写进 `rpg-entry` 命名脚本:
1.`src/components/platform-entry/PlatformEntryCreationTypeModal.tsx` 新增“大鱼吃小鱼”选项。
2.`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 中新增 Big Fish 专属 stage。
3. Big Fish 不使用 `RpgCreationResultView`,使用 `BigFishResultView`
4. `src/components/rpg-entry/*` 只能保留兼容导出或 RPG 专属组件,不允许承载 Big Fish 业务分支。
前端只允许:
1. 展示会话、草稿、资产槽位、运行快照。
2. 发送聊天、action 和摇杆输入。
3. 根据后端 snapshot 渲染实体。
4. 当后端 snapshot 返回 `won``failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。
前端禁止:
1. 自行决定刷怪。
2. 自行决定吞噬 / 合成 / 清理 / 胜负。
## 9. 本轮验收
完成后至少执行:
1. `cargo fmt -p module-big-fish -p shared-contracts -p spacetime-module -p spacetime-client -p api-server`
2. `cargo check -p module-big-fish`
3. `cargo check -p shared-contracts`
4. `cargo check -p spacetime-module`
5. `spacetime generate` 刷新 Rust bindings
6. `cargo check -p spacetime-client`
7. `cargo check -p api-server`
8. 前端类型 / 构建检查
9. `npm run check:encoding`
## 10. 本地开发补充
为避免再次出现 `POST /api/runtime/big-fish/agent/sessions` 命中旧 Node 后端 `404`,本轮额外冻结以下联调口径:
1. `npm run dev` 需要同时拉起:
- `server-node`
- Rust `api-server`
- `vite`
2. `server-node` 新增 Big Fish 专用兼容网关路由,不在 Node 内复制 Big Fish 玩法逻辑。
3. Node -> Rust 转发使用内部桥接头:
- `x-genarrative-authenticated-user-id`
- `x-genarrative-internal-api-secret`
4. Rust 侧只对带正确内部密钥的本地桥接请求接受该用户头,不对普通外部请求放开匿名身份伪造。
如检查发现本轮主链缺口,继续补齐;如已经满足上述验收,不继续扩展额外玩法能力。

View File

@@ -0,0 +1,27 @@
# 大鱼吃小鱼方向触控操作优化说明
## 背景
当前大鱼运行时使用左下固定虚拟摇杆,玩家必须点到摇杆区域才能移动。移动端实际体验应改为屏幕任意位置触控:第一次触点只建立方向原点,不直接产生移动;后续触点相对原点形成方向向量,角色按恒定速度朝该方向行动。
## 交互规则
1. 玩家在玩法舞台内按下时,记录第一个触点坐标为本次操作原点。
2. 按下瞬间提交 `{ x: 0, y: 0 }`,保证一开始玩家不动。
3. 手指/鼠标移动后,用“当前触点 - 原点”的向量计算方向。
4. 输入只表达方向,不表达速度;超过死区后归一化为单位方向向量。
5. 松开或取消触控后,清空操作原点并提交 `{ x: 0, y: 0 }`
6. 前端继续定时提交当前方向,即使没有玩家输入也提交零向量,让后端或本地直达局持续推进世界 tick。
## 本地直达局边界
- `/big-fish` 的本地占位局必须在玩家未操作时继续移动野生对象。
- 玩家速度保持恒定,只由方向决定移动方向。
- 野生对象使用确定性游动规则,避免直达入口看起来像静态截图。
## 验收口径
1. 在舞台任意位置按下时玩家不立即移动。
2. 按住并拖动后,玩家朝拖动方向恒速移动。
3. 松开后玩家停止。
4. 不操作时野生对象仍会持续游动。

View File

@@ -0,0 +1,25 @@
# 大鱼吃小鱼玩法直达路由说明
## 背景
现有前端已经包含 `BigFishRuntimeShell`,正式链路从创作中心或作品卡启动后端运行局。为了便于快速验收玩法手感,需要补一个不依赖后端会话的直达入口。
## 路由设计
- `/big-fish`:进入大鱼吃小鱼玩法直达页。
- 路由挂在 `src/routing/appRoutes.tsx`,与 `/puzzle` 一样走现有轻量路由解析层,不新增独立路由系统。
- 每个玩法仅保留一个直达入口,避免 `/play` 这类重复路径造成维护分叉。
## 运行态边界
- 直达页复用 `BigFishRuntimeShell`,不复制运行时 UI。
- 初始快照由前端本地构造,背景使用内联 SVG 占位图。
- 摇杆输入在本地推进角色位置、碰撞与成长等级,仅用于直达体验。
- 该入口不改变正式 `api-server` 运行局、作品发布、资产生成和 SpacetimeDB 持久化链路。
## 验收口径
1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。
2. 左下摇杆可移动玩家实体。
3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。
4. 左上返回按钮在直达页语义为重开当前占位局。

View File

@@ -0,0 +1,168 @@
# 大鱼吃小鱼正式图片生成接入方案 2026-04-23
日期:`2026-04-23`
## 1. 文档目的
`2026-04-23` 早些时候,我们已经修复了 Big Fish 结果页“显示已生成但只能看到蓝色底图”的问题,先让占位图真正可见。
这份文档继续冻结下一步方案:把 Big Fish 结果页从“占位可见”升级为“模型正式出图”,并且复用仓库现有的 Rust 图片生成与 OSS 真相链,不再为 Big Fish 单独发明一套新资产系统。
## 2. 当前问题复盘
上一阶段虽然解决了“看不见图”的问题,但本质仍是占位链:
1. `api-server` 先写本地 `public/generated-big-fish/*` 占位 PNG。
2. `spacetime-module``big_fish_asset_slot.status` 写成 `ready`
3. `asset_url` 写的是 `/generated-big-fish/...`
4. 结果页能看到图片,但那只是占位预览,不是真实模型图。
这意味着:
1. 用户在结果页看到的“主图 / 动作 / 背景”仍不是正式资产。
2. `ready` 语义对 Big Fish 来说仍然偏弱,只表示“槽位上已有可预览资源”,不等同于“模型正式资产已落 OSS 真相链”。
## 3. 本次目标
本次把以下三类 Big Fish 资产切到正式图片生成链:
1. `level_main_image`
2. `level_motion`
3. `stage_background`
当前只接“正式静态图片生成”,不在这一轮扩视频、逐帧序列或动作 manifest。
原因是:
1. Big Fish 结果页当前只消费单个 `assetUrl`
2. 运行态和结果页目前都按静态图预览设计。
3. 先把正式主图链闭合,比提前引入一套未被消费的视频协议更稳。
## 4. 统一真相链
Big Fish 正式图片生成统一复用现有 Rust 主链:
1. `api-server` 根据 Big Fish 草稿 prompt 调用 DashScope 文生图。
2. Rust 下载远端图片二进制。
3. Rust 上传到私有 OSS。
4. Rust 调用 `confirm_asset_object` 确认正式对象。
5. Rust 调用 `bind_asset_object_to_entity` 绑定 Big Fish 业务槽位。
6. Rust 再调用 Big Fish procedure`big_fish_asset_slot.asset_url` 写成正式兼容路径。
7. 前端继续通过 `ResolvedAssetImage``/api/assets/read-url` 消费图片。
## 5. 路径策略
### 5.1 占位路径继续保留
占位图路径继续保持:
`/generated-big-fish/*`
它只代表:
1. 本地开发态占位可见资源。
2. 旧的最小预览兼容层。
### 5.2 正式图片使用新前缀
正式 Big Fish 图片统一写到新的 OSS legacy 兼容前缀:
`/generated-big-fish-assets/*`
这样可以同时满足:
1. 与仓库现有 `/generated-*` 兼容代理体系一致。
2. 不会被前端继续误判成占位图。
3. 后续可继续通过 `LegacyAssetPrefix``/api/assets/read-url``ResolvedAssetImage` 复用现有链路。
## 6. SpacetimeDB 语义调整
### 6.1 Big Fish 资产生成输入补充 `asset_url`
当前 `BigFishAssetGenerateInput` 只有:
1. `asset_kind`
2. `level`
3. `motion_key`
4. `generated_at_micros`
这会导致 procedure 无法知道 API 层是否已经拿到了正式 OSS 兼容路径。
因此本次补充:
1. `asset_url: Option<String>`
### 6.2 槽位写入规则
`build_generated_asset_slot(...)` 改为:
1. 若输入提供 `asset_url`,则直接写正式路径。
2. 若输入未提供 `asset_url`,才回退为 `/generated-big-fish/...` 占位路径。
这样做的原因是:
1. 允许同一个 Big Fish procedure 兼容“占位生成”和“正式生成”两种调用方式。
2. 不需要为了正式图片再新增一条平行 procedure。
## 7. Big Fish 与 `asset_object` 的绑定语义
Big Fish 不新增专门资产表,继续复用:
1. `asset_object`
2. `asset_entity_binding`
绑定原则:
1. `entity_kind` 使用 Big Fish 会话实体语义。
2. `entity_id` 使用 `session_id`
3. `slot` 使用稳定可重建的槽位名。
推荐槽位命名:
1. `level_main_image:level-{n}`
2. `level_motion:level-{n}:{motion_key}`
3. `stage_background`
这样做可以:
1.`big_fish_asset_slot` 一一对应。
2. 让后续真正做“重新生成覆盖旧资产”时有稳定槽位。
## 8. 前端识别语义
当前 `BigFishResultView` 仍用路径前缀判断是否为占位图:
1. 包含 `/generated-big-fish/` -> `占位已生成`
2. 否则 -> `已生成`
本轮先保留这个最小判定方式,原因是:
1. 正式图片会改走 `/generated-big-fish-assets/`
2. 前端无需立即扩 contract 字段也能正确显示状态。
长期建议仍然是给 Big Fish 资产槽位补显式来源字段,但这不阻塞本轮正式出图。
## 9. 本轮验收标准
完成后需要满足:
1. `big_fish_generate_level_main_image` 会实际触发模型生成,并返回正式 Big Fish 图片。
2. `big_fish_generate_level_motion` 会实际触发模型生成,并返回静态动作预览图。
3. `big_fish_generate_stage_background` 会实际触发模型生成,并返回正式背景图。
4. SpacetimeDB 中对应 `big_fish_asset_slot.asset_url` 不再是 `/generated-big-fish/*`,而是 `/generated-big-fish-assets/*`
5. 结果页状态从“占位已生成”切到“已生成”。
6. `/generated-big-fish-assets/*` 能通过 Rust 同源代理正确读取 OSS 私有对象。
7. `cargo check -p api-server`
8. `cargo check -p module-big-fish`
9. `cargo check -p spacetime-module`
10. `spacetime generate`
11. `cargo check -p spacetime-client`
12. `npm run check:encoding`
## 10. 本轮明确暂不做
1. 不做视频动作生成。
2. 不做序列帧 manifest。
3. 不新增 Big Fish 专属资产数据库表。
4. 不把 Big Fish 结果页改成复杂工作流编辑器。
5. 不修改现有占位图路径的兼容职责。

View File

@@ -0,0 +1,55 @@
# 角色主形象 IP 审核失败兜底修复2026-04-25
## 1. 问题
自动生成草稿素材时,角色「艾瑞克」主形象连续 3 次失败,供应商返回:
```json
{
"code": "IPInfringementSuspect",
"message": "Input data is suspected of being involved in IP infringement."
}
```
这类失败不是网络抖动。原样重试会把同一份包含角色姓名、长设定和可能触发审核的专名继续提交给 DashScope容易稳定失败。
## 2. 参考日志与文档
- 用户提供的失败日志:`request_id=a18fb05d-d3be-9b9c-8d37-e0427397789e``task_id=cb768c95-13b7-4790-9f18-35a8a8761b31``task_status=FAILED``code=IPInfringementSuspect`。该日志确认失败源于供应商 IP/内容审核,而不是 OSS、SpacetimeDB 或下载链路。
- `docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md`:确认角色主形象正式模型 prompt 层由后端 prompt builder 编译,不能只改前端默认描述文本。
- `docs/technical/CUSTOM_WORLD_ASSET_PROMPT_DEFAULTS_2026-04-24.md`:确认当前主链已迁到 `server-rs/crates/api-server/src/custom_world_asset_prompts.rs``server-rs/crates/api-server/src/custom_world_ai.rs`,不再修改旧 `server-node`
- `docs/technical/ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md`:确认角色主形象真实生成入口走 Rust `api-server`、DashScope、OSS 与资产绑定链路。
- `server-rs/crates/api-server/src/character_animation_assets.rs` 现有日志/代码经验:动作生成已经有审核失败时的安全兜底 prompt 策略,本次沿用到角色主形象。
## 3. 修复方案
1. 在角色主图 prompt 层新增安全兜底 prompt
- 不携带角色姓名、作品名、长设定原文。
- 保留职业原型,如原创近战剑士、原创法术职业冒险者。
- 明确要求不参考现有动漫、游戏、影视、小说角色,不使用可识别 IP 元素。
2. 在 DashScope 角色主形象请求层识别审核类错误:
- `IPInfringementSuspect`
- `inappropriate`
- `sensitive`
- `risk`
- 中文内容审核、疑似侵权、知识产权等关键词。
3. 首次提交遇到上述错误时,后端自动用安全兜底 prompt 再提交一次。
4. 非审核类错误仍按原错误返回不隐藏模型、网络、OSS、超时等真实问题。
5. 错误映射保留 `raw` 字段,便于后续从日志直接看到供应商原始 `code/message/task_id`
## 4. 落地文件
- `server-rs/crates/api-server/src/prompt/character_visual.rs`
- 新增 `build_fallback_moderation_safe_character_visual_prompt`
- `server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- 导出角色主图审核兜底 prompt builder。
- `server-rs/crates/api-server/src/character_visual_assets.rs`
- 角色主形象 DashScope 请求增加审核兜底提交。
- `RequestModel` 阶段结构化日志增加 `moderationFallbackApplied`
- DashScope 上游错误保留 `raw`
## 5. 验收口径
- 用户不需要手动改「艾瑞克」这个名字;后端遇到 `IPInfringementSuspect` 会自动切换到原创安全 prompt 再试一次。
- 若兜底 prompt 生成成功,后续 OSS 草稿、发布和资产绑定链路保持原样。
- 若兜底 prompt 仍失败,错误中仍能看到 DashScope 原始失败内容,方便继续定位具体触发项。

View File

@@ -0,0 +1,66 @@
# 创作模板 Agent 聊天共用化设计2026-04-24
## 背景
当前创作模板的 Agent 聊天链路不一致RPG 世界共创与拼图共创已经由后端调用模型推理生成回复和锚点状态,大鱼吃小鱼仍在 SpacetimeDB 过程内用规则推断锚点并返回固定回复。这样会导致同一入口下不同模板的共创体验不一致,也会让后续新增模板重复实现聊天流程。
## 目标
1. 所有创作模板的 Agent 聊天都必须由后端模型推理生成回复与下一轮锚点状态。
2. Agent 聊天能力对齐 RPG 世界共创的功能效果:后端负责模型调用、锚点更新、进度推进与落库,前端只消费 session snapshot。
3. 不同模板在 Agent 聊天环节只允许通过“锚点问题配置”体现差异其余提示词骨架、流式回复、JSON 解析、失败兜底流程复用。
4. 锚点配置必须从代码逻辑中抽离为独立配置文件,新增模板时优先新增配置而不是复制聊天代码。
## 首版落地范围
| 模板 | 现状 | 本次处理 |
| --- | --- | --- |
| RPG 世界共创 | 已走模型推理,八锚点结构最完整 | 保持现有流程,作为统一体验标杆 |
| 拼图共创 | 已走模型推理,但锚点问题硬编码在 Rust 提示词中 | 把 5 个锚点问题迁入配置文件,提示词读取配置 |
| 大鱼吃小鱼 | 规则推断 + 固定回复 | 新增模型推理 turn读取 4 个锚点配置,提交消息后再 finalize 落库 |
## 配置设计
配置文件路径固定为:`server-rs/crates/api-server/src/creation_agent_anchor_templates.json`
每个模板配置包含:
- `templateId`:模板唯一标识。
- `displayName`:用于后端提示词,不直接展示在 UI。
- `creationGoal`:模板最终要收束出的可玩结果。
- `anchorQuestions`:锚点问题列表,包含 `key / label / question / requiredEffect`
首版配置只存“问题与效果”,不存模型输出 schema。原因是各模板 session 的锚点结构不同schema 仍由各自 domain 类型约束,避免为了统一而牺牲类型安全。
## 共用聊天骨架
后端新增 `creation_agent_anchor_templates` 模块,提供:
1. 读取并缓存配置文件。
2.`templateId` 返回模板配置。
3. 渲染统一的“锚点问题段落”。
各模板 agent turn 共用以下模型推理约束:
1. 系统提示词说明当前模板目标。
2. 插入配置文件中的锚点问题段落。
3. 插入当前锚点状态与最近聊天记录。
4. 要求模型只输出 JSON。
5. 流式过程中只把 `replyText` 增量给前端。
6. 完整响应解析后,由模板自身 domain 类型反序列化并落库。
## 大鱼吃小鱼落库调整
SpacetimeDB module 仍保持数据真相源职责,但不再在 `submit_big_fish_message` 内生成 assistant 回复与新锚点。流程调整为:
1. `submit_big_fish_message` 只写入用户消息,保留原锚点与进度。
2. api-server 调用模型生成 `replyText / progressPercent / nextAnchorPack`
3. 新增 `finalize_big_fish_agent_message_turn` procedure把模型结果写回 assistant 消息、锚点、进度与 `last_assistant_reply`
4. 模型失败时通过 finalize 记录失败提示,避免用户消息丢失。
## 约束
- 前端不新增逻辑分支,不在 UI 中展示规则说明类文本。
- 后端 prompt、配置与 Rust 代码必须保留中文注释或中文语义说明。
- 配置文件是后端资产,不依赖前端动态编辑。
- 若未来模板锚点结构统一,可以再把输出 schema 迁入配置;首版不做过度抽象。

View File

@@ -0,0 +1,78 @@
# 创作 Agent 聊天区滚动跟随策略修复
日期:`2026-04-23`
## 1. 背景
当前统一创作聊天工作区 [`src/components/creation-agent/CreationAgentWorkspace.tsx`](D:/Genarrative/src/components/creation-agent/CreationAgentWorkspace.tsx) 在以下任一变化时都会强制执行一次滚动到底部:
1. `session.messages`
2. `streamingReplyText`
3. `isStreamingReply`
原实现直接对底部占位节点执行:
1. `scrollIntoView`
2. `behavior: 'smooth'`
这会导致:
1. RPG 创作聊天在 SSE 流式回复期间持续被强拉到底部。
2. 用户手动上滑查看历史消息后,只要流式文本继续更新,就会再次被抢回到底部。
3. 统一聊天工作区已经复用到多条创作链,这个问题会同时影响所有使用 `CreationAgentWorkspace` 的品类。
## 2. 目标
把统一聊天区的滚动策略改成“条件跟随”,而不是“无条件强制到底”:
1. 用户本来就在底部附近时,新消息和流式回复继续跟随到底部。
2. 用户主动上滑离开底部后,不再因为流式更新被强制拉回底部。
3. 用户自己再次发送消息或点击推荐回复时,允许重新进入“跟随底部”状态。
4. 流式阶段不再对每个增量使用 `smooth scroll` 动画,避免持续抖动。
## 3. 方案
## 3.1 工作区组件内部维护滚动跟随态
`CreationAgentWorkspace` 内新增:
1. 聊天滚动容器 `ref`
2. `shouldAutoScrollRef`
判定规则:
1. 当滚动容器距离底部小于等于 `96px` 时,视为“仍在底部附近”。
2. 只有在 `shouldAutoScrollRef=true` 时,消息/流式文本更新才自动滚到底。
## 3.2 用户手动滚动优先
聊天列表监听 `onScroll`
1. 用户上滑离开底部后,把 `shouldAutoScrollRef` 置为 `false`
2. 之后流式 `reply_delta` 继续到来,也不再改写用户当前阅读位置
## 3.3 用户主动发送可重新挂到底部
以下动作视为用户主动回到当前对话尾部:
1. 输入框发送消息
2. 点击推荐回复
执行这些动作前,把 `shouldAutoScrollRef` 重新置为 `true`,保证用户主动推进对话后仍能看到最新回复。
## 3.4 适用范围
本修复落在统一工作区层,因此会同时覆盖:
1. RPG / Custom World Agent
2. Big Fish Agent
3. Puzzle Agent
不需要在各品类 controller 内重复补滚动判断。
## 4. 验收标准
1. 用户在聊天区手动上滑后,流式回复继续生成时,页面不再被持续拉到底部。
2. 用户停留在底部附近时,新消息仍能自然跟随到最新位置。
3. 用户发送新消息后,聊天区仍能回到最新对话尾部。
4. `CreationAgentWorkspace` 定向测试补齐并通过。

View File

@@ -0,0 +1,41 @@
# 创作 Agent Client 与平台流程 Controller 复用方案 2026-04-25
## 背景
RPG、自定义大鱼吃小鱼、拼图三类作品创作都已经采用 Agent-first 主链,但前端仍存在两类重复:
1. 三类 Agent client 都手写 `createSession / getSession / sendMessage / streamMessage / executeAction`
2. 平台入口壳层内直接维护大鱼、拼图的 session、流式消息、忙碌态、错误态与草稿恢复逻辑。
聊天 UI、SSE 解析、快捷补齐已经有共用模块,本轮只补齐 client 与平台流程编排层的复用,不改玩法领域数据结构。
## 目标
1. 新增通用 `createCreationAgentClient` 工厂统一请求、SSE POST、retry、超时与中文错误文案配置。
2. 大鱼、拼图 client 保留原导出函数名但内部先改走通用工厂RPG client 暂保留既有成熟实现,后续只有在不影响自动保存/结果预览语义时再接入。
3. 新增平台创作流程 controller hook收口轻量玩法的创建会话、恢复草稿、发送流式消息与执行 action。
4. 平台壳层只保留玩法差异动作:运行态启动、作品删除、拼图公开详情跳转等。
## 非目标
1. 不统一 RPG、大鱼、拼图的 session schema。
2. 不把大鱼或拼图强行接入 RPG 的发布门禁、自动保存、运行时协议。
3. 不改后端 SpacetimeDB 表、procedure 或现有路由。
## 落地边界
1. `src/services/creation-agent/creationAgentClientFactory.ts`
- 负责统一 HTTP/SSE client 骨架。
- 允许每个玩法配置 basePath、runtime 前缀、错误文案、返回值提取方式。
2. `src/components/platform-entry/usePlatformCreationAgentFlowController.ts`
- 负责通用前端流程态session、busy、error、streaming、open、restore、submit、execute。
- 通过 adapter 接收玩法差异client、stage、compile action、session 是否已有 draft。
3. `PlatformEntryFlowShellImpl.tsx`
- 大鱼与拼图切到 controller。
- 保留 RPG 现有成熟 controller不在本轮合并避免把 RPG 复杂自动保存链拉进轻量玩法抽象。
## 验收
1. 已接入工厂的 Agent client 公开 API 不变RPG client 公开 API 与既有实现都不变。
2. 大鱼、拼图仍能从平台入口新建、恢复草稿、发送消息、生成结果页。
3. 现有定向测试通过,编码检查通过。

View File

@@ -0,0 +1,76 @@
# Agent 创作页文档输入上传方案
更新时间:`2026-04-25`
## 1. 目标
Agent 创作页需要支持用户上传文档,并把文档内容解析成当前输入框里的文本,让用户可以继续编辑后再发送给 Agent。
本次只解决“文档作为输入内容”的轻量闭环,不把文件作为资产入库,也不改变 Agent 会话、消息、草稿生成的后端主链。
## 2. 职责边界
1. 前端 `CreationAgentWorkspace` 负责展示上传入口、先做文件格式与大小预检、读取浏览器选择的文件为 base64、调用解析接口、把返回文本追加到输入框。
2. Rust `api-server` 负责文件类型、大小、编码与文本抽取规则,前端不直接承载文档解析逻辑。
3. 解析完成后仍走现有 `onSubmitText` 消息提交,不新增 Agent 消息结构。
4. 该能力覆盖所有复用 `CreationAgentWorkspace` 的 Agent 创作页RPG / 自定义世界、拼图、大鱼吃小鱼。
## 3. 首版支持范围
支持扩展名:
1. `.txt`
2. `.md`
3. `.markdown`
4. `.csv`
5. `.json`
大小限制:单文件最大 `256 KiB`
编码限制:首版按 UTF-8 文本处理。若文件不是 UTF-8服务端返回 `400`,由前端展示错误。
暂不支持 `.pdf` / `.doc` / `.docx` 的二进制结构解析;后续扩展时只需要在 Rust 解析接口内部补 extractor不改变前端输入框接入方式。
## 4. 接口设计
路径:`POST /api/runtime/creation-agent/document-inputs/parse`
鉴权Bearer 登录态。
请求:
```json
{
"fileName": "世界设定.md",
"contentType": "text/markdown",
"contentBase64": "..."
}
```
响应:
```json
{
"document": {
"fileName": "世界设定.md",
"contentType": "text/markdown",
"sizeBytes": 128,
"text": "..."
}
}
```
## 5. UI 规则
1. 输入框左侧新增文件图标按钮,使用图标与 hover title 表达,不在页面铺功能说明文本。
2. 上传前先在浏览器侧拒绝不支持格式、空文件和超限文件,避免无意义读取大文件。
3. 上传处理中禁用按钮和发送按钮,避免同一输入框状态并发写入。
4. 文件解析成功后追加到当前草稿文本后方,若当前草稿非空则用两个换行分隔。
5. 错误展示复用输入区附近的短错误条,不弹独立面板。
## 6. 验收标准
1. 上传 `.txt``.md` 后,输入框出现文档内容。
2. 输入框已有内容时,解析文本追加在末尾,并用空行分隔。
3. 上传不支持格式或超限文件时,页面展示中文错误,不发送 Agent 消息。
4. 定向测试覆盖解析客户端、Rust 接口和 `CreationAgentWorkspace` 上传交互。

View File

@@ -0,0 +1,24 @@
# 创作 Agent 发送后即时等待点动画修复
日期:`2026-04-25`
## 1. 背景
统一创作 Agent 工作区已经用 `CreationAgentWorkspace` 承载 RPG / Custom World、大鱼吃小鱼、拼图三条聊天链路。旧展示条件只有在 `streamingReplyText` 已经收到文本时才追加临时 assistant 气泡,因此用户发送消息后到首个 SSE token 到达前,聊天区会短暂没有任何等待反馈。
## 2. 设计
本轮只改前端表现层不改变后端会话、SSE、消息落库或推荐回复语义
1. `CreationAgentWorkspace``isStreamingReply` 作为临时 assistant 气泡的展示条件。
2.`streamingReplyText` 为空时,临时气泡内部展示三个脉冲点。
3. 当首个流式文本到达后,同一个临时气泡切换为文本内容与光标。
4. 最终 session 回写后,临时气泡消失,由正式 assistant 消息接管原位置。
5. 大鱼吃小鱼与拼图适配层显式透传 `isStreamingReply`,不再用 `Boolean(streamingReplyText)` 推断等待状态。
## 3. 验收
1. 用户发送消息后,聊天列表底部立即出现三点等待动画。
2. 首个 SSE 文本到达前等待动画持续存在。
3. 流式文本到达后等待动画切换为正常流式回复。
4. `CreationAgentWorkspace` 定向测试覆盖空文本流式等待态。

View File

@@ -0,0 +1,35 @@
# 创作 Agent 发布门槛结果页归一化回写修正
日期:`2026-04-24`
## 1. 问题现象
`custom_world.publish_gate` 诊断日志显示:
1. `has_draft_profile=true`
2. `has_result_preview=true`
3. `has_world_hook=true`
4. `has_core_conflicts=true`
5. 但仍存在 `publish_missing_player_premise / publish_missing_main_chapter / publish_missing_first_act`
这说明接口可正常读取 session问题不在 `GET /api/runtime/custom-world/agent/sessions/:sessionId` 本身,而在结果页 profile 回写到 session 时,发布门槛需要的部分结构字段没有稳定保留下来。
## 2. 根因
前端结果页通过 `normalizeCustomWorldProfileRecord``resultPreview.preview` 转成 `CustomWorldProfile`。该归一化模型原本主要服务作品库与运行时展示,只保留了 `settingText / summary / playerGoal / creatorIntent / anchorContent / sceneChapterBlueprints` 等字段,没有把后端发布门槛直接读取的顶层 `worldHook / playerPremise` 纳入 `CustomWorldProfile` 稳定字段。
当自动保存或发布前执行 `sync_result_profile` 时,前端会把归一化后的 profile 传回 SpacetimeDB。若这份 profile 中缺少顶层 `playerPremise`,且 `creatorIntent / anchorContent` 又未包含可读玩家切入字段,后端最终 publish gate 会继续报 `publish_missing_player_premise`
## 3. 修复口径
1. `CustomWorldProfile` 显式声明 `worldHook / playerPremise` 为 Agent 发布快照兼容字段。
2. `normalizeCustomWorldProfileRecord` 保留顶层 `worldHook / playerPremise`,并在缺失时从 `creatorIntent.worldHook / creatorIntent.playerPremise / summary / playerGoal` 做最小回填。
3. 不在 UI 新增规则说明文案;这两个字段只作为后端发布门槛与 session 回写的稳定数据槽位。
4. 后端 publish gate 继续以 SpacetimeDB 中的 `draft_profile_json` 为最终真相源,前端只负责把结果页当前 profile 完整同步回去。
## 4. 验收标准
1.`resultPreview.preview` 构建结果页 profile 后,`worldHook / playerPremise` 不会被前端归一化丢弃。
2. 自动保存或点击发布前执行 `sync_result_profile` 时,传回后端的 profile 保留发布门槛所需顶层字段。
3. 若当前草稿确实包含玩家切入与 `sceneChapterBlueprints[*].acts`,后端诊断日志不应再出现对应结构 blocker。
4. 若草稿真实缺失章节或第一幕,`publish_missing_main_chapter / publish_missing_first_act` 仍应保留,不做前端假放行。

View File

@@ -0,0 +1,96 @@
# 创作 Agent 发布门槛字段对齐修复
日期:`2026-04-23`
## 1. 问题现象
RPG 创作结果页已经能看到完整草稿内容,但页面底部仍然持续显示旧的发布阻断项,例如:
1. 缺少 `world hook`
2. 缺少 `player premise`
3. 缺少主线章节草稿
4. 缺少主线第一幕
同时“发布并进入世界”按钮保持禁用,无法实际发布。
## 2. 根因
这不是单纯的前端提示未刷新,而是 Rust `publish gate` 仍在按旧 schema 校验 `draft_profile_json`
当前前端结果页、自动保存和 session preview 主链的真实结构已经演进为:
1. 世界一句话与玩家切入信息优先存放在 `anchorContent``creatorIntent`
2. 场景章节主链字段为 `sceneChapterBlueprints`
3. `settingText` 也会承载世界总体一句话设定
问题最初在拆分后的 `server-rs/crates/spacetime-module/src/custom_world/mod.rs` 中被修过一版,但当前线上实际执行入口仍保留在 `server-rs/crates/spacetime-module/src/lib.rs`
也就是说,真正参与 Agent session snapshot、结果页 publish gate 刷新和 `publish_world` 动作校验的,仍然是 `lib.rs` 里的历史实现;而它还只检查旧字段:
1. `worldHook`
2. `playerPremise`
3. `chapters`
4. `sceneChapters`
结果导致:
1. 结果页展示的是新 preview
2. 发布门槛检查读的是旧字段
3. 同一个草稿在 UI 看起来“已经有内容”,但 gate 仍然误判为缺失
因此会出现“拆分模块里的代码已经对齐,但页面实际 blocker 仍然不消失”的假象。
此外,`lib.rs` 里的最小草稿兜底结构也没有补上 `sceneChapterBlueprints` 默认槽位,导致部分恢复、回滚和草稿兜底链路继续偏向旧 schema。
## 3. 修复策略
本轮统一把实际入口 `server-rs/crates/spacetime-module/src/lib.rs` 的发布门槛与最小草稿结构对齐到当前前端主链 schema
1. `world hook` 检查同时兼容:
- `worldHook`
- `creatorIntent.worldHook`
- `anchorContent.worldPromise.hook`
- `settingText`
2. `player premise` 检查同时兼容:
- `playerPremise`
- `creatorIntent.playerPremise`
- `anchorContent.playerEntryPoint.openingIdentity`
- `anchorContent.playerEntryPoint.openingProblem`
- `anchorContent.playerEntryPoint.entryMotivation`
3. 主线章节检查同时兼容:
- `chapters`
- `sceneChapterBlueprints`
- `sceneChapters`
4. 主线第一幕检查优先读取:
- `sceneChapterBlueprints[*].acts`
- `sceneChapters[*].acts`
5. 最小草稿兜底结构同时补上 `sceneChapterBlueprints` 空数组,避免恢复链路重新回落到旧字段集合。
## 4. 验收标准
1. 结果页已包含 `anchorContent / creatorIntent / sceneChapterBlueprints` 的草稿,不再被旧 blocker 误判。
2. `publishReady` 会随当前 session 最新 preview 正确刷新。
3. “发布并进入世界”在 blocker 清空后恢复可点击。
4. `ensure_minimal_draft_profile(...)` 生成的兜底草稿也包含 `sceneChapterBlueprints`
5. 新增 Rust 单测,覆盖“当前 Agent 结果 schema 不应再误报 blocker”与“最小草稿必须保留 `sceneChapterBlueprints` 默认槽位”。
## 5. 亮色主题阻断项弹窗配色修复
日期:`2026-04-24`
### 5.1 问题现象
点击“发布并进入世界”但仍存在阻断项时,`PublishBlockersDialog` 会通过 portal 挂载到 `document.body`。在亮色主题下,弹窗面板使用平台浅色面板变量,但标题、分隔线和阻断项标签仍混用暗色主题下的 `text-white``border-white/10``text-amber-100/78` 等硬编码类名,导致局部对比度和色相不一致。
### 5.2 修复口径
1. portal 根节点显式补上 `platform-theme platform-theme--${platformTheme}`,避免弹窗脱离原页面主题变量继承。
2. 弹窗标题、上下分隔线统一改为 `--platform-text-strong``--platform-subpanel-border`
3. 阻断项卡片复用 `platform-banner platform-banner--warning`,由平台主题变量决定亮色和暗色下的边框、背景与警示文字色。
4. 阻断项正文保持 `--platform-text-strong`,保证亮色主题下可读性,不再依赖暗色主题的琥珀色文本。
### 5.3 验收标准
1. 亮色主题下阻断项弹窗标题、正文、分隔线和按钮均保持平台浅色视觉体系。
2. 阻断项卡片呈现柔和警示底色,不出现白字或过浅琥珀字落在浅底上的情况。
3. 暗色主题下弹窗仍保持原有平台暗色 warning banner 风格。

View File

@@ -0,0 +1,31 @@
# 创作 Agent 结果页 SSE 断开修复
日期:`2026-04-24`
## 1. 问题
RPG 世界共创草稿进入生成结果页后,前端仍可能保留上一条聊天消息的 `/messages/stream` 连接。该连接继续接收 `reply_delta` 时,会让结果页阶段仍表现为“聊天还在连着 SSE”。
## 2. 原因
当前聊天流式请求由 `streamRpgCreationMessage` 发起,底层使用 `fetch` 读取 SSE `ReadableStream`。旧实现只在请求自然结束后清理 `isStreamingAgentReply`,没有在以下 UI 生命周期主动中止网络流:
1.`agent-workspace` 跳到 `custom-world-result`
2. 清空或切换 `activeAgentSessionId`
3. 当前入口组件卸载。
因此,结果页虽然不再展示聊天工作区,但浏览器侧仍可能持有未完成的流读取器。
## 3. 修复设计
1. `TextStreamOptions` 增加 `signal?: AbortSignal`,让所有创作 Agent 流式读取都具备统一取消入口。
2. RPG 共创 `/messages/stream``fetch` 透传该 `signal`
3. `useRpgCreationSessionController` 持有当前聊天流的 `AbortController`
4.`selectionStage` 离开 `agent-workspace` / `custom-world-generating`,立即 `abort()` 当前聊天 SSE并清空临时流式文本。
5. session 切换、未登录清理、组件卸载时同样中止旧 SSE避免慢响应回写旧工作区状态。
## 4. 验收
1. 聊天中触发草稿生成并进入结果页后,浏览器 Network 中旧 `/messages/stream` 请求应变为 canceled/aborted 或结束。
2. 结果页不再继续追加聊天 `reply_delta`
3. 回到 Agent 工作区后,新的聊天消息会创建新的 SSE不复用已中止连接。

View File

@@ -0,0 +1,45 @@
# 创作 Agent Session 同步渲染环修复
日期:`2026-04-23`
## 1. 问题现象
本地联调时,前端在打开 RPG 创作 Agent 工作区后,会持续高频请求:
- `GET /api/runtime/custom-world/agent/sessions/:sessionId`
`api-server.log` 可以看到,同一个 `sessionId` 会在几毫秒到几十毫秒间隔内被重复读取很多次,明显高于正常 operation 轮询的 `1200ms` 周期。
## 2. 根因
这不是后端主动重试,也不是 session client 自带重试,而是前端 `useEffect` 被不稳定依赖反复触发。
触发链如下:
1. `PlatformEntryFlowShellImpl.tsx` 内部把 `enterCreateTab` 定义为:
- 依赖整个 `platformBootstrap` 对象。
2. `usePlatformEntryBootstrap()` 虽然内部的 `setPlatformTab` 是稳定回调,但返回值是一个新的对象字面量。
3. 组件每次 render 时,`platformBootstrap` 引用都会变化,导致 `enterCreateTab` 也变成新的函数引用。
4. `useRpgCreationSessionController.ts` 中“同步当前 Agent session 快照”的 `useEffect` 依赖了 `enterCreateTab`
5. 该 effect 每次重跑都会调用 `syncAgentSessionSnapshot(activeAgentSessionId)`,进而触发一次新的 `GET /agent/sessions/:sessionId`
6. `syncAgentSessionSnapshot(...)` 成功后会 `setAgentSession(...)`,又导致页面 render从而形成新的 render -> 新 `enterCreateTab` -> effect 重跑 -> 再次 GET 的闭环。
因此,真正的根因是:
- `session 同步 effect` 被一个与业务无关、且每次 render 都变化的函数依赖错误地牵连进了渲染环。
## 3. 修复策略
本轮不改后端语义,只收紧前端依赖稳定性:
1. `PlatformEntryFlowShellImpl.tsx` 不再让 `enterCreateTab` 依赖整个 `platformBootstrap` 对象。
2. 先解构稳定的 `setPlatformTab`,再用它生成 `enterCreateTab`
3. 保持 `useRpgCreationSessionController.ts` 现有 effect 逻辑不变,只让它接收到稳定的 `enterCreateTab` 引用。
4. 增加前端回归测试,确保打开 RPG Agent 工作区后session 快照不会因为 render 抖动而被重复拉取。
## 4. 验收标准
1. 打开 RPG 创作工作区后,允许出现首轮必要的 session 同步请求,但不能进入高频重复 GET。
2. 未启动 operation 轮询时,不应出现毫秒级连续读取同一 `sessionId` 的现象。
3. 存在 `activeAgentOperationId` 时,只保留原有 `1200ms` 轮询与完成态后的单次 session 刷新。
4. 创作工作区、草稿结果页、作品详情等原有导航语义保持不变。

View File

@@ -0,0 +1,71 @@
# 创作 Agent 流式消息与草稿切换稳定性修复
日期:`2026-04-23`
## 1. 背景
统一创作工作区已经承载 RPG 世界共创、大鱼吃小鱼和拼图等 Agent 对话。当前 RPG 世界共创在本地联调中暴露出以下前端状态抖动:
1. AI 流式回复过程中,中文内容会先出现乱码,随后又被正常文本覆盖。
2. 玩家刚发送的消息会在聊天列表中短暂出现,随后消失又重新出现。
3. AI 回复会短暂插在玩家消息中间,之后又跳回底部。
4. 曾经打开过某个草稿后,再打开另一个草稿或创建新对话时,结果页可能在旧草稿和当前内容之间来回闪烁。
这些现象的共同原因不是单个滚动动作,而是同一 UI 区域同时被多套不同来源的状态驱动本地乐观消息、SSE 临时回复、服务端最终 session 快照、旧草稿结果页缓存和异步恢复结果。
## 2. 目标
本轮修复只收敛前端展示稳定性,不改变后端业务语义:
1. 聊天列表只展示一条稳定的玩家消息,不因最终 session 回写而闪消。
2. AI 流式回复始终作为当前尾部 assistant 消息呈现,不和正式消息互相插队。
3. SSE 中文文本按 UTF-8 流式边界安全解码,流结束时刷新解码器尾部缓存。
4. 草稿切换、打开已有草稿、新建对话时先清理旧结果页缓存,旧异步恢复结果不得覆盖当前视图。
5. 继续保留“用户主动上滑后不强制滚到底部”的聊天区滚动策略。
## 3. 设计
## 3.1 SSE 事件读取
`src/services/creation-agent/creationAgentSse.ts` 继续作为统一 SSE 读取器,但需要补齐以下边界:
1. 使用 UTF-8 `TextDecoder` 的 streaming 模式接收 chunk。
2. `reader.read()` 结束后调用 `decoder.decode()` 刷新尾部缓冲,避免多字节中文字符残留在解码器内部。
3. 事件分隔同时兼容 `\n\n``\r\n\r\n`
4. `reply_delta``text` 字段按“当前可展示文本”传给 UI不在读取器内追加避免累计文本和增量文本语义混用。
## 3.2 玩家消息展示
RPG Agent 发送消息时,本地乐观玩家消息仍保留,但最终 session 回写时必须做稳定合并:
1. 若服务端快照已包含同一个 `clientMessageId`,以服务端消息为准。
2. 若服务端快照暂未包含该消息,临时保留本地消息,直到后续快照补齐。
3. 合并只按消息 `id` 去重,不整包丢弃本地尾部消息。
## 3.3 AI 流式回复展示
统一聊天工作区不再把流式回复作为独立于列表之外的气泡随意附加,而是在展示消息数组中合成一个稳定的尾部临时 assistant 消息:
1. session 正式消息仍是基础列表。
2. 有流式文本时,追加或替换尾部临时 assistant 消息。
3. 最终 session 到来后,临时消息消失,由正式 assistant 消息接管同一视觉位置。
4. 推荐回复只挂在正式最后一条 assistant 消息上,流式临时消息不展示推荐回复。
## 3.4 草稿切换
打开已有草稿、打开 Agent 草稿、新建 RPG Agent 对话前,必须先清理旧结果页相关缓存:
1. `generatedCustomWorldProfile`
2. `customWorldGenerationViewSource`
3. `customWorldResultViewSource`
4. 自动保存状态
异步读取 session 时要以本次打开的 `sessionId` 作为准入条件,防止上一个草稿的慢响应覆盖当前草稿。
## 4. 验收标准
1. 玩家输入发送后在聊天列表中只稳定出现一次,不再闪消。
2. AI 流式回复只在底部连续更新,不插入玩家消息中间。
3. 中文流式回复不再出现先乱码后正常的过渡。
4. 从一个草稿切换到另一个草稿或新建对话时,不再短暂显示旧草稿结果页。
5. 用户手动上滑聊天区后,流式更新仍不强制抢回底部。

View File

@@ -0,0 +1,89 @@
# 创作类别开启超时兜底修复记录
日期:`2026-04-22`
## 1. 问题现象
创作中心点击某个创作类别后,入口卡片会进入 `正在开启` 状态,但在后端创建会话迟迟不返回时,界面没有明确失败反馈,用户体感就是“卡死”。
本次定位覆盖入口:
1. `角色扮演 RPG`
2. `大鱼吃小鱼`
3. `拼图玩法`
## 2. 根因结论
这次问题不是前端少写了 `finally`
真实根因是:
1. 创作类别点击后会立即把入口置为 busy。
2. 会话创建请求如果在运行时后端 / Rust 代理 / Spacetime client 这一段长时间无返回,前端 Promise 就不会及时结束。
3. 旧实现缺少“创作入口启动阶段”的独立超时兜底,所以 busy 会持续停留在 `正在开启`
## 3. 本次修复口径
本轮只修“类别开启阶段不能无限等待”,不改创作工作区内部消息流与生成流的超时策略。
冻结口径如下:
1. 创作类别创建会话请求统一增加启动超时。
2. 超时后必须退出 `正在开启` busy 状态。
3. UI 必须展示中文可读错误,不能直接显示底层 `TimeoutError` 或毫秒数字。
4. Node 代理转发 Rust 新玩法接口时也必须有上游超时,避免代理层持续悬挂。
## 4. 具体落地
### 4.1 前端请求层
`src/services/apiClient.ts` 增加 `timeoutMs` 能力:
1. 请求可选传入超时毫秒数。
2. 到达超时后通过 `AbortController` 中断请求。
3. 向上抛出统一 `TimeoutError`
### 4.2 创作类别入口
以下创建会话入口统一使用 `15000ms` 启动超时:
1. `src/services/rpg-creation/rpgCreationAgentClient.ts`
2. `src/services/big-fish-creation/bigFishCreationClient.ts`
3. `src/services/puzzle-agent/puzzleAgentClient.ts`
### 4.3 错误文案
`src/components/rpg-entry/rpgEntryShared.ts` 中统一把超时错误映射为中文提示:
1. RPG`开启创作工作台超时,请确认运行时后端已启动后重试。`
2. Big Fish`开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。`
3. 拼图:`开启拼图创作工作台超时,请确认运行时后端已启动后重试。`
### 4.4 Node 代理
以下代理路由新增上游超时:
1. `server-node/src/routes/bigFishProxyRoutes.ts`
2. `server-node/src/routes/puzzleProxyRoutes.ts`
超时后返回:
1. `大鱼吃小鱼后端响应超时`
2. `拼图后端响应超时`
## 5. 验收标准
修复后需要满足:
1. 点击创作类别时,后端长时间无返回不会无限停留在 `正在开启`
2. 超时后入口按钮恢复可点击。
3. 页面展示中文错误提示。
4. Big Fish / 拼图的新玩法代理链同样不会无限挂起。
## 6. 本轮回归
本轮至少补以下回归:
1. `apiClient` 请求超时回归。
2. Big Fish 类别开启超时回归。
3. 拼图类别开启超时回归。

View File

@@ -0,0 +1,85 @@
# 创作入口鉴权错误串味修复
日期:`2026-04-22`
## 1. 问题现象
平台首页点击“创作”后,用户在创作入口浮层或创作中心起始卡片中会看到:
- `缺少 Authorization Bearer Token`
该文案直接暴露了后端鉴权实现细节,不符合平台入口的产品语义,也会让用户误以为“点击创作弹窗本身就失败了”。
## 2. 根因拆解
本次问题实际由两层叠加造成:
1. `useRpgCreationSessionController` 把“恢复旧 Agent 会话失败”的错误写入 `creationTypeError`
2. `PlatformEntryFlowShellImpl` 又把 `creationTypeError` 同时透传给:
- 创作中心起始卡片 `createError`
- 创作类型浮窗 `error`
- 平台首页 `platformError`
结果是:
- 旧会话恢复失败
- 未登录态残留会话恢复
- 本地 access token 丢失但 refresh cookie 仍在
这些与“当前点击新建创作”并不完全等价的错误,被错误地展示到了新建创作入口上。
## 3. 修复策略
### 3.1 错误分层
`useRpgCreationSessionController` 中新增:
- `agentWorkspaceRestoreError`
约束:
1. 旧 Agent 会话恢复失败只写入 `agentWorkspaceRestoreError`
2. 用户主动点击新建创作失败才写入 `creationTypeError`
3. 创作中心起始卡片和创作类型浮窗只展示“新建入口错误”
4. 平台页和工作区恢复占位文案展示“恢复态错误”
### 3.2 鉴权兜底
`fetchWithApiAuth` 中补充规则:
1. 受保护请求若本地没有 bearer token
2. 且请求未声明 `skipAuth / skipRefresh`
3. 先尝试 `ensureStoredAccessToken()` 静默补票
4. 补票失败再继续原始请求
这样可以覆盖“refresh cookie 仍有效,但本地 access token 丢失”的场景,避免后端直接返回“缺少 Authorization Bearer Token”。
### 3.3 用户态错误文案收敛
`resolveRpgEntryErrorMessage``401 UNAUTHORIZED``缺少 Authorization Bearer Token` 统一映射为:
- `当前登录状态已失效,请重新登录后继续。`
目标是把后端实现细节收束成平台用户可理解的恢复动作。
## 4. 影响范围
本轮覆盖:
1. RPG / Custom World 创作入口
2. 平台创作中心起始卡片
3. 平台创作类型浮窗
4. 统一前端 API 鉴权请求层
本轮不改:
1. 后端 `401` 契约
2. 登录弹窗交互
3. Big Fish / Puzzle 的后端路由鉴权策略
## 5. 验收
1. 点击“创作”后,不再出现原始 `Authorization Bearer Token` 报错文案
2. 旧会话恢复失败时,错误只停留在恢复上下文,不污染新建创作入口
3. 本地 token 丢失但 refresh 仍有效时,前端可自动补票后继续请求
4. 相关测试与编码检查通过

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
# 创作链路重构工作包 A 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 A命名规范与目录骨架**,约束如下:
1. 先建立 RPG 创作域的新命名落点。
2. 先提供 façade 和 barrel不迁移主流程行为。
3. 不提前修改工作包 B 到 H 的大块业务逻辑。
## 2. 本次已落地内容
## 2.1 前端目录骨架
已新增以下目录与 façade
1. `src/components/game-shell/rpg-creation-flow/`
2. `src/components/rpg-creation-result/`
3. `src/components/rpg-creation-editor/`
4. `src/services/rpg-creation/`
当前策略:
1. `RpgCreationShell` 继续桥接旧的 `PreGameSelectionFlow`
2. `RpgCreationResultView` 继续桥接旧的 `CustomWorldResultView`
3. `RpgCreationEntityEditorModal` 继续桥接旧的 `CustomWorldEntityEditorModal`
4. `rpgCreation*Client` 继续桥接 `aiService.ts``storageService.ts``customWorldCoverAssetService.ts`
5. `rpgCreationPreviewAdapter` 继续桥接旧的前端草稿编译函数,明确它只是过渡层。
## 2.2 后端目录骨架
已新增以下 RPG 创作域 façade
1. `server-node/src/routes/rpgCreationAgentRoutes.ts`
2. `server-node/src/routes/rpgWorldWorksRoutes.ts`
3. `server-node/src/routes/rpgWorldLibraryRoutes.ts`
4. `server-node/src/routes/rpgWorldGalleryRoutes.ts`
5. `server-node/src/services/RpgAgentOrchestrator.ts`
6. `server-node/src/services/RpgAgentSessionStore.ts`
7. `server-node/src/services/RpgWorldPreviewCompiler.ts`
8. `server-node/src/services/RpgWorldWorkSummaryService.ts`
当前策略:
1. Agent route 与 orchestrator/session store 先用新命名 façade 对齐。
2. works/library/gallery 路由先建立空骨架和基础 path 常量,避免下一轮迁移继续回落到旧命名。
3. `RpgWorldPreviewCompiler` 先桥接旧 `runtimeProfile.ts` 编译能力,为工作包 G 的目录化拆分预留落点。
## 2.3 共享契约骨架
已新增以下共享契约入口:
1. `packages/shared/src/contracts/rpgAgentAnchors.ts`
2. `packages/shared/src/contracts/rpgAgentDraft.ts`
3. `packages/shared/src/contracts/rpgAgentSession.ts`
4. `packages/shared/src/contracts/rpgAgentActions.ts`
5. `packages/shared/src/contracts/rpgCreationPreview.ts`
6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts`
当前策略:
1. 会话、动作、作品摘要先从旧 `customWorldAgent.ts` 做类型级兼容导出。
2. `rpgAgentDraft.ts` 先把 foundation draft、draft card 等草稿相关类型收口成独立入口,给工作包 H 后续物理拆分预留稳定导入点。
3. `packages/shared/src/index.ts` 已补上对 RPG 草稿契约骨架的根导出,避免后续工作包继续回退到旧 `customWorldAgent.ts` 取类型。
4. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成。
## 3. 本次没有做的事
以下内容仍保持原状,留给后续工作包:
1. 没有拆 `PreGameSelectionFlow.tsx` 内部编排。
2. 没有拆 `CustomWorldResultView.tsx``CustomWorldEntityEditorModal.tsx``CustomWorldRoleAssetStudioModal.tsx` 内部 section。
3. 没有把 `runtimeRoutes.ts` 中的 works/library/gallery 真正迁出。
4. 没有改 `customWorldAgentOrchestrator.ts``customWorldAgentSessionStore.ts``runtimeProfile.ts` 的内部职责。
5. 没有改变任何线上行为或接口语义。
## 4. 对后续工作包的直接收益
1. 工作包 B 可以直接把平台壳层 hooks 落到 `src/components/game-shell/rpg-creation-flow/`
2. 工作包 C 可以直接把结果页与编辑器 section 落到新目录,而不用先讨论命名。
3. 工作包 D 可以直接从 `rpgCreation*Client` 开始迁移导入链。
4. 工作包 E、F、G、H 可以基于 `RpgAgent*``RpgWorld*``rpg*` 契约骨架继续拆分,而不需要再回头统一首轮命名。

View File

@@ -0,0 +1,106 @@
# 创作流程链路重构工作包 B 完成记录
更新时间:`2026-04-21`
## 1. 本轮目标
工作包 B 聚焦前端平台壳层与流程编排拆分,本轮目标是把平台壳层从“大编排文件”收口成“页面壳层 + 独立 hooks / coordinator”
1. `PreGameSelectionFlow.tsx` 退化为兼容入口。
2. `RpgCreationShellImpl.tsx` 只保留 stage 切换、组件装配、视觉级 loading / error。
3. 平台 bootstrap、session controller、operation polling、detail navigation、result autosave、enter-world 逻辑全部迁入 `src/components/game-shell/rpg-creation-flow/` 新目录。
4. 保证现有交互测试继续通过,不引入主链行为回退。
---
## 2. 已完成内容
### 2.1 旧入口已退化为兼容层
`src/components/game-shell/PreGameSelectionFlow.tsx` 现在只保留:
1. 旧类型导出兼容:`PreGameSelectionFlowProps``SelectionStage`
2. 旧组件名兼容:`PreGameSelectionFlow`
3. 对新实现 `RpgCreationShellImpl` 的桥接
这样现有调用方和测试仍可继续走旧路径,不会因为命名迁移立即破坏主链。
### 2.2 新目录已承接真实实现与流程 hooks
已新增或更新以下文件:
1. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`
2. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`
3. `src/components/game-shell/rpg-creation-flow/index.ts`
4. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts`
5. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts`
6. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts`
7. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts`
8. `src/components/game-shell/rpg-creation-flow/useRpgCreationAgentOperationPolling.ts`
9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts`
10. `src/components/game-shell/rpg-creation-flow/useRpgCreationResultAutosave.ts`
11. `src/components/game-shell/rpg-creation-flow/useRpgCreationEnterWorld.ts`
其中:
1. `RpgCreationShell.tsx` 已不再桥接旧 `PreGameSelectionFlow`,而是直接桥接 `RpgCreationShellImpl.tsx`
2. `index.ts` 已开始从新目录导出 `SelectionStage`,为后续调用迁移准备统一出口。
### 2.3 平台编排已全部拆入独立 coordinator
本轮已经把原 `PreGameSelectionFlow` / `RpgCreationShellImpl` 中的主链编排拆到以下 hook
1. `useRpgCreationPlatformBootstrap.ts`
- 平台首页 works / library / gallery / history / save / dashboard 拉取
- 浏览历史写入与存档恢复
2. `useRpgCreationSessionController.ts`
- Agent session 创建 / 恢复
- 消息流、action 执行、草稿生成态与结果页自动打开
3. `useRpgCreationAgentOperationPolling.ts`
- Agent operation 轮询
- 完成态 session 刷新与失败兜底
4. `useRpgCreationDetailNavigation.ts`
- 作品详情、创作作品恢复、草稿结果页打开
- 详情页发布 / 下架 / 删除
5. `useRpgCreationResultAutosave.ts`
- 结果页自动保存
- `sync_result_profile` 协调
- 保存签名去重与延时保存
6. `useRpgCreationEnterWorld.ts`
- 进入世界前的最终草稿同步
当前 `RpgCreationShellImpl.tsx` 只保留:
1. hooks 组合
2. stage 级视图切换
3. 组件 props 装配
4. 视觉级 loading / error 展示
---
## 3. 当前状态判断
工作包 B 已达到执行方案中的验收口径:
1. `PreGameSelectionFlow.tsx` 只剩兼容导出与新壳层桥接。
2. `RpgCreationShellImpl.tsx` 不再直接持有平台请求编排、operation 轮询、自动保存或进入世界同步细节。
3. 平台侧主链已经切成壳层 + hooks / coordinator。
4. 现有 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个场景全部通过。
---
## 4. 本轮刻意未做
1. 还没有物理删除 `PreGameSelectionFlow.tsx`,当前继续保留旧入口兼容层,避免影响并行工作包的调用路径。
2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前仍允许旧入口桥接到新壳层。
3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract当前仍使用 `rpgCreationPreviewAdapter` 作为阶段性兼容层,这属于后续工作包 G / H 与 Phase 3 范围。
4. 还没有清理所有 legacy 兼容导出与 façade当前优先稳定主链与测试口径。
---
## 5. 验证结果
1. `npx eslint "src/components/game-shell/PreGameSelectionFlow.tsx" "src/components/game-shell/rpg-creation-flow/*.ts" "src/components/game-shell/rpg-creation-flow/*.tsx"`
2. `npx vitest run src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
3. `npm run check:encoding`
以上检查在本轮修改后均已通过。

View File

@@ -0,0 +1,106 @@
# 创作链路重构工作包 D 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D前端 custom world client 收口**,约束如下:
1. 把创作链主路径依赖的 custom world 请求从 `aiService.ts``storageService.ts` 中迁入 `src/services/rpg-creation/`
2. 首轮允许旧 service 兼容导出,追加清理轮必须删除已无调用方的旧命名导出。
3. 不改后端接口语义,不扩写结果页 UI 逻辑,不借机重构工作包 B / C 的内部状态编排。
## 2. 本次已落地内容
## 2.1 RPG 创作域请求基座已独立
已新增以下请求基座文件:
1. `src/services/rpg-creation/rpgCreationRuntimeClient.ts`
2. `src/services/rpg-creation/rpgCreationRequestHelpers.ts`
当前策略:
1. runtime 读写重试策略不再散落在 `storageService.ts` 内部,而是作为 RPG 创作域专属 runtime client 复用。
2. Agent SSE、POST JSON 请求辅助能力收口到 `rpgCreationRequestHelpers.ts`,避免再把流式解析细节写回通用 service。
## 2.2 五类 rpgCreation client 已持有真实请求实现
以下 client 已不再桥接旧 service而是直接持有真实网络实现
1. `src/services/rpg-creation/rpgCreationAgentClient.ts`
2. `src/services/rpg-creation/rpgCreationWorkClient.ts`
3. `src/services/rpg-creation/rpgCreationLibraryClient.ts`
4. `src/services/rpg-creation/rpgCreationAssetClient.ts`
5. `src/services/rpg-creation/rpgCreationGenerationClient.ts`
本轮已完成的具体收口:
1. Agent session 创建、读取、消息发送、消息流、action 执行、operation 查询、card detail 查询已经正式迁入 `rpgCreationAgentClient.ts`
2. works 列表查询已经正式迁入 `rpgCreationWorkClient.ts`
3. library / publish / unpublish / gallery / gallery detail 已经正式迁入 `rpgCreationLibraryClient.ts`
4. 结果页与编辑器依赖的场景图、场景 NPC、可扮演角色、场景角色、场景生成请求已经正式迁入 `rpgCreationAssetClient.ts`
5. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已进入 RPG 创作域 client。
6. `src/services/rpg-creation/index.ts` 已收口为 RPG 命名导出,创作主链不再从 barrel 暴露 `createCustomWorldAgentSession / listCustomWorldWorks / upsertCustomWorldProfile` 等旧命名入口。
## 2.3 旧 service 兼容导出已删除
追加清理轮已完成以下删除:
1. `src/services/aiService.ts` 不再 re-export RPG 创作 Agent / works / 结果页生成接口,继续只服务 story/chat 等通用 AI 运行时能力。
2. `src/services/storageService.ts` 已物理删除,运行时存档、设置、资料、浏览历史能力已迁入 `src/services/rpg-entry/``src/services/rpg-runtime/`
3. `rpgCreationAgentClient.ts``rpgCreationWorkClient.ts``rpgCreationLibraryClient.ts``rpgCreationAssetClient.ts` 已删除 `CustomWorld*` 兼容具名导出,只保留 `Rpg*` 主命名。
4. 源码扫描已确认不再存在 `createCustomWorldAgentSession / executeCustomWorldAgentAction / listCustomWorldWorks / upsertCustomWorldProfile` 等旧主链函数引用。
## 2.4 主链调用已开始直接使用 RPG 创作域 client
本轮已把以下主链入口切到 `src/services/rpg-creation/`
1. `src/components/rpg-entry/useRpgCreationSessionController.ts`
2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts`
3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
4. `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
5. 新增世界生成入口 `generateRpgWorldProfile()` 通过 `src/services/rpg-creation/` barrel 暴露,后续新代码不必再从旧 `aiService.ts` 进入。
配套收口:
1. 结果页与编辑器相关测试 mock 已改到 `rpgCreationAssetClient`,不再盯住 `aiService.ts` 的兼容层。
2. `CustomWorldResultView.test.tsx``CustomWorldEntityEditorModal.test.tsx` 已改为直接消费 `RpgCreationResultView / RpgCreationEntityEditorModal` 新入口,不再通过旧组件 façade。
## 2.5 本轮验证结果
已完成以下针对性验证:
1. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts src/services/storageService.test.ts src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldResultView.test.tsx src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx`
2. `npm run check:encoding`
验证结果:
1. 上述 5 组定向测试全部通过。
2. 编码检查通过,未写坏中文文件。
追加清理轮已完成以下验证:
1. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
结果:通过,`42` 项测试全部通过。
2. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts`
结果:通过,`2` 项测试全部通过。
3. `npm run check:encoding`
结果:通过,`1929` 个文件编码检查通过。
4. 源码扫描确认 `src / packages / server-node` 中不再存在本轮删除的旧主链函数与旧组件入口符号引用。
## 3. 本次刻意未做的事
以下内容明确留给后续工作包,不在本轮越界处理:
1. 没有改后端 works/library/gallery/agent route 的语义与 contract。
2. 没有拆 `PreGameSelectionFlow.tsx` 内部编排;这部分仍属于工作包 B。
3. 没有继续物理拆散 `RpgCreationEntityEditorShared.tsx`;这部分仍属于工作包 C 后续细拆。
4. 没有强行重命名历史数据结构类型,例如 `CustomWorldProfile` 与 runtime contract response 名称;这些仍是现有契约类型,不等同于旧脚本依赖。
5. 没有删除旧 `src/services/ai.ts` 中的 legacy 世界生成实现;它已不在当前 RPG 创作主链 client 上,后续应按独立 dead code 批次评估。
## 4. 对后续工作包的直接收益
1. 工作包 B 后续拆平台壳层时,可以直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery 请求,不必继续回到旧 service 文件找接口。
2. 工作包 C 后续继续拆结果页和编辑器时,资产生成请求已经有稳定的 RPG 创作域入口。
3. 后续清理 `aiService.ts``storageService.ts` 时,创作链主路径已经完成真实迁出,不会再被“通用 service 同时承载创作域请求”拖住。

View File

@@ -0,0 +1,150 @@
# 创作链路重构工作包 E 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E后端 Agent 编排拆分**,并严格遵守这一轮的写入边界:
1. 只改后端应用服务层,不动前端壳层。
2. 先把 `customWorldAgentOrchestrator.ts` 从“大分支调度 + 派生状态重建 + 结果回写细节”里拆薄。
3. 补齐 action executor 真实落点与 `supportedActions` 主链字段,但不在这一轮顺手重构 session store 和 runtime compiler。
## 2. 本次已落地内容
## 2.1 orchestrator 已退化为应用服务 façade
本轮后,`server-node/src/services/customWorldAgentOrchestrator.ts` 的职责开始收口为:
1. session 级入口方法保留。
2. 创建 operation 记录。
3. 调用 action registry 拿到执行计划。
4. 把消息轮转、foundation 生成、实体生成、角色资产同步等主链事务串起来。
这轮明确移出的内容:
1. `action -> executor` 的分支校验和分发。
2. `sync_result_profile` 的字段回写细节。
3. 多个 action 共用的 draftCards / assetCoverage / suggestedActions / qualityFindings 派生重建逻辑。
## 2.2 已新增 action registry 与 executor 目录,并完成真实执行迁移
已新增:
1. `server-node/src/services/customWorldAgentActionRegistry.ts`
2. `server-node/src/services/customWorldAgentActionExecutors/index.ts`
3. `server-node/src/services/customWorldAgentActionExecutors/types.ts`
本轮收口结果:
1. registry 统一处理 `draft_foundation``update_draft_card``sync_result_profile``generate_characters``generate_landmarks``generate_role_assets``sync_role_assets` 的可用性校验。
2. `publish_world``generate_scene_assets``sync_scene_assets``expand_long_tail``revert_checkpoint` 已完成真实 executor 装配,不再只是 registry 层面的“已声明但未开放”动作。
3. `lock_cards``unlock_cards``regenerate_scope` 仍统一通过 registry 返回禁用原因,不再继续堆在 orchestrator 分支里。
4. `customWorldAgentActionExecutors/` 已补 `draftFoundationExecutor.ts``updateDraftCardExecutor.ts``syncResultProfileExecutor.ts``generateCharactersExecutor.ts``generateLandmarksExecutor.ts``generateRoleAssetsExecutor.ts``syncRoleAssetsExecutor.ts``generateSceneAssetsExecutor.ts``syncSceneAssetsExecutor.ts``expandLongTailExecutor.ts``publishWorldExecutor.ts``revertCheckpointExecutor.ts`,真实 action 执行已从 orchestrator 物理迁入目录。
5. `customWorldAgentActionExecutors/helpers.ts``executorShared.ts` 已收口 action_result / summary message 构造、operation 更新和 session 读取共用逻辑,避免 executor 间重复堆样板代码。
## 2.3 已新增 message turn / suggested action / snapshot / quality gate / result sync service
已新增:
1. `server-node/src/services/customWorldAgentMessageTurnService.ts`
1. `server-node/src/services/customWorldAgentSuggestedActionService.ts`
2. `server-node/src/services/customWorldAgentSnapshotBuilder.ts`
3. `server-node/src/services/customWorldAgentQualityGateService.ts`
4. `server-node/src/services/customWorldAgentResultSyncService.ts`
本轮收口结果:
1. `CustomWorldAgentMessageTurnService` 已接管 session 初始派生状态与 message turn 的真实执行,`customWorldAgentOrchestrator.ts` 只保留 façade 委托。
1. `CustomWorldAgentSuggestedActionService` 统一维护 `foundation_review``object_refining``visual_refining` 的建议动作生成,不再散落在 orchestrator 和 session compatibility。
2. `CustomWorldAgentSnapshotBuilder` 统一承接 message turn、foundation draft、结果页回写、角色/地点追加、角色资产同步后的派生字段重建。
3. `CustomWorldAgentQualityGateService` 已形成独立 finding 入口,当前先输出角色缺失、地点缺失、玩家目标缺失、角色资产待补齐、场景资产待补齐等基础 gate finding。
4. `CustomWorldAgentResultSyncService` 接管了 `sync_result_profile` 的字段回写细节,明确这一轮只允许“摘要 + 资产确认结果 + legacyResultProfile 快照”回写进 draft profile。
## 2.4 `supportedActions` 已接入 session snapshot 主链
这一轮已把 registry 产出的能力矩阵正式装配到 `CustomWorldAgentSessionSnapshot.supportedActions`
1. `createSession``getSessionSnapshot`、stream message 完成态、各 action 完成后的 session 拉取都会返回真实 `supportedActions`
2. `supportedActions` 的启用状态按 session 当前阶段与草稿可用性计算,不再由前端根据 action 字面量自行猜测。
3. 具体 payload 校验仍保留在 action 执行阶段,能力矩阵只表达“当前阶段是否允许发起这类动作”。
## 2.5 action 主链行为保持不变,但派生状态已开始统一
这一轮没有改变现有 action contract也没有新增前端依赖字段但已经把以下重复派生逻辑统一改走 snapshot builder
1. `draft_foundation`
2. `update_draft_card`
3. `sync_result_profile`
4. `generate_characters`
5. `generate_landmarks`
6. `generate_role_assets`
7. `sync_role_assets`
8. message turn 结束后的 stage / suggested actions / quality findings / asset coverage 重建
这意味着:
1. 后续新增 action 时,不必再复制一整段 `draftCards + assetCoverage + suggestedActions + recommendedReplies` patch 拼装代码。
2. `qualityFindings` 已开始成为真实后端派生字段,而不只是 session store 中的空占位。
3. `sync_result_profile` 的边界已经能单独测试和继续收缩。
## 2.6 工作包 E 第三轮已补齐的真实闭环
本轮把工作包 E 前两轮遗留的 5 个动作补成了真实后端闭环:
1. `generate_scene_assets`
已通过 `CustomWorldAgentAssetBridgeService.buildSceneAssetStudioContext()` 打通场景图工坊上下文准备,支持营地与地点单场景进入。
2. `sync_scene_assets`
已通过 `applySceneAssetPublishResult()` 写回营地/地点正式场景图,并同步刷新对应 `sceneChapters[].acts` 的背景图与背景资产 ID。
3. `expand_long_tail`
已接入实体生成服务与 snapshot builder能真实追加长尾角色、地点并把阶段推进到 `long_tail_review`
4. `publish_world`
已改为走 `CustomWorldAgentPublishingService + RpgWorldProfileRepository` 主链,正式把 draft session 编译、写入并发布到作品库。
5. `revert_checkpoint`
已依赖 checkpoint snapshot 元数据与 `restoreCheckpoint()` 主链完成真实回滚,不再只是开放 action 名称。
这一轮同时补齐了 4 个关键收口:
1. 发布链已经统一改走 `CustomWorldAgentPublishingService``customWorldAgentOrchestrator.ts``customWorldAgentActionExecutors/index.ts``publishWorldExecutor.ts``server.ts` 的注入口径已经对齐;作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底。
2. `publish_world` 的 readiness 与正式发布已经收口到同一个服务,`profileId` 固定优先沿用 legacy 结果页 ID否则回退为 `agent-draft-${sessionId}`,避免发布产物继续使用临时时间戳。
3. `buildCheckpointSnapshot()` 已接入 `draft_foundation``update_draft_card``sync_result_profile``generate_characters``generate_landmarks``sync_role_assets``sync_scene_assets``expand_long_tail``publish_world` 等关键 executorcheckpoint 现在保存的是真正可恢复的派生快照,而不是只记一段残缺 patch。
4. `rebuildRoleAssetCoverage()` 已补营地 / 地点正式场景资产 fallback 汇总,并收口为“只有真实正式场景图已存在时才补 standalone summary”这样 `sync_scene_assets` 写回后的 camp/landmark asset coverage 在 snapshot 重建、works 读模型与 checkpoint 回放里都不会丢失,也不会误伤 phase3 自动资产回归。
## 2.7 本轮验证结果
已完成以下验证:
1. `npm --prefix server-node run build`
2. `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts`
本轮重点关注的回归范围:
1. `customWorldAgentActionRegistry.test.ts`
2. `customWorldAgentPhase3.test.ts`
3. `customWorldAgentPhase5.test.ts`
4. `publish_world`
5. `generate_scene_assets / sync_scene_assets`
6. `expand_long_tail`
7. `revert_checkpoint`
验证结果:
1. `server-node` 构建通过。
2. 定向回归通过,共 `208` 项测试全部通过。
3. Phase 3 与 Phase 5 已同时确认通过,说明这轮对 `sceneAssets` fallback summary 的收口没有打坏前序自动资产链。
## 3. 本次刻意未做的事
以下内容明确留给后续工作包或下一轮工作包 E不在本轮越界处理
1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口当前这轮只完成了发布动作本身的后端闭环。
2. 还没有改 `customWorldAgentSessionStore.ts` 内部 compatibility / snapshot 输出结构,这部分仍属于工作包 F。
3. 还没有把 result preview 正式接到 `resultPreview` 主链字段,这部分仍需要和工作包 G / H 协作。
4.`customWorldAgentPublishGateService.ts``customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,但工作包 E 主链已经不再走它们;这一轮没有继续做物理删除与引用清扫,避免越界碰到 Phase 4/Phase 5 之外的兼容入口。
## 4. 对后续工作包的直接收益
1. 工作包 F 可以在不碰 orchestrator 大分支的前提下,继续拆 session/store/repository。
2. 工作包 G 可以直接围绕 `CustomWorldAgentResultSyncService``CustomWorldAgentQualityGateService` 对接服务端 preview compiler 与 publish gate。
3. 工作包 H 可以基于已落地的 `supportedActions`、action registry 和 quality gate 继续推进 preview contract 与 contract tests。
4. 后续继续拆 action executor 时,已经有 `customWorldAgentActionExecutors/` 目录和注册表,不需要再回到 orchestrator 里重新铺路。

View File

@@ -0,0 +1,92 @@
# 创作链路重构工作包 F 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F后端 session/store/repository 拆分**,约束如下:
1. 不改动现有主链接口与行为语义。
2. 保留 `customWorldAgentSessionStore.ts``runtimeRepository.ts``customWorldWorkSummaryService.ts` 作为兼容 façade。
3. 把 session 兼容补齐、session 持久化、profile 持久化、works 读模型组装从大文件中物理拆出。
## 2. 本次已落地内容
## 2.1 session store 内部分层
已新增以下 RPG Agent session 拆分文件:
1. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts`
2. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts`
3. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts`
4. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts`
当前策略:
1. `customWorldAgentSessionStore.ts` 继续保留旧类名和旧方法签名。
2. sessionId 前缀、snapshot 输出结构、operation/checkpoint 写入语义保持兼容。
3. 旧 session 的兼容补齐逻辑集中收口到 `rpgAgentSessionCompatibility.ts`,不再继续堆在 store 主文件里。
4. `customWorldAgentSessionStore.ts` 已正式改为依赖 `RpgAgentSessionRepositoryPort`phase2~5 与 works 集成测试也已切到新的 session 仓储端口。
## 2.2 custom world 仓储从 runtime 大仓储中拆出
已新增以下 RPG 世界仓储文件:
1. `server-node/src/repositories/RpgAgentSessionRepository.ts`
2. `server-node/src/repositories/RpgWorldProfileRepository.ts`
3. `server-node/src/repositories/rpgWorldRepositoryShared.ts`
当前策略:
1. `RuntimeRepositoryPort` 继续保留兼容 façade`context.ts``server.ts``runtimeRoutes.ts`、同步脚本已开始直接注入并使用 `RpgAgentSessionRepository``RpgWorldProfileRepository`
2. `runtimeRepository.ts` 内的 custom world session/profile/gallery SQL 已改成委托新仓储。
3. `runtimeRepository.ts` 继续只保留 runtime 快照、设置、浏览历史、档案等通用能力,以及少量尚未迁走的快照同步编排。
## 2.3 works 读模型拆分
已新增以下 works 读模型相关文件:
1. `server-node/src/services/RpgWorldWorkCoverResolver.ts`
2. `server-node/src/services/RpgWorldWorkSummaryAssembler.ts`
3. `server-node/src/services/RpgWorldWorkSummaryService.ts`
并将:
1. `server-node/src/services/customWorldWorkSummaryService.ts`
退化为兼容入口,仅负责桥接新 `RpgWorldWorkSummaryService`
当前策略:
1. works service 只保留服务入口,不再内嵌标题、摘要、封面、资产覆盖率等全部组装细节。
2. 草稿封面与发布态封面解析统一走 resolver避免后续重复理解封面规则。
3. 草稿态与发布态 work summary 的字段语义保持不变,继续支持“继续创作”和“进入世界”入口判定。
4. `runtimeRoutes.ts` 中的 works/library/gallery 路由已切到 `rpgWorldWorkSummaryService``rpgWorldProfileRepository` 直接注入,不再经由 `runtimeRepository` 中转 custom world 读模型。
## 3. 验证结果
本次已完成以下定向回归:
1. 运行 `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentPhase2.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentPhase5.test.ts src/services/customWorldWorkSummaryService.integration.test.ts`
2. 以上 21 个 custom world / agent / works 相关测试全部通过。
同时确认:
1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 当前仍被仓库里既有的跨模块类型问题阻塞。
2. 这些全量类型错误大多与本工作包无关,因此本轮仍以 custom world 定向测试通过作为主验证口径。
3. 工作包 F 本轮新增的 `RpgWorldWorkSummaryService.ts`、新仓储注入链和测试 helper未在定向回归中引入新的行为回归。
## 4. 当前兼容保留项
以下内容属于阶段性兼容保留,不再视为工作包 F 未完成项:
1. `RuntimeRepositoryPort` 仍保留 custom world 相关兼容方法,避免一次性冲击 story/runtime 其他调用方。
2. `customWorldAgentSessionStore.ts``customWorldWorkSummaryService.ts` 仍保留旧文件名 façade后续统一命名治理时再清理。
3. runtime 快照同步与 custom world profile 自动回写的进一步解耦,仍留待后续围绕 `runtimeRepository.ts` 继续收口。
## 5. 对后续工作包的直接收益
1. 工作包 E 可以在不继续挤压 `customWorldAgentSessionStore.ts` 的情况下,把 orchestrator 的 result sync / snapshot builder 接到更清晰的 session 持久化边界。
2. 工作包 G 后续若需要让 preview compiler / publish gate 落库,不必再继续往 `runtimeRepository.ts` 堆 custom world SQL。
3. 工作包 H 已能直接围绕 `rpg-agent-session-store/``RpgWorldWorkSummaryAssembler.ts``RpgWorldWorkSummaryService.ts` 与新仓储端口补充更细粒度回归,而不必穿透大文件。
4. 后续若继续拆 route 命名或清理旧 façade已有 `context -> server -> runtimeRoutes -> script -> tests` 的新仓储注入链可直接复用。

View File

@@ -0,0 +1,92 @@
# 创作链路重构工作包 G 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G后端 preview compiler 与 runtime profile 目录化**,并把目录化拆分推进到文档目标结构:
1. 先把 `runtimeProfile.ts` 退化成兼容 façade。
2.`runtime-profile/` 真正拆成 `normalize/build/schema/creatorIntentBridge` 等独立模块。
3. 把服务端 result preview compiler 从 foundation draft 流程中抽出独立入口。
4. 不直接改路由层,不直接接前端结果页。
## 2. 本次已落地内容
## 2.1 runtime profile 已完成目录化完整拆分
已完成以下结构调整:
1. 新增 `server-node/src/modules/custom-world/runtime-profile/index.ts` 作为目录入口。
2.`server-node/src/modules/custom-world/runtimeProfile.ts` 已退化为兼容 façade只负责 re-export。
3. `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` 已退化为兼容 façade不再承载主实现。
4. 已新增并落地以下目标模块:
1. `normalizeShared.ts`
2. `normalizeRole.ts`
3. `normalizeLandmark.ts`
4. `normalizeSceneChapter.ts`
5. `normalizeCamp.ts`
6. `buildCompiledProfile.ts`
7. `buildAttributeSchema.ts`
8. `creatorIntentBridge.ts`
当前策略:
1. 先保证旧导入路径不失效,避免放大工作包 G 首轮改动范围。
2. 新代码优先改走 `runtime-profile/` 目录入口。
3. `runtimeProfile.ts``runtimeProfileCompiler.ts` 后续只允许继续收缩,不再接受新增主逻辑。
## 2.2 服务端 preview compiler 已从 foundation draft 流程中抽出
已完成以下收口:
1. `server-node/src/services/RpgWorldPreviewCompiler.ts` 不再只是别名导出,已提供:
1. `buildRpgWorldPreviewProfile()`
2. `normalizeRpgWorldPreviewProfile()`
3. `buildRpgWorldPreviewEnvelope()`
4. `normalizeRpgWorldPreviewEnvelope()`
2. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,把 preview 来源语义显式化。
3. `customWorldAgentFoundationDraftService.ts` 已把 LLM foundation draft 主生成链改成“直接组装 foundation draft + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。
这轮的边界变化是:
1. foundation draft 主字段已经不再依赖“先编 legacy runtime profile再转回 draft”的双重编译。
2. `legacyResultProfile` 仍保留,但只作为结果页兼容快照,不再主导 foundation draft 生成。
3. “服务端 preview 编译入口”继续独立存在,并在 Phase 5 后补上 `rpgCreationPreviewProfileBuilder.ts`,统一承接 preview 与 publish 的兼容合并规则。
4. preview source 已在 Phase 5 后正式收口为 `session_preview`,不再继续沿用兼容期的 `legacy_custom_world_profile` 标记。
## 2.3 已补最小测试与目录化回归验证
本次新增:
1. `server-node/src/services/RpgWorldPreviewCompiler.test.ts`
2. `server-node/src/services/customWorldAgentFoundationDraftService.test.ts`
当前覆盖重点:
1. 验证 preview compiler 可以输出服务端兼容预览 envelope。
2. 验证 envelope 的 `source` 保持为 `session_preview`
3. 验证 preview profile 仍保留 runtime 编译生成的关键字段,例如 `scenarioPackId``campaignPackId`
4. 验证 Phase 5 新增的 preview builder 可以在服务端保留 `legacyResultProfile` 富字段并合并最新草稿资产。
5. 验证 foundation draft service 的 LLM 路径已经直接生成 draft 主字段,不再依赖 preview compiler 反解。
6. 验证 `runtimeProfile.ts` façade 在目录化拆分后仍保持旧调用兼容。
本轮额外验证已通过:
1. `npm run check:encoding`
2. `node --test --test-concurrency=1 --import tsx server-node/src/services/customWorldAgentFoundationDraftService.test.ts server-node/src/modules/custom-world/runtimeProfile.test.ts server-node/src/services/RpgWorldPreviewCompiler.test.ts`
## 3. 本次刻意没有做的事
以下内容仍留给后续阶段:
1. 还没有让 `RpgWorldPreviewCompiler` 输出真正独立于 legacy profile 的 preview view model。
2. 还没有把 `RpgWorldPreviewCompiler` 的 preview 载体从当前 runtime-profile 兼容对象升级成真正独立的 preview view model。
3. `legacyResultProfile` 仍保留为兼容快照,结果页与自动保存链还没有完全脱离 legacy profile 富字段。
4. 还没有删除 `runtimeProfile.ts``runtimeProfileCompiler.ts` 这两个兼容 façade。
## 4. 对后续工作包的直接收益
1. 工作包 E 可以围绕 `RpgWorldPreviewCompiler` 继续补 result sync / snapshot builder 的 preview 接口。
2. 工作包 H 可以基于 `RpgCreationPreviewEnvelope` 继续细化正式 preview contract 和 contract tests。
3. Phase 3 把结果页切到服务端 preview 时,已经有稳定的后端编译入口和目录化 normalize/build 模块,不需要再回头拆 `runtimeProfile.ts` 大文件。

View File

@@ -0,0 +1,137 @@
# 创作链路重构工作包 H 落地记录
更新时间:`2026-04-21`
## 1. 本次目标
本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H共享契约与测试基建**,约束如下:
1. 把 RPG 创作域共享契约从“类型别名骨架”推进到“真实定义 + 兼容出口”。
2. 补齐可复用的 fixture避免前后端测试继续各自复制一套假数据。
3. 补齐 unit / contract / integration / regression 最小闭环,不越界重构 UI、路由和仓储主逻辑。
## 2. 本次已落地内容
## 2.1 共享契约已完成物理拆分与兼容收口
本轮已把以下文件从工作包 A 的骨架态推进为真实定义:
1. `packages/shared/src/contracts/rpgAgentAnchors.ts`
2. `packages/shared/src/contracts/rpgAgentDraft.ts`
3. `packages/shared/src/contracts/rpgAgentActions.ts`
4. `packages/shared/src/contracts/rpgAgentSession.ts`
5. `packages/shared/src/contracts/rpgCreationPreview.ts`
6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts`
本轮收口重点:
1. `rpgAgent*``rpgCreation*` 文件不再只是从旧 `customWorldAgent.ts` 做类型别名转发,而是承载真实契约定义。
2. `rpgAgentSession.ts` 已显式加入 `supportedActions?``resultPreview?` 可选字段,为工作包 E/G 后续正式接入 registry 与服务端 preview compiler 预留稳定契约入口。
3. `rpgCreationPreview.ts` 已补 `source / generatedAt / qualityFindings / blockers`,把“预览载体”和“预览来源/质量门槛”拆开。
4. `rpgCreationWorkSummary.ts` 已收口 works 列表稳定字段,明确 `canResume / canEnterWorld` 的读模型语义。
## 2.2 旧 `customWorld*` 契约已补齐兼容分文件
本轮没有直接删除旧入口,而是把旧命名收口成“聚合出口 + 分文件兼容层”:
1. 当前旧 `customWorldAgent.ts` 不再承载主定义,而是统一聚合:
- `customWorldAgentAnchors.ts`
- `customWorldAgentDraft.ts`
- `customWorldAgentActions.ts`
- `customWorldAgentSession.ts`
- `customWorldResultPreview.ts`
- `customWorldWorkSummary.ts`
2. 现有前后端直接导入 `customWorldAgent.ts` 的代码不需要在本轮一起大改,避免把工作包 H 扩成全仓导入迁移。
3. 后续工作包可以逐步把新代码改到 `rpgAgent* / rpgCreation*` 路径;如果暂时仍需旧命名,也可以先切到更细的兼容分文件,而不是继续依赖单一大聚合文件。
## 2.3 已补共享 fixture总线样本开始统一
本轮新增:
1. `packages/shared/src/contracts/rpgCreationFixtures.ts`
当前已提供并复用的样本包括:
1. 八锚点 fixture
2. foundation draft fixture
3. session snapshot fixture
4. preview envelope fixture
5. published profile fixture
6. library entry fixture
7. works response fixture
这些样本的作用是:
1. 前端 contract test、后端 integration test、后续 preview/compiler 回归可以共用同一批样本。
2. 避免继续在各测试文件里手写不一致的 session/profile/works 假数据。
3. 把工作包 H 文档中要求的“最小 eight-anchor / preview / published profile / works 样本”先落成统一入口。
## 2.4 已补 unit / contract / integration / regression 最小闭环
本轮新增测试:
1. `packages/shared/src/contracts/rpgContracts.test.ts`
2. `server-node/src/services/customWorldWorkSummaryService.integration.test.ts`
3. `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts`
4. `server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`
5. `server-node/src/services/customWorldAgentActionRegistry.test.ts`
6. `server-node/src/services/customWorldAgentResultSyncService.test.ts`
同时补充:
1. `vitest.config.ts` 已把 `packages/shared/src/**/*.test.ts` 纳入前端 Vitest 测试入口。
2. shared contract test 当前覆盖:
- session fixture、preview fixture、published profile fixture、works/library fixture 对齐关系
- `supportedActions` 能力矩阵样本
- 旧命名兼容分文件的类型消费
- 角色动作资产、分幕背景、works 门槛字段不会在 fixture 演进时悄悄回退
3. server unit / regression test 当前覆盖:
- preview compiler 可以直接消费 shared fixture
- works assembler 输出与 shared works fixture 保持一致
- 角色主图、动作集、分幕背景资产字段在 normalize / assemble 后仍能保留
- action registry 的 capability enable/disable 与 payload validate/normalize
- result sync service 只回写摘要与匹配资产,不让 runtime-only 结构反向污染 foundation draft
4. server integration test 当前验证共享 fixture 可以被 `customWorldWorkSummaryService` 正常消费,并输出和共享 works 响应样本一致的草稿/发布条目。
## 2.5 根导出已补齐
本轮已把:
1. `packages/shared/src/contracts/rpgCreationFixtures.ts`
2. `packages/shared/src/contracts/customWorldAgent.ts`
接入:
1. `packages/shared/src/index.ts`
这样后续前端和后端若要消费共享 fixture 或新契约,不需要再回退到旧单文件入口。
## 3. 本次验证结果
已完成以下定向验证:
1. `npm run test -- packages/shared/src/contracts/rpgContracts.test.ts`
2. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentResultSyncService.test.ts src/services/customWorldWorkSummaryService.integration.test.ts src/services/RpgWorldPreviewCompiler.fixture.test.ts src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`
3. `npm run check:encoding`
验证重点:
1. shared 契约样本可直接通过 Vitest 执行。
2. preview compiler、works assembler、works service 三层都可以直接消费 shared fixture不需要额外复制一套测试数据。
3. 中文文档与代码文件经过编码检查,没有把文本写坏。
## 4. 本次刻意未做的事
以下内容明确留给后续工作包或下一轮继续推进:
1. 还没有把仓库里所有 `customWorldAgent.ts` 旧导入物理迁成 `rpgAgent* / rpgCreation*` 新导入。
2. 还没有让后端 session snapshot 真正填充 `supportedActions`
3. 还没有让服务端 preview compiler 真正把 `resultPreview` 写入主链 snapshot。
4. 没有改 UI、路由、数据库仓储或 orchestrator 主逻辑,严格控制在 shared contracts 与测试基建写入边界内。
## 5. 对后续工作包的直接收益
1. 工作包 E 可以直接复用 `supportedActions` 契约入口,把 action registry 的真实能力矩阵接进 session snapshot。
2. 工作包 G 可以直接复用 `resultPreview``RpgCreationPreviewEnvelope`,继续把服务端 preview compiler 接回主链。
3. 后续前后端测试都可以从 shared fixture 取样本,不需要继续维护多套彼此漂移的 session/profile 假数据。
4. 旧命名导入可以先切到兼容分文件,再逐步替换到 `rpg*` 新契约,迁移路径更平滑。

View File

@@ -0,0 +1,63 @@
# 创作中心作品卡操作入口落地说明
日期:`2026-04-22`
## 1. 本次目标
创作中心作品卡需要补齐两个直接操作入口:
1. **体验**:对已经满足运行条件的作品,直接从卡片启动对应玩法,不再必须先进详情页。
2. **删除**:对已有正式删除契约的 RPG 已发布作品,直接从卡片删除并刷新创作中心。
## 2. 操作语义
| 作品类型 | 状态 | 主按钮 | 体验入口 | 删除入口 |
| --- | --- | --- | --- | --- |
| RPG Agent 草稿 | `draft` | `继续创作` / `继续完善` | 不展示,草稿需要先走发布链 | 不展示,本轮不新增 Agent session 物理删除 |
| RPG 已发布作品 | `published``canEnterWorld=true` | `查看详情` | 展示 `体验`,直接调用现有进入世界链 | 展示 `删除`,走 owner-only 软删除 |
| Big Fish 草稿 | `draft` | `继续创作` | 不展示,草稿需要先回到聊天或结果页继续完善 | 不展示,本轮不新增 Big Fish 草稿删除 |
| Big Fish 已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用现有 Big Fish 运行态 | 不展示,本轮不新增 Big Fish 删除契约 |
| 拼图草稿 | `draft` | `继续创作` | 不展示 | 不展示,本轮不新增拼图删除契约 |
| 拼图已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用 `startPuzzleRun` | 不展示,本轮不新增拼图删除契约 |
## 3. 后端边界
RPG 删除必须继续遵守后端治理里的软删除规则:
1. `custom_world_profile` 增加 `deleted_at` 语义字段。
2. 删除时不物理删除 profile只设置 `deleted_at`、把发布态回退为 `draft`、清空 `published_at`,并删除公开 gallery projection。
3. `library / gallery detail / works` 读取默认过滤 `deleted_at != null` 的作品。
4. 重复删除同一 profile 保持幂等,返回当前可见作品列表。
## 4. 前端边界
1. 卡片只做表现和动作分发,不在前端拼删除逻辑。
2. 删除前使用浏览器确认,避免移动端误触。
3. 卡片按钮移动端优先换行铺开,避免小屏幕上三个按钮拥挤。
4. 不在 UI 中默认展示大段规则说明,失败信息沿用创作中心现有错误 banner。
## 5. 本轮不做
1. 不新增 Agent session 草稿删除。
2. 不新增拼图作品删除。
3. 不新增独立删除面板。
4. 不新建创作页或运行时页面,只复用现有 `CustomWorldCreationHub`、RPG 进入世界链和拼图运行时链。
5. Big Fish 草稿恢复链补齐时,只补创作中心 works 投影和恢复入口,不新建独立 Big Fish 作品系统。
## 6. 已落地结果
1. 创作中心 RPG 已发布作品卡主按钮统一调整为 `查看详情`,避免和直接进入玩法的动作混淆。
2. RPG 与拼图已发布作品卡新增独立 `体验` 入口,直接复用各自现有运行时进入链路。
3. RPG 已发布作品卡新增 `删除` 入口,调用 `/api/runtime/custom-world-library/{profile_id}``DELETE` 路由,按 owner-only 软删除规则刷新作品列表与公开广场。
4. 创作中心详情页原有删除链路继续保留,和卡片删除共用同一后端删除契约。
5. 后续拼图草稿恢复链补齐后,拼图 `draft` 卡主按钮语义收口为 `继续创作`,通过 `sourceSessionId` 恢复 Agent session而不是进入详情页。
6. 后续 Big Fish 草稿恢复链补齐后Big Fish `draft` 卡主按钮同样收口为 `继续创作`,通过 `sourceSessionId` 恢复 Agent session而不是重新创建会话。
## 7. 已验证
1. `corepack pnpm vitest run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
2. 交互测试已覆盖:
- 创作卡点击 `查看详情` 进入详情页。
- 创作卡点击 `体验` 直接进入世界选择链路。
- 创作卡点击 `删除` 直接从作品列表移除。
- 详情页删除入口在新卡片动作语义下仍然可用。

View File

@@ -0,0 +1,26 @@
# 创作中心退出登录私有缓存清理修复 2026-04-25
## 问题
点击退出登录后,页面未刷新时仍能切到创作中心,并看到上一位登录用户的作品。刷新页面后才恢复正常。
## 根因
1. `AuthGate` 的退出动作先等待 `/api/auth/logout` 完成,再通过全局鉴权事件重新 hydrate期间前端 context 仍可能暴露旧用户。
2. 平台创作入口里的 RPG works 会在 `canReadProtectedData=false` 时清空,但大鱼吃小鱼与拼图 works 是 `PlatformEntryFlowShellImpl` 内部 state没有在退出登录时同步清空。
3. 创作 Tab 会保持挂载以降低闪烁,因此私有作品数组只要留在内存里,就会继续被货架组件渲染。
## 修复口径
1. 用户触发退出当前设备或退出全部设备时,前端必须先本地收回 `user / canAccessProtectedData`,再等待后端吊销会话。
2. `canReadProtectedData``true` 变为未登录态 `false` 时,创作中心必须清空所有私有作品缓存:
- RPG works / library 由 `useRpgEntryBootstrap` 清空。
- Big Fish works、Puzzle works 由 `PlatformEntryFlowShellImpl` 清空。
- 当前创作工作区、结果页、删除忙碌态与生成态一并复位。
3. 公开广场与分类数据不受影响,仍按匿名公开接口读取。
## 验收
1. 点击退出登录后,不刷新页面进入创作 Tab只能看到空作品货架不再出现上一账号作品。
2. 退出登录瞬间 `AuthUiContext.user``null``canAccessProtectedData=false`
3. 重新登录后按新账号重新拉取作品列表,不复用旧账号内存缓存。

View File

@@ -0,0 +1,36 @@
# 创作页移动端 UI 修复记录
日期:`2026-04-21`
## 问题定位
本轮修复只处理创作页表现层,不新增创作流程。
当前移动端问题主要来自三处:
1. 平台页在 `platformTab === 'create'` 时直接渲染 `CustomWorldCreationHub`,绕过了 `PlatformHomeView` 的移动端外壳,导致底部 Tab 栏没有挂载。
2. 创作中心内部仍混用 `pixel-*` 九宫格样式、`bg-black/*``text-white``border-white/*` 等暗色 Tailwind 类,亮色主题下会出现深色块和低对比文字。
3. 创作中心根节点自带 `h-full overflow-y-auto`,放回平台页后容易与平台页主滚动区抢滚动权,手机上会显得布局混乱。
## 落地约束
1. 创作页仍复用现有平台首页,不新增页面和新系统。
2. 移动端底部 Tab 必须始终由 `PlatformHomeView` 统一渲染,创作页只作为 `create` Tab 的内容。
3. 创作中心内部不再使用深色硬编码作为默认底色,普通卡片、筛选 Tab、空状态和按钮统一使用 `platform-*` token。
4. 创作中心不再自建整页滚动,只把内容交给平台页主滚动区,避免嵌套滚动。
5. UI 中不增加规则说明类文案,只保留必要入口、状态和作品信息。
## 编码方案
1.`PlatformHomeView` 增加可选的 `createTabContent`,让当前 Agent 创作中心接回平台页统一外壳。
2. `PreGameSelectionFlow` 不再在 `platformTab === 'create'` 时绕过 `PlatformHomeView`,而是把 `CustomWorldCreationHub` 作为创作 Tab 内容传入。
3. `CustomWorldCreationHub` 改为无内部整页滚动的内容容器,标题、返回、计数、错误、加载骨架都使用平台 token。
4. `CustomWorldCreationStartCard``CustomWorldWorkCard` 从像素暗色面板切换为平台卡片样式,保留游戏化主视觉但跟随亮暗主题。
5. `CustomWorldWorkTabs` 改用 `platform-tab`,并保持横向滚动与清晰选中态。
## 验收要点
1. 手机宽度下进入“创作”后,底部“首页 / 创作 / 存档 / 我的”Tab 始终可见。
2. 亮色主题下创作页默认卡片不出现大面积黑色底板。
3. 创作页只有平台页主内容区滚动,底部 Tab 不随作品列表滚走。
4. 桌面端仍可通过左侧平台导航进入创作页。

View File

@@ -0,0 +1,32 @@
# 创作页场景世界地图面板修复设计2026-04-25
## 背景
创作结果页进入“场景”编辑面板后,底部“查看世界地图”弹出的面板存在两个问题:
1. 面板仍使用偏运行时的深色地图容器,放在浅色创作页主题下时配色割裂,节点文字与背景层次也不稳定。
2. 地图只按传入的地标列表渲染,普通场景编辑时容易漏掉开局场景,无法形成完整“世界地图”视角。
## 落地范围
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- `src/components/CustomWorldEntityEditorModal.test.tsx`
## 设计约束
1. 不新增说明类大段 UI 文案,只保留必要的节点名、方向标签和空状态。
2. 地图面板继续作为独立弹窗,不在当前场景连接面板下方展开。
3. 地图数据必须使用当前编辑中的草稿状态:
- 普通场景编辑:开局场景 + 已保存场景列表,并用当前 `draft` 替换正在编辑的场景。
- 新增普通场景:开局场景 + 已保存场景列表 + 当前 `draft`
- 开局场景编辑:当前 `draft` 开局场景 + 已保存场景列表。
4. 地图节点要标记当前编辑场景,连接线要展示方向短标签,避免用户只能看到无语义的线。
5. 配色使用 `platform-*` 主题变量,适配浅色与深色创作页主题。
## 验收点
1. 在普通场景编辑器点击“查看世界地图”后,弹窗中能同时看到开局场景和当前场景。
2. 未保存的场景连接关系会立刻体现在地图弹窗里。
3. 当前编辑场景节点有明确高亮。
4. 地图容器和节点不再固定为深色运行时风格。
5. 相关前端测试覆盖普通场景与开局场景两条入口。

View File

@@ -0,0 +1,46 @@
# 作品货架统一 2026-04-25
## 背景
创作中心目前已经把 RPG、大鱼吃小鱼、拼图三类作品展示在同一个网格里但前端组件仍直接消费三类原始 works
1. RPG 使用 `status``title``subtitle``canEnterWorld`
2. 大鱼使用 `status``title`、资源完成度字段。
3. 拼图使用 `publicationStatus``levelName``authorDisplayName``themeTags`
这导致筛选、计数、按钮文案、卡片标题、副标题、标签、删除忙碌态都在 UI 组件里做三套判断。后续再接新作品类型时,货架组件会继续膨胀。
## 目标
1. 新增前端统一作品货架视图模型 `CreationWorkShelfItem`
2. 由归一化函数把 RPG / Big Fish / Puzzle works 映射成统一字段。
3. `CustomWorldCreationHub` 只负责筛选、空态和动作分发。
4. `CustomWorldWorkCard` 只负责渲染统一字段,不再理解三类原始 schema。
## 非目标
1. 本轮不新增后端统一 works 聚合接口。
2. 不改变三类现有 works API contract。
3. 不改变平台首页公开广场的 gallery 合并逻辑。
4. 不改变删除、体验、恢复草稿的业务规则。
## 统一字段
`CreationWorkShelfItem` 至少包含:
1. `id`:稳定货架 id。
2. `kind``rpg | big-fish | puzzle`
3. `status``draft | published`
4. `title / subtitle / summary / updatedAt`
5. `coverImageSrc / coverRenderMode / coverCharacterImageSrcs`
6. `badges`:状态、类型、阶段、标签等展示徽标。
7. `metrics`:角色数、地点数、素材完成度、游玩数等底部指标。
8. `openActionLabel`:卡片无障碍文案与主动作语义。
9. `source`:保留原始 work用于平台壳层执行动作。
## 验收
1. 创作中心三类作品仍在同一个网格展示。
2. 草稿 / 已发布筛选计数统一从 `CreationWorkShelfItem.status` 读取。
3. 卡片渲染不再直接判断 `publicationStatus` 或不同 works schema 的标题字段。
4. 现有创作中心交互测试通过。

View File

@@ -0,0 +1,115 @@
# Agent 创作流四阶段收口检查与旧链清理边界
更新时间:`2026-04-21`
补充修正:`2026-04-21` 本文档的“草稿恢复优先回 Agent 工作区”和“Agent 来源结果页冻结为预览收口层”属于阶段性收口口径,已被 [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md) 覆盖。当前主口径是Agent 对话框只收集八锚点,已有底稿的草稿从创作中心进入结果页继续完善。
## 1. 结论先行
当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。
阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链审计闭环。
因此这轮可以执行的清理现在有两类:
1. 删除已经不再从当前主入口可达的旧 `custom-world/sessions` 世界生成链
2. 删除已经完全脱离 `CustomWorldAgentWorkspace` 主链、只剩孤立互相引用与自测覆盖的 `custom-world-agent` 旧面板
3. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力
这轮不做:
1. 不删 `Agent session` 的底层持久化能力
2. 不删已保存作品结果页的 legacy 编辑器兼容能力
3. 不删 `custom-world/works` 聚合入口
---
## 2. 阶段完成度
### 2.1 阶段一
已完成。
证据:
1. 结果页新增了 `sync_result_profile`
2. 结果页编辑后的快照可以回写到 `Agent session`
3. 自动保存、返回创作、进入世界都优先走 session 主链
### 2.2 阶段二
已完成。
证据:
1. 平台创作入口已切到 `custom-world/works`
2. 草稿恢复优先回 Agent 工作区
3. Agent 结果页不再继续新增旧编辑入口
### 2.3 阶段三
已完成。
证据:
1. 创作中心不再把 library draft 当主草稿入口
2. Agent 来源结果页冻结为预览收口层
3. 重复同步动作已收敛为有差异才执行
### 2.4 阶段四
未完全完成。
原因:
1. 文档清理已经开始,但还没有完整收束到单一结论文档
2.`custom-world/sessions` 生成链已经完成物理清理,但与之相关的审计/PRD/知识图谱文档仍需继续统一口径
3. `custom-world-agent` 孤岛面板已经完成第二轮物理清理,但阶段四文档总收口仍未完全覆盖所有历史 PRD 口径
---
## 3. 本轮允许删除的旧链
允许删除:
1. `src/services/aiService.ts` 里的旧 `custom-world/sessions` 请求函数
2. `server-node/src/routes/runtimeRoutes.ts` 里的旧 `custom-world/sessions` 路由
3. `server-node/src/services/customWorldGenerationService.ts`
4. 与这条旧链对应的测试
5. `server-node/src/services/customWorldSessionStore.ts`
6. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx`
7. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx`
8. `src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx`
9. `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx`
10. `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx`
11. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
12. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
13. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
14. `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx`
15. 仅为上述孤岛面板存在的对应测试文件
不允许删除:
1. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力
2. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装
3. 已保存作品结果页仍在使用的 legacy 编辑器兼容能力
4. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 及其仍在主链上的 5 个子模块:
- `CustomWorldAgentHeader`
- `EightAnchorProgressBar`
- `CustomWorldAgentOperationBanner`
- `CustomWorldAgentThread`
- `CustomWorldAgentComposer`
---
## 4. 删除完成后的判断标准
如果旧链清理成功,应满足:
1. `src/services/aiService.ts` 不再暴露旧 `custom-world/sessions` 请求函数
2. `server-node/src/routes/runtimeRoutes.ts` 不再挂旧 session 路由
3. `server-node/src/services/customWorldSessionStore.ts``server-node/src/services/customWorldGenerationService.ts` 已物理删除
4. 仓库里不再有主流程可达的旧世界生成入口
5. `CustomWorldAgentWorkspace.tsx` 只保留当前正式主链需要的 5 个子模块
6. 与旧 Agent 草稿面板相关的孤岛 UI 与自测不再继续占据正式目录注意力
7. Agent 主链与已保存作品编辑链仍然可用

View File

@@ -0,0 +1,33 @@
# 当前后端实现基线2026-04-25
## 1. 当前唯一落地口径
后续正式后端实现统一以 `server-rs` 为准:
- HTTP 门面Rust `api-server` / Axum。
- 实时状态与业务真相:`crates/spacetime-module` / SpacetimeDB。
- 共享领域与契约:`server-rs` 多 crate 分层维护。
- 前端职责:只做表现、输入采集、临时 UI 状态与服务端结果渲染。
涉及 SpacetimeDB 的表、reducer、绑定生成、发布、本地联调必须按仓库内 SpacetimeDB skills 执行。
## 2. 已替代的旧方向
以下旧方向不再作为新功能设计和编码依据:
- `server-node` / Express / PostgreSQL 正式后端路线。
- Go 服务端试验路线。
- 浏览器侧承担正式运行时逻辑、正式生成编排或正式数据真相的路线。
旧实现只允许作为迁移参考:可以阅读其 contract、提示词、测试用例和边界经验但不得为了兼容旧服务端继续扩展新代码。
## 3. 新文档落点
后续补充后端方案时优先落到这些文档族:
- Rust / SpacetimeDB 架构与切流:`SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md``BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md``M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md`
- SpacetimeDB 模块拆分:`SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md`
- Rust API 路由索引:`RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md`
- 本地与远端部署:`RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md``JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md`
如果旧文档与本基线冲突,以本基线和更新日期更近的 Rust / SpacetimeDB 文档为准。

View File

@@ -0,0 +1,113 @@
# Custom World Agent 删除作品 / 新增 NPC / 新增场景 Rust 迁移记录
日期:`2026-04-24`
## 范围
本次继续检查 RPG 创作 Agent 从旧 `server-node` 迁到 `server-rs` 后的功能缺口,重点覆盖:
1. 删除作品。
2. 新增 NPC`generate_characters`)。
3. 新增场景 / 地点(`generate_landmarks`)。
## 结论
### 删除作品
Rust 链路已经存在:
```text
DELETE /api/runtime/custom-world/library/:profileId
-> api-server.delete_custom_world_library_profile
-> spacetime-client.delete_custom_world_profile
-> SpacetimeDB delete_custom_world_profile_and_return
-> owner-only 软删除 profile并从 gallery 读模型移除
```
本次未改删除作品实现,只确认它已走 Rust + SpacetimeDB不再依赖 Node。
### 新增 NPC / 新增场景
迁移前,`spacetime-module``generate_characters``generate_landmarks` 只走 `execute_placeholder_custom_world_action(...)`,不会真的调用 AI也不会更新 `draftProfile` / draft card。
本次迁移后链路变为:
```text
前端 action
-> api-server execute_custom_world_agent_action
-> api-server 读取 session snapshot
-> platform-llm 使用旧 Node prompt 生成 JSON 数组
-> payload 注入 generatedCharacters / generatedLandmarks
-> spacetime-client.execute_custom_world_agent_action
-> SpacetimeDB 更新 draftProfile
-> SpacetimeDB upsert 对应 draft card
-> SpacetimeDB 更新 publishGate / resultPreview / checkpoint / operation / action result message
```
## Node 对齐点
新增 NPC 保留旧 Node 的 system prompt 与 user prompt 字段约束:
```text
name, role, publicMask, hiddenHook, relationToPlayer, summary, threadIds
```
新增场景保留旧 Node 的 system prompt 与 user prompt 字段约束:
```text
name, purpose, mood, secret, summary, threadIds, characterIds
```
Rust 侧只做最小归一化:补 `id`、去除重名、限制数量 `1..=3`,不改写提示词原文语义。
## 落库设计
1. `generate_characters` 默认追加到 `draftProfile.storyNpcs`
2. `generate_landmarks` 追加到 `draftProfile.landmarks`
3. 每个新增对象都会生成 / 更新一张 `custom_world_draft_card`
4. 操作完成后同步更新:
- `last_assistant_reply`
- `publish_gate_json`
- `result_preview_json`
- `checkpoints_json`
- `custom_world_agent_operation`
- `custom_world_agent_message`
## 验证
已运行:
```bash
cargo test -p api-server custom_world_agent_entities --no-default-features
cargo check -p spacetime-module
```
结果:通过。`spacetime-module` 仅保留仓库既有 glob re-export warning。
## 2026-04-24 追加:结果页删除与资产动作闭环
本次继续补齐除长尾补全外的结果页可见动作:
1. `delete_characters` / `delete_landmarks` 已由 Rust SpacetimeDB reducer 直接更新 `draft_profile_json``result_preview_json``publish_gate_json``checkpoints_json``custom_world_draft_card``custom_world_agent_operation``custom_world_agent_message`
2. `generate_characters` 增加 `roleType`,可扮演角色写入 `playableNpcs`,场景角色写入 `storyNpcs`,不再把可扮演角色落到场景角色列表。
3. `generate_role_assets` / `generate_scene_assets` 不再走占位动作Rust 会校验目标对象、切到 `visual_refining`、设置 `focus_card_id`,并记录 operation/message。
4. `sync_role_assets` / `sync_scene_assets` 已迁移 Node 的 profile 字段写回逻辑:角色写回 `imageSrc``generatedVisualAssetId``generatedAnimationSetId``animationMap`;场景写回 `imageSrc``generatedSceneAssetId``generatedScenePrompt``generatedSceneModel`,并同步 `sceneChapters.acts` 背景字段。
5. 前端结果页角色卡展示“生成资产”,场景卡展示“生成场景图”,均通过 `autosaveCoordinator.executeAgentActionAndWait` 调 Rust API 并用最新 session 重建预览。
本轮仍不迁移 `expand_long_tail`,保持后续单独设计。
## 2026-04-24 追加:创作 Tab 删除作品入口
用户在 `http://127.0.0.1:3000/` 的“创作”Tab 看不到删除作品入口,原因是 `RpgEntryHomeView``CreationLibraryCard` 只支持整卡打开详情,没有接收删除回调。已补齐:
1. `CreationLibraryCard` 右上角展示“删除”按钮,点击时阻止整卡打开详情。
2. `RpgEntryHomeView` 新增 `onDeleteLibraryEntry``deletingLibraryEntryId` props。
3. `PlatformEntryFlowShellImpl` 复用 `deleteRpgEntryWorldProfile`,删除后刷新我的作品列表与公开广场。
链路保持为:前端创作 Tab -> `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除 profile / 移除 gallery 读模型。
## 2026-04-24 追加:创作 Hub 草稿删除入口修正
截图中的“创作”Tab 实际渲染的是 `CustomWorldCreationHub` / `CustomWorldWorkCard`,不是默认 `RpgEntryHomeView``CreationLibraryCard`。此前 Hub 只给 `status=published` 的 RPG 作品传入删除回调,导致草稿卡片没有“删除”按钮。
修正后:只要 RPG 创作条目存在 `profileId`,无论 `draft` 还是 `published`,都会在卡片底部动作区展示“删除”。删除继续复用 `PlatformEntryFlowShellImpl.handleDeletePublishedWork`,走 `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除。

View File

@@ -0,0 +1,23 @@
# 世界共创聊天最终回复时机调整2026-04-24
## 背景
创作聊天页顶部进度条来自后端会话快照 `progressPercent`,而助手文本此前通过 SSE `reply_delta` 在模型生成过程中提前展示。这样会导致玩家先看到完整或接近完整的文本回复,但进度、锚点、阶段与推荐操作仍停留在上一轮状态。
## 本次约束
- 玩家可继续看到自己的输入被乐观插入聊天线程。
- 助手回复不再通过中途 `reply_delta` 展示。
- 本轮模型输出解析、SpacetimeDB finalize、最终 session 快照读取全部完成后,助手回复才随最终 session 一次性显示。
- 进度条、阶段、锚点内容、推荐动作和助手回复在同一次 session 替换中同步刷新。
## 落地方案
1. `server-rs/crates/api-server/src/custom_world.rs``stream_custom_world_agent_message` 保留 SSE 响应格式,但不再发送 `reply_delta` 事件。
2. 同一接口仍等待 `run_custom_world_agent_turn` 完成,再调用 `finalize_custom_world_agent_message` 写入 SpacetimeDB。
3. finalize 成功后读取最终 session并通过 `session` 事件一次性返回给前端。
4. `src/components/creation-agent/CreationAgentWorkspace.tsx` 仅在确实存在流式文本时显示临时助手气泡,避免无 `reply_delta` 时出现空回复。
## 预期体验
发送消息后,玩家会先看到自己的消息和忙碌状态;助手文本会在进度、阶段与会话数据全部更新后一次性出现,避免“回复已经到了但进度还没动”的错位感。

View File

@@ -0,0 +1,195 @@
# Custom World Agent 大模型对话恢复设计
日期:`2026-04-22`
## 1. 背景
当前 Rust `server-rs` 里的 Custom World Agent 聊天链路已经接上了会话、消息、operation 与 SSE 外壳,但**并没有真正调用大模型生成聊天回复**。
现状问题:
1. `submit_custom_world_agent_message``spacetime-module` 中直接写死 assistant 回复。
2. `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` 只是把最后一条 assistant 文案一次性回放给前端。
3.`server-node` 已经实现过完整的大模型单轮推理链,包括:
- 动态状态识别
- 原样提示词拼装
- 流式 `replyText` 截取
- 回合结束后的 anchor / creator intent / readiness / clarification / suggested action 派生
用户本轮要求是:
1. 恢复 Agent 聊天对话使用大模型推理生成回复。
2. 把之前 Node 的提示词和后台流程恢复到 Rust 后端。
3. **禁止修改提示词正文。**
## 2. 目标
本轮只恢复 Custom World Agent 聊天主链的真实推理闭环:
1. 用户发消息后assistant 回复必须来自大模型。
2. SSE `reply_delta` 必须来自真实流式推理增量。
3. 回合结束后要把 session 派生状态一次性回写到 SpacetimeDB。
4. 旧 Node `eightAnchorPrompts.ts` 的提示词正文保持原样,不改中文文案。
## 3. 约束
### 3.1 SpacetimeDB 约束
SpacetimeDB reducer / procedure 必须保持确定性,因此:
1. 禁止在 `spacetime-module` 内直接发起 LLM 网络请求。
2. LLM 调用必须放在 `api-server`
3. `spacetime-module` 只负责提交消息、记录 operation、回写最终结果。
### 3.2 提示词冻结约束
本轮严格复用旧 Node 的以下提示词内容:
1. `server-node/src/prompts/eightAnchorPrompts.ts`
2. `BASE_SYSTEM_PROMPT`
3. `GLOBAL_HARD_RULES`
4. `MODE_RULES`
5. `USER_SIGNAL_RULES`
6. `QUICK_FILL_EXTRA_RULES`
7. `STATE_INFERENCE_SYSTEM_PROMPT`
8. `STATE_INFERENCE_OUTPUT_CONTRACT`
9. `OUTPUT_CONTRACT_REMINDER`
允许做的只有:
1. Rust 字符串字面量搬运
2. Rust 函数式重组
3. Rust/Serde 语法等价改写
不允许:
1. 修改提示词正文
2. 调整规则措辞
3. 替换成新的 prompt 版本
## 4. 目标链路
恢复后的消息链路改成两阶段:
### 4.1 阶段 A提交消息
`submit_custom_world_agent_message`
职责:
1. 校验 session / message / operation id。
2. 只写入 user message。
3. 创建 `process_message` operation。
4. operation 初始状态写为 `running`
5. 不直接写 assistant message。
6. 不直接推进 progress / stage / current_turn。
### 4.2 阶段 B完成单轮推理
`finalize_custom_world_agent_message_turn`
职责:
1. 校验 session 与 operation 所属关系。
2. 追加 assistant message。
3. 回写 session 聚合字段:
- `current_turn`
- `progress_percent`
- `stage`
- `focus_card_id`
- `anchor_content_json`
- `creator_intent_json`
- `creator_intent_readiness_json`
- `anchor_pack_json`
- `draft_profile_json`
- `last_assistant_reply`
- `pending_clarifications_json`
- `suggested_actions_json`
- `recommended_replies_json`
- `quality_findings_json`
- `asset_coverage_json`
3. 更新对应 operation 为 `completed``failed`
## 5. `api-server` 责任
`api-server` 新增 Custom World Agent turn service负责
1. 读取当前 session 快照。
2. 按旧 Node 逻辑构造 chat history。
3. 先走“动态状态识别”推理。
4. 再走“正式单轮输出”推理。
5. 流式阶段从 JSON 片段里增量截取 `replyText`,持续往 SSE 发 `reply_delta`
6. 回合结束后派生:
- creator intent
- readiness
- pending clarifications
- suggested actions
- anchor pack
- draft profile
- quality findings
- asset coverage
7. 最后调用 SpacetimeDB finalize procedure 回写真相。
## 6. 旧 Node 逻辑恢复范围
本轮恢复以下旧 Node 行为:
1. `eightAnchorPrompts.ts`
2. `eightAnchorSingleTurnService.ts`
3. `customWorldAgentMessageTurnService.ts`
4. `customWorldAgentClarificationService.ts`
5. `customWorldAgentIntentExtractionService.ts`
6. `customWorldAgentSuggestedActionService.ts`
7. `eightAnchorCompatibilityService.ts` 中聊天链需要的 anchor / progress 派生
本轮不强制一比一恢复整条结果页重编译链,只恢复聊天链真正依赖的最小派生结果。
## 7. SSE 口径
恢复后 `/messages/stream` 必须按以下顺序输出:
1. 多个 `reply_delta`
2. 一个 `session`
3. 一个 `done`
错误时输出:
1. `error`
要求:
1. `reply_delta.text` 来源于 `platform-llm.stream_text(...)` 的真实增量。
2. `session` 必须来自 finalize 完成后的最新 session 真相。
## 8. 验收
1. 用户发一条 Agent 消息后assistant 回复不再是固定文案。
2. `quickFillRequested=true` 时,推理结果仍遵循旧 Node 的 `force_complete` 逻辑。
3. SSE 能先看到逐步增长的 `reply_delta`,而不是一次性整段返回。
4. finalize 完成后,前端拿到的 session 中:
- `lastAssistantReply`
- `messages`
- `currentTurn`
- `progressPercent`
- `stage`
- `pendingClarifications`
- `suggestedActions`
已被真实更新。
5. 提示词正文未被改写。
## 9. 当前实现状态
截至 `2026-04-22`,本方案已在 `server-rs` 完成以下落地:
1. `submit_custom_world_agent_message` 已改为只提交 user message 与 running operation不再写死 assistant 回复。
2. `api-server` 的 Custom World Agent turn service 已恢复:
- 动态状态识别
- 正式单轮推理
- `replyText/progressPercent/nextAnchorContent` JSON 解析
- creator intent / readiness / clarification / suggested action 等最小派生
3. `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` 已改为真实 SSE 流:
- 推理进行中实时输出累计 `reply_delta`
- finalize 后输出最新 `session`
- 最后输出 `done`
4. 普通提交接口在 finalize 完成后返回最终 operation 快照,而不是 submit 阶段的 running 快照。
5. `finalize_custom_world_agent_message_turn` 已负责把 assistant message、session 聚合字段与 operation 最终状态一次性回写到 SpacetimeDB。

View File

@@ -0,0 +1,68 @@
# 自定义世界资产 Prompt 与默认描述配置说明2026-04-24
## 1. 目标
本说明记录生成世界草稿时,角色形象图像、角色动作视频、每一幕场景背景图像三类资产的默认描述与正式模型 prompt 的配置位置,避免后续继续误改旧 `server-node` 链路。
## 2. 世界草稿默认描述字段
生成世界草稿时,后端会要求模型在角色与幕级剧情结构阶段直接产出资产默认描述字段:
- 角色:`visualDescription`,用于打开角色形象图像生成面板时默认填入角色形象描述框。
- 角色:`actionDescription`,用于打开角色动作视频生成面板时默认填入各动作描述框;当前每个动作会从同一角色默认动作描述起步,用户切换动作后可分别编辑并缓存。
- 角色:`sceneVisualDescription`,用于描述角色常出现或关联的场景画面。三个角色默认描述字段必须在角色 outline 阶段同一次模型调用中产出;若模型遗漏,只允许后端本地兜底补字段,不再额外发起独立修复模型调用。
- 每一幕:`sceneChapterBlueprints[*].acts[*].backgroundPromptText`,用于打开该幕背景图像生成面板时默认填入场景描述框。
- 场景:`visualDescription` 只作为旧场景图或没有幕级描述时的兜底,不再从角色 AI 形象生成面板维护场景背景描述。
- 场景:`actNPCNames``connectedLandmarkNames``entryHook` 必须在关键场景生成阶段同一次模型调用中产出,并由原场景解析链路写入 `landmarks` 与幕级 `primaryNpcId / oppositeNpcId / encounterNpcIds`;不再使用独立的场景网络补全提示词。旧草稿中的 `sceneNpcNames` 仅作为兼容读取兜底,不作为新生成字段。
草稿生成契约位置:
- `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
- `build_custom_world_role_outline_batch_prompt`
- `build_custom_world_landmark_seed_batch_prompt`
- `build_foundation_draft_user_prompt`
- `normalize_scene_act_blueprint`
前端默认框映射位置:
- `src/prompts/customWorldRolePromptDefaults.ts`
- `visualPromptText` 优先取 `role.visualDescription`
- `animationPromptText` 优先取 `role.actionDescription`
- `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`
- 角色形象与动作工坊初始化默认文本。
- `animationPromptTextByKey` 负责分动作保存动作描述。
- 当角色本身已有 `visualDescription/actionDescription` 时,必须优先使用这批世界草稿新生成字段,不能让旧 workflow cache 覆盖当前草稿默认文本。
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- 幕背景图像生成弹窗优先使用 `act.backgroundPromptText`
- 普通场景图像生成弹窗仍可使用 `landmark.visualDescription` 兜底。
## 3. 正式模型 Prompt 配置
正式生成图片或视频时,不直接使用默认描述字段作为完整 prompt而是在 `server-rs` 继续编译:
- 角色主图:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_visual_prompt`
- 内部使用 `build_master_prompt`
- 只拼入用户可见的 `promptText` / `visualPromptText`,不再拼入 `characterBriefText` 或角色摘要字段。
- 角色动作视频:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_animation_prompt`
- 图生视频分支使用 `build_video_action_prompt`
- 场景背景图:`server-rs/crates/api-server/src/custom_world_ai.rs`
- `build_custom_world_scene_image_prompt`
## 4. 当前约束
- 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。
- 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。
- UI 不默认展示规则解释文案,正式约束只进入后端 prompt。
## 5. 自动草稿素材回写约束
- 世界草稿自动素材生成与草稿页手动生成使用同一套 `server-rs/crates/api-server/src/custom_world_ai.rs` 场景图接口和 OSS/SpacetimeDB 资产持久化链路。
- 自动批量生成幕背景时,后端必须把已成功生成的 `backgroundImageSrc/backgroundAssetId/generatedScenePrompt/generatedSceneModel` 写回 `sceneChapterBlueprints[*].acts[*]`,不能因为同批某一幕失败而丢弃已成功图片。
- 某一幕连续重试仍失败时,只允许在该幕写入 `backgroundGenerationError` 作为诊断字段;只要至少一幕成功,草稿仍应完成并让前端展示成功图片。
- 只有全部幕背景均失败时,才把“生成幕背景图失败”作为草稿素材阶段失败原因保存。
- Rust 服务实际生图模型读取 `DASHSCOPE_SCENE_IMAGE_MODEL` / `DASHSCOPE_COVER_IMAGE_MODEL` / `DASHSCOPE_REFERENCE_IMAGE_MODEL`;兼容旧 `DASHSCOPE_IMAGE_MODEL`,避免 `.env.example` 中配置了模型但服务端仍使用硬编码模型。
- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope它必须像草稿页手动生成一样把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。
- 自动草稿幕背景的默认尺寸必须与草稿页手动生成默认尺寸一致,当前统一为 `1280*720`;不能在自动链路中单独改成 `1600*900`,否则同一 prompt 在同一模型下也可能因供应商尺寸支持或耗时不同而表现不一致。
- 批量自动生图失败日志必须保留 `AppError.details.message` 中的供应商真实原因,不能只记录 `AppError.message()` 的 HTTP 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。

View File

@@ -0,0 +1,174 @@
# 世界草稿自动资产可见性修复说明 2026-04-20
更新时间:`2026-04-20`
## 1. 问题现象
在世界草稿生成完成后,用户反馈:
1. 草稿里看不到角色主形象
2. 场景里看不到每一幕的背景图
这类反馈容易被误判成“自动资产没有生成”,但实际排查后发现,问题主要集中在**结果页展示链路**,同时叠加了一个**fallback 资源不可预览**的问题。
## 2. 链路排查结论
本轮检查后确认:
1. 服务端自动资产服务会把角色主形象写回 `draftProfile.playableNpcs[].imageSrc / generatedVisualAssetId`
2. 服务端自动资产服务会把幕背景图写回 `draftProfile.sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId`
3. `agent draft -> result profile` 的适配层也会保留这些字段
真正的问题出在后续两个环节。
## 3. 根因
### 3.1 结果页可扮演角色卡优先用了运行时预览
结果页 `CustomWorldEntityCatalog` 的可扮演角色卡,之前优先显示:
1. `previewCharacter`
2. 再回退到 `role.imageSrc`
这会导致:
1. 草稿里已经有真实生成主图
2. 但界面仍优先渲染模板/运行时预览角色
3. 用户视觉上看不到最新生成主形象
### 3.2 场景页没有把多幕背景图真正展示出来
结果页 `场景` Tab 之前只展示:
1. 开局场景
2. 地点卡
但没有把:
`sceneChapterBlueprints[].acts[].backgroundImageSrc`
按可见结构渲染到结果页中。
因此即使后端已经生成并回写每一幕背景图,用户仍然只能看到“场景主图/地点图”,看不到“每一幕的图”。
### 3.3 fallback 自动资产写回的是 `.txt`
在没有 DashScope 图像能力时,`CustomWorldAgentAutoAssetService` 的 fallback 生成器之前会写:
1. 角色主形象:`master.txt`
2. 幕背景图:`scene.txt`
这虽然保证了字段被回写,但前端无法把 `.txt` 当图片展示,于是会进一步加重“好像没生成”的感知。
### 3.4 Agent 结果页入口优先读取 legacyResultProfile遮蔽了最新资产字段
世界草稿结果页不是直接读取当前 `draftProfile`,而是先经过:
1. `buildCustomWorldProfileFromAgentDraft`
2. `normalizeCustomWorldProfileRecord`
如果 `draftProfile.legacyResultProfile` 存在,旧逻辑会直接优先返回这份历史编译结果。
但自动资产服务在 Phase3/Phase4 后续补齐时,更新的是当前 `draftProfile` 中的:
1. `playableNpcs[].imageSrc / generatedVisualAssetId`
2. `storyNpcs[].imageSrc / generatedVisualAssetId`
3. `landmarks[].imageSrc`
4. `sceneChapters[].acts[].backgroundImageSrc / backgroundAssetId`
这会导致:
1. 服务端真实已经生成并回写了最新角色主图和分幕图
2. 结果页入口却仍然取到一份更早的 `legacyResultProfile`
3. 页面看到的是“旧草稿快照”,不是“当前带资产的草稿结果”
因此用户会表现为“完全看不到这轮刚生成出来的图片”。
## 4. 修复策略
### 4.1 结果页角色卡优先显示真实生成主图
`src/components/CustomWorldEntityCatalog.tsx` 中调整逻辑:
1.`role.imageSrc` 已存在,则优先显示该图片
2. 只有在缺失真实主图时,才回退到运行时角色预览
这样可扮演角色卡能直接展示当前草稿回写的角色主形象。
### 4.2 场景列表改为只展示场景卡,章节内容留在二级页
结合后续体验反馈,本轮又进一步收口了结果页结构:
1. `结果页 -> 场景列表` 不再直接展开章节与分幕内容
2. 场景列表卡片只负责展示:
- 场景名
- 场景摘要
- 场景图
3. 场景卡图片优先取该场景章节的首幕 `backgroundImageSrc`
4. 若首幕图缺失,再回退到场景主图 / 地标图
5. 章节标题、幕标题、幕目标等信息只在点击场景后的二级编辑页中查看
这样结果页列表保持清爽,但用户仍然能在列表里直接看到当前场景已生成的图片。
### 4.3 fallback 改为可显示 PNG
`server-node/src/services/customWorldAgentAutoAssetService.ts` 中调整 fallback
1. 不再写 `master.txt / scene.txt`
2. 改为写合法可显示的占位 `png`
3. prompt 信息单独写进 `manifest.json`
4. 角色主形象 fallback PNG 统一输出为 `1:1`
这样即使当前环境没有真实图像生成能力,草稿层也仍然会回写“前端能直接显示的图片资源”。
### 4.4 结果页读取 legacy profile 时强制合并当前草稿的最新资产字段
`src/services/customWorldAgentDraftResult.ts` 中补上合并逻辑:
1. 若存在 `legacyResultProfile`,继续保留它的完整运行时字段
2. 但会把当前 `draftProfile` 里最新回写的角色主图、地标图、分幕图再覆盖回结果页 profile
3. 这样结果页既不会丢失旧 runtime profile 的完整结构,也不会再被旧快照遮蔽最新图片资产
这一层修的是结果页真实入口,而不是仅修展示组件。
## 5. 影响范围
本次修复涉及:
1. `src/components/CustomWorldEntityCatalog.tsx`
2. `src/components/CustomWorldResultView.test.tsx`
3. `src/services/customWorldAgentDraftResult.test.ts`
4. `server-node/src/services/customWorldAgentAutoAssetService.ts`
5. `server-node/src/services/customWorldAgentAutoAssetService.test.ts`
6. `docs/technical/CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md`
7. 历史 saved profile 资产同步脚本 / 数据修复动作
## 6. 验收标准
修复后需要满足:
1. 世界草稿结果页的可扮演角色卡能直接看到生成主形象
2. 世界草稿结果页的场景列表能直接看到场景图片,且优先展示首幕背景图
3. 场景章节与分幕内容只在场景二级页中展示
3. `agent draft -> result profile` 不会丢失角色主图与幕背景字段
4. fallback 环境下回写的仍是前端可显示图片,而不是文本文件
5. 角色主形象 fallback PNG 尺寸必须满足 `1:1`
6. 即使存在 `legacyResultProfile`,结果页也必须展示当前草稿最新同步的角色主图与幕背景图
## 6.1 历史保存档案补充结论
本轮在真实 PostgreSQL 数据中又确认了一类历史问题:
1. `agent session` 中的草稿资产字段可能已经补齐
2. 但较早时刻自动保存过的 `custom_world_profiles.payload_json` 仍停留在旧路径
3. 用户如果从作品库打开的是 saved profile就会继续看到旧图或空图
因此这次修复除了改默认生成与展示逻辑,还需要对受影响的历史 saved profile 做一次同步刷新。
## 7. 后续建议
后续继续迭代这条链路时,建议保持:
1. “资产已生成”必须和“用户已看见”同时验证,不能只验证字段回写
2. 结果页与草稿工作区都要把多幕背景视为正式资产,不要只停留在编辑弹层里
3. 所有 fallback 资源都应保持为 UI 可直接消费的媒体格式

View File

@@ -0,0 +1,41 @@
# 世界草稿图片生成与预览补齐说明
更新时间:`2026-04-24`
## 1. 检查结论
当前 server-rs 的世界底稿生成链路已经在 `draft_foundation` 后台任务中补齐两类图片:
1. `playableNpcs``storyNpcs` 中的每个角色都会调用角色主形象生成链路,并把 `imageSrc``generatedVisualAssetId` 写回底稿。
2. `sceneChapterBlueprints[].acts[]` 中的每一幕都会调用场景图生成链路,并把 `backgroundImageSrc``backgroundAssetId`、生成提示词与模型信息写回底稿。
图片生成后不落本地真值,而是通过 OSS `put_object -> head_object -> confirm_asset_object -> bind_asset_object_to_entity` 确认对象,并用兼容的 `/generated-*` 路径供前端读取。
## 2. 前端缺口
结果页的场景列表此前只把每个场景的第一张幕背景图作为场景卡封面。这样虽然后端已经生成了每一幕图片,但用户只能看到第一幕,无法在结果页确认同一场景下其他幕的图片是否存在。
## 3. 本次落地
1.`CustomWorldEntityCatalog` 中增加每幕图片缩略条,来源为当前场景匹配到的 `sceneChapterBlueprints[].acts[].backgroundImageSrc`
2. 保留原来的场景卡封面策略:第一幕背景图仍作为主封面,旧的场景图字段继续作为兜底。
3. 缩略条只展示已生成图片的幕,不额外暴露章节结构文本,避免结果页变成规则说明面板。
4. 增加结果页测试,覆盖同一场景下两幕背景图都能在前端以图片形式预览。
## 4. 验收点
1. 生成世界草稿完成后,角色页签中所有可扮演角色和场景角色能展示 `imageSrc`
2. 场景页签中,每个场景卡片仍展示主封面。
3. 场景卡片下方能横向预览该场景所有已生成幕背景图。
4. OSS 未配置或上传失败时,后端任务应失败并把错误写入 operation而不是生成伪本地路径。
## 5. 上游图片服务失败降级
`draft_foundation` 的底稿文本结构是进入结果页和继续编辑的主产物,角色主图、幕背景图属于可后补资产。若 DashScope 或 OSS 上游临时不可用,后台任务不应把整份底稿标记为失败。
本次补充后:
1. 角色主图分支失败时operation 记录错误信息并继续使用未带角色图的底稿。
2. 幕背景图分支失败时operation 记录错误信息并继续使用未带幕图的底稿。
3. 已成功的并行资产分支仍会合并回底稿,不会被失败分支覆盖。
4. 后续可通过资产工坊或单项生成动作补齐缺失图片。

View File

@@ -0,0 +1,192 @@
# Custom World `draft_foundation` 迁移到 `api-server + platform-llm` 方案
日期:`2026-04-23`
## 1. 背景
当前 RPG 创作 Agent 的 `draft_foundation` 虽然已经能把会话推进到结果页,但真实执行位置仍在 `spacetime-module``execute_draft_foundation_action(...)`
这条链路的问题是:
1. `draft_foundation` 没有走 `platform-llm`
2. SpacetimeDB reducer 内部自己从 `seed_text / session.draft_profile_json` 兜底拼草稿,属于规则编译,不是“真实 LLM 生成”。
3. reducer 按 SpacetimeDB 约束不应承担外部网络副作用,因此“让 reducer 里直接调 LLM”本身也是错误方向。
验证清单第三项要求是:
1. 草稿编译需要真实走 LLM。
2. 不能再用本地占位 compile 去冒充真实生成。
因此这条链必须改成:
```text
前端 action
-> api-server 接收 draft_foundation
-> platform-llm 真实生成 foundation draft
-> spacetime-client 调用 SpacetimeDB action/procedure 写回 session / card / gate / preview
-> 前端继续通过 operation 轮询完成态
```
## 2. 本轮目标
本轮只解决第三项验证要求最核心的问题:
1. `draft_foundation` 的草稿生成必须在 `api-server` 中完成。
2. `api-server` 必须真实调用 `platform-llm`
3. `spacetime-module` 只负责:
- 校验 action 执行条件
- 落库 session / draft card / checkpoint / publish gate / result preview
4. 前端协议尽量不变,继续保留:
- `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions`
- `GET /api/runtime/custom-world/agent/sessions/:sessionId/operations/:operationId`
本轮不做:
1. 把旧 Node 的 foundation draft 全量多阶段 pipeline 一次性 1:1 搬到 Rust。
2. 额外新增前端 action 接口。
3. 在 SpacetimeDB 内新增“可联网 procedure”去直接调 LLM。
4.`legacyResultProfile` 兼容双重编译一起迁回主链。
## 3. 迁移后的职责边界
### 3.1 `api-server`
负责:
1. 识别 `draft_foundation` action。
2. 读取当前 session snapshot。
3. 基于真实 `seed_text``anchor_content / creator_intent / anchor_pack / draft_profile` 组织 foundation draft prompt。
4. 调用 `platform-llm::LlmClient` 获取首版草稿 JSON。
5. 做最小字段归一化,保证至少满足当前 `publish gate / result preview` 所需字段。
6. 把生成结果作为 `payload_json.draftProfile` 传给 `spacetime-client.execute_custom_world_agent_action(...)`
### 3.2 `spacetime-module`
负责:
1. 校验 session 是否允许执行 `draft_foundation`
2. 校验 payload 中必须带有外部已生成的 `draftProfile`
3.`draftProfile` 写入:
- `custom_world_agent_session.draft_profile_json`
- `custom_world_draft_card`
- `publish_gate_json`
- `result_preview_json`
- `checkpoints_json`
- `custom_world_agent_message`
- `custom_world_agent_operation`
4. 不再自己从 `seed_text` 兜底编译 `draftProfile`
5. 不再对 `draft_foundation` 的外部 `draftProfile` 做二次补全编译,避免责任边界重新漂回 SpacetimeDB。
### 3.3 `platform-llm`
负责:
1. 提供统一文本模型网关。
2. 返回 foundation draft JSON 文本。
## 4. 最小实现策略
## 4.1 先保留当前 action / operation 协议
前端现在的行为是:
1. `POST /actions` 拿到一个 `operation`
2. 进入“世界草稿生成进度”页
3. 轮询 `GET /operations/:operationId`
4. operation 完成后拉最新 session
因此本轮不改协议,只改服务端编排。
## 4.2 `draft_foundation` 的执行口径
`api-server` 接收到 `draft_foundation` 时:
1. 先读取当前 session。
2. 必须使用 session 中真实的 `seed_text` 与当前锚点组织 prompt不能误把 `session_id` 当作 seed 传给 LLM。
3.`progressPercent < 100`,直接返回错误。
4.`platform-llm` 生成 `draftProfile`
5. 用当前时间戳作为 action 提交时间。
6.`spacetime-client.execute_custom_world_agent_action(...)`,把 `draftProfile` 放进 payload。
7. 返回 SpacetimeDB 已落库的 operation。
首版保持同步完成,不额外引入新的 action finalize procedure。
原因:
1. 这样改动范围最小。
2. 已满足“LLM 在 api-server、SpacetimeDB 只负责落库”的验证要求。
3. 前端没有全局短超时,本轮可先接受单次 action 等待 LLM 返回。
如果后续需要更强的可观测性和更长耗时容忍,再把这条链拆成 submit/finalize 两段式后台任务。
## 4.3 foundation draft 的最小字段要求
本轮生成结果至少保证以下字段存在:
1. `name`
2. `subtitle`
3. `summary`
4. `worldHook`
5. `playerPremise`
6. `coreConflicts`
7. `playableNpcs`
8. `storyNpcs`
9. `landmarks`
10. `chapters`
11. `sceneChapterBlueprints`
这样可以直接满足当前 Rust `publish gate` 的最小校验,不会再次出现:
1. 草稿明明生成了
2. 但结果页仍然提示缺少 world hook / player premise / 主线章节 / 第一幕
## 5. 与旧 Node foundation draft 服务的关系
旧 Node 版本已经证明下面几点是成立的:
1. foundation draft 必须由后端调用真实 LLM。
2. foundation draft 与 preview compiler 应该拆边界。
3. `legacyResultProfile` 不应继续主导草稿主字段。
Rust 首版沿用这些结论,但不要求一次性照搬旧 Node 的全部多阶段拆分。
本轮只迁移:
1. “真实 LLM 生成 draft 主字段”这条主要求。
2. “结果落库由 SpacetimeDB 负责”这条边界。
## 6. 验收标准
满足以下条件时,这次迁移视为完成:
1. `draft_foundation``api-server` 真实调用 `platform-llm`
2. `spacetime-module``draft_foundation` 不再允许无 `draftProfile` 自行兜底编译。
3. 点击“生成游戏设定草稿”后session 的 `draft_profile_json / publish_gate_json / result_preview_json` 正常写回。
4. 结果页不再因为缺少最小底稿字段而错误阻断。
5. 定向测试通过。
6. 编码检查通过。
## 7. 相关文件
1. `server-rs/crates/api-server/src/custom_world.rs`
2. `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
3. `server-rs/crates/spacetime-module/src/lib.rs`
4. `server-rs/crates/module-custom-world/src/lib.rs`
5. `docs/technical/SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md`
## 2026-04-24 发布阻断快照刷新修正
### 问题
- 现象:生成 foundation draft 后,结果页已经能看到玩家 premise、主线章节、第一幕场景幕但底部仍显示旧的发布阻断项并禁用“发布并进入世界”。
- 根因:前端结果页直接消费 `session.resultPreview.blockers`。当当前页面 profile 已经补齐结构字段,但服务端 `resultPreview` 仍停留在上一轮快照时,展示态与可点击态会被旧 blocker 锁住。
- 次要根因:进入世界前的 `sync_result_profile` 发现当前页面 profile 与 session preview 签名一致时会短路,导致旧 `publish_gate_json/result_preview_json` 没机会被强制重算。
### 落地方案
- `PlatformEntryFlowShellImpl` 在 Agent 草稿结果页按当前 `generatedCustomWorldProfile` 判断旧结构 blocker 是否已过期,文案继续沿用后端 `resultPreview.blockers`,前端不新增重复中文提示。
- 非结构类 blocker 继续继承服务端快照,避免把真实质量阻断误放行。
- `useRpgCreationResultAutosave.syncAgentDraftResultProfile``agentSession.resultPreview.publishReady === false` 时不走签名短路,发布前会调用后端 `sync_result_profile` 重建 `publish_gate_json/result_preview_json`
- `api-server` 在读取 session 以及执行 `draft_foundation/sync_result_profile/publish_world` 后写入 `custom_world.publish_gate` 诊断日志,记录 blocker code、preview 来源与关键门禁字段是否为空;前端只显示简洁阻断数量,不展示字段细节。
### 验收点
- 生成草稿后,如果当前 profile 已包含 `playerPremise``anchorContent.playerEntryPoint``coreConflicts``chapters/sceneChapterBlueprints` 与至少一个 `acts`,结果页不再继续显示旧结构 blocker。
- 点击“发布并进入世界”前仍会同步到 SpacetimeDB reducer由后端重新计算最终发布门禁若仍有非结构质量 blocker按钮仍保持阻断。

View File

@@ -0,0 +1,106 @@
# Custom World `draft_foundation` Rust/Node AI 工作流对齐记录
日期:`2026-04-24`
## 背景
本次检查发现 `server-rs` 的 RPG 档案 / 世界底稿生成只做了单次 LLM 调用:直接让模型输出完整 `draftProfile`,再做最小字段归一化。这与旧 `server-node``CustomWorldAgentFoundationDraftService.generate()` 不一致。
旧 Node 流程不是单 prompt 直出,而是分阶段生成:
```text
buildFoundationGenerationSeedText
-> buildCustomWorldFrameworkPrompt
-> generateFoundationRoleOutlineEntries(playable)
-> generateFoundationRoleOutlineEntries(story)
-> generateFoundationLandmarkSeedEntries
-> expandFoundationRoleEntries(playable, narrative)
-> expandFoundationRoleEntries(playable, dossier)
-> expandFoundationRoleEntries(story, narrative)
-> expandFoundationRoleEntries(story, dossier)
-> buildFoundationDraftProfileFromFramework
```
## 本次落地
`server-rs/crates/api-server/src/custom_world_foundation_draft.rs` 已改为按 Node 原顺序执行多阶段 AI 生成:
1. 先从 `anchorContent / anchorPack / creatorIntent / seedText` 构造 `settingText`
2. 使用旧 Node 的 framework prompt 生成世界核心骨架。
3. 分批生成可扮演角色 outline。
4. 分批生成场景角色 outline。
5. 分批生成关键场景;同一次模型调用必须同时产出 `actNPCNames``connectedLandmarkNames``entryHook``actBackgroundPromptTexts``actEventDescriptions`,其中 `actNPCNames` 表示三幕各自默认主场景角色。旧草稿的 `sceneNpcNames` 只允许作为读取兜底。
6. 先补可扮演角色叙事档案,再补养成档案。
7. 先补场景角色叙事档案,再补养成档案。
8. 将分阶段结果编译回 `draftProfile`,再交给 SpacetimeDB action 落库。
## 约束
1. Rust 主链不再兼容旧 Node 的独立场景网络补全阶段;场景生成只允许通过 `build_custom_world_landmark_seed_batch_prompt` 一次完成。
2. Rust 侧新增 prompt 构造只服务 `api-server` 外部 LLM 调用SpacetimeDB reducer 仍只负责校验与落库,不承担联网生成。
3. 当前仍保留 Rust 侧最小归一化,目的仅是保证 `publish gate / result preview` 需要的字段存在,不替代 Node 的 AI 工作流。
4. 后续如继续迁移,需要优先把 Node `buildFoundationDraftProfileFromFramework` 的结构编译细节进一步完整 Rust 化,而不是回退到单 prompt 直出。
## 验证
已运行:
```bash
cargo test -p api-server custom_world_foundation_draft --no-default-features
```
结果:`3 passed`
## 2026-04-24 进度链路补齐
本次继续补齐“点击生成世界草稿”后的异步执行方式,避免 HTTP 请求阻塞到全部 LLM 调用结束才返回:
1. `execute_custom_world_agent_action(draft_foundation)` 现在先创建 `draft_foundation` running operation并立即把 `operationId` 返回给前端。
2. API 后台任务继续执行 Node 同序多阶段生成;前端已有的 operation polling 可以持续读取阶段进度。
3. Rust 生成器按 Node 的 `onProgress` 节点写入:
- `12`:整理世界骨架。
- `16-30`:生成可扮演角色。
- `30-44`:生成场景角色。
- `44-66`:生成关键场景,并同步产出幕 NPC 与场景连接。
- `66-76`:补全可扮演角色叙事基础。
- `76-84`:补全可扮演角色档案细节。
- `84-92`:补全场景角色叙事基础。
- `92-96`:补全场景角色档案细节。
- `97`:编译世界底稿。
- `98`:编译草稿卡。
4. SpacetimeDB 新增 `upsert_custom_world_agent_operation_progress`,只更新/创建 operation 进度,不插入聊天消息、不推进 turn专门承接生成中的阶段进度。
5. 最终落库仍复用 `execute_custom_world_agent_action(draft_foundation)`,但允许复用同一个 running operation 完成写入,避免中间断点和重复 operation。
补充验证:
```bash
cargo check -p api-server
cargo check -p spacetime-module
cargo test -p api-server custom_world_foundation_draft -- --nocapture
```
结果:后端检查通过;`custom_world_foundation_draft` 相关测试 `3 passed`
## 2026-04-24 `spacetime-client` facade 补齐
合并 `draft_foundation` 进度链路后,`spacetime-module` 和生成绑定中已经存在 `upsert_custom_world_agent_operation_progress` procedure但手写 `spacetime-client` facade 尚未导出对应 record input 与调用方法,导致 `api-server` 编译时报:
1. `CustomWorldAgentOperationProgressRecordInput` 未导出。
2. `SpacetimeClient::upsert_custom_world_agent_operation_progress` 不存在。
本次补齐边界:
1. `spacetime-client::mapper` 新增 `CustomWorldAgentOperationProgressRecordInput`
2. `spacetime-client::custom_world` 新增 `upsert_custom_world_agent_operation_progress(...)`,负责把字符串形式的 operation type/status 翻译为 SpacetimeDB 生成枚举后调用 procedure。
3. `spacetime-client::module_bindings::mod` 补入已生成的 progress input/procedure 索引,避免 procedure 文件存在但 `RemoteProcedures` 扩展 trait 未进入作用域。
4. `api-server` 只依赖 facade不直接碰生成绑定保持 HTTP 层与 SpacetimeDB 生成类型隔离。
补充验证:
```bash
cargo fmt -p spacetime-client -p api-server
cargo check -p api-server --bin api-server
npm run check:encoding
```
结果:`api-server` 编译通过,编码检查通过;剩余 warning 为既有 dead code。

View File

@@ -0,0 +1,149 @@
# 世界草稿生成失败与等待页卡住问题分析 2026-04-20
更新时间:`2026-04-20`
## 1. 问题背景
本次问题表现为:
1. 世界草稿生成过程中实际已经失败。
2. 前端等待页仍然停留在“编译草稿卡”步骤。
3. 用户感知为“卡住不动”,而不是“这一轮失败了,可以返回或重试”。
这个问题不是单点 bug而是由两类问题叠加造成
1. 前端进度映射把 `failed + progress=100` 误解释成“已经跑到最后一个步骤附近”,导致视觉上像卡在 `编译草稿卡`
2. 服务端 `draft_foundation` 主链把自动资产补齐也视为硬依赖,一旦角色主形象或场景幕背景图失败,就会把整版世界底稿一起打成失败。
## 2. 本次链路梳理结论
当前世界草稿生成主链路为:
```text
foundation_review
-> draft_foundation action
-> foundation draft service 生成世界底稿结构
-> auto asset service 补角色主图与幕背景图
-> draft compiler 编译 draftCards
-> session 进入 object_refining
-> 前端等待页切到结果或回工作区
```
其中真正的“必须成功”主链只有两段:
1. `foundation draft` 结构生成成功。
2. `draftCards` 编译成功。
角色主图与幕背景图属于增强链路,不应该阻断世界底稿首版落地。
## 3. 根因拆解
### 3.1 前端等待页状态映射问题
`src/services/customWorldAgentGenerationProgress.ts` 之前只对以下情况做了显式处理:
1. `completed`
2. 匹配某个 `phaseLabel`
3. 兜底按 `progress` 推断当前步骤
但没有对 `failed` 做单独分支。
于是当后端返回:
```text
status = failed
progress = 100
phaseLabel = 底稿生成失败
```
前端仍会按 `progress=100` 去推断步骤,结果高概率落在末尾附近,视觉上就像卡在:
```text
编译草稿卡
```
### 3.2 服务端把增强链路当成硬依赖
`server-node/src/services/customWorldAgentOrchestrator.ts` 中的 `processDraftFoundationOperation` 会在世界底稿生成后调用:
```ts
autoAssetService.populateDraftAssets(...)
```
`populateDraftAssets` 之前的行为是:
1. 角色主图生成失败直接 throw
2. 场景幕背景图生成失败直接 throw
于是自动资产失败会直接中断后续:
```text
draft compiler
session replaceDerivedState
checkpoint
assistant summary
operation completed
```
这会把“本来已经可以用的世界底稿”一并拖死。
## 4. 修复策略
### 4.1 前端修复
`src/services/customWorldAgentGenerationProgress.ts` 中新增显式失败步骤:
1. `failed` 状态不再走 `progress` 推断。
2. 失败时固定返回 `phaseId = failed`
3. 等待页保留已有步骤清单,但不再假装仍有某一步处于 active。
修复后,等待页会明确展示:
1. 当前状态已失败
2. 失败文案与失败详情
3. 用户可以返回工作区或重新生成
### 4.2 服务端修复
`server-node/src/services/customWorldAgentAutoAssetService.ts` 中把自动资产改成“尽力而为”:
1. 角色主形象生成失败时不再 throw而是记录 warning。
2. 幕背景图生成失败时不再 throw而是记录 warning。
3. 主链继续编译 `draftCards`,并让 `assetCoverage` 明确标记哪些资产仍缺失。
`server-node/src/services/customWorldAgentOrchestrator.ts` 中:
1. 如果草稿主链成功,只是资产补齐未完成,则 operation 仍记为 `completed`
2. `phaseDetail` 增加“有若干项资产补齐待后续处理”的说明。
3. assistant summary 也同步说明“这不影响继续精修世界底稿”。
4. 真正失败时,优先保留当前失败阶段的 `phaseLabel/phaseDetail`,避免统一抹平成模糊的“底稿生成失败”。
## 5. 影响范围
本次修改影响以下模块:
1. `src/services/customWorldAgentGenerationProgress.ts`
2. `src/services/customWorldAgentGenerationProgress.test.ts`
3. `server-node/src/services/customWorldAgentAutoAssetService.ts`
4. `server-node/src/services/customWorldAgentAutoAssetService.test.ts`
5. `server-node/src/services/customWorldAgentOrchestrator.ts`
6. `server-node/src/services/customWorldAgentPhase3.test.ts`
## 6. 验收标准
修复后需要满足:
1. 世界草稿主链失败时,等待页明确显示失败,而不是视觉上卡在 `编译草稿卡`
2. 自动资产生成失败时,世界底稿和草稿卡仍然要能生成完成。
3. session 能进入 `object_refining`,用户可以先继续精修结构内容。
4. `assetCoverage` 能反映未补齐的角色图/场景图缺口。
5. 助手消息和 operation 文案都能把“主链完成,增强链路待补”表达清楚。
## 7. 后续建议
后续若继续增强这条链路,建议保持以下原则:
1. 世界结构生成、草稿卡编译属于主链,必须最稳。
2. 角色图、动作、场景图、长尾补齐都属于增强链路,应允许降级。
3. 所有等待页都要有显式失败态映射,禁止仅靠 `progress` 推断最终阶段。
4. 操作失败时优先保留最后一个真实阶段标签,方便定位到底是结构生成失败、资产生成失败,还是写回失败。

View File

@@ -0,0 +1,93 @@
# 自定义世界草稿场景幕事件与任务字段落地设计2026-04-25
## 背景
自定义世界 Agent 生成第一版草稿时,已经会为 `sceneChapterBlueprints[*].acts[*]` 生成逐幕背景图描述,并为场景写入基础描述、出场角色等信息。后续运行时需要更稳定的章节任务上下文,因此草稿阶段必须同时生成:
1. 每一幕的对面角色。
2. 每一幕的事件描述。
3. 每个场景的场景任务描述。
## 字段契约
### SceneActBlueprint
新增字段:
- `oppositeNpcId: string`
- 当前幕“对面的角色”,优先使用该场景 `actNPCNames[actIndex]` 对应的角色。
-`actNPCNames` 缺少当前幕条目,使用 `actNPCNames[0]`;旧草稿只存在 `sceneNpcNames` 时,仅作为兼容兜底读取,不再作为新生成字段。
- 若当前场景暂未绑定角色,使用空字符串,不在草稿合成阶段伪造角色 ID。
- `eventDescription: string`
- 描述当前幕正在发生的事件。
- 必须强绑定 `oppositeNpcId` / `primaryNpcId` 所指角色,写清该角色的行动、阻碍、试探、求助或冲突。
- 三幕默认遵循戏剧曲线:第一幕铺垫并露出异常,第二幕让阻碍或立场冲突升级,第三幕进入高潮、关键抉择或直接后果。
- 默认生成兜底规则:`第N幕中玩家在当前场景遭遇/处理与某角色直接相关的事件,并推动当前场景问题升级或转向。`
兼容字段:
- `primaryNpcId` 继续保留,默认等于 `oppositeNpcId`,避免旧运行时代码读取不到主角色。
- `encounterNpcIds` 继续保留,至少承载当前场景可出场角色名称/ID。
### SceneChapterBlueprint
新增字段:
- `sceneTaskDescription: string`
- 当前场景的核心任务描述。
- 文本会作为游戏中首次进入某个场景生成章节任务的关键上下文。
- 必须结合场景描述、场景入口钩子、出场角色与 3 幕事件,说明玩家首次进入该场景时要完成什么。
- 世界档案的场景详情页必须直接展示该字段,便于创作者确认每个场景的默认章节任务。
### Landmark 生成源字段
- `actNPCNames: string[]`
- 关键场景生成阶段一次模型调用内产出,表示第 1/2/3 幕各自的主场景角色。
- 只能引用同一批角色生成链路中已有的场景角色名。
- 解析到幕蓝图时,每一幕默认写入 `primaryNpcId``oppositeNpcId`,并作为 `encounterNpcIds` 的首位。
- 新生成不再使用 `sceneNpcNames`;前端和后端可保留旧字段读取兜底,用于历史草稿不丢角色。
## 生成链路
1. `api-server``custom_world_foundation_draft.rs` 是第一版草稿的真实生成入口。
2. LLM 提示词需要要求:
- `camp.sceneTaskDescription` 默认生成开局场景核心任务。
- `landmarks[*].sceneTaskDescription` 默认生成关键场景核心任务。
- `actNPCNames` 恰好 3 条,对应每一幕默认主场景角色;如果可用场景角色名单为空,输出空数组。
- `actEventDescriptions` 恰好 3 条,对应每一幕事件。
- `actEventDescriptions[0] / [1] / [2]` 必须分别承担铺垫、冲突、高潮,不允许三条只是同一事件的近义复述。
- `actBackgroundPromptTexts[n]` 必须基于同序号幕事件和相关角色写出画面主体、站位空间、冲突痕迹与氛围,不能只用场景名或幕标题拼接。
3. 后端合成 `sceneChapterBlueprints` 时把这些源字段落到:
- `sceneChapterBlueprints[*].sceneTaskDescription`
- `sceneChapterBlueprints[*].acts[*].oppositeNpcId`
- `sceneChapterBlueprints[*].acts[*].eventDescription`
- `sceneChapterBlueprints[*].acts[*].primaryNpcId`
- `sceneChapterBlueprints[*].acts[*].encounterNpcIds[0]`
4. 若 LLM 遗漏字段,归一化阶段用场景描述、入口钩子、角色名单生成中文默认值,保证草稿阶段字段非空。
5. 前端类型与归一化逻辑必须允许读取这些字段,旧草稿缺字段时仍自动补默认值。
6. 幕信息编辑界面必须直接展示 `eventDescription`,并在保存时保留 `sceneTaskDescription / oppositeNpcId / eventDescription / backgroundPromptText`,避免旧草稿经前端编辑后丢失后端生成字段。
7. 首次进入某场景时,现有章节任务生成流程必须优先读取对应 `sceneChapterBlueprints[*].sceneTaskDescription`,并把它作为 `buildChapterQuestForScene` 的章节任务覆盖上下文;同一场景只生成一次章节任务。
## 幕配置预览标识
1. 幕配置预览图里只保留简洁角色点位标识,不新增说明类文案。
2. 主角固定在画面左侧可站立区域,对面角色槽位固定在画面右侧可站立区域:
- 主角色槽位位于画面中右侧,作为当前幕的主要对峙对象。
- 第二、第三角色槽位位于右侧上、下两个辅助位置,形成清晰的纵向层次。
3. 每个槽位使用圆形短标识表达序号:`主 / 2 / 3`,旁边只展示角色名或“添加角色”。
4. 标识必须有高对比底色、描边和轻微阴影,避免在浅色天空、地面纹理或深色背景上丢失。
5. 空槽位仍然可点击,但只能显示 `+` 与短标签,不能显示大段规则说明。
## 对话选项差异要求
运行时 NPC 聊天每轮生成的 3 个对话选项必须导向不同氛围和好感结果:
1. 第一条为温和共情或愿意倾听,通常让气氛缓和并更容易带来好感上升。
2. 第二条为冷静追问或试探,通常保持中性但推进情报。
3. 第三条为施压、质疑或立场冲突,通常让气氛变紧,可能带来好感下降或代价。
## 非目标
- 不新增 UI 说明文案。
- 不迁移或兼容 `server-node`
- 不改变现有幕背景图生成队列与资产写回链路。

View File

@@ -0,0 +1,115 @@
# 自定义世界 Phase4 数量字段语义对齐说明 2026-04-20
更新时间:`2026-04-20`
## 1. 背景
在排查世界草稿生成等待页问题后,继续执行 `server-node` 相关测试时,发现 Phase4 仍有两处失败:
1. `generate_characters` 后草稿作品卡的角色数量没有按预期增长。
2. `generate_landmarks` 的 HTTP 用例对地点数量使用了过时的固定基线。
这两个问题本质上都和“数量字段语义不一致”有关。
## 2. 当前产品语义
创作中心草稿卡在前端展示的是:
```text
角色 X
地点 Y
```
这里的“角色”从产品感知上表示:
**当前草稿中已经长出来、可继续精修的全部角色对象数量。**
而不是:
**仅 playable / 仅主角位角色数量。**
对应地,“地点”表示:
**当前草稿中已经存在的地点对象数量。**
## 3. 之前的偏差
### 3.1 角色数量偏差
`server-node/src/services/customWorldWorkSummaryService.ts` 在草稿态里原先只统计:
```ts
draftProfile.playableNpcs.length
```
但 Phase4 `generate_characters` 的实现是把新增角色插入:
```ts
draftProfile.storyNpcs
```
所以会出现:
1. 角色卡确实新增了
2. `storyNpcs` 也确实变多了
3. 创作中心草稿卡上的“角色数”却没有同步增加
这会让产品表现和数据真实状态不一致。
### 3.2 地点数量断言偏差
`server-node/src/app.test.ts``generate_landmarks` HTTP 用例里,之前写死了:
```ts
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
```
但当前基础草稿阶段的地点基线已经调整为:
```ts
FOUNDATION_DRAFT_LANDMARK_COUNT = 2
```
所以 Phase4 的正确断言应该是:
```text
在当前会话已有地点基线上,再新增 2 个地点
```
而不是继续沿用旧版本里“基础草稿默认至少 4 个地点”的固定假设。
## 4. 本次修正
### 4.1 草稿摘要角色数
草稿态 `playableNpcCount` 在工作摘要里继续沿用既有字段名,但统计语义调整为:
```text
全部草稿角色数量 = playableNpcs + storyNpcs 去重后的总数
```
原因:
1. 前端现有 contract 和展示字段已经复用 `playableNpcCount`
2. 这次目标是最小修复,不额外扩 contract
3. 草稿态 UI 标签本身展示的是“角色”,不是“可扮演角色”
因此这次保留字段名,修正其在草稿态的统计语义。
### 4.2 Phase4 地点断言
`generate_landmarks` 的 HTTP 用例改为基于当前会话的 `draftProfile.landmarks.length` 做增量校验:
```text
新增后数量 >= 基线数量 + 2
```
这样可以避免未来基础草稿默认地点数再次调整时Phase4 用例继续被写死基线误伤。
## 5. 约束建议
后续涉及草稿作品卡数量字段时,统一遵守:
1. 草稿态“角色”展示全部草稿角色数,不只统计 playable。
2. 已发布态如果 UI 明确写“可扮演角色”,再单独按 playable 统计。
3. 所有数量断言优先使用“当前基线 + 增量”的写法,不要硬编码旧阶段默认数量。

View File

@@ -0,0 +1,53 @@
# 世界结果页新增场景与 NPC 生成修复
## 背景
世界结果页在 Agent 草稿模式下点击“新增场景角色”和“新增场景”时,会走 `api-server``generate_characters / generate_landmarks` 动作:
1. `api-server` 根据当前 `draft_profile` 请求 LLM 生成新增实体。
2. `spacetime-module``generatedCharacters / generatedLandmarks` 写回 `draft_profile`
3. 结果页从服务端 `resultPreview.preview` 读取最新世界 profile。
## 问题
LLM 扩展提示词为了草稿卡片简洁,只要求返回角色的 `publicMask / hiddenHook / relationToPlayer / summary`,以及场景的 `purpose / mood / secret / summary / characterIds`
但结果页与运行时 `CustomWorldProfile` 读取的是当前完整字段:
- NPC`description / backstory / personality / motivation / relationshipHooks / tags / initialAffinity`
- 场景:`description / sceneNpcIds / connections`
因此新增实体即使后端动作成功,也可能因为字段缺失或关联字段名不一致,在结果页表现为“生成后没有可用内容 / 场景没有 NPC 关联”。
此外Agent 结果页生成回调原本只返回 `void`:当 `activeAgentSessionId` 失效、服务端没有返回最新 `resultPreview`,或最新 profile 没有新增实体时,前端只会结束 pending 动画,表现为“点击后闪一下就消失”。
## 修复方案
本次修复保持“前端只表现,后端负责数据整理”的边界:
1.`api-server` 生成实体归一化阶段补齐结果页需要的最小完整字段。
2. NPC 将 `publicMask / summary` 映射为 `description``hiddenHook` 映射为 `backstory / motivation``relationToPlayer` 进入 `relationshipHooks`
3. 场景将 `summary / purpose / mood / secret` 合成 `description`,将 `characterIds` 转为 `sceneNpcIds`
4. 保留 LLM 已返回的字段,不覆盖更完整的结构化结果。
5. 增加后端单元测试锁定新增 NPC 与场景的 profile 字段契约。
6. 前端 Agent 生成回调返回最新 profile如果没有可用会话或最新 profile 未增加对应实体,结果页显示明确错误,不再静默消失。
## 验收
- `generate_characters` 的 payload 中新增角色必须包含非空 `description` 与可用 `relationshipHooks`
- `generate_landmarks` 的 payload 中新增场景必须包含非空 `description`,并能把 `characterIds` 落为 `sceneNpcIds`
- 结果页继续只消费 `resultPreview.preview`,不新增前端本地编译分支。
- 结果页点击新增实体后,如果服务端没有回传新增内容,必须展示错误提示。
## 2026-04-24 追加:可扮演角色结果页空刷新修复
新增可扮演角色报“生成请求已完成,但结果页未收到新增内容”的根因是:`api-server` 已经把 LLM 生成结果注入 `generatedCharacters`,但 `spacetime-module` 缺少 `generate_characters / generate_landmarks` 的真实落库 executoraction 会进入分派却无法把新增内容写入 `draft_profile_json``result_preview_json`
本次补齐 SpacetimeDB module executor
1. `generate_characters(roleType=playable)` 写入 `draftProfile.playableNpcs`
2. `generate_characters(roleType=story)` 写入 `draftProfile.storyNpcs`
3. `generate_landmarks` 写入 `draftProfile.landmarks`
4. 每个新增实体同步生成 draft card并刷新 `publish_gate_json / result_preview_json / checkpoints_json / operation / message`
结果页仍只消费服务端 `resultPreview.preview`;前端不会本地伪造新增角色。

View File

@@ -0,0 +1,27 @@
# 自定义世界场景 dangerLevel 字段移除落地说明2026-04-25
## 背景
自定义世界场景对象过去在开局归处、地标、草稿实体和生图上下文中携带 `dangerLevel`。该字段会让场景结构额外承载“危险等级”枚举,并在提示词中要求模型生成固定的 `low|medium|high|extreme` 值。当前场景设计更依赖 `description``visualDescription`、幕级事件和任务描述表达氛围,不再需要独立危险等级字段。
## 落地范围
- 场景数据结构删除 `dangerLevel`,包括开局归处、地标、共享草稿协议与前端表单映射。
- 自定义世界生成、修复和补全提示词不再要求模型输出 `dangerLevel`
- 场景生图上下文只使用世界名、世界摘要、整体基调、玩家目标、场景名、场景描述和用户画面描述,不再拼接危险等级氛围。
- Rust API 与 SpacetimeDB 模块展示卡片不再从 `dangerLevel` 读取副标题或场景上下文。
- 测试夹具移除 `dangerLevel` 输入,避免新测试继续固化该字段。
## 兼容策略
- 旧存档或旧 LLM 返回中若仍包含 `dangerLevel`,前端和 Rust 归一化流程不再读取或回写该字段。
- 不新增替代字段;需要表达危险、压迫或安全感时,写入 `description``visualDescription``mood``sceneTaskDescription` 或幕级描述。
- `server-node` 旧实现不作为兼容目标,本次仅清理当前前端、共享协议与 `server-rs` 链路。
## 编码落点
- `src/types/customWorld.ts`:删除场景类型上的 `dangerLevel`
- `src/prompts/customWorldPrompts.ts``server-rs/crates/api-server/src/prompt/foundation_draft.rs`:删除所有生成/修复 `dangerLevel` 的模板与约束。
- `src/services/customWorld.ts``src/services/customWorldCamp.ts``src/services/customWorldBuilder.ts`:删除归一化和 fallback 写入。
- `src/data/customWorldLibrary.ts``src/data/customWorldSceneGraph.ts``src/data/customWorldVisuals.ts`:删除持久化、场景图谱和视觉上下文映射。
- `server-rs/crates/api-server``server-rs/crates/spacetime-module`:删除 Rust 侧默认 JSON、提示词、实体卡片副标题和场景上下文读取。

View File

@@ -0,0 +1,85 @@
# 主流程外编辑器入口清理说明2026-04-21
日期:`2026-04-21`
## 1. 文档目标
记录本轮对“挂在主流程路由外的旧编辑器入口”和“仍把这些入口当现役能力的残留说明”做的收口,避免后续开发再次把历史入口误判成正式能力。
---
## 2. 本轮清理结论
本轮确认后,当前前端正式入口只保留游戏主流程:
- `src/routing/appRoutes.tsx` 仅保留 `game`
本轮删除或收口的对象:
- 独立前端工具路由 `qwen-sprite-tool`
- 仅服务该独立入口的前端页面 `src/tools/QwenSpriteSheetTool.tsx`
- 仅服务该独立入口的工具模型与持久化封装
- 仅服务该独立入口的后端路由 `server-node/src/modules/assets/qwenSpriteRoutes.ts`
- 路由测试里把旧编辑器 / 独立工具入口当作现役分支的断言
- README、经验文档、类型检查配置中已经失效的旧编辑器文件引用
---
## 3. 为什么可以删除
本轮删除对象满足下面几个条件:
1. 不在当前玩家主流程中可达
2. 没有继续嵌入正式创作主链
3. 当前仓库已有主流程内嵌的替代能力
4. 保留它们只会继续制造“看起来还能进、实际上已经不走”的假入口
其中需要特别区分的是:
- `src/editor/shared/editorApiClient.ts`
- `server-node/src/modules/editor/editorRoutes.ts`
- `src/components/CustomWorldEntityEditorModal.tsx`
- `src/components/CustomWorldNpcVisualEditor.tsx`
- `src/components/CustomWorldRoleAssetStudioModal.tsx`
这些仍然服务当前主流程内嵌编辑能力,因此本轮不删除。
---
## 4. 当前保留的编辑能力边界
当前保留的是“嵌入主流程的编辑能力”,不是“独立编辑器站点”:
- 自定义世界实体编辑
- 自定义世界角色形象编辑
- 主流程内的角色资产工坊模态
- 与这些能力配套的 `/api/editor/*``/api/assets/character-*` 接口
后续如果还要新增编辑能力,应优先:
1. 先确认是否真的需要独立入口
2. 默认优先接回主流程模态或正式创作链
3. 如果只是内部工具,不要长期挂在正式前端路由里
---
## 5. 本轮同步更新
本轮已同步更新:
- `README.md`
- `docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md`
- `src/routing/appRoutes.tsx`
- `src/routing/appRoutes.test.ts`
- `server-node/src/app.ts`
- `tsconfig.typecheck-guardrails.json`
---
## 6. 后续建议
后续继续清理时,优先沿着这条规则推进:
1. 先识别是否还在主流程可达
2. 再判断是否仍有正式嵌入点
3. 若只剩文档、测试、兼容判断或独立路由壳,直接成批收口

View File

@@ -0,0 +1,70 @@
# 编码检查与临时工作区噪音收口方案2026-04-22
日期:`2026-04-22`
## 1. 背景
当前仓库根目录存在多份本地临时工作区与 Cargo cache 目录,例如:
1. `.codex-cargo-home-stage4*`
2. `server-rs-codex-stage4-*`
3. `server-rs/target-*`
这些目录属于本地验证产物,不属于主工程源码、文档或正式资源,但 `npm run check:encoding` 仍会通过 `git ls-files --cached --others --exclude-standard` 把其中大量未跟踪文本文件纳入扫描,导致:
1. 编码检查耗时被临时目录放大
2. 检查结果容易被本地 cache / verify copy 噪音污染
3. 仓库级 UTF-8 检查无法稳定反映真实工程文件状态
同时,当前脚本没有把 `.rs` 纳入文本扩展名集合这与仓库约束“Rust / 工程代码中的中文注释也必须保证 UTF-8 正常”不一致。
## 2. 本次冻结规则
本轮对编码检查口径做以下冻结:
1. `scripts/check-encoding.mjs` 只检查主工程真实文本文件,不扫描临时 Cargo cache、临时 verify copy 和 `server-rs/target-*` 目录。
2. `.rs` 必须纳入 UTF-8 编码检查,避免 Rust 文件中的中文注释或中文错误文案被写坏后漏检。
3. `.encoding-check-ignore` 继续只承载少量已知历史坏文本白名单,不用于掩盖大目录级临时产物。
4. 对临时目录的处理优先通过 `.gitignore` 与脚本排除规则完成,不要求物理删除本地 cache。
## 3. 具体落地点
### 3.1 `.gitignore`
新增忽略规则:
1. `/.codex-cargo-home-*/`
2. `/server-rs-codex-*/`
3. `/server-rs/target-*/`
目的:
1.`git ls-files --others --exclude-standard` 不再把这些临时目录当作待检查仓库文件。
2. 与既有噪音清理基线保持一致,继续把本地检查产物留在仓库视野之外。
### 3.2 `scripts/check-encoding.mjs`
脚本同步收紧两点:
1. 增加对上述临时前缀目录的显式排除,避免脚本在显式传参或忽略规则未生效时仍误扫临时目录。
2.`.rs` 加入文本扩展名集合,确保 Rust 源文件进入 UTF-8 校验面。
## 4. 完成定义
当以下条件满足时,本次修复视为完成:
1. `npm run check:encoding` 不再被临时 Cargo / verify 目录拖慢或污染结果。
2. 真实工程中的 Rust 文件会参与 UTF-8 检查。
3. 不需要清理用户本地 cache 目录,也不会对现有并行工作区造成破坏。
## 5. 不在本轮范围
本轮不处理:
1. `.encoding-check-ignore` 中历史坏文本的逐条修复
2. 各类本地 cache / verify 目录的物理删除
3. 与 UTF-8 检查无关的 lint / typecheck / cargo 输出目录清理策略
## 6. 相关文档
1. [./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md)

View File

@@ -0,0 +1,62 @@
# 世界底稿可选文本字段缺省防护修复 2026-04-22
更新时间:`2026-04-22`
## 1. 问题背景
用户在生成世界底稿时,进度在“补全场景角色细节”前后暂停,最终 operation 进入失败态。
数据库中的失败记录为:
```text
sessionId = custom-world-agent-session-c8fc39e07da4537cce75314bf4a5f92b
operation = draft_foundation
status = failed
phaseLabel = 编译世界底稿
error = Cannot read properties of undefined (reading 'replace')
```
这说明模型分批生成链路已经推进到末端,失败点不是“补全场景角色细节”模型请求本身,而是后续把分批结果编译成 foundation draft / 兼容结果快照时遇到可选文本字段缺省。
## 2. 根因
`server-node/src/services/customWorldAgentFoundationDraftService.ts` 内部的 `clampText(value: string, maxLength: number)` 直接调用:
```ts
value.replace(...)
```
`CustomWorldGenerationRoleOutline` 中以下字段是可选字段:
1. `visualDescription`
2. `actionDescription`
3. `sceneVisualDescription`
模型在“场景角色叙事基础 / 档案细节”批次中允许只补关键叙事字段,不保证每批都回传所有可选视觉字段。合并详情后,某些角色仍可能缺少这些字段。
当编译函数执行:
```ts
clampText(role.visualDescription, 36)
```
如果 `role.visualDescription``undefined`,就会触发 `Cannot read properties of undefined (reading 'replace')`,导致整版世界底稿失败。
## 3. 修复原则
1. 可选文本字段缺省属于正常模型输出波动,不应阻断世界底稿主链。
2. 编译层必须把 `undefined/null/非字符串` 统一归一为空文本,再进入裁剪逻辑。
3. foundation draft 主链应继续保留中文 fallback不能用英文占位替代中文内容。
4. 回归测试需要覆盖“场景角色详情批次缺省可选视觉字段”的真实失败形态。
## 4. 本次落地范围
1. 加固 `customWorldAgentFoundationDraftService.ts` 的本地文本裁剪入口。
2. 加固 `runtime-profile/normalizeShared.ts` 的公共文本裁剪入口,避免兼容 runtime profile 后续遇到同类缺省字段。
3. 新增回归测试,模拟场景角色详情批次省略可选视觉字段时仍能成功生成世界底稿。
## 5. 验收标准
1. 同类模型输出缺省 `visualDescription/actionDescription/sceneVisualDescription` 时,底稿生成不再抛出 `.replace` undefined。
2. operation 应继续推进到 `completed`,并进入结果页可浏览的草稿卡链路。
3. 测试覆盖这次失败的核心路径。

View File

@@ -0,0 +1,56 @@
# 前端首次加载慢修复记录
日期:`2026-04-26`
## 1. 背景
网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready浏览器 Network 面板中主要等待项集中在 `.tsx` 模块请求,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏 `.tsx` 冷转译与默认路由依赖图。
## 2. 现象与根因
本次排查发现三个会放大首屏等待的前端问题:
1. 默认路由进入 `AuthenticatedApp -> App -> RpgRuntimeShell -> PlatformEntryFlowShellImpl`,首屏虽然只显示平台首页,但入口文件静态导入了创作中心、拼图 Agent、拼图结果页、拼图运行态等非首屏阶段组件。Vite dev 首次访问时需要逐个请求并转译这些 `.tsx`,表现为浏览器长时间卡在加载 `.tsx`
2. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `<img>` 和 CSS 图片,等全部图片 settled 后才显示页面。图片不是本轮确认到的主等待项,但会放大 `.tsx` 冷转译后的可见延迟。
3. Vite dev server 监听范围过宽,日志中可见 `docs/``scripts/``server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加 `.tsx` 冷转译后表现为“首次加载一直等”。
## 3. 修复口径
### 3.1 首屏 `.tsx` 冷转译
默认首页入口先做低风险依赖图收敛:
- `App`、运行时阶段路由、面板路由避免从 barrel 文件导入,改为直连具体实现文件或类型文件。
- `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`
- 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab避免隐藏的创作页提前触发创作中心等懒加载模块。
- RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。
- 默认 `App` 不再首屏调用 `useRpgRuntimeSession`。平台首页先挂载轻量 `PlatformEntryFlowShell`,用户选择世界、恢复存档或进入 RPG 运行态深链后,才懒加载完整 `RpgRuntimeApp` 和故事/战斗/NPC 交互 hooks。
- 平台入口 props 移除未使用的 `gameState`,避免轻量首页为了兼容旧签名初始化完整 RPG `GameState`
- 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。
### 3.2 首屏图片门控
图片门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”:
- 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。
- 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。
- 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。
- 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。
### 3.3 Vite 监听范围
Vite dev server 只对前端真实运行入口保持热更新敏感:
- 忽略 `docs/``server-rs/``scripts/``backend-rewrite-tasklist/``media/` 等非前端首屏运行目录。
- 忽略 `*.test.ts(x)` / `*.spec.ts(x)`,避免测试文件保存触发页面 reload。
- 保留 `src/``packages/shared/` 的正常变更反馈,因为它们仍是前端运行时依赖。
## 4. 验收标准
1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件。
2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk。
3. 默认首页不再同步加载 RPG story / combat / NPC interaction 运行态 hooks进入自定义世界或恢复存档后再加载完整运行态。
4. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。
5. 修改 `docs/``server-rs/``scripts/` 或测试文件时,不再触发前端页面 reload。
6. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。
7. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。

View File

@@ -0,0 +1,48 @@
# 前端页面独立路由路径说明
## 背景
平台入口、RPG 创作链、拼图创作链和大鱼吃小鱼创作链已经在 `PlatformEntryFlowShellImpl` 中通过 `selectionStage` 分阶段渲染。此前多数页面共享同一个浏览器路径,导致刷新、复制地址和浏览器前进后退时缺少清晰页面语义。
本轮目标是在不引入 React Router、不拆现有页面组件的前提下为现有主要页面分配稳定路径并让内部阶段切换同步浏览器地址。
## 路由原则
- `src/routing/appRoutes.tsx` 继续只负责应用级入口:正式主应用、拼图调试直达页、大鱼吃小鱼调试直达页。
- 正式主应用内部页面路径由 `src/routing/appPageRoutes.ts` 统一维护,不在组件里散落硬编码字符串。
- `/puzzle``/big-fish` 保持为玩法调试直达入口;正式链路中的拼图和大鱼运行页使用 `/runtime/puzzle``/runtime/big-fish`,避免语义冲突。
- 独立路径先解决页面阶段语义和浏览器前进后退;依赖运行中内存对象的详情页、结果页和运行页直接刷新后仍允许回退到平台首页或展示现有恢复态,不在本轮扩展资源 ID 深链加载。
## 页面路径表
| 页面阶段 | 路径 | 说明 |
| --- | --- | --- |
| `platform` | `/` | 平台首页、广场、我的、创作中心等主入口 |
| `detail` | `/worlds/detail` | RPG 世界详情页,依赖当前已选作品 |
| `agent-workspace` | `/creation/rpg/agent` | RPG Agent 共创工作区 |
| `custom-world-generating` | `/creation/rpg/generating` | RPG 世界草稿生成进度页 |
| `custom-world-result` | `/creation/rpg/result` | RPG 世界结果页与编辑页 |
| `big-fish-agent-workspace` | `/creation/big-fish/agent` | 大鱼吃小鱼 Agent 共创工作区 |
| `big-fish-result` | `/creation/big-fish/result` | 大鱼吃小鱼草稿结果页 |
| `big-fish-runtime` | `/runtime/big-fish` | 正式链路中的大鱼吃小鱼运行页 |
| `puzzle-agent-workspace` | `/creation/puzzle/agent` | 拼图 Agent 共创工作区 |
| `puzzle-result` | `/creation/puzzle/result` | 拼图草稿结果页 |
| `puzzle-gallery-detail` | `/gallery/puzzle/detail` | 拼图作品详情页,依赖当前已选作品 |
| `puzzle-runtime` | `/runtime/puzzle` | 正式链路中的拼图运行页 |
| RPG 选角页 | `/runtime/rpg/characters` | 进入世界后、确认角色前的选角阶段 |
| RPG 冒险页 | `/runtime/rpg/adventure` | 已确认角色后的 RPG 主运行态 |
## 落地边界
- `useRpgRuntimeOverlayState` 初始化时从当前路径推导 `selectionStage`
- `setSelectionStage(...)` 被统一包一层,阶段变化时同步 `history.pushState`
- RPG 选角和冒险运行态由 `RpgRuntimeShell` 按当前 `gameState` 同步路径。
- 浏览器 `popstate` 时只回写 `selectionStage`,不重建详情页依赖的业务对象。
- 已有 `/puzzle``/big-fish` 调试入口继续由应用级路由分流,不进入 `selectionStage`
## 验收口径
1. 访问 `/creation/rpg/agent``/creation/puzzle/agent``/creation/big-fish/agent` 能进入主应用并初始化到对应页面阶段。
2. 从页面内切换到结果页、运行页或返回首页时,浏览器路径同步更新。
3. 浏览器后退/前进能驱动 `selectionStage` 回到对应页面。
4. `/puzzle``/big-fish` 仍进入原有玩法调试直达页。

View File

@@ -0,0 +1,31 @@
# Jenkins 部署环境文件 BOM 修复
日期:`2026-04-25`
## 1. 问题
Jenkins 部署阶段执行固定目录内的 `start.sh` 时失败:
```text
/var/lib/jenkins/deploy/Genarrative/.env.local: line 1: VITE_LLM_BASE_URL=...: No such file or directory
```
根因是 `.env.local` 第一行包含 UTF-8 BOM。旧版 `start.sh` 直接 `source .env.local`BOM 会成为变量名前缀Bash 无法按赋值语句解析,进而把整行当作命令执行。日志末尾的 sudo 提示只是 hook 执行失败后的兜底提示,不是本次失败的真实根因。
## 2. 修复口径
1. 发布包构建脚本复制 `.env``.env.local` 到发布目录和 `web/` 目录后,统一移除 UTF-8 BOM 与 CRLF。
2. Jenkins 部署脚本在移动发布产物前后,再次净化发布目录和固定部署目录中的 `.env``.env.local`,兼容已经构建出来但尚未部署成功的旧发布包。
3. 新生成的 `start.sh` 不再直接 `source` 环境文件,而是按 `KEY=value` 子集解析、导出合法变量,并跳过空行、注释和不合法行。
4. `start.sh` 仍保留 `.env` 先于 `.env.local` 的加载顺序,后加载的 `.env.local` 可以覆盖默认配置。
## 3. 运行边界
1. 环境文件应保持 UTF-8 文本,允许 UTF-8 BOM 和 CRLF但部署脚本会在发布目录中消除它们。
2. 环境变量名必须符合 `[A-Za-z_][A-Za-z0-9_]*`
3. 值支持不加引号、双引号和单引号;复杂 shell 表达式不会执行,避免把环境文件变成脚本入口。
4. 业务密钥仍通过目标服务器环境变量或发布目录 `.env.local` 管理,不写入 Jenkinsfile。
## 4. 失败现场恢复
如果 Jenkins 已经生成了失败版本,可以在拉取本次脚本修复后直接重跑部署流水线。`scripts/jenkins-deploy-release.sh` 会在执行新版本 `start.sh` 前净化已有发布目录,因此不要求手工编辑服务器上的 `.env.local`

View File

@@ -0,0 +1,202 @@
# Jenkins Rust 构建与部署流水线方案
日期:`2026-04-23`
## 1. 目标
本方案为当前仓库补齐 3 条 Jenkins 流水线:
1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。
2. `部署`:只负责把指定发布版本部署到 `/var/lib/jenkins/deploy/Genarrative/`,允许人工按参数启动,并支持按参数决定是否清空 SpacetimeDB 数据。
3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `80`,并透传是否清库。
本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。
## 2. 执行约束
1. 构建产物目录统一使用 `build/<版本号>/`
2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`
3. `构建``构建并部署``checkout scm` 后、实际构建前必须执行 `git reset --hard HEAD``git clean -fd`,避免固定源码目录内的 Git 变更和未跟踪文件影响发布包;不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。
4. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败。
5. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致。
6. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁。
7. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录。
8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。
9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。
10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。
## 3. 节点与工作区要求
这套方案依赖“本地目录发布”,因此有两个前提:
1. `构建并部署``部署` 必须落到同一台 Ubuntu Jenkins Agent或者落到同一块共享文件系统。
2. `构建并部署` 触发 `部署` 时,必须把 `SOURCE_NODE_NAME``SOURCE_WORKSPACE_ROOT` 一并传下去。
仓库中提供的 Jenkinsfile 已按这个约束实现:
1. `构建` / `构建并部署` 在指定源码目录内 `checkout scm` 并生成 `build/<版本号>/`
2. `构建并部署` 结束构建节点占用后,再触发 `部署`
3. `部署` 优先按 `SOURCE_NODE_NAME` 调度到同名节点,再读取 `SOURCE_WORKSPACE_ROOT/build/<版本号>/`
## 4. 三条流水线定义
### 4.1 构建
脚本路径:
```text
jenkins/Jenkinsfile.build
```
核心流程:
1. `checkout scm` 后执行 `git reset --hard HEAD``git clean -fd` 清理工作区。
2. 可选执行 `npm ci`
3. 在源码根目录执行:
```bash
npm run deploy:rust:remote -- --skip-upload --name <BUILD_VERSION>
```
4. 校验 `build/<BUILD_VERSION>/` 存在。
5. 归档 `build/<BUILD_VERSION>/**` 作为 Jenkins 产物。
默认版本号:
```text
BUILD_VERSION = Jenkins BUILD_NUMBER
```
### 4.2 部署
脚本路径:
```text
jenkins/Jenkinsfile.deploy
```
核心流程:
1. 读取触发原因;人工启动时跳过上游门禁,上游触发时同时兼容 `BuildUpstreamCause` 与经典 `UpstreamCause` 并继续校验上游作业名。
2. 校验 `BUILD_VERSION``SOURCE_WORKSPACE_ROOT``DEPLOY_DIRECTORY` 非空。
3. 执行:
```bash
scripts/jenkins-deploy-release.sh \
--source-dir <SOURCE_WORKSPACE_ROOT>/build/<BUILD_VERSION> \
--deploy-dir /var/lib/jenkins/deploy/Genarrative \
[--clear-database] \
--hook-with-sudo
```
脚本语义:
1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`
2. 只删除发布产物白名单中的旧文件,例如 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md`
3. 将指定版本目录中的同名发布产物移动到部署目录。
4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c always`
5. 执行新版本 `start.sh`
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF避免 `start.sh` 在 Bash 下把首行变量名误解析成命令。
### 4.3 构建并部署
脚本路径:
```text
jenkins/Jenkinsfile.build-and-deploy
```
核心流程:
1. `checkout scm` 后执行 `git reset --hard HEAD``git clean -fd` 清理工作区。
2. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`
3. 归档 `build/<BUILD_VERSION>/**`
4. 记录当前 `NODE_NAME`、源码根目录、版本号。
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `80` 的发布包。
6. 触发 `部署` 流水线,并传递:
- `BUILD_VERSION`
- `SOURCE_WORKSPACE_ROOT`
- `SOURCE_NODE_NAME`
- `DEPLOY_DIRECTORY`
- `CLEAR_DATABASE`
- `EXPECTED_UPSTREAM_JOB`
## 5. Jenkins 参数建议
三条流水线统一建议暴露以下参数:
1. `AGENT_LABEL`:默认执行节点标签。
2. `GENARRATIVE_WORKSPACE_ROOT`:源码根目录;为空时回退到 Jenkins 当前工作区。
3. `BUILD_VERSION`:发布版本号;为空时回退到 `BUILD_NUMBER`
4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`
5. `WEB_PORT`:发布包内静态网站监听端口;`构建并部署` 默认值为 `80`
6. `CLEAR_DATABASE`:部署阶段是否清空 SpacetimeDB 数据后再发布 wasm默认 `false`
如果当前 Jenkins 没有额外配置独立 Agent而是直接在控制器自身执行任务`AGENT_LABEL` 应填写 `built-in`
如果 Jenkins 进程以默认 `jenkins` 用户运行,部署目录建议直接放在 `/var/lib/jenkins/deploy/Genarrative` 这类 Jenkins 自有目录下,避免再依赖 `/home/ubuntu/*` 的额外写权限。
如果目标 Ubuntu 的 Jenkins `sh` 默认实际落到 `/bin/sh -> dash`,而流水线脚本又使用了 `set -euo pipefail`,则必须显式通过 `bash -lc` 执行命令,不能直接依赖 Jenkins 默认 `sh` 解释器。
其中仅 `部署` 流水线还需要:
1. `SOURCE_WORKSPACE_ROOT`
2. `SOURCE_NODE_NAME`
3. `DEPLOY_DIRECTORY`
4. `CLEAR_DATABASE`
5. `RUN_DEPLOY_HOOKS_WITH_SUDO`
6. `EXPECTED_UPSTREAM_JOB`
其中仅 `构建并部署` 流水线还需要:
1. `DEPLOY_JOB_NAME`
2. `RUN_DEPLOY_HOOKS_WITH_SUDO`
3. `WEB_PORT`
4. `CLEAR_DATABASE`
如果你选择启用 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,推荐提前在服务器上增加一份最小 sudoers 配置,例如:
```text
jenkins ALL=(root) NOPASSWD: /var/lib/jenkins/deploy/Genarrative/start.sh
jenkins ALL=(root) NOPASSWD: /var/lib/jenkins/deploy/Genarrative/stop.sh
```
这样可以把提权范围收敛到固定部署目录下的启停脚本,而不是把整个部署流程都交给 `sudo`
## 6. 推荐 Job 命名
建议在 Jenkins 中创建以下 3 个 Pipeline Job并分别指向仓库中的脚本路径
1. `Genarrative-Build` -> `jenkins/Jenkinsfile.build`
2. `Genarrative-Deploy` -> `jenkins/Jenkinsfile.deploy`
3. `Genarrative-Build-And-Deploy` -> `jenkins/Jenkinsfile.build-and-deploy`
同时给 `Genarrative-Deploy` 配置环境变量:
```text
GENARRATIVE_ALLOWED_UPSTREAM_JOB=Genarrative-Build-And-Deploy
```
如果 Job 在 Jenkins Folder 下,值应填写完整上游作业名,例如:
```text
game/Genarrative-Build-And-Deploy
```
## 7. 文件清单
本方案对应的仓库文件:
```text
jenkins/Jenkinsfile.build
jenkins/Jenkinsfile.deploy
jenkins/Jenkinsfile.build-and-deploy
scripts/jenkins-deploy-release.sh
```
## 8. 风险与边界
1. 该方案依赖本地目录切换,不适用于“构建节点”和“部署节点”完全隔离且不共享文件系统的 Jenkins 架构。
2. 当前 `部署` 采取的是“覆盖固定部署目录”的方式,不包含版本回滚目录管理;如需保留完整历史版本,应在后续单独补一层 release/current 软链接结构。
3. 当前 `start.sh` / `stop.sh` 仍以发布包内脚本为准,不替代 `systemd``supervisor``nginx``tls` 与日志轮转治理。

View File

@@ -0,0 +1,299 @@
# M3browse history Axum + SpacetimeDB 落地设计
日期:`2026-04-21`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
关联现状:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
- `packages/shared/src/contracts/runtime.ts`
## 1. 文档目的
`02_M3_RUNTIME_PROFILE.md` 已经把 `user_browse_history``browse history` facade 列入 M3但还没有冻结到可直接编码的字段、去重规则、路由兼容方式和错误语义。
本文只解决 `browse history` 这一个最小闭环切片:
1. `user_browse_history` 真相表
2. `GET /api/runtime/profile/browse-history`
3. `POST /api/runtime/profile/browse-history`
4. `DELETE /api/runtime/profile/browse-history`
5. `/api/profile/browse-history` 兼容路径
本文不新建 checklist不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足本轮编码所需的冻结口径。
## 2. 旧实现冻结口径
当前 Node 侧行为来自:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
冻结行为如下。
### 2.1 路由
主路径与兼容路径都必须保留:
1. `GET /api/runtime/profile/browse-history`
2. `POST /api/runtime/profile/browse-history`
3. `DELETE /api/runtime/profile/browse-history`
4. `GET /api/profile/browse-history`
5. `POST /api/profile/browse-history`
6. `DELETE /api/profile/browse-history`
所有路径都要求 Bearer JWT。
### 2.2 数据字段
单条浏览记录字段与 `packages/shared/src/contracts/runtime.ts` 保持一致:
1. `ownerUserId`
2. `profileId`
3. `worldName`
4. `subtitle`
5. `summaryText`
6. `coverImageSrc`
7. `themeMode`
8. `authorDisplayName`
9. `visitedAt`
### 2.3 POST 请求体兼容
`POST` 同时支持两种形态:
1. 单条对象
2. `{ entries: [...] }` 批量对象
批量最多 `100` 条。
### 2.4 归一化规则
旧 Node 仓储不是严格校验,而是宽松归一化:
1. `ownerUserId``profileId``worldName` 去首尾空白后必须非空,否则该条忽略。
2. `subtitle``summaryText``coverImageSrc` 去首尾空白,空串按空值处理。
3. `themeMode` 不做严格枚举校验,未知值统一回落到 `mythic`
4. `authorDisplayName` 空值回落到 `玩家`
5. `visitedAt` 缺失时回落到当前时间。
### 2.5 去重与排序规则
旧 Node 仓储的关键行为必须保持:
1. 去重键:`ownerUserId + profileId`
2. 同一批写入时,先按 `visitedAt DESC` 排序,再去重,只保留最新一条
3. 表内最终查询结果按 `visitedAt DESC` 返回
### 2.6 清空行为
`DELETE` 清空当前用户的全部浏览历史,并返回:
```json
{
"entries": []
}
```
## 3. Rust 落位决议
### 3.1 crate 分工
本切片固定按以下边界落位:
1. `crates/module-runtime`
- 定义 `browse history` DTO、字段校验、去重排序与宽松归一化规则。
2. `crates/spacetime-module`
- 定义 `user_browse_history` 表。
- 提供 `list / upsert / clear` 三个 procedure。
3. `crates/spacetime-client`
- 提供 `list_platform_browse_history`
- 提供 `upsert_platform_browse_history_entries`
- 提供 `clear_platform_browse_history`
4. `crates/shared-contracts`
- 冻结 Axum facade 的请求/响应 DTO。
5. `crates/api-server`
- 提供双路径兼容 facade。
- 保持 envelope / 错误格式与当前 `runtime settings` 一致。
### 3.2 身份边界
本轮仍沿用 Axum Bearer JWT 作为唯一鉴权边界:
1. `require_bearer_auth` 校验 token。
2. 从 claims 中提取 `user_id`
3. `user_id` 作为 procedure 入参传入 SpacetimeDB。
当前阶段不把浏览历史直接暴露给前端直连订阅。
## 4. SpacetimeDB 表设计
### 4.1 表名
`user_browse_history`
### 4.2 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `browse_history_id` | `String` | 主键,格式为 `user_id:owner_user_id:profile_id` |
| `user_id` | `String` | 当前登录用户 |
| `owner_user_id` | `String` | 被浏览世界所属用户 |
| `profile_id` | `String` | 被浏览世界 profile |
| `world_name` | `String` | 世界名 |
| `subtitle` | `String` | 副标题 |
| `summary_text` | `String` | 摘要 |
| `cover_image_src` | `Option<String>` | 封面图 |
| `theme_mode` | `RuntimeBrowseHistoryThemeMode` | 主题枚举 |
| `author_display_name` | `String` | 作者显示名 |
| `visited_at` | `Timestamp` | 最近访问时间 |
| `created_at` | `Timestamp` | 首次写入时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 4.3 索引
至少保留以下访问路径:
1. 主键 `browse_history_id`
2. `(user_id, visited_at)` 用于当前用户按时间倒序列出
3. `(user_id, owner_user_id, profile_id)` 用于幂等 upsert
### 4.4 设计决议
1. 不额外引入自增 ID直接把幂等键收口成主键。
2. `visited_at` 单独持久化成 `Timestamp`,避免把时间排序退回字符串比较。
3. `theme_mode` 在表内固定为枚举,但 Axum 输入仍宽松接受字符串。
## 5. Procedure 设计
### 5.1 procedure 列表
1. `list_platform_browse_history`
2. `upsert_platform_browse_history_and_return`
3. `clear_platform_browse_history_and_return`
统一返回:
```text
RuntimeBrowseHistoryProcedureResult {
ok
entries
error_message
}
```
### 5.2 行为约束
`list_platform_browse_history`
1. 校验 `user_id`
2. 读取当前用户所有记录
3.`visited_at DESC` 返回
`upsert_platform_browse_history_and_return`
1. 校验 `user_id`
2. 接受单批最多 `100`
3. 先按旧 Node 规则宽松归一化
4. 先按 `visitedAt DESC` 排序,再按 `ownerUserId + profileId` 去重
5.`browse_history_id` 幂等 upsert
6. 返回当前用户完整倒序列表
`clear_platform_browse_history_and_return`
1. 校验 `user_id`
2. 删除当前用户全部记录
3. 返回空列表
## 6. Axum facade 设计
### 6.1 双路径兼容
两组路径必须共用同一组 handler
1. `/api/runtime/profile/browse-history`
2. `/api/profile/browse-history`
只允许路由名不同,不允许行为分叉。
### 6.2 GET
行为:
1. Bearer JWT 校验
2. 读取 claims 中的 `user_id`
3.`spacetime_client.list_platform_browse_history`
4. 返回 `PlatformBrowseHistoryResponse`
### 6.3 POST
行为:
1. Bearer JWT 校验
2. 通过 `serde(untagged)` 同时接单条和批量 shape
3. 不对 `themeMode` 做严格 400 拒绝
4.`ownerUserId``profileId``worldName` 的缺失或空串按旧 Node 路由规则直接返回 `400`
5. 写入成功后返回最新完整列表
### 6.4 DELETE
行为:
1. Bearer JWT 校验
2. 清空当前用户全部记录
3. 返回 `entries: []`
### 6.5 错误映射
1. JSON 解析失败:`400 BAD_REQUEST`
2. DTO 构建失败:`400 BAD_REQUEST`
3. SpacetimeDB 调用失败:`502 BAD_GATEWAY`
4. JWT 缺失或失效:沿用当前 `401 UNAUTHORIZED`
错误 `details.provider` 固定为:
1. `browse-history`
2. `spacetimedb`
## 7. 测试策略
### 7.1 必跑测试
1. `module-runtime`
- 宽松 theme 归一化
- `visitedAt` 默认值
- 去重与倒序逻辑
2. `api-server`
- 未登录返回 `401`
- 兼容路径与主路径一致
- `POST` 同时支持单条和批量
- envelope 打开时错误结构稳定
### 7.2 可选联调测试
保留 `#[ignore]` 的本地 SpacetimeDB 集成测试:
1. `POST -> GET`
2. `DELETE -> GET`
## 8. 本文完成定义
当以下条件成立时,本设计视为完成:
1. `user_browse_history` 表字段、主键和排序规则已冻结。
2. 双路径 facade、请求 shape 和错误契约已冻结。
3. 后续编码不再需要猜测:
- `themeMode` 是否严格校验
- `POST` 是否支持单条/批量双 shape
- 去重时机与排序依据
## 9. 2026-04-22 实际落地进度
1. `module-runtime` 已切换为“API 入口严格校验 + 领域层静默过滤”的旧 Node 对齐模式。
2. `api-server` 已补齐双路径 browse history handler并补 `401``400`、批量 shape、兼容路径一致性测试。
3. 剩余阻塞主要在工作树内其他并行任务带来的 Rust 编译占用与跨模块联调,不属于 browse history 方案本身。

View File

@@ -0,0 +1,410 @@
# M3profile dashboard / wallet ledger / play stats Axum + SpacetimeDB 落地设计
日期:`2026-04-22`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
关联现状:
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md)
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
## 1. 文档目的
当前 M3 checklist 已经列出:
1. `profile_dashboard_state`
2. `profile_wallet_ledger`
3. `profile_played_world`
4. `/api/runtime/profile/dashboard`
5. `/api/runtime/profile/wallet-ledger`
6. `/api/runtime/profile/play-stats`
但仓库里还没有把这一组 profile 只读 facade 细化到可以直接编码的程度。本文件补足:
1. 旧 Node 行为冻结
2. SpacetimeDB projection 表字段
3. procedure 返回 contract
4. Axum 双路径 facade 与错误映射
5. 本轮只做读链、不提前承诺 snapshot 写入的边界
本文件不新增新的 M3 checklist只服务于现有 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md) 的后续落地。
## 2. 本轮范围
本轮只覆盖以下 6 条兼容路由:
1. `GET /api/runtime/profile/dashboard`
2. `GET /api/profile/dashboard`
3. `GET /api/runtime/profile/wallet-ledger`
4. `GET /api/profile/wallet-ledger`
5. `GET /api/runtime/profile/play-stats`
6. `GET /api/profile/play-stats`
本轮不做:
1. `runtime_snapshot`
2. `save archive`
3. snapshot -> profile projection 自动刷新
4. profile projection 的写 procedure
这样拆分的原因是:
1. 这组三个 profile 接口本质上都是 projection 读接口。
2. 旧 Node 读语义已经稳定,且空数据时都有明确默认值。
3. 先把读 contract 和表结构固定住,后续 `runtime_snapshot / save archive` 接上 projection writer 时不会再改 facade contract。
## 3. 旧 Node 行为冻结
Node 侧入口位于:
1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
2. `server-node/src/repositories/runtimeRepository.ts`
冻结口径如下。
### 3.1 dashboard
路由:
1. `GET /api/runtime/profile/dashboard`
2. `GET /api/profile/dashboard`
返回:
```json
{
"walletBalance": 0,
"totalPlayTimeMs": 0,
"playedWorldCount": 0,
"updatedAt": null
}
```
语义:
1. `walletBalance``profile_dashboard_state.wallet_balance` 读取。
2. `totalPlayTimeMs``profile_dashboard_state.total_play_time_ms` 读取。
3. `playedWorldCount` 通过 `profile_played_world` 的当前用户记录数计算。
4. `updatedAt` 为空时返回 `null`
5. 当用户尚无任何 projection 时,仍返回默认零值,不返回 `404`
### 3.2 wallet ledger
路由:
1. `GET /api/runtime/profile/wallet-ledger`
2. `GET /api/profile/wallet-ledger`
返回:
```json
{
"entries": [
{
"id": "ledger_001",
"amountDelta": 20,
"balanceAfter": 120,
"sourceType": "snapshot_sync",
"createdAt": "2026-04-22T10:00:00Z"
}
]
}
```
语义:
1. 只返回当前用户的流水。
2.`createdAt DESC` 排序。
3. 最多返回最近 `50` 条。
4. 当前旧 Node 仅冻结 `sourceType = "snapshot_sync"` 一种来源。
5. 没有流水时返回 `{ "entries": [] }`
### 3.3 play stats
路由:
1. `GET /api/runtime/profile/play-stats`
2. `GET /api/profile/play-stats`
返回:
```json
{
"totalPlayTimeMs": 0,
"playedWorks": [],
"updatedAt": null
}
```
其中 `playedWorks` 单项字段冻结为:
```json
{
"worldKey": "builtin:WUXIA",
"ownerUserId": null,
"profileId": null,
"worldType": "WUXIA",
"worldTitle": "武侠世界",
"worldSubtitle": "",
"firstPlayedAt": "2026-04-20T10:00:00Z",
"lastPlayedAt": "2026-04-22T10:00:00Z",
"lastObservedPlayTimeMs": 120000
}
```
语义:
1. `totalPlayTimeMs` 与 dashboard 共用 `profile_dashboard_state.total_play_time_ms`
2. `playedWorks` 来自 `profile_played_world`
3.`lastPlayedAt DESC` 排序。
4. `updatedAt` 与 dashboard 共用 `profile_dashboard_state.updated_at`
5. 没有 projection 时返回空列表和零值,不返回 `404`
## 4. 本轮边界决议
### 4.1 先做 projection 读链
本轮 profile 三接口只做:
1. projection 表 schema
2. procedure 读接口
3. Axum facade
4. shared contract
不做 snapshot 写链,原因:
1. `runtime_snapshot` 仍未冻结最终表结构。
2. save archive 还未把“领域表真相 + 聚合快照”方案完全落到文档。
3. 若现在提前补写逻辑,后续大概率要因为 snapshot 方案调整而返工。
### 4.2 默认值必须前置兼容
虽然 projection 还没有 writer但 facade 仍要先兼容旧 Node 默认值:
1. dashboard 返回零值
2. wallet ledger 返回空数组
3. play stats 返回零值 + 空数组
这样前端不会因为表暂时为空而收到 `404``null` 结构漂移。
## 5. SpacetimeDB 表设计
### 5.1 `profile_dashboard_state`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键,绑定平台用户 |
| `wallet_balance` | `u64` | 当前钱包余额 |
| `total_play_time_ms` | `u64` | 累积游玩时长 |
| `created_at` | `Timestamp` | projection 首次建立时间 |
| `updated_at` | `Timestamp` | projection 最近刷新时间 |
设计决议:
1. 一名用户只保留一行 dashboard 聚合状态。
2. `playedWorldCount` 不单独持久化,读取时直接统计 `profile_played_world`
3. 钱包余额与总游玩时长都固定为非负整数,不保留浮点。
### 5.2 `profile_wallet_ledger`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `wallet_ledger_id` | `String` | 主键,流水 ID |
| `user_id` | `String` | 用户 ID |
| `amount_delta` | `i64` | 本次余额增减 |
| `balance_after` | `u64` | 变动后的余额 |
| `source_type` | `RuntimeProfileWalletLedgerSourceType` | 当前只冻结 `snapshot_sync` |
| `created_at` | `Timestamp` | 流水发生时间 |
设计决议:
1. 钱包流水是 append-only不提供 update。
2. 本轮只冻结 `snapshot_sync` 一种来源,避免前后端散落裸字符串。
3. 读取排序由 procedure 保证,不依赖表天然顺序。
### 5.3 `profile_played_world`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `played_world_id` | `String` | 主键,固定为 `user_id:world_key` |
| `user_id` | `String` | 用户 ID |
| `world_key` | `String` | 世界唯一键,兼容内置世界与自定义世界 |
| `owner_user_id` | `Option<String>` | 自定义世界作者用户 ID |
| `profile_id` | `Option<String>` | 自定义世界 profile ID |
| `world_type` | `Option<String>` | 内置世界类型,例如 `WUXIA` |
| `world_title` | `String` | 世界标题 |
| `world_subtitle` | `String` | 世界副标题 |
| `first_played_at` | `Timestamp` | 首次游玩时间 |
| `last_played_at` | `Timestamp` | 最近游玩时间 |
| `last_observed_play_time_ms` | `u64` | 最近一次观测到的该世界累计游玩时长 |
设计决议:
1. 每个用户每个 `world_key` 只保留一行。
2. `played_world_id = user_id:world_key`,避免额外自增 ID。
3. `lastObservedPlayTimeMs` 保留在表中,为后续 snapshot sync 计算增量时长服务。
## 6. module-runtime DTO 设计
本轮在 `module-runtime` 新增以下类型族:
1. `RuntimeProfileDashboardSnapshot`
2. `RuntimeProfileDashboardProcedureResult`
3. `RuntimeProfileDashboardGetInput`
4. `RuntimeProfileWalletLedgerEntrySnapshot`
5. `RuntimeProfileWalletLedgerProcedureResult`
6. `RuntimeProfileWalletLedgerListInput`
7. `RuntimeProfilePlayedWorldSnapshot`
8. `RuntimeProfilePlayStatsSnapshot`
9. `RuntimeProfilePlayStatsProcedureResult`
10. `RuntimeProfilePlayStatsGetInput`
同时新增 record 层 DTO`spacetime-client` 返回给 Axum
1. `RuntimeProfileDashboardRecord`
2. `RuntimeProfileWalletLedgerEntryRecord`
3. `RuntimeProfilePlayedWorldRecord`
4. `RuntimeProfilePlayStatsRecord`
字段规则:
1. 所有时间在 snapshot 内部统一保存为 `*_micros`
2. record 层统一格式化成 RFC3339 字符串。
3. `updated_at_micros` 使用 `Option<i64>`,避免继续沿用 `0` 这种弱语义占位值。
## 7. Procedure 设计
本轮只新增 3 个只读 procedure
1. `get_profile_dashboard`
2. `list_profile_wallet_ledger`
3. `get_profile_play_stats`
行为要求:
### 7.1 `get_profile_dashboard`
1. 校验 `user_id` 非空。
2. 读取 `profile_dashboard_state`
3. 统计当前用户 `profile_played_world` 数量。
4. 如果 dashboard 状态不存在,返回零值快照。
### 7.2 `list_profile_wallet_ledger`
1. 校验 `user_id` 非空。
2. 读取当前用户全部流水。
3.`created_at DESC` 排序。
4. 截断到最近 `50` 条。
### 7.3 `get_profile_play_stats`
1. 校验 `user_id` 非空。
2.`profile_dashboard_state` 读取 `total_play_time_ms``updated_at`
3. 读取当前用户 `profile_played_world`
4.`last_played_at DESC` 排序。
5. 如果 dashboard 状态不存在,仍返回零值与空数组。
## 8. spacetime-client 设计
新增 3 个调用封装:
1. `get_profile_dashboard(user_id)`
2. `list_profile_wallet_ledger(user_id)`
3. `get_profile_play_stats(user_id)`
错误映射保持当前链路习惯:
1. 本地 DTO 构建失败 -> `SpacetimeClientError::Runtime`
2. procedure 执行失败 -> `SpacetimeClientError::Procedure`
不在 client 层做默认值兜底;默认值由 `spacetime-module` procedure 保证,避免多个调用方重复实现。
## 9. Axum facade 设计
### 9.1 路由
本轮 Rust facade 固定暴露 6 条路由:
1. `/api/runtime/profile/dashboard`
2. `/api/profile/dashboard`
3. `/api/runtime/profile/wallet-ledger`
4. `/api/profile/wallet-ledger`
5. `/api/runtime/profile/play-stats`
6. `/api/profile/play-stats`
全部要求 Bearer JWT。
### 9.2 响应结构
1. dashboard 直接返回 `ProfileDashboardSummaryResponse`
2. wallet-ledger 返回 `ProfileWalletLedgerResponse`
3. play-stats 返回 `ProfilePlayStatsResponse`
字段名保持 camelCase与旧 Node contract 对齐。
### 9.3 错误映射
1. JWT 缺失或失效:沿用现有 `401`
2. 本地 DTO 准备失败:`400`
3. SpacetimeDB 调用失败:`502`
`details.provider` 规则:
1. 本地 DTO 错误使用当前接口自己的 provider
2. 下游 SpacetimeDB 错误统一使用 `spacetimedb`
## 10. 本轮暂不处理的事项
以下事项在本设计中显式延后:
1. `runtime_snapshot` 写入时如何刷新三张 profile projection 表
2. `profile_wallet_ledger` 的更多 `source_type`
3. `profile_played_world` 的世界标题修复、补字段或回填历史迁移
4. `save archive``play stats` 之间的联动
这些都等 `runtime_snapshot / save archive` 主链文档冻结后继续推进。
## 11. 测试策略
### 11.1 必跑
1. `module-runtime`
- `user_id` 非空校验
- record 层时间格式化
- wallet ledger source type 字符串格式化
2. `shared-contracts`
- dashboard / wallet-ledger / play-stats 的 camelCase 序列化
3. `api-server`
- 未登录返回 `401`
- 6 条 facade 都已挂接
- SpacetimeDB 未发布时返回 `502`
- 主路径与兼容路径错误 envelope 一致
### 11.2 本轮不强制
1. 不强制本地 SpacetimeDB 联调测试
2. 不强制 projection 写入集成测试
原因是这两类测试都依赖后续 `runtime_snapshot` 写链补齐。
## 12. 本文完成定义
当以下条件满足时,本设计文档视为完成:
1. `profile_dashboard_state / profile_wallet_ledger / profile_played_world` 字段与 ID 规则已冻结。
2. `dashboard / wallet-ledger / play-stats` 的 procedure 名、返回结构、排序与默认值已冻结。
3. `api/runtime/*` 与兼容 `/api/profile/*` 双路径已冻结。
4. 可以据此直接开始 `module-runtime``shared-contracts``spacetime-module``spacetime-client``api-server` 编码。

View File

@@ -0,0 +1,276 @@
# M3runtime settings Axum + SpacetimeDB 落地设计
日期:`2026-04-21`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
关联现状:
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
- [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md)
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
## 1. 文档目的
`02_M3_RUNTIME_PROFILE.md` 已经冻结了 M3 的任务范围但还没有把首批可编码切片细化到表字段、procedure、Axum facade、兼容错误格式和测试策略。
本文件只解决 M3 第一批最小纵向切片:
1. `GET /api/runtime/settings`
2. `PUT /api/runtime/settings`
以及其在 Rust 重写中的完整落位:
1. `module-runtime` 的字段约束与 DTO
2. `crates/spacetime-module``runtime_setting` 表与 procedure
3. `crates/spacetime-client` 的 procedure 调用封装
4. `crates/api-server` 的兼容 facade 与响应 contract
本文件不新增 checklist不替代 [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md),只补足可以直接编码的技术口径。
## 2. 为什么先做 runtime settings
在 M3 范围内,`runtime settings` 是当前最适合先迁移的纵向切片:
1. 读写模型最小,只依赖 `user_id + music_volume + platform_theme`
2. 旧 Node 逻辑没有跨表聚合、副作用和复杂 projection。
3. 前端 contract 清晰,兼容路径只有一条,不涉及 `/api/profile/*` 双路径。
4. 可以先把 `Axum -> JWT -> SpacetimeDB procedure -> 标准 envelope` 主链跑通,为后续 `browse history / snapshot / save archive / dashboard` 复用。
## 3. 旧实现冻结口径
当前 Node 侧 `runtime settings` 行为来自:
- `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
冻结行为如下:
### 3.1 路由
- `GET /api/runtime/settings`
- `PUT /api/runtime/settings`
两条接口都要求 JWT。
### 3.2 请求体
`PUT /api/runtime/settings` 请求体:
```json
{
"musicVolume": 0.42,
"platformTheme": "light"
}
```
校验规则:
1. `musicVolume` 必须在 `0 ~ 1`
2. `platformTheme` 只接受 `light | dark`
### 3.3 默认值
默认值来自 `packages/shared/src/contracts/runtime.ts`
1. `DEFAULT_MUSIC_VOLUME = 0.42`
2. `DEFAULT_PLATFORM_THEME = "light"`
当用户从未写入过设置时,读取接口必须返回默认值,而不是 `404``null`
### 3.4 归一化规则
旧 Node 写入时会做以下归一化:
1. `musicVolume` 强制 clamp 到 `0 ~ 1`
2. `platformTheme` 如果不是 `dark`,统一回退到 `light`
Rust 重写阶段仍保持同样语义,避免前端产生行为漂移。
## 4. Rust 落位决议
### 4.1 crate 分工
本切片固定按以下边界落位:
1. `crates/module-runtime`
- 定义 `RuntimeSettings` 领域 DTO、默认值、字段校验与归一化规则。
2. `crates/spacetime-module`
- 定义 `runtime_setting` 表。
- 提供 `upsert_runtime_setting_and_return` procedure。
3. `crates/spacetime-client`
- 提供 `get_runtime_settings``put_runtime_settings` 调用封装。
4. `crates/api-server`
- 提供 `GET/PUT /api/runtime/settings`
- 保持当前 envelope / 错误格式 / 请求头兼容。
### 4.2 身份边界
当前阶段前端仍只访问 Axum不直连 SpacetimeDB。
因此:
1. 用户身份仍由 Axum 侧 JWT middleware 校验。
2. Axum 从已校验的 access token claims 中取 `user_id`
3. `user_id` 作为 procedure 入参写入 `runtime_setting`
注意:
1. 这不是最终的 SpacetimeDB 原生身份透传形态。
2. 在 M3 首批切片里,先以 Axum 作为唯一鉴权边界,保证与当前前端 contract 一致。
## 5. SpacetimeDB 表设计
### 5.1 表名
`runtime_setting`
### 5.2 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键,绑定平台用户 |
| `music_volume` | `f32` | 音量,持久化归一化后的值 |
| `platform_theme` | `RuntimePlatformTheme` | 平台主题枚举 |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 5.3 设计决议
1. 每个用户只保留一行设置,不做历史版本表。
2. `user_id` 直接作为主键,避免再引入无业务价值的自增 ID。
3. `platform_theme` 固定为枚举,不把 `light/dark` 继续散落成字符串字面量。
4. 首批阶段不把设置拆成多行 KV 表,避免简单需求被过度抽象。
## 6. Procedure 设计
### 6.1 不单独暴露 reducer 给 Axum
本切片优先提供 procedure而不是让 Axum 直接调 reducer + 再查询表。
原因:
1. 当前 `spacetime-client` 已经以 procedure 返回结果的模式承接资产链。
2. 设置接口需要同步返回最终写入结果procedure 可减少一次额外查询。
3. 当前 `runtime_setting` 不需要客户端订阅private table + procedure 更直接。
### 6.2 Procedure 列表
1. `get_runtime_setting_or_default`
2. `upsert_runtime_setting_and_return`
返回 DTO 固定为:
```text
RuntimeSettingSnapshot {
user_id
music_volume
platform_theme
created_at_micros
updated_at_micros
}
```
如果用户还没有设置记录:
1. `get_runtime_setting_or_default` 返回默认值快照。
2. 但不强制立即插入表,避免纯读取请求制造无意义写入。
## 7. Axum facade 设计
### 7.1 GET /api/runtime/settings
行为:
1.`require_bearer_auth`
2.`claims.user_id` 取用户 ID。
3.`spacetime_client.get_runtime_settings(user_id)`
4. 返回:
```json
{
"musicVolume": 0.42,
"platformTheme": "light"
}
```
### 7.2 PUT /api/runtime/settings
行为:
1.`require_bearer_auth`
2. 使用 Axum `Json` + `serde` 解析请求。
3.`module-runtime` 内做归一化。
4.`spacetime_client.put_runtime_settings(user_id, payload)`
5. 返回归一化后的最终值。
### 7.3 错误映射
1. 请求体解析失败:`400 BAD_REQUEST`
2. 字段校验失败:`400 BAD_REQUEST`
3. SpacetimeDB 调用失败:`502 BAD_GATEWAY`
4. JWT 缺失或失效:沿用现有 `401 UNAUTHORIZED`
错误 `details.provider` 固定为:
1. `runtime-settings`:本地字段归一化或 DTO 构建失败
2. `spacetimedb`procedure 调用失败
## 8. 首批测试策略
本切片测试分两层:
### 8.1 必跑测试
1. `module-runtime`
- 默认值
- clamp 规则
- theme 归一化
2. `api-server`
- 未登录返回 `401`
- 请求 envelope 打开时返回标准 `ok/data/error/meta`
- JSON 结构与字段名兼容
### 8.2 可选联调测试
补一条 `#[ignore]` 的集成测试:
1. 需要本地 SpacetimeDB 已启动
2. 需要当前 `spacetime-module` 已发布
3. 验证 `PUT -> GET` 能往返一致
原因:
1. 当前仓库已有资产链的 `#[ignore]` 集成测试模式。
2. 在未稳定建立测试 harness 前,不强制把 SpacetimeDB 作为默认单测前置条件。
## 9. 后续扩展顺序
`runtime settings` 完成后M3 后续能力按以下顺序推进:
1. `user_browse_history`
2. `runtime_snapshot`
3. `profile_save_archive`
4. `profile_dashboard_state + profile_wallet_ledger + profile_played_world`
顺序原因:
1. `browse_history` 仍是单表为主,只带去重与排序规则。
2. `snapshot``save_archive` 依赖兼容聚合策略,复杂度更高。
3. `dashboard / play-stats / wallet-ledger` 依赖 projection更适合放在 snapshot 规则固定后收口。
## 10. 本文完成定义
当以下条件成立时,本设计文档视为完成:
1. `runtime settings` 的字段、默认值、归一化规则、procedure 与 Axum facade 已书面冻结。
2. 后续编码无需再猜测:
- 表字段名
- 主键策略
- 默认值来源
- Axum 与 SpacetimeDB 的职责边界
3. 可以直接据此开始 `module-runtime``spacetime-module``spacetime-client``api-server` 编码。

View File

@@ -0,0 +1,274 @@
# M3runtime snapshot / save archive Axum + SpacetimeDB 落地设计
日期:`2026-04-22`
关联任务:
- [../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md](../../backend-rewrite-tasklist/02_M3_RUNTIME_PROFILE.md)
- [../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md](../../backend-rewrite-tasklist/M0_PHASE_ACCEPTANCE_MATRIX_2026-04-20.md)
关联现状:
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md)
- [M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md](./M3_PROFILE_DASHBOARD_AXUM_SPACETIMEDB_DESIGN_2026-04-22.md)
- `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts`
- `server-node/src/repositories/runtimeRepository.ts`
- `server-node/src/modules/runtime/runtimeSnapshotHydration.ts`
## 1. 文档目的
M3 剩余未完成的主链集中在三组能力:
1. `runtime_snapshot`
2. `profile_save_archive`
3. “领域表真相 + 兼容聚合快照”策略
本文件补足这些能力落地到 Rust 所需的编码级语义,确保可以直接实现:
1. `GET /api/runtime/save/snapshot`
2. `PUT /api/runtime/save/snapshot`
3. `DELETE /api/runtime/save/snapshot`
4. `GET /api/runtime/profile/save-archives`
5. `GET /api/profile/save-archives`
6. `POST /api/runtime/profile/save-archives/:worldKey`
7. `POST /api/profile/save-archives/:worldKey`
## 2. 旧 Node 行为冻结
### 2.1 当前 snapshot
路由:
1. `GET /api/runtime/save/snapshot`
2. `PUT /api/runtime/save/snapshot`
3. `DELETE /api/runtime/save/snapshot`
语义:
1. `GET` 无记录时返回 `null`,不是默认空快照。
2. `PUT` 请求体要求:
- `gameState: unknown`
- `bottomTab: string`
- `currentStory?: unknown | null`
- `savedAt?: string`
3. `PUT` 会先执行 snapshot normalize再写入当前快照。
4. `DELETE` 只删除当前快照,不删除 `profile_save_archive``profile_dashboard_state``profile_wallet_ledger``profile_played_world`
### 2.2 save archive
路由:
1. `GET /api/runtime/profile/save-archives`
2. `GET /api/profile/save-archives`
3. `POST /api/runtime/profile/save-archives/:worldKey`
4. `POST /api/profile/save-archives/:worldKey`
语义:
1. save archive 是“按世界聚合”的最近一次快照,不是多版本列表。
2. `worldKey` 冻结规则:
- 内置世界:`builtin:{worldType}`
- 自定义世界:优先 `custom:{profileId}`,否则 `custom:{worldTitle}`
3. `GET list``savedAt DESC` 排序。
4. `POST resume` 找不到记录时返回 `404`
5. `POST resume` 命中后会把 archive 重新写回当前 snapshot并返回
- `entry`
- `snapshot`
6. 旧 Node 在 `resume` 时只回填当前 snapshot不再次刷新 dashboard / save archive / custom world profile projection。
## 3. 兼容聚合快照策略
### 3.1 领域表真相
M3 当前统一采用:
1. `runtime_snapshot` 承接前端兼容恢复所需的原始运行时快照。
2. `profile_dashboard_state`
3. `profile_wallet_ledger`
4. `profile_played_world`
5. `profile_save_archive`
其中:
1. 当前快照真相是 `runtime_snapshot`
2. profile 聚合真相分别是各自 projection 表。
3. `profile_save_archive` 是“按世界聚合的最近一次快照副本”,用于列表恢复入口。
### 3.2 本轮策略
本轮不把 Node 里的超大 hydration 逻辑逐字段翻译进 `module-runtime`
本轮冻结为:
1. `PUT snapshot` 仍接收任意 JSON。
2. Axum 层只做最小兼容校验:
- `bottomTab` 非空
- `savedAt` 缺失时补当前时间
3. `bottomTab` 只接受:
- `adventure`
- `character`
- `inventory`
- 其他值统一回退到 `adventure`
4. `currentStory` 若不是 JSON object则回退为 `null`
5. `gameState` 必须可序列化为 JSON如不是 object仍允许原样存储 JSON 值
6. profile projection 刷新只依赖旧 Node 已冻结的少数字段抽取:
- `gameState.playerCurrency`
- `gameState.runtimeStats.playTimeMs`
- `gameState.worldType`
- `gameState.currentScenePreset`
- `gameState.customWorldProfile`
- `gameState.storyEngineMemory.continueGameDigest`
- `currentStory.text`
这样做的原因:
1. 先保证 M3 的保存、读取、恢复主链跑通。
2. 避免把 `runtimeSnapshotHydration.ts` 的大量历史兼容逻辑一次性搬进 Rust造成 M3 范围膨胀。
3. 不阻断当前前端恢复链路,因为前端写入的主体仍是 JSON 快照。
## 4. SpacetimeDB 表设计
### 4.1 `runtime_snapshot`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `user_id` | `String` | 主键 |
| `version` | `u32` | 当前固定 `2` |
| `saved_at` | `Timestamp` | 快照保存时间 |
| `bottom_tab` | `String` | 当前底部标签 |
| `game_state_json` | `String` | 原始 gameState JSON 字符串 |
| `current_story_json` | `Option<String>` | 原始 currentStory JSON 字符串 |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
### 4.2 `profile_save_archive`
字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `archive_id` | `String` | 主键,固定为 `user_id:world_key` |
| `user_id` | `String` | 用户 ID |
| `world_key` | `String` | 世界唯一键 |
| `owner_user_id` | `Option<String>` | 当前先冻结为 `None` |
| `profile_id` | `Option<String>` | 自定义世界 profile ID |
| `world_type` | `Option<String>` | 世界类型 |
| `world_name` | `String` | 存档展示名 |
| `subtitle` | `String` | 副标题 |
| `summary_text` | `String` | 摘要 |
| `cover_image_src` | `Option<String>` | 封面 |
| `saved_at` | `Timestamp` | 最近一次保存时间 |
| `bottom_tab` | `String` | 存档时所在 tab |
| `game_state_json` | `String` | 最近一次存档快照 JSON |
| `current_story_json` | `Option<String>` | 最近一次剧情 JSON |
| `created_at` | `Timestamp` | 首次创建时间 |
| `updated_at` | `Timestamp` | 最近更新时间 |
## 5. Projection 刷新规则
### 5.1 `PUT /api/runtime/save/snapshot`
写入顺序冻结为:
1. upsert `runtime_snapshot`
2. 刷新 `profile_dashboard_state`
3. 刷新 `profile_wallet_ledger`
4. 刷新 `profile_played_world`
5. 刷新 `profile_save_archive`
### 5.2 dashboard / wallet / played world
沿用已冻结 Node 规则:
1. `playerCurrency` 映射到 `wallet_balance`
2. 钱包变化时写一条 `snapshot_sync` ledger
3. `runtimeStats.playTimeMs``profile_played_world.last_observed_play_time_ms` 做增量比较
4. `total_play_time_ms` 为累积值
### 5.3 save archive metadata
规则:
1. 若存在 `customWorldProfile`
- `worldType = "CUSTOM"`
- `worldKey = custom:{profileId or worldTitle}`
- `worldName` 优先 `customWorldProfile.name/title`
- `subtitle` 优先 `customWorldProfile.summary/settingText`
2. 若是内置世界:
- `worldKey = builtin:{worldType}`
- `worldName` 优先 `currentScenePreset.name`,否则 `worldType` 对应默认中文名
- `subtitle` 优先 `currentScenePreset.summary/description`
3. `summaryText` 优先级:
- `storyEngineMemory.continueGameDigest`
- `currentStory.text`
- `subtitle`
- `继续推进上一次保存的故事。`
4. `coverImageSrc`
- 自定义世界优先 `customWorldProfile.coverImageSrc`
- 内置世界优先 `currentScenePreset.imageSrc`
## 6. Axum facade 设计
### 6.1 `GET /api/runtime/save/snapshot`
返回:
1. 有记录时返回标准 snapshot object
2. 无记录时返回 `null`
### 6.2 `PUT /api/runtime/save/snapshot`
返回:
1. 归一化后并已持久化的 snapshot
### 6.3 `DELETE /api/runtime/save/snapshot`
返回:
```json
{
"ok": true
}
```
### 6.4 save archive list
主路径与兼容路径:
1. `GET /api/runtime/profile/save-archives`
2. `GET /api/profile/save-archives`
返回:
```json
{
"entries": []
}
```
### 6.5 save archive resume
主路径与兼容路径:
1. `POST /api/runtime/profile/save-archives/:worldKey`
2. `POST /api/profile/save-archives/:worldKey`
行为:
1. `worldKey` 为空返回 `400`
2. 找不到 archive 返回 `404`
3. 返回 `entry + snapshot`
## 7. 本轮验收口径
完成后需满足:
1. 登录用户可 `PUT/GET/DELETE` 当前 snapshot
2. `PUT snapshot` 后可以从 `save-archives` 看到按世界聚合的恢复入口
3. `POST save-archives/:worldKey` 可恢复当前 snapshot
4. `/api/runtime/profile/save-archives``/api/profile/save-archives` 返回一致
5. `profile dashboard / browse history / save archive` 三组行为可并存,不互相覆盖

View File

@@ -0,0 +1,147 @@
# M4 Combat Reward Inventory Integration2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把 `resolve_combat_action(Victory)` 从“只发经验”推进到“经验与战利品可在同一事务内结算”的最小主链口径。**
本轮不回收完整 runtime item 导演层,也不在 `module-combat` 内直接做 AI 语义生成;只承接已经编译好的 reward item 快照。
---
## 1. 本轮落地范围
本轮只落实下面 4 件事:
1.`module-combat` 中为 `battle_state` 补充 `reward_items` 字段。
2. 允许 `BattleStateInput` 在初始化时携带已经编译好的战利品快照。
3.`spacetime-module::resolve_combat_action` 中,当结果为 `Victory` 时同步把 `reward_items` 写入 `inventory_slot`
4. 保持 `module-combat` 仍然是纯规则 crate不直接依赖 `module-inventory`
---
## 2. 当前冻结的战利品口径
### 2.1 `battle_state.reward_items`
首版字段固定复用 `module-runtime-item::RuntimeItemRewardItemSnapshot`
原因:
1. 宝箱链已经用这套 reward item contract 打通到 `inventory_slot`
2. 任务奖励当前仍有独立 `QuestRewardItem`,但战斗奖励更接近 runtime item 导演层。
3. 先复用现有 reward item 快照,避免本轮再发明第三套 combat 专属掉落结构。
### 2.2 battle 初始化来源
当前 `battle_state.reward_items` 不在战斗 reducer 内生成,只允许由上游在创建 battle 时传入:
1. `resolve_npc_battle_interaction_and_return`
2. 后续 Axum façade / runtime story orchestration
3. 其它明确的 battle create 聚合入口
也就是说:
1. `module-combat` 只消费已确定的 reward item 快照
2. 不在 reducer 内做随机、提示词、外部世界图谱推导
当前已接通:
1. `resolve_npc_battle_interaction_and_return`
2. `POST /api/story/npc/battle`
3. `POST /api/story/battles`
这几条入口都只负责透传已编译奖励,不负责现场生成掉落。
---
## 3. Victory 发物规则
`resolve_combat_action` 结算结果满足:
1. `outcome == Victory`
`spacetime-module` 需要继续执行:
1. `experience_reward > 0` 时写 `player_progression / chapter_progression`
2. `reward_items.len() > 0` 时写 `inventory_slot`
### 3.1 发物方式
当前固定规则:
1. 每个 reward item 显式映射成一条 `InventoryMutation::GrantItem`
2. `source_kind = CombatDrop`
3. `source_reference_id = battle_state_id`
4. 同一 `battle_state_id` 只允许发放一次
### 3.2 幂等约束
本轮先采用与 quest / treasure 一致的“按来源引用查重”思路:
1. 若当前 actor 的 `inventory_slot` 中已经存在 `source_reference_id = battle_state_id`
2. 视为该 battle reward 已发放
3. Victory 再次重放时跳过发物,但不影响 battle_state 已收束结果
---
## 4. 与既有链路的边界
### 4.1 与 `module-combat`
`module-combat` 继续只负责:
1. `battle_state` 结构
2. `resolve_combat_action` 状态推进
3. 胜负结果收束
不负责:
1. inventory 写表
2. progression 写表
3. runtime item 生成
### 4.2 与 `module-runtime-item`
本轮不把战斗奖励映射 helper 上提到 `module-runtime-item`
原因:
1. 当前 `RuntimeItemRewardItemSnapshot -> InventoryItemSnapshot` 的 helper 语义固定为 `TreasureReward`
2. 若直接复用,会把 `source_kind` 写错成 `TreasureReward`
3. 本轮先在 `spacetime-module` 里补一个 combat 专用映射,后续再统一抽象
---
## 5. 当前刻意未做
本轮明确不做下面这些扩张:
1. 不把 Node 版 `monster_drop` AI 导演层整体迁到 Rust
2. 不在 `resolve_npc_battle_interaction_and_return` 里现场计算掉落
3. 不处理 battle reward 的货币、好感、情报
4. 不处理战斗内 `inventory_use`
5. 不把掉落展示或 Battle Reward 面板接到前端
---
## 6. 验证要求
本轮完成后至少执行:
1. `npm run check:encoding`
2. `cargo test -p module-combat --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
3. `cargo check -p module-combat -p spacetime-module --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 把 Node 侧 `monster_drop` runtime item 编译逻辑迁到 Rust 聚合层。
2. 视复用收益决定是否把 battle / treasure 的 reward item 归一化 helper 上提到 `module-runtime-item`
3. 最后再把 battle reward 展示、story patch 和前端接口切到新后端。

View File

@@ -0,0 +1,306 @@
# M4 module-ai Axum facade 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把已经在 `spacetime-module` 落地的 `module-ai` 任务真相表与最小 procedure / reducer继续向上接到 `shared-contracts`、`spacetime-client` 与 `api-server`,形成可由 HTTP 直接调用的 AI task mutation facade。**
本轮只做最小同步 mutation 链,不扩到 SSE、真实模型供应商请求或前端订阅。
---
## 1. 本轮要解决的问题
当前仓库已经具备:
1. `module-ai`
- 统一 `AiTaskKind / AiTaskStageKind / AiResultReferenceKind`
- 统一任务、阶段、文本片段、结果引用领域模型
2. `spacetime-module`
- `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference`
- `create / append / complete / attach / fail / cancel` 最小 procedure
- `start_ai_task / start_ai_task_stage` reducer
3. `spacetime-client`
- 已生成 AI 相关 Rust bindings
但当前仍缺三层:
1. `shared-contracts` 还没有 AI task HTTP DTO
2. `spacetime-client` 还没有 AI facade 方法与 record 映射
3. `api-server` 还没有 `/api/ai/tasks*` 路由
因此本轮只补下面三层:
1. `shared-contracts` AI DTO
2. `spacetime-client` AI facade
3. `api-server` AI tasks HTTP route
---
## 2. 当前明确不做的事
本轮明确不做:
1. 不接入真实 `platform-llm` 流式回调
2. 不提供 SSE 增量推送接口
3. 不增加 AI task 查询 / 订阅 projection
4. 不把 story / npc / quest / custom-world 旧入口自动迁到这组新接口
5. 不修改 `spacetime-client/src/module_bindings/*` 生成文件
原因很直接:
1. 当前先把 AI task mutation 的最小 HTTP contract 固定下来
2. SSE 与查询态必须等待后续订阅策略或 query procedure 冻结
3. 业务编排入口切换应该在上层模块各自评估,不在本轮提前硬迁
---
## 3. 路由冻结
本轮首版新增以下路由:
1. `POST /api/ai/tasks`
2. `POST /api/ai/tasks/{taskId}/start`
3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start`
4. `POST /api/ai/tasks/{taskId}/chunks`
5. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete`
6. `POST /api/ai/tasks/{taskId}/references`
7. `POST /api/ai/tasks/{taskId}/complete`
8. `POST /api/ai/tasks/{taskId}/fail`
9. `POST /api/ai/tasks/{taskId}/cancel`
### 3.1 同步返回路由
当前下列路由走 `procedure`,成功时同步返回 `aiTask` 快照:
1. `POST /api/ai/tasks`
2. `POST /api/ai/tasks/{taskId}/chunks`
3. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete`
4. `POST /api/ai/tasks/{taskId}/references`
5. `POST /api/ai/tasks/{taskId}/complete`
6. `POST /api/ai/tasks/{taskId}/fail`
7. `POST /api/ai/tasks/{taskId}/cancel`
其中:
1. `chunks` 额外返回 `aiTextChunk`
2. 其他 mutation 当前只返回 `aiTask`
### 3.2 Accepted 路由
当前下列路由只接 `reducer`,不会同步返回快照:
1. `POST /api/ai/tasks/{taskId}/start`
2. `POST /api/ai/tasks/{taskId}/stages/{stageKind}/start`
因此本轮明确冻结为:
1. HTTP 成功状态码返回 `202 Accepted`
2. body 只返回:
- `accepted`
- `taskId`
- `action`
- `stageKind`(仅 stage start
3. 不伪装成“已经拿到最新任务快照”
后续如果要让这两条路由也同步返回快照,应先在 `spacetime-module` 增加对应 procedure。
---
## 4. 请求与响应 DTO 冻结
### 4.1 创建任务请求
`POST /api/ai/tasks` 请求体冻结为:
1. `taskKind`
2. `requestLabel`
3. `sourceModule`
4. `sourceEntityId`
5. `requestPayloadJson`
6. `stageKinds`
其中:
1. `taskId` 不接受外部写入,由 Axum 使用 `generate_ai_task_id(nowMicros)` 生成
2. `ownerUserId` 不接受外部写入,必须取自 Bearer token
3. `stageKinds` 为空时,由 `module-ai` 根据 `taskKind.default_stage_blueprints()` 自动补齐默认阶段蓝图
### 4.2 追加文本片段请求
`POST /api/ai/tasks/{taskId}/chunks` 请求体冻结为:
1. `stageKind`
2. `sequence`
3. `deltaText`
### 4.3 完成阶段请求
`POST /api/ai/tasks/{taskId}/stages/{stageKind}/complete` 请求体冻结为:
1. `textOutput`
2. `structuredPayloadJson`
3. `warningMessages`
### 4.4 绑定结果引用请求
`POST /api/ai/tasks/{taskId}/references` 请求体冻结为:
1. `referenceKind`
2. `referenceId`
3. `label`
### 4.5 失败请求
`POST /api/ai/tasks/{taskId}/fail` 请求体冻结为:
1. `failureMessage`
### 4.6 成功响应
本轮统一返回以下 payload
1. `AiTaskPayload`
2. `AiTaskStagePayload`
3. `AiResultReferencePayload`
4. `AiTextChunkPayload`
5. `AiTaskMutationResponse`
6. `AiTaskAcceptedResponse`
时间字段继续统一为 RFC3339 字符串。
---
## 5. `spacetime-client` 冻结口径
本轮新增以下 facade
1. `create_ai_task`
2. `start_ai_task`
3. `start_ai_task_stage`
4. `append_ai_text_chunk`
5. `complete_ai_stage`
6. `attach_ai_result_reference`
7. `complete_ai_task`
8. `fail_ai_task`
9. `cancel_ai_task`
### 5.1 输入边界
1. procedure 输入直接复用 `module-ai` 领域输入结构
2. `start_ai_task``start_ai_task_stage` 直接复用 reducer 输入结构
3. 不让 `api-server` 直接依赖 generated binding 类型
### 5.2 输出边界
`spacetime-client` 新增下列 record`api-server` 直接消费:
1. `AiTaskRecord`
2. `AiTaskStageRecord`
3. `AiTextChunkRecord`
4. `AiResultReferenceRecord`
5. `AiTaskMutationRecord`
字符串字段规范:
1. `taskKind` 使用:
- `story_generation`
- `character_chat`
- `npc_chat`
- `custom_world_generation`
- `quest_intent`
- `runtime_item_intent`
2. `stageKind` 使用 `module-ai::AiTaskStageKind::as_str()`
3. `status` 使用 snake_case
4. `referenceKind` 使用 snake_case
### 5.3 错误映射
AI facade 在 `spacetime-client` 内部按以下规则区分:
1. procedure / reducer 返回的业务拒绝
- 映射为 `SpacetimeClientError::Runtime`
2. SDK 调用、连接、超时、意外缺字段
- 映射为 `Build / Procedure / ConnectDropped / Timeout`
这样 `api-server` 才能稳定把业务错误映射成 `400`
---
## 6. `api-server` 冻结口径
### 6.1 鉴权与身份
所有 `/api/ai/tasks*` 路由继续统一挂 Bearer 鉴权。
其中:
1. `ownerUserId` 必须来自 `AuthenticatedAccessToken.claims().user_id()`
2. 不接受前端自行写入任务所有者
### 6.2 时间与 ID
以下字段不接受外部写入:
1. `taskId`
2. `createdAtMicros`
3. `startedAtMicros`
4. `completedAtMicros`
统一由 Axum 在请求进入时生成。
### 6.3 字段解析
`api-server` 负责把 HTTP 字符串解析为领域枚举:
1. `taskKind`
2. `stageKind`
3. `referenceKind`
解析失败统一返回 `400``details.provider` 分别写:
1. `ai-task`
2. `ai-task-stage`
3. `ai-task-reference`
---
## 7. 错误映射
本轮 AI facade 的错误策略冻结如下:
1. 请求 JSON 非法、路径字段非法、枚举解析失败:`400`
2. `SpacetimeClientError::Runtime(_)``400`
3. 其他 `SpacetimeClientError``502`
`details.provider` 统一写:
1. 路由入参准备错误:`ai-task`
2. SpacetimeDB 上游错误:`spacetimedb`
---
## 8. 本轮验收口径
满足以下条件,视为本轮 facade 基线完成:
1. `shared-contracts` 已新增 `ai.rs`
2. `spacetime-client` 已新增 AI facade 方法与 record 映射
3. `api-server` 已新增 `ai_tasks.rs`
4. `/api/ai/tasks*` 路由已注册并挂 Bearer 鉴权
5. `cargo fmt -p shared-contracts -p spacetime-client -p api-server` 通过
6. `cargo check -p shared-contracts -p spacetime-client -p api-server` 通过
---
## 9. 下一步建议
本轮完成后,后续最稳的顺序是:
1.`start_ai_task / start_ai_task_stage` 增加同步 procedure
2. 增加 AI task 查询态或订阅 projection
3. 再把 `platform-llm` 流式回调真正接到 `append_ai_text_chunk / complete_ai_stage / fail_ai_task`
4. 最后再把 story / npc / custom-world / quest / runtime-item 的 AI 编排主链逐步切到这组新接口

View File

@@ -0,0 +1,230 @@
# `module-ai` 首版基座设计
日期:`2026-04-21`
## 1. 文档目标
本文只冻结一件事:
**为 `server-rs/crates/module-ai` 建立一套可以直接编码落地的首版领域模型与最小服务边界。**
本轮不做以下内容:
1. 不直接接入真实供应商 SDK。
2. 不在 `SpacetimeDB` 里提前写完整 `ai_task` 表。
3. 不提前改造 `api-server` 的 story/chat/custom world 路由。
本轮只解决两个问题:
1. `module-ai` 不能再停留在“目录占位 + README 口号”状态。
2. 后续 `api-server``platform-llm``spacetime-module` 接线时,需要先有稳定的任务、阶段、流式片段、结果引用领域模型可复用。
## 2. 依据
本文以以下现有文档和代码为准:
1. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
2. [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md)
3. 历史 Node AI 编排代码仅作为迁移背景,不再作为当前实现依据。
## 3. 现状问题
当前 `server-rs/crates/module-ai` 只有 README占位描述虽然说明了“AI 编排模块”的方向,但还缺失编码级约束:
1. 没有任务主键、阶段主键、结果引用 ID 的统一前缀。
2. 没有 story/chat/custom world/quest/runtime-item 六类任务的共用枚举。
3. 没有“排队中/运行中/已完成/失败/已取消”的状态模型。
4. 没有“流式片段如何暂存与聚合”的领域对象。
5. 没有“结果引用”与“最终文本/结构化结果”的最小抽象。
6. 没有可供 `api-server` 直接依赖的最小内存服务。
如果继续在没有这层基座的前提下直接接 `platform-llm``api-server`,后续很容易再次把:
1. 阶段枚举散落在 handler 里,
2. 流式文本拼接散落在路由里,
3. 结果引用结构散落在 story/custom-world/quest 各模块里。
## 4. 首版职责冻结
`module-ai` 首版只负责以下职责:
1. 定义统一 AI 任务类型、任务状态、阶段状态、任务快照。
2. 定义统一流式片段、阶段输出、结果引用、最终结果快照。
3. 提供最小编排服务,支持:
- 创建任务
- 启动任务
- 记录阶段开始/完成
- 追加流式文本片段
- 绑定结果引用
- 成功完成 / 失败 / 取消
4. 提供一套内存态 store作为 `api-server` 首轮联调和测试 fallback。
`module-ai` 首版明确不负责:
1. 真实 HTTP 请求、重试、超时和供应商切换。
2. SSE 协议写回。
3. 数据库存储与表结构。
4. 业务模块自己的 prompt 组装细节。
## 5. 任务类型范围
首版统一冻结以下任务类型:
1. `StoryGeneration`
2. `CharacterChat`
3. `NpcChat`
4. `CustomWorldGeneration`
5. `QuestIntent`
6. `RuntimeItemIntent`
说明:
1. 这 6 类直接来自当前 Node 后端已经存在的正式运行时 AI 主链。
2. 不提前引入媒体类资产生成任务,因为资产生成后续归 `module-assets + platform-oss` 主导。
3. 如果后续要增加 `NarrativeRepair``ProfileRepair` 这类内部子任务,应作为新枚举值追加,不复用现有值的语义。
## 6. 阶段模型
首版阶段固定支持以下通用阶段语义:
1. `PreparePrompt`
2. `RequestModel`
3. `RepairResponse`
4. `NormalizeResult`
5. `PersistResult`
说明:
1. 不是每种任务都必须走满 5 个阶段。
2. 任务创建时应携带自己的阶段蓝图,按需要裁剪。
3. 阶段蓝图是显示给上层 orchestration 的稳定数据,不把“阶段名字符串”重新散落到 handler。
首版建议默认蓝图:
| 任务类型 | 默认阶段 |
| --- | --- |
| `StoryGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` |
| `CharacterChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `NpcChat` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `CustomWorldGeneration` | `PreparePrompt` -> `RequestModel` -> `RepairResponse` -> `NormalizeResult` -> `PersistResult` |
| `QuestIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
| `RuntimeItemIntent` | `PreparePrompt` -> `RequestModel` -> `NormalizeResult` |
## 7. 结果模型
首版结果拆成三层:
### 7.1 流式片段
用于记录模型增量输出:
1. `chunk_id`
2. `task_id`
3. `stage_kind`
4. `sequence`
5. `delta_text`
6. `created_at_micros`
这层只负责“增量片段”,不直接宣称是最终结果。
### 7.2 阶段输出
用于记录阶段收口后的聚合内容:
1. `stage_kind`
2. `text_output`
3. `structured_payload_json`
4. `warning_messages`
### 7.3 结果引用
用于让 AI 编排结果和其他模块的记录形成稳定绑定:
1. `result_ref_id`
2. `task_id`
3. `reference_kind`
4. `reference_id`
5. `label`
首版 `reference_kind` 冻结为:
1. `StorySession`
2. `StoryEvent`
3. `CustomWorldProfile`
4. `QuestRecord`
5. `RuntimeItemRecord`
6. `AssetObject`
## 8. 服务边界
首版 `AiTaskService` 只暴露纯领域操作,不直接暴露供应商能力:
1. `create_task`
2. `start_task`
3. `start_stage`
4. `append_text_chunk`
5. `complete_stage`
6. `attach_result_reference`
7. `complete_task`
8. `fail_task`
9. `cancel_task`
10. `get_task`
这层返回的都是领域快照,不返回 HTTP DTO。
## 9. 与其他 crate 的边界
### 9.1 与 `platform-llm`
`platform-llm` 负责:
1. 真实模型请求
2. 流式回调
3. 超时 / 重试 / 供应商错误
`module-ai` 负责:
1. 把这些外部回调映射为任务快照与阶段快照
2. 把供应商响应组织成稳定的模块领域状态
### 9.2 与 `api-server`
`api-server` 负责:
1. HTTP 入参校验
2. SSE 输出
3. Bearer/Cookie 鉴权
4. response envelope
`module-ai` 不负责 HTTP。
### 9.3 与 `spacetime-module`
后续 `spacetime-module` 负责:
1. 任务真相表
2. 阶段表 / 事件表 / 结果引用表
3. reducer / procedure
本轮 `module-ai` 只提供后续可映射到 SpacetimeDB 的稳定领域结构。
## 10. 首版编码要求
首版 crate 必须满足:
1. 提供 `Cargo.toml`
2. 提供 `src/lib.rs`
3. 默认不依赖 `platform-llm`
4. 默认不依赖 `SpacetimeDB`
5. 可选提供 `spacetime-types` feature便于后续映射表结构
6. 提供完整中文注释与基础测试
## 11. 本轮验收口径
本轮完成后,以下条件同时满足才算 `module-ai` 首版落地:
1. `server-rs/Cargo.toml` 已把 `module-ai` 纳入 workspace。
2. `module-ai` 不再只有 README而是有真实可编译源码。
3. 任务/阶段/结果引用/流式片段领域模型已存在。
4. 有最小内存服务可供后续 `api-server` 直接复用。
5. 至少有任务创建、流式片段聚合、阶段完成、结果引用绑定、任务失败/取消等测试。

View File

@@ -0,0 +1,266 @@
# M4 module-ai SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-ai` 从“只有领域模型和内存态服务”推进到“SpacetimeDB 侧已有最小 AI 任务真相表与 procedure 骨架”的真实落地结果。**
本轮只做最小可编译基座不扩到真实模型请求、SSE 输出或前端订阅联调。
---
## 1. 本轮落地范围
本轮只落实下面 5 件事:
1.`server-rs/crates/module-ai/` 中补齐面向 `SpacetimeDB` 接线的输入类型。
2.`server-rs/crates/spacetime-module/` 中新增 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 四张 private 表。
3.`spacetime-module` 中新增 AI 任务的最小 reducer / procedure。
4.`module-ai` 的领域快照与 `SpacetimeDB` 行结构之间的转换 helper 固定下来。
5. 补充 crate README 与技术索引,明确当前 AI 真相源边界。
---
## 2. 新增的真实工程落点
### 2.1 `module-ai`
1. `server-rs/crates/module-ai/src/lib.rs`
- 补充 `AiTaskStartInput`
- 补充 `AiTaskStageStartInput`
- 补充 `AiTextChunkAppendInput`
- 补充 `AiResultReferenceInput`
- 补充 `AiTaskFinishInput`
- 补充 `AiTaskCancelInput`
- 补充 `AiTaskFailureInput`
- 补充 `AI_TASK_STAGE_ID_PREFIX`
- 补充 `AiTaskStageKind::as_str()`
- 补充 `generate_ai_task_stage_id()`
### 2.2 `spacetime-module`
1. `server-rs/crates/spacetime-module/src/lib.rs`
- 新增 `ai_task`
- 新增 `ai_task_stage`
- 新增 `ai_text_chunk`
- 新增 `ai_result_reference`
- 新增 `create_ai_task`
- 新增 `create_ai_task_and_return`
- 新增 `start_ai_task`
- 新增 `start_ai_task_stage`
- 新增 `append_ai_text_chunk_and_return`
- 新增 `complete_ai_stage_and_return`
- 新增 `attach_ai_result_reference_and_return`
- 新增 `complete_ai_task_and_return`
- 新增 `fail_ai_task_and_return`
- 新增 `cancel_ai_task_and_return`
---
## 3. 当前冻结的数据口径
### 3.1 `ai_task`
当前首版字段冻结为:
1. `task_id`
2. `task_kind`
3. `owner_user_id`
4. `request_label`
5. `source_module`
6. `source_entity_id`
7. `request_payload_json`
8. `status`
9. `failure_message`
10. `latest_text_output`
11. `latest_structured_payload_json`
12. `version`
13. `created_at`
14. `started_at`
15. `completed_at`
16. `updated_at`
当前策略:
1. `ai_task` 只保留任务级聚合字段,不在单行内嵌套 `Vec<stage>`
2. 阶段、增量文本、结果引用全部拆到独立表,避免后续更新整行大对象。
3. `version` 继续沿用 `module-ai` 的任务快照版本语义。
### 3.2 `ai_task_stage`
当前首版字段冻结为:
1. `task_stage_id`
2. `task_id`
3. `stage_kind`
4. `label`
5. `detail`
6. `order`
7. `status`
8. `text_output`
9. `structured_payload_json`
10. `warning_messages`
11. `started_at`
12. `completed_at`
当前策略:
1. 一条 stage 一行。
2. `task_stage_id` 使用 `generate_ai_task_stage_id(task_id, stage_kind)`,保持同任务内幂等。
3. 当前不单独存“阶段版本”,统一归任务版本递增。
### 3.3 `ai_text_chunk`
当前首版字段冻结为:
1. `text_chunk_row_id`
2. `chunk_id`
3. `task_id`
4. `stage_kind`
5. `sequence`
6. `delta_text`
7. `created_at`
当前策略:
1. `chunk_id` 保留领域侧 ID 语义。
2. 表级主键使用 `text_chunk_row_id`,避免 `generate_ai_text_chunk_id(seed, sequence)` 在不同任务之间碰撞。
3. 流式文本聚合结果仍写回 `ai_task_stage.text_output``ai_task.latest_text_output`
### 3.4 `ai_result_reference`
当前首版字段冻结为:
1. `result_reference_row_id`
2. `result_ref_id`
3. `task_id`
4. `reference_kind`
5. `reference_id`
6. `label`
7. `created_at`
当前策略:
1. `result_ref_id` 保留领域侧 ID 语义。
2. 表级主键使用 `result_reference_row_id`,避免只按时间种子生成的领域 ID 在并发情况下直接作为主键带来碰撞风险。
---
## 4. 当前 reducer / procedure 口径
### 4.1 `create_ai_task`
当前负责:
1. 校验 `AiTaskCreateInput`
2. 拒绝重复 `task_id`
3. 写入 `ai_task`
4. 按蓝图写入 `ai_task_stage`
### 4.2 `start_ai_task`
当前负责:
1. 校验目标任务存在
2.`ai_task.status``Pending` 推进到 `Running`
3. 填充 `started_at`
### 4.3 `start_ai_task_stage`
当前负责:
1. 校验目标任务与目标阶段存在
2. 推进任务为 `Running`
3. 推进对应 stage 为 `Running`
### 4.4 `append_ai_text_chunk_and_return`
当前负责:
1. 校验任务与阶段存在
2. 追加 `ai_text_chunk`
3.`task_id + stage_kind + sequence` 聚合文本
4. 回写 `ai_task_stage.text_output`
5. 回写 `ai_task.latest_text_output`
### 4.5 `complete_ai_stage_and_return`
当前负责:
1. 更新 stage 状态、阶段输出、warning 列表
2. 回写 `ai_task.latest_text_output`
3. 回写 `ai_task.latest_structured_payload_json`
4. 递增任务版本
### 4.6 `attach_ai_result_reference_and_return`
当前负责:
1. 追加 `ai_result_reference`
2. 更新任务 `updated_at`
3. 递增任务版本
### 4.7 `complete_ai_task_and_return`
当前负责:
1. 推进任务为 `Completed`
2. 填充 `completed_at`
### 4.8 `fail_ai_task_and_return`
当前负责:
1. 推进任务为 `Failed`
2. 写入 `failure_message`
3. 填充 `completed_at`
### 4.9 `cancel_ai_task_and_return`
当前负责:
1. 推进任务为 `Cancelled`
2. 填充 `completed_at`
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有做 AI 任务公开订阅表。
2. 还没有做 `api-server` 的 AI facade 路由。
3. 还没有做 `platform-llm` 真实流式回调接线。
4. 还没有做 story / custom-world / quest / runtime-item 对 AI 任务的自动建链。
5. 还没有做清理旧任务、旧 chunk 的 schedule reducer。
也就是说,本轮只是把 AI 任务真相表和最小写入口立起来,不宣称已经完成 AI runtime 主链迁移。
---
## 6. 当前边界判断
当前仍保持以下职责划分:
1. `module-ai`
- 负责领域模型、校验、快照结构与最小内存服务。
2. `spacetime-module`
- 负责任务真相表、事务性持久化与 procedure 聚合返回。
3. `platform-llm`
- 后续负责真实模型调用、超时、重试、供应商错误。
4. `api-server`
- 后续负责 HTTP / SSE / 鉴权与外部 contract。
---
## 7. 下一步建议
按当前节奏,后续应继续按下面顺序推进:
1. 先把 `platform-llm` 的文本网关正式接到 `append_ai_text_chunk_and_return / complete_ai_stage_and_return`
2. 再给 `api-server` 增加 AI 任务 facade把 HTTP/SSE 对外 contract 冻结下来。
3. 再把 story、custom-world、quest、runtime-item 各自的 AI 编排入口切到 `module-ai + spacetime-module`
4. 最后再根据订阅需求评估是否补 public projection 表或事件表。

View File

@@ -0,0 +1,251 @@
# M4 module-combat Axum facade 设计2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只冻结一件事:
**把已经完成 reducer 化的 `module-combat` 再向上接一层最小同步返回链,让 `api-server` 可以显式创建战斗、推进单次战斗动作,并立即拿到 battle 快照结果。**
这份文档不是完整 `runtime story actions/resolve` 兼容方案,也不替代后续的 `resolve_story_action` 编排设计。
---
## 1. 本轮要解决的问题
当前 `module-combat` 已具备:
1. `battle_state` 真相表
2. `create_battle_state` reducer
3. `resolve_combat_action` reducer
4. `fight / spar` 两种模式下的纯规则推进
但当前仍缺一层明确能力:
1. Axum 还不能同步拿到 battle 快照
2. `spacetime-client` 还没有 battle procedure 调用封装
3. `api-server` 还没有独立的战斗 facade
因此本轮只补下面三层:
1. `spacetime-module` battle procedure
2. `spacetime-client` battle procedure 调用与返回值映射
3. `api-server` 最小战斗 HTTP facade
---
## 2. 当前明确不做的事
本轮刻意不做:
1. 不兼容旧 `POST /api/runtime/story/actions/resolve`
2. 不兼容旧 `GET /api/runtime/story/state/:sessionId`
3. 不把 `inventory_use` 提前接回战斗主链
4. 不把 `quest / progression / npc / story_event` 自动联动写回
5. 不把 battle 直接拼进 `RuntimeStoryActionResponse`
原因很直接:
1. 这些属于更高层的 runtime story 编排问题
2. 当前 battle 子域应该先把“独立可调用、同步可返回”这一层固定下来
3. 先补 procedure + facade后续 `resolve_story_action` 才有稳定下游可调入口
---
## 3. `spacetime-module` 的新增口径
### 3.1 reducer 继续保留
已有 reducer 继续保留:
1. `create_battle_state`
2. `resolve_combat_action`
职责不变:
1. reducer 仍然只负责 battle 真相写入
2. reducer 不直接向调用方返回业务快照
### 3.2 新增 procedure
本轮新增两个 procedure
1. `create_battle_state_and_return`
2. `resolve_combat_action_and_return`
职责冻结如下:
1. procedure 只包一层 `try_with_tx`
2. procedure 内部复用 reducer 共享的写入 helper
3. procedure 负责把最终 `battle_state``resolve result` 同步返回给 Axum
### 3.3 返回类型
本轮冻结两种返回 DTO
1. `BattleStateProcedureResult`
2. `ResolveCombatActionProcedureResult`
字段口径统一为:
1. `ok`
2. `snapshot``result`
3. `error_message`
这样能与现有 `story / treasure / npc` procedure 返回风格保持一致。
---
## 4. `spacetime-client` 的新增口径
`spacetime-client` 本轮新增两条最小调用链:
1. `create_battle_state`
2. `resolve_combat_action`
调用策略继续沿用当前已验证模式:
1. 先建立 `DbConnection`
2. 等待 `on_connect`
3. 再调用对应 procedure
4. 统一经 `oneshot + timeout` 收口结果
当前不做:
1. battle 订阅
2. battle cache 读模型
3. battle 长连接复用策略
---
## 5. `api-server` 的新增 facade 口径
### 5.1 路由
本轮新增两条最小路由:
1. `POST /api/story/battles`
2. `POST /api/story/battles/resolve`
这两条路由的定位不是旧 runtime 兼容层,而是:
1. 面向新 Rust 后端内部联调
2. 面向后续 `resolve_story_action` 编排层调用
### 5.2 `POST /api/story/battles`
请求体只提交 battle 建立所需的业务字段:
1. `storySessionId`
2. `runtimeSessionId`
3. `targetNpcId`
4. `targetName`
5. `battleMode`
6. `playerHp`
7. `playerMaxHp`
8. `playerMana`
9. `playerMaxMana`
10. `targetHp`
11. `targetMaxHp`
由 Axum 自动补齐:
1. `battleStateId`
2. `actorUserId`
3. `createdAtMicros`
响应返回:
1. `battleState`
### 5.3 `POST /api/story/battles/resolve`
请求体只提交单次动作推进所需字段:
1. `battleStateId`
2. `functionId`
3. `actionText`
4. `baseDamage`
5. `manaCost`
6. `heal`
7. `manaRestore`
8. `counterMultiplierBasisPoints`
由 Axum 自动补齐:
1. `updatedAtMicros`
响应返回:
1. `battleState`
2. `combat`
其中 `combat` 至少包含:
1. `damageDealt`
2. `damageTaken`
3. `outcome`
---
## 6. 认证与字段真相边界
### 6.1 `actorUserId`
`actorUserId` 不接受前端自填。
必须由:
1. `AuthenticatedAccessToken`
2. `claims.user_id`
直接生成。
### 6.2 时间字段
`createdAtMicros``updatedAtMicros` 不接受外部写入。
必须由 Axum 在请求时生成,原因如下:
1. 避免客户端伪造 battle 创建时间
2. 保持 Rust 后端各 facade 的时间字段风格一致
3. 让后续 battle / story / npc 联调时便于统一日志与排障
---
## 7. 错误映射口径
当前 battle facade 的错误映射冻结如下:
1. battle mode 非法、请求 JSON 非法、字段校验失败:`400`
2. `SpacetimeClientError::Runtime(_)``400`
3. 其他 `SpacetimeClientError``502`
返回 `details.provider` 统一写:
1. battle 输入准备错误:`story-battle`
2. SpacetimeDB 上游错误:`spacetimedb`
---
## 8. 本轮验收
满足以下条件,视为本轮 facade 基线完成:
1. `module-combat` 已新增 procedure 返回 DTO
2. `spacetime-module` 已新增 `create_battle_state_and_return`
3. `spacetime-module` 已新增 `resolve_combat_action_and_return`
4. `spacetime-client` 已可同步创建战斗并推进单次动作
5. `api-server` 已新增两条最小 battle facade 路由
6. `cargo check -p module-combat -p spacetime-client -p api-server -p spacetime-module` 通过
---
## 9. 下一步建议
本轮完成后,后续最稳的顺序是:
1. 把 battle facade 接入 `resolve_story_action`
2. 设计 battle 结束后的 `story_event` 追加口径
3. 再把 `quest / progression / inventory` 的联动收回到显式子域流程里

View File

@@ -0,0 +1,336 @@
# M4 module-combat SpacetimeDB 基线设计2026-04-21
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把 `module-combat` 从“只有 README 占位”推进到“首版 battle_state 与 resolve_combat_action 可真实编码、可编译、可继续扩展”的工程基线。**
本轮不宣称完成完整 `runtime story action` 迁移,也不把 `inventory / npc / story AI 续写` 直接耦进战斗 reducer跨子域写入继续收敛在 `spacetime-module` 聚合层。
---
## 1. 本轮落地范围
本轮只做下面 5 件事:
1. 新增 `server-rs/crates/module-combat/` 真实 crate。
2. 冻结 `battle_state` 的首版领域类型、枚举、输入结构与字段校验 helper。
3. 冻结 `resolve_combat_action` 的首版输入、输出与纯规则推进逻辑。
4.`server-rs/crates/spacetime-module/` 中新增 `battle_state` 表。
5.`spacetime-module` 中新增 `create_battle_state``resolve_combat_action` 两个 reducer。
---
## 2. 当前冻结的实现边界
### 2.1 首版必须支持的战斗 function
首版与 [../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md) 保持一致,只支持以下单行为入口:
1. `battle_attack_basic`
2. `battle_recover_breath`
3. `battle_use_skill`
4. `battle_escape_breakout`
5. 旧兼容攻击类:
- `battle_all_in_crush`
- `battle_guard_break`
- `battle_probe_pressure`
- `battle_feint_step`
- `battle_finisher_window`
本轮刻意不接入:
1. `inventory_use`
2. 技能与物品的正式外部明细读取
3.`quest_record``npc_state` 的联动写入
4. 脱战后 `story_event` 追加与 AI 续写触发
### 2.2 为什么先不做 `inventory_use`
当前 Rust 侧还没有 `inventory_slot` 正式表,也没有稳定的战斗内物品快照输入。
如果现在把 `inventory_use` 硬塞进 `module-combat`,只会出现两种坏结果:
1. reducer 内部引入并不存在的 inventory 真相依赖;
2. 退回成“让 Axum 先算完再写 battle_state”的伪迁移。
因此本轮明确冻结为:
1. `module-combat` 先完成纯战斗状态推进;
2. `inventory_use` 留到 `inventory_slot` 与 runtime snapshot projection 口径稳定后再接。
---
## 3. `battle_state` 首版字段
首版 `battle_state` 冻结为以下字段:
1. `battle_state_id`
2. `story_session_id`
3. `runtime_session_id`
4. `actor_user_id`
5. `target_npc_id`
6. `target_name`
7. `battle_mode`
8. `status`
9. `player_hp`
10. `player_max_hp`
11. `player_mana`
12. `player_max_mana`
13. `target_hp`
14. `target_max_hp`
15. `chapter_id`
16. `experience_reward`
17. `reward_items`
18. `turn_index`
19. `last_action_function_id`
20. `last_action_text`
21. `last_result_text`
22. `last_damage_dealt`
23. `last_damage_taken`
24. `last_outcome`
25. `version`
26. `created_at`
27. `updated_at`
### 3.1 设计意图
首版只解决下面这些真相问题:
1. 当前战斗是否存在、是否仍在进行中;
2. 玩家与当前目标的 HP / MP 最小数值状态;
3. 当前是 `fight` 还是 `spar`
4. 当前战斗归属哪个章节;
5. 本场战斗若胜利应发多少经验;
6. 本场战斗若胜利应发哪些已编译好的 reward item
7. 最近一次动作结算了什么;
8. 当前 battle reducer 是否发生过版本推进。
### 3.2 当前刻意不放入的字段
本轮明确不放:
1. 多目标列表
2. 技能冷却 map
3. build buff 详情
4. 掉落预算、好感预算、剧情上下文大对象
5. 大型 `rawGameState` 镜像字段
原因很直接:这些都属于后续跨子域联动层,不适合在 `battle_state` 首版里重新堆一个大 JSON。
---
## 4. 枚举与动作口径
### 4.1 `BattleMode`
只保留两种:
1. `Fight`
2. `Spar`
### 4.2 `BattleStatus`
只保留三种:
1. `Ongoing`
2. `Resolved`
3. `Aborted`
说明:
1. `Resolved` 表示战斗已正常收束,包括胜利、切磋结束、成功逃脱。
2. `Aborted` 预留给后续 session 中断、外部清理、投影回滚等异常收束场景。
### 4.3 `CombatOutcome`
首版冻结:
1. `Ongoing`
2. `Victory`
3. `SparComplete`
4. `Escaped`
这与当前共享契约里的 `RuntimeBattlePresentation.outcome` 一致,避免首版就制造新的枚举翻译成本。
---
## 5. `resolve_combat_action` 首版规则
### 5.1 输入
首版 reducer 输入只包含:
1. `battle_state_id`
2. `function_id`
3. `action_text`
4. `base_damage`
5. `mana_cost`
6. `heal`
7. `mana_restore`
8. `counter_multiplier`
9. `updated_at_micros`
### 5.2 为什么允许输入 `base_damage`
本轮 `module-combat` 的职责是把战斗推进规则固定到 SpacetimeDB。
但玩家技能、装备 build、物品 buff、成长曲线这些正式真相仍未迁完因此首版允许上游把已算好的 `base_damage / mana_cost / heal / mana_restore` 作为确定输入传进 reducer。
这意味着当前模块边界是:
1. `module-combat` 负责状态推进、反击、逃跑、战斗收束规则;
2. 更高层的 build / skill / item 数值来源仍可在后续模块中逐步收敛;
3.`inventory / progression / runtime build` 真相表稳定后,再继续把这些输入收得更窄。
### 5.3 动作规则
#### A. `battle_escape_breakout`
直接结束战斗:
1. `status = Resolved`
2. `last_outcome = Escaped`
3. `last_damage_dealt = 0`
4. `last_damage_taken = 0`
#### B. `battle_recover_breath`
恢复类动作:
1. 玩家回复 `heal`
2. 玩家回复 `mana_restore`
3. 若战斗仍持续,则按 `counter_multiplier` 吃一次敌方反击
#### C. `battle_attack_basic` / 旧兼容攻击类 / `battle_use_skill`
攻击类动作:
1. 目标扣除 `base_damage`
2. 若目标已收束,则按 `battle_mode` 进入 `Victory / SparComplete`
3. 若目标未收束,则玩家按 `counter_multiplier` 吃一次敌方反击
### 5.4 反击规则
首版固定:
1. `fight` 下敌方基础反击伤害 = `max(4, round(target_max_hp * 0.14 * counter_multiplier))`
2. `spar` 下敌方基础反击伤害固定为 `1`
这是对当前 Node 逻辑的直接收敛,先保证行为方向不漂移,不在本轮发明新的战斗公式。
### 5.5 HP 下限规则
1. `fight` 下正常下限为 `0`
2. `spar` 下双方 HP 最低保留为 `1`
这样能保留当前“切磋点到为止”的旧行为,不把 `spar` 错结算成死亡战斗。
---
## 6. `spacetime-module` 接线口径
### 6.1 battle_state 表
`spacetime-module` 首版只新增一张 private 真相表:
1. `battle_state`
建议索引:
1. `by_story_session_id`
2. `by_runtime_session_id`
3. `by_actor_user_id`
### 6.2 reducer
当前仍只保留两个战斗 reducer
1. `create_battle_state`
2. `resolve_combat_action`
职责:
1. `create_battle_state` 只负责插入 battle 真相,不负责故事会话编排。
2. `resolve_combat_action` 负责推进 battle 真相。
3.`Victory` 收束时,由 `spacetime-module` 聚合层继续把 `experience_reward` 联动写入 `player_progression / chapter_progression`
4.`Victory` 收束且 `reward_items` 非空时,由 `spacetime-module` 聚合层继续把战利品写入 `inventory_slot`
5. `resolve_combat_action` 仍不负责 AI 续写和 quest signal 全量分发。
---
## 7. 与后续子域的边界
### 7.1 与 `story`
当前关系:
1. `story` 负责更高层 action 路由与后续 story_event 追加;
2. `combat` 只返回 battle 真相推进结果。
后续再补:
1. 战斗结束时的 `story_event`
2. 脱战后的 `continue_story` / `resolve_story_action`
### 7.2 与 `inventory`
当前不直接耦合到 `module-combat` reducer。
后续再补:
1. 战斗内 `inventory_use`
2. 消耗品扣减
3. 战斗 buff 写入
当前已存在的聚合层联动:
1. `Victory` 时可把 `battle_state.reward_items` 写入 `inventory_slot`
### 7.3 与 `progression`
当前不直接在 `module-combat` reducer 内发经验与等级变更。
后续再补:
1. hostile scaling 与 reward 编译口径
当前已存在的聚合层联动:
1. `fight_victory` 的经验发放
2. 章节账本写入
### 7.4 与 `npc`
当前不直接改好感。
后续再补:
1. `spar_complete` 的 affinity 变化
2. `fight / spar` 与 encounter 状态同步
---
## 8. 本轮验收口径
满足以下条件,视为本轮 `module-combat` 基线完成:
1. `server-rs/crates/module-combat` 已从 README 占位升级为真实 crate。
2. `battle_state``BattleMode``BattleStatus``CombatOutcome``ResolveCombatActionInput` 已冻结到代码。
3. `spacetime-module` 已新增 `battle_state` 表。
4. `spacetime-module` 已新增 `create_battle_state``resolve_combat_action` reducer。
5. `cargo check -p module-combat -p spacetime-module` 通过。
---
## 9. 下一步建议
在本轮基线稳定后,下一步按以下顺序推进最稳:
1. 设计 `inventory_slot` 与战斗内 `inventory_use` 的最小真相输入。
2. 设计 `resolve_story_action` 如何编排 `story + combat + npc + quest + inventory`
3.`battle_state` 结束事件接入 `story_event`
4. 再把 Axum facade 与 `RuntimeStoryActionResponse.battle` 真正打通。

View File

@@ -0,0 +1,202 @@
# M4 module-combat battle state 查询设计2026-04-22
更新时间:`2026-04-22`
补充状态:`2026-04-22`
当前 battle query 纵切片已经完成到“真实可编译、可生成 binding、可被 Axum 调用”的状态:
1. `spacetime-module` 中的 `get_battle_state` procedure 已稳定存在。
2. `spacetime-client/src/module_bindings` 已重新执行 `spacetime generate`,当前已真实包含:
- `battle_state_query_input_type`
- `get_battle_state_procedure`
- `battle_state.reward_items` 对应字段
3. `spacetime-client/src/lib.rs` 里原本返回“binding 尚未生成”的占位 `get_battle_state(...)` 已替换为真实 procedure 调用。
4. `cargo check -p spacetime-client``cargo check -p api-server` 已再次通过。
当前仍未完成的只有长时回归验证:
1. `cargo test -p api-server --bin api-server story_battles --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 在当前机器上编译耗时较长,尚未在单次时窗内拿到最终断言结果。
2. `npm run check:encoding` 已启动但尚未在单次时窗内跑完。
## 0. 文档目标
本文件只冻结当前 `M4` 的一个最小新增切片:
**新增 `GET /api/story/battles/:battleStateId`,让 Axum 能从 `SpacetimeDB` 同步读取单个 `battle_state` 当前快照,不提前承诺旧 runtime story state 兼容。**
这轮目标不是实现旧 `GET /api/runtime/story/state/:sessionId` 的战斗子视图兼容,也不是把 `battle + story_event + currentStory` 一次性收口进 `resolve_story_action`
---
## 1. 为什么先补这个切片
当前 battle 链路已经具备:
1. `module-combat` 已冻结 `battle_state` 领域类型与纯结算规则。
2. `spacetime-module` 已有 `create_battle_state_and_return``resolve_combat_action_and_return`
3. `spacetime-client``api-server` 已能创建战斗并推进单次动作。
但现在仍缺一个最基本的恢复能力:
1. battle 建立后Axum 还不能按 `battle_state_id` 重新读取真相态。
2. 页面刷新、重连或后续 story 编排都缺一个稳定的单战斗查询入口。
3. 后续若要把 battle 收口进 `resolve_story_action`,也需要先有独立 battle query 可复用。
因此本轮先补最小 `battle state` 查询切片,不提前跳到更重的 runtime story 兼容。
---
## 2. 当前冻结范围
本轮只包含以下能力:
1. 新增公开接口:`GET /api/story/battles/:battleStateId`
2. 认证方式Bearer JWT
3. 数据来源:`SpacetimeDB procedure get_battle_state`
4. 返回体只包含:
- `battleState`
本轮明确不做:
1. 不兼容旧 `GET /api/runtime/story/state/:sessionId`
2. 不补 battle 列表查询
3. 不做 `battle_state` 订阅与 cache 读模型
4. 不在查询链路里拼装 `story_event / npc / quest / inventory`
5. 不把 battle query 直接拼回旧 `RuntimeStoryActionResponse`
---
## 3. 接口 contract
### 3.1 请求
- 方法:`GET`
- 路径:`/api/story/battles/:battleStateId`
- 认证:必须携带 Bearer JWT
- 路径参数:
- `battleStateId`:目标战斗状态 ID
### 3.2 成功响应
成功响应延续当前 `api-server` 统一 envelope`data` 字段结构为:
```json
{
"battleState": {
"battleStateId": "battle_xxx",
"storySessionId": "storysess_xxx",
"runtimeSessionId": "runtime_xxx",
"actorUserId": "user_xxx",
"chapterId": "chapter_xxx",
"targetNpcId": "npc_xxx",
"targetName": "黑爪狼",
"battleMode": "fight",
"status": "ongoing",
"playerHp": 42,
"playerMaxHp": 60,
"playerMana": 12,
"playerMaxMana": 20,
"targetHp": 18,
"targetMaxHp": 30,
"experienceReward": 18,
"rewardItems": [],
"turnIndex": 1,
"lastActionFunctionId": "battle_attack_basic",
"lastActionText": "普通攻击",
"lastResultText": "普通攻击命中了黑爪狼,本次攻击已经完成结算。",
"lastDamageDealt": 12,
"lastDamageTaken": 4,
"lastOutcome": "ongoing",
"version": 2,
"createdAt": "2026-04-22T00:00:00.000000Z",
"updatedAt": "2026-04-22T00:01:00.000000Z"
}
}
```
### 3.3 错误响应
当前延续 battle facade 已有策略:
1. `SpacetimeClientError::Runtime(_)` 映射为 `400`
2. 其他 `SpacetimeClientError` 映射为 `502`
3. 错误 `details.provider` 固定为 `spacetimedb`
---
## 4. 分层职责
### 4.1 `module-combat`
职责:
1. 冻结 `BattleStateQueryInput`
2. 负责 query input builder 与 validator
3. 继续复用 `BattleStateProcedureResult` 作为最小查询返回壳
不负责:
1. HTTP 路径解析
2. JWT 鉴权
3. battle view model 编译
### 4.2 `spacetime-module`
职责:
1. 读取 `battle_state`
2. 校验 `battle_state_id`
3. 返回单个 `BattleStateSnapshot`
### 4.3 `spacetime-client`
职责:
1. 构造 `BattleStateQueryInput`
2. 调用 `get_battle_state`
3. 把 generated binding 结果映射为 `BattleStateRecord`
当前实现补充:
1. `reward_items` 已按 generated binding 映射回 `BattleStateRecord.reward_items`,不再用空集合占位。
2. battle query 当前不再依赖 façade stub 或手写假返回。
### 4.4 `api-server`
职责:
1. 暴露 `GET /api/story/battles/:battleStateId`
2. 做 Bearer JWT 鉴权
3. 透传 `battleStateId`
4.`BattleStateRecord` 映射到 battle JSON payload
---
## 5. 验收口径
本轮验收只要求以下几点:
1. `api-server` 路由树已真实挂出该接口
2. 未登录访问返回 `401`
3.`SpacetimeDB` 未发布或未连通时返回 `502`
4. `cargo test -p api-server story_battles` 可通过
5. `cargo check -p module-combat -p spacetime-module -p spacetime-client -p api-server` 可通过
6. `npm run check:encoding` 已执行,确保新增中文文档没有编码损坏
当前验证状态:
1. 第 5 条已达成。
2. 第 4、6 条仍在继续追,不应提前宣称通过。
---
## 6. 后续边界
这条最小 battle query 落地后,后续再继续拆下一层:
1. 评估 battle 查询是否需要补 actor ownership 校验
2. 设计 battle 结束事件如何接入 `story_event`
3. 再把 battle query 与 `story state / resolve_story_action / currentStory` 汇总到更高层编排
在这些 contract 未冻结前,不应把当前接口误称为“旧 runtime story state 已迁移完成”。

View File

@@ -0,0 +1,188 @@
# M4 module-npc battle Axum facade 设计2026-04-22
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**把已经在 `spacetime-module` 落地的 `resolve_npc_battle_interaction_and_return` procedure 再向上接到 `spacetime-client` 与 `api-server`,并允许 HTTP 侧透传已编译好的 `experience_reward / reward_items`,形成可直接调用的 NPC 开战同步返回链。**
这不是完整 `resolve_story_action` 兼容设计,也不替代后续 runtime story 总入口编排。
---
## 1. 本轮要解决的问题
当前仓库已经具备:
1. `module-npc`
- `resolve_npc_interaction`
- `npc_fight / npc_spar -> BattlePending`
2. `module-combat`
- `battle_state`
- `resolve_combat_action`
3. `spacetime-module`
- `resolve_npc_battle_interaction_and_return`
- 同事务写入 `npc_state + battle_state`
但当前仍缺两层:
1. `spacetime-client` 还没有对应 facade
2. `api-server` 还没有独立 NPC 开战 HTTP 入口
因此本轮只补下面两层:
1. `spacetime-client` facade
2. `api-server` HTTP route
---
## 2. 当前刻意不做的事
本轮明确不做:
1. 不兼容旧 `POST /api/runtime/story/actions/resolve`
2. 不把 `npc_chat / npc_help / npc_recruit / npc_leave` 一起搬成统一 HTTP facade
3. 不在接口层现场计算章节自动定级、经验奖励、掉落、story_event 自动联动
4. 不把 battle 结果直接拼进旧 `RuntimeStoryActionResponse`
原因很直接:
1. 这轮目标只是把 `npc_fight / npc_spar` 的同步返回链闭环
2. 更高层 story action 编排仍应等待 `resolve_story_action` 统一设计
---
## 3. `spacetime-client` 口径
### 3.1 新增 facade
本轮新增:
1. `resolve_npc_battle_interaction`
### 3.2 输入
直接复用 `spacetime-module` procedure 输入:
1. `ResolveNpcBattleInteractionInput`
客户端 facade 负责:
1.`resolve_npc_battle_interaction_and_return`
2. 把 binding 结果映射成 Rust record
3. 统一沿用现有 `oneshot + timeout` 返回模式
### 3.3 输出
本轮冻结新的 client record
1. `NpcStateRecord`
2. `NpcInteractionRecord`
3. `NpcBattleInteractionRecord`
这样可以避免 `api-server` 直接依赖 generated binding 结构。
---
## 4. `api-server` 口径
### 4.1 路由
本轮新增:
1. `POST /api/story/npc/battle`
这条路由的定位是:
1. 独立的 NPC 开战 facade
2. 明确只处理 `npc_fight / npc_spar`
3. 返回:
- `npcInteraction`
- `battleState`
### 4.2 输入
首版 HTTP 请求字段冻结为:
1. `storySessionId`
2. `runtimeSessionId`
3. `npcId`
4. `npcName`
5. `interactionFunctionId`
- 当前只允许:
- `npc_fight`
- `npc_spar`
6. `releaseNpcId`
7. `battleStateId`
8. `playerHp`
9. `playerMaxHp`
10. `playerMana`
11. `playerMaxMana`
12. `targetHp`
13. `targetMaxHp`
14. `experienceReward`
- 默认 `0`
- 只接受上游已编译好的确定值
15. `rewardItems`
- 默认空数组
- 每项字段与 `RuntimeItemRewardItemSnapshot` 对齐
- `rarity` 固定使用:
- `common`
- `uncommon`
- `rare`
- `epic`
- `legendary`
- `equipmentSlotId` 当前只允许:
- `weapon`
- `armor`
- `relic`
### 4.3 返回
当前 HTTP 成功响应冻结为:
1. `npcInteraction`
2. `battleState`
其中:
1. `npcInteraction` 保留:
- `npcState`
- `interactionStatus`
- `actionText`
- `resultText`
- `storyText`
- `battleMode`
- `encounterClosed`
- `affinityChanged`
- `previousAffinity`
- `nextAffinity`
2. `battleState` 继续复用 battle facade 已有 payload 结构
---
## 5. 错误策略
与现有 `story_battles` / `story_sessions` 保持一致:
1. `SpacetimeClientError::Runtime`
- 映射 `400`
2. 其他 Spacetime 调用错误
- 映射 `502`
错误 body 继续统一返回:
1. `provider`
2. `message`
---
## 6. 后续建议
在这条 facade 稳定后,下一步按下面顺序推进:
1. 让前端 runtime story action 先走这条独立 NPC 开战入口
2. 再把 battle 初始化所需的 NPC 等级、经验奖励、reward item 编译来源、章节信息收口进更高层编排
3. 最后再统一进完整 `resolve_story_action`

View File

@@ -0,0 +1,151 @@
# M4 module-npc 与 module-combat 联合编排基线2026-04-21
更新时间:`2026-04-22`
## 0. 文档目标
本文件只冻结一件事:
**在不污染 `module-npc` 纯领域边界的前提下,把 `npc_fight / npc_spar` 从“只返回 `BattlePending` 语义”推进到“可在 `spacetime-module` 聚合层同步创建 `battle_state`”的最小联合编排口径。**
这不是完整 `resolve_story_action` 设计,也不是完整战斗奖励编译、经验预算和剧情续写迁移。
---
## 1. 本轮落地范围
本轮只落实下面 4 件事:
1. 明确 `module-npc` 继续只负责 NPC 交互语义,不直接依赖 `module-combat`
2.`spacetime-module` 聚合层新增 `resolve_npc_battle_interaction_and_return` procedure。
3. 让该 procedure 在同一事务内完成:
- `resolve_npc_interaction`
- `battle_state` 初始化写入
4. 返回统一结果,供后续 `spacetime-client` / Axum facade 直接消费。
---
## 2. 为什么不把 battle 初始化塞进 module-npc
原因很直接:
1. `module-npc` 当前职责是 `npc_state / relation_state / stance_profile / interaction contract`
2. `battle_state` 属于 `module-combat` 真相,不应倒灌进 NPC 领域 crate。
3. 如果把玩家 HP / MP、战斗生命、故事会话 ID 这些字段直接塞进 `ResolveNpcInteractionInput`,会把 `module-npc` 再次膨胀成跨子域入口。
因此本轮明确冻结为:
1. `module-npc`
- 继续只返回 `BattlePending + battle_mode`
2. `spacetime-module`
- 负责把 NPC 交互结果编排成真正的 `battle_state`
---
## 3. 新增 procedure 口径
### 3.1 名称
新增:
1. `resolve_npc_battle_interaction_and_return`
### 3.2 输入
首版输入冻结为:
1. `npc_interaction`
- 原样复用 `ResolveNpcInteractionInput`
- 当前只允许 `npc_fight / npc_spar`
2. `story_session_id`
3. `actor_user_id`
4. `battle_state_id`
- 允许为空
- 为空时按 `updated_at_micros` 自动派生
5. `player_hp`
6. `player_max_hp`
7. `player_mana`
8. `player_max_mana`
9. `target_hp`
10. `target_max_hp`
11. `experience_reward`
- 由上游作为已编译好的确定奖励透传
- 当前允许为 `0`
12. `reward_items`
- 类型固定为 `Vec<module-runtime-item::RuntimeItemRewardItemSnapshot>`
- 只承接已经编译好的战利品快照,不在 procedure 内现场生成
### 3.3 输出
当前返回:
1. `interaction`
- `module-npc::NpcInteractionResult`
2. `battle_state`
- `module-combat::BattleStateSnapshot`
也就是说,这个 procedure 明确是一个**聚合返回口径**,不是新的底层领域真相。
---
## 4. 当前事务流程
单次调用按下面顺序执行:
1. 校验 `story_session_id / actor_user_id`
2. 校验 `interaction_function_id` 必须是:
- `npc_fight`
- `npc_spar`
3. 先执行 `resolve_npc_interaction_record`
- 写入最新 `npc_state`
- 拿到 `NpcInteractionResult`
4.`NpcInteractionResult.battle_mode` 映射出 `BattleMode`
5. 组装 `BattleStateInput`
- 透传 `experience_reward`
- 透传 `reward_items`
6. 复用 `module-combat``validate_battle_state_input`
7. 插入 `battle_state`
8. 返回:
- `interaction`
- `battle_state`
---
## 5. 当前刻意未做
本轮明确不做下面这些扩张:
1. 不在这个 procedure 里直接发经验
2. 不在这个 procedure 里直接记 `chapter_progression`
3. 不在这个 procedure 里直接写 `story_event`
4. 不在这个 procedure 里现场计算掉落或经验预算
5. 不在这个 procedure 里直接执行 `inventory_slot` 发物
5. 不在这个 procedure 里直接接 `resolve_combat_action`
6. 不在这个 procedure 里推导敌方等级、强度、掉落预算
也就是说,这一层当前只解决:
**NPC 宣告开战后,如何立刻把 battle 真相表连同已编译奖励真相一起建立起来。**
---
## 6. 与现有文档的关系
本文件是对下面两份基线文档的补充,而不是替代:
1. `M4_MODULE_NPC_SPACETIMEDB_BASELINE_2026-04-21.md`
- 继续定义 NPC 领域 contract
2. `M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`
- 继续定义 battle_state 与单行为战斗推进规则
新增编排只发生在 `spacetime-module` 聚合层。
---
## 7. 下一步建议
在这条最小联合编排稳定后,后续按下面顺序推进最稳:
1. 把 Node 侧 `monster_drop` / hostile reward 编译逻辑收口到 Rust 聚合层。
2. 再把章节自动定级、敌对经验预算和 `chapter_progression` 所需章节上下文收口进 battle 初始化编译器。
3. 最后把这条链收口进完整 `resolve_story_action`

View File

@@ -0,0 +1,273 @@
# M4 module-npc SpacetimeDB 基座记录2026-04-21
更新时间:`2026-04-21`
## 0. 文档目标
本文件只记录一件事:
**把 `module-npc` 从“只有占位 README”推进到“已有可编译 Rust 领域 contract并接入 `SpacetimeDB` 最小 `npc_state` 真相表与社交动作 reducer/procedure”的真实落地结果。**
本轮只做最小基座,不扩到完整 `npc_trade / npc_gift / npc_recruit` 的全链结算迁移,也不改前端 UI。
---
## 1. 本轮落地范围
本轮只落实下面 6 件事:
1. 新增 `server-rs/crates/module-npc/` 真实 crate而不是继续停留在目录占位。
2.`module-npc` 中冻结 `relation_state / stance_profile / npc_state` 的首版领域类型与校验 helper。
3.`module-npc` 中补齐 `build_initial_stance_profile``normalize_npc_state_snapshot``apply_npc_social_action` 等最小规则原语。
4.`server-rs/crates/spacetime-module/` 中新增 `npc_state` 表。
5.`spacetime-module` 中新增 `upsert_npc_state``resolve_npc_social_action` 及对应 procedure形成最小可编译写入口。
6.`module-npc` 中新增 `resolve_npc_interaction` 的首版领域 contract并在 `spacetime-module` 中补对应 reducer / procedure。
---
## 2. 本轮新增的真实工程落点
### 2.1 新增 crate
1. `server-rs/crates/module-npc/Cargo.toml`
2. `server-rs/crates/module-npc/src/lib.rs`
### 2.2 workspace 与主工程聚合
1. `server-rs/Cargo.toml`
- 已把 `crates/module-npc` 纳入 workspace members
2. `server-rs/crates/spacetime-module/Cargo.toml`
- 已接入 `module-npc` 依赖
3. `server-rs/crates/spacetime-module/src/lib.rs`
- 已接入 `module-npc` 类型
- 已新增 `npc_state`
- 已新增 `upsert_npc_state`
- 已新增 `upsert_npc_state_and_return`
- 已新增 `resolve_npc_social_action`
- 已新增 `resolve_npc_social_action_and_return`
- 已新增 `resolve_npc_interaction`
- 已新增 `resolve_npc_interaction_and_return`
---
## 3. 当前冻结的数据口径
### 3.1 `relation_state`
当前首版冻结为:
1. `affinity`
2. `stance`
`stance` 当前只冻结 5 档:
1. `Hostile`
2. `Guarded`
3. `Neutral`
4. `Cooperative`
5. `Bonded`
当前阈值直接对齐现有前端 / Node 原语:
1. `< 0` -> `Hostile`
2. `< 15` -> `Guarded`
3. `< 30` -> `Neutral`
4. `< 60` -> `Cooperative`
5. `>= 60` -> `Bonded`
### 3.2 `stance_profile`
当前首版冻结为:
1. `trust`
2. `warmth`
3. `ideological_fit`
4. `fear_or_guard`
5. `loyalty`
6. `current_conflict_tag`
7. `recent_approvals`
8. `recent_disapprovals`
字段策略:
1. 数值统一收敛到 `0 ~ 100`
2. 最近好评 / 反感文本统一只保留最近 `3` 条。
3. `current_conflict_tag` 仍允许为空,不在本轮强绑世界线程 ID。
### 3.3 `npc_state`
当前首版字段冻结为:
1. `npc_state_id`
2. `runtime_session_id`
3. `npc_id`
4. `npc_name`
5. `affinity`
6. `relation_state`
7. `help_used`
8. `chatted_count`
9. `gifts_given`
10. `recruited`
11. `trade_stock_signature`
12. `revealed_facts`
13. `known_attribute_rumors`
14. `first_meaningful_contact_resolved`
15. `seen_backstory_chapter_ids`
16. `stance_profile`
17. `created_at`
18. `updated_at`
当前策略:
1. `npc_state` 保持 private 真相表口径。
2. `npc_state_id` 允许由 `runtime_session_id + npc_id` 自动派生,避免外部每次重复拼接。
3. `relation_state` 作为显式冗余字段落表,避免每次读取都重复派生。
4. `npc_name` 当前保留为调试与兼容聚合字段,不承担唯一键职责。
---
## 4. 当前 reducer / procedure 口径
### 4.1 `upsert_npc_state`
当前负责:
1. 校验 `runtime_session_id / npc_id / npc_name`
2. 归一化 `stance_profile`
3. 归一化 `relation_state`
4.`npc_state_id` 为主键执行幂等写入
### 4.2 `resolve_npc_social_action`
当前只承接 **纯 NPC 关系状态** 的最小变更,不负责背包、任务、队伍、战斗副作用。
当前动作冻结为:
1. `Chat`
2. `Help`
3. `Gift`
4. `Recruit`
5. `QuestAccept`
当前规则:
1. `Chat`
- 默认按 `max(2, 6 - chatted_count)` 推进好感
- 递增 `chatted_count`
- 强制标记 `first_meaningful_contact_resolved = true`
2. `Help`
- 若已使用过援手则拒绝
- 默认推进 `4` 点好感
- 写入 `help_used = true`
3. `Gift`
- 递增 `gifts_given`
- 默认按 `4` 点好感推进,允许外部显式传入覆盖值
4. `Recruit`
- 若当前好感 `< 60` 则拒绝
- 写入 `recruited = true`
- 同时标记首遇已完成
5. `QuestAccept`
- 默认推进 `3` 点好感
- 只改 NPC 关系侧立场数据,不直接落 quest 真相
当前 procedure 仅返回最新 `NpcStateSnapshot`,不在本轮提前扩出 story patch / UI 文案 contract。
### 4.3 `resolve_npc_interaction`
当前首版 `resolve_npc_interaction` 不直接承担所有跨子域副作用,而是先固定 **NPC 单次正式交互** 的最小统一结果口径。
当前输入冻结为:
1. `runtime_session_id`
2. `npc_id`
3. `npc_name`
4. `interaction_function_id`
5. `updated_at_micros`
6. `release_npc_id`(仅为后续招募换队预留,当前不在 Rust 侧正式消费)
当前支持的 function 只冻结为:
1. `npc_preview_talk`
2. `npc_chat`
3. `npc_help`
4. `npc_recruit`
5. `npc_fight`
6. `npc_spar`
7. `npc_leave`
当前输出冻结为:
1. `npc_state`
2. `interaction_status`
3. `action_text`
4. `result_text`
5. `story_text`
6. `battle_mode`
7. `encounter_closed`
8. `affinity_changed`
9. `previous_affinity`
10. `next_affinity`
当前规则:
1. `npc_preview_talk`
- 只把交互状态切到 `Previewed`
- 不改好感
2. `npc_chat`
- 复用 `resolve_npc_social_action(Chat)` 的关系推进
- 返回 `interaction_status = Dialogue`
3. `npc_help`
- 复用 `resolve_npc_social_action(Help)`
- 返回 `interaction_status = Resolved`
4. `npc_recruit`
- 当前只负责把 `npc_state.recruited = true`
- 不在本轮承担 companion / roster 真相写入
- 返回 `interaction_status = Recruited`
5. `npc_fight`
- 不改 `npc_state.affinity`
- 返回 `interaction_status = BattlePending`
- `battle_mode = Fight`
6. `npc_spar`
- 不改 `npc_state.affinity`
- 返回 `interaction_status = BattlePending`
- `battle_mode = Spar`
7. `npc_leave`
- 不改关系真相
- 返回 `interaction_status = Left`
- `encounter_closed = true`
当前刻意不做:
1. 不直接生成 `RuntimeStoryPatch`
2. 不直接写 `companions / roster / inventory_slot`
3. 不直接把玩家 HP / MP、切磋战斗目标、战斗奖励塞进这个 reducer
也就是说,这一层当前只负责把 **Node 侧 `resolveNpcInteraction` 的统一入口语义** 先冻结为可编译 contract不宣称已经迁完全部副作用。
---
## 5. 当前刻意未做
本轮明确没有扩到以下范围:
1. 还没有落 `npc_trade` 的库存与价格正式结算
2. 还没有落 `npc_gift` 的背包扣减与物品收益结算
3. 还没有落 `npc_recruit` 的队伍替换与 companion 真相迁移
4. `npc_fight / npc_spar` 的正式 `battle_state` 初始化编排不在 `module-npc` crate 内部完成,而是下沉到 `spacetime-module` 聚合 procedure
5. 还没有把 `custom world``narrativeProfile / backstoryReveal` 真正投影进 SpacetimeDB
6. 还没有把 Node 侧 `npcInteractionService` 全量切到 `server-rs`
7. 还没有给前端接入 `SpacetimeDB` 的 NPC 订阅读模型
也就是说,本轮只是把 **NPC 关系状态基座** 立起来,不宣称已经完成完整 NPC 子域迁移。
---
## 6. 下一步建议
后续应继续按下面顺序推进:
1.`npc_recruit` 的 companion / roster 真相迁移拆成 `module-npc + module-runtime + module-story` 的联合 reducer 设计。
2.`spacetime-client` / Axum 侧继续把 `npc_fight / npc_spar``battle_state` 联合编排接口接出来。
3.`npc_trade / npc_gift` 的正式库存、扣减与收益迁到 `inventory / runtime-item` 联动链。
4.`backstoryReveal / privateChatUnlockAffinity / narrativeProfile` 的可见性规则投成显式读模型。
5. 再接 `api-server` 的 NPC facade 与前端 runtime action。

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