Spaces:
Running
Running
Deploy 76de60b to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@76de60b42bdc80653167d4bceed12f29c472ac6d
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.agent.example +2 -1
- .env.example +21 -4
- .env.real-smoke.example +2 -1
- .github/workflows/hf-space-keepalive.yml +2 -0
- CHANGELOG.md +22 -3
- README.md +82 -22
- docs/deployment/huggingface-space-free.md +8 -0
- docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +1 -1
- docs/ui/literary-young-women-workbench-design.md +550 -0
- package-lock.json +3 -2
- package.json +3 -2
- scripts/agent-doctor.mjs +298 -11
- scripts/agent-skill-scripts.test.mjs +0 -0
- scripts/command-center.test.mjs +146 -0
- scripts/keepalive-hf-space.mjs +67 -17
- scripts/keepalive-hf-space.test.mjs +86 -3
- scripts/local-image-upstream-fixture.mjs +21 -3
- scripts/local-image-upstream-fixture.test.mjs +13 -4
- scripts/smoke-image-upstream-local-final-gate.mjs +32 -7
- scripts/smoke-image-upstream-local-final-gate.test.mjs +22 -3
- skills/gpt-image-playground-agent/SKILL.md +73 -47
- skills/gpt-image-playground-agent/agents/openai.yaml +2 -2
- skills/gpt-image-playground-agent/references/api.md +84 -15
- skills/gpt-image-playground-agent/scripts/batch-images.mjs +1226 -0
- skills/gpt-image-playground-agent/scripts/edit-image.mjs +309 -28
- skills/gpt-image-playground-agent/scripts/generate-image.mjs +833 -719
- skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs +30 -0
- skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs +407 -0
- skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +73 -8
- skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs +2 -0
- src/app/api/agent/agent-routes.test.ts +191 -18
- src/app/api/agent/images/edit/route.ts +0 -2
- src/app/api/images/route-test-helpers.ts +182 -4
- src/app/api/images/route.test.ts +497 -42
- src/app/api/images/route.ts +177 -16
- src/app/api/runtime-capabilities/route.test.ts +130 -5
- src/app/api/runtime-capabilities/route.ts +32 -3
- src/app/globals.css +165 -54
- src/app/layout.tsx +2 -2
- src/app/page-regressions.test.ts +14 -0
- src/app/page.tsx +0 -0
- src/components/api-settings-dialog.tsx +70 -47
- src/components/app-controls.tsx +0 -63
- src/components/editing-form.test.tsx +333 -0
- src/components/editing-form.tsx +0 -0
- src/components/generation-activity-timeline.tsx +265 -0
- src/components/generation-form.test.tsx +406 -0
- src/components/generation-form.tsx +0 -0
- src/components/history-panel.test.tsx +236 -0
- 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.
|
| 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 |
-
- 增加
|
| 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.
|
|
|
|
| 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 |
-

|
| 10 |

|
| 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 |
-
- 页面默认
|
|
|
|
| 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 |
-
- 流式
|
| 150 |
- 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。
|
| 151 |
-
- gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容
|
| 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
|
|
|
|
| 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` |
|
|
|
|
| 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 |
-
| `
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
并发流式批处理默认
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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` |
|
| 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 |
+

|
| 9 |

|
| 10 |

|
| 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 |
-
- 高分辨率
|
| 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.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
"name": "gpt-image-playground",
|
| 9 |
-
"version": "1.
|
|
|
|
| 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.
|
|
|
|
| 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
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
| 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 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 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 |
-
|
| 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 (
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 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:
|
| 123 |
}
|
| 124 |
});
|
| 125 |
writeSse(response, 'response.completed', {
|
| 126 |
type: 'response.completed',
|
| 127 |
response: {
|
| 128 |
-
output: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 109 |
-
assert.equal(events[2].data.response.output[0].result,
|
| 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 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{
|
| 73 |
cwd: REPO_ROOT,
|
| 74 |
env: buildLocalFinalGateEnv(baseUrl, timeoutMs),
|
| 75 |
-
timeoutMs:
|
| 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)
|
|
|
|
| 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(
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: 当用户需要通过
|
| 4 |
---
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
| 7 |
|
| 8 |
-
通过用户已部署的 GPT Image Playground
|
| 9 |
|
| 10 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
- 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
|
| 13 |
-
- `edit` 且 `max(width,height)>2048` 时,
|
| 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. 先
|
| 22 |
-
2.
|
| 23 |
-
3.
|
| 24 |
-
4.
|
| 25 |
-
5.
|
| 26 |
-
6. 图
|
| 27 |
-
7.
|
| 28 |
-
8.
|
| 29 |
-
9.
|
| 30 |
-
10.
|
| 31 |
-
11.
|
|
|
|
| 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 |
-
|
| 73 |
-
|
| 74 |
-
- `
|
|
|
|
|
|
|
|
|
|
| 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`:执行
|
| 86 |
|
| 87 |
生成脚本常用参数:
|
| 88 |
|
| 89 |
-
```
|
| 90 |
-
node
|
| 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 |
-
```
|
| 101 |
-
node
|
| 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 |
-
```
|
| 114 |
-
node
|
| 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` 强制
|
| 122 |
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
直连上游诊断:
|
| 126 |
|
| 127 |
-
```
|
| 128 |
-
|
| 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.mjs、edit-image.mjs、batch-images.mjs 或 probe-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: "
|
| 4 |
-
default_prompt: "使用 $gpt-image-playground-agent 先
|
|
|
|
| 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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` 时
|
| 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.
|
| 102 |
-
- `defaults.
|
|
|
|
| 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 批量生产
|
| 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 |
-
"
|
|
|
|
| 184 |
"partial_images": 2
|
| 185 |
}
|
| 186 |
```
|
| 187 |
|
| 188 |
-
Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
- `
|
|
|
|
|
|
|
|
|
|
| 233 |
- `mask`:可选 PNG 遮罩。
|
| 234 |
|
| 235 |
-
|
|
|
|
|
|
|
| 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}
|
| 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.
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
return {
|
| 149 |
recommended_endpoint: '/api/images',
|
| 150 |
transport: 'page_sse',
|
| 151 |
-
strength: '
|
| 152 |
-
reason: '
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 3 |
-
import fs from 'node:fs';
|
| 4 |
-
import { AGENT_ENDPOINTS, buildAgentJobResultPath } from '../../../src/lib/agent-api-paths.mjs';
|
| 5 |
import {
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
]);
|
|
|
|
| 26 |
const MIN_PARTIAL_IMAGES = 1;
|
| 27 |
const MAX_PARTIAL_IMAGES = 3;
|
| 28 |
-
const
|
| 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 |
-
|
| 36 |
} catch (error) {
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
}
|
| 41 |
if (options.help) {
|
| 42 |
-
|
| 43 |
-
|
| 44 |
}
|
| 45 |
|
| 46 |
let prompt;
|
|
@@ -49,908 +54,1017 @@ let timeoutMs;
|
|
| 49 |
let idempotencyKey;
|
| 50 |
let requestBody;
|
| 51 |
try {
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
} catch (error) {
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
}
|
| 70 |
|
| 71 |
if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
|
| 72 |
-
|
| 73 |
-
|
| 74 |
}
|
| 75 |
|
| 76 |
let baseUrl;
|
| 77 |
try {
|
| 78 |
-
|
| 79 |
} catch (error) {
|
| 80 |
-
|
| 81 |
-
|
| 82 |
}
|
| 83 |
|
| 84 |
if (isNonBillableDryRun(options, contractCheck)) {
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 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 |
-
|
| 118 |
-
|
| 119 |
}
|
| 120 |
|
| 121 |
try {
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
-
} else {
|
| 140 |
-
await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } });
|
| 141 |
-
}
|
| 142 |
} catch (error) {
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
| 145 |
process.exit(1);
|
| 146 |
-
}
|
| 147 |
-
console.error(errorMessage(error));
|
| 148 |
-
process.exit(1);
|
| 149 |
}
|
| 150 |
|
| 151 |
function parseArgs(argv) {
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
| 197 |
|
| 198 |
function readPrompt(parsed, { readPromptFile }) {
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
}
|
| 203 |
-
return parsed.promptParts.join(' ')
|
| 204 |
-
}
|
| 205 |
-
return parsed.promptParts.join(' ');
|
| 206 |
}
|
| 207 |
|
| 208 |
function buildRequestBody(promptValue, parsed) {
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
}
|
| 222 |
|
| 223 |
function buildDryRunRequestBody(parsed) {
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
}
|
| 240 |
|
| 241 |
function addUpstreamStrategyFields(body, parsed) {
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
function validateUpstreamStrategyOptions(parsed) {
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
| 265 |
|
| 266 |
function readPartialImages(value) {
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
}
|
| 273 |
|
| 274 |
function hasPromptSource(parsed) {
|
| 275 |
-
|
| 276 |
}
|
| 277 |
|
| 278 |
function isNonBillableDryRun(parsed, isContractCheck) {
|
| 279 |
-
|
| 280 |
}
|
| 281 |
|
| 282 |
function authHeaders() {
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
}
|
| 287 |
|
| 288 |
function absoluteUrl(value) {
|
| 289 |
-
|
| 290 |
-
|
| 291 |
}
|
| 292 |
|
| 293 |
function enrichImageUrls(result) {
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
}
|
| 304 |
|
| 305 |
function dryRunEndpoint(body, routeMode) {
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
}
|
| 313 |
|
| 314 |
function buildGenerateRoutingGuidance(body, routeMode) {
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
return {
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 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 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
}
|
| 354 |
|
| 355 |
-
async function
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
body: JSON.stringify(requestBody),
|
| 368 |
-
timeoutMs
|
| 369 |
-
});
|
| 370 |
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
|
|
|
| 374 |
}
|
|
|
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
lastRetryAfter =
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
|
| 383 |
-
|
| 384 |
-
|
| 385 |
}
|
| 386 |
|
| 387 |
async function runPageSseRequest() {
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
try {
|
| 393 |
-
let response;
|
| 394 |
try {
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
}
|
| 419 |
-
} finally {
|
| 420 |
-
clearTimeout(timeout);
|
| 421 |
-
}
|
| 422 |
}
|
| 423 |
|
| 424 |
function assertPageSseReady(capabilitiesValue) {
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
}
|
| 439 |
|
| 440 |
function buildPageSseFormData() {
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
formData.append('
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
| 465 |
}
|
| 466 |
|
| 467 |
function assertPageSseClientRequestIdLength(clientRequestId) {
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
}
|
| 475 |
|
| 476 |
function formatPageSseOutput(result) {
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
}
|
| 483 |
|
| 484 |
function formatPageSseImage(image) {
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
| 490 |
}
|
| 491 |
-
|
| 492 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
}
|
| 494 |
|
| 495 |
function normalizeImageBackendForPage(value) {
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
}
|
| 500 |
|
| 501 |
function readPageSseClientRequestId(event) {
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
}
|
| 506 |
|
| 507 |
function normalizePageSseImage(image, fallbackClientRequestId) {
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
}
|
| 515 |
|
| 516 |
function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
|
|
|
|
|
|
|
|
|
| 524 |
}
|
| 525 |
|
| 526 |
async function collectPageSseResult(response, signal) {
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
}
|
| 552 |
|
| 553 |
function readPageSseChunk(reader, signal) {
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
});
|
| 564 |
-
});
|
| 565 |
}
|
| 566 |
|
| 567 |
function applyPageSseEvent(state, rawEvent) {
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
}
|
| 595 |
|
| 596 |
function formatPageSseError(value) {
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
|
|
|
| 605 |
}
|
| 606 |
-
|
| 607 |
-
return '页面 SSE 返回错误事件。';
|
| 608 |
}
|
| 609 |
|
| 610 |
-
function createPageSseStreamError(event) {
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
}
|
| 618 |
|
| 619 |
function readPageSseStreamStatus(event) {
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
}
|
| 626 |
|
| 627 |
function parsePageSseEvent(rawEvent) {
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
}
|
| 641 |
|
| 642 |
function buildSuccessOutput(result, routing) {
|
| 643 |
-
|
| 644 |
}
|
| 645 |
|
| 646 |
function buildPageSseFailureOutput(error) {
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
|
|
|
| 654 |
}
|
| 655 |
|
| 656 |
function buildPageSseRouting(fallbackMode) {
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 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
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
}
|
| 714 |
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
});
|
| 730 |
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
await sleep(retryAfter);
|
| 742 |
-
}
|
| 743 |
|
| 744 |
-
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
}
|
| 747 |
|
| 748 |
-
async function
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break;
|
| 768 |
-
await sleep(retryAfter);
|
| 769 |
-
}
|
| 770 |
|
| 771 |
-
|
| 772 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
}
|
| 774 |
|
| 775 |
async function runContractCheck(capabilitiesValue) {
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
}
|
| 818 |
|
| 819 |
async function fetchJson(url, init) {
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
|
|
|
|
|
|
| 829 |
}
|
| 830 |
-
throw new Error(`请求失败:${url}。${message}`);
|
| 831 |
-
}
|
| 832 |
}
|
| 833 |
|
| 834 |
async function fetchWithTimeout(url, init) {
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
}
|
| 848 |
|
| 849 |
function readErrorFromJsonText(text) {
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
}
|
| 871 |
|
| 872 |
function shouldRetry(result) {
|
| 873 |
-
|
| 874 |
}
|
| 875 |
|
| 876 |
function supportsJobPolling(capabilitiesValue) {
|
| 877 |
-
|
|
|
|
|
|
|
| 878 |
}
|
| 879 |
|
| 880 |
function supportsPageSse(capabilitiesValue) {
|
| 881 |
-
|
| 882 |
}
|
| 883 |
|
| 884 |
function shouldUseJobPolling(capabilitiesValue, routeMode) {
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
}
|
| 891 |
|
| 892 |
function shouldUsePageSse(capabilitiesValue, request, routeMode) {
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 909 |
}
|
| 910 |
|
| 911 |
function isPageSseAllowed(request) {
|
| 912 |
-
|
| 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 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
}
|
| 927 |
|
| 928 |
function isScriptError(error) {
|
| 929 |
-
|
| 930 |
}
|
| 931 |
|
| 932 |
function createPageSseHttpError(status, detail) {
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
}
|
| 938 |
|
| 939 |
function isPageSseRequestRejected(error) {
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
}
|
| 948 |
|
| 949 |
function printUsage() {
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
|
|
|
|
|
|
| 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、stream 或 non_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 |
-
|
| 18 |
-
|
| 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(
|
| 1324 |
-
|
| 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
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1381 |
assert.equal(upstreamCalls, 0);
|
| 1382 |
} finally {
|
| 1383 |
await upstream.close();
|
| 1384 |
}
|
| 1385 |
});
|
| 1386 |
|
| 1387 |
-
it('rejects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, /
|
| 1412 |
-
assert.
|
| 1413 |
-
assert.doesNotMatch(body.error.message, /API Key|图片文件/);
|
| 1414 |
});
|
| 1415 |
|
| 1416 |
-
it('
|
| 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,
|
| 1440 |
const body = await response.json();
|
| 1441 |
-
assert.equal(body.
|
| 1442 |
-
assert.
|
| 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(
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 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 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 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 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 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 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
|
|
|
|
|
|
| 493 |
);
|
| 494 |
assert.equal(edit.status, 400);
|
| 495 |
-
assert.match(String(((await edit.json()) as Record<string, unknown>).error), /
|
| 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), /
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 1027 |
})
|
| 1028 |
);
|
| 1029 |
|
| 1030 |
-
assert.equal(response.status,
|
| 1031 |
-
const
|
| 1032 |
-
assert.
|
| 1033 |
-
assert.match(String(body.error), /16 的倍数/);
|
| 1034 |
-
assert.equal(upstreamCalls, 0);
|
| 1035 |
} finally {
|
| 1036 |
await upstream.close();
|
| 1037 |
}
|
| 1038 |
});
|
| 1039 |
|
| 1040 |
-
it('
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
| 136 |
const partialImagesCount = readCount(formData, 'partial_images', 2, 1, 3) as 1 | 2 | 3;
|
| 137 |
-
const imageBackend = readImageGenerationBackend(formData, process.env, {
|
|
|
|
|
|
|
| 138 |
const streamingStrategy = readImageStreamingStrategy(formData, process.env, {
|
| 139 |
useEnvDefault: mode === 'generate'
|
| 140 |
});
|
| 141 |
-
const
|
|
|
|
| 142 |
imageBackend,
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
});
|
| 146 |
assertResponsesImageBackendAllowed({ imageBackend, mode });
|
| 147 |
appLogger.info('图片上游兼容策略。', {
|
| 148 |
...requestLogContext,
|
| 149 |
imageBackend,
|
| 150 |
streamingStrategy,
|
| 151 |
-
|
| 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
|
| 29 |
const { GET } = await import('./route');
|
| 30 |
|
| 31 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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.
|
| 51 |
-
--background: oklch(
|
| 52 |
-
--foreground: oklch(0.
|
| 53 |
-
--card: oklch(
|
| 54 |
-
--card-foreground: oklch(0.
|
| 55 |
-
--popover: oklch(
|
| 56 |
-
--popover-foreground: oklch(0.
|
| 57 |
-
--primary: oklch(0.
|
| 58 |
-
--primary-foreground: oklch(0.
|
| 59 |
-
--secondary: oklch(0.
|
| 60 |
-
--secondary-foreground: oklch(0.
|
| 61 |
-
--muted: oklch(0.
|
| 62 |
-
--muted-foreground: oklch(0.
|
| 63 |
-
--accent: oklch(0.
|
| 64 |
-
--accent-foreground: oklch(0.
|
| 65 |
-
--destructive: oklch(0.
|
| 66 |
-
--border: oklch(0.
|
| 67 |
-
--input: oklch(0.
|
| 68 |
-
--ring: oklch(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.
|
| 75 |
-
--sidebar-foreground: oklch(0.
|
| 76 |
-
--sidebar-primary: oklch(0.
|
| 77 |
-
--sidebar-primary-foreground: oklch(0.985 0
|
| 78 |
-
--sidebar-accent: oklch(0.
|
| 79 |
-
--sidebar-accent-foreground: oklch(0.
|
| 80 |
-
--sidebar-border: oklch(0.
|
| 81 |
-
--sidebar-ring: oklch(0.
|
| 82 |
}
|
| 83 |
|
| 84 |
.dark {
|
| 85 |
-
--background: oklch(0.
|
| 86 |
-
--foreground: oklch(0.
|
| 87 |
-
--card: oklch(0.
|
| 88 |
-
--card-foreground: oklch(0.
|
| 89 |
-
--popover: oklch(0.
|
| 90 |
-
--popover-foreground: oklch(0.
|
| 91 |
-
--primary: oklch(0.
|
| 92 |
-
--primary-foreground: oklch(0.
|
| 93 |
-
--secondary: oklch(0.
|
| 94 |
-
--secondary-foreground: oklch(0.
|
| 95 |
-
--muted: oklch(0.
|
| 96 |
-
--muted-foreground: oklch(0.
|
| 97 |
-
--accent: oklch(0.
|
| 98 |
-
--accent-foreground: oklch(0.
|
| 99 |
--destructive: oklch(0.704 0.191 22.216);
|
| 100 |
-
--border: oklch(
|
| 101 |
-
--input: oklch(
|
| 102 |
-
--ring: oklch(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.
|
| 109 |
-
--sidebar-foreground: oklch(0.
|
| 110 |
-
--sidebar-primary: oklch(0.
|
| 111 |
-
--sidebar-primary-foreground: oklch(0.
|
| 112 |
-
--sidebar-accent: oklch(0.
|
| 113 |
-
--sidebar-accent-foreground: oklch(0.
|
| 114 |
-
--sidebar-border: oklch(
|
| 115 |
-
--sidebar-ring: oklch(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: '
|
| 8 |
-
description: '
|
| 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 |
-
<
|
| 91 |
-
<
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
<div className='grid gap-2'>
|
| 96 |
-
<
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
</div>
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
-
import { Card, CardContent, CardHeader
|
| 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 (
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 173 |
-
(
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 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='
|
| 201 |
-
<CardHeader className='flex flex-
|
| 202 |
-
|
| 203 |
-
<
|
| 204 |
-
{totalCost > 0 && (
|
| 205 |
<Dialog open={isTotalCostDialogOpen} onOpenChange={setIsTotalCostDialogOpen}>
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 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='
|
| 292 |
{t('common.close')}
|
| 293 |
</Button>
|
| 294 |
</DialogClose>
|
| 295 |
</DialogFooter>
|
| 296 |
</DialogContent>
|
| 297 |
</Dialog>
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
<Button
|
| 302 |
variant='ghost'
|
| 303 |
size='sm'
|
| 304 |
onClick={onClearHistory}
|
| 305 |
-
className='text-muted-foreground h-auto rounded-md px-2 py-1
|
| 306 |
{t('history.clear')}
|
| 307 |
</Button>
|
| 308 |
)}
|
| 309 |
</CardHeader>
|
| 310 |
-
<CardContent className='
|
| 311 |
-
{
|
| 312 |
-
<div className='
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
<p>{t('history.empty')}</p>
|
| 314 |
</div>
|
| 315 |
) : (
|
| 316 |
-
<div className='
|
| 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 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
if (originalStorageMode === 'indexeddb') {
|
| 341 |
thumbnailUrl = getImageSrc(firstImage.filename);
|
| 342 |
} else {
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
aria-label={t('history.viewBatch', {
|
| 357 |
time: formatTimestamp(item.timestamp)
|
| 358 |
})}>
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
time: formatTimestamp(item.timestamp)
|
| 364 |
})}
|
| 365 |
width={150}
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
<div
|
| 379 |
className={cn(
|
| 380 |
-
'pointer-events-none absolute top-1 left-1 z-10 flex items-center gap-1 rounded-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
)}>
|
| 383 |
-
{
|
|
|
|
|
|
|
| 384 |
<Pencil size={12} />
|
| 385 |
) : (
|
| 386 |
<SparklesIcon size={12} />
|
| 387 |
)}
|
| 388 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
</div>
|
| 390 |
{isMultiImage && (
|
| 391 |
-
<div className='pointer-events-none absolute right-1 bottom-1 z-10 flex items-center gap-1 rounded-
|
| 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-
|
| 398 |
{originalStorageMode === 'fs' ? (
|
| 399 |
<HardDrive size={12} className='text-muted-foreground' />
|
| 400 |
) : (
|
| 401 |
-
<Database size={12} className='text-
|
| 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-
|
| 411 |
<FileImage size={12} className='text-muted-foreground' />
|
| 412 |
<span>{outputFormat.toUpperCase()}</span>
|
| 413 |
</div>
|
| 414 |
)}
|
| 415 |
</div>
|
| 416 |
</button>
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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>
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
</div>
|
| 501 |
-
{typeof item.actualCostDetails
|
| 502 |
-
'number' && (
|
| 503 |
<div className='flex justify-between gap-3'>
|
| 504 |
-
<span>
|
|
|
|
|
|
|
| 505 |
<span>
|
| 506 |
{item.actualCostDetails.actualQuota.toLocaleString()}
|
| 507 |
</span>
|
| 508 |
</div>
|
| 509 |
)}
|
| 510 |
-
{typeof item.actualCostDetails
|
| 511 |
-
'number' && (
|
| 512 |
<div className='flex justify-between gap-3'>
|
| 513 |
-
<span>{t('history.matchedLogId')}</span>
|
| 514 |
<span>
|
| 515 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
</span>
|
| 517 |
</div>
|
| 518 |
)}
|
|
@@ -526,25 +789,32 @@ function HistoryPanelImpl({
|
|
| 526 |
{item.costDetails && (
|
| 527 |
<>
|
| 528 |
<div className='flex justify-between'>
|
| 529 |
-
<span>
|
|
|
|
|
|
|
| 530 |
<span>
|
| 531 |
{item.costDetails.text_input_tokens.toLocaleString()}{' '}
|
| 532 |
(
|
| 533 |
{formatEstimatedTokenCost(
|
| 534 |
-
item.costDetails
|
|
|
|
| 535 |
rates.textInputPerToken
|
| 536 |
)}
|
| 537 |
)
|
| 538 |
</span>
|
| 539 |
</div>
|
| 540 |
-
{item.costDetails.image_input_tokens >
|
|
|
|
| 541 |
<div className='flex justify-between'>
|
| 542 |
-
<span>
|
|
|
|
|
|
|
| 543 |
<span>
|
| 544 |
{item.costDetails.image_input_tokens.toLocaleString()}{' '}
|
| 545 |
(
|
| 546 |
{formatEstimatedTokenCost(
|
| 547 |
-
item.costDetails
|
|
|
|
| 548 |
rates.imageInputPerToken
|
| 549 |
)}
|
| 550 |
)
|
|
@@ -552,12 +822,15 @@ function HistoryPanelImpl({
|
|
| 552 |
</div>
|
| 553 |
)}
|
| 554 |
<div className='flex justify-between'>
|
| 555 |
-
<span>
|
|
|
|
|
|
|
| 556 |
<span>
|
| 557 |
{item.costDetails.image_output_tokens.toLocaleString()}{' '}
|
| 558 |
(
|
| 559 |
{formatEstimatedTokenCost(
|
| 560 |
-
item.costDetails
|
|
|
|
| 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>
|
| 569 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
</div>
|
| 571 |
</>
|
| 572 |
)}
|
|
@@ -580,7 +860,7 @@ function HistoryPanelImpl({
|
|
| 580 |
type='button'
|
| 581 |
variant='secondary'
|
| 582 |
size='sm'
|
| 583 |
-
className='
|
| 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
|
| 594 |
<p title={t('history.generatedOn', { time: formatTimestamp(item.timestamp) })}>
|
| 595 |
-
<span className='text-foreground font-medium'>
|
| 596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'>
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 644 |
<div>
|
| 645 |
-
<dt className='text-muted-foreground'>
|
| 646 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 647 |
</div>
|
| 648 |
<div>
|
| 649 |
-
<dt className='text-muted-foreground'>
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
</div>
|
| 652 |
<div>
|
| 653 |
-
<dt className='text-muted-foreground'>
|
|
|
|
|
|
|
| 654 |
<dd className='text-foreground font-medium'>
|
| 655 |
-
{item.mode === 'edit'
|
|
|
|
|
|
|
| 656 |
</dd>
|
| 657 |
</div>
|
| 658 |
<div>
|
| 659 |
-
<dt className='text-muted-foreground'>
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
</div>
|
| 662 |
<div>
|
| 663 |
-
<dt className='text-muted-foreground'>
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
</div>
|
| 666 |
<div>
|
| 667 |
-
<dt className='text-muted-foreground'>
|
| 668 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
</div>
|
| 670 |
<div>
|
| 671 |
-
<dt className='text-muted-foreground'>
|
| 672 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
</div>
|
| 674 |
<div>
|
| 675 |
-
<dt className='text-muted-foreground'>
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
</div>
|
| 678 |
<div>
|
| 679 |
-
<dt className='text-muted-foreground'>
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
</div>
|
| 682 |
<div>
|
| 683 |
-
<dt className='text-muted-foreground'>
|
| 684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 685 |
</div>
|
| 686 |
<div>
|
| 687 |
-
<dt className='text-muted-foreground'>
|
|
|
|
|
|
|
| 688 |
<dd className='text-foreground font-medium'>
|
| 689 |
-
{originalStorageMode === 'fs'
|
|
|
|
|
|
|
| 690 |
</dd>
|
| 691 |
</div>
|
| 692 |
<div>
|
| 693 |
-
<dt className='text-muted-foreground'>
|
| 694 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 695 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
<div>
|
| 697 |
-
<dt className='text-muted-foreground'>
|
|
|
|
|
|
|
| 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'>
|
|
|
|
|
|
|
| 708 |
<dd className='text-foreground font-medium'>
|
| 709 |
-
{item.costDetails
|
|
|
|
|
|
|
| 710 |
</dd>
|
| 711 |
</div>
|
| 712 |
</dl>
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
<div>
|
| 715 |
-
<dt className='text-muted-foreground'>
|
| 716 |
-
|
|
|
|
|
|
|
| 717 |
{filenames.length > 0 ? filenames.join(', ') : '-'}
|
| 718 |
</dd>
|
| 719 |
</div>
|
| 720 |
<div>
|
| 721 |
-
<dt className='text-muted-foreground'>
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
</dd>
|
| 725 |
</div>
|
| 726 |
</dl>
|
| 727 |
<div>
|
| 728 |
-
<p className='mb-1 text-sm font-medium'>
|
| 729 |
-
|
|
|
|
|
|
|
| 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='
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
{t('common.cancel')}
|
| 805 |
</Button>
|
| 806 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|