misonL commited on
Commit
97869a1
·
verified ·
1 Parent(s): 20b48cb

Deploy 653775e to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@653775e99286817989ad7a4dfb56eedae605cdcb

AGENTS.md CHANGED
@@ -13,7 +13,7 @@
13
 
14
  - 项目是 Next.js 16 + React 19 的本地图片服务,默认端口 `4783`。
15
  - 包管理工具是 `npm`,锁文件是 `package-lock.json`。
16
- - Node 版本要求是 `>=20.0.0`。
17
  - 主要页面入口是 `src/app/page.tsx`。
18
  - 图片 API 入口是 `src/app/api/images/route.ts`。
19
  - Agent API 位于 `src/app/api/agent/`。
 
13
 
14
  - 项目是 Next.js 16 + React 19 的本地图片服务,默认端口 `4783`。
15
  - 包管理工具是 `npm`,锁文件是 `package-lock.json`。
16
+ - Node 版本要求是 `>=20.9.0`。
17
  - 主要页面入口是 `src/app/page.tsx`。
18
  - 图片 API 入口是 `src/app/api/images/route.ts`。
19
  - Agent API 位于 `src/app/api/agent/`。
README.md CHANGED
@@ -102,7 +102,7 @@ start-windows.bat
102
  | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
103
  | Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
104
  | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
105
- | 渠道请求方式 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 可选。声明全局或单渠道可用方式`images-non-stream``images-sse``responses-non-stream`、`responses-sse`;旧后端别名仅为兼容输入,建议使用这四个规范值。 |
106
  | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
107
  | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
108
  | 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道、凭证或已识别请求方式是否临时移出路由池。 |
@@ -139,7 +139,7 @@ OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
139
  注意:
140
 
141
  - 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
142
- - `OPENAI_CHANNEL_N_REQUEST_MODES` 是管理员基于真实上游 smoke 设置的服务端白名单;全局默认可用 `OPENAI_UPSTREAM_REQUEST_MODES`。`/api/runtime-capabilities` 的 `channelRouting.requestModeControls`、`channelRouting.requestModeHealth` 和 Agent capabilities 的 `request_mode_controls` 会暴露配置入口、健康覆盖和对应真实 smoke gate。Agent 客户端只提交业务意图,不应自行选择 Images、Responses、SSE 或非流式路径。`stream_mode=auto` 可由服务端在 SSE 不可用时显式退到非流式并标记 fallback;`stream_mode=stream` 或显式页面 SSE 诊断必须失败可见,不会静默降级。真实执行后可从 `execution.channel_request_mode`、`execution.channel_request_mode_fallback_applied` 和 `execution.route_decision` 读取服务端实际选路结果;失败冷却若能关联到本次服务端 request mode,会只冷却该渠道或凭证的对应 request mode,并在 `error.diagnostics.cooldown_target.request_mode` 暴露。
143
  - 渠道恢复探测使用非计费 `GET /models` 只确认 host、鉴权和 models 端点恢复;它不能替代 request mode 的真实 Images/Responses/SSE smoke。管理员应以真实 smoke 结果决定 `OPENAI_CHANNEL_N_REQUEST_MODES`。
144
  - Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
145
  - Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。生成意图也可以用 `responsesModel`、`responses_model`、`gptModel` 或 `gpt_model` 覆盖单次 `/responses` 顶层模型;这些字段只影响本项目的 `responses-image-generation` 路径,不会改变兼容上游自身 `/v1/images/generations` 桥接层内部选择的模型。
@@ -359,7 +359,7 @@ Hugging Face Space 免费层部署见 [docs/deployment/huggingface-space-free.md
359
 
360
  | 问题 | 处理 |
361
  | --- | --- |
362
- | 未检测到 Node.js | 安装 Node.js 20 或更高版本。 |
363
  | 依赖安装失败 | 检查 npm 网络后重新执行 `npm install`。 |
364
  | API 返回 HTML | API URL 填成了网��或管理后台;应填写 OpenAI 兼容 `/v1` 根地址。 |
365
  | 提示需要 API Key | 在 `.env.local` 写入 `OPENAI_API_KEY`,或在页面 `API 设置` 中填写。 |
 
102
  | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
103
  | Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
104
  | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
105
+ | 渠道请求方式 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 可选。声明全局或单渠道可用方式;每个渠道只保留真实 smoke 通过的模式。新上游若 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就不要把 `responses-non-stream`、`responses-sse` 放进该渠道白。 |
106
  | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
107
  | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
108
  | 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道、凭证或已识别请求方式是否临时移出路由池。 |
 
139
  注意:
140
 
141
  - 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
142
+ - `OPENAI_CHANNEL_N_REQUEST_MODES` 是管理员基于真实上游 smoke 设置的服务端白名单;全局默认可用 `OPENAI_UPSTREAM_REQUEST_MODES`。`/api/runtime-capabilities` 的 `channelRouting.requestModeControls`、`channelRouting.requestModeHealth` 和 Agent capabilities 的 `request_mode_controls` 会暴露配置入口、健康覆盖和对应真实 smoke gate。Agent 客户端只提交业务意图,不应自行选择 Images、Responses、SSE 或非流式路径。`stream_mode=auto` 可由服务端在 SSE 不可用时显式退到非流式并标记 fallback;`stream_mode=stream` 或显式页面 SSE 诊断必须失败可见,不会静默降级。真实执行后可从 `execution.channel_request_mode`、`execution.channel_request_mode_fallback_applied` 和 `execution.route_decision` 读取服务端实际选路结果;失败冷却若能关联到本次服务端 request mode,会只冷却该渠道或凭证的对应 request mode,并在 `error.diagnostics.cooldown_target.request_mode` 暴露。新上游接入时先用真实 smoke 确认每个请求方式,再把 `OPENAI_CHANNEL_N_REQUEST_MODES` 收窄到通过的最小集合。
143
  - 渠道恢复探测使用非计费 `GET /models` 只确认 host、鉴权和 models 端点恢复;它不能替代 request mode 的真实 Images/Responses/SSE smoke。管理员应以真实 smoke 结果决定 `OPENAI_CHANNEL_N_REQUEST_MODES`。
144
  - Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
145
  - Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。生成意图也可以用 `responsesModel`、`responses_model`、`gptModel` 或 `gpt_model` 覆盖单次 `/responses` 顶层模型;这些字段只影响本项目的 `responses-image-generation` 路径,不会改变兼容上游自身 `/v1/images/generations` 桥接层内部选择的模型。
 
359
 
360
  | 问题 | 处理 |
361
  | --- | --- |
362
+ | 未检测到 Node.js | 安装 Node.js >=20.9.0。 |
363
  | 依赖安装失败 | 检查 npm 网络后重新执行 `npm install`。 |
364
  | API 返回 HTML | API URL 填成了网��或管理后台;应填写 OpenAI 兼容 `/v1` 根地址。 |
365
  | 提示需要 API Key | 在 `.env.local` 写入 `OPENAI_API_KEY`,或在页面 `API 设置` 中填写。 |
docs/deployment/huggingface-space-free.md CHANGED
@@ -42,7 +42,7 @@ hf auth whoami
42
 
43
  要求:
44
 
45
- - Node.js 20 或更高版本
46
  - npm 随 Node.js 一起可用。
47
  - Hugging Face CLI 使用当前官方 `hf` 命令。
48
  - `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
 
42
 
43
  要求:
44
 
45
+ - Node.js >=20.9.0
46
  - npm 随 Node.js 一起可用。
47
  - Hugging Face CLI 使用当前官方 `hf` 命令。
48
  - `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "c2357572329b70e2e3b953be74f8d0cedafef5f5",
4
- "created_at": "2026-06-29T10:57:33.027Z",
5
- "deploy_id": "a2febf58-db5e-4d67-a4b0-7968d3d42118"
6
  }
 
1
  {
2
  "schema_version": 1,
3
+ "local_sha": "653775e99286817989ad7a4dfb56eedae605cdcb",
4
+ "created_at": "2026-06-30T11:54:17.314Z",
5
+ "deploy_id": "5a7966ca-fd3b-44a4-aea7-e7a5a4566be3"
6
  }
scripts/agent-doctor.mjs CHANGED
@@ -12,6 +12,8 @@ import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/
12
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
13
  const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
14
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
 
 
15
  const PAGE_SSE_GENERATE_SMOKE_NAME = 'responses_page_sse_generate_1k';
16
  const RESPONSES_AGENT_GENERATE_SMOKE_NAME = 'responses_agent_generate_1k';
17
 
@@ -131,7 +133,8 @@ function buildSkippedSmoke(options) {
131
  skipped: true,
132
  reason: 'requires --allow-billable',
133
  checks: [
134
- { name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
 
135
  { name: PAGE_SSE_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
136
  { name: RESPONSES_AGENT_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
137
  {
@@ -150,7 +153,26 @@ function buildSkippedSmoke(options) {
150
 
151
  function runBillableSmoke(options, baseUrl) {
152
  const checks = [
153
- runSmokeCommand('generate_1k', [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  GENERATE_SCRIPT,
155
  '--base-url',
156
  baseUrl,
@@ -162,9 +184,13 @@ function runBillableSmoke(options, baseUrl) {
162
  '1024x1024',
163
  '--quality',
164
  'low',
 
 
 
 
165
  '--idempotency-key',
166
- `agent-doctor-generate-${Date.now()}`,
167
- 'agent doctor 1k generate smoke'
168
  ]),
169
  runSmokeCommand(PAGE_SSE_GENERATE_SMOKE_NAME, [
170
  GENERATE_SCRIPT,
@@ -383,6 +409,8 @@ function buildSummary({ capabilities, runtime, contract, smoke }) {
383
  ? Boolean(process.env.GPT_IMAGE_APP_PASSWORD_HASH)
384
  : capabilities.ok,
385
  page_sse_real_smoke: summarizePageSseSmoke(smoke),
 
 
386
  responses_page_sse_generate_smoke: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
387
  responses_agent_generate_smoke: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
388
  real_smoke_checks: summarizeSmokeChecks(smoke),
@@ -442,7 +470,8 @@ function summarizePageSseSmoke(smoke) {
442
 
443
  function summarizeSmokeChecks(smoke) {
444
  return {
445
- agent_generate_1k: summarizeSmokeCheck(smoke, 'generate_1k'),
 
446
  responses_page_sse_generate_1k: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
447
  responses_agent_generate_1k: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
448
  agent_edit_1k: summarizeSmokeCheck(smoke, 'edit_1k'),
 
12
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
13
  const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
14
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
15
+ const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k';
16
+ const AGENT_GENERATE_SMOKE_NAME = 'agent_generate_1k';
17
  const PAGE_SSE_GENERATE_SMOKE_NAME = 'responses_page_sse_generate_1k';
18
  const RESPONSES_AGENT_GENERATE_SMOKE_NAME = 'responses_agent_generate_1k';
19
 
 
133
  skipped: true,
134
  reason: 'requires --allow-billable',
135
  checks: [
136
+ { name: ORCHESTRATION_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
137
+ { name: AGENT_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
138
  { name: PAGE_SSE_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
139
  { name: RESPONSES_AGENT_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
140
  {
 
153
 
154
  function runBillableSmoke(options, baseUrl) {
155
  const checks = [
156
+ runSmokeCommand(ORCHESTRATION_GENERATE_SMOKE_NAME, [
157
+ GENERATE_SCRIPT,
158
+ '--base-url',
159
+ baseUrl,
160
+ '--allow-billable',
161
+ '--timeout-ms',
162
+ String(options.timeoutMs),
163
+ '--size',
164
+ '1024x1024',
165
+ '--quality',
166
+ 'low',
167
+ '--image-backend',
168
+ 'images-api',
169
+ '--stream-mode',
170
+ 'non_stream',
171
+ '--idempotency-key',
172
+ `agent-doctor-orchestration-generate-${Date.now()}`,
173
+ 'agent doctor orchestration 1k generate smoke'
174
+ ]),
175
+ runSmokeCommand(AGENT_GENERATE_SMOKE_NAME, [
176
  GENERATE_SCRIPT,
177
  '--base-url',
178
  baseUrl,
 
184
  '1024x1024',
185
  '--quality',
186
  'low',
187
+ '--image-backend',
188
+ 'images-api',
189
+ '--stream-mode',
190
+ 'non_stream',
191
  '--idempotency-key',
192
+ `agent-doctor-agent-generate-${Date.now()}`,
193
+ 'agent doctor agent JSON 1k generate smoke'
194
  ]),
195
  runSmokeCommand(PAGE_SSE_GENERATE_SMOKE_NAME, [
196
  GENERATE_SCRIPT,
 
409
  ? Boolean(process.env.GPT_IMAGE_APP_PASSWORD_HASH)
410
  : capabilities.ok,
411
  page_sse_real_smoke: summarizePageSseSmoke(smoke),
412
+ orchestration_generate_smoke: summarizeSmokeCheck(smoke, ORCHESTRATION_GENERATE_SMOKE_NAME),
413
+ agent_generate_smoke: summarizeSmokeCheck(smoke, AGENT_GENERATE_SMOKE_NAME),
414
  responses_page_sse_generate_smoke: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
415
  responses_agent_generate_smoke: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
416
  real_smoke_checks: summarizeSmokeChecks(smoke),
 
470
 
471
  function summarizeSmokeChecks(smoke) {
472
  return {
473
+ orchestration_generate_1k: summarizeSmokeCheck(smoke, ORCHESTRATION_GENERATE_SMOKE_NAME),
474
+ agent_generate_1k: summarizeSmokeCheck(smoke, AGENT_GENERATE_SMOKE_NAME),
475
  responses_page_sse_generate_1k: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
476
  responses_agent_generate_1k: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
477
  agent_edit_1k: summarizeSmokeCheck(smoke, 'edit_1k'),
scripts/command-center.test.mjs CHANGED
@@ -586,6 +586,14 @@ describe('Command center scripts', () => {
586
  has_password_hash: false,
587
  has_any_auth: true
588
  });
 
 
 
 
 
 
 
 
589
  assert.deepEqual(report.private_agent_env, {
590
  exists: true,
591
  has_token: true,
@@ -633,6 +641,55 @@ describe('Command center scripts', () => {
633
  );
634
  });
635
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
  it('formats first-run as human-readable text by default', async () => {
637
  const report = await buildFirstRunReport(
638
  { cwd: process.cwd(), baseUrl: 'not a url', envFiles: [] },
@@ -889,8 +946,11 @@ describe('Command center scripts', () => {
889
  assert.equal(body.summary.page_sse_auth_ready, false);
890
  assert.equal(body.summary.page_sse_declared_supported, true);
891
  assert.equal(body.summary.page_sse_real_smoke, 'skipped');
 
 
892
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'skipped');
893
  assert.deepEqual(body.summary.real_smoke_checks, {
 
894
  agent_generate_1k: 'skipped',
895
  responses_page_sse_generate_1k: 'skipped',
896
  responses_agent_generate_1k: 'skipped',
@@ -908,6 +968,10 @@ describe('Command center scripts', () => {
908
  assert.deepEqual(body.summary.request_modes.smoke['responses-non-stream'].checks, [
909
  'responses_agent_generate_1k'
910
  ]);
 
 
 
 
911
  assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'skipped');
912
  assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'skipped');
913
  assert.equal(body.summary.request_modes.smoke['responses-sse'].billable, false);
@@ -1071,26 +1135,85 @@ describe('Command center scripts', () => {
1071
  return;
1072
  }
1073
  if (request.url === '/api/agent/image-requests') {
1074
- response.writeHead(400, { 'content-type': 'application/json' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1075
  response.end(
1076
  JSON.stringify({
1077
- error: {
1078
- code: 'idempotency_key_required',
1079
- message: 'missing key',
1080
- retryable: false
 
 
 
 
 
 
 
 
 
1081
  }
1082
  })
1083
  );
1084
  return;
1085
  }
1086
  if (request.url === '/api/agent/jobs/images/generate') {
1087
- response.writeHead(400, { 'content-type': 'application/json' });
1088
  response.end(
1089
  JSON.stringify({
1090
- error: {
1091
- code: 'idempotency_key_required',
1092
- message: 'missing key',
1093
- retryable: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1094
  }
1095
  })
1096
  );
@@ -1110,8 +1233,11 @@ describe('Command center scripts', () => {
1110
  assert.equal(body.service_base_url_source, 'user_provided');
1111
  assert.equal(body.interactive_confirmation_required, false);
1112
  assert.equal(body.summary.page_sse_real_smoke, 'passed');
 
 
1113
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'passed');
1114
  assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
 
1115
  assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
1116
  assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'passed');
1117
  assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
@@ -1136,6 +1262,11 @@ describe('Command center scripts', () => {
1136
  JSON.stringify({
1137
  defaults: { state_backend: 'memory' },
1138
  storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
 
 
 
 
 
1139
  agent_streaming: { page_sse: { supported: true } },
1140
  agent_jobs: { supported: true },
1141
  routing_rules: {},
@@ -1171,6 +1302,55 @@ describe('Command center scripts', () => {
1171
  response.end(JSON.stringify({ images: [{ id: 'doctor-generate', filename: 'doctor.png' }] }));
1172
  return;
1173
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1174
  if (request.url === '/api/images') {
1175
  (async () => {
1176
  const requestText = await readRequestText(request);
@@ -1214,8 +1394,11 @@ describe('Command center scripts', () => {
1214
  assert.equal(body.ok, false);
1215
  assert.equal(body.summary.billable_smoke, 'failed');
1216
  assert.equal(body.summary.page_sse_real_smoke, 'failed');
 
 
1217
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'failed');
1218
  assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
 
1219
  assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
1220
  assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'failed');
1221
  assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
 
586
  has_password_hash: false,
587
  has_any_auth: true
588
  });
589
+ assert.deepEqual(report.agent_auth_service, {
590
+ required: true,
591
+ required_schemes: ['bearer'],
592
+ ready: true,
593
+ has_token: true,
594
+ has_password_hash: false,
595
+ has_any_auth: true
596
+ });
597
  assert.deepEqual(report.private_agent_env, {
598
  exists: true,
599
  has_token: true,
 
641
  );
642
  });
643
 
644
+ it('treats bearer-required services as not ready when only APP_PASSWORD is loaded', async () => {
645
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'first-run-auth-bearer-'));
646
+ await writeFile(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'demo', version: '1.0.0' }));
647
+ await withServer(
648
+ (_request, response) => {
649
+ response.writeHead(200, { 'content-type': 'application/json' });
650
+ response.end(
651
+ JSON.stringify({
652
+ auth: { schemes: ['bearer'] },
653
+ defaults: { state_backend: 'memory' }
654
+ })
655
+ );
656
+ },
657
+ async (baseUrl) => {
658
+ try {
659
+ const report = await buildFirstRunReport(
660
+ {
661
+ cwd: tempDir,
662
+ baseUrl,
663
+ envFiles: ['.env.agent.local'],
664
+ timeoutMs: 1000
665
+ },
666
+ { GPT_IMAGE_APP_PASSWORD_HASH: 'shell-secret' }
667
+ );
668
+
669
+ assert.deepEqual(report.agent_auth_process, {
670
+ has_token: false,
671
+ has_password_hash: true,
672
+ has_any_auth: true
673
+ });
674
+ assert.deepEqual(report.agent_auth_service, {
675
+ required: true,
676
+ required_schemes: ['bearer'],
677
+ ready: false,
678
+ has_token: false,
679
+ has_password_hash: false,
680
+ has_any_auth: false
681
+ });
682
+ assert.match(formatFirstRunText(report), /当前进程鉴权:已加载访问码哈希/);
683
+ assert.match(formatFirstRunText(report), /服务鉴权:未满足当前服务鉴权要求/);
684
+ assert.match(JSON.stringify(report.next_actions), /GPT_IMAGE_AGENT_TOKEN/);
685
+ assert.doesNotMatch(JSON.stringify(report.next_actions), /GPT_IMAGE_APP_PASSWORD_HASH/);
686
+ } finally {
687
+ await rm(tempDir, { recursive: true, force: true });
688
+ }
689
+ }
690
+ );
691
+ });
692
+
693
  it('formats first-run as human-readable text by default', async () => {
694
  const report = await buildFirstRunReport(
695
  { cwd: process.cwd(), baseUrl: 'not a url', envFiles: [] },
 
946
  assert.equal(body.summary.page_sse_auth_ready, false);
947
  assert.equal(body.summary.page_sse_declared_supported, true);
948
  assert.equal(body.summary.page_sse_real_smoke, 'skipped');
949
+ assert.equal(body.summary.orchestration_generate_smoke, 'skipped');
950
+ assert.equal(body.summary.agent_generate_smoke, 'skipped');
951
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'skipped');
952
  assert.deepEqual(body.summary.real_smoke_checks, {
953
+ orchestration_generate_1k: 'skipped',
954
  agent_generate_1k: 'skipped',
955
  responses_page_sse_generate_1k: 'skipped',
956
  responses_agent_generate_1k: 'skipped',
 
968
  assert.deepEqual(body.summary.request_modes.smoke['responses-non-stream'].checks, [
969
  'responses_agent_generate_1k'
970
  ]);
971
+ assert.deepEqual(body.summary.request_modes.smoke['images-non-stream'].checks, [
972
+ 'orchestration_generate_1k',
973
+ 'edit_1k'
974
+ ]);
975
  assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'skipped');
976
  assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'skipped');
977
  assert.equal(body.summary.request_modes.smoke['responses-sse'].billable, false);
 
1135
  return;
1136
  }
1137
  if (request.url === '/api/agent/image-requests') {
1138
+ if (request.headers['idempotency-key']) {
1139
+ response.writeHead(202, { 'content-type': 'application/json' });
1140
+ response.end(
1141
+ JSON.stringify({
1142
+ job: {
1143
+ id: 'doctor-orchestration-job',
1144
+ state: 'running',
1145
+ result_url: '/api/agent/jobs/doctor-orchestration-job/result',
1146
+ retry_after_seconds: 1
1147
+ }
1148
+ })
1149
+ );
1150
+ } else {
1151
+ response.writeHead(400, { 'content-type': 'application/json' });
1152
+ response.end(
1153
+ JSON.stringify({
1154
+ error: {
1155
+ code: 'idempotency_key_required',
1156
+ message: 'missing key',
1157
+ retryable: false
1158
+ }
1159
+ })
1160
+ );
1161
+ }
1162
+ return;
1163
+ }
1164
+ if (request.url === '/api/agent/jobs/doctor-orchestration-job/result') {
1165
+ response.writeHead(200, { 'content-type': 'application/json' });
1166
  response.end(
1167
  JSON.stringify({
1168
+ request_id: 'doctor-orchestration-job',
1169
+ idempotency_key: 'agent-doctor-orchestration-generate',
1170
+ cached: false,
1171
+ images: [{ id: 'doctor-orchestration', filename: 'doctor-orchestration.png' }],
1172
+ execution: {
1173
+ transport: 'server_orchestrated',
1174
+ endpoint: '/api/agent/image-requests',
1175
+ route_mode: 'auto',
1176
+ image_backend: 'images-api',
1177
+ stream_mode: 'non_stream',
1178
+ streaming_strategy: 'off',
1179
+ channel_request_mode: 'images-non-stream',
1180
+ channel_request_mode_fallback_applied: false
1181
  }
1182
  })
1183
  );
1184
  return;
1185
  }
1186
  if (request.url === '/api/agent/jobs/images/generate') {
1187
+ response.writeHead(202, { 'content-type': 'application/json' });
1188
  response.end(
1189
  JSON.stringify({
1190
+ job: {
1191
+ id: 'doctor-agent-job',
1192
+ state: 'running',
1193
+ result_url: '/api/agent/jobs/doctor-agent-job/result',
1194
+ retry_after_seconds: 1
1195
+ }
1196
+ })
1197
+ );
1198
+ return;
1199
+ }
1200
+ if (request.url === '/api/agent/jobs/doctor-agent-job/result') {
1201
+ response.writeHead(200, { 'content-type': 'application/json' });
1202
+ response.end(
1203
+ JSON.stringify({
1204
+ request_id: 'doctor-agent-job',
1205
+ idempotency_key: 'agent-doctor-agent-generate',
1206
+ cached: false,
1207
+ images: [{ id: 'doctor-agent', filename: 'doctor-agent.png' }],
1208
+ execution: {
1209
+ transport: 'agent_json',
1210
+ endpoint: '/api/agent/jobs/images/generate',
1211
+ route_mode: 'agent',
1212
+ image_backend: 'images-api',
1213
+ stream_mode: 'non_stream',
1214
+ streaming_strategy: 'off',
1215
+ channel_request_mode: 'images-non-stream',
1216
+ channel_request_mode_fallback_applied: false
1217
  }
1218
  })
1219
  );
 
1233
  assert.equal(body.service_base_url_source, 'user_provided');
1234
  assert.equal(body.interactive_confirmation_required, false);
1235
  assert.equal(body.summary.page_sse_real_smoke, 'passed');
1236
+ assert.equal(body.summary.orchestration_generate_smoke, 'passed');
1237
+ assert.equal(body.summary.agent_generate_smoke, 'passed');
1238
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'passed');
1239
  assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
1240
+ assert.equal(body.summary.real_smoke_checks.orchestration_generate_1k, 'passed');
1241
  assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
1242
  assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'passed');
1243
  assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
 
1262
  JSON.stringify({
1263
  defaults: { state_backend: 'memory' },
1264
  storage: { image_storage_mode: 'indexeddb', postgres_configured: false },
1265
+ orchestration: {
1266
+ supported: true,
1267
+ endpoint: '/api/agent/image-requests',
1268
+ transport_selection: 'server_owned'
1269
+ },
1270
  agent_streaming: { page_sse: { supported: true } },
1271
  agent_jobs: { supported: true },
1272
  routing_rules: {},
 
1302
  response.end(JSON.stringify({ images: [{ id: 'doctor-generate', filename: 'doctor.png' }] }));
1303
  return;
1304
  }
1305
+ if (request.url === '/api/agent/image-requests') {
1306
+ if (request.headers['idempotency-key']) {
1307
+ response.writeHead(202, { 'content-type': 'application/json' });
1308
+ response.end(
1309
+ JSON.stringify({
1310
+ job: {
1311
+ id: 'doctor-orchestration-job',
1312
+ state: 'running',
1313
+ result_url: '/api/agent/jobs/doctor-orchestration-job/result',
1314
+ retry_after_seconds: 1
1315
+ }
1316
+ })
1317
+ );
1318
+ } else {
1319
+ response.writeHead(400, { 'content-type': 'application/json' });
1320
+ response.end(
1321
+ JSON.stringify({
1322
+ error: {
1323
+ code: 'idempotency_key_required',
1324
+ message: 'missing key',
1325
+ retryable: false
1326
+ }
1327
+ })
1328
+ );
1329
+ }
1330
+ return;
1331
+ }
1332
+ if (request.url === '/api/agent/jobs/doctor-orchestration-job/result') {
1333
+ response.writeHead(200, { 'content-type': 'application/json' });
1334
+ response.end(
1335
+ JSON.stringify({
1336
+ request_id: 'doctor-orchestration-job',
1337
+ idempotency_key: 'agent-doctor-orchestration-generate',
1338
+ cached: false,
1339
+ images: [{ id: 'doctor-orchestration', filename: 'doctor-orchestration.png' }],
1340
+ execution: {
1341
+ transport: 'server_orchestrated',
1342
+ endpoint: '/api/agent/image-requests',
1343
+ route_mode: 'auto',
1344
+ image_backend: 'images-api',
1345
+ stream_mode: 'non_stream',
1346
+ streaming_strategy: 'off',
1347
+ channel_request_mode: 'images-non-stream',
1348
+ channel_request_mode_fallback_applied: false
1349
+ }
1350
+ })
1351
+ );
1352
+ return;
1353
+ }
1354
  if (request.url === '/api/images') {
1355
  (async () => {
1356
  const requestText = await readRequestText(request);
 
1394
  assert.equal(body.ok, false);
1395
  assert.equal(body.summary.billable_smoke, 'failed');
1396
  assert.equal(body.summary.page_sse_real_smoke, 'failed');
1397
+ assert.equal(body.summary.orchestration_generate_smoke, 'passed');
1398
+ assert.equal(body.summary.agent_generate_smoke, 'passed');
1399
  assert.equal(body.summary.responses_page_sse_generate_smoke, 'failed');
1400
  assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
1401
+ assert.equal(body.summary.real_smoke_checks.orchestration_generate_1k, 'passed');
1402
  assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
1403
  assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'failed');
1404
  assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
scripts/doctor-hf-space.mjs CHANGED
@@ -14,7 +14,7 @@ import {
14
  validateSpaceUrl
15
  } from './hf-space-doctor-utils.mjs';
16
 
17
- const MIN_NODE_MAJOR = 20;
18
  const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
19
  const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
20
  const REQUIRED_SPACE_VARIABLE_VALUES = new Map([
@@ -46,12 +46,14 @@ function addCheck(checks, status, name, message, details = {}) {
46
  }
47
 
48
  function checkNode(checks) {
49
- const major = Number.parseInt(process.versions.node.split('.')[0], 10);
50
- if (major >= MIN_NODE_MAJOR) {
 
 
51
  addCheck(checks, 'pass', 'node', `Node.js ${process.version} is supported.`);
52
  return;
53
  }
54
- addCheck(checks, 'fail', 'node', `Node.js ${process.version} is too old. Install Node.js ${MIN_NODE_MAJOR} or newer.`);
55
  }
56
 
57
  function checkCommand(checks, name, command, args, failureAction) {
@@ -179,7 +181,7 @@ function main() {
179
 
180
  const checks = [];
181
  checkNode(checks);
182
- checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.');
183
  const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
184
  const hfAuth = hfAvailable ? runDoctorCommand('hf', ['auth', 'whoami']) : { ok: false };
185
  if (hfAvailable && hfAuth.ok) {
 
14
  validateSpaceUrl
15
  } from './hf-space-doctor-utils.mjs';
16
 
17
+ const MIN_NODE_VERSION = '>=20.9.0';
18
  const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
19
  const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
20
  const REQUIRED_SPACE_VARIABLE_VALUES = new Map([
 
46
  }
47
 
48
  function checkNode(checks) {
49
+ const match = process.version.match(/^v(\d+)\.(\d+)\./);
50
+ const major = match ? Number(match[1]) : 0;
51
+ const minor = match ? Number(match[2]) : 0;
52
+ if (major > 20 || (major === 20 && minor >= 9)) {
53
  addCheck(checks, 'pass', 'node', `Node.js ${process.version} is supported.`);
54
  return;
55
  }
56
+ addCheck(checks, 'fail', 'node', `Node.js ${process.version} is too old. Install Node.js ${MIN_NODE_VERSION} or newer.`);
57
  }
58
 
59
  function checkCommand(checks, name, command, args, failureAction) {
 
181
 
182
  const checks = [];
183
  checkNode(checks);
184
+ checkCommand(checks, 'npm', 'npm', ['--version'], `npm is missing. Install Node.js ${MIN_NODE_VERSION} or newer with npm.`);
185
  const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
186
  const hfAuth = hfAvailable ? runDoctorCommand('hf', ['auth', 'whoami']) : { ok: false };
187
  if (hfAvailable && hfAuth.ok) {
scripts/first-run.mjs CHANGED
@@ -12,6 +12,7 @@ import {
12
 
13
  const DEFAULT_TIMEOUT_MS = 3000;
14
  const DEFAULT_ENV_FILES = ['.env.local', '.env.agent.local'];
 
15
 
16
  function parseArgs(argv) {
17
  const parsed = {
@@ -58,7 +59,7 @@ export async function buildFirstRunReport(options = {}, env = process.env) {
58
  timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
59
  headers: authHeaders(env)
60
  });
61
- const agentAuth = readAgentAuthState(env, envSummary);
62
  const checks = buildChecks({
63
  cwd,
64
  env,
@@ -76,6 +77,7 @@ export async function buildFirstRunReport(options = {}, env = process.env) {
76
  service_base_url_source: base.source,
77
  interactive_confirmation_required: base.interactive_confirmation_required,
78
  agent_auth_process: agentAuth.process,
 
79
  private_agent_env: agentAuth.privateEnv,
80
  checks,
81
  env_sources: envSummary.map((source) => summarizeEnvSource(source, cwd)),
@@ -139,12 +141,11 @@ function buildChecks({ cwd, env, envSummary, service, base, validationError }) {
139
  const packageJson = readPackageJson(cwd);
140
  const packageLock = existsSync(join(cwd, 'package-lock.json'));
141
  const nodeModules = existsSync(join(cwd, 'node_modules'));
142
- const currentToken = Boolean(env.GPT_IMAGE_AGENT_TOKEN || env.GPT_IMAGE_APP_PASSWORD_HASH);
143
- const fileToken = envSummary.some((source) =>
144
- ['GPT_IMAGE_AGENT_TOKEN', 'GPT_IMAGE_APP_PASSWORD_HASH'].some((name) => sourceHasSetVariable(source, name))
145
- );
146
  return [
147
- { name: 'node_version', ok: node.ok, current: node.current, required: '>=20.0.0' },
148
  { name: 'package_lock', ok: packageLock },
149
  {
150
  name: 'dependencies_installed',
@@ -159,9 +160,10 @@ function buildChecks({ cwd, env, envSummary, service, base, validationError }) {
159
  },
160
  {
161
  name: 'agent_auth_available_to_process',
162
- ok: currentToken,
163
  skipped: !requiresAgentAuth(service),
164
- auth_in_private_env_file: fileToken
 
165
  },
166
  {
167
  name: 'page_sse_auth_available_to_process',
@@ -203,16 +205,26 @@ function buildChecks({ cwd, env, envSummary, service, base, validationError }) {
203
  ];
204
  }
205
 
206
- function readAgentAuthState(env, envSummary) {
207
  const hasToken = Boolean(env.GPT_IMAGE_AGENT_TOKEN);
208
  const hasPasswordHash = Boolean(env.GPT_IMAGE_APP_PASSWORD_HASH);
209
  const privateSource = envSummary.find((source) => source.path.endsWith('.env.agent.local'));
 
 
210
  return {
211
  process: {
212
  has_token: hasToken,
213
  has_password_hash: hasPasswordHash,
214
  has_any_auth: hasToken || hasPasswordHash
215
  },
 
 
 
 
 
 
 
 
216
  privateEnv: {
217
  exists: privateSource?.exists === true,
218
  has_token: privateSource ? sourceHasSetVariable(privateSource, 'GPT_IMAGE_AGENT_TOKEN') : false,
@@ -223,11 +235,10 @@ function readAgentAuthState(env, envSummary) {
223
 
224
  function buildNextActions({ checks, base, service, env, envSummary, validationError }) {
225
  const actions = [];
226
- const hasCurrentAuth = Boolean(env.GPT_IMAGE_AGENT_TOKEN || env.GPT_IMAGE_APP_PASSWORD_HASH);
227
- const hasFileAuth = envSummary.some((source) =>
228
- ['GPT_IMAGE_AGENT_TOKEN', 'GPT_IMAGE_APP_PASSWORD_HASH'].some((name) => sourceHasSetVariable(source, name))
229
- );
230
- if (!findCheck(checks, 'node_version').ok) actions.push('安装 Node.js 20 或更新版本。');
231
  if (!findCheck(checks, 'dependencies_installed').ok) actions.push('运行 npm install。');
232
  if (!findCheck(checks, 'env_files').ok) {
233
  actions.push('复制 .env.example 为 .env.local,或在页面设置里配置默认上游。');
@@ -236,10 +247,8 @@ function buildNextActions({ checks, base, service, env, envSummary, validationEr
236
  actions.push('把 GPT_IMAGE_PLAYGROUND_URL 或 --base-url 设为不含凭据、查询参数或片段的 http/https 地址。');
237
  return actions;
238
  }
239
- if (!hasCurrentAuth && requiresAgentAuth(service) && !hasFileAuth) {
240
- actions.push(
241
- '在仓库外导出 GPT_IMAGE_AGENT_TOKEN 或 GPT_IMAGE_APP_PASSWORD_HASH,再运行受保护的 Agent 脚本。'
242
- );
243
  }
244
  if (requiresPageSsePasswordHash(service) && !env.GPT_IMAGE_APP_PASSWORD_HASH) {
245
  actions.push(
@@ -252,10 +261,10 @@ function buildNextActions({ checks, base, service, env, envSummary, validationEr
252
  if (base.interactive_confirmation_required) {
253
  actions.push('在交互式 Agent 任务里,先和用户确认探测到的服务地址,再发真实请求。');
254
  }
255
- if (!hasCurrentAuth && hasFileAuth) {
256
  actions.push('Agent 脚本会自动读取 .env.agent.local;如果仍提示缺少鉴权,请确认文件位于当前仓库根目录且变量名正确。');
257
  }
258
- if (service.ok && hasCurrentAuth) {
259
  actions.push('运行 npm run agent:doctor 做完整的非计费 Agent 合同检查。');
260
  }
261
  return actions;
@@ -271,6 +280,36 @@ function requiresPageSsePasswordHash(service) {
271
  return service.capabilities?.body?.agent_streaming?.page_sse?.auth?.required === true;
272
  }
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  function sourceHasSetVariable(source, name) {
275
  return source.exists && source.variables.some((item) => item.name === name && item.set === true);
276
  }
@@ -355,9 +394,10 @@ function findCheck(checks, name) {
355
  }
356
 
357
  function readNodeCheck() {
358
- const match = process.version.match(/^v(\d+)\./);
359
  const major = match ? Number(match[1]) : 0;
360
- return { ok: major >= 20, current: process.version };
 
361
  }
362
 
363
  function readPackageJson(cwd) {
@@ -405,6 +445,7 @@ export function formatFirstRunText(report) {
405
  }
406
  lines.push(`交互式确认:${report.interactive_confirmation_required ? '需要' : '不需要'}`);
407
  lines.push(`当前进程鉴权:${formatAuthState(report.agent_auth_process)}`);
 
408
  lines.push(`私有 Agent env:${formatPrivateEnvState(report.private_agent_env)}`);
409
  lines.push('');
410
  lines.push('检查项:');
@@ -479,11 +520,18 @@ function buildNotRunSmokeStatus(reason) {
479
 
480
  function formatAuthState(auth) {
481
  if (!auth) return '未知';
 
482
  if (auth.has_token) return '已加载 token';
483
  if (auth.has_password_hash) return '已加载访问码哈希';
484
  return '未加载';
485
  }
486
 
 
 
 
 
 
 
487
  function formatPrivateEnvState(privateEnv) {
488
  if (!privateEnv?.exists) return '不存在';
489
  if (privateEnv.has_token) return '存在,含 token';
 
12
 
13
  const DEFAULT_TIMEOUT_MS = 3000;
14
  const DEFAULT_ENV_FILES = ['.env.local', '.env.agent.local'];
15
+ const MIN_NODE_VERSION = '>=20.9.0';
16
 
17
  function parseArgs(argv) {
18
  const parsed = {
 
59
  timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
60
  headers: authHeaders(env)
61
  });
62
+ const agentAuth = readAgentAuthState(env, envSummary, service);
63
  const checks = buildChecks({
64
  cwd,
65
  env,
 
77
  service_base_url_source: base.source,
78
  interactive_confirmation_required: base.interactive_confirmation_required,
79
  agent_auth_process: agentAuth.process,
80
+ agent_auth_service: agentAuth.service,
81
  private_agent_env: agentAuth.privateEnv,
82
  checks,
83
  env_sources: envSummary.map((source) => summarizeEnvSource(source, cwd)),
 
141
  const packageJson = readPackageJson(cwd);
142
  const packageLock = existsSync(join(cwd, 'package-lock.json'));
143
  const nodeModules = existsSync(join(cwd, 'node_modules'));
144
+ const agentAuthSchemes = readAgentAuthSchemes(service);
145
+ const currentAgentAuth = hasAnyConfiguredAgentAuth(env, agentAuthSchemes);
146
+ const fileAgentAuth = envSummary.some((source) => hasAnyConfiguredAgentAuthSource(source, agentAuthSchemes));
 
147
  return [
148
+ { name: 'node_version', ok: node.ok, current: node.current, required: MIN_NODE_VERSION },
149
  { name: 'package_lock', ok: packageLock },
150
  {
151
  name: 'dependencies_installed',
 
160
  },
161
  {
162
  name: 'agent_auth_available_to_process',
163
+ ok: currentAgentAuth,
164
  skipped: !requiresAgentAuth(service),
165
+ auth_in_private_env_file: fileAgentAuth,
166
+ required_schemes: agentAuthSchemes
167
  },
168
  {
169
  name: 'page_sse_auth_available_to_process',
 
205
  ];
206
  }
207
 
208
+ function readAgentAuthState(env, envSummary, service) {
209
  const hasToken = Boolean(env.GPT_IMAGE_AGENT_TOKEN);
210
  const hasPasswordHash = Boolean(env.GPT_IMAGE_APP_PASSWORD_HASH);
211
  const privateSource = envSummary.find((source) => source.path.endsWith('.env.agent.local'));
212
+ const requiredSchemes = readAgentAuthSchemes(service);
213
+ const serviceRequiresAuth = requiresAgentAuth(service);
214
  return {
215
  process: {
216
  has_token: hasToken,
217
  has_password_hash: hasPasswordHash,
218
  has_any_auth: hasToken || hasPasswordHash
219
  },
220
+ service: {
221
+ required: serviceRequiresAuth,
222
+ required_schemes: requiredSchemes,
223
+ ready: serviceRequiresAuth ? hasAnyConfiguredAgentAuth(env, requiredSchemes) : false,
224
+ has_token: requiredSchemes.includes('bearer') ? hasToken : false,
225
+ has_password_hash: requiredSchemes.includes('x-app-password-hash') ? hasPasswordHash : false,
226
+ has_any_auth: serviceRequiresAuth ? hasAnyConfiguredAgentAuth(env, requiredSchemes) : false
227
+ },
228
  privateEnv: {
229
  exists: privateSource?.exists === true,
230
  has_token: privateSource ? sourceHasSetVariable(privateSource, 'GPT_IMAGE_AGENT_TOKEN') : false,
 
235
 
236
  function buildNextActions({ checks, base, service, env, envSummary, validationError }) {
237
  const actions = [];
238
+ const agentAuthSchemes = readAgentAuthSchemes(service);
239
+ const hasCurrentAgentAuth = hasAnyConfiguredAgentAuth(env, agentAuthSchemes);
240
+ const hasFileAgentAuth = envSummary.some((source) => hasAnyConfiguredAgentAuthSource(source, agentAuthSchemes));
241
+ if (!findCheck(checks, 'node_version').ok) actions.push(`安装 Node.js ${MIN_NODE_VERSION} 或更新版本。`);
 
242
  if (!findCheck(checks, 'dependencies_installed').ok) actions.push('运行 npm install。');
243
  if (!findCheck(checks, 'env_files').ok) {
244
  actions.push('复制 .env.example 为 .env.local,或在页面设置里配置默认上游。');
 
247
  actions.push('把 GPT_IMAGE_PLAYGROUND_URL 或 --base-url 设为不含凭据、查询参数或片段的 http/https 地址。');
248
  return actions;
249
  }
250
+ if (!hasCurrentAgentAuth && requiresAgentAuth(service) && !hasFileAgentAuth) {
251
+ actions.push(buildAgentAuthAction(agentAuthSchemes));
 
 
252
  }
253
  if (requiresPageSsePasswordHash(service) && !env.GPT_IMAGE_APP_PASSWORD_HASH) {
254
  actions.push(
 
261
  if (base.interactive_confirmation_required) {
262
  actions.push('在交互式 Agent 任务里,先和用户确认探测到的服务地址,再发真实请求。');
263
  }
264
+ if (!hasCurrentAgentAuth && hasFileAgentAuth) {
265
  actions.push('Agent 脚本会自动读取 .env.agent.local;如果仍提示缺少鉴权,请确认文件位于当前仓库根目录且变量名正确。');
266
  }
267
+ if (service.ok && hasCurrentAgentAuth) {
268
  actions.push('运行 npm run agent:doctor 做完整的非计费 Agent 合同检查。');
269
  }
270
  return actions;
 
280
  return service.capabilities?.body?.agent_streaming?.page_sse?.auth?.required === true;
281
  }
282
 
283
+ function readAgentAuthSchemes(service) {
284
+ const schemes = service.capabilities?.body?.auth?.schemes;
285
+ if (!Array.isArray(schemes)) return [];
286
+ return schemes.filter((scheme) => typeof scheme === 'string');
287
+ }
288
+
289
+ function hasAnyConfiguredAgentAuth(env, schemes) {
290
+ if (schemes.includes('bearer')) return Boolean(env.GPT_IMAGE_AGENT_TOKEN);
291
+ if (schemes.includes('x-app-password-hash')) return Boolean(env.GPT_IMAGE_APP_PASSWORD_HASH);
292
+ return Boolean(env.GPT_IMAGE_AGENT_TOKEN || env.GPT_IMAGE_APP_PASSWORD_HASH);
293
+ }
294
+
295
+ function hasAnyConfiguredAgentAuthSource(source, schemes) {
296
+ if (schemes.includes('bearer')) return sourceHasSetVariable(source, 'GPT_IMAGE_AGENT_TOKEN');
297
+ if (schemes.includes('x-app-password-hash')) return sourceHasSetVariable(source, 'GPT_IMAGE_APP_PASSWORD_HASH');
298
+ return (
299
+ sourceHasSetVariable(source, 'GPT_IMAGE_AGENT_TOKEN') || sourceHasSetVariable(source, 'GPT_IMAGE_APP_PASSWORD_HASH')
300
+ );
301
+ }
302
+
303
+ function buildAgentAuthAction(schemes) {
304
+ if (schemes.includes('bearer')) {
305
+ return '在仓库外导出 GPT_IMAGE_AGENT_TOKEN,再运行受保护的 Agent 脚本。';
306
+ }
307
+ if (schemes.includes('x-app-password-hash')) {
308
+ return '在仓库外导出 GPT_IMAGE_APP_PASSWORD_HASH,再运行受保护的 Agent 脚本。';
309
+ }
310
+ return '在仓库外导出 GPT_IMAGE_AGENT_TOKEN 或 GPT_IMAGE_APP_PASSWORD_HASH,再运行受保护的 Agent 脚本。';
311
+ }
312
+
313
  function sourceHasSetVariable(source, name) {
314
  return source.exists && source.variables.some((item) => item.name === name && item.set === true);
315
  }
 
394
  }
395
 
396
  function readNodeCheck() {
397
+ const match = process.version.match(/^v(\d+)\.(\d+)\./);
398
  const major = match ? Number(match[1]) : 0;
399
+ const minor = match ? Number(match[2]) : 0;
400
+ return { ok: major > 20 || (major === 20 && minor >= 9), current: process.version };
401
  }
402
 
403
  function readPackageJson(cwd) {
 
445
  }
446
  lines.push(`交互式确认:${report.interactive_confirmation_required ? '需要' : '不需要'}`);
447
  lines.push(`当前进程鉴权:${formatAuthState(report.agent_auth_process)}`);
448
+ lines.push(`服务鉴权:${formatServiceAuthState(report.agent_auth_service)}`);
449
  lines.push(`私有 Agent env:${formatPrivateEnvState(report.private_agent_env)}`);
450
  lines.push('');
451
  lines.push('检查项:');
 
520
 
521
  function formatAuthState(auth) {
522
  if (!auth) return '未知';
523
+ if (auth.has_token && auth.has_password_hash) return '已加载 token + 访问码哈希';
524
  if (auth.has_token) return '已加载 token';
525
  if (auth.has_password_hash) return '已加载访问码哈希';
526
  return '未加载';
527
  }
528
 
529
+ function formatServiceAuthState(auth) {
530
+ if (!auth) return '未知';
531
+ if (auth.required === true) return auth.ready ? '已满足当前服务鉴权要求' : '未满足当前服务鉴权要求';
532
+ return '不需要';
533
+ }
534
+
535
  function formatPrivateEnvState(privateEnv) {
536
  if (!privateEnv?.exists) return '不存在';
537
  if (privateEnv.has_token) return '存在,含 token';
scripts/hf-space-doctor-utils.mjs CHANGED
@@ -111,8 +111,8 @@ export function classifyRequiredAndRecommendedNames(names, requiredNames, recomm
111
  }
112
 
113
  const NEXT_ACTIONS = new Map([
114
- ['node', 'Install Node.js 20 or newer, then reopen the terminal.'],
115
- ['npm', 'Install Node.js 20 or newer with npm.'],
116
  ['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
117
  ['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
118
  ['node-modules', 'Run npm install.'],
 
111
  }
112
 
113
  const NEXT_ACTIONS = new Map([
114
+ ['node', 'Install Node.js >=20.9.0, then reopen the terminal.'],
115
+ ['npm', 'Install Node.js >=20.9.0 with npm.'],
116
  ['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
117
  ['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
118
  ['node-modules', 'Run npm install.'],
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -16,7 +16,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
16
  - 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。
17
  - 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
18
  - 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
19
- - 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
@@ -151,9 +151,11 @@ Authorization: Bearer <token>
151
  | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
152
  | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
153
  | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
 
 
154
  | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
155
  | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
156
- | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
157
  | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
158
  | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
159
  | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
 
16
  - 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。
17
  - 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
18
  - 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
19
+ - 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。接入新上游渠道时,先确认 `/models` 和 `/images/generations` 能通,再用 `npm run smoke:image-upstream-real -- --allow-billable` 逐个验证 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`;如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就把该渠道的 `responses-non-stream`、`responses-sse` 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
 
151
  | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
152
  | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
153
  | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
154
+ | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
155
+ | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
156
  | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
157
  | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
158
+ | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
159
  | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
160
  | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
161
  | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -210,7 +210,7 @@ GET /api/agent/capabilities
210
  - `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
211
  - `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
212
  - `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
213
- - `request_mode_controls`:管理员 request mode 白名单控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`;Agent 客户端只能用于解释执行结果,不应据此自行选择上游请求方式。
214
  - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
215
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
216
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
@@ -588,9 +588,11 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
588
  | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
589
  | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
590
  | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
 
 
591
  | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
592
  | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
593
- | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
594
  | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
595
  | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
596
  | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
 
210
  - `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
211
  - `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
212
  - `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
213
+ - `request_mode_controls`:管理员 request mode 白名单控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`;Agent 客户端只能用于解释执行结果,不应据此自行选择上游请求方式。接入新渠道时,先用 `scripts/probe-upstream-image.mjs` 验证 `/models` 和 `/images/generations`,再用 `npm run smoke:image-upstream-real -- --allow-billable` 跑 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse` 之类的真实 smoke;如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就把该渠道的 `responses-non-stream` 和 `responses-sse` 从白名单里删掉,只保留通过的 `images-*` 模式。
214
  - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
215
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
216
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
 
588
  | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
589
  | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
590
  | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
591
+ | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
592
+ | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
593
  | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
594
  | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
595
+ | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
596
  | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
597
  | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
598
  | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
src/app/api/deploy-marker/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextResponse } from 'next/server';
2
 
3
- const deployMarker = {"schema_version":1,"local_sha":"c2357572329b70e2e3b953be74f8d0cedafef5f5","created_at":"2026-06-29T10:57:33.027Z","deploy_id":"a2febf58-db5e-4d67-a4b0-7968d3d42118"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"653775e99286817989ad7a4dfb56eedae605cdcb","created_at":"2026-06-30T11:54:17.314Z","deploy_id":"5a7966ca-fd3b-44a4-aea7-e7a5a4566be3"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
src/app/page-regressions.test.ts CHANGED
@@ -24,4 +24,36 @@ describe('page state regressions', () => {
24
  /const allowResponsesImageBackend = isResponsesImageBackendRuntimeEnabled\(runtimeCapabilities \?\? \{\}\)/
25
  );
26
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  });
 
24
  /const allowResponsesImageBackend = isResponsesImageBackendRuntimeEnabled\(runtimeCapabilities \?\? \{\}\)/
25
  );
26
  });
27
+
28
+ it('treats request-level API key as an override while rejecting base URL-only settings', async () => {
29
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
30
+ const runtimeStatusCall = source.match(/resolveRuntimeHealthStatus\(\{([\s\S]*?)\n\s*\}\)/)?.[1] ?? '';
31
+
32
+ assert.match(source, /const hasRequestApiKey = apiSettings\.apiKey\.trim\(\)\.length > 0;/);
33
+ assert.match(source, /const hasRequestApiOverride = hasRequestApiKey;/);
34
+ assert.match(runtimeStatusCall, /hasRequestApiOverride/);
35
+ assert.match(runtimeStatusCall, /imageBackend: activeWorkbenchBackend/);
36
+ assert.match(source, /if \(settings\.baseUrl && !settings\.apiKey\) \{\s*throw new Error\(t\('api\.urlPairRequired'\)\);\s*\}/);
37
+ assert.match(source, /if \(apiSettings\.baseUrl && apiSettings\.apiKey\) \{/);
38
+ assert.doesNotMatch(source, /hasPairedRequestApiOverride/);
39
+ });
40
+
41
+ it('cleans stale local request API overrides that only persisted a base URL', async () => {
42
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
43
+
44
+ assert.match(source, /function normalizeStoredApiSettings\(settings: Partial<ApiSettings>\): StoredApiSettingsReadResult/);
45
+ assert.match(source, /if \(!apiKey && baseUrl\) \{\s*return \{ settings: emptyApiSettings, shouldPersist: false, shouldRemove: true \};\s*\}/);
46
+ assert.match(source, /const storedApiSettings = readStoredApiSettings\(\);/);
47
+ assert.match(source, /if \(storedApiSettings\.shouldRemove\) \{\s*window\.localStorage\.removeItem\(apiSettingsLocalStorageKey\);\s*\}/);
48
+ assert.match(source, /else if \(storedApiSettings\.shouldPersist\) \{\s*window\.localStorage\.setItem\(apiSettingsLocalStorageKey, JSON\.stringify\(storedApiSettings\.settings\)\);\s*\}/);
49
+ });
50
+
51
+ it('shows the current form route in the status strip instead of inferring from server capability modes', async () => {
52
+ const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
53
+
54
+ assert.match(source, /const activeWorkbenchBackend = usesEditControls \? editImageBackend : genImageBackend;/);
55
+ assert.match(source, /const activeRouteLabel = getWorkbenchRouteLabel\(activeWorkbenchBackend, t\);/);
56
+ assert.match(source, /routeLabel=\{activeRouteLabel\}/);
57
+ assert.doesNotMatch(source, /const activeRequestModeLabel = React\.useMemo/);
58
+ });
59
  });
src/app/page.tsx CHANGED
@@ -89,6 +89,7 @@ import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstre
89
  import { resolveMobileCreationSheetGesture } from '@/lib/mobile-creation-sheet-gesture';
90
  import { resolveMobilePrimaryDisabledReason } from '@/lib/mobile-primary-action-state';
91
  import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth';
 
92
  import { sha256Hex } from '@/lib/sha256';
93
  import { createImageShareFromBlob } from '@/lib/share-client';
94
  import { getPresetDimensions, validateGptImage2Size, validatePositiveIntegerImageSize } from '@/lib/size-utils';
@@ -154,6 +155,11 @@ type FeedbackSyncPayload = HistoryFeedbackSyncPayload;
154
  type FeedbackSyncScheduleOptions = {
155
  pruneQueuedTargets?: boolean;
156
  };
 
 
 
 
 
157
 
158
  function createFeedbackRequestSignal(signal: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
159
  const controller = new AbortController();
@@ -173,7 +179,7 @@ function createFeedbackRequestSignal(signal: AbortSignal): { signal: AbortSignal
173
  };
174
  }
175
 
176
- function getImageBackendLabel(backend: ImageUpstreamFormBackend, t: (key: string) => string): string {
177
  if (backend === 'images-api') return t('upstream.backendImages');
178
  if (backend === 'responses-image-generation') return t('upstream.backendResponses');
179
  return t('upstream.workbenchDefaultRoute');
@@ -221,19 +227,35 @@ function readStoredHistory(): HistoryMetadata[] {
221
  return [];
222
  }
223
 
224
- function readStoredApiSettings(): ApiSettings {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  const storedApiSettings = readLocalStorageValue(apiSettingsLocalStorageKey);
226
- if (!storedApiSettings) return emptyApiSettings;
 
 
227
  try {
228
  const parsedSettings = JSON.parse(storedApiSettings) as Partial<ApiSettings>;
229
- return {
230
- apiKey: typeof parsedSettings.apiKey === 'string' ? parsedSettings.apiKey : '',
231
- baseUrl: typeof parsedSettings.baseUrl === 'string' ? parsedSettings.baseUrl : ''
232
- };
233
  } catch (error) {
234
  console.error('从 localStorage 加载 API 设置失败:', error);
235
  window.localStorage.removeItem(apiSettingsLocalStorageKey);
236
- return emptyApiSettings;
237
  }
238
  }
239
 
@@ -320,6 +342,7 @@ type ApiUsage = {
320
 
321
  type RuntimeCapabilities = {
322
  streaming?: {
 
323
  defaultMode?: ImageStreamMode;
324
  defaultStrategy?: ImageStreamingStrategy;
325
  };
@@ -348,6 +371,17 @@ type RuntimeCapabilities = {
348
  };
349
  channelRouting?: {
350
  effectiveRequestModes?: string[];
 
 
 
 
 
 
 
 
 
 
 
351
  };
352
  upstreamProfile?: {
353
  activeProfile: ImageUpstreamProfileId;
@@ -612,13 +646,13 @@ export default function HomePage() {
612
  ? [runtimeCapabilities.upstreamProfile.serverProfile]
613
  : []
614
  });
615
- const hasRequestApiBaseUrl = apiSettings.baseUrl.trim().length > 0;
616
- const hasRequestApiOverride = apiSettings.apiKey.trim().length > 0 || hasRequestApiBaseUrl;
617
- const activeUpstreamProfile = hasRequestApiBaseUrl
618
  ? activeUpstreamProfileSummary.activeConstraints
619
  : runtimeCapabilities?.upstreamProfile?.activeConstraints || IMAGE_UPSTREAM_PROFILES['openai-compatible'];
620
  const activeUpstreamProfileMixed =
621
- !hasRequestApiBaseUrl && runtimeCapabilities?.upstreamProfile?.serverProfileMixed === true;
622
  const allowResponsesImageBackend = shouldAllowResponsesImageBackend({
623
  runtimeCapabilities,
624
  hasRequestApiOverride
@@ -666,7 +700,14 @@ export default function HomePage() {
666
  activeWorkbenchStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT
667
  ? defaultStreamingStrategy
668
  : activeWorkbenchStreamingStrategy;
669
- const activeWorkbenchBackendLabel = getImageBackendLabel(activeWorkbenchBackend, t);
 
 
 
 
 
 
 
670
  const activeTaskCount =
671
  mode === 'generate' && workbenchMode === 'batch'
672
  ? readBatchPromptLines(genBatchPromptText).length
@@ -1143,7 +1184,13 @@ export default function HomePage() {
1143
 
1144
  fetchAuthStatus();
1145
  queueMicrotask(() => {
1146
- setApiSettings(readStoredApiSettings());
 
 
 
 
 
 
1147
  });
1148
  }, [createErrorNotice, t, verifyEntryPasswordHash]);
1149
 
@@ -1154,6 +1201,9 @@ export default function HomePage() {
1154
  throw new Error('获取运行时能力失败');
1155
  }
1156
  const data = (await response.json()) as RuntimeCapabilities;
 
 
 
1157
  setRuntimeCapabilities(data);
1158
  return data;
1159
  } catch (error) {
@@ -1290,6 +1340,9 @@ export default function HomePage() {
1290
  };
1291
 
1292
  const handleSaveApiSettings = (settings: ApiSettings) => {
 
 
 
1293
  setApiSettings(settings);
1294
  if (settings.apiKey || settings.baseUrl) {
1295
  localStorage.setItem(apiSettingsLocalStorageKey, JSON.stringify(settings));
@@ -1411,7 +1464,7 @@ export default function HomePage() {
1411
  if (apiSettings.apiKey) {
1412
  apiFormData.append('apiKey', apiSettings.apiKey);
1413
  }
1414
- if (apiSettings.baseUrl) {
1415
  apiFormData.append('apiBaseUrl', apiSettings.baseUrl);
1416
  }
1417
 
@@ -3332,10 +3385,11 @@ export default function HomePage() {
3332
  <div className='flex flex-wrap items-center gap-4 sm:justify-end'>
3333
  <WorkbenchStatusStrip
3334
  model={activeWorkbenchModel}
3335
- channelLabel={activeWorkbenchBackendLabel}
3336
  streamStatus={getStreamingStatusLabel(streamMode, t)}
3337
  parallelBatchEnabled={activeParallelBatchVisible}
3338
  costLabel={activeEstimatedCostLabel}
 
3339
  />
3340
  <div className='text-muted-foreground hidden items-center gap-4 sm:flex'>
3341
  <HelpCircle className='h-4 w-4' aria-hidden='true' />
 
89
  import { resolveMobileCreationSheetGesture } from '@/lib/mobile-creation-sheet-gesture';
90
  import { resolveMobilePrimaryDisabledReason } from '@/lib/mobile-primary-action-state';
91
  import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth';
92
+ import { resolveRuntimeHealthStatus, type RuntimeHealthStatus } from '@/lib/runtime-health-status';
93
  import { sha256Hex } from '@/lib/sha256';
94
  import { createImageShareFromBlob } from '@/lib/share-client';
95
  import { getPresetDimensions, validateGptImage2Size, validatePositiveIntegerImageSize } from '@/lib/size-utils';
 
155
  type FeedbackSyncScheduleOptions = {
156
  pruneQueuedTargets?: boolean;
157
  };
158
+ type StoredApiSettingsReadResult = {
159
+ settings: ApiSettings;
160
+ shouldPersist: boolean;
161
+ shouldRemove: boolean;
162
+ };
163
 
164
  function createFeedbackRequestSignal(signal: AbortSignal): { signal: AbortSignal; cleanup: () => void } {
165
  const controller = new AbortController();
 
179
  };
180
  }
181
 
182
+ function getWorkbenchRouteLabel(backend: ImageUpstreamFormBackend, t: (key: string) => string): string {
183
  if (backend === 'images-api') return t('upstream.backendImages');
184
  if (backend === 'responses-image-generation') return t('upstream.backendResponses');
185
  return t('upstream.workbenchDefaultRoute');
 
227
  return [];
228
  }
229
 
230
+ function normalizeStoredApiSettings(settings: Partial<ApiSettings>): StoredApiSettingsReadResult {
231
+ const apiKey = typeof settings.apiKey === 'string' ? settings.apiKey.trim() : '';
232
+ const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim() : '';
233
+ if (!apiKey && !baseUrl) {
234
+ return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: true };
235
+ }
236
+ if (!apiKey && baseUrl) {
237
+ return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: true };
238
+ }
239
+ const normalizedSettings: ApiSettings = { apiKey, baseUrl };
240
+ return {
241
+ settings: normalizedSettings,
242
+ shouldPersist: normalizedSettings.apiKey !== settings.apiKey || normalizedSettings.baseUrl !== settings.baseUrl,
243
+ shouldRemove: false
244
+ };
245
+ }
246
+
247
+ function readStoredApiSettings(): StoredApiSettingsReadResult {
248
  const storedApiSettings = readLocalStorageValue(apiSettingsLocalStorageKey);
249
+ if (!storedApiSettings) {
250
+ return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: false };
251
+ }
252
  try {
253
  const parsedSettings = JSON.parse(storedApiSettings) as Partial<ApiSettings>;
254
+ return normalizeStoredApiSettings(parsedSettings);
 
 
 
255
  } catch (error) {
256
  console.error('从 localStorage 加载 API 设置失败:', error);
257
  window.localStorage.removeItem(apiSettingsLocalStorageKey);
258
+ return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: false };
259
  }
260
  }
261
 
 
342
 
343
  type RuntimeCapabilities = {
344
  streaming?: {
345
+ defaultBackend?: 'images-api' | 'responses-image-generation';
346
  defaultMode?: ImageStreamMode;
347
  defaultStrategy?: ImageStreamingStrategy;
348
  };
 
371
  };
372
  channelRouting?: {
373
  effectiveRequestModes?: string[];
374
+ requestModeHealth?: Array<{
375
+ mode: string;
376
+ configuredCredentialCount: number;
377
+ healthyCredentialCount: number;
378
+ configuredChannelCount: number;
379
+ healthyChannelCount: number;
380
+ }>;
381
+ effectiveRequestModesByChannel?: Array<{
382
+ channelId: string;
383
+ requestModes: string[];
384
+ }>;
385
  };
386
  upstreamProfile?: {
387
  activeProfile: ImageUpstreamProfileId;
 
646
  ? [runtimeCapabilities.upstreamProfile.serverProfile]
647
  : []
648
  });
649
+ const hasRequestApiKey = apiSettings.apiKey.trim().length > 0;
650
+ const hasRequestApiOverride = hasRequestApiKey;
651
+ const activeUpstreamProfile = hasRequestApiOverride
652
  ? activeUpstreamProfileSummary.activeConstraints
653
  : runtimeCapabilities?.upstreamProfile?.activeConstraints || IMAGE_UPSTREAM_PROFILES['openai-compatible'];
654
  const activeUpstreamProfileMixed =
655
+ !hasRequestApiOverride && runtimeCapabilities?.upstreamProfile?.serverProfileMixed === true;
656
  const allowResponsesImageBackend = shouldAllowResponsesImageBackend({
657
  runtimeCapabilities,
658
  hasRequestApiOverride
 
700
  activeWorkbenchStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT
701
  ? defaultStreamingStrategy
702
  : activeWorkbenchStreamingStrategy;
703
+ const activeRouteLabel = getWorkbenchRouteLabel(activeWorkbenchBackend, t);
704
+ const activeRuntimeHealthStatus: RuntimeHealthStatus = resolveRuntimeHealthStatus({
705
+ runtimeCapabilities,
706
+ hasRequestApiOverride,
707
+ imageBackend: activeWorkbenchBackend,
708
+ streamingStrategy: activeEffectiveStreamingStrategy,
709
+ streamMode
710
+ });
711
  const activeTaskCount =
712
  mode === 'generate' && workbenchMode === 'batch'
713
  ? readBatchPromptLines(genBatchPromptText).length
 
1184
 
1185
  fetchAuthStatus();
1186
  queueMicrotask(() => {
1187
+ const storedApiSettings = readStoredApiSettings();
1188
+ setApiSettings(storedApiSettings.settings);
1189
+ if (storedApiSettings.shouldRemove) {
1190
+ window.localStorage.removeItem(apiSettingsLocalStorageKey);
1191
+ } else if (storedApiSettings.shouldPersist) {
1192
+ window.localStorage.setItem(apiSettingsLocalStorageKey, JSON.stringify(storedApiSettings.settings));
1193
+ }
1194
  });
1195
  }, [createErrorNotice, t, verifyEntryPasswordHash]);
1196
 
 
1201
  throw new Error('获取运行时能力失败');
1202
  }
1203
  const data = (await response.json()) as RuntimeCapabilities;
1204
+ if (data === null || typeof data !== 'object') {
1205
+ throw new Error('获取运行时能力失败');
1206
+ }
1207
  setRuntimeCapabilities(data);
1208
  return data;
1209
  } catch (error) {
 
1340
  };
1341
 
1342
  const handleSaveApiSettings = (settings: ApiSettings) => {
1343
+ if (settings.baseUrl && !settings.apiKey) {
1344
+ throw new Error(t('api.urlPairRequired'));
1345
+ }
1346
  setApiSettings(settings);
1347
  if (settings.apiKey || settings.baseUrl) {
1348
  localStorage.setItem(apiSettingsLocalStorageKey, JSON.stringify(settings));
 
1464
  if (apiSettings.apiKey) {
1465
  apiFormData.append('apiKey', apiSettings.apiKey);
1466
  }
1467
+ if (apiSettings.baseUrl && apiSettings.apiKey) {
1468
  apiFormData.append('apiBaseUrl', apiSettings.baseUrl);
1469
  }
1470
 
 
3385
  <div className='flex flex-wrap items-center gap-4 sm:justify-end'>
3386
  <WorkbenchStatusStrip
3387
  model={activeWorkbenchModel}
3388
+ routeLabel={activeRouteLabel}
3389
  streamStatus={getStreamingStatusLabel(streamMode, t)}
3390
  parallelBatchEnabled={activeParallelBatchVisible}
3391
  costLabel={activeEstimatedCostLabel}
3392
+ runtimeHealthStatus={activeRuntimeHealthStatus}
3393
  />
3394
  <div className='text-muted-foreground hidden items-center gap-4 sm:flex'>
3395
  <HelpCircle className='h-4 w-4' aria-hidden='true' />
src/app/share/[token]/page-regressions.test.ts CHANGED
@@ -11,5 +11,8 @@ describe('share page regressions', () => {
11
  assert.match(source, /name='accessCode'/);
12
  assert.match(source, /autoComplete='current-password'/);
13
  assert.match(source, /share\.accessCodeRequiredHint/);
 
 
 
14
  });
15
  });
 
11
  assert.match(source, /name='accessCode'/);
12
  assert.match(source, /autoComplete='current-password'/);
13
  assert.match(source, /share\.accessCodeRequiredHint/);
14
+ assert.match(source, /aria-describedby=\{accessCode\.trim\(\)\.length === 0 \? 'share-page-access-code-hint' : undefined\}/);
15
+ assert.match(source, /id='share-page-access-code-hint'/);
16
+ assert.match(source, /if \(accessCode\.trim\(\)\.length === 0\) \{\s*return;\s*\}/);
17
  });
18
  });
src/app/share/[token]/page.tsx CHANGED
@@ -133,6 +133,9 @@ export default function SharePage({ params }: { params: Promise<{ token: string
133
  className='max-w-sm space-y-2'
134
  onSubmit={(event) => {
135
  event.preventDefault();
 
 
 
136
  void loadImage();
137
  }}>
138
  <Label htmlFor='share-page-access-code'>{t('share.accessCode')}</Label>
@@ -140,6 +143,7 @@ export default function SharePage({ params }: { params: Promise<{ token: string
140
  <Input
141
  id='share-page-access-code'
142
  name='accessCode'
 
143
  value={accessCode}
144
  onChange={(event) => setAccessCode(event.target.value)}
145
  placeholder={t('share.accessCodePlaceholder')}
@@ -155,7 +159,9 @@ export default function SharePage({ params }: { params: Promise<{ token: string
155
  </Button>
156
  </div>
157
  {accessCode.trim().length === 0 ? (
158
- <p className='text-muted-foreground text-xs'>{t('share.accessCodeRequiredHint')}</p>
 
 
159
  ) : null}
160
  </form>
161
  ) : null}
 
133
  className='max-w-sm space-y-2'
134
  onSubmit={(event) => {
135
  event.preventDefault();
136
+ if (accessCode.trim().length === 0) {
137
+ return;
138
+ }
139
  void loadImage();
140
  }}>
141
  <Label htmlFor='share-page-access-code'>{t('share.accessCode')}</Label>
 
143
  <Input
144
  id='share-page-access-code'
145
  name='accessCode'
146
+ aria-describedby={accessCode.trim().length === 0 ? 'share-page-access-code-hint' : undefined}
147
  value={accessCode}
148
  onChange={(event) => setAccessCode(event.target.value)}
149
  placeholder={t('share.accessCodePlaceholder')}
 
159
  </Button>
160
  </div>
161
  {accessCode.trim().length === 0 ? (
162
+ <p id='share-page-access-code-hint' className='text-muted-foreground text-xs'>
163
+ {t('share.accessCodeRequiredHint')}
164
+ </p>
165
  ) : null}
166
  </form>
167
  ) : null}
src/components/api-settings-dialog.tsx CHANGED
@@ -30,6 +30,7 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
30
  const { t } = useI18n();
31
  const [draft, setDraft] = React.useState<ApiSettings>(settings);
32
  const [saveStatus, setSaveStatus] = React.useState<'idle' | 'saved' | 'error'>('idle');
 
33
  const closeTimerRef = React.useRef<number | null>(null);
34
 
35
  const clearCloseTimer = React.useCallback(() => {
@@ -44,6 +45,7 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
44
  if (open) {
45
  clearCloseTimer();
46
  setSaveStatus('idle');
 
47
  setDraft(settings);
48
  }
49
  onOpenChange(open);
@@ -58,16 +60,25 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
58
  };
59
 
60
  const handleSave = () => {
 
 
 
 
 
 
 
61
  try {
62
  onSave({
63
- apiKey: draft.apiKey.trim(),
64
- baseUrl: draft.baseUrl.trim()
65
  });
 
66
  setSaveStatus('saved');
67
  closeAfterSaved();
68
  } catch (error) {
69
  console.error('Failed to save API settings.', error);
70
  setSaveStatus('error');
 
71
  }
72
  };
73
 
@@ -81,11 +92,13 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
81
  try {
82
  setDraft(emptySettings);
83
  onSave(emptySettings);
 
84
  setSaveStatus('saved');
85
  closeAfterSaved();
86
  } catch (error) {
87
  console.error('Failed to clear API settings.', error);
88
  setSaveStatus('error');
 
89
  }
90
  };
91
 
@@ -109,9 +122,10 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
109
  placeholder='sk-...'
110
  value={draft.apiKey}
111
  className='min-h-11 sm:min-h-9'
112
- onChange={(event) =>
113
- setDraft((current) => ({ ...current, apiKey: event.target.value }))
114
- }
 
115
  />
116
  </div>
117
  <div className='grid gap-2'>
@@ -126,11 +140,13 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
126
  placeholder='https://api.openai.com/v1'
127
  value={draft.baseUrl}
128
  className='min-h-11 sm:min-h-9'
129
- onChange={(event) =>
130
- setDraft((current) => ({ ...current, baseUrl: event.target.value }))
131
- }
 
132
  />
133
  <p className='text-muted-foreground text-xs leading-5'>{t('api.urlHint')}</p>
 
134
  </div>
135
  </div>
136
  <DialogFooter>
@@ -143,7 +159,7 @@ export function ApiSettingsDialog({ isOpen, onOpenChange, settings, onSave }: Ap
143
  )}
144
  {saveStatus === 'error' && (
145
  <p className='text-destructive mr-auto self-center text-sm' aria-live='polite'>
146
- {t('api.saveFailed')}
147
  </p>
148
  )}
149
  <Button
 
30
  const { t } = useI18n();
31
  const [draft, setDraft] = React.useState<ApiSettings>(settings);
32
  const [saveStatus, setSaveStatus] = React.useState<'idle' | 'saved' | 'error'>('idle');
33
+ const [validationMessage, setValidationMessage] = React.useState<string | null>(null);
34
  const closeTimerRef = React.useRef<number | null>(null);
35
 
36
  const clearCloseTimer = React.useCallback(() => {
 
45
  if (open) {
46
  clearCloseTimer();
47
  setSaveStatus('idle');
48
+ setValidationMessage(null);
49
  setDraft(settings);
50
  }
51
  onOpenChange(open);
 
60
  };
61
 
62
  const handleSave = () => {
63
+ const trimmedApiKey = draft.apiKey.trim();
64
+ const trimmedBaseUrl = draft.baseUrl.trim();
65
+ if (trimmedBaseUrl.length > 0 && trimmedApiKey.length === 0) {
66
+ setSaveStatus('error');
67
+ setValidationMessage(t('api.urlPairRequired'));
68
+ return;
69
+ }
70
  try {
71
  onSave({
72
+ apiKey: trimmedApiKey,
73
+ baseUrl: trimmedBaseUrl
74
  });
75
+ setValidationMessage(null);
76
  setSaveStatus('saved');
77
  closeAfterSaved();
78
  } catch (error) {
79
  console.error('Failed to save API settings.', error);
80
  setSaveStatus('error');
81
+ setValidationMessage(null);
82
  }
83
  };
84
 
 
92
  try {
93
  setDraft(emptySettings);
94
  onSave(emptySettings);
95
+ setValidationMessage(null);
96
  setSaveStatus('saved');
97
  closeAfterSaved();
98
  } catch (error) {
99
  console.error('Failed to clear API settings.', error);
100
  setSaveStatus('error');
101
+ setValidationMessage(null);
102
  }
103
  };
104
 
 
122
  placeholder='sk-...'
123
  value={draft.apiKey}
124
  className='min-h-11 sm:min-h-9'
125
+ onChange={(event) => {
126
+ setValidationMessage(null);
127
+ setDraft((current) => ({ ...current, apiKey: event.target.value }));
128
+ }}
129
  />
130
  </div>
131
  <div className='grid gap-2'>
 
140
  placeholder='https://api.openai.com/v1'
141
  value={draft.baseUrl}
142
  className='min-h-11 sm:min-h-9'
143
+ onChange={(event) => {
144
+ setValidationMessage(null);
145
+ setDraft((current) => ({ ...current, baseUrl: event.target.value }));
146
+ }}
147
  />
148
  <p className='text-muted-foreground text-xs leading-5'>{t('api.urlHint')}</p>
149
+ <p className='text-muted-foreground text-xs leading-5'>{t('api.urlPairHint')}</p>
150
  </div>
151
  </div>
152
  <DialogFooter>
 
159
  )}
160
  {saveStatus === 'error' && (
161
  <p className='text-destructive mr-auto self-center text-sm' aria-live='polite'>
162
+ {validationMessage ?? t('api.saveFailed')}
163
  </p>
164
  )}
165
  <Button
src/components/history-panel.test.tsx CHANGED
@@ -7,6 +7,7 @@ import {
7
  import type { HistoryMetadata } from '@/lib/history-metadata';
8
  import { I18nProvider } from '@/lib/i18n';
9
  import assert from 'node:assert/strict';
 
10
  import { describe, it } from 'node:test';
11
  import * as React from 'react';
12
  import { renderToStaticMarkup } from 'react-dom/server';
@@ -128,6 +129,16 @@ describe('HistoryPanel recent history actions', () => {
128
  assert.match(html, /aria-label="标记本次结果需要修改"/);
129
  });
130
 
 
 
 
 
 
 
 
 
 
 
131
  it('renders saved result feedback on completed history cards', () => {
132
  const html = renderHistoryPanel([
133
  {
 
7
  import type { HistoryMetadata } from '@/lib/history-metadata';
8
  import { I18nProvider } from '@/lib/i18n';
9
  import assert from 'node:assert/strict';
10
+ import { readFile } from 'node:fs/promises';
11
  import { describe, it } from 'node:test';
12
  import * as React from 'react';
13
  import { renderToStaticMarkup } from 'react-dom/server';
 
129
  assert.match(html, /aria-label="标记本次结果需要修改"/);
130
  });
131
 
132
+ it('formats summary and token counters with the active locale', async () => {
133
+ const source = await readFile(new URL('./history-panel.tsx', import.meta.url), 'utf8');
134
+
135
+ assert.match(source, /totalImages\.toLocaleString\(locale\)/);
136
+ assert.match(source, /actualQuota\.toLocaleString\(\s*locale\s*\)/);
137
+ assert.match(source, /text_input_tokens\.toLocaleString\(\s*locale\s*\)/);
138
+ assert.match(source, /image_input_tokens\.toLocaleString\(\s*locale\s*\)/);
139
+ assert.match(source, /image_output_tokens\.toLocaleString\(\s*locale\s*\)/);
140
+ });
141
+
142
  it('renders saved result feedback on completed history cards', () => {
143
  const html = renderHistoryPanel([
144
  {
src/components/history-panel.tsx CHANGED
@@ -391,7 +391,7 @@ function HistoryPanelImpl({
391
  <div className='text-muted-foreground space-y-2 py-4 text-sm'>
392
  <div className='flex justify-between'>
393
  <span>{t('history.totalImages')}</span>{' '}
394
- <span className='ui-stat'>{totalImages.toLocaleString()}</span>
395
  </div>
396
  <div className='flex justify-between'>
397
  <span>{t('history.averageCost')}</span>{' '}
@@ -782,7 +782,9 @@ function HistoryPanelImpl({
782
  {t('history.actualQuota')}
783
  </span>
784
  <span className='ui-stat'>
785
- {item.actualCostDetails.actualQuota.toLocaleString()}
 
 
786
  </span>
787
  </div>
788
  )}
@@ -814,7 +816,9 @@ function HistoryPanelImpl({
814
  {t('history.textInputTokens')}
815
  </span>{' '}
816
  <span className='ui-stat'>
817
- {item.costDetails.text_input_tokens.toLocaleString()}{' '}
 
 
818
  (
819
  {formatEstimatedTokenCost(
820
  item.costDetails
@@ -831,7 +835,9 @@ function HistoryPanelImpl({
831
  {t('history.imageInputTokens')}
832
  </span>{' '}
833
  <span className='ui-stat'>
834
- {item.costDetails.image_input_tokens.toLocaleString()}{' '}
 
 
835
  (
836
  {formatEstimatedTokenCost(
837
  item.costDetails
@@ -847,7 +853,9 @@ function HistoryPanelImpl({
847
  {t('history.imageOutputTokens')}
848
  </span>{' '}
849
  <span className='ui-stat'>
850
- {item.costDetails.image_output_tokens.toLocaleString()}{' '}
 
 
851
  (
852
  {formatEstimatedTokenCost(
853
  item.costDetails
 
391
  <div className='text-muted-foreground space-y-2 py-4 text-sm'>
392
  <div className='flex justify-between'>
393
  <span>{t('history.totalImages')}</span>{' '}
394
+ <span className='ui-stat'>{totalImages.toLocaleString(locale)}</span>
395
  </div>
396
  <div className='flex justify-between'>
397
  <span>{t('history.averageCost')}</span>{' '}
 
782
  {t('history.actualQuota')}
783
  </span>
784
  <span className='ui-stat'>
785
+ {item.actualCostDetails.actualQuota.toLocaleString(
786
+ locale
787
+ )}
788
  </span>
789
  </div>
790
  )}
 
816
  {t('history.textInputTokens')}
817
  </span>{' '}
818
  <span className='ui-stat'>
819
+ {item.costDetails.text_input_tokens.toLocaleString(
820
+ locale
821
+ )}{' '}
822
  (
823
  {formatEstimatedTokenCost(
824
  item.costDetails
 
835
  {t('history.imageInputTokens')}
836
  </span>{' '}
837
  <span className='ui-stat'>
838
+ {item.costDetails.image_input_tokens.toLocaleString(
839
+ locale
840
+ )}{' '}
841
  (
842
  {formatEstimatedTokenCost(
843
  item.costDetails
 
853
  {t('history.imageOutputTokens')}
854
  </span>{' '}
855
  <span className='ui-stat'>
856
+ {item.costDetails.image_output_tokens.toLocaleString(
857
+ locale
858
+ )}{' '}
859
  (
860
  {formatEstimatedTokenCost(
861
  item.costDetails
src/components/image-output.tsx CHANGED
@@ -461,7 +461,7 @@ export function ImageOutput({
461
  {isLoading ? (
462
  streamingPreviewImages && streamingPreviewImages.size > 0 ? (
463
  // 展示流式预览图,单图时和最终视图一样居中。
464
- <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[720px] items-center justify-center p-3'>
465
  {/* 展示最新的预览图,也就是最大索引图片。 */}
466
  {(() => {
467
  const entries = Array.from(streamingPreviewImages.entries());
@@ -486,7 +486,7 @@ export function ImageOutput({
486
  </div>
487
  </div>
488
  ) : currentMode === 'edit' && baseImagePreviewUrl ? (
489
- <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[720px] items-center justify-center p-3'>
490
  <Image
491
  src={baseImagePreviewUrl}
492
  alt={t('output.editing')}
@@ -507,7 +507,7 @@ export function ImageOutput({
507
  </div>
508
  )
509
  ) : hasFailure ? (
510
- <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[720px] flex-col justify-between p-5 sm:p-6'>
511
  <div className='space-y-3'>
512
  <div className='text-muted-foreground text-xs'>{t('output.failedKicker')}</div>
513
  <div className='space-y-2'>
@@ -545,7 +545,8 @@ export function ImageOutput({
545
  />
546
  ) : imageBatch && imageBatch.length > 0 ? (
547
  viewMode === 'grid' ? (
548
- <div className={`grid ${getGridColsClass(imageBatch.length)} w-full max-w-[720px] gap-2`}>
 
549
  {imageBatch.map((img, index) => (
550
  <button
551
  type='button'
@@ -572,12 +573,12 @@ export function ImageOutput({
572
  ))}
573
  </div>
574
  ) : imageBatch[viewMode] ? (
575
- <div className='photo-paper relative aspect-[4/3] w-full max-w-[720px] p-3'>
576
  <Image
577
  src={imageBatch[viewMode].path}
578
  alt={altText}
579
  fill
580
- sizes='(max-width: 768px) 92vw, (max-width: 1800px) 44vw, 720px'
581
  className='image-edge rounded-md object-contain'
582
  onLoad={(event) => handleImageLoad(imageBatch[viewMode].filename, event)}
583
  unoptimized
@@ -591,14 +592,14 @@ export function ImageOutput({
591
  ) : (
592
  <>
593
  {currentMode === 'edit' ? (
594
- <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[720px] flex-col justify-between p-5 sm:p-6'>
595
  {baseImagePreviewUrl ? (
596
  <>
597
  <Image
598
  src={baseImagePreviewUrl}
599
  alt={t('output.editReferenceAlt')}
600
  fill
601
- sizes='(max-width: 768px) 92vw, (max-width: 1800px) 44vw, 720px'
602
  className='image-edge rounded-md object-contain p-3'
603
  unoptimized
604
  />
@@ -628,7 +629,7 @@ export function ImageOutput({
628
  )}
629
  </div>
630
  ) : (
631
- <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[720px] flex-col justify-between p-5 sm:p-6'>
632
  <div className='space-y-3'>
633
  <div className='text-muted-foreground text-xs'>{t('output.previewEyebrow')}</div>
634
  <div className='space-y-2'>
 
461
  {isLoading ? (
462
  streamingPreviewImages && streamingPreviewImages.size > 0 ? (
463
  // 展示流式预览图,单图时和最终视图一样居中。
464
+ <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] items-center justify-center p-3 2xl:max-w-[860px]'>
465
  {/* 展示最新的预览图,也就是最大索引图片。 */}
466
  {(() => {
467
  const entries = Array.from(streamingPreviewImages.entries());
 
486
  </div>
487
  </div>
488
  ) : currentMode === 'edit' && baseImagePreviewUrl ? (
489
+ <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] items-center justify-center p-3 2xl:max-w-[860px]'>
490
  <Image
491
  src={baseImagePreviewUrl}
492
  alt={t('output.editing')}
 
507
  </div>
508
  )
509
  ) : hasFailure ? (
510
+ <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] flex-col justify-between p-5 sm:p-6 2xl:max-w-[860px]'>
511
  <div className='space-y-3'>
512
  <div className='text-muted-foreground text-xs'>{t('output.failedKicker')}</div>
513
  <div className='space-y-2'>
 
545
  />
546
  ) : imageBatch && imageBatch.length > 0 ? (
547
  viewMode === 'grid' ? (
548
+ <div
549
+ className={`grid ${getGridColsClass(imageBatch.length)} w-full max-w-[780px] gap-2 2xl:max-w-[860px]`}>
550
  {imageBatch.map((img, index) => (
551
  <button
552
  type='button'
 
573
  ))}
574
  </div>
575
  ) : imageBatch[viewMode] ? (
576
+ <div className='photo-paper relative aspect-[4/3] w-full max-w-[780px] p-3 2xl:max-w-[860px]'>
577
  <Image
578
  src={imageBatch[viewMode].path}
579
  alt={altText}
580
  fill
581
+ sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
582
  className='image-edge rounded-md object-contain'
583
  onLoad={(event) => handleImageLoad(imageBatch[viewMode].filename, event)}
584
  unoptimized
 
592
  ) : (
593
  <>
594
  {currentMode === 'edit' ? (
595
+ <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] flex-col justify-between p-5 sm:p-6 2xl:max-w-[860px]'>
596
  {baseImagePreviewUrl ? (
597
  <>
598
  <Image
599
  src={baseImagePreviewUrl}
600
  alt={t('output.editReferenceAlt')}
601
  fill
602
+ sizes='(max-width: 768px) 92vw, (max-width: 1536px) 44vw, 860px'
603
  className='image-edge rounded-md object-contain p-3'
604
  unoptimized
605
  />
 
629
  )}
630
  </div>
631
  ) : (
632
+ <div className='photo-paper relative flex aspect-[4/3] w-full max-w-[780px] flex-col justify-between p-5 sm:p-6 2xl:max-w-[860px]'>
633
  <div className='space-y-3'>
634
  <div className='text-muted-foreground text-xs'>{t('output.previewEyebrow')}</div>
635
  <div className='space-y-2'>
src/components/workbench-status-strip.test.tsx CHANGED
@@ -6,21 +6,21 @@ import * as React from 'react';
6
  import { renderToStaticMarkup } from 'react-dom/server';
7
 
8
  describe('WorkbenchStatusStrip', () => {
9
- it('shows model, channel, streaming status, cost, and API state before generation', () => {
10
  const html = renderToStaticMarkup(
11
  <I18nProvider>
12
  <WorkbenchStatusStrip
13
  model='gpt-image-2'
14
- channelLabel='默认线路'
15
  streamStatus='流式可用'
16
  costLabel='预计 0.12 积分'
17
  />
18
  </I18nProvider>
19
  );
20
 
21
- assert.match(html, /API 连接正常/);
22
  assert.match(html, /gpt-image-2/);
23
- assert.match(html, /默认线路/);
24
  assert.match(html, /流式可用/);
25
  assert.match(html, /预计 0\.12 积分/);
26
  });
@@ -30,7 +30,7 @@ describe('WorkbenchStatusStrip', () => {
30
  <I18nProvider>
31
  <WorkbenchStatusStrip
32
  model='gpt-image-2'
33
- channelLabel='默认线路'
34
  streamStatus='流式可用'
35
  parallelBatchEnabled
36
  costLabel='预计 0.24 积分'
@@ -40,4 +40,44 @@ describe('WorkbenchStatusStrip', () => {
40
 
41
  assert.match(html, /并发已启用/);
42
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  });
 
6
  import { renderToStaticMarkup } from 'react-dom/server';
7
 
8
  describe('WorkbenchStatusStrip', () => {
9
+ it('shows model, request route, streaming status, cost, and runtime state before generation', () => {
10
  const html = renderToStaticMarkup(
11
  <I18nProvider>
12
  <WorkbenchStatusStrip
13
  model='gpt-image-2'
14
+ routeLabel='images-sse'
15
  streamStatus='流式可用'
16
  costLabel='预计 0.12 积分'
17
  />
18
  </I18nProvider>
19
  );
20
 
21
+ assert.match(html, /运行时可用/);
22
  assert.match(html, /gpt-image-2/);
23
+ assert.match(html, /images-sse/);
24
  assert.match(html, /流式可用/);
25
  assert.match(html, /预计 0\.12 积分/);
26
  });
 
30
  <I18nProvider>
31
  <WorkbenchStatusStrip
32
  model='gpt-image-2'
33
+ routeLabel='默认线路'
34
  streamStatus='流式可用'
35
  parallelBatchEnabled
36
  costLabel='预计 0.24 积分'
 
40
 
41
  assert.match(html, /并发已启用/);
42
  });
43
+
44
+ it('renders disconnected, route-limited, and custom override runtime states explicitly', () => {
45
+ const disconnectedHtml = renderToStaticMarkup(
46
+ <I18nProvider>
47
+ <WorkbenchStatusStrip
48
+ model='gpt-image-2'
49
+ routeLabel='服务器默认'
50
+ streamStatus='普通生成'
51
+ costLabel='预计 0.12 积分'
52
+ runtimeHealthStatus='disconnected'
53
+ />
54
+ </I18nProvider>
55
+ );
56
+ const routeLimitedHtml = renderToStaticMarkup(
57
+ <I18nProvider>
58
+ <WorkbenchStatusStrip
59
+ model='gpt-image-2'
60
+ routeLabel='服务器默认'
61
+ streamStatus='流式可用'
62
+ costLabel='预计 0.12 积分'
63
+ runtimeHealthStatus='route-limited'
64
+ />
65
+ </I18nProvider>
66
+ );
67
+ const customOverrideHtml = renderToStaticMarkup(
68
+ <I18nProvider>
69
+ <WorkbenchStatusStrip
70
+ model='gpt-image-2'
71
+ routeLabel='使用自定义上游'
72
+ streamStatus='普通生成'
73
+ costLabel='预计 0.12 积分'
74
+ runtimeHealthStatus='custom-override'
75
+ />
76
+ </I18nProvider>
77
+ );
78
+
79
+ assert.match(disconnectedHtml, /运行时未连接/);
80
+ assert.match(routeLimitedHtml, /运行态受限/);
81
+ assert.match(customOverrideHtml, /使用自定义上游/);
82
+ });
83
  });
src/components/workbench-status-strip.tsx CHANGED
@@ -1,27 +1,46 @@
1
  'use client';
2
 
3
  import { useI18n } from '@/lib/i18n';
 
4
  import { cn } from '@/lib/utils';
5
  import { CircleCheck } from 'lucide-react';
6
 
7
  type WorkbenchStatusStripProps = {
8
  model: string;
9
- channelLabel: string;
10
  streamStatus: string;
11
  parallelBatchEnabled?: boolean;
12
  costLabel: string;
 
13
  className?: string;
14
  };
15
 
16
  export function WorkbenchStatusStrip({
17
  model,
18
- channelLabel,
19
  streamStatus,
20
  parallelBatchEnabled = false,
21
  costLabel,
 
22
  className
23
  }: WorkbenchStatusStripProps) {
24
  const { t } = useI18n();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
  return (
27
  <div
@@ -30,14 +49,14 @@ export function WorkbenchStatusStrip({
30
  className
31
  )}>
32
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center gap-1.5 rounded-full border px-2 py-0.5 sm:min-h-7 sm:gap-2 sm:px-2.5 sm:py-1'>
33
- <CircleCheck className='h-3.5 w-3.5 text-[oklch(0.5_0.12_150)]' />
34
- {t('app.apiConnected')}
35
  </span>
36
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center rounded-full border px-2 py-0.5 sm:min-h-7 sm:px-2.5 sm:py-1'>
37
  {model}
38
  </span>
39
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center rounded-full border px-2 py-0.5 sm:min-h-7 sm:px-2.5 sm:py-1'>
40
- {channelLabel}
41
  </span>
42
  <span className='inline-flex min-h-6 items-center rounded-full border border-[oklch(0.78_0.055_205)] bg-[oklch(0.94_0.028_205)] px-2 py-0.5 text-[oklch(0.38_0.065_218)] sm:min-h-7 sm:px-2.5 sm:py-1'>
43
  {streamStatus}
 
1
  'use client';
2
 
3
  import { useI18n } from '@/lib/i18n';
4
+ import type { RuntimeHealthStatus } from '@/lib/runtime-health-status';
5
  import { cn } from '@/lib/utils';
6
  import { CircleCheck } from 'lucide-react';
7
 
8
  type WorkbenchStatusStripProps = {
9
  model: string;
10
+ routeLabel: string;
11
  streamStatus: string;
12
  parallelBatchEnabled?: boolean;
13
  costLabel: string;
14
+ runtimeHealthStatus?: RuntimeHealthStatus;
15
  className?: string;
16
  };
17
 
18
  export function WorkbenchStatusStrip({
19
  model,
20
+ routeLabel,
21
  streamStatus,
22
  parallelBatchEnabled = false,
23
  costLabel,
24
+ runtimeHealthStatus = 'runtime-ready',
25
  className
26
  }: WorkbenchStatusStripProps) {
27
  const { t } = useI18n();
28
+ const runtimeStatusLabel =
29
+ runtimeHealthStatus === 'custom-override'
30
+ ? t('app.apiCustomOverride')
31
+ : runtimeHealthStatus === 'runtime-ready'
32
+ ? t('app.apiRuntimeReady')
33
+ : runtimeHealthStatus === 'route-limited'
34
+ ? t('app.apiRouteLimited')
35
+ : t('app.apiDisconnected');
36
+ const runtimeStatusColorClass =
37
+ runtimeHealthStatus === 'custom-override'
38
+ ? 'text-[oklch(0.44_0.08_55)]'
39
+ : runtimeHealthStatus === 'runtime-ready'
40
+ ? 'text-[oklch(0.5_0.12_150)]'
41
+ : runtimeHealthStatus === 'route-limited'
42
+ ? 'text-[oklch(0.56_0.12_82)]'
43
+ : 'text-[oklch(0.58_0.02_55)]';
44
 
45
  return (
46
  <div
 
49
  className
50
  )}>
51
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center gap-1.5 rounded-full border px-2 py-0.5 sm:min-h-7 sm:gap-2 sm:px-2.5 sm:py-1'>
52
+ <CircleCheck className={cn('h-3.5 w-3.5', runtimeStatusColorClass)} />
53
+ {runtimeStatusLabel}
54
  </span>
55
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center rounded-full border px-2 py-0.5 sm:min-h-7 sm:px-2.5 sm:py-1'>
56
  {model}
57
  </span>
58
  <span className='border-border bg-card/70 inline-flex min-h-6 items-center rounded-full border px-2 py-0.5 sm:min-h-7 sm:px-2.5 sm:py-1'>
59
+ {routeLabel}
60
  </span>
61
  <span className='inline-flex min-h-6 items-center rounded-full border border-[oklch(0.78_0.055_205)] bg-[oklch(0.94_0.028_205)] px-2 py-0.5 text-[oklch(0.38_0.065_218)] sm:min-h-7 sm:px-2.5 sm:py-1'>
62
  {streamStatus}
src/lib/channel-request-mode-values.mjs CHANGED
@@ -6,7 +6,7 @@ export const CHANNEL_REQUEST_MODES = Object.freeze([
6
  ]);
7
 
8
  export const CHANNEL_REQUEST_MODE_SMOKE_CASES = Object.freeze({
9
- 'images-non-stream': Object.freeze(['generate_1k', 'edit_1k']),
10
  'images-sse': Object.freeze(['page_sse_edit_2k']),
11
  'responses-non-stream': Object.freeze(['responses_agent_generate_1k']),
12
  'responses-sse': Object.freeze(['responses_page_sse_generate_1k'])
 
6
  ]);
7
 
8
  export const CHANNEL_REQUEST_MODE_SMOKE_CASES = Object.freeze({
9
+ 'images-non-stream': Object.freeze(['orchestration_generate_1k', 'edit_1k']),
10
  'images-sse': Object.freeze(['page_sse_edit_2k']),
11
  'responses-non-stream': Object.freeze(['responses_agent_generate_1k']),
12
  'responses-sse': Object.freeze(['responses_page_sse_generate_1k'])
src/lib/i18n.tsx CHANGED
@@ -19,7 +19,10 @@ const messages: Record<Locale, Record<string, string>> = {
19
  'app.studioSubtitle': '今天想做什么画面?',
20
  'app.creationControls': '创作参数',
21
  'app.canvasPreview': '画布预览',
22
- 'app.apiConnected': 'API 连接正常',
 
 
 
23
  'mode.generate': '文生图',
24
  'mode.edit': '图生图',
25
  'mode.batch': '批量',
@@ -463,6 +466,8 @@ const messages: Record<Locale, Record<string, string>> = {
463
  'api.key': 'API Key',
464
  'api.url': 'API URL',
465
  'api.urlHint': '填 OpenAI 兼容接口根地址,通常以 /v1 结尾;不要填管理后台网页地址。',
 
 
466
  'api.saved': '已保存',
467
  'api.saveFailed': '保存失败',
468
  'password.configure': '配置访问码',
@@ -537,7 +542,10 @@ const messages: Record<Locale, Record<string, string>> = {
537
  'app.studioSubtitle': 'What image are we making today?',
538
  'app.creationControls': 'Creation controls',
539
  'app.canvasPreview': 'Canvas preview',
540
- 'app.apiConnected': 'API connected',
 
 
 
541
  'mode.generate': 'Text to image',
542
  'mode.edit': 'Image edit',
543
  'mode.batch': 'Batch',
@@ -1006,6 +1014,9 @@ const messages: Record<Locale, Record<string, string>> = {
1006
  'api.url': 'API URL',
1007
  'api.urlHint':
1008
  'Use the OpenAI-compatible API root, usually ending with /v1. Do not enter an admin dashboard URL.',
 
 
 
1009
  'api.saved': 'Saved',
1010
  'api.saveFailed': 'Save failed',
1011
  'password.configure': 'Configure Access Code',
 
19
  'app.studioSubtitle': '今天想做什么画面?',
20
  'app.creationControls': '创作参数',
21
  'app.canvasPreview': '画布预览',
22
+ 'app.apiRuntimeReady': '运行时可用',
23
+ 'app.apiRouteLimited': '运行态受限',
24
+ 'app.apiDisconnected': '运行时未连接',
25
+ 'app.apiCustomOverride': '使用自定义上游',
26
  'mode.generate': '文生图',
27
  'mode.edit': '图生图',
28
  'mode.batch': '批量',
 
466
  'api.key': 'API Key',
467
  'api.url': 'API URL',
468
  'api.urlHint': '填 OpenAI 兼容接口根地址,通常以 /v1 结尾;不要填管理后台网页地址。',
469
+ 'api.urlPairHint': '自定义 API URL 必须和自定义 API Key 一起填写,避免把服务端密钥发送到未知上游。',
470
+ 'api.urlPairRequired': '自定义 API URL 必须同时填写自定义 API Key。',
471
  'api.saved': '已保存',
472
  'api.saveFailed': '保存失败',
473
  'password.configure': '配置访问码',
 
542
  'app.studioSubtitle': 'What image are we making today?',
543
  'app.creationControls': 'Creation controls',
544
  'app.canvasPreview': 'Canvas preview',
545
+ 'app.apiRuntimeReady': 'Runtime ready',
546
+ 'app.apiRouteLimited': 'Route limited',
547
+ 'app.apiDisconnected': 'Runtime disconnected',
548
+ 'app.apiCustomOverride': 'Custom upstream',
549
  'mode.generate': 'Text to image',
550
  'mode.edit': 'Image edit',
551
  'mode.batch': 'Batch',
 
1014
  'api.url': 'API URL',
1015
  'api.urlHint':
1016
  'Use the OpenAI-compatible API root, usually ending with /v1. Do not enter an admin dashboard URL.',
1017
+ 'api.urlPairHint':
1018
+ 'A custom API URL must be paired with a custom API key so the server never forwards its own credentials to an unknown upstream.',
1019
+ 'api.urlPairRequired': 'A custom API URL requires a custom API key.',
1020
  'api.saved': 'Saved',
1021
  'api.saveFailed': 'Save failed',
1022
  'password.configure': 'Configure Access Code',
src/lib/runtime-health-status.test.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { resolveRuntimeHealthStatus } from './runtime-health-status';
4
+
5
+ describe('resolveRuntimeHealthStatus', () => {
6
+ it('treats a healthy declared route as ready for the current form request', () => {
7
+ assert.equal(
8
+ resolveRuntimeHealthStatus({
9
+ runtimeCapabilities: {
10
+ streaming: { defaultBackend: 'images-api' },
11
+ responsesImageBackend: { enabled: true },
12
+ channelRouting: {
13
+ effectiveRequestModes: ['images-non-stream', 'images-sse'],
14
+ requestModeHealth: [
15
+ {
16
+ mode: 'images-sse',
17
+ configuredCredentialCount: 1,
18
+ healthyCredentialCount: 1,
19
+ configuredChannelCount: 1,
20
+ healthyChannelCount: 1
21
+ }
22
+ ]
23
+ }
24
+ },
25
+ hasRequestApiOverride: false,
26
+ imageBackend: 'images-api',
27
+ streamingStrategy: 'auto',
28
+ streamMode: 'stream'
29
+ }),
30
+ 'runtime-ready'
31
+ );
32
+ });
33
+
34
+ it('keeps auto streaming ready when only the non-stream fallback is healthy', () => {
35
+ assert.equal(
36
+ resolveRuntimeHealthStatus({
37
+ runtimeCapabilities: {
38
+ streaming: { defaultBackend: 'responses-image-generation' },
39
+ responsesImageBackend: { enabled: true },
40
+ channelRouting: {
41
+ effectiveRequestModes: ['responses-non-stream'],
42
+ requestModeHealth: [
43
+ {
44
+ mode: 'responses-non-stream',
45
+ configuredCredentialCount: 1,
46
+ healthyCredentialCount: 1,
47
+ configuredChannelCount: 1,
48
+ healthyChannelCount: 1
49
+ }
50
+ ]
51
+ }
52
+ },
53
+ hasRequestApiOverride: false,
54
+ imageBackend: 'server-default',
55
+ streamingStrategy: 'auto',
56
+ streamMode: 'auto'
57
+ }),
58
+ 'runtime-ready'
59
+ );
60
+ });
61
+
62
+ it('marks custom overrides before runtime readiness checks', () => {
63
+ assert.equal(
64
+ resolveRuntimeHealthStatus({
65
+ runtimeCapabilities: null,
66
+ hasRequestApiOverride: true,
67
+ imageBackend: 'images-api',
68
+ streamingStrategy: 'auto',
69
+ streamMode: 'auto'
70
+ }),
71
+ 'custom-override'
72
+ );
73
+ });
74
+
75
+ it('marks a missing requested route as limited when no healthy mode can satisfy it', () => {
76
+ assert.equal(
77
+ resolveRuntimeHealthStatus({
78
+ runtimeCapabilities: {
79
+ streaming: { defaultBackend: 'responses-image-generation' },
80
+ responsesImageBackend: { enabled: false },
81
+ channelRouting: {
82
+ effectiveRequestModes: ['images-non-stream'],
83
+ requestModeHealth: [
84
+ {
85
+ mode: 'images-non-stream',
86
+ configuredCredentialCount: 1,
87
+ healthyCredentialCount: 0,
88
+ configuredChannelCount: 1,
89
+ healthyChannelCount: 0
90
+ }
91
+ ]
92
+ }
93
+ },
94
+ hasRequestApiOverride: false,
95
+ imageBackend: 'responses-image-generation',
96
+ streamingStrategy: 'auto',
97
+ streamMode: 'stream'
98
+ }),
99
+ 'route-limited'
100
+ );
101
+ });
102
+
103
+ it('marks request API key-only overrides before server route health checks', () => {
104
+ assert.equal(
105
+ resolveRuntimeHealthStatus({
106
+ runtimeCapabilities: {
107
+ streaming: { defaultBackend: 'images-api' },
108
+ responsesImageBackend: { enabled: true },
109
+ channelRouting: {
110
+ effectiveRequestModes: [],
111
+ requestModeHealth: []
112
+ }
113
+ },
114
+ hasRequestApiOverride: true,
115
+ imageBackend: 'images-api',
116
+ streamingStrategy: 'auto',
117
+ streamMode: 'auto'
118
+ }),
119
+ 'custom-override'
120
+ );
121
+ });
122
+ });
src/lib/runtime-health-status.ts ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, type ImageUpstreamFormBackend } from './image-upstream-form';
2
+ import type { ImageGenerationBackend, ImageStreamMode, ImageStreamingStrategy } from './image-upstream-strategy';
3
+
4
+ type RuntimeRequestMode = 'images-non-stream' | 'images-sse' | 'responses-non-stream' | 'responses-sse';
5
+
6
+ export type RuntimeHealthStatus = 'runtime-ready' | 'route-limited' | 'disconnected' | 'custom-override';
7
+
8
+ export type RuntimeHealthCapabilities = {
9
+ streaming?: {
10
+ defaultBackend?: ImageGenerationBackend | null;
11
+ } | null;
12
+ responsesImageBackend?: {
13
+ enabled?: boolean | null;
14
+ } | null;
15
+ channelRouting?: {
16
+ effectiveRequestModes?: readonly string[] | null;
17
+ requestModeHealth?: Array<{
18
+ mode: string;
19
+ healthyCredentialCount: number;
20
+ healthyChannelCount: number;
21
+ }> | null;
22
+ } | null;
23
+ };
24
+
25
+ function resolveRequestedBackend(input: {
26
+ runtimeCapabilities: RuntimeHealthCapabilities | null;
27
+ imageBackend: ImageUpstreamFormBackend;
28
+ }): ImageGenerationBackend {
29
+ if (input.imageBackend !== IMAGE_UPSTREAM_FORM_SERVER_DEFAULT) {
30
+ return input.imageBackend;
31
+ }
32
+ return input.runtimeCapabilities?.streaming?.defaultBackend === 'responses-image-generation'
33
+ ? 'responses-image-generation'
34
+ : 'images-api';
35
+ }
36
+
37
+ function resolvePreferredRequestMode(input: {
38
+ runtimeCapabilities: RuntimeHealthCapabilities | null;
39
+ imageBackend: ImageUpstreamFormBackend;
40
+ streamingStrategy: ImageStreamingStrategy;
41
+ streamMode: ImageStreamMode;
42
+ }): RuntimeRequestMode {
43
+ const backend = resolveRequestedBackend({
44
+ runtimeCapabilities: input.runtimeCapabilities,
45
+ imageBackend: input.imageBackend
46
+ });
47
+ const streamEnabled = input.streamMode === 'stream' || (input.streamMode === 'auto' && input.streamingStrategy !== 'off');
48
+ if (backend === 'responses-image-generation') {
49
+ return streamEnabled ? 'responses-sse' : 'responses-non-stream';
50
+ }
51
+ return streamEnabled ? 'images-sse' : 'images-non-stream';
52
+ }
53
+
54
+ function resolveFallbackRequestMode(input: {
55
+ runtimeCapabilities: RuntimeHealthCapabilities | null;
56
+ imageBackend: ImageUpstreamFormBackend;
57
+ streamingStrategy: ImageStreamingStrategy;
58
+ streamMode: ImageStreamMode;
59
+ }): RuntimeRequestMode | null {
60
+ if (input.streamMode !== 'auto' || input.streamingStrategy === 'off') return null;
61
+ const backend = resolveRequestedBackend({
62
+ runtimeCapabilities: input.runtimeCapabilities,
63
+ imageBackend: input.imageBackend
64
+ });
65
+ return backend === 'responses-image-generation' ? 'responses-non-stream' : 'images-non-stream';
66
+ }
67
+
68
+ function hasHealthyRequestMode(
69
+ runtimeCapabilities: RuntimeHealthCapabilities | null,
70
+ requestMode: RuntimeRequestMode
71
+ ): boolean {
72
+ const healthItems = runtimeCapabilities?.channelRouting?.requestModeHealth;
73
+ if (Array.isArray(healthItems)) {
74
+ const healthItem = healthItems.find((item) => item.mode === requestMode);
75
+ if (healthItem) {
76
+ return healthItem.healthyCredentialCount > 0 && healthItem.healthyChannelCount > 0;
77
+ }
78
+ }
79
+ return runtimeCapabilities?.channelRouting?.effectiveRequestModes?.includes(requestMode) === true;
80
+ }
81
+
82
+ export function resolveRuntimeHealthStatus(input: {
83
+ runtimeCapabilities: RuntimeHealthCapabilities | null;
84
+ hasRequestApiOverride: boolean;
85
+ imageBackend: ImageUpstreamFormBackend;
86
+ streamingStrategy: ImageStreamingStrategy;
87
+ streamMode: ImageStreamMode;
88
+ }): RuntimeHealthStatus {
89
+ if (input.hasRequestApiOverride) return 'custom-override';
90
+ if (input.runtimeCapabilities === null) return 'disconnected';
91
+ const requestedBackend = resolveRequestedBackend({
92
+ runtimeCapabilities: input.runtimeCapabilities,
93
+ imageBackend: input.imageBackend
94
+ });
95
+ if (
96
+ requestedBackend === 'responses-image-generation' &&
97
+ input.runtimeCapabilities.responsesImageBackend?.enabled !== true
98
+ ) {
99
+ return 'route-limited';
100
+ }
101
+ const preferredRequestMode = resolvePreferredRequestMode(input);
102
+ if (hasHealthyRequestMode(input.runtimeCapabilities, preferredRequestMode)) {
103
+ return 'runtime-ready';
104
+ }
105
+ const fallbackRequestMode = resolveFallbackRequestMode(input);
106
+ if (fallbackRequestMode && hasHealthyRequestMode(input.runtimeCapabilities, fallbackRequestMode)) {
107
+ return 'runtime-ready';
108
+ }
109
+ return 'route-limited';
110
+ }