backup: preserve server changes before OpenClaw deploy
This commit is contained in:
parent
2f00645cd4
commit
7708b66e8d
@ -1633,7 +1633,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
|||||||
midjourney: "https://your-midjourney-api.example.com",
|
midjourney: "https://your-midjourney-api.example.com",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
"worldlabs-3d": "https://api.vip98k.com",
|
||||||
"kittycad-cad": "https://api.zoo.dev",
|
"kittycad-cad": "https://api.zoo.dev",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
krea: "https://api.krea.ai",
|
krea: "https://api.krea.ai",
|
||||||
|
|||||||
@ -5745,6 +5745,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
const assistantMessageId = generateUUID();
|
const assistantMessageId = generateUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
await saveMessages({
|
await saveMessages({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@ -5783,6 +5784,14 @@ export async function POST(request: Request) {
|
|||||||
imageSize: persistedImageSize,
|
imageSize: persistedImageSize,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} catch (persistError) {
|
||||||
|
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
|
||||||
|
chatId: id,
|
||||||
|
referenceId: params.reference.id,
|
||||||
|
referenceLabel: params.reference.label,
|
||||||
|
error: persistError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
@ -5810,6 +5819,7 @@ export async function POST(request: Request) {
|
|||||||
Array.from(replacementReferences.entries()),
|
Array.from(replacementReferences.entries()),
|
||||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
||||||
async ([referenceIndex, reference]) => {
|
async ([referenceIndex, reference]) => {
|
||||||
|
const itemStartedAt = Date.now();
|
||||||
if (reference.passthrough === true) {
|
if (reference.passthrough === true) {
|
||||||
const referenceSourceImageUrls = Array.from(
|
const referenceSourceImageUrls = Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
@ -5830,9 +5840,29 @@ export async function POST(request: Request) {
|
|||||||
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
|
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
|
||||||
|
referenceIndex: referenceIndex + 1,
|
||||||
|
totalReferences: replacementReferences.length,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fallbackSourceImageUrls = Array.from(
|
||||||
|
new Set([
|
||||||
|
...productInputImageUrls,
|
||||||
|
...(modelInputImageUrl ? [modelInputImageUrl] : []),
|
||||||
|
...(reference.rerunReferenceImages || [])
|
||||||
|
.map((image) => String(image.url || "").trim())
|
||||||
|
.filter((url) => /^https?:\/\//i.test(url))
|
||||||
|
.slice(0, 6),
|
||||||
|
reference.imageUrl,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const planningStartedAt = Date.now();
|
||||||
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
|
||||||
modeType: "detail_replacement",
|
modeType: "detail_replacement",
|
||||||
replacementStrategy: detailReplacementStrategy,
|
replacementStrategy: detailReplacementStrategy,
|
||||||
@ -5847,10 +5877,12 @@ export async function POST(request: Request) {
|
|||||||
planningRuntime: replacementScenePlanningRuntime,
|
planningRuntime: replacementScenePlanningRuntime,
|
||||||
models: replacementVisionModels,
|
models: replacementVisionModels,
|
||||||
});
|
});
|
||||||
|
const planningElapsedMs = Date.now() - planningStartedAt;
|
||||||
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
||||||
referenceIndex: referenceIndex + 1,
|
referenceIndex: referenceIndex + 1,
|
||||||
totalReferences: replacementReferences.length,
|
totalReferences: replacementReferences.length,
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
|
planningElapsedMs,
|
||||||
shouldReplace: replacementExecutionPlan.shouldReplace,
|
shouldReplace: replacementExecutionPlan.shouldReplace,
|
||||||
reason: replacementExecutionPlan.reason,
|
reason: replacementExecutionPlan.reason,
|
||||||
detectionSource: replacementExecutionPlan.detectionSource,
|
detectionSource: replacementExecutionPlan.detectionSource,
|
||||||
@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
|
|||||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let generationStartedAt = 0;
|
||||||
|
let generationElapsedMs: number | null = null;
|
||||||
|
let normalizeStartedAt = 0;
|
||||||
|
let normalizeElapsedMs: number | null = null;
|
||||||
try {
|
try {
|
||||||
|
generationStartedAt = Date.now();
|
||||||
const result = await generateCanvasImage({
|
const result = await generateCanvasImage({
|
||||||
prompt: replacementPrompt,
|
prompt: replacementPrompt,
|
||||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||||
@ -6000,6 +6037,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
generationElapsedMs = Date.now() - generationStartedAt;
|
||||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||||
const firstImage = result.images[0];
|
const firstImage = result.images[0];
|
||||||
if (!firstImage) {
|
if (!firstImage) {
|
||||||
@ -6009,6 +6047,7 @@ export async function POST(request: Request) {
|
|||||||
let finalImage = firstImage;
|
let finalImage = firstImage;
|
||||||
let finalImageSizeLabel: string = resolvedImageSize;
|
let finalImageSizeLabel: string = resolvedImageSize;
|
||||||
try {
|
try {
|
||||||
|
normalizeStartedAt = Date.now();
|
||||||
const normalizedImage = await normalizeReplacementResultImage({
|
const normalizedImage = await normalizeReplacementResultImage({
|
||||||
generatedImageUrl: firstImage.originalUrl,
|
generatedImageUrl: firstImage.originalUrl,
|
||||||
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
||||||
@ -6022,7 +6061,11 @@ export async function POST(request: Request) {
|
|||||||
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
||||||
};
|
};
|
||||||
finalImageSizeLabel = normalizedImage.imageSize;
|
finalImageSizeLabel = normalizedImage.imageSize;
|
||||||
|
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||||
} catch (normalizeError) {
|
} catch (normalizeError) {
|
||||||
|
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
|
||||||
|
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||||
|
}
|
||||||
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
referenceImageUrl: reference.imageUrl,
|
referenceImageUrl: reference.imageUrl,
|
||||||
@ -6040,6 +6083,22 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
|
||||||
|
referenceIndex: referenceIndex + 1,
|
||||||
|
totalReferences: replacementReferences.length,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
modelDisplayName: resolvedModelDisplayName,
|
||||||
|
resultImageSize: finalImageSizeLabel,
|
||||||
|
sourceImageCount: sourceImageUrls.length,
|
||||||
|
generationAspectRatio,
|
||||||
|
pageKind: replacementExecutionPlan.scenePlan.pageKind,
|
||||||
|
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
|
||||||
|
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
planningElapsedMs,
|
||||||
|
generationElapsedMs,
|
||||||
|
normalizeElapsedMs,
|
||||||
|
});
|
||||||
await emitReplacementResultMessage({
|
await emitReplacementResultMessage({
|
||||||
reference,
|
reference,
|
||||||
image: finalImage,
|
image: finalImage,
|
||||||
@ -6051,9 +6110,16 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
} catch (replacementItemError) {
|
} catch (replacementItemError) {
|
||||||
|
if (generationStartedAt > 0 && generationElapsedMs === null) {
|
||||||
|
generationElapsedMs = Date.now() - generationStartedAt;
|
||||||
|
}
|
||||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
||||||
referenceIndex,
|
referenceIndex,
|
||||||
referenceLabel: reference.label,
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
planningElapsedMs,
|
||||||
|
generationElapsedMs,
|
||||||
|
normalizeElapsedMs,
|
||||||
error: replacementItemError,
|
error: replacementItemError,
|
||||||
});
|
});
|
||||||
await emitReplacementResultMessage({
|
await emitReplacementResultMessage({
|
||||||
@ -6073,6 +6139,30 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
successCount += 1;
|
successCount += 1;
|
||||||
}
|
}
|
||||||
|
} catch (replacementPlanError) {
|
||||||
|
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
|
||||||
|
referenceIndex,
|
||||||
|
referenceLabel: reference.label,
|
||||||
|
elapsedMs: Date.now() - itemStartedAt,
|
||||||
|
error: replacementPlanError,
|
||||||
|
});
|
||||||
|
await emitReplacementResultMessage({
|
||||||
|
reference,
|
||||||
|
bodyText: `生成失败,当前先沿用参考图:${
|
||||||
|
replacementPlanError instanceof Error
|
||||||
|
? replacementPlanError.message
|
||||||
|
: "请稍后重试"
|
||||||
|
}`,
|
||||||
|
image: {
|
||||||
|
originalUrl: reference.imageUrl,
|
||||||
|
thumbnailUrl: reference.imageUrl,
|
||||||
|
},
|
||||||
|
modelDisplayName: null,
|
||||||
|
sourceImageUrls: fallbackSourceImageUrls,
|
||||||
|
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
|
||||||
|
});
|
||||||
|
successCount += 1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
|
|||||||
@ -83,7 +83,7 @@ const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
|||||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||||
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
|
||||||
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
|
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
|
||||||
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
|
||||||
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||||
const KREA_BASE_URL = "https://api.krea.ai";
|
const KREA_BASE_URL = "https://api.krea.ai";
|
||||||
|
|||||||
@ -192,23 +192,53 @@ export async function PUT(
|
|||||||
})
|
})
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 创建新画布(使用指定 ID)
|
const nextWorkspaceType = workspaceType || "canvas";
|
||||||
const [newProject] = await db
|
const nextCanvasData = syncedCanvasData || {
|
||||||
.insert(canvasProject)
|
|
||||||
.values({
|
|
||||||
id, // 使用指定的 ID
|
|
||||||
userId: session.user.id,
|
|
||||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
|
||||||
description,
|
|
||||||
workspaceType: workspaceType || "canvas",
|
|
||||||
canvasData: syncedCanvasData || {
|
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
viewport: { x: 0, y: 0, zoom: 1 },
|
viewport: { x: 0, y: 0, zoom: 1 },
|
||||||
},
|
};
|
||||||
|
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
|
||||||
|
const insertData: any = {
|
||||||
|
id, // 使用指定的 ID
|
||||||
|
userId: session.user.id,
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
};
|
||||||
|
const conflictUpdateData: any = {
|
||||||
|
title: nextTitle,
|
||||||
|
workspaceType: nextWorkspaceType,
|
||||||
|
canvasData: nextCanvasData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (description !== undefined) {
|
||||||
|
insertData.description = description;
|
||||||
|
conflictUpdateData.description = description;
|
||||||
|
}
|
||||||
|
if (thumbnail !== undefined) {
|
||||||
|
insertData.thumbnail = thumbnail;
|
||||||
|
conflictUpdateData.thumbnail = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
|
||||||
|
const [newProject] = await db
|
||||||
|
.insert(canvasProject)
|
||||||
|
.values(insertData)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: canvasProject.id,
|
||||||
|
set: conflictUpdateData,
|
||||||
|
where: eq(canvasProject.userId, session.user.id),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
if (!newProject) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '画布ID已被占用' },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true, data: newProject });
|
return NextResponse.json({ success: true, data: newProject });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import {
|
||||||
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
|
||||||
import type {
|
import type {
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsExternalTaskResultData,
|
||||||
WorldLabsOperation,
|
WorldLabsOperation,
|
||||||
WorldLabsWorld,
|
WorldLabsWorld,
|
||||||
WorldLabsWorldAssets,
|
WorldLabsWorldAssets,
|
||||||
@ -419,6 +426,214 @@ function buildAssetPayload(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringRecord(value: unknown) {
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
const entries = Object.entries(record).filter(
|
||||||
|
(entry): entry is [string, string] =>
|
||||||
|
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
|
||||||
|
);
|
||||||
|
return entries.length > 0 ? Object.fromEntries(entries) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedString(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): string | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getStringValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedString(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedObject(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = getObjectValue(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedObject(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNestedStringRecord(
|
||||||
|
value: unknown,
|
||||||
|
keys: string[],
|
||||||
|
depth = 0
|
||||||
|
): Record<string, string> | null {
|
||||||
|
if (depth > 8) return null;
|
||||||
|
const record = getObjectValue(value);
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const directValue = readStringRecord(record[key]);
|
||||||
|
if (directValue) return directValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of Object.values(record)) {
|
||||||
|
const found = findNestedStringRecord(child, keys, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalTaskErrorMessage(
|
||||||
|
data: WorldLabsExternalTaskResultData,
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
) {
|
||||||
|
const dataError = getObjectValue(data.error);
|
||||||
|
const payloadError = getObjectValue(payload.error);
|
||||||
|
return (
|
||||||
|
getStringValue(
|
||||||
|
data.errorMessage,
|
||||||
|
data.message,
|
||||||
|
dataError?.message,
|
||||||
|
payload.message,
|
||||||
|
payloadError?.message,
|
||||||
|
payload.error
|
||||||
|
) || "World Labs 外部任务失败"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
|
||||||
|
const root = getObjectValue(data) || {};
|
||||||
|
const resultRoot =
|
||||||
|
getObjectValue(root.result) ||
|
||||||
|
getObjectValue(root.output) ||
|
||||||
|
getObjectValue(root.world) ||
|
||||||
|
root;
|
||||||
|
const assets = findNestedObject(resultRoot, ["assets"]);
|
||||||
|
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
|
||||||
|
const splats = getObjectValue(worldAssets?.splats);
|
||||||
|
|
||||||
|
return {
|
||||||
|
worldId: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["worldId", "world_id"]),
|
||||||
|
findNestedString(root, ["worldId", "world_id"])
|
||||||
|
),
|
||||||
|
worldUrl: getStringValue(
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"worldUrl",
|
||||||
|
"world_marble_url",
|
||||||
|
"sourceWorldUrl",
|
||||||
|
"viewerUrl",
|
||||||
|
"marbleUrl",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
displayName: getStringValue(
|
||||||
|
findNestedString(resultRoot, ["displayName", "display_name"]),
|
||||||
|
findNestedString(root, ["displayName", "display_name"])
|
||||||
|
),
|
||||||
|
model:
|
||||||
|
getStringValue(
|
||||||
|
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
|
||||||
|
findNestedString(root, ["model", "modelId", "model_id"])
|
||||||
|
) || WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
caption: getStringValue(
|
||||||
|
worldAssets?.caption,
|
||||||
|
findNestedString(resultRoot, ["caption"]),
|
||||||
|
findNestedString(root, ["caption"])
|
||||||
|
),
|
||||||
|
thumbnailUrl: getStringValue(
|
||||||
|
worldAssets?.thumbnail_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"thumbnailUrl",
|
||||||
|
"thumbnail_url",
|
||||||
|
"previewImageUrl",
|
||||||
|
"preview_image_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
panoUrl: getStringValue(
|
||||||
|
worldAssets?.imagery?.pano_url,
|
||||||
|
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
|
||||||
|
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
|
||||||
|
),
|
||||||
|
meshUrl: getStringValue(
|
||||||
|
worldAssets?.mesh?.collider_mesh_url,
|
||||||
|
findNestedString(resultRoot, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
]),
|
||||||
|
findNestedString(root, [
|
||||||
|
"meshUrl",
|
||||||
|
"mesh_url",
|
||||||
|
"modelUrl",
|
||||||
|
"model_url",
|
||||||
|
"glbUrl",
|
||||||
|
"glb_url",
|
||||||
|
"colliderMeshUrl",
|
||||||
|
"collider_mesh_url",
|
||||||
|
])
|
||||||
|
),
|
||||||
|
spzUrls:
|
||||||
|
readStringRecord(splats?.spz_urls) ||
|
||||||
|
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
|
||||||
|
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
|
||||||
|
semanticsMetadata:
|
||||||
|
getObjectValue(splats?.semantics_metadata) ||
|
||||||
|
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
|
||||||
|
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ taskId: string }>;
|
params: Promise<{ taskId: string }>;
|
||||||
}
|
}
|
||||||
@ -452,6 +667,187 @@ export async function GET(request: Request, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
providerForLog = config.providerId || null;
|
providerForLog = config.providerId || null;
|
||||||
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
|
||||||
|
const persistedAssetPayload =
|
||||||
|
getObjectValue(persistedWorldAsset?.assetPayload) || {};
|
||||||
|
const shouldUseExternalTaskResultApi =
|
||||||
|
isWorldLabsExternalTaskApi(config.baseUrl) &&
|
||||||
|
persistedAssetPayload.externalTaskApi !== false &&
|
||||||
|
persistedAssetPayload.marbleApi !== true;
|
||||||
|
if (shouldUseExternalTaskResultApi) {
|
||||||
|
const externalPayload = await worldLabsRequest<
|
||||||
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
|
||||||
|
>(config, {
|
||||||
|
method: "GET",
|
||||||
|
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
|
||||||
|
});
|
||||||
|
const externalData =
|
||||||
|
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
|
||||||
|
const externalStatusText = getStringValue(externalData.status) || "running";
|
||||||
|
const externalStatus = normalizeExternalTaskStatus(
|
||||||
|
externalStatusText,
|
||||||
|
externalData.done === true
|
||||||
|
);
|
||||||
|
const externalAssetPayloadBase = {
|
||||||
|
...persistedAssetPayload,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
externalTaskApi: true,
|
||||||
|
externalTaskStatus: externalStatusText,
|
||||||
|
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (externalStatus === "running") {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "running",
|
||||||
|
progress: null,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalStatus === "failed") {
|
||||||
|
const errorMessage = extractExternalTaskErrorMessage(
|
||||||
|
externalData,
|
||||||
|
externalPayload
|
||||||
|
);
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
error: errorMessage,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
});
|
||||||
|
logger.warn("external task failed status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "failed",
|
||||||
|
progress: null,
|
||||||
|
errorMessage,
|
||||||
|
output: externalData,
|
||||||
|
assetPayload: externalAssetPayloadBase,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
|
||||||
|
const rawMeshUrl = extractedAssetPayload.meshUrl;
|
||||||
|
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
|
||||||
|
const rawPanoUrl = extractedAssetPayload.panoUrl;
|
||||||
|
const stableWorldUrl: string | null = null;
|
||||||
|
const stableModelUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
|
||||||
|
sourceUrl: rawMeshUrl,
|
||||||
|
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "glb",
|
||||||
|
validateAs: "glb",
|
||||||
|
});
|
||||||
|
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
|
||||||
|
sourceUrl: rawThumbnailUrl,
|
||||||
|
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
|
||||||
|
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
|
||||||
|
sourceUrl: rawPanoUrl,
|
||||||
|
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
});
|
||||||
|
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
|
||||||
|
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
|
||||||
|
sourceMap: extractedAssetPayload.spzUrls,
|
||||||
|
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
|
||||||
|
fallbackExtension: "spz",
|
||||||
|
validateAs: "spz",
|
||||||
|
});
|
||||||
|
const assetPayload = {
|
||||||
|
...externalAssetPayloadBase,
|
||||||
|
worldId: extractedAssetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
displayName: extractedAssetPayload.displayName,
|
||||||
|
model: resolveThreeDModelAliasId(
|
||||||
|
extractedAssetPayload.model,
|
||||||
|
modelAliasConfig.worldModels
|
||||||
|
),
|
||||||
|
caption: extractedAssetPayload.caption,
|
||||||
|
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
|
||||||
|
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
|
||||||
|
meshUrl: stableModelUrl,
|
||||||
|
spzUrls: stableSpzUrls,
|
||||||
|
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
||||||
|
storageArchivedAt: new Date().toISOString(),
|
||||||
|
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
||||||
|
sourceMeshUrl:
|
||||||
|
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||||
|
? rawMeshUrl
|
||||||
|
: null,
|
||||||
|
sourceThumbnailUrl:
|
||||||
|
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
|
||||||
|
? rawThumbnailUrl
|
||||||
|
: null,
|
||||||
|
sourcePanoUrl:
|
||||||
|
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
|
||||||
|
? rawPanoUrl
|
||||||
|
: null,
|
||||||
|
sourceSpzUrls:
|
||||||
|
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
|
||||||
|
? extractedAssetPayload.spzUrls
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updatePersistedWorldAsset({
|
||||||
|
id: persistedWorldAsset?.id,
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
error: null,
|
||||||
|
assetPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("external task success status", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
taskIdLength: normalizedTaskId.length,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
hasMesh: Boolean(stableModelUrl),
|
||||||
|
hasPano: Boolean(stablePanoUrl),
|
||||||
|
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||||
|
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
taskId: normalizedTaskId,
|
||||||
|
type: "world",
|
||||||
|
status: "success",
|
||||||
|
progress: 100,
|
||||||
|
output: externalData,
|
||||||
|
modelUrl: stableModelUrl,
|
||||||
|
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
|
||||||
|
worldId: assetPayload.worldId,
|
||||||
|
worldUrl: stableWorldUrl,
|
||||||
|
panoUrl: stablePanoUrl,
|
||||||
|
caption: assetPayload.caption,
|
||||||
|
assetPayload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
const operation = await worldLabsRequest<WorldLabsOperation>(config, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||||
|
|||||||
@ -12,13 +12,23 @@ import {
|
|||||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { get3DWorldCreditCost } from "@/lib/points/config";
|
import { get3DWorldCreditCost } from "@/lib/points/config";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import {
|
||||||
|
isWorldLabsExternalTaskApi,
|
||||||
|
WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
reportWorldLabsProviderRuntimeFailure,
|
reportWorldLabsProviderRuntimeFailure,
|
||||||
} from "@/lib/worldlabs/server";
|
} from "@/lib/worldlabs/server";
|
||||||
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types";
|
import type {
|
||||||
|
WorldLabsExternalTaskCreateData,
|
||||||
|
WorldLabsExternalTaskResponse,
|
||||||
|
WorldLabsModel,
|
||||||
|
WorldLabsOperation,
|
||||||
|
} from "@/lib/worldlabs/types";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 180;
|
export const maxDuration = 180;
|
||||||
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
|
|||||||
makePublic?: boolean;
|
makePublic?: boolean;
|
||||||
reconstructImages?: boolean;
|
reconstructImages?: boolean;
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
mediaAssets?: Array<{
|
mediaAssets?: Array<{
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
azimuth?: number | null;
|
azimuth?: number | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -58,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
|
|||||||
return value || null;
|
return value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalHttpUrl(input: unknown) {
|
||||||
|
const value = readOptionalTrimmedText(input);
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === "http:" || parsed.protocol === "https:"
|
||||||
|
? parsed.toString()
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObjectValue(value: unknown) {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
|
||||||
|
const root = getObjectValue(payload);
|
||||||
|
const data = getObjectValue(root?.data);
|
||||||
|
return (data || root || {}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
|
||||||
|
const normalized = String(status || "").trim().toLowerCase();
|
||||||
|
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
|
||||||
|
return "success" as const;
|
||||||
|
}
|
||||||
|
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
|
||||||
|
return "failed" as const;
|
||||||
|
}
|
||||||
|
return "running" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExternalTaskCreateOperation(
|
||||||
|
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
|
): WorldLabsOperation {
|
||||||
|
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
|
||||||
|
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
|
||||||
|
const status = readOptionalTrimmedText(data.status) || "accepted";
|
||||||
|
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
|
||||||
|
return {
|
||||||
|
operation_id: taskId || undefined,
|
||||||
|
done: normalizedStatus === "success",
|
||||||
|
error:
|
||||||
|
normalizedStatus === "failed"
|
||||||
|
? {
|
||||||
|
message:
|
||||||
|
readOptionalTrimmedText(
|
||||||
|
(payload as Record<string, unknown>)?.message ||
|
||||||
|
(payload as Record<string, unknown>)?.error
|
||||||
|
) || "World Labs 外部任务创建失败",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
metadata: {
|
||||||
|
externalTaskApi: true,
|
||||||
|
status,
|
||||||
|
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
|
||||||
|
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
|
||||||
|
},
|
||||||
|
response: data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
function shouldCharge3DPoints(mode: WorldTaskMode) {
|
||||||
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseExternalTaskCreateApi(
|
||||||
|
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
|
||||||
|
mode: WorldTaskMode
|
||||||
|
) {
|
||||||
|
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOfficialMarbleModel(model?: string | null) {
|
||||||
|
const normalized = readOptionalTrimmedText(model);
|
||||||
|
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
|
||||||
|
return "marble-1.1";
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||||
|
|
||||||
@ -80,14 +171,20 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
|
|
||||||
if (mode === "image") {
|
if (mode === "image") {
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("图生世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
type: "image",
|
type: "image",
|
||||||
image_prompt: {
|
image_prompt: mediaAssetId
|
||||||
|
? {
|
||||||
source: "media_asset",
|
source: "media_asset",
|
||||||
media_asset_id: mediaAssetId,
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
},
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
...(body.disableRecaption !== undefined
|
...(body.disableRecaption !== undefined
|
||||||
@ -102,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
const multiImagePrompt = mediaAssets
|
const multiImagePrompt = mediaAssets
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||||
if (!mediaAssetId) return null;
|
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||||
|
if (!mediaAssetId && !sourceUrl) return null;
|
||||||
const azimuth =
|
const azimuth =
|
||||||
item?.azimuth === null || item?.azimuth === undefined
|
item?.azimuth === null || item?.azimuth === undefined
|
||||||
? null
|
? null
|
||||||
@ -111,9 +209,14 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
...(azimuth === null ? {} : { azimuth }),
|
...(azimuth === null ? {} : { azimuth }),
|
||||||
content: {
|
content: mediaAssetId
|
||||||
|
? {
|
||||||
source: "media_asset",
|
source: "media_asset",
|
||||||
media_asset_id: mediaAssetId,
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@ -137,15 +240,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||||
if (!mediaAssetId) {
|
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
if (!mediaAssetId && !sourceUrl) {
|
||||||
|
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "video",
|
type: "video",
|
||||||
video_prompt: {
|
video_prompt: mediaAssetId
|
||||||
|
? {
|
||||||
source: "media_asset",
|
source: "media_asset",
|
||||||
media_asset_id: mediaAssetId,
|
media_asset_id: mediaAssetId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
source: "uri",
|
||||||
|
uri: sourceUrl,
|
||||||
},
|
},
|
||||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||||
};
|
};
|
||||||
@ -187,12 +296,29 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
modelForLog = resolvedWorldModel || "marble-1.1";
|
modelForLog = resolvedWorldModel || "marble-1.1";
|
||||||
|
|
||||||
|
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
||||||
|
const displayName =
|
||||||
|
readOptionalTrimmedText(body.displayName) ||
|
||||||
|
readOptionalTrimmedText(body.prompt) ||
|
||||||
|
`World ${new Date().toLocaleString("zh-CN")}`;
|
||||||
|
const worldPrompt = buildWorldPrompt(body, mode);
|
||||||
|
|
||||||
|
const candidates = preferredProviderId
|
||||||
|
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
||||||
|
: await getWorldLabsProviderConfigs();
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error("未找到可用的 World Labs Key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
|
||||||
|
const predictedBillingModel = candidates.some((candidate) =>
|
||||||
|
shouldUseExternalTaskCreateApi(candidate, mode)
|
||||||
|
)
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
const pointsConfig = await getPointsSystemConfig();
|
const pointsConfig = await getPointsSystemConfig();
|
||||||
const shouldChargePoints = shouldCharge3DPoints(mode);
|
const shouldChargePoints = shouldCharge3DPoints(mode);
|
||||||
const unitCost = get3DWorldCreditCost(
|
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||||
pointsConfig,
|
|
||||||
resolvedWorldModel || "marble-1.1",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldChargePoints &&
|
shouldChargePoints &&
|
||||||
@ -220,42 +346,51 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferredProviderId = readOptionalTrimmedText(body.providerId);
|
|
||||||
const displayName =
|
|
||||||
readOptionalTrimmedText(body.displayName) ||
|
|
||||||
readOptionalTrimmedText(body.prompt) ||
|
|
||||||
`World ${new Date().toLocaleString("zh-CN")}`;
|
|
||||||
const worldPrompt = buildWorldPrompt(body, mode);
|
|
||||||
|
|
||||||
const candidates = preferredProviderId
|
|
||||||
? [await getWorldLabsProviderConfig(preferredProviderId)]
|
|
||||||
: await getWorldLabsProviderConfigs();
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
throw new Error("未找到可用的 World Labs Key");
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: WorldLabsOperation | null = null;
|
let result: WorldLabsOperation | null = null;
|
||||||
let usedProvider = null as Awaited<
|
let usedProvider = null as Awaited<
|
||||||
ReturnType<typeof getWorldLabsProviderConfig>
|
ReturnType<typeof getWorldLabsProviderConfig>
|
||||||
> | null;
|
> | null;
|
||||||
|
let usedExternalTaskApi = false;
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
try {
|
try {
|
||||||
|
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
|
||||||
|
if (useExternalTaskApi) {
|
||||||
|
const prompt = readOptionalTrimmedText(body.prompt);
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error("请输入世界描述");
|
||||||
|
}
|
||||||
|
const externalResult = await worldLabsRequest<
|
||||||
|
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
|
||||||
|
>(candidate, {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/external/task/generate",
|
||||||
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
|
json: {
|
||||||
|
model: WORLDLABS_EXTERNAL_TASK_MODEL,
|
||||||
|
displayName,
|
||||||
|
prompt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result = normalizeExternalTaskCreateOperation(externalResult);
|
||||||
|
} else {
|
||||||
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
result = await worldLabsRequest<WorldLabsOperation>(candidate, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/marble/v1/worlds:generate",
|
path: "/marble/v1/worlds:generate",
|
||||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||||
json: {
|
json: {
|
||||||
display_name: displayName,
|
display_name: displayName,
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
model: officialMarbleModel,
|
||||||
permission: {
|
permission: {
|
||||||
public: body.makePublic === true,
|
public: body.makePublic === true,
|
||||||
},
|
},
|
||||||
world_prompt: worldPrompt,
|
world_prompt: worldPrompt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
usedProvider = candidate;
|
usedProvider = candidate;
|
||||||
|
usedExternalTaskApi = useExternalTaskApi;
|
||||||
providerForLog = candidate.providerId || null;
|
providerForLog = candidate.providerId || null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -292,12 +427,18 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||||
try {
|
try {
|
||||||
|
const usedBillingModel = usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel;
|
||||||
|
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: worldModelDisplayName;
|
||||||
await consumeUserPoints({
|
await consumeUserPoints({
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
amount: unitCost,
|
amount: unitCost,
|
||||||
reason: "world3d_generation",
|
reason: "world3d_generation",
|
||||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("points consume failed", error, {
|
logger.error("points consume failed", error, {
|
||||||
@ -313,10 +454,13 @@ export async function POST(request: Request) {
|
|||||||
elapsedMs: Date.now() - startedAt,
|
elapsedMs: Date.now() - startedAt,
|
||||||
taskIdLength: taskId.length,
|
taskIdLength: taskId.length,
|
||||||
mode,
|
mode,
|
||||||
model: resolvedWorldModel || "marble-1.1",
|
model: usedExternalTaskApi
|
||||||
|
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||||
|
: officialMarbleModel,
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
status: result.done ? "success" : "running",
|
status: result.done ? "success" : "running",
|
||||||
unitCost,
|
unitCost,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@ -327,6 +471,8 @@ export async function POST(request: Request) {
|
|||||||
assetPayload: {
|
assetPayload: {
|
||||||
providerId: usedProvider.providerId || null,
|
providerId: usedProvider.providerId || null,
|
||||||
providerName: usedProvider.providerName || null,
|
providerName: usedProvider.providerName || null,
|
||||||
|
externalTaskApi: usedExternalTaskApi,
|
||||||
|
marbleApi: !usedExternalTaskApi,
|
||||||
},
|
},
|
||||||
raw: result,
|
raw: result,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
import {
|
||||||
|
WorldLabsApiError,
|
||||||
|
worldLabsRequest,
|
||||||
|
} from "@/lib/worldlabs/client";
|
||||||
import {
|
import {
|
||||||
getWorldLabsProviderConfig,
|
getWorldLabsProviderConfig,
|
||||||
getWorldLabsProviderConfigs,
|
getWorldLabsProviderConfigs,
|
||||||
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
|||||||
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOptionalString(value: unknown) {
|
||||||
|
const text = String(value || "").trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUploadInfoFromCurlExample(curlExample: unknown) {
|
||||||
|
const curl = String(curlExample || "");
|
||||||
|
const uploadUrl =
|
||||||
|
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
|
||||||
|
.map((match) => match[0])
|
||||||
|
.find(Boolean) || null;
|
||||||
|
const requiredHeaders: Record<string, string> = {};
|
||||||
|
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
||||||
|
let headerMatch: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((headerMatch = headerPattern.exec(curl))) {
|
||||||
|
const header = String(
|
||||||
|
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
|
||||||
|
).trim();
|
||||||
|
const colonIndex = header.indexOf(":");
|
||||||
|
if (colonIndex <= 0) continue;
|
||||||
|
const name = header.slice(0, colonIndex).trim();
|
||||||
|
const value = header.slice(colonIndex + 1).trim();
|
||||||
|
if (name && value) {
|
||||||
|
requiredHeaders[name] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadUrl, requiredHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
|
||||||
|
const uploadInfo = data.upload_info || {};
|
||||||
|
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
|
||||||
|
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
|
||||||
|
const explicitHeaders =
|
||||||
|
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
|
||||||
|
? uploadInfo.required_headers
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadUrl:
|
||||||
|
readOptionalString(uploadInfo.upload_url) ||
|
||||||
|
readOptionalString(uploadInfoRecord.uploadUrl) ||
|
||||||
|
readOptionalString(uploadInfoRecord.url) ||
|
||||||
|
curlDetails.uploadUrl,
|
||||||
|
uploadMethod:
|
||||||
|
readOptionalString(uploadInfo.upload_method) ||
|
||||||
|
readOptionalString(uploadInfoRecord.method) ||
|
||||||
|
"PUT",
|
||||||
|
requiredHeaders: {
|
||||||
|
...curlDetails.requiredHeaders,
|
||||||
|
...explicitHeaders,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignedUploadFailure(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
|
return (
|
||||||
|
message.includes("SignatureDoesNotMatch") ||
|
||||||
|
message.includes("Access denied") ||
|
||||||
|
message.includes("Google secret key") ||
|
||||||
|
message.includes("签名")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const safeExtension = extension && extension !== "bin" ? extension : "bin";
|
||||||
|
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
|
||||||
|
const uploaded = await uploadFile(filename, buffer);
|
||||||
|
return uploaded.url;
|
||||||
|
}
|
||||||
|
|
||||||
function isCredentialOrBillingProviderError(error: unknown) {
|
function isCredentialOrBillingProviderError(error: unknown) {
|
||||||
return (
|
return (
|
||||||
error instanceof WorldLabsApiError &&
|
error instanceof WorldLabsApiError &&
|
||||||
@ -202,24 +281,24 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||||
|
resolvePrepareUploadDetails(prepareUpload);
|
||||||
if (!mediaAssetId || !uploadUrl) {
|
if (!mediaAssetId || !uploadUrl) {
|
||||||
throw new Error("World Labs 返回的上传信息不完整");
|
throw new Error("World Labs 返回的上传信息不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadHeaders = new Headers();
|
const uploadHeaders = new Headers();
|
||||||
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
|
||||||
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
|
|
||||||
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
|
||||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||||
uploadHeaders.set(headerName, String(headerValue));
|
uploadHeaders.set(headerName, String(headerValue));
|
||||||
}
|
}
|
||||||
|
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
|
||||||
|
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
const uploadMethod = String(
|
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||||
prepareUpload.upload_info?.upload_method || "PUT"
|
|
||||||
).toUpperCase();
|
|
||||||
const uploadResponse = await fetch(uploadUrl, {
|
const uploadResponse = await fetch(uploadUrl, {
|
||||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||||
headers: uploadHeaders,
|
headers: uploadHeaders,
|
||||||
body: file,
|
body: file,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@ -252,6 +331,28 @@ export async function POST(request: Request) {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
if (isSignedUploadFailure(error)) {
|
||||||
|
try {
|
||||||
|
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
|
||||||
|
logger.warn("worldlabs signed upload failed; using storage source url", {
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
kind: isImage ? "image" : "video",
|
||||||
|
size: file.size,
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
sourceUrl,
|
||||||
|
providerId: config.providerId || null,
|
||||||
|
providerName: config.providerName || null,
|
||||||
|
uploadFallback: "storage-source-url",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fallbackError) {
|
||||||
|
lastError = fallbackError;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (isCredentialOrBillingProviderError(error)) {
|
if (isCredentialOrBillingProviderError(error)) {
|
||||||
await reportWorldLabsProviderRuntimeFailure(
|
await reportWorldLabsProviderRuntimeFailure(
|
||||||
config.providerId,
|
config.providerId,
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
|
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||||
|
|
||||||
@ -167,9 +168,12 @@ export async function POST(request: Request) {
|
|||||||
console.warn("[ClientPerformance] prune failed", error);
|
console.warn("[ClientPerformance] prune failed", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const persistedChatId =
|
||||||
|
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||||
|
|
||||||
await db.insert(clientPerformanceEvent).values({
|
await db.insert(clientPerformanceEvent).values({
|
||||||
userId: session?.user?.id || null,
|
userId: session?.user?.id || null,
|
||||||
chatId: event.chatId || null,
|
chatId: persistedChatId,
|
||||||
projectId: clampText(event.projectId, 64),
|
projectId: clampText(event.projectId, 64),
|
||||||
eventType: clampText(event.eventType, 64) || "unknown",
|
eventType: clampText(event.eventType, 64) || "unknown",
|
||||||
section: event.section || "unknown",
|
section: event.section || "unknown",
|
||||||
|
|||||||
@ -141,6 +141,7 @@ import {
|
|||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
createEmptyAgentWorkflowPersistedState,
|
createEmptyAgentWorkflowPersistedState,
|
||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState,
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
import {
|
import {
|
||||||
createEmptyAgentModeWorkspaceState,
|
createEmptyAgentModeWorkspaceState,
|
||||||
@ -192,11 +193,6 @@ import {
|
|||||||
sanitizeAgentWorkflowSelections,
|
sanitizeAgentWorkflowSelections,
|
||||||
} from "@/lib/agents/workflow-ui-config";
|
} from "@/lib/agents/workflow-ui-config";
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import {
|
|
||||||
loadCanvasChatPanelModule,
|
|
||||||
preloadCanvasChatPanel,
|
|
||||||
} from "@/components/canvas/canvas-chat-panel-loader";
|
|
||||||
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
|
|
||||||
import {
|
import {
|
||||||
buildVideoWorkflowCanvasNode,
|
buildVideoWorkflowCanvasNode,
|
||||||
getVideoWorkflowNodeDefinition,
|
getVideoWorkflowNodeDefinition,
|
||||||
@ -232,6 +228,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
|||||||
import type {
|
import type {
|
||||||
AgentGenerationProject,
|
AgentGenerationProject,
|
||||||
AgentGenerationProjectPersistedState,
|
AgentGenerationProjectPersistedState,
|
||||||
|
AgentGenerationProjectReplacementResultState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationProjectWorkspaceAttachment,
|
AgentGenerationProjectWorkspaceAttachment,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
@ -316,7 +313,7 @@ const CanvasWorkspace = dynamic(
|
|||||||
|
|
||||||
const CanvasChatPanel = dynamic(
|
const CanvasChatPanel = dynamic(
|
||||||
() =>
|
() =>
|
||||||
loadCanvasChatPanelModule().then(
|
import("@/components/canvas/CanvasChatPanel").then(
|
||||||
(module) => module.CanvasChatPanel
|
(module) => module.CanvasChatPanel
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
@ -1140,7 +1137,7 @@ function normalizeImportedCanvasState(payload: unknown): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOATING_PANEL_WIDTH = 380;
|
const FLOATING_PANEL_WIDTH = 400;
|
||||||
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
|
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
|
||||||
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
||||||
const FLOATING_PANEL_INSET = 16;
|
const FLOATING_PANEL_INSET = 16;
|
||||||
@ -1545,6 +1542,57 @@ function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areReplacementResultStatesEqual(
|
||||||
|
left: AgentGenerationProjectReplacementResultState | undefined | null,
|
||||||
|
right: AgentGenerationProjectReplacementResultState | undefined | null
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReplacementResultUrlCount(
|
||||||
|
state: AgentGenerationProjectPersistedState | undefined | null
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
state?.replacementResultState
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
Object.values(normalized.detail_replacement || {}).filter((record) =>
|
||||||
|
Boolean(record.url)
|
||||||
|
).length +
|
||||||
|
Object.values(normalized.sku_replacement || {}).filter((record) =>
|
||||||
|
Boolean(record.url)
|
||||||
|
).length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasReplacementResultCollectionMismatch(
|
||||||
|
state: AgentGenerationProjectPersistedState | undefined | null
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
state?.replacementResultState
|
||||||
|
);
|
||||||
|
const resultCollections = state?.resultCollections || {};
|
||||||
|
const countModeRecords = (
|
||||||
|
modeType: "detail_replacement" | "sku_replacement"
|
||||||
|
) =>
|
||||||
|
Object.values(normalized[modeType] || {}).filter((record) =>
|
||||||
|
Boolean(record.url)
|
||||||
|
).length;
|
||||||
|
const countModeCollection = (
|
||||||
|
modeType: "detail_replacement" | "sku_replacement"
|
||||||
|
) => normalizeResultCollectionUrls(resultCollections[modeType]).length;
|
||||||
|
|
||||||
|
return (["detail_replacement", "sku_replacement"] as const).some(
|
||||||
|
(modeType) => {
|
||||||
|
const recordCount = countModeRecords(modeType);
|
||||||
|
return recordCount > 0 && countModeCollection(modeType) !== recordCount;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
|
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
|
||||||
|
|
||||||
function getAgentProjectDraftStorageKey(projectId: string) {
|
function getAgentProjectDraftStorageKey(projectId: string) {
|
||||||
@ -1981,7 +2029,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
);
|
);
|
||||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||||
useState<string | null>(null);
|
useState<string | null>(null);
|
||||||
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode);
|
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||||
useState(FLOATING_PANEL_WIDTH);
|
useState(FLOATING_PANEL_WIDTH);
|
||||||
const [
|
const [
|
||||||
@ -1990,7 +2038,8 @@ export default function CanvasEditorLegacyPage({
|
|||||||
] = useState(false);
|
] = useState(false);
|
||||||
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const areSecondaryPanelsReady = true;
|
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
|
||||||
|
useState(false);
|
||||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||||||
@ -2092,6 +2141,10 @@ export default function CanvasEditorLegacyPage({
|
|||||||
agentProjectDeletedResultCollections,
|
agentProjectDeletedResultCollections,
|
||||||
setAgentProjectDeletedResultCollections,
|
setAgentProjectDeletedResultCollections,
|
||||||
] = useState<AgentGenerationProjectResultCollections>({});
|
] = useState<AgentGenerationProjectResultCollections>({});
|
||||||
|
const [
|
||||||
|
agentProjectReplacementResultState,
|
||||||
|
setAgentProjectReplacementResultState,
|
||||||
|
] = useState<AgentGenerationProjectReplacementResultState>({});
|
||||||
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
||||||
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
||||||
string | null
|
string | null
|
||||||
@ -3298,13 +3351,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [checkCanvasAuthSession]);
|
}, [checkCanvasAuthSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
preloadCanvasChatPanel();
|
const timeoutId = window.setTimeout(() => {
|
||||||
preloadCanvasChatData(id);
|
setAreSecondaryPanelsReady(true);
|
||||||
|
}, 800);
|
||||||
return scheduleCanvasIdleTask(() => {
|
return () => window.clearTimeout(timeoutId);
|
||||||
void import("@/components/canvas/CanvasFileListPanel");
|
}, []);
|
||||||
}, 700);
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const hydrateCanvasProject = useCallback(
|
const hydrateCanvasProject = useCallback(
|
||||||
(project: CanvasInitialProject) => {
|
(project: CanvasInitialProject) => {
|
||||||
@ -3471,6 +3522,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectThumbnail(null);
|
setAgentProjectThumbnail(null);
|
||||||
setAgentProjectResultCollections({});
|
setAgentProjectResultCollections({});
|
||||||
setAgentProjectDeletedResultCollections({});
|
setAgentProjectDeletedResultCollections({});
|
||||||
|
setAgentProjectReplacementResultState({});
|
||||||
setLoadedAgentProjectId(null);
|
setLoadedAgentProjectId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3962,8 +4014,15 @@ export default function CanvasEditorLegacyPage({
|
|||||||
...resultCollections.sku_replacement,
|
...resultCollections.sku_replacement,
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
agentProjectReplacementResultState
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}, [agentProjectDeletedResultCollections, agentProjectResultCollections]);
|
}, [
|
||||||
|
agentProjectDeletedResultCollections,
|
||||||
|
agentProjectReplacementResultState,
|
||||||
|
agentProjectResultCollections,
|
||||||
|
]);
|
||||||
|
|
||||||
const buildPersistedAgentProjectState = useCallback(() => {
|
const buildPersistedAgentProjectState = useCallback(() => {
|
||||||
const persistedMaterials = appearanceRecognitionEnabled
|
const persistedMaterials = appearanceRecognitionEnabled
|
||||||
@ -4148,6 +4207,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
setAgentProjectDeletedResultCollections(
|
setAgentProjectDeletedResultCollections(
|
||||||
persistedState.deletedResultCollections || {}
|
persistedState.deletedResultCollections || {}
|
||||||
);
|
);
|
||||||
|
setAgentProjectReplacementResultState(
|
||||||
|
persistedState.replacementResultState || {}
|
||||||
|
);
|
||||||
setLoadedAgentProjectId(params.projectId);
|
setLoadedAgentProjectId(params.projectId);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@ -4322,7 +4384,9 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const result = await agentProjectService.updateProject(agentProjectId, {
|
const result = await agentProjectService.updateProject(agentProjectId, {
|
||||||
title: nextTitle,
|
title: nextTitle,
|
||||||
thumbnail: agentProjectThumbnail,
|
thumbnail: agentProjectThumbnail,
|
||||||
projectState: buildPersistedAgentProjectState(),
|
projectState:
|
||||||
|
persistedAgentProjectStateRef.current ||
|
||||||
|
buildPersistedAgentProjectState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@ -4388,9 +4452,27 @@ export default function CanvasEditorLegacyPage({
|
|||||||
|
|
||||||
const serverUpdatedAt =
|
const serverUpdatedAt =
|
||||||
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
Number(new Date(result.data.updatedAt).getTime()) || 0;
|
||||||
|
const serverReplacementResultCount = getReplacementResultUrlCount(
|
||||||
|
result.data.projectState
|
||||||
|
);
|
||||||
|
const localReplacementResultCount = getReplacementResultUrlCount(
|
||||||
|
localDraft?.projectState
|
||||||
|
);
|
||||||
|
const localDraftMissesServerReplacementResults =
|
||||||
|
serverReplacementResultCount > 0 && localReplacementResultCount === 0;
|
||||||
|
const localDraftReplacementResultsDifferFromServer =
|
||||||
|
serverReplacementResultCount > 0 &&
|
||||||
|
localReplacementResultCount > 0 &&
|
||||||
|
localReplacementResultCount !== serverReplacementResultCount;
|
||||||
|
const localDraftHasStaleReplacementCollections =
|
||||||
|
serverReplacementResultCount > 0 &&
|
||||||
|
hasReplacementResultCollectionMismatch(localDraft?.projectState);
|
||||||
const shouldUseLocalDraft =
|
const shouldUseLocalDraft =
|
||||||
Boolean(localDraft?.projectState) &&
|
Boolean(localDraft?.projectState) &&
|
||||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt;
|
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||||
|
!localDraftMissesServerReplacementResults &&
|
||||||
|
!localDraftReplacementResultsDifferFromServer &&
|
||||||
|
!localDraftHasStaleReplacementCollections;
|
||||||
|
|
||||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||||
hydrateAgentProjectSnapshot({
|
hydrateAgentProjectSnapshot({
|
||||||
@ -4492,6 +4574,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}, [
|
}, [
|
||||||
agentModeState,
|
agentModeState,
|
||||||
agentProjectId,
|
agentProjectId,
|
||||||
|
agentProjectReplacementResultState,
|
||||||
agentProjectResultCollections,
|
agentProjectResultCollections,
|
||||||
agentProjectThumbnail,
|
agentProjectThumbnail,
|
||||||
agentProjectTitle,
|
agentProjectTitle,
|
||||||
@ -4782,32 +4865,66 @@ export default function CanvasEditorLegacyPage({
|
|||||||
const handleAgentResultPreviewUrlsChange = useCallback(
|
const handleAgentResultPreviewUrlsChange = useCallback(
|
||||||
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
||||||
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
||||||
setAgentProjectResultCollections((current) => {
|
const currentProjectState =
|
||||||
|
persistedAgentProjectStateRef.current ||
|
||||||
|
createEmptyAgentGenerationProjectPersistedState();
|
||||||
|
const currentCollections = currentProjectState.resultCollections || {};
|
||||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||||
...current,
|
...currentCollections,
|
||||||
[modeType]: normalizedUrls,
|
[modeType]: normalizedUrls,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (areResultCollectionsEqual(current, nextCollections)) {
|
if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
...persistedAgentProjectStateRef.current,
|
...currentProjectState,
|
||||||
resultCollections: nextCollections,
|
resultCollections: nextCollections,
|
||||||
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
||||||
};
|
};
|
||||||
persistedAgentProjectStateRef.current = nextProjectState;
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAgentProjectResultCollections((current) =>
|
||||||
|
areResultCollectionsEqual(current, nextCollections)
|
||||||
|
? current
|
||||||
|
: nextCollections
|
||||||
|
);
|
||||||
setAgentProjectThumbnail((currentThumbnail) => {
|
setAgentProjectThumbnail((currentThumbnail) => {
|
||||||
const nextThumbnail =
|
const nextThumbnail =
|
||||||
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||||
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
[writeAgentProjectDraftSnapshot]
|
||||||
|
);
|
||||||
|
|
||||||
return nextCollections;
|
const handleAgentReplacementResultStateChange = useCallback(
|
||||||
});
|
(nextState: AgentGenerationProjectReplacementResultState) => {
|
||||||
|
const normalizedState =
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(nextState);
|
||||||
|
|
||||||
|
const currentProjectState =
|
||||||
|
persistedAgentProjectStateRef.current ||
|
||||||
|
createEmptyAgentGenerationProjectPersistedState();
|
||||||
|
if (
|
||||||
|
!areReplacementResultStatesEqual(
|
||||||
|
currentProjectState.replacementResultState,
|
||||||
|
normalizedState
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
|
...currentProjectState,
|
||||||
|
replacementResultState: normalizedState,
|
||||||
|
};
|
||||||
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAgentProjectReplacementResultState((current) =>
|
||||||
|
areReplacementResultStatesEqual(current, normalizedState)
|
||||||
|
? current
|
||||||
|
: normalizedState
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[writeAgentProjectDraftSnapshot]
|
[writeAgentProjectDraftSnapshot]
|
||||||
);
|
);
|
||||||
@ -4845,6 +4962,33 @@ export default function CanvasEditorLegacyPage({
|
|||||||
? current
|
? current
|
||||||
: nextDeletedCollections
|
: nextDeletedCollections
|
||||||
);
|
);
|
||||||
|
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
||||||
|
setAgentProjectReplacementResultState((current) => {
|
||||||
|
const normalizedState =
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(current);
|
||||||
|
const currentModeState = normalizedState[modeType] || {};
|
||||||
|
const nextModeState = Object.fromEntries(
|
||||||
|
Object.entries(currentModeState).filter(
|
||||||
|
([, record]) => record.url !== normalizedUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nextState: AgentGenerationProjectReplacementResultState = {
|
||||||
|
...normalizedState,
|
||||||
|
[modeType]: nextModeState,
|
||||||
|
};
|
||||||
|
if (areReplacementResultStatesEqual(normalizedState, nextState)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||||
|
...persistedAgentProjectStateRef.current,
|
||||||
|
replacementResultState: nextState,
|
||||||
|
};
|
||||||
|
persistedAgentProjectStateRef.current = nextProjectState;
|
||||||
|
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}
|
||||||
setAgentProjectThumbnail((current) => {
|
setAgentProjectThumbnail((current) => {
|
||||||
const nextThumbnail =
|
const nextThumbnail =
|
||||||
current === normalizedUrl
|
current === normalizedUrl
|
||||||
@ -4853,7 +4997,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
return current === nextThumbnail ? current : nextThumbnail;
|
return current === nextThumbnail ? current : nextThumbnail;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentProjectDeletedResultCollections, agentProjectResultCollections]
|
[
|
||||||
|
agentProjectDeletedResultCollections,
|
||||||
|
agentProjectResultCollections,
|
||||||
|
writeAgentProjectDraftSnapshot,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -6827,6 +6975,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
deletedResultCollections:
|
deletedResultCollections:
|
||||||
existingPersistedState.deletedResultCollections || {},
|
existingPersistedState.deletedResultCollections || {},
|
||||||
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
||||||
|
replacementResultState: existingPersistedState.replacementResultState || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -10113,10 +10262,13 @@ export default function CanvasEditorLegacyPage({
|
|||||||
mode: null,
|
mode: null,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onPersistProject={() => persistAgentProject("manual")}
|
||||||
|
onReplacementResultStateChange={handleAgentReplacementResultStateChange}
|
||||||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||||||
onWorkspaceStateChange={setAgentWorkspaceState}
|
onWorkspaceStateChange={setAgentWorkspaceState}
|
||||||
projectId={agentProjectId}
|
projectId={agentProjectId}
|
||||||
key={agentProjectId}
|
key={agentProjectId}
|
||||||
|
replacementResultState={agentProjectReplacementResultState}
|
||||||
resultCollections={agentProjectResultCollections}
|
resultCollections={agentProjectResultCollections}
|
||||||
selectedAgentPresetId={
|
selectedAgentPresetId={
|
||||||
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
||||||
@ -10869,7 +11021,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 rounded-full px-2.5 text-[12px] font-medium",
|
"h-10 rounded-full px-3 font-medium text-sm",
|
||||||
isVideoMode
|
isVideoMode
|
||||||
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
|
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
|
||||||
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
||||||
@ -10877,7 +11029,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
onClick={() => setIsPanelOpen(true)}
|
onClick={() => setIsPanelOpen(true)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<MessageCircle className="mr-1 h-3.5 w-3.5" />
|
<MessageCircle className="mr-1.5 h-4 w-4" />
|
||||||
对话
|
对话
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -10890,7 +11042,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
"absolute z-10 ease-in-out",
|
"absolute z-10 ease-in-out",
|
||||||
isFloatingPanelResizing
|
isFloatingPanelResizing
|
||||||
? "transition-none"
|
? "transition-none"
|
||||||
: "transition-all duration-150",
|
: "transition-all duration-300",
|
||||||
isPanelOpen
|
isPanelOpen
|
||||||
? "translate-x-0 opacity-100"
|
? "translate-x-0 opacity-100"
|
||||||
: "pointer-events-none translate-x-[120%] opacity-0"
|
: "pointer-events-none translate-x-[120%] opacity-0"
|
||||||
@ -10929,7 +11081,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
|
||||||
{!isPanelOpen ? null : !areSecondaryPanelsReady ? (
|
{!areSecondaryPanelsReady ? (
|
||||||
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
<CanvasLazyPanelLoader label="正在准备侧边面板..." />
|
||||||
) : panelMode === "chat" ? (
|
) : panelMode === "chat" ? (
|
||||||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||||||
@ -11525,60 +11677,60 @@ export default function CanvasEditorLegacyPage({
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align={shouldDockFloatingToolbarToSide ? "start" : "center"}
|
align={shouldDockFloatingToolbarToSide ? "start" : "center"}
|
||||||
className="w-[360px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border bg-background p-0 shadow-xl"
|
className="w-[420px] rounded-2xl border bg-background p-0 shadow-2xl"
|
||||||
side={shouldDockFloatingToolbarToSide ? "right" : "top"}
|
side={shouldDockFloatingToolbarToSide ? "right" : "top"}
|
||||||
>
|
>
|
||||||
<div className="border-b px-3 py-2.5">
|
<div className="border-b px-4 py-3">
|
||||||
<div className="text-[13px] font-semibold text-foreground">
|
<div className="font-semibold text-foreground text-sm">
|
||||||
视频工作流节点库
|
视频工作流节点库
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
<div className="mt-1 text-muted-foreground text-xs">
|
||||||
这里按参考 APP
|
这里按参考 APP
|
||||||
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="max-h-[60vh]">
|
<ScrollArea className="max-h-[60vh]">
|
||||||
<div className="space-y-3 p-2">
|
<div className="space-y-4 p-3">
|
||||||
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
|
||||||
const sectionNodes = getVideoWorkflowSectionNodes(
|
const sectionNodes = getVideoWorkflowSectionNodes(
|
||||||
section.id
|
section.id
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5" key={section.id}>
|
<div className="space-y-2" key={section.id}>
|
||||||
<div className="px-1.5">
|
<div className="px-2">
|
||||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
|
||||||
{section.label}
|
{section.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
<div className="mt-1 text-muted-foreground text-xs">
|
||||||
{section.description}
|
{section.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{sectionNodes.map((nodeDefinition) => (
|
{sectionNodes.map((nodeDefinition) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-start gap-2 rounded-[var(--ui-radius-control)] px-2.5 py-2"
|
className="flex items-start gap-3 rounded-xl px-3 py-3"
|
||||||
key={nodeDefinition.type}
|
key={nodeDefinition.type}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAddWorkflowNode(nodeDefinition.type)
|
handleAddWorkflowNode(nodeDefinition.type)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mt-1 h-2 w-2 rounded-full"
|
className="mt-0.5 h-2.5 w-2.5 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
nodeDefinition.accentColor,
|
nodeDefinition.accentColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span className="text-[13px] font-medium text-foreground">
|
<span className="font-medium text-foreground text-sm">
|
||||||
{nodeDefinition.label}
|
{nodeDefinition.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[10px] text-muted-foreground">
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
{nodeDefinition.type}
|
{nodeDefinition.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
<div className="mt-1 text-muted-foreground text-xs leading-5">
|
||||||
{nodeDefinition.description}
|
{nodeDefinition.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1970,15 +1970,18 @@ export default function CanvasModel3DPage() {
|
|||||||
worldImageFile as File,
|
worldImageFile as File,
|
||||||
"image"
|
"image"
|
||||||
);
|
);
|
||||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
const uploadData = uploadResult.data || null;
|
||||||
|
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||||
|
const sourceUrl = uploadData?.sourceUrl || null;
|
||||||
|
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
throw new Error(uploadResult.error || "上传世界参考图失败");
|
||||||
}
|
}
|
||||||
await submitTask({
|
await submitTask({
|
||||||
provider: "worldlabs-3d",
|
provider: "worldlabs-3d",
|
||||||
payload: {
|
payload: {
|
||||||
mode: "image",
|
mode: "image",
|
||||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
providerId: uploadResult.data.providerId,
|
providerId: uploadData.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2000,15 +2003,18 @@ export default function CanvasModel3DPage() {
|
|||||||
worldVideoFile as File,
|
worldVideoFile as File,
|
||||||
"video"
|
"video"
|
||||||
);
|
);
|
||||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
const uploadData = uploadResult.data || null;
|
||||||
|
const mediaAssetId = uploadData?.mediaAssetId || null;
|
||||||
|
const sourceUrl = uploadData?.sourceUrl || null;
|
||||||
|
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(uploadResult.error || "上传世界视频失败");
|
throw new Error(uploadResult.error || "上传世界视频失败");
|
||||||
}
|
}
|
||||||
await submitTask({
|
await submitTask({
|
||||||
provider: "worldlabs-3d",
|
provider: "worldlabs-3d",
|
||||||
payload: {
|
payload: {
|
||||||
mode: "video",
|
mode: "video",
|
||||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
providerId: uploadResult.data.providerId,
|
providerId: uploadData.providerId,
|
||||||
prompt: normalizedPrompt || undefined,
|
prompt: normalizedPrompt || undefined,
|
||||||
displayName: worldDisplayName.trim() || undefined,
|
displayName: worldDisplayName.trim() || undefined,
|
||||||
model: worldModel,
|
model: worldModel,
|
||||||
@ -2043,11 +2049,13 @@ export default function CanvasModel3DPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaAssets = uploads.map(({ slot, result }) => {
|
const mediaAssets = uploads.map(({ slot, result }) => {
|
||||||
if (!result.success || !result.data?.mediaAssetId) {
|
const mediaAssetId = result.data?.mediaAssetId || null;
|
||||||
|
const sourceUrl = result.data?.sourceUrl || null;
|
||||||
|
if (!result.success || (!mediaAssetId && !sourceUrl)) {
|
||||||
throw new Error(result.error || "上传多图世界图片失败");
|
throw new Error(result.error || "上传多图世界图片失败");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
mediaAssetId: result.data.mediaAssetId,
|
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||||
azimuth: slot.azimuth,
|
azimuth: slot.azimuth,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -123,6 +123,8 @@ import type {
|
|||||||
AgentDetailBriefForm,
|
AgentDetailBriefForm,
|
||||||
AgentDetailGenerationProgressState,
|
AgentDetailGenerationProgressState,
|
||||||
AgentDetailReplacementStrategy,
|
AgentDetailReplacementStrategy,
|
||||||
|
AgentGenerationProjectReplacementResultState,
|
||||||
|
AgentGenerationReplacementResultRecord,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationResultModeType,
|
AgentGenerationResultModeType,
|
||||||
@ -147,6 +149,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createEmptyAgentGenerationProjectWorkspaceState,
|
createEmptyAgentGenerationProjectWorkspaceState,
|
||||||
normalizeAgentGenerationProjectPersistedState,
|
normalizeAgentGenerationProjectPersistedState,
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState,
|
||||||
} from "@/lib/agents/generation-project-state";
|
} from "@/lib/agents/generation-project-state";
|
||||||
|
|
||||||
export interface AgentGenerationWorkspaceDarkV2Props {
|
export interface AgentGenerationWorkspaceDarkV2Props {
|
||||||
@ -174,6 +177,10 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
|||||||
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
||||||
resultCollections?: AgentGenerationProjectResultCollections | null;
|
resultCollections?: AgentGenerationProjectResultCollections | null;
|
||||||
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
||||||
|
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
|
||||||
|
onReplacementResultStateChange?: (
|
||||||
|
next: AgentGenerationProjectReplacementResultState
|
||||||
|
) => void;
|
||||||
onResultPreviewUrlsChange?: (
|
onResultPreviewUrlsChange?: (
|
||||||
modeType: AgentGenerationResultModeType,
|
modeType: AgentGenerationResultModeType,
|
||||||
urls: string[]
|
urls: string[]
|
||||||
@ -182,6 +189,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
|||||||
modeType: AgentGenerationResultModeType,
|
modeType: AgentGenerationResultModeType,
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
) => void;
|
) => void;
|
||||||
|
onPersistProject?: () => Promise<boolean> | boolean;
|
||||||
topNavRightSlot?: ReactNode;
|
topNavRightSlot?: ReactNode;
|
||||||
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
||||||
workflowStylePanel?: ReactNode;
|
workflowStylePanel?: ReactNode;
|
||||||
@ -248,6 +256,8 @@ type DetailResultLocalEditTarget = {
|
|||||||
|
|
||||||
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
||||||
|
|
||||||
|
type DetailResultLocalEditImageSize = "1K" | "2K";
|
||||||
|
|
||||||
type DetailResultLocalEditRect = {
|
type DetailResultLocalEditRect = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -270,6 +280,7 @@ type DetailResultLocalEditRegionInteraction = {
|
|||||||
|
|
||||||
type DetailResultLocalEditSubmitParams = {
|
type DetailResultLocalEditSubmitParams = {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
|
imageSize: DetailResultLocalEditImageSize;
|
||||||
mode: DetailResultLocalEditMode;
|
mode: DetailResultLocalEditMode;
|
||||||
title: string;
|
title: string;
|
||||||
regions: LocalEditRegion[];
|
regions: LocalEditRegion[];
|
||||||
@ -470,6 +481,15 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) {
|
||||||
|
const normalizedLeft = normalizeResultCollectionUrls(left);
|
||||||
|
const normalizedRight = normalizeResultCollectionUrls(right);
|
||||||
|
return (
|
||||||
|
normalizedLeft.length === normalizedRight.length &&
|
||||||
|
normalizedLeft.every((url, index) => normalizedRight[index] === url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function excludeDeletedResultUrls(
|
function excludeDeletedResultUrls(
|
||||||
urls: string[] | undefined | null,
|
urls: string[] | undefined | null,
|
||||||
deletedUrls: string[] | undefined | null
|
deletedUrls: string[] | undefined | null
|
||||||
@ -844,11 +864,11 @@ function getDetailScaleAdjustRegionPrompt(
|
|||||||
hasTargetPosition = false
|
hasTargetPosition = false
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。",
|
"按目标框线对框选范围内的主物体执行等比比例调整。",
|
||||||
hasTargetPosition
|
hasTargetPosition
|
||||||
? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。"
|
? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。"
|
||||||
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||||
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||||
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
@ -895,6 +915,7 @@ function DetailResultLocalEditDialog({
|
|||||||
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
||||||
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
||||||
|
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
|
||||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isScaleAdjustMode = mode === "scale_adjust";
|
const isScaleAdjustMode = mode === "scale_adjust";
|
||||||
@ -919,6 +940,7 @@ function DetailResultLocalEditDialog({
|
|||||||
setRegionInteraction(null);
|
setRegionInteraction(null);
|
||||||
setUploadingRegionId(null);
|
setUploadingRegionId(null);
|
||||||
setDrawingTargetRegionId(null);
|
setDrawingTargetRegionId(null);
|
||||||
|
setImageSize("1K");
|
||||||
dragStartRef.current = null;
|
dragStartRef.current = null;
|
||||||
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
||||||
|
|
||||||
@ -1216,7 +1238,7 @@ function DetailResultLocalEditDialog({
|
|||||||
);
|
);
|
||||||
if (unchangedRegion) {
|
if (unchangedRegion) {
|
||||||
setActiveRegionId(unchangedRegion.id);
|
setActiveRegionId(unchangedRegion.id);
|
||||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1229,11 +1251,12 @@ function DetailResultLocalEditDialog({
|
|||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
imageUrl: target.imageUrl,
|
imageUrl: target.imageUrl,
|
||||||
|
imageSize,
|
||||||
mode,
|
mode,
|
||||||
title: target.title,
|
title: target.title,
|
||||||
regions: validRegions,
|
regions: validRegions,
|
||||||
});
|
});
|
||||||
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
}, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||||
|
|
||||||
const completeDraftRegion = useCallback(
|
const completeDraftRegion = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@ -1818,6 +1841,36 @@ function DetailResultLocalEditDialog({
|
|||||||
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
||||||
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
||||||
</div>
|
</div>
|
||||||
|
{!activePreviewResult ? (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
|
||||||
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||||
|
输出尺寸
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ label: "快速 1K", value: "1K" },
|
||||||
|
{ label: "高清 2K", value: "2K" },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"h-9 rounded-xl border px-3 text-sm font-semibold transition",
|
||||||
|
imageSize === option.value
|
||||||
|
? "border-white bg-white text-black"
|
||||||
|
: "border-white/10 bg-black/20 text-white/58 hover:bg-white/[0.08] hover:text-white"
|
||||||
|
)}
|
||||||
|
disabled={submitting}
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => setImageSize(option.value)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{!activePreviewResult && isScaleAdjustMode ? (
|
{!activePreviewResult && isScaleAdjustMode ? (
|
||||||
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
|
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
|
||||||
@ -2353,13 +2406,7 @@ type ReplacementModeType = Extract<
|
|||||||
"detail_replacement" | "sku_replacement"
|
"detail_replacement" | "sku_replacement"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface ReplacementResultRecord {
|
interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
|
||||||
url: string | null;
|
|
||||||
text: string;
|
|
||||||
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
|
|
||||||
updatedAt: number;
|
|
||||||
compareSourceUrl?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReplacementExecutionContext {
|
interface ReplacementExecutionContext {
|
||||||
modeType: ReplacementModeType;
|
modeType: ReplacementModeType;
|
||||||
@ -2406,13 +2453,26 @@ type ReplacementResultState = Record<
|
|||||||
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
||||||
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
||||||
|
|
||||||
function createEmptyReplacementResultState(): ReplacementResultState {
|
function normalizeReplacementResultStateForWorkspace(
|
||||||
|
value: AgentGenerationProjectReplacementResultState | null | undefined
|
||||||
|
): ReplacementResultState {
|
||||||
|
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
|
||||||
return {
|
return {
|
||||||
detail_replacement: {},
|
detail_replacement: { ...(normalized.detail_replacement || {}) },
|
||||||
sku_replacement: {},
|
sku_replacement: { ...(normalized.sku_replacement || {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function areReplacementResultStatesEqual(
|
||||||
|
left: ReplacementResultState,
|
||||||
|
right: ReplacementResultState
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
|
||||||
|
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
|
||||||
const normalized = normalizeAssistantText(String(text || ""))
|
const normalized = normalizeAssistantText(String(text || ""))
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
@ -3060,9 +3120,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
workspaceState = null,
|
workspaceState = null,
|
||||||
resultCollections = null,
|
resultCollections = null,
|
||||||
deletedResultCollections = null,
|
deletedResultCollections = null,
|
||||||
|
replacementResultState: persistedReplacementResultState = null,
|
||||||
onWorkspaceStateChange,
|
onWorkspaceStateChange,
|
||||||
|
onReplacementResultStateChange,
|
||||||
onResultPreviewUrlsChange,
|
onResultPreviewUrlsChange,
|
||||||
onDeleteResultImage,
|
onDeleteResultImage,
|
||||||
|
onPersistProject,
|
||||||
topNavRightSlot,
|
topNavRightSlot,
|
||||||
showcaseConfig,
|
showcaseConfig,
|
||||||
workflowStylePanel,
|
workflowStylePanel,
|
||||||
@ -3214,7 +3277,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||||
() => createEmptyReplacementResultState()
|
() => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
|
||||||
);
|
);
|
||||||
const [replacementExecutionContext, setReplacementExecutionContext] =
|
const [replacementExecutionContext, setReplacementExecutionContext] =
|
||||||
useState<ReplacementExecutionContext | null>(null);
|
useState<ReplacementExecutionContext | null>(null);
|
||||||
@ -3230,6 +3293,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||||
useState<Set<string>>(() => new Set());
|
useState<Set<string>>(() => new Set());
|
||||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||||
|
const transportGenerationStartedAtRef = useRef<number | null>(null);
|
||||||
const closePsdTemplateDialog = useCallback(() => {
|
const closePsdTemplateDialog = useCallback(() => {
|
||||||
setIsPsdTemplateDialogOpen(false);
|
setIsPsdTemplateDialogOpen(false);
|
||||||
setPsdTemplateDialogSourceUrl(null);
|
setPsdTemplateDialogSourceUrl(null);
|
||||||
@ -3263,6 +3327,22 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
[activeResultModeType, isPsdTemplateEnabled]
|
[activeResultModeType, isPsdTemplateEnabled]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextState = normalizeReplacementResultStateForWorkspace(
|
||||||
|
persistedReplacementResultState
|
||||||
|
);
|
||||||
|
setReplacementResultState((current) =>
|
||||||
|
areReplacementResultStatesEqual(current, nextState) ? current : nextState
|
||||||
|
);
|
||||||
|
}, [persistedReplacementResultState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onReplacementResultStateChange?.(
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
|
||||||
|
);
|
||||||
|
}, [onReplacementResultStateChange, replacementResultState]);
|
||||||
|
|
||||||
const normalizedDeletedResultCollections = useMemo(
|
const normalizedDeletedResultCollections = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
scene_generation: normalizeResultCollectionUrls(
|
scene_generation: normalizeResultCollectionUrls(
|
||||||
@ -4038,7 +4118,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? "agent-anchor-materials"
|
? "agent-anchor-materials"
|
||||||
: "agent-anchor-generate";
|
: "agent-anchor-generate";
|
||||||
|
|
||||||
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
|
const {
|
||||||
|
messages,
|
||||||
|
setMessages,
|
||||||
|
sendMessage,
|
||||||
|
status,
|
||||||
|
stop,
|
||||||
|
error: chatError,
|
||||||
|
clearError,
|
||||||
|
} = useChat<ChatMessage>({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
messages: historyMessages,
|
messages: historyMessages,
|
||||||
experimental_throttle: 100,
|
experimental_throttle: 100,
|
||||||
@ -4503,63 +4591,48 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementReferenceCards,
|
skuReplacementReferenceCards,
|
||||||
]);
|
]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (latestReplacementResults.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailReferenceCardById = new Map(
|
||||||
|
detailReplacementReferenceCards.map((card) => [card.id, card])
|
||||||
|
);
|
||||||
|
const skuReferenceCardById = new Map(
|
||||||
|
skuReplacementReferenceCards.map((card) => [card.id, card])
|
||||||
|
);
|
||||||
|
const fallbackModeType: ReplacementModeType | null =
|
||||||
|
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"
|
||||||
|
? currentResultModeType
|
||||||
|
: null;
|
||||||
|
|
||||||
setReplacementResultState((current) => {
|
setReplacementResultState((current) => {
|
||||||
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id));
|
const nextState: ReplacementResultState = {
|
||||||
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id));
|
detail_replacement: { ...current.detail_replacement },
|
||||||
let changed = false;
|
sku_replacement: { ...current.sku_replacement },
|
||||||
|
|
||||||
const nextDetail = Object.fromEntries(
|
|
||||||
Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId))
|
|
||||||
);
|
|
||||||
if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSku = Object.fromEntries(
|
|
||||||
Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId))
|
|
||||||
);
|
|
||||||
if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
detail_replacement: nextDetail,
|
|
||||||
sku_replacement: nextSku,
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
(currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") ||
|
|
||||||
latestReplacementResults.length === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardIds = currentReplacementExecutionCardIds;
|
|
||||||
if (cardIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setReplacementResultState((current) => {
|
|
||||||
const nextModeState = { ...current[currentResultModeType] };
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
latestReplacementResults.forEach((result, index) => {
|
const applyResult = (
|
||||||
const cardId = result.replacementCardId || cardIds[index];
|
modeType: ReplacementModeType,
|
||||||
|
cardId: string | null | undefined,
|
||||||
|
result: {
|
||||||
|
url: string | null;
|
||||||
|
text: string;
|
||||||
|
replacementCardId: string | null;
|
||||||
|
},
|
||||||
|
referenceCard?: {
|
||||||
|
imageUrl?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
} | null
|
||||||
|
) => {
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
||||||
|
const nextModeState = nextState[modeType];
|
||||||
const previousRecord = nextModeState[cardId];
|
const previousRecord = nextModeState[cardId];
|
||||||
const referenceCard =
|
|
||||||
currentResultModeType === "detail_replacement"
|
|
||||||
? detailReplacementReferenceCards.find((card) => card.id === cardId)
|
|
||||||
: skuReplacementReferenceCards.find((card) => card.id === cardId);
|
|
||||||
const resultUrl = result.url || null;
|
const resultUrl = result.url || null;
|
||||||
const previousUrl =
|
const previousUrl =
|
||||||
previousRecord?.url && previousRecord.url !== resultUrl
|
previousRecord?.url && previousRecord.url !== resultUrl
|
||||||
@ -4579,27 +4652,59 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
resultUrl,
|
resultUrl,
|
||||||
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
||||||
),
|
),
|
||||||
|
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||||
|
referencePreviewUrl:
|
||||||
|
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||||
|
label: referenceCard?.label || previousRecord?.label || null,
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
previousRecord?.url === nextRecord.url &&
|
previousRecord?.url === nextRecord.url &&
|
||||||
previousRecord?.text === nextRecord.text &&
|
previousRecord?.text === nextRecord.text &&
|
||||||
previousRecord?.status === nextRecord.status &&
|
previousRecord?.status === nextRecord.status &&
|
||||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
||||||
|
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
||||||
|
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
||||||
|
previousRecord?.label === nextRecord.label
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
nextModeState[cardId] = nextRecord;
|
nextModeState[cardId] = nextRecord;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
latestReplacementResults.forEach((result, index) => {
|
||||||
|
const explicitCardId = result.replacementCardId || null;
|
||||||
|
if (explicitCardId) {
|
||||||
|
const detailReferenceCard = detailReferenceCardById.get(explicitCardId);
|
||||||
|
if (detailReferenceCard) {
|
||||||
|
applyResult("detail_replacement", explicitCardId, result, detailReferenceCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuReferenceCard = skuReferenceCardById.get(explicitCardId);
|
||||||
|
if (skuReferenceCard) {
|
||||||
|
applyResult("sku_replacement", explicitCardId, result, skuReferenceCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fallbackModeType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCardId = currentReplacementExecutionCardIds[index];
|
||||||
|
const fallbackReferenceCard =
|
||||||
|
fallbackModeType === "detail_replacement"
|
||||||
|
? detailReferenceCardById.get(fallbackCardId)
|
||||||
|
: skuReferenceCardById.get(fallbackCardId);
|
||||||
|
applyResult(fallbackModeType, fallbackCardId, result, fallbackReferenceCard);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!changed) {
|
if (!changed) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return nextState;
|
||||||
...current,
|
|
||||||
[currentResultModeType]: nextModeState,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
currentReplacementExecutionCardIds,
|
currentReplacementExecutionCardIds,
|
||||||
@ -4609,8 +4714,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementReferenceCards,
|
skuReplacementReferenceCards,
|
||||||
]);
|
]);
|
||||||
const detailReplacementResultUrls = useMemo(
|
const detailReplacementResultUrls = useMemo(
|
||||||
() =>
|
() => {
|
||||||
detailReplacementReferenceCards
|
const urlsFromCards = detailReplacementReferenceCards
|
||||||
.map((card) => {
|
.map((card) => {
|
||||||
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
||||||
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
||||||
@ -4620,7 +4725,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
? nextUrl
|
? nextUrl
|
||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter((url): url is string => Boolean(url)),
|
.filter((url): url is string => Boolean(url));
|
||||||
|
const urlsFromRecords = Object.values(replacementResultState.detail_replacement)
|
||||||
|
.map((record) => record.url)
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
.filter(
|
||||||
|
(url) => !normalizedDeletedResultCollections.detail_replacement.includes(url)
|
||||||
|
);
|
||||||
|
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
||||||
|
},
|
||||||
[
|
[
|
||||||
detailReplacementReferenceCards,
|
detailReplacementReferenceCards,
|
||||||
normalizedDeletedResultCollections.detail_replacement,
|
normalizedDeletedResultCollections.detail_replacement,
|
||||||
@ -4628,15 +4741,21 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const skuReplacementResultUrls = useMemo(
|
const skuReplacementResultUrls = useMemo(
|
||||||
() =>
|
() => {
|
||||||
skuReplacementReferenceCards
|
const urlsFromCards = skuReplacementReferenceCards
|
||||||
.map((card) => {
|
.map((card) => {
|
||||||
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
||||||
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
||||||
? nextUrl
|
? nextUrl
|
||||||
: null;
|
: null;
|
||||||
})
|
})
|
||||||
.filter((url): url is string => Boolean(url)),
|
.filter((url): url is string => Boolean(url));
|
||||||
|
const urlsFromRecords = Object.values(replacementResultState.sku_replacement)
|
||||||
|
.map((record) => record.url)
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
.filter((url) => !normalizedDeletedResultCollections.sku_replacement.includes(url));
|
||||||
|
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
|
||||||
|
},
|
||||||
[
|
[
|
||||||
normalizedDeletedResultCollections.sku_replacement,
|
normalizedDeletedResultCollections.sku_replacement,
|
||||||
replacementResultState.sku_replacement,
|
replacementResultState.sku_replacement,
|
||||||
@ -4727,24 +4846,20 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
resultCollections?.detail_generation,
|
resultCollections?.detail_generation,
|
||||||
normalizedDeletedResultCollections.detail_generation
|
normalizedDeletedResultCollections.detail_generation
|
||||||
),
|
),
|
||||||
detail_replacement: Array.from(
|
detail_replacement:
|
||||||
new Set([
|
detailReplacementResultUrls.length > 0
|
||||||
...detailReplacementResultUrls,
|
? detailReplacementResultUrls
|
||||||
...excludeDeletedResultUrls(
|
: excludeDeletedResultUrls(
|
||||||
resultCollections?.detail_replacement,
|
resultCollections?.detail_replacement,
|
||||||
normalizedDeletedResultCollections.detail_replacement
|
normalizedDeletedResultCollections.detail_replacement
|
||||||
),
|
),
|
||||||
])
|
sku_replacement:
|
||||||
),
|
skuReplacementResultUrls.length > 0
|
||||||
sku_replacement: Array.from(
|
? skuReplacementResultUrls
|
||||||
new Set([
|
: excludeDeletedResultUrls(
|
||||||
...skuReplacementResultUrls,
|
|
||||||
...excludeDeletedResultUrls(
|
|
||||||
resultCollections?.sku_replacement,
|
resultCollections?.sku_replacement,
|
||||||
normalizedDeletedResultCollections.sku_replacement
|
normalizedDeletedResultCollections.sku_replacement
|
||||||
),
|
),
|
||||||
])
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -4950,6 +5065,52 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
skuReplacementBatchDownloadUrls.length > 0
|
skuReplacementBatchDownloadUrls.length > 0
|
||||||
? skuReplacementBatchDownloadUrls
|
? skuReplacementBatchDownloadUrls
|
||||||
: mergedResultCollections.sku_replacement;
|
: mergedResultCollections.sku_replacement;
|
||||||
|
const syncReplacementResultCollectionsToParent = useCallback(
|
||||||
|
(targetModeType?: ReplacementModeType) => {
|
||||||
|
if (!onResultPreviewUrlsChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncMode = (modeType: ReplacementModeType, liveUrls: string[]) => {
|
||||||
|
if (targetModeType && targetModeType !== modeType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedLiveUrls = normalizeResultCollectionUrls(liveUrls);
|
||||||
|
if (normalizedLiveUrls.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedUrlSet = new Set(
|
||||||
|
normalizedDeletedResultCollections[modeType]
|
||||||
|
);
|
||||||
|
const nextUrls = Array.from(
|
||||||
|
new Set(normalizedLiveUrls.filter((url) => !deletedUrlSet.has(url)))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!areResultUrlListsEqual(resultCollections?.[modeType], nextUrls)) {
|
||||||
|
onResultPreviewUrlsChange(modeType, nextUrls);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onReplacementResultStateChange?.(
|
||||||
|
normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
replacementResultState
|
||||||
|
)
|
||||||
|
);
|
||||||
|
syncMode("detail_replacement", detailReplacementResultUrls);
|
||||||
|
syncMode("sku_replacement", skuReplacementResultUrls);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
detailReplacementResultUrls,
|
||||||
|
normalizedDeletedResultCollections,
|
||||||
|
onReplacementResultStateChange,
|
||||||
|
onResultPreviewUrlsChange,
|
||||||
|
replacementResultState,
|
||||||
|
resultCollections,
|
||||||
|
skuReplacementResultUrls,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const detailRemainingCount = useMemo(
|
const detailRemainingCount = useMemo(
|
||||||
() =>
|
() =>
|
||||||
detailResultCards.filter(
|
detailResultCards.filter(
|
||||||
@ -5237,6 +5398,115 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}
|
}
|
||||||
}, [pendingGeneration, pendingGenerationProgressCount]);
|
}, [pendingGeneration, pendingGenerationProgressCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGeneration || !chatError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generationRequestSnapshotRef.current = {
|
||||||
|
hiddenPrompt: null,
|
||||||
|
replacementExecutionCardIds: null,
|
||||||
|
replacementRerunReferenceImages: null,
|
||||||
|
};
|
||||||
|
setPendingGeneration(null);
|
||||||
|
toast.error(chatError.message || "生成请求失败,请稍后重试");
|
||||||
|
clearError();
|
||||||
|
}, [chatError, clearError, pendingGeneration]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingGeneration) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - pendingGeneration.timestamp;
|
||||||
|
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
||||||
|
const clearStalePendingGeneration = () => {
|
||||||
|
generationRequestSnapshotRef.current = {
|
||||||
|
hiddenPrompt: null,
|
||||||
|
replacementExecutionCardIds: null,
|
||||||
|
replacementRerunReferenceImages: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
stop();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[AgentGenerationWorkspaceDarkV2] Failed to stop stale generation transport:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
transportGenerationStartedAtRef.current = null;
|
||||||
|
setPendingGeneration((current) => {
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
current.chatId !== pendingGeneration.chatId ||
|
||||||
|
current.timestamp !== pendingGeneration.timestamp
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
toast.warning("生成请求超过 30 分钟未回传,已结束处理中状态,可重新生成未回传结果。");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
clearStalePendingGeneration();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
|
}, [pendingGeneration, stop]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!transportIsGenerating) {
|
||||||
|
transportGenerationStartedAtRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transportGenerationStartedAtRef.current === null) {
|
||||||
|
transportGenerationStartedAtRef.current =
|
||||||
|
pendingGeneration?.timestamp || Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = transportGenerationStartedAtRef.current;
|
||||||
|
const elapsedMs = Date.now() - startedAt;
|
||||||
|
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
|
||||||
|
const abortStaleTransport = () => {
|
||||||
|
const currentStartedAt = transportGenerationStartedAtRef.current;
|
||||||
|
if (
|
||||||
|
currentStartedAt === null ||
|
||||||
|
Date.now() - currentStartedAt < AGENT_PENDING_GENERATION_EXPIRY_MS
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
generationRequestSnapshotRef.current = {
|
||||||
|
hiddenPrompt: null,
|
||||||
|
replacementExecutionCardIds: null,
|
||||||
|
replacementRerunReferenceImages: null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
stop();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[AgentGenerationWorkspaceDarkV2] Failed to abort stale generation transport:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
transportGenerationStartedAtRef.current = null;
|
||||||
|
setPendingGeneration(null);
|
||||||
|
toast.warning("生成请求超过 30 分钟未回传,已中断卡住连接,可重新生成未回传结果。");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
abortStaleTransport();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerId = window.setTimeout(abortStaleTransport, remainingMs);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
|
}, [pendingGeneration?.timestamp, stop, transportIsGenerating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingGeneration || transportIsGenerating) {
|
if (!pendingGeneration || transportIsGenerating) {
|
||||||
return;
|
return;
|
||||||
@ -5274,11 +5544,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onResultPreviewUrlsChange?.(
|
if (!onResultPreviewUrlsChange) {
|
||||||
currentResultModeType,
|
return;
|
||||||
mergedResultCollections[currentResultModeType] || []
|
}
|
||||||
);
|
|
||||||
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
|
([
|
||||||
|
"scene_generation",
|
||||||
|
"detail_generation",
|
||||||
|
"detail_replacement",
|
||||||
|
"sku_replacement",
|
||||||
|
] as AgentGenerationResultModeType[]).forEach((modeType) => {
|
||||||
|
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
|
||||||
|
});
|
||||||
|
}, [mergedResultCollections, onResultPreviewUrlsChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeResultModeUrls.length === 0) {
|
if (activeResultModeUrls.length === 0) {
|
||||||
@ -5420,6 +5698,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: string[];
|
imageUrls: string[];
|
||||||
zipName: string;
|
zipName: string;
|
||||||
scope: BatchDownloadScope;
|
scope: BatchDownloadScope;
|
||||||
|
modeType?: AgentGenerationResultModeType;
|
||||||
}) => {
|
}) => {
|
||||||
const normalizedUrls = (params.imageUrls || [])
|
const normalizedUrls = (params.imageUrls || [])
|
||||||
.map((item) => String(item || "").trim())
|
.map((item) => String(item || "").trim())
|
||||||
@ -5429,6 +5708,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
params.modeType === "detail_replacement" ||
|
||||||
|
params.modeType === "sku_replacement"
|
||||||
|
) {
|
||||||
|
syncReplacementResultCollectionsToParent(params.modeType);
|
||||||
|
}
|
||||||
|
const persisted = await Promise.resolve(onPersistProject?.() ?? true).catch(
|
||||||
|
() => false
|
||||||
|
);
|
||||||
|
if (!persisted) {
|
||||||
|
toast.info("结果已保留在当前页面,项目同步稍后会自动重试");
|
||||||
|
}
|
||||||
|
|
||||||
setBatchDownloadState(params.scope);
|
setBatchDownloadState(params.scope);
|
||||||
setBatchDownloadDialog({
|
setBatchDownloadDialog({
|
||||||
scope: params.scope,
|
scope: params.scope,
|
||||||
@ -5508,7 +5800,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
}));
|
}));
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [onPersistProject, syncReplacementResultCollectionsToParent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const taskId = batchDownloadDialog?.taskId;
|
const taskId = batchDownloadDialog?.taskId;
|
||||||
@ -5607,6 +5899,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = batchDownloadDialog.downloadUrl;
|
link.href = batchDownloadDialog.downloadUrl;
|
||||||
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.style.display = "none";
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
@ -5672,6 +5967,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
setReplacementResultState((current) => {
|
setReplacementResultState((current) => {
|
||||||
const currentModeState = current[params.modeType];
|
const currentModeState = current[params.modeType];
|
||||||
const previousRecord = currentModeState[params.cardId];
|
const previousRecord = currentModeState[params.cardId];
|
||||||
|
const referenceCard =
|
||||||
|
params.modeType === "detail_replacement"
|
||||||
|
? detailReplacementReferenceCards.find((card) => card.id === params.cardId)
|
||||||
|
: skuReplacementReferenceCards.find((card) => card.id === params.cardId);
|
||||||
const inferredStatus = inferReplacementCardStatusFromText(params.text);
|
const inferredStatus = inferReplacementCardStatusFromText(params.text);
|
||||||
const explicitCompareSourceUrl =
|
const explicitCompareSourceUrl =
|
||||||
params.compareSourceUrl === undefined
|
params.compareSourceUrl === undefined
|
||||||
@ -5692,13 +5991,20 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
explicitCompareSourceUrl === undefined
|
explicitCompareSourceUrl === undefined
|
||||||
? previousRecord?.compareSourceUrl || null
|
? previousRecord?.compareSourceUrl || null
|
||||||
: explicitCompareSourceUrl,
|
: explicitCompareSourceUrl,
|
||||||
|
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||||
|
referencePreviewUrl:
|
||||||
|
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||||
|
label: referenceCard?.label || previousRecord?.label || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousRecord?.url === nextRecord.url &&
|
previousRecord?.url === nextRecord.url &&
|
||||||
previousRecord?.text === nextRecord.text &&
|
previousRecord?.text === nextRecord.text &&
|
||||||
previousRecord?.status === nextRecord.status &&
|
previousRecord?.status === nextRecord.status &&
|
||||||
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl
|
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
|
||||||
|
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
|
||||||
|
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
|
||||||
|
previousRecord?.label === nextRecord.label
|
||||||
) {
|
) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
@ -5711,7 +6017,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, []);
|
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
||||||
const handleOpenReplacementTextEdit = useCallback((params: {
|
const handleOpenReplacementTextEdit = useCallback((params: {
|
||||||
modeType: ReplacementModeType;
|
modeType: ReplacementModeType;
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@ -6060,7 +6366,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
sendMessage({ role: "user", parts });
|
void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
|
||||||
|
generationRequestSnapshotRef.current = {
|
||||||
|
hiddenPrompt: null,
|
||||||
|
replacementExecutionCardIds: null,
|
||||||
|
replacementRerunReferenceImages: null,
|
||||||
|
};
|
||||||
|
setPendingGeneration(null);
|
||||||
|
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
|
||||||
|
});
|
||||||
if (selectedAgentPreset?.type !== "image") {
|
if (selectedAgentPreset?.type !== "image") {
|
||||||
setInput("");
|
setInput("");
|
||||||
}
|
}
|
||||||
@ -6647,15 +6961,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
: selectedModelId !== "auto"
|
: selectedModelId !== "auto"
|
||||||
? selectedModelId
|
? selectedModelId
|
||||||
: effectiveDefaultModelId || undefined;
|
: effectiveDefaultModelId || undefined;
|
||||||
|
const requestedImageSize: DetailResultLocalEditImageSize =
|
||||||
|
params.imageSize === "2K" ? "2K" : "1K";
|
||||||
|
|
||||||
setDetailLocalEditSubmittingId(cardId);
|
setDetailLocalEditSubmittingId(cardId);
|
||||||
setDetailLocalEditSubmittingMode(editMode);
|
setDetailLocalEditSubmittingMode(editMode);
|
||||||
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
|
toast.info(
|
||||||
|
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const submitResult = await imageGenerationService.localEdit({
|
const submitResult = await imageGenerationService.localEdit({
|
||||||
aspectRatio: "auto",
|
aspectRatio: "auto",
|
||||||
imageSize: "2K",
|
imageSize: requestedImageSize,
|
||||||
model:
|
model:
|
||||||
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
||||||
? resolvedLocalEditModelId
|
? resolvedLocalEditModelId
|
||||||
@ -6873,6 +7191,23 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
delete next[normalizedUrl];
|
delete next[normalizedUrl];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
|
||||||
|
setReplacementResultState((current) => {
|
||||||
|
const currentModeState = current[modeType];
|
||||||
|
const nextModeState = Object.fromEntries(
|
||||||
|
Object.entries(currentModeState).filter(
|
||||||
|
([, record]) => record.url !== normalizedUrl
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (Object.keys(nextModeState).length === Object.keys(currentModeState).length) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
[modeType]: nextModeState,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onDeleteResultImage(modeType, normalizedUrl);
|
onDeleteResultImage(modeType, normalizedUrl);
|
||||||
toast.success("结果图已删除");
|
toast.success("结果图已删除");
|
||||||
@ -9351,6 +9686,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: detailReplacementOrderedDownloadUrls,
|
imageUrls: detailReplacementOrderedDownloadUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
||||||
scope: "detail",
|
scope: "detail",
|
||||||
|
modeType: "detail_replacement",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "detail"}
|
disabled={batchDownloadState === "detail"}
|
||||||
@ -9368,6 +9704,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: mergedResultCollections.detail_generation,
|
imageUrls: mergedResultCollections.detail_generation,
|
||||||
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
||||||
scope: "detail",
|
scope: "detail",
|
||||||
|
modeType: "detail_generation",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "detail"}
|
disabled={batchDownloadState === "detail"}
|
||||||
@ -9385,6 +9722,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: skuReplacementOrderedDownloadUrls,
|
imageUrls: skuReplacementOrderedDownloadUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
||||||
scope: "sku",
|
scope: "sku",
|
||||||
|
modeType: "sku_replacement",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "sku"}
|
disabled={batchDownloadState === "sku"}
|
||||||
@ -9401,6 +9739,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
imageUrls: activeResultModeUrls,
|
imageUrls: activeResultModeUrls,
|
||||||
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
||||||
scope: "scene",
|
scope: "scene",
|
||||||
|
modeType: "scene_generation",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={batchDownloadState === "scene"}
|
disabled={batchDownloadState === "scene"}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, type RefObject } from "react";
|
import { useEffect, type RefObject } from "react";
|
||||||
|
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||||
|
|
||||||
export type AgentStyleSection =
|
export type AgentStyleSection =
|
||||||
| "brand"
|
| "brand"
|
||||||
@ -140,6 +141,9 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
chatId,
|
chatId,
|
||||||
projectId,
|
projectId,
|
||||||
}: UseAgentStylePerformanceMonitorParams) {
|
}: UseAgentStylePerformanceMonitorParams) {
|
||||||
|
const persistedChatId =
|
||||||
|
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
||||||
@ -200,7 +204,7 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
browser,
|
browser,
|
||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
projectId: projectId || null,
|
projectId: projectId || null,
|
||||||
chatId: chatId || null,
|
chatId: persistedChatId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -237,5 +241,5 @@ export function useAgentStylePerformanceMonitor({
|
|||||||
root.removeEventListener("keydown", markAction, true);
|
root.removeEventListener("keydown", markAction, true);
|
||||||
root.removeEventListener("scroll", markAction, true);
|
root.removeEventListener("scroll", markAction, true);
|
||||||
};
|
};
|
||||||
}, [chatId, projectId, rootRef]);
|
}, [persistedChatId, projectId, rootRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,7 +97,6 @@ import {
|
|||||||
createLocalEditRegionId,
|
createLocalEditRegionId,
|
||||||
getLocalEditRegionsFromMetadata,
|
getLocalEditRegionsFromMetadata,
|
||||||
getNormalizedRectOverlayStyle,
|
getNormalizedRectOverlayStyle,
|
||||||
hasScaleAdjustTargetChanged,
|
|
||||||
isHttpImageUrl,
|
isHttpImageUrl,
|
||||||
LOCAL_EDIT_COLORS,
|
LOCAL_EDIT_COLORS,
|
||||||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
||||||
@ -220,12 +219,6 @@ import {
|
|||||||
} from "@/lib/ai-wash/config";
|
} from "@/lib/ai-wash/config";
|
||||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||||
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
|
||||||
import type {
|
|
||||||
CanvasCapabilityClientAction,
|
|
||||||
SelectedImageToolbarActionKey,
|
|
||||||
} from "@/lib/canvas/capabilities/types";
|
|
||||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
|
||||||
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
|
|
||||||
import { cn, fetcher } from "@/lib/utils";
|
import { cn, fetcher } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
CanvasTextEditPayloadItem,
|
CanvasTextEditPayloadItem,
|
||||||
@ -288,6 +281,16 @@ interface CanvasNodeAnnotationDisplayItem {
|
|||||||
createdByAgent?: boolean;
|
createdByAgent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LayerSplitBounds {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
coverage: number;
|
||||||
|
}
|
||||||
|
|
||||||
const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
const AI_CLOUD_IMAGE_ACTIONS = new Set([
|
||||||
"ai-wash",
|
"ai-wash",
|
||||||
"rmbg",
|
"rmbg",
|
||||||
@ -436,11 +439,11 @@ function getCanvasScaleAdjustRegionPrompt(
|
|||||||
hasTargetPosition = false
|
hasTargetPosition = false
|
||||||
) {
|
) {
|
||||||
return [
|
return [
|
||||||
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。",
|
"按目标框线对框选范围内的主物体执行等比比例调整。",
|
||||||
hasTargetPosition
|
hasTargetPosition
|
||||||
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
|
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
|
||||||
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||||
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||||
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
@ -1473,10 +1476,7 @@ export function ImageNode({
|
|||||||
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
||||||
const selectedImageInlineActionCount =
|
const selectedImageInlineActionCount =
|
||||||
getSelectedImageInlineActionCount(canvasToolbarConfig);
|
getSelectedImageInlineActionCount(canvasToolbarConfig);
|
||||||
const selectedImageToolbarActionCandidates: ReadonlyArray<{
|
const selectedImageToolbarActionCandidates = [
|
||||||
key: SelectedImageToolbarActionKey;
|
|
||||||
enabled: boolean;
|
|
||||||
}> = [
|
|
||||||
{ key: "pickObject", enabled: imagePickObjectEnabled },
|
{ key: "pickObject", enabled: imagePickObjectEnabled },
|
||||||
{ key: "enhance", enabled: imageEnhanceEnabled },
|
{ key: "enhance", enabled: imageEnhanceEnabled },
|
||||||
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
|
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
|
||||||
@ -1496,7 +1496,9 @@ export function ImageNode({
|
|||||||
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
|
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
|
||||||
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
|
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
|
||||||
{ key: "note", enabled: imageNoteEnabled },
|
{ key: "note", enabled: imageNoteEnabled },
|
||||||
];
|
] as const;
|
||||||
|
type SelectedImageToolbarActionKey =
|
||||||
|
(typeof selectedImageToolbarActionCandidates)[number]["key"];
|
||||||
const enabledSelectedImageToolbarActions =
|
const enabledSelectedImageToolbarActions =
|
||||||
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
|
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
|
||||||
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
|
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||||||
@ -1799,7 +1801,8 @@ export function ImageNode({
|
|||||||
: "",
|
: "",
|
||||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||||
);
|
);
|
||||||
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
const shouldShowAiWashQuickModelSelector =
|
||||||
|
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
|
||||||
const effectiveAiWashQuickModelRef =
|
const effectiveAiWashQuickModelRef =
|
||||||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||||||
const selectedAiWashQuickModel = useMemo(
|
const selectedAiWashQuickModel = useMemo(
|
||||||
@ -1809,6 +1812,11 @@ export function ImageNode({
|
|||||||
null,
|
null,
|
||||||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||||||
);
|
);
|
||||||
|
const aiWashQuickHasExecutableModel = Boolean(
|
||||||
|
effectiveAiWashQuickModelRef &&
|
||||||
|
selectedAiWashQuickModel &&
|
||||||
|
selectedAiWashQuickModel.selectable !== false
|
||||||
|
);
|
||||||
const aiWashQuickPrompt = useMemo(
|
const aiWashQuickPrompt = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedAiWashQuickPreset
|
selectedAiWashQuickPreset
|
||||||
@ -2034,7 +2042,6 @@ export function ImageNode({
|
|||||||
>({});
|
>({});
|
||||||
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
|
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
|
||||||
const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
|
const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
|
||||||
const processedCapabilityCommandIdsRef = useRef(new Set<string>());
|
|
||||||
|
|
||||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||||
@ -2042,7 +2049,6 @@ export function ImageNode({
|
|||||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||||
const projectId = useCanvasStore((state) => state.projectId);
|
const projectId = useCanvasStore((state) => state.projectId);
|
||||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
|
||||||
const addPendingChatAttachment = useCanvasStore(
|
const addPendingChatAttachment = useCanvasStore(
|
||||||
(state) => state.addPendingChatAttachment
|
(state) => state.addPendingChatAttachment
|
||||||
);
|
);
|
||||||
@ -2145,7 +2151,6 @@ export function ImageNode({
|
|||||||
updateCurrentGeneratingTaskNode((currentData) => ({
|
updateCurrentGeneratingTaskNode((currentData) => ({
|
||||||
...currentData,
|
...currentData,
|
||||||
src: imageUrl,
|
src: imageUrl,
|
||||||
thumbnailUrl: image?.thumbnailUrl || imageUrl,
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
|
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
|
||||||
model: task.result?.model || task.result?.modelRef || currentData.model,
|
model: task.result?.model || task.result?.modelRef || currentData.model,
|
||||||
@ -2272,7 +2277,7 @@ export function ImageNode({
|
|||||||
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
||||||
});
|
});
|
||||||
const aiWashQuickCostShortLabel =
|
const aiWashQuickCostShortLabel =
|
||||||
pointsEnabled && selectedAiWashQuickModel
|
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
|
||||||
? formatImageNodeFeatureCostShortLabel({
|
? formatImageNodeFeatureCostShortLabel({
|
||||||
cost: selectedAiWashQuickModel.unitCost,
|
cost: selectedAiWashQuickModel.unitCost,
|
||||||
isHighVip,
|
isHighVip,
|
||||||
@ -2472,18 +2477,14 @@ export function ImageNode({
|
|||||||
);
|
);
|
||||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||||
const displayImageSource =
|
|
||||||
data.thumbnailUrl && !shouldUseHighFidelityDisplayImage
|
|
||||||
? data.thumbnailUrl
|
|
||||||
: data.src;
|
|
||||||
const displayImageSrc = useMemo(
|
const displayImageSrc = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildImageProxyUrl(displayImageSource, {
|
buildImageProxyUrl(data.src, {
|
||||||
width: displayImageWidth,
|
width: displayImageWidth,
|
||||||
quality: displayImageQuality,
|
quality: displayImageQuality,
|
||||||
format: "webp",
|
format: "webp",
|
||||||
}),
|
}),
|
||||||
[displayImageQuality, displayImageSource, displayImageWidth]
|
[data.src, displayImageQuality, displayImageWidth]
|
||||||
);
|
);
|
||||||
const fullscreenPreviewSrc = useMemo(() => {
|
const fullscreenPreviewSrc = useMemo(() => {
|
||||||
const originalUrl = resolveOriginalImageUrl(data.src || "");
|
const originalUrl = resolveOriginalImageUrl(data.src || "");
|
||||||
@ -4417,11 +4418,15 @@ export function ImageNode({
|
|||||||
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const unchangedRegion = validRegions.find(
|
const unchangedRegion = validRegions.find((region) => {
|
||||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
const regionScalePercent = deriveCanvasScalePercentFromRects(
|
||||||
|
region.rect,
|
||||||
|
region.targetRect
|
||||||
);
|
);
|
||||||
|
return Math.abs(regionScalePercent) < 1;
|
||||||
|
});
|
||||||
if (unchangedRegion) {
|
if (unchangedRegion) {
|
||||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
|
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -4555,7 +4560,6 @@ export function ImageNode({
|
|||||||
updateNode(newNodeId, {
|
updateNode(newNodeId, {
|
||||||
data: {
|
data: {
|
||||||
src: generatedUrl,
|
src: generatedUrl,
|
||||||
thumbnailUrl: generatedImage?.thumbnailUrl || generatedUrl,
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
prompt:
|
prompt:
|
||||||
task.result?.prompt ||
|
task.result?.prompt ||
|
||||||
@ -6048,6 +6052,35 @@ export function ImageNode({
|
|||||||
await detectTextItems(true);
|
await detectTextItems(true);
|
||||||
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearImageNodeActionRequest();
|
||||||
|
|
||||||
|
if (imageNodeActionRequest.action === "box-cutout") {
|
||||||
|
handleEnterBoxCutoutMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageNodeActionRequest.action === "text-edit") {
|
||||||
|
void handleOpenTextEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageNodeActionRequest.action === "local-edit") {
|
||||||
|
handleEnterLocalEditMode();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
clearImageNodeActionRequest,
|
||||||
|
handleEnterBoxCutoutMode,
|
||||||
|
handleEnterLocalEditMode,
|
||||||
|
handleOpenTextEdit,
|
||||||
|
imageNodeActionRequest,
|
||||||
|
node.id,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleTextItemChange = (id: string, value: string) => {
|
const handleTextItemChange = (id: string, value: string) => {
|
||||||
setTextEditItems((prev) =>
|
setTextEditItems((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
@ -6332,6 +6365,30 @@ export function ImageNode({
|
|||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取操作的中文名称
|
||||||
|
const getActionName = (action: string): string => {
|
||||||
|
const names: Record<string, string> = {
|
||||||
|
rmbg: "抠图",
|
||||||
|
watermark: "去印",
|
||||||
|
enhance: "增强",
|
||||||
|
extend: "扩图",
|
||||||
|
crop: "裁剪",
|
||||||
|
adjust: "调整",
|
||||||
|
"flip-rotate": "翻转与旋转",
|
||||||
|
resize: "调尺",
|
||||||
|
angle: "视角",
|
||||||
|
"layer-split": "图层拆分",
|
||||||
|
"scene-fusion": "批量处理",
|
||||||
|
annotate: "标注",
|
||||||
|
"text-edit": "改字",
|
||||||
|
note: "添加批注",
|
||||||
|
"prompt-regenerate": "重绘",
|
||||||
|
"box-cutout": "框选抠图",
|
||||||
|
"local-edit": "局部编辑",
|
||||||
|
};
|
||||||
|
return names[action] || action;
|
||||||
|
};
|
||||||
|
|
||||||
const openThreedGenerationDialog = useCallback(
|
const openThreedGenerationDialog = useCallback(
|
||||||
(mode: "model" | "world") => {
|
(mode: "model" | "world") => {
|
||||||
if (!data.src) {
|
if (!data.src) {
|
||||||
@ -6420,8 +6477,11 @@ export function ImageNode({
|
|||||||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!effectiveAiWashQuickModelRef) {
|
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
|
||||||
toast.error("请先选择一个可用的生图模型");
|
toast.error(
|
||||||
|
selectedAiWashQuickModel?.disabledReason ||
|
||||||
|
"当前 AI洗图模型不可用,请在后台重新配置模型"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isProcessing || isSubmittingAiWashQuick) {
|
if (isProcessing || isSubmittingAiWashQuick) {
|
||||||
@ -6477,7 +6537,7 @@ export function ImageNode({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt: aiWashQuickPrompt,
|
prompt: aiWashQuickPrompt,
|
||||||
referenceImage: data.src,
|
referenceImage: data.src,
|
||||||
selectedModelId: effectiveAiWashQuickModelRef,
|
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
|
||||||
imageSize: aiWashQuickConfig.imageSize,
|
imageSize: aiWashQuickConfig.imageSize,
|
||||||
promptExtend: aiWashQuickConfig.promptExtend,
|
promptExtend: aiWashQuickConfig.promptExtend,
|
||||||
title: selectedAiWashQuickPreset.title,
|
title: selectedAiWashQuickPreset.title,
|
||||||
@ -6574,6 +6634,7 @@ export function ImageNode({
|
|||||||
}, [
|
}, [
|
||||||
addNode,
|
addNode,
|
||||||
aiWashQuickConfig.enabled,
|
aiWashQuickConfig.enabled,
|
||||||
|
aiWashQuickConfig.hideModelSelector,
|
||||||
aiWashQuickConfig.imageSize,
|
aiWashQuickConfig.imageSize,
|
||||||
aiWashQuickConfig.promptExtend,
|
aiWashQuickConfig.promptExtend,
|
||||||
aiWashQuickLevels,
|
aiWashQuickLevels,
|
||||||
@ -6581,6 +6642,7 @@ export function ImageNode({
|
|||||||
aiWashQuickPrompt,
|
aiWashQuickPrompt,
|
||||||
data.src,
|
data.src,
|
||||||
displayTitle,
|
displayTitle,
|
||||||
|
aiWashQuickHasExecutableModel,
|
||||||
effectiveAiWashQuickModelRef,
|
effectiveAiWashQuickModelRef,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isSubmittingAiWashQuick,
|
isSubmittingAiWashQuick,
|
||||||
@ -6588,63 +6650,16 @@ export function ImageNode({
|
|||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
resetLocalEditState,
|
resetLocalEditState,
|
||||||
selectNode,
|
selectNode,
|
||||||
|
selectedAiWashQuickModel?.disabledReason,
|
||||||
selectedAiWashQuickModel?.displayName,
|
selectedAiWashQuickModel?.displayName,
|
||||||
|
selectedAiWashQuickModel?.id,
|
||||||
selectedAiWashQuickModel?.modelId,
|
selectedAiWashQuickModel?.modelId,
|
||||||
selectedAiWashQuickPreset,
|
selectedAiWashQuickPreset,
|
||||||
updateNode,
|
updateNode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const executeSharedToolbarCapability = async (
|
// 处理图片编辑操作 - 集成我们现有的 API
|
||||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
const handleAction = async (action: string) => {
|
||||||
) => {
|
|
||||||
if (!projectId) {
|
|
||||||
toast.error("画布还在加载,请稍后再试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capability = getCanvasCapabilityByClientAction(action);
|
|
||||||
if (!capability) {
|
|
||||||
toast.error("画布能力未注册");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server creates the result node after completion. Keep the source-node
|
|
||||||
// processing overlay visible while that detached result is pending.
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(true);
|
|
||||||
try {
|
|
||||||
const command = createCanvasCapabilityClientCommand({
|
|
||||||
capabilityId: capability.id,
|
|
||||||
nodeIds: [node.id],
|
|
||||||
projectId,
|
|
||||||
source: "toolbar",
|
|
||||||
});
|
|
||||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(command),
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (!response.ok || payload?.success !== true) {
|
|
||||||
throw new Error(payload?.error || `${capability.title}失败`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResult = await canvasService.getProject(projectId);
|
|
||||||
if (!projectResult.success || !projectResult.data?.canvasData) {
|
|
||||||
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
|
|
||||||
}
|
|
||||||
loadCanvas(projectResult.data.canvasData);
|
|
||||||
toast.success(`${capability.title}完成`);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : `${capability.title}失败`;
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
setIsCreatingDetachedResultNode(false);
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理图片编辑操作
|
|
||||||
const handleAction = async (action: CanvasCapabilityClientAction | "scene-fusion" | "annotate") => {
|
|
||||||
if (!data.src) {
|
if (!data.src) {
|
||||||
toast.error("请先上传图片");
|
toast.error("请先上传图片");
|
||||||
return;
|
return;
|
||||||
@ -6747,7 +6762,34 @@ export function ImageNode({
|
|||||||
toast.info("图层拆分功能未开启");
|
toast.info("图层拆分功能未开启");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await executeSharedToolbarCapability("layer-split");
|
setIsCreatingDetachedResultNode(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
const result = await imageGenerationService.splitImageLayers(data.src);
|
||||||
|
const layers = result.data?.layers || [];
|
||||||
|
|
||||||
|
if (!result.success || layers.length === 0) {
|
||||||
|
toast.error(result.error || "图层拆分失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedLayerCount = handleLayerSplitComplete(
|
||||||
|
layers.map((layer) => ({
|
||||||
|
originalUrl: layer.originalUrl,
|
||||||
|
title: layer.title,
|
||||||
|
layerIndex: layer.layerIndex,
|
||||||
|
totalLayers: layer.totalLayers,
|
||||||
|
bounds: layer.bounds ?? null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("图层拆分失败:", error);
|
||||||
|
toast.error("图层拆分失败");
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "box-cutout") {
|
if (action === "box-cutout") {
|
||||||
@ -6809,13 +6851,102 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const actionName = getActionName(action);
|
||||||
action === "rmbg" ||
|
|
||||||
action === "watermark" ||
|
setImageAdjustPanelOpen(false);
|
||||||
action === "enhance" ||
|
setCropPanelOpen(false);
|
||||||
action === "extend"
|
setCropInteraction(null);
|
||||||
) {
|
setFlipRotatePanelOpen(false);
|
||||||
await executeSharedToolbarCapability(action);
|
setAnglePanelOpen(false);
|
||||||
|
setIsCreatingDetachedResultNode(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
// 先创建一个"处理中"状态的节点,显示在原图右侧
|
||||||
|
const newNodeId = addNode({
|
||||||
|
type: "image",
|
||||||
|
position: {
|
||||||
|
x: node.position.x + node.size.width + 20,
|
||||||
|
y: node.position.y,
|
||||||
|
},
|
||||||
|
size: { width: node.size.width, height: node.size.height },
|
||||||
|
data: {
|
||||||
|
src: "", // 暂时没有图片
|
||||||
|
status: "generating",
|
||||||
|
prompt: `${actionName}处理中...`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (action) {
|
||||||
|
case "rmbg":
|
||||||
|
result = await imageGenerationService.removeBackground(data.src);
|
||||||
|
break;
|
||||||
|
case "watermark":
|
||||||
|
result = await imageGenerationService.removeWatermark(data.src);
|
||||||
|
break;
|
||||||
|
case "enhance":
|
||||||
|
result = await imageGenerationService.enhanceImage(data.src);
|
||||||
|
break;
|
||||||
|
case "extend":
|
||||||
|
result = await imageGenerationService.extendImage(data.src);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 删除创建的节点
|
||||||
|
if (newNodeId) deleteNode(newNodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.success && result.data?.url) {
|
||||||
|
// 更新节点为完成状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: result.data.url,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `${actionName}结果`,
|
||||||
|
title: `${displayTitle}-${actionName}`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(`${actionName}处理完成`);
|
||||||
|
} else {
|
||||||
|
// 更新节点为失败状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.error(result?.error || "处理失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${action} 处理失败:`, error);
|
||||||
|
// 更新节点为失败状态
|
||||||
|
if (newNodeId) {
|
||||||
|
updateNode(newNodeId, {
|
||||||
|
data: {
|
||||||
|
src: "",
|
||||||
|
status: "failed",
|
||||||
|
prompt: `${actionName}失败`,
|
||||||
|
title: `${displayTitle}-${actionName}-失败`,
|
||||||
|
originalSrc: data.src,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.error("处理失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -6859,16 +6990,7 @@ export function ImageNode({
|
|||||||
const action = customEvent.detail?.action;
|
const action = customEvent.detail?.action;
|
||||||
|
|
||||||
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
||||||
if (action === "scene-fusion" || action === "annotate") {
|
|
||||||
void handleAction(action);
|
void handleAction(action);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const capability = getCanvasCapabilityByClientAction(
|
|
||||||
action as CanvasCapabilityClientAction
|
|
||||||
);
|
|
||||||
if (capability) {
|
|
||||||
void handleAction(capability.clientAction);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
@ -6930,6 +7052,105 @@ export function ImageNode({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLayerSplitComplete = useCallback(
|
||||||
|
(
|
||||||
|
layers: Array<{
|
||||||
|
originalUrl: string;
|
||||||
|
title?: string;
|
||||||
|
layerIndex: number;
|
||||||
|
totalLayers: number;
|
||||||
|
bounds?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
originalWidth: number;
|
||||||
|
originalHeight: number;
|
||||||
|
coverage: number;
|
||||||
|
} | null;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
const validLayers = layers.filter(
|
||||||
|
(item) =>
|
||||||
|
typeof item?.originalUrl === "string" &&
|
||||||
|
item.originalUrl.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validLayers.length === 0) {
|
||||||
|
throw new Error("没有可添加到画布的图层结果");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => {
|
||||||
|
if (!bounds) return false;
|
||||||
|
return (
|
||||||
|
bounds.coverage >= 0.92 &&
|
||||||
|
bounds.width >= bounds.originalWidth - 2 &&
|
||||||
|
bounds.height >= bounds.originalHeight - 2
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundLayers = validLayers
|
||||||
|
.filter((layer) => isFullFrameLayer(layer.bounds))
|
||||||
|
.sort(
|
||||||
|
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
|
||||||
|
);
|
||||||
|
const subjectLayers = validLayers
|
||||||
|
.filter((layer) => !isFullFrameLayer(layer.bounds))
|
||||||
|
.sort(
|
||||||
|
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
|
||||||
|
);
|
||||||
|
const orderedLayers = [...backgroundLayers, ...subjectLayers];
|
||||||
|
const groupGap = 28;
|
||||||
|
const groupOffsetX = node.size.width + groupGap;
|
||||||
|
const groupOffsetY = 0;
|
||||||
|
|
||||||
|
orderedLayers.forEach((layer, index) => {
|
||||||
|
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
|
||||||
|
const bounds = layer.bounds;
|
||||||
|
const fullFrameLayer = isFullFrameLayer(bounds);
|
||||||
|
const targetPosition = {
|
||||||
|
x: node.position.x + groupOffsetX,
|
||||||
|
y: node.position.y + groupOffsetY,
|
||||||
|
};
|
||||||
|
const targetSize = { width: node.size.width, height: node.size.height };
|
||||||
|
|
||||||
|
addNode({
|
||||||
|
type: "image",
|
||||||
|
position: targetPosition,
|
||||||
|
size: targetSize,
|
||||||
|
data: {
|
||||||
|
src: layer.originalUrl,
|
||||||
|
status: "completed",
|
||||||
|
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
|
||||||
|
title: layerTitle,
|
||||||
|
originalSrc: data.src,
|
||||||
|
metadata: {
|
||||||
|
layerIndex: layer.layerIndex,
|
||||||
|
totalLayers: layer.totalLayers,
|
||||||
|
bounds: layer.bounds,
|
||||||
|
allowOverlap: true,
|
||||||
|
placementMode: "overlay",
|
||||||
|
layerSplitRole: fullFrameLayer ? "background" : "subject",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
connections: [node.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return orderedLayers.length;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
addNode,
|
||||||
|
data.src,
|
||||||
|
displayTitle,
|
||||||
|
node.id,
|
||||||
|
node.position.x,
|
||||||
|
node.position.y,
|
||||||
|
node.size.height,
|
||||||
|
node.size.width,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
// 图片标注完成回调
|
// 图片标注完成回调
|
||||||
const handleAnnotateComplete = async (
|
const handleAnnotateComplete = async (
|
||||||
annotatedImageUrl: string,
|
annotatedImageUrl: string,
|
||||||
@ -7892,20 +8113,9 @@ export function ImageNode({
|
|||||||
setIsCreatingDetachedResultNode(true);
|
setIsCreatingDetachedResultNode(true);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
|
|
||||||
sourceImageUrl,
|
|
||||||
"image"
|
|
||||||
);
|
|
||||||
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
|
|
||||||
throw new Error(uploadResult.error || "上传世界参考图失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
const providerId = uploadResult.data.providerId || null;
|
|
||||||
const providerName = uploadResult.data.providerName || null;
|
|
||||||
const createResult = await worldLabsGenerationService.createTask({
|
const createResult = await worldLabsGenerationService.createTask({
|
||||||
mode: "image",
|
mode: "image",
|
||||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
sourceUrl: sourceImageUrl,
|
||||||
providerId,
|
|
||||||
prompt: promptText || undefined,
|
prompt: promptText || undefined,
|
||||||
displayName: displayTitle || promptText || "画布图生世界",
|
displayName: displayTitle || promptText || "画布图生世界",
|
||||||
model: modelVersion,
|
model: modelVersion,
|
||||||
@ -7918,8 +8128,6 @@ export function ImageNode({
|
|||||||
|
|
||||||
const createAssetPayload = {
|
const createAssetPayload = {
|
||||||
...(createResult.data.assetPayload || {}),
|
...(createResult.data.assetPayload || {}),
|
||||||
providerId,
|
|
||||||
providerName,
|
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
};
|
};
|
||||||
updateNode(newNodeId, {
|
updateNode(newNodeId, {
|
||||||
@ -7961,8 +8169,8 @@ export function ImageNode({
|
|||||||
void pollCanvasWorld3DTask({
|
void pollCanvasWorld3DTask({
|
||||||
nodeId: newNodeId,
|
nodeId: newNodeId,
|
||||||
taskId: createResult.data.taskId,
|
taskId: createResult.data.taskId,
|
||||||
providerId,
|
providerId: createAssetPayload.providerId || null,
|
||||||
providerName,
|
providerName: createAssetPayload.providerName || null,
|
||||||
promptText,
|
promptText,
|
||||||
modelVersion,
|
modelVersion,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
@ -8159,78 +8367,6 @@ export function ImageNode({
|
|||||||
setDownloadProgress(null);
|
setDownloadProgress(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
processedCapabilityCommandIdsRef.current.has(
|
|
||||||
imageNodeActionRequest.requestId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
processedCapabilityCommandIdsRef.current.add(imageNodeActionRequest.requestId);
|
|
||||||
|
|
||||||
const executeCommand = async () => {
|
|
||||||
const { action, args } = imageNodeActionRequest;
|
|
||||||
|
|
||||||
if (action === "add-to-chat") {
|
|
||||||
await handleAddToChat();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "pick-object") {
|
|
||||||
handleStartPicking();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "rename") {
|
|
||||||
const requestedTitle = normalizeImageTitle(String(args.title || ""));
|
|
||||||
if (requestedTitle) {
|
|
||||||
updateNode(node.id, {
|
|
||||||
data: { ...data, title: requestedTitle },
|
|
||||||
});
|
|
||||||
setDraftTitle(requestedTitle);
|
|
||||||
toast.success("图片名称已更新");
|
|
||||||
} else {
|
|
||||||
setDraftTitle(displayTitle);
|
|
||||||
setIsEditingTitle(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "download") {
|
|
||||||
await handleDownload();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (action === "delete") {
|
|
||||||
if (imageNodeActionRequest.confirmed === true) {
|
|
||||||
deleteNode(node.id);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleAction(action);
|
|
||||||
};
|
|
||||||
|
|
||||||
void executeCommand().finally(() => {
|
|
||||||
processedCapabilityCommandIdsRef.current.delete(
|
|
||||||
imageNodeActionRequest.requestId
|
|
||||||
);
|
|
||||||
clearImageNodeActionRequest();
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
clearImageNodeActionRequest,
|
|
||||||
data,
|
|
||||||
deleteNode,
|
|
||||||
displayTitle,
|
|
||||||
handleAction,
|
|
||||||
handleAddToChat,
|
|
||||||
handleDownload,
|
|
||||||
handleStartPicking,
|
|
||||||
imageNodeActionRequest,
|
|
||||||
node.id,
|
|
||||||
updateNode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
|
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
|
||||||
if (boxCutoutMetrics) {
|
if (boxCutoutMetrics) {
|
||||||
return {
|
return {
|
||||||
@ -9831,7 +9967,7 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[10000] overflow-hidden rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white text-slate-950 shadow-[0_14px_36px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
|
||||||
data-canvas-no-drag="true"
|
data-canvas-no-drag="true"
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
onMouseDown={(event) => event.stopPropagation()}
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
@ -9845,21 +9981,21 @@ export function ImageNode({
|
|||||||
scrollbarWidth: "none",
|
scrollbarWidth: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-white/10">
|
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||||
<CanvasToolbarIcon
|
<CanvasToolbarIcon
|
||||||
className="h-4 w-4"
|
className="h-5 w-5"
|
||||||
fallback={Sparkles}
|
fallback={Sparkles}
|
||||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 truncate text-[14px] font-semibold">
|
<div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
|
||||||
{aiWashQuickModeLabel}
|
{aiWashQuickModeLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
|
||||||
onClick={() => setAiWashQuickPanelOpen(false)}
|
onClick={() => setAiWashQuickPanelOpen(false)}
|
||||||
title="关闭"
|
title="关闭"
|
||||||
type="button"
|
type="button"
|
||||||
@ -9869,20 +10005,20 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiWashConfigLoading ? (
|
{aiWashConfigLoading ? (
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2.5 text-[12px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
<div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
正在加载 AI洗图 配置...
|
正在加载 AI洗图 配置...
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-4 py-3">
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 text-[13px] font-semibold text-slate-950 dark:text-white">
|
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
目标
|
目标
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
|
||||||
{getAiWashStatusLabel(aiWashQuickStatus)}
|
{getAiWashStatusLabel(aiWashQuickStatus)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -9893,7 +10029,7 @@ export function ImageNode({
|
|||||||
}}
|
}}
|
||||||
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
<SelectValue placeholder="选择渲染目标" />
|
<SelectValue placeholder="选择渲染目标" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
@ -9911,8 +10047,8 @@ export function ImageNode({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-4 py-3">
|
||||||
<div className="mb-1.5 text-[13px] font-semibold text-slate-950 dark:text-white">
|
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||||
参数
|
参数
|
||||||
</div>
|
</div>
|
||||||
{shouldShowAiWashQuickModelSelector ? (
|
{shouldShowAiWashQuickModelSelector ? (
|
||||||
@ -9924,7 +10060,7 @@ export function ImageNode({
|
|||||||
onValueChange={setAiWashQuickModelId}
|
onValueChange={setAiWashQuickModelId}
|
||||||
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8">
|
<SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
|
||||||
<SelectValue placeholder="选择生图模型" />
|
<SelectValue placeholder="选择生图模型" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
|
||||||
@ -9960,16 +10096,9 @@ export function ImageNode({
|
|||||||
: "没有可选模型"}
|
: "没有可选模型"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : selectedAiWashQuickModel ? (
|
|
||||||
<div className="mb-3 rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 px-2.5 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
|
|
||||||
{selectedAiWashQuickModel.displayName}
|
|
||||||
{selectedAiWashQuickModel.unitCost > 0
|
|
||||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
|
|
||||||
: ""}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="-mx-3 divide-y divide-slate-100 dark:divide-white/10">
|
<div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
|
||||||
{aiWashQuickControls.map((control) => {
|
{aiWashQuickControls.map((control) => {
|
||||||
const selectedIndex =
|
const selectedIndex =
|
||||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||||
@ -9979,14 +10108,14 @@ export function ImageNode({
|
|||||||
null;
|
null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-3 py-2.5"
|
className="px-4 py-3"
|
||||||
key={control.id}
|
key={control.id}
|
||||||
>
|
>
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<div className="text-[13px] font-semibold">
|
<div className="text-[14px] font-semibold">
|
||||||
{control.label}
|
{control.label}
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-7 rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-center text-[10px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
<span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
|
||||||
{activeLevel?.label || selectedIndex + 1}
|
{activeLevel?.label || selectedIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -10012,13 +10141,13 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-3">
|
||||||
<Button
|
<Button
|
||||||
className="h-9 w-full bg-slate-950 text-[13px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
|
||||||
disabled={
|
disabled={
|
||||||
isSubmittingAiWashQuick ||
|
isSubmittingAiWashQuick ||
|
||||||
!selectedAiWashQuickPreset ||
|
!selectedAiWashQuickPreset ||
|
||||||
!effectiveAiWashQuickModelRef
|
!aiWashQuickHasExecutableModel
|
||||||
}
|
}
|
||||||
onClick={() => void handleSubmitAiWashQuick()}
|
onClick={() => void handleSubmitAiWashQuick()}
|
||||||
type="button"
|
type="button"
|
||||||
@ -10031,6 +10160,7 @@ export function ImageNode({
|
|||||||
{isSubmittingAiWashQuick
|
{isSubmittingAiWashQuick
|
||||||
? "生成中"
|
? "生成中"
|
||||||
: `${aiWashQuickModeLabel}${
|
: `${aiWashQuickModeLabel}${
|
||||||
|
!aiWashQuickConfig.hideModelSelector &&
|
||||||
selectedAiWashQuickModel?.unitCost
|
selectedAiWashQuickModel?.unitCost
|
||||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||||
: ""
|
: ""
|
||||||
@ -11546,7 +11676,7 @@ export function ImageNode({
|
|||||||
typeof document !== "undefined" &&
|
typeof document !== "undefined" &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed z-[9999] w-[340px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-950"
|
className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@ -11558,7 +11688,7 @@ export function ImageNode({
|
|||||||
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Type className="h-4 w-4 text-primary" />
|
<Type className="h-4 w-4 text-primary" />
|
||||||
<span className="text-[13px] font-medium">编辑文字</span>
|
<span className="font-medium text-sm">编辑文字</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
@ -11571,12 +11701,12 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
|
||||||
<p className="text-[11px] leading-4 text-muted-foreground">
|
<p className="text-muted-foreground text-xs">
|
||||||
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{isDetectingText && (
|
{isDetectingText && (
|
||||||
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
正在识别图片文字...
|
正在识别图片文字...
|
||||||
</div>
|
</div>
|
||||||
@ -11598,7 +11728,7 @@ export function ImageNode({
|
|||||||
|
|
||||||
{textEditItems.map((item, index) => (
|
{textEditItems.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
className="space-y-2 rounded-[var(--ui-radius-control)] border border-border/70 p-2.5"
|
className="space-y-2 rounded-lg border border-border/70 p-3"
|
||||||
key={item.id}
|
key={item.id}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export const hasScaleAdjustTargetChanged = (
|
|||||||
) => {
|
) => {
|
||||||
if (!targetRect) return false;
|
if (!targetRect) return false;
|
||||||
const minimumNormalizedChange = 0.001;
|
const minimumNormalizedChange = 0.001;
|
||||||
return (['x', 'y', 'width', 'height'] as const).some(
|
return (["x", "y", "width", "height"] as const).some(
|
||||||
(key) => Math.abs(targetRect[key] - sourceRect[key]) >= minimumNormalizedChange
|
(key) => Math.abs(targetRect[key] - sourceRect[key]) >= minimumNormalizedChange
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,316 +1,93 @@
|
|||||||
# World Labs 3D 世界生成中转接口兼容要求
|
# 3D 世界生成 API 调用规范
|
||||||
|
|
||||||
本文档用于说明我方系统接入 World Labs 3D 世界生成功能时,对第三方中转服务的接口兼容要求。
|
当前 3D 世界生成通过智枢外部任务接口调用 World Labs 能力。Base URL 只填写根地址:
|
||||||
|
|
||||||
## 核心原则
|
|
||||||
|
|
||||||
我方系统会按照 World Labs 官方 API 协议发起请求:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
{baseUrl} + /marble/v1/...
|
https://api.vip98k.com
|
||||||
```
|
```
|
||||||
|
|
||||||
中转服务需要兼容官方路径、请求体、响应体和错误格式。请不要要求我方把 `baseUrl` 配置成某个完整接口地址。
|
不要填写 `/v1`,也不要填写旧的 `/marble/v1`。
|
||||||
|
|
||||||
正确示例:
|
## 鉴权方式
|
||||||
|
|
||||||
```text
|
所有请求都必须携带本站发出的智枢 API Key:
|
||||||
http://your-proxy-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
错误示例:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://your-proxy-domain.com/marble/v1/worlds:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
## 我方请求方式
|
|
||||||
|
|
||||||
我方后台会在请求中携带:
|
|
||||||
|
|
||||||
```http
|
```http
|
||||||
WLT-Api-Key: {配置的 API Key}
|
Authorization: Bearer <API_KEY>
|
||||||
Accept: application/json
|
Accept: application/json
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
中转服务应支持 `WLT-Api-Key` 请求头。如果中转服务内部自行管理官方 Key,也可以将该字段作为租户标识或鉴权凭据,但不应要求我方改成其他 header。
|
不要使用管理员 Key、其他平台 Key,也不要使用旧的 `WLT-Api-Key` 作为主链路鉴权。
|
||||||
|
|
||||||
## 必须兼容的接口
|
## 模型目录
|
||||||
|
|
||||||
### 1. 准备上传素材
|
正式创建任务前,可以先确认这把 Key 是否开通空间生成分组:
|
||||||
|
|
||||||
```http
|
```bash
|
||||||
POST {baseUrl}/marble/v1/media-assets:prepare_upload
|
curl "https://api.vip98k.com/api/external/task/models" \
|
||||||
|
-H "Authorization: Bearer <API_KEY>"
|
||||||
```
|
```
|
||||||
|
|
||||||
请求体示例:
|
返回内容里应能找到:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"file_name": "input.jpg",
|
"modelId": "world-generation"
|
||||||
"extension": "jpg",
|
|
||||||
"kind": "image",
|
|
||||||
"metadata": {
|
|
||||||
"mime_type": "image/jpeg",
|
|
||||||
"uploaded_from": "canvas_world_mode"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
响应必须包含可用的上传信息:
|
如果找不到,说明这把 Key 没有勾选空间生成分组。
|
||||||
|
|
||||||
|
## 创建任务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://api.vip98k.com/api/external/task/generate" \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: Bearer <API_KEY>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "world-generation",
|
||||||
|
"displayName": "Test scene",
|
||||||
|
"prompt": "A quiet sunlit courtyard with a small stone fountain."
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
正常会先返回 `accepted` 或 `running`,这不是失败:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"media_asset": {},
|
"data": {
|
||||||
"upload_info": {
|
"taskId": "任务ID",
|
||||||
"upload_url": "https://...",
|
"status": "accepted",
|
||||||
"upload_method": "PUT",
|
|
||||||
"required_headers": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
- `upload_url` 必须可由我方服务器直接访问。
|
|
||||||
- `upload_method` 如为 `PUT`,必须支持 `PUT` 上传文件二进制。
|
|
||||||
- `required_headers` 中返回的 header,我方会原样带上。
|
|
||||||
- 上传成功时,上传地址必须返回 `2xx`。
|
|
||||||
|
|
||||||
### 2. 创建世界生成任务
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST {baseUrl}/marble/v1/worlds:generate
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display_name": "",
|
|
||||||
"model": "marble-1.1",
|
|
||||||
"permission": {
|
|
||||||
"public": false
|
|
||||||
},
|
|
||||||
"world_prompt": {
|
|
||||||
"type": "image",
|
|
||||||
"image_prompt": {
|
|
||||||
"source": "media_asset",
|
|
||||||
"media_asset_id": "media_asset_id_here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
响应需兼容官方 operation 结构,至少包含:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operation_id": "task-id",
|
|
||||||
"done": false,
|
"done": false,
|
||||||
"metadata": {
|
"resultPath": "/api/external/task/result?taskId=任务ID"
|
||||||
"progress": {
|
|
||||||
"status": "RUNNING"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
要求:
|
## 查询结果
|
||||||
|
|
||||||
- `operation_id` 必须稳定,后续查询状态必须使用同一个 id。
|
```bash
|
||||||
- 不要在创建成功但任务未完成时返回业务失败。
|
curl "https://api.vip98k.com/api/external/task/result?taskId=<任务ID>" \
|
||||||
- 如果余额不足、Key 无效,应返回明确 HTTP 状态码和错误信息。
|
-H "Authorization: Bearer <API_KEY>"
|
||||||
|
|
||||||
### 3. 查询任务状态
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET {baseUrl}/marble/v1/operations/{operation_id}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
运行中响应示例:
|
建议每隔 5 到 10 秒查询一次,最长等待 10 分钟。直到返回:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"operation_id": "task-id",
|
"data": {
|
||||||
"done": false,
|
"status": "succeeded",
|
||||||
"metadata": {
|
"done": true
|
||||||
"progress": {
|
|
||||||
"status": "RUNNING",
|
|
||||||
"description": "World generation in progress"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
成功响应示例:
|
## 常见错误
|
||||||
|
|
||||||
```json
|
- 不能用 `/v1/chat/completions`。
|
||||||
{
|
- 不能把模型写成 `marble-1.1`。
|
||||||
"operation_id": "task-id",
|
- 不能把 `accepted` 或 `running` 当作调用失败。
|
||||||
"done": true,
|
- 不能只等待几十秒就判定超时。
|
||||||
"error": null,
|
- 必须携带 `Authorization: Bearer <API_KEY>`。
|
||||||
"metadata": {
|
|
||||||
"progress": {
|
|
||||||
"status": "SUCCEEDED"
|
|
||||||
},
|
|
||||||
"world_id": "world-id"
|
|
||||||
},
|
|
||||||
"response": {
|
|
||||||
"world_id": "world-id",
|
|
||||||
"world_marble_url": "https://marble.worldlabs.ai/world/...",
|
|
||||||
"assets": {
|
|
||||||
"mesh": {
|
|
||||||
"collider_mesh_url": "https://cdn.marble.worldlabs.ai/.../model.glb"
|
|
||||||
},
|
|
||||||
"imagery": {
|
|
||||||
"pano_url": "https://cdn.marble.worldlabs.ai/.../rgb_0.png"
|
|
||||||
},
|
|
||||||
"thumbnail_url": "https://cdn.marble.worldlabs.ai/.../thumbnail.webp",
|
|
||||||
"splats": {
|
|
||||||
"spz_urls": {
|
|
||||||
"100k": "https://cdn.marble.worldlabs.ai/...100k.spz",
|
|
||||||
"150k": "https://cdn.marble.worldlabs.ai/...150k.spz",
|
|
||||||
"500k": "https://cdn.marble.worldlabs.ai/...500k.spz",
|
|
||||||
"full_res": "https://cdn.marble.worldlabs.ai/...full.spz"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
失败响应示例:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operation_id": "task-id",
|
|
||||||
"done": true,
|
|
||||||
"error": {
|
|
||||||
"message": "Insufficient account balance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
- `done=false` 表示任务仍在运行。
|
|
||||||
- `done=true` 且 `error=null` 表示任务成功。
|
|
||||||
- `done=true` 且 `error.message` 存在表示任务失败。
|
|
||||||
- 不要把运行中的任务返回为 HTTP 404。
|
|
||||||
- 不要因为任务未完成就返回 `Operation not found`。
|
|
||||||
|
|
||||||
### 4. 查询世界详情
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET {baseUrl}/marble/v1/worlds/{world_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
当 `operations` 成功响应中资产不完整时,我方会调用该接口补充详情。响应应包含与 `response` 内 world 对象兼容的数据结构。
|
|
||||||
|
|
||||||
## 错误码要求
|
|
||||||
|
|
||||||
建议按以下语义返回:
|
|
||||||
|
|
||||||
| 场景 | HTTP 状态码 | message 示例 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| API Key 缺失或无效 | `401` | `Unauthorized` |
|
|
||||||
| 权限不足 | `403` | `Forbidden` |
|
|
||||||
| 余额不足 | `402` 或 `403` | `Insufficient account balance` |
|
|
||||||
| 请求体错误 | `400` | `Invalid request body` |
|
|
||||||
| 任务不存在 | `404` | `Operation not found` |
|
|
||||||
| 上游超时 | `504` | `Upstream timeout` |
|
|
||||||
| 上游异常 | `502` | `Upstream error` |
|
|
||||||
|
|
||||||
响应体建议:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "Insufficient account balance"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": {
|
|
||||||
"message": "Insufficient account balance"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 超时与稳定性要求
|
|
||||||
|
|
||||||
- `prepare_upload` 建议 30 秒内返回。
|
|
||||||
- `worlds:generate` 建议 60 秒内返回 operation,而不是等待生成完成。
|
|
||||||
- `operations/{id}` 建议 10 秒内返回当前状态。
|
|
||||||
- 生成任务本身可异步运行,前端会轮询状态。
|
|
||||||
- 同一个 `operation_id` 多次查询必须返回同一个任务状态,不应随机切换账号或路由。
|
|
||||||
|
|
||||||
## 上传地址要求
|
|
||||||
|
|
||||||
`prepare_upload` 返回的 `upload_url` 可以是官方签名地址,也可以是中转服务自己的上传地址,但必须满足:
|
|
||||||
|
|
||||||
- 我方服务器可直接访问。
|
|
||||||
- 支持返回的 `upload_method`。
|
|
||||||
- 支持上传原始文件二进制。
|
|
||||||
- 上传成功返回 `2xx`。
|
|
||||||
- 上传完成后,后续 `worlds:generate` 使用的 `media_asset_id` 必须可用。
|
|
||||||
|
|
||||||
## 安全要求
|
|
||||||
|
|
||||||
- 不要在错误信息中返回完整官方 API Key。
|
|
||||||
- 日志中建议只记录 Key 前后少量字符,例如 `sk-****abcd`。
|
|
||||||
- 中转服务如保存官方 Key,应加密存储。
|
|
||||||
- 上传 URL 如有签名,应设置合理过期时间。
|
|
||||||
- 不要允许用户通过 `baseUrl` 或 path 注入访问内网地址。
|
|
||||||
|
|
||||||
## 我方验收用例
|
|
||||||
|
|
||||||
中转服务上线前,请至少通过以下链路:
|
|
||||||
|
|
||||||
1. `POST /marble/v1/media-assets:prepare_upload` 返回 `media_asset_id` 和可上传 URL。
|
|
||||||
2. 使用返回的 `upload_url` 上传图片,返回 `2xx`。
|
|
||||||
3. `POST /marble/v1/worlds:generate` 返回稳定 `operation_id`。
|
|
||||||
4. `GET /marble/v1/operations/{operation_id}` 在运行中返回 `done=false`。
|
|
||||||
5. 任务完成后返回 `done=true`、`error=null`、`world_id` 和资产 URL。
|
|
||||||
6. 资产 URL 中至少应包含 thumbnail、pano、mesh 或 spz 中的一种,最好完整返回。
|
|
||||||
7. `GET /marble/v1/worlds/{world_id}` 可返回完整 world 详情。
|
|
||||||
|
|
||||||
## 配置示例
|
|
||||||
|
|
||||||
我方系统中的 API 地址应填写中转根地址:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://23.169.168.133:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
最终我方会请求:
|
|
||||||
|
|
||||||
```text
|
|
||||||
http://23.169.168.133:8080/marble/v1/media-assets:prepare_upload
|
|
||||||
http://23.169.168.133:8080/marble/v1/worlds:generate
|
|
||||||
http://23.169.168.133:8080/marble/v1/operations/{operation_id}
|
|
||||||
http://23.169.168.133:8080/marble/v1/worlds/{world_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
如果切回官方接口,配置为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://api.worldlabs.ai
|
|
||||||
```
|
|
||||||
|
|
||||||
最终请求:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload
|
|
||||||
https://api.worldlabs.ai/marble/v1/worlds:generate
|
|
||||||
https://api.worldlabs.ai/marble/v1/operations/{operation_id}
|
|
||||||
https://api.worldlabs.ai/marble/v1/worlds/{world_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
中转服务需要做的是兼容 World Labs 官方 API 协议,而不是要求我方修改接口路径。只要中转根地址完整支持 `/marble/v1/...`,我方系统就可以在官方接口和中转接口之间无缝切换。
|
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import {
|
|||||||
} from "@/lib/agents/workflow-material-config";
|
} from "@/lib/agents/workflow-material-config";
|
||||||
import type {
|
import type {
|
||||||
AgentGenerationProjectPersistedState,
|
AgentGenerationProjectPersistedState,
|
||||||
|
AgentGenerationProjectReplacementResultState,
|
||||||
AgentGenerationProjectResultCollections,
|
AgentGenerationProjectResultCollections,
|
||||||
AgentGenerationResultModeType,
|
AgentGenerationResultModeType,
|
||||||
|
AgentGenerationReplacementResultStatus,
|
||||||
AgentGenerationProjectWorkspaceState,
|
AgentGenerationProjectWorkspaceState,
|
||||||
AgentWorkflowPersistedState,
|
AgentWorkflowPersistedState,
|
||||||
} from "@/types/agent";
|
} from "@/types/agent";
|
||||||
@ -58,6 +60,7 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
|
|||||||
resultImageUrls: [],
|
resultImageUrls: [],
|
||||||
resultCollections: {},
|
resultCollections: {},
|
||||||
deletedResultCollections: {},
|
deletedResultCollections: {},
|
||||||
|
replacementResultState: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,6 +89,129 @@ function normalizeResultCollections(value: unknown): AgentGenerationProjectResul
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLACEMENT_RESULT_STATUSES = new Set<AgentGenerationReplacementResultStatus>([
|
||||||
|
"completed",
|
||||||
|
"passthrough",
|
||||||
|
"fallback",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function normalizeOptionalHttpUrl(value: unknown) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
return /^https?:\/\//i.test(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReplacementResultStatus(value: unknown): AgentGenerationReplacementResultStatus {
|
||||||
|
return REPLACEMENT_RESULT_STATUSES.has(value as AgentGenerationReplacementResultStatus)
|
||||||
|
? (value as AgentGenerationReplacementResultStatus)
|
||||||
|
: "completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
value: unknown
|
||||||
|
): AgentGenerationProjectReplacementResultState {
|
||||||
|
const raw =
|
||||||
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const normalizeModeState = (input: unknown) => {
|
||||||
|
const records =
|
||||||
|
input && typeof input === "object" && !Array.isArray(input)
|
||||||
|
? (input as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const entries: Array<
|
||||||
|
readonly [
|
||||||
|
string,
|
||||||
|
NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>[string],
|
||||||
|
]
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
Object.entries(records).forEach(([rawCardId, rawRecord]) => {
|
||||||
|
const cardId = String(rawCardId || "").trim();
|
||||||
|
if (!cardId || !rawRecord || typeof rawRecord !== "object" || Array.isArray(rawRecord)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = rawRecord as Record<string, unknown>;
|
||||||
|
const resultUrl = normalizeOptionalHttpUrl(record.url);
|
||||||
|
const text = String(record.text || "").trim();
|
||||||
|
const compareSourceUrl = normalizeOptionalHttpUrl(record.compareSourceUrl);
|
||||||
|
const referenceImageUrl = normalizeOptionalHttpUrl(record.referenceImageUrl);
|
||||||
|
const referencePreviewUrl = normalizeOptionalHttpUrl(record.referencePreviewUrl);
|
||||||
|
const label = String(record.label || "").trim() || null;
|
||||||
|
const updatedAt = Number(record.updatedAt);
|
||||||
|
|
||||||
|
if (!resultUrl && !text && !compareSourceUrl && !referenceImageUrl && !referencePreviewUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.push([
|
||||||
|
cardId.slice(0, 160),
|
||||||
|
{
|
||||||
|
url: resultUrl,
|
||||||
|
text,
|
||||||
|
status: normalizeReplacementResultStatus(record.status),
|
||||||
|
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? Math.round(updatedAt) : 0,
|
||||||
|
compareSourceUrl,
|
||||||
|
referenceImageUrl,
|
||||||
|
referencePreviewUrl,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.fromEntries(entries.slice(0, 256));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail_replacement: normalizeModeState(raw.detail_replacement),
|
||||||
|
sku_replacement: normalizeModeState(raw.sku_replacement),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReplacementResultUrls(
|
||||||
|
value:
|
||||||
|
| NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>
|
||||||
|
| undefined
|
||||||
|
| null
|
||||||
|
) {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
Object.values(value || {})
|
||||||
|
.map((record) => String(record?.url || "").trim())
|
||||||
|
.filter((url) => /^https?:\/\//i.test(url))
|
||||||
|
)
|
||||||
|
).slice(0, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileReplacementResultCollections(params: {
|
||||||
|
resultCollections: AgentGenerationProjectResultCollections;
|
||||||
|
replacementResultState: AgentGenerationProjectReplacementResultState;
|
||||||
|
}) {
|
||||||
|
const detailReplacementUrls = getReplacementResultUrls(
|
||||||
|
params.replacementResultState.detail_replacement
|
||||||
|
);
|
||||||
|
const skuReplacementUrls = getReplacementResultUrls(
|
||||||
|
params.replacementResultState.sku_replacement
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detailReplacementUrls.length === 0 && skuReplacementUrls.length === 0) {
|
||||||
|
return params.resultCollections;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...params.resultCollections,
|
||||||
|
detail_replacement:
|
||||||
|
detailReplacementUrls.length > 0
|
||||||
|
? detailReplacementUrls
|
||||||
|
: params.resultCollections.detail_replacement,
|
||||||
|
sku_replacement:
|
||||||
|
skuReplacementUrls.length > 0
|
||||||
|
? skuReplacementUrls
|
||||||
|
: params.resultCollections.sku_replacement,
|
||||||
|
} satisfies AgentGenerationProjectResultCollections;
|
||||||
|
}
|
||||||
|
|
||||||
function inferLegacyResultModeType(
|
function inferLegacyResultModeType(
|
||||||
workflowState: AgentWorkflowPersistedState,
|
workflowState: AgentWorkflowPersistedState,
|
||||||
resultUrls: string[]
|
resultUrls: string[]
|
||||||
@ -287,12 +413,18 @@ export function normalizeAgentGenerationProjectPersistedState(
|
|||||||
).slice(0, 64)
|
).slice(0, 64)
|
||||||
: [];
|
: [];
|
||||||
const workflowState = normalizeWorkflowState(raw.workflowState);
|
const workflowState = normalizeWorkflowState(raw.workflowState);
|
||||||
const resultCollections = normalizeMigratedResultCollections({
|
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
|
||||||
|
const replacementResultState = normalizeAgentGenerationProjectReplacementResultState(
|
||||||
|
raw.replacementResultState
|
||||||
|
);
|
||||||
|
const resultCollections = reconcileReplacementResultCollections({
|
||||||
|
replacementResultState,
|
||||||
|
resultCollections: normalizeMigratedResultCollections({
|
||||||
workflowState,
|
workflowState,
|
||||||
resultImageUrls,
|
resultImageUrls,
|
||||||
resultCollections: normalizeResultCollections(raw.resultCollections),
|
resultCollections: normalizeResultCollections(raw.resultCollections),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflowState,
|
workflowState,
|
||||||
@ -300,5 +432,6 @@ export function normalizeAgentGenerationProjectPersistedState(
|
|||||||
resultImageUrls,
|
resultImageUrls,
|
||||||
resultCollections,
|
resultCollections,
|
||||||
deletedResultCollections,
|
deletedResultCollections,
|
||||||
|
replacementResultState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
|||||||
midjourney: "https://your-midjourney-api.example.com",
|
midjourney: "https://your-midjourney-api.example.com",
|
||||||
"fal-video": "https://fal.run",
|
"fal-video": "https://fal.run",
|
||||||
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
|
||||||
"worldlabs-3d": "https://api.worldlabs.ai",
|
"worldlabs-3d": "https://api.vip98k.com",
|
||||||
"kittycad-cad": "https://api.zoo.dev",
|
"kittycad-cad": "https://api.zoo.dev",
|
||||||
runway: "https://api.dev.runwayml.com",
|
runway: "https://api.dev.runwayml.com",
|
||||||
krea: "https://api.krea.ai",
|
krea: "https://api.krea.ai",
|
||||||
|
|||||||
@ -56,12 +56,14 @@ interface QueueState {
|
|||||||
nextBatchEligibleAt: Map<string, number>;
|
nextBatchEligibleAt: Map<string, number>;
|
||||||
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
|
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||||
awaitingNextBatch: Set<string>;
|
awaitingNextBatch: Set<string>;
|
||||||
|
lastStaleRunningCleanupAt: number;
|
||||||
draining: boolean;
|
draining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
|
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
|
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
|
||||||
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
|
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
|
||||||
const PENDING_LOOKAHEAD_LIMIT = 100;
|
const PENDING_LOOKAHEAD_LIMIT = 100;
|
||||||
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
|
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
|
||||||
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
|
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
|
||||||
@ -82,6 +84,7 @@ function getQueueState(): QueueState {
|
|||||||
nextBatchEligibleAt: new Map<string, number>(),
|
nextBatchEligibleAt: new Map<string, number>(),
|
||||||
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||||
awaitingNextBatch: new Set<string>(),
|
awaitingNextBatch: new Set<string>(),
|
||||||
|
lastStaleRunningCleanupAt: 0,
|
||||||
draining: false,
|
draining: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -394,6 +397,48 @@ async function claimNextPendingTask(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markStaleDatabaseRunningTasksFailed() {
|
||||||
|
const state = getQueueState();
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - state.lastStaleRunningCleanupAt < STALE_RUNNING_CLEANUP_INTERVAL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.lastStaleRunningCleanupAt = now;
|
||||||
|
|
||||||
|
const localRunningIds = Array.from(state.running.keys());
|
||||||
|
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
|
||||||
|
const staleWhere =
|
||||||
|
localRunningIds.length > 0
|
||||||
|
? and(
|
||||||
|
eq(canvasImageTask.status, "running"),
|
||||||
|
lt(canvasImageTask.startedAt, staleBefore),
|
||||||
|
notInArray(canvasImageTask.id, localRunningIds)
|
||||||
|
)
|
||||||
|
: and(
|
||||||
|
eq(canvasImageTask.status, "running"),
|
||||||
|
lt(canvasImageTask.startedAt, staleBefore)
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedAt = new Date(now);
|
||||||
|
const staleTasks = await db
|
||||||
|
.update(canvasImageTask)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
error: "图片生成任务已超时,请重新发起生成",
|
||||||
|
completedAt,
|
||||||
|
updatedAt: completedAt,
|
||||||
|
})
|
||||||
|
.where(staleWhere)
|
||||||
|
.returning({ id: canvasImageTask.id });
|
||||||
|
|
||||||
|
if (staleTasks.length > 0) {
|
||||||
|
console.warn("[Canvas Image Task Queue] Marked stale running tasks failed:", {
|
||||||
|
count: staleTasks.length,
|
||||||
|
taskIds: staleTasks.slice(0, 10).map((task) => task.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runSingleTask(entry: PendingTaskEntry) {
|
async function runSingleTask(entry: PendingTaskEntry) {
|
||||||
try {
|
try {
|
||||||
const [task] = await db
|
const [task] = await db
|
||||||
@ -485,6 +530,7 @@ export async function drainCanvasImageTaskQueue() {
|
|||||||
|
|
||||||
state.draining = true;
|
state.draining = true;
|
||||||
try {
|
try {
|
||||||
|
await markStaleDatabaseRunningTasksFailed();
|
||||||
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
|
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
|
||||||
while (true) {
|
while (true) {
|
||||||
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
|
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
|
||||||
|
|||||||
@ -40,7 +40,7 @@ const providerBaseUrlDefaults = [
|
|||||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||||
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
|
||||||
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" },
|
{ type: "worldlabs-3d", baseUrl: "https://api.vip98k.com" },
|
||||||
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
|
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
|
||||||
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
||||||
{ type: "krea", baseUrl: "https://api.krea.ai" },
|
{ type: "krea", baseUrl: "https://api.krea.ai" },
|
||||||
|
|||||||
@ -89,7 +89,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
|||||||
{
|
{
|
||||||
id: "world-standard",
|
id: "world-standard",
|
||||||
displayName: "标准质量",
|
displayName: "标准质量",
|
||||||
realModelId: "marble-1.1",
|
realModelId: "world-generation",
|
||||||
description: "默认世界生成质量,适合大多数空间创建。",
|
description: "默认世界生成质量,适合大多数空间创建。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
@ -97,7 +97,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
|||||||
{
|
{
|
||||||
id: "world-fidelity",
|
id: "world-fidelity",
|
||||||
displayName: "高保真",
|
displayName: "高保真",
|
||||||
realModelId: "marble-1.1-plus",
|
realModelId: "world-generation",
|
||||||
description: "更高保真与细节表现,适合高质量空间生成。",
|
description: "更高保真与细节表现,适合高质量空间生成。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
@ -105,7 +105,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
|||||||
{
|
{
|
||||||
id: "world-compatible",
|
id: "world-compatible",
|
||||||
displayName: "兼容质量",
|
displayName: "兼容质量",
|
||||||
realModelId: "marble-1.0",
|
realModelId: "world-generation",
|
||||||
description: "兼容旧版世界任务和历史工作流。",
|
description: "兼容旧版世界任务和历史工作流。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
@ -113,7 +113,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
|||||||
{
|
{
|
||||||
id: "world-draft",
|
id: "world-draft",
|
||||||
displayName: "快速草稿",
|
displayName: "快速草稿",
|
||||||
realModelId: "marble-1.0-draft",
|
realModelId: "world-generation",
|
||||||
description: "更适合快速预览与草稿生成。",
|
description: "更适合快速预览与草稿生成。",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
|
|||||||
@ -10,7 +10,8 @@ type WorldLabsRequestOptions = {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
|
export const DEFAULT_WORLDLABS_BASE_URL = "https://api.vip98k.com";
|
||||||
|
export const WORLDLABS_EXTERNAL_TASK_MODEL = "world-generation";
|
||||||
const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000;
|
const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000;
|
||||||
|
|
||||||
function toIntMs(value: string | undefined) {
|
function toIntMs(value: string | undefined) {
|
||||||
@ -66,6 +67,17 @@ function normalizeBaseUrl(baseUrl?: string) {
|
|||||||
return resolved.replace(/\/+$/, "");
|
return resolved.replace(/\/+$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWorldLabsExternalTaskApi(baseUrl?: string | null) {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl || "");
|
||||||
|
try {
|
||||||
|
const parsed = new URL(normalized);
|
||||||
|
const hostname = parsed.hostname.toLowerCase();
|
||||||
|
return hostname === "api.vip98k.com" || normalized.includes("/api/external/task");
|
||||||
|
} catch {
|
||||||
|
return normalized.includes("api.vip98k.com") || normalized.includes("/api/external/task");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeApiKey(value: string): string {
|
function sanitizeApiKey(value: string): string {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.replace(/[\u2028\u2029]/g, "")
|
.replace(/[\u2028\u2029]/g, "")
|
||||||
@ -122,7 +134,11 @@ export async function worldLabsRequest<T = unknown>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isWorldLabsExternalTaskApi(config.baseUrl)) {
|
||||||
|
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
|
||||||
|
} else {
|
||||||
headers.set("WLT-Api-Key", normalizedApiKey);
|
headers.set("WLT-Api-Key", normalizedApiKey);
|
||||||
|
}
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
let body: BodyInit | undefined;
|
let body: BodyInit | undefined;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config";
|
export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config";
|
||||||
export const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
|
export const DEFAULT_WORLDLABS_BASE_URL = "https://api.vip98k.com";
|
||||||
|
|
||||||
export type WorldLabsKeyPoolConfig = {
|
export type WorldLabsKeyPoolConfig = {
|
||||||
importToken: string;
|
importToken: string;
|
||||||
|
|||||||
@ -4,9 +4,8 @@ import {
|
|||||||
getAiProviderById,
|
getAiProviderById,
|
||||||
getAiProvidersByType,
|
getAiProvidersByType,
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
|
import { DEFAULT_WORLDLABS_BASE_URL } from "./client";
|
||||||
import type { WorldLabsProviderConfig } from "./types";
|
import type { WorldLabsProviderConfig } from "./types";
|
||||||
|
|
||||||
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
|
|
||||||
let worldLabsProviderCursor = 0;
|
let worldLabsProviderCursor = 0;
|
||||||
|
|
||||||
export function sanitizeWorldLabsApiKey(value: string): string {
|
export function sanitizeWorldLabsApiKey(value: string): string {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
export type WorldLabsModel =
|
export type WorldLabsModel =
|
||||||
|
| "world-generation"
|
||||||
| "marble-1.0-draft"
|
| "marble-1.0-draft"
|
||||||
| "marble-1.0"
|
| "marble-1.0"
|
||||||
| "marble-1.1"
|
| "marble-1.1"
|
||||||
@ -19,8 +20,11 @@ export interface WorldLabsMediaAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorldLabsUploadInfo {
|
export interface WorldLabsUploadInfo {
|
||||||
upload_url: string;
|
upload_url?: string;
|
||||||
upload_method: string;
|
uploadUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
upload_method?: string;
|
||||||
|
method?: string;
|
||||||
required_headers?: Record<string, string> | null;
|
required_headers?: Record<string, string> | null;
|
||||||
curl_example?: string | null;
|
curl_example?: string | null;
|
||||||
}
|
}
|
||||||
@ -75,3 +79,37 @@ export interface WorldLabsProviderConfig {
|
|||||||
providerId?: string;
|
providerId?: string;
|
||||||
providerName?: string;
|
providerName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorldLabsExternalTaskCreateData {
|
||||||
|
taskId?: string;
|
||||||
|
task_id?: string;
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
done?: boolean;
|
||||||
|
resultPath?: string;
|
||||||
|
result_path?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldLabsExternalTaskResultData {
|
||||||
|
taskId?: string;
|
||||||
|
task_id?: string;
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
done?: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
errorMessage?: string;
|
||||||
|
message?: string;
|
||||||
|
result?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldLabsExternalTaskResponse<T = unknown> {
|
||||||
|
data?: T;
|
||||||
|
requestId?: string;
|
||||||
|
request_id?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -1144,10 +1144,12 @@ export const worldLabsGenerationService = {
|
|||||||
): Promise<
|
): Promise<
|
||||||
ApiResponse<{
|
ApiResponse<{
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
mediaAsset?: Record<string, any>;
|
mediaAsset?: Record<string, any>;
|
||||||
uploadInfo?: Record<string, any>;
|
uploadInfo?: Record<string, any>;
|
||||||
providerId?: string | null;
|
providerId?: string | null;
|
||||||
providerName?: string | null;
|
providerName?: string | null;
|
||||||
|
uploadFallback?: string | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
@ -1175,10 +1177,12 @@ export const worldLabsGenerationService = {
|
|||||||
): Promise<
|
): Promise<
|
||||||
ApiResponse<{
|
ApiResponse<{
|
||||||
mediaAssetId?: string;
|
mediaAssetId?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
mediaAsset?: Record<string, any>;
|
mediaAsset?: Record<string, any>;
|
||||||
uploadInfo?: Record<string, any>;
|
uploadInfo?: Record<string, any>;
|
||||||
providerId?: string | null;
|
providerId?: string | null;
|
||||||
providerName?: string | null;
|
providerName?: string | null;
|
||||||
|
uploadFallback?: string | null;
|
||||||
}>
|
}>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true
|
||||||
},
|
},
|
||||||
@ -31,7 +37,18 @@
|
|||||||
"next.config.js",
|
"next.config.js",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
".next-build/types/**/*.ts",
|
".next-build/types/**/*.ts",
|
||||||
".next-build/dev/types/**/*.ts"
|
".next-build/dev/types/**/*.ts",
|
||||||
|
".next-vip/types/**/*.ts",
|
||||||
|
".next-vip/dev/types/**/*.ts",
|
||||||
|
".next-build-hicad-20260728-135607/types/**/*.ts",
|
||||||
|
".next-build-hicad-20260728-135607/dev/types/**/*.ts",
|
||||||
|
".next-build-hicad-20260728-140320/types/**/*.ts",
|
||||||
|
".next-build-hicad-20260728-140320/dev/types/**/*.ts",
|
||||||
|
".next-candidate-home-banner-20260804-bg/types/**/*.ts",
|
||||||
|
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts",
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules", "services/pascal-editor"]
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"services/pascal-editor"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -495,6 +495,34 @@ export interface AgentGenerationProjectResultCollections {
|
|||||||
sku_replacement?: string[];
|
sku_replacement?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentGenerationReplacementModeType = Extract<
|
||||||
|
AgentGenerationResultModeType,
|
||||||
|
"detail_replacement" | "sku_replacement"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AgentGenerationReplacementResultStatus =
|
||||||
|
| "completed"
|
||||||
|
| "passthrough"
|
||||||
|
| "fallback";
|
||||||
|
|
||||||
|
export interface AgentGenerationReplacementResultRecord {
|
||||||
|
url: string | null;
|
||||||
|
text: string;
|
||||||
|
status: AgentGenerationReplacementResultStatus;
|
||||||
|
updatedAt: number;
|
||||||
|
compareSourceUrl?: string | null;
|
||||||
|
referenceImageUrl?: string | null;
|
||||||
|
referencePreviewUrl?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentGenerationProjectReplacementResultState = Partial<
|
||||||
|
Record<
|
||||||
|
AgentGenerationReplacementModeType,
|
||||||
|
Record<string, AgentGenerationReplacementResultRecord>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
export interface AgentGenerationProjectWorkspaceState {
|
export interface AgentGenerationProjectWorkspaceState {
|
||||||
input: string;
|
input: string;
|
||||||
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
|
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
|
||||||
@ -530,6 +558,7 @@ export interface AgentGenerationProjectPersistedState {
|
|||||||
resultImageUrls?: string[];
|
resultImageUrls?: string[];
|
||||||
resultCollections?: AgentGenerationProjectResultCollections;
|
resultCollections?: AgentGenerationProjectResultCollections;
|
||||||
deletedResultCollections?: AgentGenerationProjectResultCollections;
|
deletedResultCollections?: AgentGenerationProjectResultCollections;
|
||||||
|
replacementResultState?: AgentGenerationProjectReplacementResultState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentGenerationProject {
|
export interface AgentGenerationProject {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user