diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index ddc0d5c9..e142aa59 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -3,4 +3,14 @@ version = 1 name = "Genarrative" [setup] -script = "" +script = ''' +npm install +cp "C:\proj\Genarrative\.env.secrets.local" ".env.secrets.local" +npm run codegraph:init +npm run codegraph:index +''' + +[[actions]] +name = "运行" +icon = "run" +command = "npm run dev" diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index e1b45c6e..62a7c9bf 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-25 新增玩法接入必须使用统一 SOP skill + +- 背景:敲木鱼、跳一跳、汪汪声浪等玩法接入过程中,作品架曾经没有被作为强制闭环验收项,导致玩法可以先完成创作、发布、运行态或广场,但用户在草稿 / 已发布作品架中看不到自己的作品。 +- 决策:凡是新增、补齐、迁移或重构玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 的任务,开始前必须显式读取并按 `.codex/skills/genarrative-play-type-integration/SKILL.md` 执行。需要发布或试玩的玩法,作品架不是可选项,必须补齐私有 `/works` 列表、作品摘要、pending shelf 兜底、统一作品架 adapter、打开详情 / 草稿恢复、已发布分享入口和草稿 / 已发布可见性测试。 +- 影响范围:`AGENTS.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、玩法 PRD、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、新增玩法前后端接入流程。 +- 验证方式:玩法接入 PRD 和实现验收必须列出作品架链路;若一个玩法具备发布或试玩能力,但缺少 `/api/creation//works`、前端 client `listWorks`、`CustomWorldCreationHub` props、`creationWorkShelf` adapter 或草稿 / 已发布作品架测试,则接入不算完成。 +- 关联文档:`AGENTS.md`、`.codex/skills/genarrative-play-type-integration/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-24 创作 Tab banner 轮播只展示主题赛 - 背景:创作 Tab banner 曾经把后端入口配置里的默认活动横幅和两个主题赛一起轮播,导致首屏出现 58000 奖池活动卡,和当前只强调拼图 / 抓大鹅主题赛的产品口径不一致。 @@ -121,6 +129,14 @@ - 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-05-25 VectorEngine 图片 provider 收到 platform-image + +- 背景:`api-server` 里原本同时混着 VectorEngine 创建 / 编辑协议、响应解析、远端图片下载、失败日志和审计落库逻辑,Puzzle / Match3D 还各自藏着一份近似实现,导致“provider 协议”和“业务编排”边界不清。 +- 决策:把 VectorEngine `gpt-image-2` 图片 provider 协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志统一收口到 `server-rs/crates/platform-image`。`api-server` 只保留配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计桥接;旧 `openai_image_generation.rs` 只作为兼容转接层,不再承担 provider 实现。 +- 影响范围:`server-rs/crates/platform-image`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、后端架构与运维文档。 +- 验证方式:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL - 背景:release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body,容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。 diff --git a/.hermes/shared-memory/development-workflow.md b/.hermes/shared-memory/development-workflow.md index bb3daa40..bd6d441b 100644 --- a/.hermes/shared-memory/development-workflow.md +++ b/.hermes/shared-memory/development-workflow.md @@ -93,6 +93,8 @@ npm run dev:admin-web `npm run dev:api-server` 会保留终端实时输出,并把同一份输出持久化到 `logs/api-server/api-server-.log`。完整联调入口 `npm run dev` 启动的 Rust `api-server` 使用同一套日志规则。如需改写路径,可设置 `GENARRATIVE_API_SERVER_LOG_FILE`;如只改目录,可设置 `GENARRATIVE_API_SERVER_LOG_DIR`。 +开发态 `npm run dev` / `npm run dev:api-server` 默认打开 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,密码入口可以直接注册未知手机号账号;生产默认仍关闭该开关。 + 查看本地 Rust/SpacetimeDB 日志: ```bash diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 625fd555..40ce7554 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -23,6 +23,21 @@ - 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区:missing-session`、`抓大鹅工作区:missing-session` 或 `汪汪声浪配置表单`,并且不再出现“X 创作入口”空白页。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 +## 泥点不足提示不要把用户退回创作入口 + +- 现象:拼图 / 抓大鹅 / 汪汪声浪等创作表单点击生成时,如果泥点不足,页面直接回到创作 Tab 玩法模板列表,刚填的表单内容随工作台卸载全部丢失。 +- 原因:`PlatformEntryFlowShellImpl.tsx` 的 `ensureEnoughDraftGenerationPointsFromServer(...)` 曾在余额不足或余额读取失败时调用 `enterCreateTab()` 并 `setSelectionStage('platform')`,把前置校验失败当作离开工作台处理。 +- 处理:泥点前置校验失败只更新独立 `UnifiedModal` 提示,不切换 stage,不清表单;余额读取失败也走同一弹窗口径。需要提示玩法内错误时可以保留局部错误位,但不得因此退出工作台。 +- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle form checks mud points before creating a draft|match3d form checks mud points before creating a draft|bark battle form checks mud points before creating image assets"` 应断言弹窗出现、对应工作台仍在、玩法模板分类不再出现。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 玩法入口分类字段缺失要前端兜底 + +- 现象:平台创作入口初始化时,`platformEntryCreationTypes.ts` 直接对 `creationTypes[].categoryId` / `categoryLabel` 调 `trim()`,一旦后端旧数据、局部 mock 或异常返回里缺字段,整个创作页会在 `derivePlatformCreationTypes(...)` 里直接炸掉。 +- 处理:`normalizeCategoryId(...)` 和 `normalizeCategoryLabel(...)` 必须接收可空值,并分别回退到 `recent` / `最近创作`。前端这里是展示派生层,不能要求所有历史配置都先补齐字段。 +- 验证:`npm test -- src/components/platform-entry/platformEntryCreationTypes.test.ts`,再打开本地创作页确认能正常进入创作 Tab。 +- 关联:`src/components/platform-entry/platformEntryCreationTypes.ts`、`src/components/platform-entry/platformEntryCreationTypes.test.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 草稿页未读点不要继续用红色 literal - 现象:草稿页底部 Tab 和作品架的未读点视觉上仍像红点,或 glow 仍带红色阴影,和平台暖棕体系不一致。 @@ -234,6 +249,14 @@ - 验证:`SELECT event_id, scope_id AS provider, metadata_json, occurred_at FROM tracking_event WHERE event_key = 'external_api_call_failure' ORDER BY occurred_at DESC LIMIT 50;`;如果查不到同时看 tracking outbox 目录权限和 sealed 文件是否堆积。 - 关联:`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## VectorEngine 图片协议先看 platform-image,不要先翻 puzzle.rs + +- 现象:排查拼图或其它玩法的生图失败时,如果直接在 `api-server` 的大文件里找 `images/generations`、`images/edits`、base64 解码或下载逻辑,会看到很多历史 helper 和测试桥,看起来像每个玩法都自带一份 provider 实现。 +- 原因:旧实现把 VectorEngine 图片 provider 协议、响应解析、下载和日志混在 `api-server` 里,后来虽然迁出到 `platform-image`,但兼容层和测试 helper 仍会让人误判真相源位置。 +- 处理:先看 `server-rs/crates/platform-image/src/lib.rs` 的 provider 协议和结构化日志,再看 `server-rs/crates/api-server/src/openai_image_generation.rs` 的兼容桥和 `external_api_audit.rs` 的落库映射;`puzzle/vector_engine.rs` 只保留玩法编排,不再作为 provider 协议真相源。 +- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml -- --nocapture` 通过时,排障先按 `platform-image` 的日志字段查 provider / endpoint / failure_stage。 +- 关联:`server-rs/crates/platform-image/src/lib.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/puzzle/vector_engine.rs`。 + ## release 创作接口 413 先查是否还在提交 Data URL - 现象:release 上 `POST /api/runtime/puzzle/agent/sessions/{session_id}/actions` 携带参考图 Data URL 时返回 `413 Request Entity Too Large`,access log 显示 `request_time=0.000`、`upstream_status=-`。 diff --git a/AGENTS.md b/AGENTS.md index a2238a6d..01cbf619 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,10 @@ Use the default canonical triage labels: `needs-triage`, `needs-info`, `ready-fo Single-context layout: read root `CONTEXT.md` when present. Current architecture and product constraints are consolidated under `docs/`. +### 新增玩法接入 + +- 凡是新增、补齐、迁移或重构任何玩法入口、玩法类型、创作工作台、生成页、结果页、发布、运行态、作品架、广场或公开 read model 的任务,开始前必须显式读取并按 [$genarrative-play-type-integration](.codex\skills\genarrative-play-type-integration\SKILL.md) 执行;未先使用该 skill 的,不允许进入编码。 + ## 项目约束 - 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a112fb91..087e28b9 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -20,7 +20,7 @@ server-rs + Axum + SpacetimeDB - HTTP 服务:`api-server`。 - 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 -- 平台副作用:`platform-agent`、`platform-auth`、`platform-llm`、`platform-oss`、`platform-speech`。 +- 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-speech`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 @@ -119,9 +119,10 @@ npm run check:server-rs-ddd 2. Adapter 输入应显式包含 provider、prompt、reference images、OSS prefix/path/file name、asset kind、entity kind/id、slot、owner/profile/source job、metadata 和可选透明背景后处理。 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 -5. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 -6. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 -7. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 +5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image`;`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 +6. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 +7. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 +8. 系列素材图集使用 `server-rs/crates/api-server/src/generated_asset_sheets.rs`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 ## SpacetimeDB schema 变更规则 @@ -158,7 +159,7 @@ npm run check:server-rs-ddd ## 外部服务与资产 - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 -- 图片生成:VectorEngine / APIMart / DashScope,密钥只在后端环境变量中。 +- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 @@ -166,7 +167,7 @@ npm run check:server-rs-ddd - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。 -- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2-all` 图片生成 / 编辑适配器在 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段失败时记录 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`;metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount 和 imageModel。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 +- 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 ## SpacetimeDB 表目录 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c8a231d0..62a24ea8 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -45,6 +45,8 @@ npm run dev:api-server 后端日志默认写入 `logs/api-server/`。后端 API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`;不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 +开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 + 如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。 本地排查 schema 漂移时,先用当前 dev server 显式查询目标库,例如: @@ -59,7 +61,7 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv 本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致。若版本错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`,api-server 最终表现为敲木鱼等创作动作的 `SpacetimeDB procedure 调用超时`。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml` 的 `spacetimedb = "..."`;需要切版本时执行 `spacetime version install && spacetime version use `,然后重新启动 `npm run dev:spacetime`。当前 `scripts/dev.mjs` 会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`,避免把旧 standalone 继续带进新一轮创作。 -本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 +本地 `.env`、`.env.local` 或 `.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine `gpt-image-2` 图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image`;`api-server` 只做配置、玩法编排、OSS / asset 持久化、计费和失败审计落库。开局 CG 故事板、首图、背景和图集都属于长耗时图片请求;后端默认会把 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 下限收口到 `1000000`,旧进程仍可能沿用重启前的短超时。若开局 CG 故事板在 `send()` 阶段失败且日志显示 `SendRequest`,先看同一 request_id 的 `request_body_bytes`、`reference_data_url_bytes`、`sourceChain` 和 `rootSource`;当前开局 CG 会把角色图与首幕背景图压到单边 768 的 JPEG 后再作为 generations `image` 数组发送,`/v1/images/generations` 使用默认 HTTP 协商,只有 multipart `/v1/images/edits` 单独强制 HTTP/1.1。 查看本地 Rust / SpacetimeDB 日志: @@ -142,6 +144,7 @@ Codex 项目级 hook 已放在 `.codex/config.toml` 与 `.codex/hooks/`: 后端代码修改后,按变更范围选择: - `cargo test -p --manifest-path server-rs/Cargo.toml` +- `cargo test -p platform-image --manifest-path server-rs/Cargo.toml` - `cargo check -p api-server --manifest-path server-rs/Cargo.toml` - `cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml` - `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` @@ -250,7 +253,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - debug exporter / Rider 转发都会同时接收 traces、metrics 和 logs。 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 -- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2-all` 图片生成 / 编辑失败会输出 `外部 API 调用失败` trace/log,并记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`;同时写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 +- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出低基数字段结构化日志,字段包括 provider、endpoint、failure_stage、status、status_class、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model 和 raw_excerpt;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。排障时先按 provider / failureStage 聚合,再结合 request 日志和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 - SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 229c0095..325a8b9f 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,8 +8,12 @@ 当前创作 Tab 只承载赛事 banner、玩法模板分类和两列模板卡;点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作 Tab 首屏。移动端创作 Tab 顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把活动奖池当作账号余额展示。首屏 banner 结构按参考图拆成横向可滑动赛事卡、主体宣传图文区、奖池胶囊、开始 / 结束时间条和卡片内分页点;轮播只保留 `拼图主题创作赛` 和 `抓大鹅主题创作赛`,两个主题赛事奖池均为 `1000` 泥点数。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作 Tab 根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作首屏字号需要对齐平台普通 UI 档位:顶栏泥点组件、banner 正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作 Tab 首屏内容。 +创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 + `PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 +`platformEntryCreationTypes.ts` 只做前端展示派生,分组时必须把后端 `creationTypes` 里的 `categoryId` / `categoryLabel` 当作可缺失字段处理,空值统一回退到 `recent` / `最近创作`,避免旧数据、局部 mock 或异常返回把创作入口初始化直接打崩。 + 移动端底部一级导航是全局平台样式,不按单一玩法分叉。当前视觉统一为米白浮动胶囊底座、浅棕分隔线、棕色线性图标、橘色选中态和底部短下划线;中间 `创作` 入口保持凸起圆形主按钮,但凸起位移只能作用在按钮内容层,不能移动承载分隔线的 Tab 按钮容器,确保创作左右分隔线与其他分隔线垂直位置一致。Tab 名称和可见性仍由现有 `PlatformHomeTab` / 登录态规则决定,样式调整不得改写 Tab 文案或导航状态。 ## 新增玩法创作工具平台 SOP diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 0e2762b6..13cbb831 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -958,16 +958,11 @@ class DevRunner { async startApiServer(service) { await this.ensureApiServerSpacetimeToken(); - const mergedEnv = { - ...this.baseEnv, - GENARRATIVE_API_HOST: this.options.apiHost, - GENARRATIVE_API_PORT: String(this.options.apiPort), - GENARRATIVE_API_LOG: this.options.apiLog, - GENARRATIVE_SPACETIME_SERVER_URL: this.state.spacetimeServer, - GENARRATIVE_SPACETIME_DATABASE: this.options.database, - GENARRATIVE_SPACETIME_TOKEN: - this.baseEnv.GENARRATIVE_SPACETIME_TOKEN || '', - }; + const mergedEnv = buildApiServerProcessEnv({ + baseEnv: this.baseEnv, + options: this.options, + state: this.state, + }); const logFile = resolveApiServerLogFile(repoRoot, mergedEnv); ensureParentDir(logFile); @@ -1717,10 +1712,25 @@ function isSpacetimePublishPermissionError(error) { ); } +function buildApiServerProcessEnv({baseEnv, options, state}) { + return { + ...baseEnv, + // 本地 dev 允许密码入口直接创建账号,生产默认仍由 api-server 配置保持关闭。 + GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED: 'true', + GENARRATIVE_API_HOST: options.apiHost, + GENARRATIVE_API_PORT: String(options.apiPort), + GENARRATIVE_API_LOG: options.apiLog, + GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer, + GENARRATIVE_SPACETIME_DATABASE: options.database, + GENARRATIVE_SPACETIME_TOKEN: baseEnv.GENARRATIVE_SPACETIME_TOKEN || '', + }; +} + export { DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, + buildApiServerProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 851e9c07..341cde80 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -8,6 +8,7 @@ import { DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, + buildApiServerProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, @@ -89,6 +90,21 @@ describe('dev scheduler argument routing', () => { }); }); +describe('dev scheduler api-server env', () => { + test('dev 脚本默认打开密码入口自动注册', () => { + const {options} = parseArgs(['api-server', '--api-port', '9091'], {}); + const env = buildApiServerProcessEnv({ + baseEnv: {}, + options, + state: {spacetimeServer: 'http://127.0.0.1:3199'}, + }); + + expect(env.GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED).toBe('true'); + expect(env.GENARRATIVE_API_PORT).toBe('9091'); + expect(env.GENARRATIVE_SPACETIME_SERVER_URL).toBe('http://127.0.0.1:3199'); + }); +}); + describe('dev scheduler spacetime reuse guard', () => { test('记录 URL 可 ping 但没有 spacetime.pid 时不复用宿主', async () => { const tempDir = mkdtempSync(join(tmpdir(), 'genarrative-spacetime-reuse-')); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index ff327c57..de0181aa 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -108,6 +108,7 @@ dependencies = [ "opentelemetry", "platform-agent", "platform-auth", + "platform-image", "platform-llm", "platform-oss", "platform-speech", @@ -2321,6 +2322,17 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "platform-image" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "reqwest 0.12.28", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "platform-llm" version = "0.1.0" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 577c61bd..66f2a2db 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/module-visual-novel", "crates/platform-oss", "crates/platform-auth", + "crates/platform-image", "crates/platform-llm", "crates/platform-speech", "crates/platform-agent", @@ -74,6 +75,7 @@ module-story = { path = "crates/module-story", default-features = false } module-visual-novel = { path = "crates/module-visual-novel", default-features = false } platform-agent = { path = "crates/platform-agent", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false } +platform-image = { path = "crates/platform-image", default-features = false } platform-llm = { path = "crates/platform-llm", default-features = false } platform-oss = { path = "crates/platform-oss", default-features = false } platform-speech = { path = "crates/platform-speech", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index b423be50..2844c4da 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -34,6 +34,7 @@ module-story = { workspace = true } module-visual-novel = { workspace = true } platform-agent = { workspace = true } platform-auth = { workspace = true } +platform-image = { workspace = true } platform-llm = { workspace = true } platform-oss = { workspace = true } platform-speech = { workspace = true } diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 2c609792..9c531773 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -1,4 +1,5 @@ use axum::http::StatusCode; +use platform_image::PlatformImageFailureAudit; use module_runtime::RuntimeTrackingScopeKind; use serde_json::{Value, json}; use time::OffsetDateTime; @@ -109,6 +110,28 @@ impl ExternalApiFailureDraft { } } +pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( + audit: &PlatformImageFailureAudit, +) -> ExternalApiFailureDraft { + ExternalApiFailureDraft::new( + audit.provider, + audit.endpoint.clone(), + audit.operation.clone(), + audit.failure_stage, + audit.error_message.clone(), + ) + .with_status_code(audit.status_code) + .with_optional_status_class(audit.status_class) + .with_timeout(audit.timeout) + .with_retryable(audit.retryable) + .with_error_source(audit.error_source.clone()) + .with_raw_excerpt(audit.raw_excerpt.clone()) + .with_latency_ms(audit.latency_ms) + .with_prompt_chars(audit.prompt_chars) + .with_reference_image_count(audit.reference_image_count) + .with_image_model(audit.image_model) +} + /// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str { status_class(Some(status_code.as_u16())) diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 32fd3fcd..85699b70 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -113,6 +113,7 @@ fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) { StatusCode::NOT_IMPLEMENTED => ("NOT_IMPLEMENTED", "功能暂未实现"), StatusCode::CONFLICT => ("CONFLICT", "请求冲突"), StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"), + StatusCode::GATEWAY_TIMEOUT => ("GATEWAY_TIMEOUT", "上游服务请求超时"), StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"), StatusCode::SERVICE_UNAVAILABLE => ("SERVICE_UNAVAILABLE", "服务暂不可用"), _ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"), diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index f9422db4..1c191fb2 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,22 +1,30 @@ -use std::{error::Error, time::Duration}; - use axum::http::StatusCode; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use reqwest::header; -use serde_json::{Map, Value, json}; +use platform_image::{ + DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, + VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, + build_vector_engine_image_request_body, create_vector_engine_image_edit, + create_vector_engine_image_edit_with_references, create_vector_engine_image_generation, + download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url, + vector_engine_images_generation_url, +}; +use serde_json::{Value, json}; use crate::{ external_api_audit::{ - ExternalApiFailureDraft, app_error_status_class, is_retryable_external_api_failure, + ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure, }, http_error::AppError, state::AppState, }; -pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; -pub(crate) const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; -const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; +pub(crate) use platform_image::GPT_IMAGE_2_MODEL; +#[cfg(test)] +use platform_image::VECTOR_ENGINE_GPT_IMAGE_2_MODEL; + +pub(crate) type OpenAiGeneratedImages = GeneratedImages; +pub(crate) type DownloadedOpenAiImage = DownloadedImage; +pub(crate) type OpenAiReferenceImage = ReferenceImage; #[derive(Clone)] pub(crate) struct OpenAiImageSettings { @@ -41,28 +49,7 @@ impl std::fmt::Debug for OpenAiImageSettings { } } -#[derive(Clone, Debug)] -pub(crate) struct OpenAiGeneratedImages { - pub task_id: String, - pub actual_prompt: Option, - pub images: Vec, -} - -#[derive(Clone, Debug)] -pub(crate) struct DownloadedOpenAiImage { - pub bytes: Vec, - pub mime_type: String, - pub extension: String, -} - -#[derive(Clone, Debug)] -pub(crate) struct OpenAiReferenceImage { - pub bytes: Vec, - pub mime_type: String, - pub file_name: String, -} - -// 中文注释:RPG、方洞等图片资产统一走后端 VectorEngine GPT-image-2,避免把密钥或供应商协议暴露到前端。 +// 中文注释:api-server 只负责配置、审计和 HTTP envelope,VectorEngine 协议细节统一由 platform-image provider 承接。 pub(crate) fn require_openai_image_settings( state: &AppState, ) -> Result { @@ -104,17 +91,8 @@ pub(crate) fn require_openai_image_settings( pub(crate) fn build_openai_image_http_client( settings: &OpenAiImageSettings, ) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - // 中文注释:参考图会走 multipart edits;强制 HTTP/1.1 可避开部分网关对长耗时上传流的兼容问题。 - .http1_only() - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), - })) - }) + build_vector_engine_image_http_client(&settings.provider_settings()) + .map_err(map_platform_image_error) } pub(crate) async fn create_openai_image_generation( @@ -127,264 +105,18 @@ pub(crate) async fn create_openai_image_generation( reference_images: &[String], failure_context: &str, ) -> Result { - if !reference_images.is_empty() { - let resolved_references = - resolve_openai_reference_images(http_client, reference_images, failure_context).await?; - return create_openai_image_edit_with_references( - http_client, - settings, - prompt, - negative_prompt, - size, - candidate_count, - resolved_references.as_slice(), - failure_context, - ) - .await; - } - - let request_url = vector_engine_images_generation_url(settings); - let normalized_size = normalize_image_size(size); - let request_body = build_openai_image_request_body( + let result = create_vector_engine_image_generation( + http_client, + &settings.provider_settings(), prompt, negative_prompt, - normalized_size.as_str(), + size, candidate_count, reference_images, - ); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - { - Ok(response) => response, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:创建图片生成任务失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "request_send", - None, - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:创建图片生成任务失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count = reference_images.len(), - elapsed_ms = started_at.elapsed().as_millis() as u64, failure_context, - "VectorEngine 图片生成 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:读取图片生成响应失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_body", - Some(response_status.as_u16()), - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:读取图片生成响应失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - if !response_status.is_success() { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "upstream_status", - Some(response_status.as_u16()), - None, - false, - false, - parse_api_error_message(response_text.as_str(), failure_context).as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(map_openai_image_upstream_error( - response_status.as_u16(), - response_text.as_str(), - failure_context, - )); - } - - let response_json = match parse_json_payload(response_text.as_str(), failure_context) { - Ok(response_json) => response_json, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_parse", - Some(response_status.as_u16()), - None, - false, - false, - error.body_text().as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(error); - } - }; - let generation_id = extract_generation_id(&response_json.payload) - .unwrap_or_else(|| format!("vector-engine-{}", current_utc_micros())); - let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") - .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); - let image_urls = extract_image_urls(&response_json.payload); - if !image_urls.is_empty() { - let download_started_at = std::time::Instant::now(); - let mut generated = match download_images_from_urls( - http_client, - generation_id, - image_urls, - candidate_count, - ) - .await - { - Ok(generated) => generated, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "image_download", - Some(response_status.as_u16()), - Some(app_error_status_class(error.status_code())), - false, - false, - error.body_text().as_str(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), - ) - .await; - return Err(error); - } - }; - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - elapsed_ms = download_started_at.elapsed().as_millis() as u64, - failure_context, - "VectorEngine 图片下载完成" - ); - return Ok(generated); - } - let b64_images = extract_b64_images(&response_json.payload); - if !b64_images.is_empty() { - let mut generated = images_from_base64(generation_id, b64_images, candidate_count); - generated.actual_prompt = actual_prompt; - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - image_count = generated.images.len(), - failure_context, - "VectorEngine 图片 base64 解码完成" - ); - return Ok(generated); - } - - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "missing_image", - Some(response_status.as_u16()), - None, - false, - false, - format!("{failure_context}:VectorEngine 未返回图片地址").as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_images.len()), - ), ) .await; - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回图片地址"), - })), - ) + map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit( @@ -396,17 +128,17 @@ pub(crate) async fn create_openai_image_edit( reference_image: &OpenAiReferenceImage, failure_context: &str, ) -> Result { - create_openai_image_edit_with_references( + let result = create_vector_engine_image_edit( http_client, - settings, + &settings.provider_settings(), prompt, negative_prompt, size, - 1, - std::slice::from_ref(reference_image), + reference_image, failure_context, ) - .await + .await; + map_platform_image_result(settings, result).await } pub(crate) async fn create_openai_image_edit_with_references( @@ -419,257 +151,27 @@ pub(crate) async fn create_openai_image_edit_with_references( reference_images: &[OpenAiReferenceImage], failure_context: &str, ) -> Result { - if reference_images.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), - })), - ); - } - - let request_url = vector_engine_images_edit_url(settings); - let normalized_size = normalize_image_size(size); - - let mut form = reqwest::multipart::Form::new() - .text("model", GPT_IMAGE_2_MODEL.to_string()) - .text( - "prompt", - build_prompt_with_negative(prompt, negative_prompt), - ) - .text("n", candidate_count.clamp(1, 4).to_string()) - .text("size", normalized_size.clone()); - - for reference_image in reference_images.iter().take(5) { - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(reference_image.file_name.clone()) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:构造参考图失败:{error}" - )) - })?; - form = form.part("image", image_part); - } - - let reference_image_count = reference_images.iter().take(5).count(); - let started_at = std::time::Instant::now(); - let response = match http_client - .post(request_url.as_str()) - .header( - header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - { - Ok(response) => response, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:创建图片编辑任务失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "request_send", - None, - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:创建图片编辑任务失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - let response_status = response.status(); - tracing::info!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - status = response_status.as_u16(), - prompt_chars = prompt.chars().count(), - size = %normalized_size, - reference_image_count, - elapsed_ms = started_at.elapsed().as_millis() as u64, + let result = create_vector_engine_image_edit_with_references( + http_client, + &settings.provider_settings(), + prompt, + negative_prompt, + size, + candidate_count, + reference_images, failure_context, - "VectorEngine 图片编辑 HTTP 返回" - ); - let response_text = match response.text().await { - Ok(response_text) => response_text, - Err(error) => { - let latency_ms = started_at.elapsed().as_millis() as u64; - let timeout = error.is_timeout(); - let connect = error.is_connect(); - let source = error.source().map(ToString::to_string); - let message = format!("{failure_context}:读取图片编辑响应失败:{error}"); - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_body", - Some(response_status.as_u16()), - None, - timeout, - connect, - message.as_str(), - source, - None, - Some(latency_ms), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_reqwest_error( - format!("{failure_context}:读取图片编辑响应失败").as_str(), - request_url.as_str(), - error, - )); - } - }; - if !response_status.is_success() { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "upstream_status", - Some(response_status.as_u16()), - None, - false, - false, - parse_api_error_message(response_text.as_str(), failure_context).as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(map_openai_image_upstream_error( - response_status.as_u16(), - response_text.as_str(), - failure_context, - )); - } - - let response_json = match parse_json_payload(response_text.as_str(), failure_context) { - Ok(response_json) => response_json, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "response_parse", - Some(response_status.as_u16()), - None, - false, - false, - error.body_text().as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(error); - } - }; - let task_id = extract_generation_id(&response_json.payload) - .unwrap_or_else(|| format!("vector-engine-edit-{}", current_utc_micros())); - let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") - .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); - let image_urls = extract_image_urls(&response_json.payload); - if !image_urls.is_empty() { - let download_started_at = std::time::Instant::now(); - let mut generated = match download_images_from_urls( - http_client, - task_id, - image_urls, - candidate_count, - ) - .await - { - Ok(generated) => generated, - Err(error) => { - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "image_download", - Some(response_status.as_u16()), - Some(app_error_status_class(error.status_code())), - false, - false, - error.body_text().as_str(), - None, - None, - Some(download_started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), - ) - .await; - return Err(error); - } - }; - generated.actual_prompt = actual_prompt; - return Ok(generated); - } - let b64_images = extract_b64_images(&response_json.payload); - if !b64_images.is_empty() { - let mut generated = images_from_base64(task_id, b64_images, candidate_count); - generated.actual_prompt = actual_prompt; - return Ok(generated); - } - - record_openai_image_failure_if_configured( - settings, - build_openai_image_failure_audit_draft( - request_url.as_str(), - failure_context, - "missing_image", - Some(response_status.as_u16()), - None, - false, - false, - format!("{failure_context}:VectorEngine 未返回编辑图片").as_str(), - None, - Some(truncate_raw(response_text.as_str())), - Some(started_at.elapsed().as_millis() as u64), - Some(prompt.chars().count()), - Some(reference_image_count), - ), ) .await; - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:VectorEngine 未返回图片"), - })), - ) + map_platform_image_result(settings, result).await +} + +pub(crate) async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + download_platform_image_remote_image(http_client, image_url) + .await + .map_err(map_platform_image_error) } pub(crate) fn build_openai_image_request_body( @@ -677,538 +179,136 @@ pub(crate) fn build_openai_image_request_body( negative_prompt: Option<&str>, size: &str, candidate_count: u32, - _reference_images: &[String], -) -> Value { - let body = Map::from_iter([ - ( - "model".to_string(), - Value::String(GPT_IMAGE_2_MODEL.to_string()), - ), - ( - "prompt".to_string(), - Value::String(build_prompt_with_negative(prompt, negative_prompt)), - ), - ("n".to_string(), json!(candidate_count.clamp(1, 4))), - ( - "size".to_string(), - Value::String(normalize_image_size(size)), - ), - ]); - - Value::Object(body) -} - -fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { - let prompt = prompt.trim(); - let Some(negative_prompt) = negative_prompt - .map(str::trim) - .filter(|value| !value.is_empty()) - else { - return prompt.to_string(); - }; - - format!("{prompt}\n避免:{negative_prompt}") -} - -fn normalize_image_size(size: &str) -> String { - match size.trim() { - "1024*1024" | "1024x1024" | "1:1" => "1024x1024", - "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" - | "2k" => "1536x1024", - "1024*1536" | "1024x1536" | "9:16" => "1024x1536", - value if !value.is_empty() => value, - _ => "1024x1024", - } - .to_string() -} - -async fn download_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - { - images.push(download_remote_image(http_client, image_url.as_str()).await?); - } - Ok(OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - }) -} - -fn images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> OpenAiGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - .filter_map(|raw| decode_generated_image_base64(raw.as_str())) - .collect(); - - OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - } -} - -fn decode_generated_image_base64(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_image_mime_type(bytes.as_slice()); - Some(DownloadedOpenAiImage { - extension: mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -pub(crate) async fn download_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - let response = - http_client.get(image_url).send().await.map_err(|error| { - map_openai_image_request_error(format!("下载生成图片失败:{error}")) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let body = response.bytes().await.map_err(|error| { - map_openai_image_request_error(format!("读取生成图片内容失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "下载生成图片失败", - "status": status.as_u16(), - })), - ); - } - - let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); - Ok(DownloadedOpenAiImage { - extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), - mime_type: normalized_mime_type, - bytes: body.to_vec(), - }) -} - -async fn resolve_openai_reference_images( - http_client: &reqwest::Client, reference_images: &[String], - failure_context: &str, -) -> Result, AppError> { - let mut resolved = Vec::new(); - for (index, source) in reference_images.iter().take(5).enumerate() { - let source = source.trim(); - if source.is_empty() { - continue; - } - if let Some(reference_image) = parse_openai_reference_image_data_url(source, index)? { - resolved.push(reference_image); - continue; - } - if source.starts_with("http://") || source.starts_with("https://") { - let downloaded = download_remote_image(http_client, source) - .await - .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:下载参考图失败:{}", - error.body_text() - )) - })?; - resolved.push(OpenAiReferenceImage { - bytes: downloaded.bytes, - mime_type: downloaded.mime_type.clone(), - file_name: format!( - "reference-{index}.{}", - mime_to_extension(downloaded.mime_type.as_str()) - ), - }); - continue; - } - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), - })), - ); - } - - if resolved.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:图片编辑需要至少一张参考图。"), - })), - ); - } - - Ok(resolved) -} - -fn parse_openai_reference_image_data_url( - source: &str, - index: usize, -) -> Result, AppError> { - let Some(body) = source.strip_prefix("data:") else { - return Ok(None); - }; - let Some((mime_type, data)) = body.split_once(";base64,") else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "参考图 Data URL 必须是 base64 图片。", - })), - ); - }; - if !mime_type.starts_with("image/") { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "参考图 Data URL 必须是图片类型。", - })), - ); - } - let bytes = BASE64_STANDARD.decode(data.trim()).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("参考图 Data URL 解码失败:{error}"), - })) - })?; - let mime_type = normalize_downloaded_image_mime_type(mime_type); - Ok(Some(OpenAiReferenceImage { - bytes, - file_name: format!( - "reference-{index}.{}", - mime_to_extension(mime_type.as_str()) - ), - mime_type, - })) -} - -fn parse_json_payload( - raw_text: &str, - failure_context: &str, -) -> Result { - serde_json::from_str::(raw_text) - .map(|payload| ParsedJsonPayload { payload }) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{failure_context}:解析响应失败:{error}"), - "rawExcerpt": truncate_raw(raw_text), - })) - }) -} - -fn map_openai_image_request_error(message: String) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - })) -} - -fn map_openai_image_reqwest_error( - context: &str, - request_url: &str, - error: reqwest::Error, -) -> AppError { - let is_timeout = error.is_timeout(); - let is_connect = error.is_connect(); - let source = error.source().map(ToString::to_string).unwrap_or_default(); - let message = format!("{context}:{error}"); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - endpoint = %request_url, - timeout = is_timeout, - connect = is_connect, - request = error.is_request(), - body = error.is_body(), - source = %source, - message = %message, - "VectorEngine 图片请求发送失败" - ); - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "endpoint": request_url, - "timeout": is_timeout, - "connect": is_connect, - "request": error.is_request(), - "body": error.is_body(), - "source": source, - })) -} - -fn map_openai_image_upstream_error( - upstream_status: u16, - raw_text: &str, - failure_context: &str, -) -> AppError { - let message = parse_api_error_message(raw_text, failure_context); - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - upstream_status, - raw_excerpt = %truncate_raw(raw_text), - message, - "VectorEngine 图片生成上游错误" - ); - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "upstreamStatus": upstream_status, - "rawExcerpt": truncate_raw(raw_text), - })) -} - -async fn record_openai_image_failure_if_configured( - settings: &OpenAiImageSettings, - draft: ExternalApiFailureDraft, -) { - if let Some(state) = settings.external_api_audit_state.as_ref() { - record_external_api_failure(state, draft).await; - } -} - -fn build_openai_image_failure_audit_draft( - request_url: &str, - failure_context: &str, - failure_stage: &'static str, - status_code: Option, - status_class: Option<&'static str>, - timeout: bool, - connect: bool, - error_message: &str, - error_source: Option, - raw_excerpt: Option, - latency_ms: Option, - prompt_chars: Option, - reference_image_count: Option, -) -> ExternalApiFailureDraft { - ExternalApiFailureDraft::new( - VECTOR_ENGINE_PROVIDER, - request_url.to_string(), - failure_context.to_string(), - failure_stage, - error_message.to_string(), +) -> Value { + build_vector_engine_image_request_body( + prompt, + negative_prompt, + size, + candidate_count, + reference_images, ) - .with_status_code(status_code) - .with_optional_status_class(status_class) - .with_timeout(timeout) - .with_retryable(is_retryable_external_api_failure( - status_code, - timeout, - connect, - )) - .with_error_source(error_source) - .with_raw_excerpt(raw_excerpt) - .with_latency_ms(latency_ms) - .with_prompt_chars(prompt_chars) - .with_reference_image_count(reference_image_count) - .with_image_model(Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)) } -fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { - if raw_text.trim().is_empty() { - return fallback_message.to_string(); - } - - if let Ok(parsed) = serde_json::from_str::(raw_text) { - for pointer in [ - "/error/message", - "/message", - "/output/message", - "/data/message", - ] { - if let Some(message) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return message.to_string(); - } - } - for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { - if let Some(code) = parsed - .pointer(pointer) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("{fallback_message}({code})"); - } +impl OpenAiImageSettings { + fn provider_settings(&self) -> VectorEngineImageSettings { + VectorEngineImageSettings { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + request_timeout_ms: self.request_timeout_ms.max(1), } } - - raw_text.trim().to_string() } -fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { - match value { - Value::Array(entries) => { - for entry in entries { - collect_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, nested_value) in object { - if key == target_key { - match nested_value { - Value::String(text) => { - let text = text.trim(); - if !text.is_empty() { - results.push(text.to_string()); - continue; - } - } - Value::Array(entries) => { - for entry in entries { - if let Some(text) = entry - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(text.to_string()); - } - } - } - _ => {} - } - } - collect_strings_by_key(nested_value, target_key, results); - } - } - _ => {} - } -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_strings_by_key(value, target_key, &mut results); - results.into_iter().next() -} - -fn extract_generation_id(payload: &Value) -> Option { - find_first_string_by_key(payload, "id") - .or_else(|| find_first_string_by_key(payload, "created")) - .or_else(|| find_first_string_by_key(payload, "request_id")) -} - -fn extract_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_strings_by_key(payload, "url", &mut urls); - collect_strings_by_key(payload, "image", &mut urls); - collect_strings_by_key(payload, "image_url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { - deduped.push(url); +async fn map_platform_image_result( + settings: &OpenAiImageSettings, + result: Result, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(error) => { + record_openai_image_failure_if_configured(settings, &error).await; + Err(map_platform_image_error(error)) } } - deduped } -fn extract_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_strings_by_key(payload, "b64_json", &mut values); - values +pub(crate) async fn record_openai_image_failure_if_configured( + settings: &OpenAiImageSettings, + error: &PlatformImageError, +) { + let Some(state) = settings.external_api_audit_state.as_ref() else { + return; + }; + let Some(draft) = build_openai_image_failure_audit_draft(error) else { + return; + }; + record_external_api_failure(state, draft).await; } -fn vector_engine_images_generation_url(settings: &OpenAiImageSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/generations", settings.base_url) - } else { - format!("{}/v1/images/generations", settings.base_url) - } +pub(crate) fn build_openai_image_failure_audit_draft( + error: &PlatformImageError, +) -> Option { + error + .audit() + .map(build_external_api_failure_draft_from_platform_image_audit) } -fn vector_engine_images_edit_url(settings: &OpenAiImageSettings) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/edits", settings.base_url) - } else { - format!("{}/v1/images/edits", settings.base_url) - } -} +pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { + let status = match error.status_hint() { + PlatformImageStatusHint::BadRequest => StatusCode::BAD_REQUEST, + PlatformImageStatusHint::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE, + PlatformImageStatusHint::BadGateway => StatusCode::BAD_GATEWAY, + PlatformImageStatusHint::GatewayTimeout => StatusCode::GATEWAY_TIMEOUT, + }; -fn normalize_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/jpeg"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() + let mut details = json!({ + "provider": error.provider(), + "message": error.message(), + }); + + match &error { + PlatformImageError::InvalidConfig { .. } | PlatformImageError::InvalidRequest { .. } => {} + PlatformImageError::Request { + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => { + details["endpoint"] = json!(endpoint); + details["timeout"] = json!(timeout); + details["connect"] = json!(connect); + details["request"] = json!(request); + details["body"] = json!(body); + details["status"] = json!(status_code); + details["source"] = json!(source); } - _ => "image/jpeg".to_string(), + PlatformImageError::Upstream { + upstream_status, + raw_excerpt, + .. + } => { + details["upstreamStatus"] = json!(upstream_status); + details["rawExcerpt"] = json!(raw_excerpt); + } + PlatformImageError::ResponseParse { raw_excerpt, .. } => { + details["rawExcerpt"] = json!(raw_excerpt); + } + PlatformImageError::MissingImage { .. } => {} } + + if let Some(audit) = error.audit() { + details["endpoint"] = json!(audit.endpoint); + details["failureStage"] = json!(audit.failure_stage); + details["statusClass"] = json!(audit.status_class); + details["retryable"] = json!(audit.retryable); + details["timeout"] = json!(audit.timeout); + details["latencyMs"] = json!(audit.latency_ms); + details["promptChars"] = json!(audit.prompt_chars); + details["referenceImageCount"] = json!(audit.reference_image_count); + details["imageModel"] = json!(audit.image_model); + details["rawExcerpt"] = json!(audit.raw_excerpt); + } + + AppError::from_status(status).with_details(details) } -fn mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } +fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { + vector_engine_images_generation_url(&settings.provider_settings()) } -fn infer_image_mime_type(bytes: &[u8]) -> String { - if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { - return "image/png".to_string(); - } - if bytes.starts_with(b"\xFF\xD8\xFF") { - return "image/jpeg".to_string(); - } - if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - return "image/webp".to_string(); - } - if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { - return "image/gif".to_string(); - } - "image/png".to_string() -} - -fn truncate_raw(raw_text: &str) -> String { - raw_text.chars().take(800).collect() -} - -fn current_utc_micros() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch"); - i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") -} - -struct ParsedJsonPayload { - payload: Value, +fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { + vector_engine_images_edit_url(&settings.provider_settings()) } #[cfg(test)] mod tests { use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; #[test] fn gpt_image_2_generation_request_uses_create_model_without_reference_images() { @@ -1244,11 +344,11 @@ mod tests { }; assert_eq!( - vector_engine_images_generation_url(&root_settings), + vector_engine_images_generation_url_for_test(&root_settings), "https://vector.example/v1/images/generations" ); assert_eq!( - vector_engine_images_generation_url(&v1_settings), + vector_engine_images_generation_url_for_test(&v1_settings), "https://vector.example/v1/images/generations" ); } @@ -1269,11 +369,11 @@ mod tests { }; assert_eq!( - vector_engine_images_edit_url(&root_settings), + vector_engine_images_edit_url_for_test(&root_settings), "https://vector.example/v1/images/edits" ); assert_eq!( - vector_engine_images_edit_url(&v1_settings), + vector_engine_images_edit_url_for_test(&v1_settings), "https://vector.example/v1/images/edits" ); } @@ -1306,51 +406,38 @@ mod tests { } #[test] - fn reference_data_url_resolves_to_edit_image_part() { + fn reference_data_url_stays_provider_owned() { let source = format!( "data:image/png;base64,{}", BASE64_STANDARD.encode(b"pngbytes") ); - let image = parse_openai_reference_image_data_url(source.as_str(), 2) - .expect("data url should parse") - .expect("data url should resolve image"); + let body = build_openai_image_request_body("提示词", None, "1:1", 1, &[source]); - assert_eq!(image.bytes, b"pngbytes"); - assert_eq!(image.mime_type, "image/png"); - assert_eq!(image.file_name, "reference-2.png"); - } - - #[test] - fn b64_json_response_decodes_png_image() { - let images = images_from_base64( - "task-1".to_string(), - vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], - 1, - ); - - assert_eq!(images.images.len(), 1); - assert_eq!(images.images[0].mime_type, "image/png"); - assert_eq!(images.images[0].extension, "png"); + assert!(body.get("image").is_none()); } #[test] fn vector_engine_upstream_failure_builds_tracking_ready_audit_event() { - let audit = build_openai_image_failure_audit_draft( - "https://vector.example/v1/images/generations", - "拼图 UI 背景图生成失败", - "upstream_status", - Some(429), - None, - false, - false, - "上游限流", - None, - Some("{\"error\":\"rate limited\"}".to_string()), - Some(321), - Some(42), - Some(1), + let audit = platform_image::PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "拼图 UI 背景图生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(429), + status_class: None, + timeout: false, + retryable: true, + error_message: "上游限流".to_string(), + error_source: None, + raw_excerpt: Some("{\"error\":\"rate limited\"}".to_string()), + latency_ms: Some(321), + prompt_chars: Some(42), + reference_image_count: Some(1), + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + }; + let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft( + &build_external_api_failure_draft_from_platform_image_audit(&audit), ); - let tracking = crate::external_api_audit::build_external_api_failure_tracking_draft(&audit); assert_eq!( tracking.event_key, diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 7d7b5331..67453bca 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, Instant, SystemTime, UNIX_EPOCH}, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -103,7 +103,7 @@ use crate::{ }, puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json}, request_context::RequestContext, - state::PuzzleApiState, + state::{AppState, PuzzleApiState}, work_author::resolve_puzzle_work_author_by_user_id, work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success}, }; diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index e0a780da..d4bca634 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::openai_image_generation::GPT_IMAGE_2_MODEL; +use crate::openai_image_generation::{GPT_IMAGE_2_MODEL, map_platform_image_error}; +use platform_image::{PlatformImageError, VECTOR_ENGINE_PROVIDER}; +use std::time::Duration; #[test] fn puzzle_generated_image_size_is_square_1_1() { @@ -218,45 +220,6 @@ fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() { assert!(body.get("image").is_none()); } -#[test] -fn puzzle_vector_engine_generation_url_normalizes_base_url() { - let settings = PuzzleVectorEngineSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - }; - - assert_eq!( - puzzle_vector_engine_images_generation_url(&settings), - "https://vector.example/v1/images/generations" - ); -} - -#[test] -fn puzzle_vector_engine_edit_url_normalizes_base_url() { - let settings = PuzzleVectorEngineSettings { - base_url: "https://vector.example/v1".to_string(), - api_key: "test-key".to_string(), - }; - - assert_eq!( - puzzle_vector_engine_images_edit_url(&settings), - "https://vector.example/v1/images/edits" - ); -} - -#[test] -fn puzzle_vector_engine_edit_response_decodes_b64_image() { - let images = puzzle_images_from_base64( - "edit-1".to_string(), - vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], - 1, - ); - - assert_eq!(images.images.len(), 1); - assert_eq!(images.images[0].mime_type, "image/png"); - assert_eq!(images.images[0].extension, "png"); -} - #[test] fn puzzle_vector_engine_prompt_strongly_uses_reference_image() { let prompt = build_puzzle_vector_engine_generation_prompt("请生成雨夜猫街。", true); @@ -379,9 +342,18 @@ fn puzzle_asset_object_reference_requires_matching_owner() { #[test] fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { - let error = map_puzzle_vector_engine_request_error( - "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(), - ); + let error = map_platform_image_error(PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "创建拼图 VectorEngine 图片生成任务失败:operation timed out".to_string(), + endpoint: Some("https://vector.example/v1/images/generations".to_string()), + timeout: true, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + }); let response = error.into_response(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); @@ -389,11 +361,14 @@ fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() { #[test] fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() { - let error = map_puzzle_vector_engine_upstream_error( - reqwest::StatusCode::GATEWAY_TIMEOUT, - r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"#, - "创建拼图 VectorEngine 图片生成任务失败", - ); + let error = map_platform_image_error(PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "VectorEngine generation endpoint timeout".to_string(), + upstream_status: reqwest::StatusCode::GATEWAY_TIMEOUT.as_u16(), + raw_excerpt: r#"{"error":{"message":"VectorEngine generation endpoint timeout"}}"# + .to_string(), + audit: None, + }); let response = error.into_response(); assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 3832530a..6b575f6a 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -1,4 +1,7 @@ use super::*; +use crate::openai_image_generation::{ + OpenAiReferenceImage, create_openai_image_edit_with_references, +}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum PuzzleImageModel { @@ -26,6 +29,8 @@ impl PuzzleImageModel { pub(crate) struct PuzzleVectorEngineSettings { pub(crate) base_url: String, pub(crate) api_key: String, + pub(crate) request_timeout_ms: u64, + pub(crate) external_api_audit_state: Option, } pub(crate) struct PuzzleGeneratedImages { @@ -78,6 +83,25 @@ impl PuzzleDownloadedImage { bytes: image.bytes, } } + + pub(crate) fn from_openai_image(image: DownloadedOpenAiImage) -> Self { + Self { + extension: image.extension, + mime_type: normalize_puzzle_downloaded_image_mime_type(image.mime_type.as_str()), + bytes: image.bytes, + } + } +} + +impl PuzzleVectorEngineSettings { + fn to_openai_settings(&self) -> crate::openai_image_generation::OpenAiImageSettings { + crate::openai_image_generation::OpenAiImageSettings { + base_url: self.base_url.clone(), + api_key: self.api_key.clone(), + request_timeout_ms: self.request_timeout_ms, + external_api_audit_state: self.external_api_audit_state.clone(), + } + } } pub(crate) struct ParsedPuzzleImageDataUrl { @@ -151,27 +175,18 @@ pub(crate) fn require_puzzle_vector_engine_settings( Ok(PuzzleVectorEngineSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), + request_timeout_ms: state.vector_engine_image_request_timeout_ms().max(1), + external_api_audit_state: Some(state.root_state().clone()), }) } pub(crate) fn build_puzzle_image_http_client( state: &PuzzleApiState, - image_model: PuzzleImageModel, + _image_model: PuzzleImageModel, ) -> Result { - let provider = image_model.provider_name(); - let request_timeout_ms = state.vector_engine_image_request_timeout_ms(); + let settings = require_puzzle_vector_engine_settings(state)?; - reqwest::Client::builder() - .timeout(Duration::from_millis(request_timeout_ms.max(1))) - // 中文注释:参考图走 multipart edits;强制 HTTP/1.1 可降低部分网关对长耗时上传流的兼容风险。 - .http1_only() - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": provider, - "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), - })) - }) + build_openai_image_http_client(&settings.to_openai_settings()) } pub(crate) fn to_puzzle_generated_image_candidate( @@ -213,198 +228,66 @@ pub(crate) async fn create_puzzle_vector_engine_image_generation( .await; } - let request_body = build_puzzle_vector_engine_image_request_body( - image_model, + let generated = create_openai_image_generation( + http_client, + &settings.to_openai_settings(), prompt, - negative_prompt, + Some(negative_prompt), size, candidate_count, - reference_image, - ); - let request_url = puzzle_vector_engine_images_generation_url(settings); - let request_started_at = Instant::now(); - let response = http_client - .post(request_url.as_str()) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::ACCEPT, "application/json") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - .map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "创建拼图 VectorEngine 图片生成任务失败:{error}" - )) - })?; - 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 = reference_image.is_some(), - 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}" - )) - })?; - if !status.is_success() { - return Err(map_puzzle_vector_engine_upstream_error( - status, - response_text.as_str(), - "创建拼图 VectorEngine 图片生成任务失败", - )); - } - - let payload = parse_puzzle_json_payload( - response_text.as_str(), - "解析拼图 VectorEngine 图片生成响应失败", - )?; - let image_urls = extract_puzzle_image_urls(&payload); - if !image_urls.is_empty() { - 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?; - 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); - } - - let b64_images = extract_puzzle_b64_images(&payload); - if !b64_images.is_empty() { - return Ok(puzzle_images_from_base64( - format!("vector-engine-{}", current_utc_micros()), - b64_images, - candidate_count, - )); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图 VectorEngine 图片生成未返回图片地址", - })), + &[], + "拼图 VectorEngine 图片生成失败", ) + .await?; + + Ok(PuzzleGeneratedImages { + task_id: generated.task_id, + images: generated + .images + .into_iter() + .map(PuzzleDownloadedImage::from_openai_image) + .collect(), + }) } pub(crate) async fn create_puzzle_vector_engine_image_edit( http_client: &reqwest::Client, settings: &PuzzleVectorEngineSettings, - image_model: PuzzleImageModel, + _image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, reference_image: &PuzzleResolvedReferenceImage, ) -> Result { - let request_url = puzzle_vector_engine_images_edit_url(settings); - let task_id = format!("vector-engine-edit-{}", current_utc_micros()); let file_name = format!( "puzzle-reference.{}", puzzle_mime_to_extension(reference_image.mime_type.as_str()) ); - let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) - .file_name(file_name) - .mime_str(reference_image.mime_type.as_str()) - .map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "构造拼图 VectorEngine 图片编辑参考图失败:{error}" - )) - })?; - let form = reqwest::multipart::Form::new() - .part("image", image_part) - .text("model", image_model.request_model_name().to_string()) - .text( - "prompt", - build_puzzle_vector_engine_prompt(prompt, negative_prompt), - ) - .text("n", candidate_count.clamp(1, 1).to_string()) - .text("size", size.to_string()); - let request_started_at = Instant::now(); - let response = http_client - .post(request_url.as_str()) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::ACCEPT, "application/json") - .multipart(form) - .send() - .await - .map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "创建拼图 VectorEngine 图片编辑任务失败:{error}" - )) - })?; - let status = response.status(); - 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(), + let generated = create_openai_image_edit_with_references( + http_client, + &settings.to_openai_settings(), + prompt, + Some(negative_prompt), size, - reference_mime = %reference_image.mime_type, - reference_bytes = reference_image.bytes_len, - elapsed_ms = request_started_at.elapsed().as_millis() as u64, - "拼图 VectorEngine 图片编辑 HTTP 返回" - ); - let response_text = response.text().await.map_err(|error| { - map_puzzle_vector_engine_request_error(format!( - "读取拼图 VectorEngine 图片编辑响应失败:{error}" - )) - })?; - if !status.is_success() { - return Err(map_puzzle_vector_engine_upstream_error( - status, - response_text.as_str(), - "创建拼图 VectorEngine 图片编辑任务失败", - )); - } - - let payload = parse_puzzle_json_payload( - response_text.as_str(), - "解析拼图 VectorEngine 图片编辑响应失败", - )?; - let image_urls = extract_puzzle_image_urls(&payload); - if !image_urls.is_empty() { - return download_puzzle_images_from_urls(http_client, task_id, image_urls, candidate_count) - .await; - } - let b64_images = extract_puzzle_b64_images(&payload); - if !b64_images.is_empty() { - return Ok(puzzle_images_from_base64( - task_id, - b64_images, - candidate_count, - )); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": "拼图 VectorEngine 图片编辑未返回图片", - })), + candidate_count, + &[OpenAiReferenceImage { + bytes: reference_image.bytes.clone(), + mime_type: reference_image.mime_type.clone(), + file_name, + }], + "拼图 VectorEngine 图片编辑失败", ) + .await?; + + Ok(PuzzleGeneratedImages { + task_id: generated.task_id, + images: generated + .images + .into_iter() + .map(PuzzleDownloadedImage::from_openai_image) + .collect(), + }) } pub(crate) fn build_puzzle_downloaded_image_reference( @@ -569,42 +452,6 @@ pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: & format!("{prompt}\n避免:{negative_prompt}") } -pub(crate) fn puzzle_vector_engine_images_generation_url( - settings: &PuzzleVectorEngineSettings, -) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/generations", settings.base_url) - } else { - format!("{}/v1/images/generations", settings.base_url) - } -} - -pub(crate) fn puzzle_vector_engine_images_edit_url( - settings: &PuzzleVectorEngineSettings, -) -> String { - if settings.base_url.ends_with("/v1") { - format!("{}/images/edits", settings.base_url) - } else { - format!("{}/v1/images/edits", settings.base_url) - } -} - -pub(crate) async fn download_puzzle_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - { - images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); - } - Ok(PuzzleGeneratedImages { task_id, images }) -} - pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> { source .trim() @@ -890,40 +737,6 @@ async fn download_signed_puzzle_reference_image( }) } -pub(crate) async fn download_puzzle_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - let response = http_client.get(image_url).send().await.map_err(|error| { - map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}")) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let bytes = response.bytes().await.map_err(|error| { - map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}")) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "puzzle-image", - "message": "下载拼图正式图片失败", - "status": status.as_u16(), - })), - ); - } - let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); - Ok(PuzzleDownloadedImage { - extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes: bytes.to_vec(), - }) -} - pub(crate) async fn persist_puzzle_generated_asset( state: &PuzzleApiState, owner_user_id: &str, @@ -1197,18 +1010,6 @@ pub(crate) fn build_puzzle_level_asset_metadata( ]) } -pub(crate) fn parse_puzzle_json_payload( - raw_text: &str, - fallback_message: &str, -) -> Result { - serde_json::from_str::(raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": format!("{fallback_message}:{error}"), - })) - }) -} - pub(crate) fn parse_puzzle_image_data_url(value: &str) -> Option { let body = value.strip_prefix("data:")?; let (mime_type, data) = body.split_once(";base64,")?; @@ -1249,49 +1050,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option> { Some(output) } -pub(crate) fn extract_puzzle_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_puzzle_strings_by_key(payload, "image", &mut urls); - collect_puzzle_strings_by_key(payload, "url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -pub(crate) fn extract_puzzle_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_puzzle_strings_by_key(payload, "b64_json", &mut values); - values -} - -pub(crate) fn puzzle_images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> PuzzleGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - .filter_map(|raw| decode_puzzle_generated_image_base64(raw.as_str())) - .collect(); - - PuzzleGeneratedImages { task_id, images } -} - -pub(crate) fn decode_puzzle_generated_image_base64(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_puzzle_image_mime_type(bytes.as_slice()); - Some(PuzzleDownloadedImage { - extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { let mut results = Vec::new(); collect_puzzle_strings_by_key(payload, target_key, &mut results); @@ -1333,22 +1091,6 @@ pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec String { - if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { - return "image/png".to_string(); - } - if bytes.starts_with(b"\xFF\xD8\xFF") { - return "image/jpeg".to_string(); - } - if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - return "image/webp".to_string(); - } - if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { - return "image/gif".to_string(); - } - "image/png".to_string() -} - pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') @@ -1387,21 +1129,6 @@ pub(crate) fn map_puzzle_image_request_error(message: String) -> AppError { })) } -pub(crate) fn map_puzzle_vector_engine_request_error(message: String) -> AppError { - let is_timeout = is_puzzle_request_timeout_message(message.as_str()); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "message": message, - "timeout": is_timeout, - })) -} - pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool { let lower = message.to_ascii_lowercase(); lower.contains("timed out") @@ -1410,64 +1137,6 @@ pub(crate) fn is_puzzle_request_timeout_message(message: &str) -> bool { || lower.contains("deadline has elapsed") } -pub(crate) fn map_puzzle_vector_engine_upstream_error( - upstream_status: reqwest::StatusCode, - raw_text: &str, - fallback_message: &str, -) -> AppError { - let message = parse_puzzle_api_error_message(raw_text, fallback_message); - let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); - let is_timeout = is_puzzle_request_timeout_message(message.as_str()) - || is_puzzle_request_timeout_message(raw_excerpt.as_str()); - let status = if is_timeout { - StatusCode::GATEWAY_TIMEOUT - } else { - StatusCode::BAD_GATEWAY - }; - tracing::warn!( - provider = VECTOR_ENGINE_PROVIDER, - upstream_status = upstream_status.as_u16(), - timeout = is_timeout, - message = %message, - raw_excerpt = %raw_excerpt, - "拼图 VectorEngine 上游请求失败" - ); - - AppError::from_status(status).with_details(json!({ - "provider": VECTOR_ENGINE_PROVIDER, - "upstreamStatus": upstream_status.as_u16(), - "message": message, - "rawExcerpt": raw_excerpt, - "timeout": is_timeout, - })) -} - -pub(crate) fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String { - let trimmed = raw_text.trim(); - if trimmed.is_empty() { - return fallback_message.to_string(); - } - if let Ok(payload) = serde_json::from_str::(trimmed) - && let Some(message) = find_first_puzzle_string_by_key(&payload, "message") - { - return message; - } - fallback_message.to_string() -} - -pub(crate) fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { - let normalized = raw_text.split_whitespace().collect::>().join(" "); - if normalized.chars().count() <= max_chars { - return normalized; - } - - let keep_chars = max_chars.saturating_sub(3); - format!( - "{}...", - normalized.chars().take(keep_chars).collect::() - ) -} - pub(crate) fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } diff --git a/server-rs/crates/platform-image/Cargo.toml b/server-rs/crates/platform-image/Cargo.toml new file mode 100644 index 00000000..cafad647 --- /dev/null +++ b/server-rs/crates/platform-image/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "platform-image" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +base64 = { workspace = true } +reqwest = { workspace = true, features = ["json", "multipart", "rustls-tls"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["time"] } +tracing = { workspace = true } diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs new file mode 100644 index 00000000..0c6daf44 --- /dev/null +++ b/server-rs/crates/platform-image/src/lib.rs @@ -0,0 +1,1362 @@ +use std::{error::Error, fmt, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::header; +use serde_json::{Map, Value, json}; + +pub const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; +pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; +pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine"; + +#[derive(Clone, Debug)] +pub struct VectorEngineImageSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub struct GeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub struct DownloadedImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +#[derive(Clone, Debug)] +pub struct ReferenceImage { + pub bytes: Vec, + pub mime_type: String, + pub file_name: String, +} + +#[derive(Clone, Debug)] +pub struct PlatformImageFailureAudit { + pub provider: &'static str, + pub endpoint: String, + pub operation: String, + pub failure_stage: &'static str, + pub status_code: Option, + pub status_class: Option<&'static str>, + pub timeout: bool, + pub retryable: bool, + pub error_message: String, + pub error_source: Option, + pub raw_excerpt: Option, + pub latency_ms: Option, + pub prompt_chars: Option, + pub reference_image_count: Option, + pub image_model: Option<&'static str>, +} + +#[derive(Clone, Debug)] +pub enum PlatformImageError { + InvalidConfig { + provider: &'static str, + message: String, + }, + InvalidRequest { + provider: &'static str, + message: String, + }, + Request { + provider: &'static str, + message: String, + endpoint: Option, + timeout: bool, + connect: bool, + request: bool, + body: bool, + status_code: Option, + source: Option, + audit: Option, + }, + Upstream { + provider: &'static str, + message: String, + upstream_status: u16, + raw_excerpt: String, + audit: Option, + }, + ResponseParse { + provider: &'static str, + message: String, + raw_excerpt: String, + audit: Option, + }, + MissingImage { + provider: &'static str, + message: String, + audit: Option, + }, +} + +impl PlatformImageError { + pub fn provider(&self) -> &'static str { + match self { + Self::InvalidConfig { provider, .. } + | Self::InvalidRequest { provider, .. } + | Self::Request { provider, .. } + | Self::Upstream { provider, .. } + | Self::ResponseParse { provider, .. } + | Self::MissingImage { provider, .. } => provider, + } + } + + pub fn message(&self) -> &str { + match self { + Self::InvalidConfig { message, .. } + | Self::InvalidRequest { message, .. } + | Self::Request { message, .. } + | Self::Upstream { message, .. } + | Self::ResponseParse { message, .. } + | Self::MissingImage { message, .. } => message, + } + } + + pub fn audit(&self) -> Option<&PlatformImageFailureAudit> { + match self { + Self::Request { audit, .. } + | Self::Upstream { audit, .. } + | Self::ResponseParse { audit, .. } + | Self::MissingImage { audit, .. } => audit.as_ref(), + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => None, + } + } + + pub fn status_hint(&self) -> PlatformImageStatusHint { + match self { + Self::InvalidConfig { .. } => PlatformImageStatusHint::ServiceUnavailable, + Self::InvalidRequest { .. } => PlatformImageStatusHint::BadRequest, + Self::Request { timeout, .. } if *timeout => PlatformImageStatusHint::GatewayTimeout, + Self::Upstream { message, raw_excerpt, .. } + if is_timeout_message(message) || is_timeout_message(raw_excerpt) => + { + PlatformImageStatusHint::GatewayTimeout + } + Self::Request { .. } + | Self::Upstream { .. } + | Self::ResponseParse { .. } + | Self::MissingImage { .. } => PlatformImageStatusHint::BadGateway, + } + } +} + +impl fmt::Display for PlatformImageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.message()) + } +} + +impl Error for PlatformImageError {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlatformImageStatusHint { + BadRequest, + ServiceUnavailable, + BadGateway, + GatewayTimeout, +} + +pub fn build_vector_engine_image_http_client( + settings: &VectorEngineImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms.max(1))) + .http1_only() + .build() + .map_err(|error| PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("构造 VectorEngine 图片生成 HTTP 客户端失败:{error}"), + }) +} + +pub async fn create_vector_engine_image_generation( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + if !reference_images.is_empty() { + let resolved_references = + resolve_reference_images(http_client, reference_images, failure_context).await?; + return create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + candidate_count, + resolved_references.as_slice(), + failure_context, + ) + .await; + } + + let request_url = vector_engine_images_generation_url(settings); + let normalized_size = normalize_image_size(size); + let request_body = build_vector_engine_image_request_body( + prompt, + negative_prompt, + normalized_size.as_str(), + candidate_count, + reference_images, + ); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片生成任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count = reference_images.len(), + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片生成 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片生成响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_images.len()), + candidate_count, + "vector-engine", + ) + .await +} + +pub async fn create_vector_engine_image_edit( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + reference_image: &ReferenceImage, + failure_context: &str, +) -> Result { + create_vector_engine_image_edit_with_references( + http_client, + settings, + prompt, + negative_prompt, + size, + 1, + std::slice::from_ref(reference_image), + failure_context, + ) + .await +} + +pub async fn create_vector_engine_image_edit_with_references( + http_client: &reqwest::Client, + settings: &VectorEngineImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[ReferenceImage], + failure_context: &str, +) -> Result { + if reference_images.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"), + }); + } + + let request_url = vector_engine_images_edit_url(settings); + let normalized_size = normalize_image_size(size); + + let mut form = reqwest::multipart::Form::new() + .text("model", GPT_IMAGE_2_MODEL.to_string()) + .text("prompt", build_prompt_with_negative(prompt, negative_prompt)) + .text("n", candidate_count.clamp(1, 4).to_string()) + .text("size", normalized_size.clone()); + + for reference_image in reference_images.iter().take(5) { + let image_part = reqwest::multipart::Part::bytes(reference_image.bytes.clone()) + .file_name(reference_image.file_name.clone()) + .mime_str(reference_image.mime_type.as_str()) + .map_err(|error| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:构造参考图失败:{error}"), + })?; + form = form.part("image", image_part); + } + + let reference_image_count = reference_images.iter().take(5).count(); + let started_at = std::time::Instant::now(); + let response = match http_client + .post(request_url.as_str()) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::ACCEPT, "application/json") + .multipart(form) + .send() + .await + { + Ok(response) => response, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + "request_send", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + let response_status = response.status(); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status.as_u16(), + prompt_chars = prompt.chars().count(), + size = %normalized_size, + reference_image_count, + elapsed_ms = started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片编辑 HTTP 返回" + ); + let response_text = match response.text().await { + Ok(response_text) => response_text, + Err(error) => { + return Err(map_reqwest_error( + format!("{failure_context}:读取图片编辑响应失败").as_str(), + request_url.as_str(), + "response_body", + error, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + )); + } + }; + handle_vector_engine_response( + http_client, + request_url.as_str(), + response_status.as_u16(), + response_text.as_str(), + failure_context, + started_at.elapsed().as_millis() as u64, + Some(prompt.chars().count()), + Some(reference_image_count), + candidate_count, + "vector-engine-edit", + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn handle_vector_engine_response( + http_client: &reqwest::Client, + request_url: &str, + response_status: u16, + response_text: &str, + failure_context: &str, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, + candidate_count: u32, + task_prefix: &str, +) -> Result { + if !(200..=299).contains(&response_status) { + let message = parse_api_error_message(response_text, failure_context); + let raw_excerpt = truncate_raw(response_text); + let audit = build_failure_audit( + request_url, + failure_context, + "upstream_status", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(raw_excerpt.clone()), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + upstream_status = response_status, + timeout = is_timeout_message(message.as_str()) || is_timeout_message(raw_excerpt.as_str()), + retryable = audit.retryable, + message = %message, + raw_excerpt = %raw_excerpt, + "VectorEngine 图片生成上游错误" + ); + return Err(PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message, + upstream_status: response_status, + raw_excerpt, + audit: Some(audit), + }); + } + + let response_json = match parse_json_payload(response_text, failure_context) { + Ok(response_json) => response_json, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "response_parse", + Some(response_status), + None, + false, + false, + error.message(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + message = %error.message(), + "VectorEngine 图片响应解析失败" + ); + return Err(error.with_audit(audit)); + } + }; + let task_id = extract_generation_id(&response_json.payload) + .unwrap_or_else(|| format!("{task_prefix}-{}", current_utc_micros())); + let actual_prompt = find_first_string_by_key(&response_json.payload, "revised_prompt") + .or_else(|| find_first_string_by_key(&response_json.payload, "actual_prompt")); + let image_urls = extract_image_urls(&response_json.payload); + if !image_urls.is_empty() { + let download_started_at = std::time::Instant::now(); + let mut generated = match download_images_from_urls( + http_client, + task_id, + image_urls, + candidate_count, + ) + .await + { + Ok(generated) => generated, + Err(error) => { + let audit = build_failure_audit( + request_url, + failure_context, + "image_download", + Some(response_status), + Some("5xx"), + false, + false, + error.message(), + None, + None, + Some(download_started_at.elapsed().as_millis() as u64), + prompt_chars, + reference_image_count, + ); + return Err(error.with_audit(audit)); + } + }; + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + elapsed_ms = download_started_at.elapsed().as_millis() as u64, + failure_context, + "VectorEngine 图片下载完成" + ); + return Ok(generated); + } + let b64_images = extract_b64_images(&response_json.payload); + if !b64_images.is_empty() { + let mut generated = images_from_base64(task_id, b64_images, candidate_count); + generated.actual_prompt = actual_prompt; + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + image_count = generated.images.len(), + failure_context, + "VectorEngine 图片 base64 解码完成" + ); + return Ok(generated); + } + + let message = format!("{failure_context}:VectorEngine 未返回图片地址"); + let audit = build_failure_audit( + request_url, + failure_context, + "missing_image", + Some(response_status), + None, + false, + false, + message.as_str(), + None, + Some(truncate_raw(response_text)), + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + status = response_status, + raw_excerpt = %truncate_raw(response_text), + "VectorEngine 图片响应未返回图片" + ); + Err(PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message, + audit: Some(audit), + }) +} + +pub fn build_vector_engine_image_request_body( + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + _reference_images: &[String], +) -> Value { + let body = Map::from_iter([ + ( + "model".to_string(), + Value::String(GPT_IMAGE_2_MODEL.to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_prompt_with_negative(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 4))), + ( + "size".to_string(), + Value::String(normalize_image_size(size)), + ), + ]); + + Value::Object(body) +} + +pub fn normalize_image_size(size: &str) -> String { + match size.trim() { + "1024*1024" | "1024x1024" | "1:1" => "1024x1024", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" | "1536x1024" | "2048x1152" + | "2k" => "1536x1024", + "1024*1536" | "1024x1536" | "9:16" => "1024x1536", + value if !value.is_empty() => value, + _ => "1024x1024", + } + .to_string() +} + +pub fn vector_engine_images_generation_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/generations", settings.base_url) + } else { + format!("{}/v1/images/generations", settings.base_url) + } +} + +pub fn vector_engine_images_edit_url(settings: &VectorEngineImageSettings) -> String { + if settings.base_url.ends_with("/v1") { + format!("{}/images/edits", settings.base_url) + } else { + format!("{}/v1/images/edits", settings.base_url) + } +} + +pub async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + map_simple_request_error(format!("下载生成图片失败:{error}"), Some(image_url.to_string())) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_simple_request_error(format!("读取生成图片内容失败:{error}"), Some(image_url.to_string())) + })?; + if !status.is_success() { + return Err(PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "下载生成图片失败".to_string(), + endpoint: Some(image_url.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: Some(status.as_u16()), + source: None, + audit: None, + }); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedImage { + extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: body.to_vec(), + }) +} + +async fn download_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + { + images.push(download_remote_image(http_client, image_url.as_str()).await?); + } + Ok(GeneratedImages { + task_id, + actual_prompt: None, + images, + }) +} + +async fn resolve_reference_images( + http_client: &reqwest::Client, + reference_images: &[String], + failure_context: &str, +) -> Result, PlatformImageError> { + let mut resolved = Vec::new(); + for (index, source) in reference_images.iter().take(5).enumerate() { + let source = source.trim(); + if source.is_empty() { + continue; + } + if let Some(reference_image) = parse_reference_image_data_url(source, index)? { + resolved.push(reference_image); + continue; + } + if source.starts_with("http://") || source.starts_with("https://") { + let downloaded = download_remote_image(http_client, source) + .await + .map_err(|error| PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:下载参考图失败:{error}"), + endpoint: Some(source.to_string()), + timeout: false, + connect: false, + request: false, + body: false, + status_code: None, + source: None, + audit: None, + })?; + resolved.push(ReferenceImage { + bytes: downloaded.bytes, + mime_type: downloaded.mime_type.clone(), + file_name: format!( + "reference-{index}.{}", + mime_to_extension(downloaded.mime_type.as_str()) + ), + }); + continue; + } + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:参考图必须是图片 Data URL 或 HTTP(S) URL。"), + }); + } + + if resolved.is_empty() { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:图片编辑需要至少一张参考图。"), + }); + } + + Ok(resolved) +} + +fn parse_reference_image_data_url( + source: &str, + index: usize, +) -> Result, PlatformImageError> { + let Some(body) = source.strip_prefix("data:") else { + return Ok(None); + }; + let Some((mime_type, data)) = body.split_once(";base64,") else { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是 base64 图片。".to_string(), + }); + }; + if !mime_type.starts_with("image/") { + return Err(PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "参考图 Data URL 必须是图片类型。".to_string(), + }); + } + let bytes = BASE64_STANDARD + .decode(data.trim()) + .map_err(|error| PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("参考图 Data URL 解码失败:{error}"), + })?; + let mime_type = normalize_downloaded_image_mime_type(mime_type); + Ok(Some(ReferenceImage { + bytes, + file_name: format!( + "reference-{index}.{}", + mime_to_extension(mime_type.as_str()) + ), + mime_type, + })) +} + +fn images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> GeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + .filter_map(|raw| decode_generated_image_base64(raw.as_str())) + .collect(); + + GeneratedImages { + task_id, + actual_prompt: None, + images, + } +} + +fn decode_generated_image_base64(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_image_mime_type(bytes.as_slice()); + Some(DownloadedImage { + extension: mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +fn parse_json_payload( + raw_text: &str, + failure_context: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| PlatformImageError::ResponseParse { + provider: VECTOR_ENGINE_PROVIDER, + message: format!("{failure_context}:解析响应失败:{error}"), + raw_excerpt: truncate_raw(raw_text), + audit: None, + }) +} + +fn map_reqwest_error( + context: &str, + request_url: &str, + failure_stage: &'static str, + error: reqwest::Error, + latency_ms: u64, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageError { + let is_timeout = error.is_timeout(); + let is_connect = error.is_connect(); + let source = error.source().map(ToString::to_string); + let message = format!("{context}:{error}"); + let audit = build_failure_audit( + request_url, + context, + failure_stage, + error.status().map(|status| status.as_u16()), + None, + is_timeout, + is_connect, + message.as_str(), + source.clone(), + None, + Some(latency_ms), + prompt_chars, + reference_image_count, + ); + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + failure_stage, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + status = error.status().map(|status| status.as_u16()).unwrap_or_default(), + source = %source.clone().unwrap_or_default(), + message = %message, + elapsed_ms = latency_ms, + prompt_chars, + reference_image_count, + "VectorEngine 图片请求发送失败" + ); + + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint: Some(request_url.to_string()), + timeout: is_timeout, + connect: is_connect, + request: error.is_request(), + body: error.is_body(), + status_code: error.status().map(|status| status.as_u16()), + source, + audit: Some(audit), + } +} + +fn map_simple_request_error(message: String, endpoint: Option) -> PlatformImageError { + PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message, + endpoint, + timeout: false, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + } +} + +#[allow(clippy::too_many_arguments)] +fn build_failure_audit( + request_url: &str, + operation: &str, + failure_stage: &'static str, + status_code: Option, + status_class: Option<&'static str>, + timeout: bool, + connect: bool, + error_message: &str, + error_source: Option, + raw_excerpt: Option, + latency_ms: Option, + prompt_chars: Option, + reference_image_count: Option, +) -> PlatformImageFailureAudit { + PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: request_url.to_string(), + operation: operation.to_string(), + failure_stage, + status_code, + status_class, + timeout, + retryable: is_retryable_external_api_failure(status_code, timeout, connect), + error_message: error_message.to_string(), + error_source, + raw_excerpt, + latency_ms, + prompt_chars, + reference_image_count, + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + } +} + +fn is_retryable_external_api_failure( + status_code: Option, + timeout: bool, + connect: bool, +) -> bool { + timeout || connect || status_code.is_some_and(|status| status == 429 || status == 408 || status >= 500) +} + +fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { + let prompt = prompt.trim(); + let Some(negative_prompt) = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return prompt.to_string(); + }; + + format!("{prompt}\n避免:{negative_prompt}") +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for pointer in [ + "/error/message", + "/message", + "/output/message", + "/data/message", + ] { + if let Some(message) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + } + for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { + if let Some(code) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + } + + raw_text.trim().to_string() +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + match nested_value { + Value::String(text) => { + let text = text.trim(); + if !text.is_empty() { + results.push(text.to_string()); + continue; + } + } + Value::Array(entries) => { + for entry in entries { + if let Some(text) = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_generation_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "id") + .or_else(|| find_first_string_by_key(payload, "created")) + .or_else(|| find_first_string_by_key(payload, "request_id")) +} + +fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "url", &mut urls); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "image_url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn extract_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_strings_by_key(payload, "b64_json", &mut values); + values +} + +fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn infer_image_mime_type(bytes: &[u8]) -> String { + if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + return "image/png".to_string(); + } + if bytes.starts_with(b"\xFF\xD8\xFF") { + return "image/jpeg".to_string(); + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return "image/webp".to_string(); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return "image/gif".to_string(); + } + "image/png".to_string() +} + +fn is_timeout_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("operation timed out") + || lower.contains("deadline has elapsed") +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +impl PlatformImageError { + fn with_audit(self, audit: PlatformImageFailureAudit) -> Self { + match self { + Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + .. + } => Self::Request { + provider, + message, + endpoint, + timeout, + connect, + request, + body, + status_code, + source, + audit: Some(audit), + }, + Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + .. + } => Self::Upstream { + provider, + message, + upstream_status, + raw_excerpt, + audit: Some(audit), + }, + Self::ResponseParse { + provider, + message, + raw_excerpt, + .. + } => Self::ResponseParse { + provider, + message, + raw_excerpt, + audit: Some(audit), + }, + Self::MissingImage { + provider, message, .. + } => Self::MissingImage { + provider, + message, + audit: Some(audit), + }, + Self::InvalidConfig { .. } | Self::InvalidRequest { .. } => self, + } + } +} + +struct ParsedJsonPayload { + payload: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use serde_json::json; + + #[test] + fn request_body_normalizes_size_prompt_and_candidate_count() { + let body = build_vector_engine_image_request_body( + " 风雨夜里的街道 ", + Some(" 低清,水印 "), + " 1:1 ", + 10, + &["data:image/png;base64,AAAA".to_string()], + ); + + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "1024x1024"); + assert_eq!(body["n"], 4); + assert_eq!(body["prompt"], "风雨夜里的街道\n避免:低清,水印"); + assert!(body.get("image").is_none()); + } + + #[test] + fn provider_urls_normalize_root_and_v1_base_urls() { + let root_settings = VectorEngineImageSettings { + base_url: "https://vector.example".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + let v1_settings = VectorEngineImageSettings { + base_url: "https://vector.example/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000, + }; + + assert_eq!( + vector_engine_images_generation_url(&root_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_generation_url(&v1_settings), + "https://vector.example/v1/images/generations" + ); + assert_eq!( + vector_engine_images_edit_url(&root_settings), + "https://vector.example/v1/images/edits" + ); + assert_eq!( + vector_engine_images_edit_url(&v1_settings), + "https://vector.example/v1/images/edits" + ); + } + + #[test] + fn data_url_and_base64_image_decoding_preserves_image_metadata() { + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest") + ); + + let reference = parse_reference_image_data_url(&data_url, 2) + .expect("data url should parse") + .expect("image data url should be accepted"); + assert_eq!(reference.file_name, "reference-2.png"); + assert_eq!(reference.mime_type, "image/png"); + assert_eq!(reference.bytes, b"\x89PNG\r\n\x1A\nrest"); + + let image = decode_generated_image_base64(BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest").as_str()) + .expect("base64 image should decode"); + assert_eq!(image.extension, "png"); + assert_eq!(image.mime_type, "image/png"); + assert_eq!(image.bytes, b"\x89PNG\r\n\x1A\nrest"); + } + + #[test] + fn error_status_hints_and_audit_fields_are_structured() { + let audit = PlatformImageFailureAudit { + provider: VECTOR_ENGINE_PROVIDER, + endpoint: "https://vector.example/v1/images/generations".to_string(), + operation: "图片生成失败".to_string(), + failure_stage: "upstream_status", + status_code: Some(504), + status_class: Some("5xx"), + timeout: true, + retryable: true, + error_message: "上游超时".to_string(), + error_source: Some("read timeout".to_string()), + raw_excerpt: Some("{\"error\":\"timeout\"}".to_string()), + latency_ms: Some(987), + prompt_chars: Some(64), + reference_image_count: Some(2), + image_model: Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL), + }; + + let request_error = PlatformImageError::Request { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求发送失败".to_string(), + endpoint: Some("https://vector.example/v1/images/generations".to_string()), + timeout: true, + connect: false, + request: true, + body: false, + status_code: None, + source: None, + audit: None, + }; + let invalid_config = PlatformImageError::InvalidConfig { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺少配置".to_string(), + }; + let invalid_request = PlatformImageError::InvalidRequest { + provider: VECTOR_ENGINE_PROVIDER, + message: "请求不合法".to_string(), + }; + let upstream_timeout = PlatformImageError::Upstream { + provider: VECTOR_ENGINE_PROVIDER, + message: "upstream timeout".to_string(), + upstream_status: 502, + raw_excerpt: "deadline has elapsed".to_string(), + audit: Some(audit.clone()), + }; + + assert_eq!(invalid_config.status_hint(), PlatformImageStatusHint::ServiceUnavailable); + assert_eq!(invalid_request.status_hint(), PlatformImageStatusHint::BadRequest); + assert_eq!(request_error.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!(upstream_timeout.status_hint(), PlatformImageStatusHint::GatewayTimeout); + assert_eq!( + PlatformImageError::MissingImage { + provider: VECTOR_ENGINE_PROVIDER, + message: "缺图".to_string(), + audit: Some(audit.clone()), + } + .status_hint(), + PlatformImageStatusHint::BadGateway + ); + + let audit_ref = upstream_timeout.audit().expect("audit should be preserved"); + assert_eq!(audit_ref.provider, VECTOR_ENGINE_PROVIDER); + assert_eq!(audit_ref.endpoint, "https://vector.example/v1/images/generations"); + assert_eq!(audit_ref.status_code, Some(504)); + assert_eq!(audit_ref.status_class, Some("5xx")); + assert!(audit_ref.timeout); + assert!(audit_ref.retryable); + assert_eq!(audit_ref.reference_image_count, Some(2)); + assert_eq!(audit_ref.image_model, Some(VECTOR_ENGINE_GPT_IMAGE_2_MODEL)); + assert!(invalid_config.audit().is_none()); + assert!(invalid_request.audit().is_none()); + } + + #[test] + fn extract_image_urls_and_b64_values_are_deduped() { + let payload = json!({ + "data": [ + {"image": "https://example.com/a.png"}, + {"url": "https://example.com/a.png"}, + {"image_url": "ftp://example.com/b.png"}, + {"url": "https://example.com/b.png"} + ], + "nested": { + "b64_json": ["YWJj", "ZGVm"] + } + }); + + assert_eq!( + extract_image_urls(&payload), + vec![ + "https://example.com/a.png".to_string(), + "https://example.com/b.png".to_string() + ] + ); + assert_eq!( + extract_b64_images(&payload), + vec!["YWJj".to_string(), "ZGVm".to_string()] + ); + } +} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a4537c20..e9286a98 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -2735,6 +2735,10 @@ export function PlatformEntryFlowShellImpl({ ? 'platform-theme--dark' : 'platform-theme--light'; const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); + const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{ + title: string; + message: string; + } | null>(null); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] = @@ -3319,7 +3323,7 @@ export function PlatformEntryFlowShellImpl({ [draftGenerationNotices], ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( - async (pointsCost: number, setError: (message: string | null) => void) => { + async (pointsCost: number) => { try { const latestDashboard = await getPlatformProfileDashboard( RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, @@ -3327,25 +3331,26 @@ export function PlatformEntryFlowShellImpl({ platformBootstrap.setProfileDashboard(latestDashboard); const walletBalance = resolveProfileWalletBalance(latestDashboard); if (walletBalance >= pointsCost) { + setDraftGenerationPointNotice(null); return true; } - setError( - `泥点不足,本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`, + setDraftGenerationPointNotice( + { + title: '泥点不足', + message: `本次需要 ${pointsCost} 泥点,当前 ${walletBalance} 泥点。`, + }, ); - enterCreateTab(); - selectionStageRef.current = 'platform'; - setSelectionStage('platform'); return false; } catch { - setError('读取泥点余额失败,请稍后重试。'); - enterCreateTab(); - selectionStageRef.current = 'platform'; - setSelectionStage('platform'); + setDraftGenerationPointNotice({ + title: '读取泥点余额失败', + message: '请稍后重试。', + }); return false; } }, - [enterCreateTab, platformBootstrap, setSelectionStage], + [platformBootstrap], ); const resolveBigFishErrorMessage = useCallback( @@ -5294,30 +5299,27 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); return ensureEnoughDraftGenerationPointsFromServer( PUZZLE_DRAFT_GENERATION_POINT_COST, - (message) => { - setPuzzleCreationError(message); - setPuzzleError(message); - }, ); }, [ ensureEnoughDraftGenerationPointsFromServer, - setPuzzleCreationError, - setPuzzleError, ]); const preflightMatch3DDraftGeneration = useCallback(async () => { setMatch3DError(null); return ensureEnoughDraftGenerationPointsFromServer( MATCH3D_DRAFT_GENERATION_POINT_COST, - setMatch3DError, ); - }, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]); + }, [ensureEnoughDraftGenerationPointsFromServer]); const preflightBarkBattleDraftGeneration = useCallback(async () => { setBarkBattleError(null); return ensureEnoughDraftGenerationPointsFromServer( BARK_BATTLE_DRAFT_GENERATION_POINT_COST, - setBarkBattleError, ); }, [ensureEnoughDraftGenerationPointsFromServer]); + const draftGenerationPointNoticeDescription = draftGenerationPointNotice + ? draftGenerationPointNotice.title === '读取泥点余额失败' + ? '当前表单不会丢失,关闭后可继续编辑,稍后再试。' + : '当前表单不会丢失,关闭后可继续编辑或补足泥点再继续。' + : undefined; const recoverCompletedPuzzleDraftGeneration = useCallback( async ({ sessionId, @@ -15722,6 +15724,29 @@ export function PlatformEntryFlowShellImpl({ }} /> ) : null} + setDraftGenerationPointNotice(null)} + closeOnBackdrop + size="sm" + overlayClassName={`platform-theme ${platformThemeClass} !items-center`} + panelClassName="platform-remap-surface rounded-[1.75rem]" + footer={ + + } + > +
+ {draftGenerationPointNotice?.message} +
+
item.id)).toEqual(['visual-novel']); }); + +test('falls back when backend creation type category metadata is missing', () => { + const cards = derivePlatformCreationTypes([ + { + id: 'legacy-entry', + title: '历史入口', + subtitle: '旧数据缺少分类字段', + badge: '可创建', + imageSrc: '/creation-type-references/puzzle.webp', + visible: true, + open: true, + sortOrder: 10, + categoryId: undefined as unknown as string, + categoryLabel: undefined as unknown as string, + categorySortOrder: 0, + updatedAtMicros: 1, + }, + ]); + + expect(cards[0]).toEqual( + expect.objectContaining({ + id: 'legacy-entry', + categoryId: 'recent', + categoryLabel: '最近创作', + }), + ); + expect(groupVisiblePlatformCreationTypes(cards)).toEqual([ + expect.objectContaining({ + id: 'recent', + label: '最近创作', + }), + ]); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 3aad4e59..11c01c12 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -55,13 +55,13 @@ export function isPlatformCreationTypeOpen( ); } -function normalizeCategoryId(value: string) { - const normalized = value.trim(); +function normalizeCategoryId(value: string | null | undefined) { + const normalized = typeof value === 'string' ? value.trim() : ''; return normalized || FALLBACK_CREATION_CATEGORY_ID; } -function normalizeCategoryLabel(value: string) { - const normalized = value.trim(); +function normalizeCategoryLabel(value: string | null | undefined) { + const normalized = typeof value === 'string' ? value.trim() : ''; return normalized || FALLBACK_CREATION_CATEGORY_LABEL; } diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 6e0dc361..12e75aa1 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1085,6 +1085,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({ }) => (
汪汪声浪配置表单
+
{showBackButton ? 'back-visible' : 'back-hidden'}
@@ -3581,11 +3585,20 @@ test('bark battle form checks mud points before creating image assets', async () await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('汪汪声浪')); + const titleInput = await screen.findByLabelText('汪汪作品标题'); + await user.clear(titleInput); + await user.type(titleInput, '自定义声浪杯'); await user.click(await screen.findByRole('button', { name: '生成草稿' })); + const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'), + within(noticeDialog).getByText('本次需要 3 泥点,当前 2 泥点。'), ).toBeTruthy(); + expect(screen.getByText('汪汪声浪配置表单')).toBeTruthy(); + expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull(); + expect((screen.getByLabelText('汪汪作品标题') as HTMLInputElement).value).toBe( + '自定义声浪杯', + ); expect(createBarkBattleDraft).not.toHaveBeenCalled(); expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled(); }); @@ -4302,11 +4315,15 @@ test('puzzle form checks mud points before creating a draft', async () => { render(); await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); + const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - await screen.findByText('泥点不足,本次需要 2 泥点,当前 1 泥点。'), + within(noticeDialog).getByText('本次需要 2 泥点,当前 1 泥点。'), ).toBeTruthy(); + expect(screen.getByText('拼图工作区:missing-session')).toBeTruthy(); + expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); expect(executePuzzleAgentAction).not.toHaveBeenCalled(); }); @@ -4323,14 +4340,17 @@ test('match3d form checks mud points before creating a draft', async () => { render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); + const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( - await screen.findByText('泥点不足,本次需要 10 泥点,当前 9 泥点。'), + within(noticeDialog).getByText('本次需要 10 泥点,当前 9 泥点。'), ).toBeTruthy(); + expect(screen.getByText('抓大鹅工作区:missing-session')).toBeTruthy(); + expect(screen.queryByRole('tablist', { name: '玩法模板分类' })).toBeNull(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); expect(match3dCreationClient.executeAction).not.toHaveBeenCalled(); });