Compare commits
1 Commits
main
...
backup/ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ebd93f2c1 |
@ -1633,7 +1633,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
"fal-video": "https://fal.run",
|
||||
"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",
|
||||
runway: "https://api.dev.runwayml.com",
|
||||
krea: "https://api.krea.ai",
|
||||
|
||||
@ -5745,6 +5745,7 @@ export async function POST(request: Request) {
|
||||
);
|
||||
const assistantMessageId = generateUUID();
|
||||
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
@ -5783,6 +5784,14 @@ export async function POST(request: Request) {
|
||||
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(
|
||||
writer,
|
||||
@ -5810,6 +5819,7 @@ export async function POST(request: Request) {
|
||||
Array.from(replacementReferences.entries()),
|
||||
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
|
||||
async ([referenceIndex, reference]) => {
|
||||
const itemStartedAt = Date.now();
|
||||
if (reference.passthrough === true) {
|
||||
const referenceSourceImageUrls = Array.from(
|
||||
new Set([
|
||||
@ -5830,9 +5840,29 @@ export async function POST(request: Request) {
|
||||
streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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({
|
||||
modeType: "detail_replacement",
|
||||
replacementStrategy: detailReplacementStrategy,
|
||||
@ -5847,10 +5877,12 @@ export async function POST(request: Request) {
|
||||
planningRuntime: replacementScenePlanningRuntime,
|
||||
models: replacementVisionModels,
|
||||
});
|
||||
const planningElapsedMs = Date.now() - planningStartedAt;
|
||||
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
|
||||
referenceIndex: referenceIndex + 1,
|
||||
totalReferences: replacementReferences.length,
|
||||
referenceLabel: reference.label,
|
||||
planningElapsedMs,
|
||||
shouldReplace: replacementExecutionPlan.shouldReplace,
|
||||
reason: replacementExecutionPlan.reason,
|
||||
detectionSource: replacementExecutionPlan.detectionSource,
|
||||
@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
|
||||
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
|
||||
});
|
||||
|
||||
let generationStartedAt = 0;
|
||||
let generationElapsedMs: number | null = null;
|
||||
let normalizeStartedAt = 0;
|
||||
let normalizeElapsedMs: number | null = null;
|
||||
try {
|
||||
generationStartedAt = Date.now();
|
||||
const result = await generateCanvasImage({
|
||||
prompt: replacementPrompt,
|
||||
negativePrompt: effectiveImageParams?.negativePrompt,
|
||||
@ -6000,6 +6037,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
resolvedModelDisplayName = result.model || resolvedModelDisplayName;
|
||||
const firstImage = result.images[0];
|
||||
if (!firstImage) {
|
||||
@ -6009,6 +6047,7 @@ export async function POST(request: Request) {
|
||||
let finalImage = firstImage;
|
||||
let finalImageSizeLabel: string = resolvedImageSize;
|
||||
try {
|
||||
normalizeStartedAt = Date.now();
|
||||
const normalizedImage = await normalizeReplacementResultImage({
|
||||
generatedImageUrl: firstImage.originalUrl,
|
||||
generatedThumbnailUrl: firstImage.thumbnailUrl,
|
||||
@ -6022,7 +6061,11 @@ export async function POST(request: Request) {
|
||||
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
|
||||
};
|
||||
finalImageSizeLabel = normalizedImage.imageSize;
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
} catch (normalizeError) {
|
||||
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
|
||||
normalizeElapsedMs = Date.now() - normalizeStartedAt;
|
||||
}
|
||||
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
|
||||
referenceLabel: reference.label,
|
||||
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({
|
||||
reference,
|
||||
image: finalImage,
|
||||
@ -6051,9 +6110,16 @@ export async function POST(request: Request) {
|
||||
});
|
||||
successCount += 1;
|
||||
} catch (replacementItemError) {
|
||||
if (generationStartedAt > 0 && generationElapsedMs === null) {
|
||||
generationElapsedMs = Date.now() - generationStartedAt;
|
||||
}
|
||||
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
|
||||
referenceIndex,
|
||||
referenceLabel: reference.label,
|
||||
elapsedMs: Date.now() - itemStartedAt,
|
||||
planningElapsedMs,
|
||||
generationElapsedMs,
|
||||
normalizeElapsedMs,
|
||||
error: replacementItemError,
|
||||
});
|
||||
await emitReplacementResultMessage({
|
||||
@ -6073,6 +6139,30 @@ export async function POST(request: Request) {
|
||||
});
|
||||
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 {
|
||||
stopKeepalive();
|
||||
|
||||
@ -83,7 +83,7 @@ const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
|
||||
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
|
||||
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
|
||||
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 RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
|
||||
const KREA_BASE_URL = "https://api.krea.ai";
|
||||
|
||||
@ -192,23 +192,53 @@ export async function PUT(
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// 创建新画布(使用指定 ID)
|
||||
const [newProject] = await db
|
||||
.insert(canvasProject)
|
||||
.values({
|
||||
id, // 使用指定的 ID
|
||||
userId: session.user.id,
|
||||
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
|
||||
description,
|
||||
workspaceType: workspaceType || "canvas",
|
||||
canvasData: syncedCanvasData || {
|
||||
const nextWorkspaceType = workspaceType || "canvas";
|
||||
const nextCanvasData = syncedCanvasData || {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
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();
|
||||
|
||||
if (!newProject) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '画布ID已被占用' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, data: newProject });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
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 type {
|
||||
WorldLabsExternalTaskResponse,
|
||||
WorldLabsExternalTaskResultData,
|
||||
WorldLabsOperation,
|
||||
WorldLabsWorld,
|
||||
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 {
|
||||
params: Promise<{ taskId: string }>;
|
||||
}
|
||||
@ -452,6 +667,187 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
});
|
||||
providerForLog = config.providerId || null;
|
||||
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, {
|
||||
method: "GET",
|
||||
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,
|
||||
|
||||
@ -12,13 +12,23 @@ import {
|
||||
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
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 {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
reportWorldLabsProviderRuntimeFailure,
|
||||
} 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 maxDuration = 180;
|
||||
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
|
||||
makePublic?: boolean;
|
||||
reconstructImages?: boolean;
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
mediaAssets?: Array<{
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
azimuth?: number | null;
|
||||
}>;
|
||||
};
|
||||
@ -58,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
|
||||
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) {
|
||||
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) {
|
||||
const textPrompt = readOptionalTrimmedText(body.prompt);
|
||||
|
||||
@ -80,14 +171,20 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
|
||||
if (mode === "image") {
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("图生世界缺少 mediaAssetId");
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
|
||||
}
|
||||
return {
|
||||
type: "image",
|
||||
image_prompt: {
|
||||
image_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
...(body.disableRecaption !== undefined
|
||||
@ -102,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
const multiImagePrompt = mediaAssets
|
||||
.map((item) => {
|
||||
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
|
||||
if (!mediaAssetId) return null;
|
||||
const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) return null;
|
||||
const azimuth =
|
||||
item?.azimuth === null || item?.azimuth === undefined
|
||||
? null
|
||||
@ -111,9 +209,14 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
: null;
|
||||
return {
|
||||
...(azimuth === null ? {} : { azimuth }),
|
||||
content: {
|
||||
content: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
};
|
||||
})
|
||||
@ -137,15 +240,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
|
||||
}
|
||||
|
||||
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
|
||||
if (!mediaAssetId) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId");
|
||||
const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
|
||||
if (!mediaAssetId && !sourceUrl) {
|
||||
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "video",
|
||||
video_prompt: {
|
||||
video_prompt: mediaAssetId
|
||||
? {
|
||||
source: "media_asset",
|
||||
media_asset_id: mediaAssetId,
|
||||
}
|
||||
: {
|
||||
source: "uri",
|
||||
uri: sourceUrl,
|
||||
},
|
||||
...(textPrompt ? { text_prompt: textPrompt } : {}),
|
||||
};
|
||||
@ -187,12 +296,29 @@ export async function POST(request: Request) {
|
||||
);
|
||||
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 shouldChargePoints = shouldCharge3DPoints(mode);
|
||||
const unitCost = get3DWorldCreditCost(
|
||||
pointsConfig,
|
||||
resolvedWorldModel || "marble-1.1",
|
||||
);
|
||||
const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
|
||||
|
||||
if (
|
||||
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 usedProvider = null as Awaited<
|
||||
ReturnType<typeof getWorldLabsProviderConfig>
|
||||
> | null;
|
||||
let usedExternalTaskApi = false;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
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, {
|
||||
method: "POST",
|
||||
path: "/marble/v1/worlds:generate",
|
||||
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
|
||||
json: {
|
||||
display_name: displayName,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
model: officialMarbleModel,
|
||||
permission: {
|
||||
public: body.makePublic === true,
|
||||
},
|
||||
world_prompt: worldPrompt,
|
||||
},
|
||||
});
|
||||
}
|
||||
usedProvider = candidate;
|
||||
usedExternalTaskApi = useExternalTaskApi;
|
||||
providerForLog = candidate.providerId || null;
|
||||
break;
|
||||
} catch (error) {
|
||||
@ -292,12 +427,18 @@ export async function POST(request: Request) {
|
||||
|
||||
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
|
||||
try {
|
||||
const usedBillingModel = usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel;
|
||||
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: worldModelDisplayName;
|
||||
await consumeUserPoints({
|
||||
userId: session.user.id,
|
||||
amount: unitCost,
|
||||
reason: "world3d_generation",
|
||||
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`,
|
||||
note: `3D世界生成消耗积分(${worldModelDisplayName})`,
|
||||
modelId: `worldlabs-3d:${usedBillingModel}`,
|
||||
note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("points consume failed", error, {
|
||||
@ -313,10 +454,13 @@ export async function POST(request: Request) {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
taskIdLength: taskId.length,
|
||||
mode,
|
||||
model: resolvedWorldModel || "marble-1.1",
|
||||
model: usedExternalTaskApi
|
||||
? WORLDLABS_EXTERNAL_TASK_MODEL
|
||||
: officialMarbleModel,
|
||||
providerId: usedProvider.providerId || null,
|
||||
status: result.done ? "success" : "running",
|
||||
unitCost,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -327,6 +471,8 @@ export async function POST(request: Request) {
|
||||
assetPayload: {
|
||||
providerId: usedProvider.providerId || null,
|
||||
providerName: usedProvider.providerName || null,
|
||||
externalTaskApi: usedExternalTaskApi,
|
||||
marbleApi: !usedExternalTaskApi,
|
||||
},
|
||||
raw: result,
|
||||
},
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
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 {
|
||||
getWorldLabsProviderConfig,
|
||||
getWorldLabsProviderConfigs,
|
||||
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
|
||||
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) {
|
||||
return (
|
||||
error instanceof WorldLabsApiError &&
|
||||
@ -202,24 +281,24 @@ export async function POST(request: Request) {
|
||||
);
|
||||
|
||||
const mediaAssetId = normalizeMediaAssetId(prepareUpload);
|
||||
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim();
|
||||
const { uploadUrl, uploadMethod, requiredHeaders } =
|
||||
resolvePrepareUploadDetails(prepareUpload);
|
||||
if (!mediaAssetId || !uploadUrl) {
|
||||
throw new Error("World Labs 返回的上传信息不完整");
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (!headerName || headerValue === undefined || headerValue === null) continue;
|
||||
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(
|
||||
prepareUpload.upload_info?.upload_method || "PUT"
|
||||
).toUpperCase();
|
||||
const normalizedUploadMethod = uploadMethod.toUpperCase();
|
||||
const uploadResponse = await fetch(uploadUrl, {
|
||||
method: uploadMethod === "PUT" ? "PUT" : uploadMethod,
|
||||
method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
|
||||
headers: uploadHeaders,
|
||||
body: file,
|
||||
cache: "no-store",
|
||||
@ -252,6 +331,28 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (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)) {
|
||||
await reportWorldLabsProviderRuntimeFailure(
|
||||
config.providerId,
|
||||
|
||||
@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||
import { db } from "@/lib/db";
|
||||
import { clientPerformanceEvent } from "@/lib/db/schema";
|
||||
|
||||
@ -167,9 +168,12 @@ export async function POST(request: Request) {
|
||||
console.warn("[ClientPerformance] prune failed", error);
|
||||
});
|
||||
|
||||
const persistedChatId =
|
||||
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
|
||||
|
||||
await db.insert(clientPerformanceEvent).values({
|
||||
userId: session?.user?.id || null,
|
||||
chatId: event.chatId || null,
|
||||
chatId: persistedChatId,
|
||||
projectId: clampText(event.projectId, 64),
|
||||
eventType: clampText(event.eventType, 64) || "unknown",
|
||||
section: event.section || "unknown",
|
||||
|
||||
@ -141,6 +141,7 @@ import {
|
||||
createEmptyAgentGenerationProjectWorkspaceState,
|
||||
createEmptyAgentWorkflowPersistedState,
|
||||
normalizeAgentGenerationProjectPersistedState,
|
||||
normalizeAgentGenerationProjectReplacementResultState,
|
||||
} from "@/lib/agents/generation-project-state";
|
||||
import {
|
||||
createEmptyAgentModeWorkspaceState,
|
||||
@ -192,11 +193,6 @@ import {
|
||||
sanitizeAgentWorkflowSelections,
|
||||
} from "@/lib/agents/workflow-ui-config";
|
||||
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 {
|
||||
buildVideoWorkflowCanvasNode,
|
||||
getVideoWorkflowNodeDefinition,
|
||||
@ -232,6 +228,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
|
||||
import type {
|
||||
AgentGenerationProject,
|
||||
AgentGenerationProjectPersistedState,
|
||||
AgentGenerationProjectReplacementResultState,
|
||||
AgentGenerationProjectResultCollections,
|
||||
AgentGenerationProjectWorkspaceAttachment,
|
||||
AgentGenerationProjectWorkspaceState,
|
||||
@ -316,7 +313,7 @@ const CanvasWorkspace = dynamic(
|
||||
|
||||
const CanvasChatPanel = dynamic(
|
||||
() =>
|
||||
loadCanvasChatPanelModule().then(
|
||||
import("@/components/canvas/CanvasChatPanel").then(
|
||||
(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_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
|
||||
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:";
|
||||
|
||||
function getAgentProjectDraftStorageKey(projectId: string) {
|
||||
@ -1981,7 +2029,7 @@ export default function CanvasEditorLegacyPage({
|
||||
);
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
|
||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||
useState(FLOATING_PANEL_WIDTH);
|
||||
const [
|
||||
@ -1990,7 +2038,8 @@ export default function CanvasEditorLegacyPage({
|
||||
] = useState(false);
|
||||
const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
|
||||
useState(false);
|
||||
const areSecondaryPanelsReady = true;
|
||||
const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
|
||||
useState(false);
|
||||
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
|
||||
useState(false);
|
||||
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
|
||||
@ -2092,6 +2141,10 @@ export default function CanvasEditorLegacyPage({
|
||||
agentProjectDeletedResultCollections,
|
||||
setAgentProjectDeletedResultCollections,
|
||||
] = useState<AgentGenerationProjectResultCollections>({});
|
||||
const [
|
||||
agentProjectReplacementResultState,
|
||||
setAgentProjectReplacementResultState,
|
||||
] = useState<AgentGenerationProjectReplacementResultState>({});
|
||||
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
|
||||
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
|
||||
string | null
|
||||
@ -3295,13 +3348,11 @@ export default function CanvasEditorLegacyPage({
|
||||
}, [checkCanvasAuthSession]);
|
||||
|
||||
useEffect(() => {
|
||||
preloadCanvasChatPanel();
|
||||
preloadCanvasChatData(id);
|
||||
|
||||
return scheduleCanvasIdleTask(() => {
|
||||
void import("@/components/canvas/CanvasFileListPanel");
|
||||
}, 700);
|
||||
}, [id]);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setAreSecondaryPanelsReady(true);
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const hydrateCanvasProject = useCallback(
|
||||
(project: CanvasInitialProject) => {
|
||||
@ -3466,6 +3517,7 @@ export default function CanvasEditorLegacyPage({
|
||||
setAgentProjectThumbnail(null);
|
||||
setAgentProjectResultCollections({});
|
||||
setAgentProjectDeletedResultCollections({});
|
||||
setAgentProjectReplacementResultState({});
|
||||
setLoadedAgentProjectId(null);
|
||||
}
|
||||
|
||||
@ -3957,8 +4009,15 @@ export default function CanvasEditorLegacyPage({
|
||||
...resultCollections.sku_replacement,
|
||||
])
|
||||
),
|
||||
replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
|
||||
agentProjectReplacementResultState
|
||||
),
|
||||
};
|
||||
}, [agentProjectDeletedResultCollections, agentProjectResultCollections]);
|
||||
}, [
|
||||
agentProjectDeletedResultCollections,
|
||||
agentProjectReplacementResultState,
|
||||
agentProjectResultCollections,
|
||||
]);
|
||||
|
||||
const buildPersistedAgentProjectState = useCallback(() => {
|
||||
const persistedMaterials = appearanceRecognitionEnabled
|
||||
@ -4143,6 +4202,9 @@ export default function CanvasEditorLegacyPage({
|
||||
setAgentProjectDeletedResultCollections(
|
||||
persistedState.deletedResultCollections || {}
|
||||
);
|
||||
setAgentProjectReplacementResultState(
|
||||
persistedState.replacementResultState || {}
|
||||
);
|
||||
setLoadedAgentProjectId(params.projectId);
|
||||
},
|
||||
[]
|
||||
@ -4317,7 +4379,9 @@ export default function CanvasEditorLegacyPage({
|
||||
const result = await agentProjectService.updateProject(agentProjectId, {
|
||||
title: nextTitle,
|
||||
thumbnail: agentProjectThumbnail,
|
||||
projectState: buildPersistedAgentProjectState(),
|
||||
projectState:
|
||||
persistedAgentProjectStateRef.current ||
|
||||
buildPersistedAgentProjectState(),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@ -4383,9 +4447,27 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
const serverUpdatedAt =
|
||||
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 =
|
||||
Boolean(localDraft?.projectState) &&
|
||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt;
|
||||
Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
|
||||
!localDraftMissesServerReplacementResults &&
|
||||
!localDraftReplacementResultsDifferFromServer &&
|
||||
!localDraftHasStaleReplacementCollections;
|
||||
|
||||
if (shouldUseLocalDraft && localDraft?.projectState) {
|
||||
hydrateAgentProjectSnapshot({
|
||||
@ -4487,6 +4569,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}, [
|
||||
agentModeState,
|
||||
agentProjectId,
|
||||
agentProjectReplacementResultState,
|
||||
agentProjectResultCollections,
|
||||
agentProjectThumbnail,
|
||||
agentProjectTitle,
|
||||
@ -4777,32 +4860,66 @@ export default function CanvasEditorLegacyPage({
|
||||
const handleAgentResultPreviewUrlsChange = useCallback(
|
||||
(modeType: AgentGenerationResultModeType, urls: string[]) => {
|
||||
const normalizedUrls = normalizeResultCollectionUrls(urls);
|
||||
setAgentProjectResultCollections((current) => {
|
||||
const currentProjectState =
|
||||
persistedAgentProjectStateRef.current ||
|
||||
createEmptyAgentGenerationProjectPersistedState();
|
||||
const currentCollections = currentProjectState.resultCollections || {};
|
||||
const nextCollections: AgentGenerationProjectResultCollections = {
|
||||
...current,
|
||||
...currentCollections,
|
||||
[modeType]: normalizedUrls,
|
||||
};
|
||||
|
||||
if (areResultCollectionsEqual(current, nextCollections)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
|
||||
const nextProjectState: AgentGenerationProjectPersistedState = {
|
||||
...persistedAgentProjectStateRef.current,
|
||||
...currentProjectState,
|
||||
resultCollections: nextCollections,
|
||||
resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
|
||||
};
|
||||
persistedAgentProjectStateRef.current = nextProjectState;
|
||||
writeAgentProjectDraftSnapshot(nextProjectState);
|
||||
}
|
||||
|
||||
setAgentProjectResultCollections((current) =>
|
||||
areResultCollectionsEqual(current, nextCollections)
|
||||
? current
|
||||
: nextCollections
|
||||
);
|
||||
setAgentProjectThumbnail((currentThumbnail) => {
|
||||
const nextThumbnail =
|
||||
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
|
||||
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]
|
||||
);
|
||||
@ -4840,6 +4957,33 @@ export default function CanvasEditorLegacyPage({
|
||||
? current
|
||||
: 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) => {
|
||||
const nextThumbnail =
|
||||
current === normalizedUrl
|
||||
@ -4848,7 +4992,11 @@ export default function CanvasEditorLegacyPage({
|
||||
return current === nextThumbnail ? current : nextThumbnail;
|
||||
});
|
||||
},
|
||||
[agentProjectDeletedResultCollections, agentProjectResultCollections]
|
||||
[
|
||||
agentProjectDeletedResultCollections,
|
||||
agentProjectResultCollections,
|
||||
writeAgentProjectDraftSnapshot,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -6751,6 +6899,7 @@ export default function CanvasEditorLegacyPage({
|
||||
deletedResultCollections:
|
||||
existingPersistedState.deletedResultCollections || {},
|
||||
resultImageUrls: existingPersistedState.resultImageUrls || [],
|
||||
replacementResultState: existingPersistedState.replacementResultState || {},
|
||||
};
|
||||
|
||||
const payload = {
|
||||
@ -10037,10 +10186,13 @@ export default function CanvasEditorLegacyPage({
|
||||
mode: null,
|
||||
});
|
||||
}}
|
||||
onPersistProject={() => persistAgentProject("manual")}
|
||||
onReplacementResultStateChange={handleAgentReplacementResultStateChange}
|
||||
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
|
||||
onWorkspaceStateChange={setAgentWorkspaceState}
|
||||
projectId={agentProjectId}
|
||||
key={agentProjectId}
|
||||
replacementResultState={agentProjectReplacementResultState}
|
||||
resultCollections={agentProjectResultCollections}
|
||||
selectedAgentPresetId={
|
||||
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
|
||||
@ -10793,7 +10945,7 @@ export default function CanvasEditorLegacyPage({
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
"h-8 rounded-full px-2.5 text-[12px] font-medium",
|
||||
"h-10 rounded-full px-3 font-medium text-sm",
|
||||
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 bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
|
||||
@ -10801,7 +10953,7 @@ export default function CanvasEditorLegacyPage({
|
||||
onClick={() => setIsPanelOpen(true)}
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageCircle className="mr-1 h-3.5 w-3.5" />
|
||||
<MessageCircle className="mr-1.5 h-4 w-4" />
|
||||
对话
|
||||
</Button>
|
||||
</div>
|
||||
@ -10814,7 +10966,7 @@ export default function CanvasEditorLegacyPage({
|
||||
"absolute z-10 ease-in-out",
|
||||
isFloatingPanelResizing
|
||||
? "transition-none"
|
||||
: "transition-all duration-150",
|
||||
: "transition-all duration-300",
|
||||
isPanelOpen
|
||||
? "translate-x-0 opacity-100"
|
||||
: "pointer-events-none translate-x-[120%] opacity-0"
|
||||
@ -10853,7 +11005,7 @@ export default function CanvasEditorLegacyPage({
|
||||
</div>
|
||||
) : null}
|
||||
<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="正在准备侧边面板..." />
|
||||
) : panelMode === "chat" ? (
|
||||
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
|
||||
@ -11449,60 +11601,60 @@ export default function CanvasEditorLegacyPage({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
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"}
|
||||
>
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<div className="text-[13px] font-semibold text-foreground">
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="font-semibold text-foreground text-sm">
|
||||
视频工作流节点库
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
这里按参考 APP
|
||||
的节点目录整理,先把结构补齐,再逐步接入具体能力。
|
||||
</div>
|
||||
</div>
|
||||
<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) => {
|
||||
const sectionNodes = getVideoWorkflowSectionNodes(
|
||||
section.id
|
||||
);
|
||||
return (
|
||||
<div className="space-y-1.5" key={section.id}>
|
||||
<div className="px-1.5">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
<div className="space-y-2" key={section.id}>
|
||||
<div className="px-2">
|
||||
<div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
{section.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{sectionNodes.map((nodeDefinition) => (
|
||||
<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}
|
||||
onClick={() =>
|
||||
handleAddWorkflowNode(nodeDefinition.type)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mt-1 h-2 w-2 rounded-full"
|
||||
className="mt-0.5 h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
nodeDefinition.accentColor,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px] font-medium text-foreground">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{nodeDefinition.label}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{nodeDefinition.type}
|
||||
</span>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1970,15 +1970,18 @@ export default function CanvasModel3DPage() {
|
||||
worldImageFile as File,
|
||||
"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 || "上传世界参考图失败");
|
||||
}
|
||||
await submitTask({
|
||||
provider: "worldlabs-3d",
|
||||
payload: {
|
||||
mode: "image",
|
||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||
providerId: uploadResult.data.providerId,
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
providerId: uploadData.providerId,
|
||||
prompt: normalizedPrompt || undefined,
|
||||
displayName: worldDisplayName.trim() || undefined,
|
||||
model: worldModel,
|
||||
@ -2000,15 +2003,18 @@ export default function CanvasModel3DPage() {
|
||||
worldVideoFile as File,
|
||||
"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 || "上传世界视频失败");
|
||||
}
|
||||
await submitTask({
|
||||
provider: "worldlabs-3d",
|
||||
payload: {
|
||||
mode: "video",
|
||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||
providerId: uploadResult.data.providerId,
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
providerId: uploadData.providerId,
|
||||
prompt: normalizedPrompt || undefined,
|
||||
displayName: worldDisplayName.trim() || undefined,
|
||||
model: worldModel,
|
||||
@ -2043,11 +2049,13 @@ export default function CanvasModel3DPage() {
|
||||
}
|
||||
|
||||
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 || "上传多图世界图片失败");
|
||||
}
|
||||
return {
|
||||
mediaAssetId: result.data.mediaAssetId,
|
||||
...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
|
||||
azimuth: slot.azimuth,
|
||||
};
|
||||
});
|
||||
|
||||
@ -123,6 +123,8 @@ import type {
|
||||
AgentDetailBriefForm,
|
||||
AgentDetailGenerationProgressState,
|
||||
AgentDetailReplacementStrategy,
|
||||
AgentGenerationProjectReplacementResultState,
|
||||
AgentGenerationReplacementResultRecord,
|
||||
AgentGenerationProjectWorkspaceState,
|
||||
AgentGenerationProjectResultCollections,
|
||||
AgentGenerationResultModeType,
|
||||
@ -147,6 +149,7 @@ import {
|
||||
import {
|
||||
createEmptyAgentGenerationProjectWorkspaceState,
|
||||
normalizeAgentGenerationProjectPersistedState,
|
||||
normalizeAgentGenerationProjectReplacementResultState,
|
||||
} from "@/lib/agents/generation-project-state";
|
||||
|
||||
export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
@ -174,6 +177,10 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
|
||||
resultCollections?: AgentGenerationProjectResultCollections | null;
|
||||
deletedResultCollections?: AgentGenerationProjectResultCollections | null;
|
||||
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
|
||||
onReplacementResultStateChange?: (
|
||||
next: AgentGenerationProjectReplacementResultState
|
||||
) => void;
|
||||
onResultPreviewUrlsChange?: (
|
||||
modeType: AgentGenerationResultModeType,
|
||||
urls: string[]
|
||||
@ -182,6 +189,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
|
||||
modeType: AgentGenerationResultModeType,
|
||||
imageUrl: string
|
||||
) => void;
|
||||
onPersistProject?: () => Promise<boolean> | boolean;
|
||||
topNavRightSlot?: ReactNode;
|
||||
showcaseConfig?: AgentWorkflowShowcaseConfig | null;
|
||||
workflowStylePanel?: ReactNode;
|
||||
@ -248,6 +256,8 @@ type DetailResultLocalEditTarget = {
|
||||
|
||||
type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
|
||||
|
||||
type DetailResultLocalEditImageSize = "1K" | "2K";
|
||||
|
||||
type DetailResultLocalEditRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
@ -270,6 +280,7 @@ type DetailResultLocalEditRegionInteraction = {
|
||||
|
||||
type DetailResultLocalEditSubmitParams = {
|
||||
imageUrl: string;
|
||||
imageSize: DetailResultLocalEditImageSize;
|
||||
mode: DetailResultLocalEditMode;
|
||||
title: string;
|
||||
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(
|
||||
urls: string[] | undefined | null,
|
||||
deletedUrls: string[] | undefined | null
|
||||
@ -844,11 +864,11 @@ function getDetailScaleAdjustRegionPrompt(
|
||||
hasTargetPosition = false
|
||||
) {
|
||||
return [
|
||||
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。",
|
||||
"按目标框线对框选范围内的主物体执行等比比例调整。",
|
||||
hasTargetPosition
|
||||
? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。"
|
||||
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
||||
].join(" ");
|
||||
}
|
||||
@ -895,6 +915,7 @@ function DetailResultLocalEditDialog({
|
||||
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
|
||||
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
|
||||
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
|
||||
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const editSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const isScaleAdjustMode = mode === "scale_adjust";
|
||||
@ -919,6 +940,7 @@ function DetailResultLocalEditDialog({
|
||||
setRegionInteraction(null);
|
||||
setUploadingRegionId(null);
|
||||
setDrawingTargetRegionId(null);
|
||||
setImageSize("1K");
|
||||
dragStartRef.current = null;
|
||||
}, [target?.cardId, target?.imageUrl, target?.mode]);
|
||||
|
||||
@ -1216,7 +1238,7 @@ function DetailResultLocalEditDialog({
|
||||
);
|
||||
if (unchangedRegion) {
|
||||
setActiveRegionId(unchangedRegion.id);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`);
|
||||
toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -1229,11 +1251,12 @@ function DetailResultLocalEditDialog({
|
||||
}
|
||||
onSubmit({
|
||||
imageUrl: target.imageUrl,
|
||||
imageSize,
|
||||
mode,
|
||||
title: target.title,
|
||||
regions: validRegions,
|
||||
});
|
||||
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||
}, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
|
||||
|
||||
const completeDraftRegion = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@ -1818,6 +1841,36 @@ function DetailResultLocalEditDialog({
|
||||
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
|
||||
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
|
||||
</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 ? (
|
||||
<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">
|
||||
@ -2353,13 +2406,7 @@ type ReplacementModeType = Extract<
|
||||
"detail_replacement" | "sku_replacement"
|
||||
>;
|
||||
|
||||
interface ReplacementResultRecord {
|
||||
url: string | null;
|
||||
text: string;
|
||||
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
|
||||
updatedAt: number;
|
||||
compareSourceUrl?: string | null;
|
||||
}
|
||||
interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
|
||||
|
||||
interface ReplacementExecutionContext {
|
||||
modeType: ReplacementModeType;
|
||||
@ -2406,13 +2453,26 @@ type ReplacementResultState = Record<
|
||||
const MANUAL_PASSTHROUGH_RESULT_TEXT =
|
||||
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
|
||||
|
||||
function createEmptyReplacementResultState(): ReplacementResultState {
|
||||
function normalizeReplacementResultStateForWorkspace(
|
||||
value: AgentGenerationProjectReplacementResultState | null | undefined
|
||||
): ReplacementResultState {
|
||||
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
|
||||
return {
|
||||
detail_replacement: {},
|
||||
sku_replacement: {},
|
||||
detail_replacement: { ...(normalized.detail_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 {
|
||||
const normalized = normalizeAssistantText(String(text || ""))
|
||||
.replace(/\s+/g, " ")
|
||||
@ -3060,9 +3120,12 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
workspaceState = null,
|
||||
resultCollections = null,
|
||||
deletedResultCollections = null,
|
||||
replacementResultState: persistedReplacementResultState = null,
|
||||
onWorkspaceStateChange,
|
||||
onReplacementResultStateChange,
|
||||
onResultPreviewUrlsChange,
|
||||
onDeleteResultImage,
|
||||
onPersistProject,
|
||||
topNavRightSlot,
|
||||
showcaseConfig,
|
||||
workflowStylePanel,
|
||||
@ -3214,7 +3277,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
|
||||
() => createEmptyReplacementResultState()
|
||||
() => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
|
||||
);
|
||||
const [replacementExecutionContext, setReplacementExecutionContext] =
|
||||
useState<ReplacementExecutionContext | null>(null);
|
||||
@ -3230,6 +3293,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
|
||||
useState<Set<string>>(() => new Set());
|
||||
const processedResultHintTimerRef = useRef<Record<string, number>>({});
|
||||
const transportGenerationStartedAtRef = useRef<number | null>(null);
|
||||
const closePsdTemplateDialog = useCallback(() => {
|
||||
setIsPsdTemplateDialogOpen(false);
|
||||
setPsdTemplateDialogSourceUrl(null);
|
||||
@ -3263,6 +3327,22 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
[activeResultModeType, isPsdTemplateEnabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const nextState = normalizeReplacementResultStateForWorkspace(
|
||||
persistedReplacementResultState
|
||||
);
|
||||
setReplacementResultState((current) =>
|
||||
areReplacementResultStatesEqual(current, nextState) ? current : nextState
|
||||
);
|
||||
}, [persistedReplacementResultState]);
|
||||
|
||||
useEffect(() => {
|
||||
onReplacementResultStateChange?.(
|
||||
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
|
||||
);
|
||||
}, [onReplacementResultStateChange, replacementResultState]);
|
||||
|
||||
const normalizedDeletedResultCollections = useMemo(
|
||||
() => ({
|
||||
scene_generation: normalizeResultCollectionUrls(
|
||||
@ -4038,7 +4118,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? "agent-anchor-materials"
|
||||
: "agent-anchor-generate";
|
||||
|
||||
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
error: chatError,
|
||||
clearError,
|
||||
} = useChat<ChatMessage>({
|
||||
id: chatId,
|
||||
messages: historyMessages,
|
||||
experimental_throttle: 100,
|
||||
@ -4503,63 +4591,48 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
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) => {
|
||||
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id));
|
||||
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id));
|
||||
let changed = false;
|
||||
|
||||
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,
|
||||
const nextState: ReplacementResultState = {
|
||||
detail_replacement: { ...current.detail_replacement },
|
||||
sku_replacement: { ...current.sku_replacement },
|
||||
};
|
||||
});
|
||||
}, [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;
|
||||
|
||||
latestReplacementResults.forEach((result, index) => {
|
||||
const cardId = result.replacementCardId || cardIds[index];
|
||||
const applyResult = (
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
const inferredStatus = inferReplacementCardStatusFromText(result.text);
|
||||
const nextModeState = nextState[modeType];
|
||||
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 previousUrl =
|
||||
previousRecord?.url && previousRecord.url !== resultUrl
|
||||
@ -4579,27 +4652,59 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
resultUrl,
|
||||
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
|
||||
),
|
||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard?.label || previousRecord?.label || null,
|
||||
};
|
||||
if (
|
||||
previousRecord?.url === nextRecord.url &&
|
||||
previousRecord?.text === nextRecord.text &&
|
||||
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;
|
||||
}
|
||||
nextModeState[cardId] = nextRecord;
|
||||
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) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[currentResultModeType]: nextModeState,
|
||||
};
|
||||
return nextState;
|
||||
});
|
||||
}, [
|
||||
currentReplacementExecutionCardIds,
|
||||
@ -4609,8 +4714,8 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementReferenceCards,
|
||||
]);
|
||||
const detailReplacementResultUrls = useMemo(
|
||||
() =>
|
||||
detailReplacementReferenceCards
|
||||
() => {
|
||||
const urlsFromCards = detailReplacementReferenceCards
|
||||
.map((card) => {
|
||||
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
|
||||
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
|
||||
@ -4620,7 +4725,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
? nextUrl
|
||||
: 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,
|
||||
normalizedDeletedResultCollections.detail_replacement,
|
||||
@ -4628,15 +4741,21 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
]
|
||||
);
|
||||
const skuReplacementResultUrls = useMemo(
|
||||
() =>
|
||||
skuReplacementReferenceCards
|
||||
() => {
|
||||
const urlsFromCards = skuReplacementReferenceCards
|
||||
.map((card) => {
|
||||
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
|
||||
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
|
||||
? nextUrl
|
||||
: 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,
|
||||
replacementResultState.sku_replacement,
|
||||
@ -4727,24 +4846,20 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
resultCollections?.detail_generation,
|
||||
normalizedDeletedResultCollections.detail_generation
|
||||
),
|
||||
detail_replacement: Array.from(
|
||||
new Set([
|
||||
...detailReplacementResultUrls,
|
||||
...excludeDeletedResultUrls(
|
||||
detail_replacement:
|
||||
detailReplacementResultUrls.length > 0
|
||||
? detailReplacementResultUrls
|
||||
: excludeDeletedResultUrls(
|
||||
resultCollections?.detail_replacement,
|
||||
normalizedDeletedResultCollections.detail_replacement
|
||||
),
|
||||
])
|
||||
),
|
||||
sku_replacement: Array.from(
|
||||
new Set([
|
||||
...skuReplacementResultUrls,
|
||||
...excludeDeletedResultUrls(
|
||||
sku_replacement:
|
||||
skuReplacementResultUrls.length > 0
|
||||
? skuReplacementResultUrls
|
||||
: excludeDeletedResultUrls(
|
||||
resultCollections?.sku_replacement,
|
||||
normalizedDeletedResultCollections.sku_replacement
|
||||
),
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
@ -4950,6 +5065,52 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
skuReplacementBatchDownloadUrls.length > 0
|
||||
? skuReplacementBatchDownloadUrls
|
||||
: 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(
|
||||
() =>
|
||||
detailResultCards.filter(
|
||||
@ -5237,6 +5398,115 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (!pendingGeneration || transportIsGenerating) {
|
||||
return;
|
||||
@ -5274,11 +5544,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onResultPreviewUrlsChange?.(
|
||||
currentResultModeType,
|
||||
mergedResultCollections[currentResultModeType] || []
|
||||
);
|
||||
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]);
|
||||
if (!onResultPreviewUrlsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
([
|
||||
"scene_generation",
|
||||
"detail_generation",
|
||||
"detail_replacement",
|
||||
"sku_replacement",
|
||||
] as AgentGenerationResultModeType[]).forEach((modeType) => {
|
||||
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
|
||||
});
|
||||
}, [mergedResultCollections, onResultPreviewUrlsChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeResultModeUrls.length === 0) {
|
||||
@ -5420,6 +5698,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: string[];
|
||||
zipName: string;
|
||||
scope: BatchDownloadScope;
|
||||
modeType?: AgentGenerationResultModeType;
|
||||
}) => {
|
||||
const normalizedUrls = (params.imageUrls || [])
|
||||
.map((item) => String(item || "").trim())
|
||||
@ -5429,6 +5708,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
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);
|
||||
setBatchDownloadDialog({
|
||||
scope: params.scope,
|
||||
@ -5508,7 +5800,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, []);
|
||||
}, [onPersistProject, syncReplacementResultCollectionsToParent]);
|
||||
|
||||
useEffect(() => {
|
||||
const taskId = batchDownloadDialog?.taskId;
|
||||
@ -5607,6 +5899,9 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
const link = document.createElement("a");
|
||||
link.href = batchDownloadDialog.downloadUrl;
|
||||
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@ -5672,6 +5967,10 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
setReplacementResultState((current) => {
|
||||
const currentModeState = current[params.modeType];
|
||||
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 explicitCompareSourceUrl =
|
||||
params.compareSourceUrl === undefined
|
||||
@ -5692,13 +5991,20 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
explicitCompareSourceUrl === undefined
|
||||
? previousRecord?.compareSourceUrl || null
|
||||
: explicitCompareSourceUrl,
|
||||
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
|
||||
referencePreviewUrl:
|
||||
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
|
||||
label: referenceCard?.label || previousRecord?.label || null,
|
||||
};
|
||||
|
||||
if (
|
||||
previousRecord?.url === nextRecord.url &&
|
||||
previousRecord?.text === nextRecord.text &&
|
||||
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;
|
||||
}
|
||||
@ -5711,7 +6017,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
},
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
|
||||
const handleOpenReplacementTextEdit = useCallback((params: {
|
||||
modeType: ReplacementModeType;
|
||||
cardId: string;
|
||||
@ -6060,7 +6366,15 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
|
||||
}
|
||||
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") {
|
||||
setInput("");
|
||||
}
|
||||
@ -6647,15 +6961,19 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
: selectedModelId !== "auto"
|
||||
? selectedModelId
|
||||
: effectiveDefaultModelId || undefined;
|
||||
const requestedImageSize: DetailResultLocalEditImageSize =
|
||||
params.imageSize === "2K" ? "2K" : "1K";
|
||||
|
||||
setDetailLocalEditSubmittingId(cardId);
|
||||
setDetailLocalEditSubmittingMode(editMode);
|
||||
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑...");
|
||||
toast.info(
|
||||
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
|
||||
);
|
||||
|
||||
try {
|
||||
const submitResult = await imageGenerationService.localEdit({
|
||||
aspectRatio: "auto",
|
||||
imageSize: "2K",
|
||||
imageSize: requestedImageSize,
|
||||
model:
|
||||
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
|
||||
? resolvedLocalEditModelId
|
||||
@ -6873,6 +7191,23 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
delete next[normalizedUrl];
|
||||
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);
|
||||
toast.success("结果图已删除");
|
||||
@ -9351,6 +9686,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: detailReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
|
||||
scope: "detail",
|
||||
modeType: "detail_replacement",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "detail"}
|
||||
@ -9368,6 +9704,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: mergedResultCollections.detail_generation,
|
||||
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
|
||||
scope: "detail",
|
||||
modeType: "detail_generation",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "detail"}
|
||||
@ -9385,6 +9722,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: skuReplacementOrderedDownloadUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
|
||||
scope: "sku",
|
||||
modeType: "sku_replacement",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "sku"}
|
||||
@ -9401,6 +9739,7 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
imageUrls: activeResultModeUrls,
|
||||
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
|
||||
scope: "scene",
|
||||
modeType: "scene_generation",
|
||||
})
|
||||
}
|
||||
disabled={batchDownloadState === "scene"}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type RefObject } from "react";
|
||||
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
|
||||
|
||||
export type AgentStyleSection =
|
||||
| "brand"
|
||||
@ -140,6 +141,9 @@ export function useAgentStylePerformanceMonitor({
|
||||
chatId,
|
||||
projectId,
|
||||
}: UseAgentStylePerformanceMonitorParams) {
|
||||
const persistedChatId =
|
||||
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
|
||||
@ -200,7 +204,7 @@ export function useAgentStylePerformanceMonitor({
|
||||
browser,
|
||||
path: window.location.pathname,
|
||||
projectId: projectId || null,
|
||||
chatId: chatId || null,
|
||||
chatId: persistedChatId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
@ -237,5 +241,5 @@ export function useAgentStylePerformanceMonitor({
|
||||
root.removeEventListener("keydown", markAction, true);
|
||||
root.removeEventListener("scroll", markAction, true);
|
||||
};
|
||||
}, [chatId, projectId, rootRef]);
|
||||
}, [persistedChatId, projectId, rootRef]);
|
||||
}
|
||||
|
||||
@ -97,7 +97,6 @@ import {
|
||||
createLocalEditRegionId,
|
||||
getLocalEditRegionsFromMetadata,
|
||||
getNormalizedRectOverlayStyle,
|
||||
hasScaleAdjustTargetChanged,
|
||||
isHttpImageUrl,
|
||||
LOCAL_EDIT_COLORS,
|
||||
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
|
||||
@ -220,12 +219,6 @@ import {
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
|
||||
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 type {
|
||||
CanvasTextEditPayloadItem,
|
||||
@ -288,6 +281,16 @@ interface CanvasNodeAnnotationDisplayItem {
|
||||
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([
|
||||
"ai-wash",
|
||||
"rmbg",
|
||||
@ -436,11 +439,11 @@ function getCanvasScaleAdjustRegionPrompt(
|
||||
hasTargetPosition = false
|
||||
) {
|
||||
return [
|
||||
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。",
|
||||
"按目标框线对框选范围内的主物体执行等比比例调整。",
|
||||
hasTargetPosition
|
||||
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
|
||||
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
|
||||
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
"只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
|
||||
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
|
||||
].join(" ");
|
||||
}
|
||||
@ -1473,10 +1476,7 @@ export function ImageNode({
|
||||
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
|
||||
const selectedImageInlineActionCount =
|
||||
getSelectedImageInlineActionCount(canvasToolbarConfig);
|
||||
const selectedImageToolbarActionCandidates: ReadonlyArray<{
|
||||
key: SelectedImageToolbarActionKey;
|
||||
enabled: boolean;
|
||||
}> = [
|
||||
const selectedImageToolbarActionCandidates = [
|
||||
{ key: "pickObject", enabled: imagePickObjectEnabled },
|
||||
{ key: "enhance", enabled: imageEnhanceEnabled },
|
||||
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
|
||||
@ -1496,7 +1496,9 @@ export function ImageNode({
|
||||
{ key: "flipRotate", enabled: imageFlipRotateEnabled },
|
||||
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
|
||||
{ key: "note", enabled: imageNoteEnabled },
|
||||
];
|
||||
] as const;
|
||||
type SelectedImageToolbarActionKey =
|
||||
(typeof selectedImageToolbarActionCandidates)[number]["key"];
|
||||
const enabledSelectedImageToolbarActions =
|
||||
selectedImageToolbarActionCandidates.filter((action) => action.enabled);
|
||||
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
|
||||
@ -1799,7 +1801,8 @@ export function ImageNode({
|
||||
: "",
|
||||
[aiWashQuickLevels, selectedAiWashQuickPreset]
|
||||
);
|
||||
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
|
||||
const shouldShowAiWashQuickModelSelector =
|
||||
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
|
||||
const effectiveAiWashQuickModelRef =
|
||||
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
|
||||
const selectedAiWashQuickModel = useMemo(
|
||||
@ -1809,6 +1812,11 @@ export function ImageNode({
|
||||
null,
|
||||
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
|
||||
);
|
||||
const aiWashQuickHasExecutableModel = Boolean(
|
||||
effectiveAiWashQuickModelRef &&
|
||||
selectedAiWashQuickModel &&
|
||||
selectedAiWashQuickModel.selectable !== false
|
||||
);
|
||||
const aiWashQuickPrompt = useMemo(
|
||||
() =>
|
||||
selectedAiWashQuickPreset
|
||||
@ -2034,7 +2042,6 @@ export function ImageNode({
|
||||
>({});
|
||||
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
|
||||
const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
|
||||
const processedCapabilityCommandIdsRef = useRef(new Set<string>());
|
||||
|
||||
const updateNode = useCanvasStore((state) => state.updateNode);
|
||||
const deleteNode = useCanvasStore((state) => state.deleteNode);
|
||||
@ -2042,7 +2049,6 @@ export function ImageNode({
|
||||
const selectNode = useCanvasStore((state) => state.selectNode);
|
||||
const projectId = useCanvasStore((state) => state.projectId);
|
||||
const getCanvasState = useCanvasStore((state) => state.getCanvasState);
|
||||
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
|
||||
const addPendingChatAttachment = useCanvasStore(
|
||||
(state) => state.addPendingChatAttachment
|
||||
);
|
||||
@ -2145,7 +2151,6 @@ export function ImageNode({
|
||||
updateCurrentGeneratingTaskNode((currentData) => ({
|
||||
...currentData,
|
||||
src: imageUrl,
|
||||
thumbnailUrl: image?.thumbnailUrl || imageUrl,
|
||||
status: "completed",
|
||||
prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
|
||||
model: task.result?.model || task.result?.modelRef || currentData.model,
|
||||
@ -2272,7 +2277,7 @@ export function ImageNode({
|
||||
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
|
||||
});
|
||||
const aiWashQuickCostShortLabel =
|
||||
pointsEnabled && selectedAiWashQuickModel
|
||||
pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
|
||||
? formatImageNodeFeatureCostShortLabel({
|
||||
cost: selectedAiWashQuickModel.unitCost,
|
||||
isHighVip,
|
||||
@ -2472,18 +2477,14 @@ export function ImageNode({
|
||||
);
|
||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||
const displayImageSource =
|
||||
data.thumbnailUrl && !shouldUseHighFidelityDisplayImage
|
||||
? data.thumbnailUrl
|
||||
: data.src;
|
||||
const displayImageSrc = useMemo(
|
||||
() =>
|
||||
buildImageProxyUrl(displayImageSource, {
|
||||
buildImageProxyUrl(data.src, {
|
||||
width: displayImageWidth,
|
||||
quality: displayImageQuality,
|
||||
format: "webp",
|
||||
}),
|
||||
[displayImageQuality, displayImageSource, displayImageWidth]
|
||||
[data.src, displayImageQuality, displayImageWidth]
|
||||
);
|
||||
const fullscreenPreviewSrc = useMemo(() => {
|
||||
const originalUrl = resolveOriginalImageUrl(data.src || "");
|
||||
@ -4417,11 +4418,15 @@ export function ImageNode({
|
||||
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
|
||||
return;
|
||||
}
|
||||
const unchangedRegion = validRegions.find(
|
||||
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect)
|
||||
const unchangedRegion = validRegions.find((region) => {
|
||||
const regionScalePercent = deriveCanvasScalePercentFromRects(
|
||||
region.rect,
|
||||
region.targetRect
|
||||
);
|
||||
return Math.abs(regionScalePercent) < 1;
|
||||
});
|
||||
if (unchangedRegion) {
|
||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`);
|
||||
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -4555,7 +4560,6 @@ export function ImageNode({
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: generatedUrl,
|
||||
thumbnailUrl: generatedImage?.thumbnailUrl || generatedUrl,
|
||||
status: "completed",
|
||||
prompt:
|
||||
task.result?.prompt ||
|
||||
@ -6048,6 +6052,35 @@ export function ImageNode({
|
||||
await detectTextItems(true);
|
||||
}, [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) => {
|
||||
setTextEditItems((prev) =>
|
||||
prev.map((item) =>
|
||||
@ -6332,6 +6365,30 @@ export function ImageNode({
|
||||
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(
|
||||
(mode: "model" | "world") => {
|
||||
if (!data.src) {
|
||||
@ -6420,8 +6477,11 @@ export function ImageNode({
|
||||
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
|
||||
return;
|
||||
}
|
||||
if (!effectiveAiWashQuickModelRef) {
|
||||
toast.error("请先选择一个可用的生图模型");
|
||||
if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
|
||||
toast.error(
|
||||
selectedAiWashQuickModel?.disabledReason ||
|
||||
"当前 AI洗图模型不可用,请在后台重新配置模型"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isProcessing || isSubmittingAiWashQuick) {
|
||||
@ -6477,7 +6537,7 @@ export function ImageNode({
|
||||
body: JSON.stringify({
|
||||
prompt: aiWashQuickPrompt,
|
||||
referenceImage: data.src,
|
||||
selectedModelId: effectiveAiWashQuickModelRef,
|
||||
selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
|
||||
imageSize: aiWashQuickConfig.imageSize,
|
||||
promptExtend: aiWashQuickConfig.promptExtend,
|
||||
title: selectedAiWashQuickPreset.title,
|
||||
@ -6574,6 +6634,7 @@ export function ImageNode({
|
||||
}, [
|
||||
addNode,
|
||||
aiWashQuickConfig.enabled,
|
||||
aiWashQuickConfig.hideModelSelector,
|
||||
aiWashQuickConfig.imageSize,
|
||||
aiWashQuickConfig.promptExtend,
|
||||
aiWashQuickLevels,
|
||||
@ -6581,6 +6642,7 @@ export function ImageNode({
|
||||
aiWashQuickPrompt,
|
||||
data.src,
|
||||
displayTitle,
|
||||
aiWashQuickHasExecutableModel,
|
||||
effectiveAiWashQuickModelRef,
|
||||
isProcessing,
|
||||
isSubmittingAiWashQuick,
|
||||
@ -6588,63 +6650,16 @@ export function ImageNode({
|
||||
resetBoxCutoutState,
|
||||
resetLocalEditState,
|
||||
selectNode,
|
||||
selectedAiWashQuickModel?.disabledReason,
|
||||
selectedAiWashQuickModel?.displayName,
|
||||
selectedAiWashQuickModel?.id,
|
||||
selectedAiWashQuickModel?.modelId,
|
||||
selectedAiWashQuickPreset,
|
||||
updateNode,
|
||||
]);
|
||||
|
||||
const executeSharedToolbarCapability = async (
|
||||
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split"
|
||||
) => {
|
||||
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") => {
|
||||
// 处理图片编辑操作 - 集成我们现有的 API
|
||||
const handleAction = async (action: string) => {
|
||||
if (!data.src) {
|
||||
toast.error("请先上传图片");
|
||||
return;
|
||||
@ -6747,7 +6762,34 @@ export function ImageNode({
|
||||
toast.info("图层拆分功能未开启");
|
||||
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;
|
||||
}
|
||||
if (action === "box-cutout") {
|
||||
@ -6809,13 +6851,102 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
action === "rmbg" ||
|
||||
action === "watermark" ||
|
||||
action === "enhance" ||
|
||||
action === "extend"
|
||||
) {
|
||||
await executeSharedToolbarCapability(action);
|
||||
const actionName = getActionName(action);
|
||||
|
||||
setImageAdjustPanelOpen(false);
|
||||
setCropPanelOpen(false);
|
||||
setCropInteraction(null);
|
||||
setFlipRotatePanelOpen(false);
|
||||
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;
|
||||
|
||||
if (!targetNodeId || !action || targetNodeId !== node.id) return;
|
||||
if (action === "scene-fusion" || action === "annotate") {
|
||||
void handleAction(action);
|
||||
return;
|
||||
}
|
||||
const capability = getCanvasCapabilityByClientAction(
|
||||
action as CanvasCapabilityClientAction
|
||||
);
|
||||
if (capability) {
|
||||
void handleAction(capability.clientAction);
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
annotatedImageUrl: string,
|
||||
@ -7892,20 +8113,9 @@ export function ImageNode({
|
||||
setIsCreatingDetachedResultNode(true);
|
||||
setIsProcessing(true);
|
||||
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({
|
||||
mode: "image",
|
||||
mediaAssetId: uploadResult.data.mediaAssetId,
|
||||
providerId,
|
||||
sourceUrl: sourceImageUrl,
|
||||
prompt: promptText || undefined,
|
||||
displayName: displayTitle || promptText || "画布图生世界",
|
||||
model: modelVersion,
|
||||
@ -7918,8 +8128,6 @@ export function ImageNode({
|
||||
|
||||
const createAssetPayload = {
|
||||
...(createResult.data.assetPayload || {}),
|
||||
providerId,
|
||||
providerName,
|
||||
sourceImageUrl,
|
||||
};
|
||||
updateNode(newNodeId, {
|
||||
@ -7961,8 +8169,8 @@ export function ImageNode({
|
||||
void pollCanvasWorld3DTask({
|
||||
nodeId: newNodeId,
|
||||
taskId: createResult.data.taskId,
|
||||
providerId,
|
||||
providerName,
|
||||
providerId: createAssetPayload.providerId || null,
|
||||
providerName: createAssetPayload.providerName || null,
|
||||
promptText,
|
||||
modelVersion,
|
||||
sourceImageUrl,
|
||||
@ -8159,78 +8367,6 @@ export function ImageNode({
|
||||
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>(() => {
|
||||
if (boxCutoutMetrics) {
|
||||
return {
|
||||
@ -9831,7 +9967,7 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<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"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
@ -9845,21 +9981,21 @@ export function ImageNode({
|
||||
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 min-w-0 items-center gap-2">
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||
<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-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
className="h-5 w-5"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashQuickConfig.modeIconUrl}
|
||||
/>
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
<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)}
|
||||
title="关闭"
|
||||
type="button"
|
||||
@ -9869,20 +10005,20 @@ export function ImageNode({
|
||||
</div>
|
||||
|
||||
{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" />
|
||||
正在加载 AI洗图 配置...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="divide-y divide-slate-100 dark:divide-white/10">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="mb-1.5 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="px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
目标
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
@ -9893,7 +10029,7 @@ export function ImageNode({
|
||||
}}
|
||||
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="选择渲染目标" />
|
||||
</SelectTrigger>
|
||||
<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}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="mb-1.5 text-[13px] font-semibold text-slate-950 dark:text-white">
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
|
||||
参数
|
||||
</div>
|
||||
{shouldShowAiWashQuickModelSelector ? (
|
||||
@ -9924,7 +10060,7 @@ export function ImageNode({
|
||||
onValueChange={setAiWashQuickModelId}
|
||||
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="选择生图模型" />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
) : 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}
|
||||
|
||||
<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) => {
|
||||
const selectedIndex =
|
||||
aiWashQuickLevels[control.id] ?? control.defaultLevel;
|
||||
@ -9979,14 +10108,14 @@ export function ImageNode({
|
||||
null;
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2.5"
|
||||
className="px-4 py-3"
|
||||
key={control.id}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div className="text-[13px] font-semibold">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[14px] font-semibold">
|
||||
{control.label}
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@ -10012,13 +10141,13 @@ export function ImageNode({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="px-3 py-3">
|
||||
<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={
|
||||
isSubmittingAiWashQuick ||
|
||||
!selectedAiWashQuickPreset ||
|
||||
!effectiveAiWashQuickModelRef
|
||||
!aiWashQuickHasExecutableModel
|
||||
}
|
||||
onClick={() => void handleSubmitAiWashQuick()}
|
||||
type="button"
|
||||
@ -10031,6 +10160,7 @@ export function ImageNode({
|
||||
{isSubmittingAiWashQuick
|
||||
? "生成中"
|
||||
: `${aiWashQuickModeLabel}${
|
||||
!aiWashQuickConfig.hideModelSelector &&
|
||||
selectedAiWashQuickModel?.unitCost
|
||||
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
|
||||
: ""
|
||||
@ -11546,7 +11676,7 @@ export function ImageNode({
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<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()}
|
||||
onMouseDown={(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 gap-2">
|
||||
<Type className="h-4 w-4 text-primary" />
|
||||
<span className="text-[13px] font-medium">编辑文字</span>
|
||||
<span className="font-medium text-sm">编辑文字</span>
|
||||
</div>
|
||||
<Button
|
||||
className="h-7 w-7"
|
||||
@ -11571,12 +11701,12 @@ export function ImageNode({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{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" />
|
||||
正在识别图片文字...
|
||||
</div>
|
||||
@ -11598,7 +11728,7 @@ export function ImageNode({
|
||||
|
||||
{textEditItems.map((item, index) => (
|
||||
<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}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@ -67,7 +67,7 @@ export const hasScaleAdjustTargetChanged = (
|
||||
) => {
|
||||
if (!targetRect) return false;
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,316 +1,93 @@
|
||||
# World Labs 3D 世界生成中转接口兼容要求
|
||||
# 3D 世界生成 API 调用规范
|
||||
|
||||
本文档用于说明我方系统接入 World Labs 3D 世界生成功能时,对第三方中转服务的接口兼容要求。
|
||||
|
||||
## 核心原则
|
||||
|
||||
我方系统会按照 World Labs 官方 API 协议发起请求:
|
||||
当前 3D 世界生成通过智枢外部任务接口调用 World Labs 能力。Base URL 只填写根地址:
|
||||
|
||||
```text
|
||||
{baseUrl} + /marble/v1/...
|
||||
https://api.vip98k.com
|
||||
```
|
||||
|
||||
中转服务需要兼容官方路径、请求体、响应体和错误格式。请不要要求我方把 `baseUrl` 配置成某个完整接口地址。
|
||||
不要填写 `/v1`,也不要填写旧的 `/marble/v1`。
|
||||
|
||||
正确示例:
|
||||
## 鉴权方式
|
||||
|
||||
```text
|
||||
http://your-proxy-domain.com
|
||||
```
|
||||
|
||||
错误示例:
|
||||
|
||||
```text
|
||||
http://your-proxy-domain.com/marble/v1/worlds:generate
|
||||
```
|
||||
|
||||
## 我方请求方式
|
||||
|
||||
我方后台会在请求中携带:
|
||||
所有请求都必须携带本站发出的智枢 API Key:
|
||||
|
||||
```http
|
||||
WLT-Api-Key: {配置的 API Key}
|
||||
Authorization: Bearer <API_KEY>
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
中转服务应支持 `WLT-Api-Key` 请求头。如果中转服务内部自行管理官方 Key,也可以将该字段作为租户标识或鉴权凭据,但不应要求我方改成其他 header。
|
||||
不要使用管理员 Key、其他平台 Key,也不要使用旧的 `WLT-Api-Key` 作为主链路鉴权。
|
||||
|
||||
## 必须兼容的接口
|
||||
## 模型目录
|
||||
|
||||
### 1. 准备上传素材
|
||||
正式创建任务前,可以先确认这把 Key 是否开通空间生成分组:
|
||||
|
||||
```http
|
||||
POST {baseUrl}/marble/v1/media-assets:prepare_upload
|
||||
```bash
|
||||
curl "https://api.vip98k.com/api/external/task/models" \
|
||||
-H "Authorization: Bearer <API_KEY>"
|
||||
```
|
||||
|
||||
请求体示例:
|
||||
返回内容里应能找到:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_name": "input.jpg",
|
||||
"extension": "jpg",
|
||||
"kind": "image",
|
||||
"metadata": {
|
||||
"mime_type": "image/jpeg",
|
||||
"uploaded_from": "canvas_world_mode"
|
||||
}
|
||||
"modelId": "world-generation"
|
||||
}
|
||||
```
|
||||
|
||||
响应必须包含可用的上传信息:
|
||||
如果找不到,说明这把 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
|
||||
{
|
||||
"media_asset": {},
|
||||
"upload_info": {
|
||||
"upload_url": "https://...",
|
||||
"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",
|
||||
"data": {
|
||||
"taskId": "任务ID",
|
||||
"status": "accepted",
|
||||
"done": false,
|
||||
"metadata": {
|
||||
"progress": {
|
||||
"status": "RUNNING"
|
||||
}
|
||||
"resultPath": "/api/external/task/result?taskId=任务ID"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
## 查询结果
|
||||
|
||||
- `operation_id` 必须稳定,后续查询状态必须使用同一个 id。
|
||||
- 不要在创建成功但任务未完成时返回业务失败。
|
||||
- 如果余额不足、Key 无效,应返回明确 HTTP 状态码和错误信息。
|
||||
|
||||
### 3. 查询任务状态
|
||||
|
||||
```http
|
||||
GET {baseUrl}/marble/v1/operations/{operation_id}
|
||||
```bash
|
||||
curl "https://api.vip98k.com/api/external/task/result?taskId=<任务ID>" \
|
||||
-H "Authorization: Bearer <API_KEY>"
|
||||
```
|
||||
|
||||
运行中响应示例:
|
||||
建议每隔 5 到 10 秒查询一次,最长等待 10 分钟。直到返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "task-id",
|
||||
"done": false,
|
||||
"metadata": {
|
||||
"progress": {
|
||||
"status": "RUNNING",
|
||||
"description": "World generation in progress"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
成功响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_id": "task-id",
|
||||
"done": true,
|
||||
"error": null,
|
||||
"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"
|
||||
"data": {
|
||||
"status": "succeeded",
|
||||
"done": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
## 常见错误
|
||||
|
||||
- `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/...`,我方系统就可以在官方接口和中转接口之间无缝切换。
|
||||
- 不能用 `/v1/chat/completions`。
|
||||
- 不能把模型写成 `marble-1.1`。
|
||||
- 不能把 `accepted` 或 `running` 当作调用失败。
|
||||
- 不能只等待几十秒就判定超时。
|
||||
- 必须携带 `Authorization: Bearer <API_KEY>`。
|
||||
|
||||
@ -8,8 +8,10 @@ import {
|
||||
} from "@/lib/agents/workflow-material-config";
|
||||
import type {
|
||||
AgentGenerationProjectPersistedState,
|
||||
AgentGenerationProjectReplacementResultState,
|
||||
AgentGenerationProjectResultCollections,
|
||||
AgentGenerationResultModeType,
|
||||
AgentGenerationReplacementResultStatus,
|
||||
AgentGenerationProjectWorkspaceState,
|
||||
AgentWorkflowPersistedState,
|
||||
} from "@/types/agent";
|
||||
@ -58,6 +60,7 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
|
||||
resultImageUrls: [],
|
||||
resultCollections: {},
|
||||
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(
|
||||
workflowState: AgentWorkflowPersistedState,
|
||||
resultUrls: string[]
|
||||
@ -287,12 +413,18 @@ export function normalizeAgentGenerationProjectPersistedState(
|
||||
).slice(0, 64)
|
||||
: [];
|
||||
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,
|
||||
resultImageUrls,
|
||||
resultCollections: normalizeResultCollections(raw.resultCollections),
|
||||
}),
|
||||
});
|
||||
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
|
||||
|
||||
return {
|
||||
workflowState,
|
||||
@ -300,5 +432,6 @@ export function normalizeAgentGenerationProjectPersistedState(
|
||||
resultImageUrls,
|
||||
resultCollections,
|
||||
deletedResultCollections,
|
||||
replacementResultState,
|
||||
};
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
|
||||
midjourney: "https://your-midjourney-api.example.com",
|
||||
"fal-video": "https://fal.run",
|
||||
"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",
|
||||
runway: "https://api.dev.runwayml.com",
|
||||
krea: "https://api.krea.ai",
|
||||
|
||||
@ -56,12 +56,14 @@ interface QueueState {
|
||||
nextBatchEligibleAt: Map<string, number>;
|
||||
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||
awaitingNextBatch: Set<string>;
|
||||
lastStaleRunningCleanupAt: number;
|
||||
draining: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 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 QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
|
||||
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
|
||||
@ -82,6 +84,7 @@ function getQueueState(): QueueState {
|
||||
nextBatchEligibleAt: new Map<string, number>(),
|
||||
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||
awaitingNextBatch: new Set<string>(),
|
||||
lastStaleRunningCleanupAt: 0,
|
||||
draining: false,
|
||||
};
|
||||
}
|
||||
@ -394,6 +397,48 @@ async function claimNextPendingTask(
|
||||
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) {
|
||||
try {
|
||||
const [task] = await db
|
||||
@ -485,6 +530,7 @@ export async function drainCanvasImageTaskQueue() {
|
||||
|
||||
state.draining = true;
|
||||
try {
|
||||
await markStaleDatabaseRunningTasksFailed();
|
||||
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
|
||||
while (true) {
|
||||
const nextTask = await claimNextPendingTask(maxConcurrentPerModel);
|
||||
|
||||
@ -40,7 +40,7 @@ const providerBaseUrlDefaults = [
|
||||
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
|
||||
{ type: "fal-video", baseUrl: "https://fal.run" },
|
||||
{ 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: "runway", baseUrl: "https://api.dev.runwayml.com" },
|
||||
{ type: "krea", baseUrl: "https://api.krea.ai" },
|
||||
|
||||
@ -89,7 +89,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
||||
{
|
||||
id: "world-standard",
|
||||
displayName: "标准质量",
|
||||
realModelId: "marble-1.1",
|
||||
realModelId: "world-generation",
|
||||
description: "默认世界生成质量,适合大多数空间创建。",
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
@ -97,7 +97,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
||||
{
|
||||
id: "world-fidelity",
|
||||
displayName: "高保真",
|
||||
realModelId: "marble-1.1-plus",
|
||||
realModelId: "world-generation",
|
||||
description: "更高保真与细节表现,适合高质量空间生成。",
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
@ -105,7 +105,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
||||
{
|
||||
id: "world-compatible",
|
||||
displayName: "兼容质量",
|
||||
realModelId: "marble-1.0",
|
||||
realModelId: "world-generation",
|
||||
description: "兼容旧版世界任务和历史工作流。",
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
@ -113,7 +113,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
|
||||
{
|
||||
id: "world-draft",
|
||||
displayName: "快速草稿",
|
||||
realModelId: "marble-1.0-draft",
|
||||
realModelId: "world-generation",
|
||||
description: "更适合快速预览与草稿生成。",
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
|
||||
@ -10,7 +10,8 @@ type WorldLabsRequestOptions = {
|
||||
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;
|
||||
|
||||
function toIntMs(value: string | undefined) {
|
||||
@ -66,6 +67,17 @@ function normalizeBaseUrl(baseUrl?: string) {
|
||||
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 {
|
||||
return String(value || "")
|
||||
.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("Accept", "application/json");
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 = {
|
||||
importToken: string;
|
||||
|
||||
@ -4,9 +4,8 @@ import {
|
||||
getAiProviderById,
|
||||
getAiProvidersByType,
|
||||
} from "@/lib/db/queries";
|
||||
import { DEFAULT_WORLDLABS_BASE_URL } from "./client";
|
||||
import type { WorldLabsProviderConfig } from "./types";
|
||||
|
||||
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
|
||||
let worldLabsProviderCursor = 0;
|
||||
|
||||
export function sanitizeWorldLabsApiKey(value: string): string {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
export type WorldLabsModel =
|
||||
| "world-generation"
|
||||
| "marble-1.0-draft"
|
||||
| "marble-1.0"
|
||||
| "marble-1.1"
|
||||
@ -19,8 +20,11 @@ export interface WorldLabsMediaAsset {
|
||||
}
|
||||
|
||||
export interface WorldLabsUploadInfo {
|
||||
upload_url: string;
|
||||
upload_method: string;
|
||||
upload_url?: string;
|
||||
uploadUrl?: string;
|
||||
url?: string;
|
||||
upload_method?: string;
|
||||
method?: string;
|
||||
required_headers?: Record<string, string> | null;
|
||||
curl_example?: string | null;
|
||||
}
|
||||
@ -75,3 +79,37 @@ export interface WorldLabsProviderConfig {
|
||||
providerId?: 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/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@ -1170,10 +1170,12 @@ export const worldLabsGenerationService = {
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
mediaAsset?: Record<string, any>;
|
||||
uploadInfo?: Record<string, any>;
|
||||
providerId?: string | null;
|
||||
providerName?: string | null;
|
||||
uploadFallback?: string | null;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
@ -1201,10 +1203,12 @@ export const worldLabsGenerationService = {
|
||||
): Promise<
|
||||
ApiResponse<{
|
||||
mediaAssetId?: string;
|
||||
sourceUrl?: string;
|
||||
mediaAsset?: Record<string, any>;
|
||||
uploadInfo?: Record<string, any>;
|
||||
providerId?: string | null;
|
||||
providerName?: string | null;
|
||||
uploadFallback?: string | null;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -19,7 +23,9 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
@ -31,7 +37,18 @@
|
||||
"next.config.js",
|
||||
".next/dev/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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
input: string;
|
||||
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
|
||||
@ -530,6 +558,7 @@ export interface AgentGenerationProjectPersistedState {
|
||||
resultImageUrls?: string[];
|
||||
resultCollections?: AgentGenerationProjectResultCollections;
|
||||
deletedResultCollections?: AgentGenerationProjectResultCollections;
|
||||
replacementResultState?: AgentGenerationProjectReplacementResultState;
|
||||
}
|
||||
|
||||
export interface AgentGenerationProject {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user