misonL commited on
Commit
919485b
·
verified ·
1 Parent(s): 11486ce

Deploy 96f3dcf to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@96f3dcf3b00cdc76478bfb8c3de537c0af3b636a

.env.agent.local.example ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env.agent.local and keep the real file private.
2
+ # Load it into your shell before running protected Agent scripts.
3
+ #
4
+ # Example:
5
+ # set -a; source .env.agent.local; set +a
6
+
7
+ GPT_IMAGE_PLAYGROUND_URL=http://localhost:4783
8
+ GPT_IMAGE_AGENT_TOKEN=
9
+ GPT_IMAGE_APP_PASSWORD_HASH=
.gitignore CHANGED
@@ -36,6 +36,7 @@ dev-server*.log
36
  .env*
37
  !.env.example
38
  !.env.agent.example
 
39
  !.env.real-smoke.example
40
 
41
  # vercel
 
36
  .env*
37
  !.env.example
38
  !.env.agent.example
39
+ !.env.agent.local.example
40
  !.env.real-smoke.example
41
 
42
  # vercel
README.md CHANGED
@@ -17,6 +17,26 @@ app_port: 4783
17
 
18
  ## 快速开始
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  推荐 Docker:
21
 
22
  ```bash
@@ -172,12 +192,20 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
172
 
173
  AI Agent 集成时优先调用 skill 内置脚本,而不是临时手写 fetch、curl 或表单提交逻辑。脚本会先读取 capabilities,自动处理鉴权、幂等键、路由选择、超时、产物 URL 和结构化失败摘要。
174
 
 
 
 
 
 
 
 
 
175
  1. 只读检查当前服务能力,不触发计费:
176
 
177
  ```bash
178
- GPT_IMAGE_PLAYGROUND_URL=http://localhost:4783 \
179
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
180
  --contract-check \
 
181
  "capability check"
182
  ```
183
 
@@ -185,6 +213,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
185
 
186
  ```bash
187
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
188
  --size 1024x1024 \
189
  --quality high \
190
  --response-mode path \
@@ -196,6 +225,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
196
 
197
  ```bash
198
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
199
  --allow-billable \
200
  --timeout-ms 420000 \
201
  --size 1024x1024 \
@@ -209,6 +239,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
209
 
210
  ```bash
211
  node skills/gpt-image-playground-agent/scripts/edit-image.mjs \
 
212
  --image ./source.png \
213
  --format webp \
214
  --output-compression 100 \
@@ -226,6 +257,7 @@ node skills/gpt-image-playground-agent/scripts/edit-image.mjs \
226
 
227
  ```bash
228
  node skills/gpt-image-playground-agent/scripts/batch-images.mjs \
 
229
  --input tasks.jsonl \
230
  --manifest runs/product-set.manifest.jsonl \
231
  --resume \
@@ -247,6 +279,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
247
 
248
  ```bash
249
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
 
250
  --idempotency-key agent-demo-generate-001
251
  ```
252
 
@@ -254,18 +287,41 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
254
 
255
  ```bash
256
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
 
257
  --client-request-id agent-demo-edit-001
258
  ```
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  常用环境变量:
261
 
262
  | 变量 | 用途 |
263
  | --- | --- |
264
- | `GPT_IMAGE_PLAYGROUND_URL` | 指向本机、内网或公网部署地址;默认尝试 `http://localhost:4783`。 |
265
  | `GPT_IMAGE_AGENT_TOKEN` | Agent Bearer token,对应服务端 `AGENT_API_TOKEN`。 |
266
  | `GPT_IMAGE_APP_PASSWORD_HASH` | 使用页面访问码部署时的访问码哈希;页面 SSE 会作为 `passwordHash` 表单字段发送。 |
267
  | `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY` | 跨脚本进程复用同一业务操作的幂等键。 |
268
 
 
 
269
  接口边界:
270
 
271
  - `/api/agent/*` 是自动化机器契约,返回最终 JSON,不向客户端返回 SSE。
@@ -303,6 +359,18 @@ docker compose up -d --build --remove-orphans
303
  npm run deploy:local
304
  ```
305
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  常见部署模式:
307
 
308
  | 模式 | 命令或配置 | 适用场景 |
@@ -332,9 +400,11 @@ Hugging Face Space 免费层部署见 [docs/deployment/huggingface-space-free.md
332
  | `npm run lint:scripts` | 检查仓库脚本和 skill 脚本语法。 |
333
  | `npm run version:check` | 检查版本、README badge 和 CHANGELOG 口径。 |
334
  | `npm run verify` | 运行提交前基线。 |
 
 
335
  | `npm run status` | 只读查看 git、Node、部署目标和 Agent 摘要。 |
336
  | `npm run doctor` | 运行本机和部署诊断。 |
337
- | `npm run agent:doctor` | 非计费 Agent 分层诊断。 |
338
  | `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
339
 
340
  真实上游 smoke 默认不会触发计费;需要真实生图时必须显式传入 `--allow-billable`。
 
17
 
18
  ## 快速开始
19
 
20
+ 第一次配置或换机器后,先跑只读就绪检查。它不会写配置、不会输出密钥、不会触发真实生图:
21
+
22
+ ```bash
23
+ npm run first-run
24
+ ```
25
+
26
+ 该命令默认输出中文摘要;给 Agent 或脚本消费时加 `--json`。它会检查 Node、依赖、`.env.local` / `.env.agent.local` 摘要、默认本地服务 `http://localhost:4783`、Agent capabilities 和下一步动作。检查公网或 Space 服务时显式传地址:
27
+
28
+ ```bash
29
+ npm run first-run -- --base-url https://your-space.hf.space
30
+ npm run first-run -- --json --base-url https://your-space.hf.space
31
+ ```
32
+
33
+ 首次配置最短路径:
34
+
35
+ 1. 运行 `npm install`。
36
+ 2. 启动服务:本地开发用 `npm run dev`,Docker 用 `docker compose up -d --build --remove-orphans`。
37
+ 3. 运行 `npm run first-run` 看中文摘要;如果要检查 Space 或内网服务,使用 `npm run first-run -- --base-url <url>`。
38
+ 4. 如果 Agent API 需要鉴权,复制 `.env.agent.local.example` 为 `.env.agent.local`,填入本机私有 token 后加载到 shell,再运行 `npm run first-run` 或 skill 脚本。
39
+
40
  推荐 Docker:
41
 
42
  ```bash
 
192
 
193
  AI Agent 集成时优先调用 skill 内置脚本,而不是临时手写 fetch、curl 或表单提交逻辑。脚本会先读取 capabilities,自动处理鉴权、幂等键、路由选择、超时、产物 URL 和结构化失败摘要。
194
 
195
+ 交互式任务中,Agent 应先定位服务地址:用户明确提供 URL 时直接使用该 URL;否则先检查 `GPT_IMAGE_PLAYGROUND_URL`,再探测默认本地地址 `http://localhost:4783`。如果只发现环境变量或本地服务,先向用户确认是否使用;用户提供其他地址时,以用户提供的地址为准。非交互式任务无法确认时,按同一顺序自动选择,并在输出里说明地址来源。
196
+
197
+ 新环境或不确定服务地址时先运行 `npm run first-run`。它会只读报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程是否拿到 Agent 鉴权,以及 `.env.agent.local` 是否存在私有鉴权配置;如果 token 只在私有 env 文件中,先把它加载到 shell,再运行 skill 脚本。
198
+
199
+ dry-run 只做本地请求构造和静态路由规划,不读取远端 capabilities,也不验证远端鉴权、渠道容量或 manifest 写入。脚本输出里的 `verification_scope.mode=local_planning_only` 表示还没有证明远端服务可执行;需要远端合同检查时使用 `--contract-check`,真实执行必须显式添加 `--allow-billable`。
200
+
201
+ subagent 或自动化任务要固定服务地址时,优先给脚本传 `--base-url`,不要只依赖默认 localhost。`generate-image.mjs`、`edit-image.mjs`、`batch-images.mjs`、`diagnose-request.mjs` 和 `npm run agent:doctor -- --base-url <url>` 都支持显式服务地址。首次配置 Agent 鉴权时复制 `.env.agent.local.example` 为 `.env.agent.local`,填入本机私有 token 后加载到 shell;不要把 `.env.agent.local` 提交或粘到任务日志。
202
+
203
  1. 只读检查当前服务能力,不触发计费:
204
 
205
  ```bash
 
206
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
207
  --contract-check \
208
+ --base-url http://localhost:4783 \
209
  "capability check"
210
  ```
211
 
 
213
 
214
  ```bash
215
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
216
+ --base-url http://localhost:4783 \
217
  --size 1024x1024 \
218
  --quality high \
219
  --response-mode path \
 
225
 
226
  ```bash
227
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
228
+ --base-url http://localhost:4783 \
229
  --allow-billable \
230
  --timeout-ms 420000 \
231
  --size 1024x1024 \
 
239
 
240
  ```bash
241
  node skills/gpt-image-playground-agent/scripts/edit-image.mjs \
242
+ --base-url http://localhost:4783 \
243
  --image ./source.png \
244
  --format webp \
245
  --output-compression 100 \
 
257
 
258
  ```bash
259
  node skills/gpt-image-playground-agent/scripts/batch-images.mjs \
260
+ --base-url http://localhost:4783 \
261
  --input tasks.jsonl \
262
  --manifest runs/product-set.manifest.jsonl \
263
  --resume \
 
279
 
280
  ```bash
281
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
282
+ --base-url http://localhost:4783 \
283
  --idempotency-key agent-demo-generate-001
284
  ```
285
 
 
287
 
288
  ```bash
289
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
290
+ --base-url http://localhost:4783 \
291
  --client-request-id agent-demo-edit-001
292
  ```
293
 
294
+ 远��� Space、云服务或内网服务必须显式固定目标地址,避免误查本机默认服务:
295
+
296
+ ```bash
297
+ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
298
+ --base-url https://your-space.hf.space \
299
+ --idempotency-key agent-demo-generate-001
300
+ ```
301
+
302
+ 首次配置和诊断输出字段速查:
303
+
304
+ | 字段 | 出现位置 | 判断口径 |
305
+ | --- | --- | --- |
306
+ | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
307
+ | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
308
+ | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
309
+ | `agent_auth_process.has_token` | `first-run --json` | 当前 shell 是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
310
+ | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;存在不代表当前 shell 已加载。 |
311
+ | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
312
+ | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
313
+
314
  常用环境变量:
315
 
316
  | 变量 | 用途 |
317
  | --- | --- |
318
+ | `GPT_IMAGE_PLAYGROUND_URL` | 指向本机、内网或公网部署地址;未设置时脚本默认尝试 `http://localhost:4783`。交互式任务中,自动发现到本地服务后应先向用户确认。 |
319
  | `GPT_IMAGE_AGENT_TOKEN` | Agent Bearer token,对应服务端 `AGENT_API_TOKEN`。 |
320
  | `GPT_IMAGE_APP_PASSWORD_HASH` | 使用页面访问码部署时的访问码哈希;页面 SSE 会作为 `passwordHash` 表单字段发送。 |
321
  | `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY` | 跨脚本进程复用同一业务操作的幂等键。 |
322
 
323
+ Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 secret 值。远端 Space 配置了 `AGENT_API_TOKEN` 后,本机 Agent 仍需要通过不入库的 shell 环境、keychain 或本地私有 env 文件注入 `GPT_IMAGE_AGENT_TOKEN`;不要把 token 写进 README、任务 JSONL、manifest 或命令日志。仓库提供 `.env.agent.local.example` 作为私有本机配置模板。
324
+
325
  接口边界:
326
 
327
  - `/api/agent/*` 是自动化机器契约,返回最终 JSON,不向客户端返回 SSE。
 
359
  npm run deploy:local
360
  ```
361
 
362
+ 本仓库的 Compose 服务只挂载 `generated-images/`。不要用 `docker run -v "$PWD:/workspace"` 启动本地图片上游 fixture;这会把 `.git/`、`node_modules/` 和 `.next/` 暴露给 Docker Desktop 文件共享层,可能触发文件事件风暴。本地 fixture gate 使用进程内服务:
363
+
364
+ ```bash
365
+ npm run smoke:image-upstream-local
366
+ ```
367
+
368
+ 如本机遗留了整仓挂载的 fixture 容器,可执行:
369
+
370
+ ```bash
371
+ npm run docker:cleanup-fixtures
372
+ ```
373
+
374
  常见部署模式:
375
 
376
  | 模式 | 命令或配置 | 适用场景 |
 
400
  | `npm run lint:scripts` | 检查仓库脚本和 skill 脚本语法。 |
401
  | `npm run version:check` | 检查版本、README badge 和 CHANGELOG 口径。 |
402
  | `npm run verify` | 运行提交前基线。 |
403
+ | `npm run docker:cleanup-fixtures` | 清理遗留的整仓挂载 Docker fixture 容器。 |
404
+ | `npm run first-run` | 首次配置就绪检查,默认中文摘要;加 `-- --json` 输出机器可读 JSON。 |
405
  | `npm run status` | 只读查看 git、Node、部署目标和 Agent 摘要。 |
406
  | `npm run doctor` | 运行本机和部署诊断。 |
407
+ | `npm run agent:doctor` | 非计费 Agent 分层诊断;支持 `-- --base-url <url>`。 |
408
  | `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
409
 
410
  真实上游 smoke 默认不会触发计费;需要真实生图时必须显式传入 `--allow-billable`。
package.json CHANGED
@@ -12,12 +12,14 @@
12
  "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
13
  "test:postgres": "node scripts/test-postgres-live.mjs",
14
  "version:check": "node scripts/check-version-metadata.mjs",
 
15
  "doctor": "node scripts/doctor.mjs",
16
  "status": "node scripts/status.mjs",
17
  "env:summary": "node scripts/env-summary.mjs",
18
  "verify": "node scripts/verify.mjs",
19
  "deploy:local": "node scripts/deploy-local.mjs",
20
  "deploy:space": "npm run deploy:hf-space",
 
21
  "agent:doctor": "node scripts/agent-doctor.mjs",
22
  "deploy:hf-space": "node scripts/deploy-hf-space.mjs",
23
  "doctor:hf-space": "node scripts/doctor-hf-space.mjs",
 
12
  "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
13
  "test:postgres": "node scripts/test-postgres-live.mjs",
14
  "version:check": "node scripts/check-version-metadata.mjs",
15
+ "first-run": "node scripts/first-run.mjs",
16
  "doctor": "node scripts/doctor.mjs",
17
  "status": "node scripts/status.mjs",
18
  "env:summary": "node scripts/env-summary.mjs",
19
  "verify": "node scripts/verify.mjs",
20
  "deploy:local": "node scripts/deploy-local.mjs",
21
  "deploy:space": "npm run deploy:hf-space",
22
+ "docker:cleanup-fixtures": "node scripts/cleanup-docker-fixtures.mjs",
23
  "agent:doctor": "node scripts/agent-doctor.mjs",
24
  "deploy:hf-space": "node scripts/deploy-hf-space.mjs",
25
  "doctor:hf-space": "node scripts/doctor-hf-space.mjs",
scripts/agent-doctor.mjs CHANGED
@@ -2,22 +2,30 @@
2
 
3
  import { fileURLToPath } from 'node:url';
4
 
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) {
@@ -25,23 +33,30 @@ function parseArgs(argv) {
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() {
@@ -51,11 +66,12 @@ async function main() {
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({
@@ -63,14 +79,17 @@ async function main() {
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
  });
@@ -120,10 +139,12 @@ function buildSkippedSmoke(options) {
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',
@@ -141,6 +162,8 @@ function runBillableSmoke(options) {
141
  checks.push(
142
  runSmokeCommand('edit_1k', [
143
  EDIT_SCRIPT,
 
 
144
  '--allow-billable',
145
  '--agent',
146
  '--timeout-ms',
@@ -158,6 +181,8 @@ function runBillableSmoke(options) {
158
  checks.push(
159
  runSmokeCommand('page_sse_edit_2k', [
160
  EDIT_SCRIPT,
 
 
161
  '--allow-billable',
162
  '--page-sse',
163
  '--timeout-ms',
@@ -296,14 +321,14 @@ function authHeaders() {
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
 
@@ -319,11 +344,6 @@ function normalizeBaseUrl(value) {
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;
 
2
 
3
  import { fileURLToPath } from 'node:url';
4
 
5
+ import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, redactBaseUrl, runCommand } from './command-center-utils.mjs';
6
+ import { resolvePlaygroundBaseUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
7
 
8
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
9
  const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
10
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
 
11
 
12
  export function buildAgentDoctorArgs() {
13
  return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
14
  }
15
 
16
+ export function buildAgentDoctorContractArgs(baseUrl) {
17
+ const args = [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000'];
18
+ if (baseUrl) args.push('--base-url', baseUrl);
19
+ args.push('contract check');
20
+ return args;
21
+ }
22
+
23
  function parseArgs(argv) {
24
  const parsed = {
25
  help: false,
26
  allowBillable: false,
27
  timeoutMs: 60_000,
28
+ baseUrl: undefined,
29
  editImage: undefined
30
  };
31
  for (let index = 0; index < argv.length; index += 1) {
 
33
  if (arg === '--help' || arg === '-h') parsed.help = true;
34
  else if (arg === '--allow-billable') parsed.allowBillable = true;
35
  else if (arg === '--timeout-ms') parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), '--timeout-ms');
36
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
37
  else if (arg === '--edit-image') parsed.editImage = readOptionValue(argv, (index += 1), arg);
38
+ else throw new Error(`未知参数:${arg}`);
39
  }
40
  return parsed;
41
  }
42
 
43
  function printHelp() {
44
+ console.log(`用法:
45
  npm run agent:doctor
46
+ npm run agent:doctor -- --base-url https://your-space.hf.space --allow-billable --edit-image /path/to/reference.png
47
+
48
+ 环境变量:
49
+ GPT_IMAGE_PLAYGROUND_URL 服务基础地址,默认 http://localhost:4783。
50
+ GPT_IMAGE_AGENT_TOKEN capabilities 需要 bearer 鉴权时使用。
51
+ GPT_IMAGE_APP_PASSWORD_HASH capabilities 需要页面密码鉴权时使用。
52
 
53
+ 选项:
54
+ --base-url 显式服务地址,优先于 GPT_IMAGE_PLAYGROUND_URL 和本地探测。
55
+ --timeout-ms HTTP 探测和 smoke 超时,默认 60000。
56
+ --edit-image 真实 edit smoke 使用的参考图。
57
+ --allow-billable 显式允许真实 1K/2K smoke 请求。
58
 
59
+ agent:doctor 默认只读、非计费。真实 generate/edit smoke 必须显式添加 --allow-billable`);
60
  }
61
 
62
  async function main() {
 
66
  return;
67
  }
68
 
69
+ const baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
70
+ const baseUrl = baseUrlInfo.baseUrl;
71
+ const contract = runContractCheck(baseUrl);
72
  const capabilities = await readJsonLayer('capabilities', `${baseUrl}/api/agent/capabilities`, options.timeoutMs);
73
  const runtime = await readJsonLayer('runtime', `${baseUrl}/api/runtime-capabilities`, options.timeoutMs);
74
+ const smoke = options.allowBillable ? runBillableSmoke(options, baseUrl) : buildSkippedSmoke(options);
75
  const layers = buildLayers({ capabilities, runtime, contract, smoke });
76
 
77
  printJson({
 
79
  command: 'agent:doctor',
80
  billable: options.allowBillable,
81
  base_url: redactBaseUrl(baseUrl),
82
+ service_base_url: redactBaseUrl(baseUrl),
83
+ service_base_url_source: baseUrlInfo.source,
84
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
85
  layers,
86
  summary: buildSummary({ capabilities, runtime, contract, smoke })
87
  });
88
  if (layers.some((layer) => !layer.ok && !layer.skipped)) process.exit(1);
89
  }
90
 
91
+ function runContractCheck(baseUrl) {
92
+ const result = runCommand(process.execPath, buildAgentDoctorContractArgs(baseUrl), {
93
  env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' },
94
  timeoutMs: AGENT_DOCTOR_TIMEOUT_MS
95
  });
 
139
  };
140
  }
141
 
142
+ function runBillableSmoke(options, baseUrl) {
143
  const checks = [
144
  runSmokeCommand('generate_1k', [
145
  GENERATE_SCRIPT,
146
+ '--base-url',
147
+ baseUrl,
148
  '--allow-billable',
149
  '--agent',
150
  '--timeout-ms',
 
162
  checks.push(
163
  runSmokeCommand('edit_1k', [
164
  EDIT_SCRIPT,
165
+ '--base-url',
166
+ baseUrl,
167
  '--allow-billable',
168
  '--agent',
169
  '--timeout-ms',
 
181
  checks.push(
182
  runSmokeCommand('page_sse_edit_2k', [
183
  EDIT_SCRIPT,
184
+ '--base-url',
185
+ baseUrl,
186
  '--allow-billable',
187
  '--page-sse',
188
  '--timeout-ms',
 
321
 
322
  function readOptionValue(argv, index, name) {
323
  const value = argv[index];
324
+ if (!value || value.startsWith('--')) throw new Error(`${name} 需要参数值。`);
325
  return value;
326
  }
327
 
328
  function readPositiveInteger(value, name) {
329
+ if (!/^\d+$/.test(String(value))) throw new Error(`${name} 必须是正整数。`);
330
  const parsed = Number(value);
331
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} 必须是正整数。`);
332
  return parsed;
333
  }
334
 
 
344
  return normalized;
345
  }
346
 
 
 
 
 
 
347
  function safePathname(url) {
348
  try {
349
  return new URL(url).pathname;
scripts/agent-skill-scripts.test.mjs CHANGED
@@ -155,6 +155,46 @@ describe('Agent skill script argument validation', () => {
155
  assert.equal(result.stdout.trim(), '');
156
  });
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  it('rejects upstream probe base URLs with embedded credentials before network checks', () => {
159
  const result = runSkillScript('probe-upstream-image.mjs', [
160
  '--base-url',
@@ -240,6 +280,10 @@ describe('Agent skill script argument validation', () => {
240
  const body = JSON.parse(result.stdout);
241
  assert.equal(body.dry_run, true);
242
  assert.equal(body.billable, false);
 
 
 
 
243
  assert.equal(body.request.output_format, 'webp');
244
  assert.equal(body.request.output_compression, 100);
245
  assert.equal(result.stderr.trim(), '');
@@ -310,6 +354,10 @@ describe('Agent skill script argument validation', () => {
310
  assert.equal(body.request.stream_mode, 'auto');
311
  assert.equal(body.request.streaming_strategy, 'force-sse');
312
  assert.equal(body.request.partial_images, 2);
 
 
 
 
313
  assert.equal(result.stderr.trim(), '');
314
  });
315
 
@@ -2500,6 +2548,29 @@ describe('Agent skill script argument validation', () => {
2500
  assert.deepEqual(matches, []);
2501
  });
2502
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2503
  it('tells agents to use bundled scripts instead of ad hoc API callers', () => {
2504
  const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
2505
  const openAiYaml = readFileSync(join(skillRoot, 'agents/openai.yaml'), 'utf8');
@@ -2550,12 +2621,26 @@ describe('Agent skill script argument validation', () => {
2550
  const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
2551
  const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
2552
 
 
 
 
 
 
 
 
 
 
 
 
 
2553
  assert.match(readmeText, /不要手动并行启动多个单张脚本/);
2554
  assert.match(readmeText, /streamingBatch\.recommendedConcurrency/);
2555
  assert.match(readmeText, /channelQueue\.capacityPerCredential/);
2556
  assert.match(readmeText, /Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致/);
2557
  assert.match(readmeText, /不要直接输出 `\.env\.local`、`\.env\*\.local`、secret 文件或原始 `docker inspect \.Config\.Env`/);
2558
  assert.match(readmeText, /npm run env:summary/);
 
 
2559
 
2560
  assert.match(skillText, /不要手动并行启动多个单张脚本/);
2561
  assert.match(skillText, /capacity_feedback/);
@@ -2564,6 +2649,9 @@ describe('Agent skill script argument validation', () => {
2564
  assert.match(skillText, /复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时/);
2565
  assert.match(skillText, /Codex 会话日志会持久保存命令输出/);
2566
  assert.match(skillText, /npm run env:summary/);
 
 
 
2567
 
2568
  assert.match(apiReference, /不要手动并行启动多个单张脚本来绕过 `capacity_feedback`/);
2569
  assert.match(apiReference, /streamingBatch\.recommendedConcurrency/);
@@ -2571,6 +2659,9 @@ describe('Agent skill script argument validation', () => {
2571
  assert.match(apiReference, /尺寸敏感任务必须使用批量 `--dimension-check` 或下载后校验/);
2572
  assert.match(apiReference, /channel_capacity_queue_aborted/);
2573
  assert.match(apiReference, /npm run env:summary/);
 
 
 
2574
  });
2575
 
2576
  it('keeps WebUI page APIs out of the Agent OpenAPI contract', () => {
@@ -2647,6 +2738,24 @@ describe('Agent skill script argument validation', () => {
2647
  assert.match(result.stderr, /用法:generate-image\.mjs/);
2648
  assert.equal(result.stdout.trim(), '');
2649
  assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /ERR_MODULE_NOT_FOUND|src\/lib/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2650
  } finally {
2651
  rmSync(tempRoot, { recursive: true, force: true });
2652
  }
@@ -2683,6 +2792,11 @@ describe('Agent skill script argument validation', () => {
2683
  const body = JSON.parse(result.stdout);
2684
  assert.equal(body.dry_run, true);
2685
  assert.equal(body.billable, false);
 
 
 
 
 
2686
  assert.equal(body.total, 2);
2687
  assert.equal(body.concurrency, 1);
2688
  assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate');
@@ -5022,6 +5136,40 @@ describe('Agent skill script argument validation', () => {
5022
  );
5023
  });
5024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5025
  it('reports missing Agent state diagnostics as found false without failing the script', async () => {
5026
  await withServer(
5027
  async (request, response) => {
 
155
  assert.equal(result.stdout.trim(), '');
156
  });
157
 
158
+ it('lets explicit base-url override the environment service URL in dry-run output', () => {
159
+ const generateResult = runSkillScript(
160
+ 'generate-image.mjs',
161
+ ['--base-url', 'https://space.example.test/', '--size', '1024x1024', 'prompt'],
162
+ { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' }
163
+ );
164
+ assert.equal(generateResult.status, 0);
165
+ const generateBody = JSON.parse(generateResult.stdout);
166
+ assert.equal(generateBody.verification_scope.service_base_url, 'https://space.example.test');
167
+ assert.equal(generateBody.verification_scope.service_base_url_source, 'user_provided');
168
+ assert.equal(generateBody.verification_scope.interactive_confirmation_required, false);
169
+
170
+ const editResult = runSkillScript(
171
+ 'edit-image.mjs',
172
+ ['--base-url', 'https://space.example.test/', '--image', '/tmp/source.png', 'prompt'],
173
+ { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' }
174
+ );
175
+ assert.equal(editResult.status, 0);
176
+ const editBody = JSON.parse(editResult.stdout);
177
+ assert.equal(editBody.verification_scope.service_base_url, 'https://space.example.test');
178
+ assert.equal(editBody.verification_scope.service_base_url_source, 'user_provided');
179
+
180
+ const tempRoot = mkdtempSync(join(tmpdir(), 'batch-base-url-'));
181
+ try {
182
+ const inputPath = join(tempRoot, 'tasks.jsonl');
183
+ writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' }));
184
+ const batchResult = runSkillScript(
185
+ 'batch-images.mjs',
186
+ ['--base-url', 'https://space.example.test/', '--input', inputPath],
187
+ { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' }
188
+ );
189
+ assert.equal(batchResult.status, 0);
190
+ const batchBody = JSON.parse(batchResult.stdout);
191
+ assert.equal(batchBody.verification_scope.service_base_url, 'https://space.example.test');
192
+ assert.equal(batchBody.verification_scope.service_base_url_source, 'user_provided');
193
+ } finally {
194
+ rmSync(tempRoot, { recursive: true, force: true });
195
+ }
196
+ });
197
+
198
  it('rejects upstream probe base URLs with embedded credentials before network checks', () => {
199
  const result = runSkillScript('probe-upstream-image.mjs', [
200
  '--base-url',
 
280
  const body = JSON.parse(result.stdout);
281
  assert.equal(body.dry_run, true);
282
  assert.equal(body.billable, false);
283
+ assert.equal(body.verification_scope.mode, 'local_planning_only');
284
+ assert.equal(body.verification_scope.remote_capabilities_verified, false);
285
+ assert.equal(body.verification_scope.runtime_capacity_verified, false);
286
+ assert.equal(body.verification_scope.auth_verified, false);
287
  assert.equal(body.request.output_format, 'webp');
288
  assert.equal(body.request.output_compression, 100);
289
  assert.equal(result.stderr.trim(), '');
 
354
  assert.equal(body.request.stream_mode, 'auto');
355
  assert.equal(body.request.streaming_strategy, 'force-sse');
356
  assert.equal(body.request.partial_images, 2);
357
+ assert.equal(body.verification_scope.mode, 'local_planning_only');
358
+ assert.equal(body.verification_scope.remote_capabilities_verified, false);
359
+ assert.equal(body.verification_scope.runtime_capacity_verified, false);
360
+ assert.equal(body.verification_scope.auth_verified, false);
361
  assert.equal(result.stderr.trim(), '');
362
  });
363
 
 
2548
  assert.deepEqual(matches, []);
2549
  });
2550
 
2551
+ it('keeps skill frontmatter valid without requiring Python YAML tooling', () => {
2552
+ const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
2553
+ const match = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2554
+ assert.ok(match, 'SKILL.md must start with YAML frontmatter');
2555
+
2556
+ const frontmatter = Object.fromEntries(
2557
+ match[1]
2558
+ .split(/\r?\n/)
2559
+ .filter(Boolean)
2560
+ .map((line) => {
2561
+ const separator = line.indexOf(':');
2562
+ assert.ok(separator > 0, `invalid frontmatter line: ${line}`);
2563
+ return [line.slice(0, separator).trim(), line.slice(separator + 1).trim()];
2564
+ })
2565
+ );
2566
+
2567
+ assert.deepEqual(Object.keys(frontmatter).sort(), ['description', 'name']);
2568
+ assert.match(frontmatter.name, /^[a-z0-9-]+$/);
2569
+ assert.ok(frontmatter.description.length > 0);
2570
+ assert.ok(frontmatter.description.length <= 1024);
2571
+ assert.doesNotMatch(frontmatter.description, /[<>]/);
2572
+ });
2573
+
2574
  it('tells agents to use bundled scripts instead of ad hoc API callers', () => {
2575
  const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
2576
  const openAiYaml = readFileSync(join(skillRoot, 'agents/openai.yaml'), 'utf8');
 
2621
  const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
2622
  const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
2623
 
2624
+ assert.match(readmeText, /npm run first-run/);
2625
+ assert.match(readmeText, /npm run first-run -- --json/);
2626
+ assert.match(readmeText, /\.env\.agent\.local\.example/);
2627
+ assert.match(readmeText, /--base-url http:\/\/localhost:4783/);
2628
+ assert.match(skillText, /npm run first-run/);
2629
+ assert.match(skillText, /-- --json/);
2630
+ assert.match(skillText, /--base-url <url>/);
2631
+ assert.match(skillText, /\.env\.agent\.local\.example/);
2632
+ assert.match(apiReference, /npm run first-run/);
2633
+ assert.match(apiReference, /npm run first-run -- --json/);
2634
+ assert.match(apiReference, /--base-url <url>/);
2635
+ assert.match(apiReference, /\.env\.agent\.local\.example/);
2636
  assert.match(readmeText, /不要手动并行启动多个单张脚本/);
2637
  assert.match(readmeText, /streamingBatch\.recommendedConcurrency/);
2638
  assert.match(readmeText, /channelQueue\.capacityPerCredential/);
2639
  assert.match(readmeText, /Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致/);
2640
  assert.match(readmeText, /不要直接输出 `\.env\.local`、`\.env\*\.local`、secret 文件或原始 `docker inspect \.Config\.Env`/);
2641
  assert.match(readmeText, /npm run env:summary/);
2642
+ assert.match(readmeText, /verification_scope\.mode=local_planning_only/);
2643
+ assert.match(readmeText, /Hugging Face Space Secrets 只能写入和列出名称/);
2644
 
2645
  assert.match(skillText, /不要手动并行启动多个单张脚本/);
2646
  assert.match(skillText, /capacity_feedback/);
 
2649
  assert.match(skillText, /复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时/);
2650
  assert.match(skillText, /Codex 会话日志会持久保存命令输出/);
2651
  assert.match(skillText, /npm run env:summary/);
2652
+ assert.match(skillText, /verification_scope\.mode=local_planning_only/);
2653
+ assert.match(skillText, /manifest_written=false/);
2654
+ assert.match(skillText, /Hugging Face Space Secrets 只能写入和列出名称/);
2655
 
2656
  assert.match(apiReference, /不要手动并行启动多个单张脚本来绕过 `capacity_feedback`/);
2657
  assert.match(apiReference, /streamingBatch\.recommendedConcurrency/);
 
2659
  assert.match(apiReference, /尺寸敏感任务必须使用批量 `--dimension-check` 或下载后校验/);
2660
  assert.match(apiReference, /channel_capacity_queue_aborted/);
2661
  assert.match(apiReference, /npm run env:summary/);
2662
+ assert.match(apiReference, /verification_scope\.mode=local_planning_only/);
2663
+ assert.match(apiReference, /manifest_written=false/);
2664
+ assert.match(apiReference, /Hugging Face Space Secrets 只能写入和列出名称/);
2665
  });
2666
 
2667
  it('keeps WebUI page APIs out of the Agent OpenAPI contract', () => {
 
2738
  assert.match(result.stderr, /用法:generate-image\.mjs/);
2739
  assert.equal(result.stdout.trim(), '');
2740
  assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /ERR_MODULE_NOT_FOUND|src\/lib/);
2741
+
2742
+ const convertHelp = spawnSync(
2743
+ process.execPath,
2744
+ [join(copiedSkillRoot, 'scripts/convert-image-format.mjs'), '--help'],
2745
+ {
2746
+ cwd: tmpdir(),
2747
+ encoding: 'utf8',
2748
+ env: {
2749
+ ...process.env,
2750
+ NODE_PATH: ''
2751
+ }
2752
+ }
2753
+ );
2754
+
2755
+ assert.equal(convertHelp.status, 0);
2756
+ assert.match(convertHelp.stderr, /用法:convert-image-format\.mjs/);
2757
+ assert.equal(convertHelp.stdout.trim(), '');
2758
+ assert.doesNotMatch(`${convertHelp.stdout}\n${convertHelp.stderr}`, /ERR_MODULE_NOT_FOUND|sharp/);
2759
  } finally {
2760
  rmSync(tempRoot, { recursive: true, force: true });
2761
  }
 
2792
  const body = JSON.parse(result.stdout);
2793
  assert.equal(body.dry_run, true);
2794
  assert.equal(body.billable, false);
2795
+ assert.equal(body.verification_scope.mode, 'local_planning_only');
2796
+ assert.equal(body.verification_scope.remote_capabilities_verified, false);
2797
+ assert.equal(body.verification_scope.runtime_capacity_verified, false);
2798
+ assert.equal(body.manifest_written, false);
2799
+ assert.equal(body.manifest_write_reason, 'dry_run');
2800
  assert.equal(body.total, 2);
2801
  assert.equal(body.concurrency, 1);
2802
  assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate');
 
5136
  );
5137
  });
5138
 
5139
+ it('lets diagnose-request explicit base-url override the environment service URL', async () => {
5140
+ await withServer(
5141
+ async (request, response) => {
5142
+ if (request.url === '/api/agent/capabilities') {
5143
+ response.writeHead(200, { 'content-type': 'application/json' });
5144
+ response.end(JSON.stringify({ endpoints: {} }));
5145
+ return;
5146
+ }
5147
+ if (request.url === '/api/agent/diagnostics/requests/req_base_url') {
5148
+ response.writeHead(200, { 'content-type': 'application/json' });
5149
+ response.end(JSON.stringify({ found: false }));
5150
+ return;
5151
+ }
5152
+ response.writeHead(404, { 'content-type': 'application/json' });
5153
+ response.end(JSON.stringify({ error: 'missing' }));
5154
+ },
5155
+ async (baseUrl) => {
5156
+ const result = await runSkillScriptAsync(
5157
+ 'diagnose-request.mjs',
5158
+ ['--base-url', baseUrl, '--agent-request-id', 'req_base_url'],
5159
+ { GPT_IMAGE_PLAYGROUND_URL: 'http://127.0.0.1:9' }
5160
+ );
5161
+
5162
+ assert.equal(result.status, 0);
5163
+ assert.equal(result.stderr.trim(), '');
5164
+ const body = JSON.parse(result.stdout);
5165
+ assert.equal(body.service_base_url, baseUrl);
5166
+ assert.equal(body.service_base_url_source, 'user_provided');
5167
+ assert.equal(body.interactive_confirmation_required, false);
5168
+ assert.equal(body.agent_found, false);
5169
+ }
5170
+ );
5171
+ });
5172
+
5173
  it('reports missing Agent state diagnostics as found false without failing the script', async () => {
5174
  await withServer(
5175
  async (request, response) => {
scripts/cleanup-docker-fixtures.mjs ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
7
+
8
+ const LEGACY_FIXTURE_CONTAINER = 'gipc-local-image-fixture';
9
+ const LEGACY_FIXTURE_DESTINATION = '/workspace';
10
+ const DOCKER_TIMEOUT_MS = 30_000;
11
+ const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
12
+
13
+ export function hasLegacyFixtureRepoMount(mounts, repoRoot = REPO_ROOT) {
14
+ const resolvedRepoRoot = resolve(repoRoot);
15
+ return mounts.some(
16
+ (mount) =>
17
+ mount?.Type === 'bind' &&
18
+ typeof mount.Source === 'string' &&
19
+ resolve(mount.Source) === resolvedRepoRoot &&
20
+ mount.Destination === LEGACY_FIXTURE_DESTINATION
21
+ );
22
+ }
23
+
24
+ export function parseDockerInspectContainer(output) {
25
+ const parsed = JSON.parse(output);
26
+ if (!Array.isArray(parsed) || !parsed[0] || typeof parsed[0] !== 'object') {
27
+ throw new Error('docker inspect did not return a container object');
28
+ }
29
+ return parsed[0];
30
+ }
31
+
32
+ export function summarizeDockerMounts(mounts) {
33
+ return mounts.map((mount) => ({
34
+ type: mount.Type,
35
+ source: mount.Source,
36
+ destination: mount.Destination,
37
+ mode: mount.Mode,
38
+ writable: mount.RW
39
+ }));
40
+ }
41
+
42
+ function parseArgs(argv) {
43
+ const options = {
44
+ dryRun: false,
45
+ help: false,
46
+ containerName: LEGACY_FIXTURE_CONTAINER
47
+ };
48
+ for (let index = 0; index < argv.length; index += 1) {
49
+ const arg = argv[index];
50
+ if (arg === '--dry-run') {
51
+ options.dryRun = true;
52
+ continue;
53
+ }
54
+ if (arg === '--help' || arg === '-h') {
55
+ options.help = true;
56
+ continue;
57
+ }
58
+ if (arg === '--container') {
59
+ const value = argv[index + 1];
60
+ if (!value) throw new Error('--container requires a value');
61
+ options.containerName = value;
62
+ index += 1;
63
+ continue;
64
+ }
65
+ throw new Error(`Unknown option: ${arg}`);
66
+ }
67
+ return options;
68
+ }
69
+
70
+ function printHelp() {
71
+ console.log(`用法:npm run docker:cleanup-fixtures -- [--dry-run] [--container <name>]
72
+
73
+ 清理遗留的本地图片上游 Docker fixture 容器。该脚本只会删除同时满足以下条件的容器:
74
+
75
+ - 容器名匹配,默认 ${LEGACY_FIXTURE_CONTAINER}
76
+ - 存在 ${REPO_ROOT} -> ${LEGACY_FIXTURE_DESTINATION} 的整仓 bind mount
77
+
78
+ 推荐使用 npm run smoke:image-upstream-local 启动本地 fixture。不要用 docker run -v "$PWD:${LEGACY_FIXTURE_DESTINATION}" 启动 fixture。`);
79
+ }
80
+
81
+ function isMissingContainer(result) {
82
+ const output = `${result.stdout}\n${result.stderr}`;
83
+ return /No such object|No such container/i.test(output);
84
+ }
85
+
86
+ export function buildAbsentReport(containerName) {
87
+ return {
88
+ ok: true,
89
+ command: 'docker:cleanup-fixtures',
90
+ container: containerName,
91
+ present: false,
92
+ unsafe_repo_mount: false,
93
+ removed: false
94
+ };
95
+ }
96
+
97
+ export function buildSkippedReport(containerName, mounts) {
98
+ return {
99
+ ok: true,
100
+ command: 'docker:cleanup-fixtures',
101
+ container: containerName,
102
+ present: true,
103
+ unsafe_repo_mount: false,
104
+ removed: false,
105
+ mounts: summarizeDockerMounts(mounts)
106
+ };
107
+ }
108
+
109
+ function cleanupLegacyFixtureContainer(options) {
110
+ const inspect = runCommand('docker', ['inspect', options.containerName], { timeoutMs: DOCKER_TIMEOUT_MS });
111
+ if (!inspect.ok) {
112
+ if (isMissingContainer(inspect)) return buildAbsentReport(options.containerName);
113
+ return {
114
+ ok: false,
115
+ command: 'docker:cleanup-fixtures',
116
+ phase: 'inspect',
117
+ container: options.containerName,
118
+ error: pickFailureOutput(inspect)
119
+ };
120
+ }
121
+
122
+ let container;
123
+ try {
124
+ container = parseDockerInspectContainer(inspect.stdout);
125
+ } catch (error) {
126
+ return {
127
+ ok: false,
128
+ command: 'docker:cleanup-fixtures',
129
+ phase: 'parse-inspect',
130
+ container: options.containerName,
131
+ error: error instanceof Error ? error.message : String(error)
132
+ };
133
+ }
134
+
135
+ const mounts = Array.isArray(container.Mounts) ? container.Mounts : [];
136
+ if (!hasLegacyFixtureRepoMount(mounts, REPO_ROOT)) return buildSkippedReport(options.containerName, mounts);
137
+ if (options.dryRun) {
138
+ return {
139
+ ok: true,
140
+ command: 'docker:cleanup-fixtures',
141
+ container: options.containerName,
142
+ present: true,
143
+ unsafe_repo_mount: true,
144
+ removed: false,
145
+ dry_run: true,
146
+ mounts: summarizeDockerMounts(mounts)
147
+ };
148
+ }
149
+
150
+ const remove = runCommand('docker', ['rm', '-f', options.containerName], { timeoutMs: DOCKER_TIMEOUT_MS });
151
+ return {
152
+ ok: remove.ok,
153
+ command: 'docker:cleanup-fixtures',
154
+ container: options.containerName,
155
+ present: true,
156
+ unsafe_repo_mount: true,
157
+ removed: remove.ok,
158
+ mounts: summarizeDockerMounts(mounts),
159
+ ...(remove.ok ? {} : { phase: 'remove', error: pickFailureOutput(remove) })
160
+ };
161
+ }
162
+
163
+ function main() {
164
+ const options = parseArgs(process.argv.slice(2));
165
+ if (options.help) {
166
+ printHelp();
167
+ return;
168
+ }
169
+ const report = cleanupLegacyFixtureContainer(options);
170
+ printJson(report);
171
+ if (!report.ok) process.exitCode = 1;
172
+ }
173
+
174
+ if (isMainModule(import.meta.url, process.argv[1])) main();
scripts/command-center-utils.mjs CHANGED
@@ -80,6 +80,12 @@ export function parseJsonPayload(output, label = 'command') {
80
  throw new Error(`${label} returned invalid JSON: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
81
  }
82
 
 
 
 
 
 
 
83
  export async function fetchJsonWithTimeout(url, options = {}) {
84
  const timeoutMs = options.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
85
  const pathname = safeUrlPathname(url);
 
80
  throw new Error(`${label} returned invalid JSON: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
81
  }
82
 
83
+ export function redactBaseUrl(value) {
84
+ const parsed = new URL(value);
85
+ const path = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
86
+ return `${parsed.protocol}//${parsed.host}${path}`;
87
+ }
88
+
89
  export async function fetchJsonWithTimeout(url, options = {}) {
90
  const timeoutMs = options.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
91
  const pathname = safeUrlPathname(url);
scripts/command-center.test.mjs CHANGED
@@ -6,8 +6,16 @@ import os from 'node:os';
6
  import path from 'node:path';
7
  import { describe, it } from 'node:test';
8
 
9
- import { buildAgentDoctorArgs } from './agent-doctor.mjs';
10
  import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
 
 
 
 
 
 
 
 
11
  import {
12
  buildAdminCommands,
13
  buildImageUpstreamRealSmokeStatus,
@@ -21,12 +29,14 @@ import { buildVerifyPlan } from './verify.mjs';
21
  describe('Command center scripts', () => {
22
  it('exposes a small stable administrator command set', () => {
23
  assert.deepEqual(buildAdminCommands(), {
 
24
  doctor: 'npm run doctor',
25
  status: 'npm run status',
26
  env_summary: 'npm run env:summary',
27
  verify: 'npm run verify',
28
  deploy_local: 'npm run deploy:local',
29
  deploy_space: 'npm run deploy:space',
 
30
  agent_doctor: 'npm run agent:doctor',
31
  hf_space_doctor: 'npm run doctor:hf-space',
32
  hf_space_smoke: 'npm run smoke:hf-space'
@@ -322,6 +332,77 @@ describe('Command center scripts', () => {
322
  });
323
  });
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  it('fails local memory deploy probes when the overlay did not take effect', () => {
326
  assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
327
  assert.doesNotThrow(() =>
@@ -339,10 +420,188 @@ describe('Command center scripts', () => {
339
  assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']);
340
  });
341
 
342
- it('reports layered agent:doctor diagnostics without billable smoke by default', async () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  await withServer(
344
  (request, response) => {
345
  if (request.url === '/api/agent/capabilities') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  response.writeHead(200, { 'content-type': 'application/json' });
347
  response.end(
348
  JSON.stringify({
@@ -370,7 +629,7 @@ describe('Command center scripts', () => {
370
  );
371
  return;
372
  }
373
- if (request.url === '/api/runtime-capabilities') {
374
  response.writeHead(200, { 'content-type': 'application/json' });
375
  response.end(
376
  JSON.stringify({
@@ -384,7 +643,12 @@ describe('Command center scripts', () => {
384
  );
385
  return;
386
  }
387
- if (request.url === '/api/agent/images/generate') {
 
 
 
 
 
388
  response.writeHead(400, { 'content-type': 'application/json' });
389
  response.end(
390
  JSON.stringify({
@@ -397,7 +661,7 @@ describe('Command center scripts', () => {
397
  );
398
  return;
399
  }
400
- if (request.url === '/api/agent/jobs/images/generate') {
401
  response.writeHead(400, { 'content-type': 'application/json' });
402
  response.end(
403
  JSON.stringify({
@@ -414,8 +678,9 @@ describe('Command center scripts', () => {
414
  response.end(JSON.stringify({ error: 'missing' }));
415
  },
416
  async (baseUrl) => {
 
417
  const result = await runNodeCommandAsync(['scripts/agent-doctor.mjs'], {
418
- env: { ...process.env, GPT_IMAGE_PLAYGROUND_URL: baseUrl },
419
  timeoutMs: 15_000
420
  });
421
 
@@ -430,10 +695,126 @@ describe('Command center scripts', () => {
430
  assert.equal(body.summary.billable_smoke, 'skipped');
431
  assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true);
432
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
 
 
 
433
  }
434
  );
435
  });
436
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  it('preserves raw child output for command consumers', () => {
438
  const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']);
439
 
 
6
  import path from 'node:path';
7
  import { describe, it } from 'node:test';
8
 
9
+ import { buildAgentDoctorArgs, buildAgentDoctorContractArgs } from './agent-doctor.mjs';
10
  import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
11
+ import {
12
+ buildAbsentReport,
13
+ buildSkippedReport,
14
+ hasLegacyFixtureRepoMount,
15
+ parseDockerInspectContainer,
16
+ summarizeDockerMounts
17
+ } from './cleanup-docker-fixtures.mjs';
18
+ import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
19
  import {
20
  buildAdminCommands,
21
  buildImageUpstreamRealSmokeStatus,
 
29
  describe('Command center scripts', () => {
30
  it('exposes a small stable administrator command set', () => {
31
  assert.deepEqual(buildAdminCommands(), {
32
+ first_run: 'npm run first-run',
33
  doctor: 'npm run doctor',
34
  status: 'npm run status',
35
  env_summary: 'npm run env:summary',
36
  verify: 'npm run verify',
37
  deploy_local: 'npm run deploy:local',
38
  deploy_space: 'npm run deploy:space',
39
+ docker_cleanup_fixtures: 'npm run docker:cleanup-fixtures',
40
  agent_doctor: 'npm run agent:doctor',
41
  hf_space_doctor: 'npm run doctor:hf-space',
42
  hf_space_smoke: 'npm run smoke:hf-space'
 
332
  });
333
  });
334
 
335
+ it('detects only the legacy fixture whole-repository Docker mount', () => {
336
+ const repoRoot = '/Volumes/Work/code/gpt-image-playground-customer';
337
+ assert.equal(
338
+ hasLegacyFixtureRepoMount(
339
+ [
340
+ {
341
+ Type: 'bind',
342
+ Source: repoRoot,
343
+ Destination: '/workspace',
344
+ Mode: 'ro',
345
+ RW: false
346
+ }
347
+ ],
348
+ repoRoot
349
+ ),
350
+ true
351
+ );
352
+ assert.equal(
353
+ hasLegacyFixtureRepoMount(
354
+ [
355
+ {
356
+ Type: 'bind',
357
+ Source: `${repoRoot}/generated-images`,
358
+ Destination: '/app/generated-images',
359
+ Mode: '',
360
+ RW: true
361
+ }
362
+ ],
363
+ repoRoot
364
+ ),
365
+ false
366
+ );
367
+ });
368
+
369
+ it('parses and summarizes Docker fixture cleanup inspection data', () => {
370
+ const container = parseDockerInspectContainer(
371
+ JSON.stringify([
372
+ {
373
+ Mounts: [
374
+ {
375
+ Type: 'bind',
376
+ Source: '/repo',
377
+ Destination: '/workspace',
378
+ Mode: 'ro',
379
+ RW: false
380
+ }
381
+ ]
382
+ }
383
+ ])
384
+ );
385
+
386
+ assert.deepEqual(summarizeDockerMounts(container.Mounts), [
387
+ {
388
+ type: 'bind',
389
+ source: '/repo',
390
+ destination: '/workspace',
391
+ mode: 'ro',
392
+ writable: false
393
+ }
394
+ ]);
395
+ assert.deepEqual(buildAbsentReport('gipc-local-image-fixture'), {
396
+ ok: true,
397
+ command: 'docker:cleanup-fixtures',
398
+ container: 'gipc-local-image-fixture',
399
+ present: false,
400
+ unsafe_repo_mount: false,
401
+ removed: false
402
+ });
403
+ assert.equal(buildSkippedReport('custom', container.Mounts).removed, false);
404
+ });
405
+
406
  it('fails local memory deploy probes when the overlay did not take effect', () => {
407
  assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
408
  assert.doesNotThrow(() =>
 
420
  assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']);
421
  });
422
 
423
+ it('routes agent:doctor contract check through an explicit base URL', () => {
424
+ const args = buildAgentDoctorContractArgs('https://space.example.test/');
425
+ assert.match(args[0], /generate-image\.mjs$/);
426
+ assert.deepEqual(args.slice(1), [
427
+ '--contract-check',
428
+ '--timeout-ms',
429
+ '60000',
430
+ '--base-url',
431
+ 'https://space.example.test/',
432
+ 'contract check'
433
+ ]);
434
+ });
435
+
436
+ it('reports first-run readiness without exposing secrets', async () => {
437
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'first-run-'));
438
+ await writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }));
439
+ await writeFile(path.join(tempDir, 'package-lock.json'), '{}');
440
+ await writeFile(path.join(tempDir, '.env.agent.local'), 'GPT_IMAGE_AGENT_TOKEN=file-secret\n');
441
  await withServer(
442
  (request, response) => {
443
  if (request.url === '/api/agent/capabilities') {
444
+ assert.equal(request.headers.authorization, 'Bearer shell-secret');
445
+ response.writeHead(200, { 'content-type': 'application/json' });
446
+ response.end(
447
+ JSON.stringify({
448
+ auth: { schemes: ['bearer'] },
449
+ defaults: { state_backend: 'memory' },
450
+ supported: { image_backend_requirements: {} }
451
+ })
452
+ );
453
+ return;
454
+ }
455
+ if (request.url === '/api/runtime-capabilities') {
456
+ response.writeHead(200, { 'content-type': 'application/json' });
457
+ response.end(JSON.stringify({ streaming: { defaultMode: 'auto' } }));
458
+ return;
459
+ }
460
+ response.writeHead(404, { 'content-type': 'application/json' });
461
+ response.end(JSON.stringify({ error: 'missing' }));
462
+ },
463
+ async (baseUrl) => {
464
+ try {
465
+ const report = await buildFirstRunReport(
466
+ {
467
+ cwd: tempDir,
468
+ baseUrl,
469
+ envFiles: ['.env.agent.local'],
470
+ timeoutMs: 1000
471
+ },
472
+ { GPT_IMAGE_AGENT_TOKEN: 'shell-secret' }
473
+ );
474
+
475
+ assert.equal(report.command, 'first-run');
476
+ assert.equal(report.billable, false);
477
+ assert.equal(report.service_base_url, baseUrl);
478
+ assert.equal(report.service_base_url_source, 'user_provided');
479
+ assert.equal(report.interactive_confirmation_required, false);
480
+ assert.deepEqual(report.agent_auth_process, {
481
+ has_token: true,
482
+ has_password_hash: false,
483
+ has_any_auth: true
484
+ });
485
+ assert.deepEqual(report.private_agent_env, {
486
+ exists: true,
487
+ has_token: true,
488
+ has_password_hash: false
489
+ });
490
+ assert.equal(report.service.ok, true);
491
+ assert.equal(report.checks.find((check) => check.name === 'agent_auth_available_to_process').ok, true);
492
+ assert.equal(
493
+ report.checks.find((check) => check.name === 'agent_auth_available_to_process')
494
+ .auth_in_private_env_file,
495
+ true
496
+ );
497
+ assert.doesNotMatch(JSON.stringify(report), /shell-secret|file-secret/);
498
+ assert.match(JSON.stringify(report.next_actions), /agent:doctor/);
499
+ } finally {
500
+ await rm(tempDir, { recursive: true, force: true });
501
+ }
502
+ }
503
+ );
504
+ });
505
+
506
+ it('formats first-run as human-readable text by default', async () => {
507
+ const report = await buildFirstRunReport({ cwd: process.cwd(), baseUrl: 'not a url', envFiles: [] }, {});
508
+ const text = formatFirstRunText(report);
509
+
510
+ assert.match(text, /^首次配置检查:需要处理/m);
511
+ assert.match(text, /服务地址:无效/);
512
+ assert.match(text, /当前进程鉴权:未加载/);
513
+ assert.match(text, /私有 Agent env:不存在/);
514
+ assert.match(text, /下一步:/);
515
+ assert.doesNotMatch(text, /^\{/);
516
+ });
517
+
518
+ it('preserves path-prefixed service URLs in first-run reports', async () => {
519
+ // This test only checks local URL normalization in the report; .test is reserved and should not resolve.
520
+ const report = await buildFirstRunReport(
521
+ {
522
+ cwd: process.cwd(),
523
+ baseUrl: 'https://space.example.test/proxy/',
524
+ envFiles: [],
525
+ timeoutMs: 5
526
+ },
527
+ {}
528
+ );
529
+
530
+ assert.equal(report.service_base_url, 'https://space.example.test/proxy');
531
+ });
532
+
533
+ it('prints localized first-run help and option errors', async () => {
534
+ const help = await runNodeCommandAsync(['scripts/first-run.mjs', '--help'], {
535
+ env: process.env,
536
+ timeoutMs: 5_000
537
+ });
538
+ assert.equal(help.ok, true);
539
+ assert.match(help.stdout, /用法:/);
540
+ assert.match(help.stdout, /输出机器可读 JSON/);
541
+ assert.doesNotMatch(help.stdout, /Usage:/);
542
+
543
+ const invalid = await runNodeCommandAsync(['scripts/first-run.mjs', '--missing-option'], {
544
+ env: process.env,
545
+ timeoutMs: 5_000
546
+ });
547
+ assert.equal(invalid.ok, false);
548
+ assert.match(invalid.stdout, /"ok": false/);
549
+ assert.match(invalid.stdout, /未知参数:--missing-option/);
550
+ });
551
+
552
+ it('keeps first-run local discovery explicit when no URL is provided', async () => {
553
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'first-run-default-'));
554
+ try {
555
+ await writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }));
556
+
557
+ const report = await buildFirstRunReport({ cwd: tempDir, envFiles: [], timeoutMs: 20 }, {});
558
+
559
+ assert.equal(report.service_base_url_source, 'default_local_probe');
560
+ assert.equal(report.interactive_confirmation_required, true);
561
+ assert.equal(report.ok, false);
562
+ assert.match(JSON.stringify(report.next_actions), /确认探测到的服务地址/);
563
+ } finally {
564
+ await rm(tempDir, { recursive: true, force: true });
565
+ }
566
+ });
567
+
568
+ it('points first-run at auth setup when the service rejects capabilities with 401', async () => {
569
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'first-run-auth-'));
570
+ await writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }));
571
+ await withServer(
572
+ (_request, response) => {
573
+ response.writeHead(401, { 'content-type': 'application/json' });
574
+ response.end(JSON.stringify({ error: 'unauthorized' }));
575
+ },
576
+ async (baseUrl) => {
577
+ try {
578
+ const report = await buildFirstRunReport(
579
+ {
580
+ cwd: tempDir,
581
+ baseUrl: `${baseUrl}/`,
582
+ envFiles: [],
583
+ timeoutMs: 1000
584
+ },
585
+ {}
586
+ );
587
+
588
+ assert.equal(report.service_base_url, baseUrl);
589
+ assert.equal(report.checks.find((check) => check.name === 'agent_auth_available_to_process').skipped, false);
590
+ assert.equal(report.checks.find((check) => check.name === 'service_reachable').status, 401);
591
+ assert.equal('runtime' in report.service, false);
592
+ assert.match(JSON.stringify(report.next_actions), /GPT_IMAGE_AGENT_TOKEN/);
593
+ assert.doesNotMatch(JSON.stringify(report.next_actions), /Start the service/);
594
+ } finally {
595
+ await rm(tempDir, { recursive: true, force: true });
596
+ }
597
+ }
598
+ );
599
+ });
600
+
601
+ it('reports layered agent:doctor diagnostics without billable smoke by default', async () => {
602
+ await withServer(
603
+ (request, response) => {
604
+ if (request.url === '/proxy/api/agent/capabilities') {
605
  response.writeHead(200, { 'content-type': 'application/json' });
606
  response.end(
607
  JSON.stringify({
 
629
  );
630
  return;
631
  }
632
+ if (request.url === '/proxy/api/runtime-capabilities') {
633
  response.writeHead(200, { 'content-type': 'application/json' });
634
  response.end(
635
  JSON.stringify({
 
643
  );
644
  return;
645
  }
646
+ if (request.url === '/proxy/api/agent/images/generate') {
647
+ if (request.headers['idempotency-key']) {
648
+ response.writeHead(200, { 'content-type': 'application/json' });
649
+ response.end(JSON.stringify({ images: [{ id: 'doctor-generate', filename: 'doctor.png' }] }));
650
+ return;
651
+ }
652
  response.writeHead(400, { 'content-type': 'application/json' });
653
  response.end(
654
  JSON.stringify({
 
661
  );
662
  return;
663
  }
664
+ if (request.url === '/proxy/api/agent/jobs/images/generate') {
665
  response.writeHead(400, { 'content-type': 'application/json' });
666
  response.end(
667
  JSON.stringify({
 
678
  response.end(JSON.stringify({ error: 'missing' }));
679
  },
680
  async (baseUrl) => {
681
+ const prefixedBaseUrl = `${baseUrl}/proxy`;
682
  const result = await runNodeCommandAsync(['scripts/agent-doctor.mjs'], {
683
+ env: { ...process.env, GPT_IMAGE_PLAYGROUND_URL: `${prefixedBaseUrl}/` },
684
  timeoutMs: 15_000
685
  });
686
 
 
695
  assert.equal(body.summary.billable_smoke, 'skipped');
696
  assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true);
697
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
698
+ assert.equal(body.service_base_url, prefixedBaseUrl);
699
+ assert.equal(body.service_base_url_source, 'GPT_IMAGE_PLAYGROUND_URL');
700
+ assert.equal(body.interactive_confirmation_required, true);
701
  }
702
  );
703
  });
704
 
705
+ it('uses an explicit base URL for agent:doctor billable smoke checks', async () => {
706
+ const hits = [];
707
+ await withServer(
708
+ (request, response) => {
709
+ hits.push(request.url);
710
+ if (request.url === '/api/agent/capabilities') {
711
+ response.writeHead(200, { 'content-type': 'application/json' });
712
+ response.end(
713
+ JSON.stringify({
714
+ defaults: { state_backend: 'memory' },
715
+ storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
716
+ agent_streaming: { page_sse: { supported: true } },
717
+ agent_jobs: { supported: true },
718
+ routing_rules: {
719
+ high_resolution_edit: {
720
+ conditions: { operation: 'edit', max_edge: { operator: 'gt', value: 2048 } }
721
+ }
722
+ },
723
+ supported: {
724
+ image_backend_requirements: {
725
+ 'responses-image-generation': {
726
+ supported: true,
727
+ enabled: true,
728
+ missing_env: []
729
+ }
730
+ }
731
+ }
732
+ })
733
+ );
734
+ return;
735
+ }
736
+ if (request.url === '/api/runtime-capabilities') {
737
+ response.writeHead(200, { 'content-type': 'application/json' });
738
+ response.end(
739
+ JSON.stringify({
740
+ streaming: {
741
+ defaultMode: 'auto',
742
+ unavailableMarkScope: 'channel+backend+strategy+operation'
743
+ },
744
+ streamingBatch: { enabled: true },
745
+ responsesImageBackend: { enabled: true, mode: 'experimental' }
746
+ })
747
+ );
748
+ return;
749
+ }
750
+ if (request.url === '/api/agent/images/generate') {
751
+ if (request.headers['idempotency-key']) {
752
+ response.writeHead(200, { 'content-type': 'application/json' });
753
+ response.end(JSON.stringify({ images: [{ id: 'doctor-generate', filename: 'doctor.png' }] }));
754
+ } else {
755
+ response.writeHead(400, { 'content-type': 'application/json' });
756
+ response.end(
757
+ JSON.stringify({
758
+ error: {
759
+ code: 'idempotency_key_required',
760
+ message: 'missing key',
761
+ retryable: false
762
+ }
763
+ })
764
+ );
765
+ }
766
+ return;
767
+ }
768
+ if (request.url === '/api/agent/jobs/images/generate') {
769
+ response.writeHead(400, { 'content-type': 'application/json' });
770
+ response.end(
771
+ JSON.stringify({
772
+ error: {
773
+ code: 'idempotency_key_required',
774
+ message: 'missing key',
775
+ retryable: false
776
+ }
777
+ })
778
+ );
779
+ return;
780
+ }
781
+ response.writeHead(404, { 'content-type': 'application/json' });
782
+ response.end(JSON.stringify({ error: 'missing' }));
783
+ },
784
+ async (baseUrl) => {
785
+ const result = await runNodeCommandAsync(
786
+ ['scripts/agent-doctor.mjs', '--base-url', baseUrl, '--allow-billable'],
787
+ { timeoutMs: 15_000 }
788
+ );
789
+
790
+ assert.equal(result.ok, true);
791
+ const body = parseJsonPayload(result.stdout, 'agent doctor');
792
+ assert.equal(body.service_base_url_source, 'user_provided');
793
+ assert.equal(body.interactive_confirmation_required, false);
794
+ assert.ok(hits.includes('/api/agent/images/generate'));
795
+ }
796
+ );
797
+ });
798
+
799
+ it('prints localized agent:doctor help and option errors', async () => {
800
+ const help = await runNodeCommandAsync(['scripts/agent-doctor.mjs', '--help'], {
801
+ env: process.env,
802
+ timeoutMs: 5_000
803
+ });
804
+ assert.equal(help.ok, true);
805
+ assert.match(help.stdout, /用法:/);
806
+ assert.match(help.stdout, /真实 generate\/edit smoke/);
807
+ assert.doesNotMatch(help.stdout, /Usage:/);
808
+
809
+ const invalid = await runNodeCommandAsync(['scripts/agent-doctor.mjs', '--bad-option'], {
810
+ env: process.env,
811
+ timeoutMs: 5_000
812
+ });
813
+ assert.equal(invalid.ok, false);
814
+ assert.match(invalid.stdout, /"ok": false/);
815
+ assert.match(invalid.stdout, /未知参数:--bad-option/);
816
+ });
817
+
818
  it('preserves raw child output for command consumers', () => {
819
  const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']);
820
 
scripts/first-run.mjs ADDED
@@ -0,0 +1,504 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ import { isMainModule, printJson, redactBaseUrl } from './command-center-utils.mjs';
7
+ import { summarizeEnvFile } from './env-summary.mjs';
8
+ import { resolvePlaygroundBaseUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
9
+
10
+ const DEFAULT_TIMEOUT_MS = 3000;
11
+ const DEFAULT_ENV_FILES = ['.env.local', '.env.agent.local'];
12
+
13
+ function parseArgs(argv) {
14
+ const parsed = {
15
+ help: false,
16
+ json: false,
17
+ baseUrl: undefined,
18
+ timeoutMs: DEFAULT_TIMEOUT_MS
19
+ };
20
+ for (let index = 0; index < argv.length; index += 1) {
21
+ const arg = argv[index];
22
+ if (arg === '--help' || arg === '-h') parsed.help = true;
23
+ else if (arg === '--json') parsed.json = true;
24
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
25
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), arg);
26
+ else throw new Error(`未知参数:${arg}`);
27
+ }
28
+ return parsed;
29
+ }
30
+
31
+ function printHelp() {
32
+ console.log(`用法:
33
+ npm run first-run
34
+ npm run first-run -- --json
35
+ npm run first-run -- --base-url https://your-space.hf.space
36
+
37
+ 选项:
38
+ --json 输出机器可读 JSON。
39
+ --base-url 显式服务地址,优先于 GPT_IMAGE_PLAYGROUND_URL 和本地探测。
40
+ --timeout-ms HTTP 探测超时,默认 3000。
41
+
42
+ first-run 只读、非计费,不写 env 文件或 secret。`);
43
+ }
44
+
45
+ export async function buildFirstRunReport(options = {}, env = process.env) {
46
+ const cwd = options.cwd || process.cwd();
47
+ const envFiles = options.envFiles || DEFAULT_ENV_FILES;
48
+ const base = resolveFirstRunBaseUrl(options.baseUrl, env);
49
+ const envSummary = envFiles.map((filePath) => summarizeEnvFile(join(cwd, filePath)));
50
+ const validationError = readBaseUrlValidationError(base);
51
+ const service = validationError
52
+ ? { ok: false, skipped: true, error: validationError }
53
+ : await probeService(base.baseUrl, {
54
+ timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
55
+ headers: authHeaders(env)
56
+ });
57
+ const agentAuth = readAgentAuthState(env, envSummary);
58
+ const checks = buildChecks({
59
+ cwd,
60
+ env,
61
+ envSummary,
62
+ service,
63
+ base,
64
+ validationError
65
+ });
66
+ const nextActions = buildNextActions({ checks, base, service, env, envSummary, validationError });
67
+ return {
68
+ ok: checks.every((check) => check.ok || check.skipped),
69
+ command: 'first-run',
70
+ billable: false,
71
+ service_base_url: validationError ? undefined : redactBaseUrl(base.baseUrl),
72
+ service_base_url_source: base.source,
73
+ interactive_confirmation_required: base.interactive_confirmation_required,
74
+ agent_auth_process: agentAuth.process,
75
+ private_agent_env: agentAuth.privateEnv,
76
+ checks,
77
+ env_sources: envSummary.map((source) => summarizeEnvSource(source, cwd)),
78
+ service: summarizeService(service),
79
+ next_actions: nextActions
80
+ };
81
+ }
82
+
83
+ async function probeService(baseUrl, options) {
84
+ const capabilities = await readJsonEndpoint(`${baseUrl}/api/agent/capabilities`, options);
85
+ const runtime = capabilities.ok ? await readJsonEndpoint(`${baseUrl}/api/runtime-capabilities`, options) : undefined;
86
+ return {
87
+ ok: capabilities.ok && (!runtime || runtime.ok),
88
+ capabilities,
89
+ ...(runtime ? { runtime } : {})
90
+ };
91
+ }
92
+
93
+ async function readJsonEndpoint(url, options) {
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
96
+ try {
97
+ const response = await fetch(url, { headers: options.headers, signal: controller.signal });
98
+ const text = await response.text();
99
+ if (!response.ok) {
100
+ return {
101
+ ok: false,
102
+ status: response.status,
103
+ endpoint: safeEndpoint(url),
104
+ error: `HTTP ${response.status}`
105
+ };
106
+ }
107
+ try {
108
+ return {
109
+ ok: true,
110
+ status: response.status,
111
+ endpoint: safeEndpoint(url),
112
+ body: JSON.parse(text)
113
+ };
114
+ } catch {
115
+ return {
116
+ ok: false,
117
+ status: response.status,
118
+ endpoint: safeEndpoint(url),
119
+ error: 'response_not_json'
120
+ };
121
+ }
122
+ } catch (error) {
123
+ return {
124
+ ok: false,
125
+ endpoint: safeEndpoint(url),
126
+ error: error?.name === 'AbortError' ? `timeout_after_${options.timeoutMs}ms` : 'request_failed'
127
+ };
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+
133
+ function buildChecks({ cwd, env, envSummary, service, base, validationError }) {
134
+ const node = readNodeCheck();
135
+ const packageJson = readPackageJson(cwd);
136
+ const packageLock = existsSync(join(cwd, 'package-lock.json'));
137
+ const nodeModules = existsSync(join(cwd, 'node_modules'));
138
+ const currentToken = Boolean(env.GPT_IMAGE_AGENT_TOKEN || env.GPT_IMAGE_APP_PASSWORD_HASH);
139
+ const fileToken = envSummary.some((source) =>
140
+ ['GPT_IMAGE_AGENT_TOKEN', 'GPT_IMAGE_APP_PASSWORD_HASH'].some((name) => sourceHasSetVariable(source, name))
141
+ );
142
+ return [
143
+ { name: 'node_version', ok: node.ok, current: node.current, required: '>=20.0.0' },
144
+ { name: 'package_lock', ok: packageLock },
145
+ {
146
+ name: 'dependencies_installed',
147
+ ok: nodeModules,
148
+ skipped: false,
149
+ hint: nodeModules ? undefined : 'run npm install'
150
+ },
151
+ {
152
+ name: 'env_files',
153
+ ok: envSummary.some((source) => source.exists),
154
+ files: envSummary.map((source) => ({ path: source.path.replace(`${cwd}/`, ''), exists: source.exists }))
155
+ },
156
+ {
157
+ name: 'agent_auth_available_to_process',
158
+ ok: currentToken,
159
+ skipped: !requiresAgentAuth(service),
160
+ auth_in_private_env_file: fileToken
161
+ },
162
+ {
163
+ name: 'service_base_url_valid',
164
+ ok: !validationError,
165
+ source: base.source,
166
+ ...(validationError ? { error: validationError } : {})
167
+ },
168
+ {
169
+ name: 'service_reachable',
170
+ ok: service.ok,
171
+ endpoint: '/api/agent/capabilities',
172
+ ...(service.capabilities?.status ? { status: service.capabilities.status } : {}),
173
+ ...(service.capabilities?.error ? { error: service.capabilities.error } : {})
174
+ },
175
+ {
176
+ name: 'agent_capabilities_contract',
177
+ ok: isCapabilitiesBody(service.capabilities?.body),
178
+ skipped: !service.capabilities?.ok
179
+ },
180
+ {
181
+ name: 'runtime_capabilities_contract',
182
+ ok: Boolean(service.runtime?.ok),
183
+ skipped: !service.capabilities?.ok
184
+ },
185
+ {
186
+ name: 'package_metadata',
187
+ ok: Boolean(packageJson.name && packageJson.version),
188
+ package: packageJson.name,
189
+ version: packageJson.version
190
+ }
191
+ ];
192
+ }
193
+
194
+ function readAgentAuthState(env, envSummary) {
195
+ const hasToken = Boolean(env.GPT_IMAGE_AGENT_TOKEN);
196
+ const hasPasswordHash = Boolean(env.GPT_IMAGE_APP_PASSWORD_HASH);
197
+ const privateSource = envSummary.find((source) => source.path.endsWith('.env.agent.local'));
198
+ return {
199
+ process: {
200
+ has_token: hasToken,
201
+ has_password_hash: hasPasswordHash,
202
+ has_any_auth: hasToken || hasPasswordHash
203
+ },
204
+ privateEnv: {
205
+ exists: privateSource?.exists === true,
206
+ has_token: privateSource ? sourceHasSetVariable(privateSource, 'GPT_IMAGE_AGENT_TOKEN') : false,
207
+ has_password_hash: privateSource ? sourceHasSetVariable(privateSource, 'GPT_IMAGE_APP_PASSWORD_HASH') : false
208
+ }
209
+ };
210
+ }
211
+
212
+ function buildNextActions({ checks, base, service, env, envSummary, validationError }) {
213
+ const actions = [];
214
+ const hasCurrentAuth = Boolean(env.GPT_IMAGE_AGENT_TOKEN || env.GPT_IMAGE_APP_PASSWORD_HASH);
215
+ const hasFileAuth = envSummary.some((source) =>
216
+ ['GPT_IMAGE_AGENT_TOKEN', 'GPT_IMAGE_APP_PASSWORD_HASH'].some((name) => sourceHasSetVariable(source, name))
217
+ );
218
+ if (!findCheck(checks, 'node_version').ok) actions.push('安装 Node.js 20 或更新版本。');
219
+ if (!findCheck(checks, 'dependencies_installed').ok) actions.push('运行 npm install。');
220
+ if (!findCheck(checks, 'env_files').ok) {
221
+ actions.push('复制 .env.example 为 .env.local,或在页面设置里配置默认上游。');
222
+ }
223
+ if (validationError) {
224
+ actions.push('把 GPT_IMAGE_PLAYGROUND_URL 或 --base-url 设为不含凭据、查询参数或片段的 http/https 地址。');
225
+ return actions;
226
+ }
227
+ if (!hasCurrentAuth && requiresAgentAuth(service) && !hasFileAuth) {
228
+ actions.push(
229
+ '在仓库外导出 GPT_IMAGE_AGENT_TOKEN 或 GPT_IMAGE_APP_PASSWORD_HASH,再运行受保护的 Agent 脚本。'
230
+ );
231
+ }
232
+ if (!service.ok && !requiresAgentAuth(service)) {
233
+ actions.push('先用 npm run dev 或 docker compose up -d --build --remove-orphans 启动服务,再重新运行 npm run first-run。');
234
+ }
235
+ if (base.interactive_confirmation_required) {
236
+ actions.push('在交互式 Agent 任务里,先和用户确认探测到的服务地址,再发真实请求。');
237
+ }
238
+ if (!hasCurrentAuth && hasFileAuth) {
239
+ actions.push('先把私有 Agent env 文件加载到 shell,再运行 Agent 脚本。');
240
+ }
241
+ if (service.ok && hasCurrentAuth) {
242
+ actions.push('运行 npm run agent:doctor 做完整的非计费 Agent 合同检查。');
243
+ }
244
+ return actions;
245
+ }
246
+
247
+ function requiresAgentAuth(service) {
248
+ if (service.capabilities?.status === 401 || service.capabilities?.status === 403) return true;
249
+ const schemes = service.capabilities?.body?.auth?.schemes;
250
+ return Array.isArray(schemes) && schemes.length > 0;
251
+ }
252
+
253
+ function sourceHasSetVariable(source, name) {
254
+ return source.exists && source.variables.some((item) => item.name === name && item.set === true);
255
+ }
256
+
257
+ function summarizeEnvSource(source, cwd) {
258
+ return {
259
+ path: source.path.startsWith(`${cwd}/`) ? source.path.slice(cwd.length + 1) : source.path,
260
+ exists: source.exists,
261
+ variable_count: source.variable_count || 0,
262
+ configured: source.variables
263
+ .filter((item) => item.set)
264
+ .map((item) => ({
265
+ name: item.name,
266
+ sensitive: item.sensitive,
267
+ value_kind: item.value_kind
268
+ }))
269
+ };
270
+ }
271
+
272
+ function summarizeService(service) {
273
+ const capabilities = service.capabilities || {};
274
+ const runtime = service.runtime;
275
+ return {
276
+ ok: service.ok,
277
+ capabilities: {
278
+ ok: capabilities.ok === true,
279
+ endpoint: capabilities.endpoint,
280
+ ...(capabilities.status ? { status: capabilities.status } : {}),
281
+ ...(capabilities.error ? { error: capabilities.error } : {}),
282
+ ...(capabilities.ok ? summarizeCapabilitiesBody(capabilities.body) : {})
283
+ },
284
+ ...(runtime
285
+ ? {
286
+ runtime: {
287
+ ok: runtime.ok === true,
288
+ endpoint: runtime.endpoint,
289
+ ...(runtime.status ? { status: runtime.status } : {}),
290
+ ...(runtime.error ? { error: runtime.error } : {}),
291
+ ...(runtime.ok ? summarizeRuntimeBody(runtime.body) : {})
292
+ }
293
+ }
294
+ : {})
295
+ };
296
+ }
297
+
298
+ function summarizeCapabilitiesBody(body) {
299
+ return {
300
+ auth_required: body?.auth?.required === true,
301
+ auth_schemes: Array.isArray(body?.auth?.schemes) ? body.auth.schemes : [],
302
+ state_backend: body?.defaults?.state_backend,
303
+ image_storage_mode: body?.storage?.image_storage_mode,
304
+ agent_jobs_supported: body?.agent_jobs?.supported === true,
305
+ page_sse_supported: body?.agent_streaming?.page_sse?.supported === true,
306
+ responses_image_backend_enabled:
307
+ body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true
308
+ };
309
+ }
310
+
311
+ function summarizeRuntimeBody(body) {
312
+ return {
313
+ default_streaming_strategy: body?.streaming?.defaultStrategy,
314
+ streaming_batch_enabled: body?.streamingBatch?.enabled === true,
315
+ recommended_concurrency: body?.streamingBatch?.recommendedConcurrency,
316
+ channel_capacity_per_credential: body?.channelQueue?.capacityPerCredential,
317
+ responses_image_backend_enabled: body?.responsesImageBackend?.enabled === true
318
+ };
319
+ }
320
+
321
+ function findCheck(checks, name) {
322
+ return checks.find((check) => check.name === name) || { ok: false };
323
+ }
324
+
325
+ function readNodeCheck() {
326
+ const match = process.version.match(/^v(\d+)\./);
327
+ const major = match ? Number(match[1]) : 0;
328
+ return { ok: major >= 20, current: process.version };
329
+ }
330
+
331
+ function readPackageJson(cwd) {
332
+ try {
333
+ return JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
334
+ } catch {
335
+ return {};
336
+ }
337
+ }
338
+
339
+ function isCapabilitiesBody(body) {
340
+ return Boolean(body && typeof body === 'object' && body.supported && body.defaults);
341
+ }
342
+
343
+ function authHeaders(env) {
344
+ if (env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${env.GPT_IMAGE_AGENT_TOKEN}` };
345
+ if (env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': env.GPT_IMAGE_APP_PASSWORD_HASH };
346
+ return {};
347
+ }
348
+
349
+ function readBaseUrlValidationError(base) {
350
+ return base.error;
351
+ }
352
+
353
+ function resolveFirstRunBaseUrl(explicitBaseUrl, env) {
354
+ try {
355
+ return resolvePlaygroundBaseUrl(explicitBaseUrl, env);
356
+ } catch (error) {
357
+ return {
358
+ baseUrl: '',
359
+ source: explicitBaseUrl ? 'user_provided' : env.GPT_IMAGE_PLAYGROUND_URL ? 'GPT_IMAGE_PLAYGROUND_URL' : 'default_local_probe',
360
+ interactive_confirmation_required: explicitBaseUrl ? false : true,
361
+ error: error instanceof Error ? error.message : String(error)
362
+ };
363
+ }
364
+ }
365
+
366
+ export function formatFirstRunText(report) {
367
+ const lines = [];
368
+ lines.push(`首次配置检查:${report.ok ? '通过' : '需要处理'}`);
369
+ if (report.service_base_url) {
370
+ lines.push(`服务地址:${report.service_base_url}(${formatServiceSource(report.service_base_url_source)})`);
371
+ } else {
372
+ lines.push(`服务地址:无效(${readCheckError(report, 'service_base_url_valid') || 'unknown'})`);
373
+ }
374
+ lines.push(`交互式确认:${report.interactive_confirmation_required ? '需要' : '不需要'}`);
375
+ lines.push(`当前进程鉴权:${formatAuthState(report.agent_auth_process)}`);
376
+ lines.push(`私有 Agent env:${formatPrivateEnvState(report.private_agent_env)}`);
377
+ lines.push('');
378
+ lines.push('检查项:');
379
+ for (const check of report.checks) {
380
+ lines.push(`- ${formatCheckLabel(check.name)}:${formatCheckStatus(check)}`);
381
+ }
382
+ lines.push('');
383
+ lines.push('服务摘要:');
384
+ lines.push(`- 能力:${formatEndpoint(report.service?.capabilities)}`);
385
+ lines.push(`- 运行时:${formatEndpoint(report.service?.runtime)}`);
386
+ const capability = report.service?.capabilities || {};
387
+ if (capability.ok) {
388
+ lines.push(`- 鉴权:${capability.auth_required ? capability.auth_schemes.join(',') || '需要' : '不需要'}`);
389
+ lines.push(`- 状态后端:${capability.state_backend || '未知'},图片存储:${capability.image_storage_mode || '未知'}`);
390
+ }
391
+ const runtime = report.service?.runtime || {};
392
+ if (runtime.ok) {
393
+ lines.push(
394
+ `- 并发建议:推荐=${runtime.recommended_concurrency ?? '未知'},每凭证容量=${runtime.channel_capacity_per_credential ?? '未知'}`
395
+ );
396
+ }
397
+ lines.push('');
398
+ lines.push('下一步:');
399
+ if (report.next_actions.length === 0) {
400
+ lines.push('- 无');
401
+ } else {
402
+ for (const action of report.next_actions) lines.push(`- ${action}`);
403
+ }
404
+ return `${lines.join('\n')}\n`;
405
+ }
406
+
407
+ function formatCheckStatus(check) {
408
+ if (check.skipped) return '已跳过';
409
+ if (check.ok) return '通过';
410
+ if (check.status) return `失败(${check.status})`;
411
+ if (check.error) return `失败(${check.error})`;
412
+ return '失败';
413
+ }
414
+
415
+ function formatEndpoint(endpoint) {
416
+ if (!endpoint) return '已跳过';
417
+ if (endpoint.ok) return `通过 ${endpoint.status || ''}`.trim();
418
+ return `失败${endpoint.status ? ` ${endpoint.status}` : ''}${endpoint.error ? ` ${endpoint.error}` : ''}`;
419
+ }
420
+
421
+ function formatAuthState(auth) {
422
+ if (!auth) return '未知';
423
+ if (auth.has_token) return '已加载 token';
424
+ if (auth.has_password_hash) return '已加载访问码哈希';
425
+ return '未加载';
426
+ }
427
+
428
+ function formatPrivateEnvState(privateEnv) {
429
+ if (!privateEnv?.exists) return '不存在';
430
+ if (privateEnv.has_token) return '存在,含 token';
431
+ if (privateEnv.has_password_hash) return '存在,含访问码哈希';
432
+ return '存在,未发现 Agent 鉴权变量';
433
+ }
434
+
435
+ function readCheckError(report, name) {
436
+ const check = report.checks.find((item) => item.name === name);
437
+ return check?.error;
438
+ }
439
+
440
+ function formatServiceSource(source) {
441
+ if (source === 'user_provided') return '用户提供';
442
+ if (source === 'GPT_IMAGE_PLAYGROUND_URL') return '环境变量 GPT_IMAGE_PLAYGROUND_URL';
443
+ if (source === 'default_local_probe') return '默认本地探测';
444
+ return source || '未知来源';
445
+ }
446
+
447
+ function formatCheckLabel(name) {
448
+ const labels = {
449
+ node_version: 'Node.js 版本',
450
+ package_lock: 'package-lock.json',
451
+ dependencies_installed: '依赖是否已安装',
452
+ env_files: '环境文件',
453
+ agent_auth_available_to_process: 'Agent 鉴权可用',
454
+ service_base_url_valid: '服务地址合法',
455
+ service_reachable: '服务可达',
456
+ agent_capabilities_contract: 'Agent capabilities 合同',
457
+ runtime_capabilities_contract: 'runtime capabilities 合同',
458
+ package_metadata: '包信息'
459
+ };
460
+ return labels[name] || name;
461
+ }
462
+
463
+ function safeEndpoint(url) {
464
+ try {
465
+ return new URL(url).pathname;
466
+ } catch {
467
+ return String(url);
468
+ }
469
+ }
470
+
471
+ function readOptionValue(argv, index, name) {
472
+ const value = argv[index];
473
+ if (!value || value.startsWith('--')) throw new Error(`${name} 需要参数值。`);
474
+ return value;
475
+ }
476
+
477
+ function readPositiveInteger(value, name) {
478
+ if (!/^\d+$/.test(String(value))) throw new Error(`${name} 必须是正整数。`);
479
+ const parsed = Number(value);
480
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} 必须是正整数。`);
481
+ return parsed;
482
+ }
483
+
484
+ async function main() {
485
+ const options = parseArgs(process.argv.slice(2));
486
+ if (options.help) {
487
+ printHelp();
488
+ return;
489
+ }
490
+ const report = await buildFirstRunReport(options);
491
+ if (options.json) {
492
+ printJson(report);
493
+ } else {
494
+ process.stdout.write(formatFirstRunText(report));
495
+ }
496
+ if (!report.ok) process.exit(1);
497
+ }
498
+
499
+ try {
500
+ if (isMainModule(import.meta.url, process.argv[1])) await main();
501
+ } catch (error) {
502
+ printJson({ ok: false, command: 'first-run', billable: false, error: error instanceof Error ? error.message : String(error) });
503
+ process.exit(1);
504
+ }
scripts/status.mjs CHANGED
@@ -28,12 +28,14 @@ const STATUS_ENV_FILES = [
28
 
29
  export function buildAdminCommands() {
30
  return {
 
31
  doctor: 'npm run doctor',
32
  status: 'npm run status',
33
  env_summary: 'npm run env:summary',
34
  verify: 'npm run verify',
35
  deploy_local: 'npm run deploy:local',
36
  deploy_space: 'npm run deploy:space',
 
37
  agent_doctor: 'npm run agent:doctor',
38
  hf_space_doctor: 'npm run doctor:hf-space',
39
  hf_space_smoke: 'npm run smoke:hf-space'
 
28
 
29
  export function buildAdminCommands() {
30
  return {
31
+ first_run: 'npm run first-run',
32
  doctor: 'npm run doctor',
33
  status: 'npm run status',
34
  env_summary: 'npm run env:summary',
35
  verify: 'npm run verify',
36
  deploy_local: 'npm run deploy:local',
37
  deploy_space: 'npm run deploy:space',
38
+ docker_cleanup_fixtures: 'npm run docker:cleanup-fixtures',
39
  agent_doctor: 'npm run agent:doctor',
40
  hf_space_doctor: 'npm run doctor:hf-space',
41
  hf_space_smoke: 'npm run smoke:hf-space'
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -43,18 +43,21 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
43
  ## 执行流程
44
 
45
  1. 先按任务类型选择内置脚本,不要从零写 API 调用代码。
46
- 2. 定位服务基础地址。优先使户明确提供 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
47
- 3. 让脚本请求 `GET /api/agent/capabilities`。如果默认地址可达、404、不 JSON 或是 Agent capabilities 响应用户询问实际部署地址、端口、域名和是否需要鉴权
48
- 4. 读取 capabilities 中的认证方式、模型、模型级限制、`image_transport`、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端端点路径;不要硬编码假设部署方式
49
- 5. 为每个业操作生成稳 `Idempotency-Key`。网络中断运行中轮询或终态重试复用原 key同一 key `failed` 终态后不再用于触发新执行必须诊断原因,再创建新的业务操作和新的 key
50
- 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 消费。要把 `responsesModel`、`thinking`、`promptOptimization``force_web` 发送到 Agent JSON;这些字段需要页面端 `/api/images` form-data 路径
51
- 7. 图片编辑若走 Agent edit,使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段必须使用从 `image_0` 开始的连续字段,最大数量以 capabilities 的 `limits.upload_images.max` 为准;跳号超过当前 profile 上限、`image_01``image_foo` 会被显式拒绝。该 Agent 端点同样是非流式端点;上游 SSE 字段按 `agent_streaming.upstream_sse.request_fields_by_mode.edit` 发送,不要给 Agent edit 传 `image_backend`。需要 `image_backend=responses-image-generation` 或页面表单字段 `image_streaming_strategy=responses-sse` 的 edit,一律走页面`/api/images` form-data SSE脚本参数仍写作 `--streaming-strategy responses-sse`
52
- 8. 默认使用 `response_mode: "path"`,只在户明确需要图片内联数据时使用 `base64` `both`。
53
- 9. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit大图单次文生图复杂 UI 批量长图恢复显式页面参数或明确诊断后切换
54
- 10. 读取 `agent_jobs`。job 路径只在显式选择时使用`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。
55
- 11. 处理失败时读取结构化 `error.code``error.retryable`、`error.diagnostics` `Retry-After`。仅当 `retryable=true` 时等待后重试。
56
- 12. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
57
- 13. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复`Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`
 
 
 
58
 
59
  ## 鉴权
60
 
@@ -74,7 +77,8 @@ Authorization: Bearer <token>
74
  - 排查环境配置时不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。Codex 会话日志会持久保存命令输出;优先运行仓库脚本 `npm run env:summary`,或在命令中先把 `API_KEY`、`TOKEN`、`PASSWORD`、`SECRET` 值替换为 `<redacted>`。
75
  - Skill 必须保持自包含和可迁移:脚本、示例和说明不得写入本机绝对路径或仓库绝对路径;运行脚本时以当前已安装 Skill 目录为根解析 `scripts/`,不要依赖某台机器上的 checkout 位置。
76
  - Skill 必须兼容 Windows、Linux 和 macOS:脚本只用 Node.js 20+、跨平台 `node:` 标准库和 `package.json` 声明依赖;文档示例用 `node "<skill-root>/scripts/..."`,不依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。
77
- - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
 
78
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
79
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
80
  - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
@@ -105,46 +109,59 @@ Authorization: Bearer <token>
105
  - `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
106
  - `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` 时并发执行并按输入顺序输出结果。
107
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
108
- - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest,不触发生图计费。
109
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
110
 
111
- 生成编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable` 和 `next_action`。
112
  所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
113
 
114
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
115
 
 
116
  - `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
117
  - `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
118
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
119
  - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
120
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
121
- - `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  生成脚本常用参数:
124
 
125
  ```text
126
- 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"
127
  ```
128
 
129
  常用 preset 可先 dry-run 展开真实参数,不触发计费:
130
 
131
  ```text
132
- node "<skill-root>/scripts/generate-image.mjs" --preset 1k-smoke-agent "a product photo of a ceramic mug"
133
- node "<skill-root>/scripts/generate-image.mjs" --preset 4k-agent-nonstream "a cinematic landscape"
134
- node "<skill-root>/scripts/generate-image.mjs" --preset 4k-page-sse "a cinematic landscape"
135
- node "<skill-root>/scripts/generate-image.mjs" --preset 4k-upstream-sse-newapi "a cinematic landscape"
136
  ```
137
 
138
  启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
139
 
140
  ```text
141
- 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"
142
  ```
143
 
144
  真实生图必须显式开启:
145
 
146
  ```text
147
- node "<skill-root>/scripts/generate-image.mjs" --allow-billable --timeout-ms 420000 --size 2048x2048 "a product photo of a ceramic mug"
148
  ```
149
 
150
  本地格式转换不触发生图计费:
@@ -168,17 +185,17 @@ generate 或页面 SSE 请求包含 `image_backend` 时,`partial_images` 必
168
  默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务:
169
 
170
  ```text
171
- node "<skill-root>/scripts/batch-images.mjs" --input tasks.jsonl --ordered-prefix product-set
172
  ```
173
 
174
  真实批量执行必须显式允许计费。需要并发时添加 `--concurrency N`;需要严格连续失败熔断时保持 `--concurrency 1`:
175
 
176
  ```text
177
- 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
178
- 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
179
  ```
180
 
181
- `--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误以及机器可读 `summary`;`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会先读取运行态并发建议,并发执行任务并按输入顺序输出结果。服务端 `recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 小于请求值时,脚本会把有效并发降到建议值并在输出中写入 `capacity_feedback`;不要再另开多个单张脚本绕过这个限制。`--max-consecutive-failures` 会在连续失败达到阈值后跳过后续任务并输出 `failure_summary` 与 `resume_fix_list`,且只能与顺序执行的 `--concurrency 1` 同用。任务级 `sse_log_path` 会把页面 SSE 原始事件按 JSONL 追加保存;即使 fetch 或 SSE 收集阶段失败,也会记录 `request_started`、`request_failed`、`elapsed_ms`、`client_request_id` 和 `endpoint`,便于区分上游未给终图和解析/断流问题。
182
 
183
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚本在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
184
 
@@ -193,11 +210,11 @@ node "<skill-root>/scripts/probe-upstream-image.mjs" --base-url https://api.open
193
  页面请求反馈和日志诊断:
194
 
195
  ```text
196
- node "<skill-root>/scripts/diagnose-request.mjs" --client-request-id stable-operation-key --filename output.png
197
- node "<skill-root>/scripts/diagnose-request.mjs" --manifest runs/product-set.manifest.jsonl --filename output.png
198
- node "<skill-root>/scripts/diagnose-request.mjs" --manifest runs/product-set.manifest.jsonl --output runs/diagnosis.json
199
- node "<skill-root>/scripts/diagnose-request.mjs" --agent-request-id req_abc
200
- node "<skill-root>/scripts/diagnose-request.mjs" --idempotency-key stable-operation-key
201
  ```
202
 
203
  调用前按当前系统和 shell 设置 `OPENAI_API_KEY` 或 `GPT_IMAGE_UPSTREAM_API_KEY`,不要把 key 写进命令历史或文档。
@@ -208,7 +225,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --idempotency-key stable-operat
208
 
209
  脚本读取以下环境变量:
210
 
211
- - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
212
  - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
213
  - `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时,Agent 端点发送为 `X-App-Password-Hash`,页面 SSE 发送为 form-data `passwordHash`。
214
  - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键;也供 `diagnose-request.mjs` 按 Agent 幂等键查询 state。脚本不会自动重试已终态失败的 key。
@@ -217,6 +234,8 @@ node "<skill-root>/scripts/diagnose-request.mjs" --idempotency-key stable-operat
217
  - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
218
  - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
219
 
 
 
220
  上游请求头由服务端统一生成。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;capabilities、status 和 diagnostics 只暴露 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`,不暴露 secret 值。
221
 
222
  `GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
 
43
  ## 执行流程
44
 
45
  1. 先按任务类型选择内置脚本,不要从零写 API 调用代码。
46
+ 2. 定位服务基础地址。用户明确提供 URL 时直接使用 URL;否则先检查 `GPT_IMAGE_PLAYGROUND_URL`,再探测默认址 `http://localhost:4783`。
47
+ 3. 交互式任务中,如果只发现环境变量或本地默认地址,先把发现到的地址、服务可达性和鉴权需求告诉用户,并确认否使用它;要把自动发现到的本地服务直接当成用户意图。如果用户随后提供其他服务地址用户提供的地址为准
48
+ 4. 非交互式任务无法向用户确认时,按“用户提供 URL > `GPT_IMAGE_PLAYGROUND_URL` > 默认本地探测地址”的顺序执行,并在输出里标明服务地址来源是否只是自动发现
49
+ 5. 位于仓库根目录且用户是首次配置、换机器、服地址不确 token 不确定时,先运行 `npm run first-run`。该命令只读、非计费、不写 env 文件,默认输出中文摘要`-- --json` 输出机器可读 JSON。它会报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前程是否拿到 Agent 鉴权,以及 `.env.agent.local` 是否存在私有鉴权配置;如果 token 只在私有 env 文件中,先加载到 shell,再运行 skill 脚本
50
+ 6. 让脚本请求 `GET /api/agent/capabilities`。如果所选地址不404、不 JSON不是 Agent capabilities 响应,交互式任务中向用户询问实际部署地址、端口、域名和是否需要鉴权;非交互式任务中显式失败并输出下一步动作
51
+ 7. 读取 capabilities 认证方式模型、模型级、`image_transport``routing_rules`Agent 流式边界、页面 SSE 鉴权、后runtime enablement、状态后端和端点路径不要硬编码假设部署方式
52
+ 8. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key
53
+ 9. 文生图使用 `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 消费。不要把 `responsesModel``thinking``promptOptimization` 或 `force_web` 发送到 Agent JSON;这些字段需要页面端 `/api/images` form-data 路径
54
+ 10. 图片编辑若走 Agent edit,使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段必须使用`image_0` 开始连续字段,最大数量以 capabilities 的 `limits.upload_images.max` 为准;跳号、超过当前 profile 上限、`image_01` 或 `image_foo` 会被显式拒绝。该 Agent 端点同样是非流式端点;上游 SSE 字段按 `agent_streaming.upstream_sse.request_fields_by_mode.edit` 发送,不要给 Agent edit 传 `image_backend`。需要 `image_backend=responses-image-generation` 或页面表字段 `image_streaming_strategy=responses-sse` 的 edit,一律走页面端 `/api/images` form-data SSE;脚本参数仍写作 `--streaming-strategy responses-sse`
55
+ 11. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` `both`。
56
+ 12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复、显式页面参数或明确诊断后切换
57
+ 13. 读取 `agent_jobs`。job 路径只在显式选择时使`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE
58
+ 14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
59
+ 15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
60
+ 16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。
61
 
62
  ## 鉴权
63
 
 
77
  - 排查环境配置时不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。Codex 会话日志会持久保存命令输出;优先运行仓库脚本 `npm run env:summary`,或在命令中先把 `API_KEY`、`TOKEN`、`PASSWORD`、`SECRET` 值替换为 `<redacted>`。
78
  - Skill 必须保持自包含和可迁移:脚本、示例和说明不得写入本机绝对路径或仓库绝对路径;运行脚本时以当前已安装 Skill 目录为根解析 `scripts/`,不要依赖某台机器上的 checkout 位置。
79
  - Skill 必须兼容 Windows、Linux 和 macOS:脚本只用 Node.js 20+、跨平台 `node:` 标准库和 `package.json` 声明依赖;文档示例用 `node "<skill-root>/scripts/..."`,不依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。
80
+ - 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。交互式任务中,探测到本地服务后先请用户确认是否使用;用户提供其他地址时,以用户地址为准。
81
+ - subagent 或自动化任务如果用户指定 Space、云服务或内网服务,调用 `generate-image.mjs`、`edit-image.mjs`、`batch-images.mjs`、`diagnose-request.mjs` 或 `npm run agent:doctor -- --base-url <url>` 时显式传服务地址;不要依赖默认 localhost。
82
  - 不要在模型上下文中展开大体积 base64,除非用户明确要求。
83
  - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
84
  - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
 
109
  - `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
110
  - `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` 时并发执行并按输入顺序输出结果。
111
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
112
+ - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
113
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
114
 
115
+ 生成编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成和编辑 dry-run 还会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable` 和 `next_action`。
116
  所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
117
 
118
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
119
 
120
+ - `npm run first-run`:首次配置就绪检查,只读、非计费、不写 env 文件;默认输出中文摘要,加 `-- --json` 输出机器可读 JSON;用于确认 Node、依赖、服务地址、Agent capabilities、当前进程鉴权和下一步动作。
121
  - `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
122
  - `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
123
  - `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
124
  - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
125
  - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
126
+ - `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url <url>`;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。
127
+
128
+ 首次配置和诊断输出字段速查:
129
+
130
+ | 字段 | 出现位置 | 判断口径 |
131
+ | --- | --- | --- |
132
+ | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
133
+ | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
134
+ | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
135
+ | `agent_auth_process.has_token` | `first-run --json` | 当前 shell 是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
136
+ | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;存在不代表当前 shell 已加载。 |
137
+ | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
138
+ | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
139
 
140
  生成脚本常用参数:
141
 
142
  ```text
143
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "a product photo of a ceramic mug"
144
  ```
145
 
146
  常用 preset 可先 dry-run 展开真实参数,不触发计费:
147
 
148
  ```text
149
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 1k-smoke-agent "a product photo of a ceramic mug"
150
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-agent-nonstream "a cinematic landscape"
151
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-page-sse "a cinematic landscape"
152
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-upstream-sse-newapi "a cinematic landscape"
153
  ```
154
 
155
  启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
156
 
157
  ```text
158
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --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"
159
  ```
160
 
161
  真实生图必须显式开启:
162
 
163
  ```text
164
+ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --timeout-ms 420000 --size 2048x2048 "a product photo of a ceramic mug"
165
  ```
166
 
167
  本地格式转换不触发生图计费:
 
185
  默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务:
186
 
187
  ```text
188
+ node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.space --input tasks.jsonl --ordered-prefix product-set
189
  ```
190
 
191
  真实批量执行必须显式允许计费。需要并发时添加 `--concurrency N`;需要严格连续失败熔断时保持 `--concurrency 1`:
192
 
193
  ```text
194
+ node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.space --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --max-consecutive-failures 3
195
+ node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.space --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3
196
  ```
197
 
198
+ `--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误以及机器可读 `summary`;`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。dry-run 不写 manifest,输出会声明 `manifest_written=false` 和 `manifest_write_reason=dry_run`。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会先读取运行态并发建议,并发执行任务并按输入顺序输出结果。服务端 `recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 小于请求值时,脚本会把有效并发降到建议值并在输出中写入 `capacity_feedback`;不要再另开多个单张脚本绕过这个限制。`--max-consecutive-failures` 会在连续失败达到阈值后跳过后续任务并输出 `failure_summary` 与 `resume_fix_list`,且只能与顺序执行的 `--concurrency 1` 同用。任务级 `sse_log_path` 会把页面 SSE 原始事件按 JSONL 追加保存;即使 fetch 或 SSE 收集阶段失败,也会记录 `request_started`、`request_failed`、`elapsed_ms`、`client_request_id` 和 `endpoint`,便于区分上游未给终图和解析/断流问题。
199
 
200
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚本在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
201
 
 
210
  页面请求反馈和日志诊断:
211
 
212
  ```text
213
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --client-request-id stable-operation-key --filename output.png
214
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --manifest runs/product-set.manifest.jsonl --filename output.png
215
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --manifest runs/product-set.manifest.jsonl --output runs/diagnosis.json
216
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --agent-request-id req_abc
217
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --idempotency-key stable-operation-key
218
  ```
219
 
220
  调用前按当前系统和 shell 设置 `OPENAI_API_KEY` 或 `GPT_IMAGE_UPSTREAM_API_KEY`,不要把 key 写进命令历史或文档。
 
225
 
226
  脚本读取以下环境变量:
227
 
228
+ - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。脚本参数 `--base-url` 优先级高于该环境变量。
229
  - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
230
  - `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时,Agent 端点发送为 `X-App-Password-Hash`,页面 SSE 发送为 form-data `passwordHash`。
231
  - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键;也供 `diagnose-request.mjs` 按 Agent 幂等键查询 state。脚本不会自动重试已终态失败的 key。
 
234
  - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
235
  - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
236
 
237
+ Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 secret 值。远端配置 `AGENT_API_TOKEN` 后,本机 Agent 仍必须通过不入库的 shell 环境、keychain 或本地私有 env 文件注入 `GPT_IMAGE_AGENT_TOKEN`;不要把 token 写进仓库、README、任务 JSONL、manifest、命令参数或日志。仓库根目录的 `.env.agent.local.example` 只作私有本机配置模板,真实 `.env.agent.local` 不入库。
238
+
239
  上游请求头由服务端统一生成。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;capabilities、status 和 diagnostics 只暴露 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`,不暴露 secret 值。
240
 
241
  `GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
skills/gpt-image-playground-agent/agents/openai.yaml CHANGED
@@ -1,4 +1,4 @@
1
  interface:
2
  display_name: "GPT Image Agent API"
3
- short_description: "使用内置脚本调用图片 Agent API"
4
- default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本,不要临时编写 API 调用脚本。"
 
1
  interface:
2
  display_name: "GPT Image Agent API"
3
+ short_description: "使用内置脚本调用图片 Agent API,并确认目标服务地址"
4
+ default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;运行前先定位服务地址用户提供 URL 时显式传 --base-url,自动发现本地或环境变量地址时先确认,不要临时编写 API 调用脚本。"
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -26,13 +26,17 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
26
  - `scripts/edit-image.mjs`:multipart 编辑调用。
27
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
28
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
29
- - `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断。
30
  - `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
31
 
32
- 生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会按 capabilities 路由规则调用 `/api/agent/images/generate`、`/api/agent/images/edit`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
33
  上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
34
  脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
 
 
35
  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`。
 
 
36
  排查环境配置时不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。Codex 会话日志会持久保存命令输出;优先运行仓库脚本 `npm run env:summary`,或在命令中先把 `API_KEY`、`TOKEN`、`PASSWORD`、`SECRET` 值替换为 `<redacted>`。
37
 
38
  ```text
@@ -119,6 +123,8 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
119
  - `--dry-run`
120
  - `--allow-billable`
121
 
 
 
122
  批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 会选择页面 SSE 路径,且必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`,因为 Agent JSON 不接收请求级 Responses 顶层模型。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚��在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
123
 
124
  Responses edit JSONL 正例:
@@ -523,8 +529,28 @@ node "<skill-root>/scripts/diagnose-request.mjs" --agent-request-id req_abc
523
  node "<skill-root>/scripts/diagnose-request.mjs" --idempotency-key stable-operation-key
524
  ```
525
 
 
 
 
 
 
 
 
 
526
  脚本输出会包含 `diagnostics_retention`。当某个请求的 `matched_log_count=0` 时,该请求会额外包含 `diagnostics_note`,说明无匹配日志的保留窗口边界。
527
 
 
 
 
 
 
 
 
 
 
 
 
 
528
  单条 Agent state 诊断响应示例:
529
 
530
  ```json
 
26
  - `scripts/edit-image.mjs`:multipart 编辑调用。
27
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
28
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
29
+ - `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务
30
  - `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
31
 
32
+ 生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。必须显式添加 `--allow-billable` 才会按 capabilities 路由规则调用 `/api/agent/images/generate`、`/api/agent/images/edit`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
33
  上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
34
  脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
35
+ 位于仓库根目录且是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。它只读、非计费、不写 env 文件,默认输出中文摘要,并报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程鉴权和下一步动作。
36
+ 自动化消费时使用 `npm run first-run -- --json`。
37
  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`。
38
+ subagent 或自动化任务如果用户指定 Space、云服务或内网服务,调用 `generate-image.mjs`、`edit-image.mjs`、`batch-images.mjs`、`diagnose-request.mjs` 或 `npm run agent:doctor -- --base-url <url>` 时显式传服务地址;不要依赖默认 localhost。
39
+ Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 secret 值。远端配置 `AGENT_API_TOKEN` 后,本机 Agent 仍必须通过不入库的 shell 环境、keychain 或本地私有 env 文件注入 `GPT_IMAGE_AGENT_TOKEN`;不要把 token 写进仓库、README、任务 JSONL、manifest、命令参数或日志。仓库根目录的 `.env.agent.local.example` 只作私有本机配置模板,真实 `.env.agent.local` 不入库。
40
  排查环境配置时不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。Codex 会话日志会持久保存命令输出;优先运行仓库脚本 `npm run env:summary`,或在命令中先把 `API_KEY`、`TOKEN`、`PASSWORD`、`SECRET` 值替换为 `<redacted>`。
41
 
42
  ```text
 
123
  - `--dry-run`
124
  - `--allow-billable`
125
 
126
+ 批量 dry-run 不写 manifest,输出会声明 `manifest_written=false` 和 `manifest_write_reason=dry_run`。只有真实执行时 manifest 才作为 append-only 续跑记录写入。
127
+
128
  批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 会选择页面 SSE 路径,且必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`,因为 Agent JSON 不接收请求级 Responses 顶层模型。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚��在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
129
 
130
  Responses edit JSONL 正例:
 
529
  node "<skill-root>/scripts/diagnose-request.mjs" --idempotency-key stable-operation-key
530
  ```
531
 
532
+ 固定服务地址时加 `--base-url`:
533
+
534
+ ```text
535
+ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.hf.space --idempotency-key stable-operation-key
536
+ ```
537
+
538
+ 远程 Space、云服务或内网服务必须显式传 `--base-url`,不要让诊断脚本误查默认本地服务。
539
+
540
  脚本输出会包含 `diagnostics_retention`。当某个请求的 `matched_log_count=0` 时,该请求会额外包含 `diagnostics_note`,说明无匹配日志的保留窗口边界。
541
 
542
+ 首次配置和诊断输出字段速查:
543
+
544
+ | 字段 | 出现位置 | 判断口径 |
545
+ | --- | --- | --- |
546
+ | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
547
+ | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
548
+ | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
549
+ | `agent_auth_process.has_token` | `first-run --json` | 当前 shell 是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
550
+ | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;存在不代表当前 shell 已加载。 |
551
+ | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
552
+ | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
553
+
554
  单条 Agent state 诊断响应示例:
555
 
556
  ```json
skills/gpt-image-playground-agent/scripts/batch-images.mjs CHANGED
@@ -3,7 +3,6 @@ import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
6
- normalizeBaseUrl,
7
  normalizeOutputFormat,
8
  parseImageSizeValue,
9
  readCapabilitiesImageTransportTimeoutMs,
@@ -11,6 +10,7 @@ import {
11
  readMaxImageEdge,
12
  readOptionValue,
13
  readPartialImages,
 
14
  resolveSameOriginUrl,
15
  validateAgentEditRequestAgainstCapabilities,
16
  validateAgentGenerateRequestAgainstCapabilities
@@ -118,6 +118,7 @@ if (options.help) {
118
  }
119
 
120
  let baseUrl;
 
121
  let tasks;
122
  let timeoutMs;
123
  let capabilities;
@@ -126,7 +127,8 @@ let runtimeCapabilities;
126
  let runtimeCapabilitiesPromise;
127
  try {
128
  if (!options.input) throw new Error('--input 需要 JSONL 文件路径。');
129
- baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
 
130
  timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
131
  options.maxAttempts = readConfiguredPositiveInteger(
132
  options.maxAttempts ?? DEFAULT_BATCH_MAX_ATTEMPTS,
@@ -167,8 +169,11 @@ if (!options.allowBillable || options.dryRun) {
167
  ok: true,
168
  billable: false,
169
  dry_run: true,
 
170
  input: options.input,
171
  manifest: manifestPath,
 
 
172
  total: planned.length,
173
  max_attempts: options.maxAttempts,
174
  max_consecutive_failures: options.maxConsecutiveFailures,
@@ -235,6 +240,7 @@ function parseArgs(argv) {
235
  maxAttempts: undefined,
236
  maxConsecutiveFailures: undefined,
237
  concurrency: undefined,
 
238
  allowBillable: false,
239
  dryRun: false,
240
  resume: false,
@@ -252,6 +258,7 @@ function parseArgs(argv) {
252
  else if (arg === '--manifest') parsed.manifest = readOptionValue(argv, (index += 1), arg);
253
  else if (arg === '--ordered-prefix') parsed.orderedPrefix = readOptionValue(argv, (index += 1), arg);
254
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
 
255
  else if (arg === '--max-attempts') parsed.maxAttempts = readOptionValue(argv, (index += 1), arg);
256
  else if (arg === '--max-consecutive-failures') {
257
  parsed.maxConsecutiveFailures = readOptionValue(argv, (index += 1), arg);
@@ -264,6 +271,20 @@ function parseArgs(argv) {
264
  return parsed;
265
  }
266
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
  function readJsonlTasks(filePath) {
268
  return fs
269
  .readFileSync(filePath, 'utf8')
@@ -1402,5 +1423,5 @@ function mimeTypeForPath(filePath) {
1402
  function printUsage() {
1403
  console.error('用法:batch-images.mjs --input tasks.jsonl [options]');
1404
  console.error('默认只输出 dry-run;添加 --allow-billable 才会按 routing rules 逐行真实请求 Agent API 或页面 SSE。');
1405
- console.error('常用参数:--manifest --resume --ordered-prefix --dimension-check --max-attempts --max-consecutive-failures --concurrency --timeout-ms --dry-run --allow-billable');
1406
  }
 
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
 
6
  normalizeOutputFormat,
7
  parseImageSizeValue,
8
  readCapabilitiesImageTransportTimeoutMs,
 
10
  readMaxImageEdge,
11
  readOptionValue,
12
  readPartialImages,
13
+ resolvePlaygroundBaseUrl,
14
  resolveSameOriginUrl,
15
  validateAgentEditRequestAgainstCapabilities,
16
  validateAgentGenerateRequestAgainstCapabilities
 
118
  }
119
 
120
  let baseUrl;
121
+ let baseUrlInfo;
122
  let tasks;
123
  let timeoutMs;
124
  let capabilities;
 
127
  let runtimeCapabilitiesPromise;
128
  try {
129
  if (!options.input) throw new Error('--input 需要 JSONL 文件路径。');
130
+ baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
131
+ baseUrl = baseUrlInfo.baseUrl;
132
  timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
133
  options.maxAttempts = readConfiguredPositiveInteger(
134
  options.maxAttempts ?? DEFAULT_BATCH_MAX_ATTEMPTS,
 
169
  ok: true,
170
  billable: false,
171
  dry_run: true,
172
+ verification_scope: buildDryRunVerificationScope(),
173
  input: options.input,
174
  manifest: manifestPath,
175
+ manifest_written: false,
176
+ manifest_write_reason: 'dry_run',
177
  total: planned.length,
178
  max_attempts: options.maxAttempts,
179
  max_consecutive_failures: options.maxConsecutiveFailures,
 
240
  maxAttempts: undefined,
241
  maxConsecutiveFailures: undefined,
242
  concurrency: undefined,
243
+ baseUrl: undefined,
244
  allowBillable: false,
245
  dryRun: false,
246
  resume: false,
 
258
  else if (arg === '--manifest') parsed.manifest = readOptionValue(argv, (index += 1), arg);
259
  else if (arg === '--ordered-prefix') parsed.orderedPrefix = readOptionValue(argv, (index += 1), arg);
260
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
261
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
262
  else if (arg === '--max-attempts') parsed.maxAttempts = readOptionValue(argv, (index += 1), arg);
263
  else if (arg === '--max-consecutive-failures') {
264
  parsed.maxConsecutiveFailures = readOptionValue(argv, (index += 1), arg);
 
271
  return parsed;
272
  }
273
 
274
+ function buildDryRunVerificationScope() {
275
+ return {
276
+ mode: 'local_planning_only',
277
+ service_base_url: baseUrl,
278
+ service_base_url_source: baseUrlInfo.source,
279
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
280
+ remote_capabilities_verified: false,
281
+ runtime_capacity_verified: false,
282
+ auth_verified: false,
283
+ billable_request_sent: false,
284
+ note: 'Dry-run validates JSONL parsing, idempotency keys, request previews and static routing only; run with --allow-billable to verify remote capabilities, capacity and auth.'
285
+ };
286
+ }
287
+
288
  function readJsonlTasks(filePath) {
289
  return fs
290
  .readFileSync(filePath, 'utf8')
 
1423
  function printUsage() {
1424
  console.error('用法:batch-images.mjs --input tasks.jsonl [options]');
1425
  console.error('默认只输出 dry-run;添加 --allow-billable 才会按 routing rules 逐行真实请求 Agent API 或页面 SSE。');
1426
+ console.error('常用参数:--manifest --resume --ordered-prefix --dimension-check --max-attempts --max-consecutive-failures --concurrency --timeout-ms --base-url --dry-run --allow-billable');
1427
  }
skills/gpt-image-playground-agent/scripts/convert-image-format.mjs CHANGED
@@ -2,7 +2,6 @@
2
  import { errorMessage, normalizeOutputFormat, readConfiguredPositiveInteger, readOptionValue } from './lib/script-utils.mjs';
3
  import fs from 'node:fs/promises';
4
  import path from 'node:path';
5
- import sharp from 'sharp';
6
 
7
  const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
8
  const DEFAULT_OUTPUT_FORMAT = 'webp';
@@ -100,6 +99,7 @@ async function buildConversionPlan(parsed) {
100
 
101
  async function convertImage(plan) {
102
  await fs.mkdir(path.dirname(plan.output.path), { recursive: true });
 
103
  let pipeline = sharp(plan.input.path, { animated: false });
104
  if (plan.output.format === 'jpeg') {
105
  pipeline = pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: plan.output.quality });
@@ -122,6 +122,18 @@ async function convertImage(plan) {
122
  };
123
  }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  async function readInputFileStat(inputPath) {
126
  let stat;
127
  try {
 
2
  import { errorMessage, normalizeOutputFormat, readConfiguredPositiveInteger, readOptionValue } from './lib/script-utils.mjs';
3
  import fs from 'node:fs/promises';
4
  import path from 'node:path';
 
5
 
6
  const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
7
  const DEFAULT_OUTPUT_FORMAT = 'webp';
 
99
 
100
  async function convertImage(plan) {
101
  await fs.mkdir(path.dirname(plan.output.path), { recursive: true });
102
+ const sharp = await loadSharp();
103
  let pipeline = sharp(plan.input.path, { animated: false });
104
  if (plan.output.format === 'jpeg') {
105
  pipeline = pipeline.flatten({ background: '#ffffff' }).jpeg({ quality: plan.output.quality });
 
122
  };
123
  }
124
 
125
+ async function loadSharp() {
126
+ try {
127
+ const module = await import('sharp');
128
+ return module.default;
129
+ } catch (error) {
130
+ if (error?.code === 'ERR_MODULE_NOT_FOUND') {
131
+ throw new Error('缺少 sharp 依赖,无法执行图片格式转换;请在包含本 skill 的项目中安装依赖后重试。');
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+
137
  async function readInputFileStat(inputPath) {
138
  let stat;
139
  try {
skills/gpt-image-playground-agent/scripts/diagnose-request.mjs CHANGED
@@ -2,7 +2,7 @@
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
  import { dirname } from 'node:path';
4
  import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
5
- import { errorMessage, normalizeBaseUrl, readOptionValue } from './lib/script-utils.mjs';
6
 
7
  const MAX_CLIENT_REQUEST_IDS = 50;
8
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
@@ -24,8 +24,10 @@ if (options.help) {
24
  let baseUrl;
25
  let clientRequestIds;
26
  let agentLookups;
 
27
  try {
28
- baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
 
29
  clientRequestIds = readClientRequestIds(options);
30
  agentLookups = readAgentLookups(options);
31
  if (!clientRequestIds.length && !agentLookups.length) {
@@ -46,6 +48,9 @@ try {
46
  const body = {
47
  ok: true,
48
  billable: false,
 
 
 
49
  page_request_count: requests.length,
50
  agent_request_count: agentRequests.length,
51
  request_count: requests.length,
@@ -84,6 +89,7 @@ function parseArgs(argv) {
84
  filenames: [],
85
  manifests: [],
86
  output: '',
 
87
  help: false
88
  };
89
  const envClientRequestId = process.env.GPT_IMAGE_AGENT_CLIENT_REQUEST_ID?.trim();
@@ -95,7 +101,9 @@ function parseArgs(argv) {
95
 
96
  for (let index = 0; index < argv.length; index += 1) {
97
  const arg = argv[index];
98
- if (arg === '--client-request-id' || arg === '--request-id') {
 
 
99
  parsed.clientRequestIds.push(readOptionValue(argv, (index += 1), arg));
100
  } else if (arg === '--agent-request-id') {
101
  parsed.agentRequestIds.push(readOptionValue(argv, (index += 1), arg));
@@ -487,6 +495,6 @@ function writeOutputFile(filepath, text) {
487
  }
488
 
489
  function printUsage() {
490
- console.error('用法:diagnose-request.mjs [--client-request-id <id>] [--agent-request-id <id>] [--idempotency-key <key>] [--manifest <jsonl>] [--filename <name>] [--output <json>]');
491
  console.error('只读查询页面反馈/日志诊断或 Agent state 请求诊断,不触发生图计费。');
492
  }
 
2
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
  import { dirname } from 'node:path';
4
  import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
5
+ import { errorMessage, readOptionValue, resolvePlaygroundBaseUrl } from './lib/script-utils.mjs';
6
 
7
  const MAX_CLIENT_REQUEST_IDS = 50;
8
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
 
24
  let baseUrl;
25
  let clientRequestIds;
26
  let agentLookups;
27
+ let baseUrlInfo;
28
  try {
29
+ baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
30
+ baseUrl = baseUrlInfo.baseUrl;
31
  clientRequestIds = readClientRequestIds(options);
32
  agentLookups = readAgentLookups(options);
33
  if (!clientRequestIds.length && !agentLookups.length) {
 
48
  const body = {
49
  ok: true,
50
  billable: false,
51
+ service_base_url: baseUrl,
52
+ service_base_url_source: baseUrlInfo.source,
53
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
54
  page_request_count: requests.length,
55
  agent_request_count: agentRequests.length,
56
  request_count: requests.length,
 
89
  filenames: [],
90
  manifests: [],
91
  output: '',
92
+ baseUrl: undefined,
93
  help: false
94
  };
95
  const envClientRequestId = process.env.GPT_IMAGE_AGENT_CLIENT_REQUEST_ID?.trim();
 
101
 
102
  for (let index = 0; index < argv.length; index += 1) {
103
  const arg = argv[index];
104
+ if (arg === '--base-url') {
105
+ parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
106
+ } else if (arg === '--client-request-id' || arg === '--request-id') {
107
  parsed.clientRequestIds.push(readOptionValue(argv, (index += 1), arg));
108
  } else if (arg === '--agent-request-id') {
109
  parsed.agentRequestIds.push(readOptionValue(argv, (index += 1), arg));
 
495
  }
496
 
497
  function printUsage() {
498
+ console.error('用法:diagnose-request.mjs [--base-url <url>] [--client-request-id <id>] [--agent-request-id <id>] [--idempotency-key <key>] [--manifest <jsonl>] [--filename <name>] [--output <json>]');
499
  console.error('只读查询页面反馈/日志诊断或 Agent state 请求诊断,不触发生图计费。');
500
  }
skills/gpt-image-playground-agent/scripts/edit-image.mjs CHANGED
@@ -5,7 +5,6 @@ import path from 'node:path';
5
  import {
6
  errorMessage,
7
  assertValidImageSizeForModel,
8
- normalizeBaseUrl,
9
  normalizeOutputFormat,
10
  parseRetryAfterValue,
11
  readCapabilitiesImageTransportTimeoutMs,
@@ -13,6 +12,7 @@ import {
13
  readMaxImageEdge,
14
  readOptionValue,
15
  readPartialImages,
 
16
  sleep,
17
  validateAgentEditRequestAgainstCapabilities
18
  } from './lib/script-utils.mjs';
@@ -95,8 +95,10 @@ if ((!imagePath || !prompt) && !contractCheck) {
95
  }
96
 
97
  let baseUrl;
 
98
  try {
99
- baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
 
100
  } catch (error) {
101
  console.error(errorMessage(error));
102
  process.exit(2);
@@ -110,6 +112,7 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
110
  ok: true,
111
  billable: false,
112
  dry_run: true,
 
113
  endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`,
114
  routing_guidance: routingGuidance,
115
  idempotency_key: idempotencyKey,
@@ -179,6 +182,7 @@ function parseArgs(argv) {
179
  forceWeb: undefined,
180
  sseLogPath: undefined,
181
  timeoutMs: undefined,
 
182
  idempotencyKey: undefined,
183
  imagePath: undefined,
184
  imagePathSource: undefined,
@@ -215,6 +219,7 @@ function parseArgs(argv) {
215
  else if (arg === '--force-web') parsed.forceWeb = true;
216
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
217
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
 
218
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
219
  else if (arg === '--image') {
220
  if (parsed.imagePathSource === 'option') throw new Error('--image 只能设置一次。');
@@ -243,6 +248,20 @@ function absoluteUrl(value) {
243
  return new URL(value, `${baseUrl}/`).toString();
244
  }
245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  function buildEditRoutingGuidance(parsed) {
247
  if (parsed.routeMode === 'agent') {
248
  assertNoPageOnlyEditOptions(parsed, 'Agent edit');
@@ -448,7 +467,7 @@ async function fetchWithTimeout(url, init) {
448
  function printUsage() {
449
  console.error('用法:edit-image.mjs [options] <image-path> <prompt> 或 edit-image.mjs --image <path> [options] <prompt>');
450
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
451
- console.error('常用参数:--image --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');
452
  console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
453
  }
454
 
 
5
  import {
6
  errorMessage,
7
  assertValidImageSizeForModel,
 
8
  normalizeOutputFormat,
9
  parseRetryAfterValue,
10
  readCapabilitiesImageTransportTimeoutMs,
 
12
  readMaxImageEdge,
13
  readOptionValue,
14
  readPartialImages,
15
+ resolvePlaygroundBaseUrl,
16
  sleep,
17
  validateAgentEditRequestAgainstCapabilities
18
  } from './lib/script-utils.mjs';
 
95
  }
96
 
97
  let baseUrl;
98
+ let baseUrlInfo;
99
  try {
100
+ baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
101
+ baseUrl = baseUrlInfo.baseUrl;
102
  } catch (error) {
103
  console.error(errorMessage(error));
104
  process.exit(2);
 
112
  ok: true,
113
  billable: false,
114
  dry_run: true,
115
+ verification_scope: buildDryRunVerificationScope(),
116
  endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`,
117
  routing_guidance: routingGuidance,
118
  idempotency_key: idempotencyKey,
 
182
  forceWeb: undefined,
183
  sseLogPath: undefined,
184
  timeoutMs: undefined,
185
+ baseUrl: undefined,
186
  idempotencyKey: undefined,
187
  imagePath: undefined,
188
  imagePathSource: undefined,
 
219
  else if (arg === '--force-web') parsed.forceWeb = true;
220
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
221
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
222
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
223
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
224
  else if (arg === '--image') {
225
  if (parsed.imagePathSource === 'option') throw new Error('--image 只能设置一次。');
 
248
  return new URL(value, `${baseUrl}/`).toString();
249
  }
250
 
251
+ function buildDryRunVerificationScope() {
252
+ return {
253
+ mode: 'local_planning_only',
254
+ service_base_url: baseUrl,
255
+ service_base_url_source: baseUrlInfo.source,
256
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
257
+ remote_capabilities_verified: false,
258
+ runtime_capacity_verified: false,
259
+ auth_verified: false,
260
+ billable_request_sent: false,
261
+ note: 'Dry-run validates local request construction and routing guidance only; run --contract-check or --allow-billable to verify the remote service.'
262
+ };
263
+ }
264
+
265
  function buildEditRoutingGuidance(parsed) {
266
  if (parsed.routeMode === 'agent') {
267
  assertNoPageOnlyEditOptions(parsed, 'Agent edit');
 
467
  function printUsage() {
468
  console.error('用法:edit-image.mjs [options] <image-path> <prompt> 或 edit-image.mjs --image <path> [options] <prompt>');
469
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
470
+ console.error('常用参数:--image --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 --base-url --idempotency-key --page-sse --agent --dry-run --allow-billable');
471
  console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
472
  }
473
 
skills/gpt-image-playground-agent/scripts/generate-image.mjs CHANGED
@@ -3,7 +3,6 @@ import { AGENT_ENDPOINTS, buildAgentJobResultPath } from './lib/agent-api-paths.
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
6
- normalizeBaseUrl,
7
  normalizeOutputFormat,
8
  parseRetryAfterValue,
9
  readCapabilitiesImageTransportTimeoutMs,
@@ -11,6 +10,7 @@ import {
11
  readMaxImageEdge,
12
  readOptionValue,
13
  readPartialImages,
 
14
  resolveSameOriginUrl,
15
  sleep,
16
  validateAgentGenerateRequestAgainstCapabilities
@@ -120,8 +120,10 @@ if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
120
  }
121
 
122
  let baseUrl;
 
123
  try {
124
- baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
 
125
  } catch (error) {
126
  console.error(errorMessage(error));
127
  process.exit(2);
@@ -134,6 +136,7 @@ if (isNonBillableDryRun(options, contractCheck)) {
134
  ok: true,
135
  billable: false,
136
  dry_run: true,
 
137
  endpoint: dryRunEndpoint(requestBody, options.routeMode),
138
  route_mode: options.routeMode,
139
  routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode),
@@ -228,6 +231,7 @@ function parseArgs(argv) {
228
  partialImages: undefined,
229
  sseLogPath: undefined,
230
  timeoutMs: undefined,
 
231
  promptFile: undefined,
232
  idempotencyKey: undefined,
233
  routeMode: 'auto',
@@ -263,6 +267,7 @@ function parseArgs(argv) {
263
  else if (arg === '--partial-images') parsed.partialImages = readOptionValue(expandedArgv, (index += 1), arg);
264
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(expandedArgv, (index += 1), arg);
265
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(expandedArgv, (index += 1), arg);
 
266
  else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(expandedArgv, (index += 1), arg);
267
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(expandedArgv, (index += 1), arg);
268
  else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
@@ -473,6 +478,20 @@ function enrichImageUrls(result) {
473
  };
474
  }
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  function dryRunEndpoint(body, routeMode) {
477
  if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
478
  if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
@@ -1368,7 +1387,7 @@ function printUsage() {
1368
  console.error('用法:generate-image.mjs [options] <prompt>');
1369
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
1370
  console.error(
1371
- '常用参数:--model --size --quality --n --format --output-compression --response-mode --image-backend --responses-model --gpt-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --sse-log --timeout-ms --prompt-file --idempotency-key --page-sse --agent --job --no-job(兼容别名)'
1372
  );
1373
  console.error(
1374
  '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'
 
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
 
6
  normalizeOutputFormat,
7
  parseRetryAfterValue,
8
  readCapabilitiesImageTransportTimeoutMs,
 
10
  readMaxImageEdge,
11
  readOptionValue,
12
  readPartialImages,
13
+ resolvePlaygroundBaseUrl,
14
  resolveSameOriginUrl,
15
  sleep,
16
  validateAgentGenerateRequestAgainstCapabilities
 
120
  }
121
 
122
  let baseUrl;
123
+ let baseUrlInfo;
124
  try {
125
+ baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
126
+ baseUrl = baseUrlInfo.baseUrl;
127
  } catch (error) {
128
  console.error(errorMessage(error));
129
  process.exit(2);
 
136
  ok: true,
137
  billable: false,
138
  dry_run: true,
139
+ verification_scope: buildDryRunVerificationScope(),
140
  endpoint: dryRunEndpoint(requestBody, options.routeMode),
141
  route_mode: options.routeMode,
142
  routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode),
 
231
  partialImages: undefined,
232
  sseLogPath: undefined,
233
  timeoutMs: undefined,
234
+ baseUrl: undefined,
235
  promptFile: undefined,
236
  idempotencyKey: undefined,
237
  routeMode: 'auto',
 
267
  else if (arg === '--partial-images') parsed.partialImages = readOptionValue(expandedArgv, (index += 1), arg);
268
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(expandedArgv, (index += 1), arg);
269
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(expandedArgv, (index += 1), arg);
270
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(expandedArgv, (index += 1), arg);
271
  else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(expandedArgv, (index += 1), arg);
272
  else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(expandedArgv, (index += 1), arg);
273
  else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
 
478
  };
479
  }
480
 
481
+ function buildDryRunVerificationScope() {
482
+ return {
483
+ mode: 'local_planning_only',
484
+ service_base_url: baseUrl,
485
+ service_base_url_source: baseUrlInfo.source,
486
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
487
+ remote_capabilities_verified: false,
488
+ runtime_capacity_verified: false,
489
+ auth_verified: false,
490
+ billable_request_sent: false,
491
+ note: 'Dry-run validates local request construction and routing guidance only; run --contract-check or --allow-billable to verify the remote service.'
492
+ };
493
+ }
494
+
495
  function dryRunEndpoint(body, routeMode) {
496
  if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
497
  if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
 
1387
  console.error('用法:generate-image.mjs [options] <prompt>');
1388
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
1389
  console.error(
1390
+ '常用参数:--model --size --quality --n --format --output-compression --response-mode --image-backend --responses-model --gpt-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --sse-log --timeout-ms --base-url --prompt-file --idempotency-key --page-sse --agent --job --no-job(兼容别名)'
1391
  );
1392
  console.error(
1393
  '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs CHANGED
@@ -2,6 +2,7 @@ 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
 
6
  export function readOptionValue(argv, index, name) {
7
  const value = argv[index];
@@ -110,6 +111,28 @@ export function normalizeBaseUrl(value) {
110
  return normalized;
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  export function normalizeOutputFormat(value) {
114
  return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
115
  }
 
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
+ export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:4783';
6
 
7
  export function readOptionValue(argv, index, name) {
8
  const value = argv[index];
 
111
  return normalized;
112
  }
113
 
114
+ export function resolvePlaygroundBaseUrl(explicitBaseUrl, env = process.env) {
115
+ if (explicitBaseUrl) {
116
+ return {
117
+ baseUrl: normalizeBaseUrl(explicitBaseUrl),
118
+ source: 'user_provided',
119
+ interactive_confirmation_required: false
120
+ };
121
+ }
122
+ if (env.GPT_IMAGE_PLAYGROUND_URL) {
123
+ return {
124
+ baseUrl: normalizeBaseUrl(env.GPT_IMAGE_PLAYGROUND_URL),
125
+ source: 'GPT_IMAGE_PLAYGROUND_URL',
126
+ interactive_confirmation_required: true
127
+ };
128
+ }
129
+ return {
130
+ baseUrl: DEFAULT_PLAYGROUND_BASE_URL,
131
+ source: 'default_local_probe',
132
+ interactive_confirmation_required: true
133
+ };
134
+ }
135
+
136
  export function normalizeOutputFormat(value) {
137
  return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
138
  }