From 1c16152708789912170e866b8f6955050e00c6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sun, 10 May 2026 13:18:46 +0800 Subject: [PATCH] 1 --- .env.local | 5 +- .hermes/shared-memory/pitfalls.md | 24 ++ ..._DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md | 3 + ...STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md | 74 ++++ .../PUZZLE_FORM_CREATION_FLOW_2026-04-29.md | 2 +- docs/technical/README.md | 1 + ...NGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md | 20 +- ...SPEECH_STREAMING_INTEGRATION_2026-05-08.md | 2 +- scripts/dev-rust-stack.sh | 102 ++++- server-rs/crates/api-server/src/puzzle.rs | 324 ++++++++++++-- server-rs/crates/platform-speech/src/lib.rs | 2 +- .../PuzzleAgentWorkspace.interaction.test.tsx | 37 ++ .../puzzle-result/PuzzleResultView.test.tsx | 39 ++ .../puzzle-result/PuzzleResultView.tsx | 13 +- .../RpgEntryHomeView.recharge.test.tsx | 136 ++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 406 +++++++++++++++--- src/index.css | 106 ++++- 17 files changed, 1197 insertions(+), 99 deletions(-) create mode 100644 docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md diff --git a/.env.local b/.env.local index 91845816..7635ae86 100644 --- a/.env.local +++ b/.env.local @@ -60,8 +60,9 @@ ALIYUN_OSS_ACCESS_KEY_ID="LTAI5t7aiyw6uDFW4miJvU8f" ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" # Local Rust backend target for Vite dev proxy. -RUST_SERVER_TARGET="http://127.0.0.1:3100" -GENARRATIVE_API_TARGET="http://127.0.0.1:3100" +RUST_SERVER_TARGET="http://127.0.0.1:8082" +GENARRATIVE_API_TARGET="http://127.0.0.1:8082" +GENARRATIVE_API_PORT="8082" GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101" GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr" diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index b6f17e65..c7cafbb6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -51,6 +51,22 @@ - 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。 - 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`、`server-rs/crates/api-server/src/openai_image_generation.rs`。 +## 拼图参考图没有影响生成时先查 action payload 和阶段日志 + +- 现象:拼图上传参考图后生成出的画面明显不像参考图,或结果页重新生成没有按保存的参考图走图生图。 +- 原因:首图生成只通过 `compile_puzzle_draft.referenceImageSrc` 临时传 Data URL,不持久化到 SpacetimeDB;结果页重新生成则要把当前上传图或关卡 `pictureReference` 作为 `generate_puzzle_images.referenceImageSrc` 继续传给后端。 +- 处理:浏览器 Network 里确认 action payload 带 `referenceImageSrc`;api-server 日志按同一 `session_id` 查看 `拼图参考图解析完成`、`拼图 VectorEngine 图片生成 HTTP 返回`、`拼图 VectorEngine 图片下载完成`、`拼图生成图片已写入 OSS 与资产索引`,可定位慢在参考图读取、VectorEngine、下载或 OSS。 +- 验证:前端测试覆盖上传图 + AI 重绘、结果页保存的 `pictureReference` 重新生成;后端单测覆盖 VectorEngine 请求体 `image` 字段。 +- 关联:`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`、`server-rs/crates/api-server/src/puzzle.rs`。 + +## 拼图首图生成后要把入口参考图写回 `pictureReference` + +- 现象:入口页上传图后,首图看着像没吃到参考图;结果页重新生成时默认只沿用关卡旧图,没有继续带入口上传图。 +- 原因:首图生成请求虽然已经把 `referenceImageSrc` 传给 VectorEngine,但如果后端只更新 `cover_image_src` / `selected_candidate_id` 而不回写首关 `pictureReference`,结果页后续重绘就会丢失参考图。 +- 处理:在 `compile_puzzle_draft` 和 `generate_puzzle_images` 的成功与 SpacetimeDB 降级快照路径里,都把本次入口参考图写入首关 `pictureReference`。 +- 验证:后端单测覆盖 `build_puzzle_levels_with_primary_update` 和 `apply_generated_puzzle_candidates_to_session_snapshot`;结果页重新生成应在未重新上传时继续带入 `level.pictureReference`。 +- 关联:`server-rs/crates/api-server/src/puzzle.rs`、`src/components/puzzle-result/PuzzleResultView.tsx`。 + ## 旧后端路线文档造成判断漂移 - 现象:开发时参考到 Express、Node、PostgreSQL 或 Go 方向旧文档,导致接口、数据真相或部署路径与当前主线不一致。 @@ -226,6 +242,14 @@ - 验证:确认没有匹配 `C:\Genarrative\server-rs\target\debug\api-server.exe` 的进程后,`Remove-Item` 能删除旧 exe;随后 `npm run api-server` 启动并访问 `/healthz` 返回 200。 - 关联:`scripts/api-server-dev.mjs`、`server-rs/crates/api-server/src/main.rs`。 +## dev-rust-stack 端口被旧进程占用时会误判健康检查 + +- 现象:`node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --skip-spacetime` 输出 `Port 3000 is in use, trying another one...`,随后 `api-server.exe` 报 `AddrInUse` / `code: 10048`。 +- 原因:旧 `api-server` 仍监听默认 `8082` 时,脚本的 `/healthz` 探测会命中旧进程并误判新服务已就绪;旧 Vite 占住 `3000` 时,Vite 默认漂移到新端口,浏览器仍可能打开旧页面。 +- 处理:`scripts/dev-rust-stack.sh` 已在 publish / 编译前预检 `api-server`、主站 Vite、后台 Vite 端口,并让 Vite 使用 `--strictPort`;遇到端口占用时按脚本打印的 PID 停止旧进程,或显式传入 `--api-port` / `--web-port` / `--admin-web-port`。 +- 验证:默认端口被占用时,完整栈应在发布模块前直接失败并打印监听进程;清理端口后重新启动不再漂移端口或命中旧 `/healthz`。 +- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md`。 + ## Windows debug 长 SSE Future 触发 api-server 断连 - 现象:前端 Vite 代理请求 `/api/runtime/creative-agent/sessions/{sessionId}/messages/stream` 报 `read ECONNRESET`,随后 `api-server.exe` 以 `0xffffffff` 退出,`dev:rust` 回收 SpacetimeDB、Vite 和后台 Vite。 diff --git a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md index 115d7c0c..6673f80a 100644 --- a/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md +++ b/docs/design/PLATFORM_MOBILE_RECOMMEND_DISCOVER_DRAFT_TAB_REDESIGN_2026-05-05.md @@ -34,9 +34,11 @@ - 主视口占据顶部栏与作品信息区之间的主要空间,使用平台主题 token 控制运行容器背景、边框、阴影、加载态文字和错误态按钮;亮色主题不得残留纯黑底白字加载块。 - 主视口下方展示当前作品的游玩、点赞、评论/改造等紧凑指标、作者头像、作者名与作品名,不写规则说明类文案。 - 作品信息区不再提供详情箭头或点击详情入口;点击该区域无效,上滑切换下一个推荐作品,下滑切换上一个推荐作品。 +- 推荐页切换参考短视频上下滑交互:当前运行画布位于中间,上一个/下一个作品的封面预览提前挂载在画布上下屏幕外;拖动作品信息区时三屏轨道跟随位移,松手后完成切换或回弹。 - 用户停留在推荐页时,底部当前 Tab 从“推荐”切换为“下一个”,图标使用向下的倒三角 / 双下箭头语义,点击后切换下一个推荐作品。 - 推荐页不再展示额外的底部作品切换块;当前作品的完整操作继续收敛在详情页和作品自身运行态中。 - 推荐页嵌入运行只调整平台外壳容器、主题注入和玩法壳层配色,不改写作品数据、关卡设定、道具设定或图片资产。 +- 屏幕外预览只允许使用公开封面、作品名和类型,不提前启动其它作品 run,不触发道具、计时、存档或作品数据变更。 - 推荐页嵌入拼图玩法时隐藏拼图左上返回按钮,并在设置弹层中隐藏退出入口;作品切换前对当前拼图 run 执行既有“保存并退出”收口,正式 run 的交互状态以已写回后端的快照为准。 - 点赞、改造、复制作品号等完整操作继续收敛在详情页,详情入口由作品自身运行态或其它广场列表承接,推荐页作品信息区只负责展示和上下滑切换。 - 无数据、加载中、启动失败和暂不支持内嵌运行的作品沿用短状态文案。 @@ -90,6 +92,7 @@ 10. 推荐页作品信息区点击无效,上滑切下一个、下滑切上一个;点击底部“下一个”也切下一个作品。 11. 仅推荐页嵌入拼图态隐藏返回与设置内退出入口;详情页、新手引导和普通拼图运行态继续保留原有退出能力。 12. 推荐页切换作品前,如果当前作品是拼图,必须先执行当前拼图 run 的退出收口,再启动下一作品。 +13. 已登录推荐页的上/下滑切换必须展示相邻作品的屏幕外预览,并在拖动不足阈值时回弹;相邻预览不得提前启动玩法运行态。 ## 8. 2026-05-07 未登录三栏补充 diff --git a/docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md b/docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md new file mode 100644 index 00000000..363072f1 --- /dev/null +++ b/docs/technical/DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md @@ -0,0 +1,74 @@ +# 本地 Rust 栈端口冲突预检 + +日期:`2026-05-09` + +## 问题 + +执行完整本地栈启动命令时: + +```bash +node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh --spacetime-port 3101 --skip-spacetime +``` + +如果本机已有旧的 `api-server`、主站 Vite 或后台 Vite 进程仍在监听默认端口,脚本可能出现混合日志: + +```text +Port 3000 is in use, trying another one... +Error: Os { code: 10048, kind: AddrInUse } +process didn't exit successfully: `server-rs\target\debug\api-server.exe` +``` + +其中 `wait_for_api_server` 只探测 `http://127.0.0.1:/healthz`。当旧 `api-server` 仍监听 `8082` 时,健康检查会命中旧进程并误判新服务已就绪;随后新 `cargo run` 真正绑定 `8082` 时失败。与此同时,Vite 默认会在 `3000` 被占用时漂移到下一个端口,导致浏览器仍可能打开旧前端。 + +## 处理 + +`scripts/dev-rust-stack.sh` 在进入 SpacetimeDB publish 和 Rust 编译前,先检查三类端口是否可绑定: + +1. Rust `api-server`:默认 `127.0.0.1:8082`。 +2. 主站 Vite:默认 `0.0.0.0:3000`。 +3. 后台 Vite:默认 `127.0.0.1:3102`。 + +端口被占用时,脚本会直接失败并打印监听进程。Windows 本地会通过 `Get-NetTCPConnection` 与 `Win32_Process` 输出 `pid / name / address / command`,方便精确停止旧进程。 + +主站和后台 Vite 也追加 `--strictPort`,避免默认漂移到 `3001`、`3103` 等端口后让浏览器继续访问旧页面。 + +## 排障步骤 + +PowerShell 查看默认端口占用: + +```powershell +Get-NetTCPConnection -State Listen -LocalPort 3000,3102,8082,3101 -ErrorAction SilentlyContinue | + Select-Object LocalAddress,LocalPort,OwningProcess | + Sort-Object LocalPort +``` + +查看进程命令行: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.ProcessId -in @(3000端口PID, 8082端口PID) } | + Select-Object ProcessId,Name,CommandLine +``` + +停止确认可丢弃的旧本地开发进程: + +```powershell +Stop-Process -Id -Force +``` + +如果确实需要保留旧栈,可显式换端口启动新栈: + +```bash +node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh \ + --skip-spacetime \ + --spacetime-port 3101 \ + --api-port 8090 \ + --web-port 3001 \ + --admin-web-port 3103 +``` + +## 验证 + +1. `bash -n scripts/dev-rust-stack.sh` 通过。 +2. 默认端口被占用时重新运行完整栈,脚本应在 publish 前失败并打印占用进程。 +3. 清理占用进程或换端口后,重新启动时不再出现 Vite 端口漂移或 `api-server` `AddrInUse`。 diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index 326b4796..69c811a7 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -20,7 +20,7 @@ 1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。 2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。 -3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。参考图只保存在当前前端会话内,不落入 SpacetimeDB。 +3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。生成前的参考图只保存在当前前端会话内;一旦用于首图生成并成功返回,后端必须把该参考图写入首关 `pictureReference`,供结果页后续重新生成继续复用。 4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。 5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗光点,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。 6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 0c9e7af0..9db283d1 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。 +- [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。 - [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。 - [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。 - [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。 diff --git a/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md index 20ef258a..440b01f7 100644 --- a/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md +++ b/docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md @@ -80,6 +80,7 @@ VectorEngine 文档要求使用像素尺寸,不再使用 APIMart 的比例写 - 拼图默认 `gpt-image-2` 前端值继续兼容,但上游请求模型统一映射到 `gpt-image-2-all`。 - `nanobanana2` / `gemini-3.1-flash-image-preview` 不再走 APIMart;当前阶段统一回落到 VectorEngine GPT-image-2-all,避免保留旧图片网关。 - 错误 `details.provider` 改为 `vector-engine`。 + - 入口页上传图若用于首图生成,后端必须在生成成功后同步写入首关 `pictureReference`,保证结果页重新生成默认继续带同一张参考图。 3. `.codex/skills/gpt-image-2-apimart/` - 目录名暂不强制迁移,避免本地插件索引漂移;Skill 文案与脚本行为改为 VectorEngine。 @@ -110,7 +111,18 @@ VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=180000 2. 请求体 `model = gpt-image-2-all`,尺寸为 VectorEngine 支持的像素尺寸。 3. 请求体不再包含 `official_fallback`。 4. 参考图字段使用 `image`,不再使用 APIMart 的 `image_urls`。 -5. 缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时返回 `503 SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。 -6. 上游错误映射为 `502 UPSTREAM_ERROR`,保留 `upstreamStatus`、业务 message 和截断后的 raw excerpt。 -7. 运行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`。 -8. 后端改动后使用 `npm run api-server` 重启,并确认 `/healthz`。 +5. 拼图入口页上传图生成首图后,返回的首关 `pictureReference` 保留该 Data URL;结果页重新生成在用户未重新上传参考图时会继续把 `pictureReference` 作为 `referenceImageSrc` 传给后端。 +6. 缺少 `VECTOR_ENGINE_BASE_URL` 或 `VECTOR_ENGINE_API_KEY` 时返回 `503 SERVICE_UNAVAILABLE`,`details.provider = "vector-engine"`。 +7. 上游错误映射为 `502 UPSTREAM_ERROR`,保留 `upstreamStatus`、业务 message 和截断后的 raw excerpt。 +8. 运行 `npm run check:encoding`、`cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`。 +9. 后端改动后使用 `npm run api-server` 重启,并确认 `/healthz`。 + +## 拼图链路排障日志 + +拼图 `compile_puzzle_draft` 与 `generate_puzzle_images` 进入图片生成时,api-server 会输出分阶段耗时日志。排查“是谁慢”时按同一 `session_id` 串联: + +1. `拼图参考图解析完成` / `拼图参考图解析跳过`:确认前端是否传入参考图,以及 Data URL 解析或旧 `/generated-*` OSS 读取耗时;日志只记录 `reference_mime` 与 `reference_bytes`,不记录图片内容。 +2. `拼图 VectorEngine 图片生成 HTTP 返回`:VectorEngine `POST /v1/images/generations` 的同步上游耗时,若这一段长,慢点在 VectorEngine 生图接口。 +3. `拼图 VectorEngine 图片下载完成`:从 VectorEngine 返回的 `data[].url` 下载正式图耗时。 +4. `拼图生成图片已写入 OSS 与资产索引`:正式图上传 OSS、确认资产对象与实体绑定耗时。 +5. `拼图图片候选生成完成`:整段候选图生成总耗时。 diff --git a/docs/technical/VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md b/docs/technical/VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md index eda265b1..010f9dfc 100644 --- a/docs/technical/VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md +++ b/docs/technical/VOLCENGINE_SPEECH_STREAMING_INTEGRATION_2026-05-08.md @@ -35,7 +35,7 @@ VOLCENGINE_SPEECH_TTS_SSE_URL=https://openspeech.bytedance.com/api/v3/tts/unidir 配置规则: 1. 优先使用新版控制台 `VOLCENGINE_SPEECH_API_KEY`,上游请求头写 `X-Api-Key`。 -2. 若只配置旧版控制台信息,则使用 `VOLCENGINE_SPEECH_APP_ID` 和 `VOLCENGINE_SPEECH_ACCESS_KEY`。 +2. 若只配置旧版控制台信息,则使用 `VOLCENGINE_SPEECH_APP_ID` 和 `VOLCENGINE_SPEECH_ACCESS_KEY`,上游请求头写 `X-Api-App-Key` 与 `X-Api-Access-Key`。 3. ASR 默认资源 ID 选 ASR 2.0 并发版;如账号是小时版,部署时改成 `volc.seedasr.sauc.duration`。 4. TTS 默认资源 ID 选 `seed-tts-2.0`;旧音色或 1.0 计费资源由部署环境覆盖。 diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index e693e613..956fc94b 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -281,6 +281,100 @@ Get-CimInstance Win32_Process | fi } +is_tcp_port_available() { + local host="$1" + local port="$2" + + # 中文注释:用真实 bind 探测端口,覆盖 127.0.0.1 与 0.0.0.0 互相占用的情况。 + node - "${host}" "${port}" <<'NODE' +const net = require('net'); +const host = process.argv[2]; +const port = Number(process.argv[3]); + +if (!Number.isInteger(port) || port <= 0 || port > 65535) { + process.exit(1); +} + +const server = net.createServer(); +const timer = setTimeout(() => { + server.close(); + process.exit(1); +}, 1000); + +server.once('error', () => { + clearTimeout(timer); + process.exit(1); +}); + +server.once('listening', () => { + clearTimeout(timer); + server.close(() => process.exit(0)); +}); + +server.listen({ host, port }); +NODE +} + +describe_tcp_port_owner() { + local port="$1" + + # 中文注释:Windows 下直接读取监听端口对应进程,便于用户精确停止旧 dev 栈。 + if command -v powershell.exe >/dev/null 2>&1; then + GENARRATIVE_TCP_PORT="${port}" powershell.exe -NoProfile -Command ' +$portNumber = [int]$env:GENARRATIVE_TCP_PORT +$connections = Get-NetTCPConnection -State Listen -LocalPort $portNumber -ErrorAction SilentlyContinue +$seen = @{} +foreach ($connection in $connections) { + $processId = [int]$connection.OwningProcess + if ($seen.ContainsKey($processId)) { + continue + } + + $seen[$processId] = $true + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue + if ($process) { + $commandLine = ($process.CommandLine -replace "\s+", " ").Trim() + "pid=$processId name=$($process.Name) address=$($connection.LocalAddress):$($connection.LocalPort) command=$commandLine" + } else { + "pid=$processId address=$($connection.LocalAddress):$($connection.LocalPort)" + } +} +' 2>/dev/null || true + return + fi + + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null | + awk 'NR > 1 { print "pid=" $2 " name=" $1 " address=" $9 }' || true + return + fi + + if command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :${port}" 2>/dev/null || true + fi +} + +ensure_tcp_port_available() { + local label="$1" + local host="$2" + local port="$3" + local owners + + # 中文注释:完整栈不复用旧 api-server / Vite,避免健康检查命中旧进程后继续误判。 + if is_tcp_port_available "${host}" "${port}"; then + return + fi + + owners="$(describe_tcp_port_owner "${port}")" + echo "[dev:rust] ${label} 端口已被占用,无法启动: ${host}:${port}" >&2 + if [[ -n "${owners}" ]]; then + echo "[dev:rust] 当前监听进程:" >&2 + echo "${owners}" >&2 + fi + echo "[dev:rust] 请停止占用进程,或通过对应端口参数换端口后重试。" >&2 + exit 1 +} + wait_for_api_server() { local health_url="$1" local timeout_seconds="$2" @@ -545,6 +639,10 @@ echo "[dev:rust] spacetime root: ${SPACETIME_ROOT_DIR}" echo "[dev:rust] spacetime data: ${SPACETIME_DATA_DIR}" echo "[dev:rust] api timeout: ${API_SERVER_TIMEOUT_SECONDS}s" +ensure_tcp_port_available "Rust api-server" "${API_HOST}" "${API_PORT}" +ensure_tcp_port_available "主站 Vite" "${WEB_HOST}" "${WEB_PORT}" +ensure_tcp_port_available "后台 Vite" "${ADMIN_WEB_HOST}" "${ADMIN_WEB_PORT}" + if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}" SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")" @@ -633,7 +731,7 @@ echo "[dev:rust] 启动 vite" ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \ ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \ VITE_DEV_HOST="${WEB_HOST}" \ - exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" + exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}" "--strictPort" ) & PIDS+=("$!") NAMES+=("vite") @@ -644,7 +742,7 @@ echo "[dev:rust] 启动 admin vite" ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \ GENARRATIVE_API_PORT="${API_PORT}" \ - exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}" + exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}" "--strictPort" ) & PIDS+=("$!") NAMES+=("admin-vite") diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 76f53bac..0bde9d9f 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,6 +1,6 @@ use std::{ collections::BTreeMap, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -912,7 +912,11 @@ pub async fn execute_puzzle_agent_action( let generated_level_name = target_level.level_name.clone(); let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( - &build_puzzle_levels_with_primary_name(&draft, &target_level), + &build_puzzle_levels_with_primary_update( + &draft, + &target_level, + payload.reference_image_src.as_deref(), + ), )?); let candidates_json = serde_json::to_string( &candidates @@ -962,6 +966,7 @@ pub async fn execute_puzzle_agent_action( ), target_level.level_id.as_str(), candidates.into_records(), + payload.reference_image_src.as_deref(), now, )) } @@ -3081,9 +3086,10 @@ fn build_fallback_puzzle_first_level_name(picture_description: &str) -> String { "奇境初见".to_string() } -fn build_puzzle_levels_with_primary_name( +fn build_puzzle_levels_with_primary_update( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, + picture_reference: Option<&str>, ) -> Vec { let mut levels = draft.levels.clone(); if let Some(index) = levels @@ -3092,6 +3098,11 @@ fn build_puzzle_levels_with_primary_name( .or_else(|| (!levels.is_empty()).then_some(0)) { levels[index].level_name = target_level.level_name.clone(); + if let Some(picture_reference) = + picture_reference.map(str::trim).filter(|value| !value.is_empty()) + { + levels[index].picture_reference = Some(picture_reference.to_string()); + } } levels } @@ -3161,7 +3172,7 @@ async fn compile_puzzle_draft_with_initial_cover( } let generated_level_name = target_level.level_name.clone(); let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( - &build_puzzle_levels_with_primary_name(&draft, &target_level), + &build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src), )?); let candidates_json = serde_json::to_string( &candidates @@ -3208,6 +3219,7 @@ async fn compile_puzzle_draft_with_initial_cover( ), target_level.level_id.as_str(), candidates.into_records(), + reference_image_src, now, ); Ok((session, true)) @@ -3311,7 +3323,7 @@ async fn compile_puzzle_draft_with_uploaded_cover( } let generated_level_name = target_level.level_name.clone(); let levels_json_with_generated_name = Some(serialize_puzzle_level_records_for_module( - &build_puzzle_levels_with_primary_name(&draft, &target_level), + &build_puzzle_levels_with_primary_update(&draft, &target_level, reference_image_src), )?); let persisted_upload = persist_puzzle_generated_asset( state, @@ -3374,6 +3386,7 @@ async fn compile_puzzle_draft_with_uploaded_cover( ), target_level.level_id.as_str(), vec![candidate.clone()], + reference_image_src, now, ); Ok((session, true)) @@ -3413,6 +3426,7 @@ fn apply_generated_puzzle_candidates_to_session_snapshot( mut session: PuzzleAgentSessionRecord, target_level_id: &str, candidates: Vec, + picture_reference: Option<&str>, updated_at_micros: i64, ) -> PuzzleAgentSessionRecord { let Some(draft) = session.draft.as_mut() else { @@ -3443,6 +3457,12 @@ fn apply_generated_puzzle_candidates_to_session_snapshot( level.selected_candidate_id = Some(selected.candidate_id.clone()); level.cover_image_src = Some(selected.image_src.clone()); level.cover_asset_id = Some(selected.asset_id.clone()); + if let Some(picture_reference) = picture_reference + .map(str::trim) + .filter(|value| !value.is_empty()) + { + level.picture_reference = Some(picture_reference.to_string()); + } level.generation_status = "ready".to_string(); if target_index == 0 { sync_puzzle_primary_draft_fields_from_level(draft); @@ -3892,9 +3912,13 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); + // 中文注释:历史运行态或旧 SpacetimeDB 错误快照可能仍带 APIMart 图片网关文案;当前 GPT-image-2 已统一迁移到 VectorEngine,返回给前端前先归一,避免误导排障。 + let is_legacy_apimart_image_error = + message.contains("APIMart") || message.contains("apimart") || message.contains("APIMART"); let provider = if message.contains("VectorEngine") || message.contains("vector-engine") || message.contains("VECTOR_ENGINE") + || is_legacy_apimart_image_error { VECTOR_ENGINE_PROVIDER } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { @@ -3905,6 +3929,8 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let status = if provider == VECTOR_ENGINE_PROVIDER && (message.contains("VECTOR_ENGINE_API_KEY") || message.contains("VECTOR_ENGINE_BASE_URL") + || message.contains("APIMART_API_KEY") + || message.contains("APIMART_BASE_URL") || message.contains("未配置")) { StatusCode::SERVICE_UNAVAILABLE @@ -3920,6 +3946,7 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { || message.contains("VectorEngine") || message.contains("vector-engine") || message.contains("VECTOR_ENGINE") + || is_legacy_apimart_image_error || message.contains("参考图") || message.contains("图片") || message.contains("OSS") @@ -3949,13 +3976,28 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { _ => StatusCode::BAD_GATEWAY, } }; + let user_message = normalize_legacy_puzzle_image_error_message(message.as_str()); AppError::from_status(status).with_details(json!({ "provider": provider, - "message": message, + "message": user_message, })) } +fn normalize_legacy_puzzle_image_error_message(message: &str) -> String { + message + .replace( + "APIMart 图片生成密钥未配置", + "VectorEngine 图片生成密钥未配置", + ) + .replace( + "APIMart 图片生成地址未配置", + "VectorEngine 图片生成地址未配置", + ) + .replace("APIMART_API_KEY", "VECTOR_ENGINE_API_KEY") + .replace("APIMART_BASE_URL", "VECTOR_ENGINE_BASE_URL") +} + fn puzzle_error_response( request_context: &RequestContext, provider: &str, @@ -4021,10 +4063,15 @@ async fn generate_puzzle_image_candidates( candidate_count: u32, candidate_start_index: usize, ) -> Result, AppError> { + let total_started_at = Instant::now(); let count = candidate_count.clamp(1, 1); let resolved_model = resolve_puzzle_image_model(image_model); let actual_prompt = build_puzzle_image_prompt(level_name, prompt); let http_client = build_puzzle_image_http_client(state, resolved_model)?; + let has_reference_image = reference_image_src + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false); tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), @@ -4032,24 +4079,45 @@ async fn generate_puzzle_image_candidates( level_name, prompt_chars = prompt.chars().count(), actual_prompt_chars = actual_prompt.chars().count(), - has_reference_image = reference_image_src - .map(str::trim) - .map(|value| !value.is_empty()) - .unwrap_or(false), + has_reference_image, "拼图图片生成请求已准备" ); + let reference_image_started_at = Instant::now(); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) { Some(source) => { - Some(resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?) + let resolved = + resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + reference_mime = %resolved.mime_type, + reference_bytes = resolved.bytes_len, + elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, + "拼图参考图解析完成" + ); + Some(resolved) } None => None, }; + if !has_reference_image { + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + elapsed_ms = reference_image_started_at.elapsed().as_millis() as u64, + "拼图参考图解析跳过" + ); + } // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 let settings = require_puzzle_vector_engine_settings(state)?; + let vector_engine_started_at = Instant::now(); let generated = create_puzzle_vector_engine_image_generation( &http_client, &settings, @@ -4058,10 +4126,21 @@ async fn generate_puzzle_image_candidates( PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, - reference_image.as_deref(), + reference_image + .as_ref() + .map(|image| image.data_url.as_str()), ) .await .map_err(map_puzzle_generation_endpoint_error)?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + generated_image_count = generated.images.len(), + elapsed_ms = vector_engine_started_at.elapsed().as_millis() as u64, + "拼图 VectorEngine 生图与下载完成" + ); let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { @@ -4070,6 +4149,7 @@ async fn generate_puzzle_image_candidates( candidate_start_index + index + 1 ); let downloaded_image = image.clone(); + let persist_started_at = Instant::now(); let asset = persist_puzzle_generated_asset( state, owner_user_id, @@ -4082,6 +4162,17 @@ async fn generate_puzzle_image_candidates( ) .await .map_err(map_puzzle_generation_endpoint_error)?; + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + candidate_id = %candidate_id, + image_bytes = downloaded_image.bytes.len(), + image_mime = %downloaded_image.mime_type, + elapsed_ms = persist_started_at.elapsed().as_millis() as u64, + "拼图生成图片已写入 OSS 与资产索引" + ); items.push(GeneratedPuzzleImageCandidate { record: PuzzleGeneratedImageCandidateRecord { candidate_id, @@ -4097,6 +4188,16 @@ async fn generate_puzzle_image_candidates( }); } + tracing::info!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + candidate_count = items.len(), + has_reference_image, + elapsed_ms = total_started_at.elapsed().as_millis() as u64, + "拼图图片候选生成完成" + ); Ok(items) } @@ -4144,6 +4245,30 @@ mod tests { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } + #[tokio::test] + async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { + let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( + "APIMart 图片生成密钥未配置".to_string(), + )); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = response.into_body(); + let bytes = axum::body::to_bytes(body, usize::MAX) + .await + .expect("body bytes should read"); + let payload: Value = + serde_json::from_slice(&bytes).expect("error response should be valid json"); + assert_eq!( + payload["error"]["details"]["provider"], + Value::String(VECTOR_ENGINE_PROVIDER.to_string()) + ); + assert_eq!( + payload["error"]["details"]["message"], + Value::String("VectorEngine 图片生成密钥未配置".to_string()) + ); + } + #[test] fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { let levels_json = serde_json::to_string(&vec![json!({ @@ -4293,6 +4418,116 @@ mod tests { assert_eq!(draft.levels[0].level_name, "雨夜猫街"); } + fn test_puzzle_anchor_pack_record() -> PuzzleAnchorPackRecord { + let item = PuzzleAnchorItemRecord { + key: "visualSubject".to_string(), + label: "画面".to_string(), + value: "雨夜猫街".to_string(), + status: "confirmed".to_string(), + }; + + PuzzleAnchorPackRecord { + theme_promise: item.clone(), + visual_subject: item.clone(), + visual_mood: item.clone(), + composition_hooks: item.clone(), + tags_and_forbidden: item, + } + } + + fn test_puzzle_draft_record() -> PuzzleResultDraftRecord { + let anchor_pack = test_puzzle_anchor_pack_record(); + PuzzleResultDraftRecord { + work_title: "雨夜猫街".to_string(), + work_description: "一只猫在雨夜灯牌下回头。".to_string(), + level_name: "猫画面".to_string(), + summary: "一只猫在雨夜灯牌下回头。".to_string(), + theme_tags: vec![], + forbidden_directives: vec![], + creator_intent: None, + anchor_pack, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + levels: vec![PuzzleDraftLevelRecord { + level_id: "puzzle-level-1".to_string(), + level_name: "猫画面".to_string(), + picture_description: "一只猫在雨夜灯牌下回头。".to_string(), + picture_reference: None, + candidates: vec![], + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }], + form_draft: None, + } + } + + #[test] + fn puzzle_primary_level_update_preserves_reference_for_regeneration() { + let draft = test_puzzle_draft_record(); + let mut target_level = draft.levels[0].clone(); + target_level.level_name = "雨夜猫街".to_string(); + + let levels = build_puzzle_levels_with_primary_update( + &draft, + &target_level, + Some("data:image/png;base64,abcd"), + ); + + assert_eq!(levels[0].level_name, "雨夜猫街"); + assert_eq!( + levels[0].picture_reference.as_deref(), + Some("data:image/png;base64,abcd") + ); + } + + #[test] + fn puzzle_generated_fallback_snapshot_preserves_picture_reference() { + let anchor_pack = test_puzzle_anchor_pack_record(); + let session = PuzzleAgentSessionRecord { + session_id: "puzzle-session-1".to_string(), + seed_text: "雨夜猫街".to_string(), + current_turn: 1, + progress_percent: 0, + stage: "draft_ready".to_string(), + anchor_pack: anchor_pack.clone(), + draft: Some(test_puzzle_draft_record()), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + let candidate = PuzzleGeneratedImageCandidateRecord { + candidate_id: "puzzle-session-1-candidate-1".to_string(), + image_src: "/generated-puzzle-assets/puzzle-session-1/1/cover.png".to_string(), + asset_id: "puzzle-cover-1".to_string(), + prompt: "雨夜猫街".to_string(), + actual_prompt: Some("雨夜猫街".to_string()), + source_type: "generated:gpt-image-2".to_string(), + selected: true, + }; + + let session = apply_generated_puzzle_candidates_to_session_snapshot( + session, + "puzzle-level-1", + vec![candidate], + Some("data:image/png;base64,abcd"), + 1_713_686_401_234_568, + ); + + let draft = session.draft.expect("draft"); + assert_eq!( + draft.levels[0].picture_reference.as_deref(), + Some("data:image/png;base64,abcd") + ); + } + #[test] fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { let invalid_operation = @@ -4347,6 +4582,12 @@ struct PuzzleGeneratedImages { images: Vec, } +struct PuzzleResolvedReferenceImage { + data_url: String, + mime_type: String, + bytes_len: usize, +} + struct GeneratedPuzzleImageCandidate { record: PuzzleGeneratedImageCandidateRecord, downloaded_image: PuzzleDownloadedImage, @@ -4490,8 +4731,14 @@ async fn create_puzzle_vector_engine_image_generation( candidate_count, reference_image, ); + let request_url = puzzle_vector_engine_images_generation_url(settings); + let has_reference_image = reference_image + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false); + let request_started_at = Instant::now(); let response = http_client - .post(puzzle_vector_engine_images_generation_url(settings)) + .post(request_url.as_str()) .header( reqwest::header::AUTHORIZATION, format!("Bearer {}", settings.api_key), @@ -4507,6 +4754,18 @@ async fn create_puzzle_vector_engine_image_generation( )) })?; let status = response.status(); + let upstream_elapsed_ms = request_started_at.elapsed().as_millis() as u64; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + image_model = image_model.request_model_name(), + endpoint = %request_url, + status = status.as_u16(), + prompt_chars = prompt.chars().count(), + size, + has_reference_image, + elapsed_ms = upstream_elapsed_ms, + "拼图 VectorEngine 图片生成 HTTP 返回" + ); let response_text = response.text().await.map_err(|error| { map_puzzle_vector_engine_request_error(format!( "读取拼图 VectorEngine 图片生成响应失败:{error}" @@ -4526,13 +4785,22 @@ async fn create_puzzle_vector_engine_image_generation( )?; let image_urls = extract_puzzle_image_urls(&payload); if !image_urls.is_empty() { - return download_puzzle_images_from_urls( + let download_started_at = Instant::now(); + let images = download_puzzle_images_from_urls( http_client, format!("vector-engine-{}", current_utc_micros()), image_urls, candidate_count, ) - .await; + .await?; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + image_model = image_model.request_model_name(), + image_count = images.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + "拼图 VectorEngine 图片下载完成" + ); + return Ok(images); } Err( @@ -4612,7 +4880,7 @@ async fn resolve_puzzle_reference_image_as_data_url( state: &AppState, http_client: &reqwest::Client, source: &str, -) -> Result { +) -> Result { let trimmed = source.trim(); if trimmed.is_empty() { return Err( @@ -4625,11 +4893,17 @@ async fn resolve_puzzle_reference_image_as_data_url( } if let Some(parsed) = parse_puzzle_image_data_url(trimmed) { - return Ok(format!( + let bytes_len = parsed.bytes.len(); + let data_url = format!( "data:{};base64,{}", parsed.mime_type, - BASE64_STANDARD.encode(parsed.bytes) - )); + BASE64_STANDARD.encode(&parsed.bytes) + ); + return Ok(PuzzleResolvedReferenceImage { + data_url, + mime_type: parsed.mime_type, + bytes_len, + }); } if !trimmed.starts_with('/') { @@ -4699,11 +4973,13 @@ async fn resolve_puzzle_reference_image_as_data_url( ); } - Ok(format!( - "data:{};base64,{}", - content_type, - BASE64_STANDARD.encode(body) - )) + let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); + let bytes_len = body.len(); + Ok(PuzzleResolvedReferenceImage { + data_url: format!("data:{};base64,{}", mime_type, BASE64_STANDARD.encode(body)), + mime_type, + bytes_len, + }) } async fn download_puzzle_remote_image( diff --git a/server-rs/crates/platform-speech/src/lib.rs b/server-rs/crates/platform-speech/src/lib.rs index e907f194..9d9ebfdd 100644 --- a/server-rs/crates/platform-speech/src/lib.rs +++ b/server-rs/crates/platform-speech/src/lib.rs @@ -357,7 +357,7 @@ impl VolcengineSpeechConfig { } VolcengineSpeechAuthMode::LegacyApp => { headers.insert( - "X-Api-App-Id", + "X-Api-App-Key", header_value(self.app_id.as_deref().unwrap_or(""))?, ); headers.insert( diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index cad64ead..db9b2b58 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -416,6 +416,43 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () => }); }); +test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => { + const onCreateFromForm = vi.fn(); + const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; + stubReferenceImageUpload(uploadedDataUrl); + + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + onCreateFromForm={onCreateFromForm} + />, + ); + + fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), { + target: { + files: [new File(['x'], 'first-level.png', { type: 'image/png' })], + }, + }); + await waitFor(() => { + expect(screen.getByAltText('拼图图片')).toBeTruthy(); + }); + fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), { + target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' }, + }); + fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u })); + + expect(onCreateFromForm).toHaveBeenCalledWith({ + seedText: '保留上传画面的主体和构图,改成雨夜灯街。', + pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。', + referenceImageSrc: uploadedDataUrl, + imageModel: 'gpt-image-2', + aiRedraw: true, + }); +}); + test('puzzle workspace shows AI redraw switch only after upload', async () => { const uploadedDataUrl = 'data:image/png;base64,uploaded-square'; stubReferenceImageUpload(uploadedDataUrl); diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index afd0f13e..71a56ad4 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -589,6 +589,45 @@ describe('PuzzleResultView', () => { }); }); + test('uses the saved level picture reference when regenerating a level image', () => { + const onExecuteAction = vi.fn(); + const session = createSession({ + draft: { + ...createSession().draft!, + levels: [ + { + ...createSession().draft!.levels![0]!, + pictureReference: '/generated-puzzle-assets/history/saved-reference.png', + }, + ], + }, + }); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByText('雨夜猫街')); + fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); + + expect(onExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'generate_puzzle_images', + referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png', + }), + ); + }); + test('passes the selected image model when regenerating a level image', () => { const onExecuteAction = vi.fn(); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 8e2ecf58..7ccecaf9 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -637,6 +637,8 @@ function PuzzleLevelDetailDialog({ ); const formalImageSrc = resolveLevelFormalImageSrc(level); const hasFormalImage = Boolean(formalImageSrc); + const effectiveReferenceImageSrc = + referenceImageSrc.trim() || level.pictureReference?.trim() || ''; const isGenerationProgressVisible = isGenerationProgressActive; const generationSecondsLeft = isBusy ? Math.max(generationCountdown, 1) @@ -722,7 +724,7 @@ function PuzzleLevelDetailDialog({ onGenerate( level.levelId, level.pictureDescription.trim() || undefined, - referenceImageSrc || undefined, + effectiveReferenceImageSrc || undefined, imageModel, ); }; @@ -829,11 +831,11 @@ function PuzzleLevelDetailDialog({ />