新方案v1角色结构理解
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` 显式指定一个只在受控后端使用的环境文件。
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 请求/响应测试,本轮未执行真实图片编辑生成。此状态不等于视觉质量已验证。
|
||||
@@ -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'};
|
||||
}
|
||||
@@ -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')}`; }
|
||||
@@ -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 ? `<line x1="${px}" y1="${py}" x2="${parent.pivot_norm[0] * w}" y2="${parent.pivot_norm[1] * h}" stroke-dasharray="${stroke * 3}"/>` : '';
|
||||
let box = '';
|
||||
if (part.bbox_norm_xyxy) {
|
||||
const [x0, y0, x1, y1] = part.bbox_norm_xyxy;
|
||||
box = `<rect x="${x0 * w}" y="${y0 * h}" width="${(x1 - x0) * w}" height="${(y1 - y0) * h}"/>`;
|
||||
}
|
||||
const points = part.positive_points_norm.map(([x, y]) => `<circle cx="${x * w}" cy="${y * h}" r="${stroke * 1.5}" fill="${color}"/>`).join('');
|
||||
return `<g fill="none" stroke="${color}" stroke-width="${stroke}">${line}${box}${points}<circle cx="${px}" cy="${py}" r="${stroke * 3}"/><text x="${Math.min(px + 4, w * 0.6)}" y="${Math.max(font, py - 5)}" fill="${color}" stroke="#111" stroke-width="${stroke / 2}" paint-order="stroke" font-size="${font}" font-family="sans-serif">${escape(part.id)}</text></g>`;
|
||||
}).join('\n');
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="100%" height="100%" fill="#262632"/><image href="${dataUrl(image)}" width="${w}" height="${h}"/>${overlays}</svg>`;
|
||||
}
|
||||
|
||||
export function understandingReport(image, data, validation, metadata) {
|
||||
const svg = data && validation.valid ? annotatedSvg(image, data.parts) : `<img src="${dataUrl(image)}" alt="source">`;
|
||||
const rows = data && validation.valid ? data.parts.map((part) => `<tr><td>${escape(part.id)}</td><td>${escape(part.parent ?? 'root')}</td><td>${escape(part.visibility)}</td><td>${escape(part.occluded_by.join(', '))}</td><td>${part.confidence}</td></tr>`).join('') : '';
|
||||
return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>角色结构理解检查</title><style>body{font:16px system-ui;max-width:1100px;margin:24px auto;padding:16px;background:#f5f5f5}svg,img{max-width:100%;height:auto;max-height:85vh}td,th{padding:8px;border:1px solid #ccc}table{border-collapse:collapse}pre{white-space:pre-wrap;overflow-wrap:anywhere}</style><h1>角色结构理解</h1><p>此图仅包含模型预测的粗框、关节点(空心圆)和正点(实心圆),尚未执行 SAM 分割或补全。结构校验通过不代表语义正确。</p><p>状态:${validation.valid ? 'needs_review(待检查)' : 'failed'}</p>${svg}<h2>校验</h2><pre>${escape(JSON.stringify(validation, null, 2))}</pre><h2>部件</h2><table><tr><th>ID</th><th>父部件</th><th>可见性</th><th>遮挡物</th><th>自报置信度</th></tr>${rows}</table><h2>模型说明</h2><pre>${escape(JSON.stringify(data?.notes || [], null, 2))}</pre><h2>请求记录</h2><pre>${escape(JSON.stringify(metadata, null, 2))}</pre></html>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
+6
-1
@@ -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"}
|
||||
}
|
||||
|
||||
+15
-10
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('<html>error</html>').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);
|
||||
});
|
||||
@@ -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; });
|
||||
}
|
||||
Reference in New Issue
Block a user