diff --git a/.env.agent.example b/.env.agent.example index 774572a7a7c09fdb1ddb58ebfc971b3b5b729bc0..78b35b9625a37bcb179580da11360cc183c1dd8a 100644 --- a/.env.agent.example +++ b/.env.agent.example @@ -21,10 +21,11 @@ AGENT_REQUEST_LEASE_MS=600000 AGENT_REQUEST_TTL_SECONDS=86400 AGENT_RECOVERY_INTERVAL_MS=30000 -# 可选:供 OpenAPI 客户端使用的公开基础地址。 +# 可选:供 OpenAPI 客户端使用的公开基础地址。必须是不含凭据、查询参数和片段的 http/https 绝对 URL。 # AGENT_PUBLIC_BASE_URL=http://localhost:4783 # 现有应用配置仍然生效。 OPENAI_API_KEY= +# OpenAI 兼容接口根地址,通常以 /v1 结尾。支持不含凭据、查询参数和片段的 http/https 绝对 URL。 OPENAI_API_BASE_URL=https://api.openai.com/v1 APP_PASSWORD= diff --git a/.env.example b/.env.example index 60375dbacbf3878744ce7491fd0cb8d678fcef7d..ee069b7e838adcf25fad5d2aa7f15ddf2ab7bbff 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,8 @@ OPENAI_API_KEY= # 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。 +# 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。 +# 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。 # 示例:https://api.openai.com/v1 OPENAI_API_BASE_URL= @@ -21,6 +23,8 @@ OPENAI_API_BASE_URL= # - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。 # - ID 只用于日志排查,不会暴露 API Key。 # - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。 +# - BASE_URL 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。 +# - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。 # - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。 # - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。 # - API Key 本身不要包含逗号。 @@ -34,14 +38,27 @@ OPENAI_API_BASE_URL= # OPENAI_CHANNEL_2_ID=backup # OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1 # OPENAI_CHANNEL_2_API_KEYS=sk-backup-1 +# +# 可选:远程明文 HTTP 兼容接口 allowlist。默认只允许 HTTPS 和本机 loopback HTTP。 +# 多个完整 base URL 用英文逗号分隔,仅在确认网络边界安全时启用。 +# OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http://your-internal-compatible-api.example.com/v1 -# 可选:并发流式批处理。默认关闭。 -# 开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。 +# 可选:并发流式批处理容量。 +# 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。 # 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。 # key 出现鉴权、额度或限流类错误后会短暂冷却;渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。 -# ENABLE_STREAMING_BATCH=true # OPENAI_MAX_STREAMS_PER_CREDENTIAL=1 # OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000 +# +# 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的 +# credential/channel 先通过后台 GET /models 探测,成功后才重新进入用户生图流量。 +# 探测不调用 /images/generations,不触发生图费用;MAX_PER_TICK 用于限制探测流量。 +# OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true +# 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。 +# OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true +# OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS=60000 +# OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS=5000 +# OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK=1 # 可选:多上游图片兼容层默认策略。默认保持 Images API JSON 基线。 # IMAGE_GENERATION_BACKEND=images-api @@ -64,7 +81,7 @@ OPENAI_API_BASE_URL= # IMAGE_REAL_SMOKE_SUB2API_API_KEY= # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=https://sub2api.example.com/v1 # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= -# IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.example.com/v1 +# IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1 # IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY= # IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 # IMAGE_REAL_SMOKE_TIMEOUT_MS=240000 diff --git a/.env.real-smoke.example b/.env.real-smoke.example index 563c4cdd3eef51b8d0162199d1c8f354180a587c..8603523b12ae73867b026b16592db3aafbb49bf3 100644 --- a/.env.real-smoke.example +++ b/.env.real-smoke.example @@ -1,6 +1,7 @@ # Independent real upstream smoke targets. # Copy this file to .env.real-smoke.local and fill only the targets you can run. # Do not commit .env.real-smoke.local. +# Every *_BASE_URL must be a http/https absolute URL without credentials, query parameters, or fragments. # Final gate: # npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable @@ -34,7 +35,7 @@ IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low # GPT2Image style Responses image_generation SSE. -IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL= +IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1 IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY= # IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2 IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 diff --git a/.github/workflows/hf-space-keepalive.yml b/.github/workflows/hf-space-keepalive.yml index f16523e0b8351212a801faedea1e80a087cc4e07..0f94b364eb6710a90d7a90dc617d4b2aeecdf39f 100644 --- a/.github/workflows/hf-space-keepalive.yml +++ b/.github/workflows/hf-space-keepalive.yml @@ -16,6 +16,8 @@ jobs: HF_SPACE_KEEPALIVE_URL: ${{ vars.HF_SPACE_KEEPALIVE_URL || 'https://misonl-gpt-image-playground-customer.hf.space' }} HF_SPACE_KEEPALIVE_PATH: /api/auth-status HF_SPACE_KEEPALIVE_TIMEOUT_MS: '30000' + HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '3' + HF_SPACE_KEEPALIVE_RETRY_DELAY_MS: '5000' HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED: 'true' steps: - name: Checkout repository diff --git a/CHANGELOG.md b/CHANGELOG.md index 2593fce471c578cf6fd971067229a9a96a5f1cff..ef73ecb4af797826e1f7e78544d631505c9fb89c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,27 +8,45 @@ ### 新增 +- WebUI 增加 `图像手记` 工作台的显式批量模式:多条提示词逐行形成独立任务,批量进度、暂停、失败项复用和批次历史保持可追溯。 +- WebUI 在省心模式和专业模式中展示“并发批量”状态;只有用户手动启用且当前流式策略、任务数量和渠道容量满足条件时,才会把多图或多提示词拆成并发流式任务。 +- Agent skill 批量脚本支持 `--concurrency N` 并发执行、append-only manifest、续跑、尺寸校验、失败重试和页面 SSE 原始事件留档。 + +### 变更 + +- API URL 校验允许无凭据、无查询参数和无片段的 `http` 或 `https` OpenAI 兼容根地址;自定义 API URL 仍必须与自定义 API Key 成对提供,避免服务端密钥转发到未知地址。 +- WebUI 结果区和最近生成记录补齐连续工作流动作:继续编辑、做变体、复用提示词、对比、收藏和批次折叠,批量模式的底部提示词动作以当前可见批量提示词为准。 + +## [1.4.0] - 2026-05-27 + +### 新增 + - 增加 API 错误排查建议,针对鉴权失败、限流、上游 5xx 和 Cloudflare 524 返回更明确的用户提示。 - 增加图片请求默认质量、错误建议和批量部分失败明细的单元测试。 - 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。 - 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。 - 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。 +- Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE metadata、运行态启用后端和 job polling 语义,辅助脚本支持 `--page-sse`、`--agent`、`--job` 显式路由。 ### 变更 - 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。 -- 页面默认开启流式预览;单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。 +- 页面默认不发送流式请求;用户显式开启流式预览后,单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。 - 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、provider dialect 诊断和扣费解析逻辑。 - 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。 - Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。 - Agent skill 文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。 +### 修复 + +- 收紧 Responses `image_generation` 结果解析,只有标准 base64 或常见位图 `data:image/...;base64,` payload 会被当作可保存图片。 + ## [1.3.0] - 2026-05-12 ### 新增 - 增加服务端运行时能力接口 `/api/runtime-capabilities`,用于返回流式批处理开关、推荐并发和渠道健康状态。 -- 增加 `ENABLE_STREAMING_BATCH` 与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。 +- 增加运行时并发流式批处理能力与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。 - 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。 - 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。 - 增加服务端 credential/channel 失败冷却机制,支持按渠道覆盖冷却窗口。 @@ -93,7 +111,8 @@ - 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。 - 增加 Docker 部署支持和多平台启动脚本。 -[未发布]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.3.0...HEAD +[未发布]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/dc7c8f5855e80cb9507517b5748c718e7155df52...v1.1.0 diff --git a/README.md b/README.md index 5ac5522c4b61c24fc30b4392d48f872c752be5e8..8d364439fd8bb33d7d08c63417c4347dbe888764 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ app_port: 4783 # GPT Image Playground -![Version](https://img.shields.io/badge/version-1.3.0-blue) +![Version](https://img.shields.io/badge/version-1.4.0-blue) ![License](https://img.shields.io/badge/license-MIT-green) ![Node](https://img.shields.io/badge/node-%3E%3D20-339933) @@ -51,8 +51,7 @@ OPENAI_CHANNEL_2_ID=backup OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1 OPENAI_CHANNEL_2_API_KEYS=sk-backup-a,sk-backup-b -# 可选:开启并发流式批处理。默认关闭。 -ENABLE_STREAMING_BATCH=true +# 可选:页面“并发批量”开关使用的服务端容量。 OPENAI_MAX_STREAMS_PER_CREDENTIAL=1 ``` @@ -125,11 +124,13 @@ http://localhost:4783 - `gpt-image-2` 图片编辑:上传源图后用提示词修改图片,可选遮罩。 - Agent API:为 Codex、Claude Code、Gemini 等 Agent 提供强契约接口、幂等重试、结构化错误和产物追踪。 - 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。 +- `图像手记` 工作台:提供文生图、图生图、批量和复用历史四种入口,中央预览、右侧灵感相册和最近生成围绕连续创作流程组织。 - 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。 - 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。 - 流式输出:用户显式开启后支持生成和编辑过程中的局部图片预览。 +- 显式并发批量:用户手动启用后,多图或多提示词任务会按当前渠道容量拆成独立流式任务并发执行;未启用时,普通多图保持单次 `n>1` 请求,批量提示词保持逐条顺序执行。 - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。 -- 发送到编辑:从生成结果或历史记录直接进入编辑模式。 +- 连续工作流:从生成结果或历史记录继续编辑、做变体、复用提示词、对比、下载、分享或保存为灵感。 - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。 - 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。 - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。 @@ -139,24 +140,26 @@ http://localhost:4783 ## 默认行为 - 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。 -- 页面默认不发送流式请求;用户显式开启流式预览后,才会走 SSE 路径。并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。 +- 页面默认使用 `stream_mode=auto`。auto 会优先尝试 SSE;如果上游流式没有最终图,会在同一响应里显式回退到非流式并暴露 `fallback_used`。`stream` 强制流式,`non_stream` 直接走非流式 JSON。普通多图请求默认保持单次 `n>1` 上游请求;批量提示词默认按 `concurrency=1` 逐条执行。用户在页面显式勾选“并发批量”后,才会把多图或多提示词批次拆成多个 `n=1` 流式任务。 +- 批量模式下,每行提示词会形成一条独立任务,统一使用当前尺寸、质量、格式、模型和路由设置;底部提示词动作、批次历史、失败项复用和暂停状态都以当前可见批量提示词为准。 - 服务端会把官方 OpenAI Images 流式事件、gaoren002/new-api 与 sub2api 图片 SSE、OtokAPI `image.generation.*`、Responses `image_generation_call` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。 -- 流式请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障。 +- `stream` 模式失败时会显示原始错误状态和排查建议,不会自动改用非流式请求。`auto` 模式只在可观测回退路径中降级,并通过响应字段和 runtime capabilities 暴露状态。 ## 图片后端路径 - 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。原版 new-api 和 sub2api 普通 JSON 能力保持这个基线。 -- 流式能力由请求字段或环境变量显式控制:`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。`auto` 不会凭仓库名假设上游能力;Agent 辅助脚本对 `max_edge>2048` 的单次文生图默认优先使用页面端 `/api/images` SSE,失败后先诊断,再显式选择 Agent JSON 或 job 路径。 +- 流式策略由请求字段或环境变量控制:`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求级 `stream_mode` 支持 `auto`、`stream`、`non_stream`;`IMAGE_STREAMING_STRATEGY=off` 时页面会切到 `non_stream`。Agent 辅助脚本对 `max_edge>2048` 的单次文生图默认优先使用页面端 `/api/images` SSE,失败后先诊断,再显式选择 Agent JSON 或 job 路径。 - 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。 -- gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容仅发生在事件适配层:partial image 只作为预览,只有最终 completed base64 才会保存为 artifact;缺最终 base64 或仅返回远程 URL 会显式失败。 +- gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容发生在事件和结果适配层:partial image 只作为预览,最终 completed 可返回 base64、图片 data URL 或与上游 `BASE_URL` 同源的图片 URL。同源 URL 会由服务端下载并保存;跨源 URL、非图片响应或超过大小限制会显式失败。 - Responses API image generation 是实验路径,默认关闭。只有同时设置 `ENABLE_RESPONSES_IMAGE_BACKEND=true`、配置 `OPENAI_RESPONSES_API_MODEL`,并在请求中显式传入 `image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 时,服务端才会调用 `/responses` 并读取 `image_generation_call.result`。 - Agent capabilities 会同时暴露 `supported.image_backends` 枚举和 `supported.enabled_image_backends` 当前启用后端;自动化脚本应以后者和 `image_backend_requirements` 判断 runtime 是否已准备好。 -- Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。 -- Responses API 实验路径支持单张 `generate` 的非流式和上游 SSE 消费,不替换默认 Images API,不接入编辑表单。Agent generate 对外仍返回最终 JSON,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费。 +- Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;兼容字段 `gptModel`/`gpt_model` 也可覆盖顶层模型。页面表单里的图片模型只传给 `image_generation` 工具。 +- GPT2Image 兼容字段会尽量透传:文生图和图生图页面高级参数都可按后端选择提交字段;Responses 图片后端支持 `output_compression`、`promptOptimization`/`prompt_optimization` 和 `thinking`;Images API 路径支持 `force_web`/`forceWeb`。这些字段最终是否生效取决于命中的上游后端。 +- Responses API 实验路径支持单张 `generate` 和 `edit` 的非流式与上游 SSE 消费,不替换默认 Images API。页面文生图和图生图表单都可显式选择 Responses 图片后端;Agent generate/edit 对外仍返回最终 JSON;generate 可通过 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images` 控制服务端内部上游 SSE 消费,edit 仅支持 `stream_mode`、`streaming_strategy`、`partial_images`。 ## 编辑与遮罩 -编辑模式支持最多 10 张源图。遮罩必须与源图尺寸一致,绘制或上传后会随编辑请求一起提交。 +编辑模式支持最多 10 张源图,上传字段必须使用 `image_0` 到 `image_9`。遮罩必须与源图尺寸一致,绘制或上传后会随编辑请求一起提交。

遮罩创建 @@ -181,20 +184,22 @@ http://localhost:4783 | 配置 | 说明 | | --- | --- | | API Key | OpenAI 或兼容接口的密钥。 | -| API URL | OpenAI 兼容接口根地址,通常以 `/v1` 结尾。 | +| API URL | OpenAI 兼容接口根地址,通常以 `/v1` 结尾;支持无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址。 | 常见填写方式: ```text https://api.openai.com/v1 https://your-compatible-api.example.com/v1 +http://your-internal-compatible-api.example.com/v1 ``` -不要填写管理后台首页或网页地址。如果接口返回 HTML,应用会提示 API URL 不是 OpenAI Images JSON 响应。 +自定义 API URL 必须同时填写自定义 API Key,避免服务器密钥被发送到未知接口。不要填写管理后台首页或网页地址。如果接口返回 HTML,应用会提示 API URL 不是 OpenAI Images JSON 响应。 ## Agent API Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统一使用结构化错误、`Idempotency-Key` 和产物 ID。 +自动化客户端应先读取 `GET /api/agent/capabilities`,按其中的 `routing_rules`、`agent_streaming`、`agent_jobs`、`supported.enabled_image_backends` 和 `supported.image_backend_requirements` 选择路径,不要硬编码当前部署默认值。 | 接口 | 用途 | | --- | --- | @@ -217,10 +222,43 @@ Authorization: Bearer your-agent-token `AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。 +页面端 `/api/images` SSE 是独立的 form-data 路径,不属于 `/api/agent/*` JSON 响应契约。`agent_streaming.page_sse.auth.required=true` 时,脚本需要把 `GPT_IMAGE_APP_PASSWORD_HASH` 作为 form-data `passwordHash` 发送;同一个业务 key 会作为 `clientRequestId` 发送,长度不得超过 capabilities 声明的 `agent_streaming.page_sse.client_request_id.max_length`。 + 同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。 Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。 运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。 +`POST /api/agent/images/generate` 对外始终是最终 JSON;`max_edge>2048` 的单次文生图默认建议按 `/api/agent/capabilities` 使用页面端 `/api/images` SSE。显式传 `--agent` 或 `streaming_strategy=off` 时才走 Agent JSON 非流式路径,用于诊断对照。 +仓库辅助脚本支持 `--page-sse`、`--agent` 和 `--job` 显式选择路径。`--page-sse` 使用页面 SSE,`--agent` 强制 Agent generate/edit 最终 JSON,`--job` 使用 Agent generate job polling。页面流式失败后不会自动二次计费回退,需先按结构化错误和诊断字段确认原因,再选择新的业务操作和新的 `Idempotency-Key`。 +Agent 请求字段 `stream_mode=auto|stream|non_stream` 用于控制服务端内部上游流式消费:`auto` 是默认值并允许显式可观测回退,`stream` 强制上游流式并直接暴露失败,`non_stream` 直接非流式。`GET /api/runtime-capabilities` 会返回当前默认 stream mode、流式不可用标记 scope 和 availability summary。 +上游 SSE 字段边界以 `agent_streaming.upstream_sse.request_fields_by_mode` 为准:`generate` 可发送 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`;`edit` 只可发送 `stream_mode`、`streaming_strategy`、`partial_images`。 + +批量自动化可使用仓库 skill 脚本: + +```bash +node skills/gpt-image-playground-agent/scripts/batch-images.mjs \ + --input tasks.jsonl \ + --ordered-prefix product-set +``` + +默认 dry-run 只解析 JSONL 和输出计划,不联网、不计费。真实执行必须加 `--allow-billable`,并可配合 `--manifest`、`--resume`、`--dimension-check`、`--max-attempts`、`--concurrency`、`--max-consecutive-failures` 和任务级 `sse_log_path` 做 append-only 续跑、PNG/JPEG/WebP 尺寸校验、失败重试、并发执行、连续失败熔断和页面 SSE 原始事件留档。`--max-consecutive-failures` 只能与顺序执行的 `--concurrency 1` 同用。 + +并发脚本示例: + +```bash +node skills/gpt-image-playground-agent/scripts/batch-images.mjs \ + --allow-billable \ + --input tasks.jsonl \ + --manifest runs/product-set.manifest.jsonl \ + --resume \ + --dimension-check \ + --max-attempts 2 \ + --concurrency 3 +``` + +`--concurrency` 默认是 `1`。大于 `1` 时会并发执行任务并按输入顺序输出结果;连续失败熔断需要严格顺序语义,因此只能与 `--concurrency 1` 同用。 + +批量 JSONL 中,`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径,其中 edit 任务传入这些高级字段时会显式选择 `/api/images` form-data SSE,因为 Agent JSON edit 不接收这些字段。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。PNG 搭配 `output_compression` 会在 dry-run 输出 `normalizations.output_compression_ignored_for_png=true`,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。 生成示例: @@ -296,13 +334,18 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case | 变量 | 是否必填 | 默认值 | 说明 | | --- | --- | --- | --- | | `OPENAI_API_KEY` | 条件必填 | 无 | 服务端默认 API Key。也可以在页面 `API 设置` 中填写。 | -| `OPENAI_API_BASE_URL` | 否 | OpenAI 官方地址 | OpenAI 兼容接口根地址。 | +| `OPENAI_API_BASE_URL` | 否 | OpenAI 官方地址 | OpenAI 兼容接口根地址;默认要求 `https`。仅本机 loopback HTTP 可直接使用,远程 HTTP 必须显式加入 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`。 | +| `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS` | 否 | 无 | 远程明文 HTTP 兼容接口 allowlist,多个完整 base URL 用英文逗号分隔。只用于必须明文接入且已确认网络边界安全的上游。 | | `OPENAI_ROUTING_STRATEGY` | 否 | `sticky` | 服务端多渠道路由策略,可选 `sticky`、`round_robin`、`random`。 | | `OPENAI_CHANNEL_N_ID` | 否 | 无 | 第 N 个服务端渠道标识,只用于日志排查。 | -| `OPENAI_CHANNEL_N_BASE_URL` | 否 | 无 | 第 N 个 OpenAI 兼容接口根地址,通常以 `/v1` 结尾。 | +| `OPENAI_CHANNEL_N_BASE_URL` | 否 | 无 | 第 N 个 OpenAI 兼容接口根地址,通常以 `/v1` 结尾;默认要求 `https`。仅本机 loopback HTTP 可直接使用,远程 HTTP 必须显式加入 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`。 | | `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 | | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 | -| `ENABLE_STREAMING_BATCH` | 否 | `false` | 显式设为 `true` 后,流式模式下 `n>1` 会拆成多个 `n=1` 任务并发执行。 | +| `OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED` | 否 | `true` | 服务端渠道恢复探测开关。存在服务端凭证时默认开启,只请求对应上游 `GET /models`。 | +| `OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY` | 否 | 跟随恢复探测开关 | 是否要求冷却到期的 credential/channel 先通过恢复探测,成功后才回到用户流量。设为 `true` 时,`OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED` 也必须启用。 | +| `OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS` | 否 | `60000` | 后台恢复探测 tick 间隔。 | +| `OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS` | 否 | `5000` | 单次 `/models` 探测超时。 | +| `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK` | 否 | `1` | 每个 tick 最多探测的恢复候选数量,用于限制低成本探测流量。 | | `IMAGE_GENERATION_BACKEND` | 否 | `images-api` | 服务端默认图片后端,可选 `images-api` 或 `responses-image-generation`。请求字段可覆盖。 | | `IMAGE_STREAMING_STRATEGY` | 否 | `auto` | 服务端默认流式兼容策略,可选 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求字段可覆盖。 | | `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 请求才可调用 Responses API image generation。 | @@ -342,17 +385,23 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case | --- | --- | | `OPENAI_ROUTING_STRATEGY` | 可选 `sticky`、`round_robin`、`random`。不填默认 `sticky`。 | | `OPENAI_CHANNEL_N_ID` | 渠道标识,只用于日志与排查,不会暴露 API Key。 | -| `OPENAI_CHANNEL_N_BASE_URL` | 兼容接口根地址,通常以 `/v1` 结尾。 | +| `OPENAI_CHANNEL_N_BASE_URL` | 兼容接口根地址,通常以 `/v1` 结尾;支持无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址。 | | `OPENAI_CHANNEL_N_API_KEYS` | 当前渠道下的一个或多个 API Key,多个 key 用英文逗号分隔。 | | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 可选,覆盖单个渠道的失败冷却窗口。 | -并发流式批处理默认关闭。开启 `ENABLE_STREAMING_BATCH=true` 后,页面允许在流式模式下选择多张图片;应用会把批次拆成多个独立 `n=1` 流式请求。推荐并发窗口由服务端运行时能力接口返回:默认 `sticky` 路由按单个 credential 容量计算,`round_robin` / `random` 路由按完整 credential 池计算。 +并发流式批处理默认不自动启用。页面允许在流式模式下选择多张图片或进入批量提示词模式;用户显式勾选“并发批量”后,应用会把批次拆成多个独立 `n=1` 流式请求。省心模式会显示当前并发状态摘要,专业模式的流式分组提供开关和不可用原因。推荐并发窗口由服务端运行时能力接口返回:默认 `sticky` 路由按单个 credential 容量计算,`round_robin` / `random` 路由按完整 credential 池计算。 `OPENAI_MAX_STREAMS_PER_CREDENTIAL` 默认是 `1`,建议只在真实上游探针验证单 key 可承受更高并发后再调大。 如果服务端 credential 返回鉴权失败、额度不足或限流错误,应用会把该 credential 标记为短暂不可用。若渠道返回 5xx、Cloudflare 520/522/523/524、连接失败或超时,应用会冷却整个 channel,并在冷却窗口内跳过该 channel 下所有 key。若兼容网关把 `invalid_api_key`、`insufficient_quota` 等 credential 错误包在 5xx 中返回,credential 错误优先,不会误冷却整个 channel。所有 credential 都在冷却中时,请求会显式失败,不会伪造成功或静默降级。 -运行时能力接口会返回健康 credential/channel 数量与最近一次失败摘要(status、code、requestId),用于诊断和前端并发窗口刷新;不会返回 API Key 或上游错误消息。 +默认情况下,冷却到期不会直接把 credential/channel 放回用户生图流量。服务端会启动低频后台恢复探测,只对待恢复候选请求对应上游的 `GET /models`;HTTP 200 且响应包含 `data` 数组后才恢复。探测不会调用 `/images/generations`,不触发生图费用。探测失败会继续隔离该 credential/channel 并重新进入冷却窗口。可用 `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK` 限制每个 tick 的探测数量,默认每分钟最多探测 1 个候选。 + +恢复探测调度器运行在当前 Node.js 进程内,适合 Docker 或 standalone 单实例部署。Serverless 环境可能在请求结束后冻结进程;多副本部署也会各自维护内存健康状态。此类部署如需严格恢复控制,应使用常驻实例、共享健康状态或外部定时探测;大规模 credential 池可按可接受恢复速度调大 `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK`,但不要超过上游 `/models` 的限流承受能力。 + +恢复探测带有状态版本锚点:如果某次 `/models` 探测请求发出后,同一个 credential/channel 又被新的用户请求失败重新冷却,旧探测成功不会覆盖这次更新后的失败状态。 + +运行时能力接口会返回健康 credential/channel 数量、待恢复探测数量、已到期候选数量、按当前 `MAX_PER_TICK` 估算的最少 drain tick/时间、探测配置和最近一次失败摘要(status、code、requestId),用于诊断和前端并发窗口刷新;不会返回 API Key 或上游错误消息。 三种策略: @@ -483,7 +532,15 @@ npm run deploy:space npm run agent:doctor ``` -`status` 只读输出 git、Node、固定 Space 目标、Agent capabilities 路径、仓库 Skill 入口和独立真实图片上游 smoke 配置摘要。它会按 shell 环境变量、`.env.real-smoke.local`、`.env.local` 的优先级判断真实 smoke 配置是否齐全,但不会输出 URL 或 API Key;`doctor` 汇总本机与 HF Space 诊断;`verify` 执行提交前基线,需要真实 PostgreSQL gate 时加 `--postgres`;`deploy:local` 重建本地 Docker 并探测真实端点;`deploy:space` 是 HF Space 发布的稳定别名;`agent:doctor` 对当前 Agent API 做只读契约检查。 +`status` 只读输出 git、Node、固定 Space 目标、Agent capabilities 路径、仓库 Skill 入口和独立真实图片上游 smoke 配置摘要。它会按 shell 环境变量、`.env.real-smoke.local`、`.env.local` 的优先级判断真实 smoke 配置是否齐全,但不会输出 URL 或 API Key;`doctor` 汇总本机与 HF Space 诊断;`verify` 执行提交前基线,需要真实 PostgreSQL gate 时加 `--postgres`;`deploy:local` 重建本地 Docker 并探测真实端点;`deploy:space` 是 HF Space 发布的稳定别名;`agent:doctor` 默认做非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend、Responses/GPT2Image readiness,并把真实生图 smoke 标记为 skipped。 + +如需让 `agent:doctor` 执行真实计费 smoke,必须显式传入: + +```bash +npm run agent:doctor -- --allow-billable --edit-image /path/to/reference.png +``` + +其中 1K 文生图、1K 图生图和 2K page SSE edit 会按真实上游路径执行;未传 `--allow-billable` 时不会触发生图。 如果只想诊断 HF Space 前置条件,可运行: @@ -563,7 +620,7 @@ docker logs -f gpt-image-playground-customer | `npm run verify` | 执行提交前基线:测试、lint、脚本语法、构建和 `git diff --check`;加 `-- --postgres` 会包含 live PostgreSQL gate。 | | `npm run deploy:local` | 重建本地 Docker 服务并探测 `/api/auth-status`、`/api/runtime-capabilities`、`/api/agent/capabilities`;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 | | `npm run deploy:space` | 上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 | -| `npm run agent:doctor` | 通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。 | +| `npm run agent:doctor` | 执行非计费 Agent 分层诊断,真实 1K/2K smoke 必须显式加 `-- --allow-billable`。 | | `npm run deploy:hf-space` | 使用官方 `hf` CLI 上传当前干净 git HEAD 到固定 Space 并做只读公网验证。 | | `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、固定 Space 目标和远端配置。 | | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 | @@ -577,9 +634,12 @@ docker logs -f gpt-image-playground-customer 真实上游 smoke 使用以下环境变量前缀逐类配置:`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。每类至少提供 `BASE_URL` 和 `API_KEY`;Responses 场景还必须提供 `/responses` 顶层模型。可选覆盖图片 `MODEL`、`SIZE`、`QUALITY`。`BASE_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 URL。默认不触发计费请求,必须显式加 `-- --allow-billable`。可复制 `.env.real-smoke.example` 为未跟踪的 `.env.real-smoke.local`,再通过 `-- --env-file .env.real-smoke.local` 加载;shell 环境变量优先级高于 `--env-file`,`--env-file` 优先级高于 `.env.local`。 +GPT2Image 付费站的真实上游地址应配置为 `https://gpt2image.superapi.buzz/v1`,不要使用站点首页 URL。该场景会通过 Responses image_generation SSE 验证 URL 结果物化和最终图片保存。 + `npm run smoke:image-upstream-local` 会临时启动仓库内置 fixture,把 5 个独立场景全部指向本机 `/v1` 兼容服务,并调用同一个 `smoke:image-upstream-real -- --require-independent-targets --allow-billable` 门禁路径。该命令用于验证本项目的 final-gate 脚本、事件归一化和本地可复现环境;输出会标记 `local_fixture=true`。它不证明原版 new-api、gaoren/new-api、sub2api 或 GPT2Image 第三方部署当前可访问,真实验收仍需配置 `.env.real-smoke.local` 后运行真实上游门禁。 若只需要验证当前 `.env.local` 中的 `OPENAI_API_KEY` 或 `OPENAI_CHANNEL_N_*` 服务端渠道,可追加 `-- --include-server-channel`。该模式不会把服务端 API Key 写入表单或输出,真实执行仍需同时追加 `--allow-billable`;可覆盖 Images JSON、Images SSE、Responses JSON、Responses SSE、Agent 内部 Images SSE 和 Agent 内部 Responses SSE 场景。可用 `IMAGE_REAL_SMOKE_SERVER_MODEL`、`IMAGE_REAL_SMOKE_SERVER_SIZE`、`IMAGE_REAL_SMOKE_SERVER_QUALITY`、`IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL` 覆盖模型、尺寸、质量和 Responses 顶层模型。单场景默认超时 `240000ms`,可用 `--timeout-ms` 或 `IMAGE_REAL_SMOKE_TIMEOUT_MS` 调整。 +服务端渠道 smoke 只证明当前配置和上游账号池在请求时可用;如果上游返回 `503` 或 `No available compatible accounts`,应归类为上游渠道当前不可用,不要把它记成本地路由或脚本成功。 dry-run 输出中的 `independent_targets` 会汇总必跑、已选、未选、已配置和缺失的独立真实上游场景,并给出最终门禁命令;`required_count` 和 `unselected_required_count` 用于区分必跑总数和未选择数量,`configuration_complete=true` 只表示 5 个必跑场景都已选中且配置齐全,不代表已经执行计费生图。顶层 `final_gate_satisfied=true` 才表示最终独立真实上游门禁已实际执行并通过。`missing_env_any` 表示每组任选一个环境变量即可补齐该缺失项。例如 `sub2api-responses-json` 的 `BASE_URL` 和 `API_KEY` 可单独配置 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`,也可以复用 `IMAGE_REAL_SMOKE_SUB2API_*`;它的 `/responses` 顶层模型必须使用 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL`,避免和图片模型 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL` 混淆。 @@ -607,7 +667,7 @@ dry-run 输出中的 `independent_targets` 会汇总必跑、已选、未选、 ### API 返回 HTML 页面 -说明 API URL 填成了网页或管理后台地址。请填写 OpenAI 兼容接口根地址,通常以 `/v1` 结尾。 +说明 API URL 填成了网页或管理后台地址。请填写 OpenAI 兼容接口根地址,通常以 `/v1` 结尾;地址必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对 URL。 ### 生成接口提示需要 API Key diff --git a/docs/deployment/huggingface-space-free.md b/docs/deployment/huggingface-space-free.md index 4e020bee250275cc813e00dce2eaf9cb50841160..9c04928ad2a526492238337125e1ea59ca3966db 100644 --- a/docs/deployment/huggingface-space-free.md +++ b/docs/deployment/huggingface-space-free.md @@ -153,6 +153,8 @@ APP_PASSWORD= AGENT_API_TOKEN= ``` +`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。 + 公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。 如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets: @@ -194,6 +196,10 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ 脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。 +远端 Agent 调用不要硬编码路径。脚本会按 capabilities 中的 `routing_rules`、`agent_streaming` 和 `agent_jobs` 判断默认路径:普通小图走 Agent JSON,`max_edge>2048` 的单次文生图和高分辨率 edit 默认优先走页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再显式回退到 Agent JSON、Agent edit 或 job 路径。job polling 只在显式选择时使用。需要诊断对照时可用 `--agent` 或 `--streaming-strategy off` 强制 Agent JSON,也可用 `--page-sse` 或 `--job` 显式选择路径。 + +如果 Space 同时配置了 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,Agent JSON 端点用 `GPT_IMAGE_AGENT_TOKEN` 发送 Bearer token;页面端 `/api/images` SSE 仍按 capabilities 的 `agent_streaming.page_sse.auth` 判断,可能需要额外设置 `GPT_IMAGE_APP_PASSWORD_HASH`,并通过 form-data `passwordHash` 发送页面访问码哈希。页面 SSE 会把业务 key 写入 `clientRequestId`,长度上限以 capabilities 中的 `agent_streaming.page_sse.client_request_id.max_length` 为准。 + ## 本地 HF 近似 smoke 提交前运行: @@ -226,6 +232,7 @@ npm run smoke:hf-space - 工作流文件:`.github/workflows/hf-space-keepalive.yml` - 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch` - 默认目标:`https://misonl-gpt-image-playground-customer.hf.space/api/auth-status` +- GitHub Actions 中默认最多请求 3 次,每次超时 30 秒,重试间隔 5 秒。失败日志会记录每次尝试,不把超时伪装成成功。 - 行为边界:只访问只读鉴权状态端点,不携带 `APP_PASSWORD`、`AGENT_API_TOKEN` 或 OpenAI Key,不触发生图、不访问 Agent 生成接口。 如果 Space 地址变化,在 GitHub 仓库 Variables 中设置: @@ -239,6 +246,7 @@ HF_SPACE_KEEPALIVE_URL=https://-.hf.space ```bash HF_SPACE_KEEPALIVE_URL=https://-.hf.space \ HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED=true \ +HF_SPACE_KEEPALIVE_MAX_ATTEMPTS=3 \ npm run keepalive:hf-space ``` diff --git a/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md b/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md index c7d45ff9c1f26fe19981a47f29068a4f6c4368d7..30b62fd055adba936778f5053dc667232b042ab8 100644 --- a/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +++ b/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md @@ -9,7 +9,7 @@ ## 审计结论 - `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。 -- 高分辨率 Agent edit 请求在 route 层 `snapshotAgentEditFormData` 前被拒绝,服务层也在读取服务端 API 凭据前保留二道校验。 +- 当前路由口径已更新:高分辨率 edit 默认优先走页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再显式回退到 Agent edit。 - partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。 - 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。 - 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。 diff --git a/docs/ui/literary-young-women-workbench-design.md b/docs/ui/literary-young-women-workbench-design.md new file mode 100644 index 0000000000000000000000000000000000000000..75d613bbcfa11a5ddf5b15e63ba25056b36109a3 --- /dev/null +++ b/docs/ui/literary-young-women-workbench-design.md @@ -0,0 +1,550 @@ +# 图像手记 UI 设计基线 + +## 1. 定稿结论 + +本项目 Web UI 后续重构以 `女青年文艺风功能交互稿` 为唯一设计基线。 + +- 当前实现名称:`图像手记` 工作台。 +- 定稿图:`/tmp/gipc-literary-young-women-functional-v5.png` +- 产品名称:`图像手记` +- 产品副标题:`今天想做什么画面?` +- 核心定位:面向中文用户的 AI 图像创作工作台,强调文艺、柔和、清爽、可持续使用。 +- 设计目标:文艺但能干活,柔和但不牺牲参数能力。 + +本基线明确排除古风、传统器物化、普通 SaaS 仪表盘和过度 AI 科技风。 + +## 2. 目标用户与气质 + +目标用户是中文创作者,尤其偏向年轻女性、独立杂志读者、小红书内容创作者、摄影和设计爱好者、手账和咖啡馆工作流用户。 + +目标气质: + +- 现代女青年文艺风。 +- 独立杂志、咖啡馆手账、胶片摄影、艺术书、生活方式创作工具。 +- 温柔、轻盈、清新、有个人感,但仍然专业和高效。 +- 中文优先,文案自然,不暴露过多工程词。 + +避免气质: + +- 古风、国风器物、传统书画、宣纸卷轴、印章、毛笔、砚台、灯笼、宫廷或寺庙感。 +- 深色科技仪表盘、霓虹、赛博朋克、玻璃拟态、紫色渐变。 +- 企业后台、金融 SaaS、模板化 bento grid。 +- 低幼粉色、过度贴纸化、装饰大于功能。 + +## 3. 视觉系统 + +### 3.1 色彩 + +主色按温柔浅色系组织,保持低疲劳和长时间可用。 + +- 页面基底:奶油米白、淡奶茶色、浅亚麻纸色。 +- 主要文字:炭黑、咖啡棕。 +- 次级文字:灰褐色、柔和橄榄灰。 +- 辅助面:浅粉、鼠尾草绿、淡黄油色。 +- 主操作色:珊瑚番茄红,用于 `生成图像`。 +- 状态点缀:少量蓝灰,用于 API、模型和流式状态。 + +色彩比例建议: + +- 60%:奶油米白和淡奶茶背景。 +- 30%:纸卡、浅粉、鼠尾草绿、米色分层。 +- 10%:珊瑚红按钮、蓝灰状态、淡黄提示。 + +禁止使用: + +- 大面积紫色、紫蓝渐变。 +- 大面积深蓝、深灰、黑色科技底。 +- 仿古黄、旧羊皮纸、朱砂印章红。 + +### 3.2 材质 + +界面材质来自现代纸张和生活方式视觉,不来自古代书画。 + +- 细腻亚麻纸纹理。 +- 胶片照片缩略图。 +- 淡淡纸层阴影。 +- 轻量贴纸感样式标签。 +- 杂志剪贴和手账排版暗示。 +- 细线网格和裁切标记。 + +所有材质必须克制,不能影响可读性和操作效率。 + +### 3.3 字体与排版 + +中文优先,整体像现代独立杂志和创意工具。 + +- 标题:可使用更有编辑感的中文标题样式,字重中等或偏细。 +- 正文与控件:使用清晰现代无衬线中文字体。 +- 行高:保持舒展,避免压迫。 +- 标签:短句优先,避免工程缩写。 +- 英文仅用于模型名、技术名和必要状态,例如 `gpt-image-2`。 + +排版原则: + +- 中心画布最大,操作围绕画布服务。 +- 左侧信息密度中等,保持创作单的顺序。 +- 右侧卡片轻量,避免历史列表压迫主画布。 +- 不使用超大营销标题,不做 landing page。 + +### 3.4 圆角与阴影 + +- 圆角最大建议 `8px`,避免全圆角玩具感。 +- 主卡片可使用 `6px` 到 `8px`。 +- 按钮和输入框保持一致半径。 +- 阴影只模拟轻纸层,不能做厚重浮层或玻璃感。 + +## 4. 信息架构 + +页面是一个完整工作台,不是宣传页。 + +主结构: + +```text ++-------------------------------------------------------------+ +| 顶部状态栏:图像手记 / 模型 / 渠道 / 费用 / 设置 | ++--------------+------------------------------+---------------+ +| 左侧创作单 | 中央画面预览 | 右侧相册记录 | +| 模式入口 | 生成状态 | 灵感相册 | +| 提示词 | 结果动作链 | 最近生成 | +| 基础参数 | 对比与编辑 | 生成动态 | +| 生成按钮 | | | ++--------------+------------------------------+---------------+ +| 高级设置抽屉:省心模式 / 专业模式 / 输出 / 模型 / 流式 / 路由 | ++-------------------------------------------------------------+ +``` + +信息优先级: + +1. 中央画面预览。 +2. 左侧创作单和生成按钮。 +3. 结果后的编辑动作链。 +4. 右侧灵感与历史。 +5. 高级参数和日志。 + +## 5. 顶部状态栏 + +顶部状态栏用于建立当前环境和全局入口,不承载主要创作流程。 + +内容: + +- 产品名:`图像手记` +- 副标题:`今天想做什么画面?` +- 当前模型:例如 `gpt-image-2` +- 渠道状态:例如 `流式可用` +- 费用状态:例如 `预计 0.12 积分` +- API 状态:例如 `API 正常` +- 设置入口。 + +要求: + +- 视觉安静,不抢主按钮。 +- 状态信息可读但不显得像运维面板。 +- 技术状态尽量转译为用户可理解语言。 + +## 6. 左侧创作单 + +左侧是完整创作入口,采用从上到下的自然创作顺序。 + +### 6.1 模式入口 + +使用清晰分段控件,放在左侧创作单顶部。 + +四个模式: + +- `文生图` +- `图生图` +- `批量` +- `复用历史` + +默认选中 `文生图`。 + +设计要求: + +- 模式切换必须明显,不隐藏在高级设置里。 +- 每种模式切换后,左侧表单内容按任务调整。 +- 不使用工程模式名,例如 `generate`、`edit`、`batch`。 + +### 6.2 提示词输入 + +模块标题:`写下灵感` + +提示词输入区域应像现代笔记本或创作卡片,而不是普通后台 textarea。 + +示例文案方向: + +```text +午后咖啡馆窗边,一束粉白花,胶片感,柔和自然光,松弛的生活杂志封面 +``` + +要求: + +- 输入区高度足够,适合长提示词。 +- 支持多行自然书写。 +- 周围可有轻微手账感,但不能影响文字输入。 +- 空状态文案应鼓励创作,不做教程式说明。 + +### 6.3 风格标签 + +风格标签采用轻贴纸视觉,服务于快速构造提示词。 + +建议标签: + +- `胶片感` +- `奶油色` +- `日杂` +- `花束` +- `清透` +- `松弛` +- `复古咖啡` +- `夏日窗边` + +行为要求: + +- 点击后追加或融合到提示词。 +- 已选标签要有明确状态。 +- 标签数量不宜一次展示过多,可横向滚动或折叠。 + +### 6.4 基础参数 + +基础参数只保留高频项: + +- `尺寸` +- `数量` +- `清晰度` +- `格式` + +要求: + +- 使用紧凑但清晰的控件。 +- 参数命名面向用户,不暴露 API 字段名。 +- 默认值应能覆盖大多数使用场景。 +- 复杂参数放入专业模式。 + +### 6.5 生成动作区 + +生成动作区必须贴近费用、模型和渠道状态,避免用户点按钮前不清楚成本和路径。 + +内容: + +- 状态 chip:`gpt-image-2` +- 状态 chip:`流式可用` +- 费用 chip:`预计 0.12 积分` +- 主按钮:`生成图像` +- 次级操作:`存为灵感` +- 次级操作:`随便来点` + +要求: + +- `生成图像` 使用珊瑚番茄红。 +- 费用和模型信息必须在按钮附近。 +- 禁用状态要说明原因,例如缺少提示词、图片未上传、尺寸无效。 + +## 7. 中央画面预览 + +中央是页面视觉和功能重心。 + +模块标题:`画面预览` + +状态: + +- 空状态:`还没有生成图像` +- 生成中:`正在生成`,可显示进度或流式预览。 +- 成功:显示当前结果。 +- 失败:显示明确错误和重试入口。 + +视觉要求: + +- 大面积留白和画廊板感。 +- 图片区域可带轻裁切标记和细线网格。 +- 不放入厚重卡片套卡片。 +- 预览图优先,工具条围绕图片轻量排布。 + +### 7.1 结果动作链 + +结果出现后必须提供连续工作流,不让用户只看到一张图。 + +动作: + +- `下载` +- `继续编辑` +- `做变体` +- `复用提示词` +- `对比` + +行为要求: + +- `下载`:保存当前图片。 +- `继续编辑`:切换到图生图或编辑模式,并带入当前图片。 +- `做变体`:保留提示词和基础参数,生成新变体。 +- `复用提示词`:把历史或当前提示词带回左侧创作单。 +- `对比`:支持与上一张或选中历史图对比。 + +这些动作应在结果附近,而不是只藏在历史列表里。 + +## 8. 右侧相册与记录 + +右侧分为灵感和历史,避免概念混淆。 + +### 8.1 标签页 + +两个主要标签: + +- `灵感相册` +- `最近生成` + +区别: + +- `灵感相册`:保存模板、风格、参考提示词和收藏。 +- `最近生成`:真实生成记录、图片和参数。 + +### 8.2 灵感相册 + +卡片内容: + +- 缩略图或风格封面。 +- 风格名。 +- 简短提示词片段。 +- 收藏或固定入口。 +- `套用` 动作。 + +视觉: + +- 胶片照片或杂志剪贴感。 +- 小面积纸层,不要重卡片。 + +### 8.3 最近生成 + +卡片内容: + +- 缩略图。 +- 时间或批次信息。 +- 模型和尺寸。 +- 提示词片段。 +- 收藏、复用、继续编辑入口。 + +要求: + +- 历史记录支持快速回到创作单。 +- 批量结果可折叠成批次。 +- 失败记录应能看到原因,但不要占据主界面。 + +### 8.4 生成动态 + +生成动态是低干扰日志,不是开发者控制台。 + +显示内容: + +- 请求开始。 +- 流式预览更新。 +- 图片保存完成。 +- 失败原因和重试建议。 + +文案要求: + +- 用户可理解。 +- 不展示原始堆栈。 +- 技术细节放到专业模式或调试详情。 + +## 9. 高级设置 + +高级设置不能堆在主界面。 + +入口: + +- `省心模式` +- `专业模式` + +默认使用 `省心模式`。 + +专业模式分组: + +- `输出` +- `模型` +- `流式` +- `路由` + +要求: + +- 主界面只显示高频参数。 +- 专业模式可展开,但不要一次铺满所有字段。 +- 工程词在主界面减少暴露,专业模式中可保留必要精确项。 +- 当前选择会影响费用或稳定性时,应显示解释。 +- 当前实现中,桌面端高级设置位于底部 Pro Dock。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。 +- 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。 + +## 10. 模式交互 + +### 10.1 文生图 + +默认模式。 + +主流程: + +1. 写提示词。 +2. 选择风格标签。 +3. 调整尺寸、数量、清晰度和格式。 +4. 确认模型、流式状态和预计费用。 +5. 点击 `生成图像`。 +6. 在中央查看结果。 +7. 下载、编辑、变体、复用或对比。 + +### 10.2 图生图 + +图生图需要明确上传入口和参考图状态。 + +新增区域: + +- 图片上传。 +- 多图列表。 +- 遮罩或局部编辑入口。 +- 参考强度或编辑说明。 + +要求: + +- 上传图出现在中央或左侧明确位置。 +- 不让用户误以为还在纯文生图。 +- 结果仍进入同一动作链。 + +### 10.3 批量 + +批量模式面向多提示词或多尺寸任务。 + +新增区域: + +- 批量提示词列表。 +- 批次设置。 +- 失败重试策略。 +- 进度和失败汇总。 + +要求: + +- 批量进度不挤占单张预览主区域。 +- 支持暂停、重试、复用失败项。 +- 结果按批次进入最近生成。 +- 当前实现中,批量模式按“每行提示词一条任务”执行,每条任务使用当前统一的尺寸、清晰度、格式、模型、流式和路由设置。 +- 批量模式默认顺序执行。只有用户显式启用 `并发批量`,且当前任务数大于 1、流式策略可用、服务端或用户自填 API Key 有并发容量时,才会并发执行。 +- 暂停批量时,已开始任务继续完成,未开始任务保留为失败项,便于用户复用失败项后再次提交。 +- 底部提示词动作必须使用当前可见的批量提示词文本,不得回退到隐藏的单条文生图提示词。 + +### 10.4 复用历史 + +复用历史让用户从右侧记录反向进入创作。 + +能力: + +- 从历史图恢复提示词。 +- 从历史图恢复参数。 +- 从收藏模板套用风格。 +- 从某张图进入继续编辑。 + +要求: + +- 明确显示复用了哪些内容。 +- 用户可以修改后再生成。 + +## 11. 移动端原则 + +移动端不能简单压缩三栏。 + +移动端结构: + +- 画布优先。 +- 底部抽屉承载创作单。 +- 横向滑动相册。 +- 顶部只保留标题、状态和设置。 + +移动端顺序: + +1. 画面预览。 +2. 底部 `生成图像` 主按钮。 +3. 上滑展开创作单。 +4. 横滑查看灵感和历史。 +5. 专业模式放二级抽屉。 + +要求: + +- 主按钮始终易触达。 +- 不让参数挤压图片预览。 +- 图生图上传和结果动作链要适配触控。 + +## 12. 文案规范 + +整体文案中文优先,少工程味,少教程感。 + +推荐文案: + +- `图像手记` +- `今天想做什么画面?` +- `写下灵感` +- `生成图像` +- `存为灵感` +- `随便来点` +- `画面预览` +- `还没有生成图像` +- `灵感相册` +- `最近生成` +- `生成动态` +- `继续编辑` +- `做变体` +- `复用提示词` +- `省心模式` +- `专业模式` + +避免文案: + +- `Generate` +- `Edit` +- `Debug` +- `Routing` +- `Backend` +- `SSE` +- `Responses` + +必要技术名可以放入专业模式,例如 `Responses`、`SSE`、`image backend`,但主界面应转译为用户可理解的状态。 + +## 13. 实现注意 + +后续开发时应保持以下边界: + +- 不直接照搬生成图中的错字或不稳定小字,以本文档为准。 +- 不为了装饰牺牲表单可访问性和键盘操作。 +- 不把高级参数重新堆回主界面。 +- 不使用古风素材或传统器物隐喻。 +- 不提交临时生成图,除非后续明确把某张图作为正式设计资产。 +- 每次 UI 实现后需要做桌面和移动端截图复核。 +- 结果区动作链和右侧最近生成都必须保留真实可操作入口:下载、继续编辑、做变体、复用提示词、对比和保存为灵感。批量历史默认折叠多图缩略图,避免右侧列表被单个批次撑开。 +- 最近生成标签页在有生成历史且没有灵感项时应自动进入最近生成视图;失败记录显示原因但不伪造缩略图。 + +建议实现拆分: + +1. 先建立新的页面信息架构和状态模型。 +2. 再重构左侧创作单。 +3. 再重构中央画布和结果动作链。 +4. 再重构右侧灵感相册和最近生成。 +5. 最后接入专业模式抽屉和移动端布局。 + +## 14. 验收清单 + +视觉验收: + +- 页面第一印象是现代女青年文艺风,而不是古风或普通后台。 +- 米黄色基底清爽,不显旧、不显脏。 +- 柔粉、鼠尾草绿、咖啡棕和珊瑚红比例克制。 +- 装饰来自现代手账、胶片、杂志和生活方式,不来自传统器物。 + +功能验收: + +- 文生图、图生图、批量、复用历史入口清晰。 +- 用户点生成前能看到模型、渠道和预计费用。 +- 生成后能直接下载、继续编辑、做变体、复用提示词和对比。 +- 灵感相册与最近生成概念分离。 +- 高级参数默认收纳,但可被专业用户找到。 +- 移动端以画布优先,不是三栏硬压缩。 + +工程验收: + +- 主界面无装饰性 Unicode 符号。 +- 文案中文优先。 +- 核心表单可键盘操作。 +- 响应式下文本不重叠。 +- `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build` 和 `git diff --check` 作为实现收尾基线。 diff --git a/package-lock.json b/package-lock.json index 5fe53e23756fad24ed8f8e2d61f302c37012ab26..e096dcb21cc78108a17bd2719be6dbd2b05d2c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "gpt-image-playground", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gpt-image-playground", - "version": "1.3.0", + "version": "1.4.0", + "license": "MIT", "dependencies": { "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.11", diff --git a/package.json b/package.json index d438b35ae4008b592985aff9ab5ee512ea4d0709..2cff53a8e5865db02a63a94a33c352e9794d6738 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "gpt-image-playground", - "version": "1.3.0", + "version": "1.4.0", + "license": "MIT", "private": true, "scripts": { "dev": "next dev --turbopack -p 4783", "prebuild": "node scripts/clean-standalone.mjs", "build": "next build", "postbuild": "node scripts/patch-standalone-runtime.mjs", - "test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"", + "test": "node --test --import tsx \"src/**/*.test.ts\" \"src/**/*.test.tsx\" \"scripts/**/*.test.mjs\"", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", "test:postgres": "node scripts/test-postgres-live.mjs", "doctor": "node scripts/doctor.mjs", diff --git a/scripts/agent-doctor.mjs b/scripts/agent-doctor.mjs index 7633a66a98ad87b9a0e7ea209d3e1c02670ab779..faae59cc5e48ed0e77766669b8748c9f96bcaa18 100644 --- a/scripts/agent-doctor.mjs +++ b/scripts/agent-doctor.mjs @@ -5,52 +5,339 @@ import { fileURLToPath } from 'node:url'; import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs'; const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url)); +const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url)); const AGENT_DOCTOR_TIMEOUT_MS = 75_000; +const DEFAULT_BASE_URL = 'http://localhost:4783'; export function buildAgentDoctorArgs() { return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check']; } function parseArgs(argv) { - const unknown = argv.find((arg) => !['--help', '-h'].includes(arg)); - if (unknown) throw new Error(`Unknown option: ${unknown}`); - return { - help: argv.includes('--help') || argv.includes('-h') + const parsed = { + help: false, + allowBillable: false, + timeoutMs: 60_000, + editImage: undefined }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--timeout-ms') parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), '--timeout-ms'); + else if (arg === '--edit-image') parsed.editImage = readOptionValue(argv, (index += 1), arg); + else throw new Error(`Unknown option: ${arg}`); + } + return parsed; } function printHelp() { console.log(`Usage: npm run agent:doctor + npm run agent:doctor -- --allow-billable --edit-image /path/to/reference.png Environment: GPT_IMAGE_PLAYGROUND_URL Service base URL, defaults to http://localhost:4783. GPT_IMAGE_AGENT_TOKEN Bearer token when capabilities require bearer auth. - GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth.`); + GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth. + +By default agent:doctor is read-only and non-billable. Billable generate/edit smoke checks require --allow-billable.`); } -function main() { +async function main() { const options = parseArgs(process.argv.slice(2)); if (options.help) { printHelp(); return; } + const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || DEFAULT_BASE_URL); + const contract = runContractCheck(); + const capabilities = await readJsonLayer('capabilities', `${baseUrl}/api/agent/capabilities`, options.timeoutMs); + const runtime = await readJsonLayer('runtime', `${baseUrl}/api/runtime-capabilities`, options.timeoutMs); + const smoke = options.allowBillable ? runBillableSmoke(options) : buildSkippedSmoke(options); + const layers = buildLayers({ capabilities, runtime, contract, smoke }); + + printJson({ + ok: layers.every((layer) => layer.ok || layer.skipped), + command: 'agent:doctor', + billable: options.allowBillable, + base_url: redactBaseUrl(baseUrl), + layers, + summary: buildSummary({ capabilities, runtime, contract, smoke }) + }); + if (layers.some((layer) => !layer.ok && !layer.skipped)) process.exit(1); +} + +function runContractCheck() { const result = runCommand(process.execPath, buildAgentDoctorArgs(), { env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' }, timeoutMs: AGENT_DOCTOR_TIMEOUT_MS }); if (!result.ok) { - printJson({ ok: false, command: 'agent:doctor', output: pickFailureOutput(result) }); - process.exit(1); + return { ok: false, output: pickFailureOutput(result), elapsed_ms: result.elapsed_ms }; + } + return { + ok: true, + elapsed_ms: result.elapsed_ms, + body: result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {} + }; +} + +async function readJsonLayer(name, url, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { headers: authHeaders(), signal: controller.signal }); + const text = await response.text(); + if (!response.ok) throw new Error(`${safePathname(url)} failed with HTTP ${response.status}${formatBodySnippet(text)}`); + return { ok: true, body: text ? JSON.parse(text) : {} }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error), name }; + } finally { + clearTimeout(timer); + } +} + +function buildSkippedSmoke(options) { + return { + ok: true, + skipped: true, + reason: 'requires --allow-billable', + checks: [ + { name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' }, + { + name: 'edit_1k', + skipped: true, + reason: options.editImage ? 'requires --allow-billable' : 'requires --allow-billable and --edit-image' + }, + { + name: 'page_sse_edit_2k', + skipped: true, + reason: options.editImage ? 'requires --allow-billable' : 'requires --allow-billable and --edit-image' + } + ] + }; +} + +function runBillableSmoke(options) { + const checks = [ + runSmokeCommand('generate_1k', [ + GENERATE_SCRIPT, + '--allow-billable', + '--agent', + '--timeout-ms', + String(options.timeoutMs), + '--size', + '1024x1024', + '--quality', + 'low', + '--idempotency-key', + `agent-doctor-generate-${Date.now()}`, + 'agent doctor 1k generate smoke' + ]) + ]; + if (options.editImage) { + checks.push( + runSmokeCommand('edit_1k', [ + EDIT_SCRIPT, + '--allow-billable', + '--agent', + '--timeout-ms', + String(options.timeoutMs), + '--size', + '1024x1024', + '--quality', + 'low', + '--idempotency-key', + `agent-doctor-edit-${Date.now()}`, + options.editImage, + 'agent doctor 1k edit smoke' + ]) + ); + checks.push( + runSmokeCommand('page_sse_edit_2k', [ + EDIT_SCRIPT, + '--allow-billable', + '--page-sse', + '--timeout-ms', + String(options.timeoutMs), + '--size', + '2048x2048', + '--quality', + 'low', + '--idempotency-key', + `agent-doctor-page-sse-edit-${Date.now()}`, + options.editImage, + 'agent doctor 2k page SSE edit smoke' + ]) + ); + } else { + checks.push({ name: 'edit_1k', ok: true, skipped: true, reason: 'requires --edit-image' }); + checks.push({ name: 'page_sse_edit_2k', ok: true, skipped: true, reason: 'requires --edit-image' }); } + return { ok: checks.every((check) => check.ok || check.skipped), skipped: false, checks }; +} + +function runSmokeCommand(name, args) { + const result = runCommand(process.execPath, args, { + env: process.env, + timeoutMs: AGENT_DOCTOR_TIMEOUT_MS + }); + return { + name, + ok: result.ok, + elapsed_ms: result.elapsed_ms, + ...(result.ok ? {} : { output: pickFailureOutput(result) }) + }; +} + +function buildLayers({ capabilities, runtime, contract, smoke }) { + return [ + { + name: 'capabilities', + ok: capabilities.ok, + endpoint: '/api/agent/capabilities', + ...(capabilities.ok ? summarizeCapabilities(capabilities.body) : { error: capabilities.error }) + }, + { + name: 'contract_check', + ok: contract.ok, + billable: false, + ...(contract.ok ? { checks: contract.body.checks || [] } : { error: contract.output }) + }, + { + name: 'runtime_backend', + ok: runtime.ok, + endpoint: '/api/runtime-capabilities', + ...(runtime.ok ? summarizeRuntime(runtime.body) : { error: runtime.error }) + }, + { + name: 'state_backend', + ok: capabilities.ok, + ...(capabilities.ok ? summarizeStateBackend(capabilities.body) : { error: capabilities.error }) + }, + { + name: 'responses_gpt2image_readiness', + ok: capabilities.ok && runtime.ok, + ...(capabilities.ok && runtime.ok + ? summarizeResponsesReadiness(capabilities.body, runtime.body) + : { error: 'requires capabilities and runtime layers' }) + }, + { + name: 'billable_smoke', + ok: smoke.ok, + skipped: smoke.skipped, + checks: smoke.checks, + ...(smoke.reason ? { reason: smoke.reason } : {}) + } + ]; +} + +function summarizeCapabilities(body) { + return { + page_sse: body?.agent_streaming?.page_sse?.supported === true, + agent_jobs: body?.agent_jobs?.supported === true, + routing_rules: Boolean(body?.routing_rules), + executable_routing_rules: Boolean(body?.routing_rules?.high_resolution_edit?.conditions) + }; +} + +function summarizeRuntime(body) { + return { + default_stream_mode: body?.streaming?.defaultMode, + streaming_unavailable_scope: body?.streaming?.unavailableMarkScope, + responses_image_backend: body?.responsesImageBackend?.enabled === true, + streaming_batch_enabled: body?.streamingBatch?.enabled === true + }; +} + +function summarizeStateBackend(body) { + return { + backend: body?.defaults?.state_backend, + image_storage_mode: body?.storage?.image_storage_mode, + postgres_configured: body?.storage?.postgres_configured === true + }; +} + +function summarizeResponsesReadiness(capabilities, runtime) { + const requirements = capabilities?.supported?.image_backend_requirements?.['responses-image-generation']; + return { + backend_supported: requirements?.supported === true, + backend_enabled: requirements?.enabled === true, + runtime_enabled: runtime?.responsesImageBackend?.enabled === true, + missing_env: requirements?.missing_env || [], + gpt2image_real_smoke_case: 'gpt2image-responses-sse', + real_smoke_gate: + 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable' + }; +} + +function buildSummary({ capabilities, runtime, contract, smoke }) { + return { + capabilities: capabilities.ok ? 'ok' : 'failed', + contract_check: contract.ok ? 'ok' : 'failed', + runtime: runtime.ok ? 'ok' : 'failed', + state_backend: capabilities.ok ? capabilities.body?.defaults?.state_backend : 'unknown', + responses_gpt2image_ready: + capabilities.ok && runtime.ok + ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true && + runtime.body?.responsesImageBackend?.enabled === true + : false, + billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed' + }; +} + +function authHeaders() { + if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` }; + if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH }; + return {}; +} + +function readOptionValue(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`); + return value; +} + +function readPositiveInteger(value, name) { + if (!/^\d+$/.test(String(value))) throw new Error(`${name} must be a positive integer.`); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer.`); + return parsed; +} + +function normalizeBaseUrl(value) { + const normalized = String(value || '').trim().replace(/\/+$/, ''); + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('base URL must use http or https.'); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('base URL must not include credentials, query parameters, or fragments.'); + } + return normalized; +} + +function redactBaseUrl(value) { + const parsed = new URL(value); + return `${parsed.protocol}//${parsed.host}`; +} + +function safePathname(url) { + try { + return new URL(url).pathname; + } catch { + return String(url); + } +} - const body = result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {}; - printJson({ ok: true, command: 'agent:doctor', contract: body }); +function formatBodySnippet(text) { + return text ? `: ${text.slice(0, 100)}` : ''; } try { - if (isMainModule(import.meta.url, process.argv[1])) main(); + if (isMainModule(import.meta.url, process.argv[1])) await main(); } catch (error) { printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); process.exit(1); diff --git a/scripts/agent-skill-scripts.test.mjs b/scripts/agent-skill-scripts.test.mjs index a53b8419eea6dec4fc28fa761d1cc7dabce60fc6..13b813e6fa33487bd31debda7154eecd3c323245 100644 --- a/scripts/agent-skill-scripts.test.mjs +++ b/scripts/agent-skill-scripts.test.mjs @@ -1,12 +1,18 @@ +import { + parseRetryAfterValue, + resolveSameOriginUrl +} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; +import { cpSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; -import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; -import { parseRetryAfterValue, resolveSameOriginUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; +import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const skillRoot = join(repoRoot, 'skills/gpt-image-playground-agent'); const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts'); describe('Agent skill script argument validation', () => { @@ -34,6 +40,28 @@ describe('Agent skill script argument validation', () => { assert.equal(result.stdout.trim(), ''); }); + it('rejects invalid image size limits before dry-run or network requests', () => { + const generateResult = runSkillScript('generate-image.mjs', ['--size', '2049x2048', 'prompt']); + assert.equal(generateResult.status, 2); + assert.match(generateResult.stderr, /--size 的宽边和高边都必须是 16 的倍数/); + assert.equal(generateResult.stdout.trim(), ''); + + const editResult = runSkillScript('edit-image.mjs', ['--size', '8192x8192', '/tmp/source.png', 'prompt']); + assert.equal(editResult.status, 2); + assert.match(editResult.stderr, /--size 的最大单边不能超过 3840px/); + assert.equal(editResult.stdout.trim(), ''); + + const maxPixelsResult = runSkillScript('generate-image.mjs', ['--size', '3840x3840', 'prompt']); + assert.equal(maxPixelsResult.status, 2); + assert.match(maxPixelsResult.stderr, /--size 的总像素不能超过 8,294,400/); + assert.equal(maxPixelsResult.stdout.trim(), ''); + + const probeResult = runSkillScript('probe-upstream-image.mjs', ['--size', '512x512']); + assert.equal(probeResult.status, 2); + assert.match(probeResult.stderr, /--size 的总像素必须至少为 655,360/); + assert.equal(probeResult.stdout.trim(), ''); + }); + it('rejects invalid retry attempt env values', () => { const result = runSkillScript('generate-image.mjs', ['prompt'], { GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' @@ -57,7 +85,10 @@ describe('Agent skill script argument validation', () => { }); it('rejects upstream probe base URLs with embedded credentials before network checks', () => { - const result = runSkillScript('probe-upstream-image.mjs', ['--base-url', 'https://user:secret@example.test/v1']); + const result = runSkillScript('probe-upstream-image.mjs', [ + '--base-url', + 'https://user:secret@example.test/v1' + ]); assert.equal(result.status, 2); assert.match(result.stderr, /base URL/); @@ -80,6 +111,8 @@ describe('Agent skill script argument validation', () => { const result = runSkillScript('generate-image.mjs', [ '--image-backend', 'responses', + '--stream-mode', + 'stream', '--streaming-strategy', 'responses-sse', '--partial-images', @@ -90,11 +123,32 @@ describe('Agent skill script argument validation', () => { assert.equal(result.status, 0); const body = JSON.parse(result.stdout); assert.equal(body.request.image_backend, 'responses'); + assert.equal(body.request.stream_mode, 'stream'); assert.equal(body.request.streaming_strategy, 'responses-sse'); assert.equal(body.request.partial_images, 3); assert.equal(result.stderr.trim(), ''); }); + it('includes explicit upstream streaming options in edit dry-run requests', () => { + const result = runSkillScript('edit-image.mjs', [ + '--stream-mode', + 'auto', + '--streaming-strategy', + 'force-sse', + '--partial-images', + '2', + '/tmp/source.png', + 'prompt' + ]); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.request.stream_mode, 'auto'); + assert.equal(body.request.streaming_strategy, 'force-sse'); + assert.equal(body.request.partial_images, 2); + assert.equal(result.stderr.trim(), ''); + }); + it('prints page SSE routing guidance for high-resolution generate dry-runs', () => { const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']); @@ -180,7 +234,7 @@ describe('Agent skill script argument validation', () => { } if (request.url === '/api/images') { response.writeHead(400, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ error: 'size 对 gpt-image-2 无效:宽高必须是 16 的倍数。' })); + response.end(JSON.stringify({ error: 'quality 对 gpt-image-2 无效。' })); return; } response.writeHead(404, { 'content-type': 'application/json' }); @@ -189,7 +243,7 @@ describe('Agent skill script argument validation', () => { async (baseUrl) => { const result = await runSkillScriptAsync( 'generate-image.mjs', - ['--allow-billable', '--size', '2049x2048', '--quality', 'high', 'prompt'], + ['--allow-billable', '--size', '3072x2048', '--quality', 'invalid-quality', 'prompt'], { GPT_IMAGE_PLAYGROUND_URL: baseUrl } ); @@ -199,7 +253,7 @@ describe('Agent skill script argument validation', () => { assert.equal(body.billable, false); assert.equal(body.error.code, 'page_sse_request_rejected'); assert.equal(body.error.status, 400); - assert.match(body.error.message, /16 的倍数/); + assert.match(body.error.message, /quality/); assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); assert.deepEqual( requests.map((item) => `${item.method} ${item.url}`), @@ -348,7 +402,9 @@ describe('Agent skill script argument validation', () => { requests.push({ method: request.method, url: request.url }); if (request.url === '/api/agent/capabilities') { response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ agent_streaming: {}, agent_jobs: { supported: true, mode: 'job_polling' } })); + response.end( + JSON.stringify({ agent_streaming: {}, agent_jobs: { supported: true, mode: 'job_polling' } }) + ); return; } if (request.url === '/api/agent/images/generate') { @@ -371,7 +427,10 @@ describe('Agent skill script argument validation', () => { const body = JSON.parse(result.stderr); assert.equal(body.error.code, 'page_sse_unavailable'); assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); - assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities'] + ); } ); }); @@ -417,10 +476,166 @@ describe('Agent skill script argument validation', () => { const body = JSON.parse(result.stderr); assert.equal(body.error.code, 'page_sse_failed'); assert.match(body.error.message, /缺少最终 done 事件/); + assert.equal(body.error.diagnostics.partial_image_count, 0); + assert.equal(body.error.diagnostics.completed_event_count, 1); + assert.equal(body.error.diagnostics.done_received, false); + assert.equal(body.error.diagnostics.final_image_count, 1); + assert.equal(body.error.diagnostics.last_upstream_event_type, 'completed'); + } + ); + }); + + it('reports page SSE partial image diagnostics when no final image arrives', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true, endpoint: '/api/images' } } })); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"image_generation.partial_image","partial_image_b64":"partial-a"}', + '', + 'data: {"type":"done","images":[]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--size', '1024x1024', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_failed'); + assert.match(body.error.message, /未返回最终图片/); + assert.equal(body.error.diagnostics.partial_image_count, 1); + assert.equal(body.error.diagnostics.completed_event_count, 0); + assert.equal(body.error.diagnostics.done_received, true); + assert.equal(body.error.diagnostics.final_image_count, 0); + assert.equal(body.error.diagnostics.last_upstream_event_type, 'done'); } ); }); + it('saves raw page SSE events when a generate log path is configured', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-sse-log-')); + try { + const logPath = join(tempRoot, 'events.jsonl'); + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'event: image_generation.partial_image', + 'data: {"type":"image_generation.partial_image","partial_image_b64":"abc"}', + '', + 'data: {"type":"completed","filename":"image.png","path":"/generated/image.png"}', + '', + 'data: {"type":"done","images":[{"filename":"image.png","path":"/generated/image.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--sse-log', logPath, '--size', '1024x1024', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const logLines = readFileSync(logPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(logLines.length, 3); + assert.match(logLines[0].raw_event, /partial_image/); + assert.match(logLines[2].raw_event, /"type":"done"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('keeps generate page SSE successful when the optional raw log path is unwritable', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-sse-log-unwritable-')); + try { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","path":"/generated/image.png"}', + '', + 'data: {"type":"done","images":[{"filename":"image.png","path":"/generated/image.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--sse-log', tempRoot, '--size', '1024x1024', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.match(result.stderr, /SSE log write failed/); + const body = JSON.parse(result.stdout); + assert.equal(body.images.length, 1); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it('requires page access hash before calling page SSE when capabilities declare page auth', async () => { const requests = []; await withServer( @@ -434,7 +649,11 @@ describe('Agent skill script argument validation', () => { page_sse: { supported: true, endpoint: '/api/images', - auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' } + auth: { + required: true, + schemes: ['form-password-hash'], + form_field: 'passwordHash' + } } }, agent_jobs: { supported: true, mode: 'job_polling' } @@ -462,7 +681,10 @@ describe('Agent skill script argument validation', () => { const body = JSON.parse(result.stderr); assert.equal(body.error.code, 'page_sse_auth_required'); assert.match(body.error.message, /GPT_IMAGE_APP_PASSWORD_HASH/); - assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities'] + ); } ); }); @@ -496,6 +718,59 @@ describe('Agent skill script argument validation', () => { response.writeHead(404, { 'content-type': 'application/json' }); response.end(JSON.stringify({ error: 'missing' })); }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { + GPT_IMAGE_PLAYGROUND_URL: baseUrl, + GPT_IMAGE_AGENT_IDEMPOTENCY_KEY: 'x'.repeat(129) + } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_client_request_id_too_long'); + assert.match(body.error.message, /不能超过 128 个字符/); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities'] + ); + } + ); + }); + + it('uses page SSE client request id max length declared by capabilities', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' }, + client_request_id: { max_length: 12 } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, async (baseUrl) => { const result = await runSkillScriptAsync( 'generate-image.mjs', @@ -506,20 +781,22 @@ describe('Agent skill script argument validation', () => { '3072x2048', '--quality', 'high', + '--idempotency-key', + 'too-long-page-key', 'prompt' ], - { - GPT_IMAGE_PLAYGROUND_URL: baseUrl, - GPT_IMAGE_AGENT_IDEMPOTENCY_KEY: 'x'.repeat(129) - } + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } ); assert.equal(result.status, 1); assert.equal(result.stdout.trim(), ''); const body = JSON.parse(result.stderr); assert.equal(body.error.code, 'page_sse_client_request_id_too_long'); - assert.match(body.error.message, /不能超过 128 个字符/); - assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + assert.match(body.error.message, /不能超过 12 个字符/); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities'] + ); } ); }); @@ -619,7 +896,16 @@ describe('Agent skill script argument validation', () => { async (baseUrl) => { const result = await runSkillScriptAsync( 'generate-image.mjs', - ['--allow-billable', '--size', '3072x2048', '--quality', 'high', '--response-mode', 'base64', 'prompt'], + [ + '--allow-billable', + '--size', + '3072x2048', + '--quality', + 'high', + '--response-mode', + 'base64', + 'prompt' + ], { GPT_IMAGE_PLAYGROUND_URL: baseUrl } ); @@ -712,7 +998,17 @@ describe('Agent skill script argument validation', () => { async (baseUrl) => { const result = await runSkillScriptAsync( 'generate-image.mjs', - ['--allow-billable', '--page-sse', '--timeout-ms', '250', '--size', '1024x1024', '--quality', 'high', 'prompt'], + [ + '--allow-billable', + '--page-sse', + '--timeout-ms', + '250', + '--size', + '1024x1024', + '--quality', + 'high', + 'prompt' + ], { GPT_IMAGE_PLAYGROUND_URL: baseUrl }, { timeoutMs: 1_200 } ); @@ -739,7 +1035,11 @@ describe('Agent skill script argument validation', () => { page_sse: { supported: true, endpoint: '/api/images', - auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' } + auth: { + required: true, + schemes: ['form-password-hash'], + form_field: 'passwordHash' + } } }, agent_jobs: { supported: true, mode: 'job_polling' } @@ -891,6 +1191,80 @@ describe('Agent skill script argument validation', () => { ); }); + it('uses Agent JSON for billable large generate requests when streaming strategy is off', async () => { + const requests = []; + let agentRequestBody = ''; + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/agent/images/generate') { + agentRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + images: [ + { + filename: 'agent-off.png', + content_url: '/api/agent/artifacts/artifact-off/content', + metadata_url: '/api/agent/artifacts/artifact-off' + } + ] + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page SSE call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + [ + '--allow-billable', + '--size', + '3072x2048', + '--quality', + 'high', + '--streaming-strategy', + 'off', + 'prompt' + ], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images[0].filename, 'agent-off.png'); + assert.deepEqual(body.routing, { transport: 'agent_json', endpoint: '/api/agent/images/generate' }); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/agent/images/generate'] + ); + const requestBody = JSON.parse(agentRequestBody); + assert.equal(requestBody.size, '3072x2048'); + assert.equal(requestBody.streaming_strategy, 'off'); + } + ); + }); + it('uses Agent JSON for billable large generate requests when --agent is explicit', async () => { const requests = []; await withServer( @@ -1011,6 +1385,30 @@ describe('Agent skill script argument validation', () => { ); }); + it('rejects explicit page SSE when stream-mode is non_stream before network requests', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected request' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--stream-mode', 'non_stream', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 2); + assert.equal(result.stdout.trim(), ''); + assert.match(result.stderr, /stream_mode=non_stream/); + assert.doesNotMatch(result.stderr, /ModuleJob|at buildGenerateRoutingGuidance/); + assert.deepEqual(requests, []); + } + ); + }); + it('prints page SSE guidance for high-resolution edit dry-runs', () => { const result = runSkillScript('edit-image.mjs', [ '--size', @@ -1025,25 +1423,523 @@ describe('Agent skill script argument validation', () => { const body = JSON.parse(result.stdout); assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); assert.equal(body.routing_guidance.transport, 'page_sse'); - assert.equal(body.routing_guidance.strength, 'must_use'); + assert.equal(body.routing_guidance.strength, 'default'); assert.equal(result.stderr.trim(), ''); }); - it('blocks billable high-resolution Agent edit requests before reading image files', () => { + it('routes GPT2Image-compatible edit options through page SSE dry-runs', () => { + const result = runSkillScript('edit-image.mjs', [ + '--format', + 'jpeg', + '--output-compression', + '85', + '--moderation', + 'auto', + '--image-backend', + 'responses', + '--responses-model', + 'gpt-5.4-mini', + '--thinking', + 'medium', + '--prompt-optimization', + 'false', + '--force-web', + '/tmp/source.png', + 'prompt' + ]); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.routing_guidance.transport, 'page_sse'); + assert.equal(body.request.output_format, 'jpeg'); + assert.equal(body.request.output_compression, 85); + assert.equal(body.request.moderation, 'auto'); + assert.equal(body.request.image_backend, 'responses-image-generation'); + assert.equal(body.request.responsesModel, 'gpt-5.4-mini'); + assert.equal(body.request.thinking, 'medium'); + assert.equal(body.request.promptOptimization, false); + assert.equal(body.request.force_web, true); + }); + + it('uses page SSE for billable high-resolution edit requests', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + const requests = []; + let pageSseRequestBody = ''; + + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + pageSseRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"edit.png","path":"/generated/edit.png","clientRequestId":"edit-page-sse-key"}', + '', + 'data: {"type":"done","images":[{"filename":"edit.png","path":"/generated/edit.png"}],"clientRequestId":"edit-page-sse-key"}', + '', + '' + ].join('\n') + ); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + [ + '--allow-billable', + '--size', + '3072x2048', + '--idempotency-key', + 'edit-page-sse-key', + imagePath, + 'prompt' + ], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.deepEqual(body.routing, { + transport: 'page_sse', + endpoint: '/api/images', + fallback_endpoint: '/api/agent/images/edit', + fallback_mode: 'manual_after_diagnosis' + }); + assert.equal(body.images[0].absolute_path, `${baseUrl}/generated/edit.png`); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); + assert.match(pageSseRequestBody, /name="clientRequestId"\r?\n\r?\nedit-page-sse-key/); + assert.match(pageSseRequestBody, /name="image_0"; filename="source\.png"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('passes GPT2Image-compatible edit options to page SSE form-data', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-fields-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + let pageSseRequestBody = ''; + + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true } } })); + return; + } + if (request.url === '/api/images') { + pageSseRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"edit.jpg","path":"/generated/edit.jpg"}', + '', + 'data: {"type":"done","images":[{"filename":"edit.jpg","path":"/generated/edit.jpg"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + [ + '--allow-billable', + '--format', + 'jpeg', + '--output-compression', + '85', + '--moderation', + 'auto', + '--image-backend', + 'responses', + '--responses-model', + 'gpt-5.4-mini', + '--thinking', + 'medium', + '--prompt-optimization', + 'false', + '--force-web', + imagePath, + 'prompt' + ], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(pageSseRequestBody, /name="output_format"\r?\n\r?\njpeg/); + assert.match(pageSseRequestBody, /name="output_compression"\r?\n\r?\n85/); + assert.match(pageSseRequestBody, /name="moderation"\r?\n\r?\nauto/); + assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses-image-generation/); + assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-5\.4-mini/); + assert.match(pageSseRequestBody, /name="thinking"\r?\n\r?\nmedium/); + assert.match(pageSseRequestBody, /name="promptOptimization"\r?\n\r?\nfalse/); + assert.match(pageSseRequestBody, /name="force_web"\r?\n\r?\ntrue/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('keeps edit page SSE successful when the optional raw log path is unwritable', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-sse-log-unwritable-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true } } })); + return; + } + if (request.url === '/api/images') { + await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"edit.png","path":"/generated/edit.png"}', + '', + 'data: {"type":"done","images":[{"filename":"edit.png","path":"/generated/edit.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + ['--allow-billable', '--page-sse', '--sse-log', tempRoot, imagePath, 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.match(result.stderr, /SSE log write failed/); + const body = JSON.parse(result.stdout); + assert.equal(body.images.length, 1); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('uses Agent edit for explicit high-resolution edit fallback requests', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-agent-fallback-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + const requests = []; + let editRequestBody = ''; + + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/agent/images/edit') { + editRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'edit-image', filename: 'edit.png' }] })); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page SSE call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + ['--allow-billable', '--agent', '--size', '3072x2048', imagePath, 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/agent/images/edit'] + ); + assert.match(editRequestBody, /name="size"\r?\n\r?\n3072x2048/); + assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('routes high-resolution edit dry-runs to Agent edit when streaming is explicitly disabled', () => { const result = runSkillScript('edit-image.mjs', [ - '--allow-billable', '--size', '3072x2048', - '/tmp/missing-source.png', + '--stream-mode', + 'non_stream', + '/tmp/source.png', + 'prompt' + ]); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.endpoint, 'http://localhost:4783/api/agent/images/edit'); + assert.equal(body.routing_guidance.transport, 'agent_json'); + assert.equal(result.stderr.trim(), ''); + }); + + it('rejects explicit edit page SSE when stream-mode is non_stream before network requests', () => { + const result = runSkillScript('edit-image.mjs', [ + '--allow-billable', + '--page-sse', + '--stream-mode', + 'non_stream', + '/tmp/source.png', 'prompt' ]); assert.equal(result.status, 2); - const body = JSON.parse(result.stderr); - assert.equal(body.billable, false); - assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); - assert.equal(body.routing_guidance.strength, 'must_use'); assert.equal(result.stdout.trim(), ''); + assert.match(result.stderr, /stream_mode=non_stream/); + }); + + it('reports high-resolution edit page SSE failures as billable structured failures', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-fail-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + const requests = []; + + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end('data: {"type":"error","error":{"message":"edit stream failed"},"status":502}\n\n'); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + ['--allow-billable', '--size', '3072x2048', imagePath, 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, true); + assert.equal(body.error.code, 'page_sse_failed'); + assert.equal(body.error.status, 502); + assert.match(body.error.message, /edit stream failed/); + assert.deepEqual(body.routing, { + transport: 'page_sse', + endpoint: '/api/images', + fallback_endpoint: '/api/agent/images/edit', + fallback_mode: 'manual_after_diagnosis' + }); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('reports high-resolution edit page validation failures as non-billable', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-reject-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + const requests = []; + + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'invalid edit request' })); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + ['--allow-billable', '--size', '3072x2048', imagePath, 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, false); + assert.equal(body.error.code, 'page_sse_request_rejected'); + assert.equal(body.error.status, 400); + assert.match(body.error.message, /invalid edit request/); + assert.equal(body.routing.fallback_endpoint, '/api/agent/images/edit'); + assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('sends upstream streaming fields in billable edit multipart requests', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-')); + try { + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + const requests = []; + let editRequestBody = ''; + + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/edit') { + editRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'edit-image', filename: 'edit.png' }] })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'edit-image.mjs', + [ + '--allow-billable', + '--stream-mode', + 'stream', + '--streaming-strategy', + 'responses-sse', + '--partial-images', + '3', + imagePath, + 'prompt' + ], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/agent/images/edit'] + ); + assert.match(editRequestBody, /name="stream_mode"\r?\n\r?\nstream/); + assert.match(editRequestBody, /name="streaming_strategy"\r?\n\r?\nresponses-sse/); + assert.match(editRequestBody, /name="partial_images"\r?\n\r?\n3/); + assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } }); it('rejects invalid generate upstream streaming options before dry-run output', () => { @@ -1119,9 +2015,1780 @@ describe('Agent skill script argument validation', () => { assert.equal(editHelp.stdout.trim(), ''); }); + it('keeps the skill package free of machine-specific and shell-specific paths', () => { + const forbiddenPatterns = [ + /\/Users\/[^`\\s)]+/, + /\/Volumes\/[^`\\s)]+/, + /\/home\/[^`\\s)]+/, + /C:\\\\Users\\\\[^`\\s)]+/i, + /\.\.\/\.\.\/\.\.\/src\//, + /\.\.\/\.\.\/\.\.\/\.\.\/src\//, + /```bash/, + /(^|\n)[A-Z_][A-Z0-9_]*=.*\s+node\s/, + /\\\r?\n\s+--/ + ]; + + const matches = []; + for (const filePath of listTextFiles(skillRoot)) { + const content = readFileSync(filePath, 'utf8'); + for (const pattern of forbiddenPatterns) { + if (pattern.test(content)) { + matches.push(filePath); + break; + } + } + } + + assert.deepEqual(matches, []); + }); + + it('tells agents to use bundled scripts instead of ad hoc API callers', () => { + const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); + const openAiYaml = readFileSync(join(skillRoot, 'agents/openai.yaml'), 'utf8'); + const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); + + assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/); + assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); + assert.match(openAiYaml, /先选择并运行内置脚本/); + assert.match(openAiYaml, /不要临时编写 API 调用脚本/); + assert.match(apiReference, /先使用这些内置脚本/); + assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); + }); + + it('runs from a copied standalone skill directory outside the repository', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-playground-agent-')); + const copiedSkillRoot = join(tempRoot, 'gpt-image-playground-agent'); + try { + cpSync(skillRoot, copiedSkillRoot, { recursive: true }); + const result = spawnSync( + process.execPath, + [join(copiedSkillRoot, 'scripts/generate-image.mjs'), '--help'], + { + cwd: tmpdir(), + encoding: 'utf8', + env: { + ...process.env, + GPT_IMAGE_PLAYGROUND_URL: 'not a url' + } + } + ); + + assert.equal(result.status, 0); + assert.match(result.stderr, /用法:generate-image\.mjs/); + assert.equal(result.stdout.trim(), ''); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /ERR_MODULE_NOT_FOUND|src\/lib/); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('prints batch dry-run JSONL plans without contacting the service', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + [ + JSON.stringify({ + id: 'first item', + prompt: 'first prompt', + size: '1024x1024', + output_format: 'jpg', + output_compression: '80' + }), + JSON.stringify({ + mode: 'edit', + id: 'edit item', + prompt: 'edit prompt', + image_paths: ['/tmp/source-a.png', '/tmp/source-b.png'], + mask_path: '/tmp/mask.png' + }) + ].join('\n') + ); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--ordered-prefix', 'demo']); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.dry_run, true); + assert.equal(body.billable, false); + assert.equal(body.total, 2); + assert.equal(body.concurrency, 1); + assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate'); + assert.equal(body.tasks[0].idempotency_key, 'demo-0001-first-item'); + assert.equal(body.tasks[0].request.model, 'gpt-image-2'); + assert.equal(body.tasks[0].request.output_format, 'jpeg'); + assert.equal(body.tasks[0].request.output_compression, 80); + assert.equal(body.tasks[1].endpoint, '/api/agent/images/edit'); + assert.equal(body.tasks[1].idempotency_key, 'demo-0002-edit-item'); + assert.deepEqual(body.tasks[1].request.image_fields, ['image_0', 'image_1']); + assert.equal(body.tasks[1].request.mask, 'provided'); + assert.equal('image_path' in body.tasks[1].request, false); + assert.equal('image_paths' in body.tasks[1].request, false); + assert.equal('mask_path' in body.tasks[1].request, false); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects invalid batch concurrency before dry-run output', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' })); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--concurrency', '0']); + + assert.equal(result.status, 2); + assert.match(result.stderr, /--concurrency 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects batch concurrency with strict consecutive failure stopping', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-stop-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' })); + + const result = runSkillScript('batch-images.mjs', [ + '--input', + inputPath, + '--concurrency', + '2', + '--max-consecutive-failures', + '1' + ]); + + assert.equal(result.status, 2); + assert.match(result.stderr, /不能同时使用 --max-consecutive-failures/); + assert.equal(result.stdout.trim(), ''); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('validates present batch fields even when their values are falsy', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'bad-partial', prompt: 'prompt', partial_images: 0 })); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); + + assert.equal(result.status, 2); + assert.match(result.stderr, /bad-partial\.partial_images 必须是正整数/); + assert.equal(result.stdout.trim(), ''); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('preserves explicit falsy batch task ids in dry-run plans', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 0, prompt: 'prompt' })); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--ordered-prefix', 'demo']); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.tasks[0].id, '0'); + assert.equal(body.tasks[0].idempotency_key, 'demo-0001-0'); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('normalizes batch output compression before sending Agent JSON', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-compression-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'compressed', + prompt: 'prompt', + output_format: 'jpeg', + output_compression: '80' + }) + ); + + let agentRequestBody = ''; + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + agentRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'compressed-image', filename: 'compressed.jpg' }] })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const requestBody = JSON.parse(agentRequestBody); + assert.equal(requestBody.output_format, 'jpeg'); + assert.equal(requestBody.output_compression, 80); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('appends batch manifests and skips succeeded tasks during resume', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + writeFileSync( + inputPath, + [ + JSON.stringify({ id: 'first', prompt: 'first prompt', size: '1024x1024' }), + JSON.stringify({ id: 'second', prompt: 'second prompt', size: '1024x1024' }) + ].join('\n') + ); + writeFileSync( + manifestPath, + `${JSON.stringify({ id: 'first', idempotency_key: 'batch-0001-first', status: 'succeeded' })}\n` + ); + + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + images: [ + { + id: 'image-second', + filename: 'second.png', + b64_json: fakePngBase64(2, 1) + } + ] + }) + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].status, 'skipped'); + assert.equal(body.results[1].status, 'succeeded'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['POST /api/agent/images/generate'] + ); + const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(manifestLines.length, 3); + assert.equal(manifestLines[1].status, 'skipped'); + assert.equal(manifestLines[2].status, 'succeeded'); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('runs batch tasks with the requested concurrency while preserving result order', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrent-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + [ + JSON.stringify({ id: 'first', prompt: 'first prompt', idempotency_key: 'first-key' }), + JSON.stringify({ id: 'second', prompt: 'second prompt', idempotency_key: 'second-key' }), + JSON.stringify({ id: 'third', prompt: 'third prompt', idempotency_key: 'third-key' }) + ].join('\n') + ); + + let activeGenerateRequests = 0; + let maxActiveGenerateRequests = 0; + let enteredGenerateRequests = 0; + const requestKeys = []; + const twoGenerateRequestsEntered = createDeferred(); + const releaseGenerateResponses = createDeferred(); + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + activeGenerateRequests += 1; + enteredGenerateRequests += 1; + maxActiveGenerateRequests = Math.max(maxActiveGenerateRequests, activeGenerateRequests); + requestKeys.push(request.headers['idempotency-key']); + if (enteredGenerateRequests === 2) { + twoGenerateRequestsEntered.resolve(); + } + await releaseGenerateResponses.promise; + activeGenerateRequests -= 1; + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + images: [ + { + id: request.headers['idempotency-key'], + filename: `${request.headers['idempotency-key']}.png`, + b64_json: fakePngBase64(2, 1) + } + ] + }) + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const resultPromise = runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--concurrency', '2'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl }, + { timeoutMs: 10_000 } + ); + let result; + try { + await waitWithTimeout( + twoGenerateRequestsEntered.promise, + 2_000, + 'expected two generate requests to enter before any response was released' + ); + assert.equal(maxActiveGenerateRequests, 2); + releaseGenerateResponses.resolve(); + result = await resultPromise; + } finally { + releaseGenerateResponses.resolve(); + await resultPromise; + } + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.concurrency, 2); + assert.equal(maxActiveGenerateRequests, 2); + assert.deepEqual([...requestKeys].sort(), ['first-key', 'second-key', 'third-key']); + assert.deepEqual( + body.results.map((item) => item.id), + ['first', 'second', 'third'] + ); + assert.deepEqual( + body.results.map((item) => item.response.images[0].filename), + ['first-key.png', 'second-key.png', 'third-key.png'] + ); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('does not require capabilities when resume skips every batch task', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-resume-skip-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'done', prompt: 'already done', size: '1024x1024' })); + writeFileSync( + manifestPath, + `${JSON.stringify({ id: 'done', idempotency_key: 'batch-0001-done', status: 'succeeded' })}\n` + ); + + const result = runSkillScript( + 'batch-images.mjs', + ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], + { GPT_IMAGE_PLAYGROUND_URL: 'http://127.0.0.1:9' } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].status, 'skipped'); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('skips malformed batch manifest lines during resume', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'first prompt', size: '1024x1024' })); + writeFileSync( + manifestPath, + `${JSON.stringify({ id: 'first', idempotency_key: 'batch-0001-first', status: 'succeeded' })}\n{"truncated"` + ); + + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected resumed request' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].status, 'skipped'); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('runs batch edit tasks with multipart image input', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + writeFileSync( + inputPath, + JSON.stringify({ mode: 'edit', id: 'edit-one', prompt: 'edit prompt', image_path: imagePath, size: '1024x1024' }) + ); + + let editRequestBody = ''; + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/edit') { + editRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'edit-image', filename: 'edit.png', b64_json: fakePngBase64(2, 1) }] })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(editRequestBody, /name="prompt"\r?\n\r?\nedit prompt/); + assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('routes high-resolution batch edit tasks through page SSE', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + writeFileSync( + inputPath, + JSON.stringify({ + mode: 'edit', + id: 'edit-large', + prompt: 'edit prompt', + image_path: imagePath, + size: '3072x2048', + idempotency_key: 'batch-edit-page-sse-key' + }) + ); + + let pageSseRequestBody = ''; + const requests = []; + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + pageSseRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"batch-edit.png","path":"/generated/batch-edit.png"}', + '', + 'data: {"type":"done","images":[{"filename":"batch-edit.png","path":"/generated/batch-edit.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].routing.transport, 'page_sse'); + assert.equal(body.results[0].routing.strength, 'default'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); + assert.match(pageSseRequestBody, /name="clientRequestId"\r?\n\r?\nbatch-edit-page-sse-key/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('routes batch generate requests with responsesModel through page SSE', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-responses-model-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'responses-generate', + prompt: 'prompt', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + responsesModel: 'gpt-4.1-responses', + idempotency_key: 'batch-responses-model-key' + }) + ); + + let pageSseRequestBody = ''; + const requests = []; + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + pageSseRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"responses.png","path":"/generated/responses.png"}', + '', + 'data: {"type":"done","images":[{"filename":"responses.png","path":"/generated/responses.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent generate call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].routing.transport, 'page_sse'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses/); + assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-4\.1-responses/); + assert.match(pageSseRequestBody, /name="image_streaming_strategy"\r?\n\r?\nresponses-sse/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('passes GPT2Image-compatible edit options to batch page SSE form-data', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-edit-advanced-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + writeFileSync( + inputPath, + JSON.stringify({ + mode: 'edit', + id: 'edit-advanced', + prompt: 'edit prompt', + image_path: imagePath, + size: '1024x1024', + output_format: 'jpeg', + output_compression: 85, + moderation: 'auto', + image_backend: 'responses', + responsesModel: 'gpt-5.4-mini', + thinking: 'medium', + promptOptimization: false, + force_web: true, + idempotency_key: 'batch-edit-advanced-key' + }) + ); + + let pageSseRequestBody = ''; + const requests = []; + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + pageSseRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"edit.jpg","path":"/generated/edit.jpg"}', + '', + 'data: {"type":"done","images":[{"filename":"edit.jpg","path":"/generated/edit.jpg"}]}', + '', + '' + ].join('\n') + ); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].routing.transport, 'page_sse'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); + assert.match(pageSseRequestBody, /name="output_format"\r?\n\r?\njpeg/); + assert.match(pageSseRequestBody, /name="output_compression"\r?\n\r?\n85/); + assert.match(pageSseRequestBody, /name="moderation"\r?\n\r?\nauto/); + assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses-image-generation/); + assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-5\.4-mini/); + assert.match(pageSseRequestBody, /name="thinking"\r?\n\r?\nmedium/); + assert.match(pageSseRequestBody, /name="promptOptimization"\r?\n\r?\nfalse/); + assert.match(pageSseRequestBody, /name="force_web"\r?\n\r?\ntrue/); + assert.match(pageSseRequestBody, /name="image_0"/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('fails batch responsesModel routing when page SSE capability is unavailable', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-responses-model-no-sse-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'responses-no-page-sse', + prompt: 'prompt', + image_backend: 'responses-image-generation', + responsesModel: 'gpt-4.1-responses' + }) + ); + + const requests = []; + await withServer( + async (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ agent_streaming: {} })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent generate call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync('batch-images.mjs', ['--allow-billable', '--input', inputPath], { + GPT_IMAGE_PLAYGROUND_URL: baseUrl + }); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].ok, false); + assert.equal(body.results[0].error.code, 'page_sse_unavailable'); + assert.equal(body.results[0].routing.transport, 'page_sse'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities'] + ); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('does not route batch tasks through page SSE when streaming is explicitly disabled', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-disabled-page-sse-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'large-generate-non-stream', + prompt: 'prompt', + size: '3072x2048', + stream_mode: 'non_stream' + }) + ); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.tasks[0].routing.transport, 'agent_json'); + assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate'); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('rejects explicit batch page SSE when streaming is explicitly disabled', () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-disabled-explicit-page-sse-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'large-generate-forced-page-sse', + prompt: 'prompt', + size: '3072x2048', + page_sse: true, + stream_mode: 'non_stream' + }) + ); + + const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); + + assert.equal(result.status, 2); + assert.equal(result.stdout.trim(), ''); + assert.match(result.stderr, /large-generate-forced-page-sse/); + assert.match(result.stderr, /stream_mode=non_stream/); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('records structured page SSE failures in batch output and manifest', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-fail-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + writeFileSync( + inputPath, + JSON.stringify({ + mode: 'edit', + id: 'edit-large-fail', + prompt: 'edit prompt', + image_path: imagePath, + size: '3072x2048', + idempotency_key: 'batch-edit-page-sse-fail-key' + }) + ); + + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'invalid edit request' })); + return; + } + if (request.url === '/api/agent/images/edit') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--manifest', manifestPath], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.ok, false); + assert.equal(body.failed, 1); + assert.equal(body.results[0].billable, false); + assert.equal(body.results[0].error.code, 'page_sse_request_rejected'); + assert.equal(body.results[0].error.status, 400); + assert.equal(body.results[0].routing.fallback_endpoint, '/api/agent/images/edit'); + assert.equal(body.results[0].routing.fallback_mode, 'fix_request_before_retry'); + assert.match(body.results[0].next_step, /请求参数或鉴权/); + + const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(manifestLines.length, 1); + assert.equal(manifestLines[0].status, 'failed'); + assert.equal(manifestLines[0].billable, false); + assert.equal(manifestLines[0].error.code, 'page_sse_request_rejected'); + assert.equal(manifestLines[0].routing.transport, 'page_sse'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('re-fetches batch page SSE capabilities after a transient capabilities failure', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-capability-retry-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + const imagePath = join(tempRoot, 'source.png'); + writeFileSync(imagePath, fakePngBuffer(2, 1)); + writeFileSync( + inputPath, + JSON.stringify({ + mode: 'edit', + id: 'edit-large-capability-retry', + prompt: 'edit prompt', + image_path: imagePath, + size: '3072x2048', + idempotency_key: 'capability-retry-key' + }) + ); + + let capabilitiesRequests = 0; + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + capabilitiesRequests += 1; + if (capabilitiesRequests === 1) { + response.writeHead(503, { 'content-type': 'text/plain' }); + response.end('capabilities maintenance'); + return; + } + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"retry-edit.png","path":"/generated/retry-edit.png","output_format":"png"}', + '', + 'data: {"type":"done"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'GET /api/agent/capabilities', 'POST /api/images'] + ); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].attempt, 2); + assert.equal(body.results[0].root_idempotency_key, 'capability-retry-key'); + assert.equal(body.results[0].response.images[0].filename, 'retry-edit.png'); + + const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(manifestLines.length, 2); + assert.equal(manifestLines[0].status, 'failed'); + assert.equal(manifestLines[0].idempotency_key, 'capability-retry-key'); + assert.equal(manifestLines[1].status, 'succeeded'); + assert.equal(manifestLines[1].idempotency_key, 'capability-retry-key-attempt-2'); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('retries batch failures with fresh attempt idempotency keys and reports a fix list', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'retry-generate', + prompt: 'prompt', + idempotency_key: 'retry-key' + }) + ); + + const idempotencyKeys = []; + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + idempotencyKeys.push(request.headers['idempotency-key']); + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + assert.deepEqual(idempotencyKeys, ['retry-key', 'retry-key-attempt-2']); + const body = JSON.parse(result.stdout); + assert.equal(body.failure_summary.count, 1); + assert.equal(body.failure_summary.tasks[0].id, 'retry-generate'); + assert.equal(body.resume_fix_list[0].previous_idempotency_key, 'retry-key'); + assert.equal(body.resume_fix_list[0].suggested_idempotency_key, 'retry-key-attempt-3'); + assert.equal(body.results[0].attempt, 2); + assert.equal(body.results[0].root_idempotency_key, 'retry-key'); + + const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(manifestLines.length, 2); + assert.equal(manifestLines[0].idempotency_key, 'retry-key'); + assert.equal(manifestLines[0].attempt, 1); + assert.equal(manifestLines[1].idempotency_key, 'retry-key-attempt-2'); + assert.equal(manifestLines[1].root_idempotency_key, 'retry-key'); + assert.equal(manifestLines[1].attempt, 2); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('preserves the retry suffix when long batch idempotency keys are truncated', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-long-key-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + const longKey = 'k'.repeat(198); + writeFileSync( + inputPath, + JSON.stringify({ + id: 'retry-generate-long-key', + prompt: 'prompt', + idempotency_key: longKey + }) + ); + + const idempotencyKeys = []; + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + idempotencyKeys.push(request.headers['idempotency-key']); + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '3'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(idempotencyKeys.length, 3); + assert.equal(idempotencyKeys[1].length, 200); + assert.equal(idempotencyKeys[1].endsWith('-attempt-2'), true); + assert.equal(idempotencyKeys[2].length, 200); + assert.equal(idempotencyKeys[2].endsWith('-attempt-3'), true); + assert.notEqual(idempotencyKeys[1], idempotencyKeys[2]); + const body = JSON.parse(result.stdout); + assert.equal(body.resume_fix_list[0].suggested_idempotency_key.endsWith('-attempt-4'), true); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('keeps truncated batch retry idempotency keys distinct when long roots share a prefix', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-collision-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + const sharedPrefix = 'shared-key-'.padEnd(199, 'x'); + const firstKey = `${sharedPrefix}a`; + const secondKey = `${sharedPrefix}b`; + writeFileSync( + inputPath, + [ + JSON.stringify({ id: 'first-long-key', prompt: 'first', idempotency_key: firstKey }), + JSON.stringify({ id: 'second-long-key', prompt: 'second', idempotency_key: secondKey }) + ].join('\n') + ); + + const idempotencyKeys = []; + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + idempotencyKeys.push(request.headers['idempotency-key']); + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(idempotencyKeys.length, 4); + assert.equal(idempotencyKeys[1].length, 200); + assert.equal(idempotencyKeys[3].length, 200); + assert.equal(idempotencyKeys[1].endsWith('-attempt-2'), true); + assert.equal(idempotencyKeys[3].endsWith('-attempt-2'), true); + assert.notEqual(idempotencyKeys[1], idempotencyKeys[3]); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('stops batch execution after max consecutive failures and leaves later tasks resumable', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-circuit-breaker-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + const manifestPath = join(tempRoot, 'manifest.jsonl'); + writeFileSync( + inputPath, + [ + JSON.stringify({ id: 'first-fail', prompt: 'first', idempotency_key: 'first-key' }), + JSON.stringify({ id: 'second-skip', prompt: 'second', idempotency_key: 'second-key' }) + ].join('\n') + ); + + const idempotencyKeys = []; + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + idempotencyKeys.push(request.headers['idempotency-key']); + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'upstream failed' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + [ + '--allow-billable', + '--input', + inputPath, + '--manifest', + manifestPath, + '--max-consecutive-failures', + '1' + ], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + assert.deepEqual(idempotencyKeys, ['first-key']); + const body = JSON.parse(result.stdout); + assert.equal(body.failed, 1); + assert.equal(body.results[0].status, 'failed'); + assert.equal(body.results[1].status, 'skipped'); + assert.equal(body.results[1].skipped_reason, 'max_consecutive_failures'); + assert.equal(body.results[1].billable, false); + assert.equal(body.failure_summary.count, 1); + + const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); + assert.equal(manifestLines.length, 2); + assert.equal(manifestLines[0].status, 'failed'); + assert.equal(manifestLines[1].status, 'skipped'); + assert.equal(manifestLines[1].skipped_reason, 'max_consecutive_failures'); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('checks batch output dimensions from same-origin artifact URLs', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const inputPath = join(tempRoot, 'tasks.jsonl'); + writeFileSync(inputPath, JSON.stringify({ id: 'dim-ok', prompt: 'prompt', size: '1024x1024' })); + + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'dim-image', filename: 'dim.png', content_url: '/artifact/dim.png' }] })); + return; + } + if (request.url === '/artifact/dim.png') { + response.writeHead(200, { 'content-type': 'image/png' }); + response.end(fakePngBuffer(1024, 1024)); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--dimension-check', '--input', inputPath], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.results[0].status, 'succeeded'); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('fails batch dimension-check mismatches and invalid size parameters', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); + try { + const invalidInputPath = join(tempRoot, 'invalid.jsonl'); + writeFileSync(invalidInputPath, JSON.stringify({ id: 'bad-size', prompt: 'prompt', size: 'wide' })); + const invalidResult = runSkillScript('batch-images.mjs', ['--input', invalidInputPath]); + assert.equal(invalidResult.status, 2); + assert.match(invalidResult.stderr, /bad-size\.size 必须是 auto 或 WIDTHxHEIGHT/); + assert.equal(invalidResult.stdout.trim(), ''); + + const maxPixelsInputPath = join(tempRoot, 'max-pixels.jsonl'); + writeFileSync(maxPixelsInputPath, JSON.stringify({ id: 'too-many-pixels', prompt: 'prompt', size: '3840x3840' })); + const maxPixelsResult = runSkillScript('batch-images.mjs', ['--input', maxPixelsInputPath]); + assert.equal(maxPixelsResult.status, 2); + assert.match(maxPixelsResult.stderr, /too-many-pixels\.size 的总像素不能超过 8,294,400/); + assert.equal(maxPixelsResult.stdout.trim(), ''); + + const invalidModelInputPath = join(tempRoot, 'invalid-model.jsonl'); + writeFileSync( + invalidModelInputPath, + JSON.stringify({ + id: 'invalid-model', + prompt: 'prompt', + model: 'bad-model', + size: '2048x2048' + }) + ); + const invalidModelResult = runSkillScript('batch-images.mjs', ['--input', invalidModelInputPath]); + assert.equal(invalidModelResult.status, 2); + assert.match(invalidModelResult.stderr, /invalid-model\.model 的值无效:bad-model/); + assert.equal(invalidModelResult.stdout.trim(), ''); + + const pngCompressionInputPath = join(tempRoot, 'png-compression.jsonl'); + writeFileSync( + pngCompressionInputPath, + JSON.stringify({ + id: 'png-compression', + prompt: 'prompt', + output_format: 'png', + output_compression: 80 + }) + ); + const pngCompressionResult = runSkillScript('batch-images.mjs', ['--input', pngCompressionInputPath]); + assert.equal(pngCompressionResult.status, 0); + assert.equal(pngCompressionResult.stderr.trim(), ''); + const pngCompressionBody = JSON.parse(pngCompressionResult.stdout); + assert.equal( + pngCompressionBody.tasks[0].request.normalizations.output_compression_ignored_for_png, + true + ); + assert.equal(pngCompressionBody.tasks[0].request.output_compression, undefined); + + const conflictingFormatInputPath = join(tempRoot, 'conflicting-format.jsonl'); + writeFileSync( + conflictingFormatInputPath, + JSON.stringify({ + id: 'conflicting-format', + prompt: 'prompt', + output_format: 'png', + format: 'webp' + }) + ); + const conflictingFormatResult = runSkillScript('batch-images.mjs', ['--input', conflictingFormatInputPath]); + assert.equal(conflictingFormatResult.status, 2); + assert.match(conflictingFormatResult.stderr, /conflicting-format\.output_format 与 format 不能同时设置/); + assert.equal(conflictingFormatResult.stdout.trim(), ''); + + const editFormatInputPath = join(tempRoot, 'edit-format.jsonl'); + writeFileSync( + editFormatInputPath, + JSON.stringify({ + id: 'edit-format', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + size: '1024x1024', + output_format: 'jpeg' + }) + ); + const editFormatResult = runSkillScript('batch-images.mjs', ['--input', editFormatInputPath]); + assert.equal(editFormatResult.status, 0); + assert.equal(editFormatResult.stderr.trim(), ''); + const editFormatBody = JSON.parse(editFormatResult.stdout); + assert.equal(editFormatBody.tasks[0].routing.transport, 'page_sse'); + assert.match(editFormatBody.tasks[0].routing.reason, /GPT2Image-compatible edit options/); + assert.equal(editFormatBody.tasks[0].request.output_format, 'jpeg'); + + const editBackendInputPath = join(tempRoot, 'edit-backend.jsonl'); + writeFileSync( + editBackendInputPath, + JSON.stringify({ + id: 'edit-backend', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + image_backend: 'responses-image-generation' + }) + ); + const editBackendResult = runSkillScript('batch-images.mjs', ['--input', editBackendInputPath]); + assert.equal(editBackendResult.status, 0); + assert.equal(editBackendResult.stderr.trim(), ''); + const editBackendBody = JSON.parse(editBackendResult.stdout); + assert.equal(editBackendBody.tasks[0].routing.transport, 'page_sse'); + assert.equal(editBackendBody.tasks[0].request.image_backend, 'responses-image-generation'); + + const editBackgroundInputPath = join(tempRoot, 'edit-background.jsonl'); + writeFileSync( + editBackgroundInputPath, + JSON.stringify({ + id: 'edit-background', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + background: 'opaque' + }) + ); + const editBackgroundResult = runSkillScript('batch-images.mjs', ['--input', editBackgroundInputPath]); + assert.equal(editBackgroundResult.status, 2); + assert.match(editBackgroundResult.stderr, /edit-background\.background 仅适用于 generate 任务/); + assert.equal(editBackgroundResult.stdout.trim(), ''); + + const editResponsesModelInputPath = join(tempRoot, 'edit-responses-model.jsonl'); + writeFileSync( + editResponsesModelInputPath, + JSON.stringify({ + id: 'edit-responses-model', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + responsesModel: 'gpt-4.1' + }) + ); + const editResponsesModelResult = runSkillScript('batch-images.mjs', ['--input', editResponsesModelInputPath]); + assert.equal(editResponsesModelResult.status, 2); + assert.match( + editResponsesModelResult.stderr, + /edit-responses-model\.responsesModel 必须同时设置 image_backend=responses-image-generation/ + ); + assert.equal(editResponsesModelResult.stdout.trim(), ''); + + const generateImagePathInputPath = join(tempRoot, 'generate-image-path.jsonl'); + writeFileSync( + generateImagePathInputPath, + JSON.stringify({ + id: 'generate-image-path', + mode: 'generate', + prompt: 'prompt', + image_path: 'source.png' + }) + ); + const generateImagePathResult = runSkillScript('batch-images.mjs', ['--input', generateImagePathInputPath]); + assert.equal(generateImagePathResult.status, 2); + assert.match(generateImagePathResult.stderr, /generate-image-path\.image_path 仅适用于 edit 任务/); + assert.equal(generateImagePathResult.stdout.trim(), ''); + + const camelCaseBackendInputPath = join(tempRoot, 'camel-case-backend.jsonl'); + writeFileSync( + camelCaseBackendInputPath, + JSON.stringify({ + id: 'camel-case-backend', + prompt: 'prompt', + imageBackend: 'responses' + }) + ); + const camelCaseBackendResult = runSkillScript('batch-images.mjs', ['--input', camelCaseBackendInputPath]); + assert.equal(camelCaseBackendResult.status, 2); + assert.match(camelCaseBackendResult.stderr, /camel-case-backend\.imageBackend 不是支持的 batch JSONL 字段/); + assert.equal(camelCaseBackendResult.stdout.trim(), ''); + + const camelCaseResponseModeInputPath = join(tempRoot, 'camel-case-response-mode.jsonl'); + writeFileSync( + camelCaseResponseModeInputPath, + JSON.stringify({ + id: 'camel-case-response-mode', + prompt: 'prompt', + responseMode: 'both' + }) + ); + const camelCaseResponseModeResult = runSkillScript('batch-images.mjs', [ + '--input', + camelCaseResponseModeInputPath + ]); + assert.equal(camelCaseResponseModeResult.status, 2); + assert.match( + camelCaseResponseModeResult.stderr, + /camel-case-response-mode\.responseMode 不是支持的 batch JSONL 字段/ + ); + assert.equal(camelCaseResponseModeResult.stdout.trim(), ''); + + const numericImagePathInputPath = join(tempRoot, 'numeric-image-path.jsonl'); + writeFileSync( + numericImagePathInputPath, + JSON.stringify({ + id: 'numeric-image-path', + mode: 'edit', + prompt: 'prompt', + image_path: 123 + }) + ); + const numericImagePathResult = runSkillScript('batch-images.mjs', ['--input', numericImagePathInputPath]); + assert.equal(numericImagePathResult.status, 2); + assert.match(numericImagePathResult.stderr, /numeric-image-path\.image_path 必须是非空字符串/); + assert.equal(numericImagePathResult.stdout.trim(), ''); + + const emptyImagePathsInputPath = join(tempRoot, 'empty-image-paths.jsonl'); + writeFileSync( + emptyImagePathsInputPath, + JSON.stringify({ + id: 'empty-image-paths', + mode: 'edit', + prompt: 'prompt', + image_paths: [] + }) + ); + const emptyImagePathsResult = runSkillScript('batch-images.mjs', ['--input', emptyImagePathsInputPath]); + assert.equal(emptyImagePathsResult.status, 2); + assert.match(emptyImagePathsResult.stderr, /empty-image-paths\.image_paths 必须是非空字符串数组/); + assert.equal(emptyImagePathsResult.stdout.trim(), ''); + + const invalidImagePathsInputPath = join(tempRoot, 'invalid-image-paths.jsonl'); + writeFileSync( + invalidImagePathsInputPath, + JSON.stringify({ + id: 'invalid-image-paths', + mode: 'edit', + prompt: 'prompt', + image_paths: ['source.png', 123] + }) + ); + const invalidImagePathsResult = runSkillScript('batch-images.mjs', ['--input', invalidImagePathsInputPath]); + assert.equal(invalidImagePathsResult.status, 2); + assert.match(invalidImagePathsResult.stderr, /invalid-image-paths\.image_paths\[1\] 必须是非空字符串/); + assert.equal(invalidImagePathsResult.stdout.trim(), ''); + + const conflictingImagePathsInputPath = join(tempRoot, 'conflicting-image-paths.jsonl'); + writeFileSync( + conflictingImagePathsInputPath, + JSON.stringify({ + id: 'conflicting-image-paths', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + image_paths: ['source-a.png'] + }) + ); + const conflictingImagePathsResult = runSkillScript('batch-images.mjs', [ + '--input', + conflictingImagePathsInputPath + ]); + assert.equal(conflictingImagePathsResult.status, 2); + assert.match( + conflictingImagePathsResult.stderr, + /conflicting-image-paths\.image_path 与 image_paths 不能同时设置/ + ); + assert.equal(conflictingImagePathsResult.stdout.trim(), ''); + + const invalidMaskInputPath = join(tempRoot, 'invalid-mask.jsonl'); + writeFileSync( + invalidMaskInputPath, + JSON.stringify({ + id: 'invalid-mask', + mode: 'edit', + prompt: 'prompt', + image_path: 'source.png', + mask_path: {} + }) + ); + const invalidMaskResult = runSkillScript('batch-images.mjs', ['--input', invalidMaskInputPath]); + assert.equal(invalidMaskResult.status, 2); + assert.match(invalidMaskResult.stderr, /invalid-mask\.mask_path 必须是非空字符串/); + assert.equal(invalidMaskResult.stdout.trim(), ''); + + const transparentInputPath = join(tempRoot, 'transparent.jsonl'); + writeFileSync( + transparentInputPath, + JSON.stringify({ + id: 'transparent-background', + prompt: 'prompt', + background: 'transparent' + }) + ); + const transparentResult = runSkillScript('batch-images.mjs', ['--input', transparentInputPath]); + assert.equal(transparentResult.status, 2); + assert.match(transparentResult.stderr, /transparent-background\.background 对 gpt-image-2 无效/); + assert.equal(transparentResult.stdout.trim(), ''); + + const stringPageSseInputPath = join(tempRoot, 'string-page-sse.jsonl'); + writeFileSync( + stringPageSseInputPath, + JSON.stringify({ + id: 'string-page-sse', + prompt: 'prompt', + page_sse: 'true' + }) + ); + const stringPageSseResult = runSkillScript('batch-images.mjs', ['--input', stringPageSseInputPath]); + assert.equal(stringPageSseResult.status, 2); + assert.match(stringPageSseResult.stderr, /string-page-sse\.page_sse 必须是布尔值/); + assert.equal(stringPageSseResult.stdout.trim(), ''); + + const stringComplexUiInputPath = join(tempRoot, 'string-complex-ui.jsonl'); + writeFileSync( + stringComplexUiInputPath, + JSON.stringify({ + id: 'string-complex-ui', + prompt: 'prompt', + complex_ui: 'true' + }) + ); + const stringComplexUiResult = runSkillScript('batch-images.mjs', ['--input', stringComplexUiInputPath]); + assert.equal(stringComplexUiResult.status, 2); + assert.match(stringComplexUiResult.stderr, /string-complex-ui\.complex_ui 必须是布尔值/); + assert.equal(stringComplexUiResult.stdout.trim(), ''); + + const numericResumeInputPath = join(tempRoot, 'numeric-resume.jsonl'); + writeFileSync( + numericResumeInputPath, + JSON.stringify({ + id: 'numeric-resume', + prompt: 'prompt', + resume_or_recover: 1 + }) + ); + const numericResumeResult = runSkillScript('batch-images.mjs', ['--input', numericResumeInputPath]); + assert.equal(numericResumeResult.status, 2); + assert.match(numericResumeResult.stderr, /numeric-resume\.resume_or_recover 必须是布尔值/); + assert.equal(numericResumeResult.stdout.trim(), ''); + + const unsupportedTransportInputPath = join(tempRoot, 'unsupported-transport.jsonl'); + writeFileSync( + unsupportedTransportInputPath, + JSON.stringify({ + id: 'unsupported-transport', + prompt: 'prompt', + transport: 'agent_json' + }) + ); + const unsupportedTransportResult = runSkillScript('batch-images.mjs', ['--input', unsupportedTransportInputPath]); + assert.equal(unsupportedTransportResult.status, 2); + assert.match(unsupportedTransportResult.stderr, /unsupported-transport\.transport 必须是 page_sse/); + assert.equal(unsupportedTransportResult.stdout.trim(), ''); + + const responsesModelNonStreamInputPath = join(tempRoot, 'responses-model-non-stream.jsonl'); + writeFileSync( + responsesModelNonStreamInputPath, + JSON.stringify({ + id: 'responses-model-non-stream', + prompt: 'prompt', + image_backend: 'responses-image-generation', + responsesModel: 'gpt-4.1', + stream_mode: 'non_stream' + }) + ); + const responsesModelNonStreamResult = runSkillScript('batch-images.mjs', [ + '--input', + responsesModelNonStreamInputPath + ]); + assert.equal(responsesModelNonStreamResult.status, 2); + assert.match(responsesModelNonStreamResult.stderr, /responses-model-non-stream\.responsesModel 需要页面 SSE 路径/); + assert.equal(responsesModelNonStreamResult.stdout.trim(), ''); + + const responsesModelWithoutBackendInputPath = join(tempRoot, 'responses-model-without-backend.jsonl'); + writeFileSync( + responsesModelWithoutBackendInputPath, + JSON.stringify({ + id: 'responses-model-without-backend', + prompt: 'prompt', + responsesModel: 'gpt-4.1' + }) + ); + const responsesModelWithoutBackendResult = runSkillScript('batch-images.mjs', [ + '--input', + responsesModelWithoutBackendInputPath + ]); + assert.equal(responsesModelWithoutBackendResult.status, 2); + assert.match( + responsesModelWithoutBackendResult.stderr, + /responses-model-without-backend\.responsesModel 必须同时设置 image_backend=responses-image-generation/ + ); + assert.equal(responsesModelWithoutBackendResult.stdout.trim(), ''); + + const responsesModelImagesBackendInputPath = join(tempRoot, 'responses-model-images-backend.jsonl'); + writeFileSync( + responsesModelImagesBackendInputPath, + JSON.stringify({ + id: 'responses-model-images-backend', + prompt: 'prompt', + image_backend: 'images-api', + responsesModel: 'gpt-4.1' + }) + ); + const responsesModelImagesBackendResult = runSkillScript('batch-images.mjs', [ + '--input', + responsesModelImagesBackendInputPath + ]); + assert.equal(responsesModelImagesBackendResult.status, 2); + assert.match( + responsesModelImagesBackendResult.stderr, + /responses-model-images-backend\.responsesModel 仅适用于 image_backend=responses-image-generation/ + ); + assert.equal(responsesModelImagesBackendResult.stdout.trim(), ''); + + const mismatchInputPath = join(tempRoot, 'mismatch.jsonl'); + writeFileSync(mismatchInputPath, JSON.stringify({ id: 'dim-bad', prompt: 'prompt', size: '1024x1024' })); + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ images: [{ id: 'dim-image', filename: 'dim.png', b64_json: fakePngBase64(512, 512) }] })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'batch-images.mjs', + ['--allow-billable', '--dimension-check', '--input', mismatchInputPath], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.ok, false); + assert.match(body.results[0].error, /尺寸校验失败/); + } + ); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it('rejects cross-origin job result URLs before sending auth headers', () => { assert.throws( - () => resolveSameOriginUrl('https://space.example.test', 'https://evil.example.test/result', 'job.result_url'), + () => + resolveSameOriginUrl( + 'https://space.example.test', + 'https://evil.example.test/result', + 'job.result_url' + ), /不同 origin/ ); assert.equal( @@ -1193,6 +3860,20 @@ function runSkillScript(filename, args, env = {}) { }); } +function listTextFiles(root) { + const result = []; + for (const name of readdirSync(root)) { + const filePath = join(root, name); + const stat = statSync(filePath); + if (stat.isDirectory()) { + result.push(...listTextFiles(filePath)); + } else if (/\.(md|mjs|yaml)$/.test(name)) { + result.push(filePath); + } + } + return result; +} + function runSkillScriptAsync(filename, args, env = {}, options = {}) { return new Promise((resolve) => { const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], { @@ -1224,6 +3905,30 @@ function runSkillScriptAsync(filename, args, env = {}, options = {}) { }); } +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +async function waitWithTimeout(promise, timeoutMs, message) { + let timeout; + const timeoutPromise = new Promise((resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeout); + } +} + async function withServer(handler, run) { const server = createServer(handler); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -1250,3 +3955,16 @@ function readRequestText(request) { request.on('error', reject); }); } + +function fakePngBuffer(width, height) { + const buffer = Buffer.alloc(24); + buffer[0] = 0x89; + buffer.write('PNG', 1, 'ascii'); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +function fakePngBase64(width, height) { + return fakePngBuffer(width, height).toString('base64'); +} diff --git a/scripts/command-center.test.mjs b/scripts/command-center.test.mjs index 1e0e8f2dcb1661d93214e8aa37c8f7e27c4c387c..5ef8e1753cc27a047f7d82b63baceab9486762b2 100644 --- a/scripts/command-center.test.mjs +++ b/scripts/command-center.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; @@ -318,6 +319,101 @@ describe('Command center scripts', () => { assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']); }); + it('reports layered agent:doctor diagnostics without billable smoke by default', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + defaults: { state_backend: 'memory' }, + storage: { image_storage_mode: 'indexeddb', postgres_configured: false }, + agent_streaming: { + page_sse: { supported: true } + }, + agent_jobs: { supported: true }, + routing_rules: { + high_resolution_edit: { + conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } } + } + }, + supported: { + image_backend_requirements: { + 'responses-image-generation': { + supported: true, + enabled: true, + missing_env: [] + } + } + } + }) + ); + return; + } + if (request.url === '/api/runtime-capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + streaming: { + defaultMode: 'auto', + unavailableMarkScope: 'channel+backend+strategy+operation' + }, + streamingBatch: { enabled: true }, + responsesImageBackend: { enabled: true, mode: 'experimental' } + }) + ); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + error: { + code: 'idempotency_key_required', + message: 'missing key', + retryable: false + } + }) + ); + return; + } + if (request.url === '/api/agent/jobs/images/generate') { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + error: { + code: 'idempotency_key_required', + message: 'missing key', + retryable: false + } + }) + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runNodeCommandAsync(['scripts/agent-doctor.mjs'], { + env: { ...process.env, GPT_IMAGE_PLAYGROUND_URL: baseUrl }, + timeoutMs: 15_000 + }); + + assert.equal(result.ok, true); + const body = parseJsonPayload(result.stdout, 'agent doctor'); + assert.equal(body.ok, true); + assert.equal(body.billable, false); + assert.equal(body.summary.capabilities, 'ok'); + assert.equal(body.summary.runtime, 'ok'); + assert.equal(body.summary.state_backend, 'memory'); + assert.equal(body.summary.responses_gpt2image_ready, true); + assert.equal(body.summary.billable_smoke, 'skipped'); + assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true); + assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true); + } + ); + }); + it('preserves raw child output for command consumers', () => { const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']); @@ -427,3 +523,53 @@ describe('Command center scripts', () => { }); }); }); + +async function withServer(handler, run) { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + assert.equal(typeof address, 'object'); + assert.ok(address); + await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +} + +function runNodeCommandAsync(args, options = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { + cwd: process.cwd(), + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + const startedAt = Date.now(); + const timeout = options.timeoutMs + ? setTimeout(() => { + child.kill('SIGTERM'); + }, options.timeoutMs) + : undefined; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (status, signal) => { + if (timeout) clearTimeout(timeout); + resolve({ + ok: status === 0, + status, + signal, + stdout, + stderr, + elapsed_ms: Date.now() - startedAt + }); + }); + }); +} diff --git a/scripts/keepalive-hf-space.mjs b/scripts/keepalive-hf-space.mjs index 98ef67c2093a18f6f34598ba3ac2f77bfae12735..5bc26216c0f5bc1e5e0155c5850fcf175c2fa412 100644 --- a/scripts/keepalive-hf-space.mjs +++ b/scripts/keepalive-hf-space.mjs @@ -6,6 +6,9 @@ import { validateSpaceUrl } from './hf-space-doctor-utils.mjs'; const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space'; const DEFAULT_KEEPALIVE_PATH = '/api/auth-status'; const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_ATTEMPTS = 1; +const DEFAULT_RETRY_DELAY_MS = 5_000; + function normalizeUrl(rawUrl, path) { const urlError = validateSpaceUrl(rawUrl); if (urlError) { @@ -34,18 +37,36 @@ async function readJsonResponse(response) { } } -async function pingKeepaliveEndpoint() { +function readKeepaliveConfig() { const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL; const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH; const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000); + const maxAttempts = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_MAX_ATTEMPTS', DEFAULT_MAX_ATTEMPTS); + const retryDelayMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_RETRY_DELAY_MS', DEFAULT_RETRY_DELAY_MS); const expectedPasswordRequired = readExpectedPasswordRequired(); const url = normalizeUrl(spaceUrl, path); + + return { url, timeoutMs, maxAttempts, retryDelayMs, expectedPasswordRequired }; +} + +function formatKeepaliveError(error, timeoutMs) { + if (error?.name === 'AbortError') { + return `Keepalive request timed out after ${timeoutMs}ms`; + } + return error instanceof Error ? error.message : String(error); +} + +async function waitBeforeRetry(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function pingKeepaliveEndpointOnce(config, attemptLabel) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); const startedAt = Date.now(); try { - const response = await fetch(url, { + const response = await fetch(config.url, { headers: { 'User-Agent': 'gpt-image-playground-keepalive/1.0' }, @@ -57,18 +78,22 @@ async function pingKeepaliveEndpoint() { if (!response.ok) { throw new Error(`Keepalive endpoint failed with HTTP ${response.status}`); } - if (expectedPasswordRequired !== undefined && body?.passwordRequired !== expectedPasswordRequired) { - throw new Error(`passwordRequired expected ${expectedPasswordRequired}, got ${body?.passwordRequired}`); + if ( + config.expectedPasswordRequired !== undefined && + body?.passwordRequired !== config.expectedPasswordRequired + ) { + throw new Error(`passwordRequired expected ${config.expectedPasswordRequired}, got ${body?.passwordRequired}`); } console.log( JSON.stringify( { ok: true, - url, + url: config.url, status: response.status, elapsedMs, - passwordRequired: body?.passwordRequired + passwordRequired: body?.passwordRequired, + attempt: attemptLabel }, null, 2 @@ -79,16 +104,41 @@ async function pingKeepaliveEndpoint() { } } +async function pingKeepaliveEndpoint() { + const config = readKeepaliveConfig(); + let lastError; + + for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) { + try { + const attemptLabel = `${attempt}/${config.maxAttempts}`; + await pingKeepaliveEndpointOnce(config, attemptLabel); + return; + } catch (error) { + lastError = error; + console.error( + JSON.stringify( + { + ok: false, + attempt: `${attempt}/${config.maxAttempts}`, + error: formatKeepaliveError(error, config.timeoutMs) + }, + null, + 2 + ) + ); + if (attempt < config.maxAttempts) { + await waitBeforeRetry(config.retryDelayMs); + } + } + } + + throw new Error(`Keepalive attempt already reported: ${formatKeepaliveError(lastError, config.timeoutMs)}`); +} + pingKeepaliveEndpoint().catch((error) => { - console.error( - JSON.stringify( - { - ok: false, - error: error instanceof Error ? error.message : String(error) - }, - null, - 2 - ) - ); + const message = error instanceof Error ? error.message : String(error); + if (!message.startsWith('Keepalive attempt already reported: ')) { + console.error(JSON.stringify({ ok: false, error: message }, null, 2)); + } process.exit(1); }); diff --git a/scripts/keepalive-hf-space.test.mjs b/scripts/keepalive-hf-space.test.mjs index 1b608aac639c720a37991be74081efa23e4f15e7..3992e6cefe9f10ef27badc78031ab56434be52e7 100644 --- a/scripts/keepalive-hf-space.test.mjs +++ b/scripts/keepalive-hf-space.test.mjs @@ -1,11 +1,20 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; -import { describe, it } from 'node:test'; +import { after, describe, it } from 'node:test'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs'); +const tempDirectories = []; + +after(() => { + for (const directory of tempDirectories) { + rmSync(directory, { recursive: true, force: true, maxRetries: 3 }); + } +}); describe('HF Space keepalive script validation', () => { it('rejects non-integer timeout env values before network access', () => { @@ -17,6 +26,15 @@ describe('HF Space keepalive script validation', () => { assert.equal(result.stdout.trim(), ''); }); + it('rejects non-integer retry attempt values before network access', () => { + const result = runKeepalive({ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: 'two' }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /HF_SPACE_KEEPALIVE_MAX_ATTEMPTS/); + assert.match(result.stderr, /integer/); + assert.equal(result.stdout.trim(), ''); + }); + it('rejects keepalive URLs with embedded credentials before network access', () => { const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' }); @@ -26,12 +44,77 @@ describe('HF Space keepalive script validation', () => { assert.doesNotMatch(result.stderr, /secret/); assert.equal(result.stdout.trim(), ''); }); + + it('retries failed keepalive attempts before reporting success', () => { + const preloadPath = writeFetchStub(` + let count = 0; + globalThis.fetch = async () => { + count += 1; + if (count === 1) { + return new Response(JSON.stringify({ error: 'warming' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } + return new Response(JSON.stringify({ passwordRequired: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }; + `); + const result = runKeepalive( + { + HF_SPACE_KEEPALIVE_URL: 'https://misonl-gpt-image-playground-customer.hf.space', + HF_SPACE_KEEPALIVE_PATH: '/api/auth-status', + HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '2', + HF_SPACE_KEEPALIVE_RETRY_DELAY_MS: '1', + HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000' + }, + ['--import', preloadPath] + ); + + assert.equal(result.status, 0); + assert.match(result.stderr, /"attempt": "1\/2"/); + assert.match(result.stdout, /"ok": true/); + assert.match(result.stdout, /"attempt": "2\/2"/); + }); + + it('prints a timeout-specific final error when every attempt aborts', () => { + const preloadPath = writeFetchStub(` + globalThis.fetch = async (url, options) => + new Promise((resolve, reject) => { + options.signal.addEventListener('abort', () => { + reject(new DOMException('This operation was aborted', 'AbortError')); + }); + }); + `); + const result = runKeepalive( + { + HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '1', + HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000' + }, + ['--import', preloadPath] + ); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Keepalive request timed out after 1000ms/); + assert.doesNotMatch(result.stderr, /This operation was aborted/); + assert.equal((result.stderr.match(/"ok": false/g) || []).length, 1); + }); }); -function runKeepalive(env) { - return spawnSync(process.execPath, [scriptPath], { +function runKeepalive(env, nodeArgs = []) { + return spawnSync(process.execPath, [...nodeArgs, scriptPath], { cwd: repoRoot, encoding: 'utf8', env: { ...process.env, ...env } }); } + +function writeFetchStub(source) { + const directory = mkdtempSync(join(tmpdir(), 'hf-keepalive-fetch-')); + tempDirectories.push(directory); + const filePath = join(directory, 'fetch-stub.mjs'); + writeFileSync(filePath, source); + return filePath; +} diff --git a/scripts/local-image-upstream-fixture.mjs b/scripts/local-image-upstream-fixture.mjs index 9e1efcf3015d06148494c66e1f7b9602d5cab94c..29e12c2d8ea9dacdf3b1bb362946167a4eea7d81 100644 --- a/scripts/local-image-upstream-fixture.mjs +++ b/scripts/local-image-upstream-fixture.mjs @@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'; export const FIXTURE_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; export const MAX_JSON_BODY_BYTES = 1024 * 1024; +export const FIXTURE_IMAGE_PATH = '/api/storage/generations/fixture.png'; class PayloadTooLargeError extends Error { constructor() { @@ -75,6 +76,12 @@ function sendJson(response, status, body) { response.end(JSON.stringify(body)); } +function sendFixtureImage(response) { + const buffer = Buffer.from(FIXTURE_IMAGE_BASE64, 'base64'); + response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(buffer.byteLength) }); + response.end(buffer); +} + function writeSse(response, event, payload) { if (event) response.write(`event: ${event}\n`); response.write(`data: ${JSON.stringify(payload)}\n\n`); @@ -119,13 +126,20 @@ function sendResponsesStream(response) { id: 'ig_fixture', type: 'image_generation_call', status: 'completed', - result: FIXTURE_IMAGE_BASE64 + result: FIXTURE_IMAGE_PATH } }); writeSse(response, 'response.completed', { type: 'response.completed', response: { - output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }] + output: [ + { + id: 'ig_fixture', + type: 'image_generation_call', + status: 'completed', + result: FIXTURE_IMAGE_PATH + } + ] } }); response.end('data: [DONE]\n\n'); @@ -153,7 +167,7 @@ function responsesJsonResponse(body) { id: 'resp_fixture', object: 'response', model: typeof body.model === 'string' ? body.model : 'gpt-5.4', - output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }], + output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_PATH }], usage: { input_tokens: 1, output_tokens: 1, @@ -182,6 +196,10 @@ async function handleRequest(request, response) { sendJson(response, 200, modelsResponse()); return; } + if (request.method === 'GET' && url.pathname === FIXTURE_IMAGE_PATH) { + sendFixtureImage(response); + return; + } if (request.method === 'POST' && url.pathname === '/v1/images/generations') { const body = await readJsonBody(request); if (body.stream === true) { diff --git a/scripts/local-image-upstream-fixture.test.mjs b/scripts/local-image-upstream-fixture.test.mjs index 0dc6c201ddb9471ea670f020ed1b3eb853cd1eb9..0e5d79b5bdf36232727c1a521c110906f4471ec5 100644 --- a/scripts/local-image-upstream-fixture.test.mjs +++ b/scripts/local-image-upstream-fixture.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { FIXTURE_IMAGE_BASE64, MAX_JSON_BODY_BYTES, createFixtureServer } from './local-image-upstream-fixture.mjs'; +import { + FIXTURE_IMAGE_BASE64, + FIXTURE_IMAGE_PATH, + MAX_JSON_BODY_BYTES, + createFixtureServer +} from './local-image-upstream-fixture.mjs'; describe('local image upstream fixture', () => { it('serves Images API JSON responses', async () => { @@ -71,7 +76,11 @@ describe('local image upstream fixture', () => { const body = await response.json(); assert.equal(body.id, 'resp_fixture'); assert.equal(body.output[0].type, 'image_generation_call'); - assert.equal(body.output[0].result, FIXTURE_IMAGE_BASE64); + assert.equal(body.output[0].result, FIXTURE_IMAGE_PATH); + const imageResponse = await fetch(`${fixture.baseUrl}${FIXTURE_IMAGE_PATH}`); + assert.equal(imageResponse.status, 200); + assert.match(imageResponse.headers.get('content-type') || '', /^image\/png\b/); + assert.equal(Buffer.from(await imageResponse.arrayBuffer()).toString('base64'), FIXTURE_IMAGE_BASE64); } finally { await fixture.close(); } @@ -105,8 +114,8 @@ describe('local image upstream fixture', () => { ); assert.equal(events[0].data.partial_image_b64, FIXTURE_IMAGE_BASE64); assert.equal(events[1].data.item.type, 'image_generation_call'); - assert.equal(events[1].data.item.result, FIXTURE_IMAGE_BASE64); - assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_BASE64); + assert.equal(events[1].data.item.result, FIXTURE_IMAGE_PATH); + assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_PATH); assert.equal(events[3].done, true); } finally { await fixture.close(); diff --git a/scripts/smoke-image-upstream-local-final-gate.mjs b/scripts/smoke-image-upstream-local-final-gate.mjs index 0999068c718c9e96b04928b31219d02d0e08fd46..16d9e2f6b8a41136cf044e24d6ce082ec085765d 100644 --- a/scripts/smoke-image-upstream-local-final-gate.mjs +++ b/scripts/smoke-image-upstream-local-final-gate.mjs @@ -1,16 +1,17 @@ #!/usr/bin/env node - -import { spawn } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - import { commandFailureMessage, parseJsonPayload, printJson } from './command-center-utils.mjs'; import { createFixtureServer } from './local-image-upstream-fixture.mjs'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); const REAL_SMOKE_SCRIPT = fileURLToPath(new URL('./smoke-image-upstream-real.mjs', import.meta.url)); const DEFAULT_TIMEOUT_MS = 30_000; const LOCAL_FINAL_GATE_CASE_COUNT = 5; const LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS = 15_000; +const MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS = Math.floor( + (Number.MAX_SAFE_INTEGER - LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS) / LOCAL_FINAL_GATE_CASE_COUNT +); function parseArgs(argv) { const parsed = { help: false, timeoutMs: DEFAULT_TIMEOUT_MS }; @@ -66,17 +67,35 @@ async function main() { } function runLocalFinalGate(baseUrl, timeoutMs) { + const parentTimeoutMs = readLocalFinalGateParentTimeoutMs(timeoutMs); return runCommandAsync( process.execPath, - ['--import', 'tsx', REAL_SMOKE_SCRIPT, '--allow-billable', '--require-independent-targets', '--timeout-ms', String(timeoutMs)], + [ + '--import', + 'tsx', + REAL_SMOKE_SCRIPT, + '--allow-billable', + '--require-independent-targets', + '--timeout-ms', + String(timeoutMs) + ], { cwd: REPO_ROOT, env: buildLocalFinalGateEnv(baseUrl, timeoutMs), - timeoutMs: timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS + timeoutMs: parentTimeoutMs } ); } +function readLocalFinalGateParentTimeoutMs(timeoutMs) { + if (timeoutMs > MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS) { + throw new Error( + `--timeout-ms 过大,local final gate 父进程超时会超过安全整数上限;最大允许 ${MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS}。` + ); + } + return timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS; +} + function runCommandAsync(command, args, options = {}) { const startedAt = Date.now(); return new Promise((resolve) => { @@ -197,7 +216,8 @@ function closeServer(server) { } function assertFinalGateReport(report) { - if (report?.final_gate_satisfied !== true) throw new Error('local final gate did not satisfy final_gate_satisfied=true'); + if (report?.final_gate_satisfied !== true) + throw new Error('local final gate did not satisfy final_gate_satisfied=true'); if (!Array.isArray(report.results) || report.results.length !== 5) { throw new Error('local final gate did not run all five independent upstream cases'); } @@ -227,6 +247,11 @@ function readArgValue(argv, index, flag) { function readTimeoutMs(value, source) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1000) throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`); + if (parsed > MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS) { + throw new Error( + `${source} 过大,local final gate 父进程超时会超过安全整数上限;最大允许 ${MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS}。` + ); + } return parsed; } diff --git a/scripts/smoke-image-upstream-local-final-gate.test.mjs b/scripts/smoke-image-upstream-local-final-gate.test.mjs index 4ca41f7457e358500521a159fb31eb690a358a20..0732d3526dcb2cd04513f56839193245d1eafc6f 100644 --- a/scripts/smoke-image-upstream-local-final-gate.test.mjs +++ b/scripts/smoke-image-upstream-local-final-gate.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url)); @@ -32,8 +32,14 @@ describe('local image upstream final gate smoke launcher', () => { 'gpt2image-responses-sse' ] ); - assert.equal(report.results.every((item) => item.status === 200), true); - assert.equal(report.results.every((item) => item.first_b64_length === 92), true); + assert.equal( + report.results.every((item) => item.status === 200), + true + ); + assert.equal( + report.results.every((item) => item.first_b64_length === 92), + true + ); }); it('prints help without starting the fixture', () => { @@ -46,4 +52,17 @@ describe('local image upstream final gate smoke launcher', () => { assert.match(result.stdout, /smoke:image-upstream-local/); assert.match(result.stdout, /local fixture gate/); }); + + it('rejects unsafe parent timeout values before starting the fixture', () => { + const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', String(Number.MAX_SAFE_INTEGER)], { + cwd: repoRoot, + encoding: 'utf8' + }); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.ok, false); + assert.match(body.error, /父进程超时会超过安全整数上限/); + }); }); diff --git a/skills/gpt-image-playground-agent/SKILL.md b/skills/gpt-image-playground-agent/SKILL.md index 178ab714e3e9b4e0d4af2917a7c983439ac27a99..6b40191f11305598e07547af1b54f533cb23c17e 100644 --- a/skills/gpt-image-playground-agent/SKILL.md +++ b/skills/gpt-image-playground-agent/SKILL.md @@ -1,34 +1,45 @@ --- name: gpt-image-playground-agent -description: 当用户需要通过 API 调用已部署的 GPT Image Playground 批量请求图片生成时使用;支持文字生成图片、文字加图片生成图片,并返回可下载图片产物、metadata、base64 或 job 结果。 +description: 当用户需要通过已部署的 GPT Image Playground 生成、编辑、批量生成或诊断图片接口时使用;必须优先运行本 Skill 内置 scripts/generate-image.mjs、edit-image.mjs、batch-images.mjs 或 probe-upstream-image.mjs,而不是临时编写 API 调用脚本。 --- # GPT Image Playground Agent -通过用户已部署的 GPT Image Playground `/api/agent/*` 接口生成或编辑图片。不要假设服务一定在本机;不要模拟网页表单;直接使用 Agent API 契约、幂等键和产物 URL。 +通过用户已部署的 GPT Image Playground 生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本 Skill 内置脚本,让脚本处理 Agent API 契约、capabilities、幂等键、路由选择和产物 URL。 -## 路由硬规则 +## 脚本优先规则 + +- 生成单张或少量图片:优先运行 `scripts/generate-image.mjs`。 +- 编辑图片:优先运行 `scripts/edit-image.mjs`。 +- 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。 +- 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。 +- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。 +- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。 +- 先用 dry-run 或 `--contract-check` 检查请求、路由和鉴权;只有用户明确允许真实计费时才加 `--allow-billable`。 + +## 路由规则 - 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。 -- `edit` 且 `max(width,height)>2048` 时,必须使用页面端 `POST /api/images` form-data SSE 路径,不要走非流式 `/api/agent/images/edit`。 -- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE,并记录切换原因、失败清单和续跑锚点。 +- `edit` 且 `max(width,height)>2048` 时,默认优先使用页面端 `POST /api/images` form-data SSE 路径;如果页面流式不可用或失败,先诊断结构化错误,再显式回退到 Agent edit。 +- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE;需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。 - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。 - 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。 - 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。 ## 执行流程 -1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。 -2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。 -3. 读取 capabilities 中的认证方式、模型、模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。 -4. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。 -5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点对外始终返回最终 `AgentImageResponse` JSON;如 capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用内部上游 SSE 消费。 -6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。 -7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。 -8. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换。 -9. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。 -10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。 -11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。 +1. 先按任务类型选择内置脚本,不要从零写 API 调用代码。 +2. 定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。 +3. 让脚本请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。 +4. 读取 capabilities 中的认证方式、模型、模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。 +5. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。 +6. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点对外始终返回最终 `AgentImageResponse` JSON;如 capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images` 控制内部上游 SSE 消费。 +7. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段必须使用 `image_0..image_9`,类似 `image_10`、`image_01` 或 `image_foo` 的字段会被显式拒绝。该 Agent 端点同样是非流式端点;上游 SSE 字段按 `agent_streaming.upstream_sse.request_fields_by_mode.edit` 发送,不要给 edit 传 `image_backend`。 +8. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。 +9. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换。 +10. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。 +11. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。 +12. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。 ## 鉴权 @@ -45,6 +56,8 @@ Authorization: Bearer ## 调用约束 - 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。 +- Skill 必须保持自包含和可迁移:脚本、示例和说明不得写入本机绝对路径或仓库绝对路径;运行脚本时以当前已安装 Skill 目录为根解析 `scripts/`,不要依赖某台机器上的 checkout 位置。 +- Skill 必须兼容 Windows、Linux 和 macOS:脚本只用 Node.js 20+ 与跨平台 `node:` 标准库;文档示例用 `node "/scripts/..."`,不依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。 - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。 - 不要在模型上下文中展开大体积 base64,除非用户明确要求。 - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。 @@ -69,11 +82,15 @@ Authorization: Bearer ## 可用脚本 -- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实生图。 -- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。 -- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。 +以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。 + +- `scripts/generate-image.mjs`:JSON 文生图调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实生图。 +- `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。 +- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。 +- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。 生成和编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。 +所有脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或 `WIDTHxHEIGHT`,且宽高必须为 `16` 的倍数、单边不超过 `3840`、宽高比不超过 `3:1`。最低分辨率按总像素 `min_pixels=655360` 判断,最高分辨率同时受 `max_pixels=8294400` 和 `max_edge=3840` 约束。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。 如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令: @@ -82,53 +99,62 @@ Authorization: Bearer - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。 - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。 -- `npm run agent:doctor`:执行只读 Agent API 契约检查,不触发真实生图。 +- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。 生成脚本常用参数: -```bash -node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ - --size 2048x2048 \ - --quality high \ - --response-mode path \ - --idempotency-key stable-operation-key \ - "a product photo of a ceramic mug" +```text +node "/scripts/generate-image.mjs" --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "a product photo of a ceramic mug" ``` 启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON: -```bash -node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ - --allow-billable \ - --image-backend images-api \ - --streaming-strategy newapi-keepalive-sse \ - --partial-images 2 \ - --size 4096x4096 \ - --quality high \ - "a product photo of a ceramic mug" +```text +node "/scripts/generate-image.mjs" --allow-billable --image-backend images-api --stream-mode auto --streaming-strategy newapi-keepalive-sse --partial-images 2 --size 3840x2160 --quality high "a product photo of a ceramic mug" ``` 真实生图必须显式开启: -```bash -node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ - --allow-billable \ - --timeout-ms 420000 \ - --size 2048x2048 \ - "a product photo of a ceramic mug" +```text +node "/scripts/generate-image.mjs" --allow-billable --timeout-ms 420000 --size 2048x2048 "a product photo of a ceramic mug" ``` -生成脚本会对 `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE;如果 capabilities 未声明 `agent_streaming.page_sse.supported=true`,脚本会显式失败,不会静默降级到 Agent JSON。如果页面流式失败,脚本会返回结构化失败结果,先诊断再决定是否用 `--agent` 或 `--job` 重新执行,不会自动发起第二次请求。也可以用 `--page-sse` 强制页面流式,或用 `--agent` 强制非流式 Agent generate,`--job` 仍可显式选择 job 路径。上游流式字段支持 `--image-backend`、`--streaming-strategy`、`--partial-images`;默认不发送这些字段,保持服务端默认非流式基线。 +生成脚本会对 `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE;如果 capabilities 未声明 `agent_streaming.page_sse.supported=true`,脚本会显式失败,不会静默降级到 Agent JSON。如果页面流式失败,脚本会返回结构化失败结果,先诊断再决定是否用 `--agent` 或 `--job` 重新执行,不会自动发起第二次请求。编辑脚本对高分辨率 edit 也采用同样口径:默认页面流式,有问题再显式回退到 Agent edit 诊断或执行。也可以用 `--page-sse` 强制页面流式,或用 `--agent` 强制 Agent generate/edit 最终 JSON,`--job` 仍可显式选择 generate job 路径。显式传 `--streaming-strategy off` 或 `--stream-mode non_stream` 时,大图请求保持 Agent JSON 非流式路径,用于和页面 SSE 做诊断对照。上游流式字段优先读取 `agent_streaming.upstream_sse.request_fields_by_mode`:generate 支持 `--image-backend`、`--stream-mode`、`--streaming-strategy`、`--partial-images`;edit 只支持 `--stream-mode`、`--streaming-strategy`、`--partial-images`。不发送时使用服务端 capabilities 声明的默认值。 -编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。 +批量脚本 JSONL 每行是一个 generate 或 edit 任务。示例: + +```jsonl +{"id":"hero-01","mode":"generate","prompt":"a product photo of a ceramic mug","size":"1024x1024","response_mode":"path"} +{"id":"edit-01","mode":"edit","prompt":"replace the background","image_path":"./source.png","size":"1024x1024","response_mode":"path"} +``` + +默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务: + +```text +node "/scripts/batch-images.mjs" --input tasks.jsonl --ordered-prefix product-set +``` + +真实批量执行必须显式允许计费。需要并发时添加 `--concurrency N`;需要严格连续失败熔断时保持 `--concurrency 1`: + +```text +node "/scripts/batch-images.mjs" --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --max-consecutive-failures 3 +node "/scripts/batch-images.mjs" --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3 +``` + +`--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误;`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会并发执行任务并按输入顺序输出结果。`--max-consecutive-failures` 会在连续失败达到阈值后跳过后续任务并输出 `failure_summary` 与 `resume_fix_list`,且只能与顺序执行的 `--concurrency 1` 同用。任务级 `sse_log_path` 会把页面 SSE 原始事件按 JSONL 追加保存,便于区分上游未给终图和解析/断流问题。 + +批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径;edit 任务使用这些高级字段会显式走 `/api/images`,因为 Agent JSON edit 不接收它们。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。 + +编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--stream-mode`、`--streaming-strategy`、`--partial-images`、`--timeout-ms`、`--idempotency-key`、`--page-sse`、`--agent`、`--dry-run` 和 `--allow-billable`。 直连上游诊断: -```bash -OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs \ - --base-url https://api.openai.com/v1 +```text +node "/scripts/probe-upstream-image.mjs" --base-url https://api.openai.com/v1 ``` +调用前按当前系统和 shell 设置 `OPENAI_API_KEY` 或 `GPT_IMAGE_UPSTREAM_API_KEY`,不要把 key 写进命令历史或文档。 + 诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。 上游探针脚本支持 `--base-url`、`--model`、`--prompt`、`--size`、`--quality`、`--format`、`--timeout-ms` 和 `--allow-billable`。默认读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL`,API Key 读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY`。上游 base URL 同样必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 URL。 diff --git a/skills/gpt-image-playground-agent/agents/openai.yaml b/skills/gpt-image-playground-agent/agents/openai.yaml index 1466b8b99e0fcedaa6b1c8d2b12da9c89a22c94a..da6eef590ab77b0caabcee1f807354912af1bfd3 100644 --- a/skills/gpt-image-playground-agent/agents/openai.yaml +++ b/skills/gpt-image-playground-agent/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "GPT Image Agent API" - short_description: "定位部署地址后调用 Agent API 生成和编辑图片" - default_prompt: "使用 $gpt-image-playground-agent 先确认 GPT Image Playground 服务地址,再通过 Agent API 生成或编辑图片并返回产物 URL 和关键元数据。" + short_description: "使用内置脚本调用图片 Agent API" + default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本,不要临时编写 API 调用脚本。" diff --git a/skills/gpt-image-playground-agent/references/api.md b/skills/gpt-image-playground-agent/references/api.md index 4fb4b20a58b5cf06fd76ca211b7a20f8f6b802b2..eb8e624eb4505ddd1c4524a8dbdc84dab7690d21 100644 --- a/skills/gpt-image-playground-agent/references/api.md +++ b/skills/gpt-image-playground-agent/references/api.md @@ -12,17 +12,23 @@ ## 辅助脚本 -- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。 -- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。 -- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。 +脚本位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径或仓库 checkout 路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。 +脚本必须通过 `node "/scripts/..."` 调用,以兼容 Windows、Linux 和 macOS;示例不要依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。 +生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。 -生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。 +- `scripts/generate-image.mjs`:JSON 文生图调用。 +- `scripts/edit-image.mjs`:multipart 编辑调用。 +- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。 +- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。 + +生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会按 capabilities 路由规则调用 `/api/agent/images/generate`、`/api/agent/images/edit`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。 上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。 脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。 Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,Agent 端点才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。页面端 `/api/images` SSE 另看 `agent_streaming.page_sse.auth`;当其声明 `required=true` 时,form-data 必须包含 `passwordHash`。 当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。 同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。 页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。 +脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或 `WIDTHxHEIGHT`,且宽高必须为 `16` 的倍数、单边不超过 `3840`、宽高比不超过 `3:1`。最低分辨率按总像素 `min_pixels=655360` 判断,最高分辨率同时受 `max_pixels=8294400` 和 `max_edge=3840` 约束。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。 生成脚本参数: @@ -32,23 +38,65 @@ Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API - `--n`:默认 `1`。 - `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。 - `--response-mode`:默认 `path`。 +- `--image-backend`:可选,显式选择 `images-api`、`images`、`responses` 或 `responses-image-generation`。 +- `--stream-mode`:可选,显式选择 `auto`、`stream` 或 `non_stream`。 +- `--streaming-strategy`:可选,显式选择 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。 +- `--partial-images`:可选,显式设置上游 SSE partial image 数量,范围 `1` 到 `3`。 - `--timeout-ms`:默认 `420000`。 - `--prompt-file`:从文本文件读取 prompt。 - `--idempotency-key`:指定稳定幂等键。 +- `--page-sse`:强制使用页面端 `/api/images` form-data SSE。 +- `--agent`:强制使用 `/api/agent/images/generate` 非流式 JSON。 +- `--job`:强制使用 Agent job polling。 - `--dry-run`:只输出将要发送的 JSON。 - `--allow-billable`:允许真实调用生图端点。 +`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE;如果显式传 `--streaming-strategy off`,即使是大图也保持 `/api/agent/images/generate` 非流式 JSON 路径,用于诊断对照。 +当服务端默认 `IMAGE_STREAMING_STRATEGY=off` 且请求未覆盖 `streaming_strategy` 时,运行时默认策略为 `off`;WebUI 会把 server-default 流式请求切到 `non_stream`,并发批量开关不可用。脚本显式传 `--streaming-strategy off` 时同样保持非流式诊断路径。 + 编辑脚本参数: - `--model` - `--size` - `--quality` - `--response-mode` +- `--stream-mode` +- `--streaming-strategy` +- `--partial-images` - `--timeout-ms` - `--idempotency-key` +- `--page-sse` +- `--agent` +- `--dry-run` +- `--allow-billable` + +普通 edit 默认调用 `/api/agent/images/edit`,客户端拿到最终 JSON;`--stream-mode`、`--streaming-strategy` 和 `--partial-images` 只控制 Agent 内部上游流式兼容字段,不代表客户端响应会变成页面 SSE。 +`max_edge>2048` 的高分辨率 edit 默认优先走页面端 `/api/images` form-data SSE;失败后脚本输出结构化失败和备用端点建议,由下一步用 `--agent` 显式回退到 `/api/agent/images/edit` 诊断或执行,不会在同一次请求里静默二次调用。显式 `--page-sse` 会强制页面流式;显式 `--agent` 或 `stream_mode=non_stream` / `streaming_strategy=off` 会走 Agent edit 最终 JSON。 + +批量脚本参数: + +- `--input`:JSONL 任务文件路径,也可作为唯一位置参数。 +- `--manifest`:append-only JSONL manifest 路径,默认 `.manifest.jsonl`。 +- `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。 +- `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。 +- `--dimension-check`:读取响应 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`。 +- `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。 +- `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会并发执行任务并按输入顺序输出结果;适合已确认渠道容量的批量生产。 +- `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。 +- `--timeout-ms` - `--dry-run` - `--allow-billable` +批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径;edit 任务使用这些高级字段会显式走 `/api/images`,因为 Agent JSON edit 不接收这些字段。`responsesModel` 会选择页面 SSE 路径,且必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`,因为 Agent JSON 不接收请求级 Responses 顶层模型。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。 + +并发批量示例: + +```text +node "/scripts/batch-images.mjs" --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3 +``` + +连续失败熔断需要严格顺序语义,不能与并发窗口大于 `1` 的批量执行同时使用。 + 上游探针脚本参数: - `--base-url` @@ -60,7 +108,7 @@ Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API - `--timeout-ms` - `--allow-billable` -上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。输出不会包含 key,也不会输出完整 base64。 +上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。base URL 必须是无凭据、无查询参数和无片段的 `http`/`https` 绝对 URL。输出不会包含 key,也不会输出完整 base64。 ## 能力查询 @@ -83,33 +131,41 @@ GET /api/agent/capabilities - `model_limits.gpt-image-2.large_image_risk`:大尺寸请求的长耗时风险说明,当前适用于 `max_edge>2048`。 - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。 - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。 -- `agent_streaming.upstream_sse`:Agent generate 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。 +- `agent_streaming.upstream_sse`:Agent generate/edit 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。 +- `agent_streaming.upstream_sse.supported`:布尔值;当服务端支持 Agent 内部上游 SSE 消费时为 `true`,否则为 `false`。客户端只在为 `true` 时发送上游流式控制字段。 +- `agent_streaming.upstream_sse.request_fields`:兼容旧客户端的字段合集,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。 +- `agent_streaming.upstream_sse.request_fields_by_mode.generate`:generate 可发送的上游 SSE 控制字段,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。 +- `agent_streaming.upstream_sse.request_fields_by_mode.edit`:edit 可发送的上游 SSE 控制字段,当前为 `stream_mode`、`streaming_strategy`、`partial_images`。 - `agent_streaming.upstream_sse.image_backends`:支持 `images-api`、`responses-image-generation`。 - `agent_streaming.upstream_sse.enabled_image_backends`:当前运行时可直接使用的 Agent 上游 SSE 后端;`responses-image-generation` 只有在所需环境变量齐备时才出现。 - `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。 -- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前为 `openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。 +- `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。 +- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。 - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。 - `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。 - `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。 -- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时必须使用页面端 `/api/images` SSE。 +- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。 - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。 - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。 - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。 - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。 - `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。 +- 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。 - `defaults.image_backend`:Agent generate 默认 `images-api`。 -- `defaults.streaming_strategy`:Agent generate 默认 `off`,不会默认向上游发送 `stream=true`。 -- `defaults.partial_images`:Agent generate 默认 `2`,仅在显式启用上游 SSE 时使用。 +- `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。 +- `defaults.streaming_strategy`:Agent generate 默认 `auto`。 +- `defaults.partial_images`:Agent generate 默认 `2`,在 `stream_mode` 不为 `non_stream` 时使用。 - `supported.image_backends`:机器可读的图片后端枚举。 - `supported.enabled_image_backends`:当前运行时可直接使用的图片后端。 - `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。 - `supported.streaming_strategies`:机器可读的流式兼容策略枚举。 +- `supported.stream_modes`:机器可读的 `auto`、`stream`、`non_stream` 枚举。 - `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。 - `agent_jobs.mode`:当前为 `job_polling`。 - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。 - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。 -当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产不应走 Agent 非流式 edit,优先按 `routing_rules` 使用页面端 `/api/images` SSE。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。 +当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产默认优先按 `routing_rules` 使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent edit 或 job 路径。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。 ## Job Polling @@ -180,12 +236,20 @@ Content-Type: application/json "moderation": "auto", "response_mode": "path", "image_backend": "images-api", - "streaming_strategy": "off", + "stream_mode": "auto", + "streaming_strategy": "auto", "partial_images": 2 } ``` -Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费,最终响应仍是 `AgentImageResponse`。 +Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`。 + +- 页面 SSE 使用独立的 `POST /api/images` form-data 路径。 +- 若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,generate 可通过 `request_fields_by_mode.generate` 声明的字段控制服务端内部上游 SSE 消费。`image_backend=responses-image-generation` 当前只支持 generate。 +- Agent 生成端点最终响应仍是 `AgentImageResponse`。 +- `stream_mode=stream` 强制流式并直接暴露失败。 +- `stream_mode=non_stream` 直接非流式。 +- `stream_mode=auto` 允许显式可观测回退。 响应: @@ -229,10 +293,15 @@ Content-Type: multipart/form-data - `size`:`auto` 或支持的尺寸。 - `quality`:`low`、`medium`、`high` 或 `auto`。 - `response_mode`:`path`、`base64` 或 `both`。 -- `image_0..image_9`:源图片。 +- `stream_mode`:可选,`auto`、`stream` 或 `non_stream`。 +- `streaming_strategy`:可选,`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。 +- `partial_images`:可选,`1..3`。 +- `image_0..image_9`:源图片。类似 `image_10`、`image_01` 或 `image_foo` 的图片字段会被显式拒绝。 - `mask`:可选 PNG 遮罩。 -当 `size` 的最大边大于 `2048` 时,Agent edit 端点会返回 `validation_error`,不会联系上游;该场景必须按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径。 +Agent edit 不接受 `image_backend`/`imageBackend`、`output_format`/`outputFormat`/`format`、`output_compression`/`outputCompression`、`responses_model`/`responsesModel`、`background` 或 `moderation`。编辑输出格式固定为 PNG;Responses image_generation 后端当前只支持 generate。 + +当 `size` 的最大边大于 `2048` 时,默认按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径;如果页面流式不可用或失败,可显式回退到 Agent edit 最终 JSON 路径进行诊断或执行。 ## 产物元数据 diff --git a/skills/gpt-image-playground-agent/scripts/batch-images.mjs b/skills/gpt-image-playground-agent/scripts/batch-images.mjs new file mode 100644 index 0000000000000000000000000000000000000000..289f0d8c966afc1c7cb0d62a77dedd0a63500c19 --- /dev/null +++ b/skills/gpt-image-playground-agent/scripts/batch-images.mjs @@ -0,0 +1,1226 @@ +#!/usr/bin/env node +import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs'; +import { + errorMessage, + assertValidImageSizeForModel, + normalizeBaseUrl, + normalizeOutputFormat, + parseImageSizeValue, + readConfiguredPositiveInteger, + readMaxImageEdge, + readOptionValue, + resolveSameOriginUrl +} from './lib/script-utils.mjs'; +import { + PAGE_SSE_ENDPOINT, + assertPageSseReady, + buildPageSseFailureOutput, + formatPageSseOutput, + normalizeImageBackendForPage, + postPageSse +} from './lib/page-sse-client.mjs'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']); +const MODELS = new Set(['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2']); +const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']); +const QUALITIES = new Set(['low', 'medium', 'high', 'auto']); +const BACKGROUNDS = new Set(['transparent', 'opaque', 'auto']); +const MODERATIONS = new Set(['low', 'auto']); +const RESPONSE_MODES = new Set(['path', 'base64', 'both']); +const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']); +const STREAMING_STRATEGIES = new Set([ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +]); +const MAX_EDIT_IMAGES = 10; +const MIN_PARTIAL_IMAGES = 1; +const MAX_PARTIAL_IMAGES = 3; +const MAX_IDEMPOTENCY_KEY_LENGTH = 200; +const DEFAULT_BATCH_MAX_ATTEMPTS = 1; +const DEFAULT_MAX_CONSECUTIVE_FAILURES = 0; +const DEFAULT_BATCH_CONCURRENCY = 1; +const GENERATE_ONLY_FIELDS = [ + 'background' +]; +const PAGE_ADVANCED_FIELDS = [ + 'output_format', + 'format', + 'output_compression', + 'moderation', + 'image_backend', + 'responsesModel', + 'gptModel', + 'gpt_model', + 'thinking', + 'promptOptimization', + 'prompt_optimization', + 'force_web', + 'forceWeb', + 'sse_log_path' +]; +const EDIT_ONLY_FIELDS = ['image_path', 'image_paths', 'mask_path']; +const BOOLEAN_ROUTING_FIELDS = ['page_sse', 'complex_ui', 'long_image', 'resume_or_recover']; +const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']); +const TASK_FIELDS = new Set([ + 'id', + 'mode', + 'prompt', + 'idempotency_key', + 'model', + 'n', + 'size', + 'quality', + 'response_mode', + 'stream_mode', + 'streaming_strategy', + 'partial_images', + 'page_sse', + 'transport', + 'complex_ui', + 'long_image', + 'resume_or_recover', + ...GENERATE_ONLY_FIELDS, + ...PAGE_ADVANCED_FIELDS, + ...EDIT_ONLY_FIELDS +]); + +const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; +const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; + +let options; +try { + options = parseArgs(process.argv.slice(2)); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} +if (options.help) { + printUsage(); + process.exit(0); +} + +let baseUrl; +let tasks; +let timeoutMs; +let capabilities; +let capabilitiesPromise; +try { + if (!options.input) throw new Error('--input 需要 JSONL 文件路径。'); + baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); + timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000); + options.maxAttempts = readConfiguredPositiveInteger( + options.maxAttempts ?? DEFAULT_BATCH_MAX_ATTEMPTS, + '--max-attempts', + DEFAULT_BATCH_MAX_ATTEMPTS + ); + options.maxConsecutiveFailures = readNonNegativeInteger( + options.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES, + '--max-consecutive-failures' + ); + options.concurrency = readConfiguredPositiveInteger( + options.concurrency ?? DEFAULT_BATCH_CONCURRENCY, + '--concurrency', + DEFAULT_BATCH_CONCURRENCY + ); + if (options.concurrency > 1 && options.maxConsecutiveFailures > 0) { + throw new Error('--concurrency 大于 1 时不能同时使用 --max-consecutive-failures;请使用 --concurrency 1 保持严格顺序熔断。'); + } + tasks = readJsonlTasks(options.input); +} catch (error) { + console.error(errorMessage(error)); + process.exit(2); +} + +const manifestPath = options.manifest || `${options.input}.manifest.jsonl`; +let planned; +try { + planned = tasks.map((task, index) => normalizeTask(task, index, options)); +} catch (error) { + console.error(errorMessage(error)); + process.exit(2); +} + +if (!options.allowBillable || options.dryRun) { + console.log( + JSON.stringify( + { + ok: true, + billable: false, + dry_run: true, + input: options.input, + manifest: manifestPath, + total: planned.length, + max_attempts: options.maxAttempts, + max_consecutive_failures: options.maxConsecutiveFailures, + concurrency: options.concurrency, + tasks: planned.map((task) => { + const routing = buildTaskRouting(task); + return { + index: task.index, + id: task.id, + mode: task.mode, + idempotency_key: task.idempotencyKey, + endpoint: routing.endpoint, + routing, + request: buildDryRunRequestPreview(task, routing) + }; + }), + next_step: '重新执行并添加 --allow-billable 才会发起真实批量请求。' + }, + null, + 2 + ) + ); + process.exit(0); +} + +try { + const completed = options.resume ? readCompletedManifestKeys(manifestPath) : new Set(); + const { results, failedTasks } = await runPlannedTasks(planned, completed); + const failed = results.filter((result) => !result.ok).length; + console.log( + JSON.stringify( + { + ok: failed === 0, + total: results.length, + failed, + manifest: manifestPath, + max_attempts: options.maxAttempts, + max_consecutive_failures: options.maxConsecutiveFailures, + concurrency: options.concurrency, + failure_summary: buildFailureSummary(failedTasks), + resume_fix_list: buildResumeFixList(failedTasks), + results + }, + null, + 2 + ) + ); + process.exit(failed === 0 ? 0 : 1); +} catch (error) { + console.error(errorMessage(error)); + process.exit(1); +} + +function parseArgs(argv) { + const parsed = { + input: undefined, + manifest: undefined, + orderedPrefix: 'batch', + timeoutMs: undefined, + maxAttempts: undefined, + maxConsecutiveFailures: undefined, + concurrency: undefined, + allowBillable: false, + dryRun: false, + resume: false, + dimensionCheck: false, + help: false + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--dry-run') parsed.dryRun = true; + else if (arg === '--resume') parsed.resume = true; + else if (arg === '--dimension-check') parsed.dimensionCheck = true; + else if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--input') parsed.input = readOptionValue(argv, (index += 1), arg); + else if (arg === '--manifest') parsed.manifest = readOptionValue(argv, (index += 1), arg); + else if (arg === '--ordered-prefix') parsed.orderedPrefix = readOptionValue(argv, (index += 1), arg); + else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); + else if (arg === '--max-attempts') parsed.maxAttempts = readOptionValue(argv, (index += 1), arg); + else if (arg === '--max-consecutive-failures') { + parsed.maxConsecutiveFailures = readOptionValue(argv, (index += 1), arg); + } + else if (arg === '--concurrency') parsed.concurrency = readOptionValue(argv, (index += 1), arg); + else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); + else if (!parsed.input) parsed.input = arg; + else throw new Error(`未知位置参数:${arg}`); + } + return parsed; +} + +function readJsonlTasks(filePath) { + return fs + .readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line, index) => ({ line: line.trim(), index })) + .filter((item) => item.line && !item.line.startsWith('#')) + .map((item) => { + try { + return JSON.parse(item.line); + } catch (error) { + throw new Error(`${filePath}:${item.index + 1} 不是有效 JSON:${errorMessage(error)}`); + } + }); +} + +function normalizeTask(raw, index, parsedOptions) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`第 ${index + 1} 行必须是 JSON 对象。`); + } + const mode = normalizeMode(raw.mode, index); + const id = normalizeTaskId(raw.id, mode, index); + if (typeof raw.prompt !== 'string' || !raw.prompt.trim()) { + throw new Error(`${id} 缺少 prompt。`); + } + validateTaskFields(raw, id, mode); + validateTaskSize(raw, id, mode, parsedOptions.dimensionCheck); + validateTaskRoutingFields(raw, id); + if (mode === 'edit') validateEditImages(raw, id); + return { + id, + index, + mode, + raw, + idempotencyKey: normalizeIdempotencyKey(raw.idempotency_key, parsedOptions.orderedPrefix, index, id) + }; +} + +function normalizeTaskId(value, mode, index) { + if (value === undefined || value === null || value === '') return `${mode}-${index + 1}`; + return String(value); +} + +function normalizeMode(value, index) { + if (value === undefined || value === null || value === '' || value === 'generate') return 'generate'; + if (value === 'edit') return 'edit'; + throw new Error(`第 ${index + 1} 行 mode 必须是 generate 或 edit。`); +} + +function normalizeIdempotencyKey(value, orderedPrefix, index, id) { + if (value === undefined || value === null || value === '') return buildOrderedKey(orderedPrefix, index, id); + if (typeof value !== 'string') throw new Error(`${id} idempotency_key 必须是字符串。`); + return value; +} + +function validateTaskSize(raw, id, mode, dimensionCheck) { + if (raw.size !== undefined) { + assertValidImageSizeForModel(raw.size, raw.model || 'gpt-image-2', `${id}.size`); + } + if (!dimensionCheck) return; + const size = raw.size || (mode === 'generate' ? '1024x1024' : undefined); + if (!parseExpectedSize(size)) { + throw new Error(`${id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`); + } +} + +function validateEditImages(raw, id) { + const imagePaths = readEditImagePaths(raw, id); + if (imagePaths.length === 0) throw new Error(`${id} edit 任务必须提供 image_path 或 image_paths。`); + if (imagePaths.length > MAX_EDIT_IMAGES) throw new Error(`${id} edit 任务最多支持 ${MAX_EDIT_IMAGES} 张源图。`); + if (hasOwn(raw, 'mask_path')) readNonEmptyString(raw.mask_path, `${id}.mask_path`); +} + +function validateTaskFields(raw, id, mode) { + validateKnownTaskFields(raw, id); + validateModeSpecificFields(raw, id, mode); + validateRoutingControlFields(raw, id); + validateAmbiguousAliasFields(raw, id); + if (hasOwn(raw, 'model')) normalizeEnumValue(raw.model, MODELS, `${id}.model`); + if (raw.n !== undefined) readConfiguredPositiveInteger(raw.n, `${id}.n`, 1); + if (hasOwn(raw, 'quality')) normalizeEnumValue(raw.quality, QUALITIES, `${id}.quality`); + if (hasOwn(raw, 'response_mode')) normalizeEnumValue(raw.response_mode, RESPONSE_MODES, `${id}.response_mode`); + if (hasOwn(raw, 'image_backend')) normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, `${id}.image_backend`); + if (hasOwn(raw, 'background')) normalizeEnumValue(raw.background, BACKGROUNDS, `${id}.background`); + if (hasOwn(raw, 'moderation')) normalizeEnumValue(raw.moderation, MODERATIONS, `${id}.moderation`); + if (hasOwn(raw, 'thinking')) normalizeEnumValue(raw.thinking, THINKING_VALUES, `${id}.thinking`); + readPromptOptimization(raw, id); + if (hasOwn(raw, 'force_web')) readBooleanAlias(raw.force_web, `${id}.force_web`); + if (hasOwn(raw, 'forceWeb')) readBooleanAlias(raw.forceWeb, `${id}.forceWeb`); + if (hasOwn(raw, 'sse_log_path')) readNonEmptyString(raw.sse_log_path, `${id}.sse_log_path`); + validateResponsesModelField(raw, id); + if (hasOwn(raw, 'stream_mode')) normalizeEnumValue(raw.stream_mode, STREAM_MODES, `${id}.stream_mode`); + if (hasOwn(raw, 'streaming_strategy')) { + normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, `${id}.streaming_strategy`); + } + if (hasOwn(raw, 'partial_images')) readPartialImages(raw.partial_images, `${id}.partial_images`); + if (hasOwn(raw, 'output_format') || hasOwn(raw, 'format')) { + normalizeEnumValue(readOutputFormatField(raw, id), OUTPUT_FORMATS, `${id}.output_format`); + } + validateBackgroundForModel(raw, id); + validateOutputCompression(raw, id); +} + +function validateAmbiguousAliasFields(raw, id) { + if (hasOwn(raw, 'output_format') && hasOwn(raw, 'format')) { + throw new Error(`${id}.output_format 与 format 不能同时设置。`); + } + if (hasOwn(raw, 'image_path') && hasOwn(raw, 'image_paths')) { + throw new Error(`${id}.image_path 与 image_paths 不能同时设置。`); + } +} + +function validateKnownTaskFields(raw, id) { + for (const field of Object.keys(raw)) { + if (!TASK_FIELDS.has(field)) { + throw new Error(`${id}.${field} 不是支持的 batch JSONL 字段。`); + } + } +} + +function validateModeSpecificFields(raw, id, mode) { + const fields = mode === 'edit' ? GENERATE_ONLY_FIELDS : EDIT_ONLY_FIELDS; + const expectedMode = mode === 'edit' ? 'generate' : 'edit'; + for (const field of fields) { + if (hasOwn(raw, field)) { + throw new Error(`${id}.${modeSpecificFieldLabel(field)} 仅适用于 ${expectedMode} 任务。`); + } + } +} + +function modeSpecificFieldLabel(field) { + return field === 'format' ? 'output_format' : field; +} + +function validateRoutingControlFields(raw, id) { + for (const field of BOOLEAN_ROUTING_FIELDS) { + if (hasOwn(raw, field) && typeof raw[field] !== 'boolean') { + throw new Error(`${id}.${field} 必须是布尔值。`); + } + } + if (hasOwn(raw, 'transport') && raw.transport !== 'page_sse') { + throw new Error(`${id}.transport 必须是 page_sse。`); + } +} + +function validateBackgroundForModel(raw, id) { + if (!hasOwn(raw, 'background')) return; + const model = hasOwn(raw, 'model') ? String(raw.model) : 'gpt-image-2'; + if (model === 'gpt-image-2' && String(raw.background) === 'transparent') { + throw new Error(`${id}.background 对 gpt-image-2 无效:gpt-image-2 不支持 transparent 背景。`); + } +} + +function validateOutputCompression(raw, id) { + readOutputCompression(raw, id); +} + +function validateResponsesModelField(raw, id) { + const responsesModel = readResponsesModel(raw, id); + if (!responsesModel) return; + if (!hasOwn(raw, 'image_backend')) { + throw new Error(`${id}.responsesModel 必须同时设置 image_backend=responses-image-generation。`); + } + const imageBackend = normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, `${id}.image_backend`); + if (imageBackend !== 'responses-image-generation' && imageBackend !== 'responses') { + throw new Error(`${id}.responsesModel 仅适用于 image_backend=responses-image-generation。`); + } +} + +function readResponsesModel(raw, id) { + const fields = ['responsesModel', 'gptModel', 'gpt_model']; + const present = fields.filter((field) => hasOwn(raw, field)); + if (present.length === 0) return undefined; + if (present.length > 1) throw new Error(`${id}.responsesModel、gptModel 与 gpt_model 不能同时设置。`); + return readNonEmptyString(raw[present[0]], `${id}.${present[0]}`); +} + +function readPromptOptimization(raw, id) { + const fields = ['promptOptimization', 'prompt_optimization']; + const present = fields.filter((field) => hasOwn(raw, field)); + if (present.length === 0) return undefined; + if (present.length > 1) throw new Error(`${id}.promptOptimization 与 prompt_optimization 不能同时设置。`); + return readBooleanAlias(raw[present[0]], `${id}.${present[0]}`); +} + +function readForceWeb(raw, id) { + const fields = ['force_web', 'forceWeb']; + const present = fields.filter((field) => hasOwn(raw, field)); + if (present.length === 0) return undefined; + if (present.length > 1) throw new Error(`${id}.force_web 与 forceWeb 不能同时设置。`); + return readBooleanAlias(raw[present[0]], `${id}.${present[0]}`); +} + +function readBooleanAlias(value, name) { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + throw new Error(`${name} 必须是布尔值。`); +} + +function readOutputCompression(raw, id) { + if (!hasOwn(raw, 'output_compression')) return undefined; + const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format') + ? readOutputFormatField(raw, id) + : 'png'; + if (outputFormat === 'png') { + return undefined; + } + const value = raw.output_compression; + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { + throw new Error(`${id}.output_compression 必须是 0 到 100 之间的整数。`); + } + return parsed; +} + +function readTaskNormalizations(raw, id) { + if (!hasOwn(raw, 'output_compression')) return undefined; + const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format') + ? readOutputFormatField(raw, id) + : 'png'; + if (outputFormat !== 'png') return undefined; + return { output_compression_ignored_for_png: true }; +} + +function readOutputFormatField(raw, id) { + const value = raw.output_format ?? raw.format; + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${id}.output_format 必须是字符串。`); + } + return normalizeOutputFormat(value); +} + +function validateTaskRoutingFields(raw, id) { + if ((raw.page_sse === true || raw.transport === 'page_sse') && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) { + throw new Error(`${id} stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。`); + } + if (readResponsesModel(raw, id) && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) { + throw new Error(`${id}.responsesModel 需要页面 SSE 路径,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`); + } + if (hasOwn(raw, 'sse_log_path') && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) { + throw new Error(`${id}.sse_log_path 需要页面 SSE 路径,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`); + } + if (hasPageAdvancedFields(raw) && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off') && raw.mode === 'edit') { + throw new Error(`${id} 图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`); + } +} + +function hasPageAdvancedFields(raw) { + return PAGE_ADVANCED_FIELDS.some((field) => hasOwn(raw, field)); +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function readEditImagePaths(raw, id = 'edit') { + if (Array.isArray(raw.image_paths)) { + if (raw.image_paths.length === 0) throw new Error(`${id}.image_paths 必须是非空字符串数组。`); + return raw.image_paths.map((value, index) => readNonEmptyString(value, `${id}.image_paths[${index}]`)); + } + if (hasOwn(raw, 'image_paths')) throw new Error(`${id}.image_paths 必须是非空字符串数组。`); + if (hasOwn(raw, 'image_path')) return [readNonEmptyString(raw.image_path, `${id}.image_path`)]; + return []; +} + +function readNonEmptyString(value, name) { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`${name} 必须是非空字符串。`); + } + return value; +} + +function buildOrderedKey(prefix, index, id) { + const safePrefix = sanitizeKeyPart(prefix || 'batch'); + const safeId = sanitizeKeyPart(id || `item-${index + 1}`); + return `${safePrefix}-${String(index + 1).padStart(4, '0')}-${safeId}`.slice(0, 200); +} + +function sanitizeKeyPart(value) { + return String(value) + .trim() + .replace(/[^A-Za-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') || 'item'; +} + +function authHeaders() { + if (token) return { Authorization: `Bearer ${token}` }; + if (passwordHash) return { 'X-App-Password-Hash': passwordHash }; + return {}; +} + +async function readCapabilities() { + const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, { headers: authHeaders() }); + if (!response.ok) throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`); + return result; +} + +async function ensureCapabilities() { + if (capabilities) return capabilities; + capabilitiesPromise ??= readCapabilities(); + try { + capabilities = await capabilitiesPromise; + } catch (error) { + capabilitiesPromise = undefined; + throw error; + } + return capabilities; +} + +async function runTask(task) { + const routing = buildTaskRouting(task); + try { + const response = + routing.transport === 'page_sse' + ? await postPageSseTask(task, routing) + : task.mode === 'edit' + ? await postEditTask(task) + : await postGenerateTask(task); + if (options.dimensionCheck) await assertDimensions(task, response); + const output = { ok: true, status: 'succeeded', id: task.id, idempotency_key: task.idempotencyKey, routing, response }; + appendManifest(manifestPath, { ...baseManifestEntry(task), status: 'succeeded', routing, response: sanitizeResponse(response) }); + return output; + } catch (error) { + const failure = buildTaskFailureOutput(error, routing); + const output = { ok: false, status: 'failed', id: task.id, idempotency_key: task.idempotencyKey, ...failure }; + appendManifest(manifestPath, { ...baseManifestEntry(task), status: 'failed', ...failure }); + return output; + } +} + +async function runTaskWithAttempts(task) { + let lastResult; + for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) { + const attemptTask = buildAttemptTask(task, attempt); + const result = await runTask(attemptTask); + lastResult = addAttemptMetadata(result, task, attempt); + if (result.ok) return lastResult; + } + return lastResult; +} + +async function runPlannedTasks(plannedTasks, completed) { + const results = new Array(plannedTasks.length); + const failedTasks = []; + let consecutiveFailures = 0; + let nextIndex = 0; + + async function worker() { + while (nextIndex < plannedTasks.length) { + const index = nextIndex; + nextIndex += 1; + const task = plannedTasks[index]; + + if (completed.has(task.idempotencyKey) || completed.has(task.id)) { + results[index] = handleResumeSkippedTask(task); + continue; + } + + if (options.maxConsecutiveFailures > 0 && consecutiveFailures >= options.maxConsecutiveFailures) { + results[index] = handleCircuitBreakerSkippedTask(task, consecutiveFailures); + continue; + } + + const result = await runTaskWithAttempts(task); + results[index] = result; + if (result.ok) { + consecutiveFailures = 0; + } else { + consecutiveFailures += 1; + failedTasks.push(buildFailedTaskSummary(result, task)); + } + } + } + + const workerCount = Math.min(options.concurrency, plannedTasks.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + failedTasks.sort((left, right) => left.index - right.index); + return { results, failedTasks }; +} + +function handleResumeSkippedTask(task) { + const skipped = { + ok: true, + status: 'skipped', + id: task.id, + idempotency_key: task.idempotencyKey, + billable: false, + skipped_reason: 'resume' + }; + appendManifest(manifestPath, { + ...baseManifestEntry(task), + status: 'skipped', + billable: false, + skipped_reason: 'resume' + }); + return skipped; +} + +function handleCircuitBreakerSkippedTask(task, consecutiveFailures) { + const skipped = buildCircuitBreakerSkippedTask(task, consecutiveFailures); + appendManifest(manifestPath, { ...baseManifestEntry(task), ...skipped }); + return { ok: true, id: task.id, idempotency_key: task.idempotencyKey, ...skipped }; +} + +function buildAttemptTask(task, attempt) { + if (attempt === 1) return { ...task, attempt, rootIdempotencyKey: task.idempotencyKey }; + return { + ...task, + attempt, + rootIdempotencyKey: task.idempotencyKey, + idempotencyKey: buildAttemptIdempotencyKey(task.idempotencyKey, attempt) + }; +} + +function buildAttemptIdempotencyKey(idempotencyKey, attempt) { + const suffix = `-attempt-${attempt}`; + if (idempotencyKey.length + suffix.length <= MAX_IDEMPOTENCY_KEY_LENGTH) { + return `${idempotencyKey}${suffix}`; + } + const digest = crypto.createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 12); + const hashedSuffix = `-${digest}${suffix}`; + return `${idempotencyKey.slice(0, MAX_IDEMPOTENCY_KEY_LENGTH - hashedSuffix.length)}${hashedSuffix}`; +} + +function addAttemptMetadata(result, rootTask, attempt) { + return { + ...result, + attempt, + max_attempts: options.maxAttempts, + ...(attempt > 1 ? { root_idempotency_key: rootTask.idempotencyKey } : {}) + }; +} + +function buildTaskFailureOutput(error, routing) { + if (error?.pageSseFailure && typeof error.pageSseFailure === 'object') { + return { + billable: error.pageSseFailure.billable, + error: error.pageSseFailure.error, + routing: error.pageSseFailure.routing || routing, + next_step: error.pageSseFailure.next_step + }; + } + return { error: errorMessage(error), routing }; +} + +function buildCircuitBreakerSkippedTask(task, consecutiveFailures) { + return { + status: 'skipped', + billable: false, + skipped_reason: 'max_consecutive_failures', + consecutive_failures: consecutiveFailures, + next_step: '先处理 failure_summary 中的失败任务,再用 --resume 续跑剩余任务。' + }; +} + +function buildFailedTaskSummary(result, task) { + const error = normalizeFailureError(result.error); + return { + index: task.index, + id: task.id, + idempotency_key: task.idempotencyKey, + attempt: result.attempt, + route: result.routing?.transport, + endpoint: result.routing?.endpoint, + billable: result.billable !== false, + code: error.code, + message: error.message, + next_step: result.next_step || buildFailureNextStep(error) + }; +} + +function normalizeFailureError(error) { + if (error && typeof error === 'object') { + return { + code: typeof error.code === 'string' ? error.code : 'batch_task_failed', + message: typeof error.message === 'string' ? error.message : JSON.stringify(error) + }; + } + return { code: 'batch_task_failed', message: String(error || '任务失败。') }; +} + +function buildFailureNextStep(error) { + if (error.code === 'page_sse_request_rejected') return '修正请求参数或鉴权后,使用新的 Idempotency-Key 重试失败任务。'; + if (error.code === 'page_sse_unavailable') return '补齐 page_sse capability 或显式改用 Agent JSON,再重试失败任务。'; + return '诊断失败原因后,用新的 Idempotency-Key 重试失败任务;不要复用终态失败 key。'; +} + +function buildFailureSummary(failedTasks) { + return { + count: failedTasks.length, + billable_count: failedTasks.filter((task) => task.billable).length, + non_billable_count: failedTasks.filter((task) => !task.billable).length, + tasks: failedTasks + }; +} + +function buildResumeFixList(failedTasks) { + return failedTasks.map((task) => ({ + id: task.id, + previous_idempotency_key: task.idempotency_key, + suggested_idempotency_key: buildAttemptIdempotencyKey(task.idempotency_key, (task.attempt || 1) + 1), + route: task.route, + next_step: task.next_step + })); +} + +function buildTaskRouting(task) { + if (shouldUsePageSseForTask(task)) { + const reason = buildPageSseRoutingReason(task); + return { + endpoint: PAGE_SSE_ENDPOINT, + transport: 'page_sse', + strength: task.mode === 'edit' && readTaskMaxEdge(task) > 2048 ? 'default' : 'recommended', + fallback_endpoint: task.mode === 'edit' ? AGENT_ENDPOINTS.edit : AGENT_ENDPOINTS.generate, + fallback_mode: 'manual_after_diagnosis', + reason + }; + } + return { + endpoint: task.mode === 'edit' ? AGENT_ENDPOINTS.edit : AGENT_ENDPOINTS.generate, + transport: 'agent_json', + strength: 'default', + reason: 'Normal batch tasks use the Agent JSON response contract.' + }; +} + +function buildPageSseRoutingReason(task) { + if (task.raw.sse_log_path) { + return 'Task requested raw SSE event logging, so it uses page form-data SSE for observable diagnostics.'; + } + if (task.mode === 'edit' && hasPageAdvancedFields(task.raw)) { + return 'GPT2Image-compatible edit options require page form-data SSE; Agent JSON edit does not accept those fields.'; + } + if (task.mode === 'edit' && readTaskMaxEdge(task) > 2048) { + return 'High-resolution edit defaults to page form-data SSE; fall back explicitly after diagnosis if streaming has issues.'; + } + return 'Large or complex batch image tasks should use page form-data SSE for observability and recovery.'; +} + +function buildDryRunRequestPreview(task, routing) { + if (routing.transport === 'page_sse') return buildPageSseRequestPreview(task); + if (task.mode === 'edit') return buildAgentEditRequestPreview(task.raw); + return buildGenerateBody(task.raw); +} + +function buildAgentEditRequestPreview(raw) { + validateEditStrategyFields(raw); + const preview = {}; + const fields = ['prompt', 'model', 'n', 'size', 'quality', 'response_mode', 'stream_mode', 'streaming_strategy', 'partial_images']; + for (const field of fields) { + if (raw[field] !== undefined) preview[field] = raw[field]; + } + if (!raw.model) preview.model = 'gpt-image-2'; + if (!raw.response_mode) preview.response_mode = 'path'; + preview.image_fields = readEditImagePaths(raw, String(raw.id || 'edit')).map((_, index) => `image_${index}`); + if (raw.mask_path) preview.mask = 'provided'; + return preview; +} + +function buildPageSseRequestPreview(task) { + const raw = task.raw; + const preview = { + mode: task.mode, + prompt: raw.prompt, + model: raw.model || 'gpt-image-2', + size: raw.size || (task.mode === 'generate' ? '1024x1024' : 'auto'), + quality: raw.quality || (task.mode === 'generate' ? 'high' : 'auto'), + response_mode: readResponseMode(raw), + clientRequestId: task.idempotencyKey, + stream: 'true' + }; + if (raw.n !== undefined) preview.n = readConfiguredPositiveInteger(raw.n, `${task.id}.n`, 1); + if (raw.stream_mode) preview.stream_mode = String(raw.stream_mode); + if (raw.streaming_strategy) preview.image_streaming_strategy = String(raw.streaming_strategy); + if (raw.partial_images !== undefined) preview.partial_images = readPartialImages(raw.partial_images, `${task.id}.partial_images`); + if (raw.image_backend) preview.image_backend = normalizeImageBackendForPage(String(raw.image_backend)); + if (readResponsesModel(raw, task.id)) preview.responsesModel = readResponsesModel(raw, task.id); + if (raw.thinking) preview.thinking = String(raw.thinking); + if (readPromptOptimization(raw, task.id) !== undefined) preview.promptOptimization = readPromptOptimization(raw, task.id); + if (readForceWeb(raw, task.id) !== undefined) preview.force_web = readForceWeb(raw, task.id); + if (raw.sse_log_path) preview.sse_log_path = readNonEmptyString(raw.sse_log_path, `${task.id}.sse_log_path`); + if (raw.background) preview.background = String(raw.background); + if (raw.moderation) preview.moderation = String(raw.moderation); + if (raw.output_compression !== undefined) preview.output_compression = readOutputCompression(raw, task.id); + if (readTaskNormalizations(raw, task.id)) preview.normalizations = readTaskNormalizations(raw, task.id); + if (task.mode === 'edit') { + preview.image_fields = readEditImagePaths(raw, task.id).map((_, index) => `image_${index}`); + if (raw.mask_path) preview.mask = 'provided'; + } + preview.output_format = readOutputFormat(raw); + return preview; +} + +function shouldUsePageSseForTask(task) { + const pageSseAllowed = isPageSseAllowedForTask(task); + if (task.raw.page_sse === true || task.raw.transport === 'page_sse') { + if (!pageSseAllowed) { + throw new Error(`${task.id} stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。`); + } + return true; + } + if (!pageSseAllowed) return false; + if (task.raw.complex_ui === true || task.raw.long_image === true || task.raw.resume_or_recover === true) return true; + if (readResponsesModel(task.raw, task.id)) return true; + if (task.raw.sse_log_path) return true; + if (task.mode === 'edit' && hasPageAdvancedFields(task.raw)) return true; + if (task.mode === 'edit' && readTaskMaxEdge(task) > 2048) return true; + if (task.mode === 'generate' && readTaskMaxEdge(task) > 2048) { + return true; + } + return false; +} + +function isPageSseAllowedForTask(task) { + return task.raw.streaming_strategy !== 'off' && task.raw.stream_mode !== 'non_stream'; +} + +async function postGenerateTask(task) { + const body = buildGenerateBody(task.raw); + const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Idempotency-Key': task.idempotencyKey, ...authHeaders() }, + body: JSON.stringify(body) + }); + if (!response.ok) throw new Error(readErrorMessage(result) || `generate 请求失败,状态码 ${response.status}:${text}`); + return enrichImageUrls(result); +} + +async function postEditTask(task) { + const formData = new FormData(); + appendEditFields(formData, task.raw); + const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.edit}`, { + method: 'POST', + headers: { 'Idempotency-Key': task.idempotencyKey, ...authHeaders() }, + body: formData + }); + if (!response.ok) throw new Error(readErrorMessage(result) || `edit 请求失败,状态码 ${response.status}:${text}`); + return enrichImageUrls(result); +} + +async function postPageSseTask(task, routing) { + const pageSseCapabilities = await ensureCapabilities(); + try { + assertPageSseReady({ + capabilities: pageSseCapabilities, + passwordHash, + idempotencyKey: task.idempotencyKey + }); + const formData = buildPageSseTaskFormData(task); + const result = await postPageSse({ + url: `${baseUrl}${PAGE_SSE_ENDPOINT}`, + formData, + timeoutMs, + sseLogPath: task.raw.sse_log_path, + errorMessage + }); + return formatPageSseOutput({ + result, + baseUrl, + responseMode: readResponseMode(task.raw), + defaultOutputFormat: readOutputFormat(task.raw) + }); + } catch (error) { + const pageSseFailure = buildPageSseFailureOutput({ + error, + fallbackEndpoint: routing.fallback_endpoint, + errorMessage + }); + const taskError = new Error(pageSseFailure.error?.message || errorMessage(error)); + taskError.pageSseFailure = pageSseFailure; + throw taskError; + } +} + +function buildGenerateBody(raw) { + const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format') + ? normalizeOutputFormat(raw.output_format ?? raw.format) + : 'png'; + const outputCompression = readOutputCompression(raw, String(raw.id || 'generate')); + const body = { + prompt: raw.prompt, + model: raw.model || 'gpt-image-2', + n: readConfiguredPositiveInteger(raw.n, 'n', 1), + size: raw.size || '1024x1024', + quality: raw.quality || 'high', + output_format: normalizeEnumValue(outputFormat, OUTPUT_FORMATS, 'output_format'), + response_mode: normalizeEnumValue(hasOwn(raw, 'response_mode') ? raw.response_mode : 'path', RESPONSE_MODES, 'response_mode'), + ...(outputCompression !== undefined ? { output_compression: outputCompression } : {}), + ...(raw.background ? { background: raw.background } : {}), + ...(raw.moderation ? { moderation: raw.moderation } : {}), + ...(hasOwn(raw, 'image_backend') ? { image_backend: normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, 'image_backend') } : {}), + ...(hasOwn(raw, 'stream_mode') ? { stream_mode: normalizeEnumValue(raw.stream_mode, STREAM_MODES, 'stream_mode') } : {}), + ...(hasOwn(raw, 'streaming_strategy') + ? { streaming_strategy: normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, 'streaming_strategy') } + : {}), + ...(hasOwn(raw, 'partial_images') ? { partial_images: readPartialImages(raw.partial_images, 'partial_images') } : {}) + }; + const normalizations = readTaskNormalizations(raw, String(raw.id || 'generate')); + return normalizations ? { ...body, normalizations } : body; +} + +function buildPageSseTaskFormData(task) { + const raw = task.raw; + const formData = new FormData(); + formData.append('mode', task.mode); + formData.append('prompt', raw.prompt); + formData.append('model', raw.model || 'gpt-image-2'); + formData.append('size', raw.size || (task.mode === 'generate' ? '1024x1024' : 'auto')); + formData.append('quality', raw.quality || (task.mode === 'generate' ? 'high' : 'auto')); + formData.append('response_mode', readResponseMode(raw)); + formData.append('clientRequestId', task.idempotencyKey); + formData.append('stream', 'true'); + if (raw.n !== undefined) formData.append('n', String(readConfiguredPositiveInteger(raw.n, `${task.id}.n`, 1))); + if (raw.stream_mode) formData.append('stream_mode', String(raw.stream_mode)); + if (raw.streaming_strategy) formData.append('image_streaming_strategy', String(raw.streaming_strategy)); + if (raw.partial_images !== undefined) formData.append('partial_images', String(readPartialImages(raw.partial_images, `${task.id}.partial_images`))); + if (raw.image_backend) formData.append('image_backend', normalizeImageBackendForPage(String(raw.image_backend))); + if (readResponsesModel(raw, task.id)) formData.append('responsesModel', readResponsesModel(raw, task.id)); + if (raw.thinking) formData.append('thinking', String(raw.thinking)); + if (readPromptOptimization(raw, task.id) !== undefined) { + formData.append('promptOptimization', String(readPromptOptimization(raw, task.id))); + } + if (readForceWeb(raw, task.id) !== undefined) formData.append('force_web', String(readForceWeb(raw, task.id))); + if (raw.background) formData.append('background', String(raw.background)); + if (raw.moderation) formData.append('moderation', String(raw.moderation)); + if (readOutputCompression(raw, task.id) !== undefined) { + formData.append('output_compression', String(readOutputCompression(raw, task.id))); + } + if (passwordHash) formData.append('passwordHash', passwordHash); + if (task.mode === 'edit') { + readEditImagePaths(raw, task.id).forEach((filePath, index) => appendFile(formData, `image_${index}`, filePath)); + if (raw.mask_path) appendFile(formData, 'mask', raw.mask_path); + } + formData.append('output_format', readOutputFormat(raw)); + return formData; +} + +function readResponseMode(raw) { + return normalizeEnumValue(hasOwn(raw, 'response_mode') ? raw.response_mode : 'path', RESPONSE_MODES, 'response_mode'); +} + +function readOutputFormat(raw) { + const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format') + ? normalizeOutputFormat(raw.output_format ?? raw.format) + : 'png'; + return normalizeEnumValue(outputFormat, OUTPUT_FORMATS, 'output_format'); +} + +function appendEditFields(formData, raw) { + validateEditStrategyFields(raw); + const fields = ['prompt', 'model', 'n', 'size', 'quality', 'response_mode', 'stream_mode', 'streaming_strategy', 'partial_images']; + for (const field of fields) { + if (raw[field] !== undefined) formData.append(field, String(raw[field])); + } + if (!raw.model) formData.append('model', 'gpt-image-2'); + if (!raw.response_mode) formData.append('response_mode', 'path'); + readEditImagePaths(raw, String(raw.id || 'edit')).forEach((filePath, index) => appendFile(formData, `image_${index}`, filePath)); + if (raw.mask_path) appendFile(formData, 'mask', raw.mask_path); +} + +function readTaskMaxEdge(task) { + return readMaxImageEdge(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined)); +} + +function validateEditStrategyFields(raw) { + if (hasOwn(raw, 'response_mode')) normalizeEnumValue(raw.response_mode, RESPONSE_MODES, 'response_mode'); + if (hasOwn(raw, 'stream_mode')) normalizeEnumValue(raw.stream_mode, STREAM_MODES, 'stream_mode'); + if (hasOwn(raw, 'streaming_strategy')) { + normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, 'streaming_strategy'); + } + if (hasOwn(raw, 'partial_images')) readPartialImages(raw.partial_images, 'partial_images'); +} + +function normalizeEnumValue(value, allowed, name) { + const normalized = String(value); + if (allowed.has(normalized)) return normalized; + throw new Error(`${name} 的值无效:${normalized}`); +} + +function readPartialImages(value, name) { + const parsed = readConfiguredPositiveInteger(value, name, 2); + if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) { + throw new Error(`${name} 必须是 1 到 3 的整数。`); + } + return parsed; +} + +function readNonNegativeInteger(value, name) { + const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${name} 必须是非负整数。`); + } + return parsed; +} + +function appendFile(formData, field, filePath) { + const buffer = fs.readFileSync(filePath); + formData.append(field, new Blob([buffer], { type: mimeTypeForPath(filePath) }), path.basename(filePath)); +} + +async function fetchJson(url, init) { + const { response, text } = await fetchText(url, init); + let result = null; + try { + result = text ? JSON.parse(text) : null; + } catch (error) { + if (response.ok) throw new Error(`响应不是有效 JSON:${errorMessage(error)}`); + } + return { response, result, text }; +} + +async function fetchText(url, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + return { response, text: await response.text() }; + } finally { + clearTimeout(timer); + } +} + +function readErrorMessage(result) { + if (typeof result?.error === 'string') return result.error; + if (typeof result?.error?.message === 'string') return result.error.message; + return undefined; +} + +function enrichImageUrls(result) { + if (!result || !Array.isArray(result.images)) return result; + return { + ...result, + images: result.images.map((image) => ({ + ...image, + ...(image.content_url ? { absolute_content_url: new URL(image.content_url, `${baseUrl}/`).toString() } : {}), + ...(image.metadata_url ? { absolute_metadata_url: new URL(image.metadata_url, `${baseUrl}/`).toString() } : {}) + })) + }; +} + +async function assertDimensions(task, response) { + const expected = parseExpectedSize(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined)); + if (!expected) throw new Error(`${task.id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`); + if (!Array.isArray(response.images)) return; + for (const image of response.images) { + const bytes = await readImageBytes(image); + const actual = readImageDimensions(bytes); + if (actual.width !== expected.width || actual.height !== expected.height) { + throw new Error(`${task.id} 尺寸校验失败:期望 ${expected.width}x${expected.height},实际 ${actual.width}x${actual.height}。`); + } + } +} + +async function readImageBytes(image) { + if (image.b64_json) return Buffer.from(image.b64_json, 'base64'); + const url = image.absolute_content_url || image.content_url; + if (!url) throw new Error('dimension-check 需要 b64_json 或 content_url。'); + const resolved = resolveSameOriginUrl(baseUrl, url, 'content_url'); + const { response, bytes } = await fetchBytes(resolved, { headers: authHeaders() }); + if (!response.ok) throw new Error(`下载产物失败,状态码 ${response.status}。`); + return bytes; +} + +function parseExpectedSize(size) { + return parseImageSizeValue(size); +} + +function readImageDimensions(buffer) { + if (buffer.length >= 24 && buffer.toString('ascii', 1, 4) === 'PNG') { + return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }; + } + if (buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { + return readWebpDimensions(buffer); + } + return readJpegDimensions(buffer); +} + +async function fetchBytes(url, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + return { response, bytes: Buffer.from(await response.arrayBuffer()) }; + } finally { + clearTimeout(timer); + } +} + +function readJpegDimensions(buffer) { + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) break; + const marker = buffer[offset + 1]; + const length = buffer.readUInt16BE(offset + 2); + if (marker >= 0xc0 && marker <= 0xc3) { + return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }; + } + offset += 2 + length; + } + throw new Error('无法读取图片尺寸。'); +} + +function readWebpDimensions(buffer) { + const chunk = buffer.toString('ascii', 12, 16); + if (chunk === 'VP8X') { + return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) }; + } + if (chunk === 'VP8L') { + const bits = buffer.readUInt32LE(21); + return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }; + } + if (chunk === 'VP8 ') return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }; + throw new Error('无法读取 WebP 图片尺寸。'); +} + +function readCompletedManifestKeys(filePath) { + if (!fs.existsSync(filePath)) return new Set(); + const keys = new Set(); + for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) { + if (!line.trim()) continue; + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (entry.status === 'succeeded') { + if (entry.id) keys.add(entry.id); + if (entry.idempotency_key) keys.add(entry.idempotency_key); + } + } + return keys; +} + +function appendManifest(filePath, entry) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`); +} + +function baseManifestEntry(task) { + return { + at: new Date().toISOString(), + index: task.index, + id: task.id, + mode: task.mode, + idempotency_key: task.idempotencyKey, + attempt: task.attempt || 1, + ...(task.rootIdempotencyKey && task.rootIdempotencyKey !== task.idempotencyKey + ? { root_idempotency_key: task.rootIdempotencyKey } + : {}) + }; +} + +function sanitizeResponse(response) { + if (!response || !Array.isArray(response.images)) return response; + return { + ...response, + images: response.images.map((image) => ({ + ...image, + ...(image.b64_json ? { b64_json_length: image.b64_json.length, b64_json: undefined } : {}) + })) + }; +} + +function mimeTypeForPath(filePath) { + const ext = path.extname(filePath).toLowerCase(); + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.webp') return 'image/webp'; + return 'image/png'; +} + +function printUsage() { + console.error('用法:batch-images.mjs --input tasks.jsonl [options]'); + console.error('默认只输出 dry-run;添加 --allow-billable 才会按 routing rules 逐行真实请求 Agent API 或页面 SSE。'); + console.error('常用参数:--manifest --resume --ordered-prefix --dimension-check --max-attempts --max-consecutive-failures --concurrency --timeout-ms --dry-run --allow-billable'); +} diff --git a/skills/gpt-image-playground-agent/scripts/edit-image.mjs b/skills/gpt-image-playground-agent/scripts/edit-image.mjs index 03083737925e4c52791cd079a8012cfd86a11c59..0d03749bc9a769d6ccc89acc9eacfe7dd7512de6 100644 --- a/skills/gpt-image-playground-agent/scripts/edit-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/edit-image.mjs @@ -4,12 +4,40 @@ import fs from 'node:fs'; import path from 'node:path'; import { errorMessage, + assertValidImageSizeForModel, normalizeBaseUrl, + normalizeOutputFormat, parseRetryAfterValue, readConfiguredPositiveInteger, + readMaxImageEdge, readOptionValue, sleep } from './lib/script-utils.mjs'; +import { + PAGE_SSE_ENDPOINT, + assertPageSseReady, + assertPageSseStreamingAllowed, + buildPageSseFailureOutput, + formatPageSseOutput, + normalizeImageBackendForPage, + postPageSse +} from './lib/page-sse-client.mjs'; + +const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']); +const STREAMING_STRATEGIES = new Set([ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +]); +const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']); +const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']); +const MODERATIONS = new Set(['low', 'auto']); +const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']); +const MIN_PARTIAL_IMAGES = 1; +const MAX_PARTIAL_IMAGES = 3; const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; @@ -29,6 +57,15 @@ if (options.help) { process.exit(0); } +try { + validateUpstreamStreamingOptions(options); + options.size = assertValidImageSizeForModel(options.size, options.model, '--size'); +} catch (error) { + console.error(errorMessage(error)); + printUsage(); + process.exit(2); +} + let maxAttempts; let timeoutMs; try { @@ -62,7 +99,7 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) { ok: true, billable: false, dry_run: true, - endpoint: `${baseUrl}/api/agent/images/edit`, + endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`, routing_guidance: routingGuidance, idempotency_key: idempotencyKey, request: { @@ -71,7 +108,21 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) { model: options.model, size: options.size, quality: options.quality, - response_mode: options.responseMode + response_mode: options.responseMode, + ...(options.streamMode ? { stream_mode: options.streamMode } : {}), + ...(options.streamingStrategy ? { streaming_strategy: options.streamingStrategy } : {}), + ...(options.partialImages ? { partial_images: readPartialImages(options.partialImages) } : {}), + ...(options.format ? { output_format: readOutputFormat(options) } : {}), + ...(readOutputCompression(options) !== undefined ? { output_compression: readOutputCompression(options) } : {}), + ...(options.moderation ? { moderation: options.moderation } : {}), + ...(options.imageBackend ? { image_backend: normalizeImageBackendForPage(options.imageBackend) } : {}), + ...(options.responsesModel ? { responsesModel: readNonEmptyString(options.responsesModel, '--responses-model') } : {}), + ...(options.thinking ? { thinking: options.thinking } : {}), + ...(options.promptOptimization !== undefined + ? { promptOptimization: readBooleanOption(options.promptOptimization, '--prompt-optimization') } + : {}), + ...(options.forceWeb !== undefined ? { force_web: true } : {}), + ...(readEditNormalizations(options) ? { normalizations: readEditNormalizations(options) } : {}) }, next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。' }, @@ -83,20 +134,13 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) { } const routingGuidance = buildEditRoutingGuidance(options); -if (routingGuidance.strength === 'must_use') { - console.error( - JSON.stringify( - { - ok: false, - billable: false, - error: '当前请求命中高分辨率 edit 路由硬规则;请使用页面端 /api/images form-data SSE 路径。', - routing_guidance: routingGuidance - }, - null, - 2 - ) - ); - process.exit(2); +if (routingGuidance.transport === 'page_sse') { + try { + assertPageSseStreamingAllowed(options); + } catch (error) { + console.error(errorMessage(error)); + process.exit(2); + } } function parseArgs(argv) { @@ -105,6 +149,19 @@ function parseArgs(argv) { size: 'auto', quality: 'auto', responseMode: 'path', + routeMode: 'auto', + streamMode: undefined, + streamingStrategy: undefined, + partialImages: undefined, + format: undefined, + outputCompression: undefined, + moderation: undefined, + imageBackend: undefined, + responsesModel: undefined, + thinking: undefined, + promptOptimization: undefined, + forceWeb: undefined, + sseLogPath: undefined, timeoutMs: undefined, idempotencyKey: undefined, imagePath: undefined, @@ -123,6 +180,20 @@ function parseArgs(argv) { else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--agent') parsed.routeMode = 'agent'; + else if (arg === '--page-sse') parsed.routeMode = 'page_sse'; + else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg); + else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg); + else if (arg === '--format' || arg === '--output-format') parsed.format = readOptionValue(argv, (index += 1), arg); + else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg); + else if (arg === '--moderation') parsed.moderation = readOptionValue(argv, (index += 1), arg); + else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg); + else if (arg === '--responses-model' || arg === '--gpt-model') parsed.responsesModel = readOptionValue(argv, (index += 1), arg); + else if (arg === '--thinking') parsed.thinking = readOptionValue(argv, (index += 1), arg); + else if (arg === '--prompt-optimization') parsed.promptOptimization = readOptionValue(argv, (index += 1), arg); + else if (arg === '--force-web') parsed.forceWeb = true; + else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg); else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); @@ -144,12 +215,37 @@ function absoluteUrl(value) { } function buildEditRoutingGuidance(parsed) { - if (readMaxImageEdge(parsed.size) > 2048) { + if (parsed.routeMode === 'agent') { + assertNoPageOnlyEditOptions(parsed, 'Agent edit'); + return { + recommended_endpoint: '/api/agent/images/edit', + transport: 'agent_json', + strength: 'default', + reason: 'Explicit --agent requests use the Agent JSON edit response contract.' + }; + } + if (parsed.routeMode === 'page_sse') { return { recommended_endpoint: '/api/images', transport: 'page_sse', - strength: 'must_use', - reason: 'Agent edit is non-streaming; high-resolution edit should use the page form-data SSE endpoint.' + strength: 'default', + reason: 'Explicit --page-sse requests use the page form-data SSE endpoint.' + }; + } + if (hasPageOnlyEditOptions(parsed) && isPageSseAllowed(parsed)) { + return { + recommended_endpoint: '/api/images', + transport: 'page_sse', + strength: 'default', + reason: 'GPT2Image-compatible edit options require the page form-data SSE endpoint; Agent JSON edit does not accept those fields.' + }; + } + if (readMaxImageEdge(parsed.size) > 2048 && isPageSseAllowed(parsed)) { + return { + recommended_endpoint: '/api/images', + transport: 'page_sse', + strength: 'default', + reason: 'High-resolution edit defaults to the page form-data SSE endpoint; if streaming has issues, diagnose first and explicitly fall back to Agent edit.' }; } return { @@ -160,13 +256,6 @@ function buildEditRoutingGuidance(parsed) { }; } -function readMaxImageEdge(size) { - if (typeof size !== 'string') return 0; - const match = size.match(/^(\d+)x(\d+)$/); - if (!match) return 0; - return Math.max(Number(match[1]), Number(match[2])); -} - function enrichImageUrls(result) { if (!result || !Array.isArray(result.images)) return result; return { @@ -196,10 +285,117 @@ async function readCapabilities() { return response.json(); } +function assertPageSseReadyForEdit(capabilities) { + assertPageSseReady({ + capabilities, + passwordHash, + idempotencyKey + }); +} + function shouldRetry(result) { return Boolean(result?.error?.retryable); } +function validateUpstreamStreamingOptions(parsed) { + if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) { + throw new Error('--stream-mode 必须是 auto、stream 或 non_stream。'); + } + if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) { + throw new Error( + '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。' + ); + } + if (parsed.routeMode === 'page_sse') { + assertPageSseStreamingAllowed(parsed); + } + if (hasPageOnlyEditOptions(parsed) && !isPageSseAllowed(parsed)) { + throw new Error('图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。'); + } + if (parsed.routeMode === 'agent') { + assertNoPageOnlyEditOptions(parsed, 'Agent edit'); + } + if (parsed.format && !OUTPUT_FORMATS.has(readOutputFormat(parsed))) { + throw new Error('--format 必须是 png、jpeg 或 webp。'); + } + if (parsed.outputCompression !== undefined) readOutputCompression(parsed); + if (parsed.moderation && !MODERATIONS.has(parsed.moderation)) { + throw new Error('--moderation 必须是 low 或 auto。'); + } + if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) { + throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。'); + } + if (parsed.responsesModel !== undefined) readNonEmptyString(parsed.responsesModel, '--responses-model'); + if (parsed.thinking && !THINKING_VALUES.has(parsed.thinking)) { + throw new Error('--thinking 必须是 minimal、none、low、medium、high 或 xhigh。'); + } + if (parsed.promptOptimization !== undefined) readBooleanOption(parsed.promptOptimization, '--prompt-optimization'); + if (parsed.partialImages) readPartialImages(parsed.partialImages); +} + +function hasPageOnlyEditOptions(parsed) { + return Boolean( + parsed.format || + parsed.outputCompression !== undefined || + parsed.moderation || + parsed.imageBackend || + parsed.responsesModel || + parsed.thinking || + parsed.promptOptimization !== undefined || + parsed.forceWeb !== undefined + ); +} + +function assertNoPageOnlyEditOptions(parsed, context) { + if (!hasPageOnlyEditOptions(parsed)) return; + throw new Error(`${context} 不接受图生图高级页面字段;请去掉这些字段或使用 --page-sse。`); +} + +function readOutputFormat(parsed) { + return parsed.format ? normalizeOutputFormat(parsed.format) : 'png'; +} + +function readOutputCompression(parsed) { + if (parsed.outputCompression === undefined) return undefined; + const outputFormat = readOutputFormat(parsed); + if (outputFormat === 'png') return undefined; + const value = String(parsed.outputCompression); + if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。'); + const parsedValue = Number(value); + if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) { + throw new Error('--output-compression 必须是 0 到 100 之间的整数。'); + } + return parsedValue; +} + +function readEditNormalizations(parsed) { + if (parsed.outputCompression === undefined || readOutputFormat(parsed) !== 'png') return undefined; + return { output_compression_ignored_for_png: true }; +} + +function readBooleanOption(value, name) { + if (value === true || value === 'true') return true; + if (value === false || value === 'false') return false; + throw new Error(`${name} 必须是 true 或 false。`); +} + +function readNonEmptyString(value, name) { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} 必须是非空字符串。`); + return value.trim(); +} + +function isPageSseAllowed(parsed) { + return parsed.streamMode !== 'non_stream' && parsed.streamingStrategy !== 'off'; +} + +function readPartialImages(value) { + const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2); + if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) { + throw new Error('--partial-images 必须是 1 到 3 的整数。'); + } + return parsed; +} + async function fetchWithTimeout(url, init) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -213,12 +409,13 @@ async function fetchWithTimeout(url, init) { function printUsage() { console.error('用法:edit-image.mjs [options] '); console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。'); - console.error('常用参数:--model --size --quality --response-mode --timeout-ms --idempotency-key --dry-run --allow-billable'); + console.error('常用参数:--model --size --quality --response-mode --format --output-compression --moderation --image-backend --responses-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --sse-log --timeout-ms --idempotency-key --page-sse --agent --dry-run --allow-billable'); console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check'); } +let capabilities; try { - await readCapabilities(); + capabilities = await readCapabilities(); } catch (error) { console.error(errorMessage(error)); process.exit(1); @@ -265,10 +462,74 @@ function buildFormData() { formData.append('size', options.size); formData.append('quality', options.quality); formData.append('response_mode', options.responseMode); + if (options.streamMode) formData.append('stream_mode', options.streamMode); + if (options.streamingStrategy) formData.append('streaming_strategy', options.streamingStrategy); + if (options.partialImages) formData.append('partial_images', String(readPartialImages(options.partialImages))); formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath)); return formData; } +function buildPageSseFormData() { + const formData = new FormData(); + formData.append('mode', 'edit'); + formData.append('prompt', prompt); + formData.append('model', options.model); + formData.append('size', options.size); + formData.append('quality', options.quality); + formData.append('response_mode', options.responseMode); + formData.append('clientRequestId', idempotencyKey); + formData.append('stream', 'true'); + if (options.format) formData.append('output_format', readOutputFormat(options)); + if (readOutputCompression(options) !== undefined) { + formData.append('output_compression', String(readOutputCompression(options))); + } + if (options.moderation) formData.append('moderation', options.moderation); + if (options.imageBackend) formData.append('image_backend', normalizeImageBackendForPage(options.imageBackend)); + if (options.responsesModel) formData.append('responsesModel', readNonEmptyString(options.responsesModel, '--responses-model')); + if (options.thinking) formData.append('thinking', options.thinking); + if (options.promptOptimization !== undefined) { + formData.append('promptOptimization', String(readBooleanOption(options.promptOptimization, '--prompt-optimization'))); + } + if (options.forceWeb !== undefined) formData.append('force_web', 'true'); + if (options.streamMode) formData.append('stream_mode', options.streamMode); + if (options.streamingStrategy) formData.append('image_streaming_strategy', options.streamingStrategy); + if (options.partialImages) formData.append('partial_images', String(readPartialImages(options.partialImages))); + if (passwordHash) formData.append('passwordHash', passwordHash); + formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath)); + return formData; +} + +async function runPageSseEdit() { + assertPageSseReadyForEdit(capabilities); + const result = await postPageSse({ + url: `${baseUrl}${PAGE_SSE_ENDPOINT}`, + formData: buildPageSseFormData(), + timeoutMs, + sseLogPath: options.sseLogPath, + errorMessage + }); + console.log( + JSON.stringify( + { + ...formatPageSseOutput({ + result, + baseUrl, + responseMode: options.responseMode, + defaultOutputFormat: 'png' + }), + routing: { + transport: 'page_sse', + endpoint: PAGE_SSE_ENDPOINT, + fallback_endpoint: '/api/agent/images/edit', + fallback_mode: 'manual_after_diagnosis' + } + }, + null, + 2 + ) + ); +} + function mimeTypeForPath(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; @@ -279,6 +540,26 @@ function mimeTypeForPath(filePath) { let lastResult; let lastRetryAfter = null; +if (routingGuidance.transport === 'page_sse') { + try { + await runPageSseEdit(); + process.exit(0); + } catch (error) { + console.error( + JSON.stringify( + buildPageSseFailureOutput({ + error, + fallbackEndpoint: '/api/agent/images/edit', + errorMessage + }), + null, + 2 + ) + ); + process.exit(1); + } +} + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { let response; let result; diff --git a/skills/gpt-image-playground-agent/scripts/generate-image.mjs b/skills/gpt-image-playground-agent/scripts/generate-image.mjs index 4525b6592c81aa132aa78ff904c12ade1d375564..4581b93aedd7698b2faffe7e791fdc64f6f18a86 100644 --- a/skills/gpt-image-playground-agent/scripts/generate-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/generate-image.mjs @@ -1,46 +1,51 @@ #!/usr/bin/env node -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import { AGENT_ENDPOINTS, buildAgentJobResultPath } from '../../../src/lib/agent-api-paths.mjs'; +import { AGENT_ENDPOINTS, buildAgentJobResultPath } from './lib/agent-api-paths.mjs'; import { - errorMessage, - normalizeBaseUrl, - normalizeOutputFormat, - parseRetryAfterValue, - readConfiguredPositiveInteger, - readOptionValue, - resolveSameOriginUrl, - sleep + errorMessage, + assertValidImageSizeForModel, + normalizeBaseUrl, + normalizeOutputFormat, + parseRetryAfterValue, + readConfiguredPositiveInteger, + readMaxImageEdge, + readOptionValue, + resolveSameOriginUrl, + sleep } from './lib/script-utils.mjs'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']); const RESPONSE_MODES = new Set(['path', 'base64', 'both']); const STREAMING_STRATEGIES = new Set([ - 'off', - 'auto', - 'openai-sse', - 'newapi-keepalive-sse', - 'responses-sse', - 'force-sse' + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' ]); +const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']); const MIN_PARTIAL_IMAGES = 1; const MAX_PARTIAL_IMAGES = 3; -const MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH = 128; +const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128; const PAGE_SSE_ENDPOINT = '/api/images'; const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check'); +let pageSseClientRequestIdMaxLength = DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH; let options; try { - options = parseArgs(process.argv.slice(2)); + options = parseArgs(process.argv.slice(2)); } catch (error) { - console.error(errorMessage(error)); - printUsage(); - process.exit(2); + console.error(errorMessage(error)); + printUsage(); + process.exit(2); } if (options.help) { - printUsage(); - process.exit(0); + printUsage(); + process.exit(0); } let prompt; @@ -49,908 +54,1017 @@ let timeoutMs; let idempotencyKey; let requestBody; try { - maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3); - timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000); - idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`; - if (isNonBillableDryRun(options, contractCheck)) { - if (!hasPromptSource(options)) { - printUsage(); - process.exit(2); - } - requestBody = buildDryRunRequestBody(options); - } else { - prompt = readPrompt(options, { readPromptFile: !contractCheck }); - requestBody = buildRequestBody(prompt, options); - } + maxAttempts = readConfiguredPositiveInteger( + process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, + 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', + 3 + ); + timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000); + idempotencyKey = + options.idempotencyKey || + process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || + `agent-generate-${crypto.randomUUID()}`; + if (isNonBillableDryRun(options, contractCheck)) { + if (!hasPromptSource(options)) { + printUsage(); + process.exit(2); + } + requestBody = buildDryRunRequestBody(options); + } else { + prompt = readPrompt(options, { readPromptFile: !contractCheck }); + requestBody = buildRequestBody(prompt, options); + } } catch (error) { - console.error(errorMessage(error)); - printUsage(); - process.exit(2); + console.error(errorMessage(error)); + printUsage(); + process.exit(2); } if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) { - printUsage(); - process.exit(2); + printUsage(); + process.exit(2); } let baseUrl; try { - baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); + baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783'); } catch (error) { - console.error(errorMessage(error)); - process.exit(2); + console.error(errorMessage(error)); + process.exit(2); } if (isNonBillableDryRun(options, contractCheck)) { - console.log( - JSON.stringify( - { - ok: true, - billable: false, - dry_run: true, - endpoint: dryRunEndpoint(requestBody, options.routeMode), - route_mode: options.routeMode, - routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode), - idempotency_key: idempotencyKey, - request: requestBody, - next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。' - }, - null, - 2 - ) - ); - process.exit(0); + console.log( + JSON.stringify( + { + ok: true, + billable: false, + dry_run: true, + endpoint: dryRunEndpoint(requestBody, options.routeMode), + route_mode: options.routeMode, + routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode), + idempotency_key: idempotencyKey, + request: requestBody, + next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。' + }, + null, + 2 + ) + ); + process.exit(0); } -try { - var capabilities = await readCapabilities(); -} catch (error) { - if (isScriptError(error)) { - console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); - process.exit(1); - } - console.error(errorMessage(error)); - process.exit(1); -} +const capabilities = await readCapabilitiesOrExit(); +applyCapabilitiesRuntimeValues(capabilities); if (contractCheck) { - await runContractCheck(capabilities); - process.exit(0); + await runContractCheck(capabilities); + process.exit(0); } try { - if (shouldUseJobPolling(capabilities, options.routeMode)) { - await runGenerateJob(); - } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) { - try { - const result = await runPageSseRequest(); - console.log( - JSON.stringify( - buildSuccessOutput(formatPageSseOutput(result), { transport: 'page_sse', endpoint: PAGE_SSE_ENDPOINT }), - null, - 2 - ) - ); - process.exit(0); - } catch (error) { - console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); - process.exit(1); + if (shouldUseJobPolling(capabilities, options.routeMode)) { + await runGenerateJob(); + } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) { + try { + const result = await runPageSseRequest(); + console.log( + JSON.stringify( + buildSuccessOutput(formatPageSseOutput(result), { + transport: 'page_sse', + endpoint: PAGE_SSE_ENDPOINT + }), + null, + 2 + ) + ); + process.exit(0); + } catch (error) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } + } else { + await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } }); } - } else { - await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } }); - } } catch (error) { - if (isScriptError(error)) { - console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + if (isScriptError(error)) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } + console.error(errorMessage(error)); process.exit(1); - } - console.error(errorMessage(error)); - process.exit(1); } function parseArgs(argv) { - const parsed = { - model: 'gpt-image-2', - size: '1024x1024', - quality: 'high', - n: '1', - format: 'png', - responseMode: 'path', - imageBackend: undefined, - streamingStrategy: undefined, - partialImages: undefined, - timeoutMs: undefined, - promptFile: undefined, - idempotencyKey: undefined, - routeMode: 'auto', - dryRun: false, - allowBillable: false, - help: false, - promptParts: [] - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === '--dry-run') parsed.dryRun = true; - else if (arg === '--allow-billable') parsed.allowBillable = true; - else if (arg === '--job') parsed.routeMode = 'job'; - else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent'; - else if (arg === '--page-sse') parsed.routeMode = 'page_sse'; - else if (arg === '--help' || arg === '-h') parsed.help = true; - else if (arg === '--contract-check') continue; - else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); - else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); - else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); - else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg); - else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg); - else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); - else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg); - else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg); - else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg); - else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); - else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg); - else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); - else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); - else parsed.promptParts.push(arg); - } - return parsed; + const parsed = { + model: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + n: '1', + format: 'png', + responseMode: 'path', + imageBackend: undefined, + streamMode: undefined, + streamingStrategy: undefined, + partialImages: undefined, + sseLogPath: undefined, + timeoutMs: undefined, + promptFile: undefined, + idempotencyKey: undefined, + routeMode: 'auto', + dryRun: false, + allowBillable: false, + help: false, + promptParts: [] + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--dry-run') parsed.dryRun = true; + else if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--job') parsed.routeMode = 'job'; + else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent'; + else if (arg === '--page-sse') parsed.routeMode = 'page_sse'; + else if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--contract-check') continue; + else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); + else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg); + else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg); + else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg); + else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg); + else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg); + else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg); + else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg); + else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg); + else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); + else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg); + else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); + else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`); + else parsed.promptParts.push(arg); + } + return parsed; } function readPrompt(parsed, { readPromptFile }) { - if (parsed.promptFile) { - if (readPromptFile) { - return fs.readFileSync(parsed.promptFile, 'utf8'); + if (parsed.promptFile) { + if (readPromptFile) { + return fs.readFileSync(parsed.promptFile, 'utf8'); + } + return parsed.promptParts.join(' ') || 'contract check'; } - return parsed.promptParts.join(' ') || 'contract check'; - } - return parsed.promptParts.join(' '); + return parsed.promptParts.join(' '); } function buildRequestBody(promptValue, parsed) { - return addUpstreamStrategyFields( - { - prompt: promptValue || 'contract check', - model: parsed.model, - n: readConfiguredPositiveInteger(parsed.n, '--n', 1), - size: parsed.size, - quality: parsed.quality, - output_format: normalizeOutputFormat(parsed.format), - response_mode: parsed.responseMode - }, - parsed - ); + return addUpstreamStrategyFields( + { + prompt: promptValue || 'contract check', + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'), + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }, + parsed + ); } function buildDryRunRequestBody(parsed) { - const body = addUpstreamStrategyFields( - { - model: parsed.model, - n: readConfiguredPositiveInteger(parsed.n, '--n', 1), - size: parsed.size, - quality: parsed.quality, - output_format: normalizeOutputFormat(parsed.format), - response_mode: parsed.responseMode - }, - parsed - ); - if (parsed.promptFile) { - return { ...body, prompt_file: parsed.promptFile }; - } - return { ...body, prompt: parsed.promptParts.join(' ') }; + const body = addUpstreamStrategyFields( + { + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'), + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }, + parsed + ); + if (parsed.promptFile) { + return { ...body, prompt_file: parsed.promptFile }; + } + return { ...body, prompt: parsed.promptParts.join(' ') }; } function addUpstreamStrategyFields(body, parsed) { - validateUpstreamStrategyOptions(parsed); - return { - ...body, - ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}), - ...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}), - ...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {}) - }; + validateUpstreamStrategyOptions(parsed); + return { + ...body, + ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}), + ...(parsed.streamMode ? { stream_mode: parsed.streamMode } : {}), + ...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}), + ...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {}) + }; } function validateUpstreamStrategyOptions(parsed) { - if (!RESPONSE_MODES.has(parsed.responseMode)) { - throw new Error('--response-mode 必须是 path、base64 或 both。'); - } - if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) { - throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。'); - } - if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) { - throw new Error('--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'); - } - if (parsed.routeMode === 'page_sse' && parsed.streamingStrategy === 'off') { - throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); - } + if (!RESPONSE_MODES.has(parsed.responseMode)) { + throw new Error('--response-mode 必须是 path、base64 或 both。'); + } + if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) { + throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。'); + } + if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) { + throw new Error('--stream-mode 必须是 auto、stream 或 non_stream。'); + } + if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) { + throw new Error( + '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。' + ); + } + if (parsed.routeMode === 'page_sse' && (parsed.streamingStrategy === 'off' || parsed.streamMode === 'non_stream')) { + throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。'); + } } function readPartialImages(value) { - const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2); - if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) { - throw new Error('--partial-images 必须是 1 到 3 的整数。'); - } - return parsed; + const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2); + if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) { + throw new Error('--partial-images 必须是 1 到 3 的整数。'); + } + return parsed; } function hasPromptSource(parsed) { - return Boolean(parsed.promptFile || parsed.promptParts.length > 0); + return Boolean(parsed.promptFile || parsed.promptParts.length > 0); } function isNonBillableDryRun(parsed, isContractCheck) { - return parsed.dryRun || (!isContractCheck && !parsed.allowBillable); + return parsed.dryRun || (!isContractCheck && !parsed.allowBillable); } function authHeaders() { - if (token) return { Authorization: `Bearer ${token}` }; - if (passwordHash) return { 'X-App-Password-Hash': passwordHash }; - return {}; + if (token) return { Authorization: `Bearer ${token}` }; + if (passwordHash) return { 'X-App-Password-Hash': passwordHash }; + return {}; } function absoluteUrl(value) { - if (typeof value !== 'string' || !value) return undefined; - return new URL(value, `${baseUrl}/`).toString(); + if (typeof value !== 'string' || !value) return undefined; + return new URL(value, `${baseUrl}/`).toString(); } function enrichImageUrls(result) { - if (!result || !Array.isArray(result.images)) return result; - return { - ...result, - images: result.images.map((image) => ({ - ...image, - ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}), - ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {}) - })) - }; + if (!result || !Array.isArray(result.images)) return result; + return { + ...result, + images: result.images.map((image) => ({ + ...image, + ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}), + ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {}) + })) + }; } function dryRunEndpoint(body, routeMode) { - if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`; - if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`; - if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`; - return isLargeGenerate(body) && isPageSseAllowed(body) - ? `${baseUrl}${PAGE_SSE_ENDPOINT}` - : `${baseUrl}${AGENT_ENDPOINTS.generate}`; + if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`; + if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`; + if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`; + return isLargeGenerate(body) && isPageSseAllowed(body) + ? `${baseUrl}${PAGE_SSE_ENDPOINT}` + : `${baseUrl}${AGENT_ENDPOINTS.generate}`; } function buildGenerateRoutingGuidance(body, routeMode) { - if (routeMode === 'job') { - return { - recommended_endpoint: AGENT_ENDPOINTS.create_generate_job, - transport: 'agent_job_polling', - strength: 'recommended', - reason: 'Explicit --job requests use Agent job polling.' - }; - } - if (routeMode === 'page_sse' && !isPageSseAllowed(body)) { - throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); - } - if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) { + if (routeMode === 'job') { + return { + recommended_endpoint: AGENT_ENDPOINTS.create_generate_job, + transport: 'agent_job_polling', + strength: 'recommended', + reason: 'Explicit --job requests use Agent job polling.' + }; + } + if (routeMode === 'page_sse' && !isPageSseAllowed(body)) { + throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。'); + } + if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) { + return { + recommended_endpoint: PAGE_SSE_ENDPOINT, + transport: 'page_sse', + strength: 'recommended', + fallback_endpoint: AGENT_ENDPOINTS.generate, + fallback_mode: 'manual_after_diagnosis', + reason: 'Generate requests with max_edge>2048 should use page form-data SSE first; if the stream fails, diagnose first and rerun manually with Agent JSON.' + }; + } return { - recommended_endpoint: PAGE_SSE_ENDPOINT, - transport: 'page_sse', - strength: 'recommended', - fallback_endpoint: AGENT_ENDPOINTS.generate, - fallback_mode: 'manual_after_diagnosis', - reason: 'Generate requests with max_edge>2048 should use page form-data SSE first; if the stream fails, diagnose first and rerun manually with Agent JSON.' + recommended_endpoint: AGENT_ENDPOINTS.generate, + transport: 'agent_json', + strength: 'default', + reason: 'Normal single-image generate requests use the Agent JSON response contract.' }; - } - return { - recommended_endpoint: AGENT_ENDPOINTS.generate, - transport: 'agent_json', - strength: 'default', - reason: 'Normal single-image generate requests use the Agent JSON response contract.' - }; } async function readCapabilities() { - const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, { - headers: authHeaders(), - timeoutMs - }); - if (!response.ok) { - throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`); - } - return result; + const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, { + headers: authHeaders(), + timeoutMs + }); + if (!response.ok) { + throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`); + } + return result; } -async function runGenerateRequest(options = {}) { - let lastResult; - let lastRetryAfter = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': idempotencyKey, - ...authHeaders() - }, - body: JSON.stringify(requestBody), - timeoutMs - }); +async function readCapabilitiesOrExit() { + try { + return await readCapabilities(); + } catch (error) { + if (isScriptError(error)) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } + console.error(errorMessage(error)); + process.exit(1); + } +} - if (response.ok) { - console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2)); - process.exit(0); +function applyCapabilitiesRuntimeValues(capabilitiesValue) { + const maxLength = capabilitiesValue?.agent_streaming?.page_sse?.client_request_id?.max_length; + if (Number.isSafeInteger(maxLength) && maxLength > 0) { + pageSseClientRequestIdMaxLength = maxLength; } +} - const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); - lastResult = result; - lastRetryAfter = retryAfter; - if (!shouldRetry(result) || attempt === maxAttempts) break; - await sleep(retryAfter); - } +async function runGenerateRequest(options = {}) { + let lastResult; + let lastRetryAfter = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + + if (response.ok) { + console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2)); + process.exit(0); + } + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); + lastResult = result; + lastRetryAfter = retryAfter; + if (!shouldRetry(result) || attempt === maxAttempts) break; + await sleep(retryAfter); + } - console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); - process.exit(1); + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); + process.exit(1); } async function runPageSseRequest() { - const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`; - const formData = buildPageSseFormData(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - let response; + const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`; + const formData = buildPageSseFormData(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { - response = await fetch(url, { - method: 'POST', - body: formData, - signal: controller.signal - }); - } catch (error) { - throw new Error(`请求失败:${url}。${errorMessage(error)}`); - } - try { - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('text/event-stream')) { - return await collectPageSseResult(response, controller.signal); - } - const text = await response.text(); - if (!response.ok) { - throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text); - } - return parseJsonResponse(text, true, url); - } catch (error) { - if (controller.signal.aborted) { - throw new Error(`请求失败:${url}。${errorMessage(error)}`); - } - throw error; + let response; + try { + response = await fetch(url, { + method: 'POST', + body: formData, + signal: controller.signal + }); + } catch (error) { + throw new Error(`请求失败:${url}。${errorMessage(error)}`); + } + try { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) { + return await collectPageSseResult(response, controller.signal); + } + const text = await response.text(); + if (!response.ok) { + throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text); + } + return parseJsonResponse(text, true, url); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`请求失败:${url}。${errorMessage(error)}`); + } + throw error; + } + } finally { + clearTimeout(timeout); } - } finally { - clearTimeout(timeout); - } } function assertPageSseReady(capabilitiesValue) { - const pageSse = capabilitiesValue?.agent_streaming?.page_sse; - if (!supportsPageSse(capabilitiesValue)) { - throw createScriptError( - 'page_sse_unavailable', - '大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。' - ); - } - if (pageSse?.auth?.required === true && !passwordHash) { - throw createScriptError( - 'page_sse_auth_required', - '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。' - ); - } + const pageSse = capabilitiesValue?.agent_streaming?.page_sse; + if (!supportsPageSse(capabilitiesValue)) { + throw createScriptError( + 'page_sse_unavailable', + '大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。' + ); + } + if (pageSse?.auth?.required === true && !passwordHash) { + throw createScriptError( + 'page_sse_auth_required', + '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。' + ); + } } function buildPageSseFormData() { - const formData = new FormData(); - assertPageSseClientRequestIdLength(idempotencyKey); - formData.append('mode', 'generate'); - formData.append('prompt', requestBody.prompt); - formData.append('model', requestBody.model); - formData.append('n', String(requestBody.n)); - formData.append('size', requestBody.size); - formData.append('quality', requestBody.quality); - formData.append('output_format', requestBody.output_format); - formData.append('response_mode', requestBody.response_mode); - formData.append('clientRequestId', idempotencyKey); - formData.append('stream', 'true'); - formData.append('partial_images', String(requestBody.partial_images || 2)); - if (requestBody.image_backend) formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend)); - if (requestBody.streaming_strategy) { - formData.append('image_streaming_strategy', requestBody.streaming_strategy); - } - if (requestBody.background) formData.append('background', requestBody.background); - if (requestBody.moderation) formData.append('moderation', requestBody.moderation); - if (requestBody.output_compression !== undefined) { - formData.append('output_compression', String(requestBody.output_compression)); - } - if (passwordHash) formData.append('passwordHash', passwordHash); - return formData; + const formData = new FormData(); + assertPageSseClientRequestIdLength(idempotencyKey); + formData.append('mode', 'generate'); + formData.append('prompt', requestBody.prompt); + formData.append('model', requestBody.model); + formData.append('n', String(requestBody.n)); + formData.append('size', requestBody.size); + formData.append('quality', requestBody.quality); + formData.append('output_format', requestBody.output_format); + formData.append('response_mode', requestBody.response_mode); + formData.append('clientRequestId', idempotencyKey); + formData.append('stream', 'true'); + if (requestBody.stream_mode) formData.append('stream_mode', requestBody.stream_mode); + formData.append('partial_images', String(requestBody.partial_images || 2)); + if (requestBody.image_backend) + formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend)); + if (requestBody.streaming_strategy) { + formData.append('image_streaming_strategy', requestBody.streaming_strategy); + } + if (requestBody.background) formData.append('background', requestBody.background); + if (requestBody.moderation) formData.append('moderation', requestBody.moderation); + if (requestBody.output_compression !== undefined) { + formData.append('output_compression', String(requestBody.output_compression)); + } + if (passwordHash) formData.append('passwordHash', passwordHash); + return formData; } function assertPageSseClientRequestIdLength(clientRequestId) { - if (clientRequestId.length > MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH) { - throw createScriptError( - 'page_sse_client_request_id_too_long', - `页面 SSE 的 clientRequestId 不能超过 ${MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH} 个字符;请缩短 Idempotency-Key。` - ); - } + if (clientRequestId.length > pageSseClientRequestIdMaxLength) { + throw createScriptError( + 'page_sse_client_request_id_too_long', + `页面 SSE 的 clientRequestId 不能超过 ${pageSseClientRequestIdMaxLength} 个字符;请缩短 Idempotency-Key。` + ); + } } function formatPageSseOutput(result) { - if (!result || !Array.isArray(result.images)) return result; - return { - ...result, - images: result.images.map((image) => formatPageSseImage(image)) - }; + if (!result || !Array.isArray(result.images)) return result; + return { + ...result, + images: result.images.map((image) => formatPageSseImage(image)) + }; } function formatPageSseImage(image) { - const output = { ...image }; - if (output.path) { - output.absolute_path = absoluteUrl(output.path); - if (requestBody.response_mode === 'path') { - delete output.b64_json; + const output = { ...image }; + if (output.path) { + output.absolute_path = absoluteUrl(output.path); + if (requestBody.response_mode === 'path') { + delete output.b64_json; + } } - } - return output; + return output; +} + +function createPageSseState() { + return { + completedImages: [], + usage: undefined, + actualCost: undefined, + doneReceived: false, + completedEventCount: 0, + partialImageCount: 0, + lastEventType: undefined + }; } function normalizeImageBackendForPage(value) { - if (value === 'images') return 'images-api'; - if (value === 'responses') return 'responses-image-generation'; - return value; + if (value === 'images') return 'images-api'; + if (value === 'responses') return 'responses-image-generation'; + return value; } function readPageSseClientRequestId(event) { - if (typeof event.clientRequestId === 'string') return event.clientRequestId; - if (typeof event.client_request_id === 'string') return event.client_request_id; - return undefined; + if (typeof event.clientRequestId === 'string') return event.clientRequestId; + if (typeof event.client_request_id === 'string') return event.client_request_id; + return undefined; } function normalizePageSseImage(image, fallbackClientRequestId) { - const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId; - return { - ...image, - output_format: image.outputFormat || image.output_format || requestBody.output_format, - ...(clientRequestId ? { clientRequestId } : {}) - }; + const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId; + return { + ...image, + output_format: image.outputFormat || image.output_format || requestBody.output_format, + ...(clientRequestId ? { clientRequestId } : {}) + }; } function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) { - if (!Array.isArray(doneImages) || doneImages.length === 0) { - return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId)); - } - const imageCount = Math.max(doneImages.length, completedImages.length); - return Array.from({ length: imageCount }, (_, index) => - normalizePageSseImage({ ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, fallbackClientRequestId) - ); + if (!Array.isArray(doneImages) || doneImages.length === 0) { + return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId)); + } + const imageCount = Math.max(doneImages.length, completedImages.length); + return Array.from({ length: imageCount }, (_, index) => + normalizePageSseImage( + { ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, + fallbackClientRequestId + ) + ); } async function collectPageSseResult(response, signal) { - const reader = response.body?.getReader(); - if (!reader) throw new Error('页面 SSE 响应缺少 body。'); - const decoder = new TextDecoder(); - const state = { completedImages: [], usage: undefined, actualCost: undefined, doneReceived: false }; - let buffer = ''; - while (true) { - const { done, value } = await readPageSseChunk(reader, signal); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const events = buffer.split(/\r?\n\r?\n/); - buffer = events.pop() || ''; - for (const rawEvent of events) { - applyPageSseEvent(state, rawEvent); - } - } - buffer += decoder.decode(); - if (buffer.trim()) applyPageSseEvent(state, buffer); - if (state.completedImages.length === 0) { - throw new Error('页面 SSE 未返回最终图片。'); - } - if (!state.doneReceived) { - throw new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'); - } - return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost }; + const reader = response.body?.getReader(); + if (!reader) throw new Error('页面 SSE 响应缺少 body。'); + const decoder = new TextDecoder(); + const state = createPageSseState(); + let buffer = ''; + while (true) { + const { done, value } = await readPageSseChunk(reader, signal); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() || ''; + for (const rawEvent of events) { + appendPageSseLog(rawEvent); + applyPageSseEvent(state, rawEvent); + } + } + buffer += decoder.decode(); + if (buffer.trim()) { + appendPageSseLog(buffer); + applyPageSseEvent(state, buffer); + } + if (state.completedImages.length === 0) { + throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state); + } + if (!state.doneReceived) { + throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state); + } + return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) }; +} + +function appendPageSseLog(rawEvent) { + if (!options.sseLogPath || !rawEvent.trim()) return; + try { + fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true }); + fs.appendFileSync(options.sseLogPath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`); + } catch (error) { + console.warn(`SSE log write failed: ${errorMessage(error)}`); + } } function readPageSseChunk(reader, signal) { - if (!signal) return reader.read(); - if (signal.aborted) { - return Promise.reject(new Error('请求超时。')); - } - return new Promise((resolve, reject) => { - const onAbort = () => reject(new Error('请求超时。')); - signal.addEventListener('abort', onAbort, { once: true }); - reader.read().then(resolve, reject).finally(() => { - signal.removeEventListener('abort', onAbort); + if (!signal) return reader.read(); + if (signal.aborted) { + return Promise.reject(new Error('请求超时。')); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('请求超时。')); + signal.addEventListener('abort', onAbort, { once: true }); + reader + .read() + .then(resolve, reject) + .finally(() => { + signal.removeEventListener('abort', onAbort); + }); }); - }); } function applyPageSseEvent(state, rawEvent) { - const event = parsePageSseEvent(rawEvent); - if (!event) return; - if (event.type === 'error') { - throw createPageSseStreamError(event); - } - if (event.type === 'completed' && event.filename) { - state.completedImages.push( - normalizePageSseImage( - { - filename: event.filename, - b64_json: event.b64_json, - path: event.path, - output_format: event.outputFormat || event.output_format || requestBody.output_format - }, - readPageSseClientRequestId(event) - ) - ); - return; - } - if (event.type === 'done') { - state.doneReceived = true; - const clientRequestId = readPageSseClientRequestId(event); - state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId); - state.usage = event.usage; - state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost; - } + const event = parsePageSseEvent(rawEvent); + if (!event) return; + const eventType = readPageSseEventType(event); + state.lastEventType = eventType; + if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1; + if (event.type === 'error') { + throw createPageSseStreamError(event, state); + } + if (event.type === 'completed' && event.filename) { + state.completedEventCount += 1; + state.completedImages.push( + normalizePageSseImage( + { + filename: event.filename, + b64_json: event.b64_json, + path: event.path, + output_format: event.outputFormat || event.output_format || requestBody.output_format + }, + readPageSseClientRequestId(event) + ) + ); + return; + } + if (event.type === 'done') { + state.doneReceived = true; + const clientRequestId = readPageSseClientRequestId(event); + state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId); + state.usage = event.usage; + state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost; + } +} + +function readPageSseEventType(event) { + if (typeof event.type === 'string' && event.type.trim()) return event.type; + if (typeof event.event === 'string' && event.event.trim()) return event.event; + return undefined; +} + +function isPartialPageSseEvent(event, eventType) { + if (typeof eventType === 'string' && eventType.includes('partial_image')) return true; + return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64); } function formatPageSseError(value) { - if (typeof value === 'string' && value.trim()) return value; - if (value && typeof value === 'object') { - if (typeof value.message === 'string' && value.message.trim()) return value.message; - if (typeof value.code === 'string' && value.code.trim()) return value.code; - try { - return JSON.stringify(value); - } catch { - return '页面 SSE 返回错误事件。'; + if (typeof value === 'string' && value.trim()) return value; + if (value && typeof value === 'object') { + if (typeof value.message === 'string' && value.message.trim()) return value.message; + if (typeof value.code === 'string' && value.code.trim()) return value.code; + try { + return JSON.stringify(value); + } catch { + return '页面 SSE 返回错误事件。'; + } } - } - return '页面 SSE 返回错误事件。'; + return '页面 SSE 返回错误事件。'; } -function createPageSseStreamError(event) { - const error = new Error(formatPageSseError(event.error)); - const status = readPageSseStreamStatus(event); - if (Number.isInteger(status)) { - error.streamStatus = status; - } - return error; +function createPageSseStreamError(event, state) { + const error = new Error(formatPageSseError(event.error)); + const status = readPageSseStreamStatus(event); + if (Number.isInteger(status)) { + error.streamStatus = status; + } + return withPageSseDiagnostics(error, state); } function readPageSseStreamStatus(event) { - if (Number.isInteger(event.status)) return event.status; - if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) { - return event.error.status; - } - return undefined; + if (Number.isInteger(event.status)) return event.status; + if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) { + return event.error.status; + } + return undefined; } function parsePageSseEvent(rawEvent) { - const lines = rawEvent.split(/\r?\n/); - const data = lines - .filter((line) => line.startsWith('data: ')) - .map((line) => line.slice(6)) - .join('\n') - .trim(); - if (!data || data === '[DONE]') return undefined; - try { - return JSON.parse(data); - } catch (error) { - throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`); - } + const lines = rawEvent.split(/\r?\n/); + const data = lines + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join('\n') + .trim(); + if (!data || data === '[DONE]') return undefined; + try { + return JSON.parse(data); + } catch (error) { + throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`); + } } function buildSuccessOutput(result, routing) { - return routing ? { ...result, routing } : result; + return routing ? { ...result, routing } : result; } function buildPageSseFailureOutput(error) { - if (isScriptError(error)) { - return buildPageSseScriptFailure(error); - } - if (isPageSseRequestRejected(error)) { - return buildPageSseRequestRejectedFailure(error); - } - return buildBillablePageSseFailure(error); + const diagnostics = readPageSseDiagnostics(error); + if (isScriptError(error)) { + return buildPageSseScriptFailure(error, diagnostics); + } + if (isPageSseRequestRejected(error)) { + return buildPageSseRequestRejectedFailure(error, diagnostics); + } + return buildBillablePageSseFailure(error, diagnostics); } function buildPageSseRouting(fallbackMode) { - return { - transport: 'page_sse', - endpoint: PAGE_SSE_ENDPOINT, - fallback_endpoint: AGENT_ENDPOINTS.generate, - fallback_mode: fallbackMode - }; -} - -function buildPageSseScriptFailure(error) { - return { - ok: false, - billable: false, - error: { - code: error.scriptCode, - message: errorMessage(error) - }, - routing: buildPageSseRouting('manual_after_diagnosis'), - next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。' - }; -} - -function buildPageSseRequestRejectedFailure(error) { - return { - ok: false, - billable: false, - error: { - code: 'page_sse_request_rejected', - status: error.status, - message: errorMessage(error) - }, - routing: buildPageSseRouting('fix_request_before_retry'), - next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。' - }; -} - -function buildBillablePageSseFailure(error) { - return { - ok: false, - billable: true, - error: { - code: 'page_sse_failed', - ...buildPageSseFailureStatus(error), - message: errorMessage(error) - }, - routing: buildPageSseRouting('manual_after_diagnosis'), - next_step: - '先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。' - }; + return { + transport: 'page_sse', + endpoint: PAGE_SSE_ENDPOINT, + fallback_endpoint: AGENT_ENDPOINTS.generate, + fallback_mode: fallbackMode + }; } -function buildPageSseFailureStatus(error) { - if (error && typeof error === 'object') { - if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus }; - if (Number.isInteger(error.status)) return { status: error.status }; - } - return {}; +function buildPageSseScriptFailure(error, diagnostics) { + return { + ok: false, + billable: false, + error: { + code: error.scriptCode, + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting('manual_after_diagnosis'), + next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。' + }; } -async function runGenerateJob() { - let lastResult; - let lastRetryAfter = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Idempotency-Key': idempotencyKey, - ...authHeaders() - }, - body: JSON.stringify(requestBody), - timeoutMs - }); +function buildPageSseRequestRejectedFailure(error, diagnostics) { + return { + ok: false, + billable: false, + error: { + code: 'page_sse_request_rejected', + status: error.status, + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting('fix_request_before_retry'), + next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。' + }; +} - if (response.ok) { - const jobResult = await pollJobResult(result?.job); - console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2)); - process.exit(0); +function buildBillablePageSseFailure(error, diagnostics) { + return { + ok: false, + billable: true, + error: { + code: 'page_sse_failed', + ...buildPageSseFailureStatus(error), + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting('manual_after_diagnosis'), + next_step: '先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。' + }; +} + +function buildPageSseFailureStatus(error) { + if (error && typeof error === 'object') { + if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus }; + if (Number.isInteger(error.status)) return { status: error.status }; } + return {}; +} + +function withPageSseDiagnostics(error, state) { + error.pageSseDiagnostics = buildPageSseDiagnostics(state); + return error; +} - const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); - lastResult = result; - lastRetryAfter = retryAfter; - if (!shouldRetry(result) || attempt === maxAttempts) break; - await sleep(retryAfter); - } +function readPageSseDiagnostics(error) { + if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined; + return error.pageSseDiagnostics; +} - console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); - process.exit(1); +function buildPageSseDiagnostics(state) { + return { + partial_image_count: state.partialImageCount, + completed_event_count: state.completedEventCount, + done_received: state.doneReceived, + final_image_count: state.completedImages.length, + ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {}) + }; } -async function pollJobResult(job) { - if (!job || typeof job.id !== 'string') { - throw new Error('创建 job 的响应缺少 job.id。'); - } - const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || buildAgentJobResultPath(job.id), 'job.result_url'); - const deadlineMs = Date.now() + timeoutMs; - let lastResult; - let lastRetryAfter = job.retry_after_seconds || 1; - - while (Date.now() < deadlineMs) { - const { response, result } = await fetchJson(resultUrl, { - headers: authHeaders(), - timeoutMs - }); - if (response.ok) return result; +async function runGenerateJob() { + let lastResult; + let lastRetryAfter = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + + if (response.ok) { + const jobResult = await pollJobResult(result?.job); + console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2)); + process.exit(0); + } + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')); + lastResult = result; + lastRetryAfter = retryAfter; + if (!shouldRetry(result) || attempt === maxAttempts) break; + await sleep(retryAfter); + } - const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter; - lastResult = result; - lastRetryAfter = retryAfter; - if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break; - await sleep(retryAfter); - } + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); + process.exit(1); +} - console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); - process.exit(1); +async function pollJobResult(job) { + if (!job || typeof job.id !== 'string') { + throw new Error('创建 job 的响应缺少 job.id。'); + } + const resultUrl = resolveSameOriginUrl( + baseUrl, + job.result_url || buildAgentJobResultPath(job.id), + 'job.result_url' + ); + const deadlineMs = Date.now() + timeoutMs; + let lastResult; + let lastRetryAfter = job.retry_after_seconds || 1; + + while (Date.now() < deadlineMs) { + const { response, result } = await fetchJson(resultUrl, { + headers: authHeaders(), + timeoutMs + }); + if (response.ok) return result; + + const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter; + lastResult = result; + lastRetryAfter = retryAfter; + if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break; + await sleep(retryAfter); + } + + console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2)); + process.exit(1); } async function runContractCheck(capabilitiesValue) { - const checks = []; - const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...authHeaders() - }, - body: JSON.stringify(requestBody), - timeoutMs - }); - if (response.status === 400 && result?.error?.code === 'idempotency_key_required') { - checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code }); - } else { - console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2)); - process.exit(1); - } - - if (supportsJobPolling(capabilitiesValue)) { - const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...authHeaders() - }, - body: JSON.stringify(requestBody), - timeoutMs - }); - if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') { - console.error( - JSON.stringify({ ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, null, 2) - ); - process.exit(1); - } - checks.push({ - endpoint: AGENT_ENDPOINTS.create_generate_job, - status: jobCheck.response.status, - error_code: jobCheck.result.error.code + const checks = []; + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs }); - } + if (response.status === 400 && result?.error?.code === 'idempotency_key_required') { + checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code }); + } else { + console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2)); + process.exit(1); + } - console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2)); + if (supportsJobPolling(capabilitiesValue)) { + const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders() + }, + body: JSON.stringify(requestBody), + timeoutMs + }); + if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') { + console.error( + JSON.stringify( + { ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, + null, + 2 + ) + ); + process.exit(1); + } + checks.push({ + endpoint: AGENT_ENDPOINTS.create_generate_job, + status: jobCheck.response.status, + error_code: jobCheck.result.error.code + }); + } + + console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2)); } async function fetchJson(url, init) { - try { - const response = await fetchWithTimeout(url, init); - const text = await response.text(); - const result = parseJsonResponse(text, response.ok, url); - return { response, result, text }; - } catch (error) { - const message = errorMessage(error); - if (message.startsWith(`请求失败:${url}。`)) { - throw error; + try { + const response = await fetchWithTimeout(url, init); + const text = await response.text(); + const result = parseJsonResponse(text, response.ok, url); + return { response, result, text }; + } catch (error) { + const message = errorMessage(error); + if (message.startsWith(`请求失败:${url}。`)) { + throw error; + } + throw new Error(`请求失败:${url}。${message}`); } - throw new Error(`请求失败:${url}。${message}`); - } } async function fetchWithTimeout(url, init) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs); - try { - const fetchInit = { ...init }; - delete fetchInit.timeoutMs; - return await fetch(url, { ...fetchInit, signal: controller.signal }); - } catch (error) { - const message = errorMessage(error); - throw new Error(`请求失败:${url}。${message}`); - } finally { - clearTimeout(timeout); - } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs); + try { + const fetchInit = { ...init }; + delete fetchInit.timeoutMs; + return await fetch(url, { ...fetchInit, signal: controller.signal }); + } catch (error) { + const message = errorMessage(error); + throw new Error(`请求失败:${url}。${message}`); + } finally { + clearTimeout(timeout); + } } function readErrorFromJsonText(text) { - let result; - try { - result = parseJsonResponse(text, false, ''); - } catch { + let result; + try { + result = parseJsonResponse(text, false, ''); + } catch { + return undefined; + } + if (typeof result?.error === 'string') return result.error; + if (typeof result?.error?.message === 'string') return result.error.message; return undefined; - } - if (typeof result?.error === 'string') return result.error; - if (typeof result?.error?.message === 'string') return result.error.message; - return undefined; } function parseJsonResponse(text, isOk, url) { - if (!text) return null; - try { - return JSON.parse(text); - } catch (error) { - if (!isOk) return null; - const message = errorMessage(error); - throw new Error(`响应不是有效 JSON:${url}。${message}`); - } + if (!text) return null; + try { + return JSON.parse(text); + } catch (error) { + if (!isOk) return null; + const message = errorMessage(error); + throw new Error(`响应不是有效 JSON:${url}。${message}`); + } } function shouldRetry(result) { - return Boolean(result?.error?.retryable); + return Boolean(result?.error?.retryable); } function supportsJobPolling(capabilitiesValue) { - return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling'); + return Boolean( + capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling' + ); } function supportsPageSse(capabilitiesValue) { - return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true); + return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true); } function shouldUseJobPolling(capabilitiesValue, routeMode) { - if (routeMode !== 'job') return false; - if (!supportsJobPolling(capabilitiesValue)) { - throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。'); - } - return true; + if (routeMode !== 'job') return false; + if (!supportsJobPolling(capabilitiesValue)) { + throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。'); + } + return true; } function shouldUsePageSse(capabilitiesValue, request, routeMode) { - if (routeMode === 'agent' || routeMode === 'job') return false; - if (!isPageSseAllowed(request)) { - if (routeMode === 'page_sse') { - throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); + if (routeMode === 'agent' || routeMode === 'job') return false; + if (!isPageSseAllowed(request)) { + if (routeMode === 'page_sse') { + throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。'); + } + return false; + } + if (routeMode === 'page_sse' || isLargeGenerate(request)) { + assertPageSseReady(capabilitiesValue); + return true; } return false; - } - if (routeMode === 'page_sse' || isLargeGenerate(request)) { - assertPageSseReady(capabilitiesValue); - return true; - } - return false; } function isLargeGenerate(request) { - return readMaxImageEdge(request.size) > 2048; + return readMaxImageEdge(request.size) > 2048; } function isPageSseAllowed(request) { - return request.streaming_strategy !== 'off'; -} - -function readMaxImageEdge(size) { - if (typeof size !== 'string') return 0; - const match = size.match(/^(\d+)x(\d+)$/); - if (!match) return 0; - return Math.max(Number(match[1]), Number(match[2])); + return request.streaming_strategy !== 'off' && request.stream_mode !== 'non_stream'; } function createScriptError(code, message) { - const error = new Error(message); - error.scriptCode = code; - return error; + const error = new Error(message); + error.scriptCode = code; + return error; } function isScriptError(error) { - return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string'); + return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string'); } function createPageSseHttpError(status, detail) { - const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`; - const error = new Error(message); - error.status = status; - return error; + const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`; + const error = new Error(message); + error.status = status; + return error; } function isPageSseRequestRejected(error) { - return Boolean( - error && - typeof error === 'object' && - Number.isInteger(error.status) && - error.status >= 400 && - error.status < 500 - ); + return Boolean( + error && + typeof error === 'object' && + Number.isInteger(error.status) && + error.status >= 400 && + error.status < 500 + ); } function printUsage() { - console.error('用法:generate-image.mjs [options] '); - console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。'); - console.error( - '常用参数:--model --size --quality --n --format --response-mode --image-backend --streaming-strategy --partial-images --timeout-ms --prompt-file --idempotency-key --page-sse --agent --job --no-job(兼容别名)' - ); - console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'); + console.error('用法:generate-image.mjs [options] '); + console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。'); + console.error( + '常用参数:--model --size --quality --n --format --response-mode --image-backend --stream-mode --streaming-strategy --partial-images --sse-log --timeout-ms --prompt-file --idempotency-key --page-sse --agent --job --no-job(兼容别名)' + ); + console.error( + '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check' + ); } diff --git a/skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs b/skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9a892247f6e4748cb7f20ae8d30b0aff81c96515 --- /dev/null +++ b/skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs @@ -0,0 +1,30 @@ +export const AGENT_ENDPOINTS = Object.freeze({ + capabilities: '/api/agent/capabilities', + openapi: '/api/agent/openapi.json', + generate: '/api/agent/images/generate', + edit: '/api/agent/images/edit', + create_generate_job: '/api/agent/jobs/images/generate', + job: '/api/agent/jobs/{id}', + job_result: '/api/agent/jobs/{id}/result', + artifact_metadata: '/api/agent/artifacts/{id}', + artifact_content: '/api/agent/artifacts/{id}/content', + artifact_delete: '/api/agent/artifacts/{id}' +}); + +export const AGENT_JOB_ENDPOINTS = Object.freeze({ + create_generate_job: AGENT_ENDPOINTS.create_generate_job, + get_job: AGENT_ENDPOINTS.job, + get_job_result: AGENT_ENDPOINTS.job_result +}); + +export function buildAgentJobPath(jobId) { + return AGENT_ENDPOINTS.job.replace('{id}', encodePathValue(jobId)); +} + +export function buildAgentJobResultPath(jobId) { + return AGENT_ENDPOINTS.job_result.replace('{id}', encodePathValue(jobId)); +} + +function encodePathValue(value) { + return encodeURIComponent(String(value)); +} diff --git a/skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs b/skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs new file mode 100644 index 0000000000000000000000000000000000000000..6ed0755477ac3b67653d1bbbb22f0a393531a278 --- /dev/null +++ b/skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs @@ -0,0 +1,407 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const PAGE_SSE_ENDPOINT = '/api/images'; +export const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128; + +export function readPageSseClientRequestIdMaxLength(capabilities) { + const maxLength = capabilities?.agent_streaming?.page_sse?.client_request_id?.max_length; + if (Number.isSafeInteger(maxLength) && maxLength > 0) return maxLength; + return DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH; +} + +export function assertPageSseReady({ capabilities, passwordHash, idempotencyKey }) { + const pageSse = capabilities?.agent_streaming?.page_sse; + if (pageSse?.supported !== true) { + throw createPageSseScriptError( + 'page_sse_unavailable', + '当前路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级。' + ); + } + if (pageSse?.auth?.required === true && !passwordHash) { + throw createPageSseScriptError( + 'page_sse_auth_required', + '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。' + ); + } + const maxLength = readPageSseClientRequestIdMaxLength(capabilities); + if (idempotencyKey.length > maxLength) { + throw createPageSseScriptError( + 'page_sse_client_request_id_too_long', + `页面 SSE 的 clientRequestId 不能超过 ${maxLength} 个字符;请缩短 Idempotency-Key。` + ); + } +} + +export async function postPageSse({ url, formData, timeoutMs, errorMessage, sseLogPath }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let response; + try { + response = await fetch(url, { + method: 'POST', + body: formData, + signal: controller.signal + }); + } catch (error) { + throw new Error(`请求失败:${url}。${errorMessage(error)}`); + } + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) { + return await collectPageSseResult(response, controller.signal, errorMessage, sseLogPath); + } + + const text = await response.text(); + if (!response.ok) { + throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text); + } + return parseJsonResponse(text, true, url, errorMessage); + } finally { + clearTimeout(timeout); + } +} + +export function formatPageSseOutput({ result, baseUrl, responseMode = 'path', defaultOutputFormat = 'png' }) { + if (!result || !Array.isArray(result.images)) return result; + return { + ...result, + images: result.images.map((image) => + formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat }) + ) + }; +} + +export function buildPageSseFailureOutput({ error, fallbackEndpoint, fallbackMode = 'manual_after_diagnosis', errorMessage }) { + const diagnostics = readPageSseDiagnostics(error); + if (isPageSseScriptError(error)) { + return { + ok: false, + billable: false, + error: { + code: error.scriptCode, + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting(fallbackEndpoint, fallbackMode), + next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。' + }; + } + if (isPageSseRequestRejected(error)) { + return { + ok: false, + billable: false, + error: { + code: 'page_sse_request_rejected', + status: error.status, + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting(fallbackEndpoint, 'fix_request_before_retry'), + next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。' + }; + } + return { + ok: false, + billable: true, + error: { + code: 'page_sse_failed', + ...buildPageSseFailureStatus(error), + message: errorMessage(error), + ...(diagnostics ? { diagnostics } : {}) + }, + routing: buildPageSseRouting(fallbackEndpoint, fallbackMode), + next_step: '先诊断页面流式失败原因,再决定是否显式选择备用路径;不要自动重试同一个请求。' + }; +} + +export function normalizeImageBackendForPage(value) { + if (value === 'images') return 'images-api'; + if (value === 'responses') return 'responses-image-generation'; + return value; +} + +export function isPageSseDisabledByStreamingOptions(value) { + return value?.streamMode === 'non_stream' || value?.streamingStrategy === 'off'; +} + +export function assertPageSseStreamingAllowed(value) { + if (isPageSseDisabledByStreamingOptions(value)) { + throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能使用页面 SSE。'); + } +} + +function buildPageSseRouting(fallbackEndpoint, fallbackMode) { + return { + transport: 'page_sse', + endpoint: PAGE_SSE_ENDPOINT, + fallback_endpoint: fallbackEndpoint, + fallback_mode: fallbackMode + }; +} + +function createPageSseScriptError(code, message) { + const error = new Error(message); + error.scriptCode = code; + return error; +} + +function isPageSseScriptError(error) { + return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string'); +} + +function isPageSseRequestRejected(error) { + return Boolean(error && typeof error === 'object' && Number.isInteger(error.status) && error.status >= 400 && error.status < 500); +} + +function createPageSseHttpError(status, message) { + const error = new Error(formatErrorValue(message)); + error.status = status; + return error; +} + +function formatErrorValue(value) { + if (typeof value === 'string' && value.trim()) return value; + if (value && typeof value === 'object') { + if (typeof value.message === 'string' && value.message.trim()) return value.message; + if (typeof value.code === 'string' && value.code.trim()) return value.code; + try { + return JSON.stringify(value); + } catch { + return '页面 SSE 返回错误。'; + } + } + return '页面 SSE 返回错误。'; +} + +function readErrorFromJsonText(text) { + try { + const value = text ? JSON.parse(text) : null; + if (typeof value?.error === 'string') return value.error; + return value?.error || value; + } catch { + return undefined; + } +} + +function parseJsonResponse(text, allowEmpty, url, errorMessage) { + if (!text && allowEmpty) return {}; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(`响应不是有效 JSON:${url}。${errorMessage(error)}`); + } +} + +async function collectPageSseResult(response, signal, errorMessage, sseLogPath) { + const reader = response.body?.getReader(); + if (!reader) throw new Error('页面 SSE 响应缺少 body。'); + const decoder = new TextDecoder(); + const state = createPageSseState(); + let buffer = ''; + while (true) { + const { done, value } = await readPageSseChunk(reader, signal); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() || ''; + for (const rawEvent of events) { + appendPageSseLog(sseLogPath, rawEvent); + applyPageSseEvent(state, rawEvent, errorMessage); + } + } + buffer += decoder.decode(); + if (buffer.trim()) { + appendPageSseLog(sseLogPath, buffer); + applyPageSseEvent(state, buffer, errorMessage); + } + if (state.completedImages.length === 0) { + throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state); + } + if (!state.doneReceived) { + throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state); + } + return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) }; +} + +function appendPageSseLog(filePath, rawEvent) { + if (!filePath || !rawEvent.trim()) return; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`); + } catch (error) { + console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function createPageSseState() { + return { + completedImages: [], + usage: undefined, + actualCost: undefined, + doneReceived: false, + completedEventCount: 0, + partialImageCount: 0, + lastEventType: undefined + }; +} + +function readPageSseChunk(reader, signal) { + if (!signal) return reader.read(); + if (signal.aborted) return Promise.reject(new Error('请求超时。')); + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('请求超时。')); + signal.addEventListener('abort', onAbort, { once: true }); + reader + .read() + .then(resolve, reject) + .finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +function applyPageSseEvent(state, rawEvent, errorMessage) { + const event = parsePageSseEvent(rawEvent, errorMessage); + if (!event) return; + const eventType = readPageSseEventType(event); + state.lastEventType = eventType; + if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1; + if (event.type === 'error') { + throw createPageSseStreamError(event, state); + } + if (event.type === 'completed' && event.filename) { + state.completedEventCount += 1; + state.completedImages.push( + normalizePageSseImage( + { + filename: event.filename, + b64_json: event.b64_json, + path: event.path, + output_format: event.outputFormat || event.output_format + }, + readPageSseClientRequestId(event) + ) + ); + return; + } + if (event.type === 'done') { + state.doneReceived = true; + const clientRequestId = readPageSseClientRequestId(event); + state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId); + state.usage = event.usage; + state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost; + } +} + +function readPageSseEventType(event) { + if (typeof event.type === 'string' && event.type.trim()) return event.type; + if (typeof event.event === 'string' && event.event.trim()) return event.event; + return undefined; +} + +function isPartialPageSseEvent(event, eventType) { + if (typeof eventType === 'string' && eventType.includes('partial_image')) return true; + return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64); +} + +function parsePageSseEvent(rawEvent, errorMessage) { + const lines = rawEvent.split(/\r?\n/); + const data = lines + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join('\n') + .trim(); + if (!data || data === '[DONE]') return undefined; + try { + return JSON.parse(data); + } catch (error) { + throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`); + } +} + +function createPageSseStreamError(event, state) { + const error = new Error(formatErrorValue(event.error)); + const status = readPageSseStreamStatus(event); + if (Number.isInteger(status)) error.streamStatus = status; + return withPageSseDiagnostics(error, state); +} + +function readPageSseStreamStatus(event) { + if (Number.isInteger(event.status)) return event.status; + if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) { + return event.error.status; + } + return undefined; +} + +function readPageSseClientRequestId(event) { + if (typeof event.clientRequestId === 'string') return event.clientRequestId; + if (typeof event.client_request_id === 'string') return event.client_request_id; + return undefined; +} + +function normalizePageSseImage(image, fallbackClientRequestId) { + const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId; + return { + ...image, + ...(image.output_format ? { output_format: image.output_format } : {}), + ...(clientRequestId ? { clientRequestId } : {}) + }; +} + +function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) { + if (!Array.isArray(doneImages) || doneImages.length === 0) { + return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId)); + } + const imageCount = Math.max(doneImages.length, completedImages.length); + return Array.from({ length: imageCount }, (_, index) => + normalizePageSseImage( + { ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, + fallbackClientRequestId + ) + ); +} + +function formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat }) { + const output = { + ...image, + output_format: image.outputFormat || image.output_format || defaultOutputFormat + }; + if (output.path) { + output.absolute_path = new URL(output.path, `${baseUrl}/`).toString(); + output.content_url = output.content_url || output.path; + output.absolute_content_url = output.absolute_content_url || output.absolute_path; + if (responseMode === 'path') delete output.b64_json; + } + return output; +} + +function buildPageSseFailureStatus(error) { + if (error && typeof error === 'object') { + if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus }; + if (Number.isInteger(error.status)) return { status: error.status }; + } + return {}; +} + +function withPageSseDiagnostics(error, state) { + error.pageSseDiagnostics = buildPageSseDiagnostics(state); + return error; +} + +function readPageSseDiagnostics(error) { + if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined; + return error.pageSseDiagnostics; +} + +function buildPageSseDiagnostics(state) { + return { + partial_image_count: state.partialImageCount, + completed_event_count: state.completedEventCount, + done_received: state.doneReceived, + final_image_count: state.completedImages.length, + ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {}) + }; +} diff --git a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs index 3a7f89eabbed5cbb2aa9fd0f82d0a790871cdbe8..b8f30618f5aa6dafa49a04862e35c979accc67fa 100644 --- a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +++ b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs @@ -1,9 +1,12 @@ -import { - CHINESE_POSITIVE_INTEGER_MESSAGES, - parsePositiveIntegerConfig -} from '../../../../src/lib/positive-integer-config.mjs'; - const MAX_RETRY_AFTER_SECONDS = 60; +const DIGITS_PATTERN = /^\d+$/; +const IMAGE_SIZE_PATTERN = /^(\d+)x(\d+)$/; +const LEGACY_IMAGE_SIZES = new Set(['auto', '1024x1024', '1536x1024', '1024x1536']); +const GPT_IMAGE_2_MIN_PIXELS = 655_360; +const GPT_IMAGE_2_MAX_PIXELS = 8_294_400; +const GPT_IMAGE_2_MAX_EDGE = 3840; +const GPT_IMAGE_2_EDGE_MULTIPLE = 16; +const GPT_IMAGE_2_MAX_ASPECT = 3; export function readOptionValue(argv, index, name) { const value = argv[index]; @@ -14,9 +17,16 @@ export function readOptionValue(argv, index, name) { } export function readConfiguredPositiveInteger(value, name, fallback) { - return parsePositiveIntegerConfig(value, name, fallback, { - messages: CHINESE_POSITIVE_INTEGER_MESSAGES - }); + const rawValue = value === undefined || value === null ? '' : String(value).trim(); + if (!rawValue) return fallback; + if (!DIGITS_PATTERN.test(rawValue)) { + throw new Error(`${name} 必须是正整数。`); + } + const parsed = Number(rawValue); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${name} 必须是正整数。`); + } + return parsed; } export function normalizeBaseUrl(value) { @@ -40,6 +50,34 @@ export function normalizeOutputFormat(value) { return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase(); } +export function assertValidImageSizeForModel(value, model, label = 'size') { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} 必须是字符串。`); + } + if (model !== 'gpt-image-2') { + if (!LEGACY_IMAGE_SIZES.has(value)) { + throw new Error(`${label} 对 ${model} 无效;非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536。`); + } + return value; + } + if (value === 'auto') return value; + const size = parseImageSizeValue(value); + if (!size) throw new Error(`${label} 必须是 auto 或 WIDTHxHEIGHT。`); + assertValidGptImage2Dimensions(size.width, size.height, label); + return value; +} + +export function parseImageSizeValue(value) { + if (typeof value !== 'string') return undefined; + const match = IMAGE_SIZE_PATTERN.exec(value); + return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined; +} + +export function readMaxImageEdge(value) { + const size = parseImageSizeValue(value); + return size ? Math.max(size.width, size.height) : 0; +} + export function parseRetryAfterValue(value, fallback = 1) { if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback); const parsed = Number(value); @@ -68,3 +106,30 @@ export function resolveSameOriginUrl(baseUrl, value, label) { export function errorMessage(error) { return error instanceof Error ? error.message : String(error); } + +function assertValidGptImage2Dimensions(width, height, label) { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error(`${label} 的宽度和高度必须是正数。`); + } + if (!Number.isInteger(width) || !Number.isInteger(height)) { + throw new Error(`${label} 的宽度和高度必须是整数。`); + } + if (width % GPT_IMAGE_2_EDGE_MULTIPLE !== 0 || height % GPT_IMAGE_2_EDGE_MULTIPLE !== 0) { + throw new Error(`${label} 的宽边和高边都必须是 ${GPT_IMAGE_2_EDGE_MULTIPLE} 的倍数。`); + } + if (width > GPT_IMAGE_2_MAX_EDGE || height > GPT_IMAGE_2_MAX_EDGE) { + throw new Error(`${label} 的最大单边不能超过 ${GPT_IMAGE_2_MAX_EDGE}px。`); + } + const long = Math.max(width, height); + const short = Math.min(width, height); + if (long / short > GPT_IMAGE_2_MAX_ASPECT) { + throw new Error(`${label} 的宽高比(长边:短边)必须小于等于 ${GPT_IMAGE_2_MAX_ASPECT}:1。`); + } + const pixels = width * height; + if (pixels < GPT_IMAGE_2_MIN_PIXELS) { + throw new Error(`${label} 的总像素必须至少为 ${GPT_IMAGE_2_MIN_PIXELS.toLocaleString()}。`); + } + if (pixels > GPT_IMAGE_2_MAX_PIXELS) { + throw new Error(`${label} 的总像素不能超过 ${GPT_IMAGE_2_MAX_PIXELS.toLocaleString()}。`); + } +} diff --git a/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs b/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs index a5c5a637e924b39d6b4955e90984cb00869f0022..089a24dd1e69525ec346b9c76bb49f3a00adb174 100644 --- a/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs @@ -3,6 +3,7 @@ import dns from 'node:dns/promises'; import tls from 'node:tls'; import { errorMessage, + assertValidImageSizeForModel, normalizeBaseUrl, normalizeOutputFormat, readConfiguredPositiveInteger, @@ -36,6 +37,7 @@ const apiKey = process.env.GPT_IMAGE_UPSTREAM_API_KEY || process.env.OPENAI_API_ let timeoutMs; try { timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000); + options.size = assertValidImageSizeForModel(options.size, options.model, '--size'); } catch (error) { console.error(errorMessage(error)); printUsage(); diff --git a/src/app/api/agent/agent-routes.test.ts b/src/app/api/agent/agent-routes.test.ts index cc89ba92b27992f3f077cccd280286fba283cd59..3688fab2560b909a13898cde8e888f70051b058b 100644 --- a/src/app/api/agent/agent-routes.test.ts +++ b/src/app/api/agent/agent-routes.test.ts @@ -38,8 +38,17 @@ beforeEach(async () => { process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs'; delete process.env.APP_PASSWORD; delete process.env.AGENT_API_TOKEN; + delete process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_BASE_URL; + delete process.env.OPENAI_CHANNEL_1_ID; delete process.env.OPENAI_CHANNEL_1_API_KEYS; delete process.env.OPENAI_CHANNEL_1_BASE_URL; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK; + delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY; + delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS; }); afterEach(async () => { @@ -99,6 +108,10 @@ describe('Agent route integration', () => { 'images-api', 'responses-image-generation' ]); + assert.deepEqual(body.agent_streaming.upstream_sse.request_fields_by_mode, { + generate: ['image_backend', 'stream_mode', 'streaming_strategy', 'partial_images'], + edit: ['stream_mode', 'streaming_strategy', 'partial_images'] + }); }); it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => { @@ -161,6 +174,38 @@ describe('Agent route integration', () => { } }); + it('uses Agent auto stream mode as an internal upstream stream by default', async () => { + const { generateImage } = await loadAgentRoutes(); + const { getServerChannelState } = await import('@/lib/server-channel-router'); + let upstreamBody = ''; + const upstream = await startImageUpstream((body) => { + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-default-auto-stream-key', { + prompt: 'agent default auto stream', + stream_mode: 'auto' + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 1); + } finally { + await upstream.close(); + } + }); + it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => { const { generateImage } = await loadAgentRoutes(); let upstreamBody = ''; @@ -408,6 +453,7 @@ describe('Agent route integration', () => { agentJsonRequest('agent-responses-upstream-sse-partial-only-key', { prompt: 'agent responses upstream sse partial only', image_backend: 'responses-image-generation', + stream_mode: 'stream', streaming_strategy: 'responses-sse', partial_images: 2 }) @@ -454,6 +500,7 @@ describe('Agent route integration', () => { agentJsonRequest('agent-responses-upstream-sse-failed-call-key', { prompt: 'agent responses upstream sse failed call', image_backend: 'responses-image-generation', + stream_mode: 'stream', streaming_strategy: 'responses-sse', partial_images: 2 }) @@ -483,6 +530,7 @@ describe('Agent route integration', () => { const response = await generateImage( agentJsonRequest('agent-upstream-sse-partial-only-key', { prompt: 'agent upstream sse partial only', + stream_mode: 'stream', streaming_strategy: 'newapi-keepalive-sse', partial_images: 2 }) @@ -772,6 +820,7 @@ describe('Agent route integration', () => { const created = await createGenerateJob( agentJobJsonRequest('route-job-images-missing-final-key', { prompt: 'agent job images partial only', + stream_mode: 'stream', streaming_strategy: 'newapi-keepalive-sse', partial_images: 2 }) @@ -908,6 +957,7 @@ describe('Agent route integration', () => { agentJobJsonRequest('route-job-responses-missing-final-key', { prompt: 'agent job responses partial only', image_backend: 'responses-image-generation', + stream_mode: 'stream', streaming_strategy: 'responses-sse', partial_images: 2 }) @@ -1311,8 +1361,44 @@ describe('Agent route integration', () => { await upstream.close(); }); + it('can consume Agent edit upstream SSE internally while returning final JSON', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'image_edit.completed', + data: { type: 'image_edit.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await editImage( + agentEditRequest('route-edit-upstream-sse-key', 'agent edit stream', {}, { + stream_mode: 'stream', + streaming_strategy: 'openai-sse', + partial_images: '2' + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true); + assert.match(upstreamBody, /name="stream"/); + assert.match(upstreamBody, /name="partial_images"/); + } finally { + await upstream.close(); + } + }); + it('aborts Agent edit upstream calls when the client request signal aborts', async () => { const { editImage } = await loadAgentRoutes(); + const { getServerChannelState } = await import('@/lib/server-channel-router'); const upstream = await startHangingImageEditUpstream(); process.env.OPENAI_API_KEY = 'test-key'; process.env.OPENAI_API_BASE_URL = upstream.baseUrl; @@ -1320,9 +1406,13 @@ describe('Agent route integration', () => { try { const responsePromise = editImage( - agentEditRequest('route-edit-abort-key', 'agent edit abort', {}, 'path', { - signal: abortController.signal - }) + agentEditRequest( + 'route-edit-abort-key', + 'agent edit abort', + {}, + { stream_mode: 'auto', streaming_strategy: 'openai-sse' }, + { signal: abortController.signal } + ) ); await waitFor(() => upstream.requests === 1); abortController.abort(); @@ -1335,6 +1425,7 @@ describe('Agent route integration', () => { ]); assert.notEqual(response.status, 200); + assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0); } finally { abortController.abort(); await upstream.close(); @@ -1359,7 +1450,7 @@ describe('Agent route integration', () => { await upstream.close(); }); - it('rejects high-resolution Agent edit requests before contacting upstream', async () => { + it('rejects unsupported fields on Agent edit requests before calling upstream', async () => { const { editImage } = await loadAgentRoutes(); let upstreamCalls = 0; const upstream = await startImageUpstream(() => { @@ -1371,20 +1462,95 @@ describe('Agent route integration', () => { try { const response = await editImage( - agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' }) + agentEditRequest('route-edit-generate-only-fields-key', 'agent edit invalid fields', {}, { + imageBackend: 'responses-image-generation', + image_backend: 'responses-image-generation', + format: 'webp', + outputFormat: 'jpeg', + output_format: 'jpeg', + outputCompression: '80', + output_compression: '80', + responsesModel: 'gpt-4.1', + responses_model: 'gpt-4.1', + background: 'opaque', + moderation: 'auto' + }) ); assert.equal(response.status, 422); const body = await response.json(); assert.equal(body.error.code, 'validation_error'); - assert.match(body.error.message, /\/api\/images/); + assert.match(body.error.details.fields.imageBackend, /不接受该字段/); + assert.match(body.error.details.fields.image_backend, /不接受该字段/); + assert.match(body.error.details.fields.format, /不接受该字段/); + assert.match(body.error.details.fields.outputFormat, /不接受该字段/); + assert.match(body.error.details.fields.output_format, /不接受该字段/); + assert.match(body.error.details.fields.outputCompression, /不接受该字段/); + assert.match(body.error.details.fields.output_compression, /不接受该字段/); + assert.match(body.error.details.fields.responsesModel, /不接受该字段/); + assert.match(body.error.details.fields.responses_model, /不接受该字段/); + assert.match(body.error.details.fields.background, /不接受该字段/); + assert.match(body.error.details.fields.moderation, /不接受该字段/); assert.equal(upstreamCalls, 0); } finally { await upstream.close(); } }); - it('rejects high-resolution Agent edit requests before reading files or API credentials', async () => { + it('rejects page-only streaming strategy fields on Agent edit requests before calling upstream', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await editImage( + agentEditRequest('route-edit-page-streaming-field-key', 'agent edit invalid page streaming field', {}, { + image_streaming_strategy: 'force-sse', + imageStreamingStrategy: 'force-sse' + }) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.details.fields.image_streaming_strategy, /streaming_strategy/); + assert.match(body.error.details.fields.imageStreamingStrategy, /streaming_strategy/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('allows high-resolution Agent edit requests as an explicit fallback path', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await editImage( + agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.images[0].output_format, 'png'); + assert.equal(upstreamCalls, 1); + } finally { + await upstream.close(); + } + }); + + it('reports missing image files for high-resolution Agent edit before API credentials', async () => { const { editImage } = await loadAgentRoutes(); delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_BASE_URL; @@ -1408,12 +1574,11 @@ describe('Agent route integration', () => { assert.equal(response.status, 422); const body = await response.json(); assert.equal(body.error.code, 'validation_error'); - assert.match(body.error.message, /\/api\/images/); - assert.equal(body.error.details?.fields?.image_0, undefined); - assert.doesNotMatch(body.error.message, /API Key|图片文件/); + assert.match(body.error.message, /图片文件/); + assert.doesNotMatch(body.error.message, /API Key/); }); - it('rejects auto-size Agent edit when the uploaded source image is high resolution', async () => { + it('allows auto-size Agent edit when the uploaded source image is high resolution', async () => { const { editImage } = await loadAgentRoutes(); let upstreamCalls = 0; const upstream = await startImageUpstream(() => { @@ -1436,11 +1601,10 @@ describe('Agent route integration', () => { ) ); - assert.equal(response.status, 422); + assert.equal(response.status, 200); const body = await response.json(); - assert.equal(body.error.code, 'validation_error'); - assert.match(body.error.message, /\/api\/images/); - assert.equal(upstreamCalls, 0); + assert.equal(body.images[0].output_format, 'png'); + assert.equal(upstreamCalls, 1); } finally { await upstream.close(); } @@ -1967,6 +2131,8 @@ function asNextRequest(request: Request): NextRequest { } function agentJsonRequest(idempotencyKey: string, body: Record, headers: Record = {}) { + const requestBody = + 'stream_mode' in body || 'streaming_strategy' in body ? body : { ...body, stream_mode: 'non_stream' }; return new Request('http://localhost/api/agent/images/generate', { method: 'POST', headers: { @@ -1974,7 +2140,7 @@ function agentJsonRequest(idempotencyKey: string, body: Record, 'Idempotency-Key': idempotencyKey, ...headers }, - body: JSON.stringify(body) + body: JSON.stringify(requestBody) }); } @@ -1983,6 +2149,8 @@ function agentJobJsonRequest( body: Record, headers: Record = {} ) { + const requestBody = + 'stream_mode' in body || 'streaming_strategy' in body ? body : { ...body, stream_mode: 'non_stream' }; return new Request('http://localhost/api/agent/jobs/images/generate', { method: 'POST', headers: { @@ -1990,7 +2158,7 @@ function agentJobJsonRequest( 'Idempotency-Key': idempotencyKey, ...headers }, - body: JSON.stringify(body) + body: JSON.stringify(requestBody) }); } @@ -2010,6 +2178,9 @@ function agentEditRequest( typeof responseModeOrFields === 'string' ? { response_mode: responseModeOrFields } : { response_mode: 'path', ...responseModeOrFields }; + if (!('stream_mode' in fields) && !('streaming_strategy' in fields)) { + fields.stream_mode = 'non_stream'; + } const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64'); const formData = new FormData(); formData.append('prompt', prompt); @@ -2117,7 +2288,9 @@ async function startStreamingImageUpstream( ) => Array<{ event?: string; data: unknown }> | Promise> ): Promise<{ baseUrl: string; close: () => Promise }> { const server = http.createServer(async (request, response) => { - if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + const isImageStreamPath = + request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits'); + if (request.method !== 'POST' || !isImageStreamPath) { response.writeHead(404, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'not found' } })); return; diff --git a/src/app/api/agent/images/edit/route.ts b/src/app/api/agent/images/edit/route.ts index efde0a962bdfef079fef68095e79cdd348ac689e..75ad8adbd39c6fd84bf06070aa69011fd928a220 100644 --- a/src/app/api/agent/images/edit/route.ts +++ b/src/app/api/agent/images/edit/route.ts @@ -1,7 +1,6 @@ import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; import { assertAgentAuthorized } from '@/lib/agent-auth'; import { - assertAgentEditRouteAllowedFromFormData, buildEditRequestHashFromSnapshot, completeAgentExecutionState, createArtifactPersistenceError, @@ -31,7 +30,6 @@ export async function POST(request: NextRequest) { try { assertAgentAuthorized(request.headers); const formData = await parseAgentEditFormData(request); - await assertAgentEditRouteAllowedFromFormData(formData); const idempotencyKey = readIdempotencyKey(request.headers); const requestSnapshot = await snapshotAgentEditFormData(formData); const store = await ensureAgentStateStoreReady(); diff --git a/src/app/api/images/route-test-helpers.ts b/src/app/api/images/route-test-helpers.ts index 9f68ecf501eff3c9d3d69e24c8f2ca47b72fea44..ec9ba9dfc5eb18bf55b33e4def261fffa9520256 100644 --- a/src/app/api/images/route-test-helpers.ts +++ b/src/app/api/images/route-test-helpers.ts @@ -14,10 +14,19 @@ export function imageFormRequest(input: { mode?: 'generate' | 'edit'; imageBackend?: 'images' | 'responses' | 'images-api' | 'responses-image-generation'; imageStreamingStrategy?: 'off' | 'auto' | 'openai-sse' | 'newapi-keepalive-sse' | 'responses-sse' | 'force-sse'; + imageStreamingStrategyField?: 'imageStreamingStrategy' | 'image_streaming_strategy'; + streamMode?: 'auto' | 'stream' | 'non_stream'; size?: string; n?: string; responsesModel?: string; + outputFormat?: 'png' | 'jpeg' | 'webp'; + outputCompression?: string; + promptOptimization?: string; + gptModel?: string; + thinking?: string; + forceWeb?: string; clientRequestId?: string; + signal?: AbortSignal; }): NextRequest { const formData = new FormData(); formData.append('mode', input.mode || 'generate'); @@ -25,15 +34,33 @@ export function imageFormRequest(input: { formData.append('model', 'gpt-image-2'); formData.append('n', input.n || '1'); formData.append('size', input.size || '1024x1024'); - formData.append('output_format', 'png'); + formData.append('output_format', input.outputFormat || 'png'); formData.append('apiBaseUrl', input.apiBaseUrl); formData.append('apiKey', input.apiKey); formData.append('clientRequestId', input.clientRequestId ?? 'client-route-stream'); + if (input.outputCompression) { + formData.append('output_compression', input.outputCompression); + } + if (input.promptOptimization) { + formData.append('promptOptimization', input.promptOptimization); + } + if (input.gptModel) { + formData.append('gptModel', input.gptModel); + } + if (input.thinking) { + formData.append('thinking', input.thinking); + } + if (input.forceWeb) { + formData.append('forceWeb', input.forceWeb); + } if (input.imageBackend) { formData.append('imageBackend', input.imageBackend); } if (input.imageStreamingStrategy) { - formData.append('imageStreamingStrategy', input.imageStreamingStrategy); + formData.append(input.imageStreamingStrategyField || 'imageStreamingStrategy', input.imageStreamingStrategy); + } + if (input.streamMode) { + formData.append('stream_mode', input.streamMode); } if (input.responsesModel) { formData.append('responsesModel', input.responsesModel); @@ -47,7 +74,8 @@ export function imageFormRequest(input: { } return new Request('http://localhost/api/images', { method: 'POST', - body: formData + body: formData, + signal: input.signal }) as NextRequest; } @@ -85,10 +113,21 @@ export async function startStreamingImageUpstream( } export async function startImagesJsonUpstream( - handler: (body: string, url: string) => Promise + handler: (body: string, url: string) => Promise ): Promise<{ baseUrl: string; close: () => Promise }> { const server = http.createServer(async (request, response) => { const isImagePath = request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits'); + if (request.method === 'GET') { + const payload = await handler('', request.url || ''); + if (Buffer.isBuffer(payload)) { + response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(payload.byteLength) }); + response.end(payload); + return; + } + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } if (request.method !== 'POST' || !isImagePath) { response.writeHead(404, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'not found' } })); @@ -98,12 +137,112 @@ export async function startImagesJsonUpstream( request.on('data', (chunk: Buffer) => chunks.push(chunk)); await new Promise((resolve) => request.on('end', resolve)); const payload = await handler(Buffer.concat(chunks).toString('utf8'), request.url || ''); + if (Buffer.isBuffer(payload)) { + response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(payload.byteLength) }); + response.end(payload); + return; + } response.writeHead(200, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(payload)); }); return listen(server); } +export async function startImagesStreamFallbackUpstream(): Promise<{ + baseUrl: string; + calls: Array<{ stream?: boolean; partial_images?: number }>; + close: () => Promise; +}> { + const calls: Array<{ stream?: boolean; partial_images?: number }> = []; + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + stream?: boolean; + partial_images?: number; + }; + calls.push({ stream: payload.stream, partial_images: payload.partial_images }); + if (payload.stream) { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: image_generation.partial_image\ndata: ${JSON.stringify({ + type: 'image_generation.partial_image', + b64_json: 'partial-before-fallback' + })}\n\n` + ); + response.write('data: [DONE]\n\n'); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] })); + }); + const result = await listen(server); + return { ...result, calls }; +} + +export async function startHangingImagesStreamUpstream(): Promise<{ + baseUrl: string; + calls: Array<{ stream?: boolean; partial_images?: number }>; + waitForStreamRequest: () => Promise; + close: () => Promise; +}> { + const calls: Array<{ stream?: boolean; partial_images?: number }> = []; + const activeResponses = new Set(); + let resolveStreamRequest: () => void = () => {}; + const streamRequest = new Promise((resolve) => { + resolveStreamRequest = resolve; + }); + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + stream?: boolean; + partial_images?: number; + }; + calls.push({ stream: payload.stream, partial_images: payload.partial_images }); + if (!payload.stream) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] })); + return; + } + activeResponses.add(response); + response.on('close', () => activeResponses.delete(response)); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: image_generation.partial_image\ndata: ${JSON.stringify({ + type: 'image_generation.partial_image', + b64_json: 'partial-before-abort' + })}\n\n` + ); + resolveStreamRequest(); + }); + const result = await listen(server); + return { + ...result, + calls, + waitForStreamRequest: () => streamRequest, + close: async () => { + for (const response of activeResponses) { + response.destroy(); + } + await result.close(); + } + }; +} + export async function startResponsesImageUpstream( handler: (body: string) => Promise ): Promise<{ baseUrl: string; close: () => Promise }> { @@ -123,6 +262,45 @@ export async function startResponsesImageUpstream( return listen(server); } +export async function startResponsesStreamFailureThenJsonUpstream(): Promise<{ + baseUrl: string; + calls: Array<{ stream?: boolean }>; + close: () => Promise; +}> { + const calls: Array<{ stream?: boolean }> = []; + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { stream?: boolean }; + calls.push({ stream: payload.stream }); + if (payload.stream) { + response.writeHead(500, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'stream setup failed' } })); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }) + ); + }); + const result = await listen(server); + return { ...result, calls }; +} + export async function startStreamingResponsesImageUpstream( handler: (body: string) => Promise> ): Promise<{ baseUrl: string; close: () => Promise }> { diff --git a/src/app/api/images/route.test.ts b/src/app/api/images/route.test.ts index 3331ae07856188e27e7244d2589882a8cf44bb4e..a67a82a0ac8820886297167d17c48406b61a41f2 100644 --- a/src/app/api/images/route.test.ts +++ b/src/app/api/images/route.test.ts @@ -2,8 +2,11 @@ import { PNG_BASE64, imageFormRequest, readSseEvents, + startHangingImagesStreamUpstream, startImagesJsonUpstream, + startImagesStreamFallbackUpstream, startResponsesImageUpstream, + startResponsesStreamFailureThenJsonUpstream, startStreamingResponsesImageUpstream, startStreamingImageUpstream } from './route-test-helpers'; @@ -32,8 +35,15 @@ beforeEach(() => { delete process.env.APP_PASSWORD; delete process.env.OPENAI_API_KEY; delete process.env.OPENAI_API_BASE_URL; + delete process.env.OPENAI_CHANNEL_1_ID; delete process.env.OPENAI_CHANNEL_1_API_KEYS; delete process.env.OPENAI_CHANNEL_1_BASE_URL; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK; + delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY; + delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS; delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND; delete process.env.IMAGE_GENERATION_BACKEND; delete process.env.OPENAI_RESPONSES_API_MODEL; @@ -137,6 +147,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => { const { POST } = await import('./route'); let upstreamBody = ''; const upstream = await startImagesJsonUpstream(async (body) => { + if (!body) return { ok: true }; upstreamBody = body; return { data: [{ b64_json: PNG_BASE64 }] }; }); @@ -169,6 +180,113 @@ describe('POST /api/images streaming', { concurrency: false }, () => { } }); + it('falls back from auto streaming without a final image and skips streaming for the same mark', async () => { + const { POST } = await import('./route'); + const { getServerChannelState } = await import('@/lib/server-channel-router'); + const upstream = await startImagesStreamFallbackUpstream(); + const otherUpstream = await startImagesStreamFallbackUpstream(); + + try { + const first = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + streamMode: 'auto', + clientRequestId: 'client-route-auto-fallback-1' + }) + ); + + assert.equal(first.status, 200); + assert.equal(first.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(first); + assert.deepEqual( + events.map((event) => event.type), + ['partial_image', 'completed', 'done'] + ); + assert.equal(events[2].fallback_used, true); + assert.deepEqual( + upstream.calls.map((call) => call.stream), + [true, false] + ); + assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 1); + + const second = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + streamMode: 'auto', + clientRequestId: 'client-route-auto-fallback-2' + }) + ); + + assert.equal(second.status, 200); + assert.notEqual(second.headers.get('content-type'), 'text/event-stream'); + const body = (await second.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + assert.deepEqual( + upstream.calls.map((call) => call.stream), + [true, false, false] + ); + + const third = await POST( + imageFormRequest({ + apiBaseUrl: otherUpstream.baseUrl, + apiKey: 'test-key', + streamMode: 'auto', + clientRequestId: 'client-route-auto-fallback-3' + }) + ); + + assert.equal(third.status, 200); + assert.equal(third.headers.get('content-type'), 'text/event-stream'); + await readSseEvents(third); + assert.deepEqual( + otherUpstream.calls.map((call) => call.stream), + [true, false] + ); + } finally { + await upstream.close(); + await otherUpstream.close(); + } + }); + + it('does not mark auto streaming unavailable when the page SSE request is aborted', async () => { + const { POST } = await import('./route'); + const { getServerChannelState } = await import('@/lib/server-channel-router'); + const upstream = await startHangingImagesStreamUpstream(); + const abortController = new AbortController(); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + streamMode: 'auto', + clientRequestId: 'client-route-auto-abort', + signal: abortController.signal + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const reader = response.body?.getReader(); + assert.ok(reader); + await upstream.waitForStreamRequest(); + abortController.abort(); + await reader.cancel(); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.deepEqual( + upstream.calls.map((call) => call.stream), + [true] + ); + assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0); + } finally { + abortController.abort(); + await upstream.close(); + } + }); + it('returns an explicit SSE error when the upstream completed event has no image payload', async () => { const { POST } = await import('./route'); const upstream = await startStreamingImageUpstream(async () => [ @@ -285,6 +403,48 @@ describe('POST /api/images streaming', { concurrency: false }, () => { } }); + it('accepts snake_case image streaming strategy on page SSE edit requests', async () => { + const { POST } = await import('./route'); + let upstreamUrl = ''; + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream(async (body, url) => { + upstreamUrl = url; + upstreamBody = body; + return [ + { + event: 'image_edit.completed', + data: { type: 'image_edit.completed', b64_json: PNG_BASE64 } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + stream: true, + imageStreamingStrategy: 'force-sse', + imageStreamingStrategyField: 'image_streaming_strategy' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal(upstreamUrl, '/v1/images/edits'); + assert.match(upstreamBody, /name="stream"/); + assert.match(upstreamBody, /name="partial_images"/); + } finally { + await upstream.close(); + } + }); + it('rejects explicit image stream requests when the server strategy disables streaming', async () => { process.env.IMAGE_STREAMING_STRATEGY = 'off'; const { POST } = await import('./route'); @@ -435,12 +595,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => { it('rejects the experimental Responses API backend when the feature flag is disabled', async () => { const { POST } = await import('./route'); const response = await POST( - imageFormRequest({ - apiBaseUrl: 'http://127.0.0.1:1/v1', - apiKey: 'test-key', - stream: false, - imageBackend: 'responses' - }) + imageFormRequest({ + apiBaseUrl: 'http://127.0.0.1:1/v1', + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses' + }) ); assert.equal(response.status, 400); @@ -452,12 +613,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => { process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; const { POST } = await import('./route'); const response = await POST( - imageFormRequest({ - apiBaseUrl: 'http://127.0.0.1:1/v1', - apiKey: 'test-key', - stream: false, - imageBackend: 'responses' - }) + imageFormRequest({ + apiBaseUrl: 'http://127.0.0.1:1/v1', + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses' + }) ); assert.equal(response.status, 400); @@ -471,28 +633,36 @@ describe('POST /api/images streaming', { concurrency: false }, () => { const { POST } = await import('./route'); const multiImage = await POST( - imageFormRequest({ - apiBaseUrl: 'http://127.0.0.1:1/v1', - apiKey: 'test-key', - stream: false, - imageBackend: 'responses', - n: '2' - }) + imageFormRequest({ + apiBaseUrl: 'http://127.0.0.1:1/v1', + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses', + n: '2' + }) ); assert.equal(multiImage.status, 400); assert.match(String(((await multiImage.json()) as Record).error), /单张生成/); + }); + it('rejects multi-image edit requests for the Responses API backend before contacting upstream', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); const edit = await POST( - imageFormRequest({ - apiBaseUrl: 'http://127.0.0.1:1/v1', - apiKey: 'test-key', - stream: false, - imageBackend: 'responses', - mode: 'edit' - }) + imageFormRequest({ + apiBaseUrl: 'http://127.0.0.1:1/v1', + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses', + n: '2', + mode: 'edit' + }) ); assert.equal(edit.status, 400); - assert.match(String(((await edit.json()) as Record).error), /只支持 generate/); + assert.match(String(((await edit.json()) as Record).error), /单张编辑/); }); it('uses the Responses API image backend only when the flag and request opt-in are both present', async () => { @@ -520,6 +690,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => { apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', stream: false, + streamMode: 'non_stream', imageBackend: 'responses-image-generation' }) ); @@ -565,6 +736,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => { apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', stream: false, + streamMode: 'non_stream', imageBackend: 'responses-image-generation' }) ); @@ -588,13 +760,14 @@ describe('POST /api/images streaming', { concurrency: false }, () => { imageFormRequest({ apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', - stream: false + stream: false, + streamMode: 'non_stream' }) ); assert.equal(response.status, 502); const body = (await response.json()) as Record; - assert.match(String(body.error), /base64/); + assert.match(String(body.error), /同源/); assert.equal(JSON.stringify(body).includes('https://example.test/final.png'), false); } finally { await upstream.close(); @@ -841,7 +1014,8 @@ describe('POST /api/images streaming', { concurrency: false }, () => { imageFormRequest({ apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', - stream: false + stream: false, + streamMode: 'non_stream' }) ); @@ -856,12 +1030,68 @@ describe('POST /api/images streaming', { concurrency: false }, () => { } }); + it('downloads same-origin GPT2Image URL results before persisting Images API JSON responses', async () => { + const { POST } = await import('./route'); + let imageDownloadCount = 0; + const upstream = await startImagesJsonUpstream(async (_body, url) => { + if (url === '/generated/final.png') { + imageDownloadCount += 1; + return Buffer.from(PNG_BASE64, 'base64'); + } + return { data: [{ url: '/generated/final.png' }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream' + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + assert.equal(imageDownloadCount, 1); + } finally { + await upstream.close(); + } + }); + + it('rejects cross-origin GPT2Image URL results before downloading them', async () => { + const { POST } = await import('./route'); + const upstream = await startImagesJsonUpstream(async () => { + return { data: [{ url: 'https://other.example.test/generated/final.png' }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream' + }) + ); + + assert.equal(response.status, 502); + const body = (await response.json()) as Record; + assert.match(String(body.error), /同源/); + } finally { + await upstream.close(); + } + }); + it('does not apply the generate backend env default to edit requests', async () => { process.env.IMAGE_GENERATION_BACKEND = 'responses'; const { POST } = await import('./route'); let upstreamUrl = ''; const upstream = await startImagesJsonUpstream(async (_body, url) => { - upstreamUrl = url; + if (url !== '/api/log/token') { + upstreamUrl = url; + } return { data: [{ b64_json: PNG_BASE64 }] }; }); @@ -871,7 +1101,8 @@ describe('POST /api/images streaming', { concurrency: false }, () => { apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', mode: 'edit', - stream: false + stream: false, + streamMode: 'non_stream' }) ); @@ -953,6 +1184,22 @@ describe('POST /api/images streaming', { concurrency: false }, () => { } }); + it('rejects remote plain-http API base URLs before forwarding API keys', async () => { + const { POST } = await import('./route'); + + const response = await POST( + imageFormRequest({ + apiBaseUrl: 'http://api.example.com/v1', + apiKey: 'test-key', + stream: false + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as Record; + assert.match(String(body.error), /远程 HTTP API URL/); + }); + it('treats blank APP_PASSWORD as disabled for page SSE auth', async () => { process.env.APP_PASSWORD = ' '; const { POST } = await import('./route'); @@ -1010,7 +1257,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => { } }); - it('rejects invalid gpt-image-2 custom sizes before contacting upstream', async () => { + it('rejects invalid gpt-image-2 custom size boundaries before contacting upstream', async () => { const { POST } = await import('./route'); let upstreamCalls = 0; const upstream = await startImagesJsonUpstream(async () => { @@ -1018,26 +1265,70 @@ describe('POST /api/images streaming', { concurrency: false }, () => { return { data: [{ b64_json: PNG_BASE64 }] }; }); + try { + for (const { size, pattern } of [ + { size: '512x512', pattern: /至少/ }, + { size: '3840x3840', pattern: /不能超过/ }, + { size: '2049x2048', pattern: /16 的倍数/ } + ]) { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + size + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as Record; + assert.match(String(body.error), /size 对 gpt-image-2 无效/); + assert.match(String(body.error), pattern); + } + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('lets request responsesModel override the experimental backend env model', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startResponsesImageUpstream(async (body) => { + upstreamBody = body; + return { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }; + }); + try { const response = await POST( imageFormRequest({ apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', - size: '2049x2048' + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses', + responsesModel: 'gpt-4.1-request' }) ); - assert.equal(response.status, 400); - const body = (await response.json()) as Record; - assert.match(String(body.error), /size 对 gpt-image-2 无效/); - assert.match(String(body.error), /16 的倍数/); - assert.equal(upstreamCalls, 0); + assert.equal(response.status, 200); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-4.1-request'); } finally { await upstream.close(); } }); - it('lets request responsesModel override the experimental backend env model', async () => { + it('passes GPT2Image-compatible extended fields to the Responses image backend', async () => { process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env'; const { POST } = await import('./route'); @@ -1061,14 +1352,178 @@ describe('POST /api/images streaming', { concurrency: false }, () => { apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', stream: false, + streamMode: 'non_stream', imageBackend: 'responses', - responsesModel: 'gpt-4.1-request' + size: '1536x864', + outputFormat: 'webp', + outputCompression: '85', + promptOptimization: 'false', + gptModel: 'gpt-5.4-mini', + thinking: 'high' }) ); assert.equal(response.status, 200); const upstreamJson = JSON.parse(upstreamBody) as Record; - assert.equal(upstreamJson.model, 'gpt-4.1-request'); + assert.equal(upstreamJson.model, 'gpt-5.4-mini'); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].size, '1536x864'); + assert.equal(tools[0].output_format, 'webp'); + assert.equal(tools[0].output_compression, 85); + assert.equal(tools[0].prompt_optimization, false); + assert.equal(tools[0].thinking, 'high'); + } finally { + await upstream.close(); + } + }); + + it('passes GPT2Image-compatible edit fields to the Images API backend', async () => { + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startImagesJsonUpstream(async (body) => { + if (!body) return { ok: true }; + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + stream: false, + streamMode: 'non_stream', + outputFormat: 'webp', + outputCompression: '85', + forceWeb: 'true' + }) + ); + + assert.equal(response.status, 200); + assert.match(upstreamBody, /name="output_format"/); + assert.match(upstreamBody, /\r\nwebp\r\n/); + assert.match(upstreamBody, /name="output_compression"/); + assert.match(upstreamBody, /\r\n85\r\n/); + assert.match(upstreamBody, /name="force_web"/); + assert.match(upstreamBody, /\r\ntrue\r\n/); + assert.match(upstreamBody, /name="moderation"/); + assert.match(upstreamBody, /\r\nauto\r\n/); + } finally { + await upstream.close(); + } + }); + + it('passes reference images and GPT2Image-compatible fields to the Responses edit backend', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startResponsesImageUpstream(async (body) => { + upstreamBody = body; + return { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses', + size: '1536x864', + outputFormat: 'webp', + outputCompression: '85', + promptOptimization: 'false', + gptModel: 'gpt-5.4-mini', + thinking: 'high' + }) + ); + + assert.equal(response.status, 200); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-5.4-mini'); + assert.equal(upstreamJson.stream, false); + const input = upstreamJson.input as Array>; + assert.equal(input[0].role, 'user'); + const content = input[0].content as Array>; + assert.equal(content[0].type, 'input_text'); + assert.equal(content[0].text, 'route stream contract'); + assert.equal(content[1].type, 'input_image'); + assert.match(String(content[1].image_url), /^data:image\/png;base64,/); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].size, '1536x864'); + assert.equal(tools[0].output_format, 'webp'); + assert.equal(tools[0].output_compression, 85); + assert.equal(tools[0].prompt_optimization, false); + assert.equal(tools[0].thinking, 'high'); + } finally { + await upstream.close(); + } + }); + + it('falls back when Responses edit stream setup fails before returning SSE', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + const upstream = await startResponsesStreamFailureThenJsonUpstream(); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + streamMode: 'auto', + imageBackend: 'responses' + }) + ); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /application\/json/); + const body = (await response.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + assert.equal(upstream.calls[0]?.stream, true); + assert.equal(upstream.calls[upstream.calls.length - 1]?.stream, false); + } finally { + await upstream.close(); + } + }); + + it('passes GPT2Image force_web aliases through to the Images API backend', async () => { + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startImagesJsonUpstream(async (body) => { + if (!body) return { ok: true }; + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false, + streamMode: 'non_stream', + forceWeb: 'true' + }) + ); + + assert.equal(response.status, 200); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.force_web, true); } finally { await upstream.close(); } diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts index 79f2c87682bb79cba4c34bc61f235dd1d588c655..02e8da1413b84496f827c4e29003831f40524f99 100644 --- a/src/app/api/images/route.ts +++ b/src/app/api/images/route.ts @@ -6,6 +6,7 @@ import { readCount, readMode, readModel, + readPlainHttpApiBaseUrlAllowlist, readRequiredText, readStorageMode, validateApiBaseUrl @@ -30,22 +31,147 @@ import { } from '@/lib/image-service'; import { readImageGenerationBackend, + readImageStreamMode, readImageStreamingStrategy, - resolveImageStreamEnabled + resolveImageStreamEnabled, + type ImageGenerationBackend, + type ImageStreamMode, + type ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { getServerChannelState } from '@/lib/server-channel-router'; +import type { StreamingAvailabilityKey, StreamingOperation } from '@/lib/streaming-availability'; import { buildAccessCookie, readAffinityKey, verifyPasswordHash } from '@/lib/server-runtime'; +import crypto from 'crypto'; import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; +type StreamResolutionInput = { + streamMode: ImageStreamMode; + imageBackend: ImageGenerationBackend; + streamingStrategy: ImageStreamingStrategy; + operation: StreamingOperation; + selectedCredential?: ChannelCredential; + sourceId?: string; +}; + +type StreamResolution = { + availabilityKey: StreamingAvailabilityKey; + streamEnabled: boolean; + streamFallbackEnabled: boolean; + streamingMarkedUnavailable: boolean; +}; + +function readErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) return undefined; + if ('status' in error && typeof error.status === 'number') return error.status; + if ('statusCode' in error && typeof error.statusCode === 'number') return error.statusCode; + return undefined; +} + +function readErrorCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null) return undefined; + if ('code' in error && typeof error.code === 'string') return error.code; + if ('error' in error && typeof error.error === 'object' && error.error !== null) { + const nested = error.error as Record; + return typeof nested.code === 'string' ? nested.code : undefined; + } + return undefined; +} + +function createAvailabilityKey(input: StreamResolutionInput): StreamingAvailabilityKey { + return { + channelId: input.selectedCredential?.channelId, + sourceId: input.selectedCredential ? undefined : input.sourceId, + imageBackend: input.imageBackend, + streamingStrategy: input.streamingStrategy, + operation: input.operation + }; +} + +function createAvailabilitySourceId(input: { selectedCredential?: ChannelCredential; baseUrl?: string }): string | undefined { + if (input.selectedCredential) return undefined; + const normalized = normalizeAvailabilityBaseUrl(input.baseUrl); + const digest = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16); + return `upstream:${digest}`; +} + +function normalizeAvailabilityBaseUrl(baseUrl: string | undefined): string { + const rawValue = baseUrl && baseUrl.trim() ? baseUrl.trim() : 'https://api.openai.com/v1'; + try { + const parsed = new URL(rawValue); + const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase(); + } catch { + return 'invalid-upstream'; + } +} + +function resolvePageStream(input: StreamResolutionInput): StreamResolution { + const availabilityKey = createAvailabilityKey(input); + const streamingAvailability = getServerChannelState().streamingAvailability; + if (input.streamMode === 'non_stream') { + return { + availabilityKey, + streamEnabled: false, + streamFallbackEnabled: false, + streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey) + }; + } + + if (input.streamMode === 'auto' && input.streamingStrategy === 'off') { + return { + availabilityKey, + streamEnabled: false, + streamFallbackEnabled: false, + streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey) + }; + } + + if (input.streamMode === 'auto' && streamingAvailability.isUnavailable(availabilityKey)) { + return { + availabilityKey, + streamEnabled: false, + streamFallbackEnabled: false, + streamingMarkedUnavailable: true + }; + } + + return { + availabilityKey, + streamEnabled: resolveImageStreamEnabled({ + imageBackend: input.imageBackend, + requestedStream: true, + streamingStrategy: input.streamingStrategy + }), + streamFallbackEnabled: input.streamMode === 'auto', + streamingMarkedUnavailable: false + }; +} + +function markStreamingUnavailable(input: { + key: StreamingAvailabilityKey; + error?: unknown; + reason: string; + status?: number; +}) { + const status = input.status ?? readErrorStatus(input.error); + getServerChannelState().streamingAvailability.markUnavailable({ + ...input.key, + reason: input.reason, + ...(status !== undefined ? { status } : {}), + ...(readErrorCode(input.error) ? { code: readErrorCode(input.error) } : {}) + }); +} + export async function POST(request: NextRequest) { let selectedServerCredential: ChannelCredential | undefined; let clientRequestId: string | undefined; let requestLogContext: RequestLogContext | undefined; let accessCookie: AccessCookie | undefined; try { - const serverChannelRouter = getServerChannelState().router; + const serverChannelState = getServerChannelState(); + const serverChannelRouter = serverChannelState.router; const contentType = request.headers.get('content-type') || ''; if ( !contentType.includes('multipart/form-data') && @@ -58,8 +184,11 @@ export async function POST(request: NextRequest) { requestLogContext = clientRequestId ? { clientRequestId } : undefined; const requestApiKey = String(formData.get('apiKey') || '').trim(); const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim(); + const allowedPlainHttpBaseUrls = readPlainHttpApiBaseUrlAllowlist( + process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS + ); assertSafeApiOverride(requestApiKey, requestApiBaseUrl); - validateApiBaseUrl(requestApiBaseUrl); + validateApiBaseUrl(requestApiBaseUrl, { allowedPlainHttpBaseUrls }); selectedServerCredential = requestApiKey ? undefined : serverChannelRouter?.select({ affinityKey: readAffinityKey(request.headers) }); @@ -73,7 +202,7 @@ export async function POST(request: NextRequest) { legacyBaseUrl: process.env.OPENAI_API_BASE_URL, selectedCredential: selectedServerCredential }); - validateApiBaseUrl(effectiveApiBaseUrl || ''); + validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls }); if (!effectiveApiKey) { appLogger.error('未设置 OPENAI_API_KEY,且请求未提供 API Key。', requestLogContext); @@ -132,24 +261,34 @@ export async function POST(request: NextRequest) { requestLogContext ); - const requestedStream = formData.get('stream') === 'true'; + const streamMode = readImageStreamMode(formData, process.env); const partialImagesCount = readCount(formData, 'partial_images', 2, 1, 3) as 1 | 2 | 3; - const imageBackend = readImageGenerationBackend(formData, process.env, { useEnvDefault: mode === 'generate' }); + const imageBackend = readImageGenerationBackend(formData, process.env, { + useEnvDefault: mode === 'generate' + }); const streamingStrategy = readImageStreamingStrategy(formData, process.env, { useEnvDefault: mode === 'generate' }); - const streamEnabled = resolveImageStreamEnabled({ + const streamResolution = resolvePageStream({ + streamMode, imageBackend, - requestedStream, - streamingStrategy + streamingStrategy, + operation: mode, + selectedCredential, + sourceId: createAvailabilitySourceId({ + selectedCredential, + baseUrl: effectiveApiBaseUrl + }) }); assertResponsesImageBackendAllowed({ imageBackend, mode }); appLogger.info('图片上游兼容策略。', { ...requestLogContext, imageBackend, streamingStrategy, - requestedStream, - streamEnabled + streamMode, + streamEnabled: streamResolution.streamEnabled, + streamFallbackEnabled: streamResolution.streamFallbackEnabled, + streamingMarkedUnavailable: streamResolution.streamingMarkedUnavailable }); const modeResult = @@ -159,7 +298,7 @@ export async function POST(request: NextRequest) { openai, model, prompt, - streamEnabled, + streamEnabled: streamResolution.streamEnabled, partialImagesCount, imageBackend, storageMode: effectiveStorageMode, @@ -170,15 +309,25 @@ export async function POST(request: NextRequest) { requestLogContext, selectedCredential, accessCookie, - abortSignal: request.signal + abortSignal: request.signal, + streamFallbackEnabled: streamResolution.streamFallbackEnabled, + onStreamUnavailable: (error, reason) => + markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }), + onStreamingDegraded: (reason) => + markStreamingUnavailable({ + key: streamResolution.availabilityKey, + reason, + status: 200 + }) }) : await handleEditImageMode({ formData, openai, model, prompt, - streamEnabled, + streamEnabled: streamResolution.streamEnabled, partialImagesCount, + imageBackend, storageMode: effectiveStorageMode, apiBaseUrl: effectiveApiBaseUrl, apiKey: effectiveApiKey, @@ -187,7 +336,16 @@ export async function POST(request: NextRequest) { requestLogContext, selectedCredential, accessCookie, - abortSignal: request.signal + abortSignal: request.signal, + streamFallbackEnabled: streamResolution.streamFallbackEnabled, + onStreamUnavailable: (error, reason) => + markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }), + onStreamingDegraded: (reason) => + markStreamingUnavailable({ + key: streamResolution.availabilityKey, + reason, + status: 200 + }) }); if (modeResult instanceof Response) { return modeResult; @@ -201,7 +359,10 @@ export async function POST(request: NextRequest) { result, outputFormat: responseOutputFormat, storageMode: effectiveStorageMode, - includeBase64: true + includeBase64: true, + apiBaseUrl: effectiveApiBaseUrl, + apiKey: effectiveApiKey, + abortSignal: request.signal }); const savedImagesData = savedImages.map((image) => ({ ...persistedImageToLegacyResponse(image), diff --git a/src/app/api/runtime-capabilities/route.test.ts b/src/app/api/runtime-capabilities/route.test.ts index 10fb95b07d3612f429739fe90acb3d1ef34b2e25..699dba2629bdb36f2c1dbdef4919b11273fa0b07 100644 --- a/src/app/api/runtime-capabilities/route.test.ts +++ b/src/app/api/runtime-capabilities/route.test.ts @@ -17,24 +17,149 @@ function restoreProcessEnv(snapshot: NodeJS.ProcessEnv) { beforeEach(() => { originalEnv = { ...process.env }; + process.env.npm_lifecycle_event = 'test'; delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND; + delete process.env.OPENAI_RESPONSES_API_MODEL; + delete process.env.IMAGE_STREAMING_STRATEGY; + delete process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_BASE_URL; + delete process.env.OPENAI_CHANNEL_1_ID; + delete process.env.OPENAI_CHANNEL_1_API_KEYS; + delete process.env.OPENAI_CHANNEL_1_BASE_URL; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS; + delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK; + delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY; + delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS; }); -afterEach(() => { +afterEach(async () => { + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); restoreProcessEnv(originalEnv); }); -describe('GET /api/runtime-capabilities', () => { - it('exposes the experimental Responses image backend flag without enabling it by default', async () => { +describe('GET /api/runtime-capabilities', { concurrency: false }, () => { + it('exposes streaming batch capability by default without the removed env gate', async () => { const { GET } = await import('./route'); - const disabled = (await (await GET()).json()) as Record; + const body = (await (await GET()).json()) as Record; + + assert.equal(body.streamingBatch.enabled, true); + assert.equal(typeof body.streamingBatch.recommendedConcurrency, 'number'); + }); + + it('exposes the runtime default streaming strategy for client-side fanout decisions', async () => { + process.env.IMAGE_STREAMING_STRATEGY = 'off'; + const { GET } = await import('./route'); + + const body = (await (await GET()).json()) as Record; + + assert.equal(body.streaming.defaultMode, 'non_stream'); + assert.equal(body.streaming.defaultStrategy, 'off'); + }); + + it('exposes recovery probe settings without API keys', async () => { + process.env.OPENAI_CHANNEL_1_ID = 'official'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://api.openai.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-secret'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS = '120000'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS = '3000'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK = '1'; + const { GET } = await import('./route'); + + const body = (await (await GET()).json()) as { + channelRecovery: { + requireProbeForRecovery: boolean; + pendingProbeCredentialCount: number; + pendingProbeChannelCount: number; + probe: { + enabled: boolean; + intervalMs: number; + timeoutMs: number; + maxPerTick: number; + running: boolean; + }; + }; + }; + + assert.deepEqual(body.channelRecovery, { + requireProbeForRecovery: true, + pendingProbeCredentialCount: 0, + pendingProbeChannelCount: 0, + probe: { + enabled: true, + intervalMs: 120000, + timeoutMs: 3000, + maxPerTick: 1, + running: false, + pendingProbeCount: 0, + dueCandidateCount: 0, + estimatedMinimumDrainTickCount: 0, + estimatedMinimumDrainMs: 0, + lastCheckedCount: 0, + lastRecoveredCount: 0, + lastFailedCount: 0 + } + }); + assert.equal(JSON.stringify(body).includes('sk-secret'), false); + }); + + it('rejects requiring recovery probes when the prober is disabled', async () => { + process.env.OPENAI_CHANNEL_1_ID = 'official'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://api.openai.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-secret'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'true'; + const { GET } = await import('./route'); + + const response = await GET(); + const body = (await response.json()) as { error?: string }; + + assert.equal(response.status, 500); + assert.match(body.error || '', /OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY/); + assert.equal(JSON.stringify(body).includes('sk-secret'), false); + }); + + it('exposes the experimental Responses image backend when the backend flag is enabled', async () => { + const { GET } = await import('./route'); + + const disabled = (await (await GET()).json()) as Record< + string, + { + enabled: boolean; + mode?: string; + requiredEnv?: string[]; + optionalEnv?: string[]; + hasDefaultModel?: boolean; + missingEnv?: string[]; + } + >; assert.equal(disabled.responsesImageBackend.enabled, false); assert.equal(disabled.responsesImageBackend.mode, 'experimental'); + assert.deepEqual(disabled.responsesImageBackend.requiredEnv, ['ENABLE_RESPONSES_IMAGE_BACKEND']); + assert.deepEqual(disabled.responsesImageBackend.optionalEnv, ['OPENAI_RESPONSES_API_MODEL']); + assert.equal(disabled.responsesImageBackend.hasDefaultModel, false); + assert.deepEqual(disabled.responsesImageBackend.missingEnv, ['ENABLE_RESPONSES_IMAGE_BACKEND']); process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; - const enabled = (await (await GET()).json()) as Record; + const requestModelAllowed = (await (await GET()).json()) as Record< + string, + { enabled: boolean; mode?: string; hasDefaultModel?: boolean; missingEnv?: string[] } + >; + assert.equal(requestModelAllowed.responsesImageBackend.enabled, true); + assert.equal(requestModelAllowed.responsesImageBackend.hasDefaultModel, false); + assert.deepEqual(requestModelAllowed.responsesImageBackend.missingEnv, []); + + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const enabled = (await (await GET()).json()) as Record< + string, + { enabled: boolean; mode?: string; hasDefaultModel?: boolean; missingEnv?: string[] } + >; assert.equal(enabled.responsesImageBackend.enabled, true); assert.equal(enabled.responsesImageBackend.mode, 'experimental'); + assert.equal(enabled.responsesImageBackend.hasDefaultModel, true); + assert.deepEqual(enabled.responsesImageBackend.missingEnv, []); }); }); diff --git a/src/app/api/runtime-capabilities/route.ts b/src/app/api/runtime-capabilities/route.ts index c95b72c6f5e370e3e7d52fb87dda5d9eed85a56f..8edaf83d0192fcf172a68cd777a255b6a5b1530a 100644 --- a/src/app/api/runtime-capabilities/route.ts +++ b/src/app/api/runtime-capabilities/route.ts @@ -1,17 +1,22 @@ import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router'; +import { readImageStreamMode, readImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { getServerChannelState } from '@/lib/server-channel-router'; import { computeStreamingBatchRecommendation } from '@/lib/streaming-batch'; import { readBooleanEnv, readPositiveIntegerEnv } from '@/lib/server-runtime'; import { NextResponse } from 'next/server'; +const RESPONSES_IMAGE_BACKEND_REQUIRED_ENV = ['ENABLE_RESPONSES_IMAGE_BACKEND'] as const; +const RESPONSES_IMAGE_BACKEND_OPTIONAL_ENV = ['OPENAI_RESPONSES_API_MODEL'] as const; + export async function GET() { try { const serverChannelState = getServerChannelState(); const summary = getChannelPoolSummary(serverChannelState.config); const healthSummary = serverChannelState.router?.getHealthSummary(); const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1); - const streamingBatchEnabled = readBooleanEnv(process.env, 'ENABLE_STREAMING_BATCH'); const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND'); + const responsesImageBackendHasDefaultModel = Boolean(process.env.OPENAI_RESPONSES_API_MODEL?.trim()); + const responsesImageBackendMissingEnv = readResponsesImageBackendMissingEnv(process.env); const recommendedStreamingConcurrency = computeStreamingBatchRecommendation({ credentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount, maxStreamsPerCredential, @@ -19,8 +24,14 @@ export async function GET() { }); return NextResponse.json({ + streaming: { + defaultMode: readImageStreamMode(new FormData(), process.env), + defaultStrategy: readImageStreamingStrategy(new FormData(), process.env), + unavailableMarkScope: 'channel+backend+strategy+operation', + availability: serverChannelState.streamingAvailability.summary() + }, streamingBatch: { - enabled: streamingBatchEnabled, + enabled: true, recommendedConcurrency: recommendedStreamingConcurrency, requestCredentialConcurrency: maxStreamsPerCredential, healthyCredentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount, @@ -30,9 +41,19 @@ export async function GET() { unhealthyChannelCount: healthSummary?.unhealthyChannelCount ?? 0, lastFailure: toPublicChannelFailure(healthSummary?.lastFailure) }, + channelRecovery: { + requireProbeForRecovery: serverChannelState.channelRecovery.requireProbeForRecovery, + pendingProbeCredentialCount: healthSummary?.pendingRecoveryProbeCredentialCount ?? 0, + pendingProbeChannelCount: healthSummary?.pendingRecoveryProbeChannelCount ?? 0, + probe: serverChannelState.channelRecoveryProber?.summary() + }, responsesImageBackend: { enabled: responsesImageBackendEnabled, - mode: 'experimental' + mode: 'experimental', + requiredEnv: [...RESPONSES_IMAGE_BACKEND_REQUIRED_ENV], + optionalEnv: [...RESPONSES_IMAGE_BACKEND_OPTIONAL_ENV], + hasDefaultModel: responsesImageBackendHasDefaultModel, + missingEnv: responsesImageBackendMissingEnv } }); } catch (error) { @@ -42,3 +63,11 @@ export async function GET() { ); } } + +function readResponsesImageBackendMissingEnv(env: Record): string[] { + const missing: string[] = []; + if (!readBooleanEnv(env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { + missing.push('ENABLE_RESPONSES_IMAGE_BACKEND'); + } + return missing; +} diff --git a/src/app/globals.css b/src/app/globals.css index 0cff399e31d6ede305b405991b4b468b68679686..13861cbb929711f55c0d2883381c6cf4acc58608 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -44,75 +44,74 @@ } :root { - --font-geist-sans: - Arial, Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + --font-geist-sans: Arial, Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; --font-geist-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --radius: 0.5rem; + --background: oklch(0.956 0.023 80); + --foreground: oklch(0.225 0.026 56); + --card: oklch(0.992 0.01 84); + --card-foreground: oklch(0.225 0.026 56); + --popover: oklch(0.996 0.009 84); + --popover-foreground: oklch(0.225 0.026 56); + --primary: oklch(0.615 0.165 30); + --primary-foreground: oklch(0.99 0.016 84); + --secondary: oklch(0.892 0.044 122); + --secondary-foreground: oklch(0.255 0.034 66); + --muted: oklch(0.922 0.027 78); + --muted-foreground: oklch(0.505 0.031 58); + --accent: oklch(0.925 0.052 35); + --accent-foreground: oklch(0.285 0.037 52); + --destructive: oklch(0.56 0.17 25); + --border: oklch(0.858 0.03 74); + --input: oklch(0.858 0.03 74); + --ring: oklch(0.62 0.14 30); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: oklch(0.972 0.018 83); + --sidebar-foreground: oklch(0.205 0.024 58); + --sidebar-primary: oklch(0.48 0.125 38); + --sidebar-primary-foreground: oklch(0.985 0.018 86); + --sidebar-accent: oklch(0.884 0.071 64); + --sidebar-accent-foreground: oklch(0.26 0.035 55); + --sidebar-border: oklch(0.812 0.037 76); + --sidebar-ring: oklch(0.58 0.115 40); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + --background: oklch(0.19 0.025 57); + --foreground: oklch(0.93 0.026 84); + --card: oklch(0.245 0.028 58); + --card-foreground: oklch(0.93 0.026 84); + --popover: oklch(0.245 0.028 58); + --popover-foreground: oklch(0.93 0.026 84); + --primary: oklch(0.742 0.109 50); + --primary-foreground: oklch(0.18 0.023 55); + --secondary: oklch(0.315 0.035 96); + --secondary-foreground: oklch(0.915 0.025 82); + --muted: oklch(0.295 0.028 62); + --muted-foreground: oklch(0.735 0.028 78); + --accent: oklch(0.39 0.054 69); + --accent-foreground: oklch(0.93 0.026 84); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --border: oklch(0.45 0.035 68 / 52%); + --input: oklch(0.55 0.04 72 / 55%); + --ring: oklch(0.742 0.109 50); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar: oklch(0.245 0.028 58); + --sidebar-foreground: oklch(0.93 0.026 84); + --sidebar-primary: oklch(0.742 0.109 50); + --sidebar-primary-foreground: oklch(0.18 0.023 55); + --sidebar-accent: oklch(0.39 0.054 69); + --sidebar-accent-foreground: oklch(0.93 0.026 84); + --sidebar-border: oklch(0.45 0.035 68 / 52%); + --sidebar-ring: oklch(0.742 0.109 50); } @layer base { @@ -123,3 +122,115 @@ @apply bg-background text-foreground; } } + +@layer utilities { + .studio-paper { + background: + linear-gradient(105deg, oklch(0.988 0.013 86 / 0.96), oklch(0.95 0.024 78 / 0.86)), + linear-gradient(180deg, oklch(1 0 0 / 0.42), transparent 34rem), + repeating-linear-gradient( + 90deg, + color-mix(in oklch, var(--border) 14%, transparent) 0 1px, + transparent 1px 14px + ), + repeating-linear-gradient(0deg, oklch(1 0 0 / 0.16) 0 1px, transparent 1px 3px), var(--background); + } + + .paper-soft-shadow { + box-shadow: 0 12px 28px oklch(0.37 0.038 54 / 0.09); + } + + .literary-scrollbar { + scrollbar-color: oklch(0.74 0.028 72 / 0.72) transparent; + scrollbar-width: thin; + } + + .literary-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .literary-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .literary-scrollbar::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background-clip: content-box; + background-color: oklch(0.74 0.028 72 / 0.62); + } + + .scrollbar-none { + scrollbar-width: none; + } + + .scrollbar-none::-webkit-scrollbar { + display: none; + } + + .editorial-title { + font-family: 'Songti SC', 'STSong', 'Noto Serif CJK SC', serif; + letter-spacing: 0; + } + + .workbench-panel { + background: + linear-gradient(180deg, oklch(0.998 0.007 86 / 0.98), oklch(0.976 0.017 82 / 0.92)), + repeating-linear-gradient(90deg, oklch(0.72 0.025 76 / 0.045) 0 1px, transparent 1px 9px), var(--card); + box-shadow: + 0 1px 0 oklch(1 0 0 / 0.72) inset, + 0 14px 35px oklch(0.37 0.036 54 / 0.1); + } + + .photo-paper { + background: oklch(0.99 0.008 85); + box-shadow: + 0 2px 0 oklch(0.82 0.026 74 / 0.48), + 0 18px 38px oklch(0.35 0.036 54 / 0.16); + } + + .photo-paper::before { + content: ''; + position: absolute; + top: -1.25rem; + right: 7%; + z-index: 2; + width: 8rem; + height: 2.1rem; + rotate: 8deg; + border: 1px solid oklch(0.79 0.028 74 / 0.38); + background: + linear-gradient(90deg, oklch(0.9 0.025 75 / 0.82), oklch(0.78 0.024 75 / 0.64)), + repeating-linear-gradient(90deg, oklch(1 0 0 / 0.18) 0 2px, transparent 2px 7px); + box-shadow: 0 4px 10px oklch(0.35 0.036 54 / 0.08); + } + + .preview-gallery-board { + background: + linear-gradient( + 90deg, + transparent calc(50% - 0.5px), + oklch(0.78 0.022 76 / 0.14) 50%, + transparent calc(50% + 0.5px) + ), + linear-gradient( + 0deg, + transparent calc(50% - 0.5px), + oklch(0.78 0.022 76 / 0.1) 50%, + transparent calc(50% + 0.5px) + ), + repeating-linear-gradient(90deg, oklch(0.78 0.022 76 / 0.055) 0 1px, transparent 1px 42px), + repeating-linear-gradient(0deg, oklch(0.78 0.022 76 / 0.045) 0 1px, transparent 1px 42px), + linear-gradient(180deg, oklch(0.995 0.007 85), oklch(0.972 0.016 80)); + } + + .preview-gallery-board::before { + content: ''; + position: absolute; + inset: 1rem; + pointer-events: none; + border: 1px solid oklch(0.79 0.026 74 / 0.16); + border-radius: 6px; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ed43a8b2bc042091f2436beb907b407a844b5e6e..b426352463e1fe42253c0d353ba24df4962d5414 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,8 +4,8 @@ import { I18nProvider } from '@/lib/i18n'; import type { Metadata } from 'next'; export const metadata: Metadata = { - title: 'GPT Image Playground', - description: '使用 OpenAI GPT Image 模型生成和编辑图片。', + title: '图像手记', + description: '面向中文创作者的 AI 图像创作工作台。', icons: { icon: '/favicon.svg' } diff --git a/src/app/page-regressions.test.ts b/src/app/page-regressions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..39040c6dcb13a13e0ab5bf1510b3a83c02984e7e --- /dev/null +++ b/src/app/page-regressions.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +describe('page state regressions', () => { + it('uses the unified batch prompt setter when mobile random inspiration replaces batch text', async () => { + const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8'); + const batchBranch = source.match(/if \(workbenchMode === 'batch'\) \{([\s\S]*?)\n\s*\}/)?.[1]; + + assert.ok(batchBranch, 'missing mobile random inspiration batch branch'); + assert.match(batchBranch, /handleBatchPromptTextChange\(nextPrompt\)/); + assert.doesNotMatch(batchBranch, /setGenBatchPromptText\(nextPrompt\)/); + }); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 58b860a86048864e68b7fc0442b2309b9db7b8f8..3a09b0d2e23a7099e9bcb1a7d0d991969be6f64a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,37 +1,69 @@ 'use client'; import { ApiSettingsDialog, type ApiSettings } from '@/components/api-settings-dialog'; -import { AppControls } from '@/components/app-controls'; -import { EditingForm, type EditingFormData } from '@/components/editing-form'; -import { GenerationForm, type GenerationFormData } from '@/components/generation-form'; -import { HistoryPanel } from '@/components/history-panel'; +import { EditingForm, type EditingFormData, type EditingReuseContext } from '@/components/editing-form'; +import { GenerationForm, type GenerationFormData, type WorkbenchReuseContext } from '@/components/generation-form'; +import { HistoryPanel, type InspirationItem, type PromptApplySource } from '@/components/history-panel'; import { ImageOutput } from '@/components/image-output'; +import type { WorkbenchMode } from '@/components/mode-toggle'; import { PasswordDialog } from '@/components/password-dialog'; import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { WorkbenchProDock } from '@/components/workbench-pro-dock'; +import { WorkbenchStatusStrip } from '@/components/workbench-status-strip'; import { buildApiErrorNotice, buildBatchPartialFailureMessage, buildUserFacingApiErrorMessage, type ApiErrorNotice } from '@/lib/api-error-guidance'; -import { calculateApiCost, type CostDetails, type GptImageModel } from '@/lib/cost-utils'; +import { formatBatchPromptHistory, readBatchPromptLines } from '@/lib/batch-prompts'; import { db, type ImageRecord } from '@/lib/db'; +import { + advanceGenerationBatchProgress, + buildGenerationActivityItems, + collectFailedBatchPrompts, + countCompletedBatchResults, + type GenerationBatchProgress +} from '@/lib/generation-activity'; +import { resolveHistoryCompareImage } from '@/lib/history-compare'; +import { + buildCompletedHistoryEntry, + buildFailedHistoryEntry, + buildHistoryGenerationFormData, + readHistoryImageCountSelection, + readHistorySizeSelection, + resolveHistoryImageClientRequestId, + uniqueStrings, + type HistoryMetadata, + type RequestMode +} from '@/lib/history-metadata'; import { useI18n } from '@/lib/i18n'; import { IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, - appendImageUpstreamOverrideFields + appendImageUpstreamOverrideFields, + isResponsesImageBackendRuntimeEnabled, + normalizeImageUpstreamRuntimeFields, + shouldAllowResponsesHistoryRoute, + shouldBlockResponsesRequestWithoutModel, + shouldBlockExplicitResponsesRequest, + type ImageUpstreamFormBackend } from '@/lib/image-upstream-form'; +import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; +import { resolveMobileCreationSheetGesture } from '@/lib/mobile-creation-sheet-gesture'; +import { resolveMobilePrimaryDisabledReason } from '@/lib/mobile-primary-action-state'; import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth'; -import { createImageShareFromBlob } from '@/lib/share-client'; import { sha256Hex } from '@/lib/sha256'; +import { createImageShareFromBlob } from '@/lib/share-client'; import { getPresetDimensions, validateGptImage2Size } from '@/lib/size-utils'; import { applyStreamingClientEvent, + BatchPausedError, buildStreamingBatchJobs, - isRuntimeStreamingBatchEnabled, + canUseStreamingBatchTransport, resolveStreamingBatchCapacity, + resolveStreamingBatchToggleState, scheduleStreamingBatch, shouldUseStreamingBatch, type ApiImageResponseItem, @@ -39,48 +71,38 @@ import { type StreamingClientEvent, type StreamingClientState } from '@/lib/streaming-batch'; +import { getStreamingStatusLabel } from '@/lib/streaming-status-label'; import type { ActualCostDetails } from '@/lib/upstream-cost/resolve'; +import { formatEstimatedCredits } from '@/lib/workbench-cost-label'; import { useLiveQuery } from 'dexie-react-hooks'; -import { ArrowDown, Loader2, Lock, Terminal } from 'lucide-react'; +import { ArrowUp, Flower2, HelpCircle, Loader2, Lock, Pause, PenLine, Settings2, Activity, X } from 'lucide-react'; import * as React from 'react'; -type HistoryImage = { - filename: string; - clientRequestId?: string; -}; - -export type HistoryMetadata = { - timestamp: number; - images: HistoryImage[]; - storageModeUsed?: 'fs' | 'indexeddb'; - durationMs: number; - quality: GenerationFormData['quality']; - background: GenerationFormData['background']; - moderation: GenerationFormData['moderation']; - prompt: string; - mode: 'generate' | 'edit'; - costDetails: CostDetails | null; - actualCostDetails?: ActualCostDetails; - output_format?: GenerationFormData['output_format']; - model?: GptImageModel; - size?: string; - clientRequestIds?: string[]; -}; - type DrawnPoint = { x: number; y: number; size: number; }; +type MobileDrawerPointerStart = { + x: number; + y: number; +}; + const MAX_EDIT_IMAGES = 10; const apiSettingsLocalStorageKey = 'openaiImageApiSettings'; +const inspirationsLocalStorageKey = 'openaiImageInspirations'; const emptyApiSettings: ApiSettings = { apiKey: '', baseUrl: '' }; const sseEventDelimiterPattern = /\r?\n\r?\n/; -type RequestMode = 'generate' | 'edit'; -type ApiCallRetryArgs = [GenerationFormData | EditingFormData, RequestMode, boolean, 1 | 2 | 3]; +type ApiCallRetryArgs = [GenerationFormData | EditingFormData, RequestMode, ImageStreamMode, 1 | 2 | 3, boolean]; type PasswordVerificationResult = 'valid' | 'invalid' | 'unavailable'; +function getImageBackendLabel(backend: ImageUpstreamFormBackend, t: (key: string) => string): string { + if (backend === 'images-api') return t('upstream.backendImages'); + if (backend === 'responses-image-generation') return t('upstream.backendResponses'); + return t('upstream.workbenchDefaultRoute'); +} + function createClientRequestId(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return `web-${crypto.randomUUID()}`; @@ -88,19 +110,6 @@ function createClientRequestId(): string { return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; } -function uniqueStrings(values: Array): string[] { - return Array.from(new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))); -} - -function resolveHistoryImageClientRequestId(item: HistoryMetadata, imageIndex: number): string | undefined { - const imageRequestId = item.images[imageIndex]?.clientRequestId; - if (imageRequestId) return imageRequestId; - if (!item.clientRequestIds || item.clientRequestIds.length === 0) return undefined; - if (item.clientRequestIds.length === item.images.length) return item.clientRequestIds[imageIndex]; - if (item.images.length === 1) return item.clientRequestIds[0]; - return undefined; -} - function readLocalStorageValue(key: string): string | null { if (typeof window === 'undefined') return null; return window.localStorage.getItem(key); @@ -137,6 +146,42 @@ function readStoredApiSettings(): ApiSettings { } } +type StoredInspirationsReadResult = { + items: InspirationItem[]; + shouldPersist: boolean; + shouldRemove: boolean; +}; + +function readStoredInspirations(): StoredInspirationsReadResult { + const storedInspirations = readLocalStorageValue(inspirationsLocalStorageKey); + if (!storedInspirations) { + return { items: [], shouldPersist: false, shouldRemove: false }; + } + try { + const parsedInspirations: unknown = JSON.parse(storedInspirations); + if (!Array.isArray(parsedInspirations)) { + return { items: [], shouldPersist: false, shouldRemove: true }; + } + const validInspirations = parsedInspirations.filter( + (item): item is InspirationItem => + item !== null && + typeof item === 'object' && + typeof (item as InspirationItem).id === 'number' && + typeof (item as InspirationItem).prompt === 'string' && + typeof (item as InspirationItem).createdAt === 'number' + ); + const migratedInspirations = validInspirations.filter((item) => !(item.id < 0 && item.createdAt === 0)); + return { + items: migratedInspirations, + shouldPersist: migratedInspirations.length !== parsedInspirations.length, + shouldRemove: false + }; + } catch (error) { + console.error('加载或解析灵感相册失败:', error); + return { items: [], shouldPersist: false, shouldRemove: true }; + } +} + function readStoredDeletePreference(): boolean { return readLocalStorageValue('imageGenSkipDeleteConfirm') === 'true'; } @@ -169,8 +214,11 @@ type ApiImageResult = { path: string; filename: string; clientRequestId?: string; + storageMode?: HistoryMetadata['storageModeUsed']; }; +type ImageStorageMode = NonNullable; + type ApiUsage = { input_tokens_details?: { text_tokens?: number; @@ -180,6 +228,10 @@ type ApiUsage = { }; type RuntimeCapabilities = { + streaming?: { + defaultMode?: ImageStreamMode; + defaultStrategy?: ImageStreamingStrategy; + }; streamingBatch: { enabled: boolean; recommendedConcurrency: number; @@ -195,6 +247,14 @@ type RuntimeCapabilities = { requestId?: string; }; }; + responsesImageBackend?: { + enabled: boolean; + mode?: 'experimental'; + requiredEnv?: string[]; + optionalEnv?: string[]; + hasDefaultModel?: boolean; + missingEnv?: string[]; + }; }; class ApiRequestError extends Error { @@ -225,12 +285,11 @@ function renderErrorDescription(error: ApiErrorNotice): React.ReactNode {

{error.message}

{error.links.map((link) => ( + target='_blank'> {link.label} ))} @@ -300,21 +359,26 @@ function mergeActualCostValues(costs: Array): Act } export default function HomePage() { - const { t } = useI18n(); - const createErrorNotice = React.useCallback( - (message: string) => buildApiErrorNotice(message, t('error.openSuperApi')), - [t] - ); + const { locale, t } = useI18n(); + const createErrorNotice = React.useCallback((message: string) => buildApiErrorNotice(message), []); const [mode, setMode] = React.useState<'generate' | 'edit'>('generate'); + const [workbenchMode, setWorkbenchMode] = React.useState('generate'); + const [reuseContext, setReuseContext] = React.useState(null); + const [editReuseContext, setEditReuseContext] = React.useState(null); const [isPasswordRequiredByBackend, setIsPasswordRequiredByBackend] = React.useState(null); const [clientPasswordHash, setClientPasswordHash] = React.useState(null); const [isEntryAuthenticated, setIsEntryAuthenticated] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const [isSendingToEdit, setIsSendingToEdit] = React.useState(false); const [error, setError] = React.useState(null); + const [generationFailureMessage, setGenerationFailureMessage] = React.useState(null); + const [failedBatchPrompts, setFailedBatchPrompts] = React.useState([]); const [latestImageBatch, setLatestImageBatch] = React.useState(null); + const [activeResultSource, setActiveResultSource] = React.useState(null); + const [completedGenerationCount, setCompletedGenerationCount] = React.useState(null); const [imageOutputView, setImageOutputView] = React.useState<'grid' | number>('grid'); const [history, setHistory] = React.useState([]); + const [inspirations, setInspirations] = React.useState([]); const hasLoadedStoredHistoryRef = React.useRef(false); const blobUrlCacheRef = React.useRef>(new Map()); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = React.useState(false); @@ -329,21 +393,40 @@ export default function HomePage() { const [openLogsSignal, setOpenLogsSignal] = React.useState(0); const [shareDialogOpen, setShareDialogOpen] = React.useState(false); const [shareTargetFilename, setShareTargetFilename] = React.useState(null); + const [shareTargetStorageMode, setShareTargetStorageMode] = React.useState(); const [shareUrl, setShareUrl] = React.useState(null); const [shareError, setShareError] = React.useState(null); const [isCreatingShare, setIsCreatingShare] = React.useState(false); + const [isMobileCreationDrawerOpen, setIsMobileCreationDrawerOpen] = React.useState(false); const outputPanelRef = React.useRef(null); + const mobileCreationDrawerCloseButtonRef = React.useRef(null); + const mobileDrawerPointerStartRef = React.useRef(null); + const mobileDrawerGestureHandledAtRef = React.useRef(0); const allDbImages = useLiveQuery(() => db.images.toArray(), []); const [editImageFiles, setEditImageFiles] = React.useState([]); - const [editSourceImagePreviewUrls, setEditSourceImagePreviewUrls] = React.useState([]); + const [editSourceImagePreviewUrls, setEditSourceImagePreviewUrlsState] = React.useState([]); + const editSourceImagePreviewUrlsRef = React.useRef([]); + const updateEditSourceImagePreviewUrls = React.useCallback((nextUrls: string[]) => { + const nextUrlSet = new Set(nextUrls); + editSourceImagePreviewUrlsRef.current.forEach((url) => { + if (!nextUrlSet.has(url)) { + URL.revokeObjectURL(url); + } + }); + editSourceImagePreviewUrlsRef.current = nextUrls; + setEditSourceImagePreviewUrlsState(nextUrls); + }, []); const [editPrompt, setEditPrompt] = React.useState(''); const [editN, setEditN] = React.useState([1]); const [editSize, setEditSize] = React.useState('auto'); const [editCustomWidth, setEditCustomWidth] = React.useState(1024); const [editCustomHeight, setEditCustomHeight] = React.useState(1024); const [editQuality, setEditQuality] = React.useState('auto'); + const [editOutputFormat, setEditOutputFormat] = React.useState('png'); + const [editCompression, setEditCompression] = React.useState([100]); + const [editModeration, setEditModeration] = React.useState('auto'); const [editBrushSize, setEditBrushSize] = React.useState([20]); const [editShowMaskEditor, setEditShowMaskEditor] = React.useState(false); const [editGeneratedMaskFile, setEditGeneratedMaskFile] = React.useState(null); @@ -353,9 +436,24 @@ export default function HomePage() { ); const [editDrawnPoints, setEditDrawnPoints] = React.useState([]); const [editMaskPreviewUrl, setEditMaskPreviewUrl] = React.useState(null); + const [editImageBackend, setEditImageBackend] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [editStreamingStrategy, setEditStreamingStrategy] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [editResponsesModel, setEditResponsesModel] = React.useState(''); + const [editThinking, setEditThinking] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [editPromptOptimization, setEditPromptOptimization] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [editForceWeb, setEditForceWeb] = React.useState(false); const [genModel, setGenModel] = React.useState('gpt-image-2'); const [genPrompt, setGenPrompt] = React.useState(''); + const [genBatchPromptText, setGenBatchPromptText] = React.useState(''); const [genN, setGenN] = React.useState([1]); const [genSize, setGenSize] = React.useState('auto'); const [genCustomWidth, setGenCustomWidth] = React.useState(1024); @@ -365,36 +463,121 @@ export default function HomePage() { const [genCompression, setGenCompression] = React.useState([100]); const [genBackground, setGenBackground] = React.useState('auto'); const [genModeration, setGenModeration] = React.useState('auto'); - const [genImageBackend, setGenImageBackend] = - React.useState(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); - const [genStreamingStrategy, setGenStreamingStrategy] = - React.useState(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + const [genImageBackend, setGenImageBackend] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [genStreamingStrategy, setGenStreamingStrategy] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); const [genResponsesModel, setGenResponsesModel] = React.useState(''); + const [genThinking, setGenThinking] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [genPromptOptimization, setGenPromptOptimization] = React.useState( + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ); + const [genForceWeb, setGenForceWeb] = React.useState(false); const [editModel, setEditModel] = React.useState('gpt-image-2'); // 流式状态,由生成和编辑模式共用。 - const [enableStreaming, setEnableStreaming] = React.useState(false); + const [streamMode, setStreamMode] = React.useState('auto'); const [partialImages, setPartialImages] = React.useState<1 | 2 | 3>(1); + const [enableParallelBatch, setEnableParallelBatch] = React.useState(false); const [activeRequestStreaming, setActiveRequestStreaming] = React.useState(false); // 流式预览图,存储流式过程中的局部图片 base64 data URL。 const [streamingPreviewImages, setStreamingPreviewImages] = React.useState>(new Map()); + const [batchProgress, setBatchProgress] = React.useState(null); + const [isBatchPauseRequested, setIsBatchPauseRequested] = React.useState(false); + const batchPauseRequestedRef = React.useRef(false); + const defaultStreamingStrategy = runtimeCapabilities?.streaming?.defaultStrategy ?? 'auto'; + const allowResponsesImageBackend = isResponsesImageBackendRuntimeEnabled(runtimeCapabilities ?? {}); + const allowResponsesHistoryRoute = shouldAllowResponsesHistoryRoute({ + runtimeCapabilitiesAvailable: runtimeCapabilities !== null, + allowResponsesImageBackend + }); + const hasDefaultResponsesModel = runtimeCapabilities?.responsesImageBackend?.hasDefaultModel === true; const streamingBatchCapacity = resolveStreamingBatchCapacity({ - featureEnabled: isRuntimeStreamingBatchEnabled({ - serverEnabled: runtimeCapabilities?.streamingBatch.enabled - }), + featureEnabled: runtimeCapabilities?.streamingBatch.enabled === true, hasRequestApiKey: apiSettings.apiKey.trim().length > 0, requestCredentialConcurrency: runtimeCapabilities?.streamingBatch.requestCredentialConcurrency ?? 1, serverRecommendedConcurrency: runtimeCapabilities?.streamingBatch.recommendedConcurrency ?? 0 }); const streamingBatchEnabled = streamingBatchCapacity.enabled; - const currentPrompt = mode === 'generate' ? genPrompt : editPrompt; + const isPromptBatchMode = mode === 'generate' && workbenchMode === 'batch'; + const currentPrompt = + mode === 'generate' && workbenchMode === 'batch' + ? genBatchPromptText + : mode === 'generate' + ? genPrompt + : editPrompt; const hasEditSourceImage = editImageFiles.length > 0; const currentGenerateSizeValidation = genSize === 'custom' ? validateGptImage2Size(genCustomWidth, genCustomHeight) : { valid: true as const }; const currentEditSizeValidation = editSize === 'custom' ? validateGptImage2Size(editCustomWidth, editCustomHeight) : { valid: true as const }; const canOpenLogs = isPasswordRequiredByBackend === true && !!clientPasswordHash; + const usesEditControls = workbenchMode === 'edit'; + const activeWorkbenchModel = usesEditControls ? editModel : genModel; + const activeWorkbenchBackend = usesEditControls ? editImageBackend : genImageBackend; + const activeWorkbenchStreamingStrategy = usesEditControls ? editStreamingStrategy : genStreamingStrategy; + const activeEffectiveStreamingStrategy = + activeWorkbenchStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ? defaultStreamingStrategy + : activeWorkbenchStreamingStrategy; + const activeWorkbenchBackendLabel = getImageBackendLabel(activeWorkbenchBackend, t); + const activeTaskCount = + mode === 'generate' && workbenchMode === 'batch' + ? readBatchPromptLines(genBatchPromptText).length + : mode === 'generate' + ? genN[0] + : editN[0]; + const activeEstimatedCostLabel = t('workbench.estimatedCost', { + credits: formatEstimatedCredits(activeTaskCount) + }); + const activeParallelBatchVisible = resolveStreamingBatchToggleState({ + allowStreamingBatch: streamingBatchEnabled, + userEnabled: enableParallelBatch, + targetCount: activeTaskCount, + streamMode, + streamingStrategy: activeEffectiveStreamingStrategy + }).checked; + const mobileCanSaveInspiration = !isLoading && !isSendingToEdit && currentPrompt.trim().length > 0; + const hasRandomInspirationPrompt = inspirations.some((item) => item.prompt.trim().length > 0); + const pickRandomInspirationPrompt = React.useCallback(() => { + const savedPrompts = inspirations.map((item) => item.prompt.trim()).filter((prompt) => prompt.length > 0); + if (savedPrompts.length === 0) return ''; + return savedPrompts[Math.floor(Math.random() * savedPrompts.length)] ?? ''; + }, [inspirations]); + + React.useEffect(() => { + if (allowResponsesImageBackend || runtimeCapabilities === null) return; + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; + setGenImageBackend((current) => + current === 'responses-image-generation' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current + ); + setEditImageBackend((current) => + current === 'responses-image-generation' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current + ); + setGenStreamingStrategy((current) => + current === 'responses-sse' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current + ); + setEditStreamingStrategy((current) => + current === 'responses-sse' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current + ); + setGenResponsesModel(''); + setEditResponsesModel(''); + setGenThinking(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + setEditThinking(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + setGenPromptOptimization(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + setEditPromptOptimization(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + }); + return () => { + cancelled = true; + }; + }, [allowResponsesImageBackend, runtimeCapabilities]); const activeLogClientRequestIds = React.useMemo(() => { if (!latestImageBatch || latestImageBatch.length === 0) return []; if (typeof imageOutputView === 'number') { @@ -409,19 +592,201 @@ export default function HomePage() { } return uniqueStrings(latestImageBatch.map((image) => image.filename)); }, [imageOutputView, latestImageBatch]); - const mobilePrimaryDisabled = - isLoading || - isSendingToEdit || - !currentPrompt.trim() || - (mode === 'edit' && !hasEditSourceImage) || - (mode === 'generate' && !currentGenerateSizeValidation.valid) || - (mode === 'edit' && !currentEditSizeValidation.valid) || - (mode === 'edit' && editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved); + const generationActivityItems = React.useMemo( + () => + buildGenerationActivityItems({ + isLoading, + isSendingToEdit, + mode, + streamingPreviewCount: streamingPreviewImages.size, + errorMessage: error?.message, + completedGenerationCount, + batchProgress, + t + }), + [ + batchProgress, + completedGenerationCount, + error?.message, + isLoading, + isSendingToEdit, + mode, + streamingPreviewImages.size, + t + ] + ); + const mobilePrimaryDisabledReason = resolveMobilePrimaryDisabledReason({ + isLoading, + isSendingToEdit, + mode, + isBatchMode: workbenchMode === 'batch', + prompt: currentPrompt, + batchPromptCount: workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText).length : 0, + hasEditSourceImage, + hasUnsavedMask: editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved, + imageBackend: mode === 'generate' ? genImageBackend : editImageBackend, + responsesModel: mode === 'generate' ? genResponsesModel : editResponsesModel, + hasDefaultResponsesModel, + generateSizeValidation: currentGenerateSizeValidation, + editSizeValidation: currentEditSizeValidation, + t + }); + const mobilePrimaryDisabled = isLoading || isSendingToEdit || Boolean(mobilePrimaryDisabledReason); + const currentResultPrompt = activeResultSource?.prompt.trim() || currentPrompt.trim(); + const canCreateResultVariant = !isLoading && Boolean(latestImageBatch) && Boolean(currentResultPrompt); + const canReuseResultPrompt = Boolean(currentResultPrompt); + const canPausePromptBatch = isLoading && isPromptBatchMode && Boolean(batchProgress); + + const handleBatchPromptTextChange = React.useCallback((nextText: React.SetStateAction) => { + setGenBatchPromptText(nextText); + setFailedBatchPrompts([]); + }, []); + + const handlePauseBatch = React.useCallback(() => { + batchPauseRequestedRef.current = true; + setIsBatchPauseRequested(true); + }, []); + + const handleWorkbenchModeChange = React.useCallback( + (nextMode: WorkbenchMode) => { + setWorkbenchMode(nextMode); + if (nextMode !== 'reuse') { + setReuseContext(null); + } + if (nextMode !== 'edit') { + setEditReuseContext(null); + } + if (nextMode === 'edit') { + setMode('edit'); + return; + } + if (nextMode === 'batch') { + setGenN([1]); + setGenBatchPromptText((current) => (current.trim() ? current : genPrompt)); + } + setMode('generate'); + }, + [genPrompt] + ); const scrollToOutput = React.useCallback(() => { - outputPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + const outputTop = outputPanelRef.current?.getBoundingClientRect().top; + if (typeof outputTop !== 'number') return; + window.scrollTo({ + top: window.scrollY + outputTop - 12, + behavior: 'smooth' + }); + }, []); + + const blurActiveMobileTrigger = React.useCallback(() => { + if (typeof document === 'undefined') return; + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }, []); + + const openMobileCreationDrawer = React.useCallback(() => { + blurActiveMobileTrigger(); + setIsMobileCreationDrawerOpen(true); + }, [blurActiveMobileTrigger]); + + const closeMobileCreationDrawer = React.useCallback(() => { + blurActiveMobileTrigger(); + setIsMobileCreationDrawerOpen(false); + }, [blurActiveMobileTrigger]); + + const toggleMobileCreationDrawer = React.useCallback(() => { + setIsMobileCreationDrawerOpen((isOpen) => { + if (!isOpen) { + blurActiveMobileTrigger(); + } + return !isOpen; + }); + }, [blurActiveMobileTrigger]); + + const beginMobileCreationDrawerGesture = React.useCallback((event: React.PointerEvent) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + mobileDrawerPointerStartRef.current = { + x: event.clientX, + y: event.clientY + }; + }, []); + + const finishMobileCreationDrawerGesture = React.useCallback( + (event: React.PointerEvent) => { + const start = mobileDrawerPointerStartRef.current; + mobileDrawerPointerStartRef.current = null; + if (!start) return; + + const gesture = resolveMobileCreationSheetGesture({ + startX: start.x, + startY: start.y, + currentX: event.clientX, + currentY: event.clientY + }); + if (gesture === 'open') { + mobileDrawerGestureHandledAtRef.current = Date.now(); + openMobileCreationDrawer(); + } else if (gesture === 'close') { + mobileDrawerGestureHandledAtRef.current = Date.now(); + closeMobileCreationDrawer(); + } + }, + [closeMobileCreationDrawer, openMobileCreationDrawer] + ); + + const cancelMobileCreationDrawerGesture = React.useCallback(() => { + mobileDrawerPointerStartRef.current = null; }, []); + const handleMobileCreationDrawerHandleClick = React.useCallback(() => { + if (Date.now() - mobileDrawerGestureHandledAtRef.current < 500) { + mobileDrawerGestureHandledAtRef.current = 0; + return; + } + toggleMobileCreationDrawer(); + }, [toggleMobileCreationDrawer]); + + React.useEffect(() => { + if (!isMobileCreationDrawerOpen || typeof document === 'undefined') return; + + const previousBodyOverflow = document.body.style.overflow; + const previousHtmlOverflow = document.documentElement.style.overflow; + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousBodyOverflow; + document.documentElement.style.overflow = previousHtmlOverflow; + }; + }, [isMobileCreationDrawerOpen]); + + React.useEffect(() => { + if (!isMobileCreationDrawerOpen) return; + + requestAnimationFrame(() => { + mobileCreationDrawerCloseButtonRef.current?.focus(); + }); + }, [isMobileCreationDrawerOpen]); + + React.useEffect(() => { + if (!isMobileCreationDrawerOpen || typeof window === 'undefined') return; + + const desktopMediaQuery = window.matchMedia('(min-width: 1024px)'); + const closeDrawerOnDesktop = () => { + if (desktopMediaQuery.matches) { + setIsMobileCreationDrawerOpen(false); + } + }; + + closeDrawerOnDesktop(); + desktopMediaQuery.addEventListener('change', closeDrawerOnDesktop); + return () => { + desktopMediaQuery.removeEventListener('change', closeDrawerOnDesktop); + }; + }, [isMobileCreationDrawerOpen]); + const getImageSrc = React.useCallback( (filename: string): string | undefined => { const cached = blobUrlCacheRef.current.get(filename); @@ -438,6 +803,15 @@ export default function HomePage() { }, [allDbImages] ); + const historyCompareImage = React.useMemo( + () => + resolveHistoryCompareImage({ + history, + currentFilenames: latestImageBatch?.map((image) => image.filename) ?? [], + getIndexedDbImageSrc: getImageSrc + }), + [getImageSrc, history, latestImageBatch] + ); React.useEffect(() => { const cache = blobUrlCacheRef.current; @@ -450,41 +824,49 @@ export default function HomePage() { React.useEffect(() => { queueMicrotask(() => { setHistory(readStoredHistory()); + const storedInspirations = readStoredInspirations(); + setInspirations(storedInspirations.items); + if (storedInspirations.shouldRemove) { + window.localStorage.removeItem(inspirationsLocalStorageKey); + } else if (storedInspirations.shouldPersist) { + window.localStorage.setItem(inspirationsLocalStorageKey, JSON.stringify(storedInspirations.items)); + } hasLoadedStoredHistoryRef.current = true; }); }, []); React.useEffect(() => { - return () => { - editSourceImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url)); - }; + editSourceImagePreviewUrlsRef.current = editSourceImagePreviewUrls; }, [editSourceImagePreviewUrls]); - const verifyEntryPasswordHash = React.useCallback(async (passwordHash: string): Promise => { - try { - const response = await fetch('/api/auth-verify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passwordHash }) - }); + const verifyEntryPasswordHash = React.useCallback( + async (passwordHash: string): Promise => { + try { + const response = await fetch('/api/auth-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ passwordHash }) + }); - if (response.ok) { - return 'valid'; - } - if (response.status === 401) { - try { - const result = (await response.json()) as { code?: string }; - return isPagePasswordAuthErrorCode(result.code) ? 'invalid' : 'unavailable'; - } catch { - return 'unavailable'; + if (response.ok) { + return 'valid'; } + if (response.status === 401) { + try { + const result = (await response.json()) as { code?: string }; + return isPagePasswordAuthErrorCode(result.code) ? 'invalid' : 'unavailable'; + } catch { + return 'unavailable'; + } + } + return 'unavailable'; + } catch (error) { + console.error('验证入口访问码失败:', error); + return 'unavailable'; } - return 'unavailable'; - } catch (error) { - console.error('验证入口访问码失败:', error); - return 'unavailable'; - } - }, []); + }, + [] + ); const promptForExpiredPassword = React.useCallback(() => { localStorage.removeItem('clientPasswordHash'); @@ -495,35 +877,38 @@ export default function HomePage() { setError(createErrorNotice(t('error.passwordExpired'))); }, [createErrorNotice, t]); - const refreshImageAccessCookie = React.useCallback(async (passwordHash = clientPasswordHash): Promise => { - if (!isPasswordRequiredByBackend) { - return true; - } - if (!passwordHash) { - promptForExpiredPassword(); - return false; - } + const refreshImageAccessCookie = React.useCallback( + async (passwordHash = clientPasswordHash): Promise => { + if (!isPasswordRequiredByBackend) { + return true; + } + if (!passwordHash) { + promptForExpiredPassword(); + return false; + } - const verificationResult = await verifyEntryPasswordHash(passwordHash); - if (verificationResult === 'valid') { - setIsEntryAuthenticated(true); - return true; - } - if (verificationResult === 'unavailable') { - setError(createErrorNotice(t('error.authVerifyUnavailable'))); - return false; - } + const verificationResult = await verifyEntryPasswordHash(passwordHash); + if (verificationResult === 'valid') { + setIsEntryAuthenticated(true); + return true; + } + if (verificationResult === 'unavailable') { + setError(createErrorNotice(t('error.authVerifyUnavailable'))); + return false; + } - promptForExpiredPassword(); - return false; - }, [ - clientPasswordHash, - createErrorNotice, - isPasswordRequiredByBackend, - promptForExpiredPassword, - t, - verifyEntryPasswordHash - ]); + promptForExpiredPassword(); + return false; + }, + [ + clientPasswordHash, + createErrorNotice, + isPasswordRequiredByBackend, + promptForExpiredPassword, + t, + verifyEntryPasswordHash + ] + ); React.useEffect(() => { const fetchAuthStatus = async () => { @@ -607,11 +992,21 @@ export default function HomePage() { } }, [history]); + React.useEffect(() => { + if (!hasLoadedStoredHistoryRef.current) return; + try { + localStorage.setItem(inspirationsLocalStorageKey, JSON.stringify(inspirations)); + } catch (e) { + console.error('保存灵感相册到 localStorage 失败:', e); + } + }, [inspirations]); + React.useEffect(() => { return () => { - editSourceImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url)); + editSourceImagePreviewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + editSourceImagePreviewUrlsRef.current = []; }; - }, [editSourceImagePreviewUrls]); + }, []); React.useEffect(() => { queueMicrotask(() => { @@ -644,7 +1039,7 @@ export default function HomePage() { const previewUrl = URL.createObjectURL(file); setEditImageFiles((prevFiles) => [...prevFiles, file]); - setEditSourceImagePreviewUrls((prevUrls) => [...prevUrls, previewUrl]); + updateEditSourceImagePreviewUrls([...editSourceImagePreviewUrlsRef.current, previewUrl]); break; } @@ -657,7 +1052,7 @@ export default function HomePage() { return () => { window.removeEventListener('paste', handlePaste); }; - }, [mode, editImageFiles.length, t]); + }, [mode, editImageFiles.length, t, updateEditSourceImagePreviewUrls]); const handleSavePassword = async (password: string) => { if (!password.trim()) { @@ -712,72 +1107,6 @@ export default function HomePage() { } }; - const buildHistoryEntry = React.useCallback( - ( - images: ApiImageResponseItem[], - usage: unknown, - actualCost: ActualCostDetails | undefined, - durationMsValue: number - ): HistoryMetadata => { - const isGenerateMode = mode === 'generate'; - const currentModel = isGenerateMode ? genModel : editModel; - const clientRequestIds = uniqueStrings(images.map((img) => img.clientRequestId)); - const requestSize = isGenerateMode - ? genSize === 'custom' - ? `${genCustomWidth}x${genCustomHeight}` - : (getPresetDimensions(genSize, genModel) ?? genSize) - : editSize === 'custom' - ? `${editCustomWidth}x${editCustomHeight}` - : (getPresetDimensions(editSize, editModel) ?? editSize); - const costDetails = calculateApiCost(usage as Parameters[0], currentModel); - return { - timestamp: Date.now(), - images: images.map((img) => ({ - filename: img.filename, - ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) - })), - storageModeUsed: effectiveStorageModeClient, - durationMs: durationMsValue, - quality: isGenerateMode ? genQuality : editQuality, - background: isGenerateMode ? genBackground : 'auto', - moderation: isGenerateMode ? genModeration : 'auto', - output_format: isGenerateMode ? genOutputFormat : 'png', - prompt: isGenerateMode ? genPrompt : editPrompt, - mode, - costDetails, - ...(actualCost - ? { - actualCostDetails: { - ...actualCost, - ...(costDetails ? { estimatedUsd: costDetails.estimated_cost_usd } : {}) - } - } - : {}), - model: currentModel, - size: requestSize, - ...(clientRequestIds.length > 0 ? { clientRequestIds } : {}) - }; - }, - [ - editCustomHeight, - editCustomWidth, - editModel, - editPrompt, - editQuality, - editSize, - genBackground, - genCustomHeight, - genCustomWidth, - genModel, - genModeration, - genOutputFormat, - genPrompt, - genQuality, - genSize, - mode - ] - ); - const materializeImages = React.useCallback( async (images: ApiImageResponseItem[]): Promise => { if (effectiveStorageModeClient === 'indexeddb') { @@ -802,7 +1131,11 @@ export default function HomePage() { } const blobUrl = URL.createObjectURL(blob); blobUrlCacheRef.current.set(img.filename, blobUrl); - return { filename: img.filename, path: blobUrl, ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) }; + return { + filename: img.filename, + path: blobUrl, + ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) + }; }) ); return indexedDbImages; @@ -810,7 +1143,11 @@ export default function HomePage() { const fsImages = images .filter((img) => !!img.path) - .map((img) => ({ path: img.path!, filename: img.filename, ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) })); + .map((img) => ({ + path: img.path!, + filename: img.filename, + ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) + })); if (fsImages.length !== images.length) { throw new Error(t('error.apiOmittedPaths')); } @@ -825,21 +1162,40 @@ export default function HomePage() { usage: unknown, actualCost: ActualCostDetails | undefined, durationMsValue: number, - clearStreaming = false + formData: GenerationFormData | EditingFormData, + requestMode: RequestMode, + clearStreaming = false, + promptOverride?: string ) => { if (images.length === 0) { throw new Error(t('error.noImages')); } const processedImages = await materializeImages(images); - setLatestImageBatch(processedImages); + const processedImagesWithStorage = processedImages.map((image) => ({ + ...image, + storageMode: effectiveStorageModeClient + })); + setLatestImageBatch(processedImagesWithStorage); + setCompletedGenerationCount(processedImages.length); setImageOutputView(processedImages.length > 1 ? 'grid' : 0); if (clearStreaming) { setStreamingPreviewImages(new Map()); } - setHistory((prevHistory) => [buildHistoryEntry(images, usage, actualCost, durationMsValue), ...prevHistory]); + const historyEntry = buildCompletedHistoryEntry({ + images, + usage, + actualCost, + durationMs: durationMsValue, + formData, + requestMode, + storageMode: effectiveStorageModeClient, + promptOverride + }); + setActiveResultSource(historyEntry); + setHistory((prevHistory) => [historyEntry, ...prevHistory]); }, - [buildHistoryEntry, materializeImages, t] + [materializeImages, t] ); const buildApiFormData = React.useCallback( @@ -848,7 +1204,7 @@ export default function HomePage() { requestMode: RequestMode, options: { forceSingleImage?: boolean; - streaming: boolean; + streamMode: ImageStreamMode; partialImages: 1 | 2 | 3; passwordHash?: string | null; } @@ -868,8 +1224,8 @@ export default function HomePage() { apiFormData.append('apiBaseUrl', apiSettings.baseUrl); } - if (options.streaming) { - apiFormData.append('stream', 'true'); + apiFormData.append('stream_mode', options.streamMode); + if (options.streamMode !== 'non_stream') { apiFormData.append('partial_images', options.partialImages.toString()); } apiFormData.append('clientRequestId', createClientRequestId()); @@ -897,7 +1253,10 @@ export default function HomePage() { appendImageUpstreamOverrideFields(apiFormData, { imageBackend: genData.image_backend, streamingStrategy: genData.streaming_strategy, - responsesModel: genData.responsesModel + responsesModel: genData.responsesModel, + thinking: genData.thinking, + promptOptimization: genData.promptOptimization, + forceWeb: genData.forceWeb }); } else { const editData = formData as EditingFormData; @@ -910,6 +1269,22 @@ export default function HomePage() { : (getPresetDimensions(editData.size, editData.model) ?? editData.size); apiFormData.append('size', editSizeToSend); apiFormData.append('quality', editData.quality); + apiFormData.append('output_format', editData.output_format); + if ( + (editData.output_format === 'jpeg' || editData.output_format === 'webp') && + editData.output_compression !== undefined + ) { + apiFormData.append('output_compression', editData.output_compression.toString()); + } + apiFormData.append('moderation', editData.moderation); + appendImageUpstreamOverrideFields(apiFormData, { + imageBackend: editData.image_backend, + streamingStrategy: editData.streaming_strategy, + responsesModel: editData.responsesModel, + thinking: editData.thinking, + promptOptimization: editData.promptOptimization, + forceWeb: editData.forceWeb + }); editData.imageFiles.forEach((file, index) => { apiFormData.append(`image_${index}`, file, file.name); @@ -921,13 +1296,7 @@ export default function HomePage() { return apiFormData; }, - [ - apiSettings.apiKey, - apiSettings.baseUrl, - clientPasswordHash, - isPasswordRequiredByBackend, - t - ] + [apiSettings.apiKey, apiSettings.baseUrl, clientPasswordHash, isPasswordRequiredByBackend, t] ); const executeImageRequest = React.useCallback( @@ -937,8 +1306,9 @@ export default function HomePage() { previewIndexOffset?: number; retryFormData?: GenerationFormData | EditingFormData; retryMode?: RequestMode; - retryStreaming?: boolean; + retryStreamMode?: ImageStreamMode; retryPartialImages?: 1 | 2 | 3; + retryEnableParallelBatch?: boolean; } = {} ): Promise<{ images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails }> => { const formClientRequestId = String(apiFormData.get('clientRequestId') || ''); @@ -1003,7 +1373,9 @@ export default function HomePage() { return { images: streamingState.completedImages.map((image) => ({ ...image, - ...(image.clientRequestId || !formClientRequestId ? {} : { clientRequestId: formClientRequestId }) + ...(image.clientRequestId || !formClientRequestId + ? {} + : { clientRequestId: formClientRequestId }) })), usage: streamingState.usage, actualCost: streamingState.actualCost ?? undefined @@ -1028,24 +1400,33 @@ export default function HomePage() { } if (!response.ok) { - if (response.status === 401 && isPasswordRequiredByBackend && isPagePasswordAuthErrorCode(result.code)) { + if ( + response.status === 401 && + isPasswordRequiredByBackend && + isPagePasswordAuthErrorCode(result.code) + ) { if ( options.retryFormData && options.retryMode && - options.retryStreaming !== undefined && - options.retryPartialImages !== undefined + options.retryStreamMode !== undefined && + options.retryPartialImages !== undefined && + options.retryEnableParallelBatch !== undefined ) { setLastApiCallArgs([ options.retryFormData, options.retryMode, - options.retryStreaming, - options.retryPartialImages + options.retryStreamMode, + options.retryPartialImages, + options.retryEnableParallelBatch ]); } promptForExpiredPassword(); throw new ApiRequestError(t('error.passwordExpired'), 401, { preserveDisplayedError: true }); } - throw new ApiRequestError(result.error || t('error.apiFailed', { status: response.status }), response.status); + throw new ApiRequestError( + result.error || t('error.apiFailed', { status: response.status }), + response.status + ); } return { @@ -1065,82 +1446,274 @@ export default function HomePage() { async function handleApiCall( formData: GenerationFormData | EditingFormData, requestMode: RequestMode = mode, - requestStreaming: boolean = enableStreaming, + requestStreamMode: ImageStreamMode = streamMode, requestPartialImages: 1 | 2 | 3 = partialImages, + requestEnableParallelBatch = formData.enableParallelBatch, requestPasswordHash: string | null = clientPasswordHash ) { const startTime = Date.now(); let durationMs = 0; + let shouldKeepRetryArgs = true; + let effectiveFormData: GenerationFormData | EditingFormData = formData; setIsLoading(true); - setActiveRequestStreaming(requestStreaming); + setActiveRequestStreaming(requestStreamMode !== 'non_stream'); setError(null); + setGenerationFailureMessage(null); + setFailedBatchPrompts([]); + setLastApiCallArgs(null); setLatestImageBatch(null); + setActiveResultSource(null); + setCompletedGenerationCount(null); setImageOutputView('grid'); setStreamingPreviewImages(new Map()); + setBatchProgress(null); + batchPauseRequestedRef.current = false; + setIsBatchPauseRequested(false); if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) { + setIsMobileCreationDrawerOpen(false); window.setTimeout(scrollToOutput, 80); } try { const latestRuntimeCapabilities = await refreshRuntimeCapabilities(); const currentStreamingBatchCapacity = resolveStreamingBatchCapacity({ - featureEnabled: isRuntimeStreamingBatchEnabled({ - serverEnabled: latestRuntimeCapabilities?.streamingBatch.enabled - }), + featureEnabled: latestRuntimeCapabilities?.streamingBatch.enabled === true, hasRequestApiKey: apiSettings.apiKey.trim().length > 0, - requestCredentialConcurrency: latestRuntimeCapabilities?.streamingBatch.requestCredentialConcurrency ?? 1, + requestCredentialConcurrency: + latestRuntimeCapabilities?.streamingBatch.requestCredentialConcurrency ?? 1, serverRecommendedConcurrency: latestRuntimeCapabilities?.streamingBatch.recommendedConcurrency ?? 0 }); - if (isPasswordRequiredByBackend && !requestPasswordHash) { - setError(createErrorNotice(t('error.passwordRequired'))); - setPasswordDialogContext('initial'); - setIsPasswordDialogOpen(true); - return; + if (isPasswordRequiredByBackend) { + const verificationPasswordHash = requestPasswordHash; + if (!verificationPasswordHash) { + const message = t('error.passwordRequired'); + setError(createErrorNotice(message)); + setGenerationFailureMessage(message); + setPasswordDialogContext('initial'); + setIsPasswordDialogOpen(true); + return; + } + + const verificationResult = await verifyEntryPasswordHash(verificationPasswordHash); + if (verificationResult === 'valid') { + setIsEntryAuthenticated(true); + } else if (verificationResult === 'unavailable') { + const message = t('error.authVerifyUnavailable'); + setError(createErrorNotice(message)); + setGenerationFailureMessage(message); + return; + } else { + const message = t('error.passwordExpired'); + promptForExpiredPassword(); + setGenerationFailureMessage(message); + return; + } } - if (!(await refreshImageAccessCookie(requestPasswordHash))) { - return; + const allowRuntimeResponsesImageBackend = + isResponsesImageBackendRuntimeEnabled(latestRuntimeCapabilities ?? {}); + if ( + shouldBlockExplicitResponsesRequest({ + imageBackend: formData.image_backend, + allowResponsesImageBackend: allowRuntimeResponsesImageBackend + }) + ) { + throw new ApiRequestError( + latestRuntimeCapabilities === null + ? t('upstream.responsesRuntimeUnavailable') + : t('upstream.backendResponsesUnavailable') + ); } - - const imageCount = - requestMode === 'generate' ? (formData as GenerationFormData).n : (formData as EditingFormData).n; - const useStreamingBatch = shouldUseStreamingBatch({ + const runtimeFormData = normalizeImageUpstreamRuntimeFields(formData, { + allowResponsesImageBackend: allowRuntimeResponsesImageBackend + }); + if ( + shouldBlockResponsesRequestWithoutModel({ + imageBackend: runtimeFormData.image_backend, + responsesModel: runtimeFormData.responsesModel, + hasDefaultResponsesModel: + latestRuntimeCapabilities?.responsesImageBackend?.hasDefaultModel === true + }) + ) { + throw new ApiRequestError(t('upstream.responsesModelRequired')); + } + const promptBatch = + requestMode === 'generate' + ? ((runtimeFormData as GenerationFormData).batchPrompts ?? []) + .map((prompt) => prompt.trim()) + .filter((prompt) => prompt.length > 0) + : []; + const isPromptBatch = promptBatch.length > 1; + const historyPromptOverride = + requestMode === 'generate' && promptBatch.length > 0 + ? formatBatchPromptHistory(promptBatch) + : undefined; + const imageCount = isPromptBatch + ? promptBatch.length + : requestMode === 'generate' + ? (runtimeFormData as GenerationFormData).n + : (runtimeFormData as EditingFormData).n; + const requestStreamingStrategy = + requestMode === 'generate' + ? (runtimeFormData as GenerationFormData).streaming_strategy + : (runtimeFormData as EditingFormData).streaming_strategy; + const runtimeDefaultStreamingStrategy = + latestRuntimeCapabilities?.streaming?.defaultStrategy ?? defaultStreamingStrategy; + const effectiveRequestStreamingStrategy = + requestStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT + ? runtimeDefaultStreamingStrategy + : requestStreamingStrategy; + const normalizedEnableParallelBatch = shouldUseStreamingBatch({ enabled: currentStreamingBatchCapacity.enabled, - streaming: requestStreaming, + userEnabled: requestEnableParallelBatch, + streaming: canUseStreamingBatchTransport({ + streamMode: requestStreamMode, + streamingStrategy: effectiveRequestStreamingStrategy + }), imageCount }); + effectiveFormData = { + ...runtimeFormData, + enableParallelBatch: normalizedEnableParallelBatch + }; + setLastApiCallArgs([ + effectiveFormData, + requestMode, + requestStreamMode, + requestPartialImages, + normalizedEnableParallelBatch + ]); + const executeImageRequestForCurrentOptions = async ( - options: { forceSingleImage: boolean; previewIndexOffset?: number } = { forceSingleImage: false } + options: { forceSingleImage: boolean; previewIndexOffset?: number; promptOverride?: string } = { + forceSingleImage: false + } ) => { + const requestFormData = + requestMode === 'generate' && options.promptOverride + ? { + ...(effectiveFormData as GenerationFormData), + prompt: options.promptOverride, + n: 1 + } + : effectiveFormData; return executeImageRequest( - buildApiFormData(formData, requestMode, { + buildApiFormData(requestFormData, requestMode, { forceSingleImage: options.forceSingleImage, - streaming: requestStreaming, + streamMode: requestStreamMode, partialImages: requestPartialImages, passwordHash: requestPasswordHash }), { previewIndexOffset: options.previewIndexOffset, - retryFormData: formData, + retryFormData: effectiveFormData, retryMode: requestMode, - retryStreaming: requestStreaming, - retryPartialImages: requestPartialImages + retryStreamMode: requestStreamMode, + retryPartialImages: requestPartialImages, + retryEnableParallelBatch: normalizedEnableParallelBatch } ); }; - if (useStreamingBatch) { + if (isPromptBatch) { + const jobs = buildStreamingBatchJobs(promptBatch.length); + setBatchProgress({ + completed: 0, + failed: 0, + total: jobs.length + }); + const batchResults = await scheduleStreamingBatch(jobs, { + concurrency: normalizedEnableParallelBatch ? currentStreamingBatchCapacity.concurrency : 1, + runJob: async (job: StreamingBatchJob) => { + try { + const result = await executeImageRequestForCurrentOptions({ + forceSingleImage: true, + previewIndexOffset: job.outputIndex, + promptOverride: promptBatch[job.outputIndex] + }); + setBatchProgress((current) => advanceGenerationBatchProgress(current, jobs.length, false)); + return result; + } catch (error) { + setBatchProgress((current) => advanceGenerationBatchProgress(current, jobs.length, true)); + throw error; + } + }, + shouldPause: () => batchPauseRequestedRef.current + }); + const errors = batchResults.filter((result): result is Error => result instanceof Error); + const successes = batchResults.filter( + ( + result + ): result is { images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails } => + !(result instanceof Error) + ); + if (errors.some(hasPreservedDisplayedAuthError)) { + return; + } + const failedPromptBatch = collectFailedBatchPrompts(promptBatch, batchResults); + if (errors.some((batchError) => batchError instanceof BatchPausedError)) { + setBatchProgress((current) => ({ + completed: countCompletedBatchResults(batchResults), + failed: failedPromptBatch.length, + total: current?.total ?? jobs.length + })); + } + if (successes.length === 0) { + setFailedBatchPrompts(failedPromptBatch); + throw errors[0] || new Error(t('error.noImages')); + } + const images = successes.flatMap((result) => result.images); + const usage = mergeUsageValues(successes.map((result) => result.usage)); + const actualCost = mergeActualCostValues(successes.map((result) => result.actualCost)); + durationMs = Date.now() - startTime; + if (errors.length > 0) { + setFailedBatchPrompts(failedPromptBatch); + } + await commitCompletedImages( + images, + usage, + actualCost, + durationMs, + effectiveFormData, + requestMode, + true, + historyPromptOverride + ); + shouldKeepRetryArgs = false; + if (errors.length > 0) { + await refreshRuntimeCapabilities(); + setError( + createErrorNotice( + buildBatchPartialFailureMessage({ + failed: errors.length, + total: jobs.length, + errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'))), + t + }) + ) + ); + } else { + setFailedBatchPrompts([]); + } + return; + } + + if (normalizedEnableParallelBatch) { const jobs = buildStreamingBatchJobs(imageCount); - const batchResults = await scheduleStreamingBatch( - jobs, - currentStreamingBatchCapacity.concurrency, - async (job: StreamingBatchJob) => { - return executeImageRequestForCurrentOptions({ forceSingleImage: true, previewIndexOffset: job.outputIndex }); + const batchResults = await scheduleStreamingBatch(jobs, { + concurrency: currentStreamingBatchCapacity.concurrency, + runJob: async (job: StreamingBatchJob) => { + return executeImageRequestForCurrentOptions({ + forceSingleImage: true, + previewIndexOffset: job.outputIndex + }); } - ); + }); const errors = batchResults.filter((result): result is Error => result instanceof Error); const successes = batchResults.filter( - (result): result is { images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails } => + ( + result + ): result is { images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails } => !(result instanceof Error) ); if (errors.some(hasPreservedDisplayedAuthError)) { @@ -1153,7 +1726,16 @@ export default function HomePage() { const usage = mergeUsageValues(successes.map((result) => result.usage)); const actualCost = mergeActualCostValues(successes.map((result) => result.actualCost)); durationMs = Date.now() - startTime; - await commitCompletedImages(images, usage, actualCost, durationMs, true); + await commitCompletedImages( + images, + usage, + actualCost, + durationMs, + effectiveFormData, + requestMode, + true + ); + shouldKeepRetryArgs = false; if (errors.length > 0) { await refreshRuntimeCapabilities(); setError( @@ -1172,22 +1754,48 @@ export default function HomePage() { const result = await executeImageRequestForCurrentOptions(); durationMs = Date.now() - startTime; - await commitCompletedImages(result.images || [], result.usage, result.actualCost, durationMs); + await commitCompletedImages( + result.images || [], + result.usage, + result.actualCost, + durationMs, + effectiveFormData, + requestMode, + false, + historyPromptOverride + ); + shouldKeepRetryArgs = false; } catch (err: unknown) { durationMs = Date.now() - startTime; console.error(`API 调用在 ${durationMs}ms 后失败:`, err); if (hasPreservedDisplayedAuthError(err)) { + setGenerationFailureMessage(t('error.passwordExpired')); setLatestImageBatch(null); setStreamingPreviewImages(new Map()); return; } const errorSummary = summarizeApiError(err, t('error.unexpected')); - setError(createErrorNotice(buildUserFacingApiErrorMessage({ ...errorSummary, t }))); + const message = buildUserFacingApiErrorMessage({ ...errorSummary, t }); + setError(createErrorNotice(message)); + setGenerationFailureMessage(message); setLatestImageBatch(null); + setHistory((prevHistory) => [ + buildFailedHistoryEntry({ + message, + durationMs, + formData: effectiveFormData, + requestMode, + storageMode: effectiveStorageModeClient + }), + ...prevHistory + ]); setStreamingPreviewImages(new Map()); await refreshRuntimeCapabilities(); } finally { if (durationMs === 0) durationMs = Date.now() - startTime; + if (!shouldKeepRetryArgs) { + setLastApiCallArgs(null); + } setActiveRequestStreaming(false); setIsLoading(false); } @@ -1195,8 +1803,9 @@ export default function HomePage() { function handleMobilePrimaryAction() { if (mode === 'generate') { - void handleApiCall({ - prompt: genPrompt, + const batchPrompts = workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText) : undefined; + const formData: GenerationFormData = { + prompt: batchPrompts && batchPrompts.length > 0 ? batchPrompts[0] : genPrompt, n: genN[0], size: genSize, customWidth: genCustomWidth, @@ -1211,21 +1820,202 @@ export default function HomePage() { model: genModel, image_backend: genImageBackend, streaming_strategy: genStreamingStrategy, - responsesModel: genResponsesModel - }); + responsesModel: genResponsesModel, + thinking: genThinking, + promptOptimization: genPromptOptimization, + forceWeb: genForceWeb, + enableParallelBatch, + ...(batchPrompts ? { batchPrompts } : {}) + }; + void handleApiCall(formData); return; } - void handleApiCall({ + const formData: EditingFormData = { prompt: editPrompt, n: editN[0], size: editSize, customWidth: editCustomWidth, customHeight: editCustomHeight, quality: editQuality, + output_format: editOutputFormat, + ...(editOutputFormat === 'jpeg' || editOutputFormat === 'webp' + ? { output_compression: editCompression[0] } + : {}), + moderation: editModeration, imageFiles: editImageFiles, maskFile: editGeneratedMaskFile, - model: editModel - }); + model: editModel, + image_backend: editImageBackend, + streaming_strategy: editStreamingStrategy, + responsesModel: editResponsesModel, + thinking: editThinking, + promptOptimization: editPromptOptimization, + forceWeb: editForceWeb, + enableParallelBatch + }; + void handleApiCall(formData); + } + + function handleMobileSaveInspiration() { + const trimmedPrompt = currentPrompt.trim(); + if (!trimmedPrompt) return; + handleSaveInspiration(trimmedPrompt); + } + + function handleMobileRandomInspiration() { + const nextPrompt = pickRandomInspirationPrompt(); + if (!nextPrompt) return; + if (mode === 'edit') { + setEditPrompt(nextPrompt); + return; + } + if (workbenchMode === 'batch') { + handleBatchPromptTextChange(nextPrompt); + return; + } + setGenPrompt(nextPrompt); + } + + const buildCurrentGenerationFallbackFormData = React.useCallback((): GenerationFormData => { + return { + prompt: genPrompt, + n: genN[0], + size: genSize, + customWidth: genCustomWidth, + customHeight: genCustomHeight, + quality: genQuality, + output_format: genOutputFormat, + ...(genOutputFormat === 'jpeg' || genOutputFormat === 'webp' + ? { output_compression: genCompression[0] } + : {}), + background: genBackground, + moderation: genModeration, + model: genModel, + image_backend: genImageBackend, + streaming_strategy: genStreamingStrategy, + responsesModel: genResponsesModel, + thinking: genThinking, + promptOptimization: genPromptOptimization, + forceWeb: genForceWeb, + enableParallelBatch + }; + }, [ + enableParallelBatch, + genBackground, + genCompression, + genCustomHeight, + genCustomWidth, + genForceWeb, + genImageBackend, + genModel, + genModeration, + genN, + genOutputFormat, + genPrompt, + genPromptOptimization, + genQuality, + genResponsesModel, + genSize, + genStreamingStrategy, + genThinking + ]); + + const applyHistoryGenerationFormData = React.useCallback( + (formData: GenerationFormData, item: HistoryMetadata) => { + const normalizedFormData = normalizeImageUpstreamRuntimeFields(formData, { + allowResponsesImageBackend: allowResponsesHistoryRoute + }); + const trimmedPrompt = normalizedFormData.prompt.trim(); + const restoredFields = [t('reuse.fieldPrompt')]; + + setGenPrompt(trimmedPrompt || normalizedFormData.prompt); + setGenModel(normalizedFormData.model); + setGenSize(normalizedFormData.size); + setGenCustomWidth(normalizedFormData.customWidth); + setGenCustomHeight(normalizedFormData.customHeight); + setGenQuality(normalizedFormData.quality); + setGenBackground(normalizedFormData.background); + setGenModeration(normalizedFormData.moderation); + setGenOutputFormat(normalizedFormData.output_format); + setGenImageBackend(normalizedFormData.image_backend); + setGenStreamingStrategy(normalizedFormData.streaming_strategy); + setGenResponsesModel(normalizedFormData.responsesModel); + setGenThinking(normalizedFormData.thinking); + setGenPromptOptimization(normalizedFormData.promptOptimization); + setGenForceWeb(normalizedFormData.forceWeb); + setEnableParallelBatch(normalizedFormData.enableParallelBatch); + if (normalizedFormData.output_compression !== undefined) { + setGenCompression([normalizedFormData.output_compression]); + } + + restoredFields.push( + t('reuse.fieldModel'), + t('reuse.fieldSize'), + t('reuse.fieldQuality'), + t('reuse.fieldBackground'), + t('reuse.fieldModeration'), + t('reuse.fieldFormat'), + t('reuse.fieldRoute') + ); + + if (normalizedFormData.batchPrompts && normalizedFormData.batchPrompts.length > 1) { + setGenBatchPromptText(normalizedFormData.batchPrompts.join('\n')); + setGenN([1]); + setWorkbenchMode('batch'); + } else { + setGenBatchPromptText(''); + setGenN([normalizedFormData.n]); + restoredFields.push(t('reuse.fieldCount')); + setWorkbenchMode('reuse'); + } + + setReuseContext({ + sourceLabel: t('reuse.sourceHistory', { + time: new Date(item.timestamp).toLocaleString(locale) + }), + restoredFields: Array.from(new Set(restoredFields)), + promptPreview: trimmedPrompt || t('history.noPrompt') + }); + setMode('generate'); + return normalizedFormData; + }, + [allowResponsesHistoryRoute, locale, t] + ); + + function handleCreateVariant() { + if (activeResultSource) { + const formData = buildHistoryGenerationFormData( + activeResultSource, + buildCurrentGenerationFallbackFormData() + ); + const normalizedFormData = applyHistoryGenerationFormData(formData, activeResultSource); + void handleApiCall(normalizedFormData, 'generate'); + return; + } + handleMobilePrimaryAction(); + } + + function handleReuseCurrentPrompt() { + if (activeResultSource) { + const formData = buildHistoryGenerationFormData( + activeResultSource, + buildCurrentGenerationFallbackFormData() + ); + applyHistoryGenerationFormData(formData, activeResultSource); + return; + } + const promptToReuse = mode === 'edit' && editPrompt.trim() ? editPrompt : currentPrompt; + const trimmedPrompt = promptToReuse.trim(); + if (trimmedPrompt) { + setGenPrompt(trimmedPrompt); + setReuseContext({ + sourceLabel: t('reuse.sourceCurrent'), + restoredFields: [t('reuse.fieldPrompt')], + promptPreview: trimmedPrompt + }); + } + setWorkbenchMode('reuse'); + setMode('generate'); } const handleHistorySelect = React.useCallback( @@ -1234,6 +2024,7 @@ export default function HomePage() { if (originalStorageMode === 'fs' && !(await refreshImageAccessCookie())) { return; } + setCompletedGenerationCount(null); const selectedBatchPromises = item.images.map(async (imgInfo, imageIndex) => { let path: string | undefined; @@ -1248,6 +2039,7 @@ export default function HomePage() { return { path, filename: imgInfo.filename, + storageMode: originalStorageMode, ...(clientRequestId ? { clientRequestId } : {}) }; } else { @@ -1269,12 +2061,53 @@ export default function HomePage() { } setLatestImageBatch(validImages.length > 0 ? validImages : null); + setActiveResultSource(validImages.length > 0 ? item : null); setImageOutputView(validImages.length > 1 ? 'grid' : 0); }); }, [createErrorNotice, getImageSrc, refreshImageAccessCookie, t] ); + const handleApplyPrompt = React.useCallback( + (prompt: string, source: PromptApplySource) => { + const trimmedPrompt = prompt.trim(); + const restoredFields = [t('reuse.fieldPrompt')]; + + if (source.type === 'history') { + const formData = buildHistoryGenerationFormData(source.item, buildCurrentGenerationFallbackFormData()); + applyHistoryGenerationFormData(formData, source.item); + return; + } else { + setGenPrompt(trimmedPrompt || prompt); + setReuseContext({ + sourceLabel: t('reuse.sourceInspiration', { title: source.title }), + restoredFields, + promptPreview: trimmedPrompt || t('history.noPrompt') + }); + } + + setWorkbenchMode('reuse'); + setMode('generate'); + }, + [applyHistoryGenerationFormData, buildCurrentGenerationFallbackFormData, t] + ); + + const handleSaveInspiration = React.useCallback((prompt: string) => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) return; + setInspirations((current) => { + const existing = current.find((item) => item.prompt === trimmedPrompt); + if (existing) { + return [existing, ...current.filter((item) => item.id !== existing.id)]; + } + return [{ id: Date.now(), prompt: trimmedPrompt, createdAt: Date.now() }, ...current].slice(0, 24); + }); + }, []); + + const handleDeleteInspiration = React.useCallback((id: number) => { + setInspirations((current) => current.filter((item) => item.id !== id)); + }, []); + const handleClearHistory = React.useCallback(async () => { const confirmationMessage = effectiveStorageModeClient === 'indexeddb' @@ -1284,6 +2117,8 @@ export default function HomePage() { if (window.confirm(confirmationMessage)) { setHistory([]); setLatestImageBatch(null); + setActiveResultSource(null); + setCompletedGenerationCount(null); setImageOutputView('grid'); setError(null); @@ -1297,14 +2132,16 @@ export default function HomePage() { } } catch (e) { console.error('清空历史记录失败:', e); - setError(createErrorNotice(t('error.clearHistory', { message: e instanceof Error ? e.message : String(e) }))); + setError( + createErrorNotice(t('error.clearHistory', { message: e instanceof Error ? e.message : String(e) })) + ); } } }, [createErrorNotice, t]); const resolveImageBlob = React.useCallback( - async (filename: string): Promise => { - if (effectiveStorageModeClient === 'indexeddb') { + async (filename: string, storageMode: ImageStorageMode = effectiveStorageModeClient): Promise => { + if (storageMode === 'indexeddb') { const record = allDbImages?.find((img) => img.filename === filename); if (!record?.blob) { throw new Error(t('error.imageNotFoundDb', { filename })); @@ -1325,9 +2162,9 @@ export default function HomePage() { ); const handleDownloadImage = React.useCallback( - async (filename: string) => { + async (filename: string, storageMode?: HistoryMetadata['storageModeUsed']) => { try { - const blob = await resolveImageBlob(filename); + const blob = await resolveImageBlob(filename, storageMode); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -1337,14 +2174,17 @@ export default function HomePage() { link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 150); } catch (error) { - setError(createErrorNotice(error instanceof Error ? error.message : t('error.retrieveImage', { filename }))); + setError( + createErrorNotice(error instanceof Error ? error.message : t('error.retrieveImage', { filename })) + ); } }, [createErrorNotice, resolveImageBlob, t] ); - const handleOpenShareImage = React.useCallback((filename: string) => { + const handleOpenShareImage = React.useCallback((filename: string, storageMode?: HistoryMetadata['storageModeUsed']) => { setShareTargetFilename(filename); + setShareTargetStorageMode(storageMode); setShareUrl(null); setShareError(null); setShareDialogOpen(true); @@ -1356,7 +2196,7 @@ export default function HomePage() { setIsCreatingShare(true); setShareError(null); try { - const blob = await resolveImageBlob(shareTargetFilename); + const blob = await resolveImageBlob(shareTargetFilename, shareTargetStorageMode); const result = await createImageShareFromBlob({ filename: shareTargetFilename, blob, @@ -1372,31 +2212,35 @@ export default function HomePage() { setIsCreatingShare(false); } }, - [refreshImageAccessCookie, resolveImageBlob, shareTargetFilename, t] + [refreshImageAccessCookie, resolveImageBlob, shareTargetFilename, shareTargetStorageMode, t] ); - const handleSendToEdit = async (filename: string) => { - if (isSendingToEdit) return; + const handleSendToEdit = async ( + filename: string, + storageMode: HistoryMetadata['storageModeUsed'] = effectiveStorageModeClient + ): Promise => { + if (isSendingToEdit) return false; + const sourceStorageMode = storageMode || 'fs'; setIsSendingToEdit(true); setError(null); const alreadyExists = editImageFiles.some((file) => file.name === filename); if (mode === 'edit' && alreadyExists) { setIsSendingToEdit(false); - return; + return true; } if (mode === 'edit' && editImageFiles.length >= MAX_EDIT_IMAGES) { setError(createErrorNotice(t('error.maxEditImages', { count: MAX_EDIT_IMAGES }))); setIsSendingToEdit(false); - return; + return false; } try { let blob: Blob | undefined; let mimeType: string = 'image/png'; - if (effectiveStorageModeClient === 'indexeddb') { + if (sourceStorageMode === 'indexeddb') { const record = allDbImages?.find((img) => img.filename === filename); if (record?.blob) { blob = record.blob; @@ -1406,7 +2250,7 @@ export default function HomePage() { } } else { if (!(await refreshImageAccessCookie())) { - return; + return false; } const response = await fetch(`/api/image/${filename}`); if (response.status === 401 && isPasswordRequiredByBackend) { @@ -1421,7 +2265,7 @@ export default function HomePage() { } else { setError(createErrorNotice(t('error.authVerifyUnavailable'))); } - return; + return false; } if (!response.ok) { throw new Error(t('error.fetchImage', { statusText: response.statusText })); @@ -1437,23 +2281,90 @@ export default function HomePage() { const newFile = new File([blob], filename, { type: mimeType }); const newPreviewUrl = URL.createObjectURL(blob); - editSourceImagePreviewUrls.forEach((url) => URL.revokeObjectURL(url)); - setEditImageFiles([newFile]); - setEditSourceImagePreviewUrls([newPreviewUrl]); + updateEditSourceImagePreviewUrls([newPreviewUrl]); + setEditReuseContext(null); if (mode === 'generate') { setMode('edit'); + setWorkbenchMode('edit'); } + return true; } catch (err: unknown) { console.error('发送图片到编辑模式失败:', err); const errorMessage = err instanceof Error ? err.message : t('error.sendToEdit'); setError(createErrorNotice(errorMessage)); + return false; } finally { setIsSendingToEdit(false); } }; + const handleSendHistoryToEdit = async (item: HistoryMetadata) => { + const firstImage = item.images[0]; + if (!firstImage) { + setError(createErrorNotice(t('error.historyMissingImage'))); + return; + } + + const sent = await handleSendToEdit(firstImage.filename, item.storageModeUsed || 'fs'); + if (!sent) return; + + const nextModel = item.model ?? editModel; + const normalizedRouteFields = normalizeImageUpstreamRuntimeFields( + { + image_backend: item.image_backend ?? editImageBackend, + streaming_strategy: item.streaming_strategy ?? editStreamingStrategy, + responsesModel: item.responsesModel ?? editResponsesModel, + thinking: item.thinking ?? editThinking, + promptOptimization: item.promptOptimization ?? editPromptOptimization + }, + { allowResponsesImageBackend: allowResponsesHistoryRoute } + ); + const sizeSelection = readHistorySizeSelection(item, nextModel); + const restoredFields = [t('reuse.fieldReferenceImage'), t('reuse.fieldPrompt')]; + setEditPrompt(item.prompt); + setEditModel(nextModel); + setEditSize(sizeSelection.size); + if (typeof sizeSelection.customWidth === 'number') { + setEditCustomWidth(sizeSelection.customWidth); + } + if (typeof sizeSelection.customHeight === 'number') { + setEditCustomHeight(sizeSelection.customHeight); + } + setEditQuality(item.quality); + setEditModeration(item.moderation); + setEnableParallelBatch(item.enableParallelBatch === true); + setEditImageBackend(normalizedRouteFields.image_backend); + setEditStreamingStrategy(normalizedRouteFields.streaming_strategy); + setEditResponsesModel(normalizedRouteFields.responsesModel); + setEditThinking(normalizedRouteFields.thinking); + setEditPromptOptimization(normalizedRouteFields.promptOptimization); + setEditForceWeb(item.forceWeb === true); + const imageCount = readHistoryImageCountSelection(item.images.length); + if (imageCount !== null) { + setEditN([imageCount]); + restoredFields.push(t('reuse.fieldCount')); + } + restoredFields.push(t('reuse.fieldModel'), t('reuse.fieldQuality'), t('reuse.fieldModeration'), t('reuse.fieldRoute')); + if (sizeSelection.restored) { + restoredFields.push(t('reuse.fieldSize')); + } + if (item.output_format) { + setEditOutputFormat(item.output_format); + restoredFields.push(t('reuse.fieldFormat')); + } + setEditReuseContext({ + sourceLabel: t('reuse.sourceHistory', { + time: new Date(item.timestamp).toLocaleString(locale) + }), + restoredFields: Array.from(new Set(restoredFields)), + promptPreview: item.prompt.trim() || t('history.noPrompt') + }); + setMode('edit'); + setWorkbenchMode('edit'); + }; + const executeDeleteItem = React.useCallback( async (item: HistoryMetadata) => { if (!item) return; @@ -1494,6 +2405,7 @@ export default function HomePage() { setLatestImageBatch((prev) => prev && prev.some((img) => filenamesToDelete.includes(img.filename)) ? null : prev ); + setActiveResultSource((current) => (current?.timestamp === timestamp ? null : current)); } catch (e: unknown) { console.error('删除条目失败:', e); setError(createErrorNotice(e instanceof Error ? e.message : t('error.deleteUnexpected'))); @@ -1528,9 +2440,18 @@ export default function HomePage() { }, []); const showEntryLock = isPasswordRequiredByBackend === true && !isEntryAuthenticated; + const outputFailureMessage = + !isLoading && !isSendingToEdit && !latestImageBatch && error?.message === generationFailureMessage + ? generationFailureMessage + : null; + const canRetryLastGeneration = Boolean(lastApiCallArgs) && !isLoading && !isSendingToEdit; + function handleRetryLastGeneration() { + if (!lastApiCallArgs) return; + void handleApiCall(...lastApiCallArgs, clientPasswordHash); + } return ( -
+
{showEntryLock ? ( -
-
+
+
-

{t('password.required')}

-

{t('password.entryDescription')}

+

{t('password.required')}

+

{t('password.entryDescription')}

{error && ( - - {t('common.error')} + + {t('common.error')} {renderErrorDescription(error)} )} @@ -1580,23 +2503,132 @@ export default function HomePage() { setPasswordDialogContext('initial'); setIsPasswordDialogOpen(true); }} - className='bg-white px-6 text-black hover:bg-white/90'> + className='px-6'> {t('password.unlock')}
) : null} {!showEntryLock && isPasswordRequiredByBackend !== null ? ( <> -
- setIsApiSettingsDialogOpen(true)} /> -
-
+ {isMobileCreationDrawerOpen && ( + +
+
+ +
+
+
+ +
+
+ {isMobileCreationDrawerOpen && ( +
+ + +
+ )} + {isMobileCreationDrawerOpen && ( +
+ + +
+ )}
setReuseContext(null)} isPasswordRequiredByBackend={isPasswordRequiredByBackend} clientPasswordHash={clientPasswordHash} onOpenPasswordDialog={handleOpenPasswordDialog} @@ -1604,6 +2636,12 @@ export default function HomePage() { setModel={setGenModel} prompt={genPrompt} setPrompt={setGenPrompt} + batchPromptText={genBatchPromptText} + setBatchPromptText={handleBatchPromptTextChange} + failedBatchPrompts={failedBatchPrompts} + canPauseBatch={canPausePromptBatch} + isBatchPauseRequested={isBatchPauseRequested} + onPauseBatch={handlePauseBatch} n={genN} setN={setGenN} size={genSize} @@ -1622,25 +2660,39 @@ export default function HomePage() { setBackground={setGenBackground} moderation={genModeration} setModeration={setGenModeration} - enableStreaming={enableStreaming} - setEnableStreaming={setEnableStreaming} + streamMode={streamMode} + setStreamMode={setStreamMode} allowStreamingBatch={streamingBatchEnabled} + enableParallelBatch={enableParallelBatch} + setEnableParallelBatch={setEnableParallelBatch} partialImages={partialImages} setPartialImages={setPartialImages} + allowResponsesImageBackend={allowResponsesImageBackend} + hasDefaultResponsesModel={hasDefaultResponsesModel} imageBackend={genImageBackend} setImageBackend={setGenImageBackend} streamingStrategy={genStreamingStrategy} + defaultStreamingStrategy={defaultStreamingStrategy} setStreamingStrategy={setGenStreamingStrategy} responsesModel={genResponsesModel} setResponsesModel={setGenResponsesModel} + thinking={genThinking} + setThinking={setGenThinking} + promptOptimization={genPromptOptimization} + setPromptOptimization={setGenPromptOptimization} + forceWeb={genForceWeb} + setForceWeb={setGenForceWeb} + estimatedCostLabel={activeEstimatedCostLabel} />
setEditReuseContext(null)} isPasswordRequiredByBackend={isPasswordRequiredByBackend} clientPasswordHash={clientPasswordHash} onOpenPasswordDialog={handleOpenPasswordDialog} @@ -1649,7 +2701,13 @@ export default function HomePage() { imageFiles={editImageFiles} sourceImagePreviewUrls={editSourceImagePreviewUrls} setImageFiles={setEditImageFiles} - setSourceImagePreviewUrls={setEditSourceImagePreviewUrls} + setSourceImagePreviewUrls={(nextUrls) => { + const resolvedUrls = + typeof nextUrls === 'function' + ? nextUrls(editSourceImagePreviewUrlsRef.current) + : nextUrls; + updateEditSourceImagePreviewUrls(resolvedUrls); + }} maxImages={MAX_EDIT_IMAGES} editPrompt={editPrompt} setEditPrompt={setEditPrompt} @@ -1663,6 +2721,12 @@ export default function HomePage() { setEditCustomHeight={setEditCustomHeight} editQuality={editQuality} setEditQuality={setEditQuality} + editOutputFormat={editOutputFormat} + setEditOutputFormat={setEditOutputFormat} + editCompression={editCompression} + setEditCompression={setEditCompression} + editModeration={editModeration} + setEditModeration={setEditModeration} editBrushSize={editBrushSize} setEditBrushSize={setEditBrushSize} editShowMaskEditor={editShowMaskEditor} @@ -1677,100 +2741,251 @@ export default function HomePage() { setEditDrawnPoints={setEditDrawnPoints} editMaskPreviewUrl={editMaskPreviewUrl} setEditMaskPreviewUrl={setEditMaskPreviewUrl} - enableStreaming={enableStreaming} - setEnableStreaming={setEnableStreaming} + streamMode={streamMode} + setStreamMode={setStreamMode} allowStreamingBatch={streamingBatchEnabled} + enableParallelBatch={enableParallelBatch} + setEnableParallelBatch={setEnableParallelBatch} partialImages={partialImages} setPartialImages={setPartialImages} + allowResponsesImageBackend={allowResponsesImageBackend} + hasDefaultResponsesModel={hasDefaultResponsesModel} + editImageBackend={editImageBackend} + setEditImageBackend={setEditImageBackend} + editStreamingStrategy={editStreamingStrategy} + defaultStreamingStrategy={defaultStreamingStrategy} + setEditStreamingStrategy={setEditStreamingStrategy} + editResponsesModel={editResponsesModel} + setEditResponsesModel={setEditResponsesModel} + editThinking={editThinking} + setEditThinking={setEditThinking} + editPromptOptimization={editPromptOptimization} + setEditPromptOptimization={setEditPromptOptimization} + editForceWeb={editForceWeb} + setEditForceWeb={setEditForceWeb} + estimatedCostLabel={activeEstimatedCostLabel} />
-
-
+
+ aria-label={t('app.canvasPreview')} + aria-hidden={isMobileCreationDrawerOpen} + inert={isMobileCreationDrawerOpen} + className='order-1 flex min-h-[380px] scroll-mt-4 flex-col sm:min-h-[460px] lg:order-2 xl:min-h-0'> {error && ( - {t('common.error')} + className='border-destructive/45 bg-destructive/10 text-destructive mb-4'> + {t('common.error')} {renderErrorDescription(error)} )} - + +
+ -
-
- -
- + +
-
-
- - - {canOpenLogs && ( + className='mx-auto mb-2 flex h-11 w-24 touch-none items-center justify-center rounded-full select-none' + onPointerDown={beginMobileCreationDrawerGesture} + onPointerUp={finishMobileCreationDrawerGesture} + onPointerCancel={cancelMobileCreationDrawerGesture} + onClick={handleMobileCreationDrawerHandleClick} + aria-label={t('ux.openCreationSheet')} + aria-controls='mobile-creation-sheet' + aria-expanded={false}> +
-
+ )} ) : null}
diff --git a/src/components/api-settings-dialog.tsx b/src/components/api-settings-dialog.tsx index 77b25a787f8621194e3661435614849b263c4c06..a19c5930dd46423771e024c1a25be5ee687271e3 100644 --- a/src/components/api-settings-dialog.tsx +++ b/src/components/api-settings-dialog.tsx @@ -71,6 +71,11 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap } }; + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + handleSave(); + }; + const handleClear = () => { const emptySettings = { apiKey: '', baseUrl: '' }; try { @@ -87,54 +92,72 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap return ( - - {t('api.title')} - {t('api.description')} - -
-
- - setDraft((current) => ({ ...current, apiKey: event.target.value }))} - /> -
-
- - setDraft((current) => ({ ...current, baseUrl: event.target.value }))} - /> -

{t('api.urlHint')}

+
+ + {t('api.title')} + {t('api.description')} + +
+
+ + + setDraft((current) => ({ ...current, apiKey: event.target.value })) + } + /> +
+
+ + + setDraft((current) => ({ ...current, baseUrl: event.target.value })) + } + /> +

{t('api.urlHint')}

+
-
- - {saveStatus === 'saved' && ( -

{t('api.saved')}

- )} - {saveStatus === 'error' && ( -

{t('api.saveFailed')}

- )} - - -
+ + {saveStatus === 'saved' && ( +

+ {t('api.saved')} +

+ )} + {saveStatus === 'error' && ( +

+ {t('api.saveFailed')} +

+ )} + + +
+
); diff --git a/src/components/app-controls.tsx b/src/components/app-controls.tsx deleted file mode 100644 index 9d97f4ccb0b3bccb74a74fe0aa46fa60642dae40..0000000000000000000000000000000000000000 --- a/src/components/app-controls.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { Button } from '@/components/ui/button'; -import { useI18n } from '@/lib/i18n'; -import { cn } from '@/lib/utils'; -import { Languages, Moon, Settings2, Sun } from 'lucide-react'; -import { useTheme } from 'next-themes'; -import * as React from 'react'; - -type AppControlsProps = { - onOpenApiSettings: () => void; -}; - -export function AppControls({ onOpenApiSettings }: AppControlsProps) { - const { locale, setLocale, t } = useI18n(); - const { resolvedTheme, setTheme } = useTheme(); - const [isMounted, setIsMounted] = React.useState(false); - const effectiveTheme = isMounted ? resolvedTheme : 'light'; - const isDark = effectiveTheme === 'dark'; - - React.useEffect(() => { - queueMicrotask(() => setIsMounted(true)); - }, []); - - return ( -
-
- - - -
- - -
- ); -} diff --git a/src/components/editing-form.test.tsx b/src/components/editing-form.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62f4f784a6767998feb3f7399faa1c6252603ea3 --- /dev/null +++ b/src/components/editing-form.test.tsx @@ -0,0 +1,333 @@ +import { EditingForm, type EditingFormData } from './editing-form'; +import { I18nProvider } from '@/lib/i18n'; +import type { ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import * as React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; + +type RenderOptions = { + backend: EditingFormData['image_backend']; + outputFormat?: EditingFormData['output_format']; + advancedOpen?: boolean; + advancedTab?: 'output' | 'model' | 'stream' | 'route'; + reuseContext?: React.ComponentProps['reuseContext']; + allowStreamingBatch?: boolean; + enableParallelBatch?: boolean; + editN?: number[]; + streamingStrategy?: EditingFormData['streaming_strategy']; + defaultStreamingStrategy?: ImageStreamingStrategy; + allowResponsesImageBackend?: boolean; + hasDefaultResponsesModel?: boolean; + editResponsesModel?: string; + editPrompt?: string; + imageFiles?: File[]; +}; + +const noop = () => {}; + +function renderEditingForm({ + backend, + outputFormat = 'png', + advancedOpen = true, + advancedTab = 'route', + reuseContext = null, + allowStreamingBatch = false, + enableParallelBatch = false, + editN = [1], + streamingStrategy = 'server-default', + defaultStreamingStrategy = 'auto', + allowResponsesImageBackend = true, + hasDefaultResponsesModel = true, + editResponsesModel = '', + editPrompt = '', + imageFiles = [] +}: RenderOptions): string { + return renderToStaticMarkup( + + + + ); +} + +describe('EditingForm advanced upstream controls', () => { + it('keeps the full professional accordion available on desktop and mobile', () => { + const html = renderEditingForm({ backend: 'server-default', advancedTab: 'route' }); + + assert.match( + html, + /
]*aria-controls="editing-advanced-panel"/ + ); + assert.doesNotMatch( + html, + /
]*aria-controls="editing-advanced-panel"/ + ); + }); + + it('keeps model and streaming controls out of the default edit form surface', () => { + const html = renderEditingForm({ backend: 'server-default', advancedOpen: false }); + + assert.match(html, /参考图/); + assert.match(html, /修改想法/); + assert.match(html, /专业模式/); + assert.doesNotMatch(html, /edit-model-select/); + assert.doesNotMatch(html, /edit-stream-mode-select/); + }); + + it('translates the default backend into a user-facing route label near submit', () => { + const html = renderEditingForm({ backend: 'server-default', advancedOpen: false }); + + assert.match(html, /默认线路/); + assert.match(html, /预计 0\.12 积分/); + }); + + it('renders edit model controls only in the professional model tab', () => { + const html = renderEditingForm({ backend: 'server-default', advancedTab: 'model' }); + + assert.match(html, /edit-model-select/); + assert.match(html, /gpt-image-2 始终以高保真方式处理参考图/); + assert.doesNotMatch(html, /edit-image-backend-select/); + }); + + it('renders edit stream controls only in the professional stream tab', () => { + const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream' }); + + assert.match(html, /edit-stream-mode-select/); + assert.match(html, /edit-partial-1/); + assert.doesNotMatch(html, /edit-model-select/); + }); + + it('renders an explicit parallel batch toggle in edit stream settings', () => { + const html = renderEditingForm({ + backend: 'server-default', + advancedTab: 'stream', + allowStreamingBatch: true, + enableParallelBatch: true, + editN: [2] + }); + + assert.match(html, /并发批量/); + assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/); + assert.match(html, /id="edit-parallel-batch-enabled"/); + assert.match(html, /aria-checked="true"/); + }); + + it('keeps edit parallel batch disabled for a single output image', () => { + const html = renderEditingForm({ + backend: 'server-default', + advancedTab: 'stream', + allowStreamingBatch: true, + enableParallelBatch: true + }); + + assert.match(html, /选择至少 2 张图片或 2 条提示词后可启用并发/); + assert.match(html, /id="edit-parallel-batch-enabled"/); + assert.match(html, /aria-checked="false"/); + assert.match(html, /disabled=""/); + }); + + it('keeps edit parallel batch disabled when streaming strategy is off', () => { + const html = renderEditingForm({ + backend: 'server-default', + advancedTab: 'stream', + allowStreamingBatch: true, + enableParallelBatch: true, + editN: [2], + streamingStrategy: 'off' + }); + + assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); + assert.match(html, /id="edit-parallel-batch-enabled"/); + assert.match(html, /aria-checked="false"/); + assert.match(html, /disabled=""/); + }); + + it('keeps edit parallel batch disabled when the server default streaming strategy is off', () => { + const html = renderEditingForm({ + backend: 'server-default', + advancedTab: 'stream', + allowStreamingBatch: true, + enableParallelBatch: true, + editN: [2], + defaultStreamingStrategy: 'off' + }); + + assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/); + assert.match(html, /id="edit-parallel-batch-enabled"/); + assert.match(html, /aria-checked="false"/); + assert.match(html, /disabled=""/); + }); + + it('disables the edit stream mode selector when the server default streaming strategy is off', () => { + const html = renderEditingForm({ + backend: 'server-default', + advancedTab: 'stream', + defaultStreamingStrategy: 'off' + }); + + assert.match(html, /]*(?:disabled=""[^>]*id="edit-stream-mode-select"|id="edit-stream-mode-select"[^>]*disabled="")/); + }); + + it('renders Responses-specific edit controls when the Responses backend is selected', () => { + const html = renderEditingForm({ backend: 'responses-image-generation' }); + + assert.match(html, /图片生成后端/); + assert.match(html, /影响说明/); + assert.match(html, /Responses image_generation 需要实验开关和顶层模型/); + assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.match(html, /GPT 顶层模型/); + assert.match(html, /思考强度/); + assert.match(html, /提示词优化/); + assert.doesNotMatch(html, /优先 Web 账号/); + }); + + it('explains the resolved edit server default streaming strategy', () => { + const offHtml = renderEditingForm({ + backend: 'server-default', + advancedTab: 'route', + streamingStrategy: 'server-default', + defaultStreamingStrategy: 'off' + }); + const forceHtml = renderEditingForm({ + backend: 'server-default', + advancedTab: 'route', + streamingStrategy: 'server-default', + defaultStreamingStrategy: 'force-sse' + }); + + assert.match(offHtml, /关闭流式会减少长连接不稳定因素/); + assert.doesNotMatch(offHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.match(forceHtml, /强制 SSE 会跳过自动判断/); + assert.doesNotMatch(forceHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); + }); + + it('disables the experimental Responses backend when runtime capabilities do not allow it', () => { + const html = renderEditingForm({ + backend: 'server-default', + allowResponsesImageBackend: false + }); + + assert.match(html, /当前运行时未启用 Responses image_generation/); + assert.doesNotMatch(html, /GPT 顶层模型/); + }); + + it('blocks Responses edits until a top-level model is available', () => { + const html = renderEditingForm({ + backend: 'responses-image-generation', + hasDefaultResponsesModel: false, + editResponsesModel: '', + editPrompt: '用户真实编辑要求', + imageFiles: [new File(['x'], 'source.png', { type: 'image/png' })] + }); + + assert.match(html, /Responses image_generation 需要填写 GPT 顶层模型/); + assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); + }); + + it('renders Images API edit controls and compression when JPEG output is selected', () => { + const html = renderEditingForm({ backend: 'images-api', outputFormat: 'jpeg', advancedTab: 'output' }); + + assert.match(html, /Images API/); + assert.match(html, /输出格式/); + assert.match(html, /压缩:85%/); + assert.match(html, /内容审核级别/); + assert.doesNotMatch(html, /GPT 顶层模型/); + }); +}); + +describe('EditingForm reused history context', () => { + it('shows which history values were carried into edit mode', () => { + const html = renderEditingForm({ + backend: 'server-default', + reuseContext: { + sourceLabel: '最近生成:2026/6/2 12:00:00', + restoredFields: ['参考图', '提示词', '模型', '尺寸', '数量'], + promptPreview: '用户真实编辑提示词' + } + }); + + assert.match(html, /已带入内容/); + assert.match(html, /最近生成:2026\/6\/2 12:00:00/); + assert.match(html, /参考图/); + assert.match(html, /提示词/); + assert.match(html, /模型/); + assert.match(html, /尺寸/); + assert.match(html, /数量/); + assert.match(html, /用户真实编辑提示词/); + assert.match(html, /这些内容已经写入编辑单,可以修改后再生成。/); + }); +}); diff --git a/src/components/editing-form.tsx b/src/components/editing-form.tsx index 08e40a706f08112777217655204d3f65884710ac..85a4e4845d028671d69598250c5aea69cc98101a 100644 --- a/src/components/editing-form.tsx +++ b/src/components/editing-form.tsx @@ -1,22 +1,37 @@ 'use client'; import { ModeToggle } from '@/components/mode-toggle'; +import type { WorkbenchMode } from '@/components/mode-toggle'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Slider } from '@/components/ui/slider'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { GptImageModel } from '@/lib/cost-utils'; import { useI18n } from '@/lib/i18n'; +import type { + ImageUpstreamFormBackend, + ImageUpstreamFormPromptOptimization, + ImageUpstreamFormStreamingStrategy, + ImageUpstreamFormThinking +} from '@/lib/image-upstream-form'; +import { + getImageUpstreamRouteImpactKeys, + isImageUpstreamStreamingStrategySelectable, + resolveImageUpstreamEffectiveStreamingStrategy +} from '@/lib/image-upstream-form'; +import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { getPresetTooltip, validateGptImage2Size } from '@/lib/size-utils'; import type { SizePreset } from '@/lib/size-utils'; +import { resolveStreamingBatchToggleState } from '@/lib/streaming-batch'; +import { getStreamingStatusLabel } from '@/lib/streaming-status-label'; import { - Upload, Eraser, Save, Square, @@ -36,7 +51,11 @@ import { LockOpen, HelpCircle, SquareDashed, - Info + FileImage, + WandSparkles, + Globe2, + ShieldCheck, + ShieldAlert } from 'lucide-react'; import Image from 'next/image'; import * as React from 'react'; @@ -54,16 +73,28 @@ export type EditingFormData = { customWidth: number; customHeight: number; quality: 'low' | 'medium' | 'high' | 'auto'; + output_format: 'png' | 'jpeg' | 'webp'; + output_compression?: number; + moderation: 'low' | 'auto'; imageFiles: File[]; maskFile: File | null; model: GptImageModel; + image_backend: ImageUpstreamFormBackend; + streaming_strategy: ImageUpstreamFormStreamingStrategy; + responsesModel: string; + thinking: ImageUpstreamFormThinking; + promptOptimization: ImageUpstreamFormPromptOptimization; + forceWeb: boolean; + enableParallelBatch: boolean; }; type EditingFormProps = { onSubmit: (data: EditingFormData) => void; isLoading: boolean; - currentMode: 'generate' | 'edit'; - onModeChange: (mode: 'generate' | 'edit') => void; + currentMode: WorkbenchMode; + onModeChange: (mode: WorkbenchMode) => void; + reuseContext: EditingReuseContext | null; + onClearReuseContext: () => void; isPasswordRequiredByBackend: boolean | null; clientPasswordHash: string | null; onOpenPasswordDialog: () => void; @@ -86,6 +117,12 @@ type EditingFormProps = { setEditCustomHeight: React.Dispatch>; editQuality: EditingFormData['quality']; setEditQuality: React.Dispatch>; + editOutputFormat: EditingFormData['output_format']; + setEditOutputFormat: React.Dispatch>; + editCompression: number[]; + setEditCompression: React.Dispatch>; + editModeration: EditingFormData['moderation']; + setEditModeration: React.Dispatch>; editBrushSize: number[]; setEditBrushSize: React.Dispatch>; editShowMaskEditor: boolean; @@ -100,13 +137,41 @@ type EditingFormProps = { setEditDrawnPoints: React.Dispatch>; editMaskPreviewUrl: string | null; setEditMaskPreviewUrl: React.Dispatch>; - enableStreaming: boolean; - setEnableStreaming: React.Dispatch>; + streamMode: ImageStreamMode; + setStreamMode: React.Dispatch>; allowStreamingBatch: boolean; + enableParallelBatch: boolean; + setEnableParallelBatch: React.Dispatch>; partialImages: 1 | 2 | 3; setPartialImages: React.Dispatch>; + allowResponsesImageBackend: boolean; + hasDefaultResponsesModel: boolean; + editImageBackend: EditingFormData['image_backend']; + setEditImageBackend: React.Dispatch>; + editStreamingStrategy: EditingFormData['streaming_strategy']; + defaultStreamingStrategy: ImageStreamingStrategy; + setEditStreamingStrategy: React.Dispatch>; + editResponsesModel: string; + setEditResponsesModel: React.Dispatch>; + editThinking: EditingFormData['thinking']; + setEditThinking: React.Dispatch>; + editPromptOptimization: EditingFormData['promptOptimization']; + setEditPromptOptimization: React.Dispatch>; + editForceWeb: boolean; + setEditForceWeb: React.Dispatch>; + estimatedCostLabel: string; + initialAdvancedOpen?: boolean; + initialAdvancedTab?: AdvancedTab; +}; + +export type EditingReuseContext = { + sourceLabel: string; + restoredFields: string[]; + promptPreview: string; }; +type AdvancedTab = 'output' | 'model' | 'stream' | 'route'; + const RadioItemWithIcon = ({ value, id, @@ -128,9 +193,9 @@ const RadioItemWithIcon = ({ id={id} disabled={disabled} aria-label={label} - className='flex aspect-auto h-auto min-h-10 w-full items-center justify-start gap-2 rounded-md border-border px-3 py-2 text-sm text-muted-foreground shadow-none transition-[background-color,border-color,color,box-shadow,transform] enabled:motion-safe:hover:-translate-y-0.5 enabled:motion-safe:hover:scale-100 enabled:motion-safe:active:scale-100 enabled:hover:border-foreground/20 enabled:hover:bg-accent enabled:hover:text-accent-foreground enabled:active:translate-y-0 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=radio-group-indicator]]:hidden'> - - {label} + className='border-border bg-background/58 text-muted-foreground enabled:hover:border-primary/25 enabled:hover:bg-accent/45 enabled:hover:text-foreground data-[state=checked]:border-primary/55 data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary flex aspect-auto h-auto min-h-11 w-full flex-col items-center justify-center gap-0.5 rounded-md px-1 py-1 text-xs shadow-none transition-[background-color,border-color,color,box-shadow,transform] enabled:active:translate-y-0 enabled:motion-safe:hover:-translate-y-0.5 enabled:motion-safe:hover:scale-100 enabled:motion-safe:active:scale-100 lg:min-h-8 [&_[data-slot=radio-group-indicator]]:hidden'> + + {label} ); @@ -144,11 +209,44 @@ const RadioItemWithIcon = ({ ); }; +function getBackendLabel(backend: EditingFormData['image_backend'], t: (key: string) => string): string { + if (backend === 'images-api') return t('upstream.backendImages'); + if (backend === 'responses-image-generation') return t('upstream.backendResponses'); + return t('upstream.serverDefault'); +} + +function getWorkbenchBackendLabel(backend: EditingFormData['image_backend'], t: (key: string) => string): string { + if (backend === 'images-api') return t('upstream.backendImages'); + if (backend === 'responses-image-generation') return t('upstream.backendResponses'); + return t('upstream.workbenchDefaultRoute'); +} + +function getStreamModeLabel(streamMode: ImageStreamMode, t: (key: string) => string): string { + if (streamMode === 'stream') return t('streaming.modeStream'); + if (streamMode === 'non_stream') return t('streaming.modeNonStream'); + return t('streaming.modeAuto'); +} + +function getQualityLabel(quality: EditingFormData['quality'], t: (key: string) => string): string { + if (quality === 'low') return t('common.low'); + if (quality === 'medium') return t('common.medium'); + if (quality === 'high') return t('common.high'); + return t('common.auto'); +} + +function getOutputFormatLabel(format: EditingFormData['output_format'], t: (key: string) => string): string { + if (format === 'jpeg') return t('common.jpeg'); + if (format === 'webp') return t('common.webp'); + return t('common.png'); +} + export function EditingForm({ onSubmit, isLoading, currentMode, onModeChange, + reuseContext, + onClearReuseContext, isPasswordRequiredByBackend, clientPasswordHash, onOpenPasswordDialog, @@ -171,6 +269,12 @@ export function EditingForm({ setEditCustomHeight, editQuality, setEditQuality, + editOutputFormat, + setEditOutputFormat, + editCompression, + setEditCompression, + editModeration, + setEditModeration, editBrushSize, setEditBrushSize, editShowMaskEditor, @@ -185,11 +289,31 @@ export function EditingForm({ setEditDrawnPoints, editMaskPreviewUrl, setEditMaskPreviewUrl, - enableStreaming, - setEnableStreaming, + streamMode, + setStreamMode, allowStreamingBatch, + enableParallelBatch, + setEnableParallelBatch, partialImages, - setPartialImages + setPartialImages, + allowResponsesImageBackend, + hasDefaultResponsesModel, + editImageBackend, + setEditImageBackend, + editStreamingStrategy, + defaultStreamingStrategy, + setEditStreamingStrategy, + editResponsesModel, + setEditResponsesModel, + editThinking, + setEditThinking, + editPromptOptimization, + setEditPromptOptimization, + editForceWeb, + setEditForceWeb, + estimatedCostLabel, + initialAdvancedOpen = false, + initialAdvancedTab = 'output' }: EditingFormProps) { const { locale, t } = useI18n(); const [firstImagePreviewUrl, setFirstImagePreviewUrl] = React.useState(null); @@ -210,16 +334,35 @@ export function EditingForm({ const editCustomSizeError = customSizeValidation.valid ? null : t(customSizeValidation.reasonKey, customSizeValidation.values); - - const streamingDisabledByCount = editN[0] > 1 && !allowStreamingBatch; - const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(false); + const showCompression = editOutputFormat === 'jpeg' || editOutputFormat === 'webp'; + const effectiveStreamingStrategy = resolveImageUpstreamEffectiveStreamingStrategy({ + streamingStrategy: editStreamingStrategy, + defaultStreamingStrategy + }); + const streamingDisabledByStrategy = effectiveStreamingStrategy === 'off'; + const parallelBatchToggle = resolveStreamingBatchToggleState({ + allowStreamingBatch, + userEnabled: enableParallelBatch, + targetCount: editN[0], + streamMode, + streamingStrategy: effectiveStreamingStrategy + }); + const canEnableParallelBatch = parallelBatchToggle.canEnable; + const parallelBatchChecked = parallelBatchToggle.checked; + const parallelBatchUnavailableKey = parallelBatchToggle.unavailableReasonKey; + + const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(initialAdvancedOpen); + const [advancedTab, setAdvancedTab] = React.useState(initialAdvancedTab); + const requiresResponsesModel = + editImageBackend === 'responses-image-generation' && !hasDefaultResponsesModel && !editResponsesModel.trim(); const submitDisabledReason = React.useMemo(() => { if (isLoading) return ''; - if (!editPrompt.trim()) return t('ux.disabledPrompt'); if (imageFiles.length === 0) return t('ux.disabledSourceImage'); + if (!editPrompt.trim()) return t('ux.disabledPrompt'); if (editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved) { return t('ux.disabledUnsavedMask'); } + if (requiresResponsesModel) return t('upstream.responsesModelRequired'); if (customSizeInvalid) return editCustomSizeError || t('ux.disabledCustomSize'); return ''; }, [ @@ -231,15 +374,17 @@ export function EditingForm({ editPrompt, imageFiles.length, isLoading, + requiresResponsesModel, t ]); - - // 未显式开启批量流式分发时,editN > 1 会禁用流式输出。 - React.useEffect(() => { - if (streamingDisabledByCount && enableStreaming) { - setEnableStreaming(false); - } - }, [streamingDisabledByCount, enableStreaming, setEnableStreaming]); + const advancedSummary = [ + `${t('form.quality')}: ${getQualityLabel(editQuality, t)}`, + `${t('form.outputFormat')}: ${getOutputFormatLabel(editOutputFormat, t)}`, + getBackendLabel(editImageBackend, t) + ].join(', '); + const streamModeLabel = getStreamModeLabel(streamMode, t); + const streamStatusLabel = getStreamingStatusLabel(streamMode, t); + const workbenchBackendLabel = getWorkbenchBackendLabel(editImageBackend, t); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { @@ -248,11 +393,22 @@ export function EditingForm({ } }, [isGptImage2, editSize, setEditSize]); + React.useEffect(() => { + if (streamingDisabledByStrategy && streamMode !== 'non_stream') { + setStreamMode('non_stream'); + } + }, [streamingDisabledByStrategy, streamMode, setStreamMode]); + const canvasRef = React.useRef(null); const visualFeedbackCanvasRef = React.useRef(null); const isDrawing = React.useRef(false); const lastPos = React.useRef<{ x: number; y: number } | null>(null); + const imageInputRef = React.useRef(null); const maskInputRef = React.useRef(null); + const primaryImagePreviewUrl = sourceImagePreviewUrls[0] ?? null; + const hasSourceImages = imageFiles.length > 0; + const hasSourcePreviews = sourceImagePreviewUrls.length > 0; + const canAddSourceImage = !isLoading && imageFiles.length < maxImages; React.useEffect(() => { if (editOriginalImageSize) { @@ -265,7 +421,10 @@ export function EditingForm({ }, [editOriginalImageSize]); React.useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; setEditGeneratedMaskFile(null); setEditIsMaskSaved(false); setEditOriginalImageSize(null); @@ -274,26 +433,35 @@ export function EditingForm({ setEditMaskPreviewUrl(null); }); - if (imageFiles.length > 0 && sourceImagePreviewUrls.length > 0) { + if (primaryImagePreviewUrl) { const img = new window.Image(); img.onload = () => { + if (cancelled) return; setEditOriginalImageSize({ width: img.width, height: img.height }); }; - img.src = sourceImagePreviewUrls[0]; + img.src = primaryImagePreviewUrl; queueMicrotask(() => { - setFirstImagePreviewUrl(sourceImagePreviewUrls[0]); + if (!cancelled) { + setFirstImagePreviewUrl(primaryImagePreviewUrl); + } }); } else { queueMicrotask(() => { - setEditShowMaskEditor(false); + if (!cancelled) { + setEditShowMaskEditor(false); + } }); } + + return () => { + cancelled = true; + }; }, [ - imageFiles, - sourceImagePreviewUrls, + primaryImagePreviewUrl, setEditGeneratedMaskFile, setEditIsMaskSaved, setEditOriginalImageSize, + setFirstImagePreviewUrl, setEditDrawnPoints, setEditMaskPreviewUrl, setEditShowMaskEditor @@ -555,137 +723,129 @@ export function EditingForm({ customWidth: editCustomWidth, customHeight: editCustomHeight, quality: editQuality, + output_format: editOutputFormat, + ...(showCompression ? { output_compression: editCompression[0] } : {}), + moderation: editModeration, imageFiles: imageFiles, maskFile: editGeneratedMaskFile, - model: editModel + model: editModel, + image_backend: editImageBackend, + streaming_strategy: editStreamingStrategy, + responsesModel: editResponsesModel, + thinking: editThinking, + promptOptimization: editPromptOptimization, + forceWeb: editForceWeb, + enableParallelBatch: parallelBatchChecked }; onSubmit(formData); }; - const displayFileNames = (files: File[]) => { - if (files.length === 0) return t('edit.noFile'); - if (files.length === 1) return files[0].name; - return t('edit.filesSelected', { count: files.length }); - }; - return ( - - -
-
- {t('edit.title')} - {isPasswordRequiredByBackend && ( - - )} -
- {t('edit.description')} -
+ +
- -
- -
- - {isGptImage2 && ( - - - - - {t('edit.fidelityHint')} - + +
+
+ + {t('workbench.creationSheet')} + + {isPasswordRequiredByBackend && ( + )} - -
- - setEnableStreaming(!!checked)} - disabled={isLoading || streamingDisabledByCount} - className='data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground' - /> - - -
- - {streamingDisabledByCount - ? t('streaming.disabledByCount') - : allowStreamingBatch && editN[0] > 1 - ? t('streaming.batchDescription') - : t('streaming.description')} - -
- -
- -