Compare commits
3 Commits
4e1c0462a4
...
929febb4fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 929febb4fe | |||
| b355568189 | |||
| f65177b147 |
2
.idea/.name
generated
2
.idea/.name
generated
@@ -1 +1 @@
|
||||
PreGameSelectionFlow.tsx
|
||||
mod.rs
|
||||
@@ -0,0 +1,26 @@
|
||||
# 移动端创作页新建作品紧凑布局设计
|
||||
|
||||
## 目标
|
||||
|
||||
移动端创作页顶部的新建作品模块只承担快速进入创作模板的作用,不承担规则解释和长说明承载。模块在首屏中最多占用约 1/3 高度,把更多空间留给作品列表和筛选操作。
|
||||
|
||||
## 落地范围
|
||||
|
||||
- 入口组件:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`
|
||||
- 外层页面:`src/components/custom-world-home/CustomWorldCreationHub.tsx`
|
||||
- 模板元数据继续复用 `PLATFORM_CREATION_TYPES`,不新增前端业务逻辑。
|
||||
|
||||
## 移动端布局规则
|
||||
|
||||
1. 顶部标题行压缩成单行:左侧标题,右侧仅保留简短状态,不再显示说明段落。
|
||||
2. 模板入口在手机端使用横向滚动胶囊卡片,每个卡片保持单行动作感,不堆叠成长列表。
|
||||
3. 卡片高度控制在约 4rem 内,标题与状态信息并排组织,避免大留白。
|
||||
4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。
|
||||
5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。
|
||||
|
||||
## 文案约束
|
||||
|
||||
- UI 不新增规则说明类文案。
|
||||
- 原有“直接选择游戏创作模板,立刻进入对应的共创工作台。”说明在移动端隐藏,桌面端保留为辅助说明。
|
||||
- 锁定、可创建、正在开启等状态继续来自既有模板元数据或忙碌状态。
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 平台入口分类与创作 Tab 强化设计
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不新建平台入口系统的前提下,直接扩展现有 `RpgEntryHomeView` 主 Tab:
|
||||
|
||||
- 新增“分类” Tab,用作品标签聚合所有公开发布作品。
|
||||
- 强化“创作” Tab 的导航视觉权重,让它在底部导航中居中并更醒目。
|
||||
- 登录态底部导航顺序为:首页、分类、创作、存档、我的。
|
||||
- 未登录态底部导航只保留:首页、创作、分类,其中创作保持居中。
|
||||
|
||||
## 2. 数据边界
|
||||
|
||||
本次只做前端展示重排,不新增后端接口:
|
||||
|
||||
- 分类数据来源使用现有 `latestEntries` 与 `featuredEntries` 的公开作品列表。
|
||||
- 标签来源沿用 `buildPlatformWorldTags(entry)`,公开作品会映射为题材、角色数、地标数。
|
||||
- 同一公开作品若同时出现在精选与最新中,按 `ownerUserId + profileId` 去重。
|
||||
- 点击分类作品继续走现有 `onOpenGalleryDetail`,不改变详情页和登录拦截逻辑。
|
||||
|
||||
## 3. 交互规则
|
||||
|
||||
### 3.1 登录态
|
||||
|
||||
底部导航展示 5 个入口:
|
||||
|
||||
1. 首页
|
||||
2. 分类
|
||||
3. 创作
|
||||
4. 存档
|
||||
5. 我的
|
||||
|
||||
创作入口位于第三位,视觉上使用更大的图标壳、轻微上浮、渐变高亮和阴影,保证它是主行动入口。
|
||||
|
||||
### 3.2 未登录态
|
||||
|
||||
底部导航展示 3 个入口:
|
||||
|
||||
1. 首页
|
||||
2. 创作
|
||||
3. 分类
|
||||
|
||||
不展示“存档”和“我的”,避免未登录用户在底部导航看到必须登录后才有价值的入口。创作入口位于第二位,保持几何居中。
|
||||
|
||||
### 3.3 桌面端
|
||||
|
||||
桌面侧栏同步增加“分类”,但保持纵向导航,不强行做居中布局。创作入口仍使用强调样式。
|
||||
|
||||
## 4. 分类页布局
|
||||
|
||||
分类页为独立 Tab 面板,不在首页下方展开:
|
||||
|
||||
- 顶部展示标签胶囊,默认选中作品数量最多的标签。
|
||||
- 标签切换后,下方网格展示该标签下所有公开作品。
|
||||
- 无公开作品时展示现有空状态组件。
|
||||
- 分类页不写玩法规则说明类长文案,只保留必要标题、短状态文案和作品卡片。
|
||||
|
||||
## 5. 验收点
|
||||
|
||||
- 登录态移动端底部导航顺序准确,创作在 5 个 Tab 中居中。
|
||||
- 未登录态移动端底部导航只显示 3 个 Tab,创作在中间。
|
||||
- 分类 Tab 能按标签切换并展示公开作品。
|
||||
- 创作 Tab 在移动端和桌面端都比普通 Tab 更醒目。
|
||||
- 不修改 server-node,不新增后端逻辑。
|
||||
@@ -9,6 +9,8 @@
|
||||
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
|
||||
- [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。
|
||||
- [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。
|
||||
- [MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md](./MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md):移动端创作页新建作品模块最多占用首屏约 1/3 高度的紧凑布局设计。
|
||||
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
|
||||
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
|
||||
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
|
||||
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。
|
||||
|
||||
@@ -1217,8 +1217,9 @@ Phase 4 本轮已完成以下主链接线:
|
||||
- published works 明确输出 `canEnterWorld=true`
|
||||
4. 前端 Agent 结果页已开始消费服务端 Phase4 状态:
|
||||
- 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界”
|
||||
- 结果页会展示服务端 preview source、publish blockers、warning 数量
|
||||
- 有 blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界
|
||||
- 结果页会消费服务端 gate 语义,但不再把 preview source 做成底部常驻提示
|
||||
- publish blockers 改为点击“发布并进入世界”时,通过独立面板提示
|
||||
- warning 数量仍可作为非阻断摘要展示
|
||||
5. `useRpgCreationEnterWorld.ts` 与 `RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成:
|
||||
- 先 `sync_result_profile`
|
||||
- 再执行后端 `publish_world`
|
||||
|
||||
@@ -83,3 +83,31 @@ cargo check -p spacetime-module
|
||||
```
|
||||
|
||||
结果:通过。`spacetime-module` 仅保留仓库既有 glob re-export warning。
|
||||
|
||||
## 2026-04-24 追加:结果页删除与资产动作闭环
|
||||
|
||||
本次继续补齐除长尾补全外的结果页可见动作:
|
||||
|
||||
1. `delete_characters` / `delete_landmarks` 已由 Rust SpacetimeDB reducer 直接更新 `draft_profile_json`、`result_preview_json`、`publish_gate_json`、`checkpoints_json`、`custom_world_draft_card`、`custom_world_agent_operation` 与 `custom_world_agent_message`。
|
||||
2. `generate_characters` 增加 `roleType`,可扮演角色写入 `playableNpcs`,场景角色写入 `storyNpcs`,不再把可扮演角色落到场景角色列表。
|
||||
3. `generate_role_assets` / `generate_scene_assets` 不再走占位动作,Rust 会校验目标对象、切到 `visual_refining`、设置 `focus_card_id`,并记录 operation/message。
|
||||
4. `sync_role_assets` / `sync_scene_assets` 已迁移 Node 的 profile 字段写回逻辑:角色写回 `imageSrc`、`generatedVisualAssetId`、`generatedAnimationSetId`、`animationMap`;场景写回 `imageSrc`、`generatedSceneAssetId`、`generatedScenePrompt`、`generatedSceneModel`,并同步 `sceneChapters.acts` 背景字段。
|
||||
5. 前端结果页角色卡展示“生成资产”,场景卡展示“生成场景图”,均通过 `autosaveCoordinator.executeAgentActionAndWait` 调 Rust API 并用最新 session 重建预览。
|
||||
|
||||
本轮仍不迁移 `expand_long_tail`,保持后续单独设计。
|
||||
|
||||
## 2026-04-24 追加:创作 Tab 删除作品入口
|
||||
|
||||
用户在 `http://127.0.0.1:3000/` 的“创作”Tab 看不到删除作品入口,原因是 `RpgEntryHomeView` 的 `CreationLibraryCard` 只支持整卡打开详情,没有接收删除回调。已补齐:
|
||||
|
||||
1. `CreationLibraryCard` 右上角展示“删除”按钮,点击时阻止整卡打开详情。
|
||||
2. `RpgEntryHomeView` 新增 `onDeleteLibraryEntry` 与 `deletingLibraryEntryId` props。
|
||||
3. `PlatformEntryFlowShellImpl` 复用 `deleteRpgEntryWorldProfile`,删除后刷新我的作品列表与公开广场。
|
||||
|
||||
链路保持为:前端创作 Tab -> `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除 profile / 移除 gallery 读模型。
|
||||
|
||||
## 2026-04-24 追加:创作 Hub 草稿删除入口修正
|
||||
|
||||
截图中的“创作”Tab 实际渲染的是 `CustomWorldCreationHub` / `CustomWorldWorkCard`,不是默认 `RpgEntryHomeView` 的 `CreationLibraryCard`。此前 Hub 只给 `status=published` 的 RPG 作品传入删除回调,导致草稿卡片没有“删除”按钮。
|
||||
|
||||
修正后:只要 RPG 创作条目存在 `profileId`,无论 `draft` 还是 `published`,都会在卡片底部动作区展示“删除”。删除继续复用 `PlatformEntryFlowShellImpl.handleDeletePublishedWork`,走 `deleteRpgEntryWorldProfile` -> Rust runtime API -> SpacetimeDB 软删除。
|
||||
|
||||
@@ -52,3 +52,34 @@ cargo test -p api-server custom_world_foundation_draft --no-default-features
|
||||
```
|
||||
|
||||
结果:`3 passed`。
|
||||
|
||||
## 2026-04-24 进度链路补齐
|
||||
|
||||
本次继续补齐“点击生成世界草稿”后的异步执行方式,避免 HTTP 请求阻塞到全部 LLM 调用结束才返回:
|
||||
|
||||
1. `execute_custom_world_agent_action(draft_foundation)` 现在先创建 `draft_foundation` running operation,并立即把 `operationId` 返回给前端。
|
||||
2. API 后台任务继续执行 Node 同序多阶段生成;前端已有的 operation polling 可以持续读取阶段进度。
|
||||
3. Rust 生成器按 Node 的 `onProgress` 节点写入:
|
||||
- `12`:整理世界骨架。
|
||||
- `16-30`:生成可扮演角色。
|
||||
- `30-44`:生成场景角色。
|
||||
- `44-56`:生成关键场景。
|
||||
- `56-66`:建立场景连接。
|
||||
- `66-76`:补全可扮演角色叙事基础。
|
||||
- `76-84`:补全可扮演角色档案细节。
|
||||
- `84-92`:补全场景角色叙事基础。
|
||||
- `92-96`:补全场景角色档案细节。
|
||||
- `97`:编译世界底稿。
|
||||
- `98`:编译草稿卡。
|
||||
4. SpacetimeDB 新增 `upsert_custom_world_agent_operation_progress`,只更新/创建 operation 进度,不插入聊天消息、不推进 turn,专门承接生成中的阶段进度。
|
||||
5. 最终落库仍复用 `execute_custom_world_agent_action(draft_foundation)`,但允许复用同一个 running operation 完成写入,避免中间断点和重复 operation。
|
||||
|
||||
补充验证:
|
||||
|
||||
```bash
|
||||
cargo check -p api-server
|
||||
cargo check -p spacetime-module
|
||||
cargo test -p api-server custom_world_foundation_draft -- --nocapture
|
||||
```
|
||||
|
||||
结果:后端检查通过;`custom_world_foundation_draft` 相关测试 `3 passed`。
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
## 1. 目标边界
|
||||
|
||||
本次迭代开放账号密码登录、登录后修改密码、手机号验证码重置密码,但不开放密码注册。
|
||||
本次迭代开放手机号密码登录、登录后修改密码、手机号验证码重置密码,但不开放独立注册页面。
|
||||
|
||||
1. 新用户只能通过手机号验证码完成注册与首次登录。
|
||||
2. 已有用户可以在登录后设置或修改密码。
|
||||
3. 忘记密码时,只能通过已绑定手机号验证码重置密码。
|
||||
4. 密码登录只校验已存在且已设置密码的账号,不自动创建新账号。
|
||||
4. 密码登录只校验已存在且已设置密码的手机号账号,不自动创建新账号。
|
||||
5. 登录面板本地缓存最近一次成功登录的手机号,只用于回填手机号输入框,不缓存密码或验证码。
|
||||
|
||||
## 2. 接口设计
|
||||
|
||||
@@ -17,10 +18,11 @@
|
||||
|
||||
沿用现有 `POST /api/auth/entry`:
|
||||
|
||||
1. 请求字段:`username`、`password`。
|
||||
2. 用户不存在时返回 `401`,不创建账号。
|
||||
3. 用户存在但未设置密码时返回 `401`。
|
||||
4. 校验成功后签发 access token,并写入 refresh cookie。
|
||||
1. 请求字段沿用 `username`、`password`,但前端固定把手机号填入 `username`。
|
||||
2. 后端优先按标准手机号归一化后查找账号,兼容历史用户名只作为开发游客兜底能力。
|
||||
3. 手机号不存在时返回 `401`,不创建账号。
|
||||
4. 手机号存在但未设置密码时返回 `401`。
|
||||
5. 校验成功后签发 access token,并写入 refresh cookie。
|
||||
|
||||
### 2.2 修改密码
|
||||
|
||||
@@ -46,14 +48,24 @@
|
||||
|
||||
复用 `POST /api/auth/phone/send-code`,`scene` 增加 `reset_password`。
|
||||
|
||||
### 2.5 验证码注册/登录
|
||||
|
||||
复用现有 `POST /api/auth/phone/login`:
|
||||
|
||||
1. 请求字段:`phone`、`code`。
|
||||
2. 验证码校验成功后,若手机号已绑定账号,则直接完成登录。
|
||||
3. 验证码校验成功后,若手机号没有账号信息,则后端自动创建手机号账号,再完成登录。
|
||||
4. 自动创建账号默认不设置用户可用密码,用户后续可在账号设置或忘记密码流程设置密码。
|
||||
|
||||
## 3. 前端交互
|
||||
|
||||
登录弹窗拆成两个页签:
|
||||
登录弹窗不再拆独立注册页签:
|
||||
|
||||
1. `登录`:提供密码登录、手机号验证码登录、忘记密码入口。
|
||||
2. `注册`:只提供手机号验证码注册/登录,不提供账号密码注册。
|
||||
3. `忘记密码`:从登录页进入独立重置面板,不在当前表单下方展开。
|
||||
4. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。
|
||||
1. 面板直接展示手机号和密码输入,用于已设置密码账号登录。
|
||||
2. 登录按钮文本固定为 `注册/登录`,避免用户在登录和首次进入之间做页面切换。
|
||||
3. 忘记密码入口显示在登录按钮右下侧,点击后仍进入独立重置面板,不在当前表单下方展开。
|
||||
4. 同一面板保留手机号验证码注册/登录能力,用于新用户自动注册和已注册用户免密码登录。
|
||||
5. 账号设置面板提供密码修改入口;未设置密码的账号显示为设置密码。
|
||||
|
||||
## 4. 数据约束
|
||||
|
||||
@@ -63,4 +75,4 @@
|
||||
2. 微信待绑定账号默认没有用户可用密码。
|
||||
3. 只有用户显式修改或重置密码后,才允许密码登录。
|
||||
|
||||
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力。
|
||||
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
已改为:
|
||||
|
||||
补充修复:`RpgCreationResultViewImpl` 已补齐 `previewSourceLabel` props 解构,避免 Agent 结果页在渲染数据源提示时触发 `ReferenceError`。
|
||||
|
||||
```text
|
||||
Agent 结果页点击新增场景角色 / 新增场景
|
||||
-> RpgCreationResultView.onGenerateEntity
|
||||
@@ -26,26 +28,28 @@ Agent 结果页点击新增场景角色 / 新增场景
|
||||
-> 刷新结果页 profile
|
||||
```
|
||||
|
||||
说明:当前可扮演角色 tab 的“新增可扮演角色”也会调用 `generate_characters`,后端现阶段会追加到 `storyNpcs`。因此严格意义上的“新增可扮演角色”仍未完整迁移,需要后续给 action 增加角色类型参数或新增 `generate_playable_characters`。
|
||||
说明:当前可扮演角色 tab 的“新增可扮演角色”会调用 `generate_characters` 并传入 `roleType=playable`,Rust 会写入 `draftProfile.playableNpcs`;场景角色则写入 `draftProfile.storyNpcs`。
|
||||
|
||||
## 已迁移 / 可见
|
||||
|
||||
1. 删除作品:已有 Rust + SpacetimeDB 软删除链路。
|
||||
2. 新增场景角色:结果页可见,调用 Rust `generate_characters`。
|
||||
3. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks`。
|
||||
4. Agent 结果页发布进入世界:已有 `publish_world` + publish gate 链路。
|
||||
5. 手动编辑结果页 profile:目前仍通过 `sync_result_profile` 自动保存回 Agent session。
|
||||
2. 新增可扮演角色:结果页可见,调用 Rust `generate_characters(roleType=playable)` 并写入 `playableNpcs`。
|
||||
3. 新增场景角色:结果页可见,调用 Rust `generate_characters(roleType=story)` 并写入 `storyNpcs`。
|
||||
4. 新增场景 / 地点:结果页可见,调用 Rust `generate_landmarks` 并写入 `landmarks`。
|
||||
5. 批量删除场景角色:结果页可见,调用 Rust `delete_characters`,同步删除 profile 与 draft card。
|
||||
6. 批量删除场景:结果页可见,调用 Rust `delete_landmarks`,同步删除 profile、连接与 draft card。
|
||||
7. 角色资产准备:结果页角色卡可见“生成资产”,调用 Rust `generate_role_assets`,进入 `visual_refining` 并聚焦角色。
|
||||
8. 场景资产准备:结果页场景卡可见“生成场景图”,调用 Rust `generate_scene_assets`,进入 `visual_refining` 并聚焦场景。
|
||||
9. 角色资产同步:Rust `sync_role_assets` 会把 `portraitPath / generatedVisualAssetId / generatedAnimationSetId / animationMap` 写入 profile、draft card、asset coverage、preview、checkpoint、operation 与 message。
|
||||
10. 场景资产同步:Rust `sync_scene_assets` 会把 `imageSrc / generatedSceneAssetId / prompt / model` 写入 camp 或 landmark,并同步 `sceneChapters.acts`、draft card、asset coverage、preview、checkpoint、operation 与 message。
|
||||
11. Agent 结果页发布进入世界:已有 `publish_world` + publish gate 链路。
|
||||
12. 手动编辑结果页 profile:目前仍通过 `sync_result_profile` 自动保存回 Agent session。
|
||||
|
||||
## 尚未完整迁移的结果页编辑功能
|
||||
|
||||
1. 新增可扮演角色:前端有入口,但 Rust action 暂无角色类型区分,当前会落到 `storyNpcs`。
|
||||
2. 批量删除场景角色:前端只改本地 profile,再靠 `sync_result_profile` 同步,不是独立 Rust action。
|
||||
3. 批量删除场景:前端只改本地 profile,再靠 `sync_result_profile` 同步,不是独立 Rust action。
|
||||
4. 单个角色 / 场景的细粒度编辑:前端 modal 仍编辑本地 profile,再靠 `sync_result_profile` 同步;SpacetimeDB 虽有 `update_draft_card`,但结果页表单尚未按 card section action 化。
|
||||
5. 角色资产生成:`generate_role_assets / sync_role_assets` Rust 侧仍是 placeholder 或外部链路未完全接入结果页。
|
||||
6. 场景资产生成:`generate_scene_assets / sync_scene_assets` Rust 侧仍是 placeholder 或外部链路未完全接入结果页。
|
||||
7. 长尾补全:`expand_long_tail` Rust 侧仍是 placeholder。
|
||||
8. 回滚 checkpoint:Rust 有 `revert_checkpoint`,但结果页没有清晰可见入口。
|
||||
1. 单个角色 / 场景的细粒度编辑:前端 modal 仍编辑本地 profile,再靠 `sync_result_profile` 同步;SpacetimeDB 虽有 `update_draft_card`,但结果页表单尚未按 card section action 化。
|
||||
2. 长尾补全:`expand_long_tail` 本轮明确排除,Rust 侧仍是 placeholder。
|
||||
3. 回滚 checkpoint:Rust 有 `revert_checkpoint`,但结果页没有清晰可见入口。
|
||||
|
||||
## 下一步建议
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# RPG 创作与资产提示词脚本抽离方案(2026-04-24)
|
||||
|
||||
## 背景
|
||||
`server-rs/crates/api-server` 中 RPG 创作链路已经承接草稿生成、结果页补角色/补场景、场景图、角色图与角色动作生成。此前提示词散落在路由处理文件中,导致玩法规则、资产规则与结果页生成规则混杂,后续迭代容易出现落地漂移。
|
||||
|
||||
## 落地边界
|
||||
本次只调整 Rust 后端 `api-server` 内的提示词组织,不兼容 `server-node`,也不改动前端展示文案。
|
||||
|
||||
## 模块拆分
|
||||
1. `custom_world_rpg_draft_prompts.rs`
|
||||
- 承载 RPG 玩法草稿生成相关提示词。
|
||||
- 覆盖八锚点共创主提示词、状态识别提示词、模式规则、用户输入信号规则、上下文渲染。
|
||||
- `custom_world_agent_turn.rs` 只保留流程编排、LLM 调用和结果规范化。
|
||||
|
||||
2. `custom_world_asset_prompts.rs`
|
||||
- 承载生图、生动作相关提示词。
|
||||
- 覆盖角色主图提示词、角色主图负面提示词、角色动作视频/序列帧提示词、动作兜底安全提示词。
|
||||
- 场景图和封面图提示词也属于自定义世界资产提示词,统一迁入该模块。
|
||||
|
||||
3. `custom_world_result_prompts.rs`
|
||||
- 承载结果页新增实体相关提示词。
|
||||
- 覆盖新增可扮演角色、新增场景角色、新增场景的 LLM system/user prompt 构造。
|
||||
- 路由层继续负责 fallback 与返回结构,提示词模块只负责生成可审计的 prompt 文本。
|
||||
|
||||
## 约束
|
||||
- 提示词模块只做纯函数拼装,不访问网络、文件、数据库或 SpacetimeDB。
|
||||
- 保留原中文提示词语义,不把中文改写成英文。
|
||||
- 原有 fallback 行为不变:LLM 不可用或解析失败时仍回退本地生成。
|
||||
- 仅做局部迁移,避免整文件重写导致中文编码风险。
|
||||
|
||||
## 验证
|
||||
- `cargo test -p api-server` 应能通过或至少完成编译阶段。
|
||||
- 既有单元测试中关于角色图、动作结果 payload、场景图请求的断言应保持不变。
|
||||
@@ -43,7 +43,12 @@ use shared_contracts::assets::{
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
api_response::json_success_body,
|
||||
custom_world_asset_prompts::{
|
||||
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
@@ -1713,310 +1718,11 @@ fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAsset
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_animation_prompt(
|
||||
strategy: &CharacterAnimationStrategy,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
animation: &str,
|
||||
frame_count: u32,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
match strategy {
|
||||
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => {
|
||||
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
|
||||
}
|
||||
CharacterAnimationStrategy::MotionTransfer
|
||||
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
fps,
|
||||
duration_seconds,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_sequence_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
frame_count: u32,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!(
|
||||
"同一角色连续 {} 帧动作序列,动作主题是 {}。",
|
||||
frame_count, animation
|
||||
),
|
||||
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
|
||||
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
|
||||
if use_chroma_key {
|
||||
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景尽量纯净,避免复杂场景。".to_string()
|
||||
},
|
||||
prompt_text.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_npc_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
) -> String {
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let loop_rule = if loop_ {
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
|
||||
.to_string()
|
||||
} else if animation == "die" {
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
|
||||
.to_string()
|
||||
} else {
|
||||
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
template.animation
|
||||
),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
format!("动作补充:{}。", template.prompt_suffix),
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
[
|
||||
format!("单人 NPC 全身动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
action_detail_text
|
||||
},
|
||||
format!(
|
||||
"目标帧率 {} fps,时长约 {} 秒。",
|
||||
fps.clamp(1, 60),
|
||||
duration_seconds.clamp(1, 8)
|
||||
),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_ark_character_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
|
||||
let normalized_animation_name = if normalized_animation_name.is_empty() {
|
||||
"idle".to_string()
|
||||
} else {
|
||||
normalized_animation_name
|
||||
};
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let frame_rule = if loop_ {
|
||||
"首帧严格使用图片1,尾帧严格使用图片2,循环动作必须自然闭环,不要静止开场。".to_string()
|
||||
} else {
|
||||
"首帧严格使用图片1,尾帧严格使用图片2,中段完成完整动作变化,收束干净。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id.and_then(find_motion_template) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作英文名是 {}。角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
normalized_animation_name
|
||||
),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
format!("动作补充:{}。", template.prompt_suffix),
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
frame_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
[
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作英文名是 {}。",
|
||||
normalized_animation_name
|
||||
),
|
||||
"角色固定为图片1和图片2中的同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。"
|
||||
.to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移,不要多角色,不要镜头切换。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
frame_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_fallback_moderation_safe_animation_prompt(
|
||||
animation: &str,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
|
||||
if loop_ {
|
||||
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
|
||||
} else {
|
||||
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
|
||||
},
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净。".to_string()
|
||||
},
|
||||
]
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
|
||||
value
|
||||
.replace(char::is_whitespace, " ")
|
||||
.replace("血浆", "")
|
||||
.replace("喷血", "")
|
||||
.replace("鲜血", "")
|
||||
.replace("断肢", "")
|
||||
.replace("斩首", "")
|
||||
.replace("裸体", "")
|
||||
.replace("裸露", "")
|
||||
.replace("色情", "")
|
||||
.replace("性交", "")
|
||||
.replace("死亡", "倒地结束")
|
||||
.replace("死去", "倒地结束")
|
||||
.replace("击杀", "倒地结束")
|
||||
.replace("受击", "失衡")
|
||||
.replace("受伤", "失衡")
|
||||
.replace("砍杀", "挥击")
|
||||
.replace("斩击", "挥击")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
|
||||
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.split(['/', '|', '\n', ',', ',', '。', ';', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
|
||||
pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
|
||||
BUILT_IN_MOTION_TEMPLATES
|
||||
.iter()
|
||||
.find(|template| template.id == id.trim())
|
||||
}
|
||||
|
||||
fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest) -> String {
|
||||
let candidate = match payload.strategy {
|
||||
CharacterAnimationStrategy::ImageSequence => payload.image_sequence_model.as_str(),
|
||||
@@ -3486,12 +3192,12 @@ fn character_animation_error_response(
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
struct MotionTemplate {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
animation: &'static str,
|
||||
prompt_suffix: &'static str,
|
||||
notes: &'static str,
|
||||
pub(crate) struct MotionTemplate {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) label: &'static str,
|
||||
pub(crate) animation: &'static str,
|
||||
pub(crate) prompt_suffix: &'static str,
|
||||
pub(crate) notes: &'static str,
|
||||
}
|
||||
|
||||
impl MotionTemplate {
|
||||
@@ -3677,6 +3383,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub(crate) fn find_motion_template(id: &str) -> Option<&'static MotionTemplate> {
|
||||
BUILT_IN_MOTION_TEMPLATES
|
||||
.iter()
|
||||
.find(|template| template.id == id.trim())
|
||||
}
|
||||
fn resolve_character_animation_model_uses_strategy_specific_field() {
|
||||
let payload = CharacterAnimationGenerateRequest {
|
||||
character_id: "hero".to_string(),
|
||||
|
||||
@@ -32,7 +32,12 @@ use shared_contracts::assets::{
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
api_response::json_success_body,
|
||||
custom_world_asset_prompts::{
|
||||
build_character_visual_negative_prompt, build_character_visual_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
use tokio::time::sleep;
|
||||
@@ -671,58 +676,8 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
|
||||
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
|
||||
if merged.is_empty() {
|
||||
"自定义世界角色,服装完整,姿态自然。"
|
||||
} else {
|
||||
merged.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn build_character_visual_negative_prompt() -> String {
|
||||
[
|
||||
"正面视角",
|
||||
"左朝向",
|
||||
"完全 90 度纯右视图",
|
||||
"镜头透视",
|
||||
"半身像",
|
||||
"脚被裁切",
|
||||
"头顶被裁切",
|
||||
"多角色",
|
||||
"复杂背景",
|
||||
"建筑场景",
|
||||
"漂浮物",
|
||||
"烟雾环境",
|
||||
"武器消失",
|
||||
"武器换手",
|
||||
"额外手臂",
|
||||
"额外腿",
|
||||
"服装变化",
|
||||
"脸部变化",
|
||||
"模糊",
|
||||
"运动模糊",
|
||||
"文字",
|
||||
"水印",
|
||||
"UI 元素",
|
||||
"软萌 Q版大头贴",
|
||||
"儿童绘本风",
|
||||
"厚涂插画感",
|
||||
"低对比柔边",
|
||||
]
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, AppError> {
|
||||
// Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。
|
||||
let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
@@ -752,7 +707,6 @@ fn require_dashscope_settings(state: &AppState) -> Result<DashScopeSettings, App
|
||||
request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result<reqwest::Client, AppError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(settings.request_timeout_ms))
|
||||
|
||||
@@ -29,10 +29,10 @@ use shared_kernel::build_prefixed_uuid_id;
|
||||
use spacetime_client::{
|
||||
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
||||
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
|
||||
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||
@@ -925,39 +925,44 @@ pub async fn execute_custom_world_agent_action(
|
||||
})),
|
||||
));
|
||||
}
|
||||
let draft_result = generate_custom_world_foundation_draft(llm_client, &session)
|
||||
let operation_id = build_prefixed_uuid_id("operation-");
|
||||
let operation = state
|
||||
.spacetime_client()
|
||||
.upsert_custom_world_agent_operation_progress(
|
||||
CustomWorldAgentOperationProgressRecordInput {
|
||||
session_id: session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: operation_id.clone(),
|
||||
operation_type: "draft_foundation".to_string(),
|
||||
operation_status: "running".to_string(),
|
||||
phase_label: "整理世界骨架".to_string(),
|
||||
phase_detail: "正在校验已确认锚点,并准备第一版世界框架生成链路。"
|
||||
.to_string(),
|
||||
operation_progress: 12,
|
||||
error_message: None,
|
||||
updated_at_micros: submitted_at_micros,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|message| {
|
||||
.map_err(|error| {
|
||||
custom_world_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "custom-world-agent",
|
||||
"message": message,
|
||||
})),
|
||||
map_custom_world_client_error(error),
|
||||
)
|
||||
})?;
|
||||
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,
|
||||
})),
|
||||
)
|
||||
})?
|
||||
spawn_custom_world_draft_foundation_job(
|
||||
state.clone(),
|
||||
session,
|
||||
owner_user_id,
|
||||
operation_id,
|
||||
payload,
|
||||
);
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
json!({
|
||||
"operation": map_custom_world_agent_operation_response(operation),
|
||||
}),
|
||||
));
|
||||
} else {
|
||||
let generation_result =
|
||||
generate_custom_world_agent_entities(llm_client, &session, &payload)
|
||||
@@ -1021,6 +1026,177 @@ pub async fn execute_custom_world_agent_action(
|
||||
))
|
||||
}
|
||||
|
||||
fn spawn_custom_world_draft_foundation_job(
|
||||
state: AppState,
|
||||
session: CustomWorldAgentSessionRecord,
|
||||
owner_user_id: String,
|
||||
operation_id: String,
|
||||
payload: ExecuteCustomWorldAgentActionRequest,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let Some(llm_client) = state.llm_client().cloned() else {
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"底稿生成失败",
|
||||
"服务端尚未配置可用的 LLM API Key",
|
||||
100,
|
||||
Some("服务端尚未配置可用的 LLM API Key".to_string()),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
|
||||
let progress_state = state.clone();
|
||||
let progress_session_id = session.session_id.clone();
|
||||
let progress_owner_user_id = owner_user_id.clone();
|
||||
let progress_operation_id = operation_id.clone();
|
||||
let draft_result =
|
||||
generate_custom_world_foundation_draft(&llm_client, &session, move |progress| {
|
||||
let progress_state = progress_state.clone();
|
||||
let session_id = progress_session_id.clone();
|
||||
let owner_user_id = progress_owner_user_id.clone();
|
||||
let operation_id = progress_operation_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&progress_state,
|
||||
&session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"running",
|
||||
progress.phase_label.as_str(),
|
||||
progress.phase_detail.as_str(),
|
||||
progress.progress,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.await;
|
||||
|
||||
let draft_result = match draft_result {
|
||||
Ok(result) => result,
|
||||
Err(message) => {
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"底稿生成失败",
|
||||
message.clone().as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"running",
|
||||
"编译草稿卡",
|
||||
"正在把世界底稿整理成可浏览的卡片摘要和详情结构。",
|
||||
98,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let payload_json = match build_draft_foundation_action_payload_json(
|
||||
&payload,
|
||||
&draft_result.draft_profile_json,
|
||||
) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let message = match error {
|
||||
DraftFoundationPayloadError::SerializePayload(message) => message,
|
||||
DraftFoundationPayloadError::InvalidPayloadShape => {
|
||||
"action payload 必须是 object".to_string()
|
||||
}
|
||||
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message,
|
||||
};
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"底稿写入失败",
|
||||
message.clone().as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = state
|
||||
.spacetime_client()
|
||||
.execute_custom_world_agent_action(CustomWorldAgentActionExecuteRecordInput {
|
||||
session_id: session.session_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
operation_id: operation_id.clone(),
|
||||
action: "draft_foundation".to_string(),
|
||||
payload_json: Some(payload_json),
|
||||
submitted_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
let message = error.to_string();
|
||||
let _ = upsert_custom_world_draft_foundation_progress(
|
||||
&state,
|
||||
&session.session_id,
|
||||
&owner_user_id,
|
||||
&operation_id,
|
||||
"failed",
|
||||
"底稿写入失败",
|
||||
message.clone().as_str(),
|
||||
100,
|
||||
Some(message),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn upsert_custom_world_draft_foundation_progress(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
operation_id: &str,
|
||||
status: &str,
|
||||
phase_label: &str,
|
||||
phase_detail: &str,
|
||||
progress: u32,
|
||||
error_message: Option<String>,
|
||||
) -> Result<CustomWorldAgentOperationRecord, SpacetimeClientError> {
|
||||
state
|
||||
.spacetime_client()
|
||||
.upsert_custom_world_agent_operation_progress(
|
||||
CustomWorldAgentOperationProgressRecordInput {
|
||||
session_id: session_id.to_string(),
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
operation_id: operation_id.to_string(),
|
||||
operation_type: "draft_foundation".to_string(),
|
||||
operation_status: status.to_string(),
|
||||
phase_label: phase_label.to_string(),
|
||||
phase_detail: phase_detail.to_string(),
|
||||
operation_progress: progress.min(100),
|
||||
error_message,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn map_custom_world_library_entry_response(
|
||||
entry: CustomWorldLibraryEntryRecord,
|
||||
) -> CustomWorldLibraryEntryResponse {
|
||||
|
||||
@@ -5,6 +5,14 @@ use module_custom_world::{
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value as JsonValue, json};
|
||||
|
||||
use crate::custom_world_rpg_draft_prompts::{
|
||||
BASE_SYSTEM_PROMPT, GLOBAL_HARD_RULES, OUTPUT_CONTRACT_REMINDER, QUICK_FILL_EXTRA_RULES,
|
||||
STATE_INFERENCE_OUTPUT_CONTRACT, STATE_INFERENCE_SYSTEM_PROMPT,
|
||||
extract_reply_text_from_partial_json, mode_rules, parse_conversation_mode, parse_drift_risk,
|
||||
parse_json_response_text, parse_user_input_signal, render_chat_history_context,
|
||||
render_current_anchor_context, render_dynamic_state_context, user_signal_rules,
|
||||
};
|
||||
use spacetime_client::{
|
||||
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
|
||||
CustomWorldAgentSessionRecord,
|
||||
@@ -42,7 +50,7 @@ pub(crate) struct CustomWorldAgentTurnResult {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PromptUserInputSignal {
|
||||
pub(crate) enum PromptUserInputSignal {
|
||||
Rich,
|
||||
Normal,
|
||||
Sparse,
|
||||
@@ -51,14 +59,14 @@ enum PromptUserInputSignal {
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PromptDriftRisk {
|
||||
pub(crate) enum PromptDriftRisk {
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PromptConversationMode {
|
||||
pub(crate) enum PromptConversationMode {
|
||||
Bootstrap,
|
||||
Expand,
|
||||
Compress,
|
||||
@@ -69,18 +77,18 @@ enum PromptConversationMode {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[allow(dead_code)]
|
||||
struct PromptDynamicState {
|
||||
pub(crate) struct PromptDynamicState {
|
||||
current_turn: u32,
|
||||
progress_percent: u32,
|
||||
user_input_signal: PromptUserInputSignal,
|
||||
drift_risk: PromptDriftRisk,
|
||||
pub(crate) user_input_signal: PromptUserInputSignal,
|
||||
pub(crate) drift_risk: PromptDriftRisk,
|
||||
quick_fill_requested: bool,
|
||||
conversation_mode: PromptConversationMode,
|
||||
judgement_summary: String,
|
||||
pub(crate) conversation_mode: PromptConversationMode,
|
||||
pub(crate) judgement_summary: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct PromptDynamicStateInference {
|
||||
pub(crate) struct PromptDynamicStateInference {
|
||||
user_input_signal: Option<PromptUserInputSignal>,
|
||||
drift_risk: Option<PromptDriftRisk>,
|
||||
conversation_mode: Option<PromptConversationMode>,
|
||||
@@ -177,7 +185,7 @@ struct IconicElementValue {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EightAnchorContent {
|
||||
pub(crate) struct EightAnchorContent {
|
||||
#[serde(default)]
|
||||
world_promise: Option<WorldPromiseValue>,
|
||||
#[serde(default)]
|
||||
@@ -271,229 +279,6 @@ impl std::fmt::Display for CustomWorldTurnError {
|
||||
|
||||
impl std::error::Error for CustomWorldTurnError {}
|
||||
|
||||
const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复"#;
|
||||
|
||||
const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#;
|
||||
|
||||
const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#;
|
||||
|
||||
const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中"#;
|
||||
|
||||
const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}"#;
|
||||
|
||||
const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
pub(crate) async fn run_custom_world_agent_turn<F>(
|
||||
request: CustomWorldAgentTurnRequest<'_>,
|
||||
on_reply_update: F,
|
||||
@@ -1679,293 +1464,6 @@ fn summarize_dynamic_state(
|
||||
)
|
||||
}
|
||||
|
||||
fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
|
||||
format!(
|
||||
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
|
||||
dynamic_state.user_input_signal.as_str(),
|
||||
dynamic_state.drift_risk.as_str(),
|
||||
dynamic_state.conversation_mode.as_str(),
|
||||
dynamic_state.judgement_summary
|
||||
)
|
||||
}
|
||||
|
||||
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())
|
||||
)
|
||||
}
|
||||
|
||||
fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
|
||||
format!(
|
||||
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
|
||||
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"rich" => Some(PromptUserInputSignal::Rich),
|
||||
"normal" => Some(PromptUserInputSignal::Normal),
|
||||
"sparse" => Some(PromptUserInputSignal::Sparse),
|
||||
"correction" => Some(PromptUserInputSignal::Correction),
|
||||
"delegate" => Some(PromptUserInputSignal::Delegate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"low" => Some(PromptDriftRisk::Low),
|
||||
"medium" => Some(PromptDriftRisk::Medium),
|
||||
"high" => Some(PromptDriftRisk::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"bootstrap" => Some(PromptConversationMode::Bootstrap),
|
||||
"expand" => Some(PromptConversationMode::Expand),
|
||||
"compress" => Some(PromptConversationMode::Compress),
|
||||
"repair_direction" => Some(PromptConversationMode::RepairDirection),
|
||||
"force_complete" => Some(PromptConversationMode::ForceComplete),
|
||||
"closing" => Some(PromptConversationMode::Closing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
match mode {
|
||||
PromptConversationMode::Bootstrap => {
|
||||
r#"当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械"#
|
||||
}
|
||||
PromptConversationMode::Expand => {
|
||||
r#"当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界"#
|
||||
}
|
||||
PromptConversationMode::Compress => {
|
||||
r#"当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉"#
|
||||
}
|
||||
PromptConversationMode::RepairDirection => {
|
||||
r#"当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
|
||||
}
|
||||
PromptConversationMode::ForceComplete => {
|
||||
r#"当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么"#
|
||||
}
|
||||
PromptConversationMode::Closing => {
|
||||
r#"当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
|
||||
match signal {
|
||||
PromptUserInputSignal::Rich => {
|
||||
r#"本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
|
||||
}
|
||||
PromptUserInputSignal::Normal => {
|
||||
r#"本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
|
||||
}
|
||||
PromptUserInputSignal::Sparse => {
|
||||
r#"本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。"#
|
||||
}
|
||||
PromptUserInputSignal::Correction => {
|
||||
r#"本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。"#
|
||||
}
|
||||
PromptUserInputSignal::Delegate => {
|
||||
r#"本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_user_text(chat_history: &[JsonValue]) -> String {
|
||||
chat_history
|
||||
.iter()
|
||||
@@ -2075,7 +1573,7 @@ fn serialize_json(value: &JsonValue, fallback: &str) -> String {
|
||||
}
|
||||
|
||||
impl PromptUserInputSignal {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Rich => "rich",
|
||||
Self::Normal => "normal",
|
||||
@@ -2087,7 +1585,7 @@ impl PromptUserInputSignal {
|
||||
}
|
||||
|
||||
impl PromptDriftRisk {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
@@ -2097,7 +1595,7 @@ impl PromptDriftRisk {
|
||||
}
|
||||
|
||||
impl PromptConversationMode {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bootstrap => "bootstrap",
|
||||
Self::Expand => "expand",
|
||||
@@ -2111,7 +1609,7 @@ impl PromptConversationMode {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extract_reply_text_from_partial_json;
|
||||
use crate::custom_world_rpg_draft_prompts::extract_reply_text_from_partial_json;
|
||||
|
||||
#[test]
|
||||
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
|
||||
|
||||
@@ -27,8 +27,15 @@ use tokio::time::sleep;
|
||||
use webp::Encoder as WebpEncoder;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
api_response::json_success_body,
|
||||
auth::AuthenticatedAccessToken,
|
||||
custom_world_result_prompts::{
|
||||
build_result_entity_system_prompt, build_result_entity_user_prompt,
|
||||
build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -883,18 +890,8 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind:
|
||||
return fallback;
|
||||
};
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "generate_custom_world_entity",
|
||||
"kind": kind,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
LlmMessage::system(build_result_entity_system_prompt()),
|
||||
LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)),
|
||||
]);
|
||||
|
||||
llm_client
|
||||
@@ -915,18 +912,12 @@ async fn generate_scene_npc_with_fallback(
|
||||
return fallback;
|
||||
};
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "generate_custom_world_scene_npc",
|
||||
"landmarkId": landmark_id,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
LlmMessage::system(build_result_scene_npc_system_prompt()),
|
||||
LlmMessage::user(build_result_scene_npc_user_prompt(
|
||||
profile,
|
||||
landmark_id,
|
||||
&fallback,
|
||||
)),
|
||||
]);
|
||||
|
||||
llm_client
|
||||
|
||||
365
server-rs/crates/api-server/src/custom_world_asset_prompts.rs
Normal file
365
server-rs/crates/api-server/src/custom_world_asset_prompts.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use crate::character_animation_assets::find_motion_template;
|
||||
use shared_contracts::assets::CharacterAnimationStrategy;
|
||||
|
||||
/// 自定义世界角色主图提示词脚本。
|
||||
pub(crate) fn build_character_visual_prompt(
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
) -> String {
|
||||
let character_brief = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
build_master_prompt(character_brief.as_str())
|
||||
}
|
||||
|
||||
/// 角色主图统一提示词骨架,迁移自旧共享 qwenSprite 主链。
|
||||
fn build_master_prompt(character_brief: &str) -> String {
|
||||
[
|
||||
"单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
"请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。".to_string(),
|
||||
"主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。".to_string(),
|
||||
"视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。".to_string(),
|
||||
character_brief.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// 自定义世界角色主图负面提示词脚本。
|
||||
pub(crate) fn build_character_visual_negative_prompt() -> String {
|
||||
[
|
||||
"正面视角",
|
||||
"左朝向",
|
||||
"完全 90 度纯右视图",
|
||||
"镜头透视",
|
||||
"半身像",
|
||||
"脚被裁切",
|
||||
"头顶被裁切",
|
||||
"多角色",
|
||||
"复杂背景",
|
||||
"建筑场景",
|
||||
"漂浮物",
|
||||
"烟雾环境",
|
||||
"武器消失",
|
||||
"武器换手",
|
||||
"额外手臂",
|
||||
"额外腿",
|
||||
"服装变化",
|
||||
"脸部变化",
|
||||
"模糊",
|
||||
"运动模糊",
|
||||
"文字",
|
||||
"水印",
|
||||
"UI 元素",
|
||||
"软萌 Q版大头贴",
|
||||
"儿童绘本风",
|
||||
"厚涂插画感",
|
||||
"低对比柔边",
|
||||
]
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub(crate) fn build_character_animation_prompt(
|
||||
strategy: &CharacterAnimationStrategy,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
animation: &str,
|
||||
frame_count: u32,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
match strategy {
|
||||
CharacterAnimationStrategy::ImageToVideo => build_ark_character_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
),
|
||||
CharacterAnimationStrategy::ImageSequence => {
|
||||
build_image_sequence_prompt(animation, prompt_text, frame_count, use_chroma_key)
|
||||
}
|
||||
CharacterAnimationStrategy::MotionTransfer
|
||||
| CharacterAnimationStrategy::ReferenceToVideo => build_npc_animation_prompt(
|
||||
animation,
|
||||
prompt_text,
|
||||
character_brief_text,
|
||||
action_template_id,
|
||||
loop_,
|
||||
use_chroma_key,
|
||||
fps,
|
||||
duration_seconds,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_image_sequence_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
frame_count: u32,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!(
|
||||
"同一角色连续 {} 帧动作序列,动作主题是 {}。",
|
||||
frame_count, animation
|
||||
),
|
||||
"固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(),
|
||||
"帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(),
|
||||
if use_chroma_key {
|
||||
"纯绿色背景,无地面装饰,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景尽量纯净,避免复杂场景。".to_string()
|
||||
},
|
||||
prompt_text.trim().to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_npc_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
fps: u32,
|
||||
duration_seconds: u32,
|
||||
) -> String {
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
let loop_rule = if loop_ {
|
||||
"这是循环动作,直接进入动作循环中段,不要开场静止站桩,不要把主参考图原样作为第一帧。"
|
||||
.to_string()
|
||||
} else if animation == "die" {
|
||||
"这是死亡终结动作,首帧参考主图角色形象即可,尾帧停在死亡结束姿态,不要回到主图形象。"
|
||||
.to_string()
|
||||
} else {
|
||||
"这是非循环动作,首帧和尾帧都要回到参考主图角色形象,中段完成动作变化。".to_string()
|
||||
};
|
||||
|
||||
if let Some(template) = action_template_id.and_then(|id| find_motion_template(id)) {
|
||||
return [
|
||||
format!(
|
||||
"单人 NPC 全身动作视频,动作主题是 {}。角色固定为同一人,右向斜侧身,镜头稳定,轮廓清晰,武器不可丢失。",
|
||||
template.animation
|
||||
),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
format!("动作补充:{}。", template.prompt_suffix),
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("动作细节:{}。", action_detail_text)
|
||||
},
|
||||
format!("目标帧率 {} fps,时长约 {} 秒。", fps.clamp(1, 60), duration_seconds.clamp(1, 8)),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
[
|
||||
format!("单人 NPC 全身动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(),
|
||||
"动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无复杂场景。".to_string()
|
||||
},
|
||||
if character_brief.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("角色设定:{}。", character_brief)
|
||||
},
|
||||
if action_detail_text.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
action_detail_text
|
||||
},
|
||||
format!(
|
||||
"目标帧率 {} fps,时长约 {} 秒。",
|
||||
fps.clamp(1, 60),
|
||||
duration_seconds.clamp(1, 8)
|
||||
),
|
||||
loop_rule,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn build_ark_character_animation_prompt(
|
||||
animation: &str,
|
||||
prompt_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
action_template_id: Option<&str>,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
let normalized_animation_name = animation.trim().replace(char::is_whitespace, "_");
|
||||
let normalized_animation_name = if normalized_animation_name.is_empty() {
|
||||
"idle".to_string()
|
||||
} else {
|
||||
normalized_animation_name
|
||||
};
|
||||
let character_brief = build_compact_animation_character_brief(character_brief_text);
|
||||
let action_detail_text = sanitize_animation_prompt_text(prompt_text, 140);
|
||||
if let Some(template) = action_template_id.and_then(find_motion_template) {
|
||||
return build_video_action_prompt(
|
||||
template.id,
|
||||
template.prompt_suffix,
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
);
|
||||
}
|
||||
|
||||
build_video_action_prompt(
|
||||
normalized_animation_name.as_str(),
|
||||
if loop_ {
|
||||
"循环动作必须自然闭环,不要静止开场。"
|
||||
} else {
|
||||
"中段完成完整动作变化,收束干净。"
|
||||
},
|
||||
action_detail_text.as_str(),
|
||||
Some(character_brief.as_str()),
|
||||
use_chroma_key,
|
||||
)
|
||||
}
|
||||
|
||||
/// 角色动作视频统一提示词骨架,按每个动作模板与补充描述生成。
|
||||
fn build_video_action_prompt(
|
||||
action_id: &str,
|
||||
action_sequence: &str,
|
||||
action_detail_text: &str,
|
||||
character_brief_text: Option<&str>,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作英文名是 {}。", action_id),
|
||||
"角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。".to_string(),
|
||||
"视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(),
|
||||
"主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(),
|
||||
"画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。".to_string(),
|
||||
"风格要求:横版像素动作角色体型,头身比优先控制在 1 到 1.5 头身,保留清楚的头、躯干、双臂和双腿轮廓。明确的像素动作角色设定稿气质,整体按像素游戏角色设计方向组织,使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点,适合横版动作 sprite 资产。高可读性游戏角色设定图,形体清晰,服装层次明确,便于后续连续动作生成。".to_string(),
|
||||
format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence),
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string()
|
||||
},
|
||||
format!(
|
||||
"动作补充细节:{}",
|
||||
if action_detail_text.trim().is_empty() {
|
||||
"保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。"
|
||||
} else {
|
||||
action_detail_text.trim()
|
||||
}
|
||||
),
|
||||
character_brief_text
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("角色设定:{}。", value))
|
||||
.unwrap_or_default(),
|
||||
"目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub(crate) fn build_fallback_moderation_safe_animation_prompt(
|
||||
animation: &str,
|
||||
loop_: bool,
|
||||
use_chroma_key: bool,
|
||||
) -> String {
|
||||
[
|
||||
format!("单人全身角色动作视频,动作主题是 {}。", animation),
|
||||
"角色固定为同一人,右向斜侧身,镜头稳定,轮廓清楚。".to_string(),
|
||||
if loop_ {
|
||||
"循环动作直接进入稳定循环,不要静止开场,不要定格首帧。".to_string()
|
||||
} else {
|
||||
"非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string()
|
||||
},
|
||||
if use_chroma_key {
|
||||
"背景为纯绿色绿幕,无其他人物和场景元素。".to_string()
|
||||
} else {
|
||||
"背景简洁纯净。".to_string()
|
||||
},
|
||||
]
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn sanitize_animation_prompt_text(value: &str, max_length: usize) -> String {
|
||||
value
|
||||
.replace(char::is_whitespace, " ")
|
||||
.replace("血浆", "")
|
||||
.replace("喷血", "")
|
||||
.replace("鲜血", "")
|
||||
.replace("断肢", "")
|
||||
.replace("斩首", "")
|
||||
.replace("裸体", "")
|
||||
.replace("裸露", "")
|
||||
.replace("色情", "")
|
||||
.replace("性交", "")
|
||||
.replace("死亡", "倒地结束")
|
||||
.replace("死去", "倒地结束")
|
||||
.replace("击杀", "倒地结束")
|
||||
.replace("受击", "失衡")
|
||||
.replace("受伤", "失衡")
|
||||
.replace("砍杀", "挥击")
|
||||
.replace("斩击", "挥击")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(max_length)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_compact_animation_character_brief(value: Option<&str>) -> String {
|
||||
let normalized = sanitize_animation_prompt_text(value.unwrap_or_default(), 160);
|
||||
if normalized.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
normalized
|
||||
.split(['/', '|', '\n', ',', ',', '。', ';', ';'])
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
@@ -15,11 +15,25 @@ pub enum DraftFoundationPayloadError {
|
||||
InvalidGeneratedDraft(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CustomWorldFoundationDraftProgress {
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub progress: u32,
|
||||
}
|
||||
|
||||
pub async fn generate_custom_world_foundation_draft(
|
||||
llm_client: &LlmClient,
|
||||
session: &CustomWorldAgentSessionRecord,
|
||||
mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send,
|
||||
) -> Result<CustomWorldFoundationDraftResult, String> {
|
||||
let setting_text = build_foundation_generation_seed_text(session);
|
||||
emit_foundation_draft_progress(
|
||||
&mut on_progress,
|
||||
"整理世界骨架",
|
||||
"正在根据创作者锚点生成第一版世界框架。",
|
||||
12,
|
||||
);
|
||||
let mut framework = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_framework_prompt(setting_text.as_str()),
|
||||
@@ -36,6 +50,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
&framework,
|
||||
"playable",
|
||||
FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||||
(16, 30),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
framework["playableNpcs"] = JsonValue::Array(playable_outlines.clone());
|
||||
@@ -45,6 +61,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
&framework,
|
||||
"story",
|
||||
FOUNDATION_DRAFT_STORY_COUNT,
|
||||
(30, 44),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
framework["storyNpcs"] = JsonValue::Array(story_outlines.clone());
|
||||
@@ -53,6 +71,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
llm_client,
|
||||
&framework,
|
||||
FOUNDATION_DRAFT_LANDMARK_COUNT,
|
||||
(44, 56),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
framework["landmarks"] = JsonValue::Array(landmark_seeds.clone());
|
||||
@@ -62,6 +82,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
&framework,
|
||||
&story_outlines,
|
||||
&landmark_seeds,
|
||||
(56, 66),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
framework["landmarks"] = JsonValue::Array(landmarks.clone());
|
||||
@@ -72,6 +94,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
"playable",
|
||||
&playable_outlines,
|
||||
"narrative",
|
||||
(66, 76),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
let playable_detailed = expand_foundation_role_entries(
|
||||
@@ -80,6 +104,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
"playable",
|
||||
&playable_narrative,
|
||||
"dossier",
|
||||
(76, 84),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
let story_narrative = expand_foundation_role_entries(
|
||||
@@ -88,6 +114,8 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
"story",
|
||||
&story_outlines,
|
||||
"narrative",
|
||||
(84, 92),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
let story_detailed = expand_foundation_role_entries(
|
||||
@@ -96,9 +124,18 @@ pub async fn generate_custom_world_foundation_draft(
|
||||
"story",
|
||||
&story_narrative,
|
||||
"dossier",
|
||||
(92, 96),
|
||||
&mut on_progress,
|
||||
)
|
||||
.await?;
|
||||
|
||||
emit_foundation_draft_progress(
|
||||
&mut on_progress,
|
||||
"编译世界底稿",
|
||||
"正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。",
|
||||
97,
|
||||
);
|
||||
|
||||
let draft_profile = build_foundation_draft_profile_from_framework(
|
||||
framework,
|
||||
playable_detailed,
|
||||
@@ -166,6 +203,8 @@ async fn generate_foundation_role_outline_entries(
|
||||
framework: &JsonValue,
|
||||
role_type: &str,
|
||||
total_count: usize,
|
||||
progress_range: (u32, u32),
|
||||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||||
) -> Result<Vec<JsonValue>, String> {
|
||||
let mut merged_entries = Vec::new();
|
||||
let planned_batch_count = total_count
|
||||
@@ -178,6 +217,24 @@ async fn generate_foundation_role_outline_entries(
|
||||
let batch_count =
|
||||
(total_count - merged_entries.len()).min(FOUNDATION_ROLE_OUTLINE_BATCH_SIZE);
|
||||
let forbidden_names = names_from_entries(&merged_entries);
|
||||
let role_label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
format!("生成{role_label}").as_str(),
|
||||
format!(
|
||||
"正在生成{role_label}第 {} / {} 批,当前已完成 {}/{}。",
|
||||
batch_index + 1,
|
||||
planned_batch_count,
|
||||
merged_entries.len(),
|
||||
total_count,
|
||||
)
|
||||
.as_str(),
|
||||
to_batch_progress(progress_range, merged_entries.len(), total_count),
|
||||
);
|
||||
let raw = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_role_outline_batch_prompt(
|
||||
@@ -210,13 +267,27 @@ async fn generate_foundation_role_outline_entries(
|
||||
let key = role_key(role_type);
|
||||
merged_entries.extend(array_field(&raw, key).into_iter().take(batch_count));
|
||||
}
|
||||
Ok(merged_entries.into_iter().take(total_count).collect())
|
||||
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
|
||||
let role_label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
format!("生成{role_label}").as_str(),
|
||||
format!("{role_label}已经整理完成,共 {} 个。", merged_entries.len()).as_str(),
|
||||
progress_range.1,
|
||||
);
|
||||
Ok(merged_entries)
|
||||
}
|
||||
|
||||
async fn generate_foundation_landmark_seed_entries(
|
||||
llm_client: &LlmClient,
|
||||
framework: &JsonValue,
|
||||
total_count: usize,
|
||||
progress_range: (u32, u32),
|
||||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||||
) -> Result<Vec<JsonValue>, String> {
|
||||
let mut merged_entries = Vec::new();
|
||||
let planned_batch_count = total_count.div_ceil(FOUNDATION_LANDMARK_BATCH_SIZE).max(1);
|
||||
@@ -226,6 +297,19 @@ async fn generate_foundation_landmark_seed_entries(
|
||||
}
|
||||
let batch_count = (total_count - merged_entries.len()).min(FOUNDATION_LANDMARK_BATCH_SIZE);
|
||||
let forbidden_names = names_from_entries(&merged_entries);
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
"生成关键场景",
|
||||
format!(
|
||||
"正在生成关键场景第 {} / {} 批,当前已完成 {}/{}。",
|
||||
batch_index + 1,
|
||||
planned_batch_count,
|
||||
merged_entries.len(),
|
||||
total_count,
|
||||
)
|
||||
.as_str(),
|
||||
to_batch_progress(progress_range, merged_entries.len(), total_count),
|
||||
);
|
||||
let raw = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_landmark_seed_batch_prompt(framework, batch_count, &forbidden_names),
|
||||
@@ -247,7 +331,14 @@ async fn generate_foundation_landmark_seed_entries(
|
||||
.await?;
|
||||
merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count));
|
||||
}
|
||||
Ok(merged_entries.into_iter().take(total_count).collect())
|
||||
let merged_entries: Vec<JsonValue> = merged_entries.into_iter().take(total_count).collect();
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
"生成关键场景",
|
||||
format!("关键场景骨架已整理完成,共 {} 个。", merged_entries.len()).as_str(),
|
||||
progress_range.1,
|
||||
);
|
||||
Ok(merged_entries)
|
||||
}
|
||||
|
||||
async fn expand_foundation_landmark_network_entries(
|
||||
@@ -255,12 +346,28 @@ async fn expand_foundation_landmark_network_entries(
|
||||
framework: &JsonValue,
|
||||
story_npcs: &[JsonValue],
|
||||
base_entries: &[JsonValue],
|
||||
progress_range: (u32, u32),
|
||||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||||
) -> Result<Vec<JsonValue>, String> {
|
||||
let mut merged_entries = Vec::new();
|
||||
for (batch_index, batch) in base_entries
|
||||
let batches: Vec<&[JsonValue]> = base_entries
|
||||
.chunks(FOUNDATION_LANDMARK_BATCH_SIZE)
|
||||
.enumerate()
|
||||
{
|
||||
.collect();
|
||||
let mut processed_count = 0usize;
|
||||
for (batch_index, batch) in batches.iter().enumerate() {
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
"建立场景连接",
|
||||
format!(
|
||||
"正在补全场景连接第 {} / {} 批,当前已完成 {}/{}。",
|
||||
batch_index + 1,
|
||||
batches.len(),
|
||||
processed_count,
|
||||
base_entries.len(),
|
||||
)
|
||||
.as_str(),
|
||||
to_batch_progress(progress_range, processed_count, base_entries.len()),
|
||||
);
|
||||
let raw = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_landmark_network_batch_prompt(framework, story_npcs, batch),
|
||||
@@ -284,7 +391,16 @@ async fn expand_foundation_landmark_network_entries(
|
||||
)
|
||||
.await?;
|
||||
merged_entries.extend(array_field(&raw, "landmarks"));
|
||||
processed_count = processed_count
|
||||
.saturating_add(batch.len())
|
||||
.min(base_entries.len());
|
||||
}
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
"建立场景连接",
|
||||
"关键场景的角色分布与路径连接已经整理完成。",
|
||||
progress_range.1,
|
||||
);
|
||||
Ok(merge_entries_by_name(base_entries, &merged_entries))
|
||||
}
|
||||
|
||||
@@ -294,13 +410,39 @@ async fn expand_foundation_role_entries(
|
||||
role_type: &str,
|
||||
base_entries: &[JsonValue],
|
||||
stage: &str,
|
||||
progress_range: (u32, u32),
|
||||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||||
) -> Result<Vec<JsonValue>, String> {
|
||||
let mut merged_entries = Vec::new();
|
||||
for (batch_index, batch) in base_entries
|
||||
let batches: Vec<&[JsonValue]> = base_entries
|
||||
.chunks(FOUNDATION_ROLE_DETAIL_BATCH_SIZE)
|
||||
.enumerate()
|
||||
{
|
||||
.collect();
|
||||
let mut processed_count = 0usize;
|
||||
for (batch_index, batch) in batches.iter().enumerate() {
|
||||
let expected_names = names_from_entries(batch);
|
||||
let role_label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
let stage_label = if stage == "narrative" {
|
||||
"叙事基础"
|
||||
} else {
|
||||
"档案细节"
|
||||
};
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
format!("补全{role_label}{stage_label}").as_str(),
|
||||
format!(
|
||||
"正在补全{role_label}{stage_label}第 {} / {} 批,当前已完成 {}/{}。",
|
||||
batch_index + 1,
|
||||
batches.len(),
|
||||
processed_count,
|
||||
base_entries.len(),
|
||||
)
|
||||
.as_str(),
|
||||
to_batch_progress(progress_range, processed_count, base_entries.len()),
|
||||
);
|
||||
let raw = request_foundation_json_stage(
|
||||
llm_client,
|
||||
build_custom_world_role_batch_prompt(framework, role_type, batch, stage),
|
||||
@@ -326,9 +468,51 @@ async fn expand_foundation_role_entries(
|
||||
)
|
||||
.await?;
|
||||
merged_entries.extend(array_field(&raw, role_key(role_type)));
|
||||
processed_count = processed_count
|
||||
.saturating_add(batch.len())
|
||||
.min(base_entries.len());
|
||||
}
|
||||
let role_label = if role_type == "playable" {
|
||||
"可扮演角色"
|
||||
} else {
|
||||
"场景角色"
|
||||
};
|
||||
let stage_label = if stage == "narrative" {
|
||||
"叙事基础"
|
||||
} else {
|
||||
"档案细节"
|
||||
};
|
||||
emit_foundation_draft_progress(
|
||||
on_progress,
|
||||
format!("补全{role_label}{stage_label}").as_str(),
|
||||
format!("{role_label}{stage_label}已经整理完成。").as_str(),
|
||||
progress_range.1,
|
||||
);
|
||||
Ok(merge_entries_by_name(base_entries, &merged_entries))
|
||||
}
|
||||
|
||||
fn emit_foundation_draft_progress(
|
||||
on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send),
|
||||
phase_label: &str,
|
||||
phase_detail: &str,
|
||||
progress: u32,
|
||||
) {
|
||||
on_progress(CustomWorldFoundationDraftProgress {
|
||||
phase_label: phase_label.to_string(),
|
||||
phase_detail: phase_detail.to_string(),
|
||||
progress: progress.min(100),
|
||||
});
|
||||
}
|
||||
|
||||
fn to_batch_progress(progress_range: (u32, u32), completed: usize, total: usize) -> u32 {
|
||||
if total == 0 {
|
||||
return progress_range.1;
|
||||
}
|
||||
let start = progress_range.0 as f64;
|
||||
let end = progress_range.1 as f64;
|
||||
let ratio = (completed as f64 / total as f64).clamp(0.0, 1.0);
|
||||
(start + (end - start) * ratio).round().clamp(0.0, 100.0) as u32
|
||||
}
|
||||
// foundation draft 已经由 api-server 真实生成,落库前只负责把它注入现有 action payload。
|
||||
pub fn build_draft_foundation_action_payload_json(
|
||||
payload: &ExecuteCustomWorldAgentActionRequest,
|
||||
@@ -486,7 +670,7 @@ fn build_custom_world_role_outline_batch_prompt(
|
||||
};
|
||||
[
|
||||
format!("请根据下面的世界核心信息,生成一批{label}框架名单。"),
|
||||
"后续我会继续补全人物档案,所以这一步每个角色只保留最少字段。".to_string(),
|
||||
"后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
@@ -500,6 +684,9 @@ fn build_custom_world_role_outline_batch_prompt(
|
||||
" \"title\": \"称号\",".to_string(),
|
||||
" \"role\": \"身份\",".to_string(),
|
||||
" \"description\": \"极简定位描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认角色形象描述\",".to_string(),
|
||||
" \"actionDescription\": \"默认角色动作描述\",".to_string(),
|
||||
" \"sceneVisualDescription\": \"默认出现场景描述\",".to_string(),
|
||||
" \"initialAffinity\": 18,".to_string(),
|
||||
" \"relationshipHooks\": [\"一个关系切入口\"],".to_string(),
|
||||
" \"tags\": [\"标签1\", \"标签2\"]".to_string(),
|
||||
@@ -511,7 +698,10 @@ fn build_custom_world_role_outline_batch_prompt(
|
||||
format!("- 必须生成恰好 {batch_count} 个{label}。"),
|
||||
"- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。".to_string(),
|
||||
"- 只保留:name、title、role、description、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。".to_string(),
|
||||
"- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。".to_string(),
|
||||
"- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。".to_string(),
|
||||
"- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。".to_string(),
|
||||
"- initialAffinity 必须是 -40 到 90 的整数。".to_string(),
|
||||
@@ -534,7 +724,7 @@ fn build_custom_world_role_outline_batch_json_repair_prompt(
|
||||
format!("顶层必须只包含一个 {key} 数组。"),
|
||||
format!("必须保留恰好 {expected_count} 个角色对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个角色只包含:name、title、role、description、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"每个角色只包含:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,relationshipHooks 和 tags 补空数组,initialAffinity 补默认整数。".to_string(),
|
||||
"不要输出 backstory、skills、landmarks 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
@@ -548,7 +738,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
) -> String {
|
||||
[
|
||||
"请根据下面的世界核心信息,生成一批关键场景框架名单。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留最少字段。".to_string(),
|
||||
"后续我会继续补全场景网络,所以这一步每个地点只保留场景骨架与默认生图描述。".to_string(),
|
||||
"你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。".to_string(),
|
||||
"世界核心信息:".to_string(),
|
||||
build_framework_summary_text(framework, 0),
|
||||
@@ -560,6 +750,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
" {".to_string(),
|
||||
" \"name\": \"场景名称\",".to_string(),
|
||||
" \"description\": \"场景极简描述\",".to_string(),
|
||||
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
|
||||
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
|
||||
" }".to_string(),
|
||||
" ]".to_string(),
|
||||
@@ -569,7 +760,8 @@ fn build_custom_world_landmark_seed_batch_prompt(
|
||||
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
|
||||
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
|
||||
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
|
||||
"- 每个地点只保留:name、description、dangerLevel。".to_string(),
|
||||
"- 每个地点只保留:name、description、visualDescription、dangerLevel。".to_string(),
|
||||
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
|
||||
"- description 控制在 12 到 24 个汉字内。".to_string(),
|
||||
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
|
||||
"- 所有生成文本都必须使用中文。".to_string(),
|
||||
@@ -588,7 +780,7 @@ fn build_custom_world_landmark_seed_batch_json_repair_prompt(
|
||||
"顶层必须只包含一个 landmarks 数组。".to_string(),
|
||||
format!("必须保留恰好 {expected_count} 个地点对象。"),
|
||||
if forbidden_names.is_empty() { "".to_string() } else { format!("禁止使用这些重复名:{}。", forbidden_names.join("、")) },
|
||||
"每个地点只包含:name、description、dangerLevel。".to_string(),
|
||||
"每个地点只包含:name、description、visualDescription、dangerLevel。".to_string(),
|
||||
"如果缺少字段:字符串补空字符串,dangerLevel 补 medium。".to_string(),
|
||||
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
|
||||
"原始文本:".to_string(),
|
||||
@@ -1528,7 +1720,7 @@ mod tests {
|
||||
let llm_client = build_test_llm_client(server_url);
|
||||
let session = build_test_session();
|
||||
|
||||
let result = generate_custom_world_foundation_draft(&llm_client, &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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// 结果页新增可扮演角色 / 场景角色 / 场景的提示词脚本。
|
||||
/// 这里只生成 LLM 可审计输入,不处理 fallback,避免提示词规则和业务兜底混在一起。
|
||||
pub(crate) fn build_result_entity_system_prompt() -> &'static str {
|
||||
"你是 RPG 自定义世界实体生成器。只输出一个 JSON 对象,不要输出 Markdown。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_result_entity_user_prompt(
|
||||
profile: &Value,
|
||||
kind: &str,
|
||||
fallback: &Value,
|
||||
) -> String {
|
||||
json!({
|
||||
"task": "generate_custom_world_entity",
|
||||
"kind": kind,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn build_result_scene_npc_system_prompt() -> &'static str {
|
||||
"你是 RPG 自定义世界场景 NPC 生成器。只输出一个 JSON 对象,不要输出 Markdown。"
|
||||
}
|
||||
|
||||
pub(crate) fn build_result_scene_npc_user_prompt(
|
||||
profile: &Value,
|
||||
landmark_id: &str,
|
||||
fallback: &Value,
|
||||
) -> String {
|
||||
json!({
|
||||
"task": "generate_custom_world_scene_npc",
|
||||
"landmarkId": landmark_id,
|
||||
"profile": profile,
|
||||
"fallback": fallback,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
use crate::custom_world_agent_turn::{
|
||||
EightAnchorContent, PromptConversationMode, PromptDriftRisk, PromptDynamicState,
|
||||
PromptUserInputSignal,
|
||||
};
|
||||
use module_custom_world::empty_agent_anchor_content_json;
|
||||
use serde_json::Value as JsonValue;
|
||||
pub(crate) const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
|
||||
|
||||
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
|
||||
1. 当前完整设定结构
|
||||
2. 用户聊天记录
|
||||
|
||||
然后输出:
|
||||
1. 一版新的完整设定结构
|
||||
2. 当前 progress 百分比
|
||||
3. 一段直接回复用户的话
|
||||
|
||||
你必须把“新的完整设定结构”视为下一轮的唯一有效版本。
|
||||
你的输出会直接覆盖上一版设定结构。
|
||||
|
||||
你不是在做局部 patch。
|
||||
你不是在做解释报告。
|
||||
你不是在给开发者写分析。
|
||||
你是在同时完成:
|
||||
1. 世界设定更新
|
||||
2. 当前推进程度判断
|
||||
3. 对用户的共创回复"#;
|
||||
|
||||
pub(crate) const GLOBAL_HARD_RULES: &str = r#"全局硬约束:
|
||||
|
||||
1. 必须输出完整的设定结构,而不是只输出变化部分。
|
||||
2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。
|
||||
3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。
|
||||
4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。
|
||||
5. progressPercent 最低为 0,不允许为负数。
|
||||
6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。
|
||||
7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。
|
||||
8. replyText 不要写成长篇策划文,不要展开大段世界观百科。
|
||||
9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。
|
||||
10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。
|
||||
11. 你输出的 JSON 必须可以被直接解析。
|
||||
12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。"#;
|
||||
|
||||
pub(crate) const QUICK_FILL_EXTRA_RULES: &str = r#"用户刚刚主动要求你自动补全剩余设定。
|
||||
|
||||
这表示用户接受你基于当前方向自动补完剩余设定。
|
||||
|
||||
本轮要求:
|
||||
1. 不要再继续提问
|
||||
2. 直接输出一版尽量完整的设定结构
|
||||
3. progressPercent 直接输出为 100
|
||||
4. replyText 要告诉用户现在可以进入“生成游戏设定草稿”"#;
|
||||
|
||||
pub(crate) const STATE_INFERENCE_SYSTEM_PROMPT: &str = r#"你是正式生成世界设定前的一步“创作状态识别器”。
|
||||
你的职责不是直接生成新设定,而是先判断:下一轮正式生成应该用什么推进策略,尤其要判断 replyText 应该更偏确认、吸收、收束、纠偏,还是启发式提问。
|
||||
|
||||
你必须综合以下信息判断:
|
||||
1. 当前轮次 currentTurn
|
||||
2. 当前完成度 progressPercent
|
||||
3. 用户是否要求自动补全 quickFillRequested
|
||||
4. 当前完整设定结构
|
||||
5. 最近聊天记录,尤其是最近 1 到 3 轮用户消息
|
||||
|
||||
你需要输出 4 个字段:
|
||||
1. userInputSignal:只能是 rich / normal / sparse / correction / delegate
|
||||
2. driftRisk:只能是 low / medium / high
|
||||
3. conversationMode:只能是 bootstrap / expand / compress / repair_direction / force_complete / closing
|
||||
4. judgementSummary:1 到 2 句中文,概括你为什么这样判断,以及正式生成时最该注意什么
|
||||
|
||||
请按下面的语义判断。
|
||||
|
||||
一、userInputSignal 定义
|
||||
1. rich
|
||||
- 用户这一轮给了多条可直接落地的有效信息
|
||||
- 这些信息可能同时覆盖世界方向、玩家处境、开局事件、冲突、关系、标志元素中的多个
|
||||
- 正式生成时应优先高密度吸收,不要只更新一个点
|
||||
|
||||
2. normal
|
||||
- 用户在顺着当前方向做正常补充
|
||||
- 信息量中等,有明确新增内容,但没有明显推翻旧方向,也没有把决定权交给系统
|
||||
- 正式生成时应稳定推进并自然接住用户内容
|
||||
|
||||
3. sparse
|
||||
- 用户输入很短、很虚、很笼统,或几乎没有新增有效事实
|
||||
- 例如只有一个题材词、一个气质词、一句很概括的话、一个很短的倾向表达
|
||||
- 这种情况下,正式生成阶段的 replyText 应优先采用启发式提问
|
||||
- 启发式提问的要求是:只问一个最容易回答、最能推动落地设计的问题
|
||||
|
||||
4. correction
|
||||
- 用户这轮核心动作是在修正、替换、推翻、重定向旧设定
|
||||
- 即使文字不长,只要主意图是“之前那个不对,现在改成这个”,也应优先判为 correction
|
||||
- correction 的优先级高于 rich 和 normal
|
||||
|
||||
5. delegate
|
||||
- 用户把部分决定权交给系统
|
||||
- 例如“你来定”“你帮我补”“按你觉得合理的来”“先给我一个默认方案”
|
||||
- delegate 关注的是授权关系,不只是信息多寡
|
||||
|
||||
二、driftRisk 定义
|
||||
1. low
|
||||
- 当前轮输入与已有方向基本一致
|
||||
- 没有明显改口或冲突
|
||||
|
||||
2. medium
|
||||
- 当前轮带来一定方向变化或扩张
|
||||
- 还没有明显推翻旧方向,但如果处理不好,容易让设定开始发散
|
||||
|
||||
3. high
|
||||
- 用户明确纠偏、改口、替换方向,或最近多轮反复修正
|
||||
- 这时最重要的是防止旧方向重新回流到正式生成结果里
|
||||
|
||||
三、conversationMode 选择原则
|
||||
1. bootstrap
|
||||
- 适用于前期、信息少、核心方向未稳定
|
||||
- replyText 更适合低压力确认和单点启发
|
||||
|
||||
2. expand
|
||||
- 适用于方向已成形,正在顺着现有路线继续补充
|
||||
- replyText 更适合总结已接住的内容并往前推一步
|
||||
|
||||
3. compress
|
||||
- 适用于中后段,已有骨架,需要开始收束
|
||||
- replyText 更适合聚焦最关键缺口,而不是继续开支线
|
||||
|
||||
4. repair_direction
|
||||
- 适用于用户正在纠偏
|
||||
- replyText 更适合先承认修正,再沿修正后的方向继续推进
|
||||
|
||||
5. force_complete
|
||||
- 适用于用户明确要求自动补全
|
||||
- replyText 不再提问,而应给出完成感和下一步引导
|
||||
|
||||
6. closing
|
||||
- 适用于接近完成但并非强制一键补全
|
||||
- replyText 更像确认与收束,而不是前期式探索
|
||||
|
||||
四、优先级规则
|
||||
1. 如果 quickFillRequested 为 true,conversationMode 必须优先判为 force_complete
|
||||
2. 如果用户核心意图是修正旧方向,userInputSignal 优先判为 correction,conversationMode 通常优先考虑 repair_direction
|
||||
3. 如果用户核心意图是授权系统替他补完,userInputSignal 优先判为 delegate
|
||||
4. 只有在没有明显纠偏、也没有明确自动补全要求时,才主要依据 currentTurn、progressPercent 和信息密度,在 bootstrap / expand / compress / closing 之间选择
|
||||
|
||||
五、关于 replyText 风格的专门判断要求
|
||||
1. 如果用户输入较少、较虚或不够落地,正式生成阶段的 replyText 应采用启发式提问
|
||||
2. 启发式提问一次最多只能提 1 个问题,不能连问两个或更多
|
||||
3. 启发式提问必须问“最能推动当前设计落地”的那个问题,而不是泛泛而谈
|
||||
4. 如果用户输入已经足够 rich,就不要再机械提问,优先吸收和推进
|
||||
5. 如果用户在 correction 或 delegate 状态下,replyText 是否提问要服从更高目标:纠偏生效或代为补全,不要机械套 sparse 的问法
|
||||
|
||||
六、关于 replyText 用语的硬约束
|
||||
1. replyText 禁止提及内部结构名、锚点名、字段名、schema 名、框架词
|
||||
2. 禁止出现这类内部表达:世界承诺、玩家幻想、主题边界、玩家入口、核心冲突、关键关系、隐藏线、标志元素、字段、结构、模块、八锚点
|
||||
3. replyText 只能用通俗、直接、面向创作沟通的语言回应用户
|
||||
4. replyText 应该围绕用户正在讨论的具体内容来落地,比如身份、开场处境、冲突、人物关系、地点、规则、气质,而不是抽象谈结构
|
||||
5. judgementSummary 可以简洁提到“这轮更适合启发式提问”或“这轮应优先吸收修正”,但也不要堆内部术语
|
||||
|
||||
七、关于 judgementSummary 的写法
|
||||
1. 必须简洁,不要写成长篇分析
|
||||
2. 必须直接服务于下一轮正式生成
|
||||
3. 最好同时包含两层信息:
|
||||
- 为什么这么判断
|
||||
- 正式生成时最该优先做什么,或最该避免什么
|
||||
|
||||
八、硬性约束
|
||||
1. 只能输出 JSON,不能输出解释、代码块或额外说明
|
||||
2. 不能发明上下文里不存在的设定事实
|
||||
3. 你的任务是“判断生成策略”,不是“代替正式生成直接写新设定”
|
||||
4. 即使信息不完全,也必须在给定枚举里选出最合理的一组状态
|
||||
5. judgementSummary 必须是中文
|
||||
6. 输出值必须严格落在给定枚举中"#;
|
||||
|
||||
pub(crate) const STATE_INFERENCE_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"userInputSignal": "normal",
|
||||
"driftRisk": "low",
|
||||
"conversationMode": "expand",
|
||||
"judgementSummary": ""
|
||||
}"#;
|
||||
|
||||
pub(crate) const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出,不要输出其他文字:
|
||||
{
|
||||
"replyText": "",
|
||||
"progressPercent": 0,
|
||||
"nextAnchorContent": {
|
||||
"worldPromise": {
|
||||
"hook": "",
|
||||
"differentiator": "",
|
||||
"desiredExperience": ""
|
||||
},
|
||||
"playerFantasy": {
|
||||
"playerRole": "",
|
||||
"corePursuit": "",
|
||||
"fearOfLoss": ""
|
||||
},
|
||||
"themeBoundary": {
|
||||
"toneKeywords": [],
|
||||
"aestheticDirectives": [],
|
||||
"forbiddenDirectives": []
|
||||
},
|
||||
"playerEntryPoint": {
|
||||
"openingIdentity": "",
|
||||
"openingProblem": "",
|
||||
"entryMotivation": ""
|
||||
},
|
||||
"coreConflict": {
|
||||
"surfaceConflicts": [],
|
||||
"hiddenCrisis": "",
|
||||
"firstTouchedConflict": ""
|
||||
},
|
||||
"keyRelationships": [
|
||||
{
|
||||
"pairs": "",
|
||||
"relationshipType": "",
|
||||
"secretOrCost": ""
|
||||
}
|
||||
],
|
||||
"hiddenLines": {
|
||||
"hiddenTruths": [],
|
||||
"misdirectionHints": [],
|
||||
"revealPacing": ""
|
||||
},
|
||||
"iconicElements": {
|
||||
"iconicMotifs": [],
|
||||
"institutionsOrArtifacts": [],
|
||||
"hardRules": []
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
|
||||
pub(crate) fn render_dynamic_state_context(dynamic_state: &PromptDynamicState) -> String {
|
||||
format!(
|
||||
"上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: {}\n- driftRisk: {}\n- conversationMode: {}\n- judgementSummary: {}",
|
||||
dynamic_state.user_input_signal.as_str(),
|
||||
dynamic_state.drift_risk.as_str(),
|
||||
dynamic_state.conversation_mode.as_str(),
|
||||
dynamic_state.judgement_summary
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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())
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
|
||||
format!(
|
||||
"以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n{}",
|
||||
serde_json::to_string_pretty(chat_history).unwrap_or_else(|_| "[]".to_string())
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_user_input_signal(value: Option<&JsonValue>) -> Option<PromptUserInputSignal> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"rich" => Some(PromptUserInputSignal::Rich),
|
||||
"normal" => Some(PromptUserInputSignal::Normal),
|
||||
"sparse" => Some(PromptUserInputSignal::Sparse),
|
||||
"correction" => Some(PromptUserInputSignal::Correction),
|
||||
"delegate" => Some(PromptUserInputSignal::Delegate),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_drift_risk(value: Option<&JsonValue>) -> Option<PromptDriftRisk> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"low" => Some(PromptDriftRisk::Low),
|
||||
"medium" => Some(PromptDriftRisk::Medium),
|
||||
"high" => Some(PromptDriftRisk::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_conversation_mode(value: Option<&JsonValue>) -> Option<PromptConversationMode> {
|
||||
match value.and_then(JsonValue::as_str)? {
|
||||
"bootstrap" => Some(PromptConversationMode::Bootstrap),
|
||||
"expand" => Some(PromptConversationMode::Expand),
|
||||
"compress" => Some(PromptConversationMode::Compress),
|
||||
"repair_direction" => Some(PromptConversationMode::RepairDirection),
|
||||
"force_complete" => Some(PromptConversationMode::ForceComplete),
|
||||
"closing" => Some(PromptConversationMode::Closing),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mode_rules(mode: PromptConversationMode) -> &'static str {
|
||||
match mode {
|
||||
PromptConversationMode::Bootstrap => {
|
||||
r#"当前模式:bootstrap
|
||||
|
||||
目标:
|
||||
1. 先把世界的基本方向抓住
|
||||
2. 不要一次塞太多新设定
|
||||
3. 回复要降低用户开口压力
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索
|
||||
2. 如果用户信息很少,不要强行把整套结构一次补满
|
||||
3. replyText 要像共创搭档,而不是像审问
|
||||
4. 默认只推进一个最关键的问题方向
|
||||
5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步
|
||||
6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题
|
||||
7. 不要把问题问得像表单采集,不要一口气追问多个维度
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户觉得“现在很容易继续往下说”
|
||||
2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉
|
||||
3. replyText 最好短、稳、可接话
|
||||
4. 如果用户信息很少,也不要显得冷淡或机械"#
|
||||
}
|
||||
PromptConversationMode::Expand => {
|
||||
r#"当前模式:expand
|
||||
|
||||
目标:
|
||||
1. 在保持现有方向的前提下,把设定结构逐步补全
|
||||
2. 尽量让一轮输入覆盖多个关键维度
|
||||
|
||||
本轮行为要求:
|
||||
1. 继续保留上一版里仍成立的设定
|
||||
2. 优先把用户本轮输入映射进多个关键维度,而不是只更新一个字段
|
||||
3. replyText 要明确体现“你已经理解了哪些内容”
|
||||
4. 不要突然大幅改写已经成形的世界
|
||||
5. 如果用户这一轮给了多条有效信息,replyText 应先把这些信息自然串起来,再决定下一步
|
||||
6. 可以适度替用户整理,但不要把回复写成总结报告
|
||||
7. 默认继续往前推一步,不要在还没必要时突然收束或突然跳到成稿感
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚说的内容都被接住了”
|
||||
2. 回复里可以带一点顺势整理感,但不要太像会议纪要
|
||||
3. 不要无视用户刚提供的高价值细节
|
||||
4. 不要让用户觉得系统在自顾自重写世界"#
|
||||
}
|
||||
PromptConversationMode::Compress => {
|
||||
r#"当前模式:compress
|
||||
|
||||
目标:
|
||||
1. 开始收束当前设定
|
||||
2. 减少无效发散
|
||||
3. 让 progress 更接近可进入下一阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 新的设定结构优先保留稳定内容,不要无端重写
|
||||
2. 对用户本轮输入做高密度吸收
|
||||
3. replyText 要更聚焦,不要绕圈
|
||||
4. 默认只推进当前最影响 completion 的一步
|
||||
5. 如果用户还在补细节,优先把细节挂回现有骨架,而不是继续开新分支
|
||||
6. 可以适度提醒“还差哪类关键空位”,但不要把回复写成 checklist
|
||||
7. 如果已有信息足够,replyText 可以更像“确认并收束”,少一点继续发散式追问
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉世界正在变得更稳,而不是越来越散
|
||||
2. 让推进感更明确,但不要显得催促
|
||||
3. 回复语气应更笃定一些,减少反复横跳
|
||||
4. 不要把用户刚补进来的细节又冲淡掉"#
|
||||
}
|
||||
PromptConversationMode::RepairDirection => {
|
||||
r#"当前模式:repair_direction
|
||||
|
||||
目标:
|
||||
1. 处理用户对既有设定的修正
|
||||
2. 避免世界方向飘散或自相矛盾
|
||||
|
||||
本轮行为要求:
|
||||
1. 如果用户明确改口,新的设定结构必须体现修正后的方向
|
||||
2. 对已经不再成立的旧设定,不要机械保留
|
||||
3. progressPercent 可以停滞,也可以小幅回落,但不能为负
|
||||
4. replyText 要承认用户的修正,并顺着修正后的方向继续聊
|
||||
5. 先处理“改掉什么”,再决定“往哪里继续推”
|
||||
6. 不要一边口头承认用户修正,一边在设定结构里偷偷留住旧方向
|
||||
7. 如果修正幅度很大,replyText 可以帮助用户确认新方向已经接管当前语境
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“我刚刚的纠偏真的生效了”
|
||||
2. 不要和用户辩论旧方案为什么也行
|
||||
3. 不要表现出对修正的不情愿
|
||||
4. 回复要体现重心已经切到新方向,而不是停留在旧世界观惯性里"#
|
||||
}
|
||||
PromptConversationMode::ForceComplete => {
|
||||
r#"当前模式:force_complete
|
||||
|
||||
目标:
|
||||
1. 基于当前方向直接补齐剩余设定
|
||||
2. 生成一版尽量完整、可进入下一阶段的设定结构
|
||||
3. 结束当前收集阶段
|
||||
|
||||
本轮行为要求:
|
||||
1. 尽量保留已经形成的世界方向
|
||||
2. 对明显缺失的关键维度进行合理补全
|
||||
3. 不要继续拉长聊天,不要再追问用户
|
||||
4. progressPercent 直接输出为 100
|
||||
5. replyText 要自然引导用户点击“生成游戏设定草稿”
|
||||
6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突
|
||||
7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经
|
||||
8. replyText 更像阶段完成提示,不再像继续采集信息的对话
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感到“系统已经帮我把能补的补好了”
|
||||
2. 不要在这一步突然冒出很多陌生设定把用户吓出戏
|
||||
3. 回复要有完成感,但不要太官话
|
||||
4. 清楚告诉用户下一步可以做什么"#
|
||||
}
|
||||
PromptConversationMode::Closing => {
|
||||
r#"当前模式:closing
|
||||
|
||||
目标:
|
||||
1. 尽量形成一版可用的设定底子
|
||||
2. 不再继续发散新世界观
|
||||
|
||||
本轮行为要求:
|
||||
1. 优先收束,而不是扩写
|
||||
2. 不要大改已经成形的核心设定
|
||||
3. progressPercent 接近完成时,replyText 要更像确认与推进
|
||||
4. 如果用户没有大改方向,尽量让下一版内容更稳定
|
||||
5. 可以轻微补足缺口,但不要再大开新支线
|
||||
6. replyText 应减少探索式措辞,增加“已经基本成形”的稳定感
|
||||
7. 如果只差少量空位,优先把这些空位自然补平,而不是重新打开大话题
|
||||
|
||||
用户体验要求:
|
||||
1. 让用户感觉作品已经快成了,而不是还在无穷试探
|
||||
2. 回复可以更像确认和轻推,不要继续像前期那样频繁试探
|
||||
3. 保持留白感,不要把所有东西都一次说死
|
||||
4. 让用户自然过渡到下一阶段,而不是突然被切断对话"#
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn user_signal_rules(signal: PromptUserInputSignal) -> &'static str {
|
||||
match signal {
|
||||
PromptUserInputSignal::Rich => {
|
||||
r#"本轮用户输入信息密度高。
|
||||
请尽量从这一轮里提取多个锚点,不要只更新单一方向。
|
||||
如果一条输入同时影响世界方向、冲突和关系,请在新的完整设定结构中一起体现。"#
|
||||
}
|
||||
PromptUserInputSignal::Normal => {
|
||||
r#"本轮用户输入为正常补充。
|
||||
请优先顺着当前方向稳定更新,不要主动扩写太多新设定。"#
|
||||
}
|
||||
PromptUserInputSignal::Sparse => {
|
||||
r#"本轮用户输入较少或较虚。
|
||||
请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。
|
||||
replyText 要让用户容易继续往下说。"#
|
||||
}
|
||||
PromptUserInputSignal::Correction => {
|
||||
r#"本轮用户在修正或推翻旧设定。
|
||||
请优先吸收修正,不要机械复读旧版本。
|
||||
新的完整设定结构必须以修正后的方向为准。"#
|
||||
}
|
||||
PromptUserInputSignal::Delegate => {
|
||||
r#"本轮用户把部分决定权交给你。
|
||||
你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。
|
||||
新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。"#
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@ mod custom_world;
|
||||
mod custom_world_agent_entities;
|
||||
mod custom_world_agent_turn;
|
||||
mod custom_world_ai;
|
||||
mod custom_world_asset_prompts;
|
||||
mod custom_world_foundation_draft;
|
||||
mod custom_world_result_prompts;
|
||||
mod custom_world_rpg_draft_prompts;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn password_entry(
|
||||
fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
match error {
|
||||
PasswordEntryError::InvalidUsername => AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("用户名只允许 3 到 24 位字母、数字、下划线")
|
||||
.with_message("手机号格式不正确")
|
||||
.with_details(json!({
|
||||
"field": "username",
|
||||
})),
|
||||
@@ -80,10 +80,10 @@ fn map_password_entry_error(error: PasswordEntryError) -> AppError {
|
||||
"field": "username",
|
||||
})),
|
||||
PasswordEntryError::InvalidCredentials => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
|
||||
}
|
||||
PasswordEntryError::UserNotFound => {
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("用户名或密码错误")
|
||||
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("手机号或密码错误")
|
||||
}
|
||||
PasswordEntryError::Store(_) | PasswordEntryError::PasswordHash(_) => {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
|
||||
|
||||
@@ -475,24 +475,25 @@ impl PasswordEntryService {
|
||||
&self,
|
||||
input: PasswordEntryInput,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
let username = normalize_username(&input.username)?;
|
||||
validate_password(&input.password)?;
|
||||
|
||||
if let Some(existing_user) = self.store.find_by_username(&username)? {
|
||||
if !existing_user.password_login_enabled {
|
||||
// 登录面板现在固定使用手机号作为密码登录标识;先走手机号索引,
|
||||
// 再保留历史用户名路径给开发游客和旧测试数据使用。
|
||||
if let Ok(normalized_phone) = normalize_mainland_china_phone_number(&input.username) {
|
||||
let Some(existing_user) = self
|
||||
.store
|
||||
.find_by_phone_number_for_password(&normalized_phone.e164)?
|
||||
else {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
let is_valid = verify_password(&existing_user.password_hash, &input.password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
};
|
||||
|
||||
return Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
});
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
let username = normalize_username(&input.username)?;
|
||||
|
||||
if let Some(existing_user) = self.store.find_by_username(&username)? {
|
||||
return verify_stored_password_user(existing_user, &input.password).await;
|
||||
}
|
||||
|
||||
Err(PasswordEntryError::InvalidCredentials)
|
||||
@@ -1292,6 +1293,24 @@ impl InMemoryAuthStore {
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn find_by_phone_number_for_password(
|
||||
&self,
|
||||
phone_number: &str,
|
||||
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
|
||||
let state = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
|
||||
let Some(user_id) = state.phone_to_user_id.get(phone_number) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(state
|
||||
.users_by_username
|
||||
.values()
|
||||
.find(|stored_user| stored_user.user.id == *user_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
fn create_phone_user(
|
||||
&self,
|
||||
phone_number: PhoneNumberSnapshot,
|
||||
@@ -2220,6 +2239,27 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_stored_password_user(
|
||||
existing_user: StoredPasswordUser,
|
||||
password: &str,
|
||||
) -> Result<PasswordEntryResult, PasswordEntryError> {
|
||||
if !existing_user.password_login_enabled {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let is_valid = verify_password(&existing_user.password_hash, password)
|
||||
.await
|
||||
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
|
||||
if !is_valid {
|
||||
return Err(PasswordEntryError::InvalidCredentials);
|
||||
}
|
||||
|
||||
Ok(PasswordEntryResult {
|
||||
user: existing_user.user,
|
||||
created: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_sms_code_format(verify_code: &str) -> Result<(), PhoneAuthError> {
|
||||
let verify_code = verify_code.trim();
|
||||
if verify_code.len() != SMS_CODE_LENGTH
|
||||
|
||||
@@ -562,6 +562,21 @@ pub struct CustomWorldAgentOperationGetInput {
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CustomWorldAgentOperationProgressInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_type: RpgAgentOperationType,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_progress: u32,
|
||||
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 CustomWorldAgentOperationProcedureResult {
|
||||
@@ -1205,6 +1220,24 @@ pub fn validate_custom_world_agent_operation_get_input(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_agent_operation_progress_input(
|
||||
input: &CustomWorldAgentOperationProgressInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
validate_custom_world_agent_operation_get_input(&CustomWorldAgentOperationGetInput {
|
||||
session_id: input.session_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
operation_id: input.operation_id.clone(),
|
||||
})?;
|
||||
validate_custom_world_agent_operation_fields(
|
||||
&input.operation_id,
|
||||
&input.session_id,
|
||||
&input.phase_label,
|
||||
input.operation_progress,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_custom_world_works_list_input(
|
||||
input: &CustomWorldWorksListInput,
|
||||
) -> Result<(), CustomWorldFieldError> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{
|
||||
self as __sdk,
|
||||
__lib,
|
||||
__sats,
|
||||
__ws,
|
||||
};
|
||||
|
||||
use super::rpg_agent_operation_status_type::RpgAgentOperationStatus;
|
||||
use super::rpg_agent_operation_type_type::RpgAgentOperationType;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct CustomWorldAgentOperationProgressInput {
|
||||
pub session_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub operation_id: String,
|
||||
pub operation_type: RpgAgentOperationType,
|
||||
pub operation_status: RpgAgentOperationStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
pub operation_progress: u32,
|
||||
pub error_message: Option::<String>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for CustomWorldAgentOperationProgressInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{
|
||||
self as __sdk,
|
||||
__lib,
|
||||
__sats,
|
||||
__ws,
|
||||
};
|
||||
|
||||
use super::custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult;
|
||||
use super::custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct UpsertCustomWorldAgentOperationProgressArgs {
|
||||
pub input: CustomWorldAgentOperationProgressInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for UpsertCustomWorldAgentOperationProgressArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `upsert_custom_world_agent_operation_progress`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait upsert_custom_world_agent_operation_progress {
|
||||
fn upsert_custom_world_agent_operation_progress(
|
||||
&self,
|
||||
input: CustomWorldAgentOperationProgressInput,
|
||||
) {
|
||||
self.upsert_custom_world_agent_operation_progress_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn upsert_custom_world_agent_operation_progress_then(
|
||||
&self,
|
||||
input: CustomWorldAgentOperationProgressInput,
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl upsert_custom_world_agent_operation_progress for super::RemoteProcedures {
|
||||
fn upsert_custom_world_agent_operation_progress_then(
|
||||
&self,
|
||||
input: CustomWorldAgentOperationProgressInput,
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<CustomWorldAgentOperationProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp.invoke_procedure_with_callback::<_, CustomWorldAgentOperationProcedureResult>(
|
||||
"upsert_custom_world_agent_operation_progress",
|
||||
UpsertCustomWorldAgentOperationProgressArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1588,13 +1588,15 @@ fn execute_custom_world_agent_action_tx(
|
||||
}
|
||||
"publish_world" => execute_publish_world_action(ctx, &session, &input, &payload),
|
||||
"revert_checkpoint" => execute_revert_checkpoint_action(ctx, &session, &input, &payload),
|
||||
"generate_characters"
|
||||
| "generate_landmarks"
|
||||
| "generate_role_assets"
|
||||
| "sync_role_assets"
|
||||
| "generate_scene_assets"
|
||||
| "sync_scene_assets"
|
||||
| "expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
|
||||
"generate_characters" | "generate_landmarks" => {
|
||||
execute_generate_entities_action(ctx, &session, &input, &payload)
|
||||
}
|
||||
"generate_role_assets" | "generate_scene_assets" => {
|
||||
execute_prepare_asset_studio_action(ctx, &session, &input, &payload)
|
||||
}
|
||||
"sync_role_assets" => execute_sync_role_assets_action(ctx, &session, &input, &payload),
|
||||
"sync_scene_assets" => execute_sync_scene_assets_action(ctx, &session, &input, &payload),
|
||||
"expand_long_tail" => execute_placeholder_custom_world_action(ctx, &session, &input),
|
||||
other => Err(format!("custom world action `{other}` 当前尚未支持")),
|
||||
}
|
||||
}
|
||||
@@ -2134,6 +2136,146 @@ fn execute_revert_checkpoint_action(
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
|
||||
fn execute_prepare_asset_studio_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_draft_refining_stage(session.stage, input.action.as_str())?;
|
||||
let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?;
|
||||
let (focus_id, operation_type, message_text, phase_label, phase_detail) =
|
||||
if input.action == "generate_role_assets" {
|
||||
let role_id = read_first_payload_text(payload, "roleIds", "roleId")
|
||||
.ok_or_else(|| "generate_role_assets requires roleIds".to_string())?;
|
||||
let role = find_profile_entity_by_id(&draft_profile, &["playableNpcs", "storyNpcs"], &role_id)
|
||||
.ok_or_else(|| "未找到目标角色,无法进入角色资产工坊。".to_string())?;
|
||||
let role_name = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string());
|
||||
(
|
||||
role_id,
|
||||
RpgAgentOperationType::GenerateRoleAssets,
|
||||
format!("已为「{}」准备好角色资产工坊,先生成主图候选,再补核心动作。", role_name),
|
||||
"角色资产工坊已就绪",
|
||||
format!("「{}」现在可以开始生成主图和动作。", role_name),
|
||||
)
|
||||
} else {
|
||||
let scene_id = read_first_payload_text(payload, "sceneIds", "sceneId")
|
||||
.ok_or_else(|| "generate_scene_assets requires sceneIds".to_string())?;
|
||||
let scene_kind = payload.get("sceneKind").and_then(JsonValue::as_str).map(str::trim).unwrap_or("landmark");
|
||||
let scene = if scene_kind == "camp" {
|
||||
draft_profile.get("camp").and_then(JsonValue::as_object)
|
||||
} else {
|
||||
find_profile_entity_by_id(&draft_profile, &["landmarks"], &scene_id)
|
||||
}
|
||||
.ok_or_else(|| "未找到目标场景,无法进入场景资产工坊。".to_string())?;
|
||||
let scene_name = read_optional_text_field(scene, &["name"])
|
||||
.unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "未命名场景" }.to_string());
|
||||
(
|
||||
scene_id,
|
||||
RpgAgentOperationType::GenerateSceneAssets,
|
||||
format!("已为「{}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。", scene_name),
|
||||
"场景资产工坊已就绪",
|
||||
format!("「{}」现在可以继续生成和确认正式场景图。", scene_name),
|
||||
)
|
||||
};
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
focus_card_id: Some(Some(focus_id)),
|
||||
last_assistant_reply: Some(Some(message_text.clone())),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &message_text, input.submitted_at_micros);
|
||||
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, operation_type, phase_label, &phase_detail, input.submitted_at_micros);
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_sync_role_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_draft_refining_stage(session.stage, "sync_role_assets")?;
|
||||
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.ok_or_else(|| "sync_role_assets requires an existing draft foundation".to_string())?;
|
||||
let role_id = read_required_payload_text(payload, "roleId", "sync_role_assets requires roleId")?;
|
||||
let portrait_path = read_required_payload_text(payload, "portraitPath", "sync_role_assets requires portraitPath")?;
|
||||
let generated_visual_asset_id = read_required_payload_text(payload, "generatedVisualAssetId", "sync_role_assets requires generatedVisualAssetId")?;
|
||||
let generated_animation_set_id = payload.get("generatedAnimationSetId").and_then(JsonValue::as_str).map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned);
|
||||
let animation_map = payload.get("animationMap").cloned();
|
||||
let updated_role = apply_role_asset_publish_result(&mut draft_profile, &role_id, &portrait_path, &generated_visual_asset_id, generated_animation_set_id.as_deref(), animation_map)?;
|
||||
let role_name = read_optional_text_field(&updated_role, &["name"]).unwrap_or_else(|| "当前角色".to_string());
|
||||
let asset_status = resolve_role_asset_status(&updated_role);
|
||||
let asset_status_label = resolve_role_asset_status_label(asset_status).to_string();
|
||||
upsert_asset_role_card(ctx, &session.session_id, &role_id, &updated_role, asset_status, &asset_status_label, input.submitted_at_micros)?;
|
||||
let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json));
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
focus_card_id: Some(Some(role_id.clone())),
|
||||
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
|
||||
last_assistant_reply: Some(Some(format!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label))),
|
||||
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
|
||||
result_preview_json: Some(build_result_preview_json(Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?),
|
||||
checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-role-assets", &format!("同步角色资产 {}", role_name), session))?),
|
||||
asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的角色资产写回草稿,当前状态:{}。", role_name, asset_status_label), input.submitted_at_micros);
|
||||
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncRoleAssets, "角色资产已同步", &format!("「{}」的资产状态已更新为{}。", role_name, asset_status_label), input.submitted_at_micros);
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn execute_sync_scene_assets_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
input: &CustomWorldAgentActionExecuteInput,
|
||||
payload: &JsonMap<String, JsonValue>,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
ensure_draft_refining_stage(session.stage, "sync_scene_assets")?;
|
||||
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
|
||||
.ok_or_else(|| "sync_scene_assets requires an existing draft foundation".to_string())?;
|
||||
let scene_id = read_required_payload_text(payload, "sceneId", "sync_scene_assets requires sceneId")?;
|
||||
let scene_kind = read_required_payload_text(payload, "sceneKind", "sync_scene_assets requires sceneKind")?;
|
||||
let image_src = read_required_payload_text(payload, "imageSrc", "sync_scene_assets requires imageSrc")?;
|
||||
let generated_scene_asset_id = read_required_payload_text(payload, "generatedSceneAssetId", "sync_scene_assets requires generatedSceneAssetId")?;
|
||||
let updated_scene = apply_scene_asset_publish_result(&mut draft_profile, &scene_id, &scene_kind, &image_src, &generated_scene_asset_id, payload.get("generatedScenePrompt").cloned().unwrap_or(JsonValue::Null), payload.get("generatedSceneModel").cloned().unwrap_or(JsonValue::Null))?;
|
||||
let scene_name = read_optional_text_field(&updated_scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "当前场景" }.to_string());
|
||||
upsert_asset_scene_card(ctx, &session.session_id, &scene_id, &scene_kind, &updated_scene, input.submitted_at_micros)?;
|
||||
let gate = summarize_publish_gate_from_json(&input.session_id, RpgAgentStage::VisualRefining, Some(&draft_profile), &parse_json_array_or_empty(&session.quality_findings_json));
|
||||
let next_session = rebuild_custom_world_agent_session_row(
|
||||
session,
|
||||
CustomWorldAgentSessionPatch {
|
||||
stage: Some(RpgAgentStage::VisualRefining),
|
||||
focus_card_id: Some(Some(scene_id.clone())),
|
||||
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(draft_profile.clone()))?)),
|
||||
last_assistant_reply: Some(Some(format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name))),
|
||||
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(&gate))?)),
|
||||
result_preview_json: Some(build_result_preview_json(Some(&draft_profile), &gate, &parse_json_array_or_empty(&session.quality_findings_json), input.submitted_at_micros)?),
|
||||
checkpoints_json: Some(append_checkpoint_json(&session.checkpoints_json, &build_session_checkpoint_value("sync-scene-assets", &format!("同步场景资产 {}", scene_name), session))?),
|
||||
asset_coverage_json: Some(build_asset_coverage_json(&draft_profile)?),
|
||||
updated_at_micros: Some(input.submitted_at_micros),
|
||||
..CustomWorldAgentSessionPatch::default()
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_session(ctx, session, next_session);
|
||||
append_custom_world_action_result_message(ctx, &session.session_id, &input.operation_id, &format!("已把「{}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。", scene_name), input.submitted_at_micros);
|
||||
let operation = build_and_insert_custom_world_operation(ctx, &input.operation_id, &session.session_id, RpgAgentOperationType::SyncSceneAssets, "场景资产已同步", &format!("「{}」的场景图已经进入当前草稿。", scene_name), input.submitted_at_micros);
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
|
||||
fn execute_placeholder_custom_world_action(
|
||||
ctx: &ReducerContext,
|
||||
session: &CustomWorldAgentSession,
|
||||
@@ -3278,6 +3420,121 @@ fn parse_json_array_or_empty(raw: &str) -> Vec<JsonValue> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn read_first_payload_text(payload: &JsonMap<String, JsonValue>, array_key: &str, scalar_key: &str) -> Option<String> {
|
||||
payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str)
|
||||
.or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str))
|
||||
.map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn find_profile_entity_by_id<'a>(profile: &'a JsonMap<String, JsonValue>, fields: &[&str], entity_id: &str) -> Option<&'a JsonMap<String, JsonValue>> {
|
||||
for field in fields {
|
||||
if let Some(entries) = profile.get(*field).and_then(JsonValue::as_array) {
|
||||
for entry in entries {
|
||||
let Some(object) = entry.as_object() else { continue; };
|
||||
if read_optional_text_field(object, &["id"]).as_deref() == Some(entity_id) { return Some(object); }
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn apply_role_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, role_id: &str, portrait_path: &str, generated_visual_asset_id: &str, generated_animation_set_id: Option<&str>, animation_map: Option<JsonValue>) -> Result<JsonMap<String, JsonValue>, String> {
|
||||
for field in ["playableNpcs", "storyNpcs"] {
|
||||
let Some(entries) = profile.get_mut(field).and_then(JsonValue::as_array_mut) else { continue; };
|
||||
for entry in entries {
|
||||
let Some(object) = entry.as_object_mut() else { continue; };
|
||||
if read_optional_text_field(object, &["id"]).as_deref() != Some(role_id) { continue; }
|
||||
object.insert("imageSrc".to_string(), JsonValue::String(portrait_path.to_string()));
|
||||
object.insert("generatedVisualAssetId".to_string(), JsonValue::String(generated_visual_asset_id.to_string()));
|
||||
if let Some(asset_id) = generated_animation_set_id { object.insert("generatedAnimationSetId".to_string(), JsonValue::String(asset_id.to_string())); }
|
||||
if let Some(map) = animation_map { object.insert("animationMap".to_string(), map); }
|
||||
return Ok(object.clone());
|
||||
}
|
||||
}
|
||||
Err("目标角色不存在,无法同步角色资产。".to_string())
|
||||
}
|
||||
|
||||
fn apply_scene_asset_publish_result(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, scene_kind: &str, image_src: &str, generated_scene_asset_id: &str, generated_scene_prompt: JsonValue, generated_scene_model: JsonValue) -> Result<JsonMap<String, JsonValue>, String> {
|
||||
let updated_scene = if scene_kind == "camp" {
|
||||
let camp = profile.get_mut("camp").and_then(JsonValue::as_object_mut).ok_or_else(|| "目标营地不存在,无法同步场景资产。".to_string())?;
|
||||
if read_optional_text_field(camp, &["id"]).as_deref() != Some(scene_id) { return Err("目标营地不存在,无法同步场景资产。".to_string()); }
|
||||
camp.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string()));
|
||||
camp.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
|
||||
camp.insert("generatedScenePrompt".to_string(), generated_scene_prompt);
|
||||
camp.insert("generatedSceneModel".to_string(), generated_scene_model);
|
||||
camp.clone()
|
||||
} else {
|
||||
let landmarks = profile.get_mut("landmarks").and_then(JsonValue::as_array_mut).ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?;
|
||||
let mut updated = None;
|
||||
for entry in landmarks {
|
||||
let Some(object) = entry.as_object_mut() else { continue; };
|
||||
if read_optional_text_field(object, &["id"]).as_deref() != Some(scene_id) { continue; }
|
||||
object.insert("imageSrc".to_string(), JsonValue::String(image_src.to_string()));
|
||||
object.insert("generatedSceneAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
|
||||
object.insert("generatedScenePrompt".to_string(), generated_scene_prompt.clone());
|
||||
object.insert("generatedSceneModel".to_string(), generated_scene_model.clone());
|
||||
updated = Some(object.clone());
|
||||
break;
|
||||
}
|
||||
updated.ok_or_else(|| "目标地点不存在,无法同步场景资产。".to_string())?
|
||||
};
|
||||
update_scene_chapter_acts_for_scene(profile, scene_id, image_src, generated_scene_asset_id);
|
||||
Ok(updated_scene)
|
||||
}
|
||||
|
||||
fn update_scene_chapter_acts_for_scene(profile: &mut JsonMap<String, JsonValue>, scene_id: &str, image_src: &str, generated_scene_asset_id: &str) {
|
||||
let Some(chapters) = profile.get_mut("sceneChapters").and_then(JsonValue::as_array_mut) else { return; };
|
||||
for chapter in chapters {
|
||||
let Some(chapter_object) = chapter.as_object_mut() else { continue; };
|
||||
if read_optional_text_field(chapter_object, &["sceneId"]).as_deref() != Some(scene_id) { continue; }
|
||||
let Some(acts) = chapter_object.get_mut("acts").and_then(JsonValue::as_array_mut) else { continue; };
|
||||
for act in acts {
|
||||
if let Some(act_object) = act.as_object_mut() {
|
||||
act_object.insert("backgroundImageSrc".to_string(), JsonValue::String(image_src.to_string()));
|
||||
act_object.insert("backgroundAssetId".to_string(), JsonValue::String(generated_scene_asset_id.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_role_asset_status(role: &JsonMap<String, JsonValue>) -> CustomWorldRoleAssetStatus {
|
||||
let has_portrait = read_optional_text_field(role, &["imageSrc"]).is_some() && read_optional_text_field(role, &["generatedVisualAssetId"]).is_some();
|
||||
if !has_portrait { return CustomWorldRoleAssetStatus::Missing; }
|
||||
let has_animation_set = read_optional_text_field(role, &["generatedAnimationSetId"]).is_some();
|
||||
let has_animation_map = role.get("animationMap").and_then(JsonValue::as_object).map(|map| !map.is_empty()).unwrap_or(false);
|
||||
if has_animation_set && has_animation_map { CustomWorldRoleAssetStatus::Complete } else if has_animation_set { CustomWorldRoleAssetStatus::AnimationsReady } else { CustomWorldRoleAssetStatus::VisualReady }
|
||||
}
|
||||
|
||||
fn resolve_role_asset_status_label(status: CustomWorldRoleAssetStatus) -> &'static str {
|
||||
match status { CustomWorldRoleAssetStatus::Complete => "动作已就绪", CustomWorldRoleAssetStatus::AnimationsReady => "动作补齐中", CustomWorldRoleAssetStatus::VisualReady => "主图已就绪", CustomWorldRoleAssetStatus::Missing => "待生成主图" }
|
||||
}
|
||||
|
||||
fn build_asset_coverage_json(profile: &JsonMap<String, JsonValue>) -> Result<String, String> {
|
||||
serialize_json_value(&json!({"roleAssets": [], "sceneAssets": [], "allRoleAssetsReady": false, "allSceneAssetsReady": false, "profileId": read_optional_text_field(profile, &["id"])}))
|
||||
}
|
||||
|
||||
fn upsert_asset_role_card(ctx: &ReducerContext, session_id: &str, role_id: &str, role: &JsonMap<String, JsonValue>, asset_status: CustomWorldRoleAssetStatus, asset_status_label: &str, updated_at_micros: i64) -> Result<(), String> {
|
||||
let title = read_optional_text_field(role, &["name"]).unwrap_or_else(|| "角色".to_string());
|
||||
let subtitle = read_optional_text_field(role, &["role", "relationToPlayer", "publicMask"]).unwrap_or_else(|| asset_status_label.to_string());
|
||||
let summary = read_optional_text_field(role, &["summary", "description", "publicMask"]).unwrap_or_else(|| "角色资产已写回草稿。".to_string());
|
||||
upsert_asset_card(ctx, session_id, role_id, RpgAgentDraftCardKind::Character, &title, &subtitle, &summary, Some(asset_status), Some(asset_status_label), updated_at_micros)
|
||||
}
|
||||
|
||||
fn upsert_asset_scene_card(ctx: &ReducerContext, session_id: &str, scene_id: &str, scene_kind: &str, scene: &JsonMap<String, JsonValue>, updated_at_micros: i64) -> Result<(), String> {
|
||||
let kind = if scene_kind == "camp" { RpgAgentDraftCardKind::Camp } else { RpgAgentDraftCardKind::Landmark };
|
||||
let title = read_optional_text_field(scene, &["name"]).unwrap_or_else(|| if scene_kind == "camp" { "开局营地" } else { "场景" }.to_string());
|
||||
let subtitle = read_optional_text_field(scene, &["purpose", "mood", "dangerLevel"]).unwrap_or_else(|| "场景资产已就绪".to_string());
|
||||
let summary = read_optional_text_field(scene, &["summary", "description", "publicMask"]).unwrap_or_else(|| "场景图已写回草稿。".to_string());
|
||||
upsert_asset_card(ctx, session_id, scene_id, kind, &title, &subtitle, &summary, None, Some("场景图已就绪"), updated_at_micros)
|
||||
}
|
||||
|
||||
fn upsert_asset_card(ctx: &ReducerContext, session_id: &str, card_id: &str, kind: RpgAgentDraftCardKind, title: &str, subtitle: &str, summary: &str, asset_status: Option<CustomWorldRoleAssetStatus>, asset_status_label: Option<&str>, updated_at_micros: i64) -> Result<(), String> {
|
||||
let detail_payload = json!({"id": card_id, "kind": kind.as_str(), "title": title, "sections": [{"id": "summary", "label": "摘要", "value": summary}], "linkedIds": [], "locked": false, "editable": true, "editableSectionIds": ["summary"], "warningMessages": []});
|
||||
let next = CustomWorldDraftCard { card_id: card_id.to_string(), session_id: session_id.to_string(), kind, status: RpgAgentDraftCardStatus::Draft, title: title.to_string(), subtitle: subtitle.to_string(), summary: summary.to_string(), linked_ids_json: "[]".to_string(), warning_count: 0, asset_status, asset_status_label: asset_status_label.map(ToOwned::to_owned), detail_payload_json: Some(serialize_json_value(&detail_payload)?), created_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros) };
|
||||
if let Some(existing) = ctx.db.custom_world_draft_card().card_id().find(&card_id.to_string()).filter(|entry| entry.session_id == session_id) { replace_custom_world_draft_card(ctx, &existing, CustomWorldDraftCard { created_at: existing.created_at, ..next }); } else { ctx.db.custom_world_draft_card().insert(next); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn serialize_json_value(value: &JsonValue) -> Result<String, String> {
|
||||
serde_json::to_string(value).map_err(|error| format!("JSON 序列化失败: {error}"))
|
||||
}
|
||||
|
||||
@@ -1192,6 +1192,25 @@ pub fn get_custom_world_agent_operation(
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn upsert_custom_world_agent_operation_progress(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: CustomWorldAgentOperationProgressInput,
|
||||
) -> CustomWorldAgentOperationProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_custom_world_agent_operation_progress_tx(tx, input.clone())) {
|
||||
Ok(operation) => CustomWorldAgentOperationProcedureResult {
|
||||
ok: true,
|
||||
operation: Some(operation),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => CustomWorldAgentOperationProcedureResult {
|
||||
ok: false,
|
||||
operation: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn continue_story_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: StoryContinueInput,
|
||||
@@ -1474,6 +1493,59 @@ fn get_custom_world_agent_operation_tx(
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn upsert_custom_world_agent_operation_progress_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldAgentOperationProgressInput,
|
||||
) -> Result<CustomWorldAgentOperationSnapshot, String> {
|
||||
validate_custom_world_agent_operation_progress_input(&input).map_err(|error| error.to_string())?;
|
||||
ctx.db
|
||||
.custom_world_agent_session()
|
||||
.session_id()
|
||||
.find(&input.session_id)
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||||
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
|
||||
|
||||
let timestamp = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
let operation = if let Some(current) = ctx
|
||||
.db
|
||||
.custom_world_agent_operation()
|
||||
.operation_id()
|
||||
.find(&input.operation_id)
|
||||
{
|
||||
if current.session_id != input.session_id {
|
||||
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
|
||||
}
|
||||
let next = rebuild_custom_world_agent_operation_row(
|
||||
¤t,
|
||||
CustomWorldAgentOperationPatch {
|
||||
status: Some(input.operation_status),
|
||||
phase_label: Some(input.phase_label.clone()),
|
||||
phase_detail: Some(input.phase_detail.clone()),
|
||||
progress: Some(input.operation_progress),
|
||||
error_message: Some(input.error_message.clone()),
|
||||
updated_at_micros: Some(input.updated_at_micros),
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_operation(ctx, ¤t, next.clone());
|
||||
next
|
||||
} else {
|
||||
ctx.db.custom_world_agent_operation().insert(CustomWorldAgentOperation {
|
||||
operation_id: input.operation_id.clone(),
|
||||
session_id: input.session_id.clone(),
|
||||
operation_type: input.operation_type,
|
||||
status: input.operation_status,
|
||||
phase_label: input.phase_label.clone(),
|
||||
phase_detail: input.phase_detail.clone(),
|
||||
progress: input.operation_progress,
|
||||
error_message: input.error_message.clone(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
})
|
||||
};
|
||||
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
|
||||
fn finalize_custom_world_agent_message_turn_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: CustomWorldAgentMessageFinalizeInput,
|
||||
@@ -2896,14 +2968,22 @@ fn execute_custom_world_agent_action_tx(
|
||||
.filter(|row| row.owner_user_id == input.owner_user_id)
|
||||
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
|
||||
|
||||
if ctx
|
||||
if let Some(existing_operation) = ctx
|
||||
.db
|
||||
.custom_world_agent_operation()
|
||||
.operation_id()
|
||||
.find(&input.operation_id)
|
||||
.is_some()
|
||||
{
|
||||
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
|
||||
let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation"
|
||||
&& existing_operation.session_id == input.session_id
|
||||
&& existing_operation.operation_type == RpgAgentOperationType::DraftFoundation
|
||||
&& matches!(
|
||||
existing_operation.status,
|
||||
RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running
|
||||
);
|
||||
if !can_reuse_running_draft_operation {
|
||||
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let payload = parse_optional_session_object(input.payload_json.as_deref()).unwrap_or_default();
|
||||
@@ -2990,7 +3070,7 @@ fn execute_draft_foundation_action(
|
||||
updated_at,
|
||||
);
|
||||
|
||||
let operation = build_and_insert_custom_world_operation(
|
||||
let operation = complete_custom_world_operation(
|
||||
ctx,
|
||||
&input.operation_id,
|
||||
&session.session_id,
|
||||
@@ -2998,7 +3078,7 @@ fn execute_draft_foundation_action(
|
||||
"底稿已整理",
|
||||
"第一版 foundation draft 已写入会话与世界卡。",
|
||||
updated_at,
|
||||
);
|
||||
)?;
|
||||
|
||||
Ok(build_custom_world_agent_operation_snapshot(&operation))
|
||||
}
|
||||
@@ -4117,6 +4197,53 @@ fn replace_custom_world_draft_card(
|
||||
ctx.db.custom_world_draft_card().insert(next);
|
||||
}
|
||||
|
||||
fn complete_custom_world_operation(
|
||||
ctx: &ReducerContext,
|
||||
operation_id: &str,
|
||||
session_id: &str,
|
||||
operation_type: RpgAgentOperationType,
|
||||
phase_label: &str,
|
||||
phase_detail: &str,
|
||||
timestamp_micros: i64,
|
||||
) -> Result<CustomWorldAgentOperation, String> {
|
||||
if let Some(current) = ctx
|
||||
.db
|
||||
.custom_world_agent_operation()
|
||||
.operation_id()
|
||||
.find(&operation_id.to_string())
|
||||
{
|
||||
if current.session_id != session_id {
|
||||
return Err("custom_world_agent_operation.session_id 不匹配".to_string());
|
||||
}
|
||||
if current.operation_type != operation_type {
|
||||
return Err("custom_world_agent_operation.operation_type 不匹配".to_string());
|
||||
}
|
||||
let next = rebuild_custom_world_agent_operation_row(
|
||||
¤t,
|
||||
CustomWorldAgentOperationPatch {
|
||||
status: Some(RpgAgentOperationStatus::Completed),
|
||||
phase_label: Some(phase_label.to_string()),
|
||||
phase_detail: Some(phase_detail.to_string()),
|
||||
progress: Some(100),
|
||||
error_message: Some(None),
|
||||
updated_at_micros: Some(timestamp_micros),
|
||||
},
|
||||
)?;
|
||||
replace_custom_world_agent_operation(ctx, ¤t, next.clone());
|
||||
return Ok(next);
|
||||
}
|
||||
|
||||
Ok(build_and_insert_custom_world_operation(
|
||||
ctx,
|
||||
operation_id,
|
||||
session_id,
|
||||
operation_type,
|
||||
phase_label,
|
||||
phase_detail,
|
||||
timestamp_micros,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_and_insert_custom_world_operation(
|
||||
ctx: &ReducerContext,
|
||||
operation_id: &str,
|
||||
|
||||
@@ -49,6 +49,7 @@ export type CharacterAssetWorkflowCache = {
|
||||
characterId: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
animationPromptTextByKey?: Record<string, string>;
|
||||
visualDrafts: CharacterVisualDraft[];
|
||||
selectedVisualDraftId: string;
|
||||
selectedAnimation: string;
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
authEntry,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePassword,
|
||||
changePhoneNumber,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
@@ -694,6 +695,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setStoredLastLoginPhone(phone);
|
||||
setLoginCaptchaChallenge(null);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
@@ -711,6 +713,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await authEntry(username, password);
|
||||
setStoredLastLoginPhone(username);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
@@ -727,6 +730,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await resetPassword(phone, code, newPassword);
|
||||
setStoredLastLoginPhone(phone);
|
||||
activateReadyUser(nextUser);
|
||||
} catch (resetError) {
|
||||
setError(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AuthCaptchaChallenge,
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type SmsScene = 'login' | 'reset_password';
|
||||
@@ -57,11 +58,9 @@ export function LoginScreen({
|
||||
onResetPassword,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
||||
const [isResetPanelOpen, setIsResetPanelOpen] = useState(false);
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState(() => getStoredLastLoginPhone());
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [resetPhone, setResetPhone] = useState('');
|
||||
const [resetCode, setResetCode] = useState('');
|
||||
@@ -154,75 +153,55 @@ export function LoginScreen({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 px-5 py-5">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-full bg-[var(--platform-subpanel-bg)] p-1">
|
||||
<TabButton
|
||||
active={activeTab === 'login'}
|
||||
label="登录"
|
||||
onClick={() => setActiveTab('login')}
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'register'}
|
||||
label="注册"
|
||||
onClick={() => setActiveTab('register')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'login' ? (
|
||||
{passwordLoginEnabled ? (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!passwordLoginEnabled) {
|
||||
return;
|
||||
}
|
||||
void onPasswordSubmit(username, password);
|
||||
void onPasswordSubmit(phone, password);
|
||||
}}
|
||||
>
|
||||
{passwordLoginEnabled ? (
|
||||
<>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>账号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="用户名"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <ErrorBanner message={error} /> : null}
|
||||
|
||||
{passwordLoginEnabled ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitDisabled || !username.trim() || !password.trim()}
|
||||
disabled={submitDisabled || !phone.trim() || !password.trim()}
|
||||
className="platform-button platform-button--primary h-12 px-4 text-base disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loggingIn ? '登录中' : '登录'}
|
||||
{loggingIn ? '登录中' : '注册/登录'}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="self-center text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="self-end text-sm text-[var(--platform-accent)]"
|
||||
onClick={() => setIsResetPanelOpen(true)}
|
||||
>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{wechatLoginEnabled ? (
|
||||
<WechatButton
|
||||
@@ -232,7 +211,9 @@ export function LoginScreen({
|
||||
/>
|
||||
) : null}
|
||||
</form>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{phoneLoginEnabled ? (
|
||||
<PhoneCodeForm
|
||||
phone={phone}
|
||||
code={code}
|
||||
@@ -243,8 +224,9 @@ export function LoginScreen({
|
||||
loggingIn={loggingIn}
|
||||
error={error}
|
||||
hint={hint}
|
||||
submitLabel="注册并登录"
|
||||
submitLabel="注册/登录"
|
||||
enabled={phoneLoginEnabled}
|
||||
showPhoneField={!passwordLoginEnabled}
|
||||
onPhoneChange={setPhone}
|
||||
onCodeChange={setCode}
|
||||
onCaptchaAnswerChange={setCaptchaAnswer}
|
||||
@@ -262,7 +244,7 @@ export function LoginScreen({
|
||||
}}
|
||||
onSubmit={() => onPhoneSubmit(phone, code)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{!passwordLoginEnabled && !phoneLoginEnabled && !wechatLoginEnabled ? (
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
@@ -276,30 +258,6 @@ export function LoginScreen({
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`h-10 rounded-full text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-[var(--platform-panel-bg)] text-[var(--platform-text-strong)] shadow-sm'
|
||||
: 'text-[var(--platform-text-muted)]'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneCodeForm({
|
||||
phone,
|
||||
code,
|
||||
@@ -312,6 +270,7 @@ function PhoneCodeForm({
|
||||
hint,
|
||||
submitLabel,
|
||||
enabled,
|
||||
showPhoneField,
|
||||
onPhoneChange,
|
||||
onCodeChange,
|
||||
onCaptchaAnswerChange,
|
||||
@@ -329,6 +288,7 @@ function PhoneCodeForm({
|
||||
hint: string;
|
||||
submitLabel: string;
|
||||
enabled: boolean;
|
||||
showPhoneField: boolean;
|
||||
onPhoneChange: (value: string) => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onCaptchaAnswerChange: (value: string) => void;
|
||||
@@ -347,17 +307,19 @@ function PhoneCodeForm({
|
||||
void onSubmit();
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
{showPhoneField ? (
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="platform-input"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => onPhoneChange(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
|
||||
<span>验证码</span>
|
||||
|
||||
@@ -112,3 +112,20 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('我的拼图作品')).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub shows delete action for persisted rpg drafts', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onDeletePublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
@@ -199,9 +199,7 @@ export function CustomWorldCreationHub({
|
||||
: null
|
||||
}
|
||||
onDelete={
|
||||
item.kind === 'rpg' &&
|
||||
item.item.status === 'published' &&
|
||||
item.item.profileId
|
||||
item.kind === 'rpg' && item.item.profileId
|
||||
? () => {
|
||||
onDeletePublished?.(item.item);
|
||||
}
|
||||
|
||||
@@ -17,19 +17,23 @@ export function CustomWorldCreationStartCard({
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
return (
|
||||
<div className="platform-surface platform-surface--hero relative overflow-hidden px-5 py-5">
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5">
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-black text-white sm:text-3xl">
|
||||
<div className="relative z-10 space-y-2.5 sm:space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-black leading-none text-white sm:text-3xl">
|
||||
新建作品
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
|
||||
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block">
|
||||
直接选择游戏创作模板,立刻进入对应的共创工作台。
|
||||
</div>
|
||||
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
|
||||
{busy ? '正在开启' : '选择模板'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
@@ -41,15 +45,15 @@ export function CustomWorldCreationStartCard({
|
||||
onClick={() => {
|
||||
onCreateType(item.id);
|
||||
}}
|
||||
className={`platform-interactive-card relative overflow-hidden rounded-[1.5rem] border px-4 py-4 text-left transition ${
|
||||
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 ${
|
||||
item.locked
|
||||
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
|
||||
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
|
||||
} ${busy && !item.locked ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
|
||||
<span
|
||||
className={`platform-pill px-3 ${
|
||||
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
|
||||
item.locked
|
||||
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
|
||||
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
|
||||
@@ -64,11 +68,11 @@ export function CustomWorldCreationStartCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 text-lg font-black leading-tight text-inherit">
|
||||
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg">
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-2 text-sm ${
|
||||
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm ${
|
||||
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
|
||||
}`}
|
||||
>
|
||||
@@ -80,7 +84,7 @@ export function CustomWorldCreationStartCard({
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-[1.25rem] text-sm leading-6">
|
||||
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1216,6 +1216,42 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const handleDeleteLibraryEntry = useCallback(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
if (!entry.profileId || deletingCreationWorkId) {
|
||||
return;
|
||||
}
|
||||
|
||||
runProtectedAction(() => {
|
||||
const confirmed = window.confirm(
|
||||
`确认删除作品《${entry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingCreationWorkId(entry.profileId);
|
||||
platformBootstrap.setPlatformError(null);
|
||||
|
||||
void deleteRpgEntryWorldProfile(entry.profileId)
|
||||
.then(async (entries) => {
|
||||
platformBootstrap.setSavedCustomWorldEntries(entries);
|
||||
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
|
||||
await platformBootstrap.refreshPublishedGallery().catch(() => []);
|
||||
})
|
||||
.catch((error) => {
|
||||
platformBootstrap.setPlatformError(
|
||||
resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setDeletingCreationWorkId(null);
|
||||
});
|
||||
});
|
||||
},
|
||||
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
|
||||
);
|
||||
|
||||
const handleDeletePublishedWork = useCallback(
|
||||
(work: (typeof creationHubItems)[number]) => {
|
||||
if (!work.profileId || deletingCreationWorkId) {
|
||||
@@ -1556,6 +1592,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
detailNavigation.openLibraryDetail(entry);
|
||||
});
|
||||
}}
|
||||
onDeleteLibraryEntry={(entry) => {
|
||||
handleDeleteLibraryEntry(entry);
|
||||
}}
|
||||
deletingLibraryEntryId={deletingCreationWorkId}
|
||||
onSearchPublicCode={(keyword) => {
|
||||
void handlePublicCodeSearch(keyword);
|
||||
}}
|
||||
|
||||
@@ -51,6 +51,40 @@ function clampAnimationPlaybackRate(value: number) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildDefaultAnimationPromptTextByKey(defaultText: string) {
|
||||
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
|
||||
(result, action) => ({
|
||||
...result,
|
||||
[action.animation]: defaultText,
|
||||
}),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function pickCachedAnimationPromptTextByKey(
|
||||
cache: CharacterAssetWorkflowCache,
|
||||
fallbackText: string,
|
||||
) {
|
||||
const fromCache = cache.animationPromptTextByKey ?? {};
|
||||
|
||||
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
|
||||
(result, action) => {
|
||||
const cachedText = fromCache[action.animation]?.trim();
|
||||
const legacyText = cache.animationPromptText?.trim();
|
||||
return {
|
||||
...result,
|
||||
[action.animation]:
|
||||
cachedText && !isLegacyGeneratedActionDescription(cachedText)
|
||||
? cachedText
|
||||
: legacyText && !isLegacyGeneratedActionDescription(legacyText)
|
||||
? legacyText
|
||||
: fallbackText,
|
||||
};
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
function roundAnimationFps(value: number) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
@@ -548,8 +582,12 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
|
||||
CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE,
|
||||
);
|
||||
const [animationPromptText, setAnimationPromptText] = useState(
|
||||
initialPromptBundle.animationPromptText,
|
||||
const [animationPromptTextByKey, setAnimationPromptTextByKey] = useState<
|
||||
Partial<Record<AnimationState, string>>
|
||||
>(() =>
|
||||
buildDefaultAnimationPromptTextByKey(
|
||||
initialPromptBundle.animationPromptText,
|
||||
),
|
||||
);
|
||||
const [animationStatusByKey, setAnimationStatusByKey] = useState<
|
||||
Partial<Record<AnimationState, string | null>>
|
||||
@@ -617,6 +655,9 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
const selectedActionConfig =
|
||||
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
|
||||
CORE_ACTIONS[0]!;
|
||||
const animationPromptText =
|
||||
animationPromptTextByKey[selectedAnimation] ??
|
||||
initialPromptBundle.animationPromptText;
|
||||
const previewCharacter = useMemo(
|
||||
() =>
|
||||
buildAnimationPreviewCharacter({
|
||||
@@ -689,7 +730,11 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
let cancelled = false;
|
||||
setWorkingRole(baseRole);
|
||||
setVisualPromptText(initialPromptBundle.visualPromptText);
|
||||
setAnimationPromptText(initialPromptBundle.animationPromptText);
|
||||
setAnimationPromptTextByKey(
|
||||
buildDefaultAnimationPromptTextByKey(
|
||||
initialPromptBundle.animationPromptText,
|
||||
),
|
||||
);
|
||||
setReferenceImageDataUrls([]);
|
||||
setVisualDrafts([]);
|
||||
setSelectedVisualDraftId('');
|
||||
@@ -725,11 +770,11 @@ export function RpgCreationRoleAssetStudioModal({
|
||||
? cache.visualPromptText
|
||||
: initialPromptBundle.visualPromptText,
|
||||
);
|
||||
setAnimationPromptText(
|
||||
cache.animationPromptText &&
|
||||
!isLegacyGeneratedActionDescription(cache.animationPromptText)
|
||||
? cache.animationPromptText
|
||||
: initialPromptBundle.animationPromptText,
|
||||
setAnimationPromptTextByKey(
|
||||
pickCachedAnimationPromptTextByKey(
|
||||
cache,
|
||||
initialPromptBundle.animationPromptText,
|
||||
),
|
||||
);
|
||||
setVisualDrafts(cache.visualDrafts ?? []);
|
||||
setSelectedVisualDraftId(
|
||||
|
||||
@@ -2400,7 +2400,9 @@ function SceneImageGenerationModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [userPrompt, setUserPrompt] = useDraft(
|
||||
landmark.name.trim() || landmark.description.trim(),
|
||||
landmark.visualDescription?.trim() ||
|
||||
landmark.description.trim() ||
|
||||
landmark.name.trim(),
|
||||
);
|
||||
const [referenceImageSrc, setReferenceImageSrc] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
@@ -201,23 +201,6 @@ export function RpgCreationResultView({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && compactAgentResultMode && previewSourceLabel ? (
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
|
||||
当前结果页数据源:{previewSourceLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
|
||||
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
|
||||
{publishReady
|
||||
? '当前世界已满足发布门槛。'
|
||||
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
|
||||
{!publishReady ? (
|
||||
<div className="mt-2 text-xs text-[var(--platform-text-muted)]">
|
||||
详细诊断已记录到后端日志。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!error &&
|
||||
compactAgentResultMode &&
|
||||
publishBlockers.length <= 0 &&
|
||||
|
||||
@@ -1889,7 +1889,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
|
||||
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
|
||||
test('agent draft result back button syncs result profile before returning to creation hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
@@ -2076,7 +2076,7 @@ test('agent draft result back button returns to creation hub without redundant s
|
||||
sessionId === 'custom-world-agent-session-1' &&
|
||||
payload?.action === 'sync_result_profile',
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
resolvePlatformWorldLeadPortrait,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
|
||||
export type PlatformHomeTab = 'home' | 'category' | 'create' | 'saves' | 'profile';
|
||||
export interface RpgEntryHomeViewProps {
|
||||
activeTab: PlatformHomeTab;
|
||||
onTabChange: (tab: PlatformHomeTab) => void;
|
||||
@@ -76,6 +77,10 @@ export interface RpgEntryHomeViewProps {
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
onDeleteLibraryEntry?: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => void;
|
||||
deletingLibraryEntryId?: string | null;
|
||||
onSearchPublicCode?: (keyword: string) => void | Promise<void>;
|
||||
isSearchingPublicCode?: boolean;
|
||||
onOpenProfileDashboardCard?: (cardKey: ProfileDashboardCardKey) => void;
|
||||
@@ -92,6 +97,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
|
||||
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
|
||||
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'home',
|
||||
'category',
|
||||
'create',
|
||||
'saves',
|
||||
'profile',
|
||||
@@ -303,9 +309,13 @@ function WorldCard({
|
||||
function CreationLibraryCard({
|
||||
entry,
|
||||
onClick,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: {
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
|
||||
onClick: () => void;
|
||||
onDelete?: () => void;
|
||||
isDeleting?: boolean;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
@@ -343,6 +353,19 @@ function CreationLibraryCard({
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="platform-button platform-button--danger absolute right-2 top-2 z-20 min-h-0 rounded-full px-2.5 py-1 text-[10px] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isDeleting ? '删除中' : '删除'}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="relative z-10 flex h-full min-w-0 flex-col">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
@@ -449,17 +472,19 @@ function PlatformTabButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
@@ -476,17 +501,19 @@ function DesktopTabButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
emphasized = false,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
emphasized?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`platform-desktop-rail__button ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||||
className={`platform-desktop-rail__button ${emphasized ? 'platform-desktop-rail__button--primary' : ''} ${active ? 'platform-desktop-rail__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-desktop-rail__icon-shell">
|
||||
<Icon className="platform-desktop-rail__icon h-[1.1rem] w-[1.1rem]" />
|
||||
@@ -584,6 +611,41 @@ function DesktopTrendingItem({
|
||||
);
|
||||
}
|
||||
|
||||
function buildPublicCategoryGroups(
|
||||
featuredEntries: CustomWorldGalleryCard[],
|
||||
latestEntries: CustomWorldGalleryCard[],
|
||||
) {
|
||||
const publicEntryMap = new Map<string, CustomWorldGalleryCard>();
|
||||
|
||||
[...featuredEntries, ...latestEntries].forEach((entry) => {
|
||||
publicEntryMap.set(`${entry.ownerUserId}:${entry.profileId}`, entry);
|
||||
});
|
||||
|
||||
const categoryMap = new Map<string, CustomWorldGalleryCard[]>();
|
||||
Array.from(publicEntryMap.values()).forEach((entry) => {
|
||||
const tags = buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
const normalizedTags = tags.length > 0 ? tags : ['回响'];
|
||||
|
||||
normalizedTags.forEach((tag) => {
|
||||
const entries = categoryMap.get(tag) ?? [];
|
||||
entries.push(entry);
|
||||
categoryMap.set(tag, entries);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([tag, entries]) => ({ tag, entries }))
|
||||
.sort((left, right) => {
|
||||
if (right.entries.length !== left.entries.length) {
|
||||
return right.entries.length - left.entries.length;
|
||||
}
|
||||
|
||||
return left.tag.localeCompare(right.tag, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '刚刚保存';
|
||||
@@ -784,6 +846,8 @@ export function RpgEntryHomeView({
|
||||
onOpenCreateTypePicker,
|
||||
onOpenGalleryDetail,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
deletingLibraryEntryId = null,
|
||||
onSearchPublicCode,
|
||||
isSearchingPublicCode = false,
|
||||
onOpenProfileDashboardCard,
|
||||
@@ -791,12 +855,30 @@ export function RpgEntryHomeView({
|
||||
}: RpgEntryHomeViewProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [desktopSearchKeyword, setDesktopSearchKeyword] = useState('');
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const featuredShelf = useMemo(
|
||||
() => featuredEntries.slice(0, 6),
|
||||
[featuredEntries],
|
||||
);
|
||||
const categoryGroups = useMemo(
|
||||
() => buildPublicCategoryGroups(featuredEntries, latestEntries),
|
||||
[featuredEntries, latestEntries],
|
||||
);
|
||||
const activeCategoryGroup =
|
||||
categoryGroups.find((group) => group.tag === selectedCategoryTag) ??
|
||||
categoryGroups[0] ??
|
||||
null;
|
||||
const visibleTabs = useMemo<PlatformHomeTab[]>(
|
||||
() =>
|
||||
isAuthenticated
|
||||
? ['home', 'category', 'create', 'saves', 'profile']
|
||||
: ['home', 'create', 'category'],
|
||||
[isAuthenticated],
|
||||
);
|
||||
const snapshotWorldName =
|
||||
savedSnapshot?.gameState.customWorldProfile?.name ??
|
||||
savedSnapshot?.gameState.currentScenePreset?.name ??
|
||||
@@ -819,10 +901,39 @@ export function RpgEntryHomeView({
|
||||
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
|
||||
const tabIcons = {
|
||||
home: House,
|
||||
category: Tags,
|
||||
create: Sparkles,
|
||||
saves: Archive,
|
||||
profile: UserRound,
|
||||
} as const;
|
||||
const tabLabels = {
|
||||
home: '首页',
|
||||
category: '分类',
|
||||
create: '创作',
|
||||
saves: '存档',
|
||||
profile: '我的',
|
||||
} as const;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleTabs.includes(activeTab)) {
|
||||
onTabChange('home');
|
||||
}
|
||||
}, [activeTab, onTabChange, visibleTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (categoryGroups.length === 0) {
|
||||
setSelectedCategoryTag(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCategoryGroup = categoryGroups[0];
|
||||
if (
|
||||
firstCategoryGroup &&
|
||||
!categoryGroups.some((group) => group.tag === selectedCategoryTag)
|
||||
) {
|
||||
setSelectedCategoryTag(firstCategoryGroup.tag);
|
||||
}
|
||||
}, [categoryGroups, selectedCategoryTag]);
|
||||
const openUserSurface = () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -850,6 +961,9 @@ export function RpgEntryHomeView({
|
||||
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
const categoryPageClass = isDesktopLayout
|
||||
? DESKTOP_PAGE_STAGE_CLASS
|
||||
: MOBILE_PAGE_STAGE_CLASS;
|
||||
|
||||
const mobileHomeContent: ReactNode = (
|
||||
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
|
||||
@@ -944,6 +1058,51 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
);
|
||||
|
||||
const categoryContent: ReactNode = (
|
||||
<div className={categoryPageClass}>
|
||||
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
|
||||
<SectionHeader title="分类" detail="按标签浏览" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : categoryGroups.length > 0 && activeCategoryGroup ? (
|
||||
<>
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{categoryGroups.map((group) => {
|
||||
const active = group.tag === activeCategoryGroup.tag;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={group.tag}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategoryTag(group.tag)}
|
||||
className={`platform-pill shrink-0 px-3 py-1.5 ${active ? 'platform-pill--warm' : 'platform-pill--neutral'}`}
|
||||
>
|
||||
{group.tag} · {group.entries.length}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2.5 sm:gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{activeCategoryGroup.entries.map((entry) => (
|
||||
<WorldCard
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:category:${activeCategoryGroup.tag}`}
|
||||
entry={entry}
|
||||
badge={activeCategoryGroup.tag}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有可分类的作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const createContent: ReactNode = createTabContent ?? (
|
||||
<div className={MOBILE_PAGE_STAGE_CLASS}>
|
||||
<button
|
||||
@@ -983,6 +1142,8 @@ export function RpgEntryHomeView({
|
||||
key={`${entry.ownerUserId}:${entry.profileId}:mine`}
|
||||
entry={entry}
|
||||
onClick={() => onOpenLibraryDetail(entry)}
|
||||
onDelete={onDeleteLibraryEntry ? () => onDeleteLibraryEntry(entry) : undefined}
|
||||
isDeleting={deletingLibraryEntryId === entry.profileId}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -1529,11 +1690,14 @@ export function RpgEntryHomeView({
|
||||
|
||||
const tabContentById = {
|
||||
home: isDesktopLayout ? desktopHomeContent : mobileHomeContent,
|
||||
category: categoryContent,
|
||||
create: createContent,
|
||||
saves: savesContent,
|
||||
profile: profileContent,
|
||||
} satisfies Record<PlatformHomeTab, ReactNode>;
|
||||
const tabPanels = PLATFORM_HOME_TABS.map((tab) => (
|
||||
const tabPanels = PLATFORM_HOME_TABS.filter((tab) =>
|
||||
visibleTabs.includes(tab),
|
||||
).map((tab) => (
|
||||
<PlatformTabPanel key={tab} tab={tab} activeTab={activeTab}>
|
||||
{tabContentById[tab]}
|
||||
</PlatformTabPanel>
|
||||
@@ -1557,31 +1721,19 @@ export function RpgEntryHomeView({
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
|
||||
}}
|
||||
>
|
||||
<div className="platform-bottom-nav grid grid-cols-4">
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
icon={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'create'}
|
||||
label="创作"
|
||||
icon={tabIcons.create}
|
||||
onClick={() => onTabChange('create')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'saves'}
|
||||
label="存档"
|
||||
icon={tabIcons.saves}
|
||||
onClick={() => onTabChange('saves')}
|
||||
/>
|
||||
<PlatformTabButton
|
||||
active={activeTab === 'profile'}
|
||||
label="我的"
|
||||
icon={tabIcons.profile}
|
||||
onClick={() => onTabChange('profile')}
|
||||
/>
|
||||
<div
|
||||
className={`platform-bottom-nav grid ${visibleTabs.length === 5 ? 'grid-cols-5' : 'grid-cols-3'}`}
|
||||
>
|
||||
{visibleTabs.map((tab) => (
|
||||
<PlatformTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
onClick={() => onTabChange(tab)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1661,30 +1813,16 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="mt-5 flex min-h-0 gap-5">
|
||||
<aside className="platform-desktop-rail flex w-[5.8rem] shrink-0 flex-col gap-3 p-3">
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'home'}
|
||||
label="首页"
|
||||
icon={tabIcons.home}
|
||||
onClick={() => onTabChange('home')}
|
||||
/>
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'create'}
|
||||
label="创作"
|
||||
icon={tabIcons.create}
|
||||
onClick={() => onTabChange('create')}
|
||||
/>
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'saves'}
|
||||
label="存档"
|
||||
icon={tabIcons.saves}
|
||||
onClick={() => onTabChange('saves')}
|
||||
/>
|
||||
<DesktopTabButton
|
||||
active={activeTab === 'profile'}
|
||||
label="我的"
|
||||
icon={tabIcons.profile}
|
||||
onClick={() => onTabChange('profile')}
|
||||
/>
|
||||
{visibleTabs.map((tab) => (
|
||||
<DesktopTabButton
|
||||
key={tab}
|
||||
active={activeTab === tab}
|
||||
label={tabLabels[tab]}
|
||||
icon={tabIcons[tab]}
|
||||
emphasized={tab === 'create'}
|
||||
onClick={() => onTabChange(tab)}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="platform-tab-panel-stack min-w-0 flex-1">
|
||||
|
||||
@@ -941,6 +941,29 @@ body {
|
||||
box-shadow: var(--platform-bottom-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary {
|
||||
transform: translateY(-0.18rem);
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
|
||||
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.58rem);
|
||||
background: var(--platform-nav-active-fill);
|
||||
box-shadow: var(--platform-nav-icon-active-shadow);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon {
|
||||
width: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-size) + 0.18rem);
|
||||
color: var(--platform-nav-item-icon-active-text);
|
||||
}
|
||||
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.platform-bottom-nav__icon-shell,
|
||||
.platform-desktop-rail__icon-shell {
|
||||
display: flex;
|
||||
@@ -1098,8 +1121,9 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.platform-bottom-nav {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||
.platform-bottom-nav__button--primary .platform-bottom-nav__icon-shell {
|
||||
width: calc(var(--platform-bottom-nav-icon-shell-size) + 0.48rem);
|
||||
height: calc(var(--platform-bottom-nav-icon-shell-size) + 0.48rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,6 +1240,24 @@ body {
|
||||
var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-desktop-rail__button--primary {
|
||||
min-height: 5.85rem;
|
||||
border-color: var(--platform-nav-active-border);
|
||||
background: var(--platform-nav-active-fill);
|
||||
box-shadow: var(--platform-nav-active-shadow);
|
||||
}
|
||||
|
||||
.platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell {
|
||||
transform: scale(1.1);
|
||||
background: var(--platform-nav-item-icon-active-fill);
|
||||
box-shadow: var(--platform-nav-icon-active-shadow);
|
||||
}
|
||||
|
||||
.platform-desktop-rail__button--primary .platform-desktop-rail__icon,
|
||||
.platform-desktop-rail__button--primary .platform-desktop-rail__label {
|
||||
color: var(--platform-nav-item-text-active);
|
||||
}
|
||||
|
||||
.platform-desktop-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -7,9 +7,9 @@ import type {
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPasswordChangeResponse,
|
||||
AuthPasswordResetResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
@@ -18,15 +18,15 @@ import type {
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
PublicUserSearchResponse,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
ApiClientError,
|
||||
type ApiRequestOptions,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
emitAuthStateChange,
|
||||
@@ -71,10 +71,33 @@ const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
skipRefresh: true,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
|
||||
export function getStoredLastLoginPhone() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(LAST_LOGIN_PHONE_STORAGE_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setStoredLastLoginPhone(phone: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhoneInput(phone);
|
||||
if (!normalizedPhone) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
|
||||
}
|
||||
|
||||
export function getCaptchaChallengeFromError(
|
||||
error: unknown,
|
||||
): AuthCaptchaChallenge | null {
|
||||
|
||||
Reference in New Issue
Block a user