diff --git a/.env.agent.example b/.env.agent.example index 78b35b9625a37bcb179580da11360cc183c1dd8a..0da7f60faa23909f654cdf892dcf227c20be4bef 100644 --- a/.env.agent.example +++ b/.env.agent.example @@ -1,13 +1,13 @@ # Agent API 访问令牌。正式部署请使用足够长的随机值。 AGENT_API_TOKEN= -# 状态后端:HF Space 免费层或临时演示用 memory,本地单实例用 sqlite,高并发状态用 postgres。多实例还需共享 generated-images。 +# 状态后端:HF Space 免费层或临时演示使用 memory,本地单实例使用 sqlite,高并发状态使用 postgres。多实例还需共享 generated-images。 AGENT_STATE_BACKEND=sqlite -# SQLite 默认状态文件。保留在 generated-images 下,方便 Docker volume 备份同时包含元数据。 +# SQLite 默认状态文件。保留在 generated-images 下,方便 Docker 数据卷备份时同时包含元数据。 AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite -# PostgreSQL 配置。Docker Compose 模式优先使用 secret file;手动部署也可以直接提供 AGENT_DATABASE_URL。 +# PostgreSQL 配置。Docker Compose 模式优先使用密钥文件;手动部署也可以直接提供 AGENT_DATABASE_URL。 # GPT_IMAGE_POSTGRES_PASSWORD= # AGENT_DB_HOST=postgres # AGENT_DB_PORT=5432 diff --git a/.env.agent.local.example b/.env.agent.local.example index a870fbb05763d24852ff2ad65a4a777b62983e9d..9a5139a2e4c6cb6b399cd561ad0b2b1fe2d96ef9 100644 --- a/.env.agent.local.example +++ b/.env.agent.local.example @@ -1,21 +1,21 @@ -# Copy to .env.agent.local and keep the real file private. -# Agent CLI scripts automatically read this file from the current repo root. -# Shell environment variables still take precedence. -# Set GPT_IMAGE_AGENT_LOAD_ENV_FILE=0 to disable automatic loading. +# 复制为 .env.agent.local,并将真实文件保留为私有文件。 +# Agent CLI 脚本会从当前仓库根目录自动读取此文件。 +# Shell 环境变量仍具有更高优先级。 +# 设置 GPT_IMAGE_AGENT_LOAD_ENV_FILE=0 可关闭自动加载。 -# Use the deployment you intend to test. For Space or other public services, -# set the remote URL explicitly so subagents do not fall back to localhost. +# 使用准备验证的部署地址。对于 Space 或其他公网服务, +# 请显式设置远程 URL,避免子代理回退到 localhost。 GPT_IMAGE_PLAYGROUND_URL=http://localhost:4783 -# Agent JSON, Agent edit, job, artifact and diagnostics endpoints use this -# when the service is configured with AGENT_API_TOKEN. +# 服务配置 AGENT_API_TOKEN 时,Agent JSON、Agent 编辑、任务、产物和诊断端点 +# 使用此令牌。 GPT_IMAGE_AGENT_TOKEN= -# Page SSE uses the page access-code hash as a form field named passwordHash. -# This is required for --page-sse, large generate defaults, Responses backend -# edit, and page-SSE batch tasks when the deployment has APP_PASSWORD enabled. -# GPT_IMAGE_AGENT_TOKEN alone does not satisfy page SSE form auth. +# 页面 SSE 将页面访问码哈希作为名为 passwordHash 的表单字段发送。 +# 当部署启用 APP_PASSWORD 时,--page-sse、大尺寸生成默认路径、Responses 后端 +# 编辑和页面 SSE 批量任务都需要该字段。 +# 仅设置 GPT_IMAGE_AGENT_TOKEN 不能满足页面 SSE 表单鉴权。 GPT_IMAGE_APP_PASSWORD_HASH= -# Agent share access code for private share links created by generate-image.mjs --share. +# 供 generate-image.mjs --share 创建私密分享链接使用的 Agent 分享访问码。 GPT_IMAGE_SHARE_ACCESS_CODE= diff --git a/.env.example b/.env.example index dda7aa48b7d68e0be027d815db974c746aa64de9..aea45463299a3a10697ea7f28d77af6253c5e335 100755 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # 可选:如果不想在网页右上角“API 设置”里填写,可以在这里配置默认值。 # 使用时复制本文件为 .env.local,再填写真实内容。 -# OpenAI 或 OpenAI 兼容接口 Key +# OpenAI 或 OpenAI 兼容接口密钥 OPENAI_API_KEY= # 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。 @@ -14,26 +14,26 @@ OPENAI_API_BASE_URL= # 仅支持无认证、无路径、无查询参数和无片段的 http:// 或 https:// 根代理地址;不支持 SOCKS。 # OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080 -# 可选:服务端多渠道多 key 配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。 -# 页面右上角“API 设置”里手动填写的 API Key/API URL 仍然拥有最高优先级。 +# 可选:服务端多渠道多密钥配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。 +# 页面右上角“API 设置”里手动填写的 API 密钥/API URL 仍然拥有最高优先级。 # # 路由策略: -# - sticky:默认值。按请求来源稳定映射到同一个渠道 key,适合减少同一用户跳渠道。 -# - round_robin:按请求顺序轮询所有渠道 key,适合简单均摊流量。 -# - random:每次随机选择一个渠道 key,适合轻量分散请求。 +# - sticky:默认值。按请求来源稳定映射到同一个渠道密钥,适合减少同一用户跳渠道。 +# - round_robin:按请求顺序轮询所有渠道密钥,适合简单均摊流量。 +# - random:每次随机选择一个渠道密钥,适合轻量分散请求。 # OPENAI_ROUTING_STRATEGY=sticky # OPENAI_UPSTREAM_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse # OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse # # 渠道配置规则: # - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。 -# - ID 只用于日志排查,不会暴露 API Key。 +# - ID 只用于日志排查,不会暴露 API 密钥。 # - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。 -# - BASE_URL 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。 +# - BASE_URL 默认要求 https;本机回环 HTTP 可直接用于本地测试夹具。 # - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。 -# - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。 -# - REQUEST_MODES 可选,用于声明该渠道经真实 smoke 验证可用的服务端请求方式。 -# 单 key 旧配置可用 OPENAI_UPSTREAM_REQUEST_MODES 声明全局可用方式。 +# - API_KEYS 支持一个或多个密钥,多个密钥用英文逗号分隔。 +# - REQUEST_MODES 可选,用于声明该渠道经真实冒烟验证可用的服务端请求方式。 +# 单密钥旧配置可用 OPENAI_UPSTREAM_REQUEST_MODES 声明全局可用方式。 # 该白名单只供服务端路由和诊断使用;Agent 客户端仍只提交业务意图。 # 这些变量在服务启动时读取;修改后需要重启 Next.js、Docker 或 Space 服务才会生效。 # stream_mode=auto 默认由服务端按白名单和优先级选择;未配置优先级时按低费用非流式优先。 @@ -46,7 +46,7 @@ OPENAI_API_BASE_URL= # 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。 # - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。 # - PROXY_URL 可选,覆盖 OPENAI_UPSTREAM_PROXY_URL,仅用于该渠道的服务端上游请求。 -# - API Key 本身不要包含逗号。 +# - API 密钥本身不要包含逗号。 # # 示例: # OPENAI_CHANNEL_1_ID=official @@ -71,23 +71,23 @@ OPENAI_API_BASE_URL= # OPENAI_CHANNEL_3_MATSCA_APP_ID= # OPENAI_CHANNEL_3_MATSCA_APP_SECRET= # -# 可选:远程明文 HTTP 兼容接口 allowlist。默认只允许 HTTPS 和本机 loopback HTTP。 +# 可选:远程明文 HTTP 兼容接口允许列表。默认只允许 HTTPS 和本机回环 HTTP。 # 多个完整 base URL 用英文逗号分隔,仅在确认网络边界安全时启用。 # OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http://your-internal-compatible-api.example.com/v1 # 可选:统一上游请求头。默认 User-Agent 为 visual-journal/。 # 全局 UA 可用 OPENAI_UPSTREAM_USER_AGENT 或 UPSTREAM_USER_AGENT 覆盖; -# 单渠道 UA 用 OPENAI_CHANNEL_N_USER_AGENT 覆盖,安全 extra header 用 +# 单渠道 UA 用 OPENAI_CHANNEL_N_USER_AGENT 覆盖,安全额外请求头用 # OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON 配置。 -# Authorization、Accept、Content-Type、Content-Length、Host 等协议头不能由 extra headers 覆盖。 +# Authorization、Accept、Content-Type、Content-Length、Host 等协议头不能由额外请求头覆盖。 # OPENAI_UPSTREAM_USER_AGENT=visual-journal/customer # 可选:并发流式批处理容量。 # 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。 -# 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。 +# 默认 sticky 路由按单个凭据容量推荐并发;round_robin/random 才会使用完整凭据池。 # 渠道失败冷却默认关闭。显式启用后,key 出现鉴权、额度或限流类错误会短暂冷却; # 渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。 -# 如果失败能关联到本次服务端 request mode,只冷却对应 request mode,不误伤同渠道其他可用方式。 +# 如果失败能关联到本次服务端请求方式,只冷却对应请求方式,不误伤同渠道其他可用方式。 # OPENAI_MAX_STREAMS_PER_CREDENTIAL=1 # OPENAI_CHANNEL_QUEUE_ENABLED=true # OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS=420000 @@ -97,9 +97,9 @@ OPENAI_API_BASE_URL= # OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=30000 # # 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的 -# credential/channel/request mode 先通过后台 GET /models 探测,成功后才重新进入用户生图流量。 +# 凭据、渠道和请求方式先通过后台 GET /models 探测,成功后才重新进入用户生图流量。 # 探测不调用 /images/generations,不触发生图费用;它只确认 host、鉴权和 models 端点恢复, -# 不能替代 Images/Responses/SSE 的真实 smoke;MAX_PER_TICK 用于限制探测流量。 +# 不能替代 Images/Responses/SSE 的真实冒烟验证;MAX_PER_TICK 用于限制探测流量。 # OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true # 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。 # OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true @@ -129,7 +129,7 @@ OPENAI_API_BASE_URL= # ENABLE_RESPONSES_IMAGE_BACKEND=true # OPENAI_RESPONSES_API_MODEL=gpt-5.4 -# 可选:独立真实上游 smoke 目标。默认不触发计费请求,必须显式运行 --allow-billable。 +# 可选:独立真实上游冒烟验证目标。默认不触发计费请求,必须显式运行 --allow-billable。 # 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。 # IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://original-new-api.example.com/v1 # IMAGE_REAL_SMOKE_ORIGINAL_API_KEY= @@ -144,8 +144,8 @@ OPENAI_API_BASE_URL= # IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 # IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1 # IMAGE_REAL_SMOKE_MATSCA_API_KEY= -# 独立真实 smoke 目标使用页面外部凭证路径,只验证 Matsca direct 模式。 -# App 模式请通过 OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca 和 OPENAI_CHANNEL_N_MATSCA_APP_* 配置后运行 server-channel smoke。 +# 独立真实冒烟验证目标使用页面外部凭据路径,只验证 Matsca 直连模式。 +# 应用模式请通过 OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca 和 OPENAI_CHANNEL_N_MATSCA_APP_* 配置后运行服务端渠道冒烟验证。 # IMAGE_REAL_SMOKE_TIMEOUT_MS=240000 # 可选:给网页加一个访问码。公网部署时建议一定要设置。 @@ -156,7 +156,7 @@ APP_PASSWORD= # GIP_BIND_HOST=0.0.0.0 # GIP_PORT=4783 -# 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。 +# 可选:给 /api/agent/* 使用的 Bearer 令牌。公网或内网共享部署时建议设置。 # AGENT_API_TOKEN= # 可选:Agent 状态后端。memory 适合临时演示,sqlite 适合单实例本地部署,postgres 适合状态并发;多实例还需共享 generated-images。 diff --git a/.env.real-smoke.example b/.env.real-smoke.example index 94c2b43dd11334c230b37416a0376c7c389e3f21..0ca9ee83c5f3ea27a10377bd1f74d79a31fbd777 100644 --- a/.env.real-smoke.example +++ b/.env.real-smoke.example @@ -1,32 +1,32 @@ -# Independent real upstream smoke targets. -# Copy this file to .env.real-smoke.local and fill only the targets you can run. -# Do not commit .env.real-smoke.local. -# Every *_BASE_URL must be a http/https absolute URL without credentials, query parameters, or fragments. -# Final gate: +# 独立真实上游冒烟验证目标。 +# 将本文件复制为 .env.real-smoke.local,只填写能够执行的目标。 +# 不要提交 .env.real-smoke.local。 +# 每个 *_BASE_URL 都必须是不含凭据、查询参数和片段的 http/https 绝对 URL。 +# 最终门禁: # npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable -# Original QuantumNous/new-api compatible Images API JSON. +# Original QuantumNous/new-api 兼容 Images API 的 JSON 接口。 IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL= IMAGE_REAL_SMOKE_ORIGINAL_API_KEY= # IMAGE_REAL_SMOKE_ORIGINAL_MODEL=gpt-image-2 # IMAGE_REAL_SMOKE_ORIGINAL_SIZE=1024x1024 # IMAGE_REAL_SMOKE_ORIGINAL_QUALITY=low -# gaoren002/new-api Images API SSE and keepalive branch. +# gaoren002/new-api 的 Images API SSE 和保活分支。 IMAGE_REAL_SMOKE_GAOREN_BASE_URL= IMAGE_REAL_SMOKE_GAOREN_API_KEY= # IMAGE_REAL_SMOKE_GAOREN_MODEL=gpt-image-2 # IMAGE_REAL_SMOKE_GAOREN_SIZE=1024x1024 # IMAGE_REAL_SMOKE_GAOREN_QUALITY=low -# Wei-Shaw/sub2api Images API SSE. +# Wei-Shaw/sub2api 的 Images API SSE 接口。 IMAGE_REAL_SMOKE_SUB2API_BASE_URL= IMAGE_REAL_SMOKE_SUB2API_API_KEY= # IMAGE_REAL_SMOKE_SUB2API_MODEL=gpt-image-2 # IMAGE_REAL_SMOKE_SUB2API_SIZE=1024x1024 # IMAGE_REAL_SMOKE_SUB2API_QUALITY=low -# sub2api Responses image_generation bridge. If omitted, the smoke script can reuse IMAGE_REAL_SMOKE_SUB2API_*. +# sub2api 的 Responses image_generation 桥接。留空时,冒烟验证脚本可复用 IMAGE_REAL_SMOKE_SUB2API_*。 IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL= IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2 @@ -34,7 +34,7 @@ IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_SIZE=1024x1024 # IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low -# GPT2Image style Responses image_generation SSE. +# GPT2Image 风格的 Responses image_generation SSE 接口。 IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1 IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY= # IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2 @@ -42,14 +42,14 @@ IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 # IMAGE_REAL_SMOKE_GPT2IMAGE_SIZE=1024x1024 # IMAGE_REAL_SMOKE_GPT2IMAGE_QUALITY=low -# Matsca OpenAI-compatible Images API SSE. -# This independent target uses the page external credential path and validates Matsca direct mode. -# For Matsca app mode, configure OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca plus OPENAI_CHANNEL_N_MATSCA_APP_* and run server-channel smoke. +# Matsca 兼容 OpenAI 的 Images API SSE 接口。 +# 此独立目标使用页面外部凭据路径,并验证 Matsca 直连模式。 +# Matsca 应用模式请配置 OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca 和 OPENAI_CHANNEL_N_MATSCA_APP_*,再运行服务端渠道冒烟验证。 IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1 IMAGE_REAL_SMOKE_MATSCA_API_KEY= # IMAGE_REAL_SMOKE_MATSCA_MODEL=gpt-image-2 # IMAGE_REAL_SMOKE_MATSCA_SIZE=1024x1024 # IMAGE_REAL_SMOKE_MATSCA_QUALITY=low -# Shared timeout for each real upstream smoke case. +# 每个真实上游冒烟验证用例共用的超时时间。 IMAGE_REAL_SMOKE_TIMEOUT_MS=240000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bd22b692f2e90016fdd05dad19e0e735914a4f6..44244cdd25cb0e1d8657e65f1b958a6f5c2b3021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,9 +66,10 @@ jobs: HEAD_SHA: ${{ github.sha }} run: | set -euo pipefail - if [[ -n "$BASE_SHA" && "$BASE_SHA" != "0000000000000000000000000000000000000000" ]]; then + if [[ -n "$BASE_SHA" && "$BASE_SHA" != "0000000000000000000000000000000000000000" ]] && git cat-file --quiet --verify "${BASE_SHA}^{commit}"; then git diff --check "$BASE_SHA" "$HEAD_SHA" else + # 历史重写后 push 事件的旧基准提交可能已不可达,此时检查当前提交。 git show --check --format= "$HEAD_SHA" fi diff --git a/AGENTS.md b/AGENTS.md index e6100166ea4e47904d8209e7241baf835203bf98..c2964d76ea788b9d60ecd95f9cef4498f79202e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,158 +1,36 @@ -# AGENTS.md - 仓库执行约束 +# 仓库贡献指南 -## 1. 核心原则 +## 项目结构 -- 全程使用中文沟通,结论必须基于代码、测试、构建、运行结果或 git 证据。 -- 禁止为“先跑通”添加静默降级、隐藏回退、伪造成功路径或吞没异常后继续。 -- 先保证行为等价和真实失败可见,再做性能、体验或结构优化。 -- 每次只处理一个明确任务,先确认边界,再修改,再做最小充分验证。 -- 不顺手修正无关问题;发现范围外问题时单独记录,不混入当前任务。 -- 代码、注释、日志字符串和 Markdown 不使用 Emoji 或装饰性 Unicode 符号。 +图像手记是基于 Next.js 16 和 React 19 的本地优先图片创作工作台。`src/app/` 包含应用路由页面、共享页和路由处理器;主工作台为 `src/app/page.tsx`,图片接口位于 `src/app/api/images/route.ts`,智能体接口位于 `src/app/api/agent/`。将可复用的领域逻辑放在 `src/lib/`,将界面组件放在 `src/components/`,基础界面组件位于 `src/components/ui/`。`scripts/` 存放构建、部署和诊断脚本,`database/` 保存 SQLite/Postgres 数据库模式,`docs/` 保存产品、部署和审查资料。 -## 2. 项目事实 +## 开发、构建与验证 -- 项目是 Next.js 16 + React 19 的本地图片服务,默认端口 `4783`。 -- 包管理工具是 `npm`,锁文件是 `package-lock.json`。 -- Node 版本要求是 `>=22.15.0`。 -- 主要页面入口是 `src/app/page.tsx`。 -- 图片 API 入口是 `src/app/api/images/route.ts`。 -- Agent API 位于 `src/app/api/agent/`。 -- 图片请求校验位于 `src/lib/image-request-utils.ts`。 -- 多渠道路由位于 `src/lib/channel-router.ts` 和 `src/lib/server-channel-router.ts`。 -- 仓库自带 agent skill:`skills/gpt-image-playground-agent/SKILL.md`。 - -## 3. 任务工作流 - -### 3.1 任务来源 - -- 当前仓库没有独立的 `tasks.md`、`issues.csv` 或等价任务跟踪文件。 -- 在未新增任务文件前,以用户当前回合明确指定的单一任务为唯一任务来源。 -- 若后续新增任务跟踪文件,任务优先级切换为:任务文件状态 > git 提交证据 > 当前代码事实。 - -### 3.2 原子任务循环 - -- 每次只处理一个原子任务,流程固定为:读取上下文 -> 锁定范围 -> 实现或审计 -> 验证 -> 自审 -> 结束任务。 -- 修改前先确认影响文件和验证方式。 -- 修改后只在当前任务边界内收敛,不并行推进其他需求。 - -### 3.3 自审要求 - -- 对照用户给出的验收标准逐条确认。 -- 运行最小相关验证,并记录命令与结果。 -- 用 `git diff --name-only` 和 `git diff --check` 确认没有范围外改动和明显格式问题。 -- 所有验证通过后,才可以声明完成。 - -### 3.4 Code Review 模式 - -- 当任务标题或用户指令包含 `[Code Review]` 时,默认进入审计模式,不直接修改业务代码,除非用户明确要求修复。 -- 审计依据依次为:目标 diff、`AGENTS.md`、任务验收标准、相关测试和构建结果。 -- 当前仓库没有 `docs/review_checklist.md`;如需输出审计报告,放在 `docs/reviews/CR-{ID}.md`,目录不存在时按需创建。 - -## 4. 质量红线 - -### 4.1 开发边界 - -- 禁止为迎合测试或截图而硬编码业务结果。 -- 只修改完成当前任务所必需的文件。 -- 不保留无用兼容分支、死代码或无法解释的兜底逻辑。 -- 外部输入失败必须显式报错,不能静默改写后继续。 - -### 4.2 工程基线 - -- 遵循 SOLID、DRY、关注点分离和 YAGNI。 -- 命名清晰,抽象务实,只在不直观处补简洁注释。 -- 核心逻辑优先放在 `src/lib/`,UI 组件保持展示职责清晰。 -- 能通过纯函数或依赖注入表达的逻辑,不要直接绑死到全局状态或具体实现。 - -### 4.3 安全基线 - -- 严禁在源码、文档示例、测试快照里写入真实 API Key、token 或密码。 -- 自定义 API URL 和自定义 API Key 必须成对出现,避免服务端密钥被转发到未知地址。 -- 所有用户输入、上传文件、URL、文件名、尺寸、格式和上游响应都要在边界处校验。 -- 仅当密钥被写入仓库文件时,才视为泄漏事故;会话内临时调试输入不算源码泄漏。 - -## 5. 测试与验证 - -### 5.1 测试布局 - -- 当前仓库测试采用同目录 `node:test` 方案,命名为 `*.test.ts`。 -- 测试文件主要位于 `src/lib/**/*.test.ts`、`src/app/api/**/*.test.ts`。 -- 新增测试优先沿用现有同目录模式,不额外引入第二套测试目录约定。 - -### 5.2 验证基线 - -- 提交前最小基线是: - -```bash -npm run install-scripts:check -npm run npm-install-policy:check -npm run dependencies:check -npm test -npm run lint -npm run format:check -npm run lint:scripts -npm run build -git diff --check -``` - -- 若只改动局部模块,先跑最小相关测试;准备收尾时再跑上述全量基线。 -- 不能用“看起来没问题”代替自动化验证;不能把单项通过误报为整体通过。 - -### 5.3 数据与契约校验 - -- 处理 JSON、multipart、流式响应、数据库记录或 Agent API 合同时,必须明确字段含义、类型和分支语义。 -- 若逻辑依赖真实 Postgres 行为、类型转换或驱动序列化,至少补充离线契约测试;若未连真实库,必须明确说明“仅覆盖语义,未覆盖真实数据库行为”。 -- 不能把 `npm test` 全绿直接表述为线上、Docker 或真实上游接口已通过。 - -## 6. 环境与运行 - -### 6.1 本地开发 +使用 Node.js `>=22.15.0`、npm 和提交的 `package-lock.json`。首次安装运行 `npm ci --strict-allow-scripts`;运行 `npm run dev` 在 `http://localhost:4783` 启动 Turbopack 开发服务。常用命令如下: ```bash -npm run install-scripts:check -npm run npm-install-policy:check -npm ci --strict-allow-scripts -npm run dependencies:check -npm run dev +npm test # 全部单元与契约测试 +npm test -- src/lib/image-service.test.ts # 定向测试 +npm run lint # ESLint 检查 src/ +npm run format:check # Prettier 检查 +npm run build # 生产构建 +npm run verify # 提交前完整基线 ``` -- 本地默认访问地址是 `http://localhost:4783`。 -- `npm run dev` 使用 Turbopack,并固定端口 `4783`。 - -### 6.2 Docker 验证 +需要连接真实 Postgres 时,使用 `npm run verify -- --postgres` 并提供测试数据库。`npm run start` 必须在构建后使用,不要直接运行 `.next/standalone/server.js`。 -```bash -docker compose up -d --build -``` +## 架构与变更边界 -- 需要容器验证时,以最新代码重建后再做页面或接口检查。 -- 不能只看容器启动成功就声称验证完成,必须补至少一项真实访问或真实请求证据。 - -### 6.3 常用检查 - -```bash -npm test -npm run lint -npm run format:check -npm run lint:scripts -npm run build -git diff --check -``` +页面工作台、页面 SSE 接口和智能体 JSON 接口是不同的调用边界;不要因为某一路径可用就推断其他路径或真实上游也已通过。路由、流式传输、幂等键、产物分享和状态后端的行为应由服务端契约决定,客户端脚本只做薄封装。实现变更时先定位受影响的路由处理器、`src/lib/` 领域逻辑和对应测试,再做最小修改;不要为了让测试通过加入静默回退、伪造上游响应或吞掉错误。 -## 7. 提交与文件卫生 +## 代码与测试规范 -- 每个原子任务单独提交,提交信息应直接说明本次改动。 -- 提交前确认 `git diff --name-only` 只包含任务范围内文件。 -- 默认不提交临时产物、日志、缓存、截图、生成图片、个人配置或本地数据库文件,除非任务明确要求。 -- 修改 README、CHANGELOG、版本号或发布产物定义时,必须同时核对 `package.json`、`package-lock.json` 和相关文档口径。 +使用 TypeScript 严格模式和 `@/` 路径别名。保持现有四空格缩进、单引号、分号和 120 列宽;执行 `npm run format` 处理格式和导入排序。组件使用 PascalCase 导出,文件使用 kebab-case。测试与被测模块同目录放置:`*.test.ts`、`*.test.tsx` 或 `scripts/*.test.mjs`;使用 `node:test` 与 `tsx`,覆盖成功、边界和失败分支。修改接口、流式响应、路由或状态模式时,同步更新契约测试。仓库面向人的 Markdown、技能说明和环境变量样例注释统一使用中文;命令、路径、接口字段和协议值保持原样。 -## 8. 仓库内文档与技能 +## 智能体、配置与安全 -- 开始任务前,先检查仓库内是否已有相关文档或 skill 可复用。 -- 当前已知技能入口是 `skills/gpt-image-playground-agent/SKILL.md`,命中 Agent API 调用场景时必须先阅读。 -- 阶段性计划当前位于 `docs/superpowers/plans/`;它们只用于补充上下文,不替代代码事实和用户当前任务。 +复制 `.env.example` 为本地配置,绝不提交 `.env*`、真实密钥、`generated-images/` 或 `artifacts/`。自定义上游地址必须与自定义 API 密钥成对使用。涉及图片生成、编辑、批量处理或渠道诊断时,先阅读 `skills/visual-journal-image-agent/SKILL.md`,复用其中脚本以及智能体能力声明和 OpenAPI 契约;任何真实上游计费调用都必须显式传入 `--allow-billable`。 -## 9. 历史踩坑记录 +## 提交与合并请求 -- 重要踩坑应记录现象、根因、修复方式和相关文件或提交。 -- 当前仓库若需要新增长期审计或复盘文档,统一放在 `docs/reviews/`。 +近期提交使用约定式提交格式;示例中的提交文本保留仓库实际历史用语:`fix(images): validate payload`、`refactor(skill): rename agent`、`docs: clarify deployment`。一个提交只处理一个可验证任务。合并请求说明应列出改动范围、验证命令及结果;界面改动附桌面和移动端截图,配置或数据库模式改动说明迁移和部署影响。提交前运行 `git diff --check`,避免混入无关生成物或格式改动。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff18f765bef5fbf9a351b08b1b13d6638b271273..990318b643d6b021cbcfc8494cea462be98d9cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,44 @@ -# Changelog +# 变更记录 本文件记录项目的重要变更。 -本项目参考 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 格式维护变更记录,并使用语义化版本管理正式发布版本。 +本项目参考 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 规范维护变更记录,并使用语义化版本管理正式发布版本。 ## [未发布] 暂无变更。 +## [2.3.0] - 2026-08-13 + +### 新增 + +- 强化响应式图片创作工作台,覆盖生成、编辑、批量任务、历史复用、分享、保留策略、成本与耗时摘要、移动端操作和中英文界面契约。 +- 扩展 Agent API、OpenAPI 和运行时能力声明,提供服务端编排、任务轮询、产物管理、结果反馈、渠道健康、请求方式和诊断信息。 +- 为 Agent 技能增加预演与契约检查、按后端能力校验、批量续跑与尺寸校验、路由诊断和真实上游能力矩阵流程。 + +### 变更 + +- 图片渠道路由改为按实际请求方式、凭证健康状态、模型、尺寸、背景、上传和遮罩约束筛选候选渠道。 +- 支持混合渠道的离散图片数量范围和 `partial_images` 范围,能力接口、页面、Agent、OpenAPI 和 Skill 保持同一契约。 +- 明确 Responses 图片后端的非流式和 SSE 语义:非流式不发送未使用的 `partial_images`,流式仅接受有效预览范围。 +- 统一工作台的提示词长度、图片数量、尺寸、源图上传、遮罩、背景和后端兼容性校验,并改善错误提示、状态恢复、历史成本显示和结果操作。 +- 完成 Visual Journal 品牌、npm 包名、Agent Skill 路径和 Hugging Face Space 目标迁移,固定 Space 为 `misonL/visual-journal`,并同步运行时 User-Agent、保活工作流和部署脚本。 +- 将真实上游服务端渠道冒烟验证默认改为通过 HTTP 访问 Docker 服务,输出实际渠道、请求方式和上游主机,并在 Agent 请求后清理测试产物。 +- 将仓库贡献指南、环境变量示例、产品与部署资料、Agent 技能文档统一为中文,清理历史审查、计划和设计过程文档。 + +### 修复 + +- 修复非流式图片响应、混合渠道范围不相交和后端默认值处理,避免能力接口 500、合法请求误报 422/503、自动默认值选择错误和冷却渠道误选。 +- 修复页面与 Agent 编辑路径遗漏图片数量、源图文件大小、总上传量、遮罩、背景和尺寸限制的问题。 +- 修复分享错误、批量提示词超限、历史记录成本与结果操作状态、窄屏布局和本地化文案等工作台回归问题。 +- 将 `brace-expansion`、`js-yaml` 和 `nanoid` 更新到安全版本,并通过 npm override 固定受影响依赖版本。 +- 补齐版本、文案、路由、能力、上传边界、脚本参数和并发临时目录隔离的回归测试与文档漂移检查。 + +### 升级提示 + +- 自动化脚本引用的 Skill 根目录需从 `skills/gpt-image-playground-agent/` 更新为 `skills/visual-journal-image-agent/`;API 路径和环境变量保持兼容。 +- Docker Compose 默认仍使用 `images-api` 和 `auto`;Responses 图片后端只有在显式配置 `ENABLE_RESPONSES_IMAGE_BACKEND` 与顶层模型后才会启用。 + ## [2.2.0] - 2026-07-28 ### 新增 @@ -30,8 +61,8 @@ - 独立启动脚本显式加载 Next.js 运行时环境,测试和版本检查脚本改为支持显式测试文件与 runner 参数。 - 工作台、历史面板和移动端抽屉的可滚动区域与操作可达性按短视口约束收敛。 - 渠道失败冷却默认关闭;需要自动临时移出失败渠道时,必须显式设置 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED=true`。 -- WebUI 图片清理启用后默认保留 30 天,并在启动时及之后每 6 小时执行;仍由 Agent 状态库登记的 artifact 不会被提前删除。 -- 手动删除 WebUI 图片会同步清理永久保存标记;永久保存不改变 Agent artifact 的生命周期或手动删除语义。 +- WebUI 图片清理启用后默认保留 30 天,并在启动时及之后每 6 小时执行;仍由 Agent 状态库登记的产物不会被提前删除。 +- 手动删除 WebUI 图片会同步清理永久保存标记;永久保存不改变 Agent 产物的生命周期或手动删除语义。 ### 修复 @@ -41,23 +72,23 @@ - 修正移动端运行时状态加载引起的首屏布局位移,并增加键盘跳到主要内容入口。 - 清理本地临时图片日志并将 `tmp` 目录加入忽略规则,避免运行产物进入版本库。 - 修正图片输出目录在进程启动时被冻结的问题,避免运行目录变化时测试或后台任务读写错误的图片目录。 -- 规范化清理目录和 Agent artifact 的真实路径,避免 macOS 路径别名导致仍在保留期内的 Agent 图片被误删。 -- 补充 Images JSON、Images SSE、Responses JSON 和 Responses SSE 四种 Agent 请求方式的 artifact 清理生命周期回归测试。 +- 规范化清理目录和 Agent 产物的真实路径,避免 macOS 路径别名导致仍在保留期内的 Agent 图片被误删。 +- 补充 Images JSON、Images SSE、Responses JSON 和 Responses SSE 四种 Agent 请求方式的产物清理生命周期回归测试。 ## [2.1.0] - 2026-06-14 ### 新增 - 增加渠道凭证并发队列,超出单凭证容量的请求会按队列等待,并通过运行态能力接口暴露队列容量、等待上限和当前队列状态。 -- Agent skill 脚本支持默认 WebP 高质量输出、图像格式转换、Responses image_generation edit 的页面 SSE 路由,以及批量脚本按运行态容量限制有效并发。 +- Agent 技能脚本支持默认 WebP 高质量输出、图像格式转换、Responses image_generation edit 的页面 SSE 路由,以及批量脚本按运行态容量限制有效并发。 - 增加 Matsca upstream profile,支持 Matsca 直连渠道的尺寸、`partial_images`、透明背景和上传限制口径。 ### 变更 - 图片默认输出格式调整为 WebP,默认压缩质量为 `100`;需要无损归档或透明边缘复核时可显式选择 PNG。 - 渠道失败冷却默认时间调整为 `30000ms`,并增加 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED` 以允许关闭渠道或凭证冷却。 -- 页面端 `/api/images` 的 generate 和 edit 都支持 `IMAGE_GENERATION_BACKEND` 与 `IMAGE_STREAMING_STRATEGY` 运行时默认值,Responses edit 仍明确走页面 SSE 路径。 -- README、`.env.example` 和 Agent skill 文档补齐渠道队列、默认后端、默认流式策略、WebP 输出和 Responses edit 路由说明,避免把 Docker compose 误解为默认 Responses 后端。 +- 页面端 `/api/images` 的生成和编辑都支持 `IMAGE_GENERATION_BACKEND` 与 `IMAGE_STREAMING_STRATEGY` 运行时默认值,Responses 编辑仍明确走页面 SSE 路径。 +- README、`.env.example` 和 Agent 技能文档补齐渠道队列、默认后端、默认流式策略、WebP 输出和 Responses 编辑路由说明,避免把 Docker Compose 误解为默认 Responses 后端。 ### 修复 @@ -71,7 +102,7 @@ - WebUI 增加 `图像手记` 工作台的显式批量模式:多条提示词逐行形成独立任务,批量进度、暂停、失败项复用和批次历史保持可追溯。 - WebUI 在省心模式和专业模式中展示“并发批量”状态;只有用户手动启用且当前流式策略、任务数量和渠道容量满足条件时,才会把多图或多提示词拆成并发流式任务。 -- Agent skill 批量脚本支持 `--concurrency N` 并发执行、append-only manifest、续跑、尺寸校验、失败重试和页面 SSE 原始事件留档。 +- Agent 技能批量脚本支持 `--concurrency N` 并发执行、追加写入清单、续跑、尺寸校验、失败重试和页面 SSE 原始事件留档。 - 增加 `npm run version:check`,校验 `package.json`、`package-lock.json`、README 版本徽章和 `CHANGELOG.md` 版本链接一致。 ### 变更 @@ -89,16 +120,16 @@ - 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。 - 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。 - 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。 -- Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE metadata、运行态启用后端和 job polling 语义,辅助脚本支持 `--page-sse`、`--agent`、`--job` 显式路由。 +- Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE 元数据、运行态启用后端和任务轮询语义,辅助脚本支持 `--page-sse`、`--agent`、`--job` 显式路由。 ### 变更 - 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。 - 页面默认不发送流式请求;用户显式开启流式预览后,单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。 -- 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、provider dialect 诊断和扣费解析逻辑。 +- 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、上游协议方言诊断和扣费解析逻辑。 - 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。 - Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。 -- Agent skill 文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。 +- Agent 技能文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。 ### 修复 @@ -112,20 +143,20 @@ - 增加运行时并发流式批处理能力与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。 - 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。 - 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。 -- 增加服务端 credential/channel 失败冷却机制,支持按渠道覆盖冷却窗口。 -- 运行时能力接口增加健康 credential/channel 数量和最近失败摘要,用于前端刷新并发窗口。 +- 增加服务端凭证和渠道失败冷却机制,支持按渠道覆盖冷却窗口。 +- 运行时能力接口增加健康凭证和渠道数量及最近失败摘要,用于前端刷新并发窗口。 - 增加流式批处理、运行时环境读取、渠道健康状态和失败分类的单元测试。 ### 变更 - 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。 - 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。 -- README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。 +- README 与 `.env.example` 补充流式批处理、单凭证并发上限和渠道失败冷却配置说明。 - ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。 ### 修复 -- 修正 `sticky` 路由下流式批处理推荐并发被渠道数量放大的问题,避免同一 affinity key 下突破单 credential 并发上限。 +- 修正 `sticky` 路由下流式批处理推荐并发被渠道数量放大的问题,避免同一亲和键下突破单个凭证的并发上限。 - 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。 - 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。 - 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。 @@ -175,7 +206,8 @@ - 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。 - 增加 Docker 部署支持和多平台启动脚本。 -[未发布]: https://github.com/MisonL/visual-journal/compare/v2.2.0...HEAD +[未发布]: https://github.com/MisonL/visual-journal/compare/v2.3.0...HEAD +[2.3.0]: https://github.com/MisonL/visual-journal/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/MisonL/visual-journal/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/MisonL/visual-journal/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/MisonL/visual-journal/compare/v1.4.0...v2.0.0 diff --git a/README.md b/README.md index 3f9c128ad3fe7408ab9f6ca99cc663c4488b3464..6e3fcd726f49a6da580e76f51fa98cb568d14228 100755 --- a/README.md +++ b/README.md @@ -7,16 +7,16 @@ app_port: 4783 # 图像手记 / Visual Journal -![Version](https://img.shields.io/badge/version-2.2.0-blue) -![License](https://img.shields.io/badge/license-MIT-green) -![Node](https://img.shields.io/badge/node-%3E%3D22.15.0-339933) +![版本](https://img.shields.io/badge/version-2.3.0-blue) +![许可证](https://img.shields.io/badge/license-MIT-green) +![Node.js](https://img.shields.io/badge/node-%3E%3D22.15.0-339933) 图像手记(Visual Journal)是本地优先的 AI 图片创作工作台,支持 `gpt-image-2` 与 OpenAI 兼容图片接口。提供文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪、多渠道路由和 Agent API。 -对外产品名称为“图像手记 / Visual Journal”。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,仓库包名、Docker 服务、环境变量、API 路径和 Skill 标识继续使用 `gpt-image-playground` 相关技术名称。 +对外产品名称、npm 包名和 Agent Skill 标识均为“图像手记 / Visual Journal”(`visual-journal`、`visual-journal-image-agent`)。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,Docker 服务、环境变量和 API 路径继续使用 `gpt-image-playground` 相关技术名称。

- 图像手记主界面 + 图像手记主界面

## 快速开始 @@ -32,7 +32,7 @@ npm run first-run npm run deploy:local ``` -打开 [http://localhost:4783](http://localhost:4783),在页面右上角的 `API 设置` 中填写 API Key 和兼容接口地址即可使用。 +打开 [http://localhost:4783](http://localhost:4783),在页面右上角的 `API 设置` 中填写 API 密钥和兼容接口地址即可使用。 也可以复制环境变量模板,配置服务端默认上游: @@ -61,18 +61,18 @@ Windows、macOS 和 Linux 也可分别使用 `start-windows.bat`、`start-macos. - 图片创作:文生图、图生图、遮罩编辑、单图和多图输出。 - 输出控制:尺寸、质量、格式、压缩率、透明背景和流式策略。 -- 批量生产:多提示词任务、并发控制、失败续跑和 manifest 记录。 +- 批量生产:多提示词任务、并发控制、失败续跑和清单记录。 - 工作台体验:灵感相册、历史复用、继续编辑、变体、下载、分享和反馈。 -- 费用与诊断:耗时、token、估算费用、实际扣费和脱敏日志摘要。 -- 上游路由:单 key、多渠道、多 key、渠道队列、失败冷却和代理支持。 -- 自动化接口:幂等请求、异步 job、产物追踪、分享和请求诊断。 +- 费用与诊断:耗时、令牌用量、估算费用、实际扣费和脱敏日志摘要。 +- 上游路由:单密钥、多渠道、多密钥、渠道队列、失败冷却和代理支持。 +- 自动化接口:幂等请求、异步任务、产物追踪、分享和请求诊断。 - 存储选择:文件系统、IndexedDB、SQLite、PostgreSQL 和内存状态。 ## 界面预览

- 遮罩编辑界面 - 历史与费用面板 + 遮罩编辑界面 + 历史与费用面板

## 配置 @@ -85,7 +85,7 @@ Windows、macOS 和 Linux 也可分别使用 `start-windows.bat`、`start-macos. | 多渠道 | `OPENAI_CHANNEL_N_*` | 配置多个渠道、多个 key、请求方式白名单和渠道级覆盖。 | | 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 仅代理服务端到图片上游的 HTTP(S) 请求。 | | 页面访问码 | `APP_PASSWORD` | 设置后,页面生图和受保护图片需要访问码。公网部署建议开启。 | -| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后,`/api/agent/*` 需要 Bearer token。 | +| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后,`/api/agent/*` 需要 Bearer 令牌。 | | Agent 状态 | `AGENT_STATE_BACKEND` | 支持 `memory`、`sqlite` 和 `postgres`;Compose 默认使用 SQLite。 | | 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 支持 `fs` 和 `indexeddb`;Compose 默认使用文件系统。 | | 图片清理 | `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED`、`WEBUI_IMAGE_RETENTION_DAYS` | 默认关闭;启用后默认保留 30 天。 | @@ -112,14 +112,14 @@ OPENAI_CHANNEL_2_API_KEYS=your-backup-key OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream ``` -请求方式白名单只能填写已通过真实上游 smoke、且结果能被本服务消费的模式。未配置时默认只允许 `images-non-stream`;显式流式或 Responses 请求失败时不会静默降级。 +请求方式白名单只能填写已通过真实上游冒烟验证、且结果能被本服务消费的模式。未配置时默认只允许 `images-non-stream`;显式流式或 Responses 请求失败时不会静默降级。 代理 URL 仅支持无认证、无路径的 `http://` 或 `https://` 根地址,不支持 SOCKS。代理只影响服务端出站请求,不改变浏览器到本服务的连接。 安全要求: -- 自定义 API URL 必须和自定义 API Key 成对配置,避免服务端密钥被发送到未知地址。 -- 不要把真实 API Key、访问码、token 或数据库密码提交到仓库。 +- 自定义 API URL 必须和自定义 API 密钥成对配置,避免服务端密钥被发送到未知地址。 +- 不要把真实 API 密钥、访问码、令牌或数据库密码提交到仓库。 - 非回环地址部署必须同时设置 `APP_PASSWORD`,否则容器会拒绝启动。 ## 部署 @@ -129,16 +129,16 @@ OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream | 模式 | 命令 | 适用场景 | | ---------- | ------------------------------------ | --------------------------- | | SQLite | `npm run deploy:local` | 本地单实例和长期运行。 | -| Memory | `npm run deploy:local -- --memory` | 临时演示或模拟 Space 环境。 | +| 内存 | `npm run deploy:local -- --memory` | 临时演示或模拟 Space 环境。 | | PostgreSQL | `npm run deploy:local -- --postgres` | 集中状态库或多实例部署。 | -部署脚本会拒绝脏工作区,并核对 Docker 健康状态、真实 HTTP 端点和镜像 revision。Compose 默认只发布到 `127.0.0.1:4783`;需要局域网访问时先设置 `APP_PASSWORD`,再执行: +部署脚本会拒绝脏工作区,并核对 Docker 健康状态、真实 HTTP 端点和镜像版本。Compose 默认只发布到 `127.0.0.1:4783`;需要局域网访问时先设置 `APP_PASSWORD`,再执行: ```bash GIP_BIND_HOST=0.0.0.0 npm run deploy:local ``` -文件系统图片保存在 `generated-images/`。若 `.env.local` 将 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 设为 `1`、`true`、`yes` 或 `on`,部署脚本会先拒绝运行,避免服务启动后立即清理历史图片;确认可以执行时显式添加 `--allow-image-auto-cleanup`。自动清理、永久保留和 Agent artifact 生命周期配置见 [.env.example](./.env.example)。 +文件系统图片保存在 `generated-images/`。若 `.env.local` 将 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 设为 `1`、`true`、`yes` 或 `on`,部署脚本会先拒绝运行,避免服务启动后立即清理历史图片;确认可以执行时显式添加 `--allow-image-auto-cleanup`。自动清理、永久保留和 Agent 产物生命周期配置见 [.env.example](./.env.example)。 ### Hugging Face Space @@ -148,7 +148,7 @@ GIP_BIND_HOST=0.0.0.0 npm run deploy:local [![在 Hugging Face 复制此 Space](https://huggingface.co/datasets/huggingface/badges/resolve/main/duplicate-this-space-md.svg)](https://huggingface.co/new-space?duplicate=misonL%2Fvisual-journal) -登录 Hugging Face 后,创建页会预填本 Space 作为复制来源。请在创建页选择 Private;复制不会带出本服务的 API Key、访问码或 Agent token。创建后必须在新 Space 的 Settings 中配置 `APP_PASSWORD`、`AGENT_API_TOKEN` 和自己的上游凭证;Docker Space 的创建资格仍受 Hugging Face 当前账户政策约束。 +登录 Hugging Face 后,创建页会预填本 Space 作为复制来源。请在创建页选择 Private;复制不会带出本服务的 API 密钥、访问码或 Agent 令牌。创建后必须在新 Space 的 Settings 中配置 `APP_PASSWORD`、`AGENT_API_TOKEN` 和自己的上游凭证;Docker Space 的创建资格仍受 Hugging Face 当前账户政策约束。 #### 维护本项目固定 Space @@ -163,7 +163,7 @@ npm run deploy:space ## Agent API -Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平台。客户端应先读取 capabilities,再向服务端提交业务意图,由服务端决定渠道和请求方式。 +Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平台。客户端应先读取能力声明,再向服务端提交业务意图,由服务端决定渠道和请求方式。 | 接口 | 用途 | | ------------------------------------------- | -------------------------------- | @@ -179,18 +179,18 @@ Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平 ```bash npm run first-run -- --json --base-url http://localhost:4783 -node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ +node skills/visual-journal-image-agent/scripts/generate-image.mjs \ --contract-check \ --base-url http://localhost:4783 \ "capability check" ``` -仓库内置脚本默认 dry-run,不触发真实生图。只有用户明确允许计费后,才添加 `--allow-billable`。 +仓库内置脚本默认执行预演(dry-run),不触发真实生图。只有用户明确允许计费后,才添加 `--allow-billable`。 -新增 probe、diagnostics 或路由可观测能力时,先落 API / capabilities / OpenAPI 契约,再让 Skill 脚本做薄封装。完整参数、批量任务、编辑、分享、诊断、真实 smoke 和边界矩阵见: +新增探针、诊断或路由可观测能力时,先落 API、能力声明和 OpenAPI 契约,再让 Skill 脚本做薄封装。完整参数、批量任务、编辑、分享、诊断、真实冒烟验证和边界矩阵见: -- [Agent Skill](./skills/gpt-image-playground-agent/SKILL.md) -- [Agent API 参考](./skills/gpt-image-playground-agent/references/api.md) +- [Agent Skill](./skills/visual-journal-image-agent/SKILL.md) +- [Agent API 参考](./skills/visual-journal-image-agent/references/api.md) 渠道健康快照只读取当前进程内存状态,不触发上游探测或图片生成,也不替代页面 `/api/runtime-capabilities`。 @@ -198,7 +198,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ | 命令 | 用途 | | ------------------------------------ | --------------------------------------------------- | -| `npm run status` | 只读查看 Git、Node、部署目标和真实 smoke 配置状态。 | +| `npm run status` | 只读查看 Git、Node、部署目标和真实冒烟验证配置状态。 | | `npm run doctor` | 运行本机和部署诊断。 | | `npm run env:summary` | 安全汇总环境变量来源,不输出密钥值。 | | `npm run agent:doctor` | 执行非计费 Agent 分层诊断。 | @@ -207,9 +207,9 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ | `npm run format:check` | 检查 TypeScript 和 TSX 格式。 | | `npm run build` | 执行生产构建。 | | `npm run verify` | 运行提交前完整基线。 | -| `npm run smoke:image-upstream-local` | 运行本地非计费上游兼容 final gate。 | +| `npm run smoke:image-upstream-local` | 运行本地非计费上游兼容最终门禁。 | -真实上游 smoke 必须显式传入 `--allow-billable`。`npm run status` 和默认诊断只检查配置与合同,不会产生图片费用。 +真实上游冒烟验证必须显式传入 `--allow-billable`。`npm run status` 和默认诊断只检查配置与合同,不会产生图片费用。 ## 常见问题 @@ -218,17 +218,17 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ | 未检测到 Node.js | 安装 Node.js >=22.15.0。 | | 依赖安装失败 | 依次运行安装策略检查、`npm ci --strict-allow-scripts` 和依赖核对。 | | API 返回 HTML | API URL 填成了网页地址;应填写 OpenAI 兼容 `/v1` 根地址。 | -| 提示需要 API Key | 在 `.env.local` 或页面 `API 设置` 中配置。 | +| 提示需要 API 密钥 | 在 `.env.local` 或页面 `API 设置` 中配置。 | | 端口被占用 | 检查占用 `4783` 的旧进程或旧容器。 | ## 项目文档 - [产品边界](./docs/product/product-contract.md) - [用户验证脚本](./docs/product/user-validation-script.md) -- [图片 Provider Manifest](./docs/product/image-provider-manifest.md) +- [图片上游清单](./docs/product/image-provider-manifest.md) - [Hugging Face Space 部署](./docs/deployment/huggingface-space-free.md) -- [Agent Skill](./skills/gpt-image-playground-agent/SKILL.md) -- [Agent API 参考](./skills/gpt-image-playground-agent/references/api.md) +- [Agent Skill](./skills/visual-journal-image-agent/SKILL.md) +- [Agent API 参考](./skills/visual-journal-image-agent/references/api.md) - [版本记录](./CHANGELOG.md) ## 技术栈 diff --git a/docs/deployment/huggingface-space-free.md b/docs/deployment/huggingface-space-free.md index efb0ba3c5aa3349908c6de14022fc296daf26d85..b7b0c531ab5978ede4dacf9ecf4b5403dbd710fc 100644 --- a/docs/deployment/huggingface-space-free.md +++ b/docs/deployment/huggingface-space-free.md @@ -1,4 +1,4 @@ -# Hugging Face Space 部署 +# Hugging Face Space 部署指南 本文档描述如何把本项目部署到 Hugging Face Docker Space,用作公网图片生成服务。Docker Space 的创建和更新权限取决于 Hugging Face 的当前账户政策;固定目标已存在且元数据标识为 Docker,部署脚本会直接使用认证 Git 推送,避免 `hf upload` 触发创建接口的已知 `402`。这不绕过新建 Docker Space 的账户限制。 @@ -6,13 +6,13 @@ - 手机浏览器可以访问 Space 网页并正常生图。 - 电脑上的 Agent 可以通过 `/api/agent/*` 调用同一个 Space 生图。 -- 首战场景是中文内容运营者为小红书笔记、商品详情页或活动海报生成首版视觉稿,不是公开 SaaS。 +- 首战场景是中文内容运营者为小红书笔记、商品详情页或活动海报生成首版视觉稿,不是公开软件即服务平台。 - 状态后端使用 `memory`,不依赖 SQLite、PostgreSQL 或外部数据库。 -- 图片 Web 结果优先保存在浏览器 IndexedDB,减少服务端临时盘依赖。 +- 图片网页结果优先保存在浏览器 IndexedDB,减少服务端临时磁盘依赖。 -## Space README YAML +## Space README 元数据 -本仓库顶层 `README.md` 已包含 Hugging Face Space metadata。如果你维护的是单独的 Space 仓库,确认它的 `README.md` 顶部使用 Docker SDK,并暴露本项目端口: +本仓库顶层 `README.md` 已包含 Hugging Face Space 元数据。如果你维护的是单独的 Space 仓库,确认它的 `README.md` 顶部使用 Docker SDK,并暴露本项目端口: ```yaml --- @@ -23,13 +23,13 @@ app_port: 4783 官方依据: -- Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker +- Docker Space 配置、变量/密钥和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker - Space 硬件与计费政策:https://huggingface.co/docs/hub/main/spaces-gpus - Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli ## 全新电脑前置条件 -全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把 Secret 写到远端 Space。 +全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把密钥写到远端 Space。 先检查: @@ -45,8 +45,8 @@ hf auth whoami - Node.js >=22.15.0。 - npm 随 Node.js 一起可用。 - Hugging Face CLI 使用当前官方 `hf` 命令。 -- `hf auth login` 使用 Hugging Face Access Token,不是账号密码。 -- Docker 只对本地 Space-like 容器 smoke(推荐 `npm run smoke:hf-space-local`,兼容别名 `npm run smoke:hf-space`)和本地容器验证必需;远端部署由 `npm run deploy:space` 统一执行。 +- `hf auth login` 使用 Hugging Face 访问令牌,不是账号密码。 +- Docker 仅在本地 Space 近似容器冒烟验证(推荐 `npm run smoke:hf-space-local`,兼容别名 `npm run smoke:hf-space`)和本地容器验证时必需;远端部署由 `npm run deploy:space` 统一执行。 安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。 @@ -67,7 +67,7 @@ npm run dependencies:check npm run doctor ``` -`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端 Variables 和远端 Secrets。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 +`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端变量和远端密钥。该命令不会写远端密钥、不会重启 Space、不会打印密钥值。 ## 管理员命令中心 @@ -82,14 +82,14 @@ npm run deploy:space npm run agent:doctor ``` -- `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。 +- `status`:只读输出 git、Node、固定 Space 目标、Agent 能力声明路径和 Skill 入口。 - `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。 -- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。 -- `deploy:local`:拒绝脏工作区后重建本地 Docker 服务,等待 healthcheck、核验镜像 revision 并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效,加 `--postgres` 会断言 postgres/fs overlay 生效且要求通过 shell 或 Compose `.env` 提供 `GPT_IMAGE_POSTGRES_PASSWORD`。默认 Compose 只绑定 `127.0.0.1:4783`;需要非回环发布时必须显式设置 `GIP_BIND_HOST` 和 `APP_PASSWORD`。 +- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、静态检查、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL 门禁时加 `--postgres`。 +- `deploy:local`:拒绝脏工作区后重建本地 Docker 服务,等待健康检查、核验镜像版本并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb 覆盖配置生效,加 `--postgres` 会断言 postgres/fs 覆盖配置生效且要求通过 Shell 或 Compose `.env` 提供 `GPT_IMAGE_POSTGRES_PASSWORD`。默认 Compose 只绑定 `127.0.0.1:4783`;需要非回环发布时必须显式设置 `GIP_BIND_HOST` 和 `APP_PASSWORD`。 - `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。 - `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。 -HF Space 交互使用官方 `hf` CLI。不要维护本机 access 文件,不要把 `APP_PASSWORD`、`AGENT_API_TOKEN`、OpenAI Key 或 Hugging Face token 写入仓库文件。 +HF Space 交互使用官方 `hf` CLI。不要维护本机访问文件,不要把 `APP_PASSWORD`、`AGENT_API_TOKEN`、OpenAI 密钥或 Hugging Face 令牌写入仓库文件。 部署当前干净的 git HEAD 到固定 Space: @@ -104,10 +104,10 @@ npm run deploy:space - Space 发布包会排除根目录 `readme-images/` 中的 README 文档截图,以兼容 Hugging Face Git 的二进制文件门禁;Space README 会改用对应 GitHub 提交的不可变图片地址。 - 读取远端 Space 元数据;当前固定 Docker Space 直接克隆、同步已跟踪源码并使用认证 Git 推送。 - 非 Docker Space 才优先使用 `hf upload`;仅当它命中既有 Docker Space 创建政策 `402` 时才回退到认证 Git 推送,其他错误不会自动回退。 -- 等待新 Space commit 进入 `RUNNING`。 +- 等待新 Space 提交进入 `RUNNING`。 - 检查 `/api/auth-status`、`/api/agent/capabilities` 和 `/api/runtime-capabilities`,不触发真实生图。 -配置或轮换 Variables/Secrets 时,直接使用官方 `hf` CLI: +配置或轮换变量/密钥时,直接使用官方 `hf` CLI: ```bash hf spaces variables add misonL/visual-journal -e AGENT_STATE_BACKEND=memory @@ -117,11 +117,11 @@ hf spaces secrets add misonL/visual-journal -s APP_PASSWORD= hf spaces secrets add misonL/visual-journal -s AGENT_API_TOKEN= ``` -源码部署、远端诊断、Variables 和 Secrets 都由仓库命令与 `hf` CLI 协同完成;部署回退只使用现有 Git 凭据,不维护第二套 access-file 或 Secret 同步流程。 +源码部署、远端诊断、变量和密钥都由仓库命令与 `hf` CLI 协同完成;部署回退只使用现有 Git 凭据,不维护第二套访问文件或密钥同步流程。 -## Space Variables +## Space 变量 -在 Space Settings 中添加这些 Variables: +在 Space Settings 中添加这些变量: ```dotenv AGENT_STATE_BACKEND=memory @@ -129,7 +129,7 @@ NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb APP_LOG_LEVEL=warn ``` -`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。 +`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明构建参数,Hugging Face Docker Space 会把同名变量作为构建参数传入构建,并在运行期注入环境变量。 如果不使用 `memory`,再按实际状态后端追加可选变量: @@ -150,28 +150,28 @@ AGENT_PUBLIC_BASE_URL=https://-.hf.space `AGENT_PUBLIC_BASE_URL` 影响 OpenAPI `servers[0].url`,也用于 `POST /api/agent/artifacts/{id}/share` 返回用户可打开的分享外链。必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。 -## Space Secrets +## Space 密钥 -在 Space Settings 中添加 Secrets,不要写入仓库文件: +在 Space Settings 中添加密钥,不要写入仓库文件: ```dotenv -OPENAI_API_KEY= +OPENAI_API_KEY=<你的 API 密钥> OPENAI_API_BASE_URL=https://api.openai.com/v1 # 可选:仅服务端到上游的无认证 HTTP(S) 代理。 OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080 APP_PASSWORD= -AGENT_API_TOKEN= +AGENT_API_TOKEN=<足够长的随机 Agent 令牌> ``` `OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。 `OPENAI_UPSTREAM_PROXY_URL` 只影响 Space 服务端到上游 API 的出站连接,不影响用户浏览器访问 Space。它仅接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS。多渠道部署可用 `OPENAI_CHANNEL_N_PROXY_URL` 覆盖全局代理,渠道级值优先。代理地址即使不含凭据也建议作为 Space Secret 管理;修改后需要重新启动或重新部署 Space。运行态和 Agent 诊断只公开是否配置及协议,不公开主机或端口。 -公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。 +公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API 密钥。 如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。 -如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets: +如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` 密钥: ```dotenv OPENAI_ROUTING_STRATEGY=round_robin @@ -189,7 +189,7 @@ OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080 3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。 4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。 -## 电脑 Agent API 使用 +## 电脑端 Agent API 使用 先做只读契约检查,不触发真实生图: @@ -197,26 +197,26 @@ OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080 GPT_IMAGE_PLAYGROUND_URL=https://-.hf.space \ GPT_IMAGE_AGENT_TOKEN= \ GPT_IMAGE_AGENT_CONTRACT_CHECK=1 \ -node skills/gpt-image-playground-agent/scripts/generate-image.mjs +node skills/visual-journal-image-agent/scripts/generate-image.mjs ``` -真实文生图: +真实文生图请求: ```bash GPT_IMAGE_PLAYGROUND_URL=https://-.hf.space \ GPT_IMAGE_AGENT_TOKEN= \ -node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ +node skills/visual-journal-image-agent/scripts/generate-image.mjs \ --allow-billable \ - "a product photo of a ceramic mug on a wooden table" + "木桌上的陶瓷杯产品照片" ``` 脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。 -远端 Agent 调用不要硬编码路径。普通文生图默认提交业务意图到 capabilities 声明的 `orchestration.endpoint`,由服务端选择内部执行路径、上游 request mode 和轮询方式;Agent 客户端不按尺寸、远端 HTTPS 或流式参数自行选择 Images、Responses、SSE 或非流式路径。显式 page_sse 诊断、默认 WebP edit、高分辨率 edit 和复杂批量仍按 Skill 规则使用页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择 Agent JSON、Agent edit 或 job 路径对照。job polling 只在显式选择时使用。需要诊断对照时可用 `--agent` 或 `--streaming-strategy off` 强制 Agent JSON,也可用 `--page-sse` 或 `--job` 显式选择路径。 +远端 Agent 调用不要硬编码路径。普通文生图默认提交业务意图到能力声明的 `orchestration.endpoint`,由服务端选择内部执行路径、上游请求方式和轮询方式;Agent 客户端不按尺寸、远端 HTTPS 或流式参数自行选择 Images、Responses、SSE 或非流式路径。显式 page_sse 诊断、默认 WebP 编辑、高分辨率编辑和复杂批量仍按 Skill 规则使用页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择 Agent JSON、Agent 编辑或任务路径对照。任务轮询只在显式选择时使用。需要诊断对照时可用 `--agent` 或 `--streaming-strategy off` 强制 Agent JSON,也可用 `--page-sse` 或 `--job` 显式选择路径。 如果 Space 同时配置了 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,Agent JSON 端点用 `GPT_IMAGE_AGENT_TOKEN` 发送 Bearer token;页面端 `/api/images` SSE 仍按 capabilities 的 `agent_streaming.page_sse.auth` 判断,可能需要额外设置 `GPT_IMAGE_APP_PASSWORD_HASH`,并通过 form-data `passwordHash` 发送页面访问码哈希。页面 SSE 会把业务 key 写入 `clientRequestId`,长度上限以 capabilities 中的 `agent_streaming.page_sse.client_request_id.max_length` 为准。 -## 本地 HF 近似 smoke +## 本地 HF 近似冒烟验证 提交前运行: @@ -236,10 +236,10 @@ npm run smoke:hf-space-local ## 平台与运行限制 - Docker Space 的创建、更新和可用硬件受 Hugging Face 当前账户政策约束。`hf upload` 对已存在 Space 的创建接口检查收到 `402` 时,脚本会尝试认证 Git 推送;新建 Docker Space 仍需要满足平台账户要求。 -- CPU Basic 适合公开演示和轻量使用,不适合长期高并发;长时间无访问后可能休眠。需要真正永不休眠或自定义 sleep time 时,应使用满足平台要求的付费硬件。 -- Docker Space 重启后容器磁盘写入会丢失。`memory` 状态后端的 Agent 幂等记录、replay 状态和分享元数据也会丢失。 +- CPU Basic 适合公开演示和轻量使用,不适合长期高并发;长时间无访问后可能休眠。需要真正永不休眠或自定义休眠时间时,应使用满足平台要求的付费硬件。 +- Docker Space 重启后容器磁盘写入会丢失。`memory` 状态后端的 Agent 幂等记录、回放状态和分享元数据也会丢失。 - Agent API 仍会把产物图片写入容器临时文件系统,以便提供 `content_url` 下载。重启后这些链接不保证继续有效。 -- 需要长期保存图片、分享链接或 Agent replay 状态时,不应使用纯内存模式。应切换到 PostgreSQL 加持久卷或外部对象存储。 +- 需要长期保存图片、分享链接或 Agent 回放状态时,不应使用纯内存模式。应切换到 PostgreSQL 加持久卷或外部对象存储。 ## 公网客户门槛 @@ -247,16 +247,16 @@ npm run smoke:hf-space-local - 先执行 `npm run deploy:space`,确保当前干净 git HEAD 已上传到固定 Space。 - 再用真实浏览器打开 Space,确认页面能进入并完成一次真实的浏览器检查。 -- 仅有 `npm run doctor:hf-space` 的远端可达与 secret 检查,不足以证明客户可见上线。 +- 仅有 `npm run doctor:hf-space` 的远端可达与密钥检查,不足以证明客户可见上线。 - `APP_PASSWORD` 已设置,网页不会裸露给匿名访问者。 - `AGENT_API_TOKEN` 已设置,自动化调用不会回退到页面访问码哈希。 - `npm run doctor:hf-space` 的 `remote-secrets` 检查通过。 - 共享链接明确保留访问码和有效期的默认控制,不把无访问码永久链接当成默认发布形态。 - Space 重启丢失分享元数据和 Agent replay 的前提已被客户知晓。 -## Space Keepalive +## Space 保活 -本仓库提供 GitHub Actions 定时 keepalive,降低 CPU Basic 因长时间无访问进入休眠的概率: +本仓库提供 GitHub Actions 定时保活,降低 CPU Basic 因长时间无访问进入休眠的概率: - 工作流文件:`.github/workflows/hf-space-keepalive.yml` - 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch` @@ -281,11 +281,11 @@ HF_SPACE_KEEPALIVE_RETRY_MAX_DELAY_MS=20000 \ npm run keepalive:hf-space ``` -注意:keepalive 是 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应使用满足平台要求的硬件并设置永不休眠。 +注意:保活是尽力而为机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应使用满足平台要求的硬件并设置永不休眠。 ## 验证门禁 -GitHub Actions 的 `.github/workflows/ci.yml` 会在 Pull Request、`main` 分支推送和手动触发时先核对锁文件安装脚本与 `allowScripts` 白名单、npm 的严格安装脚本能力,再以严格白名单模式安装依赖并核对直接依赖完整性,随后执行版本元数据检查、完整依赖审计、测试、源码 lint、脚本语法检查、生产构建、工作流 lint、Dockerfile 与基础 Compose 加 memory/PostgreSQL 覆盖配置检查。它还会构建和启动生产镜像后验证 `/api/auth-status`,并在独立 job 中运行真实 PostgreSQL 状态契约。 +GitHub Actions 的 `.github/workflows/ci.yml` 会在 Pull Request、`main` 分支推送和手动触发时先核对锁文件安装脚本与 `allowScripts` 白名单、npm 的严格安装脚本能力,再以严格白名单模式安装依赖并核对直接依赖完整性,随后执行版本元数据检查、完整依赖审计、测试、源码静态检查、脚本语法检查、生产构建、工作流静态检查、Dockerfile 与基础 Compose 加 memory/PostgreSQL 覆盖配置检查。它还会构建和启动生产镜像后验证 `/api/auth-status`,并在独立任务中运行真实 PostgreSQL 状态契约。 最小验证: @@ -302,11 +302,11 @@ npm run smoke:hf-space-local git diff --check ``` -真实 Hugging Face gate: +真实 Hugging Face 门禁: -1. 提交代码后执行 `npm run deploy:space`,等待 Space 新 commit 进入 `RUNNING`。 +1. 提交代码后执行 `npm run deploy:space`,等待 Space 新提交进入 `RUNNING`。 2. 用真实浏览器打开 Space,确认页面可进入并至少完成一次页面检查。 3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。 4. 如有可用测试额度,再执行一次真实 Agent 生成。 -5. 重启 Space 后确认旧 Agent replay 和旧临时产物丢失符合预期。 +5. 重启 Space 后确认旧 Agent 回放状态和旧临时产物丢失符合预期。 6. 如果未执行第 1 步和第 2 步,必须在门禁报告里明确标注残余外部门禁未验证。 diff --git a/docs/product/image-provider-manifest.md b/docs/product/image-provider-manifest.md index 554baff2466bc097c1264de10f02bc4701c101ea..79e0b4e8b46b4d084875bd7913e1daaeed5dbf57 100644 --- a/docs/product/image-provider-manifest.md +++ b/docs/product/image-provider-manifest.md @@ -1,24 +1,23 @@ -# Image Provider Manifest +# 图片上游清单 -Provider manifest is a server-side configuration contract for image upstream diagnostics and capability constraints. -It is not a browser-side plugin system and it does not execute arbitrary request templates. +图片上游清单是服务端用于上游诊断和能力约束的配置契约,不是浏览器插件系统,也不会执行任意请求模板。 -## Scope +## 适用范围 -- The server reads `OPENAI_CHANNEL_N_PROVIDER_MANIFEST` for a numbered channel. -- The manifest is validated during channel config parsing. -- The manifest can narrow request constraints such as `n`, `partial_images`, upload limits, and `gpt-image-2` size policy. -- `/api/runtime-capabilities` exposes only a sanitized summary: provider id, mode type, request content type, response format, and whether async polling is declared. -- API keys and extra headers are never included in runtime capability responses. -- Upload constraints are capped globally: `max_images` cannot exceed 10, `max_single_bytes` cannot exceed 25 MiB, and `max_total_bytes` cannot exceed 100 MiB. +- 服务端按编号渠道读取 `OPENAI_CHANNEL_N_PROVIDER_MANIFEST`。 +- 解析渠道配置时会校验清单。 +- 清单可以收窄 `n`、`partial_images`、上传限制和 `gpt-image-2` 尺寸策略等请求约束。 +- `/api/runtime-capabilities` 只暴露脱敏摘要:上游标识、方式类型、请求内容类型、响应格式和是否声明异步轮询。 +- 运行时能力响应绝不包含 API 密钥或额外请求头。 +- 上传限制受全局上限约束:`max_images` 不超过 10,`max_single_bytes` 不超过 25 MiB,`max_total_bytes` 不超过 100 MiB。 -## Minimal Example +## 最小示例 ```json { "schema_version": 1, "id": "custom_async", - "name": "Custom Async Provider", + "name": "自定义异步上游", "base_profile": "openai-compatible", "modes": { "generate": { @@ -59,21 +58,20 @@ It is not a browser-side plugin system and it does not execute arbitrary request } ``` -## Failure Fixtures To Keep Covered +## 必须覆盖的失败场景 -- Invalid JSON fails during config parsing. -- Unsupported `schema_version` fails explicitly. -- Manifest `id` must be stable and lower-case. -- Submit path must be a relative API path beginning with `/`. -- Submit method only supports `POST`. -- Submit content type only supports `application/json` or `multipart/form-data`. -- Poll method only supports `GET` or `POST`. -- `base_profile` must match the channel upstream profile. -- Range constraints reject `min > max`. -- Upload constraints reject values above the global caps. -- Runtime capabilities must not include API keys, app secrets, or raw extra headers. +- 非法 JSON 必须在配置解析时失败。 +- 不支持的 `schema_version` 必须显式失败。 +- 清单 `id` 必须稳定且全为小写。 +- 提交路径必须是以 `/` 开头的相对 API 路径。 +- 提交方法只支持 `POST`。 +- 提交内容类型只支持 `application/json` 或 `multipart/form-data`。 +- 轮询方法只支持 `GET` 或 `POST`。 +- `base_profile` 必须与渠道上游配置匹配。 +- 范围约束必须拒绝 `min > max`。 +- 上传约束必须拒绝超过全局上限的值。 +- 运行时能力不得包含 API 密钥、应用密钥或原始额外请求头。 -## Current Boundary +## 当前边界 -The app still sends image requests through the existing OpenAI-compatible Images API and Responses backend paths. -Manifest support currently provides validated constraints and diagnostics so new upstream modes can be introduced without silent fallback or hidden browser-side behavior. +应用仍通过现有 OpenAI 兼容 Images API 和 Responses 后端路径发送图片请求。清单目前提供经过校验的约束和诊断,使新上游方式能够在没有静默降级或隐藏浏览器行为的前提下接入。 diff --git a/docs/product/product-contract.md b/docs/product/product-contract.md index 6fac738e78d1bac22cea924996195ad0c0289312..625043f3693afd10f268e5057b6ef25829a67841 100644 --- a/docs/product/product-contract.md +++ b/docs/product/product-contract.md @@ -1,25 +1,25 @@ -# 图像手记 / Visual Journal 产品合同 +# 图像手记产品合同 -正式产品名称:中文名为“图像手记”,英文名为“Visual Journal”。 +产品中文名称为“图像手记”,英文品牌名为 Visual Journal。 ## 阶段结论 -第一阶段主线是给中文小红书和电商内容运营者使用的本地或内网 AI 图片创作工作台,不是通用图片平台、公开 SaaS、自治 Agent 或兼容接口基准测试平台。 +第一阶段主线是供中文小红书和电商内容运营者使用的本地或内网 AI 图片创作工作台,不是通用图片平台、公开软件即服务平台、自治智能体或兼容接口基准测试平台。 ## 第一真实用户 -第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常一个人或在 2 到 5 人小团队内工作,有自己的 API Key 或由团队配置服务端 Key,需要在本机、内网或受控公网环境中完成提示词生成、结果挑选、继续编辑、下载和安全分享。 +第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常独自工作或身处 2 到 5 人小团队,拥有自己的 API 密钥或使用团队配置的服务端密钥,需要在本机、内网或受控公网环境中完成提示词生成、结果挑选、继续编辑、下载和安全分享。 ## 首战场景 -首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开 SaaS、模型兼容探测和长期 Agent 调度。 +首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开软件即服务能力、模型兼容探测和长期智能体调度。 ## 非目标用户 - 需要多人协作素材库、权限分层、审批流或结算系统的企业团队。 -- 需要生产级公网 SaaS 可用性、对象存储和审计合规的客户。 -- 需要自治 Agent 长期调度、跨实例队列和任务审计的自动化系统。 -- 只想验证任意 OpenAI-compatible 接口全部能力的工程测试人员。 +- 需要生产级公网软件即服务可用性、对象存储和审计合规的客户。 +- 需要自治智能体长期调度、跨实例队列和任务审计的自动化系统。 +- 只想验证任意 OpenAI 兼容接口全部能力的工程测试人员。 ## 核心闭环 @@ -42,7 +42,7 @@ ## 上线边界 -第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API Key 时必须配置页面访问码,Agent API 对外开放时必须配置 Agent token。 +第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API 密钥时必须配置页面访问码,智能体接口对外开放时必须配置智能体令牌。 ## 证据标准 diff --git a/docs/product/user-validation-script.md b/docs/product/user-validation-script.md index c5d83c94e11362594d5674ecc6a76178b3e7178e..81eaeb06a284cb230c5cd8117b4bd48598369d5d 100644 --- a/docs/product/user-validation-script.md +++ b/docs/product/user-validation-script.md @@ -20,7 +20,9 @@ | 用户 | 第 3 分钟是否出图 | 是否下载 | 是否继续编辑或变体 | 是否复用历史或灵感 | 是否标记可用或需修改 | 是否理解费用和失败原因 | 迁移承诺 | 授权承诺 | 付费承诺 | 引荐承诺 | 卡点 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -## The Mom Test 问题 +## 用户访谈问题 + +以下问题聚焦用户过去的真实行为、成本和具体承诺,避免只收集对产品的礼貌性评价。 - 你上次需要这类图片是什么时候。 - 当时你怎么处理。 diff --git a/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md b/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md deleted file mode 100644 index 30b62fd055adba936778f5053dc667232b042ab8..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +++ /dev/null @@ -1,34 +0,0 @@ -# CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22 - -## 范围 - -- Agent 图片端点路由契约、错误诊断与终态失败语义。 -- 本地图片上游 fixture 与 `smoke:image-upstream-real` final gate 复用路径。 -- 本记录只证明本地 fixture、脚本门禁与当前代码契约;不证明第三方真实部署当前可访问。 - -## 审计结论 - -- `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。 -- 当前路由口径已更新:高分辨率 edit 默认优先走页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再显式回退到 Agent edit。 -- partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。 -- 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。 -- 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。 -- 本地 final gate 会跳过 `.env.local` 加载,并清理 `IMAGE_REAL_SMOKE_*`、`OPENAI_*`、`APP_PASSWORD`、`AGENT_API_TOKEN` 等外层输入,避免 shell 凭据污染本地 fixture 验证。 - -## 验证记录 - -| 命令 | 退出码 | 摘要 | -| --- | --- | --- | -| `npm test` | 0 | 462 个测试通过;PostgreSQL live 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 | -| `npm run lint` | 0 | `eslint src` 通过。 | -| `npm run lint:scripts` | 0 | 脚本语法检查通过。 | -| `npm run build` | 0 | Next.js production build 通过,standalone runtime patch 完成。 | -| `node scripts/smoke-image-upstream-local-final-gate.mjs --timeout-ms 30000` | 0 | 5 个独立本地 fixture 场景全部通过,`final_gate_satisfied=true`。 | -| `git diff --check` | 0 | 当前 diff 无 whitespace error。 | -| 装饰符扫描 | 0 | 代码与 Markdown 改动中未发现 AGENTS.md 禁止的装饰性 Unicode 符号。 | -| `coderabbit review --prompt-only -t uncommitted` | 0 | CodeRabbit 返回 `findings=0`。 | - -## 剩余边界 - -- 本轮未运行真实第三方上游 `--allow-billable` 门禁;独立真实上游仍需要 `.env.real-smoke.local` 提供 5 类真实目标后再跑 `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable`。 -- 本轮未运行 live PostgreSQL gate;数据库真实行为仍以 `npm run test:postgres` 或 `npm run verify -- --postgres` 为准。 diff --git a/docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md b/docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md deleted file mode 100644 index 96faf3274dace2438fb48d48ba11ad0fa3a357fd..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md +++ /dev/null @@ -1,38 +0,0 @@ -# 部署加固回归门禁 - -日期: 2026-07-27 - -范围: Docker 本地部署、Hugging Face Space memory smoke、PostgreSQL overlay、CI 运行时检查、部署脚本和部署文档。 - -## 已审查变更 - -- 默认 Compose 发布限制为 `127.0.0.1:4783`。 -- 非回环 Compose 发布必须设置非空 `APP_PASSWORD`。 -- Docker 镜像提供 OCI revision label 和 healthcheck。 -- 本地部署校验干净的 Git revision、镜像身份、发布端口和选定的状态/存储模式。 -- PostgreSQL overlay 在使用 Docker secret 文件前清空直连数据库凭证变量。 -- HF Space 和本地端点轮询不会在最后一次失败后继续等待。 -- CI 校验实际 Docker 入口点的回环分支、健康状态、端点响应和镜像 revision。 - -## 自动化证据 - -| 命令 | 退出码 | 结果 | -| --- | --- | --- | -| `npm run verify` | 0 | 版本、安装策略、依赖、测试、lint、脚本语法、生产构建和 diff 检查均通过。 | -| `npm run test:postgres` | 0 | 101 个测试通过,包含真实 PostgreSQL 并发和 schema 契约。 | -| `npm run smoke:hf-space-local` | 0 | 最新 Docker 镜像通过 memory/indexeddb 运行态和非计费 Agent 契约检查。 | -| `docker build --check .` | 0 | 无 Dockerfile 警告。 | -| `docker compose ... config --quiet` | 0 | SQLite、memory 和 PostgreSQL Compose 配置均成功渲染。 | -| CI 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义通过 actionlint。 | -| 非回环 Docker 入口点检查 | 预期退出码 1 | 容器拒绝 `GIP_COMPOSE_DEPLOYMENT= TRUE `、`GIP_BIND_HOST=0.0.0.0` 且未设置 `APP_PASSWORD` 的启动。 | - -## 审查证据 - -- CodeRabbit 审查全部已修改和未跟踪文件后未发现问题。 -- Claude Code 使用默认模型且未传 `--model`,报告未发现 P0-P3 问题。 -- OMP 仅识别出本地部署和 CI 的最后一次轮询延迟。两条路径均已改为仅在仍有下一次尝试时等待,并为本地探针补充回归覆盖。 - -## 范围边界 - -- 本门禁不执行计费的图片生成或编辑请求。 -- 真实本地 Docker 和 Hugging Face Space 发布检查属于独立部署验证步骤,因为它们需要干净的已提交 revision 和实时服务状态。 diff --git a/docs/reviews/CR-IMAGE-STREAM-BACKENDS-2026-05-19.md b/docs/reviews/CR-IMAGE-STREAM-BACKENDS-2026-05-19.md deleted file mode 100644 index 7f93913e5bd21d1cabaae75200865565147ddaf1..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-IMAGE-STREAM-BACKENDS-2026-05-19.md +++ /dev/null @@ -1,44 +0,0 @@ -# CR-IMAGE-STREAM-BACKENDS-2026-05-19 - -## 范围 - -- 固化 Images API 上游流式事件适配层。 -- 抽取 `/api/images` 服务端流式处理公共逻辑。 -- 补充路由级流式契约测试。 -- 增加 provider dialect 诊断,不记录 API key 或原始 base64。 -- 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端。 -- 收敛 `route.ts` 复杂度,保持单文件低于 300 行。 - -## 已验证行为 - -- 官方 OpenAI Images 流式事件可归一化为前端稳定 SSE 事件。 -- OtokAPI `image.generation.chunk` / `image.generation.result` 可归一化为前端稳定 SSE 事件。 -- SDK 丢失 `event:` 名称时,仍能识别带图片数据的 fallback payload。 -- 多图 result、无 partial、缺最终图、上游流中断均有路由级契约测试覆盖。 -- 未知 completed-like payload 不伪造成功;无最终图会显式返回 `error` SSE。 -- Responses API 后端默认关闭;只有开启开关、显式传 `imageBackend=responses` 并配置独立 `/responses` 顶层模型才调用 `/responses`。 -- Responses API 实验后端只读取 `image_generation_call.result`,缺结果或失败状态会显式报错。 - -## GPT2Image 评估结论 - -- 可借鉴:对话历史、多变体展示、瀑布并发、参考图上下文。 -- 暂不照搬:浏览器保存 API key、纯前端直连上游、把 Responses API 直接替换现有 Images API 主链路。 -- 后续若要做对话式生图,应新增独立页面或模式,不混入现有生成、编辑、Agent API 与批量流式路径。 - -## 验证记录 - -- `npm test`:通过,256 pass;默认测试中的 Postgres live 用例因未设置 `AGENT_POSTGRES_TEST_DATABASE_URL` 跳过。 -- `npm run test:postgres`:通过,31 pass;脚本拉起真实 PostgreSQL 容器并执行 live gate。 -- `npm run lint`:通过。 -- `npm run build`:通过。 -- `git diff --check`:通过。 -- `docker compose up -d --build`:通过,镜像 `gpt-image-playground-customer:local` 用最新代码重建并启动。 -- Docker HTTP smoke:`/`、`/api/runtime-capabilities`、`/api/agent/capabilities`、`/api/auth-status` 均返回 200;`/api/logs` 在未配置 `APP_PASSWORD` 时返回 403,符合预期。 -- Docker 内 OpenAI 兼容假上游 smoke:非流式 `/api/images` 返回 1 张图片;流式 `/api/images` 返回 `partial_image -> completed -> done`。 -- 浏览器 smoke:Chrome 打开 `http://127.0.0.1:4783/` 成功,首页可访问。 -- 真实上游探针:当前 `.env.local` 渠道 `/v1/models` 和 `/api/images` 均返回 429 `DAILY_LIMIT_EXCEEDED`,确认真实成功出图受上游日限额阻塞。 - -## 未覆盖 - -- 未完成真实 OpenAI 或 OtokAPI 成功出图;当前上游返回 429 `DAILY_LIMIT_EXCEEDED`,需要可用额度后复验。 -- 未实现对话式生图、多轮编辑、多变体产品界面。 diff --git a/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md b/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md deleted file mode 100644 index 8747caea6f39db44c63389b23bfd09e7eff7eba4..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md +++ /dev/null @@ -1,189 +0,0 @@ -# CR-IMAGE-UPSTREAM-COMPAT-2026-05-21 - -## 范围 - -- 多上游图片生成兼容层阶段验证。 -- 区分本地 mock 覆盖、当前真实上游 smoke、以及本机未配置的上游类型。 -- 记录 2026-05-21 在 `codex/image-upstream-compat` 分支上的验证口径。 - -## 当前真实上游范围 - -`.env.local` 当前只配置了 `superapi.buzz` 这一类真实上游,共 7 个服务端渠道。没有可直接区分的原版 QuantumNous/new-api、gaoren002/new-api、Wei-Shaw/sub2api 或独立 GPT2Image 部署地址。因此“原版 new-api / gaoren / sub2api / GPT2Image”差异主要由本地契约测试覆盖,真实 smoke 只能证明当前 `superapi.buzz` 上游在对应协议路径下的表现。 - -当前独立真实上游配置检查:`.env.local` 没有 `IMAGE_REAL_SMOKE_*` 键,`.env.real-smoke.local` 不存在。仓库提供 `.env.real-smoke.example` 作为可提交模板,真实凭据仍应写入未跟踪的 `.env.real-smoke.local`。`npm run smoke:image-upstream-real` 当前返回 `independent_targets.configured_count=0`、`missing_count=5`。`npm run smoke:image-upstream-real -- --require-independent-targets` 当前以退出码 `1` 按预期失败,`skipped_required_cases` 为 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 - -## 真实 smoke 结果 - -运行方式:在当前 worktree 启动 `npx next dev --turbopack -p 4784`;Responses 路径使用 `ENABLE_RESPONSES_IMAGE_BACKEND=true OPENAI_RESPONSES_API_MODEL=gpt-5.4` 重启同端口服务。所有输出均只记录状态、事件类型、图片数量和 base64 长度,不记录 API key 或完整图片数据。 - -| 场景 | 结果 | 证据摘要 | -| --- | --- | --- | -| Images API 普通 JSON | 通过 | `/api/images` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1045668`,耗时 `19921ms`。 | -| Images API SSE / keepalive 策略 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,耗时 `29615ms`。 | -| Responses image_generation 非流式 | 当前真实上游未通过 | `/api/images` 返回 `500 application/json`,错误为 `Responses API 未返回已完成的 image_generation_call.result。`。该结果说明当前真实上游的非流式 `/responses` 返回结构不满足本项目 final image 契约。 | -| Responses image_generation SSE 初测 | 暴露缺口后已修复 | 初测事件为 `partial_image, completed, error`,错误为 `response.output_item.done` 缺少图片 payload。修复后无图的 Responses output item done 会被忽略,仍要求整条流最终有 final image。 | -| Responses image_generation SSE 去重复测 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, completed, done`,`done_image_count=1`,耗时 `46624ms`。 | -| Agent 内部 Images SSE | 通过 | `/api/agent/images/generate` 返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未返回客户端 SSE,耗时 `96081ms`。 | - -## 2026-05-22 脚本化真实复验 - -运行方式:`npm run smoke:image-upstream-real -- --include-server-channel` 先做 dry-run,确认 5 个独立真实目标因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前 `.env.local` 服务端渠道只在追加 `--allow-billable` 后执行。随后使用 `--timeout-ms 180000` 对当前服务端渠道逐项真实调用。输出只记录 host、状态、事件类型、数量和 base64 长度。 - -| 场景 | 结果 | 证据摘要 | -| --- | --- | --- | -| 当前服务端渠道 Images JSON 首次 | 显式失败 | `server-channel-images-json` 返回 `500 application/json`,`error=Connection error.`,耗时 `186537ms`,无图片。 | -| 当前服务端渠道 Images JSON 复测 | 通过 | `server-channel-images-json` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1332092`,耗时 `164893ms`。 | -| 当前服务端渠道 Images SSE | 通过 | `server-channel-images-sse` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, partial_image, completed, done`,`done_image_count=1`,`first_b64_length=1573772`,耗时 `10843ms`。 | -| 当前服务端渠道 Responses SSE | 通过 | `server-channel-responses-sse` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,`first_b64_length=1157276`,耗时 `3232ms`。 | -| 当前服务端渠道 Agent 内部 Images SSE | 通过 | `server-channel-agent-images-sse` 通过 `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-images-sse` 验证,返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未内联 base64,耗时 `59243ms`。 | -| 当前服务端渠道 Agent 内部 Responses SSE | 通过 | `server-channel-agent-responses-sse` 通过 `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-responses-sse` 验证,返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未内联 base64,耗时 `19270ms`。 | - -## 本地契约覆盖 - -- `npm run smoke:image-upstream-compat` 启动临时本地 mock 上游并直接调用当前 `/api/images` route,覆盖原版 new-api Images API JSON、sub2api Images API JSON、gaoren new-api Images SSE keepalive、gaoren JSON-as-SSE completed、sub2api Images SSE、sub2api Responses image_generation bridge JSON、GPT2Image Responses image_generation SSE。 -- `npm run smoke:image-upstream-real` 提供真实上游 smoke 入口,默认只检查 `IMAGE_REAL_SMOKE_*` 配置,不加 `-- --allow-billable` 不会触发生图。当前运行结果为 5 个独立真实目标均跳过,原因是未配置独立真实上游 `BASE_URL` 环境变量。脚本已支持 `--include-server-channel`,可复用当前 `.env.local` 的服务端渠道跑 Images JSON、Images SSE、Responses JSON、Responses SSE、Agent 内部 Images SSE 和 Agent 内部 Responses SSE smoke,且不把服务端 API Key 写入表单或输出;单场景默认超时 `240000ms`。dry-run 会返回 `independent_targets` 和 `missing_env_any`,汇总必跑、已选、未选、已配置与缺失的独立目标,并用 `required_count`、`unselected_required_count`、`configuration_complete` 和顶层 `final_gate_satisfied` 明确 5 个必跑场景是否全部已实际执行并通过;非计费阶段会拒绝带凭据、查询参数或片段的 `BASE_URL`;最终验收可加 `--require-independent-targets --allow-billable`,让任何独立真实上游未被选中或被跳过都以非零退出,并写入 `unselected_required_cases`、`skipped_required_cases`、`missing_required_count` 和 `missing_required_cases`。脚本支持 `--env-file ` 加载独立真实 smoke 凭据文件,shell 环境变量优先级高于 `--env-file`,`--env-file` 优先级高于 `.env.local`,便于把原版 new-api、gaoren、sub2api、GPT2Image 的真实目标与主服务渠道配置隔离。`.env.real-smoke.example` 是可提交模板,`.env.real-smoke.local` 继续被 `.gitignore` 排除。`scripts/smoke-image-upstream-real.test.mjs` 覆盖默认非计费、配置后仍需显式授权、当前服务端渠道 dry-run 不泄漏 API key、独立上游准备度摘要、独立上游必跑门禁、缺失 env 诊断、unsafe `BASE_URL` 预检、超时参数校验、未知场景显式失败、显式 env 文件加载优先级、测试隔离 `.env.local` 私有渠道配置,以及本地 billable Agent smoke 后清理 `generated-images/.real-smoke` 新增图片产物。 -- `src/lib/image-stream-events.test.ts` 覆盖 OpenAI Images、OtokAPI、Responses partial、Responses partial `b64_json` 兼容字段、Responses image_generation_call completed marker、顶层 completed result、Responses output item done、Responses completed、远程 URL-only 显式失败、`response.failed` 显式失败、`response.completed` 内 `image_generation_call.status=failed` 显式失败、`image_generation_call.status=failed` 显式失败、keepalive/非对象忽略。 -- `src/lib/responses-image-backend.test.ts` 覆盖 Responses image_generation 非流式后端:读取 `image_generation_call.result`、接受省略 `status` 但提供 `result` 的兼容响应、拒绝远程 URL-only 结果、显式暴露 failed `image_generation_call` 错误、提取 data URL base64、以及流式请求参数。 -- `src/app/api/images/route.test.ts` 覆盖 Images API 非流式 JSON、Images API SSE、Images API 与 Responses image_generation 下 `force-sse` 在请求省略旧 `stream` 字段时仍进入上游 SSE、gaoren JSON-as-SSE completed 包装、SDK/relay 包装 SSE、stream 断开错误、Images API 非流式远程 URL-only 显式 502 失败、Responses 后端非流式、Responses 后端 SSE、以及 Responses failed `image_generation_call` 在 JSON/SSE 两条页面路径上都返回稳定 502 错误契约。 -- `src/app/api/agent/agent-routes.test.ts` 覆盖 Agent 默认最终 JSON、`streaming_strategy=off` 不发送上游流式参数、Images API 与 Responses image_generation 下 `streaming_strategy=force-sse` 发送上游 stream 但对外仍返回最终 JSON、直接 generate 内部 Images SSE 消费、直接 generate 内部 Responses image_generation SSE 消费、Responses failed `image_generation_call` 归一化为 `upstream_unavailable`、job polling 内部 Images SSE 消费并保存最终 artifact、job polling 内部 Responses image_generation SSE 消费并保存最终 artifact、直接 generate 与 job polling 的 Images API / Responses image_generation partial-only 无 final image 失败。 -- `src/lib/agent-api-contracts.test.ts` 覆盖 capabilities/OpenAPI 中页面 SSE、Agent 内部 upstream SSE、最终响应契约、后端枚举、流式策略枚举、真正启用上游 SSE 的 activation 策略和默认非流式 Agent 策略。 -- `src/lib/image-stream-service.test.ts` 和 `src/lib/image-stream-collector.test.ts` 覆盖 Responses 流中同一 final image 跨事件重复到达、以及单个完成事件同时经 SDK/Responses 包装层重复抽取时只保存一份最终产物;同一事件内合法多图结果仍保留多张图片。 -- 浏览器 UI smoke:`npm run dev -- --port 4785` 启动页面后确认默认未勾选流式预览;高级参数展示 Images API / Responses image_generation 后端与 6 个流式策略;4K/high + auto 显示流式建议;Responses 后端显示“Responses 顶层模型”;注入本地 fetch SSE keepalive stub 后,持续 keepalive 流只显示“连接保持中...”,不生成预览或成功结果。快速关闭且无 final image 的 keepalive 流会显式失败为“API 响应中没有有效图片数据或文件名。”,不伪造成功。2026-05-22 复验中,浏览器实际提交字段包含 `stream=true`、`partial_images=1`、`size=3072x2048`、`quality=high`、`image_backend=responses-image-generation`、`image_streaming_strategy=auto`;页面 DOM 中 `document.images` 为空,最终显示上述显式错误。 -- 运行态 Agent contract smoke:`npm run dev -- --port 4785` 启动后,`GET /api/agent/capabilities` 返回 `defaults.streaming_strategy=off`、`agent_streaming.generate.mode=non_streaming_only`、`agent_streaming.upstream_sse.mode=internal_upstream_sse`、`final_response_contract=AgentImageResponse`,并列出 `image_backend`、`streaming_strategy`、`partial_images` 三个内部上游 SSE 请求字段;`GET /api/agent/openapi.json` 的 `GenerateRequest` schema 同样包含这三个字段,`AgentStreamingCapabilities.upstream_sse.request_fields` 与 capabilities 一致;`GET /api/runtime-capabilities` 当前显示 `responsesImageBackend.enabled=false`、`mode=experimental`。 - -## 2026-05-22 当前 worktree 基线复验 - -本轮补充复核:`npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`npm run smoke:image-upstream-compat`、`git diff --check` 均重新通过。`npm run smoke:image-upstream-real -- --include-server-channel` 以非计费 dry-run 通过;`npm run smoke:image-upstream-real -- --require-independent-targets` 继续按预期以退出码 `1` 失败,原因是 `.env.real-smoke.local` 不存在且 5 个独立真实上游目标均缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL`。本轮收尾未追加新的 `--allow-billable` 请求;表中带 `--allow-billable` 的服务端渠道记录为同日此前已记录的真实服务端渠道证据,不等同于独立真实上游最终门禁。`generated-images/.real-smoke` 目录无产物残留。 - -| 命令 | 退出码 | 摘要 | -| --- | --- | --- | -| `npm test` | 0 | `425` 个测试通过,`0` 个失败。PostgreSQL live 测试因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 | -| `npm run lint` | 0 | `eslint src` 通过。 | -| `npm run lint:scripts` | 0 | `scripts/check-node-syntax.mjs` 通过。 | -| `npm run build` | 0 | Next.js 16.2.6 production build 通过,standalone runtime patch 完成。 | -| `npm run test:postgres` | 0 | 临时 `postgres:16-alpine` 容器内 55 个测试通过,覆盖 Agent route PostgreSQL 集成、Postgres schema/live concurrency、迁移、清理和 share metadata 契约。 | -| `npx tsc --noEmit` | 0 | 测试和源码 TypeScript 静态检查通过。 | -| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 | -| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 上游兼容场景全部通过。 | -| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 20 个脚本测试通过,覆盖 help 输出列出所有独立真实上游 env 前缀、`--env-file`、当前服务端 Responses JSON 和 Agent Responses SSE dry-run、非计费阶段拒绝 unsafe `BASE_URL`、独立上游准备度摘要、显式 env 文件加载优先级、测试隔离 `.env.local` 私有渠道配置、本地 billable Agent smoke 后清理 `generated-images/.real-smoke` 新增图片产物、独立上游必跑/已选/未选场景报告、`--require-independent-targets --case ...` 子集运行不能误报通过、只选择 server-channel 场景时最终门禁不能误报通过、5 个独立目标全部实际跑通后 `final_gate_satisfied=true`,以及本地 mock billable Responses JSON 走 `/v1/responses` 并返回图片。 | -| `npm run smoke:image-upstream-real -- --help` | 0 | help 输出列出 `--env-file `、`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。 | -| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;`independent_targets.configured_count=0`、`missing_count=5`;5 个独立真实上游目标因缺少 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过;当前服务端渠道因缺少 `--allow-billable` 跳过,未触发真实生图。 | -| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-responses-json` | 0 | 非计费 dry-run 通过;当前服务端 Responses JSON smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实生图。 | -| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-agent-responses-sse` | 0 | 非计费 dry-run 通过;当前服务端 Agent Responses SSE smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实生图。 | -| `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-responses-sse` | 0 | 同日此前记录的真实服务端渠道通过;返回 `200 application/json`,`image_count=1`,`first_content_url=/api/agent/artifacts/.../content`,`has_inline_base64=false`。 | -| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 预期失败;`independent_targets.configured_count=0`、`missing_count=5`;`skipped_required_cases` 为 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 | -| `npm run smoke:image-upstream-real -- --env-file .env.real-smoke.example --require-independent-targets` | 1 | 预期失败;可提交模板中的空值不会被误判为已配置真实上游,5 个独立真实目标仍全部列入 `skipped_required_cases`。 | -| `git diff --check` | 0 | 当前 diff 无 whitespace error。 | -| `find generated-images/.real-smoke ...` | 0 | 当前 `generated-images/.real-smoke` 目录无新增 `png`、`jpg`、`jpeg`、`webp` 产物残留。 | - -## 2026-05-22 运行态契约复验 - -运行方式:`npx next dev --turbopack -p 4785` 启动本地服务后,只读请求 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json`、`GET /api/runtime-capabilities`。 - -| 端点 | 结果 | 摘要 | -| --- | --- | --- | -| `/api/agent/capabilities` | 通过 | `defaults.image_backend=images-api`,`defaults.streaming_strategy=off`,`defaults.partial_images=2`;`agent_streaming.generate.mode=non_streaming_only`,`agent_streaming.edit.mode=non_streaming_only`,`agent_streaming.upstream_sse.mode=internal_upstream_sse`,`request_fields=image_backend,streaming_strategy,partial_images`,`activation_strategies=openai-sse,newapi-keepalive-sse,responses-sse,force-sse`,`final_response_contract=AgentImageResponse`;`agent_streaming.page_sse.endpoint=/api/images`,`contract=page_ui_only`;`agent_jobs.mode=job_polling`。 | -| `/api/agent/openapi.json` | 通过 | `GenerateRequest` 暴露 `image_backend`、`streaming_strategy`、`partial_images`;`image_backend.enum=images-api,responses-image-generation`;`streaming_strategy.enum=off,auto,openai-sse,newapi-keepalive-sse,responses-sse,force-sse`;`partial_images` 范围为 `1..3`;`AgentStreamingCapabilities.upstream_sse.final_response_contract` 只允许 `AgentImageResponse`。 | -| `/api/runtime-capabilities` | 通过 | `responsesImageBackend.enabled=false`,`mode=experimental`;运行态流式批量能力未默认开启,当前服务端渠道健康容量为 `healthyCredentialCount=7`、`healthyChannelCount=7`。 | - -## 2026-05-22 推送后补充复验 - -当前 HEAD 为 `6fa48f1 Fix agent route test error code typing`,已推送到 `origin/codex/image-upstream-compat`。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`。本轮只修改 `src/app/api/agent/agent-routes.test.ts` 的测试桩类型,把持久化失败用例中的错误码收敛为 `AgentErrorCode`,不改变业务运行代码。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `npm run verify -- --postgres` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`npm run test:postgres`、`git diff --check`、`git diff --cached --check` 全部通过。 | -| `NODE_ENV=test node --test --import tsx src/app/api/agent/agent-routes.test.ts` | 0 | 37 个 Agent route 测试通过;PostgreSQL 子套件因该定向命令未配置 `AGENT_POSTGRES_TEST_DATABASE_URL` 跳过,完整 Postgres gate 已由 `npm run verify -- --postgres` 覆盖。 | -| `npx tsc --noEmit` | 0 | 修复后源码和测试 TypeScript 静态检查通过。 | -| `npm run smoke:image-upstream-compat` | 0 | 原版 new-api Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE 七个本地 mock 兼容场景通过。 | -| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 | -| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍未配置,当前 `.env.local` 服务端渠道因缺少 `--allow-billable` 未触发生图。 | -| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;`final_gate_satisfied=false`、`missing_required_count=5`,缺少 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 | -| `npx next dev --turbopack -p 4786` + 浏览器复验 | 0 | `/api/agent/capabilities` 显示 Agent generate/edit 仍是 `non_streaming_only`,`upstream_sse.final_response_contract=AgentImageResponse`;高级参数显示 Images API / Responses image_generation 和 6 个流式策略;4K/high + auto 显示流式建议;keepalive-only SSE 期间 `document.images.length=0` 且只显示“连接保持中...”,关闭后显式报“API 响应中没有有效图片数据或文件名。”。 | -| `.env.real-smoke.local` / `.env.real-smoke.example` | 不适用 | `.env.real-smoke.local` 当前不存在;模板 `.env.real-smoke.example` 只包含空占位符和默认 `IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4`,不能满足最终真实门禁。 | - -## 2026-05-22 status readiness 补充 - -在 `be9e7cb Read real smoke env files in status` 基础上继续补充 `npm run status` 的 env 文件读取与 URL 安全校验。`status` 现在按 shell 环境变量、`.env.real-smoke.local`、`.env.local` 的优先级只读判断独立真实上游 smoke 配置是否齐全;输出只包含场景 ID、配置数量、缺失 env 键、非法 env 键与原因、最终门禁命令,不输出 URL 或 API Key。`scripts/command-center.test.mjs` 已覆盖 `.env.local` 与 `.env.real-smoke.local` 合并、shell env 优先、sub2api Responses 复用 sub2api 配置、unsafe `BASE_URL` 不泄露值,以及输出不包含 URL/key。 - -在 `40d87f9 Harden image upstream status readiness` 基础上继续补充 `npm run smoke:image-upstream-real` 的结构化 readiness 失败报告。真实 smoke 脚本现在遇到 unsafe 独立上游 `BASE_URL` 时不再只把错误写到 stderr,而是在 JSON 报告中输出 `invalid_env`、`invalid_cases`、`invalid_required_cases`,同样只包含 env 键与 reason,不输出 URL 或 API Key。若已显式开启 `--allow-billable` 但任一选中目标存在 unsafe `BASE_URL`,脚本会在 readiness 阶段用顶层 `blocked_cases` 阻断其它已配置目标;最终门禁模式额外输出 `blocked_required_cases`,避免配置非法时产生部分真实上游调用。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `node --test scripts/command-center.test.mjs` | 0 | 22 个脚本测试通过。 | -| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 22 个真实 smoke 脚本测试通过,新增覆盖 unsafe 独立上游 `BASE_URL` 的结构化 JSON 报告、非法配置时阻断其它 billable 上游调用,以及普通 billable smoke 顶层 `blocked_cases` 汇总。 | -| `npm run status` | 0 | `image_upstream_real_smoke.configuration_complete=false`、`configured_count=0`、`missing_count=5`,并列出 5 个独立真实上游目标缺失的 `BASE_URL` env。 | -| unsafe `BASE_URL` status 探针 | 0 | 临时注入含凭据、查询参数和片段的 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 后,`status` 只输出 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 与 `must_not_include_credentials`,不输出 URL、查询 token 或 API Key。 | -| unsafe `BASE_URL` real-smoke 探针 | 1 | 临时注入含凭据、查询参数和片段的 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 后,`smoke:image-upstream-real` 返回 JSON;`invalid_required_cases=original-images-json`。同时配置一个可 billable 的 gaoren 目标时,输出 `blocked_required_cases=gaoren-images-sse` 并在 1 秒内退出,证明未进入上游调用;不输出 URL、查询 token 或 API Key。 | -| `npm run verify -- --postgres` | 0 | `npm test`、lint、script lint、build、live PostgreSQL gate、diff checks 全部通过。 | -| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过。 | -| `npx tsc --noEmit` | 0 | TypeScript 静态检查通过。 | -| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;仍缺 5 个独立真实上游目标,`final_gate_satisfied=false`。 | - -## 2026-05-22 final gate preflight 补充 - -本轮基线基于 `fc55a26 Block incomplete real smoke final gate`,该提交已推送到 `origin/codex/image-upstream-compat`。本轮补齐 `--require-independent-targets --allow-billable` 的 readiness 安全边界:若最终独立真实上游门禁发现必跑场景未选全、缺少配置或配置非法,脚本会在加载 `/api/images` / `/api/agent/images/generate` route 之前阻断已配置的可运行目标,先输出结构化失败,不发任何真实上游计费请求。该边界避免“最终门禁必然失败但已产生部分真实计费请求”的状态。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `npm run smoke:image-upstream-compat` | 0 | 原版 new-api Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE 七个本地 mock 兼容场景通过。 | -| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 23 个真实 smoke 脚本测试通过。新增覆盖最终独立门禁只配置一个目标但缺少其它必跑目标时,已配置目标被列入 `blocked_required_cases`,本地 mock 上游 `calls.length=0`。 | -| `IMAGE_REAL_SMOKE_SKIP_DOTENV=1 npm run smoke:image-upstream-real -- --require-independent-targets --allow-billable` | 1 | 预期失败;`configuration_complete=false`、`configured_count=0`、`missing_count=5`、`missing_required_count=5`。命令虽然带 `--allow-billable`,但由于独立目标配置不完整,仅输出缺失项报告,不进入上游调用路径。 | - -## 2026-05-22 Responses readiness 模型口径补充 - -本轮继续修正真实 smoke readiness 对 Responses 顶层模型的判断:`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL` 是 sub2api Responses 场景的图片模型覆盖项,不能被当成 `/responses` 顶层模型。`npm run status` 与 `npm run smoke:image-upstream-real` 现在只接受 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL` 作为 sub2api Responses 顶层模型;GPT2Image 场景仍接受 `IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL`。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `node --test scripts/command-center.test.mjs` | 0 | 新增覆盖 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2` 不会让 status readiness 误判 sub2api Responses 场景配置完成。 | -| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 新增覆盖真实 smoke dry-run 同样不会把 sub2api Responses 图片模型变量当成 `/responses` 顶层模型。 | -| `npm run status` | 0 | 当前本轮工作树有未提交改动,`head=5c6e21b`;独立真实上游仍为 `configured_count=0`、`missing_count=5`,最终门禁命令仍指向 `--env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable`。 | - -## 2026-05-22 GPT2Image Responses SSE 去重补充 - -本轮兼容 mock smoke 暴露 GPT2Image 风格 Responses SSE 会先返回 `response.output_item.done`,再在 `response.completed.response.output[]` 中重复同一个无 id 的 `image_generation_call.result`。事件归一化现在在 Responses image call 缺少 `id` / `item_id` / `call_id` 时,使用最终 base64 payload 作为内部 dedupe key;如果上游提供不同 id,即使 payload 相同仍会保留为不同最终图片。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `node --test --import tsx src/lib/image-stream-collector.test.ts` | 0 | 新增覆盖 Responses 上游缺少 image call id 时,`response.output_item.done` 与 `response.completed` 的同 payload final image 只落一张。 | -| `npm run smoke:image-upstream-compat` | 0 | GPT2Image Responses SSE 场景恢复为 `partial_image, completed, done`,不再出现重复 `completed`。 | - -## 2026-05-22 fcdd698 基线复验 - -复验基线为 `fcdd698 Fix Responses upstream smoke readiness`,分支 `codex/image-upstream-compat` 已同步到 `origin/codex/image-upstream-compat`。本小节为后续追加的文档记录,不改变业务代码。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`,当前 GitGuardian Security Checks 通过。 - -| 命令或检查 | 退出码 | 摘要 | -| --- | --- | --- | -| `npm run status` | 0 | 当前仅有本审计文档改动;独立真实上游 readiness 为 `configured_count=0`、`missing_count=5`,缺少 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 | -| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过:original Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE。 | -| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前服务端渠道未触发真实生图。 | -| `IMAGE_REAL_SMOKE_SKIP_DOTENV=1 npm run smoke:image-upstream-real -- --require-independent-targets --allow-billable` | 1 | 预期失败;`configuration_complete=false`、`configured_count=0`、`missing_required_count=5`。配置不完整时先输出缺失报告,不进入上游调用路径。 | -| `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable` | 1 | 最终独立真实上游门禁仍失败;`.env.real-smoke.local` 当前不存在,5 个必跑目标全部列入 `skipped_required_cases`。 | -| `npm run verify` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`、`git diff --cached --check` 全部通过。 | -| `find generated-images/.real-smoke ...` | 0 | 当前没有 `png`、`jpg`、`jpeg`、`webp` 真实 smoke 产物残留。 | - -本轮没有追加 `--allow-billable` 的真实生图请求。最终完成判定仍缺独立真实上游凭据和地址,需要配置 `.env.real-smoke.local` 后跑通最终门禁命令。 - -## 完成度审计矩阵 - -| 要求 | 当前证据 | 状态 | -| --- | --- | --- | -| 默认配置不破坏 OpenAI Images API、原版 new-api 和 sub2api 普通 JSON 基线 | `src/app/api/images/route.test.ts`、`npm run smoke:image-upstream-compat` 的 original new-api Images API JSON 与 sub2api Images API JSON 场景、`server-channel-images-json` 真实复测 | 已覆盖 | -| 流式能力只通过配置、UI 开关、Agent 显式策略或探测确认启用 | `src/lib/image-upstream-strategy.ts`、`src/lib/image-upstream-strategy.test.ts`、浏览器 UI smoke 默认未勾选流式、Agent defaults 为 `streaming_strategy=off` | 已覆盖 | -| gaoren/new-api keepalive SSE 和 JSON-as-SSE 能归一化,keepalive 不产生假预览 | `npm run smoke:image-upstream-compat` 的 gaoren keepalive 与 JSON-as-SSE 场景、`src/lib/image-stream-events.test.ts` keepalive/非对象忽略、浏览器 keepalive stub | 已覆盖,真实独立 gaoren 地址缺失 | -| sub2api Images SSE 与 Responses bridge 能归一化 | `npm run smoke:image-upstream-compat` 的 sub2api Images SSE 和 sub2api Responses bridge JSON 场景、`src/lib/image-stream-events.test.ts` Responses partial/output/completed 覆盖 | 已覆盖,真实独立 sub2api 地址缺失 | -| GPT2Image 风格 `/v1/responses` + `image_generation` 工具流式结果可兼容 | `src/lib/responses-image-backend.test.ts`、`npm run smoke:image-upstream-compat` 的 GPT2Image Responses SSE 场景、`server-channel-responses-sse` 真实复验 | 已覆盖,真实独立 GPT2Image 地址缺失 | -| partial image 只能作为进度预览,最终必须等待 completed base64 | `src/lib/image-stream-service.ts`、`src/lib/image-stream-collector.ts`、`src/app/api/agent/agent-routes.test.ts` partial-only 失败用例 | 已覆盖 | -| 缺 final base64、远程 URL-only、上游断流、Responses failed image call 必须显式失败 | `src/lib/image-stream-events.test.ts`、`src/lib/responses-image-backend.test.ts`、`src/app/api/images/route.test.ts` 的非流式 Images URL-only 502、Responses JSON/SSE failed image call 用例、`src/app/api/agent/agent-routes.test.ts` 的 Agent upstream SSE failed image call 用例、`server-channel-images-json` 首次真实 `Connection error` 显式失败记录 | 已覆盖 | -| Agent API 对外保持最终 JSON,内部可消费上游 SSE 并保存 artifact | `src/app/api/agent/agent-routes.test.ts` 的直接 generate Images SSE、直接 generate Responses SSE、job polling Images SSE + artifact content、job polling Responses SSE + artifact content 用例,以及 `server-channel-agent-images-sse` 与 `server-channel-agent-responses-sse` 脚本化真实 smoke | 已覆盖 | -| capabilities/OpenAPI/skill 文档清楚区分页面 SSE、Agent 内部 upstream SSE 和最终响应契约 | `src/lib/agent-api-contracts.test.ts`、`src/lib/agent-openapi.ts`、`skills/gpt-image-playground-agent/SKILL.md`、`skills/gpt-image-playground-agent/references/api.md`、运行态 `GET /api/agent/capabilities` 和 `GET /api/agent/openapi.json` smoke | 已覆盖 | -| 三类独立上游真实 smoke:原版 new-api、gaoren/new-api、sub2api/GPT2Image | `scripts/smoke-image-upstream-real.mjs` 已支持独立 `IMAGE_REAL_SMOKE_*` 目标和 `--env-file `;当前 dry-run 证明本机未配置专用 `BASE_URL`,`independent_targets` 汇总缺失目标并给出最终门禁命令,`missing_env_any` 指出缺失 env;`--require-independent-targets --allow-billable` 在配置不完整时会先失败并阻断部分真实调用;配置齐全后才允许最终门禁实际计费执行 | 未完成,缺少独立真实上游地址和 key | - -## 结论 - -- 当前实现保持默认 Images API JSON 基线,不会自动按仓库名启用流式能力。 -- 页面默认不发送 `stream=true`;用户显式开启流式后,在没有 partial image 前只显示连接保持状态,不把 keepalive 当成预览或成功。 -- 当前真实上游证明 Images JSON、Images SSE、Responses SSE、Agent 内部上游 SSE 可通过本项目稳定契约落到最终产物。 -- 当前真实上游的 Responses 非流式路径未返回符合契约的 `image_generation_call.result`,本项目按设计显式失败。 -- 因本机没有独立原版 new-api、gaoren new-api、sub2api 和 GPT2Image 地址,无法把这四类实现分别做真实 smoke;对应兼容行为以本地 mock 契约测试作为当前证据。 diff --git a/docs/reviews/CR-MAINTENANCE-CLEANUP-2026-07-27.md b/docs/reviews/CR-MAINTENANCE-CLEANUP-2026-07-27.md deleted file mode 100644 index 26378f18498ce71d6df417275b74d73009e6f5a1..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-MAINTENANCE-CLEANUP-2026-07-27.md +++ /dev/null @@ -1,52 +0,0 @@ -# 维护清理与质量门禁审计 - -日期: 2026-07-27 - -基线: `6590d91` - -分支: `codex/maintenance-cleanup-tests-docs` - -范围: 未使用代码和直接依赖清理、核心验证计划补测、源码格式统一、CI 门禁和维护文档。 - -## 清理结论 - -- 删除未被生产代码、测试或脚本引用的 `toggle.tsx` 和 `toggle-group.tsx`。 -- 删除随上述组件失去用途的 `@radix-ui/react-toggle`、`@radix-ui/react-toggle-group` 直接依赖。 -- 删除未被 ESLint 配置直接使用的 `@eslint/eslintrc` 直接依赖;lockfile 中由 ESLint 引入的传递依赖继续保留。 -- 保留 `brace-expansion` 兼容包,其仍由安全兼容层、Docker 构建和测试使用。 -- 保留 `happy-dom`,其仍由 `src/test-utils/react-dom.ts` 使用。 -- 未根据 Knip 的未使用文件报告批量删除文件。当前自定义测试入口和 Next.js 隐式路由会被该类静态扫描误报,删除前仍需逐项结合引用和框架约定确认。 - -## 测试与格式门禁 - -- 新增 `format:check`,以只读方式校验 `src/` 下 TypeScript 和 TSX 文件的 Prettier 格式。 -- `npm run verify` 的 full 和 skip-build 计划均包含 `format:check`,quick 计划保持轻量,不隐藏 full gate。 -- 命令中心测试覆盖普通 full、skip-build 和 full with PostgreSQL 三种计划中的格式检查顺序。 -- CI 在源码 lint 后执行 `format:check`,格式漂移会显式失败。 -- 对现有 `src/` TypeScript 和 TSX 文件执行一次统一格式化;改动仅涉及导入排序、换行、空白和 Tailwind class 排序。 - -## 自动化证据 - -| 命令 | 退出码 | 结果 | -| --- | --- | --- | -| `npm test -- scripts/command-center.test.mjs` | 0 | 46 个命令中心测试通过,包含新增格式门禁计划断言。 | -| `npm test -- --experimental-test-coverage --test-reporter=dot` | 0 | 全量测试在 Node 覆盖率插桩模式下通过;本次不设覆盖率百分比门槛。 | -| `npm run verify -- --postgres` | 0 | 版本、安装策略、依赖、全量测试、lint、格式、脚本语法、生产构建、真实 PostgreSQL gate 和 diff 检查均通过。 | -| `npm audit --audit-level=high` | 0 | 报告 `found 0 vulnerabilities`。 | -| 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义检查通过。 | -| `docker build --check .` | 0 | Dockerfile 检查完成,无警告。 | -| `docker compose config --quiet` | 0 | 默认 SQLite Compose 配置成功渲染。 | -| `docker compose -f docker-compose.yml -f docker-compose.memory.yml config --quiet` | 0 | memory overlay 配置成功渲染。 | -| `docker compose -f docker-compose.yml -f docker-compose.postgres.yml config --quiet` | 0 | PostgreSQL overlay 配置成功渲染。 | - -## 独立审查 - -- OMP 17.0.6 使用默认模型在隔离 worktree 中审查 `6590d91..HEAD`,未发现 P0、P1、P2 或 P3 问题。 -- OMP 独立核对删除引用、lockfile 根依赖、格式门禁接线、quick 计划、格式化语义、文档和 CI,并复跑依赖安装、格式检查、命令中心测试、actionlint 和 diff 检查。 -- OMP 未复跑的全量 verify、真实 PostgreSQL gate、依赖安全审计、Dockerfile 和 Compose 检查,均由主工作区的自动化证据覆盖。 - -## 范围边界 - -- 本次不修改图片生成、渠道路由、认证、存储或 Agent API 的业务行为。 -- 覆盖率插桩用于确认核心测试仍实际执行,不将覆盖率数字作为本次删除代码的依据。 -- 本次未执行计费图片请求,也未将自动化测试结果表述为真实上游渠道或 Hugging Face Space 已验证。 diff --git a/docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md b/docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md deleted file mode 100644 index 33c9ba45c855cf5fdbef96b15dadd817eae3d22c..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md +++ /dev/null @@ -1,61 +0,0 @@ -# Product Stage 1 Gate Review - 2026-06-06 - -## Scope - -This review verifies the first-stage product improvement boundary and the follow-up narrowing: product contract, user validation script, safer sharing defaults, local result feedback, public deployment safety and Agent API positioning. A 2026-06-07 follow-up records residual gate checks that do not require billable image generation or a fresh deployment. - -## Evidence - -| Check | Command | Exit | Result | -| --- | --- | --- | --- | -| Full local gate | `npm run verify` | 0 | `version:check`, `test`, `lint`, `lint:scripts`, `build`, `diff-check` and `diff-cached-check` passed. | -| Local browser check | `http://localhost:4784` | 0 | Recent history card rendered `结果反馈`, `可用`, `需修改` and the matching mark buttons on a real browser page. | -| Targeted result feedback tests | `node --test --import tsx src/components/history-panel.test.tsx src/lib/history-metadata.test.ts` | 0 | 22 tests passed, covering local result feedback markers and history metadata helpers. | -| Share dialog defaults | `node --test --import tsx src/components/share-dialog.test.tsx` | 0 | 2 tests passed, covering default 1-day expiry and no-access-code risk copy. | -| Share API contract | `node --test --import tsx src/app/api/shares/route.test.ts` | 0 | 21 tests passed, covering share creation, access-code behavior, expiry and content serving. | -| Script tests | `npm run test:scripts` | 0 | 187 tests passed. | -| HF Space local doctor | `npm run doctor:hf-space -- --skip-remote` | 0 | Local checks passed; remote Space checks were intentionally skipped. | -| HF Space remote doctor | `npm run doctor:hf-space` | 0 | 2026-06-07 follow-up passed. Remote Space was accessible; remote variables matched the Space-free runtime contract; `remote-secrets` confirmed `APP_PASSWORD` and `AGENT_API_TOKEN`; generation credential was configured. | -| Repository status | `npm run status` | 0 | 2026-06-07 follow-up passed. Branch was `codex/product-improvement-planning-only`, `head=4ea14f3`, `dirty=false`, and all 5 independent real-smoke cases were configured. | -| Agent API non-billable doctor | `npm run agent:doctor` | 0 | 2026-06-07 follow-up passed. Capabilities, contract check, runtime backend and state backend passed; billable smoke checks were skipped with `requires --allow-billable`. | -| Independent upstream dry-run readiness | `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local` | 0 | 2026-06-07 follow-up passed without billable calls. The report returned `ok=true`, `billable=false`, `configuration_complete=true` and five configured independent targets, but all five cases were skipped with `requires --allow-billable`; `final_gate_satisfied=false`. | -| Independent upstream final gate without billable authorization | `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets` | 1 | Expected failure. The report returned `billable=false`, `configuration_complete=true`, `missing_required_count=5` and `skipped_required_cases` for `original-images-json`, `gaoren-images-sse`, `sub2api-images-sse`, `sub2api-responses-json` and `gpt2image-responses-sse`; no real image generation was authorized. | -| Local upstream fixture final gate | `npm run smoke:image-upstream-local` | 0 | 2026-06-07 follow-up passed. Local fixture covered all 5 independent cases and returned `local_fixture=true`, `configuration_complete=true`, `final_gate_satisfied=true`; this verifies the final-gate script path, not third-party upstream availability. | -| HF Space local container smoke | `npm run smoke:hf-space` | 0 | 2026-06-07 follow-up passed after optimizing `Dockerfile` to reuse the `deps` stage for `builder`, avoiding a duplicate Alpine build-dependency install. The smoke returned `state_backend=memory`, `image_storage_mode=indexeddb` and `agent_contract_check=true`. | -| Diff check | `git diff --check` | 0 | No whitespace or patch-format issues. | - -## Product Contract - -- First user: `docs/product/product-contract.md` now defines the first real user as a Chinese content operator who repeatedly produces first publish visuals for Xiaohongshu notes, product detail pages or campaign posters. -- Non-goals: public SaaS, enterprise asset approval systems, autonomous Agent scheduling and generic OpenAI-compatible benchmarking are explicitly outside Stage 1. -- Core workflow: choose a real publish topic, write prompt, generate or edit, inspect the central preview, mark recent output as `可用` or `需修改`, then continue editing, reuse or download. -- Metrics: the contract records third-minute generation, thirtieth-minute reuse, third-day return, result quality marking and explicit failure-recovery expectations. -- Evidence standard: `docs/product/user-validation-script.md` uses past-behavior and task evidence rather than opinion prompts. - -## Share Safety - -- Default expiry: `src/components/share-dialog.tsx` exports `DEFAULT_SHARE_EXPIRY_VALUE = '1440'`, making new share links default to a 1-day expiry. -- No-access-code warning: `share.publicRiskHint` appears in both Chinese and English copy and is rendered below the access-code input. -- Server-side protected content behavior: the share route test suite still covers access-code validation, expiry handling and image-content serving behavior; no server response contract was changed in this stage. - -## Public Deployment - -- `APP_PASSWORD` gate: README, customer instructions and HF Space docs all state that public customer-visible deployments must configure page access protection. -- `AGENT_API_TOKEN` gate: Agent-facing automation must configure an Agent token when exposed publicly; the full remote doctor confirmed the target Space currently has this secret. -- Free-tier persistence boundary: HF Space docs keep `memory` mode and temporary file-system behavior visible; this is not represented as production-grade persistence. -- Remote Space evidence: the 2026-06-07 `npm run doctor:hf-space` follow-up returned `remote-secrets` pass for `APP_PASSWORD` and `AGENT_API_TOKEN`, but `npm run deploy:space` plus a real browser check are still required for customer-visible readiness. -- Local container smoke evidence: after the Dockerfile stage reuse optimization, `npm run smoke:hf-space` reached the app smoke assertions and passed with the expected free-tier approximation: memory state backend, IndexedDB client storage mode and non-billable Agent contract checks. - -## Agent API Boundary - -- Automation API wording: README and skill docs describe Agent API as a machine interface for automation clients. -- Non-goals: docs explicitly say this is not an autonomous Agent platform, long-running scheduler, cross-instance persistent queue or production orchestration layer. -- Existing contract preserved: this stage did not modify `/api/agent/*` schema or Agent route implementation files. - -## Residual Risks - -- Real 5 to 10 user validation has not been executed. The script exists, but the evidence table is not populated with actual target-user sessions. -- Independent real upstream configuration is complete, but real billable upstream image generation has not been executed in this gate. The final command remains `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable`, which requires explicit user authorization because it can trigger billable image generation. -- `npm run deploy:space` and a real browser check were not executed for the follow-up narrowing. The remote doctor confirms configuration and accessibility, but it does not prove a fresh deployment from this branch or a customer-visible Space session. -- The new local result feedback loop is client-side metadata only; it does not change server contracts or persist beyond the current history storage path. -- Multi-instance persistence, production object storage and customer SaaS readiness remain outside Stage 1 by product contract. diff --git a/docs/reviews/CR-SKILL-API-BOUNDARY-2026-06-08.md b/docs/reviews/CR-SKILL-API-BOUNDARY-2026-06-08.md deleted file mode 100644 index e8a4efd9d6252f075738c179adeac3d97c505dae..0000000000000000000000000000000000000000 --- a/docs/reviews/CR-SKILL-API-BOUNDARY-2026-06-08.md +++ /dev/null @@ -1,35 +0,0 @@ -# CR-SKILL-API-BOUNDARY-2026-06-08 - -## 范围 - -- 补齐单张 `generate-image.mjs` 对页面 SSE 已支持的 Responses/GPT2Image 高级参数覆盖。 -- 明确 Agent JSON API、页面 `/api/images` SSE、分享、日志、runtime capabilities、页面删除和前端本地体验的边界。 -- 不修改 Agent API schema、OpenAPI schema、数据库 schema、真实上游门禁或页面主链实现。 - -## 代码与文档结论 - -- `generate-image.mjs` 现在支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 和 `--force-web`。 -- 这些字段被定义为页面 SSE 专属字段:dry-run 会显示 `/api/images` 路由,真实 page SSE form-data 会发送 `responsesModel`、`thinking`、`promptOptimization` 和 `force_web`。 -- 显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 与上述页面高级字段同时出现时会在网络请求前失败。 -- `responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容别名 `responses`。 -- README、Skill 文档和 API reference 已明确:分享、日志、runtime capabilities、页面图片删除、结果反馈、灵感相册和历史复用不属于 Agent JSON API 或 Agent OpenAPI。 -- README 和 API reference 已补充前端能力到 API 边界的对照矩阵,避免把页面工作台能力误归入 Agent JSON API。 -- `agent-skill-scripts.test.mjs` 增加文档与端点边界 drift guard:页面 API 必须出现在 README、Skill 和 API reference 的边界说明中,同时不得进入 `AGENT_ENDPOINTS`。 - -## 验证记录 - -| 命令 | 结果 | 摘要 | -| --- | --- | --- | -| `node --test scripts/agent-skill-scripts.test.mjs` | 通过 | 86 个脚本测试通过,覆盖 generate 高级参数 dry-run、page SSE form-data、显式 Agent route 拒绝、关闭流式拒绝、参数校验和 WebUI/Agent 边界 drift guard。 | -| `NODE_ENV=test node --test --import tsx src/app/api/agent/agent-routes.test.ts` | 通过 | 47 个 Agent route 测试通过;PostgreSQL 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 | -| `NODE_ENV=test node --test --import tsx src/app/api/images/route.test.ts` | 通过 | 40 个页面 `/api/images` 流式与 Responses/GPT2Image 字段测试通过。 | -| `NODE_ENV=test node --test --import tsx src/app/api/logs/route.test.ts src/app/api/shares/route.test.ts` | 通过 | 24 个日志与分享页面 API 测试通过。 | -| `npm run lint:scripts` | 通过 | `scripts/check-node-syntax.mjs` 通过。 | -| `npm run verify` | 通过 | full profile 通过:`version:check`、`npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`、`git diff --cached --check`。 | -| `git diff --check` | 通过 | 当前 diff 无 whitespace error。 | - -## 残余 gate - -- `npm run verify` 本轮输出 `postgres=false`,未覆盖真实 PostgreSQL gate。 -- 本轮没有执行真实上游 `--allow-billable` smoke。 -- 本轮没有执行 Docker、Hugging Face Space 或生产部署 gate。 diff --git a/docs/reviews/PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20.md b/docs/reviews/PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20.md deleted file mode 100644 index bd1eba807d293a9df00f5b83bc344a6ab4225e7e..0000000000000000000000000000000000000000 --- a/docs/reviews/PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20.md +++ /dev/null @@ -1,20 +0,0 @@ -# PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20 - -## 范围 - -- PR: #2 Improve Docker standalone deployment and auth handling -- 作者: KwokYYYY <875596331@qq.com> -- 原始提交: c5b935cffdfa8604bba18cf34bf5c2e091401f45 -- 原始分支: KwokYYYY/docker-standalone-auth-fixes - -## 处理结论 - -PR #2 的 Docker standalone deployment 与 auth handling 方向已在内部集成分支中吸收,并通过后续主线提交完成适配、验证和部署。 - -截至 2026-05-20,PR #2 仍以 `main` 为目标分支保持打开状态,但其原始实现已与当前主线发生冲突,不再适合直接合并。为避免把过时实现重新引入主线,本仓库保留此贡献确认记录,并在对应提交中使用 `Co-authored-by` 保留作者贡献归属。 - -## 当前主线状态 - -- 当前主线已完成 Docker standalone runtime 补齐、页面访问保护、图片访问保护、Agent 状态后端、流式图片后端适配和测试环境恢复。 -- 当前主线已通过 `npm test`、`npm run lint`、`npm run build`、`npm run test:postgres` 和 Docker HTTP smoke 验证。 -- PR #2 应关闭为已吸收处理,不再直接合并。 diff --git a/docs/superpowers/plans/2026-05-12-agent-api-skill.md b/docs/superpowers/plans/2026-05-12-agent-api-skill.md deleted file mode 100644 index 63ecf7f43bf4b0a3deb5f4a61273d85cb1a50f51..0000000000000000000000000000000000000000 --- a/docs/superpowers/plans/2026-05-12-agent-api-skill.md +++ /dev/null @@ -1,20 +0,0 @@ -# Agent API and Skill Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a stable AI Agent API with idempotency, structured errors, artifact tracking, SQLite/PostgreSQL state backends, Docker deployment templates, and a repository skill package. - -**Architecture:** Keep the existing web UI API stable and add `/api/agent/*` as the machine-oriented contract. Store request state and artifact metadata in SQLite or PostgreSQL while keeping image binaries on the filesystem. - -**Tech Stack:** Next.js App Router, OpenAI JavaScript SDK, better-sqlite3, pg, node:test, Docker Compose. - ---- - -## Tasks - -- [x] Add Agent contracts, structured errors, and auth helpers. -- [x] Add SQLite and PostgreSQL state store implementations. -- [x] Add Agent generate, edit, capabilities, OpenAPI, artifact metadata, content, and delete routes. -- [x] Add database schema files and Docker/PostgreSQL deployment template. -- [x] Add repository skill package with scripts and API reference. -- [x] Run full validation: test, lint, build, Docker compose checks. diff --git a/docs/superpowers/plans/2026-05-15-image-download-share.md b/docs/superpowers/plans/2026-05-15-image-download-share.md deleted file mode 100644 index 74481eeb6f88d7c090dd31d34a4ba8844806b339..0000000000000000000000000000000000000000 --- a/docs/superpowers/plans/2026-05-15-image-download-share.md +++ /dev/null @@ -1,1509 +0,0 @@ -# 图片下载与分享实现计划 - -> **给执行代理:** 必须使用子技能:推荐 `superpowers:subagent-driven-development`,或使用 `superpowers:executing-plans`,按任务逐项执行本计划。步骤使用复选框(`- [ ]`)语法跟踪。 - -**目标:** 在现有“发送到编辑”操作旁补齐图片下载和分享操作;分享链接支持可选访问码和可选有效期。 - -**架构:** 生成图片字节仍保存在现有存储路径中,分享功能通过 `generated-images/.shares` 下复制出的不可变分享产物对外提供。浏览器结果面板把图片读取职责交给 `src/app/page.tsx`,`src/components/image-output.tsx` 只保留展示和动作入口职责。分享功能使用专门的 App Router 路由创建分享、读取受保护元数据并返回分享图片内容。 - -**技术栈:** Next.js App Router、React 19、配合 tsx 的 node:test、现有 shadcn/radix UI 基础组件、`src/lib/share-store.ts`、现有 fs/IndexedDB 图片读取路径。 - ---- - -## CSE 控制合同 - -**主目标变量:** 已生成或已选中的单张图片可以下载到本地,也可以通过公开 URL 分享;该 URL 必须强制执行可选访问码和有效期。 - -**验收标准:** -- 在结果面板的单图视图中,用户能按以下顺序看到动作按钮:可用时显示日志、发送到编辑、下载、分享。 -- 下载功能使用当前显示文件名保存当前显示图片的准确字节内容。 -- 分享弹窗可以创建无访问码、有访问码、有有效期、同时有访问码和有效期四类链接。 -- 访问需要访问码的分享链接时,未提供正确访问码不得暴露图片字节。 -- 访问已过期分享链接时不得暴露图片字节。 -- API 测试覆盖创建、读取、内容返回的成功路径和失败路径。 -- UI 测试或浏览器冒烟测试覆盖按钮可见性和分享弹窗流程。 -- 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。 - -**护栏指标:** -- 不得把受访问码保护的 `/api/image/{filename}` 直接暴露为分享机制。 -- 不得把 API Key、访问码或原始提示词写入分享 URL。 -- 不得破坏 Agent 产物内容路由。 -- 不得破坏 `http://localhost:4783` 下无访问码本地部署的既有行为。 -- 不得静默回退到 mock 图片或伪造分享成功。 - -**采样计划:** -- L0:每个后端任务后运行定向 node 测试。 -- L1:API 和 UI 集成任务后运行 `npm test`。 -- L2:端到端接线完成后运行浏览器或 Docker 冒烟测试。 - -**已知时滞与时滞预算:** -- `npm run build` 和 Docker 重建属于慢反馈门禁;推迟到 L0/L1 稳定后执行。 -- 浏览器冒烟测试依赖运行中的应用,可能需要一次开发服务器或 Docker 启动周期。 - -**恢复目标:** -- 所有改动都是普通源码改动,应能在 10 分钟内通过回滚任务提交或当前 diff 恢复。 - -**回滚触发器:** -- 如果分享 URL 在未满足访问码或有效期检查时能够暴露图片字节,立即停止并回滚分享路由改动。 -- 如果现有图片生成、历史选择或发送到编辑在 L1 测试中回归,停止功能推进并优先修复该回归。 - -**约束:** -- 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。 -- 当前脏工作区包含访问码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。 -- 保持当前 `node:test` 布局;不要引入第二套测试框架。 -- 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。 - -**边界:** -- 允许触碰的后端文件:`src/lib/share-store.ts`、`src/lib/share-store.test.ts`、新增 `src/app/api/shares/route.ts`、新增 `src/app/api/shares/[token]/route.ts`、新增 `src/app/api/shares/[token]/content/route.ts`,以及匹配的路由测试。 -- 允许触碰的前端文件:`src/app/page.tsx`、`src/components/image-output.tsx`、可选新增 `src/components/share-dialog.tsx`、`src/lib/i18n.tsx`,以及可选测试。 -- 允许触碰的文档:本计划;实现后如果行为需要面向用户说明,可补充一小段 README 说明。 -- 冻结边界:Agent API schema、现有 `/api/agent/*` 行为、现有 `/api/image/{filename}` 认证契约、数据库 schema。 - -**耦合说明:** -- 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。 -- 分享内容必须独立于页面访问码 cookie;否则外部接收者无法访问有效分享。 -- 下载是纯浏览器行为,不应要求新增服务端状态。 -- 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。 -- 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。 -- 受访问码保护的分享在元数据路由中不得暴露原始文件名;原始文件名可能包含提示词或业务信息。 -- 内容路由必须对错误访问码做基础失败限流,并返回明确的 429,不得允许无限次在线猜测。 - -**近似有效性:** -- 使用临时 `process.cwd()` 目录的单元测试可以验证 share-store 语义,但不能证明 Docker volume 持久化。 -- 浏览器冒烟测试验证 UI 接线,但不验证长期过期行为;过期逻辑由固定 `now` 的确定性单元测试覆盖。 -- 内容路由的内存限流只覆盖单进程实例。多实例部署或进程重启会丢失失败计数;如果未来部署到多副本或边缘/CDN 架构,需要改用外部共享存储限流。 - -**执行器预算:** -- 新增小型 API 路由和聚焦的 UI 动作。 -- 复用现有 share-store 基础能力,而不是替换图片存储。 -- 实现前围绕契约补充测试。 - -**风险:** -- 风险 1:分享路由意外暴露原始受保护图片 URL。缓解:只通过分享 token 路由返回复制出的分享产物。 -- 风险 2:访问码或有效期只在 UI 层校验。缓解:在服务端路由强制校验,并测试直接 HTTP 路径。 -- 风险 3:UI 动作破坏多图网格布局。缓解:在网格视图隐藏或禁用图片动作,并在浏览器中验证按钮几何布局。 - -## 项目控制拓扑 - -**总体设计负责人:** 本仓库 `AGENTS.md` 和用户当前指令是参考输入。任何超出本计划的共享路由契约变更都应停止并等待明确确认。 - -**主落点:** 数据面。本功能改变用户读取和对外暴露生成图片字节的方式。 - -**次级落点:** 状态面负责 `generated-images/.shares` 下复制出的分享记录;控制面只负责请求时的有效期和访问码决策。 - -**冻结边界:** -- `/api/agent/*` 契约保持不变。 -- 现有生成图片文件名和 `/api/image/{filename}` 校验保持不变。 -- 现有历史记录存储形状保持不变,除非后续任务明确证明必须修改。 - -**复杂性转移账本:** - -| 字段 | 内容 | -| --- | --- | -| 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 | -| 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 | -| 收益 | 外部分享访问不再依赖页面访问码 cookie 或浏览器本地 IndexedDB 状态。 | -| 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 | -| 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 | - -## 只读调查得到的当前状态 - -- 证据命令:`rg -n "download|share|Share|Download|handleSendToEdit|api/shares|share-store" src -g "!node_modules"`,并直接读取 `src/lib/share-store.ts`、`src/lib/share-store.test.ts`、`src/components/image-output.tsx` 和 `src/app/page.tsx`。 -- `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。 -- `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。 -- `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。 -- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面访问码错误码。 -- `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。 -- `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。 -- `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。 -- `src/app` 当前没有 `api/shares` 路由,也没有 `share/[token]` 页面;`find src/app -path "*shares*" -o -path "*share*"` 无返回路径。 -- `src/app/page.tsx` 已知道如何为发送到编辑和历史选择读取已选图片 blob;分享/下载应复用这条路径,而不是新增第二套图片加载来源。 -- `package.json` 当前使用 Next.js 16 和 React 19,`React.use(params)` 的 App Router 页面写法与当前技术栈匹配。 - -## 文件结构 - -- 修改 `src/lib/share-store.ts` - - 保留存储基础能力。仅当路由测试需要稳定错误原因时,才增加小型校验 helper。 -- 修改 `src/lib/share-store.test.ts` - - 保留现有测试。仅在缺失时补充支撑路由的边界用例。 -- 创建 `src/app/api/shares/route.ts` - - 接收 multipart 表单数据,包含 `image`、`sourceFilename`、可选 `accessCode`、可选 `expiresInMinutes`。 - - 返回 `{ token, url, expiresAt, accessCodeRequired }`。 -- 创建 `src/app/api/shares/route.test.ts` - - 测试分享创建成功、无效文件、无效有效期,以及响应中不包含原始访问码。 -- 创建 `src/app/api/shares/[token]/route.ts` - - 返回分享页需要的公开元数据:可公开展示的文件名、MIME 类型、大小、createdAt、expiresAt、accessCodeRequired、expired。 - - 对受访问码保护的分享,元数据中的文件名必须脱敏为通用名称。 - - 永不返回 `accessCodeHash` 或 `accessCodeSalt`。 -- 创建 `src/app/api/shares/[token]/content/route.ts` - - 仅当 token 存在、未过期,且需要访问码时访问码有效,才返回图片字节。 - - 对连续错误访问码返回 `429 share_rate_limited`,并设置禁止共享内容被中间层缓存的响应头。 -- 创建 `src/app/api/share-route.test.ts` - - 导入动态路由模块,并用固定临时 cwd 测试元数据/内容行为。 -- 创建 `src/app/share/[token]/page.tsx` - - 渲染一个小型分享查看器。如果需要访问码,先收集访问码再加载图片字节。 -- 创建 `src/components/share-dialog.tsx` - - 受控弹窗,包含访问码、有效期选择、创建按钮和复制链接动作。 -- 修改 `src/components/image-output.tsx` - - 在发送到编辑旁添加下载/分享图标按钮。 - - 不在单图视图时,保持按钮不可见或禁用。 -- 修改 `src/app/page.tsx` - - 添加已选图片 blob 解析器。 - - 添加 `handleDownloadImage` 和 `handleCreateShare`。 - - 将处理函数传给 `ImageOutput`。 -- 修改 `src/lib/i18n.tsx` - - 添加下载、分享、分享弹窗、错误、复制成功所需的中英文短文案。 -- 可选创建 `src/components/image-output.test.tsx` - - 仅当现有工具链已支持 DOM 渲染时创建;否则优先使用浏览器冒烟测试和路由/单元测试。 - -## 黑盒输入/输出矩阵 - -| 控制输入 | 目标输出 | 方向 | 外溢风险 | -| --- | --- | --- | --- | -| 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 | -| 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 | -| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面访问码 cookie | -| 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 | - -## 状态模型 - -分享生命周期: - -1. 浏览器弹窗中的 `draft`。 -2. 正在把已选图片字节上传到 `/api/shares` 时为 `creating`。 -3. 元数据和复制内容写入后为 `active`。 -4. 需要访问码且查看者尚未提供有效访问码时为 `locked`。 -5. 当 `now >= expiresAt` 时为 `expired`。 -6. token 或内容缺失时为 `not_found`。 - -服务端不变量: - -- Token 为 24 个十六进制字符。 -- Token 必须来自 `crypto.randomBytes(12)`,保持 96 bit CSPRNG 熵。 -- 分享内容路径必须解析到 `generated-images/.shares` 内部。 -- 访问码永不明文存储。 -- 访问码永不返回给客户端。 -- 访问码为空字符串或纯空白时视为未设置;非空访问码长度必须为 8 到 128 字符。 -- 已过期分享永不返回图片字节。 - -## 任务 - -### 任务 1:锁定分享路由契约 - -**文件:** -- 创建:`src/app/api/shares/route.test.ts` -- 创建:`src/app/api/share-route.test.ts` - -- [x] **步骤 1:编写分享创建的失败测试** - -创建 `src/app/api/shares/route.test.ts`: - -```ts -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, it } from 'node:test'; -import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; -import { createAccessToken } from '@/lib/server-runtime'; -import { POST } from './route'; -import { NextRequest } from 'next/server'; - -const originalAppPassword = process.env.APP_PASSWORD; -let previousCwd: string; -let tempDir: string; - -async function withTempCwd() { - previousCwd = process.cwd(); - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-route-')); - process.chdir(tempDir); -} - -afterEach(async () => { - if (previousCwd) process.chdir(previousCwd); - if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); - if (originalAppPassword === undefined) { - delete process.env.APP_PASSWORD; - } else { - process.env.APP_PASSWORD = originalAppPassword; - } -}); - -function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) { - const headers = new Headers(); - const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'access', 'code'].join('-')) : options.accessToken; - if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`); - return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form }); -} - -describe('POST /api/shares', () => { - it('creates a share from an uploaded image without returning secrets', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('accessCode', '12345678'); - form.set('expiresInMinutes', '60'); - form.set('image', new File([new Uint8Array([1, 2, 3])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form)); - assert.equal(response.status, 201); - const body = await response.json(); - - assert.match(body.token, /^[a-f0-9]{24}$/); - assert.equal(body.accessCodeRequired, true); - assert.equal(typeof body.url, 'string'); - assert.ok(body.url.includes(`/share/${body.token}`)); - assert.equal('accessCodeHash' in body, false); - assert.equal('accessCodeSalt' in body, false); - }); - - it('rejects unauthenticated share creation when a page access code is configured', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form, { accessToken: null })); - assert.equal(response.status, 401); - const body = await response.json(); - assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing); - }); - - it('rejects share creation with an invalid page access token', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form, { accessToken: 'invalid-access-token' })); - assert.equal(response.status, 401); - const body = await response.json(); - assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid); - }); - - it('allows share creation when no page access code is configured', async () => { - await withTempCwd(); - delete process.env.APP_PASSWORD; - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form, { accessToken: null })); - assert.equal(response.status, 201); - }); - - it('treats blank access codes as public shares', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('accessCode', ' '); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form)); - assert.equal(response.status, 201); - const body = await response.json(); - assert.equal(body.accessCodeRequired, false); - }); - - it('rejects short access codes', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('accessCode', '1234567'); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form)); - assert.equal(response.status, 400); - const body = await response.json(); - assert.equal(body.code, 'invalid_access_code'); - }); - - it('rejects missing image uploads', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - - const response = await POST(createShareRequest(form)); - assert.equal(response.status, 400); - const body = await response.json(); - assert.equal(body.code, 'image_required'); - }); - - it('rejects invalid expiry values', async () => { - await withTempCwd(); - process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-'); - const form = new FormData(); - form.set('sourceFilename', 'result.png'); - form.set('expiresInMinutes', '-1'); - form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' })); - - const response = await POST(createShareRequest(form)); - assert.equal(response.status, 400); - const body = await response.json(); - assert.equal(body.code, 'invalid_expiry'); - }); -}); -``` - -- [x] **步骤 2:编写分享元数据和内容的失败测试** - -创建 `src/app/api/share-route.test.ts`: - -```ts -import assert from 'node:assert/strict'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, it } from 'node:test'; -import { createImageShare } from '@/lib/share-store'; -import { GET as getShare } from './shares/[token]/route'; -import { POST as getShareContent } from './shares/[token]/content/route'; - -let previousCwd: string; -let tempDir: string; - -async function withTempCwd() { - previousCwd = process.cwd(); - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-content-route-')); - process.chdir(tempDir); -} - -function params(token: string) { - return { params: Promise.resolve({ token }) }; -} - -afterEach(async () => { - if (previousCwd) process.chdir(previousCwd); - if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); -}); - -describe('share metadata and content routes', () => { - it('returns public metadata without hashes', async () => { - await withTempCwd(); - const record = await createImageShare({ - imageBuffer: Buffer.from('image-bytes'), - sourceFilename: 'image.png', - mimeType: 'image/png', - accessCode: '12345678', - expiresInMinutes: 60 - }); - - const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token)); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.token, record.token); - assert.equal(body.accessCodeRequired, true); - assert.equal(body.expired, false); - assert.equal(body.sourceFilename, 'shared-image'); - assert.equal('accessCodeHash' in body, false); - assert.equal('accessCodeSalt' in body, false); - }); - - it('marks expired metadata without serving content', async () => { - await withTempCwd(); - const record = await createImageShare({ - imageBuffer: Buffer.from('expired-image'), - sourceFilename: 'image.png', - mimeType: 'image/png', - accessCode: undefined, - expiresInMinutes: null - }); - const expiredRecord = { ...record, expiresAt: new Date(Date.now() - 60_000).toISOString() }; - await fs.writeFile(path.join(tempDir, 'generated-images', '.shares', `${record.token}.json`), `${JSON.stringify(expiredRecord)}\n`); - - const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token)); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.expired, true); - }); - - it('serves protected content only with the correct access code', async () => { - await withTempCwd(); - const record = await createImageShare({ - imageBuffer: Buffer.from('protected-image'), - sourceFilename: 'image.png', - mimeType: 'image/png', - accessCode: '12345678', - expiresInMinutes: null - }); - - const missing = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { method: 'POST', body: JSON.stringify({}) }), - params(record.token) - ); - assert.equal(missing.status, 401); - - const wrong = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: 'bad' }) - }), - params(record.token) - ); - assert.equal(wrong.status, 401); - - const ok = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: '12345678' }) - }), - params(record.token) - ); - assert.equal(ok.status, 200); - assert.equal(ok.headers.get('content-type'), 'image/png'); - assert.match(ok.headers.get('cache-control') || '', /no-store/); - assert.equal(ok.headers.get('surrogate-control'), 'no-store'); - assert.equal(await ok.text(), 'protected-image'); - }); - - it('rate limits repeated wrong access codes', async () => { - await withTempCwd(); - const record = await createImageShare({ - imageBuffer: Buffer.from('protected-image'), - sourceFilename: 'image.png', - mimeType: 'image/png', - accessCode: '12345678', - expiresInMinutes: null - }); - - for (let attempt = 0; attempt < 10; attempt += 1) { - await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: 'bad-code' }) - }), - params(record.token) - ); - } - - const response = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: 'bad-code' }) - }), - params(record.token) - ); - assert.equal(response.status, 429); - }); - - it('does not serve expired content', async () => { - await withTempCwd(); - const record = await createImageShare({ - imageBuffer: Buffer.from('expired-image'), - sourceFilename: 'image.png', - mimeType: 'image/png', - accessCode: undefined, - expiresInMinutes: null - }); - const expiredRecord = { ...record, expiresAt: new Date(Date.now() - 60_000).toISOString() }; - await fs.writeFile(path.join(tempDir, 'generated-images', '.shares', `${record.token}.json`), `${JSON.stringify(expiredRecord)}\n`); - - const response = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}) - }), - params(record.token) - ); - assert.equal(response.status, 410); - }); -}); -``` - -- [x] **步骤 3:运行测试并确认失败** - -运行: - -```bash -npm test -- src/app/api/shares/route.test.ts src/app/api/share-route.test.ts -``` - -预期:由于 `src/app/api/shares/*` 路由尚不存在,导入失败。 - -### 任务 2:实现分享 API 路由 - -**文件:** -- 创建:`src/app/api/shares/route.ts` -- 创建:`src/app/api/shares/[token]/route.ts` -- 创建:`src/app/api/shares/[token]/content/route.ts` - -- [x] **步骤 1:添加创建路由** - -创建 `src/app/api/shares/route.ts`: - -```ts -import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; -import { createImageShare } from '@/lib/share-store'; -import { verifyAccessToken } from '@/lib/server-runtime'; -import { NextRequest, NextResponse } from 'next/server'; - -const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024; -const MIN_ACCESS_CODE_LENGTH = 8; -const MAX_ACCESS_CODE_LENGTH = 128; - -type UploadedImage = Blob & { - name?: string; - type: string; -}; - -function jsonError(code: string, message: string, status: number) { - return NextResponse.json({ error: message, code }, { status }); -} - -function parseExpiry(value: FormDataEntryValue | null): number | null | undefined { - if (value === null || value === '') return null; - if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined; - const minutes = Number(value); - if (!Number.isSafeInteger(minutes) || minutes < 1 || minutes > 60 * 24 * 30) return undefined; - return minutes; -} - -function resolveShareUrl(request: Request, token: string): string { - const url = new URL(request.url); - return `${url.origin}/share/${token}`; -} - -function isUploadedImage(value: FormDataEntryValue | null): value is UploadedImage { - return ( - typeof value === 'object' && - value !== null && - typeof (value as Blob).arrayBuffer === 'function' && - typeof (value as Blob).size === 'number' - ); -} - -function parseAccessCode(value: FormDataEntryValue | null): string | undefined | null { - if (value === null) return undefined; - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return undefined; - if (trimmed.length < MIN_ACCESS_CODE_LENGTH || trimmed.length > MAX_ACCESS_CODE_LENGTH) return null; - return trimmed; -} - -function verifyShareCreator(request: NextRequest) { - if (!process.env.APP_PASSWORD) return undefined; - const accessToken = request.cookies.get('gptImageAccess')?.value; - if (verifyAccessToken(accessToken, process.env.APP_PASSWORD)) return undefined; - const code = accessToken ? PAGE_PASSWORD_AUTH_ERROR_CODES.invalid : PAGE_PASSWORD_AUTH_ERROR_CODES.missing; - return jsonError(code, '未授权:无效的访问令牌。', 401); -} - -export async function POST(request: NextRequest) { - const authError = verifyShareCreator(request); - if (authError) return authError; - - let form: FormData; - try { - form = await request.formData(); - } catch { - return jsonError('invalid_form_data', '分享请求格式无效。', 400); - } - - const image = form.get('image'); - if (!isUploadedImage(image)) { - return jsonError('image_required', '分享图片必填。', 400); - } - if (image.size <= 0 || image.size > MAX_SHARE_IMAGE_BYTES) { - return jsonError('invalid_image_size', '分享图片大小无效。', 400); - } - - const sourceFilenameValue = form.get('sourceFilename'); - const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png'; - const sourceFilename = typeof sourceFilenameValue === 'string' && sourceFilenameValue.trim() ? sourceFilenameValue.trim() : fallbackFilename; - const expiresInMinutes = parseExpiry(form.get('expiresInMinutes')); - if (expiresInMinutes === undefined) { - return jsonError('invalid_expiry', '分享有效期无效。', 400); - } - - const accessCode = parseAccessCode(form.get('accessCode')); - if (accessCode === null) { - return jsonError('invalid_access_code', '访问码长度无效。', 400); - } - const imageBuffer = Buffer.from(await image.arrayBuffer()); - const record = await createImageShare({ - imageBuffer, - sourceFilename, - mimeType: typeof image.type === 'string' && image.type ? image.type : 'image/png', - accessCode, - expiresInMinutes - }); - - return NextResponse.json( - { - token: record.token, - url: resolveShareUrl(request, record.token), - expiresAt: record.expiresAt ?? null, - accessCodeRequired: record.accessCodeRequired - }, - { status: 201 } - ); -} -``` - -- [x] **步骤 2:添加元数据路由** - -创建 `src/app/api/shares/[token]/route.ts`: - -```ts -import { isImageShareExpired, readImageShare } from '@/lib/share-store'; -import { NextResponse } from 'next/server'; - -function jsonError(code: string, message: string, status: number) { - return NextResponse.json({ error: message, code }, { status }); -} - -function publicSourceFilename(record: { accessCodeRequired: boolean; sourceFilename: string }): string { - return record.accessCodeRequired ? 'shared-image' : record.sourceFilename; -} - -export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) { - const { token } = await params; - const record = await readImageShare(token); - if (!record) { - return jsonError('share_not_found', '分享不存在。', 404); - } - - return NextResponse.json({ - token: record.token, - sourceFilename: publicSourceFilename(record), - mimeType: record.mimeType, - sizeBytes: record.sizeBytes, - createdAt: record.createdAt, - expiresAt: record.expiresAt ?? null, - accessCodeRequired: record.accessCodeRequired, - expired: isImageShareExpired(record) - }); -} -``` - -- [x] **步骤 3:添加内容路由** - -创建 `src/app/api/shares/[token]/content/route.ts`: - -```ts -import { isImageShareExpired, readImageShare, readImageShareContent, verifyImageShareAccess } from '@/lib/share-store'; -import { NextResponse } from 'next/server'; - -const MAX_ACCESS_FAILURES = 10; -const ACCESS_FAILURE_WINDOW_MS = 15 * 60 * 1000; - -type AccessFailureState = { - count: number; - firstFailedAt: number; - blockedUntil?: number; -}; - -const accessFailures = new Map(); - -function jsonError(code: string, message: string, status: number) { - return NextResponse.json({ error: message, code }, { status }); -} - -function isAccessBlocked(token: string, now: number): boolean { - const state = accessFailures.get(token); - if (!state) return false; - if (state.blockedUntil && state.blockedUntil > now) return true; - if (state.blockedUntil && state.blockedUntil <= now) accessFailures.delete(token); - return false; -} - -function recordAccessFailure(token: string, now: number) { - const current = accessFailures.get(token); - const state = - current && now - current.firstFailedAt <= ACCESS_FAILURE_WINDOW_MS ? current : { count: 0, firstFailedAt: now }; - state.count += 1; - if (state.count >= MAX_ACCESS_FAILURES) { - state.blockedUntil = now + ACCESS_FAILURE_WINDOW_MS; - } - accessFailures.set(token, state); -} - -function clearAccessFailure(token: string) { - accessFailures.delete(token); -} - -async function readAccessCode(request: Request): Promise { - const contentType = request.headers.get('content-type') || ''; - if (!contentType.includes('application/json')) return undefined; - const body = (await request.json().catch(() => ({}))) as { accessCode?: unknown }; - return typeof body.accessCode === 'string' ? body.accessCode : undefined; -} - -export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { - const { token } = await params; - const record = await readImageShare(token); - if (!record) { - return jsonError('share_not_found', '分享不存在。', 404); - } - if (isImageShareExpired(record)) { - return jsonError('share_expired', '分享已过期。', 410); - } - - const now = Date.now(); - if (isAccessBlocked(token, now)) { - return jsonError('share_rate_limited', '访问码尝试次数过多。', 429); - } - - const accessCode = await readAccessCode(request); - if (!verifyImageShareAccess(record, accessCode)) { - recordAccessFailure(token, now); - return jsonError('share_access_denied', '访问码无效。', 401); - } - clearAccessFailure(token); - - const content = await readImageShareContent(record); - return new NextResponse(content.buffer, { - status: 200, - headers: { - 'Content-Type': content.mimeType, - 'Content-Length': content.buffer.length.toString(), - 'Cache-Control': 'private, no-store, no-cache, max-age=0, must-revalidate', - Pragma: 'no-cache', - Expires: '0', - 'Surrogate-Control': 'no-store' - } - }); -} -``` - -- [x] **步骤 4:运行分享路由测试** - -运行: - -```bash -npm test -- src/app/api/shares/route.test.ts src/app/api/share-route.test.ts -``` - -预期:通过。 - -### 任务 3:添加分享查看页 - -**文件:** -- 创建:`src/app/share/[token]/page.tsx` -- 修改:`src/lib/i18n.tsx` - -- [x] **步骤 1:创建客户端分享页** - -创建 `src/app/share/[token]/page.tsx`: - -```tsx -'use client'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { useI18n } from '@/lib/i18n'; -import * as React from 'react'; - -type ShareMetadata = { - token: string; - sourceFilename: string; - mimeType: string; - sizeBytes: number; - createdAt: string; - expiresAt: string | null; - accessCodeRequired: boolean; - expired: boolean; -}; - -export default function SharePage({ params }: { params: Promise<{ token: string }> }) { - const { t } = useI18n(); - const resolvedParams = React.use(params); - const token = resolvedParams.token; - const [metadata, setMetadata] = React.useState(null); - const [accessCode, setAccessCode] = React.useState(''); - const [imageUrl, setImageUrl] = React.useState(null); - const [error, setError] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); - const [isUnlocking, setIsUnlocking] = React.useState(false); - const imageUrlRef = React.useRef(null); - - React.useEffect(() => { - let active = true; - const loadMetadata = async () => { - setIsLoading(true); - setError(null); - try { - const response = await fetch(`/api/shares/${token}`); - const body = await response.json(); - if (!response.ok) { - throw new Error(body.error || t('share.loadFailed')); - } - if (active) setMetadata(body as ShareMetadata); - } catch (err) { - if (active) setError(err instanceof Error ? err.message : t('share.loadFailed')); - } finally { - if (active) setIsLoading(false); - } - }; - - void loadMetadata(); - return () => { - active = false; - }; - }, [token, t]); - - React.useEffect(() => { - return () => { - if (imageUrlRef.current) URL.revokeObjectURL(imageUrlRef.current); - }; - }, []); - - const loadImage = React.useCallback(async () => { - setIsUnlocking(true); - setError(null); - try { - const response = await fetch(`/api/shares/${token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(accessCode.trim() ? { accessCode: accessCode.trim() } : {}) - }); - if (!response.ok) { - const body = await response.json().catch(() => ({})); - throw new Error(body.error || t('share.unlockFailed')); - } - if (!response.headers.get('content-type')?.startsWith('image/')) { - throw new Error(t('share.unlockFailed')); - } - const blob = await response.blob(); - const nextUrl = URL.createObjectURL(blob); - if (imageUrlRef.current) URL.revokeObjectURL(imageUrlRef.current); - imageUrlRef.current = nextUrl; - setImageUrl(nextUrl); - } catch (err) { - setError(err instanceof Error ? err.message : t('share.unlockFailed')); - } finally { - setIsUnlocking(false); - } - }, [accessCode, t, token]); - - React.useEffect(() => { - if (!metadata || metadata.expired || metadata.accessCodeRequired || imageUrl) return; - void loadImage(); - }, [imageUrl, loadImage, metadata]); - - return ( -
-
-
-

{t('share.pageTitle')}

- {metadata ?

{metadata.sourceFilename}

: null} -
- {isLoading ?

{t('share.loading')}

: null} - {error ?

{error}

: null} - {metadata?.expired ?

{t('share.expired')}

: null} - {metadata && metadata.accessCodeRequired && !imageUrl && !metadata.expired ? ( -
{ - event.preventDefault(); - void loadImage(); - }}> - setAccessCode(event.target.value)} - placeholder={t('share.accessCodePlaceholder')} - type='password' - /> - -
- ) : null} - {imageUrl ? ( -
- {metadata?.sourceFilename -
- ) : null} -
-
- ); -} -``` - -- [x] **步骤 2:添加 i18n 文案** - -修改 `src/lib/i18n.tsx`,把这些 key 添加到两个语言映射中: - -```ts -'share.pageTitle': '图片分享', -'share.loading': '正在加载分享信息...', -'share.loadFailed': '加载分享失败。', -'share.unlockFailed': '打开分享失败。', -'share.expired': '这个分享已过期。', -'share.unlock': '打开', -'share.accessCodePlaceholder': '输入访问码', -'share.imageAlt': '分享图片', -``` - -英文: - -```ts -'share.pageTitle': 'Shared Image', -'share.loading': 'Loading share details...', -'share.loadFailed': 'Failed to load share.', -'share.unlockFailed': 'Failed to open share.', -'share.expired': 'This share has expired.', -'share.unlock': 'Open', -'share.accessCodePlaceholder': 'Enter access code', -'share.imageAlt': 'Shared image', -``` - -- [x] **步骤 3:运行页面类型构建门禁** - -运行: - -```bash -npm run build -``` - -预期:构建成功,并包含 `/share/[token]`。 - -### 任务 4:添加下载和分享 UI 接线 - -**文件:** -- 创建:`src/components/share-dialog.tsx` -- 修改:`src/components/image-output.tsx` -- 修改:`src/app/page.tsx` -- 修改:`src/lib/i18n.tsx` - -- [x] **步骤 1:创建分享弹窗组件** - -创建 `src/components/share-dialog.tsx`: - -```tsx -'use client'; - -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select'; -import { useI18n } from '@/lib/i18n'; -import { Copy, Loader2 } from 'lucide-react'; -import * as React from 'react'; - -export type ShareDialogValues = { - accessCode: string; - expiresInMinutes: number | null; -}; - -type ShareDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - isCreating: boolean; - shareUrl: string | null; - error: string | null; - onCreate: (values: ShareDialogValues) => void; -}; - -const expiryOptions = [ - { value: 'none', minutes: null }, - { value: '60', minutes: 60 }, - { value: '1440', minutes: 1440 }, - { value: '10080', minutes: 10080 } -] as const; - -export function ShareDialog({ open, onOpenChange, isCreating, shareUrl, error, onCreate }: ShareDialogProps) { - const { t } = useI18n(); - const [accessCode, setAccessCode] = React.useState(''); - const [expiry, setExpiry] = React.useState('none'); - const [copied, setCopied] = React.useState(false); - - React.useEffect(() => { - if (!open) { - setCopied(false); - } - }, [open]); - - const selectedExpiry = expiryOptions.find((option) => option.value === expiry) ?? expiryOptions[0]; - - return ( - - - - {t('share.dialogTitle')} - {t('share.dialogDescription')} - -
-
- - setAccessCode(event.target.value)} - placeholder={t('share.accessCodeOptional')} - /> -
-
- - -
- {error ?

{error}

: null} - {shareUrl ? ( -
- -
- - -
- {copied ?

{t('common.copied')}

: null} -
- ) : null} -
- - - -
-
- ); -} -``` - -- [x] **步骤 2:给图片输出组件添加动作 props** - -修改 `src/components/image-output.tsx`。当前组件已有 `isSingleImageView`;如果执行时发现变量名已变化,先按实际代码调整,不要新增重复状态。 - -```tsx -import { Download, Grid, Loader2, Send, Share2, Terminal, Trash2 } from 'lucide-react'; -``` - -添加 props: - -```ts -onDownloadImage: (filename: string) => void; -onShareImage: (filename: string) => void; -``` - -添加解构: - -```ts -onDownloadImage, -onShareImage, -``` - -添加处理函数: - -```ts -const handleDownloadClick = () => { - if (typeof viewMode === 'number' && imageBatch && imageBatch[viewMode]) { - onDownloadImage(imageBatch[viewMode].filename); - } -}; - -const handleShareClick = () => { - if (typeof viewMode === 'number' && imageBatch && imageBatch[viewMode]) { - onShareImage(imageBatch[viewMode].filename); - } -}; -``` - -添加 `canUseImageActions`: - -```ts -const canUseImageActions = !isLoading && isSingleImageView && imageBatch && imageBatch[viewMode]; -``` - -把两个按钮按 DOM 顺序放在发送到编辑按钮之后:先下载,再分享,确保最终动作顺序为“日志、发送到编辑、下载、分享”。 - -```tsx - - -``` - -- [x] **步骤 3:添加页面 blob 解析器和下载/分享处理函数** - -修改 `src/app/page.tsx`。 - -导入弹窗: - -```ts -import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog'; -``` - -添加状态: - -```ts -const [shareDialogOpen, setShareDialogOpen] = React.useState(false); -const [shareTargetFilename, setShareTargetFilename] = React.useState(null); -const [shareUrl, setShareUrl] = React.useState(null); -const [shareError, setShareError] = React.useState(null); -const [isCreatingShare, setIsCreatingShare] = React.useState(false); -``` - -添加共享解析器: - -```ts -const resolveImageBlob = React.useCallback( - async (filename: string): Promise => { - if (effectiveStorageModeClient === 'indexeddb') { - const record = allDbImages?.find((img) => img.filename === filename); - if (!record?.blob) { - throw new Error(t('error.imageNotFoundDb', { filename })); - } - return record.blob; - } - - if (!(await refreshImageAccessCookie())) { - throw new Error(t('error.imageAccessRefreshFailed')); - } - const response = await fetch(`/api/image/${filename}`); - if (!response.ok) { - throw new Error(t('error.fetchImage', { statusText: response.statusText })); - } - return response.blob(); - }, - [allDbImages, effectiveStorageModeClient, refreshImageAccessCookie, t] -); -``` - -添加下载: - -```ts -const handleDownloadImage = React.useCallback( - async (filename: string) => { - try { - const blob = await resolveImageBlob(filename); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 150); - } catch (error) { - setError(createErrorNotice(error instanceof Error ? error.message : t('error.retrieveImage', { filename }))); - } - }, - [createErrorNotice, resolveImageBlob, t] -); -``` - -添加打开分享和创建分享逻辑: - -```ts -const handleOpenShareImage = React.useCallback((filename: string) => { - setShareTargetFilename(filename); - setShareUrl(null); - setShareError(null); - setShareDialogOpen(true); -}, []); - -const handleCreateShare = React.useCallback( - async (values: ShareDialogValues) => { - if (!shareTargetFilename) return; - setIsCreatingShare(true); - setShareError(null); - try { - const blob = await resolveImageBlob(shareTargetFilename); - const form = new FormData(); - form.set('sourceFilename', shareTargetFilename); - form.set('image', new File([blob], shareTargetFilename, { type: blob.type || 'image/png' })); - const accessCode = values.accessCode.trim(); - if (accessCode) { - form.set('accessCode', accessCode); - } - if (typeof values.expiresInMinutes === 'number') { - form.set('expiresInMinutes', String(values.expiresInMinutes)); - } - - const response = await fetch('/api/shares', { method: 'POST', body: form }); - const body = await response.json(); - if (!response.ok) { - throw new Error(body.error || t('share.createFailed')); - } - setShareUrl(body.url); - } catch (error) { - setShareError(error instanceof Error ? error.message : t('share.createFailed')); - } finally { - setIsCreatingShare(false); - } - }, - [resolveImageBlob, shareTargetFilename, t] -); -``` - -在其他顶层弹窗附近渲染分享弹窗: - -```tsx - -``` - -向 `ImageOutput` 传递 props: - -```tsx -onDownloadImage={handleDownloadImage} -onShareImage={handleOpenShareImage} -``` - -- [x] **步骤 4:添加动作和弹窗 i18n 文案** - -修改 `src/lib/i18n.tsx`。 - -中文: - -```ts -'output.download': '下载', -'output.share': '分享', -'share.dialogTitle': '分享图片', -'share.dialogDescription': '创建一个可访问的图片链接,可以设置访问码和有效期。', -'share.accessCode': '访问码', -'share.accessCodeOptional': '留空表示不需要访问码', -'share.expiry': '有效期', -'share.expiryNone': '永久有效', -'share.expiryOneHour': '1 小时', -'share.expiryOneDay': '1 天', -'share.expirySevenDays': '7 天', -'share.link': '分享链接', -'share.copyLink': '复制分享链接', -'share.create': '创建分享', -'share.createFailed': '创建分享失败。', -'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。', -``` - -英文: - -```ts -'output.download': 'Download', -'output.share': 'Share', -'share.dialogTitle': 'Share Image', -'share.dialogDescription': 'Create an image link with an optional access code and expiry.', -'share.accessCode': 'Access Code', -'share.accessCodeOptional': 'Leave blank for no access code', -'share.expiry': 'Expiry', -'share.expiryNone': 'Never expires', -'share.expiryOneHour': '1 hour', -'share.expiryOneDay': '1 day', -'share.expirySevenDays': '7 days', -'share.link': 'Share link', -'share.copyLink': 'Copy share link', -'share.create': 'Create Share', -'share.createFailed': 'Failed to create share.', -'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.', -``` - -- [x] **步骤 5:运行前端验证** - -运行: - -```bash -npm run lint -npm run build -``` - -预期:两项都通过。 - -### 任务 5:端到端验证 - -**文件:** -- 除非验证发现缺陷,否则不改代码。 - -- [x] **步骤 1:运行完整本地门禁** - -运行: - -```bash -npm test -npm run lint -npm run build -git diff --check -``` - -预期: -- `npm test`:全部测试通过;除非已配置,否则 PostgreSQL live 测试可以继续跳过。 -- `npm run lint`:通过。 -- `npm run build`:通过。 -- `git diff --check`:通过。 - -- [x] **步骤 2:启动应用用于浏览器冒烟测试** - -如果 4783 上没有运行中的服务: - -```bash -npm run dev -``` - -如果需要 Docker 验证: - -```bash -docker compose up -d --build -``` - -预期:应用可通过 `http://localhost:4783` 访问。 - -- [x] **步骤 3:浏览器冒烟测试** - -使用内置 Browser 访问 `http://localhost:4783`: - -- 生成一张图片,或选择已有历史图片。 -- 切换到单图视图。 -- 确认发送到编辑、下载和分享按钮可见且没有重叠。 -- 点击下载,并确认浏览器启动文件下载,或产生有效的 object URL 点击路径。 -- 点击分享,创建无访问码分享,打开返回 URL,确认图片渲染。 -- 创建带访问码的分享,打开返回 URL,确认输入访问码前图片不渲染,输入正确访问码后图片渲染。 - -- [x] **步骤 4:分享路由直接 HTTP 冒烟测试** - -通过生成的一字节 PNG payload 创建受保护分享,然后测试元数据、错误访问码和正确访问码,执行直接路由冒烟测试: - -```bash -node --input-type=module <<'NODE' -import crypto from 'node:crypto'; - -async function resolveAccessCookie() { - if (!process.env.APP_PASSWORD) return ''; - const passwordHash = crypto.createHash('sha256').update(process.env.APP_PASSWORD).digest('hex'); - const response = await fetch('http://localhost:4783/api/auth-verify', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ passwordHash }) - }); - if (!response.ok) { - throw new Error(`auth-verify failed: ${response.status} ${await response.text()}`); - } - const cookie = response.headers.get('set-cookie'); - if (!cookie) throw new Error('auth-verify did not return an access cookie'); - return cookie.split(';')[0]; -} - -const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/luz7wgAAAABJRU5ErkJggg=='; -const blob = new Blob([Buffer.from(pngBase64, 'base64')], { type: 'image/png' }); -const form = new FormData(); -form.set('sourceFilename', 'smoke.png'); -form.set('accessCode', 'smoke-code'); -form.set('expiresInMinutes', '60'); -form.set('image', new File([blob], 'smoke.png', { type: 'image/png' })); - -const headers = new Headers(); -const accessCookie = await resolveAccessCookie(); -if (accessCookie) headers.set('Cookie', accessCookie); - -const createResponse = await fetch('http://localhost:4783/api/shares', { method: 'POST', headers, body: form }); -const createBody = await createResponse.json(); -console.log('create', createResponse.status, createBody); - -const metadataResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}`); -console.log('metadata', metadataResponse.status, await metadataResponse.json()); - -const wrongResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: 'wrong' }) -}); -console.log('wrong', wrongResponse.status, await wrongResponse.text()); - -const okResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}/content`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ accessCode: 'smoke-code' }) -}); -console.log('ok', okResponse.status, okResponse.headers.get('content-type'), (await okResponse.arrayBuffer()).byteLength); -NODE -``` - -预期: -- 创建接口返回 `201`,包含 24 字符十六进制 token,且不包含访问码 hash 或 salt。 -- 元数据接口返回 `200`。 -- 错误访问码返回 `401`。 -- 正确访问码返回图片内容,content type 为图片类型,且字节长度非零。 - -- [x] **步骤 5:复核 diff 范围** - -运行: - -```bash -git diff --name-only -git status --short -``` - -预期: -- Diff 只包含计划内文件,以及任何预先存在的无关脏文件。 -- 不要回滚预先存在的无关改动。 - -## 自审清单 - -- 规格覆盖:下载按钮、分享按钮、访问码、有效期、接收者直接访问、Docker/浏览器验证都已映射到任务。 -- 占位符扫描:没有任务使用 `TBD`、`TODO`、`implement later`、`fill in details` 或尖括号命令占位符。 -- 类型一致性:分享弹窗返回 `ShareDialogValues`,页面处理函数接收相同类型,分享路由返回 `accessCodeRequired` 和 `url`。 -- 边界检查:Agent API 保持冻结;分享功能使用新的 `/api/shares` 路由和复制出的分享产物。 -- 门禁边界:单元/API 测试证明服务端契约;浏览器/Docker 冒烟测试证明运行时 UI 接线。 diff --git a/docs/superpowers/plans/2026-06-06-product-improvement-stage-1.md b/docs/superpowers/plans/2026-06-06-product-improvement-stage-1.md deleted file mode 100644 index 3038068f42f52de9d2d962b1272b3e998377c9e8..0000000000000000000000000000000000000000 --- a/docs/superpowers/plans/2026-06-06-product-improvement-stage-1.md +++ /dev/null @@ -1,793 +0,0 @@ -# Product Improvement Stage 1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use Markdown task-list syntax for tracking. - -**Goal:** 收窄并完善 GPT Image Playground 的第一阶段产品闭环,让产品从“能力集合”收敛为可验证、可部署、可解释的本地和内网 AI 图片创作工作台,同时保留 Agent API 作为明确的自动化接口。 - -**Architecture:** 本阶段不重写核心生图链路,先用文档、默认值、门禁和小型 UI/API 调整收束产品控制面。主产品线锁定为“本地/内网高分辨率图片生成与编辑工作台”,Agent API 保持机器调用入口但不包装成自治 Agent,公网分享与 HF Space 部署按更保守的安全默认值处理。 - -**Tech Stack:** Next.js 16 App Router, React 19, node:test, tsx, ESLint, existing npm scripts, Hugging Face Docker Space docs. - ---- - -## Control Contract - -**Primary Setpoint:** 第一阶段完成后,仓库内存在一套一致的产品合同、验证脚本和默认安全行为,能证明“中文创作工作台”主线、分享安全默认值、公网部署门禁和 Agent API 边界已经收敛。 - -**Acceptance:** -- `docs/product/product-contract.md` 明确第一用户、非目标用户、核心路径、上线边界、指标和 The Mom Test 验证脚本。 -- `README.md`、`客户使用说明.md`、`docs/ui/literary-young-women-workbench-design.md`、`docs/deployment/huggingface-space-free.md` 对产品定位、Agent API、分享和公网部署的说法一致。 -- 分享弹窗默认不再是“无访问码且永不过期”的最宽公开状态,服务端创建接口继续强制访问码长度、有效期和图片内容校验。 -- HF Space 或公网部署文档和检查脚本明确提示 `APP_PASSWORD` 与 `AGENT_API_TOKEN`,并将未配置状态标为不适合公网客户使用。 -- Agent 文案统一为 `Agent API` 或 `automation API`,不暗示具备自治执行、跨实例持久队列或生产级调度能力。 -- 最终运行 `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`,并记录结果。 - -**Guardrail Metrics:** -- 不破坏 `/api/images`、`/api/agent/*`、`/api/shares/*` 现有响应契约。 -- 不把任何真实 API Key、token、访问码或个人配置写入源码或文档。 -- 不把离线测试通过表述成真实 OpenAI、真实 HF Space 或真实客户验证通过。 -- 不新增静默降级、mock 成功路径或吞没错误后继续的逻辑。 -- 不引入第二套测试框架。 - -**Sampling Plan:** -- L0: 每个任务完成后运行对应文件的定向测试或静态检查。 -- L1: 涉及 UI/API 接线后运行 `npm test` 和 `npm run lint`。 -- L2: 收尾运行完整本地门禁;若触碰 HF Space 部署脚本,再运行 `npm run smoke:hf-space` 或明确记录未执行原因。 - -**Delay Budget:** -- `npm test` 和 lint 属于快速门禁,本阶段每个相关代码任务后执行。 -- `npm run build` 属于慢 gate,安排在文档和代码收敛后执行。 -- 真实 HF Space、真实上游生图、真实客户访谈不在本阶段自动执行;只建立可执行脚本、文档和验收表。 - -**Recovery Target:** 如果某个任务引入回归,先停止后续任务,在 10 分钟内通过当前任务 diff 回退到上一个验证通过状态。 - -**Rollback Trigger:** -- 分享内容路由在访问码错误或过期后仍能返回图片字节。 -- 公网部署文档或脚本鼓励无访问码使用服务端 API Key。 -- Agent 文档声明超过现有能力的自治执行、跨实例持久队列或生产调度。 -- `npm test`、`npm run lint`、`npm run build` 出现与当前任务相关的失败且无法在任务内修复。 - -**Constraints:** -- 全程中文沟通。 -- 代码、注释、日志和 Markdown 不使用 Emoji 或装饰性 Unicode 符号。 -- 不修改与本阶段目标无关的 UI 视觉细节、模型兼容逻辑、数据库 schema 或 Agent API schema。 -- 当前仓库没有任务跟踪文件,以用户本回合指定的产品完善任务为唯一任务来源。 - -**Boundary:** -- Allowed docs: `docs/product/product-contract.md`, `docs/product/user-validation-script.md`, `docs/ui/literary-young-women-workbench-design.md`, `docs/deployment/huggingface-space-free.md`, `README.md`, `客户使用说明.md`. -- Allowed UI/lib files: `src/components/share-dialog.tsx`, `src/lib/share-client.ts`, `src/lib/i18n.tsx`. -- Allowed API/tests: `src/app/api/shares/route.ts`, `src/app/api/shares/route.test.ts`, `src/app/api/share-route.test.ts`, targeted share client or UI tests if needed. -- Allowed scripts/docs gates: `scripts/doctor-hf-space.mjs`, `scripts/hf-space-doctor-utils.mjs`, matching `*.test.mjs`, `scripts/verify.mjs` only if a gate integration is necessary. -- Frozen: `/api/agent/*` schema, image generation request/response schema, database schema, core upstream routing, cost calculation semantics. - -**Coupling Notes:** -- 分享默认值 touches UI state, client form submission, server validation, and route tests. -- HF Space safety touches deployment docs and doctor diagnostics, but should not change runtime auth semantics without separate approval. -- Product contract touches README and UI design docs, but must not create a second product truth that conflicts with code. - -**Approximation Validity:** -- node:test route tests validate local API semantics and in-memory/filesystem behavior, not multi-instance distributed rate limiting. -- Documentation and validation scripts can make customer research repeatable, but do not count as completed customer research. -- HF Space doctor tests can validate configuration warnings, not prove a deployed Space is production-ready. - -**Actuator Budget:** -- Documentation edits, localized copy edits, small UI default changes, route tests, doctor warning tests, and verification scripts. -- No broad redesign, no schema migration, no new database, no external analytics service, no new auth system. - -**Risks:** -- Risk 1: Product contract becomes another stale document. Mitigation: link it from README and UI design baseline, and make it the source for product-stage decisions. -- Risk 2: Safer sharing defaults reduce convenience. Mitigation: keep no-access-code sharing available as an explicit user choice, but make the risk visible. -- Risk 3: Public deployment checks become too strict for local use. Mitigation: scope warnings to public/HF deployment docs and doctor output, not local dev startup. - -## State Estimate - -- `main` is clean and matches `origin/main` at `34b982e Release v2.0.0` before this planning branch was created. -- Current branch for this work: `codex/product-improvement-planning-only`. -- Existing UI PRD defines `图像手记` as a Chinese creative workbench and names a visual audience, but does not define measurable first-user outcomes. -- Existing README also positions the repo as a local `gpt-image-2` service, compatible API probe, Agent API, and HF Space deployment artifact. -- Existing share route already validates creator auth when `APP_PASSWORD` exists, image type, access code length, expiry range and content MIME type. -- Existing share UI defaults to no access code and no expiry. -- Existing HF Space docs already warn that free tier memory state and temporary image files are not persistent. - -## Execution Record - -- Stage 1 implementation completed on branch `codex/product-improvement-planning-only`. -- Implementation commits: - - `896d4a4 docs: define product contract and validation` - - `9e36e11 fix: make image sharing defaults safer` - - `c6cc4cc docs: tighten public deployment and agent boundary` - - `9efcda8 docs: record product stage one gate` -- Final local gate executed on the completed branch: `npm run verify` returned `"ok": true`. -- Targeted share gates executed on the completed branch: - - `node --test --import tsx src/components/share-dialog.test.tsx` returned 2 pass. - - `node --test --import tsx src/app/api/shares/route.test.ts` returned 21 pass. -- HF Space diagnostic gate executed on the completed branch: `npm run doctor:hf-space` returned `ok: true` and confirmed `remote-secrets` contains `APP_PASSWORD` and `AGENT_API_TOKEN`. -- `npm run deploy:space` was not executed because the plan marks deployment as optional unless explicitly requested; this remains a residual external deployment gate in `docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md`. - -## Follow-up Addendum - -- Current branch follow-up narrows the first battle to Chinese content operators generating first publish visuals for Xiaohongshu or e-commerce workflows. -- Local result feedback markers were added to recent history items so the latest generation can be marked `可用` or `需修改` inside the workbench history. -- Fresh local browser verification on `http://localhost:4784` confirmed the result feedback block renders on a recent history card with `结果反馈`, `可用` and `需修改` actions. -- Public deployment docs now require `npm run deploy:space` plus a real browser check for customer-visible use, or an explicit residual note if that external gate is not executed. -- Agent skill docs now keep Agent API in the automation lane and out of the first-battle proof path. - -## File Structure - -- Create `docs/product/product-contract.md` - - Single source for first-stage product positioning, non-goals, core workflow, metrics, launch boundary and evidence standard. -- Create `docs/product/user-validation-script.md` - - Field script for 5 to 10 target users, including tasks, questions, pass/fail criteria and evidence table. -- Modify `docs/ui/literary-young-women-workbench-design.md` - - Reference the product contract and clarify that visual style is not the target-user proof. -- Modify `README.md` - - Align product summary, Agent API wording, sharing defaults and deployment safety copy. -- Modify `客户使用说明.md` - - Add customer-facing safety and first-run path without developer-heavy language. -- Modify `docs/deployment/huggingface-space-free.md` - - Make public deployment safety gate explicit and keep free-tier limitations visible. -- Modify `src/components/share-dialog.tsx` - - Change initial expiry default to a safer short-lived option and show explicit copy for public no-access-code sharing. -- Modify `src/lib/share-client.ts` - - Keep client form submission explicit and add small tests if defaults move into a helper. -- Modify `src/lib/i18n.tsx` - - Add or adjust share safety copy in Chinese and English. -- Modify `src/app/api/shares/route.test.ts` and `src/app/api/share-route.test.ts` - - Lock server-side share behavior against no access code, expiry, protected content and error paths. -- Optional modify `scripts/hf-space-doctor-utils.mjs` and tests - - Only if current diagnostics do not visibly mark missing public auth as unsafe. - -## Project Control Topology - -**总体设计部:** `AGENTS.md`, this plan, `docs/product/product-contract.md`, and user instructions define the project-level reference. Final gate is local verification plus explicit residual gate notes. - -**控制结构:** -- Product docs can change positioning, wording and validation requirements. -- UI/API tasks can change default choices and visible warnings. -- Deployment scripts can observe and warn about unsafe public configuration. -- Core image generation, Agent schema, database schema and upstream compatibility are frozen. - -**主落点:** 控制面。This stage changes product defaults, warnings, gates and documentation. It should not move the core data plane. - -**次级落点:** 状态面 is touched only through share metadata semantics and HF Space memory-state documentation. Data plane remains the existing image generation and image serving paths. - -**复杂性转移账本:** - -| 字段 | 内容 | -| --- | --- | -| 复杂性原位置 | 用户需要从 README、UI design、HF docs and runtime behavior infer product boundaries and safety defaults. | -| 新位置 | Product contract, safer share defaults, public deployment diagnostics and aligned customer docs. | -| 收益 | Product decisions become inspectable and deployment risks become visible before customer exposure. | -| 新成本 | Docs and diagnostics must stay aligned with future runtime changes. | -| 失效模式 | A future feature bypasses product contract or reintroduces unsafe defaults without updating tests. | - -## Black-Box Input/Output Matrix - -| Control Input | Target Output | Direction | Coupled Outputs | Rollback Signal | -| --- | --- | --- | --- | --- | -| Add product contract | Product positioning ambiguity | Decrease | README and UI docs copy | Docs contradict current API or workflow | -| Add validation script | User evidence quality | Increase | Product roadmap and acceptance gates | Script asks opinions instead of past behavior | -| Change share expiry default | Public exposure duration | Decrease | Share dialog UX and tests | Existing protected share tests fail | -| Add share risk copy | User risk awareness | Increase | i18n and dialog layout | Text overflows or confuses no-access-code option | -| Add HF public safety gate | Unsafe public deployments | Decrease | doctor output and deployment docs | Local-only use incorrectly blocked | -| Rename Agent positioning | Overclaim risk | Decrease | README, skill docs and customer docs | Agent API contract wording becomes unclear | - -## Tasks - -### Task 1: Create Product Contract - -**Files:** -- Create: `docs/product/product-contract.md` -- Modify: `README.md` -- Modify: `docs/ui/literary-young-women-workbench-design.md` - -- [x] **Step 1: Create product directory** - -Run: - -```bash -mkdir -p docs/product -``` - -Expected: `docs/product` exists. - -- [x] **Step 2: Write the product contract** - -Create `docs/product/product-contract.md` with these sections: - -```markdown -# 图像手记产品合同 - -## 阶段结论 - -第一阶段主线是给中文小红书和电商内容运营者使用的本地或内网 AI 图片创作工作台,不是通用图片平台、公开 SaaS、自治 Agent 或兼容接口基准测试平台。 - -## 第一真实用户 - -第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常一个人或在 2 到 5 人小团队内工作,有自己的 API Key 或由团队配置服务端 Key,需要在本机、内网或受控公网环境中完成提示词生成、结果挑选、继续编辑、下载和安全分享。 - -## 首战场景 - -首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开 SaaS、模型兼容探测和长期 Agent 调度。 - -## 非目标用户 - -- 需要多人协作素材库、权限分层、审批流或结算系统的企业团队。 -- 需要生产级公网 SaaS 可用性、对象存储和审计合规的客户。 -- 需要自治 Agent 长期调度、跨实例队列和任务审计的自动化系统。 -- 只想验证任意 OpenAI-compatible 接口全部能力的工程测试人员。 - -## 核心闭环 - -1. 选一个正在准备发布的笔记、商品或活动主题。 -2. 写下真实提示词。 -3. 选择常用尺寸、数量、清晰度和格式。 -4. 生成或编辑图片。 -5. 在中央预览中挑选结果。 -6. 在最近生成中标记结果为 `可用` 或 `需修改`。 -7. 继续编辑、做变体、复用提示词、对比、下载或分享。 -8. 从最近生成或灵感相册回到下一次创作。 - -## 产品指标 - -- 第 3 分钟:新用户能在不听讲解的情况下生成一张可下载图片。 -- 第 30 分钟:用户能从历史或当前结果继续编辑或复用提示词。 -- 第 3 天:用户再次打开并复用历史、灵感或参数。 -- 结果质量:用户能为最近生成结果标记 `可用` 或 `需修改`,并说明标记理由。 -- 失败恢复:用户看到明确失败原因后知道该重试、改参数、换上游还是停止。 - -## 上线边界 - -第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API Key 时必须配置页面访问码,Agent API 对外开放时必须配置 Agent token。 - -## 证据标准 - -产品判断优先使用真实行为、任务完成、质量标记、迁移、复用、授权、付费和引荐承诺。口头认可、审美偏好和内部演示不能单独证明产品成立。 -``` - -- [x] **Step 3: Link contract from README** - -Add a short sentence near the README product introduction: - -```markdown -第一阶段产品边界以 `docs/product/product-contract.md` 为准:主线是给中文内容运营者产出首版视觉稿的本地和内网 AI 图片创作工作台,Agent API 是自动化接口,不是自治 Agent 平台。 -``` - -- [x] **Step 4: Link contract from UI design baseline** - -Add this after `docs/ui/literary-young-women-workbench-design.md` line describing the core positioning: - -```markdown -产品边界以 `docs/product/product-contract.md` 为准;本文档只定义工作台信息架构和视觉交互基线,不把审美画像当作真实用户证据。首战验证必须落在真实发布任务、可下载结果、继续编辑或复用、以及最近生成里的 `可用` / `需修改` 结果反馈。 -``` - -- [x] **Step 5: Verify doc references** - -Run: - -```bash -rg -n "product-contract|自治 Agent|本地和内网 AI 图片创作工作台" README.md docs/ui docs/product -git diff --check -``` - -Expected: references are present and `git diff --check` exits 0. - -- [x] **Step 6: Commit task** - -Run: - -```bash -git add README.md docs/ui/literary-young-women-workbench-design.md docs/product/product-contract.md -git commit -m "docs: define product contract" -``` - -Expected: commit succeeds with only these files staged. - -### Task 2: Add User Validation Script - -**Files:** -- Create: `docs/product/user-validation-script.md` -- Modify: `docs/product/product-contract.md` - -- [x] **Step 1: Write validation script** - -Create `docs/product/user-validation-script.md` with: - -```markdown -# 用户验证脚本 - -## 样本 - -本轮验证 5 到 10 名目标用户。用户必须独立完成任务,观察者不能解释产品意义,只能回答环境和安全问题。 - -## 任务 - -1. 打开本地或受控公网地址,确认它是给小红书笔记、商品详情页或活动海报产图的工作台。 -2. 填写 API 设置或使用已配置服务端 Key。 -3. 用自己的真实发布需求写提示词并生成一张首版图片。 -4. 下载生成结果。 -5. 从结果进入继续编辑或做变体。 -6. 从最近生成或灵感相册复用一次提示词。 -7. 给最近生成结果标记 `可用` 或 `需修改`。 -8. 如需分享,创建一个有有效期的分享链接。 - -## 观察记录 - -| 用户 | 第 3 分钟是否出图 | 是否下载 | 是否继续编辑或变体 | 是否复用历史或灵感 | 是否标记可用或需修改 | 是否理解费用和失败原因 | 迁移承诺 | 授权承诺 | 付费承诺 | 引荐承诺 | 卡点 | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | - -## The Mom Test 问题 - -- 你上次需要这类图片是什么时候。 -- 当时你怎么处理。 -- 花了多久,经过几个人,是否付费。 -- 当时哪里最麻烦。 -- 你试过哪些替代方案,为什么没有继续用。 -- 如果这个工具现在解决该问题,你愿意付出什么承诺:时间、迁移、授权、付费、让同事使用或引荐。 -- 这次生成结果你会标记为可用还是需修改,为什么。 - -## 通过标准 - -- 至少 4 名用户能在第 3 分钟内独立生成并下载图片。 -- 至少 3 名用户能完成继续编辑、做变体或复用提示词中的任一动作。 -- 至少 4 名用户给最近生成结果标记 `可用` 或 `需修改`,并能说出标记理由。 -- 至少 2 名用户在第 3 天复用历史、灵感或参数。 -- 至少 2 名用户给出明确迁移、授权、付费、团队使用或引荐承诺中的任一项。 -- 所有用户遇到失败时都能看到明确原因或下一步处理建议。 - -## 不通过信号 - -- 用户需要听产品解释才明白产品价值。 -- 用户只夸审美但没有真实任务。 -- 用户无法给结果标记,或标记理由只停留在好看、不好看这类审美评价。 -- 用户不愿填写 API Key、不愿迁移流程、不愿复用历史。 -- 用户第 3 天没有任何复用动作,也没有迁移、授权、付费或引荐承诺。 -- 分享链接被误认为默认私密。 -``` - -- [x] **Step 2: Reference validation in product contract** - -Add to `docs/product/product-contract.md`: - -```markdown -## 当前验证门禁 - -上线前按 `docs/product/user-validation-script.md` 执行 5 到 10 名用户验证。未执行前,不能声称产品已经通过真实用户验证。 -``` - -- [x] **Step 3: Verify no opinion-only questions** - -Run: - -```bash -rg -n "你觉得|会不会用|有没有价值|不错|喜欢吗" docs/product/user-validation-script.md -``` - -Expected: no matches. - -- [x] **Step 4: Commit task** - -Run: - -```bash -git add docs/product/product-contract.md docs/product/user-validation-script.md -git commit -m "docs: add product validation script" -``` - -Expected: commit succeeds. - -### Task 3: Make Share Defaults Safer - -**Files:** -- Modify: `src/components/share-dialog.tsx` -- Modify: `src/lib/i18n.tsx` -- Modify: `src/app/api/shares/route.test.ts` -- Modify: `src/app/api/share-route.test.ts` - -- [x] **Step 1: Add failing UI default test** - -If no existing share dialog test exists, create `src/components/share-dialog.test.tsx`: - -```tsx -import { ShareDialog } from './share-dialog'; -import { I18nProvider } from '@/lib/i18n'; -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import * as React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; - -describe('ShareDialog', () => { - it('defaults to a time-limited share and explains public sharing risk', () => { - const html = renderToStaticMarkup( - - {}} - isCreating={false} - shareUrl={null} - error={null} - onCreate={() => {}} - /> - - ); - - assert.match(html, /1 天/); - assert.match(html, /无访问码/); - assert.match(html, /链接获得者/); - }); -}); -``` - -Run: - -```bash -npm test -- src/components/share-dialog.test.tsx -``` - -Expected: FAIL before copy/default changes. - -- [x] **Step 2: Change share dialog initial expiry** - -In `src/components/share-dialog.tsx`, change: - -```ts -const [expiry, setExpiry] = React.useState('none'); -``` - -to: - -```ts -const [expiry, setExpiry] = React.useState('1440'); -``` - -- [x] **Step 3: Add public share risk copy** - -Add localized messages in `src/lib/i18n.tsx`: - -```ts -'share.publicRiskHint': '不设置访问码时,链接获得者可以直接查看图片。建议保留有效期。', -``` - -and English: - -```ts -'share.publicRiskHint': 'Without an access code, anyone with the link can view the image. Keep an expiry enabled.', -``` - -Render it below the access code input in `ShareDialog`: - -```tsx -

{t('share.publicRiskHint')}

-``` - -- [x] **Step 4: Lock server behavior** - -Ensure route tests cover: - -```ts -it('allows explicit no-access-code shares but preserves expiry when provided', async () => { - // Build FormData with image and expiresInMinutes=1440. - // POST /api/shares. - // Assert 201, accessCodeRequired=false, expiresAt is a string. -}); -``` - -and: - -```ts -it('does not return expired share content', async () => { - // Create an expired share record using share-store helper. - // POST /api/shares/{token}/content. - // Assert status 410 and code share_expired. -}); -``` - -- [x] **Step 5: Run targeted verification** - -Run: - -```bash -npm test -- src/components/share-dialog.test.tsx src/app/api/shares/route.test.ts src/app/api/share-route.test.ts -npm run lint -git diff --check -``` - -Expected: all pass. - -- [x] **Step 6: Commit task** - -Run: - -```bash -git add src/components/share-dialog.tsx src/components/share-dialog.test.tsx src/lib/i18n.tsx src/app/api/shares/route.test.ts src/app/api/share-route.test.ts -git commit -m "fix: make image sharing defaults safer" -``` - -Expected: commit succeeds. - -### Task 4: Tighten Public Deployment Safety Gate - -**Files:** -- Modify: `docs/deployment/huggingface-space-free.md` -- Modify: `客户使用说明.md` -- Optional modify: `scripts/hf-space-doctor-utils.mjs` -- Optional modify: `scripts/hf-space-doctor-utils.test.mjs` - -- [x] **Step 1: Update HF Space docs** - -In `docs/deployment/huggingface-space-free.md`, keep the existing warning and add: - -```markdown -公网客户试用必须同时完成以下检查: - -- `APP_PASSWORD` 已设置。 -- 使用 Agent API 时 `AGENT_API_TOKEN` 已设置。 -- 服务端 API Key 只写入 Space Secrets,不写入仓库文件或 README。 -- 免费层只适合演示和轻量试用,不承诺图片、分享链接或 Agent replay 长期保存。 -``` - -- [x] **Step 2: Update customer instructions** - -Add to `客户使用说明.md`: - -```markdown -## 六、公网使用提醒 - -如果把服务部署到公网地址,必须设置网页访问码。没有访问码时,任何能打开网址的人都可能消耗服务端 API Key。 - -分享图片时建议保留有效期;如果不设置访问码,获得链接的人可以直接查看图片。 -``` - -- [x] **Step 3: Inspect current doctor behavior** - -Run: - -```bash -npm run doctor -- --help -npm run doctor:hf-space -- --help -``` - -Expected: commands print help or structured diagnostics without secrets. - -- [x] **Step 4: Add diagnostic warning only if missing** - -If doctor output does not already flag missing public auth, add or adjust tests in `scripts/hf-space-doctor-utils.test.mjs` so the missing `APP_PASSWORD` case returns a warning named `hf-space-public-auth`. - -Expected assertion shape: - -```js -assert.equal(result.checks.find((check) => check.name === 'hf-space-public-auth')?.status, 'warn'); -``` - -- [x] **Step 5: Run targeted verification** - -Run: - -```bash -npm run test:scripts -npm run lint:scripts -git diff --check -``` - -Expected: all pass. - -- [x] **Step 6: Commit task** - -Run: - -```bash -git add docs/deployment/huggingface-space-free.md 客户使用说明.md scripts/hf-space-doctor-utils.mjs scripts/hf-space-doctor-utils.test.mjs -git commit -m "docs: tighten public deployment safety gate" -``` - -If script files were not changed, omit them from `git add`. - -### Task 5: Align Agent API Positioning - -**Files:** -- Modify: `README.md` -- Modify: `skills/gpt-image-playground-agent/SKILL.md` -- Modify: `skills/gpt-image-playground-agent/references/api.md` -- Modify: `docs/product/product-contract.md` - -- [x] **Step 1: Search overclaim wording** - -Run: - -```bash -rg -n "自治|自主|自动完成|Agent 平台|生产级队列|持久队列|长期调度|无需人工" README.md docs skills -``` - -Expected: identify wording that could exceed current code capabilities. - -- [x] **Step 2: Replace positioning with automation API boundary** - -Use this wording where applicable: - -```markdown -Agent API 是给 Codex、Claude Code、Gemini 等自动化客户端使用的机器接口。它提供结构化错误、幂等重试、产物追踪和脚本化调用能力,但不是自治 Agent 平台,也不承诺跨实例持久队列或生产级调度。 -``` - -- [x] **Step 3: Keep existing contract details** - -Do not remove these existing README claims: - -```markdown -Agent 请求必须带 `Idempotency-Key` -`AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token -Job polling 当前是同一 Next.js 服务实例内的后台任务 -``` - -- [x] **Step 4: Verify capabilities wording** - -Run: - -```bash -rg -n "Agent API 是给|不是自治 Agent 平台|跨实例持久队列|Idempotency-Key|AGENT_API_TOKEN" README.md docs/product skills -npm run lint:scripts -git diff --check -``` - -Expected: wording exists and script lint passes. - -- [x] **Step 5: Commit task** - -Run: - -```bash -git add README.md docs/product/product-contract.md skills/gpt-image-playground-agent/SKILL.md skills/gpt-image-playground-agent/references/api.md -git commit -m "docs: clarify agent api product boundary" -``` - -Expected: commit succeeds. - -### Task 6: Create Product Gate Review - -**Files:** -- Create: `docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md` - -- [x] **Step 1: Create gate review** - -Create `docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md`: - -```markdown -# Product Stage 1 Gate Review - 2026-06-06 - -## Scope - -This review verifies the first-stage product improvement boundary: product contract, user validation script, safer sharing defaults, public deployment safety and Agent API positioning. - -## Evidence - -| Check | Command | Exit | Result | -| --- | --- | --- | --- | -| Tests | `npm test` | | | -| Lint | `npm run lint` | | | -| Script lint | `npm run lint:scripts` | | | -| Build | `npm run build` | | | -| Diff check | `git diff --check` | | | - -## Product Contract - -- First user: -- Non-goals: -- Core workflow: -- Metrics: - -## Share Safety - -- Default expiry: -- No-access-code warning: -- Server-side protected content behavior: - -## Public Deployment - -- APP_PASSWORD gate: -- AGENT_API_TOKEN gate: -- Free-tier persistence boundary: - -## Agent API Boundary - -- Automation API wording: -- Non-goals: -- Existing contract preserved: - -## Residual Risks - -- Real customer validation has not been executed until the user-validation script table is populated with actual sessions. -- Real HF Space gate is not covered unless `npm run deploy:space` and a real browser check are executed separately. -- Real upstream image generation is not covered unless a billable smoke is explicitly run. -``` - -- [x] **Step 2: Fill evidence after commands** - -Run: - -```bash -npm test -npm run lint -npm run lint:scripts -npm run build -git diff --check -``` - -Fill the evidence table with exit codes and summaries. - -- [x] **Step 3: Verify review has no blank exit cells** - -Run: - -```bash -rg -n "\\| .* \\| \\|" docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md -``` - -Expected: no matches. - -- [x] **Step 4: Commit task** - -Run: - -```bash -git add docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md -git commit -m "docs: record product stage one gate" -``` - -Expected: commit succeeds. - -### Task 7: Final Branch Verification - -**Files:** -- No new files expected. - -- [x] **Step 1: Confirm branch and diff** - -Run: - -```bash -git status --short --branch -git log --oneline --decorate -8 -git diff --stat main...HEAD -``` - -Expected: branch is `codex/product-improvement-planning-only`; working tree is clean after commits; diff contains only planned files. - -- [x] **Step 2: Run final verification** - -Run: - -```bash -npm run verify -``` - -Expected: JSON output has `"ok": true`. - -- [x] **Step 3: Record optional public deployment gate decision** - -Only if user explicitly asks to verify HF Space or deploy: - -```bash -npm run deploy:space -``` - -Expected: Space reaches `RUNNING` and documented public checks pass. - -Status: not executed in this stage because no explicit deploy request was made. The remote configuration diagnostic was executed with `npm run doctor:hf-space`; fresh deployment and browser verification remain external residual gates. - -- [x] **Step 4: Prepare closeout** - -Final report must include: - -```markdown -Summary -State Estimate / Root Cause -Changes -Verification -Residual Risks / Gate Boundary -``` - -## Self-Review - -- Spec coverage: The plan covers positioning, validation, sharing defaults, public deployment safety, Agent API wording, and final evidence. -- Placeholder scan: No prohibited placeholder markers are present. -- Type consistency: Planned files and commands match current repo conventions: npm, node:test, Next.js App Router, existing share routes and docs. -- Scope check: The plan is one implementation stage and does not attempt broader product rebuild, schema changes, analytics service, or production deployment. diff --git a/docs/superpowers/plans/2026-07-16-webui-image-auto-cleanup.md b/docs/superpowers/plans/2026-07-16-webui-image-auto-cleanup.md deleted file mode 100644 index 37bdd76318cc6fcbed66c446ff66a5c6bcb2930b..0000000000000000000000000000000000000000 --- a/docs/superpowers/plans/2026-07-16-webui-image-auto-cleanup.md +++ /dev/null @@ -1,464 +0,0 @@ -# WebUI Image Auto Cleanup Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 为 WebUI 文件系统图片增加默认关闭、启用后保留 30 天的自动清理,并证明 Agent API 四种请求方式生成的产物不会被提前删除且仍由 Agent TTL 回收。 - -**Architecture:** 新增纯文件清理模块和运行时调度模块。清理器扫描输出目录顶层合法图片,并从 Agent 状态库读取 artifact 路径作为保护集合;启动入口先完成 Agent 回收,再启动默认关闭的 6 小时清理调度。运行能力接口只读取非敏感配置与最近运行摘要。 - -**Tech Stack:** Next.js 16 instrumentation、TypeScript、Node.js `fs/promises`、`node:test`、SQLite、Postgres、Docker Compose。 - ---- - -## File Map - -- Create: `src/lib/webui-image-cleanup.ts`,负责严格配置解析、候选筛选、删除和运行结果类型。 -- Create: `src/lib/webui-image-cleanup.test.ts`,覆盖配置、文件边界、Agent 保护和失败结果。 -- Create: `src/lib/webui-image-cleanup-runtime.ts`,负责启动执行、6 小时调度、去重注册和最近状态。 -- Create: `src/lib/webui-image-cleanup-runtime.test.ts`,覆盖默认关闭、立即执行、重复注册与失败观测。 -- Modify: `src/lib/agent-state-store.ts`,增加 artifact 文件路径只读枚举契约。 -- Modify: `src/lib/agent-state-memory.ts`、`src/lib/agent-state-sqlite.ts`、`src/lib/agent-state-postgres.ts`,实现同一枚举契约。 -- Modify: `src/lib/agent-state-memory.test.ts`、`src/lib/agent-state-sqlite.test.ts`、`src/lib/agent-state-postgres.test.ts`,锁定三种后端行为或 SQL 契约。 -- Modify: `src/instrumentation.ts`、`src/lib/agent-state-runtime.test.ts`,接入启动调度并保持启动失败可见。 -- Modify: `src/app/api/runtime-capabilities/route.ts`、`src/app/api/runtime-capabilities/route.test.ts`,暴露非敏感清理状态。 -- Modify: `src/app/api/agent/agent-routes.test.ts`,覆盖四种上游请求方式的 artifact 登记和 TTL 清理契约。 -- Modify: `.env.example`、`README.md`、`CHANGELOG.md`,记录默认关闭、30 天保留和 Agent 隔离规则。 - -### Task 1: Agent Artifact Protection Contract - -**Files:** -- Modify: `src/lib/agent-state-store.ts:75` -- Modify: `src/lib/agent-state-memory.ts:233` -- Modify: `src/lib/agent-state-sqlite.ts:352` -- Modify: `src/lib/agent-state-postgres.ts:293` -- Test: `src/lib/agent-state-memory.test.ts` -- Test: `src/lib/agent-state-sqlite.test.ts` -- Test: `src/lib/agent-state-postgres.test.ts` - -- [ ] **Step 1: Write failing memory and SQLite enumeration tests** - -Save two artifacts and require sorted unique paths: - -```ts -await store.saveArtifacts([ - buildArtifact({ id: 'artifact-b', filepath: path.join(tempDir, 'generated-images', 'b.png') }), - buildArtifact({ id: 'artifact-a', filepath: path.join(tempDir, 'generated-images', 'a.png') }) -]); - -assert.deepEqual(await store.listArtifactFilepaths(), [ - path.join(tempDir, 'generated-images', 'a.png'), - path.join(tempDir, 'generated-images', 'b.png') -]); -``` - -- [ ] **Step 2: Add a failing Postgres SQL contract test** - -```ts -const source = readFileSync(new URL('./agent-state-postgres.ts', import.meta.url), 'utf8'); -assert.match(source, /SELECT DISTINCT filepath FROM agent_artifacts ORDER BY filepath ASC/); -``` - -- [ ] **Step 3: Run tests and verify RED** - -```bash -npm test -- src/lib/agent-state-memory.test.ts src/lib/agent-state-sqlite.test.ts src/lib/agent-state-postgres.test.ts -``` - -Expected: FAIL because `listArtifactFilepaths` does not exist. - -- [ ] **Step 4: Add the interface and three implementations** - -```ts -listArtifactFilepaths(): Promise; -``` - -Memory uses a sorted set. SQLite and Postgres use `SELECT DISTINCT filepath FROM agent_artifacts ORDER BY filepath ASC`. No request JSON, prompts, IDs, or credentials are returned. - -- [ ] **Step 5: Run tests and verify GREEN** - -Run Step 3 again. Expected: selected tests pass; live Postgres cases remain skipped when `AGENT_POSTGRES_TEST_DATABASE_URL` is absent. - -- [ ] **Step 6: Commit** - -```bash -git add src/lib/agent-state-store.ts src/lib/agent-state-memory.ts src/lib/agent-state-sqlite.ts src/lib/agent-state-postgres.ts src/lib/agent-state-memory.test.ts src/lib/agent-state-sqlite.test.ts src/lib/agent-state-postgres.test.ts -git commit -m "feat(storage): expose protected agent artifact paths" -``` - -### Task 2: Cleanup Configuration and File Selection - -**Files:** -- Create: `src/lib/webui-image-cleanup.ts` -- Create: `src/lib/webui-image-cleanup.test.ts` - -- [ ] **Step 1: Write failing configuration tests** - -```ts -assert.deepEqual(readWebuiImageCleanupConfig({}), { - enabled: false, - retentionDays: 30, - intervalMs: 6 * 60 * 60 * 1000 -}); -assert.deepEqual(readWebuiImageCleanupConfig({ WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'true' }), { - enabled: true, - retentionDays: 30, - intervalMs: 6 * 60 * 60 * 1000 -}); -assert.throws( - () => readWebuiImageCleanupConfig({ WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'invalid' }), - /WEBUI_IMAGE_AUTO_CLEANUP_ENABLED/ -); -assert.throws( - () => readWebuiImageCleanupConfig({ - WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'true', - WEBUI_IMAGE_RETENTION_DAYS: '0' - }), - /WEBUI_IMAGE_RETENTION_DAYS/ -); -``` - -- [ ] **Step 2: Run tests and verify RED** - -```bash -npm test -- src/lib/webui-image-cleanup.test.ts -``` - -Expected: FAIL because the module does not exist. - -- [ ] **Step 3: Implement strict configuration parsing** - -```ts -export const WEBUI_IMAGE_DEFAULT_RETENTION_DAYS = 30; -export const WEBUI_IMAGE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; - -export type WebuiImageCleanupConfig = { - enabled: boolean; - retentionDays: number; - intervalMs: number; -}; - -export function readWebuiImageCleanupConfig( - env: Record -): WebuiImageCleanupConfig; -``` - -Accept `1/true/yes/on` and `0/false/no/off`; reject other non-empty values. Parse retention with `readPositiveIntegerFromEnv` only when enabled, otherwise report the 30-day default. - -- [ ] **Step 4: Run configuration tests and verify GREEN** - -Run Step 2 again. Expected: configuration cases pass. - -- [ ] **Step 5: Write failing cleanup boundary tests** - -Create a temporary output directory with: - -- one old valid WebUI image; -- one new valid image; -- one old valid Agent-protected image; -- one old invalid filename; -- one old image under `.shares/`; -- one symbolic link to an old file outside the directory. - -```ts -const result = await cleanupExpiredWebuiImages({ - outputDir, - retentionDays: 30, - protectedArtifactFilepaths: [protectedPath], - now: new Date('2026-07-16T00:00:00.000Z') -}); -``` - -Assert only the old unprotected valid top-level image is deleted and all result counts are exact. - -- [ ] **Step 6: Verify cleanup tests are RED** - -```bash -npm test -- src/lib/webui-image-cleanup.test.ts -``` - -Expected: FAIL because `cleanupExpiredWebuiImages` is missing. - -- [ ] **Step 7: Implement minimal cleanup** - -```ts -export type WebuiImageCleanupRun = { - status: 'succeeded' | 'failed'; - startedAt: string; - completedAt: string; - cutoffAt: string; - scannedCount: number; - protectedCount: number; - deletedCount: number; - failedCount: number; - failures: Array<{ filename: string; message: string }>; -}; - -export async function cleanupExpiredWebuiImages(input: { - outputDir: string; - retentionDays: number; - protectedArtifactFilepaths: readonly string[]; - now?: Date; -}): Promise; -``` - -Use `readdir(..., { withFileTypes: true })`, `lstat`, `isValidImageFilename`, `path.resolve`, and `unlink`. Skip directories and symbolic links. Continue after per-file failures, record only sanitized filename and message, and mark the run failed when `failedCount > 0`. - -- [ ] **Step 8: Add deterministic deletion-failure coverage and verify GREEN** - -Inject narrow filesystem operations so one candidate fails while another is deleted. Assert visible failure details and continued processing. - -```bash -npm test -- src/lib/webui-image-cleanup.test.ts -``` - -Expected: all cleanup tests pass. - -- [ ] **Step 9: Commit** - -```bash -git add src/lib/webui-image-cleanup.ts src/lib/webui-image-cleanup.test.ts -git commit -m "feat(storage): add guarded webui image cleanup" -``` - -### Task 3: Runtime Scheduling and Capability Observability - -**Files:** -- Create: `src/lib/webui-image-cleanup-runtime.ts` -- Create: `src/lib/webui-image-cleanup-runtime.test.ts` -- Modify: `src/instrumentation.ts:1` -- Modify: `src/lib/agent-state-runtime.test.ts` -- Modify: `src/app/api/runtime-capabilities/route.ts:18` -- Modify: `src/app/api/runtime-capabilities/route.test.ts:18` - -- [ ] **Step 1: Write failing scheduler tests** - -```ts -const disabled = await startWebuiImageCleanupScheduler({ - env: {}, - runCleanup, - setInterval: fakeSetInterval, - logger -}); -assert.equal(disabled.enabled, false); -assert.equal(cleanupCalls, 0); -assert.equal(intervalCalls, 0); -``` - -For enabled configuration, assert one immediate cleanup, one 6-hour interval, `unref()`, and no duplicate timer after a second start. Reset module state after every test. - -- [ ] **Step 2: Run tests and verify RED** - -```bash -npm test -- src/lib/webui-image-cleanup-runtime.test.ts -``` - -Expected: FAIL because the runtime module is missing. - -- [ ] **Step 3: Implement runtime state and scheduling** - -```ts -export async function runWebuiImageCleanupNow( - env?: Record, - now?: Date -): Promise; - -export async function startWebuiImageCleanupScheduler( - options?: SchedulerOptions -): Promise; -export function getWebuiImageCleanupSummary( - env?: Record -): WebuiImageCleanupSummary; -export function resetWebuiImageCleanupRuntimeForTests(): void; -``` - -`runWebuiImageCleanupNow` calls `ensureAgentStateStoreReady`, then `listArtifactFilepaths`, then the cleanup core with `outputDir`. Top-level errors update `lastError` and rethrow. Periodic callbacks catch and log errors to avoid unhandled rejections while preserving failed status. - -- [ ] **Step 4: Run scheduler tests and verify GREEN** - -Run Step 2 again. Expected: all scheduler cases pass and no referenced timer survives test cleanup. - -- [ ] **Step 5: Write failing instrumentation tests** - -Require startup ordering: - -```ts -assert.deepEqual(events, ['agent-recovery', 'webui-cleanup-start']); -``` - -Also assert cleanup startup failures are logged and reject. - -- [ ] **Step 6: Wire instrumentation and pass tests** - -Call the cleanup scheduler only after Agent startup recovery succeeds, retaining the `NEXT_RUNTIME === 'nodejs'` guard. - -```bash -npm test -- src/lib/agent-state-runtime.test.ts src/lib/webui-image-cleanup-runtime.test.ts -``` - -Expected: all selected tests pass. - -- [ ] **Step 7: Write failing capability tests** - -```ts -assert.deepEqual(body.webuiImageCleanup, { - enabled: false, - retentionDays: 30, - intervalMs: 21_600_000, - running: false -}); -``` - -Add enabled and sanitized recent-run cases. - -- [ ] **Step 8: Expose capability and pass tests** - -Add `webuiImageCleanup: getWebuiImageCleanupSummary(process.env)`. Invalid enabled configuration must produce HTTP 500, and summaries must not expose filesystem paths. - -```bash -npm test -- src/app/api/runtime-capabilities/route.test.ts src/lib/webui-image-cleanup-runtime.test.ts -``` - -Expected: all selected tests pass. - -- [ ] **Step 9: Commit** - -```bash -git add src/instrumentation.ts src/lib/agent-state-runtime.test.ts src/lib/webui-image-cleanup-runtime.ts src/lib/webui-image-cleanup-runtime.test.ts src/app/api/runtime-capabilities/route.ts src/app/api/runtime-capabilities/route.test.ts -git commit -m "feat(runtime): schedule webui image retention cleanup" -``` - -### Task 4: Four Agent Request Mode Lifecycle Matrix - -**Files:** -- Modify: `src/app/api/agent/agent-routes.test.ts` -- Test: `src/lib/webui-image-cleanup.test.ts` - -- [ ] **Step 1: Add a four-mode persistence matrix** - -```ts -const modes = [ - 'images-non-stream', - 'images-sse', - 'responses-non-stream', - 'responses-sse' -] as const; -``` - -Use local Images JSON, Images SSE, Responses JSON, and Responses SSE fixtures. For each mode assert: - -```ts -assert.equal(response.status, 200); -assert.equal(body.execution.channel_request_mode, mode); -const artifactPath = readStoredArtifactFilepath(artifactId); -await access(artifactPath); -assert.ok((await store.listArtifactFilepaths()).includes(artifactPath)); -``` - -- [ ] **Step 2: Run matrix and verify RED for missing coverage** - -```bash -npm test -- --test-name-pattern="registers cleanup-managed artifacts for every request mode" src/app/api/agent/agent-routes.test.ts -``` - -Expected before fixtures are complete: FAIL for any mode that does not produce or register an artifact, especially Responses non-stream. - -- [ ] **Step 3: Complete only the failing fixture or production path** - -Do not add protocol fallback. Every mode must use its named protocol and then flow through shared `persistOpenAiImages` and `saveAgentExecutionArtifacts`. If production already satisfies the contract, only add the missing test fixture. - -- [ ] **Step 4: Add protection and Agent TTL assertions** - -Backdate each artifact file beyond 30 days while its Agent request remains active. Run WebUI cleanup with `store.listArtifactFilepaths()` and assert the file remains. Purge after `expiresAt` and assert artifact metadata and file are removed. - -- [ ] **Step 5: Run matrix and verify GREEN** - -Run Step 2 again. Expected: four subcases pass using their exact selected modes. - -- [ ] **Step 6: Commit** - -```bash -git add src/app/api/agent/agent-routes.test.ts src/lib/webui-image-cleanup.test.ts -git commit -m "test(agent): cover image cleanup across request modes" -``` - -### Task 5: Documentation, Full Gates, and Docker Verification - -**Files:** -- Modify: `.env.example` -- Modify: `README.md` -- Modify: `CHANGELOG.md` - -- [ ] **Step 1: Document configuration** - -```dotenv -# WebUI 文件图片自动清理默认关闭。启用后默认保留 30 天。 -# WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=false -# WEBUI_IMAGE_RETENTION_DAYS=30 -``` - -README must distinguish WebUI retention from `AGENT_REQUEST_TTL_SECONDS`, describe protected Agent paths, and state that existing unregistered top-level images are treated as WebUI images when enabled. - -- [ ] **Step 2: Format and check the diff** - -```bash -npx prettier --write "src/lib/webui-image-cleanup*.ts" "src/instrumentation.ts" "src/app/api/runtime-capabilities/route*.ts" "src/app/api/agent/agent-routes.test.ts" -git diff --check -``` - -Expected: exit 0. - -- [ ] **Step 3: Run full verification** - -```bash -npm run verify -npx tsc --noEmit -``` - -Expected: all tests, lint, script syntax, build, diff, and TypeScript checks pass. - -- [ ] **Step 4: Commit docs** - -```bash -git add .env.example README.md CHANGELOG.md -git commit -m "docs: document webui image retention controls" -``` - -- [ ] **Step 5: Rebuild Docker** - -```bash -docker compose up -d --build -docker compose ps -``` - -Expected: `gpt-image-playground-customer` is `Up` on port `4783` with zero restarts. - -- [ ] **Step 6: Verify default-off live HTTP** - -```bash -curl -fsS http://127.0.0.1:4783/api/runtime-capabilities | jq '.webuiImageCleanup' -``` - -Expected: - -```json -{ - "enabled": false, - "retentionDays": 30, - "intervalMs": 21600000, - "running": false -} -``` - -- [ ] **Step 7: Verify enabled cleanup in isolation** - -Use a temporary `IMAGE_OUTPUT_DIR` with `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=true`. Confirm an old WebUI fixture is deleted while a protected Agent fixture remains. Do not enable cleanup against the deployed `generated-images/` directory. - -- [ ] **Step 8: Final audit** - -```bash -git status --short --branch -git log --oneline -8 -docker inspect gpt-image-playground-customer --format 'status={{.State.Status}} restart={{.RestartCount}} image={{.Image}}' -``` - -Expected: worktree clean, commits are atomic, container is running with zero restarts, and current Docker remains default-off. diff --git a/docs/superpowers/plans/2026-07-17-webui-permanent-image-retention.md b/docs/superpowers/plans/2026-07-17-webui-permanent-image-retention.md deleted file mode 100644 index bd62ed5490890353652e0bcf18d64fd78f603929..0000000000000000000000000000000000000000 --- a/docs/superpowers/plans/2026-07-17-webui-permanent-image-retention.md +++ /dev/null @@ -1,378 +0,0 @@ -# WebUI 永久图片保留 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. - -**Goal:** 为文件系统 WebUI 图片提供批量永久保存标记,排除自动清理,并让清理摘要在 Docker 独立 Next 工作进程间可见。 - -**Architecture:** 在 generated-images/.webui-state/webui-image-retention.sqlite 建立独立 SQLite 状态库,保存永久文件名和已脱敏清理摘要。自动清理、页面 API 和 React 页面都通过该状态库协作;浏览器只保存临时选择状态。 - -**Tech Stack:** Next.js 16 Route Handlers、React 19、TypeScript、better-sqlite3、node:test、Radix Checkbox、lucide-react、Docker Compose。 - ---- - -## 文件结构 - -- 新建 src/lib/webui-image-retention-store.ts 和 src/lib/webui-image-retention-store.test.ts:永久标记和摘要的唯一持久化边界。 -- 新建 src/app/api/image-retention/route.ts 和 src/app/api/image-retention/route.test.ts:页面 API、鉴权和批量文件校验。 -- 修改 src/lib/webui-image-cleanup-runtime.ts 和测试:持久化清理摘要、合并永久保护路径。 -- 修改 src/app/api/image-delete/route.ts 和新建测试:手动删除同步移除标记。 -- 修改 src/components/history-panel.tsx、测试、src/app/page.tsx、页面回归测试和 src/lib/i18n.tsx:选择模式和批量交互。 -- 修改 README.md、.env.example、CHANGELOG.md:记录永久保存边界。 - -### Task 1: 建立 WebUI SQLite 状态库 - -**Files:** -- Create: src/lib/webui-image-retention-store.ts -- Create: src/lib/webui-image-retention-store.test.ts - -- [ ] **Step 1: 写入失败测试** - - it('persists batch permanent filenames and cleanup summaries across store instances', async () => { - const first = new SqliteWebuiImageRetentionStore(dbPath); - await first.init(); - await first.preserve([firstFilename, secondFilename]); - await first.writeCleanupStatus({ lastRun: publicRun }); - - const second = new SqliteWebuiImageRetentionStore(dbPath); - await second.init(); - assert.deepEqual(await second.listPermanentFilenames(), [firstFilename, secondFilename]); - assert.deepEqual(await second.readCleanupStatus(), { lastRun: publicRun }); - }); - -- [ ] **Step 2: 确认测试失败** - -Run: npm test -- src/lib/webui-image-retention-store.test.ts - -Expected: FAIL,提示状态库模块或类尚不存在。 - -- [ ] **Step 3: 实现最小存储接口和 SQLite schema** - - export type WebuiImageRetentionAction = 'preserve' | 'release'; - - export type PersistedWebuiImageCleanupStatus = { - lastRun?: PublicWebuiImageCleanupRun; - lastError?: string; - }; - - export class SqliteWebuiImageRetentionStore { - constructor(private readonly dbPath: string) {} - - async init(): Promise { - fs.mkdirSync(path.dirname(this.dbPath), { recursive: true }); - this.db = new Database(this.dbPath); - this.db.pragma('journal_mode = WAL'); - this.db.pragma('busy_timeout = 5000'); - this.db.exec(WEBUI_IMAGE_RETENTION_SCHEMA); - } - } - -创建 webui_image_retention(filename PRIMARY KEY, saved_at) 和单行 webui_image_cleanup_status。实现 preserve、release、remove、listPermanentFilenames、writeCleanupStatus、readCleanupStatus;批量更新放在 SQLite transaction 中。状态库路径由 resolveImageOutputDir(env) 派生,固定为 .webui-state/webui-image-retention.sqlite。 - -- [ ] **Step 4: 扩展状态库测试** - - it('releases stale markers without requiring the image file to exist', async () => { - await store.preserve([firstFilename]); - await store.release([firstFilename]); - assert.deepEqual(await store.listPermanentFilenames(), []); - }); - - it('does not persist failure filenames or absolute paths in cleanup status', async () => { - await store.writeCleanupStatus({ lastRun: publicRun }); - assert.equal(JSON.stringify(await store.readCleanupStatus()).includes('/private/generated-images'), false); - }); - -- [ ] **Step 5: 验证并提交** - -Run: npm test -- src/lib/webui-image-retention-store.test.ts && npx tsc --noEmit - -Expected: PASS. - - git add src/lib/webui-image-retention-store.ts src/lib/webui-image-retention-store.test.ts - git commit -m "feat(storage): add webui image retention state" - -### Task 2: 持久化清理摘要并接入永久保护集合 - -**Files:** -- Modify: src/lib/webui-image-cleanup-runtime.ts -- Modify: src/lib/webui-image-cleanup-runtime.test.ts -- Modify: src/app/api/runtime-capabilities/route.ts -- Modify: src/app/api/runtime-capabilities/route.test.ts - -- [ ] **Step 1: 运行现有跨进程摘要 RED 用例** - -Run: npm test -- src/lib/webui-image-cleanup-runtime.test.ts --test-name-pattern='reads a persisted cleanup summary' - -Expected: FAIL,summary.lastRun 为 undefined;该测试已经在工作树中,禁止先修改生产实现。 - -- [ ] **Step 2: 将公开摘要写入状态库** - - const retentionStore = await getWebuiImageRetentionStore(env); - await retentionStore.writeCleanupStatus({ lastRun: toPublicRun(result) }); - - const persisted = await readWebuiImageCleanupStatus(env); - return { - enabled: config.enabled, - retentionDays: config.retentionDays, - intervalMs: config.intervalMs, - running, - ...(lastRun ?? persisted.lastRun ? { lastRun: lastRun ?? persisted.lastRun } : {}), - ...(lastError ?? persisted.lastError ? { lastError: lastError ?? persisted.lastError } : {}) - }; - -将 getWebuiImageCleanupSummary 改为 Promise,并在 runtime-capabilities 路由中 await 它。成功后写入 lastRun;清理异常或 timer 注册失败时写入固定 CLEANUP_FAILURE_MESSAGE,并保留上次成功 lastRun。读写状态库失败必须抛出,不能静默丢失摘要。 - -- [ ] **Step 3: 合并 Agent 与永久保存保护路径** - - const permanentFilenames = await retentionStore.listPermanentFilenames(); - const permanentFilepaths = permanentFilenames.map((filename) => path.join(resolveImageOutputDir(env), filename)); - const agentFilepaths = await agentStore.listArtifactFilepaths(); - return cleanupExpiredWebuiImages({ - outputDir: resolveImageOutputDir(env), - retentionDays, - protectedArtifactFilepaths: [...agentFilepaths, ...permanentFilepaths], - now - }); - -- [ ] **Step 4: 补测试并验证** - - it('keeps permanently saved files in the cleanup protection set', async () => { - await retentionStore.preserve([oldFilename]); - const result = await runWebuiImageCleanupNow(env, now); - assert.equal(result?.protectedCount, 1); - await access(path.join(outputDir, oldFilename)); - }); - -Run: npm test -- src/lib/webui-image-cleanup-runtime.test.ts src/app/api/runtime-capabilities/route.test.ts - -Expected: PASS,摘要中没有 failures、文件名或绝对路径。 - -- [ ] **Step 5: 提交** - - git add src/lib/webui-image-cleanup-runtime.ts src/lib/webui-image-cleanup-runtime.test.ts src/app/api/runtime-capabilities/route.ts src/app/api/runtime-capabilities/route.test.ts - git commit -m "fix(runtime): persist webui cleanup summaries" - -### Task 3: 新增永久保存 API 并同步手动删除 - -**Files:** -- Create: src/app/api/image-retention/route.ts -- Create: src/app/api/image-retention/route.test.ts -- Modify: src/app/api/image-delete/route.ts -- Create: src/app/api/image-delete/route.test.ts - -- [ ] **Step 1: 写入 API 失败测试** - - it('preserves valid top-level files in one batch and reports invalid files', async () => { - const response = await POST(jsonRequest({ - action: 'preserve', - filenames: [validFilename, '../outside.png', missingFilename] - })); - assert.equal(response.status, 207); - assert.deepEqual((await response.json()).results, [ - { filename: validFilename, success: true }, - { filename: '../outside.png', success: false, error: '文件名格式无效。' }, - { filename: missingFilename, success: false, error: '文件不存在。' } - ]); - }); - -- [ ] **Step 2: 确认测试失败** - -Run: npm test -- src/app/api/image-retention/route.test.ts - -Expected: FAIL,因为 API 路由尚不存在。 - -- [ ] **Step 3: 实现 GET、POST、鉴权和路径安全** - - const MAX_RETENTION_BATCH_SIZE = 100; - - type RetentionRequestBody = { - action: 'preserve' | 'release'; - filenames: string[]; - passwordHash?: string; - }; - - export async function POST(request: NextRequest) { - const body = await readRetentionRequest(request); - await assertPageAuthorized(body.passwordHash); - const results = await applyRetentionRequest(body); - return NextResponse.json({ results }, { status: results.every((item) => item.success) ? 200 : 207 }); - } - -复制 image-delete 的 APP_PASSWORD 哈希校验语义。preserve 必须通过 isValidImageFilename、path.dirname(resolved) === outputDir、lstat().isFile() 和 !lstat().isSymbolicLink();release 可删除不存在文件的旧标记。GET 仅返回 { filenames: string[] }。 - -- [ ] **Step 4: 删除成功后移除标记** - - await fs.unlink(filepath); - await retentionStore.remove([filename]); - deletionResults.push({ filename, success: true }); - -标记移除失败必须使该文件的删除结果显式失败,避免前端误报完全成功。 - -- [ ] **Step 5: 验证并提交** - -Run: npm test -- src/app/api/image-retention/route.test.ts src/app/api/image-delete/route.test.ts - -Expected: PASS,覆盖未授权、100 张上限、部分成功、符号链接、release 缺失文件和删除同步。 - - git add src/app/api/image-retention src/app/api/image-delete/route.ts src/app/api/image-delete/route.test.ts - git commit -m "feat(api): manage permanent webui image retention" - -### Task 4: 在历史面板实现选择模式和批量动作 - -**Files:** -- Modify: src/components/history-panel.tsx -- Modify: src/components/history-panel.test.tsx -- Modify: src/lib/i18n.tsx - -- [ ] **Step 1: 写入 HistoryPanel 失败测试** - - it('renders permanent-save selection controls only for fs history when cleanup is enabled', () => { - const html = renderHistoryPanel([fsHistoryItem, indexedDbHistoryItem], [], [], false, { - cleanupEnabled: true, - permanentlySavedFilenames: new Set([fsHistoryItem.images[0].filename]) - }); - assert.match(html, /aria-label="选择最近生成图片"/); - assert.match(html, /aria-label="已永久保存"/); - }); - -- [ ] **Step 2: 确认测试失败** - -Run: npm test -- src/components/history-panel.test.tsx --test-name-pattern='permanent-save selection' - -Expected: FAIL,因为 HistoryPanelProps 尚无清理状态与永久保存集合。 - -- [ ] **Step 3: 增加受控 props 和局部选择状态** - - type HistoryPanelProps = { - cleanupEnabled?: boolean; - permanentlySavedFilenames?: ReadonlySet; - onUpdatePermanentSave?: (action: 'preserve' | 'release', filenames: string[]) => Promise; - }; - - const [isSelectingRetention, setIsSelectingRetention] = React.useState(false); - const [selectedRetentionFilenames, setSelectedRetentionFilenames] = React.useState>(() => new Set()); - -只收集 storageModeUsed === 'fs' 的单个图片文件名;失败项和 IndexedDB 项不提供勾选。进入选择模式后显示 Checkbox;已永久保存图显示 Bookmark 标记;切换 Tab、退出选择模式和成功操作后清空选择。 - -- [ ] **Step 4: 实现固定批量栏和国际化文案** - - {isSelectingRetention ? ( -
- {t('retention.selectedCount', { count })} - - -
- ) : null} - -新增 retention.select、retention.exitSelection、retention.preserve、retention.release、retention.permanentlySaved、retention.selectedCount、retention.hint、retention.updateFailed 的中英文翻译。 - -- [ ] **Step 5: 验证并提交** - -Run: npm test -- src/components/history-panel.test.tsx - -Expected: PASS,覆盖禁用时隐藏入口、fs 与 indexeddb 分流、多图历史、已保存标记、选择数和移动端底栏类名。 - - git add src/components/history-panel.tsx src/components/history-panel.test.tsx src/lib/i18n.tsx - git commit -m "feat(ui): batch permanent image retention controls" - -### Task 5: 在页面协调运行时能力与永久保存 API - -**Files:** -- Modify: src/app/page.tsx -- Modify: src/app/page-regressions.test.tsx - -- [ ] **Step 1: 写入页面失败契约测试** - - it('loads permanent filenames only when fs cleanup is enabled and forwards batch actions to HistoryPanel', async () => { - const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8'); - assert.match(source, /fetch\('\/api\/image-retention'\)/); - assert.match(source, /onUpdatePermanentSave=/); - assert.match(source, /webuiImageCleanup\?\.enabled/); - }); - -- [ ] **Step 2: 确认测试失败** - -Run: npm test -- src/app/page-regressions.test.tsx --test-name-pattern='permanent filenames' - -Expected: FAIL,因为页面尚未读取永久保存状态。 - -- [ ] **Step 3: 实现加载与批量回调** - - const [permanentlySavedFilenames, setPermanentlySavedFilenames] = React.useState>(() => new Set()); - const cleanupEnabled = runtimeCapabilities?.webuiImageCleanup?.enabled === true; - - const updatePermanentSave = React.useCallback(async (action: 'preserve' | 'release', filenames: string[]) => { - const response = await fetch('/api/image-retention', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, filenames, ...(clientPasswordHash ? { passwordHash: clientPasswordHash } : {}) }) - }); - const body = await response.json(); - if (!response.ok && response.status !== 207) throw new Error(body.error || t('retention.updateFailed')); - setPermanentlySavedFilenames((current) => mergeRetentionResults(current, action, body.results)); - if (body.results.some((result: { success: boolean }) => !result.success)) throw new Error(t('retention.partialUpdateFailed')); - }, [clientPasswordHash, t]); - -仅在 cleanupEnabled 且存在 fs 历史时请求 GET;使用 AbortController 防止卸载后更新状态。成功手动删除图片后也从本地集合移除对应文件名。 - -- [ ] **Step 4: 验证并提交** - -Run: npm test -- src/app/page-regressions.test.tsx src/components/history-panel.test.tsx - -Expected: PASS。 - - git add src/app/page.tsx src/app/page-regressions.test.tsx - git commit -m "feat(page): sync permanent image retention state" - -### Task 6: 文档、全量门禁与 Docker 实测 - -**Files:** -- Modify: .env.example -- Modify: README.md -- Modify: CHANGELOG.md -- Modify: docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md - -- [ ] **Step 1: 补齐文档** - -README 说明永久保存仅在启用 WebUI 自动清理且使用文件系统存储时可用;用户可在“最近生成”批量设置或取消;手动删除同步移除标记;Agent artifact 生命周期不受影响。.env.example 保持自动清理默认关闭和 30 天默认值,不新增永久保存开关。 - -- [ ] **Step 2: 运行定向验证** - -Run: npm test -- src/lib/webui-image-retention-store.test.ts src/lib/webui-image-cleanup.test.ts src/lib/webui-image-cleanup-runtime.test.ts src/app/api/image-retention/route.test.ts src/app/api/image-delete/route.test.ts src/app/api/runtime-capabilities/route.test.ts src/components/history-panel.test.tsx src/app/page-regressions.test.tsx - -Run: npx tsc --noEmit - -Run: npm run lint - -Run: npm run lint:scripts - -Run: git diff --check - -Expected: 全部 PASS。 - -- [ ] **Step 3: 运行完整门禁** - -Run: npm run verify - -Expected: 输出 ok: true,测试、lint、脚本检查、生产构建和 diff 检查全部通过。 - -- [ ] **Step 4: Docker 隔离验证** - -在临时输出目录启动隔离容器,创建一张 31 天前的普通图片和一张 31 天前的永久保存图片,重启后执行: - - curl -fsS http://127.0.0.1:4783/api/runtime-capabilities | jq '.webuiImageCleanup.lastRun' - curl -fsS http://127.0.0.1:4783/api/image-retention - -Expected: 普通图片已删除,永久保存图片仍存在,lastRun.deletedCount 与 protectedCount 可跨工作进程读取,响应不包含绝对路径或文件名。 - -- [ ] **Step 5: 提交文档收尾** - - git add .env.example README.md CHANGELOG.md docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md - git commit -m "docs: document permanent webui image retention" - git status --short --branch - -Expected: 只保留本功能的原子提交;不提交 generated-images、.webui-state、日志、截图或临时 Docker 文件。 diff --git a/docs/superpowers/specs/2026-07-16-webui-image-auto-cleanup-design.md b/docs/superpowers/specs/2026-07-16-webui-image-auto-cleanup-design.md deleted file mode 100644 index 52ef9c8c55c75655a237a50ba89c47daad14a5cf..0000000000000000000000000000000000000000 --- a/docs/superpowers/specs/2026-07-16-webui-image-auto-cleanup-design.md +++ /dev/null @@ -1,87 +0,0 @@ -# WebUI 图片自动清理设计 - -## 背景 - -当前文件系统存储模式会将 WebUI 与 Agent API 生成的图片写入同一个 `generated-images/` 顶层目录。WebUI 图片没有保留期限,只能通过历史记录中的显式删除操作或人工清理目录删除。Agent API 产物由状态库记录,并按 `AGENT_REQUEST_TTL_SECONDS` 独立回收。 - -本功能为 WebUI 图片增加可选的自动清理能力。自动清理默认关闭,启用后默认保留 30 天,并且不能提前删除仍受 Agent TTL 管理的产物。 - -## 配置契约 - -新增以下服务端环境变量: - -```dotenv -WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=false -WEBUI_IMAGE_RETENTION_DAYS=30 -``` - -- `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 默认值为 `false`。 -- `WEBUI_IMAGE_RETENTION_DAYS` 默认值为 `30`,启用清理时必须是正整数。 -- 清理周期固定为 6 小时,服务启动时先执行一次。 -- 配置只在服务端生效,不在浏览器设置中提供全局破坏性开关。 -- 启用状态、保留天数、清理周期和最近一次清理结果通过运行能力接口暴露。 - -## 文件分类与清理边界 - -清理器只检查 `generated-images/` 顶层目录中的普通文件,并要求文件名符合现有图片文件名校验规则。以下内容始终排除: - -- Agent 状态库中仍存在 artifact 记录的文件。 -- `.agent-state/`、`.shares/`、`.app-logs/` 及其他子目录。 -- 符号链接、目录、非法文件名和非图片文件。 -- 修改时间未超过保留期限的文件。 - -清理顺序如下: - -1. 初始化 Agent 状态库并执行现有过期请求回收。 -2. 从 Agent 状态库读取仍存在的 artifact 文件路径,构建保护集合。 -3. 扫描输出目录顶层文件。 -4. 删除不在保护集合内且修改时间早于截止时间的合法图片文件。 -5. 记录扫描数、保护数、删除数、失败数、开始时间和完成时间。 - -现有未登记到 Agent 状态库的历史顶层图片按 WebUI 图片处理。这样可以覆盖当前存量,同时避免在 Agent 配置了超过 30 天 TTL 时提前删除其已登记产物。 - -## Agent API 四种请求方式 - -需要验证以下上游请求方式: - -- `images-non-stream` -- `images-sse` -- `responses-non-stream` -- `responses-sse` - -请求方式只影响上游传输。成功结果必须统一经过 Agent 图片持久化流程,并在返回响应前保存 artifact 元数据。测试矩阵需要证明每种请求方式满足以下契约: - -- 成功图片具有 Agent artifact 记录和合法文件路径。 -- WebUI 清理器跳过仍在 Agent 状态库中的文件。 -- Agent 请求超过自身 TTL 后,现有 Agent 回收逻辑删除 artifact 文件和元数据。 -- Agent TTL 超过 WebUI 的 30 天期限时,WebUI 清理器仍不会提前删除该文件。 - -## 运行模型 - -清理调度由 Node 服务启动流程注册,默认关闭时不创建定时器。启用时立即执行一次,并使用可释放的 6 小时定时器执行后续清理。注册过程必须防止同一进程重复创建调度器。 - -单个文件删除失败时继续处理其他候选文件,但必须记录明确错误和失败计数。目录读取、状态库读取或配置校验失败时不得报告清理成功,运行能力接口应保留最近失败结果。 - -## 模块边界 - -- 新增独立的 WebUI 图片清理模块,负责配置解析、候选筛选、Agent 保护和删除结果汇总。 -- Agent 状态存储接口新增只读的 artifact 文件路径枚举能力,并在 memory、sqlite、postgres 三种后端保持一致。 -- 启动 instrumentation 负责初始化和调度,不承载文件筛选逻辑。 -- 运行能力接口只序列化清理状态,不直接触发清理。 - -## 测试与验收 - -实现遵循测试先行,至少覆盖: - -- 默认关闭且不扫描、不删除文件。 -- 启用后默认保留 30 天。 -- 非法布尔值、非正整数保留天数显式失败。 -- 只删除超过期限且未受保护的顶层合法图片。 -- 不删除新图片、Agent artifact、子目录内容、符号链接和非法文件名。 -- 部分删除失败时返回失败计数并继续处理其他文件。 -- 三种 Agent 状态后端能够枚举 artifact 文件路径。 -- 四种 Agent 请求方式生成的 artifact 均受到保护并最终由 Agent TTL 回收。 -- 启动调度默认关闭,启用后立即执行且避免重复注册。 -- 运行能力接口返回实际开关、30 天保留期和最近清理结果。 - -完成后执行相关定向测试,再执行 `npm run verify`、`npx tsc --noEmit` 和 Docker 重建后的真实 HTTP 验证。Docker 当前配置保持默认关闭。 diff --git a/docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md b/docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md deleted file mode 100644 index 79be6c08fd40c73f6b52d80f836186c5697f0be8..0000000000000000000000000000000000000000 --- a/docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md +++ /dev/null @@ -1,91 +0,0 @@ -# WebUI 永久图片保留设计 - -日期:2026-07-17 - -## 目标 - -当 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=true` 时,用户可以在 WebUI 的“最近生成”面板中选择一张或多张文件系统图片,将其标记为永久保存。永久保存的图片不会被 WebUI 自动清理;用户手动删除图片或取消永久保存后,标记失效。 - -同时,自动清理的最近执行摘要必须能在 Docker 的独立 Next 工作进程之间读取,使 `/api/runtime-capabilities` 返回实际执行结果。 - -## 范围与非目标 - -- 仅处理 `generated-images/` 中由 WebUI 使用文件系统存储的有效图片文件。 -- `indexeddb` 图片不进入服务端清理和永久保存 API。 -- Agent API artifact 继续由 `AGENT_REQUEST_TTL_SECONDS` 管理;WebUI 永久保存不改变 Agent artifact 的 TTL 或删除语义。 -- “永久”仅指永久排除 WebUI 自动清理,不是额外复制、跨机器备份或对手动删除的保护。 -- 手动删除图片时必须同步删除永久保存标记。 - -## 数据模型 - -在挂载的输出目录中创建独立状态库: - -```text -generated-images/.webui-state/webui-image-retention.sqlite -``` - -状态库仅保存文件名、时间和已脱敏的执行摘要,不保存 API Key、提示词、绝对路径或上游响应。 - -表: - -- `webui_image_retention` - - `filename`:有效图片文件名,主键。 - - `saved_at`:用户设置永久保存的 ISO 时间。 -- `webui_image_cleanup_status` - - 单行状态,保存 `last_run_json`、`last_error` 和 `updated_at`。 - -每次批量更新使用 SQLite transaction;启用 WAL 和合理的 busy timeout,避免 Next 多工作进程并发读写时丢失标记。状态库位于隐藏子目录,不属于自动清理扫描范围。 - -## 服务端接口 - -新增页面 API: - -- `GET /api/image-retention` - - 返回当前永久保存的文件名集合。 -- `POST /api/image-retention` - - 请求体:`{ "action": "preserve" | "release", "filenames": string[] }`。 - - 单次最多 100 张,去重后处理。 - - `preserve` 仅接受存在、常规文件、位于输出目录顶层且文件名合法的 WebUI 图片。 - - `release` 可清理有效文件名的已有标记,即使对应文件已不存在。 - - 使用与页面图片删除相同的访问控制。 - - 返回已变更文件名、未变更文件名和被拒绝项的结构化摘要;不返回绝对路径。 - -现有 `POST /api/image-delete` 在文件删除成功后移除对应永久保存标记。若文件已删除或本已不存在,但标记移除失败,结果会明确区分物理文件状态(`fileDeleted` 或 `fileAbsent`)与 `markerRemoved: false`;页面可以收敛不存在的历史图片,但不能把本地永久标记误报为已解除。 - -自动清理运行时读取永久保存文件名,并将它们与仍登记的 Agent artifact 路径合并为保护集合。`protectedCount` 继续表示两类受保护文件总数。同一服务进程内,批量保存、手动删除和自动清理按文件名串行;清理在实际删除前会在锁内重新读取永久标记。 - -## 清理摘要跨进程可见性 - -清理调度启动和运行的工作进程将已脱敏的 `lastRun` 或固定 `lastError` 写入 `webui_image_cleanup_status`。数据库使用稳定失败状态码,读取时映射为固定公开文案,并兼容已写入的旧文案。`getWebuiImageCleanupSummary()` 优先使用本进程内存状态,缺失时从 SQLite 读取。 - -`/api/runtime-capabilities` 因此可在独立请求工作进程中显示最近一次清理的状态、时间和计数,但永远不暴露文件名、绝对路径或删除失败细节。 - -## 前端交互 - -入口位于右侧“最近生成”标题栏,仅当运行时能力声明 WebUI 文件系统自动清理已启用、页面访问控制已完成且当前文件系统输出范围的永久标记已成功读取时显示。普通历史新增、删除或反馈更新不会卸载已确认的标记集合;退出登录、访问范围变化或请求取消时不会复用旧结果。 - -1. 点击“选择”进入选择模式。 -2. 每个可管理的文件系统缩略图显示勾选框;已永久保存图片显示低干扰标记。旧历史缺失 `storageModeUsed` 时按既有兼容语义视为文件系统存储。 -3. 底部固定操作栏显示已选数量,并提供“永久保存”和“取消保存”。 -4. 批量请求完成后,只有服务端确认成功的图片更新标记;失败项保留选择并显示明确错误。 -5. 退出选择模式或切换历史标签时清空临时选择。 -6. 移动端复用同一选择状态和底部操作栏;不在日常浏览状态增加缩略图按钮。 - -历史条目可能包含多张图片,选择粒度是单个图片文件名,而不是整条历史记录。 - -## 错误与边界 - -- 无效、跨目录、符号链接、缺失或非普通文件的永久保存请求显式拒绝。 -- 自动清理只删除顶层、合法、超过保留期且不在永久保存或 Agent artifact 保护集合中的图片。 -- SQLite 不可用、状态格式异常或数据库写入失败必须显式记录并让相应 API 或启动流程失败,不静默丢弃标记。 -- 手动删除、批量保存和自动清理在同一服务进程内按文件名锁定;以锁内的文件存在性检查和事务结果为准,不会为不存在的图片新增永久保存标记。 - -## 验收与测试 - -- 状态库的批量 preserve/release、去重、持久化、并发事务和非法文件名测试。 -- 清理测试覆盖永久保存、Agent artifact、二者重叠、手动删除清理标记和 30 天边界。 -- 页面 API 测试覆盖访问控制、批量上限、部分成功和路径安全。 -- `HistoryPanel` 测试覆盖选择模式、单图选择、批量操作可达性、已永久保存标记和移动端渲染。 -- 页面与客户端契约测试覆盖运行时开关、文件系统默认值、历史变化时选择保持、批量响应的成功项合并、手动删除后的物理文件与标记状态分离,以及畸形 API 响应拒绝。 -- 运行时能力测试覆盖在内存状态重置后仍从 SQLite 读取已脱敏的最近清理摘要。 -- Docker 验证:启动清理摘要可从 `GET /api/runtime-capabilities` 读取;永久保存的过期图片不会被删除;取消保存后按清理规则可删除。 diff --git a/docs/ui/literary-young-women-workbench-design.md b/docs/ui/literary-young-women-workbench-design.md index 60bc78946280c7abf03040f7f6e2e428e2ed8a38..d6cdaf5d0af981945680d5feb78191514589ac78 100644 --- a/docs/ui/literary-young-women-workbench-design.md +++ b/docs/ui/literary-young-women-workbench-design.md @@ -2,7 +2,7 @@ ## 1. 定稿结论 -本项目 Web UI 后续重构以 `女青年文艺风功能交互稿` 为唯一设计基线。 +本项目网页界面(Web UI)后续重构以 `女青年文艺风功能交互稿` 为唯一设计基线。 - 当前实现名称:`图像手记 / Visual Journal` 工作台。 - 定稿图:`/tmp/gipc-literary-young-women-functional-v5.png` @@ -13,7 +13,7 @@ 产品边界以 `docs/product/product-contract.md` 为准;本文档只定义工作台信息架构和视觉交互基线,不把审美画像当作真实用户证据。首战验证必须落在真实发布任务、可下载结果、继续编辑或复用、以及最近生成里的 `可用` / `需修改` 结果反馈。 -本基线明确排除古风、传统器物化、普通 SaaS 仪表盘和过度 AI 科技风。 +本基线明确排除古风、传统器物化、普通软件即服务(SaaS)仪表盘和过度 AI 科技风。 ## 2. 目标用户与气质 @@ -30,7 +30,7 @@ - 古风、国风器物、传统书画、宣纸卷轴、印章、毛笔、砚台、灯笼、宫廷或寺庙感。 - 深色科技仪表盘、霓虹、赛博朋克、玻璃拟态、紫色渐变。 -- 企业后台、金融 SaaS、模板化 bento grid。 +- 企业后台、金融软件即服务(SaaS)、模板化模块卡片网格。 - 低幼粉色、过度贴纸化、装饰大于功能。 ## 3. 视觉系统 @@ -86,7 +86,7 @@ - 中心画布最大,操作围绕画布服务。 - 左侧信息密度中等,保持创作单的顺序。 - 右侧卡片轻量,避免历史列表压迫主画布。 -- 不使用超大营销标题,不做 landing page。 +- 不使用超大营销标题,不做宣传落地页。 ### 3.4 圆角与阴影 @@ -170,7 +170,7 @@ 模块标题:`写下灵感` -提示词输入区域应像现代笔记本或创作卡片,而不是普通后台 textarea。 +提示词输入区域应像现代笔记本或创作卡片,而不是普通后台多行文本框。 示例文案方向: @@ -228,9 +228,9 @@ 内容: -- 状态 chip:`gpt-image-2` -- 状态 chip:`自动` -- 请求数量 chip:`请求 1 张图片` +- 状态标签:`gpt-image-2` +- 状态标签:`自动` +- 请求数量标签:`请求 1 张图片` - 主按钮:`生成图像` - 次级操作:`存为灵感` - 次级操作:`随便来点` @@ -371,7 +371,7 @@ - 专业模式可展开,但不要一次铺满所有字段。 - 工程词在主界面减少暴露,专业模式中可保留必要精确项。 - 当前选择会影响输出、请求方式或稳定性时,应显示解释。 -- 当前实现中,桌面端高级设置位于底部 Pro Dock。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。 +- 当前实现中,桌面端高级设置位于底部专业设置栏。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。 - 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。 ## 10. 模式交互 @@ -492,17 +492,17 @@ - `省心模式` - `专业模式` -避免文案: +避免直接使用英文界面词: -- `Generate` -- `Edit` -- `Debug` -- `Routing` -- `Backend` -- `SSE` -- `Responses` +- 生成 +- 编辑 +- 调试 +- 路由 +- 后端 +- 流式传输 +- 响应接口 -必要技术名可以放入专业模式,例如 `Responses`、`SSE`、`image backend`,但主界面应转译为用户可理解的状态。 +必要技术名可以放入专业模式,但主界面应转译为用户可理解的状态。 ## 13. 实现注意 diff --git a/package-lock.json b/package-lock.json index a14a9316b1f9a5a17ea2fef2344e19990a625315..2ad785a00df635ddbf764e79388dc89bbafba8ed 100755 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "gpt-image-playground", - "version": "2.2.0", + "name": "visual-journal", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "gpt-image-playground", - "version": "2.2.0", + "name": "visual-journal", + "version": "2.3.0", "license": "MIT", "dependencies": { "@next/env": "^16.2.10", @@ -4178,9 +4178,9 @@ }, "node_modules/brace-expansion-modern": { "name": "brace-expansion", - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -6319,9 +6319,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -6869,9 +6869,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -8938,11 +8938,11 @@ }, "vendor/brace-expansion-compat": { "name": "brace-expansion", - "version": "5.0.8", + "version": "5.0.9", "dev": true, "license": "MIT", "dependencies": { - "brace-expansion-modern": "npm:brace-expansion@5.0.8" + "brace-expansion-modern": "npm:brace-expansion@5.0.9" }, "engines": { "node": ">=20" diff --git a/package.json b/package.json index 9b8b9cc959a4d72ce485797c9e194e871acaa2a9..658ca1189e27c471ba071f81009c8538565e2d1b 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "gpt-image-playground", - "version": "2.2.0", + "name": "visual-journal", + "version": "2.3.0", "license": "MIT", "private": true, "allowScripts": { @@ -99,6 +99,8 @@ "overrides": { "postcss": "^8.5.18", "brace-expansion": "$brace-expansion", + "js-yaml": "^4.3.1", + "nanoid": "^3.3.17", "sharp": "$sharp" } } diff --git a/public/hf-space-deploy-marker.json b/public/hf-space-deploy-marker.json index 050424b3e0cc087326f4efb76701342449d39947..f6b1400c53bd6ffc0209001fcf1d1ac4206fc94c 100644 --- a/public/hf-space-deploy-marker.json +++ b/public/hf-space-deploy-marker.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "local_sha": "555483031327561b81a266eb15c58ed3019536b7", - "created_at": "2026-07-29T04:53:28.602Z", - "deploy_id": "e74cf06b-8aed-4779-b443-772bcb0ee272" + "local_sha": "9d61ce95a120e0356988adfca282e04c52ea4364", + "created_at": "2026-08-13T10:16:56.322Z", + "deploy_id": "c95caa4a-cdaf-4024-b6fa-df7effcc28a9" } diff --git a/scripts/agent-doctor.mjs b/scripts/agent-doctor.mjs index 34e95a4e71ccb326ba8e3336b299d18c2eb90831..72c4dfe37a5294e45a35e7e688c71d59b9073a43 100644 --- a/scripts/agent-doctor.mjs +++ b/scripts/agent-doctor.mjs @@ -2,7 +2,7 @@ import { loadPrivateAgentEnvFile, resolvePlaygroundBaseUrl -} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; +} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs'; import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs'; import { isMainModule, @@ -15,10 +15,10 @@ import { import { fileURLToPath } from 'node:url'; const GENERATE_SCRIPT = fileURLToPath( - new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url) + new URL('../skills/visual-journal-image-agent/scripts/generate-image.mjs', import.meta.url) ); const EDIT_SCRIPT = fileURLToPath( - new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url) + new URL('../skills/visual-journal-image-agent/scripts/edit-image.mjs', import.meta.url) ); const AGENT_DOCTOR_TIMEOUT_MS = 75_000; const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k'; diff --git a/scripts/agent-skill-scripts.test.mjs b/scripts/agent-skill-scripts.test.mjs index d5d2c66d10f7cf851581b078d04acf98ac3e5405..5605edc270ee3e4d0c49747cbded2d0030328fa4 100644 --- a/scripts/agent-skill-scripts.test.mjs +++ b/scripts/agent-skill-scripts.test.mjs @@ -1,15 +1,27 @@ -import { AGENT_ENDPOINTS } from '../skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs'; -import { enrichFailureWithAgentDiagnostics } from '../skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs'; -import { AGENT_ENDPOINTS as SERVER_AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs'; +import { AGENT_ENDPOINTS } from '../skills/visual-journal-image-agent/scripts/lib/agent-api-paths.mjs'; +import { enrichFailureWithAgentDiagnostics } from '../skills/visual-journal-image-agent/scripts/lib/agent-diagnostics-summary.mjs'; import { parseRetryAfterValue, readCapabilitiesImageTransportTimeoutMs, - resolveSameOriginUrl -} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; + resolveSameOriginUrl, + validateAgentEditRequestAgainstCapabilities, + validateAgentGenerateRequestAgainstCapabilities +} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs'; +import { AGENT_ENDPOINTS as SERVER_AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs'; import { FIXTURE_IMAGE_BASE64 } from './local-image-upstream-fixture.mjs'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs'; import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -17,8 +29,8 @@ import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); -const skillRoot = join(repoRoot, 'skills/gpt-image-playground-agent'); -const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts'); +const skillRoot = join(repoRoot, 'skills/visual-journal-image-agent'); +const skillScriptsRoot = join(repoRoot, 'skills/visual-journal-image-agent/scripts'); const localUpstreamProbeTimeoutMs = '5000'; function agentGenerateCapabilities(extra = {}) { @@ -31,6 +43,15 @@ function agentGenerateCapabilities(extra = {}) { agent_jobs: { supported: true, mode: 'job_polling' }, limits: { generate_images: { min: 1, max: 4 }, + edit_images: { min: 1, max: 4 }, + generate_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + edit_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, partial_images: { min: 0, max: 4 }, partial_images_by_backend: { 'images-api': { min: 0, max: 4 }, @@ -44,6 +65,91 @@ function agentGenerateCapabilities(extra = {}) { } describe('Agent skill script argument validation', () => { + it('prioritizes backend-specific output limits and falls back for older capabilities', () => { + const capabilities = agentGenerateCapabilities(); + + assert.doesNotThrow(() => + validateAgentGenerateRequestAgainstCapabilities({ n: 4, image_backend: 'images-api' }, capabilities) + ); + assert.throws( + () => + validateAgentGenerateRequestAgainstCapabilities( + { n: 2, image_backend: 'responses-image-generation' }, + capabilities + ), + /n 必须在当前 capabilities 允许的 1 到 1 之间/ + ); + assert.throws( + () => + validateAgentEditRequestAgainstCapabilities( + { n: 2, image_backend: 'responses-image-generation' }, + capabilities + ), + /n 必须在当前 capabilities 允许的 1 到 1 之间/ + ); + + const legacyCapabilities = { + limits: { + generate_images: { min: 1, max: 4 }, + edit_images: { min: 1, max: 4 } + } + }; + assert.doesNotThrow(() => + validateAgentGenerateRequestAgainstCapabilities( + { n: 4, image_backend: 'responses-image-generation' }, + legacyCapabilities + ) + ); + assert.doesNotThrow(() => + validateAgentEditRequestAgainstCapabilities( + { n: 4, image_backend: 'responses-image-generation' }, + legacyCapabilities + ) + ); + }); + + it('uses the public partial image range for requests that resolve to non-stream mode', () => { + const capabilities = agentGenerateCapabilities({ + upstream_request_headers: { + channels: [{ request_modes: ['images-non-stream'] }] + }, + limits: { + ...agentGenerateCapabilities().limits, + partial_images_by_backend: { + 'images-api': { min: 1, max: 3 }, + 'responses-image-generation': { min: 1, max: 3 } + } + } + }); + + assert.doesNotThrow(() => + validateAgentGenerateRequestAgainstCapabilities( + { n: 1, partial_images: 0, image_backend: 'images-api', stream_mode: 'non_stream' }, + capabilities + ) + ); + assert.doesNotThrow(() => + validateAgentEditRequestAgainstCapabilities( + { + n: 1, + partial_images: 4, + image_backend: 'images-api', + stream_mode: 'auto', + streaming_strategy: 'off' + }, + capabilities + ) + ); + assert.throws( + () => + validateAgentGenerateRequestAgainstCapabilities( + { n: 1, partial_images: 4, image_backend: 'images-api', stream_mode: 'auto' }, + { ...capabilities, upstream_request_headers: { channels: [{ request_modes: ['images-sse'] }] } } + ), + /partial_images 必须在当前 capabilities 允许的 1 到 3 之间/ + ); + }); + it('rejects invalid generate numeric options before dry-run output', () => { const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']); @@ -312,10 +418,10 @@ describe('Agent skill script argument validation', () => { assert.equal(boundedBody.verification_scope.service_base_url, 'http://localhost:4783'); const projectCopyRoot = join(parentRoot, 'project-copy'); - mkdirSync(join(projectCopyRoot, 'skills/gpt-image-playground-agent'), { recursive: true }); + mkdirSync(join(projectCopyRoot, 'skills/visual-journal-image-agent'), { recursive: true }); mkdirSync(join(projectCopyRoot, 'nested'), { recursive: true }); - writeFileSync(join(projectCopyRoot, 'package.json'), JSON.stringify({ name: 'gpt-image-playground' })); - writeFileSync(join(projectCopyRoot, 'skills/gpt-image-playground-agent/SKILL.md'), '# skill\n'); + writeFileSync(join(projectCopyRoot, 'package.json'), JSON.stringify({ name: 'visual-journal' })); + writeFileSync(join(projectCopyRoot, 'skills/visual-journal-image-agent/SKILL.md'), '# skill\n'); const projectBounded = runSkillScript( 'generate-image.mjs', ['prompt'], @@ -348,7 +454,7 @@ describe('Agent skill script argument validation', () => { ['prompt'], {}, { - cwd: join(projectCopyRoot, 'skills/gpt-image-playground-agent/scripts'), + cwd: join(projectCopyRoot, 'skills/visual-journal-image-agent/scripts'), loadPrivateAgentEnv: true, createCwd: true } @@ -4602,6 +4708,7 @@ describe('Agent skill script argument validation', () => { assert.ok(frontmatter.description.length > 0); assert.ok(frontmatter.description.length <= 1024); assert.doesNotMatch(frontmatter.description, /[<>]/); + assert.match(frontmatter.description, /替代 Codex 内置的通用生图 Skill/); }); it('tells agents to use bundled scripts instead of ad hoc API callers', () => { @@ -4610,25 +4717,30 @@ describe('Agent skill script argument validation', () => { const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/); + assert.match(skillText, /替代 Codex 内置的通用生图 Skill/); assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/); - assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); + assert.match(skillText, /不要临时编写脚本、curl 命令或手写 fetch\/FormData/); assert.match(openAiYaml, /先选择并运行内置脚本/); + assert.match(openAiYaml, /替代 Codex 内置的通用生图 Skill/); assert.match(openAiYaml, /不要临时编写 API 调用脚本/); assert.match(apiReference, /先使用这些内置脚本/); assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/); - assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); + assert.match(apiReference, /不要临时编写 Node、Python 或 shell 脚本、curl 命令或手写 fetch\/FormData/); }); - it('documents backend-specific partial image limits in dedicated Agent docs', () => { + it('documents backend-specific image output and preview limits in dedicated Agent docs', () => { const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); assert.match(skillText, /limits\.partial_images_by_backend\[image_backend\]/); + assert.match(skillText, /limits\.generate_images_by_backend\[image_backend\]/); + assert.match(skillText, /limits\.edit_images_by_backend\[image_backend\]/); + assert.match(skillText, /`responses-image-generation` 当前生成和页面 SSE 编辑都只允许 `n=1`/); assert.match( skillText, /Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API\/profile 范围校验/ ); - assert.match(skillText, /Responses image_generation edit 属于页面 SSE 路径/); + assert.match(skillText, /Responses image_generation 编辑属于页面 SSE 路径/); assert.match( skillText, /Docker compose 本身不设置这两个默认值,未配置 `\.env\.local` 时仍是 `images-api` 和 `auto`/ @@ -4642,6 +4754,9 @@ describe('Agent skill script argument validation', () => { assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/); assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/); assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/); + assert.match(apiReference, /limits\.generate_images_by_backend/); + assert.match(apiReference, /limits\.edit_images_by_backend/); + assert.match(apiReference, /`responses-image-generation` 当前两种操作都只允许 `n=1`/); assert.match(apiReference, /只代表“声明支持”,不代表当前渠道每次实测都能成功/); assert.match(apiReference, /如果 `selected_channel_id`、`upstream_host` 为空/); assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/); @@ -4709,7 +4824,7 @@ describe('Agent skill script argument validation', () => { assert.match(skillText, /channelQueue\.capacityPerCredential/); assert.match(skillText, /输出格式固定为 Agent WebP 契约/); assert.match(skillText, /Agent edit 只是对照路径,不保证与页面 SSE 的像素尺寸完全一致/); - assert.match(skillText, /复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时/); + assert.match(skillText, /复杂 UI、长提示词、高质量图生图遇到 5 分钟级超时/); assert.match(skillText, /Codex 会话日志会持久保存命令输出/); assert.match(skillText, /npm run env:summary/); assert.match(skillText, /verification_scope\.mode=local_planning_only/); @@ -4792,23 +4907,23 @@ describe('Agent skill script argument validation', () => { assert.match( readmeText, - /新增 probe、diagnostics 或路由可观测能力时,先落 API \/ capabilities \/ OpenAPI 契约/ + /新增探针、诊断或路由可观测能力时,先落 API、能力声明和 OpenAPI 契约/ ); assert.match(readmeText, /Skill 脚本做薄封装/); - assert.match(skillText, /新增 probe、diagnostics、路由健康或请求旅程能力时/); + assert.match(skillText, /新增探针、诊断、路由健康或请求旅程能力时/); assert.match(skillText, /GET \/api\/agent\/capabilities/); assert.match(skillText, /GET \/api\/agent\/openapi\.json/); assert.match(skillText, /\/api\/agent\/diagnostics\/\*/); assert.match(skillText, /Skill 脚本只做薄封装/); - assert.match(skillText, /不能复制页面 API、运行态 API 和 Agent API 的边界判断/); - assert.match(apiReference, /新增 probe、diagnostics 或健康摘要时/); + assert.match(skillText, /不能复制页面 API、运行时 API 和 Agent API 的边界判断/); + assert.match(apiReference, /新增探针、诊断或健康摘要时/); assert.match(apiReference, /capabilities、OpenAPI 或明确的 Agent 只读端点/); assert.match(apiReference, /不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑/); }); it('runs from a copied standalone skill directory outside the repository', () => { - const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-playground-agent-')); - const copiedSkillRoot = join(tempRoot, 'gpt-image-playground-agent'); + const tempRoot = mkdtempSync(join(tmpdir(), 'visual-journal-image-agent-')); + const copiedSkillRoot = join(tempRoot, 'visual-journal-image-agent'); try { cpSync(skillRoot, copiedSkillRoot, { recursive: true }); writeFileSync( @@ -8136,7 +8251,7 @@ describe('Agent skill script argument validation', () => { transport: 'agent_json', endpoint: '/api/agent/images/generate', request_headers: { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false } } @@ -8197,7 +8312,7 @@ describe('Agent skill script argument validation', () => { assert.equal(body.agent_requests[0].diagnostics.response.timing.elapsed_ms, 61234); assert.equal( body.agent_requests[0].diagnostics.response.execution.request_headers.user_agent_effective, - 'visual-journal/2.2.0' + 'visual-journal/2.3.0' ); assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key'); assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns'); @@ -8614,10 +8729,7 @@ describe('Agent skill script argument validation', () => { assert.equal(body.snapshot.channels[0].channel_id, 'primary'); assert.deepEqual( requests.map((item) => `${item.method} ${item.url}`), - [ - 'GET /playground/api/agent/capabilities', - 'GET /playground/api/agent/diagnostics/channel-health' - ] + ['GET /playground/api/agent/capabilities', 'GET /playground/api/agent/diagnostics/channel-health'] ); assert.equal(requests[0].authorization, 'Bearer channel-health-token'); assert.equal(requests[1].authorization, 'Bearer channel-health-token'); diff --git a/scripts/channel-capability-matrix.test.mjs b/scripts/channel-capability-matrix.test.mjs index f54ba03a12da3327e67f8bf26dbb0e83ac4335c6..806101e4896724d57a9b1a0d5a9f9ed7d1f454ec 100644 --- a/scripts/channel-capability-matrix.test.mjs +++ b/scripts/channel-capability-matrix.test.mjs @@ -14,10 +14,10 @@ import { import { buildChannelEnvConfig, buildRedactedChannelEnvPreview -} from '../skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs'; +} from '../skills/visual-journal-image-agent/scripts/lib/channel-capability-matrix.mjs'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); -const matrixScript = join(repoRoot, 'skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs'); +const matrixScript = join(repoRoot, 'skills/visual-journal-image-agent/scripts/channel-capability-matrix.mjs'); const testApiKey = 'test-upstream-token'; const testResponsesModel = 'gpt-5.4'; diff --git a/scripts/check-version-metadata.mjs b/scripts/check-version-metadata.mjs index 39af190d90d909861c2aec667ad47fd6c4b60978..bc33366b3a30f7910added88f74bf2b4b7836b5b 100644 --- a/scripts/check-version-metadata.mjs +++ b/scripts/check-version-metadata.mjs @@ -21,7 +21,7 @@ function escapeRegExp(value) { } function readReadmeBadgeVersion(readme) { - const match = readme.match(/!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-(.+?)-blue\)/); + const match = readme.match(/!\[(?:Version|版本)\]\(https:\/\/img\.shields\.io\/badge\/version-(.+?)-blue\)/); return match?.[1]; } diff --git a/scripts/dimension-check.test.mjs b/scripts/dimension-check.test.mjs index 6aae6ea75fb2452daae726510df907b482086be5..97ca8ef925b3b0e08aee2dbd4192a76b18d2def7 100644 --- a/scripts/dimension-check.test.mjs +++ b/scripts/dimension-check.test.mjs @@ -5,7 +5,7 @@ import { isDimensionCheckError, parseExpectedDimensions, sanitizeImageResponse -} from '../skills/gpt-image-playground-agent/scripts/lib/dimension-check.mjs'; +} from '../skills/visual-journal-image-agent/scripts/lib/dimension-check.mjs'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { test } from 'node:test'; diff --git a/scripts/first-run.mjs b/scripts/first-run.mjs index ffff94d06467852ef1c00de2180480953c5a1c1f..a216a30c6d0973fab3467dc492552cab5157f64f 100644 --- a/scripts/first-run.mjs +++ b/scripts/first-run.mjs @@ -2,7 +2,7 @@ import { loadPrivateAgentEnvFile, resolvePlaygroundBaseUrl -} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; +} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs'; import { isMainModule, printJson, redactBaseUrl } from './command-center-utils.mjs'; import { inspectDependencyInstallation, LOCAL_DEPENDENCY_INSTALL_COMMAND } from './dependency-installation.mjs'; import { summarizeEnvFile } from './env-summary.mjs'; diff --git a/scripts/image-dimensions.test.mjs b/scripts/image-dimensions.test.mjs index b875543c761012847cef0139676af5ebed854da0..22b1226018e70e62e0f39bec8ea9a1ebf6b145be 100644 --- a/scripts/image-dimensions.test.mjs +++ b/scripts/image-dimensions.test.mjs @@ -1,4 +1,4 @@ -import { readImageDimensions } from '../skills/gpt-image-playground-agent/scripts/lib/image-dimensions.mjs'; +import { readImageDimensions } from '../skills/visual-journal-image-agent/scripts/lib/image-dimensions.mjs'; import assert from 'node:assert/strict'; import { test } from 'node:test'; diff --git a/scripts/page-form-streaming.test.mjs b/scripts/page-form-streaming.test.mjs index c067965a0d3a4e9627cd59a1591a5d18c691457b..ed3e6bfe0e74fceb902c8a719b88ef5433cef113 100644 --- a/scripts/page-form-streaming.test.mjs +++ b/scripts/page-form-streaming.test.mjs @@ -8,7 +8,7 @@ import { describe, it } from 'node:test'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); -const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts'); +const skillScriptsRoot = join(repoRoot, 'skills/visual-journal-image-agent/scripts'); describe('Page form streaming defaults', () => { it('does not force stream=true for page generate JSON responses', async () => { diff --git a/scripts/product-branding.test.mjs b/scripts/product-branding.test.mjs index de62943b596ec500268c7a8f55f7f37dd007155c..66030c022c5967c3fb857ba289e1c6e352d1d9ca 100644 --- a/scripts/product-branding.test.mjs +++ b/scripts/product-branding.test.mjs @@ -63,8 +63,15 @@ describe('product branding', () => { it('uses the formal product name in operational status while retaining the package identifier', () => { const status = readRepositoryText('scripts/status.mjs'); + const packageJson = JSON.parse(readRepositoryText('package.json')); + const skill = readRepositoryText('skills/visual-journal-image-agent/SKILL.md'); + const skillConfig = readRepositoryText('skills/visual-journal-image-agent/agents/openai.yaml'); assert.equal(FORMAL_PRODUCT_NAME, '图像手记 / Visual Journal'); + assert.equal(packageJson.name, 'visual-journal'); + assert.match(skill, /^name: visual-journal-image-agent$/m); + assert.match(skillConfig, /display_name: "Visual Journal Image Agent API"/); + assert.match(skillConfig, /\$visual-journal-image-agent/); assert.match(status, /product: FORMAL_PRODUCT_NAME,/); assert.match(status, /package_name: packageJson\.name,/); }); diff --git a/scripts/smoke-hf-space-memory.mjs b/scripts/smoke-hf-space-memory.mjs index 6ed0d11838268815b2eaaa9aaa082e2a9b9374de..4661860d9bf969a6c1b5f488bf1b866a8774edbf 100644 --- a/scripts/smoke-hf-space-memory.mjs +++ b/scripts/smoke-hf-space-memory.mjs @@ -132,14 +132,14 @@ try { const runtime = await fetchJson('/api/runtime-capabilities'); assertEqual(typeof runtime.streamingBatch?.enabled, 'boolean', 'Runtime capabilities shape'); - run('node', ['skills/gpt-image-playground-agent/scripts/generate-image.mjs'], { + run('node', ['skills/visual-journal-image-agent/scripts/generate-image.mjs'], { env: { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: token, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' } }); - run('node', ['skills/gpt-image-playground-agent/scripts/edit-image.mjs'], { + run('node', ['skills/visual-journal-image-agent/scripts/edit-image.mjs'], { env: { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: token, diff --git a/scripts/smoke-image-upstream-real.mjs b/scripts/smoke-image-upstream-real.mjs index b94def083489bfb9f29b2db379b7ac0a75caba0c..933c0455aca65cc5d862bcad199a76593cc101d3 100644 --- a/scripts/smoke-image-upstream-real.mjs +++ b/scripts/smoke-image-upstream-real.mjs @@ -56,6 +56,15 @@ const CASES = [ } ]; const SERVER_CHANNEL_CASES = [ + { + id: 'server-channel-agent-images-json', + aliases: ['images-json', 'images-non-stream'], + prefix: 'IMAGE_REAL_SMOKE_SERVER', + requestMode: 'images-non-stream', + stream: false, + serverChannel: true, + endpoint: 'agent-generate' + }, { id: 'server-channel-images-json', aliases: ['images-json', 'images-non-stream'], @@ -451,10 +460,48 @@ async function runCase(loadRouteHandlersForBillable, testCase, preflight = {}) { } async function runBillableCaseAfterLoadingHandlers(loadRouteHandlersForBillable, testCase, target, startedAt, signal) { + if (target.serverChannel && env('IMAGE_REAL_SMOKE_SERVER_TRANSPORT') !== 'in-process') { + return runBillableHttpCase(testCase, target, startedAt, signal); + } const routeHandlers = await loadRouteHandlersForBillable(testCase); return runBillableCase(routeHandlers, testCase, target, startedAt, signal); } +async function runBillableHttpCase(testCase, target, startedAt, signal) { + const outputFilesBefore = snapshotRealSmokeOutputFiles(); + if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') { + process.env.OPENAI_RESPONSES_API_MODEL = target.responsesModel; + } + try { + const request = + testCase.endpoint === 'agent-generate' + ? agentGenerateRequest(testCase, target, signal) + : imageRequest(testCase, target, signal); + const response = await fetch(request); + const summary = + testCase.endpoint === 'agent-generate' + ? await summarizeAgentResponse(response) + : await summarizeResponse(response); + if (testCase.endpoint === 'agent-generate') { + await deleteHttpAgentArtifacts(target, summary.artifact_ids); + } + return { + id: testCase.id, + request_mode: testCase.requestMode, + ok: isSuccessfulBillableSmokeResponse(response, summary), + status: response.status, + elapsed_ms: Date.now() - startedAt, + server_channel: true, + upstream_host: summary.upstream_host || readHost(target.baseUrl), + ...(summary.selected_channel_id ? { selected_channel_id: summary.selected_channel_id } : {}), + ...(summary.channel_request_mode ? { channel_request_mode: summary.channel_request_mode } : {}), + ...summary + }; + } finally { + removeNewRealSmokeOutputFiles(outputFilesBefore); + } +} + async function runBillableCase(routeHandlers, testCase, target, startedAt, signal) { const outputFilesBefore = snapshotRealSmokeOutputFiles(); if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') { @@ -530,6 +577,10 @@ function readTarget(testCase) { requiresResponsesModel, baseUrl: serverBaseUrl?.value, baseUrlKey: serverBaseUrl?.key, + serviceBaseUrl: + env(`${basePrefix}_SERVICE_BASE_URL`) || + env('GPT_IMAGE_PLAYGROUND_URL') || + 'http://127.0.0.1:4783', hasServerCredential: Boolean(env('OPENAI_API_KEY') || readFirstConfiguredServerApiKeys()), model: env(`${basePrefix}_MODEL`) || 'gpt-image-2', responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'), @@ -765,11 +816,13 @@ function imageRequest(testCase, target, signal) { formData.append('stream', 'true'); formData.append('partial_images', '2'); } - return new Request('http://localhost/api/images', { method: 'POST', body: formData, signal }); + const serviceBaseUrl = target.serverChannel ? target.serviceBaseUrl : 'http://localhost'; + return new Request(`${serviceBaseUrl}/api/images`, { method: 'POST', body: formData, signal }); } function agentGenerateRequest(testCase, target, signal) { - return new Request('http://localhost/api/agent/images/generate', { + const serviceBaseUrl = target.serverChannel ? target.serviceBaseUrl : 'http://localhost'; + return new Request(`${serviceBaseUrl}/api/agent/images/generate`, { method: 'POST', headers: buildAgentRequestHeaders(testCase), body: JSON.stringify({ @@ -857,15 +910,43 @@ function summarizeJson(text, contentType) { async function summarizeAgentResponse(response) { const contentType = response.headers.get('content-type') || ''; const body = safeJson(await response.text()); + const execution = body?.execution; return { content_type: contentType || undefined, image_count: Array.isArray(body?.images) ? body.images.length : 0, + artifact_ids: Array.isArray(body?.images) + ? body.images.map((image) => image?.id).filter((id) => typeof id === 'string') + : [], first_content_url: typeof body?.images?.[0]?.content_url === 'string' ? body.images[0].content_url : undefined, has_inline_base64: Boolean(body?.images?.[0]?.b64_json), + ...(typeof execution?.selected_channel_id === 'string' + ? { selected_channel_id: execution.selected_channel_id } + : {}), + ...(typeof execution?.upstream_host === 'string' ? { upstream_host: execution.upstream_host } : {}), + ...(typeof execution?.channel_request_mode === 'string' + ? { + channel_request_mode: execution.channel_request_mode, + selected_request_mode: execution.channel_request_mode + } + : {}), ...(body?.error ? { error: String(body.error.message || body.error) } : {}) }; } +async function deleteHttpAgentArtifacts(target, artifactIds) { + if (!target.serverChannel || !Array.isArray(artifactIds) || artifactIds.length === 0) return; + const headers = readAgentAuthHeaders(); + for (const artifactId of artifactIds) { + const response = await fetch( + `${target.serviceBaseUrl}/api/agent/artifacts/${encodeURIComponent(artifactId)}`, + { method: 'DELETE', headers } + ); + if (!response.ok && response.status !== 404) { + throw new Error(`清理 Docker smoke 产物失败:HTTP ${response.status}。`); + } + } +} + function readFirstB64Length(images) { if (!Array.isArray(images) || typeof images[0]?.b64_json !== 'string') return 0; return images[0].b64_json.length; @@ -924,7 +1005,7 @@ function printUsage() { 可选 --case:all、original-images-json、gaoren-images-sse、sub2api-images-sse、sub2api-responses-json、gpt2image-responses-sse、matsca-images-sse。 也可直接用 request mode 别名 images-json、images-sse、responses-json、responses-sse;同一个 request mode 可能命中多个 case。 -添加 --include-server-channel 后还可运行:server-channel-images-json、server-channel-images-sse、server-channel-responses-sse、server-channel-responses-json、server-channel-agent-images-sse、server-channel-agent-responses-sse。 +添加 --include-server-channel 后还可运行:server-channel-agent-images-json、server-channel-agent-images-sse、server-channel-images-json、server-channel-images-sse、server-channel-responses-sse、server-channel-responses-json、server-channel-agent-responses-sse、server-channel-agent-responses-json。服务端渠道用例会通过 HTTP 访问 IMAGE_REAL_SMOKE_SERVER_SERVICE_BASE_URL、GPT_IMAGE_PLAYGROUND_URL 或默认的 http://127.0.0.1:4783。 默认只检查配置并跳过真实生图;必须加 --allow-billable 才会调用 /api/images 或 /api/agent/images/generate。 可用 --env-file 指向独立真实上游凭据文件;shell 环境变量优先级高于 --env-file,--env-file 优先级高于 .env.local。 可用 --env-file-if-exists 在凭据文件存在时加载,不存在时继续输出结构化 readiness 报告。 diff --git a/scripts/smoke-image-upstream-real.test.mjs b/scripts/smoke-image-upstream-real.test.mjs index 8694ce900f8ca4fee506892ce6c95ee0c992410f..554f3831c744b53f68fbf2fd61f0309c109fbb50 100644 --- a/scripts/smoke-image-upstream-real.test.mjs +++ b/scripts/smoke-image-upstream-real.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; @@ -121,7 +122,7 @@ describe('image upstream real smoke script', () => { }); it('loads independent real upstream targets from an explicit env file without leaking API keys', () => { - const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env'); + const envFilePath = uniqueSmokeEnvPath('real-smoke-test'); try { writeEnvFile( envFilePath, @@ -144,7 +145,7 @@ describe('image upstream real smoke script', () => { }); it('keeps shell environment values ahead of explicit env file values', () => { - const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env'); + const envFilePath = uniqueSmokeEnvPath('real-smoke-test'); try { writeEnvFile( envFilePath, @@ -571,6 +572,7 @@ describe('image upstream real smoke script', () => { OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', + IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process', AGENT_API_TOKEN: 'secret-agent-token' }, { signal: t.signal } @@ -581,6 +583,9 @@ describe('image upstream real smoke script', () => { const report = JSON.parse(result.stdout); assert.equal(report.results[0].status, 200); assert.equal(report.results[0].image_count, 1); + assert.equal(report.results[0].selected_channel_id, 'channel-1'); + assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host); + assert.equal(report.results[0].channel_request_mode, 'images-sse'); assert.deepEqual(upstream.calls, ['/v1/images/generations']); } finally { await upstream.close(); @@ -600,6 +605,7 @@ describe('image upstream real smoke script', () => { OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', + IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process', APP_PASSWORD: 'page-access-code' }, { signal: t.signal } @@ -610,6 +616,9 @@ describe('image upstream real smoke script', () => { const report = JSON.parse(result.stdout); assert.equal(report.results[0].status, 200); assert.equal(report.results[0].image_count, 1); + assert.equal(report.results[0].selected_channel_id, 'channel-1'); + assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host); + assert.equal(report.results[0].channel_request_mode, 'images-sse'); assert.deepEqual(upstream.calls, ['/v1/images/generations']); } finally { await upstream.close(); @@ -630,7 +639,8 @@ describe('image upstream real smoke script', () => { { OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', - OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse' + OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse', + IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process' }, { signal: t.signal } ); @@ -641,6 +651,9 @@ describe('image upstream real smoke script', () => { assert.equal(report.ok, true); assert.equal(report.final_gate_satisfied, false); assert.equal(report.results[0].image_count, 1); + assert.equal(report.results[0].selected_channel_id, 'channel-1'); + assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host); + assert.equal(report.results[0].channel_request_mode, 'images-sse'); after = listRealSmokeFiles(); assert.deepEqual(diffFiles(before, after), []); } finally { @@ -664,6 +677,7 @@ describe('image upstream real smoke script', () => { OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream', + IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process', IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-5.4' }, { signal: t.signal } @@ -809,7 +823,7 @@ describe('image upstream real smoke script', () => { }); it('lets the npm smoke script pass --env-file through to the smoke script', () => { - const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env'); + const missingEnvFilePath = uniqueSmokeEnvPath('missing-real-smoke'); rmSync(missingEnvFilePath, { force: true }); const result = spawnSync('npm', ['run', 'smoke:image-upstream-real', '--', '--env-file', missingEnvFilePath], { @@ -825,7 +839,7 @@ describe('image upstream real smoke script', () => { }); it('lets the npm final gate report readiness when the optional env file is absent', () => { - const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env'); + const missingEnvFilePath = uniqueSmokeEnvPath('missing-real-smoke'); rmSync(missingEnvFilePath, { force: true }); const result = spawnSync( @@ -1006,6 +1020,10 @@ function writeEnvFile(filepath, content) { writeFileSync(filepath, `${content}\n`, 'utf8'); } +function uniqueSmokeEnvPath(prefix) { + return join(repoRoot, 'generated-images', `.${prefix}-${process.pid}-${crypto.randomUUID()}.env`); +} + function buildScriptEnv(env = {}) { const scriptEnv = { ...process.env }; for (const key of Object.keys(scriptEnv)) { diff --git a/scripts/status.mjs b/scripts/status.mjs index b176a1a21d4610560d64031cc192f0d4e61b04be..d98c202746cbf4cb06e8e8c664553f5439b9dbb8 100644 --- a/scripts/status.mjs +++ b/scripts/status.mjs @@ -342,7 +342,7 @@ async function buildLocalStatus() { }, agent: { capabilities: '/api/agent/capabilities', - skill: 'skills/gpt-image-playground-agent/SKILL.md' + skill: 'skills/visual-journal-image-agent/SKILL.md' }, image_upstream_real_smoke: { ...imageUpstreamRealSmoke, diff --git a/skills/gpt-image-playground-agent/agents/openai.yaml b/skills/gpt-image-playground-agent/agents/openai.yaml deleted file mode 100644 index 1ddb290323f85c684e97b8c894b4af8bfbe9bc70..0000000000000000000000000000000000000000 --- a/skills/gpt-image-playground-agent/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Visual Journal Agent API" - short_description: "用图像手记内置脚本生成图片、诊断服务并验证上游渠道能力矩阵" - default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。" diff --git a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs deleted file mode 100644 index 0845dde9a26f95408ae4dd064558237f19f8bd5d..0000000000000000000000000000000000000000 --- a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +++ /dev/null @@ -1,304 +0,0 @@ -import { dirname, join, resolve } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; - -const MAX_RETRY_AFTER_SECONDS = 60; -const DIGITS_PATTERN = /^\d+$/; -const IMAGE_SIZE_PATTERN = /^(\d+)x(\d+)$/; -const LEGACY_IMAGE_SIZES = new Set(['auto', '1024x1024', '1536x1024', '1024x1536']); -export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:4783'; -const DEFAULT_PRIVATE_AGENT_ENV_FILE = '.env.agent.local'; -const PRIVATE_AGENT_ENV_PREFIX = 'GPT_IMAGE_'; -const DISABLE_PRIVATE_AGENT_ENV_VALUES = new Set(['0', 'false', 'no']); - -export function loadPrivateAgentEnvFile(options = {}) { - const env = options.env || process.env; - if (isPrivateAgentEnvLoadingDisabled(env)) { - return { loaded: false, skipped: true, reason: 'disabled_by_env' }; - } - const cwd = options.cwd || process.cwd(); - const filePath = options.filePath || findPrivateAgentEnvFile(cwd); - if (!existsSync(filePath)) { - return { loaded: false, skipped: true, reason: 'file_not_found', path: filePath }; - } - const entries = parsePrivateAgentEnvContent(readFileSync(filePath, 'utf8')); - const appliedNames = []; - for (const { name, value } of entries) { - if (!name.startsWith(PRIVATE_AGENT_ENV_PREFIX)) continue; - if (env[name] !== undefined) continue; - env[name] = value; - appliedNames.push(name); - } - return { - loaded: true, - path: filePath, - applied_names: appliedNames - }; -} - -export function readOptionValue(argv, index, name) { - const value = argv[index]; - if (!value || value.startsWith('--')) { - throw new Error(`${name} 需要参数值。`); - } - return value; -} - -export function readConfiguredPositiveInteger(value, name, fallback) { - const rawValue = value === undefined || value === null ? '' : String(value).trim(); - if (!rawValue) return fallback; - if (!DIGITS_PATTERN.test(rawValue)) { - throw new Error(`${name} 必须是正整数。`); - } - const parsed = Number(rawValue); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error(`${name} 必须是正整数。`); - } - return parsed; -} - -export function readConfiguredNonNegativeInteger(value, name, fallback) { - const rawValue = value === undefined || value === null ? '' : String(value).trim(); - if (!rawValue) return fallback; - if (!DIGITS_PATTERN.test(rawValue)) { - throw new Error(`${name} 必须是非负整数。`); - } - const parsed = Number(rawValue); - if (!Number.isSafeInteger(parsed) || parsed < 0) { - throw new Error(`${name} 必须是非负整数。`); - } - return parsed; -} - -export function readCapabilitiesImageTransportTimeoutMs(capabilities, fallback) { - const value = capabilities?.image_transport?.upstream_timeout_ms; - if (!Number.isSafeInteger(value) || value < 1) return fallback; - return Math.max(fallback, value); -} - -export function readPartialImages(value, name = 'partial_images') { - const parsed = readConfiguredNonNegativeInteger(value, name, 2); - if (parsed < 0 || parsed > 4) { - throw new Error(`${name} 必须是 0 到 4 的整数。`); - } - return parsed; -} - -export function validateAgentGenerateRequestAgainstCapabilities(body, capabilities) { - assertNumberWithinCapabilities(body.n, capabilities?.limits?.generate_images, 'n'); - assertNumberWithinCapabilities( - body.partial_images, - readPartialImagesLimitForBackend(body.image_backend ?? body.imageBackend, capabilities), - 'partial_images' - ); -} - -export function validateAgentEditRequestAgainstCapabilities(input, capabilities) { - assertNumberWithinCapabilities(input.n, capabilities?.limits?.edit_images, 'n'); - assertNumberWithinCapabilities( - input.partial_images, - readPartialImagesLimitForBackend(input.image_backend ?? input.imageBackend, capabilities), - 'partial_images' - ); - assertMaxCountWithinCapabilities(input.imageCount, capabilities?.limits?.upload_images?.max, 'image'); -} - -function readPartialImagesLimitForBackend(backend, capabilities) { - const normalizedBackend = - backend === 'responses' || backend === 'responses-image-generation' ? 'responses-image-generation' : 'images-api'; - return capabilities?.limits?.partial_images_by_backend?.[normalizedBackend] || capabilities?.limits?.partial_images; -} - -function assertNumberWithinCapabilities(value, limits, fieldName) { - if (value === undefined || value === null || !limits) return; - const min = limits.min; - const max = limits.max; - if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) return; - if (value < min || value > max) { - throw new Error(`${fieldName} 必须在当前 capabilities 允许的 ${min} 到 ${max} 之间。`); - } -} - -function assertMaxCountWithinCapabilities(value, max, fieldName) { - if (value === undefined || value === null || !Number.isSafeInteger(max)) return; - if (value > max) { - throw new Error(`${fieldName} 数量不能超过当前 capabilities 允许的 ${max}。`); - } -} - -export function normalizeBaseUrl(value) { - const normalized = String(value || '').trim().replace(/\/+$/, ''); - let parsed; - try { - parsed = new URL(normalized); - } catch { - throw new Error('base URL 必须是有效的 http/https 绝对 URL。'); - } - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - throw new Error('base URL 必须使用 http 或 https。'); - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - throw new Error('base URL 不能包含凭据、查询参数或片段。'); - } - return normalized; -} - -export function resolvePlaygroundBaseUrl(explicitBaseUrl, env = process.env) { - if (explicitBaseUrl) { - return { - baseUrl: normalizeBaseUrl(explicitBaseUrl), - source: 'user_provided', - interactive_confirmation_required: false - }; - } - if (env.GPT_IMAGE_PLAYGROUND_URL) { - return { - baseUrl: normalizeBaseUrl(env.GPT_IMAGE_PLAYGROUND_URL), - source: 'GPT_IMAGE_PLAYGROUND_URL', - interactive_confirmation_required: true - }; - } - return { - baseUrl: DEFAULT_PLAYGROUND_BASE_URL, - source: 'default_local_probe', - interactive_confirmation_required: true - }; -} - -export function normalizeOutputFormat(value) { - return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase(); -} - -export function assertValidImageSizeForModel(value, model, label = 'size') { - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`${label} 必须是字符串。`); - } - if (model !== 'gpt-image-2') { - if (!LEGACY_IMAGE_SIZES.has(value)) { - throw new Error(`${label} 对 ${model} 无效;非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536。`); - } - return value; - } - if (value === 'auto') return value; - const size = parseImageSizeValue(value); - if (!size) throw new Error(`${label} 必须是 auto 或 WIDTHxHEIGHT。`); - assertPositiveIntegerDimensions(size.width, size.height, label); - return value; -} - -export function parseImageSizeValue(value) { - if (typeof value !== 'string') return undefined; - const match = IMAGE_SIZE_PATTERN.exec(value); - return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined; -} - -export function readMaxImageEdge(value) { - const size = parseImageSizeValue(value); - return size ? Math.max(size.width, size.height) : 0; -} - -export function parseRetryAfterValue(value, fallback = 1) { - if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback); - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS; - return clampRetryAfterSeconds(parsed); -} - -export function sleep(seconds) { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -} - -function clampRetryAfterSeconds(value) { - if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS; - return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value))); -} - -export function resolveSameOriginUrl(baseUrl, value, label) { - const base = new URL(baseUrl); - const resolved = new URL(value, `${baseUrl}/`); - if (resolved.origin !== base.origin) { - throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`); - } - return resolved.toString(); -} - -export function errorMessage(error) { - return error instanceof Error ? error.message : String(error); -} - -function isPrivateAgentEnvLoadingDisabled(env) { - return DISABLE_PRIVATE_AGENT_ENV_VALUES.has(String(env.GPT_IMAGE_AGENT_LOAD_ENV_FILE || '').trim().toLowerCase()); -} - -function findPrivateAgentEnvFile(cwd) { - const start = resolve(cwd); - let current = start; - while (true) { - const candidate = join(current, DEFAULT_PRIVATE_AGENT_ENV_FILE); - if (existsSync(candidate)) return candidate; - if (isPrivateAgentEnvSearchBoundary(current) || dirname(current) === current) { - return join(start, DEFAULT_PRIVATE_AGENT_ENV_FILE); - } - current = dirname(current); - } -} - -function isPrivateAgentEnvSearchBoundary(directory) { - return existsSync(join(directory, '.git')) || isPlaygroundProjectRoot(directory) || isStandaloneSkillRoot(directory); -} - -function isPlaygroundProjectRoot(directory) { - return existsSync(join(directory, 'package.json')) && existsSync(join(directory, 'skills/gpt-image-playground-agent/SKILL.md')); -} - -function isStandaloneSkillRoot(directory) { - return ( - existsSync(join(directory, 'SKILL.md')) && - existsSync(join(directory, 'scripts')) && - !isPlaygroundProjectRoot(dirname(dirname(directory))) - ); -} - -function parsePrivateAgentEnvContent(content) { - const entries = []; - for (const line of content.split(/\r?\n/)) { - const parsed = parsePrivateAgentEnvLine(line); - if (parsed) entries.push(parsed); - } - return entries; -} - -function parsePrivateAgentEnvLine(line) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) return undefined; - const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); - if (!match) return undefined; - return { name: match[1], value: parsePrivateAgentEnvValue(match[2].trim()) }; -} - -function parsePrivateAgentEnvValue(value) { - if (value.length < 2) return value; - if (value.startsWith('"') || value.startsWith("'")) return parseQuotedPrivateAgentEnvValue(value); - return stripPrivateAgentEnvComment(value).trim(); -} - -function parseQuotedPrivateAgentEnvValue(value) { - const quote = value[0]; - const closeIndex = value.indexOf(quote, 1); - if (closeIndex < 0) return value.slice(1); - return value.slice(1, closeIndex); -} - -function stripPrivateAgentEnvComment(value) { - const index = value.search(/\s#/); - if (index < 0) return value; - return value.slice(0, index); -} - -function assertPositiveIntegerDimensions(width, height, label) { - if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { - throw new Error(`${label} 的宽度和高度必须是正数。`); - } - if (!Number.isInteger(width) || !Number.isInteger(height)) { - throw new Error(`${label} 的宽度和高度必须是整数。`); - } -} diff --git a/skills/gpt-image-playground-agent/SKILL.md b/skills/visual-journal-image-agent/SKILL.md similarity index 72% rename from skills/gpt-image-playground-agent/SKILL.md rename to skills/visual-journal-image-agent/SKILL.md index 87ee21ec8ecbb717868896c7eaf8e4e05a12c04f..e6a843d34e9ded5edc7251915d0a6797308e1bed 100644 --- a/skills/gpt-image-playground-agent/SKILL.md +++ b/skills/visual-journal-image-agent/SKILL.md @@ -1,43 +1,45 @@ --- -name: gpt-image-playground-agent -description: 当用户需要通过已部署的图像手记(Visual Journal)生成、编辑、批量生成、转换图片格式、查询结果反馈、渠道健康、诊断图片接口,或对新图片上游运行完整能力矩阵并生成私有渠道配置时使用;必须优先运行本 Skill 内置 scripts/generate-image.mjs、edit-image.mjs、batch-images.mjs、convert-image-format.mjs、diagnose-request.mjs、diagnose-channel-health.mjs、probe-upstream-image.mjs 或 channel-capability-matrix.mjs,而不是临时编写 API 调用脚本。 +name: visual-journal-image-agent +description: 本 Skill 是图像手记(Visual Journal)项目图片生成、编辑和诊断的首选且唯一入口,用于替代 Codex 内置的通用生图 Skill;当用户需要通过已部署服务生成、编辑、批量生成、转换图片格式、查询结果反馈、渠道健康、诊断图片接口,或对新图片上游运行完整能力矩阵并生成私有渠道配置时使用。必须优先运行本 Skill 内置 scripts/generate-image.mjs、edit-image.mjs、batch-images.mjs、convert-image-format.mjs、diagnose-request.mjs、diagnose-channel-health.mjs、probe-upstream-image.mjs 或 channel-capability-matrix.mjs,而不是调用通用生图 Skill、临时编写 API 调用脚本或手写请求。 --- # 图像手记(Visual Journal)Agent -通过用户已部署的图像手记(Visual Journal)生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本 Skill 内置脚本,让脚本处理 Agent API 契约、capabilities、幂等键、服务端编排入口和产物 URL。 +通过用户已部署的图像手记(Visual Journal)生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本技能内置脚本,让脚本处理 Agent API 契约、能力声明、幂等键、服务端编排入口和产物 URL。 Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平台。 +本项目的图片任务必须优先使用本技能,替代 Codex 内置的通用生图技能。通用技能不应绕过本项目的 Agent API、能力声明、渠道路由、幂等键和计费门禁。 + ## 脚本优先规则 - 生成单张或少量图片:优先运行 `scripts/generate-image.mjs`。 - 编辑图片:优先运行 `scripts/edit-image.mjs`。 -- 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。 +- 批量生成或编辑:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和追加写入清单管理续跑。 - 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。 - 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。 - 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。 -- 诊断上游图片接口:优先运行 `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`。脚本也接受 request mode 别名 `images-json`、`images-sse`、`responses-json`、`responses-sse`,方便按通道能力筛选 case。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费;远程 URL-only 结果不能写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果某一路径先返回 `object=image.task,status=pending`,说明该请求方式不是直接完成结果;应先确认同一业务键能否在同一渠道下重试拿到最终图片,再把可用的 `request_modes` 写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。服务端未配置 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 时按费用更少优先选择:`images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`;只有真实 smoke 证明需要改变顺序时,管理员才写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 或全局 `OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`。 -- 对新上游完成固定四模式验证并准备可直接使用的私有配置:运行 `scripts/channel-capability-matrix.mjs`。只有用户明确允许计费时才传 `--allow-billable`;需要输出配置时再显式传 `--write-env-file `。它固定串行验证 Images/Responses 的非流式和 SSE 模式,仅在 `/models` 通过、矩阵完整、至少一个方式返回可消费最终图且凭证有效时写入。生成文件只保留实际通过的 `OPENAI_CHANNEL_N_REQUEST_MODES`,显式设置匹配实测能力的 `IMAGE_GENERATION_BACKEND` 和 `IMAGE_STREAMING_STRATEGY=auto`;若没有任何 Images API 模式通过,则默认使用 `responses-image-generation`。Responses 模式通过时会同时启用 Responses 后端并写入实测顶层模型;远程明文 HTTP 目标会写入精确的 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`,使生成配置符合服务端安全门禁。输出文件是权限 `0600` 的独立私有 env 配置,默认拒绝覆盖和符号链接;脚本不合并或自动改写现有 `.env.local`,不会重启服务或部署。 -- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。 -- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。 -- 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。 -- 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游 host、脱敏请求头、重试和下一步动作;Agent JSON 失败时脚本会按幂等键做一次只读 Agent state 诊断补采样,补充 `agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status`、`request_id`、渠道和上游 host。不要再先手查 SQLite、Docker logs 或上游后台。 -- 新增 probe、diagnostics、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行态 API 和 Agent API 的边界判断。 +- 诊断上游图片接口:优先运行 `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`。脚本也接受请求方式别名 `images-json`、`images-sse`、`responses-json`、`responses-sse`,方便按渠道能力筛选用例。只有内联 `b64_json`、Responses `result` 或与 API 基础地址同源的产物 URL 才算可被本服务消费;仅有远程 URL 的结果不能写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果某路径先返回 `object=image.task,status=pending`,说明该请求方式不是直接完成结果;应先确认同一业务键能否在同一渠道下重试拿到最终图片,再把可用的 `request_modes` 写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` 请求方式从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。服务端未配置 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 时按费用更少优先选择:`images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`;只有真实冒烟验证证明需要改变顺序时,管理员才写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 或全局 `OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`。 +- 对新上游完成固定四种方式验证并准备可直接使用的私有配置:运行 `scripts/channel-capability-matrix.mjs`。只有用户明确允许计费时才传 `--allow-billable`;需要输出配置时再显式传 `--write-env-file <私有路径>`。它固定串行验证 Images/Responses 的非流式和 SSE 方式,仅在 `/models` 通过、矩阵完整、至少一种方式返回可消费最终图且凭证有效时写入。生成文件只保留实际通过的 `OPENAI_CHANNEL_N_REQUEST_MODES`,显式设置匹配实测能力的 `IMAGE_GENERATION_BACKEND` 和 `IMAGE_STREAMING_STRATEGY=auto`;若没有任何 Images API 方式通过,则默认使用 `responses-image-generation`。Responses 方式通过时会同时启用 Responses 后端并写入实测顶层模型;远程明文 HTTP 目标会写入精确的 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`,使生成配置符合服务端安全门禁。输出文件权限为 `0600` 的独立私有 env 配置,默认拒绝覆盖和符号链接;脚本不合并或自动改写现有 `.env.local`,不会重启服务或部署。 +- 不要临时编写脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。 +- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下临时调用脚本。 +- 先用预演、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。 +- 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游主机、脱敏请求头、重试和下一步动作;Agent JSON 失败时脚本会按幂等键做一次只读 Agent 状态诊断补采样,补充 `agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status`、`request_id`、渠道和上游主机。不要先手查 SQLite、Docker 日志或上游后台。 +- 新增探针、诊断、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行时 API 和 Agent API 的边界判断。 ## 产品边界 Agent API 只作为自动化客户端接口,不作为首战场景或用户验证的主证明。首阶段产品判断以页面工作台上的真实发布任务、结果下载、继续编辑、复用和最近生成里的结果反馈为准。 -结果反馈由页面工作台通过 `/api/feedback` 写入和清理,Agent 客户端通过 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback` 只读查询。日志查看的原始流仍是 WebUI/page API `/api/logs`,不接受 Agent token;Agent 客户端通过 `/api/agent/diagnostics/page-requests/{id}` 或 `/api/agent/diagnostics/page-requests` 查询脱敏日志摘要。诊断摘要来自本地 bounded app log,capabilities 的 `page_request_diagnostics.retention` 和诊断响应的 `diagnostics_retention` 声明当前窗口;`matched_log_count=0` 时响应会带 `diagnostics_note`,不等同于请求未发生。Agent JSON、Agent edit 和 job 的请求状态属于 Agent state,可通过 `/api/agent/diagnostics/requests/{request_id}` 或 `/api/agent/diagnostics/requests?idempotency_key=...` 只读查询,返回状态、时间线、artifact 摘要、成功响应 timing/execution、失败错误、状态后端和保留边界。灵感相册和历史复用属于页面工作台和浏览器本地体验,不作为 Agent capabilities 或机器 API 承诺。Agent artifact 原始下载 URL 仍需要 Agent 鉴权;需要给用户浏览器访问时,使用 `POST /api/agent/artifacts/{id}/share` 或生成脚本 `--share` 显式创建分享链接。分享链接使用 `/share/{token}` 和 `/api/shares/{token}/content` 的随机 token/访问码模型,不把 Agent token 放进 URL。 +结果反馈由页面工作台通过 `/api/feedback` 写入和清理,Agent 客户端通过 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback` 只读查询。日志查看的原始流仍是页面 API `/api/logs`,不接受 Agent token;Agent 客户端通过 `/api/agent/diagnostics/page-requests/{id}` 或 `/api/agent/diagnostics/page-requests` 查询脱敏日志摘要。诊断摘要来自本地有界应用日志,capabilities 的 `page_request_diagnostics.retention` 和诊断响应的 `diagnostics_retention` 声明当前窗口;`matched_log_count=0` 时响应会带 `diagnostics_note`,不等同于请求未发生。Agent JSON、Agent 编辑和 job 的请求状态属于 Agent 状态,可通过 `/api/agent/diagnostics/requests/{request_id}` 或 `/api/agent/diagnostics/requests?idempotency_key=...` 只读查询,返回状态、时间线、产物摘要、成功响应 timing/execution、失败错误、状态后端和保留边界。灵感相册和历史复用属于页面工作台和浏览器本地体验,不作为 Agent capabilities 或机器 API 承诺。Agent 产物原始下载 URL 仍需要 Agent 鉴权;需要给用户浏览器访问时,使用 `POST /api/agent/artifacts/{id}/share` 或生成脚本 `--share` 显式创建分享链接。分享链接使用 `/share/{token}` 和 `/api/shares/{token}/content` 的随机 token/访问码模型,不把 Agent token 放进 URL。 -渠道健康诊断使用 `GET /api/agent/diagnostics/channel-health` 和 Agent 鉴权。它只读当前服务进程已初始化的 `channel-router` 内存快照,不创建第二套状态,也不会为了读取而初始化路由或启动恢复探测,因此不触发上游探测或图片生成,也不能证明真实上游可用。响应中的 `state_initialized=false` 表示当前进程尚无可读取的路由状态,`channels` 为空,不代表未配置渠道;`healthy` 表示至少有一个有效 request mode 可用,`cooldown` 表示冷却状态,`probe_pending` 表示恢复探测门禁仍未解除;`probe_pending` 可以与 `cooldown_until` 同时出现。该 Agent 诊断不替代页面 `/api/runtime-capabilities`,后者仍是页面运行态和并发配置的摘要。 +渠道健康诊断使用 `GET /api/agent/diagnostics/channel-health` 和 Agent 鉴权。它只读当前服务进程已初始化的 `channel-router` 内存快照,不创建第二套状态,也不会为了读取而初始化路由或启动恢复探测,因此不触发上游探测或图片生成,也不能证明真实上游可用。响应中的 `state_initialized=false` 表示当前进程尚无可读取的路由状态,`channels` 为空,不代表未配置渠道;`healthy` 表示至少有一个有效请求方式可用,`cooldown` 表示冷却状态,`probe_pending` 表示恢复探测门禁仍未解除;`probe_pending` 可以与 `cooldown_until` 同时出现。该 Agent 诊断不替代页面 `/api/runtime-capabilities`,后者仍是页面运行时和并发配置的摘要。 ## 路由规则 -- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和 job polling。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或 job endpoint。 +- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和任务轮询。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或任务端点。 - `capabilities.supported.request_modes`、`capabilities.upstream_request_headers.channels[].request_modes`、`capabilities.upstream_request_headers.channels[].request_mode_priority` 和 `capabilities.request_mode_controls` 是服务端管理员配置的渠道请求方式白名单、优先级与诊断控制面;Agent 客户端不要据此绕过 `orchestration.endpoint` 自行挑选 Images、Responses、SSE 或非流式路径。 -- `providerManifests[].manifest.executionSupport=declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。遇到 pending/poll_url 时按结构化诊断处理,不要把它当成可用同步 request mode。 +- `providerManifests[].manifest.executionSupport=declared_only` 表示清单声明了异步轮询,但当前执行器不会自动轮询 provider `poll` 配置。遇到 pending/poll_url 时按结构化诊断处理,不要把它当成可用同步请求方式。 执行决策表: @@ -50,9 +52,9 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验 - 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent WebP 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。 - `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。 -- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。 +- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过清单、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。 - 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。 -- 复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩 prompt 或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。 +- 复杂 UI、长提示词、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩提示词或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。 - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。 - 普通单次文生图默认使用 `POST /api/agent/image-requests`。`--agent`、`--job`、`--page-sse` 是显式诊断或兼容开关:`--agent` 直连 `/api/agent/images/generate`,`--job` 直连 `/api/agent/jobs/images/generate`,`--page-sse` 直连页面端 `/api/images` SSE。不要把这些显式开关当成默认自动路由。 - 单张文生图使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时仍提交到服务端编排入口,由服务端选择 Responses image_generation、Images API、SSE 或非流式执行方式。`--responses-model` 覆盖本次请求的 Responses 顶层模型;未传时使用服务端 `OPENAI_RESPONSES_API_MODEL`。该字段只影响本项目 `responses-image-generation` 路径,不改变兼容上游自身 Images API 桥接层内部选择的模型。`--responses-model` 必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`;显式 `--page-sse` 才直连页面端 `/api/images` SSE 做诊断。 @@ -74,12 +76,12 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验 12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;generate 只在显式 `--page-sse` 或页面工作台诊断时使用它,默认 WebP edit、Responses edit、长图恢复或需要原始 SSE 日志的 edit 仍可使用页面 SSE。 13. 读取 `agent_jobs` 只用于理解服务端编排结果和显式 `--job` 诊断路径。普通 generate 不再由 Agent 客户端根据本地/远端或尺寸选择 Agent JSON、page SSE 或 job。 14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。 -15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。需要用户直接在浏览器打开图片时添加 `--share`,并返回 `summary.share_urls` 和 `summary.direct_content_urls`;其中 `share_urls` 是分享页入口,公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`,二者都不是需要 Bearer token 的 artifact `content_url`。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。 +15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。需要用户直接在浏览器打开图片时添加 `--share`,并返回 `summary.share_urls` 和 `summary.direct_content_urls`;其中 `share_urls` 是分享页入口,公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`,二者都不是需要 Bearer token 的产物 `content_url`。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。 16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。 ## 鉴权 -Agent JSON、Agent edit、job 和 artifact 端点的鉴权以 `auth.schemes` 为准。如果服务端配置了 `AGENT_API_TOKEN`,发送: +Agent JSON、Agent 编辑、任务和产物端点的鉴权以 `auth.schemes` 为准。如果服务端配置了 `AGENT_API_TOKEN`,发送: ```text Authorization: Bearer @@ -101,7 +103,7 @@ Authorization: Bearer - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。 - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。 - 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。 -- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。 +- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent 生成和编辑对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终产物。 - 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。 - 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope`、`error.diagnostics.cooldown_target.request_mode` 和 `retry_after_seconds`。 - 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。 @@ -109,9 +111,9 @@ Authorization: Bearer - 需要只读查看当前实例的渠道健康状态时,使用 `scripts/diagnose-channel-health.mjs --base-url ...`。脚本先从 capabilities 读取并交叉校验端点声明,再调用同源 Agent 端点;不猜测路径,不调用页面 `/api/runtime-capabilities`,不触发真实上游探测或图片生成。 -## Job Polling +## 任务轮询 -默认 generate 不直接调用 job endpoint;服务端编排入口会在内部使用 job polling 并返回 `job.result_url`。当 `agent_jobs.supported=true` 且需要显式诊断或兼容旧流程时,job 路径可使用: +默认生成不直接调用任务端点;服务端编排入口会在内部使用任务轮询并返回 `job.result_url`。当 `agent_jobs.supported=true` 且需要显式诊断或兼容旧流程时,任务路径可使用: 1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。 2. `GET /api/agent/jobs/{id}` 轮询状态。 @@ -119,31 +121,33 @@ Authorization: Bearer `GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。 -当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新提交同一业务意图与同一 `Idempotency-Key`,避免重复业务操作。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。 +当前任务轮询是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在任务结束前重启,客户端应按状态和错误码继续轮询或重新提交同一业务意图与同一 `Idempotency-Key`,避免重复业务操作。若任务已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建任务。 ## 可用脚本 以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。 -- `scripts/generate-image.mjs`:文生图调用。默认 dry-run,不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。固定尺寸任务可添加 `--dimension-check`,从内联图片或同源 artifact URL 读取 PNG/JPEG/WebP 尺寸并把上游尺寸偏差判为结构化验收失败。需要浏览器可直接打开的用户外链时添加 `--share`,可选 `--share-expires-minutes`;私密分享访问码从 `GPT_IMAGE_SHARE_ACCESS_CODE` 读取,不放进命令行参数。 -- `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。固定尺寸任务可添加 `--dimension-check`,脚本会从内联图片或同源 artifact URL 读取 PNG/JPEG/WebP 尺寸,并把上游尺寸偏差判为结构化验收失败。 -- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。 +- `scripts/generate-image.mjs`:文生图调用。默认预演(dry-run),不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。固定尺寸任务可添加 `--dimension-check`,从内联图片或同源产物 URL 读取 PNG/JPEG/WebP 尺寸并把上游尺寸偏差判为结构化验收失败。需要浏览器可直接打开的用户外链时添加 `--share`,可选 `--share-expires-minutes`;私密分享访问码从 `GPT_IMAGE_SHARE_ACCESS_CODE` 读取,不放进命令行参数。 +- `scripts/edit-image.mjs`:multipart 编辑调用。默认预演(dry-run),不消耗额度;必须添加 `--allow-billable` 才会真实编辑。固定尺寸任务可添加 `--dimension-check`,脚本会从内联图片或同源产物 URL 读取 PNG/JPEG/WebP 尺寸,并把上游尺寸偏差判为结构化验收失败。 +- `scripts/batch-images.mjs`:JSONL 批量生成或编辑调用。默认预演(dry-run),不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持仅追加清单、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。 - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。 -- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。 -- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。需要人工验收画质时,可在只选择单个 request mode 的真实探测中添加 `--save-first-image `,脚本只保存首个可消费最终图并输出尺寸和字节数,不打印图片 base64。遇到本机 DNS fake-IP 故障时,可显式传 `--connect-ip ` 仅覆盖这次探针的连接地址,原 URL 域名仍用于 Host 和 TLS SNI/证书校验。 +- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent 状态请求诊断;支持读取批量清单和 `--base-url`,不触发生图计费。 +- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。需要人工验收画质时,可在只选择单个请求方式的真实探测中添加 `--save-first-image `,脚本只保存首个可消费最终图并输出尺寸和字节数,不打印图片 base64。遇到本机 DNS fake-IP 故障时,可显式传 `--connect-ip ` 仅覆盖这次探针的连接地址,原 URL 域名仍用于 Host 和 TLS SNI/证书校验。 + +生成、编辑和批量脚本的预演输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或清单写入。生成预演添加 `--check-remote` 后会只读查询 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,输出 `verification_scope.mode=remote_contract_and_local_planning`,仍不会发送真实生图请求。生成预演默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。单张生成、单张编辑和批量任务都支持 `--dimension-check`;批量预演还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport`、`endpoint`、`route_mode`、`channel_request_mode`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;失败摘要的渠道与路由字段优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions` 和服务端 `execution` 选路字段,便于人工审查。 +所有生成、编辑、批量和探针脚本在预演或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI 兼容上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent 编辑不接收输出格式字段,服务端按固定 WebP 契约提交并保存。管理员确认需要让真实上游决定尺寸或透明背景支持时,显式添加 `--force-request`,脚本会发送 `force_request=true` 并跳过本服务本地 upstream profile 尺寸/背景限制;鉴权、幂等键、`--allow-billable`、API URL 安全、渠道请求方式白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、文件大小和 mask 完整性校验仍然生效。 -生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 添加 `--check-remote` 后会只读查询 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,输出 `verification_scope.mode=remote_contract_and_local_planning`,仍不会发送真实生图请求。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。单张生成、单张编辑和批量任务都支持 `--dimension-check`;批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport`、`endpoint`、`route_mode`、`channel_request_mode`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;失败摘要的渠道与路由字段优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions` 和服务端 `execution` 选路字段,便于人工审查。 -所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不接收输出格式字段,服务端按固定 WebP 契约提交并保存。管理员确认需要让真实上游决定尺寸或透明背景支持时,显式添加 `--force-request`,脚本会发送 `force_request=true` 并跳过本服务本地 upstream profile 尺寸/背景限制;鉴权、幂等键、`--allow-billable`、API URL 安全、渠道 request mode 白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、文件大小和 mask 完整性校验仍然生效。 +capabilities 的 `upstream_request_headers.channels[]` 是逐渠道约束契约:`constraints` 声明数量、`partial_images`、编辑上传、背景和尺寸策略,`healthy_request_modes`(存在时)只列出当前健康凭证支持的 request mode。范围可能包含 `allowedValues`,客户端不能把离散值扩展成连续 min/max。省略 `partial_images` 时不要写入 `defaults.partial_images`,由服务端按最终健康且满足请求约束的渠道计算默认值;非流式请求仅校验公共 `0..4` 边界,不向上游发送该字段。 如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令: - `npm run first-run`:首次配置就绪检查,只读、非计费、不写 env 文件;默认输出中文摘要,加 `-- --json` 输出机器可读 JSON;用于确认 Node、依赖、服务地址、Agent capabilities、当前进程鉴权和下一步动作。 - `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。 - `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。 -- `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。 +- `npm run verify`:运行提交前基线;需要真实 PostgreSQL 门禁时加 `-- --postgres`。 - `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效,加 `-- --postgres` 会断言 postgres/fs overlay 生效。PostgreSQL 模式要求在运行环境或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。 - `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。 -- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent contract、runtime backend、state backend 和 Responses/GPT2Image readiness;支持 `-- --base-url `;真实 1K/2K smoke 必须显式加 `-- --allow-billable`。 +- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent 契约、运行时后端、状态后端和 Responses/GPT2Image 就绪状态;支持 `-- --base-url `;真实 1K/2K 冒烟验证必须显式加 `-- --allow-billable`。 首次配置和诊断输出字段速查: @@ -158,7 +162,7 @@ Authorization: Bearer | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 | | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 | | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 | -| `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 | +| `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认生成主链 `/api/agent/image-requests` 的真实冒烟状态;这是普通生成在服务端编排下的主编排口径。 | | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 | | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 | | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 | @@ -172,13 +176,13 @@ Authorization: Bearer 生成脚本常用参数: ```text -node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "a product photo of a ceramic mug" +node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "一张陶瓷杯的产品照片" ``` 常用 preset 可先 dry-run 展开真实参数,不触发计费: ```text -node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 1k-smoke-agent "a product photo of a ceramic mug" +node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 1k-smoke-agent "一张陶瓷杯的产品照片" node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-agent-nonstream "a cinematic landscape" node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-page-sse "a cinematic landscape" node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-upstream-sse-newapi "a cinematic landscape" @@ -187,19 +191,19 @@ node "/scripts/generate-image.mjs" --base-url https://your-space.hf. 启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON: ```text -node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --image-backend images-api --stream-mode auto --streaming-strategy newapi-keepalive-sse --partial-images 2 --size 3840x2160 --quality high "a product photo of a ceramic mug" +node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --image-backend images-api --stream-mode auto --streaming-strategy newapi-keepalive-sse --partial-images 2 --size 3840x2160 --quality high "一张陶瓷杯的产品照片" ``` 真实生图必须显式开启: ```text -node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --timeout-ms 420000 --size 2048x2048 "a product photo of a ceramic mug" +node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --timeout-ms 420000 --size 2048x2048 "一张陶瓷杯的产品照片" ``` 创建浏览器可直接打开的分享链接: ```text -node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --share --share-expires-minutes 1440 --size 2048x2048 "a product photo of a ceramic mug" +node "/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --share --share-expires-minutes 1440 --size 2048x2048 "一张陶瓷杯的产品照片" ``` 本地格式转换不触发生图计费: @@ -209,15 +213,15 @@ node "/scripts/convert-image-format.mjs" --format webp --quality 100 node "/scripts/convert-image-format.mjs" --format png --output ./source.png ./source.webp --overwrite ``` -生成脚本默认把文生图业务意图提交到服务端编排入口 `/api/agent/image-requests`;服务端内部决定使用 Agent JSON、内部上游 SSE、job polling 或其他可观测路径。脚本不再根据 `max_edge>2048`、公网 HTTPS、页面高级字段或 `streaming_strategy=off` 自行切换默认端点。单张 generate 支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization`、`--force-web` 和 `--force-request`;这些字段会作为生成意图提交到服务端编排入口,由服务端选择 Responses image_generation、Images API、SSE 或非流式执行方式。`--agent`、`--job`、`--page-sse` 是显式诊断或兼容开关,使用时必须记录原因,并用新的 `Idempotency-Key` 避免混淆业务操作。默认 WebP edit 走页面 SSE;显式 `--agent` 才走 Agent multipart 最终 JSON,输出格式固定为 Agent WebP 契约。Responses image_generation edit 属于页面 SSE 路径:可显式传 `--page-sse --image-backend responses-image-generation --streaming-strategy responses-sse`;如果运行时已显式配置 `IMAGE_GENERATION_BACKEND=responses-image-generation` 或兼容别名 `responses`,且 `IMAGE_STREAMING_STRATEGY=responses-sse`,也可以依赖服务端默认值。Docker compose 本身不设置这两个默认值,未配置 `.env.local` 时仍是 `images-api` 和 `auto`。不要为 Responses edit 加 `--agent`。默认 WebP edit 与非流式策略冲突时脚本前置拒绝,除非显式添加 `--agent` 做 Agent JSON 对照。页面高级 edit 字段与非流式策略冲突时脚本同样前置拒绝。上游流式字段优先读取 `agent_streaming.upstream_sse.request_fields_by_mode`:generate 支持 `--image-backend`、`--stream-mode`、`--streaming-strategy`、`--partial-images`;Agent edit 只支持 `--stream-mode`、`--streaming-strategy`、`--partial-images`。页面 SSE edit 可发送 `image_backend` 和表单字段 `image_streaming_strategy`;CLI 参数是 `--streaming-strategy`,batch JSONL 字段是 `streaming_strategy`。 +生成脚本默认把文生图业务意图提交到服务端编排入口 `/api/agent/image-requests`;服务端内部决定使用 Agent JSON、内部上游 SSE、任务轮询或其他可观测路径。脚本不再根据 `max_edge>2048`、公网 HTTPS、页面高级字段或 `streaming_strategy=off` 自行切换默认端点。单张生成支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization`、`--force-web` 和 `--force-request`;这些字段会作为生成意图提交到服务端编排入口,由服务端选择 Responses image_generation、Images API、SSE 或非流式执行方式。`--agent`、`--job`、`--page-sse` 是显式诊断或兼容开关,使用时必须记录原因,并用新的 `Idempotency-Key` 避免混淆业务操作。默认 WebP 编辑走页面 SSE;显式 `--agent` 才走 Agent multipart 最终 JSON,输出格式固定为 Agent WebP 契约。Responses image_generation 编辑属于页面 SSE 路径:可显式传 `--page-sse --image-backend responses-image-generation --streaming-strategy responses-sse`;如果运行时已显式配置 `IMAGE_GENERATION_BACKEND=responses-image-generation` 或兼容别名 `responses`,且 `IMAGE_STREAMING_STRATEGY=responses-sse`,也可以依赖服务端默认值。Docker compose 本身不设置这两个默认值,未配置 `.env.local` 时仍是 `images-api` 和 `auto`。不要为 Responses 编辑加 `--agent`。默认 WebP 编辑与非流式策略冲突时脚本前置拒绝,除非显式添加 `--agent` 做 Agent JSON 对照。页面高级编辑字段与非流式策略冲突时脚本同样前置拒绝。上游流式字段优先读取 `agent_streaming.upstream_sse.request_fields_by_mode`:生成支持 `--image-backend`、`--stream-mode`、`--streaming-strategy`、`--partial-images`;Agent 编辑只支持 `--stream-mode`、`--streaming-strategy`、`--partial-images`。页面 SSE 编辑可发送 `image_backend` 和表单字段 `image_streaming_strategy`;CLI 参数是 `--streaming-strategy`,batch JSONL 字段是 `streaming_strategy`。 -generate 或页面 SSE 请求包含 `image_backend` 时,`partial_images` 必须先按 `limits.partial_images_by_backend[image_backend]` 校验;capabilities 没有该字段时才退回 `limits.partial_images`。Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API/profile 范围校验;Responses backend edit 需要页面 SSE。不要把 Matsca `limits.partial_images=0..4` 误套到 `responses-image-generation`,Responses backend 当前使用自己的 `1..3` 范围。 +generate 或页面 SSE 请求包含 `image_backend` 时,`n` 必须先按操作读取 `limits.generate_images_by_backend[image_backend]` 或 `limits.edit_images_by_backend[image_backend]`;capabilities 缺少按后端字段时才退回 `limits.generate_images` 或 `limits.edit_images`。`responses-image-generation` 当前生成和页面 SSE 编辑都只允许 `n=1`。`partial_images` 必须先按 `limits.partial_images_by_backend[image_backend]` 校验;capabilities 没有该字段时才退回 `limits.partial_images`。Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API/profile 范围校验;Responses backend edit 需要页面 SSE。不要把 Matsca `limits.partial_images=0..4` 误套到 `responses-image-generation`,Responses backend 当前使用自己的 `1..3` 范围。 批量脚本 JSONL 每行是一个 generate 或 edit 任务。示例: ```jsonl -{"id":"hero-01","mode":"generate","prompt":"a product photo of a ceramic mug","size":"1024x1024","response_mode":"path"} -{"id":"edit-01","mode":"edit","prompt":"replace the background","image_path":"./source.png","size":"1024x1024","response_mode":"path"} +{"id":"hero-01","mode":"generate","prompt":"一张陶瓷杯的产品照片","size":"1024x1024","response_mode":"path"} +{"id":"edit-01","mode":"edit","prompt":"替换背景","image_path":"./source.png","size":"1024x1024","response_mode":"path"} ``` 默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务: diff --git a/skills/visual-journal-image-agent/agents/openai.yaml b/skills/visual-journal-image-agent/agents/openai.yaml new file mode 100644 index 0000000000000000000000000000000000000000..69fef568f826eab8336809e994d89cb2b4f52234 --- /dev/null +++ b/skills/visual-journal-image-agent/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Visual Journal Image Agent API" + short_description: "用图像手记内置脚本生成图片、诊断服务并验证上游渠道能力矩阵" + default_prompt: "图片生成、编辑或诊断请使用 $visual-journal-image-agent,替代 Codex 内置的通用生图 Skill;先选择并运行内置脚本。新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。" diff --git a/skills/gpt-image-playground-agent/references/api.md b/skills/visual-journal-image-agent/references/api.md similarity index 88% rename from skills/gpt-image-playground-agent/references/api.md rename to skills/visual-journal-image-agent/references/api.md index 78865dde3edd34a31d4f81ac788de5e2d0d88f95..4cb470eb34663bfe3543662de87007e910ac245c 100644 --- a/skills/gpt-image-playground-agent/references/api.md +++ b/skills/visual-journal-image-agent/references/api.md @@ -8,30 +8,30 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平 - [辅助脚本](#辅助脚本) - [能力查询](#能力查询) -- [Job Polling](#job-polling) +- [任务轮询](#任务轮询) - [生成图片](#生成图片) - [编辑图片](#编辑图片) - [产物元数据](#产物元数据) - [结果反馈与诊断](#结果反馈与诊断) -- [WebUI Page API 边界](#webui-page-api-边界) +- [页面 API 边界](#页面-api-边界) - [错误](#错误) ## 辅助脚本 脚本位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径或仓库 checkout 路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。 脚本必须通过 `node "/scripts/..."` 调用,以兼容 Windows、Linux 和 macOS;示例不要依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。 -生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。 +生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node、Python 或 shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。 - `scripts/generate-image.mjs`:JSON 文生图调用。 - `scripts/edit-image.mjs`:multipart 编辑调用;固定尺寸任务可添加 `--dimension-check` 验收真实产物尺寸。 - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。 - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。 -- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。 +- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent 状态请求诊断,支持 `--base-url` 固定目标服务。 - `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。 - `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。 - `scripts/channel-capability-matrix.mjs`:固定串行验证四种上游图片请求方式,并在真实验证通过后生成私有渠道 env 配置。 -生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。generate 可添加 `--check-remote` 做只读远端检查,输出 `verification_scope.mode=remote_contract_and_local_planning`,仅访问 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,不会发送真实生图请求。必须显式添加 `--allow-billable` 才会调用真实端点。generate 默认提交到 `/api/agent/image-requests` 服务端编排入口;`--agent`、`--job`、`--page-sse` 才会显式改用 `/api/agent/images/generate`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。 +生成、编辑和批量脚本默认只做预演(dry-run),不触发真实生图或编辑。预演输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端能力声明,不会验证远端鉴权、渠道容量或清单写入。generate 可添加 `--check-remote` 做只读远端检查,输出 `verification_scope.mode=remote_contract_and_local_planning`,仅访问 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,不会发送真实生图请求。必须显式添加 `--allow-billable` 才会调用真实端点。generate 默认提交到 `/api/agent/image-requests` 服务端编排入口;`--agent`、`--job`、`--page-sse` 才会显式改用 `/api/agent/images/generate`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。 上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。 脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,会覆盖服务声明的默认编排入口和页面 SSE 边界,不触发真实生图或编辑。 位于仓库根目录且是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。它只读、非计费、不写 env 文件,默认输出中文摘要,并报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程鉴权、页面 SSE 鉴权和下一步动作。 @@ -50,7 +50,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom 同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。 页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。 脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。管理员确认要让真实上游决定尺寸或透明背景支持时,显式添加 `--force-request` 或在 JSONL/API 中设置 `force_request=true`;它只跳过本服务本地 upstream profile 尺寸/背景限制,鉴权、幂等键、`--allow-billable`、API URL 安全、渠道 request mode 白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、文件大小和 mask 完整性校验仍然生效。生成、页面编辑、批量和上游探针默认请求 `output_format=webp`、`output_compression=100`。 -真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。`transport` 表示 Agent 对外访问的服务端端点形态,`route_mode` 表示 Agent/job/page SSE 路径,`channel_request_mode` 表示服务端实际调用上游的 Images/Responses 与 SSE/非流式组合,`route_decision` 记录 requested backend、candidate request modes、request mode priority、preferred/fallback/selected request mode、fallback 是否发生、选中渠道、上游 host 或 no-channel 原因。`share_urls` 只在显式 `--share` 后出现,用于给用户浏览器打开分享页;`direct_content_urls` 只在显式 `--share` 后出现,用于分享后的内容直链;公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`;`content_urls` 仍是需要 Agent 鉴权的 artifact 下载路径。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;渠道与路由诊断优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。Agent JSON 失败时脚本会按幂等键只读查询 Agent state;若命中,会把 `request_id`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。 +真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。`transport` 表示 Agent 对外访问的服务端端点形态,`route_mode` 表示 Agent/job/page SSE 路径,`channel_request_mode` 表示服务端实际调用上游的 Images/Responses 与 SSE/非流式组合,`route_decision` 记录 requested backend、candidate request modes、request mode priority、preferred/fallback/selected request mode、fallback 是否发生、选中渠道、上游 host 或 no-channel 原因。`share_urls` 只在显式 `--share` 后出现,用于给用户浏览器打开分享页;`direct_content_urls` 只在显式 `--share` 后出现,用于分享后的内容直链;公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`;`content_urls` 仍是需要 Agent 鉴权的产物下载路径。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于自动化客户端按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;渠道与路由诊断优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。Agent JSON 失败时脚本会按幂等键只读查询 Agent 状态;若命中,会把 `request_id`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。 生成脚本参数: @@ -69,8 +69,8 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom - `--force-request`:生成意图字段,服务端在 Agent JSON、job 和页面 SSE 路径解释为 `force_request=true`,跳过本地 upstream profile 尺寸/背景限制,让真实上游接受或拒绝请求;鉴权、幂等键、费用确认、API URL 安全、渠道白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、上传文件和 mask 完整性仍由本服务校验。 - `--stream-mode`:可选,显式选择 `auto`、`stream` 或 `non_stream`。 - `--streaming-strategy`:可选,显式选择 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。 -- `--partial-images`:可选,显式设置上游 SSE partial image 数量。generate 或页面 SSE 请求包含 `image_backend` 时优先按 capabilities 的 `limits.partial_images_by_backend[image_backend]` 校验;缺少 backend 专属范围时才使用 `limits.partial_images`。 -- `--share`:真实生图成功后,为每个 Agent artifact 调用 `POST /api/agent/artifacts/{id}/share` 创建用户可打开的分享链接,并在顶层 `shares`、`summary.share_urls` 和 `summary.direct_content_urls` 输出结果。 +- `--partial-images`:可选,显式设置上游 SSE partial image 数量。generate 或页面 SSE 请求包含 `image_backend` 时优先按 capabilities 的 `limits.partial_images_by_backend[image_backend]` 校验;缺少 backend 专属范围时才使用 `limits.partial_images`。省略该参数时,脚本不会把 `defaults.partial_images` 写入请求,由服务端在确定健康且满足 `n`、背景、尺寸和 request mode 的最终渠道后计算默认值;非流式请求不会把该字段发送给上游。 +- `--share`:真实生图成功后,为每个 Agent 产物调用 `POST /api/agent/artifacts/{id}/share` 创建用户可打开的分享链接,并在顶层 `shares`、`summary.share_urls` 和 `summary.direct_content_urls` 输出结果。 - `--share-expires-minutes`:可选,设置分享有效期分钟数;省略时使用服务端默认值。 - `--dimension-check`:读取响应 `b64_json` 或同 origin `absolute_content_url`/`content_url`/`absolute_path`/`path`,校验 PNG/JPEG/WebP 尺寸等于 `--size`;通过时 summary 写入实际尺寸,失败时写入 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`expected_dimensions` 和 `actual_dimensions`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。 - `GPT_IMAGE_SHARE_ACCESS_CODE`:可选,创建需要访问码的分享链接;访问码不会出现在返回 URL 中,也不会出现在命令行参数里。 @@ -79,7 +79,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom - `--idempotency-key`:指定稳定幂等键。 - `--page-sse`:诊断或兼容开关,强制使用页面端 `/api/images` form-data SSE。 - `--agent`:诊断或兼容开关,强制使用 `/api/agent/images/generate` 非流式 JSON。 -- `--job`:诊断或兼容开关,强制使用 Agent job polling。 +- `--job`:诊断或兼容开关,强制使用 Agent 任务轮询。 - `--dry-run`:只输出将要发送的 JSON。 - `--allow-billable`:允许真实调用生图端点。 - `--preset`:常用 dry-run/真实调用参数集,当前包括 `1k-smoke-agent`、`4k-agent-nonstream`、`4k-page-sse` 和 `4k-upstream-sse-newapi`。dry-run 会展开真实请求字段,不触发计费。 @@ -122,8 +122,8 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom 批量脚本参数: - `--input`:JSONL 任务文件路径,也可作为唯一位置参数。 -- `--manifest`:append-only JSONL manifest 路径,默认 `.manifest.jsonl`。 -- `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。 +- `--manifest`:追加写入 JSONL 清单路径,默认 `.manifest.jsonl`。 +- `--resume`:读取清单中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。 - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。 - `--dimension-check`:读取响应 `b64_json` 或同 origin `absolute_content_url`/`content_url`/`absolute_path`/`path`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`;通过时 summary 写入实际尺寸,失败时写入 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`expected_dimensions` 和 `actual_dimensions`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。 - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。 @@ -133,7 +133,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom - `--dry-run` - `--allow-billable` -批量 dry-run 不写 manifest,输出会声明 `manifest_written=false`、`manifest_write_reason=dry_run` 和 `guardrails`。`guardrails.ordered_prefix` 是本次 dry-run 用于自动生成幂等键的前缀,真实执行应复用同一个 `--ordered-prefix`;`guardrails.dimension_check_recommended=true` 表示输入包含固定尺寸但未启用 `--dimension-check`。只有真实执行时 manifest 才作为 append-only 续跑记录写入;Agent JSON 失败时 manifest 会同时保存增强后的 `summary` 和 `agent_failure_diagnostics`。尺寸门禁失败同样写入结构化 summary 和可审查产物 URL,避免只能从中文错误文本解析期望和实际尺寸。批量总摘要会输出 `failure_summary.validation_failure_count` 和 `failure_summary.request_failure_count`,用于区分“上游已生成但本地验收失败”和“请求未成功完成”。当 `validation_failure_count>0` 而 `request_failure_count=0` 时,要按验收失败处理,不能当成上游不可用。 +批量 dry-run 不写清单,输出会声明 `manifest_written=false`、`manifest_write_reason=dry_run` 和 `guardrails`。`guardrails.ordered_prefix` 是本次 dry-run 用于自动生成幂等键的前缀,真实执行应复用同一个 `--ordered-prefix`;`guardrails.dimension_check_recommended=true` 表示输入包含固定尺寸但未启用 `--dimension-check`。只有真实执行时清单才作为追加写入的续跑记录写入;Agent JSON 失败时清单会同时保存增强后的 `summary` 和 `agent_failure_diagnostics`。尺寸门禁失败同样写入结构化 summary 和可审查产物 URL,避免只能从中文错误文本解析期望和实际尺寸。批量总摘要会输出 `failure_summary.validation_failure_count` 和 `failure_summary.request_failure_count`,用于区分“上游已生成但本地验收失败”和“请求未成功完成”。当 `validation_failure_count>0` 而 `request_failure_count=0` 时,要按验收失败处理,不能当成上游不可用。 批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。批量 generate 默认提交到 `/api/agent/image-requests`,`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 会随 JSON 业务意图提交给服务端编排入口。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images` 以及上述 Responses 控制字段可用于页面 SSE edit 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚本在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。 @@ -143,7 +143,7 @@ Responses edit JSONL 正例: { "id": "edit-responses", "mode": "edit", - "prompt": "replace the background", + "prompt": "替换背景", "image_path": "source.png", "image_backend": "responses-image-generation", "streaming_strategy": "responses-sse", @@ -159,7 +159,7 @@ dry-run 预期:`routing.transport=page_sse`、`endpoint=/api/images`、`reques { "id": "edit-responses-missing-backend", "mode": "edit", - "prompt": "replace the background", + "prompt": "替换背景", "image_path": "source.png", "responsesModel": "gpt-4.1" } @@ -244,9 +244,10 @@ GET /api/agent/capabilities - 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。 - `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。 - `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。 +- 每个 `upstream_request_headers.channels[]` 还包含按渠道脱敏的 `constraints`,声明生成/编辑数量、按 backend 的数量与 `partial_images` 范围、编辑上传数量和大小、`gpt-image-2` 背景与尺寸策略;存在已初始化路由健康状态时提供 `healthy_request_modes`,表示当前至少有一个凭证健康的 request mode。数量范围可能带 `allowedValues`,表示不连续的离散可用值,不能按 min/max 中间的整数扩展。 - `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。 - `upstream_request_headers.channels[].upstream_proxy`:该渠道的有效上游代理摘要。`OPENAI_CHANNEL_N_PROXY_URL` 优先于 `OPENAI_UPSTREAM_PROXY_URL`;摘要只返回 `configured` 和 `protocol`,不返回代理地址或端口。 -- `request_mode_controls`:管理员 request mode 白名单和优先级控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实 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;也可用 `--case images-json`、`--case images-sse`、`--case responses-json`、`--case responses-sse` 按 request mode 筛选。脚本输出的 `request_modes.passed` 和顶层 `suggested_channel_config` 是写入 `OPENAI_CHANNEL_N_REQUEST_MODES` 的候选值;未通过、未实测、只返回远程 URL-only 或只返回 pending/poll_url 的 mode 不应写入。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从白名单里删掉,只保留通过的模式。需要覆盖默认排序时,再把通过的 mode 按期望顺序写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`。 +- `request_mode_controls`:管理员 request mode 白名单和优先级控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实冒烟验证门禁和 `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` 等真实冒烟用例;也可用 `--case images-json`、`--case images-sse`、`--case responses-json`、`--case responses-sse` 按 request mode 筛选。脚本输出的 `request_modes.passed` 和顶层 `suggested_channel_config` 是写入 `OPENAI_CHANNEL_N_REQUEST_MODES` 的候选值;未通过、未实测、只返回远程 URL-only 或只返回 pending/poll_url 的 mode 不应写入。只有内联 `b64_json`、Responses `result` 或与 API 基础地址同源的产物 URL 才算可被本服务消费。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从白名单里删掉,只保留通过的模式。需要覆盖默认排序时,再把通过的 mode 按期望顺序写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`。 - `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。 - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。 - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。 @@ -261,14 +262,15 @@ GET /api/agent/capabilities - 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。 - `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。 -新增 probe、diagnostics 或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。 +新增探针、诊断或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。 - `defaults.image_backend`:Agent generate 默认 `images-api`。 - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。 - `defaults.streaming_strategy`:Agent generate 默认 `auto`。 -- `defaults.partial_images`:Agent generate 默认值会被当前 `limits.partial_images` 约束钳制,在 `stream_mode` 不为 `non_stream` 时使用;客户端发送前仍要按选中 backend 的 `limits.partial_images_by_backend` 复核。 +- `defaults.partial_images`:兼容旧客户端的默认提示值。自动模式省略 `partial_images` 时,服务端会在确定健康且满足当前 `n`、背景、尺寸和 request mode 的最终渠道后重新计算;客户端不应把该字段强行写入请求。非流式请求会校验公开的 `0..4` 输入边界,但不会向上游发送该字段。 - `upstream_profile`:当前运行时的上游能力摘要,包含 `activeProfile`、`serverProfile`、`serverProfileMixed`、`requestProfile` 与三组约束对象。 -- `limits.generate_images` / `limits.edit_images` / `limits.upload_images`:当前运行时分别允许的生成张数、编辑输出张数和编辑源图数量范围。 +- `limits.generate_images` / `limits.edit_images` / `limits.upload_images`:当前运行时分别允许的默认生成张数、默认编辑输出张数和编辑源图数量范围。 +- `limits.generate_images_by_backend` / `limits.edit_images_by_backend`:按图片后端覆盖生成或页面 SSE 编辑的输出数量范围。请求带 `image_backend` 时必须优先读取对应操作的按后端范围;旧 capabilities 未提供该字段时才退回 `limits.generate_images` 或 `limits.edit_images`。`responses-image-generation` 当前两种操作都只允许 `n=1`。 - `limits.partial_images`:当前运行时默认 profile 允许的 `partial_images` 范围。OpenAI-compatible 通常为 `1..3`,Matsca Images API 通常为 `0..4`;Agent 必须以 capabilities 返回值为准。 - `limits.partial_images_by_backend`:按图片后端覆盖 `partial_images` 范围。选择 `responses-image-generation` 或兼容别名 `responses` 时必须优先使用该字段中的 `responses-image-generation` 范围,当前通常为 `1..3`。 - `supported.image_backends`:机器可读的图片后端枚举。 @@ -276,19 +278,19 @@ GET /api/agent/capabilities - `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。 - `supported.streaming_strategies`:机器可读的流式兼容策略枚举。 - `supported.stream_modes`:机器可读的 `auto`、`stream`、`non_stream` 枚举。 -- `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。 +- `agent_jobs.supported`:当前为 `true`,表示可使用任务轮询。 - `agent_jobs.mode`:当前为 `job_polling`。 - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。 - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。 - `agent_request_diagnostics`:Agent state 请求诊断能力。`endpoints.lookup` 支持 `request_id` 或 `idempotency_key` 查询参数;`endpoints.single` 支持按 `request_id` 路径查询;`retention.ttl_seconds` 与 Agent request TTL 一致。 -普通 generate 默认使用 `orchestration.endpoint`,不是客户端直接选择 job endpoint。`agent_jobs.supported=true` 且 `mode=job_polling` 表示服务端编排和显式 `--job` 诊断路径可使用同一套 job 状态机。高分辨率 edit 和复杂 UI 批量生产仍按页面/批量规则使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent edit 或 job 路径。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。 +普通生成默认使用 `orchestration.endpoint`,不是客户端直接选择任务端点。`agent_jobs.supported=true` 且 `mode=job_polling` 表示服务端编排和显式 `--job` 诊断路径可使用同一套任务状态机。高分辨率编辑和复杂 UI 批量生产仍按页面/批量规则使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent 编辑或任务路径。当前任务轮询是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。 上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `visual-journal/`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。 上游代理同样由服务端统一执行:`OPENAI_UPSTREAM_PROXY_URL` 为全局默认值,`OPENAI_CHANNEL_N_PROXY_URL` 可覆盖单个渠道。它们只接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS;配置变更需重启或重新部署服务。代理适用于服务端上游 API、SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志,不影响 Agent 客户端到 Playground 的连接。 -## Job Polling +## 任务轮询 ```http POST /api/agent/jobs/images/generate @@ -358,7 +360,7 @@ Content-Type: application/json ```json { - "prompt": "a product photo of a ceramic mug", + "prompt": "一张陶瓷杯的产品照片", "model": "gpt-image-2", "n": 1, "size": "1024x1024", @@ -424,7 +426,7 @@ Agent JSON 生成端点对外始终返回最终 JSON,不会对客户端返回 "selected_channel_id": "default", "upstream_host": "api.example.test", "request_headers": { - "user_agent_effective": "visual-journal/2.2.0", + "user_agent_effective": "visual-journal/2.3.0", "has_extra_headers": false, "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"], "configured_header_names": [] @@ -473,7 +475,7 @@ DELETE /api/agent/artifacts/{id} 所有产物端点都需要和生成接口相同的鉴权。 -`GET /api/agent/artifacts/{id}` 返回 Agent 产物元数据;`GET /content` 返回产物图片二进制;`POST /share` 为已有 Agent artifact 复制出独立分享产物,返回 `share_url`、`direct_content_url`、过期时间和是否需要访问码;`DELETE /api/agent/artifacts/{id}` 会删除 Agent 产物文件和状态库元数据,并把关联请求标记为 `artifact_not_found`。不存在的产物返回 `artifact_not_found`。页面端 `POST /api/image-delete` 是按文件名删除页面图片文件的 WebUI API,使用页面访问码哈希和 `filenames` JSON,不等同于 Agent artifact delete。 +`GET /api/agent/artifacts/{id}` 返回 Agent 产物元数据;`GET /content` 返回产物图片二进制;`POST /share` 为已有 Agent 产物复制出独立分享产物,返回 `share_url`、`direct_content_url`、过期时间和是否需要访问码;`DELETE /api/agent/artifacts/{id}` 会删除 Agent 产物文件和状态库元数据,并把关联请求标记为 `artifact_not_found`。不存在的产物返回 `artifact_not_found`。页面端 `POST /api/image-delete` 是按文件名删除页面图片文件的 WebUI API,使用页面访问码哈希和 `filenames` JSON,不等同于 Agent 产物删除。 Agent 创建分享链接的请求示例: @@ -635,7 +637,7 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 | | `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`。 | | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要,包含 `supported`、`configured`、`effective`、`admin_whitelist_by_channel`、`effective_by_channel`、带 `severity` 的 `gaps`、`suggested_channel_env_key`、`suggested_effective_value` 和 `next_action`;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 | -| `request_mode_controls` | `capabilities` | 管理员 request mode 白名单和优先级控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 | +| `request_mode_controls` | `capabilities` | 管理员 request mode 白名单和优先级控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 | | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 | | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 | | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 | @@ -668,7 +670,7 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h "transport": "agent_json", "endpoint": "/api/agent/images/generate", "request_headers": { - "user_agent_effective": "visual-journal/2.2.0", + "user_agent_effective": "visual-journal/2.3.0", "has_extra_headers": false, "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"], "configured_header_names": [] @@ -686,7 +688,7 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h } ``` -## WebUI Page API 边界 +## 页面 API 边界 这些端点服务页面工作台,不属于 Agent JSON API,也不进入 `GET /api/agent/openapi.json`: @@ -694,10 +696,10 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h - `PUT /api/feedback`:页面结果反馈写入端点。页面把最近生成的可用性标记和备注写入服务端状态;Agent 只读查询使用 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback`。 - `DELETE /api/feedback`:页面结果反馈清理端点。页面删除历史时按 `clientRequestId` 清理对应服务端反馈;该端点不接受 Agent Bearer token。 - `GET /api/runtime-capabilities`:页面运行态能力摘要。它暴露流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议和 Responses 后端 enablement,不返回 API key 或本地密钥。它不替代也不被 Agent 的 `/api/agent/diagnostics/channel-health` 替代。 -- `POST /api/shares`:页面分享上传创建端点。配置 `APP_PASSWORD` 时要求页面访问 cookie;请求是 form-data `image`、`sourceFilename`、`expiresInMinutes` 和可选 `accessCode`。Agent 客户端不要用它上传 artifact;应使用 `/api/agent/artifacts/{id}/share`。 -- `GET /api/shares/{token}`、`GET /api/shares/{token}/content` 和 `POST /api/shares/{token}/content`:分享元数据和图片内容端点。公开分享支持浏览器直接 GET 内容;私密分享的内容读取通过分享页 POST JSON `accessCode` 校验,并有访问码失败限流;这不是 Agent artifact 下载。 +- `POST /api/shares`:页面分享上传创建端点。配置 `APP_PASSWORD` 时要求页面访问 cookie;请求是 form-data `image`、`sourceFilename`、`expiresInMinutes` 和可选 `accessCode`。Agent 客户端不要用它上传产物;应使用 `/api/agent/artifacts/{id}/share`。 +- `GET /api/shares/{token}`、`GET /api/shares/{token}/content` 和 `POST /api/shares/{token}/content`:分享元数据和图片内容端点。公开分享支持浏览器直接 GET 内容;私密分享的内容读取通过分享页 POST JSON `accessCode` 校验,并有访问码失败限流;这不是 Agent 产物下载。 - `GET /api/logs`:页面日志 SSE。必须配置 `APP_PASSWORD`,并在 `Authorization: Bearer ` 中发送访问码哈希;查询参数中的哈希会被拒绝。它不接受 `AGENT_API_TOKEN`。Agent 只读诊断使用 `/api/agent/diagnostics/page-requests/{id}`。 -- `POST /api/image-delete`:页面图片文件删除端点。请求 JSON 为 `filenames` 和可选 `passwordHash`,按页面生成文件名删除 `generated-images/` 中的图片;它不删除 Agent 状态库 artifact 记录。 +- `POST /api/image-delete`:页面图片文件删除端点。请求 JSON 为 `filenames` 和可选 `passwordHash`,按页面生成文件名删除 `generated-images/` 中的图片;它不删除 Agent 状态库产物记录。 灵感相册和历史复用属于页面工作台和浏览器本地体验。当前没有对应的 Agent capabilities 字段,也不作为机器 API 契约承诺。 @@ -706,7 +708,7 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h | 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /api/agent/image-requests`、`POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts、`POST /api/agent/artifacts/{id}/share` | Agent API | 是 | 普通 generate 默认用 image-requests;其他 Agent 端点通过 skill 脚本和 Agent 鉴权调用。分享创建需要 Agent 鉴权,返回的分享 URL 给用户浏览器访问。 | -| `GET /api/agent/diagnostics/channel-health` | Agent 只读渠道健康诊断 API | 是 | 只返回当前服务进程的路由内存快照,不触发上游探测或图片生成;需 Agent 鉴权,不能证明真实上游可用,也不替代页面 runtime capabilities。 | +| `GET /api/agent/diagnostics/channel-health` | Agent 只读渠道健康诊断 API | 是 | 只返回当前服务进程的路由内存快照,不触发上游探测或图片生成;需 Agent 鉴权,不能证明真实上游可用,也不替代页面 runtime capabilities。 | | `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 | | `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 | | `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 | @@ -714,9 +716,9 @@ node "/scripts/diagnose-request.mjs" --base-url https://your-space.h | `GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | 按页面 `clientRequestId` 查询最新反馈。 | | `POST /api/agent/diagnostics/page-requests` | Agent 日志诊断批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询脱敏日志摘要。 | | `GET /api/agent/diagnostics/page-requests/{id}` | Agent 日志诊断摘要 API | 是 | 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读取 `/api/logs` SSE。 | -| `POST /api/shares`、`GET /api/shares/{token}`、`GET/POST /api/shares/{token}/content` | 分享访问 API | 否 | `POST /api/shares` 是页面上传创建端点,不进入 Agent OpenAPI;`GET/POST /content` 使用分享 token 或访问码服务用户浏览器,不复用 Agent artifact 下载契约。Agent 只通过 `/api/agent/artifacts/{id}/share` 创建这类分享记录。 | +| `POST /api/shares`、`GET /api/shares/{token}`、`GET/POST /api/shares/{token}/content` | 分享访问 API | 否 | `POST /api/shares` 是页面上传创建端点,不进入 Agent OpenAPI;`GET/POST /content` 使用分享 token 或访问码服务用户浏览器,不复用 Agent 产物下载契约。Agent 只通过 `/api/agent/artifacts/{id}/share` 创建这类分享记录。 | | `GET /api/logs` | 页面日志 SSE API | 否 | 使用页面访问码哈希的 Bearer 头,不接受 `AGENT_API_TOKEN`。 | -| `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库 artifact。 | +| `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库产物。 | | 灵感相册 | 浏览器本地工作台状态 | 否 | 只服务页面提示词复用,不作为 Agent capabilities。 | | 历史复用 | 浏览器本地历史状态 | 否 | 只服务页面继续编辑、做变体和复用提示词。 | diff --git a/skills/gpt-image-playground-agent/scripts/batch-images.mjs b/skills/visual-journal-image-agent/scripts/batch-images.mjs similarity index 96% rename from skills/gpt-image-playground-agent/scripts/batch-images.mjs rename to skills/visual-journal-image-agent/scripts/batch-images.mjs index 5a4b399298370d68c1d9381027e678a67e45fa6e..a7ede8b2c43b43213a1478fdb46c6d39a2a8bb5f 100644 --- a/skills/gpt-image-playground-agent/scripts/batch-images.mjs +++ b/skills/visual-journal-image-agent/scripts/batch-images.mjs @@ -1173,8 +1173,14 @@ async function postGenerateTask(task) { validateAgentGenerateRequestAgainstCapabilities( { n: body.n, - partial_images: body.partial_images ?? taskCapabilities?.defaults?.partial_images, - image_backend: body.image_backend + ...(body.partial_images === undefined ? {} : { partial_images: body.partial_images }), + image_backend: body.image_backend, + stream_mode: body.stream_mode, + streaming_strategy: body.streaming_strategy, + model: body.model, + size: body.size, + background: body.background, + force_request: body.force_request }, taskCapabilities ) @@ -1240,11 +1246,16 @@ async function postEditTask(task) { validateAgentEditRequestAgainstCapabilities( { n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1), - partial_images: hasOwn(task.raw, 'partial_images') - ? readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) - : taskCapabilities?.defaults?.partial_images, + ...(hasOwn(task.raw, 'partial_images') + ? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) } + : {}), imageCount: imagePaths.length, - image_backend: task.raw.image_backend + image_backend: task.raw.image_backend, + stream_mode: task.raw.stream_mode, + streaming_strategy: task.raw.streaming_strategy, + model: task.raw.model, + size: task.raw.size, + force_request: readForceRequest(task.raw, task.id) }, taskCapabilities ) @@ -1313,9 +1324,16 @@ function validatePageSseTaskAgainstCapabilities(task, taskCapabilities) { validateAgentEditRequestAgainstCapabilities( { n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1), - partial_images: readTaskPartialImages(task, taskCapabilities), + ...(hasOwn(task.raw, 'partial_images') + ? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) } + : {}), imageCount: readEditImagePaths(task.raw, task.id).length, - image_backend: readTaskImageBackend(task.raw) + image_backend: readTaskImageBackend(task.raw), + stream_mode: task.raw.stream_mode, + streaming_strategy: task.raw.streaming_strategy, + model: task.raw.model, + size: task.raw.size, + force_request: readForceRequest(task.raw, task.id) }, taskCapabilities ); @@ -1324,21 +1342,22 @@ function validatePageSseTaskAgainstCapabilities(task, taskCapabilities) { validateAgentGenerateRequestAgainstCapabilities( { n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1), - partial_images: readTaskPartialImages(task, taskCapabilities), - image_backend: readTaskImageBackend(task.raw) + ...(hasOwn(task.raw, 'partial_images') + ? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) } + : {}), + image_backend: readTaskImageBackend(task.raw), + stream_mode: task.raw.stream_mode, + streaming_strategy: task.raw.streaming_strategy, + model: task.raw.model, + size: task.raw.size, + background: task.raw.background, + force_request: readForceRequest(task.raw, task.id) }, taskCapabilities ); }); } -function readTaskPartialImages(task, taskCapabilities) { - if (hasOwn(task.raw, 'partial_images')) { - return readPartialImages(task.raw.partial_images, `${task.id}.partial_images`); - } - return taskCapabilities?.defaults?.partial_images; -} - function readTaskImageBackend(raw) { return raw.image_backend ? normalizeImageBackendForPage(String(raw.image_backend)) : undefined; } diff --git a/skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs b/skills/visual-journal-image-agent/scripts/channel-capability-matrix.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs rename to skills/visual-journal-image-agent/scripts/channel-capability-matrix.mjs diff --git a/skills/gpt-image-playground-agent/scripts/convert-image-format.mjs b/skills/visual-journal-image-agent/scripts/convert-image-format.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/convert-image-format.mjs rename to skills/visual-journal-image-agent/scripts/convert-image-format.mjs diff --git a/skills/gpt-image-playground-agent/scripts/diagnose-channel-health.mjs b/skills/visual-journal-image-agent/scripts/diagnose-channel-health.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/diagnose-channel-health.mjs rename to skills/visual-journal-image-agent/scripts/diagnose-channel-health.mjs diff --git a/skills/gpt-image-playground-agent/scripts/diagnose-request.mjs b/skills/visual-journal-image-agent/scripts/diagnose-request.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/diagnose-request.mjs rename to skills/visual-journal-image-agent/scripts/diagnose-request.mjs diff --git a/skills/gpt-image-playground-agent/scripts/edit-image.mjs b/skills/visual-journal-image-agent/scripts/edit-image.mjs similarity index 96% rename from skills/gpt-image-playground-agent/scripts/edit-image.mjs rename to skills/visual-journal-image-agent/scripts/edit-image.mjs index 1596afeca69e0e67eda822ca6530007a10ea9dbb..a0ff26704652d4f550a0e3668001d26026e034e3 100755 --- a/skills/gpt-image-playground-agent/scripts/edit-image.mjs +++ b/skills/visual-journal-image-agent/scripts/edit-image.mjs @@ -428,13 +428,13 @@ function validateUpstreamStreamingOptions(parsed) { function hasPageOnlyEditOptions(parsed) { return Boolean( parsed.formatSpecified || - parsed.outputCompression !== undefined || - parsed.moderation || - parsed.imageBackend || - parsed.responsesModel || - parsed.thinking || - parsed.promptOptimization !== undefined || - parsed.forceWeb !== undefined + parsed.outputCompression !== undefined || + parsed.moderation || + parsed.imageBackend || + parsed.responsesModel || + parsed.thinking || + parsed.promptOptimization !== undefined || + parsed.forceWeb !== undefined ); } @@ -575,12 +575,16 @@ try { validateAgentEditRequestAgainstCapabilities( { n: 1, - partial_images: - options.partialImages !== undefined - ? readPartialImages(options.partialImages, '--partial-images') - : capabilities?.defaults?.partial_images, + ...(options.partialImages === undefined + ? {} + : { partial_images: readPartialImages(options.partialImages, '--partial-images') }), imageCount: 1, - image_backend: options.imageBackend + image_backend: options.imageBackend, + stream_mode: options.streamMode, + streaming_strategy: options.streamingStrategy, + model: options.model, + size: options.size, + force_request: options.forceRequest }, capabilities ); @@ -640,11 +644,6 @@ function buildFormData() { if (options.streamingStrategy) formData.append('streaming_strategy', options.streamingStrategy); if (options.partialImages !== undefined) { formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images'))); - } else if (capabilities?.defaults?.partial_images !== undefined) { - formData.append( - 'partial_images', - String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images')) - ); } if (options.forceRequest) formData.append('force_request', 'true'); formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath)); @@ -681,11 +680,6 @@ function buildPageSseFormData() { if (options.streamingStrategy) formData.append('image_streaming_strategy', options.streamingStrategy); if (options.partialImages !== undefined) { formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images'))); - } else if (capabilities?.defaults?.partial_images !== undefined) { - formData.append( - 'partial_images', - String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images')) - ); } if (passwordHash) formData.append('passwordHash', passwordHash); formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath)); diff --git a/skills/gpt-image-playground-agent/scripts/generate-image.mjs b/skills/visual-journal-image-agent/scripts/generate-image.mjs similarity index 98% rename from skills/gpt-image-playground-agent/scripts/generate-image.mjs rename to skills/visual-journal-image-agent/scripts/generate-image.mjs index bbbf1991ded9834cf48ddb687373aab3e69ef277..db9fe49f788fb64cb93e675d3b9e5d0070956ca2 100755 --- a/skills/gpt-image-playground-agent/scripts/generate-image.mjs +++ b/skills/visual-journal-image-agent/scripts/generate-image.mjs @@ -83,7 +83,6 @@ const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check'); let pageSseClientRequestIdMaxLength = DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH; -let pageSsePartialImages = 2; let options; try { options = parseArgs(process.argv.slice(2)); @@ -191,8 +190,14 @@ try { validateAgentGenerateRequestAgainstCapabilities( { n: requestBody.n, - partial_images: requestBody.partial_images ?? capabilities?.defaults?.partial_images, - image_backend: requestBody.image_backend + ...(requestBody.partial_images === undefined ? {} : { partial_images: requestBody.partial_images }), + image_backend: requestBody.image_backend, + stream_mode: requestBody.stream_mode, + streaming_strategy: requestBody.streaming_strategy, + model: requestBody.model, + size: requestBody.size, + background: requestBody.background, + force_request: requestBody.force_request }, capabilities ); @@ -692,10 +697,6 @@ function applyCapabilitiesRuntimeValues(capabilitiesValue) { if (Number.isSafeInteger(maxLength) && maxLength > 0) { pageSseClientRequestIdMaxLength = maxLength; } - const defaultPartialImages = capabilitiesValue?.defaults?.partial_images; - if (defaultPartialImages !== undefined) { - pageSsePartialImages = readPartialImages(defaultPartialImages, 'capabilities.defaults.partial_images'); - } } async function runGenerateRequest(options = {}) { @@ -836,7 +837,9 @@ function buildPageSseFormData() { formData.append('response_mode', requestBody.response_mode); formData.append('clientRequestId', idempotencyKey); if (requestBody.stream_mode) formData.append('stream_mode', requestBody.stream_mode); - formData.append('partial_images', String(requestBody.partial_images ?? pageSsePartialImages)); + if (requestBody.partial_images !== undefined) { + formData.append('partial_images', String(requestBody.partial_images)); + } if (requestBody.image_backend) formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend)); if (requestBody.responsesModel) formData.append('responsesModel', requestBody.responsesModel); @@ -1649,7 +1652,7 @@ function supportsJobPolling(capabilitiesValue) { function supportsServerOrchestration(capabilitiesValue) { return Boolean( capabilitiesValue?.orchestration?.supported === true && - capabilitiesValue.orchestration.transport_selection === 'server_owned' + capabilitiesValue.orchestration.transport_selection === 'server_owned' ); } @@ -1718,10 +1721,10 @@ function createPageSseHttpError(status, detail) { function isPageSseRequestRejected(error) { return Boolean( error && - typeof error === 'object' && - Number.isInteger(error.status) && - error.status >= 400 && - error.status < 500 + typeof error === 'object' && + Number.isInteger(error.status) && + error.status >= 400 && + error.status < 500 ); } diff --git a/skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs b/skills/visual-journal-image-agent/scripts/lib/agent-api-paths.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs rename to skills/visual-journal-image-agent/scripts/lib/agent-api-paths.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs b/skills/visual-journal-image-agent/scripts/lib/agent-diagnostics-summary.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs rename to skills/visual-journal-image-agent/scripts/lib/agent-diagnostics-summary.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs b/skills/visual-journal-image-agent/scripts/lib/channel-capability-matrix.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs rename to skills/visual-journal-image-agent/scripts/lib/channel-capability-matrix.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/dimension-check.mjs b/skills/visual-journal-image-agent/scripts/lib/dimension-check.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/dimension-check.mjs rename to skills/visual-journal-image-agent/scripts/lib/dimension-check.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/image-dimensions.mjs b/skills/visual-journal-image-agent/scripts/lib/image-dimensions.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/image-dimensions.mjs rename to skills/visual-journal-image-agent/scripts/lib/image-dimensions.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs b/skills/visual-journal-image-agent/scripts/lib/page-sse-client.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs rename to skills/visual-journal-image-agent/scripts/lib/page-sse-client.mjs diff --git a/skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs b/skills/visual-journal-image-agent/scripts/lib/script-summary.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs rename to skills/visual-journal-image-agent/scripts/lib/script-summary.mjs diff --git a/skills/visual-journal-image-agent/scripts/lib/script-utils.mjs b/skills/visual-journal-image-agent/scripts/lib/script-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..aff75d5bc281c00fdbffea9c525b61ed0533a85d --- /dev/null +++ b/skills/visual-journal-image-agent/scripts/lib/script-utils.mjs @@ -0,0 +1,521 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const MAX_RETRY_AFTER_SECONDS = 60; +const DIGITS_PATTERN = /^\d+$/; +const IMAGE_SIZE_PATTERN = /^(\d+)x(\d+)$/; +const LEGACY_IMAGE_SIZES = new Set(['auto', '1024x1024', '1536x1024', '1024x1536']); +export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:4783'; +const DEFAULT_PRIVATE_AGENT_ENV_FILE = '.env.agent.local'; +const PRIVATE_AGENT_ENV_PREFIX = 'GPT_IMAGE_'; +const DISABLE_PRIVATE_AGENT_ENV_VALUES = new Set(['0', 'false', 'no']); + +export function loadPrivateAgentEnvFile(options = {}) { + const env = options.env || process.env; + if (isPrivateAgentEnvLoadingDisabled(env)) { + return { loaded: false, skipped: true, reason: 'disabled_by_env' }; + } + const cwd = options.cwd || process.cwd(); + const filePath = options.filePath || findPrivateAgentEnvFile(cwd); + if (!existsSync(filePath)) { + return { loaded: false, skipped: true, reason: 'file_not_found', path: filePath }; + } + const entries = parsePrivateAgentEnvContent(readFileSync(filePath, 'utf8')); + const appliedNames = []; + for (const { name, value } of entries) { + if (!name.startsWith(PRIVATE_AGENT_ENV_PREFIX)) continue; + if (env[name] !== undefined) continue; + env[name] = value; + appliedNames.push(name); + } + return { + loaded: true, + path: filePath, + applied_names: appliedNames + }; +} + +export function readOptionValue(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith('--')) { + throw new Error(`${name} 需要参数值。`); + } + return value; +} + +export function readConfiguredPositiveInteger(value, name, fallback) { + const rawValue = value === undefined || value === null ? '' : String(value).trim(); + if (!rawValue) return fallback; + if (!DIGITS_PATTERN.test(rawValue)) { + throw new Error(`${name} 必须是正整数。`); + } + const parsed = Number(rawValue); + if (!Number.isSafeInteger(parsed) || parsed < 1) { + throw new Error(`${name} 必须是正整数。`); + } + return parsed; +} + +export function readConfiguredNonNegativeInteger(value, name, fallback) { + const rawValue = value === undefined || value === null ? '' : String(value).trim(); + if (!rawValue) return fallback; + if (!DIGITS_PATTERN.test(rawValue)) { + throw new Error(`${name} 必须是非负整数。`); + } + const parsed = Number(rawValue); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`${name} 必须是非负整数。`); + } + return parsed; +} + +export function readCapabilitiesImageTransportTimeoutMs(capabilities, fallback) { + const value = capabilities?.image_transport?.upstream_timeout_ms; + if (!Number.isSafeInteger(value) || value < 1) return fallback; + return Math.max(fallback, value); +} + +export function readPartialImages(value, name = 'partial_images') { + const parsed = readConfiguredNonNegativeInteger(value, name, 2); + if (parsed < 0 || parsed > 4) { + throw new Error(`${name} 必须是 0 到 4 的整数。`); + } + return parsed; +} + +export function validateAgentGenerateRequestAgainstCapabilities(body, capabilities) { + assertNumberWithinCapabilities( + body.n, + readImageCountLimitForBackend(body.image_backend ?? body.imageBackend, capabilities, 'generate_images'), + 'n' + ); + const partialImagesRange = resolvePartialImagesValidationRange(body, capabilities, 'generate'); + if (partialImagesRange) assertNumberWithinCapabilities(body.partial_images, partialImagesRange, 'partial_images'); +} + +export function validateAgentEditRequestAgainstCapabilities(input, capabilities) { + assertNumberWithinCapabilities( + input.n, + readImageCountLimitForBackend(input.image_backend ?? input.imageBackend, capabilities, 'edit_images'), + 'n' + ); + const partialImagesRange = resolvePartialImagesValidationRange(input, capabilities, 'edit'); + if (partialImagesRange) assertNumberWithinCapabilities(input.partial_images, partialImagesRange, 'partial_images'); + assertEditUploadCount(input, capabilities); +} + +const NON_STREAM_PARTIAL_IMAGES_RANGE = { min: 0, max: 4 }; + +function resolvePartialImagesValidationRange(input, capabilities, operation) { + if (input.partial_images === undefined || input.partial_images === null || input.partial_images === '') + return undefined; + const streamMode = input.stream_mode ?? input.streamMode ?? 'auto'; + const streamingStrategy = input.streaming_strategy ?? input.streamingStrategy ?? 'auto'; + const backend = normalizeImageBackend(input.image_backend ?? input.imageBackend); + const requestModes = resolveRequestModes(streamMode, streamingStrategy, backend); + if (requestModes.every((requestMode) => !isStreamingRequestMode(requestMode))) { + return NON_STREAM_PARTIAL_IMAGES_RANGE; + } + + const channels = capabilities?.upstream_request_headers?.channels; + if (!Array.isArray(channels) || channels.length === 0 || !channels.every((channel) => channel?.constraints)) { + return readPartialImagesLimitForBackend(input.image_backend ?? input.imageBackend, capabilities); + } + + const candidates = readCapabilityChannelCandidates(input, capabilities, operation, requestModes); + if (candidates.length === 0) { + return readPartialImagesLimitForBackend(input.image_backend ?? input.imageBackend, capabilities); + } + + const ranges = []; + for (const candidate of candidates) { + for (const requestMode of candidate.requestModes) { + ranges.push( + isStreamingRequestMode(requestMode) + ? readChannelPartialImagesRange(candidate.channel, candidate.backend) + : NON_STREAM_PARTIAL_IMAGES_RANGE + ); + } + } + return ( + unionNumericRanges(ranges.filter(Boolean)) || + readPartialImagesLimitForBackend(input.image_backend ?? input.imageBackend, capabilities) + ); +} + +function normalizeImageBackend(value) { + return value === 'responses' || value === 'responses-image-generation' + ? 'responses-image-generation' + : 'images-api'; +} + +function readPartialImagesLimitForBackend(backend, capabilities) { + const normalizedBackend = + backend === 'responses' || backend === 'responses-image-generation' + ? 'responses-image-generation' + : 'images-api'; + return capabilities?.limits?.partial_images_by_backend?.[normalizedBackend] || capabilities?.limits?.partial_images; +} + +function resolveRequestModes(streamMode, streamingStrategy, backend) { + const nonStreamRequestMode = + backend === 'responses-image-generation' ? 'responses-non-stream' : 'images-non-stream'; + const streamingRequestMode = backend === 'responses-image-generation' ? 'responses-sse' : 'images-sse'; + if (streamMode === 'non_stream' || (streamMode === 'auto' && streamingStrategy === 'off')) { + return [nonStreamRequestMode]; + } + if (streamMode === 'stream' || streamingStrategy !== 'auto') return [streamingRequestMode]; + return [nonStreamRequestMode, streamingRequestMode]; +} + +function isStreamingRequestMode(requestMode) { + return requestMode === 'images-sse' || requestMode === 'responses-sse'; +} + +function readCapabilityChannelCandidates(input, capabilities, operation, requestModes) { + const backend = normalizeImageBackend(input.image_backend ?? input.imageBackend); + const channels = capabilities?.upstream_request_headers?.channels; + if (!Array.isArray(channels)) return []; + return channels.flatMap((channel) => { + if (!channel?.constraints) return []; + const channelModes = requestModes.filter((requestMode) => channelSupportsRequestMode(channel, requestMode)); + if ( + channelModes.length === 0 || + !channelSupportsCommonInput(input, capabilities, channel, operation, backend) + ) { + return []; + } + return [{ channel, backend, requestModes: channelModes }]; + }); +} + +function channelSupportsRequestMode(channel, requestMode) { + if (!Array.isArray(channel.request_modes) || !channel.request_modes.includes(requestMode)) return false; + if (channel.healthy_request_modes === undefined) return true; + return Array.isArray(channel.healthy_request_modes) && channel.healthy_request_modes.includes(requestMode); +} + +function channelSupportsCommonInput(input, capabilities, channel, operation, backend) { + const countRange = readChannelImageCountRange(channel, operation, backend); + if (!countRange || !isIntegerWithinCapabilityRange(input.n, countRange)) return false; + if (operation === 'edit' && Number.isSafeInteger(input.imageCount)) { + if (input.imageCount > channel.constraints.upload_images.max) return false; + // image_sizes contains one source-image size per item, measured in bytes. + const imageSizes = Array.isArray(input.image_sizes) ? input.image_sizes : []; + if ( + imageSizes.some( + (size) => + typeof size === 'number' && size > channel.constraints.upload_images.max_single_mb * 1024 * 1024 + ) + ) { + return false; + } + if ( + channel.constraints.upload_images.max_total_mb !== undefined && + imageSizes.length > 0 && + imageSizes.reduce((total, size) => total + (typeof size === 'number' ? size : 0), 0) > + channel.constraints.upload_images.max_total_mb * 1024 * 1024 + ) { + return false; + } + } + if (input.force_request === true || input.forceRequest === true) return true; + const model = input.model || 'gpt-image-2'; + if (model !== 'gpt-image-2') return true; + const gptImage2 = channel.constraints.gpt_image_2; + const background = input.background || 'auto'; + if (background === 'transparent' && !gptImage2.allow_transparent_background) return false; + return isGptImage2SizeCompatible( + input.size || (operation === 'generate' ? '1024x1024' : 'auto'), + gptImage2, + capabilities + ); +} + +function isGptImage2SizeCompatible(size, constraints, capabilities) { + if (size === 'auto') return true; + const match = IMAGE_SIZE_PATTERN.exec(String(size)); + if (!match) return false; + const width = Number(match[1]); + const height = Number(match[2]); + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) return false; + if (constraints.size_policy === 'positive-integer') return true; + const limits = capabilities?.model_limits?.['gpt-image-2']; + if (!limits) return true; + const maxEdge = Math.max(width, height); + const minEdge = Math.min(width, height); + const pixels = width * height; + return ( + width % limits.edge_multiple === 0 && + height % limits.edge_multiple === 0 && + maxEdge <= limits.max_edge && + pixels <= limits.max_pixels && + pixels >= limits.min_pixels && + maxEdge / minEdge <= limits.max_aspect + ); +} + +function readChannelImageCountRange(channel, operation, backend) { + const key = operation === 'generate' ? 'generate_images_by_backend' : 'edit_images_by_backend'; + return ( + channel.constraints?.[key]?.[backend] || + (backend === 'images-api' ? channel.constraints?.[`${operation}_images`] : undefined) + ); +} + +function readChannelPartialImagesRange(channel, backend) { + return ( + channel.constraints?.partial_images_by_backend?.[backend] || + (backend === 'images-api' ? channel.constraints?.partial_images : undefined) + ); +} + +function unionNumericRanges(ranges) { + const values = ranges.flatMap((range) => { + const allowedValues = Array.isArray(range.allowedValues) + ? range.allowedValues + : Array.isArray(range.allowed_values) + ? range.allowed_values + : undefined; + return allowedValues || Array.from({ length: range.max - range.min + 1 }, (_, index) => range.min + index); + }); + const normalized = Array.from(new Set(values.filter(Number.isSafeInteger))).sort((left, right) => left - right); + if (normalized.length === 0) return undefined; + const min = normalized[0]; + const max = normalized[normalized.length - 1]; + return normalized.length === max - min + 1 ? { min, max } : { min, max, allowedValues: normalized }; +} + +function assertEditUploadCount(input, capabilities) { + const channels = capabilities?.upstream_request_headers?.channels; + if (Array.isArray(channels) && channels.length > 0 && channels.every((channel) => channel?.constraints)) { + const max = Math.max(...channels.map((channel) => channel.constraints.upload_images.max)); + assertMaxCountWithinCapabilities(input.imageCount, max, 'image'); + return; + } + assertMaxCountWithinCapabilities(input.imageCount, capabilities?.limits?.upload_images?.max, 'image'); +} + +function readImageCountLimitForBackend(backend, capabilities, legacyField) { + const normalizedBackend = + backend === 'responses' || backend === 'responses-image-generation' + ? 'responses-image-generation' + : 'images-api'; + return ( + capabilities?.limits?.[`${legacyField}_by_backend`]?.[normalizedBackend] || capabilities?.limits?.[legacyField] + ); +} + +function assertNumberWithinCapabilities(value, limits, fieldName) { + if (value === undefined || value === null || !limits) return; + const allowedValues = Array.isArray(limits.allowedValues) + ? limits.allowedValues + : Array.isArray(limits.allowed_values) + ? limits.allowed_values + : undefined; + if (allowedValues && !allowedValues.includes(value)) { + throw new Error(`${fieldName} 必须是当前 capabilities 允许的值之一:${allowedValues.join(', ')}。`); + } + const min = limits.min; + const max = limits.max; + if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) return; + if (value < min || value > max) { + throw new Error(`${fieldName} 必须在当前 capabilities 允许的 ${min} 到 ${max} 之间。`); + } +} + +function assertMaxCountWithinCapabilities(value, max, fieldName) { + if (value === undefined || value === null || !Number.isSafeInteger(max)) return; + if (value > max) { + throw new Error(`${fieldName} 数量不能超过当前 capabilities 允许的 ${max}。`); + } +} + +export function normalizeBaseUrl(value) { + const normalized = String(value || '') + .trim() + .replace(/\/+$/, ''); + let parsed; + try { + parsed = new URL(normalized); + } catch { + throw new Error('base URL 必须是有效的 http/https 绝对 URL。'); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('base URL 必须使用 http 或 https。'); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error('base URL 不能包含凭据、查询参数或片段。'); + } + return normalized; +} + +export function resolvePlaygroundBaseUrl(explicitBaseUrl, env = process.env) { + if (explicitBaseUrl) { + return { + baseUrl: normalizeBaseUrl(explicitBaseUrl), + source: 'user_provided', + interactive_confirmation_required: false + }; + } + if (env.GPT_IMAGE_PLAYGROUND_URL) { + return { + baseUrl: normalizeBaseUrl(env.GPT_IMAGE_PLAYGROUND_URL), + source: 'GPT_IMAGE_PLAYGROUND_URL', + interactive_confirmation_required: true + }; + } + return { + baseUrl: DEFAULT_PLAYGROUND_BASE_URL, + source: 'default_local_probe', + interactive_confirmation_required: true + }; +} + +export function normalizeOutputFormat(value) { + return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase(); +} + +export function assertValidImageSizeForModel(value, model, label = 'size') { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${label} 必须是字符串。`); + } + if (model !== 'gpt-image-2') { + if (!LEGACY_IMAGE_SIZES.has(value)) { + throw new Error(`${label} 对 ${model} 无效;非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536。`); + } + return value; + } + if (value === 'auto') return value; + const size = parseImageSizeValue(value); + if (!size) throw new Error(`${label} 必须是 auto 或 WIDTHxHEIGHT。`); + assertPositiveIntegerDimensions(size.width, size.height, label); + return value; +} + +export function parseImageSizeValue(value) { + if (typeof value !== 'string') return undefined; + const match = IMAGE_SIZE_PATTERN.exec(value); + return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined; +} + +export function readMaxImageEdge(value) { + const size = parseImageSizeValue(value); + return size ? Math.max(size.width, size.height) : 0; +} + +export function parseRetryAfterValue(value, fallback = 1) { + if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS; + return clampRetryAfterSeconds(parsed); +} + +export function sleep(seconds) { + return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); +} + +function clampRetryAfterSeconds(value) { + if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS; + return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value))); +} + +export function resolveSameOriginUrl(baseUrl, value, label) { + const base = new URL(baseUrl); + const resolved = new URL(value, `${baseUrl}/`); + if (resolved.origin !== base.origin) { + throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`); + } + return resolved.toString(); +} + +export function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function isPrivateAgentEnvLoadingDisabled(env) { + return DISABLE_PRIVATE_AGENT_ENV_VALUES.has( + String(env.GPT_IMAGE_AGENT_LOAD_ENV_FILE || '') + .trim() + .toLowerCase() + ); +} + +function findPrivateAgentEnvFile(cwd) { + const start = resolve(cwd); + let current = start; + while (true) { + const candidate = join(current, DEFAULT_PRIVATE_AGENT_ENV_FILE); + if (existsSync(candidate)) return candidate; + if (isPrivateAgentEnvSearchBoundary(current) || dirname(current) === current) { + return join(start, DEFAULT_PRIVATE_AGENT_ENV_FILE); + } + current = dirname(current); + } +} + +function isPrivateAgentEnvSearchBoundary(directory) { + return ( + existsSync(join(directory, '.git')) || isPlaygroundProjectRoot(directory) || isStandaloneSkillRoot(directory) + ); +} + +function isPlaygroundProjectRoot(directory) { + return ( + existsSync(join(directory, 'package.json')) && + existsSync(join(directory, 'skills/visual-journal-image-agent/SKILL.md')) + ); +} + +function isStandaloneSkillRoot(directory) { + return ( + existsSync(join(directory, 'SKILL.md')) && + existsSync(join(directory, 'scripts')) && + !isPlaygroundProjectRoot(dirname(dirname(directory))) + ); +} + +function parsePrivateAgentEnvContent(content) { + const entries = []; + for (const line of content.split(/\r?\n/)) { + const parsed = parsePrivateAgentEnvLine(line); + if (parsed) entries.push(parsed); + } + return entries; +} + +function parsePrivateAgentEnvLine(line) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return undefined; + const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match) return undefined; + return { name: match[1], value: parsePrivateAgentEnvValue(match[2].trim()) }; +} + +function parsePrivateAgentEnvValue(value) { + if (value.length < 2) return value; + if (value.startsWith('"') || value.startsWith("'")) return parseQuotedPrivateAgentEnvValue(value); + return stripPrivateAgentEnvComment(value).trim(); +} + +function parseQuotedPrivateAgentEnvValue(value) { + const quote = value[0]; + const closeIndex = value.indexOf(quote, 1); + if (closeIndex < 0) return value.slice(1); + return value.slice(1, closeIndex); +} + +function stripPrivateAgentEnvComment(value) { + const index = value.search(/\s#/); + if (index < 0) return value; + return value.slice(0, index); +} + +function assertPositiveIntegerDimensions(width, height, label) { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error(`${label} 的宽度和高度必须是正数。`); + } + if (!Number.isInteger(width) || !Number.isInteger(height)) { + throw new Error(`${label} 的宽度和高度必须是整数。`); + } +} diff --git a/skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs b/skills/visual-journal-image-agent/scripts/probe-upstream-image.mjs similarity index 100% rename from skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs rename to skills/visual-journal-image-agent/scripts/probe-upstream-image.mjs diff --git a/src/app/api/agent/agent-routes.test.ts b/src/app/api/agent/agent-routes.test.ts index 637166a4a3af5e8bf697696903b0417ff9a6df71..6c477ae34ab2089203ef57277489db8e1f95b9e8 100644 --- a/src/app/api/agent/agent-routes.test.ts +++ b/src/app/api/agent/agent-routes.test.ts @@ -165,6 +165,25 @@ describe('Agent route integration', () => { has_extra_headers: true, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: ['user-agent', 'x-app-id', 'x-app-secret', 'x-trace-token'] + }, + constraints: { + generate_images: { min: 1, max: 4 }, + edit_images: { min: 1, max: 4 }, + partial_images: { min: 0, max: 4 }, + generate_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + edit_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + partial_images_by_backend: { + 'images-api': { min: 0, max: 4 }, + 'responses-image-generation': { min: 1, max: 3 } + }, + upload_images: { max: 8, max_single_mb: 10, max_total_mb: 80 }, + gpt_image_2: { allow_transparent_background: true, size_policy: 'positive-integer' } } } ]); @@ -216,10 +235,29 @@ describe('Agent route integration', () => { request_modes: ['images-non-stream', 'images-sse'], request_mode_priority: ['images-sse', 'images-non-stream'], request_headers: { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: [] + }, + constraints: { + generate_images: { min: 1, max: 10 }, + edit_images: { min: 1, max: 10 }, + partial_images: { min: 1, max: 3 }, + generate_images_by_backend: { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + edit_images_by_backend: { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + partial_images_by_backend: { + 'images-api': { min: 1, max: 3 }, + 'responses-image-generation': { min: 1, max: 3 } + }, + upload_images: { max: 10, max_single_mb: 25 }, + gpt_image_2: { allow_transparent_background: false, size_policy: 'openai-compatible' } } } ]); @@ -433,6 +471,25 @@ describe('Agent route integration', () => { has_extra_headers: true, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: ['user-agent', 'x-app-id', 'x-app-secret', 'x-trace-token'] + }, + constraints: { + generate_images: { min: 1, max: 4 }, + edit_images: { min: 1, max: 4 }, + partial_images: { min: 0, max: 4 }, + generate_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + edit_images_by_backend: { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + partial_images_by_backend: { + 'images-api': { min: 0, max: 4 }, + 'responses-image-generation': { min: 1, max: 3 } + }, + upload_images: { max: 8, max_single_mb: 10, max_total_mb: 80 }, + gpt_image_2: { allow_transparent_background: true, size_policy: 'positive-integer' } } } ]); @@ -501,7 +558,7 @@ describe('Agent route integration', () => { upstream_host: new URL(upstream.baseUrl).host }); assert.equal(firstBody.execution.upstream_host, new URL(upstream.baseUrl).host); - assert.equal(firstBody.execution.request_headers.user_agent_effective, 'visual-journal/2.2.0'); + assert.equal(firstBody.execution.request_headers.user_agent_effective, 'visual-journal/2.3.0'); assert.equal(firstBody.execution.request_headers.has_extra_headers, false); assert.equal(typeof firstBody.timing.elapsed_ms, 'number'); assert.equal(firstBody.timing.elapsed_ms >= 0, true); @@ -670,6 +727,292 @@ describe('Agent route integration', () => { } }); + it('routes mixed provider image counts to the channel that accepts the requested count', async () => { + const { generateImage } = await loadAgentRoutes(); + let firstCalls = 0; + let secondCalls = 0; + const first = await startImageUpstream(() => { + firstCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + const second = await startImageUpstream(() => { + secondCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_CHANNEL_1_ID = 'fixed-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = first.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = second.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'second-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + + try { + const twoImageResponse = await generateImage( + agentJsonRequest('mixed-count-two-key', { prompt: 'mixed provider count two', n: 2 }) + ); + assert.equal(twoImageResponse.status, 200); + const twoImageBody = await twoImageResponse.json(); + assert.equal(twoImageBody.execution.selected_channel_id, 'fixed-two'); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 1); + + const oneImageResponse = await generateImage( + agentJsonRequest('mixed-count-one-key', { prompt: 'mixed provider count one', n: 1 }) + ); + assert.equal(oneImageResponse.status, 200); + const oneImageBody = await oneImageResponse.json(); + assert.equal(oneImageBody.execution.selected_channel_id, 'fixed-one'); + assert.equal(firstCalls, 1); + assert.equal(secondCalls, 1); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + + it('fails explicitly when no mixed-channel provider accepts the requested count', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + const backup = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_CHANNEL_1_ID = 'fixed-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'fixed-three'; + process.env.OPENAI_CHANNEL_2_BASE_URL = backup.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'backup-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-three-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 3, max: 3 } } + }); + + try { + const response = await generateImage( + agentJsonRequest('mixed-count-no-provider-key', { prompt: 'mixed provider count gap', n: 2 }) + ); + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.details.fields.n, /以下整数之一:1, 3/); + assert.equal(upstreamCalls, 0); + } finally { + await Promise.all([upstream.close(), backup.close()]); + } + }); + + it('routes automatic streaming to the mixed-channel credential that accepts partial_images', async () => { + const { generateImage } = await loadAgentRoutes(); + let firstCalls = 0; + let secondCalls = 0; + const first = await startStreamingImageUpstream(() => { + firstCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + const second = await startStreamingImageUpstream(() => { + secondCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_ROUTING_STRATEGY = 'round_robin'; + process.env.OPENAI_CHANNEL_1_ID = 'stream-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = first.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'stream-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'stream-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = second.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'second-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'stream-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 2, max: 2 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const response = await generateImage( + agentJsonRequest('mixed-stream-partial-two-key', { + prompt: 'mixed stream partial two', + stream_mode: 'auto', + streaming_strategy: 'auto', + partial_images: 2 + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.execution.selected_channel_id, 'stream-two'); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 1); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + + it('skips an Agent SSE channel marked unavailable during automatic routing', async () => { + const { generateImage } = await loadAgentRoutes(); + let markedCalls = 0; + let healthyCalls = 0; + const marked = await startStreamingImageUpstream(() => { + markedCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + const healthy = await startStreamingImageUpstream(() => { + healthyCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_CHANNEL_1_ID = 'marked-agent-sse'; + process.env.OPENAI_CHANNEL_1_BASE_URL = marked.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'marked-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_ID = 'healthy-agent-sse'; + process.env.OPENAI_CHANNEL_2_BASE_URL = healthy.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'healthy-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + const { getServerChannelState, resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + getServerChannelState().streamingAvailability.markUnavailable({ + channelId: 'marked-agent-sse', + imageBackend: 'images-api', + streamingStrategy: 'auto', + operation: 'generate', + reason: 'test_mark' + }); + const response = await generateImage( + agentJsonRequest('agent-marked-sse-key', { + prompt: 'skip marked Agent SSE channel', + stream_mode: 'auto', + streaming_strategy: 'auto' + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.execution.selected_channel_id, 'healthy-agent-sse'); + assert.equal(markedCalls, 0); + assert.equal(healthyCalls, 1); + } finally { + await Promise.all([marked.close(), healthy.close()]); + } + }); + + it('chooses the n-compatible SSE channel when automatic partial_images is omitted', async () => { + const { generateImage } = await loadAgentRoutes(); + let sseCalls = 0; + const sse = await startStreamingImageUpstream(() => { + sseCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_ROUTING_STRATEGY = 'round_robin'; + process.env.OPENAI_CHANNEL_1_ID = 'non-stream-count-two'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://non-stream.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'non-stream-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'non-stream-count-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'sse-count-one'; + process.env.OPENAI_CHANNEL_2_BASE_URL = sse.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'sse-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'sse-count-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { + generate_count: { min: 1, max: 1 }, + partial_images: { min: 3, max: 3 } + } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const response = await generateImage( + agentJsonRequest('mixed-auto-omitted-partial-key', { + prompt: 'mixed automatic request without an explicit preview count', + n: 1, + stream_mode: 'auto', + streaming_strategy: 'auto' + }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.execution.selected_channel_id, 'sse-count-one'); + assert.equal(body.execution.channel_request_mode, 'images-sse'); + assert.equal(sseCalls, 1); + } finally { + await sse.close(); + } + }); + it('does not send upstream stream parameters when Agent streaming_strategy is off', async () => { const { generateImage } = await loadAgentRoutes(); let upstreamBody = ''; @@ -700,6 +1043,46 @@ describe('Agent route integration', () => { } }); + it('ignores Agent non-stream partial_images outside the provider preview range', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startImageUpstream((body) => { + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_CHANNEL_1_ID = 'no-previews'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'no-previews-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const response = await generateImage( + agentJsonRequest('agent-non-stream-ignored-partial-key', { + prompt: 'agent non-stream ignored partial images', + stream_mode: 'auto', + streaming_strategy: 'off', + partial_images: 2 + }) + ); + + const responseBody = await response.json(); + assert.equal(response.status, 200, JSON.stringify(responseBody)); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, false); + assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false); + } finally { + await upstream.close(); + } + }); + it('uses the lower-cost non-streaming channel request mode for Agent auto streaming by default', async () => { const { generateImage } = await loadAgentRoutes(); const { getServerChannelState } = await import('@/lib/server-channel-router'); @@ -1227,6 +1610,63 @@ describe('Agent route integration', () => { assert.match(body.error.details.fields.partial_images, /1 到 3/); }); + it('accepts ignored partial_images on non-stream Responses requests with a zero provider range', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startResponsesImageJsonUpstream( + 200, + { + id: 'response-non-stream-partial-zero', + object: 'response', + status: 'completed', + output: [ + { + id: 'image-non-stream-partial-zero', + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }, + (body) => { + upstreamBody = body; + } + ); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_CHANNEL_1_ID = 'responses-non-stream'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'responses-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'responses-partial-zero-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + + try { + const response = await generateImage( + agentJsonRequest('agent-responses-non-stream-partial-zero-key', { + prompt: 'agent responses non-stream partial zero', + image_backend: 'responses-image-generation', + stream_mode: 'non_stream', + streaming_strategy: 'off', + partial_images: 2 + }) + ); + + assert.equal(response.status, 200); + const responseBody = await response.json(); + assert.equal(responseBody.execution.channel_request_mode, 'responses-non-stream'); + const upstreamJson = JSON.parse(upstreamBody) as Record; + const tools = upstreamJson.tools as Array>; + assert.equal('partial_images' in tools[0], false); + } finally { + await upstream.close(); + } + }); + it('rejects Agent Images partial_images outside the selected OpenAI-compatible profile before calling upstream', async () => { const { generateImage } = await loadAgentRoutes(); let upstreamCalls = 0; @@ -2313,6 +2753,152 @@ describe('Agent route integration', () => { await upstream.close(); }); + it('routes mixed provider edit counts to the channel that accepts the requested count', async () => { + const { editImage } = await loadAgentRoutes(); + let firstCalls = 0; + let secondCalls = 0; + const first = await startImageUpstream(() => { + firstCalls += 1; + return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; + }); + const second = await startImageUpstream(() => { + secondCalls += 1; + return { data: [{ b64_json: PNG_CONVERTIBLE_BASE64 }] }; + }); + process.env.OPENAI_CHANNEL_1_ID = 'fixed-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = first.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-one-edit-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits', content_type: 'multipart/form-data' } } }, + constraints: { edit_count: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = second.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'second-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-two-edit-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits', content_type: 'multipart/form-data' } } }, + constraints: { edit_count: { min: 2, max: 2 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const response = await editImage( + agentEditRequest('mixed-agent-edit-two-key', 'mixed provider edit count two', {}, { n: '2' }) + ); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.execution.selected_channel_id, 'fixed-two'); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 1); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + + it('rejects mixed-channel Agent edit partial_images before channel selection', async () => { + const { editImage } = await loadAgentRoutes(); + process.env.OPENAI_CHANNEL_1_ID = 'edit-partial-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://edit-one.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'edit-one-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'edit-partial-one-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits' } } }, + constraints: { partial_images: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'edit-partial-three'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://edit-three.example.com/v1'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'edit-three-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'edit-partial-three-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits' } } }, + constraints: { partial_images: { min: 3, max: 3 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + const response = await editImage( + agentEditRequest( + 'mixed-edit-partial-invalid-key', + 'mixed edit partial invalid', + {}, + { + stream_mode: 'stream', + streaming_strategy: 'force-sse', + partial_images: '2' + } + ) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.details.fields.partial_images, /partial_images 必须在 1 到 1 之间/); + }); + + it('ignores cooled non-stream edit channels when validating partial_images', async () => { + const { editImage } = await loadAgentRoutes(); + process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED = 'true'; + process.env.OPENAI_CHANNEL_1_ID = 'cooled-edit-non-stream'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://cooled-edit.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'cooled-edit-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'cooled-edit-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'healthy-edit-sse'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://healthy-edit.example.com/v1'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'healthy-edit-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'healthy-edit-provider', + base_profile: 'openai-compatible', + modes: { edit: { submit: { path: '/images/edits' } } }, + constraints: { partial_images: { min: 3, max: 3 } } + }); + const { getServerChannelState, resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const state = getServerChannelState(); + const cooledCredential = state.config.credentials.find( + (credential) => credential.channelId === 'cooled-edit-non-stream' + ); + assert.ok(cooledCredential); + assert.ok(state.router); + state.router.reportFailure(cooledCredential, { scope: 'channel', requestMode: 'images-non-stream' }); + + const response = await editImage( + agentEditRequest( + 'cooled-edit-partial-invalid-key', + 'cooled edit partial invalid', + {}, + { + stream_mode: 'auto', + streaming_strategy: 'auto', + partial_images: '1' + } + ) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.details.fields.partial_images, /partial_images 必须在 3 到 3 之间/); + }); + it('does not consume edit idempotency keys for local input validation failures', async () => { const { editImage } = await loadAgentRoutes(); let upstreamCalls = 0; @@ -4058,15 +4644,23 @@ async function startStreamingResponsesImageUpstream( async function startResponsesImageJsonUpstream( status: number, - body: unknown + body: unknown, + onRequestBody?: (body: string) => void ): Promise<{ baseUrl: string; close: () => Promise }> { - const server = http.createServer((request, response) => { + const server = http.createServer(async (request, response) => { if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { response.writeHead(404, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: { message: 'not found' } })); return; } - request.resume(); + if (onRequestBody) { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + onRequestBody(Buffer.concat(chunks).toString('utf8')); + } else { + request.resume(); + } response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); }); diff --git a/src/app/api/agent/diagnostics/channel-health/route.test.ts b/src/app/api/agent/diagnostics/channel-health/route.test.ts index b5846527e36cc1336865931c893ee23a019cd10a..fbbebd84cf36ebfca1c2cf5822dfe015bdc453be 100644 --- a/src/app/api/agent/diagnostics/channel-health/route.test.ts +++ b/src/app/api/agent/diagnostics/channel-health/route.test.ts @@ -11,7 +11,7 @@ let originalEnv: NodeJS.ProcessEnv; beforeEach(() => { originalEnv = { ...process.env }; - process.env.NODE_ENV = 'test'; + Object.assign(process.env, { NODE_ENV: 'test' }); process.env.npm_lifecycle_event = 'test'; process.env.AGENT_API_TOKEN = 'health-diagnostics-token'; process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED = 'true'; diff --git a/src/app/api/deploy-marker/route.ts b/src/app/api/deploy-marker/route.ts index d8c37133c936a92dd6adbfe66c58f83717b4ecd8..c237c6c25e0ebec56a09b9a557af06e1839e2487 100644 --- a/src/app/api/deploy-marker/route.ts +++ b/src/app/api/deploy-marker/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -const deployMarker = {"schema_version":1,"local_sha":"555483031327561b81a266eb15c58ed3019536b7","created_at":"2026-07-29T04:53:28.602Z","deploy_id":"e74cf06b-8aed-4779-b443-772bcb0ee272"} as const; +const deployMarker = {"schema_version":1,"local_sha":"9d61ce95a120e0356988adfca282e04c52ea4364","created_at":"2026-08-13T10:16:56.322Z","deploy_id":"c95caa4a-cdaf-4024-b6fa-df7effcc28a9"} as const; export const dynamic = 'force-dynamic'; diff --git a/src/app/api/images/route-responses.test.ts b/src/app/api/images/route-responses.test.ts index d82f64810e8407bc987db3aa6d636f2908cfbb18..adf225f546b1520597d2677f11d768f0208e2693 100644 --- a/src/app/api/images/route-responses.test.ts +++ b/src/app/api/images/route-responses.test.ts @@ -307,7 +307,7 @@ describe('POST /api/images streaming and Responses backends', { concurrency: fal }) ); assert.equal(multiImage.status, 400); - assert.match(String(((await multiImage.json()) as Record).error), /单张生成/); + assert.match(String(((await multiImage.json()) as Record).error), /n 必须在 1 到 1 之间/); }); it('rejects multi-image edit requests for the Responses API backend before contacting upstream', async () => { @@ -326,7 +326,138 @@ describe('POST /api/images streaming and Responses backends', { concurrency: fal }) ); assert.equal(edit.status, 400); - assert.match(String(((await edit.json()) as Record).error), /单张编辑/); + assert.match(String(((await edit.json()) as Record).error), /n 必须在 1 到 1 之间/); + }); + + it('rejects Responses requests when a provider requires two output images', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'false'; + let upstreamCalls = 0; + const upstream = await startResponsesImageUpstream(async () => { + upstreamCalls += 1; + return { output: [] }; + }); + + try { + process.env.OPENAI_CHANNEL_1_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const { POST } = await import('./route'); + + const response = await POST( + imageFormRequest({ + stream: false, + streamMode: 'non_stream', + imageBackend: 'responses-image-generation' + }) + ); + + assert.equal(response.status, 422); + const body = (await response.json()) as Record; + assert.match(String(body.error), /图片数量范围没有可用交集/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('allows non-streaming Responses requests when only partial images are unsupported', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'false'; + let upstreamBody = ''; + const upstream = await startResponsesImageUpstream(async (body) => { + upstreamBody = body; + return { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }; + }); + + try { + process.env.OPENAI_CHANNEL_1_ID = 'no-previews'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-non-stream,responses-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'no_previews_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const { POST } = await import('./route'); + + const response = await POST( + imageFormRequest({ + stream: true, + streamMode: 'non_stream', + imageBackend: 'responses-image-generation', + partialImages: '0' + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, false); + assert.equal('partial_images' in upstreamJson, false); + } finally { + await upstream.close(); + } + }); + + it('still rejects streaming Responses requests when the provider has no preview range', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'false'; + process.env.OPENAI_CHANNEL_1_ID = 'no-previews-stream'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'http://127.0.0.1:1/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-non-stream,responses-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'no_previews_stream_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const { POST } = await import('./route'); + + const response = await POST( + imageFormRequest({ + stream: true, + streamMode: 'stream', + imageBackend: 'responses-image-generation', + imageStreamingStrategy: 'responses-sse', + partialImages: '1' + }) + ); + + assert.equal(response.status, 422); + const body = (await response.json()) as Record; + assert.match(String(body.error), /partial_images范围没有可用交集/); }); it('uses the Responses API image backend only when the flag and request opt-in are both present', async () => { @@ -519,7 +650,7 @@ describe('POST /api/images streaming and Responses backends', { concurrency: fal assert.equal(response.status, 400); const body = (await response.json()) as Record; - assert.match(String(body.error), /Responses API.*partial_images/); + assert.match(String(body.error), /partial_images 必须在 1 到 3 之间/); }); it('keeps separate Responses final items when their base64 payloads match', async () => { diff --git a/src/app/api/images/route.test.ts b/src/app/api/images/route.test.ts index 5cb2c1c3037971ce5b817137d515a99f1e65a988..f5533aaeab7698105302e1fa39ed4ef801201891 100644 --- a/src/app/api/images/route.test.ts +++ b/src/app/api/images/route.test.ts @@ -164,6 +164,228 @@ describe('POST /api/images Images API streaming', { concurrency: false }, () => } }); + it('does not downgrade an unavailable page SSE channel to a non-streaming request', async () => { + const { POST } = await import('./route'); + const { getServerChannelState } = await import('@/lib/server-channel-router'); + let upstreamCalls = 0; + const upstream = await startStreamingImageUpstream(async () => { + upstreamCalls += 1; + return [{ data: { data: [{ b64_json: PNG_BASE64 }] } }]; + }); + process.env.OPENAI_CHANNEL_1_ID = 'sse-marked-unavailable'; + process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + + try { + const state = getServerChannelState(); + state.streamingAvailability.markUnavailable({ + channelId: 'sse-marked-unavailable', + imageBackend: 'images-api', + streamingStrategy: 'force-sse', + operation: 'generate', + reason: 'test_mark' + }); + + const response = await POST( + imageFormRequest({ + streamMode: 'auto', + imageStreamingStrategy: 'force-sse' + }) + ); + + assert.equal(response.status, 503); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('routes automatic page SSE to a healthy channel when another SSE channel is marked unavailable', async () => { + const { POST } = await import('./route'); + const { getServerChannelState } = await import('@/lib/server-channel-router'); + let markedChannelCalls = 0; + let healthyChannelCalls = 0; + const markedChannel = await startStreamingImageUpstream(async () => { + markedChannelCalls += 1; + return [{ data: { data: [{ b64_json: PNG_BASE64 }] } }]; + }); + const healthyChannel = await startStreamingImageUpstream(async () => { + healthyChannelCalls += 1; + return [{ data: { data: [{ b64_json: PNG_BASE64 }] } }]; + }); + process.env.OPENAI_ROUTING_STRATEGY = 'round_robin'; + process.env.OPENAI_CHANNEL_1_ID = 'marked-sse'; + process.env.OPENAI_CHANNEL_1_BASE_URL = markedChannel.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'marked-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_ID = 'healthy-sse'; + process.env.OPENAI_CHANNEL_2_BASE_URL = healthyChannel.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'healthy-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const state = getServerChannelState(); + state.streamingAvailability.markUnavailable({ + channelId: 'marked-sse', + imageBackend: 'images-api', + streamingStrategy: 'force-sse', + operation: 'generate', + reason: 'test_mark' + }); + + const response = await POST( + imageFormRequest({ + streamMode: 'auto', + imageStreamingStrategy: 'force-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(markedChannelCalls, 0); + assert.equal(healthyChannelCalls, 1); + await response.arrayBuffer(); + } finally { + await Promise.all([markedChannel.close(), healthyChannel.close()]); + } + }); + + it('routes mixed provider generate and edit counts to the eligible server channel', async () => { + const { POST } = await import('./route'); + let firstCalls = 0; + let secondCalls = 0; + const first = await startImagesJsonUpstream(async (_body, _url, request) => { + if (request.method === 'POST') firstCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + const second = await startImagesJsonUpstream(async (_body, _url, request) => { + if (request.method === 'POST') secondCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_CHANNEL_1_ID = 'fixed-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = first.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { + generate_count: { min: 1, max: 1 }, + edit_count: { min: 1, max: 1 } + } + }); + process.env.OPENAI_CHANNEL_2_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = second.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'second-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { + generate_count: { min: 2, max: 2 }, + edit_count: { min: 2, max: 2 } + } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const generate = await POST( + imageFormRequest({ + streamMode: 'non_stream', + n: '2', + clientRequestId: 'mixed-page-generate-two' + }) + ); + assert.equal(generate.status, 200); + + const edit = await POST( + imageFormRequest({ + mode: 'edit', + streamMode: 'non_stream', + n: '2', + clientRequestId: 'mixed-page-edit-two' + }) + ); + assert.equal(edit.status, 200); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 2); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + + it('routes automatic streaming to the mixed-channel credential that accepts partial_images', async () => { + const { POST } = await import('./route'); + let firstCalls = 0; + let secondCalls = 0; + const first = await startStreamingImageUpstream(async () => { + firstCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + const second = await startStreamingImageUpstream(async () => { + secondCalls += 1; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_ROUTING_STRATEGY = 'round_robin'; + process.env.OPENAI_CHANNEL_1_ID = 'stream-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = first.baseUrl; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'first-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'stream-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'stream-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = second.baseUrl; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'second-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'stream-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 2, max: 2 } } + }); + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + + try { + const response = await POST( + imageFormRequest({ + stream: true, + streamMode: 'auto', + imageStreamingStrategy: 'auto', + partialImages: '2', + clientRequestId: 'mixed-page-stream-partial-two' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + await readSseEvents(response); + assert.equal(firstCalls, 0); + assert.equal(secondCalls, 1); + } finally { + await Promise.all([first.close(), second.close()]); + } + }); + it('retries accepted async image tasks with the same upstream idempotency key', async () => { const { POST } = await import('./route'); const observedIdempotencyKeys: Array = []; diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts index 3195c05a5cc7fd80c504d3664e7c9ca5c569a177..1c31e1469242b88e3b67602fc86366e8b7d578f6 100755 --- a/src/app/api/images/route.ts +++ b/src/app/api/images/route.ts @@ -1,22 +1,32 @@ import { appLogger } from '@/lib/app-logger'; import type { ChannelCapacityLease } from '@/lib/channel-capacity-queue'; import { + getEffectiveChannelRequestModes, isStreamingChannelRequestMode, resolveChannelRequestMode, type ChannelRequestMode } from '@/lib/channel-request-mode'; -import { type ChannelCredential, resolveEffectiveCredential } from '@/lib/channel-router'; +import { + isChannelCredentialRequestModeHealthy, + type ChannelCredential, + resolveEffectiveCredential +} from '@/lib/channel-router'; import { RequestValidationError, assertSafeApiOverride, readCount, + readBackground, + readImageFiles, + readMaskFile, readMode, readModel, readPlainHttpApiBaseUrlAllowlist, readRequiredText, + readSize, readStorageMode, validateApiBaseUrl } from '@/lib/image-request-utils'; +import type { ImageMode } from '@/lib/image-request-utils'; import { handleEditImageMode, handleGenerateImageMode } from '@/lib/image-route-mode-handlers'; import { assertResponsesImageBackendAllowed, @@ -39,6 +49,9 @@ import { persistOpenAiImages } from '@/lib/image-service'; import { + clampIntegerToRange, + getImageBackendCompatibility, + isIntegerWithinRange, mergeUpstreamHeadersWithFixed, readImageUpstreamProfile, type PartialImagesCount @@ -113,6 +126,201 @@ function toPartialImagesCount(value: number): PartialImagesCount { throw new RequestValidationError('partial_images 必须在 0 到 4 之间。'); } +function readOptionalPositiveCount(formData: FormData, field: string): number | undefined { + const value = formData.get(field); + if (value === null) return undefined; + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + const count = Number(value); + if (!Number.isSafeInteger(count) || count < 1) { + throw new RequestValidationError(`${field} 必须是正整数。`); + } + return count; +} + +function readOptionalInteger(formData: FormData, field: string): number | undefined { + const value = formData.get(field); + if (value === null) return undefined; + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + const count = Number(value); + if (!Number.isSafeInteger(count)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + return count; +} + +function readOptionalPartialImages(formData: FormData): number | undefined { + const value = readOptionalInteger(formData, 'partial_images'); + return value === undefined ? undefined : toPartialImagesCount(value); +} + +function isPageCredentialEligible(input: { + credential: ChannelCredential; + requestMode?: ChannelRequestMode; + mode: ImageMode; + imageBackend: ImageGenerationBackend; + formData: FormData; + model: ReturnType; + forceRequest: boolean; + streamMode: ImageStreamMode; + streamingStrategy: ImageStreamingStrategy; + requestedImageCount?: number; + requestedPartialImages?: number; +}): boolean { + if (!input.requestMode) return true; + if ( + input.streamMode === 'auto' && + isStreamingChannelRequestMode(input.requestMode) && + getServerChannelState().streamingAvailability.isUnavailable({ + channelId: input.credential.channelId, + imageBackend: input.imageBackend, + streamingStrategy: input.streamingStrategy, + operation: input.mode + }) + ) { + return false; + } + const upstreamProfile = + input.credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: input.credential.upstreamProfile, + channelId: input.credential.channelId, + baseUrl: input.credential.baseUrl + }); + const validatePartialImages = isStreamingChannelRequestMode(input.requestMode); + const compatibility = getImageBackendCompatibility(upstreamProfile, input.mode, input.imageBackend, undefined, { + validatePartialImages + }); + const imageCountRange = compatibility.imageCountRange; + if (!compatibility.compatible || !imageCountRange) return false; + if (input.requestedImageCount !== undefined && !isIntegerWithinRange(input.requestedImageCount, imageCountRange)) { + return false; + } + if (validatePartialImages) { + const partialImagesRange = compatibility.partialImagesRange; + if ( + !partialImagesRange || + (input.requestedPartialImages !== undefined && + !isIntegerWithinRange(input.requestedPartialImages, partialImagesRange)) + ) { + return false; + } + } + try { + if (input.mode === 'generate') { + readBackground(input.formData, input.model, upstreamProfile, { forceRequest: input.forceRequest }); + readSize(input.formData, 'size', '1024x1024', input.model, upstreamProfile, { + forceRequest: input.forceRequest + }); + } else { + readSize(input.formData, 'size', 'auto', input.model, upstreamProfile, { + forceRequest: input.forceRequest + }); + readImageFiles(input.formData, upstreamProfile); + readMaskFile(input.formData, upstreamProfile); + } + return true; + } catch (error) { + if (error instanceof RequestValidationError) return false; + throw error; + } +} + +function validatePageInputAgainstConfiguredProfiles(input: { + credentials: readonly ChannelCredential[]; + formData: FormData; + mode: ImageMode; + imageBackend: ImageGenerationBackend; + model: ReturnType; + forceRequest: boolean; + requestedImageCount?: number; + requestedPartialImages?: number; + requestModes: readonly ChannelRequestMode[]; +}): void { + let firstValidationError: RequestValidationError | undefined; + let hasHealthyRequestMode = false; + const router = getServerChannelState().router; + for (const credential of input.credentials) { + const profile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + const requestModes = input.requestModes.filter((requestMode) => + getEffectiveChannelRequestModes(credential).includes(requestMode) + ); + if (requestModes.length === 0) continue; + for (const requestMode of requestModes) { + if (!isChannelCredentialRequestModeHealthy(router, credential, requestMode)) continue; + hasHealthyRequestMode = true; + try { + const validatePartialImages = isStreamingChannelRequestMode(requestMode); + const compatibility = getImageBackendCompatibility(profile, input.mode, input.imageBackend, undefined, { + validatePartialImages + }); + const imageCountRange = compatibility.imageCountRange; + if (!compatibility.compatible || !imageCountRange) { + throw new RequestValidationError( + compatibility.errors.map((error) => error.message).join(' ') || + '当前图片后端没有可用的图片数量约束。', + 422 + ); + } + if ( + input.requestedImageCount !== undefined && + !isIntegerWithinRange(input.requestedImageCount, imageCountRange) + ) { + throw new RequestValidationError( + imageCountRange.allowedValues + ? `n 必须是以下值之一:${imageCountRange.allowedValues.join(', ')}。` + : `n 必须在 ${imageCountRange.min} 到 ${imageCountRange.max} 之间。`, + 422 + ); + } + if (validatePartialImages) { + const partialImagesRange = compatibility.partialImagesRange; + if ( + !partialImagesRange || + (input.requestedPartialImages !== undefined && + !isIntegerWithinRange(input.requestedPartialImages, partialImagesRange)) + ) { + throw new RequestValidationError( + partialImagesRange + ? partialImagesRange.allowedValues + ? `partial_images 必须是以下值之一:${partialImagesRange.allowedValues.join(', ')}。` + : `partial_images 必须在 ${partialImagesRange.min} 到 ${partialImagesRange.max} 之间。` + : '当前图片后端没有可用的 partial_images 约束。', + 422 + ); + } + } + if (input.mode === 'generate') { + readBackground(input.formData, input.model, profile, { forceRequest: input.forceRequest }); + readSize(input.formData, 'size', '1024x1024', input.model, profile, { + forceRequest: input.forceRequest + }); + } else { + readSize(input.formData, 'size', 'auto', input.model, profile, { + forceRequest: input.forceRequest + }); + readImageFiles(input.formData, profile); + readMaskFile(input.formData, profile); + } + return; + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + firstValidationError ??= error; + } + } + } + if (hasHealthyRequestMode && firstValidationError) throw firstValidationError; +} + function createAvailabilityKey(input: StreamResolutionInput): StreamingAvailabilityKey { return { channelId: input.selectedCredential?.channelId, @@ -239,11 +447,13 @@ function selectPageServerCredential(input: { router: NonNullable['router']>; affinityKey: string; plan: ChannelRequestModePlan; + isEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean; }): PageChannelSelection { if (input.plan.candidates.length > 1) { const selection = input.router.selectWithRequestModes({ affinityKey: input.affinityKey, - requestModes: input.plan.candidates + requestModes: input.plan.candidates, + isEligible: input.isEligible }); return { selectedCredential: selection.credential, @@ -256,7 +466,8 @@ function selectPageServerCredential(input: { return { selectedCredential: input.router.select({ affinityKey: input.affinityKey, - requestMode: input.plan.preferred + requestMode: input.plan.preferred, + isEligible: input.isEligible }), requestMode: input.plan.preferred, forcedNonStream: !isStreamingChannelRequestMode(input.plan.preferred), @@ -269,7 +480,8 @@ function selectPageServerCredential(input: { return { selectedCredential: input.router.select({ affinityKey: input.affinityKey, - requestMode: input.plan.fallback + requestMode: input.plan.fallback, + isEligible: input.isEligible }), requestMode: input.plan.fallback, forcedNonStream: true, @@ -408,7 +620,25 @@ export async function POST(request: NextRequest) { const streamMode = readImageStreamMode(formData, process.env); const imageBackend = readImageGenerationBackend(formData, process.env); const streamingStrategy = readImageStreamingStrategy(formData, process.env); + const mode = readMode(formData); + const model = readModel(formData); + const forceRequest = readBooleanAlias(formData, 'force_request', 'forceRequest') === true; + const requestedImageCount = readOptionalPositiveCount(formData, 'n'); + const requestedPartialImages = readOptionalPartialImages(formData); const requestModePlan = resolvePageChannelRequestModePlan({ streamMode, imageBackend, streamingStrategy }); + if (!requestApiKey && serverChannelRouter) { + validatePageInputAgainstConfiguredProfiles({ + credentials: serverChannelState.config.credentials, + formData, + mode, + imageBackend, + model, + forceRequest, + requestedImageCount, + requestedPartialImages, + requestModes: requestModePlan.candidates + }); + } const channelSelection = requestApiKey || !serverChannelRouter ? { @@ -420,7 +650,21 @@ export async function POST(request: NextRequest) { : selectPageServerCredential({ router: serverChannelRouter, affinityKey: readAffinityKey(request.headers), - plan: requestModePlan + plan: requestModePlan, + isEligible: (credential, requestMode) => + isPageCredentialEligible({ + credential, + requestMode, + mode, + imageBackend, + formData, + model, + forceRequest, + streamMode, + streamingStrategy, + requestedImageCount, + requestedPartialImages + }) }); selectedServerCredential = channelSelection.selectedCredential; selectedServerRequestMode = channelSelection.requestMode; @@ -502,10 +746,7 @@ export async function POST(request: NextRequest) { accessCookie = buildAccessCookie(appPassword, request.headers); } - const mode = readMode(formData); const prompt = readRequiredText(formData, 'prompt'); - const model = readModel(formData); - const forceRequest = readBooleanAlias(formData, 'force_request', 'forceRequest') === true; const upstreamStartedAtMs = Date.now(); appLogger.info(`开始处理图片请求。模式:${mode},模型:${model}`, requestLogContext); @@ -514,15 +755,7 @@ export async function POST(request: NextRequest) { requestLogContext ); - const partialImagesCount = toPartialImagesCount( - readCount( - formData, - 'partial_images', - 2, - upstreamProfile.partialImages.min, - upstreamProfile.partialImages.max - ) - ); + assertResponsesImageBackendAllowed({ imageBackend, mode }); const streamResolution = resolvePageStream({ streamMode, imageBackend, @@ -535,7 +768,30 @@ export async function POST(request: NextRequest) { }), forceNonStream: channelSelection.forcedNonStream }); - assertResponsesImageBackendAllowed({ imageBackend, mode }); + const backendCompatibility = getImageBackendCompatibility(upstreamProfile, mode, imageBackend, undefined, { + validatePartialImages: streamResolution.streamEnabled + }); + if (!backendCompatibility.compatible) { + throw new RequestValidationError(backendCompatibility.errors.map((error) => error.message).join(' '), 422); + } + const partialImagesCount = streamResolution.streamEnabled + ? (() => { + const partialImagesRange = backendCompatibility.partialImagesRange; + if (!partialImagesRange) { + throw new RequestValidationError('当前图片后端没有可用的 partial_images 约束。', 422); + } + return toPartialImagesCount( + readCount( + formData, + 'partial_images', + clampIntegerToRange(2, partialImagesRange), + partialImagesRange.min, + partialImagesRange.max, + partialImagesRange + ) + ); + })() + : toPartialImagesCount(requestedPartialImages ?? 2); appLogger.info('图片上游兼容策略。', { ...requestLogContext, imageBackend, diff --git a/src/app/api/runtime-capabilities/route.test.ts b/src/app/api/runtime-capabilities/route.test.ts index 3806e095cfba460e357b72c1f69ad8ff7f59de45..2418db12c57c5b5a5f679956a21e0a739b1096a7 100644 --- a/src/app/api/runtime-capabilities/route.test.ts +++ b/src/app/api/runtime-capabilities/route.test.ts @@ -460,6 +460,55 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => { assert.equal(JSON.stringify(mixedBody).includes('sk-official'), false); }); + it('keeps capabilities available when channel image count ranges do not intersect', async () => { + process.env.OPENAI_CHANNEL_1_ID = 'fixed-one'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-one'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://one.example.com/v1'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed_one_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'sk-two'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://two.example.com/v1'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'false'; + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const { GET } = await import('./route'); + + const response = await GET(); + const body = (await response.json()) as { + upstreamProfile: { + serverConstraintsMixed: boolean; + serverConstraintsByProfile: Array<{ generateCount: { min: number; max: number } }>; + activeConstraints: { generateCount: { min: number; max: number } }; + }; + }; + + assert.equal(response.status, 200); + assert.equal(body.upstreamProfile.serverConstraintsMixed, true); + assert.deepEqual( + body.upstreamProfile.serverConstraintsByProfile.map((profile) => profile.generateCount), + [ + { min: 1, max: 1 }, + { min: 2, max: 2 } + ] + ); + assert.ok( + body.upstreamProfile.activeConstraints.generateCount.min <= + body.upstreamProfile.activeConstraints.generateCount.max + ); + }); + it('rejects requiring recovery probes when the prober is disabled', async () => { process.env.OPENAI_CHANNEL_1_ID = 'official'; process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://api.openai.com/v1'; @@ -584,6 +633,37 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => { assert.deepEqual(enabled.responsesImageBackend.missingEnv, []); }); + it('disables Responses when provider image-count constraints have no valid overlap', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + process.env.OPENAI_CHANNEL_1_ID = 'fixed-two'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://custom.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'configured'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED = 'false'; + process.env.OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY = 'false'; + const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); + resetServerChannelStateForTests(); + const { GET } = await import('./route'); + + const body = (await (await GET()).json()) as { + responsesImageBackend: { + enabled: boolean; + featureEnabled: boolean; + incompatibleConstraints?: string[]; + }; + }; + + assert.equal(body.responsesImageBackend.featureEnabled, true); + assert.equal(body.responsesImageBackend.enabled, false); + assert.deepEqual(body.responsesImageBackend.incompatibleConstraints, ['generate_images']); + }); + it('exposes sanitized channel request modes for routing diagnostics', async () => { process.env.OPENAI_ROUTING_STRATEGY = 'round_robin'; process.env.OPENAI_CHANNEL_1_ID = 'images'; diff --git a/src/app/api/runtime-capabilities/route.ts b/src/app/api/runtime-capabilities/route.ts index 50188d31f7f98277df234dab72051d66fe54f157..10d9ef86bdad2cb19b3b3502afc6ea16c77f60d5 100644 --- a/src/app/api/runtime-capabilities/route.ts +++ b/src/app/api/runtime-capabilities/route.ts @@ -1,6 +1,11 @@ import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_ADMIN_CONTROL } from '@/lib/channel-request-mode'; import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router'; -import { summarizeImageUpstreamProfile } from '@/lib/image-upstream-profile'; +import { + getImageBackendCompatibility, + getImageCountRangeCompatibilityForBackend, + getPartialImagesRangeCompatibilityForBackend, + summarizeImageUpstreamProfile +} from '@/lib/image-upstream-profile'; import { readImageGenerationBackend, readImageStreamMode, @@ -24,7 +29,7 @@ export async function GET() { const requestModeHealthSummary = serverChannelState.router?.getRequestModeHealthSummary(); const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1); const channelQueueSummary = serverChannelState.channelCapacityQueue.summary(); - const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND'); + const responsesImageBackendFeatureEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND'); const responsesImageBackendHasDefaultModel = Boolean(process.env.OPENAI_RESPONSES_API_MODEL?.trim()); const responsesImageBackendMissingEnv = readResponsesImageBackendMissingEnv(process.env); const recommendedStreamingConcurrency = computeStreamingBatchRecommendation({ @@ -35,6 +40,11 @@ export async function GET() { const upstreamProfile = summarizeImageUpstreamProfile({ serverProfiles: summary.channels.map((channel) => channel.effectiveProfile) }); + const responsesImageBackendIncompatibleConstraints = readResponsesImageBackendIncompatibleConstraints( + upstreamProfile.activeConstraints + ); + const responsesImageBackendEnabled = + responsesImageBackendFeatureEnabled && responsesImageBackendIncompatibleConstraints.length === 0; const providerManifests = summary.channels .filter((channel) => channel.providerManifest) .map((channel) => ({ @@ -121,11 +131,15 @@ export async function GET() { webuiImageCleanup: await getWebuiImageCleanupSummary(process.env), responsesImageBackend: { enabled: responsesImageBackendEnabled, + featureEnabled: responsesImageBackendFeatureEnabled, mode: 'experimental', requiredEnv: [...RESPONSES_IMAGE_BACKEND_REQUIRED_ENV], optionalEnv: [...RESPONSES_IMAGE_BACKEND_OPTIONAL_ENV], hasDefaultModel: responsesImageBackendHasDefaultModel, - missingEnv: responsesImageBackendMissingEnv + missingEnv: responsesImageBackendMissingEnv, + ...(responsesImageBackendIncompatibleConstraints.length > 0 + ? { incompatibleConstraints: responsesImageBackendIncompatibleConstraints } + : {}) } }); } catch (error) { @@ -133,6 +147,20 @@ export async function GET() { } } +function readResponsesImageBackendIncompatibleConstraints(profile: Parameters[0]) { + const incompatible: string[] = []; + if (!getImageCountRangeCompatibilityForBackend(profile, 'generate', 'responses-image-generation').compatible) { + incompatible.push('generate_images'); + } + if (!getImageCountRangeCompatibilityForBackend(profile, 'edit', 'responses-image-generation').compatible) { + incompatible.push('edit_images'); + } + if (!getPartialImagesRangeCompatibilityForBackend(profile, 'responses-image-generation').compatible) { + incompatible.push('partial_images'); + } + return incompatible; +} + function readResponsesImageBackendMissingEnv(env: Record): string[] { const missing: string[] = []; if (!readBooleanEnv(env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { diff --git a/src/app/page-regressions.test.ts b/src/app/page-regressions.test.ts index d2d86e5a8a19ddf6687e1d594290a3f54c1ba9ca..baa8814e3c6f6a839a80c0c8ea2cfa4b5d070ba2 100644 --- a/src/app/page-regressions.test.ts +++ b/src/app/page-regressions.test.ts @@ -57,6 +57,47 @@ describe('page state regressions', () => { ); }); + it('uses the runtime default backend for form ranges and rejects stale values before requests', async () => { + const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8'); + const generationFormBlock = source.match(//)?.[1] ?? ''; + const editingFormBlock = source.match(//)?.[1] ?? ''; + + assert.ok(generationFormBlock, 'missing generation form props block'); + assert.ok(editingFormBlock, 'missing editing form props block'); + assert.match(generationFormBlock, /defaultImageBackend=\{defaultImageBackend\}/); + assert.match(editingFormBlock, /defaultImageBackend=\{defaultImageBackend\}/); + assert.match( + source, + /const runtimeDefaultImageBackend = latestRuntimeCapabilities\?\.streaming\?\.defaultBackend;/ + ); + assert.match(source, /const runtimeBackendCompatibility = getImageBackendCompatibility\(/); + assert.match(source, /runtimeFormData\.image_backend,\s*runtimeDefaultImageBackend/); + assert.match(source, /if \(!runtimeBackendCompatibility\.compatible\) \{/); + assert.match(source, /runtimeBackendCompatibility\.errors\.map\(\(error\) => error\.message\)\.join\(' '\)/); + assert.match(source, /const runtimeCountRange = runtimeBackendCompatibility\.imageCountRange;/); + assert.match( + source, + /t\('error\.imageCountOutOfRange', \{\s*min: runtimeCountRange\.min,\s*max: runtimeCountRange\.max/ + ); + assert.match(source, /const runtimePartialImagesRange = runtimeBackendCompatibility\.partialImagesRange;/); + assert.match( + source, + /t\('error\.partialImagesOutOfRange', \{\s*min: runtimePartialImagesRange\.min,\s*max: runtimePartialImagesRange\.max/ + ); + }); + + it('uses one edit upload validator for paste, history reuse, and the final runtime preflight', async () => { + const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8'); + + assert.match(source, /imageFiles: \[\.\.\.editImageFiles, file\],\s*maskFile: editGeneratedMaskFile/); + assert.match(source, /imageFiles: \[newFile\],\s*upstreamProfile: activeUpstreamProfile/); + assert.match( + source, + /const editFormData = runtimeFormData as EditingFormData;[\s\S]*?maskFile: editFormData\.maskFile,[\s\S]*?upstreamProfile: runtimeUpstreamProfile/ + ); + assert.match(source, /formatEditSourceValidationFailure\(validationFailure, t\)/); + }); + it('loads permanent filenames only when filesystem cleanup is enabled and forwards batch actions to HistoryPanel', async () => { const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8'); @@ -149,7 +190,7 @@ describe('page state regressions', () => { assert.match( source, - /const activeRequestedImageCount =\s*mode === 'generate' && workbenchMode === 'batch'\s*\? readBatchPromptLines\(genBatchPromptText\)\.length\s*:\s*mode === 'generate'\s*\? genN\[0\]\s*:\s*editN\[0\];/ + /const activeRequestedImageCount =\s*mode === 'generate' && workbenchMode === 'batch'\s*\? activeBatchPrompts\.length\s*:\s*mode === 'generate'\s*\? genN\[0\]\s*:\s*editN\[0\];/ ); assert.match( source, diff --git a/src/app/page.tsx b/src/app/page.tsx index 8b32474eaeb6a365849f1f0f5618bcc54cd3d080..fe6236886ad00e238f4cbf0d69bbc8a9e847aec7 100755 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,9 +19,13 @@ import { buildUserFacingApiErrorMessage, type ApiErrorNotice } from '@/lib/api-error-guidance'; -import { formatBatchPromptHistory, readBatchPromptLines } from '@/lib/batch-prompts'; +import { findBatchPromptOverLimitIndex, formatBatchPromptHistory, readBatchPromptLines } from '@/lib/batch-prompts'; import { db, type ImageRecord } from '@/lib/db'; -import { hasReachedEditSourceImageLimit } from '@/lib/edit-source-limits'; +import { + formatEditSourceValidationFailure, + hasReachedEditSourceImageLimit, + validateEditSourceInput +} from '@/lib/edit-source-limits'; import { advanceGenerationBatchProgress, buildGenerationActivityItems, @@ -60,6 +64,8 @@ import { buildCompletedHistoryEntry, buildFailedHistoryEntry, buildHistoryGenerationFormData, + isSameHistoryEntry, + normalizeHistoryEntries, readHistoryImageCountSelection, readHistorySizeSelection, resolveHistoryImageClientRequestId, @@ -70,6 +76,7 @@ import { updateHistoryResultFeedback } from '@/lib/history-metadata'; import { useI18n } from '@/lib/i18n'; +import { MAX_PROMPT_LENGTH } from '@/lib/image-request-limits'; import { IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, appendImageUpstreamOverrideFields, @@ -81,6 +88,8 @@ import { type ImageUpstreamFormBackend } from '@/lib/image-upstream-form'; import { + clampIntegerToRange, + getImageBackendCompatibility, IMAGE_UPSTREAM_PROFILES, summarizeImageUpstreamProfile, type ImageUpstreamProfile, @@ -91,6 +100,7 @@ import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstre import { resolveMobileCreationSheetGesture } from '@/lib/mobile-creation-sheet-gesture'; import { resolveMobilePrimaryDisabledReason } from '@/lib/mobile-primary-action-state'; import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth'; +import { resolveResultActionState } from '@/lib/result-action-state'; import { resolveRuntimeHealthStatus, type RuntimeHealthStatus } from '@/lib/runtime-health-status'; import { sha256Hex } from '@/lib/sha256'; import { createImageShareFromBlob } from '@/lib/share-client'; @@ -236,7 +246,7 @@ function readStoredHistory(): HistoryMetadata[] { if (!storedHistory) return []; try { const parsedHistory: unknown = JSON.parse(storedHistory); - if (Array.isArray(parsedHistory)) return parsedHistory as HistoryMetadata[]; + if (Array.isArray(parsedHistory)) return normalizeHistoryEntries(parsedHistory as HistoryMetadata[]); console.warn('localStorage 中发现无效历史记录数据。'); window.localStorage.removeItem('openaiImageHistory'); } catch (e) { @@ -698,6 +708,7 @@ export default function HomePage() { : null; const retentionControlsEnabled = cleanupEnabled && permanentlySavedFilenames !== null; const defaultStreamingStrategy = runtimeCapabilities?.streaming?.defaultStrategy ?? 'auto'; + const defaultImageBackend = runtimeCapabilities?.streaming?.defaultBackend; const activeUpstreamProfileSummary = summarizeImageUpstreamProfile({ requestApiBaseUrl: apiSettings.baseUrl, serverProfileIds: runtimeCapabilities?.upstreamProfile @@ -728,6 +739,14 @@ export default function HomePage() { }); const streamingBatchEnabled = streamingBatchCapacity.enabled; const isPromptBatchMode = mode === 'generate' && workbenchMode === 'batch'; + const activeBatchPrompts = React.useMemo( + () => (isPromptBatchMode ? readBatchPromptLines(genBatchPromptText) : []), + [genBatchPromptText, isPromptBatchMode] + ); + const activeBatchPromptOverLimitIndex = React.useMemo( + () => findBatchPromptOverLimitIndex(activeBatchPrompts, MAX_PROMPT_LENGTH), + [activeBatchPrompts] + ); const currentPrompt = mode === 'generate' && workbenchMode === 'batch' ? genBatchPromptText @@ -736,6 +755,20 @@ export default function HomePage() { : editPrompt; const hasEditSourceImage = editImageFiles.length > 0; const maxEditSourceImages = activeUpstreamProfile.upload.maxImages; + const editSourceValidationFailure = React.useMemo( + () => + validateEditSourceInput({ + imageFiles: editImageFiles, + maskFile: editGeneratedMaskFile, + upstreamProfile: activeUpstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }), + [activeUpstreamProfile, defaultImageBackend, editGeneratedMaskFile, editImageBackend, editImageFiles] + ); + const editSourceValidationMessage = editSourceValidationFailure + ? formatEditSourceValidationFailure(editSourceValidationFailure, t) + : ''; const usesPositiveIntegerCustomSize = activeUpstreamProfile.gptImage2.sizePolicy === 'positive-integer'; const currentGenerateSizeValidation = genSize === 'custom' @@ -770,7 +803,7 @@ export default function HomePage() { }); const activeRequestedImageCount = mode === 'generate' && workbenchMode === 'batch' - ? readBatchPromptLines(genBatchPromptText).length + ? activeBatchPrompts.length : mode === 'generate' ? genN[0] : editN[0]; @@ -824,46 +857,54 @@ export default function HomePage() { let cancelled = false; queueMicrotask(() => { if (cancelled) return; - if ( - partialImages < activeUpstreamProfile.partialImages.min || - partialImages > activeUpstreamProfile.partialImages.max - ) { - setPartialImages( - Math.min( - activeUpstreamProfile.partialImages.max, - Math.max(activeUpstreamProfile.partialImages.min, partialImages) - ) as PartialImagesCount - ); + const generationCompatibility = getImageBackendCompatibility( + activeUpstreamProfile, + 'generate', + genImageBackend, + defaultImageBackend + ); + const editCompatibility = getImageBackendCompatibility( + activeUpstreamProfile, + 'edit', + editImageBackend, + defaultImageBackend + ); + const generationCountRange = generationCompatibility.imageCountRange; + const editCountRange = editCompatibility.imageCountRange; + const activeCompatibility = usesEditControls ? editCompatibility : generationCompatibility; + const activePartialImagesRange = activeCompatibility.partialImagesRange; + if (!generationCountRange || !editCountRange || !activePartialImagesRange) return; + if (partialImages < activePartialImagesRange.min || partialImages > activePartialImagesRange.max) { + setPartialImages(clampIntegerToRange(partialImages, activePartialImagesRange) as PartialImagesCount); } if (genBackground === 'transparent' && !activeUpstreamProfile.gptImage2.allowTransparentBackground) { setGenBackground('auto'); } setGenN((current) => - current[0] < activeUpstreamProfile.generateCount.min || - current[0] > activeUpstreamProfile.generateCount.max - ? [ - Math.min( - activeUpstreamProfile.generateCount.max, - Math.max(activeUpstreamProfile.generateCount.min, current[0]) - ) - ] + current[0] < generationCountRange.min || current[0] > generationCountRange.max + ? [clampIntegerToRange(current[0], generationCountRange)] : current ); setEditN((current) => - current[0] < activeUpstreamProfile.editCount.min || current[0] > activeUpstreamProfile.editCount.max - ? [ - Math.min( - activeUpstreamProfile.editCount.max, - Math.max(activeUpstreamProfile.editCount.min, current[0]) - ) - ] + current[0] < editCountRange.min || current[0] > editCountRange.max + ? [clampIntegerToRange(current[0], editCountRange)] : current ); }); return () => { cancelled = true; }; - }, [activeUpstreamProfile, editN, genBackground, genN, partialImages]); + }, [ + activeUpstreamProfile, + defaultImageBackend, + editImageBackend, + editN, + genBackground, + genImageBackend, + genN, + partialImages, + usesEditControls + ]); const activeLogClientRequestIds = React.useMemo(() => { if (!latestImageBatch || latestImageBatch.length === 0) return []; if (typeof imageOutputView === 'number') { @@ -902,14 +943,26 @@ export default function HomePage() { ] ); const announcedGenerationActivity = selectAnnouncedGenerationActivity(generationActivityItems); + const mobileBackendCompatibility = getImageBackendCompatibility( + activeUpstreamProfile, + mode, + mode === 'generate' ? genImageBackend : editImageBackend, + defaultImageBackend + ); + const mobileBackendCompatibilityMessage = mobileBackendCompatibility.compatible + ? '' + : mobileBackendCompatibility.errors.map((error) => error.message).join(' '); const mobilePrimaryDisabledReason = resolveMobilePrimaryDisabledReason({ isLoading, isSendingToEdit, mode, isBatchMode: workbenchMode === 'batch', prompt: currentPrompt, - batchPromptCount: workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText).length : 0, + batchPromptCount: activeBatchPrompts.length, + batchPromptOverLimitIndex: activeBatchPromptOverLimitIndex, hasEditSourceImage, + editSourceValidationMessage, + backendCompatibilityMessage: mobileBackendCompatibilityMessage, hasUnsavedMask: editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved, imageBackend: mode === 'generate' ? genImageBackend : editImageBackend, responsesModel: mode === 'generate' ? genResponsesModel : editResponsesModel, @@ -919,9 +972,17 @@ export default function HomePage() { t }); const mobilePrimaryDisabled = isLoading || isSendingToEdit || Boolean(mobilePrimaryDisabledReason); - const currentResultPrompt = activeResultSource?.prompt.trim() || currentPrompt.trim(); - const canCreateResultVariant = !isLoading && Boolean(latestImageBatch) && Boolean(currentResultPrompt); - const canReuseResultPrompt = Boolean(currentResultPrompt); + const { canCreateVariant: canCreateResultVariant, canReusePrompt: canReuseResultPrompt } = resolveResultActionState( + { + isBusy: isLoading || isSendingToEdit, + hasResultImages: Boolean(latestImageBatch?.length), + currentMode: mode, + currentPrompt, + activeResultSource: activeResultSource + ? { mode: activeResultSource.mode, prompt: activeResultSource.prompt } + : null + } + ); const canPausePromptBatch = isLoading && isPromptBatchMode && Boolean(batchProgress); const handleBatchPromptTextChange = React.useCallback((nextText: React.SetStateAction) => { @@ -1541,6 +1602,18 @@ export default function HomePage() { if (file) { event.preventDefault(); + const validationFailure = validateEditSourceInput({ + imageFiles: [...editImageFiles, file], + maskFile: editGeneratedMaskFile, + upstreamProfile: activeUpstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }); + if (validationFailure) { + alert(formatEditSourceValidationFailure(validationFailure, t)); + return; + } + const previewUrl = URL.createObjectURL(file); setEditImageFiles((prevFiles) => [...prevFiles, file]); @@ -1557,7 +1630,17 @@ export default function HomePage() { return () => { window.removeEventListener('paste', handlePaste); }; - }, [mode, editImageFiles.length, maxEditSourceImages, t, updateEditSourceImagePreviewUrls]); + }, [ + activeUpstreamProfile, + defaultImageBackend, + editGeneratedMaskFile, + editImageBackend, + editImageFiles, + maxEditSourceImages, + mode, + t, + updateEditSourceImagePreviewUrls + ]); const handleSavePassword = async (password: string) => { if (!password.trim()) { @@ -1920,7 +2003,7 @@ export default function HomePage() { }; try { result = await response.json(); - } catch (error) { + } catch { if (!response.ok) { throw new ApiRequestError(getLocalizedImageRequestError(t, response.status), response.status); } @@ -2042,7 +2125,8 @@ export default function HomePage() { if ( shouldBlockExplicitResponsesRequest({ imageBackend: formData.image_backend, - allowResponsesImageBackend: allowRuntimeResponsesImageBackend + allowResponsesImageBackend: allowRuntimeResponsesImageBackend, + defaultImageBackend: latestRuntimeCapabilities?.streaming?.defaultBackend }) ) { throw new ApiRequestError( @@ -2063,6 +2147,62 @@ export default function HomePage() { ) { throw new ApiRequestError(t('upstream.responsesModelRequired')); } + const runtimeUpstreamProfile = hasRequestApiOverride + ? summarizeImageUpstreamProfile({ requestApiBaseUrl: apiSettings.baseUrl }).activeConstraints + : latestRuntimeCapabilities?.upstreamProfile?.activeConstraints || activeUpstreamProfile; + const runtimeDefaultImageBackend = latestRuntimeCapabilities?.streaming?.defaultBackend; + const runtimeBackendCompatibility = getImageBackendCompatibility( + runtimeUpstreamProfile, + requestMode, + runtimeFormData.image_backend, + runtimeDefaultImageBackend + ); + if (!runtimeBackendCompatibility.compatible) { + throw new ApiRequestError(runtimeBackendCompatibility.errors.map((error) => error.message).join(' ')); + } + if (requestMode === 'edit') { + const editFormData = runtimeFormData as EditingFormData; + const validationFailure = validateEditSourceInput({ + imageFiles: editFormData.imageFiles, + maskFile: editFormData.maskFile, + upstreamProfile: runtimeUpstreamProfile, + imageBackend: editFormData.image_backend, + defaultImageBackend: runtimeDefaultImageBackend + }); + if (validationFailure) { + throw new ApiRequestError(formatEditSourceValidationFailure(validationFailure, t)); + } + } + const runtimeCountRange = runtimeBackendCompatibility.imageCountRange; + if (!runtimeCountRange) { + throw new ApiRequestError('当前图片后端没有可用的图片数量约束。'); + } + const runtimeImageCount = runtimeFormData.n; + if (runtimeImageCount < runtimeCountRange.min || runtimeImageCount > runtimeCountRange.max) { + throw new ApiRequestError( + t('error.imageCountOutOfRange', { + min: runtimeCountRange.min, + max: runtimeCountRange.max + }) + ); + } + if (requestStreamMode !== 'non_stream') { + const runtimePartialImagesRange = runtimeBackendCompatibility.partialImagesRange; + if (!runtimePartialImagesRange) { + throw new ApiRequestError('当前图片后端没有可用的 partial_images 约束。'); + } + if ( + requestPartialImages < runtimePartialImagesRange.min || + requestPartialImages > runtimePartialImagesRange.max + ) { + throw new ApiRequestError( + t('error.partialImagesOutOfRange', { + min: runtimePartialImagesRange.min, + max: runtimePartialImagesRange.max + }) + ); + } + } const promptBatch = requestMode === 'generate' ? ((runtimeFormData as GenerationFormData).batchPrompts ?? []) @@ -2328,6 +2468,11 @@ export default function HomePage() { } function handleMobilePrimaryAction() { + if (isLoading || isSendingToEdit) return; + if (mobilePrimaryDisabledReason) { + setError(createErrorNotice(mobilePrimaryDisabledReason)); + return; + } if (mode === 'generate') { const batchPrompts = workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText) : undefined; const formData: GenerationFormData = { @@ -2489,6 +2634,7 @@ export default function HomePage() { ); function handleCreateVariant() { + if (!canCreateResultVariant || isLoading || isSendingToEdit) return; if (activeResultSource) { const formData = buildHistoryGenerationFormData( activeResultSource, @@ -2502,6 +2648,7 @@ export default function HomePage() { } function handleReuseCurrentPrompt() { + if (!canReuseResultPrompt || isLoading || isSendingToEdit) return; if (activeResultSource) { const formData = buildHistoryGenerationFormData( activeResultSource, @@ -3091,20 +3238,20 @@ export default function HomePage() { (item: HistoryMetadata, value: ResultFeedbackValue) => { const updatedAt = Date.now(); const currentFeedback = - history.find((historyItem) => historyItem.timestamp === item.timestamp)?.resultFeedback ?? + history.find((historyItem) => isSameHistoryEntry(historyItem, item))?.resultFeedback ?? item.resultFeedback; const currentLocalNote = currentFeedback?.note; setHistory((current) => updateHistoryResultFeedback({ history: current, - timestamp: item.timestamp, + item, value, updatedAt }) ); setActiveResultSource((current) => - current?.timestamp === item.timestamp + current && isSameHistoryEntry(current, item) ? { ...current, resultFeedback: { @@ -3133,14 +3280,14 @@ export default function HomePage() { setHistory((current) => updateHistoryResultFeedback({ history: current, - timestamp: item.timestamp, + item, value: resultFeedbackValue, updatedAt, note }) ); setActiveResultSource((current) => - current?.timestamp === item.timestamp + current && isSameHistoryEntry(current, item) ? { ...current, resultFeedback: { @@ -3168,22 +3315,21 @@ export default function HomePage() { if (window.confirm(confirmationMessage)) { const feedbackDeleteTargets = buildHistoryFeedbackDeleteTargets(history); - setHistory([]); - setLatestImageBatch(null); - setActiveResultSource(null); - setCompletedGenerationCount(null); - setImageOutputView('grid'); - setError(null); try { - localStorage.removeItem('openaiImageHistory'); - if (effectiveStorageModeClient === 'indexeddb') { await db.images.clear(); blobUrlCacheRef.current.forEach((url) => URL.revokeObjectURL(url)); blobUrlCacheRef.current.clear(); } + localStorage.removeItem('openaiImageHistory'); scheduleResultFeedbackDeleteTargets(feedbackDeleteTargets); + setHistory([]); + setLatestImageBatch(null); + setActiveResultSource(null); + setCompletedGenerationCount(null); + setImageOutputView('grid'); + setError(null); } catch (e) { console.error('清空历史记录失败:', e); setError(createErrorNotice(t('error.clearHistory'))); @@ -3390,7 +3536,7 @@ export default function HomePage() { } catch { throw new ApiRequestError(t('error.sendToEdit')); } - mimeType = response.headers.get('Content-Type') || mimeType; + mimeType = response.headers.get('Content-Type')?.split(';', 1)[0]?.trim() || mimeType; } if (!blob) { @@ -3398,6 +3544,15 @@ export default function HomePage() { } const newFile = new File([blob], filename, { type: mimeType }); + const validationFailure = validateEditSourceInput({ + imageFiles: [newFile], + upstreamProfile: activeUpstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }); + if (validationFailure) { + throw new ApiRequestError(formatEditSourceValidationFailure(validationFailure, t)); + } const newPreviewUrl = URL.createObjectURL(blob); setEditImageFiles([newFile]); @@ -3495,7 +3650,7 @@ export default function HomePage() { if (!item) return; setError(null); - const { images: imagesInEntry, timestamp } = item; + const { images: imagesInEntry } = item; const storageModeUsed = item.storageModeUsed ?? 'fs'; const filenamesToDelete = imagesInEntry.map((img) => img.filename); @@ -3569,9 +3724,9 @@ export default function HomePage() { if (remainingImages.length !== imagesInEntry.length) { setHistory((prevHistory) => remainingImages.length === 0 - ? prevHistory.filter((historyItem) => historyItem.timestamp !== timestamp) + ? prevHistory.filter((historyItem) => !isSameHistoryEntry(historyItem, item)) : prevHistory.map((historyItem) => - historyItem.timestamp === timestamp + isSameHistoryEntry(historyItem, item) ? { ...historyItem, images: remainingImages } : historyItem ) @@ -3582,7 +3737,7 @@ export default function HomePage() { return remainingBatch.length > 0 ? remainingBatch : null; }); setActiveResultSource((current) => - current?.timestamp !== timestamp + !current || !isSameHistoryEntry(current, item) ? current : remainingImages.length === 0 ? null @@ -3598,11 +3753,13 @@ export default function HomePage() { return; } - setHistory((prevHistory) => prevHistory.filter((h) => h.timestamp !== timestamp)); + setHistory((prevHistory) => + prevHistory.filter((historyItem) => !isSameHistoryEntry(historyItem, item)) + ); setLatestImageBatch((prev) => prev && prev.some((img) => filenamesToDelete.includes(img.filename)) ? null : prev ); - setActiveResultSource((current) => (current?.timestamp === timestamp ? null : current)); + setActiveResultSource((current) => (current && isSameHistoryEntry(current, item) ? null : current)); scheduleResultFeedbackDeleteTargets(buildHistoryFeedbackDeleteTargets([item])); } catch (e: unknown) { console.error('删除条目失败:', e); @@ -3901,6 +4058,7 @@ export default function HomePage() { setBackground={setGenBackground} upstreamProfile={activeUpstreamProfile} upstreamProfileMixed={activeUpstreamProfileMixed} + defaultImageBackend={defaultImageBackend} moderation={genModeration} setModeration={setGenModeration} streamMode={streamMode} @@ -3982,6 +4140,7 @@ export default function HomePage() { setEditCompression={setEditCompression} upstreamProfile={activeUpstreamProfile} upstreamProfileMixed={activeUpstreamProfileMixed} + defaultImageBackend={defaultImageBackend} editModeration={editModeration} setEditModeration={setEditModeration} editBrushSize={editBrushSize} @@ -4065,6 +4224,9 @@ export default function HomePage() { openLogsSignal={openLogsSignal} logClientRequestIds={activeLogClientRequestIds} logFilenames={activeLogFilenames} + isLogScopeBatch={ + imageOutputView === 'grid' && (latestImageBatch?.length ?? 0) > 1 + } /> ['defaultImageBackend']; editResponsesModel?: string; editPrompt?: string; editModel?: React.ComponentProps['editModel']; editSize?: React.ComponentProps['editSize']; + editCustomWidth?: number; + editCustomHeight?: number; canApplyRandomInspiration?: boolean; imageFiles?: File[]; + maskFile?: File | null; upstreamProfile?: ImageUpstreamProfile; upstreamProfileMixed?: boolean; isActive?: boolean; @@ -85,12 +90,16 @@ function createEditingFormProps({ defaultStreamingStrategy = 'auto', allowResponsesImageBackend = true, hasDefaultResponsesModel = true, + defaultImageBackend, editResponsesModel = '', editPrompt = '', editModel = 'gpt-image-2', editSize = 'auto', + editCustomWidth = 1024, + editCustomHeight = 1024, canApplyRandomInspiration = true, imageFiles = [], + maskFile = null, upstreamProfile = IMAGE_UPSTREAM_PROFILES['openai-compatible'], upstreamProfileMixed = false, isActive = true, @@ -127,9 +136,9 @@ function createEditingFormProps({ setEditN: noop, editSize, setEditSize: noop, - editCustomWidth: 1024, + editCustomWidth, setEditCustomWidth: noop, - editCustomHeight: 1024, + editCustomHeight, setEditCustomHeight: noop, editQuality: 'auto', setEditQuality: noop, @@ -145,7 +154,7 @@ function createEditingFormProps({ setEditBrushSize: noop, editShowMaskEditor: false, setEditShowMaskEditor: noop, - editGeneratedMaskFile: null, + editGeneratedMaskFile: maskFile, setEditGeneratedMaskFile: noop, editIsMaskSaved: false, setEditIsMaskSaved: noop, @@ -164,6 +173,7 @@ function createEditingFormProps({ setPartialImages: noop, allowResponsesImageBackend, hasDefaultResponsesModel, + defaultImageBackend, editImageBackend: backend, setEditImageBackend: noop, editStreamingStrategy: streamingStrategy, @@ -192,6 +202,14 @@ function renderEditingForm(options: RenderOptions): string { } describe('EditingForm submit footer', { concurrency: false }, () => { + it('uses the server prompt limit in the instruction counter and input constraint', () => { + const html = renderEditingForm({ backend: 'server-default', editPrompt: '用户真实编辑要求' }); + + assert.match(html, new RegExp(`maxLength="${MAX_PROMPT_LENGTH}"`)); + assert.match(html, new RegExp(`\\/ ${MAX_PROMPT_LENGTH.toLocaleString('zh-CN')}`)); + assert.doesNotMatch(html, /\/ 1000/); + }); + it('keeps the submit footer available outside desktop breakpoints', () => { const html = renderEditingForm({ backend: 'server-default', @@ -413,17 +431,46 @@ describe('EditingForm advanced upstream controls', () => { assert.match(html, /edit-partial-4/); }); - it('intersects Matsca edit partial image options with the Responses backend contract', () => { - const html = renderEditingForm({ + it('intersects Matsca edit output and preview options with the Responses backend contract', () => { + const streamHtml = renderEditingForm({ backend: 'responses-image-generation', advancedTab: 'stream', upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca }); + const outputHtml = renderEditingForm({ + backend: 'responses-image-generation', + advancedTab: 'output', + upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca + }); - assert.doesNotMatch(html, /edit-partial-0/); - assert.match(html, /edit-partial-1/); - assert.match(html, /edit-partial-3/); - assert.doesNotMatch(html, /edit-partial-4/); + assert.doesNotMatch(streamHtml, /edit-partial-0/); + assert.match(streamHtml, /edit-partial-1/); + assert.match(streamHtml, /edit-partial-3/); + assert.doesNotMatch(streamHtml, /edit-partial-4/); + assert.match(outputHtml, /id="edit-n-slider"/); + assert.match(outputHtml, /aria-valuemax="1"/); + }); + + it('applies Responses limits to the default edit route when runtime selects that backend', () => { + const streamHtml = renderEditingForm({ + backend: 'server-default', + defaultImageBackend: 'responses-image-generation', + advancedTab: 'stream', + upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca + }); + const outputHtml = renderEditingForm({ + backend: 'server-default', + defaultImageBackend: 'responses-image-generation', + advancedTab: 'output', + upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca + }); + + assert.doesNotMatch(streamHtml, /edit-partial-0/); + assert.match(streamHtml, /edit-partial-1/); + assert.match(streamHtml, /edit-partial-3/); + assert.doesNotMatch(streamHtml, /edit-partial-4/); + assert.match(outputHtml, /id="edit-n-slider"/); + assert.match(outputHtml, /aria-valuemax="1"/); }); it('uses Matsca edit upload limits when the active upstream profile requires them', () => { @@ -445,6 +492,38 @@ describe('EditingForm advanced upstream controls', () => { assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); }); + it('explains active profile upload limits and blocks invalid reference files before submit', () => { + const emptyFile = { name: 'empty.png', size: 0, type: 'image/png' } as File; + const html = renderEditingForm({ + backend: 'server-default', + advancedOpen: false, + editPrompt: '用户真实编辑要求', + imageFiles: [emptyFile] + }); + + assert.match(html, /单张不超过 25 MB/); + assert.match(html, /当前线路未声明参考图总大小上限/); + assert.match(html, /参考图不能为空/); + assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); + }); + + it('shows the Responses combined input limit and disables an oversized reference-plus-mask set', () => { + const imageFiles = [ + { name: 'source-a.png', size: MAX_OPENAI_UPLOAD_BYTES, type: 'image/png' } as File, + { name: 'source-b.png', size: MAX_OPENAI_UPLOAD_BYTES, type: 'image/png' } as File + ]; + const html = renderEditingForm({ + backend: 'responses-image-generation', + advancedOpen: false, + editPrompt: '用户真实编辑要求', + imageFiles, + maskFile: { name: 'mask.png', size: 1, type: 'image/png' } as File + }); + + assert.match(html, /当前 Responses 路线下,参考图和蒙版合计不能超过 50 MB/); + assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); + }); + it('renders profile-aware high resolution edit size presets', () => { const openAiHtml = renderEditingForm({ backend: 'server-default' }); const matscaHtml = renderEditingForm({ @@ -457,6 +536,19 @@ describe('EditingForm advanced upstream controls', () => { assert.match(matscaHtml, /id="edit-size-square-4k"/); }); + it('does not present unsafe custom edit dimensions as a valid exact pixel calculation', () => { + const html = renderEditingForm({ + backend: 'server-default', + editSize: 'custom', + editCustomWidth: Number.MAX_SAFE_INTEGER + 1, + upstreamProfile: IMAGE_UPSTREAM_PROFILES.matsca + }); + + assert.match(html, /请先输入可精确计算的正整数尺寸。/); + assert.match(html, /宽度和高度超出可精确处理的整数范围。/); + assert.match(html, /]*disabled=""[^>]*>[\s\S]*编辑图像[\s\S]*<\/button>/); + }); + it('renders an explicit parallel batch toggle in edit stream settings', () => { const html = renderEditingForm({ backend: 'server-default', @@ -467,7 +559,7 @@ describe('EditingForm advanced upstream controls', () => { }); assert.match(html, /并发批量/); - assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/); + assert.match(html, /多张图或多条提示词会按当前服务端配置的并发上限尝试执行/); assert.match(html, /id="edit-parallel-batch-enabled"/); assert.match(html, /aria-checked="true"/); }); @@ -538,7 +630,10 @@ describe('EditingForm advanced upstream controls', () => { assert.match(html, /影响说明/); assert.match(html, /Responses image_generation 需要实验开关和顶层模型/); assert.match(html, /当前服务端渠道包含不同上游模式/); - assert.match(html, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.match( + html, + /当前自动策略会由服务端按渠道选择传输方式,可能直接使用非流式;仅在实际选中流式且上游支持时使用流式。/ + ); assert.match(html, /GPT 顶层模型/); assert.match(html, /思考强度/); assert.match(html, /提示词优化/); @@ -560,9 +655,9 @@ describe('EditingForm advanced upstream controls', () => { }); assert.match(offHtml, /关闭流式会减少长连接不稳定因素/); - assert.doesNotMatch(offHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.doesNotMatch(offHtml, /当前自动策略会由服务端按渠道选择传输方式/); assert.match(forceHtml, /强制 SSE 会跳过自动判断/); - assert.doesNotMatch(forceHtml, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.doesNotMatch(forceHtml, /当前自动策略会由服务端按渠道选择传输方式/); }); it('disables the experimental Responses backend when runtime capabilities do not allow it', () => { diff --git a/src/components/editing-form.tsx b/src/components/editing-form.tsx index 9e2e02607d5770be859bb714d5b26189da641830..222f40f1cbadddfdd05ea7c4f568f4cfdcce6ae9 100644 --- a/src/components/editing-form.tsx +++ b/src/components/editing-form.tsx @@ -14,8 +14,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { GptImageModel } from '@/lib/cost-utils'; +import { + formatEditSourceValidationFailure, + formatEditUploadLimit, + getResponsesEditInputLimitLabel, + isResponsesEditInputLimitActive, + validateEditSourceInput +} from '@/lib/edit-source-limits'; import { useI18n } from '@/lib/i18n'; import { getImageOutputFormatLabel, getImageQualityLabel } from '@/lib/image-display-labels'; +import { MAX_PROMPT_LENGTH } from '@/lib/image-request-limits'; import type { ImageUpstreamFormBackend, ImageUpstreamFormPromptOptimization, @@ -30,14 +38,17 @@ import { import { buildIntegerRangeOptions, clampIntegerToRange, - getPartialImagesRangeForBackend, + getImageBackendCompatibility, + resolveImageBackendSelection, type ImageUpstreamProfile, type PartialImagesCount } from '@/lib/image-upstream-profile'; -import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; +import type { ImageGenerationBackend, ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { getPresetTooltip, getSizePresetOptions, + formatExactImagePixelCount, + readImageSizeNumberInput, validateGptImage2Size, validatePositiveIntegerImageSize } from '@/lib/size-utils'; @@ -144,6 +155,7 @@ type EditingFormProps = { setEditCompression: React.Dispatch>; upstreamProfile: ImageUpstreamProfile; upstreamProfileMixed?: boolean; + defaultImageBackend?: ImageGenerationBackend; editModeration: EditingFormData['moderation']; setEditModeration: React.Dispatch>; editBrushSize: number[]; @@ -335,6 +347,7 @@ export function EditingForm({ setEditCompression, upstreamProfile, upstreamProfileMixed = false, + defaultImageBackend, editModeration, setEditModeration, editBrushSize, @@ -389,15 +402,14 @@ export function EditingForm({ : validateGptImage2Size(editCustomWidth, editCustomHeight) : { valid: true as const }; const customSizeInvalid = editSize === 'custom' && !customSizeValidation.valid; - const editCustomPixels = editCustomWidth * editCustomHeight; - const editCustomRatio = - editCustomWidth > 0 && editCustomHeight > 0 - ? t('form.ratio', { - ratio: ( - Math.max(editCustomWidth, editCustomHeight) / Math.min(editCustomWidth, editCustomHeight) - ).toFixed(2) - }) - : t('form.noRatio'); + const editCustomPixelCount = formatExactImagePixelCount(editCustomWidth, editCustomHeight, locale); + const editCustomRatio = editCustomPixelCount + ? t('form.ratio', { + ratio: ( + Math.max(editCustomWidth, editCustomHeight) / Math.min(editCustomWidth, editCustomHeight) + ).toFixed(2) + }) + : t('form.noRatio'); const editCustomSizeError = customSizeValidation.valid ? null : t(customSizeValidation.reasonKey, customSizeValidation.values); @@ -420,13 +432,64 @@ export function EditingForm({ const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(initialAdvancedOpen); const [advancedTab, setAdvancedTab] = React.useState(initialAdvancedTab); + const effectiveImageBackend = resolveImageBackendSelection(editImageBackend, defaultImageBackend); + const responsesBackendUnavailable = + effectiveImageBackend === 'responses-image-generation' && !allowResponsesImageBackend; const requiresResponsesModel = - editImageBackend === 'responses-image-generation' && !hasDefaultResponsesModel && !editResponsesModel.trim(); + effectiveImageBackend === 'responses-image-generation' && + !hasDefaultResponsesModel && + !editResponsesModel.trim(); + const backendCompatibility = React.useMemo( + () => getImageBackendCompatibility(upstreamProfile, 'edit', editImageBackend, defaultImageBackend), + [defaultImageBackend, editImageBackend, upstreamProfile] + ); + const backendCompatibilityMessage = backendCompatibility.compatible + ? '' + : backendCompatibility.errors.map((error) => error.message).join(' '); + const editPromptOverLimit = editPrompt.length > MAX_PROMPT_LENGTH; + const editSourceValidationFailure = React.useMemo( + () => + validateEditSourceInput({ + imageFiles, + maskFile: editGeneratedMaskFile, + upstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }), + [defaultImageBackend, editGeneratedMaskFile, editImageBackend, imageFiles, upstreamProfile] + ); + const editSourceValidationMessage = editSourceValidationFailure + ? formatEditSourceValidationFailure(editSourceValidationFailure, t) + : ''; + const editUploadHint = React.useMemo(() => { + const values: Record = { + count: maxImages, + singleLimit: formatEditUploadLimit(upstreamProfile.upload.maxSingleBytes), + totalHint: + upstreamProfile.upload.maxTotalBytes === undefined + ? t('edit.referenceNoTotalLimit') + : t('edit.referenceTotalLimit', { + limit: formatEditUploadLimit(upstreamProfile.upload.maxTotalBytes) + }) + }; + return t('edit.referenceHint', values); + }, [maxImages, t, upstreamProfile.upload.maxSingleBytes, upstreamProfile.upload.maxTotalBytes]); + const editReferenceEmptyHint = t('edit.referenceEmpty', { + singleLimit: formatEditUploadLimit(upstreamProfile.upload.maxSingleBytes) + }); + const responsesEditInputLimitActive = isResponsesEditInputLimitActive({ + imageBackend: editImageBackend, + defaultImageBackend + }); const submitDisabledReason = React.useMemo(() => { if (isLoading) return ''; if (imageFiles.length === 0) return t('ux.disabledSourceImage'); if (imageFiles.length > maxImages) return t('alert.maxImages', { count: maxImages }); + if (editSourceValidationMessage) return editSourceValidationMessage; + if (responsesBackendUnavailable) return t('upstream.backendResponsesUnavailable'); + if (backendCompatibilityMessage) return backendCompatibilityMessage; if (!editPrompt.trim()) return t('ux.disabledPrompt'); + if (editPromptOverLimit) return t('ux.disabledPromptLength', { limit: MAX_PROMPT_LENGTH }); if (editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved) { return t('ux.disabledUnsavedMask'); } @@ -435,15 +498,19 @@ export function EditingForm({ return ''; }, [ customSizeInvalid, + backendCompatibilityMessage, editCustomSizeError, editDrawnPoints.length, editGeneratedMaskFile, editIsMaskSaved, editPrompt, + editPromptOverLimit, + editSourceValidationMessage, imageFiles.length, isLoading, maxImages, requiresResponsesModel, + responsesBackendUnavailable, t ]); const advancedSummary = [ @@ -456,10 +523,14 @@ export function EditingForm({ const workbenchBackendLabel = getWorkbenchBackendLabel(editImageBackend, t); const editSizePresetOptions = getSizePresetOptions({ model: editModel, upstreamProfile }); const partialImagesRange = React.useMemo( - () => getPartialImagesRangeForBackend(upstreamProfile, editImageBackend), - [editImageBackend, upstreamProfile] + () => backendCompatibility.partialImagesRange ?? { min: 1, max: 3 }, + [backendCompatibility.partialImagesRange] ); const partialImageOptions = buildIntegerRangeOptions(partialImagesRange) as PartialImagesCount[]; + const editCountRange = React.useMemo( + () => backendCompatibility.imageCountRange ?? { min: 1, max: 1 }, + [backendCompatibility.imageCountRange] + ); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { @@ -481,10 +552,10 @@ export function EditingForm({ }, [isActive, partialImages, partialImagesRange, setPartialImages]); React.useEffect(() => { - if (isActive && (editN[0] < upstreamProfile.editCount.min || editN[0] > upstreamProfile.editCount.max)) { - setEditN([Math.min(upstreamProfile.editCount.max, Math.max(upstreamProfile.editCount.min, editN[0]))]); + if (isActive && (editN[0] < editCountRange.min || editN[0] > editCountRange.max)) { + setEditN([clampIntegerToRange(editN[0], editCountRange)]); } - }, [editN, isActive, setEditN, upstreamProfile.editCount.max, upstreamProfile.editCount.min]); + }, [editCountRange, editN, isActive, setEditN]); const canvasRef = React.useRef(null); const visualFeedbackCanvasRef = React.useRef(null); @@ -680,6 +751,20 @@ export function EditingForm({ offscreenCanvas.toBlob((blob) => { if (blob) { const maskFile = new File([blob], 'generated-mask.png', { type: 'image/png' }); + const validationFailure = validateEditSourceInput({ + imageFiles, + maskFile, + upstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }); + if (validationFailure) { + alert(formatEditSourceValidationFailure(validationFailure, t)); + setEditGeneratedMaskFile(null); + setEditIsMaskSaved(false); + setEditMaskPreviewUrl(null); + return; + } setEditGeneratedMaskFile(maskFile); setEditIsMaskSaved(true); } else { @@ -697,12 +782,21 @@ export function EditingForm({ if (totalFiles > maxImages) { alert(t('alert.maxImages', { count: maxImages })); - const allowedNewFiles = newFiles.slice(0, maxImages - imageFiles.length); - if (allowedNewFiles.length === 0) { - event.target.value = ''; - return; - } - newFiles.splice(allowedNewFiles.length); + event.target.value = ''; + return; + } + + const validationFailure = validateEditSourceInput({ + imageFiles: [...imageFiles, ...newFiles], + maskFile: editGeneratedMaskFile, + upstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }); + if (validationFailure) { + alert(formatEditSourceValidationFailure(validationFailure, t)); + event.target.value = ''; + return; } setImageFiles((prevFiles) => [...prevFiles, ...newFiles]); @@ -740,8 +834,15 @@ export function EditingForm({ return; } - if (file.type !== 'image/png') { - alert(t('alert.maskInvalidType')); + const validationFailure = validateEditSourceInput({ + imageFiles, + maskFile: file, + upstreamProfile, + imageBackend: editImageBackend, + defaultImageBackend + }); + if (validationFailure) { + alert(formatEditSourceValidationFailure(validationFailure, t)); event.target.value = ''; return; } @@ -799,6 +900,13 @@ export function EditingForm({ alert(t('alert.maxImages', { count: maxImages })); return; } + if (editSourceValidationMessage) { + alert(editSourceValidationMessage); + return; + } + if (!editPrompt.trim() || editPromptOverLimit) { + return; + } if (editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved) { alert(t('alert.saveMaskBeforeSubmit')); return; @@ -810,8 +918,8 @@ export function EditingForm({ setPartialImages(clampIntegerToRange(partialImages, partialImagesRange) as PartialImagesCount); return; } - if (editN[0] < upstreamProfile.editCount.min || editN[0] > upstreamProfile.editCount.max) { - setEditN([clampIntegerToRange(editN[0], upstreamProfile.editCount)]); + if (editN[0] < editCountRange.min || editN[0] > editCountRange.max) { + setEditN([clampIntegerToRange(editN[0], editCountRange)]); return; } @@ -910,7 +1018,7 @@ export function EditingForm({

- {t('edit.referenceHint', { count: maxImages })} + {editUploadHint}

@@ -939,7 +1047,7 @@ export function EditingForm({ {hasSourceImages ? t('edit.referenceAddMore') : t('edit.referenceAction')} - {hasSourceImages ? t('edit.referenceReady') : t('edit.referenceEmpty')} + {hasSourceImages ? t('edit.referenceReady') : editReferenceEmptyHint} @@ -964,6 +1072,13 @@ export function EditingForm({ {t('edit.referencePreparing')} )} + {responsesEditInputLimitActive && ( +

+ {t('edit.responsesInputLimit', { + limit: getResponsesEditInputLimitLabel() + })} +

+ )} {hasSourcePreviews && (
{sourceImagePreviewUrls.map((url, index) => ( @@ -1002,12 +1117,13 @@ export function EditingForm({ placeholder={t('form.editPromptPlaceholder')} value={editPrompt} onChange={(e) => setEditPrompt(e.target.value)} + maxLength={MAX_PROMPT_LENGTH} required disabled={isLoading} className='bg-muted/45 min-h-[118px] rounded-md px-4 py-3 pb-9 leading-7 shadow-inner' /> - {editPrompt.trim().length} / 1000 + {editPrompt.length.toLocaleString(locale)} / {MAX_PROMPT_LENGTH.toLocaleString(locale)}
@@ -1016,6 +1132,11 @@ export function EditingForm({
{t('edit.mask')}
+

+ {t('edit.maskHint', { + singleLimit: formatEditUploadLimit(upstreamProfile.upload.maxSingleBytes) + })} +

@@ -1421,7 +1453,7 @@ function HistoryPanelImpl({ {t('history.model')}
- {item.model || 'gpt-image-1'} + {getHistoryModelLabel(item, t)}
@@ -1494,7 +1526,7 @@ function HistoryPanelImpl({ {t('history.status')}
- {t('history.failedStatus')} + {failureStatusLabel}
)} @@ -1515,7 +1547,7 @@ function HistoryPanelImpl({ {t('history.totalEstimatedCost')}
- {item.costDetails + {isValidCostDetails(item.costDetails) ? `$${item.costDetails.estimated_cost_usd.toFixed(4)}` : '-'}
@@ -1591,12 +1623,12 @@ function HistoryPanelImpl({ variant='outline' size='sm' onClick={() => handleCopy(item.prompt, itemKey)}> - {copiedTimestamp === itemKey ? ( + {copiedHistoryId === itemKey ? ( ) : ( )} - {copiedTimestamp === itemKey + {copiedHistoryId === itemKey ? t('common.copied') : t('common.copy')} @@ -1613,7 +1645,10 @@ function HistoryPanelImpl({ { if (!isOpen) onCancelDeletion(); }}> @@ -1639,7 +1674,7 @@ function HistoryPanelImpl({
onDeletePreferenceDialogChange(!!checked) @@ -1647,7 +1682,7 @@ function HistoryPanelImpl({ className='data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground' /> diff --git a/src/components/image-compare-view.tsx b/src/components/image-compare-view.tsx index a92e373e25eefa88401cd3e3f93359546b4b3c23..8bd26652b4c45cb0c94f93b5c40760e0aff1c70c 100644 --- a/src/components/image-compare-view.tsx +++ b/src/components/image-compare-view.tsx @@ -12,6 +12,7 @@ type ImageCompareViewProps = { leftLabel: string; rightImage: CompareImage; rightLabel: string; + onImageError?: (image: CompareImage) => void; }; export function resolveCompareTargetIndex(imageCount: number, selectedImageIndex: number | null): number | null { @@ -22,7 +23,13 @@ export function resolveCompareTargetIndex(imageCount: number, selectedImageIndex return selectedImageIndex > 0 ? selectedImageIndex - 1 : 1; } -export function ImageCompareView({ leftImage, leftLabel, rightImage, rightLabel }: ImageCompareViewProps) { +export function ImageCompareView({ + leftImage, + leftLabel, + rightImage, + rightLabel, + onImageError +}: ImageCompareViewProps) { return (
{[ @@ -39,6 +46,7 @@ export function ImageCompareView({ leftImage, leftLabel, rightImage, rightLabel fill sizes='(max-width: 768px) 46vw, 28vw' className='object-contain' + onError={() => onImageError?.(image)} unoptimized />
diff --git a/src/components/image-output.test.tsx b/src/components/image-output.test.tsx index 5cd15ed854bb876b704651304f408508c951badd..6d7c8bfe6b59ce35fd3002ca9d77da0130ee245f 100644 --- a/src/components/image-output.test.tsx +++ b/src/components/image-output.test.tsx @@ -1,7 +1,9 @@ -import { buildImageActionTarget, formatLogTime, ImageOutput } from './image-output'; +import { buildImageActionTarget, formatLogTime, ImageOutput, resolveLogScopeCopy } from './image-output'; import { I18nProvider } from '@/lib/i18n'; +import { renderInClientDom } from '@/test-utils/react-dom'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import { act } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; const noop = () => {}; @@ -96,7 +98,7 @@ describe('ImageOutput result actions', () => { assert.doesNotMatch(html, /workbench-sample/); assert.doesNotMatch(html, /灵感样张/); assert.doesNotMatch(html, /1024 x 768/); - for (const action of ['继续编辑', '按相同参数再次生成', '复用提示词', '对比', '下载']) { + for (const action of ['继续编辑', '再次生成', '复用提示词', '对比', '下载']) { assert.match( html, new RegExp( @@ -289,6 +291,125 @@ describe('ImageOutput result actions', () => { ); }); + it('closes comparison and disables a target that fails to load', async () => { + const view = await renderInClientDom( + + + + ); + try { + const compareButton = [...view.container.querySelectorAll('button')].find((button) => + button.textContent?.includes('对比') + ); + assert.ok(compareButton, 'missing compare button'); + await view.click(compareButton); + + const compareImage = [...view.container.querySelectorAll('img')].find( + (image) => image.alt === '上一张 missing.png' + ); + assert.ok(compareImage, 'missing comparison target image'); + const errorEvent = new compareImage.ownerDocument.defaultView!.Event('error', { bubbles: true }); + await act(async () => { + compareImage.dispatchEvent(errorEvent); + }); + + assert.match(view.container.textContent ?? '', /对比中的图片已不可访问,已退出对比/); + const disabledCompareButton = [...view.container.querySelectorAll('button')].find( + (button) => button.textContent?.includes('对比') + ); + assert.equal(disabledCompareButton?.disabled, true); + } finally { + await view.cleanup(); + } + }); + + it('closes comparison and disables it when the current image fails to load', async () => { + const view = await renderInClientDom( + + + + ); + try { + const compareButton = [...view.container.querySelectorAll('button')].find((button) => + button.textContent?.includes('对比') + ); + assert.ok(compareButton, 'missing compare button'); + await view.click(compareButton); + + const currentImage = [...view.container.querySelectorAll('img')].find( + (image) => image.alt === '当前图 current.png' + ); + assert.ok(currentImage, 'missing current comparison image'); + const errorEvent = new currentImage.ownerDocument.defaultView!.Event('error', { bubbles: true }); + await act(async () => { + currentImage.dispatchEvent(errorEvent); + }); + + assert.match(view.container.textContent ?? '', /对比中的图片已不可访问,已退出对比/); + const disabledCompareButton = [...view.container.querySelectorAll('button')].find( + (button) => button.textContent?.includes('对比') + ); + assert.equal(disabledCompareButton?.disabled, true); + } finally { + await view.cleanup(); + } + }); + + it('uses batch-specific activity scope copy for a multi-image grid', () => { + assert.deepEqual( + resolveLogScopeCopy({ + hasLogScope: true, + hasSelectedImageBatch: true, + hasScopeCandidate: true, + isLogScopeBatch: true + }), + { scopeKey: 'logs.scopeBatch', emptyKey: 'logs.emptyForBatch' } + ); + assert.deepEqual( + resolveLogScopeCopy({ + hasLogScope: false, + hasSelectedImageBatch: true, + hasScopeCandidate: false, + isLogScopeBatch: true + }), + { scopeKey: 'logs.scopeMissingBatch', emptyKey: 'logs.historyBatchWithoutScope' } + ); + }); + it('uses user-facing generation activity copy for the activity entry', () => { const html = renderToStaticMarkup( @@ -346,4 +467,34 @@ describe('ImageOutput result actions', () => { assert.match(html, new RegExp(`]*>${buttonContentPattern}重试生成${buttonContentPattern}`)); assert.doesNotMatch(html, /灵感样张/); }); + + it('uses edit-specific failure and retry text for an image-edit request', () => { + const html = renderToStaticMarkup( + + + + ); + + assert.match(html, /编辑失败/); + assert.match(html, new RegExp(`]*>${buttonContentPattern}重试编辑${buttonContentPattern}`)); + assert.doesNotMatch(html, /重试生成/); + }); }); diff --git a/src/components/image-output.tsx b/src/components/image-output.tsx index 6446776255dfda78a7d1db9e6a94c141c9e5a11e..8078d1b6c38de3a83853e1c0001aebab1ff3944c 100755 --- a/src/components/image-output.tsx +++ b/src/components/image-output.tsx @@ -68,6 +68,7 @@ type ImageOutputProps = { openLogsSignal?: number; logClientRequestIds?: string[]; logFilenames?: string[]; + isLogScopeBatch?: boolean; }; type LogEntry = { @@ -85,6 +86,11 @@ type ImageDimensions = { height: number; }; +type UnavailableCompareImage = { + imageBatch: ImageInfo[] | null; + filenames: string[]; +}; + export function buildImageActionTarget(image: ImageInfo | null): ImageActionTarget | null { if (!image) return null; return { @@ -99,6 +105,34 @@ export function formatLogTime(value: string, locale: string): string { return date.toLocaleTimeString(locale); } +export function resolveLogScopeCopy(input: { + hasLogScope: boolean; + hasSelectedImageBatch: boolean; + hasScopeCandidate: boolean; + isLogScopeBatch: boolean; +}): { scopeKey: string; emptyKey: string } { + if (input.hasLogScope) { + return { + scopeKey: input.isLogScopeBatch ? 'logs.scopeBatch' : 'logs.scopeSelected', + emptyKey: input.isLogScopeBatch ? 'logs.emptyForBatch' : 'logs.emptyForSelection' + }; + } + + if (input.hasSelectedImageBatch) { + return { + scopeKey: input.isLogScopeBatch ? 'logs.scopeMissingBatch' : 'logs.scopeMissing', + emptyKey: + !input.hasScopeCandidate && input.isLogScopeBatch + ? 'logs.historyBatchWithoutScope' + : !input.hasScopeCandidate + ? 'logs.historyWithoutScope' + : 'logs.selectImage' + }; + } + + return { scopeKey: 'logs.scopeNone', emptyKey: 'logs.selectImage' }; +} + function formatLogScopeValues(values: string[]): string { return values.length > 0 ? values.join(', ') : '-'; } @@ -190,7 +224,8 @@ export function ImageOutput({ canOpenLogs, openLogsSignal, logClientRequestIds = [], - logFilenames = [] + logFilenames = [], + isLogScopeBatch = false }: ImageOutputProps) { const { locale, t } = useI18n(); const resolvedAltText = altText ?? t('output.alt'); @@ -203,6 +238,7 @@ export function ImageOutput({ selectedImageIndex: number; compareTargetFilename: string; } | null>(null); + const [unavailableCompareImage, setUnavailableCompareImage] = React.useState(null); const [imageDimensions, setImageDimensions] = React.useState>({}); const logEndRef = React.useRef(null); const resolvedLogClientRequestIds = React.useMemo( @@ -225,6 +261,12 @@ export function ImageOutput({ const hasSelectedImageBatch = !!imageBatch && imageBatch.length > 0; const hasLogScope = resolvedLogClientRequestIds.length > 0; const hasScopeCandidate = logClientRequestIds.length > 0 || logFilenames.length > 0; + const logScopeCopy = resolveLogScopeCopy({ + hasLogScope, + hasSelectedImageBatch, + hasScopeCandidate, + isLogScopeBatch + }); const visibleLogs = React.useMemo(() => (hasLogScope ? filteredLogs : []), [filteredLogs, hasLogScope]); const showCarousel = Boolean(imageBatch && imageBatch.length > 1); const selectedImageIndex = @@ -241,15 +283,29 @@ export function ImageOutput({ : null; const sameBatchCompareTargetImage = sameBatchCompareTargetIndex === null ? null : imageBatch?.[sameBatchCompareTargetIndex] || null; - const compareTargetImage = sameBatchCompareTargetImage || compareImage || null; + const candidateCompareTargetImage = sameBatchCompareTargetImage || compareImage || null; + const unavailableCompareFilenames = + unavailableCompareImage?.imageBatch === imageBatch ? unavailableCompareImage.filenames : []; + const isSelectedImageUnavailable = + selectedImage !== null && unavailableCompareFilenames.includes(selectedImage.filename); + const isCandidateCompareTargetUnavailable = + candidateCompareTargetImage !== null && + unavailableCompareFilenames.includes(candidateCompareTargetImage.filename); + const compareTargetImage = + candidateCompareTargetImage && !isCandidateCompareTargetUnavailable ? candidateCompareTargetImage : null; + const compareError = + isSelectedImageUnavailable || isCandidateCompareTargetUnavailable ? t('output.compareUnavailable') : null; const selectedImageDimensions = selectedImage ? imageDimensions[selectedImage.filename] : null; const hasFailure = !isLoading && !hasSelectedImageBatch && Boolean(failureMessage); const hasEditReferencePreview = currentMode === 'edit' && Boolean(baseImagePreviewUrl); const isGenerateEmptyState = !isLoading && !hasFailure && !hasSelectedImageBatch && currentMode === 'generate'; + const failureTitle = currentMode === 'edit' ? t('output.editFailedTitle') : t('output.failedTitle'); + const failureKicker = currentMode === 'edit' ? t('output.editFailedKicker') : t('output.failedKicker'); + const retryLabel = currentMode === 'edit' ? t('output.retryEdit') : t('output.retry'); const previewStateLabel = isLoading ? t('output.progressDeveloping') : hasFailure - ? t('output.failedTitle') + ? failureTitle : hasEditReferencePreview ? t('output.editReferenceReady') : currentMode === 'edit' @@ -275,7 +331,7 @@ export function ImageOutput({ isLoading ? (isStreamingRequest ? t('output.streaming') : t('output.progressGenerating')) : null ].filter((item): item is string => Boolean(item)); const canUseSelectedImageActions = !isLoading && !!selectedImage; - const canCompareImages = !isLoading && !!selectedImage && !!compareTargetImage; + const canCompareImages = !isLoading && !!selectedImage && !isSelectedImageUnavailable && !!compareTargetImage; const isCompareView = !!compareSelection && compareSelection.imageBatch === imageBatch && @@ -306,11 +362,31 @@ export function ImageOutput({ }; const handleCompareClick = () => { + if (isCompareView) { + setCompareSelection(null); + return; + } if (canCompareImages && imageBatch && selectedImageIndex !== null && compareTargetImage) { setCompareSelection({ imageBatch, selectedImageIndex, compareTargetFilename: compareTargetImage.filename }); } }; + const handleCompareImageError = React.useCallback( + (image: ImageInfo) => { + if (image.filename !== selectedImage?.filename && image.filename !== compareTargetImage?.filename) return; + setUnavailableCompareImage((current) => { + if (current?.imageBatch === imageBatch && current.filenames.includes(image.filename)) return current; + return { + imageBatch, + filenames: + current?.imageBatch === imageBatch ? [...current.filenames, image.filename] : [image.filename] + }; + }); + setCompareSelection(null); + }, + [compareTargetImage?.filename, imageBatch, selectedImage?.filename] + ); + const handleImageLoad = React.useCallback((filename: string, event: React.SyntheticEvent) => { const source = event.currentTarget.currentSrc || event.currentTarget.src; if (!source) return; @@ -520,9 +596,9 @@ export function ImageOutput({ ) : hasFailure ? (
-
{t('output.failedKicker')}
+
{failureKicker}
-

{t('output.failedTitle')}

+

{failureTitle}

{failureMessage}

@@ -532,7 +608,7 @@ export function ImageOutput({ {onRetry ? ( ) : null} {canOpenLogs ? ( @@ -553,6 +629,7 @@ export function ImageOutput({ leftLabel={compareReferenceLabel} rightImage={selectedImage} rightLabel={t('output.compareCurrent')} + onImageError={handleCompareImageError} /> ) : imageBatch && imageBatch.length > 0 ? ( viewMode === 'grid' ? ( @@ -646,6 +723,14 @@ export function ImageOutput({ )}
+ {compareError ? ( +
+ {compareError} +
+ ) : null} + @@ -658,15 +743,15 @@ export function ImageOutput({
{hasLogScope ? (
- {t('logs.scopeSelected')} + {t(logScopeCopy.scopeKey)}
) : hasSelectedImageBatch ? (
- {t('logs.scopeMissing')} + {t(logScopeCopy.scopeKey)}
) : (
- {t('logs.scopeNone')} + {t(logScopeCopy.scopeKey)}
)}
@@ -701,13 +786,7 @@ export function ImageOutput({
{visibleLogs.length === 0 ? ( -

- {hasLogScope - ? t('logs.emptyForSelection') - : hasSelectedImageBatch && !hasScopeCandidate - ? t('logs.historyWithoutScope') - : t('logs.selectImage')} -

+

{t(logScopeCopy.emptyKey)}

) : ( visibleLogs.map((entry) => (
@@ -741,7 +820,7 @@ export function ImageOutput({ @@ -831,7 +910,7 @@ export function ImageOutput({ /> } - label={t('output.compare')} + label={isCompareView ? t('output.exitCompare') : t('output.compare')} onClick={handleCompareClick} disabled={!canCompareImages} active={isCompareView} diff --git a/src/components/workbench-pro-dock-panels.tsx b/src/components/workbench-pro-dock-panels.tsx index a41f3b82dd6b33d279879a627f0731f6c6d7d5bb..19db8f255525eec757691816dde7a2d444cd70cb 100644 --- a/src/components/workbench-pro-dock-panels.tsx +++ b/src/components/workbench-pro-dock-panels.tsx @@ -44,11 +44,29 @@ function getStreamingStrategyLabel(strategy: ProPanelProps['streamingStrategy'], return t('upstream.serverDefault'); } -function formatResolution(size: SizePreset, model: GptImageModel, t: Translation): string { +function formatResolution( + size: SizePreset, + model: GptImageModel, + customWidth: number | undefined, + customHeight: number | undefined, + t: Translation +): string { + if (size === 'custom') { + if ( + typeof customWidth === 'number' && + typeof customHeight === 'number' && + Number.isInteger(customWidth) && + Number.isInteger(customHeight) && + customWidth > 0 && + customHeight > 0 + ) { + return `${customWidth}x${customHeight}`; + } + return t('common.custom'); + } const resolution = getPresetDimensions(size, model); if (resolution) return resolution; - if (size === 'custom') return t('common.custom'); - return '1024 px'; + return t('common.auto'); } function ReadonlyField({ label, children }: { label: string; children: React.ReactNode }) { @@ -64,6 +82,8 @@ function ReadonlyField({ label, children }: { label: string; children: React.Rea export function WorkbenchEasySummary({ model, + customWidth, + customHeight, streamMode, outputFormat, size, @@ -74,7 +94,7 @@ export function WorkbenchEasySummary({ defaultStreamingStrategy }: ProPanelProps) { const { t } = useI18n(); - const resolution = formatResolution(size, model, t); + const resolution = formatResolution(size, model, customWidth, customHeight, t); const effectiveStreamingStrategy = streamingStrategy === 'server-default' ? defaultStreamingStrategy : streamingStrategy; const parallelBatchVisible = resolveStreamingBatchToggleState({ @@ -293,7 +313,7 @@ export function WorkbenchProPanel({ ...props }: ProPanelProps & { defaultTab: 'output' | 'model' | 'stream' | 'route' }) { const { t } = useI18n(); - const resolution = formatResolution(props.size, props.model, t); + const resolution = formatResolution(props.size, props.model, props.customWidth, props.customHeight, t); const strategyLabel = getStreamingStrategyLabel(props.streamingStrategy, t); return ( diff --git a/src/components/workbench-pro-dock.test.tsx b/src/components/workbench-pro-dock.test.tsx index c5bbc6d6489f0171950cf27793dec664e8529487..5d243329c17c9e02e214447f6816be0326a3fc33 100644 --- a/src/components/workbench-pro-dock.test.tsx +++ b/src/components/workbench-pro-dock.test.tsx @@ -90,12 +90,14 @@ describe('WorkbenchProDock', () => { assert.match(html, /aria-pressed="true"[^>]*>省心模式/); assert.match(html, /输出格式/); assert.match(html, /gpt-image-2/); + assert.match(html, /分辨率[\s\S]*?>自动 { + it('shows the actual custom resolution in the workbench summary', () => { const html = renderToStaticMarkup( { model='gpt-image-2' onModelChange={setModel} size='custom' + customWidth={1536} + customHeight={1024} streamMode='auto' onStreamModeChange={setStreamMode} allowStreamingBatch={false} @@ -125,7 +129,8 @@ describe('WorkbenchProDock', () => { ); - assert.match(html, /自定义/); + assert.match(html, /分辨率[\s\S]*?>1536x1024自定义 { @@ -245,7 +250,7 @@ describe('WorkbenchProDock', () => { ); assert.match(html, /关闭流式会减少长连接不稳定因素/); - assert.doesNotMatch(html, /自动或服务端默认会优先使用当前推荐的流式策略/); + assert.doesNotMatch(html, /当前自动策略会由服务端按渠道选择传输方式/); }); it('disables the experimental Responses backend when runtime capabilities do not allow it', () => { @@ -318,7 +323,7 @@ describe('WorkbenchProDock', () => { assert.match(html, /pro-parallel-batch-enabled/); assert.match(html, /并发批量/); - assert.match(html, /多张图或多条提示词会按当前渠道容量并发执行/); + assert.match(html, /多张图或多条提示词会按当前服务端配置的并发上限尝试执行/); assert.match(html, /aria-checked="true"/); }); diff --git a/src/components/workbench-pro-dock.tsx b/src/components/workbench-pro-dock.tsx index 3285c1d3024bad56970db9b10ff0d66672c1d4c3..3990a4636d77983cd66268495efd38b150fc94a2 100644 --- a/src/components/workbench-pro-dock.tsx +++ b/src/components/workbench-pro-dock.tsx @@ -20,6 +20,8 @@ export type WorkbenchProDockProps = { model: GptImageModel; onModelChange: React.Dispatch>; size: SizePreset; + customWidth?: number; + customHeight?: number; streamMode: ImageStreamMode; onStreamModeChange: React.Dispatch>; allowStreamingBatch: boolean; diff --git a/src/components/workbench-status-strip.test.tsx b/src/components/workbench-status-strip.test.tsx index bb5699f8238b006b827f372189b88bc1e3d03d97..4202b64e19b9f65e7e027af6a63b9af1b55e8f33 100644 --- a/src/components/workbench-status-strip.test.tsx +++ b/src/components/workbench-status-strip.test.tsx @@ -17,7 +17,7 @@ describe('WorkbenchStatusStrip', () => { ); - assert.match(html, /当前请求路径已就绪/); + assert.match(html, /本地路由已配置,可尝试请求/); assert.match(html, /gpt-image-2/); assert.match(html, /images-sse/); assert.match(html, /自动/); @@ -107,8 +107,8 @@ describe('WorkbenchStatusStrip', () => { assert.match(checkingHtml, /正在读取运行时状态/); assert.match(checkingHtml, /animate-spin/); assert.match(disconnectedHtml, /未取得运行时状态/); - assert.match(routeLimitedHtml, /当前路由受限/); - assert.match(customOverrideHtml, /使用自定义上游/); + assert.match(routeLimitedHtml, /当前配置无法使用所选路径/); + assert.match(customOverrideHtml, /使用本次自定义 API 凭据/); }); it('shows request mode health details and suggested channel env in the status strip', () => { @@ -148,16 +148,18 @@ describe('WorkbenchStatusStrip', () => { ); - assert.match(html, /请求方式/); + assert.match(html, /请求方式状态/); assert.match(html, /images-non-stream/); - assert.match(html, /可用于请求/); + assert.match(html, /未处于冷却,可尝试请求/); assert.match(html, /responses-sse/); - assert.match(html, /冷却中或待探测/); + assert.match(html, /冷却中或等待恢复探测/); assert.match(html, /最近渠道失败/); + assert.match(html, /渠道/); + assert.doesNotMatch(html, /channel\s*\/\s*HTTP 403/); assert.match(html, /HTTP 403/); assert.match(html, /responses-sse/); assert.match(html, /OPENAI_CHANNEL_N_REQUEST_MODES=images-non-stream/); - assert.match(html, /建议的环境变量/); + assert.match(html, /请求方式配置参考/); }); it('keeps request mode details constrained on narrow screens', () => { diff --git a/src/components/workbench-status-strip.tsx b/src/components/workbench-status-strip.tsx index ff0f147780a3559e7269f7c6768550614cff9c7b..68c7d1c3ce33fc13df66367b40b9298375c517d3 100644 --- a/src/components/workbench-status-strip.tsx +++ b/src/components/workbench-status-strip.tsx @@ -38,6 +38,10 @@ type WorkbenchStatusStripProps = { className?: string; }; +function getRuntimeFailureScopeLabel(scope: RuntimeLastFailure['scope'], t: ReturnType['t']): string { + return scope === 'credential' ? t('app.failureScopeCredential') : t('app.failureScopeChannel'); +} + export function WorkbenchStatusStrip({ model, routeLabel, @@ -146,7 +150,7 @@ export function WorkbenchStatusStrip({

{t('app.recentChannelFailure')}:{' '} {[ - runtimeLastFailure.scope, + getRuntimeFailureScopeLabel(runtimeLastFailure.scope, t), runtimeLastFailure.status ? `HTTP ${runtimeLastFailure.status}` : undefined, runtimeLastFailure.code, runtimeLastFailure.requestMode, diff --git a/src/lib/agent-api-contracts.test.ts b/src/lib/agent-api-contracts.test.ts index ea795cc40d4180715635fa838e9f8b9deeb65cfd..9a913833d98262f911808c7aa5cf51ba4b9ea233 100644 --- a/src/lib/agent-api-contracts.test.ts +++ b/src/lib/agent-api-contracts.test.ts @@ -107,12 +107,14 @@ describe('validateAgentGenerateRequest', () => { ); }); - it('rejects Responses backend partial image counts outside the backend contract', () => { + it('rejects Responses streaming partial image counts outside the backend contract', () => { assert.throws( () => validateAgentGenerateRequest({ prompt: 'draw a responses image', image_backend: 'responses-image-generation', + stream_mode: 'stream', + streaming_strategy: 'responses-sse', partial_images: 0 }), (error) => { @@ -125,6 +127,24 @@ describe('validateAgentGenerateRequest', () => { ); }); + it('rejects Responses backend output counts outside the backend contract', () => { + assert.throws( + () => + validateAgentGenerateRequest({ + prompt: 'draw more than one responses image', + image_backend: 'responses-image-generation', + n: 2 + }), + (error) => { + assert.ok(error instanceof RequestValidationError); + assert.equal(error.status, 422); + const details = JSON.parse(error.message) as { fields: Record }; + assert.match(details.fields.n, /1 到 1/); + return true; + } + ); + }); + it('uses deployed upstream profile limits when validating Agent partial image counts', () => { const originalEnv = { ...process.env }; try { @@ -146,6 +166,8 @@ describe('validateAgentGenerateRequest', () => { validateAgentGenerateRequest({ prompt: 'draw through matsca responses', image_backend: 'responses-image-generation', + stream_mode: 'stream', + streaming_strategy: 'responses-sse', partial_images: 4 }), (error) => { @@ -161,6 +183,207 @@ describe('validateAgentGenerateRequest', () => { } }); + it('accepts ignored Responses partial image counts for explicit non-stream requests', () => { + const originalEnv = { ...process.env }; + try { + process.env.OPENAI_CHANNEL_1_ID = 'responses-non-stream'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://responses.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'configured'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'responses-non-stream-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + + const request = validateAgentGenerateRequest({ + prompt: 'draw through responses non-stream', + image_backend: 'responses-image-generation', + stream_mode: 'non_stream', + streaming_strategy: 'off', + partial_images: 0 + }); + + assert.equal(request.partial_images, 0); + } finally { + restoreEnv(originalEnv); + } + }); + + it('validates Responses partial image counts for automatic requests without a non-stream channel', () => { + const originalEnv = { ...process.env }; + try { + process.env.OPENAI_CHANNEL_1_ID = 'responses-sse-only'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://responses.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'configured'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'responses-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'responses-sse-only-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + + assert.throws( + () => + validateAgentGenerateRequest({ + prompt: 'draw through responses sse only', + image_backend: 'responses-image-generation', + stream_mode: 'auto', + streaming_strategy: 'auto', + partial_images: 0 + }), + (error) => { + assert.ok(error instanceof RequestValidationError); + assert.equal(error.status, 422); + const details = JSON.parse(error.message) as { fields: Record }; + assert.match(details.fields.partial_images, /1 到 3/); + return true; + } + ); + } finally { + restoreEnv(originalEnv); + } + }); + + it('chooses an n-compatible SSE preview default for automatic mixed-channel requests', () => { + const originalEnv = { ...process.env }; + try { + process.env.OPENAI_CHANNEL_1_ID = 'non-stream-count-two'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://non-stream.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'non-stream-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'non-stream-count-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'sse-count-one'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://sse.example.com/v1'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'sse-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'sse-count-one-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { + generate_count: { min: 1, max: 1 }, + partial_images: { min: 3, max: 3 } + } + }); + + const request = validateAgentGenerateRequest({ + prompt: 'automatic mixed-channel preview default', + n: 1, + stream_mode: 'auto', + streaming_strategy: 'auto' + }); + + assert.equal(request.partial_images, 3); + } finally { + restoreEnv(originalEnv); + } + }); + + it('chooses an SSE preview default from the channel that accepts the requested background', () => { + const originalEnv = { ...process.env }; + try { + process.env.OPENAI_CHANNEL_1_ID = 'openai-sse'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://openai.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'openai-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'openai-sse-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 1, max: 1 } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'matsca-sse'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://matsca.example.com/v1'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'matsca-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE = 'matsca'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'matsca-sse-provider', + base_profile: 'matsca', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 3, max: 3 } } + }); + + const request = validateAgentGenerateRequest({ + prompt: 'automatic background-aware preview default', + n: 1, + background: 'transparent', + stream_mode: 'auto', + streaming_strategy: 'auto' + }); + + assert.equal(request.partial_images, 3); + } finally { + restoreEnv(originalEnv); + } + }); + + it('chooses a healthy SSE preview default when the non-stream channel is cooling down', async () => { + const originalEnv = { ...process.env }; + let resetServerChannelStateForTests: (() => void) | undefined; + try { + process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED = 'true'; + process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_MS = '60000'; + process.env.OPENAI_CHANNEL_1_ID = 'cooling-non-stream'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://non-stream.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'non-stream-key'; + process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'cooling-non-stream-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } } + }); + process.env.OPENAI_CHANNEL_2_ID = 'healthy-sse'; + process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://sse.example.com/v1'; + process.env.OPENAI_CHANNEL_2_API_KEYS = 'sse-key'; + process.env.OPENAI_CHANNEL_2_REQUEST_MODES = 'images-sse'; + process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST = JSON.stringify({ + id: 'healthy-sse-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 3, max: 3 } } + }); + + const serverChannelRouter = await import('./server-channel-router'); + resetServerChannelStateForTests = serverChannelRouter.resetServerChannelStateForTests; + const state = serverChannelRouter.getServerChannelState(); + const coolingCredential = state.config.credentials.find( + (credential) => credential.channelId === 'cooling-non-stream' + ); + assert.ok(coolingCredential); + state.router?.reportFailure(coolingCredential, { + scope: 'channel', + requestMode: 'images-non-stream', + reason: { + at: Date.now(), + scope: 'channel', + requestMode: 'images-non-stream', + status: 503 + } + }); + + const request = validateAgentGenerateRequest({ + prompt: 'automatic request with a cooling non-stream channel', + n: 1, + stream_mode: 'auto', + streaming_strategy: 'auto' + }); + + assert.equal(request.partial_images, 3); + } finally { + resetServerChannelStateForTests?.(); + restoreEnv(originalEnv); + } + }); + it('rejects non-string Agent upstream strategy fields instead of defaulting them', () => { assert.throws( () => @@ -411,6 +634,14 @@ describe('buildAgentCapabilities', () => { 'images-api': { min: 1, max: 3 }, 'responses-image-generation': { min: 1, max: 3 } }); + assert.deepEqual(capabilities.limits.generate_images_by_backend, { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }); + assert.deepEqual(capabilities.limits.edit_images_by_backend, { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }); assert.equal(capabilities.limits.max_images, 10); assert.deepEqual(capabilities.limits.generate_images, { min: 1, max: 10 }); assert.deepEqual(capabilities.limits.edit_images, { min: 1, max: 10 }); @@ -548,7 +779,7 @@ describe('buildAgentCapabilities', () => { }); assert.equal(capabilities.agent_streaming.page_sse.agent_usage, AGENT_PAGE_SSE_AGENT_USAGE); assert.deepEqual(capabilities.upstream_request_headers.default, { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: [] @@ -718,10 +949,29 @@ describe('buildAgentCapabilities', () => { request_modes: ['images-non-stream', 'images-sse'], request_mode_priority: ['images-non-stream', 'images-sse'], request_headers: { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: [] + }, + constraints: { + generate_images: { min: 1, max: 10 }, + edit_images: { min: 1, max: 10 }, + partial_images: { min: 1, max: 3 }, + generate_images_by_backend: { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + edit_images_by_backend: { + 'images-api': { min: 1, max: 10 }, + 'responses-image-generation': { min: 1, max: 1 } + }, + partial_images_by_backend: { + 'images-api': { min: 1, max: 3 }, + 'responses-image-generation': { min: 1, max: 3 } + }, + upload_images: { max: 10, max_single_mb: 25 }, + gpt_image_2: { allow_transparent_background: false, size_policy: 'openai-compatible' } } } ]); @@ -783,6 +1033,14 @@ describe('buildAgentCapabilities', () => { 'images-api': { min: 0, max: 4 }, 'responses-image-generation': { min: 1, max: 3 } }); + assert.deepEqual(capabilities.limits.generate_images_by_backend, { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }); + assert.deepEqual(capabilities.limits.edit_images_by_backend, { + 'images-api': { min: 1, max: 4 }, + 'responses-image-generation': { min: 1, max: 1 } + }); assert.equal(capabilities.limits.upstream_profile, 'matsca'); assert.equal(capabilities.limits.upstream_profile_mixed, false); assert.equal(capabilities.model_limits['gpt-image-2'].size_policy, 'positive-integer'); @@ -818,6 +1076,190 @@ describe('buildAgentCapabilities', () => { assert.deepEqual(capabilities.limits.partial_images, { min: 1, max: 2 }); }); + it('rejects Agent n=0 when a provider manifest tries to lower the count bound to zero', () => { + const originalEnv = { ...process.env }; + try { + process.env.OPENAI_CHANNEL_1_ID = 'zero-count'; + process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://custom.example.com/v1'; + process.env.OPENAI_CHANNEL_1_API_KEYS = 'configured'; + process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST = JSON.stringify({ + id: 'zero_count_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 0, max: 2 } } + }); + + assert.throws( + () => validateAgentGenerateRequest({ prompt: 'reject zero output count', n: 0 }), + (error) => { + assert.ok(error instanceof RequestValidationError); + assert.equal(error.status, 500); + assert.match(error.message, /constraints\.generate_count\.min 必须是正整数/); + return true; + } + ); + } finally { + restoreEnv(originalEnv); + } + }); + + it('disables Responses when its provider count range has no valid intersection', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-4.1', + OPENAI_CHANNEL_1_ID: 'fixed-two', + OPENAI_CHANNEL_1_BASE_URL: 'https://custom.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }) + }); + + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']); + assert.equal(capabilities.supported.image_backend_requirements['responses-image-generation'].enabled, false); + assert.deepEqual( + capabilities.supported.image_backend_requirements['responses-image-generation'].incompatible_constraints, + ['request_modes'] + ); + }); + + it('keeps Images-only capabilities valid when Responses is disabled', () => { + const capabilities = buildAgentCapabilities({ + OPENAI_CHANNEL_1_ID: 'fixed-two', + OPENAI_CHANNEL_1_BASE_URL: 'https://custom.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { + generate_count: { min: 2, max: 2 }, + partial_images: { min: 0, max: 0 } + } + }) + }); + + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']); + assert.deepEqual(capabilities.limits.generate_images_by_backend, { + 'images-api': { min: 2, max: 2 }, + 'responses-image-generation': { min: 1, max: 1 } + }); + assert.deepEqual(capabilities.limits.partial_images_by_backend, { + 'images-api': { min: 0, max: 0 }, + 'responses-image-generation': { min: 1, max: 3 } + }); + }); + + it('keeps Responses enabled for non-stream requests when only streaming partial images are incompatible', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-5.4', + OPENAI_CHANNEL_1_ID: 'responses-non-stream', + OPENAI_CHANNEL_1_BASE_URL: 'https://responses.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream,responses-sse', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'responses-partial-zero-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }) + }); + + const requirement = capabilities.supported.image_backend_requirements['responses-image-generation']; + assert.equal(requirement.enabled, true); + assert.equal('incompatible_constraints' in requirement, false); + assert.deepEqual(requirement.streaming_incompatible_constraints, ['partial_images']); + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api', 'responses-image-generation']); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.enabled_image_backends, ['images-api']); + assert.deepEqual(capabilities.limits.partial_images_by_backend['responses-image-generation'], { + min: 1, + max: 3 + }); + }); + + it('does not advertise Responses streaming when no provider supports a preview count', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-5.4', + OPENAI_CHANNEL_1_ID: 'no-previews', + OPENAI_CHANNEL_1_BASE_URL: 'https://no-previews.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream,responses-sse', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'no-previews-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }), + OPENAI_CHANNEL_2_ID: 'four-previews', + OPENAI_CHANNEL_2_BASE_URL: 'https://four-previews.example.com/v1', + OPENAI_CHANNEL_2_API_KEYS: 'configured', + OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-non-stream,responses-sse', + OPENAI_CHANNEL_2_PROVIDER_MANIFEST: JSON.stringify({ + id: 'four-previews-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { partial_images: { min: 4, max: 4 } } + }) + }); + + const requirement = capabilities.supported.image_backend_requirements['responses-image-generation']; + assert.equal(requirement.enabled, true); + assert.deepEqual(requirement.streaming_incompatible_constraints, ['partial_images']); + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api', 'responses-image-generation']); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.enabled_image_backends, ['images-api']); + }); + + it('does not combine count and preview support from different Responses credentials', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-5.4', + OPENAI_CHANNEL_1_ID: 'count-one-no-preview', + OPENAI_CHANNEL_1_BASE_URL: 'https://count-one.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream,responses-sse', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'count-one-no-preview-provider', + base_profile: 'openai-compatible', + modes: { + generate: { submit: { path: '/responses' } }, + edit: { submit: { path: '/responses' } } + }, + constraints: { + generate_count: { min: 1, max: 1 }, + edit_count: { min: 1, max: 1 }, + partial_images: { min: 0, max: 0 } + } + }), + OPENAI_CHANNEL_2_ID: 'count-two-preview', + OPENAI_CHANNEL_2_BASE_URL: 'https://count-two.example.com/v1', + OPENAI_CHANNEL_2_API_KEYS: 'configured', + OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-non-stream,responses-sse', + OPENAI_CHANNEL_2_PROVIDER_MANIFEST: JSON.stringify({ + id: 'count-two-preview-provider', + base_profile: 'openai-compatible', + modes: { + generate: { submit: { path: '/responses' } }, + edit: { submit: { path: '/responses' } } + }, + constraints: { + generate_count: { min: 2, max: 2 }, + edit_count: { min: 2, max: 2 }, + partial_images: { min: 1, max: 1 } + } + }) + }); + + const requirement = capabilities.supported.image_backend_requirements['responses-image-generation']; + assert.equal(requirement.enabled, true); + assert.deepEqual(requirement.streaming_incompatible_constraints, ['partial_images']); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.enabled_image_backends, ['images-api']); + }); + it('reports mixed Agent limits when only one OpenAI-compatible channel has provider constraints', () => { const capabilities = buildAgentCapabilities({ OPENAI_CHANNEL_1_ID: 'custom', @@ -880,6 +1322,74 @@ describe('buildAgentCapabilities', () => { assert.equal(capabilities.model_limits['gpt-image-2'].allow_transparent_background, false); }); + it('keeps Agent capabilities valid when channel image count ranges do not intersect', () => { + const capabilities = buildAgentCapabilities({ + OPENAI_CHANNEL_1_ID: 'fixed-one', + OPENAI_CHANNEL_1_BASE_URL: 'https://one.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'fixed_one_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 1, max: 1 } } + }), + OPENAI_CHANNEL_2_ID: 'fixed-two', + OPENAI_CHANNEL_2_BASE_URL: 'https://two.example.com/v1', + OPENAI_CHANNEL_2_API_KEYS: 'configured', + OPENAI_CHANNEL_2_PROVIDER_MANIFEST: JSON.stringify({ + id: 'fixed_two_provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }) + }); + + assert.equal(capabilities.upstream_profile.serverConstraintsMixed, true); + assert.deepEqual( + capabilities.upstream_profile.serverConstraintsByProfile.map((profile) => profile.generateCount), + [ + { min: 1, max: 1 }, + { min: 2, max: 2 } + ] + ); + assert.ok( + capabilities.upstream_profile.activeConstraints.generateCount.min <= + capabilities.upstream_profile.activeConstraints.generateCount.max + ); + }); + + it('derives Responses image counts from a compatible channel profile', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-5.4', + OPENAI_CHANNEL_1_ID: 'fixed-two', + OPENAI_CHANNEL_1_BASE_URL: 'https://two.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'two-key', + OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream', + OPENAI_CHANNEL_1_PROVIDER_MANIFEST: JSON.stringify({ + id: 'fixed-two-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { generate_count: { min: 2, max: 2 } } + }), + OPENAI_CHANNEL_2_ID: 'one-to-ten', + OPENAI_CHANNEL_2_BASE_URL: 'https://one-to-ten.example.com/v1', + OPENAI_CHANNEL_2_API_KEYS: 'one-to-ten-key', + OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-non-stream', + OPENAI_CHANNEL_2_PROVIDER_MANIFEST: JSON.stringify({ + id: 'one-to-ten-provider', + base_profile: 'openai-compatible', + modes: { generate: { submit: { path: '/responses' } } }, + constraints: { generate_count: { min: 1, max: 10 } } + }) + }); + + assert.deepEqual(capabilities.limits.generate_images_by_backend['responses-image-generation'], { + min: 1, + max: 1 + }); + }); + it('exposes only the runtime-accepted bearer auth scheme when Agent token is configured', () => { assert.deepEqual( buildAgentAuthCapabilities({ @@ -916,6 +1426,24 @@ describe('buildAgentCapabilities', () => { }); }); + it('does not enable Responses when configured channels expose only Images request modes', () => { + const capabilities = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-5.4', + OPENAI_CHANNEL_1_ID: 'images-only', + OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream' + }); + + const requirement = capabilities.supported.image_backend_requirements['responses-image-generation']; + assert.equal(requirement.enabled, false); + assert.deepEqual(requirement.incompatible_constraints, ['request_modes']); + assert.deepEqual(requirement.streaming_incompatible_constraints, ['streaming_request_modes']); + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.enabled_image_backends, ['images-api']); + }); + it('exposes page SSE form password auth separately from Agent bearer auth', () => { const capabilities = buildAgentCapabilities({ AGENT_API_TOKEN: 'token', @@ -975,7 +1503,7 @@ describe('buildAgentCapabilities', () => { it('generates an OpenAPI document with the Agent generate endpoint', () => { const document = buildAgentOpenApiDocument({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test' }); assert.equal(document.openapi, '3.1.0'); - assert.equal(document.info.title, 'Visual Journal Agent API'); + assert.equal(document.info.title, 'Visual Journal Image Agent API'); assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]); assert.ok(AGENT_ENDPOINTS.openapi in document.paths); assert.ok(AGENT_ENDPOINTS.create_image_request in document.paths); @@ -1092,13 +1620,30 @@ describe('buildAgentCapabilities', () => { assert.equal(generateProperties.force_web.type, 'boolean'); assert.equal(generateProperties.force_request.type, 'boolean'); assert.deepEqual(generateProperties.n, { type: 'integer', minimum: 1, maximum: 10 }); - assert.deepEqual(generateProperties.partial_images, { type: 'integer', minimum: 1, maximum: 3, default: 2 }); - assert.deepEqual(document.components.schemas.GenerateRequest.allOf[0].then.properties.partial_images, { + assert.deepEqual(generateProperties.partial_images, { type: 'integer', minimum: 0, maximum: 4, default: 2 }); + const responsesCondition = document.components.schemas.GenerateRequest.allOf[0]; + assert.ok(responsesCondition?.then && 'allOf' in responsesCondition.then); + const responsesAllOf = responsesCondition.then.allOf; + assert.ok(Array.isArray(responsesAllOf) && responsesAllOf[1]?.then); + assert.deepEqual(responsesAllOf[0]?.then?.properties.partial_images, { + type: 'integer', + minimum: 0, + maximum: 4, + default: 2 + }); + assert.deepEqual(responsesAllOf[1].then.properties.partial_images, { type: 'integer', minimum: 1, maximum: 3, default: 2 }); + assert.ok(responsesCondition.then && 'properties' in responsesCondition.then); + assert.deepEqual(responsesCondition.then.properties.n, { + type: 'integer', + minimum: 1, + maximum: 1, + default: 1 + }); assert.deepEqual(generateProperties.background.enum, ['transparent', 'opaque', 'auto']); assert.deepEqual(document.components.schemas.GenerateRequest.allOf[1], { if: { @@ -1125,7 +1670,7 @@ describe('buildAgentCapabilities', () => { assert.ok(forceRequestProperty && typeof forceRequestProperty === 'object' && 'type' in forceRequestProperty); assert.equal(forceRequestProperty.type, 'boolean'); assert.deepEqual(editProperties.n, { type: 'integer', minimum: 1, maximum: 10 }); - assert.deepEqual(editProperties.partial_images, { type: 'integer', minimum: 1, maximum: 3, default: 2 }); + assert.deepEqual(editProperties.partial_images, { type: 'integer', minimum: 0, maximum: 4, default: 2 }); assert.deepEqual(document.components.schemas.EditRequest.required, ['prompt']); assert.match(document.components.schemas.EditRequest.description, /至少提供一个 image_0\.\.image_9/); assert.deepEqual( @@ -1163,6 +1708,16 @@ describe('buildAgentCapabilities', () => { .properties.max.const, 3 ); + assert.equal( + capabilityProperties.limits.properties.generate_images_by_backend.properties['responses-image-generation'] + .properties.max.const, + 1 + ); + assert.equal( + capabilityProperties.limits.properties.edit_images_by_backend.properties['responses-image-generation'] + .properties.max.const, + 1 + ); assert.equal( capabilityProperties.page_request_diagnostics.$ref, '#/components/schemas/AgentPageRequestDiagnosticsCapabilities' @@ -1374,12 +1929,31 @@ describe('buildAgentCapabilities', () => { assert.equal(document.components.schemas.EditRequest.properties.n.maximum, 4); assert.equal(document.components.schemas.GenerateRequest.properties.partial_images.minimum, 0); assert.equal(document.components.schemas.GenerateRequest.properties.partial_images.maximum, 4); - assert.deepEqual(document.components.schemas.GenerateRequest.allOf[0].then.properties.partial_images, { + const matscaResponsesCondition = document.components.schemas.GenerateRequest.allOf[0]; + assert.ok(matscaResponsesCondition?.then && 'allOf' in matscaResponsesCondition.then); + const matscaResponsesAllOf = matscaResponsesCondition.then.allOf; + assert.ok( + Array.isArray(matscaResponsesAllOf) && matscaResponsesAllOf[0]?.then && matscaResponsesAllOf[1]?.then + ); + assert.deepEqual(matscaResponsesAllOf[0].then.properties.partial_images, { + type: 'integer', + minimum: 0, + maximum: 4, + default: 2 + }); + assert.deepEqual(matscaResponsesAllOf[1].then.properties.partial_images, { type: 'integer', minimum: 1, maximum: 3, default: 2 }); + assert.ok(matscaResponsesCondition.then && 'properties' in matscaResponsesCondition.then); + assert.deepEqual(matscaResponsesCondition.then.properties.n, { + type: 'integer', + minimum: 1, + maximum: 1, + default: 1 + }); assert.equal(document.components.schemas.EditRequest.properties.partial_images.minimum, 0); assert.equal(document.components.schemas.EditRequest.properties.partial_images.maximum, 4); assert.match(document.components.schemas.EditRequest.description, /image_0\.\.image_7/); diff --git a/src/lib/agent-api-contracts.ts b/src/lib/agent-api-contracts.ts index c845aea8ff5699429ca4d457ca16c8ebd6fa6513..b283ee5aa3b7f4e8b7298b679e5a0e63862af3d5 100644 --- a/src/lib/agent-api-contracts.ts +++ b/src/lib/agent-api-contracts.ts @@ -4,12 +4,14 @@ import { readAppLogRetentionMetadata, type AppLogRetentionMetadata } from './app import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_ADMIN_CONTROL, + getEffectiveChannelRequestModes, + isStreamingChannelRequestMode, + resolveChannelRequestMode, type ChannelRequestMode, type ChannelRequestModeDecision } from './channel-request-mode'; import { getChannelPoolSummary, parseChannelPoolConfig } from './channel-router'; import { - MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH, RequestValidationError, validateApiBaseUrl, @@ -19,11 +21,21 @@ import { import { summarizeImageUpstreamProfile, summarizeUpstreamRequestHeaders, + buildIntegerRangeOptions, clampIntegerToRange, + getImageBackendCompatibility, + getImageCountRangeCompatibilityForBackend, + getImageCountRangeForBackend, + getPartialImagesRangeCompatibilityForBackend, getPartialImagesRangeForBackend, + isIntegerWithinRange, + readImageUpstreamProfile, + RESPONSES_IMAGE_COUNT_RANGE, + RESPONSES_PARTIAL_IMAGES_RANGE, type ImageUpstreamProfile, type ImageUpstreamProfileId, type ImageUpstreamProfileSummary, + type NumericRange, type PartialImagesCount, type UpstreamRequestHeaderSummary } from './image-upstream-profile'; @@ -38,6 +50,7 @@ import { } from './image-upstream-strategy'; import { summarizeOpenAIImageTransport, type UpstreamProxySummary } from './openai-image-transport'; import { CHINESE_POSITIVE_INTEGER_MESSAGES, readPositiveIntegerFromEnv } from './positive-integer-config.mjs'; +import { getExistingServerChannelState } from './server-channel-router'; import { readBooleanEnv } from './server-runtime'; import { GPT_IMAGE_2_EDGE_MULTIPLE, @@ -49,8 +62,10 @@ import { validatePositiveIntegerImageSize } from './size-utils'; +export type { NumericRange } from './image-upstream-profile'; + export const AGENT_API_VERSION = '1.0.0'; -export const AGENT_SCHEMA_VERSION = '2026-05-28'; +export const AGENT_SCHEMA_VERSION = '2026-07-29'; export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite'; export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000; export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60; @@ -108,11 +123,31 @@ export type AgentJobState = (typeof AGENT_JOB_STATES)[number]; export type AgentRoutingTransport = 'agent_json' | 'agent_job_polling' | 'page_sse'; export type AgentRoutingStrength = 'default' | 'recommended' | 'explicit'; export type AgentOrchestrationPolicy = 'server_orchestrated_generate_v1'; + +export type AgentChannelConstraints = { + generate_images: NumericRange; + edit_images: NumericRange; + partial_images: NumericRange; + generate_images_by_backend: Partial>; + edit_images_by_backend: Partial>; + partial_images_by_backend: Partial>; + upload_images: { + max: number; + max_single_mb: number; + max_total_mb?: number; + }; + gpt_image_2: { + allow_transparent_background: boolean; + size_policy: ImageUpstreamProfile['gptImage2']['sizePolicy']; + }; +}; export type ImageBackendRuntimeRequirement = { supported: true; enabled: boolean; required_env: string[]; missing_env: string[]; + incompatible_constraints?: string[]; + streaming_incompatible_constraints?: string[]; }; export type AgentPageRequestDiagnosticsRetention = AppLogRetentionMetadata & { @@ -248,6 +283,8 @@ export type AgentCapabilities = { request_modes: readonly ChannelRequestMode[]; request_mode_priority: readonly ChannelRequestMode[]; request_headers: UpstreamRequestHeaderSummary; + constraints: AgentChannelConstraints; + healthy_request_modes?: readonly ChannelRequestMode[]; }>; }; request_mode_controls: { @@ -275,13 +312,15 @@ export type AgentCapabilities = { limits: { max_prompt_length: number; max_images: number; - generate_images: { min: number; max: number }; - edit_images: { min: number; max: number }; + generate_images: NumericRange; + edit_images: NumericRange; + generate_images_by_backend: Record; + edit_images_by_backend: Record; upload_images: { max: number }; max_upload_mb: number; max_total_upload_mb?: number; - partial_images: { min: number; max: number }; - partial_images_by_backend: Record; + partial_images: NumericRange; + partial_images_by_backend: Record; upstream_profile: ImageUpstreamProfileId; upstream_profile_mixed: boolean; }; @@ -500,14 +539,18 @@ function readIntegerField( fallback: number, min: number, max: number, - fields: FieldErrors + fields: FieldErrors, + range?: NumericRange ): number { const value = body[field]; if (value === undefined || value === null || value === '') return fallback; const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; - if (!Number.isInteger(parsed) || parsed < min || parsed > max) { - fields[field] = `必须是 ${min} 到 ${max} 之间的整数`; + const effectiveRange = range ?? { min, max }; + if (!isIntegerWithinRange(parsed, effectiveRange)) { + fields[field] = effectiveRange.allowedValues + ? `必须是以下整数之一:${effectiveRange.allowedValues.join(', ')}` + : `必须是 ${min} 到 ${max} 之间的整数`; return fallback; } return parsed; @@ -644,7 +687,9 @@ function validateAgentImageUpstreamStrategy(input: { streamingStrategy: ImageStreamingStrategy; fields: FieldErrors; }) { - if (input.streamMode === 'non_stream') return; + if (input.streamMode === 'non_stream' || (input.streamMode === 'auto' && input.streamingStrategy === 'off')) { + return; + } try { resolveImageStreamEnabled({ imageBackend: input.imageBackend, @@ -657,15 +702,204 @@ function validateAgentImageUpstreamStrategy(input: { } } +function readAgentImageBackendCompatibility( + profile: ImageUpstreamProfile, + operation: 'generate' | 'edit', + imageBackend: ImageGenerationBackend, + fields: FieldErrors, + options: { validatePartialImages?: boolean } = {} +): ReturnType { + const compatibility = getImageBackendCompatibility(profile, operation, imageBackend, undefined, options); + if (!compatibility.compatible) { + fields.image_backend = compatibility.errors.map((error) => error.message).join(' '); + } + return compatibility; +} + +function shouldValidateAgentPartialImages(input: { + imageBackend: ImageGenerationBackend; + streamMode: ImageStreamMode; + streamingStrategy: ImageStreamingStrategy; + hasNonStreamChannel: boolean; +}): boolean { + if (input.streamMode === 'non_stream') return false; + if (input.streamMode === 'auto' && input.streamingStrategy === 'off') return false; + if (input.streamMode === 'auto' && input.streamingStrategy === 'auto') return !input.hasNonStreamChannel; + return true; +} + +function hasConfiguredNonStreamChannel(imageBackend: ImageGenerationBackend): boolean { + const requestMode = imageBackend === 'responses-image-generation' ? 'responses-non-stream' : 'images-non-stream'; + return getChannelPoolSummary(parseChannelPoolConfig(process.env)).channels.some((channel) => + channel.requestModes.includes(requestMode) + ); +} + +type AgentGenerateCandidate = { + profile: ImageUpstreamProfile; + requestMode: ChannelRequestMode; + partialImagesRange: NumericRange; +}; + +function readHealthyCredentialRequestModes(): Map> | undefined { + const router = getExistingServerChannelState()?.router; + if (!router) return undefined; + return new Map( + router + .getHealthSnapshot() + .channels.flatMap((channel) => + channel.credentials.map((credential) => [ + credential.credentialId, + new Set( + credential.requestModes + .filter((requestMode) => requestMode.state === 'healthy') + .map((requestMode) => requestMode.mode) + ) as ReadonlySet + ]) + ) + ); +} + +function readAgentAutoGenerateCandidates(input: { + imageBackend: ImageGenerationBackend; + n: number; + model: GptImageModel; + size: string; + background: AgentBackground; + forceRequest: boolean; +}): { nonStream: AgentGenerateCandidate[]; streaming: AgentGenerateCandidate[] } { + const serverState = getExistingServerChannelState(); + const credentials = serverState?.config.credentials ?? parseChannelPoolConfig(process.env).credentials; + const healthyModesByCredential = readHealthyCredentialRequestModes(); + const isAvailable = (credential: (typeof credentials)[number], requestMode: ChannelRequestMode) => { + if (!getEffectiveChannelRequestModes(credential).includes(requestMode)) return false; + if ( + isStreamingChannelRequestMode(requestMode) && + serverState?.streamingAvailability.isUnavailable({ + channelId: credential.channelId, + imageBackend: input.imageBackend, + streamingStrategy: 'auto', + operation: 'generate' + }) + ) { + return false; + } + return ( + healthyModesByCredential === undefined || + healthyModesByCredential.get(credential.id)?.has(requestMode) === true + ); + }; + const nonStreamRequestMode = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false }); + const streamingRequestMode = resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: true }); + const candidates = (requestMode: ChannelRequestMode, validatePartialImages: boolean) => + credentials.flatMap((credential) => { + if (!isAvailable(credential, requestMode)) return []; + const profile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + const compatibility = getImageBackendCompatibility(profile, 'generate', input.imageBackend, undefined, { + validatePartialImages + }); + const imageCountRange = compatibility.imageCountRange; + const partialImagesRange = compatibility.partialImagesRange; + if (!compatibility.compatible || !imageCountRange || !isIntegerWithinRange(input.n, imageCountRange)) { + return []; + } + if (!input.forceRequest) { + if ( + input.model === 'gpt-image-2' && + input.background === 'transparent' && + !profile.gptImage2.allowTransparentBackground + ) { + return []; + } + if (input.model === 'gpt-image-2' && input.size !== 'auto') { + const fields: FieldErrors = {}; + readSize({ size: input.size }, input.model, fields, '1024x1024', profile); + if (fields.size) return []; + } + } + if (!partialImagesRange) return []; + return [{ profile, requestMode, partialImagesRange }]; + }); + return { + nonStream: candidates(nonStreamRequestMode, false), + streaming: candidates(streamingRequestMode, true) + }; +} + +function resolveAgentAutoPartialImagesDefault(input: { + body: Record; + imageBackend: ImageGenerationBackend; + streamMode: ImageStreamMode; + streamingStrategy: ImageStreamingStrategy; + n: number; + model: GptImageModel; + size: string; + background: AgentBackground; + forceRequest: boolean; +}): PartialImagesCount | undefined { + if ( + input.streamMode !== 'auto' || + input.streamingStrategy !== 'auto' || + (input.body.partial_images !== undefined && + input.body.partial_images !== null && + input.body.partial_images !== '') + ) { + return undefined; + } + + const candidates = readAgentAutoGenerateCandidates(input); + const nonStreamCandidates = candidates.nonStream; + if (nonStreamCandidates.length > 0) { + return clampIntegerToRange( + AGENT_DEFAULT_PARTIAL_IMAGES, + getAgentNonStreamPartialImagesRange() + ) as PartialImagesCount; + } + + const streamingCandidates = candidates.streaming.map((candidate) => candidate.partialImagesRange); + if (streamingCandidates.length === 0) return undefined; + + const defaultRange = + streamingCandidates.find((range) => isIntegerWithinRange(AGENT_DEFAULT_PARTIAL_IMAGES, range)) ?? + streamingCandidates[0]; + return clampIntegerToRange(AGENT_DEFAULT_PARTIAL_IMAGES, defaultRange) as PartialImagesCount; +} + function readPartialImagesForBackend( body: Record, imageBackend: ImageGenerationBackend, fields: FieldErrors, - profile: ImageUpstreamProfile + profile: ImageUpstreamProfile, + compatibility: ReturnType, + options: { validatePartialImages?: boolean; defaultValue?: PartialImagesCount; range?: NumericRange } = {} ): PartialImagesCount { - const limits = getPartialImagesRangeForBackend(profile, imageBackend); - const fallback = clampDefaultPartialImages(limits); - return readIntegerField(body, 'partial_images', fallback, limits.min, limits.max, fields) as PartialImagesCount; + const limits = + options.range ?? + (options.validatePartialImages === false + ? getAgentNonStreamPartialImagesRange() + : (compatibility.partialImagesRange ?? + (imageBackend === 'responses-image-generation' + ? RESPONSES_PARTIAL_IMAGES_RANGE + : profile.partialImages))); + const fallback = + options.defaultValue !== undefined + ? clampIntegerToRange(options.defaultValue, limits) + : clampDefaultPartialImages(limits); + return readIntegerField( + body, + 'partial_images', + fallback, + limits.min, + limits.max, + fields, + limits + ) as PartialImagesCount; } function readQuality(body: Record, fields: FieldErrors): AgentQuality { @@ -686,6 +920,19 @@ function readBackground(body: Record, fields: FieldErrors): Age return value; } +function validateAgentGenerateBackground(input: { + model: GptImageModel; + background: AgentBackground; + forceRequest?: boolean; + profile: ImageUpstreamProfile; + fields: FieldErrors; +}): void { + if (input.forceRequest || input.model !== 'gpt-image-2' || input.background !== 'transparent') return; + if (!input.profile.gptImage2.allowTransparentBackground) { + input.fields.background = 'gpt-image-2 不支持 transparent 背景。'; + } +} + function readModeration(body: Record, fields: FieldErrors): AgentModeration { const value = readStringField(body, 'moderation', 'auto'); if (!value || !isOneOf(value, AGENT_MODERATIONS)) { @@ -784,9 +1031,34 @@ export function validateAgentGenerateRequest(body: unknown): AgentGenerateReques const upstreamLimits = buildAgentUpstreamLimits(process.env); const model = readModel(objectBody, fields); const prompt = validatePrompt(objectBody.prompt, fields); - const n = readIntegerField(objectBody, 'n', 1, 1, MAX_IMAGE_COUNT, fields); + const imageBackend = readAgentImageBackend(objectBody, fields); + const streamMode = readAgentStreamMode(objectBody, fields); + const streamingStrategy = readAgentStreamingStrategy(objectBody, fields); + let validatePartialImages = shouldValidateAgentPartialImages({ + imageBackend, + streamMode, + streamingStrategy, + hasNonStreamChannel: hasConfiguredNonStreamChannel(imageBackend) + }); + let backendCompatibility = readAgentImageBackendCompatibility( + upstreamLimits.validationProfile, + 'generate', + imageBackend, + fields, + { validatePartialImages } + ); + const imageCountRange = backendCompatibility.imageCountRange ?? RESPONSES_IMAGE_COUNT_RANGE; + const n = readIntegerField( + objectBody, + 'n', + imageCountRange.min, + imageCountRange.min, + imageCountRange.max, + fields, + imageCountRange + ); const forceRequest = readOptionalBooleanField(objectBody, 'force_request', fields); - const size = readSize(objectBody, model, fields, '1024x1024', upstreamLimits.profile, { + const size = readSize(objectBody, model, fields, '1024x1024', upstreamLimits.validationProfile, { forceRequest: forceRequest === true }); const quality = readQuality(objectBody, fields); @@ -795,14 +1067,72 @@ export function validateAgentGenerateRequest(body: unknown): AgentGenerateReques const background = readBackground(objectBody, fields); const moderation = readModeration(objectBody, fields); const responseMode = readResponseMode(objectBody, fields); - const imageBackend = readAgentImageBackend(objectBody, fields); - const streamMode = readAgentStreamMode(objectBody, fields); - const streamingStrategy = readAgentStreamingStrategy(objectBody, fields); - const partialImages = readPartialImagesForBackend(objectBody, imageBackend, fields, upstreamLimits.profile); + let automaticPartialImagesRange: NumericRange | undefined; + if (streamMode === 'auto' && streamingStrategy === 'auto') { + const candidates = readAgentAutoGenerateCandidates({ + imageBackend, + n, + model, + size, + background, + forceRequest: forceRequest === true + }); + validatePartialImages = candidates.nonStream.length === 0; + if (validatePartialImages && candidates.streaming.length > 0) { + automaticPartialImagesRange = unionRange( + candidates.streaming.map((candidate) => candidate.partialImagesRange), + false + ); + backendCompatibility = readAgentImageBackendCompatibility( + upstreamLimits.validationProfile, + 'generate', + imageBackend, + fields, + { validatePartialImages: true } + ); + } else if (!validatePartialImages) { + backendCompatibility = readAgentImageBackendCompatibility( + upstreamLimits.validationProfile, + 'generate', + imageBackend, + fields, + { validatePartialImages: false } + ); + } + } + const partialImages = readPartialImagesForBackend( + objectBody, + imageBackend, + fields, + upstreamLimits.validationProfile, + backendCompatibility, + { + validatePartialImages, + defaultValue: resolveAgentAutoPartialImagesDefault({ + body: objectBody, + imageBackend, + streamMode, + streamingStrategy, + n, + model, + size, + background, + forceRequest: forceRequest === true + }), + range: automaticPartialImagesRange + } + ); const responsesModel = readAgentResponsesModel(objectBody, imageBackend, fields); const thinking = readAgentThinking(objectBody, fields); const promptOptimization = readOptionalBooleanField(objectBody, 'promptOptimization', fields); const forceWeb = readOptionalBooleanField(objectBody, 'force_web', fields); + validateAgentGenerateBackground({ + model, + background, + forceRequest: forceRequest === true, + profile: upstreamLimits.validationProfile, + fields + }); validateAgentImageUpstreamStrategy({ imageBackend, streamMode, streamingStrategy, fields }); if (Object.keys(fields).length > 0) { @@ -965,17 +1295,41 @@ export function buildPageRequestDiagnosticsNoMatchNote(input: { } function readEnabledImageBackends( - requirements: Record + requirements: Record, + mode: 'request' | 'streaming' = 'request' ): ImageGenerationBackend[] { - return requirements['responses-image-generation'].enabled - ? ['images-api', 'responses-image-generation'] - : ['images-api']; + const responses = requirements['responses-image-generation']; + const responsesEnabled = + responses.enabled && + (mode !== 'streaming' || (responses.streaming_incompatible_constraints?.length ?? 0) === 0); + return responsesEnabled ? ['images-api', 'responses-image-generation'] : ['images-api']; } +type ImageBackendCapabilityChannel = { + effectiveProfile: ImageUpstreamProfile; + requestModes: readonly ChannelRequestMode[]; +}; + function buildImageBackendRequirements( - env: Record + env: Record, + profile: ImageUpstreamProfile, + profiles: ImageUpstreamProfile[] = [profile], + channels: readonly ImageBackendCapabilityChannel[] = [] ): Record { const responsesMissingEnv = readResponsesImageBackendMissingEnv(env); + const eligibleProfiles = profiles.length > 0 ? profiles : [profile]; + const incompatibleConstraints = readResponsesImageBackendIncompatibleConstraints( + profile, + false, + eligibleProfiles, + channels + ); + const streamingIncompatibleConstraints = readResponsesImageBackendIncompatibleConstraints( + profile, + true, + eligibleProfiles, + channels + ).filter((constraint) => !incompatibleConstraints.includes(constraint)); return { 'images-api': { supported: true, @@ -985,13 +1339,67 @@ function buildImageBackendRequirements( }, 'responses-image-generation': { supported: true, - enabled: responsesMissingEnv.length === 0, + enabled: responsesMissingEnv.length === 0 && incompatibleConstraints.length === 0, required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'], - missing_env: responsesMissingEnv + missing_env: responsesMissingEnv, + ...(incompatibleConstraints.length > 0 ? { incompatible_constraints: incompatibleConstraints } : {}), + ...(streamingIncompatibleConstraints.length > 0 + ? { streaming_incompatible_constraints: streamingIncompatibleConstraints } + : {}) } }; } +function readResponsesImageBackendIncompatibleConstraints( + profile: ImageUpstreamProfile, + validatePartialImages: boolean, + profiles: ImageUpstreamProfile[] = [profile], + channels: readonly ImageBackendCapabilityChannel[] = [] +): string[] { + const hasConfiguredChannels = channels.length > 0; + const requestModes = validatePartialImages + ? (['responses-sse'] as const) + : (['responses-non-stream', 'responses-sse'] as const); + const modeChannels = channels.filter((channel) => + requestModes.some((requestMode) => channel.requestModes.includes(requestMode)) + ); + if (hasConfiguredChannels && modeChannels.length === 0) { + return [validatePartialImages ? 'streaming_request_modes' : 'request_modes']; + } + const eligibleProfiles = hasConfiguredChannels + ? modeChannels.map((channel) => channel.effectiveProfile) + : profiles.length > 0 + ? profiles + : [profile]; + const incompatible: string[] = []; + const supportsResponsesGenerate = (candidate: ImageUpstreamProfile) => + getImageCountRangeCompatibilityForBackend(candidate, 'generate', 'responses-image-generation').compatible; + const supportsResponsesEdit = (candidate: ImageUpstreamProfile) => + getImageCountRangeCompatibilityForBackend(candidate, 'edit', 'responses-image-generation').compatible; + const supportsResponsesPartialImages = (candidate: ImageUpstreamProfile) => + getPartialImagesRangeCompatibilityForBackend(candidate, 'responses-image-generation').compatible; + const hasResponsesGenerate = eligibleProfiles.some((candidate) => supportsResponsesGenerate(candidate)); + const hasResponsesEdit = eligibleProfiles.some((candidate) => supportsResponsesEdit(candidate)); + if (!hasResponsesGenerate) { + incompatible.push('generate_images'); + } + if (!hasResponsesEdit) { + incompatible.push('edit_images'); + } + if (validatePartialImages && hasResponsesGenerate && hasResponsesEdit) { + const hasStreamingGenerate = eligibleProfiles.some( + (candidate) => supportsResponsesGenerate(candidate) && supportsResponsesPartialImages(candidate) + ); + const hasStreamingEdit = eligibleProfiles.some( + (candidate) => supportsResponsesEdit(candidate) && supportsResponsesPartialImages(candidate) + ); + if (!hasStreamingGenerate || !hasStreamingEdit) { + incompatible.push('partial_images'); + } + } + return incompatible; +} + function readResponsesImageBackendMissingEnv(env: Record): string[] { const missing: string[] = []; if (!readBooleanEnv(env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { @@ -1014,10 +1422,39 @@ function readPositiveIntegerEnv(env: Record, fieldNa } export function buildAgentCapabilities(env: Record): AgentCapabilities { - const imageBackendRequirements = buildImageBackendRequirements(env); - const enabledImageBackends = readEnabledImageBackends(imageBackendRequirements); const upstreamLimits = buildAgentUpstreamLimits(env); - const partialImagesByBackend = buildAgentPartialImagesByBackend(upstreamLimits.profile); + const channelSummary = getChannelPoolSummary(parseChannelPoolConfig(env)); + const normalizedGenerateImages = getImageCountRangeForBackend(upstreamLimits.profile, 'generate', 'images-api'); + const normalizedEditImages = getImageCountRangeForBackend(upstreamLimits.profile, 'edit', 'images-api'); + const imageBackendRequirements = buildImageBackendRequirements( + env, + upstreamLimits.profile, + upstreamLimits.summary.serverConstraintsByProfile, + channelSummary.channels + ); + const enabledImageBackends = readEnabledImageBackends(imageBackendRequirements); + const enabledStreamingImageBackends = readEnabledImageBackends(imageBackendRequirements, 'streaming'); + const responsesBackendEnabled = imageBackendRequirements['responses-image-generation'].enabled; + const generateImagesByBackend = buildAgentImageCountByBackend( + upstreamLimits.profile, + 'generate', + responsesBackendEnabled, + upstreamLimits.summary.serverConstraintsByProfile, + channelSummary.channels + ); + const editImagesByBackend = buildAgentImageCountByBackend( + upstreamLimits.profile, + 'edit', + responsesBackendEnabled, + upstreamLimits.summary.serverConstraintsByProfile, + channelSummary.channels + ); + const partialImagesByBackend = buildAgentPartialImagesByBackend( + upstreamLimits.profile, + responsesBackendEnabled, + upstreamLimits.summary.serverConstraintsByProfile, + channelSummary.channels + ); const defaultPartialImages = clampDefaultPartialImages(partialImagesByBackend['images-api']); return { api_version: AGENT_API_VERSION, @@ -1039,9 +1476,11 @@ export function buildAgentCapabilities(env: Record): }, limits: { max_prompt_length: MAX_PROMPT_LENGTH, - max_images: Math.min(upstreamLimits.profile.generateCount.max, upstreamLimits.profile.editCount.max), - generate_images: upstreamLimits.profile.generateCount, - edit_images: upstreamLimits.profile.editCount, + max_images: Math.min(normalizedGenerateImages.max, normalizedEditImages.max), + generate_images: normalizedGenerateImages, + edit_images: normalizedEditImages, + generate_images_by_backend: generateImagesByBackend, + edit_images_by_backend: editImagesByBackend, upload_images: { max: upstreamLimits.profile.upload.maxImages }, max_upload_mb: upstreamLimits.profile.upload.maxSingleBytes / 1024 / 1024, ...(upstreamLimits.profile.upload.maxTotalBytes !== undefined @@ -1117,7 +1556,7 @@ export function buildAgentCapabilities(env: Record): edit: AGENT_UPSTREAM_SSE_EDIT_REQUEST_FIELDS }, image_backends: AGENT_IMAGE_BACKENDS, - enabled_image_backends: enabledImageBackends, + enabled_image_backends: enabledStreamingImageBackends, streaming_strategies: AGENT_STREAMING_STRATEGIES, stream_modes: AGENT_STREAM_MODES, activation_strategies: AGENT_UPSTREAM_SSE_ACTIVATION_STRATEGIES, @@ -1329,6 +1768,9 @@ function buildAgentUpstreamRequestHeadersCapabilities( env: Record ): AgentCapabilities['upstream_request_headers'] { const channelSummary = getChannelPoolSummary(parseChannelPoolConfig(env)); + const healthyModesByChannel = readHealthyRequestModesByChannel( + channelSummary.channels.map((channel) => channel.id) + ); return { default: summarizeUpstreamRequestHeaders(undefined, env), channels: channelSummary.channels.map((channel) => ({ @@ -1336,19 +1778,96 @@ function buildAgentUpstreamRequestHeadersCapabilities( upstream_proxy: channel.upstreamProxy, request_modes: channel.requestModes, request_mode_priority: channel.requestModePriority, - request_headers: channel.requestHeaders + request_headers: channel.requestHeaders, + constraints: buildAgentChannelConstraints(channel.effectiveProfile), + ...(healthyModesByChannel?.has(channel.id) + ? { healthy_request_modes: healthyModesByChannel.get(channel.id) } + : {}) })) }; } +function buildAgentChannelConstraints(profile: ImageUpstreamProfile): AgentChannelConstraints { + const generateImagesByBackend: AgentChannelConstraints['generate_images_by_backend'] = { + 'images-api': getImageCountRangeForBackend(profile, 'generate', 'images-api') + }; + const editImagesByBackend: AgentChannelConstraints['edit_images_by_backend'] = { + 'images-api': getImageCountRangeForBackend(profile, 'edit', 'images-api') + }; + const partialImagesByBackend: AgentChannelConstraints['partial_images_by_backend'] = { + 'images-api': getPartialImagesRangeForBackend(profile, 'images-api') + }; + const responseGenerate = getImageCountRangeCompatibilityForBackend( + profile, + 'generate', + 'responses-image-generation' + ); + const responseEdit = getImageCountRangeCompatibilityForBackend(profile, 'edit', 'responses-image-generation'); + const responsePartial = getPartialImagesRangeCompatibilityForBackend(profile, 'responses-image-generation'); + if (responseGenerate.compatible) generateImagesByBackend['responses-image-generation'] = responseGenerate.range; + if (responseEdit.compatible) editImagesByBackend['responses-image-generation'] = responseEdit.range; + if (responsePartial.compatible) partialImagesByBackend['responses-image-generation'] = responsePartial.range; + return { + generate_images: getImageCountRangeForBackend(profile, 'generate', 'images-api'), + edit_images: getImageCountRangeForBackend(profile, 'edit', 'images-api'), + partial_images: getPartialImagesRangeForBackend(profile, 'images-api'), + generate_images_by_backend: generateImagesByBackend, + edit_images_by_backend: editImagesByBackend, + partial_images_by_backend: partialImagesByBackend, + upload_images: { + max: profile.upload.maxImages, + max_single_mb: profile.upload.maxSingleBytes / 1024 / 1024, + ...(profile.upload.maxTotalBytes === undefined + ? {} + : { max_total_mb: profile.upload.maxTotalBytes / 1024 / 1024 }) + }, + gpt_image_2: { + allow_transparent_background: profile.gptImage2.allowTransparentBackground, + size_policy: profile.gptImage2.sizePolicy + } + }; +} + +function readHealthyRequestModesByChannel( + channelIds: string[] +): Map | undefined { + const state = getExistingServerChannelState(); + if (!state) return undefined; + const router = state.router; + if (!router) return undefined; + const configuredIds = Array.from( + new Set(state.config.credentials.map((credential) => credential.channelId)) + ).sort(); + const expectedIds = Array.from(new Set(channelIds)).sort(); + if (configuredIds.length !== expectedIds.length || configuredIds.some((id, index) => id !== expectedIds[index])) { + return undefined; + } + const healthy = new Map(); + for (const channel of router.getHealthSnapshot().channels) { + const modes = Array.from( + new Set( + channel.credentials.flatMap((credential) => + credential.requestModes + .filter((requestMode) => requestMode.state === 'healthy') + .map((requestMode) => requestMode.mode) + ) + ) + ); + healthy.set(channel.channelId, modes); + } + return healthy; +} + function buildAgentUpstreamLimits(env: Record): { profile: ImageUpstreamProfile; + validationProfile: ImageUpstreamProfile; summary: ImageUpstreamProfileSummary; } { const serverProfiles = readServerProfiles(env); const summary = summarizeImageUpstreamProfile({ serverProfiles }); return { profile: summary.activeConstraints, + validationProfile: buildAgentRequestValidationProfile(summary), summary }; } @@ -1357,15 +1876,123 @@ function readServerProfiles(env: Record): ImageUpstr return getChannelPoolSummary(parseChannelPoolConfig(env)).channels.map((channel) => channel.effectiveProfile); } +function buildAgentRequestValidationProfile(summary: ImageUpstreamProfileSummary): ImageUpstreamProfile { + const profiles = summary.serverConstraintsByProfile; + if (profiles.length <= 1 || (!summary.serverProfileMixed && !summary.serverConstraintsMixed)) { + return summary.activeConstraints; + } + return { + ...summary.activeConstraints, + generateCount: unionRange( + profiles.map((profile) => profile.generateCount), + true + ), + editCount: unionRange( + profiles.map((profile) => profile.editCount), + true + ), + partialImages: unionRange( + profiles.map((profile) => profile.partialImages), + false + ), + upload: { + maxImages: Math.max(...profiles.map((profile) => profile.upload.maxImages)), + maxSingleBytes: Math.max(...profiles.map((profile) => profile.upload.maxSingleBytes)), + ...(maxDefined(profiles.map((profile) => profile.upload.maxTotalBytes)) !== undefined + ? { maxTotalBytes: maxDefined(profiles.map((profile) => profile.upload.maxTotalBytes)) } + : {}) + }, + gptImage2: { + allowTransparentBackground: profiles.some((profile) => profile.gptImage2.allowTransparentBackground), + sizePolicy: profiles.some((profile) => profile.gptImage2.sizePolicy === 'positive-integer') + ? 'positive-integer' + : 'openai-compatible' + } + }; +} + +function unionRange(ranges: NumericRange[], positive: boolean): NumericRange { + const values = ranges + .flatMap((range) => buildIntegerRangeOptions(range)) + .filter((value) => !positive || value >= 1); + return numericRangeFromValues(values); +} + +function numericRangeFromValues(values: number[]): NumericRange { + const normalized = Array.from(new Set(values.filter(Number.isInteger))).sort((left, right) => left - right); + const min = normalized[0]; + const max = normalized[normalized.length - 1]; + if (min === undefined || max === undefined) return { min: 1, max: 0 }; + return normalized.length === max - min + 1 ? { min, max } : { min, max, allowedValues: normalized }; +} + +function maxDefined(values: Array): number | undefined { + const defined = values.filter((value): value is number => value !== undefined); + return defined.length > 0 ? Math.max(...defined) : undefined; +} + function clampDefaultPartialImages(limits: ImageUpstreamProfile['partialImages']): PartialImagesCount { return clampIntegerToRange(AGENT_DEFAULT_PARTIAL_IMAGES, limits) as PartialImagesCount; } +export function getAgentNonStreamPartialImagesRange(): NumericRange { + return { min: 0, max: 4 }; +} + function buildAgentPartialImagesByBackend( - profile: ImageUpstreamProfile -): Record { + profile: ImageUpstreamProfile, + responsesBackendEnabled: boolean, + profiles: ImageUpstreamProfile[] = [profile], + channels: readonly ImageBackendCapabilityChannel[] = [] +): Record { + const responseProfiles = getResponsesCapabilityProfiles(profiles, channels); + const responsesRanges = responseProfiles.flatMap((candidate) => { + const compatibility = getPartialImagesRangeCompatibilityForBackend(candidate, 'responses-image-generation'); + return compatibility.compatible ? [compatibility.range] : []; + }); return { 'images-api': getPartialImagesRangeForBackend(profile, 'images-api'), - 'responses-image-generation': getPartialImagesRangeForBackend(profile, 'responses-image-generation') + 'responses-image-generation': + responsesBackendEnabled && responsesRanges.length > 0 + ? unionRange(responsesRanges, false) + : { ...RESPONSES_PARTIAL_IMAGES_RANGE } }; } + +function buildAgentImageCountByBackend( + profile: ImageUpstreamProfile, + operation: 'generate' | 'edit', + responsesBackendEnabled: boolean, + profiles: ImageUpstreamProfile[] = [profile], + channels: readonly ImageBackendCapabilityChannel[] = [] +): Record { + const responseProfiles = getResponsesCapabilityProfiles(profiles, channels); + const responsesRanges = responseProfiles.flatMap((candidate) => { + const compatibility = getImageCountRangeCompatibilityForBackend( + candidate, + operation, + 'responses-image-generation' + ); + return compatibility.compatible ? [compatibility.range] : []; + }); + return { + 'images-api': getImageCountRangeForBackend(profile, operation, 'images-api'), + 'responses-image-generation': + responsesBackendEnabled && responsesRanges.length > 0 + ? unionRange(responsesRanges, true) + : { ...RESPONSES_IMAGE_COUNT_RANGE } + }; +} + +function getResponsesCapabilityProfiles( + profiles: ImageUpstreamProfile[], + channels: readonly ImageBackendCapabilityChannel[] +): ImageUpstreamProfile[] { + if (channels.length === 0) return profiles.length > 0 ? profiles : []; + return channels + .filter( + (channel) => + channel.requestModes.includes('responses-non-stream') || channel.requestModes.includes('responses-sse') + ) + .map((channel) => channel.effectiveProfile); +} diff --git a/src/lib/agent-channel-health-contract.test.ts b/src/lib/agent-channel-health-contract.test.ts index 038c8f248e727c252748365348253df041cafb73..e75ba48a7da2f0db1695f2c4d0315ff1b3042a3f 100644 --- a/src/lib/agent-channel-health-contract.test.ts +++ b/src/lib/agent-channel-health-contract.test.ts @@ -46,7 +46,7 @@ describe('Agent channel health diagnostics contract', () => { document.components.schemas.AgentChannelHealthDiagnosticsResponse.properties.state_initialized.type, 'boolean' ); - assert.equal(document.components.schemas.AgentChannelHealthFailure.properties.message, undefined); + assert.equal(Object.hasOwn(document.components.schemas.AgentChannelHealthFailure.properties, 'message'), false); assert.deepEqual(document.components.schemas.AgentChannelHealthRequestMode.properties.mode.enum, [ 'images-non-stream', 'images-sse', diff --git a/src/lib/agent-channel-request-mode.ts b/src/lib/agent-channel-request-mode.ts index 45f4677ed3a201ff7f6643b9b107e8ba631e3052..1e41b0b42aa2d37b0b2df3f1284ecd2f79c343a7 100644 --- a/src/lib/agent-channel-request-mode.ts +++ b/src/lib/agent-channel-request-mode.ts @@ -60,12 +60,14 @@ export function selectAgentChannelCredential(input: { router: ReturnType['router']; headers: Headers; requestModePlan: AgentChannelRequestModePlan; + isCredentialEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean; }): AgentChannelSelection { if (input.requestModePlan.candidates.length > 1 && input.router) { try { const selection = input.router.selectWithRequestModes({ affinityKey: readAffinityKey(input.headers), - requestModes: input.requestModePlan.candidates + requestModes: input.requestModePlan.candidates, + isEligible: input.isCredentialEligible }); return { selectedCredential: selection.credential, @@ -137,6 +139,7 @@ function selectAgentChannelForMode( input: { router: ReturnType['router']; headers: Headers; + isCredentialEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean; }, requestMode: ChannelRequestMode, fallbackApplied: boolean @@ -144,7 +147,8 @@ function selectAgentChannelForMode( return { selectedCredential: input.router?.select({ affinityKey: readAffinityKey(input.headers), - requestMode + requestMode, + isEligible: input.isCredentialEligible }), requestMode, preferredRequestMode: requestMode, diff --git a/src/lib/agent-image-service.ts b/src/lib/agent-image-service.ts index b40d24b27501d6ea1a24801d37a089447e463d06..c9083e45aade8d55693dea7ae339b564b70b8722 100644 --- a/src/lib/agent-image-service.ts +++ b/src/lib/agent-image-service.ts @@ -36,6 +36,7 @@ import type { AgentErrorDiagnostics } from './api-error-response'; import { appLogger } from './app-logger'; import type { ChannelCapacityLease } from './channel-capacity-queue'; import { + getEffectiveChannelRequestModes, isStreamingChannelRequestMode, type ChannelRequestMode, type ChannelRequestModeDecision @@ -44,6 +45,7 @@ import { type ChannelCredential, type ChannelFailureReport, describeChannelFailure, + isChannelCredentialRequestModeHealthy, isChannelFailure, isChannelRequestModeFailure, isCredentialFailure, @@ -75,6 +77,10 @@ import { } from './image-service'; import { collectOpenAiImagesFromStream } from './image-stream-collector'; import { + clampIntegerToRange, + getImageBackendCompatibility, + getImageCountRangeCompatibilityForBackend, + isIntegerWithinRange, readImageUpstreamProfile, mergeUpstreamHeadersWithFixed, summarizeUpstreamRequestHeaders, @@ -292,9 +298,14 @@ export async function agentBeginResultResponse( } export function prepareAgentGenerate(request: AgentGenerateRequest, headers: Headers): AgentGeneratePreparation { - const credentialContext = createOpenAiClient(headers, resolveAgentGenerateChannelRequestModePlan(request)); + const credentialContext = createOpenAiClient( + headers, + resolveAgentGenerateChannelRequestModePlan(request), + (credential, requestMode) => isAgentGenerateCredentialEligible(request, credential, requestMode) + ); validateAgentGenerateAgainstUpstreamProfile(request, credentialContext.upstreamProfile, { - forceRequest: request.force_request === true + forceRequest: request.force_request === true, + requestMode: credentialContext.channelRequestMode }); return { credentialContext }; } @@ -319,21 +330,65 @@ export async function prepareAgentEdit(formData: FormData, headers: Headers): Pr const prompt = readRequiredText(formData, 'prompt'); const model = readModel(formData); const forceRequest = readAgentEditForceRequest(formData); + const requestedImageCount = readOptionalPositiveCount(formData, 'n'); + const requestedPartialImages = readAgentEditPartialImages(formData); + const streamMode = readAgentEditStreamMode(formData); + const streamingStrategy = readAgentEditStreamingStrategy(formData); + const requestModePlan = resolveAgentEditChannelRequestModePlan(formData); assertImageFilesPresent(formData); - const credentialContext = createOpenAiClient(headers, resolveAgentEditChannelRequestModePlan(formData)); - const n = readCount( + validateAgentEditInputAgainstConfiguredProfiles({ formData, - 'n', - 1, - credentialContext.upstreamProfile.editCount.min, - credentialContext.upstreamProfile.editCount.max + model, + forceRequest, + requestedImageCount, + requestModes: requestModePlan.candidates, + streamingStrategy + }); + validateAgentEditPartialImagesAgainstConfiguredChannels({ + formData, + model, + forceRequest, + requestedImageCount, + requestedPartialImages, + requestModes: requestModePlan.candidates, + streamMode, + streamingStrategy + }); + const credentialContext = createOpenAiClient(headers, requestModePlan, (credential, requestMode) => + isAgentEditCredentialEligible( + { + formData, + model, + forceRequest, + requestedImageCount, + requestedPartialImages, + streamMode, + streamingStrategy + }, + credential, + requestMode + ) + ); + const editCountCompatibility = getImageCountRangeCompatibilityForBackend( + credentialContext.upstreamProfile, + 'edit', + 'images-api' ); + if (!editCountCompatibility.compatible) { + throw new RequestValidationError(editCountCompatibility.error.message, 422); + } + const editCountRange = editCountCompatibility.range; + const n = readCount(formData, 'n', editCountRange.min, editCountRange.min, editCountRange.max, editCountRange); const size = readSize(formData, 'size', 'auto', model, credentialContext.upstreamProfile, { forceRequest }) as OpenAI.Images.ImageEditParams['size']; const quality = readEditQuality(formData) as OpenAI.Images.ImageEditParams['quality']; const responseMode = readAgentResponseModeFromForm(formData); - const streamRequest = readAgentEditStreamRequest(formData, credentialContext.upstreamProfile); + const streamRequest = readAgentEditStreamRequest( + formData, + credentialContext.upstreamProfile, + credentialContext.channelRequestMode + ); const imageFiles = readImageFiles(formData, credentialContext.upstreamProfile); const maskFile = readMaskFile(formData, credentialContext.upstreamProfile); await assertMaskCompatibility(maskFile, imageFiles); @@ -589,22 +644,33 @@ async function executeAgentGenerateUpstream( function validateAgentGenerateAgainstUpstreamProfile( request: AgentGenerateRequest, upstreamProfile: ImageUpstreamProfile, - options: { forceRequest?: boolean } = {} + options: { forceRequest?: boolean; requestMode?: ChannelRequestMode } = {} ): void { - if (request.n < upstreamProfile.generateCount.min || request.n > upstreamProfile.generateCount.max) { - throw new RequestValidationError( - `n 必须在 ${upstreamProfile.generateCount.min} 到 ${upstreamProfile.generateCount.max} 之间。`, - 422 - ); + const nonStreamResponses = isNonStreamResponsesRequest(request, options.requestMode); + const compatibility = getImageBackendCompatibility(upstreamProfile, 'generate', request.image_backend, undefined, { + validatePartialImages: !nonStreamResponses + }); + if (!compatibility.compatible) { + throw new RequestValidationError(compatibility.errors.map((error) => error.message).join(' '), 422); } - if ( - request.partial_images < upstreamProfile.partialImages.min || - request.partial_images > upstreamProfile.partialImages.max - ) { - throw new RequestValidationError( - `partial_images 必须在 ${upstreamProfile.partialImages.min} 到 ${upstreamProfile.partialImages.max} 之间。`, - 422 - ); + const imageCountRange = compatibility.imageCountRange; + const partialImagesRange = compatibility.partialImagesRange; + if (!imageCountRange) { + throw new RequestValidationError('当前图片后端没有可用的图片数量约束。', 422); + } + if (!isIntegerWithinRange(request.n, imageCountRange)) { + throw new RequestValidationError(`n 必须在 ${imageCountRange.min} 到 ${imageCountRange.max} 之间。`, 422); + } + if (!nonStreamResponses) { + if (!partialImagesRange) { + throw new RequestValidationError('当前图片后端没有可用的 partial_images 约束。', 422); + } + if (!isIntegerWithinRange(request.partial_images, partialImagesRange)) { + throw new RequestValidationError( + `partial_images 必须在 ${partialImagesRange.min} 到 ${partialImagesRange.max} 之间。`, + 422 + ); + } } if (options.forceRequest) return; if ( @@ -620,6 +686,278 @@ function validateAgentGenerateAgainstUpstreamProfile( readSize(formData, 'size', '1024x1024', request.model, upstreamProfile); } +function isNonStreamResponsesRequest(request: AgentGenerateRequest, requestMode?: ChannelRequestMode): boolean { + if (requestMode) return !isStreamingChannelRequestMode(requestMode); + if (request.stream_mode === 'non_stream') return true; + return ( + request.stream_mode === 'auto' && + (request.streaming_strategy === 'auto' || request.streaming_strategy === 'off') + ); +} + +function isAgentGenerateCredentialEligible( + request: AgentGenerateRequest, + credential: ChannelCredential, + requestMode?: ChannelRequestMode +): boolean { + const serverChannelState = getServerChannelState(); + if (requestMode && !isChannelCredentialRequestModeHealthy(serverChannelState.router, credential, requestMode)) { + return false; + } + if ( + requestMode && + isStreamingChannelRequestMode(requestMode) && + serverChannelState.streamingAvailability.isUnavailable({ + channelId: credential.channelId, + imageBackend: request.image_backend, + streamingStrategy: request.streaming_strategy, + operation: 'generate' + }) + ) { + return false; + } + const nonStreamResponses = isNonStreamResponsesRequest(request, requestMode); + const upstreamProfile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + const compatibility = getImageBackendCompatibility(upstreamProfile, 'generate', request.image_backend, undefined, { + validatePartialImages: !nonStreamResponses + }); + const imageCountRange = compatibility.imageCountRange; + if (!compatibility.compatible || !imageCountRange) return false; + if (!isIntegerWithinRange(request.n, imageCountRange)) return false; + if (!nonStreamResponses) { + const partialImagesRange = compatibility.partialImagesRange; + if (!partialImagesRange || !isIntegerWithinRange(request.partial_images, partialImagesRange)) { + return false; + } + } + if (request.force_request === true) return true; + if ( + request.model === 'gpt-image-2' && + request.background === 'transparent' && + !upstreamProfile.gptImage2.allowTransparentBackground + ) { + return false; + } + if (request.model !== 'gpt-image-2' || request.size === 'auto') return true; + try { + const formData = new FormData(); + formData.set('size', request.size); + readSize(formData, 'size', '1024x1024', request.model, upstreamProfile); + return true; + } catch (error) { + if (error instanceof RequestValidationError) return false; + throw error; + } +} + +function isAgentEditCredentialEligible( + input: { + formData: FormData; + model: GptImageModel; + forceRequest: boolean; + requestedImageCount?: number; + requestedPartialImages?: number; + streamMode: ImageStreamMode; + streamingStrategy: ImageStreamingStrategy; + }, + credential: ChannelCredential, + requestMode?: ChannelRequestMode +): boolean { + const serverChannelState = getServerChannelState(); + if (requestMode && !isChannelCredentialRequestModeHealthy(serverChannelState.router, credential, requestMode)) { + return false; + } + if ( + requestMode && + isStreamingChannelRequestMode(requestMode) && + serverChannelState.streamingAvailability.isUnavailable({ + channelId: credential.channelId, + imageBackend: 'images-api', + streamingStrategy: input.streamingStrategy, + operation: 'edit' + }) + ) { + return false; + } + const upstreamProfile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + const validatePartialImages = requestMode + ? isStreamingChannelRequestMode(requestMode) + : input.streamMode !== 'non_stream' && !(input.streamMode === 'auto' && input.streamingStrategy === 'off'); + const compatibility = getImageBackendCompatibility(upstreamProfile, 'edit', 'images-api', undefined, { + validatePartialImages + }); + const imageCountRange = compatibility.imageCountRange; + if (!compatibility.compatible || !imageCountRange) return false; + if (input.requestedImageCount !== undefined && !isIntegerWithinRange(input.requestedImageCount, imageCountRange)) { + return false; + } + if (validatePartialImages) { + const partialImagesRange = compatibility.partialImagesRange; + if ( + !partialImagesRange || + (input.requestedPartialImages !== undefined && + !isIntegerWithinRange(input.requestedPartialImages, partialImagesRange)) + ) { + return false; + } + } + try { + readImageFiles(input.formData, upstreamProfile); + readMaskFile(input.formData, upstreamProfile); + readSize(input.formData, 'size', 'auto', input.model, upstreamProfile, { forceRequest: input.forceRequest }); + return true; + } catch (error) { + if (error instanceof RequestValidationError) return false; + throw error; + } +} + +function validateAgentEditInputAgainstConfiguredProfiles(input: { + formData: FormData; + model: GptImageModel; + forceRequest: boolean; + requestedImageCount?: number; + requestModes: readonly ChannelRequestMode[]; + streamingStrategy: ImageStreamingStrategy; +}): void { + const serverChannelState = getServerChannelState(); + const candidates = + serverChannelState.config.credentials.length > 0 + ? serverChannelState.config.credentials.flatMap((credential) => { + const requestModes = input.requestModes.filter((requestMode) => { + if (!getEffectiveChannelRequestModes(credential).includes(requestMode)) return false; + if (!isChannelCredentialRequestModeHealthy(serverChannelState.router, credential, requestMode)) { + return false; + } + return !( + isStreamingChannelRequestMode(requestMode) && + serverChannelState.streamingAvailability.isUnavailable({ + channelId: credential.channelId, + imageBackend: 'images-api', + streamingStrategy: input.streamingStrategy, + operation: 'edit' + }) + ); + }); + if (requestModes.length === 0) { + return []; + } + const profile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + return [profile]; + }) + : [readImageUpstreamProfile()]; + if (candidates.length === 0) return; + let firstValidationError: RequestValidationError | undefined; + for (const profile of candidates) { + try { + const countCompatibility = getImageCountRangeCompatibilityForBackend(profile, 'edit', 'images-api'); + if (!countCompatibility.compatible) { + throw new RequestValidationError(countCompatibility.error.message, 422); + } + const count = input.requestedImageCount; + if (count !== undefined && !isIntegerWithinRange(count, countCompatibility.range)) { + throw new RequestValidationError( + countCompatibility.range.allowedValues + ? `n 必须是以下值之一:${countCompatibility.range.allowedValues.join(', ')}。` + : `n 必须在 ${countCompatibility.range.min} 到 ${countCompatibility.range.max} 之间。`, + 422 + ); + } + readSize(input.formData, 'size', 'auto', input.model, profile, { forceRequest: input.forceRequest }); + readImageFiles(input.formData, profile); + readMaskFile(input.formData, profile); + return; + } catch (error) { + if (!(error instanceof RequestValidationError)) throw error; + firstValidationError ??= error; + } + } + if (firstValidationError) throw firstValidationError; +} + +function validateAgentEditPartialImagesAgainstConfiguredChannels(input: { + formData: FormData; + model: GptImageModel; + forceRequest: boolean; + requestedImageCount?: number; + requestedPartialImages?: number; + requestModes: readonly ChannelRequestMode[]; + streamMode: ImageStreamMode; + streamingStrategy: ImageStreamingStrategy; +}): void { + if (input.requestedPartialImages === undefined) return; + const state = getServerChannelState(); + if (new Set(state.config.credentials.map((credential) => credential.channelId)).size <= 1) return; + + let hasEligibleNonStreamCandidate = false; + let hasEligibleStreamingCandidate = false; + let firstRangeError: RequestValidationError | undefined; + for (const credential of state.config.credentials) { + const requestModes = input.requestModes.filter((requestMode) => + getEffectiveChannelRequestModes(credential).includes(requestMode) + ); + for (const requestMode of requestModes) { + const eligibleWithoutPartialImages = isAgentEditCredentialEligible( + { + formData: input.formData, + model: input.model, + forceRequest: input.forceRequest, + requestedImageCount: input.requestedImageCount, + requestedPartialImages: undefined, + streamMode: input.streamMode, + streamingStrategy: input.streamingStrategy + }, + credential, + requestMode + ); + if (!eligibleWithoutPartialImages) continue; + if (!isStreamingChannelRequestMode(requestMode)) { + hasEligibleNonStreamCandidate = true; + continue; + } + hasEligibleStreamingCandidate = true; + const profile = + credential.providerProfile || + readImageUpstreamProfile({ + explicitProfile: credential.upstreamProfile, + channelId: credential.channelId, + baseUrl: credential.baseUrl + }); + const compatibility = getImageBackendCompatibility(profile, 'edit', 'images-api', undefined, { + validatePartialImages: true + }); + const partialImagesRange = compatibility.partialImagesRange; + if (partialImagesRange && isIntegerWithinRange(input.requestedPartialImages, partialImagesRange)) return; + if (partialImagesRange) { + const message = partialImagesRange.allowedValues + ? `partial_images 必须是以下值之一:${partialImagesRange.allowedValues.join(', ')}。` + : `partial_images 必须在 ${partialImagesRange.min} 到 ${partialImagesRange.max} 之间。`; + firstRangeError ??= new RequestValidationError(message, 422, { fields: { partial_images: message } }); + } + } + } + if (hasEligibleNonStreamCandidate || !hasEligibleStreamingCandidate) return; + if (firstRangeError) throw firstRangeError; +} + async function executeAgentResponsesGenerate( request: AgentGenerateRequest, credentialContext: CredentialContext, @@ -1068,12 +1406,20 @@ export async function hydrateAgentReplayResponse( }; } -function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelRequestModePlan): CredentialContext { - const serverChannelRouter = getServerChannelState().router; +function createOpenAiClient( + headers: Headers, + requestModePlan: AgentChannelRequestModePlan, + isCredentialEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean +): CredentialContext { + const serverChannelState = getServerChannelState(); + const serverChannelRouter = serverChannelState.router; + const hasMultipleServerChannels = + new Set(serverChannelState.config.credentials.map((credential) => credential.channelId)).size > 1; const selection = selectAgentChannelCredential({ router: serverChannelRouter, headers, - requestModePlan + requestModePlan, + isCredentialEligible: hasMultipleServerChannels ? isCredentialEligible : undefined }); const selectedCredential = selection.selectedCredential; const { @@ -1382,7 +1728,11 @@ function readAgentResponseModeFromForm(formData: FormData): AgentResponseMode { throw new RequestValidationError('response_mode 必须是 path、base64 或 both。', 422); } -function readAgentEditStreamRequest(formData: FormData, upstreamProfile: ImageUpstreamProfile): AgentEditStreamRequest { +function readAgentEditStreamRequest( + formData: FormData, + upstreamProfile: ImageUpstreamProfile, + requestMode?: ChannelRequestMode +): AgentEditStreamRequest { const streamMode = readAgentEditStreamMode(formData); const streamingStrategy = readAgentEditStreamingStrategy(formData); if (streamMode !== 'non_stream') { @@ -1392,19 +1742,62 @@ function readAgentEditStreamRequest(formData: FormData, upstreamProfile: ImageUp streamingStrategy }); } + const partialImagesRange = upstreamProfile.partialImages; + const requestedPartialImages = readAgentEditPartialImages(formData); + const shouldValidatePartialImages = requestMode + ? isStreamingChannelRequestMode(requestMode) + : streamMode !== 'non_stream'; return { streamMode, streamingStrategy, - partialImages: readCount( - formData, - 'partial_images', - 2, - upstreamProfile.partialImages.min, - upstreamProfile.partialImages.max - ) as PartialImagesCount + partialImages: shouldValidatePartialImages + ? (readCount( + formData, + 'partial_images', + clampIntegerToRange(2, partialImagesRange), + partialImagesRange.min, + partialImagesRange.max, + partialImagesRange + ) as PartialImagesCount) + : ((requestedPartialImages ?? clampIntegerToRange(2, partialImagesRange)) as PartialImagesCount) }; } +function readOptionalPositiveCount(formData: FormData, field: string): number | undefined { + const value = formData.get(field); + if (value === null) return undefined; + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + const count = Number(value); + if (!Number.isSafeInteger(count) || count < 1) { + throw new RequestValidationError(`${field} 必须是正整数。`); + } + return count; +} + +function readOptionalInteger(formData: FormData, field: string): number | undefined { + const value = formData.get(field); + if (value === null) return undefined; + if (typeof value !== 'string' || !/^\d+$/.test(value)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + const count = Number(value); + if (!Number.isSafeInteger(count)) { + throw new RequestValidationError(`${field} 必须是整数。`); + } + return count; +} + +function readAgentEditPartialImages(formData: FormData): number | undefined { + const value = readOptionalInteger(formData, 'partial_images'); + if (value === undefined) return undefined; + if (value > 4) { + throw new RequestValidationError('partial_images 必须在 0 到 4 之间。', 422); + } + return value; +} + function readAgentEditStreamMode(formData: FormData): ImageStreamMode { const value = formData.get('stream_mode'); if (value === null && formData.get('streaming_strategy') === 'off') return 'non_stream'; diff --git a/src/lib/agent-openapi.ts b/src/lib/agent-openapi.ts index 51aaf8faa4621baf1901aa0938a0219462605e4c..2f7f98ce2cab2a7b8c33c6e1de7b61073fc5784f 100644 --- a/src/lib/agent-openapi.ts +++ b/src/lib/agent-openapi.ts @@ -17,7 +17,9 @@ import { type AgentRoutingStrength, type AgentRoutingTransport, buildAgentCapabilities, - readAgentPublicBaseUrl + getAgentNonStreamPartialImagesRange, + readAgentPublicBaseUrl, + type NumericRange } from './agent-api-contracts'; import { AGENT_ENDPOINTS } from './agent-api-paths.mjs'; import { CHANNEL_REQUEST_MODES } from './channel-request-mode'; @@ -47,11 +49,105 @@ function buildAgentOpenApiSecuritySchemes(schemes: readonly AgentAuthScheme[]) { return securitySchemes; } +function buildImageBackendRangeSchema(limits: Record<(typeof AGENT_IMAGE_BACKENDS)[number], NumericRange>) { + return { + type: 'object', + required: [...AGENT_IMAGE_BACKENDS], + properties: Object.fromEntries( + AGENT_IMAGE_BACKENDS.map((backend) => [ + backend, + { + type: 'object', + required: ['min', 'max'], + properties: { + min: { type: 'integer', const: limits[backend].min }, + max: { type: 'integer', const: limits[backend].max }, + ...(limits[backend].allowedValues + ? { + allowedValues: { + type: 'array', + items: { type: 'integer' }, + const: [...limits[backend].allowedValues] + } + } + : {}) + }, + additionalProperties: false + } + ]) + ), + additionalProperties: false + }; +} + +function buildNumericRangeSchema() { + return { + type: 'object', + required: ['min', 'max'], + properties: { + min: { type: 'integer' }, + max: { type: 'integer' }, + allowedValues: { type: 'array', items: { type: 'integer' } } + }, + additionalProperties: false + }; +} + +function buildNonStreamPartialImagesCondition(hasAutomaticNonStreamChannel: boolean) { + const defaultOrAutoStreamMode = { + anyOf: [ + { not: { required: ['stream_mode'] } }, + { + properties: { stream_mode: { const: 'auto' } }, + required: ['stream_mode'] + } + ] + }; + const offStrategy = { + properties: { streaming_strategy: { enum: ['off'] } }, + required: ['streaming_strategy'] + }; + const autoStrategy = { + anyOf: [ + { not: { required: ['streaming_strategy'] } }, + { + properties: { streaming_strategy: { enum: ['auto'] } }, + required: ['streaming_strategy'] + } + ] + }; + return { + anyOf: [ + { + properties: { stream_mode: { const: 'non_stream' } }, + required: ['stream_mode'] + }, + { allOf: [defaultOrAutoStreamMode, offStrategy] }, + ...(hasAutomaticNonStreamChannel ? [{ allOf: [defaultOrAutoStreamMode, autoStrategy] }] : []) + ] + }; +} + export function buildAgentOpenApiDocument(env: Record) { const capabilities = buildAgentCapabilities(env); const maxGenerateImageCount = capabilities.limits.generate_images.max; const maxEditImageCount = capabilities.limits.edit_images.max; const maxSourceImageCount = capabilities.limits.upload_images.max; + const nonStreamPartialImagesSchemaRange = getAgentNonStreamPartialImagesRange(); + const configuredChannels = capabilities.upstream_request_headers.channels; + const assumeLegacyNonStreamChannel = configuredChannels.length === 0; + const hasImagesAutomaticNonStreamChannel = + assumeLegacyNonStreamChannel || + configuredChannels.some((channel) => channel.request_modes.includes('images-non-stream')); + const hasResponsesAutomaticNonStreamChannel = + assumeLegacyNonStreamChannel || + configuredChannels.some((channel) => channel.request_modes.includes('responses-non-stream')); + const responsesNonStreamPartialImagesCondition = buildNonStreamPartialImagesCondition( + hasResponsesAutomaticNonStreamChannel + ); + const imagesNonStreamPartialImagesCondition = buildNonStreamPartialImagesCondition( + hasImagesAutomaticNonStreamChannel + ); const supportedBackgrounds = capabilities.upstream_profile.activeConstraints.gptImage2.allowTransparentBackground ? AGENT_BACKGROUNDS : AGENT_BACKGROUNDS.filter((value) => value !== 'transparent'); @@ -74,7 +170,7 @@ export function buildAgentOpenApiDocument(env: Record [backend, buildNumericRangeSchema()]) + ), + additionalProperties: false + }, + edit_images_by_backend: { + type: 'object', + properties: Object.fromEntries( + AGENT_IMAGE_BACKENDS.map((backend) => [backend, buildNumericRangeSchema()]) + ), + additionalProperties: false + }, + partial_images_by_backend: { + type: 'object', + properties: Object.fromEntries( + AGENT_IMAGE_BACKENDS.map((backend) => [backend, buildNumericRangeSchema()]) + ), + additionalProperties: false + }, + upload_images: { + type: 'object', + required: ['max', 'max_single_mb'], + properties: { + max: { type: 'integer', minimum: 1 }, + max_single_mb: { type: 'number', minimum: 0 }, + max_total_mb: { type: 'number', minimum: 0 } + }, + additionalProperties: false + }, + gpt_image_2: { + type: 'object', + required: ['allow_transparent_background', 'size_policy'], + properties: { + allow_transparent_background: { type: 'boolean' }, + size_policy: { type: 'string', enum: ['openai-compatible', 'positive-integer'] } + }, + additionalProperties: false + } + }, + additionalProperties: false + }, ImageBackendRequirement: { type: 'object', required: ['supported', 'enabled', 'required_env', 'missing_env'], @@ -913,8 +1158,11 @@ export function buildAgentOpenApiDocument(env: Record ({ required: [`image_${index}`] })), + allOf: [ + { + if: { not: imagesNonStreamPartialImagesCondition }, + then: { + properties: { + partial_images: { + type: 'integer', + minimum: capabilities.limits.partial_images.min, + maximum: capabilities.limits.partial_images.max, + ...(capabilities.limits.partial_images.allowedValues + ? { enum: [...capabilities.limits.partial_images.allowedValues] } + : {}), + default: capabilities.defaults.partial_images + } + } + } + } + ], description: `Agent edit 返回最终 JSON。请求必须至少提供一个 image_0..image_${maxSourceImageCount - 1} 源图字段。高分辨率 edit 默认优先使用页面端 /api/images form-data SSE;页面流式有问题时可显式回退到 Agent edit 诊断或执行。`, properties: { prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH }, @@ -1545,7 +1901,10 @@ export function buildAgentOpenApiDocument(env: Record { selected_channel_id: 'default', upstream_host: 'example.test', request_headers: { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false, allowed_header_names: ['user-agent'], configured_header_names: [] diff --git a/src/lib/agent-state-sqlite.test.ts b/src/lib/agent-state-sqlite.test.ts index 08cc43165655fab02f51026ac84b90b7c3757e30..18207333bbea26defae3e47e32f8c26f6309e054 100644 --- a/src/lib/agent-state-sqlite.test.ts +++ b/src/lib/agent-state-sqlite.test.ts @@ -533,7 +533,11 @@ describe('SqliteAgentStateStore', () => { const requestJson = { prompt: 'purge directory artifact' }; const requestHash = hashAgentPayload(requestJson); const artifactBaseName = `purge-directory-artifact-${crypto.randomUUID()}`; - const artifactParentPath = path.join(process.cwd(), 'generated-images', '.sqlite-purge-dir-test'); + const artifactParentPath = path.join( + process.cwd(), + 'generated-images', + `.sqlite-purge-dir-test-${crypto.randomUUID()}` + ); const artifactPath = path.join(artifactParentPath, artifactBaseName); await mkdir(artifactPath, { recursive: true }); const begin = await store.beginRequest({ @@ -666,7 +670,7 @@ describe('SqliteAgentStateStore', () => { const artifactPath = path.join( process.cwd(), 'generated-images', - '.sqlite-purge-test', + `.sqlite-purge-test-${crypto.randomUUID()}`, `${crypto.randomUUID()}.png` ); await mkdir(path.dirname(artifactPath), { recursive: true }); diff --git a/src/lib/batch-prompts.test.ts b/src/lib/batch-prompts.test.ts index a9c078cd5bfe3ff884f44f30fb5ba1e4777b86be..065677e159dd9bc231ffdee9cd0570bedc3ffd11 100644 --- a/src/lib/batch-prompts.test.ts +++ b/src/lib/batch-prompts.test.ts @@ -1,4 +1,4 @@ -import { formatBatchPromptHistory, readBatchPromptLines } from './batch-prompts'; +import { findBatchPromptOverLimitIndex, formatBatchPromptHistory, readBatchPromptLines } from './batch-prompts'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -14,6 +14,12 @@ describe('readBatchPromptLines', () => { it('does not invent prompts from empty lines', () => { assert.deepEqual(readBatchPromptLines('\n \r\n'), []); }); + + it('identifies the first prompt that exceeds the configured length', () => { + assert.equal(findBatchPromptOverLimitIndex(['short', 'x'.repeat(11), 'long'], 10), 1); + assert.equal(findBatchPromptOverLimitIndex(['short', 'exactly-ten'], 10), 1); + assert.equal(findBatchPromptOverLimitIndex(['short', 'exactly-ten'], 11), null); + }); }); describe('formatBatchPromptHistory', () => { diff --git a/src/lib/batch-prompts.ts b/src/lib/batch-prompts.ts index 8b403b7d324e2c6db7baf53c763e1827e812c0cc..e09adddcec32773f5d11bf2af193f4f7c271b5e9 100644 --- a/src/lib/batch-prompts.ts +++ b/src/lib/batch-prompts.ts @@ -5,6 +5,11 @@ export function readBatchPromptLines(value: string): string[] { .filter((line) => line.length > 0); } +export function findBatchPromptOverLimitIndex(prompts: readonly string[], maxLength: number): number | null { + const index = prompts.findIndex((prompt) => prompt.length > maxLength); + return index === -1 ? null : index; +} + export function formatBatchPromptHistory(prompts: string[]): string { return prompts.join('\n'); } diff --git a/src/lib/channel-router.test.ts b/src/lib/channel-router.test.ts index 9af54e444da63556983bfa216b830637142b75e8..8129e7e8594679566ed9d9a7fa32b41ba124ce83 100644 --- a/src/lib/channel-router.test.ts +++ b/src/lib/channel-router.test.ts @@ -3,6 +3,7 @@ import { createChannelRouter, describeChannelFailure, getChannelPoolSummary, + isChannelCredentialRequestModeHealthy, isChannelFailure, isChannelRequestModeFailure, isCredentialFailure, @@ -16,7 +17,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; const DEFAULT_HEADER_SUMMARY = { - user_agent_effective: 'visual-journal/2.2.0', + user_agent_effective: 'visual-journal/2.3.0', has_extra_headers: false, allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'], configured_header_names: [] @@ -271,6 +272,25 @@ describe('parseChannelPoolConfig', () => { }); }); +describe('request mode health helpers', () => { + it('tracks request-mode cooldown independently', () => { + const config = parseChannelPoolConfig({ + OPENAI_CHANNEL_1_ID: 'mixed', + OPENAI_CHANNEL_1_API_KEYS: 'configured', + OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse' + }); + const router = createChannelRouter({ ...config, now: () => 1000, failureCooldownMs: 100 }); + const credential = config.credentials[0]; + assert.ok(credential); + + assert.equal(isChannelCredentialRequestModeHealthy(router, credential, 'images-non-stream'), true); + assert.equal(isChannelCredentialRequestModeHealthy(router, credential, 'images-sse'), true); + router.reportFailure(credential, { scope: 'channel', requestMode: 'images-non-stream' }); + assert.equal(isChannelCredentialRequestModeHealthy(router, credential, 'images-non-stream'), false); + assert.equal(isChannelCredentialRequestModeHealthy(router, credential, 'images-sse'), true); + }); +}); + describe('getChannelPoolSummary', () => { it('returns sanitized capacity metadata without exposing API keys', () => { const config = parseChannelPoolConfig({ @@ -353,8 +373,8 @@ describe('getChannelPoolSummary', () => { it('supports safe per-channel User-Agent overrides without exposing header values', () => { const config = parseChannelPoolConfig({ - OPENAI_CHANNEL_1_ID: 'jisuanyun', - OPENAI_CHANNEL_1_BASE_URL: 'https://xkj.jisuanyun.vip/v1', + OPENAI_CHANNEL_1_ID: 'example-provider', + OPENAI_CHANNEL_1_BASE_URL: 'https://provider.example.invalid/v1', OPENAI_CHANNEL_1_API_KEYS: 'sk-one', OPENAI_CHANNEL_1_USER_AGENT: 'visual-journal/customer-node' }); diff --git a/src/lib/channel-router.ts b/src/lib/channel-router.ts index c72591afdaf2821fad8e40e85e73e918260004b6..b84634728f22168a12981038f5528b34263c491f 100644 --- a/src/lib/channel-router.ts +++ b/src/lib/channel-router.ts @@ -80,10 +80,15 @@ export type ChannelPoolSummary = { }; export type ChannelRouter = { - select(options?: { affinityKey?: string; requestMode?: ChannelRequestMode }): ChannelCredential; + select(options?: { + affinityKey?: string; + requestMode?: ChannelRequestMode; + isEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean; + }): ChannelCredential; selectWithRequestModes(options: { affinityKey?: string; requestModes: readonly ChannelRequestMode[]; + isEligible?: (credential: ChannelCredential, requestMode: ChannelRequestMode) => boolean; }): ChannelRequestModeSelection; reportFailure(credential: ChannelCredential, options?: ChannelFailureReportOptions): ChannelFailureReport; getRecoveryProbeCandidates(): ChannelRecoveryProbeCandidate[]; @@ -94,6 +99,26 @@ export type ChannelRouter = { getRequestModeHealthSummary(): ChannelRequestModeHealthSummary; }; +export function isChannelCredentialRequestModeHealthy( + router: Pick | undefined, + credential: Pick, + requestMode: ChannelRequestMode +): boolean { + if (!router) return true; + const channel = router + .getHealthSnapshot() + .channels.find((candidateChannel) => candidateChannel.channelId === credential.channelId); + const credentialHealth = channel?.credentials.find( + (candidateCredential) => candidateCredential.credentialId === credential.id + ); + return ( + credentialHealth?.requestModes.some( + (candidateRequestMode) => + candidateRequestMode.mode === requestMode && candidateRequestMode.state === 'healthy' + ) === true + ); +} + export type ChannelRequestModeSelection = { credential: ChannelCredential; requestMode: ChannelRequestMode; @@ -423,9 +448,18 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute if (options.requireProbeForRecovery) probeRequiredCredentialIds.add(credential.id); return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs }; }; - const selectCredential = (selectOptions: { affinityKey?: string; requestMode?: ChannelRequestMode } = {}) => { + const selectCredential = ( + selectOptions: { + affinityKey?: string; + requestMode?: ChannelRequestMode; + isEligible?: (credential: ChannelCredential, requestMode?: ChannelRequestMode) => boolean; + } = {} + ) => { const requestMode = selectOptions.requestMode; - const candidates = options.credentials.filter((credential) => isHealthyForRequestMode(credential, requestMode)); + const isEligible = selectOptions.isEligible ?? (() => true); + const candidates = options.credentials.filter( + (credential) => isEligible(credential, requestMode) && isHealthyForRequestMode(credential, requestMode) + ); if (candidates.length === 0) { throw new RequestValidationError( requestMode @@ -439,7 +473,7 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute const credential = selectRoundRobinHealthy( options.credentials, nextIndex, - (candidate) => isHealthyForRequestMode(candidate, requestMode), + (candidate) => isEligible(candidate, requestMode) && isHealthyForRequestMode(candidate, requestMode), requestMode ); nextIndex = credential.nextIndex; @@ -455,7 +489,7 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute const startIndex = stableHash(affinityKey) % options.credentials.length; for (let offset = 0; offset < options.credentials.length; offset += 1) { const credential = options.credentials[(startIndex + offset) % options.credentials.length]; - if (isHealthyForRequestMode(credential, requestMode)) { + if (isEligible(credential, requestMode) && isHealthyForRequestMode(credential, requestMode)) { return credential; } } @@ -493,13 +527,17 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute throw new RequestValidationError('至少需要一个候选请求方式。', 500); } const affinityKey = selectOptions.affinityKey || 'default'; + const isEligible = selectOptions.isEligible ?? (() => true); const configuredCandidates = options.credentials.filter((credential) => - requestModes.some((mode) => channelSupportsRequestMode(credential, mode)) + requestModes.some( + (mode) => isEligible(credential, mode) && channelSupportsRequestMode(credential, mode) + ) ); const rankedConfiguredCandidates = getRankedRequestModeCandidates( configuredCandidates, requestModes, - options.requestModePriority + options.requestModePriority, + isEligible ); const preferredRequestMode = rankedConfiguredCandidates[0]?.requestMode; const rankedHealthyCandidates = rankedConfiguredCandidates.filter((candidate) => @@ -848,7 +886,8 @@ function readCredentialRequestModeKey(value: string): function getRankedRequestModeCandidates( credentials: readonly ChannelCredential[], requestModes: readonly ChannelRequestMode[], - poolRequestModePriority?: readonly ChannelRequestMode[] + poolRequestModePriority?: readonly ChannelRequestMode[], + isEligible: (credential: ChannelCredential, requestMode: ChannelRequestMode) => boolean = () => true ): RankedRequestModeCandidate[] { const configuredRequestModes = credentials.flatMap((credential) => getEffectiveChannelRequestModes(credential)); const poolPriority = orderChannelRequestModesByPriority({ @@ -874,14 +913,16 @@ function getRankedRequestModeCandidates( : poolRequestModePriority || credential.requestModePriority ? 1 : 2; - return requestModePriority.map((requestMode) => ({ - credential, - requestMode, - requestModePriority: rankPriority, - modeRank: rankPriority.indexOf(requestMode), - sourceRank, - credentialIndex - })); + return requestModePriority + .filter((requestMode) => isEligible(credential, requestMode)) + .map((requestMode) => ({ + credential, + requestMode, + requestModePriority: rankPriority, + modeRank: rankPriority.indexOf(requestMode), + sourceRank, + credentialIndex + })); }); return rankedCandidates diff --git a/src/lib/cost-utils.test.ts b/src/lib/cost-utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..703703027adb9783e163113b08a3aafa37f1f1fd --- /dev/null +++ b/src/lib/cost-utils.test.ts @@ -0,0 +1,95 @@ +import { + calculateApiCost, + getModelRates, + GPT_IMAGE_MODELS, + isGptImageModel, + isNonNegativeFiniteNumber, + isNonNegativeSafeInteger, + isValidCostDetails +} from './cost-utils'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('cost-utils', () => { + it('uses the shared model-rate table for every supported image model', () => { + for (const model of GPT_IMAGE_MODELS) { + const rates = getModelRates(model); + + assert.equal(isGptImageModel(model), true); + assert.ok(rates.textInputPerToken > 0); + assert.ok(rates.imageInputPerToken > 0); + assert.ok(rates.imageOutputPerToken > 0); + } + assert.equal(isGptImageModel('unknown-model'), false); + }); + + it('accepts only finite nonnegative usage and cost values', () => { + assert.equal(isNonNegativeFiniteNumber(0), true); + assert.equal(isNonNegativeFiniteNumber(1.25), true); + assert.equal(isNonNegativeFiniteNumber(-1), false); + assert.equal(isNonNegativeFiniteNumber(Number.NaN), false); + assert.equal(isNonNegativeFiniteNumber(Number.POSITIVE_INFINITY), false); + assert.equal(isNonNegativeSafeInteger(0), true); + assert.equal(isNonNegativeSafeInteger(1.25), false); + assert.equal(isNonNegativeSafeInteger(Number.MAX_SAFE_INTEGER), true); + assert.equal(isNonNegativeSafeInteger(Number.MAX_SAFE_INTEGER + 1), false); + + assert.equal( + isValidCostDetails({ + estimated_cost_usd: 0.03, + text_input_tokens: 100, + image_input_tokens: 0, + image_output_tokens: 500 + }), + true + ); + assert.equal( + isValidCostDetails({ + estimated_cost_usd: Number.NaN, + text_input_tokens: 100, + image_input_tokens: 0, + image_output_tokens: 500 + }), + false + ); + }); + + it('calculates a rounded local estimate with the selected model rate', () => { + assert.deepEqual( + calculateApiCost( + { + input_tokens_details: { text_tokens: 100, image_tokens: 200 }, + output_tokens: 1000 + }, + 'gpt-image-2' + ), + { + estimated_cost_usd: 0.0321, + text_input_tokens: 100, + image_input_tokens: 200, + image_output_tokens: 1000 + } + ); + }); + + it('rejects malformed or overflowed usage instead of returning a misleading estimate', () => { + const originalConsoleError = console.error; + console.error = () => {}; + try { + for (const outputTokens of [Number.NaN, Number.POSITIVE_INFINITY, -1, Number.MAX_VALUE]) { + assert.equal( + calculateApiCost( + { + input_tokens_details: { text_tokens: 100, image_tokens: 0 }, + output_tokens: outputTokens + }, + 'gpt-image-2' + ), + null + ); + } + } finally { + console.error = originalConsoleError; + } + }); +}); diff --git a/src/lib/cost-utils.ts b/src/lib/cost-utils.ts index 11e7ce5fab0d69cd6a3a4b7367cefd9fed2f5b82..d4c0bb918f18f81412d147afe5333e5ff2241dbb 100755 --- a/src/lib/cost-utils.ts +++ b/src/lib/cost-utils.ts @@ -13,6 +13,10 @@ export type CostDetails = { image_output_tokens: number; }; +export const GPT_IMAGE_MODELS = ['gpt-image-2', 'gpt-image-1.5', 'gpt-image-1', 'gpt-image-1-mini'] as const; + +export type GptImageModel = (typeof GPT_IMAGE_MODELS)[number]; + // gpt-image-1 价格。 const GPT_IMAGE_1_TEXT_INPUT_COST_PER_TOKEN = 0.000005; // $5.00/1M const GPT_IMAGE_1_IMAGE_INPUT_COST_PER_TOKEN = 0.00001; // $10.00/1M @@ -33,8 +37,6 @@ const GPT_IMAGE_2_TEXT_INPUT_COST_PER_TOKEN = 0.000005; // $5.00/1M const GPT_IMAGE_2_IMAGE_INPUT_COST_PER_TOKEN = 0.000008; // $8.00/1M const GPT_IMAGE_2_IMAGE_OUTPUT_COST_PER_TOKEN = 0.00003; // $30.00/1M -export type GptImageModel = 'gpt-image-1' | 'gpt-image-1-mini' | 'gpt-image-1.5' | 'gpt-image-2'; - export type ModelRates = { textInputPerToken: number; imageInputPerToken: number; @@ -44,45 +46,66 @@ export type ModelRates = { imageOutputPerMillion: number; }; -export function getModelRates(model: GptImageModel): ModelRates { - if (model === 'gpt-image-1-mini') { - return { - textInputPerToken: GPT_IMAGE_1_MINI_TEXT_INPUT_COST_PER_TOKEN, - imageInputPerToken: GPT_IMAGE_1_MINI_IMAGE_INPUT_COST_PER_TOKEN, - imageOutputPerToken: GPT_IMAGE_1_MINI_IMAGE_OUTPUT_COST_PER_TOKEN, - textInputPerMillion: 2, - imageInputPerMillion: 2.5, - imageOutputPerMillion: 8 - }; - } - if (model === 'gpt-image-1.5') { - return { - textInputPerToken: GPT_IMAGE_1_5_TEXT_INPUT_COST_PER_TOKEN, - imageInputPerToken: GPT_IMAGE_1_5_IMAGE_INPUT_COST_PER_TOKEN, - imageOutputPerToken: GPT_IMAGE_1_5_IMAGE_OUTPUT_COST_PER_TOKEN, - textInputPerMillion: 5, - imageInputPerMillion: 8, - imageOutputPerMillion: 32 - }; - } - if (model === 'gpt-image-2') { - return { - textInputPerToken: GPT_IMAGE_2_TEXT_INPUT_COST_PER_TOKEN, - imageInputPerToken: GPT_IMAGE_2_IMAGE_INPUT_COST_PER_TOKEN, - imageOutputPerToken: GPT_IMAGE_2_IMAGE_OUTPUT_COST_PER_TOKEN, - textInputPerMillion: 5, - imageInputPerMillion: 8, - imageOutputPerMillion: 30 - }; - } - return { +const MODEL_RATES: Record = { + 'gpt-image-2': { + textInputPerToken: GPT_IMAGE_2_TEXT_INPUT_COST_PER_TOKEN, + imageInputPerToken: GPT_IMAGE_2_IMAGE_INPUT_COST_PER_TOKEN, + imageOutputPerToken: GPT_IMAGE_2_IMAGE_OUTPUT_COST_PER_TOKEN, + textInputPerMillion: 5, + imageInputPerMillion: 8, + imageOutputPerMillion: 30 + }, + 'gpt-image-1.5': { + textInputPerToken: GPT_IMAGE_1_5_TEXT_INPUT_COST_PER_TOKEN, + imageInputPerToken: GPT_IMAGE_1_5_IMAGE_INPUT_COST_PER_TOKEN, + imageOutputPerToken: GPT_IMAGE_1_5_IMAGE_OUTPUT_COST_PER_TOKEN, + textInputPerMillion: 5, + imageInputPerMillion: 8, + imageOutputPerMillion: 32 + }, + 'gpt-image-1': { textInputPerToken: GPT_IMAGE_1_TEXT_INPUT_COST_PER_TOKEN, imageInputPerToken: GPT_IMAGE_1_IMAGE_INPUT_COST_PER_TOKEN, imageOutputPerToken: GPT_IMAGE_1_IMAGE_OUTPUT_COST_PER_TOKEN, textInputPerMillion: 5, imageInputPerMillion: 10, imageOutputPerMillion: 40 - }; + }, + 'gpt-image-1-mini': { + textInputPerToken: GPT_IMAGE_1_MINI_TEXT_INPUT_COST_PER_TOKEN, + imageInputPerToken: GPT_IMAGE_1_MINI_IMAGE_INPUT_COST_PER_TOKEN, + imageOutputPerToken: GPT_IMAGE_1_MINI_IMAGE_OUTPUT_COST_PER_TOKEN, + textInputPerMillion: 2, + imageInputPerMillion: 2.5, + imageOutputPerMillion: 8 + } +}; + +export function isGptImageModel(value: unknown): value is GptImageModel { + return typeof value === 'string' && (GPT_IMAGE_MODELS as readonly string[]).includes(value); +} + +export function getModelRates(model: GptImageModel): ModelRates { + return MODEL_RATES[model]; +} + +export function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +} + +export function isValidCostDetails(value: unknown): value is CostDetails { + if (!value || typeof value !== 'object') return false; + const details = value as Partial; + return ( + isNonNegativeFiniteNumber(details.estimated_cost_usd) && + isNonNegativeSafeInteger(details.text_input_tokens) && + isNonNegativeSafeInteger(details.image_input_tokens) && + isNonNegativeSafeInteger(details.image_output_tokens) + ); } /** @@ -104,41 +127,30 @@ export function calculateApiCost( const imgInT = usage.input_tokens_details.image_tokens ?? 0; const imgOutT = usage.output_tokens ?? 0; - // 校验 token 类型。 - if (typeof textInT !== 'number' || typeof imgInT !== 'number' || typeof imgOutT !== 'number') { - console.error('usage 数据中的 token 类型无效:', usage); + // Token 必须是可精确表达的非负整数,避免伪造或溢出用量污染本地估算。 + if (!isNonNegativeSafeInteger(textInT) || !isNonNegativeSafeInteger(imgInT) || !isNonNegativeSafeInteger(imgOutT)) { + console.error('usage 数据中的 token 值无效:', usage); return null; } - // 按模型选择价格。 - let textInputCost: number; - let imageInputCost: number; - let imageOutputCost: number; - - if (model === 'gpt-image-1-mini') { - textInputCost = GPT_IMAGE_1_MINI_TEXT_INPUT_COST_PER_TOKEN; - imageInputCost = GPT_IMAGE_1_MINI_IMAGE_INPUT_COST_PER_TOKEN; - imageOutputCost = GPT_IMAGE_1_MINI_IMAGE_OUTPUT_COST_PER_TOKEN; - } else if (model === 'gpt-image-1.5') { - textInputCost = GPT_IMAGE_1_5_TEXT_INPUT_COST_PER_TOKEN; - imageInputCost = GPT_IMAGE_1_5_IMAGE_INPUT_COST_PER_TOKEN; - imageOutputCost = GPT_IMAGE_1_5_IMAGE_OUTPUT_COST_PER_TOKEN; - } else if (model === 'gpt-image-2') { - textInputCost = GPT_IMAGE_2_TEXT_INPUT_COST_PER_TOKEN; - imageInputCost = GPT_IMAGE_2_IMAGE_INPUT_COST_PER_TOKEN; - imageOutputCost = GPT_IMAGE_2_IMAGE_OUTPUT_COST_PER_TOKEN; - } else { - // 默认按 gpt-image-1 计价。 - textInputCost = GPT_IMAGE_1_TEXT_INPUT_COST_PER_TOKEN; - imageInputCost = GPT_IMAGE_1_IMAGE_INPUT_COST_PER_TOKEN; - imageOutputCost = GPT_IMAGE_1_IMAGE_OUTPUT_COST_PER_TOKEN; - } + const rates = getModelRates(model); + + const costUSD = + textInT * rates.textInputPerToken + imgInT * rates.imageInputPerToken + imgOutT * rates.imageOutputPerToken; - const costUSD = textInT * textInputCost + imgInT * imageInputCost + imgOutT * imageOutputCost; + if (!isNonNegativeFiniteNumber(costUSD)) { + console.error('usage 数据计算出的费用无效:', usage); + return null; + } // 保留 4 位小数。 const costRounded = Math.round(costUSD * 10000) / 10000; + if (!isNonNegativeFiniteNumber(costRounded)) { + console.error('usage 数据四舍五入后的费用无效:', usage); + return null; + } + return { estimated_cost_usd: costRounded, text_input_tokens: textInT, diff --git a/src/lib/edit-source-limits.test.ts b/src/lib/edit-source-limits.test.ts index cd70eaeebe37fa0e6ae3aaae74a666d0e8bb0568..71be60fc9de14a5375e2d6ab4ed4a144bb7b6db6 100644 --- a/src/lib/edit-source-limits.test.ts +++ b/src/lib/edit-source-limits.test.ts @@ -1,8 +1,31 @@ -import { hasReachedEditSourceImageLimit } from './edit-source-limits'; +import { + formatEditSourceValidationFailure, + hasReachedEditSourceImageLimit, + isResponsesEditInputLimitActive, + validateEditSourceInput +} from './edit-source-limits'; +import { MAX_OPENAI_UPLOAD_BYTES, MAX_RESPONSES_EDIT_INPUT_BYTES } from './image-request-limits'; import { IMAGE_UPSTREAM_PROFILES } from './image-upstream-profile'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +function makeFile(input: { name?: string; size: number; type: string }): File { + return { + name: input.name ?? 'source.png', + size: input.size, + type: input.type + } as File; +} + +function validate(input: Partial[0]>) { + return validateEditSourceInput({ + imageFiles: [makeFile({ size: 1, type: 'image/png' })], + upstreamProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'], + imageBackend: 'images-api', + ...input + }); +} + describe('hasReachedEditSourceImageLimit', () => { it('uses the active upstream profile upload limit for edit-source entry points', () => { assert.equal( @@ -21,3 +44,87 @@ describe('hasReachedEditSourceImageLimit', () => { ); }); }); + +describe('validateEditSourceInput', () => { + it('rejects empty, unsupported, and oversized reference images before they are sent', () => { + assert.equal(validate({ imageFiles: [makeFile({ size: 0, type: 'image/png' })] })?.code, 'source-empty'); + assert.equal(validate({ imageFiles: [makeFile({ size: 1, type: 'image/gif' })] })?.code, 'source-invalid-type'); + assert.equal( + validate({ imageFiles: [makeFile({ size: MAX_OPENAI_UPLOAD_BYTES + 1, type: 'image/png' })] })?.code, + 'source-too-large' + ); + }); + + it('enforces profile-level reference totals independently of individual file limits', () => { + const profile = { + upload: { + ...IMAGE_UPSTREAM_PROFILES.matsca.upload, + maxImages: 9 + } + }; + const imageFiles = Array.from({ length: 9 }, () => makeFile({ size: 9 * 1024 * 1024, type: 'image/png' })); + + const failure = validate({ imageFiles, upstreamProfile: profile }); + + assert.deepEqual(failure, { + code: 'source-total-too-large', + maxBytes: IMAGE_UPSTREAM_PROFILES.matsca.upload.maxTotalBytes + }); + }); + + it('validates mask type and size with the same active profile limit', () => { + assert.equal(validate({ maskFile: makeFile({ size: 1, type: 'image/jpeg' }) })?.code, 'mask-invalid-type'); + assert.equal(validate({ maskFile: makeFile({ size: 0, type: 'image/png' }) })?.code, 'mask-empty'); + assert.equal( + validate({ maskFile: makeFile({ size: MAX_OPENAI_UPLOAD_BYTES + 1, type: 'image/png' }) })?.code, + 'mask-too-large' + ); + }); + + it('applies the Responses combined input limit to explicit and default Responses routes', () => { + const imageFiles = [ + makeFile({ name: 'source-a.png', size: MAX_OPENAI_UPLOAD_BYTES, type: 'image/png' }), + makeFile({ name: 'source-b.png', size: MAX_OPENAI_UPLOAD_BYTES, type: 'image/png' }) + ]; + const maskFile = makeFile({ name: 'mask.png', size: 1, type: 'image/png' }); + + const explicitFailure = validate({ + imageFiles, + maskFile, + imageBackend: 'responses-image-generation' + }); + const defaultFailure = validate({ + imageFiles, + maskFile, + imageBackend: 'server-default', + defaultImageBackend: 'responses-image-generation' + }); + + assert.deepEqual(explicitFailure, { + code: 'responses-input-too-large', + maxBytes: MAX_RESPONSES_EDIT_INPUT_BYTES + }); + assert.deepEqual(defaultFailure, explicitFailure); + assert.equal( + validate({ imageFiles, maskFile, imageBackend: 'images-api' }), + undefined, + 'Images API does not inherit the Responses-only combined limit' + ); + assert.equal( + isResponsesEditInputLimitActive({ + imageBackend: 'server-default', + defaultImageBackend: 'responses-image-generation' + }), + true + ); + }); + + it('returns localized validation text without embedding UI strings in the validator', () => { + const failure = validate({ imageFiles: [makeFile({ size: 0, type: 'image/png' })] }); + assert.ok(failure); + + const message = formatEditSourceValidationFailure(failure, (key) => key); + + assert.equal(message, 'alert.editReferenceEmpty'); + }); +}); diff --git a/src/lib/edit-source-limits.ts b/src/lib/edit-source-limits.ts index fbd1421ac3a1a06147359fe566edc26f91cc14e5..89cff84b530002039a361e488a864a8c26522d06 100644 --- a/src/lib/edit-source-limits.ts +++ b/src/lib/edit-source-limits.ts @@ -1,3 +1,115 @@ +import { MAX_RESPONSES_EDIT_INPUT_BYTES } from './image-request-limits'; +import { resolveImageBackendSelection, type ImageUpstreamProfile } from './image-upstream-profile'; +import type { ImageGenerationBackend } from './image-upstream-strategy'; + +const SUPPORTED_EDIT_SOURCE_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; + +type Translate = (key: string, values?: Record) => string; +type EditUploadFile = Pick; + +export type EditSourceValidationFailure = + | { code: 'too-many-images'; maxImages: number } + | { code: 'source-empty' } + | { code: 'source-invalid-type' } + | { code: 'source-too-large'; maxBytes: number } + | { code: 'source-total-too-large'; maxBytes: number } + | { code: 'mask-empty' } + | { code: 'mask-invalid-type' } + | { code: 'mask-too-large'; maxBytes: number } + | { code: 'responses-input-too-large'; maxBytes: number }; + export function hasReachedEditSourceImageLimit(input: { currentCount: number; maxImages: number }): boolean { return input.currentCount >= input.maxImages; } + +export function validateEditSourceInput(input: { + imageFiles: readonly EditUploadFile[]; + maskFile?: EditUploadFile | null; + upstreamProfile: Pick; + imageBackend: ImageGenerationBackend | 'server-default'; + defaultImageBackend?: ImageGenerationBackend | null; +}): EditSourceValidationFailure | undefined { + if (input.imageFiles.length > input.upstreamProfile.upload.maxImages) { + return { code: 'too-many-images', maxImages: input.upstreamProfile.upload.maxImages }; + } + + for (const file of input.imageFiles) { + if (file.size <= 0) return { code: 'source-empty' }; + if (!isSupportedEditSourceImageType(file.type)) return { code: 'source-invalid-type' }; + if (file.size > input.upstreamProfile.upload.maxSingleBytes) { + return { code: 'source-too-large', maxBytes: input.upstreamProfile.upload.maxSingleBytes }; + } + } + + const sourceTotalBytes = sumFileBytes(input.imageFiles); + if ( + input.upstreamProfile.upload.maxTotalBytes !== undefined && + sourceTotalBytes > input.upstreamProfile.upload.maxTotalBytes + ) { + return { code: 'source-total-too-large', maxBytes: input.upstreamProfile.upload.maxTotalBytes }; + } + + if (input.maskFile) { + if (input.maskFile.size <= 0) return { code: 'mask-empty' }; + if (input.maskFile.type !== 'image/png') return { code: 'mask-invalid-type' }; + if (input.maskFile.size > input.upstreamProfile.upload.maxSingleBytes) { + return { code: 'mask-too-large', maxBytes: input.upstreamProfile.upload.maxSingleBytes }; + } + } + + const effectiveBackend = resolveImageBackendSelection(input.imageBackend, input.defaultImageBackend); + if ( + effectiveBackend === 'responses-image-generation' && + sourceTotalBytes + (input.maskFile?.size ?? 0) > MAX_RESPONSES_EDIT_INPUT_BYTES + ) { + return { code: 'responses-input-too-large', maxBytes: MAX_RESPONSES_EDIT_INPUT_BYTES }; + } + + return undefined; +} + +export function isResponsesEditInputLimitActive(input: { + imageBackend: ImageGenerationBackend | 'server-default'; + defaultImageBackend?: ImageGenerationBackend | null; +}): boolean { + return resolveImageBackendSelection(input.imageBackend, input.defaultImageBackend) === 'responses-image-generation'; +} + +export function formatEditUploadLimit(bytes: number): string { + return `${bytes / 1024 / 1024} MB`; +} + +export function getResponsesEditInputLimitLabel(): string { + return formatEditUploadLimit(MAX_RESPONSES_EDIT_INPUT_BYTES); +} + +export function formatEditSourceValidationFailure(failure: EditSourceValidationFailure, t: Translate): string { + switch (failure.code) { + case 'too-many-images': + return t('alert.maxImages', { count: failure.maxImages }); + case 'source-empty': + return t('alert.editReferenceEmpty'); + case 'source-invalid-type': + return t('alert.editReferenceInvalidType'); + case 'source-too-large': + return t('alert.editReferenceTooLarge', { limit: formatEditUploadLimit(failure.maxBytes) }); + case 'source-total-too-large': + return t('alert.editReferenceTotalTooLarge', { limit: formatEditUploadLimit(failure.maxBytes) }); + case 'mask-empty': + return t('alert.maskEmpty'); + case 'mask-invalid-type': + return t('alert.maskInvalidType'); + case 'mask-too-large': + return t('alert.maskTooLarge', { limit: formatEditUploadLimit(failure.maxBytes) }); + case 'responses-input-too-large': + return t('alert.responsesEditInputTooLarge', { limit: formatEditUploadLimit(failure.maxBytes) }); + } +} + +function isSupportedEditSourceImageType(type: string): boolean { + return SUPPORTED_EDIT_SOURCE_IMAGE_TYPES.includes(type as (typeof SUPPORTED_EDIT_SOURCE_IMAGE_TYPES)[number]); +} + +function sumFileBytes(files: readonly EditUploadFile[]): number { + return files.reduce((total, file) => total + file.size, 0); +} diff --git a/src/lib/generation-activity.test.ts b/src/lib/generation-activity.test.ts index 702c0cbb878fb5ec840092a82a4359e81256cad3..d69df0e362c42b4bdb81d5d4843f2c0e1fa09180 100644 --- a/src/lib/generation-activity.test.ts +++ b/src/lib/generation-activity.test.ts @@ -14,7 +14,8 @@ const messages: Record = { 'error.unexpected': '发生未知错误。', 'history.activityEditingDetail': '正在根据参考图生成新画面,完成后会进入最近生成。', 'history.activityFailed': '生成失败', - 'history.activityFailedDetail': '{message} 建议检查 API 设置后重试,或切换可用渠道。', + 'history.activityEditFailed': '编辑失败', + 'history.activityFailedDetail': '{message}', 'history.activityBatchProgress': '批量进度', 'history.activityBatchProgressDetail': '已完成 {completed}/{total} 条任务。', 'history.activityBatchProgressWithFailures': '已完成 {completed}/{total} 条任务,失败 {failed} 条。', @@ -52,7 +53,8 @@ describe('buildGenerationActivityItems', () => { assert.match(items[0].detail, /参考图生成新画面/); assert.match(items[1].detail, /2 张过程预览/); assert.match(items[2].detail, /上游服务不可用/); - assert.match(items[2].detail, /建议检查 API 设置后重试/); + assert.equal(items[2].label, '编辑失败'); + assert.doesNotMatch(items[2].detail, /建议检查 API 设置后重试/); }); it('reports saved completion only after loading ends with a completed generation count', () => { @@ -123,7 +125,7 @@ describe('buildGenerationActivityItems', () => { assert.equal(items[0].tone, 'warning'); }); - it('keeps existing API advice instead of appending a second retry suggestion', () => { + it('preserves upstream advice without appending a generic recommendation', () => { const detail = buildFailureActivityDetail('API 请求失败。建议:稍后重试。', t); assert.equal(detail, 'API 请求失败。建议:稍后重试。'); diff --git a/src/lib/generation-activity.ts b/src/lib/generation-activity.ts index 45d87e87a6aa0b7bc606ce6553dad0d518bd828b..94b41e83b45297eceaadf85c3b1d212934a69a5e 100644 --- a/src/lib/generation-activity.ts +++ b/src/lib/generation-activity.ts @@ -71,9 +71,11 @@ export function buildGenerationActivityItems(options: GenerationActivityOptions) } if (options.errorMessage) { + const failedLabel = + options.mode === 'edit' ? options.t('history.activityEditFailed') : options.t('history.activityFailed'); items.push({ id: 'failed', - label: options.t('history.activityFailed'), + label: failedLabel, detail: buildFailureActivityDetail(options.errorMessage, options.t), tone: 'warning' }); @@ -137,10 +139,6 @@ export function countCompletedBatchResults(results: GenerationBatchResult[]): nu export function buildFailureActivityDetail(message: string, t: Translate): string { const trimmedMessage = message.trim(); const effectiveMessage = trimmedMessage || t('error.unexpected'); - if (effectiveMessage.includes('建议:') || effectiveMessage.includes('Recommendation:')) { - return effectiveMessage; - } - return t('history.activityFailedDetail', { message: effectiveMessage }); } diff --git a/src/lib/history-metadata.test.ts b/src/lib/history-metadata.test.ts index eb804e52ccb947e37709465a738c69a8f42e4065..b090fce0050a910c1027adbb9ef7f6cf18e32c96 100644 --- a/src/lib/history-metadata.test.ts +++ b/src/lib/history-metadata.test.ts @@ -151,7 +151,7 @@ describe('history metadata helpers', () => { const second = { ...historyWithSize('3072x2048'), timestamp: 2 }; const updated = updateHistoryResultFeedback({ history: [first, second], - timestamp: second.timestamp, + item: second, value: 'usable', updatedAt: 1770000000000 }); @@ -167,7 +167,7 @@ describe('history metadata helpers', () => { const base = historyWithSize('2048x2048'); const withNote = updateHistoryResultFeedback({ history: [base], - timestamp: base.timestamp, + item: base, value: 'usable', updatedAt: 1770000000001, note: ' 首版已经可以发给运营确认 ' @@ -181,7 +181,7 @@ describe('history metadata helpers', () => { const preservedNote = updateHistoryResultFeedback({ history: withNote, - timestamp: base.timestamp, + item: withNote[0], value: 'needs_revision', updatedAt: 1770000000002 }); @@ -194,7 +194,7 @@ describe('history metadata helpers', () => { const clearedNote = updateHistoryResultFeedback({ history: preservedNote, - timestamp: base.timestamp, + item: preservedNote[0], value: 'needs_revision', updatedAt: 1770000000003, note: '' @@ -221,6 +221,17 @@ describe('history metadata helpers', () => { }); }); + it('does not restore zero or unsafe custom dimensions from saved history', () => { + for (const size of ['0x1536', '2304x0', '9007199254740992x1536']) { + assert.deepEqual(readHistorySizeSelection(historyWithSize(size), 'gpt-image-2'), { + size: 'auto', + customWidth: null, + customHeight: null, + restored: false + }); + } + }); + it('restores legacy preset dimensions even when the current fallback model differs', () => { assert.deepEqual(readHistorySizeSelection(historyWithSize('1536x1024'), 'gpt-image-2'), { size: 'landscape', diff --git a/src/lib/history-metadata.ts b/src/lib/history-metadata.ts index d8dc549aa5e4ffbb6992700909230aace0ebe7ff..ac0520cb073dbcd9592d6a4f85ca10c240627525 100644 --- a/src/lib/history-metadata.ts +++ b/src/lib/history-metadata.ts @@ -22,6 +22,7 @@ export type ResultFeedback = { export const RESULT_FEEDBACK_NOTE_MAX_LENGTH = 500; export type HistoryMetadata = { + id?: string; timestamp: number; images: HistoryImage[]; status?: 'completed' | 'failed'; @@ -51,6 +52,45 @@ export type HistoryMetadata = { export type RequestMode = HistoryMetadata['mode']; +export function createHistoryEntryId(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return `history-${globalThis.crypto.randomUUID()}`; + } + return `history-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function getHistoryEntryId( + item: Pick +): string { + if (typeof item.id === 'string' && item.id.trim().length > 0) return item.id.trim(); + return `legacy-${encodeURIComponent( + JSON.stringify({ + timestamp: item.timestamp, + mode: item.mode, + prompt: item.prompt, + images: item.images.map((image) => [image.filename, image.clientRequestId ?? '']), + clientRequestIds: item.clientRequestIds ?? [] + }) + )}`; +} + +export function isSameHistoryEntry(left: HistoryMetadata, right: HistoryMetadata): boolean { + return getHistoryEntryId(left) === getHistoryEntryId(right); +} + +export function normalizeHistoryEntries(history: HistoryMetadata[]): HistoryMetadata[] { + const usedIds = new Set(); + return history.map((item) => { + const existingId = typeof item.id === 'string' ? item.id.trim() : ''; + let id = existingId; + while (!id || usedIds.has(id)) { + id = createHistoryEntryId(); + } + usedIds.add(id); + return item.id === id ? item : { ...item, id }; + }); +} + export function uniqueStrings(values: Array): string[] { return Array.from( new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0)) @@ -68,7 +108,7 @@ export function resolveHistoryImageClientRequestId(item: HistoryMetadata, imageI export function updateHistoryResultFeedback(input: { history: HistoryMetadata[]; - timestamp: number; + item: HistoryMetadata; value: ResultFeedbackValue; updatedAt?: number; note?: string; @@ -76,7 +116,7 @@ export function updateHistoryResultFeedback(input: { const hasNoteInput = Object.prototype.hasOwnProperty.call(input, 'note'); const updatedAt = input.updatedAt ?? Date.now(); return input.history.map((item) => { - if (item.timestamp !== input.timestamp) return item; + if (!isSameHistoryEntry(item, input.item)) return item; const rawNote = hasNoteInput ? input.note : item.resultFeedback?.note; const note = normalizeResultFeedbackNote(rawNote); return { @@ -142,12 +182,21 @@ export function readHistorySizeSelection( const customMatch = /^(\d+)x(\d+)$/.exec(rawSize); if (customMatch && model === 'gpt-image-2') { - return { - size: 'custom', - customWidth: Number(customMatch[1]), - customHeight: Number(customMatch[2]), - restored: true - }; + const customWidth = Number(customMatch[1]); + const customHeight = Number(customMatch[2]); + if ( + Number.isSafeInteger(customWidth) && + Number.isSafeInteger(customHeight) && + customWidth > 0 && + customHeight > 0 + ) { + return { + size: 'custom', + customWidth, + customHeight, + restored: true + }; + } } return { size: 'auto', customWidth: null, customHeight: null, restored: false }; @@ -252,6 +301,7 @@ export function buildCompletedHistoryEntry(input: { const currentModel = getRequestModel(input.formData, input.requestMode); const costDetails = calculateApiCost(input.usage as Parameters[0], currentModel); return { + id: createHistoryEntryId(), timestamp: Date.now(), images: input.images.map((img) => ({ filename: img.filename, @@ -295,6 +345,7 @@ export function buildFailedHistoryEntry(input: { storageMode: 'fs' | 'indexeddb'; }): HistoryMetadata { return { + id: createHistoryEntryId(), timestamp: Date.now(), images: [], status: 'failed', diff --git a/src/lib/i18n-contract.test.ts b/src/lib/i18n-contract.test.ts index c45bb29d7bf130c5cc6ecddf32b4346fcddfc17e..abec7692cdd32e353896036a1b438163df4232e0 100644 --- a/src/lib/i18n-contract.test.ts +++ b/src/lib/i18n-contract.test.ts @@ -102,6 +102,49 @@ describe('WebUI translation contract', () => { assert.deepEqual(optionalPluralValues, []); }); + + it('keeps automatic-cleanup protection copy aligned with local-history clearing behavior', async () => { + const catalogs = await readCatalogs(); + + assert.match(catalogs['zh-CN']['retention.hint'], /手动删除会移除服务器图片/); + assert.match(catalogs['zh-CN']['retention.hint'], /清空历史只会清除当前浏览器中的记录/); + assert.match(catalogs['en-US']['retention.hint'], /Manual deletion removes the server image/); + assert.match(catalogs['en-US']['retention.hint'], /clearing history only clears records in this browser/); + }); + + it('distinguishes browser-only history clearing from IndexedDB image deletion', async () => { + const catalogs = await readCatalogs(); + + assert.match(catalogs['zh-CN']['confirm.clearHistoryFs'], /服务器图片不会被删除/); + assert.match(catalogs['en-US']['confirm.clearHistoryFs'], /Server images will not be deleted/); + assert.match(catalogs['zh-CN']['confirm.clearHistoryIndexedDb'], /永久删除已存储图片/); + assert.match(catalogs['en-US']['confirm.clearHistoryIndexedDb'], /permanently delete all stored images/); + }); + + it('states edit upload format and size limits in both locales', async () => { + const catalogs = await readCatalogs(); + + assert.match(catalogs['zh-CN']['edit.referenceEmpty'], /PNG、JPEG、WebP/); + assert.match(catalogs['zh-CN']['edit.referenceHint'], /singleLimit/); + assert.match(catalogs['zh-CN']['edit.referenceTotalLimit'], /总大小/); + assert.match(catalogs['zh-CN']['edit.responsesInputLimit'], /参考图和蒙版/); + assert.match(catalogs['zh-CN']['edit.maskHint'], /透明区域/); + assert.match(catalogs['en-US']['edit.referenceEmpty'], /PNG, JPEG, and WebP/); + assert.match(catalogs['en-US']['edit.referenceHint'], /singleLimit/); + assert.match(catalogs['en-US']['edit.referenceTotalLimit'], /together cannot exceed/); + assert.match(catalogs['en-US']['edit.responsesInputLimit'], /reference images and the mask/); + assert.match(catalogs['en-US']['edit.maskHint'], /transparent areas/); + }); + + it('keeps English edit upload summaries independent of trailing translation whitespace', async () => { + const catalogs = await readCatalogs(); + const english = catalogs['en-US']; + + for (const key of ['edit.referenceTotalLimit', 'edit.referenceNoTotalLimit']) { + assert.equal(english[key], english[key].trimEnd(), `${key} must not carry layout whitespace`); + } + assert.match(english['edit.referenceHint'], /per image\. \{totalHint\} Add the image/); + }); }); async function readCatalogs(): Promise { @@ -175,7 +218,7 @@ function collectUserVisibleTextViolationsFromNode(file: ts.SourceFile, node: ts. if (ts.isJsxExpression(node) && node.expression && ts.isStringLiteralLike(node.expression)) { reportVisibleTextViolation(file, node, node.expression.text, violations); } - if (ts.isJsxAttribute(node) && userVisibleAttributeNames.has(node.name.text)) { + if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && userVisibleAttributeNames.has(node.name.text)) { const value = getStaticJsxAttributeValue(node); if (value !== undefined) reportVisibleTextViolation(file, node, value, violations); } diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 1ac9c84caf7d1cb27c746a21ad114ef84d82bd8a..2152aadc6d3712d0f7bff9e4d6bbdc3be91f786c 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -25,16 +25,18 @@ const messages: Record> = { 'app.creationControls': '创作参数', 'app.canvasPreview': '画布预览', 'app.apiRuntimeChecking': '正在读取运行时状态', - 'app.apiRuntimeReady': '当前请求路径已就绪', - 'app.apiRouteLimited': '当前路由受限', + 'app.apiRuntimeReady': '本地路由已配置,可尝试请求', + 'app.apiRouteLimited': '当前配置无法使用所选路径', 'app.apiDisconnected': '未取得运行时状态', - 'app.apiCustomOverride': '使用自定义上游', - 'app.requestModeHealth': '请求方式', - 'app.requestModeVerified': '可用于请求', + 'app.apiCustomOverride': '使用本次自定义 API 凭据', + 'app.requestModeHealth': '请求方式状态', + 'app.requestModeVerified': '未处于冷却,可尝试请求', 'app.requestModeNotConfigured': '未配置', - 'app.requestModeCoolingOrProbe': '冷却中或待探测', + 'app.requestModeCoolingOrProbe': '冷却中或等待恢复探测', 'app.recentChannelFailure': '最近渠道失败', - 'app.suggestedRequestModes': '建议的环境变量', + 'app.failureScopeCredential': '凭据', + 'app.failureScopeChannel': '渠道', + 'app.suggestedRequestModes': '请求方式配置参考', 'mode.generate': '文生图', 'mode.edit': '图像编辑', 'mode.batch': '批量', @@ -50,6 +52,7 @@ const messages: Record> = { 'batch.promptList': '批量提示词列表', 'batch.promptCount': '{count} 条任务', 'batch.promptPlaceholder': '每行输入一条提示词', + 'batch.promptLengthHint': '每条提示词最多 {limit} 个字符。', 'batch.summaryTitle': '任务摘要', 'batch.summaryPerLine': '每一行会生成一张图,沿用下方尺寸、清晰度和格式。', 'batch.summaryFailureTitle': '失败后处理', @@ -118,6 +121,8 @@ const messages: Record> = { 'ux.closeCreationSheetButton': '关闭创作单', 'ux.disabledPrompt': '请输入提示词后再提交。', 'ux.disabledBatchPrompts': '请至少填写一条批量提示词。', + 'ux.disabledPromptLength': '提示词不能超过 {limit} 个字符。', + 'ux.disabledBatchPromptLength': '第 {index} 条批量提示词不能超过 {limit} 个字符。', 'ux.disabledSourceImage': '请先放入参考图。', 'ux.disabledCustomSize': '请先修正自定义尺寸。', 'ux.disabledUnsavedMask': '请先保存已绘制的蒙版。', @@ -140,10 +145,12 @@ const messages: Record> = { 'form.height': '高度 (px)', 'form.pixelsMeta': '{pixels} 像素 - {ratio}', 'form.pixelsMetaOfMaximum': '{pixels} 像素(最大值的 {percent}%)- {ratio}', + 'form.pixelsUnavailable': '请先输入可精确计算的正整数尺寸。', 'form.ratio': '{ratio}:1 比例', 'form.noRatio': '-', 'form.customConstraints': '限制:宽度和高度必须是正整数;具体上游可能继续返回更严格的尺寸错误。', 'sizeError.positive': '宽度和高度必须为正数。', + 'sizeError.safeInteger': '宽度和高度超出可精确处理的整数范围。', 'sizeError.whole': '宽度和高度必须为整数。', 'sizeError.multiple': '宽度和高度都必须是 {multiple} 的倍数。', 'sizeError.maxEdge': '单边最大值为 {max}px。', @@ -158,13 +165,14 @@ const messages: Record> = { 'streaming.previewImages': '预览图数量', 'streaming.disabledByCount': '只有生成单张图片(n=1)时支持流式预览。', 'streaming.disabledByStrategy': '当前流式兼容模式已关闭。', - 'streaming.description': '生成过程中显示局部预览图,交互反馈更及时。', + 'streaming.description': + '只有本次请求实际使用流式传输且上游返回过程图时,才会显示局部预览;自动模式会由服务端按当前渠道选择传输方式,可能直接使用非流式。', 'streaming.batchDescription': '手动启用并发后,每张图会拆成独立流式任务。', 'streaming.parallelBatch': '并发批量', 'streaming.parallelBatchEnabled': '并发已启用', - 'streaming.parallelBatchDescription': '勾选后,多张图或多条提示词会按当前渠道容量并发执行。', + 'streaming.parallelBatchDescription': '勾选后,多张图或多条提示词会按当前服务端配置的并发上限尝试执行。', 'streaming.parallelBatchUnavailable': '并发批量需要流式模式;非流式会保持顺序执行。', - 'streaming.parallelBatchUnavailableCapacity': '当前服务端没有可用并发容量,会保持顺序执行。', + 'streaming.parallelBatchUnavailableCapacity': '当前服务端配置没有可用并发槽位,会保持顺序执行。', 'streaming.parallelBatchUnavailableSingle': '选择至少 2 张图片或 2 条提示词后可启用并发。', 'streaming.costHint': '预览图可能增加输出 token 和费用,具体以当前上游计费为准。', 'streaming.highResolutionRecommendation': @@ -196,7 +204,8 @@ const messages: Record> = { '无法确认当前运行时是否启用 Responses image_generation,因此不会自动改走其他后端。请稍后重试或切换到服务端默认 / Images API 路径。', 'upstream.responsesModelRequired': 'Responses image_generation 需要填写 GPT 顶层模型,或在服务端配置 OPENAI_RESPONSES_API_MODEL。', - 'upstream.strategyImpactAuto': '自动或服务端默认会优先使用当前推荐的流式策略,兼顾可用性和诊断。', + 'upstream.strategyImpactAuto': + '当前自动策略会由服务端按渠道选择传输方式,可能直接使用非流式;仅在实际选中流式且上游支持时使用流式。', 'upstream.strategyImpactOff': '关闭流式会减少长连接不稳定因素,但生成过程中不会显示预览。', 'upstream.strategyImpactSse': '指定 SSE 策略会固定上游连接方式,适合排查特定渠道的稳定性。', 'upstream.strategyImpactForceSse': '强制 SSE 会跳过自动判断,适合诊断,但不应作为不明原因失败时的默认选择。', @@ -222,20 +231,24 @@ const messages: Record> = { 'edit.loading': '编辑中...', 'edit.sourceImages': '参考图(最多 {count} 张)', 'edit.referenceTitle': '参考图', - 'edit.referenceHint': '最多 {count} 张,先放入想继续修改的图片。', + 'edit.referenceHint': '最多 {count} 张,单张不超过 {singleLimit}。{totalHint}先放入想继续修改的图片。', + 'edit.referenceTotalLimit': '参考图总大小不能超过 {limit}。', + 'edit.referenceNoTotalLimit': '当前线路未声明参考图总大小上限。', 'edit.referenceCount': '{count} / {max} 张', 'edit.referenceAction': '放入参考图', - 'edit.referenceEmpty': '支持 PNG、JPEG、WebP,可多选。', + 'edit.referenceEmpty': '支持 PNG、JPEG、WebP;单张不超过 {singleLimit},可多选。', 'edit.referenceAddMore': '继续添加同一组参考图。', 'edit.referenceReady': '参考图已放入,可继续补充。', 'edit.referencePreparing': '正在整理参考图预览。', 'edit.referenceRequiredNote': '请先放入一张参考图,再写下想调整的画面。', + 'edit.responsesInputLimit': '当前 Responses 路线下,参考图和蒙版合计不能超过 {limit}。', 'edit.instructionTitle': '修改想法', 'edit.routeSettings': '模型与流式', 'edit.noFile': '未选择文件。', 'edit.filesSelected': '已选择 {count} 个文件', 'edit.browse': '浏览...', 'edit.mask': '蒙版', + 'edit.maskHint': '仅支持 PNG,单张不超过 {singleLimit},且必须与第一张参考图尺寸一致并包含透明区域。', 'edit.createMask': '创建蒙版', 'edit.closeMaskEditor': '关闭蒙版编辑器', 'edit.editSavedMask': '编辑当前蒙版', @@ -266,9 +279,11 @@ const messages: Record> = { 'output.keepalive': '连接保持中...', 'output.sendToEdit': '发送到编辑', 'output.continueEdit': '继续编辑', - 'output.createVariant': '按相同参数再次生成', + 'output.createVariant': '再次生成', 'output.reusePrompt': '复用提示词', 'output.compare': '对比', + 'output.exitCompare': '退出对比', + 'output.compareUnavailable': '对比中的图片已不可访问,已退出对比。请从最近生成中重新选择可用图片。', 'output.compareReference': '上一张', 'output.compareOther': '对比图', 'output.compareCurrent': '当前图', @@ -282,6 +297,9 @@ const messages: Record> = { 'output.failedKicker': '本次生成没有完成', 'output.failedTitle': '生成失败', 'output.retry': '重试生成', + 'output.editFailedKicker': '本次编辑没有完成', + 'output.editFailedTitle': '编辑失败', + 'output.retryEdit': '重试编辑', 'output.editReferenceKicker': '图生图模式', 'output.editReferenceNeeded': '先放入参考图', 'output.editReferenceReady': '参考图已就绪', @@ -300,19 +318,23 @@ const messages: Record> = { 'output.alt': '生成图片输出', 'logs.open': '查看动态', 'logs.title': '生成动态', - 'logs.description': '显示当前画面的生成过程、保存结果和可追踪的失败原因。', + 'logs.description': '显示所选图片或批次的生成过程、保存结果和可追踪的失败原因。', 'logs.empty': '等待生成动态。', 'logs.emptyForSelection': '当前图片还没有匹配到生成动态。', + 'logs.emptyForBatch': '当前批次还没有匹配到生成动态。', 'logs.historyWithoutScope': '这张图片缺少可匹配的请求信息,暂时找不到对应生成动态。', + 'logs.historyBatchWithoutScope': '这个批次缺少可匹配的请求信息,暂时找不到对应生成动态。', 'logs.selectImage': '请选择一张已生成图片查看对应动态。', 'logs.scopeSelected': '仅显示当前图片相关动态。', + 'logs.scopeBatch': '仅显示当前批次相关动态。', 'logs.scopeNone': '当前没有选中的图片动态范围。', 'logs.scopeMissing': '当前图片缺少直接请求信息,正在尝试按文件名匹配最近动态。', + 'logs.scopeMissingBatch': '当前批次缺少直接请求信息,正在尝试按文件名匹配最近动态。', 'logs.scopeRequestIds': '请求 ID:{value}', 'logs.scopeFilenames': '文件名:{value}', 'logs.scopeResolvedRequestIds': '按文件名匹配到:{value}', 'logs.copyScope': '复制诊断范围', - 'logs.clear': '清空动态', + 'logs.clearCurrent': '清空当前显示', 'logs.count': '{count} 条动态', 'logs.status.idle': '未连接', 'logs.status.connected': '实时连接中', @@ -405,18 +427,20 @@ const messages: Record> = { 'history.activitySaved': '图片保存完成', 'history.activitySavedDetail': '本次结果共 {count} 张,已可继续编辑或下载。', 'history.activityFailed': '生成失败', - 'history.activityFailedDetail': '{message} 建议检查 API 设置后重试,或切换可用渠道。', + 'history.activityEditFailed': '编辑失败', + 'history.activityFailedDetail': '{message}', 'history.empty': '暂无生成记录', - 'history.clear': '清空', - 'history.totalCost': '估算:${cost}', - 'history.totalCostSummary': '估算总成本摘要', - 'history.costSummaryDescription': '历史记录中所有生成图片的预计总成本摘要。', + 'history.clear': '清空本地历史', + 'history.totalCost': '本地估算:${cost}', + 'history.totalCostSummary': '本地估算成本摘要', + 'history.costSummaryDescription': '仅汇总具有本地用量估算的历史图片,不代表渠道实际扣费。', 'history.totalImages': '生成图片总数:', - 'history.averageCost': '单张平均成本:', - 'history.totalEstimatedCost': '预计总成本:', + 'history.estimatedImages': '纳入本地估算的图片数:', + 'history.averageCost': '纳入估算图片的单张平均成本:', + 'history.totalEstimatedCost': '本地预计总成本:', 'history.actualCost': '匹配的上游费用(美元等值):', 'history.actualCostShort': '匹配费用', - 'history.estimatedCostShort': '估算', + 'history.estimatedCostShort': '本地估算', 'history.actualCostPending': '等待匹配', 'history.actualCostUnavailable': '未匹配', 'history.costSource': '扣费来源:', @@ -435,8 +459,9 @@ const messages: Record> = { 'history.actualQuota': '上游返回的额度值:', 'history.matchedLogId': '日志 ID:', 'history.costBreakdown': '成本明细', - 'history.costBreakdownDescription': '本次图片生成的预计成本明细。', - 'history.pricingFor': '{model} 计费:', + 'history.costBreakdownDescription': '按保存的本地用量和模型费率计算的预计成本明细,不代表上游实际扣费。', + 'history.pricingFor': '{model} 本地估算费率:', + 'history.pricingUnavailable': '模型未记录或没有本地费率,无法按本地费率拆分 token 成本。', 'history.textInput': '文本输入:', 'history.imageInput': '图片输入:', 'history.imageOutput': '图片输出:', @@ -452,6 +477,7 @@ const messages: Record> = { 'history.generatedAt': '生成时间:', 'history.mode': '模式:', 'history.model': '模型:', + 'history.modelNotRecorded': '未记录', 'history.resolution': '分辨率:', 'history.quality': '清晰度:', 'history.bg': '背景:', @@ -462,6 +488,7 @@ const messages: Record> = { 'history.status': '状态:', 'history.costStatus': '成本状态:', 'history.failedStatus': '生成失败', + 'history.editFailedStatus': '编辑失败', 'history.failureReason': '失败原因:', 'history.failedReasonUnavailable': '没有记录到具体原因。', 'history.filename': '文件名', @@ -476,8 +503,9 @@ const messages: Record> = { 'history.confirmDeletion': '确认删除', 'history.confirmDeletionDescription': '确定要删除这条历史记录吗?这会移除 {count} 张图片。此操作无法撤销。', 'history.dontAskAgain': '不再询问', - 'history.viewBatch': '查看 {time} 生成的图片批次', - 'history.previewBatch': '{time} 生成批次的预览', + 'history.viewBatch': '查看 {time} 的图片批次', + 'history.viewFailed': '查看 {time} 的失败记录', + 'history.previewBatch': '{time} 的图片批次预览', 'history.previewUnavailable': '预览不可用', 'history.expandBatch': '展开批次', 'history.collapseBatch': '收起批次', @@ -493,7 +521,7 @@ const messages: Record> = { 'retention.release': '取消自动清理保护', 'retention.permanentlySaved': '已免于自动清理', 'retention.selectedCount': '已选择 {count} 张', - 'retention.hint': '仅防止服务端自动清理;手动删除或清空历史仍会移除图片。', + 'retention.hint': '仅防止服务端自动清理。手动删除会移除服务器图片;清空历史只会清除当前浏览器中的记录。', 'retention.updateFailed': '更新自动清理保护失败。', 'retention.partialUpdateFailed': '部分图片的自动清理保护未更新。', 'retention.loadFailed': '读取自动清理保护状态失败。', @@ -530,6 +558,8 @@ const messages: Record> = { 'error.unexpected': '发生未知错误。', 'error.responseBodyNull': '服务返回了空响应,请重试。', 'error.invalidRequest': '请求无效,请检查输入、图片和尺寸后重试。', + 'error.imageCountOutOfRange': '当前后端只允许生成 {min} 到 {max} 张图片。', + 'error.partialImagesOutOfRange': '当前后端只允许设置 {min} 到 {max} 张预览图。', 'error.streaming': '流式生成出错。', 'error.networkRequest': '无法连接图片服务,请检查网络后重试。', 'error.apiFailedWithAdvice': '{message}。建议:{advice}', @@ -560,7 +590,7 @@ const messages: Record> = { 'error.sendToEdit': '发送到编辑表单失败。', 'error.deleteUnexpected': '删除时发生未知错误。', 'error.deletePartial': '部分图片未能删除,或其自动清理保护未能完整清理。', - 'confirm.clearHistoryFs': '确定要清空全部图片历史吗?此操作无法撤销。', + 'confirm.clearHistoryFs': '确定要清空当前浏览器中的全部图片历史吗?服务器图片不会被删除。此操作无法撤销。', 'confirm.clearHistoryIndexedDb': '确定要清空全部图片历史吗?IndexedDB 模式下也会永久删除已存储图片。此操作无法撤销。', 'workbench.creationSheet': '创作单', @@ -572,8 +602,15 @@ const messages: Record> = { 'workbench.album': '相册', 'alert.editNoImage': '请至少选择一张要编辑的图片。', 'alert.maxImages': '最多只能选择 {count} 张图片。', + 'alert.editReferenceEmpty': '参考图不能为空。', + 'alert.editReferenceInvalidType': '参考图仅支持 PNG、JPEG 或 WebP。', + 'alert.editReferenceTooLarge': '单张参考图不能超过 {limit}。', + 'alert.editReferenceTotalTooLarge': '参考图总大小不能超过 {limit}。', 'alert.saveMaskBeforeSubmit': '请先保存已绘制的蒙版再提交。', + 'alert.maskEmpty': '蒙版文件不能为空。', 'alert.maskInvalidType': '蒙版文件类型无效。请上传 PNG 文件。', + 'alert.maskTooLarge': '蒙版不能超过 {limit}。', + 'alert.responsesEditInputTooLarge': '当前 Responses 路线下,参考图和蒙版合计不能超过 {limit}。', 'alert.maskDimensionMismatch': '蒙版尺寸({actual})必须与源图片尺寸({expected})一致。', 'alert.maskLoadFailed': '无法加载上传的蒙版图片并检查尺寸。', 'alert.pasteMaxImages': '无法粘贴:已达到最多 {count} 张图片的限制。' @@ -595,16 +632,18 @@ const messages: Record> = { 'app.creationControls': 'Creation controls', 'app.canvasPreview': 'Canvas preview', 'app.apiRuntimeChecking': 'Reading runtime status', - 'app.apiRuntimeReady': 'Request route ready', - 'app.apiRouteLimited': 'Current route limited', + 'app.apiRuntimeReady': 'Local route is configured; a request can be attempted', + 'app.apiRouteLimited': 'Current configuration cannot use the selected path', 'app.apiDisconnected': 'Runtime status unavailable', - 'app.apiCustomOverride': 'Custom upstream', - 'app.requestModeHealth': 'Request modes', - 'app.requestModeVerified': 'Ready for requests', + 'app.apiCustomOverride': 'Using request-specific API credentials', + 'app.requestModeHealth': 'Request mode status', + 'app.requestModeVerified': 'Not cooling down; a request can be attempted', 'app.requestModeNotConfigured': 'Not configured', - 'app.requestModeCoolingOrProbe': 'Cooling or pending probe', + 'app.requestModeCoolingOrProbe': 'Cooling down or awaiting recovery probe', 'app.recentChannelFailure': 'Recent channel failure', - 'app.suggestedRequestModes': 'Suggested environment variable', + 'app.failureScopeCredential': 'Credential', + 'app.failureScopeChannel': 'Channel', + 'app.suggestedRequestModes': 'Request mode configuration reference', 'mode.generate': 'Text to image', 'mode.edit': 'Image editing', 'mode.batch': 'Batch', @@ -620,6 +659,7 @@ const messages: Record> = { 'batch.promptList': 'Batch prompt list', 'batch.promptCount': '{count} tasks', 'batch.promptPlaceholder': 'Enter one prompt per line', + 'batch.promptLengthHint': 'Each prompt can contain up to {limit} characters.', 'batch.summaryTitle': 'Task summary', 'batch.summaryPerLine': 'Each line creates one image with the size, quality, and format below.', 'batch.summaryFailureTitle': 'After a failure', @@ -690,6 +730,8 @@ const messages: Record> = { 'ux.closeCreationSheetButton': 'Close creation sheet', 'ux.disabledPrompt': 'Enter a prompt before submitting.', 'ux.disabledBatchPrompts': 'Add at least one batch prompt.', + 'ux.disabledPromptLength': 'A prompt cannot exceed {limit} characters.', + 'ux.disabledBatchPromptLength': 'Batch prompt {index} cannot exceed {limit} characters.', 'ux.disabledSourceImage': 'Add a reference image first.', 'ux.disabledCustomSize': 'Fix the custom size first.', 'ux.disabledUnsavedMask': 'Save the drawn mask first.', @@ -712,11 +754,13 @@ const messages: Record> = { 'form.height': 'Height (px)', 'form.pixelsMeta': '{pixels} pixels - {ratio}', 'form.pixelsMetaOfMaximum': '{pixels} pixels ({percent}% of the maximum) - {ratio}', + 'form.pixelsUnavailable': 'Enter positive whole-number dimensions that can be calculated precisely.', 'form.ratio': '{ratio}:1 ratio', 'form.noRatio': '-', 'form.customConstraints': 'Constraints: width and height must be positive whole numbers; the selected upstream may return stricter size errors.', 'sizeError.positive': 'Width and height must be positive numbers.', + 'sizeError.safeInteger': 'Width and height are outside the integer range that can be processed precisely.', 'sizeError.whole': 'Width and height must be whole numbers.', 'sizeError.multiple': 'Both edges must be multiples of {multiple}.', 'sizeError.maxEdge': 'Maximum edge is {max}px.', @@ -732,15 +776,15 @@ const messages: Record> = { 'streaming.disabledByCount': 'Streaming is only supported when generating a single image (n=1).', 'streaming.disabledByStrategy': 'The current streaming compatibility mode is off.', 'streaming.description': - 'Shows partial preview images as they are generated, providing a more interactive experience.', + 'Partial previews appear only when this request uses streaming and the upstream returns them. In auto mode, the server selects transport for the current channel and may use non-streaming directly.', 'streaming.batchDescription': 'After manual enablement, each image runs as a separate streaming job.', 'streaming.parallelBatch': 'Parallel batch', 'streaming.parallelBatchEnabled': 'Parallel on', 'streaming.parallelBatchDescription': - 'When checked, multiple images or prompts run concurrently within the current channel capacity.', + 'When checked, multiple images or prompts attempt to run concurrently within the server-configured limit.', 'streaming.parallelBatchUnavailable': 'Parallel batch requires streaming mode. Non-streaming stays sequential.', 'streaming.parallelBatchUnavailableCapacity': - 'The server has no available parallel capacity, so requests stay sequential.', + 'The server configuration has no available parallel slots, so requests stay sequential.', 'streaming.parallelBatchUnavailableSingle': 'Choose at least 2 images or 2 prompts to enable parallel batch.', 'streaming.costHint': 'Preview images may increase output tokens and cost. Refer to the current upstream pricing.', @@ -776,7 +820,7 @@ const messages: Record> = { 'upstream.responsesModelRequired': 'Responses image_generation needs a GPT top-level model here or OPENAI_RESPONSES_API_MODEL on the server.', 'upstream.strategyImpactAuto': - 'Auto or server default uses the currently recommended streaming strategy for availability and diagnostics.', + 'The current auto strategy lets the server choose transport for each channel and may use non-streaming directly; streaming is used only when selected and supported by the upstream.', 'upstream.strategyImpactOff': 'Turning streaming off reduces long-connection instability, but no preview appears during generation.', 'upstream.strategyImpactSse': @@ -807,20 +851,28 @@ const messages: Record> = { 'edit.loading': 'Editing...', 'edit.sourceImages': 'Reference images (up to {count})', 'edit.referenceTitle': 'Reference images', - 'edit.referenceHint': 'Up to {count} images. Add the image you want to keep working on first.', + 'edit.referenceHint': + 'Up to {count} images and {singleLimit} per image. {totalHint} Add the image you want to keep working on first.', + 'edit.referenceTotalLimit': 'Reference images together cannot exceed {limit}.', + 'edit.referenceNoTotalLimit': 'This route does not declare a reference-image total limit.', 'edit.referenceCount': '{count} / {max} images', 'edit.referenceAction': 'Add reference image', - 'edit.referenceEmpty': 'PNG, JPEG, and WebP are supported. Multiple images are allowed.', + 'edit.referenceEmpty': + 'PNG, JPEG, and WebP are supported. Up to {singleLimit} per image; multiple images are allowed.', 'edit.referenceAddMore': 'Add more images to the same reference set.', 'edit.referenceReady': 'Reference image added. You can add more.', 'edit.referencePreparing': 'Preparing reference preview.', 'edit.referenceRequiredNote': 'Add a reference image before writing the edit direction.', + 'edit.responsesInputLimit': + 'On the current Responses route, reference images and the mask together cannot exceed {limit}.', 'edit.instructionTitle': 'Edit direction', 'edit.routeSettings': 'Model and streaming', 'edit.noFile': 'No file selected.', 'edit.filesSelected': '{count} files selected', 'edit.browse': 'Browse...', 'edit.mask': 'Mask', + 'edit.maskHint': + 'PNG only, up to {singleLimit}; it must match the first reference image dimensions and include transparent areas.', 'edit.createMask': 'Create Mask', 'edit.closeMaskEditor': 'Close Mask Editor', 'edit.editSavedMask': 'Edit Current Mask', @@ -856,6 +908,9 @@ const messages: Record> = { 'output.createVariant': 'Generate again with the same settings', 'output.reusePrompt': 'Reuse prompt', 'output.compare': 'Compare', + 'output.exitCompare': 'Exit comparison', + 'output.compareUnavailable': + 'An image in the comparison is no longer available. Comparison was closed; select an available image from Recent.', 'output.compareReference': 'Previous', 'output.compareOther': 'Compare', 'output.compareCurrent': 'Current', @@ -869,6 +924,9 @@ const messages: Record> = { 'output.failedKicker': 'This generation did not finish', 'output.failedTitle': 'Generation failed', 'output.retry': 'Retry generation', + 'output.editFailedKicker': 'This edit did not finish', + 'output.editFailedTitle': 'Edit failed', + 'output.retryEdit': 'Retry edit', 'output.editReferenceKicker': 'Image edit mode', 'output.editReferenceNeeded': 'Add a reference image first', 'output.editReferenceReady': 'Reference image ready', @@ -891,18 +949,24 @@ const messages: Record> = { 'logs.description': 'Shows the current image activity, saved results, and traceable failure reasons.', 'logs.empty': 'Waiting for generation activity.', 'logs.emptyForSelection': 'No generation activity matched the current image yet.', + 'logs.emptyForBatch': 'No generation activity matched the current batch yet.', 'logs.historyWithoutScope': 'This image has no request information for matching activity, and no matching filename was found recently.', + 'logs.historyBatchWithoutScope': + 'This batch has no request information for matching activity, and no matching filename was found recently.', 'logs.selectImage': 'Select a generated image to view its activity.', 'logs.scopeSelected': 'Showing activity for the current image only.', + 'logs.scopeBatch': 'Showing activity for the current batch only.', 'logs.scopeNone': 'No image activity scope is selected.', 'logs.scopeMissing': 'The current image has no direct request information. Matching recent activity by filename.', + 'logs.scopeMissingBatch': + 'The current batch has no direct request information. Matching recent activity by filename.', 'logs.scopeRequestIds': 'Request IDs: {value}', 'logs.scopeFilenames': 'Filenames: {value}', 'logs.scopeResolvedRequestIds': 'Matched by filename: {value}', 'logs.copyScope': 'Copy diagnostic scope', - 'logs.clear': 'Clear Activity', + 'logs.clearCurrent': 'Clear current display', 'logs.count': '{count} activity entries', 'logs.status.idle': 'Disconnected', 'logs.status.connected': 'Live connection active', @@ -997,19 +1061,21 @@ const messages: Record> = { 'history.activitySaved': 'Images saved', 'history.activitySavedDetail': 'Results ready to edit or download: {count}.', 'history.activityFailed': 'Generation failed', - 'history.activityFailedDetail': '{message} Recommendation: check API settings and retry, or switch channel.', + 'history.activityEditFailed': 'Edit failed', + 'history.activityFailedDetail': '{message}', 'history.empty': 'No generated images yet.', 'history.clear': 'Clear', - 'history.totalCost': 'Estimated: ${cost}', - 'history.totalCostSummary': 'Estimated Cost Summary', + 'history.totalCost': 'Local estimate: ${cost}', + 'history.totalCostSummary': 'Local Estimate Summary', 'history.costSummaryDescription': - 'A summary of the total estimated cost for all generated images in the history.', + 'Only includes history images with a local usage estimate and does not represent the upstream charge.', 'history.totalImages': 'Total Images Generated:', - 'history.averageCost': 'Average Cost Per Image:', - 'history.totalEstimatedCost': 'Total Estimated Cost:', + 'history.estimatedImages': 'Images Included in Local Estimate:', + 'history.averageCost': 'Average Cost Per Estimated Image:', + 'history.totalEstimatedCost': 'Total Local Estimate:', 'history.actualCost': 'Matched upstream charge (USD equivalent):', 'history.actualCostShort': 'Matched charge', - 'history.estimatedCostShort': 'Estimated', + 'history.estimatedCostShort': 'Local estimate', 'history.actualCostPending': 'Pending match', 'history.actualCostUnavailable': 'Not matched', 'history.costSource': 'Cost Source:', @@ -1028,8 +1094,11 @@ const messages: Record> = { 'history.actualQuota': 'Upstream-reported quota:', 'history.matchedLogId': 'Log ID:', 'history.costBreakdown': 'Cost Breakdown', - 'history.costBreakdownDescription': 'Estimated cost breakdown for this image generation.', - 'history.pricingFor': 'Pricing for {model}:', + 'history.costBreakdownDescription': + 'Estimated cost from the saved local usage and model rate; it does not represent the upstream charge.', + 'history.pricingFor': 'Local estimate pricing for {model}:', + 'history.pricingUnavailable': + 'The model was not recorded or has no local rate, so token costs cannot be split by local rate.', 'history.textInput': 'Text Input:', 'history.imageInput': 'Image Input:', 'history.imageOutput': 'Image Output:', @@ -1045,6 +1114,7 @@ const messages: Record> = { 'history.generatedAt': 'Generated At:', 'history.mode': 'Mode:', 'history.model': 'Model:', + 'history.modelNotRecorded': 'Not recorded', 'history.resolution': 'Resolution:', 'history.quality': 'Quality:', 'history.bg': 'Background:', @@ -1055,6 +1125,7 @@ const messages: Record> = { 'history.status': 'Status:', 'history.costStatus': 'Cost Status:', 'history.failedStatus': 'Generation failed', + 'history.editFailedStatus': 'Edit failed', 'history.failureReason': 'Failure reason:', 'history.failedReasonUnavailable': 'No specific reason was recorded.', 'history.filename': 'Filename', @@ -1071,7 +1142,8 @@ const messages: Record> = { 'Are you sure you want to delete this history entry? Number of images to remove: {count}. This action cannot be undone.', 'history.dontAskAgain': "Don't ask me again", 'history.viewBatch': 'View image batch from {time}', - 'history.previewBatch': 'Preview for batch generated at {time}', + 'history.viewFailed': 'View failed request from {time}', + 'history.previewBatch': 'Preview for image batch at {time}', 'history.previewUnavailable': 'Preview unavailable', 'history.expandBatch': 'Expand batch', 'history.collapseBatch': 'Collapse batch', @@ -1088,7 +1160,7 @@ const messages: Record> = { 'retention.permanentlySaved': 'Protected from auto-cleanup', 'retention.selectedCount': '{count} selected', 'retention.hint': - 'This only prevents server-side automatic cleanup. Manual deletion or clearing history still removes the image.', + 'This only prevents server-side automatic cleanup. Manual deletion removes the server image; clearing history only clears records in this browser.', 'retention.updateFailed': 'Failed to update automatic-cleanup protection.', 'retention.partialUpdateFailed': 'Some image cleanup-protection settings were not updated.', 'retention.loadFailed': 'Failed to load automatic-cleanup protection.', @@ -1128,6 +1200,8 @@ const messages: Record> = { 'error.unexpected': 'An unexpected error occurred.', 'error.responseBodyNull': 'The image service returned an empty response. Try again.', 'error.invalidRequest': 'The request is invalid. Check the input, images, and size, then try again.', + 'error.imageCountOutOfRange': 'The current backend only allows {min} to {max} generated images.', + 'error.partialImagesOutOfRange': 'The current backend only allows {min} to {max} preview images.', 'error.streaming': 'Streaming error occurred', 'error.networkRequest': 'Could not reach the image service. Check the network and try again.', 'error.apiFailedWithAdvice': '{message}. Recommendation: {advice}', @@ -1162,7 +1236,8 @@ const messages: Record> = { 'error.deleteUnexpected': 'An unexpected error occurred during deletion.', 'error.deletePartial': 'Some images could not be deleted, or their cleanup protection could not be fully cleared.', - 'confirm.clearHistoryFs': 'Are you sure you want to clear the entire image history? This cannot be undone.', + 'confirm.clearHistoryFs': + 'Are you sure you want to clear all image history in this browser? Server images will not be deleted. This cannot be undone.', 'confirm.clearHistoryIndexedDb': 'Are you sure you want to clear the entire image history? In IndexedDB mode, this will also permanently delete all stored images. This cannot be undone.', 'workbench.creationSheet': 'Creation sheet', @@ -1174,8 +1249,16 @@ const messages: Record> = { 'workbench.album': 'Album', 'alert.editNoImage': 'Please select at least one image to edit.', 'alert.maxImages': 'You can only select up to {count} images.', + 'alert.editReferenceEmpty': 'A reference image cannot be empty.', + 'alert.editReferenceInvalidType': 'Reference images must be PNG, JPEG, or WebP.', + 'alert.editReferenceTooLarge': 'Each reference image cannot exceed {limit}.', + 'alert.editReferenceTotalTooLarge': 'Reference images together cannot exceed {limit}.', 'alert.saveMaskBeforeSubmit': 'Save the drawn mask before submitting.', + 'alert.maskEmpty': 'The mask file cannot be empty.', 'alert.maskInvalidType': 'Invalid file type. Please upload a PNG file for the mask.', + 'alert.maskTooLarge': 'The mask cannot exceed {limit}.', + 'alert.responsesEditInputTooLarge': + 'On the current Responses route, reference images and the mask together cannot exceed {limit}.', 'alert.maskDimensionMismatch': 'Mask dimensions ({actual}) must match the source image dimensions ({expected}).', 'alert.maskLoadFailed': 'Failed to load the uploaded mask image to check dimensions.', diff --git a/src/lib/image-request-limits.ts b/src/lib/image-request-limits.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4da757d3c03da39b6fd754e957925372d9fbe44 --- /dev/null +++ b/src/lib/image-request-limits.ts @@ -0,0 +1,4 @@ +export const MAX_PROMPT_LENGTH = 32_000; +export const BYTES_PER_MEGABYTE = 1024 * 1024; +export const MAX_OPENAI_UPLOAD_BYTES = 25 * BYTES_PER_MEGABYTE; +export const MAX_RESPONSES_EDIT_INPUT_BYTES = MAX_OPENAI_UPLOAD_BYTES * 2; diff --git a/src/lib/image-request-utils.test.ts b/src/lib/image-request-utils.test.ts index de65b1bd18b5e322bfe5bd5fd8cbcb8ccf3f2c42..cbf0c74ed0ee8ba7f3e6275ac8cf4606dd42c838 100644 --- a/src/lib/image-request-utils.test.ts +++ b/src/lib/image-request-utils.test.ts @@ -152,6 +152,13 @@ describe('readImageFiles', () => { ); assert.throws(() => readImageFiles(oversizedFormData, IMAGE_UPSTREAM_PROFILES.matsca), /10 MB/); }); + + it('rejects source image formats outside the documented PNG, JPEG, and WebP contract', () => { + const formData = new FormData(); + formData.append('image_0', new File(['image'], 'source.gif', { type: 'image/gif' })); + + assert.throws(() => readImageFiles(formData), /PNG、JPEG 或 WebP/); + }); }); describe('Matsca upstream image parameter compatibility', () => { diff --git a/src/lib/image-request-utils.ts b/src/lib/image-request-utils.ts index b453115be4dd972840e53332841baa8c325c0ee1..b860078a3a93c17555ec0beb644d24a0ab9ff096 100644 --- a/src/lib/image-request-utils.ts +++ b/src/lib/image-request-utils.ts @@ -1,4 +1,10 @@ -import { IMAGE_UPSTREAM_PROFILES, type ImageUpstreamProfile } from './image-upstream-profile'; +import { MAX_OPENAI_UPLOAD_BYTES, MAX_PROMPT_LENGTH } from './image-request-limits'; +import { + IMAGE_UPSTREAM_PROFILES, + isIntegerWithinRange, + type ImageUpstreamProfile, + type NumericRange +} from './image-upstream-profile'; import { validateGptImage2Size } from './size-utils'; import { promisify } from 'node:util'; import { inflate } from 'node:zlib'; @@ -6,8 +12,8 @@ import type OpenAI from 'openai'; export const VALID_IMAGE_FILENAME_PATTERN = /^\d{13}(?:-[a-f0-9]{16})?-\d+\.(png|jpe?g|webp)$/i; export const MAX_IMAGE_COUNT = 10; -export const MAX_UPLOAD_BYTES = 25 * 1024 * 1024; -export const MAX_PROMPT_LENGTH = 32000; +export const MAX_UPLOAD_BYTES = MAX_OPENAI_UPLOAD_BYTES; +export { MAX_PROMPT_LENGTH } from './image-request-limits'; const VALID_MODE_VALUES = ['generate', 'edit'] as const; const VALID_MODEL_VALUES = ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'] as const; @@ -17,6 +23,7 @@ const VALID_EDIT_QUALITY_VALUES = ['low', 'medium', 'high', 'auto'] as const; const VALID_BACKGROUND_VALUES = ['transparent', 'opaque', 'auto'] as const; const VALID_MODERATION_VALUES = ['low', 'auto'] as const; const VALID_LEGACY_SIZE_VALUES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; +const VALID_UPLOAD_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; const IMAGE_UPLOAD_CONTROL_FIELDS = new Set(['image_backend', 'image_streaming_strategy']); const DEFAULT_IMAGE_PROFILE = IMAGE_UPSTREAM_PROFILES['openai-compatible']; const DEFAULT_OUTPUT_FORMAT = 'webp'; @@ -100,15 +107,27 @@ export function readModel(formData: FormData): GptImageModel { return value; } -export function readCount(formData: FormData, field: string, fallback: number, min: number, max: number): number { +export function readCount( + formData: FormData, + field: string, + fallback: number, + min: number, + max: number, + range?: NumericRange +): number { const rawValue = formData.get(field); if (rawValue === null) return fallback; if (typeof rawValue !== 'string' || !/^\d+$/.test(rawValue)) { throw new RequestValidationError(`${field} 必须是整数。`); } const value = Number(rawValue); - if (!Number.isInteger(value) || value < min || value > max) { - throw new RequestValidationError(`${field} 必须在 ${min} 到 ${max} 之间。`); + const effectiveRange = range ?? { min, max }; + if (!isIntegerWithinRange(value, effectiveRange)) { + throw new RequestValidationError( + effectiveRange.allowedValues + ? `${field} 必须是以下值之一:${effectiveRange.allowedValues.join(', ')}。` + : `${field} 必须在 ${min} 到 ${max} 之间。` + ); } return value; } @@ -434,12 +453,12 @@ function validateUploadFile( if (file.size > maxBytes) { throw new RequestValidationError(`${fieldName} 超过 ${maxBytes / 1024 / 1024} MB 限制。`); } - if (!file.type.startsWith('image/')) { - throw new RequestValidationError(`${fieldName} 必须是图片文件。`); - } if (options.requirePng && file.type !== 'image/png') { throw new RequestValidationError(`${fieldName} 必须是 PNG 文件。`); } + if (!VALID_UPLOAD_IMAGE_TYPES.includes(file.type as (typeof VALID_UPLOAD_IMAGE_TYPES)[number])) { + throw new RequestValidationError(`${fieldName} 必须是 PNG、JPEG 或 WebP 图片。`); + } } async function readUploadedImageDimensions(file: File, fieldName: string): Promise { diff --git a/src/lib/image-route-mode-handlers.ts b/src/lib/image-route-mode-handlers.ts index c747b4f82cbb4ae85475097881cddbbe6c31d3ca..3440c4928fb5d27bb1a381a56daef60d5b5e7840 100644 --- a/src/lib/image-route-mode-handlers.ts +++ b/src/lib/image-route-mode-handlers.ts @@ -34,6 +34,7 @@ import { import { resolveAcceptedImageTaskResponse, type AcceptedImageTaskResponseError } from './image-service'; import { createImageStreamResponse } from './image-stream-service'; import { + getImageCountRangeCompatibilityForBackend, mergeUpstreamHeadersWithFixed, type ImageUpstreamProfile, type PartialImagesCount, @@ -56,6 +57,7 @@ type CommonModeInput = { model: GptImageModel; prompt: string; streamEnabled: boolean; + imageBackend: ImageBackend; partialImagesCount: PartialImagesCount; upstreamProfile: ImageUpstreamProfile; upstreamHeaders?: UpstreamRequestHeaders; @@ -125,14 +127,27 @@ function toResponsesPartialImagesCount(value: PartialImagesCount): 1 | 2 | 3 { throw new RequestValidationError('Responses API 图片后端的 partial_images 必须在 1 到 3 之间。', 400); } -function readGenerateOptions(input: CommonModeInput): GenerateOptions { - const n = readCount( +function readImageCountForBackend(input: CommonModeInput, operation: 'generate' | 'edit'): number { + const compatibility = getImageCountRangeCompatibilityForBackend( + input.upstreamProfile, + operation, + input.imageBackend + ); + if (!compatibility.compatible) { + throw new RequestValidationError(compatibility.error.message, 422); + } + return readCount( input.formData, 'n', - 1, - input.upstreamProfile.generateCount.min, - input.upstreamProfile.generateCount.max + compatibility.range.min, + compatibility.range.min, + compatibility.range.max, + compatibility.range ); +} + +function readGenerateOptions(input: CommonModeInput): GenerateOptions { + const n = readImageCountForBackend(input, 'generate'); const size = readSize(input.formData, 'size', '1024x1024', input.model, input.upstreamProfile, { forceRequest: input.forceRequest === true }); @@ -408,9 +423,7 @@ async function createGenerateStreamResponse( return appendAccessCookie(response, input.accessCookie); } -export async function handleGenerateImageMode( - input: CommonModeInput & { imageBackend: ImageBackend } -): Promise { +export async function handleGenerateImageMode(input: CommonModeInput): Promise { const options = readGenerateOptions(input); if (input.imageBackend === 'responses-image-generation') { if (input.streamEnabled) { @@ -428,13 +441,7 @@ export async function handleGenerateImageMode( } function readEditOptions(input: CommonModeInput): EditOptions { - const n = readCount( - input.formData, - 'n', - 1, - input.upstreamProfile.editCount.min, - input.upstreamProfile.editCount.max - ); + const n = readImageCountForBackend(input, 'edit'); const size = readSize(input.formData, 'size', 'auto', input.model, input.upstreamProfile, { forceRequest: input.forceRequest === true }); @@ -552,9 +559,7 @@ async function createEditStreamResponse(input: CommonModeInput, options: EditOpt return appendAccessCookie(response, input.accessCookie); } -export async function handleEditImageMode( - input: CommonModeInput & { imageBackend: ImageBackend } -): Promise { +export async function handleEditImageMode(input: CommonModeInput): Promise { const options = readEditOptions(input); await assertMaskCompatibility(options.maskFile, options.imageFiles); if (input.imageBackend === 'responses-image-generation') { diff --git a/src/lib/image-upstream-form.ts b/src/lib/image-upstream-form.ts index 9b0901fa20c80a8352a7d9ecc665f2cf17ccd55e..f29d7084b2f95aae7790fb2b6488de249639ea67 100644 --- a/src/lib/image-upstream-form.ts +++ b/src/lib/image-upstream-form.ts @@ -1,3 +1,4 @@ +import { resolveImageBackendSelection } from './image-upstream-profile'; import type { ImageGenerationBackend, ImageStreamingStrategy } from './image-upstream-strategy'; export const IMAGE_UPSTREAM_FORM_SERVER_DEFAULT = 'server-default'; @@ -42,21 +43,26 @@ export function isResponsesImageBackendRuntimeEnabled(input: { export function shouldAllowResponsesImageBackend(input: { runtimeCapabilities?: { - responsesImageBackend?: { enabled?: boolean } | null; + responsesImageBackend?: { enabled?: boolean; featureEnabled?: boolean } | null; channelRouting?: { effectiveRequestModes?: readonly string[] } | null; } | null; hasRequestApiOverride: boolean; }): boolean { + const responsesImageBackend = input.runtimeCapabilities?.responsesImageBackend; + if (input.hasRequestApiOverride) { + return responsesImageBackend?.featureEnabled === true || responsesImageBackend?.enabled === true; + } if (!isResponsesImageBackendRuntimeEnabled(input.runtimeCapabilities ?? {})) return false; - if (input.hasRequestApiOverride) return true; return hasResponsesChannelRequestMode(input.runtimeCapabilities ?? {}); } export function shouldBlockExplicitResponsesRequest(input: { imageBackend: ImageUpstreamFormBackend; allowResponsesImageBackend: boolean; + defaultImageBackend?: ImageGenerationBackend | null; }): boolean { - return input.imageBackend === 'responses-image-generation' && !input.allowResponsesImageBackend; + const effectiveBackend = resolveImageBackendSelection(input.imageBackend, input.defaultImageBackend); + return effectiveBackend === 'responses-image-generation' && !input.allowResponsesImageBackend; } export function shouldBlockResponsesRequestWithoutModel(input: { diff --git a/src/lib/image-upstream-profile.test.ts b/src/lib/image-upstream-profile.test.ts index d1bffecb8c6e44051ea17a2717c3a58b5c478ffd..f05856ae70089cdb2f58fbbf8b375df65c30576f 100644 --- a/src/lib/image-upstream-profile.test.ts +++ b/src/lib/image-upstream-profile.test.ts @@ -1,5 +1,7 @@ import { buildMatscaAppHeaders, + getImageCountRangeForBackend, + getPartialImagesRangeForBackend, IMAGE_UPSTREAM_PROFILES, readImageUpstreamProfile, summarizeImageUpstreamProfile @@ -37,6 +39,50 @@ describe('buildMatscaAppHeaders', () => { }); }); +describe('backend-specific image limits', () => { + it('keeps Responses image_generation to one image and one to three previews', () => { + const profile = IMAGE_UPSTREAM_PROFILES.matsca; + + assert.deepEqual(getImageCountRangeForBackend(profile, 'generate', 'images-api'), { min: 1, max: 4 }); + assert.deepEqual(getImageCountRangeForBackend(profile, 'edit', 'images-api'), { min: 1, max: 4 }); + assert.deepEqual(getImageCountRangeForBackend(profile, 'generate', 'responses-image-generation'), { + min: 1, + max: 1 + }); + assert.deepEqual(getImageCountRangeForBackend(profile, 'edit', 'responses-image-generation'), { + min: 1, + max: 1 + }); + assert.deepEqual( + getImageCountRangeForBackend(profile, 'generate', 'server-default', 'responses-image-generation'), + { min: 1, max: 1 } + ); + assert.deepEqual(getPartialImagesRangeForBackend(profile, 'responses-image-generation'), { min: 1, max: 3 }); + assert.deepEqual(getPartialImagesRangeForBackend(profile, 'server-default', 'responses-image-generation'), { + min: 1, + max: 3 + }); + assert.deepEqual(getPartialImagesRangeForBackend(profile, 'server-default'), { min: 0, max: 4 }); + }); + + it('rejects a Responses backend when the upstream count range has no overlap', () => { + const profile = { + ...IMAGE_UPSTREAM_PROFILES['openai-compatible'], + generateCount: { min: 2, max: 2 }, + editCount: { min: 2, max: 2 } + }; + + assert.throws( + () => getImageCountRangeForBackend(profile, 'generate', 'responses-image-generation'), + /图片数量范围没有可用交集/ + ); + assert.throws( + () => getImageCountRangeForBackend(profile, 'edit', 'responses-image-generation'), + /图片数量范围没有可用交集/ + ); + }); +}); + describe('summarizeImageUpstreamProfile', () => { it('lets a request Matsca URL drive client-side constraints without waiting for server profile metadata', () => { const summary = summarizeImageUpstreamProfile({ @@ -111,4 +157,42 @@ describe('summarizeImageUpstreamProfile', () => { assert.equal(summary.activeConstraints.upload.maxImages, 3); assert.equal(summary.activeConstraints.upload.maxSingleBytes, 10 * 1024 * 1024); }); + + it('keeps mixed server capabilities valid when a range has no common value', () => { + const fixedOne = { + ...IMAGE_UPSTREAM_PROFILES['openai-compatible'], + generateCount: { min: 1, max: 1 } + }; + const fixedTwo = { + ...IMAGE_UPSTREAM_PROFILES['openai-compatible'], + generateCount: { min: 2, max: 2 } + }; + + const summary = summarizeImageUpstreamProfile({ serverProfiles: [fixedOne, fixedTwo] }); + + assert.equal(summary.serverProfileMixed, true); + assert.equal(summary.serverConstraintsMixed, true); + assert.deepEqual(summary.serverConstraintsByProfile, [fixedOne, fixedTwo]); + assert.ok(summary.serverConstraints.generateCount.min <= summary.serverConstraints.generateCount.max); + assert.deepEqual(summary.serverConstraints.generateCount, { min: 1, max: 2 }); + }); + + it('preserves gaps when mixed server count ranges are unioned', () => { + const fixedOne = { + ...IMAGE_UPSTREAM_PROFILES['openai-compatible'], + generateCount: { min: 1, max: 1 } + }; + const fixedThree = { + ...IMAGE_UPSTREAM_PROFILES['openai-compatible'], + generateCount: { min: 3, max: 3 } + }; + + const summary = summarizeImageUpstreamProfile({ serverProfiles: [fixedOne, fixedThree] }); + + assert.deepEqual(summary.serverConstraints.generateCount, { + min: 1, + max: 3, + allowedValues: [1, 3] + }); + }); }); diff --git a/src/lib/image-upstream-profile.ts b/src/lib/image-upstream-profile.ts index d8ff88afd6842474f090ce5eaf323c4a2e6f2b01..362f9a7f22f4b8de9e90d8eae197ef6dca299a1d 100644 --- a/src/lib/image-upstream-profile.ts +++ b/src/lib/image-upstream-profile.ts @@ -1,3 +1,4 @@ +import { MAX_OPENAI_UPLOAD_BYTES } from './image-request-limits'; import type { ImageProviderManifestSummary } from './image-upstream-provider-manifest'; import type { ImageGenerationBackend } from './image-upstream-strategy'; @@ -15,14 +16,49 @@ export type ImageUpstreamProfileId = 'openai-compatible' | 'matsca'; export type PartialImagesCount = 0 | 1 | 2 | 3 | 4; -export type NumericRange = { min: number; max: number }; +export type NumericRange = { min: number; max: number; allowedValues?: readonly number[] }; + +export type ImageRequestOperation = 'generate' | 'edit'; + +export type ImageBackendRangeCompatibility = + { compatible: true; range: NumericRange } | { compatible: false; error: ImageUpstreamRangeError }; + +export type ImageBackendCompatibility = { + compatible: boolean; + imageCountRange?: NumericRange; + partialImagesRange?: NumericRange; + errors: ImageUpstreamRangeError[]; +}; + +export type ImageBackendCompatibilityOptions = { + validatePartialImages?: boolean; +}; + +type ImageBackendSelection = ImageGenerationBackend | 'server-default'; + +export const RESPONSES_IMAGE_COUNT_RANGE = { min: 1, max: 1 } as const; +export const RESPONSES_PARTIAL_IMAGES_RANGE = { min: 1, max: 3 } as const; + +export class ImageUpstreamRangeError extends RangeError { + readonly rangeLabel: string; + readonly min: number; + readonly max: number; + + constructor(rangeLabel: string, min: number, max: number) { + super(`${rangeLabel}范围没有可用交集(min=${min}, max=${max})。`); + this.name = 'ImageUpstreamRangeError'; + this.rangeLabel = rangeLabel; + this.min = min; + this.max = max; + } +} export type ImageUpstreamProfile = { id: ImageUpstreamProfileId; providerManifest?: ImageProviderManifestSummary; - generateCount: { min: number; max: number }; - editCount: { min: number; max: number }; - partialImages: { min: number; max: number }; + generateCount: NumericRange; + editCount: NumericRange; + partialImages: NumericRange; upload: { maxImages: number; maxSingleBytes: number; @@ -38,6 +74,8 @@ export type ImageUpstreamProfileSummary = { activeProfile: ImageUpstreamProfileId; serverProfile: ImageUpstreamProfileId; serverProfileMixed: boolean; + serverConstraintsMixed: boolean; + serverConstraintsByProfile: ImageUpstreamProfile[]; requestProfile: ImageUpstreamProfileId; activeConstraints: ImageUpstreamProfile; serverConstraints: ImageUpstreamProfile; @@ -54,7 +92,7 @@ export const IMAGE_UPSTREAM_PROFILES: Record { + const candidateDistance = Math.abs(candidate - value); + const closestDistance = Math.abs(closest - value); + return candidateDistance < closestDistance || (candidateDistance === closestDistance && candidate < closest) + ? candidate + : closest; + }); +} + +export function isIntegerWithinRange(value: number, range: NumericRange): boolean { + return ( + Number.isInteger(value) && + value >= range.min && + value <= range.max && + (!range.allowedValues || range.allowedValues.includes(value)) + ); +} + +export function resolveImageBackendSelection( + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null +): ImageGenerationBackend | undefined { + return imageBackend === 'server-default' ? (defaultBackend ?? undefined) : imageBackend; +} + +export function getImageCountRangeForBackend( + profile: Pick, + operation: ImageRequestOperation, + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null +): NumericRange { + const profileRange = normalizePositiveImageCountRange( + operation === 'generate' ? profile.generateCount : profile.editCount + ); + const effectiveBackend = resolveImageBackendSelection(imageBackend, defaultBackend); + if (effectiveBackend !== 'responses-image-generation') return profileRange; + return intersectRanges([profileRange, RESPONSES_IMAGE_COUNT_RANGE], '图片数量'); +} + +export function getImageCountRangeCompatibilityForBackend( + profile: Pick, + operation: ImageRequestOperation, + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null +): ImageBackendRangeCompatibility { + try { + return { + compatible: true, + range: getImageCountRangeForBackend(profile, operation, imageBackend, defaultBackend) + }; + } catch (error) { + if (error instanceof ImageUpstreamRangeError) return { compatible: false, error }; + throw error; + } } export function getPartialImagesRangeForBackend( profile: Pick, - imageBackend: ImageGenerationBackend | 'server-default' + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null ): NumericRange { - if (imageBackend !== 'responses-image-generation') return profile.partialImages; - return intersectRanges([profile.partialImages, { min: 1, max: 3 }]); + const effectiveBackend = resolveImageBackendSelection(imageBackend, defaultBackend); + if (effectiveBackend !== 'responses-image-generation') return profile.partialImages; + return intersectRanges([profile.partialImages, RESPONSES_PARTIAL_IMAGES_RANGE], 'partial_images'); +} + +export function getPartialImagesRangeCompatibilityForBackend( + profile: Pick, + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null +): ImageBackendRangeCompatibility { + try { + return { + compatible: true, + range: getPartialImagesRangeForBackend(profile, imageBackend, defaultBackend) + }; + } catch (error) { + if (error instanceof ImageUpstreamRangeError) return { compatible: false, error }; + throw error; + } +} + +export function getImageBackendCompatibility( + profile: Pick, + operation: ImageRequestOperation, + imageBackend: ImageBackendSelection, + defaultBackend?: ImageGenerationBackend | null, + options: ImageBackendCompatibilityOptions = {} +): ImageBackendCompatibility { + const imageCount = getImageCountRangeCompatibilityForBackend(profile, operation, imageBackend, defaultBackend); + const partialImages = + options.validatePartialImages === false + ? { compatible: true as const, range: profile.partialImages } + : getPartialImagesRangeCompatibilityForBackend(profile, imageBackend, defaultBackend); + const errors = [ + ...(imageCount.compatible ? [] : [imageCount.error]), + ...(partialImages.compatible ? [] : [partialImages.error]) + ]; + return { + compatible: errors.length === 0, + ...(imageCount.compatible ? { imageCountRange: imageCount.range } : {}), + ...(partialImages.compatible ? { partialImagesRange: partialImages.range } : {}), + errors + }; } export function normalizeImageUpstreamProfileId(value: string | undefined): ImageUpstreamProfileId | undefined { @@ -136,6 +271,7 @@ export function summarizeImageUpstreamProfile(input: { const serverProfileIds = input.serverProfileIds ?? input.serverProfiles?.map((profile) => profile.id) ?? []; const serverProfiles = input.serverProfiles ?? serverProfileIds.map((id) => IMAGE_UPSTREAM_PROFILES[id]); const serverProfileMixed = hasMixedServerProfiles(serverProfiles); + const serverConstraintsMixed = hasDisjointServerConstraints(serverProfiles); const uniqueServerProfiles = Array.from(new Set(serverProfiles.map((profile) => profile.id))); const serverProfile = uniqueServerProfiles.length === 1 ? uniqueServerProfiles[0] : DEFAULT_IMAGE_UPSTREAM_PROFILE_ID; @@ -145,6 +281,8 @@ export function summarizeImageUpstreamProfile(input: { activeProfile: input.requestApiBaseUrl?.trim() ? requestProfile : serverProfile, serverProfile, serverProfileMixed, + serverConstraintsMixed, + serverConstraintsByProfile: serverProfiles, requestProfile, activeConstraints, serverConstraints, @@ -168,9 +306,9 @@ export function combineImageUpstreamProfiles(profiles: ImageUpstreamProfile[]): const maxTotalBytes = minDefined(profiles.map((profile) => profile.upload.maxTotalBytes)); return { id, - generateCount: intersectRanges(profiles.map((profile) => profile.generateCount)), - editCount: intersectRanges(profiles.map((profile) => profile.editCount)), - partialImages: intersectRanges(profiles.map((profile) => profile.partialImages)), + generateCount: intersectRangesOrUnion(profiles.map((profile) => profile.generateCount)), + editCount: intersectRangesOrUnion(profiles.map((profile) => profile.editCount)), + partialImages: intersectRangesOrUnion(profiles.map((profile) => profile.partialImages)), upload: { maxImages: Math.min(...profiles.map((profile) => profile.upload.maxImages)), maxSingleBytes: Math.min(...profiles.map((profile) => profile.upload.maxSingleBytes)), @@ -185,11 +323,48 @@ export function combineImageUpstreamProfiles(profiles: ImageUpstreamProfile[]): }; } -function intersectRanges(ranges: Array<{ min: number; max: number }>): { min: number; max: number } { - return { - min: Math.max(...ranges.map((range) => range.min)), - max: Math.min(...ranges.map((range) => range.max)) - }; +function intersectRanges(ranges: NumericRange[], rangeLabel: string): NumericRange { + const min = Math.max(...ranges.map((range) => range.min)); + const max = Math.min(...ranges.map((range) => range.max)); + if (min > max) throw new ImageUpstreamRangeError(rangeLabel, min, max); + const values = ranges.reduce((intersection, range) => { + const rangeValues = buildIntegerRangeOptions(range).filter((value) => value >= min && value <= max); + if (intersection === undefined) return rangeValues; + return intersection.filter((value) => rangeValues.includes(value)); + }, undefined); + if (!values?.length) throw new ImageUpstreamRangeError(rangeLabel, min, max); + return numericRangeFromValues(values); +} + +function intersectRangesOrUnion(ranges: NumericRange[]): NumericRange { + const min = Math.max(...ranges.map((range) => range.min)); + const max = Math.min(...ranges.map((range) => range.max)); + if (min <= max) { + const intersection = ranges.reduce((values, range) => { + const rangeValues = buildIntegerRangeOptions(range).filter((value) => value >= min && value <= max); + return values === undefined ? rangeValues : values.filter((value) => rangeValues.includes(value)); + }, undefined); + if (intersection?.length) return numericRangeFromValues(intersection); + } + return numericRangeFromValues(ranges.flatMap((range) => buildIntegerRangeOptions(range))); +} + +function normalizePositiveImageCountRange(range: NumericRange): NumericRange { + const values = buildIntegerRangeOptions(range).filter((value) => value >= 1); + if (!values.length) { + const min = Math.max(1, range.min); + throw new ImageUpstreamRangeError('图片数量', min, range.max); + } + return numericRangeFromValues(values); +} + +function numericRangeFromValues(values: number[]): NumericRange { + const normalized = Array.from(new Set(values.filter(Number.isInteger))).sort((left, right) => left - right); + if (!normalized.length) throw new ImageUpstreamRangeError('数值', 1, 0); + const min = normalized[0]; + const max = normalized[normalized.length - 1]; + if (normalized.length === max - min + 1) return { min, max }; + return { min, max, allowedValues: normalized }; } function minDefined(values: Array): number | undefined { @@ -205,6 +380,23 @@ function hasMixedServerProfiles(profiles: ImageUpstreamProfile[]): boolean { return remainingProfiles.some((profile) => imageUpstreamProfileSignature(profile) !== firstSignature); } +function hasDisjointServerConstraints(profiles: ImageUpstreamProfile[]): boolean { + if (profiles.length <= 1) return false; + return ( + hasDisjointRanges(profiles.map((profile) => profile.generateCount)) || + hasDisjointRanges(profiles.map((profile) => profile.editCount)) || + hasDisjointRanges(profiles.map((profile) => profile.partialImages)) + ); +} + +function hasDisjointRanges(ranges: Array<{ min: number; max: number }>): boolean { + const intersection = ranges.reduce((values, range) => { + const rangeValues = buildIntegerRangeOptions(range); + return values === undefined ? rangeValues : values.filter((value) => rangeValues.includes(value)); + }, undefined); + return !intersection?.length; +} + function imageUpstreamProfileSignature(profile: ImageUpstreamProfile): string { return JSON.stringify({ id: profile.id, diff --git a/src/lib/image-upstream-provider-manifest.test.ts b/src/lib/image-upstream-provider-manifest.test.ts index 35efa10ab943cd362fb1d970bd908ee991eeb248..aa2e1d07e530fbbf2ee7900238f358411b802105 100644 --- a/src/lib/image-upstream-provider-manifest.test.ts +++ b/src/lib/image-upstream-provider-manifest.test.ts @@ -140,6 +140,30 @@ describe('image upstream provider manifest', () => { }), /generate_count\.max 不能大于 10/ ); + assert.throws( + () => + validateImageProviderManifest({ + id: 'ok_provider', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { generate_count: { min: 0, max: 2 } } + }), + /constraints\.generate_count\.min 必须是正整数/ + ); + assert.throws( + () => + validateImageProviderManifest({ + id: 'ok_provider', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { edit_count: { min: 0, max: 2 } } + }), + /constraints\.edit_count\.min 必须是正整数/ + ); + const zeroPartialImages = validateImageProviderManifest({ + id: 'ok_provider', + modes: { generate: { submit: { path: '/images/generations' } } }, + constraints: { partial_images: { min: 0, max: 0 } } + }); + assert.deepEqual(zeroPartialImages.constraints?.partial_images, { min: 0, max: 0 }); assert.throws( () => validateImageProviderManifest({ diff --git a/src/lib/image-upstream-provider-manifest.ts b/src/lib/image-upstream-provider-manifest.ts index 32e7e2aae9333be9210a4fafba7f7e7600bd41f7..d2a054a79e58b7827771738cca7b33b8e38aa92a 100644 --- a/src/lib/image-upstream-provider-manifest.ts +++ b/src/lib/image-upstream-provider-manifest.ts @@ -128,9 +128,12 @@ export function validateImageProviderManifest(value: unknown): ImageProviderMani const baseUrl = readOptionalString(manifest, 'base_url'); if (baseUrl) validateManifestBaseUrl(baseUrl); const constraints = readConstraintsField(manifest.constraints).constraints; - if (constraints?.generate_count) validateRangeConstraint(constraints.generate_count, 'constraints.generate_count'); - if (constraints?.edit_count) validateRangeConstraint(constraints.edit_count, 'constraints.edit_count'); - if (constraints?.partial_images) validateRangeConstraint(constraints.partial_images, 'constraints.partial_images'); + if (constraints?.generate_count) + validateRangeConstraint(constraints.generate_count, 'constraints.generate_count', { allowZero: false }); + if (constraints?.edit_count) + validateRangeConstraint(constraints.edit_count, 'constraints.edit_count', { allowZero: false }); + if (constraints?.partial_images) + validateRangeConstraint(constraints.partial_images, 'constraints.partial_images', { allowZero: true }); if (constraints?.generate_count) validateRangeUpperBound(constraints.generate_count, 'constraints.generate_count', MAX_PROVIDER_IMAGE_COUNT); if (constraints?.edit_count) @@ -188,13 +191,17 @@ export function createProviderManifestProfile(manifest: ImageProviderManifest): generateCount: readRangeConstraint( constraints.generate_count, baseProfile.generateCount, - 'constraints.generate_count' + 'constraints.generate_count', + { allowZero: false } ), - editCount: readRangeConstraint(constraints.edit_count, baseProfile.editCount, 'constraints.edit_count'), + editCount: readRangeConstraint(constraints.edit_count, baseProfile.editCount, 'constraints.edit_count', { + allowZero: false + }), partialImages: readRangeConstraint( constraints.partial_images, baseProfile.partialImages, - 'constraints.partial_images' + 'constraints.partial_images', + { allowZero: true } ), upload: { maxImages: readPositiveIntegerConstraint( @@ -323,18 +330,25 @@ function readResponseFormat(mode: ImageProviderModeManifest): ProviderResponseFo function readRangeConstraint( value: PartialRange | undefined, fallback: { min: number; max: number }, - fieldName: string + fieldName: string, + options: { allowZero: boolean } ): { min: number; max: number } { if (!value) return fallback; - const min = readPositiveIntegerConstraint(value.min, fallback.min, `${fieldName}.min`, { allowZero: true }); - const max = readPositiveIntegerConstraint(value.max, fallback.max, `${fieldName}.max`, { allowZero: true }); + const min = readPositiveIntegerConstraint(value.min, fallback.min, `${fieldName}.min`, options); + const max = readPositiveIntegerConstraint(value.max, fallback.max, `${fieldName}.max`, options); if (min > max) { throw new RequestValidationError(`${fieldName}.min 不能大于 max。`, 500); } return { min, max }; } -function validateRangeConstraint(value: PartialRange, fieldName: string): void { +function validateRangeConstraint(value: PartialRange, fieldName: string, options: { allowZero: boolean }): void { + if (!options.allowZero && value.min !== undefined && value.min < 1) { + throw new RequestValidationError(`${fieldName}.min 必须是正整数。`, 500); + } + if (!options.allowZero && value.max !== undefined && value.max < 1) { + throw new RequestValidationError(`${fieldName}.max 必须是正整数。`, 500); + } if (value.min !== undefined && value.max !== undefined && value.min > value.max) { throw new RequestValidationError(`${fieldName}.min 不能大于 max。`, 500); } diff --git a/src/lib/image-url-result.test.ts b/src/lib/image-url-result.test.ts index cf04c501aa7eac8129e704736a90dc8aa4f37b76..cfae6f3a1b1d8670563547001a28016137cab068 100644 --- a/src/lib/image-url-result.test.ts +++ b/src/lib/image-url-result.test.ts @@ -39,7 +39,7 @@ describe('downloadSameOriginImageAsBase64', () => { assert.equal(result, Buffer.from('png').toString('base64')); assert.equal(observedAuthorization, 'Bearer test-key'); - assert.equal(observedUserAgent, 'visual-journal/2.2.0'); + assert.equal(observedUserAgent, 'visual-journal/2.3.0'); assert.equal(observedAppId, 'app-id'); assert.equal(observedAppSecret, 'app-secret'); }); @@ -69,7 +69,7 @@ describe('downloadSameOriginImageAsBase64', () => { }); assert.equal(observedProxyAuthorization, null); - assert.equal(observedUserAgent, 'visual-journal/2.2.0'); + assert.equal(observedUserAgent, 'visual-journal/2.3.0'); assert.equal(observedAppId, 'app-id'); }); diff --git a/src/lib/images-api-stream.test.ts b/src/lib/images-api-stream.test.ts index 29c891330aee3746f872d0792ef1d752bf4bf36e..03733cfd4e095f78b0d96693005deb04fdb78af6 100644 --- a/src/lib/images-api-stream.test.ts +++ b/src/lib/images-api-stream.test.ts @@ -51,7 +51,7 @@ describe('createImagesApiGenerateStream', () => { assert.equal(observedAuthorization, 'Bearer test-key'); assert.equal(observedAccept, 'text/event-stream, application/json'); assert.equal(observedContentType, 'application/json'); - assert.equal(observedUserAgent, 'visual-journal/2.2.0'); + assert.equal(observedUserAgent, 'visual-journal/2.3.0'); assert.equal(observedAppId, 'app-id'); assert.equal(observedIdempotencyKey, 'stream-idempotency-key'); }); diff --git a/src/lib/mobile-primary-action-state.test.ts b/src/lib/mobile-primary-action-state.test.ts index 81c9923a11be12976818205f5c294d8cd24ef1c2..604d9256f0398930c8f9c8983db1cb0534c1d675 100644 --- a/src/lib/mobile-primary-action-state.test.ts +++ b/src/lib/mobile-primary-action-state.test.ts @@ -1,3 +1,4 @@ +import { MAX_PROMPT_LENGTH } from './image-request-limits'; import { resolveMobilePrimaryDisabledReason } from './mobile-primary-action-state'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -7,7 +8,9 @@ const messages: Record = { 'sizeError.positive': '宽度和高度必须为正数。', 'upstream.responsesModelRequired': 'Responses image_generation 需要填写 GPT 顶层模型。', 'ux.disabledBatchPrompts': '请至少填写一条批量提示词。', + 'ux.disabledBatchPromptLength': '第 {index} 条批量提示词不能超过 {limit} 个字符。', 'ux.disabledPrompt': '请输入提示词后再提交。', + 'ux.disabledPromptLength': '提示词不能超过 {limit} 个字符。', 'ux.disabledSourceImage': '请先放入参考图。', 'ux.disabledUnsavedMask': '请先保存已绘制的蒙版。' }; @@ -33,7 +36,10 @@ function resolveReason(overrides: Partial): string { isBatchMode: false, prompt: '用户真实提示词', batchPromptCount: 0, + batchPromptOverLimitIndex: null, hasEditSourceImage: false, + editSourceValidationMessage: '', + backendCompatibilityMessage: '', hasUnsavedMask: false, imageBackend: 'server-default', responsesModel: '', @@ -64,6 +70,34 @@ describe('resolveMobilePrimaryDisabledReason', () => { assert.equal(reason, '请至少填写一条批量提示词。'); }); + it('blocks an oversized single prompt with the same limit as the form', () => { + const reason = resolveReason({ + prompt: 'a'.repeat(MAX_PROMPT_LENGTH + 1) + }); + + assert.equal(reason, `提示词不能超过 ${MAX_PROMPT_LENGTH} 个字符。`); + }); + + it('identifies the oversized line in a mobile batch request', () => { + const reason = resolveReason({ + isBatchMode: true, + batchPromptCount: 2, + batchPromptOverLimitIndex: 1 + }); + + assert.equal(reason, `第 2 条批量提示词不能超过 ${MAX_PROMPT_LENGTH} 个字符。`); + }); + + it('does not apply the aggregate prompt limit to valid mobile batch lines', () => { + const reason = resolveReason({ + isBatchMode: true, + prompt: `${'a'.repeat(20_000)}\n${'b'.repeat(20_000)}`, + batchPromptCount: 2 + }); + + assert.equal(reason, ''); + }); + it('explains missing reference images before edit prompt issues', () => { const reason = resolveReason({ mode: 'edit', @@ -74,6 +108,25 @@ describe('resolveMobilePrimaryDisabledReason', () => { assert.equal(reason, '请先放入参考图。'); }); + it('uses the same edit upload validation message as the desktop submit control', () => { + const reason = resolveReason({ + mode: 'edit', + prompt: '用户真实编辑要求', + hasEditSourceImage: true, + editSourceValidationMessage: '单张参考图不能超过 25 MB。' + }); + + assert.equal(reason, '单张参考图不能超过 25 MB。'); + }); + + it('blocks a backend with incompatible provider constraints before submission', () => { + const reason = resolveReason({ + backendCompatibilityMessage: '图片数量范围没有可用交集(min=2, max=1)。' + }); + + assert.equal(reason, '图片数量范围没有可用交集(min=2, max=1)。'); + }); + it('surfaces custom size validation after required content is present', () => { const reason = resolveReason({ generateSizeValidation: invalidSize }); diff --git a/src/lib/mobile-primary-action-state.ts b/src/lib/mobile-primary-action-state.ts index e9fb855a6da323b97f45383b54b69cc7534936c8..d9bd346ec512406d08dee749fcd4af9958f806ea 100644 --- a/src/lib/mobile-primary-action-state.ts +++ b/src/lib/mobile-primary-action-state.ts @@ -1,3 +1,4 @@ +import { MAX_PROMPT_LENGTH } from './image-request-limits'; import type { ImageUpstreamFormBackend } from './image-upstream-form'; import type { SizeValidation } from './size-utils'; @@ -10,8 +11,11 @@ type MobilePrimaryDisabledReasonOptions = { isBatchMode: boolean; prompt: string; batchPromptCount: number; + batchPromptOverLimitIndex: number | null; hasEditSourceImage: boolean; + editSourceValidationMessage: string; hasUnsavedMask: boolean; + backendCompatibilityMessage: string; imageBackend: ImageUpstreamFormBackend; responsesModel: string; hasDefaultResponsesModel: boolean; @@ -37,7 +41,12 @@ export function resolveMobilePrimaryDisabledReason(options: MobilePrimaryDisable if (options.mode === 'edit') { if (!options.hasEditSourceImage) return options.t('ux.disabledSourceImage'); + if (options.editSourceValidationMessage) return options.editSourceValidationMessage; + if (options.backendCompatibilityMessage) return options.backendCompatibilityMessage; if (!options.prompt.trim()) return options.t('ux.disabledPrompt'); + if (options.prompt.length > MAX_PROMPT_LENGTH) { + return options.t('ux.disabledPromptLength', { limit: MAX_PROMPT_LENGTH }); + } if (options.hasUnsavedMask) return options.t('ux.disabledUnsavedMask'); const responsesModelReason = readResponsesModelReason(options); if (responsesModelReason) return responsesModelReason; @@ -47,7 +56,17 @@ export function resolveMobilePrimaryDisabledReason(options: MobilePrimaryDisable if (options.isBatchMode && options.batchPromptCount === 0) { return options.t('ux.disabledBatchPrompts'); } + if (options.isBatchMode && options.batchPromptOverLimitIndex !== null) { + return options.t('ux.disabledBatchPromptLength', { + index: options.batchPromptOverLimitIndex + 1, + limit: MAX_PROMPT_LENGTH + }); + } + if (options.backendCompatibilityMessage) return options.backendCompatibilityMessage; if (!options.prompt.trim()) return options.t('ux.disabledPrompt'); + if (!options.isBatchMode && options.prompt.length > MAX_PROMPT_LENGTH) { + return options.t('ux.disabledPromptLength', { limit: MAX_PROMPT_LENGTH }); + } const responsesModelReason = readResponsesModelReason(options); if (responsesModelReason) return responsesModelReason; return readSizeValidationReason(options.generateSizeValidation, options.t); diff --git a/src/lib/responses-image-backend.ts b/src/lib/responses-image-backend.ts index 8f47dc33cca90a39f445b97732addbcaebefd522..89b245df9d12aad2330fe380684cf9373b4ff63a 100644 --- a/src/lib/responses-image-backend.ts +++ b/src/lib/responses-image-backend.ts @@ -1,13 +1,12 @@ import { extractImageBase64FromDataUrl, isRemoteHttpUrl, readResponsesImageResultBase64 } from './image-payload'; -import { MAX_UPLOAD_BYTES, RequestValidationError } from './image-request-utils'; +import { MAX_RESPONSES_EDIT_INPUT_BYTES } from './image-request-limits'; +import { RequestValidationError } from './image-request-utils'; import { asRecord } from './json-record'; import { buildOpenAIImageRequestOptions } from './openai-image-transport'; import type OpenAI from 'openai'; type ImageUsage = OpenAI.Images.ImagesResponse['usage']; type OpenAiImageData = NonNullable[number]; -const MAX_RESPONSES_EDIT_INPUT_BYTES = MAX_UPLOAD_BYTES * 2; - type ResponsesCreateClient = { create( params: OpenAI.Responses.ResponseCreateParamsNonStreaming, diff --git a/src/lib/result-action-state.test.ts b/src/lib/result-action-state.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0039f1b7c963c2d2ce79bf0f02ab2a4033155cb --- /dev/null +++ b/src/lib/result-action-state.test.ts @@ -0,0 +1,80 @@ +import { resolveResultActionState } from './result-action-state'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('resolveResultActionState', () => { + it('allows a completed text-to-image result to be generated again', () => { + assert.deepEqual( + resolveResultActionState({ + isBusy: false, + hasResultImages: true, + currentMode: 'generate', + currentPrompt: '当前提示词', + activeResultSource: { mode: 'generate', prompt: '历史提示词' } + }), + { canCreateVariant: true, canReusePrompt: true } + ); + }); + + it('does not turn an image-edit result into a text-to-image variant', () => { + assert.deepEqual( + resolveResultActionState({ + isBusy: false, + hasResultImages: true, + currentMode: 'edit', + currentPrompt: '当前编辑提示词', + activeResultSource: { mode: 'edit', prompt: '历史编辑提示词' } + }), + { canCreateVariant: false, canReusePrompt: true } + ); + }); + + it('does not substitute an unrelated current prompt when the selected history result has no prompt', () => { + assert.deepEqual( + resolveResultActionState({ + isBusy: false, + hasResultImages: true, + currentMode: 'generate', + currentPrompt: '不应被借用的当前提示词', + activeResultSource: { mode: 'generate', prompt: ' ' } + }), + { canCreateVariant: false, canReusePrompt: false } + ); + }); + + it('only enables a source-less result when the current form is text-to-image and ready', () => { + assert.equal( + resolveResultActionState({ + isBusy: false, + hasResultImages: true, + currentMode: 'edit', + currentPrompt: '编辑提示词', + activeResultSource: null + }).canCreateVariant, + false + ); + assert.equal( + resolveResultActionState({ + isBusy: false, + hasResultImages: true, + currentMode: 'generate', + currentPrompt: '生成提示词', + activeResultSource: null + }).canCreateVariant, + true + ); + }); + + it('disables both result actions while a request or edit-source transfer is in progress', () => { + assert.deepEqual( + resolveResultActionState({ + isBusy: true, + hasResultImages: true, + currentMode: 'generate', + currentPrompt: '当前提示词', + activeResultSource: null + }), + { canCreateVariant: false, canReusePrompt: false } + ); + }); +}); diff --git a/src/lib/result-action-state.ts b/src/lib/result-action-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c46bbb8822693660eced38aa17b6e2f567d1b55 --- /dev/null +++ b/src/lib/result-action-state.ts @@ -0,0 +1,28 @@ +type ResultActionSource = { + mode: 'generate' | 'edit'; + prompt: string; +} | null; + +type ResolveResultActionStateInput = { + isBusy: boolean; + hasResultImages: boolean; + currentMode: 'generate' | 'edit'; + currentPrompt: string; + activeResultSource: ResultActionSource; +}; + +export function resolveResultActionState(input: ResolveResultActionStateInput): { + canCreateVariant: boolean; + canReusePrompt: boolean; +} { + const sourcePrompt = input.activeResultSource?.prompt ?? input.currentPrompt; + const hasReusablePrompt = sourcePrompt.trim().length > 0; + const isGenerationResult = input.activeResultSource + ? input.activeResultSource.mode === 'generate' + : input.currentMode === 'generate'; + + return { + canCreateVariant: !input.isBusy && input.hasResultImages && isGenerationResult && hasReusablePrompt, + canReusePrompt: !input.isBusy && hasReusablePrompt + }; +} diff --git a/src/lib/size-utils.test.ts b/src/lib/size-utils.test.ts index 07d93499494aa8eabe4247da4a83d9d2c6a258e5..d233a541d9dad4c954eed291a0ef6ccb1ac0af66 100644 --- a/src/lib/size-utils.test.ts +++ b/src/lib/size-utils.test.ts @@ -1,5 +1,13 @@ import { IMAGE_UPSTREAM_PROFILES } from './image-upstream-profile'; -import { getPresetDimensions, getPresetTooltip, getSizePresetOptions } from './size-utils'; +import { + formatExactImagePixelCount, + getPresetDimensions, + getPresetTooltip, + getSizePresetOptions, + readImageSizeNumberInput, + validateGptImage2Size, + validatePositiveIntegerImageSize +} from './size-utils'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -46,3 +54,38 @@ describe('size presets', () => { assert.match(getPresetTooltip('square-2k', 'gpt-image-2') || '', /1:1/); }); }); + +describe('image size numeric boundaries', () => { + it('rejects dimensions outside the safe integer range for every gpt-image-2 policy', () => { + const unsafeWidth = Number.MAX_SAFE_INTEGER + 1; + + const positiveIntegerResult = validatePositiveIntegerImageSize(unsafeWidth, 1); + assert.equal(positiveIntegerResult.valid, false); + if (!positiveIntegerResult.valid) assert.equal(positiveIntegerResult.reasonKey, 'sizeError.safeInteger'); + + const openAiCompatibleResult = validateGptImage2Size(unsafeWidth, 1); + assert.equal(openAiCompatibleResult.valid, false); + if (!openAiCompatibleResult.valid) assert.equal(openAiCompatibleResult.reasonKey, 'sizeError.safeInteger'); + }); + + it('keeps decimal dimensions distinct from unsafe integer dimensions', () => { + const result = validatePositiveIntegerImageSize(12.5, 1); + + assert.equal(result.valid, false); + if (!result.valid) assert.equal(result.reasonKey, 'sizeError.whole'); + }); + + it('preserves decimal input for validation instead of silently truncating it', () => { + assert.equal(readImageSizeNumberInput('12.5'), 12.5); + assert.equal(readImageSizeNumberInput('1e3'), 1000); + assert.equal(readImageSizeNumberInput(''), 0); + }); + + it('formats large safe dimension products without losing precision', () => { + const width = Number.MAX_SAFE_INTEGER; + const expected = (BigInt(width) * BigInt(width)).toLocaleString('en-US'); + + assert.equal(formatExactImagePixelCount(width, width, 'en-US'), expected); + assert.equal(formatExactImagePixelCount(Number.MAX_SAFE_INTEGER + 1, 1, 'en-US'), null); + }); +}); diff --git a/src/lib/size-utils.ts b/src/lib/size-utils.ts index d43229650bffa74686e3af07e72ce2fd26151fd9..d843a4bfbe55c8cb9a521656df264a23e26f63e6 100755 --- a/src/lib/size-utils.ts +++ b/src/lib/size-utils.ts @@ -27,6 +27,13 @@ export function validateGptImage2Size(width: number, height: number): SizeValida reasonKey: 'sizeError.whole' }; } + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) { + return { + valid: false, + reason: '宽度和高度超出可精确处理的整数范围。', + reasonKey: 'sizeError.safeInteger' + }; + } if (width % GPT_IMAGE_2_EDGE_MULTIPLE !== 0 || height % GPT_IMAGE_2_EDGE_MULTIPLE !== 0) { return { valid: false, @@ -88,9 +95,28 @@ export function validatePositiveIntegerImageSize(width: number, height: number): reasonKey: 'sizeError.whole' }; } + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) { + return { + valid: false, + reason: '宽度和高度超出可精确处理的整数范围。', + reasonKey: 'sizeError.safeInteger' + }; + } return { valid: true }; } +export function formatExactImagePixelCount(width: number, height: number, locale: string): string | null { + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) { + return null; + } + return (BigInt(width) * BigInt(height)).toLocaleString(locale); +} + +export function readImageSizeNumberInput(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + export type SizePreset = | 'auto' | 'custom' diff --git a/src/lib/upstream-request-headers.test.ts b/src/lib/upstream-request-headers.test.ts index 2378be6b7bf555cfc0d1492c82c8ea564c090f6d..e53c7df67d3c48164b2a88b7a24faa81ec18936e 100644 --- a/src/lib/upstream-request-headers.test.ts +++ b/src/lib/upstream-request-headers.test.ts @@ -10,7 +10,7 @@ import { describe, it } from 'node:test'; describe('upstream request headers', () => { it('uses a stable product User-Agent by default', () => { assert.deepEqual(buildDefaultUpstreamHeaders({}), { - 'User-Agent': 'visual-journal/2.2.0' + 'User-Agent': 'visual-journal/2.3.0' }); }); diff --git a/src/test-utils/react-dom.ts b/src/test-utils/react-dom.ts index 5282abb870ae88f154fa6c20eb14903add44b326..ec15270d34f9d3c3a96f9ec1691a54a3af968ed2 100644 --- a/src/test-utils/react-dom.ts +++ b/src/test-utils/react-dom.ts @@ -92,6 +92,13 @@ function restoreClientDomGlobals(descriptors: Map { const window = new Window({ url: 'http://localhost' }); const descriptors = setClientDomGlobals(window); + installClientDomLayoutStyles(window); const happyDomContainer = window.document.createElement('div'); window.document.body.append(happyDomContainer); const container = happyDomContainer as unknown as HTMLDivElement; diff --git a/vendor/brace-expansion-compat/package.json b/vendor/brace-expansion-compat/package.json index 8d3a910ea9b4fece4476172a85059b7390549142..45752c6b07f118cecdad461c7c55853d2b1f6a2c 100644 --- a/vendor/brace-expansion-compat/package.json +++ b/vendor/brace-expansion-compat/package.json @@ -1,7 +1,7 @@ { "name": "brace-expansion", - "version": "5.0.8", - "description": "Compatibility facade for brace-expansion 5.0.8", + "version": "5.0.9", + "description": "Compatibility facade for brace-expansion 5.0.9", "private": true, "license": "MIT", "type": "module", @@ -25,6 +25,6 @@ "node": ">=20" }, "dependencies": { - "brace-expansion-modern": "npm:brace-expansion@5.0.8" + "brace-expansion-modern": "npm:brace-expansion@5.0.9" } }