Spaces:
Running
Running
Visual Journal deploy commited on
Commit ·
4588c8c
1
Parent(s): e4e0afe
Deploy 9d61ce9 to Docker Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.agent.example +3 -3
- .env.agent.local.example +13 -13
- .env.example +23 -23
- .env.real-smoke.example +14 -14
- .github/workflows/ci.yml +2 -1
- AGENTS.md +20 -142
- CHANGELOG.md +50 -18
- README.md +33 -33
- docs/deployment/huggingface-space-free.md +44 -44
- docs/product/image-provider-manifest.md +25 -27
- docs/product/product-contract.md +9 -9
- docs/product/user-validation-script.md +3 -1
- docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +0 -34
- docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md +0 -38
- docs/reviews/CR-IMAGE-STREAM-BACKENDS-2026-05-19.md +0 -44
- docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md +0 -189
- docs/reviews/CR-MAINTENANCE-CLEANUP-2026-07-27.md +0 -52
- docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md +0 -61
- docs/reviews/CR-SKILL-API-BOUNDARY-2026-06-08.md +0 -35
- docs/reviews/PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20.md +0 -20
- docs/superpowers/plans/2026-05-12-agent-api-skill.md +0 -20
- docs/superpowers/plans/2026-05-15-image-download-share.md +0 -1509
- docs/superpowers/plans/2026-06-06-product-improvement-stage-1.md +0 -793
- docs/superpowers/plans/2026-07-16-webui-image-auto-cleanup.md +0 -464
- docs/superpowers/plans/2026-07-17-webui-permanent-image-retention.md +0 -378
- docs/superpowers/specs/2026-07-16-webui-image-auto-cleanup-design.md +0 -87
- docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md +0 -91
- docs/ui/literary-young-women-workbench-design.md +18 -18
- package-lock.json +15 -15
- package.json +4 -2
- public/hf-space-deploy-marker.json +3 -3
- scripts/agent-doctor.mjs +3 -3
- scripts/agent-skill-scripts.test.mjs +141 -29
- scripts/channel-capability-matrix.test.mjs +2 -2
- scripts/check-version-metadata.mjs +1 -1
- scripts/dimension-check.test.mjs +1 -1
- scripts/first-run.mjs +1 -1
- scripts/image-dimensions.test.mjs +1 -1
- scripts/page-form-streaming.test.mjs +1 -1
- scripts/product-branding.test.mjs +7 -0
- scripts/smoke-hf-space-memory.mjs +2 -2
- scripts/smoke-image-upstream-real.mjs +84 -3
- scripts/smoke-image-upstream-real.test.mjs +23 -5
- scripts/status.mjs +1 -1
- skills/gpt-image-playground-agent/agents/openai.yaml +0 -4
- skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +0 -304
- skills/{gpt-image-playground-agent → visual-journal-image-agent}/SKILL.md +46 -42
- skills/visual-journal-image-agent/agents/openai.yaml +4 -0
- skills/{gpt-image-playground-agent → visual-journal-image-agent}/references/api.md +35 -33
- skills/{gpt-image-playground-agent → visual-journal-image-agent}/scripts/batch-images.mjs +36 -17
.env.agent.example
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
# Agent API 访问令牌。正式部署请使用足够长的随机值。
|
| 2 |
AGENT_API_TOKEN=
|
| 3 |
|
| 4 |
-
# 状态后端:HF Space 免费层或临时演示用 memory,本地单实例用 sqlite,高并发状态用 postgres。多实例还需共享 generated-images。
|
| 5 |
AGENT_STATE_BACKEND=sqlite
|
| 6 |
|
| 7 |
-
# SQLite 默认状态文件。保留在 generated-images 下,方便 Docker
|
| 8 |
AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
|
| 9 |
|
| 10 |
-
# PostgreSQL 配置。Docker Compose 模式优先使用
|
| 11 |
# GPT_IMAGE_POSTGRES_PASSWORD=<database-password>
|
| 12 |
# AGENT_DB_HOST=postgres
|
| 13 |
# AGENT_DB_PORT=5432
|
|
|
|
| 1 |
# Agent API 访问令牌。正式部署请使用足够长的随机值。
|
| 2 |
AGENT_API_TOKEN=
|
| 3 |
|
| 4 |
+
# 状态后端:HF Space 免费层或临时演示使用 memory,本地单实例使用 sqlite,高并发状态使用 postgres。多实例还需共享 generated-images。
|
| 5 |
AGENT_STATE_BACKEND=sqlite
|
| 6 |
|
| 7 |
+
# SQLite 默认状态文件。保留在 generated-images 下,方便 Docker 数据卷备份时同时包含元数据。
|
| 8 |
AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
|
| 9 |
|
| 10 |
+
# PostgreSQL 配置。Docker Compose 模式优先使用密钥文件;手动部署也可以直接提供 AGENT_DATABASE_URL。
|
| 11 |
# GPT_IMAGE_POSTGRES_PASSWORD=<database-password>
|
| 12 |
# AGENT_DB_HOST=postgres
|
| 13 |
# AGENT_DB_PORT=5432
|
.env.agent.local.example
CHANGED
|
@@ -1,21 +1,21 @@
|
|
| 1 |
-
#
|
| 2 |
-
# Agent CLI
|
| 3 |
-
# Shell
|
| 4 |
-
#
|
| 5 |
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
GPT_IMAGE_PLAYGROUND_URL=http://localhost:4783
|
| 9 |
|
| 10 |
-
# Agent JSON
|
| 11 |
-
#
|
| 12 |
GPT_IMAGE_AGENT_TOKEN=
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
-
# GPT_IMAGE_AGENT_TOKEN
|
| 18 |
GPT_IMAGE_APP_PASSWORD_HASH=
|
| 19 |
|
| 20 |
-
#
|
| 21 |
GPT_IMAGE_SHARE_ACCESS_CODE=
|
|
|
|
| 1 |
+
# 复制为 .env.agent.local,并将真实文件保留为私有文件。
|
| 2 |
+
# Agent CLI 脚本会从当前仓库根目录自动读取此文件。
|
| 3 |
+
# Shell 环境变量仍具有更高优先级。
|
| 4 |
+
# 设置 GPT_IMAGE_AGENT_LOAD_ENV_FILE=0 可关闭自动加载。
|
| 5 |
|
| 6 |
+
# 使用准备验证的部署地址。对于 Space 或其他公网服务,
|
| 7 |
+
# 请显式设置远程 URL,避免子代理回退到 localhost。
|
| 8 |
GPT_IMAGE_PLAYGROUND_URL=http://localhost:4783
|
| 9 |
|
| 10 |
+
# 服务配置 AGENT_API_TOKEN 时,Agent JSON、Agent 编辑、任务、产物和诊断端点
|
| 11 |
+
# 使用此令牌。
|
| 12 |
GPT_IMAGE_AGENT_TOKEN=
|
| 13 |
|
| 14 |
+
# 页面 SSE 将页面访问码哈希作为名为 passwordHash 的表单字段发送。
|
| 15 |
+
# 当部署启用 APP_PASSWORD 时,--page-sse、大尺寸生成默认路径、Responses 后端
|
| 16 |
+
# 编辑和页面 SSE 批量任务都需要该字段。
|
| 17 |
+
# 仅设置 GPT_IMAGE_AGENT_TOKEN 不能满足页面 SSE 表单鉴权。
|
| 18 |
GPT_IMAGE_APP_PASSWORD_HASH=
|
| 19 |
|
| 20 |
+
# 供 generate-image.mjs --share 创建私密分享链接使用的 Agent 分享访问码。
|
| 21 |
GPT_IMAGE_SHARE_ACCESS_CODE=
|
.env.example
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# 可选:如果不想在网页右上角“API 设置”里填写,可以在这里配置默认值。
|
| 2 |
# 使用时复制本文件为 .env.local,再填写真实内容。
|
| 3 |
|
| 4 |
-
# OpenAI 或 OpenAI 兼容接口
|
| 5 |
OPENAI_API_KEY=
|
| 6 |
|
| 7 |
# 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。
|
|
@@ -14,26 +14,26 @@ OPENAI_API_BASE_URL=
|
|
| 14 |
# 仅支持无认证、无路径、无查询参数和无片段的 http:// 或 https:// 根代理地址;不支持 SOCKS。
|
| 15 |
# OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 16 |
|
| 17 |
-
# 可选:服务端多渠道多
|
| 18 |
-
# 页面右上角“API 设置”里手动填写的 API
|
| 19 |
#
|
| 20 |
# 路由策略:
|
| 21 |
-
# - sticky:默认值。按请求来源稳定映射到同一个渠道
|
| 22 |
-
# - round_robin:按请求顺序轮询所有渠道
|
| 23 |
-
# - random:每次随机选择一个渠道
|
| 24 |
# OPENAI_ROUTING_STRATEGY=sticky
|
| 25 |
# OPENAI_UPSTREAM_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse
|
| 26 |
# OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse
|
| 27 |
#
|
| 28 |
# 渠道配置规则:
|
| 29 |
# - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
|
| 30 |
-
# - ID 只用于日志排查,不会暴露 API
|
| 31 |
# - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。
|
| 32 |
-
# - BASE_URL 默认要求 https;本机
|
| 33 |
# - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
|
| 34 |
-
# - API_KEYS 支持一个或多个
|
| 35 |
-
# - REQUEST_MODES 可选,用于声明该渠道经真实
|
| 36 |
-
# 单
|
| 37 |
# 该白名单只供服务端路由和诊断使用;Agent 客户端仍只提交业务意图。
|
| 38 |
# 这些变量在服务启动时读取;修改后需要重启 Next.js、Docker 或 Space 服务才会生效。
|
| 39 |
# stream_mode=auto 默认由服务端按白名单和优先级选择;未配置优先级时按低费用非流式优先。
|
|
@@ -46,7 +46,7 @@ OPENAI_API_BASE_URL=
|
|
| 46 |
# 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。
|
| 47 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
| 48 |
# - PROXY_URL 可选,覆盖 OPENAI_UPSTREAM_PROXY_URL,仅用于该渠道的服务端上游请求。
|
| 49 |
-
# - API
|
| 50 |
#
|
| 51 |
# 示例:
|
| 52 |
# OPENAI_CHANNEL_1_ID=official
|
|
@@ -71,23 +71,23 @@ OPENAI_API_BASE_URL=
|
|
| 71 |
# OPENAI_CHANNEL_3_MATSCA_APP_ID=
|
| 72 |
# OPENAI_CHANNEL_3_MATSCA_APP_SECRET=
|
| 73 |
#
|
| 74 |
-
# 可选:远程明文 HTTP 兼容接口
|
| 75 |
# 多个完整 base URL 用英文逗号分隔,仅在确认网络边界安全时启用。
|
| 76 |
# OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http://your-internal-compatible-api.example.com/v1
|
| 77 |
|
| 78 |
# 可选:统一上游请求头。默认 User-Agent 为 visual-journal/<package-version>。
|
| 79 |
# 全局 UA 可用 OPENAI_UPSTREAM_USER_AGENT 或 UPSTREAM_USER_AGENT 覆盖;
|
| 80 |
-
# 单渠道 UA 用 OPENAI_CHANNEL_N_USER_AGENT 覆盖,安全
|
| 81 |
# OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON 配置。
|
| 82 |
-
# Authorization、Accept、Content-Type、Content-Length、Host 等协议头不能由
|
| 83 |
# OPENAI_UPSTREAM_USER_AGENT=visual-journal/customer
|
| 84 |
|
| 85 |
# 可选:并发流式批处理容量。
|
| 86 |
# 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
|
| 87 |
-
# 默认 sticky 路由按单个
|
| 88 |
# 渠道失败冷却默认关闭。显式启用后,key 出现鉴权、额度或限流类错误会短暂冷却;
|
| 89 |
# 渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
|
| 90 |
-
# 如果失败能关联到本次服务端
|
| 91 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 92 |
# OPENAI_CHANNEL_QUEUE_ENABLED=true
|
| 93 |
# OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS=420000
|
|
@@ -97,9 +97,9 @@ OPENAI_API_BASE_URL=
|
|
| 97 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=30000
|
| 98 |
#
|
| 99 |
# 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的
|
| 100 |
-
#
|
| 101 |
# 探测不调用 /images/generations,不触发生图费用;它只确认 host、鉴权和 models 端点恢复,
|
| 102 |
-
# 不能替代 Images/Responses/SSE 的真实
|
| 103 |
# OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true
|
| 104 |
# 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。
|
| 105 |
# OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true
|
|
@@ -129,7 +129,7 @@ OPENAI_API_BASE_URL=
|
|
| 129 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 130 |
# OPENAI_RESPONSES_API_MODEL=gpt-5.4
|
| 131 |
|
| 132 |
-
# 可选:独立真实上游
|
| 133 |
# 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。
|
| 134 |
# IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://original-new-api.example.com/v1
|
| 135 |
# IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
|
@@ -144,8 +144,8 @@ OPENAI_API_BASE_URL=
|
|
| 144 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
|
| 145 |
# IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1
|
| 146 |
# IMAGE_REAL_SMOKE_MATSCA_API_KEY=
|
| 147 |
-
# 独立真实
|
| 148 |
-
#
|
| 149 |
# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
| 150 |
|
| 151 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
|
@@ -156,7 +156,7 @@ APP_PASSWORD=
|
|
| 156 |
# GIP_BIND_HOST=0.0.0.0
|
| 157 |
# GIP_PORT=4783
|
| 158 |
|
| 159 |
-
# 可选:给 /api/agent/* 使用的 Bearer
|
| 160 |
# AGENT_API_TOKEN=
|
| 161 |
|
| 162 |
# 可选:Agent 状态后端。memory 适合临时演示,sqlite 适合单实例本地部署,postgres 适合状态并发;多实例还需共享 generated-images。
|
|
|
|
| 1 |
# 可选:如果不想在网页右上角“API 设置”里填写,可以在这里配置默认值。
|
| 2 |
# 使用时复制本文件为 .env.local,再填写真实内容。
|
| 3 |
|
| 4 |
+
# OpenAI 或 OpenAI 兼容接口密钥
|
| 5 |
OPENAI_API_KEY=
|
| 6 |
|
| 7 |
# 可选:OpenAI 兼容接口根地址,通常以 /v1 结尾。
|
|
|
|
| 14 |
# 仅支持无认证、无路径、无查询参数和无片段的 http:// 或 https:// 根代理地址;不支持 SOCKS。
|
| 15 |
# OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 16 |
|
| 17 |
+
# 可选:服务端多渠道多密钥配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。
|
| 18 |
+
# 页面右上角“API 设置”里手动填写的 API 密钥/API URL 仍然拥有最高优先级。
|
| 19 |
#
|
| 20 |
# 路由策略:
|
| 21 |
+
# - sticky:默认值。按请求来源稳定映射到同一个渠道密钥,适合减少同一用户跳渠道。
|
| 22 |
+
# - round_robin:按请求顺序轮询所有渠道密钥,适合简单均摊流量。
|
| 23 |
+
# - random:每次随机选择一个渠道密钥,适合轻量分散请求。
|
| 24 |
# OPENAI_ROUTING_STRATEGY=sticky
|
| 25 |
# OPENAI_UPSTREAM_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse
|
| 26 |
# OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse
|
| 27 |
#
|
| 28 |
# 渠道配置规则:
|
| 29 |
# - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
|
| 30 |
+
# - ID 只用于日志排查,不会暴露 API 密钥。
|
| 31 |
# - BASE_URL 是 OpenAI 兼容接口根地址,通常以 /v1 结尾。
|
| 32 |
+
# - BASE_URL 默认要求 https;本机回环 HTTP 可直接用于本地测试夹具。
|
| 33 |
# - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
|
| 34 |
+
# - API_KEYS 支持一个或多个密钥,多个密钥用英文逗号分隔。
|
| 35 |
+
# - REQUEST_MODES 可选,用于声明该渠道经真实冒烟验证可用的服务端请求方式。
|
| 36 |
+
# 单密钥旧配置可用 OPENAI_UPSTREAM_REQUEST_MODES 声明全局可用方式。
|
| 37 |
# 该白名单只供服务端路由和诊断使用;Agent 客户端仍只提交业务意图。
|
| 38 |
# 这些变量在服务启动时读取;修改后需要重启 Next.js、Docker 或 Space 服务才会生效。
|
| 39 |
# stream_mode=auto 默认由服务端按白名单和优先级选择;未配置优先级时按低费用非流式优先。
|
|
|
|
| 46 |
# 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。
|
| 47 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
| 48 |
# - PROXY_URL 可选,覆盖 OPENAI_UPSTREAM_PROXY_URL,仅用于该渠道的服务端上游请求。
|
| 49 |
+
# - API 密钥本身不要包含逗号。
|
| 50 |
#
|
| 51 |
# 示例:
|
| 52 |
# OPENAI_CHANNEL_1_ID=official
|
|
|
|
| 71 |
# OPENAI_CHANNEL_3_MATSCA_APP_ID=
|
| 72 |
# OPENAI_CHANNEL_3_MATSCA_APP_SECRET=
|
| 73 |
#
|
| 74 |
+
# 可选:远程明文 HTTP 兼容接口允许列表。默认只允许 HTTPS 和本机回环 HTTP。
|
| 75 |
# 多个完整 base URL 用英文逗号分隔,仅在确认网络边界安全时启用。
|
| 76 |
# OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http://your-internal-compatible-api.example.com/v1
|
| 77 |
|
| 78 |
# 可选:统一上游请求头。默认 User-Agent 为 visual-journal/<package-version>。
|
| 79 |
# 全局 UA 可用 OPENAI_UPSTREAM_USER_AGENT 或 UPSTREAM_USER_AGENT 覆盖;
|
| 80 |
+
# 单渠道 UA 用 OPENAI_CHANNEL_N_USER_AGENT 覆盖,安全额外请求头用
|
| 81 |
# OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON 配置。
|
| 82 |
+
# Authorization、Accept、Content-Type、Content-Length、Host 等协议头不能由额外请求头覆盖。
|
| 83 |
# OPENAI_UPSTREAM_USER_AGENT=visual-journal/customer
|
| 84 |
|
| 85 |
# 可选:并发流式批处理容量。
|
| 86 |
# 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
|
| 87 |
+
# 默认 sticky 路由按单个凭据容量推荐并发;round_robin/random 才会使用完整凭据池。
|
| 88 |
# 渠道失败冷却默认关闭。显式启用后,key 出现鉴权、额度或限流类错误会短暂冷却;
|
| 89 |
# 渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
|
| 90 |
+
# 如果失败能关联到本次服务端请求方式,只冷却对应请求方式,不误伤同渠道其他可用方式。
|
| 91 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 92 |
# OPENAI_CHANNEL_QUEUE_ENABLED=true
|
| 93 |
# OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS=420000
|
|
|
|
| 97 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=30000
|
| 98 |
#
|
| 99 |
# 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的
|
| 100 |
+
# 凭据、渠道和请求方式先通过后台 GET /models 探测,成功后才重新进入用户生图流量。
|
| 101 |
# 探测不调用 /images/generations,不触发生图费用;它只确认 host、鉴权和 models 端点恢复,
|
| 102 |
+
# 不能替代 Images/Responses/SSE 的真实冒烟验证;MAX_PER_TICK 用于限制探测流量。
|
| 103 |
# OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true
|
| 104 |
# 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。
|
| 105 |
# OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true
|
|
|
|
| 129 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 130 |
# OPENAI_RESPONSES_API_MODEL=gpt-5.4
|
| 131 |
|
| 132 |
+
# 可选:独立真实上游冒烟验证目标。默认不触发计费请求,必须显式运行 --allow-billable。
|
| 133 |
# 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。
|
| 134 |
# IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://original-new-api.example.com/v1
|
| 135 |
# IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
|
|
|
| 144 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
|
| 145 |
# IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1
|
| 146 |
# IMAGE_REAL_SMOKE_MATSCA_API_KEY=
|
| 147 |
+
# 独立真实冒烟验证目标使用页面外部凭据路径,只验证 Matsca 直连模式。
|
| 148 |
+
# 应用模式请通过 OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca 和 OPENAI_CHANNEL_N_MATSCA_APP_* 配置后运行服务端渠道冒烟验证。
|
| 149 |
# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
| 150 |
|
| 151 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
|
|
|
| 156 |
# GIP_BIND_HOST=0.0.0.0
|
| 157 |
# GIP_PORT=4783
|
| 158 |
|
| 159 |
+
# 可选:给 /api/agent/* 使用的 Bearer 令牌。公网或内网共享部署时建议设置。
|
| 160 |
# AGENT_API_TOKEN=
|
| 161 |
|
| 162 |
# 可选:Agent 状态后端。memory 适合临时演示,sqlite 适合单实例本地部署,postgres 适合状态并发;多实例还需共享 generated-images。
|
.env.real-smoke.example
CHANGED
|
@@ -1,32 +1,32 @@
|
|
| 1 |
-
#
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
# npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable
|
| 7 |
|
| 8 |
-
# Original QuantumNous/new-api
|
| 9 |
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=
|
| 10 |
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
| 11 |
# IMAGE_REAL_SMOKE_ORIGINAL_MODEL=gpt-image-2
|
| 12 |
# IMAGE_REAL_SMOKE_ORIGINAL_SIZE=1024x1024
|
| 13 |
# IMAGE_REAL_SMOKE_ORIGINAL_QUALITY=low
|
| 14 |
|
| 15 |
-
# gaoren002/new-api Images API SSE
|
| 16 |
IMAGE_REAL_SMOKE_GAOREN_BASE_URL=
|
| 17 |
IMAGE_REAL_SMOKE_GAOREN_API_KEY=
|
| 18 |
# IMAGE_REAL_SMOKE_GAOREN_MODEL=gpt-image-2
|
| 19 |
# IMAGE_REAL_SMOKE_GAOREN_SIZE=1024x1024
|
| 20 |
# IMAGE_REAL_SMOKE_GAOREN_QUALITY=low
|
| 21 |
|
| 22 |
-
# Wei-Shaw/sub2api Images API SSE
|
| 23 |
IMAGE_REAL_SMOKE_SUB2API_BASE_URL=
|
| 24 |
IMAGE_REAL_SMOKE_SUB2API_API_KEY=
|
| 25 |
# IMAGE_REAL_SMOKE_SUB2API_MODEL=gpt-image-2
|
| 26 |
# IMAGE_REAL_SMOKE_SUB2API_SIZE=1024x1024
|
| 27 |
# IMAGE_REAL_SMOKE_SUB2API_QUALITY=low
|
| 28 |
|
| 29 |
-
# sub2api Responses image_generation
|
| 30 |
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=
|
| 31 |
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
|
| 32 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2
|
|
@@ -34,7 +34,7 @@ IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
|
|
| 34 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_SIZE=1024x1024
|
| 35 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low
|
| 36 |
|
| 37 |
-
# GPT2Image
|
| 38 |
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1
|
| 39 |
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
|
| 40 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2
|
|
@@ -42,14 +42,14 @@ IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
|
|
| 42 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_SIZE=1024x1024
|
| 43 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_QUALITY=low
|
| 44 |
|
| 45 |
-
# Matsca OpenAI
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1
|
| 49 |
IMAGE_REAL_SMOKE_MATSCA_API_KEY=
|
| 50 |
# IMAGE_REAL_SMOKE_MATSCA_MODEL=gpt-image-2
|
| 51 |
# IMAGE_REAL_SMOKE_MATSCA_SIZE=1024x1024
|
| 52 |
# IMAGE_REAL_SMOKE_MATSCA_QUALITY=low
|
| 53 |
|
| 54 |
-
#
|
| 55 |
IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
|
|
|
| 1 |
+
# 独立真实上游冒烟验证目标。
|
| 2 |
+
# 将本文件复制为 .env.real-smoke.local,只填写能够执行的目标。
|
| 3 |
+
# 不要提交 .env.real-smoke.local。
|
| 4 |
+
# 每个 *_BASE_URL 都必须是不含凭据、查询参数和片段的 http/https 绝对 URL。
|
| 5 |
+
# 最终门禁:
|
| 6 |
# npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable
|
| 7 |
|
| 8 |
+
# Original QuantumNous/new-api 兼容 Images API 的 JSON 接口。
|
| 9 |
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=
|
| 10 |
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
| 11 |
# IMAGE_REAL_SMOKE_ORIGINAL_MODEL=gpt-image-2
|
| 12 |
# IMAGE_REAL_SMOKE_ORIGINAL_SIZE=1024x1024
|
| 13 |
# IMAGE_REAL_SMOKE_ORIGINAL_QUALITY=low
|
| 14 |
|
| 15 |
+
# gaoren002/new-api 的 Images API SSE 和保活分支。
|
| 16 |
IMAGE_REAL_SMOKE_GAOREN_BASE_URL=
|
| 17 |
IMAGE_REAL_SMOKE_GAOREN_API_KEY=
|
| 18 |
# IMAGE_REAL_SMOKE_GAOREN_MODEL=gpt-image-2
|
| 19 |
# IMAGE_REAL_SMOKE_GAOREN_SIZE=1024x1024
|
| 20 |
# IMAGE_REAL_SMOKE_GAOREN_QUALITY=low
|
| 21 |
|
| 22 |
+
# Wei-Shaw/sub2api 的 Images API SSE 接口。
|
| 23 |
IMAGE_REAL_SMOKE_SUB2API_BASE_URL=
|
| 24 |
IMAGE_REAL_SMOKE_SUB2API_API_KEY=
|
| 25 |
# IMAGE_REAL_SMOKE_SUB2API_MODEL=gpt-image-2
|
| 26 |
# IMAGE_REAL_SMOKE_SUB2API_SIZE=1024x1024
|
| 27 |
# IMAGE_REAL_SMOKE_SUB2API_QUALITY=low
|
| 28 |
|
| 29 |
+
# sub2api 的 Responses image_generation 桥接。留空时,冒烟验证脚本可复用 IMAGE_REAL_SMOKE_SUB2API_*。
|
| 30 |
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=
|
| 31 |
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
|
| 32 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2
|
|
|
|
| 34 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_SIZE=1024x1024
|
| 35 |
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low
|
| 36 |
|
| 37 |
+
# GPT2Image 风格的 Responses image_generation SSE 接口。
|
| 38 |
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.superapi.buzz/v1
|
| 39 |
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
|
| 40 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2
|
|
|
|
| 42 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_SIZE=1024x1024
|
| 43 |
# IMAGE_REAL_SMOKE_GPT2IMAGE_QUALITY=low
|
| 44 |
|
| 45 |
+
# Matsca 兼容 OpenAI 的 Images API SSE 接口。
|
| 46 |
+
# 此独立目标使用页面外部凭据路径,并验证 Matsca 直连模式。
|
| 47 |
+
# Matsca 应用模式请配置 OPENAI_CHANNEL_N_UPSTREAM_PROFILE=matsca 和 OPENAI_CHANNEL_N_MATSCA_APP_*,再运行服务端渠道冒烟验证。
|
| 48 |
IMAGE_REAL_SMOKE_MATSCA_BASE_URL=https://img.matsca.com/v1
|
| 49 |
IMAGE_REAL_SMOKE_MATSCA_API_KEY=
|
| 50 |
# IMAGE_REAL_SMOKE_MATSCA_MODEL=gpt-image-2
|
| 51 |
# IMAGE_REAL_SMOKE_MATSCA_SIZE=1024x1024
|
| 52 |
# IMAGE_REAL_SMOKE_MATSCA_QUALITY=low
|
| 53 |
|
| 54 |
+
# 每个真实上游冒烟验证用例共用的超时时间。
|
| 55 |
IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
.github/workflows/ci.yml
CHANGED
|
@@ -66,9 +66,10 @@ jobs:
|
|
| 66 |
HEAD_SHA: ${{ github.sha }}
|
| 67 |
run: |
|
| 68 |
set -euo pipefail
|
| 69 |
-
if [[ -n "$BASE_SHA" && "$BASE_SHA" != "0000000000000000000000000000000000000000" ]]; then
|
| 70 |
git diff --check "$BASE_SHA" "$HEAD_SHA"
|
| 71 |
else
|
|
|
|
| 72 |
git show --check --format= "$HEAD_SHA"
|
| 73 |
fi
|
| 74 |
|
|
|
|
| 66 |
HEAD_SHA: ${{ github.sha }}
|
| 67 |
run: |
|
| 68 |
set -euo pipefail
|
| 69 |
+
if [[ -n "$BASE_SHA" && "$BASE_SHA" != "0000000000000000000000000000000000000000" ]] && git cat-file --quiet --verify "${BASE_SHA}^{commit}"; then
|
| 70 |
git diff --check "$BASE_SHA" "$HEAD_SHA"
|
| 71 |
else
|
| 72 |
+
# 历史重写后 push 事件的旧基准提交可能已不可达,此时检查当前提交。
|
| 73 |
git show --check --format= "$HEAD_SHA"
|
| 74 |
fi
|
| 75 |
|
AGENTS.md
CHANGED
|
@@ -1,158 +1,36 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
##
|
| 4 |
|
| 5 |
-
|
| 6 |
-
- 禁止为“先跑通”添加静默降级、隐藏回退、伪造成功路径或吞没异常后继续。
|
| 7 |
-
- 先保证行为等价和真实失败可见,再做性能、体验或结构优化。
|
| 8 |
-
- 每次只处理一个明确任务,先确认边界,再修改,再做最小充分验证。
|
| 9 |
-
- 不顺手修正无关问题;发现范围外问题时单独记录,不混入当前任务。
|
| 10 |
-
- 代码、注释、日志字符串和 Markdown 不使用 Emoji 或装饰性 Unicode 符号。
|
| 11 |
|
| 12 |
-
##
|
| 13 |
|
| 14 |
-
|
| 15 |
-
- 包管理工具是 `npm`,锁文件是 `package-lock.json`。
|
| 16 |
-
- Node 版本要求是 `>=22.15.0`。
|
| 17 |
-
- 主要页面入口是 `src/app/page.tsx`。
|
| 18 |
-
- 图片 API 入口是 `src/app/api/images/route.ts`。
|
| 19 |
-
- Agent API 位于 `src/app/api/agent/`。
|
| 20 |
-
- 图片请求校验位于 `src/lib/image-request-utils.ts`。
|
| 21 |
-
- 多渠道路由位于 `src/lib/channel-router.ts` 和 `src/lib/server-channel-router.ts`。
|
| 22 |
-
- 仓库自带 agent skill:`skills/gpt-image-playground-agent/SKILL.md`。
|
| 23 |
-
|
| 24 |
-
## 3. 任务工作流
|
| 25 |
-
|
| 26 |
-
### 3.1 任务来源
|
| 27 |
-
|
| 28 |
-
- 当前仓库没有独立的 `tasks.md`、`issues.csv` 或等价任务跟踪文件。
|
| 29 |
-
- 在未新增任务文件前,以用户当前回合明确指定的单一任务为唯一任务来源。
|
| 30 |
-
- 若后续新增任务跟踪文件,任务优先级切换为:任务文件状态 > git 提交证据 > 当前代码事实。
|
| 31 |
-
|
| 32 |
-
### 3.2 原子任务循环
|
| 33 |
-
|
| 34 |
-
- 每次只处理一个原子任务,流程固定为:读取上下文 -> 锁定范围 -> 实现或审计 -> 验证 -> 自审 -> 结束任务。
|
| 35 |
-
- 修改前先确认影响文件和验证方式。
|
| 36 |
-
- 修改后只在当前任务边界内收敛,不并行推进其他需求。
|
| 37 |
-
|
| 38 |
-
### 3.3 自审要求
|
| 39 |
-
|
| 40 |
-
- 对照用户给出的验收标准逐条确认。
|
| 41 |
-
- 运行最小相关验证,并记录命令与结果。
|
| 42 |
-
- 用 `git diff --name-only` 和 `git diff --check` 确认没有范围外改动和明显格式问题。
|
| 43 |
-
- 所有验证通过后,才可以声明完成。
|
| 44 |
-
|
| 45 |
-
### 3.4 Code Review 模式
|
| 46 |
-
|
| 47 |
-
- 当任务标题或用户指令包含 `[Code Review]` 时,默认进入审计模式,不直接修改业务代码,除非用户明确要求修复。
|
| 48 |
-
- 审计依据依次为:目标 diff、`AGENTS.md`、任务验收标准、相关测试和构建结果。
|
| 49 |
-
- 当前仓库没有 `docs/review_checklist.md`;如需输出审计报告,放在 `docs/reviews/CR-{ID}.md`,目录不存在时按需创建。
|
| 50 |
-
|
| 51 |
-
## 4. 质量红线
|
| 52 |
-
|
| 53 |
-
### 4.1 开发边界
|
| 54 |
-
|
| 55 |
-
- 禁止为迎合测试或截图而硬编码业务结果。
|
| 56 |
-
- 只修改完成当前任务所必需的文件。
|
| 57 |
-
- 不保留无用兼容分支、死代码或无法解释的兜底逻辑。
|
| 58 |
-
- 外部输入失败必须显式报错,不能静默改写后继续。
|
| 59 |
-
|
| 60 |
-
### 4.2 工程基线
|
| 61 |
-
|
| 62 |
-
- 遵循 SOLID、DRY、关注点分离和 YAGNI。
|
| 63 |
-
- 命名清晰,抽象务实,只在不直观处补简洁注释。
|
| 64 |
-
- 核心逻辑优先放在 `src/lib/`,UI 组件保持展示职责清晰。
|
| 65 |
-
- 能通过纯函数或依赖注入表达的逻辑,不要直接绑死到全局状态或具体实现。
|
| 66 |
-
|
| 67 |
-
### 4.3 安全基线
|
| 68 |
-
|
| 69 |
-
- 严禁在源码、文档示例、测试快照里写入真实 API Key、token 或密码。
|
| 70 |
-
- 自定义 API URL 和自定义 API Key 必须成对出现,避免服务端密钥被转发到未知地址。
|
| 71 |
-
- 所有用户输入、上传文件、URL、文件名、尺寸、格式和上游响应都要在边界处校验。
|
| 72 |
-
- 仅当密钥被写入仓库文件时,才视为泄漏事故;会话内临时调试输入不算源码泄漏。
|
| 73 |
-
|
| 74 |
-
## 5. 测试与验证
|
| 75 |
-
|
| 76 |
-
### 5.1 测试布局
|
| 77 |
-
|
| 78 |
-
- 当前仓库测试采用同目录 `node:test` 方案,命名为 `*.test.ts`。
|
| 79 |
-
- 测试文件主要位于 `src/lib/**/*.test.ts`、`src/app/api/**/*.test.ts`。
|
| 80 |
-
- 新增测试优先沿用现有同目录模式,不额外引入第二套测试目录约定。
|
| 81 |
-
|
| 82 |
-
### 5.2 验证基线
|
| 83 |
-
|
| 84 |
-
- 提交前最小基线是:
|
| 85 |
-
|
| 86 |
-
```bash
|
| 87 |
-
npm run install-scripts:check
|
| 88 |
-
npm run npm-install-policy:check
|
| 89 |
-
npm run dependencies:check
|
| 90 |
-
npm test
|
| 91 |
-
npm run lint
|
| 92 |
-
npm run format:check
|
| 93 |
-
npm run lint:scripts
|
| 94 |
-
npm run build
|
| 95 |
-
git diff --check
|
| 96 |
-
```
|
| 97 |
-
|
| 98 |
-
- 若只改动局部模块,先跑最小相关测试;准备收尾时再跑上述全量基线。
|
| 99 |
-
- 不能��“看起来没问题”代替自动化验证;不能把单项通过误报为整体通过。
|
| 100 |
-
|
| 101 |
-
### 5.3 数据与契约校验
|
| 102 |
-
|
| 103 |
-
- 处理 JSON、multipart、流式响应、数据库记录或 Agent API 合同时,必须明确字段含义、类型和分支语义。
|
| 104 |
-
- 若逻辑依赖真实 Postgres 行为、类型转换或驱动序列化,至少补充离线契约测试;若未连真实库,必须明确说明“仅覆盖语义,未覆盖真实数据库行为”。
|
| 105 |
-
- 不能把 `npm test` 全绿直接表述为线上、Docker 或真实上游接口已通过。
|
| 106 |
-
|
| 107 |
-
## 6. 环境与运行
|
| 108 |
-
|
| 109 |
-
### 6.1 本地开发
|
| 110 |
|
| 111 |
```bash
|
| 112 |
-
npm
|
| 113 |
-
npm
|
| 114 |
-
npm
|
| 115 |
-
npm run
|
| 116 |
-
npm run
|
|
|
|
| 117 |
```
|
| 118 |
|
| 119 |
-
-
|
| 120 |
-
- `npm run dev` 使用 Turbopack,并固定端口 `4783`。
|
| 121 |
-
|
| 122 |
-
### 6.2 Docker 验证
|
| 123 |
|
| 124 |
-
|
| 125 |
-
docker compose up -d --build
|
| 126 |
-
```
|
| 127 |
|
| 128 |
-
|
| 129 |
-
- 不能只看容器启动成功就声称验证完成,必须补至少一项真实访问或真实请求证据。
|
| 130 |
-
|
| 131 |
-
### 6.3 常用检查
|
| 132 |
-
|
| 133 |
-
```bash
|
| 134 |
-
npm test
|
| 135 |
-
npm run lint
|
| 136 |
-
npm run format:check
|
| 137 |
-
npm run lint:scripts
|
| 138 |
-
npm run build
|
| 139 |
-
git diff --check
|
| 140 |
-
```
|
| 141 |
|
| 142 |
-
##
|
| 143 |
|
| 144 |
-
|
| 145 |
-
- 提交前确认 `git diff --name-only` 只包含任务范围内文件。
|
| 146 |
-
- 默认不提交临时产物、日志、缓存、截图、生成图片、个人配置或本地数据库文件,除非任务明确要求。
|
| 147 |
-
- 修改 README、CHANGELOG、版本号或发布产物定义时,必须同时核对 `package.json`、`package-lock.json` 和相关文档口径。
|
| 148 |
|
| 149 |
-
##
|
| 150 |
|
| 151 |
-
|
| 152 |
-
- 当前已知技能入口是 `skills/gpt-image-playground-agent/SKILL.md`,命中 Agent API 调用场景时必须先阅读。
|
| 153 |
-
- 阶段性计划当前位于 `docs/superpowers/plans/`;它们只用于补充上下文,不替代代码事实和用户当前任务。
|
| 154 |
|
| 155 |
-
##
|
| 156 |
|
| 157 |
-
|
| 158 |
-
- 当前仓库若需要新增长期审计或复盘文档,统一放在 `docs/reviews/`。
|
|
|
|
| 1 |
+
# 仓库贡献指南
|
| 2 |
|
| 3 |
+
## 项目结构
|
| 4 |
|
| 5 |
+
图像手记是基于 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/` 保存产品、部署和审查资料。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
## 开发、构建与验证
|
| 8 |
|
| 9 |
+
使用 Node.js `>=22.15.0`、npm 和提交的 `package-lock.json`。首次安装运行 `npm ci --strict-allow-scripts`;运行 `npm run dev` 在 `http://localhost:4783` 启动 Turbopack 开发服务。常用命令如下:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
```bash
|
| 12 |
+
npm test # 全部单元与契约测试
|
| 13 |
+
npm test -- src/lib/image-service.test.ts # 定向测试
|
| 14 |
+
npm run lint # ESLint 检查 src/
|
| 15 |
+
npm run format:check # Prettier 检查
|
| 16 |
+
npm run build # 生产构建
|
| 17 |
+
npm run verify # 提交前完整基线
|
| 18 |
```
|
| 19 |
|
| 20 |
+
需要连接真实 Postgres 时,使用 `npm run verify -- --postgres` 并提供测试数据库。`npm run start` 必须在构建后使用,不要直接运行 `.next/standalone/server.js`。
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
## 架构与变更边界
|
|
|
|
|
|
|
| 23 |
|
| 24 |
+
页面工作台、页面 SSE 接口和智能体 JSON 接口是不同的调用边界;不要因为某一路径可用就推断其他路径或真实上游也已通过。路由、流式传输、幂等键、产物分享和状态后端的行为应由服务端契约决定,客户端脚本只做薄封装。实现变更时先定位受影响的路由处理器、`src/lib/` 领域逻辑和对应测试,再做最小修改;不要为了让测试通过加入静默回退、伪造上游响应或吞掉错误。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
## 代码与测试规范
|
| 27 |
|
| 28 |
+
使用 TypeScript 严格模式和 `@/` 路径别名。保持现有四空格缩进、单引号、分号和 120 列宽;执行 `npm run format` 处理格式和导入排序。组件使用 PascalCase 导出,文件使用 kebab-case。测试与被测模块同目录放置:`*.test.ts`、`*.test.tsx` 或 `scripts/*.test.mjs`;使用 `node:test` 与 `tsx`,覆盖成功、边界和失败分支。修改接口、流式响应、路由或状态模式时,同步更新契约测试。仓库面向人的 Markdown、技能说明和环境变量样例注释统一使用中文;命令、路径、接口字段和协议值保持原样。
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
## 智能体、配置与安全
|
| 31 |
|
| 32 |
+
复制 `.env.example` 为本地配置,绝不提交 `.env*`、真实密钥、`generated-images/` 或 `artifacts/`。自定义上游地址必须与自定义 API 密钥成对使用。涉及图片生成、编辑、批量处理或渠道诊断时,先阅读 `skills/visual-journal-image-agent/SKILL.md`,复用其中脚本以及智能体能力声明和 OpenAPI 契约;任何真实上游计费调用都必须显式传入 `--allow-billable`。
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
## 提交与合并请求
|
| 35 |
|
| 36 |
+
近期提交使用约定式提交格式;示例中的提交文本保留仓库实际历史用语:`fix(images): validate payload`、`refactor(skill): rename agent`、`docs: clarify deployment`。一个提交只处理一个可验证任务。合并请求说明应列出改动范��、验证命令及结果;界面改动附桌面和移动端截图,配置或数据库模式改动说明迁移和部署影响。提交前运行 `git diff --check`,避免混入无关生成物或格式改动。
|
|
|
CHANGELOG.md
CHANGED
|
@@ -1,13 +1,44 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
本文件记录项目的重要变更。
|
| 4 |
|
| 5 |
-
本项目参考 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
| 6 |
|
| 7 |
## [未发布]
|
| 8 |
|
| 9 |
暂无变更。
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
## [2.2.0] - 2026-07-28
|
| 12 |
|
| 13 |
### 新增
|
|
@@ -30,8 +61,8 @@
|
|
| 30 |
- 独立启动脚本显式加载 Next.js 运行时环境,测试和版本检查脚本改为支持显式测试文件与 runner 参数。
|
| 31 |
- 工作台、历史面板和移动端抽屉的可滚动区域与操作可达性按短视口约束收敛。
|
| 32 |
- 渠道失败冷却默认关闭;需要自动临时移出失败渠道时,必须显式设置 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED=true`。
|
| 33 |
-
- WebUI 图片清理启用后默认保留 30 天,并在启动时及之后每 6 小时执行;仍由 Agent 状态库登记的
|
| 34 |
-
- 手动删除 WebUI 图片会同步清理永久保存标记;永久保存不改变 Agent
|
| 35 |
|
| 36 |
### 修复
|
| 37 |
|
|
@@ -41,23 +72,23 @@
|
|
| 41 |
- 修正移动端运行时状态加载引起的首屏布局位移,并增加键盘跳到主要内容入口。
|
| 42 |
- 清理本地临时图片日志并将 `tmp` 目录加入忽略规则,避免运行产物进入版本库。
|
| 43 |
- 修正图片输出目录在进程启动时被冻结的问题,避免运行目录变化时测试或后台任务读写错误的图片目录。
|
| 44 |
-
- 规范化清理目录和 Agent
|
| 45 |
-
- 补充 Images JSON、Images SSE、Responses JSON 和 Responses SSE 四种 Agent 请求方式的
|
| 46 |
|
| 47 |
## [2.1.0] - 2026-06-14
|
| 48 |
|
| 49 |
### 新增
|
| 50 |
|
| 51 |
- 增加渠道凭证并发队列,超出单凭证容量的请求会按队列等待,并通过运行态能力接口暴露队列容量、等待上限和当前队列状态。
|
| 52 |
-
- Agent
|
| 53 |
- 增加 Matsca upstream profile,支持 Matsca 直连渠道的尺寸、`partial_images`、透明背景和上传限制口径。
|
| 54 |
|
| 55 |
### 变更
|
| 56 |
|
| 57 |
- 图片默认输出格式调整为 WebP,默认压缩质量为 `100`;需要无损归档或透明边缘复核时可显式选择 PNG。
|
| 58 |
- 渠道失败冷却默认时间调整为 `30000ms`,并增加 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED` 以允许关闭渠道或凭证冷却。
|
| 59 |
-
- 页面端 `/api/images` 的
|
| 60 |
-
- README、`.env.example` 和 Agent
|
| 61 |
|
| 62 |
### 修复
|
| 63 |
|
|
@@ -71,7 +102,7 @@
|
|
| 71 |
|
| 72 |
- WebUI 增加 `图像手记` 工作台的显式批量模式:多条提示词逐行形成独立任务,批量进度、暂停、失败项复用和批次历史保持可追溯。
|
| 73 |
- WebUI 在省心模式和专业模式中展示“并发批量”状态;只有用户手动启用且当前流式策略、任务数量和渠道容量满足条件时,才会把多图或多提示词拆成并发流式任务。
|
| 74 |
-
- Agent
|
| 75 |
- 增加 `npm run version:check`,校验 `package.json`、`package-lock.json`、README 版本徽章和 `CHANGELOG.md` 版本链接一致。
|
| 76 |
|
| 77 |
### 变更
|
|
@@ -89,16 +120,16 @@
|
|
| 89 |
- 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。
|
| 90 |
- 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。
|
| 91 |
- 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。
|
| 92 |
-
- Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE
|
| 93 |
|
| 94 |
### 变更
|
| 95 |
|
| 96 |
- 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。
|
| 97 |
- 页面默认不发送流式请求;用户显式开启流式预览后,单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。
|
| 98 |
-
- 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、
|
| 99 |
- 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。
|
| 100 |
- Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。
|
| 101 |
-
- Agent
|
| 102 |
|
| 103 |
### 修复
|
| 104 |
|
|
@@ -112,20 +143,20 @@
|
|
| 112 |
- 增加运行时并发流式批处理能力与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。
|
| 113 |
- 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。
|
| 114 |
- 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。
|
| 115 |
-
- 增加服务端
|
| 116 |
-
- 运行时能力接口增加健康
|
| 117 |
- 增加流式批处理、运行时环境读取、渠道健康状态和失败分类的单元测试。
|
| 118 |
|
| 119 |
### 变更
|
| 120 |
|
| 121 |
- 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
|
| 122 |
- 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。
|
| 123 |
-
- README 与 `.env.example` 补充流式批处理、单
|
| 124 |
- ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
|
| 125 |
|
| 126 |
### 修复
|
| 127 |
|
| 128 |
-
- 修正 `sticky` 路由下流式批处理推荐并发被渠道数量放大的问题,避免同一
|
| 129 |
- 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
|
| 130 |
- 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
|
| 131 |
- 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
|
|
@@ -175,7 +206,8 @@
|
|
| 175 |
- 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。
|
| 176 |
- 增加 Docker 部署支持和多平台启动脚本。
|
| 177 |
|
| 178 |
-
[未发布]: https://github.com/MisonL/visual-journal/compare/v2.
|
|
|
|
| 179 |
[2.2.0]: https://github.com/MisonL/visual-journal/compare/v2.1.0...v2.2.0
|
| 180 |
[2.1.0]: https://github.com/MisonL/visual-journal/compare/v2.0.0...v2.1.0
|
| 181 |
[2.0.0]: https://github.com/MisonL/visual-journal/compare/v1.4.0...v2.0.0
|
|
|
|
| 1 |
+
# 变更记录
|
| 2 |
|
| 3 |
本文件记录项目的重要变更。
|
| 4 |
|
| 5 |
+
本项目参考 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) 规范维护变更记录,并使用语义化版本管理正式发布版本。
|
| 6 |
|
| 7 |
## [未发布]
|
| 8 |
|
| 9 |
暂无变更。
|
| 10 |
|
| 11 |
+
## [2.3.0] - 2026-08-13
|
| 12 |
+
|
| 13 |
+
### 新增
|
| 14 |
+
|
| 15 |
+
- 强化响应式图片创作工作台,覆盖生成、编辑、批量任务、历史复用、分享、保留策略、成本与耗时摘要、移动端操作和中英文界面契约。
|
| 16 |
+
- 扩展 Agent API、OpenAPI 和运行时能力声明,提供服务端编排、任务轮询、产物管理、结果反馈、渠道健康、请求方式和诊断信息。
|
| 17 |
+
- 为 Agent 技能增加预演与契约检查、按后端能力校验、批量续跑与尺寸校验、路由诊断和真实上游能力矩阵流程。
|
| 18 |
+
|
| 19 |
+
### 变更
|
| 20 |
+
|
| 21 |
+
- 图片渠道路由改为按实际请求方式、凭证健康状态、模型、尺寸、背景、上传和遮罩约束筛选候选渠道。
|
| 22 |
+
- 支持混合渠道的离散图片数量范围和 `partial_images` 范围,能力接口、页面、Agent、OpenAPI 和 Skill 保持同一契约。
|
| 23 |
+
- 明确 Responses 图片后端的非流式和 SSE 语义:非流式不发送未使用的 `partial_images`,流式仅接受有效预览范围。
|
| 24 |
+
- 统一工作台的提示词长度、图片数量、尺寸、源图上传、遮罩、背景和后端兼容性校验,并改善错误提示、状态恢复、历史成本显示和结果操作。
|
| 25 |
+
- 完成 Visual Journal 品牌、npm 包名、Agent Skill 路径和 Hugging Face Space 目标迁移,固定 Space 为 `misonL/visual-journal`,并同步运行时 User-Agent、保活工作流和部署脚本。
|
| 26 |
+
- 将真实上游服务端渠道冒烟验证默认改为通过 HTTP 访问 Docker 服务,输出实际渠道、请求方式和上游主机,并在 Agent 请求后清理测试产物。
|
| 27 |
+
- 将仓库贡献指南、环境变量示例、产品与部署资料、Agent 技能文档统一为中文,清理历史审查、计划和设计过程文档。
|
| 28 |
+
|
| 29 |
+
### 修复
|
| 30 |
+
|
| 31 |
+
- 修复非流式图片响应、混合渠道范围不相交和后端默认值处理,避免能力接口 500、合法请求误报 422/503、自动默认值选择错误和冷却渠道误选。
|
| 32 |
+
- 修复页面与 Agent 编辑路径遗漏图片数量、源图文件大小、总上传量、遮罩、背景和尺寸限制的问题。
|
| 33 |
+
- 修复分享错误、批量提示词超限、历史记录成本与结果操作状态、窄屏布局和本地化文案等工作台回归问题。
|
| 34 |
+
- 将 `brace-expansion`、`js-yaml` 和 `nanoid` 更新到安全版本,并通过 npm override 固定受影响依赖版本。
|
| 35 |
+
- 补齐版本、文案、路由、能力、上传边界、脚本参数和并发临时目录隔离的回归测试与文档漂移检查。
|
| 36 |
+
|
| 37 |
+
### 升级提示
|
| 38 |
+
|
| 39 |
+
- 自动化脚本引用的 Skill 根目录需从 `skills/gpt-image-playground-agent/` 更新为 `skills/visual-journal-image-agent/`;API 路径和环境变量保持兼容。
|
| 40 |
+
- Docker Compose 默认仍使用 `images-api` 和 `auto`;Responses 图片后端只有在显式配置 `ENABLE_RESPONSES_IMAGE_BACKEND` 与顶层模型后才会启用。
|
| 41 |
+
|
| 42 |
## [2.2.0] - 2026-07-28
|
| 43 |
|
| 44 |
### 新增
|
|
|
|
| 61 |
- 独立启动脚本显式加载 Next.js 运行时环境,测试和版本检查脚本改为支持显式测试文件与 runner 参数。
|
| 62 |
- 工作台、历史面板和移动端抽屉的可滚动区域与操作可达性按短视口约束收敛。
|
| 63 |
- 渠道失败冷却默认关闭;需要自动临时移出失败渠道时,必须显式设置 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED=true`。
|
| 64 |
+
- WebUI 图片清理启用后默认保留 30 天,并在启动时及之后每 6 小时执行;仍由 Agent 状态库登记的产物不会被提前删除。
|
| 65 |
+
- 手动删除 WebUI 图片会同步清理永久保存标记;永久保存不改变 Agent 产物的生命周期或手动删除语义。
|
| 66 |
|
| 67 |
### 修复
|
| 68 |
|
|
|
|
| 72 |
- 修正移动端运行时状态加载引起的首屏布局位移,并增加键盘跳到主要内容入口。
|
| 73 |
- 清理本地临时图片日志并将 `tmp` 目录加入忽略规则,避免运行产物进入版本库。
|
| 74 |
- 修正图片输出目录在进程启动时被冻结的问题,避免运行目录变化时测试或后台任务读写错误的图片目录。
|
| 75 |
+
- 规范化清理目录和 Agent 产物的真实��径,避免 macOS 路径别名导致仍在保留期内的 Agent 图片被误删。
|
| 76 |
+
- 补充 Images JSON、Images SSE、Responses JSON 和 Responses SSE 四种 Agent 请求方式的产物清理生命周期回归测试。
|
| 77 |
|
| 78 |
## [2.1.0] - 2026-06-14
|
| 79 |
|
| 80 |
### 新增
|
| 81 |
|
| 82 |
- 增加渠道凭证并发队列,超出单凭证容量的请求会按队列等待,并通过运行态能力接口暴露队列容量、等待上限和当前队列状态。
|
| 83 |
+
- Agent 技能脚本支持默认 WebP 高质量输出、图像格式转换、Responses image_generation edit 的页面 SSE 路由,以及批量脚本按运行态容量限制有效并发。
|
| 84 |
- 增加 Matsca upstream profile,支持 Matsca 直连渠道的尺寸、`partial_images`、透明背景和上传限制口径。
|
| 85 |
|
| 86 |
### 变更
|
| 87 |
|
| 88 |
- 图片默认输出格式调整为 WebP,默认压缩质量为 `100`;需要无损归档或透明边缘复核时可显式选择 PNG。
|
| 89 |
- 渠道失败冷却默认时间调整为 `30000ms`,并增加 `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED` 以允许关闭渠道或凭证冷却。
|
| 90 |
+
- 页面端 `/api/images` 的生成和编辑都支持 `IMAGE_GENERATION_BACKEND` 与 `IMAGE_STREAMING_STRATEGY` 运行时默认值,Responses 编辑仍明确走页面 SSE 路径。
|
| 91 |
+
- README、`.env.example` 和 Agent 技能文档补齐渠道队列、默认后端、默认流式策略、WebP 输出和 Responses 编辑路由说明,避免把 Docker Compose 误解为默认 Responses 后端。
|
| 92 |
|
| 93 |
### 修复
|
| 94 |
|
|
|
|
| 102 |
|
| 103 |
- WebUI 增加 `图像手记` 工作台的显式批量模式:多条提示词逐行形成独立任务,批量进度、暂停、失败项复用和批次历史保持可追溯。
|
| 104 |
- WebUI 在省心模式和专业模式中展示“并发批量”状态;只有用户手动启用且当前流式策略、任务数量和渠道容量满足条件时,才会把多图或多提示词拆成并发流式任务。
|
| 105 |
+
- Agent 技能批量脚本支持 `--concurrency N` 并发执行、追加写入清单、续跑、尺寸校验、失败重试和页面 SSE 原始事件留档。
|
| 106 |
- 增加 `npm run version:check`,校验 `package.json`、`package-lock.json`、README 版本徽章和 `CHANGELOG.md` 版本链接一致。
|
| 107 |
|
| 108 |
### 变更
|
|
|
|
| 120 |
- 增加上游图片流事件适配层,兼容官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件。
|
| 121 |
- 增加 `/api/images` 流式路由契约测试,覆盖兼容上游 SSE 到前端稳定事件的映射、多图结果、缺图错误和上游断流。
|
| 122 |
- 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端,显式请求 `imageBackend=responses` 且配置独立 Responses 顶层模型时读取 `image_generation_call.result`。
|
| 123 |
+
- Agent capabilities 和 OpenAPI 增加机器可读 `routing_rules`、页面 SSE 元数据、运行态启用后端和任务轮询语义,辅助脚本支持 `--page-sse`、`--agent`、`--job` 显式路由。
|
| 124 |
|
| 125 |
### 变更
|
| 126 |
|
| 127 |
- 图片生成默认质量从 `auto` 调整为 `high`,前端、Agent API 默认值和 OpenAPI 描述保持一致。
|
| 128 |
- 页面默认不发送流式请求;用户显式开启流式预览后,单图流式失败会显式展示原始错误和建议,不再自动改用非流式请求。
|
| 129 |
+
- 抽取服务端流式图片响应处理,生成和编辑共用同一套 SSE 输出、图片保存、上游协议方言诊断和扣费解析逻辑。
|
| 130 |
- 运行时能力接口增加实验 Responses API 图片后端开关状态,默认关闭且不影响现有 Images API 路径。
|
| 131 |
- Agent API、图片接口、脚本和文档中的用户可见错误文案统一为中文。
|
| 132 |
+
- Agent 技能文档改为先定位服务地址,再按 `/api/agent/*` 契约调用,避免默认假设服务只在 `localhost:4783`。
|
| 133 |
|
| 134 |
### 修复
|
| 135 |
|
|
|
|
| 143 |
- 增加运行时并发流式批处理能力与 `OPENAI_MAX_STREAMS_PER_CREDENTIAL`,支持在流式模式下把 `n>1` 拆成多个 `n=1` 任务并发执行。
|
| 144 |
- 增加前端流式批处理执行链路,支持并发调度、SSE 完成事件聚合、预览图索引映射、用量合并和部分失败提示。
|
| 145 |
- 生成和编辑表单在服务端允许批处理时支持 `n>1` 开启流式预览,并补充中英文提示文案。
|
| 146 |
+
- 增加服务端凭证和渠道失败冷却机制,支持按渠道覆盖冷却窗口。
|
| 147 |
+
- 运行时能力接口增加健康凭证和渠道数量及最近失败摘要,用于前端刷新并发窗口。
|
| 148 |
- 增加流式批处理、运行时环境读取、渠道健康状态和失败分类的单元测试。
|
| 149 |
|
| 150 |
### 变更
|
| 151 |
|
| 152 |
- 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
|
| 153 |
- 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。
|
| 154 |
+
- README 与 `.env.example` 补充流式批处理、单凭证并发上限和渠道失败冷却配置说明。
|
| 155 |
- ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
|
| 156 |
|
| 157 |
### 修复
|
| 158 |
|
| 159 |
+
- 修正 `sticky` 路由下流式批处理推荐并发被渠道数量放大的问题,避免同一亲和键下突破单个凭证的并发上限。
|
| 160 |
- 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
|
| 161 |
- 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
|
| 162 |
- 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
|
|
|
|
| 206 |
- 支持基于 OpenAI 兼容 Images API 的本地图片生成和编辑流程。
|
| 207 |
- 增加 Docker 部署支持和多平台启动脚本。
|
| 208 |
|
| 209 |
+
[未发布]: https://github.com/MisonL/visual-journal/compare/v2.3.0...HEAD
|
| 210 |
+
[2.3.0]: https://github.com/MisonL/visual-journal/compare/v2.2.0...v2.3.0
|
| 211 |
[2.2.0]: https://github.com/MisonL/visual-journal/compare/v2.1.0...v2.2.0
|
| 212 |
[2.1.0]: https://github.com/MisonL/visual-journal/compare/v2.0.0...v2.1.0
|
| 213 |
[2.0.0]: https://github.com/MisonL/visual-journal/compare/v1.4.0...v2.0.0
|
README.md
CHANGED
|
@@ -7,16 +7,16 @@ app_port: 4783
|
|
| 7 |
|
| 8 |
# 图像手记 / Visual Journal
|
| 9 |
|
| 10 |
-

|
| 13 |
|
| 14 |
图像手记(Visual Journal)是本地优先的 AI 图片创作工作台,支持 `gpt-image-2` 与 OpenAI 兼容图片接口。提供文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪、多渠道路由和 Agent API。
|
| 15 |
|
| 16 |
-
对外产品名称为“图像手记 / Visual Journal”。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,
|
| 17 |
|
| 18 |
<p align="center">
|
| 19 |
-
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/
|
| 20 |
</p>
|
| 21 |
|
| 22 |
## 快速开始
|
|
@@ -32,7 +32,7 @@ npm run first-run
|
|
| 32 |
npm run deploy:local
|
| 33 |
```
|
| 34 |
|
| 35 |
-
打开 [http://localhost:4783](http://localhost:4783),在页面右上角的 `API 设置` 中填写 API
|
| 36 |
|
| 37 |
也可以复制环境变量模板,配置服务端默认上游:
|
| 38 |
|
|
@@ -61,18 +61,18 @@ Windows、macOS 和 Linux 也可分别使用 `start-windows.bat`、`start-macos.
|
|
| 61 |
|
| 62 |
- 图片创作:文生图、图生图、遮罩编辑、单图和多图输出。
|
| 63 |
- 输出控制:尺寸、质量、格式、压缩率、透明背景和流式策略。
|
| 64 |
-
- 批量生产:多提示词任务、并发控制、失败续跑和
|
| 65 |
- 工作台体验:灵感相册、历史复用、继续编辑、变体、下载、分享和反馈。
|
| 66 |
-
- 费用与诊断:耗时、
|
| 67 |
-
- 上游路由:单
|
| 68 |
-
- 自动化接口:幂等请求、异步
|
| 69 |
- 存储选择:文件系统、IndexedDB、SQLite、PostgreSQL 和内存状态。
|
| 70 |
|
| 71 |
## 界面预览
|
| 72 |
|
| 73 |
<p align="center">
|
| 74 |
-
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/
|
| 75 |
-
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/
|
| 76 |
</p>
|
| 77 |
|
| 78 |
## 配置
|
|
@@ -85,7 +85,7 @@ Windows、macOS 和 Linux 也可分别使用 `start-windows.bat`、`start-macos.
|
|
| 85 |
| 多渠道 | `OPENAI_CHANNEL_N_*` | 配置多个渠道、多个 key、请求方式白名单和渠道级覆盖。 |
|
| 86 |
| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 仅代理服务端到图片上游的 HTTP(S) 请求。 |
|
| 87 |
| 页面访问码 | `APP_PASSWORD` | 设置后,页面生图和受保护图片需要访问码。公网部署建议开启。 |
|
| 88 |
-
| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后,`/api/agent/*` 需要 Bearer
|
| 89 |
| Agent 状态 | `AGENT_STATE_BACKEND` | 支持 `memory`、`sqlite` 和 `postgres`;Compose 默认使用 SQLite。 |
|
| 90 |
| 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 支持 `fs` 和 `indexeddb`;Compose 默认使用文件系统。 |
|
| 91 |
| 图片清理 | `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED`、`WEBUI_IMAGE_RETENTION_DAYS` | 默认关闭;启用后默认保留 30 天。 |
|
|
@@ -112,14 +112,14 @@ OPENAI_CHANNEL_2_API_KEYS=your-backup-key
|
|
| 112 |
OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream
|
| 113 |
```
|
| 114 |
|
| 115 |
-
请求方式白名单只能填写已通过真实上游
|
| 116 |
|
| 117 |
代理 URL 仅支持无认证、无路径的 `http://` 或 `https://` 根地址,不支持 SOCKS。代理只影响服务端出站请求,不改变浏览器到本服务的连接。
|
| 118 |
|
| 119 |
安全要求:
|
| 120 |
|
| 121 |
-
- 自定义 API URL 必须和自定义 API
|
| 122 |
-
- 不要把真实 API
|
| 123 |
- 非回环地址部署必须同时设置 `APP_PASSWORD`,否则容器会拒绝启动。
|
| 124 |
|
| 125 |
## 部署
|
|
@@ -129,16 +129,16 @@ OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream
|
|
| 129 |
| 模式 | 命令 | 适用场景 |
|
| 130 |
| ---------- | ------------------------------------ | --------------------------- |
|
| 131 |
| SQLite | `npm run deploy:local` | 本地单实例和长期运行。 |
|
| 132 |
-
|
|
| 133 |
| PostgreSQL | `npm run deploy:local -- --postgres` | 集中状态库或多实例部署。 |
|
| 134 |
|
| 135 |
-
部署脚本会拒绝脏工作区,并核对 Docker 健康状态、真实 HTTP 端点和镜像
|
| 136 |
|
| 137 |
```bash
|
| 138 |
GIP_BIND_HOST=0.0.0.0 npm run deploy:local
|
| 139 |
```
|
| 140 |
|
| 141 |
-
文件系统图片保存在 `generated-images/`。若 `.env.local` 将 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 设为 `1`、`true`、`yes` 或 `on`,部署脚本会先拒绝运行,避免服务启动后立即清理历史图片;确认可以执行时显式添加 `--allow-image-auto-cleanup`。自动清理、永久保留和 Agent
|
| 142 |
|
| 143 |
### Hugging Face Space
|
| 144 |
|
|
@@ -148,7 +148,7 @@ GIP_BIND_HOST=0.0.0.0 npm run deploy:local
|
|
| 148 |
|
| 149 |
[](https://huggingface.co/new-space?duplicate=misonL%2Fvisual-journal)
|
| 150 |
|
| 151 |
-
登录 Hugging Face 后,创建页会预填本 Space 作为复制来源。请在创建页选择 Private;复制不会带出本服务的 API
|
| 152 |
|
| 153 |
#### 维护本项目固定 Space
|
| 154 |
|
|
@@ -163,7 +163,7 @@ npm run deploy:space
|
|
| 163 |
|
| 164 |
## Agent API
|
| 165 |
|
| 166 |
-
Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平台。客户端应先读取
|
| 167 |
|
| 168 |
| 接口 | 用途 |
|
| 169 |
| ------------------------------------------- | -------------------------------- |
|
|
@@ -179,18 +179,18 @@ Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平
|
|
| 179 |
```bash
|
| 180 |
npm run first-run -- --json --base-url http://localhost:4783
|
| 181 |
|
| 182 |
-
node skills/
|
| 183 |
--contract-check \
|
| 184 |
--base-url http://localhost:4783 \
|
| 185 |
"capability check"
|
| 186 |
```
|
| 187 |
|
| 188 |
-
仓库内置脚本默认
|
| 189 |
|
| 190 |
-
新增
|
| 191 |
|
| 192 |
-
- [Agent Skill](./skills/
|
| 193 |
-
- [Agent API 参考](./skills/
|
| 194 |
|
| 195 |
渠道健康快照只读取当前进程内存状态,不触发上游探测或图片生成,也不替代页面 `/api/runtime-capabilities`。
|
| 196 |
|
|
@@ -198,7 +198,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
| 198 |
|
| 199 |
| 命令 | 用途 |
|
| 200 |
| ------------------------------------ | --------------------------------------------------- |
|
| 201 |
-
| `npm run status` | 只读查看 Git、Node、部署目标和真实
|
| 202 |
| `npm run doctor` | 运行本机和部署诊断。 |
|
| 203 |
| `npm run env:summary` | 安全汇总环境变量来源,不输出密钥值。 |
|
| 204 |
| `npm run agent:doctor` | 执行非计费 Agent 分层诊断。 |
|
|
@@ -207,9 +207,9 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
| 207 |
| `npm run format:check` | 检查 TypeScript 和 TSX 格式。 |
|
| 208 |
| `npm run build` | 执行生产构建。 |
|
| 209 |
| `npm run verify` | 运行提交前完整基线。 |
|
| 210 |
-
| `npm run smoke:image-upstream-local` | 运行本地非计费上游兼容
|
| 211 |
|
| 212 |
-
真实上游
|
| 213 |
|
| 214 |
## 常见问题
|
| 215 |
|
|
@@ -218,17 +218,17 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
| 218 |
| 未检测到 Node.js | 安装 Node.js >=22.15.0。 |
|
| 219 |
| 依赖安装失败 | 依次运行安装策略检查、`npm ci --strict-allow-scripts` 和依赖核对。 |
|
| 220 |
| API 返回 HTML | API URL 填成了网页地址;应填写 OpenAI 兼容 `/v1` 根地址。 |
|
| 221 |
-
| 提示需要 API
|
| 222 |
| 端口被占用 | 检查占用 `4783` 的旧进程或旧容器。 |
|
| 223 |
|
| 224 |
## 项目文档
|
| 225 |
|
| 226 |
- [产品边界](./docs/product/product-contract.md)
|
| 227 |
- [用户验证脚本](./docs/product/user-validation-script.md)
|
| 228 |
-
- [图片
|
| 229 |
- [Hugging Face Space 部署](./docs/deployment/huggingface-space-free.md)
|
| 230 |
-
- [Agent Skill](./skills/
|
| 231 |
-
- [Agent API 参考](./skills/
|
| 232 |
- [版本记录](./CHANGELOG.md)
|
| 233 |
|
| 234 |
## 技术栈
|
|
|
|
| 7 |
|
| 8 |
# 图像手记 / Visual Journal
|
| 9 |
|
| 10 |
+

|
| 11 |
+

|
| 12 |
+

|
| 13 |
|
| 14 |
图像手记(Visual Journal)是本地优先的 AI 图片创作工作台,支持 `gpt-image-2` 与 OpenAI 兼容图片接口。提供文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪、多渠道路由和 Agent API。
|
| 15 |
|
| 16 |
+
对外产品名称、npm 包名和 Agent Skill 标识均为“图像手记 / Visual Journal”(`visual-journal`、`visual-journal-image-agent`)。HF Space 已使用 `visual-journal` 名称;为保持已有部署和自动化客户端兼容,Docker 服务、环境变量和 API 路径继续使用 `gpt-image-playground` 相关技术名称。
|
| 17 |
|
| 18 |
<p align="center">
|
| 19 |
+
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/9d61ce95a120e0356988adfca282e04c52ea4364/readme-images/interface.jpg" alt="图像手记主界面" width="900"/>
|
| 20 |
</p>
|
| 21 |
|
| 22 |
## 快速开始
|
|
|
|
| 32 |
npm run deploy:local
|
| 33 |
```
|
| 34 |
|
| 35 |
+
打开 [http://localhost:4783](http://localhost:4783),在页面右上角的 `API 设置` 中填写 API 密钥和兼容接口地址即可使用。
|
| 36 |
|
| 37 |
也可以复制环境变量模板,配置服务端默认上游:
|
| 38 |
|
|
|
|
| 61 |
|
| 62 |
- 图片创作:文生图、图生图、遮罩编辑、单图和多图输出。
|
| 63 |
- 输出控制:尺寸、质量、格式、压缩率、透明背景和流式策略。
|
| 64 |
+
- 批量生产:多提示词任务、并发控制、失败续跑和清单记录。
|
| 65 |
- 工作台体验:灵感相册、历史复用、继续编辑、变体、下载、分享和反馈。
|
| 66 |
+
- 费用与诊断:耗时、令牌用量、估算费用、实际扣费和脱敏日志摘要。
|
| 67 |
+
- 上游路由:单密钥、多渠道、多密钥、渠道队列、失败冷却和代理支持。
|
| 68 |
+
- 自动化接口:幂等请求、异步任务、产物追踪、分享和请求诊断。
|
| 69 |
- 存储选择:文件系统、IndexedDB、SQLite、PostgreSQL 和内存状态。
|
| 70 |
|
| 71 |
## 界面预览
|
| 72 |
|
| 73 |
<p align="center">
|
| 74 |
+
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/9d61ce95a120e0356988adfca282e04c52ea4364/readme-images/mask-creation.jpg" alt="遮罩编辑界面" width="49%"/>
|
| 75 |
+
<img src="https://raw.githubusercontent.com/MisonL/visual-journal/9d61ce95a120e0356988adfca282e04c52ea4364/readme-images/history.jpg" alt="历史与费用面板" width="49%"/>
|
| 76 |
</p>
|
| 77 |
|
| 78 |
## 配置
|
|
|
|
| 85 |
| 多渠道 | `OPENAI_CHANNEL_N_*` | 配置多个渠道、多个 key、请求方式白名单和渠道级覆盖。 |
|
| 86 |
| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 仅代理服务端到图片上游的 HTTP(S) 请求。 |
|
| 87 |
| 页面访问码 | `APP_PASSWORD` | 设置后,页面生图和受保护图片需要访问码。公网部署建议开启。 |
|
| 88 |
+
| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后,`/api/agent/*` 需要 Bearer 令牌。 |
|
| 89 |
| Agent 状态 | `AGENT_STATE_BACKEND` | 支持 `memory`、`sqlite` 和 `postgres`;Compose 默认使用 SQLite。 |
|
| 90 |
| 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 支持 `fs` 和 `indexeddb`;Compose 默认使用文件系统。 |
|
| 91 |
| 图片清理 | `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED`、`WEBUI_IMAGE_RETENTION_DAYS` | 默认关闭;启用后默认保留 30 天。 |
|
|
|
|
| 112 |
OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream
|
| 113 |
```
|
| 114 |
|
| 115 |
+
请求方式白名单只能填写已通过真实上游冒烟验证、且结果能被本服务消费的模式。未配置时默认只允许 `images-non-stream`;显式流式或 Responses 请求失败时不会静默降级。
|
| 116 |
|
| 117 |
代理 URL 仅支持无认证、无路径的 `http://` 或 `https://` 根地址,不支持 SOCKS。代理只影响服务端出站请求,不改变浏览器到本服务的连接。
|
| 118 |
|
| 119 |
安全要求:
|
| 120 |
|
| 121 |
+
- 自定义 API URL 必须和自定义 API 密钥成对配置,避免服务端密钥被发送到未知地址。
|
| 122 |
+
- 不要把真实 API 密钥、访问码、令牌或数据库密码提交到仓库。
|
| 123 |
- 非回环地址部署必须同时设置 `APP_PASSWORD`,否则容器会拒绝启动。
|
| 124 |
|
| 125 |
## 部署
|
|
|
|
| 129 |
| 模式 | 命令 | 适用场景 |
|
| 130 |
| ---------- | ------------------------------------ | --------------------------- |
|
| 131 |
| SQLite | `npm run deploy:local` | 本地单实例和长期运行。 |
|
| 132 |
+
| 内存 | `npm run deploy:local -- --memory` | 临时演示或模拟 Space 环境。 |
|
| 133 |
| PostgreSQL | `npm run deploy:local -- --postgres` | 集中状态库或多实例部署。 |
|
| 134 |
|
| 135 |
+
部署脚本会拒绝脏工作区,并核对 Docker 健康状态、真实 HTTP 端点和镜像版本。Compose 默认只发布到 `127.0.0.1:4783`;需要局域网访问时先设置 `APP_PASSWORD`,再执行:
|
| 136 |
|
| 137 |
```bash
|
| 138 |
GIP_BIND_HOST=0.0.0.0 npm run deploy:local
|
| 139 |
```
|
| 140 |
|
| 141 |
+
文件系统图片保存在 `generated-images/`。若 `.env.local` 将 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 设为 `1`、`true`、`yes` 或 `on`,部署脚本会先拒绝运行,避免服务启动后立即清理历史图片;确认可以执行时显式添加 `--allow-image-auto-cleanup`。自动清理、永久保留和 Agent 产物生命周期配置见 [.env.example](./.env.example)。
|
| 142 |
|
| 143 |
### Hugging Face Space
|
| 144 |
|
|
|
|
| 148 |
|
| 149 |
[](https://huggingface.co/new-space?duplicate=misonL%2Fvisual-journal)
|
| 150 |
|
| 151 |
+
登录 Hugging Face 后,创建页会预填本 Space 作为复制来源。请在创建页选择 Private;复制不会带出本服务的 API 密钥、访问码或 Agent 令牌。创建后必须在新 Space 的 Settings 中配置 `APP_PASSWORD`、`AGENT_API_TOKEN` 和自己的上游凭证;Docker Space 的创建资格仍受 Hugging Face 当前账户政策约束。
|
| 152 |
|
| 153 |
#### 维护本项目固定 Space
|
| 154 |
|
|
|
|
| 163 |
|
| 164 |
## Agent API
|
| 165 |
|
| 166 |
+
Agent API 是供自动化客户端调用的机器接口,不是自治 Agent 平台。客户端应先读取能力声明,再向服务端提交业务意图,由服务端决定渠道和请求方式。
|
| 167 |
|
| 168 |
| 接口 | 用途 |
|
| 169 |
| ------------------------------------------- | -------------------------------- |
|
|
|
|
| 179 |
```bash
|
| 180 |
npm run first-run -- --json --base-url http://localhost:4783
|
| 181 |
|
| 182 |
+
node skills/visual-journal-image-agent/scripts/generate-image.mjs \
|
| 183 |
--contract-check \
|
| 184 |
--base-url http://localhost:4783 \
|
| 185 |
"capability check"
|
| 186 |
```
|
| 187 |
|
| 188 |
+
仓库内置脚本默认执行预演(dry-run),不触发真实生图。只有用户明确允许计费后,才添加 `--allow-billable`。
|
| 189 |
|
| 190 |
+
新增探针、诊断或路由可观测能力时,先落 API、能力声明和 OpenAPI 契约,再让 Skill 脚本做薄封装。完整参数、批量任务、编辑、分享、诊断、真实冒烟验证和边界矩阵见:
|
| 191 |
|
| 192 |
+
- [Agent Skill](./skills/visual-journal-image-agent/SKILL.md)
|
| 193 |
+
- [Agent API 参考](./skills/visual-journal-image-agent/references/api.md)
|
| 194 |
|
| 195 |
渠道健康快照只读取当前进程内存状态,不触发上游探测或图片生成,也不替代页面 `/api/runtime-capabilities`。
|
| 196 |
|
|
|
|
| 198 |
|
| 199 |
| 命令 | 用途 |
|
| 200 |
| ------------------------------------ | --------------------------------------------------- |
|
| 201 |
+
| `npm run status` | 只读查看 Git、Node、部署目标和真实冒烟验证配置状态。 |
|
| 202 |
| `npm run doctor` | 运行本机和部署诊断。 |
|
| 203 |
| `npm run env:summary` | 安全汇总环境变量来源,不输出密钥值。 |
|
| 204 |
| `npm run agent:doctor` | 执行非计费 Agent 分层诊断。 |
|
|
|
|
| 207 |
| `npm run format:check` | 检查 TypeScript 和 TSX 格式。 |
|
| 208 |
| `npm run build` | 执行生产构建。 |
|
| 209 |
| `npm run verify` | 运行提交前完整基线。 |
|
| 210 |
+
| `npm run smoke:image-upstream-local` | 运行本地非计费上游兼容最终门禁。 |
|
| 211 |
|
| 212 |
+
真实上游冒烟验证必须显式传入 `--allow-billable`。`npm run status` 和默认诊断只检查配置与合同,不会产生图片费用。
|
| 213 |
|
| 214 |
## 常见问题
|
| 215 |
|
|
|
|
| 218 |
| 未检测到 Node.js | 安装 Node.js >=22.15.0。 |
|
| 219 |
| 依赖安装失败 | 依次运行安装策略检查、`npm ci --strict-allow-scripts` 和依赖核对。 |
|
| 220 |
| API 返回 HTML | API URL 填成了网页地址;应填写 OpenAI 兼容 `/v1` 根地址。 |
|
| 221 |
+
| 提示需要 API 密钥 | 在 `.env.local` 或页面 `API 设置` 中配置。 |
|
| 222 |
| 端口被占用 | 检查占用 `4783` 的旧进程或旧容器。 |
|
| 223 |
|
| 224 |
## 项目文档
|
| 225 |
|
| 226 |
- [产品边界](./docs/product/product-contract.md)
|
| 227 |
- [用户验证脚本](./docs/product/user-validation-script.md)
|
| 228 |
+
- [图片上游清单](./docs/product/image-provider-manifest.md)
|
| 229 |
- [Hugging Face Space 部署](./docs/deployment/huggingface-space-free.md)
|
| 230 |
+
- [Agent Skill](./skills/visual-journal-image-agent/SKILL.md)
|
| 231 |
+
- [Agent API 参考](./skills/visual-journal-image-agent/references/api.md)
|
| 232 |
- [版本记录](./CHANGELOG.md)
|
| 233 |
|
| 234 |
## 技术栈
|
docs/deployment/huggingface-space-free.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# Hugging Face Space 部署
|
| 2 |
|
| 3 |
本文档描述如何把本项目部署到 Hugging Face Docker Space,用作公网图片生成服务。Docker Space 的创建和更新权限取决于 Hugging Face 的当前账户政策;固定目标已存在且元数据标识为 Docker,部署脚本会直接使用认证 Git 推送,避免 `hf upload` 触发创建接口的已知 `402`。这不绕过新建 Docker Space 的账户限制。
|
| 4 |
|
|
@@ -6,13 +6,13 @@
|
|
| 6 |
|
| 7 |
- 手机浏览器可以访问 Space 网页并正常生图。
|
| 8 |
- 电脑上的 Agent 可以通过 `/api/agent/*` 调用同一个 Space 生图。
|
| 9 |
-
- 首战场景是中文内容运营者为小红书笔记、商品详情页或活动海报生成首版视觉稿,不是公开
|
| 10 |
- 状态后端使用 `memory`,不依赖 SQLite、PostgreSQL 或外部数据库。
|
| 11 |
-
- 图片
|
| 12 |
|
| 13 |
-
## Space README
|
| 14 |
|
| 15 |
-
本仓库顶层 `README.md` 已包含 Hugging Face Space
|
| 16 |
|
| 17 |
```yaml
|
| 18 |
---
|
|
@@ -23,13 +23,13 @@ app_port: 4783
|
|
| 23 |
|
| 24 |
官方依据:
|
| 25 |
|
| 26 |
-
- Docker Space 配置、
|
| 27 |
- Space 硬件与计费政策:https://huggingface.co/docs/hub/main/spaces-gpus
|
| 28 |
- Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli
|
| 29 |
|
| 30 |
## 全新电脑前置条件
|
| 31 |
|
| 32 |
-
全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把
|
| 33 |
|
| 34 |
先检查:
|
| 35 |
|
|
@@ -45,8 +45,8 @@ hf auth whoami
|
|
| 45 |
- Node.js >=22.15.0。
|
| 46 |
- npm 随 Node.js 一起可用。
|
| 47 |
- Hugging Face CLI 使用当前官方 `hf` 命令。
|
| 48 |
-
- `hf auth login` 使用 Hugging Face
|
| 49 |
-
- Docker
|
| 50 |
|
| 51 |
安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
|
| 52 |
|
|
@@ -67,7 +67,7 @@ npm run dependencies:check
|
|
| 67 |
npm run doctor
|
| 68 |
```
|
| 69 |
|
| 70 |
-
`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端
|
| 71 |
|
| 72 |
## 管理员命令中心
|
| 73 |
|
|
@@ -82,14 +82,14 @@ npm run deploy:space
|
|
| 82 |
npm run agent:doctor
|
| 83 |
```
|
| 84 |
|
| 85 |
-
- `status`:只读输出 git、Node、固定 Space 目标、Agent
|
| 86 |
- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
|
| 87 |
-
- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、
|
| 88 |
-
- `deploy:local`:拒绝脏工作区后重建本地 Docker 服务,等待
|
| 89 |
- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
|
| 90 |
- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
|
| 91 |
|
| 92 |
-
HF Space 交互使用官方 `hf` CLI。不要维护本机
|
| 93 |
|
| 94 |
部署当前干净的 git HEAD 到固定 Space:
|
| 95 |
|
|
@@ -104,10 +104,10 @@ npm run deploy:space
|
|
| 104 |
- Space 发布包会排除根目录 `readme-images/` 中的 README 文档截图,以兼容 Hugging Face Git 的二进制文件门禁;Space README 会改用对应 GitHub 提交的不可变图片地址。
|
| 105 |
- 读取远端 Space 元数据;当前固定 Docker Space 直接克隆、同步已跟踪源码并使用认证 Git 推送。
|
| 106 |
- 非 Docker Space 才优先使用 `hf upload`;仅当它命中既有 Docker Space 创建政策 `402` 时才回退到认证 Git 推送,其他错误不会自动回退。
|
| 107 |
-
- 等待新 Space
|
| 108 |
- 检查 `/api/auth-status`、`/api/agent/capabilities` 和 `/api/runtime-capabilities`,不触发真实生图。
|
| 109 |
|
| 110 |
-
配置或轮换
|
| 111 |
|
| 112 |
```bash
|
| 113 |
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=<page-access-code>
|
|
| 117 |
hf spaces secrets add misonL/visual-journal -s AGENT_API_TOKEN=<long-random-agent-token>
|
| 118 |
```
|
| 119 |
|
| 120 |
-
源码部署、远端诊断、
|
| 121 |
|
| 122 |
-
## Space
|
| 123 |
|
| 124 |
-
在 Space Settings 中添加这些
|
| 125 |
|
| 126 |
```dotenv
|
| 127 |
AGENT_STATE_BACKEND=memory
|
|
@@ -129,7 +129,7 @@ NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
|
|
| 129 |
APP_LOG_LEVEL=warn
|
| 130 |
```
|
| 131 |
|
| 132 |
-
`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明
|
| 133 |
|
| 134 |
如果不使用 `memory`,再按实际状态后端追加可选变量:
|
| 135 |
|
|
@@ -150,28 +150,28 @@ AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
|
|
| 150 |
|
| 151 |
`AGENT_PUBLIC_BASE_URL` 影响 OpenAPI `servers[0].url`,也用于 `POST /api/agent/artifacts/{id}/share` 返回用户可打开的分享外链。必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
|
| 152 |
|
| 153 |
-
## Space
|
| 154 |
|
| 155 |
-
在 Space Settings 中添加
|
| 156 |
|
| 157 |
```dotenv
|
| 158 |
-
OPENAI_API_KEY=<
|
| 159 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
| 160 |
# 可选:仅服务端到上游的无认证 HTTP(S) 代理。
|
| 161 |
OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 162 |
APP_PASSWORD=<page-access-code>
|
| 163 |
-
AGENT_API_TOKEN=<
|
| 164 |
```
|
| 165 |
|
| 166 |
`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` ���尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
|
| 167 |
|
| 168 |
`OPENAI_UPSTREAM_PROXY_URL` 只影响 Space 服务端到上游 API 的出站连接,不影响用户浏览器访问 Space。它仅接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS。多渠道部署可用 `OPENAI_CHANNEL_N_PROXY_URL` 覆盖全局代理,渠道级值优先。代理地址即使不含凭据也建议作为 Space Secret 管理;修改后需要重新启动或重新部署 Space。运行态和 Agent 诊断只公开是否配置及协议,不公开主机或端口。
|
| 169 |
|
| 170 |
-
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API
|
| 171 |
|
| 172 |
如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。
|
| 173 |
|
| 174 |
-
如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*`
|
| 175 |
|
| 176 |
```dotenv
|
| 177 |
OPENAI_ROUTING_STRATEGY=round_robin
|
|
@@ -189,7 +189,7 @@ OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
|
|
| 189 |
3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
|
| 190 |
4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
|
| 191 |
|
| 192 |
-
## 电脑 Agent API 使用
|
| 193 |
|
| 194 |
先做只读契约检查,不触发真实生图:
|
| 195 |
|
|
@@ -197,26 +197,26 @@ OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
|
|
| 197 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 198 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 199 |
GPT_IMAGE_AGENT_CONTRACT_CHECK=1 \
|
| 200 |
-
node skills/
|
| 201 |
```
|
| 202 |
|
| 203 |
-
真实文生图:
|
| 204 |
|
| 205 |
```bash
|
| 206 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 207 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 208 |
-
node skills/
|
| 209 |
--allow-billable \
|
| 210 |
-
"
|
| 211 |
```
|
| 212 |
|
| 213 |
脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。
|
| 214 |
|
| 215 |
-
远端 Agent 调用不要硬编码路径。普通文生图默认提交业务意图到
|
| 216 |
|
| 217 |
如果 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` 为准。
|
| 218 |
|
| 219 |
-
## 本地 HF 近似
|
| 220 |
|
| 221 |
提交前运行:
|
| 222 |
|
|
@@ -236,10 +236,10 @@ npm run smoke:hf-space-local
|
|
| 236 |
## 平台与运行限制
|
| 237 |
|
| 238 |
- Docker Space 的创建、更新和可用硬件受 Hugging Face 当前账户政策约束。`hf upload` 对已存在 Space 的创建接口检查收到 `402` 时,脚本会尝试认证 Git 推送;新建 Docker Space 仍需要满足平台账户要求。
|
| 239 |
-
- CPU Basic 适合公开演示和轻量使用,不适合长期高并发;长时间无访问后可能休眠。需要真正永不休眠或自定义
|
| 240 |
-
- Docker Space 重启后容器磁盘写入会丢失。`memory` 状态后端的 Agent 幂等记录、
|
| 241 |
- Agent API 仍会把产物图片写入容器临时文件系统,以便提供 `content_url` 下载。重启后这些链接不保证继续有效。
|
| 242 |
-
- 需要长期保存图片、分享链接或 Agent
|
| 243 |
|
| 244 |
## 公网客户门槛
|
| 245 |
|
|
@@ -247,16 +247,16 @@ npm run smoke:hf-space-local
|
|
| 247 |
|
| 248 |
- 先执行 `npm run deploy:space`,确保当前干净 git HEAD 已上传到固定 Space。
|
| 249 |
- 再用真实浏览器打开 Space,确认页面能进入并完成一次真实的浏览器检查。
|
| 250 |
-
- 仅有 `npm run doctor:hf-space` 的远端可达与
|
| 251 |
- `APP_PASSWORD` 已设置,网页不会裸露给匿名访问者。
|
| 252 |
- `AGENT_API_TOKEN` 已设置,自动化调用不会回退到页面访问码哈希。
|
| 253 |
- `npm run doctor:hf-space` 的 `remote-secrets` 检查通过。
|
| 254 |
- 共享链接明确保留访问码和有效期的默认控制,不把无访问码永久链接当成默认发布形态。
|
| 255 |
- Space 重启丢失分享元数据和 Agent replay 的前提已被客户知晓。
|
| 256 |
|
| 257 |
-
## Space
|
| 258 |
|
| 259 |
-
本仓库提供 GitHub Actions 定时
|
| 260 |
|
| 261 |
- 工作流文件:`.github/workflows/hf-space-keepalive.yml`
|
| 262 |
- 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch`
|
|
@@ -281,11 +281,11 @@ HF_SPACE_KEEPALIVE_RETRY_MAX_DELAY_MS=20000 \
|
|
| 281 |
npm run keepalive:hf-space
|
| 282 |
```
|
| 283 |
|
| 284 |
-
注意:
|
| 285 |
|
| 286 |
## 验证门禁
|
| 287 |
|
| 288 |
-
GitHub Actions 的 `.github/workflows/ci.yml` 会在 Pull Request、`main` 分支推送和手动触发时先核对锁文件安装脚本与 `allowScripts` 白名单、npm 的严格安装脚本能力,再以严格白名单模式安装依赖并核对直接依赖完整性,随后执行版本元数据检查、完整依赖审计、测试、源码
|
| 289 |
|
| 290 |
最小验证:
|
| 291 |
|
|
@@ -302,11 +302,11 @@ npm run smoke:hf-space-local
|
|
| 302 |
git diff --check
|
| 303 |
```
|
| 304 |
|
| 305 |
-
真实 Hugging Face
|
| 306 |
|
| 307 |
-
1. 提交代码后执行 `npm run deploy:space`,等待 Space 新
|
| 308 |
2. 用真实浏览器打开 Space,确认页面可进入并至少完成一次页面检查。
|
| 309 |
3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。
|
| 310 |
4. 如有可用测试额度,再执行一次真实 Agent 生成。
|
| 311 |
-
5. 重启 Space 后确认旧 Agent
|
| 312 |
6. 如果未执行第 1 步和第 2 步,必须在门禁报告里明确标注残余外部门禁未验证。
|
|
|
|
| 1 |
+
# Hugging Face Space 部署指南
|
| 2 |
|
| 3 |
本文档描述如何把本项目部署到 Hugging Face Docker Space,用作公网图片生成服务。Docker Space 的创建和更新权限取决于 Hugging Face 的当前账户政策;固定目标已存在且元数据标识为 Docker,部署脚本会直接使用认证 Git 推送,避免 `hf upload` 触发创建接口的已知 `402`。这不绕过新建 Docker Space 的账户限制。
|
| 4 |
|
|
|
|
| 6 |
|
| 7 |
- 手机浏览器可以访问 Space 网页并正常生图。
|
| 8 |
- 电脑上的 Agent 可以通过 `/api/agent/*` 调用同一个 Space 生图。
|
| 9 |
+
- 首战场景是中文内容运营者为小红书笔记、商品详情页或活动海报生成首版视觉稿,不是公开软件即服务平台。
|
| 10 |
- 状态后端使用 `memory`,不依赖 SQLite、PostgreSQL 或外部数据库。
|
| 11 |
+
- 图片网页结果优先保存在浏览器 IndexedDB,减少服务端临时磁盘依赖。
|
| 12 |
|
| 13 |
+
## Space README 元数据
|
| 14 |
|
| 15 |
+
本仓库顶层 `README.md` 已包含 Hugging Face Space 元数据。如果你维护的是单独的 Space 仓库,确认它的 `README.md` 顶部使用 Docker SDK,并暴露本项目端口:
|
| 16 |
|
| 17 |
```yaml
|
| 18 |
---
|
|
|
|
| 23 |
|
| 24 |
官方依据:
|
| 25 |
|
| 26 |
+
- Docker Space 配置、变量/密钥和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker
|
| 27 |
- Space 硬件与计费政策:https://huggingface.co/docs/hub/main/spaces-gpus
|
| 28 |
- Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli
|
| 29 |
|
| 30 |
## 全新电脑前置条件
|
| 31 |
|
| 32 |
+
全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把密钥写到远端 Space。
|
| 33 |
|
| 34 |
先检查:
|
| 35 |
|
|
|
|
| 45 |
- Node.js >=22.15.0。
|
| 46 |
- npm 随 Node.js 一起可用。
|
| 47 |
- Hugging Face CLI 使用当前官方 `hf` 命令。
|
| 48 |
+
- `hf auth login` 使用 Hugging Face 访问令牌,不是账号密码。
|
| 49 |
+
- Docker 仅在本地 Space 近似容器冒烟验证(推荐 `npm run smoke:hf-space-local`,兼容别名 `npm run smoke:hf-space`)和本地容器验证时必需;远端部署由 `npm run deploy:space` 统一执行。
|
| 50 |
|
| 51 |
安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
|
| 52 |
|
|
|
|
| 67 |
npm run doctor
|
| 68 |
```
|
| 69 |
|
| 70 |
+
`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端变量和远端密钥。该命令不会写远端密钥、不会重启 Space、不会打印密钥值。
|
| 71 |
|
| 72 |
## 管理员命令中心
|
| 73 |
|
|
|
|
| 82 |
npm run agent:doctor
|
| 83 |
```
|
| 84 |
|
| 85 |
+
- `status`:只读输出 git、Node、固定 Space 目标、Agent 能力声明路径和 Skill 入口。
|
| 86 |
- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查,并校验当前 npm 是否支持严格安装脚本策略、本地 `node_modules` 隐藏锁文件和直接依赖版本是否与根锁文件一致。
|
| 87 |
+
- `verify`:提交前基线,先核对锁文件安装脚本与 `allowScripts` 白名单、当前 npm 严格安装策略能力和已安装直接依赖,再执行测试、静态检查、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL 门禁时加 `--postgres`。
|
| 88 |
+
- `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`。
|
| 89 |
- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证;已存在 Docker Space 根据远端元数据直接使用认证 Git 推送,其他类型才优先尝试 `hf upload`。
|
| 90 |
- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
|
| 91 |
|
| 92 |
+
HF Space 交互使用官方 `hf` CLI。不要维护本机访问文件,不要把 `APP_PASSWORD`、`AGENT_API_TOKEN`、OpenAI 密钥或 Hugging Face 令牌写入仓库文件。
|
| 93 |
|
| 94 |
部署当前干净的 git HEAD 到固定 Space:
|
| 95 |
|
|
|
|
| 104 |
- Space 发布包会排除根目录 `readme-images/` 中的 README 文档截图,以兼容 Hugging Face Git 的二进制文件门禁;Space README 会改用对应 GitHub 提交的不可变图片地址。
|
| 105 |
- 读取远端 Space 元数据;当前固定 Docker Space 直接克隆、同步已跟踪源码并使用认证 Git 推送。
|
| 106 |
- 非 Docker Space 才优先使用 `hf upload`;仅当它命中既有 Docker Space 创建政策 `402` 时才回退到认证 Git 推送,其他错误不会自动回退。
|
| 107 |
+
- 等待新 Space 提交进入 `RUNNING`。
|
| 108 |
- 检查 `/api/auth-status`、`/api/agent/capabilities` 和 `/api/runtime-capabilities`,不触发真实生图。
|
| 109 |
|
| 110 |
+
配置或轮换变量/密钥时,直接使用官方 `hf` CLI:
|
| 111 |
|
| 112 |
```bash
|
| 113 |
hf spaces variables add misonL/visual-journal -e AGENT_STATE_BACKEND=memory
|
|
|
|
| 117 |
hf spaces secrets add misonL/visual-journal -s AGENT_API_TOKEN=<long-random-agent-token>
|
| 118 |
```
|
| 119 |
|
| 120 |
+
源码部署、远端诊断、变量和密钥都由仓库命令与 `hf` CLI 协同完成;部署回退只使用现有 Git 凭据,不维护第二套访问文件或密钥同步流程。
|
| 121 |
|
| 122 |
+
## Space 变量
|
| 123 |
|
| 124 |
+
在 Space Settings 中添加这些变量:
|
| 125 |
|
| 126 |
```dotenv
|
| 127 |
AGENT_STATE_BACKEND=memory
|
|
|
|
| 129 |
APP_LOG_LEVEL=warn
|
| 130 |
```
|
| 131 |
|
| 132 |
+
`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明构建参数,Hugging Face Docker Space 会把同名变量作为构建参数传入构建,并在运行期注入环境变量。
|
| 133 |
|
| 134 |
如果不使用 `memory`,再按实际状态后端追加可选变量:
|
| 135 |
|
|
|
|
| 150 |
|
| 151 |
`AGENT_PUBLIC_BASE_URL` 影响 OpenAPI `servers[0].url`,也用于 `POST /api/agent/artifacts/{id}/share` 返回用户可打开的分享外链。必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
|
| 152 |
|
| 153 |
+
## Space 密钥
|
| 154 |
|
| 155 |
+
在 Space Settings 中添加密钥,不要写入仓库文件:
|
| 156 |
|
| 157 |
```dotenv
|
| 158 |
+
OPENAI_API_KEY=<你的 API 密钥>
|
| 159 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
| 160 |
# 可选:仅服务端到上游的无认证 HTTP(S) 代理。
|
| 161 |
OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 162 |
APP_PASSWORD=<page-access-code>
|
| 163 |
+
AGENT_API_TOKEN=<足够长的随机 Agent 令牌>
|
| 164 |
```
|
| 165 |
|
| 166 |
`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` ���尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
|
| 167 |
|
| 168 |
`OPENAI_UPSTREAM_PROXY_URL` 只影响 Space 服务端到上游 API 的出站连接,不影响用户浏览器访问 Space。它仅接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS。多渠道部署可用 `OPENAI_CHANNEL_N_PROXY_URL` 覆盖全局代理,渠道级值优先。代理地址即使不含凭据也建议作为 Space Secret 管理;修改后需要重新启动或重新部署 Space。运行态和 Agent 诊断只公开是否配置及协议,不公开主机或端口。
|
| 169 |
|
| 170 |
+
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API 密钥。
|
| 171 |
|
| 172 |
如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。
|
| 173 |
|
| 174 |
+
如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` 密钥:
|
| 175 |
|
| 176 |
```dotenv
|
| 177 |
OPENAI_ROUTING_STRATEGY=round_robin
|
|
|
|
| 189 |
3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
|
| 190 |
4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
|
| 191 |
|
| 192 |
+
## 电脑端 Agent API 使用
|
| 193 |
|
| 194 |
先做只读契约检查,不触发真实生图:
|
| 195 |
|
|
|
|
| 197 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 198 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 199 |
GPT_IMAGE_AGENT_CONTRACT_CHECK=1 \
|
| 200 |
+
node skills/visual-journal-image-agent/scripts/generate-image.mjs
|
| 201 |
```
|
| 202 |
|
| 203 |
+
真实文生图请求:
|
| 204 |
|
| 205 |
```bash
|
| 206 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 207 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 208 |
+
node skills/visual-journal-image-agent/scripts/generate-image.mjs \
|
| 209 |
--allow-billable \
|
| 210 |
+
"木桌上的陶瓷杯产品照片"
|
| 211 |
```
|
| 212 |
|
| 213 |
脚本会先读取 `GET /api/agent/capabilities`,再调用 Agent API。成功响应会保留相对 `content_url`,同时补充 `absolute_content_url` 和 `absolute_metadata_url`,便于在桌面环境直接下载产物。
|
| 214 |
|
| 215 |
+
远端 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` 显式选择路径。
|
| 216 |
|
| 217 |
如果 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` 为准。
|
| 218 |
|
| 219 |
+
## 本地 HF 近似冒烟验证
|
| 220 |
|
| 221 |
提交前运行:
|
| 222 |
|
|
|
|
| 236 |
## 平台与运行限制
|
| 237 |
|
| 238 |
- Docker Space 的创建、更新和可用硬件受 Hugging Face 当前账户政策约束。`hf upload` 对已存在 Space 的创建接口检查收到 `402` 时,脚本会尝试认证 Git 推送;新建 Docker Space 仍需要满足平台账户要求。
|
| 239 |
+
- CPU Basic 适合公开演示和轻量使用,不适合长期高并发;长时间无访问后可能休眠。需要真正永不休眠或自定义休眠时间时,应使用满足平台要求的付费硬件。
|
| 240 |
+
- Docker Space 重启后容器磁盘写入会丢失。`memory` 状态后端的 Agent 幂等记录、回放状态和分享元数据也会丢失。
|
| 241 |
- Agent API 仍会把产物图片写入容器临时文件系统,以便提供 `content_url` 下载。重启后这些链接不保证继续有效。
|
| 242 |
+
- 需要长期保存图片、分享链接或 Agent 回放状态时,不应使用纯内存模式。应切换到 PostgreSQL 加持久卷或外部对象存储。
|
| 243 |
|
| 244 |
## 公网客户门槛
|
| 245 |
|
|
|
|
| 247 |
|
| 248 |
- 先执行 `npm run deploy:space`,确保当前干净 git HEAD 已上传到固定 Space。
|
| 249 |
- 再用真实浏览器打开 Space,确认页面能进入并完成一次真实的浏览器检查。
|
| 250 |
+
- 仅有 `npm run doctor:hf-space` 的远端可达与密钥检查,不足以证明客户可见上线。
|
| 251 |
- `APP_PASSWORD` 已设置,网页不会裸露给匿名访问者。
|
| 252 |
- `AGENT_API_TOKEN` 已设置,自动化调用不会回退到页面访问码哈希。
|
| 253 |
- `npm run doctor:hf-space` 的 `remote-secrets` 检查通过。
|
| 254 |
- 共享链接明确保留访问码和有效期的默认控制,不把无访问码永久链接当成默认发布形态。
|
| 255 |
- Space 重启丢失分享元数据和 Agent replay 的前提已被客户知晓。
|
| 256 |
|
| 257 |
+
## Space 保活
|
| 258 |
|
| 259 |
+
本仓库提供 GitHub Actions 定时保活,降低 CPU Basic 因长时间无访问进入休眠的概率:
|
| 260 |
|
| 261 |
- 工作流文件:`.github/workflows/hf-space-keepalive.yml`
|
| 262 |
- 默认频率:每 6 小时一次,可手动触发 `workflow_dispatch`
|
|
|
|
| 281 |
npm run keepalive:hf-space
|
| 282 |
```
|
| 283 |
|
| 284 |
+
注意:保活是尽力而为机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应使用满足平台要求的硬件并设置永不休眠。
|
| 285 |
|
| 286 |
## 验证门禁
|
| 287 |
|
| 288 |
+
GitHub Actions 的 `.github/workflows/ci.yml` 会在 Pull Request、`main` 分支推送和手动触发时先核对锁文件安装脚本与 `allowScripts` 白名单、npm 的严格安装脚本能力,再以严格白名单模式安装依赖并核对直接依赖完整性,随后执行版本元数据检查、完整依赖审计、测试、源码静态检查、脚本语法检查、生产构建、工作流静态检查、Dockerfile 与基础 Compose 加 memory/PostgreSQL 覆盖配置检查。它还会构建和启动生产镜像后验证 `/api/auth-status`,并在独立任务中运行真实 PostgreSQL 状态契约。
|
| 289 |
|
| 290 |
最小验证:
|
| 291 |
|
|
|
|
| 302 |
git diff --check
|
| 303 |
```
|
| 304 |
|
| 305 |
+
真实 Hugging Face 门禁:
|
| 306 |
|
| 307 |
+
1. 提交代码后执行 `npm run deploy:space`,等待 Space 新提交进入 `RUNNING`。
|
| 308 |
2. 用真实浏览器打开 Space,确认页面可进入并至少完成一次页面检查。
|
| 309 |
3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。
|
| 310 |
4. 如有可用测试额度,再执行一次真实 Agent 生成。
|
| 311 |
+
5. 重启 Space 后确认旧 Agent 回放状态和旧临时产物丢失符合预期。
|
| 312 |
6. 如果未执行第 1 步和第 2 步,必须在门禁报告里明确标注残余外部门禁未验证。
|
docs/product/image-provider-manifest.md
CHANGED
|
@@ -1,24 +1,23 @@
|
|
| 1 |
-
#
|
| 2 |
|
| 3 |
-
|
| 4 |
-
It is not a browser-side plugin system and it does not execute arbitrary request templates.
|
| 5 |
|
| 6 |
-
##
|
| 7 |
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
- `/api/runtime-capabilities`
|
| 12 |
-
- API
|
| 13 |
-
-
|
| 14 |
|
| 15 |
-
##
|
| 16 |
|
| 17 |
```json
|
| 18 |
{
|
| 19 |
"schema_version": 1,
|
| 20 |
"id": "custom_async",
|
| 21 |
-
"name": "
|
| 22 |
"base_profile": "openai-compatible",
|
| 23 |
"modes": {
|
| 24 |
"generate": {
|
|
@@ -59,21 +58,20 @@ It is not a browser-side plugin system and it does not execute arbitrary request
|
|
| 59 |
}
|
| 60 |
```
|
| 61 |
|
| 62 |
-
##
|
| 63 |
|
| 64 |
-
-
|
| 65 |
-
-
|
| 66 |
-
-
|
| 67 |
-
-
|
| 68 |
-
-
|
| 69 |
-
-
|
| 70 |
-
-
|
| 71 |
-
- `base_profile`
|
| 72 |
-
-
|
| 73 |
-
-
|
| 74 |
-
-
|
| 75 |
|
| 76 |
-
##
|
| 77 |
|
| 78 |
-
|
| 79 |
-
Manifest support currently provides validated constraints and diagnostics so new upstream modes can be introduced without silent fallback or hidden browser-side behavior.
|
|
|
|
| 1 |
+
# 图片上游清单
|
| 2 |
|
| 3 |
+
图片上游清单是服务端用于上游诊断和能力约束的配置契约,不是浏览器插件系统,也不会执行任意请求模板。
|
|
|
|
| 4 |
|
| 5 |
+
## 适用范围
|
| 6 |
|
| 7 |
+
- 服务端按编号渠道读取 `OPENAI_CHANNEL_N_PROVIDER_MANIFEST`。
|
| 8 |
+
- 解析渠道配置时会校验清单。
|
| 9 |
+
- 清单可以收窄 `n`、`partial_images`、上传限制和 `gpt-image-2` 尺寸策略等请求约束。
|
| 10 |
+
- `/api/runtime-capabilities` 只暴露脱敏摘要:上游标识、方式类型、请求内容类型、响应格式和是否声明异步轮询。
|
| 11 |
+
- 运行时能力响应绝不包含 API 密钥或额外请求头。
|
| 12 |
+
- 上传限制受全局上限约束:`max_images` 不超过 10,`max_single_bytes` 不超过 25 MiB,`max_total_bytes` 不超过 100 MiB。
|
| 13 |
|
| 14 |
+
## 最小示例
|
| 15 |
|
| 16 |
```json
|
| 17 |
{
|
| 18 |
"schema_version": 1,
|
| 19 |
"id": "custom_async",
|
| 20 |
+
"name": "自定义异步上游",
|
| 21 |
"base_profile": "openai-compatible",
|
| 22 |
"modes": {
|
| 23 |
"generate": {
|
|
|
|
| 58 |
}
|
| 59 |
```
|
| 60 |
|
| 61 |
+
## 必须覆盖的失败场景
|
| 62 |
|
| 63 |
+
- 非法 JSON 必须在配置解析时失败。
|
| 64 |
+
- 不支持的 `schema_version` 必须显式失败。
|
| 65 |
+
- 清单 `id` 必须稳定且全为小写。
|
| 66 |
+
- 提交路径必须是以 `/` 开头的相对 API 路径。
|
| 67 |
+
- 提交方法只支持 `POST`。
|
| 68 |
+
- 提交内容类型只支持 `application/json` 或 `multipart/form-data`。
|
| 69 |
+
- 轮询方法只支持 `GET` 或 `POST`。
|
| 70 |
+
- `base_profile` 必须与渠道上游配置匹配。
|
| 71 |
+
- 范围约束必须拒绝 `min > max`。
|
| 72 |
+
- 上传约束必须拒绝超过全局上限的值。
|
| 73 |
+
- 运行时能力不得包含 API 密钥、应用密钥或原始额外请求头。
|
| 74 |
|
| 75 |
+
## 当前边界
|
| 76 |
|
| 77 |
+
应用仍通过现有 OpenAI 兼容 Images API 和 Responses 后端路径发送图片请求。清单目前提供经过校验的约束和诊断,使新上游方式能够在没有静默降级或隐藏浏览器行为的前提下接入。
|
|
|
docs/product/product-contract.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
| 1 |
-
# 图像手记
|
| 2 |
|
| 3 |
-
|
| 4 |
|
| 5 |
## 阶段结论
|
| 6 |
|
| 7 |
-
第一阶段主线是
|
| 8 |
|
| 9 |
## 第一真实用户
|
| 10 |
|
| 11 |
-
第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常
|
| 12 |
|
| 13 |
## 首战场景
|
| 14 |
|
| 15 |
-
首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开
|
| 16 |
|
| 17 |
## 非目标用户
|
| 18 |
|
| 19 |
- 需要多人协作素材库、权限分层、审批流或结算系统的企业团队。
|
| 20 |
-
- 需要生产级公网
|
| 21 |
-
- 需要自治
|
| 22 |
-
- 只想验证任意 OpenAI
|
| 23 |
|
| 24 |
## 核心闭环
|
| 25 |
|
|
@@ -42,7 +42,7 @@
|
|
| 42 |
|
| 43 |
## 上线边界
|
| 44 |
|
| 45 |
-
第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API
|
| 46 |
|
| 47 |
## 证据标准
|
| 48 |
|
|
|
|
| 1 |
+
# 图像手记产品合同
|
| 2 |
|
| 3 |
+
产品中文名称为“图像手记”,英文品牌名为 Visual Journal。
|
| 4 |
|
| 5 |
## 阶段结论
|
| 6 |
|
| 7 |
+
第一阶段主线是供中文小红书和电商内容运营者使用的本地或内网 AI 图片创作工作台,不是通用图片平台、公开软件即服务平台、自治智能体或兼容接口基准测试平台。
|
| 8 |
|
| 9 |
## 第一真实用户
|
| 10 |
|
| 11 |
+
第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常独自工作或身处 2 到 5 人小团队,拥有自己的 API 密钥或使用团队配置的服务端密钥,需要在本机、内网或受控公网环境中完成提示词生成、结果挑选、继续编辑、下载和安全分享。
|
| 12 |
|
| 13 |
## 首战场景
|
| 14 |
|
| 15 |
+
首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开软件即服务能力、模型兼容探测和长期智能体调度。
|
| 16 |
|
| 17 |
## 非目标用户
|
| 18 |
|
| 19 |
- 需要多人协作素材库、权限分层、审批流或结算系统的企业团队。
|
| 20 |
+
- 需要生产级公网软件即服务可用性、对象存储和审计合规的客户。
|
| 21 |
+
- 需要自治智能体长期调度、跨实例队列和任务审计的自动化系统。
|
| 22 |
+
- 只想验证任意 OpenAI 兼容接口全部能力的工程测试人员。
|
| 23 |
|
| 24 |
## 核心闭环
|
| 25 |
|
|
|
|
| 42 |
|
| 43 |
## 上线边界
|
| 44 |
|
| 45 |
+
第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API 密钥时必须配置页面访问码,智能体接口对外开放时必须配置智能体令牌。
|
| 46 |
|
| 47 |
## 证据标准
|
| 48 |
|
docs/product/user-validation-script.md
CHANGED
|
@@ -20,7 +20,9 @@
|
|
| 20 |
| 用户 | 第 3 分钟是否出图 | 是否下载 | 是否继续编辑或变体 | 是否复用历史或灵感 | 是否标记可用或需修改 | 是否理解费用和失败原因 | 迁移承诺 | 授权承诺 | 付费承诺 | 引荐承诺 | 卡点 |
|
| 21 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| 22 |
|
| 23 |
-
##
|
|
|
|
|
|
|
| 24 |
|
| 25 |
- 你上次需要这类图片是什么时候。
|
| 26 |
- 当时你怎么处理。
|
|
|
|
| 20 |
| 用户 | 第 3 分钟是否出图 | 是否下载 | 是否继续编辑或变体 | 是否复用历史或灵感 | 是否标记可用或需修改 | 是否理解费用和失败原因 | 迁移承诺 | 授权承诺 | 付费承诺 | 引荐承诺 | 卡点 |
|
| 21 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| 22 |
|
| 23 |
+
## 用户访谈问题
|
| 24 |
+
|
| 25 |
+
以下问题聚焦用户过去的真实行为、成本和具体承诺,避免只收集对产品的礼貌性评价。
|
| 26 |
|
| 27 |
- 你上次需要这类图片是什么时候。
|
| 28 |
- 当时你怎么处理。
|
docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
# CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22
|
| 2 |
-
|
| 3 |
-
## 范围
|
| 4 |
-
|
| 5 |
-
- Agent 图片端点路由契约、错误诊断与终态失败语义。
|
| 6 |
-
- 本地图片上游 fixture 与 `smoke:image-upstream-real` final gate 复用路径。
|
| 7 |
-
- 本记录只证明本地 fixture、脚本门禁与当前代码契约;不证明第三方真实部署当前可访问。
|
| 8 |
-
|
| 9 |
-
## 审计结论
|
| 10 |
-
|
| 11 |
-
- `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。
|
| 12 |
-
- 当前路由口径已更新:高分辨率 edit 默认优先走页面端 `/api/images` SSE;页面流式失败或不可用时,先诊断结构化错误,再显式回退到 Agent edit。
|
| 13 |
-
- partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。
|
| 14 |
-
- 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。
|
| 15 |
-
- 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。
|
| 16 |
-
- 本地 final gate 会跳过 `.env.local` 加载,并清理 `IMAGE_REAL_SMOKE_*`、`OPENAI_*`、`APP_PASSWORD`、`AGENT_API_TOKEN` 等外层输入,避免 shell 凭据污染本地 fixture 验证。
|
| 17 |
-
|
| 18 |
-
## 验证记录
|
| 19 |
-
|
| 20 |
-
| 命令 | 退出码 | 摘要 |
|
| 21 |
-
| --- | --- | --- |
|
| 22 |
-
| `npm test` | 0 | 462 个测试通过;PostgreSQL live 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 |
|
| 23 |
-
| `npm run lint` | 0 | `eslint src` 通过。 |
|
| 24 |
-
| `npm run lint:scripts` | 0 | 脚本语法检查通过。 |
|
| 25 |
-
| `npm run build` | 0 | Next.js production build 通过,standalone runtime patch 完成。 |
|
| 26 |
-
| `node scripts/smoke-image-upstream-local-final-gate.mjs --timeout-ms 30000` | 0 | 5 个独立本地 fixture 场景全部通过,`final_gate_satisfied=true`。 |
|
| 27 |
-
| `git diff --check` | 0 | 当前 diff 无 whitespace error。 |
|
| 28 |
-
| 装饰符扫描 | 0 | 代码与 Markdown 改动中未发现 AGENTS.md 禁止的装饰性 Unicode 符号。 |
|
| 29 |
-
| `coderabbit review --prompt-only -t uncommitted` | 0 | CodeRabbit 返回 `findings=0`。 |
|
| 30 |
-
|
| 31 |
-
## 剩余边界
|
| 32 |
-
|
| 33 |
-
- 本轮未运行真实第三方上游 `--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`。
|
| 34 |
-
- 本轮未运行 live PostgreSQL gate;数据库真实行为仍以 `npm run test:postgres` 或 `npm run verify -- --postgres` 为准。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-DEPLOYMENT-HARDENING-2026-07-27.md
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
# 部署加固回归门禁
|
| 2 |
-
|
| 3 |
-
日期: 2026-07-27
|
| 4 |
-
|
| 5 |
-
范围: Docker 本地部署、Hugging Face Space memory smoke、PostgreSQL overlay、CI 运行时检查、部署脚本和部署文档。
|
| 6 |
-
|
| 7 |
-
## 已审查变更
|
| 8 |
-
|
| 9 |
-
- 默认 Compose 发布限制为 `127.0.0.1:4783`。
|
| 10 |
-
- 非回环 Compose 发布必须设置非空 `APP_PASSWORD`。
|
| 11 |
-
- Docker 镜像提供 OCI revision label 和 healthcheck。
|
| 12 |
-
- 本地部署校验干净的 Git revision、镜像身份、发布端口和选定的状态/存储模式。
|
| 13 |
-
- PostgreSQL overlay 在使用 Docker secret 文件前清空直连数据库凭证变量。
|
| 14 |
-
- HF Space 和本地端点轮询不会在最后一次失败后继续等待。
|
| 15 |
-
- CI 校验实际 Docker 入口点的回环分支、健康状态、端点响应和镜像 revision。
|
| 16 |
-
|
| 17 |
-
## 自动化证据
|
| 18 |
-
|
| 19 |
-
| 命令 | 退出码 | 结果 |
|
| 20 |
-
| --- | --- | --- |
|
| 21 |
-
| `npm run verify` | 0 | 版本、安装策略、依赖、测试、lint、脚本语法、生产构建和 diff 检查均通过。 |
|
| 22 |
-
| `npm run test:postgres` | 0 | 101 个测试通过,包含真实 PostgreSQL 并发和 schema 契约。 |
|
| 23 |
-
| `npm run smoke:hf-space-local` | 0 | 最新 Docker 镜像通过 memory/indexeddb 运行态和非计费 Agent 契约检查。 |
|
| 24 |
-
| `docker build --check .` | 0 | 无 Dockerfile 警告。 |
|
| 25 |
-
| `docker compose ... config --quiet` | 0 | SQLite、memory 和 PostgreSQL Compose 配置均成功渲染。 |
|
| 26 |
-
| CI 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义通过 actionlint。 |
|
| 27 |
-
| 非回环 Docker 入口点检查 | 预期退出码 1 | 容器拒绝 `GIP_COMPOSE_DEPLOYMENT= TRUE `、`GIP_BIND_HOST=0.0.0.0` 且未设置 `APP_PASSWORD` 的启动。 |
|
| 28 |
-
|
| 29 |
-
## 审查证据
|
| 30 |
-
|
| 31 |
-
- CodeRabbit 审查全部已修改和未跟踪文件后未发现问题。
|
| 32 |
-
- Claude Code 使用默认模型且未传 `--model`,报告未发现 P0-P3 问题。
|
| 33 |
-
- OMP 仅识别出本地部署和 CI 的最后一次轮询延迟。两条路径均已改为仅在仍有下一次尝试时等待,并为本地探针补充回归覆盖。
|
| 34 |
-
|
| 35 |
-
## 范围边界
|
| 36 |
-
|
| 37 |
-
- 本门禁不执行计费的图片生成或编辑请求。
|
| 38 |
-
- 真实本地 Docker 和 Hugging Face Space 发布检查属于独立部署验证步骤,因为它们需要干净的已提交 revision 和实时服务状态。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-IMAGE-STREAM-BACKENDS-2026-05-19.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
# CR-IMAGE-STREAM-BACKENDS-2026-05-19
|
| 2 |
-
|
| 3 |
-
## 范围
|
| 4 |
-
|
| 5 |
-
- 固化 Images API 上游流式事件适配层。
|
| 6 |
-
- 抽取 `/api/images` 服务端流式处理公共逻辑。
|
| 7 |
-
- 补充路由级流式契约测试。
|
| 8 |
-
- 增加 provider dialect 诊断,不记录 API key 或原始 base64。
|
| 9 |
-
- 增加受 `ENABLE_RESPONSES_IMAGE_BACKEND` 保护的实验 Responses API 图片后端。
|
| 10 |
-
- 收敛 `route.ts` 复杂度,保持单文件低于 300 行。
|
| 11 |
-
|
| 12 |
-
## 已验证行为
|
| 13 |
-
|
| 14 |
-
- 官方 OpenAI Images 流式事件可归一化为前端稳定 SSE 事件。
|
| 15 |
-
- OtokAPI `image.generation.chunk` / `image.generation.result` 可归一化为前端稳定 SSE 事件。
|
| 16 |
-
- SDK 丢失 `event:` 名称时,仍能识别带图片数据的 fallback payload。
|
| 17 |
-
- 多图 result、无 partial、缺最终图、上游流中断均有路由级契约测试覆盖。
|
| 18 |
-
- 未知 completed-like payload 不伪造成功;无最终图会显式返回 `error` SSE。
|
| 19 |
-
- Responses API 后端默认关闭;只有开启开关、显式传 `imageBackend=responses` 并配置独立 `/responses` 顶层模型才调用 `/responses`。
|
| 20 |
-
- Responses API 实验后端只读取 `image_generation_call.result`,缺结果或失败状态会显式报错。
|
| 21 |
-
|
| 22 |
-
## GPT2Image 评估结论
|
| 23 |
-
|
| 24 |
-
- 可借鉴:对话历史、多变体展示、瀑布并发、参考图上下文。
|
| 25 |
-
- 暂不照搬:浏览器保存 API key、纯前端直连上游、把 Responses API 直接替换现有 Images API 主链路。
|
| 26 |
-
- 后续若要做对话式生图,应新增独立页面或模式,不混入现有生成、编辑、Agent API 与批量流式路径。
|
| 27 |
-
|
| 28 |
-
## 验证记录
|
| 29 |
-
|
| 30 |
-
- `npm test`:通过,256 pass;默认测试中的 Postgres live 用例因未设置 `AGENT_POSTGRES_TEST_DATABASE_URL` 跳过。
|
| 31 |
-
- `npm run test:postgres`:通过,31 pass;脚本拉起真实 PostgreSQL 容器并执行 live gate。
|
| 32 |
-
- `npm run lint`:通过。
|
| 33 |
-
- `npm run build`:通过。
|
| 34 |
-
- `git diff --check`:通过。
|
| 35 |
-
- `docker compose up -d --build`:通过,镜像 `gpt-image-playground-customer:local` 用最新代码重建并启动。
|
| 36 |
-
- Docker HTTP smoke:`/`、`/api/runtime-capabilities`、`/api/agent/capabilities`、`/api/auth-status` 均返回 200;`/api/logs` 在未配置 `APP_PASSWORD` 时返回 403,符合预期。
|
| 37 |
-
- Docker 内 OpenAI 兼容假上游 smoke:非流式 `/api/images` 返回 1 张图片;流式 `/api/images` 返回 `partial_image -> completed -> done`。
|
| 38 |
-
- 浏览器 smoke:Chrome 打开 `http://127.0.0.1:4783/` 成功,首页可访问。
|
| 39 |
-
- 真实上游探针:当前 `.env.local` 渠道 `/v1/models` 和 `/api/images` 均返回 429 `DAILY_LIMIT_EXCEEDED`,确认真实成功出图受上游日限额阻塞。
|
| 40 |
-
|
| 41 |
-
## 未覆盖
|
| 42 |
-
|
| 43 |
-
- 未完成真实 OpenAI 或 OtokAPI 成功出图;当前上游返回 429 `DAILY_LIMIT_EXCEEDED`,需要可用额度后复验。
|
| 44 |
-
- 未实现对话式生图、多轮编辑、多变体产品界面。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md
DELETED
|
@@ -1,189 +0,0 @@
|
|
| 1 |
-
# CR-IMAGE-UPSTREAM-COMPAT-2026-05-21
|
| 2 |
-
|
| 3 |
-
## 范围
|
| 4 |
-
|
| 5 |
-
- 多上游图片生成兼容层阶段验证。
|
| 6 |
-
- 区分本地 mock 覆盖、当前真实上游 smoke、以及本机未配置的上游类型。
|
| 7 |
-
- 记录 2026-05-21 在 `codex/image-upstream-compat` 分支上的验证口径。
|
| 8 |
-
|
| 9 |
-
## 当前真实上游范围
|
| 10 |
-
|
| 11 |
-
`.env.local` 当前只配置了 `superapi.buzz` 这一类真实上游,共 7 个服务端渠道。没有可直接区分的原版 QuantumNous/new-api、gaoren002/new-api、Wei-Shaw/sub2api 或独立 GPT2Image 部署地址。因此“原版 new-api / gaoren / sub2api / GPT2Image”差异主要由本地契约测试覆盖,真实 smoke 只能证明当前 `superapi.buzz` 上游在对应协议路径下的表现。
|
| 12 |
-
|
| 13 |
-
当前独立真实上游配置检查:`.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`。
|
| 14 |
-
|
| 15 |
-
## 真实 smoke 结果
|
| 16 |
-
|
| 17 |
-
运行方式:在当前 worktree 启动 `npx next dev --turbopack -p 4784`;Responses 路径使用 `ENABLE_RESPONSES_IMAGE_BACKEND=true OPENAI_RESPONSES_API_MODEL=gpt-5.4` 重启同端口服务。所有输出均只记录状态、事件类型、图片数量和 base64 长度,不记录 API key 或完整图片数据。
|
| 18 |
-
|
| 19 |
-
| 场景 | 结果 | 证据摘要 |
|
| 20 |
-
| --- | --- | --- |
|
| 21 |
-
| Images API 普通 JSON | 通过 | `/api/images` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1045668`,耗时 `19921ms`。 |
|
| 22 |
-
| Images API SSE / keepalive 策略 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,耗时 `29615ms`。 |
|
| 23 |
-
| Responses image_generation 非流式 | 当前真实上游未通过 | `/api/images` 返回 `500 application/json`,错误为 `Responses API 未返回已完成的 image_generation_call.result。`。该结果说明当前真实上游的非流式 `/responses` 返回结构不满足本项目 final image 契约。 |
|
| 24 |
-
| Responses image_generation SSE 初测 | 暴露缺口后已修复 | 初测事件为 `partial_image, completed, error`,错误为 `response.output_item.done` 缺少图片 payload。修复后无图的 Responses output item done 会被忽略,仍要求整条流最终有 final image。 |
|
| 25 |
-
| Responses image_generation SSE 去重复测 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, completed, done`,`done_image_count=1`,耗时 `46624ms`。 |
|
| 26 |
-
| Agent 内部 Images SSE | 通过 | `/api/agent/images/generate` 返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未返回客户端 SSE,耗时 `96081ms`。 |
|
| 27 |
-
|
| 28 |
-
## 2026-05-22 脚本化真实复验
|
| 29 |
-
|
| 30 |
-
运行方式:`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 长度。
|
| 31 |
-
|
| 32 |
-
| 场景 | 结果 | 证据摘要 |
|
| 33 |
-
| --- | --- | --- |
|
| 34 |
-
| 当前服务端渠道 Images JSON 首次 | 显式失败 | `server-channel-images-json` 返回 `500 application/json`,`error=Connection error.`,耗时 `186537ms`,无图片。 |
|
| 35 |
-
| 当前服务端渠道 Images JSON 复测 | 通过 | `server-channel-images-json` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1332092`,耗时 `164893ms`。 |
|
| 36 |
-
| 当前服务端渠道 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`。 |
|
| 37 |
-
| 当前服务端渠道 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`。 |
|
| 38 |
-
| 当前服务端渠道 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`。 |
|
| 39 |
-
| 当前服务端渠道 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`。 |
|
| 40 |
-
|
| 41 |
-
## 本地契约覆盖
|
| 42 |
-
|
| 43 |
-
- `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。
|
| 44 |
-
- `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 <path>` 加载独立真实 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` 新增图片产物。
|
| 45 |
-
- `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/非对象忽略。
|
| 46 |
-
- `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、以及流式请求参数。
|
| 47 |
-
- `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 错误契约。
|
| 48 |
-
- `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 失败。
|
| 49 |
-
- `src/lib/agent-api-contracts.test.ts` 覆盖 capabilities/OpenAPI 中页面 SSE、Agent 内部 upstream SSE、最终响应契约、后端枚举、流式策略枚举、真正启用上游 SSE 的 activation 策略和默认非流式 Agent 策略。
|
| 50 |
-
- `src/lib/image-stream-service.test.ts` 和 `src/lib/image-stream-collector.test.ts` 覆盖 Responses 流中同一 final image 跨事件重复到达、以及单个完成事件同时经 SDK/Responses 包装层重复抽取时只保存一份最终产物;同一事件内合法多图结果仍保留多张图片。
|
| 51 |
-
- 浏览器 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` 为空,最终显示上述显式错误。
|
| 52 |
-
- 运行态 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`。
|
| 53 |
-
|
| 54 |
-
## 2026-05-22 当前 worktree 基线复验
|
| 55 |
-
|
| 56 |
-
本轮补充复核:`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` 目录无产物残留。
|
| 57 |
-
|
| 58 |
-
| 命令 | 退出码 | 摘要 |
|
| 59 |
-
| --- | --- | --- |
|
| 60 |
-
| `npm test` | 0 | `425` 个测试通过,`0` 个失败。PostgreSQL live 测试因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 |
|
| 61 |
-
| `npm run lint` | 0 | `eslint src` 通过。 |
|
| 62 |
-
| `npm run lint:scripts` | 0 | `scripts/check-node-syntax.mjs` 通过。 |
|
| 63 |
-
| `npm run build` | 0 | Next.js 16.2.6 production build 通过,standalone runtime patch 完成。 |
|
| 64 |
-
| `npm run test:postgres` | 0 | 临时 `postgres:16-alpine` 容器内 55 个测试通过,覆盖 Agent route PostgreSQL 集成、Postgres schema/live concurrency、迁移、清理和 share metadata 契约。 |
|
| 65 |
-
| `npx tsc --noEmit` | 0 | 测试和源码 TypeScript 静态检查通过。 |
|
| 66 |
-
| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 |
|
| 67 |
-
| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 上游兼容场景全部通过。 |
|
| 68 |
-
| `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` 并返回图片。 |
|
| 69 |
-
| `npm run smoke:image-upstream-real -- --help` | 0 | help 输出列出 `--env-file <path>`、`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。 |
|
| 70 |
-
| `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` 跳过,未触发真实生图。 |
|
| 71 |
-
| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-responses-json` | 0 | 非计费 dry-run 通过;当前服务端 Responses JSON smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实��图。 |
|
| 72 |
-
| `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` 跳过,未触发真实生图。 |
|
| 73 |
-
| `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`。 |
|
| 74 |
-
| `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`。 |
|
| 75 |
-
| `npm run smoke:image-upstream-real -- --env-file .env.real-smoke.example --require-independent-targets` | 1 | 预期失败;可提交模板中的空值不会被误判为已配置真实上游,5 个独立真实目标仍全部列入 `skipped_required_cases`。 |
|
| 76 |
-
| `git diff --check` | 0 | 当前 diff 无 whitespace error。 |
|
| 77 |
-
| `find generated-images/.real-smoke ...` | 0 | 当前 `generated-images/.real-smoke` 目录无新增 `png`、`jpg`、`jpeg`、`webp` 产物残留。 |
|
| 78 |
-
|
| 79 |
-
## 2026-05-22 运行态契约复验
|
| 80 |
-
|
| 81 |
-
运行方式:`npx next dev --turbopack -p 4785` 启动本地服务后,只读请求 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json`、`GET /api/runtime-capabilities`。
|
| 82 |
-
|
| 83 |
-
| 端点 | 结果 | 摘要 |
|
| 84 |
-
| --- | --- | --- |
|
| 85 |
-
| `/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`。 |
|
| 86 |
-
| `/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`。 |
|
| 87 |
-
| `/api/runtime-capabilities` | 通过 | `responsesImageBackend.enabled=false`,`mode=experimental`;运行态流式批量能力未默认开启,当前服务端渠道健康容量为 `healthyCredentialCount=7`、`healthyChannelCount=7`。 |
|
| 88 |
-
|
| 89 |
-
## 2026-05-22 推送后补充复验
|
| 90 |
-
|
| 91 |
-
当前 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`,不改变业务运行代码。
|
| 92 |
-
|
| 93 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 94 |
-
| --- | --- | --- |
|
| 95 |
-
| `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` 全部通过。 |
|
| 96 |
-
| `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` 覆盖。 |
|
| 97 |
-
| `npx tsc --noEmit` | 0 | 修复后源码和测试 TypeScript 静态检查通过。 |
|
| 98 |
-
| `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 兼容场景通过。 |
|
| 99 |
-
| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 |
|
| 100 |
-
| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍未配置,当前 `.env.local` 服务端渠道因缺少 `--allow-billable` 未触发生图。 |
|
| 101 |
-
| `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`。 |
|
| 102 |
-
| `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 响应中没有有效图片数据或文件名。”。 |
|
| 103 |
-
| `.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`,不能满足最终真实门禁。 |
|
| 104 |
-
|
| 105 |
-
## 2026-05-22 status readiness 补充
|
| 106 |
-
|
| 107 |
-
在 `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。
|
| 108 |
-
|
| 109 |
-
在 `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`,避免配置非法时产生部分真实上游调用。
|
| 110 |
-
|
| 111 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 112 |
-
| --- | --- | --- |
|
| 113 |
-
| `node --test scripts/command-center.test.mjs` | 0 | 22 个脚本测试通过。 |
|
| 114 |
-
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 22 个真实 smoke 脚本测试通过,新增覆盖 unsafe 独立上游 `BASE_URL` 的结构化 JSON 报告、非法配置时阻断其它 billable 上游调用,以及普通 billable smoke 顶层 `blocked_cases` 汇总。 |
|
| 115 |
-
| `npm run status` | 0 | `image_upstream_real_smoke.configuration_complete=false`、`configured_count=0`、`missing_count=5`,并列出 5 个独立真实上游目标缺失的 `BASE_URL` env。 |
|
| 116 |
-
| 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。 |
|
| 117 |
-
| 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。 |
|
| 118 |
-
| `npm run verify -- --postgres` | 0 | `npm test`、lint、script lint、build、live PostgreSQL gate、diff checks 全部通过。 |
|
| 119 |
-
| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过。 |
|
| 120 |
-
| `npx tsc --noEmit` | 0 | TypeScript 静态检查通过。 |
|
| 121 |
-
| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;仍缺 5 个独立真实上游目标,`final_gate_satisfied=false`。 |
|
| 122 |
-
|
| 123 |
-
## 2026-05-22 final gate preflight 补充
|
| 124 |
-
|
| 125 |
-
本轮基线基于 `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 之前阻断已配置的可运行目标,先输出结构化失败,不发任何真实上游计费请求。该边界避免“最终门禁必然失败但已产生部分真实计费请求”的状态。
|
| 126 |
-
|
| 127 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 128 |
-
| --- | --- | --- |
|
| 129 |
-
| `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 兼容场景通过。 |
|
| 130 |
-
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 23 个真实 smoke 脚本测试通过。新增覆盖最终独立门禁只配置一个目标但缺少其它必跑目标时,已配置目标被列入 `blocked_required_cases`,本地 mock 上游 `calls.length=0`。 |
|
| 131 |
-
| `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`,但由于独立目标配置不完整,仅输出缺失项报告,不进入上游调用路径。 |
|
| 132 |
-
|
| 133 |
-
## 2026-05-22 Responses readiness 模型口径补充
|
| 134 |
-
|
| 135 |
-
本轮继续修正真实 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`。
|
| 136 |
-
|
| 137 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 138 |
-
| --- | --- | --- |
|
| 139 |
-
| `node --test scripts/command-center.test.mjs` | 0 | 新增覆盖 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2` 不会让 status readiness 误判 sub2api Responses 场景配置完成。 |
|
| 140 |
-
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 新增覆盖真实 smoke dry-run 同样不会把 sub2api Responses 图片模型变量当成 `/responses` 顶层模型。 |
|
| 141 |
-
| `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`。 |
|
| 142 |
-
|
| 143 |
-
## 2026-05-22 GPT2Image Responses SSE 去重补充
|
| 144 |
-
|
| 145 |
-
本轮兼容 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 相同仍会保留为不同最终图片。
|
| 146 |
-
|
| 147 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 148 |
-
| --- | --- | --- |
|
| 149 |
-
| `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 只落一张。 |
|
| 150 |
-
| `npm run smoke:image-upstream-compat` | 0 | GPT2Image Responses SSE 场景恢复为 `partial_image, completed, done`,不再出现重复 `completed`。 |
|
| 151 |
-
|
| 152 |
-
## 2026-05-22 fcdd698 基线复验
|
| 153 |
-
|
| 154 |
-
复验基线为 `fcdd698 Fix Responses upstream smoke readiness`,分支 `codex/image-upstream-compat` 已同步到 `origin/codex/image-upstream-compat`。本小节为后续追加的文档记录,不改变业务代码。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`,当前 GitGuardian Security Checks 通过。
|
| 155 |
-
|
| 156 |
-
| 命令或检查 | 退出码 | 摘要 |
|
| 157 |
-
| --- | --- | --- |
|
| 158 |
-
| `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`。 |
|
| 159 |
-
| `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。 |
|
| 160 |
-
| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前服务端渠道未触发真实生图。 |
|
| 161 |
-
| `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`。配置不完整时先输出缺失报告,不进入上游调用路径。 |
|
| 162 |
-
| `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`。 |
|
| 163 |
-
| `npm run verify` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`、`git diff --cached --check` 全部通过。 |
|
| 164 |
-
| `find generated-images/.real-smoke ...` | 0 | 当前没有 `png`、`jpg`、`jpeg`、`webp` 真实 smoke 产物残留。 |
|
| 165 |
-
|
| 166 |
-
本轮没有追加 `--allow-billable` 的真实生图请求。最终完成判定仍缺独立真实上游凭据和地址,需要配置 `.env.real-smoke.local` 后跑通最终门禁命令。
|
| 167 |
-
|
| 168 |
-
## 完成度审计矩阵
|
| 169 |
-
|
| 170 |
-
| 要求 | 当前证据 | 状态 |
|
| 171 |
-
| --- | --- | --- |
|
| 172 |
-
| 默认配置不破坏 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` 真实复测 | 已覆盖 |
|
| 173 |
-
| 流式能力只通过配置、UI 开关、Agent 显式策略或探测确认启用 | `src/lib/image-upstream-strategy.ts`、`src/lib/image-upstream-strategy.test.ts`、浏览器 UI smoke 默认未勾选流式、Agent defaults 为 `streaming_strategy=off` | 已覆盖 |
|
| 174 |
-
| 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 地址缺失 |
|
| 175 |
-
| 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 地址缺失 |
|
| 176 |
-
| 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 地址缺失 |
|
| 177 |
-
| 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 失败用例 | 已覆盖 |
|
| 178 |
-
| 缺 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` 显式失败记录 | 已覆盖 |
|
| 179 |
-
| 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 | 已覆盖 |
|
| 180 |
-
| 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 | 已覆盖 |
|
| 181 |
-
| 三类独立上游真实 smoke:原版 new-api、gaoren/new-api、sub2api/GPT2Image | `scripts/smoke-image-upstream-real.mjs` 已支持独立 `IMAGE_REAL_SMOKE_*` 目标和 `--env-file <path>`;当前 dry-run 证明本机未配置专用 `BASE_URL`,`independent_targets` 汇总缺失目标并给出最终门禁命令,`missing_env_any` 指出缺失 env;`--require-independent-targets --allow-billable` 在配置不完整时会先失败并阻断部分真实调用;配置齐全后才允许最终门禁实际计费执行 | 未完成,缺少独立真实上游地址和 key |
|
| 182 |
-
|
| 183 |
-
## 结论
|
| 184 |
-
|
| 185 |
-
- 当前实现保持默认 Images API JSON 基线,不会自动按仓库名启用流式能力。
|
| 186 |
-
- 页面默认不发送 `stream=true`;用户显式开启流式后,在没有 partial image 前只显示连接保持状态,不把 keepalive 当成预览或成功。
|
| 187 |
-
- 当前真实上游证明 Images JSON、Images SSE、Responses SSE、Agent 内部上游 SSE 可通过本项目稳定契约落到最终产物。
|
| 188 |
-
- 当前真实上游的 Responses 非流式路径未返回符合契约的 `image_generation_call.result`,本项目按设计显式失败。
|
| 189 |
-
- 因本机没有独立原版 new-api、gaoren new-api、sub2api 和 GPT2Image 地址,无法把这四类实现分别做真实 smoke;对应兼容行为以本地 mock 契约测试作为当前证据。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-MAINTENANCE-CLEANUP-2026-07-27.md
DELETED
|
@@ -1,52 +0,0 @@
|
|
| 1 |
-
# 维护清理与质量门禁审计
|
| 2 |
-
|
| 3 |
-
日期: 2026-07-27
|
| 4 |
-
|
| 5 |
-
基线: `6590d91`
|
| 6 |
-
|
| 7 |
-
分支: `codex/maintenance-cleanup-tests-docs`
|
| 8 |
-
|
| 9 |
-
范围: 未使用代码和直接依赖清理、核心验证计划补测、源码格式统一、CI 门禁和维护文档。
|
| 10 |
-
|
| 11 |
-
## 清理结论
|
| 12 |
-
|
| 13 |
-
- 删除未被生产代码、测试或脚本引用的 `toggle.tsx` 和 `toggle-group.tsx`。
|
| 14 |
-
- 删除随上述组件失去用途的 `@radix-ui/react-toggle`、`@radix-ui/react-toggle-group` 直接依赖。
|
| 15 |
-
- 删除未被 ESLint 配置直接使用的 `@eslint/eslintrc` 直接依赖;lockfile 中由 ESLint 引入的传递依赖继续保留。
|
| 16 |
-
- 保留 `brace-expansion` 兼容包,其仍由安全兼容层、Docker 构建和测试使用。
|
| 17 |
-
- 保留 `happy-dom`,其仍由 `src/test-utils/react-dom.ts` 使用。
|
| 18 |
-
- 未根据 Knip 的未使用文件报告批量删除文件。当前自定义测试入口和 Next.js 隐式路由会被该类静态扫描误报,删除前仍需逐项结合引用和框架约定确认。
|
| 19 |
-
|
| 20 |
-
## 测试与格式门禁
|
| 21 |
-
|
| 22 |
-
- 新增 `format:check`,以只读方式校验 `src/` 下 TypeScript 和 TSX 文件的 Prettier 格式。
|
| 23 |
-
- `npm run verify` 的 full 和 skip-build 计划均包含 `format:check`,quick 计划保持轻量,不隐藏 full gate。
|
| 24 |
-
- 命令中心测试覆盖普通 full、skip-build 和 full with PostgreSQL 三种计划中的格式检查顺序。
|
| 25 |
-
- CI 在源码 lint 后执行 `format:check`,格式漂移会显式失败。
|
| 26 |
-
- 对现有 `src/` TypeScript 和 TSX 文件执行一次统一格式化;改动仅涉及导入排序、换行、空白和 Tailwind class 排序。
|
| 27 |
-
|
| 28 |
-
## 自动化证据
|
| 29 |
-
|
| 30 |
-
| 命令 | 退出码 | 结果 |
|
| 31 |
-
| --- | --- | --- |
|
| 32 |
-
| `npm test -- scripts/command-center.test.mjs` | 0 | 46 个命令中心测试通过,包含新增格式门禁计划断言。 |
|
| 33 |
-
| `npm test -- --experimental-test-coverage --test-reporter=dot` | 0 | 全量测试在 Node 覆盖率插桩模式下通过;本次不设覆盖率百分比门槛。 |
|
| 34 |
-
| `npm run verify -- --postgres` | 0 | 版本、安装策略、依赖、全量测试、lint、格式、脚本语法、生产构建、真实 PostgreSQL gate 和 diff 检查均通过。 |
|
| 35 |
-
| `npm audit --audit-level=high` | 0 | 报告 `found 0 vulnerabilities`。 |
|
| 36 |
-
| 固定 digest 的 actionlint 容器 | 0 | GitHub Actions 工作流语法和语义检查通过。 |
|
| 37 |
-
| `docker build --check .` | 0 | Dockerfile 检查完成,无警告。 |
|
| 38 |
-
| `docker compose config --quiet` | 0 | 默认 SQLite Compose 配置成功渲染。 |
|
| 39 |
-
| `docker compose -f docker-compose.yml -f docker-compose.memory.yml config --quiet` | 0 | memory overlay 配置成功渲染。 |
|
| 40 |
-
| `docker compose -f docker-compose.yml -f docker-compose.postgres.yml config --quiet` | 0 | PostgreSQL overlay 配置成功渲染。 |
|
| 41 |
-
|
| 42 |
-
## 独立审查
|
| 43 |
-
|
| 44 |
-
- OMP 17.0.6 使用默认模型在隔离 worktree 中审查 `6590d91..HEAD`,未发现 P0、P1、P2 或 P3 问题。
|
| 45 |
-
- OMP 独立核对删除引用、lockfile 根依赖、格式门禁接线、quick 计划、格式化语义、文档和 CI,并复跑依赖安装、格式检查、命令中心测试、actionlint 和 diff 检查。
|
| 46 |
-
- OMP 未复跑的全量 verify、真实 PostgreSQL gate、依赖安全审计、Dockerfile 和 Compose 检查,均由主工作区的自动化证据覆盖。
|
| 47 |
-
|
| 48 |
-
## 范围边界
|
| 49 |
-
|
| 50 |
-
- 本次不修改图片生成、渠道路由、认证、存储或 Agent API 的业务行为。
|
| 51 |
-
- 覆盖率插桩用于确认核心测试仍实际执行,不将覆盖率数字作为本次删除代码的依据。
|
| 52 |
-
- 本次未执行计费图片请求,也未将自动化测试结果表述为真实上游渠道或 Hugging Face Space 已验证。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
# Product Stage 1 Gate Review - 2026-06-06
|
| 2 |
-
|
| 3 |
-
## Scope
|
| 4 |
-
|
| 5 |
-
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.
|
| 6 |
-
|
| 7 |
-
## Evidence
|
| 8 |
-
|
| 9 |
-
| Check | Command | Exit | Result |
|
| 10 |
-
| --- | --- | --- | --- |
|
| 11 |
-
| Full local gate | `npm run verify` | 0 | `version:check`, `test`, `lint`, `lint:scripts`, `build`, `diff-check` and `diff-cached-check` passed. |
|
| 12 |
-
| Local browser check | `http://localhost:4784` | 0 | Recent history card rendered `结果反馈`, `可用`, `需修改` and the matching mark buttons on a real browser page. |
|
| 13 |
-
| 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. |
|
| 14 |
-
| 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. |
|
| 15 |
-
| 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. |
|
| 16 |
-
| Script tests | `npm run test:scripts` | 0 | 187 tests passed. |
|
| 17 |
-
| HF Space local doctor | `npm run doctor:hf-space -- --skip-remote` | 0 | Local checks passed; remote Space checks were intentionally skipped. |
|
| 18 |
-
| 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. |
|
| 19 |
-
| 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. |
|
| 20 |
-
| 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`. |
|
| 21 |
-
| 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`. |
|
| 22 |
-
| 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. |
|
| 23 |
-
| 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. |
|
| 24 |
-
| 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`. |
|
| 25 |
-
| Diff check | `git diff --check` | 0 | No whitespace or patch-format issues. |
|
| 26 |
-
|
| 27 |
-
## Product Contract
|
| 28 |
-
|
| 29 |
-
- 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.
|
| 30 |
-
- Non-goals: public SaaS, enterprise asset approval systems, autonomous Agent scheduling and generic OpenAI-compatible benchmarking are explicitly outside Stage 1.
|
| 31 |
-
- 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.
|
| 32 |
-
- Metrics: the contract records third-minute generation, thirtieth-minute reuse, third-day return, result quality marking and explicit failure-recovery expectations.
|
| 33 |
-
- Evidence standard: `docs/product/user-validation-script.md` uses past-behavior and task evidence rather than opinion prompts.
|
| 34 |
-
|
| 35 |
-
## Share Safety
|
| 36 |
-
|
| 37 |
-
- Default expiry: `src/components/share-dialog.tsx` exports `DEFAULT_SHARE_EXPIRY_VALUE = '1440'`, making new share links default to a 1-day expiry.
|
| 38 |
-
- No-access-code warning: `share.publicRiskHint` appears in both Chinese and English copy and is rendered below the access-code input.
|
| 39 |
-
- 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.
|
| 40 |
-
|
| 41 |
-
## Public Deployment
|
| 42 |
-
|
| 43 |
-
- `APP_PASSWORD` gate: README, customer instructions and HF Space docs all state that public customer-visible deployments must configure page access protection.
|
| 44 |
-
- `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.
|
| 45 |
-
- Free-tier persistence boundary: HF Space docs keep `memory` mode and temporary file-system behavior visible; this is not represented as production-grade persistence.
|
| 46 |
-
- 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.
|
| 47 |
-
- 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.
|
| 48 |
-
|
| 49 |
-
## Agent API Boundary
|
| 50 |
-
|
| 51 |
-
- Automation API wording: README and skill docs describe Agent API as a machine interface for automation clients.
|
| 52 |
-
- Non-goals: docs explicitly say this is not an autonomous Agent platform, long-running scheduler, cross-instance persistent queue or production orchestration layer.
|
| 53 |
-
- Existing contract preserved: this stage did not modify `/api/agent/*` schema or Agent route implementation files.
|
| 54 |
-
|
| 55 |
-
## Residual Risks
|
| 56 |
-
|
| 57 |
-
- 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.
|
| 58 |
-
- 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.
|
| 59 |
-
- `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.
|
| 60 |
-
- 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.
|
| 61 |
-
- Multi-instance persistence, production object storage and customer SaaS readiness remain outside Stage 1 by product contract.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/CR-SKILL-API-BOUNDARY-2026-06-08.md
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
# CR-SKILL-API-BOUNDARY-2026-06-08
|
| 2 |
-
|
| 3 |
-
## 范围
|
| 4 |
-
|
| 5 |
-
- 补齐单张 `generate-image.mjs` 对页面 SSE 已支持的 Responses/GPT2Image 高级参数覆盖。
|
| 6 |
-
- 明确 Agent JSON API、页面 `/api/images` SSE、分享、日志、runtime capabilities、页面删除和前端本地体验的边界。
|
| 7 |
-
- 不修改 Agent API schema、OpenAPI schema、数据库 schema、真实上游门禁或页面主链实现。
|
| 8 |
-
|
| 9 |
-
## 代码与文档结论
|
| 10 |
-
|
| 11 |
-
- `generate-image.mjs` 现在支持 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 和 `--force-web`。
|
| 12 |
-
- 这些字段被定义为页面 SSE 专属字段:dry-run 会显示 `/api/images` 路由,真实 page SSE form-data 会发送 `responsesModel`、`thinking`、`promptOptimization` 和 `force_web`。
|
| 13 |
-
- 显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 与上述页面高级字段同时出现时会在网络请求前失败。
|
| 14 |
-
- `responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容别名 `responses`。
|
| 15 |
-
- README、Skill 文档和 API reference 已明确:分享、日志、runtime capabilities、页面图片删除、结果反馈、灵感相册和历史复用不属于 Agent JSON API 或 Agent OpenAPI。
|
| 16 |
-
- README 和 API reference 已补充前端能力到 API 边界的对照矩阵,避免把页面工作台能力误归入 Agent JSON API。
|
| 17 |
-
- `agent-skill-scripts.test.mjs` 增加文档与端点边界 drift guard:页面 API 必须出现在 README、Skill 和 API reference 的边界说明中,同时不得进入 `AGENT_ENDPOINTS`。
|
| 18 |
-
|
| 19 |
-
## 验证记录
|
| 20 |
-
|
| 21 |
-
| 命令 | 结果 | 摘要 |
|
| 22 |
-
| --- | --- | --- |
|
| 23 |
-
| `node --test scripts/agent-skill-scripts.test.mjs` | 通过 | 86 个脚本测试通过,覆盖 generate 高级参数 dry-run、page SSE form-data、显式 Agent route 拒绝、关闭流式拒绝、参数校验和 WebUI/Agent 边界 drift guard。 |
|
| 24 |
-
| `NODE_ENV=test node --test --import tsx src/app/api/agent/agent-routes.test.ts` | 通过 | 47 个 Agent route 测试通过;PostgreSQL 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 |
|
| 25 |
-
| `NODE_ENV=test node --test --import tsx src/app/api/images/route.test.ts` | 通过 | 40 个页面 `/api/images` 流式与 Responses/GPT2Image 字段测试通过。 |
|
| 26 |
-
| `NODE_ENV=test node --test --import tsx src/app/api/logs/route.test.ts src/app/api/shares/route.test.ts` | 通过 | 24 个日志与分享页面 API 测试通过。 |
|
| 27 |
-
| `npm run lint:scripts` | 通过 | `scripts/check-node-syntax.mjs` 通过。 |
|
| 28 |
-
| `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`。 |
|
| 29 |
-
| `git diff --check` | 通过 | 当前 diff 无 whitespace error。 |
|
| 30 |
-
|
| 31 |
-
## 残余 gate
|
| 32 |
-
|
| 33 |
-
- `npm run verify` 本轮输出 `postgres=false`,未覆盖真实 PostgreSQL gate。
|
| 34 |
-
- 本轮没有执行真实上游 `--allow-billable` smoke。
|
| 35 |
-
- 本轮没有执行 Docker、Hugging Face Space 或生产部署 gate。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/reviews/PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20.md
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
# PR-2-CONTRIBUTION-ACKNOWLEDGEMENT-2026-05-20
|
| 2 |
-
|
| 3 |
-
## 范围
|
| 4 |
-
|
| 5 |
-
- PR: #2 Improve Docker standalone deployment and auth handling
|
| 6 |
-
- 作者: KwokYYYY <875596331@qq.com>
|
| 7 |
-
- 原始提交: c5b935cffdfa8604bba18cf34bf5c2e091401f45
|
| 8 |
-
- 原始分支: KwokYYYY/docker-standalone-auth-fixes
|
| 9 |
-
|
| 10 |
-
## 处理结论
|
| 11 |
-
|
| 12 |
-
PR #2 的 Docker standalone deployment 与 auth handling 方向已在内部集成分支中吸收,并通过后续主线提交完成适配、验证和部署。
|
| 13 |
-
|
| 14 |
-
截至 2026-05-20,PR #2 仍以 `main` 为目标分支保持打开状态,但其原始实现已与当前主线发生冲突,不再适合直接合并。为避免把过时实现重新引入主线,本仓库保留此贡献确认记录,并在对应提交中使用 `Co-authored-by` 保留作者贡献归属。
|
| 15 |
-
|
| 16 |
-
## 当前主线状态
|
| 17 |
-
|
| 18 |
-
- 当前主线已完成 Docker standalone runtime 补齐、页面访问保护、图片访问保护、Agent 状态后端、流式图片后端适配和测试环境恢复。
|
| 19 |
-
- 当前主线已通过 `npm test`、`npm run lint`、`npm run build`、`npm run test:postgres` 和 Docker HTTP smoke 验证。
|
| 20 |
-
- PR #2 应关闭为已吸收处理,不再直接合并。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-05-12-agent-api-skill.md
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
# Agent API and Skill Implementation Plan
|
| 2 |
-
|
| 3 |
-
> **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.
|
| 4 |
-
|
| 5 |
-
**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.
|
| 6 |
-
|
| 7 |
-
**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.
|
| 8 |
-
|
| 9 |
-
**Tech Stack:** Next.js App Router, OpenAI JavaScript SDK, better-sqlite3, pg, node:test, Docker Compose.
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## Tasks
|
| 14 |
-
|
| 15 |
-
- [x] Add Agent contracts, structured errors, and auth helpers.
|
| 16 |
-
- [x] Add SQLite and PostgreSQL state store implementations.
|
| 17 |
-
- [x] Add Agent generate, edit, capabilities, OpenAPI, artifact metadata, content, and delete routes.
|
| 18 |
-
- [x] Add database schema files and Docker/PostgreSQL deployment template.
|
| 19 |
-
- [x] Add repository skill package with scripts and API reference.
|
| 20 |
-
- [x] Run full validation: test, lint, build, Docker compose checks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-05-15-image-download-share.md
DELETED
|
@@ -1,1509 +0,0 @@
|
|
| 1 |
-
# 图片下载与分享实现计划
|
| 2 |
-
|
| 3 |
-
> **给执行代理:** 必须使用子技能:推荐 `superpowers:subagent-driven-development`,或使用 `superpowers:executing-plans`,按任务逐项执行本计划。步骤使用复选框(`- [ ]`)语法跟踪。
|
| 4 |
-
|
| 5 |
-
**目标:** 在现有“发送到编辑”操作旁补齐图片下载和分享操作;分享链接支持可选访问码和可选有效期。
|
| 6 |
-
|
| 7 |
-
**架构:** 生成图片字节仍保存在现有存储路径中,分享功能通过 `generated-images/.shares` 下复制出的不可变分享产物对外提供。浏览器结果面板把图片读取职责交给 `src/app/page.tsx`,`src/components/image-output.tsx` 只保留展示和动作入口职责。分享功能使用专门的 App Router 路由创建分享、读取受保护元数据并返回分享图片内容。
|
| 8 |
-
|
| 9 |
-
**技术栈:** Next.js App Router、React 19、配合 tsx 的 node:test、现有 shadcn/radix UI 基础组件、`src/lib/share-store.ts`、现有 fs/IndexedDB 图片读取路径。
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## CSE 控制合同
|
| 14 |
-
|
| 15 |
-
**主目标变量:** 已生成或已选中的单张图片可以下载到本地,也可以通过公开 URL 分享;该 URL 必须强制执行可选访问码和有效期。
|
| 16 |
-
|
| 17 |
-
**验收标准:**
|
| 18 |
-
- 在结果面板的单图视图中,用户能按以下顺序看到动作按钮:可用时显示日志、发送到编辑、下载、分享。
|
| 19 |
-
- 下载功能使用当前显示文件名保存当前显示图片的准确字节内容。
|
| 20 |
-
- 分享弹窗可以创建无访问码、有访问码、有有效期、同时有访问码和有效期四类链接。
|
| 21 |
-
- 访问需要访问码的分享链接时,未提供正确访问码不得暴露图片字节。
|
| 22 |
-
- 访问已过期分享链接时不得暴露图片字节。
|
| 23 |
-
- API 测试覆盖创建、读取、内容返回的成功路径和失败路径。
|
| 24 |
-
- UI 测试或浏览器冒烟测试覆盖按钮可见性和分享弹窗流程。
|
| 25 |
-
- 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。
|
| 26 |
-
|
| 27 |
-
**护栏指标:**
|
| 28 |
-
- 不得把受访问码保护的 `/api/image/{filename}` 直接暴露为分享机制。
|
| 29 |
-
- 不得把 API Key、访问码或原始提示词写入分享 URL。
|
| 30 |
-
- 不得破坏 Agent 产物内容路由。
|
| 31 |
-
- 不得破坏 `http://localhost:4783` 下无访问码本地部署的既有行为。
|
| 32 |
-
- 不得静默回退到 mock 图片或伪造分享成功。
|
| 33 |
-
|
| 34 |
-
**采样计划:**
|
| 35 |
-
- L0:每个后端任务后运行定向 node 测试。
|
| 36 |
-
- L1:API 和 UI 集成任务后运行 `npm test`。
|
| 37 |
-
- L2:端到端接线完成后运行浏览器或 Docker 冒烟测试。
|
| 38 |
-
|
| 39 |
-
**已知时滞与时滞预算:**
|
| 40 |
-
- `npm run build` 和 Docker 重建属于慢反馈门禁;推迟到 L0/L1 稳定后执行。
|
| 41 |
-
- 浏览器冒烟测试依赖运行中的应用,可能需要一次开发服务器或 Docker 启动周期。
|
| 42 |
-
|
| 43 |
-
**恢复目标:**
|
| 44 |
-
- 所有改动都是普通源码改动,应能在 10 分钟内通过回滚任务提交或当前 diff 恢复。
|
| 45 |
-
|
| 46 |
-
**回滚触发器:**
|
| 47 |
-
- 如果分享 URL 在未满足访问码或有效期检查时能够暴露图片字节,立即停止并回滚分享路由改动。
|
| 48 |
-
- 如果现有图片生成、历史选择或发送到编辑在 L1 测试中回归,停止功能推进并优先修复该回归。
|
| 49 |
-
|
| 50 |
-
**约束:**
|
| 51 |
-
- 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。
|
| 52 |
-
- 当前脏工作区包含访问码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。
|
| 53 |
-
- 保持当前 `node:test` 布局;不要引入第二套测试框架。
|
| 54 |
-
- 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。
|
| 55 |
-
|
| 56 |
-
**边界:**
|
| 57 |
-
- 允许触碰的后端文件:`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`,以及匹配的路由测试。
|
| 58 |
-
- 允许触碰的前端文件:`src/app/page.tsx`、`src/components/image-output.tsx`、可选新增 `src/components/share-dialog.tsx`、`src/lib/i18n.tsx`,以及可选测试。
|
| 59 |
-
- 允许触碰的文档:本计划;实现后如果行为需要面向用户说明,可补充一小段 README 说明。
|
| 60 |
-
- 冻结边界:Agent API schema、现有 `/api/agent/*` 行为、现有 `/api/image/{filename}` 认证契约、数据库 schema。
|
| 61 |
-
|
| 62 |
-
**耦合说明:**
|
| 63 |
-
- 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。
|
| 64 |
-
- 分享内容必须独立于页面访问码 cookie;否则外部接收者无法访问有效分享。
|
| 65 |
-
- 下载是纯浏览器行为,不应要求新增服务端状态。
|
| 66 |
-
- 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。
|
| 67 |
-
- 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。
|
| 68 |
-
- ��访问码保护的分享在元数据路由中不得暴露原始文件名;原始文件名可能包含提示词或业务信息。
|
| 69 |
-
- 内容路由必须对错误访问码做基础失败限流,并返回明确的 429,不得允许无限次在线猜测。
|
| 70 |
-
|
| 71 |
-
**近似有效性:**
|
| 72 |
-
- 使用临时 `process.cwd()` 目录的单元测试可以验证 share-store 语义,但不能证明 Docker volume 持久化。
|
| 73 |
-
- 浏览器冒烟测试验证 UI 接线,但不验证长期过期行为;过期逻辑由固定 `now` 的确定性单元测试覆盖。
|
| 74 |
-
- 内容路由的内存限流只覆盖单进程实例。多实例部署或进程重启会丢失失败计数;如果未来部署到多副本或边缘/CDN 架构,需要改用外部共享存储限流。
|
| 75 |
-
|
| 76 |
-
**执行器预算:**
|
| 77 |
-
- 新增小型 API 路由和聚焦的 UI 动作。
|
| 78 |
-
- 复用现有 share-store 基础能力,而不是替换图片存储。
|
| 79 |
-
- 实现前围绕契约补充测试。
|
| 80 |
-
|
| 81 |
-
**风险:**
|
| 82 |
-
- 风险 1:分享路由意外暴露原始受保护图片 URL。缓解:只通过分享 token 路由返回复制出的分享产物。
|
| 83 |
-
- 风险 2:访问码或有效期只在 UI 层校验。缓解:在服务端路由强制校验,并测试直接 HTTP 路径。
|
| 84 |
-
- 风险 3:UI 动作破坏多图网格布局。缓解:在网格视图隐藏或禁用图片动作,并在浏览器中验证按钮几何布局。
|
| 85 |
-
|
| 86 |
-
## 项目控制拓扑
|
| 87 |
-
|
| 88 |
-
**总体设计负责人:** 本仓库 `AGENTS.md` 和用户当前指令是参考输入。任何超出本计划的共享路由契约变更都应停止并等待明确确认。
|
| 89 |
-
|
| 90 |
-
**主落点:** 数据面。本功能改变用户读取和对外暴露生成图片字节的方式。
|
| 91 |
-
|
| 92 |
-
**次级落点:** 状态面负责 `generated-images/.shares` 下复制出的分享记录;控制面只负责请求时的有效期和访问码决策。
|
| 93 |
-
|
| 94 |
-
**冻结边界:**
|
| 95 |
-
- `/api/agent/*` 契约保持不变。
|
| 96 |
-
- 现有生成图片文件名和 `/api/image/{filename}` 校验保持不变。
|
| 97 |
-
- 现有历史记录存储形状保持不变,除非后续任务明确证明必须修改。
|
| 98 |
-
|
| 99 |
-
**复杂性转移账本:**
|
| 100 |
-
|
| 101 |
-
| 字段 | 内容 |
|
| 102 |
-
| --- | --- |
|
| 103 |
-
| 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 |
|
| 104 |
-
| 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 |
|
| 105 |
-
| 收益 | 外部分享访问不再依赖页面访问码 cookie 或浏览器本地 IndexedDB 状态。 |
|
| 106 |
-
| 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 |
|
| 107 |
-
| 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 |
|
| 108 |
-
|
| 109 |
-
## 只读调查得到的当前状态
|
| 110 |
-
|
| 111 |
-
- 证据命令:`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`。
|
| 112 |
-
- `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。
|
| 113 |
-
- `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。
|
| 114 |
-
- `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。
|
| 115 |
-
- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面访问码错误码。
|
| 116 |
-
- `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。
|
| 117 |
-
- `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。
|
| 118 |
-
- `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。
|
| 119 |
-
- `src/app` 当前没有 `api/shares` 路由,也没有 `share/[token]` 页面;`find src/app -path "*shares*" -o -path "*share*"` 无返回路径。
|
| 120 |
-
- `src/app/page.tsx` 已知道如何为发送到编辑和历史选择读取已选图片 blob;分享/下载应复用这条路径,而不是新增第二套图片加载来源。
|
| 121 |
-
- `package.json` 当前使用 Next.js 16 和 React 19,`React.use(params)` 的 App Router 页面写法与当前技术栈匹配。
|
| 122 |
-
|
| 123 |
-
## 文件结构
|
| 124 |
-
|
| 125 |
-
- 修改 `src/lib/share-store.ts`
|
| 126 |
-
- 保留存储基础能力。仅当路由测试需要稳定错误原因时,才增加小型校验 helper。
|
| 127 |
-
- 修改 `src/lib/share-store.test.ts`
|
| 128 |
-
- 保留现有测试。仅在缺失时补充支撑路由的边界用例。
|
| 129 |
-
- 创建 `src/app/api/shares/route.ts`
|
| 130 |
-
- 接收 multipart 表单数据,包含 `image`、`sourceFilename`、可选 `accessCode`、可选 `expiresInMinutes`。
|
| 131 |
-
- 返回 `{ token, url, expiresAt, accessCodeRequired }`。
|
| 132 |
-
- 创建 `src/app/api/shares/route.test.ts`
|
| 133 |
-
- 测试分享创建成功、无效文件、无效有效期,以及响应中不包含原始访问码。
|
| 134 |
-
- 创建 `src/app/api/shares/[token]/route.ts`
|
| 135 |
-
- 返回分享页需要的公开元数据:可公开展示的文件名、MIME 类型、大小、createdAt、expiresAt、accessCodeRequired、expired。
|
| 136 |
-
- 对受访问码保护的分享,元数据中的文件名必须脱敏为通用名称。
|
| 137 |
-
- 永不返回 `accessCodeHash` 或 `accessCodeSalt`。
|
| 138 |
-
- 创建 `src/app/api/shares/[token]/content/route.ts`
|
| 139 |
-
- 仅当 token 存在、未过期,且需要访问码时访问码有效,才返回图片字节。
|
| 140 |
-
- 对连续错误访问码返回 `429 share_rate_limited`,并设置禁止共享内容被中间层缓存的响应头。
|
| 141 |
-
- 创建 `src/app/api/share-route.test.ts`
|
| 142 |
-
- 导入动态路由模块,并用固定临时 cwd 测试元数据/内容行为。
|
| 143 |
-
- 创建 `src/app/share/[token]/page.tsx`
|
| 144 |
-
- 渲染一个小型分享查看器。如果需要访问码,先收集访问码再加载图片字节。
|
| 145 |
-
- 创建 `src/components/share-dialog.tsx`
|
| 146 |
-
- 受控弹窗,包含访问码、有效期选择、创建按钮和复制链接动作。
|
| 147 |
-
- 修改 `src/components/image-output.tsx`
|
| 148 |
-
- 在发送到编辑旁添加下载/分享图标按钮。
|
| 149 |
-
- 不在单图视图时,保持按钮不可见或禁用。
|
| 150 |
-
- 修改 `src/app/page.tsx`
|
| 151 |
-
- 添加已选图片 blob 解析器。
|
| 152 |
-
- 添加 `handleDownloadImage` 和 `handleCreateShare`。
|
| 153 |
-
- 将处理函数传给 `ImageOutput`。
|
| 154 |
-
- 修改 `src/lib/i18n.tsx`
|
| 155 |
-
- 添加下载、分享、分享弹窗、错误、复制成功所需的中英文短文案。
|
| 156 |
-
- 可选创建 `src/components/image-output.test.tsx`
|
| 157 |
-
- 仅当现有工具链已支持 DOM 渲染时创建;否则优先使用浏览器冒烟测试和路由/单元测试。
|
| 158 |
-
|
| 159 |
-
## 黑盒输入/输出矩阵
|
| 160 |
-
|
| 161 |
-
| 控制输入 | 目标输出 | 方向 | 外溢风险 |
|
| 162 |
-
| --- | --- | --- | --- |
|
| 163 |
-
| 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 |
|
| 164 |
-
| 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 |
|
| 165 |
-
| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面访问码 cookie |
|
| 166 |
-
| 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 |
|
| 167 |
-
|
| 168 |
-
## 状态模型
|
| 169 |
-
|
| 170 |
-
分享生命周期:
|
| 171 |
-
|
| 172 |
-
1. 浏览器弹窗中的 `draft`。
|
| 173 |
-
2. 正在把已选图片字节上传到 `/api/shares` 时为 `creating`。
|
| 174 |
-
3. 元数据和复制内容写入后为 `active`。
|
| 175 |
-
4. 需要访问码且查看者尚未提供有效访问码时为 `locked`。
|
| 176 |
-
5. 当 `now >= expiresAt` 时为 `expired`。
|
| 177 |
-
6. token 或内容缺失时为 `not_found`。
|
| 178 |
-
|
| 179 |
-
服务端不变量:
|
| 180 |
-
|
| 181 |
-
- Token 为 24 个十六进制字符。
|
| 182 |
-
- Token 必须来自 `crypto.randomBytes(12)`,保持 96 bit CSPRNG 熵。
|
| 183 |
-
- 分享内容路径必须解析到 `generated-images/.shares` 内部。
|
| 184 |
-
- 访问码永不明文存储。
|
| 185 |
-
- 访问码永不返回给客户端。
|
| 186 |
-
- 访问码为空字符串或纯空白时视为未设置;非空访问码长度必须为 8 到 128 字符。
|
| 187 |
-
- 已过期分享永不返回图片字节。
|
| 188 |
-
|
| 189 |
-
## 任务
|
| 190 |
-
|
| 191 |
-
### 任务 1:锁定分享路由契约
|
| 192 |
-
|
| 193 |
-
**文件:**
|
| 194 |
-
- 创建:`src/app/api/shares/route.test.ts`
|
| 195 |
-
- 创建:`src/app/api/share-route.test.ts`
|
| 196 |
-
|
| 197 |
-
- [x] **步骤 1:编写分享创建的失败测试**
|
| 198 |
-
|
| 199 |
-
创建 `src/app/api/shares/route.test.ts`:
|
| 200 |
-
|
| 201 |
-
```ts
|
| 202 |
-
import assert from 'node:assert/strict';
|
| 203 |
-
import fs from 'node:fs/promises';
|
| 204 |
-
import os from 'node:os';
|
| 205 |
-
import path from 'node:path';
|
| 206 |
-
import { afterEach, describe, it } from 'node:test';
|
| 207 |
-
import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
|
| 208 |
-
import { createAccessToken } from '@/lib/server-runtime';
|
| 209 |
-
import { POST } from './route';
|
| 210 |
-
import { NextRequest } from 'next/server';
|
| 211 |
-
|
| 212 |
-
const originalAppPassword = process.env.APP_PASSWORD;
|
| 213 |
-
let previousCwd: string;
|
| 214 |
-
let tempDir: string;
|
| 215 |
-
|
| 216 |
-
async function withTempCwd() {
|
| 217 |
-
previousCwd = process.cwd();
|
| 218 |
-
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-route-'));
|
| 219 |
-
process.chdir(tempDir);
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
afterEach(async () => {
|
| 223 |
-
if (previousCwd) process.chdir(previousCwd);
|
| 224 |
-
if (tempDir) await fs.rm(tempDir, { recursive: true, force: true });
|
| 225 |
-
if (originalAppPassword === undefined) {
|
| 226 |
-
delete process.env.APP_PASSWORD;
|
| 227 |
-
} else {
|
| 228 |
-
process.env.APP_PASSWORD = originalAppPassword;
|
| 229 |
-
}
|
| 230 |
-
});
|
| 231 |
-
|
| 232 |
-
function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) {
|
| 233 |
-
const headers = new Headers();
|
| 234 |
-
const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'access', 'code'].join('-')) : options.accessToken;
|
| 235 |
-
if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`);
|
| 236 |
-
return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form });
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
describe('POST /api/shares', () => {
|
| 240 |
-
it('creates a share from an uploaded image without returning secrets', async () => {
|
| 241 |
-
await withTempCwd();
|
| 242 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 243 |
-
const form = new FormData();
|
| 244 |
-
form.set('sourceFilename', 'result.png');
|
| 245 |
-
form.set('accessCode', '12345678');
|
| 246 |
-
form.set('expiresInMinutes', '60');
|
| 247 |
-
form.set('image', new File([new Uint8Array([1, 2, 3])], 'result.png', { type: 'image/png' }));
|
| 248 |
-
|
| 249 |
-
const response = await POST(createShareRequest(form));
|
| 250 |
-
assert.equal(response.status, 201);
|
| 251 |
-
const body = await response.json();
|
| 252 |
-
|
| 253 |
-
assert.match(body.token, /^[a-f0-9]{24}$/);
|
| 254 |
-
assert.equal(body.accessCodeRequired, true);
|
| 255 |
-
assert.equal(typeof body.url, 'string');
|
| 256 |
-
assert.ok(body.url.includes(`/share/${body.token}`));
|
| 257 |
-
assert.equal('accessCodeHash' in body, false);
|
| 258 |
-
assert.equal('accessCodeSalt' in body, false);
|
| 259 |
-
});
|
| 260 |
-
|
| 261 |
-
it('rejects unauthenticated share creation when a page access code is configured', async () => {
|
| 262 |
-
await withTempCwd();
|
| 263 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 264 |
-
const form = new FormData();
|
| 265 |
-
form.set('sourceFilename', 'result.png');
|
| 266 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 267 |
-
|
| 268 |
-
const response = await POST(createShareRequest(form, { accessToken: null }));
|
| 269 |
-
assert.equal(response.status, 401);
|
| 270 |
-
const body = await response.json();
|
| 271 |
-
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing);
|
| 272 |
-
});
|
| 273 |
-
|
| 274 |
-
it('rejects share creation with an invalid page access token', async () => {
|
| 275 |
-
await withTempCwd();
|
| 276 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 277 |
-
const form = new FormData();
|
| 278 |
-
form.set('sourceFilename', 'result.png');
|
| 279 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 280 |
-
|
| 281 |
-
const response = await POST(createShareRequest(form, { accessToken: 'invalid-access-token' }));
|
| 282 |
-
assert.equal(response.status, 401);
|
| 283 |
-
const body = await response.json();
|
| 284 |
-
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
|
| 285 |
-
});
|
| 286 |
-
|
| 287 |
-
it('allows share creation when no page access code is configured', async () => {
|
| 288 |
-
await withTempCwd();
|
| 289 |
-
delete process.env.APP_PASSWORD;
|
| 290 |
-
const form = new FormData();
|
| 291 |
-
form.set('sourceFilename', 'result.png');
|
| 292 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 293 |
-
|
| 294 |
-
const response = await POST(createShareRequest(form, { accessToken: null }));
|
| 295 |
-
assert.equal(response.status, 201);
|
| 296 |
-
});
|
| 297 |
-
|
| 298 |
-
it('treats blank access codes as public shares', async () => {
|
| 299 |
-
await withTempCwd();
|
| 300 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 301 |
-
const form = new FormData();
|
| 302 |
-
form.set('sourceFilename', 'result.png');
|
| 303 |
-
form.set('accessCode', ' ');
|
| 304 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 305 |
-
|
| 306 |
-
const response = await POST(createShareRequest(form));
|
| 307 |
-
assert.equal(response.status, 201);
|
| 308 |
-
const body = await response.json();
|
| 309 |
-
assert.equal(body.accessCodeRequired, false);
|
| 310 |
-
});
|
| 311 |
-
|
| 312 |
-
it('rejects short access codes', async () => {
|
| 313 |
-
await withTempCwd();
|
| 314 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 315 |
-
const form = new FormData();
|
| 316 |
-
form.set('sourceFilename', 'result.png');
|
| 317 |
-
form.set('accessCode', '1234567');
|
| 318 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 319 |
-
|
| 320 |
-
const response = await POST(createShareRequest(form));
|
| 321 |
-
assert.equal(response.status, 400);
|
| 322 |
-
const body = await response.json();
|
| 323 |
-
assert.equal(body.code, 'invalid_access_code');
|
| 324 |
-
});
|
| 325 |
-
|
| 326 |
-
it('rejects missing image uploads', async () => {
|
| 327 |
-
await withTempCwd();
|
| 328 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 329 |
-
const form = new FormData();
|
| 330 |
-
form.set('sourceFilename', 'result.png');
|
| 331 |
-
|
| 332 |
-
const response = await POST(createShareRequest(form));
|
| 333 |
-
assert.equal(response.status, 400);
|
| 334 |
-
const body = await response.json();
|
| 335 |
-
assert.equal(body.code, 'image_required');
|
| 336 |
-
});
|
| 337 |
-
|
| 338 |
-
it('rejects invalid expiry values', async () => {
|
| 339 |
-
await withTempCwd();
|
| 340 |
-
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 341 |
-
const form = new FormData();
|
| 342 |
-
form.set('sourceFilename', 'result.png');
|
| 343 |
-
form.set('expiresInMinutes', '-1');
|
| 344 |
-
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
| 345 |
-
|
| 346 |
-
const response = await POST(createShareRequest(form));
|
| 347 |
-
assert.equal(response.status, 400);
|
| 348 |
-
const body = await response.json();
|
| 349 |
-
assert.equal(body.code, 'invalid_expiry');
|
| 350 |
-
});
|
| 351 |
-
});
|
| 352 |
-
```
|
| 353 |
-
|
| 354 |
-
- [x] **步骤 2:编写分享元数据和内容的失败测试**
|
| 355 |
-
|
| 356 |
-
创建 `src/app/api/share-route.test.ts`:
|
| 357 |
-
|
| 358 |
-
```ts
|
| 359 |
-
import assert from 'node:assert/strict';
|
| 360 |
-
import fs from 'node:fs/promises';
|
| 361 |
-
import os from 'node:os';
|
| 362 |
-
import path from 'node:path';
|
| 363 |
-
import { afterEach, describe, it } from 'node:test';
|
| 364 |
-
import { createImageShare } from '@/lib/share-store';
|
| 365 |
-
import { GET as getShare } from './shares/[token]/route';
|
| 366 |
-
import { POST as getShareContent } from './shares/[token]/content/route';
|
| 367 |
-
|
| 368 |
-
let previousCwd: string;
|
| 369 |
-
let tempDir: string;
|
| 370 |
-
|
| 371 |
-
async function withTempCwd() {
|
| 372 |
-
previousCwd = process.cwd();
|
| 373 |
-
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'share-content-route-'));
|
| 374 |
-
process.chdir(tempDir);
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
function params(token: string) {
|
| 378 |
-
return { params: Promise.resolve({ token }) };
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
afterEach(async () => {
|
| 382 |
-
if (previousCwd) process.chdir(previousCwd);
|
| 383 |
-
if (tempDir) await fs.rm(tempDir, { recursive: true, force: true });
|
| 384 |
-
});
|
| 385 |
-
|
| 386 |
-
describe('share metadata and content routes', () => {
|
| 387 |
-
it('returns public metadata without hashes', async () => {
|
| 388 |
-
await withTempCwd();
|
| 389 |
-
const record = await createImageShare({
|
| 390 |
-
imageBuffer: Buffer.from('image-bytes'),
|
| 391 |
-
sourceFilename: 'image.png',
|
| 392 |
-
mimeType: 'image/png',
|
| 393 |
-
accessCode: '12345678',
|
| 394 |
-
expiresInMinutes: 60
|
| 395 |
-
});
|
| 396 |
-
|
| 397 |
-
const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token));
|
| 398 |
-
assert.equal(response.status, 200);
|
| 399 |
-
const body = await response.json();
|
| 400 |
-
assert.equal(body.token, record.token);
|
| 401 |
-
assert.equal(body.accessCodeRequired, true);
|
| 402 |
-
assert.equal(body.expired, false);
|
| 403 |
-
assert.equal(body.sourceFilename, 'shared-image');
|
| 404 |
-
assert.equal('accessCodeHash' in body, false);
|
| 405 |
-
assert.equal('accessCodeSalt' in body, false);
|
| 406 |
-
});
|
| 407 |
-
|
| 408 |
-
it('marks expired metadata without serving content', async () => {
|
| 409 |
-
await withTempCwd();
|
| 410 |
-
const record = await createImageShare({
|
| 411 |
-
imageBuffer: Buffer.from('expired-image'),
|
| 412 |
-
sourceFilename: 'image.png',
|
| 413 |
-
mimeType: 'image/png',
|
| 414 |
-
accessCode: undefined,
|
| 415 |
-
expiresInMinutes: null
|
| 416 |
-
});
|
| 417 |
-
const expiredRecord = { ...record, expiresAt: new Date(Date.now() - 60_000).toISOString() };
|
| 418 |
-
await fs.writeFile(path.join(tempDir, 'generated-images', '.shares', `${record.token}.json`), `${JSON.stringify(expiredRecord)}\n`);
|
| 419 |
-
|
| 420 |
-
const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token));
|
| 421 |
-
assert.equal(response.status, 200);
|
| 422 |
-
const body = await response.json();
|
| 423 |
-
assert.equal(body.expired, true);
|
| 424 |
-
});
|
| 425 |
-
|
| 426 |
-
it('serves protected content only with the correct access code', async () => {
|
| 427 |
-
await withTempCwd();
|
| 428 |
-
const record = await createImageShare({
|
| 429 |
-
imageBuffer: Buffer.from('protected-image'),
|
| 430 |
-
sourceFilename: 'image.png',
|
| 431 |
-
mimeType: 'image/png',
|
| 432 |
-
accessCode: '12345678',
|
| 433 |
-
expiresInMinutes: null
|
| 434 |
-
});
|
| 435 |
-
|
| 436 |
-
const missing = await getShareContent(
|
| 437 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, { method: 'POST', body: JSON.stringify({}) }),
|
| 438 |
-
params(record.token)
|
| 439 |
-
);
|
| 440 |
-
assert.equal(missing.status, 401);
|
| 441 |
-
|
| 442 |
-
const wrong = await getShareContent(
|
| 443 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 444 |
-
method: 'POST',
|
| 445 |
-
headers: { 'content-type': 'application/json' },
|
| 446 |
-
body: JSON.stringify({ accessCode: 'bad' })
|
| 447 |
-
}),
|
| 448 |
-
params(record.token)
|
| 449 |
-
);
|
| 450 |
-
assert.equal(wrong.status, 401);
|
| 451 |
-
|
| 452 |
-
const ok = await getShareContent(
|
| 453 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 454 |
-
method: 'POST',
|
| 455 |
-
headers: { 'content-type': 'application/json' },
|
| 456 |
-
body: JSON.stringify({ accessCode: '12345678' })
|
| 457 |
-
}),
|
| 458 |
-
params(record.token)
|
| 459 |
-
);
|
| 460 |
-
assert.equal(ok.status, 200);
|
| 461 |
-
assert.equal(ok.headers.get('content-type'), 'image/png');
|
| 462 |
-
assert.match(ok.headers.get('cache-control') || '', /no-store/);
|
| 463 |
-
assert.equal(ok.headers.get('surrogate-control'), 'no-store');
|
| 464 |
-
assert.equal(await ok.text(), 'protected-image');
|
| 465 |
-
});
|
| 466 |
-
|
| 467 |
-
it('rate limits repeated wrong access codes', async () => {
|
| 468 |
-
await withTempCwd();
|
| 469 |
-
const record = await createImageShare({
|
| 470 |
-
imageBuffer: Buffer.from('protected-image'),
|
| 471 |
-
sourceFilename: 'image.png',
|
| 472 |
-
mimeType: 'image/png',
|
| 473 |
-
accessCode: '12345678',
|
| 474 |
-
expiresInMinutes: null
|
| 475 |
-
});
|
| 476 |
-
|
| 477 |
-
for (let attempt = 0; attempt < 10; attempt += 1) {
|
| 478 |
-
await getShareContent(
|
| 479 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 480 |
-
method: 'POST',
|
| 481 |
-
headers: { 'content-type': 'application/json' },
|
| 482 |
-
body: JSON.stringify({ accessCode: 'bad-code' })
|
| 483 |
-
}),
|
| 484 |
-
params(record.token)
|
| 485 |
-
);
|
| 486 |
-
}
|
| 487 |
-
|
| 488 |
-
const response = await getShareContent(
|
| 489 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 490 |
-
method: 'POST',
|
| 491 |
-
headers: { 'content-type': 'application/json' },
|
| 492 |
-
body: JSON.stringify({ accessCode: 'bad-code' })
|
| 493 |
-
}),
|
| 494 |
-
params(record.token)
|
| 495 |
-
);
|
| 496 |
-
assert.equal(response.status, 429);
|
| 497 |
-
});
|
| 498 |
-
|
| 499 |
-
it('does not serve expired content', async () => {
|
| 500 |
-
await withTempCwd();
|
| 501 |
-
const record = await createImageShare({
|
| 502 |
-
imageBuffer: Buffer.from('expired-image'),
|
| 503 |
-
sourceFilename: 'image.png',
|
| 504 |
-
mimeType: 'image/png',
|
| 505 |
-
accessCode: undefined,
|
| 506 |
-
expiresInMinutes: null
|
| 507 |
-
});
|
| 508 |
-
const expiredRecord = { ...record, expiresAt: new Date(Date.now() - 60_000).toISOString() };
|
| 509 |
-
await fs.writeFile(path.join(tempDir, 'generated-images', '.shares', `${record.token}.json`), `${JSON.stringify(expiredRecord)}\n`);
|
| 510 |
-
|
| 511 |
-
const response = await getShareContent(
|
| 512 |
-
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 513 |
-
method: 'POST',
|
| 514 |
-
headers: { 'content-type': 'application/json' },
|
| 515 |
-
body: JSON.stringify({})
|
| 516 |
-
}),
|
| 517 |
-
params(record.token)
|
| 518 |
-
);
|
| 519 |
-
assert.equal(response.status, 410);
|
| 520 |
-
});
|
| 521 |
-
});
|
| 522 |
-
```
|
| 523 |
-
|
| 524 |
-
- [x] **步骤 3:运行测试并确认失败**
|
| 525 |
-
|
| 526 |
-
运行:
|
| 527 |
-
|
| 528 |
-
```bash
|
| 529 |
-
npm test -- src/app/api/shares/route.test.ts src/app/api/share-route.test.ts
|
| 530 |
-
```
|
| 531 |
-
|
| 532 |
-
预期:由于 `src/app/api/shares/*` 路由尚不存在,导入失败。
|
| 533 |
-
|
| 534 |
-
### 任务 2:实现分享 API 路由
|
| 535 |
-
|
| 536 |
-
**文件:**
|
| 537 |
-
- 创建:`src/app/api/shares/route.ts`
|
| 538 |
-
- 创建:`src/app/api/shares/[token]/route.ts`
|
| 539 |
-
- 创建:`src/app/api/shares/[token]/content/route.ts`
|
| 540 |
-
|
| 541 |
-
- [x] **步骤 1:添加创建路由**
|
| 542 |
-
|
| 543 |
-
创建 `src/app/api/shares/route.ts`:
|
| 544 |
-
|
| 545 |
-
```ts
|
| 546 |
-
import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
|
| 547 |
-
import { createImageShare } from '@/lib/share-store';
|
| 548 |
-
import { verifyAccessToken } from '@/lib/server-runtime';
|
| 549 |
-
import { NextRequest, NextResponse } from 'next/server';
|
| 550 |
-
|
| 551 |
-
const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024;
|
| 552 |
-
const MIN_ACCESS_CODE_LENGTH = 8;
|
| 553 |
-
const MAX_ACCESS_CODE_LENGTH = 128;
|
| 554 |
-
|
| 555 |
-
type UploadedImage = Blob & {
|
| 556 |
-
name?: string;
|
| 557 |
-
type: string;
|
| 558 |
-
};
|
| 559 |
-
|
| 560 |
-
function jsonError(code: string, message: string, status: number) {
|
| 561 |
-
return NextResponse.json({ error: message, code }, { status });
|
| 562 |
-
}
|
| 563 |
-
|
| 564 |
-
function parseExpiry(value: FormDataEntryValue | null): number | null | undefined {
|
| 565 |
-
if (value === null || value === '') return null;
|
| 566 |
-
if (typeof value !== 'string' || !/^\d+$/.test(value)) return undefined;
|
| 567 |
-
const minutes = Number(value);
|
| 568 |
-
if (!Number.isSafeInteger(minutes) || minutes < 1 || minutes > 60 * 24 * 30) return undefined;
|
| 569 |
-
return minutes;
|
| 570 |
-
}
|
| 571 |
-
|
| 572 |
-
function resolveShareUrl(request: Request, token: string): string {
|
| 573 |
-
const url = new URL(request.url);
|
| 574 |
-
return `${url.origin}/share/${token}`;
|
| 575 |
-
}
|
| 576 |
-
|
| 577 |
-
function isUploadedImage(value: FormDataEntryValue | null): value is UploadedImage {
|
| 578 |
-
return (
|
| 579 |
-
typeof value === 'object' &&
|
| 580 |
-
value !== null &&
|
| 581 |
-
typeof (value as Blob).arrayBuffer === 'function' &&
|
| 582 |
-
typeof (value as Blob).size === 'number'
|
| 583 |
-
);
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
function parseAccessCode(value: FormDataEntryValue | null): string | undefined | null {
|
| 587 |
-
if (value === null) return undefined;
|
| 588 |
-
if (typeof value !== 'string') return null;
|
| 589 |
-
const trimmed = value.trim();
|
| 590 |
-
if (!trimmed) return undefined;
|
| 591 |
-
if (trimmed.length < MIN_ACCESS_CODE_LENGTH || trimmed.length > MAX_ACCESS_CODE_LENGTH) return null;
|
| 592 |
-
return trimmed;
|
| 593 |
-
}
|
| 594 |
-
|
| 595 |
-
function verifyShareCreator(request: NextRequest) {
|
| 596 |
-
if (!process.env.APP_PASSWORD) return undefined;
|
| 597 |
-
const accessToken = request.cookies.get('gptImageAccess')?.value;
|
| 598 |
-
if (verifyAccessToken(accessToken, process.env.APP_PASSWORD)) return undefined;
|
| 599 |
-
const code = accessToken ? PAGE_PASSWORD_AUTH_ERROR_CODES.invalid : PAGE_PASSWORD_AUTH_ERROR_CODES.missing;
|
| 600 |
-
return jsonError(code, '未授权:无效的访问令牌。', 401);
|
| 601 |
-
}
|
| 602 |
-
|
| 603 |
-
export async function POST(request: NextRequest) {
|
| 604 |
-
const authError = verifyShareCreator(request);
|
| 605 |
-
if (authError) return authError;
|
| 606 |
-
|
| 607 |
-
let form: FormData;
|
| 608 |
-
try {
|
| 609 |
-
form = await request.formData();
|
| 610 |
-
} catch {
|
| 611 |
-
return jsonError('invalid_form_data', '分享请求格式无效。', 400);
|
| 612 |
-
}
|
| 613 |
-
|
| 614 |
-
const image = form.get('image');
|
| 615 |
-
if (!isUploadedImage(image)) {
|
| 616 |
-
return jsonError('image_required', '分享图片必填。', 400);
|
| 617 |
-
}
|
| 618 |
-
if (image.size <= 0 || image.size > MAX_SHARE_IMAGE_BYTES) {
|
| 619 |
-
return jsonError('invalid_image_size', '分享图片大小无效。', 400);
|
| 620 |
-
}
|
| 621 |
-
|
| 622 |
-
const sourceFilenameValue = form.get('sourceFilename');
|
| 623 |
-
const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png';
|
| 624 |
-
const sourceFilename = typeof sourceFilenameValue === 'string' && sourceFilenameValue.trim() ? sourceFilenameValue.trim() : fallbackFilename;
|
| 625 |
-
const expiresInMinutes = parseExpiry(form.get('expiresInMinutes'));
|
| 626 |
-
if (expiresInMinutes === undefined) {
|
| 627 |
-
return jsonError('invalid_expiry', '分享有效期无效。', 400);
|
| 628 |
-
}
|
| 629 |
-
|
| 630 |
-
const accessCode = parseAccessCode(form.get('accessCode'));
|
| 631 |
-
if (accessCode === null) {
|
| 632 |
-
return jsonError('invalid_access_code', '访问码长度无效。', 400);
|
| 633 |
-
}
|
| 634 |
-
const imageBuffer = Buffer.from(await image.arrayBuffer());
|
| 635 |
-
const record = await createImageShare({
|
| 636 |
-
imageBuffer,
|
| 637 |
-
sourceFilename,
|
| 638 |
-
mimeType: typeof image.type === 'string' && image.type ? image.type : 'image/png',
|
| 639 |
-
accessCode,
|
| 640 |
-
expiresInMinutes
|
| 641 |
-
});
|
| 642 |
-
|
| 643 |
-
return NextResponse.json(
|
| 644 |
-
{
|
| 645 |
-
token: record.token,
|
| 646 |
-
url: resolveShareUrl(request, record.token),
|
| 647 |
-
expiresAt: record.expiresAt ?? null,
|
| 648 |
-
accessCodeRequired: record.accessCodeRequired
|
| 649 |
-
},
|
| 650 |
-
{ status: 201 }
|
| 651 |
-
);
|
| 652 |
-
}
|
| 653 |
-
```
|
| 654 |
-
|
| 655 |
-
- [x] **步骤 2:添加元数据路由**
|
| 656 |
-
|
| 657 |
-
创建 `src/app/api/shares/[token]/route.ts`:
|
| 658 |
-
|
| 659 |
-
```ts
|
| 660 |
-
import { isImageShareExpired, readImageShare } from '@/lib/share-store';
|
| 661 |
-
import { NextResponse } from 'next/server';
|
| 662 |
-
|
| 663 |
-
function jsonError(code: string, message: string, status: number) {
|
| 664 |
-
return NextResponse.json({ error: message, code }, { status });
|
| 665 |
-
}
|
| 666 |
-
|
| 667 |
-
function publicSourceFilename(record: { accessCodeRequired: boolean; sourceFilename: string }): string {
|
| 668 |
-
return record.accessCodeRequired ? 'shared-image' : record.sourceFilename;
|
| 669 |
-
}
|
| 670 |
-
|
| 671 |
-
export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
|
| 672 |
-
const { token } = await params;
|
| 673 |
-
const record = await readImageShare(token);
|
| 674 |
-
if (!record) {
|
| 675 |
-
return jsonError('share_not_found', '分享不存在。', 404);
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
return NextResponse.json({
|
| 679 |
-
token: record.token,
|
| 680 |
-
sourceFilename: publicSourceFilename(record),
|
| 681 |
-
mimeType: record.mimeType,
|
| 682 |
-
sizeBytes: record.sizeBytes,
|
| 683 |
-
createdAt: record.createdAt,
|
| 684 |
-
expiresAt: record.expiresAt ?? null,
|
| 685 |
-
accessCodeRequired: record.accessCodeRequired,
|
| 686 |
-
expired: isImageShareExpired(record)
|
| 687 |
-
});
|
| 688 |
-
}
|
| 689 |
-
```
|
| 690 |
-
|
| 691 |
-
- [x] **步骤 3:添加内容路由**
|
| 692 |
-
|
| 693 |
-
创建 `src/app/api/shares/[token]/content/route.ts`:
|
| 694 |
-
|
| 695 |
-
```ts
|
| 696 |
-
import { isImageShareExpired, readImageShare, readImageShareContent, verifyImageShareAccess } from '@/lib/share-store';
|
| 697 |
-
import { NextResponse } from 'next/server';
|
| 698 |
-
|
| 699 |
-
const MAX_ACCESS_FAILURES = 10;
|
| 700 |
-
const ACCESS_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
| 701 |
-
|
| 702 |
-
type AccessFailureState = {
|
| 703 |
-
count: number;
|
| 704 |
-
firstFailedAt: number;
|
| 705 |
-
blockedUntil?: number;
|
| 706 |
-
};
|
| 707 |
-
|
| 708 |
-
const accessFailures = new Map<string, AccessFailureState>();
|
| 709 |
-
|
| 710 |
-
function jsonError(code: string, message: string, status: number) {
|
| 711 |
-
return NextResponse.json({ error: message, code }, { status });
|
| 712 |
-
}
|
| 713 |
-
|
| 714 |
-
function isAccessBlocked(token: string, now: number): boolean {
|
| 715 |
-
const state = accessFailures.get(token);
|
| 716 |
-
if (!state) return false;
|
| 717 |
-
if (state.blockedUntil && state.blockedUntil > now) return true;
|
| 718 |
-
if (state.blockedUntil && state.blockedUntil <= now) accessFailures.delete(token);
|
| 719 |
-
return false;
|
| 720 |
-
}
|
| 721 |
-
|
| 722 |
-
function recordAccessFailure(token: string, now: number) {
|
| 723 |
-
const current = accessFailures.get(token);
|
| 724 |
-
const state =
|
| 725 |
-
current && now - current.firstFailedAt <= ACCESS_FAILURE_WINDOW_MS ? current : { count: 0, firstFailedAt: now };
|
| 726 |
-
state.count += 1;
|
| 727 |
-
if (state.count >= MAX_ACCESS_FAILURES) {
|
| 728 |
-
state.blockedUntil = now + ACCESS_FAILURE_WINDOW_MS;
|
| 729 |
-
}
|
| 730 |
-
accessFailures.set(token, state);
|
| 731 |
-
}
|
| 732 |
-
|
| 733 |
-
function clearAccessFailure(token: string) {
|
| 734 |
-
accessFailures.delete(token);
|
| 735 |
-
}
|
| 736 |
-
|
| 737 |
-
async function readAccessCode(request: Request): Promise<string | undefined> {
|
| 738 |
-
const contentType = request.headers.get('content-type') || '';
|
| 739 |
-
if (!contentType.includes('application/json')) return undefined;
|
| 740 |
-
const body = (await request.json().catch(() => ({}))) as { accessCode?: unknown };
|
| 741 |
-
return typeof body.accessCode === 'string' ? body.accessCode : undefined;
|
| 742 |
-
}
|
| 743 |
-
|
| 744 |
-
export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
| 745 |
-
const { token } = await params;
|
| 746 |
-
const record = await readImageShare(token);
|
| 747 |
-
if (!record) {
|
| 748 |
-
return jsonError('share_not_found', '分享不存在。', 404);
|
| 749 |
-
}
|
| 750 |
-
if (isImageShareExpired(record)) {
|
| 751 |
-
return jsonError('share_expired', '分享已过期。', 410);
|
| 752 |
-
}
|
| 753 |
-
|
| 754 |
-
const now = Date.now();
|
| 755 |
-
if (isAccessBlocked(token, now)) {
|
| 756 |
-
return jsonError('share_rate_limited', '访问码尝试次数过多。', 429);
|
| 757 |
-
}
|
| 758 |
-
|
| 759 |
-
const accessCode = await readAccessCode(request);
|
| 760 |
-
if (!verifyImageShareAccess(record, accessCode)) {
|
| 761 |
-
recordAccessFailure(token, now);
|
| 762 |
-
return jsonError('share_access_denied', '访问码无效。', 401);
|
| 763 |
-
}
|
| 764 |
-
clearAccessFailure(token);
|
| 765 |
-
|
| 766 |
-
const content = await readImageShareContent(record);
|
| 767 |
-
return new NextResponse(content.buffer, {
|
| 768 |
-
status: 200,
|
| 769 |
-
headers: {
|
| 770 |
-
'Content-Type': content.mimeType,
|
| 771 |
-
'Content-Length': content.buffer.length.toString(),
|
| 772 |
-
'Cache-Control': 'private, no-store, no-cache, max-age=0, must-revalidate',
|
| 773 |
-
Pragma: 'no-cache',
|
| 774 |
-
Expires: '0',
|
| 775 |
-
'Surrogate-Control': 'no-store'
|
| 776 |
-
}
|
| 777 |
-
});
|
| 778 |
-
}
|
| 779 |
-
```
|
| 780 |
-
|
| 781 |
-
- [x] **步骤 4:运行分享路由测试**
|
| 782 |
-
|
| 783 |
-
运行:
|
| 784 |
-
|
| 785 |
-
```bash
|
| 786 |
-
npm test -- src/app/api/shares/route.test.ts src/app/api/share-route.test.ts
|
| 787 |
-
```
|
| 788 |
-
|
| 789 |
-
预期:通过。
|
| 790 |
-
|
| 791 |
-
### 任务 3:添加分享查看页
|
| 792 |
-
|
| 793 |
-
**文件:**
|
| 794 |
-
- 创建:`src/app/share/[token]/page.tsx`
|
| 795 |
-
- 修改:`src/lib/i18n.tsx`
|
| 796 |
-
|
| 797 |
-
- [x] **步骤 1:创建客户端分享页**
|
| 798 |
-
|
| 799 |
-
创建 `src/app/share/[token]/page.tsx`:
|
| 800 |
-
|
| 801 |
-
```tsx
|
| 802 |
-
'use client';
|
| 803 |
-
|
| 804 |
-
import { Button } from '@/components/ui/button';
|
| 805 |
-
import { Input } from '@/components/ui/input';
|
| 806 |
-
import { useI18n } from '@/lib/i18n';
|
| 807 |
-
import * as React from 'react';
|
| 808 |
-
|
| 809 |
-
type ShareMetadata = {
|
| 810 |
-
token: string;
|
| 811 |
-
sourceFilename: string;
|
| 812 |
-
mimeType: string;
|
| 813 |
-
sizeBytes: number;
|
| 814 |
-
createdAt: string;
|
| 815 |
-
expiresAt: string | null;
|
| 816 |
-
accessCodeRequired: boolean;
|
| 817 |
-
expired: boolean;
|
| 818 |
-
};
|
| 819 |
-
|
| 820 |
-
export default function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
| 821 |
-
const { t } = useI18n();
|
| 822 |
-
const resolvedParams = React.use(params);
|
| 823 |
-
const token = resolvedParams.token;
|
| 824 |
-
const [metadata, setMetadata] = React.useState<ShareMetadata | null>(null);
|
| 825 |
-
const [accessCode, setAccessCode] = React.useState('');
|
| 826 |
-
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
|
| 827 |
-
const [error, setError] = React.useState<string | null>(null);
|
| 828 |
-
const [isLoading, setIsLoading] = React.useState(true);
|
| 829 |
-
const [isUnlocking, setIsUnlocking] = React.useState(false);
|
| 830 |
-
const imageUrlRef = React.useRef<string | null>(null);
|
| 831 |
-
|
| 832 |
-
React.useEffect(() => {
|
| 833 |
-
let active = true;
|
| 834 |
-
const loadMetadata = async () => {
|
| 835 |
-
setIsLoading(true);
|
| 836 |
-
setError(null);
|
| 837 |
-
try {
|
| 838 |
-
const response = await fetch(`/api/shares/${token}`);
|
| 839 |
-
const body = await response.json();
|
| 840 |
-
if (!response.ok) {
|
| 841 |
-
throw new Error(body.error || t('share.loadFailed'));
|
| 842 |
-
}
|
| 843 |
-
if (active) setMetadata(body as ShareMetadata);
|
| 844 |
-
} catch (err) {
|
| 845 |
-
if (active) setError(err instanceof Error ? err.message : t('share.loadFailed'));
|
| 846 |
-
} finally {
|
| 847 |
-
if (active) setIsLoading(false);
|
| 848 |
-
}
|
| 849 |
-
};
|
| 850 |
-
|
| 851 |
-
void loadMetadata();
|
| 852 |
-
return () => {
|
| 853 |
-
active = false;
|
| 854 |
-
};
|
| 855 |
-
}, [token, t]);
|
| 856 |
-
|
| 857 |
-
React.useEffect(() => {
|
| 858 |
-
return () => {
|
| 859 |
-
if (imageUrlRef.current) URL.revokeObjectURL(imageUrlRef.current);
|
| 860 |
-
};
|
| 861 |
-
}, []);
|
| 862 |
-
|
| 863 |
-
const loadImage = React.useCallback(async () => {
|
| 864 |
-
setIsUnlocking(true);
|
| 865 |
-
setError(null);
|
| 866 |
-
try {
|
| 867 |
-
const response = await fetch(`/api/shares/${token}/content`, {
|
| 868 |
-
method: 'POST',
|
| 869 |
-
headers: { 'content-type': 'application/json' },
|
| 870 |
-
body: JSON.stringify(accessCode.trim() ? { accessCode: accessCode.trim() } : {})
|
| 871 |
-
});
|
| 872 |
-
if (!response.ok) {
|
| 873 |
-
const body = await response.json().catch(() => ({}));
|
| 874 |
-
throw new Error(body.error || t('share.unlockFailed'));
|
| 875 |
-
}
|
| 876 |
-
if (!response.headers.get('content-type')?.startsWith('image/')) {
|
| 877 |
-
throw new Error(t('share.unlockFailed'));
|
| 878 |
-
}
|
| 879 |
-
const blob = await response.blob();
|
| 880 |
-
const nextUrl = URL.createObjectURL(blob);
|
| 881 |
-
if (imageUrlRef.current) URL.revokeObjectURL(imageUrlRef.current);
|
| 882 |
-
imageUrlRef.current = nextUrl;
|
| 883 |
-
setImageUrl(nextUrl);
|
| 884 |
-
} catch (err) {
|
| 885 |
-
setError(err instanceof Error ? err.message : t('share.unlockFailed'));
|
| 886 |
-
} finally {
|
| 887 |
-
setIsUnlocking(false);
|
| 888 |
-
}
|
| 889 |
-
}, [accessCode, t, token]);
|
| 890 |
-
|
| 891 |
-
React.useEffect(() => {
|
| 892 |
-
if (!metadata || metadata.expired || metadata.accessCodeRequired || imageUrl) return;
|
| 893 |
-
void loadImage();
|
| 894 |
-
}, [imageUrl, loadImage, metadata]);
|
| 895 |
-
|
| 896 |
-
return (
|
| 897 |
-
<main className='bg-background text-foreground flex min-h-screen items-center justify-center p-6'>
|
| 898 |
-
<section className='w-full max-w-3xl space-y-4'>
|
| 899 |
-
<div>
|
| 900 |
-
<h1 className='text-2xl font-semibold'>{t('share.pageTitle')}</h1>
|
| 901 |
-
{metadata ? <p className='text-muted-foreground mt-2 text-sm'>{metadata.sourceFilename}</p> : null}
|
| 902 |
-
</div>
|
| 903 |
-
{isLoading ? <p className='text-muted-foreground'>{t('share.loading')}</p> : null}
|
| 904 |
-
{error ? <p className='text-destructive text-sm'>{error}</p> : null}
|
| 905 |
-
{metadata?.expired ? <p className='text-destructive text-sm'>{t('share.expired')}</p> : null}
|
| 906 |
-
{metadata && metadata.accessCodeRequired && !imageUrl && !metadata.expired ? (
|
| 907 |
-
<form
|
| 908 |
-
className='flex max-w-sm gap-2'
|
| 909 |
-
onSubmit={(event) => {
|
| 910 |
-
event.preventDefault();
|
| 911 |
-
void loadImage();
|
| 912 |
-
}}>
|
| 913 |
-
<Input
|
| 914 |
-
value={accessCode}
|
| 915 |
-
onChange={(event) => setAccessCode(event.target.value)}
|
| 916 |
-
placeholder={t('share.accessCodePlaceholder')}
|
| 917 |
-
type='password'
|
| 918 |
-
/>
|
| 919 |
-
<Button type='submit' disabled={isUnlocking || accessCode.trim().length === 0}>
|
| 920 |
-
{t('share.unlock')}
|
| 921 |
-
</Button>
|
| 922 |
-
</form>
|
| 923 |
-
) : null}
|
| 924 |
-
{imageUrl ? (
|
| 925 |
-
<div className='relative aspect-square w-full overflow-hidden rounded-md border border-border bg-muted'>
|
| 926 |
-
<img src={imageUrl} alt={metadata?.sourceFilename || t('share.imageAlt')} className='h-full w-full object-contain' />
|
| 927 |
-
</div>
|
| 928 |
-
) : null}
|
| 929 |
-
</section>
|
| 930 |
-
</main>
|
| 931 |
-
);
|
| 932 |
-
}
|
| 933 |
-
```
|
| 934 |
-
|
| 935 |
-
- [x] **步骤 2:添加 i18n 文案**
|
| 936 |
-
|
| 937 |
-
修改 `src/lib/i18n.tsx`,把这些 key 添加到两个语言映射中:
|
| 938 |
-
|
| 939 |
-
```ts
|
| 940 |
-
'share.pageTitle': '图片分享',
|
| 941 |
-
'share.loading': '正在加载分享信息...',
|
| 942 |
-
'share.loadFailed': '加载分享失败。',
|
| 943 |
-
'share.unlockFailed': '打开分享失败。',
|
| 944 |
-
'share.expired': '这个分享已过期。',
|
| 945 |
-
'share.unlock': '打开',
|
| 946 |
-
'share.accessCodePlaceholder': '输入访问码',
|
| 947 |
-
'share.imageAlt': '分享图片',
|
| 948 |
-
```
|
| 949 |
-
|
| 950 |
-
英文:
|
| 951 |
-
|
| 952 |
-
```ts
|
| 953 |
-
'share.pageTitle': 'Shared Image',
|
| 954 |
-
'share.loading': 'Loading share details...',
|
| 955 |
-
'share.loadFailed': 'Failed to load share.',
|
| 956 |
-
'share.unlockFailed': 'Failed to open share.',
|
| 957 |
-
'share.expired': 'This share has expired.',
|
| 958 |
-
'share.unlock': 'Open',
|
| 959 |
-
'share.accessCodePlaceholder': 'Enter access code',
|
| 960 |
-
'share.imageAlt': 'Shared image',
|
| 961 |
-
```
|
| 962 |
-
|
| 963 |
-
- [x] **步骤 3:运行页面类型构建门禁**
|
| 964 |
-
|
| 965 |
-
运行:
|
| 966 |
-
|
| 967 |
-
```bash
|
| 968 |
-
npm run build
|
| 969 |
-
```
|
| 970 |
-
|
| 971 |
-
预期:构建成功,并包含 `/share/[token]`。
|
| 972 |
-
|
| 973 |
-
### 任务 4:添加下载和分享 UI 接线
|
| 974 |
-
|
| 975 |
-
**文件:**
|
| 976 |
-
- 创建:`src/components/share-dialog.tsx`
|
| 977 |
-
- 修改:`src/components/image-output.tsx`
|
| 978 |
-
- 修改:`src/app/page.tsx`
|
| 979 |
-
- 修改:`src/lib/i18n.tsx`
|
| 980 |
-
|
| 981 |
-
- [x] **步骤 1:创建分享弹窗组件**
|
| 982 |
-
|
| 983 |
-
创建 `src/components/share-dialog.tsx`:
|
| 984 |
-
|
| 985 |
-
```tsx
|
| 986 |
-
'use client';
|
| 987 |
-
|
| 988 |
-
import { Button } from '@/components/ui/button';
|
| 989 |
-
import {
|
| 990 |
-
Dialog,
|
| 991 |
-
DialogContent,
|
| 992 |
-
DialogDescription,
|
| 993 |
-
DialogFooter,
|
| 994 |
-
DialogHeader,
|
| 995 |
-
DialogTitle
|
| 996 |
-
} from '@/components/ui/dialog';
|
| 997 |
-
import { Input } from '@/components/ui/input';
|
| 998 |
-
import { Label } from '@/components/ui/label';
|
| 999 |
-
import {
|
| 1000 |
-
Select,
|
| 1001 |
-
SelectContent,
|
| 1002 |
-
SelectItem,
|
| 1003 |
-
SelectTrigger,
|
| 1004 |
-
SelectValue
|
| 1005 |
-
} from '@/components/ui/select';
|
| 1006 |
-
import { useI18n } from '@/lib/i18n';
|
| 1007 |
-
import { Copy, Loader2 } from 'lucide-react';
|
| 1008 |
-
import * as React from 'react';
|
| 1009 |
-
|
| 1010 |
-
export type ShareDialogValues = {
|
| 1011 |
-
accessCode: string;
|
| 1012 |
-
expiresInMinutes: number | null;
|
| 1013 |
-
};
|
| 1014 |
-
|
| 1015 |
-
type ShareDialogProps = {
|
| 1016 |
-
open: boolean;
|
| 1017 |
-
onOpenChange: (open: boolean) => void;
|
| 1018 |
-
isCreating: boolean;
|
| 1019 |
-
shareUrl: string | null;
|
| 1020 |
-
error: string | null;
|
| 1021 |
-
onCreate: (values: ShareDialogValues) => void;
|
| 1022 |
-
};
|
| 1023 |
-
|
| 1024 |
-
const expiryOptions = [
|
| 1025 |
-
{ value: 'none', minutes: null },
|
| 1026 |
-
{ value: '60', minutes: 60 },
|
| 1027 |
-
{ value: '1440', minutes: 1440 },
|
| 1028 |
-
{ value: '10080', minutes: 10080 }
|
| 1029 |
-
] as const;
|
| 1030 |
-
|
| 1031 |
-
export function ShareDialog({ open, onOpenChange, isCreating, shareUrl, error, onCreate }: ShareDialogProps) {
|
| 1032 |
-
const { t } = useI18n();
|
| 1033 |
-
const [accessCode, setAccessCode] = React.useState('');
|
| 1034 |
-
const [expiry, setExpiry] = React.useState('none');
|
| 1035 |
-
const [copied, setCopied] = React.useState(false);
|
| 1036 |
-
|
| 1037 |
-
React.useEffect(() => {
|
| 1038 |
-
if (!open) {
|
| 1039 |
-
setCopied(false);
|
| 1040 |
-
}
|
| 1041 |
-
}, [open]);
|
| 1042 |
-
|
| 1043 |
-
const selectedExpiry = expiryOptions.find((option) => option.value === expiry) ?? expiryOptions[0];
|
| 1044 |
-
|
| 1045 |
-
return (
|
| 1046 |
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
| 1047 |
-
<DialogContent>
|
| 1048 |
-
<DialogHeader>
|
| 1049 |
-
<DialogTitle>{t('share.dialogTitle')}</DialogTitle>
|
| 1050 |
-
<DialogDescription>{t('share.dialogDescription')}</DialogDescription>
|
| 1051 |
-
</DialogHeader>
|
| 1052 |
-
<div className='grid gap-4'>
|
| 1053 |
-
<div className='grid gap-2'>
|
| 1054 |
-
<Label htmlFor='share-access-code'>{t('share.accessCode')}</Label>
|
| 1055 |
-
<Input
|
| 1056 |
-
id='share-access-code'
|
| 1057 |
-
value={accessCode}
|
| 1058 |
-
onChange={(event) => setAccessCode(event.target.value)}
|
| 1059 |
-
placeholder={t('share.accessCodeOptional')}
|
| 1060 |
-
/>
|
| 1061 |
-
</div>
|
| 1062 |
-
<div className='grid gap-2'>
|
| 1063 |
-
<Label>{t('share.expiry')}</Label>
|
| 1064 |
-
<Select value={expiry} onValueChange={setExpiry}>
|
| 1065 |
-
<SelectTrigger className='w-full'>
|
| 1066 |
-
<SelectValue />
|
| 1067 |
-
</SelectTrigger>
|
| 1068 |
-
<SelectContent>
|
| 1069 |
-
<SelectItem value='none'>{t('share.expiryNone')}</SelectItem>
|
| 1070 |
-
<SelectItem value='60'>{t('share.expiryOneHour')}</SelectItem>
|
| 1071 |
-
<SelectItem value='1440'>{t('share.expiryOneDay')}</SelectItem>
|
| 1072 |
-
<SelectItem value='10080'>{t('share.expirySevenDays')}</SelectItem>
|
| 1073 |
-
</SelectContent>
|
| 1074 |
-
</Select>
|
| 1075 |
-
</div>
|
| 1076 |
-
{error ? <p className='text-destructive text-sm'>{error}</p> : null}
|
| 1077 |
-
{shareUrl ? (
|
| 1078 |
-
<div className='grid gap-2'>
|
| 1079 |
-
<Label>{t('share.link')}</Label>
|
| 1080 |
-
<div className='flex gap-2'>
|
| 1081 |
-
<Input value={shareUrl} readOnly />
|
| 1082 |
-
<Button
|
| 1083 |
-
type='button'
|
| 1084 |
-
variant='outline'
|
| 1085 |
-
size='icon'
|
| 1086 |
-
onClick={async () => {
|
| 1087 |
-
await navigator.clipboard.writeText(shareUrl);
|
| 1088 |
-
setCopied(true);
|
| 1089 |
-
}}
|
| 1090 |
-
aria-label={t('share.copyLink')}>
|
| 1091 |
-
<Copy className='h-4 w-4' />
|
| 1092 |
-
</Button>
|
| 1093 |
-
</div>
|
| 1094 |
-
{copied ? <p className='text-sm text-emerald-600'>{t('common.copied')}</p> : null}
|
| 1095 |
-
</div>
|
| 1096 |
-
) : null}
|
| 1097 |
-
</div>
|
| 1098 |
-
<DialogFooter>
|
| 1099 |
-
<Button
|
| 1100 |
-
type='button'
|
| 1101 |
-
onClick={() => onCreate({ accessCode, expiresInMinutes: selectedExpiry.minutes })}
|
| 1102 |
-
disabled={isCreating}>
|
| 1103 |
-
{isCreating ? <Loader2 className='mr-2 h-4 w-4 animate-spin' /> : null}
|
| 1104 |
-
{t('share.create')}
|
| 1105 |
-
</Button>
|
| 1106 |
-
</DialogFooter>
|
| 1107 |
-
</DialogContent>
|
| 1108 |
-
</Dialog>
|
| 1109 |
-
);
|
| 1110 |
-
}
|
| 1111 |
-
```
|
| 1112 |
-
|
| 1113 |
-
- [x] **步骤 2:给图片输出组件添加动作 props**
|
| 1114 |
-
|
| 1115 |
-
修改 `src/components/image-output.tsx`。当前组件已有 `isSingleImageView`;如果执行时发现变量名已变化,先按实际代码调整,不要新增重复状态。
|
| 1116 |
-
|
| 1117 |
-
```tsx
|
| 1118 |
-
import { Download, Grid, Loader2, Send, Share2, Terminal, Trash2 } from 'lucide-react';
|
| 1119 |
-
```
|
| 1120 |
-
|
| 1121 |
-
添加 props:
|
| 1122 |
-
|
| 1123 |
-
```ts
|
| 1124 |
-
onDownloadImage: (filename: string) => void;
|
| 1125 |
-
onShareImage: (filename: string) => void;
|
| 1126 |
-
```
|
| 1127 |
-
|
| 1128 |
-
添加解构:
|
| 1129 |
-
|
| 1130 |
-
```ts
|
| 1131 |
-
onDownloadImage,
|
| 1132 |
-
onShareImage,
|
| 1133 |
-
```
|
| 1134 |
-
|
| 1135 |
-
添加处理函数:
|
| 1136 |
-
|
| 1137 |
-
```ts
|
| 1138 |
-
const handleDownloadClick = () => {
|
| 1139 |
-
if (typeof viewMode === 'number' && imageBatch && imageBatch[viewMode]) {
|
| 1140 |
-
onDownloadImage(imageBatch[viewMode].filename);
|
| 1141 |
-
}
|
| 1142 |
-
};
|
| 1143 |
-
|
| 1144 |
-
const handleShareClick = () => {
|
| 1145 |
-
if (typeof viewMode === 'number' && imageBatch && imageBatch[viewMode]) {
|
| 1146 |
-
onShareImage(imageBatch[viewMode].filename);
|
| 1147 |
-
}
|
| 1148 |
-
};
|
| 1149 |
-
```
|
| 1150 |
-
|
| 1151 |
-
添加 `canUseImageActions`:
|
| 1152 |
-
|
| 1153 |
-
```ts
|
| 1154 |
-
const canUseImageActions = !isLoading && isSingleImageView && imageBatch && imageBatch[viewMode];
|
| 1155 |
-
```
|
| 1156 |
-
|
| 1157 |
-
把两个按钮按 DOM 顺序放在发送到编辑按钮之后:先下载,再分享,确保最终动作顺序为“日志、发送到编辑、下载、分享”。
|
| 1158 |
-
|
| 1159 |
-
```tsx
|
| 1160 |
-
<Button
|
| 1161 |
-
variant='outline'
|
| 1162 |
-
size='sm'
|
| 1163 |
-
onClick={handleDownloadClick}
|
| 1164 |
-
disabled={!canUseImageActions}
|
| 1165 |
-
className={cn(
|
| 1166 |
-
'shrink-0 disabled:opacity-50',
|
| 1167 |
-
showCarousel && viewMode === 'grid' ? 'invisible' : 'visible'
|
| 1168 |
-
)}>
|
| 1169 |
-
<Download className='mr-2 h-4 w-4' />
|
| 1170 |
-
{t('output.download')}
|
| 1171 |
-
</Button>
|
| 1172 |
-
<Button
|
| 1173 |
-
variant='outline'
|
| 1174 |
-
size='sm'
|
| 1175 |
-
onClick={handleShareClick}
|
| 1176 |
-
disabled={!canUseImageActions}
|
| 1177 |
-
className={cn(
|
| 1178 |
-
'shrink-0 disabled:opacity-50',
|
| 1179 |
-
showCarousel && viewMode === 'grid' ? 'invisible' : 'visible'
|
| 1180 |
-
)}>
|
| 1181 |
-
<Share2 className='mr-2 h-4 w-4' />
|
| 1182 |
-
{t('output.share')}
|
| 1183 |
-
</Button>
|
| 1184 |
-
```
|
| 1185 |
-
|
| 1186 |
-
- [x] **步骤 3:添加页面 blob 解析器和下载/分享处理函数**
|
| 1187 |
-
|
| 1188 |
-
修改 `src/app/page.tsx`。
|
| 1189 |
-
|
| 1190 |
-
导入弹窗:
|
| 1191 |
-
|
| 1192 |
-
```ts
|
| 1193 |
-
import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog';
|
| 1194 |
-
```
|
| 1195 |
-
|
| 1196 |
-
添加状态:
|
| 1197 |
-
|
| 1198 |
-
```ts
|
| 1199 |
-
const [shareDialogOpen, setShareDialogOpen] = React.useState(false);
|
| 1200 |
-
const [shareTargetFilename, setShareTargetFilename] = React.useState<string | null>(null);
|
| 1201 |
-
const [shareUrl, setShareUrl] = React.useState<string | null>(null);
|
| 1202 |
-
const [shareError, setShareError] = React.useState<string | null>(null);
|
| 1203 |
-
const [isCreatingShare, setIsCreatingShare] = React.useState(false);
|
| 1204 |
-
```
|
| 1205 |
-
|
| 1206 |
-
添加共享解析器:
|
| 1207 |
-
|
| 1208 |
-
```ts
|
| 1209 |
-
const resolveImageBlob = React.useCallback(
|
| 1210 |
-
async (filename: string): Promise<Blob> => {
|
| 1211 |
-
if (effectiveStorageModeClient === 'indexeddb') {
|
| 1212 |
-
const record = allDbImages?.find((img) => img.filename === filename);
|
| 1213 |
-
if (!record?.blob) {
|
| 1214 |
-
throw new Error(t('error.imageNotFoundDb', { filename }));
|
| 1215 |
-
}
|
| 1216 |
-
return record.blob;
|
| 1217 |
-
}
|
| 1218 |
-
|
| 1219 |
-
if (!(await refreshImageAccessCookie())) {
|
| 1220 |
-
throw new Error(t('error.imageAccessRefreshFailed'));
|
| 1221 |
-
}
|
| 1222 |
-
const response = await fetch(`/api/image/${filename}`);
|
| 1223 |
-
if (!response.ok) {
|
| 1224 |
-
throw new Error(t('error.fetchImage', { statusText: response.statusText }));
|
| 1225 |
-
}
|
| 1226 |
-
return response.blob();
|
| 1227 |
-
},
|
| 1228 |
-
[allDbImages, effectiveStorageModeClient, refreshImageAccessCookie, t]
|
| 1229 |
-
);
|
| 1230 |
-
```
|
| 1231 |
-
|
| 1232 |
-
添加下载:
|
| 1233 |
-
|
| 1234 |
-
```ts
|
| 1235 |
-
const handleDownloadImage = React.useCallback(
|
| 1236 |
-
async (filename: string) => {
|
| 1237 |
-
try {
|
| 1238 |
-
const blob = await resolveImageBlob(filename);
|
| 1239 |
-
const url = URL.createObjectURL(blob);
|
| 1240 |
-
const link = document.createElement('a');
|
| 1241 |
-
link.href = url;
|
| 1242 |
-
link.download = filename;
|
| 1243 |
-
document.body.appendChild(link);
|
| 1244 |
-
link.click();
|
| 1245 |
-
link.remove();
|
| 1246 |
-
window.setTimeout(() => URL.revokeObjectURL(url), 150);
|
| 1247 |
-
} catch (error) {
|
| 1248 |
-
setError(createErrorNotice(error instanceof Error ? error.message : t('error.retrieveImage', { filename })));
|
| 1249 |
-
}
|
| 1250 |
-
},
|
| 1251 |
-
[createErrorNotice, resolveImageBlob, t]
|
| 1252 |
-
);
|
| 1253 |
-
```
|
| 1254 |
-
|
| 1255 |
-
添加打开分享和创建分享逻辑:
|
| 1256 |
-
|
| 1257 |
-
```ts
|
| 1258 |
-
const handleOpenShareImage = React.useCallback((filename: string) => {
|
| 1259 |
-
setShareTargetFilename(filename);
|
| 1260 |
-
setShareUrl(null);
|
| 1261 |
-
setShareError(null);
|
| 1262 |
-
setShareDialogOpen(true);
|
| 1263 |
-
}, []);
|
| 1264 |
-
|
| 1265 |
-
const handleCreateShare = React.useCallback(
|
| 1266 |
-
async (values: ShareDialogValues) => {
|
| 1267 |
-
if (!shareTargetFilename) return;
|
| 1268 |
-
setIsCreatingShare(true);
|
| 1269 |
-
setShareError(null);
|
| 1270 |
-
try {
|
| 1271 |
-
const blob = await resolveImageBlob(shareTargetFilename);
|
| 1272 |
-
const form = new FormData();
|
| 1273 |
-
form.set('sourceFilename', shareTargetFilename);
|
| 1274 |
-
form.set('image', new File([blob], shareTargetFilename, { type: blob.type || 'image/png' }));
|
| 1275 |
-
const accessCode = values.accessCode.trim();
|
| 1276 |
-
if (accessCode) {
|
| 1277 |
-
form.set('accessCode', accessCode);
|
| 1278 |
-
}
|
| 1279 |
-
if (typeof values.expiresInMinutes === 'number') {
|
| 1280 |
-
form.set('expiresInMinutes', String(values.expiresInMinutes));
|
| 1281 |
-
}
|
| 1282 |
-
|
| 1283 |
-
const response = await fetch('/api/shares', { method: 'POST', body: form });
|
| 1284 |
-
const body = await response.json();
|
| 1285 |
-
if (!response.ok) {
|
| 1286 |
-
throw new Error(body.error || t('share.createFailed'));
|
| 1287 |
-
}
|
| 1288 |
-
setShareUrl(body.url);
|
| 1289 |
-
} catch (error) {
|
| 1290 |
-
setShareError(error instanceof Error ? error.message : t('share.createFailed'));
|
| 1291 |
-
} finally {
|
| 1292 |
-
setIsCreatingShare(false);
|
| 1293 |
-
}
|
| 1294 |
-
},
|
| 1295 |
-
[resolveImageBlob, shareTargetFilename, t]
|
| 1296 |
-
);
|
| 1297 |
-
```
|
| 1298 |
-
|
| 1299 |
-
在其他顶层弹窗附近渲染分享弹窗:
|
| 1300 |
-
|
| 1301 |
-
```tsx
|
| 1302 |
-
<ShareDialog
|
| 1303 |
-
open={shareDialogOpen}
|
| 1304 |
-
onOpenChange={setShareDialogOpen}
|
| 1305 |
-
isCreating={isCreatingShare}
|
| 1306 |
-
shareUrl={shareUrl}
|
| 1307 |
-
error={shareError}
|
| 1308 |
-
onCreate={handleCreateShare}
|
| 1309 |
-
/>
|
| 1310 |
-
```
|
| 1311 |
-
|
| 1312 |
-
向 `ImageOutput` 传递 props:
|
| 1313 |
-
|
| 1314 |
-
```tsx
|
| 1315 |
-
onDownloadImage={handleDownloadImage}
|
| 1316 |
-
onShareImage={handleOpenShareImage}
|
| 1317 |
-
```
|
| 1318 |
-
|
| 1319 |
-
- [x] **步骤 4:添加动作和弹窗 i18n 文案**
|
| 1320 |
-
|
| 1321 |
-
修改 `src/lib/i18n.tsx`。
|
| 1322 |
-
|
| 1323 |
-
中文:
|
| 1324 |
-
|
| 1325 |
-
```ts
|
| 1326 |
-
'output.download': '下载',
|
| 1327 |
-
'output.share': '分享',
|
| 1328 |
-
'share.dialogTitle': '分享图片',
|
| 1329 |
-
'share.dialogDescription': '创建一个可访问的图片链接,可以设置访问码和有效期。',
|
| 1330 |
-
'share.accessCode': '访问码',
|
| 1331 |
-
'share.accessCodeOptional': '留空表示不需要访问码',
|
| 1332 |
-
'share.expiry': '有效期',
|
| 1333 |
-
'share.expiryNone': '永久有效',
|
| 1334 |
-
'share.expiryOneHour': '1 小时',
|
| 1335 |
-
'share.expiryOneDay': '1 天',
|
| 1336 |
-
'share.expirySevenDays': '7 天',
|
| 1337 |
-
'share.link': '分享链接',
|
| 1338 |
-
'share.copyLink': '复制分享链接',
|
| 1339 |
-
'share.create': '创建分享',
|
| 1340 |
-
'share.createFailed': '创建分享失败。',
|
| 1341 |
-
'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。',
|
| 1342 |
-
```
|
| 1343 |
-
|
| 1344 |
-
英文:
|
| 1345 |
-
|
| 1346 |
-
```ts
|
| 1347 |
-
'output.download': 'Download',
|
| 1348 |
-
'output.share': 'Share',
|
| 1349 |
-
'share.dialogTitle': 'Share Image',
|
| 1350 |
-
'share.dialogDescription': 'Create an image link with an optional access code and expiry.',
|
| 1351 |
-
'share.accessCode': 'Access Code',
|
| 1352 |
-
'share.accessCodeOptional': 'Leave blank for no access code',
|
| 1353 |
-
'share.expiry': 'Expiry',
|
| 1354 |
-
'share.expiryNone': 'Never expires',
|
| 1355 |
-
'share.expiryOneHour': '1 hour',
|
| 1356 |
-
'share.expiryOneDay': '1 day',
|
| 1357 |
-
'share.expirySevenDays': '7 days',
|
| 1358 |
-
'share.link': 'Share link',
|
| 1359 |
-
'share.copyLink': 'Copy share link',
|
| 1360 |
-
'share.create': 'Create Share',
|
| 1361 |
-
'share.createFailed': 'Failed to create share.',
|
| 1362 |
-
'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.',
|
| 1363 |
-
```
|
| 1364 |
-
|
| 1365 |
-
- [x] **步骤 5:运行前端验证**
|
| 1366 |
-
|
| 1367 |
-
运行:
|
| 1368 |
-
|
| 1369 |
-
```bash
|
| 1370 |
-
npm run lint
|
| 1371 |
-
npm run build
|
| 1372 |
-
```
|
| 1373 |
-
|
| 1374 |
-
预期:两项都通过。
|
| 1375 |
-
|
| 1376 |
-
### 任务 5:端到端验证
|
| 1377 |
-
|
| 1378 |
-
**文件:**
|
| 1379 |
-
- 除非验证发现缺陷,否则不改代码。
|
| 1380 |
-
|
| 1381 |
-
- [x] **步骤 1:运行完整本地门禁**
|
| 1382 |
-
|
| 1383 |
-
运行:
|
| 1384 |
-
|
| 1385 |
-
```bash
|
| 1386 |
-
npm test
|
| 1387 |
-
npm run lint
|
| 1388 |
-
npm run build
|
| 1389 |
-
git diff --check
|
| 1390 |
-
```
|
| 1391 |
-
|
| 1392 |
-
预期:
|
| 1393 |
-
- `npm test`:全部测试通过;除非已配置,否则 PostgreSQL live 测试可以继续跳过。
|
| 1394 |
-
- `npm run lint`:通过。
|
| 1395 |
-
- `npm run build`:通过。
|
| 1396 |
-
- `git diff --check`:通过。
|
| 1397 |
-
|
| 1398 |
-
- [x] **步骤 2:启动应用用于浏览器冒烟测试**
|
| 1399 |
-
|
| 1400 |
-
如果 4783 上没有运行中的服务:
|
| 1401 |
-
|
| 1402 |
-
```bash
|
| 1403 |
-
npm run dev
|
| 1404 |
-
```
|
| 1405 |
-
|
| 1406 |
-
如果需要 Docker 验证:
|
| 1407 |
-
|
| 1408 |
-
```bash
|
| 1409 |
-
docker compose up -d --build
|
| 1410 |
-
```
|
| 1411 |
-
|
| 1412 |
-
预期:应用可通过 `http://localhost:4783` 访问。
|
| 1413 |
-
|
| 1414 |
-
- [x] **步骤 3:浏览器冒烟测试**
|
| 1415 |
-
|
| 1416 |
-
使用内置 Browser 访问 `http://localhost:4783`:
|
| 1417 |
-
|
| 1418 |
-
- 生成一张图片,或选择已有历史图片。
|
| 1419 |
-
- 切换到单图视图。
|
| 1420 |
-
- 确认发送到编辑、下载和分享按钮可见且没有重叠。
|
| 1421 |
-
- 点击下载,并确认浏览器启动文件下载,或产生有效的 object URL 点击路径。
|
| 1422 |
-
- 点击分享,创建无访问码分享,打开返回 URL,确认图片渲染。
|
| 1423 |
-
- 创建带访问码的分享,打开返回 URL,确认输入访问码前图片不渲染,输入正确访问码后图片渲染。
|
| 1424 |
-
|
| 1425 |
-
- [x] **步骤 4:分享路由直接 HTTP 冒烟测试**
|
| 1426 |
-
|
| 1427 |
-
通过生成的一字节 PNG payload 创建受保护分享,然后测试元数据、错误访问码和正确访问码,执行直接路由冒烟测试:
|
| 1428 |
-
|
| 1429 |
-
```bash
|
| 1430 |
-
node --input-type=module <<'NODE'
|
| 1431 |
-
import crypto from 'node:crypto';
|
| 1432 |
-
|
| 1433 |
-
async function resolveAccessCookie() {
|
| 1434 |
-
if (!process.env.APP_PASSWORD) return '';
|
| 1435 |
-
const passwordHash = crypto.createHash('sha256').update(process.env.APP_PASSWORD).digest('hex');
|
| 1436 |
-
const response = await fetch('http://localhost:4783/api/auth-verify', {
|
| 1437 |
-
method: 'POST',
|
| 1438 |
-
headers: { 'content-type': 'application/json' },
|
| 1439 |
-
body: JSON.stringify({ passwordHash })
|
| 1440 |
-
});
|
| 1441 |
-
if (!response.ok) {
|
| 1442 |
-
throw new Error(`auth-verify failed: ${response.status} ${await response.text()}`);
|
| 1443 |
-
}
|
| 1444 |
-
const cookie = response.headers.get('set-cookie');
|
| 1445 |
-
if (!cookie) throw new Error('auth-verify did not return an access cookie');
|
| 1446 |
-
return cookie.split(';')[0];
|
| 1447 |
-
}
|
| 1448 |
-
|
| 1449 |
-
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/luz7wgAAAABJRU5ErkJggg==';
|
| 1450 |
-
const blob = new Blob([Buffer.from(pngBase64, 'base64')], { type: 'image/png' });
|
| 1451 |
-
const form = new FormData();
|
| 1452 |
-
form.set('sourceFilename', 'smoke.png');
|
| 1453 |
-
form.set('accessCode', 'smoke-code');
|
| 1454 |
-
form.set('expiresInMinutes', '60');
|
| 1455 |
-
form.set('image', new File([blob], 'smoke.png', { type: 'image/png' }));
|
| 1456 |
-
|
| 1457 |
-
const headers = new Headers();
|
| 1458 |
-
const accessCookie = await resolveAccessCookie();
|
| 1459 |
-
if (accessCookie) headers.set('Cookie', accessCookie);
|
| 1460 |
-
|
| 1461 |
-
const createResponse = await fetch('http://localhost:4783/api/shares', { method: 'POST', headers, body: form });
|
| 1462 |
-
const createBody = await createResponse.json();
|
| 1463 |
-
console.log('create', createResponse.status, createBody);
|
| 1464 |
-
|
| 1465 |
-
const metadataResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}`);
|
| 1466 |
-
console.log('metadata', metadataResponse.status, await metadataResponse.json());
|
| 1467 |
-
|
| 1468 |
-
const wrongResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}/content`, {
|
| 1469 |
-
method: 'POST',
|
| 1470 |
-
headers: { 'content-type': 'application/json' },
|
| 1471 |
-
body: JSON.stringify({ accessCode: 'wrong' })
|
| 1472 |
-
});
|
| 1473 |
-
console.log('wrong', wrongResponse.status, await wrongResponse.text());
|
| 1474 |
-
|
| 1475 |
-
const okResponse = await fetch(`http://localhost:4783/api/shares/${createBody.token}/content`, {
|
| 1476 |
-
method: 'POST',
|
| 1477 |
-
headers: { 'content-type': 'application/json' },
|
| 1478 |
-
body: JSON.stringify({ accessCode: 'smoke-code' })
|
| 1479 |
-
});
|
| 1480 |
-
console.log('ok', okResponse.status, okResponse.headers.get('content-type'), (await okResponse.arrayBuffer()).byteLength);
|
| 1481 |
-
NODE
|
| 1482 |
-
```
|
| 1483 |
-
|
| 1484 |
-
预期:
|
| 1485 |
-
- 创建接口返回 `201`,包含 24 字符十六进制 token,且不包含访问码 hash 或 salt。
|
| 1486 |
-
- 元数据接口返回 `200`。
|
| 1487 |
-
- 错误访问码返回 `401`。
|
| 1488 |
-
- 正确访问码返回图片内容,content type 为图片类型,且字节长度非零。
|
| 1489 |
-
|
| 1490 |
-
- [x] **步骤 5:复核 diff 范围**
|
| 1491 |
-
|
| 1492 |
-
运行:
|
| 1493 |
-
|
| 1494 |
-
```bash
|
| 1495 |
-
git diff --name-only
|
| 1496 |
-
git status --short
|
| 1497 |
-
```
|
| 1498 |
-
|
| 1499 |
-
预期:
|
| 1500 |
-
- Diff 只包含计划内文件,以及任何预先存在的无关脏文件。
|
| 1501 |
-
- 不要回滚预先存在的无关改动。
|
| 1502 |
-
|
| 1503 |
-
## 自审清单
|
| 1504 |
-
|
| 1505 |
-
- 规格覆盖:下载按钮、分享按钮、访问码、有效期、接收者直接访问、Docker/浏览器验证都已映射到任务。
|
| 1506 |
-
- 占位符扫描:没有任务使用 `TBD`、`TODO`、`implement later`、`fill in details` 或尖括号命令占位符。
|
| 1507 |
-
- 类型一致性:分享弹窗返回 `ShareDialogValues`,页面处理函数接收相同类型,分享路由返回 `accessCodeRequired` 和 `url`。
|
| 1508 |
-
- 边界检查:Agent API 保持冻结;分享功能使用新的 `/api/shares` 路由和复制出的分享产物。
|
| 1509 |
-
- 门禁边界:单元/API 测试证明服务端契约;浏览器/Docker 冒烟测试证明运行时 UI 接线。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-06-06-product-improvement-stage-1.md
DELETED
|
@@ -1,793 +0,0 @@
|
|
| 1 |
-
# Product Improvement Stage 1 Implementation Plan
|
| 2 |
-
|
| 3 |
-
> **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.
|
| 4 |
-
|
| 5 |
-
**Goal:** 收窄并完善 GPT Image Playground 的第一阶段产品闭环,让产品从“能力集合”收敛为可验证、可部署、可解释的本地和内网 AI 图片创作工作台,同时保留 Agent API 作为明确的自动化接口。
|
| 6 |
-
|
| 7 |
-
**Architecture:** 本阶段不重写核心生图链路,先用文档、默认值、门禁和小型 UI/API 调整收束产品控制面。主产品线锁定为“本地/内网高分辨率图片生成与编辑工作台”,Agent API 保持机器调用入口但不包装成自治 Agent,公网分享与 HF Space 部署按更保守的安全默认值处理。
|
| 8 |
-
|
| 9 |
-
**Tech Stack:** Next.js 16 App Router, React 19, node:test, tsx, ESLint, existing npm scripts, Hugging Face Docker Space docs.
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## Control Contract
|
| 14 |
-
|
| 15 |
-
**Primary Setpoint:** 第一阶段完成后,仓库内存在一套一致的产品合同、验证脚本和默认安全行为,能证明“中文创作工作台”主线、分享安全默认值、公网部署门禁和 Agent API 边界已经收敛。
|
| 16 |
-
|
| 17 |
-
**Acceptance:**
|
| 18 |
-
- `docs/product/product-contract.md` 明确第一用户、非目标用户、核心路径、上线边界、指标和 The Mom Test 验证脚本。
|
| 19 |
-
- `README.md`、`客户使用说明.md`、`docs/ui/literary-young-women-workbench-design.md`、`docs/deployment/huggingface-space-free.md` 对产品定位、Agent API、分享和公网部署的说法一致。
|
| 20 |
-
- 分享弹窗默认不再是“无访问码且永不过期”的最宽公开状态,服务端创建接口继续强制访问码长度、有效期和图片内容校验。
|
| 21 |
-
- HF Space 或公网部署文档和检查脚本明确提示 `APP_PASSWORD` 与 `AGENT_API_TOKEN`,并将未配置状态标为不适合公网客户使用。
|
| 22 |
-
- Agent 文案统一为 `Agent API` 或 `automation API`,不暗示具备自治执行、跨实例持久队列或生产级调度能力。
|
| 23 |
-
- 最终运行 `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`,并记录结果。
|
| 24 |
-
|
| 25 |
-
**Guardrail Metrics:**
|
| 26 |
-
- 不破坏 `/api/images`、`/api/agent/*`、`/api/shares/*` 现有响应契约。
|
| 27 |
-
- 不把任何真实 API Key、token、访问码或个人配置写入源码或文档。
|
| 28 |
-
- 不把离线测试通过表述成真实 OpenAI、真实 HF Space 或真实客户验证通过。
|
| 29 |
-
- 不新增静默降级、mock 成功路径或吞没错误后继续的逻辑。
|
| 30 |
-
- 不引入第二套测试框架。
|
| 31 |
-
|
| 32 |
-
**Sampling Plan:**
|
| 33 |
-
- L0: 每个任务完成后运行对应文件的定向测试或静态检查。
|
| 34 |
-
- L1: 涉及 UI/API 接线后运行 `npm test` 和 `npm run lint`。
|
| 35 |
-
- L2: 收尾运行完整本地门禁;若触碰 HF Space 部署脚本,再运行 `npm run smoke:hf-space` 或明确记录未执行原因。
|
| 36 |
-
|
| 37 |
-
**Delay Budget:**
|
| 38 |
-
- `npm test` 和 lint 属于快速门禁,本阶段每个相关代码任务后执行。
|
| 39 |
-
- `npm run build` 属于慢 gate,安排在文档和代码收敛后执行。
|
| 40 |
-
- 真实 HF Space、真实上游生图、真实客户访谈不在本阶段自动执行;只建立可执行脚本、文档和验收表。
|
| 41 |
-
|
| 42 |
-
**Recovery Target:** 如果某个任务引入回归,先停止后续任务,在 10 分钟内通过当前任务 diff 回退到上一个验证通过状态。
|
| 43 |
-
|
| 44 |
-
**Rollback Trigger:**
|
| 45 |
-
- 分享内容路由在访问码错误或过期后仍能返回图片字节。
|
| 46 |
-
- 公网部署文档或脚本鼓励无访问码使用服务端 API Key。
|
| 47 |
-
- Agent 文档声明超过现有能力的自治执行、跨实例持久队列或生产调度。
|
| 48 |
-
- `npm test`、`npm run lint`、`npm run build` 出现与当前任务相关的失败且无法在任务内修复。
|
| 49 |
-
|
| 50 |
-
**Constraints:**
|
| 51 |
-
- 全程中文沟通。
|
| 52 |
-
- 代码、注释、日志和 Markdown 不使用 Emoji 或装饰性 Unicode 符号。
|
| 53 |
-
- 不修改与本阶段目标无关的 UI 视觉细节、模型兼容逻辑、数据库 schema 或 Agent API schema。
|
| 54 |
-
- 当前仓库没有任务跟踪文件,以用户本回合指定的产品完善任务为唯一任务来源。
|
| 55 |
-
|
| 56 |
-
**Boundary:**
|
| 57 |
-
- 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`.
|
| 58 |
-
- Allowed UI/lib files: `src/components/share-dialog.tsx`, `src/lib/share-client.ts`, `src/lib/i18n.tsx`.
|
| 59 |
-
- 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.
|
| 60 |
-
- 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.
|
| 61 |
-
- Frozen: `/api/agent/*` schema, image generation request/response schema, database schema, core upstream routing, cost calculation semantics.
|
| 62 |
-
|
| 63 |
-
**Coupling Notes:**
|
| 64 |
-
- 分享默认值 touches UI state, client form submission, server validation, and route tests.
|
| 65 |
-
- HF Space safety touches deployment docs and doctor diagnostics, but should not change runtime auth semantics without separate approval.
|
| 66 |
-
- Product contract touches README and UI design docs, but must not create a second product truth that conflicts with code.
|
| 67 |
-
|
| 68 |
-
**Approximation Validity:**
|
| 69 |
-
- node:test route tests validate local API semantics and in-memory/filesystem behavior, not multi-instance distributed rate limiting.
|
| 70 |
-
- Documentation and validation scripts can make customer research repeatable, but do not count as completed customer research.
|
| 71 |
-
- HF Space doctor tests can validate configuration warnings, not prove a deployed Space is production-ready.
|
| 72 |
-
|
| 73 |
-
**Actuator Budget:**
|
| 74 |
-
- Documentation edits, localized copy edits, small UI default changes, route tests, doctor warning tests, and verification scripts.
|
| 75 |
-
- No broad redesign, no schema migration, no new database, no external analytics service, no new auth system.
|
| 76 |
-
|
| 77 |
-
**Risks:**
|
| 78 |
-
- 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.
|
| 79 |
-
- Risk 2: Safer sharing defaults reduce convenience. Mitigation: keep no-access-code sharing available as an explicit user choice, but make the risk visible.
|
| 80 |
-
- 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.
|
| 81 |
-
|
| 82 |
-
## State Estimate
|
| 83 |
-
|
| 84 |
-
- `main` is clean and matches `origin/main` at `34b982e Release v2.0.0` before this planning branch was created.
|
| 85 |
-
- Current branch for this work: `codex/product-improvement-planning-only`.
|
| 86 |
-
- Existing UI PRD defines `图像手记` as a Chinese creative workbench and names a visual audience, but does not define measurable first-user outcomes.
|
| 87 |
-
- Existing README also positions the repo as a local `gpt-image-2` service, compatible API probe, Agent API, and HF Space deployment artifact.
|
| 88 |
-
- Existing share route already validates creator auth when `APP_PASSWORD` exists, image type, access code length, expiry range and content MIME type.
|
| 89 |
-
- Existing share UI defaults to no access code and no expiry.
|
| 90 |
-
- Existing HF Space docs already warn that free tier memory state and temporary image files are not persistent.
|
| 91 |
-
|
| 92 |
-
## Execution Record
|
| 93 |
-
|
| 94 |
-
- Stage 1 implementation completed on branch `codex/product-improvement-planning-only`.
|
| 95 |
-
- Implementation commits:
|
| 96 |
-
- `896d4a4 docs: define product contract and validation`
|
| 97 |
-
- `9e36e11 fix: make image sharing defaults safer`
|
| 98 |
-
- `c6cc4cc docs: tighten public deployment and agent boundary`
|
| 99 |
-
- `9efcda8 docs: record product stage one gate`
|
| 100 |
-
- Final local gate executed on the completed branch: `npm run verify` returned `"ok": true`.
|
| 101 |
-
- Targeted share gates executed on the completed branch:
|
| 102 |
-
- `node --test --import tsx src/components/share-dialog.test.tsx` returned 2 pass.
|
| 103 |
-
- `node --test --import tsx src/app/api/shares/route.test.ts` returned 21 pass.
|
| 104 |
-
- 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`.
|
| 105 |
-
- `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`.
|
| 106 |
-
|
| 107 |
-
## Follow-up Addendum
|
| 108 |
-
|
| 109 |
-
- Current branch follow-up narrows the first battle to Chinese content operators generating first publish visuals for Xiaohongshu or e-commerce workflows.
|
| 110 |
-
- Local result feedback markers were added to recent history items so the latest generation can be marked `可用` or `需修改` inside the workbench history.
|
| 111 |
-
- Fresh local browser verification on `http://localhost:4784` confirmed the result feedback block renders on a recent history card with `结果反馈`, `可用` and `需修改` actions.
|
| 112 |
-
- 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.
|
| 113 |
-
- Agent skill docs now keep Agent API in the automation lane and out of the first-battle proof path.
|
| 114 |
-
|
| 115 |
-
## File Structure
|
| 116 |
-
|
| 117 |
-
- Create `docs/product/product-contract.md`
|
| 118 |
-
- Single source for first-stage product positioning, non-goals, core workflow, metrics, launch boundary and evidence standard.
|
| 119 |
-
- Create `docs/product/user-validation-script.md`
|
| 120 |
-
- Field script for 5 to 10 target users, including tasks, questions, pass/fail criteria and evidence table.
|
| 121 |
-
- Modify `docs/ui/literary-young-women-workbench-design.md`
|
| 122 |
-
- Reference the product contract and clarify that visual style is not the target-user proof.
|
| 123 |
-
- Modify `README.md`
|
| 124 |
-
- Align product summary, Agent API wording, sharing defaults and deployment safety copy.
|
| 125 |
-
- Modify `客户使用说明.md`
|
| 126 |
-
- Add customer-facing safety and first-run path without developer-heavy language.
|
| 127 |
-
- Modify `docs/deployment/huggingface-space-free.md`
|
| 128 |
-
- Make public deployment safety gate explicit and keep free-tier limitations visible.
|
| 129 |
-
- Modify `src/components/share-dialog.tsx`
|
| 130 |
-
- Change initial expiry default to a safer short-lived option and show explicit copy for public no-access-code sharing.
|
| 131 |
-
- Modify `src/lib/share-client.ts`
|
| 132 |
-
- Keep client form submission explicit and add small tests if defaults move into a helper.
|
| 133 |
-
- Modify `src/lib/i18n.tsx`
|
| 134 |
-
- Add or adjust share safety copy in Chinese and English.
|
| 135 |
-
- Modify `src/app/api/shares/route.test.ts` and `src/app/api/share-route.test.ts`
|
| 136 |
-
- Lock server-side share behavior against no access code, expiry, protected content and error paths.
|
| 137 |
-
- Optional modify `scripts/hf-space-doctor-utils.mjs` and tests
|
| 138 |
-
- Only if current diagnostics do not visibly mark missing public auth as unsafe.
|
| 139 |
-
|
| 140 |
-
## Project Control Topology
|
| 141 |
-
|
| 142 |
-
**总体设计部:** `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.
|
| 143 |
-
|
| 144 |
-
**控制结构:**
|
| 145 |
-
- Product docs can change positioning, wording and validation requirements.
|
| 146 |
-
- UI/API tasks can change default choices and visible warnings.
|
| 147 |
-
- Deployment scripts can observe and warn about unsafe public configuration.
|
| 148 |
-
- Core image generation, Agent schema, database schema and upstream compatibility are frozen.
|
| 149 |
-
|
| 150 |
-
**主落点:** 控制面。This stage changes product defaults, warnings, gates and documentation. It should not move the core data plane.
|
| 151 |
-
|
| 152 |
-
**次级落点:** 状态面 is touched only through share metadata semantics and HF Space memory-state documentation. Data plane remains the existing image generation and image serving paths.
|
| 153 |
-
|
| 154 |
-
**复杂性转移账本:**
|
| 155 |
-
|
| 156 |
-
| 字段 | 内容 |
|
| 157 |
-
| --- | --- |
|
| 158 |
-
| 复杂性原位置 | 用户需要从 README、UI design、HF docs and runtime behavior infer product boundaries and safety defaults. |
|
| 159 |
-
| 新位置 | Product contract, safer share defaults, public deployment diagnostics and aligned customer docs. |
|
| 160 |
-
| 收益 | Product decisions become inspectable and deployment risks become visible before customer exposure. |
|
| 161 |
-
| 新成本 | Docs and diagnostics must stay aligned with future runtime changes. |
|
| 162 |
-
| 失效模式 | A future feature bypasses product contract or reintroduces unsafe defaults without updating tests. |
|
| 163 |
-
|
| 164 |
-
## Black-Box Input/Output Matrix
|
| 165 |
-
|
| 166 |
-
| Control Input | Target Output | Direction | Coupled Outputs | Rollback Signal |
|
| 167 |
-
| --- | --- | --- | --- | --- |
|
| 168 |
-
| Add product contract | Product positioning ambiguity | Decrease | README and UI docs copy | Docs contradict current API or workflow |
|
| 169 |
-
| Add validation script | User evidence quality | Increase | Product roadmap and acceptance gates | Script asks opinions instead of past behavior |
|
| 170 |
-
| Change share expiry default | Public exposure duration | Decrease | Share dialog UX and tests | Existing protected share tests fail |
|
| 171 |
-
| Add share risk copy | User risk awareness | Increase | i18n and dialog layout | Text overflows or confuses no-access-code option |
|
| 172 |
-
| Add HF public safety gate | Unsafe public deployments | Decrease | doctor output and deployment docs | Local-only use incorrectly blocked |
|
| 173 |
-
| Rename Agent positioning | Overclaim risk | Decrease | README, skill docs and customer docs | Agent API contract wording becomes unclear |
|
| 174 |
-
|
| 175 |
-
## Tasks
|
| 176 |
-
|
| 177 |
-
### Task 1: Create Product Contract
|
| 178 |
-
|
| 179 |
-
**Files:**
|
| 180 |
-
- Create: `docs/product/product-contract.md`
|
| 181 |
-
- Modify: `README.md`
|
| 182 |
-
- Modify: `docs/ui/literary-young-women-workbench-design.md`
|
| 183 |
-
|
| 184 |
-
- [x] **Step 1: Create product directory**
|
| 185 |
-
|
| 186 |
-
Run:
|
| 187 |
-
|
| 188 |
-
```bash
|
| 189 |
-
mkdir -p docs/product
|
| 190 |
-
```
|
| 191 |
-
|
| 192 |
-
Expected: `docs/product` exists.
|
| 193 |
-
|
| 194 |
-
- [x] **Step 2: Write the product contract**
|
| 195 |
-
|
| 196 |
-
Create `docs/product/product-contract.md` with these sections:
|
| 197 |
-
|
| 198 |
-
```markdown
|
| 199 |
-
# 图像手记产品合同
|
| 200 |
-
|
| 201 |
-
## 阶段结论
|
| 202 |
-
|
| 203 |
-
第一阶段主线是给中文小红书和电商内容运营者使用的本地或内网 AI 图片创作工作台,不是通用图片平台、公开 SaaS、自治 Agent 或兼容接口基准测试平台。
|
| 204 |
-
|
| 205 |
-
## 第一真实用户
|
| 206 |
-
|
| 207 |
-
第一真实用户是需要为小红书笔记、商品详情页或活动海报反复产出首版视觉稿的中文内容运营者。她通常一个人或在 2 到 5 人小团队内工作,有自己的 API Key 或由团队配置服务端 Key,需要在本机、内网或受控公网环境中完成提示词生成、结果挑选、继续编辑、下载和安全分享。
|
| 208 |
-
|
| 209 |
-
## 首战场景
|
| 210 |
-
|
| 211 |
-
首战场景只验证一件事:目标用户为一个真实发布任务生成一张可下载、可继续修改、可被标记为可用或需修改的首版封面或商品氛围图。第一阶段不同时证明企业素材库、多人审批、公开 SaaS、模型兼容探测和长期 Agent 调度。
|
| 212 |
-
|
| 213 |
-
## 非目标用户
|
| 214 |
-
|
| 215 |
-
- 需要多人协作素材库、权限分层、审批流或结算系统的企业团队。
|
| 216 |
-
- 需要生产级公网 SaaS 可用性、对象存储和审计合规的客户。
|
| 217 |
-
- 需要自治 Agent 长期调度、跨实例队列和任务审计的自动化系统。
|
| 218 |
-
- 只想验证任意 OpenAI-compatible 接口全部能力的工程测试人员。
|
| 219 |
-
|
| 220 |
-
## 核心闭环
|
| 221 |
-
|
| 222 |
-
1. 选一个正在准备发布的笔记、商品或活动主题。
|
| 223 |
-
2. 写下真实提示词。
|
| 224 |
-
3. 选择常用尺寸、数量、清晰度和格式。
|
| 225 |
-
4. 生成或编辑图片。
|
| 226 |
-
5. 在中央预览中挑选结果。
|
| 227 |
-
6. 在最近生成中标记结果为 `可用` 或 `需修改`。
|
| 228 |
-
7. 继续编辑、做变体、复用提示词、对比、下载或分享。
|
| 229 |
-
8. 从最近生成或灵感相册回到下一次创作。
|
| 230 |
-
|
| 231 |
-
## 产品指标
|
| 232 |
-
|
| 233 |
-
- 第 3 分钟:新用户能在不听讲解的情况下生成一张可下载图片。
|
| 234 |
-
- 第 30 分钟:用户能从历史或当前结果继续编辑或复用提示词。
|
| 235 |
-
- 第 3 天:用户再次打开并复用历史、灵感或参数。
|
| 236 |
-
- 结果质量:用户能为最近生成结果标记 `可用` 或 `需修改`,并说明标记理由。
|
| 237 |
-
- 失败恢复:用户看到明确失败原因后知道该重试、改参数、换上游还是停止。
|
| 238 |
-
|
| 239 |
-
## 上线边界
|
| 240 |
-
|
| 241 |
-
第一阶段可以面向本地、内网和受控公网部署。公网部署必须配置页面访问码;使用服务端 API Key 时必须配置页面访问码,Agent API 对外开放时必须配置 Agent token。
|
| 242 |
-
|
| 243 |
-
## 证据标准
|
| 244 |
-
|
| 245 |
-
产品判断优先使用真实行为、任务完成、质量标记、迁移、复用、授权、付费和引荐承诺。口头认可、审美偏好和内部演示不能单独证明产品成立。
|
| 246 |
-
```
|
| 247 |
-
|
| 248 |
-
- [x] **Step 3: Link contract from README**
|
| 249 |
-
|
| 250 |
-
Add a short sentence near the README product introduction:
|
| 251 |
-
|
| 252 |
-
```markdown
|
| 253 |
-
第一阶段产品边界以 `docs/product/product-contract.md` 为准:主线是给中文内容运营者产出首版视觉稿的本地和内网 AI 图片创作工作台,Agent API 是自动化接口,不是自治 Agent 平台。
|
| 254 |
-
```
|
| 255 |
-
|
| 256 |
-
- [x] **Step 4: Link contract from UI design baseline**
|
| 257 |
-
|
| 258 |
-
Add this after `docs/ui/literary-young-women-workbench-design.md` line describing the core positioning:
|
| 259 |
-
|
| 260 |
-
```markdown
|
| 261 |
-
产品边界以 `docs/product/product-contract.md` 为准;本文档只定义工作台信息架构和视觉交互基线,不把审美画像当作真实用户证据。首战验证必须落在真实发布任务、可下载结果、继续编辑或复用、以及最近生成里的 `可用` / `需修改` 结果反馈。
|
| 262 |
-
```
|
| 263 |
-
|
| 264 |
-
- [x] **Step 5: Verify doc references**
|
| 265 |
-
|
| 266 |
-
Run:
|
| 267 |
-
|
| 268 |
-
```bash
|
| 269 |
-
rg -n "product-contract|自治 Agent|本地和内网 AI 图片创作工作台" README.md docs/ui docs/product
|
| 270 |
-
git diff --check
|
| 271 |
-
```
|
| 272 |
-
|
| 273 |
-
Expected: references are present and `git diff --check` exits 0.
|
| 274 |
-
|
| 275 |
-
- [x] **Step 6: Commit task**
|
| 276 |
-
|
| 277 |
-
Run:
|
| 278 |
-
|
| 279 |
-
```bash
|
| 280 |
-
git add README.md docs/ui/literary-young-women-workbench-design.md docs/product/product-contract.md
|
| 281 |
-
git commit -m "docs: define product contract"
|
| 282 |
-
```
|
| 283 |
-
|
| 284 |
-
Expected: commit succeeds with only these files staged.
|
| 285 |
-
|
| 286 |
-
### Task 2: Add User Validation Script
|
| 287 |
-
|
| 288 |
-
**Files:**
|
| 289 |
-
- Create: `docs/product/user-validation-script.md`
|
| 290 |
-
- Modify: `docs/product/product-contract.md`
|
| 291 |
-
|
| 292 |
-
- [x] **Step 1: Write validation script**
|
| 293 |
-
|
| 294 |
-
Create `docs/product/user-validation-script.md` with:
|
| 295 |
-
|
| 296 |
-
```markdown
|
| 297 |
-
# 用户验证脚本
|
| 298 |
-
|
| 299 |
-
## 样本
|
| 300 |
-
|
| 301 |
-
本轮验证 5 到 10 名目标用户。用户必须独立完成任务,观察者不能解释产品意义,只能回答环境和安全问题。
|
| 302 |
-
|
| 303 |
-
## 任务
|
| 304 |
-
|
| 305 |
-
1. 打开本地或受控公网地址,确认它是给小红书笔记、商品详情页或活动海报产图的工作台。
|
| 306 |
-
2. 填写 API 设置或使用已配置服务端 Key。
|
| 307 |
-
3. 用自己的真实发布需求写提示词并生成一张首版图片。
|
| 308 |
-
4. 下载生成结果。
|
| 309 |
-
5. 从结果进入继续编辑或做变体。
|
| 310 |
-
6. 从最近生成或灵感相册复用一次提示词。
|
| 311 |
-
7. 给最近生成结果标记 `可用` 或 `需修改`。
|
| 312 |
-
8. 如需分享,创建一个有有效期的分享链接。
|
| 313 |
-
|
| 314 |
-
## 观察记录
|
| 315 |
-
|
| 316 |
-
| 用户 | 第 3 分钟是否出图 | 是否下载 | 是否继续编辑或变体 | 是否复用历史或灵感 | 是否标记可用或需修改 | 是否理解费用和失败原因 | 迁移承诺 | 授权承诺 | 付费承诺 | 引荐承诺 | 卡点 |
|
| 317 |
-
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
| 318 |
-
|
| 319 |
-
## The Mom Test 问题
|
| 320 |
-
|
| 321 |
-
- 你上次需要这类图片是什么时候。
|
| 322 |
-
- 当时你怎么处理。
|
| 323 |
-
- 花了多久,经过几个人,是否付费。
|
| 324 |
-
- 当时哪里最麻烦。
|
| 325 |
-
- 你试过哪些替代方案,为什么没有继续用。
|
| 326 |
-
- 如果这个工具现在解决该问题,你愿意付出什么承诺:时间、迁移、授权、付费、让同事使用或引荐。
|
| 327 |
-
- 这次生成结果你会标记为可用还是需修改,为什么。
|
| 328 |
-
|
| 329 |
-
## 通过标准
|
| 330 |
-
|
| 331 |
-
- 至少 4 名用户能在第 3 分钟内独立生成并下载图片。
|
| 332 |
-
- 至少 3 名用户能完成继续编辑、做变体或复用提示词中的任一动作。
|
| 333 |
-
- 至少 4 名用户给最近生成结果标记 `可用` 或 `需修改`,并能说出标记理由。
|
| 334 |
-
- 至少 2 名用户在第 3 天复用历史、灵感或参数。
|
| 335 |
-
- 至少 2 名用户给出明确迁移、授权、付费、团队使用或引荐承诺中的任一项。
|
| 336 |
-
- 所有用户遇到失败时都能看到���确原因或下一步处理建议。
|
| 337 |
-
|
| 338 |
-
## 不通过信号
|
| 339 |
-
|
| 340 |
-
- 用户需要听产品解释才明白产品价值。
|
| 341 |
-
- 用户只夸审美但没有真实任务。
|
| 342 |
-
- 用户无法给结果标记,或标记理由只停留在好看、不好看这类审美评价。
|
| 343 |
-
- 用户不愿填写 API Key、不愿迁移流程、不愿复用历史。
|
| 344 |
-
- 用户第 3 天没有任何复用动作,也没有迁移、授权、付费或引荐承诺。
|
| 345 |
-
- 分享链接被误认为默认私密。
|
| 346 |
-
```
|
| 347 |
-
|
| 348 |
-
- [x] **Step 2: Reference validation in product contract**
|
| 349 |
-
|
| 350 |
-
Add to `docs/product/product-contract.md`:
|
| 351 |
-
|
| 352 |
-
```markdown
|
| 353 |
-
## 当前验证门禁
|
| 354 |
-
|
| 355 |
-
上线前按 `docs/product/user-validation-script.md` 执行 5 到 10 名用户验证。未执行前,不能声称产品已经通过真实用户验证。
|
| 356 |
-
```
|
| 357 |
-
|
| 358 |
-
- [x] **Step 3: Verify no opinion-only questions**
|
| 359 |
-
|
| 360 |
-
Run:
|
| 361 |
-
|
| 362 |
-
```bash
|
| 363 |
-
rg -n "你觉得|会不会用|有没有价值|不错|喜欢吗" docs/product/user-validation-script.md
|
| 364 |
-
```
|
| 365 |
-
|
| 366 |
-
Expected: no matches.
|
| 367 |
-
|
| 368 |
-
- [x] **Step 4: Commit task**
|
| 369 |
-
|
| 370 |
-
Run:
|
| 371 |
-
|
| 372 |
-
```bash
|
| 373 |
-
git add docs/product/product-contract.md docs/product/user-validation-script.md
|
| 374 |
-
git commit -m "docs: add product validation script"
|
| 375 |
-
```
|
| 376 |
-
|
| 377 |
-
Expected: commit succeeds.
|
| 378 |
-
|
| 379 |
-
### Task 3: Make Share Defaults Safer
|
| 380 |
-
|
| 381 |
-
**Files:**
|
| 382 |
-
- Modify: `src/components/share-dialog.tsx`
|
| 383 |
-
- Modify: `src/lib/i18n.tsx`
|
| 384 |
-
- Modify: `src/app/api/shares/route.test.ts`
|
| 385 |
-
- Modify: `src/app/api/share-route.test.ts`
|
| 386 |
-
|
| 387 |
-
- [x] **Step 1: Add failing UI default test**
|
| 388 |
-
|
| 389 |
-
If no existing share dialog test exists, create `src/components/share-dialog.test.tsx`:
|
| 390 |
-
|
| 391 |
-
```tsx
|
| 392 |
-
import { ShareDialog } from './share-dialog';
|
| 393 |
-
import { I18nProvider } from '@/lib/i18n';
|
| 394 |
-
import assert from 'node:assert/strict';
|
| 395 |
-
import { describe, it } from 'node:test';
|
| 396 |
-
import * as React from 'react';
|
| 397 |
-
import { renderToStaticMarkup } from 'react-dom/server';
|
| 398 |
-
|
| 399 |
-
describe('ShareDialog', () => {
|
| 400 |
-
it('defaults to a time-limited share and explains public sharing risk', () => {
|
| 401 |
-
const html = renderToStaticMarkup(
|
| 402 |
-
<I18nProvider>
|
| 403 |
-
<ShareDialog
|
| 404 |
-
open
|
| 405 |
-
onOpenChange={() => {}}
|
| 406 |
-
isCreating={false}
|
| 407 |
-
shareUrl={null}
|
| 408 |
-
error={null}
|
| 409 |
-
onCreate={() => {}}
|
| 410 |
-
/>
|
| 411 |
-
</I18nProvider>
|
| 412 |
-
);
|
| 413 |
-
|
| 414 |
-
assert.match(html, /1 天/);
|
| 415 |
-
assert.match(html, /无访问码/);
|
| 416 |
-
assert.match(html, /链接获得者/);
|
| 417 |
-
});
|
| 418 |
-
});
|
| 419 |
-
```
|
| 420 |
-
|
| 421 |
-
Run:
|
| 422 |
-
|
| 423 |
-
```bash
|
| 424 |
-
npm test -- src/components/share-dialog.test.tsx
|
| 425 |
-
```
|
| 426 |
-
|
| 427 |
-
Expected: FAIL before copy/default changes.
|
| 428 |
-
|
| 429 |
-
- [x] **Step 2: Change share dialog initial expiry**
|
| 430 |
-
|
| 431 |
-
In `src/components/share-dialog.tsx`, change:
|
| 432 |
-
|
| 433 |
-
```ts
|
| 434 |
-
const [expiry, setExpiry] = React.useState('none');
|
| 435 |
-
```
|
| 436 |
-
|
| 437 |
-
to:
|
| 438 |
-
|
| 439 |
-
```ts
|
| 440 |
-
const [expiry, setExpiry] = React.useState('1440');
|
| 441 |
-
```
|
| 442 |
-
|
| 443 |
-
- [x] **Step 3: Add public share risk copy**
|
| 444 |
-
|
| 445 |
-
Add localized messages in `src/lib/i18n.tsx`:
|
| 446 |
-
|
| 447 |
-
```ts
|
| 448 |
-
'share.publicRiskHint': '不设置访问码时,链接获得者可以直接查看图片。建议保留有效期。',
|
| 449 |
-
```
|
| 450 |
-
|
| 451 |
-
and English:
|
| 452 |
-
|
| 453 |
-
```ts
|
| 454 |
-
'share.publicRiskHint': 'Without an access code, anyone with the link can view the image. Keep an expiry enabled.',
|
| 455 |
-
```
|
| 456 |
-
|
| 457 |
-
Render it below the access code input in `ShareDialog`:
|
| 458 |
-
|
| 459 |
-
```tsx
|
| 460 |
-
<p className='text-muted-foreground text-xs'>{t('share.publicRiskHint')}</p>
|
| 461 |
-
```
|
| 462 |
-
|
| 463 |
-
- [x] **Step 4: Lock server behavior**
|
| 464 |
-
|
| 465 |
-
Ensure route tests cover:
|
| 466 |
-
|
| 467 |
-
```ts
|
| 468 |
-
it('allows explicit no-access-code shares but preserves expiry when provided', async () => {
|
| 469 |
-
// Build FormData with image and expiresInMinutes=1440.
|
| 470 |
-
// POST /api/shares.
|
| 471 |
-
// Assert 201, accessCodeRequired=false, expiresAt is a string.
|
| 472 |
-
});
|
| 473 |
-
```
|
| 474 |
-
|
| 475 |
-
and:
|
| 476 |
-
|
| 477 |
-
```ts
|
| 478 |
-
it('does not return expired share content', async () => {
|
| 479 |
-
// Create an expired share record using share-store helper.
|
| 480 |
-
// POST /api/shares/{token}/content.
|
| 481 |
-
// Assert status 410 and code share_expired.
|
| 482 |
-
});
|
| 483 |
-
```
|
| 484 |
-
|
| 485 |
-
- [x] **Step 5: Run targeted verification**
|
| 486 |
-
|
| 487 |
-
Run:
|
| 488 |
-
|
| 489 |
-
```bash
|
| 490 |
-
npm test -- src/components/share-dialog.test.tsx src/app/api/shares/route.test.ts src/app/api/share-route.test.ts
|
| 491 |
-
npm run lint
|
| 492 |
-
git diff --check
|
| 493 |
-
```
|
| 494 |
-
|
| 495 |
-
Expected: all pass.
|
| 496 |
-
|
| 497 |
-
- [x] **Step 6: Commit task**
|
| 498 |
-
|
| 499 |
-
Run:
|
| 500 |
-
|
| 501 |
-
```bash
|
| 502 |
-
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
|
| 503 |
-
git commit -m "fix: make image sharing defaults safer"
|
| 504 |
-
```
|
| 505 |
-
|
| 506 |
-
Expected: commit succeeds.
|
| 507 |
-
|
| 508 |
-
### Task 4: Tighten Public Deployment Safety Gate
|
| 509 |
-
|
| 510 |
-
**Files:**
|
| 511 |
-
- Modify: `docs/deployment/huggingface-space-free.md`
|
| 512 |
-
- Modify: `客户使用说明.md`
|
| 513 |
-
- Optional modify: `scripts/hf-space-doctor-utils.mjs`
|
| 514 |
-
- Optional modify: `scripts/hf-space-doctor-utils.test.mjs`
|
| 515 |
-
|
| 516 |
-
- [x] **Step 1: Update HF Space docs**
|
| 517 |
-
|
| 518 |
-
In `docs/deployment/huggingface-space-free.md`, keep the existing warning and add:
|
| 519 |
-
|
| 520 |
-
```markdown
|
| 521 |
-
公网客户试用必须同时完成以下检查:
|
| 522 |
-
|
| 523 |
-
- `APP_PASSWORD` 已设置。
|
| 524 |
-
- 使用 Agent API 时 `AGENT_API_TOKEN` 已设置。
|
| 525 |
-
- 服务端 API Key ���写入 Space Secrets,不写入仓库文件或 README。
|
| 526 |
-
- 免费层只适合演示和轻量试用,不承诺图片、分享链接或 Agent replay 长期保存。
|
| 527 |
-
```
|
| 528 |
-
|
| 529 |
-
- [x] **Step 2: Update customer instructions**
|
| 530 |
-
|
| 531 |
-
Add to `客户使用说明.md`:
|
| 532 |
-
|
| 533 |
-
```markdown
|
| 534 |
-
## 六、公网使用提醒
|
| 535 |
-
|
| 536 |
-
如果把服务部署到公网地址,必须设置网页访问码。没有访问码时,任何能打开网址的人都可能消耗服务端 API Key。
|
| 537 |
-
|
| 538 |
-
分享图片时建议保留有效期;如果不设置访问码,获得链接的人可以直接查看图片。
|
| 539 |
-
```
|
| 540 |
-
|
| 541 |
-
- [x] **Step 3: Inspect current doctor behavior**
|
| 542 |
-
|
| 543 |
-
Run:
|
| 544 |
-
|
| 545 |
-
```bash
|
| 546 |
-
npm run doctor -- --help
|
| 547 |
-
npm run doctor:hf-space -- --help
|
| 548 |
-
```
|
| 549 |
-
|
| 550 |
-
Expected: commands print help or structured diagnostics without secrets.
|
| 551 |
-
|
| 552 |
-
- [x] **Step 4: Add diagnostic warning only if missing**
|
| 553 |
-
|
| 554 |
-
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`.
|
| 555 |
-
|
| 556 |
-
Expected assertion shape:
|
| 557 |
-
|
| 558 |
-
```js
|
| 559 |
-
assert.equal(result.checks.find((check) => check.name === 'hf-space-public-auth')?.status, 'warn');
|
| 560 |
-
```
|
| 561 |
-
|
| 562 |
-
- [x] **Step 5: Run targeted verification**
|
| 563 |
-
|
| 564 |
-
Run:
|
| 565 |
-
|
| 566 |
-
```bash
|
| 567 |
-
npm run test:scripts
|
| 568 |
-
npm run lint:scripts
|
| 569 |
-
git diff --check
|
| 570 |
-
```
|
| 571 |
-
|
| 572 |
-
Expected: all pass.
|
| 573 |
-
|
| 574 |
-
- [x] **Step 6: Commit task**
|
| 575 |
-
|
| 576 |
-
Run:
|
| 577 |
-
|
| 578 |
-
```bash
|
| 579 |
-
git add docs/deployment/huggingface-space-free.md 客户使用说明.md scripts/hf-space-doctor-utils.mjs scripts/hf-space-doctor-utils.test.mjs
|
| 580 |
-
git commit -m "docs: tighten public deployment safety gate"
|
| 581 |
-
```
|
| 582 |
-
|
| 583 |
-
If script files were not changed, omit them from `git add`.
|
| 584 |
-
|
| 585 |
-
### Task 5: Align Agent API Positioning
|
| 586 |
-
|
| 587 |
-
**Files:**
|
| 588 |
-
- Modify: `README.md`
|
| 589 |
-
- Modify: `skills/gpt-image-playground-agent/SKILL.md`
|
| 590 |
-
- Modify: `skills/gpt-image-playground-agent/references/api.md`
|
| 591 |
-
- Modify: `docs/product/product-contract.md`
|
| 592 |
-
|
| 593 |
-
- [x] **Step 1: Search overclaim wording**
|
| 594 |
-
|
| 595 |
-
Run:
|
| 596 |
-
|
| 597 |
-
```bash
|
| 598 |
-
rg -n "自治|自主|自动完成|Agent 平台|生产级队列|持久队列|长期调度|无需人工" README.md docs skills
|
| 599 |
-
```
|
| 600 |
-
|
| 601 |
-
Expected: identify wording that could exceed current code capabilities.
|
| 602 |
-
|
| 603 |
-
- [x] **Step 2: Replace positioning with automation API boundary**
|
| 604 |
-
|
| 605 |
-
Use this wording where applicable:
|
| 606 |
-
|
| 607 |
-
```markdown
|
| 608 |
-
Agent API 是给 Codex、Claude Code、Gemini 等自动化客户端使用的机器接口。它提供结构化错误、幂等重试、产物追踪和脚本化调用能力,但不是自治 Agent 平台,也不承诺跨实例持久队列或生产级调度。
|
| 609 |
-
```
|
| 610 |
-
|
| 611 |
-
- [x] **Step 3: Keep existing contract details**
|
| 612 |
-
|
| 613 |
-
Do not remove these existing README claims:
|
| 614 |
-
|
| 615 |
-
```markdown
|
| 616 |
-
Agent 请求必须带 `Idempotency-Key`
|
| 617 |
-
`AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token
|
| 618 |
-
Job polling 当前是同一 Next.js 服务实例内的后台任务
|
| 619 |
-
```
|
| 620 |
-
|
| 621 |
-
- [x] **Step 4: Verify capabilities wording**
|
| 622 |
-
|
| 623 |
-
Run:
|
| 624 |
-
|
| 625 |
-
```bash
|
| 626 |
-
rg -n "Agent API 是给|不是自治 Agent 平台|跨实例持久队列|Idempotency-Key|AGENT_API_TOKEN" README.md docs/product skills
|
| 627 |
-
npm run lint:scripts
|
| 628 |
-
git diff --check
|
| 629 |
-
```
|
| 630 |
-
|
| 631 |
-
Expected: wording exists and script lint passes.
|
| 632 |
-
|
| 633 |
-
- [x] **Step 5: Commit task**
|
| 634 |
-
|
| 635 |
-
Run:
|
| 636 |
-
|
| 637 |
-
```bash
|
| 638 |
-
git add README.md docs/product/product-contract.md skills/gpt-image-playground-agent/SKILL.md skills/gpt-image-playground-agent/references/api.md
|
| 639 |
-
git commit -m "docs: clarify agent api product boundary"
|
| 640 |
-
```
|
| 641 |
-
|
| 642 |
-
Expected: commit succeeds.
|
| 643 |
-
|
| 644 |
-
### Task 6: Create Product Gate Review
|
| 645 |
-
|
| 646 |
-
**Files:**
|
| 647 |
-
- Create: `docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md`
|
| 648 |
-
|
| 649 |
-
- [x] **Step 1: Create gate review**
|
| 650 |
-
|
| 651 |
-
Create `docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md`:
|
| 652 |
-
|
| 653 |
-
```markdown
|
| 654 |
-
# Product Stage 1 Gate Review - 2026-06-06
|
| 655 |
-
|
| 656 |
-
## Scope
|
| 657 |
-
|
| 658 |
-
This review verifies the first-stage product improvement boundary: product contract, user validation script, safer sharing defaults, public deployment safety and Agent API positioning.
|
| 659 |
-
|
| 660 |
-
## Evidence
|
| 661 |
-
|
| 662 |
-
| Check | Command | Exit | Result |
|
| 663 |
-
| --- | --- | --- | --- |
|
| 664 |
-
| Tests | `npm test` | | |
|
| 665 |
-
| Lint | `npm run lint` | | |
|
| 666 |
-
| Script lint | `npm run lint:scripts` | | |
|
| 667 |
-
| Build | `npm run build` | | |
|
| 668 |
-
| Diff check | `git diff --check` | | |
|
| 669 |
-
|
| 670 |
-
## Product Contract
|
| 671 |
-
|
| 672 |
-
- First user:
|
| 673 |
-
- Non-goals:
|
| 674 |
-
- Core workflow:
|
| 675 |
-
- Metrics:
|
| 676 |
-
|
| 677 |
-
## Share Safety
|
| 678 |
-
|
| 679 |
-
- Default expiry:
|
| 680 |
-
- No-access-code warning:
|
| 681 |
-
- Server-side protected content behavior:
|
| 682 |
-
|
| 683 |
-
## Public Deployment
|
| 684 |
-
|
| 685 |
-
- APP_PASSWORD gate:
|
| 686 |
-
- AGENT_API_TOKEN gate:
|
| 687 |
-
- Free-tier persistence boundary:
|
| 688 |
-
|
| 689 |
-
## Agent API Boundary
|
| 690 |
-
|
| 691 |
-
- Automation API wording:
|
| 692 |
-
- Non-goals:
|
| 693 |
-
- Existing contract preserved:
|
| 694 |
-
|
| 695 |
-
## Residual Risks
|
| 696 |
-
|
| 697 |
-
- Real customer validation has not been executed until the user-validation script table is populated with actual sessions.
|
| 698 |
-
- Real HF Space gate is not covered unless `npm run deploy:space` and a real browser check are executed separately.
|
| 699 |
-
- Real upstream image generation is not covered unless a billable smoke is explicitly run.
|
| 700 |
-
```
|
| 701 |
-
|
| 702 |
-
- [x] **Step 2: Fill evidence after commands**
|
| 703 |
-
|
| 704 |
-
Run:
|
| 705 |
-
|
| 706 |
-
```bash
|
| 707 |
-
npm test
|
| 708 |
-
npm run lint
|
| 709 |
-
npm run lint:scripts
|
| 710 |
-
npm run build
|
| 711 |
-
git diff --check
|
| 712 |
-
```
|
| 713 |
-
|
| 714 |
-
Fill the evidence table with exit codes and summaries.
|
| 715 |
-
|
| 716 |
-
- [x] **Step 3: Verify review has no blank exit cells**
|
| 717 |
-
|
| 718 |
-
Run:
|
| 719 |
-
|
| 720 |
-
```bash
|
| 721 |
-
rg -n "\\| .* \\| \\|" docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md
|
| 722 |
-
```
|
| 723 |
-
|
| 724 |
-
Expected: no matches.
|
| 725 |
-
|
| 726 |
-
- [x] **Step 4: Commit task**
|
| 727 |
-
|
| 728 |
-
Run:
|
| 729 |
-
|
| 730 |
-
```bash
|
| 731 |
-
git add docs/reviews/CR-PRODUCT-STAGE-1-GATE-2026-06-06.md
|
| 732 |
-
git commit -m "docs: record product stage one gate"
|
| 733 |
-
```
|
| 734 |
-
|
| 735 |
-
Expected: commit succeeds.
|
| 736 |
-
|
| 737 |
-
### Task 7: Final Branch Verification
|
| 738 |
-
|
| 739 |
-
**Files:**
|
| 740 |
-
- No new files expected.
|
| 741 |
-
|
| 742 |
-
- [x] **Step 1: Confirm branch and diff**
|
| 743 |
-
|
| 744 |
-
Run:
|
| 745 |
-
|
| 746 |
-
```bash
|
| 747 |
-
git status --short --branch
|
| 748 |
-
git log --oneline --decorate -8
|
| 749 |
-
git diff --stat main...HEAD
|
| 750 |
-
```
|
| 751 |
-
|
| 752 |
-
Expected: branch is `codex/product-improvement-planning-only`; working tree is clean after commits; diff contains only planned files.
|
| 753 |
-
|
| 754 |
-
- [x] **Step 2: Run final verification**
|
| 755 |
-
|
| 756 |
-
Run:
|
| 757 |
-
|
| 758 |
-
```bash
|
| 759 |
-
npm run verify
|
| 760 |
-
```
|
| 761 |
-
|
| 762 |
-
Expected: JSON output has `"ok": true`.
|
| 763 |
-
|
| 764 |
-
- [x] **Step 3: Record optional public deployment gate decision**
|
| 765 |
-
|
| 766 |
-
Only if user explicitly asks to verify HF Space or deploy:
|
| 767 |
-
|
| 768 |
-
```bash
|
| 769 |
-
npm run deploy:space
|
| 770 |
-
```
|
| 771 |
-
|
| 772 |
-
Expected: Space reaches `RUNNING` and documented public checks pass.
|
| 773 |
-
|
| 774 |
-
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.
|
| 775 |
-
|
| 776 |
-
- [x] **Step 4: Prepare closeout**
|
| 777 |
-
|
| 778 |
-
Final report must include:
|
| 779 |
-
|
| 780 |
-
```markdown
|
| 781 |
-
Summary
|
| 782 |
-
State Estimate / Root Cause
|
| 783 |
-
Changes
|
| 784 |
-
Verification
|
| 785 |
-
Residual Risks / Gate Boundary
|
| 786 |
-
```
|
| 787 |
-
|
| 788 |
-
## Self-Review
|
| 789 |
-
|
| 790 |
-
- Spec coverage: The plan covers positioning, validation, sharing defaults, public deployment safety, Agent API wording, and final evidence.
|
| 791 |
-
- Placeholder scan: No prohibited placeholder markers are present.
|
| 792 |
-
- Type consistency: Planned files and commands match current repo conventions: npm, node:test, Next.js App Router, existing share routes and docs.
|
| 793 |
-
- Scope check: The plan is one implementation stage and does not attempt broader product rebuild, schema changes, analytics service, or production deployment.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-07-16-webui-image-auto-cleanup.md
DELETED
|
@@ -1,464 +0,0 @@
|
|
| 1 |
-
# WebUI Image Auto Cleanup Implementation Plan
|
| 2 |
-
|
| 3 |
-
> **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.
|
| 4 |
-
|
| 5 |
-
**Goal:** 为 WebUI 文件系统图片增加默认关闭、启用后保留 30 天的自动清理,并证明 Agent API 四种请求方式生成的产物不会被提前删除且仍由 Agent TTL 回收。
|
| 6 |
-
|
| 7 |
-
**Architecture:** 新增纯文件清理模块和运行时调度模块。清理器扫描输出目录顶层合法图片,并从 Agent 状态库读取 artifact 路径作为保护集合;启动入口先完成 Agent 回收,再启动默认关闭的 6 小时清理调度。运行能力接口只读取非敏感配置与最近运行摘要。
|
| 8 |
-
|
| 9 |
-
**Tech Stack:** Next.js 16 instrumentation、TypeScript、Node.js `fs/promises`、`node:test`、SQLite、Postgres、Docker Compose。
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## File Map
|
| 14 |
-
|
| 15 |
-
- Create: `src/lib/webui-image-cleanup.ts`,负责严格配置解析、候选筛选、删除和运行结果类型。
|
| 16 |
-
- Create: `src/lib/webui-image-cleanup.test.ts`,覆盖配置、文件边界、Agent 保护和失败结果。
|
| 17 |
-
- Create: `src/lib/webui-image-cleanup-runtime.ts`,负责启动执行、6 小时调度、去重注册和最近状态。
|
| 18 |
-
- Create: `src/lib/webui-image-cleanup-runtime.test.ts`,覆盖默认关闭、立即执行、重复注册与失败观测。
|
| 19 |
-
- Modify: `src/lib/agent-state-store.ts`,增加 artifact 文件路径只读枚举契约。
|
| 20 |
-
- Modify: `src/lib/agent-state-memory.ts`、`src/lib/agent-state-sqlite.ts`、`src/lib/agent-state-postgres.ts`,实现同一枚举契约。
|
| 21 |
-
- Modify: `src/lib/agent-state-memory.test.ts`、`src/lib/agent-state-sqlite.test.ts`、`src/lib/agent-state-postgres.test.ts`,锁定三种后端行为或 SQL 契约。
|
| 22 |
-
- Modify: `src/instrumentation.ts`、`src/lib/agent-state-runtime.test.ts`,接入启动调度并保持启动失败可见。
|
| 23 |
-
- Modify: `src/app/api/runtime-capabilities/route.ts`、`src/app/api/runtime-capabilities/route.test.ts`,暴露非敏感清理状态。
|
| 24 |
-
- Modify: `src/app/api/agent/agent-routes.test.ts`,覆盖四种上游请求方式的 artifact 登记和 TTL 清理契约。
|
| 25 |
-
- Modify: `.env.example`、`README.md`、`CHANGELOG.md`,记录默认关闭、30 天保留和 Agent 隔离规则。
|
| 26 |
-
|
| 27 |
-
### Task 1: Agent Artifact Protection Contract
|
| 28 |
-
|
| 29 |
-
**Files:**
|
| 30 |
-
- Modify: `src/lib/agent-state-store.ts:75`
|
| 31 |
-
- Modify: `src/lib/agent-state-memory.ts:233`
|
| 32 |
-
- Modify: `src/lib/agent-state-sqlite.ts:352`
|
| 33 |
-
- Modify: `src/lib/agent-state-postgres.ts:293`
|
| 34 |
-
- Test: `src/lib/agent-state-memory.test.ts`
|
| 35 |
-
- Test: `src/lib/agent-state-sqlite.test.ts`
|
| 36 |
-
- Test: `src/lib/agent-state-postgres.test.ts`
|
| 37 |
-
|
| 38 |
-
- [ ] **Step 1: Write failing memory and SQLite enumeration tests**
|
| 39 |
-
|
| 40 |
-
Save two artifacts and require sorted unique paths:
|
| 41 |
-
|
| 42 |
-
```ts
|
| 43 |
-
await store.saveArtifacts([
|
| 44 |
-
buildArtifact({ id: 'artifact-b', filepath: path.join(tempDir, 'generated-images', 'b.png') }),
|
| 45 |
-
buildArtifact({ id: 'artifact-a', filepath: path.join(tempDir, 'generated-images', 'a.png') })
|
| 46 |
-
]);
|
| 47 |
-
|
| 48 |
-
assert.deepEqual(await store.listArtifactFilepaths(), [
|
| 49 |
-
path.join(tempDir, 'generated-images', 'a.png'),
|
| 50 |
-
path.join(tempDir, 'generated-images', 'b.png')
|
| 51 |
-
]);
|
| 52 |
-
```
|
| 53 |
-
|
| 54 |
-
- [ ] **Step 2: Add a failing Postgres SQL contract test**
|
| 55 |
-
|
| 56 |
-
```ts
|
| 57 |
-
const source = readFileSync(new URL('./agent-state-postgres.ts', import.meta.url), 'utf8');
|
| 58 |
-
assert.match(source, /SELECT DISTINCT filepath FROM agent_artifacts ORDER BY filepath ASC/);
|
| 59 |
-
```
|
| 60 |
-
|
| 61 |
-
- [ ] **Step 3: Run tests and verify RED**
|
| 62 |
-
|
| 63 |
-
```bash
|
| 64 |
-
npm test -- src/lib/agent-state-memory.test.ts src/lib/agent-state-sqlite.test.ts src/lib/agent-state-postgres.test.ts
|
| 65 |
-
```
|
| 66 |
-
|
| 67 |
-
Expected: FAIL because `listArtifactFilepaths` does not exist.
|
| 68 |
-
|
| 69 |
-
- [ ] **Step 4: Add the interface and three implementations**
|
| 70 |
-
|
| 71 |
-
```ts
|
| 72 |
-
listArtifactFilepaths(): Promise<string[]>;
|
| 73 |
-
```
|
| 74 |
-
|
| 75 |
-
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.
|
| 76 |
-
|
| 77 |
-
- [ ] **Step 5: Run tests and verify GREEN**
|
| 78 |
-
|
| 79 |
-
Run Step 3 again. Expected: selected tests pass; live Postgres cases remain skipped when `AGENT_POSTGRES_TEST_DATABASE_URL` is absent.
|
| 80 |
-
|
| 81 |
-
- [ ] **Step 6: Commit**
|
| 82 |
-
|
| 83 |
-
```bash
|
| 84 |
-
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
|
| 85 |
-
git commit -m "feat(storage): expose protected agent artifact paths"
|
| 86 |
-
```
|
| 87 |
-
|
| 88 |
-
### Task 2: Cleanup Configuration and File Selection
|
| 89 |
-
|
| 90 |
-
**Files:**
|
| 91 |
-
- Create: `src/lib/webui-image-cleanup.ts`
|
| 92 |
-
- Create: `src/lib/webui-image-cleanup.test.ts`
|
| 93 |
-
|
| 94 |
-
- [ ] **Step 1: Write failing configuration tests**
|
| 95 |
-
|
| 96 |
-
```ts
|
| 97 |
-
assert.deepEqual(readWebuiImageCleanupConfig({}), {
|
| 98 |
-
enabled: false,
|
| 99 |
-
retentionDays: 30,
|
| 100 |
-
intervalMs: 6 * 60 * 60 * 1000
|
| 101 |
-
});
|
| 102 |
-
assert.deepEqual(readWebuiImageCleanupConfig({ WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'true' }), {
|
| 103 |
-
enabled: true,
|
| 104 |
-
retentionDays: 30,
|
| 105 |
-
intervalMs: 6 * 60 * 60 * 1000
|
| 106 |
-
});
|
| 107 |
-
assert.throws(
|
| 108 |
-
() => readWebuiImageCleanupConfig({ WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'invalid' }),
|
| 109 |
-
/WEBUI_IMAGE_AUTO_CLEANUP_ENABLED/
|
| 110 |
-
);
|
| 111 |
-
assert.throws(
|
| 112 |
-
() => readWebuiImageCleanupConfig({
|
| 113 |
-
WEBUI_IMAGE_AUTO_CLEANUP_ENABLED: 'true',
|
| 114 |
-
WEBUI_IMAGE_RETENTION_DAYS: '0'
|
| 115 |
-
}),
|
| 116 |
-
/WEBUI_IMAGE_RETENTION_DAYS/
|
| 117 |
-
);
|
| 118 |
-
```
|
| 119 |
-
|
| 120 |
-
- [ ] **Step 2: Run tests and verify RED**
|
| 121 |
-
|
| 122 |
-
```bash
|
| 123 |
-
npm test -- src/lib/webui-image-cleanup.test.ts
|
| 124 |
-
```
|
| 125 |
-
|
| 126 |
-
Expected: FAIL because the module does not exist.
|
| 127 |
-
|
| 128 |
-
- [ ] **Step 3: Implement strict configuration parsing**
|
| 129 |
-
|
| 130 |
-
```ts
|
| 131 |
-
export const WEBUI_IMAGE_DEFAULT_RETENTION_DAYS = 30;
|
| 132 |
-
export const WEBUI_IMAGE_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
| 133 |
-
|
| 134 |
-
export type WebuiImageCleanupConfig = {
|
| 135 |
-
enabled: boolean;
|
| 136 |
-
retentionDays: number;
|
| 137 |
-
intervalMs: number;
|
| 138 |
-
};
|
| 139 |
-
|
| 140 |
-
export function readWebuiImageCleanupConfig(
|
| 141 |
-
env: Record<string, string | undefined>
|
| 142 |
-
): WebuiImageCleanupConfig;
|
| 143 |
-
```
|
| 144 |
-
|
| 145 |
-
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.
|
| 146 |
-
|
| 147 |
-
- [ ] **Step 4: Run configuration tests and verify GREEN**
|
| 148 |
-
|
| 149 |
-
Run Step 2 again. Expected: configuration cases pass.
|
| 150 |
-
|
| 151 |
-
- [ ] **Step 5: Write failing cleanup boundary tests**
|
| 152 |
-
|
| 153 |
-
Create a temporary output directory with:
|
| 154 |
-
|
| 155 |
-
- one old valid WebUI image;
|
| 156 |
-
- one new valid image;
|
| 157 |
-
- one old valid Agent-protected image;
|
| 158 |
-
- one old invalid filename;
|
| 159 |
-
- one old image under `.shares/`;
|
| 160 |
-
- one symbolic link to an old file outside the directory.
|
| 161 |
-
|
| 162 |
-
```ts
|
| 163 |
-
const result = await cleanupExpiredWebuiImages({
|
| 164 |
-
outputDir,
|
| 165 |
-
retentionDays: 30,
|
| 166 |
-
protectedArtifactFilepaths: [protectedPath],
|
| 167 |
-
now: new Date('2026-07-16T00:00:00.000Z')
|
| 168 |
-
});
|
| 169 |
-
```
|
| 170 |
-
|
| 171 |
-
Assert only the old unprotected valid top-level image is deleted and all result counts are exact.
|
| 172 |
-
|
| 173 |
-
- [ ] **Step 6: Verify cleanup tests are RED**
|
| 174 |
-
|
| 175 |
-
```bash
|
| 176 |
-
npm test -- src/lib/webui-image-cleanup.test.ts
|
| 177 |
-
```
|
| 178 |
-
|
| 179 |
-
Expected: FAIL because `cleanupExpiredWebuiImages` is missing.
|
| 180 |
-
|
| 181 |
-
- [ ] **Step 7: Implement minimal cleanup**
|
| 182 |
-
|
| 183 |
-
```ts
|
| 184 |
-
export type WebuiImageCleanupRun = {
|
| 185 |
-
status: 'succeeded' | 'failed';
|
| 186 |
-
startedAt: string;
|
| 187 |
-
completedAt: string;
|
| 188 |
-
cutoffAt: string;
|
| 189 |
-
scannedCount: number;
|
| 190 |
-
protectedCount: number;
|
| 191 |
-
deletedCount: number;
|
| 192 |
-
failedCount: number;
|
| 193 |
-
failures: Array<{ filename: string; message: string }>;
|
| 194 |
-
};
|
| 195 |
-
|
| 196 |
-
export async function cleanupExpiredWebuiImages(input: {
|
| 197 |
-
outputDir: string;
|
| 198 |
-
retentionDays: number;
|
| 199 |
-
protectedArtifactFilepaths: readonly string[];
|
| 200 |
-
now?: Date;
|
| 201 |
-
}): Promise<WebuiImageCleanupRun>;
|
| 202 |
-
```
|
| 203 |
-
|
| 204 |
-
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`.
|
| 205 |
-
|
| 206 |
-
- [ ] **Step 8: Add deterministic deletion-failure coverage and verify GREEN**
|
| 207 |
-
|
| 208 |
-
Inject narrow filesystem operations so one candidate fails while another is deleted. Assert visible failure details and continued processing.
|
| 209 |
-
|
| 210 |
-
```bash
|
| 211 |
-
npm test -- src/lib/webui-image-cleanup.test.ts
|
| 212 |
-
```
|
| 213 |
-
|
| 214 |
-
Expected: all cleanup tests pass.
|
| 215 |
-
|
| 216 |
-
- [ ] **Step 9: Commit**
|
| 217 |
-
|
| 218 |
-
```bash
|
| 219 |
-
git add src/lib/webui-image-cleanup.ts src/lib/webui-image-cleanup.test.ts
|
| 220 |
-
git commit -m "feat(storage): add guarded webui image cleanup"
|
| 221 |
-
```
|
| 222 |
-
|
| 223 |
-
### Task 3: Runtime Scheduling and Capability Observability
|
| 224 |
-
|
| 225 |
-
**Files:**
|
| 226 |
-
- Create: `src/lib/webui-image-cleanup-runtime.ts`
|
| 227 |
-
- Create: `src/lib/webui-image-cleanup-runtime.test.ts`
|
| 228 |
-
- Modify: `src/instrumentation.ts:1`
|
| 229 |
-
- Modify: `src/lib/agent-state-runtime.test.ts`
|
| 230 |
-
- Modify: `src/app/api/runtime-capabilities/route.ts:18`
|
| 231 |
-
- Modify: `src/app/api/runtime-capabilities/route.test.ts:18`
|
| 232 |
-
|
| 233 |
-
- [ ] **Step 1: Write failing scheduler tests**
|
| 234 |
-
|
| 235 |
-
```ts
|
| 236 |
-
const disabled = await startWebuiImageCleanupScheduler({
|
| 237 |
-
env: {},
|
| 238 |
-
runCleanup,
|
| 239 |
-
setInterval: fakeSetInterval,
|
| 240 |
-
logger
|
| 241 |
-
});
|
| 242 |
-
assert.equal(disabled.enabled, false);
|
| 243 |
-
assert.equal(cleanupCalls, 0);
|
| 244 |
-
assert.equal(intervalCalls, 0);
|
| 245 |
-
```
|
| 246 |
-
|
| 247 |
-
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.
|
| 248 |
-
|
| 249 |
-
- [ ] **Step 2: Run tests and verify RED**
|
| 250 |
-
|
| 251 |
-
```bash
|
| 252 |
-
npm test -- src/lib/webui-image-cleanup-runtime.test.ts
|
| 253 |
-
```
|
| 254 |
-
|
| 255 |
-
Expected: FAIL because the runtime module is missing.
|
| 256 |
-
|
| 257 |
-
- [ ] **Step 3: Implement runtime state and scheduling**
|
| 258 |
-
|
| 259 |
-
```ts
|
| 260 |
-
export async function runWebuiImageCleanupNow(
|
| 261 |
-
env?: Record<string, string | undefined>,
|
| 262 |
-
now?: Date
|
| 263 |
-
): Promise<WebuiImageCleanupRun | undefined>;
|
| 264 |
-
|
| 265 |
-
export async function startWebuiImageCleanupScheduler(
|
| 266 |
-
options?: SchedulerOptions
|
| 267 |
-
): Promise<WebuiImageCleanupSummary>;
|
| 268 |
-
export function getWebuiImageCleanupSummary(
|
| 269 |
-
env?: Record<string, string | undefined>
|
| 270 |
-
): WebuiImageCleanupSummary;
|
| 271 |
-
export function resetWebuiImageCleanupRuntimeForTests(): void;
|
| 272 |
-
```
|
| 273 |
-
|
| 274 |
-
`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.
|
| 275 |
-
|
| 276 |
-
- [ ] **Step 4: Run scheduler tests and verify GREEN**
|
| 277 |
-
|
| 278 |
-
Run Step 2 again. Expected: all scheduler cases pass and no referenced timer survives test cleanup.
|
| 279 |
-
|
| 280 |
-
- [ ] **Step 5: Write failing instrumentation tests**
|
| 281 |
-
|
| 282 |
-
Require startup ordering:
|
| 283 |
-
|
| 284 |
-
```ts
|
| 285 |
-
assert.deepEqual(events, ['agent-recovery', 'webui-cleanup-start']);
|
| 286 |
-
```
|
| 287 |
-
|
| 288 |
-
Also assert cleanup startup failures are logged and reject.
|
| 289 |
-
|
| 290 |
-
- [ ] **Step 6: Wire instrumentation and pass tests**
|
| 291 |
-
|
| 292 |
-
Call the cleanup scheduler only after Agent startup recovery succeeds, retaining the `NEXT_RUNTIME === 'nodejs'` guard.
|
| 293 |
-
|
| 294 |
-
```bash
|
| 295 |
-
npm test -- src/lib/agent-state-runtime.test.ts src/lib/webui-image-cleanup-runtime.test.ts
|
| 296 |
-
```
|
| 297 |
-
|
| 298 |
-
Expected: all selected tests pass.
|
| 299 |
-
|
| 300 |
-
- [ ] **Step 7: Write failing capability tests**
|
| 301 |
-
|
| 302 |
-
```ts
|
| 303 |
-
assert.deepEqual(body.webuiImageCleanup, {
|
| 304 |
-
enabled: false,
|
| 305 |
-
retentionDays: 30,
|
| 306 |
-
intervalMs: 21_600_000,
|
| 307 |
-
running: false
|
| 308 |
-
});
|
| 309 |
-
```
|
| 310 |
-
|
| 311 |
-
Add enabled and sanitized recent-run cases.
|
| 312 |
-
|
| 313 |
-
- [ ] **Step 8: Expose capability and pass tests**
|
| 314 |
-
|
| 315 |
-
Add `webuiImageCleanup: getWebuiImageCleanupSummary(process.env)`. Invalid enabled configuration must produce HTTP 500, and summaries must not expose filesystem paths.
|
| 316 |
-
|
| 317 |
-
```bash
|
| 318 |
-
npm test -- src/app/api/runtime-capabilities/route.test.ts src/lib/webui-image-cleanup-runtime.test.ts
|
| 319 |
-
```
|
| 320 |
-
|
| 321 |
-
Expected: all selected tests pass.
|
| 322 |
-
|
| 323 |
-
- [ ] **Step 9: Commit**
|
| 324 |
-
|
| 325 |
-
```bash
|
| 326 |
-
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
|
| 327 |
-
git commit -m "feat(runtime): schedule webui image retention cleanup"
|
| 328 |
-
```
|
| 329 |
-
|
| 330 |
-
### Task 4: Four Agent Request Mode Lifecycle Matrix
|
| 331 |
-
|
| 332 |
-
**Files:**
|
| 333 |
-
- Modify: `src/app/api/agent/agent-routes.test.ts`
|
| 334 |
-
- Test: `src/lib/webui-image-cleanup.test.ts`
|
| 335 |
-
|
| 336 |
-
- [ ] **Step 1: Add a four-mode persistence matrix**
|
| 337 |
-
|
| 338 |
-
```ts
|
| 339 |
-
const modes = [
|
| 340 |
-
'images-non-stream',
|
| 341 |
-
'images-sse',
|
| 342 |
-
'responses-non-stream',
|
| 343 |
-
'responses-sse'
|
| 344 |
-
] as const;
|
| 345 |
-
```
|
| 346 |
-
|
| 347 |
-
Use local Images JSON, Images SSE, Responses JSON, and Responses SSE fixtures. For each mode assert:
|
| 348 |
-
|
| 349 |
-
```ts
|
| 350 |
-
assert.equal(response.status, 200);
|
| 351 |
-
assert.equal(body.execution.channel_request_mode, mode);
|
| 352 |
-
const artifactPath = readStoredArtifactFilepath(artifactId);
|
| 353 |
-
await access(artifactPath);
|
| 354 |
-
assert.ok((await store.listArtifactFilepaths()).includes(artifactPath));
|
| 355 |
-
```
|
| 356 |
-
|
| 357 |
-
- [ ] **Step 2: Run matrix and verify RED for missing coverage**
|
| 358 |
-
|
| 359 |
-
```bash
|
| 360 |
-
npm test -- --test-name-pattern="registers cleanup-managed artifacts for every request mode" src/app/api/agent/agent-routes.test.ts
|
| 361 |
-
```
|
| 362 |
-
|
| 363 |
-
Expected before fixtures are complete: FAIL for any mode that does not produce or register an artifact, especially Responses non-stream.
|
| 364 |
-
|
| 365 |
-
- [ ] **Step 3: Complete only the failing fixture or production path**
|
| 366 |
-
|
| 367 |
-
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.
|
| 368 |
-
|
| 369 |
-
- [ ] **Step 4: Add protection and Agent TTL assertions**
|
| 370 |
-
|
| 371 |
-
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.
|
| 372 |
-
|
| 373 |
-
- [ ] **Step 5: Run matrix and verify GREEN**
|
| 374 |
-
|
| 375 |
-
Run Step 2 again. Expected: four subcases pass using their exact selected modes.
|
| 376 |
-
|
| 377 |
-
- [ ] **Step 6: Commit**
|
| 378 |
-
|
| 379 |
-
```bash
|
| 380 |
-
git add src/app/api/agent/agent-routes.test.ts src/lib/webui-image-cleanup.test.ts
|
| 381 |
-
git commit -m "test(agent): cover image cleanup across request modes"
|
| 382 |
-
```
|
| 383 |
-
|
| 384 |
-
### Task 5: Documentation, Full Gates, and Docker Verification
|
| 385 |
-
|
| 386 |
-
**Files:**
|
| 387 |
-
- Modify: `.env.example`
|
| 388 |
-
- Modify: `README.md`
|
| 389 |
-
- Modify: `CHANGELOG.md`
|
| 390 |
-
|
| 391 |
-
- [ ] **Step 1: Document configuration**
|
| 392 |
-
|
| 393 |
-
```dotenv
|
| 394 |
-
# WebUI 文件图片自动清理默认关闭。启用后默认保留 30 天。
|
| 395 |
-
# WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=false
|
| 396 |
-
# WEBUI_IMAGE_RETENTION_DAYS=30
|
| 397 |
-
```
|
| 398 |
-
|
| 399 |
-
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.
|
| 400 |
-
|
| 401 |
-
- [ ] **Step 2: Format and check the diff**
|
| 402 |
-
|
| 403 |
-
```bash
|
| 404 |
-
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"
|
| 405 |
-
git diff --check
|
| 406 |
-
```
|
| 407 |
-
|
| 408 |
-
Expected: exit 0.
|
| 409 |
-
|
| 410 |
-
- [ ] **Step 3: Run full verification**
|
| 411 |
-
|
| 412 |
-
```bash
|
| 413 |
-
npm run verify
|
| 414 |
-
npx tsc --noEmit
|
| 415 |
-
```
|
| 416 |
-
|
| 417 |
-
Expected: all tests, lint, script syntax, build, diff, and TypeScript checks pass.
|
| 418 |
-
|
| 419 |
-
- [ ] **Step 4: Commit docs**
|
| 420 |
-
|
| 421 |
-
```bash
|
| 422 |
-
git add .env.example README.md CHANGELOG.md
|
| 423 |
-
git commit -m "docs: document webui image retention controls"
|
| 424 |
-
```
|
| 425 |
-
|
| 426 |
-
- [ ] **Step 5: Rebuild Docker**
|
| 427 |
-
|
| 428 |
-
```bash
|
| 429 |
-
docker compose up -d --build
|
| 430 |
-
docker compose ps
|
| 431 |
-
```
|
| 432 |
-
|
| 433 |
-
Expected: `gpt-image-playground-customer` is `Up` on port `4783` with zero restarts.
|
| 434 |
-
|
| 435 |
-
- [ ] **Step 6: Verify default-off live HTTP**
|
| 436 |
-
|
| 437 |
-
```bash
|
| 438 |
-
curl -fsS http://127.0.0.1:4783/api/runtime-capabilities | jq '.webuiImageCleanup'
|
| 439 |
-
```
|
| 440 |
-
|
| 441 |
-
Expected:
|
| 442 |
-
|
| 443 |
-
```json
|
| 444 |
-
{
|
| 445 |
-
"enabled": false,
|
| 446 |
-
"retentionDays": 30,
|
| 447 |
-
"intervalMs": 21600000,
|
| 448 |
-
"running": false
|
| 449 |
-
}
|
| 450 |
-
```
|
| 451 |
-
|
| 452 |
-
- [ ] **Step 7: Verify enabled cleanup in isolation**
|
| 453 |
-
|
| 454 |
-
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.
|
| 455 |
-
|
| 456 |
-
- [ ] **Step 8: Final audit**
|
| 457 |
-
|
| 458 |
-
```bash
|
| 459 |
-
git status --short --branch
|
| 460 |
-
git log --oneline -8
|
| 461 |
-
docker inspect gpt-image-playground-customer --format 'status={{.State.Status}} restart={{.RestartCount}} image={{.Image}}'
|
| 462 |
-
```
|
| 463 |
-
|
| 464 |
-
Expected: worktree clean, commits are atomic, container is running with zero restarts, and current Docker remains default-off.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/plans/2026-07-17-webui-permanent-image-retention.md
DELETED
|
@@ -1,378 +0,0 @@
|
|
| 1 |
-
# WebUI 永久图片保留 Implementation Plan
|
| 2 |
-
|
| 3 |
-
> **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.
|
| 4 |
-
|
| 5 |
-
**Goal:** 为文件系统 WebUI 图片提供批量永久保存标记,排除自动清理,并让清理摘要在 Docker 独立 Next 工作进程间可见。
|
| 6 |
-
|
| 7 |
-
**Architecture:** 在 generated-images/.webui-state/webui-image-retention.sqlite 建立独立 SQLite 状态库,保存永久文件名和已脱敏清理摘要。自动清理、页面 API 和 React 页面都通过该状态库协作;浏览器只保存临时选择状态。
|
| 8 |
-
|
| 9 |
-
**Tech Stack:** Next.js 16 Route Handlers、React 19、TypeScript、better-sqlite3、node:test、Radix Checkbox、lucide-react、Docker Compose。
|
| 10 |
-
|
| 11 |
-
---
|
| 12 |
-
|
| 13 |
-
## 文件结构
|
| 14 |
-
|
| 15 |
-
- 新建 src/lib/webui-image-retention-store.ts 和 src/lib/webui-image-retention-store.test.ts:永久标记和摘要的唯一持久化边界。
|
| 16 |
-
- 新建 src/app/api/image-retention/route.ts 和 src/app/api/image-retention/route.test.ts:页面 API、鉴权和批量文件校验。
|
| 17 |
-
- 修改 src/lib/webui-image-cleanup-runtime.ts 和测试:持久化清理摘要、合并永久保护路径。
|
| 18 |
-
- 修改 src/app/api/image-delete/route.ts 和新建测试:手动删除同步移除标记。
|
| 19 |
-
- 修改 src/components/history-panel.tsx、测试、src/app/page.tsx、页面回归测试和 src/lib/i18n.tsx:选择模式和批量交互。
|
| 20 |
-
- 修改 README.md、.env.example、CHANGELOG.md:记录永久保存边界。
|
| 21 |
-
|
| 22 |
-
### Task 1: 建立 WebUI SQLite 状态库
|
| 23 |
-
|
| 24 |
-
**Files:**
|
| 25 |
-
- Create: src/lib/webui-image-retention-store.ts
|
| 26 |
-
- Create: src/lib/webui-image-retention-store.test.ts
|
| 27 |
-
|
| 28 |
-
- [ ] **Step 1: 写入失败测试**
|
| 29 |
-
|
| 30 |
-
it('persists batch permanent filenames and cleanup summaries across store instances', async () => {
|
| 31 |
-
const first = new SqliteWebuiImageRetentionStore(dbPath);
|
| 32 |
-
await first.init();
|
| 33 |
-
await first.preserve([firstFilename, secondFilename]);
|
| 34 |
-
await first.writeCleanupStatus({ lastRun: publicRun });
|
| 35 |
-
|
| 36 |
-
const second = new SqliteWebuiImageRetentionStore(dbPath);
|
| 37 |
-
await second.init();
|
| 38 |
-
assert.deepEqual(await second.listPermanentFilenames(), [firstFilename, secondFilename]);
|
| 39 |
-
assert.deepEqual(await second.readCleanupStatus(), { lastRun: publicRun });
|
| 40 |
-
});
|
| 41 |
-
|
| 42 |
-
- [ ] **Step 2: 确认测试失败**
|
| 43 |
-
|
| 44 |
-
Run: npm test -- src/lib/webui-image-retention-store.test.ts
|
| 45 |
-
|
| 46 |
-
Expected: FAIL,提示状态库模块或类尚不存在。
|
| 47 |
-
|
| 48 |
-
- [ ] **Step 3: 实现最小存储接口和 SQLite schema**
|
| 49 |
-
|
| 50 |
-
export type WebuiImageRetentionAction = 'preserve' | 'release';
|
| 51 |
-
|
| 52 |
-
export type PersistedWebuiImageCleanupStatus = {
|
| 53 |
-
lastRun?: PublicWebuiImageCleanupRun;
|
| 54 |
-
lastError?: string;
|
| 55 |
-
};
|
| 56 |
-
|
| 57 |
-
export class SqliteWebuiImageRetentionStore {
|
| 58 |
-
constructor(private readonly dbPath: string) {}
|
| 59 |
-
|
| 60 |
-
async init(): Promise<void> {
|
| 61 |
-
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
|
| 62 |
-
this.db = new Database(this.dbPath);
|
| 63 |
-
this.db.pragma('journal_mode = WAL');
|
| 64 |
-
this.db.pragma('busy_timeout = 5000');
|
| 65 |
-
this.db.exec(WEBUI_IMAGE_RETENTION_SCHEMA);
|
| 66 |
-
}
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
创建 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。
|
| 70 |
-
|
| 71 |
-
- [ ] **Step 4: 扩展状态库测试**
|
| 72 |
-
|
| 73 |
-
it('releases stale markers without requiring the image file to exist', async () => {
|
| 74 |
-
await store.preserve([firstFilename]);
|
| 75 |
-
await store.release([firstFilename]);
|
| 76 |
-
assert.deepEqual(await store.listPermanentFilenames(), []);
|
| 77 |
-
});
|
| 78 |
-
|
| 79 |
-
it('does not persist failure filenames or absolute paths in cleanup status', async () => {
|
| 80 |
-
await store.writeCleanupStatus({ lastRun: publicRun });
|
| 81 |
-
assert.equal(JSON.stringify(await store.readCleanupStatus()).includes('/private/generated-images'), false);
|
| 82 |
-
});
|
| 83 |
-
|
| 84 |
-
- [ ] **Step 5: 验证并提交**
|
| 85 |
-
|
| 86 |
-
Run: npm test -- src/lib/webui-image-retention-store.test.ts && npx tsc --noEmit
|
| 87 |
-
|
| 88 |
-
Expected: PASS.
|
| 89 |
-
|
| 90 |
-
git add src/lib/webui-image-retention-store.ts src/lib/webui-image-retention-store.test.ts
|
| 91 |
-
git commit -m "feat(storage): add webui image retention state"
|
| 92 |
-
|
| 93 |
-
### Task 2: 持久化清理摘要并接入永久保护集合
|
| 94 |
-
|
| 95 |
-
**Files:**
|
| 96 |
-
- Modify: src/lib/webui-image-cleanup-runtime.ts
|
| 97 |
-
- Modify: src/lib/webui-image-cleanup-runtime.test.ts
|
| 98 |
-
- Modify: src/app/api/runtime-capabilities/route.ts
|
| 99 |
-
- Modify: src/app/api/runtime-capabilities/route.test.ts
|
| 100 |
-
|
| 101 |
-
- [ ] **Step 1: 运行现有跨进程摘要 RED 用例**
|
| 102 |
-
|
| 103 |
-
Run: npm test -- src/lib/webui-image-cleanup-runtime.test.ts --test-name-pattern='reads a persisted cleanup summary'
|
| 104 |
-
|
| 105 |
-
Expected: FAIL,summary.lastRun 为 undefined;该测试已经在工作树中,禁止先修改生产实现。
|
| 106 |
-
|
| 107 |
-
- [ ] **Step 2: 将公开摘要写入状态库**
|
| 108 |
-
|
| 109 |
-
const retentionStore = await getWebuiImageRetentionStore(env);
|
| 110 |
-
await retentionStore.writeCleanupStatus({ lastRun: toPublicRun(result) });
|
| 111 |
-
|
| 112 |
-
const persisted = await readWebuiImageCleanupStatus(env);
|
| 113 |
-
return {
|
| 114 |
-
enabled: config.enabled,
|
| 115 |
-
retentionDays: config.retentionDays,
|
| 116 |
-
intervalMs: config.intervalMs,
|
| 117 |
-
running,
|
| 118 |
-
...(lastRun ?? persisted.lastRun ? { lastRun: lastRun ?? persisted.lastRun } : {}),
|
| 119 |
-
...(lastError ?? persisted.lastError ? { lastError: lastError ?? persisted.lastError } : {})
|
| 120 |
-
};
|
| 121 |
-
|
| 122 |
-
将 getWebuiImageCleanupSummary 改为 Promise<WebuiImageCleanupSummary>,并在 runtime-capabilities 路由中 await 它。成功后写入 lastRun;清理异常或 timer 注册失败时写入固定 CLEANUP_FAILURE_MESSAGE,并保留上次成功 lastRun。读写状态库失败必须抛出,不能静默丢失摘要。
|
| 123 |
-
|
| 124 |
-
- [ ] **Step 3: 合并 Agent 与永久保存保护路径**
|
| 125 |
-
|
| 126 |
-
const permanentFilenames = await retentionStore.listPermanentFilenames();
|
| 127 |
-
const permanentFilepaths = permanentFilenames.map((filename) => path.join(resolveImageOutputDir(env), filename));
|
| 128 |
-
const agentFilepaths = await agentStore.listArtifactFilepaths();
|
| 129 |
-
return cleanupExpiredWebuiImages({
|
| 130 |
-
outputDir: resolveImageOutputDir(env),
|
| 131 |
-
retentionDays,
|
| 132 |
-
protectedArtifactFilepaths: [...agentFilepaths, ...permanentFilepaths],
|
| 133 |
-
now
|
| 134 |
-
});
|
| 135 |
-
|
| 136 |
-
- [ ] **Step 4: 补测试并验证**
|
| 137 |
-
|
| 138 |
-
it('keeps permanently saved files in the cleanup protection set', async () => {
|
| 139 |
-
await retentionStore.preserve([oldFilename]);
|
| 140 |
-
const result = await runWebuiImageCleanupNow(env, now);
|
| 141 |
-
assert.equal(result?.protectedCount, 1);
|
| 142 |
-
await access(path.join(outputDir, oldFilename));
|
| 143 |
-
});
|
| 144 |
-
|
| 145 |
-
Run: npm test -- src/lib/webui-image-cleanup-runtime.test.ts src/app/api/runtime-capabilities/route.test.ts
|
| 146 |
-
|
| 147 |
-
Expected: PASS,摘要中没有 failures、文件名或绝对路径。
|
| 148 |
-
|
| 149 |
-
- [ ] **Step 5: 提交**
|
| 150 |
-
|
| 151 |
-
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
|
| 152 |
-
git commit -m "fix(runtime): persist webui cleanup summaries"
|
| 153 |
-
|
| 154 |
-
### Task 3: 新增永久保存 API 并同步手动删除
|
| 155 |
-
|
| 156 |
-
**Files:**
|
| 157 |
-
- Create: src/app/api/image-retention/route.ts
|
| 158 |
-
- Create: src/app/api/image-retention/route.test.ts
|
| 159 |
-
- Modify: src/app/api/image-delete/route.ts
|
| 160 |
-
- Create: src/app/api/image-delete/route.test.ts
|
| 161 |
-
|
| 162 |
-
- [ ] **Step 1: 写入 API 失败测试**
|
| 163 |
-
|
| 164 |
-
it('preserves valid top-level files in one batch and reports invalid files', async () => {
|
| 165 |
-
const response = await POST(jsonRequest({
|
| 166 |
-
action: 'preserve',
|
| 167 |
-
filenames: [validFilename, '../outside.png', missingFilename]
|
| 168 |
-
}));
|
| 169 |
-
assert.equal(response.status, 207);
|
| 170 |
-
assert.deepEqual((await response.json()).results, [
|
| 171 |
-
{ filename: validFilename, success: true },
|
| 172 |
-
{ filename: '../outside.png', success: false, error: '文件名格式无效。' },
|
| 173 |
-
{ filename: missingFilename, success: false, error: '文件不存在。' }
|
| 174 |
-
]);
|
| 175 |
-
});
|
| 176 |
-
|
| 177 |
-
- [ ] **Step 2: 确认测试失败**
|
| 178 |
-
|
| 179 |
-
Run: npm test -- src/app/api/image-retention/route.test.ts
|
| 180 |
-
|
| 181 |
-
Expected: FAIL,因为 API 路由尚不存在。
|
| 182 |
-
|
| 183 |
-
- [ ] **Step 3: 实现 GET、POST、鉴权和路径安全**
|
| 184 |
-
|
| 185 |
-
const MAX_RETENTION_BATCH_SIZE = 100;
|
| 186 |
-
|
| 187 |
-
type RetentionRequestBody = {
|
| 188 |
-
action: 'preserve' | 'release';
|
| 189 |
-
filenames: string[];
|
| 190 |
-
passwordHash?: string;
|
| 191 |
-
};
|
| 192 |
-
|
| 193 |
-
export async function POST(request: NextRequest) {
|
| 194 |
-
const body = await readRetentionRequest(request);
|
| 195 |
-
await assertPageAuthorized(body.passwordHash);
|
| 196 |
-
const results = await applyRetentionRequest(body);
|
| 197 |
-
return NextResponse.json({ results }, { status: results.every((item) => item.success) ? 200 : 207 });
|
| 198 |
-
}
|
| 199 |
-
|
| 200 |
-
复制 image-delete 的 APP_PASSWORD 哈希校验语义。preserve 必须通过 isValidImageFilename、path.dirname(resolved) === outputDir、lstat().isFile() 和 !lstat().isSymbolicLink();release 可删除不存在文件的旧标记。GET 仅返回 { filenames: string[] }。
|
| 201 |
-
|
| 202 |
-
- [ ] **Step 4: 删除成功后移除标记**
|
| 203 |
-
|
| 204 |
-
await fs.unlink(filepath);
|
| 205 |
-
await retentionStore.remove([filename]);
|
| 206 |
-
deletionResults.push({ filename, success: true });
|
| 207 |
-
|
| 208 |
-
标记移除失败必须使该文件的删除结果显式失败,避免前端误报完全成功。
|
| 209 |
-
|
| 210 |
-
- [ ] **Step 5: 验证并提交**
|
| 211 |
-
|
| 212 |
-
Run: npm test -- src/app/api/image-retention/route.test.ts src/app/api/image-delete/route.test.ts
|
| 213 |
-
|
| 214 |
-
Expected: PASS,覆盖未授权、100 张上限、部分成功、符号链接、release 缺失文件和删除同步。
|
| 215 |
-
|
| 216 |
-
git add src/app/api/image-retention src/app/api/image-delete/route.ts src/app/api/image-delete/route.test.ts
|
| 217 |
-
git commit -m "feat(api): manage permanent webui image retention"
|
| 218 |
-
|
| 219 |
-
### Task 4: 在历史面板实现选择模式和批量动作
|
| 220 |
-
|
| 221 |
-
**Files:**
|
| 222 |
-
- Modify: src/components/history-panel.tsx
|
| 223 |
-
- Modify: src/components/history-panel.test.tsx
|
| 224 |
-
- Modify: src/lib/i18n.tsx
|
| 225 |
-
|
| 226 |
-
- [ ] **Step 1: 写入 HistoryPanel 失败测试**
|
| 227 |
-
|
| 228 |
-
it('renders permanent-save selection controls only for fs history when cleanup is enabled', () => {
|
| 229 |
-
const html = renderHistoryPanel([fsHistoryItem, indexedDbHistoryItem], [], [], false, {
|
| 230 |
-
cleanupEnabled: true,
|
| 231 |
-
permanentlySavedFilenames: new Set([fsHistoryItem.images[0].filename])
|
| 232 |
-
});
|
| 233 |
-
assert.match(html, /aria-label="选择最近生成图片"/);
|
| 234 |
-
assert.match(html, /aria-label="已永久保存"/);
|
| 235 |
-
});
|
| 236 |
-
|
| 237 |
-
- [ ] **Step 2: 确认测试失败**
|
| 238 |
-
|
| 239 |
-
Run: npm test -- src/components/history-panel.test.tsx --test-name-pattern='permanent-save selection'
|
| 240 |
-
|
| 241 |
-
Expected: FAIL,因为 HistoryPanelProps 尚无清理状态与永久保存集合。
|
| 242 |
-
|
| 243 |
-
- [ ] **Step 3: 增加受控 props 和局部选择状态**
|
| 244 |
-
|
| 245 |
-
type HistoryPanelProps = {
|
| 246 |
-
cleanupEnabled?: boolean;
|
| 247 |
-
permanentlySavedFilenames?: ReadonlySet<string>;
|
| 248 |
-
onUpdatePermanentSave?: (action: 'preserve' | 'release', filenames: string[]) => Promise<void>;
|
| 249 |
-
};
|
| 250 |
-
|
| 251 |
-
const [isSelectingRetention, setIsSelectingRetention] = React.useState(false);
|
| 252 |
-
const [selectedRetentionFilenames, setSelectedRetentionFilenames] = React.useState<Set<string>>(() => new Set());
|
| 253 |
-
|
| 254 |
-
只收集 storageModeUsed === 'fs' 的单个图片文件名;失败项和 IndexedDB 项不提供勾选。进入选择模式后显示 Checkbox;已永久保存图显示 Bookmark 标记;切换 Tab、退出选择模式和成功操作后清空选择。
|
| 255 |
-
|
| 256 |
-
- [ ] **Step 4: 实现固定批量栏和国际化文案**
|
| 257 |
-
|
| 258 |
-
{isSelectingRetention ? (
|
| 259 |
-
<div className='border-border bg-background sticky bottom-0 z-20 flex min-h-14 items-center gap-2 border-t px-3 py-2'>
|
| 260 |
-
<span className='text-muted-foreground mr-auto text-xs'>{t('retention.selectedCount', { count })}</span>
|
| 261 |
-
<Button disabled={count === 0 || isUpdatingRetention} onClick={() => void submitRetention('preserve')}>
|
| 262 |
-
<Bookmark className='h-3.5 w-3.5' />
|
| 263 |
-
{t('retention.preserve')}
|
| 264 |
-
</Button>
|
| 265 |
-
<Button variant='outline' disabled={count === 0 || isUpdatingRetention} onClick={() => void submitRetention('release')}>
|
| 266 |
-
{t('retention.release')}
|
| 267 |
-
</Button>
|
| 268 |
-
</div>
|
| 269 |
-
) : null}
|
| 270 |
-
|
| 271 |
-
新增 retention.select、retention.exitSelection、retention.preserve、retention.release、retention.permanentlySaved、retention.selectedCount、retention.hint、retention.updateFailed 的中英文翻译。
|
| 272 |
-
|
| 273 |
-
- [ ] **Step 5: 验证并提交**
|
| 274 |
-
|
| 275 |
-
Run: npm test -- src/components/history-panel.test.tsx
|
| 276 |
-
|
| 277 |
-
Expected: PASS,覆盖禁用时隐藏入口、fs 与 indexeddb 分流、多图历史、已保存标记、选择数和移动端底栏类名。
|
| 278 |
-
|
| 279 |
-
git add src/components/history-panel.tsx src/components/history-panel.test.tsx src/lib/i18n.tsx
|
| 280 |
-
git commit -m "feat(ui): batch permanent image retention controls"
|
| 281 |
-
|
| 282 |
-
### Task 5: 在页面协调运行时能力与永久保存 API
|
| 283 |
-
|
| 284 |
-
**Files:**
|
| 285 |
-
- Modify: src/app/page.tsx
|
| 286 |
-
- Modify: src/app/page-regressions.test.tsx
|
| 287 |
-
|
| 288 |
-
- [ ] **Step 1: 写入页面失败契约测试**
|
| 289 |
-
|
| 290 |
-
it('loads permanent filenames only when fs cleanup is enabled and forwards batch actions to HistoryPanel', async () => {
|
| 291 |
-
const source = await readFile(new URL('./page.tsx', import.meta.url), 'utf8');
|
| 292 |
-
assert.match(source, /fetch\('\/api\/image-retention'\)/);
|
| 293 |
-
assert.match(source, /onUpdatePermanentSave=/);
|
| 294 |
-
assert.match(source, /webuiImageCleanup\?\.enabled/);
|
| 295 |
-
});
|
| 296 |
-
|
| 297 |
-
- [ ] **Step 2: 确认测试失败**
|
| 298 |
-
|
| 299 |
-
Run: npm test -- src/app/page-regressions.test.tsx --test-name-pattern='permanent filenames'
|
| 300 |
-
|
| 301 |
-
Expected: FAIL,因为页面尚未读取永久保存状态。
|
| 302 |
-
|
| 303 |
-
- [ ] **Step 3: 实现加载与批量回调**
|
| 304 |
-
|
| 305 |
-
const [permanentlySavedFilenames, setPermanentlySavedFilenames] = React.useState<Set<string>>(() => new Set());
|
| 306 |
-
const cleanupEnabled = runtimeCapabilities?.webuiImageCleanup?.enabled === true;
|
| 307 |
-
|
| 308 |
-
const updatePermanentSave = React.useCallback(async (action: 'preserve' | 'release', filenames: string[]) => {
|
| 309 |
-
const response = await fetch('/api/image-retention', {
|
| 310 |
-
method: 'POST',
|
| 311 |
-
headers: { 'Content-Type': 'application/json' },
|
| 312 |
-
body: JSON.stringify({ action, filenames, ...(clientPasswordHash ? { passwordHash: clientPasswordHash } : {}) })
|
| 313 |
-
});
|
| 314 |
-
const body = await response.json();
|
| 315 |
-
if (!response.ok && response.status !== 207) throw new Error(body.error || t('retention.updateFailed'));
|
| 316 |
-
setPermanentlySavedFilenames((current) => mergeRetentionResults(current, action, body.results));
|
| 317 |
-
if (body.results.some((result: { success: boolean }) => !result.success)) throw new Error(t('retention.partialUpdateFailed'));
|
| 318 |
-
}, [clientPasswordHash, t]);
|
| 319 |
-
|
| 320 |
-
仅在 cleanupEnabled 且存在 fs 历史时请求 GET;使用 AbortController 防止卸载后更新状态。成功手动删除图片后也从本地集合移除对应文件名。
|
| 321 |
-
|
| 322 |
-
- [ ] **Step 4: 验证并提交**
|
| 323 |
-
|
| 324 |
-
Run: npm test -- src/app/page-regressions.test.tsx src/components/history-panel.test.tsx
|
| 325 |
-
|
| 326 |
-
Expected: PASS。
|
| 327 |
-
|
| 328 |
-
git add src/app/page.tsx src/app/page-regressions.test.tsx
|
| 329 |
-
git commit -m "feat(page): sync permanent image retention state"
|
| 330 |
-
|
| 331 |
-
### Task 6: 文档、全量门禁与 Docker 实测
|
| 332 |
-
|
| 333 |
-
**Files:**
|
| 334 |
-
- Modify: .env.example
|
| 335 |
-
- Modify: README.md
|
| 336 |
-
- Modify: CHANGELOG.md
|
| 337 |
-
- Modify: docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md
|
| 338 |
-
|
| 339 |
-
- [ ] **Step 1: 补齐文档**
|
| 340 |
-
|
| 341 |
-
README 说明永久保存仅在启用 WebUI 自动清理且使用文件系统存储时可用;用户可在“最近生成”批量设置或取消;手动删除同步移除标记;Agent artifact 生命周期不受影响。.env.example 保持自动清理默认关闭和 30 天默认值,不新增永久保存开关。
|
| 342 |
-
|
| 343 |
-
- [ ] **Step 2: 运行定向验证**
|
| 344 |
-
|
| 345 |
-
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
|
| 346 |
-
|
| 347 |
-
Run: npx tsc --noEmit
|
| 348 |
-
|
| 349 |
-
Run: npm run lint
|
| 350 |
-
|
| 351 |
-
Run: npm run lint:scripts
|
| 352 |
-
|
| 353 |
-
Run: git diff --check
|
| 354 |
-
|
| 355 |
-
Expected: 全部 PASS。
|
| 356 |
-
|
| 357 |
-
- [ ] **Step 3: 运行完整门禁**
|
| 358 |
-
|
| 359 |
-
Run: npm run verify
|
| 360 |
-
|
| 361 |
-
Expected: 输出 ok: true,测试、lint、脚本检查、生产构建和 diff 检查全部通过。
|
| 362 |
-
|
| 363 |
-
- [ ] **Step 4: Docker 隔离验证**
|
| 364 |
-
|
| 365 |
-
在临时输出目录启动隔离容器,创建一张 31 天前的普通图片和一张 31 天前的永久保存图片,重启后执行:
|
| 366 |
-
|
| 367 |
-
curl -fsS http://127.0.0.1:4783/api/runtime-capabilities | jq '.webuiImageCleanup.lastRun'
|
| 368 |
-
curl -fsS http://127.0.0.1:4783/api/image-retention
|
| 369 |
-
|
| 370 |
-
Expected: 普通图片已删除,永久保存图片仍存在,lastRun.deletedCount 与 protectedCount 可跨工作进程读取,响应不包含绝对路径或文件名。
|
| 371 |
-
|
| 372 |
-
- [ ] **Step 5: 提交文档收尾**
|
| 373 |
-
|
| 374 |
-
git add .env.example README.md CHANGELOG.md docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md
|
| 375 |
-
git commit -m "docs: document permanent webui image retention"
|
| 376 |
-
git status --short --branch
|
| 377 |
-
|
| 378 |
-
Expected: 只保留本功能的原子提交;不提交 generated-images、.webui-state、日志、截图或临时 Docker 文件。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/specs/2026-07-16-webui-image-auto-cleanup-design.md
DELETED
|
@@ -1,87 +0,0 @@
|
|
| 1 |
-
# WebUI 图片自动清理设计
|
| 2 |
-
|
| 3 |
-
## 背景
|
| 4 |
-
|
| 5 |
-
当前文件系统存储模式会将 WebUI 与 Agent API 生成的图片写入同一个 `generated-images/` 顶层目录。WebUI 图片没有保留期限,只能通过历史记录中的显式删除操作或人工清理目录删除。Agent API 产物由状态库记录,并按 `AGENT_REQUEST_TTL_SECONDS` 独立回收。
|
| 6 |
-
|
| 7 |
-
本功能为 WebUI 图片增加可选的自动清理能力。自动清理默认关闭,启用后默认保留 30 天,并且不能提前删除仍受 Agent TTL 管理的产物。
|
| 8 |
-
|
| 9 |
-
## 配置契约
|
| 10 |
-
|
| 11 |
-
新增以下服务端环境变量:
|
| 12 |
-
|
| 13 |
-
```dotenv
|
| 14 |
-
WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=false
|
| 15 |
-
WEBUI_IMAGE_RETENTION_DAYS=30
|
| 16 |
-
```
|
| 17 |
-
|
| 18 |
-
- `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED` 默认值为 `false`。
|
| 19 |
-
- `WEBUI_IMAGE_RETENTION_DAYS` 默认值为 `30`,启用清理时必须是正整数。
|
| 20 |
-
- 清理周期固定为 6 小时,服务启动时先执行一次。
|
| 21 |
-
- 配置只在服务端生效,不在浏览器设置中提供全局破坏性开关。
|
| 22 |
-
- 启用状态、保留天数、清理周期和最近一次清理结果通过运行能力接口暴露。
|
| 23 |
-
|
| 24 |
-
## 文件分类与清理边界
|
| 25 |
-
|
| 26 |
-
清理器只检查 `generated-images/` 顶层目录中的普通文件,并要求文件名符合现有图片文件名校验规则。以下内容始终排除:
|
| 27 |
-
|
| 28 |
-
- Agent 状态库中仍存在 artifact 记录的文件。
|
| 29 |
-
- `.agent-state/`、`.shares/`、`.app-logs/` 及其他子目录。
|
| 30 |
-
- 符号链接、目录、非法文件名和非图片文件。
|
| 31 |
-
- 修改时间未超过保留期限的文件。
|
| 32 |
-
|
| 33 |
-
清理顺序如下:
|
| 34 |
-
|
| 35 |
-
1. 初始化 Agent 状态库并执行现有过期请求回收。
|
| 36 |
-
2. 从 Agent 状态库读取仍存在的 artifact 文件路径,构建保护集合。
|
| 37 |
-
3. 扫描输出目录顶层文件。
|
| 38 |
-
4. 删除不在保护集合内且修改时间早于截止时间的合法图片文件。
|
| 39 |
-
5. 记录扫描数、保护数、删除数、失败数、开始时间和完成时间。
|
| 40 |
-
|
| 41 |
-
现有未登记到 Agent 状态库的历史顶层图片按 WebUI 图片处理。这样可以覆盖当前存量,同时避免在 Agent 配置了超过 30 天 TTL 时提前删除其已登记产物。
|
| 42 |
-
|
| 43 |
-
## Agent API 四种请求方式
|
| 44 |
-
|
| 45 |
-
需要验证以下上游请求方式:
|
| 46 |
-
|
| 47 |
-
- `images-non-stream`
|
| 48 |
-
- `images-sse`
|
| 49 |
-
- `responses-non-stream`
|
| 50 |
-
- `responses-sse`
|
| 51 |
-
|
| 52 |
-
请求方式只影响上游传输。成功结果必须统一经过 Agent 图片持久化流程,并在返回响应前保存 artifact 元数据。测试矩阵需要证明每种请求方式满足以下契约:
|
| 53 |
-
|
| 54 |
-
- 成功图片具有 Agent artifact 记录和合法文件路径。
|
| 55 |
-
- WebUI 清理器跳过仍在 Agent 状态库中的文件。
|
| 56 |
-
- Agent 请求超过自身 TTL 后,现有 Agent 回收逻辑删除 artifact 文件和元数据。
|
| 57 |
-
- Agent TTL 超过 WebUI 的 30 天期限时,WebUI 清理器仍不会提前删除该文件。
|
| 58 |
-
|
| 59 |
-
## 运行模型
|
| 60 |
-
|
| 61 |
-
清理调度由 Node 服务启动流程注册,默认关闭时不创建定时器。启用时立即执行一次,并使用可释放的 6 小时定时器执行后续清理。注册过程必须防止同一进程重复创建调度器。
|
| 62 |
-
|
| 63 |
-
单个文件删除失败时继续处理其他候选文件,但必须记录明确错误和失败计数。目录读取、状态库读取或配置校验失败时不得报告清理成功,运行能力接口应保留最近失败结果。
|
| 64 |
-
|
| 65 |
-
## 模块边界
|
| 66 |
-
|
| 67 |
-
- 新增独立的 WebUI 图片清理模块,负责配置解析、候选筛选、Agent 保护和删除结果汇总。
|
| 68 |
-
- Agent 状态存储接口新增只读的 artifact 文件路径枚举能力,并在 memory、sqlite、postgres 三种后端保持一致。
|
| 69 |
-
- 启动 instrumentation 负责初始化和调度,不承载文件筛选逻辑。
|
| 70 |
-
- 运行能力接口只序列化清理状态,不直接触发清理。
|
| 71 |
-
|
| 72 |
-
## 测试与验收
|
| 73 |
-
|
| 74 |
-
实现遵循测试先行,至少覆盖:
|
| 75 |
-
|
| 76 |
-
- 默认关闭且不扫描、不删除文件。
|
| 77 |
-
- 启用后默认保留 30 天。
|
| 78 |
-
- 非法布尔值、非正整数保留天数显式失败。
|
| 79 |
-
- 只删除超过期限且未受保护的顶层合法图片。
|
| 80 |
-
- 不删除新图片、Agent artifact、子目录内容、符号链接和非法文件名。
|
| 81 |
-
- 部分删除失败时返回失败计数并继续处理其他文件。
|
| 82 |
-
- 三种 Agent 状态后端能够枚举 artifact 文件路径。
|
| 83 |
-
- 四种 Agent 请求方式生成的 artifact 均受到保护并最终由 Agent TTL 回收。
|
| 84 |
-
- 启动调度默认关闭,启用后立即执行且避免重复注册。
|
| 85 |
-
- 运行能力接口返回实际开关、30 天保留期和最近清理结果。
|
| 86 |
-
|
| 87 |
-
完成后执行相关定向测试,再执行 `npm run verify`、`npx tsc --noEmit` 和 Docker 重建后的真实 HTTP 验证。Docker 当前配置保持默认关闭。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/superpowers/specs/2026-07-17-webui-permanent-image-retention-design.md
DELETED
|
@@ -1,91 +0,0 @@
|
|
| 1 |
-
# WebUI 永久图片保留设计
|
| 2 |
-
|
| 3 |
-
日期:2026-07-17
|
| 4 |
-
|
| 5 |
-
## 目标
|
| 6 |
-
|
| 7 |
-
当 `WEBUI_IMAGE_AUTO_CLEANUP_ENABLED=true` 时,用户可以在 WebUI 的“最近生成”面板中选择一张或多张文件系统图片,将其标记为永久保存。永久保存的图片不会被 WebUI 自动清理;用户手动删除图片或取消永久保存后,标记失效。
|
| 8 |
-
|
| 9 |
-
同时,自动清理的最近执行摘要必须能在 Docker 的独立 Next 工作进程之间读取,使 `/api/runtime-capabilities` 返回实际执行结果。
|
| 10 |
-
|
| 11 |
-
## 范围与非目标
|
| 12 |
-
|
| 13 |
-
- 仅处理 `generated-images/` 中由 WebUI 使用文件系统存储的有效图片文件。
|
| 14 |
-
- `indexeddb` 图片不进入服务端清理和永久保存 API。
|
| 15 |
-
- Agent API artifact 继续由 `AGENT_REQUEST_TTL_SECONDS` 管理;WebUI 永久保存不改变 Agent artifact 的 TTL 或删除语义。
|
| 16 |
-
- “永久”仅指永久排除 WebUI 自动清理,不是额外复制、跨机器备份或对手动删除的保护。
|
| 17 |
-
- 手动删除图片时必须同步删除永久保存标记。
|
| 18 |
-
|
| 19 |
-
## 数据模型
|
| 20 |
-
|
| 21 |
-
在挂载的输出目录中创建独立状态库:
|
| 22 |
-
|
| 23 |
-
```text
|
| 24 |
-
generated-images/.webui-state/webui-image-retention.sqlite
|
| 25 |
-
```
|
| 26 |
-
|
| 27 |
-
状态库仅保存文件名、时间和已脱敏的执行摘要,不保存 API Key、提示词、绝对路径或上游响应。
|
| 28 |
-
|
| 29 |
-
表:
|
| 30 |
-
|
| 31 |
-
- `webui_image_retention`
|
| 32 |
-
- `filename`:有效图片文件名,主键。
|
| 33 |
-
- `saved_at`:用户设置永久保存的 ISO 时间。
|
| 34 |
-
- `webui_image_cleanup_status`
|
| 35 |
-
- 单行状态,保存 `last_run_json`、`last_error` 和 `updated_at`。
|
| 36 |
-
|
| 37 |
-
每次批量更新使用 SQLite transaction;启用 WAL 和合理的 busy timeout,避免 Next 多工作进程并发读写时丢失标记。状态库位于隐藏子目录,不属于自动清理扫描范围。
|
| 38 |
-
|
| 39 |
-
## 服务端接口
|
| 40 |
-
|
| 41 |
-
新增页面 API:
|
| 42 |
-
|
| 43 |
-
- `GET /api/image-retention`
|
| 44 |
-
- 返回当前永久保存的文件名集合。
|
| 45 |
-
- `POST /api/image-retention`
|
| 46 |
-
- 请求体:`{ "action": "preserve" | "release", "filenames": string[] }`。
|
| 47 |
-
- 单次最多 100 张,去重后处理。
|
| 48 |
-
- `preserve` 仅接受存在、常规文件、位于输出目录顶层且文件名合法的 WebUI 图片。
|
| 49 |
-
- `release` 可清理有效文件名的已有标记,即使对应文件已不存在。
|
| 50 |
-
- 使用与页面图片删除相同的访问控制。
|
| 51 |
-
- 返回已变更文件名、未变更文件名和被拒绝项的结构化摘要;不返回绝对路径。
|
| 52 |
-
|
| 53 |
-
现有 `POST /api/image-delete` 在文件删除成功后移除对应永久保存标记。若文件已删除或本已不存在,但标记移除失败,结果会明确区分物理文件状态(`fileDeleted` 或 `fileAbsent`)与 `markerRemoved: false`;页面可以收敛不存在的历史图片,但不能把本地永久标记误报为已解除。
|
| 54 |
-
|
| 55 |
-
自动清理运行时读取永久保存文件名,并将它们与仍登记的 Agent artifact 路径合并为保护集合。`protectedCount` 继续表示两类受保护文件总数。同一服务进程内,批量保存、手动删除和自动清理按文件名串行;清理在实际删除前会在锁内重新读取永久标记。
|
| 56 |
-
|
| 57 |
-
## 清理摘要跨进程可见性
|
| 58 |
-
|
| 59 |
-
清理调度启动和运行的工作进程将已脱敏的 `lastRun` 或固定 `lastError` 写入 `webui_image_cleanup_status`。数据库使用稳定失败状态码,读取时映射为固定公开文案,并兼容已写入的旧文案。`getWebuiImageCleanupSummary()` 优先使用本进程内存状态,缺失时从 SQLite 读取。
|
| 60 |
-
|
| 61 |
-
`/api/runtime-capabilities` 因此可在独立请求工作进程中显示最近一次清理的状态、时间和计数,但永远不暴露文件名、绝对路径或删除失败细节。
|
| 62 |
-
|
| 63 |
-
## 前端交互
|
| 64 |
-
|
| 65 |
-
入口位于右侧“最近生成”标题栏,仅当运行时能力声明 WebUI 文件系统自动清理已启用、页面访问控制已完成且当前文件系统输出范围的永久标记已成功读取时显示。普通历史新增、删除或反馈更新不会卸载已确认的标记集合;退出登录、访问范围变化或请求取消时不会复用旧结果。
|
| 66 |
-
|
| 67 |
-
1. 点击“选择”进入选择模式。
|
| 68 |
-
2. 每个可管理的文件系统缩略图显示勾选框;已永久保存图片显示低干扰标记。旧历史缺失 `storageModeUsed` 时按既有兼容语义视为文件系统存储。
|
| 69 |
-
3. 底部固定操作栏显示已选数量,并提供“永久保存”和“取消保存”。
|
| 70 |
-
4. 批量请求完成后,只有服务端确认成功的图片更新标记;失败项保留选择并显示明确错误。
|
| 71 |
-
5. 退出选择模式或切换历史标签时清空临时选择。
|
| 72 |
-
6. 移动端复用同一选择状态和底部操作栏;不在日常浏览状态增加缩略图按钮。
|
| 73 |
-
|
| 74 |
-
历史条目可能包含多张图片,选择粒度是单个图片文件名,而不是整条历史记录。
|
| 75 |
-
|
| 76 |
-
## 错误与边界
|
| 77 |
-
|
| 78 |
-
- 无效、跨目录、符号链接、缺失或非普通文件的永久保存请求显式拒绝。
|
| 79 |
-
- 自动清理只删除顶层、合法、超过保留期且不在永久保存或 Agent artifact 保护集合中的图片。
|
| 80 |
-
- SQLite 不可用、状态格式异常或数据库写入失败必须显式记录并让相应 API 或启动流程失败,不静默丢弃标记。
|
| 81 |
-
- 手动删除、批量保存和自动清理在同一服务进程内按文件名锁定;以锁内的文件存在性检查和事务结果为准,不会为不存在的图片新增永久保存标记。
|
| 82 |
-
|
| 83 |
-
## 验收与测试
|
| 84 |
-
|
| 85 |
-
- 状态库的批量 preserve/release、去重、持久化、并发事务和非法文件名测试。
|
| 86 |
-
- 清理测试覆盖永久保存、Agent artifact、二者重叠、手动删除清理标记和 30 天边界。
|
| 87 |
-
- 页面 API 测试覆盖访问控制、批量上限、部分成功和路径安全。
|
| 88 |
-
- `HistoryPanel` 测试覆盖选择模式、单图选择、批量操作可达性、已永久保存标记和移动端渲染。
|
| 89 |
-
- 页面与客户端契约测试覆盖运行时开关、文件系统默认值、历史变化时选择保持、批量响应的成功项合并、手动删除后的物理文件与标记状态分离,以及畸形 API 响应拒绝。
|
| 90 |
-
- 运行时能力测试覆盖在内存状态重置后仍从 SQLite 读取已脱敏的最近清理摘要。
|
| 91 |
-
- Docker 验证:启动清理摘要可从 `GET /api/runtime-capabilities` 读取;永久保存的过期图片不会被删除;取消保存后按清理规则可删除。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/ui/literary-young-women-workbench-design.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
## 1. 定稿结论
|
| 4 |
|
| 5 |
-
本项目
|
| 6 |
|
| 7 |
- 当前实现名称:`图像手记 / Visual Journal` 工作台。
|
| 8 |
- 定稿图:`/tmp/gipc-literary-young-women-functional-v5.png`
|
|
@@ -13,7 +13,7 @@
|
|
| 13 |
|
| 14 |
产品边界以 `docs/product/product-contract.md` 为准;本文档只定义工作台信息架构和视觉交互基线,不把审美画像当作真实用户证据。首战验证必须落在真实发布任务、可下载结果、继续编辑或复用、以及最近生成里的 `可用` / `需修改` 结果反馈。
|
| 15 |
|
| 16 |
-
本基线明确排除古风、传统器物化、普通
|
| 17 |
|
| 18 |
## 2. 目标用户与气质
|
| 19 |
|
|
@@ -30,7 +30,7 @@
|
|
| 30 |
|
| 31 |
- 古风、国风器物、传统书画、宣纸卷轴、印章、毛笔、砚台、灯笼、宫廷或寺庙感。
|
| 32 |
- 深色科技仪表盘、霓虹、赛博朋克、玻璃拟态、紫色渐变。
|
| 33 |
-
- 企业后台、金融
|
| 34 |
- 低幼粉色、过度贴纸化、装饰大于功能。
|
| 35 |
|
| 36 |
## 3. 视觉系统
|
|
@@ -86,7 +86,7 @@
|
|
| 86 |
- 中心画布最大,操作围绕画布服务。
|
| 87 |
- 左侧信息密度中等,保持创作单的顺序。
|
| 88 |
- 右侧卡片轻量,避免历史列表压迫主画布。
|
| 89 |
-
- 不使用超大营销标题,不做
|
| 90 |
|
| 91 |
### 3.4 圆角与阴影
|
| 92 |
|
|
@@ -170,7 +170,7 @@
|
|
| 170 |
|
| 171 |
模块标题:`写下灵感`
|
| 172 |
|
| 173 |
-
提示词输入区域应像现代笔记本或创作卡片,而不是普通后台
|
| 174 |
|
| 175 |
示例文案方向:
|
| 176 |
|
|
@@ -228,9 +228,9 @@
|
|
| 228 |
|
| 229 |
内容:
|
| 230 |
|
| 231 |
-
- 状态
|
| 232 |
-
- 状态
|
| 233 |
-
- 请求数量
|
| 234 |
- 主按钮:`生成图像`
|
| 235 |
- 次级操作:`存为灵感`
|
| 236 |
- 次级操作:`随便来点`
|
|
@@ -371,7 +371,7 @@
|
|
| 371 |
- 专业模式可展开,但不要一次铺满所有字段。
|
| 372 |
- 工程词在主界面减少暴露,专业模式中可保留必要精确项。
|
| 373 |
- 当前选择会影响输出、请求方式或稳定性时,应显示解释。
|
| 374 |
-
- 当前实现中,桌面端高级设置位于底部
|
| 375 |
- 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。
|
| 376 |
|
| 377 |
## 10. 模式交互
|
|
@@ -492,17 +492,17 @@
|
|
| 492 |
- `省心模式`
|
| 493 |
- `专业模式`
|
| 494 |
|
| 495 |
-
避免文
|
| 496 |
|
| 497 |
-
-
|
| 498 |
-
-
|
| 499 |
-
-
|
| 500 |
-
-
|
| 501 |
-
-
|
| 502 |
-
-
|
| 503 |
-
-
|
| 504 |
|
| 505 |
-
必要技术名可以放入专业模式,
|
| 506 |
|
| 507 |
## 13. 实现注意
|
| 508 |
|
|
|
|
| 2 |
|
| 3 |
## 1. 定稿结论
|
| 4 |
|
| 5 |
+
本项目网页界面(Web UI)后续重构以 `女青年文艺风功能交互稿` 为唯一设计基线。
|
| 6 |
|
| 7 |
- 当前实现名称:`图像手记 / Visual Journal` 工作台。
|
| 8 |
- 定稿图:`/tmp/gipc-literary-young-women-functional-v5.png`
|
|
|
|
| 13 |
|
| 14 |
产品边界以 `docs/product/product-contract.md` 为准;本文档只定义工作台信息架构和视觉交互基线,不把审美画像当作真实用户证据。首战验证必须落在真实发布任务、可下载结果、继续编辑或复用、以及最近生成里的 `可用` / `需修改` 结果反馈。
|
| 15 |
|
| 16 |
+
本基线明确排除古风、传统器物化、普通软件即服务(SaaS)仪表盘和过度 AI 科技风。
|
| 17 |
|
| 18 |
## 2. 目标用户与气质
|
| 19 |
|
|
|
|
| 30 |
|
| 31 |
- 古风、国风器物、传统书画、宣纸卷轴、印章、毛笔、砚台、灯笼、宫廷或寺庙感。
|
| 32 |
- 深色科技仪表盘、霓虹、赛博朋克、玻璃拟态、紫色渐变。
|
| 33 |
+
- 企业后台、金融软件即服务(SaaS)、模板化模块卡片网格。
|
| 34 |
- 低幼粉色、过度贴纸化、装饰大于功能。
|
| 35 |
|
| 36 |
## 3. 视觉系统
|
|
|
|
| 86 |
- 中心画布最大,操作围绕画布服务。
|
| 87 |
- 左侧信息密度中等,保持创作单的顺序。
|
| 88 |
- 右侧卡片轻量,避免历史列表压迫主画布。
|
| 89 |
+
- 不使用超大营销标题,不做宣传落地页。
|
| 90 |
|
| 91 |
### 3.4 圆角与阴影
|
| 92 |
|
|
|
|
| 170 |
|
| 171 |
模块标题:`写下灵感`
|
| 172 |
|
| 173 |
+
提示词输入区域应像现代笔记本或创作卡片,而不是普通后台多行文本框。
|
| 174 |
|
| 175 |
示例文案方向:
|
| 176 |
|
|
|
|
| 228 |
|
| 229 |
内容:
|
| 230 |
|
| 231 |
+
- 状态标签:`gpt-image-2`
|
| 232 |
+
- 状态标签:`自动`
|
| 233 |
+
- 请求数量标签:`请求 1 张图片`
|
| 234 |
- 主按钮:`生成图像`
|
| 235 |
- 次级操作:`存为灵感`
|
| 236 |
- 次级操作:`随便来点`
|
|
|
|
| 371 |
- 专业模式可展开,但不要一次铺满所有字段。
|
| 372 |
- 工程词在主界面减少暴露,专业模式中可保留必要精确项。
|
| 373 |
- 当前选择会影响输出、请求方式或稳定性时,应显示解释。
|
| 374 |
+
- 当前实现中,桌面端高级设置位于底部专业设置栏。`省心模式` 展示模型、流式、格式和尺寸摘要;启用并发批量时,流式摘要必须显示并发状态。
|
| 375 |
- 当前实现中,`专业模式` 包含 `输出`、`模型`、`流式`、`路由` 分组。`并发批量` 开关位于流式分组;当流式策略关闭、任务数不足或服务端容量不可用时,开关保持禁用并显示原因。
|
| 376 |
|
| 377 |
## 10. 模式交互
|
|
|
|
| 492 |
- `省心模式`
|
| 493 |
- `专业模式`
|
| 494 |
|
| 495 |
+
避免直接使用英文界面词:
|
| 496 |
|
| 497 |
+
- 生成
|
| 498 |
+
- 编辑
|
| 499 |
+
- 调试
|
| 500 |
+
- 路由
|
| 501 |
+
- 后端
|
| 502 |
+
- 流式传输
|
| 503 |
+
- 响应接口
|
| 504 |
|
| 505 |
+
必要技术名可以放入专业模式,但主界面应转译为用户可理解的状态。
|
| 506 |
|
| 507 |
## 13. 实现注意
|
| 508 |
|
package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "2.
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
-
"name": "
|
| 9 |
-
"version": "2.
|
| 10 |
"license": "MIT",
|
| 11 |
"dependencies": {
|
| 12 |
"@next/env": "^16.2.10",
|
|
@@ -4178,9 +4178,9 @@
|
|
| 4178 |
},
|
| 4179 |
"node_modules/brace-expansion-modern": {
|
| 4180 |
"name": "brace-expansion",
|
| 4181 |
-
"version": "5.0.
|
| 4182 |
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.
|
| 4183 |
-
"integrity": "sha512-
|
| 4184 |
"dev": true,
|
| 4185 |
"license": "MIT",
|
| 4186 |
"dependencies": {
|
|
@@ -6319,9 +6319,9 @@
|
|
| 6319 |
"license": "MIT"
|
| 6320 |
},
|
| 6321 |
"node_modules/js-yaml": {
|
| 6322 |
-
"version": "4.3.
|
| 6323 |
-
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.
|
| 6324 |
-
"integrity": "sha512-
|
| 6325 |
"dev": true,
|
| 6326 |
"funding": [
|
| 6327 |
{
|
|
@@ -6869,9 +6869,9 @@
|
|
| 6869 |
"license": "MIT"
|
| 6870 |
},
|
| 6871 |
"node_modules/nanoid": {
|
| 6872 |
-
"version": "3.3.
|
| 6873 |
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
| 6874 |
-
"integrity": "sha512-
|
| 6875 |
"funding": [
|
| 6876 |
{
|
| 6877 |
"type": "github",
|
|
@@ -8938,11 +8938,11 @@
|
|
| 8938 |
},
|
| 8939 |
"vendor/brace-expansion-compat": {
|
| 8940 |
"name": "brace-expansion",
|
| 8941 |
-
"version": "5.0.
|
| 8942 |
"dev": true,
|
| 8943 |
"license": "MIT",
|
| 8944 |
"dependencies": {
|
| 8945 |
-
"brace-expansion-modern": "npm:brace-expansion@5.0.
|
| 8946 |
},
|
| 8947 |
"engines": {
|
| 8948 |
"node": ">=20"
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "visual-journal",
|
| 3 |
+
"version": "2.3.0",
|
| 4 |
"lockfileVersion": 3,
|
| 5 |
"requires": true,
|
| 6 |
"packages": {
|
| 7 |
"": {
|
| 8 |
+
"name": "visual-journal",
|
| 9 |
+
"version": "2.3.0",
|
| 10 |
"license": "MIT",
|
| 11 |
"dependencies": {
|
| 12 |
"@next/env": "^16.2.10",
|
|
|
|
| 4178 |
},
|
| 4179 |
"node_modules/brace-expansion-modern": {
|
| 4180 |
"name": "brace-expansion",
|
| 4181 |
+
"version": "5.0.9",
|
| 4182 |
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
| 4183 |
+
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
| 4184 |
"dev": true,
|
| 4185 |
"license": "MIT",
|
| 4186 |
"dependencies": {
|
|
|
|
| 6319 |
"license": "MIT"
|
| 6320 |
},
|
| 6321 |
"node_modules/js-yaml": {
|
| 6322 |
+
"version": "4.3.1",
|
| 6323 |
+
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
| 6324 |
+
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
| 6325 |
"dev": true,
|
| 6326 |
"funding": [
|
| 6327 |
{
|
|
|
|
| 6869 |
"license": "MIT"
|
| 6870 |
},
|
| 6871 |
"node_modules/nanoid": {
|
| 6872 |
+
"version": "3.3.18",
|
| 6873 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
| 6874 |
+
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
| 6875 |
"funding": [
|
| 6876 |
{
|
| 6877 |
"type": "github",
|
|
|
|
| 8938 |
},
|
| 8939 |
"vendor/brace-expansion-compat": {
|
| 8940 |
"name": "brace-expansion",
|
| 8941 |
+
"version": "5.0.9",
|
| 8942 |
"dev": true,
|
| 8943 |
"license": "MIT",
|
| 8944 |
"dependencies": {
|
| 8945 |
+
"brace-expansion-modern": "npm:brace-expansion@5.0.9"
|
| 8946 |
},
|
| 8947 |
"engines": {
|
| 8948 |
"node": ">=20"
|
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"name": "
|
| 3 |
-
"version": "2.
|
| 4 |
"license": "MIT",
|
| 5 |
"private": true,
|
| 6 |
"allowScripts": {
|
|
@@ -99,6 +99,8 @@
|
|
| 99 |
"overrides": {
|
| 100 |
"postcss": "^8.5.18",
|
| 101 |
"brace-expansion": "$brace-expansion",
|
|
|
|
|
|
|
| 102 |
"sharp": "$sharp"
|
| 103 |
}
|
| 104 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"name": "visual-journal",
|
| 3 |
+
"version": "2.3.0",
|
| 4 |
"license": "MIT",
|
| 5 |
"private": true,
|
| 6 |
"allowScripts": {
|
|
|
|
| 99 |
"overrides": {
|
| 100 |
"postcss": "^8.5.18",
|
| 101 |
"brace-expansion": "$brace-expansion",
|
| 102 |
+
"js-yaml": "^4.3.1",
|
| 103 |
+
"nanoid": "^3.3.17",
|
| 104 |
"sharp": "$sharp"
|
| 105 |
}
|
| 106 |
}
|
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-
|
| 5 |
-
"deploy_id": "
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
+
"local_sha": "9d61ce95a120e0356988adfca282e04c52ea4364",
|
| 4 |
+
"created_at": "2026-08-13T10:16:56.322Z",
|
| 5 |
+
"deploy_id": "c95caa4a-cdaf-4024-b6fa-df7effcc28a9"
|
| 6 |
}
|
scripts/agent-doctor.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import {
|
| 3 |
loadPrivateAgentEnvFile,
|
| 4 |
resolvePlaygroundBaseUrl
|
| 5 |
-
} from '../skills/
|
| 6 |
import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs';
|
| 7 |
import {
|
| 8 |
isMainModule,
|
|
@@ -15,10 +15,10 @@ import {
|
|
| 15 |
import { fileURLToPath } from 'node:url';
|
| 16 |
|
| 17 |
const GENERATE_SCRIPT = fileURLToPath(
|
| 18 |
-
new URL('../skills/
|
| 19 |
);
|
| 20 |
const EDIT_SCRIPT = fileURLToPath(
|
| 21 |
-
new URL('../skills/
|
| 22 |
);
|
| 23 |
const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
|
| 24 |
const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k';
|
|
|
|
| 2 |
import {
|
| 3 |
loadPrivateAgentEnvFile,
|
| 4 |
resolvePlaygroundBaseUrl
|
| 5 |
+
} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs';
|
| 6 |
import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs';
|
| 7 |
import {
|
| 8 |
isMainModule,
|
|
|
|
| 15 |
import { fileURLToPath } from 'node:url';
|
| 16 |
|
| 17 |
const GENERATE_SCRIPT = fileURLToPath(
|
| 18 |
+
new URL('../skills/visual-journal-image-agent/scripts/generate-image.mjs', import.meta.url)
|
| 19 |
);
|
| 20 |
const EDIT_SCRIPT = fileURLToPath(
|
| 21 |
+
new URL('../skills/visual-journal-image-agent/scripts/edit-image.mjs', import.meta.url)
|
| 22 |
);
|
| 23 |
const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
|
| 24 |
const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k';
|
scripts/agent-skill-scripts.test.mjs
CHANGED
|
@@ -1,15 +1,27 @@
|
|
| 1 |
-
import { AGENT_ENDPOINTS } from '../skills/
|
| 2 |
-
import { enrichFailureWithAgentDiagnostics } from '../skills/
|
| 3 |
-
import { AGENT_ENDPOINTS as SERVER_AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs';
|
| 4 |
import {
|
| 5 |
parseRetryAfterValue,
|
| 6 |
readCapabilitiesImageTransportTimeoutMs,
|
| 7 |
-
resolveSameOriginUrl
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
| 9 |
import { FIXTURE_IMAGE_BASE64 } from './local-image-upstream-fixture.mjs';
|
| 10 |
import assert from 'node:assert/strict';
|
| 11 |
import { spawn, spawnSync } from 'node:child_process';
|
| 12 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
import { createServer } from 'node:http';
|
| 14 |
import { tmpdir } from 'node:os';
|
| 15 |
import { join } from 'node:path';
|
|
@@ -17,8 +29,8 @@ import { describe, it } from 'node:test';
|
|
| 17 |
import { fileURLToPath } from 'node:url';
|
| 18 |
|
| 19 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 20 |
-
const skillRoot = join(repoRoot, 'skills/
|
| 21 |
-
const skillScriptsRoot = join(repoRoot, 'skills/
|
| 22 |
const localUpstreamProbeTimeoutMs = '5000';
|
| 23 |
|
| 24 |
function agentGenerateCapabilities(extra = {}) {
|
|
@@ -31,6 +43,15 @@ function agentGenerateCapabilities(extra = {}) {
|
|
| 31 |
agent_jobs: { supported: true, mode: 'job_polling' },
|
| 32 |
limits: {
|
| 33 |
generate_images: { min: 1, max: 4 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
partial_images: { min: 0, max: 4 },
|
| 35 |
partial_images_by_backend: {
|
| 36 |
'images-api': { min: 0, max: 4 },
|
|
@@ -44,6 +65,91 @@ function agentGenerateCapabilities(extra = {}) {
|
|
| 44 |
}
|
| 45 |
|
| 46 |
describe('Agent skill script argument validation', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
it('rejects invalid generate numeric options before dry-run output', () => {
|
| 48 |
const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
|
| 49 |
|
|
@@ -312,10 +418,10 @@ describe('Agent skill script argument validation', () => {
|
|
| 312 |
assert.equal(boundedBody.verification_scope.service_base_url, 'http://localhost:4783');
|
| 313 |
|
| 314 |
const projectCopyRoot = join(parentRoot, 'project-copy');
|
| 315 |
-
mkdirSync(join(projectCopyRoot, 'skills/
|
| 316 |
mkdirSync(join(projectCopyRoot, 'nested'), { recursive: true });
|
| 317 |
-
writeFileSync(join(projectCopyRoot, 'package.json'), JSON.stringify({ name: '
|
| 318 |
-
writeFileSync(join(projectCopyRoot, 'skills/
|
| 319 |
const projectBounded = runSkillScript(
|
| 320 |
'generate-image.mjs',
|
| 321 |
['prompt'],
|
|
@@ -348,7 +454,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 348 |
['prompt'],
|
| 349 |
{},
|
| 350 |
{
|
| 351 |
-
cwd: join(projectCopyRoot, 'skills/
|
| 352 |
loadPrivateAgentEnv: true,
|
| 353 |
createCwd: true
|
| 354 |
}
|
|
@@ -4602,6 +4708,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 4602 |
assert.ok(frontmatter.description.length > 0);
|
| 4603 |
assert.ok(frontmatter.description.length <= 1024);
|
| 4604 |
assert.doesNotMatch(frontmatter.description, /[<>]/);
|
|
|
|
| 4605 |
});
|
| 4606 |
|
| 4607 |
it('tells agents to use bundled scripts instead of ad hoc API callers', () => {
|
|
@@ -4610,25 +4717,30 @@ describe('Agent skill script argument validation', () => {
|
|
| 4610 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4611 |
|
| 4612 |
assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/);
|
|
|
|
| 4613 |
assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/);
|
| 4614 |
-
assert.match(skillText, /不要临时编写
|
| 4615 |
assert.match(openAiYaml, /先选择并运行内置脚本/);
|
|
|
|
| 4616 |
assert.match(openAiYaml, /不要临时编写 API 调用脚本/);
|
| 4617 |
assert.match(apiReference, /先使用这些内置脚本/);
|
| 4618 |
assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/);
|
| 4619 |
-
assert.match(apiReference, /不要临时编写 Node
|
| 4620 |
});
|
| 4621 |
|
| 4622 |
-
it('documents backend-specific
|
| 4623 |
const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
|
| 4624 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4625 |
|
| 4626 |
assert.match(skillText, /limits\.partial_images_by_backend\[image_backend\]/);
|
|
|
|
|
|
|
|
|
|
| 4627 |
assert.match(
|
| 4628 |
skillText,
|
| 4629 |
/Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API\/profile 范围校验/
|
| 4630 |
);
|
| 4631 |
-
assert.match(skillText, /Responses image_generation
|
| 4632 |
assert.match(
|
| 4633 |
skillText,
|
| 4634 |
/Docker compose 本身不设置这两个默认值,未配置 `\.env\.local` 时仍是 `images-api` 和 `auto`/
|
|
@@ -4642,6 +4754,9 @@ describe('Agent skill script argument validation', () => {
|
|
| 4642 |
assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/);
|
| 4643 |
assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/);
|
| 4644 |
assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/);
|
|
|
|
|
|
|
|
|
|
| 4645 |
assert.match(apiReference, /只代表“声明支持”,不代表当前渠道每次实测都能成功/);
|
| 4646 |
assert.match(apiReference, /如果 `selected_channel_id`、`upstream_host` 为空/);
|
| 4647 |
assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/);
|
|
@@ -4709,7 +4824,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 4709 |
assert.match(skillText, /channelQueue\.capacityPerCredential/);
|
| 4710 |
assert.match(skillText, /输出格式固定为 Agent WebP 契约/);
|
| 4711 |
assert.match(skillText, /Agent edit 只是对照路径,不保证与页面 SSE 的像素尺寸完全一致/);
|
| 4712 |
-
assert.match(skillText, /复杂 UI、长
|
| 4713 |
assert.match(skillText, /Codex 会话日志会持久保存命令输出/);
|
| 4714 |
assert.match(skillText, /npm run env:summary/);
|
| 4715 |
assert.match(skillText, /verification_scope\.mode=local_planning_only/);
|
|
@@ -4792,23 +4907,23 @@ describe('Agent skill script argument validation', () => {
|
|
| 4792 |
|
| 4793 |
assert.match(
|
| 4794 |
readmeText,
|
| 4795 |
-
/新增
|
| 4796 |
);
|
| 4797 |
assert.match(readmeText, /Skill 脚本做薄封装/);
|
| 4798 |
-
assert.match(skillText, /新增
|
| 4799 |
assert.match(skillText, /GET \/api\/agent\/capabilities/);
|
| 4800 |
assert.match(skillText, /GET \/api\/agent\/openapi\.json/);
|
| 4801 |
assert.match(skillText, /\/api\/agent\/diagnostics\/\*/);
|
| 4802 |
assert.match(skillText, /Skill 脚本只做薄封装/);
|
| 4803 |
-
assert.match(skillText, /不能复制页面 API、运行
|
| 4804 |
-
assert.match(apiReference, /新增
|
| 4805 |
assert.match(apiReference, /capabilities、OpenAPI 或明确的 Agent 只读端点/);
|
| 4806 |
assert.match(apiReference, /不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑/);
|
| 4807 |
});
|
| 4808 |
|
| 4809 |
it('runs from a copied standalone skill directory outside the repository', () => {
|
| 4810 |
-
const tempRoot = mkdtempSync(join(tmpdir(), '
|
| 4811 |
-
const copiedSkillRoot = join(tempRoot, '
|
| 4812 |
try {
|
| 4813 |
cpSync(skillRoot, copiedSkillRoot, { recursive: true });
|
| 4814 |
writeFileSync(
|
|
@@ -8136,7 +8251,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 8136 |
transport: 'agent_json',
|
| 8137 |
endpoint: '/api/agent/images/generate',
|
| 8138 |
request_headers: {
|
| 8139 |
-
user_agent_effective: 'visual-journal/2.
|
| 8140 |
has_extra_headers: false
|
| 8141 |
}
|
| 8142 |
}
|
|
@@ -8197,7 +8312,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 8197 |
assert.equal(body.agent_requests[0].diagnostics.response.timing.elapsed_ms, 61234);
|
| 8198 |
assert.equal(
|
| 8199 |
body.agent_requests[0].diagnostics.response.execution.request_headers.user_agent_effective,
|
| 8200 |
-
'visual-journal/2.
|
| 8201 |
);
|
| 8202 |
assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key');
|
| 8203 |
assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns');
|
|
@@ -8614,10 +8729,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 8614 |
assert.equal(body.snapshot.channels[0].channel_id, 'primary');
|
| 8615 |
assert.deepEqual(
|
| 8616 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 8617 |
-
[
|
| 8618 |
-
'GET /playground/api/agent/capabilities',
|
| 8619 |
-
'GET /playground/api/agent/diagnostics/channel-health'
|
| 8620 |
-
]
|
| 8621 |
);
|
| 8622 |
assert.equal(requests[0].authorization, 'Bearer channel-health-token');
|
| 8623 |
assert.equal(requests[1].authorization, 'Bearer channel-health-token');
|
|
|
|
| 1 |
+
import { AGENT_ENDPOINTS } from '../skills/visual-journal-image-agent/scripts/lib/agent-api-paths.mjs';
|
| 2 |
+
import { enrichFailureWithAgentDiagnostics } from '../skills/visual-journal-image-agent/scripts/lib/agent-diagnostics-summary.mjs';
|
|
|
|
| 3 |
import {
|
| 4 |
parseRetryAfterValue,
|
| 5 |
readCapabilitiesImageTransportTimeoutMs,
|
| 6 |
+
resolveSameOriginUrl,
|
| 7 |
+
validateAgentEditRequestAgainstCapabilities,
|
| 8 |
+
validateAgentGenerateRequestAgainstCapabilities
|
| 9 |
+
} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs';
|
| 10 |
+
import { AGENT_ENDPOINTS as SERVER_AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs';
|
| 11 |
import { FIXTURE_IMAGE_BASE64 } from './local-image-upstream-fixture.mjs';
|
| 12 |
import assert from 'node:assert/strict';
|
| 13 |
import { spawn, spawnSync } from 'node:child_process';
|
| 14 |
+
import {
|
| 15 |
+
cpSync,
|
| 16 |
+
existsSync,
|
| 17 |
+
mkdirSync,
|
| 18 |
+
mkdtempSync,
|
| 19 |
+
readFileSync,
|
| 20 |
+
readdirSync,
|
| 21 |
+
rmSync,
|
| 22 |
+
statSync,
|
| 23 |
+
writeFileSync
|
| 24 |
+
} from 'node:fs';
|
| 25 |
import { createServer } from 'node:http';
|
| 26 |
import { tmpdir } from 'node:os';
|
| 27 |
import { join } from 'node:path';
|
|
|
|
| 29 |
import { fileURLToPath } from 'node:url';
|
| 30 |
|
| 31 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 32 |
+
const skillRoot = join(repoRoot, 'skills/visual-journal-image-agent');
|
| 33 |
+
const skillScriptsRoot = join(repoRoot, 'skills/visual-journal-image-agent/scripts');
|
| 34 |
const localUpstreamProbeTimeoutMs = '5000';
|
| 35 |
|
| 36 |
function agentGenerateCapabilities(extra = {}) {
|
|
|
|
| 43 |
agent_jobs: { supported: true, mode: 'job_polling' },
|
| 44 |
limits: {
|
| 45 |
generate_images: { min: 1, max: 4 },
|
| 46 |
+
edit_images: { min: 1, max: 4 },
|
| 47 |
+
generate_images_by_backend: {
|
| 48 |
+
'images-api': { min: 1, max: 4 },
|
| 49 |
+
'responses-image-generation': { min: 1, max: 1 }
|
| 50 |
+
},
|
| 51 |
+
edit_images_by_backend: {
|
| 52 |
+
'images-api': { min: 1, max: 4 },
|
| 53 |
+
'responses-image-generation': { min: 1, max: 1 }
|
| 54 |
+
},
|
| 55 |
partial_images: { min: 0, max: 4 },
|
| 56 |
partial_images_by_backend: {
|
| 57 |
'images-api': { min: 0, max: 4 },
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
describe('Agent skill script argument validation', () => {
|
| 68 |
+
it('prioritizes backend-specific output limits and falls back for older capabilities', () => {
|
| 69 |
+
const capabilities = agentGenerateCapabilities();
|
| 70 |
+
|
| 71 |
+
assert.doesNotThrow(() =>
|
| 72 |
+
validateAgentGenerateRequestAgainstCapabilities({ n: 4, image_backend: 'images-api' }, capabilities)
|
| 73 |
+
);
|
| 74 |
+
assert.throws(
|
| 75 |
+
() =>
|
| 76 |
+
validateAgentGenerateRequestAgainstCapabilities(
|
| 77 |
+
{ n: 2, image_backend: 'responses-image-generation' },
|
| 78 |
+
capabilities
|
| 79 |
+
),
|
| 80 |
+
/n 必须在当前 capabilities 允许的 1 到 1 之间/
|
| 81 |
+
);
|
| 82 |
+
assert.throws(
|
| 83 |
+
() =>
|
| 84 |
+
validateAgentEditRequestAgainstCapabilities(
|
| 85 |
+
{ n: 2, image_backend: 'responses-image-generation' },
|
| 86 |
+
capabilities
|
| 87 |
+
),
|
| 88 |
+
/n 必须在当前 capabilities 允许的 1 到 1 之间/
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
const legacyCapabilities = {
|
| 92 |
+
limits: {
|
| 93 |
+
generate_images: { min: 1, max: 4 },
|
| 94 |
+
edit_images: { min: 1, max: 4 }
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
assert.doesNotThrow(() =>
|
| 98 |
+
validateAgentGenerateRequestAgainstCapabilities(
|
| 99 |
+
{ n: 4, image_backend: 'responses-image-generation' },
|
| 100 |
+
legacyCapabilities
|
| 101 |
+
)
|
| 102 |
+
);
|
| 103 |
+
assert.doesNotThrow(() =>
|
| 104 |
+
validateAgentEditRequestAgainstCapabilities(
|
| 105 |
+
{ n: 4, image_backend: 'responses-image-generation' },
|
| 106 |
+
legacyCapabilities
|
| 107 |
+
)
|
| 108 |
+
);
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
it('uses the public partial image range for requests that resolve to non-stream mode', () => {
|
| 112 |
+
const capabilities = agentGenerateCapabilities({
|
| 113 |
+
upstream_request_headers: {
|
| 114 |
+
channels: [{ request_modes: ['images-non-stream'] }]
|
| 115 |
+
},
|
| 116 |
+
limits: {
|
| 117 |
+
...agentGenerateCapabilities().limits,
|
| 118 |
+
partial_images_by_backend: {
|
| 119 |
+
'images-api': { min: 1, max: 3 },
|
| 120 |
+
'responses-image-generation': { min: 1, max: 3 }
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
assert.doesNotThrow(() =>
|
| 126 |
+
validateAgentGenerateRequestAgainstCapabilities(
|
| 127 |
+
{ n: 1, partial_images: 0, image_backend: 'images-api', stream_mode: 'non_stream' },
|
| 128 |
+
capabilities
|
| 129 |
+
)
|
| 130 |
+
);
|
| 131 |
+
assert.doesNotThrow(() =>
|
| 132 |
+
validateAgentEditRequestAgainstCapabilities(
|
| 133 |
+
{
|
| 134 |
+
n: 1,
|
| 135 |
+
partial_images: 4,
|
| 136 |
+
image_backend: 'images-api',
|
| 137 |
+
stream_mode: 'auto',
|
| 138 |
+
streaming_strategy: 'off'
|
| 139 |
+
},
|
| 140 |
+
capabilities
|
| 141 |
+
)
|
| 142 |
+
);
|
| 143 |
+
assert.throws(
|
| 144 |
+
() =>
|
| 145 |
+
validateAgentGenerateRequestAgainstCapabilities(
|
| 146 |
+
{ n: 1, partial_images: 4, image_backend: 'images-api', stream_mode: 'auto' },
|
| 147 |
+
{ ...capabilities, upstream_request_headers: { channels: [{ request_modes: ['images-sse'] }] } }
|
| 148 |
+
),
|
| 149 |
+
/partial_images 必须在当前 capabilities 允许的 1 到 3 之间/
|
| 150 |
+
);
|
| 151 |
+
});
|
| 152 |
+
|
| 153 |
it('rejects invalid generate numeric options before dry-run output', () => {
|
| 154 |
const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
|
| 155 |
|
|
|
|
| 418 |
assert.equal(boundedBody.verification_scope.service_base_url, 'http://localhost:4783');
|
| 419 |
|
| 420 |
const projectCopyRoot = join(parentRoot, 'project-copy');
|
| 421 |
+
mkdirSync(join(projectCopyRoot, 'skills/visual-journal-image-agent'), { recursive: true });
|
| 422 |
mkdirSync(join(projectCopyRoot, 'nested'), { recursive: true });
|
| 423 |
+
writeFileSync(join(projectCopyRoot, 'package.json'), JSON.stringify({ name: 'visual-journal' }));
|
| 424 |
+
writeFileSync(join(projectCopyRoot, 'skills/visual-journal-image-agent/SKILL.md'), '# skill\n');
|
| 425 |
const projectBounded = runSkillScript(
|
| 426 |
'generate-image.mjs',
|
| 427 |
['prompt'],
|
|
|
|
| 454 |
['prompt'],
|
| 455 |
{},
|
| 456 |
{
|
| 457 |
+
cwd: join(projectCopyRoot, 'skills/visual-journal-image-agent/scripts'),
|
| 458 |
loadPrivateAgentEnv: true,
|
| 459 |
createCwd: true
|
| 460 |
}
|
|
|
|
| 4708 |
assert.ok(frontmatter.description.length > 0);
|
| 4709 |
assert.ok(frontmatter.description.length <= 1024);
|
| 4710 |
assert.doesNotMatch(frontmatter.description, /[<>]/);
|
| 4711 |
+
assert.match(frontmatter.description, /替代 Codex 内置的通用生图 Skill/);
|
| 4712 |
});
|
| 4713 |
|
| 4714 |
it('tells agents to use bundled scripts instead of ad hoc API callers', () => {
|
|
|
|
| 4717 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4718 |
|
| 4719 |
assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/);
|
| 4720 |
+
assert.match(skillText, /替代 Codex 内置的通用生图 Skill/);
|
| 4721 |
assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/);
|
| 4722 |
+
assert.match(skillText, /不要临时编写脚本、curl 命令或手写 fetch\/FormData/);
|
| 4723 |
assert.match(openAiYaml, /先选择并运行内置脚本/);
|
| 4724 |
+
assert.match(openAiYaml, /替代 Codex 内置的通用生图 Skill/);
|
| 4725 |
assert.match(openAiYaml, /不要临时编写 API 调用脚本/);
|
| 4726 |
assert.match(apiReference, /先使用这些内置脚本/);
|
| 4727 |
assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/);
|
| 4728 |
+
assert.match(apiReference, /不要临时编写 Node、Python 或 shell 脚本、curl 命令或手写 fetch\/FormData/);
|
| 4729 |
});
|
| 4730 |
|
| 4731 |
+
it('documents backend-specific image output and preview limits in dedicated Agent docs', () => {
|
| 4732 |
const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
|
| 4733 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4734 |
|
| 4735 |
assert.match(skillText, /limits\.partial_images_by_backend\[image_backend\]/);
|
| 4736 |
+
assert.match(skillText, /limits\.generate_images_by_backend\[image_backend\]/);
|
| 4737 |
+
assert.match(skillText, /limits\.edit_images_by_backend\[image_backend\]/);
|
| 4738 |
+
assert.match(skillText, /`responses-image-generation` 当前生成和页面 SSE 编辑都只允许 `n=1`/);
|
| 4739 |
assert.match(
|
| 4740 |
skillText,
|
| 4741 |
/Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API\/profile 范围校验/
|
| 4742 |
);
|
| 4743 |
+
assert.match(skillText, /Responses image_generation 编辑属于页面 SSE 路径/);
|
| 4744 |
assert.match(
|
| 4745 |
skillText,
|
| 4746 |
/Docker compose 本身不设置这两个默认值,未配置 `\.env\.local` 时仍是 `images-api` 和 `auto`/
|
|
|
|
| 4754 |
assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/);
|
| 4755 |
assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/);
|
| 4756 |
assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/);
|
| 4757 |
+
assert.match(apiReference, /limits\.generate_images_by_backend/);
|
| 4758 |
+
assert.match(apiReference, /limits\.edit_images_by_backend/);
|
| 4759 |
+
assert.match(apiReference, /`responses-image-generation` 当前两种操作都只允许 `n=1`/);
|
| 4760 |
assert.match(apiReference, /只代表“声明支持”,不代表当前渠道每次实测都能成功/);
|
| 4761 |
assert.match(apiReference, /如果 `selected_channel_id`、`upstream_host` 为空/);
|
| 4762 |
assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/);
|
|
|
|
| 4824 |
assert.match(skillText, /channelQueue\.capacityPerCredential/);
|
| 4825 |
assert.match(skillText, /输出格式固定为 Agent WebP 契约/);
|
| 4826 |
assert.match(skillText, /Agent edit 只是对照路径,不保证与页面 SSE 的像素尺寸完全一致/);
|
| 4827 |
+
assert.match(skillText, /复杂 UI、长提示词、高质量图生图遇到 5 分钟级超时/);
|
| 4828 |
assert.match(skillText, /Codex 会话日志会持久保存命令输出/);
|
| 4829 |
assert.match(skillText, /npm run env:summary/);
|
| 4830 |
assert.match(skillText, /verification_scope\.mode=local_planning_only/);
|
|
|
|
| 4907 |
|
| 4908 |
assert.match(
|
| 4909 |
readmeText,
|
| 4910 |
+
/新增探针、诊断或路由可观测能力时,先落 API、能力声明和 OpenAPI 契约/
|
| 4911 |
);
|
| 4912 |
assert.match(readmeText, /Skill 脚本做薄封装/);
|
| 4913 |
+
assert.match(skillText, /新增探针、诊断、路由健康或请求旅程能力时/);
|
| 4914 |
assert.match(skillText, /GET \/api\/agent\/capabilities/);
|
| 4915 |
assert.match(skillText, /GET \/api\/agent\/openapi\.json/);
|
| 4916 |
assert.match(skillText, /\/api\/agent\/diagnostics\/\*/);
|
| 4917 |
assert.match(skillText, /Skill 脚本只做薄封装/);
|
| 4918 |
+
assert.match(skillText, /不能复制页面 API、运行时 API 和 Agent API 的边界判断/);
|
| 4919 |
+
assert.match(apiReference, /新增探针、诊断或健康摘要时/);
|
| 4920 |
assert.match(apiReference, /capabilities、OpenAPI 或明确的 Agent 只读端点/);
|
| 4921 |
assert.match(apiReference, /不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑/);
|
| 4922 |
});
|
| 4923 |
|
| 4924 |
it('runs from a copied standalone skill directory outside the repository', () => {
|
| 4925 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'visual-journal-image-agent-'));
|
| 4926 |
+
const copiedSkillRoot = join(tempRoot, 'visual-journal-image-agent');
|
| 4927 |
try {
|
| 4928 |
cpSync(skillRoot, copiedSkillRoot, { recursive: true });
|
| 4929 |
writeFileSync(
|
|
|
|
| 8251 |
transport: 'agent_json',
|
| 8252 |
endpoint: '/api/agent/images/generate',
|
| 8253 |
request_headers: {
|
| 8254 |
+
user_agent_effective: 'visual-journal/2.3.0',
|
| 8255 |
has_extra_headers: false
|
| 8256 |
}
|
| 8257 |
}
|
|
|
|
| 8312 |
assert.equal(body.agent_requests[0].diagnostics.response.timing.elapsed_ms, 61234);
|
| 8313 |
assert.equal(
|
| 8314 |
body.agent_requests[0].diagnostics.response.execution.request_headers.user_agent_effective,
|
| 8315 |
+
'visual-journal/2.3.0'
|
| 8316 |
);
|
| 8317 |
assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key');
|
| 8318 |
assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns');
|
|
|
|
| 8729 |
assert.equal(body.snapshot.channels[0].channel_id, 'primary');
|
| 8730 |
assert.deepEqual(
|
| 8731 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 8732 |
+
['GET /playground/api/agent/capabilities', 'GET /playground/api/agent/diagnostics/channel-health']
|
|
|
|
|
|
|
|
|
|
| 8733 |
);
|
| 8734 |
assert.equal(requests[0].authorization, 'Bearer channel-health-token');
|
| 8735 |
assert.equal(requests[1].authorization, 'Bearer channel-health-token');
|
scripts/channel-capability-matrix.test.mjs
CHANGED
|
@@ -14,10 +14,10 @@ import {
|
|
| 14 |
import {
|
| 15 |
buildChannelEnvConfig,
|
| 16 |
buildRedactedChannelEnvPreview
|
| 17 |
-
} from '../skills/
|
| 18 |
|
| 19 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 20 |
-
const matrixScript = join(repoRoot, 'skills/
|
| 21 |
const testApiKey = 'test-upstream-token';
|
| 22 |
const testResponsesModel = 'gpt-5.4';
|
| 23 |
|
|
|
|
| 14 |
import {
|
| 15 |
buildChannelEnvConfig,
|
| 16 |
buildRedactedChannelEnvPreview
|
| 17 |
+
} from '../skills/visual-journal-image-agent/scripts/lib/channel-capability-matrix.mjs';
|
| 18 |
|
| 19 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 20 |
+
const matrixScript = join(repoRoot, 'skills/visual-journal-image-agent/scripts/channel-capability-matrix.mjs');
|
| 21 |
const testApiKey = 'test-upstream-token';
|
| 22 |
const testResponsesModel = 'gpt-5.4';
|
| 23 |
|
scripts/check-version-metadata.mjs
CHANGED
|
@@ -21,7 +21,7 @@ function escapeRegExp(value) {
|
|
| 21 |
}
|
| 22 |
|
| 23 |
function readReadmeBadgeVersion(readme) {
|
| 24 |
-
const match = readme.match(/!\[Version\]\(https:\/\/img\.shields\.io\/badge\/version-(.+?)-blue\)/);
|
| 25 |
return match?.[1];
|
| 26 |
}
|
| 27 |
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
function readReadmeBadgeVersion(readme) {
|
| 24 |
+
const match = readme.match(/!\[(?:Version|版本)\]\(https:\/\/img\.shields\.io\/badge\/version-(.+?)-blue\)/);
|
| 25 |
return match?.[1];
|
| 26 |
}
|
| 27 |
|
scripts/dimension-check.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
| 5 |
isDimensionCheckError,
|
| 6 |
parseExpectedDimensions,
|
| 7 |
sanitizeImageResponse
|
| 8 |
-
} from '../skills/
|
| 9 |
import assert from 'node:assert/strict';
|
| 10 |
import { createServer } from 'node:http';
|
| 11 |
import { test } from 'node:test';
|
|
|
|
| 5 |
isDimensionCheckError,
|
| 6 |
parseExpectedDimensions,
|
| 7 |
sanitizeImageResponse
|
| 8 |
+
} from '../skills/visual-journal-image-agent/scripts/lib/dimension-check.mjs';
|
| 9 |
import assert from 'node:assert/strict';
|
| 10 |
import { createServer } from 'node:http';
|
| 11 |
import { test } from 'node:test';
|
scripts/first-run.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
import {
|
| 3 |
loadPrivateAgentEnvFile,
|
| 4 |
resolvePlaygroundBaseUrl
|
| 5 |
-
} from '../skills/
|
| 6 |
import { isMainModule, printJson, redactBaseUrl } from './command-center-utils.mjs';
|
| 7 |
import { inspectDependencyInstallation, LOCAL_DEPENDENCY_INSTALL_COMMAND } from './dependency-installation.mjs';
|
| 8 |
import { summarizeEnvFile } from './env-summary.mjs';
|
|
|
|
| 2 |
import {
|
| 3 |
loadPrivateAgentEnvFile,
|
| 4 |
resolvePlaygroundBaseUrl
|
| 5 |
+
} from '../skills/visual-journal-image-agent/scripts/lib/script-utils.mjs';
|
| 6 |
import { isMainModule, printJson, redactBaseUrl } from './command-center-utils.mjs';
|
| 7 |
import { inspectDependencyInstallation, LOCAL_DEPENDENCY_INSTALL_COMMAND } from './dependency-installation.mjs';
|
| 8 |
import { summarizeEnvFile } from './env-summary.mjs';
|
scripts/image-dimensions.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { readImageDimensions } from '../skills/
|
| 2 |
import assert from 'node:assert/strict';
|
| 3 |
import { test } from 'node:test';
|
| 4 |
|
|
|
|
| 1 |
+
import { readImageDimensions } from '../skills/visual-journal-image-agent/scripts/lib/image-dimensions.mjs';
|
| 2 |
import assert from 'node:assert/strict';
|
| 3 |
import { test } from 'node:test';
|
| 4 |
|
scripts/page-form-streaming.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { describe, it } from 'node:test';
|
|
| 8 |
import { fileURLToPath } from 'node:url';
|
| 9 |
|
| 10 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 11 |
-
const skillScriptsRoot = join(repoRoot, 'skills/
|
| 12 |
|
| 13 |
describe('Page form streaming defaults', () => {
|
| 14 |
it('does not force stream=true for page generate JSON responses', async () => {
|
|
|
|
| 8 |
import { fileURLToPath } from 'node:url';
|
| 9 |
|
| 10 |
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 11 |
+
const skillScriptsRoot = join(repoRoot, 'skills/visual-journal-image-agent/scripts');
|
| 12 |
|
| 13 |
describe('Page form streaming defaults', () => {
|
| 14 |
it('does not force stream=true for page generate JSON responses', async () => {
|
scripts/product-branding.test.mjs
CHANGED
|
@@ -63,8 +63,15 @@ describe('product branding', () => {
|
|
| 63 |
|
| 64 |
it('uses the formal product name in operational status while retaining the package identifier', () => {
|
| 65 |
const status = readRepositoryText('scripts/status.mjs');
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
assert.equal(FORMAL_PRODUCT_NAME, '图像手记 / Visual Journal');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
assert.match(status, /product: FORMAL_PRODUCT_NAME,/);
|
| 69 |
assert.match(status, /package_name: packageJson\.name,/);
|
| 70 |
});
|
|
|
|
| 63 |
|
| 64 |
it('uses the formal product name in operational status while retaining the package identifier', () => {
|
| 65 |
const status = readRepositoryText('scripts/status.mjs');
|
| 66 |
+
const packageJson = JSON.parse(readRepositoryText('package.json'));
|
| 67 |
+
const skill = readRepositoryText('skills/visual-journal-image-agent/SKILL.md');
|
| 68 |
+
const skillConfig = readRepositoryText('skills/visual-journal-image-agent/agents/openai.yaml');
|
| 69 |
|
| 70 |
assert.equal(FORMAL_PRODUCT_NAME, '图像手记 / Visual Journal');
|
| 71 |
+
assert.equal(packageJson.name, 'visual-journal');
|
| 72 |
+
assert.match(skill, /^name: visual-journal-image-agent$/m);
|
| 73 |
+
assert.match(skillConfig, /display_name: "Visual Journal Image Agent API"/);
|
| 74 |
+
assert.match(skillConfig, /\$visual-journal-image-agent/);
|
| 75 |
assert.match(status, /product: FORMAL_PRODUCT_NAME,/);
|
| 76 |
assert.match(status, /package_name: packageJson\.name,/);
|
| 77 |
});
|
scripts/smoke-hf-space-memory.mjs
CHANGED
|
@@ -132,14 +132,14 @@ try {
|
|
| 132 |
const runtime = await fetchJson('/api/runtime-capabilities');
|
| 133 |
assertEqual(typeof runtime.streamingBatch?.enabled, 'boolean', 'Runtime capabilities shape');
|
| 134 |
|
| 135 |
-
run('node', ['skills/
|
| 136 |
env: {
|
| 137 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 138 |
GPT_IMAGE_AGENT_TOKEN: token,
|
| 139 |
GPT_IMAGE_AGENT_CONTRACT_CHECK: '1'
|
| 140 |
}
|
| 141 |
});
|
| 142 |
-
run('node', ['skills/
|
| 143 |
env: {
|
| 144 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 145 |
GPT_IMAGE_AGENT_TOKEN: token,
|
|
|
|
| 132 |
const runtime = await fetchJson('/api/runtime-capabilities');
|
| 133 |
assertEqual(typeof runtime.streamingBatch?.enabled, 'boolean', 'Runtime capabilities shape');
|
| 134 |
|
| 135 |
+
run('node', ['skills/visual-journal-image-agent/scripts/generate-image.mjs'], {
|
| 136 |
env: {
|
| 137 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 138 |
GPT_IMAGE_AGENT_TOKEN: token,
|
| 139 |
GPT_IMAGE_AGENT_CONTRACT_CHECK: '1'
|
| 140 |
}
|
| 141 |
});
|
| 142 |
+
run('node', ['skills/visual-journal-image-agent/scripts/edit-image.mjs'], {
|
| 143 |
env: {
|
| 144 |
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 145 |
GPT_IMAGE_AGENT_TOKEN: token,
|
scripts/smoke-image-upstream-real.mjs
CHANGED
|
@@ -56,6 +56,15 @@ const CASES = [
|
|
| 56 |
}
|
| 57 |
];
|
| 58 |
const SERVER_CHANNEL_CASES = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
{
|
| 60 |
id: 'server-channel-images-json',
|
| 61 |
aliases: ['images-json', 'images-non-stream'],
|
|
@@ -451,10 +460,48 @@ async function runCase(loadRouteHandlersForBillable, testCase, preflight = {}) {
|
|
| 451 |
}
|
| 452 |
|
| 453 |
async function runBillableCaseAfterLoadingHandlers(loadRouteHandlersForBillable, testCase, target, startedAt, signal) {
|
|
|
|
|
|
|
|
|
|
| 454 |
const routeHandlers = await loadRouteHandlersForBillable(testCase);
|
| 455 |
return runBillableCase(routeHandlers, testCase, target, startedAt, signal);
|
| 456 |
}
|
| 457 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 458 |
async function runBillableCase(routeHandlers, testCase, target, startedAt, signal) {
|
| 459 |
const outputFilesBefore = snapshotRealSmokeOutputFiles();
|
| 460 |
if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') {
|
|
@@ -530,6 +577,10 @@ function readTarget(testCase) {
|
|
| 530 |
requiresResponsesModel,
|
| 531 |
baseUrl: serverBaseUrl?.value,
|
| 532 |
baseUrlKey: serverBaseUrl?.key,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 533 |
hasServerCredential: Boolean(env('OPENAI_API_KEY') || readFirstConfiguredServerApiKeys()),
|
| 534 |
model: env(`${basePrefix}_MODEL`) || 'gpt-image-2',
|
| 535 |
responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'),
|
|
@@ -765,11 +816,13 @@ function imageRequest(testCase, target, signal) {
|
|
| 765 |
formData.append('stream', 'true');
|
| 766 |
formData.append('partial_images', '2');
|
| 767 |
}
|
| 768 |
-
|
|
|
|
| 769 |
}
|
| 770 |
|
| 771 |
function agentGenerateRequest(testCase, target, signal) {
|
| 772 |
-
|
|
|
|
| 773 |
method: 'POST',
|
| 774 |
headers: buildAgentRequestHeaders(testCase),
|
| 775 |
body: JSON.stringify({
|
|
@@ -857,15 +910,43 @@ function summarizeJson(text, contentType) {
|
|
| 857 |
async function summarizeAgentResponse(response) {
|
| 858 |
const contentType = response.headers.get('content-type') || '';
|
| 859 |
const body = safeJson(await response.text());
|
|
|
|
| 860 |
return {
|
| 861 |
content_type: contentType || undefined,
|
| 862 |
image_count: Array.isArray(body?.images) ? body.images.length : 0,
|
|
|
|
|
|
|
|
|
|
| 863 |
first_content_url: typeof body?.images?.[0]?.content_url === 'string' ? body.images[0].content_url : undefined,
|
| 864 |
has_inline_base64: Boolean(body?.images?.[0]?.b64_json),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 865 |
...(body?.error ? { error: String(body.error.message || body.error) } : {})
|
| 866 |
};
|
| 867 |
}
|
| 868 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 869 |
function readFirstB64Length(images) {
|
| 870 |
if (!Array.isArray(images) || typeof images[0]?.b64_json !== 'string') return 0;
|
| 871 |
return images[0].b64_json.length;
|
|
@@ -924,7 +1005,7 @@ function printUsage() {
|
|
| 924 |
|
| 925 |
可选 --case:all、original-images-json、gaoren-images-sse、sub2api-images-sse、sub2api-responses-json、gpt2image-responses-sse、matsca-images-sse。
|
| 926 |
也可直接用 request mode 别名 images-json、images-sse、responses-json、responses-sse;同一个 request mode 可能命中多个 case。
|
| 927 |
-
添加 --include-server-channel 后还可运行:server-channel-images-json、server-channel-images-sse、server-channel-responses-sse、server-channel-responses-json、server-channel-agent-
|
| 928 |
默认只检查配置并跳过真实生图;必须加 --allow-billable 才会调用 /api/images 或 /api/agent/images/generate。
|
| 929 |
可用 --env-file 指向独立真实上游凭据文件;shell 环境变量优先级高于 --env-file,--env-file 优先级高于 .env.local。
|
| 930 |
可用 --env-file-if-exists 在凭据文件存在时加载,不存在时继续输出结构化 readiness 报告。
|
|
|
|
| 56 |
}
|
| 57 |
];
|
| 58 |
const SERVER_CHANNEL_CASES = [
|
| 59 |
+
{
|
| 60 |
+
id: 'server-channel-agent-images-json',
|
| 61 |
+
aliases: ['images-json', 'images-non-stream'],
|
| 62 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 63 |
+
requestMode: 'images-non-stream',
|
| 64 |
+
stream: false,
|
| 65 |
+
serverChannel: true,
|
| 66 |
+
endpoint: 'agent-generate'
|
| 67 |
+
},
|
| 68 |
{
|
| 69 |
id: 'server-channel-images-json',
|
| 70 |
aliases: ['images-json', 'images-non-stream'],
|
|
|
|
| 460 |
}
|
| 461 |
|
| 462 |
async function runBillableCaseAfterLoadingHandlers(loadRouteHandlersForBillable, testCase, target, startedAt, signal) {
|
| 463 |
+
if (target.serverChannel && env('IMAGE_REAL_SMOKE_SERVER_TRANSPORT') !== 'in-process') {
|
| 464 |
+
return runBillableHttpCase(testCase, target, startedAt, signal);
|
| 465 |
+
}
|
| 466 |
const routeHandlers = await loadRouteHandlersForBillable(testCase);
|
| 467 |
return runBillableCase(routeHandlers, testCase, target, startedAt, signal);
|
| 468 |
}
|
| 469 |
|
| 470 |
+
async function runBillableHttpCase(testCase, target, startedAt, signal) {
|
| 471 |
+
const outputFilesBefore = snapshotRealSmokeOutputFiles();
|
| 472 |
+
if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') {
|
| 473 |
+
process.env.OPENAI_RESPONSES_API_MODEL = target.responsesModel;
|
| 474 |
+
}
|
| 475 |
+
try {
|
| 476 |
+
const request =
|
| 477 |
+
testCase.endpoint === 'agent-generate'
|
| 478 |
+
? agentGenerateRequest(testCase, target, signal)
|
| 479 |
+
: imageRequest(testCase, target, signal);
|
| 480 |
+
const response = await fetch(request);
|
| 481 |
+
const summary =
|
| 482 |
+
testCase.endpoint === 'agent-generate'
|
| 483 |
+
? await summarizeAgentResponse(response)
|
| 484 |
+
: await summarizeResponse(response);
|
| 485 |
+
if (testCase.endpoint === 'agent-generate') {
|
| 486 |
+
await deleteHttpAgentArtifacts(target, summary.artifact_ids);
|
| 487 |
+
}
|
| 488 |
+
return {
|
| 489 |
+
id: testCase.id,
|
| 490 |
+
request_mode: testCase.requestMode,
|
| 491 |
+
ok: isSuccessfulBillableSmokeResponse(response, summary),
|
| 492 |
+
status: response.status,
|
| 493 |
+
elapsed_ms: Date.now() - startedAt,
|
| 494 |
+
server_channel: true,
|
| 495 |
+
upstream_host: summary.upstream_host || readHost(target.baseUrl),
|
| 496 |
+
...(summary.selected_channel_id ? { selected_channel_id: summary.selected_channel_id } : {}),
|
| 497 |
+
...(summary.channel_request_mode ? { channel_request_mode: summary.channel_request_mode } : {}),
|
| 498 |
+
...summary
|
| 499 |
+
};
|
| 500 |
+
} finally {
|
| 501 |
+
removeNewRealSmokeOutputFiles(outputFilesBefore);
|
| 502 |
+
}
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
async function runBillableCase(routeHandlers, testCase, target, startedAt, signal) {
|
| 506 |
const outputFilesBefore = snapshotRealSmokeOutputFiles();
|
| 507 |
if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') {
|
|
|
|
| 577 |
requiresResponsesModel,
|
| 578 |
baseUrl: serverBaseUrl?.value,
|
| 579 |
baseUrlKey: serverBaseUrl?.key,
|
| 580 |
+
serviceBaseUrl:
|
| 581 |
+
env(`${basePrefix}_SERVICE_BASE_URL`) ||
|
| 582 |
+
env('GPT_IMAGE_PLAYGROUND_URL') ||
|
| 583 |
+
'http://127.0.0.1:4783',
|
| 584 |
hasServerCredential: Boolean(env('OPENAI_API_KEY') || readFirstConfiguredServerApiKeys()),
|
| 585 |
model: env(`${basePrefix}_MODEL`) || 'gpt-image-2',
|
| 586 |
responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'),
|
|
|
|
| 816 |
formData.append('stream', 'true');
|
| 817 |
formData.append('partial_images', '2');
|
| 818 |
}
|
| 819 |
+
const serviceBaseUrl = target.serverChannel ? target.serviceBaseUrl : 'http://localhost';
|
| 820 |
+
return new Request(`${serviceBaseUrl}/api/images`, { method: 'POST', body: formData, signal });
|
| 821 |
}
|
| 822 |
|
| 823 |
function agentGenerateRequest(testCase, target, signal) {
|
| 824 |
+
const serviceBaseUrl = target.serverChannel ? target.serviceBaseUrl : 'http://localhost';
|
| 825 |
+
return new Request(`${serviceBaseUrl}/api/agent/images/generate`, {
|
| 826 |
method: 'POST',
|
| 827 |
headers: buildAgentRequestHeaders(testCase),
|
| 828 |
body: JSON.stringify({
|
|
|
|
| 910 |
async function summarizeAgentResponse(response) {
|
| 911 |
const contentType = response.headers.get('content-type') || '';
|
| 912 |
const body = safeJson(await response.text());
|
| 913 |
+
const execution = body?.execution;
|
| 914 |
return {
|
| 915 |
content_type: contentType || undefined,
|
| 916 |
image_count: Array.isArray(body?.images) ? body.images.length : 0,
|
| 917 |
+
artifact_ids: Array.isArray(body?.images)
|
| 918 |
+
? body.images.map((image) => image?.id).filter((id) => typeof id === 'string')
|
| 919 |
+
: [],
|
| 920 |
first_content_url: typeof body?.images?.[0]?.content_url === 'string' ? body.images[0].content_url : undefined,
|
| 921 |
has_inline_base64: Boolean(body?.images?.[0]?.b64_json),
|
| 922 |
+
...(typeof execution?.selected_channel_id === 'string'
|
| 923 |
+
? { selected_channel_id: execution.selected_channel_id }
|
| 924 |
+
: {}),
|
| 925 |
+
...(typeof execution?.upstream_host === 'string' ? { upstream_host: execution.upstream_host } : {}),
|
| 926 |
+
...(typeof execution?.channel_request_mode === 'string'
|
| 927 |
+
? {
|
| 928 |
+
channel_request_mode: execution.channel_request_mode,
|
| 929 |
+
selected_request_mode: execution.channel_request_mode
|
| 930 |
+
}
|
| 931 |
+
: {}),
|
| 932 |
...(body?.error ? { error: String(body.error.message || body.error) } : {})
|
| 933 |
};
|
| 934 |
}
|
| 935 |
|
| 936 |
+
async function deleteHttpAgentArtifacts(target, artifactIds) {
|
| 937 |
+
if (!target.serverChannel || !Array.isArray(artifactIds) || artifactIds.length === 0) return;
|
| 938 |
+
const headers = readAgentAuthHeaders();
|
| 939 |
+
for (const artifactId of artifactIds) {
|
| 940 |
+
const response = await fetch(
|
| 941 |
+
`${target.serviceBaseUrl}/api/agent/artifacts/${encodeURIComponent(artifactId)}`,
|
| 942 |
+
{ method: 'DELETE', headers }
|
| 943 |
+
);
|
| 944 |
+
if (!response.ok && response.status !== 404) {
|
| 945 |
+
throw new Error(`清理 Docker smoke 产物失败:HTTP ${response.status}。`);
|
| 946 |
+
}
|
| 947 |
+
}
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
function readFirstB64Length(images) {
|
| 951 |
if (!Array.isArray(images) || typeof images[0]?.b64_json !== 'string') return 0;
|
| 952 |
return images[0].b64_json.length;
|
|
|
|
| 1005 |
|
| 1006 |
可选 --case:all、original-images-json、gaoren-images-sse、sub2api-images-sse、sub2api-responses-json、gpt2image-responses-sse、matsca-images-sse。
|
| 1007 |
也可直接用 request mode 别名 images-json、images-sse、responses-json、responses-sse;同一个 request mode 可能命中多个 case。
|
| 1008 |
+
添加 --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。
|
| 1009 |
默认只检查配置并跳过真实生图;必须加 --allow-billable 才会调用 /api/images 或 /api/agent/images/generate。
|
| 1010 |
可用 --env-file 指向独立真实上游凭据文件;shell 环境变量优先级高于 --env-file,--env-file 优先级高于 .env.local。
|
| 1011 |
可用 --env-file-if-exists 在凭据文件存在时加载,不存在时继续输出结构化 readiness 报告。
|
scripts/smoke-image-upstream-real.test.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import assert from 'node:assert/strict';
|
|
|
|
| 2 |
import { spawn, spawnSync } from 'node:child_process';
|
| 3 |
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
| 4 |
import { createServer } from 'node:http';
|
|
@@ -121,7 +122,7 @@ describe('image upstream real smoke script', () => {
|
|
| 121 |
});
|
| 122 |
|
| 123 |
it('loads independent real upstream targets from an explicit env file without leaking API keys', () => {
|
| 124 |
-
const envFilePath =
|
| 125 |
try {
|
| 126 |
writeEnvFile(
|
| 127 |
envFilePath,
|
|
@@ -144,7 +145,7 @@ describe('image upstream real smoke script', () => {
|
|
| 144 |
});
|
| 145 |
|
| 146 |
it('keeps shell environment values ahead of explicit env file values', () => {
|
| 147 |
-
const envFilePath =
|
| 148 |
try {
|
| 149 |
writeEnvFile(
|
| 150 |
envFilePath,
|
|
@@ -571,6 +572,7 @@ describe('image upstream real smoke script', () => {
|
|
| 571 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 572 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 573 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse',
|
|
|
|
| 574 |
AGENT_API_TOKEN: 'secret-agent-token'
|
| 575 |
},
|
| 576 |
{ signal: t.signal }
|
|
@@ -581,6 +583,9 @@ describe('image upstream real smoke script', () => {
|
|
| 581 |
const report = JSON.parse(result.stdout);
|
| 582 |
assert.equal(report.results[0].status, 200);
|
| 583 |
assert.equal(report.results[0].image_count, 1);
|
|
|
|
|
|
|
|
|
|
| 584 |
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 585 |
} finally {
|
| 586 |
await upstream.close();
|
|
@@ -600,6 +605,7 @@ describe('image upstream real smoke script', () => {
|
|
| 600 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 601 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 602 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse',
|
|
|
|
| 603 |
APP_PASSWORD: 'page-access-code'
|
| 604 |
},
|
| 605 |
{ signal: t.signal }
|
|
@@ -610,6 +616,9 @@ describe('image upstream real smoke script', () => {
|
|
| 610 |
const report = JSON.parse(result.stdout);
|
| 611 |
assert.equal(report.results[0].status, 200);
|
| 612 |
assert.equal(report.results[0].image_count, 1);
|
|
|
|
|
|
|
|
|
|
| 613 |
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 614 |
} finally {
|
| 615 |
await upstream.close();
|
|
@@ -630,7 +639,8 @@ describe('image upstream real smoke script', () => {
|
|
| 630 |
{
|
| 631 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 632 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 633 |
-
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse'
|
|
|
|
| 634 |
},
|
| 635 |
{ signal: t.signal }
|
| 636 |
);
|
|
@@ -641,6 +651,9 @@ describe('image upstream real smoke script', () => {
|
|
| 641 |
assert.equal(report.ok, true);
|
| 642 |
assert.equal(report.final_gate_satisfied, false);
|
| 643 |
assert.equal(report.results[0].image_count, 1);
|
|
|
|
|
|
|
|
|
|
| 644 |
after = listRealSmokeFiles();
|
| 645 |
assert.deepEqual(diffFiles(before, after), []);
|
| 646 |
} finally {
|
|
@@ -664,6 +677,7 @@ describe('image upstream real smoke script', () => {
|
|
| 664 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 665 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 666 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream',
|
|
|
|
| 667 |
IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-5.4'
|
| 668 |
},
|
| 669 |
{ signal: t.signal }
|
|
@@ -809,7 +823,7 @@ describe('image upstream real smoke script', () => {
|
|
| 809 |
});
|
| 810 |
|
| 811 |
it('lets the npm smoke script pass --env-file through to the smoke script', () => {
|
| 812 |
-
const missingEnvFilePath =
|
| 813 |
rmSync(missingEnvFilePath, { force: true });
|
| 814 |
|
| 815 |
const result = spawnSync('npm', ['run', 'smoke:image-upstream-real', '--', '--env-file', missingEnvFilePath], {
|
|
@@ -825,7 +839,7 @@ describe('image upstream real smoke script', () => {
|
|
| 825 |
});
|
| 826 |
|
| 827 |
it('lets the npm final gate report readiness when the optional env file is absent', () => {
|
| 828 |
-
const missingEnvFilePath =
|
| 829 |
rmSync(missingEnvFilePath, { force: true });
|
| 830 |
|
| 831 |
const result = spawnSync(
|
|
@@ -1006,6 +1020,10 @@ function writeEnvFile(filepath, content) {
|
|
| 1006 |
writeFileSync(filepath, `${content}\n`, 'utf8');
|
| 1007 |
}
|
| 1008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
function buildScriptEnv(env = {}) {
|
| 1010 |
const scriptEnv = { ...process.env };
|
| 1011 |
for (const key of Object.keys(scriptEnv)) {
|
|
|
|
| 1 |
import assert from 'node:assert/strict';
|
| 2 |
+
import crypto from 'node:crypto';
|
| 3 |
import { spawn, spawnSync } from 'node:child_process';
|
| 4 |
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
| 5 |
import { createServer } from 'node:http';
|
|
|
|
| 122 |
});
|
| 123 |
|
| 124 |
it('loads independent real upstream targets from an explicit env file without leaking API keys', () => {
|
| 125 |
+
const envFilePath = uniqueSmokeEnvPath('real-smoke-test');
|
| 126 |
try {
|
| 127 |
writeEnvFile(
|
| 128 |
envFilePath,
|
|
|
|
| 145 |
});
|
| 146 |
|
| 147 |
it('keeps shell environment values ahead of explicit env file values', () => {
|
| 148 |
+
const envFilePath = uniqueSmokeEnvPath('real-smoke-test');
|
| 149 |
try {
|
| 150 |
writeEnvFile(
|
| 151 |
envFilePath,
|
|
|
|
| 572 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 573 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 574 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse',
|
| 575 |
+
IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process',
|
| 576 |
AGENT_API_TOKEN: 'secret-agent-token'
|
| 577 |
},
|
| 578 |
{ signal: t.signal }
|
|
|
|
| 583 |
const report = JSON.parse(result.stdout);
|
| 584 |
assert.equal(report.results[0].status, 200);
|
| 585 |
assert.equal(report.results[0].image_count, 1);
|
| 586 |
+
assert.equal(report.results[0].selected_channel_id, 'channel-1');
|
| 587 |
+
assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host);
|
| 588 |
+
assert.equal(report.results[0].channel_request_mode, 'images-sse');
|
| 589 |
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 590 |
} finally {
|
| 591 |
await upstream.close();
|
|
|
|
| 605 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 606 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 607 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse',
|
| 608 |
+
IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process',
|
| 609 |
APP_PASSWORD: 'page-access-code'
|
| 610 |
},
|
| 611 |
{ signal: t.signal }
|
|
|
|
| 616 |
const report = JSON.parse(result.stdout);
|
| 617 |
assert.equal(report.results[0].status, 200);
|
| 618 |
assert.equal(report.results[0].image_count, 1);
|
| 619 |
+
assert.equal(report.results[0].selected_channel_id, 'channel-1');
|
| 620 |
+
assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host);
|
| 621 |
+
assert.equal(report.results[0].channel_request_mode, 'images-sse');
|
| 622 |
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 623 |
} finally {
|
| 624 |
await upstream.close();
|
|
|
|
| 639 |
{
|
| 640 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 641 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 642 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-sse',
|
| 643 |
+
IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process'
|
| 644 |
},
|
| 645 |
{ signal: t.signal }
|
| 646 |
);
|
|
|
|
| 651 |
assert.equal(report.ok, true);
|
| 652 |
assert.equal(report.final_gate_satisfied, false);
|
| 653 |
assert.equal(report.results[0].image_count, 1);
|
| 654 |
+
assert.equal(report.results[0].selected_channel_id, 'channel-1');
|
| 655 |
+
assert.equal(report.results[0].upstream_host, new URL(upstream.baseUrl).host);
|
| 656 |
+
assert.equal(report.results[0].channel_request_mode, 'images-sse');
|
| 657 |
after = listRealSmokeFiles();
|
| 658 |
assert.deepEqual(diffFiles(before, after), []);
|
| 659 |
} finally {
|
|
|
|
| 677 |
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 678 |
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 679 |
OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-non-stream',
|
| 680 |
+
IMAGE_REAL_SMOKE_SERVER_TRANSPORT: 'in-process',
|
| 681 |
IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-5.4'
|
| 682 |
},
|
| 683 |
{ signal: t.signal }
|
|
|
|
| 823 |
});
|
| 824 |
|
| 825 |
it('lets the npm smoke script pass --env-file through to the smoke script', () => {
|
| 826 |
+
const missingEnvFilePath = uniqueSmokeEnvPath('missing-real-smoke');
|
| 827 |
rmSync(missingEnvFilePath, { force: true });
|
| 828 |
|
| 829 |
const result = spawnSync('npm', ['run', 'smoke:image-upstream-real', '--', '--env-file', missingEnvFilePath], {
|
|
|
|
| 839 |
});
|
| 840 |
|
| 841 |
it('lets the npm final gate report readiness when the optional env file is absent', () => {
|
| 842 |
+
const missingEnvFilePath = uniqueSmokeEnvPath('missing-real-smoke');
|
| 843 |
rmSync(missingEnvFilePath, { force: true });
|
| 844 |
|
| 845 |
const result = spawnSync(
|
|
|
|
| 1020 |
writeFileSync(filepath, `${content}\n`, 'utf8');
|
| 1021 |
}
|
| 1022 |
|
| 1023 |
+
function uniqueSmokeEnvPath(prefix) {
|
| 1024 |
+
return join(repoRoot, 'generated-images', `.${prefix}-${process.pid}-${crypto.randomUUID()}.env`);
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
function buildScriptEnv(env = {}) {
|
| 1028 |
const scriptEnv = { ...process.env };
|
| 1029 |
for (const key of Object.keys(scriptEnv)) {
|
scripts/status.mjs
CHANGED
|
@@ -342,7 +342,7 @@ async function buildLocalStatus() {
|
|
| 342 |
},
|
| 343 |
agent: {
|
| 344 |
capabilities: '/api/agent/capabilities',
|
| 345 |
-
skill: 'skills/
|
| 346 |
},
|
| 347 |
image_upstream_real_smoke: {
|
| 348 |
...imageUpstreamRealSmoke,
|
|
|
|
| 342 |
},
|
| 343 |
agent: {
|
| 344 |
capabilities: '/api/agent/capabilities',
|
| 345 |
+
skill: 'skills/visual-journal-image-agent/SKILL.md'
|
| 346 |
},
|
| 347 |
image_upstream_real_smoke: {
|
| 348 |
...imageUpstreamRealSmoke,
|
skills/gpt-image-playground-agent/agents/openai.yaml
DELETED
|
@@ -1,4 +0,0 @@
|
|
| 1 |
-
interface:
|
| 2 |
-
display_name: "Visual Journal Agent API"
|
| 3 |
-
short_description: "用图像手记内置脚本生成图片、诊断服务并验证上游渠道能力矩阵"
|
| 4 |
-
default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs
DELETED
|
@@ -1,304 +0,0 @@
|
|
| 1 |
-
import { dirname, join, resolve } from 'node:path';
|
| 2 |
-
import { existsSync, readFileSync } from 'node:fs';
|
| 3 |
-
|
| 4 |
-
const MAX_RETRY_AFTER_SECONDS = 60;
|
| 5 |
-
const DIGITS_PATTERN = /^\d+$/;
|
| 6 |
-
const IMAGE_SIZE_PATTERN = /^(\d+)x(\d+)$/;
|
| 7 |
-
const LEGACY_IMAGE_SIZES = new Set(['auto', '1024x1024', '1536x1024', '1024x1536']);
|
| 8 |
-
export const DEFAULT_PLAYGROUND_BASE_URL = 'http://localhost:4783';
|
| 9 |
-
const DEFAULT_PRIVATE_AGENT_ENV_FILE = '.env.agent.local';
|
| 10 |
-
const PRIVATE_AGENT_ENV_PREFIX = 'GPT_IMAGE_';
|
| 11 |
-
const DISABLE_PRIVATE_AGENT_ENV_VALUES = new Set(['0', 'false', 'no']);
|
| 12 |
-
|
| 13 |
-
export function loadPrivateAgentEnvFile(options = {}) {
|
| 14 |
-
const env = options.env || process.env;
|
| 15 |
-
if (isPrivateAgentEnvLoadingDisabled(env)) {
|
| 16 |
-
return { loaded: false, skipped: true, reason: 'disabled_by_env' };
|
| 17 |
-
}
|
| 18 |
-
const cwd = options.cwd || process.cwd();
|
| 19 |
-
const filePath = options.filePath || findPrivateAgentEnvFile(cwd);
|
| 20 |
-
if (!existsSync(filePath)) {
|
| 21 |
-
return { loaded: false, skipped: true, reason: 'file_not_found', path: filePath };
|
| 22 |
-
}
|
| 23 |
-
const entries = parsePrivateAgentEnvContent(readFileSync(filePath, 'utf8'));
|
| 24 |
-
const appliedNames = [];
|
| 25 |
-
for (const { name, value } of entries) {
|
| 26 |
-
if (!name.startsWith(PRIVATE_AGENT_ENV_PREFIX)) continue;
|
| 27 |
-
if (env[name] !== undefined) continue;
|
| 28 |
-
env[name] = value;
|
| 29 |
-
appliedNames.push(name);
|
| 30 |
-
}
|
| 31 |
-
return {
|
| 32 |
-
loaded: true,
|
| 33 |
-
path: filePath,
|
| 34 |
-
applied_names: appliedNames
|
| 35 |
-
};
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
export function readOptionValue(argv, index, name) {
|
| 39 |
-
const value = argv[index];
|
| 40 |
-
if (!value || value.startsWith('--')) {
|
| 41 |
-
throw new Error(`${name} 需要参数值。`);
|
| 42 |
-
}
|
| 43 |
-
return value;
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
export function readConfiguredPositiveInteger(value, name, fallback) {
|
| 47 |
-
const rawValue = value === undefined || value === null ? '' : String(value).trim();
|
| 48 |
-
if (!rawValue) return fallback;
|
| 49 |
-
if (!DIGITS_PATTERN.test(rawValue)) {
|
| 50 |
-
throw new Error(`${name} 必须是正整数。`);
|
| 51 |
-
}
|
| 52 |
-
const parsed = Number(rawValue);
|
| 53 |
-
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
| 54 |
-
throw new Error(`${name} 必须是正整数。`);
|
| 55 |
-
}
|
| 56 |
-
return parsed;
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
export function readConfiguredNonNegativeInteger(value, name, fallback) {
|
| 60 |
-
const rawValue = value === undefined || value === null ? '' : String(value).trim();
|
| 61 |
-
if (!rawValue) return fallback;
|
| 62 |
-
if (!DIGITS_PATTERN.test(rawValue)) {
|
| 63 |
-
throw new Error(`${name} 必须是非负整数。`);
|
| 64 |
-
}
|
| 65 |
-
const parsed = Number(rawValue);
|
| 66 |
-
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
| 67 |
-
throw new Error(`${name} 必须是非负整数。`);
|
| 68 |
-
}
|
| 69 |
-
return parsed;
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
export function readCapabilitiesImageTransportTimeoutMs(capabilities, fallback) {
|
| 73 |
-
const value = capabilities?.image_transport?.upstream_timeout_ms;
|
| 74 |
-
if (!Number.isSafeInteger(value) || value < 1) return fallback;
|
| 75 |
-
return Math.max(fallback, value);
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
export function readPartialImages(value, name = 'partial_images') {
|
| 79 |
-
const parsed = readConfiguredNonNegativeInteger(value, name, 2);
|
| 80 |
-
if (parsed < 0 || parsed > 4) {
|
| 81 |
-
throw new Error(`${name} 必须是 0 到 4 的整数。`);
|
| 82 |
-
}
|
| 83 |
-
return parsed;
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
export function validateAgentGenerateRequestAgainstCapabilities(body, capabilities) {
|
| 87 |
-
assertNumberWithinCapabilities(body.n, capabilities?.limits?.generate_images, 'n');
|
| 88 |
-
assertNumberWithinCapabilities(
|
| 89 |
-
body.partial_images,
|
| 90 |
-
readPartialImagesLimitForBackend(body.image_backend ?? body.imageBackend, capabilities),
|
| 91 |
-
'partial_images'
|
| 92 |
-
);
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
export function validateAgentEditRequestAgainstCapabilities(input, capabilities) {
|
| 96 |
-
assertNumberWithinCapabilities(input.n, capabilities?.limits?.edit_images, 'n');
|
| 97 |
-
assertNumberWithinCapabilities(
|
| 98 |
-
input.partial_images,
|
| 99 |
-
readPartialImagesLimitForBackend(input.image_backend ?? input.imageBackend, capabilities),
|
| 100 |
-
'partial_images'
|
| 101 |
-
);
|
| 102 |
-
assertMaxCountWithinCapabilities(input.imageCount, capabilities?.limits?.upload_images?.max, 'image');
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
function readPartialImagesLimitForBackend(backend, capabilities) {
|
| 106 |
-
const normalizedBackend =
|
| 107 |
-
backend === 'responses' || backend === 'responses-image-generation' ? 'responses-image-generation' : 'images-api';
|
| 108 |
-
return capabilities?.limits?.partial_images_by_backend?.[normalizedBackend] || capabilities?.limits?.partial_images;
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
function assertNumberWithinCapabilities(value, limits, fieldName) {
|
| 112 |
-
if (value === undefined || value === null || !limits) return;
|
| 113 |
-
const min = limits.min;
|
| 114 |
-
const max = limits.max;
|
| 115 |
-
if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) return;
|
| 116 |
-
if (value < min || value > max) {
|
| 117 |
-
throw new Error(`${fieldName} 必须在当前 capabilities 允许的 ${min} 到 ${max} 之间。`);
|
| 118 |
-
}
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
function assertMaxCountWithinCapabilities(value, max, fieldName) {
|
| 122 |
-
if (value === undefined || value === null || !Number.isSafeInteger(max)) return;
|
| 123 |
-
if (value > max) {
|
| 124 |
-
throw new Error(`${fieldName} 数量不能超过当前 capabilities 允许的 ${max}。`);
|
| 125 |
-
}
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
export function normalizeBaseUrl(value) {
|
| 129 |
-
const normalized = String(value || '').trim().replace(/\/+$/, '');
|
| 130 |
-
let parsed;
|
| 131 |
-
try {
|
| 132 |
-
parsed = new URL(normalized);
|
| 133 |
-
} catch {
|
| 134 |
-
throw new Error('base URL 必须是有效的 http/https 绝对 URL。');
|
| 135 |
-
}
|
| 136 |
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
| 137 |
-
throw new Error('base URL 必须使用 http 或 https。');
|
| 138 |
-
}
|
| 139 |
-
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
| 140 |
-
throw new Error('base URL 不能包含凭据、查询参数或片段。');
|
| 141 |
-
}
|
| 142 |
-
return normalized;
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
export function resolvePlaygroundBaseUrl(explicitBaseUrl, env = process.env) {
|
| 146 |
-
if (explicitBaseUrl) {
|
| 147 |
-
return {
|
| 148 |
-
baseUrl: normalizeBaseUrl(explicitBaseUrl),
|
| 149 |
-
source: 'user_provided',
|
| 150 |
-
interactive_confirmation_required: false
|
| 151 |
-
};
|
| 152 |
-
}
|
| 153 |
-
if (env.GPT_IMAGE_PLAYGROUND_URL) {
|
| 154 |
-
return {
|
| 155 |
-
baseUrl: normalizeBaseUrl(env.GPT_IMAGE_PLAYGROUND_URL),
|
| 156 |
-
source: 'GPT_IMAGE_PLAYGROUND_URL',
|
| 157 |
-
interactive_confirmation_required: true
|
| 158 |
-
};
|
| 159 |
-
}
|
| 160 |
-
return {
|
| 161 |
-
baseUrl: DEFAULT_PLAYGROUND_BASE_URL,
|
| 162 |
-
source: 'default_local_probe',
|
| 163 |
-
interactive_confirmation_required: true
|
| 164 |
-
};
|
| 165 |
-
}
|
| 166 |
-
|
| 167 |
-
export function normalizeOutputFormat(value) {
|
| 168 |
-
return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
|
| 169 |
-
}
|
| 170 |
-
|
| 171 |
-
export function assertValidImageSizeForModel(value, model, label = 'size') {
|
| 172 |
-
if (typeof value !== 'string' || value.trim().length === 0) {
|
| 173 |
-
throw new Error(`${label} 必须是字符串。`);
|
| 174 |
-
}
|
| 175 |
-
if (model !== 'gpt-image-2') {
|
| 176 |
-
if (!LEGACY_IMAGE_SIZES.has(value)) {
|
| 177 |
-
throw new Error(`${label} 对 ${model} 无效;非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536。`);
|
| 178 |
-
}
|
| 179 |
-
return value;
|
| 180 |
-
}
|
| 181 |
-
if (value === 'auto') return value;
|
| 182 |
-
const size = parseImageSizeValue(value);
|
| 183 |
-
if (!size) throw new Error(`${label} 必须是 auto 或 WIDTHxHEIGHT。`);
|
| 184 |
-
assertPositiveIntegerDimensions(size.width, size.height, label);
|
| 185 |
-
return value;
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
export function parseImageSizeValue(value) {
|
| 189 |
-
if (typeof value !== 'string') return undefined;
|
| 190 |
-
const match = IMAGE_SIZE_PATTERN.exec(value);
|
| 191 |
-
return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined;
|
| 192 |
-
}
|
| 193 |
-
|
| 194 |
-
export function readMaxImageEdge(value) {
|
| 195 |
-
const size = parseImageSizeValue(value);
|
| 196 |
-
return size ? Math.max(size.width, size.height) : 0;
|
| 197 |
-
}
|
| 198 |
-
|
| 199 |
-
export function parseRetryAfterValue(value, fallback = 1) {
|
| 200 |
-
if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback);
|
| 201 |
-
const parsed = Number(value);
|
| 202 |
-
if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS;
|
| 203 |
-
return clampRetryAfterSeconds(parsed);
|
| 204 |
-
}
|
| 205 |
-
|
| 206 |
-
export function sleep(seconds) {
|
| 207 |
-
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
function clampRetryAfterSeconds(value) {
|
| 211 |
-
if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS;
|
| 212 |
-
return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value)));
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
export function resolveSameOriginUrl(baseUrl, value, label) {
|
| 216 |
-
const base = new URL(baseUrl);
|
| 217 |
-
const resolved = new URL(value, `${baseUrl}/`);
|
| 218 |
-
if (resolved.origin !== base.origin) {
|
| 219 |
-
throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`);
|
| 220 |
-
}
|
| 221 |
-
return resolved.toString();
|
| 222 |
-
}
|
| 223 |
-
|
| 224 |
-
export function errorMessage(error) {
|
| 225 |
-
return error instanceof Error ? error.message : String(error);
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
function isPrivateAgentEnvLoadingDisabled(env) {
|
| 229 |
-
return DISABLE_PRIVATE_AGENT_ENV_VALUES.has(String(env.GPT_IMAGE_AGENT_LOAD_ENV_FILE || '').trim().toLowerCase());
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
function findPrivateAgentEnvFile(cwd) {
|
| 233 |
-
const start = resolve(cwd);
|
| 234 |
-
let current = start;
|
| 235 |
-
while (true) {
|
| 236 |
-
const candidate = join(current, DEFAULT_PRIVATE_AGENT_ENV_FILE);
|
| 237 |
-
if (existsSync(candidate)) return candidate;
|
| 238 |
-
if (isPrivateAgentEnvSearchBoundary(current) || dirname(current) === current) {
|
| 239 |
-
return join(start, DEFAULT_PRIVATE_AGENT_ENV_FILE);
|
| 240 |
-
}
|
| 241 |
-
current = dirname(current);
|
| 242 |
-
}
|
| 243 |
-
}
|
| 244 |
-
|
| 245 |
-
function isPrivateAgentEnvSearchBoundary(directory) {
|
| 246 |
-
return existsSync(join(directory, '.git')) || isPlaygroundProjectRoot(directory) || isStandaloneSkillRoot(directory);
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
function isPlaygroundProjectRoot(directory) {
|
| 250 |
-
return existsSync(join(directory, 'package.json')) && existsSync(join(directory, 'skills/gpt-image-playground-agent/SKILL.md'));
|
| 251 |
-
}
|
| 252 |
-
|
| 253 |
-
function isStandaloneSkillRoot(directory) {
|
| 254 |
-
return (
|
| 255 |
-
existsSync(join(directory, 'SKILL.md')) &&
|
| 256 |
-
existsSync(join(directory, 'scripts')) &&
|
| 257 |
-
!isPlaygroundProjectRoot(dirname(dirname(directory)))
|
| 258 |
-
);
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
function parsePrivateAgentEnvContent(content) {
|
| 262 |
-
const entries = [];
|
| 263 |
-
for (const line of content.split(/\r?\n/)) {
|
| 264 |
-
const parsed = parsePrivateAgentEnvLine(line);
|
| 265 |
-
if (parsed) entries.push(parsed);
|
| 266 |
-
}
|
| 267 |
-
return entries;
|
| 268 |
-
}
|
| 269 |
-
|
| 270 |
-
function parsePrivateAgentEnvLine(line) {
|
| 271 |
-
const trimmed = line.trim();
|
| 272 |
-
if (!trimmed || trimmed.startsWith('#')) return undefined;
|
| 273 |
-
const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
| 274 |
-
if (!match) return undefined;
|
| 275 |
-
return { name: match[1], value: parsePrivateAgentEnvValue(match[2].trim()) };
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
function parsePrivateAgentEnvValue(value) {
|
| 279 |
-
if (value.length < 2) return value;
|
| 280 |
-
if (value.startsWith('"') || value.startsWith("'")) return parseQuotedPrivateAgentEnvValue(value);
|
| 281 |
-
return stripPrivateAgentEnvComment(value).trim();
|
| 282 |
-
}
|
| 283 |
-
|
| 284 |
-
function parseQuotedPrivateAgentEnvValue(value) {
|
| 285 |
-
const quote = value[0];
|
| 286 |
-
const closeIndex = value.indexOf(quote, 1);
|
| 287 |
-
if (closeIndex < 0) return value.slice(1);
|
| 288 |
-
return value.slice(1, closeIndex);
|
| 289 |
-
}
|
| 290 |
-
|
| 291 |
-
function stripPrivateAgentEnvComment(value) {
|
| 292 |
-
const index = value.search(/\s#/);
|
| 293 |
-
if (index < 0) return value;
|
| 294 |
-
return value.slice(0, index);
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
function assertPositiveIntegerDimensions(width, height, label) {
|
| 298 |
-
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
| 299 |
-
throw new Error(`${label} 的宽度和高度必须是正数。`);
|
| 300 |
-
}
|
| 301 |
-
if (!Number.isInteger(width) || !Number.isInteger(height)) {
|
| 302 |
-
throw new Error(`${label} 的宽度和高度必须是整数。`);
|
| 303 |
-
}
|
| 304 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
skills/{gpt-image-playground-agent → visual-journal-image-agent}/SKILL.md
RENAMED
|
@@ -1,43 +1,45 @@
|
|
| 1 |
---
|
| 2 |
-
name:
|
| 3 |
-
description: 当用户需要通过已部署
|
| 4 |
---
|
| 5 |
|
| 6 |
# 图像手记(Visual Journal)Agent
|
| 7 |
|
| 8 |
-
通过用户已部署的图像手记(Visual Journal)生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本
|
| 9 |
|
| 10 |
Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平台。
|
| 11 |
|
|
|
|
|
|
|
| 12 |
## 脚本优先规则
|
| 13 |
|
| 14 |
- 生成单张或少量图片:优先运行 `scripts/generate-image.mjs`。
|
| 15 |
- 编辑图片:优先运行 `scripts/edit-image.mjs`。
|
| 16 |
-
- 批量
|
| 17 |
- 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
|
| 18 |
- 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
|
| 19 |
- 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。
|
| 20 |
-
- 诊断上游图片接口:优先运行 `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`。脚本也接受
|
| 21 |
-
- 对新上游完成固定四
|
| 22 |
-
- 不要临时编写
|
| 23 |
-
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下
|
| 24 |
-
- 先用
|
| 25 |
-
- 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游
|
| 26 |
-
- 新增
|
| 27 |
|
| 28 |
## 产品边界
|
| 29 |
|
| 30 |
Agent API 只作为自动化客户端接口,不作为首战场景或用户验证的主证明。首阶段产品判断以页面工作台上的真实发布任务、结果下载、继续编辑、复用和最近生成里的结果反馈为准。
|
| 31 |
|
| 32 |
-
结果反馈由页面工作台通过 `/api/feedback` 写入和清理,Agent 客户端通过 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback` 只读查询。日志查看的原始流仍是
|
| 33 |
|
| 34 |
-
渠道健康诊断使用 `GET /api/agent/diagnostics/channel-health` 和 Agent 鉴权。它只读当前服务进程已
|
| 35 |
|
| 36 |
## 路由规则
|
| 37 |
|
| 38 |
-
- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和
|
| 39 |
- `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 或非流式路径。
|
| 40 |
-
- `providerManifests[].manifest.executionSupport=declared_only` 表示
|
| 41 |
|
| 42 |
执行决策表:
|
| 43 |
|
|
@@ -50,9 +52,9 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
|
|
| 50 |
|
| 51 |
- 默认 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` 或下载后校验。
|
| 52 |
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
|
| 53 |
-
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过
|
| 54 |
- 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
|
| 55 |
-
- 复杂 UI、长
|
| 56 |
- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
|
| 57 |
- 普通单次文生图默认使用 `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。不要把这些显式开关当成默认自动路由。
|
| 58 |
- 单张文生图使用 `--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 只作为自动化客户端接口,不作为首战场景或用户验
|
|
| 74 |
12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;generate 只在显式 `--page-sse` 或页面工作台诊断时使用它,默认 WebP edit、Responses edit、长图恢复或需要原始 SSE 日志的 edit 仍可使用页面 SSE。
|
| 75 |
13. 读取 `agent_jobs` 只用于理解服务端编排结果和显式 `--job` 诊断路径。普通 generate 不再由 Agent 客户端根据本地/远端或尺寸选择 Agent JSON、page SSE 或 job。
|
| 76 |
14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。
|
| 77 |
-
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 的
|
| 78 |
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 ...`。
|
| 79 |
|
| 80 |
## 鉴权
|
| 81 |
|
| 82 |
-
Agent JSON、Agent
|
| 83 |
|
| 84 |
```text
|
| 85 |
Authorization: Bearer <token>
|
|
@@ -101,7 +103,7 @@ Authorization: Bearer <token>
|
|
| 101 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 102 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 103 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 104 |
-
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent
|
| 105 |
- 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。
|
| 106 |
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `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`。
|
| 107 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
|
@@ -109,9 +111,9 @@ Authorization: Bearer <token>
|
|
| 109 |
|
| 110 |
- 需要只读查看当前实例的渠道健康状态时,使用 `scripts/diagnose-channel-health.mjs --base-url ...`。脚本先从 capabilities 读取并交叉校验端点声明,再调用同源 Agent 端点;不猜测路径,不调用页面 `/api/runtime-capabilities`,不触发真实上游探测或图片生成。
|
| 111 |
|
| 112 |
-
##
|
| 113 |
|
| 114 |
-
默认
|
| 115 |
|
| 116 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 117 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
@@ -119,31 +121,33 @@ Authorization: Bearer <token>
|
|
| 119 |
|
| 120 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 121 |
|
| 122 |
-
当前
|
| 123 |
|
| 124 |
## 可用脚本
|
| 125 |
|
| 126 |
以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 127 |
|
| 128 |
-
- `scripts/generate-image.mjs`:文生图调用。默认
|
| 129 |
-
- `scripts/edit-image.mjs`:multipart 编辑调用。默认
|
| 130 |
-
- `scripts/batch-images.mjs`:JSONL 批量
|
| 131 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
|
| 132 |
-
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent
|
| 133 |
-
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。需要人工验收画质时,可在只选择单个
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
所有生成、编辑、批量和探针脚本在 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 完整性校验仍然生效。
|
| 137 |
|
| 138 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
| 139 |
|
| 140 |
- `npm run first-run`:首次配置就绪检查,只读、非计费、不写 env 文件;默认输出中文摘要,加 `-- --json` 输出机器可读 JSON;用于确认 Node、依赖、服务地址、Agent capabilities、当前进程鉴权和下一步动作。
|
| 141 |
- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
|
| 142 |
- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
|
| 143 |
-
- `npm run verify`:运行提交前基线;需要真实 PostgreSQL
|
| 144 |
- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效,加 `-- --postgres` 会断言 postgres/fs overlay 生效。PostgreSQL 模式要求在运行环境或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。
|
| 145 |
- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
|
| 146 |
-
- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent
|
| 147 |
|
| 148 |
首次配置和诊断输出字段速查:
|
| 149 |
|
|
@@ -158,7 +162,7 @@ Authorization: Bearer <token>
|
|
| 158 |
| `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
|
| 159 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 160 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 161 |
-
| `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认
|
| 162 |
| `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
|
| 163 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 164 |
| `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 <token>
|
|
| 172 |
生成脚本常用参数:
|
| 173 |
|
| 174 |
```text
|
| 175 |
-
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "
|
| 176 |
```
|
| 177 |
|
| 178 |
常用 preset 可先 dry-run 展开真实参数,不触发计费:
|
| 179 |
|
| 180 |
```text
|
| 181 |
-
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 1k-smoke-agent "
|
| 182 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-agent-nonstream "a cinematic landscape"
|
| 183 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-page-sse "a cinematic landscape"
|
| 184 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-upstream-sse-newapi "a cinematic landscape"
|
|
@@ -187,19 +191,19 @@ node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.
|
|
| 187 |
启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
|
| 188 |
|
| 189 |
```text
|
| 190 |
-
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --image-backend images-api --stream-mode auto --streaming-strategy newapi-keepalive-sse --partial-images 2 --size 3840x2160 --quality high "
|
| 191 |
```
|
| 192 |
|
| 193 |
真实生图必须显式开启:
|
| 194 |
|
| 195 |
```text
|
| 196 |
-
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --timeout-ms 420000 --size 2048x2048 "
|
| 197 |
```
|
| 198 |
|
| 199 |
创建浏览器可直接打开的分享链接:
|
| 200 |
|
| 201 |
```text
|
| 202 |
-
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --share --share-expires-minutes 1440 --size 2048x2048 "
|
| 203 |
```
|
| 204 |
|
| 205 |
本地格式转换不触发生图计费:
|
|
@@ -209,15 +213,15 @@ node "<skill-root>/scripts/convert-image-format.mjs" --format webp --quality 100
|
|
| 209 |
node "<skill-root>/scripts/convert-image-format.mjs" --format png --output ./source.png ./source.webp --overwrite
|
| 210 |
```
|
| 211 |
|
| 212 |
-
生成脚本默认把文生图业务意图提交到服务端编排入口 `/api/agent/image-requests`;服务端内部决定使用 Agent JSON、内部上游 SSE、
|
| 213 |
|
| 214 |
-
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` 范围。
|
| 215 |
|
| 216 |
批量脚本 JSONL 每行是一个 generate 或 edit 任务。示例:
|
| 217 |
|
| 218 |
```jsonl
|
| 219 |
-
{"id":"hero-01","mode":"generate","prompt":"
|
| 220 |
-
{"id":"edit-01","mode":"edit","prompt":"
|
| 221 |
```
|
| 222 |
|
| 223 |
默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务:
|
|
|
|
| 1 |
---
|
| 2 |
+
name: visual-journal-image-agent
|
| 3 |
+
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 调用脚本或手写请求。
|
| 4 |
---
|
| 5 |
|
| 6 |
# 图像手记(Visual Journal)Agent
|
| 7 |
|
| 8 |
+
通过用户已部署的图像手记(Visual Journal)生成、编辑、批量处理或诊断图片接口。不要假设服务一定在本机;不要模拟网页表单;优先运行本技能内置脚本,让脚本处理 Agent API 契约、能力声明、幂等键、服务端编排入口和产物 URL。
|
| 9 |
|
| 10 |
Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平台。
|
| 11 |
|
| 12 |
+
本项目的图片任务必须优先使用本技能,替代 Codex 内置的通用生图技能。通用技能不应绕过本项目的 Agent API、能力声明、渠道路由、幂等键和计费门禁。
|
| 13 |
+
|
| 14 |
## 脚本优先规则
|
| 15 |
|
| 16 |
- 生成单张或少量图片:优先运行 `scripts/generate-image.mjs`。
|
| 17 |
- 编辑图片:优先运行 `scripts/edit-image.mjs`。
|
| 18 |
+
- 批量生成或编辑:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和追加写入清单管理续跑。
|
| 19 |
- 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
|
| 20 |
- 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
|
| 21 |
- 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。
|
| 22 |
+
- 诊断上游图片接口:优先运行 `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`。
|
| 23 |
+
- 对新上游完成固定四种方式验证并准备可直接使用的私有配置:运行 `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`,不会重启服务或部署。
|
| 24 |
+
- 不要临时编写脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
|
| 25 |
+
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下临时调用脚本。
|
| 26 |
+
- 先用预演、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
|
| 27 |
+
- 真实调用成功或失败后,优先读取脚本输出的 `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 日志或上游后台。
|
| 28 |
+
- 新增探针、诊断、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行时 API 和 Agent API 的边界判断。
|
| 29 |
|
| 30 |
## 产品边界
|
| 31 |
|
| 32 |
Agent API 只作为自动化客户端接口,不作为首战场景或用户验证的主证明。首阶段产品判断以页面工作台上的真实发布任务、结果下载、继续编辑、复用和最近生成里的结果反馈为准。
|
| 33 |
|
| 34 |
+
结果反馈由页面工作台通过 `/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。
|
| 35 |
|
| 36 |
+
渠道健康诊断使用 `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`,后者仍是页面运行时和并发配置的摘要。
|
| 37 |
|
| 38 |
## 路由规则
|
| 39 |
|
| 40 |
+
- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和任务轮询。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或任务端点。
|
| 41 |
- `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 或非流式路径。
|
| 42 |
+
- `providerManifests[].manifest.executionSupport=declared_only` 表示清单声明了异步轮询,但当前执行器不会自动轮询 provider `poll` 配置。遇到 pending/poll_url 时按结构化诊断处理,不要把它当成可用同步请求方式。
|
| 43 |
|
| 44 |
执行决策表:
|
| 45 |
|
|
|
|
| 52 |
|
| 53 |
- 默认 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` 或下载后校验。
|
| 54 |
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
|
| 55 |
+
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过清单、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
|
| 56 |
- 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
|
| 57 |
+
- 复杂 UI、长提示词、高质量图生图遇到 5 分钟级超时��连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩提示词或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。
|
| 58 |
- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
|
| 59 |
- 普通单次文生图默认使用 `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。不要把这些显式开关当成默认自动路由。
|
| 60 |
- 单张文生图使用 `--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 做诊断。
|
|
|
|
| 76 |
12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;generate 只在显式 `--page-sse` 或页面工作台诊断时使用它,默认 WebP edit、Responses edit、长图恢复或需要原始 SSE 日志的 edit 仍可使用页面 SSE。
|
| 77 |
13. 读取 `agent_jobs` 只用于理解服务端编排结果和显式 `--job` 诊断路径。普通 generate 不再由 Agent 客户端根据本地/远端或尺寸选择 Agent JSON、page SSE 或 job。
|
| 78 |
14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。
|
| 79 |
+
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`。
|
| 80 |
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 ...`。
|
| 81 |
|
| 82 |
## 鉴权
|
| 83 |
|
| 84 |
+
Agent JSON、Agent 编辑、任务和产物端点的鉴权以 `auth.schemes` 为准。如果服务端配置了 `AGENT_API_TOKEN`,发送:
|
| 85 |
|
| 86 |
```text
|
| 87 |
Authorization: Bearer <token>
|
|
|
|
| 103 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 104 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 105 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 106 |
+
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent 生成和编辑对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终产物。
|
| 107 |
- 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。
|
| 108 |
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `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`。
|
| 109 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
|
|
|
| 111 |
|
| 112 |
- 需要只读查看当前实例的渠道健康状态时,使用 `scripts/diagnose-channel-health.mjs --base-url ...`。脚本先从 capabilities 读取并交叉校验端点声明,再调用同源 Agent 端点;不猜测路径,不调用页面 `/api/runtime-capabilities`,不触发真实上游探测或图片生成。
|
| 113 |
|
| 114 |
+
## 任务轮询
|
| 115 |
|
| 116 |
+
默认生成不直接调用任务端点;服务端编排入口会在内部使用任务轮询并返回 `job.result_url`。当 `agent_jobs.supported=true` 且需要显式诊断或兼容旧流程时,任务路径可使用:
|
| 117 |
|
| 118 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 119 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
|
|
| 121 |
|
| 122 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 123 |
|
| 124 |
+
当前任务轮询是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在任务结束前重启,客户端应按状态和错误码继续轮询或重新提交同一业务意图与同一 `Idempotency-Key`,避免重复业务操作。若任务已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建任务。
|
| 125 |
|
| 126 |
## 可用脚本
|
| 127 |
|
| 128 |
以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 129 |
|
| 130 |
+
- `scripts/generate-image.mjs`:文生图调用。默认预演(dry-run),不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。固定尺寸任务可添加 `--dimension-check`,从内联图片或同源产物 URL 读取 PNG/JPEG/WebP 尺寸并把上游尺寸偏差判为结构化验收失败。需要浏览器可直接打开的用户外链时添加 `--share`,可选 `--share-expires-minutes`;私密分享访问码从 `GPT_IMAGE_SHARE_ACCESS_CODE` 读取,不放进命令行参数。
|
| 131 |
+
- `scripts/edit-image.mjs`:multipart 编辑调用。默认预演(dry-run),不消耗额度;必须添加 `--allow-billable` 才会真实编辑。固定尺寸任务可添加 `--dimension-check`,脚本会从内联图片或同源产物 URL 读取 PNG/JPEG/WebP 尺寸,并把上游尺寸偏差判为结构化验收失败。
|
| 132 |
+
- `scripts/batch-images.mjs`:JSONL 批量生成或编辑调用。默认预演(dry-run),不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持仅追加清单、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。
|
| 133 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
|
| 134 |
+
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent 状态请求诊断;支持读取批量清单和 `--base-url`,不触发生图计费。
|
| 135 |
+
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。需要人工验收画质时,可在只选择单个请求方式的真实探测中添加 `--save-first-image <path>`,脚本只保存首个可消费最终图并输出尺寸和字节数,不打印图片 base64。遇到本机 DNS fake-IP 故障时,可显式传 `--connect-ip <IPv4-or-IPv6>` 仅覆盖这次探针的连接地址,原 URL 域名仍用于 Host 和 TLS SNI/证书校验。
|
| 136 |
+
|
| 137 |
+
生成、编辑和批量脚本的预演输出会包含 `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` 选路字段,便于人工审查。
|
| 138 |
+
所有生成、编辑、批量和探针脚本在预演或真实请求前都会校验尺寸参数。`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 完整性校验仍然生效。
|
| 139 |
|
| 140 |
+
capabilities 的 `upstream_request_headers.channels[]` 是逐渠道约束契约:`constraints` 声明数量、`partial_images`、编辑上传、背景和尺寸策略,`healthy_request_modes`(存在时)只列出当前健康凭证支持的 request mode。范围可能包含 `allowedValues`,客户端不能把离散值扩展成连续 min/max。省略 `partial_images` 时不要写入 `defaults.partial_images`,由服务端按最终健康且满足请求约束的渠道计算默认值;非流式请求仅校验公共 `0..4` 边界,不向上游发送该字段。
|
|
|
|
| 141 |
|
| 142 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
| 143 |
|
| 144 |
- `npm run first-run`:首次配置就绪检查,只读、非计费、不写 env 文件;默认输出中文摘要,加 `-- --json` 输出机器可读 JSON;用于确认 Node、依赖、服务地址、Agent capabilities、当前进程鉴权和下一步动作。
|
| 145 |
- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。
|
| 146 |
- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
|
| 147 |
+
- `npm run verify`:运行提交前基线;需要真实 PostgreSQL 门禁时加 `-- --postgres`。
|
| 148 |
- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效,加 `-- --postgres` 会断言 postgres/fs overlay 生效。PostgreSQL 模式要求在运行环境或 Compose `.env` 中提供 `GPT_IMAGE_POSTGRES_PASSWORD`。
|
| 149 |
- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
|
| 150 |
+
- `npm run agent:doctor`:执行非计费分层诊断,覆盖 capabilities、Agent 契约、运行时后端、状态后端和 Responses/GPT2Image 就绪状态;支持 `-- --base-url <url>`;真实 1K/2K 冒烟验证必须显式加 `-- --allow-billable`。
|
| 151 |
|
| 152 |
首次配置和诊断输出字段速查:
|
| 153 |
|
|
|
|
| 162 |
| `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
|
| 163 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 164 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 165 |
+
| `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认生成主链 `/api/agent/image-requests` 的真实冒烟状态;这是普通生成在服务端编排下的主编排口径。 |
|
| 166 |
| `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
|
| 167 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 168 |
| `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
|
|
|
| 176 |
生成脚本常用参数:
|
| 177 |
|
| 178 |
```text
|
| 179 |
+
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --size 2048x2048 --quality high --response-mode path --idempotency-key stable-operation-key "一张陶瓷杯的产品照片"
|
| 180 |
```
|
| 181 |
|
| 182 |
常用 preset 可先 dry-run 展开真实参数,不触发计费:
|
| 183 |
|
| 184 |
```text
|
| 185 |
+
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 1k-smoke-agent "一张陶瓷杯的产品照片"
|
| 186 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-agent-nonstream "a cinematic landscape"
|
| 187 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-page-sse "a cinematic landscape"
|
| 188 |
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --preset 4k-upstream-sse-newapi "a cinematic landscape"
|
|
|
|
| 191 |
启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
|
| 192 |
|
| 193 |
```text
|
| 194 |
+
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --image-backend images-api --stream-mode auto --streaming-strategy newapi-keepalive-sse --partial-images 2 --size 3840x2160 --quality high "一张陶瓷杯的产品照片"
|
| 195 |
```
|
| 196 |
|
| 197 |
真实生图必须显式开启:
|
| 198 |
|
| 199 |
```text
|
| 200 |
+
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --timeout-ms 420000 --size 2048x2048 "一张陶瓷杯的产品照片"
|
| 201 |
```
|
| 202 |
|
| 203 |
创建浏览器可直接打开的分享链接:
|
| 204 |
|
| 205 |
```text
|
| 206 |
+
node "<skill-root>/scripts/generate-image.mjs" --base-url https://your-space.hf.space --allow-billable --share --share-expires-minutes 1440 --size 2048x2048 "一张陶瓷杯的产品照片"
|
| 207 |
```
|
| 208 |
|
| 209 |
本地格式转换不触发生图计费:
|
|
|
|
| 213 |
node "<skill-root>/scripts/convert-image-format.mjs" --format png --output ./source.png ./source.webp --overwrite
|
| 214 |
```
|
| 215 |
|
| 216 |
+
生成脚本默认把文生图业务意图提交到服务端编排入口 `/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`。
|
| 217 |
|
| 218 |
+
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` 范围。
|
| 219 |
|
| 220 |
批量脚本 JSONL 每行是一个 generate 或 edit 任务。示例:
|
| 221 |
|
| 222 |
```jsonl
|
| 223 |
+
{"id":"hero-01","mode":"generate","prompt":"一张陶瓷杯的产品照片","size":"1024x1024","response_mode":"path"}
|
| 224 |
+
{"id":"edit-01","mode":"edit","prompt":"替换背景","image_path":"./source.png","size":"1024x1024","response_mode":"path"}
|
| 225 |
```
|
| 226 |
|
| 227 |
默认 dry-run 只解析 JSONL、生成稳定幂等键并输出计划,不请求服务:
|
skills/visual-journal-image-agent/agents/openai.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface:
|
| 2 |
+
display_name: "Visual Journal Image Agent API"
|
| 3 |
+
short_description: "用图像手记内置脚本生成图片、诊断服务并验证上游渠道能力矩阵"
|
| 4 |
+
default_prompt: "图片生成、编辑或诊断请使用 $visual-journal-image-agent,替代 Codex 内置的通用生图 Skill;先选择并运行内置脚本。新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。"
|
skills/{gpt-image-playground-agent → visual-journal-image-agent}/references/api.md
RENAMED
|
@@ -8,30 +8,30 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
|
|
| 8 |
|
| 9 |
- [辅助脚本](#辅助脚本)
|
| 10 |
- [能力查询](#能力查询)
|
| 11 |
-
- [
|
| 12 |
- [生成图片](#生成图片)
|
| 13 |
- [编辑图片](#编辑图片)
|
| 14 |
- [产物元数据](#产物元数据)
|
| 15 |
- [结果反馈与诊断](#结果反馈与诊断)
|
| 16 |
-
- [
|
| 17 |
- [错误](#错误)
|
| 18 |
|
| 19 |
## 辅助脚本
|
| 20 |
|
| 21 |
脚本位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径或仓库 checkout 路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 22 |
脚本必须通过 `node "<skill-root>/scripts/..."` 调用,以兼容 Windows、Linux 和 macOS;示例不要依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。
|
| 23 |
-
生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node
|
| 24 |
|
| 25 |
- `scripts/generate-image.mjs`:JSON 文生图调用。
|
| 26 |
- `scripts/edit-image.mjs`:multipart 编辑调用;固定尺寸任务可添加 `--dimension-check` 验收真实产物尺寸。
|
| 27 |
- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
|
| 28 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
|
| 29 |
-
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent
|
| 30 |
- `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。
|
| 31 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 32 |
- `scripts/channel-capability-matrix.mjs`:固定串行验证四种上游图片请求方式,并在真实验证通过后生成私有渠道 env 配置。
|
| 33 |
|
| 34 |
-
生成、编辑和批量脚本默认只做
|
| 35 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 36 |
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,会覆盖服务声明的默认编排入口和页面 SSE 边界,不触发真实生图或编辑。
|
| 37 |
位于仓库根目录且是首次配置、换机器、服务地址不确定或 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
|
|
| 50 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 51 |
页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
|
| 52 |
脚本会在 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`。
|
| 53 |
-
真实执行输出会包含机器可读 `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 鉴权的
|
| 54 |
|
| 55 |
生成脚本参数:
|
| 56 |
|
|
@@ -69,8 +69,8 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
|
|
| 69 |
- `--force-request`:生成意图字段,服务端在 Agent JSON、job 和页面 SSE 路径解释为 `force_request=true`,跳过本地 upstream profile 尺寸/背景限制,让真实上游接受或拒绝请求;鉴权、幂等键、费用确认、API URL 安全、渠道白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、上传文件和 mask 完整性仍由本服务校验。
|
| 70 |
- `--stream-mode`:可选,显式选择 `auto`、`stream` 或 `non_stream`。
|
| 71 |
- `--streaming-strategy`:可选,显式选择 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。
|
| 72 |
-
- `--partial-images`:可选,显式设置上游 SSE partial image 数量。generate 或页面 SSE 请求包含 `image_backend` 时优先按 capabilities 的 `limits.partial_images_by_backend[image_backend]` 校验;缺少 backend 专属范围时才使用 `limits.partial_images`。
|
| 73 |
-
- `--share`:真实生图成功后,为每个 Agent
|
| 74 |
- `--share-expires-minutes`:可选,设置分享有效期分钟数;省略时使用服务端默认值。
|
| 75 |
- `--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`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
|
| 76 |
- `GPT_IMAGE_SHARE_ACCESS_CODE`:可选,创建需要访问码的分享链接;访问码不会出现在返回 URL 中,也不会出现在命令行参数里。
|
|
@@ -79,7 +79,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
|
|
| 79 |
- `--idempotency-key`:指定稳定幂等键。
|
| 80 |
- `--page-sse`:诊断或兼容开关,强制使用页面端 `/api/images` form-data SSE。
|
| 81 |
- `--agent`:诊断或兼容开关,强制使用 `/api/agent/images/generate` 非流式 JSON。
|
| 82 |
-
- `--job`:诊断或兼容开关,强制使用 Agent
|
| 83 |
- `--dry-run`:只输出将要发送的 JSON。
|
| 84 |
- `--allow-billable`:允许真实调用生图端点。
|
| 85 |
- `--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
|
|
| 122 |
批量脚本参数:
|
| 123 |
|
| 124 |
- `--input`:JSONL 任务文件路径,也可作为唯一位置参数。
|
| 125 |
-
- `--manifest`:
|
| 126 |
-
- `--resume`:读取
|
| 127 |
- `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
|
| 128 |
- `--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`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
|
| 129 |
- `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
|
|
@@ -133,7 +133,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
|
|
| 133 |
- `--dry-run`
|
| 134 |
- `--allow-billable`
|
| 135 |
|
| 136 |
-
批量 dry-run 不写
|
| 137 |
|
| 138 |
批量 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
|
| 139 |
|
|
@@ -143,7 +143,7 @@ Responses edit JSONL 正例:
|
|
| 143 |
{
|
| 144 |
"id": "edit-responses",
|
| 145 |
"mode": "edit",
|
| 146 |
-
"prompt": "
|
| 147 |
"image_path": "source.png",
|
| 148 |
"image_backend": "responses-image-generation",
|
| 149 |
"streaming_strategy": "responses-sse",
|
|
@@ -159,7 +159,7 @@ dry-run 预期:`routing.transport=page_sse`、`endpoint=/api/images`、`reques
|
|
| 159 |
{
|
| 160 |
"id": "edit-responses-missing-backend",
|
| 161 |
"mode": "edit",
|
| 162 |
-
"prompt": "
|
| 163 |
"image_path": "source.png",
|
| 164 |
"responsesModel": "gpt-4.1"
|
| 165 |
}
|
|
@@ -244,9 +244,10 @@ GET /api/agent/capabilities
|
|
| 244 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
| 245 |
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
|
| 246 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
|
|
|
| 247 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
| 248 |
- `upstream_request_headers.channels[].upstream_proxy`:该渠道的有效上游代理摘要。`OPENAI_CHANNEL_N_PROXY_URL` 优先于 `OPENAI_UPSTREAM_PROXY_URL`;摘要只返回 `configured` 和 `protocol`,不返回代理地址或端口。
|
| 249 |
-
- `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`、默认低费用优先顺序、真实
|
| 250 |
- `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。
|
| 251 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 252 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
|
@@ -261,14 +262,15 @@ GET /api/agent/capabilities
|
|
| 261 |
- 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
|
| 262 |
- `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
|
| 263 |
|
| 264 |
-
新增
|
| 265 |
|
| 266 |
- `defaults.image_backend`:Agent generate 默认 `images-api`。
|
| 267 |
- `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
|
| 268 |
- `defaults.streaming_strategy`:Agent generate 默认 `auto`。
|
| 269 |
-
- `defaults.partial_images`:
|
| 270 |
- `upstream_profile`:当前运行时的上游能力摘要,包含 `activeProfile`、`serverProfile`、`serverProfileMixed`、`requestProfile` 与三组约束对象。
|
| 271 |
-
- `limits.generate_images` / `limits.edit_images` / `limits.upload_images`:当前运行时分别允许的生成张数、编辑输出张数和编辑源图数量范围。
|
|
|
|
| 272 |
- `limits.partial_images`:当前运行时默认 profile 允许的 `partial_images` 范围。OpenAI-compatible 通常为 `1..3`,Matsca Images API 通常为 `0..4`;Agent 必须以 capabilities 返回值为准。
|
| 273 |
- `limits.partial_images_by_backend`:按图片后端覆盖 `partial_images` 范围。选择 `responses-image-generation` 或兼容别名 `responses` 时必须优先使用该字段中的 `responses-image-generation` 范围,当前通常为 `1..3`。
|
| 274 |
- `supported.image_backends`:机器可读的图片后端枚举。
|
|
@@ -276,19 +278,19 @@ GET /api/agent/capabilities
|
|
| 276 |
- `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。
|
| 277 |
- `supported.streaming_strategies`:机器可读的流式兼容策略枚举。
|
| 278 |
- `supported.stream_modes`:机器可读的 `auto`、`stream`、`non_stream` 枚举。
|
| 279 |
-
- `agent_jobs.supported`:当前为 `true`,表示可使用
|
| 280 |
- `agent_jobs.mode`:当前为 `job_polling`。
|
| 281 |
- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
|
| 282 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 283 |
- `agent_request_diagnostics`:Agent state 请求诊断能力。`endpoints.lookup` 支持 `request_id` 或 `idempotency_key` 查询参数;`endpoints.single` 支持按 `request_id` 路径查询;`retention.ttl_seconds` 与 Agent request TTL 一致。
|
| 284 |
|
| 285 |
-
普通
|
| 286 |
|
| 287 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `visual-journal/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 288 |
|
| 289 |
上游代理同样由服务端统一执行:`OPENAI_UPSTREAM_PROXY_URL` 为全局默认值,`OPENAI_CHANNEL_N_PROXY_URL` 可覆盖单个渠道。它们只接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS;配置变更需重启或重新部署服务。代理适用于服务端上游 API、SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志,不影响 Agent 客户端到 Playground 的连接。
|
| 290 |
|
| 291 |
-
##
|
| 292 |
|
| 293 |
```http
|
| 294 |
POST /api/agent/jobs/images/generate
|
|
@@ -358,7 +360,7 @@ Content-Type: application/json
|
|
| 358 |
|
| 359 |
```json
|
| 360 |
{
|
| 361 |
-
"prompt": "
|
| 362 |
"model": "gpt-image-2",
|
| 363 |
"n": 1,
|
| 364 |
"size": "1024x1024",
|
|
@@ -424,7 +426,7 @@ Agent JSON 生成端点对外始终返回最终 JSON,不会对客户端返回
|
|
| 424 |
"selected_channel_id": "default",
|
| 425 |
"upstream_host": "api.example.test",
|
| 426 |
"request_headers": {
|
| 427 |
-
"user_agent_effective": "visual-journal/2.
|
| 428 |
"has_extra_headers": false,
|
| 429 |
"allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
|
| 430 |
"configured_header_names": []
|
|
@@ -473,7 +475,7 @@ DELETE /api/agent/artifacts/{id}
|
|
| 473 |
|
| 474 |
所有产物端点都需要和生成接口相同的鉴权。
|
| 475 |
|
| 476 |
-
`GET /api/agent/artifacts/{id}` 返回 Agent 产物元数据;`GET /content` 返回产物图片二进制;`POST /share` 为已有 Agent
|
| 477 |
|
| 478 |
Agent 创建分享链接的请求示例:
|
| 479 |
|
|
@@ -635,7 +637,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 635 |
| `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 636 |
| `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`。 |
|
| 637 |
| `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` 时只能证明配置可见,不能当作真实上游通过。 |
|
| 638 |
-
| `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`。
|
| 639 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 640 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 641 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
|
@@ -668,7 +670,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 668 |
"transport": "agent_json",
|
| 669 |
"endpoint": "/api/agent/images/generate",
|
| 670 |
"request_headers": {
|
| 671 |
-
"user_agent_effective": "visual-journal/2.
|
| 672 |
"has_extra_headers": false,
|
| 673 |
"allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
|
| 674 |
"configured_header_names": []
|
|
@@ -686,7 +688,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 686 |
}
|
| 687 |
```
|
| 688 |
|
| 689 |
-
##
|
| 690 |
|
| 691 |
这些端点服务页面工作台,不属于 Agent JSON API,也不进入 `GET /api/agent/openapi.json`:
|
| 692 |
|
|
@@ -694,10 +696,10 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 694 |
- `PUT /api/feedback`:页面结果反馈写入端��。页面把最近生成的可用性标记和备注写入服务端状态;Agent 只读查询使用 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback`。
|
| 695 |
- `DELETE /api/feedback`:页面结果反馈清理端点。页面删除历史时按 `clientRequestId` 清理对应服务端反馈;该端点不接受 Agent Bearer token。
|
| 696 |
- `GET /api/runtime-capabilities`:页面运行态能力摘要。它暴露流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议和 Responses 后端 enablement,不返回 API key 或本地密钥。它不替代也不被 Agent 的 `/api/agent/diagnostics/channel-health` 替代。
|
| 697 |
-
- `POST /api/shares`:页面分享上传创建端点。配置 `APP_PASSWORD` 时要求页面访问 cookie;请求是 form-data `image`、`sourceFilename`、`expiresInMinutes` 和可选 `accessCode`。Agent 客户端不要用它上传
|
| 698 |
-
- `GET /api/shares/{token}`、`GET /api/shares/{token}/content` 和 `POST /api/shares/{token}/content`:分享元数据和图片内容端点。公开分享支持浏览器直接 GET 内容;私密分享的内容读取通过分享页 POST JSON `accessCode` 校验,并有访问码失败限流;这不是 Agent
|
| 699 |
- `GET /api/logs`:页面日志 SSE。必须配置 `APP_PASSWORD`,并在 `Authorization: Bearer <sha256(APP_PASSWORD)>` 中发送访问码哈希;查询参数中的哈希会被拒绝。它不接受 `AGENT_API_TOKEN`。Agent 只读诊断使用 `/api/agent/diagnostics/page-requests/{id}`。
|
| 700 |
-
- `POST /api/image-delete`:页面图片文件删除端点。请求 JSON 为 `filenames` 和可选 `passwordHash`,按页面生成文件名删除 `generated-images/` 中的图片;它不删除 Agent 状态库
|
| 701 |
|
| 702 |
灵感相册和历史复用属于页面工作台和浏览器本地体验。当前没有对应的 Agent capabilities 字段,也不作为机器 API 契约承诺。
|
| 703 |
|
|
@@ -706,7 +708,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 706 |
| 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
|
| 707 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 708 |
| `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 给用户浏览器访问。 |
|
| 709 |
-
| `GET /api/agent/diagnostics/channel-health`
|
| 710 |
| `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 |
|
| 711 |
| `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
|
| 712 |
| `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
|
|
@@ -714,9 +716,9 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 714 |
| `GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | 按页面 `clientRequestId` 查询最新反馈。 |
|
| 715 |
| `POST /api/agent/diagnostics/page-requests` | Agent 日志诊断批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询脱敏日志摘要。 |
|
| 716 |
| `GET /api/agent/diagnostics/page-requests/{id}` | Agent 日志诊断摘要 API | 是 | 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读取 `/api/logs` SSE。 |
|
| 717 |
-
| `POST /api/shares`、`GET /api/shares/{token}`、`GET/POST /api/shares/{token}/content` | 分享访问 API | 否 | `POST /api/shares` 是页面上传创建端点,不进入 Agent OpenAPI;`GET/POST /content` 使用分享 token 或访问码服务用户浏览器,不复用 Agent
|
| 718 |
| `GET /api/logs` | 页面日志 SSE API | 否 | 使用页面访问码哈希的 Bearer 头,不接受 `AGENT_API_TOKEN`。 |
|
| 719 |
-
| `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库
|
| 720 |
| 灵感相册 | 浏览器本地工作台状态 | 否 | 只服务页面提示词复用,不作为 Agent capabilities。 |
|
| 721 |
| 历史复用 | 浏览器本地历史状态 | 否 | 只服务页面继续编辑、做变体和复用提示词。 |
|
| 722 |
|
|
|
|
| 8 |
|
| 9 |
- [辅助脚本](#辅助脚本)
|
| 10 |
- [能力查询](#能力查询)
|
| 11 |
+
- [任务轮询](#任务轮询)
|
| 12 |
- [生成图片](#生成图片)
|
| 13 |
- [编辑图片](#编辑图片)
|
| 14 |
- [产物元数据](#产物元数据)
|
| 15 |
- [结果反馈与诊断](#结果反馈与诊断)
|
| 16 |
+
- [页面 API 边界](#页面-api-边界)
|
| 17 |
- [错误](#错误)
|
| 18 |
|
| 19 |
## 辅助脚本
|
| 20 |
|
| 21 |
脚本位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径或仓库 checkout 路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
|
| 22 |
脚本必须通过 `node "<skill-root>/scripts/..."` 调用,以兼容 Windows、Linux 和 macOS;示例不要依赖 bash、sh、chmod、可执行位、POSIX inline env 或反斜杠续行。
|
| 23 |
+
生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node、Python 或 shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。
|
| 24 |
|
| 25 |
- `scripts/generate-image.mjs`:JSON 文生图调用。
|
| 26 |
- `scripts/edit-image.mjs`:multipart 编辑调用;固定尺寸任务可添加 `--dimension-check` 验收真实产物尺寸。
|
| 27 |
- `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
|
| 28 |
- `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
|
| 29 |
+
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent 状态请求诊断,支持 `--base-url` 固定目标服务。
|
| 30 |
- `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。
|
| 31 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 32 |
- `scripts/channel-capability-matrix.mjs`:固定串行验证四种上游图片请求方式,并在真实验证通过后生成私有渠道 env 配置。
|
| 33 |
|
| 34 |
+
生成、编辑和批量脚本默认只做预演(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。
|
| 35 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 36 |
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,会覆盖服务声明的默认编排入口和页面 SSE 边界,不触发真实生图或编辑。
|
| 37 |
位于仓库根目录且是首次配置、换机器、服务地址不确定或 token 不确定时,先运行 `npm run first-run`。它只读、非计费、不写 env 文件,默认输出中文摘要,并报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程鉴权、页面 SSE 鉴权和下一步动作。
|
|
|
|
| 50 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 51 |
页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
|
| 52 |
脚本会在 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`。
|
| 53 |
+
真实执行输出会包含机器可读 `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`。
|
| 54 |
|
| 55 |
生成脚本参数:
|
| 56 |
|
|
|
|
| 69 |
- `--force-request`:生成意图字段,服务端在 Agent JSON、job 和页面 SSE 路径解释为 `force_request=true`,跳过本地 upstream profile 尺寸/背景限制,让真实上游接受或拒绝请求;鉴权、幂等键、费用确认、API URL 安全、渠道白名单、非 `gpt-image-2` 尺寸白名单、正整数尺寸语法、图片数量、`partial_images`、上传文件和 mask 完整性仍由本服务校验。
|
| 70 |
- `--stream-mode`:可选,显式选择 `auto`、`stream` 或 `non_stream`。
|
| 71 |
- `--streaming-strategy`:可选,显式选择 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse` 或 `force-sse`。
|
| 72 |
+
- `--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 的最终渠道后计算默认值;非流式请求不会把该字段发送给上游。
|
| 73 |
+
- `--share`:真实生图成功后,为每个 Agent 产物调用 `POST /api/agent/artifacts/{id}/share` 创建用户可打开的分享链接,并在顶层 `shares`、`summary.share_urls` 和 `summary.direct_content_urls` 输出结果。
|
| 74 |
- `--share-expires-minutes`:可选,设置分享有效期分钟数;省略时使用服务端默认值。
|
| 75 |
- `--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`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
|
| 76 |
- `GPT_IMAGE_SHARE_ACCESS_CODE`:可选,创建需要访问码的分享链接;访问码不会出现在返回 URL 中,也不会出现在命令行参数里。
|
|
|
|
| 79 |
- `--idempotency-key`:指定稳定幂等键。
|
| 80 |
- `--page-sse`:诊断或兼容开关,强制使用页面端 `/api/images` form-data SSE。
|
| 81 |
- `--agent`:诊断或兼容开关,强制使用 `/api/agent/images/generate` 非流式 JSON。
|
| 82 |
+
- `--job`:诊断或兼容开关,强制使用 Agent 任务轮询。
|
| 83 |
- `--dry-run`:只输出将要发送的 JSON。
|
| 84 |
- `--allow-billable`:允许真实调用生图端点。
|
| 85 |
- `--preset`:常用 dry-run/真实调��参数集,当前包括 `1k-smoke-agent`、`4k-agent-nonstream`、`4k-page-sse` 和 `4k-upstream-sse-newapi`。dry-run 会展开真实请求字段,不触发计费。
|
|
|
|
| 122 |
批量脚本参数:
|
| 123 |
|
| 124 |
- `--input`:JSONL 任务文件路径,也可作为唯一位置参数。
|
| 125 |
+
- `--manifest`:追加写入 JSONL 清单路径,默认 `<input>.manifest.jsonl`。
|
| 126 |
+
- `--resume`:读取清单中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
|
| 127 |
- `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
|
| 128 |
- `--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`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
|
| 129 |
- `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
|
|
|
|
| 133 |
- `--dry-run`
|
| 134 |
- `--allow-billable`
|
| 135 |
|
| 136 |
+
批量 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` 时,要按验收失败处理,不能当成上游不可用。
|
| 137 |
|
| 138 |
批量 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
|
| 139 |
|
|
|
|
| 143 |
{
|
| 144 |
"id": "edit-responses",
|
| 145 |
"mode": "edit",
|
| 146 |
+
"prompt": "替换背景",
|
| 147 |
"image_path": "source.png",
|
| 148 |
"image_backend": "responses-image-generation",
|
| 149 |
"streaming_strategy": "responses-sse",
|
|
|
|
| 159 |
{
|
| 160 |
"id": "edit-responses-missing-backend",
|
| 161 |
"mode": "edit",
|
| 162 |
+
"prompt": "替换背景",
|
| 163 |
"image_path": "source.png",
|
| 164 |
"responsesModel": "gpt-4.1"
|
| 165 |
}
|
|
|
|
| 244 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
| 245 |
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
|
| 246 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
| 247 |
+
- 每个 `upstream_request_headers.channels[]` 还包含按渠道脱敏的 `constraints`,声明生成/编辑数量、按 backend 的数量与 `partial_images` 范围、编辑上传数量和大小、`gpt-image-2` 背景与尺寸策略;存在已初始化路由健康状态时提供 `healthy_request_modes`,表示当前至少有一个凭证健康的 request mode。数量范围可能带 `allowedValues`,表示不连续的离散可用值,不能按 min/max 中间的整数扩展。
|
| 248 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
| 249 |
- `upstream_request_headers.channels[].upstream_proxy`:该渠道的有效上游代理摘要。`OPENAI_CHANNEL_N_PROXY_URL` 优先于 `OPENAI_UPSTREAM_PROXY_URL`;摘要只返回 `configured` 和 `protocol`,不返回代理地址或端口。
|
| 250 |
+
- `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`。
|
| 251 |
- `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。
|
| 252 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 253 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
|
|
|
| 262 |
- 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
|
| 263 |
- `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
|
| 264 |
|
| 265 |
+
新增探针、诊断或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。
|
| 266 |
|
| 267 |
- `defaults.image_backend`:Agent generate 默认 `images-api`。
|
| 268 |
- `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
|
| 269 |
- `defaults.streaming_strategy`:Agent generate 默认 `auto`。
|
| 270 |
+
- `defaults.partial_images`:兼容旧客户端的默认提示值。自动模式省略 `partial_images` 时,服务端会在确定健康且满足当前 `n`、背景、尺寸和 request mode 的最终渠道后重新计算;客户端不应把该字段强行写入请求。非流式请求会校验公开的 `0..4` 输入边界,但不会向上游发送该字段。
|
| 271 |
- `upstream_profile`:当前运行时的上游能力摘要,包含 `activeProfile`、`serverProfile`、`serverProfileMixed`、`requestProfile` 与三组约束对象。
|
| 272 |
+
- `limits.generate_images` / `limits.edit_images` / `limits.upload_images`:当前运行时分别允许的默认生成张数、默认编辑输出张数和编辑源图数量范围。
|
| 273 |
+
- `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`。
|
| 274 |
- `limits.partial_images`:当前运行时默认 profile 允许的 `partial_images` 范围。OpenAI-compatible 通常为 `1..3`,Matsca Images API 通常为 `0..4`;Agent 必须以 capabilities 返回值为准。
|
| 275 |
- `limits.partial_images_by_backend`:按图片后端覆盖 `partial_images` 范围。选择 `responses-image-generation` 或兼容别名 `responses` 时必须优先使用该字段中的 `responses-image-generation` 范围,当前通常为 `1..3`。
|
| 276 |
- `supported.image_backends`:机器可读的图片后端枚举。
|
|
|
|
| 278 |
- `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。
|
| 279 |
- `supported.streaming_strategies`:机器可读的流式兼容策略枚举。
|
| 280 |
- `supported.stream_modes`:机器可读的 `auto`、`stream`、`non_stream` 枚举。
|
| 281 |
+
- `agent_jobs.supported`:当前为 `true`,表示可使用任务轮询。
|
| 282 |
- `agent_jobs.mode`:当前为 `job_polling`。
|
| 283 |
- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
|
| 284 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 285 |
- `agent_request_diagnostics`:Agent state 请求诊断能力。`endpoints.lookup` 支持 `request_id` 或 `idempotency_key` 查询参数;`endpoints.single` 支持按 `request_id` 路径查询;`retention.ttl_seconds` 与 Agent request TTL 一致。
|
| 286 |
|
| 287 |
+
普通生成默认使用 `orchestration.endpoint`,不是客户端直接选择任务端点。`agent_jobs.supported=true` 且 `mode=job_polling` 表示服务端编排和显式 `--job` 诊断路径可使用同一套任务状态机。高分辨率编辑和复杂 UI 批量生产仍按页面/批量规则使用页面端 `/api/images` SSE;页面流式有问题时,先诊断再显式选择 Agent JSON、Agent 编辑或任务路径。当前任务轮询是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。
|
| 288 |
|
| 289 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `visual-journal/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 290 |
|
| 291 |
上游代理同样由服务端统一执行:`OPENAI_UPSTREAM_PROXY_URL` 为全局默认值,`OPENAI_CHANNEL_N_PROXY_URL` 可覆盖单个渠道。它们只接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS;配置变更需重启或重新部署服务。代理适用于服务端上游 API、SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志,不影响 Agent 客户端到 Playground 的连接。
|
| 292 |
|
| 293 |
+
## 任务轮询
|
| 294 |
|
| 295 |
```http
|
| 296 |
POST /api/agent/jobs/images/generate
|
|
|
|
| 360 |
|
| 361 |
```json
|
| 362 |
{
|
| 363 |
+
"prompt": "一张陶瓷杯的产品照片",
|
| 364 |
"model": "gpt-image-2",
|
| 365 |
"n": 1,
|
| 366 |
"size": "1024x1024",
|
|
|
|
| 426 |
"selected_channel_id": "default",
|
| 427 |
"upstream_host": "api.example.test",
|
| 428 |
"request_headers": {
|
| 429 |
+
"user_agent_effective": "visual-journal/2.3.0",
|
| 430 |
"has_extra_headers": false,
|
| 431 |
"allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
|
| 432 |
"configured_header_names": []
|
|
|
|
| 475 |
|
| 476 |
所有产物端点都需要和生成接口相同的鉴权。
|
| 477 |
|
| 478 |
+
`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 产物删除。
|
| 479 |
|
| 480 |
Agent 创建分享链接的请求示例:
|
| 481 |
|
|
|
|
| 637 |
| `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 638 |
| `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`。 |
|
| 639 |
| `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` 时只能证明配置可见,不能当作真实上游通过。 |
|
| 640 |
+
| `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`。 |
|
| 641 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 642 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 643 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
|
|
|
| 670 |
"transport": "agent_json",
|
| 671 |
"endpoint": "/api/agent/images/generate",
|
| 672 |
"request_headers": {
|
| 673 |
+
"user_agent_effective": "visual-journal/2.3.0",
|
| 674 |
"has_extra_headers": false,
|
| 675 |
"allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
|
| 676 |
"configured_header_names": []
|
|
|
|
| 688 |
}
|
| 689 |
```
|
| 690 |
|
| 691 |
+
## 页面 API 边界
|
| 692 |
|
| 693 |
这些端点服务页面工作台,不属于 Agent JSON API,也不进入 `GET /api/agent/openapi.json`:
|
| 694 |
|
|
|
|
| 696 |
- `PUT /api/feedback`:页面结果反馈写入端��。页面把最近生成的可用性标记和备注写入服务端状态;Agent 只读查询使用 `/api/agent/page-requests/{id}/feedback` 或 `/api/agent/page-requests/feedback`。
|
| 697 |
- `DELETE /api/feedback`:页面结果反馈清理端点。页面删除历史时按 `clientRequestId` 清理对应服务端反馈;该端点不接受 Agent Bearer token。
|
| 698 |
- `GET /api/runtime-capabilities`:页面运行态能力摘要。它暴露流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议和 Responses 后端 enablement,不返回 API key 或本地密钥。它不替代也不被 Agent 的 `/api/agent/diagnostics/channel-health` 替代。
|
| 699 |
+
- `POST /api/shares`:页面分享上传创建端点。配置 `APP_PASSWORD` 时要求页面访问 cookie;请求是 form-data `image`、`sourceFilename`、`expiresInMinutes` 和可选 `accessCode`。Agent 客户端不要用它上传产物;应使用 `/api/agent/artifacts/{id}/share`。
|
| 700 |
+
- `GET /api/shares/{token}`、`GET /api/shares/{token}/content` 和 `POST /api/shares/{token}/content`:分享元数据和图片内容端点。公开分享支持浏览器直接 GET 内容;私密分享的内容读取通过分享页 POST JSON `accessCode` 校验,并有访问码失败限流;这不是 Agent 产物下载。
|
| 701 |
- `GET /api/logs`:页面日志 SSE。必须配置 `APP_PASSWORD`,并在 `Authorization: Bearer <sha256(APP_PASSWORD)>` 中发送访问码哈希;查询参数中的哈希会被拒绝。它不接受 `AGENT_API_TOKEN`。Agent 只读诊断使用 `/api/agent/diagnostics/page-requests/{id}`。
|
| 702 |
+
- `POST /api/image-delete`:页面图片文件删除端点。请求 JSON 为 `filenames` 和可选 `passwordHash`,按页面生成文件名删除 `generated-images/` 中的图片;它不删除 Agent 状态库产物记录。
|
| 703 |
|
| 704 |
灵感相册和历史复用属于页面工作台和浏览器本地体验。当前没有对应的 Agent capabilities 字段,也不作为机器 API 契约承诺。
|
| 705 |
|
|
|
|
| 708 |
| 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
|
| 709 |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 710 |
| `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 给用户浏览器访问。 |
|
| 711 |
+
| `GET /api/agent/diagnostics/channel-health` | Agent 只读渠道健康诊断 API | 是 | 只返回当前服务进程的路由��存快照,不触发上游探测或图片生成;需 Agent 鉴权,不能证明真实上游可用,也不替代页面 runtime capabilities。 |
|
| 712 |
| `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 |
|
| 713 |
| `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
|
| 714 |
| `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
|
|
|
|
| 716 |
| `GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | 按页面 `clientRequestId` 查询最新反馈。 |
|
| 717 |
| `POST /api/agent/diagnostics/page-requests` | Agent 日志诊断批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询脱敏日志摘要。 |
|
| 718 |
| `GET /api/agent/diagnostics/page-requests/{id}` | Agent 日志诊断摘要 API | 是 | 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读取 `/api/logs` SSE。 |
|
| 719 |
+
| `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` 创建这类分享记录。 |
|
| 720 |
| `GET /api/logs` | 页面日志 SSE API | 否 | 使用页面访问码哈希的 Bearer 头,不接受 `AGENT_API_TOKEN`。 |
|
| 721 |
+
| `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库产物。 |
|
| 722 |
| 灵感相册 | 浏览器本地工作台状态 | 否 | 只服务页面提示词复用,不作为 Agent capabilities。 |
|
| 723 |
| 历史复用 | 浏览器本地历史状态 | 否 | 只服务页面继续编辑、做变体和复用提示词。 |
|
| 724 |
|
skills/{gpt-image-playground-agent → visual-journal-image-agent}/scripts/batch-images.mjs
RENAMED
|
@@ -1173,8 +1173,14 @@ async function postGenerateTask(task) {
|
|
| 1173 |
validateAgentGenerateRequestAgainstCapabilities(
|
| 1174 |
{
|
| 1175 |
n: body.n,
|
| 1176 |
-
|
| 1177 |
-
image_backend: body.image_backend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1178 |
},
|
| 1179 |
taskCapabilities
|
| 1180 |
)
|
|
@@ -1240,11 +1246,16 @@ async function postEditTask(task) {
|
|
| 1240 |
validateAgentEditRequestAgainstCapabilities(
|
| 1241 |
{
|
| 1242 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1243 |
-
|
| 1244 |
-
? readPartialImages(task.raw.partial_images, `${task.id}.partial_images`)
|
| 1245 |
-
:
|
| 1246 |
imageCount: imagePaths.length,
|
| 1247 |
-
image_backend: task.raw.image_backend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1248 |
},
|
| 1249 |
taskCapabilities
|
| 1250 |
)
|
|
@@ -1313,9 +1324,16 @@ function validatePageSseTaskAgainstCapabilities(task, taskCapabilities) {
|
|
| 1313 |
validateAgentEditRequestAgainstCapabilities(
|
| 1314 |
{
|
| 1315 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1316 |
-
|
|
|
|
|
|
|
| 1317 |
imageCount: readEditImagePaths(task.raw, task.id).length,
|
| 1318 |
-
image_backend: readTaskImageBackend(task.raw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
},
|
| 1320 |
taskCapabilities
|
| 1321 |
);
|
|
@@ -1324,21 +1342,22 @@ function validatePageSseTaskAgainstCapabilities(task, taskCapabilities) {
|
|
| 1324 |
validateAgentGenerateRequestAgainstCapabilities(
|
| 1325 |
{
|
| 1326 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1327 |
-
|
| 1328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1329 |
},
|
| 1330 |
taskCapabilities
|
| 1331 |
);
|
| 1332 |
});
|
| 1333 |
}
|
| 1334 |
|
| 1335 |
-
function readTaskPartialImages(task, taskCapabilities) {
|
| 1336 |
-
if (hasOwn(task.raw, 'partial_images')) {
|
| 1337 |
-
return readPartialImages(task.raw.partial_images, `${task.id}.partial_images`);
|
| 1338 |
-
}
|
| 1339 |
-
return taskCapabilities?.defaults?.partial_images;
|
| 1340 |
-
}
|
| 1341 |
-
|
| 1342 |
function readTaskImageBackend(raw) {
|
| 1343 |
return raw.image_backend ? normalizeImageBackendForPage(String(raw.image_backend)) : undefined;
|
| 1344 |
}
|
|
|
|
| 1173 |
validateAgentGenerateRequestAgainstCapabilities(
|
| 1174 |
{
|
| 1175 |
n: body.n,
|
| 1176 |
+
...(body.partial_images === undefined ? {} : { partial_images: body.partial_images }),
|
| 1177 |
+
image_backend: body.image_backend,
|
| 1178 |
+
stream_mode: body.stream_mode,
|
| 1179 |
+
streaming_strategy: body.streaming_strategy,
|
| 1180 |
+
model: body.model,
|
| 1181 |
+
size: body.size,
|
| 1182 |
+
background: body.background,
|
| 1183 |
+
force_request: body.force_request
|
| 1184 |
},
|
| 1185 |
taskCapabilities
|
| 1186 |
)
|
|
|
|
| 1246 |
validateAgentEditRequestAgainstCapabilities(
|
| 1247 |
{
|
| 1248 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1249 |
+
...(hasOwn(task.raw, 'partial_images')
|
| 1250 |
+
? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) }
|
| 1251 |
+
: {}),
|
| 1252 |
imageCount: imagePaths.length,
|
| 1253 |
+
image_backend: task.raw.image_backend,
|
| 1254 |
+
stream_mode: task.raw.stream_mode,
|
| 1255 |
+
streaming_strategy: task.raw.streaming_strategy,
|
| 1256 |
+
model: task.raw.model,
|
| 1257 |
+
size: task.raw.size,
|
| 1258 |
+
force_request: readForceRequest(task.raw, task.id)
|
| 1259 |
},
|
| 1260 |
taskCapabilities
|
| 1261 |
)
|
|
|
|
| 1324 |
validateAgentEditRequestAgainstCapabilities(
|
| 1325 |
{
|
| 1326 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1327 |
+
...(hasOwn(task.raw, 'partial_images')
|
| 1328 |
+
? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) }
|
| 1329 |
+
: {}),
|
| 1330 |
imageCount: readEditImagePaths(task.raw, task.id).length,
|
| 1331 |
+
image_backend: readTaskImageBackend(task.raw),
|
| 1332 |
+
stream_mode: task.raw.stream_mode,
|
| 1333 |
+
streaming_strategy: task.raw.streaming_strategy,
|
| 1334 |
+
model: task.raw.model,
|
| 1335 |
+
size: task.raw.size,
|
| 1336 |
+
force_request: readForceRequest(task.raw, task.id)
|
| 1337 |
},
|
| 1338 |
taskCapabilities
|
| 1339 |
);
|
|
|
|
| 1342 |
validateAgentGenerateRequestAgainstCapabilities(
|
| 1343 |
{
|
| 1344 |
n: task.raw.n === undefined ? 1 : readConfiguredPositiveInteger(task.raw.n, `${task.id}.n`, 1),
|
| 1345 |
+
...(hasOwn(task.raw, 'partial_images')
|
| 1346 |
+
? { partial_images: readPartialImages(task.raw.partial_images, `${task.id}.partial_images`) }
|
| 1347 |
+
: {}),
|
| 1348 |
+
image_backend: readTaskImageBackend(task.raw),
|
| 1349 |
+
stream_mode: task.raw.stream_mode,
|
| 1350 |
+
streaming_strategy: task.raw.streaming_strategy,
|
| 1351 |
+
model: task.raw.model,
|
| 1352 |
+
size: task.raw.size,
|
| 1353 |
+
background: task.raw.background,
|
| 1354 |
+
force_request: readForceRequest(task.raw, task.id)
|
| 1355 |
},
|
| 1356 |
taskCapabilities
|
| 1357 |
);
|
| 1358 |
});
|
| 1359 |
}
|
| 1360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1361 |
function readTaskImageBackend(raw) {
|
| 1362 |
return raw.image_backend ? normalizeImageBackendForPage(String(raw.image_backend)) : undefined;
|
| 1363 |
}
|