mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
chore: release v1.31.0
This commit is contained in:
@@ -463,9 +463,22 @@ async function waitForCheck(options, label, check) {
|
||||
let lastValue = null;
|
||||
|
||||
while (Date.now() - startedAt < options.timeoutMs) {
|
||||
lastValue = await check();
|
||||
if (lastValue?.ok) {
|
||||
return lastValue.value;
|
||||
try {
|
||||
lastValue = await check();
|
||||
if (lastValue?.ok) {
|
||||
return lastValue.value;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isTransientPageEvaluationError(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastValue = {
|
||||
ok: false,
|
||||
value: {
|
||||
transientError:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
await sleep(options.intervalMs);
|
||||
}
|
||||
@@ -477,6 +490,15 @@ async function waitForCheck(options, label, check) {
|
||||
);
|
||||
}
|
||||
|
||||
function isTransientPageEvaluationError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes("Execution context was destroyed") ||
|
||||
message.includes("most likely because of a navigation") ||
|
||||
message.includes("Cannot find context with specified id")
|
||||
);
|
||||
}
|
||||
|
||||
async function launchPlaywrightContext(userDataDir) {
|
||||
const launchOptions = {
|
||||
headless: true,
|
||||
|
||||
+259
-43
@@ -28,6 +28,7 @@ const DEFAULTS = {
|
||||
timeoutMs: 180_000,
|
||||
intervalMs: 1_000,
|
||||
analyzer: "default",
|
||||
imageTask: "none",
|
||||
projectRoundtrip: true,
|
||||
};
|
||||
|
||||
@@ -43,8 +44,7 @@ const WORKER_OCR_PRIORITY_TEXT =
|
||||
const WORKER_CLEAN_PLATE_SOURCE =
|
||||
"背景修补来源:Simple browser clean plate provider / simple_neighbor_inpaint_v1";
|
||||
const WORKER_POSTPROCESS_ALPHA_HOLE_TEXT = "主体 alpha 孔洞已修复";
|
||||
const WORKER_POSTPROCESS_CLEAN_PLATE_HALO_TEXT =
|
||||
"clean plate 边缘残影已修补";
|
||||
const WORKER_POSTPROCESS_CLEAN_PLATE_HALO_TEXT = "clean plate 边缘残影已修补";
|
||||
const NATIVE_SUBJECT_META = "subject / 置信度 74%";
|
||||
const NATIVE_HIGH_RISK_LEVEL_TEXT = "拆层质量:高风险";
|
||||
const NATIVE_ELLIPSE_FALLBACK_TEXT = "主体 mask 使用兜底椭圆";
|
||||
@@ -65,6 +65,25 @@ const WORKER_MODEL_SLOT_EXPORT_RELATIVE_PATH =
|
||||
".lime/layered-designs/design-canvas-smoke.layered-design";
|
||||
const EXTRACTION_QUALITY_EXPORT_RELATIVE_PATH =
|
||||
".lime/layered-designs/smoke-flat-image.layered-design";
|
||||
const GENERATED_IMAGE_TASK_EXPECTED_LAYERS = [
|
||||
{
|
||||
layerId: "background-image",
|
||||
assetId:
|
||||
"design-canvas-smoke-asset-background-generated-smoke-image-task-background-image",
|
||||
},
|
||||
{
|
||||
layerId: "subject-image",
|
||||
assetId:
|
||||
"design-canvas-smoke-asset-subject-generated-smoke-image-task-subject-image",
|
||||
},
|
||||
{
|
||||
layerId: "atmosphere-effect",
|
||||
assetId:
|
||||
"design-canvas-smoke-asset-effect-generated-smoke-image-task-atmosphere-effect",
|
||||
},
|
||||
];
|
||||
const GENERATED_IMAGE_TASK_EXPORT_RELATIVE_PATH =
|
||||
".lime/layered-designs/design-canvas-smoke.layered-design";
|
||||
const WORKER_MODEL_SLOT_QUALITY_CONTRACT_EXPECTATIONS = {
|
||||
subject_matting: {
|
||||
slotId: "smoke-subject-matting-slot",
|
||||
@@ -110,6 +129,7 @@ const ANALYZER_MODES = new Set([
|
||||
"worker-model-slots-native-ocr",
|
||||
"native",
|
||||
]);
|
||||
const IMAGE_TASK_MODES = new Set(["none", "auto-refresh-fixture"]);
|
||||
|
||||
const ANALYZER_BADGE_TEXT = {
|
||||
default: "默认 analyzer",
|
||||
@@ -265,6 +285,7 @@ Lime Design Canvas Smoke
|
||||
--timeout-ms <ms> 总超时,默认 180000
|
||||
--interval-ms <ms> 轮询间隔,默认 1000
|
||||
--analyzer <mode> analyzer 注入模式:default / worker / worker-refined / worker-matting / worker-ocr / worker-ocr-priority / worker-clean-plate / worker-model-slots / worker-model-slots-http-json / worker-model-slots-native-ocr / native,默认 default(产品默认 worker-first)
|
||||
--image-task <mode> 图片任务注入模式:none / auto-refresh-fixture,默认 none
|
||||
--project-roundtrip 上传拆层前验证 prompt seed 工程保存与重新打开(默认开启)
|
||||
--skip-project-roundtrip 跳过工程保存/重新打开,仅用于定位非持久化链路问题
|
||||
-h, --help 显示帮助
|
||||
@@ -313,6 +334,12 @@ function parseArgs(argv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--image-task" && argv[index + 1]) {
|
||||
options.imageTask = String(argv[index + 1]).trim();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -343,6 +370,9 @@ function parseArgs(argv) {
|
||||
"--analyzer 必须是 worker、worker-refined、worker-matting、worker-ocr、worker-ocr-priority、worker-clean-plate、worker-model-slots、worker-model-slots-http-json、worker-model-slots-native-ocr、native 或 default",
|
||||
);
|
||||
}
|
||||
if (!IMAGE_TASK_MODES.has(options.imageTask)) {
|
||||
throw new Error("--image-task 必须是 none 或 auto-refresh-fixture");
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -553,8 +583,7 @@ function createSmokeFlatImagePngBuffer(width = 360, height = 560) {
|
||||
y > height * 0.24 &&
|
||||
y < height * 0.82;
|
||||
const inSubjectInteriorHole =
|
||||
Math.abs(x - width * 0.5) <= 2 &&
|
||||
Math.abs(y - height * 0.5) <= 2;
|
||||
Math.abs(x - width * 0.5) <= 2 && Math.abs(y - height * 0.5) <= 2;
|
||||
const inSubjectConnectedHalo =
|
||||
x > width * 0.18 &&
|
||||
x < width * 0.18 + 5 &&
|
||||
@@ -723,7 +752,10 @@ async function resolveDefaultWorkspace(options) {
|
||||
}
|
||||
|
||||
async function assertWorkerModelSlotsManifest(page, workspace) {
|
||||
assert(workspace.rootPath, "worker-model-slots manifest 断言缺少 workspace rootPath");
|
||||
assert(
|
||||
workspace.rootPath,
|
||||
"worker-model-slots manifest 断言缺少 workspace rootPath",
|
||||
);
|
||||
|
||||
const manifest = await readProjectExportManifest(
|
||||
page,
|
||||
@@ -804,7 +836,8 @@ function assertWorkerModelSlotHttpJsonSidecarRequests(requests) {
|
||||
WORKER_MODEL_SLOT_QUALITY_CONTRACT_EXPECTATIONS,
|
||||
)) {
|
||||
const request = requests.find(
|
||||
(item) => item?.kind === kind && item?.context?.slotId === expected.slotId,
|
||||
(item) =>
|
||||
item?.kind === kind && item?.context?.slotId === expected.slotId,
|
||||
);
|
||||
assert(
|
||||
request,
|
||||
@@ -843,9 +876,8 @@ async function readProjectExportOutput(
|
||||
|
||||
return await page.evaluate(
|
||||
async ({ projectRootPath, exportDirectoryRelativePath }) => {
|
||||
const { readLayeredDesignProjectExport } = await import(
|
||||
"/src/lib/api/layeredDesignProject.ts"
|
||||
);
|
||||
const { readLayeredDesignProjectExport } =
|
||||
await import("/src/lib/api/layeredDesignProject.ts");
|
||||
return readLayeredDesignProjectExport({
|
||||
projectRootPath,
|
||||
exportDirectoryRelativePath,
|
||||
@@ -945,7 +977,10 @@ async function assertExtractionQualityManifest(page, workspace) {
|
||||
);
|
||||
}
|
||||
|
||||
async function assertWorkerModelSlotsExtractionQualityManifest(page, workspace) {
|
||||
async function assertWorkerModelSlotsExtractionQualityManifest(
|
||||
page,
|
||||
workspace,
|
||||
) {
|
||||
const output = await readProjectExportOutput(
|
||||
page,
|
||||
workspace,
|
||||
@@ -1171,6 +1206,7 @@ function buildSmokeUrl(options, workspace, sidecar) {
|
||||
url.searchParams.set("projectRootPath", workspace.rootPath);
|
||||
url.searchParams.set("projectId", workspace.projectId);
|
||||
url.searchParams.set("analyzer", options.analyzer);
|
||||
url.searchParams.set("imageTask", options.imageTask);
|
||||
if (sidecar?.url) {
|
||||
url.searchParams.set("modelSlotEndpointUrl", sidecar.url);
|
||||
}
|
||||
@@ -1184,7 +1220,10 @@ async function waitForText(page, label, text) {
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
const bodyText = await page.locator("body").innerText().catch(() => "");
|
||||
const bodyText = await page
|
||||
.locator("body")
|
||||
.innerText()
|
||||
.catch(() => "");
|
||||
throw new Error(
|
||||
`[smoke:design-canvas] ${label} 等待失败,缺少文本 ${JSON.stringify(
|
||||
text,
|
||||
@@ -1193,6 +1232,92 @@ async function waitForText(page, label, text) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPromptSeedImageTaskManifest(page, workspace) {
|
||||
const output = await readProjectExportOutput(
|
||||
page,
|
||||
workspace,
|
||||
GENERATED_IMAGE_TASK_EXPORT_RELATIVE_PATH,
|
||||
);
|
||||
const designJson = pickStringField(output, "designJson", "design_json");
|
||||
assert(
|
||||
designJson,
|
||||
`图片任务工程导出缺少 designJson: ${GENERATED_IMAGE_TASK_EXPORT_RELATIVE_PATH}`,
|
||||
);
|
||||
const design = JSON.parse(designJson);
|
||||
const layers = Array.isArray(design?.layers) ? design.layers : [];
|
||||
const assets = Array.isArray(design?.assets) ? design.assets : [];
|
||||
const editHistoryJson = JSON.stringify(design?.editHistory ?? []);
|
||||
|
||||
for (const expected of GENERATED_IMAGE_TASK_EXPECTED_LAYERS) {
|
||||
const layer = layers.find((item) => item?.id === expected.layerId);
|
||||
const generatedAsset = assets.find(
|
||||
(asset) => asset?.id === expected.assetId,
|
||||
);
|
||||
|
||||
assert(
|
||||
layer?.assetId === expected.assetId && layer?.source === "generated",
|
||||
`图片任务导出未把 ${expected.layerId} 图层写回生成资产: ${JSON.stringify(
|
||||
layer,
|
||||
)}`,
|
||||
);
|
||||
assert(
|
||||
typeof generatedAsset?.src === "string" &&
|
||||
generatedAsset.src.startsWith("data:image/png;base64,") &&
|
||||
generatedAsset?.params?.source === "image_generation_task" &&
|
||||
generatedAsset?.params?.generatedImageSource ===
|
||||
"smoke_auto_refresh_fixture" &&
|
||||
generatedAsset?.params?.postprocess?.status === "succeeded",
|
||||
`图片任务导出缺少 ${expected.assetId} 生成资产和后处理元数据: ${JSON.stringify(
|
||||
generatedAsset,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
editHistoryJson.includes("asset_generation_requested") &&
|
||||
editHistoryJson.includes("asset_replaced"),
|
||||
`图片任务导出缺少提交与写回历史: ${editHistoryJson}`,
|
||||
);
|
||||
|
||||
const manifestJson = pickStringField(output, "manifestJson", "manifest_json");
|
||||
assert(
|
||||
manifestJson,
|
||||
`图片任务工程导出缺少 manifestJson: ${GENERATED_IMAGE_TASK_EXPORT_RELATIVE_PATH}`,
|
||||
);
|
||||
const manifest = JSON.parse(manifestJson);
|
||||
const manifestAssets = Array.isArray(manifest?.assets) ? manifest.assets : [];
|
||||
for (const expected of GENERATED_IMAGE_TASK_EXPECTED_LAYERS) {
|
||||
const manifestAsset = manifestAssets.find(
|
||||
(asset) => asset?.id === expected.assetId,
|
||||
);
|
||||
assert(
|
||||
manifestAsset?.source === "file" &&
|
||||
typeof manifestAsset?.filename === "string" &&
|
||||
manifestAsset.filename.startsWith("assets/"),
|
||||
`图片任务导出 manifest 未缓存 ${expected.assetId} data URL 生成资产: ${JSON.stringify(
|
||||
manifestAsset,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const psdLikeManifestJson = pickStringField(
|
||||
output,
|
||||
"psdLikeManifestJson",
|
||||
"psd_like_manifest_json",
|
||||
);
|
||||
assert(
|
||||
psdLikeManifestJson,
|
||||
`图片任务工程导出缺少 psdLikeManifestJson: ${GENERATED_IMAGE_TASK_EXPORT_RELATIVE_PATH}`,
|
||||
);
|
||||
assert(
|
||||
!designJson.match(/poster_generate|canvas:poster|ImageTaskViewer/) &&
|
||||
!manifestJson.match(/poster_generate|canvas:poster|ImageTaskViewer/) &&
|
||||
!psdLikeManifestJson.match(
|
||||
/poster_generate|canvas:poster|ImageTaskViewer/,
|
||||
),
|
||||
"图片任务工程导出不应回流旧 poster / ImageTaskViewer 链路",
|
||||
);
|
||||
}
|
||||
|
||||
async function runPageFlow(options, smokeUrl) {
|
||||
const userDataDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `lime-design-canvas-smoke-${process.pid}-`),
|
||||
@@ -1214,7 +1339,10 @@ async function runPageFlow(options, smokeUrl) {
|
||||
chromeError instanceof Error ? chromeError.message : String(chromeError)
|
||||
}`,
|
||||
);
|
||||
context = await chromium.launchPersistentContext(userDataDir, launchOptions);
|
||||
context = await chromium.launchPersistentContext(
|
||||
userDataDir,
|
||||
launchOptions,
|
||||
);
|
||||
}
|
||||
|
||||
const page = context.pages()[0] ?? (await context.newPage());
|
||||
@@ -1261,6 +1389,77 @@ async function runPageFlow(options, smokeUrl) {
|
||||
await waitForText(page, "导出入口", "导出设计工程");
|
||||
await waitForText(page, "工程恢复入口", "打开最近工程");
|
||||
|
||||
if (options.imageTask === "auto-refresh-fixture") {
|
||||
logStage("image-task-auto-refresh-fixture");
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务 fixture 标记",
|
||||
"图片任务自动刷新 fixture",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "生成全部图片层", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务自动刷新写回",
|
||||
"自动刷新写回 3 个图层结果",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "选择图层 氛围特效", exact: true })
|
||||
.click({ timeout: ACTION_TIMEOUT_MS });
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务生成来源",
|
||||
"smoke-layered-design-image-v1",
|
||||
);
|
||||
|
||||
if (options.projectRoundtrip) {
|
||||
logStage("image-task-export-roundtrip");
|
||||
await page
|
||||
.getByRole("button", { name: "导出设计工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务工程目录保存结果",
|
||||
"已保存图层设计工程",
|
||||
);
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务工程目录保存路径",
|
||||
"design-canvas-smoke.layered-design",
|
||||
);
|
||||
|
||||
const smokeUrlObject = new URL(smokeUrl);
|
||||
const workspace = {
|
||||
rootPath: smokeUrlObject.searchParams.get("projectRootPath") ?? "",
|
||||
};
|
||||
await assertPromptSeedImageTaskManifest(page, workspace);
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "打开最近工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(
|
||||
page,
|
||||
"图片任务工程目录恢复结果",
|
||||
"已打开图层设计工程",
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "选择图层 氛围特效", exact: true })
|
||||
.click({ timeout: ACTION_TIMEOUT_MS });
|
||||
await waitForText(
|
||||
page,
|
||||
"恢复后图片任务生成来源",
|
||||
"smoke-layered-design-image-v1",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logStage("interact-layer");
|
||||
await page.getByRole("button", { name: "选择图层 主标题" }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
@@ -1278,11 +1477,17 @@ async function runPageFlow(options, smokeUrl) {
|
||||
|
||||
if (options.projectRoundtrip) {
|
||||
logStage("project-roundtrip-save-open");
|
||||
await page.getByRole("button", { name: "导出设计工程", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "导出设计工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(page, "工程目录保存结果", "已保存图层设计工程");
|
||||
await waitForText(page, "工程目录保存路径", "design-canvas-smoke.layered-design");
|
||||
await waitForText(
|
||||
page,
|
||||
"工程目录保存路径",
|
||||
"design-canvas-smoke.layered-design",
|
||||
);
|
||||
if (
|
||||
options.analyzer === "worker-model-slots" ||
|
||||
options.analyzer === "worker-model-slots-http-json" ||
|
||||
@@ -1294,9 +1499,11 @@ async function runPageFlow(options, smokeUrl) {
|
||||
});
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "打开最近工程", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "打开最近工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(page, "工程目录恢复结果", "已打开图层设计工程");
|
||||
await waitForText(page, "恢复后画布标题", "Smoke 图层设计海报");
|
||||
await waitForText(page, "恢复后图层栏", "主标题");
|
||||
@@ -1313,11 +1520,7 @@ async function runPageFlow(options, smokeUrl) {
|
||||
await waitForText(page, "上传扁平图结果", "已载入扁平图 draft");
|
||||
const expectedAnalyzerResult = ANALYZER_RESULT_TEXT[options.analyzer];
|
||||
if (expectedAnalyzerResult) {
|
||||
await waitForText(
|
||||
page,
|
||||
"analyzer 执行结果",
|
||||
expectedAnalyzerResult,
|
||||
);
|
||||
await waitForText(page, "analyzer 执行结果", expectedAnalyzerResult);
|
||||
}
|
||||
const extraCheck = ANALYZER_EXTRA_CHECK[options.analyzer];
|
||||
if (extraCheck) {
|
||||
@@ -1393,9 +1596,11 @@ async function runPageFlow(options, smokeUrl) {
|
||||
await waitForText(page, "候选图层", "候选图层");
|
||||
|
||||
if (extraCheck?.qualityTexts || extraCheck?.highRiskManifestFindingIds) {
|
||||
await page.getByRole("button", { name: "恢复默认候选", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "恢复默认候选", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /☑\s*主体候选/ })
|
||||
.waitFor({ state: "visible", timeout: ACTION_TIMEOUT_MS });
|
||||
@@ -1413,10 +1618,16 @@ async function runPageFlow(options, smokeUrl) {
|
||||
|
||||
if (options.projectRoundtrip) {
|
||||
logStage("high-risk-extraction-quality-export-manifest");
|
||||
await page.getByRole("button", { name: "导出设计工程", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(page, "高风险拆层工程目录保存结果", "已保存图层设计工程");
|
||||
await page
|
||||
.getByRole("button", { name: "导出设计工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(
|
||||
page,
|
||||
"高风险拆层工程目录保存结果",
|
||||
"已保存图层设计工程",
|
||||
);
|
||||
await waitForText(
|
||||
page,
|
||||
"高风险拆层工程目录保存路径",
|
||||
@@ -1433,13 +1644,17 @@ async function runPageFlow(options, smokeUrl) {
|
||||
);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "仅保留原图", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "仅保留原图", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
} else {
|
||||
await page.getByRole("button", { name: "进入图层编辑", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "进入图层编辑", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
await page
|
||||
.getByRole("button", { name: "进入图层编辑", exact: true })
|
||||
@@ -1451,9 +1666,11 @@ async function runPageFlow(options, smokeUrl) {
|
||||
(extraCheck?.qualityTexts || extraCheck?.modelSlotQualityManifest)
|
||||
) {
|
||||
logStage("extraction-quality-export-manifest");
|
||||
await page.getByRole("button", { name: "导出设计工程", exact: true }).click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "导出设计工程", exact: true })
|
||||
.click({
|
||||
timeout: ACTION_TIMEOUT_MS,
|
||||
});
|
||||
await waitForText(page, "拆层工程目录保存结果", "已保存图层设计工程");
|
||||
await waitForText(
|
||||
page,
|
||||
@@ -1513,9 +1730,8 @@ async function main() {
|
||||
|
||||
await runPageFlow(options, smokeUrl);
|
||||
if (sidecar) {
|
||||
const requests = await readModelSlotHttpJsonExecutorSidecarRequests(
|
||||
sidecar,
|
||||
);
|
||||
const requests =
|
||||
await readModelSlotHttpJsonExecutorSidecarRequests(sidecar);
|
||||
assertWorkerModelSlotHttpJsonSidecarRequests(requests);
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 下载 models 仓库数据到 src-tauri/resources/models
|
||||
# 用于本地开发环境
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
MODELS_DIR="$PROJECT_ROOT/src-tauri/resources/models"
|
||||
|
||||
BASE_URL="https://raw.githubusercontent.com/aiclientproxy/models/main"
|
||||
|
||||
echo "📦 下载 models 数据..."
|
||||
echo " 目标目录: $MODELS_DIR"
|
||||
|
||||
# 创建目录结构
|
||||
mkdir -p "$MODELS_DIR/providers" "$MODELS_DIR/aliases"
|
||||
|
||||
# 下载 index.json
|
||||
echo " 下载 index.json..."
|
||||
curl -sL "$BASE_URL/index.json" -o "$MODELS_DIR/index.json"
|
||||
|
||||
# 解析 providers 列表并下载每个 provider 的数据
|
||||
echo " 下载 providers..."
|
||||
for provider in $(cat "$MODELS_DIR/index.json" | jq -r '.providers[]'); do
|
||||
echo " - $provider"
|
||||
curl -sL "$BASE_URL/providers/${provider}.json" -o "$MODELS_DIR/providers/${provider}.json"
|
||||
done
|
||||
|
||||
# 下载别名配置
|
||||
echo " 下载 aliases..."
|
||||
for alias in kiro antigravity; do
|
||||
echo " - $alias"
|
||||
curl -sL "$BASE_URL/aliases/${alias}.json" -o "$MODELS_DIR/aliases/${alias}.json" 2>/dev/null || echo " (跳过 $alias - 文件不存在)"
|
||||
done
|
||||
|
||||
# 统计
|
||||
provider_count=$(ls -1 "$MODELS_DIR/providers" 2>/dev/null | wc -l | tr -d ' ')
|
||||
alias_count=$(ls -1 "$MODELS_DIR/aliases" 2>/dev/null | wc -l | tr -d ' ')
|
||||
|
||||
echo ""
|
||||
echo "✅ 下载完成!"
|
||||
echo " Providers: $provider_count"
|
||||
echo " Aliases: $alias_count"
|
||||
+149
-116
@@ -70,7 +70,7 @@ const PERSONA_PACK = {
|
||||
"",
|
||||
"- 语气:清晰、克制、只说已确认事实。",
|
||||
"- 边界:不得把 smoke 数据当作真实用户资料。",
|
||||
"- 适用:验证 KnowledgePage chooser 的 persona + data 协同。",
|
||||
"- 适用:验证项目资料选择弹层的写作口吻和参考资料协同。",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
@@ -80,9 +80,9 @@ const AGENT_RESULT_MESSAGE = {
|
||||
content: [
|
||||
"# 对话结果资料",
|
||||
"",
|
||||
"- 事实:该结果来自当前 Agent 对话,用于验证生成结果可以沉淀成项目资料。",
|
||||
"- 事实:该结果来自当前 Agent 对话,用于验证生成结果可以保存到项目资料。",
|
||||
"- 适用场景:用户拿到一段可复用结论后,可以一键保存,随后在项目资料管理页检查确认。",
|
||||
"- 风险提示:沉淀后仍需人工确认,避免把临时分析当成长期事实。",
|
||||
"- 风险提示:保存后仍需确认,避免把临时分析当成长期事实。",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
@@ -100,6 +100,20 @@ const USER_FACING_FORBIDDEN_TEXT = [
|
||||
"runtimeMode",
|
||||
"primaryDocument",
|
||||
"runtimeBinding",
|
||||
"Builder Skill",
|
||||
"Knowledge Pack",
|
||||
"Resolver",
|
||||
"Context Run",
|
||||
"runtime",
|
||||
"profile",
|
||||
"documents",
|
||||
"sources",
|
||||
"runs",
|
||||
"persona",
|
||||
"data",
|
||||
"wrapper",
|
||||
"selected sections",
|
||||
"compile",
|
||||
"Request failed",
|
||||
"Bad request",
|
||||
];
|
||||
@@ -298,6 +312,8 @@ async function waitForPageText(page, label, needles, timeoutMs) {
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
} catch (error) {
|
||||
const pageUrl = page.url();
|
||||
const pageTitle = await page.title().catch(() => "");
|
||||
const text = await page
|
||||
.locator("body")
|
||||
.innerText()
|
||||
@@ -323,7 +339,7 @@ async function waitForPageText(page, label, needles, timeoutMs) {
|
||||
throw new Error(
|
||||
`[smoke:knowledge-gui] ${label} 等待失败,缺少 ${JSON.stringify(
|
||||
missing,
|
||||
)},页面文本预览: ${searchableText.slice(0, 2_000)}`,
|
||||
)},url=${pageUrl},title=${pageTitle},页面文本预览: ${searchableText.slice(0, 2_000)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
@@ -437,7 +453,15 @@ async function openPackDetail(page, title) {
|
||||
await page
|
||||
.locator("article")
|
||||
.filter({ hasText: title })
|
||||
.getByRole("button", { name: "查看详情" })
|
||||
.getByRole("button", { name: "打开" })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
async function openPackForCreation(page, title) {
|
||||
await page
|
||||
.locator("article")
|
||||
.filter({ hasText: title })
|
||||
.getByRole("button", { name: "用于创作" })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
@@ -482,8 +506,8 @@ async function confirmKnowledgeComposer(
|
||||
) {
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Knowledge chooser 打开",
|
||||
["选择本轮 Knowledge 上下文", "Persona(最多 1 个)", "Data(可多选)"],
|
||||
"项目资料选择弹层打开",
|
||||
["选择这次创作用哪些资料", "写作口吻(只能选 1 个)", "要参考的资料(可多选)"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
@@ -499,86 +523,64 @@ async function confirmKnowledgeComposer(
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Knowledge chooser 多 data 选择",
|
||||
["已选 2"],
|
||||
"项目资料选择弹层多选",
|
||||
[selectPersona ? "已选 3 份资料" : "已选 2 份资料"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (selectPersona && selectSecondaryData) {
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Knowledge chooser persona + 多 data 选择",
|
||||
["当前选择 3 份资料"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
await clickPageControl(page, { text: "确认启用" });
|
||||
await clickPageControl(page, { text: "确认使用" });
|
||||
}
|
||||
|
||||
async function verifyDetailTabs(page, options, packTitle) {
|
||||
async function verifyReviewPage(page, options, packTitle) {
|
||||
await openPackDetail(page, packTitle);
|
||||
await waitForPageText(
|
||||
page,
|
||||
"资料详情头部加载",
|
||||
"确认资料页加载",
|
||||
[
|
||||
packTitle,
|
||||
"概览",
|
||||
"内容",
|
||||
"原始资料",
|
||||
"引用摘要",
|
||||
"缺口与风险",
|
||||
"整理记录",
|
||||
"完整资料文档",
|
||||
"打开完整文档",
|
||||
"导出",
|
||||
"修改内容",
|
||||
"需要你确认的内容",
|
||||
"确认后会发生什么",
|
||||
"不会覆盖原始资料",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, "资料详情头部");
|
||||
await assertNoUserFacingInternalText(page, "确认资料页");
|
||||
|
||||
const tabExpectations = [
|
||||
{ tab: "概览", needles: ["适用场景", "当前引用摘要"] },
|
||||
{ tab: "内容", needles: ["资料说明", "整理内容"] },
|
||||
{ tab: "原始资料", needles: ["原始资料"] },
|
||||
{ tab: "引用摘要", needles: ["引用摘要"] },
|
||||
{ tab: "缺口与风险", needles: ["缺口与风险", "安全边界"] },
|
||||
{ tab: "整理记录", needles: ["整理记录"] },
|
||||
];
|
||||
|
||||
for (const item of tabExpectations) {
|
||||
await clickPageControl(page, { text: item.tab });
|
||||
await waitForPageText(
|
||||
page,
|
||||
`资料详情 Tab ${item.tab}`,
|
||||
item.needles,
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, `资料详情 Tab ${item.tab}`);
|
||||
}
|
||||
await clickPageControl(page, { text: "打开完整文档" });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"完整资料文档展开",
|
||||
["完整资料文档内容"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, "完整资料文档展开");
|
||||
}
|
||||
|
||||
async function verifyBuilderImportAndReview(page, options) {
|
||||
await clickPageControl(page, { text: "Builder 整理" });
|
||||
await clickPageControl(page, { text: "整理新资料" });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Builder 整理台加载",
|
||||
"整理新资料页面加载",
|
||||
[
|
||||
"Builder Skills 整理台",
|
||||
"1 选择 Builder Skill",
|
||||
"2 导入原始材料",
|
||||
"3 交给 Builder Skill",
|
||||
"4 审阅与入上下文",
|
||||
"选择资料用途",
|
||||
"添加原始资料",
|
||||
"Lime 开始整理",
|
||||
"没有确认的资料不会自动用于创作",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "内容运营 选题日历、栏目节奏、素材复用和发布复盘。",
|
||||
})
|
||||
.getByRole("button", { name: "内容运营", exact: true })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
await page.getByLabel("Pack 显示名").fill(BUILDER_ACCEPTANCE_PACK.title);
|
||||
await page.getByLabel("资料名称").fill(BUILDER_ACCEPTANCE_PACK.title);
|
||||
await page
|
||||
.getByLabel("原始材料正文")
|
||||
.getByLabel("原始资料正文")
|
||||
.fill(BUILDER_ACCEPTANCE_PACK.sourceText);
|
||||
await waitForPageText(
|
||||
page,
|
||||
@@ -591,42 +593,30 @@ async function verifyBuilderImportAndReview(page, options) {
|
||||
options.timeoutMs,
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "导入并生成 Pack", exact: true })
|
||||
.getByRole("button", { name: "Lime 开始整理", exact: true })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
|
||||
await waitForExactButton(page, "Builder 产物详情加载", "人工确认", options.timeoutMs);
|
||||
await waitForExactButton(page, "整理结果详情加载", "确认可用", options.timeoutMs);
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Builder 产物详情加载",
|
||||
"整理结果详情加载",
|
||||
[
|
||||
BUILDER_ACCEPTANCE_PACK.title,
|
||||
"重新整理",
|
||||
"待人工确认",
|
||||
"引用摘要",
|
||||
"人工确认",
|
||||
"完整资料文档",
|
||||
"需要你确认的内容",
|
||||
"确认可用",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, "Builder 产物详情");
|
||||
await assertNoUserFacingInternalText(page, "整理结果详情");
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "人工确认", exact: true })
|
||||
.getByRole("button", { name: "确认可用", exact: true })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Builder 产物人工确认",
|
||||
["资料已人工确认", BUILDER_ACCEPTANCE_PACK.title],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
await waitForExactButton(page, "Builder 产物设为默认入口", "设为默认", options.timeoutMs);
|
||||
await page
|
||||
.getByRole("button", { name: "设为默认", exact: true })
|
||||
.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Builder 产物设为默认",
|
||||
["已设为当前项目默认资料", "默认资料"],
|
||||
"整理结果确认可用",
|
||||
["资料已确认可用", BUILDER_ACCEPTANCE_PACK.title],
|
||||
options.timeoutMs,
|
||||
);
|
||||
}
|
||||
@@ -863,39 +853,60 @@ async function runPlaywrightGuiFlow(options) {
|
||||
page,
|
||||
"知识库总览加载",
|
||||
[
|
||||
"Agent Knowledge 工作台",
|
||||
"Knowledge v2 · Skills-first",
|
||||
"Skills 生产线",
|
||||
"回到 Agent 整理",
|
||||
"上下文总览",
|
||||
"Knowledge Pack 清单",
|
||||
"资料进入 Pack",
|
||||
"Builder 已整理",
|
||||
"可入上下文",
|
||||
"让 Lime 记住这个项目",
|
||||
"回到创作",
|
||||
"整理新资料",
|
||||
"项目资料清单",
|
||||
"可用于创作",
|
||||
"本轮创作会使用",
|
||||
PERSONA_PACK.title,
|
||||
DEFAULT_PACK.title,
|
||||
SECONDARY_PACK.title,
|
||||
FILE_MANAGER_SOURCE_TITLE,
|
||||
options.projectName,
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertVisibleText(page, "Knowledge v2 总览细节", [
|
||||
"Knowledge v2 上下文组合",
|
||||
"Persona 人设层",
|
||||
"Data 运营资料层",
|
||||
"审阅闸门:等你确认的资料",
|
||||
"v2 闭环概览",
|
||||
"运营类资料覆盖",
|
||||
await assertVisibleText(page, "项目资料首页细节", [
|
||||
"接下来你可以",
|
||||
"确认待审资料",
|
||||
"选择创作时使用的资料",
|
||||
"资料只有确认后才会用于创作",
|
||||
]);
|
||||
await assertNoUserFacingInternalText(page, "Knowledge v2 总览");
|
||||
await assertNoUserFacingInternalText(page, "项目资料首页");
|
||||
|
||||
logStage("verify-detail-tabs");
|
||||
await verifyDetailTabs(page, options, DEFAULT_PACK.title);
|
||||
await clickPageControl(page, { text: "上下文总览" });
|
||||
logStage("verify-states-page");
|
||||
await clickPageControl(page, { text: "查看状态说明" });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"项目资料状态说明页加载",
|
||||
["项目资料状态说明", "没有资料", "已可用", "待确认", "需要补充", "整理失败"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, "项目资料状态说明页");
|
||||
await clickScopedButton(page, {
|
||||
scope: '[data-testid="app-sidebar-main-nav"]',
|
||||
ariaLabel: "灵感",
|
||||
});
|
||||
await waitForPageText(
|
||||
page,
|
||||
"灵感页加载",
|
||||
["收藏过的想法"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await openKnowledgePageFromMainNav(page);
|
||||
await waitForPageText(
|
||||
page,
|
||||
"状态说明后返回项目资料首页",
|
||||
["让 Lime 记住这个项目", "项目资料清单", DEFAULT_PACK.title],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
logStage("verify-review-page");
|
||||
await verifyReviewPage(page, options, DEFAULT_PACK.title);
|
||||
await clickPageControl(page, { text: "回到项目资料" });
|
||||
|
||||
logStage("open-agent-with-knowledge");
|
||||
await clickPageControl(page, { text: "选择用于生成" });
|
||||
await openPackForCreation(page, DEFAULT_PACK.title);
|
||||
await confirmKnowledgeComposer(page, options, {
|
||||
selectSecondaryData: true,
|
||||
selectPersona: true,
|
||||
@@ -906,7 +917,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Agent 页面加载",
|
||||
[`资料:${DEFAULT_PACK.title}`, "+2", "请基于当前项目资料生成内容"],
|
||||
[`资料:${DEFAULT_PACK.title}`, "+2", "请基于当前项目资料创作内容"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -925,7 +936,7 @@ async function runPlaywrightGuiFlow(options) {
|
||||
|
||||
logStage("prepare-agent-result");
|
||||
await seedAgentResultForKnowledgeCapture(page, options);
|
||||
await clickPageControl(page, { text: "选择用于生成" });
|
||||
await openPackForCreation(page, DEFAULT_PACK.title);
|
||||
await confirmKnowledgeComposer(page, options);
|
||||
|
||||
logStage("wait-agent-result");
|
||||
@@ -934,35 +945,57 @@ async function runPlaywrightGuiFlow(options) {
|
||||
"Agent 结果样本加载",
|
||||
[
|
||||
`资料:${DEFAULT_PACK.title}`,
|
||||
"沉淀为项目资料",
|
||||
"保存到项目资料",
|
||||
"事实:该结果来自当前 Agent 对话",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
logStage("capture-agent-result");
|
||||
await clickPageControl(page, { ariaLabel: "沉淀为项目资料" });
|
||||
await clickPageControl(page, { ariaLabel: "保存到项目资料" });
|
||||
|
||||
logStage("wait-agent-result-captured");
|
||||
logStage("wait-agent-result-save-page");
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Agent 结果沉淀完成",
|
||||
["项目资料已整理", AGENT_RESULT_MESSAGE.title],
|
||||
"Agent 结果进入保存页",
|
||||
[
|
||||
"存到哪里?",
|
||||
"补充已有资料",
|
||||
"新建一份资料",
|
||||
"保存到项目资料",
|
||||
"保存后不会立刻用于创作,确认后才会生效",
|
||||
"事实:该结果来自当前 Agent 对话",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
await assertNoUserFacingInternalText(page, "Agent 结果保存页");
|
||||
|
||||
await clickPageControl(page, { text: "保存到项目资料" });
|
||||
await waitForPageText(
|
||||
page,
|
||||
"Agent 结果保存完成",
|
||||
["资料已保存,确认后才会用于创作", "新增 2 个内容点", "更新 1 个章节"],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
logStage("return-knowledge-page");
|
||||
logStage("return-knowledge-overview");
|
||||
await clickScopedButton(page, {
|
||||
scope: '[data-testid="app-sidebar-main-nav"]',
|
||||
ariaLabel: "灵感",
|
||||
});
|
||||
await waitForPageText(page, "灵感页加载", ["灵感"], options.timeoutMs);
|
||||
await openKnowledgePageFromMainNav(page);
|
||||
|
||||
logStage("wait-captured-agent-result");
|
||||
await waitForPageText(
|
||||
page,
|
||||
"沉淀资料进入管理页",
|
||||
"保存资料进入管理页",
|
||||
[
|
||||
"Agent Knowledge 工作台",
|
||||
"Knowledge Pack 清单",
|
||||
AGENT_RESULT_MESSAGE.title,
|
||||
"继续确认",
|
||||
"让 Lime 记住这个项目",
|
||||
"项目资料清单",
|
||||
DEFAULT_PACK.title,
|
||||
"待确认",
|
||||
"去确认",
|
||||
],
|
||||
options.timeoutMs,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ const REQUIRED_KINDS = ["subject_matting", "clean_plate", "text_ocr"];
|
||||
const DEFAULT_MINIMUM_SAMPLES = 5;
|
||||
const DEFAULT_MINIMUM_SAMPLE_SCORE = 8;
|
||||
const DEFAULT_MINIMUM_PASSED_TOOLS = 1;
|
||||
const PNG_SIGNATURE_HEX = "89504e470d0a1a0a";
|
||||
const PSD_VERIFIER_PATH = fileURLToPath(
|
||||
new URL("./verify-layered-design-psd-export.mjs", import.meta.url),
|
||||
);
|
||||
@@ -104,6 +105,97 @@ function hasAllItems(values, required) {
|
||||
return required.every((item) => values.includes(item));
|
||||
}
|
||||
|
||||
function assertNonEmptyFile(filePath, label) {
|
||||
assert(fs.existsSync(filePath), `${label} 不存在: ${filePath}`);
|
||||
const byteLength = fs.statSync(filePath).size;
|
||||
assert(byteLength > 0, `${label} 是空文件: ${filePath}`);
|
||||
return byteLength;
|
||||
}
|
||||
|
||||
function readPngDimensions(imagePath, label = "PNG 图片") {
|
||||
const header = fs.readFileSync(imagePath).subarray(0, 24);
|
||||
assert(
|
||||
header.length >= 24 &&
|
||||
header.subarray(0, 8).toString("hex") === PNG_SIGNATURE_HEX &&
|
||||
header.subarray(12, 16).toString("ascii") === "IHDR",
|
||||
`${label} 不是有效 PNG: ${imagePath}`,
|
||||
);
|
||||
return {
|
||||
width: header.readUInt32BE(16),
|
||||
height: header.readUInt32BE(20),
|
||||
};
|
||||
}
|
||||
|
||||
function validatePngFile(imagePath, label, expected = {}) {
|
||||
const byteLength = assertNonEmptyFile(imagePath, label);
|
||||
const dimensions = readPngDimensions(imagePath, label);
|
||||
if (Number.isInteger(expected.byteLength)) {
|
||||
assert(
|
||||
byteLength === expected.byteLength,
|
||||
`${label} 文件大小不匹配: ${byteLength} != ${expected.byteLength}`,
|
||||
);
|
||||
}
|
||||
if (Number.isInteger(expected.width)) {
|
||||
assert(
|
||||
dimensions.width === expected.width,
|
||||
`${label} 宽度不匹配: ${dimensions.width} != ${expected.width}`,
|
||||
);
|
||||
}
|
||||
if (Number.isInteger(expected.height)) {
|
||||
assert(
|
||||
dimensions.height === expected.height,
|
||||
`${label} 高度不匹配: ${dimensions.height} != ${expected.height}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
path: imagePath,
|
||||
byteLength,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExistingPngPath(baseDir, value, label, expected = {}) {
|
||||
const imagePath = resolveExistingPath(baseDir, value, label);
|
||||
return validatePngFile(imagePath, label, expected);
|
||||
}
|
||||
|
||||
function validateSampleManifest(sampleManifestPath, checkedSamples) {
|
||||
const manifest = readJson(sampleManifestPath);
|
||||
const samples = Array.isArray(manifest.samples) ? manifest.samples : [];
|
||||
const byId = new Map(samples.map((sample) => [sample.id, sample]));
|
||||
const baseDir = path.dirname(sampleManifestPath);
|
||||
const sampleImages = [];
|
||||
|
||||
for (const sampleId of checkedSamples) {
|
||||
const sample = byId.get(sampleId);
|
||||
assert(sample, `sample manifest 缺少 benchmark 样本: ${sampleId}`);
|
||||
const image = isRecord(sample.image) ? sample.image : undefined;
|
||||
assert(image, `sample manifest 样本缺少 image: ${sampleId}`);
|
||||
assert(
|
||||
image.mimeType === "image/png",
|
||||
`sample image ${sampleId} mimeType 必须是 image/png: ${image.mimeType}`,
|
||||
);
|
||||
const sampleImage = resolveExistingPngPath(
|
||||
baseDir,
|
||||
image.path,
|
||||
`sample image ${sampleId}`,
|
||||
{
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
},
|
||||
);
|
||||
sampleImages.push(sampleImage);
|
||||
}
|
||||
|
||||
return {
|
||||
sampleManifestPath,
|
||||
sampleImages,
|
||||
sampleCount: samples.length,
|
||||
};
|
||||
}
|
||||
|
||||
function validateBenchmarkReport(benchmarkPath, requirements) {
|
||||
const report = readJson(benchmarkPath);
|
||||
const benchmark = isRecord(report.benchmark) ? report.benchmark : undefined;
|
||||
@@ -137,9 +229,16 @@ function validateBenchmarkReport(benchmarkPath, requirements) {
|
||||
benchmark.checkedRequestCount >= checkedSamples.length * requiredKinds.length,
|
||||
`benchmark checkedRequestCount 不足: ${benchmark.checkedRequestCount}`,
|
||||
);
|
||||
const sampleManifestPath = resolveExistingPath(
|
||||
path.dirname(benchmarkPath),
|
||||
benchmark.sampleManifestPath,
|
||||
"benchmark.sampleManifestPath",
|
||||
);
|
||||
const sampleManifest = validateSampleManifest(sampleManifestPath, checkedSamples);
|
||||
|
||||
return {
|
||||
report,
|
||||
sampleManifest,
|
||||
checkedSamples,
|
||||
checkedKinds,
|
||||
checkedRequestCount: benchmark.checkedRequestCount,
|
||||
@@ -180,11 +279,17 @@ function validateHumanReview(reviewPath, benchmark, requirements) {
|
||||
const byId = new Map(samples.map((sample) => [sample.id, sample]));
|
||||
const acceptedSamples = [];
|
||||
const failures = [];
|
||||
const baseDir = path.dirname(reviewPath);
|
||||
const evidenceFiles = readStringArray(review.evidenceFiles);
|
||||
|
||||
assert(
|
||||
review.schemaVersion === HUMAN_REVIEW_SCHEMA_VERSION,
|
||||
`人工复核 schemaVersion 不匹配: ${review.schemaVersion}`,
|
||||
);
|
||||
assert(evidenceFiles.length > 0, "人工复核缺少 evidenceFiles,不能作为最终验收证据");
|
||||
const evidenceImages = evidenceFiles.map((file, index) =>
|
||||
resolveExistingPngPath(baseDir, file, `人工复核 evidenceFiles[${index}]`),
|
||||
);
|
||||
|
||||
for (const sampleId of benchmark.checkedSamples) {
|
||||
const sample = byId.get(sampleId);
|
||||
@@ -216,6 +321,7 @@ function validateHumanReview(reviewPath, benchmark, requirements) {
|
||||
return {
|
||||
reviewedAt: review.reviewedAt,
|
||||
acceptedSamples,
|
||||
evidenceImages,
|
||||
sampleCount: samples.length,
|
||||
};
|
||||
}
|
||||
@@ -236,11 +342,39 @@ function validateExportEvidence(exportDirectoryPath, benchmark) {
|
||||
const psdLikeManifestPath = path.join(exportDirectoryPath, "psd-like-manifest.json");
|
||||
const trialPsdPath = path.join(exportDirectoryPath, "trial.psd");
|
||||
const exportManifest = readJson(exportManifestPath);
|
||||
const psdLikeManifest = readJson(psdLikeManifestPath);
|
||||
const benchmarkSummary = exportManifest.evidence?.modelSlotBenchmark;
|
||||
|
||||
assert(fs.existsSync(exportManifestPath), `缺少 export-manifest.json: ${exportManifestPath}`);
|
||||
assert(fs.existsSync(psdLikeManifestPath), `缺少 psd-like-manifest.json: ${psdLikeManifestPath}`);
|
||||
assert(fs.existsSync(trialPsdPath), `缺少 trial.psd: ${trialPsdPath}`);
|
||||
const designFile = resolveExistingPath(
|
||||
exportDirectoryPath,
|
||||
exportManifest.designFile ?? "design.json",
|
||||
"导出工程 design.json",
|
||||
);
|
||||
assertNonEmptyFile(designFile, "导出工程 design.json");
|
||||
const previewSvgFile = resolveExistingPath(
|
||||
exportDirectoryPath,
|
||||
exportManifest.previewSvgFile ?? psdLikeManifest.preview?.svgFile ?? "preview.svg",
|
||||
"导出工程 preview.svg",
|
||||
);
|
||||
assertNonEmptyFile(previewSvgFile, "导出工程 preview.svg");
|
||||
const previewPng = resolveExistingPngPath(
|
||||
exportDirectoryPath,
|
||||
exportManifest.previewPngFile ?? psdLikeManifest.preview?.pngFile ?? "preview.png",
|
||||
"导出工程 preview.png",
|
||||
);
|
||||
const assetPngs = (Array.isArray(exportManifest.assets) ? exportManifest.assets : []).map(
|
||||
(asset, index) => {
|
||||
assert(isRecord(asset), `导出工程 assets[${index}] 不是对象`);
|
||||
return resolveExistingPngPath(
|
||||
exportDirectoryPath,
|
||||
asset.filename,
|
||||
`导出工程 assets[${index}]`,
|
||||
);
|
||||
},
|
||||
);
|
||||
assert(benchmarkSummary, "export-manifest.json 缺少 evidence.modelSlotBenchmark");
|
||||
assert(
|
||||
benchmarkSummary.completionGate?.status === "sample_manifest_completed",
|
||||
@@ -279,6 +413,10 @@ function validateExportEvidence(exportDirectoryPath, benchmark) {
|
||||
exportManifestPath,
|
||||
psdLikeManifestPath,
|
||||
trialPsdPath,
|
||||
designFile,
|
||||
previewSvgFile,
|
||||
previewPng,
|
||||
assetPngs,
|
||||
modelSlotBenchmark: normalizeModelSlotBenchmarkSummary(benchmark),
|
||||
psdLayerCount: verifierSummary.psd.layerCount,
|
||||
psdLayerNames: verifierSummary.psd.layerNames,
|
||||
@@ -316,13 +454,16 @@ function validateDesignToolEvidence(evidencePath, exportEvidence, requirements)
|
||||
const checks = isRecord(item.checks) ? item.checks : undefined;
|
||||
const files = readStringArray(item.evidenceFiles);
|
||||
const allChecksPassed = checks && requiredChecks.every((key) => checks[key] === true);
|
||||
const allFilesExist = files.length > 0 && files.every((file) => fs.existsSync(path.resolve(baseDir, file)));
|
||||
if (item.status === "passed" && allChecksPassed && allFilesExist) {
|
||||
const evidenceImages = files.map((file, index) =>
|
||||
resolveExistingPngPath(baseDir, file, `${item.tool ?? "design tool"} evidenceFiles[${index}]`),
|
||||
);
|
||||
if (item.status === "passed" && allChecksPassed && evidenceImages.length > 0) {
|
||||
passedTools.push({
|
||||
tool: item.tool,
|
||||
toolVersion: item.toolVersion,
|
||||
openedAt: item.openedAt,
|
||||
evidenceFiles: files.map((file) => path.resolve(baseDir, file)),
|
||||
evidenceImages,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -346,6 +487,8 @@ function validateGptImageLiveEvidence(evidencePath) {
|
||||
const models = isRecord(evidence.models) ? evidence.models : undefined;
|
||||
const result = isRecord(evidence.result) ? evidence.result : undefined;
|
||||
const document = isRecord(evidence.document) ? evidence.document : undefined;
|
||||
const gateway = isRecord(evidence.gateway) ? evidence.gateway : undefined;
|
||||
const transport = result?.transport ?? gateway?.transport ?? "responses";
|
||||
|
||||
assert(
|
||||
evidence.schema === GPT_IMAGE_LIVE_SCHEMA_VERSION,
|
||||
@@ -385,14 +528,25 @@ function validateGptImageLiveEvidence(evidencePath) {
|
||||
Number.isInteger(result?.imageBytes) && result.imageBytes > 0,
|
||||
`GPT Image live evidence imageBytes 无效: ${result?.imageBytes}`,
|
||||
);
|
||||
assert(
|
||||
Number.isInteger(result?.eventCount) && result.eventCount > 0,
|
||||
`GPT Image live evidence eventCount 无效: ${result?.eventCount}`,
|
||||
);
|
||||
assert(
|
||||
Number.isInteger(result?.outputItemCount) && result.outputItemCount > 0,
|
||||
`GPT Image live evidence outputItemCount 无效: ${result?.outputItemCount}`,
|
||||
);
|
||||
if (transport === "responses") {
|
||||
assert(
|
||||
Number.isInteger(result?.eventCount) && result.eventCount > 0,
|
||||
`GPT Image live evidence eventCount 无效: ${result?.eventCount}`,
|
||||
);
|
||||
assert(
|
||||
Number.isInteger(result?.outputItemCount) && result.outputItemCount > 0,
|
||||
`GPT Image live evidence outputItemCount 无效: ${result?.outputItemCount}`,
|
||||
);
|
||||
} else {
|
||||
assert(
|
||||
transport === "lime_images_gateway",
|
||||
`GPT Image live evidence transport 不支持: ${transport}`,
|
||||
);
|
||||
assert(
|
||||
gateway?.imagesPath === "/v1/images/generations",
|
||||
`GPT Image live evidence 本地配图网关路径不匹配: ${gateway?.imagesPath}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
typeof result?.imageOutputPath === "string" &&
|
||||
result.imageOutputPath.trim().length > 0,
|
||||
@@ -400,7 +554,9 @@ function validateGptImageLiveEvidence(evidencePath) {
|
||||
);
|
||||
|
||||
const imageOutputPath = path.resolve(baseDir, result.imageOutputPath);
|
||||
assert(fs.existsSync(imageOutputPath), `GPT Image live PNG evidence 不存在: ${imageOutputPath}`);
|
||||
const imageOutput = validatePngFile(imageOutputPath, "GPT Image live PNG evidence", {
|
||||
byteLength: result.imageBytes,
|
||||
});
|
||||
|
||||
assert(
|
||||
typeof document?.generatedAssetId === "string" &&
|
||||
@@ -419,8 +575,10 @@ function validateGptImageLiveEvidence(evidencePath) {
|
||||
imageModel: models?.imageModel,
|
||||
outerModel: models?.outerModel,
|
||||
executorMode: models?.executorMode,
|
||||
transport,
|
||||
imageBytes: result.imageBytes,
|
||||
imageOutputPath,
|
||||
imageOutput,
|
||||
targetLayerId: document?.targetLayerId,
|
||||
generatedAssetId: document?.generatedAssetId,
|
||||
};
|
||||
@@ -448,11 +606,22 @@ function validateCompletionEvidenceFile(evidencePath) {
|
||||
const baseDir = path.dirname(resolvedEvidencePath);
|
||||
const evidence = readJson(resolvedEvidencePath);
|
||||
const requirements = normalizeRequirements(evidence.requirements);
|
||||
const completionGate = isRecord(evidence.completionGate)
|
||||
? evidence.completionGate
|
||||
: undefined;
|
||||
|
||||
assert(
|
||||
evidence.schemaVersion === SCHEMA_VERSION,
|
||||
`completion evidence schemaVersion 不匹配: ${evidence.schemaVersion}`,
|
||||
);
|
||||
assert(
|
||||
completionGate?.status === "external_evidence_completed",
|
||||
`completion evidence gate 不是 external_evidence_completed: ${completionGate?.status}`,
|
||||
);
|
||||
assert(
|
||||
readStringArray(completionGate.missing).length === 0,
|
||||
`completion evidence gate 仍有缺口: ${readStringArray(completionGate.missing).join(", ")}`,
|
||||
);
|
||||
|
||||
const benchmarkPath = resolveExistingPath(
|
||||
baseDir,
|
||||
@@ -500,6 +669,9 @@ function validateCompletionEvidenceFile(evidencePath) {
|
||||
evidencePath: resolvedEvidencePath,
|
||||
benchmark: {
|
||||
reportPath: benchmarkPath,
|
||||
sampleManifestPath: benchmark.sampleManifest.sampleManifestPath,
|
||||
sampleImages: benchmark.sampleManifest.sampleImages,
|
||||
sampleImagePaths: benchmark.sampleManifest.sampleImages.map((image) => image.path),
|
||||
checkedSamples: benchmark.checkedSamples,
|
||||
checkedKinds: benchmark.checkedKinds,
|
||||
checkedRequestCount: benchmark.checkedRequestCount,
|
||||
@@ -616,6 +788,28 @@ function createSelfTestFiles(rootDir) {
|
||||
"real-poster-dark-neon-004",
|
||||
"real-poster-dense-product-card-005",
|
||||
];
|
||||
const tinyPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABDQottAAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
const sampleDir = path.join(rootDir, "samples");
|
||||
fs.mkdirSync(sampleDir, { recursive: true });
|
||||
const sampleManifest = {
|
||||
schemaVersion: "layered-design-model-slot-benchmark-samples@1",
|
||||
samples: sampleIds.map((id) => {
|
||||
fs.writeFileSync(path.join(sampleDir, `${id}.png`), tinyPng);
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
image: {
|
||||
path: `samples/${id}.png`,
|
||||
width: 1,
|
||||
height: 1,
|
||||
mimeType: "image/png",
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
const benchmarkReport = {
|
||||
schemaVersion: BENCHMARK_SCHEMA_VERSION,
|
||||
createdAt: "2026-05-08T00:00:00.000Z",
|
||||
@@ -646,6 +840,7 @@ function createSelfTestFiles(rootDir) {
|
||||
schemaVersion: HUMAN_REVIEW_SCHEMA_VERSION,
|
||||
reviewedAt: "2026-05-08T00:05:00.000Z",
|
||||
benchmarkReport: "model-slot-benchmark.real.json",
|
||||
evidenceFiles: ["model-slot-review-contact-sheet.png"],
|
||||
samples: sampleIds.map((id) => ({
|
||||
id,
|
||||
scores: {
|
||||
@@ -660,21 +855,46 @@ function createSelfTestFiles(rootDir) {
|
||||
})),
|
||||
};
|
||||
const exportDir = path.join(rootDir, "real-sample.layered-design");
|
||||
const assetDir = path.join(exportDir, "assets");
|
||||
fs.mkdirSync(exportDir, { recursive: true });
|
||||
fs.mkdirSync(assetDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "model-slot-benchmark.real.json"),
|
||||
JSON.stringify(benchmarkReport, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "real-samples.json"),
|
||||
JSON.stringify(sampleManifest, null, 2),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "real-samples.human-review.json"),
|
||||
JSON.stringify(review, null, 2),
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "model-slot-review-contact-sheet.png"), tinyPng);
|
||||
fs.writeFileSync(path.join(exportDir, "design.json"), "{}\n");
|
||||
fs.writeFileSync(path.join(exportDir, "preview.svg"), "<svg xmlns=\"http://www.w3.org/2000/svg\" />\n");
|
||||
fs.writeFileSync(path.join(exportDir, "preview.png"), tinyPng);
|
||||
fs.writeFileSync(path.join(assetDir, "subject.png"), tinyPng);
|
||||
fs.writeFileSync(
|
||||
path.join(exportDir, "export-manifest.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
schemaVersion: "layered-design-export@1",
|
||||
designFile: "design.json",
|
||||
previewSvgFile: "preview.svg",
|
||||
previewPngFile: "preview.png",
|
||||
evidence: { modelSlotBenchmark: benchmarkSummary },
|
||||
assets: [
|
||||
{
|
||||
id: "subject",
|
||||
kind: "subject_cutout",
|
||||
source: "file",
|
||||
filename: "assets/subject.png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
hasAlpha: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -685,6 +905,10 @@ function createSelfTestFiles(rootDir) {
|
||||
JSON.stringify(
|
||||
{
|
||||
compatibility: { truePsd: false },
|
||||
preview: {
|
||||
svgFile: "preview.svg",
|
||||
pngFile: "preview.png",
|
||||
},
|
||||
layers: [{ name: "主体候选", type: "image" }],
|
||||
},
|
||||
null,
|
||||
@@ -695,7 +919,7 @@ function createSelfTestFiles(rootDir) {
|
||||
path.join(exportDir, "trial.psd"),
|
||||
createMinimalPsdWithLayer("主体候选"),
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "photopea-layer-panel.png"), "self-test");
|
||||
fs.writeFileSync(path.join(rootDir, "photopea-layer-panel.png"), tinyPng);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "design-tool-interoperability.json"),
|
||||
JSON.stringify(
|
||||
@@ -726,7 +950,7 @@ function createSelfTestFiles(rootDir) {
|
||||
2,
|
||||
),
|
||||
);
|
||||
fs.writeFileSync(path.join(rootDir, "gpt-image-live.png"), "self-test-png");
|
||||
fs.writeFileSync(path.join(rootDir, "gpt-image-live.png"), tinyPng);
|
||||
const gptImageLiveEvidence = {
|
||||
schema: GPT_IMAGE_LIVE_SCHEMA_VERSION,
|
||||
mode: "live",
|
||||
@@ -756,7 +980,7 @@ function createSelfTestFiles(rootDir) {
|
||||
imageItemId: "ig_self_test",
|
||||
eventCount: 2,
|
||||
outputItemCount: 1,
|
||||
imageBytes: 13,
|
||||
imageBytes: tinyPng.length,
|
||||
imageOutputPath: "gpt-image-live.png",
|
||||
},
|
||||
document: {
|
||||
@@ -796,6 +1020,10 @@ function createSelfTestFiles(rootDir) {
|
||||
minimumPassedTools: DEFAULT_MINIMUM_PASSED_TOOLS,
|
||||
requiredKinds: REQUIRED_KINDS,
|
||||
},
|
||||
completionGate: {
|
||||
status: "external_evidence_completed",
|
||||
missing: [],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
@@ -831,6 +1059,29 @@ function runSelfTest() {
|
||||
"self-test GPT Image live executor 异常",
|
||||
);
|
||||
|
||||
const localGatewayGptImageLiveEvidence = structuredClone(gptImageLiveEvidence);
|
||||
localGatewayGptImageLiveEvidence.gateway = {
|
||||
baseUrlHash: "selftest-local",
|
||||
transport: "lime_images_gateway",
|
||||
imagesPath: "/v1/images/generations",
|
||||
};
|
||||
localGatewayGptImageLiveEvidence.result.transport = "lime_images_gateway";
|
||||
localGatewayGptImageLiveEvidence.result.eventCount = 0;
|
||||
localGatewayGptImageLiveEvidence.result.outputItemCount = 0;
|
||||
fs.writeFileSync(
|
||||
gptImageLivePath,
|
||||
JSON.stringify(localGatewayGptImageLiveEvidence, null, 2),
|
||||
);
|
||||
const localGatewaySummary = validateCompletionEvidenceFile(evidencePath);
|
||||
assert(
|
||||
localGatewaySummary.gptImageLive.transport === "lime_images_gateway",
|
||||
"self-test 本地配图网关 GPT Image live transport 异常",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
gptImageLivePath,
|
||||
JSON.stringify(gptImageLiveEvidence, null, 2),
|
||||
);
|
||||
|
||||
const invalidGptImageLiveEvidence = structuredClone(gptImageLiveEvidence);
|
||||
invalidGptImageLiveEvidence.checks.targetLayerUpdated = false;
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env tsx
|
||||
/* global Buffer, process */
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
import type { MediaTaskArtifactOutput } from "../src/lib/api/mediaTasks";
|
||||
import {
|
||||
@@ -16,20 +17,38 @@ import { createLayeredDesignSeedDocument } from "../src/lib/layered-design/plann
|
||||
|
||||
const DEFAULT_OUTER_MODEL = "gpt-5.5";
|
||||
const DEFAULT_IMAGE_MODEL = "gpt-images-2";
|
||||
const DEFAULT_IMAGE_SIZE = "1024x1024";
|
||||
const DEFAULT_DEV_BRIDGE_INVOKE_URL = "http://127.0.0.1:3030/invoke";
|
||||
const TINY_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
type ImageGenerationTransport = "responses" | "lime_images_gateway";
|
||||
type ExpectedExecutorMode = "images_api" | "responses_image_generation";
|
||||
|
||||
interface CliOptions {
|
||||
selfTest: boolean;
|
||||
localLimeImageGateway: boolean;
|
||||
dryRun: boolean;
|
||||
autoProvider: boolean;
|
||||
allowExternalImageGeneration: boolean;
|
||||
output?: string;
|
||||
imageOutput?: string;
|
||||
baseUrl?: string;
|
||||
providerId?: string;
|
||||
devBridgeInvokeUrl?: string;
|
||||
limeConfigPath?: string;
|
||||
imageModel: string;
|
||||
outerModel: string;
|
||||
imageSize: string;
|
||||
expectedExecutorMode?: ExpectedExecutorMode;
|
||||
evidenceSchema?: string;
|
||||
verificationLabel?: string;
|
||||
}
|
||||
|
||||
interface ImageGenerationResponse {
|
||||
transport: ImageGenerationTransport;
|
||||
imageBase64: string;
|
||||
imageUrl?: string;
|
||||
imageItemId?: string;
|
||||
revisedPrompt?: string;
|
||||
eventCount: number;
|
||||
@@ -39,8 +58,13 @@ interface ImageGenerationResponse {
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
selfTest: false,
|
||||
localLimeImageGateway: false,
|
||||
dryRun: false,
|
||||
autoProvider: false,
|
||||
allowExternalImageGeneration: false,
|
||||
imageModel: DEFAULT_IMAGE_MODEL,
|
||||
outerModel: DEFAULT_OUTER_MODEL,
|
||||
imageSize: DEFAULT_IMAGE_SIZE,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
@@ -49,6 +73,22 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
options.selfTest = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--lime-local-image-gateway") {
|
||||
options.localLimeImageGateway = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--auto-provider") {
|
||||
options.autoProvider = true;
|
||||
continue;
|
||||
}
|
||||
if (token === "--allow-external-image-generation") {
|
||||
options.allowExternalImageGeneration = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token.startsWith("--")) {
|
||||
throw new Error(`未知参数: ${token}`);
|
||||
@@ -71,12 +111,42 @@ function parseArgs(argv: string[]): CliOptions {
|
||||
case "base-url":
|
||||
options.baseUrl = value;
|
||||
break;
|
||||
case "provider-id":
|
||||
if (value.trim().toLowerCase() === "auto") {
|
||||
options.autoProvider = true;
|
||||
} else {
|
||||
options.providerId = value;
|
||||
}
|
||||
break;
|
||||
case "dev-bridge-invoke-url":
|
||||
options.devBridgeInvokeUrl = value;
|
||||
break;
|
||||
case "lime-config":
|
||||
options.limeConfigPath = value;
|
||||
break;
|
||||
case "image-model":
|
||||
options.imageModel = value;
|
||||
break;
|
||||
case "outer-model":
|
||||
options.outerModel = value;
|
||||
break;
|
||||
case "image-size":
|
||||
options.imageSize = value;
|
||||
break;
|
||||
case "expected-executor-mode":
|
||||
if (value !== "images_api" && value !== "responses_image_generation") {
|
||||
throw new Error(
|
||||
"--expected-executor-mode 只能是 images_api 或 responses_image_generation",
|
||||
);
|
||||
}
|
||||
options.expectedExecutorMode = value;
|
||||
break;
|
||||
case "evidence-schema":
|
||||
options.evidenceSchema = value;
|
||||
break;
|
||||
case "verification-label":
|
||||
options.verificationLabel = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知参数: --${key}`);
|
||||
}
|
||||
@@ -91,12 +161,27 @@ function usage(): string {
|
||||
" npm exec -- tsx scripts/verify-layered-design-gpt-image-live.ts --self-test --output /tmp/evidence.json",
|
||||
"",
|
||||
"真实网关验收:",
|
||||
" IMAGE_API_KEY=... IMAGE_BASE_URL=... npm exec -- tsx scripts/verify-layered-design-gpt-image-live.ts --output docs/roadmap/ai-layered-design/evidence/gpt-image-live.json --image-output docs/roadmap/ai-layered-design/evidence/gpt-image-live.png",
|
||||
" IMAGE_API_KEY=... IMAGE_BASE_URL=... npm exec -- tsx scripts/verify-layered-design-gpt-image-live.ts --allow-external-image-generation --output docs/roadmap/ai-layered-design/evidence/gpt-image-live.json --image-output docs/roadmap/ai-layered-design/evidence/gpt-image-live.png",
|
||||
"",
|
||||
"复用 Lime 本地配图网关验收:",
|
||||
" npm exec -- tsx scripts/verify-layered-design-gpt-image-live.ts --lime-local-image-gateway --provider-id <provider-id> --allow-external-image-generation --output docs/roadmap/ai-layered-design/evidence/gpt-image-live.json --image-output docs/roadmap/ai-layered-design/evidence/gpt-image-live.png",
|
||||
" npm exec -- tsx scripts/verify-layered-design-gpt-image-live.ts --lime-local-image-gateway --auto-provider --allow-external-image-generation --output docs/roadmap/ai-layered-design/evidence/gpt-image-live.json --image-output docs/roadmap/ai-layered-design/evidence/gpt-image-live.png",
|
||||
"",
|
||||
"可选参数:",
|
||||
" --base-url <Responses 网关基址,通常到 /v1>",
|
||||
" --lime-local-image-gateway <复用本地 Lime /v1/images/generations 配图网关>",
|
||||
" --provider-id <本地 Lime API Key Provider ID;也可传 auto>",
|
||||
" --auto-provider <通过 DevBridge 自动选择 custom_models 命中图片模型且有启用 key 的 provider>",
|
||||
" --dev-bridge-invoke-url <DevBridge invoke URL,默认 http://127.0.0.1:3030/invoke>",
|
||||
" --allow-external-image-generation <确认会调用真实图片接口并可能消耗额度>",
|
||||
" --lime-config <本地 Lime config.yaml 路径>",
|
||||
" --dry-run <只做本地网关/参数预检,不生成图片>",
|
||||
" --image-model gpt-images-2",
|
||||
" --outer-model gpt-5.5",
|
||||
" --image-size 1024x1024",
|
||||
" --expected-executor-mode responses_image_generation|images_api",
|
||||
" --evidence-schema <证据 schema,默认 layered-design-gpt-image-live-evidence@1>",
|
||||
" --verification-label <证据标签,默认 gpt-image-live>",
|
||||
" --image-output <保存生成 PNG 的路径>",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -112,6 +197,30 @@ function buildResponsesUrl(baseUrl: string): string {
|
||||
return `${trimmed}/responses`;
|
||||
}
|
||||
|
||||
function buildImagesGenerationUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
throw new Error("base URL 不能为空");
|
||||
}
|
||||
if (trimmed.endsWith("/images/generations")) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.endsWith("/v1")) {
|
||||
return `${trimmed}/images/generations`;
|
||||
}
|
||||
return `${trimmed}/v1/images/generations`;
|
||||
}
|
||||
|
||||
function buildLocalHealthUrl(baseUrl: string): string {
|
||||
const parsed = new URL(buildImagesGenerationUrl(baseUrl));
|
||||
return `${parsed.origin}/health`;
|
||||
}
|
||||
|
||||
function buildLocalModelsUrl(baseUrl: string): string {
|
||||
const parsed = new URL(buildImagesGenerationUrl(baseUrl));
|
||||
return `${parsed.origin}/v1/models`;
|
||||
}
|
||||
|
||||
function buildInput(prompt: string, useInputList: boolean): unknown {
|
||||
if (!useInputList) {
|
||||
return prompt;
|
||||
@@ -149,6 +258,32 @@ async function requestResponsesImageGeneration(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function requestLimeImagesGatewayGeneration(params: {
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerId: string;
|
||||
imageModel: string;
|
||||
imageSize: string;
|
||||
prompt: string;
|
||||
}): Promise<Response> {
|
||||
return fetch(buildImagesGenerationUrl(params.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Provider-Id": params.providerId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
prompt: params.prompt,
|
||||
model: params.imageModel,
|
||||
n: 1,
|
||||
size: params.imageSize,
|
||||
response_format: "b64_json",
|
||||
user: "layered-design-gpt-image-live",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRetryWithInputList(status: number, text: string): boolean {
|
||||
return status === 400 && /input must be a list/i.test(text);
|
||||
}
|
||||
@@ -225,16 +360,112 @@ async function extractImageGenerationResult(
|
||||
throw new Error("Responses SSE 流里没有 image_generation_call.result");
|
||||
}
|
||||
|
||||
return { imageBase64, imageItemId, revisedPrompt, eventCount, outputItemCount };
|
||||
return {
|
||||
transport: "responses",
|
||||
imageBase64,
|
||||
imageItemId,
|
||||
revisedPrompt,
|
||||
eventCount,
|
||||
outputItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
function isHttpImageUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadImageUrlAsBase64(imageUrl: string): Promise<string> {
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
Accept: "image/png,image/*",
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载远程图片失败 ${response.status}: ${imageUrl}`);
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return bytes.toString("base64");
|
||||
}
|
||||
|
||||
function extractBase64FromDataUrl(value: string): string | undefined {
|
||||
const match = value.trim().match(/^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/);
|
||||
return match?.[1]?.trim();
|
||||
}
|
||||
|
||||
async function extractImagesGatewayResult(response: Response): Promise<ImageGenerationResponse> {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Lime 本地配图网关请求失败 ${response.status}: ${text.slice(0, 1000)}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error(`解析 Lime 本地配图网关响应失败: ${String(error)}`);
|
||||
}
|
||||
|
||||
const data = (parsed as { data?: Array<Record<string, unknown>> }).data;
|
||||
const firstImage = Array.isArray(data) ? data[0] : undefined;
|
||||
const rawBase64 =
|
||||
typeof firstImage?.b64_json === "string" ? firstImage.b64_json.trim() : undefined;
|
||||
const dataUrlBase64 =
|
||||
typeof firstImage?.url === "string" ? extractBase64FromDataUrl(firstImage.url) : undefined;
|
||||
const remoteImageUrl =
|
||||
typeof firstImage?.url === "string" && isHttpImageUrl(firstImage.url)
|
||||
? firstImage.url.trim()
|
||||
: undefined;
|
||||
const imageBase64 =
|
||||
rawBase64 ||
|
||||
dataUrlBase64 ||
|
||||
(remoteImageUrl ? await downloadImageUrlAsBase64(remoteImageUrl) : undefined);
|
||||
if (!imageBase64) {
|
||||
throw new Error("Lime 本地配图网关未返回 b64_json、PNG data URL 或远程图片 URL");
|
||||
}
|
||||
|
||||
return {
|
||||
transport: "lime_images_gateway",
|
||||
imageBase64,
|
||||
imageUrl: remoteImageUrl,
|
||||
revisedPrompt:
|
||||
typeof firstImage?.revised_prompt === "string" ? firstImage.revised_prompt : undefined,
|
||||
eventCount: 0,
|
||||
outputItemCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateImage(params: {
|
||||
transport: ImageGenerationTransport;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerId?: string;
|
||||
outerModel: string;
|
||||
imageModel: string;
|
||||
imageSize: string;
|
||||
prompt: string;
|
||||
}): Promise<ImageGenerationResponse> {
|
||||
if (params.transport === "lime_images_gateway") {
|
||||
if (!params.providerId) {
|
||||
throw new Error("复用 Lime 本地配图网关时必须提供 providerId");
|
||||
}
|
||||
const response = await requestLimeImagesGatewayGeneration({
|
||||
apiKey: params.apiKey,
|
||||
baseUrl: params.baseUrl,
|
||||
providerId: params.providerId,
|
||||
imageModel: params.imageModel,
|
||||
imageSize: params.imageSize,
|
||||
prompt: params.prompt,
|
||||
});
|
||||
return extractImagesGatewayResult(response);
|
||||
}
|
||||
|
||||
let response = await requestResponsesImageGeneration(params);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
@@ -258,6 +489,10 @@ function createTaskOutput(params: {
|
||||
}): MediaTaskArtifactOutput {
|
||||
const createdAt = new Date().toISOString();
|
||||
const imageUrl = `data:image/png;base64,${params.result.imageBase64}`;
|
||||
const imageSource =
|
||||
params.result.transport === "lime_images_gateway"
|
||||
? "lime_images_gateway"
|
||||
: "responses_image_generation";
|
||||
return {
|
||||
success: true,
|
||||
task_id: params.taskId,
|
||||
@@ -300,12 +535,13 @@ function createTaskOutput(params: {
|
||||
{
|
||||
url: imageUrl,
|
||||
revised_prompt: params.result.revisedPrompt,
|
||||
source: "responses_image_generation",
|
||||
source: imageSource,
|
||||
},
|
||||
],
|
||||
responses: [
|
||||
{
|
||||
executor_mode: "responses_image_generation",
|
||||
executor_mode: params.taskRequest.executorMode,
|
||||
transport: params.result.transport,
|
||||
event_count: params.result.eventCount,
|
||||
output_item_count: params.result.outputItemCount,
|
||||
image_item_id: params.result.imageItemId,
|
||||
@@ -320,6 +556,20 @@ function sha256Short(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function isPngBuffer(buffer: Buffer): boolean {
|
||||
return (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer[1] === 0x50 &&
|
||||
buffer[2] === 0x4e &&
|
||||
buffer[3] === 0x47 &&
|
||||
buffer[4] === 0x0d &&
|
||||
buffer[5] === 0x0a &&
|
||||
buffer[6] === 0x1a &&
|
||||
buffer[7] === 0x0a
|
||||
);
|
||||
}
|
||||
|
||||
function readEnvOrOption(options: CliOptions): { apiKey: string; baseUrl: string } {
|
||||
const apiKey = process.env.IMAGE_API_KEY?.trim();
|
||||
const baseUrl = options.baseUrl ?? process.env.IMAGE_BASE_URL?.trim();
|
||||
@@ -329,12 +579,307 @@ function readEnvOrOption(options: CliOptions): { apiKey: string; baseUrl: string
|
||||
return { apiKey, baseUrl };
|
||||
}
|
||||
|
||||
function resolveDefaultLimeConfigPath(): string {
|
||||
if (process.env.LIME_CONFIG_PATH?.trim()) {
|
||||
return process.env.LIME_CONFIG_PATH.trim();
|
||||
}
|
||||
|
||||
const homeDir = os.homedir();
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(homeDir, "Library/Application Support/lime/config.yaml");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(process.env.APPDATA ?? path.join(homeDir, "AppData/Roaming"), "lime/config.yaml");
|
||||
}
|
||||
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "lime/config.yaml");
|
||||
}
|
||||
|
||||
function readNestedRecord(value: unknown, key: string): Record<string, unknown> {
|
||||
const record = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
|
||||
const nested = record[key];
|
||||
return typeof nested === "object" && nested !== null
|
||||
? nested as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function readOptionalNestedString(value: unknown, pathKeys: string[]): string | undefined {
|
||||
let current: unknown = value;
|
||||
for (const key of pathKeys) {
|
||||
if (typeof current !== "object" || current === null) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeServerHost(host: string | undefined): string {
|
||||
const trimmed = host?.trim() || "127.0.0.1";
|
||||
return trimmed === "0.0.0.0" || trimmed === "::" ? "127.0.0.1" : trimmed;
|
||||
}
|
||||
|
||||
interface LocalProviderCandidate {
|
||||
id?: unknown;
|
||||
enabled?: unknown;
|
||||
api_key_count?: unknown;
|
||||
apiKeys?: unknown;
|
||||
api_keys?: unknown;
|
||||
custom_models?: unknown;
|
||||
customModels?: unknown;
|
||||
}
|
||||
|
||||
function normalizeProviderModelToken(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function readProviderCustomModels(provider: LocalProviderCandidate): string[] {
|
||||
const raw = provider.custom_models ?? provider.customModels;
|
||||
return Array.isArray(raw)
|
||||
? raw.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function hasEnabledProviderKey(provider: LocalProviderCandidate): boolean {
|
||||
const rawKeys = provider.api_keys ?? provider.apiKeys;
|
||||
if (Array.isArray(rawKeys)) {
|
||||
return rawKeys.some((item) => {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return false;
|
||||
}
|
||||
return (item as { enabled?: unknown }).enabled !== false;
|
||||
});
|
||||
}
|
||||
|
||||
return typeof provider.api_key_count === "number" && provider.api_key_count > 0;
|
||||
}
|
||||
|
||||
function providerMatchesImageModel(
|
||||
provider: LocalProviderCandidate,
|
||||
imageModel: string,
|
||||
): boolean {
|
||||
const target = normalizeProviderModelToken(imageModel);
|
||||
return readProviderCustomModels(provider).some((model) => {
|
||||
const normalized = normalizeProviderModelToken(model);
|
||||
return normalized === target || normalized.endsWith(`/${target}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeDevBridge(invokeUrl: string, cmd: string): Promise<unknown> {
|
||||
const response = await fetch(invokeUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ cmd, args: {} }),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`DevBridge 非 JSON 响应: ${text.slice(0, 240)}`);
|
||||
}
|
||||
const record = typeof payload === "object" && payload !== null
|
||||
? payload as { error?: unknown; result?: unknown }
|
||||
: {};
|
||||
if (record.error) {
|
||||
throw new Error(String(record.error));
|
||||
}
|
||||
return record.result;
|
||||
}
|
||||
|
||||
async function resolveAutoLocalImageProviderId(options: CliOptions): Promise<string> {
|
||||
const invokeUrl =
|
||||
options.devBridgeInvokeUrl?.trim() ||
|
||||
process.env.LIME_DEV_BRIDGE_INVOKE_URL?.trim() ||
|
||||
DEFAULT_DEV_BRIDGE_INVOKE_URL;
|
||||
let providersRaw: unknown;
|
||||
try {
|
||||
providersRaw = await invokeDevBridge(invokeUrl, "get_api_key_providers");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`无法通过 DevBridge 自动选择图片 provider,请传 --provider-id;${String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const providers = Array.isArray(providersRaw)
|
||||
? providersRaw.filter(
|
||||
(item): item is LocalProviderCandidate =>
|
||||
typeof item === "object" && item !== null,
|
||||
)
|
||||
: [];
|
||||
const candidates = providers.filter((provider) => {
|
||||
const id = typeof provider.id === "string" ? provider.id.trim() : "";
|
||||
return (
|
||||
id &&
|
||||
provider.enabled !== false &&
|
||||
hasEnabledProviderKey(provider) &&
|
||||
providerMatchesImageModel(provider, options.imageModel)
|
||||
);
|
||||
});
|
||||
const selected = candidates[0];
|
||||
const selectedId = typeof selected?.id === "string" ? selected.id.trim() : "";
|
||||
if (!selectedId) {
|
||||
throw new Error(
|
||||
`未找到 custom_models 包含 ${options.imageModel} 且有启用 API Key 的本地图片 provider,请传 --provider-id`,
|
||||
);
|
||||
}
|
||||
return selectedId;
|
||||
}
|
||||
|
||||
async function readLocalLimeGatewayConfig(options: CliOptions): Promise<{
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
providerId: string;
|
||||
}> {
|
||||
const configPath = path.resolve(options.limeConfigPath ?? resolveDefaultLimeConfigPath());
|
||||
const configText = await readFile(configPath, "utf8");
|
||||
const config = parseYaml(configText) as Record<string, unknown>;
|
||||
const server = readNestedRecord(config, "server");
|
||||
const apiKey = typeof server.api_key === "string" ? server.api_key.trim() : "";
|
||||
const host = normalizeServerHost(typeof server.host === "string" ? server.host : undefined);
|
||||
const port = Number(server.port ?? 8999);
|
||||
let providerId =
|
||||
options.providerId?.trim() ??
|
||||
process.env.IMAGE_PROVIDER_ID?.trim() ??
|
||||
readOptionalNestedString(config, [
|
||||
"workspace_preferences",
|
||||
"media_defaults",
|
||||
"image",
|
||||
"preferred_provider_id",
|
||||
]);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`Lime config 缺少 server.api_key: ${configPath}`);
|
||||
}
|
||||
if (!providerId && options.autoProvider) {
|
||||
providerId = await resolveAutoLocalImageProviderId(options);
|
||||
}
|
||||
if (!providerId) {
|
||||
throw new Error("复用 Lime 本地配图网关时必须传 --provider-id / --auto-provider,或配置 media_defaults.image.preferred_provider_id");
|
||||
}
|
||||
if (!Number.isFinite(port) || port <= 0) {
|
||||
throw new Error(`Lime config server.port 无效: ${String(server.port)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
baseUrl: `http://${host}:${port}/v1`,
|
||||
providerId,
|
||||
};
|
||||
}
|
||||
|
||||
async function runDryRun(params: {
|
||||
transport: ImageGenerationTransport;
|
||||
baseUrl: string;
|
||||
providerId: string;
|
||||
imageModel: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (params.transport === "responses") {
|
||||
return {
|
||||
ok: true,
|
||||
dryRun: true,
|
||||
transport: params.transport,
|
||||
gateway: {
|
||||
baseUrlHash: sha256Short(params.baseUrl),
|
||||
responsesPath: new URL(buildResponsesUrl(params.baseUrl)).pathname,
|
||||
},
|
||||
checks: {
|
||||
credentialsProvided: true,
|
||||
networkProbeSkipped: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const healthUrl = buildLocalHealthUrl(params.baseUrl);
|
||||
const modelsUrl = buildLocalModelsUrl(params.baseUrl);
|
||||
let healthStatus = 0;
|
||||
let modelIds: string[] = [];
|
||||
try {
|
||||
const healthResponse = await fetch(healthUrl);
|
||||
healthStatus = healthResponse.status;
|
||||
} catch {
|
||||
healthStatus = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const modelsResponse = await fetch(modelsUrl);
|
||||
const modelsBody = await modelsResponse.json() as { data?: Array<{ id?: string }> };
|
||||
modelIds = Array.isArray(modelsBody.data)
|
||||
? modelsBody.data.map((item) => item.id).filter((item): item is string => Boolean(item))
|
||||
: [];
|
||||
} catch {
|
||||
modelIds = [];
|
||||
}
|
||||
|
||||
const imageModelListed = modelIds.includes(params.imageModel);
|
||||
return {
|
||||
ok: healthStatus >= 200 && healthStatus < 300 && Boolean(params.providerId),
|
||||
dryRun: true,
|
||||
transport: params.transport,
|
||||
providerId: params.providerId,
|
||||
gateway: {
|
||||
baseUrlHash: sha256Short(params.baseUrl),
|
||||
healthPath: new URL(healthUrl).pathname,
|
||||
imagesPath: new URL(buildImagesGenerationUrl(params.baseUrl)).pathname,
|
||||
modelsPath: new URL(modelsUrl).pathname,
|
||||
},
|
||||
checks: {
|
||||
localServerHealthy: healthStatus >= 200 && healthStatus < 300,
|
||||
providerIdProvided: Boolean(params.providerId),
|
||||
imageModelListed,
|
||||
imageGenerationSkipped: true,
|
||||
},
|
||||
observed: {
|
||||
healthStatus,
|
||||
modelCount: modelIds.length,
|
||||
matchingModels: modelIds.filter((item) => item.includes("gpt-image") || item.includes("gpt-images")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startSelfTestServer(): Promise<{
|
||||
baseUrl: string;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
const server = createServer(async (request: IncomingMessage, response: ServerResponse) => {
|
||||
if (request.method !== "POST" || request.url !== "/v1/responses") {
|
||||
if (request.method === "GET" && request.url === "/health") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ status: "healthy", version: "self-test" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && request.url === "/v1/models") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
object: "list",
|
||||
data: [{ id: DEFAULT_IMAGE_MODEL, object: "model", owned_by: "self-test" }],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url === "/v1/images/generations") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
created: 1_717_200_000,
|
||||
data: [
|
||||
{
|
||||
b64_json: TINY_PNG_BASE64,
|
||||
revised_prompt: "本地配图网关自测青柠图层",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.url !== "/v1/responses") {
|
||||
response.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
@@ -397,24 +942,57 @@ async function main() {
|
||||
let selfTestServer: Awaited<ReturnType<typeof startSelfTestServer>> | null = null;
|
||||
let apiKey = "";
|
||||
let baseUrl = "";
|
||||
let providerId = options.providerId?.trim() || "openai";
|
||||
const transport: ImageGenerationTransport = options.localLimeImageGateway
|
||||
? "lime_images_gateway"
|
||||
: "responses";
|
||||
const mode = options.selfTest ? "self_test" : "live";
|
||||
|
||||
try {
|
||||
if (!options.selfTest && !options.dryRun && !options.allowExternalImageGeneration) {
|
||||
throw new Error(
|
||||
`${usage()}\n\n拒绝执行真实图片生成:请显式传 --allow-external-image-generation,表示已确认会调用真实图片接口并可能消耗额度。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.selfTest) {
|
||||
selfTestServer = await startSelfTestServer();
|
||||
apiKey = "self-test-key";
|
||||
baseUrl = selfTestServer.baseUrl;
|
||||
if (options.localLimeImageGateway) {
|
||||
providerId = options.providerId?.trim() || "self-test-provider";
|
||||
}
|
||||
} else if (options.localLimeImageGateway) {
|
||||
const localConfig = await readLocalLimeGatewayConfig(options);
|
||||
apiKey = localConfig.apiKey;
|
||||
baseUrl = localConfig.baseUrl;
|
||||
providerId = localConfig.providerId;
|
||||
} else {
|
||||
const env = readEnvOrOption(options);
|
||||
apiKey = env.apiKey;
|
||||
baseUrl = env.baseUrl;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
const dryRun = await runDryRun({
|
||||
transport,
|
||||
baseUrl,
|
||||
providerId,
|
||||
imageModel: options.imageModel,
|
||||
});
|
||||
if (options.output) {
|
||||
await writeJson(path.resolve(options.output), dryRun);
|
||||
}
|
||||
console.log(JSON.stringify(dryRun, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRootPath = path.join(os.tmpdir(), "lime-layered-design-gpt-image-live");
|
||||
const verificationLabel = options.verificationLabel?.trim() || "gpt-image-live";
|
||||
const document = createLayeredDesignSeedDocument({
|
||||
prompt: "@配图 青柠汽水产品主视觉,透明主体图层",
|
||||
id: "gpt-image-live-design",
|
||||
title: "GPT Image 图层生成验收",
|
||||
id: `${verificationLabel}-design`,
|
||||
title: `${verificationLabel} 图层生成验收`,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const generationRequest =
|
||||
@@ -426,22 +1004,25 @@ async function main() {
|
||||
|
||||
const taskRequest = createLayeredDesignImageTaskRequest(document, generationRequest, {
|
||||
projectRootPath,
|
||||
providerId: "openai",
|
||||
providerId,
|
||||
model: options.imageModel,
|
||||
outerModel: options.outerModel,
|
||||
usage: "layered_design_gpt_image_live_verification",
|
||||
});
|
||||
|
||||
const generated = await generateImage({
|
||||
transport,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
providerId,
|
||||
outerModel: options.outerModel,
|
||||
imageModel: options.imageModel,
|
||||
imageSize: options.imageSize,
|
||||
prompt: taskRequest.prompt,
|
||||
});
|
||||
const taskOutput = createTaskOutput({
|
||||
projectRootPath,
|
||||
taskId: `${mode}-gpt-image-task`,
|
||||
taskId: `${mode}-${verificationLabel}-task`,
|
||||
taskRequest,
|
||||
result: generated,
|
||||
});
|
||||
@@ -460,14 +1041,24 @@ async function main() {
|
||||
const appliedAsset = appliedDocument.assets.find(
|
||||
(asset) => asset.id === targetLayer?.assetId,
|
||||
);
|
||||
const imageBuffer = Buffer.from(generated.imageBase64, "base64");
|
||||
const imageOutputPath = await writeImageIfRequested(options.imageOutput, generated.imageBase64);
|
||||
const expectedExecutorMode =
|
||||
options.expectedExecutorMode ?? "responses_image_generation";
|
||||
const evidence = {
|
||||
schema: "layered-design-gpt-image-live-evidence@1",
|
||||
schema:
|
||||
options.evidenceSchema?.trim() || "layered-design-gpt-image-live-evidence@1",
|
||||
mode,
|
||||
generatedAt: new Date().toISOString(),
|
||||
verificationLabel,
|
||||
gateway: {
|
||||
baseUrlHash: sha256Short(baseUrl),
|
||||
responsesPath: new URL(buildResponsesUrl(baseUrl)).pathname,
|
||||
transport,
|
||||
responsesPath: transport === "responses" ? new URL(buildResponsesUrl(baseUrl)).pathname : undefined,
|
||||
imagesPath:
|
||||
transport === "lime_images_gateway"
|
||||
? new URL(buildImagesGenerationUrl(baseUrl)).pathname
|
||||
: undefined,
|
||||
},
|
||||
models: {
|
||||
imageModel: options.imageModel,
|
||||
@@ -486,12 +1077,14 @@ async function main() {
|
||||
targetOutputRefId: taskRequest.targetOutputRefId,
|
||||
},
|
||||
result: {
|
||||
transport: generated.transport,
|
||||
imageCount: 1,
|
||||
imageItemId: generated.imageItemId,
|
||||
eventCount: generated.eventCount,
|
||||
outputItemCount: generated.outputItemCount,
|
||||
imageBytes: Buffer.byteLength(generated.imageBase64, "base64"),
|
||||
imageBytes: imageBuffer.length,
|
||||
imageOutputPath,
|
||||
remoteImageUrl: generated.imageUrl,
|
||||
},
|
||||
document: {
|
||||
documentId: appliedDocument.id,
|
||||
@@ -505,10 +1098,12 @@ async function main() {
|
||||
noLegacyPosterRoute:
|
||||
!JSON.stringify(taskRequest).includes("poster_generate") &&
|
||||
!JSON.stringify(taskRequest).includes("canvas:poster"),
|
||||
executorModeResponses: taskRequest.executorMode === "responses_image_generation",
|
||||
executorModeMatchesExpected:
|
||||
taskRequest.executorMode === expectedExecutorMode,
|
||||
imageDataUrl: taskOutput.record.result?.images?.[0]?.url?.startsWith(
|
||||
"data:image/png;base64,",
|
||||
),
|
||||
pngMagic: isPngBuffer(imageBuffer),
|
||||
generatedAssetApplied: Boolean(appliedAsset?.src),
|
||||
targetLayerUpdated: targetLayer?.assetId === appliedAsset?.id,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user