From 30e82af4f1786cad1c584f8acb59384fbdfd035a Mon Sep 17 00:00:00 2001
From: Ukawka <140130066+Ukawka@users.noreply.github.com>
Date: Thu, 17 Sep 2026 17:44:24 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=96=B9=E6=A1=88v1=E8=A7=92=E8=89=B2?=
=?UTF-8?q?=E7=BB=93=E6=9E=84=E7=90=86=E8=A7=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 8 +
README.md | 16 +-
configs/astra-qwen.json | 45 ++++++
configs/deepseek-qwen.json | 45 ++++++
docs/model-stages.md | 165 +++++++++++++++++++++
lib/config.mjs | 92 ++++++++++++
lib/images.mjs | 23 +++
lib/report.mjs | 30 ++++
lib/run-artifacts.mjs | 34 +++++
lib/validate-parts.mjs | 91 ++++++++++++
package.json | 7 +-
pipeline.mjs | 25 ++--
prompts/complete-v1.txt | 10 ++
prompts/understand-v1.txt | 20 +++
providers/http.mjs | 112 ++++++++++++++
providers/image-edits.mjs | 45 ++++++
providers/vision.mjs | 53 +++++++
schemas/semantic-parts.schema.json | 30 ++++
stages/model-stage.mjs | 118 +++++++++++++++
tests/model-stages.test.mjs | 228 +++++++++++++++++++++++++++++
tools/model-check.mjs | 104 +++++++++++++
21 files changed, 1287 insertions(+), 14 deletions(-)
create mode 100644 configs/astra-qwen.json
create mode 100644 configs/deepseek-qwen.json
create mode 100644 docs/model-stages.md
create mode 100644 lib/config.mjs
create mode 100644 lib/images.mjs
create mode 100644 lib/report.mjs
create mode 100644 lib/run-artifacts.mjs
create mode 100644 lib/validate-parts.mjs
create mode 100644 prompts/complete-v1.txt
create mode 100644 prompts/understand-v1.txt
create mode 100644 providers/http.mjs
create mode 100644 providers/image-edits.mjs
create mode 100644 providers/vision.mjs
create mode 100644 schemas/semantic-parts.schema.json
create mode 100644 stages/model-stage.mjs
create mode 100644 tests/model-stages.test.mjs
create mode 100644 tools/model-check.mjs
diff --git a/.env.example b/.env.example
index 8ae1c43..00fae72 100644
--- a/.env.example
+++ b/.env.example
@@ -3,3 +3,11 @@ VECTOR_ENGINE_BASE_URL=https://your-vector-engine.example
VECTOR_ENGINE_API_KEY=replace-me
# Optional request timeout in milliseconds
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1200000
+
+# Independent model IDs for the annotated-parts pipeline. Use EXACT relay IDs.
+# Confirm with: node tools/model-check.mjs --list-models
+SPINE_UNDERSTANDING_MODEL=gpt-6-astra
+SPINE_COMPLETION_MODEL=qwen-image-3.0
+
+# Checkpoint location only; the SAM runtime is integrated in milestone 2.
+SAM_MODEL_DIR=F:/Study/sam3.1
diff --git a/README.md b/README.md
index 83ebd42..a525e18 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,18 @@
# Genarrative / Genielabs Spine Animation Pipeline
+## 新增:可替换模型与角色结构理解
+
+已新增 `--mode annotated-parts`,默认 GPT6Astra 做结构理解,Qwen Image 3.0 作为独立图片编辑适配器。模型、协议和提示词可分别配置;第一阶段输出 JSON、粗标注 SVG 和检查报告。SAM 分割与补全合并尚未接入。
+
+```powershell
+node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand
+```
+
+完整配置、模型切换、独立图片编辑测试和产物说明见 [docs/model-stages.md](docs/model-stages.md)。以下为旧版拆件管线的说明。
+
这是独立目录,不修改 Genarrative。它只参考 Genarrative 的 VectorEngine 图片生成协议:后端环境变量提供 `VECTOR_ENGINE_BASE_URL` 与 `VECTOR_ENGINE_API_KEY`,使用 OpenAI-compatible `POST /v1/images/edits` multipart 请求。
-当前上游没有 `gpt-image-2.5`,脚本会把它映射为 `gpt-image-2`,并在 manifest 中保留 `requestedModel`。
+模型 ID 原样传给中转站,不再进行隐式映射。可通过 `node tools/model-check.mjs --list-models` 查询当前服务实际列出的 ID。
拆件提示词明确要求保留参考图的原始姿态、左右方向、遮挡关系和非对称结构,禁止镜像、拉直和生成对称备件。图片接口对 429/5xx 会自动重试 3 次;使用新输出目录即可重新生成,旧目录可用 `--resume` 复用已生成的拆件图。
@@ -16,7 +26,7 @@ node pipeline.mjs \
--out-dir ./output/character-spine \
--genielabs-dir /absolute/path/spine-animation-ai \
--env-file /absolute/path/.env \
- --image-model gpt-image-2.5 \
+ --image-model qwen-image-3.0 \
--animations idle,walk,wave
```
@@ -32,7 +42,7 @@ node pipeline.mjs \
`matched` 依赖拆件图与参考图保持相同画风和可辨识纹理;低纹理部件会进入模板匹配回退,并进行白底归一化以避免透明像素产生 `NaN/inf`。`anchored` 只按拆件图的行布局做通用兜底,不应作为正式定位结果。
-Python 环境探测顺序为显式 `--python`、Anaconda、Homebrew Python、系统 Python;要求导入 `cv2`、`PIL`、`numpy`。如果全部不可用,脚本会在本目录创建 `.venv` 并安装 `requirements.txt`。
+旧流程的 Python 环境默认探测 `python`、`python3` 和项目 `.venv`;显式 `--python` 时只使用指定环境。要求导入 `cv2`、`PIL`、`numpy`。自动创建虚拟环境时区分 Windows 的 `Scripts/python.exe` 和 Unix 的 `bin/python`。新增的结构理解模式不需要 Python。
脚本默认只读取本目录 `.env` 和当前 shell 环境,不会自动读取 Genarrative 的 secrets 文件。可以通过 `--env-file` 显式指定一个只在受控后端使用的环境文件。
diff --git a/configs/astra-qwen.json b/configs/astra-qwen.json
new file mode 100644
index 0000000..db0e6a9
--- /dev/null
+++ b/configs/astra-qwen.json
@@ -0,0 +1,45 @@
+{
+ "schema_version": 1,
+ "providers": {
+ "relay-vision": {
+ "protocol": "responses",
+ "base_url_env": "VECTOR_ENGINE_BASE_URL",
+ "api_key_env": "VECTOR_ENGINE_API_KEY",
+ "timeout_ms": 180000,
+ "max_retries": 2
+ },
+ "relay-image-edit": {
+ "protocol": "images-edits",
+ "base_url_env": "VECTOR_ENGINE_BASE_URL",
+ "api_key_env": "VECTOR_ENGINE_API_KEY",
+ "timeout_ms": 1200000,
+ "max_retries": 2,
+ "capabilities": {"multiple_images": false, "mask": false},
+ "image_field": "image",
+ "mask_editable": "transparent"
+ }
+ },
+ "stages": {
+ "understanding": {
+ "provider": "relay-vision",
+ "model_env": "SPINE_UNDERSTANDING_MODEL",
+ "default_model": "gpt-6-astra",
+ "prompt": "../prompts/understand-v1.txt",
+ "output_mode": "text-json",
+ "stream": true,
+ "params": {"store": false}
+ },
+ "completion": {
+ "provider": "relay-image-edit",
+ "model_env": "SPINE_COMPLETION_MODEL",
+ "default_model": "qwen-image-3.0",
+ "prompt": "../prompts/complete-v1.txt",
+ "params": {"n": 1}
+ },
+ "segmentation": {
+ "provider": "sam-local",
+ "model_dir_env": "SAM_MODEL_DIR",
+ "default_model_dir": "F:/Study/sam3.1"
+ }
+ }
+}
diff --git a/configs/deepseek-qwen.json b/configs/deepseek-qwen.json
new file mode 100644
index 0000000..2fe1a7b
--- /dev/null
+++ b/configs/deepseek-qwen.json
@@ -0,0 +1,45 @@
+{
+ "schema_version": 1,
+ "providers": {
+ "relay-vision": {
+ "protocol": "chat-completions",
+ "base_url_env": "VECTOR_ENGINE_BASE_URL",
+ "api_key_env": "VECTOR_ENGINE_API_KEY",
+ "timeout_ms": 180000,
+ "max_retries": 2
+ },
+ "relay-image-edit": {
+ "protocol": "images-edits",
+ "base_url_env": "VECTOR_ENGINE_BASE_URL",
+ "api_key_env": "VECTOR_ENGINE_API_KEY",
+ "timeout_ms": 1200000,
+ "max_retries": 2,
+ "capabilities": {"multiple_images": false, "mask": false},
+ "image_field": "image",
+ "mask_editable": "transparent"
+ }
+ },
+ "stages": {
+ "understanding": {
+ "provider": "relay-vision",
+ "model_env": "SPINE_UNDERSTANDING_MODEL",
+ "default_model": "deepseek-flash",
+ "prompt": "../prompts/understand-v1.txt",
+ "output_mode": "text-json",
+ "stream": false,
+ "params": {}
+ },
+ "completion": {
+ "provider": "relay-image-edit",
+ "model_env": "SPINE_COMPLETION_MODEL",
+ "default_model": "qwen-image-3.0",
+ "prompt": "../prompts/complete-v1.txt",
+ "params": {"n": 1}
+ },
+ "segmentation": {
+ "provider": "sam-local",
+ "model_dir_env": "SAM_MODEL_DIR",
+ "default_model_dir": "F:/Study/sam3.1"
+ }
+ }
+}
diff --git a/docs/model-stages.md b/docs/model-stages.md
new file mode 100644
index 0000000..4b7a9b2
--- /dev/null
+++ b/docs/model-stages.md
@@ -0,0 +1,165 @@
+# 第一阶段:可替换模型接口与角色结构理解
+
+本轮实现 GPT6Astra 结构理解、Qwen Image 3.0 图片编辑适配器、结构 JSON 校验、粗标注预览、实验记录和缓存。新增模式不需要 Python,不运行旧的“拆件图 → SIFT 定位”流程。
+
+结构理解当前停在 `understand`;SAM 分割、掩码修正、隐藏部件合并、Spine 骨骼导出是后续阶段。图片编辑测试保存的是原始候选,不是验收通过的最终资产。
+
+## 环境与模型
+
+要求 Node.js 20 或更高版本,无新增 npm 依赖。在 `AI_spine` 目录运行。
+
+复用现有 `.env` 的地址和密钥,可增加:
+
+```dotenv
+SPINE_UNDERSTANDING_MODEL=gpt-6-astra
+SPINE_COMPLETION_MODEL=qwen-image-3.0
+SAM_MODEL_DIR=F:/Study/sam3.1
+```
+
+不配置这三项时,`configs/astra-qwen.json` 已提供相同默认值。不会自动改写用户的 `.env`。
+
+查询中转站模型 ID(只读取列表,不调用生成):
+
+```powershell
+node .\tools\model-check.mjs --list-models
+```
+
+模型展示名不等于 API ID。2026-09-17 的本机接口查询包含 `gpt-6-astra` 和 `qwen-image-3.0`。支持列表不保证所有端点/多图/mask 参数可用,需要分别验证。
+
+本地配置检查(不联网,不生成图片):
+
+```powershell
+node .\tools\model-check.mjs --dry-run
+```
+
+## 运行结构理解
+
+```powershell
+node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-astra --stop-after understand
+```
+
+首次调用会向配置的中转站发送参考图。每个实验使用新输出目录;本轮不会自动覆盖旧产物。
+
+切换理解模型:
+
+```powershell
+node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-other --understanding-model YOUR_EXACT_MODEL_ID --stop-after understand
+```
+
+切换协议(需中转站实际支持):
+
+```powershell
+node .\pipeline.mjs --mode annotated-parts --reference ..\spine-animation-ai\demo\character_reference.png --out-dir .\output\sombrero-responses --understanding-protocol responses --stop-after understand
+```
+
+协议可选 `chat-completions` / `responses`,默认 `responses`:本机中转站实测 Astra 的 codex channel 明确拒绝 `/v1/chat/completions`。输出模式默认 `text-json`:在提示词中提供 Schema,并始终在本地严格校验。显式确认中转站支持后,可使用 `--output-mode json-object` 或 `--output-mode json-schema`。不会因 400/不支持的协议自动换模型或偷偷降低输出约束。
+
+该 Astra 通道还要求 `stream=true`,所以默认配置启用流式 Responses,并设置 `store=false`。适配器收集 SSE 后只接受完整的终止事件,输出仍是普通 JSON 文件。新模型可在阶段配置中设置 `stream: false`。`response.raw.json` 对 SSE 保存终止事件中的 response 对象,非逐字节 SSE 日志;`request-metadata.json` 记录 wire_format。
+
+切换到非流式 Chat Completions 模型时可使用 `--understanding-protocol chat-completions --stream false`,不会沿用 Astra 的流式设置。
+
+项目同时提供 `configs/deepseek-qwen.json`。它把第一阶段固定为
+`deepseek-flash + chat-completions + stream=false`,第三阶段仍使用
+`qwen-image-3.0`,适合与默认的 `configs/astra-qwen.json` 做对照实验:
+
+```powershell
+node .\pipeline.mjs --mode annotated-parts `
+ --config .\configs\deepseek-qwen.json `
+ --reference ..\spine-animation-ai\demo\character_reference.png `
+ --out-dir .\output\sombrero-understand-deepseek `
+ --stop-after understand
+```
+
+主要产物:
+
+| 文件 | 含义 |
+| --- | --- |
+| `source.png` | 实际提交的原图,不缩放、不裁剪 |
+| `semantic-parts.json` | 已通过结构校验的部件清单 |
+| `annotated.svg` | 原图上的粗框、关节点、正点和父子连线,可直接浏览器打开 |
+| `report.html` | 自包含检查页面,带部件表和模型记录 |
+| `validation.json` | 字段、坐标、引用、父子环和遮挡环校验结果 |
+| `response.raw.json` / `response.txt` | 原始服务端响应 / 提取的文本 |
+| `prompt.txt` / `request.json` | 提示词 / 请求设置与输入哈希;图片内容单独保存在 source.png |
+| `request-metadata.json` | requested/returned model、request ID、usage、耗时和请求次数 |
+| `manifest.json` | 运行状态、配置、代码版本、源文件哈希和产物哈希 |
+
+`status=completed` 仅表示这一步执行完成。`quality_status=needs_review` 表示仍需人工检查语义和框的位置。粗框不是像素 mask,输出也不是自动批准的骨骼。
+
+校验失败保存原始响应、validation 和报告,退出码为非零,不继续分割。网络/API 失败保存失败 manifest。拒绝、截断和不完整 Responses 都不会被当作成功。
+
+HTTP/SSE/网络失败还保存 `response.error.json`:状态码、请求 ID、耗时、尝试次数、响应体(最多 2 MB)或网络错误代码,密钥会脱敏。若服务端返回 overloaded,使用新目录稍后重跑;这不表示 schema 或分割模型出错。
+
+## 独立测试图片编辑接口
+
+先准备一个局部部件 PNG 和任务文本 `task.txt`,例如:
+
+```text
+The input canvas contains the screen-left forearm. Complete the hidden elbow
+connection behind the sleeve. Keep the visible hand and forearm in their exact
+positions. Preserve the original outline and shading. Do not add other parts.
+```
+
+调用 Qwen:
+
+```powershell
+node .\tools\model-check.mjs --stage completion --image .\local-part.png --prompt-file .\task.txt --out-dir .\output\qwen-edit-test --completion-model qwen-image-3.0
+```
+
+改 `--completion-model` 即可对比另一个兼容的图片编辑模型。此命令直接调用图片接口并可能计费,不调用结构理解。
+
+结果保存 `candidate-1.png`(或服务端实际返回的 JPEG/WebP 扩展名)、输入图、任务提示词、原始响应和运行记录。PNG 输出会记录是否与输入画布同尺寸;尺寸相同不代表内容对齐。
+
+默认图片适配器只发送一张图。若中转站确认支持多图,在配置中启用 `capabilities.multiple_images`,并使用重复 `--image`:第一张是局部画布,其余是原图/标注图参考。字段名通过 `image_field` 选择 `image` 或 `image[]`。
+
+若确认支持编辑 mask,再启用 `capabilities.mask` 并使用 `--mask edit-mask.png`。当前适配器约定透明像素可编辑,要求 mask 与第一张图同尺寸、包含 alpha 通道。SAM 的普通黑白 mask 不能直接当作编辑 alpha mask。未启用能力时参数会明确报错,不静默忽略。
+
+本轮没有恢复原始可见像素,也没有生成隐藏区域的透明掩码;因此候选始终标记 `raw_completion_probe` 和 `needs_review`,不能直接接入最终 atlas。
+
+## 配置与切换规则
+
+`configs/astra-qwen.json` 分离 `providers` 和 `stages`:
+
+- provider:协议、base URL/key 的环境变量名、超时、重试次数、多图和 mask 声明。
+- stage:provider 名称、模型、提示词文件、输出模式和模型特定参数。
+- 提示词路径相对于配置 JSON 所在目录解析;命令行输入/输出路径相对于当前目录。
+- 模型优先级:CLI > 阶段显式 `model` > `model_env` 环境变量 > `default_model`。
+- shell 环境覆盖 `.env`。实验 JSON 不应存放密钥,仅引用密钥环境变量名。
+- 不同服务可配置各自的 base URL/key 环境变量。
+- 模型 ID 原样发送,不再把 `gpt-image-2.5` 映射成其他模型。
+- `params` 允许模型特定参数,但不能覆盖 model、prompt、messages、input 等适配器保留字段。
+- 新模式的超时/重试来自 provider 配置;旧模式的图片超时仍用 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`。
+
+例如把理解阶段改为固定模型:
+
+```json
+{
+ "provider": "relay-vision",
+ "model": "YOUR_EXACT_MODEL_ID",
+ "prompt": "../prompts/understand-v1.txt",
+ "output_mode": "text-json",
+ "params": {}
+}
+```
+
+## 重跑与对比
+
+同一命令增加 `--resume`,仅当上次完整结束,且模型、协议、参数、提示词、Schema、源图、适配器代码和所有产物校验一致时复用。模型变化时要求新的输出目录,防止覆盖对照组。失败任务也保留原目录,使用新目录重跑。
+
+本轮实现单阶段缓存;跨 SAM/补全/导出的依赖缓存留到后续阶段接入。对比补全模型时,固定相同局部画布、任务文本与掩码。
+
+显式 429/5xx 最多按配置重试,超时或网络断开不自动重试,因为服务端可能已经执行任务。完整请求不保证模型内容可复现;seed 只在提供方确认支持时放入 params,usage 不存在时记录 null,不估造价格或 token 数。
+
+## SAM 路径
+
+默认 `F:/Study/sam3.1`,检查 `sam3.1_multiplex.pt` 是否存在。该目录 README 明确说明它仅提供 checkpoint,不是可直接调用的推理服务。本轮仅记录路径和 checkpoint 存在性,状态为 `not_integrated`,不加载权重、不占用 GPU。
+
+## 测试
+
+```powershell
+npm test
+```
+
+测试使用本地 mock HTTP 服务或注入响应,不调用真实付费模型。覆盖模型优先级、协议请求/响应、multipart、能力限制、数值/引用/环校验、错误响应、重试、缓存和 CLI 全链路。
+
+2026-09-17 的集成检查:模型列表请求成功;Astra 要求 Responses + stream=true,切换后服务端返回 overloaded,尚未得到成功的真实角色标注。Qwen 适配器通过 mock 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
diff --git a/lib/config.mjs b/lib/config.mjs
new file mode 100644
index 0000000..f33292a
--- /dev/null
+++ b/lib/config.mjs
@@ -0,0 +1,92 @@
+import {existsSync, readFileSync} from 'node:fs';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+export const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+
+export function readEnvFile(file) {
+ if (!existsSync(file)) return {};
+ const values = {};
+ for (const line of readFileSync(file, 'utf8').replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
+ if (!match) continue;
+ let value = match[2];
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ } else value = value.replace(/\s+#.*$/u, '');
+ values[match[1]] = value;
+ }
+ return values;
+}
+
+export function loadConfig({configFile, envFile, env = process.env} = {}) {
+ const file = path.resolve(configFile || path.join(projectRoot, 'configs', 'astra-qwen.json'));
+ const config = JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
+ if (config.schema_version !== 1 || !config.providers || !config.stages) {
+ throw new Error('配置必须声明 schema_version: 1、providers 和 stages');
+ }
+ return {config, file, env: {...readEnvFile(envFile || path.join(projectRoot, '.env')), ...env}};
+}
+
+export function resolveStage(loaded, name, overrides = {}, {requireKey = true} = {}) {
+ const stage = loaded.config.stages[name];
+ if (!stage) throw new Error(`没有配置阶段:${name}`);
+ const provider = loaded.config.providers[stage.provider];
+ if (!provider) throw new Error(`没有配置提供方:${stage.provider}`);
+ const model = String(overrides.model || stage.model || loaded.env[stage.model_env] || stage.default_model || '').trim();
+ if (!model) throw new Error(`缺少 ${name} 的模型 ID`);
+ const baseUrl = String(loaded.env[provider.base_url_env] || '').trim().replace(/\/+$/u, '');
+ const apiKey = String(loaded.env[provider.api_key_env] || '').trim();
+ if (requireKey && (!baseUrl || !apiKey)) throw new Error(`需要配置 ${provider.base_url_env} 和 ${provider.api_key_env}`);
+ if (baseUrl) {
+ const url = new URL(baseUrl);
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
+ throw new Error('提供方 base URL 必须是无用户名、密码、查询参数的 HTTP(S) 地址');
+ }
+ }
+ const protocol = overrides.protocol || provider.protocol;
+ const allowed = name === 'understanding' ? ['chat-completions', 'responses'] : ['images-edits'];
+ if (!allowed.includes(protocol)) throw new Error(`${name} 不支持协议:${protocol}`);
+ const params = {...stage.params, ...overrides.params};
+ // Semantic fields are owned by adapters, never overridden via opaque params.
+ for (const key of ['model', 'messages', 'input', 'instructions', 'prompt', 'image', 'image[]', 'mask', 'response_format', 'text', 'stream', 'Authorization', 'api_key', 'apiKey']) {
+ if (Object.hasOwn(params, key)) throw new Error(`params 不能覆盖保留字段:${key}`);
+ }
+ const timeoutMs = provider.timeout_ms ?? 180000;
+ const maxRetries = provider.max_retries ?? 2;
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || !Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
+ throw new Error('timeout_ms 必须为正数,max_retries 必须为 0~5 的整数');
+ }
+ const outputMode = overrides.outputMode || stage.output_mode || 'text-json';
+ if (!['text-json', 'json-object', 'json-schema'].includes(outputMode)) throw new Error(`未知 output_mode:${outputMode}`);
+ const stream = overrides.stream ?? stage.stream ?? false;
+ if (typeof stream !== 'boolean') throw new Error('stream 必须为 boolean');
+ const promptPath = path.resolve(path.dirname(loaded.file), overrides.prompt || stage.prompt);
+ const capabilities = {multiple_images: false, mask: false, ...provider.capabilities};
+ if (typeof capabilities.mask !== 'boolean' || typeof capabilities.multiple_images !== 'boolean') {
+ throw new Error('capabilities.mask 和 multiple_images 必须为 boolean');
+ }
+ const imageField = provider.image_field || 'image';
+ if (!['image', 'image[]'].includes(imageField)) throw new Error('image_field 必须是 image 或 image[]');
+ return {
+ name, provider: stage.provider, protocol, model, baseUrl, apiKey, timeoutMs, maxRetries,
+ params, outputMode, stream, promptPath, prompt: readFileSync(promptPath, 'utf8'), capabilities,
+ imageField, maskEditable: provider.mask_editable || 'transparent',
+ };
+}
+
+export function publicStage(stage) {
+ return {
+ provider: stage.provider, protocol: stage.protocol, model: stage.model,
+ params: stage.params, output_mode: stage.outputMode, stream: stage.stream, timeout_ms: stage.timeoutMs,
+ max_retries: stage.maxRetries, capabilities: stage.capabilities,
+ image_field: stage.imageField, mask_editable: stage.maskEditable,
+ };
+}
+
+export function samInfo(loaded) {
+ const stage = loaded.config.stages.segmentation || {};
+ const modelDir = loaded.env[stage.model_dir_env] || stage.default_model_dir || '';
+ const checkpoint = modelDir ? path.join(modelDir, 'sam3.1_multiplex.pt') : '';
+ return {model_dir: modelDir, checkpoint, checkpoint_exists: Boolean(checkpoint && existsSync(checkpoint)), runtime_status: 'not_integrated'};
+}
diff --git a/lib/images.mjs b/lib/images.mjs
new file mode 100644
index 0000000..44a5a69
--- /dev/null
+++ b/lib/images.mjs
@@ -0,0 +1,23 @@
+import {readFileSync} from 'node:fs';
+import {createHash} from 'node:crypto';
+import path from 'node:path';
+
+export function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
+
+export function imageFormat(bytes) {
+ if (bytes.length >= 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
+ return {mime: 'image/png', extension: 'png', width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)};
+ }
+ if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return {mime: 'image/jpeg', extension: 'jpg'};
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return {mime: 'image/webp', extension: 'webp'};
+ throw new Error('图片数据不是支持的 PNG/JPEG/WebP 格式');
+}
+
+export function readImage(file, {pngOnly = false} = {}) {
+ const bytes = readFileSync(file);
+ const format = imageFormat(bytes);
+ if (pngOnly && (format.extension !== 'png' || !format.width || !format.height)) throw new Error('结构理解与局部画布当前要求非空 PNG 图片');
+ return {file: path.resolve(file), bytes, ...format, sha256: sha256(bytes)};
+}
+
+export function dataUrl(image) { return `data:${image.mime};base64,${image.bytes.toString('base64')}`; }
diff --git a/lib/report.mjs b/lib/report.mjs
new file mode 100644
index 0000000..f0e813e
--- /dev/null
+++ b/lib/report.mjs
@@ -0,0 +1,30 @@
+import {dataUrl} from './images.mjs';
+
+const escape = (value) => String(value).replace(/[&<>"']/gu, (c) => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
+
+export function annotatedSvg(image, parts) {
+ const {width: w, height: h} = image;
+ const stroke = Math.max(w, h) / 400;
+ const font = Math.max(w, h) / 65;
+ const byId = new Map(parts.map((part) => [part.id, part]));
+ const overlays = parts.map((part, index) => {
+ const color = `hsl(${(index * 137.5) % 360} 85% 55%)`;
+ const [px, py] = [part.pivot_norm[0] * w, part.pivot_norm[1] * h];
+ const parent = byId.get(part.parent);
+ const line = parent ? ``;
+ const rows = data && validation.valid ? data.parts.map((part) => `
此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。
状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}
${svg}${escape(JSON.stringify(validation, null, 2))}| ID | 父部件 | 可见性 | 遮挡物 | 自报置信度 |
|---|
${escape(JSON.stringify(data?.notes || [], null, 2))}${escape(JSON.stringify(metadata, null, 2))}`;
+}
diff --git a/lib/run-artifacts.mjs b/lib/run-artifacts.mjs
new file mode 100644
index 0000000..9d702c2
--- /dev/null
+++ b/lib/run-artifacts.mjs
@@ -0,0 +1,34 @@
+import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
+import path from 'node:path';
+import {sha256} from './images.mjs';
+import {redact} from '../providers/http.mjs';
+
+export function jsonFile(file, value, secret = '') {
+ writeFileSync(file, `${redact(JSON.stringify(value, null, 2), secret)}\n`, 'utf8');
+}
+
+export function prepareRun(outDir, fingerprint, resume) {
+ const out = path.resolve(outDir);
+ const manifestPath = path.join(out, 'manifest.json');
+ if (existsSync(out) && readdirSync(out).length) {
+ if (!resume) throw new Error(`输出目录非空,请使用新目录或 --resume:${out}`);
+ if (!existsSync(manifestPath)) throw new Error('无法复用:目录没有 manifest.json');
+ const previous = JSON.parse(readFileSync(manifestPath, 'utf8'));
+ if (previous.fingerprint !== fingerprint) throw new Error('无法复用:模型、配置、输入或代码版本变化,请使用新的输出目录');
+ if (previous.status !== 'completed') throw new Error('上次任务没有完整结束,请保留诊断文件并使用新目录重跑');
+ for (const [name, hash] of Object.entries(previous.artifact_hashes || {})) {
+ const file = path.join(out, name);
+ if (!existsSync(file) || sha256(readFileSync(file)) !== hash) throw new Error(`缓存产物缺失或被修改:${name}`);
+ }
+ if (!Object.keys(previous.artifact_hashes || {}).length) throw new Error('缓存缺少产物校验记录');
+ return {out, manifestPath, reused: true};
+ }
+ mkdirSync(out, {recursive: true});
+ return {out, manifestPath, reused: false};
+}
+
+export function finishRun(run, manifest, artifacts, secret) {
+ manifest.artifact_hashes = Object.fromEntries(artifacts.map((name) => [name, sha256(readFileSync(path.join(run.out, name)))]));
+ manifest.status = 'completed';
+ jsonFile(run.manifestPath, manifest, secret);
+}
diff --git a/lib/validate-parts.mjs b/lib/validate-parts.mjs
new file mode 100644
index 0000000..9048c61
--- /dev/null
+++ b/lib/validate-parts.mjs
@@ -0,0 +1,91 @@
+import {readFileSync} from 'node:fs';
+import path from 'node:path';
+import {projectRoot} from './config.mjs';
+
+export const partsSchema = JSON.parse(readFileSync(path.join(projectRoot, 'schemas', 'semantic-parts.schema.json'), 'utf8'));
+
+// Validate the subset of JSON Schema used by the checked-in contract. Keeping
+// the validation driven by that contract avoids two independent field lists.
+function validate(value, schema, at, errors) {
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
+ const matches = (type) => {
+ if (type === 'null') return value === null;
+ if (type === 'array') return Array.isArray(value);
+ if (type === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
+ if (type === 'integer') return Number.isInteger(value);
+ if (type === 'number') return typeof value === 'number' && Number.isFinite(value);
+ return typeof value === type;
+ };
+ if (!types.some(matches)) { errors.push(`${at}: 类型必须为 ${types.join('/')}`); return; }
+ if (schema.enum && !schema.enum.includes(value)) errors.push(`${at}: 值不在允许列表中`);
+ if (value === null) return;
+ if (typeof value === 'number') {
+ if (value < (schema.minimum ?? -Infinity) || value > (schema.maximum ?? Infinity)) errors.push(`${at}: 数值越界`);
+ }
+ if (typeof value === 'string') {
+ if (schema.pattern && !new RegExp(schema.pattern, 'u').test(value)) errors.push(`${at}: 格式不合法`);
+ if (value.length < (schema.minLength ?? 0) || value.length > (schema.maxLength ?? Infinity)) errors.push(`${at}: 字符串长度不合法`);
+ }
+ if (Array.isArray(value)) {
+ if (value.length < (schema.minItems ?? 0) || value.length > (schema.maxItems ?? Infinity)) errors.push(`${at}: 数组长度不合法`);
+ value.forEach((item, index) => validate(item, schema.items, `${at}[${index}]`, errors));
+ } else if (typeof value === 'object') {
+ for (const key of schema.required || []) if (!Object.hasOwn(value, key)) errors.push(`${at}.${key}: 必填`);
+ for (const [key, item] of Object.entries(value)) {
+ if (schema.properties[key]) validate(item, schema.properties[key], `${at}.${key}`, errors);
+ else if (schema.additionalProperties === false) errors.push(`${at}.${key}: 未知字段`);
+ }
+ }
+}
+
+export function parseParts(text) {
+ const cleaned = text.trim().replace(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/u, '$1');
+ return JSON.parse(cleaned);
+}
+
+export function validateParts(data) {
+ const errors = [];
+ const warnings = [];
+ validate(data, partsSchema, '$', errors);
+ if (errors.length) return {valid: false, errors, warnings};
+ const ids = new Set(data.parts.map((part) => part.id));
+ if (ids.size !== data.parts.length) errors.push('部件 ID 重复');
+ for (const part of data.parts) {
+ if (part.id === 'root') errors.push('root 为后续导出保留 ID');
+ for (const ref of [part.parent, ...part.occluded_by].filter((item) => item !== null)) {
+ if (!ids.has(ref) || ref === part.id) errors.push(`${part.id}: 引用了不存在的部件或自身 ${ref}`);
+ }
+ if (new Set(part.occluded_by).size !== part.occluded_by.length) errors.push(`${part.id}: occluded_by 重复`);
+ const box = part.bbox_norm_xyxy;
+ if (part.visibility === 'hidden') {
+ if (box !== null || part.positive_points_norm.length || !part.needs_completion) errors.push(`${part.id}: 完全隐藏部件应无可见框/正点,且需要补全`);
+ warnings.push(`${part.id}: 完全隐藏部件需要人工确认,不能直接生成可见 mask`);
+ } else {
+ if (!box || box[0] >= box[2] || box[1] >= box[3]) errors.push(`${part.id}: 可见 bbox 必须具有正面积`);
+ if (!part.positive_points_norm.length) errors.push(`${part.id}: 可见部件需要至少一个正点`);
+ if (box) for (const [x, y] of part.positive_points_norm) {
+ if (x < box[0] || x > box[2] || y < box[1] || y > box[3]) errors.push(`${part.id}: 正点不在可见框内`);
+ }
+ }
+ if (part.confidence < 0.6) warnings.push(`${part.id}: 模型自报低置信度,需要复核`);
+ }
+ if (!errors.length) {
+ const parts = new Map(data.parts.map((part) => [part.id, part]));
+ for (const relation of ['parent', 'occluded_by']) {
+ const visiting = new Set();
+ const done = new Set();
+ const visit = (id) => {
+ if (visiting.has(id)) { errors.push(`${relation} 存在环:${id}`); return; }
+ if (done.has(id)) return;
+ visiting.add(id);
+ const part = parts.get(id);
+ const refs = relation === 'parent' ? [part.parent].filter(Boolean) : part.occluded_by;
+ refs.forEach(visit);
+ visiting.delete(id);
+ done.add(id);
+ };
+ ids.forEach(visit);
+ }
+ }
+ return {valid: errors.length === 0, errors, warnings};
+}
diff --git a/package.json b/package.json
index 0ecd1fe..d12472a 100644
--- a/package.json
+++ b/package.json
@@ -2,5 +2,10 @@
"name": "genarrative-genielabs-spine-pipeline",
"private": true,
"type": "module",
- "scripts": {"run": "node pipeline.mjs"}
+ "scripts": {
+ "run": "node pipeline.mjs",
+ "models": "node tools/model-check.mjs --list-models",
+ "test": "node --test"
+ },
+ "engines": {"node": ">=20"}
}
diff --git a/pipeline.mjs b/pipeline.mjs
index a2f9bec..246f4e0 100644
--- a/pipeline.mjs
+++ b/pipeline.mjs
@@ -13,7 +13,7 @@ import {fileURLToPath} from 'node:url';
import path from 'node:path';
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
-const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai';
+const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai');
const defaultModel = 'gpt-image-2';
const positivePrompt = [
'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.',
@@ -57,12 +57,8 @@ function run(command, commandArgs, cwd = repoRoot) {
}
function resolveModel(model) {
- const normalized = String(model || '').trim();
- if (normalized === 'gpt-image-2.5' || normalized === 'gpt image 2.5') {
- console.warn('提示:VectorEngine 当前没有 gpt-image-2.5,已兼容映射为 gpt-image-2。');
- return defaultModel;
- }
- return normalized || defaultModel;
+ // Preserve exact provider IDs so experiments never silently use another model.
+ return String(model || '').trim() || defaultModel;
}
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
@@ -87,14 +83,17 @@ function loadEnvFile(file) {
}
function selectPython(requested) {
- const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean);
+ const venvPython = path.join(repoRoot, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', process.platform === 'win32' ? 'python.exe' : 'python');
+ const candidates = requested ? [requested] : ['python', 'python3', venvPython];
for (const candidate of candidates) {
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return candidate;
}
- const venvPython = path.join(repoRoot, '.venv', 'bin', 'python');
+ if (requested) throw new Error(`指定的 Python 无法导入 cv2、PIL、numpy:${requested};请在该环境安装 requirements.txt`);
if (!existsSync(venvPython)) {
- const bootstrap = spawnSync('python3', ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
+ const bootstrapPython = ['python', 'python3'].find((candidate) => spawnSync(candidate, ['-c', 'import sys'], {stdio: 'ignore'}).status === 0);
+ if (!bootstrapPython) throw new Error('未找到 Python,请激活环境或通过 --python 指定');
+ const bootstrap = spawnSync(bootstrapPython, ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'});
if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt');
@@ -248,6 +247,12 @@ function writeReferencePreview(reference, outputPath) {
}
async function main() {
+ const modeIndex = process.argv.indexOf('--mode');
+ if (modeIndex >= 0) {
+ if (process.argv[modeIndex + 1] !== 'annotated-parts') throw new Error('新增 --mode 仅支持 annotated-parts;旧流程无需 --mode');
+ const {main: modelMain} = await import('./tools/model-check.mjs');
+ return modelMain(process.argv.slice(2));
+ }
const options = args(process.argv);
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
options.python = selectPython(options.python);
diff --git a/prompts/complete-v1.txt b/prompts/complete-v1.txt
new file mode 100644
index 0000000..4f1a382
--- /dev/null
+++ b/prompts/complete-v1.txt
@@ -0,0 +1,10 @@
+Complete only the hidden portion of the specified character part on the first
+input image (the local canvas). Any subsequent images are context references.
+Preserve the exact original pose, scale, screen direction, perspective, colors,
+line thickness and shading. Do not mirror, rotate, resize, recenter or redesign.
+Keep the canvas dimensions and the position of every already-visible feature.
+Extend the hidden joint area naturally behind its occluder, without adding other
+body parts, props, labels, annotations or extra versions of the same part.
+If an edit mask is supplied, transparent mask pixels are editable. If no edit
+mask is supplied, this request is only an adapter experiment, not a guaranteed
+pixel-preserving final asset. A later stage will restore source visible pixels.
diff --git a/prompts/understand-v1.txt b/prompts/understand-v1.txt
new file mode 100644
index 0000000..149b2db
--- /dev/null
+++ b/prompts/understand-v1.txt
@@ -0,0 +1,20 @@
+Analyze the supplied single character image for a Spine cutout animation pipeline.
+Return a JSON part plan, not an image. Preserve the exact pose, asymmetry, clothing,
+and screen-left/screen-right orientation. Do not require a fixed number of parts.
+Use stable lowercase ASCII IDs (letters, numbers, underscores, hyphens).
+Separate parts only where independent motion is useful and the image supports it.
+Parent refers to the motion hierarchy; null means attached to the future root bone.
+Occlusion is a separate relation: occluded_by lists parts visually in front.
+All coordinates are normalized to the full supplied canvas, including transparent
+margins. bbox_norm_xyxy is [xmin,ymin,xmax,ymax], never [x,y,width,height].
+pivot_norm is the estimated attachment joint, not necessarily the bbox center.
+For visible or partly visible parts give a coarse visible bbox and positive points
+inside visible pixels; optional negative points identify nearby other parts.
+Do not claim precise pixel masks. A segmentation stage will refine boundaries.
+If a part is fully hidden, mark visibility="hidden", bbox_norm_xyxy=null,
+positive_points_norm=[], needs_completion=true and explain the uncertainty.
+Do not invent hidden limbs or accessories without visual evidence. Put assumptions
+in notes. Confidence is your uncertainty estimate, not a measured segmentation score.
+Avoid cycles in parent and occluded_by relations. If an object crosses in front
+and behind another, split its visual layers or flag the ambiguity in notes.
+Return schema_version=1, coordinate_space="normalized_xy_top_left", parts, notes.
diff --git a/providers/http.mjs b/providers/http.mjs
new file mode 100644
index 0000000..39a5507
--- /dev/null
+++ b/providers/http.mjs
@@ -0,0 +1,112 @@
+export function endpoint(baseUrl, route) {
+ const base = baseUrl.replace(/\/+$/u, '');
+ return `${base.endsWith('/v1') ? base : `${base}/v1`}/${route}`;
+}
+
+export function redact(value, secret = '') {
+ let text = typeof value === 'string' ? value : JSON.stringify(value);
+ if (secret) text = text.split(secret).join('[REDACTED]');
+ return text.replace(/Bearer\s+[^\s"<>]+/giu, 'Bearer [REDACTED]');
+}
+
+// Some relay channels require SSE even for offline jobs. Accept a terminal
+// Responses event (not only text deltas) so truncated streams cannot pass QA.
+export function parseEventStream(raw) {
+ let terminal = null;
+ let text = '';
+ let model = null;
+ let usage = null;
+ let id = null;
+ let finishReason = null;
+ let refusal = '';
+ for (const block of raw.replace(/\r\n/gu, '\n').split(/\n\n/u)) {
+ const data = block.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
+ if (!data || data.trim() === '[DONE]') continue;
+ let event;
+ try { event = JSON.parse(data); } catch { throw new Error('SSE 包含不完整或非法 JSON 事件'); }
+ if (event.error || event.type === 'error') throw new Error(`SSE 服务端错误:${event.error?.message || event.message || 'unknown error'}`);
+ if (['response.completed', 'response.incomplete', 'response.failed'].includes(event.type)) {
+ if (!event.response || typeof event.response !== 'object') throw new Error('SSE 终止事件缺少 response');
+ terminal = event.response;
+ terminal.status ||= event.type.slice('response.'.length);
+ }
+ if (event.choices) {
+ model = event.model || model; id = event.id || id; usage = event.usage || usage;
+ const choice = event.choices.find((item) => (item.index ?? 0) === 0);
+ if (typeof choice?.delta?.content === 'string') text += choice.delta.content;
+ if (typeof choice?.delta?.refusal === 'string') refusal += choice.delta.refusal;
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
+ }
+ }
+ if (terminal) return terminal;
+ if (finishReason) return {id, model, usage, choices: [{finish_reason: finishReason, message: {content: text, ...(refusal ? {refusal} : {})}}]};
+ throw new Error('SSE 未收到完整终止事件,可能连接中断;不接受部分输出');
+}
+
+// Retry only explicit 429/5xx responses. A timeout/network failure may already
+// have generated and billed an image; do not automatically duplicate that job.
+export async function requestJson(stage, route, makeRequest, {
+ fetchImpl = fetch,
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+} = {}) {
+ const started = Date.now();
+ for (let attempt = 0; attempt <= stage.maxRetries; attempt += 1) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), stage.timeoutMs);
+ let diagnostics = null;
+ try {
+ const request = makeRequest();
+ const response = await fetchImpl(endpoint(stage.baseUrl, route), {
+ ...request,
+ headers: {...request.headers, Authorization: `Bearer ${stage.apiKey}`},
+ signal: controller.signal,
+ redirect: 'error',
+ });
+ const raw = await response.text();
+ diagnostics = {
+ http_status: response.status, request_id: response.headers.get('x-request-id'),
+ elapsed_ms: Date.now() - started, attempts: attempt + 1,
+ // Bounded diagnostics; normal successful artifacts retain full payloads.
+ response_body: raw.slice(0, 2_000_000), truncated: raw.length > 2_000_000,
+ };
+ let payload;
+ try { payload = JSON.parse(raw); } catch { payload = null; }
+ if (!response.ok) {
+ if ((response.status === 429 || response.status >= 500) && attempt < stage.maxRetries) {
+ clearTimeout(timer);
+ await sleep(Math.min(10000, 2000 * (2 ** attempt)));
+ continue;
+ }
+ const message = payload?.error?.message || `请求未成功 (${response.status})`;
+ throw new Error(redact(`HTTP ${response.status}: ${message}`, stage.apiKey));
+ }
+ const isSse = response.headers.get('content-type')?.includes('text/event-stream') || /^data:|\nevent:|^event:/u.test(raw);
+ if (!payload && isSse) payload = parseEventStream(raw);
+ if (!payload || typeof payload !== 'object') throw new Error('服务端没有返回 JSON 对象或完整 SSE');
+ if (payload.error) throw new Error(redact(`服务端错误:${payload.error.message || 'unknown error'}`, stage.apiKey));
+ return {payload, metadata: {
+ requested_model: stage.model, returned_model: payload.model || null,
+ request_id: response.headers.get('x-request-id') || payload.id || null,
+ usage: payload.usage || null, elapsed_ms: Date.now() - started, attempts: attempt + 1, wire_format: isSse ? 'sse' : 'json',
+ }};
+ } catch (error) {
+ const cause = error.cause?.code || error.cause?.message;
+ const failure = new Error(controller.signal.aborted
+ ? `请求超时 (${stage.timeoutMs} ms),未自动重试;请检查中转站任务状态`
+ : redact(`${error.message}${cause ? ` (${cause})` : ''}`, stage.apiKey));
+ failure.diagnostics = diagnostics
+ ? JSON.parse(redact(diagnostics, stage.apiKey))
+ : {elapsed_ms: Date.now() - started, attempts: attempt + 1, network_code: cause || null};
+ throw failure;
+ } finally { clearTimeout(timer); }
+ }
+}
+
+export async function downloadImage(url, stage, fetchImpl = fetch) {
+ const parsed = new URL(url);
+ if (!['https:', 'http:'].includes(parsed.protocol)) throw new Error('图片 URL 必须使用 HTTP(S)');
+ const response = await fetchImpl(parsed, {signal: AbortSignal.timeout(stage.timeoutMs)});
+ if (!response.ok) throw new Error(`下载图片失败:HTTP ${response.status}`);
+ // Never forward the relay API key to image download hosts.
+ return Buffer.from(await response.arrayBuffer());
+}
diff --git a/providers/image-edits.mjs b/providers/image-edits.mjs
new file mode 100644
index 0000000..19eafd1
--- /dev/null
+++ b/providers/image-edits.mjs
@@ -0,0 +1,45 @@
+import path from 'node:path';
+import {imageFormat} from '../lib/images.mjs';
+import {requestJson, downloadImage} from './http.mjs';
+
+export function validateEditInputs(stage, images, mask) {
+ if (!images.length) throw new Error('图片编辑至少需要一张输入图');
+ if (images.length > 1 && !stage.capabilities.multiple_images) throw new Error('尚未确认此提供方支持多图;请先验证再启用 capabilities.multiple_images');
+ if (mask) {
+ if (!stage.capabilities.mask) throw new Error('尚未确认此提供方支持编辑掩码;不能静默忽略 --mask');
+ if (stage.maskEditable !== 'transparent') throw new Error('当前掩码适配仅支持透明像素为可编辑区域');
+ if (mask.extension !== 'png' || mask.width !== images[0].width || mask.height !== images[0].height) {
+ throw new Error('编辑 mask 必须为与第一张输入图同尺寸的 PNG');
+ }
+ if (![4, 6].includes(mask.bytes[25])) throw new Error('编辑 mask 必须包含 alpha 通道(透明处允许编辑),不能直接传 SAM 黑白掩码');
+ }
+}
+
+export async function editImage(stage, {images, mask, prompt}, options) {
+ validateEditInputs(stage, images, mask);
+ return requestJson(stage, 'images/edits', () => {
+ const form = new FormData();
+ form.append('model', stage.model);
+ form.append('prompt', prompt);
+ for (const [name, value] of Object.entries(stage.params)) {
+ if (value !== null && value !== undefined) form.append(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
+ }
+ for (const image of images) form.append(stage.imageField, new Blob([image.bytes], {type: image.mime}), path.basename(image.file));
+ if (mask) form.append('mask', new Blob([mask.bytes], {type: mask.mime}), path.basename(mask.file));
+ return {method: 'POST', body: form};
+ }, options);
+}
+
+export async function decodeEditedImages(payload, stage, {fetchImpl = fetch} = {}) {
+ const items = payload.data || payload.images || payload.output;
+ if (!Array.isArray(items) || !items.length) throw new Error('图片编辑响应没有图片列表');
+ const decoded = [];
+ for (const item of items) {
+ let bytes;
+ if (typeof item.b64_json === 'string') bytes = Buffer.from(item.b64_json, 'base64');
+ else if (typeof item.url === 'string') bytes = await downloadImage(item.url, stage, fetchImpl);
+ else throw new Error('图片编辑响应缺少 b64_json/url');
+ decoded.push({bytes, ...imageFormat(bytes)});
+ }
+ return decoded;
+}
diff --git a/providers/vision.mjs b/providers/vision.mjs
new file mode 100644
index 0000000..8ad67fd
--- /dev/null
+++ b/providers/vision.mjs
@@ -0,0 +1,53 @@
+import {dataUrl} from '../lib/images.mjs';
+import {requestJson} from './http.mjs';
+
+export function buildVisionRequest(stage, image, schema) {
+ const instruction = `${stage.prompt}\n\nCanvas: ${image.width} x ${image.height} pixels.\nReturn ONLY JSON matching this schema:\n${JSON.stringify(schema)}`;
+ if (stage.protocol === 'chat-completions') {
+ const body = { ...stage.params, model: stage.model, stream: stage.stream,
+ messages: [{role: 'user', content: [
+ {type: 'text', text: instruction},
+ {type: 'image_url', image_url: {url: dataUrl(image)}},
+ ]}],
+ };
+ if (stage.outputMode === 'json-object') body.response_format = {type: 'json_object'};
+ if (stage.outputMode === 'json-schema') body.response_format = {type: 'json_schema', json_schema: {name: 'semantic_parts', strict: true, schema}};
+ return {route: 'chat/completions', body};
+ }
+ if (stage.protocol === 'responses') {
+ const body = {...stage.params, model: stage.model, stream: stage.stream, instructions: stage.prompt, input: [{role: 'user', content: [
+ {type: 'input_text', text: instruction},
+ {type: 'input_image', image_url: dataUrl(image)},
+ ]}]};
+ if (stage.outputMode === 'json-object') body.text = {format: {type: 'json_object'}};
+ if (stage.outputMode === 'json-schema') body.text = {format: {type: 'json_schema', name: 'semantic_parts', strict: true, schema}};
+ return {route: 'responses', body};
+ }
+ throw new Error(`未知理解协议:${stage.protocol}`);
+}
+
+export function extractVisionText(payload, protocol) {
+ if (protocol === 'chat-completions') {
+ const choice = payload.choices?.[0];
+ if (choice?.message?.refusal) throw new Error('结构理解请求被模型拒绝');
+ if (choice?.finish_reason && choice.finish_reason !== 'stop') throw new Error(`结构理解响应未完整结束:${choice.finish_reason}`);
+ const content = choice?.message?.content;
+ if (typeof content === 'string' && content.trim()) return content;
+ if (Array.isArray(content)) {
+ const text = content.filter((item) => item.type === 'text').map((item) => item.text).join('\n');
+ if (text.trim()) return text;
+ }
+ } else {
+ if (payload.status && payload.status !== 'completed') throw new Error(`结构理解响应状态:${payload.status}`);
+ const items = (payload.output || []).flatMap((item) => item.content || []);
+ if (items.some((item) => item.type === 'refusal')) throw new Error('结构理解请求被模型拒绝');
+ const text = payload.output_text || items.filter((item) => item.type === 'output_text').map((item) => item.text).join('\n');
+ if (typeof text === 'string' && text.trim()) return text;
+ }
+ throw new Error('服务端响应中没有结构理解文本');
+}
+
+export async function analyze(stage, image, schema, options) {
+ const {route, body} = buildVisionRequest(stage, image, schema);
+ return requestJson(stage, route, () => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}), options);
+}
diff --git a/schemas/semantic-parts.schema.json b/schemas/semantic-parts.schema.json
new file mode 100644
index 0000000..2305176
--- /dev/null
+++ b/schemas/semantic-parts.schema.json
@@ -0,0 +1,30 @@
+{
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema_version", "coordinate_space", "parts", "notes"],
+ "properties": {
+ "schema_version": {"type": "integer", "enum": [1]},
+ "coordinate_space": {"type": "string", "enum": ["normalized_xy_top_left"]},
+ "notes": {"type": "array", "items": {"type": "string"}},
+ "parts": {
+ "type": "array", "minItems": 1, "maxItems": 64,
+ "items": {
+ "type": "object", "additionalProperties": false,
+ "required": ["id", "semantic", "parent", "visibility", "bbox_norm_xyxy", "pivot_norm", "positive_points_norm", "negative_points_norm", "occluded_by", "needs_completion", "confidence"],
+ "properties": {
+ "id": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$", "maxLength": 64},
+ "semantic": {"type": "string", "minLength": 1},
+ "parent": {"type": ["string", "null"]},
+ "visibility": {"type": "string", "enum": ["visible", "partial", "hidden"]},
+ "bbox_norm_xyxy": {"type": ["array", "null"], "minItems": 4, "maxItems": 4, "items": {"type": "number", "minimum": 0, "maximum": 1}},
+ "pivot_norm": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}},
+ "positive_points_norm": {"type": "array", "maxItems": 32, "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}}},
+ "negative_points_norm": {"type": "array", "maxItems": 32, "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number", "minimum": 0, "maximum": 1}}},
+ "occluded_by": {"type": "array", "items": {"type": "string"}},
+ "needs_completion": {"type": "boolean"},
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1}
+ }
+ }
+ }
+ }
+}
diff --git a/stages/model-stage.mjs b/stages/model-stage.mjs
new file mode 100644
index 0000000..467f922
--- /dev/null
+++ b/stages/model-stage.mjs
@@ -0,0 +1,118 @@
+import {readFileSync, writeFileSync} from 'node:fs';
+import {execFileSync} from 'node:child_process';
+import path from 'node:path';
+import {projectRoot, publicStage, samInfo} from '../lib/config.mjs';
+import {readImage, sha256} from '../lib/images.mjs';
+import {partsSchema, parseParts, validateParts} from '../lib/validate-parts.mjs';
+import {annotatedSvg, understandingReport} from '../lib/report.mjs';
+import {prepareRun, jsonFile, finishRun} from '../lib/run-artifacts.mjs';
+import {analyze, extractVisionText} from '../providers/vision.mjs';
+import {editImage, validateEditInputs, decodeEditedImages} from '../providers/image-edits.mjs';
+import {redact} from '../providers/http.mjs';
+
+function codeVersion() {
+ // Include uncommitted source changes as well as a commit ID for experiments.
+ const files = [
+ 'stages/model-stage.mjs', 'lib/config.mjs', 'lib/images.mjs', 'lib/report.mjs',
+ 'lib/run-artifacts.mjs', 'lib/validate-parts.mjs', 'providers/http.mjs',
+ 'providers/vision.mjs', 'providers/image-edits.mjs', 'tools/model-check.mjs',
+ 'schemas/semantic-parts.schema.json',
+ ];
+ const sourceHash = sha256(files.map((file) => `${file}\n${readFileSync(path.join(projectRoot, file), 'utf8')}`).join('\n'));
+ let commit = null;
+ try { commit = execFileSync('git', ['rev-parse', 'HEAD'], {cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}).trim(); } catch { /* Not all distributions are Git checkouts. */ }
+ return {commit, source_sha256: sourceHash};
+}
+
+function runIdentity(stage, images, extra) {
+ return {
+ schema_version: 1, stage: stage.name, resolved_stage: publicStage(stage),
+ endpoint_sha256: sha256(stage.baseUrl), prompt_sha256: sha256(stage.prompt),
+ inputs: images.map((image) => ({sha256: image.sha256, mime: image.mime, width: image.width ?? null, height: image.height ?? null})),
+ code: codeVersion(), ...extra,
+ };
+}
+
+export async function runUnderstanding({loaded, stage, reference, outDir, resume = false}, transport) {
+ const image = readImage(reference, {pngOnly: true});
+ const identity = runIdentity(stage, [image], {schema_sha256: sha256(JSON.stringify(partsSchema))});
+ const run = prepareRun(outDir, sha256(JSON.stringify(identity)), resume);
+ if (run.reused) return {out: run.out, reused: true};
+ const manifest = {...identity, fingerprint: sha256(JSON.stringify(identity)), status: 'running', quality_status: 'pending', started_at: new Date().toISOString(), sam: samInfo(loaded)};
+ jsonFile(run.manifestPath, manifest, stage.apiKey);
+ writeFileSync(path.join(run.out, 'source.png'), image.bytes);
+ writeFileSync(path.join(run.out, 'prompt.txt'), stage.prompt);
+ jsonFile(path.join(run.out, 'request.json'), {...identity, reference: 'source.png'}, stage.apiKey);
+ try {
+ const {payload, metadata} = await analyze(stage, image, partsSchema, transport);
+ jsonFile(path.join(run.out, 'response.raw.json'), payload, stage.apiKey);
+ jsonFile(path.join(run.out, 'request-metadata.json'), metadata, stage.apiKey);
+ let data = null;
+ let validation;
+ try {
+ const text = extractVisionText(payload, stage.protocol);
+ writeFileSync(path.join(run.out, 'response.txt'), redact(text, stage.apiKey));
+ data = parseParts(text);
+ validation = validateParts(data);
+ } catch (error) { validation = {valid: false, errors: [redact(error.message, stage.apiKey)], warnings: []}; }
+ jsonFile(path.join(run.out, 'validation.json'), validation, stage.apiKey);
+ writeFileSync(path.join(run.out, 'report.html'), redact(understandingReport(image, data, validation, metadata), stage.apiKey));
+ if (!validation.valid) throw new Error('结构理解结果未通过本地校验,请查看 validation.json、response.raw.json 和 report.html');
+ jsonFile(path.join(run.out, 'semantic-parts.json'), data, stage.apiKey);
+ writeFileSync(path.join(run.out, 'annotated.svg'), redact(annotatedSvg(image, data.parts), stage.apiKey));
+ manifest.request = metadata;
+ manifest.quality_status = 'needs_review';
+ manifest.completed_at = new Date().toISOString();
+ finishRun(run, manifest, ['source.png', 'prompt.txt', 'request.json', 'response.raw.json', 'response.txt', 'request-metadata.json', 'validation.json', 'report.html', 'semantic-parts.json', 'annotated.svg'], stage.apiKey);
+ return {out: run.out, reused: false, parts: data.parts.length};
+ } catch (error) {
+ if (error.diagnostics) jsonFile(path.join(run.out, 'response.error.json'), error.diagnostics, stage.apiKey);
+ manifest.status = 'failed';
+ manifest.quality_status = 'failed';
+ manifest.error = redact(error.message, stage.apiKey);
+ jsonFile(run.manifestPath, manifest, stage.apiKey);
+ throw error;
+ }
+}
+
+export async function runCompletionProbe({stage, imagePaths, maskPath, promptFile, outDir, resume = false}, transport) {
+ const images = imagePaths.map((file) => readImage(file, {pngOnly: true}));
+ const mask = maskPath ? readImage(maskPath, {pngOnly: true}) : null;
+ validateEditInputs(stage, images, mask);
+ const taskPrompt = promptFile ? readFileSync(promptFile, 'utf8') : '';
+ if (!taskPrompt.trim()) throw new Error('图片编辑测试需要 --prompt-file 明确指定本次部件和补全需求');
+ const prompt = `${stage.prompt}\n\nTask:\n${taskPrompt}`;
+ const identity = runIdentity(stage, images, {task_prompt_sha256: sha256(taskPrompt), mask_sha256: mask?.sha256 || null});
+ const run = prepareRun(outDir, sha256(JSON.stringify(identity)), resume);
+ if (run.reused) return {out: run.out, reused: true};
+ const manifest = {...identity, fingerprint: sha256(JSON.stringify(identity)), status: 'running', quality_status: 'pending', started_at: new Date().toISOString(), output_kind: 'raw_completion_probe'};
+ jsonFile(run.manifestPath, manifest, stage.apiKey);
+ const artifacts = [];
+ images.forEach((image, i) => { const name = `input-${i}.png`; writeFileSync(path.join(run.out, name), image.bytes); artifacts.push(name); });
+ if (mask) { writeFileSync(path.join(run.out, 'edit-mask.png'), mask.bytes); artifacts.push('edit-mask.png'); }
+ writeFileSync(path.join(run.out, 'prompt.txt'), prompt);
+ jsonFile(path.join(run.out, 'request.json'), identity, stage.apiKey);
+ try {
+ const {payload, metadata} = await editImage(stage, {images, mask, prompt}, transport);
+ jsonFile(path.join(run.out, 'response.raw.json'), payload, stage.apiKey);
+ jsonFile(path.join(run.out, 'request-metadata.json'), metadata, stage.apiKey);
+ const outputs = await decodeEditedImages(payload, stage, transport);
+ manifest.outputs = outputs.map((output, index) => {
+ const file = `candidate-${index + 1}.${output.extension}`;
+ writeFileSync(path.join(run.out, file), output.bytes);
+ artifacts.push(file);
+ return {file, width: output.width ?? null, height: output.height ?? null, canvas_size_matches: output.width ? output.width === images[0].width && output.height === images[0].height : null};
+ });
+ manifest.request = metadata;
+ manifest.quality_status = 'needs_review';
+ manifest.notes = ['仅验证图片编辑适配器;尚未执行掩码提取、可见像素恢复、几何验收或 Spine 导出。', 'mask/多图能力由显式配置声明,不能仅凭 API 返回 200 判断模型遵循约束。'];
+ manifest.completed_at = new Date().toISOString();
+ finishRun(run, manifest, [...artifacts, 'prompt.txt', 'request.json', 'response.raw.json', 'request-metadata.json'], stage.apiKey);
+ return {out: run.out, reused: false, candidates: outputs.length};
+ } catch (error) {
+ if (error.diagnostics) jsonFile(path.join(run.out, 'response.error.json'), error.diagnostics, stage.apiKey);
+ manifest.status = 'failed'; manifest.error = redact(error.message, stage.apiKey);
+ jsonFile(run.manifestPath, manifest, stage.apiKey);
+ throw error;
+ }
+}
diff --git a/tests/model-stages.test.mjs b/tests/model-stages.test.mjs
new file mode 100644
index 0000000..9b95c73
--- /dev/null
+++ b/tests/model-stages.test.mjs
@@ -0,0 +1,228 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import path from 'node:path';
+import {createServer} from 'node:http';
+import {execFile} from 'node:child_process';
+import {promisify} from 'node:util';
+import {loadConfig, resolveStage, projectRoot, publicStage} from '../lib/config.mjs';
+import {readImage} from '../lib/images.mjs';
+import {validateParts, partsSchema} from '../lib/validate-parts.mjs';
+import {requestJson, parseEventStream} from '../providers/http.mjs';
+import {buildVisionRequest, extractVisionText} from '../providers/vision.mjs';
+import {editImage, decodeEditedImages, validateEditInputs} from '../providers/image-edits.mjs';
+import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
+
+const exec = promisify(execFile);
+const reference = path.join(projectRoot, 'references', 'characters', 'kenney-beige-idle.png');
+const image = readImage(reference, {pngOnly: true});
+const part = (id, parent = null) => ({id, semantic: id, parent, visibility: 'visible', bbox_norm_xyxy: [0.1, 0.1, 0.9, 0.9], pivot_norm: [0.5, 0.5], positive_points_norm: [[0.5, 0.5]], negative_points_norm: [], occluded_by: [], needs_completion: false, confidence: 0.8});
+const goodParts = () => ({schema_version: 1, coordinate_space: 'normalized_xy_top_left', parts: [part('torso'), part('head', 'torso')], notes: []});
+const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {status, headers: {'Content-Type': 'application/json', 'x-request-id': 'test-request'}});
+const stageFor = (name = 'understanding') => resolveStage(loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid/v1', VECTOR_ENGINE_API_KEY: 'test-secret'}}), name, name === 'understanding' ? {protocol: 'chat-completions'} : {});
+const chatPayload = (parts = goodParts()) => ({model: 'gpt-6-astra', choices: [{finish_reason: 'stop', message: {content: JSON.stringify(parts)}}], usage: {total_tokens: 12}});
+
+function temporary(t) {
+ const base = realpathSync(tmpdir());
+ const dir = mkdtempSync(path.join(base, 'ai-spine-test-'));
+ t.after(() => {
+ const resolved = realpathSync(dir);
+ assert.equal(path.dirname(resolved), base);
+ assert.ok(path.basename(resolved).startsWith('ai-spine-test-'));
+ rmSync(resolved, {recursive: true});
+ });
+ return dir;
+}
+
+test('model precedence, independent model selection, no aliases, no secrets in public config', () => {
+ const loaded = loadConfig({envFile: '__no_test_env__', env: {VECTOR_ENGINE_BASE_URL: 'https://relay.invalid', VECTOR_ENGINE_API_KEY: 'secret', SPINE_UNDERSTANDING_MODEL: 'env-vision', SPINE_COMPLETION_MODEL: 'env-image'}});
+ assert.equal(resolveStage(loaded, 'understanding').model, 'env-vision');
+ loaded.config.stages.understanding.model = 'config-vision';
+ assert.equal(resolveStage(loaded, 'understanding').model, 'config-vision');
+ assert.equal(resolveStage(loaded, 'understanding', {model: 'cli-vision'}).model, 'cli-vision');
+ assert.equal(resolveStage(loaded, 'understanding', {stream: false}).stream, false);
+ assert.equal(resolveStage(loaded, 'completion').model, 'env-image');
+ assert.equal(resolveStage(loaded, 'completion', {model: 'gpt-image-2.5'}).model, 'gpt-image-2.5');
+ assert.ok(!JSON.stringify(publicStage(resolveStage(loaded, 'understanding'))).includes('secret'));
+ loaded.config.stages.understanding.params = {model: 'sneaky'};
+ assert.throws(() => resolveStage(loaded, 'understanding'), /保留字段/u);
+});
+
+test('Chat and Responses payloads retain exact model/image/schema and parse structured text', () => {
+ const stage = stageFor();
+ stage.outputMode = 'json-schema';
+ let request = buildVisionRequest(stage, image, partsSchema);
+ assert.equal(request.route, 'chat/completions');
+ assert.equal(request.body.model, 'gpt-6-astra');
+ assert.ok(request.body.messages[0].content[1].image_url.url.startsWith('data:image/png;base64,'));
+ assert.equal(request.body.response_format.json_schema.schema, partsSchema);
+ stage.protocol = 'responses';
+ request = buildVisionRequest(stage, image, partsSchema);
+ assert.equal(request.route, 'responses');
+ assert.equal(request.body.text.format.type, 'json_schema');
+ assert.equal(request.body.input[0].content[1].type, 'input_image');
+ assert.equal(extractVisionText({status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: '{}'}]}]}, 'responses'), '{}');
+ assert.throws(() => extractVisionText({status: 'incomplete', output_text: '{}'}, 'responses'), /状态/u);
+ assert.throws(() => extractVisionText({choices: [{finish_reason: 'length', message: {content: '{}'}}]}, 'chat-completions'), /未完整/u);
+});
+
+test('schema + semantic validation reject invalid coordinates, references and both graph cycles', () => {
+ assert.equal(validateParts(goodParts()).valid, true);
+ for (const mutate of [
+ (d) => { d.parts[0].bbox_norm_xyxy = [0.9, 0, 0.1, 1]; },
+ (d) => { d.parts[0].confidence = 2; },
+ (d) => { d.parts[0].parent = 'missing'; },
+ (d) => { d.parts[0].parent = 'head'; },
+ (d) => { d.parts[0].occluded_by = ['head']; d.parts[1].occluded_by = ['torso']; },
+ (d) => { d.parts[1].id = 'torso'; },
+ (d) => { d.parts[0].visibility = 'hidden'; },
+ (d) => { d.parts[0].positive_points_norm = [[0, 0]]; },
+ (d) => { d.parts[0].unknown = 1; },
+ ]) {
+ const data = goodParts(); mutate(data);
+ assert.equal(validateParts(data).valid, false);
+ }
+});
+
+test('SSE requires terminal completion, supports Responses and Chat, rejects truncated/error streams', async () => {
+ const payload = {status: 'completed', model: 'gpt-6-astra', output: [{content: [{type: 'output_text', text: '{}'}]}]};
+ const stream = `event: response.completed\r\ndata: ${JSON.stringify({type: 'response.completed', response: payload})}\r\n\r\ndata: [DONE]\r\n\r\n`;
+ assert.deepEqual(parseEventStream(stream), payload);
+ const result = await requestJson(stageFor(), 'responses', () => ({method: 'POST'}), {fetchImpl: async () => new Response(stream, {headers: {'content-type': 'text/event-stream'}})});
+ assert.equal(result.metadata.wire_format, 'sse');
+ assert.equal(extractVisionText(result.payload, 'responses'), '{}');
+ assert.throws(() => parseEventStream('data: {"type":"response.output_text.delta","delta":"{}"}\n\ndata: [DONE]\n\n'), /终止事件/u);
+ assert.throws(() => parseEventStream('data: {"type":"error","message":"bad"}\n\n'), /bad/u);
+ const chatStream = 'data: {"model":"chat","choices":[{"index":0,"delta":{"content":"{}"},"finish_reason":null}]}\n\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n';
+ assert.equal(extractVisionText(parseEventStream(chatStream), 'chat-completions'), '{}');
+});
+
+test('HTTP retries explicit 429/5xx, not 400 or network errors; credentials redacted', async () => {
+ const stage = stageFor();
+ let count = 0;
+ const result = await requestJson(stage, 'models', () => ({method: 'GET'}), {
+ fetchImpl: async (url, options) => {
+ assert.equal(url, 'https://relay.invalid/v1/models');
+ assert.equal(options.headers.Authorization, 'Bearer test-secret');
+ count += 1;
+ return count < 3 ? jsonResponse({error: {message: 'retry'}}, count === 1 ? 429 : 500) : jsonResponse({data: []});
+ }, sleep: async () => {},
+ });
+ assert.equal(result.metadata.attempts, 3);
+ count = 0;
+ await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; return jsonResponse({error: {message: 'test-secret'}}, 400); }}), (error) => !error.message.includes('test-secret'));
+ assert.equal(count, 1);
+ count = 0;
+ await assert.rejects(requestJson(stage, 'models', () => ({method: 'GET'}), {fetchImpl: async () => { count += 1; throw new Error('network'); }}), /network/u);
+ assert.equal(count, 1);
+});
+
+test('image adapter sends multipart, blocks unconfirmed capabilities, decodes inline and downloaded images', async () => {
+ const stage = stageFor('completion');
+ assert.throws(() => validateEditInputs(stage, [image, image]), /多图/u);
+ assert.throws(() => validateEditInputs(stage, [image], image), /掩码/u);
+ let captured;
+ await editImage(stage, {images: [image], prompt: 'test'}, {fetchImpl: async (url, request) => {
+ captured = request;
+ assert.ok(url.endsWith('/images/edits'));
+ return jsonResponse({data: [{b64_json: image.bytes.toString('base64')}]});
+ }});
+ assert.equal(captured.body.get('model'), 'qwen-image-3.0');
+ assert.equal(captured.body.get('n'), '1');
+ assert.equal(captured.body.get('image').type, 'image/png');
+ assert.equal(captured.headers['Content-Type'], undefined);
+ const decoded = await decodeEditedImages({data: [{b64_json: image.bytes.toString('base64')}, {url: 'https://cdn.invalid/output.png'}]}, stage, {fetchImpl: async (url, request) => {
+ assert.equal(request.headers, undefined);
+ return new Response(image.bytes);
+ }});
+ assert.equal(decoded.length, 2);
+ assert.equal(decoded[0].width, 128);
+ await assert.rejects(decodeEditedImages({data: [{b64_json: Buffer.from('error').toString('base64')}]}, stage), /图片数据/u);
+ await assert.rejects(decodeEditedImages({data: [{url: 'https://cdn.invalid/404'}]}, stage, {fetchImpl: async () => new Response('', {status: 404})}), /404/u);
+});
+
+test('understanding run saves report and provenance; resume checks inputs/model/artifacts', async (t) => {
+ const dir = temporary(t);
+ const stage = stageFor();
+ const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
+ const outDir = path.join(dir, 'run');
+ const options = {loaded, stage, reference, outDir};
+ let calls = 0;
+ const transport = {fetchImpl: async () => { calls += 1; return jsonResponse(chatPayload()); }};
+ await runUnderstanding(options, transport);
+ const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json')));
+ assert.equal(manifest.status, 'completed');
+ assert.equal(manifest.quality_status, 'needs_review');
+ assert.equal(manifest.request.returned_model, 'gpt-6-astra');
+ assert.equal(manifest.inputs[0].width, 128);
+ assert.match(readFileSync(path.join(outDir, 'report.html'), 'utf8'), /尚未执行 SAM/u);
+ await assert.rejects(runUnderstanding(options, transport), /非空/u);
+ assert.equal((await runUnderstanding({...options, resume: true}, transport)).reused, true);
+ assert.equal(calls, 1);
+ await assert.rejects(runUnderstanding({...options, stage: {...stage, model: 'another-model'}, resume: true}, transport), /变化/u);
+ writeFileSync(path.join(outDir, 'semantic-parts.json'), '{}');
+ await assert.rejects(runUnderstanding({...options, resume: true}, transport), /被修改/u);
+});
+
+test('invalid/refused/truncated understanding stays failed, preserving raw responses', async (t) => {
+ const dir = temporary(t);
+ const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
+ const invalid = chatPayload(); invalid.choices[0].message.content = 'not json';
+ const refused = {choices: [{message: {refusal: 'no'}}]};
+ for (const [i, payload] of [invalid, refused].entries()) {
+ const outDir = path.join(dir, `failed-${i}`);
+ await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => jsonResponse(payload)}), /校验/u);
+ assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed');
+ assert.deepEqual(JSON.parse(readFileSync(path.join(outDir, 'response.raw.json'))), payload);
+ assert.equal(JSON.parse(readFileSync(path.join(outDir, 'validation.json'))).valid, false);
+ }
+});
+
+test('SSE overload keeps redacted diagnostics and a failed manifest', async (t) => {
+ const outDir = path.join(temporary(t), 'overloaded');
+ const loaded = loadConfig({envFile: '__no_test_env__', env: {}});
+ await assert.rejects(runUnderstanding({loaded, stage: stageFor(), reference, outDir}, {fetchImpl: async () => new Response('data: {"type":"error","message":"overloaded test-secret"}\n\n', {headers: {'content-type': 'text/event-stream'}})}), /overloaded/u);
+ const diagnostic = readFileSync(path.join(outDir, 'response.error.json'), 'utf8');
+ assert.ok(!diagnostic.includes('test-secret'));
+ assert.match(diagnostic, /REDACTED/u);
+ assert.equal(JSON.parse(diagnostic).http_status, 200);
+ assert.equal(JSON.parse(readFileSync(path.join(outDir, 'manifest.json'))).status, 'failed');
+});
+
+test('completion probe produces raw candidates and never claims final assets', async (t) => {
+ const dir = temporary(t);
+ const promptFile = path.join(dir, 'task.txt'); writeFileSync(promptFile, 'Complete this sleeve behind the torso.');
+ const outDir = path.join(dir, 'edit');
+ await runCompletionProbe({stage: stageFor('completion'), imagePaths: [reference], promptFile, outDir}, {fetchImpl: async () => jsonResponse({model: 'qwen-image-3.0', data: [{b64_json: image.bytes.toString('base64')}]})});
+ const manifest = JSON.parse(readFileSync(path.join(outDir, 'manifest.json')));
+ assert.equal(manifest.output_kind, 'raw_completion_probe');
+ assert.equal(manifest.quality_status, 'needs_review');
+ assert.equal(manifest.outputs[0].canvas_size_matches, true);
+ assert.deepEqual(readFileSync(path.join(outDir, 'candidate-1.png')), image.bytes);
+});
+
+test('pipeline new mode reaches a mock relay without Python; model listing and unsupported stages', async (t) => {
+ const dir = temporary(t);
+ let requests = 0;
+ const server = createServer(async (req, res) => {
+ requests += 1;
+ const chunks = []; for await (const chunk of req) chunks.push(chunk);
+ const body = Buffer.concat(chunks).toString();
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/v1/models') res.end(JSON.stringify({data: [{id: 'relay-vision-test'}]}));
+ else if (req.url === '/v1/responses' && JSON.parse(body).model === 'relay-vision-test') res.end(JSON.stringify({model: 'relay-vision-test', status: 'completed', output: [{type: 'message', content: [{type: 'output_text', text: JSON.stringify(goodParts())}]}]}));
+ else { res.statusCode = 400; res.end('{}'); }
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ t.after(() => new Promise((resolve) => server.close(resolve)));
+ const env = {...process.env, VECTOR_ENGINE_BASE_URL: `http://127.0.0.1:${server.address().port}`, VECTOR_ENGINE_API_KEY: 'mock-key'};
+ const outDir = path.join(dir, 'cli');
+ const result = await exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--reference', reference, '--out-dir', outDir, '--stop-after', 'understand', '--understanding-model', 'relay-vision-test', '--python', 'deliberately-nonexistent-python'], {cwd: projectRoot, env});
+ assert.match(result.stdout, /结构理解完成/u);
+ const list = await exec(process.execPath, ['tools/model-check.mjs', '--list-models'], {cwd: projectRoot, env});
+ assert.match(list.stdout, /relay-vision-test/u);
+ assert.equal(requests, 2);
+ await assert.rejects(exec(process.execPath, ['pipeline.mjs', '--mode', 'annotated-parts', '--stop-after', 'segment'], {cwd: projectRoot, env}), (error) => /仅支持 understand/u.test(error.stderr));
+ assert.equal(requests, 2);
+});
diff --git a/tools/model-check.mjs b/tools/model-check.mjs
new file mode 100644
index 0000000..9206935
--- /dev/null
+++ b/tools/model-check.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+import {loadConfig, resolveStage, publicStage, samInfo} from '../lib/config.mjs';
+import {requestJson} from '../providers/http.mjs';
+import {runUnderstanding, runCompletionProbe} from '../stages/model-stage.mjs';
+import {readImage} from '../lib/images.mjs';
+
+const help = `模型接入与结构理解(新增模式,不调用旧拆件流程)
+ node pipeline.mjs --mode annotated-parts --reference image.png --out-dir output/run --stop-after understand
+ node tools/model-check.mjs --stage completion --image local.png --prompt-file task.txt --out-dir output/edit
+ node tools/model-check.mjs --list-models
+ node tools/model-check.mjs --dry-run
+
+公共选项:
+ --config FILE 默认 configs/astra-qwen.json(prompt 相对配置文件)
+ --env-file FILE 默认项目 .env,shell 环境变量优先
+ --understanding-model ID 理解模型覆盖(使用原样 ID,不自动映射)
+ --completion-model ID 图片编辑模型覆盖
+ --understanding-protocol NAME chat-completions / responses
+ --stream true|false 覆盖理解阶段的流式设置
+ --output-mode NAME text-json / json-object / json-schema
+ --resume 输入/配置/代码和产物哈希相同时复用
+ --image FILE 图片编辑输入,可重复;多图需配置 capabilities
+ --mask FILE 透明处可编辑的 RGBA PNG;需配置 capabilities
+ --dry-run 检查配置与本地输入,不请求模型、不生成产物
+
+本轮 --stop-after 仅支持 understand。SAM 路径已配置,但分割/合并/导出尚未接入。`;
+
+export function parseOptions(argv) {
+ const options = {stage: 'understanding', imagePaths: []};
+ const valueFlags = new Map([
+ ['--config', 'configFile'], ['--env-file', 'envFile'], ['--reference', 'reference'], ['--out-dir', 'outDir'],
+ ['--understanding-model', 'understandingModel'], ['--completion-model', 'completionModel'],
+ ['--understanding-protocol', 'understandingProtocol'], ['--output-mode', 'outputMode'],
+ ['--stage', 'stage'], ['--mask', 'maskPath'], ['--prompt-file', 'promptFile'], ['--stop-after', 'stopAfter'],
+ ['--mode', 'mode'], ['--python', 'python'], ['--stream', 'streamRaw'],
+ ]);
+ for (let i = 0; i < argv.length; i += 1) {
+ const flag = argv[i];
+ if (['--help', '-h'].includes(flag)) options.help = true;
+ else if (flag === '--resume') options.resume = true;
+ else if (flag === '--dry-run') options.dryRun = true;
+ else if (flag === '--list-models') options.listModels = true;
+ else if (valueFlags.has(flag) || flag === '--image') {
+ const value = argv[++i];
+ if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值`);
+ if (flag === '--image') options.imagePaths.push(value);
+ else options[valueFlags.get(flag)] = value;
+ } else throw new Error(`未知参数:${flag},请用 --help 查看`);
+ }
+ return options;
+}
+
+export async function main(argv = process.argv.slice(2)) {
+ const options = parseOptions(argv);
+ if (options.help) { console.log(help); return; }
+ if (!['understanding', 'completion'].includes(options.stage)) throw new Error('--stage 仅支持 understanding 或 completion');
+ if (options.streamRaw !== undefined && !['true', 'false'].includes(options.streamRaw)) throw new Error('--stream 仅支持 true 或 false');
+ options.stream = options.streamRaw === undefined ? undefined : options.streamRaw === 'true';
+ if (options.mode && options.mode !== 'annotated-parts') throw new Error('新入口仅支持 --mode annotated-parts');
+ if (options.stopAfter && (options.stopAfter !== 'understand' || options.stage !== 'understanding')) {
+ throw new Error('本轮仅落地模型接入和结构理解;--stop-after 目前仅支持 understand');
+ }
+ const loaded = loadConfig(options);
+ const overrides = options.stage === 'understanding'
+ ? {model: options.understandingModel, protocol: options.understandingProtocol, outputMode: options.outputMode, stream: options.stream}
+ : {model: options.completionModel};
+ const stage = resolveStage(loaded, options.stage, overrides, {requireKey: !options.dryRun});
+ if (options.listModels) {
+ if (options.dryRun) throw new Error('--list-models 会请求模型列表,不能与 --dry-run 同时使用');
+ const {payload} = await requestJson(stage, 'models', () => ({method: 'GET'}));
+ if (!Array.isArray(payload.data)) throw new Error('模型列表响应缺少 data 数组,请核对中转站协议');
+ console.log(payload.data.map((item) => item.id).filter((id) => typeof id === 'string').join('\n'));
+ return;
+ }
+ if (options.dryRun) {
+ const understanding = resolveStage(loaded, 'understanding', {model: options.understandingModel, protocol: options.understandingProtocol, outputMode: options.outputMode, stream: options.stream}, {requireKey: false});
+ const completion = resolveStage(loaded, 'completion', {model: options.completionModel}, {requireKey: false});
+ if (options.reference) readImage(options.reference, {pngOnly: true});
+ for (const file of options.imagePaths) readImage(file, {pngOnly: true});
+ console.log(JSON.stringify({understanding: publicStage(understanding), completion: publicStage(completion), sam: samInfo(loaded), credentials_present: Boolean(stage.apiKey && stage.baseUrl), network_requested: false}, null, 2));
+ return;
+ }
+ if (!options.outDir) throw new Error('需要 --out-dir,建议每个实验使用独立目录');
+ let result;
+ if (options.stage === 'understanding') {
+ if (!options.reference) throw new Error('结构理解需要 --reference');
+ if (options.imagePaths.length || options.maskPath || options.promptFile) throw new Error('结构理解不接受图片编辑参数');
+ console.log(`角色结构理解:${stage.model} (${stage.protocol})`);
+ result = await runUnderstanding({...options, loaded, stage});
+ console.log(`${result.reused ? '复用已校验产物' : '结构理解完成,语义待复核'}:${path.join(result.out, 'report.html')}`);
+ } else {
+ if (options.reference) throw new Error('图片编辑使用 --image 指定局部画布;额外参考图也通过重复 --image 指定');
+ console.log(`图片编辑接入测试:${stage.model}`);
+ result = await runCompletionProbe({...options, stage});
+ console.log(`${result.reused ? '复用已校验产物' : '原始候选已保存,尚未生成最终部件'}:${result.out}`);
+ }
+ return result;
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ main().catch((error) => { console.error(`模型阶段失败:${error.message}`); process.exitCode = 1; });
+}