4 Commits

561 changed files with 22224 additions and 14665 deletions

View File

@@ -28,6 +28,9 @@ GENARRATIVE_RUNTIME_SERVER_TARGET=""
# and by the standalone Rust dev / deploy scripts.
GENARRATIVE_API_PORT="3100"
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
GENARRATIVE_ADMIN_USERNAME=""
GENARRATIVE_ADMIN_PASSWORD=""
GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS="14400"
GENARRATIVE_INTERNAL_API_SECRET="CHANGE_ME_FOR_PRODUCTION"
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3001"
GENARRATIVE_SPACETIME_DATABASE="genarrative-dev"

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,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,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

@@ -23,7 +23,9 @@ RPG 创作结果页已经能看到完整草稿内容,但页面底部仍然持
2. 场景章节主链字段为 `sceneChapterBlueprints`
3. `settingText` 也会承载世界总体一句话设定
`server-rs/crates/spacetime-module/src/custom_world/mod.rs``summarize_publish_gate_from_json(...)` 仍只检查旧字段:
问题最初在拆分后的 `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`
@@ -36,11 +38,13 @@ RPG 创作结果页已经能看到完整草稿内容,但页面底部仍然持
2. 发布门槛检查读的是旧字段
3. 同一个草稿在 UI 看起来“已经有内容”,但 gate 仍然误判为缺失
此外,正式发布编译在把 session draft 编译成发布 profile 时,也只把 `sceneChapters` 映射为 `sceneChapterBlueprints`,没有兼容当前更常见的 `sceneChapterBlueprints` 输入
因此会出现“拆分模块里的代码已经对齐,但页面实际 blocker 仍然不消失”的假象
此外,`lib.rs` 里的最小草稿兜底结构也没有补上 `sceneChapterBlueprints` 默认槽位,导致部分恢复、回滚和草稿兜底链路继续偏向旧 schema。
## 3. 修复策略
本轮统一把发布门槛与发布编译对齐到当前前端主链 schema
本轮统一把实际入口 `server-rs/crates/spacetime-module/src/lib.rs` 的发布门槛与最小草稿结构对齐到当前前端主链 schema
1. `world hook` 检查同时兼容:
- `worldHook`
@@ -60,11 +64,12 @@ RPG 创作结果页已经能看到完整草稿内容,但页面底部仍然持
4. 主线第一幕检查优先读取:
- `sceneChapterBlueprints[*].acts`
- `sceneChapters[*].acts`
5. 发布编译时,`sceneChapterBlueprints` 与旧 `sceneChapters` 都能写入最终 profile
5. 最小草稿兜底结构同时补上 `sceneChapterBlueprints` 空数组,避免恢复链路重新回落到旧字段集合
## 4. 验收标准
1. 结果页已包含 `anchorContent / creatorIntent / sceneChapterBlueprints` 的草稿,不再被旧 blocker 误判。
2. `publishReady` 会随当前 session 最新 preview 正确刷新。
3. “发布并进入世界”在 blocker 清空后恢复可点击。
4. 正式发布后的 compiled profile 仍保留 `sceneChapterBlueprints`
4. `ensure_minimal_draft_profile(...)` 生成的兜底草稿也包含 `sceneChapterBlueprints`
5. 新增 Rust 单测,覆盖“当前 Agent 结果 schema 不应再误报 blocker”与“最小草稿必须保留 `sceneChapterBlueprints` 默认槽位”。

View File

@@ -15,7 +15,9 @@
| --- | --- | --- | --- | --- |
| RPG Agent 草稿 | `draft` | `继续创作` / `继续完善` | 不展示,草稿需要先走发布链 | 不展示,本轮不新增 Agent session 物理删除 |
| RPG 已发布作品 | `published``canEnterWorld=true` | `查看详情` | 展示 `体验`,直接调用现有进入世界链 | 展示 `删除`,走 owner-only 软删除 |
| 拼图草稿 | `draft` | `查看详情` | 不展示 | 不展示,本轮不新增拼图删除契约 |
| Big Fish 草稿 | `draft` | `继续创作` | 不展示,草稿需要先回到聊天或结果页继续完善 | 不展示,本轮不新增 Big Fish 草稿删除 |
| Big Fish 已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用现有 Big Fish 运行态 | 不展示,本轮不新增 Big Fish 删除契约 |
| 拼图草稿 | `draft` | `继续创作` | 不展示 | 不展示,本轮不新增拼图删除契约 |
| 拼图已发布作品 | `published` | `查看详情` | 展示 `体验`,直接调用 `startPuzzleRun` | 不展示,本轮不新增拼图删除契约 |
## 3. 后端边界
@@ -40,6 +42,7 @@ RPG 删除必须继续遵守后端治理里的软删除规则:
2. 不新增拼图作品删除。
3. 不新增独立删除面板。
4. 不新建创作页或运行时页面,只复用现有 `CustomWorldCreationHub`、RPG 进入世界链和拼图运行时链。
5. Big Fish 草稿恢复链补齐时,只补创作中心 works 投影和恢复入口,不新建独立 Big Fish 作品系统。
## 6. 已落地结果
@@ -47,6 +50,8 @@ 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. 已验证

View File

@@ -0,0 +1,175 @@
# 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`

View File

@@ -0,0 +1,277 @@
# M6 角色动作外部真实生成 Stage 2 设计
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结 `POST /api/assets/character-animation/generate` 从 Stage 1 占位动作链切到真实外部动作生成后的实现口径。
本阶段优先级最高的是当前前端正在使用的 `image-to-video` 主链;如条件允许,再同步补齐 `image-sequence``motion-transfer``reference-to-video` 的真实实现。
## 2. 当前问题
Stage 1 当前存在以下占位行为:
1. `CHARACTER_ANIMATION_MODEL = "rust-placeholder-character-animation"`
2. `image-sequence` 直接产出 SVG 帧
3. 视频类策略会复用参考视频或仓库占位预览视频
4. 即使返回 `previewVideoPath`,也不代表真实调用了外部视频模型
这会导致角色资产工坊的动作生成不能通过“真实外部生成”验证。
## 3. Stage 2 范围
### 3.1 本阶段必须完成
1. `image-to-video` 改为真实 Ark 视频生成
2. 继续兼容前端当前请求字段
3. 继续把预览视频写入 `generated-character-drafts/*`
4. 任务查询和正式发布 contract 继续保持兼容
5. 删除 `image-to-video` 的占位视频回退
### 3.2 争取同步完成
1. `image-sequence` 接真实 DashScope 连续图片生成
2. `motion-transfer` 接真实 DashScope 视频驱动
3. `reference-to-video` 接真实 DashScope 参考生视频
### 3.3 本阶段不做
1. 不新增 SpacetimeDB 动作任务表
2. 不改前端调用字段名
3. 不把动作生成迁回 Node
## 4. 主链优先级
当前前端 `useRoleAnimationWorkflow.ts` 固定使用:
1. `strategy = image-to-video`
2. `videoModel = doubao-seedance-2-0-fast-260128`
3. `motionTransferModel = wan2.2-animate-move`
4. `referenceVideoModel = wan2.7-r2v`
因此 Stage 2 的必须项是先把 `image-to-video` 主链迁成真实实现。
## 5. image-to-video 真实协议
### 5.1 上游与模型
`image-to-video` 统一走 Ark
1. 基础 URL`ARK_CHARACTER_VIDEO_BASE_URL``ARK_BASE_URL`
2. 默认 URL`https://ark.cn-beijing.volces.com/api/v3`
3. API Key`ARK_CHARACTER_VIDEO_API_KEY``ARK_API_KEY`
4. 默认模型:`doubao-seedance-2-0-fast-260128`
### 5.2 创建任务接口
请求:
`POST {ARK_BASE_URL}/contents/generations/tasks`
请求体固定遵循旧 Node 已验证协议:
1. `model`
2. `content[0] = { type: "text", text: prompt }`
3. `content[1] = { type: "image_url", role: "first_frame", image_url: { url } }`
4. `content[2] = { type: "image_url", role: "last_frame", image_url: { url } }`
5. `resolution = "480p"`
6. `ratio = "1:1"`
7. `duration = 4`
8. `watermark = false`
说明:
1. 即使前端传 `720P / 16:9 / 7s`Stage 2 也必须按现网主链固定为 `480p / 1:1 / 4`
2. 这是旧 Node 测试已经冻结的真实协议
### 5.3 轮询接口
请求:
`GET {ARK_BASE_URL}/contents/generations/tasks/{taskId}`
成功判定:
1. 返回状态进入 `completed/succeeded/done` 等完成态
2. 或响应里已经能抽取到 `video_url`
失败判定:
1. `failed`
2. `canceled/cancelled`
3. `error`
4. `rejected`
5. `expired`
6. `unknown`
## 6. 输入媒体解析
### 6.1 首帧与尾帧
`image-to-video` 需要:
1. `visualSource` 作为首帧主参考
2. `lastFrameImageDataUrl` 存在时用作尾帧
3. 若未传尾帧,则回落到 `visualSource`
### 6.2 支持的输入
媒体源继续兼容:
1. Data URL
2. `/generated-*` 旧路径
Rust 需要把它们统一转成可直接给 Ark 的 Data URL。
## 7. Prompt 口径
### 7.1 image-to-video prompt
Stage 2 要求尽量对齐旧 Node `buildArkCharacterAnimationPrompt` 语义,至少包含:
1. 单人 NPC 全身动作视频
2. 动作英文名
3. 角色固定为首尾两张图中的同一人
4. 右向斜侧身
5. 轮廓清晰,武器不可丢失
6. 避免多角色与镜头切换
7. 纯绿色绿幕
8. 首帧严格使用图片 1
9. 尾帧严格使用图片 2
### 7.2 审核降级
`image-to-video` 允许在命中不当内容时重试一次保守 prompt。
重试规则:
1. 第一次请求用正式 prompt
2. 若 Ark 返回错误信息明确命中不当内容
3. 且存在保守 prompt
4. 则用保守 prompt 再请求一次
除此之外不允许再回退到占位视频。
## 8. 其他策略口径
### 8.1 image-sequence
若本轮同步落地:
1. 走 DashScope 图片生成接口
2. 请求体沿用旧 Node 的 `image-generation/generation`
3. `messages[0].content = [{ text }, { image }, ...]`
4. `parameters.n = frameCount`
5. `parameters.size = 768*1024`
6. `parameters.enable_sequential = true`
7. `parameters.prompt_extend = true`
8. `parameters.watermark = false`
### 8.2 motion-transfer
若本轮同步落地:
1. 走 DashScope `image2video/video-synthesis`
2. 先把主图和参考视频上传为 `oss://` 资源
3. 请求头显式带:
1. `X-DashScope-Async: enable`
2. `X-DashScope-OssResourceResolve: enable`
### 8.3 reference-to-video
若本轮同步落地:
1. 走 DashScope `video-generation/video-synthesis`
2. 支持主图、参考图、参考视频混合媒体
3. 同样依赖 DashScope upload policy 上传为 `oss://` 资源
## 9. 预览视频落 OSS
所有真实视频策略成功后都必须:
1. 下载远程 `video_url`
2.`preview.mp4` 落到 `generated-character-drafts/{character}/animation/{animation}/{taskId}/`
3. 返回 `/generated-character-drafts/*/preview.mp4`
Stage 2 明确禁止:
1. 复用仓库内占位视频
2. 仅在 `referenceVideoDataUrls[0]` 不为空时偷传参考视频作为结果
## 10. 配置项
### 10.1 DashScope
继续使用已有:
1. `DASHSCOPE_BASE_URL`
2. `DASHSCOPE_API_KEY`
3. `DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS`
### 10.2 Ark
Stage 2 新增动作视频专属配置:
1. `ARK_CHARACTER_VIDEO_BASE_URL`
2. `ARK_CHARACTER_VIDEO_API_KEY`
3. `ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS`
4. `ARK_CHARACTER_VIDEO_MODEL`
兼容回退顺序:
1. 先读 `ARK_CHARACTER_VIDEO_*`
2. 再读 `ARK_*`
3. 最后回退到默认值
## 11. 错误与回退策略
### 11.1 直接报错的情况
以下情况不再允许伪成功:
1. 缺少 `ARK_API_KEY` 且策略为 `image-to-video`
2. 上游创建任务失败
3. 上游轮询失败
4. 上游成功但没有视频 URL
5. 下载视频失败
6. OSS 写入失败
### 11.2 禁止继续保留的回退
1. `load_stage1_placeholder_preview_video()`
2. 无模型调用时伪造 `previewVideoPath`
3. `image-sequence` 继续用 SVG 帧充当真实结果
## 12. 任务状态口径
继续复用现有阶段:
1. `prepare_prompt`
2. `request_model`
3. `normalize_result`
4. `persist_result`
新增要求:
1. `request_model` 需记录真实上游任务 id
2. `normalize_result` 需记录 `previewVideoPath``imageSources`
3. `failed` 状态必须返回真实错误消息,不再隐藏为占位成功
## 13. 验收标准
满足以下条件视为 Stage 2 完成:
1. 角色资产工坊点击生成动作时,`image-to-video` 会真实请求 Ark
2. 请求体包含 `first_frame``last_frame`
3. 请求参数固定为 `480p / 1:1 / 4`
4. 返回视频真实来自上游而非占位文件
5. 预览视频成功写入 OSS 草稿区
6. 正式发布链仍能产出 `generated-animations/*` manifest
7. 不再依赖仓库内占位视频完成主流程
## 14. 关联文档
1. [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
2. [ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md)
3. [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)

View File

@@ -0,0 +1,258 @@
# M6 角色动作后端抽帧与正式发布 Stage 3 设计
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结角色动作资产链路从“后端生成预览视频,前端抽帧后回传发布”继续推进到“后端生成预览视频,后端抽帧、抠绿幕、上传 OSS、落库绑定”的 Stage 3 实现口径。
本阶段的目标不是新增一套全新动作系统,而是在现有 `POST /api/assets/character-animation/generate``POST /api/assets/character-animation/publish` 的 contract 基础上,尽量用最小迁移把动作资产正式发布链完整收口到 Rust `api-server`
## 2. 当前问题
截至 `2026-04-23` Stage 2 完成后,角色动作链路已经具备以下能力:
1. `image-to-video` 会真实请求 Ark 生成 `preview.mp4`
2. 预览视频会真实写入 OSS 草稿区
3. 正式发布时会把动作 manifest、动作集 manifest 上传 OSS并通过 `asset_object + asset_entity_binding` 绑定到角色
但仍存在一个关键断点:
1. `preview.mp4` 的抽帧仍在前端浏览器完成
2. 去绿幕仍在前端 `canvas` 中完成
3. 前端要把整组 `framesDataUrls` 再回传给后端,才会触发正式发布
这与当前工程约束冲突:
1. 前端只负责表现,不负责正式资产加工
2. 外部 I/O、文件处理、OSS 写入应尽量统一收口在后端
## 3. Stage 3 范围
### 3.1 本阶段必须完成
1. `character-animation/publish` 支持仅凭 `previewVideoPath` 在后端完成抽帧
2. 后端抽帧后负责做去绿幕、尺寸归一、PNG 输出、正式帧上传
3. 正式动作 manifest 与动作集 manifest 继续由后端生成并上传 OSS
4. 正式动作集继续由后端确认 `asset_object` 并绑定到角色
5. 前端主链不再要求本地生成 `framesDataUrls`
### 3.2 本阶段必须保留的兼容能力
1. 若前端仍传 `framesDataUrls`,后端继续按旧路径发布
2.`previewVideoPath` 字段名不改
3. 已存在的 `animationMap` 结构不改
### 3.3 本阶段不做
1. 不把外部视频解码逻辑放进 SpacetimeDB reducer / procedure
2. 不新增动作资产专用 SpacetimeDB 表
3. 不改前端 `generate` 接口字段名
4. 不重做 UI 面板结构
## 4. 后端边界
Stage 3 的后端抽帧与上传逻辑统一落在 Rust `api-server`,不进入 `spacetime-module`
原因:
1. 抽帧依赖外部进程与本地临时文件,不满足 reducer 的 deterministic 约束
2. OSS 读写与外部视频工具调用属于平台 I/O不应落入 SpacetimeDB 真相层
3. `spacetime-module` 继续只负责资产对象确认与绑定后的真相落库
因此本阶段职责边界为:
1. `api-server`:下载草稿视频、抽帧、去绿幕、上传 OSS、生成 manifest、调用 `spacetime-client`
2. `spacetime-module`:继续只承接 `asset_object``asset_entity_binding`
## 5. 技术决策
## 5.1 抽帧工具
Stage 3 统一采用系统 `ffmpeg + ffprobe`,不在 Rust 内部新增重型视频解码依赖。
原因:
1. 当前仓库没有可复用的视频解码能力
2. 仅为动作发布链引入一整套 Rust 视频编解码栈,风险高且集成成本大
3. 当前动作帧数很低,采用外部命令行工具能更快稳定落地
## 5.2 运行时依赖
部署与本地联调环境必须满足:
1. `ffmpeg` 可执行
2. `ffprobe` 可执行
配置项:
1. `CHARACTER_ANIMATION_FFMPEG_PATH`
2. `CHARACTER_ANIMATION_FFPROBE_PATH`
3. `CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS`
默认值:
1. `ffmpeg`
2. `ffprobe`
3. `120000`
说明:
1. 若未显式配置路径,则默认从系统 `PATH` 查找
2. 若运行环境缺少 `ffmpeg``ffprobe`,发布动作时必须直接报错,禁止偷偷回退到前端抽帧或占位帧
## 5.3 去绿幕方案
Stage 3 继续保持“后端产出透明背景 PNG 序列”的口径。
实现方式:
1. `ffmpeg` 负责按指定时间点截取原始视频帧
2. Rust 使用 `image` crate 读取单帧 PNG
3. Rust 复用前端现有绿幕去底逻辑的同等阈值与邻域策略,对 RGBA 像素做去背景与边缘去污染
4. Rust 再把去底后的图像按 contain 方式绘制到目标帧尺寸画布中,输出正式 PNG 帧
这样做的原因:
1. 抽帧交给 `ffmpeg`,减少视频解码复杂度
2. 去底逻辑留在 Rust方便与前端当前视觉结果对齐
3. 正式帧统一输出 PNG便于当前 `animationMap` 与运行时消费保持稳定
## 6. Contract 变更
## 6.1 `CharacterAnimationDraftPayload`
在保留旧字段的基础上,新增后端抽帧所需参数:
1. `frameCount?: number`
2. `applyChromaKey?: boolean`
3. `sampleStartRatio?: number`
4. `sampleEndRatio?: number`
兼容规则:
1. `framesDataUrls` 允许为空或省略
2.`framesDataUrls` 非空时,后端继续沿用旧发布路径
3.`framesDataUrls` 为空且存在 `previewVideoPath` 时,后端进入 Stage 3 抽帧路径
## 6.2 默认采样规则
若前端未显式传采样区间,则后端按以下默认值执行:
1. `loop = true` 时:
- `sampleStartRatio = 0.12`
- `sampleEndRatio = 0.94`
2. `loop = false` 时:
- `sampleStartRatio = 0`
- `sampleEndRatio = 1`
`frameCount` 未传,则后端默认使用 `8`
## 7. 正式发布算法
`publish` 收到某个动作草稿时,后端按以下顺序处理:
1.`framesDataUrls` 非空:
- 继续读取图片源
- 继续上传正式帧
- 继续生成动作 manifest
2.`framesDataUrls` 为空但 `previewVideoPath` 存在:
- 从 OSS 草稿区读取视频
- 写入临时目录
- 调用 `ffprobe` 读取时长
- 依据 `frameCount / sampleStartRatio / sampleEndRatio / loop` 计算采样时间点
- 对每个时间点调用 `ffmpeg` 抽一张原始 PNG 帧
- 对每帧做去绿幕与 contain 尺寸归一
-`generated-animations/{character}/{animationSetId}/{action}/framexx.png` 上传正式帧
- 生成动作 manifest
3. 若两者都没有:
- 直接报 `400`
## 8. Manifest 与资产绑定口径
Stage 3 不改变正式 manifest 的结构真相:
1. 动作级 manifest 仍记录:
- `action`
- `frameCount`
- `fps`
- `loop`
- `frameWidth`
- `frameHeight`
- `previewVideoPath`
- `framePaths`
2. 动作集 manifest 仍记录:
- `animationSetId`
- `characterId`
- `visualAssetId`
- `actions`
- `animationMap`
资产落库口径继续保持:
1. 只对动作集根 manifest 执行 `confirm_asset_object`
2. 再把该对象绑定到角色 `animation_set` 槽位
说明:
1. Stage 3 的重点是“正式帧生产链后移”,不是扩展资产对象颗粒度
2. 单帧 PNG 与单动作 manifest 继续只作为 OSS 内部发布物,不额外逐一建表
## 9. 前端迁移口径
Stage 3 后前端职责收口为:
1. 请求后端生成 `preview.mp4`
2. 记录动作元数据:
- `fps`
- `loop`
- `frameWidth`
- `frameHeight`
- `frameCount`
- `applyChromaKey`
- `sampleStartRatio`
- `sampleEndRatio`
- `previewVideoPath`
3. 调用 `publish`,让后端完成正式帧生产
4. 用返回的 `animationMap` 做 UI 预览与角色配置
前端不再承担:
1. 本地视频解码
2. 本地抽帧
3. 本地去绿幕
4. 把整组帧图片 Data URL 回传后端
## 10. 错误策略
以下情况必须直接失败,不允许伪成功:
1. `previewVideoPath` 存在但 OSS 读取失败
2. `ffprobe` 执行失败
3. `ffmpeg` 执行失败
4. 抽帧数量不足
5. PNG 解码失败
6. 去绿幕或 PNG 编码失败
7. 正式帧上传 OSS 失败
错误返回要求:
1. 明确指出失败发生在“视频读取 / 抽帧 / 去底 / 上传 / 落库”哪个阶段
2. 缺少 `ffmpeg``ffprobe` 时,错误消息必须能直接提示环境缺失
## 11. 验收标准
满足以下条件视为 Stage 3 完成:
1. 角色动作 `generate` 会真实生成并返回 OSS 草稿区 `preview.mp4`
2. 角色动作 `publish` 在不传 `framesDataUrls` 的情况下也能成功
3. 正式动作帧由后端抽出并写入 `generated-animations/*`
4. `manifest.json``framePaths` 指向正式 PNG 帧
5. 动作集对象 `object_key` 成功确认并绑定到角色
6. 前端主链不再依赖浏览器本地抽帧
## 12. 关联文档
1. [M6_CHARACTER_ANIMATION_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md](./M6_CHARACTER_ANIMATION_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md)
2. [ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md)
3. [AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md](./AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md)

View File

@@ -0,0 +1,227 @@
# M6 角色主形象外部真实生成 Stage 2 设计
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结 `POST /api/assets/character-visual/generate` 从 Stage 1 SVG 占位草稿切到真实外部图片生成后的实现口径。
本阶段目标不是重做整套资产系统,而是在保留现有 `OSS + asset_object + asset_entity_binding + AiTaskService` 主链不变的前提下,把“候选图生成”改成真实调用图片模型。
## 2. 当前问题
Stage 1 当前存在以下占位实现:
1. `server-rs/crates/api-server/src/character_visual_assets.rs`
2. `CHARACTER_VISUAL_MODEL = "rust-svg-character-visual"`
3. `persist_visual_drafts(...)` 直接生成 `candidate-xx.svg`
4. `publish` 虽然已经能把候选对象发布到 `generated-characters/*`,但候选对象本身不是真实模型产物
这会导致第 4 项验证里“角色资产工坊图片真实外部生成”不能通过。
## 3. Stage 2 范围
### 3.1 本阶段必须完成
1. `character-visual/generate` 改为真实 DashScope 图片生成
2. 支持 `text-to-image`
3. 支持 `image-to-image`
4. 参考图继续兼容 Data URL 与 `/generated-*` 旧路径
5. 候选草稿继续写入 `generated-character-drafts/{character}/visual/{taskId}/candidate-xx.*`
6. `publish` 继续沿用现有 `OSS + asset_object + asset_entity_binding` 正式主链
7. 返回 contract 继续保持前端可直接消费
### 3.2 本阶段不做
1. 不新增 SpacetimeDB 资产任务真相表
2. 不回写本地 `public/generated-*`
3. 不新增前端协议字段
4. 不把角色主图生成迁回 `server-node`
## 4. 真实模型与上游协议
### 4.1 默认模型
默认模型统一使用:
`wan2.7-image-pro`
说明:
1. 这是旧 Node 角色主图正式链的默认模型
2. 当前前端 `useRoleVisualCandidateWorkflow.ts` 也固定传该模型
3. Rust 允许请求显式覆盖 `imageModel`,但默认值必须与旧链一致
### 4.2 DashScope 接口
#### 文生图
`sourceMode = text-to-image` 时:
1. 请求 `POST {DASHSCOPE_BASE_URL}/services/aigc/image-generation/generation`
2. 头部带 `Authorization: Bearer {DASHSCOPE_API_KEY}`
3. 头部带 `X-DashScope-Async: enable`
4. body 使用旧 Node 已验证的 `messages[0].content = [{ text }]` 结构
5. `parameters` 需显式带:
1. `n`
2. `size`
3. `negative_prompt`
4. `prompt_extend = true`
5. `watermark = false`
6. 创建成功后按 `/tasks/{task_id}` 轮询直到成功或失败
#### 图生图
`sourceMode = image-to-image` 时:
1. 请求 `POST {DASHSCOPE_BASE_URL}/services/aigc/image-generation/generation`
2. body 仍使用旧 Node 已验证的 `messages[0].content = [{ text }, { image }, ...]`
3. 参考图顺序保持为“文字在前,图片在后”
4. `parameters` 与文生图一致
5. 同样使用异步任务轮询
## 5. Prompt 口径
### 5.1 正向 prompt
Rust Stage 2 不再使用“LLM 先摘要再拼 SVG”的链路。
新的生成 prompt 口径:
1. 以请求里的 `promptText + characterBriefText` 组装正式主图 prompt
2. 约束必须覆盖:
1. 单人
2. 右向斜侧身
3. 1:1 正方形画布
4. 纯绿色绿幕
5. 3 到 4 头身
6. 像素动作角色
7. 不要扩写复杂背景
3. 主目标是与旧 Node `buildNpcVisualPrompt` 生成出的正式约束保持同方向
### 5.2 负向 prompt
Rust Stage 2 需要显式带负向提示词,至少覆盖以下禁止项:
1. 正面视角
2. 左朝向
3. 纯 90 度侧视
4. 半身像
5. 多角色
6. 复杂背景
7. 文字
8. 水印
9. UI 元素
10. 软萌 Q 版大头贴
## 6. 参考图解析口径
参考图继续兼容两类输入:
1. `data:image/*;base64,...`
2. `/generated-*` 旧路径
不再兼容:
1. 任意仓库磁盘路径
2.`/generated-*` 的普通相对路径
Rust 解析步骤:
1. Data URL 直接转发
2. `/generated-*` 通过 OSS 签名读获取二进制
3. 下载后重新编码为 `data:{mime};base64,...`
4. 再提交给 DashScope
## 7. 候选图下载与存储
DashScope 成功后需要:
1. 下载返回的远程图片 URL
2. 归一化 mime type 和扩展名
3. 对 PNG 做去绿幕/去白底透明化处理
4. 上传到 OSS 草稿区
5. 返回 `/generated-character-drafts/*`
### 7.1 去底规则
为保持与旧 Node 可见效果一致:
1. 当下载结果是 PNG 时,对图像执行背景透明化
2. 透明化逻辑需兼容:
1. 纯绿底
2. 白底边缘
3. 绿边污染
3. 若处理失败,则保留原图,不因后处理失败阻断整个生成链
## 8. 配置项
Stage 2 统一使用以下配置:
1. `DASHSCOPE_BASE_URL`
2. `DASHSCOPE_API_KEY`
3. `DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS`
补充默认值:
1. `DASHSCOPE_BASE_URL` 默认 `https://dashscope.aliyuncs.com/api/v1`
2. 超时继续沿用现有 `dashscope_image_request_timeout_ms`
当前阶段不新增新的角色主图专属环境变量。
## 9. 错误与回退策略
Stage 2 明确取消 SVG 占位回退。
### 9.1 允许的失败表现
当以下条件不满足时,接口直接返回错误:
1. 缺少 `DASHSCOPE_API_KEY`
2. DashScope 创建任务失败
3. DashScope 轮询失败
4. DashScope 成功但未返回图片 URL
5. 下载图片失败
6. OSS 写入失败
### 9.2 禁止的回退
以下回退在 Stage 2 禁止继续保留:
1. LLM 摘要后生成 SVG 占位图
2. 本地静态预置图
3. 返回“伪成功”但实际未调模型
## 10. 任务状态口径
`AiTaskService` 继续复用现有阶段:
1. `prepare_prompt`
2. `request_model`
3. `normalize_result`
4. `persist_result`
阶段语义调整如下:
1. `prepare_prompt`:冻结最终 prompt、source mode、参考图数量
2. `request_model`记录真实模型名、DashScope task id、实际 prompt
3. `normalize_result`:记录候选对象路径
4. `persist_result`:确认 OSS 草稿已落成
## 11. 验收标准
满足以下条件视为 Stage 2 完成:
1.`character-visual/generate` 时不再生成 SVG 草稿
2. 返回的候选对象是 DashScope 真实产图
3. 草稿对象仍写到 `generated-character-drafts/*`
4. `publish` 仍能发布到 `generated-characters/*`
5. 发布后仍能形成 `asset_object`
6. 发布后仍能形成 `asset_entity_binding`
7. 前端角色资产工坊无须改协议即可继续使用
## 12. 关联文档
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
2. [ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md)
3. [BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md](./BIG_FISH_FORMAL_IMAGE_GENERATION_2026-04-23.md)

View File

@@ -0,0 +1,331 @@
# 公开编号用户搜索与广场作品搜索设计
## 1. 背景
当前前端展示的“叙世号”由前端基于 `AuthUser.id` 临时拼装:
- 前缀固定为 `SY-`
-`user.id``username` 去除非字母数字字符后的末 8 位
- 不足 8 位左侧补零
该方案只适合展示,不适合作为正式检索键,主要问题:
1. 它不是后端持久化字段,前后端对同一编号没有统一语义。
2. 它依赖 `user.id` 当前格式,一旦账号 ID 生成规则调整,展示号会漂移。
3. 只截取末 8 位存在潜在碰撞风险,不适合作为用户搜索主键。
4. 广场作品当前仅能通过 `ownerUserId + profileId` 读取详情,不利于做公开搜索和分享。
本次目标是把“公开编号”升级为后端一等字段,并同时支持:
1. 按用户公开编号搜索用户
2. 按作品公开编号搜索广场作品
3. 前端统一展示后端返回的公开编号,不再本地拼接
## 2. 目标与非目标
### 2.1 目标
1. 为用户增加稳定唯一的公开编号 `public_user_code`
2. 为发布到广场的作品增加稳定唯一的公开编号 `public_work_code`
3. 提供匿名可读的公开搜索接口
4. 平台首页 / 广场搜索框支持输入公开编号直达用户或作品
5. 搜索兼容用户输入的不同格式,如大小写、带不带前缀、是否包含空格
### 2.2 非目标
1. 本期不实现复杂全文搜索排序系统
2. 本期不做“用户主页”完整社交系统,只返回搜索命中的公开资料摘要
3. 本期不把所有广场列表改造成关键词后端分页搜索
4. 本期不修改既有业务主键 `user.id / profile_id`
## 3. 核心设计原则
1. **公开编号必须后端生成并持久化**
2. **公开编号只做公开检索键,不替代内部主键**
3. **前端只展示和透传公开编号,不自行拼装**
4. **公开接口只暴露最小必要公开信息**
5. **SpacetimeDB 查询走唯一索引或明确索引,不做无界扫描**
## 4. 数据模型设计
## 4.1 用户公开编号
在认证用户模型中新增字段:
- `public_user_code: String`
格式定义:
- 标准展示格式:`SY-00000001`
- 前缀固定:`SY-`
- 数字部分固定 8 位,左侧补零
生成规则:
1. 新建账号时,从认证存储中的 `next_user_id` 派生
2. 若用户内部 ID 为 `user_{:08}`,则公开编号同步使用同一序号生成
3. 一旦生成后永久不变
示例:
- `user_00000001` -> `SY-00000001`
- `user_00001234` -> `SY-00001234`
原因:
1. 与当前展示习惯兼容,用户认知成本最低
2. 不再依赖前端截断逻辑
3. 可在后端保证唯一性与稳定性
## 4.2 广场作品公开编号
在作品真相模型与广场快照中分别新增字段:
- `CustomWorldProfile.public_work_code: String`
- `CustomWorldProfile.author_public_user_code: String`
在广场作品快照模型 `CustomWorldGalleryEntry` 中新增字段:
- `public_work_code: String`
- `author_public_user_code: String`
格式定义:
- 标准展示格式:`CW-00000001`
- 前缀固定:`CW-`
- 数字部分固定 8 位,左侧补零
生成规则:
1. 作品第一次发布到广场时分配
2. 编号先写入 `CustomWorldProfile` 真相表,再同步到 `CustomWorldGalleryEntry`
3. 同一 `profile_id` 重复发布、更新、重新上架时沿用原编号
4. 删除后不回收编号
原因:
1. 作品公开检索和分享应使用作品级稳定编号,而不是 `ownerUserId + profileId`
2. 同一作品反复编辑发布不应导致公开编号变化
## 4.3 归一化规则
用户与作品公开编号搜索都应在后端统一做归一化,前端仅做轻提示,不做最终判定。
### 用户编号归一化
输入样例:
- `SY-00000001`
- `sy00000001`
- `00000001`
- ` sy-00000001 `
归一化步骤:
1. 去掉首尾空白
2. 转大写
3. 去掉所有非字母数字字符
4. 若结果以 `SY` 开头,则去掉前缀
5. 剩余部分必须为 1~8 位数字
6. 左侧补零到 8 位
7. 最终重建为标准格式 `SY-XXXXXXXX`
### 作品编号归一化
输入样例:
- `CW-00000001`
- `cw00000001`
- `00000001`
归一化步骤:
1. 去掉首尾空白
2. 转大写
3. 去掉所有非字母数字字符
4. 若结果以 `CW` 开头,则去掉前缀
5. 剩余部分必须为 1~8 位数字
6. 左侧补零到 8 位
7. 最终重建为标准格式 `CW-XXXXXXXX`
## 5. 后端接口设计
## 5.1 用户公开编号搜索
新增匿名可读接口:
- `GET /api/auth/public-users/by-code/{code}`
- `GET /api/auth/public-users/by-id/{userId}`
响应:
```json
{
"user": {
"id": "user_00000001",
"publicUserCode": "SY-00000001",
"displayName": "旅人一号"
}
}
```
说明:
1. `id` 返回内部 ID 仅供当前工程内部跳转与资源读取使用,不在 UI 上直接暴露为文案
2. 不返回手机号、登录方式、绑定状态、tokenVersion 等敏感字段
3. 未命中返回 `404`
4. `by-id` 仅接受内部 `user_XXXXXXXX` 这类用户 ID用于工程内跳转、运营排查或已有资源引用不替代公开叙世号主搜索语义
## 5.2 广场作品公开编号搜索
新增匿名可读接口:
- `GET /api/runtime/custom-world-gallery/by-code/{code}`
响应结构与现有广场详情接口一致:
```json
{
"entry": {
"ownerUserId": "user_00000001",
"profileId": "world-public-1",
"publicWorkCode": "CW-00000001",
"publicUserCode": "SY-00000001",
"authorDisplayName": "旅人一号",
"worldName": "雾港旧梦"
}
}
```
说明:
1. 作品搜索命中后仍使用现有详情页承载
2. 详情返回里补入 `publicWorkCode` 和作者 `publicUserCode`
## 6. SpacetimeDB 与认证存储改造
## 6.1 认证域
认证域目前是 `module-auth` 内存存储,不是 SpacetimeDB 表。
需要改造:
1. `AuthUser` 增加 `public_user_code`
2. `create_user / create_phone_user / create_pending_wechat_user` 统一生成公开编号
3. `AuthUserPayload` / 前端 `AuthUser` 合约增加 `publicUserCode`
4. `auth/me`、密码登录、手机登录、微信登录、绑手机返回都补齐该字段
## 6.2 广场作品表
`CustomWorldProfile` 增加:
1. `public_work_code: String`
2. `author_public_user_code: String`
`CustomWorldGalleryEntry` 增加:
1. `public_work_code: String`
2. `author_public_user_code: String`
索引建议:
1. `public_work_code` 唯一索引
2. 保留现有 `owner_user_id` 索引
3. `profile_id` 仍作为主键 / 唯一查找键之一
其中 `author_public_user_code` 本期可先不建索引,除非后续明确需要“按用户公开号列出该作者作品”。
## 6.3 作品公开编号分配策略
需要在模块内新增稳定计数状态,例如:
- `gallery_public_work_counter`
要求:
1. 每次首次发布作品时分配一次
2. 若作品已存在公开编号,则从 `CustomWorldProfile` 直接复用并同步到广场快照
3. 不因下架、删除、重新发布而重新编号
## 7. 前端交互设计
## 7.1 账号展示
当前首页资料卡和桌面顶部都展示前端拼装叙世号,改为:
1. 直接展示 `authUi.user.publicUserCode`
2. 复制按钮复制后端返回值
3. 若字段缺失才进入兼容兜底逻辑,但兼容逻辑仅作过渡
## 7.2 广场作品卡
广场作品卡和详情页增加:
1. 作品号 `CW-XXXXXXXX`
2. 作者叙世号 `SY-XXXXXXXX`
展示要求:
1. 以轻量辅助信息形式出现
2. 不在卡片默认堆过多说明文字
3. 移动端优先,避免挤压主要标题和摘要
## 7.3 搜索入口
平台首页顶部搜索框当前只是静态文案,需要接成真实输入与行为:
1. 当输入命中 `SY` 格式时,优先走用户公开号搜索
2. 当输入命中 `CW` 格式时,优先走作品公开号搜索
3. 当输入纯 1~8 位数字时:
- 先尝试作品号
- 未命中再尝试用户号
4. 暂不命中公开编号格式时,保持当前占位,不在本期强做全文关键词搜索
用户搜索命中后的最小行为:
1. 打开独立用户搜索结果面板或对话框
2. 展示头像字母、显示名、叙世号
3. 提供“查看该作者作品”入口
作品搜索命中后的行为:
1. 直接打开广场作品详情
## 8. 安全与边界
1. 用户公开搜索接口只允许返回公开资料摘要
2. 不允许通过公开接口枚举登录方式、绑定状态、设备信息、手机号掩码
3. 作品公开搜索接口只返回已公开发布的作品
4. 对不存在的编号统一返回未找到,避免泄露更多状态差异
## 9. 实现步骤
1.`module-auth` 用户模型与返回合约,补 `public_user_code`
2.`shared-contracts / packages/shared`,前后端统一 `publicUserCode`
3. 改首页展示,去掉前端本地拼装依赖
4. 改 SpacetimeDB `CustomWorldGalleryEntry` 表与快照,补 `public_work_code / author_public_user_code`
5. 新增广场按公开作品号读取 procedure / api-server 路由
6. 新增认证域按公开用户号读取接口
7. 改平台首页搜索框,支持按公开编号跳转
8. 补测试:
- 公开编号归一化
- 用户搜索命中/未命中
- 作品搜索命中/未命中
- 前端展示与复制
## 10. 验收标准
1. 新注册、游客自动登录、手机号登录、微信登录用户都能拿到稳定 `publicUserCode`
2. 首页与账户入口展示统一使用后端 `publicUserCode`
3. 新发布广场作品自动获得稳定 `publicWorkCode`
4. 输入 `SY-00000001` 可命中对应用户
5. 输入 `CW-00000001` 可命中对应广场作品并打开详情
6. 输入 `00000001` 时仍能归一化识别并命中
7. 既有广场列表、详情和发布流程不回归
## 11. 当前落地说明
1. 首页叙世号展示已优先读取后端 `publicUserCode`,原本基于 `AuthUser.id/username` 的前端拼装仅保留为兼容兜底,避免老会话未刷新时界面直接空白。
2. 用户公开搜索与广场作品公开搜索均已改为调用后端匿名接口,前端只负责输入、展示与跳转,不再自行决定最终编号格式。
3. 自定义世界发布链路已改为从认证服务读取真实 `public_user_code` 写入作品真相与广场读模型,不再从内部 `user_id` 临时反推 `SY-XXXXXXXX`
4. 当前作品号 `public_work_code` 仍采用基于 `profile_id` 的稳定 fallback 方案生成 `CW-XXXXXXXX`;若后续补独立计数表,需要在不改变读写接口的前提下替换生成来源。

View File

@@ -0,0 +1,230 @@
# 拼图 Agent 聊天接入大模型设计
日期:`2026-04-23`
## 1. 背景
当前拼图创作链已经具备:
1. `PuzzleAgentWorkspace` 前端聊天壳层
2. Rust `api-server``submit / stream / action` HTTP facade
3. `spacetime-module``puzzle_agent_session / puzzle_agent_message` 真相表
4. `module-puzzle` 的锚点推断、草稿编译、发布校验与运行态规则
`submit_puzzle_agent_message` 仍然是 deterministic 占位逻辑:
1. 用户发言后,`spacetime-module` 直接推断 `anchor_pack`
2. 同一事务里直接写入固定 assistant 总结文案
3. SSE `/messages/stream` 只是把 `last_assistant_reply` 一次性回放给前端
这导致拼图 Agent 看起来有聊天面板,但没有真实 LLM 共创能力。
## 2. 目标
本轮只恢复拼图 Agent 聊天主链的真实 LLM 接入,不扩到图片模型和复杂多阶段编排:
1. 用户发消息后assistant 回复必须来自 LLM
2. LLM 输出必须同时产出:
- `replyText`
- `progressPercent`
- `nextAnchorPack`
3. SSE `reply_delta` 必须来自真实流式增量解析
4. finalize 后一次性回写最新 session 真相
5. LLM 不可用或解析失败时,不再写固定 assistant 假回复
## 3. 分层边界
### 3.1 `module-puzzle`
只负责:
1. `PuzzleAnchorPack``PuzzleCreatorIntent``PuzzleResultDraft` 纯领域模型
2. 锚点清洗、草稿编译、发布校验
3. 运行态拼图规则
明确不负责:
1. 网络请求
2. LLM prompt 组织
3. SSE 推流
### 3.2 `spacetime-module`
只负责:
1. `submit_puzzle_agent_message`
- 校验 session / ownership / message id
- 只写入 user message
- 不再直接写 assistant message
- 不再直接推进 `current_turn / progress_percent / anchor_pack_json`
2. `finalize_puzzle_agent_message_turn`
- 追加 assistant message
- 覆盖 `anchor_pack_json`
- 回写 `current_turn / progress_percent / stage / last_assistant_reply / updated_at`
### 3.3 `api-server`
负责:
1. 读取拼图 session 快照
2. 组织 LLM prompt
3. 流式截取 `replyText`
4. 回合结束后组装 finalize 输入
5. 调用 SpacetimeDB finalize procedure
## 4. 目标链路
### 4.1 阶段 A提交消息
`submit_puzzle_agent_message`
职责:
1. 写入 user message
2. 返回 submit 后的 session 快照
3. 该快照中尚未新增 assistant message
4. 该快照中的 `anchorPack / progressPercent / currentTurn` 保持提交前真相
### 4.2 阶段 B完成单轮推理
`finalize_puzzle_agent_message_turn`
职责:
1. 追加 assistant message
2. 回写:
- `current_turn`
- `progress_percent`
- `stage`
- `anchor_pack_json`
- `last_assistant_reply`
- `updated_at`
3. 若已存在 `draft_json`,允许继续保留,不在聊天 finalize 中改写结果页草稿
## 5. LLM 输出契约
拼图聊天不需要复刻 Custom World 那么重的多层结构,本轮冻结为单个 JSON
```json
{
"replyText": "我已经收住画面方向了,接下来你更想强调夜雨反光,还是猫咪本身的奇幻感?",
"progressPercent": 46,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "雨夜中的奇幻探索",
"status": "confirmed"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "发光猫咪站在遗迹台阶上",
"status": "confirmed"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "潮湿、梦幻、带轻微悬疑",
"status": "confirmed"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "台阶透视、倒影、远处遗迹门洞",
"status": "inferred"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "雨夜、猫咪、神庙遗迹;禁止文字水印",
"status": "inferred"
}
}
}
```
要求:
1. 只能输出 JSON
2. `progressPercent` 范围固定 `0..100`
3. `nextAnchorPack` 必须是完整对象,不允许只给增量 patch
## 6. Prompt 设计
本轮不追求“复杂创作状态识别器”,只做拼图场景最小可用 prompt
1. system prompt 明确模型角色是“拼图视觉共创策划”
2. 明确 5 个锚点:
- 题材承诺
- 画面主体
- 视觉气质
- 拼图记忆点
- 标签与禁忌
3. 明确回复必须:
- 自然中文
- 一次只推进一个最关键问题
- 不提“字段”“锚点”“JSON 结构”等内部词
4. user prompt 携带:
- 当前 turn / progress
- 当前 anchor pack
- 最近聊天记录
- 输出 JSON 契约
## 7. SSE 口径
`POST /api/runtime/puzzle/agent/sessions/:sessionId/messages/stream`
成功时按顺序输出:
1. 多个 `reply_delta`
2. 一个 `session`
3. 一个 `done`
失败时输出:
1. `error`
要求:
1. `reply_delta.text` 来自真实流式累计 `replyText`
2. `session` 必须来自 finalize 后重新读取的最新 session
## 8. 错误策略
1. 如果 `llm_client` 未配置:
- 普通接口返回 `502`
- SSE 返回 `error`
- 不写 assistant message
2. 如果 LLM 响应解析失败:
- 普通接口返回 `502`
- SSE 返回 `error`
- 只保留 user message
3. 如果 finalize 失败:
- 普通接口返回 `502`
- SSE 返回 `error`
- 以前端重新拉 session 为准
## 9. 实现清单
1. `module-puzzle`
- 新增 `PuzzleAgentMessageFinalizeInput`
2. `spacetime-module/src/puzzle.rs`
- 新增 `finalize_puzzle_agent_message_turn`
- 修改 `submit_puzzle_agent_message`
3. `spacetime-client`
- 刷新 Rust bindings
- 新增 `finalize_puzzle_agent_message(...)`
4. `api-server`
- 新增 `puzzle_agent_turn.rs`
- `puzzle.rs` 的普通消息与 SSE 改接 turn service
5. `docs/technical/README.md`
- 补入本文档索引
## 10. 验收
1. 拼图 Agent 普通发送接口不再返回固定 assistant 文案
2. 拼图 Agent SSE 可看到逐步增长的 `reply_delta`
3. finalize 后 `session.messages` 中新增 assistant chat 消息
4. `session.anchorPack / progressPercent / currentTurn / lastAssistantReply` 已真实更新
5. LLM 关闭时不会再写入伪造 assistant 回复

View File

@@ -0,0 +1,100 @@
# 拼图草稿恢复 Agent 会话设计
日期:`2026-04-23`
## 1. 背景
当前拼图链已经具备:
1. `puzzle_agent_session / puzzle_agent_message` 作为聊天真相。
2. `get_puzzle_agent_session(sessionId)` 可按会话恢复完整消息。
3. `puzzle_work_profile` 作为创作中心与广场的作品列表投影。
但现状仍有两个断点:
1. `compile_puzzle_agent_draft` 只把结果页草稿写回 session没有同步生成 `draft``puzzle_work_profile`
2. 创作中心点击拼图草稿卡时,只会走“查看详情”,没有利用 `sourceSessionId` 恢复聊天会话。
这导致“进入创作草稿恢复聊天记录”在拼图链上并不完整。
## 2. 目标
本轮只实现以下闭环,不扩展到用户级历史列表:
1. 拼图 Agent 编译出结果页草稿后,创作中心必须出现对应草稿卡。
2. 草稿卡必须带 `sourceSessionId`,作为恢复聊天记录的唯一索引。
3. 点击拼图草稿卡时:
- 若 session 仍存在且已带 `draft`,优先进入 `puzzle-result`
- 若 session 存在但尚无 `draft`,进入 `puzzle-agent-workspace`
4. 恢复后继续复用同一个 `puzzleSession`,返回聊天工作区时能看到完整历史消息。
## 3. 真相源与投影边界
### 3.1 真相源
拼图聊天历史、锚点、阶段、结果页草稿仍以 `puzzle_agent_session` 为准。
### 3.2 投影
`puzzle_work_profile` 只承担:
1. 创作中心作品卡展示。
2. 结果页 / 详情页入口锚点。
3. 通过 `source_session_id` 反查 Agent session。
它不是新的聊天真相,不承担额外会话列表职责。
## 4. 后端落地
### 4.1 编译草稿时同步 upsert draft 作品
`compile_puzzle_agent_draft` 除了更新 session 外,还要:
1. 依据当前 `draft` 创建或更新一条 `PuzzlePublicationStatus::Draft``puzzle_work_profile`
2. `source_session_id` 固定写当前 session id。
3. `work_id / profile_id` 使用稳定的 session 派生规则,避免同一 session 每次编译都生成新卡。
### 4.2 图片相关操作持续同步 draft 作品
`save_puzzle_generated_images``select_puzzle_cover_image` 会改变结果页草稿真相,因此也要同步更新对应 draft 作品记录,保证创作中心卡片封面、摘要、标签与当前草稿一致。
### 4.3 发布时升级同一条作品记录
`publish_puzzle_work` 不再新生成随机 `work_id / profile_id`,而是复用 session 派生的稳定 ID把同一条 draft 作品升级为 `published`
1. 避免创作中心出现“草稿卡 + 已发布卡”两条重复记录。
2. 保持 `source_session_id` 连续可追溯。
## 5. 前端落地
### 5.1 创作中心卡片语义
拼图草稿卡主按钮从 `查看详情` 改为 `继续创作`
### 5.2 打开拼图草稿
平台壳层新增拼图草稿恢复入口:
1.`PuzzleWorkSummary.sourceSessionId`
2. 用现有 `getPuzzleAgentSession(sourceSessionId)` 拉回 session。
3.`session.draft` 存在:
- 写入 `puzzleSession`
- 切到 `puzzle-result`
4.`session.draft` 不存在:
- 写入 `puzzleSession`
- 切到 `puzzle-agent-workspace`
### 5.3 失败回退
如果 `sourceSessionId` 缺失或对应 session 已失效:
1. 刷新拼图作品列表。
2. 停留在创作中心。
3. 通过现有错误 banner 提示,不新增独立说明 UI。
## 6. 验收
1. 编译拼图结果页草稿后,创作中心出现 `draft` 态拼图卡。
2. 草稿卡点击后会恢复对应 `puzzleSession.messages`
3. 已有 `draft` 的 session 恢复后直达结果页,点击返回能看到原聊天记录。
4. 发布后不会额外生成第二条重复作品记录。

View File

@@ -4,7 +4,13 @@
## 文档列表
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。
- [CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md](./CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md):冻结 `draft_foundation` 从 SpacetimeDB 内部规则编译迁到 `api-server + platform-llm` 的边界,明确草稿必须由 `api-server` 真实调 LLM 生成SpacetimeDB 只负责落库。
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
- [PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md](./PUZZLE_AGENT_LLM_REPLY_INTEGRATION_2026-04-23.md):冻结拼图 Agent 聊天接入真实 LLM 的最小 Rust 落地方案,明确 submit 只写 user message、`api-server` 承接推理、SSE 流式回放与 finalize 回写 session 真相的边界。
- [UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md](./UNIFIED_CREATION_DRAFT_SESSION_RESTORE_2026-04-23.md):冻结创作中心全草稿恢复 Agent 会话的统一口径,覆盖 RPG、Big Fish、Puzzle 三类草稿的会话索引、结果页分流和 Big Fish works 最小投影边界。
- [PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md](./PUZZLE_DRAFT_SESSION_RESTORE_2026-04-23.md):冻结拼图结果页草稿进入创作中心后的恢复口径,明确 draft 作品投影、`sourceSessionId` 反查 Agent session、编译时同步落草稿卡与发布复用同一作品记录。
- [RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md](./RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md):冻结 Rust 本地联调启动前必须 publish/generate 最新 `spacetime-module` 的守卫,以及 Custom World Agent 在 LLM 失败时禁止写固定 assistant 回复的 finalize 与 HTTP/SSE 错误策略。
- [CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md](./CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md):记录统一创作聊天工作区从“每次更新都强制滚到底”改为“仅在用户仍停留在底部附近时跟随”的滚动策略修复,避免流式回复持续抢走阅读位置。
- [CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md](./CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md):记录创作 Agent 聊天流式文本、玩家乐观消息、最终 session 回写和草稿切换的展示稳定性修复,避免乱码、闪消、插队和旧草稿闪烁。
@@ -14,7 +20,7 @@
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传和安全清库开关。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 96 条 Axum 路由,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md)`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md)`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。
@@ -30,6 +36,10 @@
- [PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md](./PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md):冻结手机号验证码发送链路的日志补强口径,确保 `api-server``module-auth``platform-auth` 能直接暴露发送前后与错误分类关键字段。
- [PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md](./PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md):冻结短信平台受理成功与最终送达状态的区分方式、追踪字段、送达回执接口和前端提示文案边界。
- [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第一项“真实短信验证码链路”的本地启动、前端操作、日志观察点、通过标准与失败排查步骤。
- [ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./ASSET_EXTERNAL_GENERATION_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第四项“图片、视频、动作真实外部生成”的人工联调口径,明确哪些入口已接真实外部图片服务、哪些入口仍是 Stage 1 占位链,以及前端点击路径、日志观察点和通过标准。
- [M6_CHARACTER_VISUAL_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md](./M6_CHARACTER_VISUAL_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md):冻结角色主形象从 Stage 1 SVG 占位草稿切到 DashScope 真实出图的 Stage 2 口径覆盖模型、参考图解析、去底处理、OSS 草稿存储和失败策略。
- [M6_CHARACTER_ANIMATION_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md](./M6_CHARACTER_ANIMATION_ASSET_EXTERNAL_GENERATION_STAGE2_2026-04-23.md):冻结角色动作从 Stage 1 占位视频切到真实 Ark/DashScope 生成的 Stage 2 口径,优先明确 image-to-video 主链、Ark 固定参数、媒体上传与错误回退规则。
- [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):冻结角色动作正式发布链从“前端抽帧回传”继续迁到“后端抽帧、去绿幕、上传 OSS、落库绑定”的 Stage 3 口径,明确 `ffmpeg/ffprobe` 依赖、contract 扩展与兼容策略。
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。
- [WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md](./WECHAT_LOGIN_REAL_INTEGRATION_RUNBOOK_2026-04-21.md):微信登录从本地 mock 到真实微信开放平台联调的执行手册,覆盖环境变量、回调域名、代理头要求、验证步骤与常见失败排查。
- [PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](./PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md):密码登录与自动建号落地设计,冻结 `/api/auth/entry`、幂等兼容策略、模块边界以及与 JWT / refresh cookie 的衔接方式。

View File

@@ -1,6 +1,6 @@
# Rust API Server 路由索引2026-04-22
# Rust API Server 路由索引2026-04-23
更新时间:`2026-04-22`
更新时间:`2026-04-23`
## 1. 文档目标
@@ -10,27 +10,36 @@
## 2. 当前统计
当前 Rust `api-server``app.rs` 可抽取到 `96` 条路由:
当前 Rust `api-server``app.rs` 可抽取到 `101` 条路由:
1. 内部鉴权调试接口:`2` 条。
2. AI task 接口:`9` 条。
3. assets / OSS 接口:`15` 条。
4. auth 接口:`12` 条。
5. custom world / agent 接口:`23` 条。
6. llm proxy 接口:`1` 条。
7. profile / runtime profile 接口:`12` 条。
8. runtime story / story gameplay 接口:`15` 条。
9. legacy generated 静态路径兼容`6` 条。
10. health check`1` 条。
1. 管理后台接口:`5` 条。
2. 内部鉴权调试接口:`2` 条。
3. AI task 接口:`9` 条。
4. assets / OSS 接口:`15` 条。
5. auth 接口:`12` 条。
6. custom world / agent 接口:`23` 条。
7. llm proxy 接口:`1` 条。
8. profile / runtime profile 接口:`12` 条。
9. runtime story / story gameplay 接口`15` 条。
10. legacy generated 静态路径兼容`6` 条。
11. health check`1` 条。
## 3. 路由清单
### 3.1 内部鉴权调试
### 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`
### 3.2 内部鉴权调试
1. `GET /_internal/auth/claims`
2. `GET /_internal/auth/refresh-cookie`
### 3.2 AI Task
### 3.3 AI Task
1. `POST /api/ai/tasks`
2. `POST /api/ai/tasks/{task_id}/start`
@@ -42,7 +51,7 @@
8. `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/start`
9. `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/complete`
### 3.3 Assets / OSS
### 3.4 Assets / OSS
1. `POST /api/assets/direct-upload-tickets`
2. `POST /api/assets/sts-upload-credentials`
@@ -60,7 +69,7 @@
14. `GET /api/assets/character-workflow-cache/{character_id}`
15. `GET / POST /api/assets/character-workflow-cache`
### 3.4 Auth
### 3.5 Auth
1. `GET /api/auth/login-options`
2. `GET /api/auth/me`
@@ -75,7 +84,7 @@
11. `POST /api/auth/wechat/bind-phone`
12. `POST /api/auth/entry`
### 3.5 Custom World / Agent
### 3.6 Custom World / Agent
1. `GET /api/runtime/custom-world-library`
2. `GET /api/runtime/custom-world-library/{profile_id}`
@@ -101,11 +110,11 @@
22. `POST /api/runtime/custom-world/cover-image`
23. `POST /api/runtime/custom-world/cover-upload`
### 3.6 LLM Proxy
### 3.7 LLM Proxy
1. `POST /api/llm/chat/completions`
### 3.7 Profile / Runtime Profile
### 3.8 Profile / Runtime Profile
1. `GET /api/profile/dashboard`
2. `GET /api/runtime/profile/dashboard`
@@ -120,7 +129,7 @@
11. `POST /api/profile/save-archives/{world_key}`
12. `POST /api/runtime/profile/save-archives/{world_key}`
### 3.8 Runtime Story / Gameplay
### 3.9 Runtime Story / Gameplay
1. `POST /api/runtime/save/snapshot`
2. `GET /api/runtime/settings`
@@ -138,7 +147,7 @@
14. `POST /api/story/npc/battle`
15. `GET /api/runtime/sessions/{runtime_session_id}/inventory`
### 3.9 Legacy Generated 路径
### 3.10 Legacy Generated 路径
1. `GET /generated-character-drafts/{*path}`
2. `GET /generated-characters/{*path}`
@@ -147,7 +156,7 @@
5. `GET /generated-custom-world-covers/{*path}`
6. `GET /generated-qwen-sprites/{*path}`
### 3.10 Health
### 3.11 Health
1. `GET /healthz`

View File

@@ -0,0 +1,241 @@
# spacetime-module `lib.rs` 拆分执行方案
日期:`2026-04-23`
## 1. 背景
当前 `server-rs/crates/spacetime-module/src/lib.rs` 已经超过 `9000` 行,同时混合了承载:
1. SpacetimeDB 主工程入口
2. 跨域共享类型
3. Big Fish
4. Asset Metadata
5. Runtime
6. Gameplay
7. Custom World
8. AI
9. Puzzle
10. 测试
这会直接导致:
1. 任意一个领域改动都要在同一个超大文件里定位
2. 不同玩法与领域的 reducer / procedure / helper 互相缠绕
3. 结构上已经与 `M7` 冻结的“按业务与 SpacetimeDB 聚合层次拆分目录”目标不一致
## 2. 本轮约束
本轮拆分严格遵守以下规则:
1. 只做物理结构收口,不改 table 名、reducer 名、procedure 名。
2. 不改现有 schema 字段名、字段顺序语义与已有对外 contract。
3. 不把外部副作用搬进 SpacetimeDB reducer / procedure。
4. 允许在过渡期继续保留少量跨域 helper 在 `lib.rs`,但新增内容禁止继续直接堆回 `lib.rs`
## 3. 模块地图
### 3.1 根入口
`server-rs/crates/spacetime-module/src/lib.rs`
后续只允许保留:
1. `use` 聚合
2. `mod` 声明
3. 少量跨域共享 helper
4. 迁移过渡期测试
禁止继续新增某个具体业务域的 table / reducer / procedure / tx helper。
导入导出风格同步冻结为:
1. `src/lib.rs` 对外统一优先使用 `pub use xxx::*;` 重新导出主工程模块与外部模块 crate 内容。
2. 已拆出的业务模块内部统一优先使用 `use crate::*;`,避免每个文件重复维护大段显式 `use` 列表。
3. 子模块只有遇到命名冲突、宏限制或无法从 crate 根重导出的外部符号时,才允许补局部显式 `use`
4. 后续拆分时不再因为导入列表变长而把业务实现留在 `lib.rs`
### 3.2 已冻结的一级模块
1. `src/entry.rs`
- 模块初始化入口
- `#[spacetimedb::reducer(init)]`
2. `src/domain_types.rs`
- 跨域共享的 SpacetimeDB 类型
- 目前主要承载 NPC 开战桥接输入输出
3. `src/asset_metadata/mod.rs`
- `asset_object`
- `asset_entity_binding`
- 资产确认与绑定 reducer / procedure
4. `src/big_fish/mod.rs`
- Big Fish session / message / asset slot / runtime run
- Big Fish procedure 与 tx helper
5. `src/runtime/mod.rs`
- Runtime settings / snapshots / browse history / dashboard / wallet / save archive
6. `src/gameplay/mod.rs`
- `story / combat / inventory / npc / quest / runtime_item / progression`
7. `src/custom_world/mod.rs`
- profile / session / agent / publish / gallery / works
8. `src/ai/mod.rs`
- ai task / stage / chunk / reference
9. `src/puzzle.rs`
- 拼图玩法当前仍为单文件域模块,后续再决定是否继续拆目录
### 3.3 本轮先创建的二级落位点
#### `asset_metadata/`
1. `objects.rs`
2. `bindings.rs`
#### `big_fish/`
1. `tables.rs`
2. `session.rs`
3. `assets.rs`
4. `runtime.rs`
#### `runtime/`
1. `settings.rs`
2. `snapshots.rs`
3. `browse_history.rs`
4. `profile.rs`
#### `gameplay/`
1. `combat.rs`
2. `inventory.rs`
3. `npc.rs`
4. `progression.rs`
5. `quest.rs`
6. `runtime_item.rs`
7. `story.rs`
#### `custom_world/`
1. `profile.rs`
2. `session.rs`
3. `agent.rs`
4. `publishing.rs`
5. `gallery.rs`
6. `works.rs`
#### `ai/`
1. `tasks.rs`
2. `stages.rs`
3. `snapshots.rs`
## 4. 迁移顺序
为了降低脏工作区下的冲突风险,本轮按下面顺序推进:
1. 先冻结文档与 README 路由规则。
2. 先创建二级空模块文件,作为后续内容落位点。
3. 第一批先迁:
- `entry.rs`
- `domain_types.rs`
- `asset_metadata/mod.rs`
- `big_fish/mod.rs`
4. 第二批再迁:
- `runtime/mod.rs`
- `gameplay/mod.rs`
5. 第三批再迁:
- `custom_world/mod.rs`
- `ai/mod.rs`
6. `puzzle.rs` 暂时保持单文件,不在本轮强拆。
## 5. 本轮完成标准
1. `README.md` 明确声明后续新增逻辑的落位规则。
2. 一级模块与二级空模块文件创建完成。
3. `lib.rs` 不再承载 `init`、共享 domain types、asset metadata、big fish 的实现细节。
4. `cargo check -p spacetime-module --lib` 继续通过。
5. 中文文档与 Rust 文件经过编码检查,没有写坏。
## 6. `runtime` 域实拆记录
`2026-04-23` 追加执行 `runtime` 域真实内容拆分,目标是把根入口中的 runtime 表、procedure 与同域 tx helper 收口到 `src/runtime/`,同时保持对外 API 名称不变。
### 6.1 已落位文件
1. `server-rs/crates/spacetime-module/src/runtime/mod.rs`
- 仅保留二级模块声明与 `pub use xxx::*;` 聚合导出。
2. `server-rs/crates/spacetime-module/src/runtime/settings.rs`
- 承载 `RuntimeSetting`
- 承载 runtime setting 的读取、upsert procedure 与快照构建 helper。
3. `server-rs/crates/spacetime-module/src/runtime/snapshots.rs`
- 承载 `RuntimeSnapshotRow`
- 承载 runtime snapshot 的读取、upsert、delete 与 JSON 解析 helper。
4. `server-rs/crates/spacetime-module/src/runtime/browse_history.rs`
- 承载 `UserBrowseHistory`
- 承载平台浏览历史 list、upsert、clear procedure 与行转换 helper。
5. `server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 承载 `ProfileDashboardState`
- 承载 `ProfileWalletLedger`
- 承载 `ProfilePlayedWorld`
- 承载 `ProfileSaveArchive`
- 承载 profile dashboard、wallet ledger、play stats、save archive 投影与 snapshot 同步 helper。
### 6.2 根入口调整
`server-rs/crates/spacetime-module/src/lib.rs` 当前只通过下面方式接入 runtime 域:
1. `mod runtime;`
2. `pub use runtime::*;`
原先留在 `lib.rs` 的 runtime setting、snapshot、profile、browse history 旧 helper 已删除,避免同名实现重复存在,也避免后续继续在根入口堆叠 runtime 业务逻辑。
### 6.3 后续维护规则
1. 对外导出继续通过 `pub use runtime::*;` 以及 `src/runtime/mod.rs` 中的 `pub use xxx::*;` 透出。
2. `runtime` 子文件内部优先使用 `use crate::*;`,只有 SpacetimeDB table accessor trait、命名冲突或宏限制需要时才补最小显式导入。
3. 新增 runtime 相关表、procedure、reducer 或 tx helper 时,必须先按 `settings / snapshots / browse_history / profile` 判断二级落位点;不匹配时先更新本文件与 README再新增二级模块。
4. `lib.rs` 只保留跨域共享 helper 和尚未迁出的过渡代码,不再接收 runtime 域新增实现。
## 7. `ai` 域实拆记录
`2026-04-23` 追加执行 `ai` 域真实内容拆分,目标是把根入口中的 AI 表、procedure 与同域 tx helper 收口到 `src/ai/`,同时保持 `module-ai` 对外输入输出 contract 与 SpacetimeDB reducer / procedure 名称不变。
### 7.1 已落位文件
1. `server-rs/crates/spacetime-module/src/ai/mod.rs`
- 仅保留二级模块声明与聚合导出。
- public API 通过 `pub use stages::*;``pub use tasks::*;` 透出。
- 内部转换 helper 通过 `pub(crate) use snapshots::*;` 供 AI 子模块共享。
2. `server-rs/crates/spacetime-module/src/ai/tasks.rs`
- 承载 `AiTask`
- 承载 `create_ai_task`
- 承载 `create_ai_task_and_return`
- 承载 `start_ai_task`
- 承载 `complete_ai_task_and_return`
- 承载 `fail_ai_task_and_return`
- 承载 `cancel_ai_task_and_return`
- 承载 task 状态迁移、读取与持久化 helper。
3. `server-rs/crates/spacetime-module/src/ai/stages.rs`
- 承载 `AiTaskStage`
- 承载 `AiTextChunk`
- 承载 `AiResultReference`
- 承载 `start_ai_task_stage`
- 承载 `append_ai_text_chunk_and_return`
- 承载 `complete_ai_stage_and_return`
- 承载 `attach_ai_result_reference_and_return`
- 承载阶段流式文本聚合、阶段替换与 result reference 写入 helper。
4. `server-rs/crates/spacetime-module/src/ai/snapshots.rs`
- 承载 `AiTask / AiTaskStage / AiTextChunk / AiResultReference` 的 row 与 snapshot 转换 helper。
### 7.2 根入口调整
`server-rs/crates/spacetime-module/src/lib.rs` 当前只通过下面方式接入 AI 域:
1. `mod ai;`
2. `pub use ai::*;`
原先留在 `lib.rs` 的 AI 表、procedure 与 helper 已删除,避免根入口继续堆叠 AI 业务实现。
### 7.3 后续维护规则
1. 对外导出继续通过 `pub use ai::*;` 以及 `src/ai/mod.rs` 中的 `pub use xxx::*;` 透出。
2. `ai` 子文件内部优先使用 `use crate::*;`,只有 `module_ai` 中的归一化函数、校验函数、ID 前缀常量等需要避免 glob 歧义的符号才补最小显式导入。
3. 新增 AI 相关表、procedure、reducer 或 tx helper 时,必须先按 `tasks / stages / snapshots` 判断二级落位点;不匹配时先更新本文件与 README再新增二级模块。
4. `lib.rs` 不再接收 AI 域新增实现。

View File

@@ -0,0 +1,141 @@
# 创作中心全草稿恢复 Agent 会话设计
日期:`2026-04-23`
## 1. 背景
当前创作中心已经承载多种“先聊天收束,再进入结果页”的创作链:
1. RPG / Custom World
2. Big Fish
3. Puzzle
但三条链当前恢复能力并不一致:
1. RPG 草稿已经能通过 `sessionId` 恢复 Agent 会话。
2. Puzzle 草稿已补到通过 `sourceSessionId` 恢复 Agent 会话。
3. Big Fish 仍停留在“有会话但没有创作中心草稿投影”的状态,用户退出登录后缺少重新进入草稿的入口。
这会导致“进入创作草稿继续聊”的体验只在部分品类成立,不满足创作中心统一入口的要求。
## 2. 目标
本轮统一收口到以下规则:
1. 只要是创作中心中的 `draft` 草稿,都必须能恢复对应 Agent 聊天历史。
2. 恢复能力只要求在“重新进入创作中心草稿”时成立,不扩展到用户级独立聊天历史列表。
3. 所有草稿卡片都遵循同一入口语义:
- 尚无结果页草稿时,进入 Agent 工作区继续聊。
- 已有结果页草稿时,直接进入结果页。
- 从结果页返回后,仍能看到原聊天记录。
## 3. 统一边界
### 3.1 真相源
聊天历史、当前阶段、锚点、草稿真相始终在各自的 Agent session 表中:
1. RPG / Custom World`custom_world_agent_session`
2. Big Fish`big_fish_creation_session + big_fish_agent_message`
3. Puzzle`puzzle_agent_session + puzzle_agent_message`
### 3.2 创作中心作品卡
创作中心作品卡只承担:
1. 展示草稿摘要。
2. 保存恢复用的稳定会话标识。
3. 作为重新进入创作链的入口。
它不是新的聊天真相,也不独立承载消息历史。
## 4. 统一恢复规则
### 4.1 草稿卡必须带会话索引
不同品类的草稿卡都必须能反查到会话:
1. RPG / Custom World使用现有 `sessionId`
2. Big Fish新增 `sourceSessionId`
3. Puzzle使用现有 `sourceSessionId`
### 4.2 打开草稿时的分流
前端打开草稿卡时统一执行:
1. 先按会话 id 读取对应 session snapshot。
2. 若 session 已有结果页草稿:
- RPG 进入 `custom-world-result`
- Big Fish 进入 `big-fish-result`
- Puzzle 进入 `puzzle-result`
3. 若 session 尚无结果页草稿:
- RPG 进入 `agent-workspace`
- Big Fish 进入 `big-fish-agent-workspace`
- Puzzle 进入 `puzzle-agent-workspace`
### 4.3 失败回退
如果草稿卡缺少会话索引,或会话已不存在:
1. 刷新对应作品列表。
2. 停留在创作中心。
3. 通过现有错误 banner 提示,不新增规则说明 UI。
## 5. 分品类落地要求
### 5.1 RPG / Custom World
RPG 已具备恢复基础,本轮只把它纳入统一口径:
1. `draft + sessionId` 继续作为恢复前提。
2. 有结果页草稿时,主按钮保持 `继续完善`
3. 没有结果页草稿时,主按钮保持 `继续创作`
### 5.2 Puzzle
Puzzle 继续沿用本轮已落地的规则:
1. 编译结果页草稿时同步 upsert `draft` 作品投影。
2. 作品投影保留 `sourceSessionId`
3. 草稿卡点击恢复 `puzzleSession`,优先进入结果页。
### 5.3 Big Fish
Big Fish 需要补齐缺口:
1.`big_fish_creation_session` 增加 works 读模型输出,不新建第二套聊天存储。
2. 创作中心读取 Big Fish works 并合并展示。
3. 草稿卡固定带 `sourceSessionId = sessionId`
4. `draft` 卡主按钮使用 `继续创作`
5. `published` 卡主按钮使用 `查看详情`,体验入口仍直接进入运行态。
6. 在独立 Big Fish 详情页补齐前,`查看详情` 先复用结果页承载详情与返回聊天的入口,不额外新建页面。
## 6. Big Fish works 最小方案
### 6.1 不新增独立 Big Fish profile 表
本轮 Big Fish 只补“草稿恢复聊天”闭环,不强行新建完整发布作品仓储。
创作中心所需 Big Fish work summary 直接由 `big_fish_creation_session` 派生:
1. `draft_json` 存在时输出草稿标题、副标题、摘要。
2. `asset_slots` 提供封面图和资源完成度提示。
3. `stage == Published` 时视为 `published`
4. 其他阶段统一视为 `draft`
### 6.2 稳定 workId
Big Fish works 使用会话派生稳定 id
1. `workId = big-fish-work-{sessionId}`
2. `sourceSessionId = sessionId`
这样同一份草稿不会在创作中心重复出现。
## 7. 验收
1. RPG、Big Fish、Puzzle 三类草稿都能在创作中心重新打开。
2. 退出登录后重新登录,进入同一份草稿仍能恢复对应聊天记录。
3. Big Fish 草稿首次编译出结果页后,会在创作中心出现草稿卡。
4. Big Fish / Puzzle 已有结果页草稿时,点击草稿卡直达结果页。
5. 从结果页返回各自 Agent 工作区后,历史消息不丢失。

View File

@@ -3,6 +3,7 @@ export type AuthLoginMethod = 'password' | 'phone' | 'wechat';
export type AuthUser = {
id: string;
publicUserCode: string;
username: string;
displayName: string;
phoneNumberMasked: string | null;
@@ -11,6 +12,16 @@ export type AuthUser = {
wechatBound: boolean;
};
export type PublicUserSummary = {
id: string;
publicUserCode: string;
displayName: string;
};
export type PublicUserSearchResponse = {
user: PublicUserSummary;
};
export type AuthEntryRequest = {
username: string;
password: string;

View File

@@ -0,0 +1,21 @@
export type BigFishWorkStatus = 'draft' | 'published';
export interface BigFishWorkSummary {
workId: string;
sourceSessionId: string;
title: string;
subtitle: string;
summary: string;
coverImageSrc: string | null;
status: BigFishWorkStatus;
updatedAt: string;
publishReady: boolean;
levelCount: number;
levelMainImageReadyCount: number;
levelMotionReadyCount: number;
backgroundReady: boolean;
}
export interface BigFishWorksResponse {
items: BigFishWorkSummary[];
}

View File

@@ -629,6 +629,8 @@ export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry<Cus
return cloneFixture({
ownerUserId: RPG_CREATION_FIXTURE_USER_ID,
profileId: RPG_CREATION_FIXTURE_PROFILE_ID,
publicWorkCode: 'cw-fixture-001',
authorPublicUserCode: 'sy-fixture-user',
profile,
visibility: 'published',
publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT,

View File

@@ -118,6 +118,8 @@ export type CustomWorldProfileRecord = JsonObject & {
export type CustomWorldLibraryEntry<TProfile = CustomWorldProfileRecord> = {
ownerUserId: string;
profileId: string;
publicWorkCode: string | null;
authorPublicUserCode: string | null;
profile: TProfile;
visibility: CustomWorldPublicationStatus;
publishedAt: string | null;

2
server-rs/Cargo.lock generated
View File

@@ -86,6 +86,7 @@ dependencies = [
"module-custom-world",
"module-inventory",
"module-npc",
"module-puzzle",
"module-runtime",
"module-runtime-item",
"module-runtime-story-compat",
@@ -2666,6 +2667,7 @@ dependencies = [
"module-runtime",
"module-runtime-item",
"module-story",
"serde",
"serde_json",
"shared-kernel",
"spacetimedb-sdk",

View File

@@ -21,6 +21,7 @@ module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" }
module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
module-runtime-item = { path = "../module-runtime-item" }

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,10 @@ use tower_http::{
use tracing::{Level, Span, error, info, info_span, warn};
use crate::{
admin::{
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
@@ -26,10 +30,11 @@ use crate::{
require_bearer_auth,
},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session,
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
},
character_animation_assets::{
@@ -44,8 +49,9 @@ use crate::{
create_custom_world_agent_session, delete_custom_world_library_profile,
execute_custom_world_agent_action, get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_library, get_custom_world_library_detail,
get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile,
get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code,
get_custom_world_library, get_custom_world_library_detail, get_custom_world_works,
list_custom_world_gallery, publish_custom_world_library_profile,
put_custom_world_library_profile, stream_custom_world_agent_message,
submit_custom_world_agent_message, unpublish_custom_world_library_profile,
},
@@ -105,6 +111,29 @@ pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new()
.route("/admin", get(admin_console_page))
.route("/admin/api/login", post(admin_login))
.route(
"/admin/api/me",
get(admin_me).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/overview",
get(admin_overview).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/debug/http",
post(admin_debug_http).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/healthz",
get(|Extension(request_context): Extension<_>| async move {
@@ -126,6 +155,14 @@ pub fn build_router(state: AppState) -> Router {
)),
)
.route("/api/auth/login-options", get(auth_login_options))
.route(
"/api/auth/public-users/by-code/{code}",
get(get_public_user_by_code),
)
.route(
"/api/auth/public-users/by-id/{user_id}",
get(get_public_user_by_id),
)
.route(
"/generated-character-drafts/{*path}",
get(proxy_generated_character_drafts),
@@ -391,6 +428,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}",
get(get_custom_world_gallery_detail),
)
.route(
"/api/runtime/custom-world-gallery/by-code/{code}",
get(get_custom_world_gallery_detail_by_code),
)
.route(
"/api/runtime/custom-world/agent/sessions",
post(create_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
@@ -482,6 +523,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works",
get(get_big_fish_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
@@ -915,8 +963,10 @@ mod tests {
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
};
use reqwest::Client;
use serde_json::Value;
use time::OffsetDateTime;
use tokio::net::TcpListener;
use tower::ServiceExt;
use crate::{config::AppConfig, state::AppState};
@@ -974,7 +1024,7 @@ mod tests {
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["service"],
Value::String("genarrative-node-server".to_string())
Value::String("genarrative-api-server".to_string())
);
}
@@ -1008,7 +1058,7 @@ mod tests {
assert_eq!(payload["ok"], Value::Bool(true));
assert_eq!(
payload["data"]["service"],
Value::String("genarrative-node-server".to_string())
Value::String("genarrative-api-server".to_string())
);
assert_eq!(
payload["meta"]["requestId"],
@@ -2905,4 +2955,190 @@ mod tests {
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn admin_login_returns_token_when_configured() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "root",
"password": "secret123"
})
.to_string(),
))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert!(payload["token"].as_str().is_some());
assert_eq!(
payload["admin"]["username"],
Value::String("root".to_string())
);
}
#[tokio::test]
async fn admin_route_rejects_regular_user_token() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let state = AppState::new(config).expect("state should build");
let app = build_router(state.clone());
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_admin_forbidden",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login should succeed");
let login_body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let login_payload: Value =
serde_json::from_slice(&login_body).expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let response = app
.oneshot(
Request::builder()
.uri("/admin/api/me")
.header("authorization", format!("Bearer {access_token}"))
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_debug_http_can_probe_healthz_when_authenticated() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let local_addr = listener
.local_addr()
.expect("listener should expose local addr");
config.bind_host = "127.0.0.1".to_string();
config.bind_port = local_addr.port();
let app = build_router(AppState::new(config).expect("state should build"));
let server = tokio::spawn(async move {
axum::serve(listener, app)
.await
.expect("test admin server should serve");
});
let http_client = Client::new();
let base_url = format!("http://{}", local_addr);
let login_payload: Value = http_client
.post(format!("{base_url}/admin/api/login"))
.json(&serde_json::json!({
"username": "root",
"password": "secret123"
}))
.send()
.await
.expect("login request should succeed")
.json()
.await
.expect("login payload should be json");
let access_token = login_payload["token"]
.as_str()
.expect("token should exist")
.to_string();
let payload: Value = http_client
.post(format!("{base_url}/admin/api/debug/http"))
.bearer_auth(access_token)
.json(&serde_json::json!({
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
}))
.send()
.await
.expect("debug request should succeed")
.json()
.await
.expect("debug payload should be json");
server.abort();
let _ = server.await;
assert_eq!(payload["status"], Value::Number(200.into()));
}
#[tokio::test]
async fn admin_debug_http_requires_authentication() {
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
let app = build_router(AppState::new(config).expect("state should build"));
let debug_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/debug/http")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
})
.to_string(),
))
.expect("debug request should build"),
)
.await
.expect("debug request should succeed");
assert_eq!(debug_response.status(), StatusCode::UNAUTHORIZED);
}
}

View File

@@ -3,11 +3,12 @@ use axum::{
extract::{Extension, State},
http::StatusCode,
};
use shared_contracts::auth::{AuthMeResponse, AuthUserPayload, build_available_login_methods};
use shared_contracts::auth::{AuthMeResponse, build_available_login_methods};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
api_response::json_success_body, auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload, http_error::AppError, request_context::RequestContext,
state::AppState,
};
pub async fn auth_me(
@@ -30,15 +31,7 @@ pub async fn auth_me(
Ok(json_success_body(
Some(&request_context),
AuthMeResponse {
user: AuthUserPayload {
id: user.user.id,
username: user.user.username,
display_name: user.user.display_name,
phone_number_masked: user.user.phone_number_masked,
login_method: user.user.login_method.as_str().to_string(),
binding_status: user.user.binding_status.as_str().to_string(),
wechat_bound: user.user.wechat_bound,
},
user: map_auth_user_payload(user.user),
available_login_methods: build_available_login_methods(
state.config.sms_auth_enabled,
state.config.wechat_auth_enabled,

View File

@@ -0,0 +1,23 @@
use module_auth::AuthUser;
use shared_contracts::auth::{AuthUserPayload, PublicUserSummaryPayload};
pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
AuthUserPayload {
id: user.id,
public_user_code: user.public_user_code,
username: user.username,
display_name: user.display_name,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
}
}
pub fn map_public_user_summary_payload(user: AuthUser) -> PublicUserSummaryPayload {
PublicUserSummaryPayload {
id: user.id,
public_user_code: user.public_user_code,
display_name: user.display_name,
}
}

View File

@@ -0,0 +1,84 @@
use axum::{
Json,
extract::{Extension, Path, State},
http::StatusCode,
};
use shared_contracts::auth::PublicUserSearchResponse;
use crate::{
api_response::json_success_body, auth_payload::map_public_user_summary_payload,
http_error::AppError, request_context::RequestContext, state::AppState,
};
pub async fn get_public_user_by_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(code): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user = state
.password_entry_service()
.get_user_by_public_user_code(&code)
.map_err(map_public_user_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应叙世号用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user.user),
},
))
}
pub async fn get_public_user_by_id(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Path(user_id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = user_id.trim();
if user_id.is_empty() {
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message("用户 ID 不能为空"));
}
let user = state
.auth_user_service()
.get_user_by_id(user_id)
.map_err(map_public_user_id_search_error)?
.ok_or_else(|| {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
})?;
Ok(json_success_body(
Some(&request_context),
PublicUserSearchResponse {
user: map_public_user_summary_payload(user),
},
))
}
fn map_public_user_search_error(error: module_auth::PasswordEntryError) -> AppError {
match error {
module_auth::PasswordEntryError::InvalidPublicUserCode => {
AppError::from_status(StatusCode::BAD_REQUEST).with_message("叙世号格式不正确")
}
module_auth::PasswordEntryError::Store(_)
| module_auth::PasswordEntryError::PasswordHash(_)
| module_auth::PasswordEntryError::InvalidUsername
| module_auth::PasswordEntryError::InvalidPasswordLength
| module_auth::PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}
fn map_public_user_id_search_error(error: module_auth::LogoutError) -> AppError {
match error {
module_auth::LogoutError::UserNotFound => {
AppError::from_status(StatusCode::NOT_FOUND).with_message("未找到对应用户")
}
module_auth::LogoutError::Store(_) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
}
}
}

View File

@@ -13,9 +13,7 @@ use module_assets::{
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
};
use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
};
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
use serde_json::{Map, Value, json};
use shared_contracts::big_fish::{
BigFishActionResponse, BigFishAgentMessageResponse, BigFishAnchorItemResponse,
@@ -26,7 +24,8 @@ use shared_contracts::big_fish::{
BigFishVector2Response, CreateBigFishSessionRequest, ExecuteBigFishActionRequest,
SendBigFishMessageRequest, SubmitBigFishInputRequest,
};
use shared_kernel::build_prefixed_uuid_id;
use shared_contracts::big_fish_works::{BigFishWorkSummaryResponse, BigFishWorksResponse};
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::{
BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord,
BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord,
@@ -34,7 +33,7 @@ use spacetime_client::{
BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
SpacetimeClientError,
BigFishWorkSummaryRecord, SpacetimeClientError,
};
use tokio::time::sleep;
@@ -107,6 +106,30 @@ pub async fn get_big_fish_session(
))
}
pub async fn get_big_fish_works(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_works(authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -610,6 +633,26 @@ fn map_big_fish_runtime_response(run: BigFishRuntimeRecord) -> BigFishRuntimeSna
}
}
fn map_big_fish_work_summary_response(
item: BigFishWorkSummaryRecord,
) -> BigFishWorkSummaryResponse {
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
cover_image_src: item.cover_image_src,
status: item.status,
updated_at: current_timestamp_micros_to_string(item.updated_at_micros),
publish_ready: item.publish_ready,
level_count: item.level_count,
level_main_image_ready_count: item.level_main_image_ready_count,
level_motion_ready_count: item.level_motion_ready_count,
background_ready: item.background_ready,
}
}
fn map_big_fish_entity_response(
entity: BigFishRuntimeEntityRecord,
) -> BigFishRuntimeEntityResponse {
@@ -666,8 +709,7 @@ struct BigFishFormalAssetContext {
const BIG_FISH_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
const BIG_FISH_ENTITY_KIND: &str = "big_fish_session";
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str =
"文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景";
const BIG_FISH_DEFAULT_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框多余肢体畸形鱼体低清晰度模糊压缩噪点现代摄影棚写实照片背景";
async fn generate_big_fish_formal_asset(
state: &AppState,
@@ -794,10 +836,12 @@ fn build_big_fish_formal_asset_context(
asset_id,
],
}),
_ => Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
}))),
_ => Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "big-fish",
"message": format!("assetKind `{asset_kind}` 不支持正式图片生成。"),
})),
),
}
}
@@ -983,9 +1027,9 @@ async fn create_big_fish_text_to_image_generation(
}))
.send()
.await
.map_err(|error| map_big_fish_dashscope_request_error(format!(
"创建 Big Fish 图片生成任务失败:{error}"
)))?;
.map_err(|error| {
map_big_fish_dashscope_request_error(format!("创建 Big Fish 图片生成任务失败:{error}"))
})?;
let status = response.status();
let response_text = response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("读取 Big Fish 图片生成响应失败:{error}"))
@@ -996,7 +1040,8 @@ async fn create_big_fish_text_to_image_generation(
"创建 Big Fish 图片生成任务失败",
));
}
let payload = parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
let payload =
parse_big_fish_json_payload(response_text.as_str(), "解析 Big Fish 图片生成响应失败")?;
let task_id = extract_big_fish_task_id(&payload).ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "dashscope",
@@ -1014,9 +1059,11 @@ async fn create_big_fish_text_to_image_generation(
)
.send()
.await
.map_err(|error| map_big_fish_dashscope_request_error(format!(
"查询 Big Fish 图片生成任务失败:{error}"
)))?;
.map_err(|error| {
map_big_fish_dashscope_request_error(format!(
"查询 Big Fish 图片生成任务失败:{error}"
))
})?;
let poll_status = poll_response.status();
let poll_text = poll_response.text().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!(
@@ -1070,11 +1117,9 @@ async fn download_big_fish_remote_image(
image_url: &str,
fallback_message: &str,
) -> Result<BigFishDownloadedImage, AppError> {
let response = http_client
.get(image_url)
.send()
.await
.map_err(|error| map_big_fish_dashscope_request_error(format!("{fallback_message}{error}")))?;
let response = http_client.get(image_url).send().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("{fallback_message}{error}"))
})?;
let status = response.status();
let content_type = response
.headers()
@@ -1082,10 +1127,9 @@ async fn download_big_fish_remote_image(
.and_then(|value| value.to_str().ok())
.unwrap_or("image/jpeg")
.to_string();
let bytes = response
.bytes()
.await
.map_err(|error| map_big_fish_dashscope_request_error(format!("{fallback_message}{error}")))?;
let bytes = response.bytes().await.map_err(|error| {
map_big_fish_dashscope_request_error(format!("{fallback_message}{error}"))
})?;
if !status.is_success() {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
@@ -1475,3 +1519,7 @@ fn current_utc_micros() -> i64 {
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
fn current_timestamp_micros_to_string(value: i64) -> String {
format_timestamp_micros(value)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,9 @@ pub struct AppConfig {
pub bind_host: String,
pub bind_port: u16,
pub log_filter: String,
pub admin_username: Option<String>,
pub admin_password: Option<String>,
pub admin_token_ttl_seconds: u64,
pub internal_api_secret: Option<String>,
pub jwt_issuer: String,
pub jwt_secret: String,
@@ -78,6 +81,13 @@ pub struct AppConfig {
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_image_request_timeout_ms: u64,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64,
pub ark_character_video_model: String,
pub character_animation_ffmpeg_path: String,
pub character_animation_ffprobe_path: String,
pub character_animation_frame_extract_timeout_ms: u64,
pub slow_request_threshold_ms: u64,
}
@@ -87,6 +97,9 @@ impl Default for AppConfig {
bind_host: "127.0.0.1".to_string(),
bind_port: 3000,
log_filter: "info,tower_http=info".to_string(),
admin_username: None,
admin_password: None,
admin_token_ttl_seconds: 4 * 60 * 60,
internal_api_secret: Some(DEFAULT_INTERNAL_API_SECRET.to_string()),
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
@@ -151,6 +164,13 @@ impl Default for AppConfig {
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_image_request_timeout_ms: 150_000,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000,
ark_character_video_model: "doubao-seedance-2-0-fast-260128".to_string(),
character_animation_ffmpeg_path: "ffmpeg".to_string(),
character_animation_ffprobe_path: "ffprobe".to_string(),
character_animation_frame_extract_timeout_ms: 120_000,
slow_request_threshold_ms: 1_000,
}
}
@@ -182,6 +202,14 @@ impl AppConfig {
config.log_filter = log_filter;
}
config.admin_username = read_first_non_empty_env(&["GENARRATIVE_ADMIN_USERNAME"]);
config.admin_password = read_first_non_empty_env(&["GENARRATIVE_ADMIN_PASSWORD"]);
if let Some(admin_token_ttl_seconds) =
read_first_duration_seconds_env(&["GENARRATIVE_ADMIN_TOKEN_TTL_SECONDS"])
{
config.admin_token_ttl_seconds = admin_token_ttl_seconds;
}
config.internal_api_secret = read_first_non_empty_env(&["GENARRATIVE_INTERNAL_API_SECRET"]);
if let Some(jwt_issuer) =
@@ -430,6 +458,55 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL",
"GENARRATIVE_LLM_BASE_URL",
"LLM_BASE_URL",
]) {
config.ark_character_video_base_url = ark_character_video_base_url;
}
config.ark_character_video_api_key = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_API_KEY",
"ARK_API_KEY",
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
]);
if let Some(ark_character_video_request_timeout_ms) = read_first_positive_u64_env(&[
"ARK_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
"DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS",
]) {
config.ark_character_video_request_timeout_ms = ark_character_video_request_timeout_ms;
}
if let Some(ark_character_video_model) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_MODEL",
"DASHSCOPE_CHARACTER_VIDEO_MODEL",
]) {
config.ark_character_video_model = ark_character_video_model;
}
if let Some(character_animation_ffmpeg_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFMPEG_PATH"])
{
config.character_animation_ffmpeg_path = character_animation_ffmpeg_path;
}
if let Some(character_animation_ffprobe_path) =
read_first_non_empty_env(&["CHARACTER_ANIMATION_FFPROBE_PATH"])
{
config.character_animation_ffprobe_path = character_animation_ffprobe_path;
}
if let Some(character_animation_frame_extract_timeout_ms) =
read_first_positive_u64_env(&["CHARACTER_ANIMATION_FRAME_EXTRACT_TIMEOUT_MS"])
{
config.character_animation_frame_extract_timeout_ms =
character_animation_frame_extract_timeout_ms;
}
if let Some(slow_request_threshold_ms) =
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
{

View File

@@ -40,12 +40,19 @@ use spacetime_client::{
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
request_context::RequestContext, state::AppState,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn get_custom_world_library(
@@ -142,12 +149,16 @@ pub async fn put_custom_world_library_profile(
})),
)
})?;
let author_display_name = resolve_author_display_name(&authenticated);
let author_display_name = resolve_author_display_name(&state, &authenticated);
let author_public_user_code =
resolve_author_public_user_code(&state, &authenticated, &request_context)?;
let mutation = state
.spacetime_client()
.upsert_custom_world_profile(CustomWorldProfileUpsertRecordInput {
profile_id: profile_id.clone(),
owner_user_id: owner_user_id.clone(),
public_work_code: None,
author_public_user_code: Some(author_public_user_code),
source_agent_session_id: payload.source_agent_session_id.clone(),
world_name: metadata.world_name,
subtitle: metadata.subtitle,
@@ -240,7 +251,9 @@ pub async fn publish_custom_world_library_profile(
.publish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
None,
resolve_author_public_user_code(&state, &authenticated, &request_context)?,
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -279,7 +292,7 @@ pub async fn unpublish_custom_world_library_profile(
.unpublish_custom_world_profile(
profile_id,
owner_user_id,
resolve_author_display_name(&authenticated),
resolve_author_display_name(&state, &authenticated),
current_utc_micros(),
)
.await
@@ -350,6 +363,37 @@ pub async fn get_custom_world_gallery_detail(
))
}
pub async fn get_custom_world_gallery_detail_by_code(
State(state): State<AppState>,
Path(code): Path<String>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
if code.trim().is_empty() {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-gallery",
"message": "code is required",
})),
));
}
let detail = state
.spacetime_client()
.get_custom_world_gallery_detail_by_code(code)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldGalleryDetailResponse {
entry: map_custom_world_library_entry_response(detail.entry),
},
))
}
pub async fn create_custom_world_agent_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -870,25 +914,88 @@ pub async fn execute_custom_world_agent_action(
));
}
let payload_json = serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?;
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_at_micros = current_utc_micros();
let payload_json = if action == "draft_foundation" {
let session = state
.spacetime_client()
.get_custom_world_agent_session(session_id.clone(), owner_user_id.clone())
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if session.progress_percent < 100 {
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": "draft_foundation requires progressPercent >= 100",
})),
));
}
let llm_client = state.llm_client().ok_or_else(|| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "custom-world-agent",
"message": "服务端尚未配置可用的 LLM API Key",
})),
)
})?;
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
.await
.map_err(|message| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?;
build_draft_foundation_action_payload_json(&payload, &draft_result.draft_profile_json)
.map_err(|error| {
let (status, message) = match error {
DraftFoundationPayloadError::SerializePayload(message) => {
(StatusCode::BAD_REQUEST, message)
}
DraftFoundationPayloadError::InvalidPayloadShape => (
StatusCode::BAD_REQUEST,
"action payload 必须是 object".to_string(),
),
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => {
(StatusCode::BAD_GATEWAY, message)
}
};
custom_world_error_response(
&request_context,
AppError::from_status(status).with_details(json!({
"provider": "custom-world-agent",
"message": message,
})),
)
})?
} else {
serde_json::to_string(&payload).map_err(|error| {
custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "custom-world-agent",
"message": format!("action payload JSON 序列化失败:{error}"),
})),
)
})?
};
let result = state
.spacetime_client()
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
owner_user_id,
operation_id: build_prefixed_uuid_id("operation-"),
action,
payload_json: Some(payload_json),
submitted_at_micros: current_utc_micros(),
submitted_at_micros,
})
.await
.map_err(|error| {
@@ -909,6 +1016,8 @@ fn map_custom_world_library_entry_response(
CustomWorldLibraryEntryResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
profile: entry.profile,
visibility: entry.visibility,
published_at: entry.published_at,
@@ -930,6 +1039,8 @@ fn map_custom_world_gallery_card_response(
CustomWorldGalleryCardResponse {
owner_user_id: entry.owner_user_id,
profile_id: entry.profile_id,
public_work_code: entry.public_work_code,
author_public_user_code: entry.author_public_user_code,
visibility: entry.visibility,
published_at: entry.published_at,
updated_at: entry.updated_at,
@@ -1213,8 +1324,48 @@ fn custom_world_sse_error_event_message(message: String) -> Event {
Event::default().event("error").data(payload)
}
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
"玩家".to_string()
fn resolve_author_display_name(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
) -> String {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.ok()
.flatten()
.map(|user| user.display_name)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "玩家".to_string())
}
fn resolve_author_public_user_code(
state: &AppState,
authenticated: &AuthenticatedAccessToken,
request_context: &RequestContext,
) -> Result<String, Response> {
state
.auth_user_service()
.get_user_by_id(authenticated.claims().user_id())
.map_err(|error| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "custom-world-library",
"message": format!("作者叙世号读取失败:{error}"),
})),
)
})?
.map(|user| user.public_user_code)
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
custom_world_error_response(
request_context,
AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({
"provider": "custom-world-library",
"message": "当前登录用户缺少叙世号",
})),
)
})
}
fn build_custom_world_agent_welcome_text(seed_text: &str) -> String {

View File

@@ -611,7 +611,10 @@ where
empty_json_array()
};
let asset_coverage_json = if should_stay_in_draft_stage {
serialize_json(&request.session.asset_coverage, &empty_agent_asset_coverage_json())
serialize_json(
&request.session.asset_coverage,
&empty_agent_asset_coverage_json(),
)
} else {
empty_agent_asset_coverage_json()
};
@@ -732,7 +735,10 @@ pub(crate) fn build_failed_finalize_record_input(
stage: session.stage.clone(),
progress_percent: session.progress_percent,
focus_card_id: session.focus_card_id.clone(),
anchor_content_json: serialize_json(&session.anchor_content, &empty_agent_anchor_content_json()),
anchor_content_json: serialize_json(
&session.anchor_content,
&empty_agent_anchor_content_json(),
),
creator_intent_json: serialize_optional_json_object(&session.creator_intent),
creator_intent_readiness_json: serialize_json(
&session.creator_intent_readiness,
@@ -753,7 +759,10 @@ pub(crate) fn build_failed_finalize_record_input(
&JsonValue::Array(session.quality_findings.clone()),
&empty_json_array(),
),
asset_coverage_json: serialize_json(&session.asset_coverage, &empty_agent_asset_coverage_json()),
asset_coverage_json: serialize_json(
&session.asset_coverage,
&empty_agent_asset_coverage_json(),
),
error_message: Some(error_message),
updated_at_micros,
}
@@ -771,13 +780,18 @@ async fn stream_single_turn<F>(
where
F: FnMut(&str),
{
let llm_client = llm_client.ok_or_else(|| {
CustomWorldTurnError::new("当前模型不可用,请稍后重试。")
})?;
let llm_client =
llm_client.ok_or_else(|| CustomWorldTurnError::new("当前模型不可用,请稍后重试。"))?;
let chat_history = build_chat_history(messages);
let dynamic_state =
resolve_dynamic_state(llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history)
.await;
let dynamic_state = resolve_dynamic_state(
llm_client,
current_turn,
progress_percent,
quick_fill_requested,
current_anchor_content,
&chat_history,
)
.await;
let prompt = build_eight_anchor_single_turn_prompt(
current_turn,
progress_percent,
@@ -806,27 +820,21 @@ where
)
.await;
let response = response.map_err(|_| {
CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。")
})?;
let response =
response.map_err(|_| CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str()).map_err(|_| {
CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。")
})?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。"))?;
let next_anchor_content = normalize_eight_anchor_content(
parsed
.get("nextAnchorContent")
.unwrap_or(&JsonValue::Null),
);
let next_anchor_content =
normalize_eight_anchor_content(parsed.get("nextAnchorContent").unwrap_or(&JsonValue::Null));
let progress_percent = if quick_fill_requested {
100
} else {
clamp_progress_percent(parsed.get("progressPercent"))
};
let reply_text = to_text(parsed.get("replyText")).ok_or_else(|| {
CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。")
})?;
let reply_text = to_text(parsed.get("replyText"))
.ok_or_else(|| CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。"))?;
if reply_text != latest_reply_text {
on_reply_update(reply_text.as_str());
}
@@ -907,13 +915,19 @@ fn build_prompt_dynamic_state(
let Some(inference) = inference else {
return fallback;
};
let user_input_signal = inference.user_input_signal.unwrap_or(fallback.user_input_signal);
let user_input_signal = inference
.user_input_signal
.unwrap_or(fallback.user_input_signal);
let drift_risk = inference.drift_risk.unwrap_or(fallback.drift_risk);
let conversation_mode = inference.conversation_mode.unwrap_or(fallback.conversation_mode);
let conversation_mode = inference
.conversation_mode
.unwrap_or(fallback.conversation_mode);
let judgement_summary = inference
.judgement_summary
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode));
.unwrap_or_else(|| {
summarize_dynamic_state(user_input_signal, drift_risk, conversation_mode)
});
PromptDynamicState {
current_turn,
@@ -966,7 +980,11 @@ fn build_prompt_dynamic_state_inference_prompt(
chat_history: &[JsonValue],
) -> (String, String) {
(
[STATE_INFERENCE_SYSTEM_PROMPT, STATE_INFERENCE_OUTPUT_CONTRACT].join("\n\n"),
[
STATE_INFERENCE_SYSTEM_PROMPT,
STATE_INFERENCE_OUTPUT_CONTRACT,
]
.join("\n\n"),
[
format!("当前轮次:{current_turn}"),
format!("当前完成度:{progress_percent}"),
@@ -1010,7 +1028,8 @@ fn build_chat_history(messages: &[CustomWorldAgentMessageRecord]) -> Vec<JsonVal
messages
.iter()
.filter(|message| {
(message.role == "user" || message.role == "assistant") && !message.text.trim().is_empty()
(message.role == "user" || message.role == "assistant")
&& !message.text.trim().is_empty()
})
.map(|message| {
json!({
@@ -1059,8 +1078,7 @@ fn build_creator_intent_from_eight_anchor_content(
.iter()
.cloned()
.chain(
(!value.hidden_crisis.trim().is_empty())
.then_some(value.hidden_crisis.clone()),
(!value.hidden_crisis.trim().is_empty()).then_some(value.hidden_crisis.clone()),
)
.collect::<Vec<_>>()
})
@@ -1205,7 +1223,10 @@ fn evaluate_creator_intent_readiness(intent: &CreatorIntentRecord) -> CreatorInt
}
}
fn resolve_creator_intent_stage(has_user_input: bool, readiness: &CreatorIntentReadiness) -> &'static str {
fn resolve_creator_intent_stage(
has_user_input: bool,
readiness: &CreatorIntentReadiness,
) -> &'static str {
if readiness.is_ready {
"foundation_review"
} else if has_user_input {
@@ -1509,11 +1530,16 @@ fn detect_user_input_signal(chat_history: &[JsonValue]) -> PromptUserInputSignal
if latest_user_text.is_empty() {
return PromptUserInputSignal::Sparse;
}
if contains_any(&latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻", "修正"])
{
if contains_any(
&latest_user_text,
&["不是", "改成", "改为", "换成", "重来", "推翻", "修正"],
) {
return PromptUserInputSignal::Correction;
}
if contains_any(&latest_user_text, &["你帮我想", "你来定", "你决定", "你补完"]) {
if contains_any(
&latest_user_text,
&["你帮我想", "你来定", "你决定", "你补完"],
) {
return PromptUserInputSignal::Delegate;
}
let segments = split_sentences(&latest_user_text);
@@ -1535,8 +1561,14 @@ fn detect_drift_risk(
let recent_user_messages = chat_history
.iter()
.filter_map(|entry| {
(entry.get("role").and_then(JsonValue::as_str) == Some("user"))
.then(|| entry.get("content").and_then(JsonValue::as_str).unwrap_or("").trim().to_string())
(entry.get("role").and_then(JsonValue::as_str) == Some("user")).then(|| {
entry
.get("content")
.and_then(JsonValue::as_str)
.unwrap_or("")
.trim()
.to_string()
})
})
.filter(|value| !value.is_empty())
.rev()
@@ -1545,11 +1577,19 @@ fn detect_drift_risk(
let correction_count = recent_user_messages
.iter()
.filter(|entry| contains_any(entry, &["不是", "改成", "改为", "换成", "推翻", "重来", "修正"]))
.filter(|entry| {
contains_any(
entry,
&["不是", "改成", "改为", "换成", "推翻", "重来", "修正"],
)
})
.count();
if correction_count >= 2
|| (progress_percent >= 65
&& contains_any(&latest_user_text, &["不是", "改成", "改为", "换成", "重来", "推翻"]))
&& contains_any(
&latest_user_text,
&["不是", "改成", "改为", "换成", "重来", "推翻"],
))
{
return PromptDriftRisk::High;
}
@@ -1652,7 +1692,8 @@ fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
fn render_current_anchor_context(anchor_content: &EightAnchorContent) -> String {
format!(
"当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{}",
serde_json::to_string_pretty(anchor_content).unwrap_or_else(|_| empty_agent_anchor_content_json())
serde_json::to_string_pretty(anchor_content)
.unwrap_or_else(|_| empty_agent_anchor_content_json())
)
}
@@ -1757,7 +1798,8 @@ fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversati
fn mode_rules(mode: PromptConversationMode) -> &'static str {
match mode {
PromptConversationMode::Bootstrap => r#"当前模式bootstrap
PromptConversationMode::Bootstrap => {
r#"当前模式bootstrap
目标:
1. 先把世界的基本方向抓住
@@ -1777,8 +1819,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户觉得“现在很容易继续往下说”
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
3. replyText 最好短、稳、可接话
4. 如果用户信息很少,也不要显得冷淡或机械"#,
PromptConversationMode::Expand => r#"当前模式expand
4. 如果用户信息很少,也不要显得冷淡或机械"#
}
PromptConversationMode::Expand => {
r#"当前模式expand
目标:
1. 在保持现有方向的前提下,把设定结构逐步补全
@@ -1797,8 +1841,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户感到“我刚说的内容都被接住了”
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
3. 不要无视用户刚提供的高价值细节
4. 不要让用户觉得系统在自顾自重写世界"#,
PromptConversationMode::Compress => r#"当前模式compress
4. 不要让用户觉得系统在自顾自重写世界"#
}
PromptConversationMode::Compress => {
r#"当前模式compress
目标:
1. 开始收束当前设定
@@ -1818,8 +1864,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户感觉世界正在变得更稳,而不是越来越散
2. 让推进感更明确,但不要显得催促
3. 回复语气应更笃定一些,减少反复横跳
4. 不要把用户刚补进来的细节又冲淡掉"#,
PromptConversationMode::RepairDirection => r#"当前模式repair_direction
4. 不要把用户刚补进来的细节又冲淡掉"#
}
PromptConversationMode::RepairDirection => {
r#"当前模式repair_direction
目标:
1. 处理用户对既有设定的修正
@@ -1838,8 +1886,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户感到“我刚刚的纠偏真的生效了”
2. 不要和用户辩论旧方案为什么也行
3. 不要表现出对修正的不情愿
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#,
PromptConversationMode::ForceComplete => r#"当前模式force_complete
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
}
PromptConversationMode::ForceComplete => {
r#"当前模式force_complete
目标:
1. 基于当前方向直接补齐剩余设定
@@ -1860,8 +1910,10 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户感到“系统已经帮我把能补的补好了”
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
3. 回复要有完成感,但不要太官话
4. 清楚告诉用户下一步可以做什么"#,
PromptConversationMode::Closing => r#"当前模式closing
4. 清楚告诉用户下一步可以做什么"#
}
PromptConversationMode::Closing => {
r#"当前模式closing
目标:
1. 尽量形成一版可用的设定底子
@@ -1880,26 +1932,37 @@ fn mode_rules(mode: PromptConversationMode) -> &'static str {
1. 让用户感觉作品已经快成了,而不是还在无穷试探
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
3. 保持留白感,不要把所有东西都一次说死
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#,
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
}
}
}
fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
match signal {
PromptUserInputSignal::Rich => r#"本轮用户输入信息密度高。
PromptUserInputSignal::Rich => {
r#"本轮用户输入信息密度高。
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#,
PromptUserInputSignal::Normal => r#"本轮用户输入为正常补充。
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#,
PromptUserInputSignal::Sparse => r#"本轮用户输入较少或较虚
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
}
PromptUserInputSignal::Normal => {
r#"本轮用户输入为正常补充
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
}
PromptUserInputSignal::Sparse => {
r#"本轮用户输入较少或较虚。
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
replyText 要让用户容易继续往下说。"#,
PromptUserInputSignal::Correction => r#"本轮用户在修正或推翻旧设定。
replyText 要让用户容易继续往下说。"#
}
PromptUserInputSignal::Correction => {
r#"本轮用户在修正或推翻旧设定。
请优先吸收修正,不要机械复读旧版本。
新的完整设定结构必须以修正后的方向为准。"#,
PromptUserInputSignal::Delegate => r#"本轮用户把部分决定权交给你。
新的完整设定结构必须以修正后的方向为准。"#
}
PromptUserInputSignal::Delegate => {
r#"本轮用户把部分决定权交给你。
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#,
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
}
}
}
@@ -1985,7 +2048,11 @@ fn clamp_text(value: &str, max_length: usize) -> String {
if normalized.chars().count() <= max_length {
return normalized;
}
normalized.chars().take(max_length.saturating_sub(1)).collect::<String>() + ""
normalized
.chars()
.take(max_length.saturating_sub(1))
.collect::<String>()
+ ""
}
fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 {
@@ -1996,7 +2063,8 @@ fn clamp_progress_percent(value: Option<&JsonValue>) -> u32 {
}
fn to_text(value: Option<&JsonValue>) -> Option<String> {
value.and_then(JsonValue::as_str)
value
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)

View File

@@ -0,0 +1,669 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldFoundationDraftResult {
pub draft_profile_json: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum DraftFoundationPayloadError {
SerializePayload(String),
InvalidPayloadShape,
InvalidGeneratedDraft(String),
}
pub async fn generate_custom_world_foundation_draft(
llm_client: &LlmClient,
session: &CustomWorldAgentSessionRecord,
) -> Result<CustomWorldFoundationDraftResult, String> {
let system_prompt = build_foundation_draft_system_prompt();
let user_prompt = build_foundation_draft_user_prompt(session);
let response = llm_client
.request_text(LlmTextRequest::new(vec![
LlmMessage::system(system_prompt),
LlmMessage::user(user_prompt),
]))
.await
.map_err(|error| format!("foundation draft LLM 请求失败:{error}"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|error| format!("foundation draft JSON 解析失败:{error}"))?;
let draft_profile = normalize_foundation_draft_profile(parsed, session);
let draft_profile_json = serde_json::to_string(&JsonValue::Object(draft_profile))
.map_err(|error| format!("foundation draft JSON 序列化失败:{error}"))?;
Ok(CustomWorldFoundationDraftResult { draft_profile_json })
}
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
pub fn build_draft_foundation_action_payload_json(
payload: &ExecuteCustomWorldAgentActionRequest,
draft_profile_json: &str,
) -> Result<String, DraftFoundationPayloadError> {
let mut payload_value = serde_json::to_value(payload).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})?;
let payload_object = payload_value
.as_object_mut()
.ok_or(DraftFoundationPayloadError::InvalidPayloadShape)?;
let draft_profile_value =
serde_json::from_str::<JsonValue>(draft_profile_json).map_err(|error| {
DraftFoundationPayloadError::InvalidGeneratedDraft(format!(
"foundation draft JSON 非法:{error}"
))
})?;
if !draft_profile_value.is_object() {
return Err(DraftFoundationPayloadError::InvalidGeneratedDraft(
"foundation draft JSON 必须是 object".to_string(),
));
}
payload_object.insert("draftProfile".to_string(), draft_profile_value);
serde_json::to_string(&payload_value).map_err(|error| {
DraftFoundationPayloadError::SerializePayload(format!(
"action payload JSON 序列化失败:{error}"
))
})
}
fn build_foundation_draft_system_prompt() -> String {
[
"你是 RPG 世界共创后端里的底稿编译器。",
"你的任务是根据当前会话已经确认的世界锚点,生成第一版“世界设定草稿” JSON。",
"必须只输出一个 JSON object不要输出 markdown、解释、前后缀。",
"输出必须使用中文内容。",
"不要返回占位符不要写“待补充”“略”“TBD”“placeholder”。",
"如果某些信息不完整,也要基于已知锚点给出一版合理、可继续精修的首稿。",
"字段必须至少包含name、subtitle、summary、worldHook、playerPremise、coreConflicts、playableNpcs、storyNpcs、landmarks、chapters、sceneChapterBlueprints。",
"sceneChapterBlueprints 至少包含 1 个 chapter且 chapter.acts 至少包含 1 个 act。",
"playableNpcs、storyNpcs、landmarks 可以是小规模首批关键对象,不要求长尾铺满。",
]
.join("\n")
}
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
let anchor_pack = to_pretty_json(&session.anchor_pack);
let current_draft = if is_non_null_json(&session.draft_profile) {
to_pretty_json(&session.draft_profile)
} else {
"{}".to_string()
};
let quality_findings = to_pretty_json(&JsonValue::Array(session.quality_findings.clone()));
[
format!("seedText{}", session.seed_text.trim()),
format!("当前 stage{}", session.stage.trim()),
format!("当前 progressPercent{}", session.progress_percent),
format!(
"当前最后一条 assistant 回复:{}",
session.last_assistant_reply.clone().unwrap_or_default()
),
format!("当前 anchorContent\n{anchor_content}"),
format!("当前 creatorIntent\n{creator_intent}"),
format!("当前 anchorPack\n{anchor_pack}"),
format!("当前已有 draftProfile\n{current_draft}"),
format!("当前 qualityFindings\n{quality_findings}"),
"请直接返回第一版 foundation draft JSON。".to_string(),
"约束:".to_string(),
"1. worldHook 必须是一句可以直接用于发布门禁校验的世界钩子。".to_string(),
"2. playerPremise 必须明确玩家身份与切入前提。".to_string(),
"3. coreConflicts 必须至少 1 条。".to_string(),
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
"6. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
]
.join("\n\n")
}
fn normalize_foundation_draft_profile(
value: JsonValue,
session: &CustomWorldAgentSessionRecord,
) -> JsonMap<String, JsonValue> {
let mut object = value.as_object().cloned().unwrap_or_default();
let fallback_title = derive_world_name(&object, session);
let fallback_world_hook = derive_world_hook(&object, session);
let fallback_player_premise = derive_player_premise(&object, session);
ensure_text_field(&mut object, "name", fallback_title.as_str());
ensure_text_field(&mut object, "subtitle", "世界底稿已生成");
ensure_text_field(
&mut object,
"summary",
"第一版世界底稿已经整理完成,可继续精修关键角色、地点和主线第一幕。",
);
ensure_text_field(&mut object, "worldHook", fallback_world_hook.as_str());
ensure_text_field(
&mut object,
"playerPremise",
fallback_player_premise.as_str(),
);
ensure_text_array_field(
&mut object,
"coreConflicts",
vec!["核心冲突仍需继续深化,但已经具备第一版主线推进方向。"],
);
ensure_object_array_field(&mut object, "playableNpcs");
ensure_object_array_field(&mut object, "storyNpcs");
ensure_object_array_field(&mut object, "landmarks");
ensure_object_array_field(&mut object, "chapters");
ensure_scene_chapter_blueprints(&mut object);
object
}
fn ensure_text_field(object: &mut JsonMap<String, JsonValue>, key: &str, fallback: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
object.insert(
key.to_string(),
JsonValue::String(current.unwrap_or_else(|| fallback.to_string())),
);
}
fn ensure_text_array_field(
object: &mut JsonMap<String, JsonValue>,
key: &str,
fallback_items: Vec<&str>,
) {
let current_items = object
.get(key)
.and_then(JsonValue::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str().map(str::trim))
.filter(|value| !value.is_empty())
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if current_items.is_empty() {
object.insert(
key.to_string(),
JsonValue::Array(
fallback_items
.into_iter()
.map(|value| JsonValue::String(value.to_string()))
.collect(),
),
);
} else {
object.insert(key.to_string(), JsonValue::Array(current_items));
}
}
fn ensure_object_array_field(object: &mut JsonMap<String, JsonValue>, key: &str) {
let current = object
.get(key)
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
object.insert(key.to_string(), JsonValue::Array(current));
}
fn ensure_scene_chapter_blueprints(object: &mut JsonMap<String, JsonValue>) {
let blueprints = object
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if blueprints.is_empty() {
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(vec![build_fallback_scene_chapter_blueprint()]),
);
return;
}
let normalized = blueprints
.into_iter()
.map(|chapter| normalize_scene_chapter_blueprint(chapter))
.collect::<Vec<_>>();
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(normalized),
);
}
fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
let mut object = chapter.as_object().cloned().unwrap_or_default();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("第一幕");
object.insert("title".to_string(), JsonValue::String(title.to_string()));
let acts = object
.get("acts")
.and_then(JsonValue::as_array)
.cloned()
.unwrap_or_default();
if acts.is_empty() {
object.insert(
"acts".to_string(),
JsonValue::Array(vec![build_fallback_scene_act()]),
);
}
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
"title": "第一幕",
"summary": "第一幕用于让玩家进入当前世界的主线矛盾,并看见最初的风险与方向。",
"acts": [build_fallback_scene_act()],
})
}
fn build_fallback_scene_act() -> JsonValue {
json!({
"id": "scene-act-1",
"title": "开场场景幕",
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
})
}
fn derive_world_name(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["name", "title"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "未命名世界草稿".to_string())
}
fn derive_world_hook(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["worldHook"])
.or_else(|| {
session
.anchor_content
.get("worldPromise")
.and_then(JsonValue::as_object)
.and_then(|entry| entry.get("hook"))
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| {
"这个世界正被一条持续扩大的主线危机推向失衡,而玩家会被卷入其中心。".to_string()
})
}
fn derive_player_premise(
object: &JsonMap<String, JsonValue>,
session: &CustomWorldAgentSessionRecord,
) -> String {
read_text_field(object, &["playerPremise"])
.or_else(|| {
session
.anchor_content
.get("playerEntryPoint")
.and_then(JsonValue::as_object)
.map(|entry| {
let identity = entry
.get("openingIdentity")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let problem = entry
.get("openingProblem")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
let motivation = entry
.get("entryMotivation")
.and_then(JsonValue::as_str)
.map(str::trim)
.unwrap_or_default();
[identity, problem, motivation]
.into_iter()
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
})
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"玩家会以一名已经卷入当前局势的人物进入世界,并被迫尽快确认自己的立场与行动方向。"
.to_string()
})
}
fn read_text_field(object: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<String> {
for key in keys {
let mut current = JsonValue::Object(object.clone());
let mut found = true;
for segment in key.split('.') {
if let Some(next) = current.get(segment) {
current = next.clone();
} else {
found = false;
break;
}
}
if found
&& let Some(value) = current
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(value.to_string());
}
}
None
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
#[cfg(test)]
mod tests {
use std::{
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::Duration as StdDuration,
};
use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider};
use super::*;
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
let prompt = build_foundation_draft_user_prompt(&session);
assert!(prompt.contains("seedText海雾会吞掉记错航线的人。"));
assert!(!prompt.contains("seedTextcustom-world-agent-session-1"));
}
#[test]
fn build_draft_foundation_action_payload_json_injects_generated_profile() {
let payload = ExecuteCustomWorldAgentActionRequest {
action: "draft_foundation".to_string(),
profile_id: Some("profile-1".to_string()),
draft_profile: Some(json!({ "name": "旧草稿" })),
legacy_result_profile: None,
setting_text: Some("旧设定".to_string()),
card_id: None,
sections: None,
profile: None,
count: None,
prompt_text: Some("补充提示".to_string()),
anchor_card_ids: Some(vec!["card-1".to_string()]),
role_ids: None,
role_id: None,
portrait_path: None,
generated_visual_asset_id: None,
generated_animation_set_id: None,
animation_map: None,
scene_ids: None,
scene_id: None,
scene_kind: None,
image_src: None,
generated_scene_asset_id: None,
generated_scene_prompt: None,
generated_scene_model: None,
checkpoint_id: None,
};
let payload_json = build_draft_foundation_action_payload_json(
&payload,
r#"{"name":"新草稿","worldHook":"失灯海域会吞掉所有记错航线的人。"}"#,
)
.expect("payload json should build");
let payload_value =
serde_json::from_str::<JsonValue>(&payload_json).expect("payload json should parse");
assert_eq!(
payload_value.get("action"),
Some(&json!("draft_foundation"))
);
assert_eq!(payload_value.get("profileId"), Some(&json!("profile-1")));
assert_eq!(
payload_value
.get("draftProfile")
.and_then(|value| value.get("name")),
Some(&json!("新草稿"))
);
}
#[tokio::test]
async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() {
let request_capture = Arc::new(Mutex::new(String::new()));
let server_url = spawn_mock_server(
request_capture.clone(),
r#"{"id":"resp_01","choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"]}"}}]}"#
.to_string(),
);
let llm_client = build_test_llm_client(server_url);
let session = build_test_session();
let result = generate_custom_world_foundation_draft(&llm_client, &session)
.await
.expect("draft generation should succeed");
let draft_profile = serde_json::from_str::<JsonValue>(&result.draft_profile_json)
.expect("draft profile should parse");
let request_text = request_capture
.lock()
.expect("request capture should lock")
.clone();
assert!(request_text.contains("海雾会吞掉记错航线的人。"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert!(
draft_profile
.get("worldHook")
.and_then(JsonValue::as_str)
.is_some()
);
assert!(
draft_profile
.get("playerPremise")
.and_then(JsonValue::as_str)
.is_some()
);
assert_eq!(
draft_profile
.get("sceneChapterBlueprints")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("acts"))
.and_then(JsonValue::as_array)
.map(|entries| !entries.is_empty()),
Some(true)
);
}
fn build_test_session() -> CustomWorldAgentSessionRecord {
CustomWorldAgentSessionRecord {
session_id: "custom-world-agent-session-1".to_string(),
seed_text: "海雾会吞掉记错航线的人。".to_string(),
current_turn: 2,
anchor_content: json!({
"worldPromise": {
"hook": "在失真的海图上追查一场被篡改的沉船事故。"
},
"playerEntryPoint": {
"openingIdentity": "被停职返乡的守灯人",
"openingProblem": "灯塔记录被人改写",
"entryMotivation": "查清父亲沉船真相"
}
}),
progress_percent: 100,
last_assistant_reply: Some("世界锚点已经基本齐全,可以整理第一版底稿。".to_string()),
stage: "foundation_review".to_string(),
focus_card_id: None,
creator_intent: json!({
"theme": "悬疑航海",
"playerPremise": "玩家是返乡调查旧案的守灯人。"
}),
creator_intent_readiness: json!({
"isReady": true
}),
anchor_pack: json!({
"coreConflict": "群岛议会正在掩盖沉船真相。"
}),
lock_state: json!({}),
draft_profile: JsonValue::Null,
messages: Vec::new(),
draft_cards: Vec::new(),
pending_clarifications: Vec::new(),
suggested_actions: Vec::new(),
recommended_replies: Vec::new(),
quality_findings: Vec::new(),
asset_coverage: json!({}),
checkpoints: Vec::new(),
supported_actions: Vec::new(),
publish_gate: None,
result_preview: None,
updated_at: "2026-04-23T00:00:00Z".to_string(),
}
}
fn build_test_llm_client(base_url: String) -> LlmClient {
let config = LlmConfig::new(
LlmProvider::Ark,
base_url,
"test-key".to_string(),
"test-model".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
0,
1,
)
.expect("llm config should build");
LlmClient::new(config).expect("llm client should build")
}
fn spawn_mock_server(request_capture: Arc<Mutex<String>>, response_body: String) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener
.local_addr()
.expect("listener should expose address");
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
*request_capture.lock().expect("request capture should lock") = request_text;
write_response(&mut stream, response_body);
});
format!("http://{address}")
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be configured");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8(buffer).expect("request should be utf-8")
}
fn write_response(stream: &mut std::net::TcpStream, body: String) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -1,9 +1,12 @@
mod admin;
mod ai_tasks;
mod api_response;
mod app;
mod assets;
mod auth;
mod auth_me;
mod auth_payload;
mod auth_public_user;
mod auth_session;
mod auth_sessions;
mod big_fish;
@@ -13,6 +16,7 @@ mod config;
mod custom_world;
mod custom_world_agent_turn;
mod custom_world_ai;
mod custom_world_foundation_draft;
mod error_middleware;
mod health;
mod http_error;
@@ -24,6 +28,7 @@ mod logout_all;
mod password_entry;
mod phone_auth;
mod puzzle;
mod puzzle_agent_turn;
mod refresh_session;
mod request_context;
mod response_headers;

View File

@@ -6,10 +6,11 @@ use axum::{
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use serde_json::json;
use shared_contracts::auth::{AuthUserPayload, PasswordEntryRequest, PasswordEntryResponse};
use shared_contracts::auth::{PasswordEntryRequest, PasswordEntryResponse};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
},
@@ -48,15 +49,7 @@ pub async fn password_entry(
Some(&request_context),
PasswordEntryResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))
@@ -74,6 +67,11 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
.with_details(json!({
"field": "password",
})),
PasswordEntryError::InvalidPublicUserCode => AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("叙世号格式不正确")
.with_details(json!({
"field": "username",
})),
PasswordEntryError::InvalidCredentials => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
}

View File

@@ -9,14 +9,14 @@ use module_auth::{
};
use serde_json::json;
use shared_contracts::auth::{
AuthUserPayload, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::{
api_response::json_success_body,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
@@ -166,15 +166,7 @@ pub async fn phone_login(
Some(&request_context),
PhoneLoginResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))

View File

@@ -8,7 +8,10 @@ use axum::{
Json,
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
http::{HeaderName, StatusCode, header},
response::{IntoResponse, Response},
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use serde_json::{Value, json};
use shared_contracts::{
@@ -46,10 +49,18 @@ use spacetime_client::{
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
SpacetimeClientError,
};
use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
puzzle_agent_turn::{
PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input,
run_puzzle_agent_turn,
},
request_context::RequestContext,
state::AppState,
};
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
@@ -169,11 +180,12 @@ pub async fn submit_puzzle_agent_message(
));
}
let session = state
let owner_user_id = authenticated.claims().user_id().to_string();
let submitted_session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: client_message_id,
user_message_text: message_text,
submitted_at_micros: current_utc_micros(),
@@ -186,6 +198,41 @@ pub async fn submit_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let turn_result = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
},
|_| {},
)
.await;
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
format!("assistant-{session_id}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
&submitted_session,
error.to_string(),
current_utc_micros(),
),
};
let session = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
@@ -219,11 +266,12 @@ pub async fn stream_puzzle_agent_message(
"sessionId",
)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let session = state
.spacetime_client()
.submit_puzzle_agent_message(PuzzleAgentMessageSubmitRecordInput {
session_id,
owner_user_id: authenticated.claims().user_id().to_string(),
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
user_message_id: payload.client_message_id.trim().to_string(),
user_message_text: payload.text.trim().to_string(),
submitted_at_micros: current_utc_micros(),
@@ -236,32 +284,100 @@ pub async fn stream_puzzle_agent_message(
map_puzzle_client_error(error),
)
})?;
let state = state.clone();
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let stream = async_stream::stream! {
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let turn_result = {
let run_turn = run_puzzle_agent_turn(
PuzzleAgentTurnRequest {
llm_client: state.llm_client(),
session: &session,
},
move |text| {
let _ = reply_tx.send(text.to_string());
},
);
tokio::pin!(run_turn);
let session_response = map_puzzle_agent_session_response(session);
let reply_text = session_response
.last_assistant_reply
.clone()
.unwrap_or_else(|| "拼图锚点已更新。".to_string());
let mut sse_body = String::new();
append_sse_event(
&request_context,
&mut sse_body,
"reply_delta",
&json!({ "text": reply_text }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"session",
&json!({ "session": session_response }),
)?;
append_sse_event(
&request_context,
&mut sse_body,
"done",
&json!({ "ok": true }),
)?;
Ok(build_event_stream_response(sse_body))
loop {
tokio::select! {
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
}
}
}
};
while let Some(text) = reply_rx.recv().await {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
));
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
format!("assistant-{session_id_for_stream}-{}", current_utc_micros()),
turn_result,
current_utc_micros(),
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalize_result = state
.spacetime_client()
.finalize_puzzle_agent_message(finalize_input)
.await;
let _final_session = match finalize_result {
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let final_session = match state
.spacetime_client()
.get_puzzle_agent_session(session_id_for_stream, owner_user_id_for_stream)
.await
{
Ok(session) => session,
Err(error) => {
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"error",
json!({ "message": error.to_string() }),
));
return;
}
};
let session_response = map_puzzle_agent_session_response(final_session);
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"session",
json!({ "session": session_response }),
));
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"done",
json!({ "ok": true }),
));
};
Ok(Sse::new(stream).into_response())
}
pub async fn execute_puzzle_agent_action(
@@ -413,13 +529,15 @@ pub async fn execute_puzzle_agent_action(
)
}
"publish_puzzle_work" => {
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
let profile = state
.spacetime_client()
.publish_puzzle_work(PuzzlePublishRecordInput {
session_id: session_id.clone(),
owner_user_id: owner_user_id.clone(),
work_id: build_prefixed_uuid_id("puzzle-work-"),
profile_id: build_prefixed_uuid_id("puzzle-profile-"),
// 发布沿用 session 派生的稳定作品 ID避免草稿卡与已发布卡重复。
work_id,
profile_id,
author_display_name: resolve_author_display_name(&state, &authenticated),
level_name: payload.level_name.clone(),
summary: payload.summary.clone(),
@@ -1153,6 +1271,16 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String {
"我先接住你的画面灵感,再一起把它收束成正式拼图关卡。".to_string()
}
fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) {
let stable_suffix = session_id
.strip_prefix("puzzle-session-")
.unwrap_or(session_id);
(
format!("puzzle-work-{stable_suffix}"),
format!("puzzle-profile-{stable_suffix}"),
)
}
fn ensure_non_empty(
request_context: &RequestContext,
provider: &str,
@@ -1193,6 +1321,14 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
_ => StatusCode::BAD_GATEWAY,
};
@@ -1216,41 +1352,32 @@ fn puzzle_error_response(
response
}
fn append_sse_event(
request_context: &RequestContext,
body: &mut String,
event_name: &str,
payload: &Value,
) -> Result<(), Response> {
let payload = serde_json::to_string(payload).map_err(|error| {
puzzle_error_response(
request_context,
PUZZLE_AGENT_API_BASE_PROVIDER,
fn puzzle_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
Event::default()
.event(event_name)
.json_data(payload)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
"provider": "sse",
"message": format!("SSE payload 序列化失败:{error}"),
})),
)
})?;
body.push_str("event: ");
body.push_str(event_name);
body.push('\n');
body.push_str("data: ");
body.push_str(&payload);
body.push_str("\n\n");
Ok(())
}))
})
}
fn build_event_stream_response(body: String) -> Response {
(
[
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache, no-transform"),
(header::CONNECTION, "keep-alive"),
],
body,
)
.into_response()
fn puzzle_sse_json_event_or_error(event_name: &str, payload: Value) -> Event {
match puzzle_sse_json_event(event_name, payload) {
Ok(event) => event,
Err(_) => puzzle_sse_error_event_message("SSE payload 序列化失败".to_string()),
}
}
fn puzzle_sse_error_event_message(message: String) -> Event {
let payload = format!(
"{{\"message\":{}}}",
serde_json::to_string(&message)
.unwrap_or_else(|_| "\"SSE 错误事件序列化失败\"".to_string())
);
Event::default().event("error").data(payload)
}
fn build_placeholder_puzzle_candidates(

View File

@@ -0,0 +1,378 @@
use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack};
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use spacetime_client::{
PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord,
};
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnRequest<'a> {
pub llm_client: Option<&'a LlmClient>,
pub session: &'a PuzzleAgentSessionRecord,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnResult {
pub assistant_reply_text: String,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct PuzzleAgentTurnError {
message: String,
}
impl PuzzleAgentTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for PuzzleAgentTurnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for PuzzleAgentTurnError {}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PuzzleAgentModelOutput {
reply_text: String,
progress_percent: u32,
next_anchor_pack: PuzzleAnchorPack,
}
const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和创作者共创拼图画面的中文创意策划。
你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。
你必须同时输出:
1. 一段直接发给用户的中文回复 replyText
2. 当前进度 progressPercent
3. 下一轮完整可用的 nextAnchorPack
拼图创作固定围绕 5 个视觉锚点:
1. themePromise题材承诺
2. visualSubject画面主体
3. visualMood视觉气质
4. compositionHooks拼图记忆点
5. tagsAndForbidden标签与禁忌
硬约束:
1. 只能输出 JSON不能输出代码块或解释
2. nextAnchorPack 必须是完整对象,不能只输出 patch
3. replyText 必须是自然中文不能提“字段”“锚点”“结构”“JSON”等内部词
4. replyText 一次最多推进一个最关键问题
5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问
6. progressPercent 范围只能是 0 到 100
7. status 只能使用 missing / inferred / confirmed / locked
"#;
const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字:
{
"replyText": "",
"progressPercent": 0,
"nextAnchorPack": {
"themePromise": {
"key": "themePromise",
"label": "题材承诺",
"value": "",
"status": "missing"
},
"visualSubject": {
"key": "visualSubject",
"label": "画面主体",
"value": "",
"status": "missing"
},
"visualMood": {
"key": "visualMood",
"label": "视觉气质",
"value": "",
"status": "missing"
},
"compositionHooks": {
"key": "compositionHooks",
"label": "拼图记忆点",
"value": "",
"status": "missing"
},
"tagsAndForbidden": {
"key": "tagsAndForbidden",
"label": "标签与禁忌",
"value": "",
"status": "missing"
}
}
}"#;
pub(crate) async fn run_puzzle_agent_turn<F>(
request: PuzzleAgentTurnRequest<'_>,
mut on_reply_update: F,
) -> Result<PuzzleAgentTurnResult, PuzzleAgentTurnError>
where
F: FnMut(&str),
{
let llm_client = request
.llm_client
.ok_or_else(|| PuzzleAgentTurnError::new("当前模型不可用,请稍后重试。"))?;
let prompt = build_puzzle_agent_prompt(request.session);
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
LlmTextRequest::new(vec![
LlmMessage::system(format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}")),
LlmMessage::user("请按约定输出这一轮的 JSON。"),
]),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
&& reply_progress != latest_reply_text
{
latest_reply_text = reply_progress.clone();
on_reply_update(reply_progress.as_str());
}
},
)
.await
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天生成失败,请稍后重试。"))?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| PuzzleAgentTurnError::new("拼图聊天结果解析失败,请稍后重试。"))?;
let output = parse_model_output(&parsed)?;
if output.reply_text != latest_reply_text {
on_reply_update(output.reply_text.as_str());
}
Ok(PuzzleAgentTurnResult {
assistant_reply_text: output.reply_text,
stage: resolve_puzzle_agent_stage(output.progress_percent)
.as_str()
.to_string(),
progress_percent: output.progress_percent,
anchor_pack_json: serde_json::to_string(&output.next_anchor_pack).unwrap_or_else(|_| {
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
}),
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
session_id: String,
owner_user_id: String,
assistant_message_id: String,
result: PuzzleAgentTurnResult,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
stage: result.stage,
progress_percent: result.progress_percent,
anchor_pack_json: result.anchor_pack_json,
error_message: result.error_message,
updated_at_micros,
}
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
session: &PuzzleAgentSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> PuzzleAgentMessageFinalizeRecordInput {
let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| {
serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string())
});
PuzzleAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
assistant_message_id: None,
assistant_reply_text: None,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
anchor_pack_json,
error_message: Some(error_message),
updated_at_micros,
}
}
fn build_puzzle_agent_prompt(session: &PuzzleAgentSessionRecord) -> String {
format!(
"当前是第 {turn} 轮,当前进度 {progress}% 。\n\n当前 anchor pack\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}",
turn = session.current_turn.saturating_add(1),
progress = session.progress_percent,
anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack))
.unwrap_or_else(|_| "{}".to_string()),
chat_history =
serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice()))
.unwrap_or_else(|_| "[]".to_string()),
contract = PUZZLE_AGENT_OUTPUT_CONTRACT,
)
}
fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec<JsonValue> {
messages
.iter()
.map(|message| {
json!({
"role": message.role,
"kind": message.kind,
"content": message.text,
})
})
.collect()
}
fn parse_model_output(parsed: &JsonValue) -> Result<PuzzleAgentModelOutput, PuzzleAgentTurnError> {
let reply_text = parsed
.get("replyText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少有效回复,请稍后重试。"))?
.to_string();
let progress_percent = parsed
.get("progressPercent")
.and_then(JsonValue::as_u64)
.map(|value| value.min(100) as u32)
.unwrap_or(0);
let next_anchor_pack = parsed
.get("nextAnchorPack")
.cloned()
.ok_or_else(|| PuzzleAgentTurnError::new("拼图聊天结果缺少 nextAnchorPack。"))
.and_then(|value| {
serde_json::from_value::<PuzzleAnchorPack>(value)
.map_err(|_| PuzzleAgentTurnError::new("拼图 anchor pack 解析失败,请稍后重试。"))
})?;
Ok(PuzzleAgentModelOutput {
reply_text,
progress_percent,
next_anchor_pack,
})
}
fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage {
if progress_percent >= 85 {
PuzzleAgentStage::DraftReady
} else {
PuzzleAgentStage::CollectingAnchors
}
}
fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack {
PuzzleAnchorPack {
theme_promise: map_record_anchor_item(&record.theme_promise),
visual_subject: map_record_anchor_item(&record.visual_subject),
visual_mood: map_record_anchor_item(&record.visual_mood),
composition_hooks: map_record_anchor_item(&record.composition_hooks),
tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden),
}
}
fn map_record_anchor_item(
record: &spacetime_client::PuzzleAnchorItemRecord,
) -> module_puzzle::PuzzleAnchorItem {
module_puzzle::PuzzleAnchorItem {
key: record.key.clone(),
label: record.label.clone(),
value: record.value.clone(),
status: parse_anchor_status(record.status.as_str()),
}
}
fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus {
match value {
"confirmed" => PuzzleAnchorStatus::Confirmed,
"locked" => PuzzleAnchorStatus::Locked,
"inferred" => PuzzleAnchorStatus::Inferred,
_ => PuzzleAnchorStatus::Missing,
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
&& let Some(end) = trimmed.rfind('}')
&& end > start
{
return serde_json::from_str::<JsonValue>(&trimmed[start..=end]);
}
serde_json::from_str::<JsonValue>(trimmed)
}
fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
}
}
other => decoded.push(other),
}
continue;
}
decoded.push(current);
}
Some(decoded)
}
#[cfg(test)]
mod tests {
use super::extract_reply_text_from_partial_json;
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("夜雨猫咪遗迹"));
}
}

View File

@@ -1,10 +1,7 @@
use std::{error::Error, fmt};
use std::{error::Error, fmt, sync::Arc};
#[cfg(test)]
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use std::{collections::HashMap, sync::Mutex};
use module_ai::{AiTaskService, InMemoryAiTaskStore};
use module_auth::{
@@ -15,17 +12,21 @@ use module_runtime::RuntimeSnapshotRecord;
#[cfg(test)]
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
SmsAuthConfig, SmsAuthProvider, SmsAuthProviderKind, SmsProviderError,
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, JwtError,
RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite, SmsAuthConfig, SmsAuthProvider,
SmsAuthProviderKind, SmsProviderError, sign_access_token, verify_access_token,
};
use platform_llm::{LlmClient, LlmConfig, LlmError};
use platform_oss::{OssClient, OssConfig, OssError};
use serde_json::Value;
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
use time::OffsetDateTime;
use crate::config::AppConfig;
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
const ADMIN_ROLE: &str = "admin";
// 当前阶段先保留最小共享状态壳,后续逐步接入配置、客户端与平台适配。
#[derive(Clone, Debug)]
pub struct AppState {
@@ -33,6 +34,7 @@ pub struct AppState {
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
admin_runtime: Option<AdminRuntime>,
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
password_entry_service: PasswordEntryService,
@@ -51,6 +53,35 @@ pub struct AppState {
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
}
// 后台管理员运行态独立于普通玩家登录体系,只从环境变量构造。
#[derive(Clone, Debug)]
pub struct AdminRuntime {
username: Arc<str>,
password: Arc<str>,
subject: Arc<str>,
display_name: Arc<str>,
token_ttl_seconds: u64,
jwt_config: JwtConfig,
}
#[derive(Clone, Debug)]
pub struct AdminClaims {
pub subject: String,
pub username: String,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Clone, Debug)]
pub struct AdminSession {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
@@ -67,6 +98,7 @@ impl AppState {
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
let admin_runtime = build_admin_runtime(&config, &auth_jwt_config)?;
let refresh_cookie_same_site =
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
@@ -123,6 +155,7 @@ impl AppState {
Ok(Self {
config,
auth_jwt_config,
admin_runtime,
refresh_cookie_config,
oss_client,
password_entry_service,
@@ -144,6 +177,10 @@ impl AppState {
&self.auth_jwt_config
}
pub fn admin_runtime(&self) -> Option<&AdminRuntime> {
self.admin_runtime.as_ref()
}
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
@@ -394,6 +431,90 @@ impl From<LlmError> for AppStateInitError {
}
}
impl AdminRuntime {
pub fn is_enabled(&self) -> bool {
!self.username.trim().is_empty() && !self.password.trim().is_empty()
}
pub fn username(&self) -> &str {
&self.username
}
pub fn password(&self) -> &str {
&self.password
}
pub fn build_claims(&self, now: OffsetDateTime) -> Result<AdminClaims, String> {
let expires_at = now
.checked_add(time::Duration::seconds(
i64::try_from(self.token_ttl_seconds)
.map_err(|_| "后台 token TTL 超出 i64 上限".to_string())?,
))
.ok_or_else(|| "后台 token 过期时间计算溢出".to_string())?;
Ok(AdminClaims {
subject: self.subject.to_string(),
username: self.username.to_string(),
issued_at: now,
expires_at,
})
}
pub fn sign_token(&self, claims: &AdminClaims) -> Result<String, String> {
let jwt_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: claims.subject.clone(),
session_id: format!("admin-session-{}", claims.username),
provider: AuthProvider::Password,
roles: vec![ADMIN_ROLE.to_string()],
token_version: 1,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(self.display_name.to_string()),
},
&self.jwt_config,
claims.issued_at,
)
.map_err(|error| error.to_string())?;
sign_access_token(&jwt_claims, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn verify_token(&self, token: &str) -> Result<AccessTokenClaims, String> {
verify_access_token(token, &self.jwt_config).map_err(|error| error.to_string())
}
pub fn validate_claims(&self, claims: &AccessTokenClaims) -> Result<AdminSession, String> {
if claims.user_id() != self.subject.as_ref() {
return Err("后台管理员主体不匹配".to_string());
}
if !claims.roles.iter().any(|role| role == ADMIN_ROLE) {
return Err("当前令牌不是管理员令牌".to_string());
}
let issued_at = OffsetDateTime::from_unix_timestamp(claims.iat as i64)
.map_err(|_| "后台令牌签发时间无效".to_string())?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp as i64)
.map_err(|_| "后台令牌过期时间无效".to_string())?;
Ok(AdminSession {
subject: claims.user_id().to_string(),
username: self.username.to_string(),
display_name: self.display_name.to_string(),
roles: claims.roles.clone(),
issued_at,
expires_at,
})
}
pub fn build_session(&self, claims: &AdminClaims) -> AdminSession {
AdminSession {
subject: claims.subject.clone(),
username: claims.username.clone(),
display_name: self.display_name.to_string(),
roles: vec![ADMIN_ROLE.to_string()],
issued_at: claims.issued_at,
expires_at: claims.expires_at,
}
}
}
fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateInitError> {
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
@@ -441,6 +562,43 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
Ok(Some(LlmClient::new(llm_config)?))
}
// 只有在用户名和密码都已配置时才启用后台,避免半配置状态暴露伪入口。
fn build_admin_runtime(
config: &AppConfig,
base_jwt_config: &JwtConfig,
) -> Result<Option<AdminRuntime>, AppStateInitError> {
let Some(username) = config
.admin_username
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let Some(password) = config
.admin_password
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let jwt_config = JwtConfig::new(
base_jwt_config.issuer().to_string(),
config.jwt_secret.clone(),
config.admin_token_ttl_seconds,
)?;
Ok(Some(AdminRuntime {
username: Arc::<str>::from(username),
password: Arc::<str>::from(password),
subject: Arc::<str>::from(format!("admin:{username}")),
display_name: Arc::<str>::from(format!("管理员 {username}")),
token_ttl_seconds: config.admin_token_ttl_seconds,
jwt_config,
}))
}
#[cfg(test)]
mod tests {
use module_ai::{AiTaskKind, generate_ai_task_id};

View File

@@ -9,8 +9,8 @@ use module_auth::{
WechatAuthScene,
};
use shared_contracts::auth::{
AuthUserPayload, WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery,
WechatStartQuery, WechatStartResponse,
WechatBindPhoneRequest, WechatBindPhoneResponse, WechatCallbackQuery, WechatStartQuery,
WechatStartResponse,
};
use time::OffsetDateTime;
use url::Url;
@@ -18,6 +18,7 @@ use url::Url;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth_payload::map_auth_user_payload,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_auth_session,
},
@@ -199,15 +200,7 @@ pub async fn bind_wechat_phone(
Some(&request_context),
WechatBindPhoneResponse {
token: signed_session.access_token,
user: AuthUserPayload {
id: result.user.id,
username: result.user.username,
display_name: result.user.display_name,
phone_number_masked: result.user.phone_number_masked,
login_method: result.user.login_method.as_str().to_string(),
binding_status: result.user.binding_status.as_str().to_string(),
wechat_bound: result.user.wechat_bound,
},
user: map_auth_user_payload(result.user),
},
),
))

View File

@@ -41,6 +41,7 @@ pub enum AuthBindingStatus {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthUser {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
@@ -55,6 +56,11 @@ pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicUserSearchResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
@@ -262,6 +268,7 @@ pub struct LogoutAllSessionsResult {
pub enum PasswordEntryError {
InvalidUsername,
InvalidPasswordLength,
InvalidPublicUserCode,
InvalidCredentials,
Store(String),
PasswordHash(String),
@@ -457,6 +464,16 @@ impl PasswordEntryService {
.find_by_user_id(user_id)
.map(|maybe_user| maybe_user.map(|stored| AuthMeResult { user: stored.user }))
}
pub fn get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<PublicUserSearchResult>, PasswordEntryError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.map(|maybe_user| maybe_user.map(|stored| PublicUserSearchResult { user: stored.user }))
}
}
impl RefreshSessionService {
@@ -619,8 +636,14 @@ impl PhoneAuthService {
phone_national_masked = normalized_phone.masked_national_number.as_str(),
cooldown_seconds = provider_result.cooldown_seconds,
expires_in_seconds = provider_result.expires_in_seconds,
provider_request_id = provider_result.provider_request_id.as_deref().unwrap_or("unknown"),
provider_out_id = provider_result.provider_out_id.as_deref().unwrap_or("unknown"),
provider_request_id = provider_result
.provider_request_id
.as_deref()
.unwrap_or("unknown"),
provider_out_id = provider_result
.provider_out_id
.as_deref()
.unwrap_or("unknown"),
"手机号验证码 provider 调用成功,准备写入本地快照"
);
@@ -870,6 +893,18 @@ impl AuthUserService {
.map_err(map_password_error_to_logout_error)
}
pub fn get_user_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<AuthUser>, LogoutError> {
let normalized_public_user_code = normalize_public_user_code(public_user_code)
.map_err(map_password_error_to_logout_error)?;
self.store
.find_by_public_user_code(&normalized_public_user_code)
.map(|maybe_user| maybe_user.map(|stored| stored.user))
.map_err(map_password_error_to_logout_error)
}
pub fn logout_current_session(
&self,
input: LogoutCurrentSessionInput,
@@ -962,6 +997,22 @@ impl InMemoryAuthStore {
.cloned())
}
fn find_by_public_user_code(
&self,
public_user_code: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
.cloned())
}
fn find_by_phone_number(
&self,
phone_number: &str,
@@ -994,11 +1045,14 @@ impl InMemoryAuthStore {
return Err(CreateUserError::AlreadyExists);
}
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let user = AuthUser {
id: user_id,
public_user_code,
username: username.clone(),
display_name: username.clone(),
phone_number_masked: None,
@@ -1035,11 +1089,14 @@ impl InMemoryAuthStore {
));
}
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("phone", state.next_user_id);
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
phone_number_masked: Some(phone_number.masked_national_number.clone()),
@@ -1073,7 +1130,9 @@ impl InMemoryAuthStore {
.lock()
.map_err(|_| WechatAuthError::Store("用户仓储锁已中毒".to_string()))?;
let user_id = format!("user_{:08}", state.next_user_id);
let sequence = state.next_user_id;
let user_id = format!("user_{sequence:08}");
let public_user_code = build_public_user_code(sequence);
state.next_user_id += 1;
let username = build_system_username("wechat", state.next_user_id);
let display_name = profile
@@ -1085,6 +1144,7 @@ impl InMemoryAuthStore {
.to_string();
let user = AuthUser {
id: user_id.clone(),
public_user_code,
username: username.clone(),
display_name,
phone_number_masked: None,
@@ -1722,6 +1782,7 @@ impl fmt::Display for PasswordEntryError {
match self {
Self::InvalidUsername => f.write_str("用户名只允许 3 到 24 位字母、数字、下划线"),
Self::InvalidPasswordLength => f.write_str("密码长度需要在 6 到 128 位之间"),
Self::InvalidPublicUserCode => f.write_str("叙世号格式不正确"),
Self::InvalidCredentials => f.write_str("用户名或密码错误"),
Self::Store(message) | Self::PasswordHash(message) => f.write_str(message),
}
@@ -1794,6 +1855,7 @@ fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
@@ -1807,6 +1869,7 @@ fn map_password_error_to_phone_error(error: PasswordEntryError) -> PhoneAuthErro
PasswordEntryError::PasswordHash(message) => PhoneAuthError::PasswordHash(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials => {
PhoneAuthError::Store("用户仓储读取失败".to_string())
}
@@ -1818,6 +1881,7 @@ fn map_password_error_to_logout_error(error: PasswordEntryError) -> LogoutError
PasswordEntryError::Store(message) => LogoutError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidPublicUserCode
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => LogoutError::Store("用户仓储读取失败".to_string()),
}
@@ -1923,6 +1987,30 @@ fn build_system_username(prefix: &str, sequence: u64) -> String {
format!("{prefix}_{sequence:08}")
}
// 公开叙世号是稳定的公开检索键,不替代内部 user_id仅用于展示、分享与搜索。
fn build_public_user_code(sequence: u64) -> String {
format!("SY-{sequence:08}")
}
pub fn normalize_public_user_code(input: &str) -> Result<String, PasswordEntryError> {
let normalized = input
.trim()
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_uppercase();
let digits = normalized.strip_prefix("SY").unwrap_or(&normalized);
if digits.is_empty()
|| digits.len() > 8
|| !digits.chars().all(|character| character.is_ascii_digit())
{
return Err(PasswordEntryError::InvalidPublicUserCode);
}
Ok(format!("SY-{digits:0>8}"))
}
fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
format_shared_rfc3339(value)
}

View File

@@ -252,6 +252,38 @@ pub struct BigFishSessionProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {
pub ok: bool,
pub items_json: Option<String>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct BigFishRunProcedureResult {
@@ -693,6 +725,13 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(),
validate_session_owner(&input.session_id, &input.owner_user_id)
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}
Ok(())
}
pub fn validate_session_create_input(
input: &BigFishSessionCreateInput,
) -> Result<(), BigFishFieldError> {
@@ -835,7 +874,10 @@ fn build_level_blueprint(level: u32, level_count: u32, theme: &str) -> BigFishLe
} else {
format!("{level} 阶实体,继续吞噬同级和低级个体成长")
},
silhouette_direction: format!("体型约为初始的 {:.1} 倍,轮廓更清晰", 1.0 + level as f32 * 0.22),
silhouette_direction: format!(
"体型约为初始的 {:.1} 倍,轮廓更清晰",
1.0 + level as f32 * 0.22
),
size_ratio: 1.0 + (level.saturating_sub(1) as f32 * 0.22),
visual_prompt_seed: format!("{theme} 第 {level} 级实体主图,透明背景,清晰轮廓"),
motion_prompt_seed: format!("{theme} 第 {level} 级实体 idle_float 与 move_swim 动作"),
@@ -870,7 +912,10 @@ fn build_asset_prompt_snapshot(
.find(|item| item.level == level)
.ok_or(BigFishFieldError::InvalidLevel)?;
let motion_key = motion_key.ok_or(BigFishFieldError::InvalidAssetKind)?;
Ok(format!("{},动作位:{}", blueprint.motion_prompt_seed, motion_key))
Ok(format!(
"{},动作位:{}",
blueprint.motion_prompt_seed, motion_key
))
}
BigFishAssetKind::StageBackground => Ok(draft.background.background_prompt_seed.clone()),
}
@@ -951,8 +996,14 @@ fn move_owned_entities(
) {
let input = snapshot.last_input.clone();
if let Some(leader) = snapshot.owned_entities.first_mut() {
leader.position.x = clamp_world(leader.position.x + input.x * params.leader_move_speed * step_seconds, true);
leader.position.y = clamp_world(leader.position.y + input.y * params.leader_move_speed * step_seconds, false);
leader.position.x = clamp_world(
leader.position.x + input.x * params.leader_move_speed * step_seconds,
true,
);
leader.position.y = clamp_world(
leader.position.y + input.y * params.leader_move_speed * step_seconds,
false,
);
snapshot.camera_center = leader.position.clone();
}
@@ -999,10 +1050,15 @@ fn resolve_collisions(snapshot: &mut BigFishRuntimeSnapshot, _params: &BigFishRu
radius: entity_radius(wild.level),
offscreen_seconds: 0.0,
});
snapshot.event_log.push(format!("收编 {} 级实体", wild.level));
snapshot
.event_log
.push(format!("收编 {} 级实体", wild.level));
} else {
owned_to_remove.push(owned_index);
snapshot.event_log.push(format!("{} 级己方实体被 {} 级野生实体吃掉", owned.level, wild.level));
snapshot.event_log.push(format!(
"{} 级己方实体被 {} 级野生实体吃掉",
owned.level, wild.level
));
}
}
}
@@ -1036,7 +1092,9 @@ fn apply_chain_merges(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRun
radius: entity_radius(level + 1),
offscreen_seconds: 0.0,
});
snapshot.event_log.push(format!("3 个 {} 级实体合成 {}", level, level + 1));
snapshot
.event_log
.push(format!("3 个 {} 级实体合成 {}", level, level + 1));
merged = true;
break;
}
@@ -1059,7 +1117,10 @@ fn refresh_player_leader(snapshot: &mut BigFishRuntimeSnapshot) {
})
.then_with(|| left.entity_id.cmp(&right.entity_id))
});
snapshot.leader_entity_id = snapshot.owned_entities.first().map(|entity| entity.entity_id.clone());
snapshot.leader_entity_id = snapshot
.owned_entities
.first()
.map(|entity| entity.entity_id.clone());
snapshot.player_level = snapshot
.owned_entities
.iter()
@@ -1074,12 +1135,16 @@ fn refresh_player_leader(snapshot: &mut BigFishRuntimeSnapshot) {
fn apply_win_or_fail(snapshot: &mut BigFishRuntimeSnapshot, params: &BigFishRuntimeParams) {
if snapshot.owned_entities.is_empty() {
snapshot.status = BigFishRunStatus::Failed;
snapshot.event_log.push("己方实体归零,本局失败".to_string());
snapshot
.event_log
.push("己方实体归零,本局失败".to_string());
return;
}
if snapshot.player_level >= params.win_level {
snapshot.status = BigFishRunStatus::Won;
snapshot.event_log.push("获得最高等级实体,通关".to_string());
snapshot
.event_log
.push("获得最高等级实体,通关".to_string());
}
}
@@ -1243,7 +1308,12 @@ mod tests {
assert_eq!(draft.runtime_params.offscreen_cull_seconds, 3.0);
assert_eq!(draft.runtime_params.prey_spawn_delta_levels, vec![1, 2]);
assert_eq!(draft.runtime_params.threat_spawn_delta_levels, vec![1, 2]);
assert!(draft.levels.last().is_some_and(|level| level.is_final_level));
assert!(
draft
.levels
.last()
.is_some_and(|level| level.is_final_level)
);
}
#[test]
@@ -1253,20 +1323,26 @@ mod tests {
assert!(!coverage.publish_ready);
assert_eq!(coverage.required_level_count, 8);
assert!(coverage.blockers.iter().any(|item| item.contains("等级主图")));
assert!(coverage.blockers.iter().any(|item| item.contains("基础动作")));
assert!(
coverage
.blockers
.iter()
.any(|item| item.contains("等级主图"))
);
assert!(
coverage
.blockers
.iter()
.any(|item| item.contains("基础动作"))
);
assert!(coverage.blockers.iter().any(|item| item.contains("背景图")));
}
#[test]
fn same_level_wild_entity_can_be_collected_at_start() {
let draft = compile_default_draft(&infer_anchor_pack("深海", None));
let mut snapshot = build_initial_runtime_snapshot(
"run-1".to_string(),
"session-1".to_string(),
&draft,
1,
);
let mut snapshot =
build_initial_runtime_snapshot("run-1".to_string(), "session-1".to_string(), &draft, 1);
snapshot.wild_entities[0].position = BigFishVector2 { x: 1.0, y: 0.0 };
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 2);
@@ -1344,15 +1420,14 @@ mod tests {
});
snapshot.updated_at_micros = 1_000_000;
let next = advance_runtime_snapshot(
snapshot,
&draft.runtime_params,
0.0,
0.0,
1_250_000,
);
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_250_000);
assert!(!next.wild_entities.iter().any(|entity| entity.entity_id == "wild-cull"));
assert!(
!next
.wild_entities
.iter()
.any(|entity| entity.entity_id == "wild-cull")
);
}
#[test]
@@ -1374,17 +1449,12 @@ mod tests {
});
snapshot.updated_at_micros = 1_000_000;
let next = advance_runtime_snapshot(
snapshot,
&draft.runtime_params,
0.0,
0.0,
1_200_000,
);
let next = advance_runtime_snapshot(snapshot, &draft.runtime_params, 0.0, 0.0, 1_200_000);
assert!(next
.wild_entities
.iter()
.any(|entity| entity.entity_id == "wild-cull-safe"));
assert!(
next.wild_entities
.iter()
.any(|entity| entity.entity_id == "wild-cull-safe")
);
}
}

View File

@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
MissingProfileId,
MissingSessionId,
MissingOwnerUserId,
MissingPublicWorkCode,
MissingAction,
MissingWorldName,
MissingDraftProfileJson,
@@ -170,6 +171,8 @@ pub enum CustomWorldFieldError {
pub struct CustomWorldProfileSnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub publication_status: CustomWorldPublicationStatus,
pub world_name: String,
@@ -192,6 +195,8 @@ pub struct CustomWorldProfileSnapshot {
pub struct CustomWorldGalleryEntrySnapshot {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub author_display_name: String,
pub world_name: String,
pub subtitle: String,
@@ -408,6 +413,8 @@ pub struct CustomWorldAgentSessionProcedureResult {
pub struct CustomWorldProfileUpsertInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
@@ -426,6 +433,8 @@ pub struct CustomWorldProfileUpsertInput {
pub struct CustomWorldProfilePublishInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub author_display_name: String,
pub published_at_micros: i64,
}
@@ -467,6 +476,12 @@ pub struct CustomWorldGalleryDetailInput {
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldGalleryDetailByCodeInput {
pub public_work_code: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomWorldAgentSessionCreateInput {
@@ -630,6 +645,8 @@ pub struct CustomWorldPublishWorldInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
@@ -862,6 +879,9 @@ pub fn validate_custom_world_published_profile_compile_input(
pub fn validate_custom_world_publish_world_input(
input: &CustomWorldPublishWorldInput,
) -> Result<(), CustomWorldFieldError> {
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_published_profile_compile_input(
&CustomWorldPublishedProfileCompileInput {
session_id: input.session_id.clone(),
@@ -905,6 +925,9 @@ pub fn validate_custom_world_profile_publish_input(
if input.author_display_name.trim().is_empty() {
return Err(CustomWorldFieldError::MissingAuthorDisplayName);
}
if input.author_public_user_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
Ok(())
}
@@ -974,6 +997,16 @@ pub fn validate_custom_world_gallery_detail_input(
Ok(())
}
pub fn validate_custom_world_gallery_detail_by_code_input(
input: &CustomWorldGalleryDetailByCodeInput,
) -> Result<(), CustomWorldFieldError> {
if input.public_work_code.trim().is_empty() {
return Err(CustomWorldFieldError::MissingPublicWorkCode);
}
Ok(())
}
pub fn validate_custom_world_session_fields(
session_id: &str,
owner_user_id: &str,
@@ -1562,6 +1595,9 @@ impl fmt::Display for CustomWorldFieldError {
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
Self::MissingPublicWorkCode => {
f.write_str("custom_world_gallery_detail.public_work_code 不能为空")
}
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
Self::MissingDraftProfileJson => {

View File

@@ -1,4 +1,8 @@
use std::{collections::{BTreeMap, BTreeSet, VecDeque}, error::Error, fmt};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
error::Error,
fmt,
};
use serde::{Deserialize, Serialize};
use shared_kernel::{normalize_required_string, normalize_string_list};
@@ -302,6 +306,20 @@ pub struct PuzzleAgentMessageSubmitInput {
pub submitted_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: PuzzleAgentStage,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleDraftCompileInput {
@@ -592,7 +610,10 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl
pack
}
pub fn build_creator_intent(anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot]) -> PuzzleCreatorIntent {
pub fn build_creator_intent(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
) -> PuzzleCreatorIntent {
PuzzleCreatorIntent {
source_mode: "agent_chat".to_string(),
raw_messages_summary: messages
@@ -610,11 +631,16 @@ pub fn build_creator_intent(anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAg
.into_iter()
.take(PUZZLE_MAX_TAG_COUNT)
.collect(),
forbidden_directives: vec![extract_forbidden_directive(&anchor_pack.tags_and_forbidden.value)],
forbidden_directives: vec![extract_forbidden_directive(
&anchor_pack.tags_and_forbidden.value,
)],
}
}
pub fn compile_result_draft(anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot]) -> PuzzleResultDraft {
pub fn compile_result_draft(
anchor_pack: &PuzzleAnchorPack,
messages: &[PuzzleAgentMessageSnapshot],
) -> PuzzleResultDraft {
let creator_intent = build_creator_intent(anchor_pack, messages);
let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone());
let level_name = build_level_name(anchor_pack, &normalized_tags);
@@ -700,7 +726,10 @@ pub fn apply_selected_candidate(
Ok(draft)
}
pub fn build_result_preview(draft: &PuzzleResultDraft, author_display_name: Option<&str>) -> PuzzleResultPreviewEnvelope {
pub fn build_result_preview(
draft: &PuzzleResultDraft,
author_display_name: Option<&str>,
) -> PuzzleResultPreviewEnvelope {
let blockers = validate_publish_requirements(draft, author_display_name);
PuzzleResultPreviewEnvelope {
draft: draft.clone(),
@@ -722,14 +751,22 @@ pub fn validate_publish_requirements(
message: "关卡名不能为空".to_string(),
});
}
if draft.cover_image_src.as_deref().map(str::trim).unwrap_or("").is_empty() {
if draft
.cover_image_src
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
{
blockers.push(PuzzleResultPreviewBlocker {
id: "missing-cover-image".to_string(),
code: "MISSING_COVER_IMAGE".to_string(),
message: "正式拼图图片尚未确定".to_string(),
});
}
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT {
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
{
blockers.push(PuzzleResultPreviewBlocker {
id: "invalid-tag-count".to_string(),
code: "INVALID_TAG_COUNT".to_string(),
@@ -913,7 +950,10 @@ pub fn swap_pieces(
normalize_required_string(first_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let second_piece_id =
normalize_required_string(second_piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let current_level = run.current_level.clone().ok_or(PuzzleFieldError::InvalidOperation)?;
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
@@ -927,9 +967,14 @@ pub fn swap_pieces(
.position(|piece| piece.piece_id == second_piece_id)
.ok_or(PuzzleFieldError::MissingPieceId)?;
let (first_row, first_col) = (pieces[first_index].current_row, pieces[first_index].current_col);
let (second_row, second_col) =
(pieces[second_index].current_row, pieces[second_index].current_col);
let (first_row, first_col) = (
pieces[first_index].current_row,
pieces[first_index].current_col,
);
let (second_row, second_col) = (
pieces[second_index].current_row,
pieces[second_index].current_col,
);
pieces[first_index].current_row = second_row;
pieces[first_index].current_col = second_col;
pieces[second_index].current_row = first_row;
@@ -946,7 +991,10 @@ pub fn drag_piece_or_group(
target_col: u32,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let piece_id = normalize_required_string(piece_id).ok_or(PuzzleFieldError::MissingPieceId)?;
let current_level = run.current_level.clone().ok_or(PuzzleFieldError::InvalidOperation)?;
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status == PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
@@ -975,7 +1023,10 @@ pub fn advance_next_level(
run: &PuzzleRunSnapshot,
next_profile: &PuzzleWorkProfile,
) -> Result<PuzzleRunSnapshot, PuzzleFieldError> {
let current_level = run.current_level.clone().ok_or(PuzzleFieldError::InvalidOperation)?;
let current_level = run
.current_level
.clone()
.ok_or(PuzzleFieldError::InvalidOperation)?;
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
return Err(PuzzleFieldError::InvalidOperation);
}
@@ -1043,7 +1094,10 @@ pub fn select_next_profile<'a>(
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
tag_similarity_score(&current_profile.theme_tags, &left.theme_tags)
.partial_cmp(&tag_similarity_score(&current_profile.theme_tags, &right.theme_tags))
.partial_cmp(&tag_similarity_score(
&current_profile.theme_tags,
&right.theme_tags,
))
.unwrap_or(std::cmp::Ordering::Equal)
})
.then_with(|| right.play_count.cmp(&left.play_count))
@@ -1051,7 +1105,10 @@ pub fn select_next_profile<'a>(
})
}
pub fn recommendation_score(current_profile: &PuzzleWorkProfile, candidate: &PuzzleWorkProfile) -> f32 {
pub fn recommendation_score(
current_profile: &PuzzleWorkProfile,
candidate: &PuzzleWorkProfile,
) -> f32 {
let tag_similarity = tag_similarity_score(&current_profile.theme_tags, &candidate.theme_tags);
let same_author_score = if current_profile.owner_user_id == candidate.owner_user_id {
1.0
@@ -1062,8 +1119,12 @@ pub fn recommendation_score(current_profile: &PuzzleWorkProfile, candidate: &Puz
}
pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 {
let left_set = normalize_theme_tags(left_tags.to_vec()).into_iter().collect::<BTreeSet<_>>();
let right_set = normalize_theme_tags(right_tags.to_vec()).into_iter().collect::<BTreeSet<_>>();
let left_set = normalize_theme_tags(left_tags.to_vec())
.into_iter()
.collect::<BTreeSet<_>>();
let right_set = normalize_theme_tags(right_tags.to_vec())
.into_iter()
.collect::<BTreeSet<_>>();
if left_set.is_empty() && right_set.is_empty() {
return 0.0;
}
@@ -1197,7 +1258,11 @@ fn rebuild_board_snapshot(
let group_by_piece = merged_groups
.iter()
.flat_map(|group| {
group.piece_ids.iter().cloned().map(|piece_id| (piece_id, group.group_id.clone()))
group
.piece_ids
.iter()
.cloned()
.map(|piece_id| (piece_id, group.group_id.clone()))
})
.collect::<BTreeMap<_, _>>();
@@ -1249,7 +1314,9 @@ fn resolve_merged_groups(pieces: &[PuzzlePieceState]) -> Vec<PuzzleMergedGroupSt
};
collected_ids.push(current_piece_id.clone());
for (neighbor_row, neighbor_col) in neighbor_cells(current_piece.current_row, current_piece.current_col) {
for (neighbor_row, neighbor_col) in
neighbor_cells(current_piece.current_row, current_piece.current_col)
{
if let Some(neighbor_piece) = pieces_by_cell.get(&(neighbor_row, neighbor_col))
&& are_correct_neighbors(current_piece, neighbor_piece)
{
@@ -1316,12 +1383,18 @@ fn drag_single_piece(
.ok_or(PuzzleFieldError::InvalidTargetCell)?;
if let Some(target_group_id) = pieces[target_index].merged_group_id.clone() {
for piece in pieces.iter_mut().filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str())) {
for piece in pieces
.iter_mut()
.filter(|piece| piece.merged_group_id.as_deref() == Some(target_group_id.as_str()))
{
piece.merged_group_id = None;
}
}
let (source_row, source_col) = (pieces[piece_index].current_row, pieces[piece_index].current_col);
let (source_row, source_col) = (
pieces[piece_index].current_row,
pieces[piece_index].current_col,
);
pieces[piece_index].current_row = target_row;
pieces[piece_index].current_col = target_col;
if target_index != piece_index {
@@ -1341,7 +1414,9 @@ fn drag_group(
let group_indices = pieces
.iter()
.enumerate()
.filter_map(|(index, piece)| (piece.merged_group_id.as_deref() == Some(group_id)).then_some(index))
.filter_map(|(index, piece)| {
(piece.merged_group_id.as_deref() == Some(group_id)).then_some(index)
})
.collect::<Vec<_>>();
if group_indices.is_empty() {
return Err(PuzzleFieldError::InvalidOperation);
@@ -1354,7 +1429,11 @@ fn drag_group(
for &index in &group_indices {
let next_row = pieces[index].current_row as i32 + row_offset;
let next_col = pieces[index].current_col as i32 + col_offset;
if next_row < 0 || next_col < 0 || next_row >= grid_size as i32 || next_col >= grid_size as i32 {
if next_row < 0
|| next_col < 0
|| next_row >= grid_size as i32
|| next_col >= grid_size as i32
{
return Err(PuzzleFieldError::InvalidTargetCell);
}
target_positions.push((index, next_row as u32, next_col as u32));
@@ -1419,7 +1498,11 @@ fn with_next_board(run: &PuzzleRunSnapshot, next_board: PuzzleBoardSnapshot) ->
mod tests {
use super::*;
fn build_published_profile(profile_id: &str, owner_user_id: &str, tags: Vec<&str>) -> PuzzleWorkProfile {
fn build_published_profile(
profile_id: &str,
owner_user_id: &str,
tags: Vec<&str>,
) -> PuzzleWorkProfile {
PuzzleWorkProfile {
work_id: format!("work-{profile_id}"),
profile_id: profile_id.to_string(),
@@ -1476,8 +1559,8 @@ mod tests {
build_published_profile("b", "owner-a", vec!["蒸汽城市", "雨夜"]),
build_published_profile("c", "owner-c", vec!["猫咪", "森林"]),
];
let selected = select_next_profile(&current, &["a".to_string()], &candidates)
.expect("should select");
let selected =
select_next_profile(&current, &["a".to_string()], &candidates).expect("should select");
assert_eq!(selected.profile_id, "b");
}
@@ -1488,8 +1571,18 @@ mod tests {
let current_level = run.current_level.clone().expect("level");
let first_piece = current_level.board.pieces[0].clone();
let second_piece = current_level.board.pieces[1].clone();
let swapped = swap_pieces(&run, &first_piece.piece_id, &second_piece.piece_id).expect("swap");
assert_eq!(swapped.current_level.as_ref().expect("level").board.pieces.len(), 9);
let swapped =
swap_pieces(&run, &first_piece.piece_id, &second_piece.piece_id).expect("swap");
assert_eq!(
swapped
.current_level
.as_ref()
.expect("level")
.board
.pieces
.len(),
9
);
}
#[test]
@@ -1525,13 +1618,9 @@ mod tests {
fn apply_publish_overrides_rejects_invalid_tag_count() {
let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市"));
let draft = compile_result_draft(&anchor_pack, &[]);
let error = apply_publish_overrides_to_draft(
&draft,
None,
None,
Some(vec!["蒸汽".to_string()]),
)
.expect_err("invalid tag count should fail");
let error =
apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()]))
.expect_err("invalid tag count should fail");
assert_eq!(error, PuzzleFieldError::InvalidTagCount);
}

View File

@@ -1,8 +1,7 @@
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
RuntimeStoryPatch,
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, RuntimeStoryPatch,
};
use crate::{
@@ -471,7 +470,10 @@ fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemVie
.collect()
}
fn find_player_inventory_item(game_state: &Value, item_id: &str) -> Option<BattleInventoryItemView> {
fn find_player_inventory_item(
game_state: &Value,
item_id: &str,
) -> Option<BattleInventoryItemView> {
read_player_inventory_items(game_state)
.into_iter()
.find(|item| item.id == item_id)
@@ -798,10 +800,7 @@ fn build_inventory_use_battle_action_plan(
})
}
fn battle_action_toast(
function_id: &str,
request: &RuntimeStoryActionRequest,
) -> Option<String> {
fn battle_action_toast(function_id: &str, request: &RuntimeStoryActionRequest) -> Option<String> {
if function_id != "inventory_use" {
return None;
}

View File

@@ -115,7 +115,10 @@ pub fn resolve_forge_dismantle_action(
next_inventory = remove_inventory_item_from_list(next_inventory, item_id.as_str(), 1);
next_inventory = add_inventory_items_to_list(next_inventory, outputs.clone());
write_player_inventory_values(game_state, next_inventory);
let output_names = outputs.iter().map(read_inventory_item_name).collect::<Vec<_>>();
let output_names = outputs
.iter()
.map(read_inventory_item_name)
.collect::<Vec<_>>();
Ok(StoryResolution {
action_text: resolve_action_text(

View File

@@ -13,6 +13,9 @@ pub mod npc_support;
pub mod options;
pub mod view_model;
pub use battle::{
build_battle_runtime_story_options, resolve_battle_action, restore_player_resource,
};
pub use core::{
MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs,
append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required,
@@ -25,27 +28,26 @@ pub use core::{
write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field,
write_u32_field, xp_to_next_level_for,
};
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
pub use forge_actions::{
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
};
pub use game_state::{
add_inventory_items_to_list, apply_equipment_loadout_to_state, battle_mode_text,
build_current_build_toast, clone_inventory_item_with_quantity, current_encounter_id,
current_encounter_name, current_encounter_name_from_battle, ensure_inventory_action_available,
equipment_bonus_fallbacks, equipment_item_bonuses, equipment_slot_label,
find_player_inventory_entry, has_giftable_player_inventory, item_rarity_key,
normalize_equipped_item, normalize_equipment_slot_id, read_equipment_total_bonuses,
normalize_equipment_slot_id, normalize_equipped_item, read_equipment_total_bonuses,
read_inventory_item_name, read_player_equipment_item, read_player_inventory_values,
read_runtime_equipment_bonus_cache, remove_inventory_item_from_list,
resolve_equipment_slot_for_item, write_player_equipment_item, write_player_inventory_values,
write_runtime_equipment_bonus_cache,
};
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
pub use forge_actions::{
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
};
pub use npc_support::{
build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party,
resolve_npc_gift_affinity_gain, trade_quantity_suffix,
};
pub use battle::{build_battle_runtime_story_options, resolve_battle_action, restore_player_resource};
pub use options::{
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
build_runtime_story_option_interaction, build_runtime_story_option_with_payload,

View File

@@ -1,12 +1,8 @@
use serde_json::Value;
use shared_contracts::runtime_story::{
RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
};
use shared_contracts::runtime_story::{RuntimeStoryOptionInteraction, RuntimeStoryOptionView};
use crate::{
read_bool_field, read_field, read_optional_string_field, read_required_string_field,
};
use crate::{read_bool_field, read_field, read_optional_string_field, read_required_string_field};
/// 这批 helper 只负责 runtime story option 的纯 DTO 编译,不触碰 HTTP / AppState。
pub fn infer_option_scope(function_id: &str) -> &'static str {

View File

@@ -38,9 +38,7 @@ pub fn build_runtime_story_view_model(
}
}
pub fn build_runtime_story_companions(
game_state: &Value,
) -> Vec<RuntimeStoryCompanionViewModel> {
pub fn build_runtime_story_companions(game_state: &Value) -> Vec<RuntimeStoryCompanionViewModel> {
read_array_field(game_state, "companions")
.into_iter()
.filter_map(|entry| {
@@ -54,9 +52,7 @@ pub fn build_runtime_story_companions(
.collect()
}
pub fn build_runtime_story_encounter(
game_state: &Value,
) -> Option<RuntimeStoryEncounterViewModel> {
pub fn build_runtime_story_encounter(game_state: &Value) -> Option<RuntimeStoryEncounterViewModel> {
let encounter = read_object_field(game_state, "currentEncounter")?;
let npc_name = read_required_string_field(encounter, "npcName")
.or_else(|| read_required_string_field(encounter, "name"))

View File

@@ -652,10 +652,10 @@ pub fn build_runtime_snapshot_upsert_input(
updated_at_micros: i64,
) -> Result<RuntimeSnapshotUpsertInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let bottom_tab = normalize_bottom_tab(bottom_tab)
.ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
let game_state_json =
serde_json::to_string(&game_state).map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let bottom_tab =
normalize_bottom_tab(bottom_tab).ok_or(RuntimeProfileFieldError::MissingBottomTab)?;
let game_state_json = serde_json::to_string(&game_state)
.map_err(|_| RuntimeProfileFieldError::InvalidGameStateJson)?;
let current_story_json = normalize_current_story_json(current_story)?;
Ok(RuntimeSnapshotUpsertInput {
@@ -1012,7 +1012,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingUserId => f.write_str("profile.user_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),
Self::InvalidGameStateJson => f.write_str("runtime_snapshot.game_state 必须是合法 JSON"),
Self::InvalidGameStateJson => {
f.write_str("runtime_snapshot.game_state 必须是合法 JSON")
}
Self::InvalidCurrentStoryJson => {
f.write_str("runtime_snapshot.current_story 必须是合法 JSON object 或 null")
}

View File

@@ -504,10 +504,7 @@ impl SmsAuthProvider {
}
}
pub async fn verify_code(
&self,
request: SmsVerifyCodeRequest,
) -> Result<(), SmsProviderError> {
pub async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> {
match self {
Self::Mock(provider) => provider.verify_code(request).await,
Self::Aliyun(provider) => provider.verify_code(request).await,
@@ -520,7 +517,8 @@ impl MockSmsAuthProvider {
&self,
request: SmsSendCodeRequest,
) -> Result<SmsSendCodeResult, SmsProviderError> {
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let provider_out_id =
build_sms_provider_out_id(&request.scene, &request.national_phone_number);
Ok(SmsSendCodeResult {
cooldown_seconds: self.config.interval_seconds,
@@ -530,10 +528,7 @@ impl MockSmsAuthProvider {
})
}
async fn verify_code(
&self,
request: SmsVerifyCodeRequest,
) -> Result<(), SmsProviderError> {
async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> {
if request.verify_code.trim() != self.config.mock_verify_code {
return Err(SmsProviderError::InvalidVerifyCode);
}
@@ -546,7 +541,8 @@ impl AliyunSmsAuthProvider {
&self,
request: SmsSendCodeRequest,
) -> Result<SmsSendCodeResult, SmsProviderError> {
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let provider_out_id =
build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let phone_masked = mask_phone_number(&request.national_phone_number);
let template_param = serde_json::json!({
self.config.template_param_key.clone(): "##code##",
@@ -577,26 +573,23 @@ impl AliyunSmsAuthProvider {
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config
.access_key_id
.clone()
.unwrap_or_default(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
);
query.insert(
"CountryCode".to_string(),
self.config.country_code.clone(),
);
query.insert("CountryCode".to_string(), self.config.country_code.clone());
query.insert("SignName".to_string(), self.config.sign_name.clone());
query.insert(
"TemplateCode".to_string(),
self.config.template_code.clone(),
);
query.insert("TemplateParam".to_string(), template_param);
query.insert("CodeLength".to_string(), self.config.code_length.to_string());
query.insert(
"CodeLength".to_string(),
self.config.code_length.to_string(),
);
query.insert("CodeType".to_string(), self.config.code_type.to_string());
query.insert(
"ValidTime".to_string(),
@@ -640,7 +633,10 @@ impl AliyunSmsAuthProvider {
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.or_else(|| body
.model
.as_ref()
.and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
@@ -661,7 +657,10 @@ impl AliyunSmsAuthProvider {
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.or_else(|| body
.model
.as_ref()
.and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
@@ -680,17 +679,16 @@ impl AliyunSmsAuthProvider {
Ok(SmsSendCodeResult {
cooldown_seconds: self.config.interval_seconds,
expires_in_seconds: self.config.valid_time_seconds,
provider_request_id: body
.request_id
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.clone())),
provider_request_id: body.request_id.or_else(|| {
body.model
.as_ref()
.and_then(|model| model.request_id.clone())
}),
provider_out_id: body.model.and_then(|model| model.out_id),
})
}
async fn verify_code(
&self,
request: SmsVerifyCodeRequest,
) -> Result<(), SmsProviderError> {
async fn verify_code(&self, request: SmsVerifyCodeRequest) -> Result<(), SmsProviderError> {
let mut query = BTreeMap::new();
query.insert("Action".to_string(), "CheckSmsVerifyCode".to_string());
query.insert("Format".to_string(), "json".to_string());
@@ -701,19 +699,13 @@ impl AliyunSmsAuthProvider {
query.insert("SignatureVersion".to_string(), "1.0".to_string());
query.insert(
"AccessKeyId".to_string(),
self.config
.access_key_id
.clone()
.unwrap_or_default(),
self.config.access_key_id.clone().unwrap_or_default(),
);
query.insert(
"PhoneNumber".to_string(),
request.national_phone_number.trim().to_string(),
);
query.insert(
"CountryCode".to_string(),
self.config.country_code.clone(),
);
query.insert("CountryCode".to_string(), self.config.country_code.clone());
query.insert(
"VerifyCode".to_string(),
request.verify_code.trim().to_string(),
@@ -746,12 +738,7 @@ impl AliyunSmsAuthProvider {
body.code,
));
}
if body
.model
.and_then(|model| model.verify_result)
.as_deref()
!= Some("PASS")
{
if body.model.and_then(|model| model.verify_result).as_deref() != Some("PASS") {
return Err(SmsProviderError::InvalidVerifyCode);
}
@@ -759,11 +746,9 @@ impl AliyunSmsAuthProvider {
}
fn sign_query(&self, query: &mut BTreeMap<String, String>) -> Result<(), SmsProviderError> {
let access_key_secret = self
.config
.access_key_secret
.as_deref()
.ok_or_else(|| SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string()))?;
let access_key_secret = self.config.access_key_secret.as_deref().ok_or_else(|| {
SmsProviderError::InvalidConfig("阿里云短信 AccessKeySecret 未配置".to_string())
})?;
let canonicalized = canonicalize_aliyun_rpc_params(query);
let string_to_sign = format!(
"POST&{}&{}",
@@ -771,7 +756,9 @@ impl AliyunSmsAuthProvider {
aliyun_percent_encode(&canonicalized)
);
let mut signer = HmacSha1::new_from_slice(format!("{access_key_secret}&").as_bytes())
.map_err(|error| SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}")))?;
.map_err(|error| {
SmsProviderError::InvalidConfig(format!("初始化短信签名器失败:{error}"))
})?;
signer.update(string_to_sign.as_bytes());
let signature = BASE64_STANDARD.encode(signer.finalize().into_bytes());
query.insert("Signature".to_string(), signature);
@@ -1138,9 +1125,10 @@ async fn parse_aliyun_json_response(
.text()
.await
.map_err(|error| SmsProviderError::Upstream(format!("{fallback_message}{error}")))?;
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(&body).map_err(|error| {
SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}"))
})?;
let payload =
serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(&body).map_err(|error| {
SmsProviderError::Upstream(format!("{fallback_message}:响应解析失败:{error}"))
})?;
if status.is_client_error() || status.is_server_error() {
return Err(map_http_status_to_sms_provider_error(
fallback_message,
@@ -1160,8 +1148,10 @@ async fn parse_aliyun_json_response_for_verify(
.text()
.await
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:{error}")))?;
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(&body)
.map_err(|error| SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}")))?;
let payload =
serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(&body).map_err(|error| {
SmsProviderError::Upstream(format!("验证码校验失败:响应解析失败:{error}"))
})?;
if status.is_client_error() || status.is_server_error() {
return Err(map_http_status_to_sms_provider_error(
"验证码校验失败",
@@ -1461,7 +1451,10 @@ mod tests {
#[test]
fn sms_auth_provider_kind_parses_supported_values() {
assert_eq!(SmsAuthProviderKind::parse("mock"), Some(SmsAuthProviderKind::Mock));
assert_eq!(
SmsAuthProviderKind::parse("mock"),
Some(SmsAuthProviderKind::Mock)
);
assert_eq!(
SmsAuthProviderKind::parse("aliyun"),
Some(SmsAuthProviderKind::Aliyun)
@@ -1482,7 +1475,10 @@ mod tests {
.expect("send code should succeed");
assert_eq!(send_result.cooldown_seconds, DEFAULT_SMS_INTERVAL_SECONDS);
assert_eq!(send_result.expires_in_seconds, DEFAULT_SMS_VALID_TIME_SECONDS);
assert_eq!(
send_result.expires_in_seconds,
DEFAULT_SMS_VALID_TIME_SECONDS
);
assert_eq!(
send_result.provider_request_id.as_deref(),
Some("mock-request-id")
@@ -1548,7 +1544,10 @@ mod tests {
#[test]
fn canonicalize_aliyun_rpc_params_keeps_sorted_percent_encoded_order() {
let mut params = BTreeMap::new();
params.insert("TemplateParam".to_string(), "{\"code\":\"##code##\"}".to_string());
params.insert(
"TemplateParam".to_string(),
"{\"code\":\"##code##\"}".to_string(),
);
params.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
params.insert("PhoneNumber".to_string(), "13800138000".to_string());
@@ -1580,7 +1579,10 @@ mod tests {
assert_eq!(payload.request_id.as_deref(), Some("req_123"));
assert_eq!(payload.success, Some(true));
assert_eq!(
payload.model.as_ref().and_then(|model| model.out_id.as_deref()),
payload
.model
.as_ref()
.and_then(|model| model.out_id.as_deref()),
Some("out_789")
);
assert_eq!(

View File

@@ -458,11 +458,9 @@ impl LlmClient {
}
if !undecoded_chunk_bytes.is_empty() {
let trailing_text = std_str::from_utf8(undecoded_chunk_bytes.as_slice())
.map_err(|error| {
LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{error}"
))
let trailing_text =
std_str::from_utf8(undecoded_chunk_bytes.as_slice()).map_err(|error| {
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}"))
})?;
if !trailing_text.is_empty() {
for event in parser.push_chunk(trailing_text)? {
@@ -761,9 +759,7 @@ fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec<u8>), LlmError>
let valid_up_to = error.valid_up_to();
let Some(_) = error.error_len() else {
let decoded = std_str::from_utf8(&bytes[..valid_up_to]).map_err(|inner_error| {
LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{inner_error}"
))
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{inner_error}"))
})?;
return Ok((decoded.to_string(), bytes[valid_up_to..].to_vec()));
};

View File

@@ -0,0 +1,107 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
// 管理后台协议统一收口在 shared-contracts避免页面脚本和 Rust handler 各自手拼字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginRequest {
pub username: String,
pub password: String,
}
// 登录成功后返回管理员访问令牌与基础会话信息。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminLoginResponse {
pub token: String,
pub admin: AdminSessionPayload,
}
// 管理员会话只暴露页面展示和鉴权调试所需的最小字段。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminSessionPayload {
pub subject: String,
pub username: String,
pub display_name: String,
pub roles: Vec<String>,
pub issued_at: String,
pub expires_at: String,
}
// 页面恢复登录态时读取当前管理员会话。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminMeResponse {
pub admin: AdminSessionPayload,
}
// 后台概览统一返回服务信息与数据库信息两块,前端不再额外拼装。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminOverviewResponse {
pub service: AdminServiceOverviewPayload,
pub database: AdminDatabaseOverviewPayload,
}
// 服务概览描述当前 api-server 与 SpacetimeDB 连接配置。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminServiceOverviewPayload {
pub bind_host: String,
pub bind_port: u16,
pub jwt_issuer: String,
pub admin_enabled: bool,
pub spacetime_server_url: String,
pub spacetime_database: String,
}
// 数据库概览返回真实数据库元信息、表清单与统计错误。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseOverviewPayload {
pub database_identity: Option<String>,
pub owner_identity: Option<String>,
pub host_type: Option<String>,
pub schema_table_names: Vec<String>,
pub table_stats: Vec<AdminDatabaseTableStatPayload>,
pub fetch_errors: Vec<String>,
}
// 单表统计允许成功和失败并存,避免某张表失败导致整页概览不可用。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDatabaseTableStatPayload {
pub table_name: String,
pub row_count: Option<u64>,
pub error_message: Option<String>,
}
// 调试请求只允许同源路径、受控请求头和有限请求体。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHttpRequest {
pub method: String,
pub path: String,
pub headers: Option<Vec<AdminDebugHeaderInput>>,
pub body: Option<String>,
}
// 调试请求头使用显式结构,避免页面直接塞任意对象。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHeaderInput {
pub name: String,
pub value: String,
}
// 调试响应回显状态、响应头与文本/JSON 预览,便于后台排查接口问题。
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDebugHttpResponse {
pub status: u16,
pub status_text: String,
pub headers: Vec<AdminDebugHeaderInput>,
pub body_text: String,
pub body_json: Option<Value>,
}

View File

@@ -277,6 +277,7 @@ pub struct CharacterAnimationGenerateResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CharacterAnimationDraftPayload {
#[serde(default)]
pub frames_data_urls: Vec<String>,
pub fps: u32,
#[serde(rename = "loop")]
@@ -284,6 +285,14 @@ pub struct CharacterAnimationDraftPayload {
pub frame_width: u32,
pub frame_height: u32,
#[serde(default)]
pub frame_count: Option<u32>,
#[serde(default)]
pub apply_chroma_key: Option<bool>,
#[serde(default)]
pub sample_start_ratio: Option<f32>,
#[serde(default)]
pub sample_end_ratio: Option<f32>,
#[serde(default)]
pub preview_video_path: Option<String>,
}
@@ -815,4 +824,26 @@ mod tests {
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
}
#[test]
fn character_animation_draft_payload_accepts_backend_extraction_fields() {
let payload = serde_json::from_value::<CharacterAnimationDraftPayload>(json!({
"fps": 8,
"loop": true,
"frameWidth": 192,
"frameHeight": 256,
"frameCount": 8,
"applyChromaKey": true,
"sampleStartRatio": 0.12,
"sampleEndRatio": 0.94,
"previewVideoPath": "/generated-character-drafts/hero/animation/idle/task/preview.mp4"
}))
.expect("draft payload should deserialize without framesDataUrls");
assert!(payload.frames_data_urls.is_empty());
assert_eq!(payload.frame_count, Some(8));
assert_eq!(payload.apply_chroma_key, Some(true));
assert_eq!(payload.sample_start_ratio, Some(0.12));
assert_eq!(payload.sample_end_ratio, Some(0.94));
}
}

View File

@@ -16,6 +16,7 @@ pub struct AuthLoginOptionsResponse {
#[serde(rename_all = "camelCase")]
pub struct AuthUserPayload {
pub id: String,
pub public_user_code: String,
pub username: String,
pub display_name: String,
pub phone_number_masked: Option<String>,
@@ -24,6 +25,20 @@ pub struct AuthUserPayload {
pub wechat_bound: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicUserSummaryPayload {
pub id: String,
pub public_user_code: String,
pub display_name: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PublicUserSearchResponse {
pub user: PublicUserSummaryPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PasswordEntryRequest {

View File

@@ -0,0 +1,26 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishWorkSummaryResponse {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
#[serde(default)]
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at: String,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BigFishWorksResponse {
pub items: Vec<BigFishWorkSummaryResponse>,
}

View File

@@ -1,8 +1,10 @@
pub mod admin;
pub mod ai;
pub mod api;
pub mod assets;
pub mod auth;
pub mod big_fish;
pub mod big_fish_works;
pub mod llm;
pub mod puzzle_agent;
pub mod puzzle_gallery;

View File

@@ -233,6 +233,8 @@ pub struct CustomWorldProfileUpsertRequest {
pub struct CustomWorldLibraryEntryResponse {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub profile: serde_json::Value,
pub visibility: String,
pub published_at: Option<String>,
@@ -252,6 +254,8 @@ pub struct CustomWorldLibraryEntryResponse {
pub struct CustomWorldGalleryCardResponse {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,

View File

@@ -16,6 +16,7 @@ module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" }
module-runtime-item = { path = "../module-runtime-item" }
module-story = { path = "../module-story" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
shared-kernel = { path = "../shared-kernel" }
spacetimedb-sdk = "2.1.0"

View File

@@ -155,7 +155,9 @@ use crate::module_bindings::{
BigFishSessionGetInput as BindingBigFishSessionGetInput,
BigFishSessionProcedureResult as BindingBigFishSessionProcedureResult,
BigFishSessionSnapshot as BindingBigFishSessionSnapshot,
BigFishVector2 as BindingBigFishVector2, CombatOutcome as BindingCombatOutcome,
BigFishVector2 as BindingBigFishVector2, BigFishWorksListInput as BindingBigFishWorksListInput,
BigFishWorksProcedureResult as BindingBigFishWorksProcedureResult,
CombatOutcome as BindingCombatOutcome,
CustomWorldAgentActionExecuteInput as BindingCustomWorldAgentActionExecuteInput,
CustomWorldAgentActionExecuteResult as BindingCustomWorldAgentActionExecuteResult,
CustomWorldAgentCardDetailGetInput as BindingCustomWorldAgentCardDetailGetInput,
@@ -173,6 +175,7 @@ use crate::module_bindings::{
CustomWorldDraftCardDetailSectionSnapshot as BindingCustomWorldDraftCardDetailSectionSnapshot,
CustomWorldDraftCardDetailSnapshot as BindingCustomWorldDraftCardDetailSnapshot,
CustomWorldDraftCardSnapshot as BindingCustomWorldDraftCardSnapshot,
CustomWorldGalleryDetailByCodeInput as BindingCustomWorldGalleryDetailByCodeInput,
CustomWorldGalleryDetailInput as BindingCustomWorldGalleryDetailInput,
CustomWorldGalleryEntrySnapshot as BindingCustomWorldGalleryEntrySnapshot,
CustomWorldGalleryListResult as BindingCustomWorldGalleryListResult,
@@ -205,6 +208,7 @@ use crate::module_bindings::{
NpcInteractionStatus as BindingNpcInteractionStatus,
NpcRelationStance as BindingNpcRelationStance, NpcRelationState as BindingNpcRelationState,
NpcStanceProfile as BindingNpcStanceProfile, NpcStateSnapshot as BindingNpcStateSnapshot,
PuzzleAgentMessageFinalizeInput as BindingPuzzleAgentMessageFinalizeInput,
PuzzleAgentMessageSubmitInput as BindingPuzzleAgentMessageSubmitInput,
PuzzleAgentSessionCreateInput as BindingPuzzleAgentSessionCreateInput,
PuzzleAgentSessionGetInput as BindingPuzzleAgentSessionGetInput,
@@ -297,6 +301,7 @@ use crate::module_bindings::{
execute_custom_world_agent_action_procedure::execute_custom_world_agent_action as _,
fail_ai_task_and_return_procedure::fail_ai_task_and_return as _,
finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn as _,
finalize_puzzle_agent_message_turn_procedure::finalize_puzzle_agent_message_turn as _,
generate_big_fish_asset_procedure::generate_big_fish_asset as _,
get_battle_state_procedure::get_battle_state as _,
get_big_fish_run_procedure::get_big_fish_run as _,
@@ -304,6 +309,7 @@ use crate::module_bindings::{
get_custom_world_agent_card_detail_procedure::get_custom_world_agent_card_detail as _,
get_custom_world_agent_operation_procedure::get_custom_world_agent_operation as _,
get_custom_world_agent_session_procedure::get_custom_world_agent_session as _,
get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code as _,
get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail as _,
get_custom_world_library_detail_procedure::get_custom_world_library_detail as _,
get_profile_dashboard_procedure::get_profile_dashboard as _,
@@ -316,6 +322,7 @@ use crate::module_bindings::{
get_runtime_setting_or_default_procedure::get_runtime_setting_or_default as _,
get_runtime_snapshot_procedure::get_runtime_snapshot as _,
get_story_session_state_procedure::get_story_session_state as _,
list_big_fish_works_procedure::list_big_fish_works as _,
list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries as _,
list_custom_world_profiles_procedure::list_custom_world_profiles as _,
list_custom_world_works_procedure::list_custom_world_works as _,
@@ -739,12 +746,16 @@ impl SpacetimeClient {
&self,
profile_id: String,
owner_user_id: String,
public_work_code: Option<String>,
author_public_user_code: String,
author_display_name: String,
published_at_micros: i64,
) -> Result<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldProfilePublishInput {
profile_id,
owner_user_id,
public_work_code,
author_public_user_code,
author_display_name,
published_at_micros,
};
@@ -856,6 +867,25 @@ impl SpacetimeClient {
.await
}
pub async fn get_custom_world_gallery_detail_by_code(
&self,
public_work_code: String,
) -> Result<CustomWorldLibraryMutationRecord, SpacetimeClientError> {
let procedure_input = BindingCustomWorldGalleryDetailByCodeInput { public_work_code };
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_custom_world_gallery_detail_by_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_library_mutation_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn publish_custom_world_world(
&self,
input: CustomWorldPublishWorldRecordInput,
@@ -1086,6 +1116,35 @@ impl SpacetimeClient {
.await
}
pub async fn finalize_puzzle_agent_message(
&self,
input: PuzzleAgentMessageFinalizeRecordInput,
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
let procedure_input = BindingPuzzleAgentMessageFinalizeInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
assistant_message_id: input.assistant_message_id,
assistant_reply_text: input.assistant_reply_text,
stage: parse_puzzle_agent_stage_record(input.stage.as_str())?,
progress_percent: input.progress_percent,
anchor_pack_json: input.anchor_pack_json,
error_message: input.error_message,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.finalize_puzzle_agent_message_turn_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_agent_session_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn compile_puzzle_agent_draft(
&self,
session_id: String,
@@ -1467,6 +1526,25 @@ impl SpacetimeClient {
.await
}
pub async fn list_big_fish_works(
&self,
owner_user_id: String,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BindingBigFishWorksListInput { owner_user_id };
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.list_big_fish_works_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn submit_big_fish_message(
&self,
input: BigFishMessageSubmitRecordInput,
@@ -1707,15 +1785,17 @@ impl SpacetimeClient {
};
self.call_after_connect(move |connection, sender| {
connection.procedures().finalize_custom_world_agent_message_turn_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
connection
.procedures()
.finalize_custom_world_agent_message_turn_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_agent_operation_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
@@ -2764,6 +2844,8 @@ fn map_custom_world_profile_upsert_input(
BindingCustomWorldProfileUpsertInput {
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
public_work_code: input.public_work_code,
author_public_user_code: input.author_public_user_code,
source_agent_session_id: input.source_agent_session_id,
world_name: input.world_name,
subtitle: input.subtitle,
@@ -2785,6 +2867,8 @@ fn map_custom_world_publish_world_input(
session_id: input.session_id,
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
public_work_code: input.public_work_code,
author_public_user_code: input.author_public_user_code,
draft_profile_json: input.draft_profile_json,
legacy_result_profile_json: input.legacy_result_profile_json,
setting_text: input.setting_text,
@@ -3480,6 +3564,27 @@ fn map_big_fish_session_procedure_result(
Ok(map_big_fish_session_snapshot(session))
}
fn map_big_fish_works_procedure_result(
result: BindingBigFishWorksProcedureResult,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let items_json = result.items_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 big fish works 快照".to_string(),
)
})?;
serde_json::from_str::<Vec<BigFishWorkSummaryRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("big fish works items_json 非法: {error}"))
})
}
fn map_big_fish_run_procedure_result(
result: BindingBigFishRunProcedureResult,
) -> Result<BigFishRuntimeRecord, SpacetimeClientError> {
@@ -3817,6 +3922,8 @@ fn map_custom_world_library_entry_from_profile_snapshot(
Ok(CustomWorldLibraryEntryRecord {
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
public_work_code: snapshot.public_work_code,
author_public_user_code: snapshot.author_public_user_code,
profile,
visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
@@ -3841,6 +3948,8 @@ fn map_custom_world_gallery_entry_snapshot(
Ok(CustomWorldGalleryEntryRecord {
owner_user_id: snapshot.owner_user_id,
profile_id: snapshot.profile_id,
public_work_code: snapshot.public_work_code,
author_public_user_code: snapshot.author_public_user_code,
visibility: "published".to_string(),
published_at: Some(format_timestamp_micros(snapshot.published_at_micros)),
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
@@ -3990,6 +4099,7 @@ fn map_custom_world_agent_session_snapshot(
Ok(CustomWorldAgentSessionRecord {
session_id: snapshot.session_id,
seed_text: snapshot.seed_text,
current_turn: snapshot.current_turn,
anchor_content,
progress_percent: snapshot.progress_percent,
@@ -5021,6 +5131,21 @@ fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String {
.to_string()
}
fn parse_puzzle_agent_stage_record(
value: &str,
) -> Result<crate::module_bindings::PuzzleAgentStage, SpacetimeClientError> {
match value.trim() {
"collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors),
"draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady),
"image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining),
"ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish),
"published" => Ok(crate::module_bindings::PuzzleAgentStage::Published),
other => Err(SpacetimeClientError::Runtime(format!(
"未知 puzzle agent stage: {other}"
))),
}
}
fn parse_rpg_agent_stage_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentStage, SpacetimeClientError> {
@@ -5680,6 +5805,8 @@ pub struct ResolveCombatActionRecord {
pub struct CustomWorldLibraryEntryRecord {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub profile: serde_json::Value,
pub visibility: String,
pub published_at: Option<String>,
@@ -5698,6 +5825,8 @@ pub struct CustomWorldLibraryEntryRecord {
pub struct CustomWorldGalleryEntryRecord {
pub owner_user_id: String,
pub profile_id: String,
pub public_work_code: String,
pub author_public_user_code: String,
pub visibility: String,
pub published_at: Option<String>,
pub updated_at: String,
@@ -5863,6 +5992,7 @@ pub struct CustomWorldDraftCardDetailRecord {
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldAgentSessionRecord {
pub session_id: String,
pub seed_text: String,
pub current_turn: u32,
pub anchor_content: serde_json::Value,
pub progress_percent: u32,
@@ -5892,6 +6022,8 @@ pub struct CustomWorldAgentSessionRecord {
pub struct CustomWorldProfileUpsertRecordInput {
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: Option<String>,
pub source_agent_session_id: Option<String>,
pub world_name: String,
pub subtitle: String,
@@ -5910,6 +6042,8 @@ pub struct CustomWorldPublishWorldRecordInput {
pub session_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub public_work_code: Option<String>,
pub author_public_user_code: String,
pub draft_profile_json: String,
pub legacy_result_profile_json: Option<String>,
pub setting_text: String,
@@ -6011,6 +6145,19 @@ pub struct PuzzleAgentMessageSubmitRecordInput {
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAgentMessageFinalizeRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub stage: String,
pub progress_percent: u32,
pub anchor_pack_json: String,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleGeneratedImagesSaveRecordInput {
pub session_id: String,
@@ -6438,6 +6585,23 @@ pub struct BigFishSessionRecord {
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct BigFishWorkSummaryRecord {
pub work_id: String,
pub source_session_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
pub cover_image_src: Option<String>,
pub status: String,
pub updated_at_micros: i64,
pub publish_ready: bool,
pub level_count: u32,
pub level_main_image_ready_count: u32,
pub level_motion_ready_count: u32,
pub background_ready: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BigFishVector2Record {
pub x: f32,

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::quest_record_input_type::QuestRecordInput;
@@ -19,10 +14,8 @@ pub(super) struct AcceptQuestArgs {
impl From<AcceptQuestArgs> for super::Reducer {
fn from(args: AcceptQuestArgs) -> Self {
Self::AcceptQuest {
input: args.input,
}
}
Self::AcceptQuest { input: args.input }
}
}
impl __sdk::InModule for AcceptQuestArgs {
@@ -40,9 +33,8 @@ pub trait accept_quest {
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`accept_quest:accept_quest_then`] to run a callback after the reducer completes.
fn accept_quest(&self, input: QuestRecordInput,
) -> __sdk::Result<()> {
self.accept_quest_then(input, |_, _| {})
fn accept_quest(&self, input: QuestRecordInput) -> __sdk::Result<()> {
self.accept_quest_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `accept_quest` to run as soon as possible,
@@ -55,9 +47,11 @@ pub trait accept_quest {
&self,
input: QuestRecordInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -66,11 +60,13 @@ impl accept_quest for super::RemoteReducers {
&self,
input: QuestRecordInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(AcceptQuestArgs { input, }, callback)
self.imp
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::quest_completion_ack_input_type::QuestCompletionAckInput;
@@ -19,10 +14,8 @@ pub(super) struct AcknowledgeQuestCompletionArgs {
impl From<AcknowledgeQuestCompletionArgs> for super::Reducer {
fn from(args: AcknowledgeQuestCompletionArgs) -> Self {
Self::AcknowledgeQuestCompletion {
input: args.input,
}
}
Self::AcknowledgeQuestCompletion { input: args.input }
}
}
impl __sdk::InModule for AcknowledgeQuestCompletionArgs {
@@ -40,9 +33,8 @@ pub trait acknowledge_quest_completion {
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`acknowledge_quest_completion:acknowledge_quest_completion_then`] to run a callback after the reducer completes.
fn acknowledge_quest_completion(&self, input: QuestCompletionAckInput,
) -> __sdk::Result<()> {
self.acknowledge_quest_completion_then(input, |_, _| {})
fn acknowledge_quest_completion(&self, input: QuestCompletionAckInput) -> __sdk::Result<()> {
self.acknowledge_quest_completion_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `acknowledge_quest_completion` to run as soon as possible,
@@ -55,9 +47,11 @@ pub trait acknowledge_quest_completion {
&self,
input: QuestCompletionAckInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -66,11 +60,13 @@ impl acknowledge_quest_completion for super::RemoteReducers {
&self,
input: QuestCompletionAckInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input, }, callback)
self.imp
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
}
}

View File

@@ -2,23 +2,17 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::puzzle_run_next_level_input_type::PuzzleRunNextLevelInput;
use super::puzzle_run_procedure_result_type::PuzzleRunProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdvancePuzzleNextLevelArgs {
struct AdvancePuzzleNextLevelArgs {
pub input: PuzzleRunNextLevelInput,
}
impl __sdk::InModule for AdvancePuzzleNextLevelArgs {
type Module = super::RemoteModule;
}
@@ -28,16 +22,19 @@ impl __sdk::InModule for AdvancePuzzleNextLevelArgs {
///
/// Implemented for [`super::RemoteProcedures`].
pub trait advance_puzzle_next_level {
fn advance_puzzle_next_level(&self, input: PuzzleRunNextLevelInput,
) {
self.advance_puzzle_next_level_then(input, |_, _| {});
fn advance_puzzle_next_level(&self, input: PuzzleRunNextLevelInput) {
self.advance_puzzle_next_level_then(input, |_, _| {});
}
fn advance_puzzle_next_level_then(
&self,
input: PuzzleRunNextLevelInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleRunProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -46,13 +43,17 @@ impl advance_puzzle_next_level for super::RemoteProcedures {
&self,
input: PuzzleRunNextLevelInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleRunProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<PuzzleRunProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
"advance_puzzle_next_level",
AdvancePuzzleNextLevelArgs { input, },
__callback,
);
self.imp
.invoke_procedure_with_callback::<_, PuzzleRunProcedureResult>(
"advance_puzzle_next_level",
AdvancePuzzleNextLevelArgs { input },
__callback,
);
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_result_reference_kind_type::AiResultReferenceKind;
@@ -17,12 +12,10 @@ pub struct AiResultReferenceInput {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option::<String>,
pub label: Option<String>,
pub created_at_micros: i64,
}
impl __sdk::InModule for AiResultReferenceInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -24,12 +19,8 @@ pub enum AiResultReferenceKind {
RuntimeItemRecord,
AssetObject,
}
impl __sdk::InModule for AiResultReferenceKind {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_result_reference_kind_type::AiResultReferenceKind;
@@ -18,12 +13,10 @@ pub struct AiResultReferenceSnapshot {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option::<String>,
pub label: Option<String>,
pub created_at_micros: i64,
}
impl __sdk::InModule for AiResultReferenceSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -2,14 +2,9 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::ai_result_reference_type::AiResultReference;
use super::ai_result_reference_kind_type::AiResultReferenceKind;
use super::ai_result_reference_type::AiResultReference;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_result_reference`.
///
@@ -37,7 +32,9 @@ pub trait AiResultReferenceTableAccess {
impl AiResultReferenceTableAccess for super::RemoteTables {
fn ai_result_reference(&self) -> AiResultReferenceTableHandle<'_> {
AiResultReferenceTableHandle {
imp: self.imp.get_table::<AiResultReference>("ai_result_reference"),
imp: self
.imp
.get_table::<AiResultReference>("ai_result_reference"),
ctx: std::marker::PhantomData,
}
}
@@ -50,8 +47,12 @@ impl<'ctx> __sdk::Table for AiResultReferenceTableHandle<'ctx> {
type Row = AiResultReference;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = AiResultReference> + '_ { self.imp.iter() }
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiResultReference> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiResultReferenceInsertCallbackId;
@@ -97,41 +98,44 @@ impl<'ctx> __sdk::TableWithPrimaryKey for AiResultReferenceTableHandle<'ctx> {
}
}
/// Access to the `result_reference_row_id` unique index on the table `ai_result_reference`,
/// which allows point queries on the field of the same name
/// via the [`AiResultReferenceResultReferenceRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_result_reference().result_reference_row_id().find(...)`.
pub struct AiResultReferenceResultReferenceRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiResultReference, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Access to the `result_reference_row_id` unique index on the table `ai_result_reference`,
/// which allows point queries on the field of the same name
/// via the [`AiResultReferenceResultReferenceRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_result_reference().result_reference_row_id().find(...)`.
pub struct AiResultReferenceResultReferenceRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiResultReference, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiResultReferenceTableHandle<'ctx> {
/// Get a handle on the `result_reference_row_id` unique index on the table `ai_result_reference`.
pub fn result_reference_row_id(&self) -> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
AiResultReferenceResultReferenceRowIdUnique {
imp: self.imp.get_unique_constraint::<String>("result_reference_row_id"),
phantom: std::marker::PhantomData,
}
}
impl<'ctx> AiResultReferenceTableHandle<'ctx> {
/// Get a handle on the `result_reference_row_id` unique index on the table `ai_result_reference`.
pub fn result_reference_row_id(&self) -> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
AiResultReferenceResultReferenceRowIdUnique {
imp: self
.imp
.get_unique_constraint::<String>("result_reference_row_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
/// Find the subscribed row whose `result_reference_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiResultReference> {
self.imp.find(col_val)
}
}
impl<'ctx> AiResultReferenceResultReferenceRowIdUnique<'ctx> {
/// Find the subscribed row whose `result_reference_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiResultReference> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiResultReference>("ai_result_reference");
_table.add_unique_constraint::<String>("result_reference_row_id", |row| &row.result_reference_row_id);
_table.add_unique_constraint::<String>("result_reference_row_id", |row| {
&row.result_reference_row_id
});
}
#[doc(hidden)]
@@ -139,26 +143,24 @@ pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiResultReference>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<AiResultReference>",
"TableUpdate",
).with_cause(e).into()
__sdk::InternalError::failed_parse("TableUpdate<AiResultReference>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiResultReference`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_result_referenceQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiResultReference`.
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference>;
}
impl ai_result_referenceQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference> {
__sdk::__query_builder::Table::new("ai_result_reference")
}
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiResultReference`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_result_referenceQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiResultReference`.
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference>;
}
impl ai_result_referenceQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_result_reference(&self) -> __sdk::__query_builder::Table<AiResultReference> {
__sdk::__query_builder::Table::new("ai_result_reference")
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_result_reference_kind_type::AiResultReferenceKind;
@@ -19,16 +14,14 @@ pub struct AiResultReference {
pub task_id: String,
pub reference_kind: AiResultReferenceKind,
pub reference_id: String,
pub label: Option::<String>,
pub label: Option<String>,
pub created_at: __sdk::Timestamp,
}
impl __sdk::InModule for AiResultReference {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AiResultReference`.
///
/// Provides typed access to columns for query building.
@@ -38,7 +31,7 @@ pub struct AiResultReferenceCols {
pub task_id: __sdk::__query_builder::Col<AiResultReference, String>,
pub reference_kind: __sdk::__query_builder::Col<AiResultReference, AiResultReferenceKind>,
pub reference_id: __sdk::__query_builder::Col<AiResultReference, String>,
pub label: __sdk::__query_builder::Col<AiResultReference, Option::<String>>,
pub label: __sdk::__query_builder::Col<AiResultReference, Option<String>>,
pub created_at: __sdk::__query_builder::Col<AiResultReference, __sdk::Timestamp>,
}
@@ -46,14 +39,16 @@ impl __sdk::__query_builder::HasCols for AiResultReference {
type Cols = AiResultReferenceCols;
fn cols(table_name: &'static str) -> Self::Cols {
AiResultReferenceCols {
result_reference_row_id: __sdk::__query_builder::Col::new(table_name, "result_reference_row_id"),
result_reference_row_id: __sdk::__query_builder::Col::new(
table_name,
"result_reference_row_id",
),
result_ref_id: __sdk::__query_builder::Col::new(table_name, "result_ref_id"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
reference_kind: __sdk::__query_builder::Col::new(table_name, "reference_kind"),
reference_id: __sdk::__query_builder::Col::new(table_name, "reference_id"),
label: __sdk::__query_builder::Col::new(table_name, "label"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
}
}
}
@@ -70,12 +65,13 @@ impl __sdk::__query_builder::HasIxCols for AiResultReference {
type IxCols = AiResultReferenceIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
AiResultReferenceIxCols {
result_reference_row_id: __sdk::__query_builder::IxCol::new(table_name, "result_reference_row_id"),
result_reference_row_id: __sdk::__query_builder::IxCol::new(
table_name,
"result_reference_row_id",
),
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AiResultReference {}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -16,14 +11,12 @@ use super::ai_task_stage_kind_type::AiTaskStageKind;
pub struct AiStageCompletionInput {
pub task_id: String,
pub stage_kind: AiTaskStageKind,
pub text_output: Option::<String>,
pub structured_payload_json: Option::<String>,
pub warning_messages: Vec::<String>,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub completed_at_micros: i64,
}
impl __sdk::InModule for AiStageCompletionInput {
type Module = super::RemoteModule;
}

View File

@@ -2,13 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -17,8 +11,6 @@ pub struct AiTaskCancelInput {
pub completed_at_micros: i64,
}
impl __sdk::InModule for AiTaskCancelInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_stage_blueprint_type::AiTaskStageBlueprint;
@@ -20,14 +15,12 @@ pub struct AiTaskCreateInput {
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option::<String>,
pub request_payload_json: Option::<String>,
pub stages: Vec::<AiTaskStageBlueprint>,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub stages: Vec<AiTaskStageBlueprint>,
pub created_at_micros: i64,
}
impl __sdk::InModule for AiTaskCreateInput {
type Module = super::RemoteModule;
}

View File

@@ -2,13 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -18,8 +12,6 @@ pub struct AiTaskFailureInput {
pub completed_at_micros: i64,
}
impl __sdk::InModule for AiTaskFailureInput {
type Module = super::RemoteModule;
}

View File

@@ -2,13 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -17,8 +11,6 @@ pub struct AiTaskFinishInput {
pub completed_at_micros: i64,
}
impl __sdk::InModule for AiTaskFinishInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -24,12 +19,8 @@ pub enum AiTaskKind {
QuestIntent,
RuntimeItemIntent,
}
impl __sdk::InModule for AiTaskKind {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_snapshot_type::AiTaskSnapshot;
use super::ai_text_chunk_snapshot_type::AiTextChunkSnapshot;
@@ -16,13 +11,11 @@ use super::ai_text_chunk_snapshot_type::AiTextChunkSnapshot;
#[sats(crate = __lib)]
pub struct AiTaskProcedureResult {
pub ok: bool,
pub task: Option::<AiTaskSnapshot>,
pub text_chunk: Option::<AiTextChunkSnapshot>,
pub error_message: Option::<String>,
pub task: Option<AiTaskSnapshot>,
pub text_chunk: Option<AiTextChunkSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AiTaskProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -2,17 +2,12 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_status_type::AiTaskStatus;
use super::ai_task_stage_snapshot_type::AiTaskStageSnapshot;
use super::ai_result_reference_snapshot_type::AiResultReferenceSnapshot;
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_stage_snapshot_type::AiTaskStageSnapshot;
use super::ai_task_status_type::AiTaskStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -22,23 +17,21 @@ pub struct AiTaskSnapshot {
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option::<String>,
pub request_payload_json: Option::<String>,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option::<String>,
pub stages: Vec::<AiTaskStageSnapshot>,
pub result_references: Vec::<AiResultReferenceSnapshot>,
pub latest_text_output: Option::<String>,
pub latest_structured_payload_json: Option::<String>,
pub failure_message: Option<String>,
pub stages: Vec<AiTaskStageSnapshot>,
pub result_references: Vec<AiResultReferenceSnapshot>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at_micros: i64,
pub started_at_micros: Option::<i64>,
pub completed_at_micros: Option::<i64>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AiTaskSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -20,8 +15,6 @@ pub struct AiTaskStageBlueprint {
pub order: u32,
}
impl __sdk::InModule for AiTaskStageBlueprint {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -22,12 +17,8 @@ pub enum AiTaskStageKind {
NormalizeResult,
PersistResult,
}
impl __sdk::InModule for AiTaskStageKind {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_task_stage_status_type::AiTaskStageStatus;
@@ -20,15 +15,13 @@ pub struct AiTaskStageSnapshot {
pub detail: String,
pub order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option::<String>,
pub structured_payload_json: Option::<String>,
pub warning_messages: Vec::<String>,
pub started_at_micros: Option::<i64>,
pub completed_at_micros: Option::<i64>,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at_micros: Option<i64>,
pub completed_at_micros: Option<i64>,
}
impl __sdk::InModule for AiTaskStageSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -19,8 +14,6 @@ pub struct AiTaskStageStartInput {
pub started_at_micros: i64,
}
impl __sdk::InModule for AiTaskStageStartInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -20,12 +15,8 @@ pub enum AiTaskStageStatus {
Completed,
Skipped,
}
impl __sdk::InModule for AiTaskStageStatus {
type Module = super::RemoteModule;
}

View File

@@ -2,15 +2,10 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::ai_task_stage_type::AiTaskStage;
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_task_stage_status_type::AiTaskStageStatus;
use super::ai_task_stage_type::AiTaskStage;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_task_stage`.
///
@@ -51,8 +46,12 @@ impl<'ctx> __sdk::Table for AiTaskStageTableHandle<'ctx> {
type Row = AiTaskStage;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = AiTaskStage> + '_ { self.imp.iter() }
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTaskStage> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTaskStageInsertCallbackId;
@@ -98,39 +97,38 @@ impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskStageTableHandle<'ctx> {
}
}
/// Access to the `task_stage_id` unique index on the table `ai_task_stage`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskStageTaskStageIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task_stage().task_stage_id().find(...)`.
pub struct AiTaskStageTaskStageIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTaskStage, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Access to the `task_stage_id` unique index on the table `ai_task_stage`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskStageTaskStageIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task_stage().task_stage_id().find(...)`.
pub struct AiTaskStageTaskStageIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTaskStage, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTaskStageTableHandle<'ctx> {
/// Get a handle on the `task_stage_id` unique index on the table `ai_task_stage`.
pub fn task_stage_id(&self) -> AiTaskStageTaskStageIdUnique<'ctx> {
AiTaskStageTaskStageIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_stage_id"),
phantom: std::marker::PhantomData,
}
}
impl<'ctx> AiTaskStageTableHandle<'ctx> {
/// Get a handle on the `task_stage_id` unique index on the table `ai_task_stage`.
pub fn task_stage_id(&self) -> AiTaskStageTaskStageIdUnique<'ctx> {
AiTaskStageTaskStageIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_stage_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTaskStageTaskStageIdUnique<'ctx> {
/// Find the subscribed row whose `task_stage_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTaskStage> {
self.imp.find(col_val)
}
}
impl<'ctx> AiTaskStageTaskStageIdUnique<'ctx> {
/// Find the subscribed row whose `task_stage_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTaskStage> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTaskStage>("ai_task_stage");
_table.add_unique_constraint::<String>("task_stage_id", |row| &row.task_stage_id);
}
@@ -140,26 +138,24 @@ pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTaskStage>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<AiTaskStage>",
"TableUpdate",
).with_cause(e).into()
__sdk::InternalError::failed_parse("TableUpdate<AiTaskStage>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTaskStage`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_task_stageQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTaskStage`.
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage>;
}
impl ai_task_stageQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage> {
__sdk::__query_builder::Table::new("ai_task_stage")
}
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTaskStage`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_task_stageQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTaskStage`.
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage>;
}
impl ai_task_stageQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task_stage(&self) -> __sdk::__query_builder::Table<AiTaskStage> {
__sdk::__query_builder::Table::new("ai_task_stage")
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_task_stage_status_type::AiTaskStageStatus;
@@ -22,19 +17,17 @@ pub struct AiTaskStage {
pub detail: String,
pub stage_order: u32,
pub status: AiTaskStageStatus,
pub text_output: Option::<String>,
pub structured_payload_json: Option::<String>,
pub warning_messages: Vec::<String>,
pub started_at: Option::<__sdk::Timestamp>,
pub completed_at: Option::<__sdk::Timestamp>,
pub text_output: Option<String>,
pub structured_payload_json: Option<String>,
pub warning_messages: Vec<String>,
pub started_at: Option<__sdk::Timestamp>,
pub completed_at: Option<__sdk::Timestamp>,
}
impl __sdk::InModule for AiTaskStage {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AiTaskStage`.
///
/// Provides typed access to columns for query building.
@@ -46,11 +39,11 @@ pub struct AiTaskStageCols {
pub detail: __sdk::__query_builder::Col<AiTaskStage, String>,
pub stage_order: __sdk::__query_builder::Col<AiTaskStage, u32>,
pub status: __sdk::__query_builder::Col<AiTaskStage, AiTaskStageStatus>,
pub text_output: __sdk::__query_builder::Col<AiTaskStage, Option::<String>>,
pub structured_payload_json: __sdk::__query_builder::Col<AiTaskStage, Option::<String>>,
pub warning_messages: __sdk::__query_builder::Col<AiTaskStage, Vec::<String>>,
pub started_at: __sdk::__query_builder::Col<AiTaskStage, Option::<__sdk::Timestamp>>,
pub completed_at: __sdk::__query_builder::Col<AiTaskStage, Option::<__sdk::Timestamp>>,
pub text_output: __sdk::__query_builder::Col<AiTaskStage, Option<String>>,
pub structured_payload_json: __sdk::__query_builder::Col<AiTaskStage, Option<String>>,
pub warning_messages: __sdk::__query_builder::Col<AiTaskStage, Vec<String>>,
pub started_at: __sdk::__query_builder::Col<AiTaskStage, Option<__sdk::Timestamp>>,
pub completed_at: __sdk::__query_builder::Col<AiTaskStage, Option<__sdk::Timestamp>>,
}
impl __sdk::__query_builder::HasCols for AiTaskStage {
@@ -65,11 +58,13 @@ impl __sdk::__query_builder::HasCols for AiTaskStage {
stage_order: __sdk::__query_builder::Col::new(table_name, "stage_order"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
text_output: __sdk::__query_builder::Col::new(table_name, "text_output"),
structured_payload_json: __sdk::__query_builder::Col::new(table_name, "structured_payload_json"),
structured_payload_json: __sdk::__query_builder::Col::new(
table_name,
"structured_payload_json",
),
warning_messages: __sdk::__query_builder::Col::new(table_name, "warning_messages"),
started_at: __sdk::__query_builder::Col::new(table_name, "started_at"),
completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"),
}
}
}
@@ -88,10 +83,8 @@ impl __sdk::__query_builder::HasIxCols for AiTaskStage {
AiTaskStageIxCols {
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
task_stage_id: __sdk::__query_builder::IxCol::new(table_name, "task_stage_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AiTaskStage {}

View File

@@ -2,13 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -17,8 +11,6 @@ pub struct AiTaskStartInput {
pub started_at_micros: i64,
}
impl __sdk::InModule for AiTaskStartInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -22,12 +17,8 @@ pub enum AiTaskStatus {
Failed,
Cancelled,
}
impl __sdk::InModule for AiTaskStatus {
type Module = super::RemoteModule;
}

View File

@@ -2,15 +2,10 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::ai_task_type::AiTask;
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_status_type::AiTaskStatus;
use super::ai_task_type::AiTask;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_task`.
///
@@ -51,8 +46,12 @@ impl<'ctx> __sdk::Table for AiTaskTableHandle<'ctx> {
type Row = AiTask;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = AiTask> + '_ { self.imp.iter() }
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTask> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTaskInsertCallbackId;
@@ -98,39 +97,38 @@ impl<'ctx> __sdk::TableWithPrimaryKey for AiTaskTableHandle<'ctx> {
}
}
/// Access to the `task_id` unique index on the table `ai_task`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskTaskIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task().task_id().find(...)`.
pub struct AiTaskTaskIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTask, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Access to the `task_id` unique index on the table `ai_task`,
/// which allows point queries on the field of the same name
/// via the [`AiTaskTaskIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_task().task_id().find(...)`.
pub struct AiTaskTaskIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTask, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTaskTableHandle<'ctx> {
/// Get a handle on the `task_id` unique index on the table `ai_task`.
pub fn task_id(&self) -> AiTaskTaskIdUnique<'ctx> {
AiTaskTaskIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_id"),
phantom: std::marker::PhantomData,
}
}
impl<'ctx> AiTaskTableHandle<'ctx> {
/// Get a handle on the `task_id` unique index on the table `ai_task`.
pub fn task_id(&self) -> AiTaskTaskIdUnique<'ctx> {
AiTaskTaskIdUnique {
imp: self.imp.get_unique_constraint::<String>("task_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTaskTaskIdUnique<'ctx> {
/// Find the subscribed row whose `task_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTask> {
self.imp.find(col_val)
}
}
impl<'ctx> AiTaskTaskIdUnique<'ctx> {
/// Find the subscribed row whose `task_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTask> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTask>("ai_task");
_table.add_unique_constraint::<String>("task_id", |row| &row.task_id);
}
@@ -140,26 +138,24 @@ pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTask>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<AiTask>",
"TableUpdate",
).with_cause(e).into()
__sdk::InternalError::failed_parse("TableUpdate<AiTask>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTask`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_taskQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTask`.
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask>;
}
impl ai_taskQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask> {
__sdk::__query_builder::Table::new("ai_task")
}
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTask`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_taskQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTask`.
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask>;
}
impl ai_taskQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_task(&self) -> __sdk::__query_builder::Table<AiTask> {
__sdk::__query_builder::Table::new("ai_task")
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_kind_type::AiTaskKind;
use super::ai_task_status_type::AiTaskStatus;
@@ -20,25 +15,23 @@ pub struct AiTask {
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: Option::<String>,
pub request_payload_json: Option::<String>,
pub source_entity_id: Option<String>,
pub request_payload_json: Option<String>,
pub status: AiTaskStatus,
pub failure_message: Option::<String>,
pub latest_text_output: Option::<String>,
pub latest_structured_payload_json: Option::<String>,
pub failure_message: Option<String>,
pub latest_text_output: Option<String>,
pub latest_structured_payload_json: Option<String>,
pub version: u32,
pub created_at: __sdk::Timestamp,
pub started_at: Option::<__sdk::Timestamp>,
pub completed_at: Option::<__sdk::Timestamp>,
pub started_at: Option<__sdk::Timestamp>,
pub completed_at: Option<__sdk::Timestamp>,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for AiTask {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AiTask`.
///
/// Provides typed access to columns for query building.
@@ -48,16 +41,16 @@ pub struct AiTaskCols {
pub owner_user_id: __sdk::__query_builder::Col<AiTask, String>,
pub request_label: __sdk::__query_builder::Col<AiTask, String>,
pub source_module: __sdk::__query_builder::Col<AiTask, String>,
pub source_entity_id: __sdk::__query_builder::Col<AiTask, Option::<String>>,
pub request_payload_json: __sdk::__query_builder::Col<AiTask, Option::<String>>,
pub source_entity_id: __sdk::__query_builder::Col<AiTask, Option<String>>,
pub request_payload_json: __sdk::__query_builder::Col<AiTask, Option<String>>,
pub status: __sdk::__query_builder::Col<AiTask, AiTaskStatus>,
pub failure_message: __sdk::__query_builder::Col<AiTask, Option::<String>>,
pub latest_text_output: __sdk::__query_builder::Col<AiTask, Option::<String>>,
pub latest_structured_payload_json: __sdk::__query_builder::Col<AiTask, Option::<String>>,
pub failure_message: __sdk::__query_builder::Col<AiTask, Option<String>>,
pub latest_text_output: __sdk::__query_builder::Col<AiTask, Option<String>>,
pub latest_structured_payload_json: __sdk::__query_builder::Col<AiTask, Option<String>>,
pub version: __sdk::__query_builder::Col<AiTask, u32>,
pub created_at: __sdk::__query_builder::Col<AiTask, __sdk::Timestamp>,
pub started_at: __sdk::__query_builder::Col<AiTask, Option::<__sdk::Timestamp>>,
pub completed_at: __sdk::__query_builder::Col<AiTask, Option::<__sdk::Timestamp>>,
pub started_at: __sdk::__query_builder::Col<AiTask, Option<__sdk::Timestamp>>,
pub completed_at: __sdk::__query_builder::Col<AiTask, Option<__sdk::Timestamp>>,
pub updated_at: __sdk::__query_builder::Col<AiTask, __sdk::Timestamp>,
}
@@ -71,17 +64,22 @@ impl __sdk::__query_builder::HasCols for AiTask {
request_label: __sdk::__query_builder::Col::new(table_name, "request_label"),
source_module: __sdk::__query_builder::Col::new(table_name, "source_module"),
source_entity_id: __sdk::__query_builder::Col::new(table_name, "source_entity_id"),
request_payload_json: __sdk::__query_builder::Col::new(table_name, "request_payload_json"),
request_payload_json: __sdk::__query_builder::Col::new(
table_name,
"request_payload_json",
),
status: __sdk::__query_builder::Col::new(table_name, "status"),
failure_message: __sdk::__query_builder::Col::new(table_name, "failure_message"),
latest_text_output: __sdk::__query_builder::Col::new(table_name, "latest_text_output"),
latest_structured_payload_json: __sdk::__query_builder::Col::new(table_name, "latest_structured_payload_json"),
latest_structured_payload_json: __sdk::__query_builder::Col::new(
table_name,
"latest_structured_payload_json",
),
version: __sdk::__query_builder::Col::new(table_name, "version"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
started_at: __sdk::__query_builder::Col::new(table_name, "started_at"),
completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
@@ -104,10 +102,8 @@ impl __sdk::__query_builder::HasIxCols for AiTask {
status: __sdk::__query_builder::IxCol::new(table_name, "status"),
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
task_kind: __sdk::__query_builder::IxCol::new(table_name, "task_kind"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AiTask {}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -21,8 +16,6 @@ pub struct AiTextChunkAppendInput {
pub created_at_micros: i64,
}
impl __sdk::InModule for AiTextChunkAppendInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -22,8 +17,6 @@ pub struct AiTextChunkSnapshot {
pub created_at_micros: i64,
}
impl __sdk::InModule for AiTextChunkSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -2,14 +2,9 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::ai_text_chunk_type::AiTextChunk;
use super::ai_task_stage_kind_type::AiTaskStageKind;
use super::ai_text_chunk_type::AiTextChunk;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `ai_text_chunk`.
///
@@ -50,8 +45,12 @@ impl<'ctx> __sdk::Table for AiTextChunkTableHandle<'ctx> {
type Row = AiTextChunk;
type EventContext = super::EventContext;
fn count(&self) -> u64 { self.imp.count() }
fn iter(&self) -> impl Iterator<Item = AiTextChunk> + '_ { self.imp.iter() }
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = AiTextChunk> + '_ {
self.imp.iter()
}
type InsertCallbackId = AiTextChunkInsertCallbackId;
@@ -97,39 +96,40 @@ impl<'ctx> __sdk::TableWithPrimaryKey for AiTextChunkTableHandle<'ctx> {
}
}
/// Access to the `text_chunk_row_id` unique index on the table `ai_text_chunk`,
/// which allows point queries on the field of the same name
/// via the [`AiTextChunkTextChunkRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_text_chunk().text_chunk_row_id().find(...)`.
pub struct AiTextChunkTextChunkRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTextChunk, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
/// Access to the `text_chunk_row_id` unique index on the table `ai_text_chunk`,
/// which allows point queries on the field of the same name
/// via the [`AiTextChunkTextChunkRowIdUnique::find`] method.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.ai_text_chunk().text_chunk_row_id().find(...)`.
pub struct AiTextChunkTextChunkRowIdUnique<'ctx> {
imp: __sdk::UniqueConstraintHandle<AiTextChunk, String>,
phantom: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
impl<'ctx> AiTextChunkTableHandle<'ctx> {
/// Get a handle on the `text_chunk_row_id` unique index on the table `ai_text_chunk`.
pub fn text_chunk_row_id(&self) -> AiTextChunkTextChunkRowIdUnique<'ctx> {
AiTextChunkTextChunkRowIdUnique {
imp: self.imp.get_unique_constraint::<String>("text_chunk_row_id"),
phantom: std::marker::PhantomData,
}
}
impl<'ctx> AiTextChunkTableHandle<'ctx> {
/// Get a handle on the `text_chunk_row_id` unique index on the table `ai_text_chunk`.
pub fn text_chunk_row_id(&self) -> AiTextChunkTextChunkRowIdUnique<'ctx> {
AiTextChunkTextChunkRowIdUnique {
imp: self
.imp
.get_unique_constraint::<String>("text_chunk_row_id"),
phantom: std::marker::PhantomData,
}
}
}
impl<'ctx> AiTextChunkTextChunkRowIdUnique<'ctx> {
/// Find the subscribed row whose `text_chunk_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTextChunk> {
self.imp.find(col_val)
}
}
impl<'ctx> AiTextChunkTextChunkRowIdUnique<'ctx> {
/// Find the subscribed row whose `text_chunk_row_id` column value is equal to `col_val`,
/// if such a row is present in the client cache.
pub fn find(&self, col_val: &String) -> Option<AiTextChunk> {
self.imp.find(col_val)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table = client_cache.get_or_make_table::<AiTextChunk>("ai_text_chunk");
_table.add_unique_constraint::<String>("text_chunk_row_id", |row| &row.text_chunk_row_id);
}
@@ -139,26 +139,24 @@ pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<AiTextChunk>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse(
"TableUpdate<AiTextChunk>",
"TableUpdate",
).with_cause(e).into()
__sdk::InternalError::failed_parse("TableUpdate<AiTextChunk>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTextChunk`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_text_chunkQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTextChunk`.
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk>;
}
impl ai_text_chunkQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk> {
__sdk::__query_builder::Table::new("ai_text_chunk")
}
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `AiTextChunk`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait ai_text_chunkQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `AiTextChunk`.
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk>;
}
impl ai_text_chunkQueryTableAccess for __sdk::QueryTableAccessor {
fn ai_text_chunk(&self) -> __sdk::__query_builder::Table<AiTextChunk> {
__sdk::__query_builder::Table::new("ai_text_chunk")
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_task_stage_kind_type::AiTaskStageKind;
@@ -23,12 +18,10 @@ pub struct AiTextChunk {
pub created_at: __sdk::Timestamp,
}
impl __sdk::InModule for AiTextChunk {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `AiTextChunk`.
///
/// Provides typed access to columns for query building.
@@ -53,7 +46,6 @@ impl __sdk::__query_builder::HasCols for AiTextChunk {
sequence: __sdk::__query_builder::Col::new(table_name, "sequence"),
delta_text: __sdk::__query_builder::Col::new(table_name, "delta_text"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
}
}
}
@@ -72,10 +64,8 @@ impl __sdk::__query_builder::HasIxCols for AiTextChunk {
AiTextChunkIxCols {
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
text_chunk_row_id: __sdk::__query_builder::IxCol::new(table_name, "text_chunk_row_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for AiTextChunk {}

View File

@@ -2,23 +2,17 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::ai_text_chunk_append_input_type::AiTextChunkAppendInput;
use super::ai_task_procedure_result_type::AiTaskProcedureResult;
use super::ai_text_chunk_append_input_type::AiTextChunkAppendInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AppendAiTextChunkAndReturnArgs {
struct AppendAiTextChunkAndReturnArgs {
pub input: AiTextChunkAppendInput,
}
impl __sdk::InModule for AppendAiTextChunkAndReturnArgs {
type Module = super::RemoteModule;
}
@@ -28,16 +22,19 @@ impl __sdk::InModule for AppendAiTextChunkAndReturnArgs {
///
/// Implemented for [`super::RemoteProcedures`].
pub trait append_ai_text_chunk_and_return {
fn append_ai_text_chunk_and_return(&self, input: AiTextChunkAppendInput,
) {
self.append_ai_text_chunk_and_return_then(input, |_, _| {});
fn append_ai_text_chunk_and_return(&self, input: AiTextChunkAppendInput) {
self.append_ai_text_chunk_and_return_then(input, |_, _| {});
}
fn append_ai_text_chunk_and_return_then(
&self,
input: AiTextChunkAppendInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<AiTaskProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AiTaskProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -46,13 +43,17 @@ impl append_ai_text_chunk_and_return for super::RemoteProcedures {
&self,
input: AiTextChunkAppendInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<AiTaskProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<AiTaskProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
"append_ai_text_chunk_and_return",
AppendAiTextChunkAndReturnArgs { input, },
__callback,
);
self.imp
.invoke_procedure_with_callback::<_, AiTaskProcedureResult>(
"append_ai_text_chunk_and_return",
AppendAiTextChunkAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -2,23 +2,17 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::chapter_progression_ledger_input_type::ChapterProgressionLedgerInput;
use super::chapter_progression_procedure_result_type::ChapterProgressionProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ApplyChapterProgressionLedgerEntryAndReturnArgs {
struct ApplyChapterProgressionLedgerEntryAndReturnArgs {
pub input: ChapterProgressionLedgerInput,
}
impl __sdk::InModule for ApplyChapterProgressionLedgerEntryAndReturnArgs {
type Module = super::RemoteModule;
}
@@ -28,16 +22,22 @@ impl __sdk::InModule for ApplyChapterProgressionLedgerEntryAndReturnArgs {
///
/// Implemented for [`super::RemoteProcedures`].
pub trait apply_chapter_progression_ledger_entry_and_return {
fn apply_chapter_progression_ledger_entry_and_return(&self, input: ChapterProgressionLedgerInput,
) {
self.apply_chapter_progression_ledger_entry_and_return_then(input, |_, _| {});
fn apply_chapter_progression_ledger_entry_and_return(
&self,
input: ChapterProgressionLedgerInput,
) {
self.apply_chapter_progression_ledger_entry_and_return_then(input, |_, _| {});
}
fn apply_chapter_progression_ledger_entry_and_return_then(
&self,
input: ChapterProgressionLedgerInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<ChapterProgressionProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -46,13 +46,17 @@ impl apply_chapter_progression_ledger_entry_and_return for super::RemoteProcedur
&self,
input: ChapterProgressionLedgerInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<ChapterProgressionProcedureResult, __sdk::InternalError>) + Send + 'static,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ChapterProgressionProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp.invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>(
"apply_chapter_progression_ledger_entry_and_return",
ApplyChapterProgressionLedgerEntryAndReturnArgs { input, },
__callback,
);
self.imp
.invoke_procedure_with_callback::<_, ChapterProgressionProcedureResult>(
"apply_chapter_progression_ledger_entry_and_return",
ApplyChapterProgressionLedgerEntryAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::chapter_progression_ledger_input_type::ChapterProgressionLedgerInput;
@@ -19,10 +14,8 @@ pub(super) struct ApplyChapterProgressionLedgerEntryArgs {
impl From<ApplyChapterProgressionLedgerEntryArgs> for super::Reducer {
fn from(args: ApplyChapterProgressionLedgerEntryArgs) -> Self {
Self::ApplyChapterProgressionLedgerEntry {
input: args.input,
}
}
Self::ApplyChapterProgressionLedgerEntry { input: args.input }
}
}
impl __sdk::InModule for ApplyChapterProgressionLedgerEntryArgs {
@@ -40,9 +33,11 @@ pub trait apply_chapter_progression_ledger_entry {
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`apply_chapter_progression_ledger_entry:apply_chapter_progression_ledger_entry_then`] to run a callback after the reducer completes.
fn apply_chapter_progression_ledger_entry(&self, input: ChapterProgressionLedgerInput,
) -> __sdk::Result<()> {
self.apply_chapter_progression_ledger_entry_then(input, |_, _| {})
fn apply_chapter_progression_ledger_entry(
&self,
input: ChapterProgressionLedgerInput,
) -> __sdk::Result<()> {
self.apply_chapter_progression_ledger_entry_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `apply_chapter_progression_ledger_entry` to run as soon as possible,
@@ -55,9 +50,11 @@ pub trait apply_chapter_progression_ledger_entry {
&self,
input: ChapterProgressionLedgerInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -66,11 +63,15 @@ impl apply_chapter_progression_ledger_entry for super::RemoteReducers {
&self,
input: ChapterProgressionLedgerInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(ApplyChapterProgressionLedgerEntryArgs { input, }, callback)
self.imp.invoke_reducer_with_callback(
ApplyChapterProgressionLedgerEntryArgs { input },
callback,
)
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::inventory_mutation_input_type::InventoryMutationInput;
@@ -19,10 +14,8 @@ pub(super) struct ApplyInventoryMutationArgs {
impl From<ApplyInventoryMutationArgs> for super::Reducer {
fn from(args: ApplyInventoryMutationArgs) -> Self {
Self::ApplyInventoryMutation {
input: args.input,
}
}
Self::ApplyInventoryMutation { input: args.input }
}
}
impl __sdk::InModule for ApplyInventoryMutationArgs {
@@ -40,9 +33,8 @@ pub trait apply_inventory_mutation {
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`apply_inventory_mutation:apply_inventory_mutation_then`] to run a callback after the reducer completes.
fn apply_inventory_mutation(&self, input: InventoryMutationInput,
) -> __sdk::Result<()> {
self.apply_inventory_mutation_then(input, |_, _| {})
fn apply_inventory_mutation(&self, input: InventoryMutationInput) -> __sdk::Result<()> {
self.apply_inventory_mutation_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `apply_inventory_mutation` to run as soon as possible,
@@ -55,9 +47,11 @@ pub trait apply_inventory_mutation {
&self,
input: InventoryMutationInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -66,11 +60,13 @@ impl apply_inventory_mutation for super::RemoteReducers {
&self,
input: InventoryMutationInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(ApplyInventoryMutationArgs { input, }, callback)
self.imp
.invoke_reducer_with_callback(ApplyInventoryMutationArgs { input }, callback)
}
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::quest_signal_apply_input_type::QuestSignalApplyInput;
@@ -19,10 +14,8 @@ pub(super) struct ApplyQuestSignalArgs {
impl From<ApplyQuestSignalArgs> for super::Reducer {
fn from(args: ApplyQuestSignalArgs) -> Self {
Self::ApplyQuestSignal {
input: args.input,
}
}
Self::ApplyQuestSignal { input: args.input }
}
}
impl __sdk::InModule for ApplyQuestSignalArgs {
@@ -40,9 +33,8 @@ pub trait apply_quest_signal {
/// The reducer will run asynchronously in the future,
/// and this method provides no way to listen for its completion status.
/// /// Use [`apply_quest_signal:apply_quest_signal_then`] to run a callback after the reducer completes.
fn apply_quest_signal(&self, input: QuestSignalApplyInput,
) -> __sdk::Result<()> {
self.apply_quest_signal_then(input, |_, _| {})
fn apply_quest_signal(&self, input: QuestSignalApplyInput) -> __sdk::Result<()> {
self.apply_quest_signal_then(input, |_, _| {})
}
/// Request that the remote module invoke the reducer `apply_quest_signal` to run as soon as possible,
@@ -55,9 +47,11 @@ pub trait apply_quest_signal {
&self,
input: QuestSignalApplyInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -66,11 +60,13 @@ impl apply_quest_signal for super::RemoteReducers {
&self,
input: QuestSignalApplyInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp.invoke_reducer_with_callback(ApplyQuestSignalArgs { input, }, callback)
self.imp
.invoke_reducer_with_callback(ApplyQuestSignalArgs { input }, callback)
}
}

View File

@@ -2,13 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
@@ -19,13 +13,11 @@ pub struct AssetEntityBindingInput {
pub entity_id: String,
pub slot: String,
pub asset_kind: String,
pub owner_user_id: Option::<String>,
pub profile_id: Option::<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for AssetEntityBindingInput {
type Module = super::RemoteModule;
}

View File

@@ -2,12 +2,7 @@
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot;
@@ -15,12 +10,10 @@ use super::asset_entity_binding_snapshot_type::AssetEntityBindingSnapshot;
#[sats(crate = __lib)]
pub struct AssetEntityBindingProcedureResult {
pub ok: bool,
pub record: Option::<AssetEntityBindingSnapshot>,
pub error_message: Option::<String>,
pub record: Option<AssetEntityBindingSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for AssetEntityBindingProcedureResult {
type Module = super::RemoteModule;
}

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