misonL commited on
Commit
e445b51
·
verified ·
1 Parent(s): 9d44811

Deploy 76de60b to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@76de60b42bdc80653167d4bceed12f29c472ac6d

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.agent.example +2 -1
  2. .env.example +21 -4
  3. .env.real-smoke.example +2 -1
  4. .github/workflows/hf-space-keepalive.yml +2 -0
  5. CHANGELOG.md +22 -3
  6. README.md +82 -22
  7. docs/deployment/huggingface-space-free.md +8 -0
  8. docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +1 -1
  9. docs/ui/literary-young-women-workbench-design.md +550 -0
  10. package-lock.json +3 -2
  11. package.json +3 -2
  12. scripts/agent-doctor.mjs +298 -11
  13. scripts/agent-skill-scripts.test.mjs +0 -0
  14. scripts/command-center.test.mjs +146 -0
  15. scripts/keepalive-hf-space.mjs +67 -17
  16. scripts/keepalive-hf-space.test.mjs +86 -3
  17. scripts/local-image-upstream-fixture.mjs +21 -3
  18. scripts/local-image-upstream-fixture.test.mjs +13 -4
  19. scripts/smoke-image-upstream-local-final-gate.mjs +32 -7
  20. scripts/smoke-image-upstream-local-final-gate.test.mjs +22 -3
  21. skills/gpt-image-playground-agent/SKILL.md +73 -47
  22. skills/gpt-image-playground-agent/agents/openai.yaml +2 -2
  23. skills/gpt-image-playground-agent/references/api.md +84 -15
  24. skills/gpt-image-playground-agent/scripts/batch-images.mjs +1226 -0
  25. skills/gpt-image-playground-agent/scripts/edit-image.mjs +309 -28
  26. skills/gpt-image-playground-agent/scripts/generate-image.mjs +833 -719
  27. skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs +30 -0
  28. skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs +407 -0
  29. skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +73 -8
  30. skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs +2 -0
  31. src/app/api/agent/agent-routes.test.ts +191 -18
  32. src/app/api/agent/images/edit/route.ts +0 -2
  33. src/app/api/images/route-test-helpers.ts +182 -4
  34. src/app/api/images/route.test.ts +497 -42
  35. src/app/api/images/route.ts +177 -16
  36. src/app/api/runtime-capabilities/route.test.ts +130 -5
  37. src/app/api/runtime-capabilities/route.ts +32 -3
  38. src/app/globals.css +165 -54
  39. src/app/layout.tsx +2 -2
  40. src/app/page-regressions.test.ts +14 -0
  41. src/app/page.tsx +0 -0
  42. src/components/api-settings-dialog.tsx +70 -47
  43. src/components/app-controls.tsx +0 -63
  44. src/components/editing-form.test.tsx +333 -0
  45. src/components/editing-form.tsx +0 -0
  46. src/components/generation-activity-timeline.tsx +265 -0
  47. src/components/generation-form.test.tsx +406 -0
  48. src/components/generation-form.tsx +0 -0
  49. src/components/history-panel.test.tsx +236 -0
  50. src/components/history-panel.tsx +708 -190
.env.agent.example CHANGED
@@ -21,10 +21,11 @@ AGENT_REQUEST_LEASE_MS=600000
21
  AGENT_REQUEST_TTL_SECONDS=86400
22
  AGENT_RECOVERY_INTERVAL_MS=30000
23
 
24
- # 可选:供 OpenAPI 客户端使用的公开基础地址。
25
  # AGENT_PUBLIC_BASE_URL=http://localhost:4783
26
 
27
  # 现有应用配置仍然生效。
28
  OPENAI_API_KEY=
 
29
  OPENAI_API_BASE_URL=https://api.openai.com/v1
30
  APP_PASSWORD=
 
21
  AGENT_REQUEST_TTL_SECONDS=86400
22
  AGENT_RECOVERY_INTERVAL_MS=30000
23
 
24
+ # 可选:供 OpenAPI 客户端使用的公开基础地址。必须是不含凭据、查询参数和片段的 http/https 绝对 URL。
25
  # AGENT_PUBLIC_BASE_URL=http://localhost:4783
26
 
27
  # 现有应用配置仍然生效。
28
  OPENAI_API_KEY=
29
+ # OpenAI 兼容接口根地址,通常以 /v1 结尾。支持不含凭据、查询参数和片段的 http/https 绝对 URL。
30
  OPENAI_API_BASE_URL=https://api.openai.com/v1
31
  APP_PASSWORD=
.env.example CHANGED
@@ -5,6 +5,8 @@
5
  OPENAI_API_KEY=
6
 
7
  # 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。
 
 
8
  # 示例:https://api.openai.com/v1
9
  OPENAI_API_BASE_URL=
10
 
@@ -21,6 +23,8 @@ OPENAI_API_BASE_URL=
21
  # - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
22
  # - ID 只用于日志排查,不会暴露 API Key。
23
  # - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。
 
 
24
  # - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。
25
  # - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
26
  # - API Key 本身不要包含逗号。
@@ -34,14 +38,27 @@ OPENAI_API_BASE_URL=
34
  # OPENAI_CHANNEL_2_ID=backup
35
  # OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
36
  # OPENAI_CHANNEL_2_API_KEYS=sk-backup-1
 
 
 
 
37
 
38
- # 可选:并发流式批处理。默认关闭。
39
- # 开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
40
  # 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。
41
  # key 出现鉴权、额度或限流类错误后会短暂冷却;渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
42
- # ENABLE_STREAMING_BATCH=true
43
  # OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
44
  # OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000
 
 
 
 
 
 
 
 
 
 
45
 
46
  # 可选:多上游图片兼容层默认策略。默认保持 Images API JSON 基线。
47
  # IMAGE_GENERATION_BACKEND=images-api
@@ -64,7 +81,7 @@ OPENAI_API_BASE_URL=
64
  # IMAGE_REAL_SMOKE_SUB2API_API_KEY=
65
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=https://sub2api.example.com/v1
66
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
67
- # IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.example.com/v1
68
  # IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
69
  # IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
70
  # IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
 
5
  OPENAI_API_KEY=
6
 
7
  # 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。
8
+ # 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。
9
+ # 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
10
  # 示例:https://api.openai.com/v1
11
  OPENAI_API_BASE_URL=
12
 
 
23
  # - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
24
  # - ID 只用于日志排查,不会暴露 API Key。
25
  # - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。
26
+ # - BASE_URL 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。
27
+ # - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
28
  # - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。
29
  # - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
30
  # - API Key 本身不要包含逗号。
 
38
  # OPENAI_CHANNEL_2_ID=backup
39
  # OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
40
  # OPENAI_CHANNEL_2_API_KEYS=sk-backup-1
41
+ #
42
+ # 可选:远程明文 HTTP 兼容接口 allowlist。默认只允许 HTTPS 和本机 loopback HTTP。
43
+ # 多个完整 base URL 用英文逗号分隔,仅在确认网络边界安全时启用。
44
+ # OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http://your-internal-compatible-api.example.com/v1
45
 
46
+ # 可选:并发流式批处理容量
47
+ # 页面提供显式“并发批量”关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
48
  # 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。
49
  # key 出现鉴权、额度或限流类错误后会短暂冷却;渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
 
50
  # OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
51
  # OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000
52
+ #
53
+ # 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的
54
+ # credential/channel 先通过后台 GET /models 探测,成功后才重新进入用户生图流量。
55
+ # 探测不调用 /images/generations,不触发生图费用;MAX_PER_TICK 用于限制探测流量。
56
+ # OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true
57
+ # 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。
58
+ # OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true
59
+ # OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS=60000
60
+ # OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS=5000
61
+ # OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK=1
62
 
63
  # 可选:多上游图片兼容层默认策略。默认保持 Images API JSON 基线。
64
  # IMAGE_GENERATION_BACKEND=images-api
 
81
  # IMAGE_REAL_SMOKE_SUB2API_API_KEY=
82
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=https://sub2api.example.com/v1
83
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
84
+ # IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1
85
  # IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
86
  # IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
87
  # IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
.env.real-smoke.example CHANGED
@@ -1,6 +1,7 @@
1
  # Independent real upstream smoke targets.
2
  # Copy this file to .env.real-smoke.local and fill only the targets you can run.
3
  # Do not commit .env.real-smoke.local.
 
4
  # Final gate:
5
  # npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable
6
 
@@ -34,7 +35,7 @@ IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
34
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low
35
 
36
  # GPT2Image style Responses image_generation SSE.
37
- IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=
38
  IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
39
  # IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2
40
  IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
 
1
  # Independent real upstream smoke targets.
2
  # Copy this file to .env.real-smoke.local and fill only the targets you can run.
3
  # Do not commit .env.real-smoke.local.
4
+ # Every *_BASE_URL must be a http/https absolute URL without credentials, query parameters, or fragments.
5
  # Final gate:
6
  # npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable
7
 
 
35
  # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low
36
 
37
  # GPT2Image style Responses image_generation SSE.
38
+ IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1
39
  IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
40
  # IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2
41
  IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
.github/workflows/hf-space-keepalive.yml CHANGED
@@ -16,6 +16,8 @@ jobs:
16
  HF_SPACE_KEEPALIVE_URL: ${{ vars.HF_SPACE_KEEPALIVE_URL || 'https://misonl-gpt-image-playground-customer.hf.space' }}
17
  HF_SPACE_KEEPALIVE_PATH: /api/auth-status
18
  HF_SPACE_KEEPALIVE_TIMEOUT_MS: '30000'
 
 
19
  HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED: 'true'
20
  steps:
21
  - name: Checkout repository
 
16
  HF_SPACE_KEEPALIVE_URL: ${{ vars.HF_SPACE_KEEPALIVE_URL || 'https://misonl-gpt-image-playground-customer.hf.space' }}
17
  HF_SPACE_KEEPALIVE_PATH: /api/auth-status
18
  HF_SPACE_KEEPALIVE_TIMEOUT_MS: '30000'
19
+ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '3'
20
+ HF_SPACE_KEEPALIVE_RETRY_DELAY_MS: '5000'
21
  HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED: 'true'
22
  steps:
23
  - name: Checkout repository
CHANGELOG.md CHANGED
@@ -8,27 +8,45 @@
8
 
9
  ### 新增
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  - 增加 API 错误排查建议,针对鉴权失败、限流、上游 5xx 和 Cloudflare 524 返回更明确的用户提示。
12
  - 增加图片请求默认质量、错误建议和批量部分失败明细的单元测试。
13
  - 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。
14
  - 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。
15
  - 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。
 
16
 
17
  ### 变更
18
 
19
  - 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。
20
- - 页面默认开启流式预览单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。
21
  - 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、provider dialect 诊断和扣费解析逻辑。
22
  - 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。
23
  - Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。
24
  - Agent skill 文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。
25
 
 
 
 
 
26
  ## [1.3.0] - 2026-05-12
27
 
28
  ### 新增
29
 
30
  - 增加服务端运行时能力接口 `/api/runtime-capabilities`,用于返回流式批处理开关、推荐并发和渠道健康状态。
31
- - 增加 `ENABLE_STREAMING_BATCH` 与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。
32
  - 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。
33
  - 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。
34
  - 增加服务端 credential/channel 失败冷却机制,支持按渠道覆盖冷却窗口。
@@ -93,7 +111,8 @@
93
  - 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。
94
  - 增加 Docker 部署支持和多平台启动脚本。
95
 
96
- [未发布]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.3.0...HEAD
 
97
  [1.3.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.2.0...v1.3.0
98
  [1.2.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.1.0...v1.2.0
99
  [1.1.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/dc7c8f5855e80cb9507517b5748c718e7155df52...v1.1.0
 
8
 
9
  ### 新增
10
 
11
+ - WebUI 增加 `图像手记` 工作台的显式批量模式:多条提示词逐行形成独立任务,批量进度、暂停、失败项复用和批次历史保持可追溯。
12
+ - WebUI 在省心模式和专业模式中展示“并发批量”状态;只有用户手动启用且当前流式策略、任务数量和渠道容量满足条件时,才会把多图或多提示词拆成并发流式任务。
13
+ - Agent skill 批量脚本支持 `--concurrency N` 并发执行、append-only manifest、续跑、尺寸校验、失败重试和页面 SSE 原始事件留档。
14
+
15
+ ### 变更
16
+
17
+ - API URL 校验允许无凭据、无查询参数和无片段的 `http` 或 `https` OpenAI 兼容根地址;自定义 API URL 仍必须与自定义 API Key 成对提供,避免服务端密钥转发到未知地址。
18
+ - WebUI 结果区和最近生成记录补齐连续工作流动作:继续编辑、做变体、复用提示词、对比、收藏和批次折叠,批量模式的底部提示词动作以当前可见批量提示词为准。
19
+
20
+ ## [1.4.0] - 2026-05-27
21
+
22
+ ### 新增
23
+
24
  - 增加 API 错误排查建议,针对鉴权失败、限流、上游 5xx 和 Cloudflare 524 返回更明确的用户提示。
25
  - 增加图片请求默认质量、错误建议和批量部分失败明细的单元测试。
26
  - 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。
27
  - 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。
28
  - 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。
29
+ - Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE metadata、运行态启用后端和 job polling 语义,辅助脚本支持 `--page-sse`、`--agent`、`--job` 显式路由。
30
 
31
  ### 变更
32
 
33
  - 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。
34
+ - 页面默认不发送流式请求;用户显式开启流式预览后,单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。
35
  - 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、provider dialect 诊断和扣费解析逻辑。
36
  - 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。
37
  - Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。
38
  - Agent skill 文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。
39
 
40
+ ### 修复
41
+
42
+ - 收紧 Responses `image_generation` 结果解析,只有标准 base64 或常见位图 `data:image/...;base64,` payload 会被当作可保存图片。
43
+
44
  ## [1.3.0] - 2026-05-12
45
 
46
  ### 新增
47
 
48
  - 增加服务端运行时能力接口 `/api/runtime-capabilities`,用于返回流式批处理开关、推荐并发和渠道健康状态。
49
+ - 增加运行时并发流式批处理能力与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。
50
  - 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。
51
  - 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。
52
  - 增加服务端 credential/channel 失败冷却机制,支持按渠道覆盖冷却窗口。
 
111
  - 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。
112
  - 增加 Docker 部署支持和多平台启动脚本。
113
 
114
+ [未发布]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.4.0...HEAD
115
+ [1.4.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.3.0...v1.4.0
116
  [1.3.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.2.0...v1.3.0
117
  [1.2.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/v1.1.0...v1.2.0
118
  [1.1.0]: https://github.com/MisonL/gpt-image-playground-customer/compare/dc7c8f5855e80cb9507517b5748c718e7155df52...v1.1.0
README.md CHANGED
@@ -5,7 +5,7 @@ app_port: 4783
5
 
6
  # GPT Image Playground
7
 
8
- ![Version](https://img.shields.io/badge/version-1.3.0-blue)
9
  ![License](https://img.shields.io/badge/license-MIT-green)
10
  ![Node](https://img.shields.io/badge/node-%3E%3D20-339933)
11
 
@@ -51,8 +51,7 @@ OPENAI_CHANNEL_2_ID=backup
51
  OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
52
  OPENAI_CHANNEL_2_API_KEYS=sk-backup-a,sk-backup-b
53
 
54
- # 可选:开启并发流式处理。默认
55
- ENABLE_STREAMING_BATCH=true
56
  OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
57
  ```
58
 
@@ -125,11 +124,13 @@ http://localhost:4783
125
  - `gpt-image-2` 图片编辑:上传源图后用提示词修改图片,可选遮罩。
126
  - Agent API:为 Codex、Claude Code、Gemini 等 Agent 提供强契约接口、幂等重试、结构化错误和产物追踪。
127
  - 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。
 
128
  - 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。
129
  - 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。
130
  - 流式输出:用户显式开启后支持生成和编辑过程中的局部图片预览。
 
131
  - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
132
- - 发送到编辑:从生成结果或历史记录直接进入编辑模式
133
  - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
134
  - 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。
135
  - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
@@ -139,24 +140,26 @@ http://localhost:4783
139
  ## 默认行为
140
 
141
  - 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。
142
- - 页面默认不发送流式请求;用户显式开启流式预览后,才会走 SSE 路径并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。
 
143
  - 服务端会把官方 OpenAI Images 流式事件、gaoren002/new-api 与 sub2api 图片 SSE、OtokAPI `image.generation.*`、Responses `image_generation_call` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。
144
- - 请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障
145
 
146
  ## 图片后端路径
147
 
148
  - 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。原版 new-api 和 sub2api 普通 JSON 能力保持这个基线。
149
- - 流式能力由请求字段或环境变量显式控制:`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。`auto` 凭仓库名假设上游能力;Agent 助脚本对 `max_edge>2048` 的单次文生图默认优先使用页面端 `/api/images` SSE,失败后先诊断,再显式选择 Agent JSON 或 job 路径。
150
  - 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。
151
- - gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容发生在事件适配层:partial image 只作为预览,只有最终 completed base64 才会保存为 artifact;缺最终 base64 仅返回远程 URL 会显式失败。
152
  - 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`。
153
  - Agent capabilities 会同时暴露 `supported.image_backends` 枚举和 `supported.enabled_image_backends` 当前启用后端;自动化脚本应以后者和 `image_backend_requirements` 判断 runtime 是否已准备好。
154
- - Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。
155
- - Responses API 实验路径支持单张 `generate` 的非流式上游 SSE 消费,不替换默认 Images API,不接入编辑表单。Agent generate 对外仍返回最终 JSON,可通过 `image_backend``streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费
 
156
 
157
  ## 编辑与遮罩
158
 
159
- 编辑模式支持最多 10 张源图。遮罩必须与源图尺寸一致,绘制或上传后会随编辑请求一起提交。
160
 
161
  <p align="center">
162
  <img src="./readme-images/mask-creation.jpg" alt="遮罩创建" width="460"/>
@@ -181,20 +184,22 @@ http://localhost:4783
181
  | 配置 | 说明 |
182
  | --- | --- |
183
  | API Key | OpenAI 或兼容接口的密钥。 |
184
- | API URL | OpenAI 兼容接口根地址,通常以 `/v1` 结尾。 |
185
 
186
  常见填写方式:
187
 
188
  ```text
189
  https://api.openai.com/v1
190
  https://your-compatible-api.example.com/v1
 
191
  ```
192
 
193
- 不要填写管理后台首页或网页地址。如果接口返回 HTML,应用会提示 API URL 不是 OpenAI Images JSON 响应。
194
 
195
  ## Agent API
196
 
197
  Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统一使用结构化错误、`Idempotency-Key` 和产物 ID。
 
198
 
199
  | 接口 | 用途 |
200
  | --- | --- |
@@ -217,10 +222,43 @@ Authorization: Bearer your-agent-token
217
 
218
  `AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。
219
 
 
 
220
  同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。
221
 
222
  Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。
223
  运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
 
225
  生成示例:
226
 
@@ -296,13 +334,18 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
296
  | 变量 | 是否必填 | 默认值 | 说明 |
297
  | --- | --- | --- | --- |
298
  | `OPENAI_API_KEY` | 条件必填 | 无 | 服务端默认 API Key。也可以在页面 `API 设置` 中填写。 |
299
- | `OPENAI_API_BASE_URL` | | OpenAI 官方地址 | OpenAI 兼容接口根地址。 |
 
300
  | `OPENAI_ROUTING_STRATEGY` | 否 | `sticky` | 服务端多渠道路由策略,可选 `sticky`、`round_robin`、`random`。 |
301
  | `OPENAI_CHANNEL_N_ID` | 否 | 无 | 第 N 个服务端渠道标识,只用于日志排查。 |
302
- | `OPENAI_CHANNEL_N_BASE_URL` | 否 | 无 | 第 N 个 OpenAI 兼容接口根地址,通常以 `/v1` 结尾。 |
303
  | `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 |
304
  | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 |
305
- | `ENABLE_STREAMING_BATCH` | 否 | `false` | 显式设为 `true` 后流式模式下 `n>1` 会拆成多个 `n=1` 任务并发执行。 |
 
 
 
 
306
  | `IMAGE_GENERATION_BACKEND` | 否 | `images-api` | 服务端默认图片后端,可选 `images-api` 或 `responses-image-generation`。请求字段可覆盖。 |
307
  | `IMAGE_STREAMING_STRATEGY` | 否 | `auto` | 服务端默认流式兼容策略,可选 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求字段可覆盖。 |
308
  | `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
342
  | --- | --- |
343
  | `OPENAI_ROUTING_STRATEGY` | 可选 `sticky`、`round_robin`、`random`。不填默认 `sticky`。 |
344
  | `OPENAI_CHANNEL_N_ID` | 渠道标识,只用于日志与排查,不会暴露 API Key。 |
345
- | `OPENAI_CHANNEL_N_BASE_URL` | 兼容接口根地址,通常以 `/v1` 结尾。 |
346
  | `OPENAI_CHANNEL_N_API_KEYS` | 当前渠道下的一个或多个 API Key,多个 key 用英文逗号分隔。 |
347
  | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 可选,覆盖单个渠道的失败冷却窗口。 |
348
 
349
- 并发流式批处理默认关闭。开 `ENABLE_STREAMING_BATCH=true` 后,页面允许在流式模式下选择多张图片;应用会把批次拆成多个独立 `n=1` 流式请求。推荐并发窗口由服务端运行时能力接口返回:默认 `sticky` 路由按单个 credential 容量计算,`round_robin` / `random` 路由按完整 credential 池计算。
350
 
351
  `OPENAI_MAX_STREAMS_PER_CREDENTIAL` 默认是 `1`,建议只在真实上游探针验证单 key 可承受更高并发后再调大。
352
 
353
  如果服务端 credential 返回鉴权失败、额度不足或限流错误,应用会把该 credential 标记为短暂不可用。若渠道返回 5xx、Cloudflare 520/522/523/524、连接失败或超时,应用会冷却整个 channel,并在冷却窗口内跳过该 channel 下所有 key。若兼容网关把 `invalid_api_key`、`insufficient_quota` 等 credential 错误包在 5xx 中返回,credential 错误优先,不会误冷却整个 channel。所有 credential 都在冷却中时,请求会显式失败��不会伪造成功或静默降级。
354
 
355
- 运行时能力接口返回健康 credential/channel 与最近一次失败摘要(status、code、requestId),用于诊断和前端并发窗口刷新;不会返回 API Key 或上游错误消息
 
 
 
 
 
 
356
 
357
  三种策略:
358
 
@@ -483,7 +532,15 @@ npm run deploy:space
483
  npm run agent:doctor
484
  ```
485
 
486
- `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 做只读契约检查
 
 
 
 
 
 
 
 
487
 
488
  如果只想诊断 HF Space 前置条件,可运行:
489
 
@@ -563,7 +620,7 @@ docker logs -f gpt-image-playground-customer
563
  | `npm run verify` | 执行提交前基线:测试、lint、脚本语法、构建和 `git diff --check`;加 `-- --postgres` 会包含 live PostgreSQL gate。 |
564
  | `npm run deploy:local` | 重建本地 Docker 服务并探测 `/api/auth-status`、`/api/runtime-capabilities`、`/api/agent/capabilities`;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 |
565
  | `npm run deploy:space` | 上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 |
566
- | `npm run agent:doctor` | 通过仓库 Skill 脚本执行只读 Agent API 契约检查不触发真实生图。 |
567
  | `npm run deploy:hf-space` | 使用官方 `hf` CLI 上传当前干净 git HEAD 到固定 Space 并做只读公网验证。 |
568
  | `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、固定 Space 目标和远端配置。 |
569
  | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
@@ -577,9 +634,12 @@ docker logs -f gpt-image-playground-customer
577
 
578
  真实上游 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`。
579
 
 
 
580
  `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` 后运行真实上游门禁。
581
 
582
  若只需要验证当前 `.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` 调整。
 
583
 
584
  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` 混淆。
585
 
@@ -607,7 +667,7 @@ dry-run 输出中的 `independent_targets` 会汇总必跑、已选、未选、
607
 
608
  ### API 返回 HTML 页面
609
 
610
- 说明 API URL 填成了网页或管理后台地址。请填写 OpenAI 兼容接口根地址,通常以 `/v1` 结尾。
611
 
612
  ### 生成接口提示需要 API Key
613
 
 
5
 
6
  # GPT Image Playground
7
 
8
+ ![Version](https://img.shields.io/badge/version-1.4.0-blue)
9
  ![License](https://img.shields.io/badge/license-MIT-green)
10
  ![Node](https://img.shields.io/badge/node-%3E%3D20-339933)
11
 
 
51
  OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
52
  OPENAI_CHANNEL_2_API_KEYS=sk-backup-a,sk-backup-b
53
 
54
+ # 可选:页面“并发批量”开使用的服务端容量
 
55
  OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
56
  ```
57
 
 
124
  - `gpt-image-2` 图片编辑:上传源图后用提示词修改图片,可选遮罩。
125
  - Agent API:为 Codex、Claude Code、Gemini 等 Agent 提供强契约接口、幂等重试、结构化错误和产物追踪。
126
  - 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。
127
+ - `图像手记` 工作台:提供文生图、图生图、批量和复用历史四种入口,中央预览、右侧灵感相册和最近生成围绕连续创作流程组织。
128
  - 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。
129
  - 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。
130
  - 流式输出:用户显式开启后支持生成和编辑过程中的局部图片预览。
131
+ - 显式并发批量:用户手动启用后,多图或多提示词任务会按当前渠道容量拆成独立流式任务并发执行;未启用时,普通多图保持单次 `n>1` 请求,批量提示词保持逐条顺序执行。
132
  - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
133
+ - 连续工作流:从生成结果或历史记录继续编辑、做变体、复用提示词、对比、下载、分享或保存为灵感
134
  - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
135
  - 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。
136
  - Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
 
140
  ## 默认行为
141
 
142
  - 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。
143
+ - 页面默认使用 `stream_mode=auto`。auto 会优先尝试 SSE;如果上游流式没有最终图,会在同一响应里显式回退到非流式并暴露 `fallback_used``stream` 强制流式,`non_stream` 直接走非流式 JSON。普通多图请求默认保持单次 `n>1` 上游请求;量提示词默认 `concurrency=1` 逐条执行。用户在页面显式勾选“并发批量”才会把多图或多提示词批次拆成多个 `n=1` 流式任务。
144
+ - 批量模式下,每行提示词会形成一条独立任务,统一使用当前尺寸、质量、格式、模型和路由设置;底部提示词动作、批次历史、失败项复用和暂停状态都以当前可见批量提示词为准。
145
  - 服务端会把官方 OpenAI Images 流式事件、gaoren002/new-api 与 sub2api 图片 SSE、OtokAPI `image.generation.*`、Responses `image_generation_call` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。
146
+ - `stream` 模式失败时会显示原始错误状态和排查建议,不会自动改用非流式请求。`auto` 模式只在可观测回退路径中降级并通过响应字段和 runtime capabilities 暴露状态
147
 
148
  ## 图片后端路径
149
 
150
  - 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。原版 new-api 和 sub2api 普通 JSON 能力保持这个基线。
151
+ - 流式策略由请求字段或环境变量控制:`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 路径。
152
  - 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。
153
+ - gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容发生在事件和结果适配层:partial image 只作为预览,最终 completed 可返回 base64、图片 data URL与上游 `BASE_URL` 同源的图片 URL。同源 URL 由服务端下载并保存;跨源 URL、非图片响应或超过大小限制会显式失败。
154
  - 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`。
155
  - Agent capabilities 会同时暴露 `supported.image_backends` 枚举和 `supported.enabled_image_backends` 当前启用后端;自动化脚本应以后者和 `image_backend_requirements` 判断 runtime 是否已准备好。
156
+ - Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;兼容字段 `gptModel`/`gpt_model` 也可覆盖顶层模型。页面表单里的图片模型只传给 `image_generation` 工具。
157
+ - GPT2Image 兼容字段会尽量透传:文生图和图生图页面高级参数都可按后端选择提交字段;Responses 图片后端支持 `output_compression`、`promptOptimization`/`prompt_optimization``thinking`;Images API 路径支持 `force_web`/`forceWeb`。这些字段最终是否生效取决于命中的上游后端
158
+ - 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`。
159
 
160
  ## 编辑与遮罩
161
 
162
+ 编辑模式支持最多 10 张源图,上传字段必须使用 `image_0` 到 `image_9`。遮罩必须与源图尺寸一致,绘制或上传后会随编辑请求一起提交。
163
 
164
  <p align="center">
165
  <img src="./readme-images/mask-creation.jpg" alt="遮罩创建" width="460"/>
 
184
  | 配置 | 说明 |
185
  | --- | --- |
186
  | API Key | OpenAI 或兼容接口的密钥。 |
187
+ | API URL | OpenAI 兼容接口根地址,通常以 `/v1` 结尾;支持无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址。 |
188
 
189
  常见填写方式:
190
 
191
  ```text
192
  https://api.openai.com/v1
193
  https://your-compatible-api.example.com/v1
194
+ http://your-internal-compatible-api.example.com/v1
195
  ```
196
 
197
+ 自定义 API URL 必须同时填写自定义 API Key,避免服务器密钥被发送到未知接口。不要填写管理后台首页或网页地址。如果接口返回 HTML,应用会提示 API URL 不是 OpenAI Images JSON 响应。
198
 
199
  ## Agent API
200
 
201
  Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统一使用结构化错误、`Idempotency-Key` 和产物 ID。
202
+ 自动化客户端应先读取 `GET /api/agent/capabilities`,按其中的 `routing_rules`、`agent_streaming`、`agent_jobs`、`supported.enabled_image_backends` 和 `supported.image_backend_requirements` 选择路径,不要硬编码当前部署默认值。
203
 
204
  | 接口 | 用途 |
205
  | --- | --- |
 
222
 
223
  `AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。
224
 
225
+ 页面端 `/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`。
226
+
227
  同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。
228
 
229
  Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。
230
  运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。
231
+ `POST /api/agent/images/generate` 对外始终是最终 JSON;`max_edge>2048` 的单次文生图默认建议按 `/api/agent/capabilities` 使用页面端 `/api/images` SSE。显式传 `--agent` 或 `streaming_strategy=off` 时才走 Agent JSON 非流式路径,用于诊断对照。
232
+ 仓库辅助脚本支持 `--page-sse`、`--agent` 和 `--job` 显式选择路径。`--page-sse` 使用页面 SSE,`--agent` 强制 Agent generate/edit 最终 JSON,`--job` 使用 Agent generate job polling。页面流式失败后不会自动二次计费回退,需先按结构化错误和诊断字段确认原因,再选择新的业务操作和新的 `Idempotency-Key`。
233
+ Agent 请求字段 `stream_mode=auto|stream|non_stream` 用于控制服务端内部上游流式消费:`auto` 是默认值并允许显式可观测回退,`stream` 强制上游流式并直接暴露失败,`non_stream` 直接非流式。`GET /api/runtime-capabilities` 会返回当前默认 stream mode、流式不可用标记 scope 和 availability summary。
234
+ 上游 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`。
235
+
236
+ 批量自动化可使用仓库 skill 脚本:
237
+
238
+ ```bash
239
+ node skills/gpt-image-playground-agent/scripts/batch-images.mjs \
240
+ --input tasks.jsonl \
241
+ --ordered-prefix product-set
242
+ ```
243
+
244
+ 默认 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` 同用。
245
+
246
+ 并发脚本示例:
247
+
248
+ ```bash
249
+ node skills/gpt-image-playground-agent/scripts/batch-images.mjs \
250
+ --allow-billable \
251
+ --input tasks.jsonl \
252
+ --manifest runs/product-set.manifest.jsonl \
253
+ --resume \
254
+ --dimension-check \
255
+ --max-attempts 2 \
256
+ --concurrency 3
257
+ ```
258
+
259
+ `--concurrency` 默认是 `1`。大于 `1` 时会并发执行任务并按输入顺序输出结果;连续失败熔断需要严格顺序语义,因此只能与 `--concurrency 1` 同用。
260
+
261
+ 批量 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
262
 
263
  生成示例:
264
 
 
334
  | 变量 | 是否必填 | 默认值 | 说明 |
335
  | --- | --- | --- | --- |
336
  | `OPENAI_API_KEY` | 条件必填 | 无 | 服务端默认 API Key。也可以在页面 `API 设置` 中填写。 |
337
+ | `OPENAI_API_BASE_URL` | �� | OpenAI 官方地址 | OpenAI 兼容接口根地址;默认要求 `https`仅本机 loopback HTTP 可直接使用,远程 HTTP 必须显式加入 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`。 |
338
+ | `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS` | 否 | 无 | 远程明文 HTTP 兼容接口 allowlist,多个完整 base URL 用英文逗号分隔。只用于必须明文接入且已确认网络边界安全的上游。 |
339
  | `OPENAI_ROUTING_STRATEGY` | 否 | `sticky` | 服务端多渠道路由策略,可选 `sticky`、`round_robin`、`random`。 |
340
  | `OPENAI_CHANNEL_N_ID` | 否 | 无 | 第 N 个服务端渠道标识,只用于日志排查。 |
341
+ | `OPENAI_CHANNEL_N_BASE_URL` | 否 | 无 | 第 N 个 OpenAI 兼容接口根地址,通常以 `/v1` 结尾;默认要求 `https`仅本机 loopback HTTP 可直接使用,远程 HTTP 必须显式加入 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`。 |
342
  | `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 |
343
  | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 |
344
+ | `OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED` | 否 | `true` | 服务端渠道恢复探测开关。存在服务端凭证时默认开启只请求对应上游 `GET /models`。 |
345
+ | `OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY` | 否 | 跟随恢复探测开关 | 是否要求冷却到期的 credential/channel 先通过恢复探测,成功后才回到用户流量。设为 `true` 时,`OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED` 也必须启用。 |
346
+ | `OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS` | 否 | `60000` | 后台恢复探测 tick 间隔。 |
347
+ | `OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS` | 否 | `5000` | 单次 `/models` 探测超时。 |
348
+ | `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK` | 否 | `1` | 每个 tick 最多探测的恢复候选数量,用于限制低成本探测流量。 |
349
  | `IMAGE_GENERATION_BACKEND` | 否 | `images-api` | 服务端默认图片后端,可选 `images-api` 或 `responses-image-generation`。请求字段可覆盖。 |
350
  | `IMAGE_STREAMING_STRATEGY` | 否 | `auto` | 服务端默认流式兼容策略,可选 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求字段可覆盖。 |
351
  | `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 请求才可调用 Responses API image generation。 |
 
385
  | --- | --- |
386
  | `OPENAI_ROUTING_STRATEGY` | 可选 `sticky`、`round_robin`、`random`。不填默认 `sticky`。 |
387
  | `OPENAI_CHANNEL_N_ID` | 渠道标识,只用于日志与排查,不会暴露 API Key。 |
388
+ | `OPENAI_CHANNEL_N_BASE_URL` | 兼容接口根地址,通常以 `/v1` 结尾;支持无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址。 |
389
  | `OPENAI_CHANNEL_N_API_KEYS` | 当前渠道下的一个或多个 API Key,多个 key 用英文逗号分隔。 |
390
  | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 可选,覆盖单个渠道的失败冷却窗口。 |
391
 
392
+ 并发流式批处理默认不自动用。页面允许在流式模式下选择多张图片或进入批量提示词模式用户显式勾选“并发批量”后,应用会把批次拆成多个独立 `n=1` 流式请求。省心模式会显示当前并发状态摘要,专业模式的流式分组提供开关和不可用原因。推荐并发窗口由服务端运行时能力接口返回:默认 `sticky` 路由按单个 credential 容量计算,`round_robin` / `random` 路由按完整 credential 池计算。
393
 
394
  `OPENAI_MAX_STREAMS_PER_CREDENTIAL` 默认是 `1`,建议只在真实上游探针验证单 key 可承受更高并发后再调大。
395
 
396
  如果服务端 credential 返回鉴权失败、额度不足或限流错误,应用会把该 credential 标记为短暂不可用。若渠道返回 5xx、Cloudflare 520/522/523/524、连接失败或超时,应用会冷却整个 channel,并在冷却窗口内跳过该 channel 下所有 key。若兼容网关把 `invalid_api_key`、`insufficient_quota` 等 credential 错误包在 5xx 中返回,credential 错误优先,不会误冷却整个 channel。所有 credential 都在冷却中时,请求会显式失败��不会伪造成功或静默降级。
397
 
398
+ 默认情况下,冷却到期不直接把 credential/channel 放回用户生图流。服务端会启动低频后台恢复探测只对待恢复候选请求对应上游的 `GET /models`;HTTP 200 且响应包含 `data` 数组后才恢复。探测不会调 `/images/generations`,不触生图费用。探测失败会继续隔离该 credential/channel 并重新进入冷却窗口。可用 `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK` 限制每个 tick 的探测数量,默认每分钟最多探测 1 个候选
399
+
400
+ 恢复探测调度器运行在当前 Node.js 进程内,适合 Docker 或 standalone 单实例部署。Serverless 环境可能在请求结束后冻结进程;多副本部署也会各自维护内存健康状态。此类部署如需严格恢复控制,应使用常驻实例、共享健康状态或外部定时探测;大规模 credential 池可按可接受恢复速度调大 `OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK`,但不要超过上游 `/models` 的限流承受能力。
401
+
402
+ 恢复探测带有状态版本锚点:如果某次 `/models` 探测请求发出后,同一个 credential/channel 又被新的用户请求失败重新冷却,旧探测成功不会覆盖这次更新后的失败状态。
403
+
404
+ 运行时能力接口会返回健康 credential/channel 数量、待恢复探测数量、已到期候选数量、按当前 `MAX_PER_TICK` 估算的最少 drain tick/时间、探测配置和最近一次失败摘要(status、code、requestId),用于诊断和前端并发窗口刷新;不会返回 API Key 或上游错误消息。
405
 
406
  三种策略:
407
 
 
532
  npm run agent:doctor
533
  ```
534
 
535
+ `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
536
+
537
+ 如需让 `agent:doctor` 执行真实计费 smoke,必须显式传入:
538
+
539
+ ```bash
540
+ npm run agent:doctor -- --allow-billable --edit-image /path/to/reference.png
541
+ ```
542
+
543
+ 其中 1K 文生图、1K 图生图和 2K page SSE edit 会按真实上游路径执行;未传 `--allow-billable` 时不会触发生图。
544
 
545
  如果只想诊断 HF Space 前置条件,可运行:
546
 
 
620
  | `npm run verify` | 执行提交前基线:测试、lint、脚本语法、构建和 `git diff --check`;加 `-- --postgres` 会包含 live PostgreSQL gate。 |
621
  | `npm run deploy:local` | 重建本地 Docker 服务并探测 `/api/auth-status`、`/api/runtime-capabilities`、`/api/agent/capabilities`;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 |
622
  | `npm run deploy:space` | 上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 |
623
+ | `npm run agent:doctor` | 执行非计费 Agent 分层诊断,真实 1K/2K smoke 必须显式加 `-- --allow-billable`。 |
624
  | `npm run deploy:hf-space` | 使用官方 `hf` CLI 上传当前干净 git HEAD 到固定 Space 并做只读公网验证。 |
625
  | `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、固定 Space 目标和远端配置。 |
626
  | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
 
634
 
635
  真实上游 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`。
636
 
637
+ GPT2Image 付费站的真实上游地址应配置为 `https://gpt2image.superapi.buzz/v1`,不要使用站点首页 URL。该场景会通过 Responses image_generation SSE 验证 URL 结果物化和最终图片保存。
638
+
639
  `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` 后运行真实上游门禁。
640
 
641
  若只需要验证当前 `.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` 调整。
642
+ 服务端渠道 smoke 只证明当前配置和上游账号池在请求时可用;如果上游返回 `503` 或 `No available compatible accounts`,应归类为上游渠道当前不可用,不要把它记成本地路由或脚本成功。
643
 
644
  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` 混淆。
645
 
 
667
 
668
  ### API 返回 HTML 页面
669
 
670
+ 说明 API URL 填成了网页或管理后台地址。请填写 OpenAI 兼容接口根地址,通常以 `/v1` 结尾;地址必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对 URL
671
 
672
  ### 生成接口提示需要 API Key
673
 
docs/deployment/huggingface-space-free.md CHANGED
@@ -153,6 +153,8 @@ APP_PASSWORD=<page-access-code>
153
  AGENT_API_TOKEN=<long-random-agent-token>
154
  ```
155
 
 
 
156
  公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
157
 
158
  如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
@@ -194,6 +196,10 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
194
 
195
  脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。
196
 
 
 
 
 
197
  ## 本地 HF 近似 smoke
198
 
199
  提交前运行:
@@ -226,6 +232,7 @@ npm run smoke:hf-space
226
  - 工作流文件:`.github/workflows/hf-space-keepalive.yml`
227
  - 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch`
228
  - 默认目标:`https://misonl-gpt-image-playground-customer.hf.space/api/auth-status`
 
229
  - 行为边界:只访问只读鉴权状态端点,不携带 `APP_PASSWORD`、`AGENT_API_TOKEN` 或 OpenAI Key,不触发生图、不访问 Agent 生成接口。
230
 
231
  如果 Space 地址变化,在 GitHub 仓库 Variables 中设置:
@@ -239,6 +246,7 @@ HF_SPACE_KEEPALIVE_URL=https://<user>-<space>.hf.space
239
  ```bash
240
  HF_SPACE_KEEPALIVE_URL=https://<user>-<space>.hf.space \
241
  HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED=true \
 
242
  npm run keepalive:hf-space
243
  ```
244
 
 
153
  AGENT_API_TOKEN=<long-random-agent-token>
154
  ```
155
 
156
+ `OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
157
+
158
  公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
159
 
160
  如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
 
196
 
197
  脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。
198
 
199
+ 远端 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` 显式选择路径。
200
+
201
+ 如果 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` 为准。
202
+
203
  ## 本地 HF 近似 smoke
204
 
205
  提交前运行:
 
232
  - 工作流文件:`.github/workflows/hf-space-keepalive.yml`
233
  - 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch`
234
  - 默认目标:`https://misonl-gpt-image-playground-customer.hf.space/api/auth-status`
235
+ - GitHub Actions 中默认最多请求 3 次,每次超时 30 秒,重试间隔 5 秒。失败日志会记录每次尝试,不把超时伪装成成功。
236
  - 行为边界:只访问只读鉴权状态端点,不携带 `APP_PASSWORD`、`AGENT_API_TOKEN` 或 OpenAI Key,不触发生图、不访问 Agent 生成接口。
237
 
238
  如果 Space 地址变化,在 GitHub 仓库 Variables 中设置:
 
246
  ```bash
247
  HF_SPACE_KEEPALIVE_URL=https://<user>-<space>.hf.space \
248
  HF_SPACE_KEEPALIVE_EXPECT_PASSWORD_REQUIRED=true \
249
+ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS=3 \
250
  npm run keepalive:hf-space
251
  ```
252
 
docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md CHANGED
@@ -9,7 +9,7 @@
9
  ## 审计结论
10
 
11
  - `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。
12
- - 高分辨率 Agent edit 请求在 route 层 `snapshotAgentEditFormData` 前被拒绝服务层也在读取服务端 API 凭据前保留二道校验
13
  - partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。
14
  - 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。
15
  - 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。
 
9
  ## 审计结论
10
 
11
  - `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。
12
+ - 当前路由口径已更新:高分辨率 edit 默认优先走页面端 `/api/images` SSE;页面流式失败或不可用时先诊断结构化错误,再显式回退到 Agent edit
13
  - partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。
14
  - 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。
15
  - 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。
docs/ui/literary-young-women-workbench-design.md ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 图像手记 UI 设计基线
2
+
3
+ ## 1. 定稿结论
4
+
5
+ 本项目 Web UI 后续重构以 `女青年文艺风功能交互稿` 为唯一设计基线。
6
+
7
+ - 当前实现名称:`图像手记` 工作台。
8
+ - 定稿图:`/tmp/gipc-literary-young-women-functional-v5.png`
9
+ - 产品名称:`图像手记`
10
+ - 产品副标题:`今天想做什么画面?`
11
+ - 核心定位:面向中文用户的 AI 图像创作工作台,强调文艺、柔和、清爽、可持续使用。
12
+ - 设计目标:文艺但能干活,柔和但不牺牲参数能力。
13
+
14
+ 本基线明确排除古风、传统器物化、普通 SaaS 仪表盘和过度 AI 科技风。
15
+
16
+ ## 2. 目标用户与气质
17
+
18
+ 目标用户是中文创作者,尤其偏向年轻女性、独立杂志读者、小红书内容创作者、摄影和设计爱好者、手账和咖啡馆工作流用户。
19
+
20
+ 目标气质:
21
+
22
+ - 现代女青年文艺风。
23
+ - 独立杂志、咖啡馆手账、胶片摄影、艺术书、生活方式创作工具。
24
+ - 温柔、轻盈、清新、有个人感,但仍然专业和高效。
25
+ - 中文优先,文案自然,不暴露过多工程词。
26
+
27
+ 避免气质:
28
+
29
+ - 古风、国风器物、传统书画、宣纸卷轴、印章、毛笔、砚台、灯笼、宫廷或寺庙感。
30
+ - 深色科技仪表盘、霓虹、赛博朋克、玻璃拟态、紫色渐变。
31
+ - 企业后台、金融 SaaS、模板化 bento grid。
32
+ - 低幼粉色、过度贴纸化、装饰大于功能。
33
+
34
+ ## 3. 视觉系统
35
+
36
+ ### 3.1 色彩
37
+
38
+ 主色按温柔浅色系组织,保持低疲劳和长时间可用。
39
+
40
+ - 页面基底:奶油米白、淡奶茶色、浅亚麻纸色。
41
+ - 主要文字:炭黑、咖啡棕。
42
+ - 次级文字:灰褐色、柔和橄榄灰。
43
+ - 辅助面:浅粉、鼠尾草绿、淡黄油色。
44
+ - 主操作色:珊瑚番茄红,用于 `生成图像`。
45
+ - 状态点缀:少量蓝灰,用于 API、模型和流式状态。
46
+
47
+ 色彩比例建议:
48
+
49
+ - 60%:奶油米白和淡奶茶背景。
50
+ - 30%:纸卡、浅粉、鼠尾草绿、米色分层。
51
+ - 10%:珊瑚红按钮、蓝灰状态、淡黄提示。
52
+
53
+ 禁止使用:
54
+
55
+ - 大面积紫色、紫蓝渐变。
56
+ - 大面积深蓝、深灰、黑色科技底。
57
+ - 仿古黄、旧羊皮纸、朱砂印章红。
58
+
59
+ ### 3.2 材质
60
+
61
+ 界面材质来自现代纸张和生活方式视觉,不来自古代书画。
62
+
63
+ - 细腻亚麻纸纹理。
64
+ - 胶片照片缩略图。
65
+ - 淡淡纸层阴影。
66
+ - 轻量贴纸感样式标签。
67
+ - 杂志剪贴和手账排版暗示。
68
+ - 细线网格和裁切标记。
69
+
70
+ 所有材质必须克制,不能影响可读性和操作效率。
71
+
72
+ ### 3.3 字体与排版
73
+
74
+ 中文优先,整体像现代独立杂志和创意工具。
75
+
76
+ - 标题:可使用更有编辑感的中文标题样式,字重中等或偏细。
77
+ - 正文与控件:使用清晰现代无衬线中文字体。
78
+ - 行高:保持舒展,避免压迫。
79
+ - 标签:短句优先,避免工程缩写。
80
+ - 英文仅用于模型名、技术名和必要状态,例如 `gpt-image-2`。
81
+
82
+ 排版原则:
83
+
84
+ - 中心画布最大,操作围绕画布服务。
85
+ - 左侧信息密度中等,保持创作单的顺序。
86
+ - 右侧卡片轻量,避免历史列表压迫主画布。
87
+ - 不使用超大营销标题,不做 landing page。
88
+
89
+ ### 3.4 圆角与阴影
90
+
91
+ - 圆角最大建议 `8px`,避免全圆角玩具感。
92
+ - 主卡片可使用 `6px` 到 `8px`。
93
+ - 按钮和输入框保持一致半径。
94
+ - 阴影只模拟轻纸层,不能做厚重浮层或玻璃感。
95
+
96
+ ## 4. 信息架构
97
+
98
+ 页面是一个完整工作台,不是宣传页。
99
+
100
+ 主结构:
101
+
102
+ ```text
103
+ +-------------------------------------------------------------+
104
+ | 顶部状态栏:图像手记 / 模型 / 渠道 / 费用 / 设置 |
105
+ +--------------+------------------------------+---------------+
106
+ | 左侧创作单 | 中央画面预览 | 右侧相册记录 |
107
+ | 模式入口 | 生成状态 | 灵感相册 |
108
+ | 提示词 | 结果动作链 | 最近生成 |
109
+ | 基础参数 | 对比与编辑 | 生成动态 |
110
+ | 生成按钮 | | |
111
+ +--------------+------------------------------+---------------+
112
+ | 高级设置抽屉:省心模式 / 专业模式 / 输出 / 模型 / 流式 / 路由 |
113
+ +-------------------------------------------------------------+
114
+ ```
115
+
116
+ 信息优先级:
117
+
118
+ 1. 中央画面预览。
119
+ 2. 左侧创作单和生成按钮。
120
+ 3. 结果后的编辑动作链。
121
+ 4. 右侧灵感与历史。
122
+ 5. 高级参数和日志。
123
+
124
+ ## 5. 顶部状态栏
125
+
126
+ 顶部状态栏用于建立当前环境和全局入口,不承载主要创作流程。
127
+
128
+ 内容:
129
+
130
+ - 产品名:`图像手记`
131
+ - 副标题:`今天想做什么画面?`
132
+ - 当前模型:例如 `gpt-image-2`
133
+ - 渠道状态:例如 `流式可用`
134
+ - 费用状态:例如 `预计 0.12 积分`
135
+ - API 状态:例如 `API 正常`
136
+ - 设置入口。
137
+
138
+ 要求:
139
+
140
+ - 视觉安静,不抢主按钮。
141
+ - 状态信息可读但不显得像运维面板。
142
+ - 技术状态尽量转译为用户可理解语言。
143
+
144
+ ## 6. 左侧创作单
145
+
146
+ 左侧是完整创作入口,采用从上到下的自然创作顺序。
147
+
148
+ ### 6.1 模式入口
149
+
150
+ 使用清晰分段控件,放在左侧创作单顶部。
151
+
152
+ 四个模式:
153
+
154
+ - `文生图`
155
+ - `图生图`
156
+ - `批量`
157
+ - `复用历史`
158
+
159
+ 默认选中 `文生图`。
160
+
161
+ 设计要求:
162
+
163
+ - 模式切换必须明显,不隐藏在高级设置里。
164
+ - 每种模式切换后,左侧表单内容按任务调整。
165
+ - 不使用工程模式名,例如 `generate`、`edit`、`batch`。
166
+
167
+ ### 6.2 提示词输入
168
+
169
+ 模块标题:`写下灵感`
170
+
171
+ 提示词输入区域应像现代笔记本或创作卡片,而不是普通后台 textarea。
172
+
173
+ 示例文案方向:
174
+
175
+ ```text
176
+ 午后咖啡馆窗边,一束粉白花,胶片感,柔和自然光,松弛的生活杂志封面
177
+ ```
178
+
179
+ 要求:
180
+
181
+ - 输入区高度足够,适合长提示词。
182
+ - 支持多行自然书写。
183
+ - 周围可有轻微手账感,但不能影响文字输入。
184
+ - 空状态文案应鼓励创作,不做教程式说明。
185
+
186
+ ### 6.3 风格标签
187
+
188
+ 风格标签采用轻贴纸视觉,服务于快速构造提示词。
189
+
190
+ 建议标签:
191
+
192
+ - `胶片感`
193
+ - `奶油色`
194
+ - `日杂`
195
+ - `花束`
196
+ - `清透`
197
+ - `松弛`
198
+ - `复古咖啡`
199
+ - `夏日窗边`
200
+
201
+ 行为要求:
202
+
203
+ - 点击后追加或融合到提示词。
204
+ - 已选标签要有明确状态。
205
+ - 标签数量不宜一次展示过多,可横向滚动或折叠。
206
+
207
+ ### 6.4 基础参数
208
+
209
+ 基础参数只保留高频项:
210
+
211
+ - `尺寸`
212
+ - `数量`
213
+ - `清晰度`
214
+ - `格式`
215
+
216
+ 要求:
217
+
218
+ - 使用紧凑但清晰的控件。
219
+ - 参数命名面向用户,不暴露 API 字段名。
220
+ - 默认值应能覆盖大多数使用场景。
221
+ - 复杂参数放入专业模式。
222
+
223
+ ### 6.5 生成动作区
224
+
225
+ 生成动作区必须贴近费用、模型和渠道状态,避免用户点按钮前不清楚成本和路径。
226
+
227
+ 内容:
228
+
229
+ - 状态 chip:`gpt-image-2`
230
+ - 状态 chip:`流式可用`
231
+ - 费用 chip:`预计 0.12 积分`
232
+ - 主按钮:`生成图像`
233
+ - 次级操作:`存为灵感`
234
+ - 次级操作:`随便来点`
235
+
236
+ 要求:
237
+
238
+ - `生成图像` 使用珊瑚番茄红。
239
+ - 费用和模型信息必须在按钮附近。
240
+ - 禁用状态要说明原因,例如缺少提示词、图片未上传、尺寸无效。
241
+
242
+ ## 7. 中央画面预览
243
+
244
+ 中央是页面视觉和功能重心。
245
+
246
+ 模块标题:`画面预览`
247
+
248
+ 状态:
249
+
250
+ - 空状态:`还没有生成图像`
251
+ - 生成中:`正在生成`,可显示进度或流式预览。
252
+ - 成功:显示当前结果。
253
+ - 失败:显示明确错误和重试入口。
254
+
255
+ 视觉要求:
256
+
257
+ - 大面积留白和画廊板感。
258
+ - 图片区域可带轻裁切标记和细线网格。
259
+ - 不放入厚重卡片套卡片。
260
+ - 预览图优先,工具条围绕图片轻量排布。
261
+
262
+ ### 7.1 结果动作链
263
+
264
+ 结果出现后必须提供连续工作流,不让用户只看到一张图。
265
+
266
+ 动作:
267
+
268
+ - `下载`
269
+ - `继续编辑`
270
+ - `做变体`
271
+ - `复用提示词`
272
+ - `对比`
273
+
274
+ 行为要求:
275
+
276
+ - `下载`:保存当前图片。
277
+ - `继续编辑`:切换到图生图或编辑模式,并带入当前图片。
278
+ - `做变体`:保留提示词和基础参数,生成新变体。
279
+ - `复用提示词`:把历史或当前提示词带回左侧创作单。
280
+ - `对比`:支持与上一张或选中历史图对比。
281
+
282
+ 这些动作应在结果附近,而不是只藏在历史列表里。
283
+
284
+ ## 8. 右侧相册与记录
285
+
286
+ 右侧分为灵感和历史,避免概念混淆。
287
+
288
+ ### 8.1 标签页
289
+
290
+ 两个主要标签:
291
+
292
+ - `灵感相册`
293
+ - `最近生成`
294
+
295
+ 区别:
296
+
297
+ - `灵感相册`:保存模板、风格、参考提示词和收藏。
298
+ - `最近生成`:真实生成记录、图片和参数。
299
+
300
+ ### 8.2 灵感相册
301
+
302
+ 卡片内容:
303
+
304
+ - 缩略图或风格封面。
305
+ - 风格名。
306
+ - 简短提示词片段。
307
+ - 收藏或固定入口。
308
+ - `套用` 动作。
309
+
310
+ 视觉:
311
+
312
+ - 胶片照片或杂志剪贴感。
313
+ - 小面积纸层,不要重卡片。
314
+
315
+ ### 8.3 最近生成
316
+
317
+ 卡片内容:
318
+
319
+ - 缩略图。
320
+ - 时间或批次信息。
321
+ - 模型和尺寸。
322
+ - 提示词片段。
323
+ - 收藏、复用、继续编辑入口。
324
+
325
+ 要求:
326
+
327
+ - 历史记录支持快速回到创作单。
328
+ - 批量结果可折叠成批次。
329
+ - 失败记录应能看到原因,但不要占据主界面。
330
+
331
+ ### 8.4 生成动态
332
+
333
+ 生成动态是低干扰日志,不是开发者控制台。
334
+
335
+ 显示内容:
336
+
337
+ - 请求开始。
338
+ - 流式预览更新。
339
+ - 图片保存完成。
340
+ - 失败原因和重试建议。
341
+
342
+ 文案要求:
343
+
344
+ - 用户可理解。
345
+ - 不展示原始堆栈。
346
+ - 技术细节放到专业模式或调试详情。
347
+
348
+ ## 9. 高级设置
349
+
350
+ 高级设置不能堆在主界面。
351
+
352
+ 入口:
353
+
354
+ - `省心模式`
355
+ - `专业模式`
356
+
357
+ 默认使用 `省心模式`。
358
+
359
+ 专业模式分组:
360
+
361
+ - `输出`
362
+ - `模型`
363
+ - `流式`
364
+ - `路由`
365
+
366
+ 要求:
367
+
368
+ - 主界面只显示高频参数。
369
+ - 专业模式可展开,但不要一次铺满所有字段。
370
+ - 工程词在主界面减少暴露,专业模式中可保留必要精确项。
371
+ - 当前选择会影响费用或稳定性时,应显示解释。
372
+ - 当前实现中,桌面端高级设置位于底部 Pro Dock。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。
373
+ - 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。
374
+
375
+ ## 10. 模式交互
376
+
377
+ ### 10.1 文生图
378
+
379
+ 默认模式。
380
+
381
+ 主流程:
382
+
383
+ 1. 写提示词。
384
+ 2. 选择风格标签。
385
+ 3. 调整尺寸、数量、清晰度和格式。
386
+ 4. 确认模型、流式状态和预计费用。
387
+ 5. 点击 `生成图像`。
388
+ 6. 在中央查看结果。
389
+ 7. 下载、编辑、变体、复用或对比。
390
+
391
+ ### 10.2 图生图
392
+
393
+ 图生图需要明确上传入口和参考图状态。
394
+
395
+ 新增区域:
396
+
397
+ - 图片上传。
398
+ - 多图列表。
399
+ - 遮罩或局部编辑入口。
400
+ - 参考强度或编辑说明。
401
+
402
+ 要求:
403
+
404
+ - 上传图出现在中央或左侧明确位置。
405
+ - 不让用户误以为还在纯文生图。
406
+ - 结果仍进入同一动作链。
407
+
408
+ ### 10.3 批量
409
+
410
+ 批量模式面向多提示词或多尺寸任务。
411
+
412
+ 新增区域:
413
+
414
+ - 批量提示词列表。
415
+ - 批次设置。
416
+ - 失败重试策略。
417
+ - 进度和失败汇总。
418
+
419
+ 要求:
420
+
421
+ - 批量进度不挤占单张预览主区域。
422
+ - 支持暂停、重试、复用失败项。
423
+ - 结果按批次进入最近生成。
424
+ - 当前实现中,批量模式按“每行提示词一条任务”执行,每条任务使用当前统一的尺寸、清晰度、格式、模型、流式和路由设置。
425
+ - 批量模式默认顺序执行。只有用户显式启用 `并发批量`,且当前任务数大于 1、流式策略可用、服务端或用户自填 API Key 有并发容量时,才会并发执行。
426
+ - 暂停批量时,已开始任务继续完成,未开始任务保留为失败项,便于用户复用失败项后再次提交。
427
+ - 底部提示词动作必须使用当前可见的批量提示词文本,不得回退到隐藏的单条文生图提示词。
428
+
429
+ ### 10.4 复用历史
430
+
431
+ 复用历史让用户从右侧记录反向进入创作。
432
+
433
+ 能力:
434
+
435
+ - 从历史图恢复提示词。
436
+ - 从历史图恢复参数。
437
+ - 从收藏模板套用风格。
438
+ - 从某张图进入继续编辑。
439
+
440
+ 要求:
441
+
442
+ - 明确显示复用了哪些内容。
443
+ - 用户可以修改后再生成。
444
+
445
+ ## 11. 移动端原则
446
+
447
+ 移动端不能简单压缩三栏。
448
+
449
+ 移动端结构:
450
+
451
+ - 画布优先。
452
+ - 底部抽屉承载创作单。
453
+ - 横向滑动相册。
454
+ - 顶部只保留标题、状态和设置。
455
+
456
+ 移动端顺序:
457
+
458
+ 1. 画面预览。
459
+ 2. 底部 `生成图像` 主按钮。
460
+ 3. 上滑展开创作单。
461
+ 4. 横滑查看灵感和历史。
462
+ 5. 专业模式放二级抽屉。
463
+
464
+ 要求:
465
+
466
+ - 主按钮始终易触达。
467
+ - 不让参数挤压图片预览。
468
+ - 图生图上传和结果动作链要适配触控。
469
+
470
+ ## 12. 文案规范
471
+
472
+ 整体文案中文优先,少工程味,少教程感。
473
+
474
+ 推荐文案:
475
+
476
+ - `图像手记`
477
+ - `今天想做什么画面?`
478
+ - `写下灵感`
479
+ - `生成图像`
480
+ - `存为灵感`
481
+ - `随便来点`
482
+ - `画面预览`
483
+ - `还没有生成图像`
484
+ - `灵感相册`
485
+ - `最近生成`
486
+ - `生成动态`
487
+ - `继续编辑`
488
+ - `做变体`
489
+ - `复用提示词`
490
+ - `省心模式`
491
+ - `专业模式`
492
+
493
+ 避免文案:
494
+
495
+ - `Generate`
496
+ - `Edit`
497
+ - `Debug`
498
+ - `Routing`
499
+ - `Backend`
500
+ - `SSE`
501
+ - `Responses`
502
+
503
+ 必要技术名可以放入专业模式,例如 `Responses`、`SSE`、`image backend`,但主界面应转译为用户可理解的状态。
504
+
505
+ ## 13. 实现注意
506
+
507
+ 后续开发时应保持以下边界:
508
+
509
+ - 不直接照搬生成图中的错字或不稳定小字,以本文档为准。
510
+ - 不为了装饰牺牲表单可访问性和键盘操作。
511
+ - 不把高级参数重新堆回主界面。
512
+ - 不使用古风素材或传统器物隐喻。
513
+ - 不提交临时生成图,除非后续明确把某张图作为正式设计资产。
514
+ - 每次 UI 实现后需要做桌面和移动端截图复核。
515
+ - 结果区动作链和右侧最近生成都必须保留真实可操作入口:下载、继续编辑、做变体、复用提示词、对比和保存为灵感。批量历史默认折叠多图缩略图,避免右侧列表被单个批次撑开。
516
+ - 最近生成标签页在有生成历史且没有灵感项时应自动进入最近生成视图;失败记录显示原因但不伪造缩略图。
517
+
518
+ 建议实现拆分:
519
+
520
+ 1. 先建立新的页面信息架构和状态模型。
521
+ 2. 再重构左侧创作单。
522
+ 3. 再重构中央画布和结果动作链。
523
+ 4. 再重构右侧灵感相册和最近生成。
524
+ 5. 最后接入专业模式抽屉和移动端布局。
525
+
526
+ ## 14. 验收清单
527
+
528
+ 视觉验收:
529
+
530
+ - 页面第一印象是现代女青年文艺风,而不是古风或普通后台。
531
+ - 米黄色基底清爽,不显旧、不显脏。
532
+ - 柔粉、鼠尾草绿、咖啡��和珊瑚红比例克制。
533
+ - 装饰来自现代手账、胶片、杂志和生活方式,不来自传统器物。
534
+
535
+ 功能验收:
536
+
537
+ - 文生图、图生图、批量、复用历史入口清晰。
538
+ - 用户点生成前能看到模型、渠道和预计费用。
539
+ - 生成后能直接下载、继续编辑、做变体、复用提示词和对比。
540
+ - 灵感相册与最近生成概念分离。
541
+ - 高级参数默认收纳,但可被专业用户找到。
542
+ - 移动端以画布优先,不是三栏硬压缩。
543
+
544
+ 工程验收:
545
+
546
+ - 主界面无装饰性 Unicode 符号。
547
+ - 文案中文优先。
548
+ - 核心表单可键盘操作。
549
+ - 响应式下文本不重叠。
550
+ - `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build` 和 `git diff --check` 作为实现收尾基线。
package-lock.json CHANGED
@@ -1,12 +1,13 @@
1
  {
2
  "name": "gpt-image-playground",
3
- "version": "1.3.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "gpt-image-playground",
9
- "version": "1.3.0",
 
10
  "dependencies": {
11
  "@radix-ui/react-checkbox": "^1.3.2",
12
  "@radix-ui/react-dialog": "^1.1.11",
 
1
  {
2
  "name": "gpt-image-playground",
3
+ "version": "1.4.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "gpt-image-playground",
9
+ "version": "1.4.0",
10
+ "license": "MIT",
11
  "dependencies": {
12
  "@radix-ui/react-checkbox": "^1.3.2",
13
  "@radix-ui/react-dialog": "^1.1.11",
package.json CHANGED
@@ -1,13 +1,14 @@
1
  {
2
  "name": "gpt-image-playground",
3
- "version": "1.3.0",
 
4
  "private": true,
5
  "scripts": {
6
  "dev": "next dev --turbopack -p 4783",
7
  "prebuild": "node scripts/clean-standalone.mjs",
8
  "build": "next build",
9
  "postbuild": "node scripts/patch-standalone-runtime.mjs",
10
- "test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"",
11
  "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
12
  "test:postgres": "node scripts/test-postgres-live.mjs",
13
  "doctor": "node scripts/doctor.mjs",
 
1
  {
2
  "name": "gpt-image-playground",
3
+ "version": "1.4.0",
4
+ "license": "MIT",
5
  "private": true,
6
  "scripts": {
7
  "dev": "next dev --turbopack -p 4783",
8
  "prebuild": "node scripts/clean-standalone.mjs",
9
  "build": "next build",
10
  "postbuild": "node scripts/patch-standalone-runtime.mjs",
11
+ "test": "node --test --import tsx \"src/**/*.test.ts\" \"src/**/*.test.tsx\" \"scripts/**/*.test.mjs\"",
12
  "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
13
  "test:postgres": "node scripts/test-postgres-live.mjs",
14
  "doctor": "node scripts/doctor.mjs",
scripts/agent-doctor.mjs CHANGED
@@ -5,52 +5,339 @@ import { fileURLToPath } from 'node:url';
5
  import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
6
 
7
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
 
8
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
 
9
 
10
  export function buildAgentDoctorArgs() {
11
  return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
12
  }
13
 
14
  function parseArgs(argv) {
15
- const unknown = argv.find((arg) => !['--help', '-h'].includes(arg));
16
- if (unknown) throw new Error(`Unknown option: ${unknown}`);
17
- return {
18
- help: argv.includes('--help') || argv.includes('-h')
 
19
  };
 
 
 
 
 
 
 
 
 
20
  }
21
 
22
  function printHelp() {
23
  console.log(`Usage:
24
  npm run agent:doctor
 
25
 
26
  Environment:
27
  GPT_IMAGE_PLAYGROUND_URL Service base URL, defaults to http://localhost:4783.
28
  GPT_IMAGE_AGENT_TOKEN Bearer token when capabilities require bearer auth.
29
- GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth.`);
 
 
30
  }
31
 
32
- function main() {
33
  const options = parseArgs(process.argv.slice(2));
34
  if (options.help) {
35
  printHelp();
36
  return;
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  const result = runCommand(process.execPath, buildAgentDoctorArgs(), {
40
  env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' },
41
  timeoutMs: AGENT_DOCTOR_TIMEOUT_MS
42
  });
43
  if (!result.ok) {
44
- printJson({ ok: false, command: 'agent:doctor', output: pickFailureOutput(result) });
45
- process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- const body = result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {};
49
- printJson({ ok: true, command: 'agent:doctor', contract: body });
50
  }
51
 
52
  try {
53
- if (isMainModule(import.meta.url, process.argv[1])) main();
54
  } catch (error) {
55
  printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
56
  process.exit(1);
 
5
  import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
6
 
7
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
8
+ const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
9
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
10
+ const DEFAULT_BASE_URL = 'http://localhost:4783';
11
 
12
  export function buildAgentDoctorArgs() {
13
  return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
14
  }
15
 
16
  function parseArgs(argv) {
17
+ const parsed = {
18
+ help: false,
19
+ allowBillable: false,
20
+ timeoutMs: 60_000,
21
+ editImage: undefined
22
  };
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const arg = argv[index];
25
+ if (arg === '--help' || arg === '-h') parsed.help = true;
26
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
27
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), '--timeout-ms');
28
+ else if (arg === '--edit-image') parsed.editImage = readOptionValue(argv, (index += 1), arg);
29
+ else throw new Error(`Unknown option: ${arg}`);
30
+ }
31
+ return parsed;
32
  }
33
 
34
  function printHelp() {
35
  console.log(`Usage:
36
  npm run agent:doctor
37
+ npm run agent:doctor -- --allow-billable --edit-image /path/to/reference.png
38
 
39
  Environment:
40
  GPT_IMAGE_PLAYGROUND_URL Service base URL, defaults to http://localhost:4783.
41
  GPT_IMAGE_AGENT_TOKEN Bearer token when capabilities require bearer auth.
42
+ GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth.
43
+
44
+ By default agent:doctor is read-only and non-billable. Billable generate/edit smoke checks require --allow-billable.`);
45
  }
46
 
47
+ async function main() {
48
  const options = parseArgs(process.argv.slice(2));
49
  if (options.help) {
50
  printHelp();
51
  return;
52
  }
53
 
54
+ const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || DEFAULT_BASE_URL);
55
+ const contract = runContractCheck();
56
+ const capabilities = await readJsonLayer('capabilities', `${baseUrl}/api/agent/capabilities`, options.timeoutMs);
57
+ const runtime = await readJsonLayer('runtime', `${baseUrl}/api/runtime-capabilities`, options.timeoutMs);
58
+ const smoke = options.allowBillable ? runBillableSmoke(options) : buildSkippedSmoke(options);
59
+ const layers = buildLayers({ capabilities, runtime, contract, smoke });
60
+
61
+ printJson({
62
+ ok: layers.every((layer) => layer.ok || layer.skipped),
63
+ command: 'agent:doctor',
64
+ billable: options.allowBillable,
65
+ base_url: redactBaseUrl(baseUrl),
66
+ layers,
67
+ summary: buildSummary({ capabilities, runtime, contract, smoke })
68
+ });
69
+ if (layers.some((layer) => !layer.ok && !layer.skipped)) process.exit(1);
70
+ }
71
+
72
+ function runContractCheck() {
73
  const result = runCommand(process.execPath, buildAgentDoctorArgs(), {
74
  env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' },
75
  timeoutMs: AGENT_DOCTOR_TIMEOUT_MS
76
  });
77
  if (!result.ok) {
78
+ return { ok: false, output: pickFailureOutput(result), elapsed_ms: result.elapsed_ms };
79
+ }
80
+ return {
81
+ ok: true,
82
+ elapsed_ms: result.elapsed_ms,
83
+ body: result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {}
84
+ };
85
+ }
86
+
87
+ async function readJsonLayer(name, url, timeoutMs) {
88
+ const controller = new AbortController();
89
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
90
+ try {
91
+ const response = await fetch(url, { headers: authHeaders(), signal: controller.signal });
92
+ const text = await response.text();
93
+ if (!response.ok) throw new Error(`${safePathname(url)} failed with HTTP ${response.status}${formatBodySnippet(text)}`);
94
+ return { ok: true, body: text ? JSON.parse(text) : {} };
95
+ } catch (error) {
96
+ return { ok: false, error: error instanceof Error ? error.message : String(error), name };
97
+ } finally {
98
+ clearTimeout(timer);
99
+ }
100
+ }
101
+
102
+ function buildSkippedSmoke(options) {
103
+ return {
104
+ ok: true,
105
+ skipped: true,
106
+ reason: 'requires --allow-billable',
107
+ checks: [
108
+ { name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
109
+ {
110
+ name: 'edit_1k',
111
+ skipped: true,
112
+ reason: options.editImage ? 'requires --allow-billable' : 'requires --allow-billable and --edit-image'
113
+ },
114
+ {
115
+ name: 'page_sse_edit_2k',
116
+ skipped: true,
117
+ reason: options.editImage ? 'requires --allow-billable' : 'requires --allow-billable and --edit-image'
118
+ }
119
+ ]
120
+ };
121
+ }
122
+
123
+ function runBillableSmoke(options) {
124
+ const checks = [
125
+ runSmokeCommand('generate_1k', [
126
+ GENERATE_SCRIPT,
127
+ '--allow-billable',
128
+ '--agent',
129
+ '--timeout-ms',
130
+ String(options.timeoutMs),
131
+ '--size',
132
+ '1024x1024',
133
+ '--quality',
134
+ 'low',
135
+ '--idempotency-key',
136
+ `agent-doctor-generate-${Date.now()}`,
137
+ 'agent doctor 1k generate smoke'
138
+ ])
139
+ ];
140
+ if (options.editImage) {
141
+ checks.push(
142
+ runSmokeCommand('edit_1k', [
143
+ EDIT_SCRIPT,
144
+ '--allow-billable',
145
+ '--agent',
146
+ '--timeout-ms',
147
+ String(options.timeoutMs),
148
+ '--size',
149
+ '1024x1024',
150
+ '--quality',
151
+ 'low',
152
+ '--idempotency-key',
153
+ `agent-doctor-edit-${Date.now()}`,
154
+ options.editImage,
155
+ 'agent doctor 1k edit smoke'
156
+ ])
157
+ );
158
+ checks.push(
159
+ runSmokeCommand('page_sse_edit_2k', [
160
+ EDIT_SCRIPT,
161
+ '--allow-billable',
162
+ '--page-sse',
163
+ '--timeout-ms',
164
+ String(options.timeoutMs),
165
+ '--size',
166
+ '2048x2048',
167
+ '--quality',
168
+ 'low',
169
+ '--idempotency-key',
170
+ `agent-doctor-page-sse-edit-${Date.now()}`,
171
+ options.editImage,
172
+ 'agent doctor 2k page SSE edit smoke'
173
+ ])
174
+ );
175
+ } else {
176
+ checks.push({ name: 'edit_1k', ok: true, skipped: true, reason: 'requires --edit-image' });
177
+ checks.push({ name: 'page_sse_edit_2k', ok: true, skipped: true, reason: 'requires --edit-image' });
178
  }
179
+ return { ok: checks.every((check) => check.ok || check.skipped), skipped: false, checks };
180
+ }
181
+
182
+ function runSmokeCommand(name, args) {
183
+ const result = runCommand(process.execPath, args, {
184
+ env: process.env,
185
+ timeoutMs: AGENT_DOCTOR_TIMEOUT_MS
186
+ });
187
+ return {
188
+ name,
189
+ ok: result.ok,
190
+ elapsed_ms: result.elapsed_ms,
191
+ ...(result.ok ? {} : { output: pickFailureOutput(result) })
192
+ };
193
+ }
194
+
195
+ function buildLayers({ capabilities, runtime, contract, smoke }) {
196
+ return [
197
+ {
198
+ name: 'capabilities',
199
+ ok: capabilities.ok,
200
+ endpoint: '/api/agent/capabilities',
201
+ ...(capabilities.ok ? summarizeCapabilities(capabilities.body) : { error: capabilities.error })
202
+ },
203
+ {
204
+ name: 'contract_check',
205
+ ok: contract.ok,
206
+ billable: false,
207
+ ...(contract.ok ? { checks: contract.body.checks || [] } : { error: contract.output })
208
+ },
209
+ {
210
+ name: 'runtime_backend',
211
+ ok: runtime.ok,
212
+ endpoint: '/api/runtime-capabilities',
213
+ ...(runtime.ok ? summarizeRuntime(runtime.body) : { error: runtime.error })
214
+ },
215
+ {
216
+ name: 'state_backend',
217
+ ok: capabilities.ok,
218
+ ...(capabilities.ok ? summarizeStateBackend(capabilities.body) : { error: capabilities.error })
219
+ },
220
+ {
221
+ name: 'responses_gpt2image_readiness',
222
+ ok: capabilities.ok && runtime.ok,
223
+ ...(capabilities.ok && runtime.ok
224
+ ? summarizeResponsesReadiness(capabilities.body, runtime.body)
225
+ : { error: 'requires capabilities and runtime layers' })
226
+ },
227
+ {
228
+ name: 'billable_smoke',
229
+ ok: smoke.ok,
230
+ skipped: smoke.skipped,
231
+ checks: smoke.checks,
232
+ ...(smoke.reason ? { reason: smoke.reason } : {})
233
+ }
234
+ ];
235
+ }
236
+
237
+ function summarizeCapabilities(body) {
238
+ return {
239
+ page_sse: body?.agent_streaming?.page_sse?.supported === true,
240
+ agent_jobs: body?.agent_jobs?.supported === true,
241
+ routing_rules: Boolean(body?.routing_rules),
242
+ executable_routing_rules: Boolean(body?.routing_rules?.high_resolution_edit?.conditions)
243
+ };
244
+ }
245
+
246
+ function summarizeRuntime(body) {
247
+ return {
248
+ default_stream_mode: body?.streaming?.defaultMode,
249
+ streaming_unavailable_scope: body?.streaming?.unavailableMarkScope,
250
+ responses_image_backend: body?.responsesImageBackend?.enabled === true,
251
+ streaming_batch_enabled: body?.streamingBatch?.enabled === true
252
+ };
253
+ }
254
+
255
+ function summarizeStateBackend(body) {
256
+ return {
257
+ backend: body?.defaults?.state_backend,
258
+ image_storage_mode: body?.storage?.image_storage_mode,
259
+ postgres_configured: body?.storage?.postgres_configured === true
260
+ };
261
+ }
262
+
263
+ function summarizeResponsesReadiness(capabilities, runtime) {
264
+ const requirements = capabilities?.supported?.image_backend_requirements?.['responses-image-generation'];
265
+ return {
266
+ backend_supported: requirements?.supported === true,
267
+ backend_enabled: requirements?.enabled === true,
268
+ runtime_enabled: runtime?.responsesImageBackend?.enabled === true,
269
+ missing_env: requirements?.missing_env || [],
270
+ gpt2image_real_smoke_case: 'gpt2image-responses-sse',
271
+ real_smoke_gate:
272
+ 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
273
+ };
274
+ }
275
+
276
+ function buildSummary({ capabilities, runtime, contract, smoke }) {
277
+ return {
278
+ capabilities: capabilities.ok ? 'ok' : 'failed',
279
+ contract_check: contract.ok ? 'ok' : 'failed',
280
+ runtime: runtime.ok ? 'ok' : 'failed',
281
+ state_backend: capabilities.ok ? capabilities.body?.defaults?.state_backend : 'unknown',
282
+ responses_gpt2image_ready:
283
+ capabilities.ok && runtime.ok
284
+ ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
285
+ runtime.body?.responsesImageBackend?.enabled === true
286
+ : false,
287
+ billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed'
288
+ };
289
+ }
290
+
291
+ function authHeaders() {
292
+ if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
293
+ if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
294
+ return {};
295
+ }
296
+
297
+ function readOptionValue(argv, index, name) {
298
+ const value = argv[index];
299
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value.`);
300
+ return value;
301
+ }
302
+
303
+ function readPositiveInteger(value, name) {
304
+ if (!/^\d+$/.test(String(value))) throw new Error(`${name} must be a positive integer.`);
305
+ const parsed = Number(value);
306
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer.`);
307
+ return parsed;
308
+ }
309
+
310
+ function normalizeBaseUrl(value) {
311
+ const normalized = String(value || '').trim().replace(/\/+$/, '');
312
+ const parsed = new URL(normalized);
313
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
314
+ throw new Error('base URL must use http or https.');
315
+ }
316
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
317
+ throw new Error('base URL must not include credentials, query parameters, or fragments.');
318
+ }
319
+ return normalized;
320
+ }
321
+
322
+ function redactBaseUrl(value) {
323
+ const parsed = new URL(value);
324
+ return `${parsed.protocol}//${parsed.host}`;
325
+ }
326
+
327
+ function safePathname(url) {
328
+ try {
329
+ return new URL(url).pathname;
330
+ } catch {
331
+ return String(url);
332
+ }
333
+ }
334
 
335
+ function formatBodySnippet(text) {
336
+ return text ? `: ${text.slice(0, 100)}` : '';
337
  }
338
 
339
  try {
340
+ if (isMainModule(import.meta.url, process.argv[1])) await main();
341
  } catch (error) {
342
  printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
343
  process.exit(1);
scripts/agent-skill-scripts.test.mjs CHANGED
The diff for this file is too large to render. See raw diff
 
scripts/command-center.test.mjs CHANGED
@@ -1,4 +1,5 @@
1
  import assert from 'node:assert/strict';
 
2
  import { createServer } from 'node:http';
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
  import os from 'node:os';
@@ -318,6 +319,101 @@ describe('Command center scripts', () => {
318
  assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']);
319
  });
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  it('preserves raw child output for command consumers', () => {
322
  const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']);
323
 
@@ -427,3 +523,53 @@ describe('Command center scripts', () => {
427
  });
428
  });
429
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
  import { createServer } from 'node:http';
4
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
  import os from 'node:os';
 
319
  assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']);
320
  });
321
 
322
+ it('reports layered agent:doctor diagnostics without billable smoke by default', async () => {
323
+ await withServer(
324
+ (request, response) => {
325
+ if (request.url === '/api/agent/capabilities') {
326
+ response.writeHead(200, { 'content-type': 'application/json' });
327
+ response.end(
328
+ JSON.stringify({
329
+ defaults: { state_backend: 'memory' },
330
+ storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
331
+ agent_streaming: {
332
+ page_sse: { supported: true }
333
+ },
334
+ agent_jobs: { supported: true },
335
+ routing_rules: {
336
+ high_resolution_edit: {
337
+ conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
338
+ }
339
+ },
340
+ supported: {
341
+ image_backend_requirements: {
342
+ 'responses-image-generation': {
343
+ supported: true,
344
+ enabled: true,
345
+ missing_env: []
346
+ }
347
+ }
348
+ }
349
+ })
350
+ );
351
+ return;
352
+ }
353
+ if (request.url === '/api/runtime-capabilities') {
354
+ response.writeHead(200, { 'content-type': 'application/json' });
355
+ response.end(
356
+ JSON.stringify({
357
+ streaming: {
358
+ defaultMode: 'auto',
359
+ unavailableMarkScope: 'channel+backend+strategy+operation'
360
+ },
361
+ streamingBatch: { enabled: true },
362
+ responsesImageBackend: { enabled: true, mode: 'experimental' }
363
+ })
364
+ );
365
+ return;
366
+ }
367
+ if (request.url === '/api/agent/images/generate') {
368
+ response.writeHead(400, { 'content-type': 'application/json' });
369
+ response.end(
370
+ JSON.stringify({
371
+ error: {
372
+ code: 'idempotency_key_required',
373
+ message: 'missing key',
374
+ retryable: false
375
+ }
376
+ })
377
+ );
378
+ return;
379
+ }
380
+ if (request.url === '/api/agent/jobs/images/generate') {
381
+ response.writeHead(400, { 'content-type': 'application/json' });
382
+ response.end(
383
+ JSON.stringify({
384
+ error: {
385
+ code: 'idempotency_key_required',
386
+ message: 'missing key',
387
+ retryable: false
388
+ }
389
+ })
390
+ );
391
+ return;
392
+ }
393
+ response.writeHead(404, { 'content-type': 'application/json' });
394
+ response.end(JSON.stringify({ error: 'missing' }));
395
+ },
396
+ async (baseUrl) => {
397
+ const result = await runNodeCommandAsync(['scripts/agent-doctor.mjs'], {
398
+ env: { ...process.env, GPT_IMAGE_PLAYGROUND_URL: baseUrl },
399
+ timeoutMs: 15_000
400
+ });
401
+
402
+ assert.equal(result.ok, true);
403
+ const body = parseJsonPayload(result.stdout, 'agent doctor');
404
+ assert.equal(body.ok, true);
405
+ assert.equal(body.billable, false);
406
+ assert.equal(body.summary.capabilities, 'ok');
407
+ assert.equal(body.summary.runtime, 'ok');
408
+ assert.equal(body.summary.state_backend, 'memory');
409
+ assert.equal(body.summary.responses_gpt2image_ready, true);
410
+ assert.equal(body.summary.billable_smoke, 'skipped');
411
+ assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true);
412
+ assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
413
+ }
414
+ );
415
+ });
416
+
417
  it('preserves raw child output for command consumers', () => {
418
  const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']);
419
 
 
523
  });
524
  });
525
  });
526
+
527
+ async function withServer(handler, run) {
528
+ const server = createServer(handler);
529
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
530
+ const address = server.address();
531
+ try {
532
+ assert.equal(typeof address, 'object');
533
+ assert.ok(address);
534
+ await run(`http://127.0.0.1:${address.port}`);
535
+ } finally {
536
+ await new Promise((resolve) => server.close(resolve));
537
+ }
538
+ }
539
+
540
+ function runNodeCommandAsync(args, options = {}) {
541
+ return new Promise((resolve) => {
542
+ const child = spawn(process.execPath, args, {
543
+ cwd: process.cwd(),
544
+ env: options.env,
545
+ stdio: ['ignore', 'pipe', 'pipe']
546
+ });
547
+ let stdout = '';
548
+ let stderr = '';
549
+ const startedAt = Date.now();
550
+ const timeout = options.timeoutMs
551
+ ? setTimeout(() => {
552
+ child.kill('SIGTERM');
553
+ }, options.timeoutMs)
554
+ : undefined;
555
+ child.stdout.setEncoding('utf8');
556
+ child.stderr.setEncoding('utf8');
557
+ child.stdout.on('data', (chunk) => {
558
+ stdout += chunk;
559
+ });
560
+ child.stderr.on('data', (chunk) => {
561
+ stderr += chunk;
562
+ });
563
+ child.on('close', (status, signal) => {
564
+ if (timeout) clearTimeout(timeout);
565
+ resolve({
566
+ ok: status === 0,
567
+ status,
568
+ signal,
569
+ stdout,
570
+ stderr,
571
+ elapsed_ms: Date.now() - startedAt
572
+ });
573
+ });
574
+ });
575
+ }
scripts/keepalive-hf-space.mjs CHANGED
@@ -6,6 +6,9 @@ import { validateSpaceUrl } from './hf-space-doctor-utils.mjs';
6
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
7
  const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
8
  const DEFAULT_TIMEOUT_MS = 30_000;
 
 
 
9
  function normalizeUrl(rawUrl, path) {
10
  const urlError = validateSpaceUrl(rawUrl);
11
  if (urlError) {
@@ -34,18 +37,36 @@ async function readJsonResponse(response) {
34
  }
35
  }
36
 
37
- async function pingKeepaliveEndpoint() {
38
  const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL;
39
  const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH;
40
  const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000);
 
 
41
  const expectedPasswordRequired = readExpectedPasswordRequired();
42
  const url = normalizeUrl(spaceUrl, path);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  const controller = new AbortController();
44
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
45
  const startedAt = Date.now();
46
 
47
  try {
48
- const response = await fetch(url, {
49
  headers: {
50
  'User-Agent': 'gpt-image-playground-keepalive/1.0'
51
  },
@@ -57,18 +78,22 @@ async function pingKeepaliveEndpoint() {
57
  if (!response.ok) {
58
  throw new Error(`Keepalive endpoint failed with HTTP ${response.status}`);
59
  }
60
- if (expectedPasswordRequired !== undefined && body?.passwordRequired !== expectedPasswordRequired) {
61
- throw new Error(`passwordRequired expected ${expectedPasswordRequired}, got ${body?.passwordRequired}`);
 
 
 
62
  }
63
 
64
  console.log(
65
  JSON.stringify(
66
  {
67
  ok: true,
68
- url,
69
  status: response.status,
70
  elapsedMs,
71
- passwordRequired: body?.passwordRequired
 
72
  },
73
  null,
74
  2
@@ -79,16 +104,41 @@ async function pingKeepaliveEndpoint() {
79
  }
80
  }
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  pingKeepaliveEndpoint().catch((error) => {
83
- console.error(
84
- JSON.stringify(
85
- {
86
- ok: false,
87
- error: error instanceof Error ? error.message : String(error)
88
- },
89
- null,
90
- 2
91
- )
92
- );
93
  process.exit(1);
94
  });
 
6
  const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
7
  const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
8
  const DEFAULT_TIMEOUT_MS = 30_000;
9
+ const DEFAULT_MAX_ATTEMPTS = 1;
10
+ const DEFAULT_RETRY_DELAY_MS = 5_000;
11
+
12
  function normalizeUrl(rawUrl, path) {
13
  const urlError = validateSpaceUrl(rawUrl);
14
  if (urlError) {
 
37
  }
38
  }
39
 
40
+ function readKeepaliveConfig() {
41
  const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL;
42
  const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH;
43
  const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000);
44
+ const maxAttempts = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_MAX_ATTEMPTS', DEFAULT_MAX_ATTEMPTS);
45
+ const retryDelayMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_RETRY_DELAY_MS', DEFAULT_RETRY_DELAY_MS);
46
  const expectedPasswordRequired = readExpectedPasswordRequired();
47
  const url = normalizeUrl(spaceUrl, path);
48
+
49
+ return { url, timeoutMs, maxAttempts, retryDelayMs, expectedPasswordRequired };
50
+ }
51
+
52
+ function formatKeepaliveError(error, timeoutMs) {
53
+ if (error?.name === 'AbortError') {
54
+ return `Keepalive request timed out after ${timeoutMs}ms`;
55
+ }
56
+ return error instanceof Error ? error.message : String(error);
57
+ }
58
+
59
+ async function waitBeforeRetry(ms) {
60
+ await new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+
63
+ async function pingKeepaliveEndpointOnce(config, attemptLabel) {
64
  const controller = new AbortController();
65
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
66
  const startedAt = Date.now();
67
 
68
  try {
69
+ const response = await fetch(config.url, {
70
  headers: {
71
  'User-Agent': 'gpt-image-playground-keepalive/1.0'
72
  },
 
78
  if (!response.ok) {
79
  throw new Error(`Keepalive endpoint failed with HTTP ${response.status}`);
80
  }
81
+ if (
82
+ config.expectedPasswordRequired !== undefined &&
83
+ body?.passwordRequired !== config.expectedPasswordRequired
84
+ ) {
85
+ throw new Error(`passwordRequired expected ${config.expectedPasswordRequired}, got ${body?.passwordRequired}`);
86
  }
87
 
88
  console.log(
89
  JSON.stringify(
90
  {
91
  ok: true,
92
+ url: config.url,
93
  status: response.status,
94
  elapsedMs,
95
+ passwordRequired: body?.passwordRequired,
96
+ attempt: attemptLabel
97
  },
98
  null,
99
  2
 
104
  }
105
  }
106
 
107
+ async function pingKeepaliveEndpoint() {
108
+ const config = readKeepaliveConfig();
109
+ let lastError;
110
+
111
+ for (let attempt = 1; attempt <= config.maxAttempts; attempt += 1) {
112
+ try {
113
+ const attemptLabel = `${attempt}/${config.maxAttempts}`;
114
+ await pingKeepaliveEndpointOnce(config, attemptLabel);
115
+ return;
116
+ } catch (error) {
117
+ lastError = error;
118
+ console.error(
119
+ JSON.stringify(
120
+ {
121
+ ok: false,
122
+ attempt: `${attempt}/${config.maxAttempts}`,
123
+ error: formatKeepaliveError(error, config.timeoutMs)
124
+ },
125
+ null,
126
+ 2
127
+ )
128
+ );
129
+ if (attempt < config.maxAttempts) {
130
+ await waitBeforeRetry(config.retryDelayMs);
131
+ }
132
+ }
133
+ }
134
+
135
+ throw new Error(`Keepalive attempt already reported: ${formatKeepaliveError(lastError, config.timeoutMs)}`);
136
+ }
137
+
138
  pingKeepaliveEndpoint().catch((error) => {
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ if (!message.startsWith('Keepalive attempt already reported: ')) {
141
+ console.error(JSON.stringify({ ok: false, error: message }, null, 2));
142
+ }
 
 
 
 
 
 
143
  process.exit(1);
144
  });
scripts/keepalive-hf-space.test.mjs CHANGED
@@ -1,11 +1,20 @@
1
  import assert from 'node:assert/strict';
 
 
2
  import { spawnSync } from 'node:child_process';
3
  import { fileURLToPath } from 'node:url';
4
  import { join } from 'node:path';
5
- import { describe, it } from 'node:test';
6
 
7
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
8
  const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs');
 
 
 
 
 
 
 
9
 
10
  describe('HF Space keepalive script validation', () => {
11
  it('rejects non-integer timeout env values before network access', () => {
@@ -17,6 +26,15 @@ describe('HF Space keepalive script validation', () => {
17
  assert.equal(result.stdout.trim(), '');
18
  });
19
 
 
 
 
 
 
 
 
 
 
20
  it('rejects keepalive URLs with embedded credentials before network access', () => {
21
  const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' });
22
 
@@ -26,12 +44,77 @@ describe('HF Space keepalive script validation', () => {
26
  assert.doesNotMatch(result.stderr, /secret/);
27
  assert.equal(result.stdout.trim(), '');
28
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  });
30
 
31
- function runKeepalive(env) {
32
- return spawnSync(process.execPath, [scriptPath], {
33
  cwd: repoRoot,
34
  encoding: 'utf8',
35
  env: { ...process.env, ...env }
36
  });
37
  }
 
 
 
 
 
 
 
 
 
1
  import assert from 'node:assert/strict';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
  import { spawnSync } from 'node:child_process';
5
  import { fileURLToPath } from 'node:url';
6
  import { join } from 'node:path';
7
+ import { after, describe, it } from 'node:test';
8
 
9
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
10
  const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs');
11
+ const tempDirectories = [];
12
+
13
+ after(() => {
14
+ for (const directory of tempDirectories) {
15
+ rmSync(directory, { recursive: true, force: true, maxRetries: 3 });
16
+ }
17
+ });
18
 
19
  describe('HF Space keepalive script validation', () => {
20
  it('rejects non-integer timeout env values before network access', () => {
 
26
  assert.equal(result.stdout.trim(), '');
27
  });
28
 
29
+ it('rejects non-integer retry attempt values before network access', () => {
30
+ const result = runKeepalive({ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: 'two' });
31
+
32
+ assert.equal(result.status, 1);
33
+ assert.match(result.stderr, /HF_SPACE_KEEPALIVE_MAX_ATTEMPTS/);
34
+ assert.match(result.stderr, /integer/);
35
+ assert.equal(result.stdout.trim(), '');
36
+ });
37
+
38
  it('rejects keepalive URLs with embedded credentials before network access', () => {
39
  const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' });
40
 
 
44
  assert.doesNotMatch(result.stderr, /secret/);
45
  assert.equal(result.stdout.trim(), '');
46
  });
47
+
48
+ it('retries failed keepalive attempts before reporting success', () => {
49
+ const preloadPath = writeFetchStub(`
50
+ let count = 0;
51
+ globalThis.fetch = async () => {
52
+ count += 1;
53
+ if (count === 1) {
54
+ return new Response(JSON.stringify({ error: 'warming' }), {
55
+ status: 503,
56
+ headers: { 'Content-Type': 'application/json' }
57
+ });
58
+ }
59
+ return new Response(JSON.stringify({ passwordRequired: true }), {
60
+ status: 200,
61
+ headers: { 'Content-Type': 'application/json' }
62
+ });
63
+ };
64
+ `);
65
+ const result = runKeepalive(
66
+ {
67
+ HF_SPACE_KEEPALIVE_URL: 'https://misonl-gpt-image-playground-customer.hf.space',
68
+ HF_SPACE_KEEPALIVE_PATH: '/api/auth-status',
69
+ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '2',
70
+ HF_SPACE_KEEPALIVE_RETRY_DELAY_MS: '1',
71
+ HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000'
72
+ },
73
+ ['--import', preloadPath]
74
+ );
75
+
76
+ assert.equal(result.status, 0);
77
+ assert.match(result.stderr, /"attempt": "1\/2"/);
78
+ assert.match(result.stdout, /"ok": true/);
79
+ assert.match(result.stdout, /"attempt": "2\/2"/);
80
+ });
81
+
82
+ it('prints a timeout-specific final error when every attempt aborts', () => {
83
+ const preloadPath = writeFetchStub(`
84
+ globalThis.fetch = async (url, options) =>
85
+ new Promise((resolve, reject) => {
86
+ options.signal.addEventListener('abort', () => {
87
+ reject(new DOMException('This operation was aborted', 'AbortError'));
88
+ });
89
+ });
90
+ `);
91
+ const result = runKeepalive(
92
+ {
93
+ HF_SPACE_KEEPALIVE_MAX_ATTEMPTS: '1',
94
+ HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000'
95
+ },
96
+ ['--import', preloadPath]
97
+ );
98
+
99
+ assert.equal(result.status, 1);
100
+ assert.match(result.stderr, /Keepalive request timed out after 1000ms/);
101
+ assert.doesNotMatch(result.stderr, /This operation was aborted/);
102
+ assert.equal((result.stderr.match(/"ok": false/g) || []).length, 1);
103
+ });
104
  });
105
 
106
+ function runKeepalive(env, nodeArgs = []) {
107
+ return spawnSync(process.execPath, [...nodeArgs, scriptPath], {
108
  cwd: repoRoot,
109
  encoding: 'utf8',
110
  env: { ...process.env, ...env }
111
  });
112
  }
113
+
114
+ function writeFetchStub(source) {
115
+ const directory = mkdtempSync(join(tmpdir(), 'hf-keepalive-fetch-'));
116
+ tempDirectories.push(directory);
117
+ const filePath = join(directory, 'fetch-stub.mjs');
118
+ writeFileSync(filePath, source);
119
+ return filePath;
120
+ }
scripts/local-image-upstream-fixture.mjs CHANGED
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
6
  export const FIXTURE_IMAGE_BASE64 =
7
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
8
  export const MAX_JSON_BODY_BYTES = 1024 * 1024;
 
9
 
10
  class PayloadTooLargeError extends Error {
11
  constructor() {
@@ -75,6 +76,12 @@ function sendJson(response, status, body) {
75
  response.end(JSON.stringify(body));
76
  }
77
 
 
 
 
 
 
 
78
  function writeSse(response, event, payload) {
79
  if (event) response.write(`event: ${event}\n`);
80
  response.write(`data: ${JSON.stringify(payload)}\n\n`);
@@ -119,13 +126,20 @@ function sendResponsesStream(response) {
119
  id: 'ig_fixture',
120
  type: 'image_generation_call',
121
  status: 'completed',
122
- result: FIXTURE_IMAGE_BASE64
123
  }
124
  });
125
  writeSse(response, 'response.completed', {
126
  type: 'response.completed',
127
  response: {
128
- output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }]
 
 
 
 
 
 
 
129
  }
130
  });
131
  response.end('data: [DONE]\n\n');
@@ -153,7 +167,7 @@ function responsesJsonResponse(body) {
153
  id: 'resp_fixture',
154
  object: 'response',
155
  model: typeof body.model === 'string' ? body.model : 'gpt-5.4',
156
- output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }],
157
  usage: {
158
  input_tokens: 1,
159
  output_tokens: 1,
@@ -182,6 +196,10 @@ async function handleRequest(request, response) {
182
  sendJson(response, 200, modelsResponse());
183
  return;
184
  }
 
 
 
 
185
  if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
186
  const body = await readJsonBody(request);
187
  if (body.stream === true) {
 
6
  export const FIXTURE_IMAGE_BASE64 =
7
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
8
  export const MAX_JSON_BODY_BYTES = 1024 * 1024;
9
+ export const FIXTURE_IMAGE_PATH = '/api/storage/generations/fixture.png';
10
 
11
  class PayloadTooLargeError extends Error {
12
  constructor() {
 
76
  response.end(JSON.stringify(body));
77
  }
78
 
79
+ function sendFixtureImage(response) {
80
+ const buffer = Buffer.from(FIXTURE_IMAGE_BASE64, 'base64');
81
+ response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(buffer.byteLength) });
82
+ response.end(buffer);
83
+ }
84
+
85
  function writeSse(response, event, payload) {
86
  if (event) response.write(`event: ${event}\n`);
87
  response.write(`data: ${JSON.stringify(payload)}\n\n`);
 
126
  id: 'ig_fixture',
127
  type: 'image_generation_call',
128
  status: 'completed',
129
+ result: FIXTURE_IMAGE_PATH
130
  }
131
  });
132
  writeSse(response, 'response.completed', {
133
  type: 'response.completed',
134
  response: {
135
+ output: [
136
+ {
137
+ id: 'ig_fixture',
138
+ type: 'image_generation_call',
139
+ status: 'completed',
140
+ result: FIXTURE_IMAGE_PATH
141
+ }
142
+ ]
143
  }
144
  });
145
  response.end('data: [DONE]\n\n');
 
167
  id: 'resp_fixture',
168
  object: 'response',
169
  model: typeof body.model === 'string' ? body.model : 'gpt-5.4',
170
+ output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_PATH }],
171
  usage: {
172
  input_tokens: 1,
173
  output_tokens: 1,
 
196
  sendJson(response, 200, modelsResponse());
197
  return;
198
  }
199
+ if (request.method === 'GET' && url.pathname === FIXTURE_IMAGE_PATH) {
200
+ sendFixtureImage(response);
201
+ return;
202
+ }
203
  if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
204
  const body = await readJsonBody(request);
205
  if (body.stream === true) {
scripts/local-image-upstream-fixture.test.mjs CHANGED
@@ -1,7 +1,12 @@
1
  import assert from 'node:assert/strict';
2
  import { describe, it } from 'node:test';
3
 
4
- import { FIXTURE_IMAGE_BASE64, MAX_JSON_BODY_BYTES, createFixtureServer } from './local-image-upstream-fixture.mjs';
 
 
 
 
 
5
 
6
  describe('local image upstream fixture', () => {
7
  it('serves Images API JSON responses', async () => {
@@ -71,7 +76,11 @@ describe('local image upstream fixture', () => {
71
  const body = await response.json();
72
  assert.equal(body.id, 'resp_fixture');
73
  assert.equal(body.output[0].type, 'image_generation_call');
74
- assert.equal(body.output[0].result, FIXTURE_IMAGE_BASE64);
 
 
 
 
75
  } finally {
76
  await fixture.close();
77
  }
@@ -105,8 +114,8 @@ describe('local image upstream fixture', () => {
105
  );
106
  assert.equal(events[0].data.partial_image_b64, FIXTURE_IMAGE_BASE64);
107
  assert.equal(events[1].data.item.type, 'image_generation_call');
108
- assert.equal(events[1].data.item.result, FIXTURE_IMAGE_BASE64);
109
- assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_BASE64);
110
  assert.equal(events[3].done, true);
111
  } finally {
112
  await fixture.close();
 
1
  import assert from 'node:assert/strict';
2
  import { describe, it } from 'node:test';
3
 
4
+ import {
5
+ FIXTURE_IMAGE_BASE64,
6
+ FIXTURE_IMAGE_PATH,
7
+ MAX_JSON_BODY_BYTES,
8
+ createFixtureServer
9
+ } from './local-image-upstream-fixture.mjs';
10
 
11
  describe('local image upstream fixture', () => {
12
  it('serves Images API JSON responses', async () => {
 
76
  const body = await response.json();
77
  assert.equal(body.id, 'resp_fixture');
78
  assert.equal(body.output[0].type, 'image_generation_call');
79
+ assert.equal(body.output[0].result, FIXTURE_IMAGE_PATH);
80
+ const imageResponse = await fetch(`${fixture.baseUrl}${FIXTURE_IMAGE_PATH}`);
81
+ assert.equal(imageResponse.status, 200);
82
+ assert.match(imageResponse.headers.get('content-type') || '', /^image\/png\b/);
83
+ assert.equal(Buffer.from(await imageResponse.arrayBuffer()).toString('base64'), FIXTURE_IMAGE_BASE64);
84
  } finally {
85
  await fixture.close();
86
  }
 
114
  );
115
  assert.equal(events[0].data.partial_image_b64, FIXTURE_IMAGE_BASE64);
116
  assert.equal(events[1].data.item.type, 'image_generation_call');
117
+ assert.equal(events[1].data.item.result, FIXTURE_IMAGE_PATH);
118
+ assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_PATH);
119
  assert.equal(events[3].done, true);
120
  } finally {
121
  await fixture.close();
scripts/smoke-image-upstream-local-final-gate.mjs CHANGED
@@ -1,16 +1,17 @@
1
  #!/usr/bin/env node
2
-
3
- import { spawn } from 'node:child_process';
4
- import { fileURLToPath } from 'node:url';
5
-
6
  import { commandFailureMessage, parseJsonPayload, printJson } from './command-center-utils.mjs';
7
  import { createFixtureServer } from './local-image-upstream-fixture.mjs';
 
 
8
 
9
  const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
10
  const REAL_SMOKE_SCRIPT = fileURLToPath(new URL('./smoke-image-upstream-real.mjs', import.meta.url));
11
  const DEFAULT_TIMEOUT_MS = 30_000;
12
  const LOCAL_FINAL_GATE_CASE_COUNT = 5;
13
  const LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS = 15_000;
 
 
 
14
 
15
  function parseArgs(argv) {
16
  const parsed = { help: false, timeoutMs: DEFAULT_TIMEOUT_MS };
@@ -66,17 +67,35 @@ async function main() {
66
  }
67
 
68
  function runLocalFinalGate(baseUrl, timeoutMs) {
 
69
  return runCommandAsync(
70
  process.execPath,
71
- ['--import', 'tsx', REAL_SMOKE_SCRIPT, '--allow-billable', '--require-independent-targets', '--timeout-ms', String(timeoutMs)],
 
 
 
 
 
 
 
 
72
  {
73
  cwd: REPO_ROOT,
74
  env: buildLocalFinalGateEnv(baseUrl, timeoutMs),
75
- timeoutMs: timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS
76
  }
77
  );
78
  }
79
 
 
 
 
 
 
 
 
 
 
80
  function runCommandAsync(command, args, options = {}) {
81
  const startedAt = Date.now();
82
  return new Promise((resolve) => {
@@ -197,7 +216,8 @@ function closeServer(server) {
197
  }
198
 
199
  function assertFinalGateReport(report) {
200
- if (report?.final_gate_satisfied !== true) throw new Error('local final gate did not satisfy final_gate_satisfied=true');
 
201
  if (!Array.isArray(report.results) || report.results.length !== 5) {
202
  throw new Error('local final gate did not run all five independent upstream cases');
203
  }
@@ -227,6 +247,11 @@ function readArgValue(argv, index, flag) {
227
  function readTimeoutMs(value, source) {
228
  const parsed = Number(value);
229
  if (!Number.isInteger(parsed) || parsed < 1000) throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`);
 
 
 
 
 
230
  return parsed;
231
  }
232
 
 
1
  #!/usr/bin/env node
 
 
 
 
2
  import { commandFailureMessage, parseJsonPayload, printJson } from './command-center-utils.mjs';
3
  import { createFixtureServer } from './local-image-upstream-fixture.mjs';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
 
7
  const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
8
  const REAL_SMOKE_SCRIPT = fileURLToPath(new URL('./smoke-image-upstream-real.mjs', import.meta.url));
9
  const DEFAULT_TIMEOUT_MS = 30_000;
10
  const LOCAL_FINAL_GATE_CASE_COUNT = 5;
11
  const LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS = 15_000;
12
+ const MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS = Math.floor(
13
+ (Number.MAX_SAFE_INTEGER - LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS) / LOCAL_FINAL_GATE_CASE_COUNT
14
+ );
15
 
16
  function parseArgs(argv) {
17
  const parsed = { help: false, timeoutMs: DEFAULT_TIMEOUT_MS };
 
67
  }
68
 
69
  function runLocalFinalGate(baseUrl, timeoutMs) {
70
+ const parentTimeoutMs = readLocalFinalGateParentTimeoutMs(timeoutMs);
71
  return runCommandAsync(
72
  process.execPath,
73
+ [
74
+ '--import',
75
+ 'tsx',
76
+ REAL_SMOKE_SCRIPT,
77
+ '--allow-billable',
78
+ '--require-independent-targets',
79
+ '--timeout-ms',
80
+ String(timeoutMs)
81
+ ],
82
  {
83
  cwd: REPO_ROOT,
84
  env: buildLocalFinalGateEnv(baseUrl, timeoutMs),
85
+ timeoutMs: parentTimeoutMs
86
  }
87
  );
88
  }
89
 
90
+ function readLocalFinalGateParentTimeoutMs(timeoutMs) {
91
+ if (timeoutMs > MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS) {
92
+ throw new Error(
93
+ `--timeout-ms 过大,local final gate 父进程超时会超过安全整数上限;最大允许 ${MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS}。`
94
+ );
95
+ }
96
+ return timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS;
97
+ }
98
+
99
  function runCommandAsync(command, args, options = {}) {
100
  const startedAt = Date.now();
101
  return new Promise((resolve) => {
 
216
  }
217
 
218
  function assertFinalGateReport(report) {
219
+ if (report?.final_gate_satisfied !== true)
220
+ throw new Error('local final gate did not satisfy final_gate_satisfied=true');
221
  if (!Array.isArray(report.results) || report.results.length !== 5) {
222
  throw new Error('local final gate did not run all five independent upstream cases');
223
  }
 
247
  function readTimeoutMs(value, source) {
248
  const parsed = Number(value);
249
  if (!Number.isInteger(parsed) || parsed < 1000) throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`);
250
+ if (parsed > MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS) {
251
+ throw new Error(
252
+ `${source} 过大,local final gate 父进程超时会超过安全整数上限;最大允许 ${MAX_LOCAL_FINAL_GATE_CASE_TIMEOUT_MS}。`
253
+ );
254
+ }
255
  return parsed;
256
  }
257
 
scripts/smoke-image-upstream-local-final-gate.test.mjs CHANGED
@@ -1,7 +1,7 @@
1
  import assert from 'node:assert/strict';
2
  import { spawnSync } from 'node:child_process';
3
- import { fileURLToPath } from 'node:url';
4
  import { describe, it } from 'node:test';
 
5
 
6
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
7
  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', () => {
32
  'gpt2image-responses-sse'
33
  ]
34
  );
35
- assert.equal(report.results.every((item) => item.status === 200), true);
36
- assert.equal(report.results.every((item) => item.first_b64_length === 92), true);
 
 
 
 
 
 
37
  });
38
 
39
  it('prints help without starting the fixture', () => {
@@ -46,4 +52,17 @@ describe('local image upstream final gate smoke launcher', () => {
46
  assert.match(result.stdout, /smoke:image-upstream-local/);
47
  assert.match(result.stdout, /local fixture gate/);
48
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  });
 
1
  import assert from 'node:assert/strict';
2
  import { spawnSync } from 'node:child_process';
 
3
  import { describe, it } from 'node:test';
4
+ import { fileURLToPath } from 'node:url';
5
 
6
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
7
  const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url));
 
32
  'gpt2image-responses-sse'
33
  ]
34
  );
35
+ assert.equal(
36
+ report.results.every((item) => item.status === 200),
37
+ true
38
+ );
39
+ assert.equal(
40
+ report.results.every((item) => item.first_b64_length === 92),
41
+ true
42
+ );
43
  });
44
 
45
  it('prints help without starting the fixture', () => {
 
52
  assert.match(result.stdout, /smoke:image-upstream-local/);
53
  assert.match(result.stdout, /local fixture gate/);
54
  });
55
+
56
+ it('rejects unsafe parent timeout values before starting the fixture', () => {
57
+ const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', String(Number.MAX_SAFE_INTEGER)], {
58
+ cwd: repoRoot,
59
+ encoding: 'utf8'
60
+ });
61
+
62
+ assert.equal(result.status, 1);
63
+ assert.equal(result.stderr.trim(), '');
64
+ const body = JSON.parse(result.stdout);
65
+ assert.equal(body.ok, false);
66
+ assert.match(body.error, /父进程超时会超过安全整数上限/);
67
+ });
68
  });
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -1,34 +1,45 @@
1
  ---
2
  name: gpt-image-playground-agent
3
- description: 当用户需要通过 API 调用已部署的 GPT Image Playground 批量请求图片生成时使用;支持文字生成图片文字加图片生成图片,并返回可下载图片产物metadata、base64job 结果
4
  ---
5
 
6
  # GPT Image Playground Agent
7
 
8
- 通过用户已部署的 GPT Image Playground `/api/agent/*` 接口生成编辑图片。不要假设服务一定在本机;不要模拟网页表单;直接使用 Agent API 契约、幂等键和产物 URL。
9
 
10
- ## 路由硬规则
 
 
 
 
 
 
 
 
 
 
11
 
12
  - 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
13
- - `edit` 且 `max(width,height)>2048` 时,必须使用页面端 `POST /api/images` form-data SSE 路径,不要走非流式 `/api/agent/images/edit`
14
- - 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE,并记录切换原因、失败清单和续跑锚点。
15
  - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
16
  - 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。
17
  - 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
18
 
19
  ## 执行流程
20
 
21
- 1. 先定位服基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`
22
- 2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权
23
- 3. 读取 capabilities 中的证方式模型模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement状态后端和端点路径;不硬编码假设部署方式
24
- 4. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作新的 key
25
- 5. 图使用 `POST /api/agent/images/generate`,请求体为 JSON该 Agent 端点对外始返回最终 `AgentImageResponse` JSON capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启内部上游 SSE 消费
26
- 6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点
27
- 7. 默认使用 `response_mode: "path"`,只在用户明确需要片内联数据时使用 `base64` 或 `both`。
28
- 8. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、��图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换
29
- 9. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 单次文生图默认优先走页面端 `/api/images` SSE
30
- 10. 处理失败时读取结构化 `error.code``error.retryable`、`error.diagnostics` `Retry-After`。仅当 `retryable=true` 时等待后重试
31
- 11. 返回结果优先给出 `content_url`、`metadata_url`、`absolute_content_url``absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂缓存
 
32
 
33
  ## 鉴权
34
 
@@ -45,6 +56,8 @@ Authorization: Bearer <token>
45
  ## 调用约束
46
 
47
  - 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。
 
 
48
  - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
49
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
50
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
@@ -69,11 +82,15 @@ Authorization: Bearer <token>
69
 
70
  ## 可用脚本
71
 
72
- - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用默认 dry-run,消耗额度必须添加 `--allow-billable` 才会真实生图
73
- - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
74
- - `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`
 
 
 
75
 
76
  生成和编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。
 
77
 
78
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
79
 
@@ -82,53 +99,62 @@ Authorization: Bearer <token>
82
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
83
  - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
84
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
85
- - `npm run agent:doctor`:执行只读 Agent API 契约检查,不触发真实生图
86
 
87
  生成脚本常用参数:
88
 
89
- ```bash
90
- node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
91
- --size 2048x2048 \
92
- --quality high \
93
- --response-mode path \
94
- --idempotency-key stable-operation-key \
95
- "a product photo of a ceramic mug"
96
  ```
97
 
98
  启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
99
 
100
- ```bash
101
- node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
102
- --allow-billable \
103
- --image-backend images-api \
104
- --streaming-strategy newapi-keepalive-sse \
105
- --partial-images 2 \
106
- --size 4096x4096 \
107
- --quality high \
108
- "a product photo of a ceramic mug"
109
  ```
110
 
111
  真实生图必须显式开启:
112
 
113
- ```bash
114
- node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
115
- --allow-billable \
116
- --timeout-ms 420000 \
117
- --size 2048x2048 \
118
- "a product photo of a ceramic mug"
119
  ```
120
 
121
- 生成脚本会对 `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`;默认不发送这些字段,保持服务端默认非流式基线
122
 
123
- 编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` `--allow-billable`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  直连上游诊断:
126
 
127
- ```bash
128
- OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs \
129
- --base-url https://api.openai.com/v1
130
  ```
131
 
 
 
132
  诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。
133
 
134
  上游探针脚本支持 `--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。
 
1
  ---
2
  name: gpt-image-playground-agent
3
+ description: 当用户需要通过已部署的 GPT Image Playground 生成、编辑、批量生成或诊断图片接口时使用;必须优先运行本 Skill 内置 scripts/generate-image.mjsedit-image.mjsbatch-images.mjsprobe-upstream-image.mjs,而不是临时编写 API 调用脚本
4
  ---
5
 
6
  # GPT Image Playground Agent
7
 
8
+ 通过用户已部署的 GPT Image Playground 生成编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本 Skill 内置脚本,让脚本处理 Agent API 契约、capabilities、幂等键、路由选择和产物 URL。
9
 
10
+ ## 脚本优先规则
11
+
12
+ - 生成单张或少量图片:优先运行 `scripts/generate-image.mjs`。
13
+ - 编辑图片:优先运行 `scripts/edit-image.mjs`。
14
+ - 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。
15
+ - 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。
16
+ - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
17
+ - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
18
+ - 先用 dry-run 或 `--contract-check` 检查请求、路由和鉴权;只有用户明确允许真实计费时才加 `--allow-billable`。
19
+
20
+ ## 路由规则
21
 
22
  - 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
23
+ - `edit` 且 `max(width,height)>2048` 时,默认优先使用页面端 `POST /api/images` form-data SSE 路径;如果页面流式不可用或失败,先诊断结构化错误,再显式回退到 Agent edit。
24
+ - 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE;需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
25
  - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
26
  - 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。
27
  - 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
28
 
29
  ## 执行流程
30
 
31
+ 1. 先按任类型选择内置脚本,不要从零写 API 调用代码
32
+ 2. 定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`
33
+ 3. 让脚本请求 `GET /api/agent/capabilities`。如果默地址不可达404不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名是否需鉴权
34
+ 4. 读取 capabilities 中的认证方式、模型、模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端点路径;不要硬编码假设部署方式
35
+ 5. 为每个业务操作成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非态重试复用原 key同一 key 已进入 `failed` 终态后不再于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key
36
+ 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 消费
37
+ 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`。
38
+ 8. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` `both`。
39
+ 9. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换
40
+ 10. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE
41
+ 11. 处理失败读取结构化 `error.code`、`error.retryable`、`error.diagnostics``Retry-After`。仅当 `retryable=true` 时待后重试
42
+ 12. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。
43
 
44
  ## 鉴权
45
 
 
56
  ## 调用约束
57
 
58
  - 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。
59
+ - Skill 必须保持自包含和可迁移:脚本、示例和说明不得写入本机绝对路径或仓库绝对路径;运行脚本时以当前已安装 Skill 目录为根解析 `scripts/`,不要依赖某台机器上的 checkout 位置。
60
+ - Skill 必须兼容 Windows、Linux 和 macOS:脚本只用 Node.js 20+ 与跨平台 `node:` 标准库;文档示例用 `node "<skill-root>/scripts/..."`,不依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。
61
  - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
62
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
63
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
 
82
 
83
  ## 可用脚本
84
 
85
+ 以下脚本都位于当前 Skill 目录的 `scripts/` 。不要硬编码本机安装路径由运行环境按当前 `SKILL.md` 所在目录解析脚本路径
86
+
87
+ - `scripts/generate-image.mjs`:JSON 文生调用。默认 dry-run不消耗额度;必须添加 `--allow-billable` 才会真实生图
88
+ - `scripts/edit-image.mjs`:multipart 编���调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
89
+ - `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` 时并发执行并按输入顺序输出结果。
90
+ - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
91
 
92
  生成和编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。
93
+ 所有脚本在 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`。
94
 
95
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
96
 
 
99
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
100
  - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
101
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
102
+ - `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;真实 1K/2K smoke 必须显式加 `-- --allow-billable`
103
 
104
  生成脚本常用参数:
105
 
106
+ ```text
107
+ node "<skill-root>/scripts/generate-image.mjs" --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "a product photo of a ceramic mug"
 
 
 
 
 
108
  ```
109
 
110
  启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
111
 
112
+ ```text
113
+ node "<skill-root>/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"
 
 
 
 
 
 
 
114
  ```
115
 
116
  真实生图必须显式开启:
117
 
118
+ ```text
119
+ node "<skill-root>/scripts/generate-image.mjs" --allow-billable --timeout-ms 420000 --size 2048x2048 "a product photo of a ceramic mug"
 
 
 
 
120
  ```
121
 
122
+ 生成脚本会对 `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 声明的默认
123
 
124
+ 批量脚本 JSONL 每行是一个 generate 或 edit 任务示例:
125
+
126
+ ```jsonl
127
+ {"id":"hero-01","mode":"generate","prompt":"a product photo of a ceramic mug","size":"1024x1024","response_mode":"path"}
128
+ {"id":"edit-01","mode":"edit","prompt":"replace the background","image_path":"./source.png","size":"1024x1024","response_mode":"path"}
129
+ ```
130
+
131
+ 默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务:
132
+
133
+ ```text
134
+ node "<skill-root>/scripts/batch-images.mjs" --input tasks.jsonl --ordered-prefix product-set
135
+ ```
136
+
137
+ 真实批量执行必须显式允许计费。需要并发时添加 `--concurrency N`;需要严格连续失败熔断时保持 `--concurrency 1`:
138
+
139
+ ```text
140
+ node "<skill-root>/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
141
+ node "<skill-root>/scripts/batch-images.mjs" --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3
142
+ ```
143
+
144
+ `--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 追加保存,便于区分上游未给终图和解析/断流问题。
145
+
146
+ 批量 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
147
+
148
+ 编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--stream-mode`、`--streaming-strategy`、`--partial-images`、`--timeout-ms`、`--idempotency-key`、`--page-sse`、`--agent`、`--dry-run` 和 `--allow-billable`。
149
 
150
  直连上游诊断:
151
 
152
+ ```text
153
+ node "<skill-root>/scripts/probe-upstream-image.mjs" --base-url https://api.openai.com/v1
 
154
  ```
155
 
156
+ 调用前按当前系统和 shell 设置 `OPENAI_API_KEY` 或 `GPT_IMAGE_UPSTREAM_API_KEY`,不要把 key 写进命令历史或文档。
157
+
158
  诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。
159
 
160
  上游探针脚本支持 `--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。
skills/gpt-image-playground-agent/agents/openai.yaml CHANGED
@@ -1,4 +1,4 @@
1
  interface:
2
  display_name: "GPT Image Agent API"
3
- short_description: "定位部署地址后调用 Agent API 生成和编辑图片"
4
- default_prompt: "使用 $gpt-image-playground-agent 先确认 GPT Image Playground 服务地址再通过 Agent API 生成或编辑图片并返回产物 URL 和关键元数据。"
 
1
  interface:
2
  display_name: "GPT Image Agent API"
3
+ short_description: "使用内置脚本调用图片 Agent API"
4
+ default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本不要临时编写 API 调用脚本。"
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -12,17 +12,23 @@
12
 
13
  ## 辅助脚本
14
 
15
- - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用
16
- - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
17
- - `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:上游图片接口连通性探针
18
 
19
- 生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`
 
 
 
 
 
20
  上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
21
  脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
22
  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`。
23
  当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。
24
  同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
25
  页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
 
26
 
27
  生成脚本参数:
28
 
@@ -32,23 +38,65 @@ Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API
32
  - `--n`:默认 `1`。
33
  - `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。
34
  - `--response-mode`:默认 `path`。
 
 
 
 
35
  - `--timeout-ms`:默认 `420000`。
36
  - `--prompt-file`:从文本文件读取 prompt。
37
  - `--idempotency-key`:指定稳定幂等键。
 
 
 
38
  - `--dry-run`:只输出将要发送的 JSON。
39
  - `--allow-billable`:允许真实调用生图端点。
40
 
 
 
 
41
  编辑脚本参数:
42
 
43
  - `--model`
44
  - `--size`
45
  - `--quality`
46
  - `--response-mode`
 
 
 
47
  - `--timeout-ms`
48
  - `--idempotency-key`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  - `--dry-run`
50
  - `--allow-billable`
51
 
 
 
 
 
 
 
 
 
 
 
52
  上游探针脚本参数:
53
 
54
  - `--base-url`
@@ -60,7 +108,7 @@ Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API
60
  - `--timeout-ms`
61
  - `--allow-billable`
62
 
63
- 上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。输出不会包含 key,也不会输出完整 base64。
64
 
65
  ## 能力查询
66
 
@@ -83,33 +131,41 @@ GET /api/agent/capabilities
83
  - `model_limits.gpt-image-2.large_image_risk`:大尺寸请求的长耗时风险说明,当前适用于 `max_edge>2048`。
84
  - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
85
  - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
86
- - `agent_streaming.upstream_sse`:Agent generate 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。
 
 
 
 
87
  - `agent_streaming.upstream_sse.image_backends`:支持 `images-api`、`responses-image-generation`。
88
  - `agent_streaming.upstream_sse.enabled_image_backends`:当前运行时可直接使用的 Agent 上游 SSE 后端;`responses-image-generation` 只有在所需环境变量齐备时才出现。
89
  - `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
90
- - `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前为 `openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`
 
91
  - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
92
  - `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
93
  - `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
94
- - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时必须使用页面端 `/api/images` SSE。
95
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
96
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
97
  - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。
98
  - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。
99
  - `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
 
100
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
101
- - `defaults.streaming_strategy`:Agent generate 默认 `off`,不默认向上游发送 `stream=true`
102
- - `defaults.partial_images`:Agent generate 默认 `2`,仅在显式启用上游 SSE 时使用
 
103
  - `supported.image_backends`:机器可读的图片后端枚举。
104
  - `supported.enabled_image_backends`:当前运行时可直接使用的图片后端。
105
  - `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。
106
  - `supported.streaming_strategies`:机器可读的流式兼容策略枚举。
 
107
  - `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
108
  - `agent_jobs.mode`:当前为 `job_polling`。
109
  - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
110
  - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
111
 
112
- 当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产不应走 Agent 非流式 edit,优先按 `routing_rules` 使用页面端 `/api/images` SSE。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。
113
 
114
  ## Job Polling
115
 
@@ -180,12 +236,20 @@ Content-Type: application/json
180
  "moderation": "auto",
181
  "response_mode": "path",
182
  "image_backend": "images-api",
183
- "streaming_strategy": "off",
 
184
  "partial_images": 2
185
  }
186
  ```
187
 
188
- 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`。
 
 
 
 
 
 
 
189
 
190
  响应:
191
 
@@ -229,10 +293,15 @@ Content-Type: multipart/form-data
229
  - `size`:`auto` 或支持的尺寸。
230
  - `quality`:`low`、`medium`、`high` 或 `auto`。
231
  - `response_mode`:`path`、`base64` 或 `both`。
232
- - `image_0..image_9`:源图片
 
 
 
233
  - `mask`:可选 PNG 遮罩。
234
 
235
- 当 `size` 的最大边大于 `2048` 时,Agent edit 端点会返回 `validation_error`,不会联系上游;该场景必须按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径
 
 
236
 
237
  ## 产物元数据
238
 
 
12
 
13
  ## 辅助脚本
14
 
15
+ 脚本位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径或仓库 checkout 路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径
16
+ 脚本必须通过 `node "<skill-root>/scripts/..."` 调用,以兼容 Windows、Linux 和 macOS;示例不要依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行
17
+ 生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用
18
 
19
+ - `scripts/generate-image.mjs`:JSON 文生图调用。
20
+ - `scripts/edit-image.mjs`:multipart 编辑调用。
21
+ - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
22
+ - `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
23
+
24
+ 生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会按 capabilities 路由规则调用 `/api/agent/images/generate`、`/api/agent/images/edit`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
25
  上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
26
  脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
27
  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`。
28
  当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。
29
  同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
30
  页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
31
+ 脚本会在 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
 
33
  生成脚本参数:
34
 
 
38
  - `--n`:默认 `1`。
39
  - `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。
40
  - `--response-mode`:默认 `path`。
41
+ - `--image-backend`:可选,显式选择 `images-api`、`images`、`responses` 或 `responses-image-generation`。
42
+ - `--stream-mode`:可选,显式选择 `auto`、`stream` 或 `non_stream`。
43
+ - `--streaming-strategy`:可选,显式选择 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。
44
+ - `--partial-images`:可选,显式设置上游 SSE partial image 数量,范围 `1` 到 `3`。
45
  - `--timeout-ms`:默认 `420000`。
46
  - `--prompt-file`:从文本文件读取 prompt。
47
  - `--idempotency-key`:指定稳定幂等键。
48
+ - `--page-sse`:强制使用页面端 `/api/images` form-data SSE。
49
+ - `--agent`:强制使用 `/api/agent/images/generate` 非流式 JSON。
50
+ - `--job`:强制使用 Agent job polling。
51
  - `--dry-run`:只输出将要发送的 JSON。
52
  - `--allow-billable`:允许真实调用生图端点。
53
 
54
+ `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE;如果显式传 `--streaming-strategy off`,即使是大图也保持 `/api/agent/images/generate` 非流式 JSON 路径,用于诊断对照。
55
+ 当服务端默认 `IMAGE_STREAMING_STRATEGY=off` 且请求未覆盖 `streaming_strategy` 时,运行时默认策略为 `off`;WebUI 会把 server-default 流式请求切到 `non_stream`,并发批量开关不可用。脚本显式传 `--streaming-strategy off` 时同样保持非流��诊断路径。
56
+
57
  编辑脚本参数:
58
 
59
  - `--model`
60
  - `--size`
61
  - `--quality`
62
  - `--response-mode`
63
+ - `--stream-mode`
64
+ - `--streaming-strategy`
65
+ - `--partial-images`
66
  - `--timeout-ms`
67
  - `--idempotency-key`
68
+ - `--page-sse`
69
+ - `--agent`
70
+ - `--dry-run`
71
+ - `--allow-billable`
72
+
73
+ 普通 edit 默认调用 `/api/agent/images/edit`,客户端拿到最终 JSON;`--stream-mode`、`--streaming-strategy` 和 `--partial-images` 只控制 Agent 内部上游流式兼容字段,不代表客户端响应会变成页面 SSE。
74
+ `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。
75
+
76
+ 批量脚本参数:
77
+
78
+ - `--input`:JSONL 任务文件路径,也可作为唯一位置参数。
79
+ - `--manifest`:append-only JSONL manifest 路径,默认 `<input>.manifest.jsonl`。
80
+ - `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
81
+ - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
82
+ - `--dimension-check`:读取响应 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`。
83
+ - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
84
+ - `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会并发执行任务并按输入顺序输出结果;适合已确认渠道容量的批量生产。
85
+ - `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。
86
+ - `--timeout-ms`
87
  - `--dry-run`
88
  - `--allow-billable`
89
 
90
+ 批量 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
91
+
92
+ 并发批量示例:
93
+
94
+ ```text
95
+ node "<skill-root>/scripts/batch-images.mjs" --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3
96
+ ```
97
+
98
+ 连续失败熔断需要严格顺序语义,不能与并发窗口大于 `1` 的批量执行同时使用。
99
+
100
  上游探针脚本参数:
101
 
102
  - `--base-url`
 
108
  - `--timeout-ms`
109
  - `--allow-billable`
110
 
111
+ 上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。base URL 必须是无凭据、无查询参数和无片段的 `http`/`https` 绝对 URL。输出不会包含 key,也不会输出完整 base64。
112
 
113
  ## 能力查询
114
 
 
131
  - `model_limits.gpt-image-2.large_image_risk`:大尺寸请求的长耗时风险说明,当前适用于 `max_edge>2048`。
132
  - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
133
  - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
134
+ - `agent_streaming.upstream_sse`:Agent generate/edit 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。
135
+ - `agent_streaming.upstream_sse.supported`:布尔值;当服务端支持 Agent 内部上游 SSE 消费时为 `true`,否则为 `false`。客户端只在为 `true` 时发送上游流式控制字段。
136
+ - `agent_streaming.upstream_sse.request_fields`:兼容旧客户端的字段合集,当前为 `image_backend`��`stream_mode`、`streaming_strategy`、`partial_images`。
137
+ - `agent_streaming.upstream_sse.request_fields_by_mode.generate`:generate 可发送的上游 SSE 控制字段,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。
138
+ - `agent_streaming.upstream_sse.request_fields_by_mode.edit`:edit 可发送的上游 SSE 控制字段,当前为 `stream_mode`、`streaming_strategy`、`partial_images`。
139
  - `agent_streaming.upstream_sse.image_backends`:支持 `images-api`、`responses-image-generation`。
140
  - `agent_streaming.upstream_sse.enabled_image_backends`:当前运行时可直接使用的 Agent 上游 SSE 后端;`responses-image-generation` 只有在所需环境变量齐备时才出现。
141
  - `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
142
+ - `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。
143
+ - `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
144
  - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
145
  - `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
146
  - `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
147
+ - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退
148
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
149
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
150
  - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。
151
  - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。
152
  - `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
153
+ - 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
154
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
155
+ - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记
156
+ - `defaults.streaming_strategy`:Agent generate 默认 `auto`。
157
+ - `defaults.partial_images`:Agent generate 默认 `2`,在 `stream_mode` 不为 `non_stream` 时使用。
158
  - `supported.image_backends`:机器可读的图片后端枚举。
159
  - `supported.enabled_image_backends`:当前运行时可直接使用的图片后端。
160
  - `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。
161
  - `supported.streaming_strategies`:机器可读的流式兼容策略枚举。
162
+ - `supported.stream_modes`:机器可读的 `auto`、`stream`、`non_stream` 枚举。
163
  - `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
164
  - `agent_jobs.mode`:当前为 `job_polling`。
165
  - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
166
  - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
167
 
168
+ 当 `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 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。
169
 
170
  ## Job Polling
171
 
 
236
  "moderation": "auto",
237
  "response_mode": "path",
238
  "image_backend": "images-api",
239
+ "stream_mode": "auto",
240
+ "streaming_strategy": "auto",
241
  "partial_images": 2
242
  }
243
  ```
244
 
245
+ Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`。
246
+
247
+ - 页面 SSE 使用独立的 `POST /api/images` form-data 路径。
248
+ - 若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,generate 可通过 `request_fields_by_mode.generate` 声明的字段控制服务端内部上游 SSE 消费。`image_backend=responses-image-generation` 当前只支持 generate。
249
+ - Agent 生成端点最终响应仍是 `AgentImageResponse`。
250
+ - `stream_mode=stream` 强制流式并直接暴露失败。
251
+ - `stream_mode=non_stream` 直接非流式。
252
+ - `stream_mode=auto` 允许显式可观测回退。
253
 
254
  响应:
255
 
 
293
  - `size`:`auto` 或支持的尺寸。
294
  - `quality`:`low`、`medium`、`high` 或 `auto`。
295
  - `response_mode`:`path`、`base64` 或 `both`。
296
+ - `stream_mode`:可选,`auto`、`stream` 或 `non_stream`
297
+ - `streaming_strategy`:可选,`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。
298
+ - `partial_images`:可选,`1..3`。
299
+ - `image_0..image_9`:源图片。类似 `image_10`、`image_01` 或 `image_foo` 的图片字段会被显式拒绝。
300
  - `mask`:可选 PNG 遮罩。
301
 
302
+ Agent edit 不接受 `image_backend`/`imageBackend``output_format`/`outputFormat`/`format`、`output_compression`/`outputCompression`、`responses_model`/`responsesModel`、`background` `moderation`。编辑输出格式固定为 PNG;Responses image_generation 后端当前只支持 generate
303
+
304
+ 当 `size` 的最大边大于 `2048` 时,默认按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径;如果页面流式不可用或失败,可显式回退到 Agent edit 最终 JSON 路径进行诊断或执行。
305
 
306
  ## 产物元数据
307
 
skills/gpt-image-playground-agent/scripts/batch-images.mjs ADDED
@@ -0,0 +1,1226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
3
+ import {
4
+ errorMessage,
5
+ assertValidImageSizeForModel,
6
+ normalizeBaseUrl,
7
+ normalizeOutputFormat,
8
+ parseImageSizeValue,
9
+ readConfiguredPositiveInteger,
10
+ readMaxImageEdge,
11
+ readOptionValue,
12
+ resolveSameOriginUrl
13
+ } from './lib/script-utils.mjs';
14
+ import {
15
+ PAGE_SSE_ENDPOINT,
16
+ assertPageSseReady,
17
+ buildPageSseFailureOutput,
18
+ formatPageSseOutput,
19
+ normalizeImageBackendForPage,
20
+ postPageSse
21
+ } from './lib/page-sse-client.mjs';
22
+ import crypto from 'node:crypto';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
27
+ const MODELS = new Set(['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2']);
28
+ const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
29
+ const QUALITIES = new Set(['low', 'medium', 'high', 'auto']);
30
+ const BACKGROUNDS = new Set(['transparent', 'opaque', 'auto']);
31
+ const MODERATIONS = new Set(['low', 'auto']);
32
+ const RESPONSE_MODES = new Set(['path', 'base64', 'both']);
33
+ const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
34
+ const STREAMING_STRATEGIES = new Set([
35
+ 'off',
36
+ 'auto',
37
+ 'openai-sse',
38
+ 'newapi-keepalive-sse',
39
+ 'responses-sse',
40
+ 'force-sse'
41
+ ]);
42
+ const MAX_EDIT_IMAGES = 10;
43
+ const MIN_PARTIAL_IMAGES = 1;
44
+ const MAX_PARTIAL_IMAGES = 3;
45
+ const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
46
+ const DEFAULT_BATCH_MAX_ATTEMPTS = 1;
47
+ const DEFAULT_MAX_CONSECUTIVE_FAILURES = 0;
48
+ const DEFAULT_BATCH_CONCURRENCY = 1;
49
+ const GENERATE_ONLY_FIELDS = [
50
+ 'background'
51
+ ];
52
+ const PAGE_ADVANCED_FIELDS = [
53
+ 'output_format',
54
+ 'format',
55
+ 'output_compression',
56
+ 'moderation',
57
+ 'image_backend',
58
+ 'responsesModel',
59
+ 'gptModel',
60
+ 'gpt_model',
61
+ 'thinking',
62
+ 'promptOptimization',
63
+ 'prompt_optimization',
64
+ 'force_web',
65
+ 'forceWeb',
66
+ 'sse_log_path'
67
+ ];
68
+ const EDIT_ONLY_FIELDS = ['image_path', 'image_paths', 'mask_path'];
69
+ const BOOLEAN_ROUTING_FIELDS = ['page_sse', 'complex_ui', 'long_image', 'resume_or_recover'];
70
+ const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
71
+ const TASK_FIELDS = new Set([
72
+ 'id',
73
+ 'mode',
74
+ 'prompt',
75
+ 'idempotency_key',
76
+ 'model',
77
+ 'n',
78
+ 'size',
79
+ 'quality',
80
+ 'response_mode',
81
+ 'stream_mode',
82
+ 'streaming_strategy',
83
+ 'partial_images',
84
+ 'page_sse',
85
+ 'transport',
86
+ 'complex_ui',
87
+ 'long_image',
88
+ 'resume_or_recover',
89
+ ...GENERATE_ONLY_FIELDS,
90
+ ...PAGE_ADVANCED_FIELDS,
91
+ ...EDIT_ONLY_FIELDS
92
+ ]);
93
+
94
+ const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
95
+ const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
96
+
97
+ let options;
98
+ try {
99
+ options = parseArgs(process.argv.slice(2));
100
+ } catch (error) {
101
+ console.error(errorMessage(error));
102
+ printUsage();
103
+ process.exit(2);
104
+ }
105
+ if (options.help) {
106
+ printUsage();
107
+ process.exit(0);
108
+ }
109
+
110
+ let baseUrl;
111
+ let tasks;
112
+ let timeoutMs;
113
+ let capabilities;
114
+ let capabilitiesPromise;
115
+ try {
116
+ if (!options.input) throw new Error('--input 需要 JSONL 文件路径。');
117
+ baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
118
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
119
+ options.maxAttempts = readConfiguredPositiveInteger(
120
+ options.maxAttempts ?? DEFAULT_BATCH_MAX_ATTEMPTS,
121
+ '--max-attempts',
122
+ DEFAULT_BATCH_MAX_ATTEMPTS
123
+ );
124
+ options.maxConsecutiveFailures = readNonNegativeInteger(
125
+ options.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES,
126
+ '--max-consecutive-failures'
127
+ );
128
+ options.concurrency = readConfiguredPositiveInteger(
129
+ options.concurrency ?? DEFAULT_BATCH_CONCURRENCY,
130
+ '--concurrency',
131
+ DEFAULT_BATCH_CONCURRENCY
132
+ );
133
+ if (options.concurrency > 1 && options.maxConsecutiveFailures > 0) {
134
+ throw new Error('--concurrency 大于 1 时不能同时使用 --max-consecutive-failures;请使用 --concurrency 1 保持严格顺序熔断。');
135
+ }
136
+ tasks = readJsonlTasks(options.input);
137
+ } catch (error) {
138
+ console.error(errorMessage(error));
139
+ process.exit(2);
140
+ }
141
+
142
+ const manifestPath = options.manifest || `${options.input}.manifest.jsonl`;
143
+ let planned;
144
+ try {
145
+ planned = tasks.map((task, index) => normalizeTask(task, index, options));
146
+ } catch (error) {
147
+ console.error(errorMessage(error));
148
+ process.exit(2);
149
+ }
150
+
151
+ if (!options.allowBillable || options.dryRun) {
152
+ console.log(
153
+ JSON.stringify(
154
+ {
155
+ ok: true,
156
+ billable: false,
157
+ dry_run: true,
158
+ input: options.input,
159
+ manifest: manifestPath,
160
+ total: planned.length,
161
+ max_attempts: options.maxAttempts,
162
+ max_consecutive_failures: options.maxConsecutiveFailures,
163
+ concurrency: options.concurrency,
164
+ tasks: planned.map((task) => {
165
+ const routing = buildTaskRouting(task);
166
+ return {
167
+ index: task.index,
168
+ id: task.id,
169
+ mode: task.mode,
170
+ idempotency_key: task.idempotencyKey,
171
+ endpoint: routing.endpoint,
172
+ routing,
173
+ request: buildDryRunRequestPreview(task, routing)
174
+ };
175
+ }),
176
+ next_step: '重新执行并添加 --allow-billable 才会发起真实批量请求。'
177
+ },
178
+ null,
179
+ 2
180
+ )
181
+ );
182
+ process.exit(0);
183
+ }
184
+
185
+ try {
186
+ const completed = options.resume ? readCompletedManifestKeys(manifestPath) : new Set();
187
+ const { results, failedTasks } = await runPlannedTasks(planned, completed);
188
+ const failed = results.filter((result) => !result.ok).length;
189
+ console.log(
190
+ JSON.stringify(
191
+ {
192
+ ok: failed === 0,
193
+ total: results.length,
194
+ failed,
195
+ manifest: manifestPath,
196
+ max_attempts: options.maxAttempts,
197
+ max_consecutive_failures: options.maxConsecutiveFailures,
198
+ concurrency: options.concurrency,
199
+ failure_summary: buildFailureSummary(failedTasks),
200
+ resume_fix_list: buildResumeFixList(failedTasks),
201
+ results
202
+ },
203
+ null,
204
+ 2
205
+ )
206
+ );
207
+ process.exit(failed === 0 ? 0 : 1);
208
+ } catch (error) {
209
+ console.error(errorMessage(error));
210
+ process.exit(1);
211
+ }
212
+
213
+ function parseArgs(argv) {
214
+ const parsed = {
215
+ input: undefined,
216
+ manifest: undefined,
217
+ orderedPrefix: 'batch',
218
+ timeoutMs: undefined,
219
+ maxAttempts: undefined,
220
+ maxConsecutiveFailures: undefined,
221
+ concurrency: undefined,
222
+ allowBillable: false,
223
+ dryRun: false,
224
+ resume: false,
225
+ dimensionCheck: false,
226
+ help: false
227
+ };
228
+ for (let index = 0; index < argv.length; index += 1) {
229
+ const arg = argv[index];
230
+ if (arg === '--allow-billable') parsed.allowBillable = true;
231
+ else if (arg === '--dry-run') parsed.dryRun = true;
232
+ else if (arg === '--resume') parsed.resume = true;
233
+ else if (arg === '--dimension-check') parsed.dimensionCheck = true;
234
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
235
+ else if (arg === '--input') parsed.input = readOptionValue(argv, (index += 1), arg);
236
+ else if (arg === '--manifest') parsed.manifest = readOptionValue(argv, (index += 1), arg);
237
+ else if (arg === '--ordered-prefix') parsed.orderedPrefix = readOptionValue(argv, (index += 1), arg);
238
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
239
+ else if (arg === '--max-attempts') parsed.maxAttempts = readOptionValue(argv, (index += 1), arg);
240
+ else if (arg === '--max-consecutive-failures') {
241
+ parsed.maxConsecutiveFailures = readOptionValue(argv, (index += 1), arg);
242
+ }
243
+ else if (arg === '--concurrency') parsed.concurrency = readOptionValue(argv, (index += 1), arg);
244
+ else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
245
+ else if (!parsed.input) parsed.input = arg;
246
+ else throw new Error(`未知位置参数:${arg}`);
247
+ }
248
+ return parsed;
249
+ }
250
+
251
+ function readJsonlTasks(filePath) {
252
+ return fs
253
+ .readFileSync(filePath, 'utf8')
254
+ .split(/\r?\n/)
255
+ .map((line, index) => ({ line: line.trim(), index }))
256
+ .filter((item) => item.line && !item.line.startsWith('#'))
257
+ .map((item) => {
258
+ try {
259
+ return JSON.parse(item.line);
260
+ } catch (error) {
261
+ throw new Error(`${filePath}:${item.index + 1} 不是有效 JSON:${errorMessage(error)}`);
262
+ }
263
+ });
264
+ }
265
+
266
+ function normalizeTask(raw, index, parsedOptions) {
267
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
268
+ throw new Error(`第 ${index + 1} 行必须是 JSON 对象。`);
269
+ }
270
+ const mode = normalizeMode(raw.mode, index);
271
+ const id = normalizeTaskId(raw.id, mode, index);
272
+ if (typeof raw.prompt !== 'string' || !raw.prompt.trim()) {
273
+ throw new Error(`${id} 缺少 prompt。`);
274
+ }
275
+ validateTaskFields(raw, id, mode);
276
+ validateTaskSize(raw, id, mode, parsedOptions.dimensionCheck);
277
+ validateTaskRoutingFields(raw, id);
278
+ if (mode === 'edit') validateEditImages(raw, id);
279
+ return {
280
+ id,
281
+ index,
282
+ mode,
283
+ raw,
284
+ idempotencyKey: normalizeIdempotencyKey(raw.idempotency_key, parsedOptions.orderedPrefix, index, id)
285
+ };
286
+ }
287
+
288
+ function normalizeTaskId(value, mode, index) {
289
+ if (value === undefined || value === null || value === '') return `${mode}-${index + 1}`;
290
+ return String(value);
291
+ }
292
+
293
+ function normalizeMode(value, index) {
294
+ if (value === undefined || value === null || value === '' || value === 'generate') return 'generate';
295
+ if (value === 'edit') return 'edit';
296
+ throw new Error(`第 ${index + 1} 行 mode 必须是 generate 或 edit。`);
297
+ }
298
+
299
+ function normalizeIdempotencyKey(value, orderedPrefix, index, id) {
300
+ if (value === undefined || value === null || value === '') return buildOrderedKey(orderedPrefix, index, id);
301
+ if (typeof value !== 'string') throw new Error(`${id} idempotency_key 必须是字符串。`);
302
+ return value;
303
+ }
304
+
305
+ function validateTaskSize(raw, id, mode, dimensionCheck) {
306
+ if (raw.size !== undefined) {
307
+ assertValidImageSizeForModel(raw.size, raw.model || 'gpt-image-2', `${id}.size`);
308
+ }
309
+ if (!dimensionCheck) return;
310
+ const size = raw.size || (mode === 'generate' ? '1024x1024' : undefined);
311
+ if (!parseExpectedSize(size)) {
312
+ throw new Error(`${id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`);
313
+ }
314
+ }
315
+
316
+ function validateEditImages(raw, id) {
317
+ const imagePaths = readEditImagePaths(raw, id);
318
+ if (imagePaths.length === 0) throw new Error(`${id} edit 任务必须提供 image_path 或 image_paths。`);
319
+ if (imagePaths.length > MAX_EDIT_IMAGES) throw new Error(`${id} edit 任务最多支持 ${MAX_EDIT_IMAGES} 张源图。`);
320
+ if (hasOwn(raw, 'mask_path')) readNonEmptyString(raw.mask_path, `${id}.mask_path`);
321
+ }
322
+
323
+ function validateTaskFields(raw, id, mode) {
324
+ validateKnownTaskFields(raw, id);
325
+ validateModeSpecificFields(raw, id, mode);
326
+ validateRoutingControlFields(raw, id);
327
+ validateAmbiguousAliasFields(raw, id);
328
+ if (hasOwn(raw, 'model')) normalizeEnumValue(raw.model, MODELS, `${id}.model`);
329
+ if (raw.n !== undefined) readConfiguredPositiveInteger(raw.n, `${id}.n`, 1);
330
+ if (hasOwn(raw, 'quality')) normalizeEnumValue(raw.quality, QUALITIES, `${id}.quality`);
331
+ if (hasOwn(raw, 'response_mode')) normalizeEnumValue(raw.response_mode, RESPONSE_MODES, `${id}.response_mode`);
332
+ if (hasOwn(raw, 'image_backend')) normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, `${id}.image_backend`);
333
+ if (hasOwn(raw, 'background')) normalizeEnumValue(raw.background, BACKGROUNDS, `${id}.background`);
334
+ if (hasOwn(raw, 'moderation')) normalizeEnumValue(raw.moderation, MODERATIONS, `${id}.moderation`);
335
+ if (hasOwn(raw, 'thinking')) normalizeEnumValue(raw.thinking, THINKING_VALUES, `${id}.thinking`);
336
+ readPromptOptimization(raw, id);
337
+ if (hasOwn(raw, 'force_web')) readBooleanAlias(raw.force_web, `${id}.force_web`);
338
+ if (hasOwn(raw, 'forceWeb')) readBooleanAlias(raw.forceWeb, `${id}.forceWeb`);
339
+ if (hasOwn(raw, 'sse_log_path')) readNonEmptyString(raw.sse_log_path, `${id}.sse_log_path`);
340
+ validateResponsesModelField(raw, id);
341
+ if (hasOwn(raw, 'stream_mode')) normalizeEnumValue(raw.stream_mode, STREAM_MODES, `${id}.stream_mode`);
342
+ if (hasOwn(raw, 'streaming_strategy')) {
343
+ normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, `${id}.streaming_strategy`);
344
+ }
345
+ if (hasOwn(raw, 'partial_images')) readPartialImages(raw.partial_images, `${id}.partial_images`);
346
+ if (hasOwn(raw, 'output_format') || hasOwn(raw, 'format')) {
347
+ normalizeEnumValue(readOutputFormatField(raw, id), OUTPUT_FORMATS, `${id}.output_format`);
348
+ }
349
+ validateBackgroundForModel(raw, id);
350
+ validateOutputCompression(raw, id);
351
+ }
352
+
353
+ function validateAmbiguousAliasFields(raw, id) {
354
+ if (hasOwn(raw, 'output_format') && hasOwn(raw, 'format')) {
355
+ throw new Error(`${id}.output_format 与 format 不能同时设置。`);
356
+ }
357
+ if (hasOwn(raw, 'image_path') && hasOwn(raw, 'image_paths')) {
358
+ throw new Error(`${id}.image_path 与 image_paths 不能同时设置。`);
359
+ }
360
+ }
361
+
362
+ function validateKnownTaskFields(raw, id) {
363
+ for (const field of Object.keys(raw)) {
364
+ if (!TASK_FIELDS.has(field)) {
365
+ throw new Error(`${id}.${field} 不是支持的 batch JSONL 字段。`);
366
+ }
367
+ }
368
+ }
369
+
370
+ function validateModeSpecificFields(raw, id, mode) {
371
+ const fields = mode === 'edit' ? GENERATE_ONLY_FIELDS : EDIT_ONLY_FIELDS;
372
+ const expectedMode = mode === 'edit' ? 'generate' : 'edit';
373
+ for (const field of fields) {
374
+ if (hasOwn(raw, field)) {
375
+ throw new Error(`${id}.${modeSpecificFieldLabel(field)} 仅适用于 ${expectedMode} 任务。`);
376
+ }
377
+ }
378
+ }
379
+
380
+ function modeSpecificFieldLabel(field) {
381
+ return field === 'format' ? 'output_format' : field;
382
+ }
383
+
384
+ function validateRoutingControlFields(raw, id) {
385
+ for (const field of BOOLEAN_ROUTING_FIELDS) {
386
+ if (hasOwn(raw, field) && typeof raw[field] !== 'boolean') {
387
+ throw new Error(`${id}.${field} 必须是布尔值。`);
388
+ }
389
+ }
390
+ if (hasOwn(raw, 'transport') && raw.transport !== 'page_sse') {
391
+ throw new Error(`${id}.transport 必须是 page_sse。`);
392
+ }
393
+ }
394
+
395
+ function validateBackgroundForModel(raw, id) {
396
+ if (!hasOwn(raw, 'background')) return;
397
+ const model = hasOwn(raw, 'model') ? String(raw.model) : 'gpt-image-2';
398
+ if (model === 'gpt-image-2' && String(raw.background) === 'transparent') {
399
+ throw new Error(`${id}.background 对 gpt-image-2 无效:gpt-image-2 不支持 transparent 背景。`);
400
+ }
401
+ }
402
+
403
+ function validateOutputCompression(raw, id) {
404
+ readOutputCompression(raw, id);
405
+ }
406
+
407
+ function validateResponsesModelField(raw, id) {
408
+ const responsesModel = readResponsesModel(raw, id);
409
+ if (!responsesModel) return;
410
+ if (!hasOwn(raw, 'image_backend')) {
411
+ throw new Error(`${id}.responsesModel 必须同时设置 image_backend=responses-image-generation。`);
412
+ }
413
+ const imageBackend = normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, `${id}.image_backend`);
414
+ if (imageBackend !== 'responses-image-generation' && imageBackend !== 'responses') {
415
+ throw new Error(`${id}.responsesModel 仅适用于 image_backend=responses-image-generation。`);
416
+ }
417
+ }
418
+
419
+ function readResponsesModel(raw, id) {
420
+ const fields = ['responsesModel', 'gptModel', 'gpt_model'];
421
+ const present = fields.filter((field) => hasOwn(raw, field));
422
+ if (present.length === 0) return undefined;
423
+ if (present.length > 1) throw new Error(`${id}.responsesModel、gptModel 与 gpt_model 不能同时设置。`);
424
+ return readNonEmptyString(raw[present[0]], `${id}.${present[0]}`);
425
+ }
426
+
427
+ function readPromptOptimization(raw, id) {
428
+ const fields = ['promptOptimization', 'prompt_optimization'];
429
+ const present = fields.filter((field) => hasOwn(raw, field));
430
+ if (present.length === 0) return undefined;
431
+ if (present.length > 1) throw new Error(`${id}.promptOptimization 与 prompt_optimization 不能同时设置。`);
432
+ return readBooleanAlias(raw[present[0]], `${id}.${present[0]}`);
433
+ }
434
+
435
+ function readForceWeb(raw, id) {
436
+ const fields = ['force_web', 'forceWeb'];
437
+ const present = fields.filter((field) => hasOwn(raw, field));
438
+ if (present.length === 0) return undefined;
439
+ if (present.length > 1) throw new Error(`${id}.force_web 与 forceWeb 不能同时设置。`);
440
+ return readBooleanAlias(raw[present[0]], `${id}.${present[0]}`);
441
+ }
442
+
443
+ function readBooleanAlias(value, name) {
444
+ if (typeof value === 'boolean') return value;
445
+ if (value === 'true') return true;
446
+ if (value === 'false') return false;
447
+ throw new Error(`${name} 必须是布尔值。`);
448
+ }
449
+
450
+ function readOutputCompression(raw, id) {
451
+ if (!hasOwn(raw, 'output_compression')) return undefined;
452
+ const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format')
453
+ ? readOutputFormatField(raw, id)
454
+ : 'png';
455
+ if (outputFormat === 'png') {
456
+ return undefined;
457
+ }
458
+ const value = raw.output_compression;
459
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
460
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {
461
+ throw new Error(`${id}.output_compression 必须是 0 到 100 之间的整数。`);
462
+ }
463
+ return parsed;
464
+ }
465
+
466
+ function readTaskNormalizations(raw, id) {
467
+ if (!hasOwn(raw, 'output_compression')) return undefined;
468
+ const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format')
469
+ ? readOutputFormatField(raw, id)
470
+ : 'png';
471
+ if (outputFormat !== 'png') return undefined;
472
+ return { output_compression_ignored_for_png: true };
473
+ }
474
+
475
+ function readOutputFormatField(raw, id) {
476
+ const value = raw.output_format ?? raw.format;
477
+ if (typeof value !== 'string' || !value.trim()) {
478
+ throw new Error(`${id}.output_format 必须是字符串。`);
479
+ }
480
+ return normalizeOutputFormat(value);
481
+ }
482
+
483
+ function validateTaskRoutingFields(raw, id) {
484
+ if ((raw.page_sse === true || raw.transport === 'page_sse') && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) {
485
+ throw new Error(`${id} stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。`);
486
+ }
487
+ if (readResponsesModel(raw, id) && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) {
488
+ throw new Error(`${id}.responsesModel 需要页面 SSE 路径,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`);
489
+ }
490
+ if (hasOwn(raw, 'sse_log_path') && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off')) {
491
+ throw new Error(`${id}.sse_log_path 需要页面 SSE 路径,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`);
492
+ }
493
+ if (hasPageAdvancedFields(raw) && (raw.stream_mode === 'non_stream' || raw.streaming_strategy === 'off') && raw.mode === 'edit') {
494
+ throw new Error(`${id} 图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。`);
495
+ }
496
+ }
497
+
498
+ function hasPageAdvancedFields(raw) {
499
+ return PAGE_ADVANCED_FIELDS.some((field) => hasOwn(raw, field));
500
+ }
501
+
502
+ function hasOwn(value, key) {
503
+ return Object.prototype.hasOwnProperty.call(value, key);
504
+ }
505
+
506
+ function readEditImagePaths(raw, id = 'edit') {
507
+ if (Array.isArray(raw.image_paths)) {
508
+ if (raw.image_paths.length === 0) throw new Error(`${id}.image_paths 必须是非空字符串数组。`);
509
+ return raw.image_paths.map((value, index) => readNonEmptyString(value, `${id}.image_paths[${index}]`));
510
+ }
511
+ if (hasOwn(raw, 'image_paths')) throw new Error(`${id}.image_paths 必须是非空字符串数组。`);
512
+ if (hasOwn(raw, 'image_path')) return [readNonEmptyString(raw.image_path, `${id}.image_path`)];
513
+ return [];
514
+ }
515
+
516
+ function readNonEmptyString(value, name) {
517
+ if (typeof value !== 'string' || !value.trim()) {
518
+ throw new Error(`${name} 必须是非空字符串。`);
519
+ }
520
+ return value;
521
+ }
522
+
523
+ function buildOrderedKey(prefix, index, id) {
524
+ const safePrefix = sanitizeKeyPart(prefix || 'batch');
525
+ const safeId = sanitizeKeyPart(id || `item-${index + 1}`);
526
+ return `${safePrefix}-${String(index + 1).padStart(4, '0')}-${safeId}`.slice(0, 200);
527
+ }
528
+
529
+ function sanitizeKeyPart(value) {
530
+ return String(value)
531
+ .trim()
532
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
533
+ .replace(/^-+|-+$/g, '') || 'item';
534
+ }
535
+
536
+ function authHeaders() {
537
+ if (token) return { Authorization: `Bearer ${token}` };
538
+ if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
539
+ return {};
540
+ }
541
+
542
+ async function readCapabilities() {
543
+ const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, { headers: authHeaders() });
544
+ if (!response.ok) throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`);
545
+ return result;
546
+ }
547
+
548
+ async function ensureCapabilities() {
549
+ if (capabilities) return capabilities;
550
+ capabilitiesPromise ??= readCapabilities();
551
+ try {
552
+ capabilities = await capabilitiesPromise;
553
+ } catch (error) {
554
+ capabilitiesPromise = undefined;
555
+ throw error;
556
+ }
557
+ return capabilities;
558
+ }
559
+
560
+ async function runTask(task) {
561
+ const routing = buildTaskRouting(task);
562
+ try {
563
+ const response =
564
+ routing.transport === 'page_sse'
565
+ ? await postPageSseTask(task, routing)
566
+ : task.mode === 'edit'
567
+ ? await postEditTask(task)
568
+ : await postGenerateTask(task);
569
+ if (options.dimensionCheck) await assertDimensions(task, response);
570
+ const output = { ok: true, status: 'succeeded', id: task.id, idempotency_key: task.idempotencyKey, routing, response };
571
+ appendManifest(manifestPath, { ...baseManifestEntry(task), status: 'succeeded', routing, response: sanitizeResponse(response) });
572
+ return output;
573
+ } catch (error) {
574
+ const failure = buildTaskFailureOutput(error, routing);
575
+ const output = { ok: false, status: 'failed', id: task.id, idempotency_key: task.idempotencyKey, ...failure };
576
+ appendManifest(manifestPath, { ...baseManifestEntry(task), status: 'failed', ...failure });
577
+ return output;
578
+ }
579
+ }
580
+
581
+ async function runTaskWithAttempts(task) {
582
+ let lastResult;
583
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
584
+ const attemptTask = buildAttemptTask(task, attempt);
585
+ const result = await runTask(attemptTask);
586
+ lastResult = addAttemptMetadata(result, task, attempt);
587
+ if (result.ok) return lastResult;
588
+ }
589
+ return lastResult;
590
+ }
591
+
592
+ async function runPlannedTasks(plannedTasks, completed) {
593
+ const results = new Array(plannedTasks.length);
594
+ const failedTasks = [];
595
+ let consecutiveFailures = 0;
596
+ let nextIndex = 0;
597
+
598
+ async function worker() {
599
+ while (nextIndex < plannedTasks.length) {
600
+ const index = nextIndex;
601
+ nextIndex += 1;
602
+ const task = plannedTasks[index];
603
+
604
+ if (completed.has(task.idempotencyKey) || completed.has(task.id)) {
605
+ results[index] = handleResumeSkippedTask(task);
606
+ continue;
607
+ }
608
+
609
+ if (options.maxConsecutiveFailures > 0 && consecutiveFailures >= options.maxConsecutiveFailures) {
610
+ results[index] = handleCircuitBreakerSkippedTask(task, consecutiveFailures);
611
+ continue;
612
+ }
613
+
614
+ const result = await runTaskWithAttempts(task);
615
+ results[index] = result;
616
+ if (result.ok) {
617
+ consecutiveFailures = 0;
618
+ } else {
619
+ consecutiveFailures += 1;
620
+ failedTasks.push(buildFailedTaskSummary(result, task));
621
+ }
622
+ }
623
+ }
624
+
625
+ const workerCount = Math.min(options.concurrency, plannedTasks.length);
626
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
627
+ failedTasks.sort((left, right) => left.index - right.index);
628
+ return { results, failedTasks };
629
+ }
630
+
631
+ function handleResumeSkippedTask(task) {
632
+ const skipped = {
633
+ ok: true,
634
+ status: 'skipped',
635
+ id: task.id,
636
+ idempotency_key: task.idempotencyKey,
637
+ billable: false,
638
+ skipped_reason: 'resume'
639
+ };
640
+ appendManifest(manifestPath, {
641
+ ...baseManifestEntry(task),
642
+ status: 'skipped',
643
+ billable: false,
644
+ skipped_reason: 'resume'
645
+ });
646
+ return skipped;
647
+ }
648
+
649
+ function handleCircuitBreakerSkippedTask(task, consecutiveFailures) {
650
+ const skipped = buildCircuitBreakerSkippedTask(task, consecutiveFailures);
651
+ appendManifest(manifestPath, { ...baseManifestEntry(task), ...skipped });
652
+ return { ok: true, id: task.id, idempotency_key: task.idempotencyKey, ...skipped };
653
+ }
654
+
655
+ function buildAttemptTask(task, attempt) {
656
+ if (attempt === 1) return { ...task, attempt, rootIdempotencyKey: task.idempotencyKey };
657
+ return {
658
+ ...task,
659
+ attempt,
660
+ rootIdempotencyKey: task.idempotencyKey,
661
+ idempotencyKey: buildAttemptIdempotencyKey(task.idempotencyKey, attempt)
662
+ };
663
+ }
664
+
665
+ function buildAttemptIdempotencyKey(idempotencyKey, attempt) {
666
+ const suffix = `-attempt-${attempt}`;
667
+ if (idempotencyKey.length + suffix.length <= MAX_IDEMPOTENCY_KEY_LENGTH) {
668
+ return `${idempotencyKey}${suffix}`;
669
+ }
670
+ const digest = crypto.createHash('sha256').update(idempotencyKey).digest('hex').slice(0, 12);
671
+ const hashedSuffix = `-${digest}${suffix}`;
672
+ return `${idempotencyKey.slice(0, MAX_IDEMPOTENCY_KEY_LENGTH - hashedSuffix.length)}${hashedSuffix}`;
673
+ }
674
+
675
+ function addAttemptMetadata(result, rootTask, attempt) {
676
+ return {
677
+ ...result,
678
+ attempt,
679
+ max_attempts: options.maxAttempts,
680
+ ...(attempt > 1 ? { root_idempotency_key: rootTask.idempotencyKey } : {})
681
+ };
682
+ }
683
+
684
+ function buildTaskFailureOutput(error, routing) {
685
+ if (error?.pageSseFailure && typeof error.pageSseFailure === 'object') {
686
+ return {
687
+ billable: error.pageSseFailure.billable,
688
+ error: error.pageSseFailure.error,
689
+ routing: error.pageSseFailure.routing || routing,
690
+ next_step: error.pageSseFailure.next_step
691
+ };
692
+ }
693
+ return { error: errorMessage(error), routing };
694
+ }
695
+
696
+ function buildCircuitBreakerSkippedTask(task, consecutiveFailures) {
697
+ return {
698
+ status: 'skipped',
699
+ billable: false,
700
+ skipped_reason: 'max_consecutive_failures',
701
+ consecutive_failures: consecutiveFailures,
702
+ next_step: '先处理 failure_summary 中的失败任务,再用 --resume 续跑剩余任务。'
703
+ };
704
+ }
705
+
706
+ function buildFailedTaskSummary(result, task) {
707
+ const error = normalizeFailureError(result.error);
708
+ return {
709
+ index: task.index,
710
+ id: task.id,
711
+ idempotency_key: task.idempotencyKey,
712
+ attempt: result.attempt,
713
+ route: result.routing?.transport,
714
+ endpoint: result.routing?.endpoint,
715
+ billable: result.billable !== false,
716
+ code: error.code,
717
+ message: error.message,
718
+ next_step: result.next_step || buildFailureNextStep(error)
719
+ };
720
+ }
721
+
722
+ function normalizeFailureError(error) {
723
+ if (error && typeof error === 'object') {
724
+ return {
725
+ code: typeof error.code === 'string' ? error.code : 'batch_task_failed',
726
+ message: typeof error.message === 'string' ? error.message : JSON.stringify(error)
727
+ };
728
+ }
729
+ return { code: 'batch_task_failed', message: String(error || '任务失败。') };
730
+ }
731
+
732
+ function buildFailureNextStep(error) {
733
+ if (error.code === 'page_sse_request_rejected') return '修正请求参数或鉴权后,使用新的 Idempotency-Key 重试失败任务。';
734
+ if (error.code === 'page_sse_unavailable') return '补齐 page_sse capability 或显式改用 Agent JSON,再重试失败任务。';
735
+ return '诊断失败原因后,用新的 Idempotency-Key 重试失败任务;不要复用终态失败 key。';
736
+ }
737
+
738
+ function buildFailureSummary(failedTasks) {
739
+ return {
740
+ count: failedTasks.length,
741
+ billable_count: failedTasks.filter((task) => task.billable).length,
742
+ non_billable_count: failedTasks.filter((task) => !task.billable).length,
743
+ tasks: failedTasks
744
+ };
745
+ }
746
+
747
+ function buildResumeFixList(failedTasks) {
748
+ return failedTasks.map((task) => ({
749
+ id: task.id,
750
+ previous_idempotency_key: task.idempotency_key,
751
+ suggested_idempotency_key: buildAttemptIdempotencyKey(task.idempotency_key, (task.attempt || 1) + 1),
752
+ route: task.route,
753
+ next_step: task.next_step
754
+ }));
755
+ }
756
+
757
+ function buildTaskRouting(task) {
758
+ if (shouldUsePageSseForTask(task)) {
759
+ const reason = buildPageSseRoutingReason(task);
760
+ return {
761
+ endpoint: PAGE_SSE_ENDPOINT,
762
+ transport: 'page_sse',
763
+ strength: task.mode === 'edit' && readTaskMaxEdge(task) > 2048 ? 'default' : 'recommended',
764
+ fallback_endpoint: task.mode === 'edit' ? AGENT_ENDPOINTS.edit : AGENT_ENDPOINTS.generate,
765
+ fallback_mode: 'manual_after_diagnosis',
766
+ reason
767
+ };
768
+ }
769
+ return {
770
+ endpoint: task.mode === 'edit' ? AGENT_ENDPOINTS.edit : AGENT_ENDPOINTS.generate,
771
+ transport: 'agent_json',
772
+ strength: 'default',
773
+ reason: 'Normal batch tasks use the Agent JSON response contract.'
774
+ };
775
+ }
776
+
777
+ function buildPageSseRoutingReason(task) {
778
+ if (task.raw.sse_log_path) {
779
+ return 'Task requested raw SSE event logging, so it uses page form-data SSE for observable diagnostics.';
780
+ }
781
+ if (task.mode === 'edit' && hasPageAdvancedFields(task.raw)) {
782
+ return 'GPT2Image-compatible edit options require page form-data SSE; Agent JSON edit does not accept those fields.';
783
+ }
784
+ if (task.mode === 'edit' && readTaskMaxEdge(task) > 2048) {
785
+ return 'High-resolution edit defaults to page form-data SSE; fall back explicitly after diagnosis if streaming has issues.';
786
+ }
787
+ return 'Large or complex batch image tasks should use page form-data SSE for observability and recovery.';
788
+ }
789
+
790
+ function buildDryRunRequestPreview(task, routing) {
791
+ if (routing.transport === 'page_sse') return buildPageSseRequestPreview(task);
792
+ if (task.mode === 'edit') return buildAgentEditRequestPreview(task.raw);
793
+ return buildGenerateBody(task.raw);
794
+ }
795
+
796
+ function buildAgentEditRequestPreview(raw) {
797
+ validateEditStrategyFields(raw);
798
+ const preview = {};
799
+ const fields = ['prompt', 'model', 'n', 'size', 'quality', 'response_mode', 'stream_mode', 'streaming_strategy', 'partial_images'];
800
+ for (const field of fields) {
801
+ if (raw[field] !== undefined) preview[field] = raw[field];
802
+ }
803
+ if (!raw.model) preview.model = 'gpt-image-2';
804
+ if (!raw.response_mode) preview.response_mode = 'path';
805
+ preview.image_fields = readEditImagePaths(raw, String(raw.id || 'edit')).map((_, index) => `image_${index}`);
806
+ if (raw.mask_path) preview.mask = 'provided';
807
+ return preview;
808
+ }
809
+
810
+ function buildPageSseRequestPreview(task) {
811
+ const raw = task.raw;
812
+ const preview = {
813
+ mode: task.mode,
814
+ prompt: raw.prompt,
815
+ model: raw.model || 'gpt-image-2',
816
+ size: raw.size || (task.mode === 'generate' ? '1024x1024' : 'auto'),
817
+ quality: raw.quality || (task.mode === 'generate' ? 'high' : 'auto'),
818
+ response_mode: readResponseMode(raw),
819
+ clientRequestId: task.idempotencyKey,
820
+ stream: 'true'
821
+ };
822
+ if (raw.n !== undefined) preview.n = readConfiguredPositiveInteger(raw.n, `${task.id}.n`, 1);
823
+ if (raw.stream_mode) preview.stream_mode = String(raw.stream_mode);
824
+ if (raw.streaming_strategy) preview.image_streaming_strategy = String(raw.streaming_strategy);
825
+ if (raw.partial_images !== undefined) preview.partial_images = readPartialImages(raw.partial_images, `${task.id}.partial_images`);
826
+ if (raw.image_backend) preview.image_backend = normalizeImageBackendForPage(String(raw.image_backend));
827
+ if (readResponsesModel(raw, task.id)) preview.responsesModel = readResponsesModel(raw, task.id);
828
+ if (raw.thinking) preview.thinking = String(raw.thinking);
829
+ if (readPromptOptimization(raw, task.id) !== undefined) preview.promptOptimization = readPromptOptimization(raw, task.id);
830
+ if (readForceWeb(raw, task.id) !== undefined) preview.force_web = readForceWeb(raw, task.id);
831
+ if (raw.sse_log_path) preview.sse_log_path = readNonEmptyString(raw.sse_log_path, `${task.id}.sse_log_path`);
832
+ if (raw.background) preview.background = String(raw.background);
833
+ if (raw.moderation) preview.moderation = String(raw.moderation);
834
+ if (raw.output_compression !== undefined) preview.output_compression = readOutputCompression(raw, task.id);
835
+ if (readTaskNormalizations(raw, task.id)) preview.normalizations = readTaskNormalizations(raw, task.id);
836
+ if (task.mode === 'edit') {
837
+ preview.image_fields = readEditImagePaths(raw, task.id).map((_, index) => `image_${index}`);
838
+ if (raw.mask_path) preview.mask = 'provided';
839
+ }
840
+ preview.output_format = readOutputFormat(raw);
841
+ return preview;
842
+ }
843
+
844
+ function shouldUsePageSseForTask(task) {
845
+ const pageSseAllowed = isPageSseAllowedForTask(task);
846
+ if (task.raw.page_sse === true || task.raw.transport === 'page_sse') {
847
+ if (!pageSseAllowed) {
848
+ throw new Error(`${task.id} stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。`);
849
+ }
850
+ return true;
851
+ }
852
+ if (!pageSseAllowed) return false;
853
+ if (task.raw.complex_ui === true || task.raw.long_image === true || task.raw.resume_or_recover === true) return true;
854
+ if (readResponsesModel(task.raw, task.id)) return true;
855
+ if (task.raw.sse_log_path) return true;
856
+ if (task.mode === 'edit' && hasPageAdvancedFields(task.raw)) return true;
857
+ if (task.mode === 'edit' && readTaskMaxEdge(task) > 2048) return true;
858
+ if (task.mode === 'generate' && readTaskMaxEdge(task) > 2048) {
859
+ return true;
860
+ }
861
+ return false;
862
+ }
863
+
864
+ function isPageSseAllowedForTask(task) {
865
+ return task.raw.streaming_strategy !== 'off' && task.raw.stream_mode !== 'non_stream';
866
+ }
867
+
868
+ async function postGenerateTask(task) {
869
+ const body = buildGenerateBody(task.raw);
870
+ const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
871
+ method: 'POST',
872
+ headers: { 'Content-Type': 'application/json', 'Idempotency-Key': task.idempotencyKey, ...authHeaders() },
873
+ body: JSON.stringify(body)
874
+ });
875
+ if (!response.ok) throw new Error(readErrorMessage(result) || `generate 请求失败,状态码 ${response.status}:${text}`);
876
+ return enrichImageUrls(result);
877
+ }
878
+
879
+ async function postEditTask(task) {
880
+ const formData = new FormData();
881
+ appendEditFields(formData, task.raw);
882
+ const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.edit}`, {
883
+ method: 'POST',
884
+ headers: { 'Idempotency-Key': task.idempotencyKey, ...authHeaders() },
885
+ body: formData
886
+ });
887
+ if (!response.ok) throw new Error(readErrorMessage(result) || `edit 请求失败,状态码 ${response.status}:${text}`);
888
+ return enrichImageUrls(result);
889
+ }
890
+
891
+ async function postPageSseTask(task, routing) {
892
+ const pageSseCapabilities = await ensureCapabilities();
893
+ try {
894
+ assertPageSseReady({
895
+ capabilities: pageSseCapabilities,
896
+ passwordHash,
897
+ idempotencyKey: task.idempotencyKey
898
+ });
899
+ const formData = buildPageSseTaskFormData(task);
900
+ const result = await postPageSse({
901
+ url: `${baseUrl}${PAGE_SSE_ENDPOINT}`,
902
+ formData,
903
+ timeoutMs,
904
+ sseLogPath: task.raw.sse_log_path,
905
+ errorMessage
906
+ });
907
+ return formatPageSseOutput({
908
+ result,
909
+ baseUrl,
910
+ responseMode: readResponseMode(task.raw),
911
+ defaultOutputFormat: readOutputFormat(task.raw)
912
+ });
913
+ } catch (error) {
914
+ const pageSseFailure = buildPageSseFailureOutput({
915
+ error,
916
+ fallbackEndpoint: routing.fallback_endpoint,
917
+ errorMessage
918
+ });
919
+ const taskError = new Error(pageSseFailure.error?.message || errorMessage(error));
920
+ taskError.pageSseFailure = pageSseFailure;
921
+ throw taskError;
922
+ }
923
+ }
924
+
925
+ function buildGenerateBody(raw) {
926
+ const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format')
927
+ ? normalizeOutputFormat(raw.output_format ?? raw.format)
928
+ : 'png';
929
+ const outputCompression = readOutputCompression(raw, String(raw.id || 'generate'));
930
+ const body = {
931
+ prompt: raw.prompt,
932
+ model: raw.model || 'gpt-image-2',
933
+ n: readConfiguredPositiveInteger(raw.n, 'n', 1),
934
+ size: raw.size || '1024x1024',
935
+ quality: raw.quality || 'high',
936
+ output_format: normalizeEnumValue(outputFormat, OUTPUT_FORMATS, 'output_format'),
937
+ response_mode: normalizeEnumValue(hasOwn(raw, 'response_mode') ? raw.response_mode : 'path', RESPONSE_MODES, 'response_mode'),
938
+ ...(outputCompression !== undefined ? { output_compression: outputCompression } : {}),
939
+ ...(raw.background ? { background: raw.background } : {}),
940
+ ...(raw.moderation ? { moderation: raw.moderation } : {}),
941
+ ...(hasOwn(raw, 'image_backend') ? { image_backend: normalizeEnumValue(raw.image_backend, IMAGE_BACKENDS, 'image_backend') } : {}),
942
+ ...(hasOwn(raw, 'stream_mode') ? { stream_mode: normalizeEnumValue(raw.stream_mode, STREAM_MODES, 'stream_mode') } : {}),
943
+ ...(hasOwn(raw, 'streaming_strategy')
944
+ ? { streaming_strategy: normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, 'streaming_strategy') }
945
+ : {}),
946
+ ...(hasOwn(raw, 'partial_images') ? { partial_images: readPartialImages(raw.partial_images, 'partial_images') } : {})
947
+ };
948
+ const normalizations = readTaskNormalizations(raw, String(raw.id || 'generate'));
949
+ return normalizations ? { ...body, normalizations } : body;
950
+ }
951
+
952
+ function buildPageSseTaskFormData(task) {
953
+ const raw = task.raw;
954
+ const formData = new FormData();
955
+ formData.append('mode', task.mode);
956
+ formData.append('prompt', raw.prompt);
957
+ formData.append('model', raw.model || 'gpt-image-2');
958
+ formData.append('size', raw.size || (task.mode === 'generate' ? '1024x1024' : 'auto'));
959
+ formData.append('quality', raw.quality || (task.mode === 'generate' ? 'high' : 'auto'));
960
+ formData.append('response_mode', readResponseMode(raw));
961
+ formData.append('clientRequestId', task.idempotencyKey);
962
+ formData.append('stream', 'true');
963
+ if (raw.n !== undefined) formData.append('n', String(readConfiguredPositiveInteger(raw.n, `${task.id}.n`, 1)));
964
+ if (raw.stream_mode) formData.append('stream_mode', String(raw.stream_mode));
965
+ if (raw.streaming_strategy) formData.append('image_streaming_strategy', String(raw.streaming_strategy));
966
+ if (raw.partial_images !== undefined) formData.append('partial_images', String(readPartialImages(raw.partial_images, `${task.id}.partial_images`)));
967
+ if (raw.image_backend) formData.append('image_backend', normalizeImageBackendForPage(String(raw.image_backend)));
968
+ if (readResponsesModel(raw, task.id)) formData.append('responsesModel', readResponsesModel(raw, task.id));
969
+ if (raw.thinking) formData.append('thinking', String(raw.thinking));
970
+ if (readPromptOptimization(raw, task.id) !== undefined) {
971
+ formData.append('promptOptimization', String(readPromptOptimization(raw, task.id)));
972
+ }
973
+ if (readForceWeb(raw, task.id) !== undefined) formData.append('force_web', String(readForceWeb(raw, task.id)));
974
+ if (raw.background) formData.append('background', String(raw.background));
975
+ if (raw.moderation) formData.append('moderation', String(raw.moderation));
976
+ if (readOutputCompression(raw, task.id) !== undefined) {
977
+ formData.append('output_compression', String(readOutputCompression(raw, task.id)));
978
+ }
979
+ if (passwordHash) formData.append('passwordHash', passwordHash);
980
+ if (task.mode === 'edit') {
981
+ readEditImagePaths(raw, task.id).forEach((filePath, index) => appendFile(formData, `image_${index}`, filePath));
982
+ if (raw.mask_path) appendFile(formData, 'mask', raw.mask_path);
983
+ }
984
+ formData.append('output_format', readOutputFormat(raw));
985
+ return formData;
986
+ }
987
+
988
+ function readResponseMode(raw) {
989
+ return normalizeEnumValue(hasOwn(raw, 'response_mode') ? raw.response_mode : 'path', RESPONSE_MODES, 'response_mode');
990
+ }
991
+
992
+ function readOutputFormat(raw) {
993
+ const outputFormat = hasOwn(raw, 'output_format') || hasOwn(raw, 'format')
994
+ ? normalizeOutputFormat(raw.output_format ?? raw.format)
995
+ : 'png';
996
+ return normalizeEnumValue(outputFormat, OUTPUT_FORMATS, 'output_format');
997
+ }
998
+
999
+ function appendEditFields(formData, raw) {
1000
+ validateEditStrategyFields(raw);
1001
+ const fields = ['prompt', 'model', 'n', 'size', 'quality', 'response_mode', 'stream_mode', 'streaming_strategy', 'partial_images'];
1002
+ for (const field of fields) {
1003
+ if (raw[field] !== undefined) formData.append(field, String(raw[field]));
1004
+ }
1005
+ if (!raw.model) formData.append('model', 'gpt-image-2');
1006
+ if (!raw.response_mode) formData.append('response_mode', 'path');
1007
+ readEditImagePaths(raw, String(raw.id || 'edit')).forEach((filePath, index) => appendFile(formData, `image_${index}`, filePath));
1008
+ if (raw.mask_path) appendFile(formData, 'mask', raw.mask_path);
1009
+ }
1010
+
1011
+ function readTaskMaxEdge(task) {
1012
+ return readMaxImageEdge(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined));
1013
+ }
1014
+
1015
+ function validateEditStrategyFields(raw) {
1016
+ if (hasOwn(raw, 'response_mode')) normalizeEnumValue(raw.response_mode, RESPONSE_MODES, 'response_mode');
1017
+ if (hasOwn(raw, 'stream_mode')) normalizeEnumValue(raw.stream_mode, STREAM_MODES, 'stream_mode');
1018
+ if (hasOwn(raw, 'streaming_strategy')) {
1019
+ normalizeEnumValue(raw.streaming_strategy, STREAMING_STRATEGIES, 'streaming_strategy');
1020
+ }
1021
+ if (hasOwn(raw, 'partial_images')) readPartialImages(raw.partial_images, 'partial_images');
1022
+ }
1023
+
1024
+ function normalizeEnumValue(value, allowed, name) {
1025
+ const normalized = String(value);
1026
+ if (allowed.has(normalized)) return normalized;
1027
+ throw new Error(`${name} 的值无效:${normalized}`);
1028
+ }
1029
+
1030
+ function readPartialImages(value, name) {
1031
+ const parsed = readConfiguredPositiveInteger(value, name, 2);
1032
+ if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) {
1033
+ throw new Error(`${name} 必须是 1 到 3 的整数。`);
1034
+ }
1035
+ return parsed;
1036
+ }
1037
+
1038
+ function readNonNegativeInteger(value, name) {
1039
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN;
1040
+ if (!Number.isInteger(parsed) || parsed < 0) {
1041
+ throw new Error(`${name} 必须是非负整数。`);
1042
+ }
1043
+ return parsed;
1044
+ }
1045
+
1046
+ function appendFile(formData, field, filePath) {
1047
+ const buffer = fs.readFileSync(filePath);
1048
+ formData.append(field, new Blob([buffer], { type: mimeTypeForPath(filePath) }), path.basename(filePath));
1049
+ }
1050
+
1051
+ async function fetchJson(url, init) {
1052
+ const { response, text } = await fetchText(url, init);
1053
+ let result = null;
1054
+ try {
1055
+ result = text ? JSON.parse(text) : null;
1056
+ } catch (error) {
1057
+ if (response.ok) throw new Error(`响应不是有效 JSON:${errorMessage(error)}`);
1058
+ }
1059
+ return { response, result, text };
1060
+ }
1061
+
1062
+ async function fetchText(url, init = {}) {
1063
+ const controller = new AbortController();
1064
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1065
+ try {
1066
+ const response = await fetch(url, { ...init, signal: controller.signal });
1067
+ return { response, text: await response.text() };
1068
+ } finally {
1069
+ clearTimeout(timer);
1070
+ }
1071
+ }
1072
+
1073
+ function readErrorMessage(result) {
1074
+ if (typeof result?.error === 'string') return result.error;
1075
+ if (typeof result?.error?.message === 'string') return result.error.message;
1076
+ return undefined;
1077
+ }
1078
+
1079
+ function enrichImageUrls(result) {
1080
+ if (!result || !Array.isArray(result.images)) return result;
1081
+ return {
1082
+ ...result,
1083
+ images: result.images.map((image) => ({
1084
+ ...image,
1085
+ ...(image.content_url ? { absolute_content_url: new URL(image.content_url, `${baseUrl}/`).toString() } : {}),
1086
+ ...(image.metadata_url ? { absolute_metadata_url: new URL(image.metadata_url, `${baseUrl}/`).toString() } : {})
1087
+ }))
1088
+ };
1089
+ }
1090
+
1091
+ async function assertDimensions(task, response) {
1092
+ const expected = parseExpectedSize(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined));
1093
+ if (!expected) throw new Error(`${task.id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`);
1094
+ if (!Array.isArray(response.images)) return;
1095
+ for (const image of response.images) {
1096
+ const bytes = await readImageBytes(image);
1097
+ const actual = readImageDimensions(bytes);
1098
+ if (actual.width !== expected.width || actual.height !== expected.height) {
1099
+ throw new Error(`${task.id} 尺寸校验失败:期望 ${expected.width}x${expected.height},实际 ${actual.width}x${actual.height}。`);
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ async function readImageBytes(image) {
1105
+ if (image.b64_json) return Buffer.from(image.b64_json, 'base64');
1106
+ const url = image.absolute_content_url || image.content_url;
1107
+ if (!url) throw new Error('dimension-check 需要 b64_json 或 content_url。');
1108
+ const resolved = resolveSameOriginUrl(baseUrl, url, 'content_url');
1109
+ const { response, bytes } = await fetchBytes(resolved, { headers: authHeaders() });
1110
+ if (!response.ok) throw new Error(`下载产物失败,状态码 ${response.status}。`);
1111
+ return bytes;
1112
+ }
1113
+
1114
+ function parseExpectedSize(size) {
1115
+ return parseImageSizeValue(size);
1116
+ }
1117
+
1118
+ function readImageDimensions(buffer) {
1119
+ if (buffer.length >= 24 && buffer.toString('ascii', 1, 4) === 'PNG') {
1120
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
1121
+ }
1122
+ if (buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
1123
+ return readWebpDimensions(buffer);
1124
+ }
1125
+ return readJpegDimensions(buffer);
1126
+ }
1127
+
1128
+ async function fetchBytes(url, init = {}) {
1129
+ const controller = new AbortController();
1130
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1131
+ try {
1132
+ const response = await fetch(url, { ...init, signal: controller.signal });
1133
+ return { response, bytes: Buffer.from(await response.arrayBuffer()) };
1134
+ } finally {
1135
+ clearTimeout(timer);
1136
+ }
1137
+ }
1138
+
1139
+ function readJpegDimensions(buffer) {
1140
+ let offset = 2;
1141
+ while (offset + 9 < buffer.length) {
1142
+ if (buffer[offset] !== 0xff) break;
1143
+ const marker = buffer[offset + 1];
1144
+ const length = buffer.readUInt16BE(offset + 2);
1145
+ if (marker >= 0xc0 && marker <= 0xc3) {
1146
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) };
1147
+ }
1148
+ offset += 2 + length;
1149
+ }
1150
+ throw new Error('无法读取图片尺寸。');
1151
+ }
1152
+
1153
+ function readWebpDimensions(buffer) {
1154
+ const chunk = buffer.toString('ascii', 12, 16);
1155
+ if (chunk === 'VP8X') {
1156
+ return { width: 1 + buffer.readUIntLE(24, 3), height: 1 + buffer.readUIntLE(27, 3) };
1157
+ }
1158
+ if (chunk === 'VP8L') {
1159
+ const bits = buffer.readUInt32LE(21);
1160
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
1161
+ }
1162
+ if (chunk === 'VP8 ') return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff };
1163
+ throw new Error('无法读取 WebP 图片尺寸。');
1164
+ }
1165
+
1166
+ function readCompletedManifestKeys(filePath) {
1167
+ if (!fs.existsSync(filePath)) return new Set();
1168
+ const keys = new Set();
1169
+ for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
1170
+ if (!line.trim()) continue;
1171
+ let entry;
1172
+ try {
1173
+ entry = JSON.parse(line);
1174
+ } catch {
1175
+ continue;
1176
+ }
1177
+ if (entry.status === 'succeeded') {
1178
+ if (entry.id) keys.add(entry.id);
1179
+ if (entry.idempotency_key) keys.add(entry.idempotency_key);
1180
+ }
1181
+ }
1182
+ return keys;
1183
+ }
1184
+
1185
+ function appendManifest(filePath, entry) {
1186
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1187
+ fs.appendFileSync(filePath, `${JSON.stringify(entry)}\n`);
1188
+ }
1189
+
1190
+ function baseManifestEntry(task) {
1191
+ return {
1192
+ at: new Date().toISOString(),
1193
+ index: task.index,
1194
+ id: task.id,
1195
+ mode: task.mode,
1196
+ idempotency_key: task.idempotencyKey,
1197
+ attempt: task.attempt || 1,
1198
+ ...(task.rootIdempotencyKey && task.rootIdempotencyKey !== task.idempotencyKey
1199
+ ? { root_idempotency_key: task.rootIdempotencyKey }
1200
+ : {})
1201
+ };
1202
+ }
1203
+
1204
+ function sanitizeResponse(response) {
1205
+ if (!response || !Array.isArray(response.images)) return response;
1206
+ return {
1207
+ ...response,
1208
+ images: response.images.map((image) => ({
1209
+ ...image,
1210
+ ...(image.b64_json ? { b64_json_length: image.b64_json.length, b64_json: undefined } : {})
1211
+ }))
1212
+ };
1213
+ }
1214
+
1215
+ function mimeTypeForPath(filePath) {
1216
+ const ext = path.extname(filePath).toLowerCase();
1217
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
1218
+ if (ext === '.webp') return 'image/webp';
1219
+ return 'image/png';
1220
+ }
1221
+
1222
+ function printUsage() {
1223
+ console.error('用法:batch-images.mjs --input tasks.jsonl [options]');
1224
+ console.error('默认只输出 dry-run;添加 --allow-billable 才会按 routing rules 逐行真实请求 Agent API 或页面 SSE。');
1225
+ console.error('常用参数:--manifest --resume --ordered-prefix --dimension-check --max-attempts --max-consecutive-failures --concurrency --timeout-ms --dry-run --allow-billable');
1226
+ }
skills/gpt-image-playground-agent/scripts/edit-image.mjs CHANGED
@@ -4,12 +4,40 @@ import fs from 'node:fs';
4
  import path from 'node:path';
5
  import {
6
  errorMessage,
 
7
  normalizeBaseUrl,
 
8
  parseRetryAfterValue,
9
  readConfiguredPositiveInteger,
 
10
  readOptionValue,
11
  sleep
12
  } from './lib/script-utils.mjs';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
15
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
@@ -29,6 +57,15 @@ if (options.help) {
29
  process.exit(0);
30
  }
31
 
 
 
 
 
 
 
 
 
 
32
  let maxAttempts;
33
  let timeoutMs;
34
  try {
@@ -62,7 +99,7 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
62
  ok: true,
63
  billable: false,
64
  dry_run: true,
65
- endpoint: `${baseUrl}/api/agent/images/edit`,
66
  routing_guidance: routingGuidance,
67
  idempotency_key: idempotencyKey,
68
  request: {
@@ -71,7 +108,21 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
71
  model: options.model,
72
  size: options.size,
73
  quality: options.quality,
74
- response_mode: options.responseMode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  },
76
  next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
77
  },
@@ -83,20 +134,13 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
83
  }
84
 
85
  const routingGuidance = buildEditRoutingGuidance(options);
86
- if (routingGuidance.strength === 'must_use') {
87
- console.error(
88
- JSON.stringify(
89
- {
90
- ok: false,
91
- billable: false,
92
- error: '当前请求命中高分辨率 edit 路由硬规则;请使用页面端 /api/images form-data SSE 路径。',
93
- routing_guidance: routingGuidance
94
- },
95
- null,
96
- 2
97
- )
98
- );
99
- process.exit(2);
100
  }
101
 
102
  function parseArgs(argv) {
@@ -105,6 +149,19 @@ function parseArgs(argv) {
105
  size: 'auto',
106
  quality: 'auto',
107
  responseMode: 'path',
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  timeoutMs: undefined,
109
  idempotencyKey: undefined,
110
  imagePath: undefined,
@@ -123,6 +180,20 @@ function parseArgs(argv) {
123
  else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
124
  else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
125
  else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
127
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
128
  else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
@@ -144,12 +215,37 @@ function absoluteUrl(value) {
144
  }
145
 
146
  function buildEditRoutingGuidance(parsed) {
147
- if (readMaxImageEdge(parsed.size) > 2048) {
 
 
 
 
 
 
 
 
 
148
  return {
149
  recommended_endpoint: '/api/images',
150
  transport: 'page_sse',
151
- strength: 'must_use',
152
- reason: 'Agent edit is non-streaming; high-resolution edit should use the page form-data SSE endpoint.'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  };
154
  }
155
  return {
@@ -160,13 +256,6 @@ function buildEditRoutingGuidance(parsed) {
160
  };
161
  }
162
 
163
- function readMaxImageEdge(size) {
164
- if (typeof size !== 'string') return 0;
165
- const match = size.match(/^(\d+)x(\d+)$/);
166
- if (!match) return 0;
167
- return Math.max(Number(match[1]), Number(match[2]));
168
- }
169
-
170
  function enrichImageUrls(result) {
171
  if (!result || !Array.isArray(result.images)) return result;
172
  return {
@@ -196,10 +285,117 @@ async function readCapabilities() {
196
  return response.json();
197
  }
198
 
 
 
 
 
 
 
 
 
199
  function shouldRetry(result) {
200
  return Boolean(result?.error?.retryable);
201
  }
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  async function fetchWithTimeout(url, init) {
204
  const controller = new AbortController();
205
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -213,12 +409,13 @@ async function fetchWithTimeout(url, init) {
213
  function printUsage() {
214
  console.error('用法:edit-image.mjs [options] <image-path> <prompt>');
215
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
216
- console.error('常用参数:--model --size --quality --response-mode --timeout-ms --idempotency-key --dry-run --allow-billable');
217
  console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
218
  }
219
 
 
220
  try {
221
- await readCapabilities();
222
  } catch (error) {
223
  console.error(errorMessage(error));
224
  process.exit(1);
@@ -265,10 +462,74 @@ function buildFormData() {
265
  formData.append('size', options.size);
266
  formData.append('quality', options.quality);
267
  formData.append('response_mode', options.responseMode);
 
 
 
268
  formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
269
  return formData;
270
  }
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  function mimeTypeForPath(filePath) {
273
  const ext = path.extname(filePath).toLowerCase();
274
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
@@ -279,6 +540,26 @@ function mimeTypeForPath(filePath) {
279
  let lastResult;
280
  let lastRetryAfter = null;
281
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
283
  let response;
284
  let result;
 
4
  import path from 'node:path';
5
  import {
6
  errorMessage,
7
+ assertValidImageSizeForModel,
8
  normalizeBaseUrl,
9
+ normalizeOutputFormat,
10
  parseRetryAfterValue,
11
  readConfiguredPositiveInteger,
12
+ readMaxImageEdge,
13
  readOptionValue,
14
  sleep
15
  } from './lib/script-utils.mjs';
16
+ import {
17
+ PAGE_SSE_ENDPOINT,
18
+ assertPageSseReady,
19
+ assertPageSseStreamingAllowed,
20
+ buildPageSseFailureOutput,
21
+ formatPageSseOutput,
22
+ normalizeImageBackendForPage,
23
+ postPageSse
24
+ } from './lib/page-sse-client.mjs';
25
+
26
+ const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
27
+ const STREAMING_STRATEGIES = new Set([
28
+ 'off',
29
+ 'auto',
30
+ 'openai-sse',
31
+ 'newapi-keepalive-sse',
32
+ 'responses-sse',
33
+ 'force-sse'
34
+ ]);
35
+ const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
36
+ const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
37
+ const MODERATIONS = new Set(['low', 'auto']);
38
+ const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
39
+ const MIN_PARTIAL_IMAGES = 1;
40
+ const MAX_PARTIAL_IMAGES = 3;
41
 
42
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
43
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
 
57
  process.exit(0);
58
  }
59
 
60
+ try {
61
+ validateUpstreamStreamingOptions(options);
62
+ options.size = assertValidImageSizeForModel(options.size, options.model, '--size');
63
+ } catch (error) {
64
+ console.error(errorMessage(error));
65
+ printUsage();
66
+ process.exit(2);
67
+ }
68
+
69
  let maxAttempts;
70
  let timeoutMs;
71
  try {
 
99
  ok: true,
100
  billable: false,
101
  dry_run: true,
102
+ endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`,
103
  routing_guidance: routingGuidance,
104
  idempotency_key: idempotencyKey,
105
  request: {
 
108
  model: options.model,
109
  size: options.size,
110
  quality: options.quality,
111
+ response_mode: options.responseMode,
112
+ ...(options.streamMode ? { stream_mode: options.streamMode } : {}),
113
+ ...(options.streamingStrategy ? { streaming_strategy: options.streamingStrategy } : {}),
114
+ ...(options.partialImages ? { partial_images: readPartialImages(options.partialImages) } : {}),
115
+ ...(options.format ? { output_format: readOutputFormat(options) } : {}),
116
+ ...(readOutputCompression(options) !== undefined ? { output_compression: readOutputCompression(options) } : {}),
117
+ ...(options.moderation ? { moderation: options.moderation } : {}),
118
+ ...(options.imageBackend ? { image_backend: normalizeImageBackendForPage(options.imageBackend) } : {}),
119
+ ...(options.responsesModel ? { responsesModel: readNonEmptyString(options.responsesModel, '--responses-model') } : {}),
120
+ ...(options.thinking ? { thinking: options.thinking } : {}),
121
+ ...(options.promptOptimization !== undefined
122
+ ? { promptOptimization: readBooleanOption(options.promptOptimization, '--prompt-optimization') }
123
+ : {}),
124
+ ...(options.forceWeb !== undefined ? { force_web: true } : {}),
125
+ ...(readEditNormalizations(options) ? { normalizations: readEditNormalizations(options) } : {})
126
  },
127
  next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
128
  },
 
134
  }
135
 
136
  const routingGuidance = buildEditRoutingGuidance(options);
137
+ if (routingGuidance.transport === 'page_sse') {
138
+ try {
139
+ assertPageSseStreamingAllowed(options);
140
+ } catch (error) {
141
+ console.error(errorMessage(error));
142
+ process.exit(2);
143
+ }
 
 
 
 
 
 
 
144
  }
145
 
146
  function parseArgs(argv) {
 
149
  size: 'auto',
150
  quality: 'auto',
151
  responseMode: 'path',
152
+ routeMode: 'auto',
153
+ streamMode: undefined,
154
+ streamingStrategy: undefined,
155
+ partialImages: undefined,
156
+ format: undefined,
157
+ outputCompression: undefined,
158
+ moderation: undefined,
159
+ imageBackend: undefined,
160
+ responsesModel: undefined,
161
+ thinking: undefined,
162
+ promptOptimization: undefined,
163
+ forceWeb: undefined,
164
+ sseLogPath: undefined,
165
  timeoutMs: undefined,
166
  idempotencyKey: undefined,
167
  imagePath: undefined,
 
180
  else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
181
  else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
182
  else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
183
+ else if (arg === '--agent') parsed.routeMode = 'agent';
184
+ else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
185
+ else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg);
186
+ else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
187
+ else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
188
+ else if (arg === '--format' || arg === '--output-format') parsed.format = readOptionValue(argv, (index += 1), arg);
189
+ else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg);
190
+ else if (arg === '--moderation') parsed.moderation = readOptionValue(argv, (index += 1), arg);
191
+ else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
192
+ else if (arg === '--responses-model' || arg === '--gpt-model') parsed.responsesModel = readOptionValue(argv, (index += 1), arg);
193
+ else if (arg === '--thinking') parsed.thinking = readOptionValue(argv, (index += 1), arg);
194
+ else if (arg === '--prompt-optimization') parsed.promptOptimization = readOptionValue(argv, (index += 1), arg);
195
+ else if (arg === '--force-web') parsed.forceWeb = true;
196
+ else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
197
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
198
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
199
  else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
 
215
  }
216
 
217
  function buildEditRoutingGuidance(parsed) {
218
+ if (parsed.routeMode === 'agent') {
219
+ assertNoPageOnlyEditOptions(parsed, 'Agent edit');
220
+ return {
221
+ recommended_endpoint: '/api/agent/images/edit',
222
+ transport: 'agent_json',
223
+ strength: 'default',
224
+ reason: 'Explicit --agent requests use the Agent JSON edit response contract.'
225
+ };
226
+ }
227
+ if (parsed.routeMode === 'page_sse') {
228
  return {
229
  recommended_endpoint: '/api/images',
230
  transport: 'page_sse',
231
+ strength: 'default',
232
+ reason: 'Explicit --page-sse requests use the page form-data SSE endpoint.'
233
+ };
234
+ }
235
+ if (hasPageOnlyEditOptions(parsed) && isPageSseAllowed(parsed)) {
236
+ return {
237
+ recommended_endpoint: '/api/images',
238
+ transport: 'page_sse',
239
+ strength: 'default',
240
+ reason: 'GPT2Image-compatible edit options require the page form-data SSE endpoint; Agent JSON edit does not accept those fields.'
241
+ };
242
+ }
243
+ if (readMaxImageEdge(parsed.size) > 2048 && isPageSseAllowed(parsed)) {
244
+ return {
245
+ recommended_endpoint: '/api/images',
246
+ transport: 'page_sse',
247
+ strength: 'default',
248
+ 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.'
249
  };
250
  }
251
  return {
 
256
  };
257
  }
258
 
 
 
 
 
 
 
 
259
  function enrichImageUrls(result) {
260
  if (!result || !Array.isArray(result.images)) return result;
261
  return {
 
285
  return response.json();
286
  }
287
 
288
+ function assertPageSseReadyForEdit(capabilities) {
289
+ assertPageSseReady({
290
+ capabilities,
291
+ passwordHash,
292
+ idempotencyKey
293
+ });
294
+ }
295
+
296
  function shouldRetry(result) {
297
  return Boolean(result?.error?.retryable);
298
  }
299
 
300
+ function validateUpstreamStreamingOptions(parsed) {
301
+ if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) {
302
+ throw new Error('--stream-mode 必须是 auto、stream 或 non_stream。');
303
+ }
304
+ if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
305
+ throw new Error(
306
+ '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'
307
+ );
308
+ }
309
+ if (parsed.routeMode === 'page_sse') {
310
+ assertPageSseStreamingAllowed(parsed);
311
+ }
312
+ if (hasPageOnlyEditOptions(parsed) && !isPageSseAllowed(parsed)) {
313
+ throw new Error('图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。');
314
+ }
315
+ if (parsed.routeMode === 'agent') {
316
+ assertNoPageOnlyEditOptions(parsed, 'Agent edit');
317
+ }
318
+ if (parsed.format && !OUTPUT_FORMATS.has(readOutputFormat(parsed))) {
319
+ throw new Error('--format 必须是 png、jpeg 或 webp。');
320
+ }
321
+ if (parsed.outputCompression !== undefined) readOutputCompression(parsed);
322
+ if (parsed.moderation && !MODERATIONS.has(parsed.moderation)) {
323
+ throw new Error('--moderation 必须是 low 或 auto。');
324
+ }
325
+ if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
326
+ throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
327
+ }
328
+ if (parsed.responsesModel !== undefined) readNonEmptyString(parsed.responsesModel, '--responses-model');
329
+ if (parsed.thinking && !THINKING_VALUES.has(parsed.thinking)) {
330
+ throw new Error('--thinking 必须是 minimal、none、low、medium、high 或 xhigh。');
331
+ }
332
+ if (parsed.promptOptimization !== undefined) readBooleanOption(parsed.promptOptimization, '--prompt-optimization');
333
+ if (parsed.partialImages) readPartialImages(parsed.partialImages);
334
+ }
335
+
336
+ function hasPageOnlyEditOptions(parsed) {
337
+ return Boolean(
338
+ parsed.format ||
339
+ parsed.outputCompression !== undefined ||
340
+ parsed.moderation ||
341
+ parsed.imageBackend ||
342
+ parsed.responsesModel ||
343
+ parsed.thinking ||
344
+ parsed.promptOptimization !== undefined ||
345
+ parsed.forceWeb !== undefined
346
+ );
347
+ }
348
+
349
+ function assertNoPageOnlyEditOptions(parsed, context) {
350
+ if (!hasPageOnlyEditOptions(parsed)) return;
351
+ throw new Error(`${context} 不接受图生图高级页面字段;请去掉这些字段或使用 --page-sse。`);
352
+ }
353
+
354
+ function readOutputFormat(parsed) {
355
+ return parsed.format ? normalizeOutputFormat(parsed.format) : 'png';
356
+ }
357
+
358
+ function readOutputCompression(parsed) {
359
+ if (parsed.outputCompression === undefined) return undefined;
360
+ const outputFormat = readOutputFormat(parsed);
361
+ if (outputFormat === 'png') return undefined;
362
+ const value = String(parsed.outputCompression);
363
+ if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
364
+ const parsedValue = Number(value);
365
+ if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) {
366
+ throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
367
+ }
368
+ return parsedValue;
369
+ }
370
+
371
+ function readEditNormalizations(parsed) {
372
+ if (parsed.outputCompression === undefined || readOutputFormat(parsed) !== 'png') return undefined;
373
+ return { output_compression_ignored_for_png: true };
374
+ }
375
+
376
+ function readBooleanOption(value, name) {
377
+ if (value === true || value === 'true') return true;
378
+ if (value === false || value === 'false') return false;
379
+ throw new Error(`${name} 必须是 true 或 false。`);
380
+ }
381
+
382
+ function readNonEmptyString(value, name) {
383
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} 必须是非空字符串。`);
384
+ return value.trim();
385
+ }
386
+
387
+ function isPageSseAllowed(parsed) {
388
+ return parsed.streamMode !== 'non_stream' && parsed.streamingStrategy !== 'off';
389
+ }
390
+
391
+ function readPartialImages(value) {
392
+ const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2);
393
+ if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) {
394
+ throw new Error('--partial-images 必须是 1 到 3 的整数。');
395
+ }
396
+ return parsed;
397
+ }
398
+
399
  async function fetchWithTimeout(url, init) {
400
  const controller = new AbortController();
401
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
 
409
  function printUsage() {
410
  console.error('用法:edit-image.mjs [options] <image-path> <prompt>');
411
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
412
+ 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');
413
  console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
414
  }
415
 
416
+ let capabilities;
417
  try {
418
+ capabilities = await readCapabilities();
419
  } catch (error) {
420
  console.error(errorMessage(error));
421
  process.exit(1);
 
462
  formData.append('size', options.size);
463
  formData.append('quality', options.quality);
464
  formData.append('response_mode', options.responseMode);
465
+ if (options.streamMode) formData.append('stream_mode', options.streamMode);
466
+ if (options.streamingStrategy) formData.append('streaming_strategy', options.streamingStrategy);
467
+ if (options.partialImages) formData.append('partial_images', String(readPartialImages(options.partialImages)));
468
  formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
469
  return formData;
470
  }
471
 
472
+ function buildPageSseFormData() {
473
+ const formData = new FormData();
474
+ formData.append('mode', 'edit');
475
+ formData.append('prompt', prompt);
476
+ formData.append('model', options.model);
477
+ formData.append('size', options.size);
478
+ formData.append('quality', options.quality);
479
+ formData.append('response_mode', options.responseMode);
480
+ formData.append('clientRequestId', idempotencyKey);
481
+ formData.append('stream', 'true');
482
+ if (options.format) formData.append('output_format', readOutputFormat(options));
483
+ if (readOutputCompression(options) !== undefined) {
484
+ formData.append('output_compression', String(readOutputCompression(options)));
485
+ }
486
+ if (options.moderation) formData.append('moderation', options.moderation);
487
+ if (options.imageBackend) formData.append('image_backend', normalizeImageBackendForPage(options.imageBackend));
488
+ if (options.responsesModel) formData.append('responsesModel', readNonEmptyString(options.responsesModel, '--responses-model'));
489
+ if (options.thinking) formData.append('thinking', options.thinking);
490
+ if (options.promptOptimization !== undefined) {
491
+ formData.append('promptOptimization', String(readBooleanOption(options.promptOptimization, '--prompt-optimization')));
492
+ }
493
+ if (options.forceWeb !== undefined) formData.append('force_web', 'true');
494
+ if (options.streamMode) formData.append('stream_mode', options.streamMode);
495
+ if (options.streamingStrategy) formData.append('image_streaming_strategy', options.streamingStrategy);
496
+ if (options.partialImages) formData.append('partial_images', String(readPartialImages(options.partialImages)));
497
+ if (passwordHash) formData.append('passwordHash', passwordHash);
498
+ formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
499
+ return formData;
500
+ }
501
+
502
+ async function runPageSseEdit() {
503
+ assertPageSseReadyForEdit(capabilities);
504
+ const result = await postPageSse({
505
+ url: `${baseUrl}${PAGE_SSE_ENDPOINT}`,
506
+ formData: buildPageSseFormData(),
507
+ timeoutMs,
508
+ sseLogPath: options.sseLogPath,
509
+ errorMessage
510
+ });
511
+ console.log(
512
+ JSON.stringify(
513
+ {
514
+ ...formatPageSseOutput({
515
+ result,
516
+ baseUrl,
517
+ responseMode: options.responseMode,
518
+ defaultOutputFormat: 'png'
519
+ }),
520
+ routing: {
521
+ transport: 'page_sse',
522
+ endpoint: PAGE_SSE_ENDPOINT,
523
+ fallback_endpoint: '/api/agent/images/edit',
524
+ fallback_mode: 'manual_after_diagnosis'
525
+ }
526
+ },
527
+ null,
528
+ 2
529
+ )
530
+ );
531
+ }
532
+
533
  function mimeTypeForPath(filePath) {
534
  const ext = path.extname(filePath).toLowerCase();
535
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
 
540
  let lastResult;
541
  let lastRetryAfter = null;
542
 
543
+ if (routingGuidance.transport === 'page_sse') {
544
+ try {
545
+ await runPageSseEdit();
546
+ process.exit(0);
547
+ } catch (error) {
548
+ console.error(
549
+ JSON.stringify(
550
+ buildPageSseFailureOutput({
551
+ error,
552
+ fallbackEndpoint: '/api/agent/images/edit',
553
+ errorMessage
554
+ }),
555
+ null,
556
+ 2
557
+ )
558
+ );
559
+ process.exit(1);
560
+ }
561
+ }
562
+
563
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
564
  let response;
565
  let result;
skills/gpt-image-playground-agent/scripts/generate-image.mjs CHANGED
@@ -1,46 +1,51 @@
1
  #!/usr/bin/env node
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import { AGENT_ENDPOINTS, buildAgentJobResultPath } from '../../../src/lib/agent-api-paths.mjs';
5
  import {
6
- errorMessage,
7
- normalizeBaseUrl,
8
- normalizeOutputFormat,
9
- parseRetryAfterValue,
10
- readConfiguredPositiveInteger,
11
- readOptionValue,
12
- resolveSameOriginUrl,
13
- sleep
 
 
14
  } from './lib/script-utils.mjs';
 
 
 
15
 
16
  const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
17
  const RESPONSE_MODES = new Set(['path', 'base64', 'both']);
18
  const STREAMING_STRATEGIES = new Set([
19
- 'off',
20
- 'auto',
21
- 'openai-sse',
22
- 'newapi-keepalive-sse',
23
- 'responses-sse',
24
- 'force-sse'
25
  ]);
 
26
  const MIN_PARTIAL_IMAGES = 1;
27
  const MAX_PARTIAL_IMAGES = 3;
28
- const MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH = 128;
29
  const PAGE_SSE_ENDPOINT = '/api/images';
30
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
31
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
32
  const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
 
33
  let options;
34
  try {
35
- options = parseArgs(process.argv.slice(2));
36
  } catch (error) {
37
- console.error(errorMessage(error));
38
- printUsage();
39
- process.exit(2);
40
  }
41
  if (options.help) {
42
- printUsage();
43
- process.exit(0);
44
  }
45
 
46
  let prompt;
@@ -49,908 +54,1017 @@ let timeoutMs;
49
  let idempotencyKey;
50
  let requestBody;
51
  try {
52
- maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
53
- timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
54
- idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`;
55
- if (isNonBillableDryRun(options, contractCheck)) {
56
- if (!hasPromptSource(options)) {
57
- printUsage();
58
- process.exit(2);
59
- }
60
- requestBody = buildDryRunRequestBody(options);
61
- } else {
62
- prompt = readPrompt(options, { readPromptFile: !contractCheck });
63
- requestBody = buildRequestBody(prompt, options);
64
- }
 
 
 
 
 
 
 
65
  } catch (error) {
66
- console.error(errorMessage(error));
67
- printUsage();
68
- process.exit(2);
69
  }
70
 
71
  if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
72
- printUsage();
73
- process.exit(2);
74
  }
75
 
76
  let baseUrl;
77
  try {
78
- baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
79
  } catch (error) {
80
- console.error(errorMessage(error));
81
- process.exit(2);
82
  }
83
 
84
  if (isNonBillableDryRun(options, contractCheck)) {
85
- console.log(
86
- JSON.stringify(
87
- {
88
- ok: true,
89
- billable: false,
90
- dry_run: true,
91
- endpoint: dryRunEndpoint(requestBody, options.routeMode),
92
- route_mode: options.routeMode,
93
- routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode),
94
- idempotency_key: idempotencyKey,
95
- request: requestBody,
96
- next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
97
- },
98
- null,
99
- 2
100
- )
101
- );
102
- process.exit(0);
103
  }
104
 
105
- try {
106
- var capabilities = await readCapabilities();
107
- } catch (error) {
108
- if (isScriptError(error)) {
109
- console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
110
- process.exit(1);
111
- }
112
- console.error(errorMessage(error));
113
- process.exit(1);
114
- }
115
 
116
  if (contractCheck) {
117
- await runContractCheck(capabilities);
118
- process.exit(0);
119
  }
120
 
121
  try {
122
- if (shouldUseJobPolling(capabilities, options.routeMode)) {
123
- await runGenerateJob();
124
- } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
125
- try {
126
- const result = await runPageSseRequest();
127
- console.log(
128
- JSON.stringify(
129
- buildSuccessOutput(formatPageSseOutput(result), { transport: 'page_sse', endpoint: PAGE_SSE_ENDPOINT }),
130
- null,
131
- 2
132
- )
133
- );
134
- process.exit(0);
135
- } catch (error) {
136
- console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
137
- process.exit(1);
 
 
 
 
 
 
138
  }
139
- } else {
140
- await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } });
141
- }
142
  } catch (error) {
143
- if (isScriptError(error)) {
144
- console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
 
 
 
145
  process.exit(1);
146
- }
147
- console.error(errorMessage(error));
148
- process.exit(1);
149
  }
150
 
151
  function parseArgs(argv) {
152
- const parsed = {
153
- model: 'gpt-image-2',
154
- size: '1024x1024',
155
- quality: 'high',
156
- n: '1',
157
- format: 'png',
158
- responseMode: 'path',
159
- imageBackend: undefined,
160
- streamingStrategy: undefined,
161
- partialImages: undefined,
162
- timeoutMs: undefined,
163
- promptFile: undefined,
164
- idempotencyKey: undefined,
165
- routeMode: 'auto',
166
- dryRun: false,
167
- allowBillable: false,
168
- help: false,
169
- promptParts: []
170
- };
171
- for (let index = 0; index < argv.length; index += 1) {
172
- const arg = argv[index];
173
- if (arg === '--dry-run') parsed.dryRun = true;
174
- else if (arg === '--allow-billable') parsed.allowBillable = true;
175
- else if (arg === '--job') parsed.routeMode = 'job';
176
- else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent';
177
- else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
178
- else if (arg === '--help' || arg === '-h') parsed.help = true;
179
- else if (arg === '--contract-check') continue;
180
- else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
181
- else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
182
- else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
183
- else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
184
- else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
185
- else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
186
- else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
187
- else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
188
- else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
189
- else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
190
- else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
191
- else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
192
- else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
193
- else parsed.promptParts.push(arg);
194
- }
195
- return parsed;
 
 
 
 
196
  }
197
 
198
  function readPrompt(parsed, { readPromptFile }) {
199
- if (parsed.promptFile) {
200
- if (readPromptFile) {
201
- return fs.readFileSync(parsed.promptFile, 'utf8');
 
 
202
  }
203
- return parsed.promptParts.join(' ') || 'contract check';
204
- }
205
- return parsed.promptParts.join(' ');
206
  }
207
 
208
  function buildRequestBody(promptValue, parsed) {
209
- return addUpstreamStrategyFields(
210
- {
211
- prompt: promptValue || 'contract check',
212
- model: parsed.model,
213
- n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
214
- size: parsed.size,
215
- quality: parsed.quality,
216
- output_format: normalizeOutputFormat(parsed.format),
217
- response_mode: parsed.responseMode
218
- },
219
- parsed
220
- );
221
  }
222
 
223
  function buildDryRunRequestBody(parsed) {
224
- const body = addUpstreamStrategyFields(
225
- {
226
- model: parsed.model,
227
- n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
228
- size: parsed.size,
229
- quality: parsed.quality,
230
- output_format: normalizeOutputFormat(parsed.format),
231
- response_mode: parsed.responseMode
232
- },
233
- parsed
234
- );
235
- if (parsed.promptFile) {
236
- return { ...body, prompt_file: parsed.promptFile };
237
- }
238
- return { ...body, prompt: parsed.promptParts.join(' ') };
239
  }
240
 
241
  function addUpstreamStrategyFields(body, parsed) {
242
- validateUpstreamStrategyOptions(parsed);
243
- return {
244
- ...body,
245
- ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}),
246
- ...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}),
247
- ...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {})
248
- };
 
249
  }
250
 
251
  function validateUpstreamStrategyOptions(parsed) {
252
- if (!RESPONSE_MODES.has(parsed.responseMode)) {
253
- throw new Error('--response-mode 必须是 path、base64 或 both。');
254
- }
255
- if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
256
- throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
257
- }
258
- if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
259
- throw new Error('--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sseforce-sse。');
260
- }
261
- if (parsed.routeMode === 'page_sse' && parsed.streamingStrategy === 'off') {
262
- throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
263
- }
 
 
 
 
 
264
  }
265
 
266
  function readPartialImages(value) {
267
- const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2);
268
- if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) {
269
- throw new Error('--partial-images 必须是 1 到 3 的整数。');
270
- }
271
- return parsed;
272
  }
273
 
274
  function hasPromptSource(parsed) {
275
- return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
276
  }
277
 
278
  function isNonBillableDryRun(parsed, isContractCheck) {
279
- return parsed.dryRun || (!isContractCheck && !parsed.allowBillable);
280
  }
281
 
282
  function authHeaders() {
283
- if (token) return { Authorization: `Bearer ${token}` };
284
- if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
285
- return {};
286
  }
287
 
288
  function absoluteUrl(value) {
289
- if (typeof value !== 'string' || !value) return undefined;
290
- return new URL(value, `${baseUrl}/`).toString();
291
  }
292
 
293
  function enrichImageUrls(result) {
294
- if (!result || !Array.isArray(result.images)) return result;
295
- return {
296
- ...result,
297
- images: result.images.map((image) => ({
298
- ...image,
299
- ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}),
300
- ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {})
301
- }))
302
- };
303
  }
304
 
305
  function dryRunEndpoint(body, routeMode) {
306
- if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
307
- if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
308
- if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`;
309
- return isLargeGenerate(body) && isPageSseAllowed(body)
310
- ? `${baseUrl}${PAGE_SSE_ENDPOINT}`
311
- : `${baseUrl}${AGENT_ENDPOINTS.generate}`;
312
  }
313
 
314
  function buildGenerateRoutingGuidance(body, routeMode) {
315
- if (routeMode === 'job') {
316
- return {
317
- recommended_endpoint: AGENT_ENDPOINTS.create_generate_job,
318
- transport: 'agent_job_polling',
319
- strength: 'recommended',
320
- reason: 'Explicit --job requests use Agent job polling.'
321
- };
322
- }
323
- if (routeMode === 'page_sse' && !isPageSseAllowed(body)) {
324
- throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
325
- }
326
- if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) {
 
 
 
 
 
 
 
 
 
327
  return {
328
- recommended_endpoint: PAGE_SSE_ENDPOINT,
329
- transport: 'page_sse',
330
- strength: 'recommended',
331
- fallback_endpoint: AGENT_ENDPOINTS.generate,
332
- fallback_mode: 'manual_after_diagnosis',
333
- 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.'
334
  };
335
- }
336
- return {
337
- recommended_endpoint: AGENT_ENDPOINTS.generate,
338
- transport: 'agent_json',
339
- strength: 'default',
340
- reason: 'Normal single-image generate requests use the Agent JSON response contract.'
341
- };
342
  }
343
 
344
  async function readCapabilities() {
345
- const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, {
346
- headers: authHeaders(),
347
- timeoutMs
348
- });
349
- if (!response.ok) {
350
- throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`);
351
- }
352
- return result;
353
  }
354
 
355
- async function runGenerateRequest(options = {}) {
356
- let lastResult;
357
- let lastRetryAfter = null;
358
-
359
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
360
- const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
361
- method: 'POST',
362
- headers: {
363
- 'Content-Type': 'application/json',
364
- 'Idempotency-Key': idempotencyKey,
365
- ...authHeaders()
366
- },
367
- body: JSON.stringify(requestBody),
368
- timeoutMs
369
- });
370
 
371
- if (response.ok) {
372
- console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2));
373
- process.exit(0);
 
374
  }
 
375
 
376
- const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
377
- lastResult = result;
378
- lastRetryAfter = retryAfter;
379
- if (!shouldRetry(result) || attempt === maxAttempts) break;
380
- await sleep(retryAfter);
381
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
- console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
384
- process.exit(1);
385
  }
386
 
387
  async function runPageSseRequest() {
388
- const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`;
389
- const formData = buildPageSseFormData();
390
- const controller = new AbortController();
391
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
392
- try {
393
- let response;
394
  try {
395
- response = await fetch(url, {
396
- method: 'POST',
397
- body: formData,
398
- signal: controller.signal
399
- });
400
- } catch (error) {
401
- throw new Error(`请求失败:${url}。${errorMessage(error)}`);
402
- }
403
- try {
404
- const contentType = response.headers.get('content-type') || '';
405
- if (contentType.includes('text/event-stream')) {
406
- return await collectPageSseResult(response, controller.signal);
407
- }
408
- const text = await response.text();
409
- if (!response.ok) {
410
- throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
411
- }
412
- return parseJsonResponse(text, true, url);
413
- } catch (error) {
414
- if (controller.signal.aborted) {
415
- throw new Error(`请求失败:${url}。${errorMessage(error)}`);
416
- }
417
- throw error;
 
 
 
 
 
418
  }
419
- } finally {
420
- clearTimeout(timeout);
421
- }
422
  }
423
 
424
  function assertPageSseReady(capabilitiesValue) {
425
- const pageSse = capabilitiesValue?.agent_streaming?.page_sse;
426
- if (!supportsPageSse(capabilitiesValue)) {
427
- throw createScriptError(
428
- 'page_sse_unavailable',
429
- '大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。'
430
- );
431
- }
432
- if (pageSse?.auth?.required === true && !passwordHash) {
433
- throw createScriptError(
434
- 'page_sse_auth_required',
435
- '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。'
436
- );
437
- }
438
  }
439
 
440
  function buildPageSseFormData() {
441
- const formData = new FormData();
442
- assertPageSseClientRequestIdLength(idempotencyKey);
443
- formData.append('mode', 'generate');
444
- formData.append('prompt', requestBody.prompt);
445
- formData.append('model', requestBody.model);
446
- formData.append('n', String(requestBody.n));
447
- formData.append('size', requestBody.size);
448
- formData.append('quality', requestBody.quality);
449
- formData.append('output_format', requestBody.output_format);
450
- formData.append('response_mode', requestBody.response_mode);
451
- formData.append('clientRequestId', idempotencyKey);
452
- formData.append('stream', 'true');
453
- formData.append('partial_images', String(requestBody.partial_images || 2));
454
- if (requestBody.image_backend) formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend));
455
- if (requestBody.streaming_strategy) {
456
- formData.append('image_streaming_strategy', requestBody.streaming_strategy);
457
- }
458
- if (requestBody.background) formData.append('background', requestBody.background);
459
- if (requestBody.moderation) formData.append('moderation', requestBody.moderation);
460
- if (requestBody.output_compression !== undefined) {
461
- formData.append('output_compression', String(requestBody.output_compression));
462
- }
463
- if (passwordHash) formData.append('passwordHash', passwordHash);
464
- return formData;
 
 
465
  }
466
 
467
  function assertPageSseClientRequestIdLength(clientRequestId) {
468
- if (clientRequestId.length > MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH) {
469
- throw createScriptError(
470
- 'page_sse_client_request_id_too_long',
471
- `页面 SSE 的 clientRequestId 不能超过 ${MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH} 个字符;请缩短 Idempotency-Key。`
472
- );
473
- }
474
  }
475
 
476
  function formatPageSseOutput(result) {
477
- if (!result || !Array.isArray(result.images)) return result;
478
- return {
479
- ...result,
480
- images: result.images.map((image) => formatPageSseImage(image))
481
- };
482
  }
483
 
484
  function formatPageSseImage(image) {
485
- const output = { ...image };
486
- if (output.path) {
487
- output.absolute_path = absoluteUrl(output.path);
488
- if (requestBody.response_mode === 'path') {
489
- delete output.b64_json;
 
490
  }
491
- }
492
- return output;
 
 
 
 
 
 
 
 
 
 
 
493
  }
494
 
495
  function normalizeImageBackendForPage(value) {
496
- if (value === 'images') return 'images-api';
497
- if (value === 'responses') return 'responses-image-generation';
498
- return value;
499
  }
500
 
501
  function readPageSseClientRequestId(event) {
502
- if (typeof event.clientRequestId === 'string') return event.clientRequestId;
503
- if (typeof event.client_request_id === 'string') return event.client_request_id;
504
- return undefined;
505
  }
506
 
507
  function normalizePageSseImage(image, fallbackClientRequestId) {
508
- const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
509
- return {
510
- ...image,
511
- output_format: image.outputFormat || image.output_format || requestBody.output_format,
512
- ...(clientRequestId ? { clientRequestId } : {})
513
- };
514
  }
515
 
516
  function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
517
- if (!Array.isArray(doneImages) || doneImages.length === 0) {
518
- return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
519
- }
520
- const imageCount = Math.max(doneImages.length, completedImages.length);
521
- return Array.from({ length: imageCount }, (_, index) =>
522
- normalizePageSseImage({ ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, fallbackClientRequestId)
523
- );
 
 
 
524
  }
525
 
526
  async function collectPageSseResult(response, signal) {
527
- const reader = response.body?.getReader();
528
- if (!reader) throw new Error('页面 SSE 响应缺少 body。');
529
- const decoder = new TextDecoder();
530
- const state = { completedImages: [], usage: undefined, actualCost: undefined, doneReceived: false };
531
- let buffer = '';
532
- while (true) {
533
- const { done, value } = await readPageSseChunk(reader, signal);
534
- if (done) break;
535
- buffer += decoder.decode(value, { stream: true });
536
- const events = buffer.split(/\r?\n\r?\n/);
537
- buffer = events.pop() || '';
538
- for (const rawEvent of events) {
539
- applyPageSseEvent(state, rawEvent);
540
- }
541
- }
542
- buffer += decoder.decode();
543
- if (buffer.trim()) applyPageSseEvent(state, buffer);
544
- if (state.completedImages.length === 0) {
545
- throw new Error('页面 SSE 未返回最终图片。');
546
- }
547
- if (!state.doneReceived) {
548
- throw new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。');
549
- }
550
- return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
  }
552
 
553
  function readPageSseChunk(reader, signal) {
554
- if (!signal) return reader.read();
555
- if (signal.aborted) {
556
- return Promise.reject(new Error('请求超时。'));
557
- }
558
- return new Promise((resolve, reject) => {
559
- const onAbort = () => reject(new Error('请求超时。'));
560
- signal.addEventListener('abort', onAbort, { once: true });
561
- reader.read().then(resolve, reject).finally(() => {
562
- signal.removeEventListener('abort', onAbort);
 
 
 
 
563
  });
564
- });
565
  }
566
 
567
  function applyPageSseEvent(state, rawEvent) {
568
- const event = parsePageSseEvent(rawEvent);
569
- if (!event) return;
570
- if (event.type === 'error') {
571
- throw createPageSseStreamError(event);
572
- }
573
- if (event.type === 'completed' && event.filename) {
574
- state.completedImages.push(
575
- normalizePageSseImage(
576
- {
577
- filename: event.filename,
578
- b64_json: event.b64_json,
579
- path: event.path,
580
- output_format: event.outputFormat || event.output_format || requestBody.output_format
581
- },
582
- readPageSseClientRequestId(event)
583
- )
584
- );
585
- return;
586
- }
587
- if (event.type === 'done') {
588
- state.doneReceived = true;
589
- const clientRequestId = readPageSseClientRequestId(event);
590
- state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
591
- state.usage = event.usage;
592
- state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
593
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  }
595
 
596
  function formatPageSseError(value) {
597
- if (typeof value === 'string' && value.trim()) return value;
598
- if (value && typeof value === 'object') {
599
- if (typeof value.message === 'string' && value.message.trim()) return value.message;
600
- if (typeof value.code === 'string' && value.code.trim()) return value.code;
601
- try {
602
- return JSON.stringify(value);
603
- } catch {
604
- return '页面 SSE 返回错误事件。';
 
605
  }
606
- }
607
- return '页面 SSE 返回错误事件。';
608
  }
609
 
610
- function createPageSseStreamError(event) {
611
- const error = new Error(formatPageSseError(event.error));
612
- const status = readPageSseStreamStatus(event);
613
- if (Number.isInteger(status)) {
614
- error.streamStatus = status;
615
- }
616
- return error;
617
  }
618
 
619
  function readPageSseStreamStatus(event) {
620
- if (Number.isInteger(event.status)) return event.status;
621
- if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
622
- return event.error.status;
623
- }
624
- return undefined;
625
  }
626
 
627
  function parsePageSseEvent(rawEvent) {
628
- const lines = rawEvent.split(/\r?\n/);
629
- const data = lines
630
- .filter((line) => line.startsWith('data: '))
631
- .map((line) => line.slice(6))
632
- .join('\n')
633
- .trim();
634
- if (!data || data === '[DONE]') return undefined;
635
- try {
636
- return JSON.parse(data);
637
- } catch (error) {
638
- throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
639
- }
640
  }
641
 
642
  function buildSuccessOutput(result, routing) {
643
- return routing ? { ...result, routing } : result;
644
  }
645
 
646
  function buildPageSseFailureOutput(error) {
647
- if (isScriptError(error)) {
648
- return buildPageSseScriptFailure(error);
649
- }
650
- if (isPageSseRequestRejected(error)) {
651
- return buildPageSseRequestRejectedFailure(error);
652
- }
653
- return buildBillablePageSseFailure(error);
 
654
  }
655
 
656
  function buildPageSseRouting(fallbackMode) {
657
- return {
658
- transport: 'page_sse',
659
- endpoint: PAGE_SSE_ENDPOINT,
660
- fallback_endpoint: AGENT_ENDPOINTS.generate,
661
- fallback_mode: fallbackMode
662
- };
663
- }
664
-
665
- function buildPageSseScriptFailure(error) {
666
- return {
667
- ok: false,
668
- billable: false,
669
- error: {
670
- code: error.scriptCode,
671
- message: errorMessage(error)
672
- },
673
- routing: buildPageSseRouting('manual_after_diagnosis'),
674
- next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
675
- };
676
- }
677
-
678
- function buildPageSseRequestRejectedFailure(error) {
679
- return {
680
- ok: false,
681
- billable: false,
682
- error: {
683
- code: 'page_sse_request_rejected',
684
- status: error.status,
685
- message: errorMessage(error)
686
- },
687
- routing: buildPageSseRouting('fix_request_before_retry'),
688
- next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
689
- };
690
- }
691
-
692
- function buildBillablePageSseFailure(error) {
693
- return {
694
- ok: false,
695
- billable: true,
696
- error: {
697
- code: 'page_sse_failed',
698
- ...buildPageSseFailureStatus(error),
699
- message: errorMessage(error)
700
- },
701
- routing: buildPageSseRouting('manual_after_diagnosis'),
702
- next_step:
703
- '先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。'
704
- };
705
  }
706
 
707
- function buildPageSseFailureStatus(error) {
708
- if (error && typeof error === 'object') {
709
- if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
710
- if (Number.isInteger(error.status)) return { status: error.status };
711
- }
712
- return {};
 
 
 
 
 
 
713
  }
714
 
715
- async function runGenerateJob() {
716
- let lastResult;
717
- let lastRetryAfter = null;
718
-
719
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
720
- const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
721
- method: 'POST',
722
- headers: {
723
- 'Content-Type': 'application/json',
724
- 'Idempotency-Key': idempotencyKey,
725
- ...authHeaders()
726
- },
727
- body: JSON.stringify(requestBody),
728
- timeoutMs
729
- });
730
 
731
- if (response.ok) {
732
- const jobResult = await pollJobResult(result?.job);
733
- console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2));
734
- process.exit(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
735
  }
 
 
 
 
 
 
 
736
 
737
- const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
738
- lastResult = result;
739
- lastRetryAfter = retryAfter;
740
- if (!shouldRetry(result) || attempt === maxAttempts) break;
741
- await sleep(retryAfter);
742
- }
743
 
744
- console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
745
- process.exit(1);
 
 
 
 
 
 
746
  }
747
 
748
- async function pollJobResult(job) {
749
- if (!job || typeof job.id !== 'string') {
750
- throw new Error('创建 job 的响应缺少 job.id。');
751
- }
752
- const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || buildAgentJobResultPath(job.id), 'job.result_url');
753
- const deadlineMs = Date.now() + timeoutMs;
754
- let lastResult;
755
- let lastRetryAfter = job.retry_after_seconds || 1;
756
-
757
- while (Date.now() < deadlineMs) {
758
- const { response, result } = await fetchJson(resultUrl, {
759
- headers: authHeaders(),
760
- timeoutMs
761
- });
762
- if (response.ok) return result;
 
 
 
 
 
 
 
 
 
 
 
 
 
763
 
764
- const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter;
765
- lastResult = result;
766
- lastRetryAfter = retryAfter;
767
- if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break;
768
- await sleep(retryAfter);
769
- }
770
 
771
- console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
772
- process.exit(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  }
774
 
775
  async function runContractCheck(capabilitiesValue) {
776
- const checks = [];
777
- const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
778
- method: 'POST',
779
- headers: {
780
- 'Content-Type': 'application/json',
781
- ...authHeaders()
782
- },
783
- body: JSON.stringify(requestBody),
784
- timeoutMs
785
- });
786
- if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
787
- checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code });
788
- } else {
789
- console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
790
- process.exit(1);
791
- }
792
-
793
- if (supportsJobPolling(capabilitiesValue)) {
794
- const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
795
- method: 'POST',
796
- headers: {
797
- 'Content-Type': 'application/json',
798
- ...authHeaders()
799
- },
800
- body: JSON.stringify(requestBody),
801
- timeoutMs
802
- });
803
- if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') {
804
- console.error(
805
- JSON.stringify({ ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, null, 2)
806
- );
807
- process.exit(1);
808
- }
809
- checks.push({
810
- endpoint: AGENT_ENDPOINTS.create_generate_job,
811
- status: jobCheck.response.status,
812
- error_code: jobCheck.result.error.code
813
  });
814
- }
 
 
 
 
 
815
 
816
- console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
817
  }
818
 
819
  async function fetchJson(url, init) {
820
- try {
821
- const response = await fetchWithTimeout(url, init);
822
- const text = await response.text();
823
- const result = parseJsonResponse(text, response.ok, url);
824
- return { response, result, text };
825
- } catch (error) {
826
- const message = errorMessage(error);
827
- if (message.startsWith(`请求失败:${url}。`)) {
828
- throw error;
 
 
829
  }
830
- throw new Error(`请求失败:${url}。${message}`);
831
- }
832
  }
833
 
834
  async function fetchWithTimeout(url, init) {
835
- const controller = new AbortController();
836
- const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs);
837
- try {
838
- const fetchInit = { ...init };
839
- delete fetchInit.timeoutMs;
840
- return await fetch(url, { ...fetchInit, signal: controller.signal });
841
- } catch (error) {
842
- const message = errorMessage(error);
843
- throw new Error(`请求失败:${url}。${message}`);
844
- } finally {
845
- clearTimeout(timeout);
846
- }
847
  }
848
 
849
  function readErrorFromJsonText(text) {
850
- let result;
851
- try {
852
- result = parseJsonResponse(text, false, '');
853
- } catch {
 
 
 
 
854
  return undefined;
855
- }
856
- if (typeof result?.error === 'string') return result.error;
857
- if (typeof result?.error?.message === 'string') return result.error.message;
858
- return undefined;
859
  }
860
 
861
  function parseJsonResponse(text, isOk, url) {
862
- if (!text) return null;
863
- try {
864
- return JSON.parse(text);
865
- } catch (error) {
866
- if (!isOk) return null;
867
- const message = errorMessage(error);
868
- throw new Error(`响应不是有效 JSON:${url}。${message}`);
869
- }
870
  }
871
 
872
  function shouldRetry(result) {
873
- return Boolean(result?.error?.retryable);
874
  }
875
 
876
  function supportsJobPolling(capabilitiesValue) {
877
- return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling');
 
 
878
  }
879
 
880
  function supportsPageSse(capabilitiesValue) {
881
- return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true);
882
  }
883
 
884
  function shouldUseJobPolling(capabilitiesValue, routeMode) {
885
- if (routeMode !== 'job') return false;
886
- if (!supportsJobPolling(capabilitiesValue)) {
887
- throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
888
- }
889
- return true;
890
  }
891
 
892
  function shouldUsePageSse(capabilitiesValue, request, routeMode) {
893
- if (routeMode === 'agent' || routeMode === 'job') return false;
894
- if (!isPageSseAllowed(request)) {
895
- if (routeMode === 'page_sse') {
896
- throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
 
 
 
 
 
 
897
  }
898
  return false;
899
- }
900
- if (routeMode === 'page_sse' || isLargeGenerate(request)) {
901
- assertPageSseReady(capabilitiesValue);
902
- return true;
903
- }
904
- return false;
905
  }
906
 
907
  function isLargeGenerate(request) {
908
- return readMaxImageEdge(request.size) > 2048;
909
  }
910
 
911
  function isPageSseAllowed(request) {
912
- return request.streaming_strategy !== 'off';
913
- }
914
-
915
- function readMaxImageEdge(size) {
916
- if (typeof size !== 'string') return 0;
917
- const match = size.match(/^(\d+)x(\d+)$/);
918
- if (!match) return 0;
919
- return Math.max(Number(match[1]), Number(match[2]));
920
  }
921
 
922
  function createScriptError(code, message) {
923
- const error = new Error(message);
924
- error.scriptCode = code;
925
- return error;
926
  }
927
 
928
  function isScriptError(error) {
929
- return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
930
  }
931
 
932
  function createPageSseHttpError(status, detail) {
933
- const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`;
934
- const error = new Error(message);
935
- error.status = status;
936
- return error;
937
  }
938
 
939
  function isPageSseRequestRejected(error) {
940
- return Boolean(
941
- error &&
942
- typeof error === 'object' &&
943
- Number.isInteger(error.status) &&
944
- error.status >= 400 &&
945
- error.status < 500
946
- );
947
  }
948
 
949
  function printUsage() {
950
- console.error('用法:generate-image.mjs [options] <prompt>');
951
- console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
952
- console.error(
953
- '常用参数:--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(兼容别名)'
954
- );
955
- console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check');
 
 
956
  }
 
1
  #!/usr/bin/env node
2
+ import { AGENT_ENDPOINTS, buildAgentJobResultPath } from './lib/agent-api-paths.mjs';
 
 
3
  import {
4
+ errorMessage,
5
+ assertValidImageSizeForModel,
6
+ normalizeBaseUrl,
7
+ normalizeOutputFormat,
8
+ parseRetryAfterValue,
9
+ readConfiguredPositiveInteger,
10
+ readMaxImageEdge,
11
+ readOptionValue,
12
+ resolveSameOriginUrl,
13
+ sleep
14
  } from './lib/script-utils.mjs';
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
 
19
  const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
20
  const RESPONSE_MODES = new Set(['path', 'base64', 'both']);
21
  const STREAMING_STRATEGIES = new Set([
22
+ 'off',
23
+ 'auto',
24
+ 'openai-sse',
25
+ 'newapi-keepalive-sse',
26
+ 'responses-sse',
27
+ 'force-sse'
28
  ]);
29
+ const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
30
  const MIN_PARTIAL_IMAGES = 1;
31
  const MAX_PARTIAL_IMAGES = 3;
32
+ const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
33
  const PAGE_SSE_ENDPOINT = '/api/images';
34
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
35
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
36
  const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
37
+ let pageSseClientRequestIdMaxLength = DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH;
38
  let options;
39
  try {
40
+ options = parseArgs(process.argv.slice(2));
41
  } catch (error) {
42
+ console.error(errorMessage(error));
43
+ printUsage();
44
+ process.exit(2);
45
  }
46
  if (options.help) {
47
+ printUsage();
48
+ process.exit(0);
49
  }
50
 
51
  let prompt;
 
54
  let idempotencyKey;
55
  let requestBody;
56
  try {
57
+ maxAttempts = readConfiguredPositiveInteger(
58
+ process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS,
59
+ 'GPT_IMAGE_AGENT_MAX_ATTEMPTS',
60
+ 3
61
+ );
62
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
63
+ idempotencyKey =
64
+ options.idempotencyKey ||
65
+ process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY ||
66
+ `agent-generate-${crypto.randomUUID()}`;
67
+ if (isNonBillableDryRun(options, contractCheck)) {
68
+ if (!hasPromptSource(options)) {
69
+ printUsage();
70
+ process.exit(2);
71
+ }
72
+ requestBody = buildDryRunRequestBody(options);
73
+ } else {
74
+ prompt = readPrompt(options, { readPromptFile: !contractCheck });
75
+ requestBody = buildRequestBody(prompt, options);
76
+ }
77
  } catch (error) {
78
+ console.error(errorMessage(error));
79
+ printUsage();
80
+ process.exit(2);
81
  }
82
 
83
  if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
84
+ printUsage();
85
+ process.exit(2);
86
  }
87
 
88
  let baseUrl;
89
  try {
90
+ baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
91
  } catch (error) {
92
+ console.error(errorMessage(error));
93
+ process.exit(2);
94
  }
95
 
96
  if (isNonBillableDryRun(options, contractCheck)) {
97
+ console.log(
98
+ JSON.stringify(
99
+ {
100
+ ok: true,
101
+ billable: false,
102
+ dry_run: true,
103
+ endpoint: dryRunEndpoint(requestBody, options.routeMode),
104
+ route_mode: options.routeMode,
105
+ routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode),
106
+ idempotency_key: idempotencyKey,
107
+ request: requestBody,
108
+ next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
109
+ },
110
+ null,
111
+ 2
112
+ )
113
+ );
114
+ process.exit(0);
115
  }
116
 
117
+ const capabilities = await readCapabilitiesOrExit();
118
+ applyCapabilitiesRuntimeValues(capabilities);
 
 
 
 
 
 
 
 
119
 
120
  if (contractCheck) {
121
+ await runContractCheck(capabilities);
122
+ process.exit(0);
123
  }
124
 
125
  try {
126
+ if (shouldUseJobPolling(capabilities, options.routeMode)) {
127
+ await runGenerateJob();
128
+ } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
129
+ try {
130
+ const result = await runPageSseRequest();
131
+ console.log(
132
+ JSON.stringify(
133
+ buildSuccessOutput(formatPageSseOutput(result), {
134
+ transport: 'page_sse',
135
+ endpoint: PAGE_SSE_ENDPOINT
136
+ }),
137
+ null,
138
+ 2
139
+ )
140
+ );
141
+ process.exit(0);
142
+ } catch (error) {
143
+ console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
144
+ process.exit(1);
145
+ }
146
+ } else {
147
+ await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } });
148
  }
 
 
 
149
  } catch (error) {
150
+ if (isScriptError(error)) {
151
+ console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
152
+ process.exit(1);
153
+ }
154
+ console.error(errorMessage(error));
155
  process.exit(1);
 
 
 
156
  }
157
 
158
  function parseArgs(argv) {
159
+ const parsed = {
160
+ model: 'gpt-image-2',
161
+ size: '1024x1024',
162
+ quality: 'high',
163
+ n: '1',
164
+ format: 'png',
165
+ responseMode: 'path',
166
+ imageBackend: undefined,
167
+ streamMode: undefined,
168
+ streamingStrategy: undefined,
169
+ partialImages: undefined,
170
+ sseLogPath: undefined,
171
+ timeoutMs: undefined,
172
+ promptFile: undefined,
173
+ idempotencyKey: undefined,
174
+ routeMode: 'auto',
175
+ dryRun: false,
176
+ allowBillable: false,
177
+ help: false,
178
+ promptParts: []
179
+ };
180
+ for (let index = 0; index < argv.length; index += 1) {
181
+ const arg = argv[index];
182
+ if (arg === '--dry-run') parsed.dryRun = true;
183
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
184
+ else if (arg === '--job') parsed.routeMode = 'job';
185
+ else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent';
186
+ else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
187
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
188
+ else if (arg === '--contract-check') continue;
189
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
190
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
191
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
192
+ else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
193
+ else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
194
+ else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
195
+ else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
196
+ else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg);
197
+ else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
198
+ else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
199
+ else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
200
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
201
+ else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
202
+ else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
203
+ else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
204
+ else parsed.promptParts.push(arg);
205
+ }
206
+ return parsed;
207
  }
208
 
209
  function readPrompt(parsed, { readPromptFile }) {
210
+ if (parsed.promptFile) {
211
+ if (readPromptFile) {
212
+ return fs.readFileSync(parsed.promptFile, 'utf8');
213
+ }
214
+ return parsed.promptParts.join(' ') || 'contract check';
215
  }
216
+ return parsed.promptParts.join(' ');
 
 
217
  }
218
 
219
  function buildRequestBody(promptValue, parsed) {
220
+ return addUpstreamStrategyFields(
221
+ {
222
+ prompt: promptValue || 'contract check',
223
+ model: parsed.model,
224
+ n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
225
+ size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
226
+ quality: parsed.quality,
227
+ output_format: normalizeOutputFormat(parsed.format),
228
+ response_mode: parsed.responseMode
229
+ },
230
+ parsed
231
+ );
232
  }
233
 
234
  function buildDryRunRequestBody(parsed) {
235
+ const body = addUpstreamStrategyFields(
236
+ {
237
+ model: parsed.model,
238
+ n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
239
+ size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
240
+ quality: parsed.quality,
241
+ output_format: normalizeOutputFormat(parsed.format),
242
+ response_mode: parsed.responseMode
243
+ },
244
+ parsed
245
+ );
246
+ if (parsed.promptFile) {
247
+ return { ...body, prompt_file: parsed.promptFile };
248
+ }
249
+ return { ...body, prompt: parsed.promptParts.join(' ') };
250
  }
251
 
252
  function addUpstreamStrategyFields(body, parsed) {
253
+ validateUpstreamStrategyOptions(parsed);
254
+ return {
255
+ ...body,
256
+ ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}),
257
+ ...(parsed.streamMode ? { stream_mode: parsed.streamMode } : {}),
258
+ ...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}),
259
+ ...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {})
260
+ };
261
  }
262
 
263
  function validateUpstreamStrategyOptions(parsed) {
264
+ if (!RESPONSE_MODES.has(parsed.responseMode)) {
265
+ throw new Error('--response-mode 必须是 path、base64 或 both。');
266
+ }
267
+ if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
268
+ throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
269
+ }
270
+ if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) {
271
+ throw new Error('--stream-mode 必须是 auto、streamnon_stream。');
272
+ }
273
+ if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
274
+ throw new Error(
275
+ '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'
276
+ );
277
+ }
278
+ if (parsed.routeMode === 'page_sse' && (parsed.streamingStrategy === 'off' || parsed.streamMode === 'non_stream')) {
279
+ throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。');
280
+ }
281
  }
282
 
283
  function readPartialImages(value) {
284
+ const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2);
285
+ if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) {
286
+ throw new Error('--partial-images 必须是 1 到 3 的整数。');
287
+ }
288
+ return parsed;
289
  }
290
 
291
  function hasPromptSource(parsed) {
292
+ return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
293
  }
294
 
295
  function isNonBillableDryRun(parsed, isContractCheck) {
296
+ return parsed.dryRun || (!isContractCheck && !parsed.allowBillable);
297
  }
298
 
299
  function authHeaders() {
300
+ if (token) return { Authorization: `Bearer ${token}` };
301
+ if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
302
+ return {};
303
  }
304
 
305
  function absoluteUrl(value) {
306
+ if (typeof value !== 'string' || !value) return undefined;
307
+ return new URL(value, `${baseUrl}/`).toString();
308
  }
309
 
310
  function enrichImageUrls(result) {
311
+ if (!result || !Array.isArray(result.images)) return result;
312
+ return {
313
+ ...result,
314
+ images: result.images.map((image) => ({
315
+ ...image,
316
+ ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}),
317
+ ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {})
318
+ }))
319
+ };
320
  }
321
 
322
  function dryRunEndpoint(body, routeMode) {
323
+ if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
324
+ if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
325
+ if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`;
326
+ return isLargeGenerate(body) && isPageSseAllowed(body)
327
+ ? `${baseUrl}${PAGE_SSE_ENDPOINT}`
328
+ : `${baseUrl}${AGENT_ENDPOINTS.generate}`;
329
  }
330
 
331
  function buildGenerateRoutingGuidance(body, routeMode) {
332
+ if (routeMode === 'job') {
333
+ return {
334
+ recommended_endpoint: AGENT_ENDPOINTS.create_generate_job,
335
+ transport: 'agent_job_polling',
336
+ strength: 'recommended',
337
+ reason: 'Explicit --job requests use Agent job polling.'
338
+ };
339
+ }
340
+ if (routeMode === 'page_sse' && !isPageSseAllowed(body)) {
341
+ throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。');
342
+ }
343
+ if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) {
344
+ return {
345
+ recommended_endpoint: PAGE_SSE_ENDPOINT,
346
+ transport: 'page_sse',
347
+ strength: 'recommended',
348
+ fallback_endpoint: AGENT_ENDPOINTS.generate,
349
+ fallback_mode: 'manual_after_diagnosis',
350
+ 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.'
351
+ };
352
+ }
353
  return {
354
+ recommended_endpoint: AGENT_ENDPOINTS.generate,
355
+ transport: 'agent_json',
356
+ strength: 'default',
357
+ reason: 'Normal single-image generate requests use the Agent JSON response contract.'
 
 
358
  };
 
 
 
 
 
 
 
359
  }
360
 
361
  async function readCapabilities() {
362
+ const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, {
363
+ headers: authHeaders(),
364
+ timeoutMs
365
+ });
366
+ if (!response.ok) {
367
+ throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`);
368
+ }
369
+ return result;
370
  }
371
 
372
+ async function readCapabilitiesOrExit() {
373
+ try {
374
+ return await readCapabilities();
375
+ } catch (error) {
376
+ if (isScriptError(error)) {
377
+ console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
378
+ process.exit(1);
379
+ }
380
+ console.error(errorMessage(error));
381
+ process.exit(1);
382
+ }
383
+ }
 
 
 
384
 
385
+ function applyCapabilitiesRuntimeValues(capabilitiesValue) {
386
+ const maxLength = capabilitiesValue?.agent_streaming?.page_sse?.client_request_id?.max_length;
387
+ if (Number.isSafeInteger(maxLength) && maxLength > 0) {
388
+ pageSseClientRequestIdMaxLength = maxLength;
389
  }
390
+ }
391
 
392
+ async function runGenerateRequest(options = {}) {
393
+ let lastResult;
394
+ let lastRetryAfter = null;
395
+
396
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
397
+ const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
398
+ method: 'POST',
399
+ headers: {
400
+ 'Content-Type': 'application/json',
401
+ 'Idempotency-Key': idempotencyKey,
402
+ ...authHeaders()
403
+ },
404
+ body: JSON.stringify(requestBody),
405
+ timeoutMs
406
+ });
407
+
408
+ if (response.ok) {
409
+ console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2));
410
+ process.exit(0);
411
+ }
412
+
413
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
414
+ lastResult = result;
415
+ lastRetryAfter = retryAfter;
416
+ if (!shouldRetry(result) || attempt === maxAttempts) break;
417
+ await sleep(retryAfter);
418
+ }
419
 
420
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
421
+ process.exit(1);
422
  }
423
 
424
  async function runPageSseRequest() {
425
+ const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`;
426
+ const formData = buildPageSseFormData();
427
+ const controller = new AbortController();
428
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
 
 
429
  try {
430
+ let response;
431
+ try {
432
+ response = await fetch(url, {
433
+ method: 'POST',
434
+ body: formData,
435
+ signal: controller.signal
436
+ });
437
+ } catch (error) {
438
+ throw new Error(`请求失败:${url}。${errorMessage(error)}`);
439
+ }
440
+ try {
441
+ const contentType = response.headers.get('content-type') || '';
442
+ if (contentType.includes('text/event-stream')) {
443
+ return await collectPageSseResult(response, controller.signal);
444
+ }
445
+ const text = await response.text();
446
+ if (!response.ok) {
447
+ throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
448
+ }
449
+ return parseJsonResponse(text, true, url);
450
+ } catch (error) {
451
+ if (controller.signal.aborted) {
452
+ throw new Error(`请求失败:${url}。${errorMessage(error)}`);
453
+ }
454
+ throw error;
455
+ }
456
+ } finally {
457
+ clearTimeout(timeout);
458
  }
 
 
 
459
  }
460
 
461
  function assertPageSseReady(capabilitiesValue) {
462
+ const pageSse = capabilitiesValue?.agent_streaming?.page_sse;
463
+ if (!supportsPageSse(capabilitiesValue)) {
464
+ throw createScriptError(
465
+ 'page_sse_unavailable',
466
+ '大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。'
467
+ );
468
+ }
469
+ if (pageSse?.auth?.required === true && !passwordHash) {
470
+ throw createScriptError(
471
+ 'page_sse_auth_required',
472
+ '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。'
473
+ );
474
+ }
475
  }
476
 
477
  function buildPageSseFormData() {
478
+ const formData = new FormData();
479
+ assertPageSseClientRequestIdLength(idempotencyKey);
480
+ formData.append('mode', 'generate');
481
+ formData.append('prompt', requestBody.prompt);
482
+ formData.append('model', requestBody.model);
483
+ formData.append('n', String(requestBody.n));
484
+ formData.append('size', requestBody.size);
485
+ formData.append('quality', requestBody.quality);
486
+ formData.append('output_format', requestBody.output_format);
487
+ formData.append('response_mode', requestBody.response_mode);
488
+ formData.append('clientRequestId', idempotencyKey);
489
+ formData.append('stream', 'true');
490
+ if (requestBody.stream_mode) formData.append('stream_mode', requestBody.stream_mode);
491
+ formData.append('partial_images', String(requestBody.partial_images || 2));
492
+ if (requestBody.image_backend)
493
+ formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend));
494
+ if (requestBody.streaming_strategy) {
495
+ formData.append('image_streaming_strategy', requestBody.streaming_strategy);
496
+ }
497
+ if (requestBody.background) formData.append('background', requestBody.background);
498
+ if (requestBody.moderation) formData.append('moderation', requestBody.moderation);
499
+ if (requestBody.output_compression !== undefined) {
500
+ formData.append('output_compression', String(requestBody.output_compression));
501
+ }
502
+ if (passwordHash) formData.append('passwordHash', passwordHash);
503
+ return formData;
504
  }
505
 
506
  function assertPageSseClientRequestIdLength(clientRequestId) {
507
+ if (clientRequestId.length > pageSseClientRequestIdMaxLength) {
508
+ throw createScriptError(
509
+ 'page_sse_client_request_id_too_long',
510
+ `页面 SSE 的 clientRequestId 不能超过 ${pageSseClientRequestIdMaxLength} 个字符;请缩短 Idempotency-Key。`
511
+ );
512
+ }
513
  }
514
 
515
  function formatPageSseOutput(result) {
516
+ if (!result || !Array.isArray(result.images)) return result;
517
+ return {
518
+ ...result,
519
+ images: result.images.map((image) => formatPageSseImage(image))
520
+ };
521
  }
522
 
523
  function formatPageSseImage(image) {
524
+ const output = { ...image };
525
+ if (output.path) {
526
+ output.absolute_path = absoluteUrl(output.path);
527
+ if (requestBody.response_mode === 'path') {
528
+ delete output.b64_json;
529
+ }
530
  }
531
+ return output;
532
+ }
533
+
534
+ function createPageSseState() {
535
+ return {
536
+ completedImages: [],
537
+ usage: undefined,
538
+ actualCost: undefined,
539
+ doneReceived: false,
540
+ completedEventCount: 0,
541
+ partialImageCount: 0,
542
+ lastEventType: undefined
543
+ };
544
  }
545
 
546
  function normalizeImageBackendForPage(value) {
547
+ if (value === 'images') return 'images-api';
548
+ if (value === 'responses') return 'responses-image-generation';
549
+ return value;
550
  }
551
 
552
  function readPageSseClientRequestId(event) {
553
+ if (typeof event.clientRequestId === 'string') return event.clientRequestId;
554
+ if (typeof event.client_request_id === 'string') return event.client_request_id;
555
+ return undefined;
556
  }
557
 
558
  function normalizePageSseImage(image, fallbackClientRequestId) {
559
+ const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
560
+ return {
561
+ ...image,
562
+ output_format: image.outputFormat || image.output_format || requestBody.output_format,
563
+ ...(clientRequestId ? { clientRequestId } : {})
564
+ };
565
  }
566
 
567
  function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
568
+ if (!Array.isArray(doneImages) || doneImages.length === 0) {
569
+ return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
570
+ }
571
+ const imageCount = Math.max(doneImages.length, completedImages.length);
572
+ return Array.from({ length: imageCount }, (_, index) =>
573
+ normalizePageSseImage(
574
+ { ...(completedImages[index] || {}), ...(doneImages[index] || {}) },
575
+ fallbackClientRequestId
576
+ )
577
+ );
578
  }
579
 
580
  async function collectPageSseResult(response, signal) {
581
+ const reader = response.body?.getReader();
582
+ if (!reader) throw new Error('页面 SSE 响应缺少 body。');
583
+ const decoder = new TextDecoder();
584
+ const state = createPageSseState();
585
+ let buffer = '';
586
+ while (true) {
587
+ const { done, value } = await readPageSseChunk(reader, signal);
588
+ if (done) break;
589
+ buffer += decoder.decode(value, { stream: true });
590
+ const events = buffer.split(/\r?\n\r?\n/);
591
+ buffer = events.pop() || '';
592
+ for (const rawEvent of events) {
593
+ appendPageSseLog(rawEvent);
594
+ applyPageSseEvent(state, rawEvent);
595
+ }
596
+ }
597
+ buffer += decoder.decode();
598
+ if (buffer.trim()) {
599
+ appendPageSseLog(buffer);
600
+ applyPageSseEvent(state, buffer);
601
+ }
602
+ if (state.completedImages.length === 0) {
603
+ throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state);
604
+ }
605
+ if (!state.doneReceived) {
606
+ throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state);
607
+ }
608
+ return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) };
609
+ }
610
+
611
+ function appendPageSseLog(rawEvent) {
612
+ if (!options.sseLogPath || !rawEvent.trim()) return;
613
+ try {
614
+ fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true });
615
+ fs.appendFileSync(options.sseLogPath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`);
616
+ } catch (error) {
617
+ console.warn(`SSE log write failed: ${errorMessage(error)}`);
618
+ }
619
  }
620
 
621
  function readPageSseChunk(reader, signal) {
622
+ if (!signal) return reader.read();
623
+ if (signal.aborted) {
624
+ return Promise.reject(new Error('请求超时。'));
625
+ }
626
+ return new Promise((resolve, reject) => {
627
+ const onAbort = () => reject(new Error('请求超时。'));
628
+ signal.addEventListener('abort', onAbort, { once: true });
629
+ reader
630
+ .read()
631
+ .then(resolve, reject)
632
+ .finally(() => {
633
+ signal.removeEventListener('abort', onAbort);
634
+ });
635
  });
 
636
  }
637
 
638
  function applyPageSseEvent(state, rawEvent) {
639
+ const event = parsePageSseEvent(rawEvent);
640
+ if (!event) return;
641
+ const eventType = readPageSseEventType(event);
642
+ state.lastEventType = eventType;
643
+ if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1;
644
+ if (event.type === 'error') {
645
+ throw createPageSseStreamError(event, state);
646
+ }
647
+ if (event.type === 'completed' && event.filename) {
648
+ state.completedEventCount += 1;
649
+ state.completedImages.push(
650
+ normalizePageSseImage(
651
+ {
652
+ filename: event.filename,
653
+ b64_json: event.b64_json,
654
+ path: event.path,
655
+ output_format: event.outputFormat || event.output_format || requestBody.output_format
656
+ },
657
+ readPageSseClientRequestId(event)
658
+ )
659
+ );
660
+ return;
661
+ }
662
+ if (event.type === 'done') {
663
+ state.doneReceived = true;
664
+ const clientRequestId = readPageSseClientRequestId(event);
665
+ state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
666
+ state.usage = event.usage;
667
+ state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
668
+ }
669
+ }
670
+
671
+ function readPageSseEventType(event) {
672
+ if (typeof event.type === 'string' && event.type.trim()) return event.type;
673
+ if (typeof event.event === 'string' && event.event.trim()) return event.event;
674
+ return undefined;
675
+ }
676
+
677
+ function isPartialPageSseEvent(event, eventType) {
678
+ if (typeof eventType === 'string' && eventType.includes('partial_image')) return true;
679
+ return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64);
680
  }
681
 
682
  function formatPageSseError(value) {
683
+ if (typeof value === 'string' && value.trim()) return value;
684
+ if (value && typeof value === 'object') {
685
+ if (typeof value.message === 'string' && value.message.trim()) return value.message;
686
+ if (typeof value.code === 'string' && value.code.trim()) return value.code;
687
+ try {
688
+ return JSON.stringify(value);
689
+ } catch {
690
+ return '页面 SSE 返回错误事件。';
691
+ }
692
  }
693
+ return '页面 SSE 返回错误事件。';
 
694
  }
695
 
696
+ function createPageSseStreamError(event, state) {
697
+ const error = new Error(formatPageSseError(event.error));
698
+ const status = readPageSseStreamStatus(event);
699
+ if (Number.isInteger(status)) {
700
+ error.streamStatus = status;
701
+ }
702
+ return withPageSseDiagnostics(error, state);
703
  }
704
 
705
  function readPageSseStreamStatus(event) {
706
+ if (Number.isInteger(event.status)) return event.status;
707
+ if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
708
+ return event.error.status;
709
+ }
710
+ return undefined;
711
  }
712
 
713
  function parsePageSseEvent(rawEvent) {
714
+ const lines = rawEvent.split(/\r?\n/);
715
+ const data = lines
716
+ .filter((line) => line.startsWith('data: '))
717
+ .map((line) => line.slice(6))
718
+ .join('\n')
719
+ .trim();
720
+ if (!data || data === '[DONE]') return undefined;
721
+ try {
722
+ return JSON.parse(data);
723
+ } catch (error) {
724
+ throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
725
+ }
726
  }
727
 
728
  function buildSuccessOutput(result, routing) {
729
+ return routing ? { ...result, routing } : result;
730
  }
731
 
732
  function buildPageSseFailureOutput(error) {
733
+ const diagnostics = readPageSseDiagnostics(error);
734
+ if (isScriptError(error)) {
735
+ return buildPageSseScriptFailure(error, diagnostics);
736
+ }
737
+ if (isPageSseRequestRejected(error)) {
738
+ return buildPageSseRequestRejectedFailure(error, diagnostics);
739
+ }
740
+ return buildBillablePageSseFailure(error, diagnostics);
741
  }
742
 
743
  function buildPageSseRouting(fallbackMode) {
744
+ return {
745
+ transport: 'page_sse',
746
+ endpoint: PAGE_SSE_ENDPOINT,
747
+ fallback_endpoint: AGENT_ENDPOINTS.generate,
748
+ fallback_mode: fallbackMode
749
+ };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  }
751
 
752
+ function buildPageSseScriptFailure(error, diagnostics) {
753
+ return {
754
+ ok: false,
755
+ billable: false,
756
+ error: {
757
+ code: error.scriptCode,
758
+ message: errorMessage(error),
759
+ ...(diagnostics ? { diagnostics } : {})
760
+ },
761
+ routing: buildPageSseRouting('manual_after_diagnosis'),
762
+ next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
763
+ };
764
  }
765
 
766
+ function buildPageSseRequestRejectedFailure(error, diagnostics) {
767
+ return {
768
+ ok: false,
769
+ billable: false,
770
+ error: {
771
+ code: 'page_sse_request_rejected',
772
+ status: error.status,
773
+ message: errorMessage(error),
774
+ ...(diagnostics ? { diagnostics } : {})
775
+ },
776
+ routing: buildPageSseRouting('fix_request_before_retry'),
777
+ next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
778
+ };
779
+ }
 
780
 
781
+ function buildBillablePageSseFailure(error, diagnostics) {
782
+ return {
783
+ ok: false,
784
+ billable: true,
785
+ error: {
786
+ code: 'page_sse_failed',
787
+ ...buildPageSseFailureStatus(error),
788
+ message: errorMessage(error),
789
+ ...(diagnostics ? { diagnostics } : {})
790
+ },
791
+ routing: buildPageSseRouting('manual_after_diagnosis'),
792
+ next_step: '先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。'
793
+ };
794
+ }
795
+
796
+ function buildPageSseFailureStatus(error) {
797
+ if (error && typeof error === 'object') {
798
+ if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
799
+ if (Number.isInteger(error.status)) return { status: error.status };
800
  }
801
+ return {};
802
+ }
803
+
804
+ function withPageSseDiagnostics(error, state) {
805
+ error.pageSseDiagnostics = buildPageSseDiagnostics(state);
806
+ return error;
807
+ }
808
 
809
+ function readPageSseDiagnostics(error) {
810
+ if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined;
811
+ return error.pageSseDiagnostics;
812
+ }
 
 
813
 
814
+ function buildPageSseDiagnostics(state) {
815
+ return {
816
+ partial_image_count: state.partialImageCount,
817
+ completed_event_count: state.completedEventCount,
818
+ done_received: state.doneReceived,
819
+ final_image_count: state.completedImages.length,
820
+ ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {})
821
+ };
822
  }
823
 
824
+ async function runGenerateJob() {
825
+ let lastResult;
826
+ let lastRetryAfter = null;
827
+
828
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
829
+ const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
830
+ method: 'POST',
831
+ headers: {
832
+ 'Content-Type': 'application/json',
833
+ 'Idempotency-Key': idempotencyKey,
834
+ ...authHeaders()
835
+ },
836
+ body: JSON.stringify(requestBody),
837
+ timeoutMs
838
+ });
839
+
840
+ if (response.ok) {
841
+ const jobResult = await pollJobResult(result?.job);
842
+ console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2));
843
+ process.exit(0);
844
+ }
845
+
846
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
847
+ lastResult = result;
848
+ lastRetryAfter = retryAfter;
849
+ if (!shouldRetry(result) || attempt === maxAttempts) break;
850
+ await sleep(retryAfter);
851
+ }
852
 
853
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
854
+ process.exit(1);
855
+ }
 
 
 
856
 
857
+ async function pollJobResult(job) {
858
+ if (!job || typeof job.id !== 'string') {
859
+ throw new Error('创建 job 的响应缺少 job.id。');
860
+ }
861
+ const resultUrl = resolveSameOriginUrl(
862
+ baseUrl,
863
+ job.result_url || buildAgentJobResultPath(job.id),
864
+ 'job.result_url'
865
+ );
866
+ const deadlineMs = Date.now() + timeoutMs;
867
+ let lastResult;
868
+ let lastRetryAfter = job.retry_after_seconds || 1;
869
+
870
+ while (Date.now() < deadlineMs) {
871
+ const { response, result } = await fetchJson(resultUrl, {
872
+ headers: authHeaders(),
873
+ timeoutMs
874
+ });
875
+ if (response.ok) return result;
876
+
877
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter;
878
+ lastResult = result;
879
+ lastRetryAfter = retryAfter;
880
+ if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break;
881
+ await sleep(retryAfter);
882
+ }
883
+
884
+ console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
885
+ process.exit(1);
886
  }
887
 
888
  async function runContractCheck(capabilitiesValue) {
889
+ const checks = [];
890
+ const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
891
+ method: 'POST',
892
+ headers: {
893
+ 'Content-Type': 'application/json',
894
+ ...authHeaders()
895
+ },
896
+ body: JSON.stringify(requestBody),
897
+ timeoutMs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  });
899
+ if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
900
+ checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code });
901
+ } else {
902
+ console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
903
+ process.exit(1);
904
+ }
905
 
906
+ if (supportsJobPolling(capabilitiesValue)) {
907
+ const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
908
+ method: 'POST',
909
+ headers: {
910
+ 'Content-Type': 'application/json',
911
+ ...authHeaders()
912
+ },
913
+ body: JSON.stringify(requestBody),
914
+ timeoutMs
915
+ });
916
+ if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') {
917
+ console.error(
918
+ JSON.stringify(
919
+ { ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result },
920
+ null,
921
+ 2
922
+ )
923
+ );
924
+ process.exit(1);
925
+ }
926
+ checks.push({
927
+ endpoint: AGENT_ENDPOINTS.create_generate_job,
928
+ status: jobCheck.response.status,
929
+ error_code: jobCheck.result.error.code
930
+ });
931
+ }
932
+
933
+ console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
934
  }
935
 
936
  async function fetchJson(url, init) {
937
+ try {
938
+ const response = await fetchWithTimeout(url, init);
939
+ const text = await response.text();
940
+ const result = parseJsonResponse(text, response.ok, url);
941
+ return { response, result, text };
942
+ } catch (error) {
943
+ const message = errorMessage(error);
944
+ if (message.startsWith(`请求失败:${url}。`)) {
945
+ throw error;
946
+ }
947
+ throw new Error(`请求失败:${url}。${message}`);
948
  }
 
 
949
  }
950
 
951
  async function fetchWithTimeout(url, init) {
952
+ const controller = new AbortController();
953
+ const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs);
954
+ try {
955
+ const fetchInit = { ...init };
956
+ delete fetchInit.timeoutMs;
957
+ return await fetch(url, { ...fetchInit, signal: controller.signal });
958
+ } catch (error) {
959
+ const message = errorMessage(error);
960
+ throw new Error(`请求失败:${url}。${message}`);
961
+ } finally {
962
+ clearTimeout(timeout);
963
+ }
964
  }
965
 
966
  function readErrorFromJsonText(text) {
967
+ let result;
968
+ try {
969
+ result = parseJsonResponse(text, false, '');
970
+ } catch {
971
+ return undefined;
972
+ }
973
+ if (typeof result?.error === 'string') return result.error;
974
+ if (typeof result?.error?.message === 'string') return result.error.message;
975
  return undefined;
 
 
 
 
976
  }
977
 
978
  function parseJsonResponse(text, isOk, url) {
979
+ if (!text) return null;
980
+ try {
981
+ return JSON.parse(text);
982
+ } catch (error) {
983
+ if (!isOk) return null;
984
+ const message = errorMessage(error);
985
+ throw new Error(`响应不是有效 JSON:${url}。${message}`);
986
+ }
987
  }
988
 
989
  function shouldRetry(result) {
990
+ return Boolean(result?.error?.retryable);
991
  }
992
 
993
  function supportsJobPolling(capabilitiesValue) {
994
+ return Boolean(
995
+ capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling'
996
+ );
997
  }
998
 
999
  function supportsPageSse(capabilitiesValue) {
1000
+ return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true);
1001
  }
1002
 
1003
  function shouldUseJobPolling(capabilitiesValue, routeMode) {
1004
+ if (routeMode !== 'job') return false;
1005
+ if (!supportsJobPolling(capabilitiesValue)) {
1006
+ throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
1007
+ }
1008
+ return true;
1009
  }
1010
 
1011
  function shouldUsePageSse(capabilitiesValue, request, routeMode) {
1012
+ if (routeMode === 'agent' || routeMode === 'job') return false;
1013
+ if (!isPageSseAllowed(request)) {
1014
+ if (routeMode === 'page_sse') {
1015
+ throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能强制使用页面 SSE。');
1016
+ }
1017
+ return false;
1018
+ }
1019
+ if (routeMode === 'page_sse' || isLargeGenerate(request)) {
1020
+ assertPageSseReady(capabilitiesValue);
1021
+ return true;
1022
  }
1023
  return false;
 
 
 
 
 
 
1024
  }
1025
 
1026
  function isLargeGenerate(request) {
1027
+ return readMaxImageEdge(request.size) > 2048;
1028
  }
1029
 
1030
  function isPageSseAllowed(request) {
1031
+ return request.streaming_strategy !== 'off' && request.stream_mode !== 'non_stream';
 
 
 
 
 
 
 
1032
  }
1033
 
1034
  function createScriptError(code, message) {
1035
+ const error = new Error(message);
1036
+ error.scriptCode = code;
1037
+ return error;
1038
  }
1039
 
1040
  function isScriptError(error) {
1041
+ return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
1042
  }
1043
 
1044
  function createPageSseHttpError(status, detail) {
1045
+ const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`;
1046
+ const error = new Error(message);
1047
+ error.status = status;
1048
+ return error;
1049
  }
1050
 
1051
  function isPageSseRequestRejected(error) {
1052
+ return Boolean(
1053
+ error &&
1054
+ typeof error === 'object' &&
1055
+ Number.isInteger(error.status) &&
1056
+ error.status >= 400 &&
1057
+ error.status < 500
1058
+ );
1059
  }
1060
 
1061
  function printUsage() {
1062
+ console.error('用法:generate-image.mjs [options] <prompt>');
1063
+ console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
1064
+ console.error(
1065
+ '常用参数:--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(兼容别名)'
1066
+ );
1067
+ console.error(
1068
+ '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'
1069
+ );
1070
  }
skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const AGENT_ENDPOINTS = Object.freeze({
2
+ capabilities: '/api/agent/capabilities',
3
+ openapi: '/api/agent/openapi.json',
4
+ generate: '/api/agent/images/generate',
5
+ edit: '/api/agent/images/edit',
6
+ create_generate_job: '/api/agent/jobs/images/generate',
7
+ job: '/api/agent/jobs/{id}',
8
+ job_result: '/api/agent/jobs/{id}/result',
9
+ artifact_metadata: '/api/agent/artifacts/{id}',
10
+ artifact_content: '/api/agent/artifacts/{id}/content',
11
+ artifact_delete: '/api/agent/artifacts/{id}'
12
+ });
13
+
14
+ export const AGENT_JOB_ENDPOINTS = Object.freeze({
15
+ create_generate_job: AGENT_ENDPOINTS.create_generate_job,
16
+ get_job: AGENT_ENDPOINTS.job,
17
+ get_job_result: AGENT_ENDPOINTS.job_result
18
+ });
19
+
20
+ export function buildAgentJobPath(jobId) {
21
+ return AGENT_ENDPOINTS.job.replace('{id}', encodePathValue(jobId));
22
+ }
23
+
24
+ export function buildAgentJobResultPath(jobId) {
25
+ return AGENT_ENDPOINTS.job_result.replace('{id}', encodePathValue(jobId));
26
+ }
27
+
28
+ function encodePathValue(value) {
29
+ return encodeURIComponent(String(value));
30
+ }
skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const PAGE_SSE_ENDPOINT = '/api/images';
5
+ export const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
6
+
7
+ export function readPageSseClientRequestIdMaxLength(capabilities) {
8
+ const maxLength = capabilities?.agent_streaming?.page_sse?.client_request_id?.max_length;
9
+ if (Number.isSafeInteger(maxLength) && maxLength > 0) return maxLength;
10
+ return DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH;
11
+ }
12
+
13
+ export function assertPageSseReady({ capabilities, passwordHash, idempotencyKey }) {
14
+ const pageSse = capabilities?.agent_streaming?.page_sse;
15
+ if (pageSse?.supported !== true) {
16
+ throw createPageSseScriptError(
17
+ 'page_sse_unavailable',
18
+ '当前路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级。'
19
+ );
20
+ }
21
+ if (pageSse?.auth?.required === true && !passwordHash) {
22
+ throw createPageSseScriptError(
23
+ 'page_sse_auth_required',
24
+ '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。'
25
+ );
26
+ }
27
+ const maxLength = readPageSseClientRequestIdMaxLength(capabilities);
28
+ if (idempotencyKey.length > maxLength) {
29
+ throw createPageSseScriptError(
30
+ 'page_sse_client_request_id_too_long',
31
+ `页面 SSE 的 clientRequestId 不能超过 ${maxLength} 个字符;请缩短 Idempotency-Key。`
32
+ );
33
+ }
34
+ }
35
+
36
+ export async function postPageSse({ url, formData, timeoutMs, errorMessage, sseLogPath }) {
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
39
+ try {
40
+ let response;
41
+ try {
42
+ response = await fetch(url, {
43
+ method: 'POST',
44
+ body: formData,
45
+ signal: controller.signal
46
+ });
47
+ } catch (error) {
48
+ throw new Error(`请求失败:${url}。${errorMessage(error)}`);
49
+ }
50
+
51
+ const contentType = response.headers.get('content-type') || '';
52
+ if (contentType.includes('text/event-stream')) {
53
+ return await collectPageSseResult(response, controller.signal, errorMessage, sseLogPath);
54
+ }
55
+
56
+ const text = await response.text();
57
+ if (!response.ok) {
58
+ throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
59
+ }
60
+ return parseJsonResponse(text, true, url, errorMessage);
61
+ } finally {
62
+ clearTimeout(timeout);
63
+ }
64
+ }
65
+
66
+ export function formatPageSseOutput({ result, baseUrl, responseMode = 'path', defaultOutputFormat = 'png' }) {
67
+ if (!result || !Array.isArray(result.images)) return result;
68
+ return {
69
+ ...result,
70
+ images: result.images.map((image) =>
71
+ formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat })
72
+ )
73
+ };
74
+ }
75
+
76
+ export function buildPageSseFailureOutput({ error, fallbackEndpoint, fallbackMode = 'manual_after_diagnosis', errorMessage }) {
77
+ const diagnostics = readPageSseDiagnostics(error);
78
+ if (isPageSseScriptError(error)) {
79
+ return {
80
+ ok: false,
81
+ billable: false,
82
+ error: {
83
+ code: error.scriptCode,
84
+ message: errorMessage(error),
85
+ ...(diagnostics ? { diagnostics } : {})
86
+ },
87
+ routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
88
+ next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
89
+ };
90
+ }
91
+ if (isPageSseRequestRejected(error)) {
92
+ return {
93
+ ok: false,
94
+ billable: false,
95
+ error: {
96
+ code: 'page_sse_request_rejected',
97
+ status: error.status,
98
+ message: errorMessage(error),
99
+ ...(diagnostics ? { diagnostics } : {})
100
+ },
101
+ routing: buildPageSseRouting(fallbackEndpoint, 'fix_request_before_retry'),
102
+ next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
103
+ };
104
+ }
105
+ return {
106
+ ok: false,
107
+ billable: true,
108
+ error: {
109
+ code: 'page_sse_failed',
110
+ ...buildPageSseFailureStatus(error),
111
+ message: errorMessage(error),
112
+ ...(diagnostics ? { diagnostics } : {})
113
+ },
114
+ routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
115
+ next_step: '先诊断页面流式失败原因,再决定是否显式选择备用路径;不要自动重试同一个请求。'
116
+ };
117
+ }
118
+
119
+ export function normalizeImageBackendForPage(value) {
120
+ if (value === 'images') return 'images-api';
121
+ if (value === 'responses') return 'responses-image-generation';
122
+ return value;
123
+ }
124
+
125
+ export function isPageSseDisabledByStreamingOptions(value) {
126
+ return value?.streamMode === 'non_stream' || value?.streamingStrategy === 'off';
127
+ }
128
+
129
+ export function assertPageSseStreamingAllowed(value) {
130
+ if (isPageSseDisabledByStreamingOptions(value)) {
131
+ throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能使用页面 SSE。');
132
+ }
133
+ }
134
+
135
+ function buildPageSseRouting(fallbackEndpoint, fallbackMode) {
136
+ return {
137
+ transport: 'page_sse',
138
+ endpoint: PAGE_SSE_ENDPOINT,
139
+ fallback_endpoint: fallbackEndpoint,
140
+ fallback_mode: fallbackMode
141
+ };
142
+ }
143
+
144
+ function createPageSseScriptError(code, message) {
145
+ const error = new Error(message);
146
+ error.scriptCode = code;
147
+ return error;
148
+ }
149
+
150
+ function isPageSseScriptError(error) {
151
+ return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
152
+ }
153
+
154
+ function isPageSseRequestRejected(error) {
155
+ return Boolean(error && typeof error === 'object' && Number.isInteger(error.status) && error.status >= 400 && error.status < 500);
156
+ }
157
+
158
+ function createPageSseHttpError(status, message) {
159
+ const error = new Error(formatErrorValue(message));
160
+ error.status = status;
161
+ return error;
162
+ }
163
+
164
+ function formatErrorValue(value) {
165
+ if (typeof value === 'string' && value.trim()) return value;
166
+ if (value && typeof value === 'object') {
167
+ if (typeof value.message === 'string' && value.message.trim()) return value.message;
168
+ if (typeof value.code === 'string' && value.code.trim()) return value.code;
169
+ try {
170
+ return JSON.stringify(value);
171
+ } catch {
172
+ return '页面 SSE 返回错误。';
173
+ }
174
+ }
175
+ return '页面 SSE 返回错误。';
176
+ }
177
+
178
+ function readErrorFromJsonText(text) {
179
+ try {
180
+ const value = text ? JSON.parse(text) : null;
181
+ if (typeof value?.error === 'string') return value.error;
182
+ return value?.error || value;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ function parseJsonResponse(text, allowEmpty, url, errorMessage) {
189
+ if (!text && allowEmpty) return {};
190
+ try {
191
+ return JSON.parse(text);
192
+ } catch (error) {
193
+ throw new Error(`响应不是有效 JSON:${url}。${errorMessage(error)}`);
194
+ }
195
+ }
196
+
197
+ async function collectPageSseResult(response, signal, errorMessage, sseLogPath) {
198
+ const reader = response.body?.getReader();
199
+ if (!reader) throw new Error('页面 SSE 响应缺少 body。');
200
+ const decoder = new TextDecoder();
201
+ const state = createPageSseState();
202
+ let buffer = '';
203
+ while (true) {
204
+ const { done, value } = await readPageSseChunk(reader, signal);
205
+ if (done) break;
206
+ buffer += decoder.decode(value, { stream: true });
207
+ const events = buffer.split(/\r?\n\r?\n/);
208
+ buffer = events.pop() || '';
209
+ for (const rawEvent of events) {
210
+ appendPageSseLog(sseLogPath, rawEvent);
211
+ applyPageSseEvent(state, rawEvent, errorMessage);
212
+ }
213
+ }
214
+ buffer += decoder.decode();
215
+ if (buffer.trim()) {
216
+ appendPageSseLog(sseLogPath, buffer);
217
+ applyPageSseEvent(state, buffer, errorMessage);
218
+ }
219
+ if (state.completedImages.length === 0) {
220
+ throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state);
221
+ }
222
+ if (!state.doneReceived) {
223
+ throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state);
224
+ }
225
+ return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) };
226
+ }
227
+
228
+ function appendPageSseLog(filePath, rawEvent) {
229
+ if (!filePath || !rawEvent.trim()) return;
230
+ try {
231
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
232
+ fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`);
233
+ } catch (error) {
234
+ console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`);
235
+ }
236
+ }
237
+
238
+ function createPageSseState() {
239
+ return {
240
+ completedImages: [],
241
+ usage: undefined,
242
+ actualCost: undefined,
243
+ doneReceived: false,
244
+ completedEventCount: 0,
245
+ partialImageCount: 0,
246
+ lastEventType: undefined
247
+ };
248
+ }
249
+
250
+ function readPageSseChunk(reader, signal) {
251
+ if (!signal) return reader.read();
252
+ if (signal.aborted) return Promise.reject(new Error('请求超时。'));
253
+ return new Promise((resolve, reject) => {
254
+ const onAbort = () => reject(new Error('请求超时。'));
255
+ signal.addEventListener('abort', onAbort, { once: true });
256
+ reader
257
+ .read()
258
+ .then(resolve, reject)
259
+ .finally(() => {
260
+ signal.removeEventListener('abort', onAbort);
261
+ });
262
+ });
263
+ }
264
+
265
+ function applyPageSseEvent(state, rawEvent, errorMessage) {
266
+ const event = parsePageSseEvent(rawEvent, errorMessage);
267
+ if (!event) return;
268
+ const eventType = readPageSseEventType(event);
269
+ state.lastEventType = eventType;
270
+ if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1;
271
+ if (event.type === 'error') {
272
+ throw createPageSseStreamError(event, state);
273
+ }
274
+ if (event.type === 'completed' && event.filename) {
275
+ state.completedEventCount += 1;
276
+ state.completedImages.push(
277
+ normalizePageSseImage(
278
+ {
279
+ filename: event.filename,
280
+ b64_json: event.b64_json,
281
+ path: event.path,
282
+ output_format: event.outputFormat || event.output_format
283
+ },
284
+ readPageSseClientRequestId(event)
285
+ )
286
+ );
287
+ return;
288
+ }
289
+ if (event.type === 'done') {
290
+ state.doneReceived = true;
291
+ const clientRequestId = readPageSseClientRequestId(event);
292
+ state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
293
+ state.usage = event.usage;
294
+ state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
295
+ }
296
+ }
297
+
298
+ function readPageSseEventType(event) {
299
+ if (typeof event.type === 'string' && event.type.trim()) return event.type;
300
+ if (typeof event.event === 'string' && event.event.trim()) return event.event;
301
+ return undefined;
302
+ }
303
+
304
+ function isPartialPageSseEvent(event, eventType) {
305
+ if (typeof eventType === 'string' && eventType.includes('partial_image')) return true;
306
+ return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64);
307
+ }
308
+
309
+ function parsePageSseEvent(rawEvent, errorMessage) {
310
+ const lines = rawEvent.split(/\r?\n/);
311
+ const data = lines
312
+ .filter((line) => line.startsWith('data: '))
313
+ .map((line) => line.slice(6))
314
+ .join('\n')
315
+ .trim();
316
+ if (!data || data === '[DONE]') return undefined;
317
+ try {
318
+ return JSON.parse(data);
319
+ } catch (error) {
320
+ throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
321
+ }
322
+ }
323
+
324
+ function createPageSseStreamError(event, state) {
325
+ const error = new Error(formatErrorValue(event.error));
326
+ const status = readPageSseStreamStatus(event);
327
+ if (Number.isInteger(status)) error.streamStatus = status;
328
+ return withPageSseDiagnostics(error, state);
329
+ }
330
+
331
+ function readPageSseStreamStatus(event) {
332
+ if (Number.isInteger(event.status)) return event.status;
333
+ if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
334
+ return event.error.status;
335
+ }
336
+ return undefined;
337
+ }
338
+
339
+ function readPageSseClientRequestId(event) {
340
+ if (typeof event.clientRequestId === 'string') return event.clientRequestId;
341
+ if (typeof event.client_request_id === 'string') return event.client_request_id;
342
+ return undefined;
343
+ }
344
+
345
+ function normalizePageSseImage(image, fallbackClientRequestId) {
346
+ const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
347
+ return {
348
+ ...image,
349
+ ...(image.output_format ? { output_format: image.output_format } : {}),
350
+ ...(clientRequestId ? { clientRequestId } : {})
351
+ };
352
+ }
353
+
354
+ function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
355
+ if (!Array.isArray(doneImages) || doneImages.length === 0) {
356
+ return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
357
+ }
358
+ const imageCount = Math.max(doneImages.length, completedImages.length);
359
+ return Array.from({ length: imageCount }, (_, index) =>
360
+ normalizePageSseImage(
361
+ { ...(completedImages[index] || {}), ...(doneImages[index] || {}) },
362
+ fallbackClientRequestId
363
+ )
364
+ );
365
+ }
366
+
367
+ function formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat }) {
368
+ const output = {
369
+ ...image,
370
+ output_format: image.outputFormat || image.output_format || defaultOutputFormat
371
+ };
372
+ if (output.path) {
373
+ output.absolute_path = new URL(output.path, `${baseUrl}/`).toString();
374
+ output.content_url = output.content_url || output.path;
375
+ output.absolute_content_url = output.absolute_content_url || output.absolute_path;
376
+ if (responseMode === 'path') delete output.b64_json;
377
+ }
378
+ return output;
379
+ }
380
+
381
+ function buildPageSseFailureStatus(error) {
382
+ if (error && typeof error === 'object') {
383
+ if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
384
+ if (Number.isInteger(error.status)) return { status: error.status };
385
+ }
386
+ return {};
387
+ }
388
+
389
+ function withPageSseDiagnostics(error, state) {
390
+ error.pageSseDiagnostics = buildPageSseDiagnostics(state);
391
+ return error;
392
+ }
393
+
394
+ function readPageSseDiagnostics(error) {
395
+ if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined;
396
+ return error.pageSseDiagnostics;
397
+ }
398
+
399
+ function buildPageSseDiagnostics(state) {
400
+ return {
401
+ partial_image_count: state.partialImageCount,
402
+ completed_event_count: state.completedEventCount,
403
+ done_received: state.doneReceived,
404
+ final_image_count: state.completedImages.length,
405
+ ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {})
406
+ };
407
+ }
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs CHANGED
@@ -1,9 +1,12 @@
1
- import {
2
- CHINESE_POSITIVE_INTEGER_MESSAGES,
3
- parsePositiveIntegerConfig
4
- } from '../../../../src/lib/positive-integer-config.mjs';
5
-
6
  const MAX_RETRY_AFTER_SECONDS = 60;
 
 
 
 
 
 
 
 
7
 
8
  export function readOptionValue(argv, index, name) {
9
  const value = argv[index];
@@ -14,9 +17,16 @@ export function readOptionValue(argv, index, name) {
14
  }
15
 
16
  export function readConfiguredPositiveInteger(value, name, fallback) {
17
- return parsePositiveIntegerConfig(value, name, fallback, {
18
- messages: CHINESE_POSITIVE_INTEGER_MESSAGES
19
- });
 
 
 
 
 
 
 
20
  }
21
 
22
  export function normalizeBaseUrl(value) {
@@ -40,6 +50,34 @@ export function normalizeOutputFormat(value) {
40
  return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  export function parseRetryAfterValue(value, fallback = 1) {
44
  if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback);
45
  const parsed = Number(value);
@@ -68,3 +106,30 @@ export function resolveSameOriginUrl(baseUrl, value, label) {
68
  export function errorMessage(error) {
69
  return error instanceof Error ? error.message : String(error);
70
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  const MAX_RETRY_AFTER_SECONDS = 60;
2
+ const DIGITS_PATTERN = /^\d+$/;
3
+ const IMAGE_SIZE_PATTERN = /^(\d+)x(\d+)$/;
4
+ const LEGACY_IMAGE_SIZES = new Set(['auto', '1024x1024', '1536x1024', '1024x1536']);
5
+ const GPT_IMAGE_2_MIN_PIXELS = 655_360;
6
+ const GPT_IMAGE_2_MAX_PIXELS = 8_294_400;
7
+ const GPT_IMAGE_2_MAX_EDGE = 3840;
8
+ const GPT_IMAGE_2_EDGE_MULTIPLE = 16;
9
+ const GPT_IMAGE_2_MAX_ASPECT = 3;
10
 
11
  export function readOptionValue(argv, index, name) {
12
  const value = argv[index];
 
17
  }
18
 
19
  export function readConfiguredPositiveInteger(value, name, fallback) {
20
+ const rawValue = value === undefined || value === null ? '' : String(value).trim();
21
+ if (!rawValue) return fallback;
22
+ if (!DIGITS_PATTERN.test(rawValue)) {
23
+ throw new Error(`${name} 必须是正整数。`);
24
+ }
25
+ const parsed = Number(rawValue);
26
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
27
+ throw new Error(`${name} 必须是正整数。`);
28
+ }
29
+ return parsed;
30
  }
31
 
32
  export function normalizeBaseUrl(value) {
 
50
  return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
51
  }
52
 
53
+ export function assertValidImageSizeForModel(value, model, label = 'size') {
54
+ if (typeof value !== 'string' || value.trim().length === 0) {
55
+ throw new Error(`${label} 必须是字符串。`);
56
+ }
57
+ if (model !== 'gpt-image-2') {
58
+ if (!LEGACY_IMAGE_SIZES.has(value)) {
59
+ throw new Error(`${label} 对 ${model} 无效;非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536。`);
60
+ }
61
+ return value;
62
+ }
63
+ if (value === 'auto') return value;
64
+ const size = parseImageSizeValue(value);
65
+ if (!size) throw new Error(`${label} 必须是 auto 或 WIDTHxHEIGHT。`);
66
+ assertValidGptImage2Dimensions(size.width, size.height, label);
67
+ return value;
68
+ }
69
+
70
+ export function parseImageSizeValue(value) {
71
+ if (typeof value !== 'string') return undefined;
72
+ const match = IMAGE_SIZE_PATTERN.exec(value);
73
+ return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined;
74
+ }
75
+
76
+ export function readMaxImageEdge(value) {
77
+ const size = parseImageSizeValue(value);
78
+ return size ? Math.max(size.width, size.height) : 0;
79
+ }
80
+
81
  export function parseRetryAfterValue(value, fallback = 1) {
82
  if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback);
83
  const parsed = Number(value);
 
106
  export function errorMessage(error) {
107
  return error instanceof Error ? error.message : String(error);
108
  }
109
+
110
+ function assertValidGptImage2Dimensions(width, height, label) {
111
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
112
+ throw new Error(`${label} 的宽度和高度必须是正数。`);
113
+ }
114
+ if (!Number.isInteger(width) || !Number.isInteger(height)) {
115
+ throw new Error(`${label} 的宽度和高度必须是整数。`);
116
+ }
117
+ if (width % GPT_IMAGE_2_EDGE_MULTIPLE !== 0 || height % GPT_IMAGE_2_EDGE_MULTIPLE !== 0) {
118
+ throw new Error(`${label} 的宽边和高边都必须是 ${GPT_IMAGE_2_EDGE_MULTIPLE} 的倍数。`);
119
+ }
120
+ if (width > GPT_IMAGE_2_MAX_EDGE || height > GPT_IMAGE_2_MAX_EDGE) {
121
+ throw new Error(`${label} 的最大单边不能超过 ${GPT_IMAGE_2_MAX_EDGE}px。`);
122
+ }
123
+ const long = Math.max(width, height);
124
+ const short = Math.min(width, height);
125
+ if (long / short > GPT_IMAGE_2_MAX_ASPECT) {
126
+ throw new Error(`${label} 的宽高比(长边:短边)必须小于等于 ${GPT_IMAGE_2_MAX_ASPECT}:1。`);
127
+ }
128
+ const pixels = width * height;
129
+ if (pixels < GPT_IMAGE_2_MIN_PIXELS) {
130
+ throw new Error(`${label} 的总像素必须至少为 ${GPT_IMAGE_2_MIN_PIXELS.toLocaleString()}。`);
131
+ }
132
+ if (pixels > GPT_IMAGE_2_MAX_PIXELS) {
133
+ throw new Error(`${label} 的总像素不能超过 ${GPT_IMAGE_2_MAX_PIXELS.toLocaleString()}。`);
134
+ }
135
+ }
skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs CHANGED
@@ -3,6 +3,7 @@ import dns from 'node:dns/promises';
3
  import tls from 'node:tls';
4
  import {
5
  errorMessage,
 
6
  normalizeBaseUrl,
7
  normalizeOutputFormat,
8
  readConfiguredPositiveInteger,
@@ -36,6 +37,7 @@ const apiKey = process.env.GPT_IMAGE_UPSTREAM_API_KEY || process.env.OPENAI_API_
36
  let timeoutMs;
37
  try {
38
  timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000);
 
39
  } catch (error) {
40
  console.error(errorMessage(error));
41
  printUsage();
 
3
  import tls from 'node:tls';
4
  import {
5
  errorMessage,
6
+ assertValidImageSizeForModel,
7
  normalizeBaseUrl,
8
  normalizeOutputFormat,
9
  readConfiguredPositiveInteger,
 
37
  let timeoutMs;
38
  try {
39
  timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000);
40
+ options.size = assertValidImageSizeForModel(options.size, options.model, '--size');
41
  } catch (error) {
42
  console.error(errorMessage(error));
43
  printUsage();
src/app/api/agent/agent-routes.test.ts CHANGED
@@ -38,8 +38,17 @@ beforeEach(async () => {
38
  process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs';
39
  delete process.env.APP_PASSWORD;
40
  delete process.env.AGENT_API_TOKEN;
 
 
 
41
  delete process.env.OPENAI_CHANNEL_1_API_KEYS;
42
  delete process.env.OPENAI_CHANNEL_1_BASE_URL;
 
 
 
 
 
 
43
  });
44
 
45
  afterEach(async () => {
@@ -99,6 +108,10 @@ describe('Agent route integration', () => {
99
  'images-api',
100
  'responses-image-generation'
101
  ]);
 
 
 
 
102
  });
103
 
104
  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', () => {
161
  }
162
  });
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => {
165
  const { generateImage } = await loadAgentRoutes();
166
  let upstreamBody = '';
@@ -408,6 +453,7 @@ describe('Agent route integration', () => {
408
  agentJsonRequest('agent-responses-upstream-sse-partial-only-key', {
409
  prompt: 'agent responses upstream sse partial only',
410
  image_backend: 'responses-image-generation',
 
411
  streaming_strategy: 'responses-sse',
412
  partial_images: 2
413
  })
@@ -454,6 +500,7 @@ describe('Agent route integration', () => {
454
  agentJsonRequest('agent-responses-upstream-sse-failed-call-key', {
455
  prompt: 'agent responses upstream sse failed call',
456
  image_backend: 'responses-image-generation',
 
457
  streaming_strategy: 'responses-sse',
458
  partial_images: 2
459
  })
@@ -483,6 +530,7 @@ describe('Agent route integration', () => {
483
  const response = await generateImage(
484
  agentJsonRequest('agent-upstream-sse-partial-only-key', {
485
  prompt: 'agent upstream sse partial only',
 
486
  streaming_strategy: 'newapi-keepalive-sse',
487
  partial_images: 2
488
  })
@@ -772,6 +820,7 @@ describe('Agent route integration', () => {
772
  const created = await createGenerateJob(
773
  agentJobJsonRequest('route-job-images-missing-final-key', {
774
  prompt: 'agent job images partial only',
 
775
  streaming_strategy: 'newapi-keepalive-sse',
776
  partial_images: 2
777
  })
@@ -908,6 +957,7 @@ describe('Agent route integration', () => {
908
  agentJobJsonRequest('route-job-responses-missing-final-key', {
909
  prompt: 'agent job responses partial only',
910
  image_backend: 'responses-image-generation',
 
911
  streaming_strategy: 'responses-sse',
912
  partial_images: 2
913
  })
@@ -1311,8 +1361,44 @@ describe('Agent route integration', () => {
1311
  await upstream.close();
1312
  });
1313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1314
  it('aborts Agent edit upstream calls when the client request signal aborts', async () => {
1315
  const { editImage } = await loadAgentRoutes();
 
1316
  const upstream = await startHangingImageEditUpstream();
1317
  process.env.OPENAI_API_KEY = 'test-key';
1318
  process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
@@ -1320,9 +1406,13 @@ describe('Agent route integration', () => {
1320
 
1321
  try {
1322
  const responsePromise = editImage(
1323
- agentEditRequest('route-edit-abort-key', 'agent edit abort', {}, 'path', {
1324
- signal: abortController.signal
1325
- })
 
 
 
 
1326
  );
1327
  await waitFor(() => upstream.requests === 1);
1328
  abortController.abort();
@@ -1335,6 +1425,7 @@ describe('Agent route integration', () => {
1335
  ]);
1336
 
1337
  assert.notEqual(response.status, 200);
 
1338
  } finally {
1339
  abortController.abort();
1340
  await upstream.close();
@@ -1359,7 +1450,7 @@ describe('Agent route integration', () => {
1359
  await upstream.close();
1360
  });
1361
 
1362
- it('rejects high-resolution Agent edit requests before contacting upstream', async () => {
1363
  const { editImage } = await loadAgentRoutes();
1364
  let upstreamCalls = 0;
1365
  const upstream = await startImageUpstream(() => {
@@ -1371,20 +1462,95 @@ describe('Agent route integration', () => {
1371
 
1372
  try {
1373
  const response = await editImage(
1374
- agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' })
 
 
 
 
 
 
 
 
 
 
 
 
1375
  );
1376
 
1377
  assert.equal(response.status, 422);
1378
  const body = await response.json();
1379
  assert.equal(body.error.code, 'validation_error');
1380
- assert.match(body.error.message, /\/api\/images/);
 
 
 
 
 
 
 
 
 
 
1381
  assert.equal(upstreamCalls, 0);
1382
  } finally {
1383
  await upstream.close();
1384
  }
1385
  });
1386
 
1387
- it('rejects high-resolution Agent edit requests before reading files or API credentials', async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1388
  const { editImage } = await loadAgentRoutes();
1389
  delete process.env.OPENAI_API_KEY;
1390
  delete process.env.OPENAI_API_BASE_URL;
@@ -1408,12 +1574,11 @@ describe('Agent route integration', () => {
1408
  assert.equal(response.status, 422);
1409
  const body = await response.json();
1410
  assert.equal(body.error.code, 'validation_error');
1411
- assert.match(body.error.message, /\/api\/images/);
1412
- assert.equal(body.error.details?.fields?.image_0, undefined);
1413
- assert.doesNotMatch(body.error.message, /API Key|图片文件/);
1414
  });
1415
 
1416
- it('rejects auto-size Agent edit when the uploaded source image is high resolution', async () => {
1417
  const { editImage } = await loadAgentRoutes();
1418
  let upstreamCalls = 0;
1419
  const upstream = await startImageUpstream(() => {
@@ -1436,11 +1601,10 @@ describe('Agent route integration', () => {
1436
  )
1437
  );
1438
 
1439
- assert.equal(response.status, 422);
1440
  const body = await response.json();
1441
- assert.equal(body.error.code, 'validation_error');
1442
- assert.match(body.error.message, /\/api\/images/);
1443
- assert.equal(upstreamCalls, 0);
1444
  } finally {
1445
  await upstream.close();
1446
  }
@@ -1967,6 +2131,8 @@ function asNextRequest(request: Request): NextRequest {
1967
  }
1968
 
1969
  function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
 
 
1970
  return new Request('http://localhost/api/agent/images/generate', {
1971
  method: 'POST',
1972
  headers: {
@@ -1974,7 +2140,7 @@ function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>,
1974
  'Idempotency-Key': idempotencyKey,
1975
  ...headers
1976
  },
1977
- body: JSON.stringify(body)
1978
  });
1979
  }
1980
 
@@ -1983,6 +2149,8 @@ function agentJobJsonRequest(
1983
  body: Record<string, unknown>,
1984
  headers: Record<string, string> = {}
1985
  ) {
 
 
1986
  return new Request('http://localhost/api/agent/jobs/images/generate', {
1987
  method: 'POST',
1988
  headers: {
@@ -1990,7 +2158,7 @@ function agentJobJsonRequest(
1990
  'Idempotency-Key': idempotencyKey,
1991
  ...headers
1992
  },
1993
- body: JSON.stringify(body)
1994
  });
1995
  }
1996
 
@@ -2010,6 +2178,9 @@ function agentEditRequest(
2010
  typeof responseModeOrFields === 'string'
2011
  ? { response_mode: responseModeOrFields }
2012
  : { response_mode: 'path', ...responseModeOrFields };
 
 
 
2013
  const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64');
2014
  const formData = new FormData();
2015
  formData.append('prompt', prompt);
@@ -2117,7 +2288,9 @@ async function startStreamingImageUpstream(
2117
  ) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>>
2118
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
2119
  const server = http.createServer(async (request, response) => {
2120
- if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
 
 
2121
  response.writeHead(404, { 'Content-Type': 'application/json' });
2122
  response.end(JSON.stringify({ error: { message: 'not found' } }));
2123
  return;
 
38
  process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs';
39
  delete process.env.APP_PASSWORD;
40
  delete process.env.AGENT_API_TOKEN;
41
+ delete process.env.OPENAI_API_KEY;
42
+ delete process.env.OPENAI_API_BASE_URL;
43
+ delete process.env.OPENAI_CHANNEL_1_ID;
44
  delete process.env.OPENAI_CHANNEL_1_API_KEYS;
45
  delete process.env.OPENAI_CHANNEL_1_BASE_URL;
46
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED;
47
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS;
48
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS;
49
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK;
50
+ delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY;
51
+ delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS;
52
  });
53
 
54
  afterEach(async () => {
 
108
  'images-api',
109
  'responses-image-generation'
110
  ]);
111
+ assert.deepEqual(body.agent_streaming.upstream_sse.request_fields_by_mode, {
112
+ generate: ['image_backend', 'stream_mode', 'streaming_strategy', 'partial_images'],
113
+ edit: ['stream_mode', 'streaming_strategy', 'partial_images']
114
+ });
115
  });
116
 
117
  it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
 
174
  }
175
  });
176
 
177
+ it('uses Agent auto stream mode as an internal upstream stream by default', async () => {
178
+ const { generateImage } = await loadAgentRoutes();
179
+ const { getServerChannelState } = await import('@/lib/server-channel-router');
180
+ let upstreamBody = '';
181
+ const upstream = await startImageUpstream((body) => {
182
+ upstreamBody = body;
183
+ return { data: [{ b64_json: PNG_BASE64 }] };
184
+ });
185
+ process.env.OPENAI_API_KEY = 'test-key';
186
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
187
+
188
+ try {
189
+ const response = await generateImage(
190
+ agentJsonRequest('agent-default-auto-stream-key', {
191
+ prompt: 'agent default auto stream',
192
+ stream_mode: 'auto'
193
+ })
194
+ );
195
+
196
+ assert.equal(response.status, 200);
197
+ assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
198
+ const body = await response.json();
199
+ assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true);
200
+ const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
201
+ assert.equal(upstreamJson.stream, true);
202
+ assert.equal(upstreamJson.partial_images, 2);
203
+ assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 1);
204
+ } finally {
205
+ await upstream.close();
206
+ }
207
+ });
208
+
209
  it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => {
210
  const { generateImage } = await loadAgentRoutes();
211
  let upstreamBody = '';
 
453
  agentJsonRequest('agent-responses-upstream-sse-partial-only-key', {
454
  prompt: 'agent responses upstream sse partial only',
455
  image_backend: 'responses-image-generation',
456
+ stream_mode: 'stream',
457
  streaming_strategy: 'responses-sse',
458
  partial_images: 2
459
  })
 
500
  agentJsonRequest('agent-responses-upstream-sse-failed-call-key', {
501
  prompt: 'agent responses upstream sse failed call',
502
  image_backend: 'responses-image-generation',
503
+ stream_mode: 'stream',
504
  streaming_strategy: 'responses-sse',
505
  partial_images: 2
506
  })
 
530
  const response = await generateImage(
531
  agentJsonRequest('agent-upstream-sse-partial-only-key', {
532
  prompt: 'agent upstream sse partial only',
533
+ stream_mode: 'stream',
534
  streaming_strategy: 'newapi-keepalive-sse',
535
  partial_images: 2
536
  })
 
820
  const created = await createGenerateJob(
821
  agentJobJsonRequest('route-job-images-missing-final-key', {
822
  prompt: 'agent job images partial only',
823
+ stream_mode: 'stream',
824
  streaming_strategy: 'newapi-keepalive-sse',
825
  partial_images: 2
826
  })
 
957
  agentJobJsonRequest('route-job-responses-missing-final-key', {
958
  prompt: 'agent job responses partial only',
959
  image_backend: 'responses-image-generation',
960
+ stream_mode: 'stream',
961
  streaming_strategy: 'responses-sse',
962
  partial_images: 2
963
  })
 
1361
  await upstream.close();
1362
  });
1363
 
1364
+ it('can consume Agent edit upstream SSE internally while returning final JSON', async () => {
1365
+ const { editImage } = await loadAgentRoutes();
1366
+ let upstreamBody = '';
1367
+ const upstream = await startStreamingImageUpstream((body) => {
1368
+ upstreamBody = body;
1369
+ return [
1370
+ {
1371
+ event: 'image_edit.completed',
1372
+ data: { type: 'image_edit.completed', b64_json: PNG_BASE64 }
1373
+ }
1374
+ ];
1375
+ });
1376
+ process.env.OPENAI_API_KEY = 'test-key';
1377
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
1378
+
1379
+ try {
1380
+ const response = await editImage(
1381
+ agentEditRequest('route-edit-upstream-sse-key', 'agent edit stream', {}, {
1382
+ stream_mode: 'stream',
1383
+ streaming_strategy: 'openai-sse',
1384
+ partial_images: '2'
1385
+ })
1386
+ );
1387
+
1388
+ assert.equal(response.status, 200);
1389
+ assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
1390
+ const body = await response.json();
1391
+ assert.equal(body.images[0].content_url.startsWith('/api/agent/artifacts/'), true);
1392
+ assert.match(upstreamBody, /name="stream"/);
1393
+ assert.match(upstreamBody, /name="partial_images"/);
1394
+ } finally {
1395
+ await upstream.close();
1396
+ }
1397
+ });
1398
+
1399
  it('aborts Agent edit upstream calls when the client request signal aborts', async () => {
1400
  const { editImage } = await loadAgentRoutes();
1401
+ const { getServerChannelState } = await import('@/lib/server-channel-router');
1402
  const upstream = await startHangingImageEditUpstream();
1403
  process.env.OPENAI_API_KEY = 'test-key';
1404
  process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
 
1406
 
1407
  try {
1408
  const responsePromise = editImage(
1409
+ agentEditRequest(
1410
+ 'route-edit-abort-key',
1411
+ 'agent edit abort',
1412
+ {},
1413
+ { stream_mode: 'auto', streaming_strategy: 'openai-sse' },
1414
+ { signal: abortController.signal }
1415
+ )
1416
  );
1417
  await waitFor(() => upstream.requests === 1);
1418
  abortController.abort();
 
1425
  ]);
1426
 
1427
  assert.notEqual(response.status, 200);
1428
+ assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0);
1429
  } finally {
1430
  abortController.abort();
1431
  await upstream.close();
 
1450
  await upstream.close();
1451
  });
1452
 
1453
+ it('rejects unsupported fields on Agent edit requests before calling upstream', async () => {
1454
  const { editImage } = await loadAgentRoutes();
1455
  let upstreamCalls = 0;
1456
  const upstream = await startImageUpstream(() => {
 
1462
 
1463
  try {
1464
  const response = await editImage(
1465
+ agentEditRequest('route-edit-generate-only-fields-key', 'agent edit invalid fields', {}, {
1466
+ imageBackend: 'responses-image-generation',
1467
+ image_backend: 'responses-image-generation',
1468
+ format: 'webp',
1469
+ outputFormat: 'jpeg',
1470
+ output_format: 'jpeg',
1471
+ outputCompression: '80',
1472
+ output_compression: '80',
1473
+ responsesModel: 'gpt-4.1',
1474
+ responses_model: 'gpt-4.1',
1475
+ background: 'opaque',
1476
+ moderation: 'auto'
1477
+ })
1478
  );
1479
 
1480
  assert.equal(response.status, 422);
1481
  const body = await response.json();
1482
  assert.equal(body.error.code, 'validation_error');
1483
+ assert.match(body.error.details.fields.imageBackend, /不接受该字段/);
1484
+ assert.match(body.error.details.fields.image_backend, /不接受该字段/);
1485
+ assert.match(body.error.details.fields.format, /不接受该字段/);
1486
+ assert.match(body.error.details.fields.outputFormat, /不接受该字段/);
1487
+ assert.match(body.error.details.fields.output_format, /不接受该字段/);
1488
+ assert.match(body.error.details.fields.outputCompression, /不接受该字段/);
1489
+ assert.match(body.error.details.fields.output_compression, /不接受该字段/);
1490
+ assert.match(body.error.details.fields.responsesModel, /不接受该字段/);
1491
+ assert.match(body.error.details.fields.responses_model, /不接受该字段/);
1492
+ assert.match(body.error.details.fields.background, /不接受该字段/);
1493
+ assert.match(body.error.details.fields.moderation, /不接受该字段/);
1494
  assert.equal(upstreamCalls, 0);
1495
  } finally {
1496
  await upstream.close();
1497
  }
1498
  });
1499
 
1500
+ it('rejects page-only streaming strategy fields on Agent edit requests before calling upstream', async () => {
1501
+ const { editImage } = await loadAgentRoutes();
1502
+ let upstreamCalls = 0;
1503
+ const upstream = await startImageUpstream(() => {
1504
+ upstreamCalls += 1;
1505
+ return { data: [{ b64_json: PNG_BASE64 }] };
1506
+ });
1507
+ process.env.OPENAI_API_KEY = 'test-key';
1508
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
1509
+
1510
+ try {
1511
+ const response = await editImage(
1512
+ agentEditRequest('route-edit-page-streaming-field-key', 'agent edit invalid page streaming field', {}, {
1513
+ image_streaming_strategy: 'force-sse',
1514
+ imageStreamingStrategy: 'force-sse'
1515
+ })
1516
+ );
1517
+
1518
+ assert.equal(response.status, 422);
1519
+ const body = await response.json();
1520
+ assert.equal(body.error.code, 'validation_error');
1521
+ assert.match(body.error.details.fields.image_streaming_strategy, /streaming_strategy/);
1522
+ assert.match(body.error.details.fields.imageStreamingStrategy, /streaming_strategy/);
1523
+ assert.equal(upstreamCalls, 0);
1524
+ } finally {
1525
+ await upstream.close();
1526
+ }
1527
+ });
1528
+
1529
+ it('allows high-resolution Agent edit requests as an explicit fallback path', async () => {
1530
+ const { editImage } = await loadAgentRoutes();
1531
+ let upstreamCalls = 0;
1532
+ const upstream = await startImageUpstream(() => {
1533
+ upstreamCalls += 1;
1534
+ return { data: [{ b64_json: PNG_BASE64 }] };
1535
+ });
1536
+ process.env.OPENAI_API_KEY = 'test-key';
1537
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
1538
+
1539
+ try {
1540
+ const response = await editImage(
1541
+ agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' })
1542
+ );
1543
+
1544
+ assert.equal(response.status, 200);
1545
+ const body = await response.json();
1546
+ assert.equal(body.images[0].output_format, 'png');
1547
+ assert.equal(upstreamCalls, 1);
1548
+ } finally {
1549
+ await upstream.close();
1550
+ }
1551
+ });
1552
+
1553
+ it('reports missing image files for high-resolution Agent edit before API credentials', async () => {
1554
  const { editImage } = await loadAgentRoutes();
1555
  delete process.env.OPENAI_API_KEY;
1556
  delete process.env.OPENAI_API_BASE_URL;
 
1574
  assert.equal(response.status, 422);
1575
  const body = await response.json();
1576
  assert.equal(body.error.code, 'validation_error');
1577
+ assert.match(body.error.message, /图片文件/);
1578
+ assert.doesNotMatch(body.error.message, /API Key/);
 
1579
  });
1580
 
1581
+ it('allows auto-size Agent edit when the uploaded source image is high resolution', async () => {
1582
  const { editImage } = await loadAgentRoutes();
1583
  let upstreamCalls = 0;
1584
  const upstream = await startImageUpstream(() => {
 
1601
  )
1602
  );
1603
 
1604
+ assert.equal(response.status, 200);
1605
  const body = await response.json();
1606
+ assert.equal(body.images[0].output_format, 'png');
1607
+ assert.equal(upstreamCalls, 1);
 
1608
  } finally {
1609
  await upstream.close();
1610
  }
 
2131
  }
2132
 
2133
  function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
2134
+ const requestBody =
2135
+ 'stream_mode' in body || 'streaming_strategy' in body ? body : { ...body, stream_mode: 'non_stream' };
2136
  return new Request('http://localhost/api/agent/images/generate', {
2137
  method: 'POST',
2138
  headers: {
 
2140
  'Idempotency-Key': idempotencyKey,
2141
  ...headers
2142
  },
2143
+ body: JSON.stringify(requestBody)
2144
  });
2145
  }
2146
 
 
2149
  body: Record<string, unknown>,
2150
  headers: Record<string, string> = {}
2151
  ) {
2152
+ const requestBody =
2153
+ 'stream_mode' in body || 'streaming_strategy' in body ? body : { ...body, stream_mode: 'non_stream' };
2154
  return new Request('http://localhost/api/agent/jobs/images/generate', {
2155
  method: 'POST',
2156
  headers: {
 
2158
  'Idempotency-Key': idempotencyKey,
2159
  ...headers
2160
  },
2161
+ body: JSON.stringify(requestBody)
2162
  });
2163
  }
2164
 
 
2178
  typeof responseModeOrFields === 'string'
2179
  ? { response_mode: responseModeOrFields }
2180
  : { response_mode: 'path', ...responseModeOrFields };
2181
+ if (!('stream_mode' in fields) && !('streaming_strategy' in fields)) {
2182
+ fields.stream_mode = 'non_stream';
2183
+ }
2184
  const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64');
2185
  const formData = new FormData();
2186
  formData.append('prompt', prompt);
 
2288
  ) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>>
2289
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
2290
  const server = http.createServer(async (request, response) => {
2291
+ const isImageStreamPath =
2292
+ request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits');
2293
+ if (request.method !== 'POST' || !isImageStreamPath) {
2294
  response.writeHead(404, { 'Content-Type': 'application/json' });
2295
  response.end(JSON.stringify({ error: { message: 'not found' } }));
2296
  return;
src/app/api/agent/images/edit/route.ts CHANGED
@@ -1,7 +1,6 @@
1
  import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
2
  import { assertAgentAuthorized } from '@/lib/agent-auth';
3
  import {
4
- assertAgentEditRouteAllowedFromFormData,
5
  buildEditRequestHashFromSnapshot,
6
  completeAgentExecutionState,
7
  createArtifactPersistenceError,
@@ -31,7 +30,6 @@ export async function POST(request: NextRequest) {
31
  try {
32
  assertAgentAuthorized(request.headers);
33
  const formData = await parseAgentEditFormData(request);
34
- await assertAgentEditRouteAllowedFromFormData(formData);
35
  const idempotencyKey = readIdempotencyKey(request.headers);
36
  const requestSnapshot = await snapshotAgentEditFormData(formData);
37
  const store = await ensureAgentStateStoreReady();
 
1
  import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
2
  import { assertAgentAuthorized } from '@/lib/agent-auth';
3
  import {
 
4
  buildEditRequestHashFromSnapshot,
5
  completeAgentExecutionState,
6
  createArtifactPersistenceError,
 
30
  try {
31
  assertAgentAuthorized(request.headers);
32
  const formData = await parseAgentEditFormData(request);
 
33
  const idempotencyKey = readIdempotencyKey(request.headers);
34
  const requestSnapshot = await snapshotAgentEditFormData(formData);
35
  const store = await ensureAgentStateStoreReady();
src/app/api/images/route-test-helpers.ts CHANGED
@@ -14,10 +14,19 @@ export function imageFormRequest(input: {
14
  mode?: 'generate' | 'edit';
15
  imageBackend?: 'images' | 'responses' | 'images-api' | 'responses-image-generation';
16
  imageStreamingStrategy?: 'off' | 'auto' | 'openai-sse' | 'newapi-keepalive-sse' | 'responses-sse' | 'force-sse';
 
 
17
  size?: string;
18
  n?: string;
19
  responsesModel?: string;
 
 
 
 
 
 
20
  clientRequestId?: string;
 
21
  }): NextRequest {
22
  const formData = new FormData();
23
  formData.append('mode', input.mode || 'generate');
@@ -25,15 +34,33 @@ export function imageFormRequest(input: {
25
  formData.append('model', 'gpt-image-2');
26
  formData.append('n', input.n || '1');
27
  formData.append('size', input.size || '1024x1024');
28
- formData.append('output_format', 'png');
29
  formData.append('apiBaseUrl', input.apiBaseUrl);
30
  formData.append('apiKey', input.apiKey);
31
  formData.append('clientRequestId', input.clientRequestId ?? 'client-route-stream');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  if (input.imageBackend) {
33
  formData.append('imageBackend', input.imageBackend);
34
  }
35
  if (input.imageStreamingStrategy) {
36
- formData.append('imageStreamingStrategy', input.imageStreamingStrategy);
 
 
 
37
  }
38
  if (input.responsesModel) {
39
  formData.append('responsesModel', input.responsesModel);
@@ -47,7 +74,8 @@ export function imageFormRequest(input: {
47
  }
48
  return new Request('http://localhost/api/images', {
49
  method: 'POST',
50
- body: formData
 
51
  }) as NextRequest;
52
  }
53
 
@@ -85,10 +113,21 @@ export async function startStreamingImageUpstream(
85
  }
86
 
87
  export async function startImagesJsonUpstream(
88
- handler: (body: string, url: string) => Promise<unknown>
89
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
90
  const server = http.createServer(async (request, response) => {
91
  const isImagePath = request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits');
 
 
 
 
 
 
 
 
 
 
 
92
  if (request.method !== 'POST' || !isImagePath) {
93
  response.writeHead(404, { 'Content-Type': 'application/json' });
94
  response.end(JSON.stringify({ error: { message: 'not found' } }));
@@ -98,12 +137,112 @@ export async function startImagesJsonUpstream(
98
  request.on('data', (chunk: Buffer) => chunks.push(chunk));
99
  await new Promise<void>((resolve) => request.on('end', resolve));
100
  const payload = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '');
 
 
 
 
 
101
  response.writeHead(200, { 'Content-Type': 'application/json' });
102
  response.end(JSON.stringify(payload));
103
  });
104
  return listen(server);
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  export async function startResponsesImageUpstream(
108
  handler: (body: string) => Promise<unknown>
109
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
@@ -123,6 +262,45 @@ export async function startResponsesImageUpstream(
123
  return listen(server);
124
  }
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  export async function startStreamingResponsesImageUpstream(
127
  handler: (body: string) => Promise<Array<{ event?: string; data: unknown }>>
128
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
 
14
  mode?: 'generate' | 'edit';
15
  imageBackend?: 'images' | 'responses' | 'images-api' | 'responses-image-generation';
16
  imageStreamingStrategy?: 'off' | 'auto' | 'openai-sse' | 'newapi-keepalive-sse' | 'responses-sse' | 'force-sse';
17
+ imageStreamingStrategyField?: 'imageStreamingStrategy' | 'image_streaming_strategy';
18
+ streamMode?: 'auto' | 'stream' | 'non_stream';
19
  size?: string;
20
  n?: string;
21
  responsesModel?: string;
22
+ outputFormat?: 'png' | 'jpeg' | 'webp';
23
+ outputCompression?: string;
24
+ promptOptimization?: string;
25
+ gptModel?: string;
26
+ thinking?: string;
27
+ forceWeb?: string;
28
  clientRequestId?: string;
29
+ signal?: AbortSignal;
30
  }): NextRequest {
31
  const formData = new FormData();
32
  formData.append('mode', input.mode || 'generate');
 
34
  formData.append('model', 'gpt-image-2');
35
  formData.append('n', input.n || '1');
36
  formData.append('size', input.size || '1024x1024');
37
+ formData.append('output_format', input.outputFormat || 'png');
38
  formData.append('apiBaseUrl', input.apiBaseUrl);
39
  formData.append('apiKey', input.apiKey);
40
  formData.append('clientRequestId', input.clientRequestId ?? 'client-route-stream');
41
+ if (input.outputCompression) {
42
+ formData.append('output_compression', input.outputCompression);
43
+ }
44
+ if (input.promptOptimization) {
45
+ formData.append('promptOptimization', input.promptOptimization);
46
+ }
47
+ if (input.gptModel) {
48
+ formData.append('gptModel', input.gptModel);
49
+ }
50
+ if (input.thinking) {
51
+ formData.append('thinking', input.thinking);
52
+ }
53
+ if (input.forceWeb) {
54
+ formData.append('forceWeb', input.forceWeb);
55
+ }
56
  if (input.imageBackend) {
57
  formData.append('imageBackend', input.imageBackend);
58
  }
59
  if (input.imageStreamingStrategy) {
60
+ formData.append(input.imageStreamingStrategyField || 'imageStreamingStrategy', input.imageStreamingStrategy);
61
+ }
62
+ if (input.streamMode) {
63
+ formData.append('stream_mode', input.streamMode);
64
  }
65
  if (input.responsesModel) {
66
  formData.append('responsesModel', input.responsesModel);
 
74
  }
75
  return new Request('http://localhost/api/images', {
76
  method: 'POST',
77
+ body: formData,
78
+ signal: input.signal
79
  }) as NextRequest;
80
  }
81
 
 
113
  }
114
 
115
  export async function startImagesJsonUpstream(
116
+ handler: (body: string, url: string) => Promise<unknown | Buffer>
117
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
118
  const server = http.createServer(async (request, response) => {
119
  const isImagePath = request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits');
120
+ if (request.method === 'GET') {
121
+ const payload = await handler('', request.url || '');
122
+ if (Buffer.isBuffer(payload)) {
123
+ response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(payload.byteLength) });
124
+ response.end(payload);
125
+ return;
126
+ }
127
+ response.writeHead(404, { 'Content-Type': 'application/json' });
128
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
129
+ return;
130
+ }
131
  if (request.method !== 'POST' || !isImagePath) {
132
  response.writeHead(404, { 'Content-Type': 'application/json' });
133
  response.end(JSON.stringify({ error: { message: 'not found' } }));
 
137
  request.on('data', (chunk: Buffer) => chunks.push(chunk));
138
  await new Promise<void>((resolve) => request.on('end', resolve));
139
  const payload = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '');
140
+ if (Buffer.isBuffer(payload)) {
141
+ response.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': String(payload.byteLength) });
142
+ response.end(payload);
143
+ return;
144
+ }
145
  response.writeHead(200, { 'Content-Type': 'application/json' });
146
  response.end(JSON.stringify(payload));
147
  });
148
  return listen(server);
149
  }
150
 
151
+ export async function startImagesStreamFallbackUpstream(): Promise<{
152
+ baseUrl: string;
153
+ calls: Array<{ stream?: boolean; partial_images?: number }>;
154
+ close: () => Promise<void>;
155
+ }> {
156
+ const calls: Array<{ stream?: boolean; partial_images?: number }> = [];
157
+ const server = http.createServer(async (request, response) => {
158
+ if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
159
+ response.writeHead(404, { 'Content-Type': 'application/json' });
160
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
161
+ return;
162
+ }
163
+ const chunks: Buffer[] = [];
164
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
165
+ await new Promise<void>((resolve) => request.on('end', resolve));
166
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
167
+ stream?: boolean;
168
+ partial_images?: number;
169
+ };
170
+ calls.push({ stream: payload.stream, partial_images: payload.partial_images });
171
+ if (payload.stream) {
172
+ response.writeHead(200, { 'Content-Type': 'text/event-stream' });
173
+ response.write(
174
+ `event: image_generation.partial_image\ndata: ${JSON.stringify({
175
+ type: 'image_generation.partial_image',
176
+ b64_json: 'partial-before-fallback'
177
+ })}\n\n`
178
+ );
179
+ response.write('data: [DONE]\n\n');
180
+ response.end();
181
+ return;
182
+ }
183
+ response.writeHead(200, { 'Content-Type': 'application/json' });
184
+ response.end(JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] }));
185
+ });
186
+ const result = await listen(server);
187
+ return { ...result, calls };
188
+ }
189
+
190
+ export async function startHangingImagesStreamUpstream(): Promise<{
191
+ baseUrl: string;
192
+ calls: Array<{ stream?: boolean; partial_images?: number }>;
193
+ waitForStreamRequest: () => Promise<void>;
194
+ close: () => Promise<void>;
195
+ }> {
196
+ const calls: Array<{ stream?: boolean; partial_images?: number }> = [];
197
+ const activeResponses = new Set<http.ServerResponse>();
198
+ let resolveStreamRequest: () => void = () => {};
199
+ const streamRequest = new Promise<void>((resolve) => {
200
+ resolveStreamRequest = resolve;
201
+ });
202
+ const server = http.createServer(async (request, response) => {
203
+ if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
204
+ response.writeHead(404, { 'Content-Type': 'application/json' });
205
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
206
+ return;
207
+ }
208
+ const chunks: Buffer[] = [];
209
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
210
+ await new Promise<void>((resolve) => request.on('end', resolve));
211
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
212
+ stream?: boolean;
213
+ partial_images?: number;
214
+ };
215
+ calls.push({ stream: payload.stream, partial_images: payload.partial_images });
216
+ if (!payload.stream) {
217
+ response.writeHead(200, { 'Content-Type': 'application/json' });
218
+ response.end(JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] }));
219
+ return;
220
+ }
221
+ activeResponses.add(response);
222
+ response.on('close', () => activeResponses.delete(response));
223
+ response.writeHead(200, { 'Content-Type': 'text/event-stream' });
224
+ response.write(
225
+ `event: image_generation.partial_image\ndata: ${JSON.stringify({
226
+ type: 'image_generation.partial_image',
227
+ b64_json: 'partial-before-abort'
228
+ })}\n\n`
229
+ );
230
+ resolveStreamRequest();
231
+ });
232
+ const result = await listen(server);
233
+ return {
234
+ ...result,
235
+ calls,
236
+ waitForStreamRequest: () => streamRequest,
237
+ close: async () => {
238
+ for (const response of activeResponses) {
239
+ response.destroy();
240
+ }
241
+ await result.close();
242
+ }
243
+ };
244
+ }
245
+
246
  export async function startResponsesImageUpstream(
247
  handler: (body: string) => Promise<unknown>
248
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
 
262
  return listen(server);
263
  }
264
 
265
+ export async function startResponsesStreamFailureThenJsonUpstream(): Promise<{
266
+ baseUrl: string;
267
+ calls: Array<{ stream?: boolean }>;
268
+ close: () => Promise<void>;
269
+ }> {
270
+ const calls: Array<{ stream?: boolean }> = [];
271
+ const server = http.createServer(async (request, response) => {
272
+ if (request.method !== 'POST' || !request.url?.endsWith('/responses')) {
273
+ response.writeHead(404, { 'Content-Type': 'application/json' });
274
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
275
+ return;
276
+ }
277
+ const chunks: Buffer[] = [];
278
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
279
+ await new Promise<void>((resolve) => request.on('end', resolve));
280
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { stream?: boolean };
281
+ calls.push({ stream: payload.stream });
282
+ if (payload.stream) {
283
+ response.writeHead(500, { 'Content-Type': 'application/json' });
284
+ response.end(JSON.stringify({ error: { message: 'stream setup failed' } }));
285
+ return;
286
+ }
287
+ response.writeHead(200, { 'Content-Type': 'application/json' });
288
+ response.end(
289
+ JSON.stringify({
290
+ output: [
291
+ {
292
+ type: 'image_generation_call',
293
+ status: 'completed',
294
+ result: PNG_BASE64
295
+ }
296
+ ]
297
+ })
298
+ );
299
+ });
300
+ const result = await listen(server);
301
+ return { ...result, calls };
302
+ }
303
+
304
  export async function startStreamingResponsesImageUpstream(
305
  handler: (body: string) => Promise<Array<{ event?: string; data: unknown }>>
306
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
src/app/api/images/route.test.ts CHANGED
@@ -2,8 +2,11 @@ import {
2
  PNG_BASE64,
3
  imageFormRequest,
4
  readSseEvents,
 
5
  startImagesJsonUpstream,
 
6
  startResponsesImageUpstream,
 
7
  startStreamingResponsesImageUpstream,
8
  startStreamingImageUpstream
9
  } from './route-test-helpers';
@@ -32,8 +35,15 @@ beforeEach(() => {
32
  delete process.env.APP_PASSWORD;
33
  delete process.env.OPENAI_API_KEY;
34
  delete process.env.OPENAI_API_BASE_URL;
 
35
  delete process.env.OPENAI_CHANNEL_1_API_KEYS;
36
  delete process.env.OPENAI_CHANNEL_1_BASE_URL;
 
 
 
 
 
 
37
  delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND;
38
  delete process.env.IMAGE_GENERATION_BACKEND;
39
  delete process.env.OPENAI_RESPONSES_API_MODEL;
@@ -137,6 +147,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
137
  const { POST } = await import('./route');
138
  let upstreamBody = '';
139
  const upstream = await startImagesJsonUpstream(async (body) => {
 
140
  upstreamBody = body;
141
  return { data: [{ b64_json: PNG_BASE64 }] };
142
  });
@@ -169,6 +180,113 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
169
  }
170
  });
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  it('returns an explicit SSE error when the upstream completed event has no image payload', async () => {
173
  const { POST } = await import('./route');
174
  const upstream = await startStreamingImageUpstream(async () => [
@@ -285,6 +403,48 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
285
  }
286
  });
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  it('rejects explicit image stream requests when the server strategy disables streaming', async () => {
289
  process.env.IMAGE_STREAMING_STRATEGY = 'off';
290
  const { POST } = await import('./route');
@@ -435,12 +595,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
435
  it('rejects the experimental Responses API backend when the feature flag is disabled', async () => {
436
  const { POST } = await import('./route');
437
  const response = await POST(
438
- imageFormRequest({
439
- apiBaseUrl: 'http://127.0.0.1:1/v1',
440
- apiKey: 'test-key',
441
- stream: false,
442
- imageBackend: 'responses'
443
- })
 
444
  );
445
 
446
  assert.equal(response.status, 400);
@@ -452,12 +613,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
452
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
453
  const { POST } = await import('./route');
454
  const response = await POST(
455
- imageFormRequest({
456
- apiBaseUrl: 'http://127.0.0.1:1/v1',
457
- apiKey: 'test-key',
458
- stream: false,
459
- imageBackend: 'responses'
460
- })
 
461
  );
462
 
463
  assert.equal(response.status, 400);
@@ -471,28 +633,36 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
471
  const { POST } = await import('./route');
472
 
473
  const multiImage = await POST(
474
- imageFormRequest({
475
- apiBaseUrl: 'http://127.0.0.1:1/v1',
476
- apiKey: 'test-key',
477
- stream: false,
478
- imageBackend: 'responses',
479
- n: '2'
480
- })
 
481
  );
482
  assert.equal(multiImage.status, 400);
483
  assert.match(String(((await multiImage.json()) as Record<string, unknown>).error), /单张生成/);
 
484
 
 
 
 
 
485
  const edit = await POST(
486
- imageFormRequest({
487
- apiBaseUrl: 'http://127.0.0.1:1/v1',
488
- apiKey: 'test-key',
489
- stream: false,
490
- imageBackend: 'responses',
491
- mode: 'edit'
492
- })
 
 
493
  );
494
  assert.equal(edit.status, 400);
495
- assert.match(String(((await edit.json()) as Record<string, unknown>).error), /只支持 generate/);
496
  });
497
 
498
  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 }, () => {
520
  apiBaseUrl: upstream.baseUrl,
521
  apiKey: 'test-key',
522
  stream: false,
 
523
  imageBackend: 'responses-image-generation'
524
  })
525
  );
@@ -565,6 +736,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
565
  apiBaseUrl: upstream.baseUrl,
566
  apiKey: 'test-key',
567
  stream: false,
 
568
  imageBackend: 'responses-image-generation'
569
  })
570
  );
@@ -588,13 +760,14 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
588
  imageFormRequest({
589
  apiBaseUrl: upstream.baseUrl,
590
  apiKey: 'test-key',
591
- stream: false
 
592
  })
593
  );
594
 
595
  assert.equal(response.status, 502);
596
  const body = (await response.json()) as Record<string, unknown>;
597
- assert.match(String(body.error), /base64/);
598
  assert.equal(JSON.stringify(body).includes('https://example.test/final.png'), false);
599
  } finally {
600
  await upstream.close();
@@ -841,7 +1014,8 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
841
  imageFormRequest({
842
  apiBaseUrl: upstream.baseUrl,
843
  apiKey: 'test-key',
844
- stream: false
 
845
  })
846
  );
847
 
@@ -856,12 +1030,68 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
856
  }
857
  });
858
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
  it('does not apply the generate backend env default to edit requests', async () => {
860
  process.env.IMAGE_GENERATION_BACKEND = 'responses';
861
  const { POST } = await import('./route');
862
  let upstreamUrl = '';
863
  const upstream = await startImagesJsonUpstream(async (_body, url) => {
864
- upstreamUrl = url;
 
 
865
  return { data: [{ b64_json: PNG_BASE64 }] };
866
  });
867
 
@@ -871,7 +1101,8 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
871
  apiBaseUrl: upstream.baseUrl,
872
  apiKey: 'test-key',
873
  mode: 'edit',
874
- stream: false
 
875
  })
876
  );
877
 
@@ -953,6 +1184,22 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
953
  }
954
  });
955
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
956
  it('treats blank APP_PASSWORD as disabled for page SSE auth', async () => {
957
  process.env.APP_PASSWORD = ' ';
958
  const { POST } = await import('./route');
@@ -1010,7 +1257,7 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
1010
  }
1011
  });
1012
 
1013
- it('rejects invalid gpt-image-2 custom sizes before contacting upstream', async () => {
1014
  const { POST } = await import('./route');
1015
  let upstreamCalls = 0;
1016
  const upstream = await startImagesJsonUpstream(async () => {
@@ -1018,26 +1265,70 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
1018
  return { data: [{ b64_json: PNG_BASE64 }] };
1019
  });
1020
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1021
  try {
1022
  const response = await POST(
1023
  imageFormRequest({
1024
  apiBaseUrl: upstream.baseUrl,
1025
  apiKey: 'test-key',
1026
- size: '2049x2048'
 
 
 
1027
  })
1028
  );
1029
 
1030
- assert.equal(response.status, 400);
1031
- const body = (await response.json()) as Record<string, unknown>;
1032
- assert.match(String(body.error), /size 对 gpt-image-2 无效/);
1033
- assert.match(String(body.error), /16 的倍数/);
1034
- assert.equal(upstreamCalls, 0);
1035
  } finally {
1036
  await upstream.close();
1037
  }
1038
  });
1039
 
1040
- it('lets request responsesModel override the experimental backend env model', async () => {
1041
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
1042
  process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env';
1043
  const { POST } = await import('./route');
@@ -1061,14 +1352,178 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
1061
  apiBaseUrl: upstream.baseUrl,
1062
  apiKey: 'test-key',
1063
  stream: false,
 
1064
  imageBackend: 'responses',
1065
- responsesModel: 'gpt-4.1-request'
 
 
 
 
 
1066
  })
1067
  );
1068
 
1069
  assert.equal(response.status, 200);
1070
  const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
1071
- assert.equal(upstreamJson.model, 'gpt-4.1-request');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  } finally {
1073
  await upstream.close();
1074
  }
 
2
  PNG_BASE64,
3
  imageFormRequest,
4
  readSseEvents,
5
+ startHangingImagesStreamUpstream,
6
  startImagesJsonUpstream,
7
+ startImagesStreamFallbackUpstream,
8
  startResponsesImageUpstream,
9
+ startResponsesStreamFailureThenJsonUpstream,
10
  startStreamingResponsesImageUpstream,
11
  startStreamingImageUpstream
12
  } from './route-test-helpers';
 
35
  delete process.env.APP_PASSWORD;
36
  delete process.env.OPENAI_API_KEY;
37
  delete process.env.OPENAI_API_BASE_URL;
38
+ delete process.env.OPENAI_CHANNEL_1_ID;
39
  delete process.env.OPENAI_CHANNEL_1_API_KEYS;
40
  delete process.env.OPENAI_CHANNEL_1_BASE_URL;
41
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED;
42
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS;
43
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS;
44
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK;
45
+ delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY;
46
+ delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS;
47
  delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND;
48
  delete process.env.IMAGE_GENERATION_BACKEND;
49
  delete process.env.OPENAI_RESPONSES_API_MODEL;
 
147
  const { POST } = await import('./route');
148
  let upstreamBody = '';
149
  const upstream = await startImagesJsonUpstream(async (body) => {
150
+ if (!body) return { ok: true };
151
  upstreamBody = body;
152
  return { data: [{ b64_json: PNG_BASE64 }] };
153
  });
 
180
  }
181
  });
182
 
183
+ it('falls back from auto streaming without a final image and skips streaming for the same mark', async () => {
184
+ const { POST } = await import('./route');
185
+ const { getServerChannelState } = await import('@/lib/server-channel-router');
186
+ const upstream = await startImagesStreamFallbackUpstream();
187
+ const otherUpstream = await startImagesStreamFallbackUpstream();
188
+
189
+ try {
190
+ const first = await POST(
191
+ imageFormRequest({
192
+ apiBaseUrl: upstream.baseUrl,
193
+ apiKey: 'test-key',
194
+ streamMode: 'auto',
195
+ clientRequestId: 'client-route-auto-fallback-1'
196
+ })
197
+ );
198
+
199
+ assert.equal(first.status, 200);
200
+ assert.equal(first.headers.get('content-type'), 'text/event-stream');
201
+ const events = await readSseEvents(first);
202
+ assert.deepEqual(
203
+ events.map((event) => event.type),
204
+ ['partial_image', 'completed', 'done']
205
+ );
206
+ assert.equal(events[2].fallback_used, true);
207
+ assert.deepEqual(
208
+ upstream.calls.map((call) => call.stream),
209
+ [true, false]
210
+ );
211
+ assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 1);
212
+
213
+ const second = await POST(
214
+ imageFormRequest({
215
+ apiBaseUrl: upstream.baseUrl,
216
+ apiKey: 'test-key',
217
+ streamMode: 'auto',
218
+ clientRequestId: 'client-route-auto-fallback-2'
219
+ })
220
+ );
221
+
222
+ assert.equal(second.status, 200);
223
+ assert.notEqual(second.headers.get('content-type'), 'text/event-stream');
224
+ const body = (await second.json()) as { images?: Array<Record<string, unknown>> };
225
+ assert.equal(body.images?.[0]?.b64_json, PNG_BASE64);
226
+ assert.deepEqual(
227
+ upstream.calls.map((call) => call.stream),
228
+ [true, false, false]
229
+ );
230
+
231
+ const third = await POST(
232
+ imageFormRequest({
233
+ apiBaseUrl: otherUpstream.baseUrl,
234
+ apiKey: 'test-key',
235
+ streamMode: 'auto',
236
+ clientRequestId: 'client-route-auto-fallback-3'
237
+ })
238
+ );
239
+
240
+ assert.equal(third.status, 200);
241
+ assert.equal(third.headers.get('content-type'), 'text/event-stream');
242
+ await readSseEvents(third);
243
+ assert.deepEqual(
244
+ otherUpstream.calls.map((call) => call.stream),
245
+ [true, false]
246
+ );
247
+ } finally {
248
+ await upstream.close();
249
+ await otherUpstream.close();
250
+ }
251
+ });
252
+
253
+ it('does not mark auto streaming unavailable when the page SSE request is aborted', async () => {
254
+ const { POST } = await import('./route');
255
+ const { getServerChannelState } = await import('@/lib/server-channel-router');
256
+ const upstream = await startHangingImagesStreamUpstream();
257
+ const abortController = new AbortController();
258
+
259
+ try {
260
+ const response = await POST(
261
+ imageFormRequest({
262
+ apiBaseUrl: upstream.baseUrl,
263
+ apiKey: 'test-key',
264
+ streamMode: 'auto',
265
+ clientRequestId: 'client-route-auto-abort',
266
+ signal: abortController.signal
267
+ })
268
+ );
269
+
270
+ assert.equal(response.status, 200);
271
+ assert.equal(response.headers.get('content-type'), 'text/event-stream');
272
+ const reader = response.body?.getReader();
273
+ assert.ok(reader);
274
+ await upstream.waitForStreamRequest();
275
+ abortController.abort();
276
+ await reader.cancel();
277
+ await new Promise((resolve) => setTimeout(resolve, 25));
278
+
279
+ assert.deepEqual(
280
+ upstream.calls.map((call) => call.stream),
281
+ [true]
282
+ );
283
+ assert.equal(getServerChannelState().streamingAvailability.summary().mark_count, 0);
284
+ } finally {
285
+ abortController.abort();
286
+ await upstream.close();
287
+ }
288
+ });
289
+
290
  it('returns an explicit SSE error when the upstream completed event has no image payload', async () => {
291
  const { POST } = await import('./route');
292
  const upstream = await startStreamingImageUpstream(async () => [
 
403
  }
404
  });
405
 
406
+ it('accepts snake_case image streaming strategy on page SSE edit requests', async () => {
407
+ const { POST } = await import('./route');
408
+ let upstreamUrl = '';
409
+ let upstreamBody = '';
410
+ const upstream = await startStreamingImageUpstream(async (body, url) => {
411
+ upstreamUrl = url;
412
+ upstreamBody = body;
413
+ return [
414
+ {
415
+ event: 'image_edit.completed',
416
+ data: { type: 'image_edit.completed', b64_json: PNG_BASE64 }
417
+ }
418
+ ];
419
+ });
420
+
421
+ try {
422
+ const response = await POST(
423
+ imageFormRequest({
424
+ apiBaseUrl: upstream.baseUrl,
425
+ apiKey: 'test-key',
426
+ mode: 'edit',
427
+ stream: true,
428
+ imageStreamingStrategy: 'force-sse',
429
+ imageStreamingStrategyField: 'image_streaming_strategy'
430
+ })
431
+ );
432
+
433
+ assert.equal(response.status, 200);
434
+ assert.equal(response.headers.get('content-type'), 'text/event-stream');
435
+ const events = await readSseEvents(response);
436
+ assert.deepEqual(
437
+ events.map((event) => event.type),
438
+ ['completed', 'done']
439
+ );
440
+ assert.equal(upstreamUrl, '/v1/images/edits');
441
+ assert.match(upstreamBody, /name="stream"/);
442
+ assert.match(upstreamBody, /name="partial_images"/);
443
+ } finally {
444
+ await upstream.close();
445
+ }
446
+ });
447
+
448
  it('rejects explicit image stream requests when the server strategy disables streaming', async () => {
449
  process.env.IMAGE_STREAMING_STRATEGY = 'off';
450
  const { POST } = await import('./route');
 
595
  it('rejects the experimental Responses API backend when the feature flag is disabled', async () => {
596
  const { POST } = await import('./route');
597
  const response = await POST(
598
+ imageFormRequest({
599
+ apiBaseUrl: 'http://127.0.0.1:1/v1',
600
+ apiKey: 'test-key',
601
+ stream: false,
602
+ streamMode: 'non_stream',
603
+ imageBackend: 'responses'
604
+ })
605
  );
606
 
607
  assert.equal(response.status, 400);
 
613
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
614
  const { POST } = await import('./route');
615
  const response = await POST(
616
+ imageFormRequest({
617
+ apiBaseUrl: 'http://127.0.0.1:1/v1',
618
+ apiKey: 'test-key',
619
+ stream: false,
620
+ streamMode: 'non_stream',
621
+ imageBackend: 'responses'
622
+ })
623
  );
624
 
625
  assert.equal(response.status, 400);
 
633
  const { POST } = await import('./route');
634
 
635
  const multiImage = await POST(
636
+ imageFormRequest({
637
+ apiBaseUrl: 'http://127.0.0.1:1/v1',
638
+ apiKey: 'test-key',
639
+ stream: false,
640
+ streamMode: 'non_stream',
641
+ imageBackend: 'responses',
642
+ n: '2'
643
+ })
644
  );
645
  assert.equal(multiImage.status, 400);
646
  assert.match(String(((await multiImage.json()) as Record<string, unknown>).error), /单张生成/);
647
+ });
648
 
649
+ it('rejects multi-image edit requests for the Responses API backend before contacting upstream', async () => {
650
+ process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
651
+ process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1';
652
+ const { POST } = await import('./route');
653
  const edit = await POST(
654
+ imageFormRequest({
655
+ apiBaseUrl: 'http://127.0.0.1:1/v1',
656
+ apiKey: 'test-key',
657
+ stream: false,
658
+ streamMode: 'non_stream',
659
+ imageBackend: 'responses',
660
+ n: '2',
661
+ mode: 'edit'
662
+ })
663
  );
664
  assert.equal(edit.status, 400);
665
+ assert.match(String(((await edit.json()) as Record<string, unknown>).error), /单张编辑/);
666
  });
667
 
668
  it('uses the Responses API image backend only when the flag and request opt-in are both present', async () => {
 
690
  apiBaseUrl: upstream.baseUrl,
691
  apiKey: 'test-key',
692
  stream: false,
693
+ streamMode: 'non_stream',
694
  imageBackend: 'responses-image-generation'
695
  })
696
  );
 
736
  apiBaseUrl: upstream.baseUrl,
737
  apiKey: 'test-key',
738
  stream: false,
739
+ streamMode: 'non_stream',
740
  imageBackend: 'responses-image-generation'
741
  })
742
  );
 
760
  imageFormRequest({
761
  apiBaseUrl: upstream.baseUrl,
762
  apiKey: 'test-key',
763
+ stream: false,
764
+ streamMode: 'non_stream'
765
  })
766
  );
767
 
768
  assert.equal(response.status, 502);
769
  const body = (await response.json()) as Record<string, unknown>;
770
+ assert.match(String(body.error), /同源/);
771
  assert.equal(JSON.stringify(body).includes('https://example.test/final.png'), false);
772
  } finally {
773
  await upstream.close();
 
1014
  imageFormRequest({
1015
  apiBaseUrl: upstream.baseUrl,
1016
  apiKey: 'test-key',
1017
+ stream: false,
1018
+ streamMode: 'non_stream'
1019
  })
1020
  );
1021
 
 
1030
  }
1031
  });
1032
 
1033
+ it('downloads same-origin GPT2Image URL results before persisting Images API JSON responses', async () => {
1034
+ const { POST } = await import('./route');
1035
+ let imageDownloadCount = 0;
1036
+ const upstream = await startImagesJsonUpstream(async (_body, url) => {
1037
+ if (url === '/generated/final.png') {
1038
+ imageDownloadCount += 1;
1039
+ return Buffer.from(PNG_BASE64, 'base64');
1040
+ }
1041
+ return { data: [{ url: '/generated/final.png' }] };
1042
+ });
1043
+
1044
+ try {
1045
+ const response = await POST(
1046
+ imageFormRequest({
1047
+ apiBaseUrl: upstream.baseUrl,
1048
+ apiKey: 'test-key',
1049
+ stream: false,
1050
+ streamMode: 'non_stream'
1051
+ })
1052
+ );
1053
+
1054
+ assert.equal(response.status, 200);
1055
+ const body = (await response.json()) as { images?: Array<Record<string, unknown>> };
1056
+ assert.equal(body.images?.[0]?.b64_json, PNG_BASE64);
1057
+ assert.equal(imageDownloadCount, 1);
1058
+ } finally {
1059
+ await upstream.close();
1060
+ }
1061
+ });
1062
+
1063
+ it('rejects cross-origin GPT2Image URL results before downloading them', async () => {
1064
+ const { POST } = await import('./route');
1065
+ const upstream = await startImagesJsonUpstream(async () => {
1066
+ return { data: [{ url: 'https://other.example.test/generated/final.png' }] };
1067
+ });
1068
+
1069
+ try {
1070
+ const response = await POST(
1071
+ imageFormRequest({
1072
+ apiBaseUrl: upstream.baseUrl,
1073
+ apiKey: 'test-key',
1074
+ stream: false,
1075
+ streamMode: 'non_stream'
1076
+ })
1077
+ );
1078
+
1079
+ assert.equal(response.status, 502);
1080
+ const body = (await response.json()) as Record<string, unknown>;
1081
+ assert.match(String(body.error), /同源/);
1082
+ } finally {
1083
+ await upstream.close();
1084
+ }
1085
+ });
1086
+
1087
  it('does not apply the generate backend env default to edit requests', async () => {
1088
  process.env.IMAGE_GENERATION_BACKEND = 'responses';
1089
  const { POST } = await import('./route');
1090
  let upstreamUrl = '';
1091
  const upstream = await startImagesJsonUpstream(async (_body, url) => {
1092
+ if (url !== '/api/log/token') {
1093
+ upstreamUrl = url;
1094
+ }
1095
  return { data: [{ b64_json: PNG_BASE64 }] };
1096
  });
1097
 
 
1101
  apiBaseUrl: upstream.baseUrl,
1102
  apiKey: 'test-key',
1103
  mode: 'edit',
1104
+ stream: false,
1105
+ streamMode: 'non_stream'
1106
  })
1107
  );
1108
 
 
1184
  }
1185
  });
1186
 
1187
+ it('rejects remote plain-http API base URLs before forwarding API keys', async () => {
1188
+ const { POST } = await import('./route');
1189
+
1190
+ const response = await POST(
1191
+ imageFormRequest({
1192
+ apiBaseUrl: 'http://api.example.com/v1',
1193
+ apiKey: 'test-key',
1194
+ stream: false
1195
+ })
1196
+ );
1197
+
1198
+ assert.equal(response.status, 400);
1199
+ const body = (await response.json()) as Record<string, unknown>;
1200
+ assert.match(String(body.error), /远程 HTTP API URL/);
1201
+ });
1202
+
1203
  it('treats blank APP_PASSWORD as disabled for page SSE auth', async () => {
1204
  process.env.APP_PASSWORD = ' ';
1205
  const { POST } = await import('./route');
 
1257
  }
1258
  });
1259
 
1260
+ it('rejects invalid gpt-image-2 custom size boundaries before contacting upstream', async () => {
1261
  const { POST } = await import('./route');
1262
  let upstreamCalls = 0;
1263
  const upstream = await startImagesJsonUpstream(async () => {
 
1265
  return { data: [{ b64_json: PNG_BASE64 }] };
1266
  });
1267
 
1268
+ try {
1269
+ for (const { size, pattern } of [
1270
+ { size: '512x512', pattern: /至少/ },
1271
+ { size: '3840x3840', pattern: /不能超过/ },
1272
+ { size: '2049x2048', pattern: /16 的倍数/ }
1273
+ ]) {
1274
+ const response = await POST(
1275
+ imageFormRequest({
1276
+ apiBaseUrl: upstream.baseUrl,
1277
+ apiKey: 'test-key',
1278
+ size
1279
+ })
1280
+ );
1281
+
1282
+ assert.equal(response.status, 400);
1283
+ const body = (await response.json()) as Record<string, unknown>;
1284
+ assert.match(String(body.error), /size 对 gpt-image-2 无效/);
1285
+ assert.match(String(body.error), pattern);
1286
+ }
1287
+ assert.equal(upstreamCalls, 0);
1288
+ } finally {
1289
+ await upstream.close();
1290
+ }
1291
+ });
1292
+
1293
+ it('lets request responsesModel override the experimental backend env model', async () => {
1294
+ process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
1295
+ process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env';
1296
+ const { POST } = await import('./route');
1297
+ let upstreamBody = '';
1298
+ const upstream = await startResponsesImageUpstream(async (body) => {
1299
+ upstreamBody = body;
1300
+ return {
1301
+ output: [
1302
+ {
1303
+ type: 'image_generation_call',
1304
+ status: 'completed',
1305
+ result: PNG_BASE64
1306
+ }
1307
+ ]
1308
+ };
1309
+ });
1310
+
1311
  try {
1312
  const response = await POST(
1313
  imageFormRequest({
1314
  apiBaseUrl: upstream.baseUrl,
1315
  apiKey: 'test-key',
1316
+ stream: false,
1317
+ streamMode: 'non_stream',
1318
+ imageBackend: 'responses',
1319
+ responsesModel: 'gpt-4.1-request'
1320
  })
1321
  );
1322
 
1323
+ assert.equal(response.status, 200);
1324
+ const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
1325
+ assert.equal(upstreamJson.model, 'gpt-4.1-request');
 
 
1326
  } finally {
1327
  await upstream.close();
1328
  }
1329
  });
1330
 
1331
+ it('passes GPT2Image-compatible extended fields to the Responses image backend', async () => {
1332
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
1333
  process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env';
1334
  const { POST } = await import('./route');
 
1352
  apiBaseUrl: upstream.baseUrl,
1353
  apiKey: 'test-key',
1354
  stream: false,
1355
+ streamMode: 'non_stream',
1356
  imageBackend: 'responses',
1357
+ size: '1536x864',
1358
+ outputFormat: 'webp',
1359
+ outputCompression: '85',
1360
+ promptOptimization: 'false',
1361
+ gptModel: 'gpt-5.4-mini',
1362
+ thinking: 'high'
1363
  })
1364
  );
1365
 
1366
  assert.equal(response.status, 200);
1367
  const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
1368
+ assert.equal(upstreamJson.model, 'gpt-5.4-mini');
1369
+ const tools = upstreamJson.tools as Array<Record<string, unknown>>;
1370
+ assert.equal(tools[0].size, '1536x864');
1371
+ assert.equal(tools[0].output_format, 'webp');
1372
+ assert.equal(tools[0].output_compression, 85);
1373
+ assert.equal(tools[0].prompt_optimization, false);
1374
+ assert.equal(tools[0].thinking, 'high');
1375
+ } finally {
1376
+ await upstream.close();
1377
+ }
1378
+ });
1379
+
1380
+ it('passes GPT2Image-compatible edit fields to the Images API backend', async () => {
1381
+ const { POST } = await import('./route');
1382
+ let upstreamBody = '';
1383
+ const upstream = await startImagesJsonUpstream(async (body) => {
1384
+ if (!body) return { ok: true };
1385
+ upstreamBody = body;
1386
+ return { data: [{ b64_json: PNG_BASE64 }] };
1387
+ });
1388
+
1389
+ try {
1390
+ const response = await POST(
1391
+ imageFormRequest({
1392
+ apiBaseUrl: upstream.baseUrl,
1393
+ apiKey: 'test-key',
1394
+ mode: 'edit',
1395
+ stream: false,
1396
+ streamMode: 'non_stream',
1397
+ outputFormat: 'webp',
1398
+ outputCompression: '85',
1399
+ forceWeb: 'true'
1400
+ })
1401
+ );
1402
+
1403
+ assert.equal(response.status, 200);
1404
+ assert.match(upstreamBody, /name="output_format"/);
1405
+ assert.match(upstreamBody, /\r\nwebp\r\n/);
1406
+ assert.match(upstreamBody, /name="output_compression"/);
1407
+ assert.match(upstreamBody, /\r\n85\r\n/);
1408
+ assert.match(upstreamBody, /name="force_web"/);
1409
+ assert.match(upstreamBody, /\r\ntrue\r\n/);
1410
+ assert.match(upstreamBody, /name="moderation"/);
1411
+ assert.match(upstreamBody, /\r\nauto\r\n/);
1412
+ } finally {
1413
+ await upstream.close();
1414
+ }
1415
+ });
1416
+
1417
+ it('passes reference images and GPT2Image-compatible fields to the Responses edit backend', async () => {
1418
+ process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
1419
+ process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env';
1420
+ const { POST } = await import('./route');
1421
+ let upstreamBody = '';
1422
+ const upstream = await startResponsesImageUpstream(async (body) => {
1423
+ upstreamBody = body;
1424
+ return {
1425
+ output: [
1426
+ {
1427
+ type: 'image_generation_call',
1428
+ status: 'completed',
1429
+ result: PNG_BASE64
1430
+ }
1431
+ ]
1432
+ };
1433
+ });
1434
+
1435
+ try {
1436
+ const response = await POST(
1437
+ imageFormRequest({
1438
+ apiBaseUrl: upstream.baseUrl,
1439
+ apiKey: 'test-key',
1440
+ mode: 'edit',
1441
+ stream: false,
1442
+ streamMode: 'non_stream',
1443
+ imageBackend: 'responses',
1444
+ size: '1536x864',
1445
+ outputFormat: 'webp',
1446
+ outputCompression: '85',
1447
+ promptOptimization: 'false',
1448
+ gptModel: 'gpt-5.4-mini',
1449
+ thinking: 'high'
1450
+ })
1451
+ );
1452
+
1453
+ assert.equal(response.status, 200);
1454
+ const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
1455
+ assert.equal(upstreamJson.model, 'gpt-5.4-mini');
1456
+ assert.equal(upstreamJson.stream, false);
1457
+ const input = upstreamJson.input as Array<Record<string, unknown>>;
1458
+ assert.equal(input[0].role, 'user');
1459
+ const content = input[0].content as Array<Record<string, unknown>>;
1460
+ assert.equal(content[0].type, 'input_text');
1461
+ assert.equal(content[0].text, 'route stream contract');
1462
+ assert.equal(content[1].type, 'input_image');
1463
+ assert.match(String(content[1].image_url), /^data:image\/png;base64,/);
1464
+ const tools = upstreamJson.tools as Array<Record<string, unknown>>;
1465
+ assert.equal(tools[0].type, 'image_generation');
1466
+ assert.equal(tools[0].size, '1536x864');
1467
+ assert.equal(tools[0].output_format, 'webp');
1468
+ assert.equal(tools[0].output_compression, 85);
1469
+ assert.equal(tools[0].prompt_optimization, false);
1470
+ assert.equal(tools[0].thinking, 'high');
1471
+ } finally {
1472
+ await upstream.close();
1473
+ }
1474
+ });
1475
+
1476
+ it('falls back when Responses edit stream setup fails before returning SSE', async () => {
1477
+ process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
1478
+ process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1';
1479
+ const { POST } = await import('./route');
1480
+ const upstream = await startResponsesStreamFailureThenJsonUpstream();
1481
+
1482
+ try {
1483
+ const response = await POST(
1484
+ imageFormRequest({
1485
+ apiBaseUrl: upstream.baseUrl,
1486
+ apiKey: 'test-key',
1487
+ mode: 'edit',
1488
+ streamMode: 'auto',
1489
+ imageBackend: 'responses'
1490
+ })
1491
+ );
1492
+
1493
+ assert.equal(response.status, 200);
1494
+ assert.match(response.headers.get('content-type') || '', /application\/json/);
1495
+ const body = (await response.json()) as { images?: Array<Record<string, unknown>> };
1496
+ assert.equal(body.images?.[0]?.b64_json, PNG_BASE64);
1497
+ assert.equal(upstream.calls[0]?.stream, true);
1498
+ assert.equal(upstream.calls[upstream.calls.length - 1]?.stream, false);
1499
+ } finally {
1500
+ await upstream.close();
1501
+ }
1502
+ });
1503
+
1504
+ it('passes GPT2Image force_web aliases through to the Images API backend', async () => {
1505
+ const { POST } = await import('./route');
1506
+ let upstreamBody = '';
1507
+ const upstream = await startImagesJsonUpstream(async (body) => {
1508
+ if (!body) return { ok: true };
1509
+ upstreamBody = body;
1510
+ return { data: [{ b64_json: PNG_BASE64 }] };
1511
+ });
1512
+
1513
+ try {
1514
+ const response = await POST(
1515
+ imageFormRequest({
1516
+ apiBaseUrl: upstream.baseUrl,
1517
+ apiKey: 'test-key',
1518
+ stream: false,
1519
+ streamMode: 'non_stream',
1520
+ forceWeb: 'true'
1521
+ })
1522
+ );
1523
+
1524
+ assert.equal(response.status, 200);
1525
+ const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
1526
+ assert.equal(upstreamJson.force_web, true);
1527
  } finally {
1528
  await upstream.close();
1529
  }
src/app/api/images/route.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
  readCount,
7
  readMode,
8
  readModel,
 
9
  readRequiredText,
10
  readStorageMode,
11
  validateApiBaseUrl
@@ -30,22 +31,147 @@ import {
30
  } from '@/lib/image-service';
31
  import {
32
  readImageGenerationBackend,
 
33
  readImageStreamingStrategy,
34
- resolveImageStreamEnabled
 
 
 
35
  } from '@/lib/image-upstream-strategy';
36
  import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
37
  import { getServerChannelState } from '@/lib/server-channel-router';
 
38
  import { buildAccessCookie, readAffinityKey, verifyPasswordHash } from '@/lib/server-runtime';
 
39
  import { NextRequest, NextResponse } from 'next/server';
40
  import OpenAI from 'openai';
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  export async function POST(request: NextRequest) {
43
  let selectedServerCredential: ChannelCredential | undefined;
44
  let clientRequestId: string | undefined;
45
  let requestLogContext: RequestLogContext | undefined;
46
  let accessCookie: AccessCookie | undefined;
47
  try {
48
- const serverChannelRouter = getServerChannelState().router;
 
49
  const contentType = request.headers.get('content-type') || '';
50
  if (
51
  !contentType.includes('multipart/form-data') &&
@@ -58,8 +184,11 @@ export async function POST(request: NextRequest) {
58
  requestLogContext = clientRequestId ? { clientRequestId } : undefined;
59
  const requestApiKey = String(formData.get('apiKey') || '').trim();
60
  const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim();
 
 
 
61
  assertSafeApiOverride(requestApiKey, requestApiBaseUrl);
62
- validateApiBaseUrl(requestApiBaseUrl);
63
  selectedServerCredential = requestApiKey
64
  ? undefined
65
  : serverChannelRouter?.select({ affinityKey: readAffinityKey(request.headers) });
@@ -73,7 +202,7 @@ export async function POST(request: NextRequest) {
73
  legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
74
  selectedCredential: selectedServerCredential
75
  });
76
- validateApiBaseUrl(effectiveApiBaseUrl || '');
77
 
78
  if (!effectiveApiKey) {
79
  appLogger.error('未设置 OPENAI_API_KEY,且请求未提供 API Key。', requestLogContext);
@@ -132,24 +261,34 @@ export async function POST(request: NextRequest) {
132
  requestLogContext
133
  );
134
 
135
- const requestedStream = formData.get('stream') === 'true';
136
  const partialImagesCount = readCount(formData, 'partial_images', 2, 1, 3) as 1 | 2 | 3;
137
- const imageBackend = readImageGenerationBackend(formData, process.env, { useEnvDefault: mode === 'generate' });
 
 
138
  const streamingStrategy = readImageStreamingStrategy(formData, process.env, {
139
  useEnvDefault: mode === 'generate'
140
  });
141
- const streamEnabled = resolveImageStreamEnabled({
 
142
  imageBackend,
143
- requestedStream,
144
- streamingStrategy
 
 
 
 
 
145
  });
146
  assertResponsesImageBackendAllowed({ imageBackend, mode });
147
  appLogger.info('图片上游兼容策略。', {
148
  ...requestLogContext,
149
  imageBackend,
150
  streamingStrategy,
151
- requestedStream,
152
- streamEnabled
 
 
153
  });
154
 
155
  const modeResult =
@@ -159,7 +298,7 @@ export async function POST(request: NextRequest) {
159
  openai,
160
  model,
161
  prompt,
162
- streamEnabled,
163
  partialImagesCount,
164
  imageBackend,
165
  storageMode: effectiveStorageMode,
@@ -170,15 +309,25 @@ export async function POST(request: NextRequest) {
170
  requestLogContext,
171
  selectedCredential,
172
  accessCookie,
173
- abortSignal: request.signal
 
 
 
 
 
 
 
 
 
174
  })
175
  : await handleEditImageMode({
176
  formData,
177
  openai,
178
  model,
179
  prompt,
180
- streamEnabled,
181
  partialImagesCount,
 
182
  storageMode: effectiveStorageMode,
183
  apiBaseUrl: effectiveApiBaseUrl,
184
  apiKey: effectiveApiKey,
@@ -187,7 +336,16 @@ export async function POST(request: NextRequest) {
187
  requestLogContext,
188
  selectedCredential,
189
  accessCookie,
190
- abortSignal: request.signal
 
 
 
 
 
 
 
 
 
191
  });
192
  if (modeResult instanceof Response) {
193
  return modeResult;
@@ -201,7 +359,10 @@ export async function POST(request: NextRequest) {
201
  result,
202
  outputFormat: responseOutputFormat,
203
  storageMode: effectiveStorageMode,
204
- includeBase64: true
 
 
 
205
  });
206
  const savedImagesData = savedImages.map((image) => ({
207
  ...persistedImageToLegacyResponse(image),
 
6
  readCount,
7
  readMode,
8
  readModel,
9
+ readPlainHttpApiBaseUrlAllowlist,
10
  readRequiredText,
11
  readStorageMode,
12
  validateApiBaseUrl
 
31
  } from '@/lib/image-service';
32
  import {
33
  readImageGenerationBackend,
34
+ readImageStreamMode,
35
  readImageStreamingStrategy,
36
+ resolveImageStreamEnabled,
37
+ type ImageGenerationBackend,
38
+ type ImageStreamMode,
39
+ type ImageStreamingStrategy
40
  } from '@/lib/image-upstream-strategy';
41
  import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
42
  import { getServerChannelState } from '@/lib/server-channel-router';
43
+ import type { StreamingAvailabilityKey, StreamingOperation } from '@/lib/streaming-availability';
44
  import { buildAccessCookie, readAffinityKey, verifyPasswordHash } from '@/lib/server-runtime';
45
+ import crypto from 'crypto';
46
  import { NextRequest, NextResponse } from 'next/server';
47
  import OpenAI from 'openai';
48
 
49
+ type StreamResolutionInput = {
50
+ streamMode: ImageStreamMode;
51
+ imageBackend: ImageGenerationBackend;
52
+ streamingStrategy: ImageStreamingStrategy;
53
+ operation: StreamingOperation;
54
+ selectedCredential?: ChannelCredential;
55
+ sourceId?: string;
56
+ };
57
+
58
+ type StreamResolution = {
59
+ availabilityKey: StreamingAvailabilityKey;
60
+ streamEnabled: boolean;
61
+ streamFallbackEnabled: boolean;
62
+ streamingMarkedUnavailable: boolean;
63
+ };
64
+
65
+ function readErrorStatus(error: unknown): number | undefined {
66
+ if (typeof error !== 'object' || error === null) return undefined;
67
+ if ('status' in error && typeof error.status === 'number') return error.status;
68
+ if ('statusCode' in error && typeof error.statusCode === 'number') return error.statusCode;
69
+ return undefined;
70
+ }
71
+
72
+ function readErrorCode(error: unknown): string | undefined {
73
+ if (typeof error !== 'object' || error === null) return undefined;
74
+ if ('code' in error && typeof error.code === 'string') return error.code;
75
+ if ('error' in error && typeof error.error === 'object' && error.error !== null) {
76
+ const nested = error.error as Record<string, unknown>;
77
+ return typeof nested.code === 'string' ? nested.code : undefined;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ function createAvailabilityKey(input: StreamResolutionInput): StreamingAvailabilityKey {
83
+ return {
84
+ channelId: input.selectedCredential?.channelId,
85
+ sourceId: input.selectedCredential ? undefined : input.sourceId,
86
+ imageBackend: input.imageBackend,
87
+ streamingStrategy: input.streamingStrategy,
88
+ operation: input.operation
89
+ };
90
+ }
91
+
92
+ function createAvailabilitySourceId(input: { selectedCredential?: ChannelCredential; baseUrl?: string }): string | undefined {
93
+ if (input.selectedCredential) return undefined;
94
+ const normalized = normalizeAvailabilityBaseUrl(input.baseUrl);
95
+ const digest = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 16);
96
+ return `upstream:${digest}`;
97
+ }
98
+
99
+ function normalizeAvailabilityBaseUrl(baseUrl: string | undefined): string {
100
+ const rawValue = baseUrl && baseUrl.trim() ? baseUrl.trim() : 'https://api.openai.com/v1';
101
+ try {
102
+ const parsed = new URL(rawValue);
103
+ const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
104
+ return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase();
105
+ } catch {
106
+ return 'invalid-upstream';
107
+ }
108
+ }
109
+
110
+ function resolvePageStream(input: StreamResolutionInput): StreamResolution {
111
+ const availabilityKey = createAvailabilityKey(input);
112
+ const streamingAvailability = getServerChannelState().streamingAvailability;
113
+ if (input.streamMode === 'non_stream') {
114
+ return {
115
+ availabilityKey,
116
+ streamEnabled: false,
117
+ streamFallbackEnabled: false,
118
+ streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey)
119
+ };
120
+ }
121
+
122
+ if (input.streamMode === 'auto' && input.streamingStrategy === 'off') {
123
+ return {
124
+ availabilityKey,
125
+ streamEnabled: false,
126
+ streamFallbackEnabled: false,
127
+ streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey)
128
+ };
129
+ }
130
+
131
+ if (input.streamMode === 'auto' && streamingAvailability.isUnavailable(availabilityKey)) {
132
+ return {
133
+ availabilityKey,
134
+ streamEnabled: false,
135
+ streamFallbackEnabled: false,
136
+ streamingMarkedUnavailable: true
137
+ };
138
+ }
139
+
140
+ return {
141
+ availabilityKey,
142
+ streamEnabled: resolveImageStreamEnabled({
143
+ imageBackend: input.imageBackend,
144
+ requestedStream: true,
145
+ streamingStrategy: input.streamingStrategy
146
+ }),
147
+ streamFallbackEnabled: input.streamMode === 'auto',
148
+ streamingMarkedUnavailable: false
149
+ };
150
+ }
151
+
152
+ function markStreamingUnavailable(input: {
153
+ key: StreamingAvailabilityKey;
154
+ error?: unknown;
155
+ reason: string;
156
+ status?: number;
157
+ }) {
158
+ const status = input.status ?? readErrorStatus(input.error);
159
+ getServerChannelState().streamingAvailability.markUnavailable({
160
+ ...input.key,
161
+ reason: input.reason,
162
+ ...(status !== undefined ? { status } : {}),
163
+ ...(readErrorCode(input.error) ? { code: readErrorCode(input.error) } : {})
164
+ });
165
+ }
166
+
167
  export async function POST(request: NextRequest) {
168
  let selectedServerCredential: ChannelCredential | undefined;
169
  let clientRequestId: string | undefined;
170
  let requestLogContext: RequestLogContext | undefined;
171
  let accessCookie: AccessCookie | undefined;
172
  try {
173
+ const serverChannelState = getServerChannelState();
174
+ const serverChannelRouter = serverChannelState.router;
175
  const contentType = request.headers.get('content-type') || '';
176
  if (
177
  !contentType.includes('multipart/form-data') &&
 
184
  requestLogContext = clientRequestId ? { clientRequestId } : undefined;
185
  const requestApiKey = String(formData.get('apiKey') || '').trim();
186
  const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim();
187
+ const allowedPlainHttpBaseUrls = readPlainHttpApiBaseUrlAllowlist(
188
+ process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS
189
+ );
190
  assertSafeApiOverride(requestApiKey, requestApiBaseUrl);
191
+ validateApiBaseUrl(requestApiBaseUrl, { allowedPlainHttpBaseUrls });
192
  selectedServerCredential = requestApiKey
193
  ? undefined
194
  : serverChannelRouter?.select({ affinityKey: readAffinityKey(request.headers) });
 
202
  legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
203
  selectedCredential: selectedServerCredential
204
  });
205
+ validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls });
206
 
207
  if (!effectiveApiKey) {
208
  appLogger.error('未设置 OPENAI_API_KEY,且请求未提供 API Key。', requestLogContext);
 
261
  requestLogContext
262
  );
263
 
264
+ const streamMode = readImageStreamMode(formData, process.env);
265
  const partialImagesCount = readCount(formData, 'partial_images', 2, 1, 3) as 1 | 2 | 3;
266
+ const imageBackend = readImageGenerationBackend(formData, process.env, {
267
+ useEnvDefault: mode === 'generate'
268
+ });
269
  const streamingStrategy = readImageStreamingStrategy(formData, process.env, {
270
  useEnvDefault: mode === 'generate'
271
  });
272
+ const streamResolution = resolvePageStream({
273
+ streamMode,
274
  imageBackend,
275
+ streamingStrategy,
276
+ operation: mode,
277
+ selectedCredential,
278
+ sourceId: createAvailabilitySourceId({
279
+ selectedCredential,
280
+ baseUrl: effectiveApiBaseUrl
281
+ })
282
  });
283
  assertResponsesImageBackendAllowed({ imageBackend, mode });
284
  appLogger.info('图片上游兼容策略。', {
285
  ...requestLogContext,
286
  imageBackend,
287
  streamingStrategy,
288
+ streamMode,
289
+ streamEnabled: streamResolution.streamEnabled,
290
+ streamFallbackEnabled: streamResolution.streamFallbackEnabled,
291
+ streamingMarkedUnavailable: streamResolution.streamingMarkedUnavailable
292
  });
293
 
294
  const modeResult =
 
298
  openai,
299
  model,
300
  prompt,
301
+ streamEnabled: streamResolution.streamEnabled,
302
  partialImagesCount,
303
  imageBackend,
304
  storageMode: effectiveStorageMode,
 
309
  requestLogContext,
310
  selectedCredential,
311
  accessCookie,
312
+ abortSignal: request.signal,
313
+ streamFallbackEnabled: streamResolution.streamFallbackEnabled,
314
+ onStreamUnavailable: (error, reason) =>
315
+ markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }),
316
+ onStreamingDegraded: (reason) =>
317
+ markStreamingUnavailable({
318
+ key: streamResolution.availabilityKey,
319
+ reason,
320
+ status: 200
321
+ })
322
  })
323
  : await handleEditImageMode({
324
  formData,
325
  openai,
326
  model,
327
  prompt,
328
+ streamEnabled: streamResolution.streamEnabled,
329
  partialImagesCount,
330
+ imageBackend,
331
  storageMode: effectiveStorageMode,
332
  apiBaseUrl: effectiveApiBaseUrl,
333
  apiKey: effectiveApiKey,
 
336
  requestLogContext,
337
  selectedCredential,
338
  accessCookie,
339
+ abortSignal: request.signal,
340
+ streamFallbackEnabled: streamResolution.streamFallbackEnabled,
341
+ onStreamUnavailable: (error, reason) =>
342
+ markStreamingUnavailable({ key: streamResolution.availabilityKey, error, reason }),
343
+ onStreamingDegraded: (reason) =>
344
+ markStreamingUnavailable({
345
+ key: streamResolution.availabilityKey,
346
+ reason,
347
+ status: 200
348
+ })
349
  });
350
  if (modeResult instanceof Response) {
351
  return modeResult;
 
359
  result,
360
  outputFormat: responseOutputFormat,
361
  storageMode: effectiveStorageMode,
362
+ includeBase64: true,
363
+ apiBaseUrl: effectiveApiBaseUrl,
364
+ apiKey: effectiveApiKey,
365
+ abortSignal: request.signal
366
  });
367
  const savedImagesData = savedImages.map((image) => ({
368
  ...persistedImageToLegacyResponse(image),
src/app/api/runtime-capabilities/route.test.ts CHANGED
@@ -17,24 +17,149 @@ function restoreProcessEnv(snapshot: NodeJS.ProcessEnv) {
17
 
18
  beforeEach(() => {
19
  originalEnv = { ...process.env };
 
20
  delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND;
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  });
22
 
23
- afterEach(() => {
 
 
24
  restoreProcessEnv(originalEnv);
25
  });
26
 
27
- describe('GET /api/runtime-capabilities', () => {
28
- it('exposes the experimental Responses image backend flag without enabling it by default', async () => {
29
  const { GET } = await import('./route');
30
 
31
- const disabled = (await (await GET()).json()) as Record<string, { enabled: boolean; mode?: string }>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  assert.equal(disabled.responsesImageBackend.enabled, false);
33
  assert.equal(disabled.responsesImageBackend.mode, 'experimental');
 
 
 
 
34
 
35
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
36
- const enabled = (await (await GET()).json()) as Record<string, { enabled: boolean; mode?: string }>;
 
 
 
 
 
 
 
 
 
 
 
 
37
  assert.equal(enabled.responsesImageBackend.enabled, true);
38
  assert.equal(enabled.responsesImageBackend.mode, 'experimental');
 
 
39
  });
40
  });
 
17
 
18
  beforeEach(() => {
19
  originalEnv = { ...process.env };
20
+ process.env.npm_lifecycle_event = 'test';
21
  delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND;
22
+ delete process.env.OPENAI_RESPONSES_API_MODEL;
23
+ delete process.env.IMAGE_STREAMING_STRATEGY;
24
+ delete process.env.OPENAI_API_KEY;
25
+ delete process.env.OPENAI_API_BASE_URL;
26
+ delete process.env.OPENAI_CHANNEL_1_ID;
27
+ delete process.env.OPENAI_CHANNEL_1_API_KEYS;
28
+ delete process.env.OPENAI_CHANNEL_1_BASE_URL;
29
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED;
30
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS;
31
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS;
32
+ delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK;
33
+ delete process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY;
34
+ delete process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS;
35
  });
36
 
37
+ afterEach(async () => {
38
+ const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router');
39
+ resetServerChannelStateForTests();
40
  restoreProcessEnv(originalEnv);
41
  });
42
 
43
+ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
44
+ it('exposes streaming batch capability by default without the removed env gate', async () => {
45
  const { GET } = await import('./route');
46
 
47
+ const body = (await (await GET()).json()) as Record<string, { enabled: boolean; recommendedConcurrency?: number }>;
48
+
49
+ assert.equal(body.streamingBatch.enabled, true);
50
+ assert.equal(typeof body.streamingBatch.recommendedConcurrency, 'number');
51
+ });
52
+
53
+ it('exposes the runtime default streaming strategy for client-side fanout decisions', async () => {
54
+ process.env.IMAGE_STREAMING_STRATEGY = 'off';
55
+ const { GET } = await import('./route');
56
+
57
+ const body = (await (await GET()).json()) as Record<string, { defaultMode?: string; defaultStrategy?: string }>;
58
+
59
+ assert.equal(body.streaming.defaultMode, 'non_stream');
60
+ assert.equal(body.streaming.defaultStrategy, 'off');
61
+ });
62
+
63
+ it('exposes recovery probe settings without API keys', async () => {
64
+ process.env.OPENAI_CHANNEL_1_ID = 'official';
65
+ process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://api.openai.com/v1';
66
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-secret';
67
+ process.env.OPENAI_CHANNEL_RECOVERY_PROBE_INTERVAL_MS = '120000';
68
+ process.env.OPENAI_CHANNEL_RECOVERY_PROBE_TIMEOUT_MS = '3000';
69
+ process.env.OPENAI_CHANNEL_RECOVERY_PROBE_MAX_PER_TICK = '1';
70
+ const { GET } = await import('./route');
71
+
72
+ const body = (await (await GET()).json()) as {
73
+ channelRecovery: {
74
+ requireProbeForRecovery: boolean;
75
+ pendingProbeCredentialCount: number;
76
+ pendingProbeChannelCount: number;
77
+ probe: {
78
+ enabled: boolean;
79
+ intervalMs: number;
80
+ timeoutMs: number;
81
+ maxPerTick: number;
82
+ running: boolean;
83
+ };
84
+ };
85
+ };
86
+
87
+ assert.deepEqual(body.channelRecovery, {
88
+ requireProbeForRecovery: true,
89
+ pendingProbeCredentialCount: 0,
90
+ pendingProbeChannelCount: 0,
91
+ probe: {
92
+ enabled: true,
93
+ intervalMs: 120000,
94
+ timeoutMs: 3000,
95
+ maxPerTick: 1,
96
+ running: false,
97
+ pendingProbeCount: 0,
98
+ dueCandidateCount: 0,
99
+ estimatedMinimumDrainTickCount: 0,
100
+ estimatedMinimumDrainMs: 0,
101
+ lastCheckedCount: 0,
102
+ lastRecoveredCount: 0,
103
+ lastFailedCount: 0
104
+ }
105
+ });
106
+ assert.equal(JSON.stringify(body).includes('sk-secret'), false);
107
+ });
108
+
109
+ it('rejects requiring recovery probes when the prober is disabled', async () => {
110
+ process.env.OPENAI_CHANNEL_1_ID = 'official';
111
+ process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://api.openai.com/v1';
112
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-secret';
113
+ process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false';
114
+ process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'true';
115
+ const { GET } = await import('./route');
116
+
117
+ const response = await GET();
118
+ const body = (await response.json()) as { error?: string };
119
+
120
+ assert.equal(response.status, 500);
121
+ assert.match(body.error || '', /OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY/);
122
+ assert.equal(JSON.stringify(body).includes('sk-secret'), false);
123
+ });
124
+
125
+ it('exposes the experimental Responses image backend when the backend flag is enabled', async () => {
126
+ const { GET } = await import('./route');
127
+
128
+ const disabled = (await (await GET()).json()) as Record<
129
+ string,
130
+ {
131
+ enabled: boolean;
132
+ mode?: string;
133
+ requiredEnv?: string[];
134
+ optionalEnv?: string[];
135
+ hasDefaultModel?: boolean;
136
+ missingEnv?: string[];
137
+ }
138
+ >;
139
  assert.equal(disabled.responsesImageBackend.enabled, false);
140
  assert.equal(disabled.responsesImageBackend.mode, 'experimental');
141
+ assert.deepEqual(disabled.responsesImageBackend.requiredEnv, ['ENABLE_RESPONSES_IMAGE_BACKEND']);
142
+ assert.deepEqual(disabled.responsesImageBackend.optionalEnv, ['OPENAI_RESPONSES_API_MODEL']);
143
+ assert.equal(disabled.responsesImageBackend.hasDefaultModel, false);
144
+ assert.deepEqual(disabled.responsesImageBackend.missingEnv, ['ENABLE_RESPONSES_IMAGE_BACKEND']);
145
 
146
  process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
147
+ const requestModelAllowed = (await (await GET()).json()) as Record<
148
+ string,
149
+ { enabled: boolean; mode?: string; hasDefaultModel?: boolean; missingEnv?: string[] }
150
+ >;
151
+ assert.equal(requestModelAllowed.responsesImageBackend.enabled, true);
152
+ assert.equal(requestModelAllowed.responsesImageBackend.hasDefaultModel, false);
153
+ assert.deepEqual(requestModelAllowed.responsesImageBackend.missingEnv, []);
154
+
155
+ process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1';
156
+ const enabled = (await (await GET()).json()) as Record<
157
+ string,
158
+ { enabled: boolean; mode?: string; hasDefaultModel?: boolean; missingEnv?: string[] }
159
+ >;
160
  assert.equal(enabled.responsesImageBackend.enabled, true);
161
  assert.equal(enabled.responsesImageBackend.mode, 'experimental');
162
+ assert.equal(enabled.responsesImageBackend.hasDefaultModel, true);
163
+ assert.deepEqual(enabled.responsesImageBackend.missingEnv, []);
164
  });
165
  });
src/app/api/runtime-capabilities/route.ts CHANGED
@@ -1,17 +1,22 @@
1
  import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router';
 
2
  import { getServerChannelState } from '@/lib/server-channel-router';
3
  import { computeStreamingBatchRecommendation } from '@/lib/streaming-batch';
4
  import { readBooleanEnv, readPositiveIntegerEnv } from '@/lib/server-runtime';
5
  import { NextResponse } from 'next/server';
6
 
 
 
 
7
  export async function GET() {
8
  try {
9
  const serverChannelState = getServerChannelState();
10
  const summary = getChannelPoolSummary(serverChannelState.config);
11
  const healthSummary = serverChannelState.router?.getHealthSummary();
12
  const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1);
13
- const streamingBatchEnabled = readBooleanEnv(process.env, 'ENABLE_STREAMING_BATCH');
14
  const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND');
 
 
15
  const recommendedStreamingConcurrency = computeStreamingBatchRecommendation({
16
  credentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount,
17
  maxStreamsPerCredential,
@@ -19,8 +24,14 @@ export async function GET() {
19
  });
20
 
21
  return NextResponse.json({
 
 
 
 
 
 
22
  streamingBatch: {
23
- enabled: streamingBatchEnabled,
24
  recommendedConcurrency: recommendedStreamingConcurrency,
25
  requestCredentialConcurrency: maxStreamsPerCredential,
26
  healthyCredentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount,
@@ -30,9 +41,19 @@ export async function GET() {
30
  unhealthyChannelCount: healthSummary?.unhealthyChannelCount ?? 0,
31
  lastFailure: toPublicChannelFailure(healthSummary?.lastFailure)
32
  },
 
 
 
 
 
 
33
  responsesImageBackend: {
34
  enabled: responsesImageBackendEnabled,
35
- mode: 'experimental'
 
 
 
 
36
  }
37
  });
38
  } catch (error) {
@@ -42,3 +63,11 @@ export async function GET() {
42
  );
43
  }
44
  }
 
 
 
 
 
 
 
 
 
1
  import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router';
2
+ import { readImageStreamMode, readImageStreamingStrategy } from '@/lib/image-upstream-strategy';
3
  import { getServerChannelState } from '@/lib/server-channel-router';
4
  import { computeStreamingBatchRecommendation } from '@/lib/streaming-batch';
5
  import { readBooleanEnv, readPositiveIntegerEnv } from '@/lib/server-runtime';
6
  import { NextResponse } from 'next/server';
7
 
8
+ const RESPONSES_IMAGE_BACKEND_REQUIRED_ENV = ['ENABLE_RESPONSES_IMAGE_BACKEND'] as const;
9
+ const RESPONSES_IMAGE_BACKEND_OPTIONAL_ENV = ['OPENAI_RESPONSES_API_MODEL'] as const;
10
+
11
  export async function GET() {
12
  try {
13
  const serverChannelState = getServerChannelState();
14
  const summary = getChannelPoolSummary(serverChannelState.config);
15
  const healthSummary = serverChannelState.router?.getHealthSummary();
16
  const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1);
 
17
  const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND');
18
+ const responsesImageBackendHasDefaultModel = Boolean(process.env.OPENAI_RESPONSES_API_MODEL?.trim());
19
+ const responsesImageBackendMissingEnv = readResponsesImageBackendMissingEnv(process.env);
20
  const recommendedStreamingConcurrency = computeStreamingBatchRecommendation({
21
  credentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount,
22
  maxStreamsPerCredential,
 
24
  });
25
 
26
  return NextResponse.json({
27
+ streaming: {
28
+ defaultMode: readImageStreamMode(new FormData(), process.env),
29
+ defaultStrategy: readImageStreamingStrategy(new FormData(), process.env),
30
+ unavailableMarkScope: 'channel+backend+strategy+operation',
31
+ availability: serverChannelState.streamingAvailability.summary()
32
+ },
33
  streamingBatch: {
34
+ enabled: true,
35
  recommendedConcurrency: recommendedStreamingConcurrency,
36
  requestCredentialConcurrency: maxStreamsPerCredential,
37
  healthyCredentialCount: healthSummary?.healthyCredentialCount ?? summary.credentialCount,
 
41
  unhealthyChannelCount: healthSummary?.unhealthyChannelCount ?? 0,
42
  lastFailure: toPublicChannelFailure(healthSummary?.lastFailure)
43
  },
44
+ channelRecovery: {
45
+ requireProbeForRecovery: serverChannelState.channelRecovery.requireProbeForRecovery,
46
+ pendingProbeCredentialCount: healthSummary?.pendingRecoveryProbeCredentialCount ?? 0,
47
+ pendingProbeChannelCount: healthSummary?.pendingRecoveryProbeChannelCount ?? 0,
48
+ probe: serverChannelState.channelRecoveryProber?.summary()
49
+ },
50
  responsesImageBackend: {
51
  enabled: responsesImageBackendEnabled,
52
+ mode: 'experimental',
53
+ requiredEnv: [...RESPONSES_IMAGE_BACKEND_REQUIRED_ENV],
54
+ optionalEnv: [...RESPONSES_IMAGE_BACKEND_OPTIONAL_ENV],
55
+ hasDefaultModel: responsesImageBackendHasDefaultModel,
56
+ missingEnv: responsesImageBackendMissingEnv
57
  }
58
  });
59
  } catch (error) {
 
63
  );
64
  }
65
  }
66
+
67
+ function readResponsesImageBackendMissingEnv(env: Record<string, string | undefined>): string[] {
68
+ const missing: string[] = [];
69
+ if (!readBooleanEnv(env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) {
70
+ missing.push('ENABLE_RESPONSES_IMAGE_BACKEND');
71
+ }
72
+ return missing;
73
+ }
src/app/globals.css CHANGED
@@ -44,75 +44,74 @@
44
  }
45
 
46
  :root {
47
- --font-geist-sans:
48
- Arial, Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
49
  --font-geist-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
50
- --radius: 0.625rem;
51
- --background: oklch(1 0 0);
52
- --foreground: oklch(0.145 0 0);
53
- --card: oklch(1 0 0);
54
- --card-foreground: oklch(0.145 0 0);
55
- --popover: oklch(1 0 0);
56
- --popover-foreground: oklch(0.145 0 0);
57
- --primary: oklch(0.205 0 0);
58
- --primary-foreground: oklch(0.985 0 0);
59
- --secondary: oklch(0.97 0 0);
60
- --secondary-foreground: oklch(0.205 0 0);
61
- --muted: oklch(0.97 0 0);
62
- --muted-foreground: oklch(0.556 0 0);
63
- --accent: oklch(0.97 0 0);
64
- --accent-foreground: oklch(0.205 0 0);
65
- --destructive: oklch(0.577 0.245 27.325);
66
- --border: oklch(0.922 0 0);
67
- --input: oklch(0.922 0 0);
68
- --ring: oklch(0.708 0 0);
69
  --chart-1: oklch(0.646 0.222 41.116);
70
  --chart-2: oklch(0.6 0.118 184.704);
71
  --chart-3: oklch(0.398 0.07 227.392);
72
  --chart-4: oklch(0.828 0.189 84.429);
73
  --chart-5: oklch(0.769 0.188 70.08);
74
- --sidebar: oklch(0.985 0 0);
75
- --sidebar-foreground: oklch(0.145 0 0);
76
- --sidebar-primary: oklch(0.205 0 0);
77
- --sidebar-primary-foreground: oklch(0.985 0 0);
78
- --sidebar-accent: oklch(0.97 0 0);
79
- --sidebar-accent-foreground: oklch(0.205 0 0);
80
- --sidebar-border: oklch(0.922 0 0);
81
- --sidebar-ring: oklch(0.708 0 0);
82
  }
83
 
84
  .dark {
85
- --background: oklch(0.145 0 0);
86
- --foreground: oklch(0.985 0 0);
87
- --card: oklch(0.205 0 0);
88
- --card-foreground: oklch(0.985 0 0);
89
- --popover: oklch(0.205 0 0);
90
- --popover-foreground: oklch(0.985 0 0);
91
- --primary: oklch(0.922 0 0);
92
- --primary-foreground: oklch(0.205 0 0);
93
- --secondary: oklch(0.269 0 0);
94
- --secondary-foreground: oklch(0.985 0 0);
95
- --muted: oklch(0.269 0 0);
96
- --muted-foreground: oklch(0.708 0 0);
97
- --accent: oklch(0.269 0 0);
98
- --accent-foreground: oklch(0.985 0 0);
99
  --destructive: oklch(0.704 0.191 22.216);
100
- --border: oklch(1 0 0 / 10%);
101
- --input: oklch(1 0 0 / 15%);
102
- --ring: oklch(0.556 0 0);
103
  --chart-1: oklch(0.488 0.243 264.376);
104
  --chart-2: oklch(0.696 0.17 162.48);
105
  --chart-3: oklch(0.769 0.188 70.08);
106
  --chart-4: oklch(0.627 0.265 303.9);
107
  --chart-5: oklch(0.645 0.246 16.439);
108
- --sidebar: oklch(0.205 0 0);
109
- --sidebar-foreground: oklch(0.985 0 0);
110
- --sidebar-primary: oklch(0.488 0.243 264.376);
111
- --sidebar-primary-foreground: oklch(0.985 0 0);
112
- --sidebar-accent: oklch(0.269 0 0);
113
- --sidebar-accent-foreground: oklch(0.985 0 0);
114
- --sidebar-border: oklch(1 0 0 / 10%);
115
- --sidebar-ring: oklch(0.556 0 0);
116
  }
117
 
118
  @layer base {
@@ -123,3 +122,115 @@
123
  @apply bg-background text-foreground;
124
  }
125
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
45
 
46
  :root {
47
+ --font-geist-sans: Arial, Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
 
48
  --font-geist-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
49
+ --radius: 0.5rem;
50
+ --background: oklch(0.956 0.023 80);
51
+ --foreground: oklch(0.225 0.026 56);
52
+ --card: oklch(0.992 0.01 84);
53
+ --card-foreground: oklch(0.225 0.026 56);
54
+ --popover: oklch(0.996 0.009 84);
55
+ --popover-foreground: oklch(0.225 0.026 56);
56
+ --primary: oklch(0.615 0.165 30);
57
+ --primary-foreground: oklch(0.99 0.016 84);
58
+ --secondary: oklch(0.892 0.044 122);
59
+ --secondary-foreground: oklch(0.255 0.034 66);
60
+ --muted: oklch(0.922 0.027 78);
61
+ --muted-foreground: oklch(0.505 0.031 58);
62
+ --accent: oklch(0.925 0.052 35);
63
+ --accent-foreground: oklch(0.285 0.037 52);
64
+ --destructive: oklch(0.56 0.17 25);
65
+ --border: oklch(0.858 0.03 74);
66
+ --input: oklch(0.858 0.03 74);
67
+ --ring: oklch(0.62 0.14 30);
68
  --chart-1: oklch(0.646 0.222 41.116);
69
  --chart-2: oklch(0.6 0.118 184.704);
70
  --chart-3: oklch(0.398 0.07 227.392);
71
  --chart-4: oklch(0.828 0.189 84.429);
72
  --chart-5: oklch(0.769 0.188 70.08);
73
+ --sidebar: oklch(0.972 0.018 83);
74
+ --sidebar-foreground: oklch(0.205 0.024 58);
75
+ --sidebar-primary: oklch(0.48 0.125 38);
76
+ --sidebar-primary-foreground: oklch(0.985 0.018 86);
77
+ --sidebar-accent: oklch(0.884 0.071 64);
78
+ --sidebar-accent-foreground: oklch(0.26 0.035 55);
79
+ --sidebar-border: oklch(0.812 0.037 76);
80
+ --sidebar-ring: oklch(0.58 0.115 40);
81
  }
82
 
83
  .dark {
84
+ --background: oklch(0.19 0.025 57);
85
+ --foreground: oklch(0.93 0.026 84);
86
+ --card: oklch(0.245 0.028 58);
87
+ --card-foreground: oklch(0.93 0.026 84);
88
+ --popover: oklch(0.245 0.028 58);
89
+ --popover-foreground: oklch(0.93 0.026 84);
90
+ --primary: oklch(0.742 0.109 50);
91
+ --primary-foreground: oklch(0.18 0.023 55);
92
+ --secondary: oklch(0.315 0.035 96);
93
+ --secondary-foreground: oklch(0.915 0.025 82);
94
+ --muted: oklch(0.295 0.028 62);
95
+ --muted-foreground: oklch(0.735 0.028 78);
96
+ --accent: oklch(0.39 0.054 69);
97
+ --accent-foreground: oklch(0.93 0.026 84);
98
  --destructive: oklch(0.704 0.191 22.216);
99
+ --border: oklch(0.45 0.035 68 / 52%);
100
+ --input: oklch(0.55 0.04 72 / 55%);
101
+ --ring: oklch(0.742 0.109 50);
102
  --chart-1: oklch(0.488 0.243 264.376);
103
  --chart-2: oklch(0.696 0.17 162.48);
104
  --chart-3: oklch(0.769 0.188 70.08);
105
  --chart-4: oklch(0.627 0.265 303.9);
106
  --chart-5: oklch(0.645 0.246 16.439);
107
+ --sidebar: oklch(0.245 0.028 58);
108
+ --sidebar-foreground: oklch(0.93 0.026 84);
109
+ --sidebar-primary: oklch(0.742 0.109 50);
110
+ --sidebar-primary-foreground: oklch(0.18 0.023 55);
111
+ --sidebar-accent: oklch(0.39 0.054 69);
112
+ --sidebar-accent-foreground: oklch(0.93 0.026 84);
113
+ --sidebar-border: oklch(0.45 0.035 68 / 52%);
114
+ --sidebar-ring: oklch(0.742 0.109 50);
115
  }
116
 
117
  @layer base {
 
122
  @apply bg-background text-foreground;
123
  }
124
  }
125
+
126
+ @layer utilities {
127
+ .studio-paper {
128
+ background:
129
+ linear-gradient(105deg, oklch(0.988 0.013 86 / 0.96), oklch(0.95 0.024 78 / 0.86)),
130
+ linear-gradient(180deg, oklch(1 0 0 / 0.42), transparent 34rem),
131
+ repeating-linear-gradient(
132
+ 90deg,
133
+ color-mix(in oklch, var(--border) 14%, transparent) 0 1px,
134
+ transparent 1px 14px
135
+ ),
136
+ repeating-linear-gradient(0deg, oklch(1 0 0 / 0.16) 0 1px, transparent 1px 3px), var(--background);
137
+ }
138
+
139
+ .paper-soft-shadow {
140
+ box-shadow: 0 12px 28px oklch(0.37 0.038 54 / 0.09);
141
+ }
142
+
143
+ .literary-scrollbar {
144
+ scrollbar-color: oklch(0.74 0.028 72 / 0.72) transparent;
145
+ scrollbar-width: thin;
146
+ }
147
+
148
+ .literary-scrollbar::-webkit-scrollbar {
149
+ width: 8px;
150
+ height: 8px;
151
+ }
152
+
153
+ .literary-scrollbar::-webkit-scrollbar-track {
154
+ background: transparent;
155
+ }
156
+
157
+ .literary-scrollbar::-webkit-scrollbar-thumb {
158
+ border: 2px solid transparent;
159
+ border-radius: 999px;
160
+ background-clip: content-box;
161
+ background-color: oklch(0.74 0.028 72 / 0.62);
162
+ }
163
+
164
+ .scrollbar-none {
165
+ scrollbar-width: none;
166
+ }
167
+
168
+ .scrollbar-none::-webkit-scrollbar {
169
+ display: none;
170
+ }
171
+
172
+ .editorial-title {
173
+ font-family: 'Songti SC', 'STSong', 'Noto Serif CJK SC', serif;
174
+ letter-spacing: 0;
175
+ }
176
+
177
+ .workbench-panel {
178
+ background:
179
+ linear-gradient(180deg, oklch(0.998 0.007 86 / 0.98), oklch(0.976 0.017 82 / 0.92)),
180
+ repeating-linear-gradient(90deg, oklch(0.72 0.025 76 / 0.045) 0 1px, transparent 1px 9px), var(--card);
181
+ box-shadow:
182
+ 0 1px 0 oklch(1 0 0 / 0.72) inset,
183
+ 0 14px 35px oklch(0.37 0.036 54 / 0.1);
184
+ }
185
+
186
+ .photo-paper {
187
+ background: oklch(0.99 0.008 85);
188
+ box-shadow:
189
+ 0 2px 0 oklch(0.82 0.026 74 / 0.48),
190
+ 0 18px 38px oklch(0.35 0.036 54 / 0.16);
191
+ }
192
+
193
+ .photo-paper::before {
194
+ content: '';
195
+ position: absolute;
196
+ top: -1.25rem;
197
+ right: 7%;
198
+ z-index: 2;
199
+ width: 8rem;
200
+ height: 2.1rem;
201
+ rotate: 8deg;
202
+ border: 1px solid oklch(0.79 0.028 74 / 0.38);
203
+ background:
204
+ linear-gradient(90deg, oklch(0.9 0.025 75 / 0.82), oklch(0.78 0.024 75 / 0.64)),
205
+ repeating-linear-gradient(90deg, oklch(1 0 0 / 0.18) 0 2px, transparent 2px 7px);
206
+ box-shadow: 0 4px 10px oklch(0.35 0.036 54 / 0.08);
207
+ }
208
+
209
+ .preview-gallery-board {
210
+ background:
211
+ linear-gradient(
212
+ 90deg,
213
+ transparent calc(50% - 0.5px),
214
+ oklch(0.78 0.022 76 / 0.14) 50%,
215
+ transparent calc(50% + 0.5px)
216
+ ),
217
+ linear-gradient(
218
+ 0deg,
219
+ transparent calc(50% - 0.5px),
220
+ oklch(0.78 0.022 76 / 0.1) 50%,
221
+ transparent calc(50% + 0.5px)
222
+ ),
223
+ repeating-linear-gradient(90deg, oklch(0.78 0.022 76 / 0.055) 0 1px, transparent 1px 42px),
224
+ repeating-linear-gradient(0deg, oklch(0.78 0.022 76 / 0.045) 0 1px, transparent 1px 42px),
225
+ linear-gradient(180deg, oklch(0.995 0.007 85), oklch(0.972 0.016 80));
226
+ }
227
+
228
+ .preview-gallery-board::before {
229
+ content: '';
230
+ position: absolute;
231
+ inset: 1rem;
232
+ pointer-events: none;
233
+ border: 1px solid oklch(0.79 0.026 74 / 0.16);
234
+ border-radius: 6px;
235
+ }
236
+ }
src/app/layout.tsx CHANGED
@@ -4,8 +4,8 @@ import { I18nProvider } from '@/lib/i18n';
4
  import type { Metadata } from 'next';
5
 
6
  export const metadata: Metadata = {
7
- title: 'GPT Image Playground',
8
- description: '使用 OpenAI GPT Image 模型生成和编辑。',
9
  icons: {
10
  icon: '/favicon.svg'
11
  }
 
4
  import type { Metadata } from 'next';
5
 
6
  export const metadata: Metadata = {
7
+ title: '图像手记',
8
+ description: '面向中文创作者的 AI像创作工作台。',
9
  icons: {
10
  icon: '/favicon.svg'
11
  }
src/app/page-regressions.test.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { describe, it } from 'node:test';
4
+
5
+ describe('page state regressions', () => {
6
+ it('uses the unified batch prompt setter when mobile random inspiration replaces batch text', async () => {
7
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
8
+ const batchBranch = source.match(/if \(workbenchMode === 'batch'\) \{([\s\S]*?)\n\s*\}/)?.[1];
9
+
10
+ assert.ok(batchBranch, 'missing mobile random inspiration batch branch');
11
+ assert.match(batchBranch, /handleBatchPromptTextChange\(nextPrompt\)/);
12
+ assert.doesNotMatch(batchBranch, /setGenBatchPromptText\(nextPrompt\)/);
13
+ });
14
+ });
src/app/page.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
src/components/api-settings-dialog.tsx CHANGED
@@ -71,6 +71,11 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
71
  }
72
  };
73
 
 
 
 
 
 
74
  const handleClear = () => {
75
  const emptySettings = { apiKey: '', baseUrl: '' };
76
  try {
@@ -87,54 +92,72 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
87
  return (
88
  <Dialog open={isOpen} onOpenChange={handleOpenChange}>
89
  <DialogContent className='sm:max-w-[520px]'>
90
- <DialogHeader>
91
- <DialogTitle>{t('api.title')}</DialogTitle>
92
- <DialogDescription>{t('api.description')}</DialogDescription>
93
- </DialogHeader>
94
- <div className='grid gap-4 py-2'>
95
- <div className='grid gap-2'>
96
- <Label htmlFor='api-key-input'>{t('api.key')}</Label>
97
- <Input
98
- id='api-key-input'
99
- name='apiKey'
100
- type='password'
101
- autoComplete='off'
102
- spellCheck={false}
103
- placeholder='sk-...'
104
- value={draft.apiKey}
105
- onChange={(event) => setDraft((current) => ({ ...current, apiKey: event.target.value }))}
106
- />
107
- </div>
108
- <div className='grid gap-2'>
109
- <Label htmlFor='api-base-url-input'>{t('api.url')}</Label>
110
- <Input
111
- id='api-base-url-input'
112
- name='baseUrl'
113
- type='url'
114
- inputMode='url'
115
- autoComplete='off'
116
- spellCheck={false}
117
- placeholder='https://api.openai.com/v1'
118
- value={draft.baseUrl}
119
- onChange={(event) => setDraft((current) => ({ ...current, baseUrl: event.target.value }))}
120
- />
121
- <p className='text-muted-foreground text-xs leading-5'>{t('api.urlHint')}</p>
 
 
 
 
 
 
 
 
122
  </div>
123
- </div>
124
- <DialogFooter>
125
- {saveStatus === 'saved' && (
126
- <p className='mr-auto self-center text-sm text-emerald-600 dark:text-emerald-400' aria-live='polite'>{t('api.saved')}</p>
127
- )}
128
- {saveStatus === 'error' && (
129
- <p className='text-destructive mr-auto self-center text-sm' aria-live='polite'>{t('api.saveFailed')}</p>
130
- )}
131
- <Button type='button' variant='ghost' onClick={handleClear} className='text-muted-foreground hover:text-foreground'>
132
- {t('common.clear')}
133
- </Button>
134
- <Button type='button' onClick={handleSave} className='px-6'>
135
- {t('common.save')}
136
- </Button>
137
- </DialogFooter>
 
 
 
 
 
 
 
 
 
 
138
  </DialogContent>
139
  </Dialog>
140
  );
 
71
  }
72
  };
73
 
74
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
75
+ event.preventDefault();
76
+ handleSave();
77
+ };
78
+
79
  const handleClear = () => {
80
  const emptySettings = { apiKey: '', baseUrl: '' };
81
  try {
 
92
  return (
93
  <Dialog open={isOpen} onOpenChange={handleOpenChange}>
94
  <DialogContent className='sm:max-w-[520px]'>
95
+ <form onSubmit={handleSubmit} className='grid gap-4'>
96
+ <DialogHeader>
97
+ <DialogTitle>{t('api.title')}</DialogTitle>
98
+ <DialogDescription>{t('api.description')}</DialogDescription>
99
+ </DialogHeader>
100
+ <div className='grid gap-4 py-2'>
101
+ <div className='grid gap-2'>
102
+ <Label htmlFor='api-key-input'>{t('api.key')}</Label>
103
+ <Input
104
+ id='api-key-input'
105
+ name='apiKey'
106
+ type='password'
107
+ autoComplete='off'
108
+ spellCheck={false}
109
+ placeholder='sk-...'
110
+ value={draft.apiKey}
111
+ className='min-h-11 sm:min-h-9'
112
+ onChange={(event) =>
113
+ setDraft((current) => ({ ...current, apiKey: event.target.value }))
114
+ }
115
+ />
116
+ </div>
117
+ <div className='grid gap-2'>
118
+ <Label htmlFor='api-base-url-input'>{t('api.url')}</Label>
119
+ <Input
120
+ id='api-base-url-input'
121
+ name='baseUrl'
122
+ type='url'
123
+ inputMode='url'
124
+ autoComplete='off'
125
+ spellCheck={false}
126
+ placeholder='https://api.openai.com/v1'
127
+ value={draft.baseUrl}
128
+ className='min-h-11 sm:min-h-9'
129
+ onChange={(event) =>
130
+ setDraft((current) => ({ ...current, baseUrl: event.target.value }))
131
+ }
132
+ />
133
+ <p className='text-muted-foreground text-xs leading-5'>{t('api.urlHint')}</p>
134
+ </div>
135
  </div>
136
+ <DialogFooter>
137
+ {saveStatus === 'saved' && (
138
+ <p
139
+ className='mr-auto self-center text-sm text-emerald-600 dark:text-emerald-400'
140
+ aria-live='polite'>
141
+ {t('api.saved')}
142
+ </p>
143
+ )}
144
+ {saveStatus === 'error' && (
145
+ <p className='text-destructive mr-auto self-center text-sm' aria-live='polite'>
146
+ {t('api.saveFailed')}
147
+ </p>
148
+ )}
149
+ <Button
150
+ type='button'
151
+ variant='ghost'
152
+ onClick={handleClear}
153
+ className='text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9'>
154
+ {t('common.clear')}
155
+ </Button>
156
+ <Button type='submit' className='min-h-11 px-6 sm:min-h-9'>
157
+ {t('common.save')}
158
+ </Button>
159
+ </DialogFooter>
160
+ </form>
161
  </DialogContent>
162
  </Dialog>
163
  );
src/components/app-controls.tsx DELETED
@@ -1,63 +0,0 @@
1
- 'use client';
2
-
3
- import { Button } from '@/components/ui/button';
4
- import { useI18n } from '@/lib/i18n';
5
- import { cn } from '@/lib/utils';
6
- import { Languages, Moon, Settings2, Sun } from 'lucide-react';
7
- import { useTheme } from 'next-themes';
8
- import * as React from 'react';
9
-
10
- type AppControlsProps = {
11
- onOpenApiSettings: () => void;
12
- };
13
-
14
- export function AppControls({ onOpenApiSettings }: AppControlsProps) {
15
- const { locale, setLocale, t } = useI18n();
16
- const { resolvedTheme, setTheme } = useTheme();
17
- const [isMounted, setIsMounted] = React.useState(false);
18
- const effectiveTheme = isMounted ? resolvedTheme : 'light';
19
- const isDark = effectiveTheme === 'dark';
20
-
21
- React.useEffect(() => {
22
- queueMicrotask(() => setIsMounted(true));
23
- }, []);
24
-
25
- return (
26
- <div className='flex flex-wrap items-center justify-end gap-2'>
27
- <div
28
- className='border-border bg-card text-card-foreground inline-flex items-center gap-1 rounded-md border p-1'
29
- aria-label={t('app.language')}>
30
- <Languages className='text-muted-foreground ml-1 h-4 w-4' />
31
- <Button
32
- type='button'
33
- variant={locale === 'zh-CN' ? 'default' : 'ghost'}
34
- size='sm'
35
- onClick={() => setLocale('zh-CN')}
36
- className='h-7 px-2'>
37
- 中文
38
- </Button>
39
- <Button
40
- type='button'
41
- variant={locale === 'en-US' ? 'default' : 'ghost'}
42
- size='sm'
43
- onClick={() => setLocale('en-US')}
44
- className='h-7 px-2'>
45
- EN
46
- </Button>
47
- </div>
48
- <Button
49
- type='button'
50
- variant='outline'
51
- size='sm'
52
- onClick={() => setTheme(isDark ? 'light' : 'dark')}
53
- className={cn('h-9 gap-2', isDark ? 'border-border' : 'border-input')}>
54
- {isDark ? <Moon className='h-4 w-4' /> : <Sun className='h-4 w-4' />}
55
- {isDark ? t('app.themeDark') : t('app.themeLight')}
56
- </Button>
57
- <Button type='button' variant='outline' onClick={onOpenApiSettings} className='gap-2'>
58
- <Settings2 className='h-4 w-4' />
59
- {t('app.apiSettings')}
60
- </Button>
61
- </div>
62
- );
63
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/editing-form.test.tsx ADDED
@@ -0,0 +1,333 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { EditingForm, type EditingFormData } from './editing-form';
2
+ import { I18nProvider } from '@/lib/i18n';
3
+ import type { ImageStreamingStrategy } from '@/lib/image-upstream-strategy';
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+ import * as React from 'react';
7
+ import { renderToStaticMarkup } from 'react-dom/server';
8
+
9
+ type RenderOptions = {
10
+ backend: EditingFormData['image_backend'];
11
+ outputFormat?: EditingFormData['output_format'];
12
+ advancedOpen?: boolean;
13
+ advancedTab?: 'output' | 'model' | 'stream' | 'route';
14
+ reuseContext?: React.ComponentProps<typeof EditingForm>['reuseContext'];
15
+ allowStreamingBatch?: boolean;
16
+ enableParallelBatch?: boolean;
17
+ editN?: number[];
18
+ streamingStrategy?: EditingFormData['streaming_strategy'];
19
+ defaultStreamingStrategy?: ImageStreamingStrategy;
20
+ allowResponsesImageBackend?: boolean;
21
+ hasDefaultResponsesModel?: boolean;
22
+ editResponsesModel?: string;
23
+ editPrompt?: string;
24
+ imageFiles?: File[];
25
+ };
26
+
27
+ const noop = () => {};
28
+
29
+ function renderEditingForm({
30
+ backend,
31
+ outputFormat = 'png',
32
+ advancedOpen = true,
33
+ advancedTab = 'route',
34
+ reuseContext = null,
35
+ allowStreamingBatch = false,
36
+ enableParallelBatch = false,
37
+ editN = [1],
38
+ streamingStrategy = 'server-default',
39
+ defaultStreamingStrategy = 'auto',
40
+ allowResponsesImageBackend = true,
41
+ hasDefaultResponsesModel = true,
42
+ editResponsesModel = '',
43
+ editPrompt = '',
44
+ imageFiles = []
45
+ }: RenderOptions): string {
46
+ return renderToStaticMarkup(
47
+ <I18nProvider>
48
+ <EditingForm
49
+ onSubmit={noop}
50
+ isLoading={false}
51
+ currentMode='edit'
52
+ onModeChange={noop}
53
+ reuseContext={reuseContext}
54
+ onClearReuseContext={noop}
55
+ isPasswordRequiredByBackend={false}
56
+ clientPasswordHash={null}
57
+ onOpenPasswordDialog={noop}
58
+ editModel='gpt-image-2'
59
+ setEditModel={noop}
60
+ imageFiles={imageFiles}
61
+ sourceImagePreviewUrls={[]}
62
+ setImageFiles={noop}
63
+ setSourceImagePreviewUrls={noop}
64
+ maxImages={10}
65
+ editPrompt={editPrompt}
66
+ setEditPrompt={noop}
67
+ editN={editN}
68
+ setEditN={noop}
69
+ editSize='auto'
70
+ setEditSize={noop}
71
+ editCustomWidth={1024}
72
+ setEditCustomWidth={noop}
73
+ editCustomHeight={1024}
74
+ setEditCustomHeight={noop}
75
+ editQuality='auto'
76
+ setEditQuality={noop}
77
+ editOutputFormat={outputFormat}
78
+ setEditOutputFormat={noop}
79
+ editCompression={[85]}
80
+ setEditCompression={noop}
81
+ editModeration='auto'
82
+ setEditModeration={noop}
83
+ editBrushSize={[20]}
84
+ setEditBrushSize={noop}
85
+ editShowMaskEditor={false}
86
+ setEditShowMaskEditor={noop}
87
+ editGeneratedMaskFile={null}
88
+ setEditGeneratedMaskFile={noop}
89
+ editIsMaskSaved={false}
90
+ setEditIsMaskSaved={noop}
91
+ editOriginalImageSize={null}
92
+ setEditOriginalImageSize={noop}
93
+ editDrawnPoints={[]}
94
+ setEditDrawnPoints={noop}
95
+ editMaskPreviewUrl={null}
96
+ setEditMaskPreviewUrl={noop}
97
+ streamMode='auto'
98
+ setStreamMode={noop}
99
+ allowStreamingBatch={allowStreamingBatch}
100
+ enableParallelBatch={enableParallelBatch}
101
+ setEnableParallelBatch={noop}
102
+ partialImages={1}
103
+ setPartialImages={noop}
104
+ allowResponsesImageBackend={allowResponsesImageBackend}
105
+ hasDefaultResponsesModel={hasDefaultResponsesModel}
106
+ editImageBackend={backend}
107
+ setEditImageBackend={noop}
108
+ editStreamingStrategy={streamingStrategy}
109
+ defaultStreamingStrategy={defaultStreamingStrategy}
110
+ setEditStreamingStrategy={noop}
111
+ editResponsesModel={editResponsesModel}
112
+ setEditResponsesModel={noop}
113
+ editThinking='server-default'
114
+ setEditThinking={noop}
115
+ editPromptOptimization='server-default'
116
+ setEditPromptOptimization={noop}
117
+ editForceWeb={false}
118
+ setEditForceWeb={noop}
119
+ estimatedCostLabel='预计 0.12 积分'
120
+ initialAdvancedOpen={advancedOpen}
121
+ initialAdvancedTab={advancedTab}
122
+ />
123
+ </I18nProvider>
124
+ );
125
+ }
126
+
127
+ describe('EditingForm advanced upstream controls', () => {
128
+ it('keeps the full professional accordion available on desktop and mobile', () => {
129
+ const html = renderEditingForm({ backend: 'server-default', advancedTab: 'route' });
130
+
131
+ assert.match(
132
+ html,
133
+ /<div class="border-border bg-muted\/20 rounded-md border"><button[^>]*aria-controls="editing-advanced-panel"/
134
+ );
135
+ assert.doesNotMatch(
136
+ html,
137
+ /<div class="[^"]*lg:hidden[^"]*"><button[^>]*aria-controls="editing-advanced-panel"/
138
+ );
139
+ });
140
+
141
+ it('keeps model and streaming controls out of the default edit form surface', () => {
142
+ const html = renderEditingForm({ backend: 'server-default', advancedOpen: false });
143
+
144
+ assert.match(html, /参考图/);
145
+ assert.match(html, /修改想法/);
146
+ assert.match(html, /专业模式/);
147
+ assert.doesNotMatch(html, /edit-model-select/);
148
+ assert.doesNotMatch(html, /edit-stream-mode-select/);
149
+ });
150
+
151
+ it('translates the default backend into a user-facing route label near submit', () => {
152
+ const html = renderEditingForm({ backend: 'server-default', advancedOpen: false });
153
+
154
+ assert.match(html, /默认线路/);
155
+ assert.match(html, /预计 0\.12 积分/);
156
+ });
157
+
158
+ it('renders edit model controls only in the professional model tab', () => {
159
+ const html = renderEditingForm({ backend: 'server-default', advancedTab: 'model' });
160
+
161
+ assert.match(html, /edit-model-select/);
162
+ assert.match(html, /gpt-image-2 始终以高保真方式处理参考图/);
163
+ assert.doesNotMatch(html, /edit-image-backend-select/);
164
+ });
165
+
166
+ it('renders edit stream controls only in the professional stream tab', () => {
167
+ const html = renderEditingForm({ backend: 'server-default', advancedTab: 'stream' });
168
+
169
+ assert.match(html, /edit-stream-mode-select/);
170
+ assert.match(html, /edit-partial-1/);
171
+ assert.doesNotMatch(html, /edit-model-select/);
172
+ });
173
+
174
+ it('renders an explicit parallel batch toggle in edit stream settings', () => {
175
+ const html = renderEditingForm({
176
+ backend: 'server-default',
177
+ advancedTab: 'stream',
178
+ allowStreamingBatch: true,
179
+ enableParallelBatch: true,
180
+ editN: [2]
181
+ });
182
+
183
+ assert.match(html, /并发批量/);
184
+ assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/);
185
+ assert.match(html, /id="edit-parallel-batch-enabled"/);
186
+ assert.match(html, /aria-checked="true"/);
187
+ });
188
+
189
+ it('keeps edit parallel batch disabled for a single output image', () => {
190
+ const html = renderEditingForm({
191
+ backend: 'server-default',
192
+ advancedTab: 'stream',
193
+ allowStreamingBatch: true,
194
+ enableParallelBatch: true
195
+ });
196
+
197
+ assert.match(html, /选择至少 2 张图片或 2 条提示词后可启用并发/);
198
+ assert.match(html, /id="edit-parallel-batch-enabled"/);
199
+ assert.match(html, /aria-checked="false"/);
200
+ assert.match(html, /disabled=""/);
201
+ });
202
+
203
+ it('keeps edit parallel batch disabled when streaming strategy is off', () => {
204
+ const html = renderEditingForm({
205
+ backend: 'server-default',
206
+ advancedTab: 'stream',
207
+ allowStreamingBatch: true,
208
+ enableParallelBatch: true,
209
+ editN: [2],
210
+ streamingStrategy: 'off'
211
+ });
212
+
213
+ assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/);
214
+ assert.match(html, /id="edit-parallel-batch-enabled"/);
215
+ assert.match(html, /aria-checked="false"/);
216
+ assert.match(html, /disabled=""/);
217
+ });
218
+
219
+ it('keeps edit parallel batch disabled when the server default streaming strategy is off', () => {
220
+ const html = renderEditingForm({
221
+ backend: 'server-default',
222
+ advancedTab: 'stream',
223
+ allowStreamingBatch: true,
224
+ enableParallelBatch: true,
225
+ editN: [2],
226
+ defaultStreamingStrategy: 'off'
227
+ });
228
+
229
+ assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/);
230
+ assert.match(html, /id="edit-parallel-batch-enabled"/);
231
+ assert.match(html, /aria-checked="false"/);
232
+ assert.match(html, /disabled=""/);
233
+ });
234
+
235
+ it('disables the edit stream mode selector when the server default streaming strategy is off', () => {
236
+ const html = renderEditingForm({
237
+ backend: 'server-default',
238
+ advancedTab: 'stream',
239
+ defaultStreamingStrategy: 'off'
240
+ });
241
+
242
+ assert.match(html, /<button[^>]*(?:disabled=""[^>]*id="edit-stream-mode-select"|id="edit-stream-mode-select"[^>]*disabled="")/);
243
+ });
244
+
245
+ it('renders Responses-specific edit controls when the Responses backend is selected', () => {
246
+ const html = renderEditingForm({ backend: 'responses-image-generation' });
247
+
248
+ assert.match(html, /图片生成后端/);
249
+ assert.match(html, /影响说明/);
250
+ assert.match(html, /Responses image_generation 需要实验开关和顶层模型/);
251
+ assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/);
252
+ assert.match(html, /GPT 顶层模型/);
253
+ assert.match(html, /思考强度/);
254
+ assert.match(html, /提示词优化/);
255
+ assert.doesNotMatch(html, /优先 Web 账号/);
256
+ });
257
+
258
+ it('explains the resolved edit server default streaming strategy', () => {
259
+ const offHtml = renderEditingForm({
260
+ backend: 'server-default',
261
+ advancedTab: 'route',
262
+ streamingStrategy: 'server-default',
263
+ defaultStreamingStrategy: 'off'
264
+ });
265
+ const forceHtml = renderEditingForm({
266
+ backend: 'server-default',
267
+ advancedTab: 'route',
268
+ streamingStrategy: 'server-default',
269
+ defaultStreamingStrategy: 'force-sse'
270
+ });
271
+
272
+ assert.match(offHtml, /关闭流式会减少长连接不稳定因素/);
273
+ assert.doesNotMatch(offHtml, /自动或服务端默认会优先使用当前推荐的流式策略/);
274
+ assert.match(forceHtml, /强制 SSE 会跳过自动判断/);
275
+ assert.doesNotMatch(forceHtml, /自动或服务端默认会优先使用当前推荐的流式策略/);
276
+ });
277
+
278
+ it('disables the experimental Responses backend when runtime capabilities do not allow it', () => {
279
+ const html = renderEditingForm({
280
+ backend: 'server-default',
281
+ allowResponsesImageBackend: false
282
+ });
283
+
284
+ assert.match(html, /当前运行时未启用 Responses image_generation/);
285
+ assert.doesNotMatch(html, /GPT 顶层模型/);
286
+ });
287
+
288
+ it('blocks Responses edits until a top-level model is available', () => {
289
+ const html = renderEditingForm({
290
+ backend: 'responses-image-generation',
291
+ hasDefaultResponsesModel: false,
292
+ editResponsesModel: '',
293
+ editPrompt: '用户真实编辑要求',
294
+ imageFiles: [new File(['x'], 'source.png', { type: 'image/png' })]
295
+ });
296
+
297
+ assert.match(html, /Responses image_generation 需要填写 GPT 顶层模型/);
298
+ assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/);
299
+ });
300
+
301
+ it('renders Images API edit controls and compression when JPEG output is selected', () => {
302
+ const html = renderEditingForm({ backend: 'images-api', outputFormat: 'jpeg', advancedTab: 'output' });
303
+
304
+ assert.match(html, /Images API/);
305
+ assert.match(html, /输出格式/);
306
+ assert.match(html, /压缩:85%/);
307
+ assert.match(html, /内容审核级别/);
308
+ assert.doesNotMatch(html, /GPT 顶层模型/);
309
+ });
310
+ });
311
+
312
+ describe('EditingForm reused history context', () => {
313
+ it('shows which history values were carried into edit mode', () => {
314
+ const html = renderEditingForm({
315
+ backend: 'server-default',
316
+ reuseContext: {
317
+ sourceLabel: '最近生成:2026/6/2 12:00:00',
318
+ restoredFields: ['参考图', '提示词', '模型', '尺寸', '数量'],
319
+ promptPreview: '用户真实编辑提示词'
320
+ }
321
+ });
322
+
323
+ assert.match(html, /已带入内容/);
324
+ assert.match(html, /最近生成:2026\/6\/2 12:00:00/);
325
+ assert.match(html, /参考图/);
326
+ assert.match(html, /提示词/);
327
+ assert.match(html, /模型/);
328
+ assert.match(html, /尺寸/);
329
+ assert.match(html, /数量/);
330
+ assert.match(html, /用户真实编辑提示词/);
331
+ assert.match(html, /这些内容已经写入编辑单,可以修改后再生成。/);
332
+ });
333
+ });
src/components/editing-form.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
src/components/generation-activity-timeline.tsx ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use client';
2
+
3
+ import type { GenerationActivityItem } from '@/lib/generation-activity';
4
+ import type { HistoryMetadata } from '@/lib/history-metadata';
5
+ import { cn } from '@/lib/utils';
6
+ import Image from 'next/image';
7
+
8
+ type ActivityTimelineProps = {
9
+ activityItems: GenerationActivityItem[];
10
+ history: HistoryMetadata[];
11
+ pendingActivityItems: GenerationActivityItem[];
12
+ onSelectImage: (item: HistoryMetadata) => void;
13
+ onClearHistory: () => void;
14
+ getImageSrc: (filename: string) => string | undefined;
15
+ formatStatusTime: (timestamp: number) => string;
16
+ formatDuration: (ms: number) => string;
17
+ t: (key: string, values?: Record<string, string | number>) => string;
18
+ };
19
+
20
+ function getActivityToneClass(tone: GenerationActivityItem['tone']): string {
21
+ if (tone === 'progress') return 'bg-[oklch(0.58_0.1_220)]';
22
+ if (tone === 'success') return 'bg-[oklch(0.58_0.1_145)]';
23
+ if (tone === 'warning') return 'bg-[oklch(0.62_0.13_38)]';
24
+ return 'bg-[oklch(0.72_0.05_86)]';
25
+ }
26
+
27
+ function getActivityToneLabel(tone: GenerationActivityItem['tone'], t: ActivityTimelineProps['t']): string {
28
+ if (tone === 'progress') return t('history.activityToneProgress');
29
+ if (tone === 'success') return t('history.activityToneSuccess');
30
+ if (tone === 'warning') return t('history.activityToneWarning');
31
+ return t('history.activityToneNeutral');
32
+ }
33
+
34
+ function getPendingStageLabel(id: string, t: ActivityTimelineProps['t']): string {
35
+ if (id === 'pending-request') return t('history.pendingRequestStage');
36
+ if (id === 'pending-streaming') return t('history.pendingStreamingStage');
37
+ if (id === 'pending-saved') return t('history.pendingSavedStage');
38
+ if (id === 'pending-failed') return t('history.pendingFailedStage');
39
+ return t('history.activityToneNeutral');
40
+ }
41
+
42
+ function GenerationActivityRows({
43
+ items,
44
+ t,
45
+ compact = false
46
+ }: {
47
+ items: GenerationActivityItem[];
48
+ t: ActivityTimelineProps['t'];
49
+ compact?: boolean;
50
+ }) {
51
+ return (
52
+ <>
53
+ {items.map((item, index) => (
54
+ <div
55
+ key={item.id}
56
+ className={cn(
57
+ 'grid grid-cols-[auto_auto_1fr] items-start gap-2 rounded-md px-2 text-left transition-[background-color]',
58
+ index === 0 ? 'bg-[oklch(0.96_0.035_82)]' : 'bg-background/58',
59
+ compact ? 'py-1.5' : 'py-2',
60
+ item.tone === 'warning' && 'bg-destructive/5'
61
+ )}>
62
+ <span className='text-muted-foreground mt-0.5 w-10 shrink-0 text-right text-[10px] leading-4 tabular-nums'>
63
+ {getActivityToneLabel(item.tone, t)}
64
+ </span>
65
+ <span className={cn('flex h-full w-3 justify-center', compact ? 'pt-1' : 'pt-1.5')}>
66
+ <span className={cn('h-2 w-2 rounded-full', getActivityToneClass(item.tone))} />
67
+ </span>
68
+ <span className='min-w-0'>
69
+ <span className='text-foreground block truncate text-xs font-medium'>{item.label}</span>
70
+ <span
71
+ className={cn(
72
+ 'text-muted-foreground block truncate text-[11px]',
73
+ compact ? 'mt-0 leading-4' : 'mt-0.5'
74
+ )}>
75
+ {item.detail}
76
+ </span>
77
+ </span>
78
+ </div>
79
+ ))}
80
+ </>
81
+ );
82
+ }
83
+
84
+ function PendingActivityLine({ items, t }: { items: GenerationActivityItem[]; t: ActivityTimelineProps['t'] }) {
85
+ return (
86
+ <div className='activity-feed grid gap-1.5' aria-label={t('history.pendingActivityFeed')}>
87
+ {items.map((item, index) => (
88
+ <div
89
+ key={item.id}
90
+ className={cn(
91
+ 'grid grid-cols-[auto_auto_1fr] items-start gap-2 rounded-md px-2 py-1.5 text-xs',
92
+ index === 1 ? 'bg-[oklch(0.955_0.04_86)]' : 'bg-background/58'
93
+ )}>
94
+ <span className='text-muted-foreground mt-0.5 w-10 shrink-0 text-right text-[10px] leading-4 tabular-nums'>
95
+ {getPendingStageLabel(item.id, t)}
96
+ </span>
97
+ <span className='flex h-full w-3 justify-center pt-1'>
98
+ <span className={cn('h-1.5 w-1.5 rounded-full', getActivityToneClass(item.tone))} />
99
+ </span>
100
+ <span className='min-w-0'>
101
+ <span className='text-foreground block truncate font-medium'>{item.label}</span>
102
+ <span className='text-muted-foreground mt-0.5 block truncate text-[11px] leading-4'>
103
+ {item.detail}
104
+ </span>
105
+ </span>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ );
110
+ }
111
+
112
+ function ActivityHistoryRow({
113
+ item,
114
+ index,
115
+ hasLiveActivity,
116
+ onSelectImage,
117
+ getImageSrc,
118
+ formatStatusTime,
119
+ formatDuration,
120
+ t
121
+ }: {
122
+ item: HistoryMetadata;
123
+ index: number;
124
+ hasLiveActivity: boolean;
125
+ onSelectImage: (item: HistoryMetadata) => void;
126
+ getImageSrc: (filename: string) => string | undefined;
127
+ formatStatusTime: (timestamp: number) => string;
128
+ formatDuration: (ms: number) => string;
129
+ t: (key: string, values?: Record<string, string | number>) => string;
130
+ }) {
131
+ const isLatestQuietActivity = index === 0 && !hasLiveActivity;
132
+ const isFailedItem = item.status === 'failed';
133
+ const activityLabel = isFailedItem
134
+ ? t('history.failedStatus')
135
+ : item.mode === 'edit'
136
+ ? t('history.statusEditDone')
137
+ : t('history.statusCreateDone');
138
+ const activityDetail = isFailedItem
139
+ ? item.failureMessage?.trim() || t('history.failedReasonUnavailable')
140
+ : t('history.statusBatchSummary', {
141
+ count: item.images.length,
142
+ duration: formatDuration(item.durationMs)
143
+ });
144
+
145
+ return (
146
+ <button
147
+ type='button'
148
+ onClick={() => onSelectImage(item)}
149
+ disabled={isFailedItem}
150
+ className={cn(
151
+ 'grid w-full grid-cols-[auto_auto_1fr_auto] items-start gap-2 rounded-md px-2 py-2 text-left transition-[background-color,color,transform] hover:-translate-y-0.5 active:translate-y-0',
152
+ isLatestQuietActivity
153
+ ? 'text-foreground bg-[oklch(0.95_0.04_86)]'
154
+ : 'text-muted-foreground hover:text-foreground hover:bg-[oklch(0.965_0.025_84)]',
155
+ isFailedItem && 'cursor-default hover:translate-y-0'
156
+ )}>
157
+ <span className='text-muted-foreground mt-0.5 w-10 shrink-0 text-right text-[10px] leading-4 tabular-nums'>
158
+ {formatStatusTime(item.timestamp)}
159
+ </span>
160
+ <span className='flex h-full w-3 justify-center pt-1.5'>
161
+ <span
162
+ className={cn(
163
+ 'h-2 w-2 rounded-full',
164
+ isFailedItem
165
+ ? 'bg-destructive'
166
+ : isLatestQuietActivity
167
+ ? 'bg-[oklch(0.62_0.13_38)]'
168
+ : 'bg-[oklch(0.66_0.08_145)]'
169
+ )}
170
+ />
171
+ </span>
172
+ <span className='min-w-0'>
173
+ <span className='flex min-w-0 items-center gap-2'>
174
+ <span className='text-foreground truncate text-xs font-medium'>{activityLabel}</span>
175
+ </span>
176
+ <span className='text-muted-foreground mt-0.5 block truncate text-[11px]'>{activityDetail}</span>
177
+ </span>
178
+ <span className='hidden shrink-0 gap-1 sm:flex'>
179
+ {item.images.slice(0, 3).map((image) => {
180
+ const source =
181
+ item.storageModeUsed === 'indexeddb'
182
+ ? getImageSrc(image.filename)
183
+ : `/api/image/${image.filename}`;
184
+ return source ? (
185
+ <span
186
+ key={image.filename}
187
+ className='border-background bg-muted relative h-7 w-7 overflow-hidden rounded-sm border shadow-sm'>
188
+ <Image
189
+ src={source}
190
+ alt={image.filename}
191
+ fill
192
+ sizes='28px'
193
+ className='object-cover'
194
+ unoptimized
195
+ />
196
+ </span>
197
+ ) : null;
198
+ })}
199
+ </span>
200
+ </button>
201
+ );
202
+ }
203
+
204
+ export function ActivityTimeline({
205
+ activityItems,
206
+ history,
207
+ pendingActivityItems,
208
+ onSelectImage,
209
+ onClearHistory,
210
+ getImageSrc,
211
+ formatStatusTime,
212
+ formatDuration,
213
+ t
214
+ }: ActivityTimelineProps) {
215
+ const hasActivity = activityItems.length > 0 || history.length > 0;
216
+
217
+ return (
218
+ <div className='border-border/60 bg-background/48 shrink-0 border-t p-3 lg:mt-1 lg:min-h-[16.5rem] lg:border-t-0 lg:bg-transparent'>
219
+ <div className='space-y-2 rounded-md border border-[oklch(0.86_0.035_78)] bg-[oklch(0.982_0.014_84)] p-2.5 shadow-[0_6px_16px_oklch(0.42_0.035_58/0.08)]'>
220
+ <div className='flex items-center justify-between gap-2'>
221
+ <div className='min-w-0'>
222
+ <div className='flex items-center gap-2'>
223
+ <span className='h-2 w-2 rounded-full bg-[oklch(0.62_0.13_38)]' />
224
+ <p className='text-sm font-medium'>{t('history.generationStatus')}</p>
225
+ </div>
226
+ <p className='text-muted-foreground mt-0.5 truncate text-[11px]'>
227
+ {t('history.generationStatusHint')}
228
+ </p>
229
+ </div>
230
+ {history.length > 0 ? (
231
+ <button
232
+ type='button'
233
+ className='text-muted-foreground hover:text-foreground shrink-0 text-xs'
234
+ onClick={onClearHistory}>
235
+ {t('history.clear')}
236
+ </button>
237
+ ) : null}
238
+ </div>
239
+ {hasActivity ? (
240
+ <div className='max-h-56 space-y-2 overflow-y-auto pr-1 text-xs lg:max-h-[12.5rem]'>
241
+ <GenerationActivityRows items={activityItems} t={t} compact />
242
+ {history.slice(0, 4).map((item, index) => (
243
+ <ActivityHistoryRow
244
+ key={item.timestamp}
245
+ item={item}
246
+ index={index}
247
+ hasLiveActivity={activityItems.length > 0}
248
+ onSelectImage={onSelectImage}
249
+ getImageSrc={getImageSrc}
250
+ formatStatusTime={formatStatusTime}
251
+ formatDuration={formatDuration}
252
+ t={t}
253
+ />
254
+ ))}
255
+ </div>
256
+ ) : (
257
+ <div className='space-y-1.5'>
258
+ <p className='text-muted-foreground px-1 text-[11px]'>{t('history.statusEmpty')}</p>
259
+ <PendingActivityLine items={pendingActivityItems} t={t} />
260
+ </div>
261
+ )}
262
+ </div>
263
+ </div>
264
+ );
265
+ }
src/components/generation-form.test.tsx ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { GenerationForm, resolveGenerationFooterPromptTarget } from './generation-form';
2
+ import { I18nProvider } from '@/lib/i18n';
3
+ import type { ImageStreamingStrategy } from '@/lib/image-upstream-strategy';
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+ import * as React from 'react';
7
+ import { renderToStaticMarkup } from 'react-dom/server';
8
+
9
+ const noop = () => {};
10
+
11
+ function renderGenerationForm(
12
+ options: {
13
+ currentMode?: 'generate' | 'edit' | 'batch' | 'reuse';
14
+ defaultAdvancedTab?: 'output' | 'model' | 'stream' | 'route';
15
+ failedBatchPrompts?: string[];
16
+ canPauseBatch?: boolean;
17
+ isBatchPauseRequested?: boolean;
18
+ defaultAdvancedOpen?: boolean;
19
+ omitPauseHandler?: boolean;
20
+ allowStreamingBatch?: boolean;
21
+ enableParallelBatch?: boolean;
22
+ streamingStrategy?: React.ComponentProps<typeof GenerationForm>['streamingStrategy'];
23
+ defaultStreamingStrategy?: ImageStreamingStrategy;
24
+ prompt?: string;
25
+ batchPromptText?: string;
26
+ canApplyRandomInspiration?: boolean;
27
+ allowResponsesImageBackend?: boolean;
28
+ hasDefaultResponsesModel?: boolean;
29
+ responsesModel?: string;
30
+ imageBackend?: React.ComponentProps<typeof GenerationForm>['imageBackend'];
31
+ } = {}
32
+ ) {
33
+ return renderToStaticMarkup(
34
+ <I18nProvider>
35
+ <GenerationForm
36
+ onSubmit={noop}
37
+ onSaveInspiration={noop}
38
+ canApplyRandomInspiration={options.canApplyRandomInspiration ?? true}
39
+ onPickRandomInspiration={() => '用户保存的真实提示词'}
40
+ isLoading={false}
41
+ currentMode={options.currentMode ?? 'generate'}
42
+ onModeChange={noop}
43
+ reuseContext={null}
44
+ onClearReuseContext={noop}
45
+ isPasswordRequiredByBackend={false}
46
+ clientPasswordHash={null}
47
+ onOpenPasswordDialog={noop}
48
+ model='gpt-image-2'
49
+ setModel={noop}
50
+ prompt={options.prompt ?? '用户真实提示词 A'}
51
+ setPrompt={noop}
52
+ batchPromptText={options.batchPromptText ?? '用户真实提示词 A\n用户真实提示词 B'}
53
+ setBatchPromptText={noop}
54
+ failedBatchPrompts={options.failedBatchPrompts}
55
+ canPauseBatch={options.canPauseBatch}
56
+ isBatchPauseRequested={options.isBatchPauseRequested}
57
+ onPauseBatch={options.omitPauseHandler ? undefined : noop}
58
+ n={[1]}
59
+ setN={noop}
60
+ size='auto'
61
+ setSize={noop}
62
+ customWidth={1024}
63
+ setCustomWidth={noop}
64
+ customHeight={1024}
65
+ setCustomHeight={noop}
66
+ quality='high'
67
+ setQuality={noop}
68
+ outputFormat='png'
69
+ setOutputFormat={noop}
70
+ compression={[100]}
71
+ setCompression={noop}
72
+ background='auto'
73
+ setBackground={noop}
74
+ moderation='auto'
75
+ setModeration={noop}
76
+ streamMode='auto'
77
+ setStreamMode={noop}
78
+ allowStreamingBatch={options.allowStreamingBatch ?? false}
79
+ enableParallelBatch={options.enableParallelBatch ?? false}
80
+ setEnableParallelBatch={noop}
81
+ partialImages={1}
82
+ setPartialImages={noop}
83
+ allowResponsesImageBackend={options.allowResponsesImageBackend ?? true}
84
+ hasDefaultResponsesModel={options.hasDefaultResponsesModel ?? true}
85
+ imageBackend={options.imageBackend ?? 'server-default'}
86
+ setImageBackend={noop}
87
+ streamingStrategy={options.streamingStrategy ?? 'server-default'}
88
+ defaultStreamingStrategy={options.defaultStreamingStrategy ?? 'auto'}
89
+ setStreamingStrategy={noop}
90
+ responsesModel={options.responsesModel ?? ''}
91
+ setResponsesModel={noop}
92
+ thinking='server-default'
93
+ setThinking={noop}
94
+ promptOptimization='server-default'
95
+ setPromptOptimization={noop}
96
+ forceWeb={false}
97
+ setForceWeb={noop}
98
+ estimatedCostLabel='预计 0.12 积分'
99
+ defaultAdvancedOpen={options.defaultAdvancedOpen}
100
+ defaultAdvancedTab={options.defaultAdvancedTab}
101
+ />
102
+ </I18nProvider>
103
+ );
104
+ }
105
+
106
+ describe('GenerationForm advanced groups', () => {
107
+ it('shows task-specific descriptions in the mode segmented control', () => {
108
+ const html = renderGenerationForm();
109
+
110
+ for (const label of ['从灵感开始', '带图继续改', '一次多张', '套用旧稿']) {
111
+ assert.match(html, new RegExp(label));
112
+ }
113
+ });
114
+
115
+ it('keeps the full professional accordion available on desktop and mobile', () => {
116
+ const html = renderGenerationForm({ defaultAdvancedTab: 'route' });
117
+
118
+ assert.match(
119
+ html,
120
+ /<div class="border-border bg-muted\/20 rounded-md border"><button[^>]*aria-controls="generation-advanced-panel"/
121
+ );
122
+ assert.doesNotMatch(
123
+ html,
124
+ /<div class="[^"]*lg:hidden[^"]*"><button[^>]*aria-controls="generation-advanced-panel"/
125
+ );
126
+ });
127
+
128
+ it('labels the collapsed mobile advanced drawer as easy mode', () => {
129
+ const html = renderGenerationForm();
130
+
131
+ assert.match(html, /省心模式/);
132
+ assert.match(html, /常用参数已放在基础设置里/);
133
+ });
134
+
135
+ it('translates the default backend into a user-facing route label near submit', () => {
136
+ const html = renderGenerationForm();
137
+
138
+ assert.match(html, /默认线路/);
139
+ assert.match(html, /预计 0\.12 积分/);
140
+ });
141
+
142
+ it('labels the expanded mobile advanced drawer as professional mode', () => {
143
+ const html = renderGenerationForm({ defaultAdvancedOpen: true });
144
+
145
+ assert.match(html, /专业模式/);
146
+ assert.match(html, /清晰度: 高, 输出格式: PNG, 服务端默认/);
147
+ });
148
+
149
+ it('renders route controls in a separate professional tab', () => {
150
+ const html = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'route' });
151
+
152
+ for (const label of ['输出', '模型', '流式', '路由']) {
153
+ assert.match(html, new RegExp(label));
154
+ }
155
+ assert.match(html, /image-backend-select/);
156
+ assert.match(html, /streaming-strategy-select/);
157
+ assert.doesNotMatch(html, /model-select/);
158
+ });
159
+
160
+ it('explains route choices that affect stability and cost', () => {
161
+ const html = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'route' });
162
+
163
+ assert.match(html, /影响说明/);
164
+ assert.match(html, /服务端默认会沿用当前部署配置/);
165
+ assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/);
166
+ assert.match(html, /费用主要由模型、尺寸、数量和预览图数量决定/);
167
+ });
168
+
169
+ it('explains the resolved server default streaming strategy', () => {
170
+ const offHtml = renderGenerationForm({
171
+ defaultAdvancedOpen: true,
172
+ defaultAdvancedTab: 'route',
173
+ streamingStrategy: 'server-default',
174
+ defaultStreamingStrategy: 'off'
175
+ });
176
+ const forceHtml = renderGenerationForm({
177
+ defaultAdvancedOpen: true,
178
+ defaultAdvancedTab: 'route',
179
+ streamingStrategy: 'server-default',
180
+ defaultStreamingStrategy: 'force-sse'
181
+ });
182
+
183
+ assert.match(offHtml, /关闭流式会减少长连接不稳定因素/);
184
+ assert.doesNotMatch(offHtml, /自动或服务端默认会优先使用当前推荐的流式策略/);
185
+ assert.match(forceHtml, /强制 SSE 会跳过自动判断/);
186
+ assert.doesNotMatch(forceHtml, /自动或服务端默认会优先使用当前推荐的流式策略/);
187
+ });
188
+
189
+ it('disables the experimental Responses backend when runtime capabilities do not allow it', () => {
190
+ const html = renderGenerationForm({
191
+ defaultAdvancedOpen: true,
192
+ defaultAdvancedTab: 'route',
193
+ allowResponsesImageBackend: false
194
+ });
195
+
196
+ assert.match(html, /当前运行时未启用 Responses image_generation/);
197
+ assert.doesNotMatch(html, /GPT 顶层模型/);
198
+ });
199
+
200
+ it('blocks Responses generation until a top-level model is available', () => {
201
+ const html = renderGenerationForm({
202
+ defaultAdvancedOpen: true,
203
+ defaultAdvancedTab: 'route',
204
+ imageBackend: 'responses-image-generation',
205
+ hasDefaultResponsesModel: false,
206
+ responsesModel: ''
207
+ });
208
+
209
+ assert.match(html, /Responses image_generation 需要填写 GPT 顶层模型/);
210
+ assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*生成图像[\s\S]*<\/button>/);
211
+ });
212
+
213
+ it('allows Responses generation when the runtime has a default top-level model', () => {
214
+ const html = renderGenerationForm({
215
+ defaultAdvancedOpen: true,
216
+ defaultAdvancedTab: 'route',
217
+ imageBackend: 'responses-image-generation',
218
+ hasDefaultResponsesModel: true,
219
+ responsesModel: ''
220
+ });
221
+
222
+ assert.match(html, /GPT 顶层模型/);
223
+ assert.doesNotMatch(html, /Responses image_generation 需要填写 GPT 顶层模型/);
224
+ });
225
+
226
+ it('keeps the model selector out of the route group', () => {
227
+ const html = renderGenerationForm({ defaultAdvancedOpen: true, defaultAdvancedTab: 'model' });
228
+
229
+ assert.match(html, /model-select/);
230
+ assert.doesNotMatch(html, /image-backend-select/);
231
+ });
232
+
233
+ it('disables random inspiration when no saved prompt is available', () => {
234
+ const html = renderGenerationForm({ canApplyRandomInspiration: false });
235
+
236
+ assert.match(html, /随便来点/);
237
+ assert.match(html, /<button[^>]*disabled=""[^>]*>[\s\S]*随便来点[\s\S]*<\/button>/);
238
+ assert.doesNotMatch(html, /夏日窗边的奶油色房间/);
239
+ });
240
+
241
+ it('does not render built-in prompt style chips before the user enters real content', () => {
242
+ const html = renderGenerationForm();
243
+
244
+ assert.doesNotMatch(html, /风格偏好/);
245
+ assert.doesNotMatch(html, /胶片感/);
246
+ assert.doesNotMatch(html, /奶油色/);
247
+ assert.doesNotMatch(html, /夏日窗边/);
248
+ });
249
+ });
250
+
251
+ describe('GenerationForm batch mode', () => {
252
+ it('uses the visible batch prompt text for footer prompt actions', () => {
253
+ assert.deepEqual(
254
+ resolveGenerationFooterPromptTarget({
255
+ currentMode: 'batch',
256
+ prompt: 'hidden single prompt',
257
+ batchPromptText: 'first batch prompt\nsecond batch prompt'
258
+ }),
259
+ {
260
+ value: 'first batch prompt\nsecond batch prompt',
261
+ isEmpty: false
262
+ }
263
+ );
264
+ assert.deepEqual(
265
+ resolveGenerationFooterPromptTarget({
266
+ currentMode: 'generate',
267
+ prompt: 'visible single prompt',
268
+ batchPromptText: 'hidden batch prompt'
269
+ }),
270
+ {
271
+ value: 'visible single prompt',
272
+ isEmpty: false
273
+ }
274
+ );
275
+ });
276
+
277
+ it('renders a real batch prompt list only in batch mode', () => {
278
+ const html = renderGenerationForm({ currentMode: 'batch' });
279
+
280
+ assert.match(html, /批量提示词列表/);
281
+ assert.match(html, /2 条任务/);
282
+ assert.match(html, /batch-prompt-list/);
283
+ assert.doesNotMatch(html, /id="prompt"/);
284
+ });
285
+
286
+ it('shows a low-interruption task summary for batch jobs', () => {
287
+ const html = renderGenerationForm({ currentMode: 'batch' });
288
+
289
+ assert.match(html, /batch-task-summary/);
290
+ assert.match(html, /任务摘要/);
291
+ assert.match(html, /每一行会生成一张图/);
292
+ assert.match(html, /失败后处理/);
293
+ assert.match(html, /生成动态/);
294
+ assert.match(html, /不占用中央单张预览/);
295
+ });
296
+
297
+ it('shows a reusable failed-task entry for partial batch failures', () => {
298
+ const html = renderGenerationForm({
299
+ currentMode: 'batch',
300
+ failedBatchPrompts: ['用户真实提示词 B']
301
+ });
302
+
303
+ assert.match(html, /上次批量有 1 条未完成/);
304
+ assert.match(html, /只保留失败项/);
305
+ });
306
+
307
+ it('keeps the batch prompt list out of the default generate mode', () => {
308
+ const html = renderGenerationForm({ failedBatchPrompts: ['用户真实提示词 B'] });
309
+
310
+ assert.doesNotMatch(html, /batch-prompt-list/);
311
+ assert.doesNotMatch(html, /batch-task-summary/);
312
+ assert.doesNotMatch(html, /只保留失败项/);
313
+ });
314
+
315
+ it('shows a real pause action while a batch is loading', () => {
316
+ const html = renderGenerationForm({
317
+ currentMode: 'batch',
318
+ canPauseBatch: true
319
+ });
320
+
321
+ assert.match(html, /暂停批量/);
322
+ assert.match(html, /已开始的任务会完成,未开始的任务会保留为失败项再重试。/);
323
+ });
324
+
325
+ it('keeps the pause action renderable when the handler is omitted', () => {
326
+ const html = renderGenerationForm({
327
+ currentMode: 'batch',
328
+ canPauseBatch: true,
329
+ omitPauseHandler: true
330
+ });
331
+
332
+ assert.match(html, /暂停批量/);
333
+ });
334
+
335
+ it('switches the pause action into requested state after pause is requested', () => {
336
+ const html = renderGenerationForm({
337
+ currentMode: 'batch',
338
+ canPauseBatch: true,
339
+ isBatchPauseRequested: true
340
+ });
341
+
342
+ assert.match(html, /暂停中/);
343
+ assert.match(html, /disabled=""/);
344
+ });
345
+
346
+ it('renders an explicit parallel batch toggle in stream settings', () => {
347
+ const html = renderGenerationForm({
348
+ currentMode: 'batch',
349
+ defaultAdvancedOpen: true,
350
+ defaultAdvancedTab: 'stream',
351
+ allowStreamingBatch: true,
352
+ enableParallelBatch: true
353
+ });
354
+
355
+ assert.match(html, /并发批量/);
356
+ assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/);
357
+ assert.match(html, /id="parallel-batch-enabled"/);
358
+ assert.match(html, /aria-checked="true"/);
359
+ });
360
+
361
+ it('keeps the parallel batch toggle disabled for a single image request', () => {
362
+ const html = renderGenerationForm({
363
+ defaultAdvancedOpen: true,
364
+ defaultAdvancedTab: 'stream',
365
+ allowStreamingBatch: true,
366
+ enableParallelBatch: true
367
+ });
368
+
369
+ assert.match(html, /选择至少 2 张图片或 2 条提示词后可启用并发/);
370
+ assert.match(html, /id="parallel-batch-enabled"/);
371
+ assert.match(html, /aria-checked="false"/);
372
+ assert.match(html, /disabled=""/);
373
+ });
374
+
375
+ it('keeps the parallel batch toggle disabled when streaming strategy is off', () => {
376
+ const html = renderGenerationForm({
377
+ currentMode: 'batch',
378
+ defaultAdvancedOpen: true,
379
+ defaultAdvancedTab: 'stream',
380
+ allowStreamingBatch: true,
381
+ enableParallelBatch: true,
382
+ streamingStrategy: 'off'
383
+ });
384
+
385
+ assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/);
386
+ assert.match(html, /id="parallel-batch-enabled"/);
387
+ assert.match(html, /aria-checked="false"/);
388
+ assert.match(html, /disabled=""/);
389
+ });
390
+
391
+ it('keeps the parallel batch toggle disabled when the server default streaming strategy is off', () => {
392
+ const html = renderGenerationForm({
393
+ currentMode: 'batch',
394
+ defaultAdvancedOpen: true,
395
+ defaultAdvancedTab: 'stream',
396
+ allowStreamingBatch: true,
397
+ enableParallelBatch: true,
398
+ defaultStreamingStrategy: 'off'
399
+ });
400
+
401
+ assert.match(html, /并发批量需要流式模式;非流式会保持顺序执行。/);
402
+ assert.match(html, /id="parallel-batch-enabled"/);
403
+ assert.match(html, /aria-checked="false"/);
404
+ assert.match(html, /disabled=""/);
405
+ });
406
+ });
src/components/generation-form.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
src/components/history-panel.test.tsx ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ HistoryPanel,
3
+ resolveHistoryPanelTabSync,
4
+ type GenerationActivityItem,
5
+ type InspirationItem
6
+ } from './history-panel';
7
+ import type { HistoryMetadata } from '@/lib/history-metadata';
8
+ import { I18nProvider } from '@/lib/i18n';
9
+ import assert from 'node:assert/strict';
10
+ import { describe, it } from 'node:test';
11
+ import * as React from 'react';
12
+ import { renderToStaticMarkup } from 'react-dom/server';
13
+
14
+ const noop = () => {};
15
+ const historyItem: HistoryMetadata = {
16
+ timestamp: Date.UTC(2026, 4, 31, 12, 15),
17
+ images: [{ filename: 'history-card.png', clientRequestId: 'request-1' }],
18
+ storageModeUsed: 'indexeddb',
19
+ durationMs: 1350,
20
+ quality: 'high',
21
+ background: 'auto',
22
+ moderation: 'auto',
23
+ prompt: '用户真实历史提示词',
24
+ mode: 'generate',
25
+ costDetails: null,
26
+ output_format: 'png',
27
+ model: 'gpt-image-2',
28
+ size: '2048x2048'
29
+ };
30
+ const batchHistoryItem: HistoryMetadata = {
31
+ ...historyItem,
32
+ images: [
33
+ { filename: 'batch-card-1.png', clientRequestId: 'batch-request-1' },
34
+ { filename: 'batch-card-2.png', clientRequestId: 'batch-request-2' },
35
+ { filename: 'batch-card-3.png', clientRequestId: 'batch-request-3' }
36
+ ]
37
+ };
38
+ const failedHistoryItem: HistoryMetadata = {
39
+ ...historyItem,
40
+ timestamp: Date.UTC(2026, 4, 31, 12, 35),
41
+ images: [],
42
+ status: 'failed',
43
+ failureMessage: '上游或 API 中转站异常。请稍后重试。',
44
+ durationMs: 2200,
45
+ prompt: '用户真实失败提示词'
46
+ };
47
+ const inspirationItem: InspirationItem = {
48
+ id: 1,
49
+ createdAt: Date.UTC(2026, 4, 31, 12, 20),
50
+ prompt: '用户保存的真实灵感提示词'
51
+ };
52
+
53
+ function renderHistoryPanel(
54
+ history: HistoryMetadata[],
55
+ inspirations: InspirationItem[] = [],
56
+ activityItems: GenerationActivityItem[] = []
57
+ ): string {
58
+ return renderToStaticMarkup(
59
+ <I18nProvider>
60
+ <HistoryPanel
61
+ history={history}
62
+ inspirations={inspirations}
63
+ activityItems={activityItems}
64
+ onSelectImage={noop}
65
+ onApplyPrompt={noop}
66
+ onSaveInspiration={noop}
67
+ onSendHistoryToEdit={noop}
68
+ onDeleteInspiration={noop}
69
+ onClearHistory={noop}
70
+ getImageSrc={() => '/api/image/history-card.png'}
71
+ onDeleteItemRequest={noop}
72
+ itemPendingDeleteConfirmation={null}
73
+ onConfirmDeletion={noop}
74
+ onCancelDeletion={noop}
75
+ deletePreferenceDialogValue={false}
76
+ onDeletePreferenceDialogChange={noop}
77
+ />
78
+ </I18nProvider>
79
+ );
80
+ }
81
+
82
+ describe('HistoryPanel recent history actions', () => {
83
+ it('switches to recent history when history appears and no inspirations exist', () => {
84
+ assert.equal(
85
+ resolveHistoryPanelTabSync({
86
+ activeTab: 'inspiration',
87
+ historyCount: 1,
88
+ inspirationCount: 0
89
+ }),
90
+ 'history'
91
+ );
92
+ assert.equal(
93
+ resolveHistoryPanelTabSync({
94
+ activeTab: 'inspiration',
95
+ historyCount: 1,
96
+ inspirationCount: 1
97
+ }),
98
+ 'inspiration'
99
+ );
100
+ assert.equal(
101
+ resolveHistoryPanelTabSync({
102
+ activeTab: 'history',
103
+ historyCount: 0,
104
+ inspirationCount: 1
105
+ }),
106
+ 'inspiration'
107
+ );
108
+ });
109
+
110
+ it('renders visible save, reuse, and continue-edit actions on history cards', () => {
111
+ const html = renderHistoryPanel([historyItem]);
112
+
113
+ assert.match(html, /最近生成/);
114
+ assert.match(html, /用户真实历史提示词/);
115
+ assert.match(html, /数据库/);
116
+ assert.doesNotMatch(html, /Album/);
117
+ assert.doesNotMatch(html, /Local/);
118
+ assert.match(html, /收藏这条历史提示词/);
119
+ assert.match(html, /复用这条历史记录到创作单/);
120
+ assert.match(html, /用这条历史记录的首张图片继续编辑/);
121
+ });
122
+
123
+ it('renders recent history as a mobile horizontal snap album', () => {
124
+ const html = renderHistoryPanel([historyItem]);
125
+
126
+ assert.match(html, /snap-x snap-mandatory/);
127
+ assert.match(html, /w-\[min\(76vw,280px\)\]/);
128
+ });
129
+
130
+ it('keeps multi-image history batches collapsed by default with an expand control', () => {
131
+ const html = renderHistoryPanel([batchHistoryItem]);
132
+
133
+ assert.match(html, /展开批次/);
134
+ assert.match(html, /3 张图/);
135
+ assert.match(html, /aria-expanded="false"/);
136
+ assert.match(html, /aria-controls="history-batch-/);
137
+ assert.doesNotMatch(html, /batch-thumbnail-strip/);
138
+ });
139
+
140
+ it('shows failed recent history reasons without a fake thumbnail', () => {
141
+ const html = renderHistoryPanel([failedHistoryItem]);
142
+
143
+ assert.match(html, /生成失败/);
144
+ assert.match(html, /失败原因:/);
145
+ assert.match(html, /上游或 API 中转站异常。请稍后重试。/);
146
+ assert.match(html, /用户真实失败提示词/);
147
+ assert.doesNotMatch(html, /<img/);
148
+ assert.doesNotMatch(html, /新图已入册 0 张/);
149
+ });
150
+
151
+ it('renders inspirations as a mobile horizontal snap album', () => {
152
+ const html = renderHistoryPanel([], [inspirationItem]);
153
+
154
+ assert.match(html, /snap-x snap-mandatory/);
155
+ assert.match(html, /w-\[min\(84vw,360px\)\]/);
156
+ assert.match(html, /用户保存的真实灵感提示词/);
157
+ assert.match(html, /aria-label="套用灵感:用户保存的真实灵感提示词"/);
158
+ assert.doesNotMatch(html, /窗边的花与书/);
159
+ assert.doesNotMatch(html, /inspiration-flowers/);
160
+ });
161
+
162
+ it('does not render fake inspiration actions without handlers', () => {
163
+ const html = renderHistoryPanel([], [inspirationItem]);
164
+
165
+ assert.match(html, /已保存的灵感/);
166
+ assert.match(html, /套用首个模板/);
167
+ assert.match(html, /1 条灵感/);
168
+ assert.doesNotMatch(html, /aria-label="收藏"/);
169
+ assert.doesNotMatch(html, />管理</);
170
+ });
171
+
172
+ it('renders a non-fake pending activity timeline before the first generation', () => {
173
+ const html = renderHistoryPanel([], [inspirationItem]);
174
+
175
+ assert.match(html, /activity-feed/);
176
+ assert.match(html, /aria-label="待开始生成动态"/);
177
+ assert.match(html, /点击生成后,这里会记录创作过程。/);
178
+ assert.match(html, /准备/);
179
+ assert.match(html, /预览/);
180
+ assert.match(html, /保存/);
181
+ assert.match(html, /失败/);
182
+ assert.match(html, /等待请求开始/);
183
+ assert.match(html, /等待流式预览/);
184
+ assert.match(html, /等待保存结果/);
185
+ assert.match(html, /失败时显示原因/);
186
+ assert.doesNotMatch(html, /新图已入册/);
187
+ });
188
+
189
+ it('renders live generation activity before completed history', () => {
190
+ const html = renderHistoryPanel(
191
+ [historyItem],
192
+ [inspirationItem],
193
+ [
194
+ {
195
+ id: 'generating',
196
+ label: '正在生成',
197
+ detail: '正在把当前创作单送去生成,完成后会进入最近生成。',
198
+ tone: 'progress'
199
+ }
200
+ ]
201
+ );
202
+
203
+ assert.match(html, /正在生成/);
204
+ assert.match(html, /正在把当前创作单送去生成/);
205
+ assert.match(html, /新图已入册/);
206
+ });
207
+
208
+ it('keeps generation activity visible outside the inspiration tab content', () => {
209
+ const html = renderHistoryPanel([historyItem], [inspirationItem]);
210
+ const tabContentIndex = html.indexOf('用户保存的真实灵感提示词');
211
+ const activityIndex = html.indexOf('请求、预览、保存和失败会在这里轻量更新。');
212
+
213
+ assert.ok(tabContentIndex >= 0);
214
+ assert.ok(activityIndex > tabContentIndex);
215
+ assert.match(html, /最近生成/);
216
+ assert.match(html, /新图已入册/);
217
+ });
218
+
219
+ it('renders batch progress activity as user-facing copy', () => {
220
+ const html = renderHistoryPanel(
221
+ [historyItem],
222
+ [inspirationItem],
223
+ [
224
+ {
225
+ id: 'batch-progress',
226
+ label: '批量进度',
227
+ detail: '已完成 2/3 条任务。',
228
+ tone: 'progress'
229
+ }
230
+ ]
231
+ );
232
+
233
+ assert.match(html, /批量进度/);
234
+ assert.match(html, /已完成 2\/3 条任务。/);
235
+ });
236
+ });
src/components/history-panel.tsx CHANGED
@@ -1,8 +1,8 @@
1
  'use client';
2
 
3
- import type { HistoryMetadata } from '@/app/page';
4
  import { Button } from '@/components/ui/button';
5
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6
  import { Checkbox } from '@/components/ui/checkbox';
7
  import {
8
  Dialog,
@@ -14,7 +14,10 @@ import {
14
  DialogFooter,
15
  DialogClose
16
  } from '@/components/ui/dialog';
 
17
  import { getModelRates, type GptImageModel } from '@/lib/cost-utils';
 
 
18
  import { useI18n } from '@/lib/i18n';
19
  import { cn } from '@/lib/utils';
20
  import {
@@ -27,14 +30,25 @@ import {
27
  HardDrive,
28
  Database,
29
  FileImage,
30
- Trash2
 
 
 
 
 
31
  } from 'lucide-react';
32
  import Image from 'next/image';
33
  import * as React from 'react';
34
 
35
  type HistoryPanelProps = {
36
  history: HistoryMetadata[];
 
 
37
  onSelectImage: (item: HistoryMetadata) => void;
 
 
 
 
38
  onClearHistory: () => void;
39
  getImageSrc: (filename: string) => string | undefined;
40
  onDeleteItemRequest: (item: HistoryMetadata) => void;
@@ -45,6 +59,40 @@ type HistoryPanelProps = {
45
  onDeletePreferenceDialogChange: (isChecked: boolean) => void;
46
  };
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  const formatDuration = (ms: number): string => {
49
  if (ms < 1000) {
50
  return `${ms}ms`;
@@ -73,11 +121,18 @@ const calculateCost = (value: number, rate: number): string => {
73
  const formatMoney = (value: number): string => value.toFixed(4);
74
  const formatEstimatedTokenCost = (value: number, rate: number): string => `$${calculateCost(value, rate)}`;
75
 
 
 
 
 
76
  function getCostBadge(
77
  item: HistoryMetadata,
78
  labels: { actual: string; estimated: string; pending: string }
79
  ): { label: string; actual: boolean } | null {
80
- if (item.actualCostDetails?.source === 'new-api-log-token' && typeof item.actualCostDetails.actualAmount === 'number') {
 
 
 
81
  return { label: `${labels.actual} $${formatMoney(item.actualCostDetails.actualAmount)}`, actual: true };
82
  }
83
  if (item.actualCostDetails?.source === 'pending') {
@@ -112,7 +167,39 @@ function getHistoryClientRequestIds(item: HistoryMetadata): string[] {
112
  );
113
  }
114
 
115
- function getCostStatusLabel(item: HistoryMetadata, labels: { actual: string; pending: string; unavailable: string; estimated: string }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  if (item.actualCostDetails?.source === 'new-api-log-token') return labels.actual;
117
  if (item.actualCostDetails?.source === 'pending') return labels.pending;
118
  if (item.actualCostDetails?.source === 'unavailable') return labels.unavailable;
@@ -122,7 +209,13 @@ function getCostStatusLabel(item: HistoryMetadata, labels: { actual: string; pen
122
 
123
  function HistoryPanelImpl({
124
  history,
 
 
125
  onSelectImage,
 
 
 
 
126
  onClearHistory,
127
  getImageSrc,
128
  onDeleteItemRequest,
@@ -133,12 +226,22 @@ function HistoryPanelImpl({
133
  onDeletePreferenceDialogChange
134
  }: HistoryPanelProps) {
135
  const { locale, t } = useI18n();
 
 
 
 
136
  const [openPromptDialogTimestamp, setOpenPromptDialogTimestamp] = React.useState<number | null>(null);
137
  const [openCostDialogTimestamp, setOpenCostDialogTimestamp] = React.useState<number | null>(null);
138
  const [isTotalCostDialogOpen, setIsTotalCostDialogOpen] = React.useState(false);
139
  const [copiedTimestamp, setCopiedTimestamp] = React.useState<number | null>(null);
140
  const [imageResolutions, setImageResolutions] = React.useState<Record<string, string>>({});
141
  const [failedThumbnails, setFailedThumbnails] = React.useState<Record<string, boolean>>({});
 
 
 
 
 
 
142
 
143
  const { totalCost, totalImages } = React.useMemo(() => {
144
  let cost = 0;
@@ -169,18 +272,23 @@ function HistoryPanelImpl({
169
  (timestamp: number) => new Date(timestamp).toLocaleString(locale),
170
  [locale]
171
  );
172
- const handleThumbnailLoad = React.useCallback(
173
- (filename: string, event: React.SyntheticEvent<HTMLImageElement>) => {
174
- const image = event.currentTarget;
175
- if (!image.naturalWidth || !image.naturalHeight) return;
176
- const resolution = `${image.naturalWidth}x${image.naturalHeight}`;
177
- setImageResolutions((current) => {
178
- if (current[filename] === resolution) return current;
179
- return { ...current, [filename]: resolution };
180
- });
181
- },
182
- []
183
  );
 
 
 
 
 
 
 
 
 
184
  const handleThumbnailError = React.useCallback((filename: string) => {
185
  setFailedThumbnails((current) => {
186
  if (current[filename]) return current;
@@ -197,17 +305,16 @@ function HistoryPanelImpl({
197
  );
198
 
199
  return (
200
- <Card className='bg-card text-card-foreground flex h-full w-full flex-col overflow-hidden rounded-lg border border-border'>
201
- <CardHeader className='flex flex-row items-center justify-between gap-4 border-b border-border px-4 py-3'>
202
- <div className='flex items-center gap-2'>
203
- <CardTitle className='text-lg font-medium'>{t('history.title')}</CardTitle>
204
- {totalCost > 0 && (
205
  <Dialog open={isTotalCostDialogOpen} onOpenChange={setIsTotalCostDialogOpen}>
206
- <DialogTrigger asChild>
207
- <button
208
- type='button'
209
- className='mt-0.5 flex cursor-pointer items-center gap-1 rounded-full bg-green-600/80 px-1.5 py-0.5 text-[12px] text-white transition-[background-color,transform] hover:-translate-y-0.5 hover:bg-green-500/90 active:translate-y-0'
210
- aria-label={t('history.showTotalCost')}>
211
  {t('history.totalCost', { cost: totalCost.toFixed(4) })}
212
  </button>
213
  </DialogTrigger>
@@ -288,159 +395,302 @@ function HistoryPanelImpl({
288
  type='button'
289
  variant='secondary'
290
  size='sm'
291
- className='border border-transparent shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground active:scale-[0.98]'>
292
  {t('common.close')}
293
  </Button>
294
  </DialogClose>
295
  </DialogFooter>
296
  </DialogContent>
297
  </Dialog>
298
- )}
299
- </div>
300
- {history.length > 0 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  <Button
302
  variant='ghost'
303
  size='sm'
304
  onClick={onClearHistory}
305
- className='text-muted-foreground h-auto rounded-md px-2 py-1 hover:text-foreground'>
306
  {t('history.clear')}
307
  </Button>
308
  )}
309
  </CardHeader>
310
- <CardContent className='flex-grow overflow-y-auto p-4'>
311
- {history.length === 0 ? (
312
- <div className='text-muted-foreground flex min-h-24 items-center justify-center rounded-md border border-dashed border-border px-4 text-center text-sm'>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  <p>{t('history.empty')}</p>
314
  </div>
315
  ) : (
316
- <div className='grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5'>
317
  {[...history].map((item) => {
318
  const firstImage = item.images?.[0];
319
  const imageCount = item.images?.length ?? 0;
 
 
320
  const isMultiImage = imageCount > 1;
321
  const itemKey = item.timestamp;
322
- const originalStorageMode = item.storageModeUsed || 'fs';
323
- const outputFormat = item.output_format || 'png';
324
- const costBadge = getCostBadge(item, {
325
- actual: t('history.actualCostShort'),
326
- estimated: t('history.estimatedCostShort'),
327
- pending: t('history.actualCostPending')
328
- });
329
- const requestIds = getHistoryClientRequestIds(item);
330
- const filenames = item.images.map((image) => image.filename);
331
- const costStatus = getCostStatusLabel(item, {
332
- actual: t('history.actualCostShort'),
333
- pending: t('history.actualCostPending'),
334
- unavailable: t('history.actualCostUnavailable'),
335
- estimated: t('history.estimatedCostShort')
336
- });
 
 
337
 
338
- let thumbnailUrl: string | undefined;
339
- if (firstImage) {
340
  if (originalStorageMode === 'indexeddb') {
341
  thumbnailUrl = getImageSrc(firstImage.filename);
342
  } else {
343
- thumbnailUrl = `/api/image/${firstImage.filename}`;
344
- }
345
- }
346
- const isThumbnailUnavailable =
347
- !thumbnailUrl || (firstImage ? failedThumbnails[firstImage.filename] : false);
348
 
349
- return (
350
- <div key={itemKey} className='flex flex-col'>
351
- <div className='group relative'>
352
- <button
353
- type='button'
354
- onClick={() => onSelectImage(item)}
355
- className='focus:ring-ring focus:ring-offset-background relative block aspect-square w-full cursor-pointer overflow-hidden rounded-t-md border border-border transition-[border-color,filter,transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:border-foreground/20 hover:brightness-110 active:translate-y-0 focus:outline-none focus:ring-2 focus:ring-offset-2'
 
 
 
 
 
 
 
 
356
  aria-label={t('history.viewBatch', {
357
  time: formatTimestamp(item.timestamp)
358
  })}>
359
- {!isThumbnailUnavailable && thumbnailUrl && firstImage ? (
360
- <Image
361
- src={thumbnailUrl}
362
- alt={t('history.previewBatch', {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  time: formatTimestamp(item.timestamp)
364
  })}
365
  width={150}
366
- height={150}
367
- className='h-full w-full object-cover'
368
- onLoad={(event) => handleThumbnailLoad(firstImage.filename, event)}
369
- onError={() => handleThumbnailError(firstImage.filename)}
370
- unoptimized
371
- />
372
- ) : (
373
- <div className='bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 px-3 text-center text-xs'>
374
- <FileImage size={18} className='text-muted-foreground' />
375
- <span>{t('history.previewUnavailable')}</span>
376
- </div>
377
- )}
378
  <div
379
  className={cn(
380
- 'pointer-events-none absolute top-1 left-1 z-10 flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] text-white',
381
- item.mode === 'edit' ? 'bg-orange-600/80' : 'bg-blue-600/80'
 
 
 
 
382
  )}>
383
- {item.mode === 'edit' ? (
 
 
384
  <Pencil size={12} />
385
  ) : (
386
  <SparklesIcon size={12} />
387
  )}
388
- {item.mode === 'edit' ? t('history.modeEdit') : t('history.modeCreate')}
 
 
 
 
389
  </div>
390
  {isMultiImage && (
391
- <div className='pointer-events-none absolute right-1 bottom-1 z-10 flex items-center gap-1 rounded-full bg-black/70 px-1.5 py-0.5 text-[12px] text-white'>
392
  <Layers size={16} />
393
  {imageCount}
394
  </div>
395
  )}
396
  <div className='pointer-events-none absolute bottom-1 left-1 z-10 flex items-center gap-1'>
397
- <div className='bg-background/85 text-muted-foreground flex items-center gap-1 rounded-full border border-border px-1 py-0.5 text-[11px]'>
398
  {originalStorageMode === 'fs' ? (
399
  <HardDrive size={12} className='text-muted-foreground' />
400
  ) : (
401
- <Database size={12} className='text-blue-400' />
402
  )}
403
- <span>
404
- {originalStorageMode === 'fs'
405
- ? t('history.storageFile')
406
- : t('history.storageDb')}
407
- </span>
408
  </div>
409
  {item.output_format && (
410
- <div className='bg-background/85 text-muted-foreground flex items-center gap-1 rounded-full border border-border px-1 py-0.5 text-[11px]'>
411
  <FileImage size={12} className='text-muted-foreground' />
412
  <span>{outputFormat.toUpperCase()}</span>
413
  </div>
414
  )}
415
  </div>
416
  </button>
417
- {costBadge && (
418
- <Dialog
419
- open={openCostDialogTimestamp === itemKey}
420
- onOpenChange={(isOpen) => !isOpen && setOpenCostDialogTimestamp(null)}>
421
- <DialogTrigger asChild>
422
- <button
423
- type='button'
424
- onClick={(e) => {
425
- e.stopPropagation();
426
- setOpenCostDialogTimestamp(itemKey);
427
- }}
428
- className={cn(
429
- 'absolute top-7 right-1 z-20 flex min-h-6 cursor-pointer items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] text-white shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 active:translate-y-0',
430
- costBadge.actual
431
- ? 'bg-green-600/80 hover:bg-green-500/90'
432
- : 'bg-zinc-700/80 hover:bg-zinc-600/90'
433
- )}
434
- aria-label={`${t('history.showCost')} ${costBadge.label}`}>
435
- <DollarSign size={12} />
436
- {costBadge.label}
437
- </button>
438
- </DialogTrigger>
439
  <DialogContent className='sm:max-w-[450px]'>
440
  <DialogHeader>
441
- <DialogTitle>
442
- {t('history.costBreakdown')}
443
- </DialogTitle>
444
  <DialogDescription className='sr-only'>
445
  {t('history.costBreakdownDescription')}
446
  </DialogDescription>
@@ -477,13 +727,15 @@ function HistoryPanelImpl({
477
  </div>
478
  <div className='text-muted-foreground space-y-2 py-4 text-sm'>
479
  {item.actualCostDetails && (
480
- <div className='space-y-2 rounded-md border border-border bg-card/70 p-3'>
481
  <div className='flex justify-between gap-3'>
482
  <span>{t('history.actualCost')}</span>
483
  <span className='text-foreground font-medium'>
484
  {formatActualCostLabel(
485
  item,
486
- t('history.actualCostUnavailable'),
 
 
487
  t('history.actualCostPending')
488
  )}
489
  </span>
@@ -495,24 +747,35 @@ function HistoryPanelImpl({
495
  </span>
496
  </div>
497
  <div className='flex justify-between gap-3'>
498
- <span>{t('history.costConfidence')}</span>
499
- <span>{item.actualCostDetails.confidence}</span>
 
 
 
 
500
  </div>
501
- {typeof item.actualCostDetails.actualQuota ===
502
- 'number' && (
503
  <div className='flex justify-between gap-3'>
504
- <span>{t('history.actualQuota')}</span>
 
 
505
  <span>
506
  {item.actualCostDetails.actualQuota.toLocaleString()}
507
  </span>
508
  </div>
509
  )}
510
- {typeof item.actualCostDetails.matchedLogId ===
511
- 'number' && (
512
  <div className='flex justify-between gap-3'>
513
- <span>{t('history.matchedLogId')}</span>
514
  <span>
515
- {item.actualCostDetails.matchedLogId}
 
 
 
 
 
 
516
  </span>
517
  </div>
518
  )}
@@ -526,25 +789,32 @@ function HistoryPanelImpl({
526
  {item.costDetails && (
527
  <>
528
  <div className='flex justify-between'>
529
- <span>{t('history.textInputTokens')}</span>{' '}
 
 
530
  <span>
531
  {item.costDetails.text_input_tokens.toLocaleString()}{' '}
532
  (
533
  {formatEstimatedTokenCost(
534
- item.costDetails.text_input_tokens,
 
535
  rates.textInputPerToken
536
  )}
537
  )
538
  </span>
539
  </div>
540
- {item.costDetails.image_input_tokens > 0 && (
 
541
  <div className='flex justify-between'>
542
- <span>{t('history.imageInputTokens')}</span>{' '}
 
 
543
  <span>
544
  {item.costDetails.image_input_tokens.toLocaleString()}{' '}
545
  (
546
  {formatEstimatedTokenCost(
547
- item.costDetails.image_input_tokens,
 
548
  rates.imageInputPerToken
549
  )}
550
  )
@@ -552,12 +822,15 @@ function HistoryPanelImpl({
552
  </div>
553
  )}
554
  <div className='flex justify-between'>
555
- <span>{t('history.imageOutputTokens')}</span>{' '}
 
 
556
  <span>
557
  {item.costDetails.image_output_tokens.toLocaleString()}{' '}
558
  (
559
  {formatEstimatedTokenCost(
560
- item.costDetails.image_output_tokens,
 
561
  rates.imageOutputPerToken
562
  )}
563
  )
@@ -565,8 +838,15 @@ function HistoryPanelImpl({
565
  </div>
566
  <hr className='border-border my-2' />
567
  <div className='text-foreground flex justify-between font-medium'>
568
- <span>{t('history.totalEstimatedCost')}</span>
569
- <span>${item.costDetails.estimated_cost_usd.toFixed(4)}</span>
 
 
 
 
 
 
 
570
  </div>
571
  </>
572
  )}
@@ -580,7 +860,7 @@ function HistoryPanelImpl({
580
  type='button'
581
  variant='secondary'
582
  size='sm'
583
- className='border border-transparent shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground active:scale-[0.98]'>
584
  {t('common.close')}
585
  </Button>
586
  </DialogClose>
@@ -590,17 +870,34 @@ function HistoryPanelImpl({
590
  )}
591
  </div>
592
 
593
- <div className='text-muted-foreground bg-card/70 space-y-1 rounded-b-md border border-t-0 border-border p-2 text-xs'>
594
  <p title={t('history.generatedOn', { time: formatTimestamp(item.timestamp) })}>
595
- <span className='text-foreground font-medium'>{t('history.time')}</span>{' '}
596
- {formatDuration(item.durationMs)}
 
 
 
 
 
 
 
 
 
597
  </p>
 
 
 
 
 
 
598
  <p>
599
  <span className='text-foreground font-medium'>{t('history.model')}</span>{' '}
600
  {item.model || 'gpt-image-1'}
601
  </p>
602
  <p>
603
- <span className='text-foreground font-medium'>{t('history.resolution')}</span>{' '}
 
 
604
  {formatResolution(item, firstImage)}
605
  </p>
606
  <p>
@@ -615,7 +912,130 @@ function HistoryPanelImpl({
615
  <span className='text-foreground font-medium'>{t('history.mod')}</span>{' '}
616
  {item.moderation}
617
  </p>
618
- <div className='mt-2 flex items-center gap-1'>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  <Dialog
620
  open={openPromptDialogTimestamp === itemKey}
621
  onOpenChange={(isOpen) =>
@@ -632,69 +1052,127 @@ function HistoryPanelImpl({
632
  </DialogTrigger>
633
  <DialogContent className='sm:max-w-[625px]'>
634
  <DialogHeader>
635
- <DialogTitle>
636
- {t('history.details')}
637
- </DialogTitle>
638
  <DialogDescription className='sr-only'>
639
  {t('history.detailsDescription')}
640
  </DialogDescription>
641
  </DialogHeader>
642
  <div className='space-y-3'>
643
- <dl className='grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border border-border bg-card/70 p-3 text-sm sm:grid-cols-3'>
644
  <div>
645
- <dt className='text-muted-foreground'>{t('history.generatedAt')}</dt>
646
- <dd className='text-foreground font-medium'>{formatTimestamp(item.timestamp)}</dd>
 
 
 
 
647
  </div>
648
  <div>
649
- <dt className='text-muted-foreground'>{t('history.time')}</dt>
650
- <dd className='text-foreground font-medium'>{formatDuration(item.durationMs)}</dd>
 
 
 
 
651
  </div>
652
  <div>
653
- <dt className='text-muted-foreground'>{t('history.mode')}</dt>
 
 
654
  <dd className='text-foreground font-medium'>
655
- {item.mode === 'edit' ? t('history.modeEdit') : t('history.modeCreate')}
 
 
656
  </dd>
657
  </div>
658
  <div>
659
- <dt className='text-muted-foreground'>{t('history.model')}</dt>
660
- <dd className='text-foreground font-medium'>{item.model || 'gpt-image-1'}</dd>
 
 
 
 
661
  </div>
662
  <div>
663
- <dt className='text-muted-foreground'>{t('history.resolution')}</dt>
664
- <dd className='text-foreground font-medium'>{formatResolution(item, firstImage)}</dd>
 
 
 
 
665
  </div>
666
  <div>
667
- <dt className='text-muted-foreground'>{t('history.quality')}</dt>
668
- <dd className='text-foreground font-medium'>{item.quality}</dd>
 
 
 
 
669
  </div>
670
  <div>
671
- <dt className='text-muted-foreground'>{t('history.bg')}</dt>
672
- <dd className='text-foreground font-medium'>{item.background}</dd>
 
 
 
 
673
  </div>
674
  <div>
675
- <dt className='text-muted-foreground'>{t('history.mod')}</dt>
676
- <dd className='text-foreground font-medium'>{item.moderation}</dd>
 
 
 
 
677
  </div>
678
  <div>
679
- <dt className='text-muted-foreground'>{t('history.imageCount')}</dt>
680
- <dd className='text-foreground font-medium'>{imageCount.toLocaleString(locale)}</dd>
 
 
 
 
681
  </div>
682
  <div>
683
- <dt className='text-muted-foreground'>{t('history.outputFormat')}</dt>
684
- <dd className='text-foreground font-medium'>{outputFormat.toUpperCase()}</dd>
 
 
 
 
685
  </div>
686
  <div>
687
- <dt className='text-muted-foreground'>{t('history.storage')}</dt>
 
 
688
  <dd className='text-foreground font-medium'>
689
- {originalStorageMode === 'fs' ? t('history.storageFile') : t('history.storageDb')}
 
 
690
  </dd>
691
  </div>
692
  <div>
693
- <dt className='text-muted-foreground'>{t('history.costStatus')}</dt>
694
- <dd className='text-foreground font-medium'>{costStatus}</dd>
 
 
 
 
695
  </div>
 
 
 
 
 
 
 
 
 
 
696
  <div>
697
- <dt className='text-muted-foreground'>{t('history.actualCost')}</dt>
 
 
698
  <dd className='text-foreground font-medium'>
699
  {formatActualCostLabel(
700
  item,
@@ -704,29 +1182,52 @@ function HistoryPanelImpl({
704
  </dd>
705
  </div>
706
  <div>
707
- <dt className='text-muted-foreground'>{t('history.totalEstimatedCost')}</dt>
 
 
708
  <dd className='text-foreground font-medium'>
709
- {item.costDetails ? `$${item.costDetails.estimated_cost_usd.toFixed(4)}` : '-'}
 
 
710
  </dd>
711
  </div>
712
  </dl>
713
- <dl className='space-y-2 rounded-md border border-border bg-card/70 p-3 text-sm'>
 
 
 
 
 
 
 
 
 
 
 
714
  <div>
715
- <dt className='text-muted-foreground'>{t('history.filename')}</dt>
716
- <dd className='text-foreground mt-1 break-all font-mono text-xs'>
 
 
717
  {filenames.length > 0 ? filenames.join(', ') : '-'}
718
  </dd>
719
  </div>
720
  <div>
721
- <dt className='text-muted-foreground'>{t('history.requestId')}</dt>
722
- <dd className='text-foreground mt-1 break-all font-mono text-xs'>
723
- {requestIds.length > 0 ? requestIds.join(', ') : '-'}
 
 
 
 
724
  </dd>
725
  </div>
726
  </dl>
727
  <div>
728
- <p className='mb-1 text-sm font-medium'>{t('history.prompt')}</p>
729
- <div className='text-foreground bg-muted max-h-[320px] overflow-y-auto whitespace-pre-wrap break-words rounded-md border border-border p-3 py-4 text-sm'>
 
 
730
  {item.prompt || t('history.noPrompt')}
731
  </div>
732
  </div>
@@ -750,7 +1251,7 @@ function HistoryPanelImpl({
750
  type='button'
751
  variant='secondary'
752
  size='sm'
753
- className='border border-transparent shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground active:scale-[0.98]'>
754
  {t('common.close')}
755
  </Button>
756
  </DialogClose>
@@ -764,7 +1265,7 @@ function HistoryPanelImpl({
764
  }}>
765
  <DialogTrigger asChild>
766
  <Button
767
- className='h-6 w-6 bg-red-700/60 text-white hover:bg-red-600/60'
768
  onClick={(e) => {
769
  e.stopPropagation();
770
  onDeleteItemRequest(item);
@@ -775,9 +1276,7 @@ function HistoryPanelImpl({
775
  </DialogTrigger>
776
  <DialogContent className='sm:max-w-md'>
777
  <DialogHeader>
778
- <DialogTitle>
779
- {t('history.confirmDeletion')}
780
- </DialogTitle>
781
  <DialogDescription className='pt-2'>
782
  {t('history.confirmDeletionDescription', {
783
  count: item.images.length
@@ -800,10 +1299,18 @@ function HistoryPanelImpl({
800
  </label>
801
  </div>
802
  <DialogFooter className='gap-2 sm:justify-end'>
803
- <Button type='button' variant='outline' size='sm' onClick={onCancelDeletion}>
 
 
 
 
804
  {t('common.cancel')}
805
  </Button>
806
- <Button type='button' variant='destructive' size='sm' onClick={onConfirmDeletion}>
 
 
 
 
807
  {t('common.delete')}
808
  </Button>
809
  </DialogFooter>
@@ -817,6 +1324,17 @@ function HistoryPanelImpl({
817
  </div>
818
  )}
819
  </CardContent>
 
 
 
 
 
 
 
 
 
 
 
820
  </Card>
821
  );
822
  }
 
1
  'use client';
2
 
3
+ import { ActivityTimeline } from '@/components/generation-activity-timeline';
4
  import { Button } from '@/components/ui/button';
5
+ import { Card, CardContent, CardHeader } from '@/components/ui/card';
6
  import { Checkbox } from '@/components/ui/checkbox';
7
  import {
8
  Dialog,
 
14
  DialogFooter,
15
  DialogClose
16
  } from '@/components/ui/dialog';
17
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
18
  import { getModelRates, type GptImageModel } from '@/lib/cost-utils';
19
+ import type { GenerationActivityItem } from '@/lib/generation-activity';
20
+ import type { HistoryMetadata } from '@/lib/history-metadata';
21
  import { useI18n } from '@/lib/i18n';
22
  import { cn } from '@/lib/utils';
23
  import {
 
30
  HardDrive,
31
  Database,
32
  FileImage,
33
+ Trash2,
34
+ WandSparkles,
35
+ Bookmark,
36
+ Plus,
37
+ ChevronDown,
38
+ AlertTriangle
39
  } from 'lucide-react';
40
  import Image from 'next/image';
41
  import * as React from 'react';
42
 
43
  type HistoryPanelProps = {
44
  history: HistoryMetadata[];
45
+ inspirations: InspirationItem[];
46
+ activityItems?: GenerationActivityItem[];
47
  onSelectImage: (item: HistoryMetadata) => void;
48
+ onApplyPrompt: (prompt: string, source: PromptApplySource) => void;
49
+ onSaveInspiration: (prompt: string) => void;
50
+ onSendHistoryToEdit: (item: HistoryMetadata) => void | Promise<void>;
51
+ onDeleteInspiration: (id: number) => void;
52
  onClearHistory: () => void;
53
  getImageSrc: (filename: string) => string | undefined;
54
  onDeleteItemRequest: (item: HistoryMetadata) => void;
 
59
  onDeletePreferenceDialogChange: (isChecked: boolean) => void;
60
  };
61
 
62
+ type HistoryPanelTab = 'inspiration' | 'history';
63
+
64
+ export function resolveHistoryPanelTabSync(input: {
65
+ activeTab: HistoryPanelTab;
66
+ historyCount: number;
67
+ inspirationCount: number;
68
+ }): HistoryPanelTab {
69
+ if (input.activeTab === 'inspiration' && input.historyCount > 0 && input.inspirationCount === 0) {
70
+ return 'history';
71
+ }
72
+ if (input.activeTab === 'history' && input.historyCount === 0 && input.inspirationCount > 0) {
73
+ return 'inspiration';
74
+ }
75
+ return input.activeTab;
76
+ }
77
+
78
+ export type InspirationItem = {
79
+ id: number;
80
+ prompt: string;
81
+ createdAt: number;
82
+ };
83
+
84
+ export type { GenerationActivityItem } from '@/lib/generation-activity';
85
+
86
+ export type PromptApplySource =
87
+ | {
88
+ type: 'inspiration';
89
+ title: string;
90
+ }
91
+ | {
92
+ type: 'history';
93
+ item: HistoryMetadata;
94
+ };
95
+
96
  const formatDuration = (ms: number): string => {
97
  if (ms < 1000) {
98
  return `${ms}ms`;
 
121
  const formatMoney = (value: number): string => value.toFixed(4);
122
  const formatEstimatedTokenCost = (value: number, rate: number): string => `$${calculateCost(value, rate)}`;
123
 
124
+ function getStorageLabel(storageMode: HistoryMetadata['storageModeUsed'], t: ReturnType<typeof useI18n>['t']): string {
125
+ return storageMode === 'fs' ? t('history.storageFile') : t('history.storageDb');
126
+ }
127
+
128
  function getCostBadge(
129
  item: HistoryMetadata,
130
  labels: { actual: string; estimated: string; pending: string }
131
  ): { label: string; actual: boolean } | null {
132
+ if (
133
+ item.actualCostDetails?.source === 'new-api-log-token' &&
134
+ typeof item.actualCostDetails.actualAmount === 'number'
135
+ ) {
136
  return { label: `${labels.actual} $${formatMoney(item.actualCostDetails.actualAmount)}`, actual: true };
137
  }
138
  if (item.actualCostDetails?.source === 'pending') {
 
167
  );
168
  }
169
 
170
+ function buildPendingActivityItems(t: (key: string) => string): GenerationActivityItem[] {
171
+ return [
172
+ {
173
+ id: 'pending-request',
174
+ label: t('history.pendingRequest'),
175
+ detail: t('history.pendingRequestDetail'),
176
+ tone: 'neutral'
177
+ },
178
+ {
179
+ id: 'pending-streaming',
180
+ label: t('history.pendingStreaming'),
181
+ detail: t('history.pendingStreamingDetail'),
182
+ tone: 'neutral'
183
+ },
184
+ {
185
+ id: 'pending-saved',
186
+ label: t('history.pendingSaved'),
187
+ detail: t('history.pendingSavedDetail'),
188
+ tone: 'neutral'
189
+ },
190
+ {
191
+ id: 'pending-failed',
192
+ label: t('history.pendingFailed'),
193
+ detail: t('history.pendingFailedDetail'),
194
+ tone: 'neutral'
195
+ }
196
+ ];
197
+ }
198
+
199
+ function getCostStatusLabel(
200
+ item: HistoryMetadata,
201
+ labels: { actual: string; pending: string; unavailable: string; estimated: string }
202
+ ) {
203
  if (item.actualCostDetails?.source === 'new-api-log-token') return labels.actual;
204
  if (item.actualCostDetails?.source === 'pending') return labels.pending;
205
  if (item.actualCostDetails?.source === 'unavailable') return labels.unavailable;
 
209
 
210
  function HistoryPanelImpl({
211
  history,
212
+ inspirations,
213
+ activityItems = [],
214
  onSelectImage,
215
+ onApplyPrompt,
216
+ onSaveInspiration,
217
+ onSendHistoryToEdit,
218
+ onDeleteInspiration,
219
  onClearHistory,
220
  getImageSrc,
221
  onDeleteItemRequest,
 
226
  onDeletePreferenceDialogChange
227
  }: HistoryPanelProps) {
228
  const { locale, t } = useI18n();
229
+ const pendingActivityItems = React.useMemo(() => buildPendingActivityItems(t), [t]);
230
+ const [activeTab, setActiveTab] = React.useState<HistoryPanelTab>(() =>
231
+ history.length > 0 && inspirations.length === 0 ? 'history' : 'inspiration'
232
+ );
233
  const [openPromptDialogTimestamp, setOpenPromptDialogTimestamp] = React.useState<number | null>(null);
234
  const [openCostDialogTimestamp, setOpenCostDialogTimestamp] = React.useState<number | null>(null);
235
  const [isTotalCostDialogOpen, setIsTotalCostDialogOpen] = React.useState(false);
236
  const [copiedTimestamp, setCopiedTimestamp] = React.useState<number | null>(null);
237
  const [imageResolutions, setImageResolutions] = React.useState<Record<string, string>>({});
238
  const [failedThumbnails, setFailedThumbnails] = React.useState<Record<string, boolean>>({});
239
+ const [expandedBatchTimestamp, setExpandedBatchTimestamp] = React.useState<number | null>(null);
240
+ const effectiveActiveTab = resolveHistoryPanelTabSync({
241
+ activeTab,
242
+ historyCount: history.length,
243
+ inspirationCount: inspirations.length
244
+ });
245
 
246
  const { totalCost, totalImages } = React.useMemo(() => {
247
  let cost = 0;
 
272
  (timestamp: number) => new Date(timestamp).toLocaleString(locale),
273
  [locale]
274
  );
275
+ const formatStatusTime = React.useCallback(
276
+ (timestamp: number) =>
277
+ new Date(timestamp).toLocaleTimeString(locale, {
278
+ hour: '2-digit',
279
+ minute: '2-digit'
280
+ }),
281
+ [locale]
 
 
 
 
282
  );
283
+ const handleThumbnailLoad = React.useCallback((filename: string, event: React.SyntheticEvent<HTMLImageElement>) => {
284
+ const image = event.currentTarget;
285
+ if (!image.naturalWidth || !image.naturalHeight) return;
286
+ const resolution = `${image.naturalWidth}x${image.naturalHeight}`;
287
+ setImageResolutions((current) => {
288
+ if (current[filename] === resolution) return current;
289
+ return { ...current, [filename]: resolution };
290
+ });
291
+ }, []);
292
  const handleThumbnailError = React.useCallback((filename: string) => {
293
  setFailedThumbnails((current) => {
294
  if (current[filename]) return current;
 
305
  );
306
 
307
  return (
308
+ <Card className='workbench-panel text-card-foreground border-border flex h-full w-full flex-col gap-0 overflow-hidden rounded-lg border py-0'>
309
+ <CardHeader className='border-border/70 flex flex-col gap-2 border-b px-4 pt-3 !pb-3'>
310
+ {totalCost > 0 ? (
311
+ <div className='flex items-center justify-end gap-3'>
 
312
  <Dialog open={isTotalCostDialogOpen} onOpenChange={setIsTotalCostDialogOpen}>
313
+ <DialogTrigger asChild>
314
+ <button
315
+ type='button'
316
+ className='bg-secondary text-secondary-foreground hover:bg-secondary/80 mt-0.5 flex min-h-6 cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-[12px] transition-[background-color,transform] hover:-translate-y-0.5 active:translate-y-0'
317
+ aria-label={t('history.showTotalCost')}>
318
  {t('history.totalCost', { cost: totalCost.toFixed(4) })}
319
  </button>
320
  </DialogTrigger>
 
395
  type='button'
396
  variant='secondary'
397
  size='sm'
398
+ className='hover:border-border hover:bg-accent hover:text-accent-foreground border border-transparent shadow-sm active:scale-[0.98]'>
399
  {t('common.close')}
400
  </Button>
401
  </DialogClose>
402
  </DialogFooter>
403
  </DialogContent>
404
  </Dialog>
405
+ </div>
406
+ ) : null}
407
+ <Tabs
408
+ value={effectiveActiveTab}
409
+ onValueChange={(value) => setActiveTab(value as 'inspiration' | 'history')}
410
+ className='gap-0'>
411
+ <TabsList className='border-border grid h-auto w-full grid-cols-2 rounded-none border-0 border-b bg-transparent p-0'>
412
+ <TabsTrigger
413
+ value='inspiration'
414
+ className='data-[state=active]:border-primary data-[state=active]:text-primary min-h-11 rounded-none border-0 border-b-2 border-transparent bg-transparent px-1 shadow-none data-[state=active]:bg-transparent data-[state=active]:shadow-none lg:min-h-8'>
415
+ {t('history.inspirationAlbum')}
416
+ </TabsTrigger>
417
+ <TabsTrigger
418
+ value='history'
419
+ className='data-[state=active]:border-primary data-[state=active]:text-primary min-h-11 rounded-none border-0 border-b-2 border-transparent bg-transparent px-1 shadow-none data-[state=active]:bg-transparent data-[state=active]:shadow-none lg:min-h-8'>
420
+ {t('history.recentGenerated')}
421
+ </TabsTrigger>
422
+ </TabsList>
423
+ <TabsContent value='inspiration' forceMount className='hidden' />
424
+ <TabsContent value='history' forceMount className='hidden' />
425
+ </Tabs>
426
+ {history.length > 0 && effectiveActiveTab === 'history' && (
427
  <Button
428
  variant='ghost'
429
  size='sm'
430
  onClick={onClearHistory}
431
+ className='text-muted-foreground hover:text-foreground h-auto rounded-md px-2 py-1'>
432
  {t('history.clear')}
433
  </Button>
434
  )}
435
  </CardHeader>
436
+ <CardContent className='literary-scrollbar min-h-0 p-3 lg:max-h-[calc(100%-17.5rem)] lg:overflow-y-auto lg:p-3 xl:flex-none'>
437
+ {effectiveActiveTab === 'inspiration' ? (
438
+ <div className='space-y-3 lg:space-y-2'>
439
+ {inspirations.length === 0 ? (
440
+ <div className='text-muted-foreground border-border bg-muted/20 flex min-h-32 flex-col items-center justify-center gap-2 rounded-md border border-dashed px-4 text-center text-sm'>
441
+ <WandSparkles className='h-5 w-5 opacity-70' />
442
+ <p>{t('history.inspirationEmpty')}</p>
443
+ </div>
444
+ ) : (
445
+ <div className='-mx-3 flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 pb-1 lg:mx-0 lg:block lg:space-y-2 lg:overflow-visible lg:px-0 lg:pb-0'>
446
+ {inspirations.map((item) => {
447
+ const title = item.prompt.trim() || t('history.inspirationAlbum');
448
+ return (
449
+ <div
450
+ key={item.id}
451
+ className='group border-border/70 bg-card/58 hover:border-primary/30 flex w-[min(84vw,360px)] shrink-0 snap-start items-stretch gap-3 rounded-md border p-2 shadow-sm transition-[border-color,box-shadow] hover:shadow-md lg:w-auto lg:gap-2 lg:p-1.5 2xl:p-1'>
452
+ <button
453
+ type='button'
454
+ onClick={() =>
455
+ onApplyPrompt(item.prompt, { type: 'inspiration', title })
456
+ }
457
+ aria-label={t('history.applyInspirationPrompt', { title })}
458
+ className='border-border bg-muted/30 text-muted-foreground flex min-h-24 w-24 shrink-0 items-center justify-center rounded border shadow-sm lg:min-h-20 lg:w-20 2xl:min-h-18 2xl:w-18'>
459
+ <Bookmark className='h-5 w-5' />
460
+ </button>
461
+ <div className='flex min-w-0 flex-1 flex-col gap-1.5 pr-1 2xl:gap-1'>
462
+ <div className='flex items-start justify-between gap-1.5'>
463
+ <div className='min-w-0'>
464
+ <p
465
+ className='line-clamp-2 text-[15px] leading-5 font-medium 2xl:text-sm'
466
+ title={title}>
467
+ {title}
468
+ </p>
469
+ </div>
470
+ <div className='flex shrink-0 gap-0.5 opacity-65 transition-opacity group-hover:opacity-100'>
471
+ <span
472
+ className='text-primary flex h-11 w-11 items-center justify-center rounded-md lg:h-7 lg:w-7'
473
+ aria-label={t('history.savedInspiration')}
474
+ role='img'>
475
+ <Bookmark className='h-3.5 w-3.5 fill-current' />
476
+ </span>
477
+ <Button
478
+ type='button'
479
+ variant='ghost'
480
+ size='icon'
481
+ className='text-muted-foreground hover:text-destructive h-11 w-11 lg:h-7 lg:w-7'
482
+ onClick={() => onDeleteInspiration(item.id)}
483
+ aria-label={t('history.deleteInspiration')}>
484
+ <Trash2 className='h-3.5 w-3.5' />
485
+ </Button>
486
+ </div>
487
+ </div>
488
+ <div className='flex items-center justify-between gap-2'>
489
+ <span className='text-muted-foreground text-[11px]'>
490
+ {item.createdAt > 0
491
+ ? formatTimestamp(item.createdAt)
492
+ : t('history.template')}
493
+ </span>
494
+ <Button
495
+ type='button'
496
+ size='sm'
497
+ variant='ghost'
498
+ className='min-h-11 px-3 text-xs lg:min-h-7 lg:px-2'
499
+ onClick={() =>
500
+ onApplyPrompt(item.prompt, { type: 'inspiration', title })
501
+ }>
502
+ {t('history.applyInspiration')}
503
+ </Button>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ );
508
+ })}
509
+ </div>
510
+ )}
511
+ <div className='flex items-center justify-between rounded-md px-1 text-sm'>
512
+ <button
513
+ type='button'
514
+ className='text-muted-foreground hover:text-primary focus-visible:ring-ring -ml-2 inline-flex min-h-11 items-center gap-1.5 rounded-md px-2 transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-2 focus-visible:outline-none lg:ml-0 lg:min-h-8 lg:px-0'
515
+ disabled={!inspirations[0]}
516
+ onClick={() =>
517
+ inspirations[0] &&
518
+ onApplyPrompt(inspirations[0].prompt, {
519
+ type: 'inspiration',
520
+ title: t('history.inspirationAlbum')
521
+ })
522
+ }>
523
+ <Plus className='h-4 w-4' />
524
+ {t('history.applyFirstTemplate')}
525
+ </button>
526
+ <span className='text-muted-foreground -mr-2 min-h-11 rounded-md px-2 py-3 lg:mr-0 lg:min-h-8 lg:py-1.5'>
527
+ {t('history.inspirationCount', { count: inspirations.length })}
528
+ </span>
529
+ </div>
530
+ </div>
531
+ ) : history.length === 0 ? (
532
+ <div className='text-muted-foreground border-border flex min-h-24 items-center justify-center rounded-md border border-dashed px-4 text-center text-sm'>
533
  <p>{t('history.empty')}</p>
534
  </div>
535
  ) : (
536
+ <div className='-mx-3 flex snap-x snap-mandatory gap-3 overflow-x-auto px-3 pb-1 lg:mx-0 lg:grid lg:grid-cols-1 lg:overflow-visible lg:px-0 lg:pb-0 2xl:grid-cols-2'>
537
  {[...history].map((item) => {
538
  const firstImage = item.images?.[0];
539
  const imageCount = item.images?.length ?? 0;
540
+ const isFailedItem = item.status === 'failed';
541
+ const failureMessage = item.failureMessage?.trim();
542
  const isMultiImage = imageCount > 1;
543
  const itemKey = item.timestamp;
544
+ const hasPrompt = item.prompt.trim().length > 0;
545
+ const originalStorageMode = item.storageModeUsed || 'fs';
546
+ const outputFormat = item.output_format || 'png';
547
+ const costBadge = getCostBadge(item, {
548
+ actual: t('history.actualCostShort'),
549
+ estimated: t('history.estimatedCostShort'),
550
+ pending: t('history.actualCostPending')
551
+ });
552
+ const requestIds = getHistoryClientRequestIds(item);
553
+ const filenames = item.images.map((image) => image.filename);
554
+ const isBatchExpanded = expandedBatchTimestamp === item.timestamp;
555
+ const costStatus = getCostStatusLabel(item, {
556
+ actual: t('history.actualCostShort'),
557
+ pending: t('history.actualCostPending'),
558
+ unavailable: t('history.actualCostUnavailable'),
559
+ estimated: t('history.estimatedCostShort')
560
+ });
561
 
562
+ let thumbnailUrl: string | undefined;
563
+ if (firstImage) {
564
  if (originalStorageMode === 'indexeddb') {
565
  thumbnailUrl = getImageSrc(firstImage.filename);
566
  } else {
567
+ thumbnailUrl = `/api/image/${firstImage.filename}`;
568
+ }
569
+ }
570
+ const isThumbnailUnavailable =
571
+ !thumbnailUrl || (firstImage ? failedThumbnails[firstImage.filename] : false);
572
 
573
+ return (
574
+ <div
575
+ key={itemKey}
576
+ className='flex w-[min(76vw,280px)] shrink-0 snap-start flex-col lg:w-auto'>
577
+ <div className='group relative'>
578
+ <button
579
+ type='button'
580
+ onClick={() => onSelectImage(item)}
581
+ disabled={isFailedItem}
582
+ className={cn(
583
+ 'focus:ring-ring focus:ring-offset-background border-border relative block aspect-square w-full overflow-hidden rounded-t-md border transition-[border-color,filter,transform,box-shadow] duration-150 focus:ring-2 focus:ring-offset-2 focus:outline-none active:translate-y-0',
584
+ isFailedItem
585
+ ? 'cursor-default'
586
+ : 'hover:border-foreground/20 cursor-pointer hover:-translate-y-0.5 hover:brightness-110'
587
+ )}
588
  aria-label={t('history.viewBatch', {
589
  time: formatTimestamp(item.timestamp)
590
  })}>
591
+ {isFailedItem ? (
592
+ <div className='bg-destructive/5 text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center text-xs'>
593
+ <AlertTriangle
594
+ size={20}
595
+ className='text-destructive'
596
+ aria-hidden='true'
597
+ />
598
+ <span className='text-foreground font-medium'>
599
+ {t('history.failedStatus')}
600
+ </span>
601
+ <span className='line-clamp-2'>
602
+ {failureMessage || t('history.failedReasonUnavailable')}
603
+ </span>
604
+ </div>
605
+ ) : !isThumbnailUnavailable && thumbnailUrl && firstImage ? (
606
+ <Image
607
+ src={thumbnailUrl}
608
+ alt={t('history.previewBatch', {
609
  time: formatTimestamp(item.timestamp)
610
  })}
611
  width={150}
612
+ height={150}
613
+ className='h-full w-full object-cover'
614
+ onLoad={(event) => handleThumbnailLoad(firstImage.filename, event)}
615
+ onError={() => handleThumbnailError(firstImage.filename)}
616
+ unoptimized
617
+ />
618
+ ) : (
619
+ <div className='bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-1 px-3 text-center text-xs'>
620
+ <FileImage size={18} className='text-muted-foreground' />
621
+ <span>{t('history.previewUnavailable')}</span>
622
+ </div>
623
+ )}
624
  <div
625
  className={cn(
626
+ 'pointer-events-none absolute top-1 left-1 z-10 flex items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[11px]',
627
+ isFailedItem
628
+ ? 'border-destructive/35 bg-destructive/90 text-destructive-foreground'
629
+ : item.mode === 'edit'
630
+ ? 'border-primary/30 bg-primary/88 text-primary-foreground'
631
+ : 'border-secondary/40 bg-secondary/90 text-secondary-foreground'
632
  )}>
633
+ {isFailedItem ? (
634
+ <AlertTriangle size={12} />
635
+ ) : item.mode === 'edit' ? (
636
  <Pencil size={12} />
637
  ) : (
638
  <SparklesIcon size={12} />
639
  )}
640
+ {isFailedItem
641
+ ? t('history.failedStatus')
642
+ : item.mode === 'edit'
643
+ ? t('history.modeEdit')
644
+ : t('history.modeCreate')}
645
  </div>
646
  {isMultiImage && (
647
+ <div className='pointer-events-none absolute right-1 bottom-1 z-10 flex items-center gap-1 rounded-sm bg-[oklch(0.28_0.028_58/0.78)] px-1.5 py-0.5 text-[12px] text-white'>
648
  <Layers size={16} />
649
  {imageCount}
650
  </div>
651
  )}
652
  <div className='pointer-events-none absolute bottom-1 left-1 z-10 flex items-center gap-1'>
653
+ <div className='bg-background/85 text-muted-foreground border-border flex items-center gap-1 rounded-sm border px-1 py-0.5 text-[11px]'>
654
  {originalStorageMode === 'fs' ? (
655
  <HardDrive size={12} className='text-muted-foreground' />
656
  ) : (
657
+ <Database size={12} className='text-primary' />
658
  )}
659
+ <span>{getStorageLabel(originalStorageMode, t)}</span>
 
 
 
 
660
  </div>
661
  {item.output_format && (
662
+ <div className='bg-background/85 text-muted-foreground border-border flex items-center gap-1 rounded-sm border px-1 py-0.5 text-[11px]'>
663
  <FileImage size={12} className='text-muted-foreground' />
664
  <span>{outputFormat.toUpperCase()}</span>
665
  </div>
666
  )}
667
  </div>
668
  </button>
669
+ {costBadge && (
670
+ <Dialog
671
+ open={openCostDialogTimestamp === itemKey}
672
+ onOpenChange={(isOpen) => !isOpen && setOpenCostDialogTimestamp(null)}>
673
+ <DialogTrigger asChild>
674
+ <button
675
+ type='button'
676
+ onClick={(e) => {
677
+ e.stopPropagation();
678
+ setOpenCostDialogTimestamp(itemKey);
679
+ }}
680
+ className={cn(
681
+ 'absolute top-7 right-1 z-20 flex min-h-6 cursor-pointer items-center gap-0.5 rounded-sm px-2 py-0.5 text-[11px] text-white shadow-sm transition-[background-color,transform] hover:-translate-y-0.5 active:translate-y-0',
682
+ costBadge.actual
683
+ ? 'bg-secondary text-secondary-foreground hover:bg-secondary/85'
684
+ : 'bg-foreground/80 text-background hover:bg-foreground/70'
685
+ )}
686
+ aria-label={`${t('history.showCost')} ${costBadge.label}`}>
687
+ <DollarSign size={12} />
688
+ {costBadge.label}
689
+ </button>
690
+ </DialogTrigger>
691
  <DialogContent className='sm:max-w-[450px]'>
692
  <DialogHeader>
693
+ <DialogTitle>{t('history.costBreakdown')}</DialogTitle>
 
 
694
  <DialogDescription className='sr-only'>
695
  {t('history.costBreakdownDescription')}
696
  </DialogDescription>
 
727
  </div>
728
  <div className='text-muted-foreground space-y-2 py-4 text-sm'>
729
  {item.actualCostDetails && (
730
+ <div className='border-border bg-card/70 space-y-2 rounded-md border p-3'>
731
  <div className='flex justify-between gap-3'>
732
  <span>{t('history.actualCost')}</span>
733
  <span className='text-foreground font-medium'>
734
  {formatActualCostLabel(
735
  item,
736
+ t(
737
+ 'history.actualCostUnavailable'
738
+ ),
739
  t('history.actualCostPending')
740
  )}
741
  </span>
 
747
  </span>
748
  </div>
749
  <div className='flex justify-between gap-3'>
750
+ <span>
751
+ {t('history.costConfidence')}
752
+ </span>
753
+ <span>
754
+ {item.actualCostDetails.confidence}
755
+ </span>
756
  </div>
757
+ {typeof item.actualCostDetails
758
+ .actualQuota === 'number' && (
759
  <div className='flex justify-between gap-3'>
760
+ <span>
761
+ {t('history.actualQuota')}
762
+ </span>
763
  <span>
764
  {item.actualCostDetails.actualQuota.toLocaleString()}
765
  </span>
766
  </div>
767
  )}
768
+ {typeof item.actualCostDetails
769
+ .matchedLogId === 'number' && (
770
  <div className='flex justify-between gap-3'>
 
771
  <span>
772
+ {t('history.matchedLogId')}
773
+ </span>
774
+ <span>
775
+ {
776
+ item.actualCostDetails
777
+ .matchedLogId
778
+ }
779
  </span>
780
  </div>
781
  )}
 
789
  {item.costDetails && (
790
  <>
791
  <div className='flex justify-between'>
792
+ <span>
793
+ {t('history.textInputTokens')}
794
+ </span>{' '}
795
  <span>
796
  {item.costDetails.text_input_tokens.toLocaleString()}{' '}
797
  (
798
  {formatEstimatedTokenCost(
799
+ item.costDetails
800
+ .text_input_tokens,
801
  rates.textInputPerToken
802
  )}
803
  )
804
  </span>
805
  </div>
806
+ {item.costDetails.image_input_tokens >
807
+ 0 && (
808
  <div className='flex justify-between'>
809
+ <span>
810
+ {t('history.imageInputTokens')}
811
+ </span>{' '}
812
  <span>
813
  {item.costDetails.image_input_tokens.toLocaleString()}{' '}
814
  (
815
  {formatEstimatedTokenCost(
816
+ item.costDetails
817
+ .image_input_tokens,
818
  rates.imageInputPerToken
819
  )}
820
  )
 
822
  </div>
823
  )}
824
  <div className='flex justify-between'>
825
+ <span>
826
+ {t('history.imageOutputTokens')}
827
+ </span>{' '}
828
  <span>
829
  {item.costDetails.image_output_tokens.toLocaleString()}{' '}
830
  (
831
  {formatEstimatedTokenCost(
832
+ item.costDetails
833
+ .image_output_tokens,
834
  rates.imageOutputPerToken
835
  )}
836
  )
 
838
  </div>
839
  <hr className='border-border my-2' />
840
  <div className='text-foreground flex justify-between font-medium'>
841
+ <span>
842
+ {t('history.totalEstimatedCost')}
843
+ </span>
844
+ <span>
845
+ $
846
+ {item.costDetails.estimated_cost_usd.toFixed(
847
+ 4
848
+ )}
849
+ </span>
850
  </div>
851
  </>
852
  )}
 
860
  type='button'
861
  variant='secondary'
862
  size='sm'
863
+ className='hover:border-border hover:bg-accent hover:text-accent-foreground border border-transparent shadow-sm active:scale-[0.98]'>
864
  {t('common.close')}
865
  </Button>
866
  </DialogClose>
 
870
  )}
871
  </div>
872
 
873
+ <div className='text-muted-foreground bg-card/70 border-border space-y-1 rounded-b-md border border-t-0 p-2 text-xs'>
874
  <p title={t('history.generatedOn', { time: formatTimestamp(item.timestamp) })}>
875
+ <span className='text-foreground font-medium'>
876
+ {t('history.generatedAt')}
877
+ </span>{' '}
878
+ {formatStatusTime(item.timestamp)}
879
+ <span className='text-muted-foreground/70 px-1'>/</span>
880
+ {isFailedItem
881
+ ? `${t('history.failedStatus')},${formatDuration(item.durationMs)}`
882
+ : t('history.statusBatchSummary', {
883
+ count: imageCount,
884
+ duration: formatDuration(item.durationMs)
885
+ })}
886
  </p>
887
+ {isFailedItem && (
888
+ <p className='border-destructive/25 bg-destructive/5 text-destructive rounded-sm border px-2 py-1 leading-4'>
889
+ <span className='font-medium'>{t('history.failureReason')}</span>{' '}
890
+ {failureMessage || t('history.failedReasonUnavailable')}
891
+ </p>
892
+ )}
893
  <p>
894
  <span className='text-foreground font-medium'>{t('history.model')}</span>{' '}
895
  {item.model || 'gpt-image-1'}
896
  </p>
897
  <p>
898
+ <span className='text-foreground font-medium'>
899
+ {t('history.resolution')}
900
+ </span>{' '}
901
  {formatResolution(item, firstImage)}
902
  </p>
903
  <p>
 
912
  <span className='text-foreground font-medium'>{t('history.mod')}</span>{' '}
913
  {item.moderation}
914
  </p>
915
+ <p
916
+ className='mt-2 max-h-8 min-h-8 overflow-hidden leading-4 break-words'
917
+ title={item.prompt || t('history.noPrompt')}>
918
+ {item.prompt || t('history.noPrompt')}
919
+ </p>
920
+ <div className='mt-2 grid grid-cols-3 gap-1'>
921
+ <Button
922
+ type='button'
923
+ variant='outline'
924
+ size='sm'
925
+ className='min-h-11 min-w-0 px-2 text-[11px] lg:h-7 lg:min-h-0 lg:px-1'
926
+ disabled={!hasPrompt}
927
+ onClick={() => onSaveInspiration(item.prompt)}
928
+ aria-label={t('history.saveHistoryPrompt')}>
929
+ <Bookmark className='h-3.5 w-3.5' />
930
+ <span className='truncate'>{t('history.saveToInspiration')}</span>
931
+ </Button>
932
+ <Button
933
+ type='button'
934
+ variant='outline'
935
+ size='sm'
936
+ className='min-h-11 min-w-0 px-2 text-[11px] lg:h-7 lg:min-h-0 lg:px-1'
937
+ disabled={!hasPrompt}
938
+ onClick={() => onApplyPrompt(item.prompt, { type: 'history', item })}
939
+ aria-label={t('history.reuseHistoryPrompt')}>
940
+ <WandSparkles className='h-3.5 w-3.5' />
941
+ <span className='truncate'>{t('history.reuseHistory')}</span>
942
+ </Button>
943
+ <Button
944
+ type='button'
945
+ variant='outline'
946
+ size='sm'
947
+ className='min-h-11 min-w-0 px-2 text-[11px] lg:h-7 lg:min-h-0 lg:px-1'
948
+ disabled={!firstImage || isFailedItem}
949
+ onClick={() => onSendHistoryToEdit(item)}
950
+ aria-label={t('history.continueHistoryEdit')}>
951
+ <Pencil className='h-3.5 w-3.5' />
952
+ <span className='truncate'>{t('history.continueEdit')}</span>
953
+ </Button>
954
+ </div>
955
+ {isMultiImage && (
956
+ <div className='mt-2 space-y-1.5'>
957
+ <Button
958
+ type='button'
959
+ variant='ghost'
960
+ size='sm'
961
+ className='text-muted-foreground hover:text-foreground min-h-11 w-full justify-between px-2 text-[11px] lg:h-7 lg:min-h-0'
962
+ aria-expanded={isBatchExpanded}
963
+ aria-controls={`history-batch-${item.timestamp}`}
964
+ onClick={() =>
965
+ setExpandedBatchTimestamp((current) =>
966
+ current === item.timestamp ? null : item.timestamp
967
+ )
968
+ }>
969
+ <span>
970
+ {isBatchExpanded
971
+ ? t('history.collapseBatch')
972
+ : t('history.expandBatch')}
973
+ </span>
974
+ <span className='inline-flex items-center gap-1'>
975
+ {t('history.batchImageCount', { count: imageCount })}
976
+ <ChevronDown
977
+ className={cn(
978
+ 'h-3.5 w-3.5 transition-transform',
979
+ isBatchExpanded && 'rotate-180'
980
+ )}
981
+ />
982
+ </span>
983
+ </Button>
984
+ {isBatchExpanded && (
985
+ <div
986
+ id={`history-batch-${item.timestamp}`}
987
+ className='batch-thumbnail-strip grid grid-cols-3 gap-1.5'
988
+ aria-label={t('history.batchThumbnails')}>
989
+ {item.images.map((image, index) => {
990
+ const imageSrc =
991
+ originalStorageMode === 'indexeddb'
992
+ ? getImageSrc(image.filename)
993
+ : `/api/image/${image.filename}`;
994
+ const unavailable =
995
+ !imageSrc || failedThumbnails[image.filename];
996
+
997
+ return (
998
+ <button
999
+ key={`${item.timestamp}-${image.filename}`}
1000
+ type='button'
1001
+ onClick={() => onSelectImage(item)}
1002
+ className='border-border bg-muted relative aspect-square overflow-hidden rounded-sm border'
1003
+ aria-label={t('history.batchThumbnail', {
1004
+ index: index + 1
1005
+ })}>
1006
+ {!unavailable ? (
1007
+ <Image
1008
+ src={imageSrc}
1009
+ alt={t('history.batchThumbnail', {
1010
+ index: index + 1
1011
+ })}
1012
+ fill
1013
+ sizes='88px'
1014
+ className='object-cover'
1015
+ onLoad={(event) =>
1016
+ handleThumbnailLoad(
1017
+ image.filename,
1018
+ event
1019
+ )
1020
+ }
1021
+ onError={() =>
1022
+ handleThumbnailError(image.filename)
1023
+ }
1024
+ unoptimized
1025
+ />
1026
+ ) : (
1027
+ <span className='text-muted-foreground flex h-full items-center justify-center px-1 text-center text-[10px] leading-3'>
1028
+ {t('history.previewUnavailable')}
1029
+ </span>
1030
+ )}
1031
+ </button>
1032
+ );
1033
+ })}
1034
+ </div>
1035
+ )}
1036
+ </div>
1037
+ )}
1038
+ <div className='mt-1 flex items-center gap-1'>
1039
  <Dialog
1040
  open={openPromptDialogTimestamp === itemKey}
1041
  onOpenChange={(isOpen) =>
 
1052
  </DialogTrigger>
1053
  <DialogContent className='sm:max-w-[625px]'>
1054
  <DialogHeader>
1055
+ <DialogTitle>{t('history.details')}</DialogTitle>
 
 
1056
  <DialogDescription className='sr-only'>
1057
  {t('history.detailsDescription')}
1058
  </DialogDescription>
1059
  </DialogHeader>
1060
  <div className='space-y-3'>
1061
+ <dl className='border-border bg-card/70 grid grid-cols-2 gap-x-4 gap-y-3 rounded-md border p-3 text-sm sm:grid-cols-3'>
1062
  <div>
1063
+ <dt className='text-muted-foreground'>
1064
+ {t('history.generatedAt')}
1065
+ </dt>
1066
+ <dd className='text-foreground font-medium'>
1067
+ {formatTimestamp(item.timestamp)}
1068
+ </dd>
1069
  </div>
1070
  <div>
1071
+ <dt className='text-muted-foreground'>
1072
+ {t('history.time')}
1073
+ </dt>
1074
+ <dd className='text-foreground font-medium'>
1075
+ {formatDuration(item.durationMs)}
1076
+ </dd>
1077
  </div>
1078
  <div>
1079
+ <dt className='text-muted-foreground'>
1080
+ {t('history.mode')}
1081
+ </dt>
1082
  <dd className='text-foreground font-medium'>
1083
+ {item.mode === 'edit'
1084
+ ? t('history.modeEdit')
1085
+ : t('history.modeCreate')}
1086
  </dd>
1087
  </div>
1088
  <div>
1089
+ <dt className='text-muted-foreground'>
1090
+ {t('history.model')}
1091
+ </dt>
1092
+ <dd className='text-foreground font-medium'>
1093
+ {item.model || 'gpt-image-1'}
1094
+ </dd>
1095
  </div>
1096
  <div>
1097
+ <dt className='text-muted-foreground'>
1098
+ {t('history.resolution')}
1099
+ </dt>
1100
+ <dd className='text-foreground font-medium'>
1101
+ {formatResolution(item, firstImage)}
1102
+ </dd>
1103
  </div>
1104
  <div>
1105
+ <dt className='text-muted-foreground'>
1106
+ {t('history.quality')}
1107
+ </dt>
1108
+ <dd className='text-foreground font-medium'>
1109
+ {item.quality}
1110
+ </dd>
1111
  </div>
1112
  <div>
1113
+ <dt className='text-muted-foreground'>
1114
+ {t('history.bg')}
1115
+ </dt>
1116
+ <dd className='text-foreground font-medium'>
1117
+ {item.background}
1118
+ </dd>
1119
  </div>
1120
  <div>
1121
+ <dt className='text-muted-foreground'>
1122
+ {t('history.mod')}
1123
+ </dt>
1124
+ <dd className='text-foreground font-medium'>
1125
+ {item.moderation}
1126
+ </dd>
1127
  </div>
1128
  <div>
1129
+ <dt className='text-muted-foreground'>
1130
+ {t('history.imageCount')}
1131
+ </dt>
1132
+ <dd className='text-foreground font-medium'>
1133
+ {imageCount.toLocaleString(locale)}
1134
+ </dd>
1135
  </div>
1136
  <div>
1137
+ <dt className='text-muted-foreground'>
1138
+ {t('history.outputFormat')}
1139
+ </dt>
1140
+ <dd className='text-foreground font-medium'>
1141
+ {outputFormat.toUpperCase()}
1142
+ </dd>
1143
  </div>
1144
  <div>
1145
+ <dt className='text-muted-foreground'>
1146
+ {t('history.storage')}
1147
+ </dt>
1148
  <dd className='text-foreground font-medium'>
1149
+ {originalStorageMode === 'fs'
1150
+ ? t('history.storageFile')
1151
+ : t('history.storageDb')}
1152
  </dd>
1153
  </div>
1154
  <div>
1155
+ <dt className='text-muted-foreground'>
1156
+ {t('history.costStatus')}
1157
+ </dt>
1158
+ <dd className='text-foreground font-medium'>
1159
+ {costStatus}
1160
+ </dd>
1161
  </div>
1162
+ {isFailedItem && (
1163
+ <div>
1164
+ <dt className='text-muted-foreground'>
1165
+ {t('history.status')}
1166
+ </dt>
1167
+ <dd className='text-destructive font-medium'>
1168
+ {t('history.failedStatus')}
1169
+ </dd>
1170
+ </div>
1171
+ )}
1172
  <div>
1173
+ <dt className='text-muted-foreground'>
1174
+ {t('history.actualCost')}
1175
+ </dt>
1176
  <dd className='text-foreground font-medium'>
1177
  {formatActualCostLabel(
1178
  item,
 
1182
  </dd>
1183
  </div>
1184
  <div>
1185
+ <dt className='text-muted-foreground'>
1186
+ {t('history.totalEstimatedCost')}
1187
+ </dt>
1188
  <dd className='text-foreground font-medium'>
1189
+ {item.costDetails
1190
+ ? `$${item.costDetails.estimated_cost_usd.toFixed(4)}`
1191
+ : '-'}
1192
  </dd>
1193
  </div>
1194
  </dl>
1195
+ {isFailedItem && (
1196
+ <div>
1197
+ <p className='mb-1 text-sm font-medium'>
1198
+ {t('history.failureReason')}
1199
+ </p>
1200
+ <div className='border-destructive/25 bg-destructive/5 text-destructive max-h-[180px] overflow-y-auto rounded-md border p-3 text-sm break-words whitespace-pre-wrap'>
1201
+ {failureMessage ||
1202
+ t('history.failedReasonUnavailable')}
1203
+ </div>
1204
+ </div>
1205
+ )}
1206
+ <dl className='border-border bg-card/70 space-y-2 rounded-md border p-3 text-sm'>
1207
  <div>
1208
+ <dt className='text-muted-foreground'>
1209
+ {t('history.filename')}
1210
+ </dt>
1211
+ <dd className='text-foreground mt-1 font-mono text-xs break-all'>
1212
  {filenames.length > 0 ? filenames.join(', ') : '-'}
1213
  </dd>
1214
  </div>
1215
  <div>
1216
+ <dt className='text-muted-foreground'>
1217
+ {t('history.requestId')}
1218
+ </dt>
1219
+ <dd className='text-foreground mt-1 font-mono text-xs break-all'>
1220
+ {requestIds.length > 0
1221
+ ? requestIds.join(', ')
1222
+ : '-'}
1223
  </dd>
1224
  </div>
1225
  </dl>
1226
  <div>
1227
+ <p className='mb-1 text-sm font-medium'>
1228
+ {t('history.prompt')}
1229
+ </p>
1230
+ <div className='text-foreground bg-muted border-border max-h-[320px] overflow-y-auto rounded-md border p-3 py-4 text-sm break-words whitespace-pre-wrap'>
1231
  {item.prompt || t('history.noPrompt')}
1232
  </div>
1233
  </div>
 
1251
  type='button'
1252
  variant='secondary'
1253
  size='sm'
1254
+ className='hover:border-border hover:bg-accent hover:text-accent-foreground border border-transparent shadow-sm active:scale-[0.98]'>
1255
  {t('common.close')}
1256
  </Button>
1257
  </DialogClose>
 
1265
  }}>
1266
  <DialogTrigger asChild>
1267
  <Button
1268
+ className='bg-destructive hover:bg-destructive/90 h-6 w-6 text-white'
1269
  onClick={(e) => {
1270
  e.stopPropagation();
1271
  onDeleteItemRequest(item);
 
1276
  </DialogTrigger>
1277
  <DialogContent className='sm:max-w-md'>
1278
  <DialogHeader>
1279
+ <DialogTitle>{t('history.confirmDeletion')}</DialogTitle>
 
 
1280
  <DialogDescription className='pt-2'>
1281
  {t('history.confirmDeletionDescription', {
1282
  count: item.images.length
 
1299
  </label>
1300
  </div>
1301
  <DialogFooter className='gap-2 sm:justify-end'>
1302
+ <Button
1303
+ type='button'
1304
+ variant='outline'
1305
+ size='sm'
1306
+ onClick={onCancelDeletion}>
1307
  {t('common.cancel')}
1308
  </Button>
1309
+ <Button
1310
+ type='button'
1311
+ variant='destructive'
1312
+ size='sm'
1313
+ onClick={onConfirmDeletion}>
1314
  {t('common.delete')}
1315
  </Button>
1316
  </DialogFooter>
 
1324
  </div>
1325
  )}
1326
  </CardContent>
1327
+ <ActivityTimeline
1328
+ activityItems={activityItems}
1329
+ history={history}
1330
+ pendingActivityItems={pendingActivityItems}
1331
+ onSelectImage={onSelectImage}
1332
+ onClearHistory={onClearHistory}
1333
+ getImageSrc={getImageSrc}
1334
+ formatStatusTime={formatStatusTime}
1335
+ formatDuration={formatDuration}
1336
+ t={t}
1337
+ />
1338
  </Card>
1339
  );
1340
  }