chore: checkpoint local workspace changes

This commit is contained in:
2026-04-23 12:45:15 +08:00
parent 3eb9390e8f
commit a6cd9afcbb
47 changed files with 2154 additions and 529 deletions

View File

@@ -3,9 +3,4 @@ version = 1
name = "Genarrative"
[setup]
script = '''
npm install
cd ./node-server
npm install
cd ..
'''
script = ""

View File

@@ -1606,8 +1606,6 @@ type ResolvedAgentIntent =
1. 不再承担整世界重生成主入口
2. 不再承担核心 Agent 对话主流程
3. 仅保留“查看 / 导出 / 发布确认 / 进入世界”
4. 结果页底部不常驻展示“数据源”提示
5. 发布阻断项只在创作者点击发布动作时,通过独立确认面板提示,不在结果页吸底常驻展开
## 9.12 角色资产工坊

View File

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

View File

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

View File

@@ -1217,9 +1217,8 @@ Phase 4 本轮已完成以下主链接线:
- published works 明确输出 `canEnterWorld=true`
4. 前端 Agent 结果页已开始消费服务端 Phase4 状态:
- 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界”
- 结果页会消费服务端 gate 语义,但不再把 preview source 做成底部常驻提示
- publish blockers 改为点击“发布并进入世界”时,通过独立面板提示
- warning 数量仍可作为非阻断摘要展示
- 结果页会展示服务端 preview source、publish blockers、warning 数量
- blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界
5. `useRpgCreationEnterWorld.ts``RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成:
-`sync_result_profile`
- 再执行后端 `publish_world`

View File

@@ -89,13 +89,13 @@ scripts/jenkins-deploy-release.sh \
脚本语义:
1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`
2. 直接清空部署目录中的全部旧文件
3. 将指定版本目录中的内容移动到部署目录。
2. 只删除发布产物白名单中的旧文件,例如 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md`
3. 将指定版本目录中的同名发布产物移动到部署目录。
4. 执行新版本 `start.sh`
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足你要求的“直接覆盖部署目录中的所有文件”。同时这也意味着部署目录内原有的 `.env``.env.local`、日志和本地 SpacetimeDB 数据都会被清掉,最终以构建产物中的文件为准。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 仍会以构建产物中的文件为准。
### 4.3 构建并部署

View File

@@ -0,0 +1,73 @@
# 手机验证码阿里云响应字段映射修复
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结验证清单第一项在 Rust 本地联调中暴露出的一个实现级问题:
1. 前端点击“获取验证码”后,请求确实进入了 Rust `api-server`
2. `platform-auth` 也确实发起了阿里云短信 RPC 请求。
3. 但 Rust 侧把阿里云响应误判成失败,导致界面显示“短信验证码发送失败”。
## 2. 根因
根因不是前端请求没发出,也不是 `module-auth` 仍在走 mock而是 `platform-auth` 对阿里云原始 JSON 的字段名映射不正确。
阿里云 `SendSmsVerifyCode / CheckSmsVerifyCode` 原始响应字段使用首字母大写命名,例如:
1. `Code`
2. `Message`
3. `RequestId`
4. `Success`
5. `Model`
而 Rust 侧此前按小写字段解析:
1. `code`
2. `message`
3. `request_id`
4. `success`
5. `model`
这会导致:
1. 即使阿里云真实返回 `Code=OK``Success=true`
2. Rust 反序列化后仍得到 `None`
3. 后续逻辑把这次发送误判成“短信验证码发送失败”
## 3. 本次修复
本次修复只做最小映射纠偏,不改动现有 DTO contract 和业务边界:
1.`AliyunSendSmsVerifyCodeResponse` 显式补充 `Code / Message / RequestId / Success / Model``serde rename`
2.`AliyunCheckSmsVerifyCodeResponse` 显式补充 `Code / Message / Success / Model``serde rename`
3. 补充单元测试,确保首字母大写的阿里云 JSON 可以被 Rust 正确解析
## 4. 影响范围
本次只影响:
1. `server-rs/crates/platform-auth`
本次不影响:
1. 前端手机号登录交互 contract
2. `module-auth` 的冷却、过期与失败次数逻辑
3. `api-server` 对外错误映射结构
## 5. 验收标准
修复后再次点击“获取验证码”时,应满足:
1. 如果阿里云真实返回成功Rust 日志输出“手机号验证码发送请求已提交”
2. 日志中出现:
- `provider=aliyun`
- `provider_request_id`
- `provider_out_id`
3. 前端不再因为字段解析错误而直接提示“短信验证码发送失败”
## 6. 关联文档
1. [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
2. [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md)

View File

@@ -0,0 +1,185 @@
# 手机验证码真实 Provider 手动验证运行手册
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结 `验证清单.md` 第一项“短信验证功能真实走到阿里云服务”的本地验证口径,避免继续出现以下问题:
1. 本地服务虽然返回 `200`,但没有证据证明请求真的打到了阿里云短信接口。
2. 前端手动输入手机号后,后端日志缺少可核验字段,导致无法判断是前端没发出请求、后端没收到请求,还是上游阿里云返回了错误。
3. 本地验证动作与后续回归标准没有文档化,下一轮继续验证时还要重复人工猜步骤。
## 2. 本次验证范围
本次只验证“发送验证码”和“验证码登录”主链是否真实经过 Rust `api-server` 和阿里云短信 Provider。
本次验证固定范围:
1. 前端入口使用本仓库 Web 端登录弹窗。
2. 后端入口使用 `server-rs/crates/api-server`
3. 手机验证码 provider 使用 `SMS_AUTH_PROVIDER=aliyun`
4. 当前只验证中国大陆手机号。
本次明确不验证:
1. 阿里云短信送达回执是否已完整回流仓库。
2. 运营商最终投递状态。
3. 微信绑定手机号链路。
4.`server-node` 短信链路。
## 3. 前置条件
开始验证前,必须同时满足以下条件:
1. 仓库根目录 `.env.local` 中已启用:
- `SMS_AUTH_ENABLED="true"`
- `SMS_AUTH_PROVIDER="aliyun"`
2. `.env.local` 中阿里云短信配置不为空:
- `ALIYUN_SMS_ACCESS_KEY_ID`
- `ALIYUN_SMS_ACCESS_KEY_SECRET`
- `ALIYUN_SMS_SIGN_NAME`
- `ALIYUN_SMS_TEMPLATE_CODE`
3. 本机已安装:
- `cargo`
- `node`
- `spacetime`
4. 本地端口可用或已有可复用开发栈:
- Web`3000`
- Rust API`8082`
- SpacetimeDB`3101`
说明:
1. Rust `api-server` 启动时会自动读取仓库根目录 `.env``.env.local`
2. 若本机已有 `3000 / 8082 / 3101` 运行中的 Genarrative Rust 栈,可以直接复用,但必须确认它已经加载当前分支代码。
## 4. 启动方式
推荐统一使用:
```powershell
npm run dev:rust
```
该命令会完成以下动作:
1. 启动本地 `SpacetimeDB standalone`
2. 发布 `server-rs/crates/spacetime-module`
3. 启动 Rust `api-server`
4. 启动 Vite Web 开发服务器。
如果已有栈在运行,至少要确认以下健康状态:
1. Web 可访问:`http://127.0.0.1:3000`
2. Rust API 正在监听:`http://127.0.0.1:8082`
3. `api-server` 控制台可实时看到日志输出
## 5. 手动验证步骤
### 5.1 发送验证码
1. 打开 `http://127.0.0.1:3000`
2. 进入任一受保护动作,拉起手机号验证码登录弹窗。
3. 输入中国大陆手机号。
4. 点击“获取验证码”。
5. 观察前端是否提示发送请求已提交。
6. 同时观察 Rust `api-server` 控制台日志。
发送成功时,日志必须至少包含以下字段:
1. `request_id`
2. `scene`
3. `phone_masked`
4. `provider`
5. `provider_request_id`
6. `provider_out_id`
7. `cooldown_seconds`
8. `expires_in_seconds`
期望值约束:
1. `provider` 必须为 `aliyun`
2. `provider_request_id` 不能为空或 `unknown`
3. `provider_out_id` 不能为空或 `unknown`
### 5.2 验证码登录
1. 如果手机已收到验证码,在同一弹窗输入验证码。
2. 点击登录。
3. 观察前端是否进入已登录态。
4. 同时观察 Rust `api-server` 控制台日志。
登录成功时,日志必须至少包含以下字段:
1. `request_id`
2. `scene=login`
3. `phone_masked`
4. `provider`
5. `provider_out_id`
6. `user_id`
7. `created`
期望值约束:
1. `provider` 必须为 `aliyun`
2. 日志必须明确输出“手机号验证码登录成功”
## 6. 通过标准
本次验证满足以下条件时,视为通过:
1. 前端点击“获取验证码”后Rust `api-server` 日志明确输出“手机号验证码发送请求已提交”。
2. 发送成功日志里的 `provider=aliyun`
3. 发送成功日志里的 `provider_request_id``provider_out_id` 都不是空值。
4. 手机实际收到验证码后,输入验证码可以成功登录。
5. 登录成功日志明确输出“手机号验证码登录成功”。
## 7. 失败判定与排查
### 7.1 发送阶段失败
优先看 Rust `api-server` 日志中的错误文本:
1. `手机号格式不正确`
- 说明前端输入未满足中国大陆手机号格式要求。
2. `验证码发送过于频繁,请稍后再试`
- 说明命中了本地冷却限制,不代表阿里云不可用。
3. `阿里云短信 AccessKey 未配置`
- 说明 `.env.local` 配置缺失。
4. `短信验证码发送失败`
- 说明请求已经进入 provider 调用,但阿里云返回了上游错误或网络错误。
### 7.2 登录阶段失败
1. `验证码错误`
- 说明验证码校验已真实进入 provider 校验链,但输入的验证码不正确。
2. `验证码不存在或已失效`
- 说明本地验证码快照已过期或已被消费。
3. `验证码错误次数过多,请重新获取验证码`
- 说明当前快照已耗尽,需要重新发送。
## 8. 结果解释边界
即使本次验证通过,也只代表:
1. 前端请求已经进入 Rust `api-server`
2. Rust `api-server` 已按 `aliyun` provider 口径调用阿里云接口
3. 阿里云返回了可追踪的 `provider_request_id / provider_out_id`
它不自动代表:
1. 运营商已经完成最终投递
2. 用户手机一定已经收到短信
关于“平台受理成功”和“最终送达成功”的差异,后续统一按:
`docs/technical/PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md`
继续补齐回执与状态追踪闭环。
## 9. 关联文档
1. [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
2. [PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md](./PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md)
3. [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md)

View File

@@ -0,0 +1,113 @@
# 手机验证码发送链路可观测性补强
日期:`2026-04-23`
## 1. 文档目的
这份文档用于冻结 Rust `api-server + module-auth + platform-auth` 在手机号验证码发送链路上的新增日志口径,解决当前排查成本过高的问题:
1. 前端点击“获取验证码”后,只能看到 `/api/auth/phone/send-code` 返回了 `500`
2. 服务端虽然已有部分失败日志,但关键上下文不完整,仍需要人工反推是哪一层失败。
3. 阿里云真实返回 `UNKNOWN``check frequency failed` 这类错误时,缺少足够字段判断是本地冷却、上游频控、配置异常还是响应解析问题。
## 2. 本次补强范围
本次只补强短信发送链路日志,不改前端 UI不扩散到无关模块
1. `server-rs/crates/api-server`
2. `server-rs/crates/module-auth`
3. `server-rs/crates/platform-auth`
## 3. 日志补强目标
新增日志后,排查一次 `send-code` 失败时,单看日志应能回答:
1. 请求是否真的进入 `api-server`
2. 当前使用的是 `mock` 还是 `aliyun`
3. 手机号和场景是否已经通过服务端规范化
4. 失败发生在本地冷却检查前、阿里云请求发出前、阿里云返回后,还是快照写入阶段
5. 阿里云返回的 HTTP 状态、`Code``Message``RequestId` 是否可见
## 4. 新增日志位置
### 4.1 `api-server`
`POST /api/auth/phone/send-code` handler 内新增发送前日志,至少输出:
1. `request_id`
2. `operation`
3. `scene`
4. `provider`
5. `phone_input_masked`
失败日志继续保留,但补充:
1. `provider`
2. `phone_input_masked`
### 4.2 `module-auth`
`PhoneAuthService::send_code` 内新增:
1. 规范化手机号后、调用 provider 前的调试日志
2. 本地冷却命中时的日志
3. provider 成功后、写入本地验证码快照前的日志
字段至少包含:
1. `scene`
2. `phone_e164_masked`
3. `phone_national_masked`
4. `provider`
5. `cooldown_seconds`
6. `expires_in_seconds`
7. `provider_request_id`
8. `provider_out_id`
### 4.3 `platform-auth`
在阿里云 provider 内新增:
1. 发起 `SendSmsVerifyCode` 前的日志
2. 收到阿里云 HTTP 响应后的日志
3. 错误分类前的日志
字段至少包含:
1. `provider=aliyun`
2. `scene`
3. `phone_masked`
4. `endpoint`
5. `http_status`
6. `provider_code`
7. `provider_message`
8. `provider_request_id`
9. `provider_out_id`
## 5. 约束
为了避免日志变成新的风险源,本次必须满足:
1. 不打印完整手机号
2. 不打印验证码明文
3. 不打印 `AccessKeySecret`
4. 不打印完整签名串
5. 只打印排查发送链路需要的最小字段
## 6. 验收标准
补强后再次请求 `/api/auth/phone/send-code`,无论成功还是失败,都应满足:
1. `api-server` 能看到请求进入日志
2. `module-auth` 能看到 provider 调用前的规范化结果
3. `platform-auth` 能看到阿里云返回的状态和关键字段
4. 如果失败,日志里可以直接区分:
- 本地冷却
- 阿里云配置缺失
- 阿里云上游失败
- 阿里云频控或业务拒绝
## 7. 关联文档
1. [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md)
2. [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md)

View File

@@ -5,6 +5,9 @@
## 文档列表
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
- [RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md](./RUST_LOCAL_DEV_SPACETIMEDB_PUBLISH_GUARD_AND_AGENT_LLM_FAILURE_POLICY_2026-04-23.md):冻结 Rust 本地联调启动前必须 publish/generate 最新 `spacetime-module` 的守卫,以及 Custom World Agent 在 LLM 失败时禁止写固定 assistant 回复的 finalize 与 HTTP/SSE 错误策略。
- [CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md](./CREATION_AGENT_CHAT_SCROLL_FOLLOW_POLICY_FIX_2026-04-23.md):记录统一创作聊天工作区从“每次更新都强制滚到底”改为“仅在用户仍停留在底部附近时跟随”的滚动策略修复,避免流式回复持续抢走阅读位置。
- [CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md](./CREATION_AGENT_STREAMING_MESSAGE_STABILITY_FIX_2026-04-23.md):记录创作 Agent 聊天流式文本、玩家乐观消息、最终 session 回写和草稿切换的展示稳定性修复,避免乱码、闪消、插队和旧草稿闪烁。
- [CREATION_HUB_CARD_ACTIONS_2026-04-22.md](./CREATION_HUB_CARD_ACTIONS_2026-04-22.md):冻结创作中心作品卡“体验 / 删除”入口的最小落地语义,明确 RPG 已发布作品软删除、卡片直达运行时,以及暂不扩草稿 / 拼图删除契约。
- [CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md](./CREATION_CATEGORY_OPENING_TIMEOUT_GUARD_FIX_2026-04-22.md):记录创作中心点击类别后长时间停留在“正在开启”的根因与修复口径,收口前端创建会话启动超时、中文错误提示以及 Big Fish / 拼图代理上游超时兜底。
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本与 `/home/ubuntu/Genarrative-deploy/` 覆盖策略。
@@ -22,6 +25,7 @@
- [PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md](./PHONE_AUTH_AXUM_RATE_LIMIT_AND_FAILURE_DESIGN_2026-04-21.md):手机号验证码冷却与失败次数限制设计,冻结同手机号同场景发送冷却、错误次数耗尽、`429``Retry-After` contract。
- [PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md](./PHONE_AUTH_AXUM_REAL_SMS_PROVIDER_DESIGN_2026-04-22.md):冻结 Rust `api-server + module-auth + platform-auth` 接入真实阿里云短信 provider 的 crate 边界、发送与校验职责、配置项和错误语义。
- [PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md](./PHONE_SMS_ALIYUN_RESPONSE_FIELD_MAPPING_FIX_2026-04-23.md):记录 Rust `platform-auth` 把阿里云 PascalCase 响应字段误判成空值的问题根因,并冻结字段映射修复与回归标准。
- [PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md](./PHONE_SMS_SEND_CODE_OBSERVABILITY_FIX_2026-04-23.md):冻结手机号验证码发送链路的日志补强口径,确保 `api-server``module-auth``platform-auth` 能直接暴露发送前后与错误分类关键字段。
- [PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md](./PHONE_SMS_DELIVERY_OBSERVABILITY_AND_RECEIPT_DESIGN_2026-04-22.md):冻结短信平台受理成功与最终送达状态的区分方式、追踪字段、送达回执接口和前端提示文案边界。
- [PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md](./PHONE_SMS_REAL_PROVIDER_MANUAL_VERIFICATION_RUNBOOK_2026-04-23.md):冻结验证清单第一项“真实短信验证码链路”的本地启动、前端操作、日志观察点、通过标准与失败排查步骤。
- [WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md](./WECHAT_LOGIN_AXUM_IMPLEMENTATION_DESIGN_2026-04-21.md)Rust `api-server` 微信登录实现设计,冻结微信 provider 接入、系统 JWT 签发边界、`wechat/start` / `wechat/callback` / `wechat/bind-phone` 闭环,以及与后续 `SpacetimeDB` claims 透传的关系。

View File

@@ -104,7 +104,7 @@ npm run deploy:rust:remote
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
6. 把仓库根目录的 `.env``.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下。
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先加载发布目录根部的 `.env``.env.local`,再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值。
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先加载发布目录根部的 `.env``.env.local`,再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
发布包结构:
@@ -140,6 +140,8 @@ cd build/<timestamp>
./stop.sh
```
如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md` 等发布产物,不会删除部署目录中的 `spacetimedb-data/``logs/``run/` 这类运行态目录。
安全边界:
1. 构建脚本会把仓库根目录已有的 `.env``.env.local` 一并复制进发布包,因此运行前必须确认这些文件内容适合被带入目标环境。

View File

@@ -0,0 +1,150 @@
# Rust 本地联调 Spacetime 发布守卫与 Agent LLM 失败策略
日期:`2026-04-23`
## 1. 背景
当前第 2 项验证要求:
1. 创作中心里的 RPG 入口必须走真实后端链路。
2. 聊天引导必须真实接入 LLM。
3. 不允许用固定模板伪造 assistant 回复。
本地联调时暴露出两个直接阻塞点:
1. 本地 `spacetime-module` schema 未及时 publishRust `api-server` 在 finalize Agent 回合时调用不到最新 procedure。
2. Custom World Agent 在 LLM 不可用、上游失败或输出 JSON 非法时,仍会写入固定中文 assistant 文案,和“不得使用固定模板来回答”的验收要求冲突。
## 2. 当前本地真实目标库
以仓库当前配置为准:
1. `spacetime.local.json`
- `database = xushi-p4wfr`
2. `spacetime.json`
- `server = http://127.0.0.1:3101`
本地 `npm run dev``npm run dev:rust`、手工 publish / generate 都必须以这一组配置为真实来源,除非开发者显式通过环境变量覆盖。
## 3. 本地开发守卫
### 3.1 启动前强制 publish
`scripts/dev-node.mjs` 在拉起 Rust `api-server` 前,必须先执行:
```bash
spacetime publish xushi-p4wfr --server http://127.0.0.1:3101 --module-path D:\Genarrative\server-rs\crates\spacetime-module --yes
```
要求:
1. `spacetime` CLI 不存在时直接 fail-fast。
2. publish 失败时整个本地 dev 启动终止,不允许继续拉起前后端。
3. 不允许继续依赖“开发者记得手工 publish”这种脆弱约定。
4. 默认不清空本地库;只有显式设置 `GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT=1` 时,才允许追加 `--delete-data=on-conflict` 处理开发库 schema 冲突。
### 3.2 启动前强制 generate Rust bindings
publish 成功后,继续执行:
```bash
spacetime generate --no-config --lang rust --out-dir D:\Genarrative\server-rs\crates\spacetime-client\src\module_bindings --module-path D:\Genarrative\server-rs\crates\spacetime-module --include-private --yes
```
要求:
1. bindings 生成失败时整个本地 dev 启动终止。
2. 不手改 `server-rs/crates/spacetime-client/src/module_bindings` 生成物。
3. Rust contract 变化后,以重新 generate 的 bindings 作为唯一真相。
### 3.3 已知本地库迁移门禁
当前 `xushi-p4wfr` 本地库已存在旧版 `custom_world_profile` 表,缺少软删除字段 `deleted_at`。直接 publish 最新 `spacetime-module`SpacetimeDB 会拒绝自动迁移并提示:
1. `Reordering table custom_world_profile requires a manual migration`
2. `Adding a column deleted_at to table custom_world_profile requires a default value annotation`
处理方式:
1. 优先执行正式迁移,补齐 `deleted_at`
2. 若确认只是本地开发库且可丢弃数据,可临时设置 `GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT=1` 后重新启动。
3. 禁止在未确认的情况下默认清库。
## 4. Agent LLM 失败策略
### 4.1 明确禁止的旧行为
以下场景不允许再写固定 assistant 回复:
1. `llm_client` 缺失。
2. `platform-llm.stream_text(...)` 调用失败。
3. 大模型输出不是合法 JSON。
4. 输出缺失 `replyText``replyText` 为空白。
这类失败只能被视为:
1. 本轮 `process_message` operation 失败。
2. 当前回合未产出正式 assistant message。
### 4.2 finalize contract
`finalize_custom_world_agent_message_turn` 需要允许失败态:
1. `operation_status = failed`
2. `assistant_message_id = None`
3. `assistant_reply_text = None`
同时保持成功态约束不变:
1. `completed` 必须写 assistant message。
2. `completed` 必须推进 `current_turn``last_assistant_reply`
### 4.3 SpacetimeDB 真相更新规则
`spacetime-module` 的 finalize 事务必须分支处理:
1. 成功态:
- 插入 assistant message
- `current_turn += 1`
- 更新 `last_assistant_reply`
- 更新 session 聚合 JSON
- operation 标记为 `completed`
2. 失败态:
- 不插入 assistant message
- 不推进 `current_turn`
- 不更新 `last_assistant_reply`
- 不覆盖 session 聚合 JSON
- 仅更新 session `updated_at`
- operation 标记为 `failed`
## 5. HTTP / SSE 口径
### 5.1 `/messages`
普通提交接口在 finalize 后:
1. 若 operation 为 `completed`,返回成功响应。
2. 若 operation 为 `failed`,返回正式 HTTP 错误,不返回成功 envelope。
### 5.2 `/messages/stream`
流式接口在 finalize 后:
1. 若 operation 为 `completed`,继续发送 `session``done`
2. 若 operation 为 `failed`,只发送 `error`,不得再发送 `session` / `done`
注意:
1. 失败前已经产生的真实 `reply_delta` 可以保留。
2. 失败时不得额外补发固定中文 assistant 兜底文案。
## 6. 验收标准
满足以下条件才算第 2 项通过:
1. 本地 dev 启动前会自动 publish 最新 `spacetime-module`
2. 本地 dev 启动前会自动 generate 最新 Rust bindings。
3. `finalize_custom_world_agent_message_turn` 已存在于本地目标库 schema。
4. 流式 RPG 创作聊天不再报 `No such procedure`
5. LLM 成功时,前端看到的回复来自真实大模型。
6. LLM 失败时,只看到正式错误,不再看到固定 assistant 回复。

View File

@@ -50,10 +50,9 @@ src/services/creation-agent/
聊天页展示规则:
1. Agent 聊天页不展示锚点内容卡片,锚点只作为进度与后端生成依据。
2. 标题区文案支持按品类留空;当 `title``assistantSummary` 都为空时,顶部模块只保留返回、进度和操作按钮,不显示额外标题与副文案
3. 生成草稿 / 生成结果页主按钮只在 `progressPercent` 归一化后达到 `100%` 时显示
4. 进度条下方承载“总结当前设定”“补全剩余设定”等进度操作按钮
5. “补全剩余设定”必须配置 `minTurn: 2`,对话不足两轮时不显示。
2. 生成草稿 / 生成结果页主按钮只在 `progressPercent` 归一化后达到 `100%` 时显示
3. 进度条下方承载“总结当前设定”“补全剩余设定”等进度操作按钮
4. “补全剩余设定”必须配置 `minTurn: 2`,对话不足两轮时不显示
组件内部只做表现,不读取任何 RPG、Big Fish、Puzzle 专属字段。

View File

@@ -484,6 +484,10 @@ API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
WEB_HOST="${GENARRATIVE_WEB_HOST:-__GENARRATIVE_DEFAULT_WEB_HOST__}"
WEB_PORT="${GENARRATIVE_WEB_PORT:-__GENARRATIVE_DEFAULT_WEB_PORT__}"
# 日志默认落文件,显式关闭 ANSI 颜色码,避免控制字符写入 *.log。
export NO_COLOR="${NO_COLOR:-1}"
export CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-never}"
require_command() {
local command_name="$1"
@@ -651,6 +655,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- 启动时会先加载发布目录根部的 \`.env\` 与 \`.env.local\`,再回退到脚本内默认值。
- 脚本内默认值来自构建时的 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 参数。
- 默认导出 \`NO_COLOR=1\` 与 \`CARGO_TERM_COLOR=never\`,避免 ANSI 颜色控制码写入日志文件;如确有需要可在启动前显式覆盖。
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`

View File

@@ -1,4 +1,4 @@
import {spawn} from 'node:child_process';
import {spawn, spawnSync} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import net from 'node:net';
import path from 'node:path';
@@ -33,6 +33,14 @@ const DEFAULT_RUST_API_PORT = '3100';
const DEFAULT_SPACETIME_SERVER_URL = 'http://127.0.0.1:3001';
const DEFAULT_SPACETIME_DATABASE = 'genarrative-dev';
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
const spacetimeModulePath = path.join(serverRsRoot, 'crates', 'spacetime-module');
const spacetimeRustBindingsOutDir = path.join(
serverRsRoot,
'crates',
'spacetime-client',
'src',
'module_bindings',
);
function parseEnvContents(contents) {
return contents
@@ -195,6 +203,98 @@ function prependEnvPath(envMap, nextEntry) {
envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter);
}
function resolveSpacetimeCommand() {
const command = process.platform === 'win32' ? 'where' : 'which';
const result = spawnSync(command, ['spacetime'], {
cwd: repoRoot,
env: mergedEnv,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.status !== 0) {
return null;
}
const firstLine = `${result.stdout || ''}`
.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean);
return firstLine || 'spacetime';
}
function runRequiredCommand(command, args, label) {
const result = spawnSync(command, args, {
cwd: repoRoot,
env: mergedEnv,
stdio: 'inherit',
});
if (result.status !== 0) {
console.error(`[dev:node] ${label} failed with exit code ${result.status ?? 1}`);
process.exit(result.status ?? 1);
}
}
function ensureSpacetimeSchemaReady() {
const spacetimeCommand = resolveSpacetimeCommand();
if (!spacetimeCommand) {
console.error(
'[dev:node] Missing `spacetime` CLI. Install or expose it in PATH before starting local dev.',
);
process.exit(1);
}
const spacetimeServerUrl = `${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || ''}`.trim();
const spacetimeDatabase = `${mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''}`.trim();
if (!spacetimeServerUrl || !spacetimeDatabase) {
console.error(
'[dev:node] Missing GENARRATIVE_SPACETIME_SERVER_URL or GENARRATIVE_SPACETIME_DATABASE, cannot publish local schema.',
);
process.exit(1);
}
console.log(
`[dev:node] Publishing spacetime-module to ${spacetimeDatabase} (${spacetimeServerUrl}) before Rust api-server starts...`,
);
runRequiredCommand(
spacetimeCommand,
[
'publish',
spacetimeDatabase,
'--server',
spacetimeServerUrl,
'--module-path',
spacetimeModulePath,
'--yes',
...(mergedEnv.GENARRATIVE_SPACETIME_DELETE_DATA_ON_CONFLICT === '1'
? ['--delete-data=on-conflict']
: []),
],
'spacetime publish',
);
console.log('[dev:node] Generating Rust Spacetime bindings before Rust api-server starts...');
runRequiredCommand(
spacetimeCommand,
[
'generate',
'--no-config',
'--lang',
'rust',
'--out-dir',
spacetimeRustBindingsOutDir,
'--module-path',
spacetimeModulePath,
'--include-private',
'--yes',
],
'spacetime generate (rust)',
);
}
const exampleEnv = readEnvFile(envExamplePath);
const localEnv = readEnvFile(envLocalPath);
const spacetimeConfig = readJsonFile(spacetimeConfigPath);
@@ -292,6 +392,8 @@ console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)
console.log(`[dev:node] VITE_DEV_HOST=${mergedEnv.VITE_DEV_HOST}`);
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
ensureSpacetimeSchemaReady();
const children = new Set();
let shuttingDown = false;
let pendingExitCode = 0;

View File

@@ -9,8 +9,8 @@ usage() {
说明:
1. 如果部署目录已有旧版本且存在 stop.sh则先执行旧版本 stop.sh。
2. 直接清空部署目录中的全部旧文件
3. 把指定发布目录中的内容移动到部署目录。
2. 仅删除并替换发布产物文件,保留部署目录中的运行数据目录
3. 把指定发布目录中的内容覆盖到部署目录。
4. 最后执行新版本 start.sh。
参数:
@@ -33,6 +33,17 @@ require_argument() {
SOURCE_DIR=""
DEPLOY_DIR=""
HOOK_WITH_SUDO="0"
DEPLOY_ITEMS=(
".env"
".env.local"
"README.md"
"api-server"
"spacetime_module.wasm"
"start.sh"
"stop.sh"
"web"
"web-server.mjs"
)
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -114,10 +125,20 @@ else
fi
echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}"
find "${DEPLOY_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
for item in "${DEPLOY_ITEMS[@]}"; do
if [[ -e "${DEPLOY_DIR}/${item}" ]]; then
echo "[jenkins-deploy] 删除旧产物: ${DEPLOY_DIR}/${item}"
rm -rf "${DEPLOY_DIR:?}/${item}"
fi
done
echo "[jenkins-deploy] 移动发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
find "${SOURCE_DIR}" -mindepth 1 -maxdepth 1 -exec mv {} "${DEPLOY_DIR}/" \;
for item in "${DEPLOY_ITEMS[@]}"; do
if [[ -e "${SOURCE_DIR}/${item}" ]]; then
echo "[jenkins-deploy] 覆盖产物: ${item}"
mv "${SOURCE_DIR}/${item}" "${DEPLOY_DIR}/"
fi
done
chmod +x "${DEPLOY_DIR}/start.sh"

2
server-rs/Cargo.lock generated
View File

@@ -1520,6 +1520,7 @@ dependencies = [
"shared-kernel",
"time",
"tokio",
"tracing",
"uuid",
]
@@ -1859,6 +1860,7 @@ dependencies = [
"shared-kernel",
"time",
"tokio",
"tracing",
"urlencoding",
"uuid",
]

View File

@@ -42,7 +42,8 @@ use std::convert::Infallible;
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_finalize_record_input, run_custom_world_agent_turn,
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
request_context::RequestContext, state::AppState,
};
@@ -566,21 +567,47 @@ pub async fn submit_custom_world_agent_message(
|_| {},
)
.await;
let finalized_operation = state
.spacetime_client()
.finalize_custom_world_agent_message(build_finalize_record_input(
session_id,
owner_user_id,
operation_id,
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
operation_id.clone(),
format!("assistant-{}", operation.operation_id),
turn_result,
current_utc_micros(),
))
),
Err(error) => build_failed_finalize_record_input(
session_id.clone(),
owner_user_id.clone(),
operation_id.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalized_operation = state
.spacetime_client()
.finalize_custom_world_agent_message(finalize_input)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
if finalized_operation.status == "failed" {
let message = finalized_operation
.error_message
.clone()
.unwrap_or_else(|| "消息处理失败".to_string());
return Err(custom_world_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "custom-world-agent",
"message": message,
"operationId": finalized_operation.operation_id,
})),
));
}
Ok(json_success_body(
Some(&request_context),
json!({
@@ -695,16 +722,27 @@ pub async fn stream_custom_world_agent_message(
));
}
let finalize_result = state
.spacetime_client()
.finalize_custom_world_agent_message(build_finalize_record_input(
let finalize_input = match turn_result {
Ok(turn_result) => build_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
operation_id.clone(),
format!("assistant-{operation_id}"),
turn_result,
current_utc_micros(),
))
),
Err(error) => build_failed_finalize_record_input(
session_id_for_stream.clone(),
owner_user_id_for_stream.clone(),
operation_id.clone(),
&session,
error.to_string(),
current_utc_micros(),
),
};
let finalize_result = state
.spacetime_client()
.finalize_custom_world_agent_message(finalize_input)
.await;
let _finalized_operation = match finalize_result {
Ok(operation) => operation,
@@ -716,6 +754,18 @@ pub async fn stream_custom_world_agent_message(
return;
}
};
if _finalized_operation.status == "failed" {
yield Ok::<Event, Infallible>(custom_world_sse_json_event_or_error(
"error",
json!({
"message": _finalized_operation
.error_message
.clone()
.unwrap_or_else(|| "消息处理失败".to_string())
}),
));
return;
}
let final_session = match state
.spacetime_client()
@@ -1113,6 +1163,14 @@ fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
{
StatusCode::NOT_FOUND
}
SpacetimeClientError::Procedure(message)
if message.contains("当前模型不可用")
|| message.contains("设定生成失败")
|| message.contains("解析失败")
|| message.contains("缺少有效回复") =>
{
StatusCode::BAD_GATEWAY
}
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_GATEWAY,
};

View File

@@ -248,9 +248,29 @@ struct SingleTurnModelOutput {
next_anchor_content: EightAnchorContent,
progress_percent: u32,
reply_text: String,
fallback_error: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct CustomWorldTurnError {
message: String,
}
impl CustomWorldTurnError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl std::fmt::Display for CustomWorldTurnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for CustomWorldTurnError {}
const BASE_SYSTEM_PROMPT: &str = r#"你是一个负责共创游戏世界设定的专业策划。
你正在和用户一起共创一个游戏世界。每一轮你都必须读取:
@@ -477,7 +497,7 @@ const OUTPUT_CONTRACT_REMINDER: &str = r#"请严格按以下 JSON 结构输出
pub(crate) async fn run_custom_world_agent_turn<F>(
request: CustomWorldAgentTurnRequest<'_>,
on_reply_update: F,
) -> CustomWorldAgentTurnResult
) -> Result<CustomWorldAgentTurnResult, CustomWorldTurnError>
where
F: FnMut(&str),
{
@@ -495,7 +515,7 @@ where
&current_anchor_content,
on_reply_update,
)
.await;
.await?;
let next_anchor_content = assistant_turn.next_anchor_content.clone();
let next_creator_intent = build_creator_intent_from_eight_anchor_content(&next_anchor_content);
@@ -607,30 +627,12 @@ where
))
};
let (phase_label, phase_detail, operation_status, operation_progress, error_message) =
match assistant_turn.fallback_error {
Some(message) => (
"模型暂不可用".to_string(),
message.clone(),
"failed".to_string(),
100,
Some(message),
),
None => (
"消息已处理".to_string(),
"本轮回复已由大模型生成并回写会话。".to_string(),
"completed".to_string(),
100,
None,
),
};
CustomWorldAgentTurnResult {
Ok(CustomWorldAgentTurnResult {
assistant_reply_text: assistant_turn.reply_text,
phase_label,
phase_detail,
operation_status,
operation_progress,
phase_label: "消息已处理".to_string(),
phase_detail: "本轮回复已由大模型生成并回写会话。".to_string(),
operation_status: "completed".to_string(),
operation_progress: 100,
stage: next_stage,
progress_percent,
focus_card_id: if should_stay_in_draft_stage {
@@ -648,8 +650,8 @@ where
recommended_replies_json,
quality_findings_json,
asset_coverage_json,
error_message,
}
error_message: None,
})
}
pub(crate) fn build_finalize_record_input(
@@ -664,8 +666,8 @@ pub(crate) fn build_finalize_record_input(
session_id,
owner_user_id,
operation_id,
assistant_message_id,
assistant_reply_text: result.assistant_reply_text,
assistant_message_id: Some(assistant_message_id),
assistant_reply_text: Some(result.assistant_reply_text),
phase_label: result.phase_label,
phase_detail: result.phase_detail,
operation_status: result.operation_status,
@@ -688,6 +690,75 @@ pub(crate) fn build_finalize_record_input(
}
}
fn serialize_optional_json_object(value: &JsonValue) -> Option<String> {
if value.is_null() {
None
} else {
Some(serialize_json(value, &empty_json_object()))
}
}
fn serialize_string_array(values: &[String]) -> String {
serialize_json(
&JsonValue::Array(
values
.iter()
.cloned()
.map(JsonValue::String)
.collect::<Vec<_>>(),
),
&empty_json_array(),
)
}
pub(crate) fn build_failed_finalize_record_input(
session_id: String,
owner_user_id: String,
operation_id: String,
session: &CustomWorldAgentSessionRecord,
error_message: String,
updated_at_micros: i64,
) -> CustomWorldAgentMessageFinalizeRecordInput {
CustomWorldAgentMessageFinalizeRecordInput {
session_id,
owner_user_id,
operation_id,
assistant_message_id: None,
assistant_reply_text: None,
phase_label: "消息处理失败".to_string(),
phase_detail: error_message.clone(),
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: serialize_json(&session.anchor_content, &empty_agent_anchor_content_json()),
creator_intent_json: serialize_optional_json_object(&session.creator_intent),
creator_intent_readiness_json: serialize_json(
&session.creator_intent_readiness,
&empty_agent_creator_intent_readiness_json(),
),
anchor_pack_json: serialize_optional_json_object(&session.anchor_pack),
draft_profile_json: serialize_optional_json_object(&session.draft_profile),
pending_clarifications_json: serialize_json(
&JsonValue::Array(session.pending_clarifications.clone()),
&empty_json_array(),
),
suggested_actions_json: serialize_json(
&JsonValue::Array(session.suggested_actions.clone()),
&empty_json_array(),
),
recommended_replies_json: serialize_string_array(&session.recommended_replies),
quality_findings_json: serialize_json(
&JsonValue::Array(session.quality_findings.clone()),
&empty_json_array(),
),
asset_coverage_json: serialize_json(&session.asset_coverage, &empty_agent_asset_coverage_json()),
error_message: Some(error_message),
updated_at_micros,
}
}
async fn stream_single_turn<F>(
llm_client: Option<&LlmClient>,
messages: &[CustomWorldAgentMessageRecord],
@@ -696,17 +767,13 @@ async fn stream_single_turn<F>(
quick_fill_requested: bool,
current_anchor_content: &EightAnchorContent,
mut on_reply_update: F,
) -> SingleTurnModelOutput
) -> Result<SingleTurnModelOutput, CustomWorldTurnError>
where
F: FnMut(&str),
{
if llm_client.is_none() {
let fallback = build_unavailable_output(current_anchor_content, progress_percent, true);
on_reply_update(fallback.reply_text.as_str());
return fallback;
}
let llm_client = llm_client.expect("checked above");
let llm_client = llm_client.ok_or_else(|| {
CustomWorldTurnError::new("当前模型不可用,请稍后重试。")
})?;
let chat_history = build_chat_history(messages);
let dynamic_state =
resolve_dynamic_state(llm_client, current_turn, progress_percent, quick_fill_requested, current_anchor_content, &chat_history)
@@ -739,21 +806,13 @@ where
)
.await;
let Ok(response) = response else {
let fallback = build_unavailable_output(current_anchor_content, progress_percent, false);
if fallback.reply_text != latest_reply_text {
on_reply_update(fallback.reply_text.as_str());
}
return fallback;
};
let response = response.map_err(|_| {
CustomWorldTurnError::new("这一轮设定生成失败,请稍后重试。")
})?;
let Ok(parsed) = parse_json_response_text(response.content.as_str()) else {
let fallback = build_unavailable_output(current_anchor_content, progress_percent, false);
if fallback.reply_text != latest_reply_text {
on_reply_update(fallback.reply_text.as_str());
}
return fallback;
};
let parsed = parse_json_response_text(response.content.as_str()).map_err(|_| {
CustomWorldTurnError::new("模型返回结果解析失败,请稍后重试。")
})?;
let next_anchor_content = normalize_eight_anchor_content(
parsed
@@ -765,19 +824,18 @@ where
} else {
clamp_progress_percent(parsed.get("progressPercent"))
};
let reply_text = to_text(parsed.get("replyText")).unwrap_or_else(|| {
build_unavailable_output(current_anchor_content, progress_percent, false).reply_text
});
let reply_text = to_text(parsed.get("replyText")).ok_or_else(|| {
CustomWorldTurnError::new("模型返回结果缺少有效回复,请稍后重试。")
})?;
if reply_text != latest_reply_text {
on_reply_update(reply_text.as_str());
}
SingleTurnModelOutput {
Ok(SingleTurnModelOutput {
next_anchor_content,
progress_percent,
reply_text,
fallback_error: None,
}
})
}
async fn resolve_dynamic_state(
@@ -1605,28 +1663,6 @@ fn render_chat_history_context(chat_history: &[JsonValue]) -> String {
)
}
fn build_unavailable_output(
current_anchor_content: &EightAnchorContent,
progress_percent: u32,
unavailable: bool,
) -> SingleTurnModelOutput {
SingleTurnModelOutput {
next_anchor_content: current_anchor_content.clone(),
progress_percent,
reply_text: if unavailable {
"当前模型不可用,这一轮设定先保留上一版。你可以稍后重试。".to_string()
} else {
"这一轮设定还没成功更新,我先保留上一版。你可以再发一次,我继续接着收。"
.to_string()
},
fallback_error: Some(if unavailable {
"当前模型不可用,这一轮设定先保留上一版。".to_string()
} else {
"这一轮设定还没成功更新,我先保留上一版。".to_string()
}),
}
}
fn parse_json_response_text(text: &str) -> Result<JsonValue, serde_json::Error> {
let trimmed = text.trim();
if let Some(start) = trimmed.find('{')
@@ -1642,53 +1678,47 @@ fn extract_reply_text_from_partial_json(text: &str) -> Option<String> {
let key_index = text.find("\"replyText\"")?;
let colon_index = text[key_index..].find(':')? + key_index;
let mut cursor = colon_index + 1;
let bytes = text.as_bytes();
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
while cursor < text.len() && text.as_bytes()[cursor].is_ascii_whitespace() {
cursor += 1;
}
if bytes.get(cursor).copied() != Some(b'"') {
if text.as_bytes().get(cursor).copied() != Some(b'"') {
return None;
}
cursor += 1;
let mut decoded = String::new();
while cursor < bytes.len() {
let current = bytes[cursor];
if current == b'"' {
let remainder = text.get(cursor..)?;
let mut characters = remainder.chars().peekable();
while let Some(current) = characters.next() {
if current == '"' {
return Some(decoded);
}
if current == b'\\' {
cursor += 1;
if cursor >= bytes.len() {
break;
}
match bytes[cursor] {
b'"' => decoded.push('"'),
b'\\' => decoded.push('\\'),
b'/' => decoded.push('/'),
b'b' => decoded.push('\u{0008}'),
b'f' => decoded.push('\u{000C}'),
b'n' => decoded.push('\n'),
b'r' => decoded.push('\r'),
b't' => decoded.push('\t'),
b'u' => {
if cursor + 4 < bytes.len()
&& let Ok(hex) = std::str::from_utf8(&bytes[cursor + 1..cursor + 5])
&& let Ok(code) = u16::from_str_radix(hex, 16)
if current == '\\' {
let escaped = characters.next()?;
match escaped {
'"' => decoded.push('"'),
'\\' => decoded.push('\\'),
'/' => decoded.push('/'),
'b' => decoded.push('\u{0008}'),
'f' => decoded.push('\u{000C}'),
'n' => decoded.push('\n'),
'r' => decoded.push('\r'),
't' => decoded.push('\t'),
'u' => {
let mut hex = String::new();
for _ in 0..4 {
hex.push(characters.next()?);
}
if let Ok(code) = u16::from_str_radix(hex.as_str(), 16)
&& let Some(character) = char::from_u32(code as u32)
{
decoded.push(character);
cursor += 5;
continue;
}
decoded.push('u');
}
other => decoded.push(other as char),
other => decoded.push(other),
}
cursor += 1;
continue;
}
decoded.push(current as char);
cursor += 1;
decoded.push(current);
}
Some(decoded)
}
@@ -2010,3 +2040,17 @@ impl PromptConversationMode {
}
}
}
#[cfg(test)]
mod tests {
use super::extract_reply_text_from_partial_json;
#[test]
fn extract_reply_text_from_partial_json_preserves_chinese_characters() {
let partial_json = r#"{"replyText":"你好,潮雾列岛","progressPercent":32"#;
let extracted = extract_reply_text_from_partial_json(partial_json);
assert_eq!(extracted.as_deref(), Some("你好,潮雾列岛"));
}
}

View File

@@ -8,7 +8,7 @@ pub async fn health_check(Extension(request_context): Extension<RequestContext>)
Some(&request_context),
json!({
"ok": true,
"service": "genarrative-node-server",
"service": "genarrative-api-server",
}),
)
}

View File

@@ -13,6 +13,7 @@ use shared_contracts::auth::{
PhoneSendCodeResponse,
};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::{
api_response::json_success_body,
@@ -37,17 +38,54 @@ pub async fn send_phone_code(
);
}
let scene = map_phone_auth_scene(payload.scene.as_deref())?;
let result = state
let phone_input_masked = mask_phone_input(payload.phone.as_str());
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = scene.as_str(),
provider = state.config.sms_auth_provider.as_str(),
phone_input_masked = phone_input_masked.as_str(),
"收到手机号验证码发送请求"
);
let result = match state
.phone_auth_service()
.send_code(
SendPhoneCodeInput {
phone_number: payload.phone,
scene,
scene: scene.clone(),
},
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
{
Ok(result) => {
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = %result.scene,
phone_masked = %result.phone_number_masked,
provider = %result.provider,
provider_request_id = %result.provider_request_id.as_deref().unwrap_or("unknown"),
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
cooldown_seconds = result.cooldown_seconds,
expires_in_seconds = result.expires_in_seconds,
"手机号验证码发送请求已提交"
);
result
}
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = scene.as_str(),
provider = state.config.sms_auth_provider.as_str(),
phone_input_masked = phone_input_masked.as_str(),
error = %error,
"手机号验证码发送失败"
);
return Err(map_phone_auth_error(error));
}
};
Ok(json_success_body(
Some(&request_context),
@@ -72,7 +110,7 @@ pub async fn phone_login(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
);
}
let result = state
let result = match state
.phone_auth_service()
.login(
PhoneLoginInput {
@@ -82,7 +120,32 @@ pub async fn phone_login(
OffsetDateTime::now_utc(),
)
.await
.map_err(map_phone_auth_error)?;
{
Ok(result) => {
info!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = "login",
phone_masked = %result.phone_number_masked,
provider = %result.provider,
provider_out_id = %result.provider_out_id.as_deref().unwrap_or("unknown"),
user_id = %result.user.id,
created = result.created,
"手机号验证码登录成功"
);
result
}
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
scene = "login",
error = %error,
"手机号验证码登录失败"
);
return Err(map_phone_auth_error(error));
}
};
let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session(
&state,
@@ -128,6 +191,37 @@ fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppEr
}
}
fn mask_phone_input(phone: &str) -> String {
let trimmed = phone.trim();
if trimmed.is_empty() {
return "empty".to_string();
}
let digits: String = trimmed.chars().filter(|ch| ch.is_ascii_digit()).collect();
let target = if digits.len() >= 7 {
digits
} else {
trimmed.to_string()
};
mask_phone_digits(&target)
}
fn mask_phone_digits(value: &str) -> String {
let chars: Vec<char> = value.chars().collect();
if chars.len() <= 4 {
return "*".repeat(chars.len().max(1));
}
let prefix_len = chars.len().min(3);
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
let mut masked = String::new();
masked.extend(chars.iter().take(prefix_len));
masked.push_str(&"*".repeat(mask_len.max(1)));
if suffix_len > 0 {
masked.extend(chars.iter().skip(chars.len() - suffix_len));
}
masked
}
fn map_phone_auth_error(error: PhoneAuthError) -> AppError {
match error {
PhoneAuthError::InvalidPhoneNumber

View File

@@ -8,6 +8,7 @@ license.workspace = true
platform-auth = { path = "../platform-auth" }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["formatting", "parsing"] }
tracing = "0.1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]

View File

@@ -14,6 +14,7 @@ use shared_kernel::{
normalize_optional_string, normalize_required_string, parse_rfc3339,
};
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
@@ -90,6 +91,10 @@ pub struct SendPhoneCodeResult {
pub cooldown_seconds: u64,
pub expires_in_seconds: u64,
pub provider_request_id: Option<String>,
pub provider_out_id: Option<String>,
pub provider: String,
pub scene: String,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -102,6 +107,9 @@ pub struct PhoneLoginInput {
pub struct PhoneLoginResult {
pub user: AuthUser,
pub created: bool,
pub provider: String,
pub provider_out_id: Option<String>,
pub phone_number_masked: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -577,7 +585,18 @@ impl PhoneAuthService {
input: SendPhoneCodeInput,
now: OffsetDateTime,
) -> Result<SendPhoneCodeResult, PhoneAuthError> {
let scene = input.scene.clone();
let normalized_phone = normalize_mainland_china_phone_number(&input.phone_number)?;
let national_phone_number = build_national_phone_number(&normalized_phone.e164)?;
info!(
scene = scene.as_str(),
provider = self.sms_provider.kind().as_str(),
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
phone_national_masked = normalized_phone.masked_national_number.as_str(),
"手机号验证码发送准备调用 provider"
);
self.store
.ensure_phone_code_not_cooling_down(&normalized_phone.e164, &scene, now)?;
let expires_at = now
.checked_add(Duration::minutes(SMS_CODE_TTL_MINUTES))
.ok_or_else(|| PhoneAuthError::Store("短信验证码过期时间计算溢出".to_string()))?;
@@ -588,16 +607,27 @@ impl PhoneAuthService {
let provider_result = self
.sms_provider
.send_code(SmsSendCodeRequest {
national_phone_number: build_national_phone_number(&normalized_phone.e164)?,
national_phone_number,
scene: input.scene.as_str().to_string(),
})
.await
.map_err(map_sms_provider_error_to_phone_error)?;
info!(
scene = scene.as_str(),
provider = self.sms_provider.kind().as_str(),
phone_e164_masked = mask_phone_number(&normalized_phone.e164).as_str(),
phone_national_masked = normalized_phone.masked_national_number.as_str(),
cooldown_seconds = provider_result.cooldown_seconds,
expires_in_seconds = provider_result.expires_in_seconds,
provider_request_id = provider_result.provider_request_id.as_deref().unwrap_or("unknown"),
provider_out_id = provider_result.provider_out_id.as_deref().unwrap_or("unknown"),
"手机号验证码 provider 调用成功,准备写入本地快照"
);
self.store.upsert_phone_code(
StoredPhoneCode {
phone_number: normalized_phone.e164.clone(),
scene: input.scene,
scene,
expires_at,
last_sent_at: format_rfc3339(now).map_err(|message| {
PhoneAuthError::Store(format!("短信验证码发送时间格式化失败:{message}"))
@@ -612,6 +642,10 @@ impl PhoneAuthService {
cooldown_seconds: provider_result.cooldown_seconds,
expires_in_seconds: provider_result.expires_in_seconds,
provider_request_id: provider_result.provider_request_id,
provider_out_id: provider_result.provider_out_id,
provider: self.sms_provider.kind().as_str().to_string(),
scene: input.scene.as_str().to_string(),
phone_number_masked: normalized_phone.masked_national_number,
})
}
@@ -656,6 +690,9 @@ impl PhoneAuthService {
..user
},
created: false,
provider: self.sms_provider.kind().as_str().to_string(),
provider_out_id,
phone_number_masked: normalized_phone.masked_national_number,
});
}
@@ -671,6 +708,9 @@ impl PhoneAuthService {
Ok(PhoneLoginResult {
user: created_user,
created: true,
provider: self.sms_provider.kind().as_str().to_string(),
provider_out_id,
phone_number_masked: normalized_phone.masked_national_number,
})
}
@@ -1205,7 +1245,7 @@ impl InMemoryAuthStore {
fn upsert_phone_code(
&self,
code: StoredPhoneCode,
now: OffsetDateTime,
_now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
let mut state = self
.inner
@@ -1213,26 +1253,49 @@ impl InMemoryAuthStore {
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
// 手机号和业务场景共同决定同一份验证码快照,重复发送时直接覆盖旧值。
let key = build_phone_code_key(&code.phone_number, &code.scene);
if let Some(stored) = state.phone_codes_by_key.get(&key).cloned() {
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
if expires_at > now {
let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?;
let cooling_until = last_sent_at
.checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64))
.ok_or_else(|| {
PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string())
})?;
if cooling_until > now {
return Err(PhoneAuthError::SendCoolingDown {
retry_after_seconds: seconds_until(now, cooling_until),
});
}
}
}
state.phone_codes_by_key.insert(key, code);
Ok(())
}
fn ensure_phone_code_not_cooling_down(
&self,
phone_number: &str,
scene: &PhoneAuthScene,
now: OffsetDateTime,
) -> Result<(), PhoneAuthError> {
let state = self
.inner
.lock()
.map_err(|_| PhoneAuthError::Store("短信验证码仓储锁已中毒".to_string()))?;
let key = build_phone_code_key(phone_number, scene);
let Some(stored) = state.phone_codes_by_key.get(&key).cloned() else {
return Ok(());
};
drop(state);
let expires_at = parse_phone_code_time(&stored.expires_at, "过期时间")?;
if expires_at <= now {
return Ok(());
}
let last_sent_at = parse_phone_code_time(&stored.last_sent_at, "发送时间")?;
let cooling_until = last_sent_at
.checked_add(Duration::seconds(SMS_CODE_COOLDOWN_SECONDS as i64))
.ok_or_else(|| PhoneAuthError::Store("短信验证码冷却时间计算溢出".to_string()))?;
if cooling_until <= now {
return Ok(());
}
let retry_after_seconds = seconds_until(now, cooling_until);
warn!(
scene = scene.as_str(),
phone_masked = mask_phone_number(phone_number).as_str(),
retry_after_seconds,
"手机号验证码发送命中本地冷却限制"
);
Err(PhoneAuthError::SendCoolingDown {
retry_after_seconds,
})
}
fn assert_phone_code_active(
&self,
phone_number: &str,

View File

@@ -514,8 +514,8 @@ pub struct CustomWorldAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub assistant_message_id: String,
pub assistant_reply_text: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub phase_label: String,
pub phase_detail: String,
pub operation_status: RpgAgentOperationStatus,
@@ -1106,11 +1106,23 @@ pub fn validate_custom_world_agent_message_finalize_input(
return Err(CustomWorldFieldError::MissingOwnerUserId);
}
validate_custom_world_agent_message_fields(
&input.assistant_message_id,
&input.session_id,
&input.assistant_reply_text,
)?;
match input.operation_status {
RpgAgentOperationStatus::Completed => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
RpgAgentOperationStatus::Failed => {}
_ => {
validate_custom_world_agent_message_fields(
input.assistant_message_id.as_deref().unwrap_or_default(),
&input.session_id,
input.assistant_reply_text.as_deref().unwrap_or_default(),
)?;
}
}
validate_custom_world_agent_operation_fields(
&input.operation_id,
&input.session_id,
@@ -1733,8 +1745,8 @@ mod tests {
session_id: "session_001".to_string(),
owner_user_id: "user_001".to_string(),
operation_id: "operation_001".to_string(),
assistant_message_id: "message_001".to_string(),
assistant_reply_text: "已生成回复".to_string(),
assistant_message_id: Some("message_001".to_string()),
assistant_reply_text: Some("已生成回复".to_string()),
phase_label: "消息已处理".to_string(),
phase_detail: "这一轮已完成推理并写回".to_string(),
operation_status: RpgAgentOperationStatus::Completed,
@@ -1761,6 +1773,37 @@ mod tests {
assert_eq!(error, CustomWorldFieldError::InvalidJsonPayload);
}
#[test]
fn agent_message_finalize_allows_missing_assistant_reply_when_failed() {
validate_custom_world_agent_message_finalize_input(&CustomWorldAgentMessageFinalizeInput {
session_id: "session_001".to_string(),
owner_user_id: "user_001".to_string(),
operation_id: "operation_001".to_string(),
assistant_message_id: None,
assistant_reply_text: None,
phase_label: "消息处理失败".to_string(),
phase_detail: "当前模型不可用,请稍后重试。".to_string(),
operation_status: RpgAgentOperationStatus::Failed,
operation_progress: 100,
stage: RpgAgentStage::Clarifying,
progress_percent: 20,
focus_card_id: None,
anchor_content_json: "{}".to_string(),
creator_intent_json: Some("{}".to_string()),
creator_intent_readiness_json: "{}".to_string(),
anchor_pack_json: Some("{}".to_string()),
draft_profile_json: Some("{}".to_string()),
pending_clarifications_json: "[]".to_string(),
suggested_actions_json: "[]".to_string(),
recommended_replies_json: "[]".to_string(),
quality_findings_json: "[]".to_string(),
asset_coverage_json: "{}".to_string(),
error_message: Some("当前模型不可用,请稍后重试。".to_string()),
updated_at_micros: 1,
})
.expect("failed finalize should allow empty assistant message");
}
#[test]
fn published_profile_compile_merges_legacy_theme_and_latest_assets() {
let snapshot = build_custom_world_published_profile_compile_snapshot(

View File

@@ -17,6 +17,7 @@ rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }
shared-kernel = { path = "../shared-kernel" }
time = { version = "0.3", features = ["std"] }
tracing = "0.1"
urlencoding = "2"
uuid = { version = "1", features = ["v4"] }

View File

@@ -18,6 +18,7 @@ use sha1::Sha1;
use sha2::{Digest, Sha256};
use shared_kernel::{new_uuid_simple_string, normalize_optional_string, normalize_required_string};
use time::{Duration, OffsetDateTime};
use tracing::{info, warn};
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
@@ -110,7 +111,7 @@ pub struct RefreshCookieConfig {
refresh_session_ttl_days: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SmsAuthProviderKind {
Mock,
Aliyun,
@@ -203,15 +204,16 @@ pub enum SmsProviderError {
#[derive(Debug, Deserialize)]
struct AliyunSendSmsVerifyCodeResponse {
#[serde(default)]
// 阿里云 RPC 原始 JSON 使用首字母大写字段名,这里必须显式映射,避免把成功响应误判成空值。
#[serde(default, rename = "Code")]
code: Option<String>,
#[serde(default)]
#[serde(default, rename = "Message")]
message: Option<String>,
#[serde(default)]
#[serde(default, rename = "RequestId")]
request_id: Option<String>,
#[serde(default)]
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default)]
#[serde(default, rename = "Model")]
model: Option<AliyunSendSmsVerifyCodeModel>,
}
@@ -227,13 +229,14 @@ struct AliyunSendSmsVerifyCodeModel {
#[derive(Debug, Deserialize)]
struct AliyunCheckSmsVerifyCodeResponse {
#[serde(default)]
// 校验接口同样返回首字母大写字段名,保持和发送接口一致的显式映射。
#[serde(default, rename = "Code")]
code: Option<String>,
#[serde(default)]
#[serde(default, rename = "Message")]
message: Option<String>,
#[serde(default)]
#[serde(default, rename = "Success")]
success: Option<bool>,
#[serde(default)]
#[serde(default, rename = "Model")]
model: Option<AliyunCheckSmsVerifyCodeModel>,
}
@@ -356,6 +359,13 @@ impl SmsAuthProviderKind {
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Mock => "mock",
Self::Aliyun => "aliyun",
}
}
}
impl SmsAuthConfig {
@@ -477,6 +487,13 @@ impl SmsAuthProvider {
}
}
pub fn kind(&self) -> SmsAuthProviderKind {
match self {
Self::Mock(_) => SmsAuthProviderKind::Mock,
Self::Aliyun(_) => SmsAuthProviderKind::Aliyun,
}
}
pub async fn send_code(
&self,
request: SmsSendCodeRequest,
@@ -530,11 +547,25 @@ impl AliyunSmsAuthProvider {
request: SmsSendCodeRequest,
) -> Result<SmsSendCodeResult, SmsProviderError> {
let provider_out_id = build_sms_provider_out_id(&request.scene, &request.national_phone_number);
let phone_masked = mask_phone_number(&request.national_phone_number);
let template_param = serde_json::json!({
self.config.template_param_key.clone(): "##code##",
"min": self.config.valid_time_seconds,
})
.to_string();
info!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
endpoint = self.config.endpoint.as_str(),
sign_name = self.config.sign_name.as_str(),
template_code = self.config.template_code.as_str(),
code_length = self.config.code_length,
valid_time_seconds = self.config.valid_time_seconds,
interval_seconds = self.config.interval_seconds,
provider_out_id = provider_out_id.as_str(),
"准备调用阿里云短信发送接口"
);
let mut query = BTreeMap::new();
query.insert("Action".to_string(), "SendSmsVerifyCode".to_string());
@@ -596,9 +627,49 @@ impl AliyunSmsAuthProvider {
.send()
.await
.map_err(|error| SmsProviderError::Upstream(format!("短信验证码发送失败:{error}")))?;
let http_status = payload.status();
let body = parse_aliyun_json_response(payload, "短信验证码发送失败").await?;
info!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
.as_ref()
.and_then(|model| model.out_id.as_deref())
.unwrap_or("unknown"),
success = body.success.unwrap_or(false),
"阿里云短信发送接口返回响应"
);
if !body.success.unwrap_or(false) || body.code.as_deref() != Some("OK") {
warn!(
provider = "aliyun",
scene = request.scene.as_str(),
phone_masked = phone_masked.as_str(),
http_status = http_status.as_u16(),
provider_code = body.code.as_deref().unwrap_or("unknown"),
provider_message = body.message.as_deref().unwrap_or("unknown"),
provider_request_id = body
.request_id
.as_deref()
.or_else(|| body.model.as_ref().and_then(|model| model.request_id.as_deref()))
.unwrap_or("unknown"),
provider_out_id = body
.model
.as_ref()
.and_then(|model| model.out_id.as_deref())
.unwrap_or("unknown"),
"阿里云短信发送接口返回业务失败"
);
return Err(map_aliyun_provider_error(
"短信验证码发送失败",
body.message,
@@ -1173,6 +1244,23 @@ fn build_provider_error_message(prefix: &str, provider_message: &str) -> String
}
}
fn mask_phone_number(phone_number: &str) -> String {
let chars: Vec<char> = phone_number.chars().collect();
if chars.len() <= 4 {
return "*".repeat(chars.len().max(1));
}
let prefix_len = chars.len().min(3);
let suffix_len = 4.min(chars.len().saturating_sub(prefix_len));
let mask_len = chars.len().saturating_sub(prefix_len + suffix_len);
let mut masked = String::new();
masked.extend(chars.iter().take(prefix_len));
masked.push_str(&"*".repeat(mask_len.max(1)));
if suffix_len > 0 {
masked.extend(chars.iter().skip(chars.len() - suffix_len));
}
masked
}
impl fmt::Display for SmsProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -1469,4 +1557,65 @@ mod tests {
"Action=SendSmsVerifyCode&PhoneNumber=13800138000&TemplateParam=%7B%22code%22%3A%22%23%23code%23%23%22%7D"
);
}
#[test]
fn aliyun_send_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunSendSmsVerifyCodeResponse>(
r#"{
"Code": "OK",
"Message": "成功",
"RequestId": "req_123",
"Success": true,
"Model": {
"BizId": "biz_456",
"OutId": "out_789",
"RequestId": "req_model_001"
}
}"#,
)
.expect("aliyun send response should deserialize");
assert_eq!(payload.code.as_deref(), Some("OK"));
assert_eq!(payload.message.as_deref(), Some("成功"));
assert_eq!(payload.request_id.as_deref(), Some("req_123"));
assert_eq!(payload.success, Some(true));
assert_eq!(
payload.model.as_ref().and_then(|model| model.out_id.as_deref()),
Some("out_789")
);
assert_eq!(
payload
.model
.as_ref()
.and_then(|model| model.request_id.as_deref()),
Some("req_model_001")
);
}
#[test]
fn aliyun_verify_response_deserializes_pascal_case_fields() {
let payload = serde_json::from_str::<AliyunCheckSmsVerifyCodeResponse>(
r#"{
"Code": "OK",
"Message": "成功",
"Success": true,
"Model": {
"OutId": "out_789",
"VerifyResult": "PASS"
}
}"#,
)
.expect("aliyun verify response should deserialize");
assert_eq!(payload.code.as_deref(), Some("OK"));
assert_eq!(payload.message.as_deref(), Some("成功"));
assert_eq!(payload.success, Some(true));
assert_eq!(
payload
.model
.as_ref()
.and_then(|model| model.verify_result.as_deref()),
Some("PASS")
);
}
}

View File

@@ -1,4 +1,4 @@
use std::{error::Error, fmt, time::Duration};
use std::{error::Error, fmt, str as std_str, time::Duration};
use log::{debug, warn};
use reqwest::{Client, StatusCode};
@@ -419,6 +419,7 @@ impl LlmClient {
let mut parser = OpenAiCompatibleSseParser::default();
let mut accumulated_text = String::new();
let mut finish_reason = None;
let mut undecoded_chunk_bytes = Vec::new();
loop {
let next_chunk = response
@@ -430,7 +431,13 @@ impl LlmClient {
break;
};
let chunk_text = String::from_utf8_lossy(chunk.as_ref());
undecoded_chunk_bytes.extend_from_slice(chunk.as_ref());
let (chunk_text, remaining_bytes) =
decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice())?;
undecoded_chunk_bytes = remaining_bytes;
if chunk_text.is_empty() {
continue;
}
for event in parser.push_chunk(chunk_text.as_ref())? {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
@@ -450,6 +457,34 @@ impl LlmClient {
}
}
if !undecoded_chunk_bytes.is_empty() {
let trailing_text = std_str::from_utf8(undecoded_chunk_bytes.as_slice())
.map_err(|error| {
LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{error}"
))
})?;
if !trailing_text.is_empty() {
for event in parser.push_chunk(trailing_text)? {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
{
accumulated_text.push_str(delta_text.as_str());
let update = LlmStreamDelta {
accumulated_text: accumulated_text.clone(),
delta_text,
finish_reason: event.finish_reason.clone(),
};
on_delta(&update);
}
if event.finish_reason.is_some() {
finish_reason = event.finish_reason;
}
}
}
}
for event in parser.finish()? {
if let Some(delta_text) = event.delta_text
&& !delta_text.is_empty()
@@ -719,6 +754,27 @@ fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
}
}
fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec<u8>), LlmError> {
match std_str::from_utf8(bytes) {
Ok(text) => Ok((text.to_string(), Vec::new())),
Err(error) => {
let valid_up_to = error.valid_up_to();
let Some(_) = error.error_len() else {
let decoded = std_str::from_utf8(&bytes[..valid_up_to]).map_err(|inner_error| {
LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{inner_error}"
))
})?;
return Ok((decoded.to_string(), bytes[valid_up_to..].to_vec()));
};
Err(LlmError::Deserialize(format!(
"解析 LLM 流式 UTF-8 响应失败:{error}"
)))
}
}
}
fn parse_sse_event_block(block: &str) -> Result<Option<ParsedStreamEvent>, LlmError> {
let data_lines = block
.lines()
@@ -873,6 +929,22 @@ mod tests {
assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop"));
}
#[test]
fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() {
let full_bytes = "你好".as_bytes();
let first_result = decode_utf8_stream_chunk(&full_bytes[..2])
.expect("incomplete utf-8 chunk should be buffered");
assert_eq!(first_result.0, "");
assert_eq!(first_result.1, full_bytes[..2].to_vec());
let mut combined = first_result.1;
combined.extend_from_slice(&full_bytes[2..]);
let second_result = decode_utf8_stream_chunk(combined.as_slice())
.expect("completed utf-8 bytes should decode");
assert_eq!(second_result.0, "你好");
assert!(second_result.1.is_empty());
}
#[tokio::test]
async fn request_text_parses_non_stream_response() {
let server_url = spawn_mock_server(vec![MockResponse {

View File

@@ -16,6 +16,7 @@ pub fn init_tracing(default_filter: &str) -> Result<(), io::Error> {
fmt()
.with_env_filter(env_filter)
.with_target(true)
.with_ansi(false)
.compact()
.try_init()
.map_err(|error| io::Error::other(format!("初始化 tracing subscriber 失败:{error}")))

View File

@@ -5788,8 +5788,8 @@ pub struct CustomWorldAgentMessageFinalizeRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub assistant_message_id: String,
pub assistant_reply_text: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub phase_label: String,
pub phase_detail: String,
pub operation_status: String,

View File

@@ -18,8 +18,8 @@ pub struct CustomWorldAgentMessageFinalizeInput {
pub session_id: String,
pub owner_user_id: String,
pub operation_id: String,
pub assistant_message_id: String,
pub assistant_reply_text: String,
pub assistant_message_id: Option::<String>,
pub assistant_reply_text: Option::<String>,
pub phase_label: String,
pub phase_detail: String,
pub operation_status: RpgAgentOperationStatus,

View File

@@ -2622,52 +2622,71 @@ fn finalize_custom_world_agent_message_turn_tx(
.filter(|row| row.session_id == input.session_id)
.ok_or_else(|| "custom_world_agent_operation 不存在".to_string())?;
if ctx
.db
.custom_world_agent_message()
.message_id()
.find(&input.assistant_message_id)
.is_some()
{
return Err("custom_world_agent_message.assistant_message_id 已存在".to_string());
}
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
ctx.db
.custom_world_agent_message()
.insert(CustomWorldAgentMessage {
message_id: input.assistant_message_id.clone(),
session_id: input.session_id.clone(),
role: RpgAgentMessageRole::Assistant,
kind: RpgAgentMessageKind::Chat,
text: input.assistant_reply_text.clone(),
related_operation_id: Some(input.operation_id.clone()),
created_at: updated_at,
});
let next_session = if input.operation_status == RpgAgentOperationStatus::Failed {
rebuild_custom_world_agent_session_row(
&session,
CustomWorldAgentSessionPatch {
updated_at_micros: Some(input.updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?
} else {
let assistant_message_id = input
.assistant_message_id
.clone()
.ok_or_else(|| "custom_world_agent_message.assistant_message_id 不能为空".to_string())?;
let assistant_reply_text = input
.assistant_reply_text
.clone()
.ok_or_else(|| "custom_world_agent_message.text 不能为空".to_string())?;
let next_session = rebuild_custom_world_agent_session_row(
&session,
CustomWorldAgentSessionPatch {
current_turn: Some(session.current_turn.saturating_add(1)),
progress_percent: Some(input.progress_percent),
stage: Some(input.stage),
focus_card_id: Some(input.focus_card_id.clone()),
anchor_content_json: Some(input.anchor_content_json.clone()),
creator_intent_json: Some(input.creator_intent_json.clone()),
creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()),
anchor_pack_json: Some(input.anchor_pack_json.clone()),
draft_profile_json: Some(input.draft_profile_json.clone()),
last_assistant_reply: Some(Some(input.assistant_reply_text.clone())),
pending_clarifications_json: Some(input.pending_clarifications_json.clone()),
quality_findings_json: Some(input.quality_findings_json.clone()),
suggested_actions_json: Some(input.suggested_actions_json.clone()),
recommended_replies_json: Some(input.recommended_replies_json.clone()),
asset_coverage_json: Some(input.asset_coverage_json.clone()),
updated_at_micros: Some(input.updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
if ctx
.db
.custom_world_agent_message()
.message_id()
.find(&assistant_message_id)
.is_some()
{
return Err("custom_world_agent_message.assistant_message_id 已存在".to_string());
}
ctx.db
.custom_world_agent_message()
.insert(CustomWorldAgentMessage {
message_id: assistant_message_id,
session_id: input.session_id.clone(),
role: RpgAgentMessageRole::Assistant,
kind: RpgAgentMessageKind::Chat,
text: assistant_reply_text.clone(),
related_operation_id: Some(input.operation_id.clone()),
created_at: updated_at,
});
rebuild_custom_world_agent_session_row(
&session,
CustomWorldAgentSessionPatch {
current_turn: Some(session.current_turn.saturating_add(1)),
progress_percent: Some(input.progress_percent),
stage: Some(input.stage),
focus_card_id: Some(input.focus_card_id.clone()),
anchor_content_json: Some(input.anchor_content_json.clone()),
creator_intent_json: Some(input.creator_intent_json.clone()),
creator_intent_readiness_json: Some(input.creator_intent_readiness_json.clone()),
anchor_pack_json: Some(input.anchor_pack_json.clone()),
draft_profile_json: Some(input.draft_profile_json.clone()),
last_assistant_reply: Some(Some(assistant_reply_text)),
pending_clarifications_json: Some(input.pending_clarifications_json.clone()),
quality_findings_json: Some(input.quality_findings_json.clone()),
suggested_actions_json: Some(input.suggested_actions_json.clone()),
recommended_replies_json: Some(input.recommended_replies_json.clone()),
asset_coverage_json: Some(input.asset_coverage_json.clone()),
updated_at_micros: Some(input.updated_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?
};
replace_custom_world_agent_session(ctx, &session, next_session);
let next_operation = rebuild_custom_world_agent_operation_row(

View File

@@ -1,5 +1,5 @@
{
"server": "local",
"server": "http://127.0.0.1:3101",
"module-path": "./server-rs/crates/spacetime-module",
"generate": [
{

View File

@@ -443,7 +443,7 @@ test('readOnly result view hides edit and create actions for agent preview mode'
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('agent result view keeps publish-enter action clickable and hides sticky publish hints', () => {
test('agent result view shows publish blockers and disables publish-enter action', () => {
render(
<RpgCreationResultView
profile={baseProfile}
@@ -474,48 +474,15 @@ test('agent result view keeps publish-enter action clickable and hides sticky pu
/>,
);
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByText(/ 2 /u)).toBeNull();
});
test('agent result view opens publish blocker dialog only when user clicks publish action', async () => {
const user = userEvent.setup();
render(
<RpgCreationResultView
profile={baseProfile}
previewCharacters={[]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
compactAgentResultMode
publishReady={false}
publishBlockers={[
'仍有角色缺少正式主图或动作资产,发布前需要先补齐。',
'营地还缺少正式场景图资产,发布前需要先确认营地图。',
]}
previewSourceLabel="服务端预览"
enterWorldActionLabel="发布并进入世界"
onEnterWorld={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(
screen.getByRole('dialog', { name: '发布前检查' }),
).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: '发布并进入世界',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
});
test('agent result view keeps publish-enter action enabled when publish gate is clear', () => {

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import {
@@ -20,14 +20,22 @@ afterEach(() => {
vi.restoreAllMocks();
});
function ensureScrollApis() {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = () => {};
}
}
test('creation agent workspace filters duplicate recommended replies', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
ensureScrollApis();
render(
<CreationAgentWorkspace
@@ -78,9 +86,7 @@ test('creation agent workspace filters duplicate recommended replies', () => {
});
test('creation agent workspace renders streaming assistant text', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
ensureScrollApis();
render(
<CreationAgentWorkspace
@@ -114,10 +120,65 @@ test('creation agent workspace renders streaming assistant text', () => {
expect(screen.getByText(//u)).toBeTruthy();
});
test('creation agent workspace appends streaming assistant message after stable message list', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 40,
anchors: [],
messages: [
{
id: 'message-user-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
{
id: 'message-assistant-1',
role: 'assistant',
kind: 'chat',
text: '我先接住这个方向。',
},
{
id: 'message-user-2',
role: 'user',
kind: 'chat',
text: '开场我想先撞上一场假航灯事故。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="那我就把开场事故往沉船旧案上收。"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const bubbles = screen
.getByTestId('creation-agent-message-list')
.querySelectorAll('.whitespace-pre-wrap');
const bubbleTexts = Array.from(bubbles).map((node) => node.textContent?.trim());
expect(bubbleTexts).toEqual([
'我想做一个潮湿压抑的海上世界。',
'我先接住这个方向。',
'开场我想先撞上一场假航灯事故。',
'那我就把开场事故往沉船旧案上收。',
]);
});
test('creation agent workspace hides anchors and primary action before completed progress', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
ensureScrollApis();
render(
<CreationAgentWorkspace
@@ -159,9 +220,7 @@ test('creation agent workspace hides anchors and primary action before completed
});
test('creation agent workspace shows primary and progress actions at completed progress', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
ensureScrollApis();
render(
<CreationAgentWorkspace
@@ -206,26 +265,26 @@ test('creation agent workspace shows primary and progress actions at completed p
expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy();
});
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
ensureScrollApis();
render(
const scrollToSpy = vi.fn();
HTMLElement.prototype.scrollTo = scrollToSpy;
const { rerender } = render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
assistantSummary: null,
currentTurn: 2,
progressPercent: 60,
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '继续把设定收束到可生成状态。',
text: '先确定一下世界方向。',
},
],
}}
@@ -239,6 +298,56 @@ test('creation agent workspace hides hero copy area when title and summary are a
/>,
);
expect(screen.queryByText('统一共创')).toBeNull();
expect(screen.getByText('创作进度')).toBeTruthy();
const messageList = screen.getByTestId('creation-agent-message-list');
let scrollTop = 120;
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 640,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 240,
});
Object.defineProperty(messageList, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = Number(value);
},
});
fireEvent.scroll(messageList);
scrollToSpy.mockClear();
rerender(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '先确定一下世界方向。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="继续往下收束开场冲突。"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(scrollToSpy).not.toHaveBeenCalled();
});

View File

@@ -34,7 +34,7 @@ export type CreationAgentOperationView = {
export type CreationAgentSessionView = {
sessionId: string;
title?: string | null;
title: string;
assistantSummary?: string | null;
currentTurn: number;
progressPercent: number;
@@ -79,6 +79,8 @@ type CreationAgentWorkspaceProps = {
onQuickAction?: (action: CreationAgentQuickAction) => void;
};
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean))].slice(
0,
@@ -165,16 +167,18 @@ function CreationAgentMessageBubble({
message,
theme,
recommendedReplies,
isStreaming = false,
onRecommendedReply,
}: {
message: CreationAgentMessageView;
theme: CreationAgentTheme;
recommendedReplies?: string[];
isStreaming?: boolean;
onRecommendedReply: (text: string) => void;
}) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const visibleRecommendedReplies = isUser
const visibleRecommendedReplies = isUser || isStreaming
? []
: uniqueRecommendedReplies(recommendedReplies);
const bubbleToneClass = isUser
@@ -188,7 +192,24 @@ function CreationAgentMessageBubble({
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
{isStreaming ? (
message.text ? (
<div className="whitespace-pre-wrap">
{message.text}
<span
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
/>
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)
) : (
<div className="whitespace-pre-wrap">{message.text}</div>
)}
{visibleRecommendedReplies.length > 0 ? (
<div className="mt-2.5 flex flex-col gap-1.5">
{visibleRecommendedReplies.map((reply, replyIndex) => (
@@ -228,6 +249,25 @@ function shouldShowQuickAction(
return true;
}
function isMessageListNearBottom(container: HTMLDivElement) {
return (
container.scrollHeight - container.scrollTop - container.clientHeight <=
AUTO_SCROLL_FOLLOW_THRESHOLD_PX
);
}
function scrollMessageListToBottom(container: HTMLDivElement) {
if (typeof container.scrollTo === 'function') {
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
});
return;
}
container.scrollTop = container.scrollHeight;
}
export function CreationAgentWorkspace({
session,
theme,
@@ -247,14 +287,18 @@ export function CreationAgentWorkspace({
onQuickAction,
}: CreationAgentWorkspaceProps) {
const [draftText, setDraftText] = useState('');
const bottomRef = useRef<HTMLDivElement | null>(null);
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
const messageListRef = useRef<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [session?.messages, streamingReplyText, isStreamingReply]);
const container = messageListRef.current;
if (!container || !shouldAutoScrollRef.current) {
return;
}
scrollMessageListToBottom(container);
}, [session?.sessionId, session?.messages, streamingReplyText, isStreamingReply]);
if (!session) {
return (
@@ -267,23 +311,53 @@ export function CreationAgentWorkspace({
}
const progress = normalizeCreationAgentProgress(session.progressPercent);
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
const canShowPrimaryAction = progress >= 100;
const visibleQuickActions = quickActions.filter((action) =>
shouldShowQuickAction(action, session, progress),
);
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
const displayedMessages = isStreamingReply
? [
...session.messages,
{
id: streamingMessageId,
role: 'assistant',
kind: 'chat',
text: streamingReplyText,
} satisfies CreationAgentMessageView,
]
: session.messages;
const lastAssistantMessageIndex = session.messages.reduce(
(lastIndex, message, index) =>
message.role === 'assistant' ? index : lastIndex,
-1,
);
const armAutoScrollToBottom = () => {
shouldAutoScrollRef.current = true;
};
const handleMessageListScroll = () => {
const container = messageListRef.current;
if (!container) {
return;
}
shouldAutoScrollRef.current = isMessageListNearBottom(container);
};
const submitRecommendedReply = (text: string) => {
armAutoScrollToBottom();
onSubmitText(text);
};
const submit = () => {
const text = draftText.trim();
if (!text || isBusy) {
return;
}
armAutoScrollToBottom();
onSubmitText(text);
setDraftText('');
};
@@ -314,22 +388,18 @@ export function CreationAgentWorkspace({
) : null}
</div>
{hasHeroCopy ? (
<div className="mt-6">
{session.title ? (
<div className="text-2xl font-black leading-tight sm:text-3xl">
{session.title}
</div>
) : null}
{session.assistantSummary ? (
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
{session.assistantSummary}
</div>
) : null}
<div className="mt-6">
<div className="text-2xl font-black leading-tight sm:text-3xl">
{session.title}
</div>
) : null}
{session.assistantSummary ? (
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
{session.assistantSummary}
</div>
) : null}
</div>
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
@@ -370,48 +440,33 @@ export function CreationAgentWorkspace({
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
{session.messages.length === 0 ? (
<div
ref={messageListRef}
data-testid="creation-agent-message-list"
onScroll={handleMessageListScroll}
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4"
>
{displayedMessages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
) : (
session.messages.map((message, index) => (
displayedMessages.map((message, index) => (
<CreationAgentMessageBubble
key={message.id || `message-${index}`}
message={message}
theme={theme}
isStreaming={message.id === streamingMessageId}
recommendedReplies={
message.id !== streamingMessageId &&
index === lastAssistantMessageIndex
? session.recommendedReplies
: []
}
onRecommendedReply={(text) => onSubmitText(text)}
onRecommendedReply={submitRecommendedReply}
/>
))
)}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
/>
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)}
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
{error ? (

View File

@@ -77,10 +77,6 @@ test('custom world agent workspace renders minimum loop chat layout', () => {
expect(html).toContain('输入消息');
expect(html).toContain('总结当前设定');
expect(html).toContain('补全剩余设定');
expect(html).not.toContain('世界共创');
expect(html).not.toContain(
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
);
expect(html).not.toContain('Agent');
expect(html).not.toContain('刷新');
expect(html).not.toContain('当前轮次');

View File

@@ -83,9 +83,10 @@ function mapCustomWorldSession(
): CreationAgentSessionView {
return {
sessionId: session.sessionId,
// 自定义世界 Agent 聊天页顶部保持纯操作区,不额外显示标题和引导副文案。
title: null,
assistantSummary: null,
title: '世界共创',
assistantSummary:
session.lastAssistantReply ||
'先说一个你最想让玩家记住的世界方向,我会帮你收束成可生成草稿的锚点。',
currentTurn: session.currentTurn,
progressPercent: session.progressPercent,
anchors: [

View File

@@ -958,6 +958,10 @@ export function PlatformEntryFlowShellImpl({
if (!confirmed) {
return;
}
if (!work.profileId) {
platformBootstrap.setPlatformError('当前作品缺少 profileId暂时无法删除。');
return;
}
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);

View File

@@ -1,6 +1,4 @@
import { X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ReactNode } from 'react';
import type { CustomWorldProfile } from '../../types';
@@ -31,81 +29,6 @@ function SmallButton({
);
}
function PublishBlockersDialog({
blockers,
onClose,
}: {
blockers: string[];
onClose: () => void;
}) {
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
className="platform-overlay fixed inset-0 z-[140] flex items-end justify-center bg-slate-950/56 p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-label="发布前检查"
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,42rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
</div>
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
{blockers.length}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="platform-icon-button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`publish-blocker-${index}-${blocker}`}
className="rounded-[1.1rem] border border-amber-300/18 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)]"
>
<div className="text-xs font-semibold tracking-[0.14em] text-amber-100/78">
{index + 1}
</div>
<div className="mt-1">{blocker}</div>
</div>
))}
</div>
</div>
<div className="flex justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
className="platform-button platform-button--primary"
>
</button>
</div>
</div>
</div>,
document.body,
);
}
interface RpgCreationResultActionBarProps {
editActionLabel: string;
enterWorldActionLabel: string;
@@ -117,7 +40,6 @@ interface RpgCreationResultActionBarProps {
profile: CustomWorldProfile;
regenerateActionLabel: string;
publishReady: boolean;
publishBlockers: string[];
}
export function RpgCreationResultActionBar({
@@ -131,21 +53,7 @@ export function RpgCreationResultActionBar({
profile,
regenerateActionLabel,
publishReady,
publishBlockers,
}: RpgCreationResultActionBarProps) {
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
useState(false);
// 结果页保持清爽,只有用户发起发布动作时才弹出阻断项提示。
const handleEnterWorld = () => {
if (!publishReady) {
setShowPublishBlockersDialog(true);
return;
}
onEnterWorld?.();
};
return (
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
@@ -174,24 +82,14 @@ export function RpgCreationResultActionBar({
{onEnterWorld ? (
<button
type="button"
onClick={handleEnterWorld}
disabled={isGenerating}
onClick={onEnterWorld}
disabled={isGenerating || !publishReady}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
{enterWorldActionLabel}
</button>
) : null}
</div>
{showPublishBlockersDialog ? (
<PublishBlockersDialog
blockers={
publishBlockers.length > 0
? publishBlockers
: ['当前草稿还没有通过发布门槛,请先补齐必要内容。']
}
onClose={() => setShowPublishBlockersDialog(false)}
/>
) : null}
</div>
);
}

View File

@@ -68,6 +68,7 @@ export function RpgCreationResultView({
publishReady = true,
publishBlockers = [],
qualityFindings = [],
previewSourceLabel = null,
}: RpgCreationResultViewProps) {
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const assetDebugEnabled = useMemo(
@@ -170,6 +171,25 @@ export function RpgCreationResultView({
{error}
</div>
) : null}
{!error && compactAgentResultMode && previewSourceLabel ? (
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
{previewSourceLabel}
</div>
) : null}
{!error && compactAgentResultMode && publishBlockers.length > 0 ? (
<div className="platform-banner platform-banner--warning mt-3 rounded-2xl text-sm leading-6">
{publishReady
? '当前世界已满足发布门槛。'
: `当前还有 ${publishBlockers.length} 个发布阻断项,请先补齐后再进入世界。`}
<div className="mt-2 space-y-1">
{publishBlockers.slice(0, 4).map((entry, index) => (
<div key={`publish-blocker-${index}-${entry}`}>
{index + 1}. {entry}
</div>
))}
</div>
</div>
) : null}
{!error &&
compactAgentResultMode &&
publishBlockers.length <= 0 &&
@@ -196,7 +216,6 @@ export function RpgCreationResultView({
profile={profile}
regenerateActionLabel={regenerateActionLabel}
publishReady={publishReady}
publishBlockers={publishBlockers}
/>
<RpgCreationEntityEditorModal

View File

@@ -598,7 +598,7 @@ beforeEach(() => {
sessionId: 'puzzle-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
stage: 'collecting_anchors',
anchorPack: {
themePromise: {
key: 'theme_promise',
@@ -691,7 +691,11 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock
});
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
await screen.findByText(
'Agent工作区custom-world-agent-session-1',
{},
{ timeout: 5000 },
),
).toBeTruthy();
});
@@ -1097,7 +1101,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
});
test('agent result view shows publish blocker dialog before publish action when preview gate is not ready', async () => {
test('agent result view shows publish blockers and disables publish-enter action when preview gate is not ready', async () => {
const user = userEvent.setup();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
@@ -1128,33 +1132,11 @@ test('agent result view shows publish blocker dialog before publish action when
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
expect(await screen.findByText(/ 1 /u)).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: //u,
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
const publishWorldCallCountBeforeClick = vi
.mocked(executeRpgCreationAction)
.mock.calls.filter(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'publish_world',
).length;
await user.click(actionButton);
expect(await screen.findByRole('dialog', { name: '发布前检查' })).toBeTruthy();
expect(screen.getByText(/ 1 /u)).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
const publishWorldCallCountAfterClick = vi
.mocked(executeRpgCreationAction)
.mock.calls.filter(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'publish_world',
).length;
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
expect((actionButton as HTMLButtonElement).disabled).toBe(true);
});
test('agent draft result publishes before entering world and uses published preview profile', async () => {

View File

@@ -46,6 +46,11 @@ type UseRpgCreationSessionControllerParams = {
onSessionOpened?: (() => void) | undefined;
};
type PendingAgentUserMessage = {
sessionId: string;
message: CustomWorldAgentSessionSnapshot['messages'][number];
};
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
@@ -64,6 +69,8 @@ export function useRpgCreationSessionController(
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const currentAgentSessionIdRef = useRef<string | null>(null);
const latestAgentSessionSyncRequestIdRef = useRef(0);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
@@ -78,6 +85,8 @@ export function useRpgCreationSessionController(
useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [pendingAgentUserMessage, setPendingAgentUserMessage] =
useState<PendingAgentUserMessage | null>(null);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
@@ -91,6 +100,44 @@ export function useRpgCreationSessionController(
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(null);
useEffect(() => {
currentAgentSessionIdRef.current = agentSession?.sessionId ?? null;
}, [agentSession]);
useEffect(() => {
pendingAgentUserMessageRef.current = pendingAgentUserMessage;
}, [pendingAgentUserMessage]);
const invalidateAgentSessionSyncRequests = useCallback(() => {
latestAgentSessionSyncRequestIdRef.current += 1;
}, []);
const mergePendingAgentUserMessageIntoSession = useCallback(
(
session: CustomWorldAgentSessionSnapshot | null,
pending: PendingAgentUserMessage | null = pendingAgentUserMessageRef.current,
) => {
if (!session || !pending || pending.sessionId !== session.sessionId) {
return session;
}
const hasServerEchoedPendingMessage = session.messages.some(
(message) => message.id === pending.message.id,
);
if (hasServerEchoedPendingMessage) {
return session;
}
return {
...session,
messages: [...session.messages, pending.message],
updatedAt: pending.message.createdAt,
};
},
[],
);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
@@ -105,10 +152,26 @@ export function useRpgCreationSessionController(
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
setAgentSession(nextSession);
return nextSession;
}, []);
const mergedSession = mergePendingAgentUserMessageIntoSession(nextSession);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage = pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) => message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
}
}
return mergedSession;
}, [mergePendingAgentUserMessageIntoSession]);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
@@ -135,22 +198,26 @@ export function useRpgCreationSessionController(
useEffect(() => {
if (!activeAgentSessionId) {
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
return;
}
if (!userId) {
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentWorkspaceRestoreError(null);
return;
}
@@ -159,6 +226,15 @@ export function useRpgCreationSessionController(
const isInitialWorkspaceRestore =
isHydratingInitialAgentWorkspaceRef.current &&
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
if (currentAgentSessionIdRef.current !== activeAgentSessionId) {
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
}
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
@@ -204,6 +280,7 @@ export function useRpgCreationSessionController(
}, [
activeAgentSessionId,
enterCreateTab,
invalidateAgentSessionSyncRequests,
persistAgentUiState,
setSelectionStage,
syncAgentSessionSnapshot,
@@ -368,6 +445,17 @@ export function useRpgCreationSessionController(
setIsCreatingAgentSession(true);
setCreationTypeError(null);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
try {
const { session } = await createRpgCreationSession(
@@ -395,6 +483,7 @@ export function useRpgCreationSessionController(
},
[
enterCreateTab,
invalidateAgentSessionSyncRequests,
isCreatingAgentSession,
onSessionOpened,
persistAgentUiState,
@@ -404,7 +493,7 @@ export function useRpgCreationSessionController(
const submitAgentMessage = useCallback(
async (payload: SendCustomWorldAgentMessageRequest) => {
if (!activeAgentSessionId) {
if (!activeAgentSessionId || isStreamingAgentReply) {
return;
}
@@ -414,19 +503,18 @@ export function useRpgCreationSessionController(
kind: 'chat',
text: payload.text.trim(),
});
const pendingMessagePayload: PendingAgentUserMessage = {
sessionId: activeAgentSessionId,
message: optimisticUserMessage,
};
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setPendingAgentUserMessage(pendingMessagePayload);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload),
);
try {
@@ -439,38 +527,58 @@ export function useRpgCreationSessionController(
},
},
);
setAgentSession(nextSession);
const mergedNextSession = mergePendingAgentUserMessageIntoSession(
nextSession,
pendingMessagePayload,
);
setAgentSession(mergedNextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
const hasServerEchoedPendingMessage = nextSession.messages.some(
(message) => message.id === optimisticUserMessage.id,
);
setPendingAgentUserMessage(
hasServerEchoedPendingMessage ? null : pendingMessagePayload,
);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'发送共创消息失败。',
);
const warningMessage = buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
});
setAgentSession((current) =>
current
? {
...current,
messages: [
...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
{
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
},
);
setPendingAgentUserMessage(null);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
}
},
[activeAgentSessionId, persistAgentUiState],
[
activeAgentSessionId,
isStreamingAgentReply,
mergePendingAgentUserMessageIntoSession,
persistAgentUiState,
],
);
const executeAgentAction = useCallback(
@@ -532,6 +640,7 @@ export function useRpgCreationSessionController(
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);

View File

@@ -199,6 +199,11 @@ export function useRpgEntryLibraryDetail(
const openSavedCustomWorldEditor = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
setSelectedDetailEntry(entry);
resetAutoSaveTrackingToIdle();
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
entry.profile,
);
@@ -230,7 +235,9 @@ export function useRpgEntryLibraryDetail(
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
resetAutoSaveTrackingToIdle();
const shouldOpenAgentWorkspace =

View File

@@ -0,0 +1,53 @@
import { expect, test } from 'vitest';
import { readCreationAgentSessionFromSse } from './creationAgentSse';
function createChunkedStreamResponse(chunks: Uint8Array[]) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
},
});
}
test('readCreationAgentSessionFromSse flushes decoder tail and handles CRLF boundaries', async () => {
const encoder = new TextEncoder();
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
const replyTextBytes = encoder.encode('你好,潮雾列岛');
const suffix = encoder.encode(
'"}\r\n\r\nevent: session\r\ndata: {"session":{"sessionId":"session-1","title":"世界共创"}}\r\n\r\n',
);
const splitIndex = replyTextBytes.length - 1;
const chunks = [
new Uint8Array([...prefix, ...replyTextBytes.slice(0, splitIndex)]),
new Uint8Array([...replyTextBytes.slice(splitIndex), ...suffix]),
];
const updates: string[] = [];
const session = await readCreationAgentSessionFromSse<{
sessionId: string;
title: string;
}>(createChunkedStreamResponse(chunks), {
fallbackMessage: '发送失败',
incompleteMessage: '结果不完整',
onUpdate: (text) => {
updates.push(text);
},
});
expect(updates).toEqual(['你好,潮雾列岛']);
expect(session).toEqual({
sessionId: 'session-1',
title: '世界共创',
});
});

View File

@@ -6,6 +6,34 @@ type CreationAgentSseOptions<TSession> = TextStreamOptions & {
resolveSession?: (rawSession: unknown) => TSession | null;
};
function findSseEventBoundary(buffer: string) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
if (lfBoundary === -1 && crlfBoundary === -1) {
return null;
}
if (lfBoundary === -1) {
return {
index: crlfBoundary,
length: 4,
};
}
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
return {
index: lfBoundary,
length: 2,
};
}
return {
index: crlfBoundary,
length: 4,
};
}
function parseSseEventBlock(eventBlock: string) {
let eventName = 'message';
const dataLines: string[] = [];
@@ -62,10 +90,14 @@ export async function readCreationAgentSessionFromSse<TSession>(
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
@@ -97,6 +129,47 @@ export async function readCreationAgentSessionFromSse<TSession>(
}
}
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
buffer += decoder.decode();
for (;;) {
const boundary = findSseEventBoundary(buffer);
if (!boundary) {
break;
}
const eventBlock = buffer.slice(0, boundary.index);
buffer = buffer.slice(boundary.index + boundary.length);
const { eventName, data } = parseSseEventBlock(eventBlock);
if (!data) {
continue;
}
const parsed = parseJsonObject(data);
if (eventName === 'reply_delta' && parsed) {
const text = parsed.text;
if (typeof text === 'string') {
options.onUpdate?.(text);
}
continue;
}
if (eventName === 'session' && parsed?.session) {
finalSession = resolveSession(parsed.session);
continue;
}
if (eventName === 'error' && parsed) {
const message =
typeof parsed.message === 'string' && parsed.message.trim()
? parsed.message.trim()
: options.fallbackMessage;
throw new Error(message);
}
}
if (!finalSession) {
throw new Error(options.incompleteMessage);
}

View File

@@ -18,6 +18,7 @@ export type RpgCreationRuntimeRequestOptions = {
retry?: ApiRetryOptions;
skipAuth?: boolean;
skipRefresh?: boolean;
timeoutMs?: number;
};
export function requestRpgCreationRuntimeJson<T>(
@@ -42,6 +43,7 @@ export function requestRpgCreationRuntimeJson<T>(
retry,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
timeoutMs: options.timeoutMs,
},
);
}

8
验证清单.md Normal file
View File

@@ -0,0 +1,8 @@
# 验证清单
鉴于重构规模庞大而且有非常多的细节未能落地实现而是使用mock或者魔数来糊弄过去的而且测试也没有起到本应有的作用故此有个本清单用于手动验证每个环节。
- [x] 短信验证功能,要保证真实走到阿里云的服务上,你启动服务,我来通过前端输入手机号和验证码,你查看日志。`2026-04-23` 已通过。
- [ ] 创作中心里目前先保证RPG入口的畅通聊天引导部分需要真实接入到LLM不得使用固定模板来回答。
- [ ] 草稿的编译也需要真实走LLM去生成对应信息。
- [ ] 图片、视频、动作的生成要真实走到外部服务的生成服务上,而不是用占位符来敷衍。