chore: release v1.31.0

This commit is contained in:
coso
2026-05-09 02:55:03 +08:00
parent a87039a312
commit 32c770c157
154 changed files with 10626 additions and 5684 deletions
@@ -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
View File
@@ -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 {
-44
View File
@@ -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
View File
@@ -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(
+609 -14
View File
@@ -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,
},