feat: restore generation draft persistence

This commit is contained in:
2026-04-25 11:41:09 +08:00
60 changed files with 38221 additions and 382 deletions

35281
.codex/tmp-schema.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -79,6 +79,7 @@
- [x] 兼容 `/api/runtime/custom-world/works`
- [x] 兼容 `/api/runtime/custom-world/agent/sessions`Stage 6 首批 Axum facade
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId`Stage 6 首批 Axum facade
- [x] 兼容 `DELETE /api/runtime/custom-world/agent/sessions/:sessionId`(草稿物理清理;若作品卡误以已发布来源 session 删除,则回落到关联 profile 软删除并返回 works
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages`Stage 7 deterministic message submit
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/messages/stream`Stage 8 SSE facade
- [x] 兼容 `/api/runtime/custom-world/agent/sessions/:sessionId/actions`Stage 9 全量 action procedure 已接通)

View File

@@ -38,3 +38,15 @@
2. 确认前端不再通过任何路径调用 Node 后端能力。
3. 删除旧脚本、旧 smoke、旧 manifest 与 `server-node/` 目录。
4. 删除冻结基线检查中对历史引用的豁免。
## 6. 已确认迁移项
### 6.1 场景幕背景图提示词
2026-04-25 已把旧 Node 自动资产链路中的场景幕背景图提示词包装迁移到 Rust 主线:
1. 旧来源:`server-node/src/services/customWorldAgentAutoAssetService.ts``buildSceneActPrompt(...)`
2. 新主源:`server-rs/crates/api-server/src/custom_world.rs``build_scene_act_background_image_prompt(...)`
3. 使用位置:`generate_draft_foundation_act_backgrounds(...)` 收集 `sceneChapterBlueprints[].acts[]` 后,先构造幕背景图专用提示词,再调用 `generate_custom_world_scene_image_for_profile(...)`
4. 保留语义:世界名、场景名、幕标题、幕摘要、幕目标、过渡钩子、主角色、辅助角色、世界气质、背景描述,以及“只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字”的约束。
5. 迁移边界:`server-node/` 仅作为历史来源说明,不再参与运行;后续调整统一修改 Rust 主源。

View File

@@ -6,7 +6,10 @@
## 落地规则
- 作品卡右上角固定展示删除 icon底部主操作区只保留继续创作、查看详情、体验等正向操作
- 作品卡整体就是继续创作 / 继续完善 / 查看详情入口,不再在底部展示“继续完善”等重复主按钮
- 作品卡右上角固定展示删除 icon底部主操作区只保留体验等必须独立触发的正向操作。
- 点击作品卡任意非独立按钮区域都进入继续完善链路;点击删除或体验时不得冒泡触发作品卡打开。
- 作品卡保留键盘可访问性:焦点落在卡片时按 Enter 或空格等同点击作品,焦点落在删除 / 体验按钮时只执行对应按钮动作。
- 删除入口不按发布状态隐藏:草稿、已发布作品均可删除。
- 删除入口不按玩法类型隐藏RPG、大鱼吃小鱼、拼图作品均应在创作页可删除。
- 点击删除前保留浏览器确认弹窗,避免误触;删除中仅禁用当前作品卡的删除 icon。
@@ -18,3 +21,5 @@
- 大鱼作品按 `sourceSessionId` 删除创作 session并同步清理消息、素材槽和运行快照。
- 拼图作品按 `profileId` 删除作品 profile并同步清理来源 Agent session、消息和入口运行快照。
- RPG 已发布/持久草稿按 `profileId` 走既有自定义世界删除链路;纯 Agent session 草稿按 `sessionId` 走 owner-only session 删除过程,并清理消息、操作与草稿卡。
- 自定义世界 Agent 的异步进度写回必须通过 `upsert_custom_world_agent_operation_progress` 过程落到 SpacetimeDB`server-rs` 只做字符串入参与过程封装,不在 API 层维护额外进度状态。
- `server-rs` 的删除路由使用 Axum 标准 `Path(sessionId)` 提取参数,并在进入 SpacetimeDB 前做 owner-only 与空值校验,避免 handler 签名和过程入参漂移。

View File

@@ -71,6 +71,7 @@
弹窗内默认只保留:
- 标题:`登录账号`
- 登录方式页签:`短信登录` / `密码登录`
- 手机号输入框
- 验证码输入框
- 获取验证码按钮
@@ -88,6 +89,17 @@
- “先登录再同步进度”这类描述性文案
- 占据视觉主体的装饰信息块
## 3.2.1 登录页签落地约束
账号面板需要把短信验证码登录和密码登录拆成互斥页签,避免两个登录表单在同一个面板里上下堆叠。
- 同时开放短信与密码登录时,面板顶部展示两个居中的文字页签,当前页签使用深色字重和短下划线强调。
- 只渲染当前页签对应的输入区;切换页签不弹出新面板,不展示二维码入口。
- `短信登录` 页签包含手机号、验证码、获取验证码和主按钮。
- `密码登录` 页签包含手机号/邮箱、密码、主按钮和忘记密码入口。
- 未开放某个登录方式时不展示对应页签,避免用户进入不可用表单。
- 移动端页签保持等分点击区域,输入框与按钮宽度仍随弹窗收缩。
## 3.3 登录成功后的行为
- 手机号登录成功后,关闭弹窗

View File

@@ -24,4 +24,8 @@
- PC 端使用更明确的 `xl:grid`、固定信息侧栏和更小间距,让主内容首屏承载更多信息。
- 卡片在 PC 端降低无效高度,操作按钮与状态信息尽量同行展示。
- 作品卡片底部统计标签必须保留在卡片圆角范围内,不能为了压缩高度让标签贴边或被 `overflow-hidden` 裁掉。
- 卡片正文摘要优先缩短行数来给底部标签留空间;当标题、摘要或标签变长时,允许卡片自然增高。
- RPG 作品卡片点击行为按作品状态分流:草稿统一继续创作,已发布作品进入详情或世界;不要只依赖 `sourceType` 判断草稿可打开性。
- 整张作品卡片需要由卡片根节点承载点击与键盘打开能力,避免透明绝对定位按钮在真实浏览器中被判定不可见,导致自动化和用户点击不稳定。
- 保留现有 `platform-*` 视觉体系,避免引入新的 UI 系统。

View File

@@ -249,7 +249,19 @@
3. 针对初始同伴流程补一份单独的状态图 / 时序图
4. 对大 chunk 警告做代码分包
## 14. 一句话总结
## 14. SpacetimeDB 绑定桥接层要做同名去重
`server-rs/crates/spacetime-client` 里有一部分内容是围绕 SpacetimeDB 生成绑定补的手写桥接层。
经验:
- 新增 procedure、input type 或 mapper 时,先全局确认 `module_bindings/mod.rs``mapper.rs`、业务封装文件里是否已经存在同名声明
- `module_bindings/mod.rs` 同一个模块只保留一条 `pub mod` 和一条 `pub use`,不要同时放在 reducer 区和 procedure 区
- `mapper.rs` 的字符串枚举解析函数、API 入参结构只保留一个权威定义,业务侧统一复用
- 业务封装文件里同一个 procedure 只暴露一个客户端方法,避免 Rust 在编译期出现 E0428、E0252、E0119、E0592 这类重复定义错误
- 修复重复绑定时优先删除后追加的重复块,不要重写整文件,避免影响中文注释和生成绑定附近的大段内容
## 15. 一句话总结
这个项目真正的开发经验不是“怎么多写一个按钮”,而是:

View File

@@ -23,3 +23,7 @@
- 只需要读一份时,优先看 `PROJECT_WORK_EXPERIENCE_PLAYBOOK`
- 做 UI 改动时,把本目录和根目录的 `UI_CODING_STANDARD.md` 对照着看。
- 做运行时流程改动时,把本目录和 `docs/audits/engineering/README.md` 一起看,能更快发现风险边界。
## 近期专项记录
- [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。

View File

@@ -0,0 +1,32 @@
# RPG 幕背景默认描述来源修正 2026-04-24
## 背景
草稿编辑器中“AI 生成幕背景”的“画面内容描述”曾出现类似“温馨员工宿舍第1幕背景玩家入职后的首个落脚处玩家会在温馨员工宿舍接住这一章的开场入口。”的默认文本。这类文本不是大模型直接写出的画面描述而是前端或后端在缺少 `backgroundPromptText` 时,把地点名、幕标题、摘要规则句拼接出来的兜底文案。
## 落地约束
1. 幕背景图的默认画面描述必须来自草稿生成链路里的关键场景生成步骤,字段源为 `landmarks[*].actBackgroundPromptTexts[*]`
2. `sceneChapterBlueprints[*].acts[*].backgroundPromptText` 只承接上述幕级大模型产物,不再用 `title``summary`、地点描述或规则句拼接。
3. 如果大模型漏产某一幕描述,后端规范化只保留空字符串,让后续生图前的 `backgroundPromptText` 校验暴露底稿质量问题,不能伪造可用默认文本。
4. 前端编辑器 sanitize 只展示已有 `act.backgroundPromptText`;缺失时留空,不能在 UI 层重新拼接默认描述。
5. 手动打开 AI 生成面板时,若字段为空,可由用户输入,但系统默认不替用户生成规则句。
## 当前实现
- `server-rs/crates/api-server/src/custom_world_foundation_draft.rs` 的关键场景框架 prompt 要求 LLM 为每个地点生成 3 条 `actBackgroundPromptTexts`
- 草稿合成阶段通过 `build_scene_chapter_blueprints_from_landmarks` 把这些幕级描述写入 `sceneChapterBlueprints[*].acts[*].backgroundPromptText`
- `normalize_scene_act_blueprint` 不再把缺失描述补成“标题 + 摘要 + 通用场景背景”格式。
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` 不再用地点名、幕标题和 `actSummary` 生成 `backgroundPromptText` fallback。
## 验收要点
- 新草稿中每一幕的 `backgroundPromptText` 应该像自然的画面描述,包含主体、前中远景、站位空间、氛围识别点。
- 不应再出现“第1幕背景玩家会在……”这类明显拼接句。
- 如果 LLM 漏掉 `actBackgroundPromptTexts`,生成幕背景图阶段应失败并提示缺少 `backgroundPromptText`,而不是静默使用拼接文案。
## 2026-04-24 并发限流错误处理补充
- 批量生成幕背景图时,`JoinSet` 子任务的成功值和失败值固定承载 `(chapter_index, act_index, message)`,用于把错误精确标记回对应章节幕。
- `Semaphore::acquire``AcquireError` 不能在子任务中转成裸 `String` 后直接使用 `?`,否则会破坏子任务统一错误类型并导致 `E0277`
- 限流器异常应映射为同一组三元组错误,保持后续 `mark_scene_act_background_generation_error` 和部分成功保留逻辑可复用。

View File

@@ -0,0 +1,36 @@
# RPG 底稿图片并行生成说明 2026-04-24
## 背景
RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景图都依赖同一份结构化底稿,但二者之间没有数据依赖。旧流程先生成所有角色主形象,再生成场景背景图,导致用户需要串行等待两类图片任务。
## 落地约束
1. 角色主形象与场景背景图必须在 API 编排层并行发起,且类内每个角色、每一幕背景也必须同时调用生图接口,不能只做到“角色大类”和“背景大类”并行。这里的场景背景图指 `sceneChapterBlueprints[*].acts[*]` 中每一幕的 `backgroundImageSrc`,不是世界封面图,也不是只按章节或地点生成一张图。
2. SpacetimeDB reducer 只负责持久化操作进度和底稿写入,不承载外部 LLM / 图片生成调用。
3. 生图前必须已经有文本设定:角色主形象使用角色对象的 `visualDescription`;幕背景图使用对应幕的 `backgroundPromptText`。缺字段时应中断并暴露底稿质量问题,不能退回 `description``summary` 或通用兜底词直接生图。
4. 并行分支各自基于同一份底稿副本写入素材字段,完成后只合并背景图生成产物字段,避免覆盖角色图片字段或其他草稿内容。
5. 单个大类失败仍按原有失败语义终止底稿写入,保留“生成角色主形象失败”和“生成幕背景图失败”的进度提示。
## 当前实现
- `server-rs/crates/api-server/src/custom_world.rs``spawn_custom_world_draft_foundation_job` 中使用 `tokio::join!` 同时执行:
- `generate_draft_foundation_role_visuals`
- `generate_draft_foundation_act_backgrounds`
- 角色分支使用 `JoinSet` 把所有角色主形象任务一次性投递,返回后再按角色位置写入 `imageSrc``generatedVisualAssetId`
- 背景分支使用 `JoinSet``sceneChapterBlueprints[*].acts[*]` 的每一幕背景任务一次性投递,返回后写入 `backgroundImageSrc``backgroundAssetId``generatedScenePrompt``generatedSceneModel`
- `merge_generated_act_backgrounds` 只把背景图字段合并回角色分支副本,再进入后续草稿卡编译和 SpacetimeDB 写入。
- 幕背景 prompt 同时兼容 `backgroundPromptText``scenePromptText``visualPromptText``promptText``imagePromptText``backgroundPrompt``visualPrompt`,避免 LLM 输出字段别名导致整批背景图被误判缺失。
- 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。幕背景图允许部分成功:只要至少一幕成功,就必须保留已成功写入的 `backgroundImageSrc` 并继续生成草稿卡;全部幕都失败时才把素材阶段标记为“生成幕背景图失败”。
- 图片任务仍然一次性投递,保证角色与幕背景两类任务不回退到串行编排;但真正请求上游生图服务时必须共用并发闸门。并发数由 `GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS``DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS` 配置,默认 4避免固定为 2 导致多角色、多幕草稿总耗时过长。
- 幕背景图失败文案必须带第几章、第几幕和幕标题不能只显示“第1幕 / 第2幕 / 第3幕”否则多章节同名幕会被用户误认为同一失败项重复上报。
- 中止或部分失败前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId`
- 每一幕自动生图必须记录 operation、session、第几章、第几幕、sceneId、sceneName、attempt、elapsedMs 与供应商真实错误,避免再次出现只看到“生成幕背景图失败”但无法定位哪张图、哪次请求、哪个上游原因的问题。
- 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。
## 后续注意
如果后续图片供应商出现强限流,再在网关层做队列或供应商侧限流;不要在 RPG 底稿编排层恢复逐张串行,否则会重新退化成多张图片总耗时累加。

View File

@@ -0,0 +1,30 @@
# RPG 角色形象描述数据链路核查 2026-04-24
## 结论
草稿生成阶段会让大模型为每个可扮演角色和场景角色生成 `visualDescription`,该字段是角色主形象生成和资产工坊“形象描述”输入框的同一份默认文本来源。
本次核查发现前端 `normalizeCustomWorldProfileRecord` 曾在规范化 `playableNpcs` / `storyNpcs` 时丢弃 `visualDescription``actionDescription``sceneVisualDescription`。因此后端草稿 JSON 中有大模型生成的文字,但草稿进入前端编辑器后,资产工坊可能只能回退到 `description`,用户看不到真正的角色形象文字描述。
## 数据链路
1. 后端草稿生成:`server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
- 角色框架名单 prompt 要求 LLM 输出 `visualDescription``actionDescription``sceneVisualDescription`
- `visualDescription` 定义为打开角色形象图像生成面板时默认填入的角色形象描述。
2. 后端角色主形象生成:`server-rs/crates/api-server/src/custom_world.rs`
- `generate_draft_foundation_role_visuals` 从角色对象读取 `visualDescription`
- 缺失时直接失败,提示不能在角色形象设定文本生成前生图。
- 生图成功只写回 `imageSrc``generatedVisualAssetId`,不会覆盖 `visualDescription`
3. 草稿持久化:草稿 profile JSON 保留角色对象字段,`visualDescription` 应与图片字段一起进入保存载荷。
4. 前端规范化:`src/data/customWorldLibrary.ts`
- `normalizePlayableNpc` / `normalizeStoryNpc` 必须保留三类资产描述字段。
5. 资产工坊展示:`src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`
- modal 用角色对象构造 `baseRole`
- `buildDefaultRolePromptBundle(baseRole)` 优先把 `role.visualDescription` 转成 `visualPromptText`
- `RpgCreationRoleVisualSection` 的“形象描述” TextArea 展示 `visualPromptText`
## 验收要点
- 草稿生成完毕后,打开某个角色的资产工坊,应在“形象描述”框看到 LLM 生成的 `visualDescription`
- 如果角色有 `visualDescription`,缓存中的旧 `visualPromptText` 不应覆盖它。
- 如果角色缺 `visualDescription`,才允许前端回退到更弱的字段或缓存文本。

View File

@@ -361,10 +361,26 @@
1. `id`
2. `name`
3. `role`
4. `publicMask`
5. `hiddenHook`
6. `relationToPlayer`
7. `summary`
4. `description`
5. `visualDescription`
6. `actionDescription`
7. `sceneVisualDescription`
8. `publicMask`
9. `hiddenHook`
10. `relationToPlayer`
11. `summary`
### 角色资产工坊默认文本来源
`visualDescription` 是角色主形象生成入口的默认形象描述主源,必须在可扮演角色 / 场景角色的草稿生成步骤中跟随角色一并生成,不允许在资产工坊打开时再用本地规则把 `description``role``tags` 拼成默认文案。
生成要求:
1. `description` 只写角色定位,控制在 8 到 18 个汉字内,用于角色卡摘要。
2. `visualDescription` 专门写角色外观,包含轮廓、服饰 / 身体特征、携带物或材质气质,不写性格规则和玩法说明。
3. `actionDescription` 专门写动作气质,用于动作生成默认文本。
4. `sceneVisualDescription` 专门写角色常出现的场景氛围,用于场景图或角色场景联动默认文本。
5. 资产工坊默认值优先读取 `visualDescription`,只有历史草稿缺失该字段时才允许回退到 `description`
### 插入规则

View File

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

View File

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

View File

@@ -52,3 +52,14 @@
- 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。
- 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。
- UI 不默认展示规则解释文案,正式约束只进入后端 prompt。
## 5. 自动草稿素材回写约束
- 世界草稿自动素材生成与草稿页手动生成使用同一套 `server-rs/crates/api-server/src/custom_world_ai.rs` 场景图接口和 OSS/SpacetimeDB 资产持久化链路。
- 自动批量生成幕背景时,后端必须把已成功生成的 `backgroundImageSrc/backgroundAssetId/generatedScenePrompt/generatedSceneModel` 写回 `sceneChapterBlueprints[*].acts[*]`,不能因为同批某一幕失败而丢弃已成功图片。
- 某一幕连续重试仍失败时,只允许在该幕写入 `backgroundGenerationError` 作为诊断字段;只要至少一幕成功,草稿仍应完成并让前端展示成功图片。
- 只有全部幕背景均失败时,才把“生成幕背景图失败”作为草稿素材阶段失败原因保存。
- Rust 服务实际生图模型读取 `DASHSCOPE_SCENE_IMAGE_MODEL` / `DASHSCOPE_COVER_IMAGE_MODEL` / `DASHSCOPE_REFERENCE_IMAGE_MODEL`;兼容旧 `DASHSCOPE_IMAGE_MODEL`,避免 `.env.example` 中配置了模型但服务端仍使用硬编码模型。
- 自动草稿幕背景不能把 `backgroundPromptText` 直接作为最终 `prompt` 传给 DashScope它必须像草稿页手动生成一样把幕级描述作为 `userPrompt`,并用同一个地点对象的 `name/description/dangerLevel` 作为场景上下文,再由 `build_custom_world_scene_image_prompt` 统一拼入世界名、世界摘要、风格、玩家目标、场景名、场景描述和负面词。用户不修改默认描述直接点生成时,手动生成与自动草稿生成的正式生图上下文必须一致。
- 自动草稿幕背景的默认尺寸必须与草稿页手动生成默认尺寸一致,当前统一为 `1280*720`;不能在自动链路中单独改成 `1600*900`,否则同一 prompt 在同一模型下也可能因供应商尺寸支持或耗时不同而表现不一致。
- 批量自动生图失败日志必须保留 `AppError.details.message` 中的供应商真实原因,不能只记录 `AppError.message()` 的 HTTP 泛化文案,否则排查时只能看到“上游服务请求失败”,无法确认是尺寸、模型、限流、超时还是内容审核失败。

View File

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

View File

@@ -7,6 +7,8 @@
- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。
- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。
- [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界。
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。
@@ -164,4 +166,3 @@

View File

@@ -0,0 +1,26 @@
# RPG 生成流程刷新恢复与即时持久化设计2026-04-24
## 背景
- RPG 共创从 Agent 聊天页触发 `draft_foundation` 后进入生成过程页。
- 旧实现只持久化 `activeSessionId``activeOperationId`,刷新时恢复入口会无条件回到 Agent 聊天页。
- operation 失败后继续创作也会因为 operation 指针被清空而缺失生成页上下文。
## 目标
1. 生成中刷新网页后仍停留在生成过程页。
2. 生成完成后结果页内容第一时间落入作品持久化链路。
3. 生成失败后从创作入口继续处理该草稿时,优先回到生成过程页展示失败状态,而不是 Agent 聊天页。
## 落地规则
- 前端只保存恢复指针,不在 UI 持久层复制世界数据。
- `sessionStorage` 与 URL query 中增加生成页来源字段 `customWorldGenerationSource`,当前仅支持 `agent-draft-foundation`
- 初始恢复时:
- 若存在 `activeOperationId` 且来源为 `agent-draft-foundation`,先进入 `custom-world-generating`
- 否则若 session 已经可构建结果预览,进入 `custom-world-result`
- 其他情况进入 `agent-workspace`
- operation 进入 `completed``failed` 后仍保留 `activeOperationId`,直到用户离开、重新发起操作或清理工作区,保证刷新和继续创作能恢复完成/失败状态。
- 生成完成后由 `useRpgCreationResultAutosave` 在结果页立即保存。生成页跳结果页前必须先同步最新 session 并写入 `generatedCustomWorldProfile`,确保自动保存消费的是最新快照。
## 验收点
- 生成中刷新URL/sessionStorage 可恢复 `custom-world-generating`,页面显示“世界草稿生成进度”。
- 生成失败刷新或继续创作:页面仍显示生成过程页和失败信息,不展示 Agent 聊天页。
- 生成完成:跳到结果页后触发 `upsertRpgWorldProfile`,保存请求带 `sourceAgentSessionId`

View File

@@ -37,8 +37,9 @@ npm run dev:rust
4. 等待 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 可用。
5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
6. 注入 `GENARRATIVE_API_*``GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
7. 注入 `GENARRATIVE_BACKEND_STACK=rust``RUST_SERVER_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite
8. 任一子进程退出时,脚本回收其余子进程
7. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`
8. 注入 `GENARRATIVE_BACKEND_STACK=rust``RUST_SERVER_TARGET``GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite
9. 任一子进程退出时,脚本回收其余子进程。
Vite 代理覆盖范围:
@@ -84,6 +85,13 @@ npm run dev:rust:logs -- --follow
1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database/<database>/subscribe` 是否指向了未发布的库。
2. `spacetime --root-dir=server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`
3. 发布库名与 `GENARRATIVE_SPACETIME_DATABASE` 不一致时,`/api/runtime/custom-world-gallery` 会从 Rust `api-server` 返回 `502`,前端首页只能展示空态或错误提示,无法自行修复。
4. 如果 Vite 输出 `/api/auth/refresh``/api/auth/login-options``/api/runtime/custom-world-gallery``ECONNREFUSED`,先确认当前脚本是否已经打印 `等待 api-server 就绪` 并通过;正常情况下 Vite 只会在 `/healthz` 可访问后启动,不应再因为 Rust 监听未完成而代理失败。
编译警告治理:
1. Rust 本地栈启动日志应保持可行动,运行态未使用函数不应长期保留为普通编译警告。
2. 仅供测试断言使用的辅助函数使用 `#[cfg(test)]` 限定,避免进入 `cargo run -p api-server` 的普通二进制编译。
3. 已无调用入口且无迁移价值的映射函数直接删除;如果后续新增同类 SpacetimeDB 记录映射,再按实际调用路径补回,避免提前保留死代码。
## 3. Ubuntu 发布包脚本

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/usr/bin/env bash
set -euo pipefail
@@ -88,6 +88,39 @@ wait_for_spacetime() {
exit 1
}
wait_for_api_server() {
local health_url="$1"
local timeout_seconds="$2"
local process_pid="${3:-}"
local deadline=$((SECONDS + timeout_seconds))
while ((SECONDS < deadline)); do
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[dev:rust] api-server 进程在就绪前退出。" >&2
exit 1
fi
# 使用 Node 发起健康检查,避免要求 Windows 本地额外安装 curl/wget。
if node -e '
const target = process.argv[1];
const client = target.startsWith("https:") ? require("https") : require("http");
const request = client.get(target, { timeout: 1000 }, (response) => {
response.resume();
process.exit(response.statusCode >= 200 && response.statusCode < 500 ? 0 : 1);
});
request.on("timeout", () => request.destroy(new Error("timeout")));
request.on("error", () => process.exit(1));
' "${health_url}" >/dev/null 2>&1; then
return
fi
sleep 0.5
done
echo "[dev:rust] 等待 api-server 就绪超时: ${health_url}" >&2
exit 1
}
sync_local_spacetime_install() {
local root_dir="$1"
@@ -337,9 +370,13 @@ echo "[dev:rust] 启动 api-server"
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
) &
PIDS+=("$!")
API_PID="$!"
PIDS+=("${API_PID}")
NAMES+=("api-server")
echo "[dev:rust] 等待 api-server 就绪"
wait_for_api_server "${RUST_SERVER_TARGET}/healthz" "${SPACETIME_TIMEOUT_SECONDS}" "${API_PID}"
echo "[dev:rust] 启动 vite"
(
cd "${REPO_ROOT}"

View File

@@ -0,0 +1,233 @@
use module_ai::{
AiTaskCreateInput, AiTaskKind, AiTaskStageBlueprint, AiTaskStageKind, AiTaskStageStartInput,
AiTextChunkAppendInput,
};
use serde_json::json;
use spacetime_client::{SpacetimeClient, SpacetimeClientError};
use std::sync::{Arc, Mutex};
use tracing::warn;
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftContext {
pub task_id: String,
pub owner_user_id: String,
pub request_label: String,
pub source_module: String,
pub source_entity_id: String,
pub template_key: String,
pub operation_id: String,
}
impl AiGenerationDraftContext {
pub fn new(
template_key: &str,
owner_user_id: &str,
session_id: &str,
operation_id: &str,
request_label: &str,
) -> Self {
let normalized_template = normalize_identifier_segment(template_key);
let normalized_session = normalize_identifier_segment(session_id);
let normalized_operation = normalize_identifier_segment(operation_id);
Self {
// 生成过程草稿使用稳定 task_id保证同一模板会话操作重试时能继续定位已有内容。
task_id: format!(
"aitask_draft_{normalized_template}_{normalized_session}_{normalized_operation}"
),
owner_user_id: owner_user_id.trim().to_string(),
request_label: request_label.trim().to_string(),
source_module: normalized_template,
source_entity_id: session_id.trim().to_string(),
template_key: template_key.trim().to_string(),
operation_id: operation_id.trim().to_string(),
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct AiGenerationDraftSink {
context: AiGenerationDraftContext,
client: SpacetimeClient,
next_sequence: Arc<Mutex<u32>>,
persisted_text: Arc<Mutex<String>>,
}
impl AiGenerationDraftSink {
pub fn new(context: AiGenerationDraftContext, client: SpacetimeClient) -> Self {
Self {
context,
client,
next_sequence: Arc::new(Mutex::new(1)),
persisted_text: Arc::new(Mutex::new(String::new())),
}
}
pub fn persist_visible_text_async(&self, visible_text: &str) {
let (sequence, delta_text) = {
let mut persisted_text = self
.persisted_text
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let delta_text = visible_text
.strip_prefix(persisted_text.as_str())
.unwrap_or(visible_text)
.to_string();
*persisted_text = visible_text.to_string();
if delta_text.trim().is_empty() {
return;
}
let mut next_sequence = self
.next_sequence
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let sequence = *next_sequence;
*next_sequence = next_sequence.saturating_add(1);
(sequence, delta_text)
};
let context = self.context.clone();
let client = self.client.clone();
tokio::spawn(async move {
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text,
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %context.task_id,
sequence,
error = %error,
"AI 生成草稿后台增量落库失败,主生成流程继续执行"
);
}
});
}
}
#[derive(Debug)]
pub(crate) struct AiGenerationDraftWriter {
context: AiGenerationDraftContext,
next_sequence: u32,
persisted_text: String,
}
impl AiGenerationDraftWriter {
pub fn new(context: AiGenerationDraftContext) -> Self {
Self {
context,
next_sequence: 1,
persisted_text: String::new(),
}
}
pub async fn ensure_started(
&mut self,
client: &SpacetimeClient,
) -> Result<(), SpacetimeClientError> {
let now_micros = current_utc_micros();
match client
.create_ai_task(AiTaskCreateInput {
task_id: self.context.task_id.clone(),
task_kind: AiTaskKind::CustomWorldGeneration,
owner_user_id: self.context.owner_user_id.clone(),
request_label: self.context.request_label.clone(),
source_module: self.context.source_module.clone(),
source_entity_id: Some(self.context.source_entity_id.clone()),
request_payload_json: Some(
json!({
"templateKey": self.context.template_key,
"operationId": self.context.operation_id,
})
.to_string(),
),
stages: vec![AiTaskStageBlueprint {
stage_kind: AiTaskStageKind::RequestModel,
label: "请求模型".to_string(),
detail: "模板生成过程中持续写入模型已生成文本。".to_string(),
order: 1,
}],
created_at_micros: now_micros,
})
.await
{
Ok(_) => {}
Err(error) if is_duplicate_ai_task_error(&error) => {}
Err(error) => return Err(error),
}
client
.start_ai_task_stage(AiTaskStageStartInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
started_at_micros: now_micros,
})
.await
}
pub async fn persist_visible_text(&mut self, client: &SpacetimeClient, visible_text: &str) {
let delta_text = match visible_text.strip_prefix(self.persisted_text.as_str()) {
Some(delta) => delta,
None => visible_text,
};
if delta_text.trim().is_empty() {
self.persisted_text = visible_text.to_string();
return;
}
let sequence = self.next_sequence;
self.next_sequence = self.next_sequence.saturating_add(1);
self.persisted_text = visible_text.to_string();
if let Err(error) = client
.append_ai_text_chunk(AiTextChunkAppendInput {
task_id: self.context.task_id.clone(),
stage_kind: AiTaskStageKind::RequestModel,
sequence,
delta_text: delta_text.to_string(),
created_at_micros: current_utc_micros(),
})
.await
{
warn!(
task_id = %self.context.task_id,
sequence,
error = %error,
"AI 生成草稿增量落库失败,主生成流程继续执行"
);
}
}
}
fn normalize_identifier_segment(value: &str) -> String {
let normalized = value
.trim()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if normalized.is_empty() {
"unknown".to_string()
} else {
normalized
}
}
fn is_duplicate_ai_task_error(error: &SpacetimeClientError) -> bool {
error.to_string().contains("ai_task.task_id 已存在")
}
fn current_utc_micros() -> i64 {
time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000
}

View File

@@ -42,8 +42,14 @@ use crate::big_fish_agent_turn::{
run_big_fish_agent_turn,
};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
request_context::RequestContext, state::AppState,
ai_generation_drafts::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
},
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
pub async fn create_big_fish_session(
@@ -203,15 +209,45 @@ pub async fn submit_big_fish_message(
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
),
state.spacetime_client().clone(),
);
let turn_result = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
llm_client: state.llm_client(),
session: &submitted_session,
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
},
|_| {},
move |text| {
draft_sink.persist_visible_text_async(text);
},
)
.await;
if let Ok(result) = &turn_result {
draft_writer
.persist_visible_text(
state.spacetime_client(),
result.assistant_reply_text.as_str(),
)
.await;
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
@@ -287,6 +323,26 @@ pub async fn stream_big_fish_message(
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
let quick_fill_requested = payload.quick_fill_requested.unwrap_or(false);
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "大鱼吃小鱼模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
"big_fish",
owner_user_id.as_str(),
session_id.as_str(),
payload.client_message_id.as_str(),
"大鱼吃小鱼模板生成草稿",
),
state.spacetime_client().clone(),
);
let mut streamed_reply_text = String::new();
let turn_result = run_big_fish_agent_turn(
BigFishAgentTurnRequest {
@@ -295,10 +351,16 @@ pub async fn stream_big_fish_message(
quick_fill_requested,
},
|text| {
draft_sink.persist_visible_text_async(text);
streamed_reply_text = text.to_string();
},
)
.await;
if !streamed_reply_text.is_empty() {
draft_writer
.persist_visible_text(state.spacetime_client(), streamed_reply_text.as_str())
.await;
}
let reply_text = match &turn_result {
Ok(result) => result.assistant_reply_text.clone(),
Err(error) => error.to_string(),

View File

@@ -82,7 +82,11 @@ pub struct AppConfig {
pub llm_retry_backoff_ms: u64,
pub dashscope_base_url: String,
pub dashscope_api_key: Option<String>,
pub dashscope_scene_image_model: String,
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
pub ark_character_video_request_timeout_ms: u64,
@@ -166,7 +170,11 @@ impl Default for AppConfig {
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
dashscope_api_key: None,
dashscope_scene_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
ark_character_video_request_timeout_ms: 420_000,
@@ -464,12 +472,38 @@ impl AppConfig {
config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]);
if let Some(dashscope_scene_image_model) =
read_first_non_empty_env(&["DASHSCOPE_SCENE_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_scene_image_model = dashscope_scene_image_model;
}
if let Some(dashscope_reference_image_model) = read_first_non_empty_env(&[
"DASHSCOPE_REFERENCE_IMAGE_MODEL",
"DASHSCOPE_IMAGE_EDIT_MODEL",
]) {
config.dashscope_reference_image_model = dashscope_reference_image_model;
}
if let Some(dashscope_cover_image_model) =
read_first_non_empty_env(&["DASHSCOPE_COVER_IMAGE_MODEL", "DASHSCOPE_IMAGE_MODEL"])
{
config.dashscope_cover_image_model = dashscope_cover_image_model;
}
if let Some(dashscope_image_request_timeout_ms) =
read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
]) {
config.draft_asset_generation_max_concurrent_requests = max_concurrent_requests;
}
if let Some(ark_character_video_base_url) = read_first_non_empty_env(&[
"ARK_CHARACTER_VIDEO_BASE_URL",
"ARK_BASE_URL",
@@ -623,6 +657,14 @@ fn read_first_u64_env(keys: &[&str]) -> Option<u64> {
.find_map(|key| env::var(key).ok().and_then(|value| parse_u64(&value)))
}
fn read_first_usize_env(keys: &[&str]) -> Option<usize> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_usize(&value))
})
}
fn read_first_u8_env(keys: &[&str]) -> Option<u8> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value)))
@@ -704,6 +746,15 @@ fn parse_u64(raw: &str) -> Option<u64> {
raw.trim().parse::<u64>().ok()
}
fn parse_positive_usize(raw: &str) -> Option<usize> {
let value = raw.trim().parse::<usize>().ok()?;
if value == 0 {
return None;
}
Some(value)
}
fn parse_u8(raw: &str) -> Option<u8> {
raw.trim().parse::<u8>().ok()
}

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use axum::{
Json,
extract::{Extension, Path, State, rejection::JsonRejection},
@@ -28,19 +30,25 @@ use shared_contracts::runtime::{
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, SpacetimeClientError,
};
use std::convert::Infallible;
use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant};
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tracing::info;
use crate::{
ai_generation_drafts::{
AiGenerationDraftContext, AiGenerationDraftSink, AiGenerationDraftWriter,
},
api_response::json_success_body,
auth::AuthenticatedAccessToken,
character_visual_assets::generate_character_primary_visual_for_profile,
@@ -59,6 +67,8 @@ use crate::{
state::AppState,
};
const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3;
pub async fn get_custom_world_library(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -633,6 +643,26 @@ pub async fn submit_custom_world_agent_message(
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"custom_world",
owner_user_id.as_str(),
session_id.as_str(),
operation_id.as_str(),
"自定义世界模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
"custom_world",
owner_user_id.as_str(),
session_id.as_str(),
operation_id.as_str(),
"自定义世界模板生成草稿",
),
state.spacetime_client().clone(),
);
let turn_result = run_custom_world_agent_turn(
CustomWorldAgentTurnRequest {
llm_client: state.llm_client(),
@@ -640,9 +670,19 @@ pub async fn submit_custom_world_agent_message(
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
focus_card_id: payload.focus_card_id.clone(),
},
|_| {},
move |text| {
draft_sink.persist_visible_text_async(text);
},
)
.await;
if let Ok(result) = &turn_result {
draft_writer
.persist_visible_text(
state.spacetime_client(),
result.assistant_reply_text.as_str(),
)
.await;
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
@@ -761,6 +801,26 @@ pub async fn stream_custom_world_agent_message(
let owner_user_id_for_stream = owner_user_id.clone();
let operation_id = operation.operation_id.clone();
let stream = async_stream::stream! {
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"custom_world",
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
operation_id.as_str(),
"自定义世界模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "自定义世界模板生成草稿任务启动失败,主生成流程继续执行");
}
let draft_sink = AiGenerationDraftSink::new(
AiGenerationDraftContext::new(
"custom_world",
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
operation_id.as_str(),
"自定义世界模板生成草稿",
),
state.spacetime_client().clone(),
);
// 聊天回复必须等本轮模型解析、进度与会话快照全部落库后,
// 再随最终 session 一次性返回,避免玩家先看到回复而进度仍停在旧状态。
let turn_result = run_custom_world_agent_turn(
@@ -770,9 +830,16 @@ pub async fn stream_custom_world_agent_message(
quick_fill_requested,
focus_card_id,
},
|_| {},
move |text| {
draft_sink.persist_visible_text_async(text);
},
)
.await;
if let Ok(result) = &turn_result {
draft_writer
.persist_visible_text(state.spacetime_client(), result.assistant_reply_text.as_str())
.await;
}
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
@@ -1162,49 +1229,73 @@ fn spawn_custom_world_draft_foundation_job(
}
};
if let Err(message) = generate_draft_foundation_role_visuals(
let image_generation_limiter = Arc::new(Semaphore::new(
state.config.draft_asset_generation_max_concurrent_requests,
));
let role_visual_profile_input = draft_profile_value.clone();
let act_background_profile_input = draft_profile_value.clone();
// 角色主形象与幕背景图互不依赖,必须并行发起;上游生图请求统一限流,避免同批草稿瞬时打满供应商接口。
let (role_visual_result, act_background_result) = tokio::join!(
async {
let mut profile = role_visual_profile_input;
generate_draft_foundation_role_visuals(
&state,
&session,
&owner_user_id,
&operation_id,
&mut draft_profile_value,
&mut profile,
image_generation_limiter.clone(),
)
.await
{
let _ = upsert_custom_world_draft_foundation_progress(
.map(|_| profile)
},
async {
let mut profile = act_background_profile_input;
generate_draft_foundation_act_backgrounds(
&state,
&session.session_id,
&session,
&owner_user_id,
&operation_id,
"failed",
"生成角色主形象失败",
message.as_str(),
100,
Some(message.clone()),
&mut profile,
image_generation_limiter.clone(),
)
.await;
return;
.await
.map(|_| profile)
}
);
if let Err(message) = generate_draft_foundation_act_backgrounds(
let mut draft_profile_with_assets = draft_profile_value.clone();
let mut asset_generation_errors = Vec::new();
match role_visual_result {
Ok(profile) => draft_profile_with_assets = profile,
Err(message) => asset_generation_errors.push(("生成角色主形象失败", message)),
}
match act_background_result {
Ok(profile) => {
merge_generated_act_backgrounds(&mut draft_profile_with_assets, &profile)
}
Err(message) => asset_generation_errors.push(("生成幕背景图失败", message)),
}
draft_profile_value = draft_profile_with_assets;
if !asset_generation_errors.is_empty() {
let message = asset_generation_errors
.iter()
.map(|(_, message)| message.as_str())
.collect::<Vec<_>>()
.join("");
let phase_label = asset_generation_errors
.first()
.map(|(label, _)| *label)
.unwrap_or("素材生成失败");
persist_partial_draft_foundation_after_asset_failure(
&state,
&session,
&owner_user_id,
&operation_id,
&mut draft_profile_value,
)
.await
{
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"生成幕背景图失败",
&draft_profile_value,
phase_label,
message.as_str(),
100,
Some(message.clone()),
)
.await;
return;
@@ -1305,6 +1396,7 @@ async fn generate_draft_foundation_role_visuals(
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
image_generation_limiter: Arc<Semaphore>,
) -> Result<(), String> {
let Some(profile_object) = draft_profile.as_object_mut() else {
return Err("foundation draft JSON 必须是 object".to_string());
@@ -1317,8 +1409,8 @@ async fn generate_draft_foundation_role_visuals(
}
}
}
let total = role_refs.len().max(1);
for (completed, (key, index)) in role_refs.into_iter().enumerate() {
let mut role_generation_refs = Vec::new();
for (key, index) in role_refs {
let role = profile_object
.get(key.as_str())
.and_then(Value::as_array)
@@ -1328,31 +1420,89 @@ async fn generate_draft_foundation_role_visuals(
let name =
json_text_from_value(&role, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let role_id = json_text_from_value(&role, "id").unwrap_or_else(|| format!("{key}-{index}"));
let visual_prompt = json_text_from_value(&role, "visualDescription")
.or_else(|| json_text_from_value(&role, "description"))
.unwrap_or_else(|| name.clone());
let visual_prompt = json_text_from_value(&role, "visualDescription").ok_or_else(|| {
format!("角色「{name}」缺少 visualDescription不能在角色形象设定文本生成前直接生图。")
})?;
role_generation_refs.push(RoleVisualGenerationRef {
key,
index,
role_id,
name,
prompt: visual_prompt,
});
}
upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
owner_user_id,
operation_id,
"running",
"生成角色主形象",
format!("正在生成角色主形象 {}/{}{}", completed + 1, total, name).as_str(),
97 + ((completed as u32).min(1)),
"并行生成角色主形象",
format!("正在同时生成 {} 张角色主形象。", role_generation_refs.len()).as_str(),
97,
None,
)
.await
.map_err(|error| error.to_string())?;
let generated = generate_character_primary_visual_for_profile(
state,
owner_user_id,
role_id.as_str(),
visual_prompt.as_str(),
Some(name.as_str()),
let mut generation_tasks = JoinSet::new();
for role_ref in role_generation_refs {
let task_state = (*state).clone();
let task_owner_user_id = owner_user_id.to_string();
let task_limiter = image_generation_limiter.clone();
generation_tasks.spawn(async move {
let mut last_error = None;
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
let generation_result = {
let _permit = task_limiter
.acquire()
.await
.map_err(|error| format!("图片生成并发控制失效:{error}"))?;
generate_character_primary_visual_for_profile(
&task_state,
task_owner_user_id.as_str(),
role_ref.role_id.as_str(),
role_ref.prompt.as_str(),
Some(role_ref.name.as_str()),
)
.await
.map_err(|error| error.message().to_string())?;
};
match generation_result {
Ok(generated) => {
return Ok::<_, String>((role_ref.key, role_ref.index, generated));
}
Err(error) => {
last_error = Some(error.body_text());
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
))
.await;
}
}
}
}
Err(format!(
"角色「{}」主形象连续生成 {} 次失败:{}",
role_ref.name,
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
last_error.unwrap_or_else(|| "未知错误".to_string())
))
});
}
let mut errors = Vec::new();
while let Some(result) = generation_tasks.join_next().await {
let task_result = result.map_err(|error| error.to_string())?;
let (key, index, generated) = match task_result {
Ok(value) => value,
Err(message) => {
errors.push(message);
continue;
}
};
if let Some(role_object) = profile_object
.get_mut(key.as_str())
.and_then(Value::as_array_mut)
@@ -1366,6 +1516,9 @@ async fn generate_draft_foundation_role_visuals(
);
}
}
if !errors.is_empty() {
return Err(join_unique_error_messages(errors));
}
Ok(())
}
@@ -1375,51 +1528,163 @@ async fn generate_draft_foundation_act_backgrounds(
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
image_generation_limiter: Arc<Semaphore>,
) -> Result<(), String> {
let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = json_text_from_value(draft_profile, "id");
let scene_image_profile_input = draft_profile.clone();
let act_refs = collect_scene_act_refs(draft_profile);
let total = act_refs.len().max(1);
for (completed, act_ref) in act_refs.into_iter().enumerate() {
validate_scene_act_background_prompts(&act_refs)?;
tracing::info!(
operation_id,
session_id = %session.session_id,
act_count = act_refs.len(),
max_concurrent_requests = state.config.draft_asset_generation_max_concurrent_requests,
"开始并行生成草稿幕背景图"
);
upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
owner_user_id,
operation_id,
"running",
"生成幕背景图",
format!(
"正在生成幕背景图 {}/{}{}",
completed + 1,
total,
act_ref.title
)
.as_str(),
"并行生成幕背景图",
format!("正在同时生成 {} 张幕背景图。", act_refs.len()).as_str(),
98,
None,
)
.await
.map_err(|error| error.to_string())?;
let generated = generate_custom_world_scene_image_for_profile(
state,
owner_user_id,
profile_id.as_deref(),
world_name.as_str(),
let mut generation_tasks = JoinSet::new();
for act_ref in act_refs {
let task_state = (*state).clone();
let task_owner_user_id = owner_user_id.to_string();
let task_profile_id = profile_id.clone();
let task_world_name = world_name.clone();
let task_profile = scene_image_profile_input.clone();
let task_limiter = image_generation_limiter.clone();
let task_operation_id = operation_id.to_string();
let task_session_id = session.session_id.clone();
generation_tasks.spawn(async move {
let mut last_error = None;
for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
let attempt_started_at = Instant::now();
tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
"开始生成单幕背景图"
);
let generation_result = {
let _permit = task_limiter.acquire().await.map_err(|error| {
(
act_ref.chapter_index,
act_ref.act_index,
format!("图片生成并发控制失效:{error}"),
)
})?;
generate_custom_world_scene_image_for_profile(
&task_state,
task_owner_user_id.as_str(),
&task_profile,
task_profile_id.as_deref(),
task_world_name.as_str(),
act_ref.scene_id.as_str(),
act_ref.title.as_str(),
act_ref.summary.as_str(),
act_ref.scene_name.as_str(),
act_ref.scene_description.as_str(),
act_ref.prompt.as_str(),
)
.await
.map_err(|error| error.message().to_string())?;
};
match generation_result {
Ok(generated) => {
tracing::info!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
"单幕背景图生成成功"
);
return Ok::<_, (usize, usize, String)>((
act_ref.chapter_index,
act_ref.act_index,
generated,
));
}
Err(error) => {
let error_message = error.body_text();
tracing::warn!(
operation_id = %task_operation_id,
session_id = %task_session_id,
chapter_index = act_ref.chapter_index,
act_index = act_ref.act_index,
scene_id = %act_ref.scene_id,
scene_name = %act_ref.scene_name,
attempt,
elapsed_ms = attempt_started_at.elapsed().as_millis(),
error_message = %error_message,
"单幕背景图生成失败"
);
last_error = Some(error_message);
if attempt < DRAFT_ASSET_GENERATION_MAX_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
))
.await;
}
}
}
}
Err((
act_ref.chapter_index,
act_ref.act_index,
format!(
"{}章第{}幕「{}」背景图连续生成 {} 次失败:{}",
act_ref.chapter_index + 1,
act_ref.act_index + 1,
act_ref.scene_name,
DRAFT_ASSET_GENERATION_MAX_ATTEMPTS,
last_error.unwrap_or_else(|| "未知错误".to_string())
),
))
});
}
let mut errors = Vec::new();
let mut generated_count = 0usize;
while let Some(result) = generation_tasks.join_next().await {
let task_result = result.map_err(|error| error.to_string())?;
let (chapter_index, act_index, generated) = match task_result {
Ok(value) => value,
Err((chapter_index, act_index, message)) => {
mark_scene_act_background_generation_error(
draft_profile,
chapter_index,
act_index,
&message,
);
errors.push(message);
continue;
}
};
if let Some(act_object) = draft_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
.and_then(|chapters| chapters.get_mut(act_ref.chapter_index))
.and_then(|chapters| chapters.get_mut(chapter_index))
.and_then(|chapter| chapter.get_mut("acts"))
.and_then(Value::as_array_mut)
.and_then(|acts| acts.get_mut(act_ref.act_index))
.and_then(|acts| acts.get_mut(act_index))
.and_then(Value::as_object_mut)
{
act_object.insert(
@@ -1438,21 +1703,79 @@ async fn generate_draft_foundation_act_backgrounds(
"generatedSceneModel".to_string(),
Value::String(generated.model),
);
generated_count += 1;
}
}
if !errors.is_empty() {
if generated_count > 0 {
// 自动草稿生成和手动生成用的是同一套生图与资产入库能力;这里不能因为批量中的个别幕失败,
// 把已经写入 profile 分支的 backgroundImageSrc 一起丢掉,否则前端就看不到已经生成好的图。
tracing::warn!(
generated_count,
failed_count = errors.len(),
error_message = %join_unique_error_messages(errors),
"部分幕背景图生成失败,已保留成功生成的幕图"
);
return Ok(());
}
return Err(join_unique_error_messages(errors));
}
Ok(())
}
fn mark_scene_act_background_generation_error(
draft_profile: &mut Value,
chapter_index: usize,
act_index: usize,
message: &str,
) {
if let Some(act_object) = draft_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
.and_then(|chapters| chapters.get_mut(chapter_index))
.and_then(|chapter| chapter.get_mut("acts"))
.and_then(Value::as_array_mut)
.and_then(|acts| acts.get_mut(act_index))
.and_then(Value::as_object_mut)
{
act_object.insert(
"backgroundGenerationError".to_string(),
Value::String(message.trim().to_string()),
);
}
}
fn join_unique_error_messages(messages: Vec<String>) -> String {
// 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。
messages
.into_iter()
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.join("")
}
struct RoleVisualGenerationRef {
key: String,
index: usize,
role_id: String,
name: String,
prompt: String,
}
struct SceneActGenerationRef {
chapter_index: usize,
act_index: usize,
scene_id: String,
title: String,
summary: String,
scene_name: String,
scene_description: String,
prompt: String,
}
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let scene_context_by_id = collect_scene_context_by_id(draft_profile);
draft_profile
.get("sceneChapterBlueprints")
.and_then(Value::as_array)
@@ -1463,28 +1786,188 @@ fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
let chapter_scene_id = json_text_from_value(chapter, "sceneId")
.or_else(|| json_text_from_value(chapter, "id"))
.unwrap_or_else(|| format!("chapter-{chapter_index}"));
let chapter_scene_name = json_first_text_from_value(
chapter,
&["sceneName", "landmarkName", "name", "title"],
)
.unwrap_or_else(|| chapter_scene_id.clone());
let chapter_scene_context = scene_context_by_id
.get(&chapter_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: chapter_scene_id.clone(),
name: chapter_scene_name.clone(),
description: json_text_from_value(chapter, "description")
.or_else(|| json_text_from_value(chapter, "summary"))
.unwrap_or_default(),
danger_level: json_text_from_value(chapter, "dangerLevel").unwrap_or_default(),
});
let scene_contexts = scene_context_by_id.clone();
chapter
.get("acts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(move |(act_index, act)| SceneActGenerationRef {
.map(move |(act_index, act)| {
let prompt = json_first_text_from_value(
act,
&[
"backgroundPromptText",
"scenePromptText",
"visualPromptText",
"promptText",
"imagePromptText",
"backgroundPrompt",
"visualPrompt",
],
)
.unwrap_or_default();
let scene_name = json_first_text_from_value(
act,
&["sceneName", "landmarkName", "locationName"],
)
.unwrap_or_else(|| chapter_scene_context.name.clone());
let act_scene_id = json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_context.id.clone());
let scene_context =
scene_contexts
.get(&act_scene_id)
.cloned()
.unwrap_or_else(|| SceneImageContext {
id: act_scene_id.clone(),
name: scene_name,
description: chapter_scene_context.description.clone(),
danger_level: chapter_scene_context.danger_level.clone(),
});
SceneActGenerationRef {
chapter_index,
act_index,
scene_id: json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_id.clone()),
title: json_text_from_value(act, "title")
.unwrap_or_else(|| format!("{}", act_index + 1)),
summary: json_text_from_value(act, "summary").unwrap_or_default(),
prompt: json_text_from_value(act, "backgroundPromptText")
.or_else(|| json_text_from_value(act, "summary"))
.unwrap_or_else(|| "场景幕背景图,突出探索空间与局势氛围。".to_string()),
scene_id: act_scene_id,
scene_name: scene_context.name,
scene_description: scene_context.description,
prompt: prompt.clone(),
}
})
})
.collect()
}
#[derive(Clone, Debug)]
struct SceneImageContext {
id: String,
name: String,
description: String,
danger_level: String,
}
fn collect_scene_context_by_id(draft_profile: &Value) -> BTreeMap<String, SceneImageContext> {
let mut contexts = BTreeMap::new();
if let Some(camp) = draft_profile.get("camp").and_then(Value::as_object) {
if let Some(context) = scene_context_from_object(camp, "camp") {
contexts.insert(context.id.clone(), context);
}
}
if let Some(landmarks) = draft_profile.get("landmarks").and_then(Value::as_array) {
for landmark in landmarks.iter().filter_map(Value::as_object) {
if let Some(context) = scene_context_from_object(landmark, "landmark") {
contexts.insert(context.id.clone(), context);
}
}
}
contexts
}
fn scene_context_from_object(
object: &Map<String, Value>,
fallback_id: &str,
) -> Option<SceneImageContext> {
let id = read_string_field(object, "id")
.or_else(|| read_string_field(object, "sceneId"))
.unwrap_or_else(|| fallback_id.to_string());
let name = read_string_field(object, "name")
.or_else(|| read_string_field(object, "sceneName"))
.unwrap_or_else(|| id.clone());
Some(SceneImageContext {
id,
name,
description: read_string_field(object, "description")
.or_else(|| read_string_field(object, "visualDescription"))
.unwrap_or_default(),
danger_level: read_string_field(object, "dangerLevel").unwrap_or_default(),
})
}
fn validate_scene_act_background_prompts(act_refs: &[SceneActGenerationRef]) -> Result<(), String> {
if let Some(act_ref) = act_refs.iter().find(|act_ref| act_ref.prompt.is_empty()) {
return Err(format!(
"{}章第{}幕「{}」缺少 backgroundPromptText不能在幕背景图描述文本生成前直接生图。",
act_ref.chapter_index + 1,
act_ref.act_index + 1,
act_ref.scene_name
));
}
Ok(())
}
fn json_first_text_from_value(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| json_text_from_value(value, key))
}
fn merge_generated_act_backgrounds(target_profile: &mut Value, background_profile: &Value) {
let Some(target_chapters) = target_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
else {
return;
};
let Some(background_chapters) = background_profile
.get("sceneChapterBlueprints")
.and_then(Value::as_array)
else {
return;
};
for (chapter_index, background_chapter) in background_chapters.iter().enumerate() {
let Some(target_acts) = target_chapters
.get_mut(chapter_index)
.and_then(|chapter| chapter.get_mut("acts"))
.and_then(Value::as_array_mut)
else {
continue;
};
let Some(background_acts) = background_chapter.get("acts").and_then(Value::as_array) else {
continue;
};
for (act_index, background_act) in background_acts.iter().enumerate() {
let Some(target_act_object) = target_acts
.get_mut(act_index)
.and_then(Value::as_object_mut)
else {
continue;
};
let Some(background_act_object) = background_act.as_object() else {
continue;
};
// 只合并图片生成产物字段,避免并行分支把其他草稿内容互相覆盖。
for key in [
"backgroundImageSrc",
"backgroundAssetId",
"generatedScenePrompt",
"generatedSceneModel",
] {
if let Some(value) = background_act_object.get(key) {
target_act_object.insert(key.to_string(), value.clone());
}
}
}
}
}
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
@@ -1494,6 +1977,68 @@ fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
async fn persist_partial_draft_foundation_after_asset_failure(
state: &AppState,
session: &CustomWorldAgentSessionRecord,
owner_user_id: &str,
operation_id: &str,
draft_profile: &Value,
phase_label: &str,
error_message: &str,
) {
let draft_profile_json = match serde_json::to_string(draft_profile) {
Ok(value) => Some(value),
Err(error) => {
tracing::warn!(error = %error, "素材失败后的部分底稿序列化失败");
None
}
};
let finalize_result = state
.spacetime_client()
.finalize_custom_world_agent_message(CustomWorldAgentMessageFinalizeRecordInput {
session_id: session.session_id.clone(),
owner_user_id: owner_user_id.to_string(),
operation_id: operation_id.to_string(),
assistant_message_id: None,
assistant_reply_text: None,
phase_label: phase_label.to_string(),
phase_detail: format!("已保存成功生成的素材,失败项超过 {DRAFT_ASSET_GENERATION_MAX_ATTEMPTS} 次重试:{error_message}"),
operation_status: "failed".to_string(),
operation_progress: 100,
stage: session.stage.clone(),
progress_percent: session.progress_percent,
focus_card_id: session.focus_card_id.clone(),
anchor_content_json: session.anchor_content.to_string(),
creator_intent_json: Some(session.creator_intent.to_string()),
creator_intent_readiness_json: session.creator_intent_readiness.to_string(),
anchor_pack_json: Some(session.anchor_pack.to_string()),
draft_profile_json,
pending_clarifications_json: Value::Array(session.pending_clarifications.clone()).to_string(),
suggested_actions_json: Value::Array(session.suggested_actions.clone()).to_string(),
recommended_replies_json: json!(session.recommended_replies).to_string(),
quality_findings_json: Value::Array(session.quality_findings.clone()).to_string(),
asset_coverage_json: session.asset_coverage.to_string(),
error_message: Some(error_message.to_string()),
updated_at_micros: current_utc_micros(),
})
.await;
if let Err(error) = finalize_result {
tracing::warn!(error = %error, "素材失败后的部分底稿持久化失败");
let _ = upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
owner_user_id,
operation_id,
"failed",
phase_label,
error_message,
100,
Some(error_message.to_string()),
)
.await;
}
}
async fn upsert_custom_world_draft_foundation_progress(
state: &AppState,
session_id: &str,
@@ -2105,3 +2650,53 @@ fn current_utc_micros() -> i64 {
.expect("system clock should be after unix epoch");
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_scene_act_refs_accepts_scene_prompt_text_alias() {
let draft_profile = json!({
"name": "雾港纪元",
"tone": "潮湿、悬疑、低照度",
"landmarks": [
{
"id": "scene-office",
"name": "旧港办公室",
"description": "旧港边缘的玻璃办公室,窗外能看到潮湿码头。",
"dangerLevel": "low"
}
],
"sceneChapterBlueprints": [
{
"sceneId": "scene-office",
"sceneName": "旧港办公室",
"acts": [
{
"title": "深夜工位",
"summary": "团队在凌晨三点继续赶版本。",
"actGoal": "找到丢失的部署钥匙",
"transitionHook": "电梯门在无人操作时打开",
"primaryRoleName": "林澈",
"supportRoleNames": ["阿岚"],
"scenePromptText": "现代创业公司办公室,凌晨灯光,紧张忙碌"
}
]
}
]
});
let act_refs = collect_scene_act_refs(&draft_profile);
assert_eq!(act_refs.len(), 1);
assert_eq!(act_refs[0].prompt, "现代创业公司办公室,凌晨灯光,紧张忙碌");
assert_eq!(act_refs[0].scene_id, "scene-office");
assert_eq!(act_refs[0].scene_name, "旧港办公室");
assert_eq!(
act_refs[0].scene_description,
"旧港边缘的玻璃办公室,窗外能看到潮湿码头。"
);
assert!(validate_scene_act_background_prompts(&act_refs).is_ok());
}
}

View File

@@ -323,7 +323,6 @@ struct NormalizedSceneImageRequest {
prompt: String,
negative_prompt: String,
reference_image_src: Option<String>,
model: String,
}
#[derive(Debug)]
@@ -341,10 +340,6 @@ struct OptimizedCoverUpload {
bytes: Vec<u8>,
}
const TEXT_TO_IMAGE_SCENE_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_SCENE_MODEL: &str = "qwen-image-2.0";
const TEXT_TO_IMAGE_COVER_MODEL: &str = "wan2.2-t2i-flash";
const REFERENCE_IMAGE_COVER_MODEL: &str = "qwen-image-2.0";
const DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT: &str = "文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头";
const COVER_OUTPUT_WIDTH: u32 = 1600;
const COVER_OUTPUT_HEIGHT: u32 = 900;
@@ -467,7 +462,7 @@ pub async fn generate_custom_world_scene_image(
create_reference_image_generation(
&http_client,
&settings,
REFERENCE_IMAGE_SCENE_MODEL,
state.config.dashscope_reference_image_model.as_str(),
normalized.prompt.as_str(),
normalized.size.as_str(),
&[reference_image.to_string()],
@@ -481,7 +476,7 @@ pub async fn generate_custom_world_scene_image(
create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_SCENE_MODEL,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
@@ -493,6 +488,11 @@ pub async fn generate_custom_world_scene_image(
.await
}
.map_err(|error| custom_world_ai_error_response(&request_context, error))?;
let scene_model = if reference_image.is_some() {
state.config.dashscope_reference_image_model.clone()
} else {
state.config.dashscope_scene_image_model.clone()
};
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
@@ -532,7 +532,7 @@ pub async fn generate_custom_world_scene_image(
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(normalized.model),
model: Some(scene_model),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(normalized.prompt),
@@ -548,6 +548,7 @@ pub async fn generate_custom_world_scene_image(
pub(crate) async fn generate_custom_world_scene_image_for_profile(
state: &AppState,
owner_user_id: &str,
profile: &Value,
profile_id: Option<&str>,
world_name: &str,
scene_id: &str,
@@ -560,20 +561,16 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
world_name: Some(world_name.to_string()),
landmark_id: Some(scene_id.to_string()),
landmark_name: Some(scene_name.to_string()),
prompt: Some(prompt_text.to_string()),
size: Some("1600*900".to_string()),
// 自动草稿生成必须和草稿页手动生成走同一条 prompt 编译链:
// 只把幕级描述作为 userPrompt 输入,仍交给 normalize_scene_image_request 组装世界名、地点名、风格与负面词。
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(prompt_text.to_string()),
profile: Some(SceneImageProfileInput {
id: profile_id.map(ToOwned::to_owned),
name: Some(world_name.to_string()),
subtitle: None,
summary: None,
tone: None,
player_goal: None,
setting_text: None,
}),
profile: Some(scene_image_profile_input_from_value(
profile, profile_id, world_name,
)),
landmark: Some(SceneImageLandmarkInput {
id: Some(scene_id.to_string()),
name: Some(scene_name.to_string()),
@@ -587,7 +584,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
let generated = create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_SCENE_MODEL,
state.config.dashscope_scene_image_model.as_str(),
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
@@ -627,7 +624,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let model = normalized.model.clone();
let model = state.config.dashscope_scene_image_model.clone();
let prompt = normalized.prompt.clone();
let asset = persist_custom_world_asset(
state,
@@ -653,6 +650,31 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile(
})
}
fn scene_image_profile_input_from_value(
profile: &Value,
profile_id: Option<&str>,
world_name: &str,
) -> SceneImageProfileInput {
SceneImageProfileInput {
id: profile_id.map(ToOwned::to_owned),
name: Some(world_name.to_string()),
subtitle: json_text_from_value(profile, "subtitle"),
summary: json_text_from_value(profile, "summary"),
tone: json_text_from_value(profile, "tone"),
player_goal: json_text_from_value(profile, "playerGoal"),
setting_text: json_text_from_value(profile, "settingText"),
}
}
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -707,7 +729,7 @@ pub async fn generate_custom_world_cover_image(
create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_COVER_MODEL,
state.config.dashscope_cover_image_model.as_str(),
prompt.as_str(),
None,
size.as_str(),
@@ -721,7 +743,7 @@ pub async fn generate_custom_world_cover_image(
create_reference_image_generation(
&http_client,
&settings,
REFERENCE_IMAGE_COVER_MODEL,
state.config.dashscope_reference_image_model.as_str(),
prompt.as_str(),
size.as_str(),
&reference_images,
@@ -766,9 +788,9 @@ pub async fn generate_custom_world_cover_image(
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(if reference_images.is_empty() {
TEXT_TO_IMAGE_COVER_MODEL.to_string()
state.config.dashscope_cover_image_model.clone()
} else {
REFERENCE_IMAGE_COVER_MODEL.to_string()
state.config.dashscope_reference_image_model.clone()
}),
size: Some(size),
task_id: Some(generated.task_id),
@@ -1187,11 +1209,6 @@ fn normalize_scene_image_request(
negative_prompt: trim_to_option(payload.negative_prompt.as_deref())
.unwrap_or_else(|| DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT.to_string()),
reference_image_src: reference_image_src.clone(),
model: if reference_image_src.is_some() {
REFERENCE_IMAGE_SCENE_MODEL.to_string()
} else {
TEXT_TO_IMAGE_SCENE_MODEL.to_string()
},
})
}
@@ -2580,6 +2597,103 @@ mod tests {
);
}
#[test]
fn automatic_scene_image_payload_reuses_manual_prompt_compiler() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let payload = CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some("破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。".to_string()),
profile: Some(scene_image_profile_input_from_value(
&profile,
Some("profile_001"),
"雾海群岛",
)),
landmark: Some(SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: None,
}),
};
let normalized = normalize_scene_image_request(payload).expect("payload should normalize");
assert!(normalized.prompt.contains("世界名:雾海群岛"));
assert!(normalized.prompt.contains("世界副标题:失落航线"));
assert!(normalized.prompt.contains("场景名称:礁石神殿"));
assert!(
normalized
.prompt
.contains("本次想要生成的画面内容:破碎神殿")
);
assert_ne!(
normalized.prompt,
"破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。"
);
}
#[test]
fn automatic_default_scene_image_context_matches_manual_default_context() {
let profile = json!({
"id": "profile_001",
"name": "雾海群岛",
"subtitle": "失落航线",
"summary": "玩家在雾海中追查沉没王冠。",
"tone": "潮湿、神秘、低魔奇幻",
"playerGoal": "找到王冠并阻止海妖复苏",
"settingText": "群岛被永恒雾潮包围。"
});
let user_prompt = "破碎神殿矗立在蓝绿色雾潮中,潮湿石阶上有幽光贝壳。";
let profile_input =
scene_image_profile_input_from_value(&profile, Some("profile_001"), "雾海群岛");
let landmark = SceneImageLandmarkInput {
id: Some("reef_temple".to_string()),
name: Some("礁石神殿".to_string()),
description: Some("古老礁石上的半沉神殿。".to_string()),
danger_level: Some("high".to_string()),
};
let manual_prompt = build_custom_world_scene_image_prompt(
&profile_input,
&landmark,
user_prompt,
false,
Some("礁石神殿"),
"雾海群岛",
);
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
profile_id: Some("profile_001".to_string()),
world_name: Some("雾海群岛".to_string()),
landmark_id: Some("reef_temple".to_string()),
landmark_name: Some("礁石神殿".to_string()),
prompt: None,
size: Some("1280*720".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(user_prompt.to_string()),
profile: Some(profile_input),
landmark: Some(landmark),
})
.expect("payload should normalize");
assert_eq!(normalized.prompt, manual_prompt);
}
#[tokio::test]
async fn cover_image_returns_service_unavailable_when_dashscope_missing() {
let state = AppState::new(AppConfig::default()).expect("state should build");

View File

@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
use serde_json::{Map as JsonMap, Value as JsonValue, json};
use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest;
use spacetime_client::CustomWorldAgentSessionRecord;
@@ -738,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),
@@ -751,6 +751,7 @@ fn build_custom_world_landmark_seed_batch_prompt(
" \"name\": \"场景名称\",".to_string(),
" \"description\": \"场景极简描述\",".to_string(),
" \"visualDescription\": \"默认场景生图描述\",".to_string(),
" \"actBackgroundPromptTexts\": [\"第一幕背景画面描述\", \"第二幕背景画面描述\", \"第三幕背景画面描述\"],".to_string(),
" \"dangerLevel\": \"low|medium|high|extreme\"".to_string(),
" }".to_string(),
" ]".to_string(),
@@ -760,8 +761,10 @@ fn build_custom_world_landmark_seed_batch_prompt(
format!("- 必须生成恰好 {batch_count} 个关键场景。"),
"- 这是一个完全独立的自定义世界;地点名称必须直接服务玩家输入主题。".to_string(),
"- 名称必须具体且互不重复,不要使用 地点1、场景1 之类的占位名。".to_string(),
"- 每个地点只保留name、description、visualDescription、dangerLevel。".to_string(),
"- 每个地点只保留name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
"- visualDescription 是打开场景背景图像生成面板时默认填入的场景描述,必须具体到画面主体、远近景层次、地面可站立区域和氛围识别点,控制在 32 到 80 个汉字内。".to_string(),
"- actBackgroundPromptTexts 必须恰好 3 条,分别对应这个场景章节的第 1/2/3 幕背景图画面内容描述;每条都必须是大模型根据当前地点、主线阶段和可出场角色直接写出的画面描述,控制在 40 到 90 个汉字内。".to_string(),
"- actBackgroundPromptTexts 禁止使用“某某第1幕背景玩家会在……”这类标题、摘要、规则句拼接格式必须像可直接交给生图模型的自然画面描述。".to_string(),
"- description 控制在 12 到 24 个汉字内。".to_string(),
"- dangerLevel 只能是 low、medium、high、extreme 之一。".to_string(),
"- 所有生成文本都必须使用中文。".to_string(),
@@ -780,8 +783,8 @@ 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、visualDescription、dangerLevel。".to_string(),
"如果缺少字段字符串补空字符串dangerLevel 补 medium。".to_string(),
"每个地点只包含name、description、visualDescription、actBackgroundPromptTexts、dangerLevel。".to_string(),
"如果缺少字段:字符串补空字符串,actBackgroundPromptTexts 补空数组,dangerLevel 补 medium。".to_string(),
"不要输出 sceneNpcNames、connectedLandmarks、items 或任何其他字段。".to_string(),
"原始文本:".to_string(),
response_text.trim().to_string(),
@@ -945,6 +948,7 @@ fn build_custom_world_role_batch_json_repair_prompt(
response_text.trim().to_string(),
].join("\n")
}
#[cfg(test)]
fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -> String {
let anchor_content = to_pretty_json(&session.anchor_content);
let creator_intent = to_pretty_json(&session.creator_intent);
@@ -1063,11 +1067,80 @@ fn build_foundation_draft_profile_from_framework(
JsonValue::Array(playable_detailed),
);
object.insert("storyNpcs".to_string(), JsonValue::Array(story_detailed));
let scene_chapter_blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
object.insert("landmarks".to_string(), JsonValue::Array(landmarks));
object.insert("chapters".to_string(), JsonValue::Array(Vec::new()));
object.insert(
"sceneChapterBlueprints".to_string(),
JsonValue::Array(scene_chapter_blueprints),
);
normalize_foundation_draft_profile(JsonValue::Object(object), session)
}
fn build_scene_chapter_blueprints_from_landmarks(landmarks: &[JsonValue]) -> Vec<JsonValue> {
// 幕背景描述必须来自关键场景生成步骤,不能在草稿合成阶段再用规则句拼接。
landmarks
.iter()
.enumerate()
.map(|(chapter_index, landmark)| {
let scene_name = json_text(landmark, "name")
.unwrap_or_else(|| format!("关键场景{}", chapter_index + 1));
let scene_id = json_text(landmark, "id")
.unwrap_or_else(|| format!("saved-landmark-{}", chapter_index + 1));
let summary = json_text(landmark, "description").unwrap_or_default();
let act_prompts =
json_string_array(landmark, "actBackgroundPromptTexts").unwrap_or_default();
let scene_npc_names = json_string_array(landmark, "sceneNpcNames").unwrap_or_default();
json!({
"id": scene_id.clone(),
"sceneId": scene_id.clone(),
"title": scene_name,
"summary": summary,
"linkedLandmarkIds": [scene_id.clone()],
"acts": (0..3)
.map(|act_index| build_scene_act_blueprint_from_landmark(
&scene_id,
&summary,
&act_prompts,
&scene_npc_names,
act_index,
))
.collect::<Vec<_>>(),
})
})
.collect()
}
fn build_scene_act_blueprint_from_landmark(
scene_id: &str,
scene_summary: &str,
act_prompts: &[String],
scene_npc_names: &[String],
act_index: usize,
) -> JsonValue {
let act_title = if act_index == 0 {
"第1幕".to_string()
} else {
format!("{}", act_index + 1)
};
let prompt = act_prompts
.get(act_index)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
// 缺失时保留空值,让后续生图前校验暴露底稿质量问题。
json!({
"id": format!("{}-act-{}", scene_id, act_index + 1),
"sceneId": scene_id,
"title": act_title,
"summary": scene_summary,
"backgroundPromptText": prompt,
"encounterNpcIds": scene_npc_names,
})
}
fn normalize_framework_shape(framework: &mut JsonValue, setting_text: &str) {
if !framework.is_object() {
*framework = json!({});
@@ -1469,12 +1542,6 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
let mut object = act.as_object().cloned().unwrap_or_default();
let fallback_act = build_fallback_scene_act_with_index(index);
let fallback_prompt = fallback_act
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
.to_string();
let title = object
.get("title")
.and_then(JsonValue::as_str)
@@ -1497,7 +1564,7 @@ fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{title}{summary}{fallback_prompt}"));
.unwrap_or_default();
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
@@ -1523,7 +1590,7 @@ fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
"backgroundPromptText": "",
})
}
@@ -1642,10 +1709,12 @@ fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error>
serde_json::from_str::<JsonValue>(trimmed)
}
#[cfg(test)]
fn to_pretty_json(value: &JsonValue) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
}
#[cfg(test)]
fn is_non_null_json(value: &JsonValue) -> bool {
!matches!(value, JsonValue::Null)
}
@@ -1665,6 +1734,54 @@ mod tests {
use super::*;
#[test]
fn scene_chapter_blueprints_use_landmark_act_background_prompts() {
let landmarks = vec![json!({
"name": "雾港码头",
"description": "旧船骨露出黑潮。",
"actBackgroundPromptTexts": [
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。",
"封锁绳与巡海灯横切码头,中景堆满浸水货箱,远景黑潮拍打沉船残骸。",
"退潮后的泥滩露出父亲留下的海图匣,雾中灯火错位闪烁,岸边留出对峙站位。"
],
"sceneNpcNames": ["灯童丁"]
})];
let blueprints = build_scene_chapter_blueprints_from_landmarks(&landmarks);
let acts = blueprints[0]
.get("acts")
.and_then(JsonValue::as_array)
.expect("acts should exist");
assert_eq!(acts.len(), 3);
assert_eq!(
acts[0].get("backgroundPromptText"),
Some(&json!(
"潮湿木栈桥在青灰雾里延伸,近处有可站立的破旧甲板,远处旧船骨与灯塔剪影压低天空。"
))
);
assert!(
!acts[0]
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or_default()
.contains("第1幕背景")
);
}
#[test]
fn normalize_scene_act_keeps_missing_background_prompt_empty() {
let act = normalize_scene_act_blueprint(
json!({
"title": "第1幕",
"summary": "玩家进入雾港码头。"
}),
0,
);
assert_eq!(act.get("backgroundPromptText"), Some(&json!("")));
}
#[test]
fn foundation_prompt_uses_real_seed_text() {
let session = build_test_session();
@@ -1740,7 +1857,7 @@ mod tests {
r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#,
),
llm_response(
r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#,
@@ -1795,6 +1912,24 @@ mod tests {
assert!(request_text.contains("叙事档案"));
assert!(request_text.contains("养成档案"));
assert!(!request_text.contains("seedText\\uff1acustom-world-agent-session-1"));
assert_eq!(
draft_profile
.get("playableNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。")
);
assert_eq!(
draft_profile
.get("storyNpcs")
.and_then(JsonValue::as_array)
.and_then(|entries| entries.first())
.and_then(|entry| entry.get("visualDescription"))
.and_then(JsonValue::as_str),
Some("深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。")
);
assert_eq!(draft_profile.get("name"), Some(&json!("雾港归航")));
assert!(
draft_profile

View File

@@ -38,6 +38,18 @@ impl AppError {
&self.message
}
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details
.as_ref()
.and_then(|details| details.get("message"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|message| !message.is_empty())
.unwrap_or(self.message.as_str())
.to_string()
}
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = message.into();
self

View File

@@ -1,4 +1,5 @@
mod admin;
mod ai_generation_drafts;
mod ai_tasks;
mod api_response;
mod app;

View File

@@ -58,6 +58,7 @@ use std::convert::Infallible;
use tokio::time::sleep;
use crate::{
ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter},
api_response::json_success_body,
auth::AuthenticatedAccessToken,
http_error::AppError,
@@ -300,6 +301,16 @@ pub async fn stream_puzzle_agent_message(
let session_id_for_stream = session_id.clone();
let owner_user_id_for_stream = owner_user_id.clone();
let stream = async_stream::stream! {
let mut draft_writer = AiGenerationDraftWriter::new(AiGenerationDraftContext::new(
"puzzle",
owner_user_id_for_stream.as_str(),
session_id_for_stream.as_str(),
payload.client_message_id.as_str(),
"拼图模板生成草稿",
));
if let Err(error) = draft_writer.ensure_started(state.spacetime_client()).await {
tracing::warn!(error = %error, "拼图模板生成草稿任务启动失败,主生成流程继续执行");
}
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let turn_result = {
let run_turn = run_puzzle_agent_turn(
@@ -319,6 +330,7 @@ pub async fn stream_puzzle_agent_message(
result = &mut run_turn => break result,
maybe_text = reply_rx.recv() => {
if let Some(text) = maybe_text {
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),
@@ -330,6 +342,7 @@ pub async fn stream_puzzle_agent_message(
};
while let Some(text) = reply_rx.recv().await {
draft_writer.persist_visible_text(state.spacetime_client(), text.as_str()).await;
yield Ok::<Event, Infallible>(puzzle_sse_json_event_or_error(
"reply_delta",
json!({ "text": text }),

View File

@@ -1,4 +1,12 @@
use std::{error::Error, fmt, str as std_str, time::Duration};
use std::{
env,
error::Error,
fmt, fs,
path::PathBuf,
str as std_str,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use log::{debug, warn};
use reqwest::{Client, StatusCode};
@@ -10,6 +18,9 @@ pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000;
pub const DEFAULT_MAX_RETRIES: u32 = 1;
pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500;
pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions";
const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw";
static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1);
// 冻结平台来源,避免上层继续散落 provider 字符串。
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -113,6 +124,17 @@ struct ChatCompletionsRequestBody<'a> {
max_tokens: Option<u32>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmRawFailureInputLog<'a> {
provider: &'static str,
model: &'a str,
stream: bool,
attempt: u32,
max_tokens: Option<u32>,
messages: &'a [LlmMessage],
}
#[derive(Deserialize)]
struct ChatCompletionsResponseEnvelope {
id: Option<String>,
@@ -156,6 +178,7 @@ struct ChatCompletionsContentPart {
#[derive(Default)]
struct OpenAiCompatibleSseParser {
buffer: String,
raw_text: String,
}
#[derive(Debug)]
@@ -382,12 +405,31 @@ impl LlmClient {
request.validate()?;
let resolved_model = request.resolved_model(self.config.model()).to_string();
let response = self.execute_request(&request, false).await?;
let raw_text = response
.text()
.await
.map_err(|error| map_stream_read_error(error, 1))?;
let raw_text = response.text().await.map_err(|error| {
let llm_error = map_stream_read_error(error, 1);
log_llm_raw_failure(
&self.config,
&request,
false,
1,
"read_response_failed",
llm_error.to_string().as_str(),
);
llm_error
})?;
parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str())
.map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
false,
1,
"parse_response_failed",
raw_text.as_str(),
);
error
})
}
pub async fn request_single_message_text(
@@ -422,10 +464,18 @@ impl LlmClient {
let mut undecoded_chunk_bytes = Vec::new();
loop {
let next_chunk = response
.chunk()
.await
.map_err(|error| map_stream_read_error(error, 1))?;
let next_chunk = response.chunk().await.map_err(|error| {
let llm_error = map_stream_read_error(error, 1);
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"read_stream_failed",
parser.raw_text().as_str(),
);
llm_error
})?;
let Some(chunk) = next_chunk else {
break;
@@ -433,12 +483,33 @@ impl LlmClient {
undecoded_chunk_bytes.extend_from_slice(chunk.as_ref());
let (chunk_text, remaining_bytes) =
decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice())?;
decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"decode_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
undecoded_chunk_bytes = remaining_bytes;
if chunk_text.is_empty() {
continue;
}
for event in parser.push_chunk(chunk_text.as_ref())? {
let stream_events = parser.push_chunk(chunk_text.as_ref()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in stream_events {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
{
@@ -460,10 +531,29 @@ impl LlmClient {
if !undecoded_chunk_bytes.is_empty() {
let trailing_text =
std_str::from_utf8(undecoded_chunk_bytes.as_slice()).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"decode_stream_failed",
parser.raw_text().as_str(),
);
LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}"))
})?;
if !trailing_text.is_empty() {
for event in parser.push_chunk(trailing_text)? {
let trailing_events = parser.push_chunk(trailing_text).map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in trailing_events {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
{
@@ -483,7 +573,18 @@ impl LlmClient {
}
}
for event in parser.finish()? {
let remaining_events = parser.finish().map_err(|error| {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"parse_stream_failed",
parser.raw_text().as_str(),
);
error
})?;
for event in remaining_events {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
{
@@ -503,6 +604,14 @@ impl LlmClient {
let content = accumulated_text.trim().to_string();
if content.is_empty() {
log_llm_raw_failure(
&self.config,
&request,
true,
1,
"empty_stream_response",
parser.raw_text().as_str(),
);
return Err(LlmError::EmptyResponse);
}
@@ -591,6 +700,14 @@ impl LlmClient {
continue;
}
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"upstream_status_failed",
raw_text.as_str(),
);
return Err(LlmError::Upstream {
status_code: status.as_u16(),
message,
@@ -607,7 +724,16 @@ impl LlmClient {
continue;
}
return Err(LlmError::Timeout { attempts: attempt });
let error = LlmError::Timeout { attempts: attempt };
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_timeout",
error.to_string().as_str(),
);
return Err(error);
}
Err(error) if error.is_connect() => {
let message = error.to_string();
@@ -622,13 +748,31 @@ impl LlmClient {
continue;
}
return Err(LlmError::Connectivity {
let error = LlmError::Connectivity {
attempts: attempt,
message,
});
};
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_connectivity_failed",
error.to_string().as_str(),
);
return Err(error);
}
Err(error) => {
return Err(LlmError::Transport(error.to_string()));
let error = LlmError::Transport(error.to_string());
log_llm_raw_failure(
&self.config,
request,
stream,
attempt,
"request_transport_failed",
error.to_string().as_str(),
);
return Err(error);
}
}
}
@@ -652,11 +796,16 @@ impl LlmClient {
impl OpenAiCompatibleSseParser {
fn push_chunk(&mut self, chunk: &str) -> Result<Vec<ParsedStreamEvent>, LlmError> {
self.raw_text.push_str(chunk);
self.buffer.push_str(chunk);
self.buffer = self.buffer.replace("\r\n", "\n");
self.drain_complete_events()
}
fn raw_text(&self) -> String {
self.raw_text.clone()
}
fn finish(&mut self) -> Result<Vec<ParsedStreamEvent>, LlmError> {
if self.buffer.trim().is_empty() {
return Ok(Vec::new());
@@ -691,6 +840,87 @@ fn normalize_non_empty(value: String, error_message: &str) -> Result<String, Llm
Ok(trimmed)
}
fn log_llm_raw_failure(
config: &LlmConfig,
request: &LlmTextRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) {
if let Err(error) =
write_llm_raw_failure(config, request, stream, attempt, failure_stage, raw_output)
{
warn!(
"LLM 失败原文日志落盘失败,主错误流程继续执行: failure_stage={}, error={}",
failure_stage, error
);
}
}
fn write_llm_raw_failure(
config: &LlmConfig,
request: &LlmTextRequest,
stream: bool,
attempt: u32,
failure_stage: &str,
raw_output: &str,
) -> Result<(), String> {
let log_dir = env::var("LLM_RAW_LOG_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_LLM_RAW_LOG_DIR));
fs::create_dir_all(&log_dir).map_err(|error| format!("创建日志目录失败:{error}"))?;
let prefix = build_llm_raw_log_prefix(failure_stage);
let model = request.resolved_model(config.model());
let input_log = LlmRawFailureInputLog {
provider: config.provider().as_str(),
model,
stream,
attempt,
max_tokens: request.max_tokens,
messages: request.messages.as_slice(),
};
let input_text = serde_json::to_string_pretty(&input_log)
.map_err(|error| format!("序列化模型输入日志失败:{error}"))?;
fs::write(log_dir.join(format!("{prefix}.input.json")), input_text)
.map_err(|error| format!("写入模型输入日志失败:{error}"))?;
fs::write(log_dir.join(format!("{prefix}.output.txt")), raw_output)
.map_err(|error| format!("写入模型输出日志失败:{error}"))?;
Ok(())
}
fn build_llm_raw_log_prefix(failure_stage: &str) -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or_default();
let sequence = LLM_RAW_LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let safe_stage = sanitize_log_file_segment(failure_stage);
format!("{millis}-{}-{sequence:06}-{safe_stage}", std::process::id())
}
fn sanitize_log_file_segment(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect::<String>();
if sanitized.is_empty() {
"unknown".to_string()
} else {
sanitized
}
}
fn parse_chat_completions_response(
provider: LlmProvider,
fallback_model: &str,
@@ -1028,6 +1258,62 @@ mod tests {
assert_eq!(response.response_id.as_deref(), Some("req_stream_01"));
}
#[tokio::test]
async fn request_text_writes_raw_failure_logs_after_parse_error() {
let log_dir = std::env::temp_dir().join(format!(
"platform-llm-raw-log-test-{}",
build_llm_raw_log_prefix("parse_error")
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let server_url = spawn_mock_server(vec![MockResponse {
status_line: "200 OK",
content_type: "application/json; charset=utf-8",
body: "不是合法 JSON".to_string(),
extra_headers: Vec::new(),
}]);
let client = build_test_client(server_url, 0);
let error = client
.request_single_message_text("系统原文", "用户原文")
.await
.expect_err("invalid json should fail");
assert!(matches!(error, LlmError::Deserialize(_)));
let mut input_logs = Vec::new();
let mut output_logs = Vec::new();
for entry in fs::read_dir(&log_dir).expect("log dir should exist") {
let path = entry.expect("log entry should be readable").path();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
.to_string();
if file_name.ends_with(".input.json") {
input_logs.push(path);
} else if file_name.ends_with(".output.txt") {
output_logs.push(path);
}
}
assert_eq!(input_logs.len(), 1);
assert_eq!(output_logs.len(), 1);
let input_text = fs::read_to_string(&input_logs[0]).expect("input log should be readable");
let output_text =
fs::read_to_string(&output_logs[0]).expect("output log should be readable");
assert!(input_text.contains("系统原文"));
assert!(input_text.contains("用户原文"));
assert!(!input_text.contains("test-key"));
assert_eq!(output_text, "不是合法 JSON");
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
fs::remove_dir_all(log_dir).expect("log dir should be removed");
}
fn build_test_client(base_url: String, max_retries: u32) -> LlmClient {
let config = LlmConfig::new(
LlmProvider::Ark,

View File

@@ -519,4 +519,5 @@ impl SpacetimeClient {
})
.await
}
}

View File

@@ -18,7 +18,8 @@ pub use mapper::{
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput,
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,

View File

@@ -2726,17 +2726,6 @@ pub(crate) fn format_rpg_agent_operation_type(
}
}
pub(crate) fn format_rpg_agent_operation_status(
value: crate::module_bindings::RpgAgentOperationStatus,
) -> &'static str {
match value {
crate::module_bindings::RpgAgentOperationStatus::Queued => "queued",
crate::module_bindings::RpgAgentOperationStatus::Running => "running",
crate::module_bindings::RpgAgentOperationStatus::Completed => "completed",
crate::module_bindings::RpgAgentOperationStatus::Failed => "failed",
}
}
pub(crate) fn parse_rpg_agent_operation_type_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationType, SpacetimeClientError> {
@@ -2744,22 +2733,12 @@ pub(crate) fn parse_rpg_agent_operation_type_record(
"process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage),
"draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation),
"update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard),
"sync_result_profile" => {
Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile)
}
"generate_characters" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters)
}
"generate_landmarks" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks)
}
"generate_role_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets)
}
"sync_result_profile" => Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile),
"generate_characters" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters),
"generate_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks),
"generate_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets),
"sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets),
"generate_scene_assets" => {
Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets)
}
"generate_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets),
"sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets),
"expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail),
"publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld),
@@ -2772,6 +2751,17 @@ pub(crate) fn parse_rpg_agent_operation_type_record(
}
}
pub(crate) fn format_rpg_agent_operation_status(
value: crate::module_bindings::RpgAgentOperationStatus,
) -> &'static str {
match value {
crate::module_bindings::RpgAgentOperationStatus::Queued => "queued",
crate::module_bindings::RpgAgentOperationStatus::Running => "running",
crate::module_bindings::RpgAgentOperationStatus::Completed => "completed",
crate::module_bindings::RpgAgentOperationStatus::Failed => "failed",
}
}
pub(crate) fn parse_rpg_agent_operation_status_record(
value: &str,
) -> Result<crate::module_bindings::RpgAgentOperationStatus, SpacetimeClientError> {
@@ -2841,22 +2831,6 @@ impl TryFrom<&str> for BigFishAssetKind {
}
}
pub(crate) fn map_big_fish_creation_stage(
value: module_big_fish::BigFishCreationStage,
) -> BigFishCreationStage {
match value {
module_big_fish::BigFishCreationStage::CollectingAnchors => {
BigFishCreationStage::CollectingAnchors
}
module_big_fish::BigFishCreationStage::DraftReady => BigFishCreationStage::DraftReady,
module_big_fish::BigFishCreationStage::AssetRefining => BigFishCreationStage::AssetRefining,
module_big_fish::BigFishCreationStage::ReadyToPublish => {
BigFishCreationStage::ReadyToPublish
}
module_big_fish::BigFishCreationStage::Published => BigFishCreationStage::Published,
}
}
pub(crate) fn parse_big_fish_creation_stage(
value: &str,
) -> Result<BigFishCreationStage, SpacetimeClientError> {
@@ -3501,6 +3475,21 @@ pub struct CustomWorldAgentOperationRecord {
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentOperationProgressRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
// SpacetimeDB 模块侧使用枚举存储操作类型,这里保留字符串给 API 层做轻量传参。
pub operation_type: String,
pub operation_status: String,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CustomWorldDraftCardRecord {
pub card_id: String,
@@ -3721,20 +3710,6 @@ pub struct CustomWorldAgentMessageFinalizeRecordInput {
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentOperationProgressRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub operation_type: String,
pub operation_status: String,
pub phase_label: String,
pub phase_detail: String,
pub operation_progress: u32,
pub error_message: Option<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CustomWorldAgentActionExecuteRecordInput {
pub session_id: String,

View File

@@ -342,7 +342,6 @@ pub mod start_ai_task_reducer;
pub mod start_ai_task_stage_reducer;
pub mod turn_in_quest_reducer;
pub mod unpublish_custom_world_profile_reducer;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_chapter_progression_reducer;
pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer;
@@ -477,6 +476,7 @@ pub mod unpublish_custom_world_profile_and_return_procedure;
pub mod update_puzzle_work_procedure;
pub mod upsert_auth_store_snapshot_procedure;
pub mod upsert_chapter_progression_and_return_procedure;
pub mod upsert_custom_world_agent_operation_progress_procedure;
pub mod upsert_custom_world_profile_and_return_procedure;
pub mod upsert_npc_state_and_return_procedure;
pub mod upsert_platform_browse_history_and_return_procedure;
@@ -856,7 +856,6 @@ pub use start_ai_task_reducer::start_ai_task;
pub use start_ai_task_stage_reducer::start_ai_task_stage;
pub use turn_in_quest_reducer::turn_in_quest;
pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
pub use upsert_npc_state_reducer::upsert_npc_state;
@@ -946,6 +945,7 @@ pub use unpublish_custom_world_profile_and_return_procedure::unpublish_custom_wo
pub use update_puzzle_work_procedure::update_puzzle_work;
pub use upsert_auth_store_snapshot_procedure::upsert_auth_store_snapshot;
pub use upsert_chapter_progression_and_return_procedure::upsert_chapter_progression_and_return;
pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress;
pub use upsert_custom_world_profile_and_return_procedure::upsert_custom_world_profile_and_return;
pub use upsert_npc_state_and_return_procedure::upsert_npc_state_and_return;
pub use upsert_platform_browse_history_and_return_procedure::upsert_platform_browse_history_and_return;

View File

@@ -1572,7 +1572,34 @@ fn delete_custom_world_agent_session_tx(
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if session.stage == RpgAgentStage::Published {
return Err("已发布 RPG 作品请通过 profile 删除".to_string());
let published_profile = ctx
.db
.custom_world_profile()
.iter()
.find(|row| {
row.owner_user_id == input.owner_user_id
&& row.source_agent_session_id.as_deref() == Some(input.session_id.as_str())
&& row.deleted_at.is_none()
})
.ok_or_else(|| "已发布 RPG 作品缺少关联 profile无法删除".to_string())?;
// 作品卡可能只携带源 Agent sessionId。这里把“按 session 删除已发布作品”收敛为
// profile 软删除,避免前端误入草稿删除接口时把业务分支放大成上游 502。
delete_custom_world_profile_record(
ctx,
CustomWorldProfileDeleteInput {
profile_id: published_profile.profile_id,
owner_user_id: input.owner_user_id.clone(),
deleted_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)?;
return list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
);
}
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。

View File

@@ -73,4 +73,19 @@ describe('CharacterAnimator portrait fallbacks', () => {
expect(image.style.transform).toContain('rotate(-90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
it('uses generated portrait for movement when generated animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.RUN}
character={buildCharacter({generatedVisualAssetId: 'assetobj-role-main'})}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 run animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
});
});

View File

@@ -107,6 +107,9 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
playbackRate = 1,
}) => {
const explicitConfig = character.animationMap?.[state];
const hasGeneratedPortraitOnly =
Boolean(character.generatedVisualAssetId && character.portrait?.trim())
&& !explicitConfig;
const usePortraitIdleFallback =
!explicitConfig && state === AnimationState.IDLE;
const usePortraitDeathFallback =
@@ -118,7 +121,7 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)

View File

@@ -341,6 +341,46 @@ function resolveSceneCardImage(params: {
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function SceneActPreviewStrip({
acts,
sceneName,
}: {
acts: Array<{ id: string; title: string; imageSrc: string }>;
sceneName: string;
}) {
if (acts.length <= 0) return null;
return (
<div className="flex w-full gap-1.5 overflow-x-auto pb-0.5">
{acts.map((act) => (
<div
key={act.id}
className="platform-subpanel h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
title={act.title}
>
<ResolvedAssetImage
src={act.imageSrc}
alt={`${sceneName}-${act.title}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
}
function CatalogCard({
title,
description,
@@ -1015,6 +1055,7 @@ export function CustomWorldEntityCatalog({
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
actPreviews: collectSceneActImagePreviews(openingSceneChapters),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
@@ -1039,6 +1080,7 @@ export function CustomWorldEntityCatalog({
sceneChapters,
}),
sceneChapters,
actPreviews: collectSceneActImagePreviews(sceneChapters),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
@@ -1576,6 +1618,12 @@ export function CustomWorldEntityCatalog({
tone="landscape"
/>
}
actions={
<SceneActPreviewStrip
acts={scene.actPreviews}
sceneName={scene.name}
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))

View File

@@ -262,6 +262,21 @@ const baseProfile = {
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
{
id: 'scene-act-2',
sceneId: 'landmark-1',
title: '钟楼回响',
summary: '第二幕把旧钟与暗线证据推到台前。',
stageCoverage: ['investigation'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png',
backgroundAssetId: 'scene-asset-2',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_clue_found',
actGoal: '找到旧钟证据',
transitionHook: '钟楼深处传来第二次回响。',
},
],
},
],
@@ -400,7 +415,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
test('landmark tab previews every generated act image while keeping chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
@@ -414,6 +429,17 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
@@ -535,10 +561,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(
screen.getByRole('dialog', { name: '发布前检查' }),
).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();

View File

@@ -10,11 +10,14 @@ import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -26,10 +29,13 @@ vi.mock('../../services/apiClient', () => ({
}));
vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry,
bindWechatPhone: vi.fn(),
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
@@ -40,8 +46,10 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
}));
@@ -86,6 +94,9 @@ beforeEach(() => {
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -203,13 +214,13 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
@@ -220,7 +231,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
@@ -280,7 +291,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
@@ -296,7 +307,48 @@ test('auth gate shows sms send feedback in the login modal', async () => {
});
expect(
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(
within(dialog)
.getByRole('tab', { name: '密码登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});

View File

@@ -10,6 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
type LoginScreenProps = {
isOpen: boolean;
@@ -72,6 +73,18 @@ export function LoginScreen({
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
setActiveLoginTab('password');
return;
}
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
useEffect(() => {
if (cooldownSeconds <= 0) {
@@ -152,8 +165,29 @@ export function LoginScreen({
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
/>
) : (
<div className="flex flex-col gap-4 px-5 py-5">
{passwordLoginEnabled ? (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
@@ -162,14 +196,13 @@ export function LoginScreen({
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<span>/</span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
placeholder="手机号或邮箱"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -213,7 +246,7 @@ export function LoginScreen({
</form>
) : null}
{phoneLoginEnabled ? (
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
@@ -226,7 +259,7 @@ export function LoginScreen({
hint={hint}
submitLabel="注册/登录"
enabled={phoneLoginEnabled}
showPhoneField={!passwordLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
@@ -258,6 +291,35 @@ export function LoginScreen({
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
@@ -127,5 +128,36 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];
const persistedDraft = {
...baseDraftItem,
workId: 'draft:profile-1',
sourceType: 'published_profile' as const,
sessionId: null,
profileId: 'profile-1',
title: '可继续整理的草稿',
};
render(
<CustomWorldCreationHub
items={[persistedDraft]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={(item) => {
openedItems.push(item);
}}
onEnterPublished={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: /稿/u }));
expect(openedItems).toEqual([persistedDraft]);
});

View File

@@ -171,10 +171,7 @@ export function CustomWorldCreationHub({
return;
}
if (
item.item.sourceType === 'agent_session' &&
item.item.sessionId
) {
if (item.item.status === 'draft') {
onOpenDraft(item.item);
return;
}

View File

@@ -76,7 +76,21 @@ export function CustomWorldWorkCard({
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem] xl:min-h-[12.25rem] xl:px-4 xl:py-3.5">
<div
role="button"
tabIndex={0}
aria-label={`${openActionLabel}${title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
>
<CustomWorldCoverArtwork
imageSrc={coverImageSrc}
title={title}
@@ -86,7 +100,7 @@ export function CustomWorldWorkCard({
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col xl:min-h-[10.75rem]">
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
@@ -124,11 +138,14 @@ export function CustomWorldWorkCard({
{onDelete ? (
<button
type="button"
onClick={onDelete}
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : `删除作品《${title}`}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
@@ -155,19 +172,19 @@ export function CustomWorldWorkCard({
</div>
</div>
<div className="mt-4 xl:mt-3">
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2 xl:line-clamp-2 xl:leading-6">
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between xl:gap-2 xl:pt-3">
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{isPuzzle ? (
<>
@@ -222,18 +239,14 @@ export function CustomWorldWorkCard({
)}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
<button
type="button"
onClick={onOpen}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
{openActionLabel}
</button>
{onExperience ? (
<button
type="button"
onClick={onExperience}
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>

View File

@@ -1246,12 +1246,13 @@ export function PlatformEntryFlowShellImpl({
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
const deleteTask = work.profileId
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: work.sessionId
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
})
@@ -2062,7 +2063,10 @@ export function PlatformEntryFlowShellImpl({
});
});
}}
onTestWorld={() => {
onTestWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? () => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
@@ -2075,10 +2079,27 @@ export function PlatformEntryFlowShellImpl({
);
});
});
}}
onPublishWorld={async () => {
}
: undefined
}
onPublishWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? async () => {
try {
await enterWorldCoordinator.publishCurrentResult();
}}
} catch (error) {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布到广场失败。',
),
);
throw error;
}
}
: undefined
}
onGenerateEntity={
sessionController.isAgentDraftResultView
? async (kind) => {

View File

@@ -345,11 +345,8 @@ function buildDefaultSceneActBlueprint(params: {
title: actTitle,
summary: actSummary,
stageCoverage,
backgroundPromptText: compactTextList([
`${sceneLabel}${actTitle}背景`,
sceneSummary,
actSummary,
]).join(''),
// 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。
backgroundPromptText: '',
backgroundImageSrc: params.backgroundImageSrc || undefined,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
@@ -472,9 +469,7 @@ function sanitizeSceneChapterBlueprint(params: {
title: currentAct?.title?.trim() || fallbackAct.title,
summary: currentAct?.summary?.trim() || fallbackAct.summary,
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
backgroundPromptText:
currentAct?.backgroundPromptText?.trim() ||
fallbackAct.backgroundPromptText,
backgroundPromptText: currentAct?.backgroundPromptText?.trim() || '',
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||

View File

@@ -291,6 +291,7 @@ export function RpgCreationResultActionBar({
isPublishing={isPublishing}
onClose={() => setShowPublishBlockersDialog(false)}
onEditCover={() => {
setShowPublishBlockersDialog(false);
onOpenCoverEditor?.();
}}
onPublish={() => {

View File

@@ -1470,7 +1470,7 @@ test('big fish draft card restores the bound agent session and opens the result
throw new Error('Missing big fish draft card');
}
await user.click(within(card).getByRole('button', { name: //u }));
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
@@ -1522,6 +1522,70 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('refresh restores running draft generation progress instead of agent workspace', async () => {
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-1&customWorldOperationId=operation-draft-foundation-1&customWorldGenerationSource=agent-draft-foundation',
);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
});
render(<TestWrapper withAuth />);
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('failed draft work continues on generation progress view instead of agent workspace', async () => {
const user = userEvent.setup();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '失败中的潮雾列岛',
subtitle: '生成失败待处理',
summary: '草稿生成过程中失败,需要继续处理。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '生成失败待处理',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
});
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
@@ -1544,9 +1608,8 @@ test('existing draft sessions open result page refinement instead of agent dialo
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
},
{ timeout: 2500 },
);
@@ -1594,9 +1657,11 @@ test('agent result view shows publish blocker dialog before publish action when
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
});
const actionButton = await screen.findByRole(
'button',
{ name: '发布' },
{ timeout: 5000 },
);
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
const publishWorldCallCountBeforeClick = vi
@@ -1609,8 +1674,9 @@ test('agent result view shows publish blocker dialog before publish action when
await user.click(actionButton);
expect(await screen.findByRole('dialog', { name: '发布前检查' })).toBeTruthy();
expect(screen.getByText(/ 1 /u)).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
const publishWorldCallCountAfterClick = vi
@@ -1623,7 +1689,7 @@ test('agent result view shows publish blocker dialog before publish action when
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
});
test('agent draft result publishes before entering world and uses published preview profile', async () => {
test('agent draft result publishes to gallery from publish panel', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
@@ -1711,9 +1777,10 @@ test('agent draft result publishes before entering world and uses published prev
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
name: '发布',
});
await user.click(actionButton);
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
await waitFor(() => {
expect(executeRpgCreationAction).toHaveBeenCalledWith(
@@ -1723,23 +1790,78 @@ test('agent draft result publishes before entering world and uses published prev
}),
);
});
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
});
test('agent draft result test button enters current draft without publish gate', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: false,
blockers: [
{
id: 'missing-cover-image',
code: 'MISSING_COVER_IMAGE',
message: '发布前需要补齐作品封面。',
},
],
},
});
function TestDraftWrapper() {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<TestDraftWrapper />);
await openNewRpgCreation(user);
await user.click(await screen.findByRole('button', { name: '作品测试' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
payload?.action === 'publish_world',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
name: '潮雾列岛·已发布',
summary: '发布完成后应直接使用已发布预览进入世界。',
}),
);
});
});
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
@@ -1879,12 +2001,13 @@ test('agent result view does not keep legacy publish blockers when preview uses
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
});
expect(screen.queryByText(/ 4 /u)).toBeNull();
const actionButton = screen.getByRole('button', {
name: //u,
name: '发布',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});

View File

@@ -22,6 +22,7 @@ type UseRpgCreationAgentOperationPollingParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
@@ -68,7 +69,15 @@ export function useRpgCreationAgentOperationPolling(
nextOperation.status === 'completed' ||
nextOperation.status === 'failed'
) {
persistAgentUiState(activeAgentSessionId, null);
persistAgentUiState(
activeAgentSessionId,
nextOperation.type === 'draft_foundation'
? activeAgentOperationId
: null,
nextOperation.type === 'draft_foundation'
? 'agent-draft-foundation'
: null,
);
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
);

View File

@@ -50,6 +50,7 @@ type UseRpgCreationResultAutosaveParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,

View File

@@ -51,6 +51,9 @@ type PendingAgentUserMessage = {
message: CustomWorldAgentSessionSnapshot['messages'][number];
};
const AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS = 12;
const AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS = 900;
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
@@ -162,12 +165,17 @@ export function useRpgCreationSessionController(
);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
(
nextSessionId: string | null,
nextOperationId: string | null,
nextGenerationSource: CustomWorldGenerationViewSource = null,
) => {
setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({
activeSessionId: nextSessionId,
activeOperationId: nextOperationId,
customWorldGenerationSource: nextGenerationSource,
// 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户,
// 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。
ownerUserId: nextSessionId ? userId : null,
@@ -211,6 +219,16 @@ export function useRpgCreationSessionController(
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => {
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
});
}
@@ -228,6 +246,17 @@ export function useRpgCreationSessionController(
}
hasAppliedInitialAgentWorkspaceRef.current = true;
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
@@ -365,8 +394,23 @@ export function useRpgCreationSessionController(
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void (async () => {
for (
let attempt = 1;
attempt <= AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS;
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
@@ -382,10 +426,7 @@ export function useRpgCreationSessionController(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
continue;
}
setGeneratedCustomWorldProfile(
@@ -395,12 +436,16 @@ export function useRpgCreationSessionController(
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
return;
}
if (!cancelled) {
setAgentDraftGenerationStartedAt(null);
}
})();
}, 900);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
activeAgentSessionId,
@@ -678,7 +723,11 @@ export function useRpgCreationSessionController(
payload,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
persistAgentUiState(
activeAgentSessionId,
operation.operationId,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
@@ -694,7 +743,11 @@ export function useRpgCreationSessionController(
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
persistAgentUiState(
activeAgentSessionId,
null,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
}
},
[activeAgentSessionId, persistAgentUiState, setSelectionStage],

View File

@@ -67,6 +67,7 @@ type UseRpgEntryLibraryDetailParams = {
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
@@ -244,7 +245,30 @@ export function useRpgEntryLibraryDetail(
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
try {
if (shouldOpenAgentWorkspace) {
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace && !nextProfile) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
@@ -256,13 +280,16 @@ export function useRpgEntryLibraryDetail(
}
releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) {
persistAgentUiState(work.sessionId, null);
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { normalizeCustomWorldProfileRecord } from './customWorldLibrary';
describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
it('保留草稿生成阶段产出的角色形象描述字段', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
description: '追查旧案的人',
visualDescription: '瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
actionDescription: '抬灯照出雾中航线,侧身抽出卷边海图迅速标记。',
sceneVisualDescription: '旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
},
],
storyNpcs: [
{
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
description: '压住旧档的人',
visualDescription: '银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
actionDescription: '用印信压住卷宗,抬手示意巡海队封锁出口。',
sceneVisualDescription: '议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
},
],
});
expect(profile?.playableNpcs[0]?.visualDescription).toBe(
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
);
expect(profile?.playableNpcs[0]?.actionDescription).toContain('抬灯');
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain('旧灯塔');
expect(profile?.storyNpcs[0]?.visualDescription).toBe(
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
);
expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信');
expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅');
});
it('保留 Agent 发布门槛需要的顶层 worldHook 和 playerPremise', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
summary: '海雾会吞掉记错航线的人。',
worldHook: '在失真的海图上追查一场被篡改的沉船事故。',
playerPremise: '玩家是返乡调查旧案的守灯人。',
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '失灯港',
acts: [
{
id: 'act-1',
title: '第一幕',
summary: '玩家在雾港发现灯册被改写。',
},
],
},
],
});
expect(profile?.worldHook).toBe(
'在失真的海图上追查一场被篡改的沉船事故。',
);
expect(profile?.playerPremise).toBe('玩家是返乡调查旧案的守灯人。');
expect(profile?.sceneChapterBlueprints?.[0]?.acts).toHaveLength(1);
});
});

View File

@@ -683,6 +683,9 @@ function normalizePlayableNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -755,6 +758,9 @@ function normalizeStoryNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -1044,6 +1050,17 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const creatorIntentRecord = isRecord(value.creatorIntent)
? value.creatorIntent
: null;
const worldHook = toText(
value.worldHook,
toText(creatorIntentRecord?.worldHook, toText(value.summary, settingText || name)),
);
const playerPremise = toText(
value.playerPremise,
toText(creatorIntentRecord?.playerPremise, playerGoal),
);
const majorFactions = toStringArray(value.majorFactions);
const coreConflicts = toStringArray(value.coreConflicts);
const resolvedCoreConflicts =
@@ -1087,6 +1104,8 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
summary,
tone,
playerGoal,
worldHook,
playerPremise,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { buildCustomWorldRoleOutlineBatchPrompt } from './customWorldPrompts';
const framework = {
settingText: '潮雾封锁的边境港城,旧灯塔下藏着失踪船队的线索。',
name: '潮雾港',
subtitle: '旧灯塔仍在雾里亮着',
summary: '玩家需要在港城各方势力间找到失踪船队真相。',
tone: '潮湿、悬疑、克制',
playerGoal: '找回失踪船队并决定港城秩序的走向。',
templateWorldType: 'custom',
compatibilityTemplateWorldType: 'custom',
majorFactions: ['守灯人', '走私船帮'],
coreConflicts: ['旧航道真相', '港城权力交接'],
camp: {
name: '旧灯塔营地',
description: '潮雾里的临时归处。',
dangerLevel: 'medium',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
describe('buildCustomWorldRoleOutlineBatchPrompt', () => {
it('requires model-generated visual descriptions for role drafts', () => {
const prompt = buildCustomWorldRoleOutlineBatchPrompt({
framework,
roleType: 'playable',
batchCount: 2,
});
expect(prompt).toContain('"visualDescription"');
expect(prompt).toContain('"actionDescription"');
expect(prompt).toContain('"sceneVisualDescription"');
expect(prompt).toContain('visualDescription 必须跟随本步骤直接生成');
expect(prompt).toContain('不能复制 description');
});
});

View File

@@ -494,6 +494,9 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
' "title": "称号",',
' "role": "身份",',
' "description": "极简定位描述",',
' "visualDescription": "专门用于主形象生成的外观描述",',
' "actionDescription": "专门用于动作生成的动作气质描述",',
' "sceneVisualDescription": "角色常出现的场景氛围描述",',
' "initialAffinity": 18,',
' "relationshipHooks": ["一个关系切入口"],',
' "tags": ["标签1", "标签2"]',
@@ -505,9 +508,12 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'- description 控制在 8 到 18 个汉字内,只写角色定位,不写外观。',
'- visualDescription 必须跟随本步骤直接生成,专门描述角色外观,包含轮廓、服饰 / 身体特征、携带物或材质气质,不能复制 description。',
'- actionDescription 专门描述动作气质sceneVisualDescription 专门描述角色常出现的场景氛围。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
'- description 控制在 8 到 18 个汉字内,title 和 role 尽量短。',
'- title 和 role 尽量短。',
'- initialAffinity 必须是 -40 到 90 的整数。',
roleType === 'playable'
? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。'
@@ -536,8 +542,9 @@ export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: {
forbiddenNames.length > 0
? `禁止使用这些重复名:${forbiddenNames.join('、')}`
: '',
'每个角色只包含name、title、role、description、initialAffinity、relationshipHooks、tags。',
'每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。',
'visualDescription 必须是独立外观描述,不能用 description 原文替代。',
'不要输出 backstory、skills、landmarks 或任何其他字段。',
'原始文本:',
responseText.trim(),

View File

@@ -28,7 +28,6 @@ import {
buildCustomWorldRoleBatchPrompt,
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
buildCustomWorldRoleOutlineBatchPrompt,
buildCustomWorldSceneImagePrompt,
buildCustomWorldStoryGraphJsonRepairPrompt,
buildCustomWorldStoryGraphPrompt,
buildCustomWorldThemePackJsonRepairPrompt,
@@ -1951,11 +1950,7 @@ export async function generateCustomWorldSceneImage({
size = '1280*720',
referenceImageSrc,
}: CustomWorldSceneImageRequest): Promise<CustomWorldSceneImageResult> {
const resolvedPrompt =
prompt?.trim() ||
buildCustomWorldSceneImagePrompt(profile, landmark, userPrompt, {
hasReferenceImage: Boolean(referenceImageSrc?.trim()),
});
const resolvedPrompt = prompt?.trim() || userPrompt?.trim() || '';
const resolvedNegativePrompt =
negativePrompt?.trim() || DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT;
const controller = new AbortController();
@@ -1975,9 +1970,25 @@ export async function generateCustomWorldSceneImage({
worldName: profile.name,
landmarkId: landmark.id,
landmarkName: landmark.name,
prompt: resolvedPrompt,
...(prompt?.trim() ? { prompt: prompt.trim() } : {}),
userPrompt: resolvedPrompt,
negativePrompt: resolvedNegativePrompt,
size,
profile: {
id: profile.id,
name: profile.name,
subtitle: profile.subtitle,
summary: profile.summary,
tone: profile.tone,
playerGoal: profile.playerGoal,
settingText: profile.settingText,
},
landmark: {
id: landmark.id,
name: landmark.name,
description: landmark.description,
dangerLevel: landmark.dangerLevel,
},
...(referenceImageSrc?.trim()
? { referenceImageSrc: referenceImageSrc.trim() }
: {}),

View File

@@ -45,6 +45,7 @@ test('custom world agent ui state reads from query first and persists to session
{
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1',
},
env,
@@ -52,15 +53,20 @@ test('custom world agent ui state reads from query first and persists to session
expect(currentUrl).toContain('customWorldSessionId=session-1');
expect(currentUrl).toContain('customWorldOperationId=operation-1');
expect(currentUrl).toContain(
'customWorldGenerationSource=agent-draft-foundation',
);
expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
});
currentUrl = '/play';
expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
customWorldGenerationSource: 'agent-draft-foundation',
ownerUserId: 'user-1',
});

View File

@@ -2,6 +2,8 @@ import type { CustomWorldAgentUiState } from '../types';
export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
export const CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY =
'customWorldGenerationSource';
export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
'genarrative.custom-world-agent-ui.v1';
@@ -50,6 +52,10 @@ function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function normalizeGenerationSource(value: unknown) {
return value === 'agent-draft-foundation' ? value : null;
}
export function readCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
): CustomWorldAgentUiState {
@@ -62,9 +68,16 @@ export function readCustomWorldAgentUiState(
activeOperationId: normalizeValue(
params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
),
customWorldGenerationSource: normalizeGenerationSource(
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
),
};
if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) {
if (
stateFromQuery.activeSessionId ||
stateFromQuery.activeOperationId ||
stateFromQuery.customWorldGenerationSource
) {
return stateFromQuery;
}
@@ -80,6 +93,9 @@ export function readCustomWorldAgentUiState(
return {
activeSessionId: normalizeValue(parsed.activeSessionId),
activeOperationId: normalizeValue(parsed.activeOperationId),
customWorldGenerationSource: normalizeGenerationSource(
parsed.customWorldGenerationSource,
),
ownerUserId: normalizeValue(parsed.ownerUserId),
};
} catch {
@@ -95,10 +111,14 @@ export function writeCustomWorldAgentUiState(
const resolved = resolveEnvironment(env);
const activeSessionId = normalizeValue(state.activeSessionId);
const activeOperationId = normalizeValue(state.activeOperationId);
const customWorldGenerationSource = normalizeGenerationSource(
state.customWorldGenerationSource,
);
const ownerUserId = normalizeValue(state.ownerUserId);
const nextState: CustomWorldAgentUiState = {
activeSessionId,
activeOperationId,
customWorldGenerationSource,
ownerUserId,
};
@@ -116,6 +136,15 @@ export function writeCustomWorldAgentUiState(
params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
}
if (customWorldGenerationSource) {
params.set(
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
customWorldGenerationSource,
);
} else {
params.delete(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY);
}
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
@@ -124,7 +153,7 @@ export function writeCustomWorldAgentUiState(
}
if (resolved.sessionStorage) {
if (activeSessionId || activeOperationId) {
if (activeSessionId || activeOperationId || customWorldGenerationSource) {
resolved.sessionStorage.setItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
JSON.stringify(nextState),

View File

@@ -29,6 +29,7 @@ export type CustomWorldCoverSourceType = 'default' | 'uploaded' | 'generated';
export type CustomWorldAgentUiState = {
activeSessionId?: string | null;
activeOperationId?: string | null;
customWorldGenerationSource?: 'agent-draft-foundation' | null;
ownerUserId?: string | null;
};
@@ -397,6 +398,16 @@ export interface CustomWorldProfile {
summary: string;
tone: string;
playerGoal: string;
/**
* 发布门槛直接读取的世界一句话钩子。
* Agent 结果页回写 session 时需要保留该字段,避免只剩 UI 归一化字段导致后端误判缺失。
*/
worldHook?: string | null;
/**
* 发布门槛直接读取的玩家身份与切入前提。
* 即使 creatorIntent / anchorContent 中已有结构化信息,也要保留顶层字段作为 SpacetimeDB 发布快照的稳定兼容槽位。
*/
playerPremise?: string | null;
cover?: CustomWorldCoverProfile | null;
templateWorldType: WorldTemplateType;
compatibilityTemplateWorldType?: WorldTemplateType | null;