Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 14:23:33 +08:00
50 changed files with 1908 additions and 270 deletions

View File

@@ -0,0 +1,24 @@
# 大鱼吃小鱼发布反馈修复 2026-04-26
## 背景
大鱼吃小鱼结果页的“发布”按钮已经会向后端发送 `big_fish_publish_game` action。后端发布成功后会把当前 Agent session 的 `stage` 改成 `published`,作品列表也会从 session 聚合出已发布作品。
问题出在前端发布成功后的反馈链路不完整:
1. 结果页没有把 `stage: published` 显示成“已发布”状态,用户点击后看起来没有变化。
2. 平台父层没有在大鱼发布成功后刷新“大鱼吃小鱼”作品列表,创作中心仍可能保留旧的草稿状态。
## 落地口径
1. `BigFishResultView``session.stage === 'published'` 作为已发布态真相。
2. 已发布态下发布按钮显示“已发布”并禁用,避免重复提交。
3. 已发布态下发布校验区显示“已发布”状态,继续保留资源完成度信息。
4. `PlatformEntryFlowShellImpl``big_fish_publish_game` 成功后刷新 `bigFishWorks`
5. 发布失败仍沿用既有错误模态,展示后端 `details.message` 里的具体校验原因。
## 验收
1. 在大鱼结果页点击“发布”会调用 `/api/runtime/big-fish/agent/sessions/{sessionId}/actions``big_fish_publish_game`
2. 后端返回已发布 session 后,结果页按钮变为“已发布”。
3. 返回创作中心后,该作品卡片状态通过刷新后的作品列表体现为已发布。

View File

@@ -30,3 +30,4 @@
- [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。

View File

@@ -147,7 +147,7 @@
## 6. HTTP contract
所有接口挂在 `/api/runtime/big-fish/*`,全部需要 Bearer 鉴权
所有接口挂在 `/api/runtime/big-fish/*`。创作、私有作品列表、删除、运行态启动与输入推进需要 Bearer 鉴权;公开广场读取接口不要求登录,只返回已发布作品
开发态本地链路补充约定:
@@ -191,12 +191,24 @@
2. `GET /api/runtime/big-fish/runs/{runId}`
3. `POST /api/runtime/big-fish/runs/{runId}/input`
运行态启动规则:
1. 当前用户启动自己未发布草稿时,`session.owner_user_id` 必须等于当前登录用户。
2. 当前用户启动别人作品时,只允许启动 `stage = published` 的公开作品。
3. 新建的 `big_fish_runtime_run.owner_user_id` 始终写入当前游玩用户,不能写入作品作者,后续 run 查询与输入推进仍按游玩用户隔离。
### 6.3 作品列表
1. `GET /api/runtime/big-fish/works`
开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node`
### 6.4 公开广场
1. `GET /api/runtime/big-fish/gallery`
公开广场只返回 `status = published` 的大鱼吃小鱼作品。响应复用 `BigFishWorksResponse`,每个条目必须包含 `ownerUserId`,供前端生成稳定广场卡片 key 与后续运行态权限判断。发布动作完成后,前端必须同时刷新私有作品列表和公开广场列表,保证发布结果能立即出现在首页与分类页。
`input` 请求体:
```json
@@ -242,6 +254,7 @@
2. 发送聊天、action 和摇杆输入。
3. 根据后端 snapshot 渲染实体。
4. 当后端 snapshot 返回 `won``failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。
5. 结算浮层必须提供可继续操作的出口:`failed` 至少包含“重来”和“退出”,`won` 至少包含“退出”。“重来”只能重新启动当前大鱼作品的一局后端 run不能在前端本地篡改旧 run snapshot“退出”回到当前作品结果页或直达入口的上级页面。
前端禁止:

View File

@@ -20,6 +20,7 @@
## 验收口径
1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。
2. 左下摇杆可移动玩家实体。
2. 屏幕任意位置按下并拖动可移动玩家实体。
3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。
4. 左上返回按钮在直达页语义为重开当前占位局。
4. 左上返回按钮在直达页语义为退出到平台首页。
5. 直达页通关或失败后,结算浮层继续复用正式运行态出口;失败态点击“重来”重开本地占位局,点击“退出”回到平台首页。

View File

@@ -0,0 +1,33 @@
# 大鱼吃小鱼运行页规则入口说明 2026-04-26
## 背景
大鱼吃小鱼玩法规则已经在 PRD 与运行态技术方案中定义,但网站运行页没有给玩家查看规则的入口。玩家进入 `/big-fish` 或正式运行页后,只能看到当前等级、状态和事件日志,无法在游玩前快速理解吞噬、合成、胜负条件。
## 设计结论
1. 规则入口放在运行页顶部操作区,使用 `CircleHelp` 图标按钮。
2. 默认界面不直接铺规则长文案,点击按钮后打开独立模态窗口。
3. 模态窗口只保留玩家决策所需的核心规则:
- 拖动方向控制移动。
- 吃掉低级或同级野生实体并收编。
- 碰到更高级野生实体时,己方实体会被吃掉。
- 3 个同级己方实体自动合成更高一级。
- 拥有最高等级后通关,己方实体归零后失败。
4. 入口必须在移动端单手可点,不遮挡舞台主体。
5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。
## 落地范围
1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx`
- 增加规则按钮与规则模态窗口。
- 复用 `UnifiedModal`,避免在当前玩法舞台内容流里展开说明。
2. `src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx`
- 覆盖规则入口打开与关闭。
## 验收口径
1. 进入大鱼吃小鱼运行页后,右上角可看到规则图标入口。
2. 点击规则入口后出现独立弹窗。
3. 弹窗能展示核心吞噬、合成、通关与失败规则。
4. 关闭弹窗后回到玩法舞台,不改变当前运行快照。

View File

@@ -12,6 +12,7 @@
- 正式主应用内部页面路径由 `src/routing/appPageRoutes.ts` 统一维护,不在组件里散落硬编码字符串。
- `/puzzle``/big-fish` 保持为玩法调试直达入口;正式链路中的拼图和大鱼运行页使用 `/runtime/puzzle``/runtime/big-fish`,避免语义冲突。
- 独立路径先解决页面阶段语义和浏览器前进后退;依赖运行中内存对象的详情页、结果页和运行页直接刷新后仍允许回退到平台首页或展示现有恢复态,不在本轮扩展资源 ID 深链加载。
- `sessionStorage` 里的 RPG Agent 恢复指针只能在当前路径属于 `/creation/rpg/*`,或 URL 显式携带 `customWorldSessionId / customWorldOperationId / customWorldGenerationSource` 时生效;刷新平台首页、分类页、作品详情页时不能被本地残留指针强制跳到 `/creation/rpg/agent`
## 页面路径表
@@ -46,3 +47,4 @@
2. 从页面内切换到结果页、运行页或返回首页时,浏览器路径同步更新。
3. 浏览器后退/前进能驱动 `selectionStage` 回到对应页面。
4. `/puzzle``/big-fish` 仍进入原有玩法调试直达页。
5. 仅有 `sessionStorage` 残留 RPG Agent 指针时,刷新 `/` 仍停留平台首页;刷新 `/creation/rpg/agent` 才恢复对应 Agent 工作区。

View File

@@ -8,7 +8,7 @@
1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。
2. `部署`:只负责把指定发布版本部署到 `/var/lib/jenkins/deploy/Genarrative/`,允许人工按参数启动,并支持按参数决定是否清空 SpacetimeDB 数据。
3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `80`,并透传是否清库
3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `25001`,并把同名端口参数继续透传给下游部署,部署阶段以该参数作为最终监听端口
本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。
@@ -24,6 +24,7 @@
8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。
9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。
10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。
11. `WEB_PORT` 必须在 `构建并部署``部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local``GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退。
## 3. 节点与工作区要求
@@ -85,6 +86,7 @@ jenkins/Jenkinsfile.deploy
scripts/jenkins-deploy-release.sh \
--source-dir <SOURCE_WORKSPACE_ROOT>/build/<BUILD_VERSION> \
--deploy-dir /var/lib/jenkins/deploy/Genarrative \
--web-port <WEB_PORT> \
[--clear-database] \
--hook-with-sudo
```
@@ -93,13 +95,13 @@ scripts/jenkins-deploy-release.sh \
1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`
2. 只删除发布产物白名单中的旧文件,例如 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md`
3. 将指定版本目录中的同名发布产物移动到部署目录。
4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c always`
3. 将指定版本目录中的同名发布产物复制到部署目录;文件产物使用普通复制,`web/` 等目录产物必须递归复制
4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c=on-conflict`
5. 执行新版本 `start.sh`
如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo否则部署会直接失败不会进入交互式密码提示。
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local` 会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF避免 `start.sh` 在 Bash 下把首行变量名误解析成命令
这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/``logs/``run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env``.env.local`以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local``GENARRATIVE_WEB_PORT`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份
### 4.3 构建并部署
@@ -115,12 +117,13 @@ jenkins/Jenkinsfile.build-and-deploy
2. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`
3. 归档 `build/<BUILD_VERSION>/**`
4. 记录当前 `NODE_NAME`、源码根目录、版本号。
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `80` 的发布包。
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包。
6. 触发 `部署` 流水线,并传递:
- `BUILD_VERSION`
- `SOURCE_WORKSPACE_ROOT`
- `SOURCE_NODE_NAME`
- `DEPLOY_DIRECTORY`
- `WEB_PORT`
- `CLEAR_DATABASE`
- `EXPECTED_UPSTREAM_JOB`
@@ -132,7 +135,7 @@ jenkins/Jenkinsfile.build-and-deploy
2. `GENARRATIVE_WORKSPACE_ROOT`:源码根目录;为空时回退到 Jenkins 当前工作区。
3. `BUILD_VERSION`:发布版本号;为空时回退到 `BUILD_NUMBER`
4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`
5. `WEB_PORT`发布包内静态网站监听端口;`构建并部署` 默认值为 `80`
5. `WEB_PORT`:静态网站监听端口;`构建并部署` 默认值为 `25001`,并通过下游 `部署` 同名参数作为最终启动端口
6. `CLEAR_DATABASE`:部署阶段是否清空 SpacetimeDB 数据后再发布 wasm默认 `false`
如果当前 Jenkins 没有额外配置独立 Agent而是直接在控制器自身执行任务`AGENT_LABEL` 应填写 `built-in`
@@ -147,6 +150,7 @@ jenkins/Jenkinsfile.build-and-deploy
4. `CLEAR_DATABASE`
5. `RUN_DEPLOY_HOOKS_WITH_SUDO`
6. `EXPECTED_UPSTREAM_JOB`
7. `WEB_PORT`
其中仅 `构建并部署` 流水线还需要:

View File

@@ -43,6 +43,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
- 玩法特有的生成进度只通过 `beforeExecuteAction``onActionError` 这类回调接入compile action 发起前切到独立生成页并初始化进度,失败时把进度置为 failed。
- compile action 成功后继续由通用控制器切到结果页,页面层只补齐生成资产数量、拼图操作记录、作品架与广场刷新等玩法差异。
- 离开玩法流程时,先清理运行态与生成进度态,再交给通用控制器恢复创作中心,避免流式回复和进度状态在下一次创作中残留。
- 生成进度页的阶段文本、资产完成文本、草稿写回文本只属于进度读模型和结果页状态,不属于 Agent 聊天历史。后端 compile / asset action 不再追加 `action_result` 聊天消息;前端聊天气泡只展示 `kind === "chat" | "summary" | "warning"` 的消息,历史会话中已经存在的 `action_result` 只作为兼容数据保留,不再渲染。
## 验收点
@@ -50,4 +51,5 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进
- 生成中可看到独立进度页,且进度步骤随 action 完成逐步推进。
- 拼图结果页打开时已有正式图;大鱼结果页打开时主图、动作和背景资产均已写入 `assetSlots`
- 前端点击生成草稿时不串行调用多个资产 action多阶段业务编排收敛在 `server-rs`
- 返回 Agent 工作区后,聊天区不出现“拼图结果页草稿已生成。”“本级主图已正式生成,可在结果页继续预览。”这类生成进度页状态消息。
- 不新增 server-node 依赖,不复活 legacy public 静态资产路径。

View File

@@ -4,13 +4,11 @@
## 文档列表
- [PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md](./PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md):记录拼图运行时拖动跟手延迟的前端根因,冻结 `requestAnimationFrame + DOM transform` 直写方案与不改玩法裁决边界
- [ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md](./ASSET_HISTORY_PUZZLE_COVER_KIND_FIX_2026-04-27.md):记录资产历史接口补齐 `puzzle_cover_image` 白名单、错误文案与回归测试的修复口径。
- [PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md](./PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md):记录拼图图片生成提示词从 `puzzle.rs` 拆到 `prompt/puzzle_image.rs` 的后端边界,保持 DashScope、OSS 与 SpacetimeDB 写回逻辑不变。
- [PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md](./PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md):记录拼图生成图写入 `/generated-puzzle-assets` 后必须同步补齐 Axum 旧资源代理与 Vite dev proxy避免结果页候选图和参考图读取链路失效。
- [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log``server ping`、端口监听和 root-dir 相关进程
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
- [RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md](./RPG_SCENE_ACT_BACK_ROW_RUNTIME_PRESENTATION_FIX_2026-04-26.md):记录多幕场景后排两个角色未进入幕预览和正式游戏画布的根因,冻结当前幕环境角色渲染、运行时场景 id 别名匹配与主角色优先相遇口径
- [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式
- [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。
- [SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md](./SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md):记录发布包 `start.sh` 执行 `spacetime publish` 遇到 `403 Forbidden` 的身份根因、`.spacetimedb/` root-dir 隔离修复和排查步骤。
- [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。
- [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。
- [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。

View File

@@ -142,7 +142,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/` 下;复制后统一移除 UTF-8 BOM 与 CRLF避免目标服务器 Bash 加载环境文件失败。
7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*``/generated-*``/healthz` 反代到本包内的 `api-server`
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env``.env.local`,兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
8. 在目标目录写入 `start.sh``stop.sh``start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env``.env.local`,兼容 UTF-8 BOM 与 CRLF再回退到构建时通过 `--database``--api-port``--web-port``--spacetime-host``--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1``CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr <host>:<port>`,探活必须确认 `server ping` 输出包含 `Server is online:`如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/<timestamp> ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。
发布包结构:
@@ -178,7 +178,7 @@ 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/` 这类运行态目录。
如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/``api-server``spacetime_module.wasm``.env*``start.sh``stop.sh``web-server.mjs``README.md` 等发布产物;文件产物使用普通复制,`web/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/``logs/``run/` 这类运行态目录。
安全边界:
@@ -187,8 +187,10 @@ cd build/<timestamp>
3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口。
4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发。
5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。
6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置
7.只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传
6. `start.sh` 会先复用已经按目标地址就绪的 SpacetimeDB如果同一个 `.spacetimedb/` root-dir 已被其他未就绪实例占用,则只输出命令名为 `spacetime``spacetimedb-cli` 且命令行包含当前 root-dir 的真实占用进程并失败,避免把排查用的 `grep` / `awk` 误判为 SpacetimeDB 实例
7.`spacetime publish``403 Forbidden`,优先确认 `spacetime --root-dir ./.spacetimedb login show` 输出的身份是否有权更新目标库;`--clear-database` 不能绕过身份授权
8. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。
9. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。
目标服务器最小要求:

View File

@@ -0,0 +1,75 @@
# start.sh SpacetimeDB 就绪前退出诊断补强
日期:`2026-04-27`
## 1. 问题
执行发布包内 `start.sh` 时,可能只看到:
```text
[start] 启动 spacetimedb
[start] SpacetimeDB 进程在就绪前退出。
```
这条信息只能说明 `spacetime start` 子进程在 `server ping` 判定就绪前退出,不能直接说明根因。真实错误通常已经写入发布目录下的 `logs/spacetimedb.log`
## 2. 常见根因
1. `GENARRATIVE_SPACETIME_PORT` 对应端口已被其他进程占用。
2. `.spacetimedb/` root-dir 权限不正确当前用户无法写入数据、bin 或日志目录。
3. 目标机 `spacetime` 安装不完整,发布包同步不到可执行的 `bin/current/spacetimedb-cli`
4. 目标机上的 `spacetime` 版本与脚本启动参数不兼容。
5. 旧 SpacetimeDB 进程仍持有同一 root-dir 或数据锁,但当前 `GENARRATIVE_SPACETIME_SERVER_URL` 指向的端口未就绪。
6. `.spacetimedb/bin/current/` 下只有 `spacetimedb-cli`,缺少 `spacetimedb-standalone`,日志会显示 `exec failed for .../spacetimedb-standalone`
## 3. 落地修复
发布包生成的 `start.sh` 调整为:
1. 等待循环先执行 `server ping`,再检查启动进程是否退出,避免目标服务刚好已就绪但启动包装进程已退出时误报。
2. `sync_ubuntu_spacetime_install` 不再只判断或复制 `spacetimedb-cli`,而是要求 `bin/current/spacetimedb-cli``bin/current/spacetimedb-standalone` 同时存在;从 Ubuntu 用户级安装目录同步时也复制完整版本目录。
3. 当 SpacetimeDB 进程提前退出或等待超时时,自动打印:
- `GENARRATIVE_SPACETIME_SERVER_URL` 对应的目标地址。
- `GENARRATIVE_SPACETIME_HOST:GENARRATIVE_SPACETIME_PORT` 对应的监听地址。
- 当前 `GENARRATIVE_SPACETIME_ROOT_DIR`
- `logs/spacetimedb.log` 最近 120 行。
- `spacetime server ping` 的原始输出。
- `ss``netstat` 中当前端口的监听情况。
- 同一 root-dir 下仍在运行的 SpacetimeDB 进程。
## 4. 现场排查
在发布目录中执行:
```bash
tail -n 120 logs/spacetimedb.log
spacetime --root-dir ./.spacetimedb server ping "${GENARRATIVE_SPACETIME_SERVER_URL:-http://127.0.0.1:3101}"
ss -ltnp | grep ':3101' || true
```
如果日志包含:
```text
exec failed for /var/lib/jenkins/deploy/Genarrative/.spacetimedb/bin/current/spacetimedb-standalone
No such file or directory (os error 2)
```
说明发布目录的 SpacetimeDB root-dir 中同步了 CLI但没有同步 standalone。现场可先执行
```bash
cd /var/lib/jenkins/deploy/Genarrative
SPACETIME_VERSION_DIR="$(find /usr/.local/share/spacetime/bin "$HOME/.local/share/spacetime/bin" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1)"
test -x "${SPACETIME_VERSION_DIR}/spacetimedb-cli"
test -x "${SPACETIME_VERSION_DIR}/spacetimedb-standalone"
rm -rf .spacetimedb/bin/current
mkdir -p .spacetimedb/bin/current
cp -a "${SPACETIME_VERSION_DIR}/." .spacetimedb/bin/current/
chmod +x .spacetimedb/bin/current/spacetimedb-cli .spacetimedb/bin/current/spacetimedb-standalone
./start.sh
```
如果日志显示端口占用,先确认占用者是否就是旧的 SpacetimeDB。需要复用时`GENARRATIVE_SPACETIME_PORT``GENARRATIVE_SPACETIME_SERVER_URL` 改成实际端口;需要重启时,优先执行同目录 `./stop.sh`
如果日志显示权限问题,先修复发布目录、`.spacetimedb/``logs/` 的所属用户,不要直接删除 `.spacetimedb/` 绕过问题。删除 `.spacetimedb/` 会同时影响本地 SpacetimeDB 数据与 CLI 身份,只能在确认本地库可丢弃时使用。
如果日志显示身份或 `403 Forbidden`,继续按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 处理;这类错误发生在发布阶段,不属于启动进程提前退出。

View File

@@ -0,0 +1,75 @@
# start.sh 发布 SpacetimeDB 遇到 403 的处理方案
日期:`2026-04-26`
## 1. 问题
执行发布包内 `start.sh` 时,`spacetime publish` 可能在 `Checking for breaking changes...` 后失败:
```text
Error: Pre-publish check failed with status 403 Forbidden: <identity> is not authorized to perform action on database <database-identity>: update database
```
这不是 wasm 构建失败,也不是 schema 冲突。错误含义是:当前 `spacetime` CLI 使用的身份无权更新目标数据库。
## 2. 根因
发布包 `start.sh` 会启动本地 SpacetimeDB再把当前包内的 `spacetime_module.wasm` 发布到 `GENARRATIVE_SPACETIME_DATABASE`
SpacetimeDB 的数据库更新权限绑定到创建或被授权的身份。只要出现以下情况之一,就会触发 403
1. 部署机上执行 `start.sh` 的用户切换过 `spacetime login` 身份。
2. 固定部署目录保留了旧 `.spacetimedb/`,但当前 CLI 身份不是旧数据库创建者。
3. `GENARRATIVE_SPACETIME_SERVER_URL` 指向 Maincloud而当前 CLI 身份不是该 Maincloud 数据库的所有者或授权成员。
4. `.env.local` 中的 `GENARRATIVE_SPACETIME_DATABASE` 指向了另一个环境的数据库名或数据库 identity。
## 3. 落地修复
发布包生成的 `start.sh` 使用发布目录下的 `.spacetimedb/` 作为 SpacetimeDB root
```bash
GENARRATIVE_SPACETIME_ROOT_DIR="${SCRIPT_DIR}/.spacetimedb"
```
启动、探活和发布统一使用:
```bash
spacetime --root-dir="${GENARRATIVE_SPACETIME_ROOT_DIR}" ...
```
`spacetime start` 不再额外设置 `--data-dir`,启动前会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin/<version>/spacetimedb-cli``$HOME/.local/share/spacetime/bin/<version>/spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`;当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`。启动参数、探活和 root-dir 占用判定都使用同一个 `.spacetimedb/`。这样可以把发布包与部署机全局 `~/.spacetime` 隔离,避免后续人工 `spacetime login` 影响本地发布包。但如果旧 `.spacetimedb/` 已经由另一个身份创建,仍需要按第 4 节处理。
## 4. 排查与处理
先在执行 `start.sh` 的同一台机器、同一用户下确认身份:
```bash
spacetime --root-dir ./.spacetimedb login show
spacetime --root-dir ./.spacetimedb list --server http://127.0.0.1:3101
```
如果目标是本地部署库,且允许清空本地数据:
```bash
./stop.sh
mv .spacetimedb ".spacetimedb.backup.$(date +%Y%m%d-%H%M%S)"
./start.sh
```
如果目标是本地部署库,但必须保留数据:
1. 不要删除 `.spacetimedb/`
2. 找到创建该数据库的 SpacetimeDB 身份。
3. 用该身份对应的 CLI root 执行发布,或在 SpacetimeDB 侧补授权后再发布。
如果目标是 Maincloud
1. 执行 `spacetime login show` 确认当前身份。
2. 确认该身份对 `GENARRATIVE_SPACETIME_DATABASE` 有更新权限。
3. 如果只是连错库,修正 `.env.local` 中的 `GENARRATIVE_SPACETIME_DATABASE` / `GENARRATIVE_SPACETIME_SERVER_URL`
## 5. 约束
1. `--clear-database` 只处理 schema 冲突时的数据清理,不会绕过 SpacetimeDB 身份授权。
2. 不要通过切回旧 `server-node` 或 PostgreSQL 绕过发布错误。
3. 前端与 `api-server` 的数据库名必须和 `start.sh` 发布的库名一致,否则后续接口会连到未发布或无权限的库。

View File

@@ -0,0 +1,44 @@
# start.sh root-dir 占用误报修复
日期:`2026-04-27`
## 1. 问题
执行发布包内 `start.sh` 时,可能出现:
```text
[start] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。
[start] 目标地址未就绪: http://127.0.0.1:3101
root 2119763 2119760 0 09:06 pts/1 00:00:00 grep -F /var/lib/jenkins/deploy/Genarrative/.spacetimedb
```
此时执行 `sudo kill 2119763` 会返回 `No such process`。这是正常现象,因为该 pid 是一次性 `grep` 进程,日志打印出来时已经退出。
## 2. 根因
旧的占用检测逻辑使用:
```bash
ps -ef | grep '[s]pacetime' | grep -F "${SPACETIME_ROOT_DIR}"
```
第一段 `grep '[s]pacetime'` 能避开自己的 `grep` 命令,但第二段 `grep -F "${SPACETIME_ROOT_DIR}"` 的命令行会包含 `.spacetimedb` 路径。由于 `.spacetimedb` 本身含有 `spacetime` 字符串,第一段过滤会把第二段 `grep` 也当成命中的进程,最终误判 root-dir 已被占用。
## 3. 修复
占用检测改为读取 `ps``comm``args` 字段,仅当同时满足以下条件时才输出占用进程:
1. 命令名是 `spacetime``spacetimedb-cli`
2. 完整命令行包含当前 `SPACETIME_ROOT_DIR`
这样既能定位真实持有同一 root-dir 的 SpacetimeDB 实例,也不会把 `grep``awk` 或其他排查命令误列为占用者。
## 4. 现场处理
如果日志里只看到 `grep -F .../.spacetimedb`,不要继续 kill 该 pid它不是残留 SpacetimeDB。更新发布包中的 `start.sh` 后重新执行启动即可。
如果新日志列出 `spacetime``spacetimedb-cli`,再按实际场景处理:
1. 如需复用,确认该进程监听的端口,并把 `GENARRATIVE_SPACETIME_PORT``GENARRATIVE_SPACETIME_SERVER_URL` 改为实际地址。
2. 如需重启,先执行同目录 `./stop.sh`;若该进程不是本目录脚本启动,再按列出的真实 pid 停止。
3. 不要删除 `.spacetimedb/` 来绕过身份或数据问题;涉及 403 身份错误时继续按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查。

View File

@@ -10,7 +10,7 @@ pipeline {
string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签')
string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区')
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
string(name: 'WEB_PORT', defaultValue: '80', description: '发布包内静态网站端口,默认 80')
string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001')
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm')
booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci')
string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名')
@@ -30,6 +30,22 @@ pipeline {
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
// 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
def webPort = params.WEB_PORT?.trim()
if (!webPort) {
error('WEB_PORT 不能为空。')
}
if (!(webPort ==~ /^[0-9]+$/)) {
error("WEB_PORT 必须是数字端口,当前值: ${webPort}")
}
if (webPort.length() > 5) {
error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}")
}
def parsedWebPort = webPort.toInteger()
if (parsedWebPort < 1 || parsedWebPort > 65535) {
error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}")
}
// 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。
env.EFFECTIVE_WEB_PORT = webPort
// 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。
env.SOURCE_NODE_NAME = env.NODE_NAME
}
@@ -57,8 +73,8 @@ pipeline {
sh """
bash -lc '
set -euo pipefail
# 构建并部署流水线显式透传 Web 端口,确保部署包默认监听 80,同时允许 Jenkins 参数覆盖。
npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" --web-port "${params.WEB_PORT}"
# 构建并部署流水线显式透传 Web 端口,确保部署包默认监听 25001,同时允许 Jenkins 参数覆盖。
npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" --web-port "${env.EFFECTIVE_WEB_PORT}"
test -d "build/${env.EFFECTIVE_BUILD_VERSION}"
'
"""
@@ -79,6 +95,7 @@ pipeline {
string(name: 'SOURCE_WORKSPACE_ROOT', value: env.WORKSPACE_ROOT),
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION),
string(name: 'DEPLOY_DIRECTORY', value: params.DEPLOY_DIRECTORY),
string(name: 'WEB_PORT', value: env.EFFECTIVE_WEB_PORT),
booleanParam(name: 'CLEAR_DATABASE', value: params.CLEAR_DATABASE),
booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', value: params.RUN_DEPLOY_HOOKS_WITH_SUDO),
string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME),

View File

@@ -11,6 +11,7 @@ pipeline {
string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录')
string(name: 'BUILD_VERSION', defaultValue: '', description: '待部署版本号')
string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录')
string(name: 'WEB_PORT', defaultValue: '25001', description: '静态网站监听端口,默认 25001上游构建并部署流水线会透传同名参数')
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm')
booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', defaultValue: true, description: 'start.sh / stop.sh 是否通过 sudo -n 执行')
string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名')
@@ -53,6 +54,26 @@ pipeline {
error('SOURCE_NODE_NAME 不能为空。')
}
def webPort = params.WEB_PORT?.trim()
if (!webPort) {
error('WEB_PORT 不能为空。')
}
if (!(webPort ==~ /^[0-9]+$/)) {
error("WEB_PORT 必须是数字端口,当前值: ${webPort}")
}
if (webPort.length() > 5) {
error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}")
}
def parsedWebPort = webPort.toInteger()
if (parsedWebPort < 1 || parsedWebPort > 65535) {
error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}")
}
// 部署脚本只接收校验后的端口值,避免手工参数前后空格传到 Bash。
env.EFFECTIVE_WEB_PORT = webPort
if (upstreamCause && !actualUpstreamJob?.trim()) {
error('无法从上游触发原因中解析作业名,请检查 Jenkins Pipeline Build Step 插件版本与触发链。')
}
@@ -85,6 +106,7 @@ pipeline {
deploy_args=(
--source-dir "build/${params.BUILD_VERSION}"
--deploy-dir "${params.DEPLOY_DIRECTORY}"
--web-port "${env.EFFECTIVE_WEB_PORT}"
)
if [[ "${params.CLEAR_DATABASE}" == "true" ]]; then
deploy_args+=(--clear-database)

View File

@@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published';
export interface BigFishWorkSummary {
workId: string;
sourceSessionId: string;
ownerUserId: string;
title: string;
subtitle: string;
summary: string;

View File

@@ -67,7 +67,7 @@ normalize_env_file() {
# 发布环境文件可能由 Windows 编辑器保存,启动脚本只接受无 BOM、无 CRLF 的 KEY=value 文本。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
cp "${temp_file}" "${env_file}"
}
copy_optional_file() {
@@ -518,11 +518,12 @@ done
load_env_file "${SCRIPT_DIR}/.env"
load_env_file "${SCRIPT_DIR}/.env.local"
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
SPACETIME_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}"
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}"
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}"
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}"
SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}"
API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}"
API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}"
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
@@ -543,19 +544,180 @@ require_command() {
}
wait_for_spacetime() {
local deadline=$((SECONDS + 60))
local process_pid="${1:-}"
local deadline=$((SECONDS + SPACETIME_TIMEOUT_SECONDS))
while ((SECONDS < deadline)); do
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
if is_spacetime_ready; then
return
fi
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
echo "[start] SpacetimeDB 进程在就绪前退出。" >&2
print_spacetime_start_diagnostics
exit 1
fi
sleep 0.5
done
if is_spacetime_ready; then
return
fi
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
print_spacetime_start_diagnostics
exit 1
}
is_spacetime_ready() {
local output
if ! output="$(spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" 2>&1)"; then
return 1
fi
# SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0不能只依赖退出码。
[[ "${output}" == *"Server is online:"* ]]
}
print_spacetime_start_diagnostics() {
local log_file="${LOG_DIR}/spacetimedb.log"
local root_owner=""
# SpacetimeDB 启动日志默认重定向到文件;失败时主动回显关键现场,避免只看到“就绪前退出”。
echo "[start] SpacetimeDB 启动诊断:" >&2
echo "[start] - server: ${SPACETIME_SERVER_URL}" >&2
echo "[start] - listen: ${SPACETIME_HOST}:${SPACETIME_PORT}" >&2
echo "[start] - root-dir: ${SPACETIME_ROOT_DIR}" >&2
echo "[start] - log: ${log_file}" >&2
if [[ -f "${log_file}" ]]; then
echo "[start] ${log_file} 最近 120 行:" >&2
tail -n 120 "${log_file}" >&2 || true
else
echo "[start] 尚未生成 ${log_file}" >&2
fi
echo "[start] server ping 结果:" >&2
spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" >&2 || true
if command -v ss >/dev/null 2>&1; then
echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2
ss -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true
elif command -v netstat >/dev/null 2>&1; then
echo "[start] ${SPACETIME_PORT} 端口监听检查:" >&2
netstat -ltnp 2>/dev/null | awk -v listen=":${SPACETIME_PORT}" 'NR == 1 || index($0, listen) > 0 { print }' >&2 || true
fi
root_owner="$(describe_spacetime_root_owner || true)"
if [[ -n "${root_owner}" ]]; then
echo "[start] root-dir 相关 SpacetimeDB 进程:" >&2
echo "${root_owner}" >&2
fi
}
describe_spacetime_root_owner() {
if command -v ps >/dev/null 2>&1; then
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${SPACETIME_ROOT_DIR}" '
{
user = $1
pid = $2
ppid = $3
stat = $4
command = $5
args = $0
sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args)
name = command
sub(/^.*\//, "", name)
# 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) {
print user " " pid " " ppid " " stat " " name " " args
}
}
' || true
fi
}
sync_ubuntu_spacetime_install() {
local root_dir="$1"
local target_bin_dir="${root_dir}/bin/current"
local target_cli="${target_bin_dir}/spacetimedb-cli"
local target_standalone="${target_bin_dir}/spacetimedb-standalone"
local spacetime_command=""
local resolved_command=""
local install_dir=""
local root_bin="${root_dir}/bin"
local parent_dir=""
local share_bin_dir=""
local version_dir=""
if [[ -x "${target_cli}" && -x "${target_standalone}" ]]; then
return
fi
spacetime_command="$(command -v spacetime || true)"
if [[ -z "${spacetime_command}" ]]; then
echo "[start] 缺少 spacetime 命令,无法同步 SpacetimeDB 安装。" >&2
exit 1
fi
resolved_command="${spacetime_command}"
if command -v readlink >/dev/null 2>&1; then
resolved_command="$(readlink -f "${spacetime_command}" 2>/dev/null || echo "${spacetime_command}")"
fi
install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)"
mkdir -p "${root_bin}"
for share_bin_dir in \
"/usr/.local/share/spacetime/bin" \
"${HOME:-}/.local/share/spacetime/bin"; do
if [[ -d "${share_bin_dir}" ]]; then
version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)"
if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" && -x "${version_dir}/spacetimedb-standalone" ]]; then
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${version_dir} -> ${target_bin_dir}"
rm -rf "${target_bin_dir}"
mkdir -p "${target_bin_dir}"
cp -a "${version_dir}/." "${target_bin_dir}/"
chmod +x "${target_cli}" "${target_standalone}"
return
fi
fi
done
if [[ -d "${install_dir}/bin" ]]; then
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}"
cp -a "${install_dir}/bin/." "${root_bin}/"
elif [[ -x "${install_dir}/current/spacetimedb-cli" ]]; then
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${root_bin}"
cp -a "${install_dir}/." "${root_bin}/"
elif [[ -x "${install_dir}/spacetimedb-cli" && -x "${install_dir}/spacetimedb-standalone" ]]; then
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${target_bin_dir}"
rm -rf "${target_bin_dir}"
mkdir -p "${target_bin_dir}"
cp -f "${install_dir}/spacetimedb-cli" "${target_cli}"
cp -f "${install_dir}/spacetimedb-standalone" "${target_standalone}"
chmod +x "${target_cli}" "${target_standalone}"
elif [[ -f "${resolved_command}" ]]; then
parent_dir="$(cd -- "${install_dir}/.." && pwd)"
if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" && -x "${parent_dir}/bin/current/spacetimedb-standalone" ]]; then
echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}"
cp -a "${parent_dir}/bin/." "${root_bin}/"
else
echo "[start] 未能从 spacetime 命令路径推断完整 SpacetimeDB 安装目录: ${resolved_command}" >&2
fi
fi
if [[ ! -x "${target_cli}" || ! -x "${target_standalone}" ]]; then
echo "[start] 同步 SpacetimeDB 安装后仍未找到完整 current 目录。" >&2
echo "[start] 需要同时存在: ${target_cli} 与 ${target_standalone}" >&2
echo "[start] 请确认 Ubuntu 上的 spacetime 安装目录包含 spacetimedb-cli 和 spacetimedb-standalone。" >&2
exit 1
fi
}
start_process() {
local name="$1"
shift
@@ -575,16 +737,32 @@ start_process() {
require_command node
require_command spacetime
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_ROOT_DIR}"
sync_ubuntu_spacetime_install "${SPACETIME_ROOT_DIR}"
start_process spacetimedb \
spacetime \
start \
--data-dir "${SPACETIME_DATA_DIR}" \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
--non-interactive
SPACETIME_PID=""
if is_spacetime_ready; then
echo "[start] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER_URL}"
else
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner)"
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
echo "[start] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
echo "[start] 目标地址未就绪: ${SPACETIME_SERVER_URL}" >&2
echo "[start] 如需复用,请把 GENARRATIVE_SPACETIME_PORT 改为占用实例实际端口;如需重启,请先停止下列进程。" >&2
echo "${SPACETIME_ROOT_OWNER}" >&2
exit 1
fi
wait_for_spacetime
start_process spacetimedb \
spacetime \
--root-dir="${SPACETIME_ROOT_DIR}" \
start \
--edition standalone \
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
SPACETIME_PID="$(cat "${PID_DIR}/spacetimedb.pid")"
fi
wait_for_spacetime "${SPACETIME_PID}"
PUBLISH_ARGS=(
publish
@@ -600,7 +778,15 @@ if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
fi
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
spacetime "${PUBLISH_ARGS[@]}"
if ! spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"; then
echo "[start] SpacetimeDB 发布失败。" >&2
echo "[start] 如果错误包含 403 Forbidden 或 is not authorized通常是当前 CLI 身份无权更新目标数据库。" >&2
echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2
spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true
echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh。" >&2
echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2
exit 1
fi
export GENARRATIVE_API_HOST="${API_HOST}"
export GENARRATIVE_API_PORT="${API_PORT}"
@@ -707,7 +893,8 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
- \`GENARRATIVE_SPACETIME_ROOT_DIR\`:默认使用发布目录下的 \`.spacetimedb/\`,同时承载本地 SpacetimeDB 运行数据与 CLI 身份。
- \`GENARRATIVE_SPACETIME_TIMEOUT_SECONDS\`:等待 SpacetimeDB 就绪的秒数,默认 \`60\`。
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
EOF

View File

@@ -124,7 +124,24 @@ Get-CimInstance Win32_Process |
fi
if command -v ps >/dev/null 2>&1; then
ps -ef 2>/dev/null | grep '[s]pacetime' | grep -F "${root_dir}" || true
ps -eo user=,pid=,ppid=,stat=,comm=,args= 2>/dev/null | awk -v root_dir="${root_dir}" '
{
user = $1
pid = $2
ppid = $3
stat = $4
command = $5
args = $0
sub(/^[[:space:]]*[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]+[^[:space:]]+[[:space:]]*/, "", args)
name = command
sub(/^.*\//, "", name)
# 只认真实的 SpacetimeDB 启动进程,避免 .spacetimedb 路径让 grep/awk 自身误命中。
if ((name == "spacetime" || name == "spacetimedb-cli") && index(args, root_dir) > 0) {
print user " " pid " " ppid " " stat " " name " " args
}
}
' || true
fi
}

View File

@@ -5,18 +5,19 @@ set -euo pipefail
usage() {
cat <<'EOF'
用法:
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative [--clear-database] [--hook-with-sudo]
./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--hook-with-sudo]
说明:
1. 如果部署目录已有旧版本且存在 stop.sh则先执行旧版本 stop.sh。
2. 仅删除并替换发布产物文件,保留部署目录中的运行数据目录。
3. 把指定发布目录中的内容覆盖到部署目录。
2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。
3. 把指定发布目录中的白名单产物复制覆盖到部署目录。
4. 如指定 --clear-database则以清库模式执行新版本 start.sh。
5. 最后执行新版本 start.sh。
参数:
--source-dir <path> 必填,待部署的发布目录,例如 build/123
--deploy-dir <path> 必填,固定部署目录,例如 /var/lib/jenkins/deploy/Genarrative
--web-port <port> 必填,本次部署后静态网站监听端口
--clear-database 可选,启动新版本时追加 --clear-database
--hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行
EOF
@@ -32,6 +33,28 @@ require_argument() {
fi
}
validate_port() {
local value="$1"
local label="$2"
local numeric_value
if [[ ! "${value}" =~ ^[0-9]+$ ]]; then
echo "[jenkins-deploy] ${label} 必须是数字端口: ${value}" >&2
exit 1
fi
if ((${#value} > 5)); then
echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2
exit 1
fi
numeric_value=$((10#${value}))
if ((numeric_value < 1 || numeric_value > 65535)); then
echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2
exit 1
fi
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
@@ -42,7 +65,7 @@ normalize_env_file() {
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF避免 start.sh 加载时报命令不存在。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
mv "${temp_file}" "${env_file}"
cp "${temp_file}" "${env_file}"
}
normalize_release_env_files() {
@@ -54,8 +77,34 @@ normalize_release_env_files() {
normalize_env_file "${release_dir}/web/.env.local"
}
write_env_override() {
local env_file="$1"
local key="$2"
local value="$3"
local temp_file="${env_file}.tmp.$$"
mkdir -p "$(dirname "${env_file}")"
if [[ -f "${env_file}" ]]; then
# 先移除旧的同名变量,再追加 Jenkins 本次部署参数,确保 sudo 启动时也能被 start.sh 读取。
awk -v target_key="${key}" '
BEGIN {
pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "="
}
$0 !~ pattern {
print
}
' "${env_file}" >"${temp_file}"
else
: >"${temp_file}"
fi
printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}"
cp "${temp_file}" "${env_file}"
}
SOURCE_DIR=""
DEPLOY_DIR=""
WEB_PORT=""
CLEAR_DATABASE="0"
HOOK_WITH_SUDO="0"
DEPLOY_ITEMS=(
@@ -84,6 +133,10 @@ while [[ $# -gt 0 ]]; do
DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}"
shift 2
;;
--web-port)
WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2
;;
--clear-database)
CLEAR_DATABASE="1"
shift
@@ -102,6 +155,8 @@ done
require_argument "${SOURCE_DIR}" "--source-dir"
require_argument "${DEPLOY_DIR}" "--deploy-dir"
require_argument "${WEB_PORT}" "--web-port"
validate_port "${WEB_PORT}" "--web-port"
run_hook() {
local hook_dir="$1"
@@ -164,11 +219,17 @@ for item in "${DEPLOY_ITEMS[@]}"; do
fi
done
echo "[jenkins-deploy] 移动发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
echo "[jenkins-deploy] 复制发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
for item in "${DEPLOY_ITEMS[@]}"; do
if [[ -e "${SOURCE_DIR}/${item}" ]]; then
source_item="${SOURCE_DIR}/${item}"
if [[ -e "${source_item}" ]]; then
echo "[jenkins-deploy] 覆盖产物: ${item}"
mv "${SOURCE_DIR}/${item}" "${DEPLOY_DIR}/"
# web 是目录产物,必须递归复制;文件产物保持普通复制,避免误扩大复制语义。
if [[ -d "${source_item}" ]]; then
cp -R "${source_item}" "${DEPLOY_DIR}/"
else
cp "${source_item}" "${DEPLOY_DIR}/"
fi
fi
done
@@ -179,6 +240,7 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
fi
normalize_release_env_files "${DEPLOY_DIR}"
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_WEB_PORT" "${WEB_PORT}"
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
if [[ "${CLEAR_DATABASE}" == "1" ]]; then

View File

@@ -34,8 +34,8 @@ use crate::{
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message,
submit_big_fish_input, submit_big_fish_message,
get_big_fish_session, get_big_fish_works, list_big_fish_gallery, start_big_fish_run,
stream_big_fish_message, submit_big_fish_input, submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -566,6 +566,7 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery))
.route(
"/api/runtime/big-fish/works/{session_id}",
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(

View File

@@ -144,6 +144,29 @@ pub async fn get_big_fish_works(
))
}
pub async fn list_big_fish_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, Response> {
let items = state
.spacetime_client()
.list_big_fish_gallery()
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn delete_big_fish_work(
State(state): State<AppState>,
Path(session_id): Path<String>,
@@ -919,6 +942,7 @@ fn map_big_fish_work_summary_response(
BigFishWorkSummaryResponse {
work_id: item.work_id,
source_session_id: item.source_session_id,
owner_user_id: item.owner_user_id,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,

View File

@@ -257,6 +257,7 @@ pub struct BigFishSessionProcedureResult {
pub struct BigFishWorkSummarySnapshot {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,
@@ -274,6 +275,7 @@ pub struct BigFishWorkSummarySnapshot {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -747,6 +749,9 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(),
}
pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> {
if input.published_only {
return Ok(());
}
if normalize_required_string(&input.owner_user_id).is_none() {
return Err(BigFishFieldError::MissingOwnerUserId);
}

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub struct BigFishWorkSummaryResponse {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,

View File

@@ -57,8 +57,28 @@ impl SpacetimeClient {
&self,
owner_user_id: String,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BigFishWorksListInput { owner_user_id };
let procedure_input = BigFishWorksListInput {
owner_user_id,
published_only: false,
};
self.list_big_fish_works_with_input(procedure_input).await
}
pub async fn list_big_fish_gallery(
&self,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.list_big_fish_works_with_input(BigFishWorksListInput {
owner_user_id: String::new(),
published_only: true,
})
.await
}
async fn list_big_fish_works_with_input(
&self,
procedure_input: BigFishWorksListInput,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| {
connection
.procedures()

View File

@@ -4590,6 +4590,7 @@ pub struct BigFishSessionRecord {
pub struct BigFishWorkSummaryRecord {
pub work_id: String,
pub source_session_id: String,
pub owner_user_id: String,
pub title: String,
pub subtitle: String,
pub summary: String,

View File

@@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[sats(crate = __lib)]
pub struct BigFishWorksListInput {
pub owner_user_id: String,
pub published_only: bool,
}
impl __sdk::InModule for BigFishWorksListInput {

View File

@@ -112,13 +112,6 @@ pub(crate) fn generate_big_fish_asset_tx(
updated_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-asset-{}", input.generated_at_micros),
reply,
input.generated_at_micros,
);
get_big_fish_session_tx(
ctx,

View File

@@ -77,8 +77,12 @@ fn start_big_fish_run_tx(
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if session.owner_user_id != input.owner_user_id
&& session.stage != BigFishCreationStage::Published
{
return Err("big_fish_creation_session 不存在".to_string());
}
let draft = session
.draft_json
.as_deref()
@@ -124,8 +128,12 @@ fn submit_big_fish_input_tx(
.big_fish_creation_session()
.session_id()
.find(&run.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
if session.owner_user_id != input.owner_user_id
&& session.stage != BigFishCreationStage::Published
{
return Err("big_fish_creation_session 不存在".to_string());
}
let draft = session
.draft_json
.as_deref()

View File

@@ -239,6 +239,10 @@ pub(crate) fn list_big_fish_works_tx(
.big_fish_creation_session()
.iter()
.filter(|row| {
if input.published_only {
return row.stage == BigFishCreationStage::Published;
}
row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row)
})
.map(|row| build_big_fish_work_summary(ctx, &row))
@@ -330,6 +334,7 @@ pub(crate) fn delete_big_fish_work_tx(
ctx,
BigFishWorksListInput {
owner_user_id: input.owner_user_id,
published_only: false,
},
)
}
@@ -538,13 +543,6 @@ pub(crate) fn compile_big_fish_draft_tx(
updated_at: compiled_at,
};
replace_big_fish_session(ctx, &session, next_session);
append_big_fish_system_message(
ctx,
&input.session_id,
format!("big-fish-message-compile-{}", input.compiled_at_micros),
reply,
input.compiled_at_micros,
);
get_big_fish_session_tx(
ctx,
@@ -649,6 +647,7 @@ pub(crate) fn build_big_fish_work_summary(
Ok(BigFishWorkSummarySnapshot {
work_id: format!("big-fish-work-{}", row.session_id),
source_session_id: row.session_id.clone(),
owner_user_id: row.owner_user_id.clone(),
title,
subtitle,
summary,
@@ -682,32 +681,6 @@ pub(crate) fn replace_big_fish_session(
ctx.db.big_fish_creation_session().insert(next);
}
pub(crate) fn append_big_fish_system_message(
ctx: &ReducerContext,
session_id: &str,
message_id: String,
text: String,
created_at_micros: i64,
) {
if ctx
.db
.big_fish_agent_message()
.message_id()
.find(&message_id)
.is_some()
{
return;
}
ctx.db.big_fish_agent_message().insert(BigFishAgentMessage {
message_id,
session_id: session_id.to_string(),
role: BigFishAgentMessageRole::Assistant,
kind: BigFishAgentMessageKind::ActionResult,
text,
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
});
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -659,12 +659,6 @@ fn compile_puzzle_agent_draft_tx(
updated_at: compiled_at,
},
);
append_system_message(
ctx,
&row.session_id,
input.compiled_at_micros,
"拼图结果页草稿已生成。",
)?;
get_puzzle_agent_session_tx(
ctx,
PuzzleAgentSessionGetInput {
@@ -1260,25 +1254,6 @@ fn build_puzzle_suggested_actions(
}
}
fn append_system_message(
ctx: &TxContext,
session_id: &str,
created_at_micros: i64,
text: &str,
) -> Result<(), String> {
let message_id = format!("{session_id}-system-{created_at_micros}");
ensure_message_missing(ctx, &message_id)?;
ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow {
message_id,
session_id: session_id.to_string(),
role: PuzzleAgentMessageRole::Assistant,
kind: PuzzleAgentMessageKind::ActionResult,
text: text.to_string(),
created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros),
});
Ok(())
}
fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> {
if ctx
.db

View File

@@ -208,11 +208,16 @@ export default function BigFishPlaygroundApp() {
setRun(buildInitialRun());
}, []);
const handleExit = useCallback(() => {
window.location.assign('/');
}, []);
return (
<BigFishRuntimeShell
run={run}
assetSlots={assetSlots}
onBack={handleRestart}
onBack={handleExit}
onRestart={handleRestart}
onSubmitInput={handleSubmitInput}
/>
);

View File

@@ -103,3 +103,31 @@ test('big fish workspace hides keyword fill before two turns', () => {
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
});
test('big fish workspace does not render progress action messages as chat bubbles', () => {
render(
<BigFishAgentWorkspace
session={{
...baseSession,
messages: [
...baseSession.messages,
{
id: 'message-action-result-1',
role: 'assistant',
kind: 'action_result',
text: '本级主图已正式生成,可在结果页继续预览。',
createdAt: '2026-04-24T10:01:00.000Z',
},
],
}}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.getByText('爽点和生态已经清楚,继续补剩余关键词。')).toBeTruthy();
expect(
screen.queryByText('本级主图已正式生成,可在结果页继续预览。'),
).toBeNull();
});

View File

@@ -52,6 +52,14 @@ function mapBigFishAnchor(
function mapBigFishSession(
session: BigFishSessionSnapshotResponse,
): CreationAgentSessionView {
// 中文注释:生成进度与资产完成记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
const chatMessages = session.messages.filter(
(message) =>
message.kind === 'chat' ||
message.kind === 'summary' ||
message.kind === 'warning',
);
return {
sessionId: session.sessionId,
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
@@ -65,7 +73,7 @@ function mapBigFishSession(
session.anchorPack.growthLadder,
session.anchorPack.riskTempo,
].map(mapBigFishAnchor),
messages: session.messages,
messages: chatMessages,
recommendedReplies: [],
};
}

View File

@@ -170,4 +170,35 @@ describe('BigFishResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onDismissError).toHaveBeenCalledTimes(1);
});
test('shows published state and prevents duplicate publish clicks', () => {
const onExecuteAction = vi.fn();
render(
<BigFishResultView
session={{
...createSession(),
stage: 'published',
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 1,
levelMotionReadyCount: 2,
backgroundReady: true,
requiredLevelCount: 1,
publishReady: true,
blockers: [],
},
}}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={() => {}}
/>,
);
const publishedButton = screen.getByRole('button', { name: '已发布' });
expect((publishedButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByText('已发布').length).toBeGreaterThan(0);
fireEvent.click(publishedButton);
expect(onExecuteAction).not.toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,7 @@ import {
Sparkles,
Waves,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -338,6 +338,7 @@ export function BigFishResultView({
}: BigFishResultViewProps) {
const [studioTarget, setStudioTarget] =
useState<BigFishAssetStudioTarget | null>(null);
const [isPublishSubmitting, setIsPublishSubmitting] = useState(false);
const draft = session.draft;
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
@@ -345,6 +346,8 @@ export function BigFishResultView({
() => session.assetCoverage.blockers.filter(Boolean),
[session.assetCoverage.blockers],
);
const isPublished = session.stage === 'published';
const canClickPublish = !isPublished && !isBusy;
const studioPreviewUrl = useMemo(() => {
if (!studioTarget) {
return null;
@@ -352,6 +355,12 @@ export function BigFishResultView({
return buildStudioAssetPreview(session.assetSlots, studioTarget);
}, [session.assetSlots, studioTarget]);
useEffect(() => {
if (!isBusy || isPublished || error) {
setIsPublishSubmitting(false);
}
}, [error, isBusy, isPublished]);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
@@ -388,14 +397,23 @@ export function BigFishResultView({
</button>
<button
type="button"
disabled={isBusy}
disabled={!canClickPublish}
onClick={() => {
setIsPublishSubmitting(true);
onExecuteAction({ action: 'big_fish_publish_game' });
}}
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<CheckCircle2 className="h-4 w-4" />
{isPublishSubmitting && isBusy && !isPublished ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{isPublished
? '已发布'
: isPublishSubmitting && isBusy
? '发布中'
: '发布'}
</button>
</div>
</div>
@@ -487,7 +505,11 @@ export function BigFishResultView({
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{blockers.length > 0 ? (
{isPublished ? (
<div className="mt-3 text-sm font-semibold text-emerald-600">
</div>
) : blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
{blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div>

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
function createRun(
status: BigFishRuntimeSnapshotResponse['status'],
): BigFishRuntimeSnapshotResponse {
return {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-1',
status,
tick: 18,
playerLevel: 2,
winLevel: 5,
leaderEntityId: null,
ownedEntities: [],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['己方鱼群已经耗尽'],
updatedAt: '2026-04-26T12:00:00.000Z',
};
}
describe('BigFishRuntimeShell', () => {
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
const onRestart = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('failed')}
onBack={onBack}
onRestart={onRestart}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重来' }));
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(screen.getByText('本轮失败')).toBeTruthy();
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('keeps an exit action after a won run', () => {
const onBack = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('won')}
onBack={onBack}
onSubmitInput={() => {}}
/>,
);
expect(screen.getByText('通关完成')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重来' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
test('opens and closes the runtime rule modal', () => {
render(
<BigFishRuntimeShell
run={createRun('running')}
onBack={() => {}}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看规则' }));
expect(screen.getByRole('dialog', { name: '玩法规则' })).toBeTruthy();
expect(screen.getByText('低级或同级野生实体会被收编。')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -7,6 +7,7 @@ import type {
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type TouchOrigin = {
@@ -21,6 +22,7 @@ type BigFishRuntimeShellProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRestart?: () => void;
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
};
@@ -126,6 +128,38 @@ function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) {
return null;
}
function BigFishRuleModal({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
return (
<UnifiedModal
open={open}
title="玩法规则"
onClose={onClose}
size="sm"
zIndexClassName="z-[140]"
panelClassName="rounded-[1.25rem]"
bodyClassName="px-4 py-3 sm:px-5 sm:py-4"
>
<div className="space-y-3 text-sm leading-6 text-[var(--platform-text-base)]">
<div className="rounded-2xl bg-cyan-50 px-4 py-3 text-cyan-950">
</div>
<div className="grid gap-2">
<div></div>
<div></div>
<div>3 </div>
<div></div>
</div>
</div>
</UnifiedModal>
);
}
function BigFishEntityDot({
entity,
run,
@@ -188,10 +222,12 @@ export function BigFishRuntimeShell({
isBusy = false,
error = null,
onBack,
onRestart,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
const [touchOrigin, setTouchOrigin] = useState<TouchOrigin | null>(null);
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -200,6 +236,10 @@ export function BigFishRuntimeShell({
}, [stick]);
useEffect(() => {
if (run?.status !== 'running') {
return undefined;
}
const timer = window.setInterval(() => {
const current = stickRef.current;
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
@@ -209,7 +249,7 @@ export function BigFishRuntimeShell({
return () => {
window.clearInterval(timer);
};
}, [onSubmitInput]);
}, [onSubmitInput, run?.status]);
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
@@ -291,8 +331,19 @@ export function BigFishRuntimeShell({
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
<div className="flex items-center gap-2">
<button
type="button"
aria-label="查看规则"
title="查看规则"
onClick={() => setIsRuleModalOpen(true)}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<CircleHelp className="h-4 w-4" />
</button>
<div className="rounded-full bg-black/28 px-4 py-2 text-xs font-bold backdrop-blur">
Lv.{run.playerLevel}/{run.winLevel} · {statusLabel}
</div>
</div>
</div>
@@ -318,16 +369,39 @@ export function BigFishRuntimeShell({
</div>
{settlementCopy ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
<div className="absolute inset-0 z-40 flex items-center justify-center px-5">
<div
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
className={`w-full max-w-[20rem] rounded-[1.5rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
>
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
<div className="text-3xl font-black text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
{settlementCopy.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
{settlementCopy.message}
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
{run.status === 'failed' && onRestart ? (
<button
type="button"
disabled={isBusy}
onClick={onRestart}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-bold text-slate-950 shadow-lg shadow-slate-950/20 disabled:opacity-45"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={onBack}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/30 bg-black/24 px-4 text-sm font-bold text-white backdrop-blur ${
run.status === 'failed' && onRestart ? '' : 'col-span-2'
}`}
>
<ArrowLeft className="h-4 w-4" />
退
</button>
</div>
</div>
</div>
) : null}
@@ -341,6 +415,10 @@ export function BigFishRuntimeShell({
</div>
))}
</div>
<BigFishRuleModal
open={isRuleModalOpen}
onClose={() => setIsRuleModalOpen(false)}
/>
</div>
</div>
);

View File

@@ -49,11 +49,15 @@ import {
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
} from '../../services/big-fish-runtime';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
deleteBigFishWork,
listBigFishWorks,
} from '../../services/big-fish-works';
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
import {
readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildBigFishGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress,
@@ -62,6 +66,10 @@ import {
type MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import {
isSameBigFishPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
@@ -79,7 +87,6 @@ import {
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import {
@@ -88,15 +95,17 @@ import {
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import {
isBigFishGalleryEntry,
isPuzzleGalleryEntry,
mapBigFishWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import {
isPuzzleGalleryEntry,
mapPuzzleWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
import {
@@ -110,8 +119,8 @@ import {
} from './platformEntryShared';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
import { usePlatformEntryNavigation } from './usePlatformEntryNavigation';
@@ -146,7 +155,12 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
}
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
function mergePlatformPublicGalleryEntries(
@@ -393,6 +407,9 @@ export function PlatformEntryFlowShellImpl({
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
BigFishWorkSummary[]
>([]);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
@@ -428,7 +445,8 @@ export function PlatformEntryFlowShellImpl({
>(null);
const hadReadableProtectedDataRef = useRef(false);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
readCustomWorldAgentUiState().activeSessionId &&
shouldRestoreCustomWorldAgentUiState(),
);
const platformBootstrap = usePlatformEntryBootstrap({
@@ -461,6 +479,64 @@ export function PlatformEntryFlowShellImpl({
[],
);
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
try {
const worksResponse = await listBigFishWorks();
setBigFishWorks(worksResponse.items);
setBigFishError(null);
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
);
} finally {
setIsBigFishLoadingLibrary(false);
}
}, [resolveBigFishErrorMessage]);
const refreshBigFishGallery = useCallback(async () => {
try {
const galleryResponse = await listBigFishGallery();
setBigFishGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setBigFishGalleryEntries([]);
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼广场失败。'),
);
return [];
}
}, [resolveBigFishErrorMessage]);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
setIsPuzzleLoadingLibrary(false);
}
}, [resolvePuzzleErrorMessage]);
const refreshPuzzleGallery = useCallback(async () => {
try {
const galleryResponse = await listPuzzleGallery();
setPuzzleGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
@@ -553,6 +629,7 @@ export function PlatformEntryFlowShellImpl({
await Promise.allSettled([
platformBootstrap.refreshPublishedGallery(),
platformBootstrap.refreshCustomWorldWorks(),
refreshBigFishGallery(),
refreshPuzzleGallery(),
]);
return latestSession;
@@ -607,21 +684,35 @@ export function PlatformEntryFlowShellImpl({
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(() => {
const bigFishPublicEntries = bigFishGalleryEntries.map(
mapBigFishWorkToPlatformGalleryCard,
);
const puzzlePublicEntries = puzzleGalleryEntries.map(
mapPuzzleWorkToPlatformGalleryCard,
);
return mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzlePublicEntries,
[...bigFishPublicEntries, ...puzzlePublicEntries],
).slice(0, 6);
}, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]);
}, [
bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
]);
const latestGalleryEntries = useMemo(
() =>
mergePlatformPublicGalleryEntries(
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
[
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard),
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
],
),
[platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries],
[
bigFishGalleryEntries,
platformBootstrap.publishedGalleryEntries,
puzzleGalleryEntries,
],
);
const creationHubItems =
@@ -681,50 +772,6 @@ export function PlatformEntryFlowShellImpl({
setShowCreationTypeModal(true);
}, [prepareCreationLaunch]);
const refreshBigFishShelf = useCallback(async () => {
setIsBigFishLoadingLibrary(true);
try {
const worksResponse = await listBigFishWorks();
setBigFishWorks(worksResponse.items);
setBigFishError(null);
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'),
);
} finally {
setIsBigFishLoadingLibrary(false);
}
}, [resolveBigFishErrorMessage]);
const refreshPuzzleShelf = useCallback(async () => {
setIsPuzzleLoadingLibrary(true);
try {
const worksResponse = await listPuzzleWorks();
setPuzzleWorks(worksResponse.items);
setPuzzleError(null);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
);
} finally {
setIsPuzzleLoadingLibrary(false);
}
}, [resolvePuzzleErrorMessage]);
const refreshPuzzleGallery = useCallback(async () => {
try {
const galleryResponse = await listPuzzleGallery();
setPuzzleGalleryEntries(galleryResponse.items);
return galleryResponse.items;
} catch (error) {
setPuzzleGalleryEntries([]);
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。'));
return [];
}
}, [resolvePuzzleErrorMessage]);
const bigFishFlow = usePlatformCreationAgentFlowController<
BigFishSessionSnapshotResponse,
Record<string, never>,
@@ -760,6 +807,10 @@ export function PlatformEntryFlowShellImpl({
},
onActionComplete: ({ payload, response, setSession }) => {
setSession(response.session);
if (payload.action === 'big_fish_publish_game') {
void refreshBigFishShelf();
void refreshBigFishGallery();
}
if (payload.action !== 'big_fish_compile_draft') {
return;
}
@@ -1080,6 +1131,34 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage,
]);
const restartBigFishRun = useCallback(async () => {
const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId;
if (!sessionId || isBigFishBusy) {
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
}, [
bigFishRun?.sessionId,
bigFishSession?.sessionId,
isBigFishBusy,
resolveBigFishErrorMessage,
setSelectionStage,
]);
const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => {
if (isPuzzleBusy) {
@@ -1157,7 +1236,11 @@ export function PlatformEntryFlowShellImpl({
const submitBigFishInput = useCallback(
(payload: SubmitBigFishInputRequest) => {
if (!bigFishRun || bigFishInputInFlightRef.current) {
if (
!bigFishRun ||
bigFishRun.status !== 'running' ||
bigFishInputInFlightRef.current
) {
return;
}
@@ -1437,8 +1520,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
void deleteBigFishWork(work.sourceSessionId)
.then((response) => {
.then(async (response) => {
setBigFishWorks(response.items);
await refreshBigFishGallery().catch(() => []);
})
.catch((error) => {
setBigFishError(
@@ -1450,7 +1534,12 @@ export function PlatformEntryFlowShellImpl({
});
});
},
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction],
[
deletingCreationWorkId,
refreshBigFishGallery,
resolveBigFishErrorMessage,
runProtectedAction,
],
);
const handleDeletePuzzleWork = useCallback(
@@ -1546,6 +1635,33 @@ export function PlatformEntryFlowShellImpl({
[openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError],
);
const startBigFishRunFromWork = useCallback(
async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { run } = await startBigFishRuntimeRun(sessionId);
bigFishFlow.setSession(null);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
@@ -1561,15 +1677,19 @@ export function PlatformEntryFlowShellImpl({
const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test(
normalizedKeyword,
);
const shouldSearchBigFishFirst = upperKeyword.startsWith('BF');
const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ');
const shouldSearchWorkFirst =
!shouldSearchUserIdFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchPuzzleFirst &&
(upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword));
const shouldSearchUserFirst =
shouldSearchUserIdFirst ||
upperKeyword.startsWith('SY') ||
(!shouldSearchWorkFirst && !shouldSearchPuzzleFirst);
(!shouldSearchWorkFirst &&
!shouldSearchBigFishFirst &&
!shouldSearchPuzzleFirst);
const tryOpenGalleryEntry = async () => {
const entry =
@@ -1609,6 +1729,21 @@ export function PlatformEntryFlowShellImpl({
tab: platformBootstrap.platformTab,
});
};
const tryOpenBigFishGalleryEntry = async () => {
const entries =
bigFishGalleryEntries.length > 0
? bigFishGalleryEntries
: await refreshBigFishGallery();
const matchedEntry = entries.find((entry) =>
isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId),
);
if (!matchedEntry) {
throw new Error('未找到大鱼吃小鱼作品。');
}
await startBigFishRunFromWork(matchedEntry);
};
try {
if (shouldSearchUserIdFirst) {
@@ -1622,11 +1757,18 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (shouldSearchBigFishFirst) {
await tryOpenBigFishGalleryEntry();
return;
}
if (shouldSearchWorkFirst) {
try {
await tryOpenGalleryEntry();
return;
} catch {}
} catch {
// 作品号优先时允许继续回退到用户号搜索。
}
}
if (shouldSearchUserFirst) {
@@ -1634,7 +1776,9 @@ export function PlatformEntryFlowShellImpl({
const user = await getPublicAuthUserByCode(normalizedKeyword);
setSearchedPublicUser(user);
return;
} catch {}
} catch {
// 用户号优先时允许继续回退到作品号搜索。
}
}
if (!shouldSearchWorkFirst) {
@@ -1654,10 +1798,13 @@ export function PlatformEntryFlowShellImpl({
},
[
detailNavigation,
bigFishGalleryEntries,
openPuzzleDetail,
platformBootstrap.platformTab,
puzzleGalleryEntries,
refreshBigFishGallery,
refreshPuzzleGallery,
startBigFishRunFromWork,
],
);
@@ -1674,39 +1821,12 @@ export function PlatformEntryFlowShellImpl({
[bigFishFlow, refreshBigFishShelf],
);
const startBigFishRunFromWork = useCallback(
async (item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setIsBigFishBusy(true);
setBigFishError(null);
try {
const { session } = await getBigFishCreationSession(sessionId);
const { run } = await startBigFishRuntimeRun(sessionId);
bigFishFlow.setSession(session);
setBigFishRun(run);
setSelectionStage('big-fish-runtime');
} catch (error) {
setBigFishError(
resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'),
);
} finally {
setIsBigFishBusy(false);
}
},
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
useEffect(() => {
if (selectionStage === 'platform') {
void refreshBigFishGallery();
void refreshPuzzleGallery();
}
}, [refreshPuzzleGallery, selectionStage]);
}, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]);
useEffect(() => {
if (
@@ -1879,6 +1999,33 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
if (isBigFishGalleryEntry(entry)) {
runProtectedAction(() => {
void startBigFishRunFromWork({
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishReady: true,
levelCount: Number.parseInt(
entry.themeTags
.find((tag) => /^\d+$/u.test(tag))
?.replace('级', '') ?? '0',
10,
),
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
});
});
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
@@ -2152,7 +2299,12 @@ export function PlatformEntryFlowShellImpl({
isBusy={isBigFishBusy}
error={bigFishError}
onBack={() => {
setSelectionStage('big-fish-result');
setSelectionStage(
bigFishSession ? 'big-fish-result' : 'platform',
);
}}
onRestart={() => {
void restartBigFishRun();
}}
onSubmitInput={submitBigFishInput}
/>
@@ -2168,7 +2320,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense fallback={<LazyPanelFallback label="正在加载拼图创作..." />}>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图创作..." />}
>
<PuzzleAgentWorkspace
session={puzzleSession}
activeOperation={puzzleOperation}
@@ -2241,7 +2395,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
>
<PuzzleResultView
session={puzzleSession}
isBusy={isPuzzleBusy}
@@ -2266,7 +2422,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense fallback={<LazyPanelFallback label="正在加载拼图详情..." />}>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图详情..." />}
>
<PuzzleGalleryDetailView
item={selectedPuzzleDetail}
isBusy={isPuzzleBusy}
@@ -2289,7 +2447,9 @@ export function PlatformEntryFlowShellImpl({
: null
}
onStartGame={() => {
void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId);
void startPuzzleRunFromProfile(
selectedPuzzleDetail.profileId,
);
}}
/>
</Suspense>
@@ -2304,7 +2464,9 @@ export function PlatformEntryFlowShellImpl({
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100]"
>
<Suspense fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}>
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}

View File

@@ -102,3 +102,29 @@ test('puzzle workspace hides keyword fill before two turns', () => {
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
});
test('puzzle workspace does not render progress action messages as chat bubbles', () => {
render(
<PuzzleAgentWorkspace
session={{
...baseSession,
messages: [
...baseSession.messages,
{
id: 'message-action-result-1',
role: 'assistant',
kind: 'action_result',
text: '拼图结果页草稿已生成。',
createdAt: '2026-04-24T10:01:00.000Z',
},
],
}}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy();
expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull();
});

View File

@@ -44,6 +44,14 @@ const PUZZLE_AGENT_THEME: CreationAgentTheme = {
function mapPuzzleSession(
session: PuzzleAgentSessionSnapshot,
): CreationAgentSessionView {
// 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。
const chatMessages = session.messages.filter(
(message) =>
message.kind === 'chat' ||
message.kind === 'summary' ||
message.kind === 'warning',
);
return {
sessionId: session.sessionId,
// 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。
@@ -58,7 +66,7 @@ function mapPuzzleSession(
session.anchorPack.compositionHooks,
session.anchorPack.tagsAndForbidden,
],
messages: session.messages,
messages: chatMessages,
recommendedReplies: [],
};
}

View File

@@ -6,36 +6,18 @@ import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import type { AuthUser } from '../../services/authService';
import { ApiClientError } from '../../services/apiClient';
import {
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
getRpgEntryWorldGalleryDetail,
getRpgProfileDashboard as getProfileDashboard,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory as listProfileBrowseHistory,
listRpgProfileSaveArchives as listProfileSaveArchives,
publishRpgEntryWorldProfile,
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
unpublishRpgEntryWorldProfile,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import type { AuthUser } from '../../services/authService';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import { startBigFishRuntimeRun } from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
createPuzzleAgentSession,
@@ -46,6 +28,26 @@ import {
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { listPuzzleWorks } from '../../services/puzzle-works';
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import {
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
getRpgEntryWorldGalleryDetail,
getRpgProfileDashboard as getProfileDashboard,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory as listProfileBrowseHistory,
listRpgProfileSaveArchives as listProfileSaveArchives,
resumeRpgProfileSaveArchive as resumeProfileSaveArchive,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
@@ -146,6 +148,15 @@ vi.mock('../../services/big-fish-works', () => ({
listBigFishWorks: vi.fn(),
}));
vi.mock('../../services/big-fish-gallery', () => ({
listBigFishGallery: vi.fn(),
}));
vi.mock('../../services/big-fish-runtime', () => ({
startBigFishRuntimeRun: vi.fn(),
submitBigFishRuntimeInput: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(),
@@ -153,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({
streamPuzzleAgentMessage: vi.fn(),
}));
vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
PuzzleAgentWorkspace: ({
session,
onBack,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
onBack: () => void;
}) => (
<div className="puzzle-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
session,
onBack,
}: {
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
<div className="puzzle-result-view-mock">
<div></div>
<label>
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({
PuzzleGalleryDetailView: ({
item,
onBack,
onStartGame,
}: {
item: { levelName: string };
onBack: () => void;
onStartGame: () => void;
}) => (
<div className="puzzle-gallery-detail-view-mock">
<div>{item.levelName}</div>
<button type="button" onClick={onStartGame}>
1
</button>
<button type="button" onClick={onBack}>
</button>
</div>
),
}));
vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({
BigFishAgentWorkspace: ({
session,
@@ -172,13 +246,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
BigFishResultView: ({
session,
onBack,
onExecuteAction,
}: {
session: { draft?: { title: string } | null };
onBack: () => void;
onExecuteAction: (payload: { action: string }) => void;
}) => (
<div className="big-fish-result-view-mock">
<div></div>
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
<button
type="button"
onClick={() => {
onExecuteAction({ action: 'big_fish_publish_game' });
}}
>
</button>
<button type="button" onClick={onBack}>
</button>
@@ -223,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = {
'玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。',
coreConflict:
'守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。',
keyRelationships:
'玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。',
hiddenLines:
'沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。',
iconicElements:
@@ -947,6 +1030,34 @@ beforeEach(() => {
vi.mocked(listBigFishWorks).mockResolvedValue({
items: [],
});
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [],
});
vi.mocked(startBigFishRuntimeRun).mockResolvedValue({
run: {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-public-1',
status: 'running',
tick: 0,
playerLevel: 1,
winLevel: 8,
leaderEntityId: 'owned-1',
ownedEntities: [
{
entityId: 'owned-1',
level: 1,
position: { x: 0, y: 0 },
radius: 12,
offscreenSeconds: 0,
},
],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['机械鱼群开始巡游。'],
updatedAt: '2026-04-25T12:12:00.000Z',
},
});
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
@@ -1320,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat
{
workId: 'big-fish-logout-cache-1',
sourceSessionId: 'big-fish-logout-cache-session',
ownerUserId: 'user-1',
title: '大鱼退出缓存作品',
subtitle: '登出后不应继续可见',
summary: '这条大鱼私有作品只能在登录态展示。',
@@ -1418,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async
).toBeGreaterThan(0);
});
test('published big fish works appear on home and category public shelves', async () => {
const user = userEvent.setup();
const publishedBigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [publishedBigFishWork],
});
render(<TestWrapper />);
await waitFor(() => {
expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan(
0,
);
});
await user.click(screen.getByRole('button', { name: '分类' }));
const categoryPanel = getPlatformTabPanel('category');
expect(
within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length,
).toBeGreaterThan(0);
expect(
within(categoryPanel).getAllByRole('button', { name: //u }).length,
).toBeGreaterThan(0);
});
test('published puzzle detail returns to the source platform tab', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
@@ -1547,6 +1701,47 @@ test('restoring an agent workspace ignores a stored session owned by another use
expect(window.location.search).toBe('');
});
test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => {
window.sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
JSON.stringify({
activeSessionId: 'custom-world-agent-session-1',
activeOperationId: null,
ownerUserId: 'user-1',
}),
);
render(<TestWrapper withAuth />);
expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(getRpgCreationSession).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/');
});
test('refreshing RPG agent path restores stored agent workspace pointer', async () => {
window.history.replaceState(null, '', '/creation/rpg/agent');
window.sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
JSON.stringify({
activeSessionId: 'custom-world-agent-session-1',
activeOperationId: null,
ownerUserId: 'user-1',
}),
);
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(getRpgCreationSession).toHaveBeenCalledWith(
'custom-world-agent-session-1',
);
});
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
@@ -1747,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => {
render(<TestWrapper withAuth />);
const searchInput =
await screen.findByPlaceholderText('输入 SY / CW / PZ 编号');
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'PZ-EPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
@@ -1762,6 +1958,49 @@ test('public code search opens a published puzzle by PZ code', async () => {
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens a published big fish work by BF code', async () => {
const user = userEvent.setup();
const bigFishWork: BigFishWorkSummary = {
workId: 'big-fish-work-public-1',
sourceSessionId: 'big-fish-session-public-1',
ownerUserId: 'user-2',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化',
summary: '从微光孢子一路吞并成长到深海巨鲲。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-25T10:30:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
};
vi.mocked(listBigFishGallery).mockResolvedValue({
items: [bigFishWork],
});
render(<TestWrapper withAuth />);
const searchInput = await screen.findByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'BF-NPUBLIC1');
await user.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(startBigFishRuntimeRun).toHaveBeenCalledWith(
'big-fish-session-public-1',
);
});
expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy();
expect(getBigFishCreationSession).not.toHaveBeenCalledWith(
'big-fish-session-public-1',
);
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('big fish draft card restores the bound agent session and opens the result view', async () => {
const user = userEvent.setup();
@@ -1770,6 +2009,7 @@ test('big fish draft card restores the bound agent session and opens the result
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
@@ -1815,6 +2055,109 @@ test('big fish draft card restores the bound agent session and opens the result
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
});
test('big fish result publish action refreshes creation works', async () => {
const user = userEvent.setup();
const baseBigFishSession = (
await getBigFishCreationSession('big-fish-session-1')
).session;
vi.mocked(getBigFishCreationSession).mockClear();
vi.mocked(listBigFishWorks).mockClear();
vi.mocked(listBigFishGallery).mockClear();
const publishedBigFishSession = {
...baseBigFishSession,
stage: 'published',
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
requiredLevelCount: 8,
publishReady: true,
blockers: [],
},
updatedAt: '2026-04-22T12:20:00.000Z',
};
vi.mocked(executeBigFishCreationAction).mockResolvedValue({
session: publishedBigFishSession,
});
vi.mocked(listBigFishWorks)
.mockResolvedValueOnce({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
})
.mockResolvedValue({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-22T12:20:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
const title = await screen.findByText('机械深海 大鱼吃小鱼');
const card = title.closest('.platform-surface');
if (!(card instanceof HTMLElement)) {
throw new Error('Missing big fish draft card');
}
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
'big-fish-session-1',
);
});
vi.mocked(listBigFishWorks).mockClear();
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '发布' }));
await waitFor(() => {
expect(executeBigFishCreationAction).toHaveBeenCalledWith(
'big-fish-session-1',
{
action: 'big_fish_publish_game',
},
);
});
await waitFor(() => {
expect(listBigFishWorks).toHaveBeenCalled();
});
await waitFor(() => {
expect(listBigFishGallery).toHaveBeenCalled();
});
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();

View File

@@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
import {
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
@@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => {
</AuthUiContext.Provider>,
);
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
const searchInput = screen.getByPlaceholderText(
'输入 SY / CW / BF / PZ 编号',
);
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
@@ -359,8 +364,9 @@ test('public gallery cards hide work code until detail is opened', async () => {
});
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
.toBeNull();
expect(
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));

View File

@@ -56,6 +56,7 @@ import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
isBigFishGalleryEntry,
isPuzzleGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
@@ -223,7 +224,7 @@ function PublicCodeSearchBar({
onSubmit();
}
}}
placeholder="输入 SY / CW / PZ 编号"
placeholder="输入 SY / CW / BF / PZ 编号"
className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
@@ -665,9 +666,11 @@ function DesktopTrendingItem({
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode)}
{isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode)}
</span>
)}
</div>
@@ -714,13 +717,20 @@ function buildPublicCategoryGroups(
}
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`;
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
return isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
return isBigFishGalleryEntry(entry)
? '大鱼'
: isPuzzleGalleryEntry(entry)
? '拼图'
: describePlatformThemeLabel(entry.themeMode);
}
function formatSnapshotTime(value: string | null | undefined) {

View File

@@ -2,15 +2,20 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformBigFishGalleryCard
| PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = {
@@ -30,8 +35,26 @@ export type PlatformPuzzleGalleryCard = {
updatedAt: string;
};
export type PlatformBigFishGalleryCard = {
sourceType: 'big-fish';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
| PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry(
@@ -46,6 +69,12 @@ export function isPuzzleGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'puzzle';
}
export function isBigFishGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformBigFishGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'big-fish';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -67,6 +96,27 @@ export function mapPuzzleWorkToPlatformGalleryCard(
};
}
export function mapBigFishWorkToPlatformGalleryCard(
work: BigFishWorkSummary,
): PlatformBigFishGalleryCard {
return {
sourceType: 'big-fish',
workId: work.workId,
profileId: work.sourceSessionId,
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
ownerUserId: work.ownerUserId,
authorDisplayName: '大鱼创作者',
worldName: work.title,
subtitle: work.subtitle || '大鱼吃小鱼',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
visibility: 'published',
publishedAt: work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;
@@ -88,6 +138,10 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
}
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isBigFishGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
}
if (isPuzzleGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
@@ -128,6 +182,10 @@ export function formatPlatformWorldTime(value: string | null) {
export function resolvePlatformPublicWorkCode(
entry: PlatformWorldCardLike,
): string | null {
if (isBigFishGalleryEntry(entry)) {
return entry.publicWorkCode;
}
if (isPuzzleGalleryEntry(entry)) {
return entry.publicWorkCode;
}

View File

@@ -15,6 +15,7 @@ import {
} from '../../services/customWorldAgentGenerationProgress';
import {
readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
@@ -66,12 +67,16 @@ export function useRpgCreationSessionController(
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const shouldRestoreInitialAgentUiStateRef = useRef(
shouldRestoreCustomWorldAgentUiState(),
);
const isInitialAgentUiStateOwnedByCurrentUser =
!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId;
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(
initialAgentUiStateRef.current.activeSessionId &&
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser,
),
);
@@ -88,6 +93,7 @@ export function useRpgCreationSessionController(
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
string | null
>(() =>
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeSessionId ?? null)
: null,
@@ -95,6 +101,7 @@ export function useRpgCreationSessionController(
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
string | null
>(() =>
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeOperationId ?? null)
: null,
@@ -209,7 +216,25 @@ export function useRpgCreationSessionController(
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
if (
!initialAgentSessionId ||
hasAppliedInitialAgentWorkspaceRef.current
) {
return;
}
if (
initialAgentUiStateRef.current.ownerUserId &&
userId &&
initialAgentUiStateRef.current.ownerUserId !== userId
) {
hasAppliedInitialAgentWorkspaceRef.current = true;
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
return;
}
if (!shouldRestoreInitialAgentUiStateRef.current) {
return;
}
@@ -781,7 +806,10 @@ export function useRpgCreationSessionController(
}, []);
return {
initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null,
initialAgentSessionId:
shouldRestoreInitialAgentUiStateRef.current
? (initialAgentUiStateRef.current.activeSessionId ?? null)
: null,
isCreatingAgentSession,
activeAgentSessionId,
activeAgentOperationId,

View File

@@ -0,0 +1,29 @@
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const BIG_FISH_GALLERY_API_BASE = '/api/runtime/big-fish/gallery';
const BIG_FISH_GALLERY_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
/**
* 读取大鱼吃小鱼公开广场列表。
*/
export async function listBigFishGallery() {
return requestJson<BigFishWorksResponse>(
BIG_FISH_GALLERY_API_BASE,
{
method: 'GET',
},
'读取大鱼吃小鱼广场失败',
{
retry: BIG_FISH_GALLERY_READ_RETRY,
},
);
}
export const bigFishGalleryClient = {
list: listBigFishGallery,
};

View File

@@ -0,0 +1,4 @@
export {
bigFishGalleryClient,
listBigFishGallery,
} from './bigFishGalleryClient';

View File

@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
import {
clearCustomWorldAgentUiState,
readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from './customWorldAgentUiState';
@@ -73,3 +74,49 @@ test('custom world agent ui state reads from query first and persists to session
clearCustomWorldAgentUiState(env);
expect(readCustomWorldAgentUiState(env)).toEqual({});
});
test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => {
const sessionStorage = createMemoryStorage();
sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
JSON.stringify({
activeSessionId: 'session-1',
ownerUserId: 'user-1',
}),
);
expect(
shouldRestoreCustomWorldAgentUiState({
location: {
pathname: '/',
search: '',
},
history: null,
sessionStorage,
}),
).toBe(false);
expect(
shouldRestoreCustomWorldAgentUiState({
location: {
pathname: '/creation/rpg/agent',
search: '',
},
history: null,
sessionStorage,
}),
).toBe(true);
});
test('custom world agent ui state restores explicit query pointers on any main path', () => {
expect(
shouldRestoreCustomWorldAgentUiState({
location: {
pathname: '/',
search: '?customWorldSessionId=session-1',
},
history: null,
sessionStorage: createMemoryStorage(),
}),
).toBe(true);
});

View File

@@ -56,6 +56,49 @@ function normalizeGenerationSource(value: unknown) {
return value === 'agent-draft-foundation' ? value : null;
}
function hasExplicitAgentUiStateQuery(
params: URLSearchParams,
) {
return (
params.has(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY) ||
params.has(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY) ||
params.has(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY)
);
}
function normalizePathname(value: string | undefined) {
const pathname = value?.trim().toLowerCase() ?? '';
if (!pathname || pathname === '/') {
return '/';
}
return pathname.replace(/\/+$/u, '');
}
function isRpgCreationRestorePath(pathname: string | undefined) {
const normalizedPathname = normalizePathname(pathname);
return (
normalizedPathname === '/creation/rpg' ||
normalizedPathname.startsWith('/creation/rpg/')
);
}
export function shouldRestoreCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
) {
const resolved = resolveEnvironment(env);
const params = new URLSearchParams(resolved.location?.search ?? '');
// URL 显式恢复参数优先于当前路径,用于支持外部分享或登录回跳后的深链恢复。
if (hasExplicitAgentUiStateQuery(params)) {
return true;
}
// sessionStorage 里的残留指针只能在 RPG 创作页面生效,
// 避免刷新平台首页时被旧工作区状态强制带到 Agent 页面。
return isRpgCreationRestorePath(resolved.location?.pathname);
}
export function readCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
): CustomWorldAgentUiState {

View File

@@ -13,6 +13,14 @@ export function buildPuzzlePublicWorkCode(profileId: string) {
return `PZ-${suffix}`;
}
export function buildBigFishPublicWorkCode(sessionId: string) {
const normalized = normalizePublicCodeText(sessionId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `BF-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -22,3 +30,16 @@ export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBigFishPublicWorkCode(
keyword: string,
sessionId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildBigFishPublicWorkCode(sessionId)) ||
normalizedKeyword === normalizePublicCodeText(sessionId)
);
}