Spaces:
Running
Running
Deploy 653775e to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@653775e99286817989ad7a4dfb56eedae605cdcb
- AGENTS.md +1 -1
- README.md +3 -3
- docs/deployment/huggingface-space-free.md +1 -1
- public/hf-space-deploy-marker.json +3 -3
- scripts/agent-doctor.mjs +34 -5
- scripts/command-center.test.mjs +193 -10
- scripts/doctor-hf-space.mjs +7 -5
- scripts/first-run.mjs +70 -22
- scripts/hf-space-doctor-utils.mjs +2 -2
- skills/gpt-image-playground-agent/SKILL.md +4 -2
- skills/gpt-image-playground-agent/references/api.md +4 -2
- src/app/api/deploy-marker/route.ts +1 -1
- src/app/page-regressions.test.ts +32 -0
- src/app/page.tsx +70 -16
- src/app/share/[token]/page-regressions.test.ts +3 -0
- src/app/share/[token]/page.tsx +7 -1
- src/components/api-settings-dialog.tsx +25 -9
- src/components/history-panel.test.tsx +11 -0
- src/components/history-panel.tsx +13 -5
- src/components/image-output.tsx +10 -9
- src/components/workbench-status-strip.test.tsx +45 -5
- src/components/workbench-status-strip.tsx +24 -5
- src/lib/channel-request-mode-values.mjs +1 -1
- src/lib/i18n.tsx +13 -2
- src/lib/runtime-health-status.test.ts +122 -0
- src/lib/runtime-health-status.ts +110 -0
AGENTS.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
| 13 |
|
| 14 |
- 项目是 Next.js 16 + React 19 的本地图片服务,默认端口 `4783`。
|
| 15 |
- 包管理工具是 `npm`,锁文件是 `package-lock.json`。
|
| 16 |
-
- Node 版本要求是 `>=20.
|
| 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` | 可选。声明全局或单渠道可用方式
|
| 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": "
|
| 4 |
-
"created_at": "2026-06-
|
| 5 |
-
"deploy_id": "
|
| 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:
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1075 |
response.end(
|
| 1076 |
JSON.stringify({
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1081 |
}
|
| 1082 |
})
|
| 1083 |
);
|
| 1084 |
return;
|
| 1085 |
}
|
| 1086 |
if (request.url === '/api/agent/jobs/images/generate') {
|
| 1087 |
-
response.writeHead(
|
| 1088 |
response.end(
|
| 1089 |
JSON.stringify({
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 50 |
-
|
|
|
|
|
|
|
| 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 ${
|
| 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'],
|
| 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
|
| 143 |
-
const
|
| 144 |
-
|
| 145 |
-
);
|
| 146 |
return [
|
| 147 |
-
{ name: 'node_version', ok: node.ok, current: node.current, required:
|
| 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:
|
| 163 |
skipped: !requiresAgentAuth(service),
|
| 164 |
-
auth_in_private_env_file:
|
|
|
|
| 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
|
| 227 |
-
const
|
| 228 |
-
|
| 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 (!
|
| 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 (!
|
| 256 |
actions.push('Agent 脚本会自动读取 .env.agent.local;如果仍提示缺少鉴权,请确认文件位于当前仓库根目录且变量名正确。');
|
| 257 |
}
|
| 258 |
-
if (service.ok &&
|
| 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 |
-
|
|
|
|
| 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
|
| 115 |
-
['npm', 'Install Node.js 20
|
| 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":"
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
const storedApiSettings = readLocalStorageValue(apiSettingsLocalStorageKey);
|
| 226 |
-
if (!storedApiSettings)
|
|
|
|
|
|
|
| 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
|
| 616 |
-
const hasRequestApiOverride =
|
| 617 |
-
const activeUpstreamProfile =
|
| 618 |
? activeUpstreamProfileSummary.activeConstraints
|
| 619 |
: runtimeCapabilities?.upstreamProfile?.activeConstraints || IMAGE_UPSTREAM_PROFILES['openai-compatible'];
|
| 620 |
const activeUpstreamProfileMixed =
|
| 621 |
-
!
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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'>
|
|
|
|
|
|
|
| 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:
|
| 64 |
-
baseUrl:
|
| 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 |
-
|
| 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 |
-
|
| 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-[
|
| 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-[
|
| 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-[
|
| 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
|
|
|
|
| 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-[
|
| 576 |
<Image
|
| 577 |
src={imageBatch[viewMode].path}
|
| 578 |
alt={altText}
|
| 579 |
fill
|
| 580 |
-
sizes='(max-width: 768px) 92vw, (max-width:
|
| 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-[
|
| 595 |
{baseImagePreviewUrl ? (
|
| 596 |
<>
|
| 597 |
<Image
|
| 598 |
src={baseImagePreviewUrl}
|
| 599 |
alt={t('output.editReferenceAlt')}
|
| 600 |
fill
|
| 601 |
-
sizes='(max-width: 768px) 92vw, (max-width:
|
| 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-[
|
| 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,
|
| 10 |
const html = renderToStaticMarkup(
|
| 11 |
<I18nProvider>
|
| 12 |
<WorkbenchStatusStrip
|
| 13 |
model='gpt-image-2'
|
| 14 |
-
|
| 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, /
|
| 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 |
-
|
| 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 |
-
|
| 10 |
streamStatus: string;
|
| 11 |
parallelBatchEnabled?: boolean;
|
| 12 |
costLabel: string;
|
|
|
|
| 13 |
className?: string;
|
| 14 |
};
|
| 15 |
|
| 16 |
export function WorkbenchStatusStrip({
|
| 17 |
model,
|
| 18 |
-
|
| 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
|
| 34 |
-
{
|
| 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 |
-
{
|
| 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(['
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|