|
|
@@ -0,0 +1,764 @@
|
|
|
+import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
+import { execFile } from "node:child_process";
|
|
|
+import { homedir } from "node:os";
|
|
|
+import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
|
+import { promisify } from "node:util";
|
|
|
+import { fileURLToPath } from "node:url";
|
|
|
+import { uploadLocalImage } from "./qiniu-upload-client.mjs";
|
|
|
+import { assertRunFile, pathsForRun } from "../core/run-paths.mjs";
|
|
|
+import { sha256, signState, verifyState } from "../core/run-state-integrity.mjs";
|
|
|
+import { loadVisionConfig, reviewGeneratedProduct } from "./commerce-vision-provider.mjs";
|
|
|
+
|
|
|
+const args = process.argv.slice(2);
|
|
|
+const planFileArg = args[0];
|
|
|
+if (!planFileArg || planFileArg.startsWith("--")) {
|
|
|
+ throw new Error("Usage: node jimeng-v4-client.mjs <prompt-plan.json> --dry-run|--prepare-assets|--execute|--download-only [--vision-result vision-result.json] [--max-cost 1.54] [--asset-map assets.json] [--output result.json] [--download-timeout-seconds 180]");
|
|
|
+}
|
|
|
+
|
|
|
+const flag = (name) => args.includes(name);
|
|
|
+const option = (name, fallback) => {
|
|
|
+ const index = args.indexOf(name);
|
|
|
+ return index >= 0 ? args[index + 1] : fallback;
|
|
|
+};
|
|
|
+async function readConfiguredToken() {
|
|
|
+ if (process.env.JIMENG_TOKEN) return process.env.JIMENG_TOKEN;
|
|
|
+ try {
|
|
|
+ const serialized = await readFile(join(homedir(), ".openclaw", "voc-credentials.json"), "utf8");
|
|
|
+ const credentials = JSON.parse(serialized.replace(/^\uFEFF/, ""));
|
|
|
+ return credentials.jimengToken ?? credentials.jimeng?.token ?? credentials.token;
|
|
|
+ } catch {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+}
|
|
|
+const requestedModes = ["--dry-run", "--prepare-assets", "--execute", "--download-only"].filter(flag);
|
|
|
+if (requestedModes.length > 1) throw new Error("Choose only one mode: --dry-run, --prepare-assets, --execute, or --download-only");
|
|
|
+const mode = flag("--execute") ? "execute" : flag("--prepare-assets") ? "prepare-assets" : flag("--download-only") ? "download-only" : "dry-run";
|
|
|
+if (requestedModes.length === 0) console.warn("No mode supplied; defaulting to dry-run.");
|
|
|
+
|
|
|
+const planFile = resolve(planFileArg);
|
|
|
+const planText = await readFile(planFile, "utf8");
|
|
|
+const plan = JSON.parse(planText);
|
|
|
+if (plan.status !== "ready" || !Array.isArray(plan.images) || plan.images.length !== 7) {
|
|
|
+ throw new Error("Prompt plan must be ready and contain seven images");
|
|
|
+}
|
|
|
+const canonicalPaths = pathsForRun(plan.run_id);
|
|
|
+assertRunFile(planFile, canonicalPaths.run_directory, "prompt-plan.json");
|
|
|
+const execFileAsync = promisify(execFile);
|
|
|
+const validatorFile = fileURLToPath(new URL("../features/image-set/validate-plan.mjs", import.meta.url));
|
|
|
+try {
|
|
|
+ await execFileAsync(process.execPath, [validatorFile, planFile]);
|
|
|
+} catch (error) {
|
|
|
+ let validation = null;
|
|
|
+ try {
|
|
|
+ validation = JSON.parse(error?.stderr || "");
|
|
|
+ } catch {
|
|
|
+ validation = null;
|
|
|
+ }
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: {
|
|
|
+ code: "PLAN_VALIDATION_FAILED",
|
|
|
+ message: validation?.errors?.join("; ") || error?.message || "Prompt plan validation failed",
|
|
|
+ },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+}
|
|
|
+
|
|
|
+let visionResult = null;
|
|
|
+let visionResultFile = null;
|
|
|
+let visionResultText = "";
|
|
|
+if (mode === "execute") {
|
|
|
+ const visionResultArg = option("--vision-result", "");
|
|
|
+ if (!visionResultArg) {
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: { code: "VISION_PIPELINE_REQUIRED", message: "Run the internal product vision adapter before Jimeng execution" },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+ }
|
|
|
+ visionResultFile = resolve(visionResultArg);
|
|
|
+ try {
|
|
|
+ assertRunFile(visionResultFile, canonicalPaths.run_directory, "vision-result.json");
|
|
|
+ visionResultText = (await readFile(visionResultFile, "utf8")).replace(/^\uFEFF/, "");
|
|
|
+ visionResult = JSON.parse(visionResultText);
|
|
|
+ } catch (error) {
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: { code: "VISION_RESULT_INVALID", message: `Cannot read vision result: ${error.message}` },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+ }
|
|
|
+ if (visionResult.status !== "complete" || !visionResult.product_understanding || !visionResult.reference?.public_url) {
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: { code: "VISION_RESULT_INVALID", message: "Vision result is incomplete and cannot authorize Jimeng execution" },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+ }
|
|
|
+ const planIdentity = plan.product_understanding?.identity_manifest;
|
|
|
+ const visionIdentity = visionResult.product_understanding?.identity_manifest;
|
|
|
+ const planFacts = plan.product_understanding?.verified_visible_facts;
|
|
|
+ const visionFacts = visionResult.product_understanding?.verified_visible_facts;
|
|
|
+ if (
|
|
|
+ JSON.stringify(planIdentity) !== JSON.stringify(visionIdentity)
|
|
|
+ || JSON.stringify(planFacts) !== JSON.stringify(visionFacts)
|
|
|
+ ) {
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: {
|
|
|
+ code: "VISION_PLAN_IDENTITY_MISMATCH",
|
|
|
+ message: "The prompt plan changed the audited product identity or verified visible facts",
|
|
|
+ },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const publicUrlPattern = /^https?:\/\//i;
|
|
|
+const costPerImage = 0.22;
|
|
|
+const estimatedCost = Number((plan.images.length * costPerImage).toFixed(2));
|
|
|
+const assetMapFile = assertRunFile(
|
|
|
+ resolve(option("--asset-map", canonicalPaths.asset_map)),
|
|
|
+ canonicalPaths.run_directory,
|
|
|
+ "reference-assets.json",
|
|
|
+);
|
|
|
+const resultOutputFile = assertRunFile(
|
|
|
+ resolve(option("--output", canonicalPaths.jimeng_result)),
|
|
|
+ canonicalPaths.run_directory,
|
|
|
+ "jimeng-result.json",
|
|
|
+);
|
|
|
+const downloadDir = resolve(option("--download-dir", canonicalPaths.images_directory));
|
|
|
+const samePath = (left, right) => process.platform === "win32"
|
|
|
+ ? resolve(left).toLowerCase() === resolve(right).toLowerCase()
|
|
|
+ : resolve(left) === resolve(right);
|
|
|
+if (!samePath(downloadDir, canonicalPaths.images_directory)) {
|
|
|
+ throw new Error(`RUN_PATH_INVALID: images must be downloaded to ${canonicalPaths.images_directory}`);
|
|
|
+}
|
|
|
+const downloadTimeoutSeconds = Math.max(10, Number(option("--download-timeout-seconds", "180")) || 180);
|
|
|
+const forwardPath = (value) => resolve(value).replace(/\\/g, "/");
|
|
|
+const deliveryMetadata = (items) => ({
|
|
|
+ download_dir: downloadDir,
|
|
|
+ local_images: items.map((item) => item.file),
|
|
|
+ image_markdown_links: items.map((item) => `[${item.label}](<${forwardPath(item.file)}>)`),
|
|
|
+ open_folder_command: `explorer "${downloadDir}"`,
|
|
|
+});
|
|
|
+const deliveryItems = (results, acceptedOnly = true) => results.flatMap((entry) => {
|
|
|
+ if (acceptedOnly && entry.status !== "complete") return [];
|
|
|
+ const files = entry.local_images ?? [];
|
|
|
+ return files.map((file, index) => ({
|
|
|
+ slot_id: entry.slot_id,
|
|
|
+ file,
|
|
|
+ label: files.length === 1 ? entry.slot_id : `${entry.slot_id}-${index + 1}`,
|
|
|
+ }));
|
|
|
+});
|
|
|
+
|
|
|
+function imageExtension(url, contentType) {
|
|
|
+ const fromUrl = extname(new URL(url).pathname).toLowerCase();
|
|
|
+ if ([".jpg", ".jpeg", ".png", ".webp"].includes(fromUrl)) return fromUrl;
|
|
|
+ if (contentType?.includes("webp")) return ".webp";
|
|
|
+ if (contentType?.includes("jpeg")) return ".jpg";
|
|
|
+ return ".png";
|
|
|
+}
|
|
|
+
|
|
|
+const localRoleNames = {
|
|
|
+ hero: "白底主图",
|
|
|
+ scene: "场景图",
|
|
|
+ selling_point: "卖点图",
|
|
|
+ decision_support: "购买决策图",
|
|
|
+};
|
|
|
+function localImageStem(slotId) {
|
|
|
+ const image = plan.images.find((item) => item.id === slotId);
|
|
|
+ return `${slotId}-${localRoleNames[image?.business_category] ?? "商品图"}`;
|
|
|
+}
|
|
|
+
|
|
|
+async function downloadImagesForSlot(slotId, imageUrls) {
|
|
|
+ if (!Array.isArray(imageUrls) || imageUrls.length === 0) return [];
|
|
|
+ await mkdir(downloadDir, { recursive: true });
|
|
|
+ const files = [];
|
|
|
+ for (const [index, imageUrl] of imageUrls.entries()) {
|
|
|
+ const response = await fetch(imageUrl, {
|
|
|
+ signal: AbortSignal.timeout(downloadTimeoutSeconds * 1000),
|
|
|
+ });
|
|
|
+ if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
|
|
|
+ const extension = imageExtension(imageUrl, response.headers.get("content-type"));
|
|
|
+ const suffix = imageUrls.length > 1 ? `-${index + 1}` : "";
|
|
|
+ const target = join(downloadDir, `${localImageStem(slotId)}${suffix}${extension}`);
|
|
|
+ await writeFile(target, Buffer.from(await response.arrayBuffer()));
|
|
|
+ files.push(target);
|
|
|
+ }
|
|
|
+ return files;
|
|
|
+}
|
|
|
+
|
|
|
+if (mode === "download-only") {
|
|
|
+ const rawToken = await readConfiguredToken();
|
|
|
+ if (!rawToken) throw new Error("Jimeng credential is required to verify executor state");
|
|
|
+ const prior = JSON.parse((await readFile(resultOutputFile, "utf8")).replace(/^\uFEFF/, ""));
|
|
|
+ verifyState(prior, rawToken);
|
|
|
+ let downloaded = 0;
|
|
|
+ let failed = 0;
|
|
|
+ for (const entry of prior.results ?? []) {
|
|
|
+ if (!Array.isArray(entry.images) || entry.images.length === 0) continue;
|
|
|
+ try {
|
|
|
+ entry.local_images = await downloadImagesForSlot(entry.slot_id, entry.images);
|
|
|
+ delete entry.download_error;
|
|
|
+ downloaded += entry.local_images.length;
|
|
|
+ } catch (error) {
|
|
|
+ entry.download_error = error.message;
|
|
|
+ failed += 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ await writeFile(resultOutputFile, `${JSON.stringify(signState(prior, rawToken), null, 2)}\n`, "utf8");
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ mode,
|
|
|
+ output: resultOutputFile,
|
|
|
+ ...deliveryMetadata(deliveryItems(prior.results ?? [])),
|
|
|
+ downloaded,
|
|
|
+ failed,
|
|
|
+ }, null, 2));
|
|
|
+} else {
|
|
|
+async function existingFile(candidate) {
|
|
|
+ try {
|
|
|
+ const fileStat = await stat(candidate);
|
|
|
+ return fileStat.isFile();
|
|
|
+ } catch {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function resolveReference(reference) {
|
|
|
+ if (typeof reference !== "string" || !reference.trim()) throw new Error("Reference image entries must be non-empty strings");
|
|
|
+ const value = reference.trim();
|
|
|
+ if (publicUrlPattern.test(value)) return { kind: "public", source: value, url: value };
|
|
|
+
|
|
|
+ const candidates = isAbsolute(value)
|
|
|
+ ? [resolve(value)]
|
|
|
+ : [resolve(process.cwd(), value), resolve(dirname(planFile), value)];
|
|
|
+ for (const candidate of [...new Set(candidates)]) {
|
|
|
+ if (await existingFile(candidate)) return { kind: "local", source: candidate };
|
|
|
+ }
|
|
|
+ throw new Error(`Reference image was not found locally and is not a public URL: ${value}`);
|
|
|
+}
|
|
|
+
|
|
|
+async function loadAssetMap() {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(await readFile(assetMapFile, "utf8"));
|
|
|
+ return parsed && typeof parsed.assets === "object" ? parsed : { schema_version: "0.1", run_id: plan.run_id, assets: {} };
|
|
|
+ } catch (error) {
|
|
|
+ if (error?.code !== "ENOENT") throw new Error(`Cannot read asset map: ${error.message}`);
|
|
|
+ return { schema_version: "0.1", run_id: plan.run_id, assets: {} };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveAssetMap(assetMap) {
|
|
|
+ await writeFile(assetMapFile, `${JSON.stringify(assetMap, null, 2)}\n`, "utf8");
|
|
|
+}
|
|
|
+
|
|
|
+const referenceSets = [];
|
|
|
+const uniqueReferences = new Map();
|
|
|
+for (const image of plan.images) {
|
|
|
+ const rawReferences = [...new Set([...(image.reference_images ?? []), ...(plan.request?.reference_images ?? [])])];
|
|
|
+ const resolvedReferences = [];
|
|
|
+ for (const rawReference of rawReferences) {
|
|
|
+ const reference = await resolveReference(rawReference);
|
|
|
+ const key = reference.kind === "public" ? reference.url : reference.source;
|
|
|
+ if (!uniqueReferences.has(key)) uniqueReferences.set(key, reference);
|
|
|
+ resolvedReferences.push(uniqueReferences.get(key));
|
|
|
+ }
|
|
|
+ referenceSets.push(resolvedReferences);
|
|
|
+}
|
|
|
+
|
|
|
+const assetMap = await loadAssetMap();
|
|
|
+if (visionResult?.reference?.kind === "local" && visionResult.reference.source && visionResult.reference.public_url) {
|
|
|
+ const source = resolve(visionResult.reference.source);
|
|
|
+ assetMap.assets[source] = {
|
|
|
+ ...(visionResult.reference.asset ?? {}),
|
|
|
+ source,
|
|
|
+ url: visionResult.reference.public_url,
|
|
|
+ };
|
|
|
+ await saveAssetMap(assetMap);
|
|
|
+}
|
|
|
+const assetUploads = [];
|
|
|
+const urlBySource = new Map();
|
|
|
+for (const reference of uniqueReferences.values()) {
|
|
|
+ if (reference.kind === "public") {
|
|
|
+ urlBySource.set(reference.source, reference.url);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const cached = assetMap.assets[reference.source];
|
|
|
+ if (cached && publicUrlPattern.test(cached.url ?? "")) {
|
|
|
+ urlBySource.set(reference.source, cached.url);
|
|
|
+ assetUploads.push({ ...cached, reused: true });
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function prepareLocalAssets() {
|
|
|
+ for (const reference of uniqueReferences.values()) {
|
|
|
+ if (reference.kind !== "local" || urlBySource.has(reference.source)) continue;
|
|
|
+ const uploaded = await uploadLocalImage(reference.source);
|
|
|
+ const safeRecord = { ...uploaded, reused: false };
|
|
|
+ assetMap.assets[reference.source] = uploaded;
|
|
|
+ urlBySource.set(reference.source, uploaded.url);
|
|
|
+ assetUploads.push(safeRecord);
|
|
|
+ await saveAssetMap(assetMap);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildTasks() {
|
|
|
+ return plan.images.map((image, index) => {
|
|
|
+ const references = referenceSets[index];
|
|
|
+ const imageUrls = [...new Set(references.map((reference) => urlBySource.get(reference.source)).filter(Boolean))];
|
|
|
+ const pendingLocal = references.some((reference) => reference.kind === "local" && !urlBySource.has(reference.source));
|
|
|
+ const negative = image.negative_prompt?.trim();
|
|
|
+ const prompt = negative ? `${image.generation_prompt}\n禁止:${negative}` : image.generation_prompt;
|
|
|
+ if ([...prompt].length > 800) throw new Error(`Slot ${image.id} final prompt exceeds 800 characters`);
|
|
|
+ return {
|
|
|
+ slot_id: image.id,
|
|
|
+ endpoint: "https://server.fmode.cn/api/volcengine/jimeng/getImgV4",
|
|
|
+ router_name: "getImgV4",
|
|
|
+ reference_status: imageUrls.length ? "ready" : pendingLocal ? "local_path_pending_qiniu_upload" : "missing_reference_image",
|
|
|
+ local_reference_images: references.filter((reference) => reference.kind === "local").map((reference) => reference.source),
|
|
|
+ payload: {
|
|
|
+ prompt,
|
|
|
+ image_urls: imageUrls,
|
|
|
+ sizeDate: { width: image.model_parameters.width, height: image.model_parameters.height },
|
|
|
+ scale: image.model_parameters.scale,
|
|
|
+ force_single: true,
|
|
|
+ client_request_id: `${plan.run_id}:${image.id}`,
|
|
|
+ },
|
|
|
+ estimated_cost_cny: costPerImage,
|
|
|
+ };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+if (mode === "dry-run") {
|
|
|
+ console.log(JSON.stringify({ mode, run_id: plan.run_id, estimated_cost_cny: estimatedCost, will_upload_local_assets: true, tasks: buildTasks() }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+}
|
|
|
+
|
|
|
+let token;
|
|
|
+let stateSecret;
|
|
|
+if (mode === "execute") {
|
|
|
+ const maxCost = Number(option("--max-cost", "0"));
|
|
|
+ if (!Number.isFinite(maxCost) || maxCost < estimatedCost) throw new Error(`--max-cost must be at least ${estimatedCost}`);
|
|
|
+ const rawToken = await readConfiguredToken();
|
|
|
+ if (!rawToken) throw new Error("Jimeng credential is missing; set JIMENG_TOKEN or configure ~/.openclaw/voc-credentials.json");
|
|
|
+ stateSecret = rawToken;
|
|
|
+ token = rawToken.startsWith("Bearer ") ? rawToken : `Bearer ${rawToken}`;
|
|
|
+}
|
|
|
+
|
|
|
+await prepareLocalAssets();
|
|
|
+const tasks = buildTasks();
|
|
|
+if (tasks.some((task) => task.payload.image_urls.length === 0)) {
|
|
|
+ throw new Error("All seven slots require at least one product reference image; local uploads must succeed before Jimeng is called");
|
|
|
+}
|
|
|
+
|
|
|
+if (mode === "prepare-assets") {
|
|
|
+ const summary = {
|
|
|
+ mode,
|
|
|
+ run_id: plan.run_id,
|
|
|
+ asset_map: assetMapFile,
|
|
|
+ unique_local_assets: [...uniqueReferences.values()].filter((reference) => reference.kind === "local").length,
|
|
|
+ uploaded: assetUploads.filter((item) => !item.reused).length,
|
|
|
+ reused: assetUploads.filter((item) => item.reused).length,
|
|
|
+ assets: assetUploads,
|
|
|
+ jimeng_called: false,
|
|
|
+ };
|
|
|
+ const outputFile = option("--output", "");
|
|
|
+ if (outputFile) await writeFile(resolve(outputFile), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
|
|
+ console.log(JSON.stringify(summary, null, 2));
|
|
|
+}
|
|
|
+
|
|
|
+if (mode === "execute") {
|
|
|
+ const sleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
|
+ const pollSeconds = Math.max(0.05, Number(option("--poll-seconds", "4")) || 4);
|
|
|
+ const timeoutSeconds = Math.max(0.1, Number(option("--timeout-seconds", "240")) || 240);
|
|
|
+ const safeRequestAttempts = Math.max(1, Number(option("--request-attempts", "5")) || 5);
|
|
|
+ const submitResponseAttempts = Math.max(1, Number(option("--submit-attempts", "3")) || 3);
|
|
|
+ const unknownCooldownSeconds = Math.max(0, Number(option("--unknown-cooldown-seconds", "30")) || 0);
|
|
|
+ const preflightAttempts = Math.max(1, Number(option("--preflight-attempts", "5")) || 5);
|
|
|
+ const outputFile = resolve(option("--output", `jimeng-result-${plan.run_id}.json`));
|
|
|
+ const submitUrl = process.env.JIMENG_SUBMIT_URL || "https://server.fmode.cn/api/volcengine/jimeng/getImgV4";
|
|
|
+ const queryUrl = process.env.JIMENG_QUERY_URL || "https://server.fmode.cn/api/volcengine/jimeng/getDataByTask02";
|
|
|
+ const resultBaseUrl = process.env.JIMENG_RESULT_BASE_URL || "https://server.fmode.cn/parse/classes/ImagineWork";
|
|
|
+ const preflightUrl = option(
|
|
|
+ "--preflight-url",
|
|
|
+ process.env.JIMENG_PREFLIGHT_URL
|
|
|
+ || (new URL(submitUrl).hostname === "server.fmode.cn"
|
|
|
+ ? `${new URL(submitUrl).origin}/parse/classes/ImagineWork/connection-preflight`
|
|
|
+ : ""),
|
|
|
+ );
|
|
|
+ const qualityConfig = await loadVisionConfig();
|
|
|
+ if (!qualityConfig.apiKey) {
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ status: "blocked",
|
|
|
+ jimeng_called: false,
|
|
|
+ error: {
|
|
|
+ code: "QUALITY_REVIEW_NOT_CONFIGURED",
|
|
|
+ message: "The configured vision provider is required for generated-product identity review",
|
|
|
+ },
|
|
|
+ }, null, 2));
|
|
|
+ process.exit(0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const describeError = (error) => [error?.message, error?.cause?.code].filter(Boolean).join(" ") || "unknown error";
|
|
|
+
|
|
|
+ async function readJsonResponse(response) {
|
|
|
+ const text = await response.text();
|
|
|
+ let result;
|
|
|
+ try {
|
|
|
+ result = text ? JSON.parse(text) : {};
|
|
|
+ } catch {
|
|
|
+ result = {};
|
|
|
+ }
|
|
|
+ if (!response.ok || (result.code !== undefined && result.code !== 200)) {
|
|
|
+ const error = new Error(result.msg ?? result.message ?? `HTTP ${response.status}`);
|
|
|
+ error.status = response.status;
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function postJson(url, body) {
|
|
|
+ const response = await fetch(url, {
|
|
|
+ method: "POST",
|
|
|
+ headers: { "Content-Type": "application/json" },
|
|
|
+ body: JSON.stringify(body),
|
|
|
+ signal: AbortSignal.timeout(30_000),
|
|
|
+ });
|
|
|
+ return readJsonResponse(response);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function getResult(workId) {
|
|
|
+ const response = await fetch(`${resultBaseUrl}/${encodeURIComponent(workId)}`, {
|
|
|
+ headers: { "X-Parse-Application-Id": "ncloudmaster" },
|
|
|
+ signal: AbortSignal.timeout(30_000),
|
|
|
+ });
|
|
|
+ return readJsonResponse(response);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function retrySafe(operation) {
|
|
|
+ let lastError;
|
|
|
+ for (let attempt = 1; attempt <= safeRequestAttempts; attempt += 1) {
|
|
|
+ try {
|
|
|
+ return await operation();
|
|
|
+ } catch (error) {
|
|
|
+ lastError = error;
|
|
|
+ if (attempt < safeRequestAttempts) await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw lastError;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadRunState() {
|
|
|
+ let existing;
|
|
|
+ try {
|
|
|
+ existing = JSON.parse((await readFile(outputFile, "utf8")).replace(/^\uFEFF/, ""));
|
|
|
+ verifyState(existing, stateSecret);
|
|
|
+ if (existing.run_id !== plan.run_id) throw new Error("RUN_STATE_PLAN_MISMATCH: run_id changed");
|
|
|
+ if (existing.plan_sha256 !== sha256(planText)) throw new Error("RUN_STATE_PLAN_MISMATCH: prompt-plan.json changed after execution started");
|
|
|
+ if (existing.vision_sha256 !== sha256(visionResultText)) throw new Error("RUN_STATE_VISION_MISMATCH: vision-result.json changed after execution started");
|
|
|
+ } catch (error) {
|
|
|
+ if (error?.code !== "ENOENT") throw new Error(`Cannot read run state: ${error.message}`);
|
|
|
+ }
|
|
|
+ const previous = existing?.run_id === plan.run_id && Array.isArray(existing.results)
|
|
|
+ ? new Map(existing.results.map((item) => [item.slot_id, item]))
|
|
|
+ : new Map();
|
|
|
+ return {
|
|
|
+ schema_version: "0.3",
|
|
|
+ run_id: plan.run_id,
|
|
|
+ adapter: "jimeng_img_v4",
|
|
|
+ status: "running",
|
|
|
+ estimated_cost_cny: estimatedCost,
|
|
|
+ asset_uploads: assetUploads,
|
|
|
+ vision_result: visionResultFile,
|
|
|
+ plan_sha256: sha256(planText),
|
|
|
+ vision_sha256: sha256(visionResultText),
|
|
|
+ started_at: existing?.started_at || new Date().toISOString(),
|
|
|
+ updated_at: new Date().toISOString(),
|
|
|
+ issues: Array.isArray(existing?.issues) ? existing.issues : [],
|
|
|
+ results: tasks.map((task) => ({
|
|
|
+ slot_id: task.slot_id,
|
|
|
+ client_request_id: `${plan.run_id}:${task.slot_id}`,
|
|
|
+ status: "not_submitted",
|
|
|
+ work_id: null,
|
|
|
+ images: [],
|
|
|
+ cost_cny: task.estimated_cost_cny,
|
|
|
+ error: null,
|
|
|
+ ...(previous.get(task.slot_id) ?? {}),
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const runState = await loadRunState();
|
|
|
+ const entryBySlot = new Map(runState.results.map((item) => [item.slot_id, item]));
|
|
|
+ async function saveRunState() {
|
|
|
+ runState.updated_at = new Date().toISOString();
|
|
|
+ const signed = signState(runState, stateSecret);
|
|
|
+ runState.state_integrity = signed.state_integrity;
|
|
|
+ await writeFile(outputFile, `${JSON.stringify(signed, null, 2)}\n`, "utf8");
|
|
|
+ }
|
|
|
+ async function materializeCompletedImages(entry) {
|
|
|
+ if (!Array.isArray(entry.images) || entry.images.length === 0 || entry.local_images?.length === entry.images.length) return;
|
|
|
+ entry.local_images = await retrySafe(() => downloadImagesForSlot(entry.slot_id, entry.images));
|
|
|
+ delete entry.download_error;
|
|
|
+ }
|
|
|
+ await saveRunState();
|
|
|
+
|
|
|
+ async function recordIssue(entry, status, error) {
|
|
|
+ entry.status = status;
|
|
|
+ entry.error = describeError(error);
|
|
|
+ const issue = { slot_id: entry.slot_id, code: status, message: entry.error, at: new Date().toISOString() };
|
|
|
+ runState.issues.push(issue);
|
|
|
+ await saveRunState();
|
|
|
+ console.error(`[${entry.slot_id}] ${status}: ${entry.error}`);
|
|
|
+ return issue;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isRetryableSubmit(error) {
|
|
|
+ return error?.status === 429 || (error?.status >= 500 && error?.status <= 599);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function ensureSubmitTransportReady(entry) {
|
|
|
+ if (!preflightUrl) return true;
|
|
|
+ let lastError;
|
|
|
+ for (let attempt = 1; attempt <= preflightAttempts; attempt += 1) {
|
|
|
+ entry.preflight_attempts = (entry.preflight_attempts ?? 0) + 1;
|
|
|
+ await saveRunState();
|
|
|
+ try {
|
|
|
+ const response = await fetch(preflightUrl, {
|
|
|
+ method: "GET",
|
|
|
+ headers: { "X-Parse-Application-Id": "ncloudmaster" },
|
|
|
+ signal: AbortSignal.timeout(15_000),
|
|
|
+ });
|
|
|
+ await response.arrayBuffer();
|
|
|
+ entry.last_preflight_at = new Date().toISOString();
|
|
|
+ delete entry.preflight_error;
|
|
|
+ await saveRunState();
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ lastError = error;
|
|
|
+ entry.preflight_error = describeError(error);
|
|
|
+ await saveRunState();
|
|
|
+ if (attempt < preflightAttempts) {
|
|
|
+ await sleep(Math.min(8_000, 500 * (2 ** (attempt - 1))));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ await recordIssue(entry, "transport_unavailable", lastError ?? new Error("Jimeng transport preflight failed"));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function submitOne(task, entry) {
|
|
|
+ if (!(await ensureSubmitTransportReady(entry))) return false;
|
|
|
+ for (let attempt = 1; attempt <= submitResponseAttempts; attempt += 1) {
|
|
|
+ entry.submit_attempts = (entry.submit_attempts ?? 0) + 1;
|
|
|
+ await saveRunState();
|
|
|
+ try {
|
|
|
+ const submitted = await postJson(submitUrl, { ...task.payload, token });
|
|
|
+ const workId = submitted.data?.workId;
|
|
|
+ if (!workId) throw new Error(`Slot ${task.slot_id} returned no workId; submission acceptance is unknown`);
|
|
|
+ entry.work_id = workId;
|
|
|
+ entry.status = "submitted";
|
|
|
+ entry.error = null;
|
|
|
+ await saveRunState();
|
|
|
+ console.error(`[${task.slot_id}] submitted workId=${workId}`);
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ if (!error?.status) {
|
|
|
+ entry.uncertain_submit_attempts = (entry.uncertain_submit_attempts ?? 0) + 1;
|
|
|
+ await saveRunState();
|
|
|
+ await recordIssue(entry, "submission_unknown", error);
|
|
|
+ if (unknownCooldownSeconds > 0) {
|
|
|
+ console.error(`[${entry.slot_id}] acceptance is unknown; waiting ${unknownCooldownSeconds}s before skipping this slot`);
|
|
|
+ await sleep(unknownCooldownSeconds * 1000);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!isRetryableSubmit(error) || attempt === submitResponseAttempts) {
|
|
|
+ await recordIssue(entry, "submit_failed", error);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ console.error(`[${task.slot_id}] submit attempt ${attempt} failed with HTTP ${error.status}; retrying`);
|
|
|
+ await sleep(Math.min(8_000, 1_000 * (2 ** (attempt - 1))));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function waitAndDownload(entry) {
|
|
|
+ const deadline = Date.now() + timeoutSeconds * 1000;
|
|
|
+ let transientFailures = 0;
|
|
|
+ while (Date.now() < deadline) {
|
|
|
+ try {
|
|
|
+ const direct = await retrySafe(() => getResult(entry.work_id));
|
|
|
+ if (Array.isArray(direct.images) && direct.images.length > 0) {
|
|
|
+ entry.images = direct.images;
|
|
|
+ entry.status = "downloading";
|
|
|
+ entry.error = null;
|
|
|
+ await saveRunState();
|
|
|
+ try {
|
|
|
+ await materializeCompletedImages(entry);
|
|
|
+ } catch (error) {
|
|
|
+ await recordIssue(entry, "download_failed", error);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ entry.status = "quality_review";
|
|
|
+ await saveRunState();
|
|
|
+ try {
|
|
|
+ const reviews = [];
|
|
|
+ for (const generatedUrl of entry.images) {
|
|
|
+ reviews.push(await retrySafe(() => reviewGeneratedProduct({
|
|
|
+ referenceUrl: visionResult.reference.public_url,
|
|
|
+ generatedUrl,
|
|
|
+ identityManifest: visionResult.product_understanding.identity_manifest,
|
|
|
+ config: qualityConfig,
|
|
|
+ })));
|
|
|
+ }
|
|
|
+ entry.quality_review = {
|
|
|
+ accepted: reviews.every((review) => review.accepted),
|
|
|
+ reviews,
|
|
|
+ reviewed_at: new Date().toISOString(),
|
|
|
+ };
|
|
|
+ if (!entry.quality_review.accepted) {
|
|
|
+ const violations = reviews.flatMap((review) => review.critical_violations ?? []);
|
|
|
+ await recordIssue(entry, "quality_rejected", new Error(
|
|
|
+ violations.map((item) => `${item.code}: ${item.detail}`).join("; ") || "Generated product no longer matches the reference",
|
|
|
+ ));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ await recordIssue(entry, "quality_review_failed", error);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ entry.status = "complete";
|
|
|
+ await saveRunState();
|
|
|
+ console.error(`[${entry.slot_id}] complete and downloaded`);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ const state = await retrySafe(() => postJson(queryUrl, {
|
|
|
+ workId: entry.work_id,
|
|
|
+ routerName: "getImgV4",
|
|
|
+ token,
|
|
|
+ }));
|
|
|
+ entry.status = "submitted";
|
|
|
+ entry.error = null;
|
|
|
+ transientFailures = 0;
|
|
|
+ await saveRunState();
|
|
|
+ console.error(`[${entry.slot_id}] ${state.data?.tip ?? "pending"}`);
|
|
|
+ } catch (error) {
|
|
|
+ transientFailures += 1;
|
|
|
+ entry.transient_query_failures = (entry.transient_query_failures ?? 0) + 1;
|
|
|
+ entry.last_transient_error = describeError(error);
|
|
|
+ await saveRunState();
|
|
|
+ const delay = Math.min(15_000, 1_000 * (2 ** Math.min(transientFailures - 1, 4)));
|
|
|
+ console.error(`[${entry.slot_id}] transient query failure; retrying within slot timeout: ${entry.last_transient_error}`);
|
|
|
+ await sleep(delay);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ await sleep(pollSeconds * 1000);
|
|
|
+ }
|
|
|
+ await recordIssue(entry, "poll_timeout", new Error(`Slot ${entry.slot_id} did not complete within ${timeoutSeconds}s; workId preserved`));
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Normal path is strictly serial: submit, wait, download, then move to the next slot.
|
|
|
+ // A failed/unknown/timed-out slot is isolated and skipped so later slots can still finish.
|
|
|
+ // Unknown submissions are never resubmitted because the remote service has no idempotency key.
|
|
|
+ for (const task of tasks) {
|
|
|
+ const entry = entryBySlot.get(task.slot_id);
|
|
|
+ if (entry.status === "complete" && entry.quality_review?.accepted) {
|
|
|
+ try {
|
|
|
+ await materializeCompletedImages(entry);
|
|
|
+ await saveRunState();
|
|
|
+ } catch (error) {
|
|
|
+ await recordIssue(entry, "download_failed", error);
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (entry.status === "submission_unknown" || entry.status === "quality_rejected") {
|
|
|
+ console.error(entry.status === "submission_unknown"
|
|
|
+ ? `[${entry.slot_id}] skipped: prior submission acceptance is unknown and will not be resubmitted`
|
|
|
+ : `[${entry.slot_id}] skipped: generated image failed product-identity review and is not auto-regenerated`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (!entry.work_id && !(await submitOne(task, entry))) continue;
|
|
|
+ await waitAndDownload(entry);
|
|
|
+ }
|
|
|
+
|
|
|
+ const completed = runState.results.filter((item) => item.status === "complete").length;
|
|
|
+ const pending = runState.results.filter((item) => item.work_id
|
|
|
+ && !["complete", "quality_rejected"].includes(item.status)).length;
|
|
|
+ const failedStatuses = new Set([
|
|
|
+ "submission_unknown",
|
|
|
+ "submit_failed",
|
|
|
+ "download_failed",
|
|
|
+ "poll_timeout",
|
|
|
+ "quality_review_failed",
|
|
|
+ "quality_rejected",
|
|
|
+ "transport_unavailable",
|
|
|
+ ]);
|
|
|
+ const failedEntries = runState.results.filter((item) => failedStatuses.has(item.status));
|
|
|
+ const failed = failedEntries.length;
|
|
|
+ const unknownSlots = runState.results.filter((item) => item.status === "submission_unknown").map((item) => item.slot_id);
|
|
|
+ const resumableSlots = runState.results
|
|
|
+ .filter((item) => !["complete", "submission_unknown", "quality_rejected"].includes(item.status))
|
|
|
+ .map((item) => item.slot_id);
|
|
|
+ const rejectedSlots = runState.results
|
|
|
+ .filter((item) => item.status === "quality_rejected")
|
|
|
+ .map((item) => item.slot_id);
|
|
|
+ const confirmedSubmitted = runState.results.filter((item) => item.work_id).length;
|
|
|
+ const potentialUnknown = unknownSlots.length;
|
|
|
+ const confirmedMinimumCost = Number((confirmedSubmitted * costPerImage).toFixed(2));
|
|
|
+ const potentialAdditionalCost = Number((potentialUnknown * costPerImage).toFixed(2));
|
|
|
+ const costRange = {
|
|
|
+ min: confirmedMinimumCost,
|
|
|
+ max: Number((confirmedMinimumCost + potentialAdditionalCost).toFixed(2)),
|
|
|
+ };
|
|
|
+ runState.status = completed === 7 ? "complete" : "partial";
|
|
|
+ runState.summary = {
|
|
|
+ completed,
|
|
|
+ failed,
|
|
|
+ unknown_slots: unknownSlots,
|
|
|
+ rejected_slots: rejectedSlots,
|
|
|
+ resumable_slots: resumableSlots,
|
|
|
+ cost_range_cny: costRange,
|
|
|
+ };
|
|
|
+ await saveRunState();
|
|
|
+ const resumable = resumableSlots.length > 0;
|
|
|
+ const nextAction = runState.status === "complete"
|
|
|
+ ? "deliver_local_images"
|
|
|
+ : resumable
|
|
|
+ ? "deliver_partial_then_resume_same_plan_vision_and_output"
|
|
|
+ : rejectedSlots.length
|
|
|
+ ? "deliver_accepted_images_and_report_quality_rejections_without_auto_regeneration"
|
|
|
+ : "deliver_partial_and_report_unknown_do_not_resubmit";
|
|
|
+ const completedItems = deliveryItems(runState.results);
|
|
|
+ const rejectedItems = deliveryItems(
|
|
|
+ runState.results.filter((item) => item.status === "quality_rejected"),
|
|
|
+ false,
|
|
|
+ );
|
|
|
+ console.log(JSON.stringify({
|
|
|
+ output: outputFile,
|
|
|
+ ...deliveryMetadata(completedItems),
|
|
|
+ status: runState.status,
|
|
|
+ completed,
|
|
|
+ pending,
|
|
|
+ failed,
|
|
|
+ failed_slots: failedEntries.map((item) => ({ slot_id: item.slot_id, code: item.status, message: item.error })),
|
|
|
+ unknown_slots: unknownSlots,
|
|
|
+ rejected_slots: rejectedSlots,
|
|
|
+ rejected_local_images: rejectedItems.map((item) => item.file),
|
|
|
+ resumable_slots: resumableSlots,
|
|
|
+ resumable,
|
|
|
+ confirmed_minimum_cost_cny: confirmedMinimumCost,
|
|
|
+ potential_additional_cost_cny: potentialAdditionalCost,
|
|
|
+ cost_range_cny: costRange,
|
|
|
+ next_action: nextAction,
|
|
|
+ }, null, 2));
|
|
|
+}
|
|
|
+}
|