backup: preserve server changes before OpenClaw deploy

This commit is contained in:
root 2026-08-07 11:28:19 +08:00
parent 2f00645cd4
commit 7708b66e8d
28 changed files with 2275 additions and 816 deletions

View File

@ -1633,7 +1633,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
midjourney: "https://your-midjourney-api.example.com", midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run", "fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi", "tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai", "worldlabs-3d": "https://api.vip98k.com",
"kittycad-cad": "https://api.zoo.dev", "kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com", runway: "https://api.dev.runwayml.com",
krea: "https://api.krea.ai", krea: "https://api.krea.ai",

View File

@ -5745,6 +5745,7 @@ export async function POST(request: Request) {
); );
const assistantMessageId = generateUUID(); const assistantMessageId = generateUUID();
try {
await saveMessages({ await saveMessages({
messages: [ messages: [
{ {
@ -5783,6 +5784,14 @@ export async function POST(request: Request) {
imageSize: persistedImageSize, imageSize: persistedImageSize,
}, },
); );
} catch (persistError) {
chatApiLogger.warn("[Chat API] Failed to persist direct detail replacement result; streaming response anyway:", {
chatId: id,
referenceId: params.reference.id,
referenceLabel: params.reference.label,
error: persistError,
});
}
writeAssistantTextChunk( writeAssistantTextChunk(
writer, writer,
@ -5810,6 +5819,7 @@ export async function POST(request: Request) {
Array.from(replacementReferences.entries()), Array.from(replacementReferences.entries()),
AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT, AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT,
async ([referenceIndex, reference]) => { async ([referenceIndex, reference]) => {
const itemStartedAt = Date.now();
if (reference.passthrough === true) { if (reference.passthrough === true) {
const referenceSourceImageUrls = Array.from( const referenceSourceImageUrls = Array.from(
new Set([ new Set([
@ -5830,9 +5840,29 @@ export async function POST(request: Request) {
streamLabel: "agent-mode:image:detail-replacement:item-passthrough", streamLabel: "agent-mode:image:detail-replacement:item-passthrough",
}); });
successCount += 1; successCount += 1;
chatApiLogger.info("[Chat API] Direct detail replacement item passthrough completed:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
});
return; return;
} }
const fallbackSourceImageUrls = Array.from(
new Set([
...productInputImageUrls,
...(modelInputImageUrl ? [modelInputImageUrl] : []),
...(reference.rerunReferenceImages || [])
.map((image) => String(image.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
.slice(0, 6),
reference.imageUrl,
]),
);
try {
const planningStartedAt = Date.now();
const replacementExecutionPlan = await analyzeReplacementExecutionPlan({ const replacementExecutionPlan = await analyzeReplacementExecutionPlan({
modeType: "detail_replacement", modeType: "detail_replacement",
replacementStrategy: detailReplacementStrategy, replacementStrategy: detailReplacementStrategy,
@ -5847,10 +5877,12 @@ export async function POST(request: Request) {
planningRuntime: replacementScenePlanningRuntime, planningRuntime: replacementScenePlanningRuntime,
models: replacementVisionModels, models: replacementVisionModels,
}); });
const planningElapsedMs = Date.now() - planningStartedAt;
chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", { chatApiLogger.info("[Chat API] Direct detail replacement reference decision:", {
referenceIndex: referenceIndex + 1, referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length, totalReferences: replacementReferences.length,
referenceLabel: reference.label, referenceLabel: reference.label,
planningElapsedMs,
shouldReplace: replacementExecutionPlan.shouldReplace, shouldReplace: replacementExecutionPlan.shouldReplace,
reason: replacementExecutionPlan.reason, reason: replacementExecutionPlan.reason,
detectionSource: replacementExecutionPlan.detectionSource, detectionSource: replacementExecutionPlan.detectionSource,
@ -5960,7 +5992,12 @@ export async function POST(request: Request) {
hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"), hasPromptPackBlock: replacementPrompt.includes("【必须生效的前置场景词包】"),
}); });
let generationStartedAt = 0;
let generationElapsedMs: number | null = null;
let normalizeStartedAt = 0;
let normalizeElapsedMs: number | null = null;
try { try {
generationStartedAt = Date.now();
const result = await generateCanvasImage({ const result = await generateCanvasImage({
prompt: replacementPrompt, prompt: replacementPrompt,
negativePrompt: effectiveImageParams?.negativePrompt, negativePrompt: effectiveImageParams?.negativePrompt,
@ -6000,6 +6037,7 @@ export async function POST(request: Request) {
} }
: undefined, : undefined,
}); });
generationElapsedMs = Date.now() - generationStartedAt;
resolvedModelDisplayName = result.model || resolvedModelDisplayName; resolvedModelDisplayName = result.model || resolvedModelDisplayName;
const firstImage = result.images[0]; const firstImage = result.images[0];
if (!firstImage) { if (!firstImage) {
@ -6009,6 +6047,7 @@ export async function POST(request: Request) {
let finalImage = firstImage; let finalImage = firstImage;
let finalImageSizeLabel: string = resolvedImageSize; let finalImageSizeLabel: string = resolvedImageSize;
try { try {
normalizeStartedAt = Date.now();
const normalizedImage = await normalizeReplacementResultImage({ const normalizedImage = await normalizeReplacementResultImage({
generatedImageUrl: firstImage.originalUrl, generatedImageUrl: firstImage.originalUrl,
generatedThumbnailUrl: firstImage.thumbnailUrl, generatedThumbnailUrl: firstImage.thumbnailUrl,
@ -6022,7 +6061,11 @@ export async function POST(request: Request) {
thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl, thumbnailUrl: normalizedImage.thumbnailUrl || normalizedImage.originalUrl,
}; };
finalImageSizeLabel = normalizedImage.imageSize; finalImageSizeLabel = normalizedImage.imageSize;
normalizeElapsedMs = Date.now() - normalizeStartedAt;
} catch (normalizeError) { } catch (normalizeError) {
if (normalizeStartedAt > 0 && normalizeElapsedMs === null) {
normalizeElapsedMs = Date.now() - normalizeStartedAt;
}
chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", { chatApiLogger.warn("[Chat API] Failed to normalize direct detail replacement output:", {
referenceLabel: reference.label, referenceLabel: reference.label,
referenceImageUrl: reference.imageUrl, referenceImageUrl: reference.imageUrl,
@ -6040,6 +6083,22 @@ export async function POST(request: Request) {
} }
} }
chatApiLogger.info("[Chat API] Direct detail replacement image generation completed:", {
referenceIndex: referenceIndex + 1,
totalReferences: replacementReferences.length,
referenceLabel: reference.label,
modelDisplayName: resolvedModelDisplayName,
resultImageSize: finalImageSizeLabel,
sourceImageCount: sourceImageUrls.length,
generationAspectRatio,
pageKind: replacementExecutionPlan.scenePlan.pageKind,
generationRoute: replacementExecutionPlan.scenePlan.generationRoute,
promptPackPolicy: replacementExecutionPlan.scenePlan.promptPackPolicy,
elapsedMs: Date.now() - itemStartedAt,
planningElapsedMs,
generationElapsedMs,
normalizeElapsedMs,
});
await emitReplacementResultMessage({ await emitReplacementResultMessage({
reference, reference,
image: finalImage, image: finalImage,
@ -6051,9 +6110,16 @@ export async function POST(request: Request) {
}); });
successCount += 1; successCount += 1;
} catch (replacementItemError) { } catch (replacementItemError) {
if (generationStartedAt > 0 && generationElapsedMs === null) {
generationElapsedMs = Date.now() - generationStartedAt;
}
chatApiLogger.error("[Chat API] Direct detail replacement item failed:", { chatApiLogger.error("[Chat API] Direct detail replacement item failed:", {
referenceIndex, referenceIndex,
referenceLabel: reference.label, referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
planningElapsedMs,
generationElapsedMs,
normalizeElapsedMs,
error: replacementItemError, error: replacementItemError,
}); });
await emitReplacementResultMessage({ await emitReplacementResultMessage({
@ -6073,6 +6139,30 @@ export async function POST(request: Request) {
}); });
successCount += 1; successCount += 1;
} }
} catch (replacementPlanError) {
chatApiLogger.error("[Chat API] Direct detail replacement item failed before generation:", {
referenceIndex,
referenceLabel: reference.label,
elapsedMs: Date.now() - itemStartedAt,
error: replacementPlanError,
});
await emitReplacementResultMessage({
reference,
bodyText: `生成失败,当前先沿用参考图:${
replacementPlanError instanceof Error
? replacementPlanError.message
: "请稍后重试"
}`,
image: {
originalUrl: reference.imageUrl,
thumbnailUrl: reference.imageUrl,
},
modelDisplayName: null,
sourceImageUrls: fallbackSourceImageUrls,
streamLabel: "agent-mode:image:detail-replacement:item-fallback",
});
successCount += 1;
}
}); });
} finally { } finally {
stopKeepalive(); stopKeepalive();

View File

@ -83,7 +83,7 @@ const STEPFUN_IMAGE_BASE_URL = "https://api.stepfun.com";
const BFL_IMAGE_BASE_URL = "https://api.bfl.ai"; const BFL_IMAGE_BASE_URL = "https://api.bfl.ai";
const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com"; const MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi"; const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai"; const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev"; const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com"; const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai"; const KREA_BASE_URL = "https://api.krea.ai";

View File

@ -192,23 +192,53 @@ export async function PUT(
}) })
: undefined; : undefined;
// 创建新画布(使用指定 ID) const nextWorkspaceType = workspaceType || "canvas";
const [newProject] = await db const nextCanvasData = syncedCanvasData || {
.insert(canvasProject)
.values({
id, // 使用指定的 ID
userId: session.user.id,
title: title || getDefaultWorkspaceTitle(workspaceType || "canvas"),
description,
workspaceType: workspaceType || "canvas",
canvasData: syncedCanvasData || {
nodes: [], nodes: [],
edges: [], edges: [],
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
}, };
const nextTitle = title || getDefaultWorkspaceTitle(nextWorkspaceType);
const insertData: any = {
id, // 使用指定的 ID
userId: session.user.id,
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
};
const conflictUpdateData: any = {
title: nextTitle,
workspaceType: nextWorkspaceType,
canvasData: nextCanvasData,
updatedAt: new Date(),
};
if (description !== undefined) {
insertData.description = description;
conflictUpdateData.description = description;
}
if (thumbnail !== undefined) {
insertData.thumbnail = thumbnail;
conflictUpdateData.thumbnail = thumbnail;
}
// 创建新画布(使用指定 ID),并兼容同一用户并发创建同一画布的场景。
const [newProject] = await db
.insert(canvasProject)
.values(insertData)
.onConflictDoUpdate({
target: canvasProject.id,
set: conflictUpdateData,
where: eq(canvasProject.userId, session.user.id),
}) })
.returning(); .returning();
if (!newProject) {
return NextResponse.json(
{ success: false, error: '画布ID已被占用' },
{ status: 409 }
);
}
return NextResponse.json({ success: true, data: newProject }); return NextResponse.json({ success: true, data: newProject });
} }
} catch (error) { } catch (error) {

View File

@ -7,9 +7,16 @@ import { resolveThreeDModelAliasId } from "@/lib/model3d/model-alias-config";
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import {
isWorldLabsExternalTaskApi,
WORLDLABS_EXTERNAL_TASK_MODEL,
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server"; import { getWorldLabsProviderConfig } from "@/lib/worldlabs/server";
import type { import type {
WorldLabsExternalTaskResponse,
WorldLabsExternalTaskResultData,
WorldLabsOperation, WorldLabsOperation,
WorldLabsWorld, WorldLabsWorld,
WorldLabsWorldAssets, WorldLabsWorldAssets,
@ -419,6 +426,214 @@ function buildAssetPayload(
}; };
} }
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
const root = getObjectValue(payload);
const data = getObjectValue(root?.data);
return (data || root || {}) as T;
}
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
const normalized = String(status || "").trim().toLowerCase();
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
return "success" as const;
}
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
return "failed" as const;
}
return "running" as const;
}
function readStringRecord(value: unknown) {
const record = getObjectValue(value);
if (!record) return null;
const entries = Object.entries(record).filter(
(entry): entry is [string, string] =>
Boolean(entry[0]) && typeof entry[1] === "string" && entry[1].trim().length > 0
);
return entries.length > 0 ? Object.fromEntries(entries) : null;
}
function findNestedString(
value: unknown,
keys: string[],
depth = 0
): string | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = getStringValue(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedString(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function findNestedObject(
value: unknown,
keys: string[],
depth = 0
): Record<string, unknown> | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = getObjectValue(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedObject(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function findNestedStringRecord(
value: unknown,
keys: string[],
depth = 0
): Record<string, string> | null {
if (depth > 8) return null;
const record = getObjectValue(value);
if (!record) return null;
for (const key of keys) {
const directValue = readStringRecord(record[key]);
if (directValue) return directValue;
}
for (const child of Object.values(record)) {
const found = findNestedStringRecord(child, keys, depth + 1);
if (found) return found;
}
return null;
}
function extractExternalTaskErrorMessage(
data: WorldLabsExternalTaskResultData,
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
) {
const dataError = getObjectValue(data.error);
const payloadError = getObjectValue(payload.error);
return (
getStringValue(
data.errorMessage,
data.message,
dataError?.message,
payload.message,
payloadError?.message,
payload.error
) || "World Labs 外部任务失败"
);
}
function extractExternalWorldAssetPayload(data: WorldLabsExternalTaskResultData) {
const root = getObjectValue(data) || {};
const resultRoot =
getObjectValue(root.result) ||
getObjectValue(root.output) ||
getObjectValue(root.world) ||
root;
const assets = findNestedObject(resultRoot, ["assets"]);
const worldAssets = assets as Partial<WorldLabsWorldAssets> | null;
const splats = getObjectValue(worldAssets?.splats);
return {
worldId: getStringValue(
findNestedString(resultRoot, ["worldId", "world_id"]),
findNestedString(root, ["worldId", "world_id"])
),
worldUrl: getStringValue(
findNestedString(resultRoot, [
"worldUrl",
"world_marble_url",
"sourceWorldUrl",
"viewerUrl",
"marbleUrl",
]),
findNestedString(root, [
"worldUrl",
"world_marble_url",
"sourceWorldUrl",
"viewerUrl",
"marbleUrl",
])
),
displayName: getStringValue(
findNestedString(resultRoot, ["displayName", "display_name"]),
findNestedString(root, ["displayName", "display_name"])
),
model:
getStringValue(
findNestedString(resultRoot, ["model", "modelId", "model_id"]),
findNestedString(root, ["model", "modelId", "model_id"])
) || WORLDLABS_EXTERNAL_TASK_MODEL,
caption: getStringValue(
worldAssets?.caption,
findNestedString(resultRoot, ["caption"]),
findNestedString(root, ["caption"])
),
thumbnailUrl: getStringValue(
worldAssets?.thumbnail_url,
findNestedString(resultRoot, [
"thumbnailUrl",
"thumbnail_url",
"previewImageUrl",
"preview_image_url",
]),
findNestedString(root, [
"thumbnailUrl",
"thumbnail_url",
"previewImageUrl",
"preview_image_url",
])
),
panoUrl: getStringValue(
worldAssets?.imagery?.pano_url,
findNestedString(resultRoot, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"]),
findNestedString(root, ["panoUrl", "pano_url", "panoramaUrl", "panorama_url"])
),
meshUrl: getStringValue(
worldAssets?.mesh?.collider_mesh_url,
findNestedString(resultRoot, [
"meshUrl",
"mesh_url",
"modelUrl",
"model_url",
"glbUrl",
"glb_url",
"colliderMeshUrl",
"collider_mesh_url",
]),
findNestedString(root, [
"meshUrl",
"mesh_url",
"modelUrl",
"model_url",
"glbUrl",
"glb_url",
"colliderMeshUrl",
"collider_mesh_url",
])
),
spzUrls:
readStringRecord(splats?.spz_urls) ||
findNestedStringRecord(resultRoot, ["spzUrls", "spz_urls"]) ||
findNestedStringRecord(root, ["spzUrls", "spz_urls"]),
semanticsMetadata:
getObjectValue(splats?.semantics_metadata) ||
findNestedObject(resultRoot, ["semanticsMetadata", "semantics_metadata"]) ||
findNestedObject(root, ["semanticsMetadata", "semantics_metadata"]),
};
}
interface RouteParams { interface RouteParams {
params: Promise<{ taskId: string }>; params: Promise<{ taskId: string }>;
} }
@ -452,6 +667,187 @@ export async function GET(request: Request, { params }: RouteParams) {
}); });
providerForLog = config.providerId || null; providerForLog = config.providerId || null;
const modelAliasConfig = await getThreeDModelAliasConfigForRuntime(); const modelAliasConfig = await getThreeDModelAliasConfigForRuntime();
const persistedAssetPayload =
getObjectValue(persistedWorldAsset?.assetPayload) || {};
const shouldUseExternalTaskResultApi =
isWorldLabsExternalTaskApi(config.baseUrl) &&
persistedAssetPayload.externalTaskApi !== false &&
persistedAssetPayload.marbleApi !== true;
if (shouldUseExternalTaskResultApi) {
const externalPayload = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskResultData>
>(config, {
method: "GET",
path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`,
timeoutMs: resolveWorldLabsStatusTimeoutMs(),
});
const externalData =
unwrapExternalTaskData<WorldLabsExternalTaskResultData>(externalPayload);
const externalStatusText = getStringValue(externalData.status) || "running";
const externalStatus = normalizeExternalTaskStatus(
externalStatusText,
externalData.done === true
);
const externalAssetPayloadBase = {
...persistedAssetPayload,
providerId: config.providerId || null,
providerName: config.providerName || null,
externalTaskApi: true,
externalTaskStatus: externalStatusText,
requestId: getStringValue(externalPayload.requestId, externalPayload.request_id),
};
if (externalStatus === "running") {
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "running",
progress: null,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
if (externalStatus === "failed") {
const errorMessage = extractExternalTaskErrorMessage(
externalData,
externalPayload
);
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "failed",
progress: null,
error: errorMessage,
assetPayload: externalAssetPayloadBase,
});
logger.warn("external task failed status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
error: errorMessage,
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "failed",
progress: null,
errorMessage,
output: externalData,
assetPayload: externalAssetPayloadBase,
},
});
}
const extractedAssetPayload = extractExternalWorldAssetPayload(externalData);
const rawMeshUrl = extractedAssetPayload.meshUrl;
const rawThumbnailUrl = extractedAssetPayload.thumbnailUrl;
const rawPanoUrl = extractedAssetPayload.panoUrl;
const stableWorldUrl: string | null = null;
const stableModelUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:mesh`,
sourceUrl: rawMeshUrl,
filenamePrefix: `worldlabs-mesh-${normalizedTaskId}`,
fallbackExtension: "glb",
validateAs: "glb",
});
const stableThumbnailUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:thumb`,
sourceUrl: rawThumbnailUrl,
filenamePrefix: `worldlabs-thumb-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stablePanoUrl = await archiveRemoteAssetIfNeeded({
cacheKey: `worldlabs:${normalizedTaskId}:pano`,
sourceUrl: rawPanoUrl,
filenamePrefix: `worldlabs-pano-${normalizedTaskId}`,
fallbackExtension: "jpg",
});
const stableSpzUrls = await archiveRemoteAssetMapIfNeeded({
cacheKeyPrefix: `worldlabs:${normalizedTaskId}:spz`,
sourceMap: extractedAssetPayload.spzUrls,
filenamePrefix: `worldlabs-splat-${normalizedTaskId}`,
fallbackExtension: "spz",
validateAs: "spz",
});
const assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
worldUrl: stableWorldUrl,
displayName: extractedAssetPayload.displayName,
model: resolveThreeDModelAliasId(
extractedAssetPayload.model,
modelAliasConfig.worldModels
),
caption: extractedAssetPayload.caption,
thumbnailUrl: stableThumbnailUrl || extractedAssetPayload.thumbnailUrl,
panoUrl: stablePanoUrl || extractedAssetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
storageArchivedAt: new Date().toISOString(),
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
? rawMeshUrl
: null,
sourceThumbnailUrl:
rawThumbnailUrl && stableThumbnailUrl && rawThumbnailUrl !== stableThumbnailUrl
? rawThumbnailUrl
: null,
sourcePanoUrl:
rawPanoUrl && stablePanoUrl && rawPanoUrl !== stablePanoUrl
? rawPanoUrl
: null,
sourceSpzUrls:
extractedAssetPayload.spzUrls && stableSpzUrls !== extractedAssetPayload.spzUrls
? extractedAssetPayload.spzUrls
: null,
};
await updatePersistedWorldAsset({
id: persistedWorldAsset?.id,
status: "success",
progress: 100,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
error: null,
assetPayload,
});
logger.info("external task success status", {
elapsedMs: Date.now() - startedAt,
taskIdLength: normalizedTaskId.length,
providerId: config.providerId || null,
hasMesh: Boolean(stableModelUrl),
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
});
return NextResponse.json({
success: true,
data: {
taskId: normalizedTaskId,
type: "world",
status: "success",
progress: 100,
output: externalData,
modelUrl: stableModelUrl,
previewImageUrl: stableThumbnailUrl || stablePanoUrl || null,
worldId: assetPayload.worldId,
worldUrl: stableWorldUrl,
panoUrl: stablePanoUrl,
caption: assetPayload.caption,
assetPayload,
},
});
}
const operation = await worldLabsRequest<WorldLabsOperation>(config, { const operation = await worldLabsRequest<WorldLabsOperation>(config, {
method: "GET", method: "GET",
path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`,

View File

@ -12,13 +12,23 @@ import {
import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server"; import { getThreeDModelAliasConfigForRuntime } from "@/lib/model3d/model-alias-config-server";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { get3DWorldCreditCost } from "@/lib/points/config"; import { get3DWorldCreditCost } from "@/lib/points/config";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import {
isWorldLabsExternalTaskApi,
WORLDLABS_EXTERNAL_TASK_MODEL,
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import { import {
getWorldLabsProviderConfig, getWorldLabsProviderConfig,
getWorldLabsProviderConfigs, getWorldLabsProviderConfigs,
reportWorldLabsProviderRuntimeFailure, reportWorldLabsProviderRuntimeFailure,
} from "@/lib/worldlabs/server"; } from "@/lib/worldlabs/server";
import type { WorldLabsModel, WorldLabsOperation } from "@/lib/worldlabs/types"; import type {
WorldLabsExternalTaskCreateData,
WorldLabsExternalTaskResponse,
WorldLabsModel,
WorldLabsOperation,
} from "@/lib/worldlabs/types";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 180; export const maxDuration = 180;
@ -47,8 +57,10 @@ type CreateWorldTaskRequest = {
makePublic?: boolean; makePublic?: boolean;
reconstructImages?: boolean; reconstructImages?: boolean;
mediaAssetId?: string; mediaAssetId?: string;
sourceUrl?: string;
mediaAssets?: Array<{ mediaAssets?: Array<{
mediaAssetId?: string; mediaAssetId?: string;
sourceUrl?: string;
azimuth?: number | null; azimuth?: number | null;
}>; }>;
}; };
@ -58,10 +70,89 @@ function readOptionalTrimmedText(input: unknown) {
return value || null; return value || null;
} }
function readOptionalHttpUrl(input: unknown) {
const value = readOptionalTrimmedText(input);
if (!value) return null;
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:"
? parsed.toString()
: null;
} catch {
return null;
}
}
function getObjectValue(value: unknown) {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function unwrapExternalTaskData<T extends Record<string, unknown>>(payload: unknown) {
const root = getObjectValue(payload);
const data = getObjectValue(root?.data);
return (data || root || {}) as T;
}
function normalizeExternalTaskStatus(status?: string | null, done?: boolean) {
const normalized = String(status || "").trim().toLowerCase();
if (done === true || ["succeeded", "success", "completed", "done"].includes(normalized)) {
return "success" as const;
}
if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
return "failed" as const;
}
return "running" as const;
}
function normalizeExternalTaskCreateOperation(
payload: WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
): WorldLabsOperation {
const data = unwrapExternalTaskData<WorldLabsExternalTaskCreateData>(payload);
const taskId = readOptionalTrimmedText(data.taskId || data.task_id || data.id);
const status = readOptionalTrimmedText(data.status) || "accepted";
const normalizedStatus = normalizeExternalTaskStatus(status, data.done === true);
return {
operation_id: taskId || undefined,
done: normalizedStatus === "success",
error:
normalizedStatus === "failed"
? {
message:
readOptionalTrimmedText(
(payload as Record<string, unknown>)?.message ||
(payload as Record<string, unknown>)?.error
) || "World Labs 外部任务创建失败",
}
: null,
metadata: {
externalTaskApi: true,
status,
resultPath: readOptionalTrimmedText(data.resultPath || data.result_path),
requestId: readOptionalTrimmedText(payload.requestId || payload.request_id),
},
response: data,
};
}
function shouldCharge3DPoints(mode: WorldTaskMode) { function shouldCharge3DPoints(mode: WorldTaskMode) {
return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video"; return mode === "text" || mode === "image" || mode === "multi-image" || mode === "video";
} }
function shouldUseExternalTaskCreateApi(
config: Awaited<ReturnType<typeof getWorldLabsProviderConfig>>,
mode: WorldTaskMode
) {
return isWorldLabsExternalTaskApi(config.baseUrl) && mode === "text";
}
function resolveOfficialMarbleModel(model?: string | null) {
const normalized = readOptionalTrimmedText(model);
if (!normalized || normalized === WORLDLABS_EXTERNAL_TASK_MODEL) {
return "marble-1.1";
}
return normalized;
}
function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) { function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const textPrompt = readOptionalTrimmedText(body.prompt); const textPrompt = readOptionalTrimmedText(body.prompt);
@ -80,14 +171,20 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
if (mode === "image") { if (mode === "image") {
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId); const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) { const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
throw new Error("图生世界缺少 mediaAssetId"); if (!mediaAssetId && !sourceUrl) {
throw new Error("图生世界缺少 mediaAssetId 或 sourceUrl");
} }
return { return {
type: "image", type: "image",
image_prompt: { image_prompt: mediaAssetId
? {
source: "media_asset", source: "media_asset",
media_asset_id: mediaAssetId, media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
}, },
...(textPrompt ? { text_prompt: textPrompt } : {}), ...(textPrompt ? { text_prompt: textPrompt } : {}),
...(body.disableRecaption !== undefined ...(body.disableRecaption !== undefined
@ -102,7 +199,8 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
const multiImagePrompt = mediaAssets const multiImagePrompt = mediaAssets
.map((item) => { .map((item) => {
const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId); const mediaAssetId = readOptionalTrimmedText(item?.mediaAssetId);
if (!mediaAssetId) return null; const sourceUrl = readOptionalHttpUrl(item?.sourceUrl);
if (!mediaAssetId && !sourceUrl) return null;
const azimuth = const azimuth =
item?.azimuth === null || item?.azimuth === undefined item?.azimuth === null || item?.azimuth === undefined
? null ? null
@ -111,9 +209,14 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
: null; : null;
return { return {
...(azimuth === null ? {} : { azimuth }), ...(azimuth === null ? {} : { azimuth }),
content: { content: mediaAssetId
? {
source: "media_asset", source: "media_asset",
media_asset_id: mediaAssetId, media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
}, },
}; };
}) })
@ -137,15 +240,21 @@ function buildWorldPrompt(body: CreateWorldTaskRequest, mode: WorldTaskMode) {
} }
const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId); const mediaAssetId = readOptionalTrimmedText(body.mediaAssetId);
if (!mediaAssetId) { const sourceUrl = readOptionalHttpUrl(body.sourceUrl);
throw new Error("视频生成世界缺少 mediaAssetId"); if (!mediaAssetId && !sourceUrl) {
throw new Error("视频生成世界缺少 mediaAssetId 或 sourceUrl");
} }
return { return {
type: "video", type: "video",
video_prompt: { video_prompt: mediaAssetId
? {
source: "media_asset", source: "media_asset",
media_asset_id: mediaAssetId, media_asset_id: mediaAssetId,
}
: {
source: "uri",
uri: sourceUrl,
}, },
...(textPrompt ? { text_prompt: textPrompt } : {}), ...(textPrompt ? { text_prompt: textPrompt } : {}),
}; };
@ -187,12 +296,29 @@ export async function POST(request: Request) {
); );
modelForLog = resolvedWorldModel || "marble-1.1"; modelForLog = resolvedWorldModel || "marble-1.1";
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
const officialMarbleModel = resolveOfficialMarbleModel(resolvedWorldModel);
const predictedBillingModel = candidates.some((candidate) =>
shouldUseExternalTaskCreateApi(candidate, mode)
)
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel;
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const shouldChargePoints = shouldCharge3DPoints(mode); const shouldChargePoints = shouldCharge3DPoints(mode);
const unitCost = get3DWorldCreditCost( const unitCost = get3DWorldCreditCost(pointsConfig, predictedBillingModel);
pointsConfig,
resolvedWorldModel || "marble-1.1",
);
if ( if (
shouldChargePoints && shouldChargePoints &&
@ -220,42 +346,51 @@ export async function POST(request: Request) {
} }
} }
const preferredProviderId = readOptionalTrimmedText(body.providerId);
const displayName =
readOptionalTrimmedText(body.displayName) ||
readOptionalTrimmedText(body.prompt) ||
`World ${new Date().toLocaleString("zh-CN")}`;
const worldPrompt = buildWorldPrompt(body, mode);
const candidates = preferredProviderId
? [await getWorldLabsProviderConfig(preferredProviderId)]
: await getWorldLabsProviderConfigs();
if (candidates.length === 0) {
throw new Error("未找到可用的 World Labs Key");
}
let result: WorldLabsOperation | null = null; let result: WorldLabsOperation | null = null;
let usedProvider = null as Awaited< let usedProvider = null as Awaited<
ReturnType<typeof getWorldLabsProviderConfig> ReturnType<typeof getWorldLabsProviderConfig>
> | null; > | null;
let usedExternalTaskApi = false;
let lastError: unknown = null; let lastError: unknown = null;
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const useExternalTaskApi = shouldUseExternalTaskCreateApi(candidate, mode);
if (useExternalTaskApi) {
const prompt = readOptionalTrimmedText(body.prompt);
if (!prompt) {
throw new Error("请输入世界描述");
}
const externalResult = await worldLabsRequest<
WorldLabsExternalTaskResponse<WorldLabsExternalTaskCreateData>
>(candidate, {
method: "POST",
path: "/api/external/task/generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: {
model: WORLDLABS_EXTERNAL_TASK_MODEL,
displayName,
prompt,
},
});
result = normalizeExternalTaskCreateOperation(externalResult);
} else {
result = await worldLabsRequest<WorldLabsOperation>(candidate, { result = await worldLabsRequest<WorldLabsOperation>(candidate, {
method: "POST", method: "POST",
path: "/marble/v1/worlds:generate", path: "/marble/v1/worlds:generate",
timeoutMs: resolveWorldLabsCreateTimeoutMs(), timeoutMs: resolveWorldLabsCreateTimeoutMs(),
json: { json: {
display_name: displayName, display_name: displayName,
model: resolvedWorldModel || "marble-1.1", model: officialMarbleModel,
permission: { permission: {
public: body.makePublic === true, public: body.makePublic === true,
}, },
world_prompt: worldPrompt, world_prompt: worldPrompt,
}, },
}); });
}
usedProvider = candidate; usedProvider = candidate;
usedExternalTaskApi = useExternalTaskApi;
providerForLog = candidate.providerId || null; providerForLog = candidate.providerId || null;
break; break;
} catch (error) { } catch (error) {
@ -292,12 +427,18 @@ export async function POST(request: Request) {
if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) { if (shouldChargePoints && pointsConfig.enabled && unitCost > 0) {
try { try {
const usedBillingModel = usedExternalTaskApi
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel;
const usedBillingDisplayName = usedBillingModel === WORLDLABS_EXTERNAL_TASK_MODEL
? WORLDLABS_EXTERNAL_TASK_MODEL
: worldModelDisplayName;
await consumeUserPoints({ await consumeUserPoints({
userId: session.user.id, userId: session.user.id,
amount: unitCost, amount: unitCost,
reason: "world3d_generation", reason: "world3d_generation",
modelId: `worldlabs-3d:${resolvedWorldModel || "marble-1.1"}`, modelId: `worldlabs-3d:${usedBillingModel}`,
note: `3D世界生成消耗积分(${worldModelDisplayName})`, note: `3D世界生成消耗积分(${usedBillingDisplayName})`,
}); });
} catch (error) { } catch (error) {
logger.error("points consume failed", error, { logger.error("points consume failed", error, {
@ -313,10 +454,13 @@ export async function POST(request: Request) {
elapsedMs: Date.now() - startedAt, elapsedMs: Date.now() - startedAt,
taskIdLength: taskId.length, taskIdLength: taskId.length,
mode, mode,
model: resolvedWorldModel || "marble-1.1", model: usedExternalTaskApi
? WORLDLABS_EXTERNAL_TASK_MODEL
: officialMarbleModel,
providerId: usedProvider.providerId || null, providerId: usedProvider.providerId || null,
status: result.done ? "success" : "running", status: result.done ? "success" : "running",
unitCost, unitCost,
externalTaskApi: usedExternalTaskApi,
}); });
return NextResponse.json({ return NextResponse.json({
@ -327,6 +471,8 @@ export async function POST(request: Request) {
assetPayload: { assetPayload: {
providerId: usedProvider.providerId || null, providerId: usedProvider.providerId || null,
providerName: usedProvider.providerName || null, providerName: usedProvider.providerName || null,
externalTaskApi: usedExternalTaskApi,
marbleApi: !usedExternalTaskApi,
}, },
raw: result, raw: result,
}, },

View File

@ -1,7 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { WorldLabsApiError, worldLabsRequest } from "@/lib/worldlabs/client"; import { uploadFile } from "@/lib/storage/service";
import {
WorldLabsApiError,
worldLabsRequest,
} from "@/lib/worldlabs/client";
import { import {
getWorldLabsProviderConfig, getWorldLabsProviderConfig,
getWorldLabsProviderConfigs, getWorldLabsProviderConfigs,
@ -112,6 +116,81 @@ function normalizeMediaAssetId(data: WorldLabsPrepareUploadResponse) {
return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim(); return String(data.media_asset?.media_asset_id || data.media_asset?.id || "").trim();
} }
function readOptionalString(value: unknown) {
const text = String(value || "").trim();
return text || null;
}
function extractUploadInfoFromCurlExample(curlExample: unknown) {
const curl = String(curlExample || "");
const uploadUrl =
[...curl.matchAll(/https?:\/\/[^\s'"\\]+/g)]
.map((match) => match[0])
.find(Boolean) || null;
const requiredHeaders: Record<string, string> = {};
const headerPattern = /(?:-H|--header)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
let headerMatch: RegExpExecArray | null;
while ((headerMatch = headerPattern.exec(curl))) {
const header = String(
headerMatch[1] || headerMatch[2] || headerMatch[3] || ""
).trim();
const colonIndex = header.indexOf(":");
if (colonIndex <= 0) continue;
const name = header.slice(0, colonIndex).trim();
const value = header.slice(colonIndex + 1).trim();
if (name && value) {
requiredHeaders[name] = value;
}
}
return { uploadUrl, requiredHeaders };
}
function resolvePrepareUploadDetails(data: WorldLabsPrepareUploadResponse) {
const uploadInfo = data.upload_info || {};
const uploadInfoRecord = uploadInfo as Record<string, unknown>;
const curlDetails = extractUploadInfoFromCurlExample(uploadInfo.curl_example);
const explicitHeaders =
uploadInfo.required_headers && typeof uploadInfo.required_headers === "object"
? uploadInfo.required_headers
: {};
return {
uploadUrl:
readOptionalString(uploadInfo.upload_url) ||
readOptionalString(uploadInfoRecord.uploadUrl) ||
readOptionalString(uploadInfoRecord.url) ||
curlDetails.uploadUrl,
uploadMethod:
readOptionalString(uploadInfo.upload_method) ||
readOptionalString(uploadInfoRecord.method) ||
"PUT",
requiredHeaders: {
...curlDetails.requiredHeaders,
...explicitHeaders,
},
};
}
function isSignedUploadFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error || "");
return (
message.includes("SignatureDoesNotMatch") ||
message.includes("Access denied") ||
message.includes("Google secret key") ||
message.includes("签名")
);
}
async function uploadWorldLabsInputToStorage(file: File, extension: string) {
const buffer = await file.arrayBuffer();
const safeExtension = extension && extension !== "bin" ? extension : "bin";
const filename = `worldlabs-input-${Date.now()}-${crypto.randomUUID()}.${safeExtension}`;
const uploaded = await uploadFile(filename, buffer);
return uploaded.url;
}
function isCredentialOrBillingProviderError(error: unknown) { function isCredentialOrBillingProviderError(error: unknown) {
return ( return (
error instanceof WorldLabsApiError && error instanceof WorldLabsApiError &&
@ -202,24 +281,24 @@ export async function POST(request: Request) {
); );
const mediaAssetId = normalizeMediaAssetId(prepareUpload); const mediaAssetId = normalizeMediaAssetId(prepareUpload);
const uploadUrl = String(prepareUpload.upload_info?.upload_url || "").trim(); const { uploadUrl, uploadMethod, requiredHeaders } =
resolvePrepareUploadDetails(prepareUpload);
if (!mediaAssetId || !uploadUrl) { if (!mediaAssetId || !uploadUrl) {
throw new Error("World Labs 返回的上传信息不完整"); throw new Error("World Labs 返回的上传信息不完整");
} }
const uploadHeaders = new Headers(); const uploadHeaders = new Headers();
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
const requiredHeaders = prepareUpload.upload_info?.required_headers || {};
for (const [headerName, headerValue] of Object.entries(requiredHeaders)) { for (const [headerName, headerValue] of Object.entries(requiredHeaders)) {
if (!headerName || headerValue === undefined || headerValue === null) continue; if (!headerName || headerValue === undefined || headerValue === null) continue;
uploadHeaders.set(headerName, String(headerValue)); uploadHeaders.set(headerName, String(headerValue));
} }
if (!uploadHeaders.has("Content-Type") && !prepareUpload.upload_info?.curl_example) {
uploadHeaders.set("Content-Type", file.type || "application/octet-stream");
}
const uploadMethod = String( const normalizedUploadMethod = uploadMethod.toUpperCase();
prepareUpload.upload_info?.upload_method || "PUT"
).toUpperCase();
const uploadResponse = await fetch(uploadUrl, { const uploadResponse = await fetch(uploadUrl, {
method: uploadMethod === "PUT" ? "PUT" : uploadMethod, method: normalizedUploadMethod === "PUT" ? "PUT" : normalizedUploadMethod,
headers: uploadHeaders, headers: uploadHeaders,
body: file, body: file,
cache: "no-store", cache: "no-store",
@ -252,6 +331,28 @@ export async function POST(request: Request) {
}); });
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (isSignedUploadFailure(error)) {
try {
const sourceUrl = await uploadWorldLabsInputToStorage(file, extension);
logger.warn("worldlabs signed upload failed; using storage source url", {
elapsedMs: Date.now() - startedAt,
providerId: config.providerId || null,
kind: isImage ? "image" : "video",
size: file.size,
});
return NextResponse.json({
success: true,
data: {
sourceUrl,
providerId: config.providerId || null,
providerName: config.providerName || null,
uploadFallback: "storage-source-url",
},
});
} catch (fallbackError) {
lastError = fallbackError;
}
}
if (isCredentialOrBillingProviderError(error)) { if (isCredentialOrBillingProviderError(error)) {
await reportWorldLabsProviderRuntimeFailure( await reportWorldLabsProviderRuntimeFailure(
config.providerId, config.providerId,

View File

@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { clientPerformanceEvent } from "@/lib/db/schema"; import { clientPerformanceEvent } from "@/lib/db/schema";
@ -167,9 +168,12 @@ export async function POST(request: Request) {
console.warn("[ClientPerformance] prune failed", error); console.warn("[ClientPerformance] prune failed", error);
}); });
const persistedChatId =
event.chatId && !isAgentModeScopedChatId(event.chatId) ? event.chatId : null;
await db.insert(clientPerformanceEvent).values({ await db.insert(clientPerformanceEvent).values({
userId: session?.user?.id || null, userId: session?.user?.id || null,
chatId: event.chatId || null, chatId: persistedChatId,
projectId: clampText(event.projectId, 64), projectId: clampText(event.projectId, 64),
eventType: clampText(event.eventType, 64) || "unknown", eventType: clampText(event.eventType, 64) || "unknown",
section: event.section || "unknown", section: event.section || "unknown",

View File

@ -141,6 +141,7 @@ import {
createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentGenerationProjectWorkspaceState,
createEmptyAgentWorkflowPersistedState, createEmptyAgentWorkflowPersistedState,
normalizeAgentGenerationProjectPersistedState, normalizeAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectReplacementResultState,
} from "@/lib/agents/generation-project-state"; } from "@/lib/agents/generation-project-state";
import { import {
createEmptyAgentModeWorkspaceState, createEmptyAgentModeWorkspaceState,
@ -192,11 +193,6 @@ import {
sanitizeAgentWorkflowSelections, sanitizeAgentWorkflowSelections,
} from "@/lib/agents/workflow-ui-config"; } from "@/lib/agents/workflow-ui-config";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import {
loadCanvasChatPanelModule,
preloadCanvasChatPanel,
} from "@/components/canvas/canvas-chat-panel-loader";
import { preloadCanvasChatData } from "@/lib/client/canvas-chat-preload";
import { import {
buildVideoWorkflowCanvasNode, buildVideoWorkflowCanvasNode,
getVideoWorkflowNodeDefinition, getVideoWorkflowNodeDefinition,
@ -232,6 +228,7 @@ import { canvasSelectors, useCanvasStore } from "@/store/canvas";
import type { import type {
AgentGenerationProject, AgentGenerationProject,
AgentGenerationProjectPersistedState, AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections, AgentGenerationProjectResultCollections,
AgentGenerationProjectWorkspaceAttachment, AgentGenerationProjectWorkspaceAttachment,
AgentGenerationProjectWorkspaceState, AgentGenerationProjectWorkspaceState,
@ -316,7 +313,7 @@ const CanvasWorkspace = dynamic(
const CanvasChatPanel = dynamic( const CanvasChatPanel = dynamic(
() => () =>
loadCanvasChatPanelModule().then( import("@/components/canvas/CanvasChatPanel").then(
(module) => module.CanvasChatPanel (module) => module.CanvasChatPanel
), ),
{ {
@ -1140,7 +1137,7 @@ function normalizeImportedCanvasState(payload: unknown): {
}; };
} }
const FLOATING_PANEL_WIDTH = 380; const FLOATING_PANEL_WIDTH = 400;
const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75; const FLOATING_PANEL_MAX_VIEWPORT_RATIO = 0.75;
const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1"; const FLOATING_PANEL_WIDTH_STORAGE_KEY = "canvas.chatPanel.width.v1";
const FLOATING_PANEL_INSET = 16; const FLOATING_PANEL_INSET = 16;
@ -1545,6 +1542,57 @@ function normalizeResultCollectionUrls(urls: string[] | undefined | null) {
); );
} }
function areReplacementResultStatesEqual(
left: AgentGenerationProjectReplacementResultState | undefined | null,
right: AgentGenerationProjectReplacementResultState | undefined | null
) {
return (
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
);
}
function getReplacementResultUrlCount(
state: AgentGenerationProjectPersistedState | undefined | null
) {
const normalized = normalizeAgentGenerationProjectReplacementResultState(
state?.replacementResultState
);
return (
Object.values(normalized.detail_replacement || {}).filter((record) =>
Boolean(record.url)
).length +
Object.values(normalized.sku_replacement || {}).filter((record) =>
Boolean(record.url)
).length
);
}
function hasReplacementResultCollectionMismatch(
state: AgentGenerationProjectPersistedState | undefined | null
) {
const normalized = normalizeAgentGenerationProjectReplacementResultState(
state?.replacementResultState
);
const resultCollections = state?.resultCollections || {};
const countModeRecords = (
modeType: "detail_replacement" | "sku_replacement"
) =>
Object.values(normalized[modeType] || {}).filter((record) =>
Boolean(record.url)
).length;
const countModeCollection = (
modeType: "detail_replacement" | "sku_replacement"
) => normalizeResultCollectionUrls(resultCollections[modeType]).length;
return (["detail_replacement", "sku_replacement"] as const).some(
(modeType) => {
const recordCount = countModeRecords(modeType);
return recordCount > 0 && countModeCollection(modeType) !== recordCount;
}
);
}
const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:"; const AGENT_PROJECT_DRAFT_STORAGE_KEY_PREFIX = "agent-project-draft:";
function getAgentProjectDraftStorageKey(projectId: string) { function getAgentProjectDraftStorageKey(projectId: string) {
@ -1981,7 +2029,7 @@ export default function CanvasEditorLegacyPage({
); );
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
useState<string | null>(null); useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode); const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [floatingPanelWidth, setFloatingPanelWidth] = const [floatingPanelWidth, setFloatingPanelWidth] =
useState(FLOATING_PANEL_WIDTH); useState(FLOATING_PANEL_WIDTH);
const [ const [
@ -1990,7 +2038,8 @@ export default function CanvasEditorLegacyPage({
] = useState(false); ] = useState(false);
const [isFloatingPanelResizing, setIsFloatingPanelResizing] = const [isFloatingPanelResizing, setIsFloatingPanelResizing] =
useState(false); useState(false);
const areSecondaryPanelsReady = true; const [areSecondaryPanelsReady, setAreSecondaryPanelsReady] =
useState(false);
const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] = const [isVideoModuleSidebarCollapsed, setIsVideoModuleSidebarCollapsed] =
useState(false); useState(false);
const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState< const [videoModuleSectionOpen, setVideoModuleSectionOpen] = useState<
@ -2092,6 +2141,10 @@ export default function CanvasEditorLegacyPage({
agentProjectDeletedResultCollections, agentProjectDeletedResultCollections,
setAgentProjectDeletedResultCollections, setAgentProjectDeletedResultCollections,
] = useState<AgentGenerationProjectResultCollections>({}); ] = useState<AgentGenerationProjectResultCollections>({});
const [
agentProjectReplacementResultState,
setAgentProjectReplacementResultState,
] = useState<AgentGenerationProjectReplacementResultState>({});
const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false); const [isAgentProjectLoading, setIsAgentProjectLoading] = useState(false);
const [loadedAgentProjectId, setLoadedAgentProjectId] = useState< const [loadedAgentProjectId, setLoadedAgentProjectId] = useState<
string | null string | null
@ -3298,13 +3351,11 @@ export default function CanvasEditorLegacyPage({
}, [checkCanvasAuthSession]); }, [checkCanvasAuthSession]);
useEffect(() => { useEffect(() => {
preloadCanvasChatPanel(); const timeoutId = window.setTimeout(() => {
preloadCanvasChatData(id); setAreSecondaryPanelsReady(true);
}, 800);
return scheduleCanvasIdleTask(() => { return () => window.clearTimeout(timeoutId);
void import("@/components/canvas/CanvasFileListPanel"); }, []);
}, 700);
}, [id]);
const hydrateCanvasProject = useCallback( const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => { (project: CanvasInitialProject) => {
@ -3471,6 +3522,7 @@ export default function CanvasEditorLegacyPage({
setAgentProjectThumbnail(null); setAgentProjectThumbnail(null);
setAgentProjectResultCollections({}); setAgentProjectResultCollections({});
setAgentProjectDeletedResultCollections({}); setAgentProjectDeletedResultCollections({});
setAgentProjectReplacementResultState({});
setLoadedAgentProjectId(null); setLoadedAgentProjectId(null);
} }
@ -3962,8 +4014,15 @@ export default function CanvasEditorLegacyPage({
...resultCollections.sku_replacement, ...resultCollections.sku_replacement,
]) ])
), ),
replacementResultState: normalizeAgentGenerationProjectReplacementResultState(
agentProjectReplacementResultState
),
}; };
}, [agentProjectDeletedResultCollections, agentProjectResultCollections]); }, [
agentProjectDeletedResultCollections,
agentProjectReplacementResultState,
agentProjectResultCollections,
]);
const buildPersistedAgentProjectState = useCallback(() => { const buildPersistedAgentProjectState = useCallback(() => {
const persistedMaterials = appearanceRecognitionEnabled const persistedMaterials = appearanceRecognitionEnabled
@ -4148,6 +4207,9 @@ export default function CanvasEditorLegacyPage({
setAgentProjectDeletedResultCollections( setAgentProjectDeletedResultCollections(
persistedState.deletedResultCollections || {} persistedState.deletedResultCollections || {}
); );
setAgentProjectReplacementResultState(
persistedState.replacementResultState || {}
);
setLoadedAgentProjectId(params.projectId); setLoadedAgentProjectId(params.projectId);
}, },
[] []
@ -4322,7 +4384,9 @@ export default function CanvasEditorLegacyPage({
const result = await agentProjectService.updateProject(agentProjectId, { const result = await agentProjectService.updateProject(agentProjectId, {
title: nextTitle, title: nextTitle,
thumbnail: agentProjectThumbnail, thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(), projectState:
persistedAgentProjectStateRef.current ||
buildPersistedAgentProjectState(),
}); });
if (!result.success) { if (!result.success) {
@ -4388,9 +4452,27 @@ export default function CanvasEditorLegacyPage({
const serverUpdatedAt = const serverUpdatedAt =
Number(new Date(result.data.updatedAt).getTime()) || 0; Number(new Date(result.data.updatedAt).getTime()) || 0;
const serverReplacementResultCount = getReplacementResultUrlCount(
result.data.projectState
);
const localReplacementResultCount = getReplacementResultUrlCount(
localDraft?.projectState
);
const localDraftMissesServerReplacementResults =
serverReplacementResultCount > 0 && localReplacementResultCount === 0;
const localDraftReplacementResultsDifferFromServer =
serverReplacementResultCount > 0 &&
localReplacementResultCount > 0 &&
localReplacementResultCount !== serverReplacementResultCount;
const localDraftHasStaleReplacementCollections =
serverReplacementResultCount > 0 &&
hasReplacementResultCollectionMismatch(localDraft?.projectState);
const shouldUseLocalDraft = const shouldUseLocalDraft =
Boolean(localDraft?.projectState) && Boolean(localDraft?.projectState) &&
Number(localDraft?.savedAt || 0) > serverUpdatedAt; Number(localDraft?.savedAt || 0) > serverUpdatedAt &&
!localDraftMissesServerReplacementResults &&
!localDraftReplacementResultsDifferFromServer &&
!localDraftHasStaleReplacementCollections;
if (shouldUseLocalDraft && localDraft?.projectState) { if (shouldUseLocalDraft && localDraft?.projectState) {
hydrateAgentProjectSnapshot({ hydrateAgentProjectSnapshot({
@ -4492,6 +4574,7 @@ export default function CanvasEditorLegacyPage({
}, [ }, [
agentModeState, agentModeState,
agentProjectId, agentProjectId,
agentProjectReplacementResultState,
agentProjectResultCollections, agentProjectResultCollections,
agentProjectThumbnail, agentProjectThumbnail,
agentProjectTitle, agentProjectTitle,
@ -4782,32 +4865,66 @@ export default function CanvasEditorLegacyPage({
const handleAgentResultPreviewUrlsChange = useCallback( const handleAgentResultPreviewUrlsChange = useCallback(
(modeType: AgentGenerationResultModeType, urls: string[]) => { (modeType: AgentGenerationResultModeType, urls: string[]) => {
const normalizedUrls = normalizeResultCollectionUrls(urls); const normalizedUrls = normalizeResultCollectionUrls(urls);
setAgentProjectResultCollections((current) => { const currentProjectState =
persistedAgentProjectStateRef.current ||
createEmptyAgentGenerationProjectPersistedState();
const currentCollections = currentProjectState.resultCollections || {};
const nextCollections: AgentGenerationProjectResultCollections = { const nextCollections: AgentGenerationProjectResultCollections = {
...current, ...currentCollections,
[modeType]: normalizedUrls, [modeType]: normalizedUrls,
}; };
if (areResultCollectionsEqual(current, nextCollections)) { if (!areResultCollectionsEqual(currentCollections, nextCollections)) {
return current;
}
const nextProjectState: AgentGenerationProjectPersistedState = { const nextProjectState: AgentGenerationProjectPersistedState = {
...persistedAgentProjectStateRef.current, ...currentProjectState,
resultCollections: nextCollections, resultCollections: nextCollections,
resultImageUrls: getAllResultUrlsFromCollections(nextCollections), resultImageUrls: getAllResultUrlsFromCollections(nextCollections),
}; };
persistedAgentProjectStateRef.current = nextProjectState; persistedAgentProjectStateRef.current = nextProjectState;
writeAgentProjectDraftSnapshot(nextProjectState); writeAgentProjectDraftSnapshot(nextProjectState);
}
setAgentProjectResultCollections((current) =>
areResultCollectionsEqual(current, nextCollections)
? current
: nextCollections
);
setAgentProjectThumbnail((currentThumbnail) => { setAgentProjectThumbnail((currentThumbnail) => {
const nextThumbnail = const nextThumbnail =
normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections); normalizedUrls[0] || getFirstResultUrlFromCollections(nextCollections);
return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail; return currentThumbnail === nextThumbnail ? currentThumbnail : nextThumbnail;
}); });
},
[writeAgentProjectDraftSnapshot]
);
return nextCollections; const handleAgentReplacementResultStateChange = useCallback(
}); (nextState: AgentGenerationProjectReplacementResultState) => {
const normalizedState =
normalizeAgentGenerationProjectReplacementResultState(nextState);
const currentProjectState =
persistedAgentProjectStateRef.current ||
createEmptyAgentGenerationProjectPersistedState();
if (
!areReplacementResultStatesEqual(
currentProjectState.replacementResultState,
normalizedState
)
) {
const nextProjectState: AgentGenerationProjectPersistedState = {
...currentProjectState,
replacementResultState: normalizedState,
};
persistedAgentProjectStateRef.current = nextProjectState;
writeAgentProjectDraftSnapshot(nextProjectState);
}
setAgentProjectReplacementResultState((current) =>
areReplacementResultStatesEqual(current, normalizedState)
? current
: normalizedState
);
}, },
[writeAgentProjectDraftSnapshot] [writeAgentProjectDraftSnapshot]
); );
@ -4845,6 +4962,33 @@ export default function CanvasEditorLegacyPage({
? current ? current
: nextDeletedCollections : nextDeletedCollections
); );
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
setAgentProjectReplacementResultState((current) => {
const normalizedState =
normalizeAgentGenerationProjectReplacementResultState(current);
const currentModeState = normalizedState[modeType] || {};
const nextModeState = Object.fromEntries(
Object.entries(currentModeState).filter(
([, record]) => record.url !== normalizedUrl
)
);
const nextState: AgentGenerationProjectReplacementResultState = {
...normalizedState,
[modeType]: nextModeState,
};
if (areReplacementResultStatesEqual(normalizedState, nextState)) {
return current;
}
const nextProjectState: AgentGenerationProjectPersistedState = {
...persistedAgentProjectStateRef.current,
replacementResultState: nextState,
};
persistedAgentProjectStateRef.current = nextProjectState;
writeAgentProjectDraftSnapshot(nextProjectState);
return nextState;
});
}
setAgentProjectThumbnail((current) => { setAgentProjectThumbnail((current) => {
const nextThumbnail = const nextThumbnail =
current === normalizedUrl current === normalizedUrl
@ -4853,7 +4997,11 @@ export default function CanvasEditorLegacyPage({
return current === nextThumbnail ? current : nextThumbnail; return current === nextThumbnail ? current : nextThumbnail;
}); });
}, },
[agentProjectDeletedResultCollections, agentProjectResultCollections] [
agentProjectDeletedResultCollections,
agentProjectResultCollections,
writeAgentProjectDraftSnapshot,
]
); );
useEffect(() => { useEffect(() => {
@ -6827,6 +6975,7 @@ export default function CanvasEditorLegacyPage({
deletedResultCollections: deletedResultCollections:
existingPersistedState.deletedResultCollections || {}, existingPersistedState.deletedResultCollections || {},
resultImageUrls: existingPersistedState.resultImageUrls || [], resultImageUrls: existingPersistedState.resultImageUrls || [],
replacementResultState: existingPersistedState.replacementResultState || {},
}; };
const payload = { const payload = {
@ -10113,10 +10262,13 @@ export default function CanvasEditorLegacyPage({
mode: null, mode: null,
}); });
}} }}
onPersistProject={() => persistAgentProject("manual")}
onReplacementResultStateChange={handleAgentReplacementResultStateChange}
onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange} onResultPreviewUrlsChange={handleAgentResultPreviewUrlsChange}
onWorkspaceStateChange={setAgentWorkspaceState} onWorkspaceStateChange={setAgentWorkspaceState}
projectId={agentProjectId} projectId={agentProjectId}
key={agentProjectId} key={agentProjectId}
replacementResultState={agentProjectReplacementResultState}
resultCollections={agentProjectResultCollections} resultCollections={agentProjectResultCollections}
selectedAgentPresetId={ selectedAgentPresetId={
isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null isAgentWorkspaceEnabled ? effectiveSelectedAgentPresetId : null
@ -10869,7 +11021,7 @@ export default function CanvasEditorLegacyPage({
> >
<Button <Button
className={cn( className={cn(
"h-8 rounded-full px-2.5 text-[12px] font-medium", "h-10 rounded-full px-3 font-medium text-sm",
isVideoMode isVideoMode
? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82" ? "border border-border/60 bg-background/94 text-foreground shadow-sm backdrop-blur-xl hover:bg-background supports-[backdrop-filter]:bg-background/82"
: "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background" : "border bg-background/90 text-muted-foreground shadow-sm backdrop-blur-md hover:bg-background"
@ -10877,7 +11029,7 @@ export default function CanvasEditorLegacyPage({
onClick={() => setIsPanelOpen(true)} onClick={() => setIsPanelOpen(true)}
variant="ghost" variant="ghost"
> >
<MessageCircle className="mr-1 h-3.5 w-3.5" /> <MessageCircle className="mr-1.5 h-4 w-4" />
对话 对话
</Button> </Button>
</div> </div>
@ -10890,7 +11042,7 @@ export default function CanvasEditorLegacyPage({
"absolute z-10 ease-in-out", "absolute z-10 ease-in-out",
isFloatingPanelResizing isFloatingPanelResizing
? "transition-none" ? "transition-none"
: "transition-all duration-150", : "transition-all duration-300",
isPanelOpen isPanelOpen
? "translate-x-0 opacity-100" ? "translate-x-0 opacity-100"
: "pointer-events-none translate-x-[120%] opacity-0" : "pointer-events-none translate-x-[120%] opacity-0"
@ -10929,7 +11081,7 @@ export default function CanvasEditorLegacyPage({
</div> </div>
) : null} ) : null}
<div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950"> <div className="flex h-full w-full flex-col overflow-hidden border-l bg-white dark:bg-zinc-950">
{!isPanelOpen ? null : !areSecondaryPanelsReady ? ( {!areSecondaryPanelsReady ? (
<CanvasLazyPanelLoader label="正在准备侧边面板..." /> <CanvasLazyPanelLoader label="正在准备侧边面板..." />
) : panelMode === "chat" ? ( ) : panelMode === "chat" ? (
// AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载
@ -11525,60 +11677,60 @@ export default function CanvasEditorLegacyPage({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align={shouldDockFloatingToolbarToSide ? "start" : "center"} align={shouldDockFloatingToolbarToSide ? "start" : "center"}
className="w-[360px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border bg-background p-0 shadow-xl" className="w-[420px] rounded-2xl border bg-background p-0 shadow-2xl"
side={shouldDockFloatingToolbarToSide ? "right" : "top"} side={shouldDockFloatingToolbarToSide ? "right" : "top"}
> >
<div className="border-b px-3 py-2.5"> <div className="border-b px-4 py-3">
<div className="text-[13px] font-semibold text-foreground"> <div className="font-semibold text-foreground text-sm">
视频工作流节点库 视频工作流节点库
</div> </div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground"> <div className="mt-1 text-muted-foreground text-xs">
这里按参考 APP 这里按参考 APP
的节点目录整理,先把结构补齐,再逐步接入具体能力。 的节点目录整理,先把结构补齐,再逐步接入具体能力。
</div> </div>
</div> </div>
<ScrollArea className="max-h-[60vh]"> <ScrollArea className="max-h-[60vh]">
<div className="space-y-3 p-2"> <div className="space-y-4 p-3">
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => { {VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => {
const sectionNodes = getVideoWorkflowSectionNodes( const sectionNodes = getVideoWorkflowSectionNodes(
section.id section.id
); );
return ( return (
<div className="space-y-1.5" key={section.id}> <div className="space-y-2" key={section.id}>
<div className="px-1.5"> <div className="px-2">
<div className="text-[10px] font-semibold uppercase text-muted-foreground"> <div className="font-semibold text-muted-foreground text-xs uppercase tracking-[0.16em]">
{section.label} {section.label}
</div> </div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground"> <div className="mt-1 text-muted-foreground text-xs">
{section.description} {section.description}
</div> </div>
</div> </div>
<div className="grid gap-1"> <div className="grid gap-1">
{sectionNodes.map((nodeDefinition) => ( {sectionNodes.map((nodeDefinition) => (
<DropdownMenuItem <DropdownMenuItem
className="flex items-start gap-2 rounded-[var(--ui-radius-control)] px-2.5 py-2" className="flex items-start gap-3 rounded-xl px-3 py-3"
key={nodeDefinition.type} key={nodeDefinition.type}
onClick={() => onClick={() =>
handleAddWorkflowNode(nodeDefinition.type) handleAddWorkflowNode(nodeDefinition.type)
} }
> >
<div <div
className="mt-1 h-2 w-2 rounded-full" className="mt-0.5 h-2.5 w-2.5 rounded-full"
style={{ style={{
backgroundColor: backgroundColor:
nodeDefinition.accentColor, nodeDefinition.accentColor,
}} }}
/> />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-3">
<span className="text-[13px] font-medium text-foreground"> <span className="font-medium text-foreground text-sm">
{nodeDefinition.label} {nodeDefinition.label}
</span> </span>
<span className="font-mono text-[10px] text-muted-foreground"> <span className="font-mono text-[10px] text-muted-foreground">
{nodeDefinition.type} {nodeDefinition.type}
</span> </span>
</div> </div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground"> <div className="mt-1 text-muted-foreground text-xs leading-5">
{nodeDefinition.description} {nodeDefinition.description}
</div> </div>
</div> </div>

View File

@ -1970,15 +1970,18 @@ export default function CanvasModel3DPage() {
worldImageFile as File, worldImageFile as File,
"image" "image"
); );
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) { const uploadData = uploadResult.data || null;
const mediaAssetId = uploadData?.mediaAssetId || null;
const sourceUrl = uploadData?.sourceUrl || null;
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
throw new Error(uploadResult.error || "上传世界参考图失败"); throw new Error(uploadResult.error || "上传世界参考图失败");
} }
await submitTask({ await submitTask({
provider: "worldlabs-3d", provider: "worldlabs-3d",
payload: { payload: {
mode: "image", mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId, ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
providerId: uploadResult.data.providerId, providerId: uploadData.providerId,
prompt: normalizedPrompt || undefined, prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined, displayName: worldDisplayName.trim() || undefined,
model: worldModel, model: worldModel,
@ -2000,15 +2003,18 @@ export default function CanvasModel3DPage() {
worldVideoFile as File, worldVideoFile as File,
"video" "video"
); );
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) { const uploadData = uploadResult.data || null;
const mediaAssetId = uploadData?.mediaAssetId || null;
const sourceUrl = uploadData?.sourceUrl || null;
if (!uploadResult.success || !uploadData || (!mediaAssetId && !sourceUrl)) {
throw new Error(uploadResult.error || "上传世界视频失败"); throw new Error(uploadResult.error || "上传世界视频失败");
} }
await submitTask({ await submitTask({
provider: "worldlabs-3d", provider: "worldlabs-3d",
payload: { payload: {
mode: "video", mode: "video",
mediaAssetId: uploadResult.data.mediaAssetId, ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
providerId: uploadResult.data.providerId, providerId: uploadData.providerId,
prompt: normalizedPrompt || undefined, prompt: normalizedPrompt || undefined,
displayName: worldDisplayName.trim() || undefined, displayName: worldDisplayName.trim() || undefined,
model: worldModel, model: worldModel,
@ -2043,11 +2049,13 @@ export default function CanvasModel3DPage() {
} }
const mediaAssets = uploads.map(({ slot, result }) => { const mediaAssets = uploads.map(({ slot, result }) => {
if (!result.success || !result.data?.mediaAssetId) { const mediaAssetId = result.data?.mediaAssetId || null;
const sourceUrl = result.data?.sourceUrl || null;
if (!result.success || (!mediaAssetId && !sourceUrl)) {
throw new Error(result.error || "上传多图世界图片失败"); throw new Error(result.error || "上传多图世界图片失败");
} }
return { return {
mediaAssetId: result.data.mediaAssetId, ...(mediaAssetId ? { mediaAssetId } : { sourceUrl }),
azimuth: slot.azimuth, azimuth: slot.azimuth,
}; };
}); });

View File

@ -123,6 +123,8 @@ import type {
AgentDetailBriefForm, AgentDetailBriefForm,
AgentDetailGenerationProgressState, AgentDetailGenerationProgressState,
AgentDetailReplacementStrategy, AgentDetailReplacementStrategy,
AgentGenerationProjectReplacementResultState,
AgentGenerationReplacementResultRecord,
AgentGenerationProjectWorkspaceState, AgentGenerationProjectWorkspaceState,
AgentGenerationProjectResultCollections, AgentGenerationProjectResultCollections,
AgentGenerationResultModeType, AgentGenerationResultModeType,
@ -147,6 +149,7 @@ import {
import { import {
createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentGenerationProjectWorkspaceState,
normalizeAgentGenerationProjectPersistedState, normalizeAgentGenerationProjectPersistedState,
normalizeAgentGenerationProjectReplacementResultState,
} from "@/lib/agents/generation-project-state"; } from "@/lib/agents/generation-project-state";
export interface AgentGenerationWorkspaceDarkV2Props { export interface AgentGenerationWorkspaceDarkV2Props {
@ -174,6 +177,10 @@ export interface AgentGenerationWorkspaceDarkV2Props {
onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void; onWorkspaceStateChange?: (next: AgentGenerationProjectWorkspaceState) => void;
resultCollections?: AgentGenerationProjectResultCollections | null; resultCollections?: AgentGenerationProjectResultCollections | null;
deletedResultCollections?: AgentGenerationProjectResultCollections | null; deletedResultCollections?: AgentGenerationProjectResultCollections | null;
replacementResultState?: AgentGenerationProjectReplacementResultState | null;
onReplacementResultStateChange?: (
next: AgentGenerationProjectReplacementResultState
) => void;
onResultPreviewUrlsChange?: ( onResultPreviewUrlsChange?: (
modeType: AgentGenerationResultModeType, modeType: AgentGenerationResultModeType,
urls: string[] urls: string[]
@ -182,6 +189,7 @@ export interface AgentGenerationWorkspaceDarkV2Props {
modeType: AgentGenerationResultModeType, modeType: AgentGenerationResultModeType,
imageUrl: string imageUrl: string
) => void; ) => void;
onPersistProject?: () => Promise<boolean> | boolean;
topNavRightSlot?: ReactNode; topNavRightSlot?: ReactNode;
showcaseConfig?: AgentWorkflowShowcaseConfig | null; showcaseConfig?: AgentWorkflowShowcaseConfig | null;
workflowStylePanel?: ReactNode; workflowStylePanel?: ReactNode;
@ -248,6 +256,8 @@ type DetailResultLocalEditTarget = {
type DetailResultLocalEditMode = "local_edit" | "scale_adjust"; type DetailResultLocalEditMode = "local_edit" | "scale_adjust";
type DetailResultLocalEditImageSize = "1K" | "2K";
type DetailResultLocalEditRect = { type DetailResultLocalEditRect = {
x: number; x: number;
y: number; y: number;
@ -270,6 +280,7 @@ type DetailResultLocalEditRegionInteraction = {
type DetailResultLocalEditSubmitParams = { type DetailResultLocalEditSubmitParams = {
imageUrl: string; imageUrl: string;
imageSize: DetailResultLocalEditImageSize;
mode: DetailResultLocalEditMode; mode: DetailResultLocalEditMode;
title: string; title: string;
regions: LocalEditRegion[]; regions: LocalEditRegion[];
@ -470,6 +481,15 @@ function normalizeResultCollectionUrls(urls?: string[] | null) {
); );
} }
function areResultUrlListsEqual(left?: string[] | null, right?: string[] | null) {
const normalizedLeft = normalizeResultCollectionUrls(left);
const normalizedRight = normalizeResultCollectionUrls(right);
return (
normalizedLeft.length === normalizedRight.length &&
normalizedLeft.every((url, index) => normalizedRight[index] === url)
);
}
function excludeDeletedResultUrls( function excludeDeletedResultUrls(
urls: string[] | undefined | null, urls: string[] | undefined | null,
deletedUrls: string[] | undefined | null deletedUrls: string[] | undefined | null
@ -844,11 +864,11 @@ function getDetailScaleAdjustRegionPrompt(
hasTargetPosition = false hasTargetPosition = false
) { ) {
return [ return [
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。", "按目标框线对框选范围内的主物体执行等比比例调整。",
hasTargetPosition hasTargetPosition
? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。" ? "主体框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出主体框,并由周边背景补全衔接。"
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。", : "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。", "目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
].join(" "); ].join(" ");
} }
@ -895,6 +915,7 @@ function DetailResultLocalEditDialog({
const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null); const [uploadingRegionId, setUploadingRegionId] = useState<string | null>(null);
const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null); const [drawingTargetRegionId, setDrawingTargetRegionId] = useState<string | null>(null);
const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit"); const [mode, setMode] = useState<DetailResultLocalEditMode>("local_edit");
const [imageSize, setImageSize] = useState<DetailResultLocalEditImageSize>("1K");
const dragStartRef = useRef<{ x: number; y: number } | null>(null); const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const editSurfaceRef = useRef<HTMLDivElement | null>(null); const editSurfaceRef = useRef<HTMLDivElement | null>(null);
const isScaleAdjustMode = mode === "scale_adjust"; const isScaleAdjustMode = mode === "scale_adjust";
@ -919,6 +940,7 @@ function DetailResultLocalEditDialog({
setRegionInteraction(null); setRegionInteraction(null);
setUploadingRegionId(null); setUploadingRegionId(null);
setDrawingTargetRegionId(null); setDrawingTargetRegionId(null);
setImageSize("1K");
dragStartRef.current = null; dragStartRef.current = null;
}, [target?.cardId, target?.imageUrl, target?.mode]); }, [target?.cardId, target?.imageUrl, target?.mode]);
@ -1216,7 +1238,7 @@ function DetailResultLocalEditDialog({
); );
if (unchangedRegion) { if (unchangedRegion) {
setActiveRegionId(unchangedRegion.id); setActiveRegionId(unchangedRegion.id);
toast.error(`区域 ${unchangedRegion.index} 的目标框大小和位置均未变化`); toast.error(`区域 ${unchangedRegion.index} 的目标框大小变化不明显,请重新框选`);
return; return;
} }
} else { } else {
@ -1229,11 +1251,12 @@ function DetailResultLocalEditDialog({
} }
onSubmit({ onSubmit({
imageUrl: target.imageUrl, imageUrl: target.imageUrl,
imageSize,
mode, mode,
title: target.title, title: target.title,
regions: validRegions, regions: validRegions,
}); });
}, [isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]); }, [imageSize, isScaleAdjustMode, mode, onSubmit, regions, target, uploadingRegionId]);
const completeDraftRegion = useCallback( const completeDraftRegion = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => { (event: React.PointerEvent<HTMLDivElement>) => {
@ -1818,6 +1841,36 @@ function DetailResultLocalEditDialog({
? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}` ? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`
: `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`} : `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
</div> </div>
{!activePreviewResult ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
输出尺寸
</div>
<div className="grid grid-cols-2 gap-2">
{(
[
{ label: "快速 1K", value: "1K" },
{ label: "高清 2K", value: "2K" },
] as const
).map((option) => (
<button
className={cn(
"h-9 rounded-xl border px-3 text-sm font-semibold transition",
imageSize === option.value
? "border-white bg-white text-black"
: "border-white/10 bg-black/20 text-white/58 hover:bg-white/[0.08] hover:text-white"
)}
disabled={submitting}
key={option.value}
onClick={() => setImageSize(option.value)}
type="button"
>
{option.label}
</button>
))}
</div>
</div>
) : null}
{!activePreviewResult && isScaleAdjustMode ? ( {!activePreviewResult && isScaleAdjustMode ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58"> <div className="rounded-2xl border border-white/10 bg-white/[0.035] p-3 text-sm text-white/58">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38"> <div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/38">
@ -2353,13 +2406,7 @@ type ReplacementModeType = Extract<
"detail_replacement" | "sku_replacement" "detail_replacement" | "sku_replacement"
>; >;
interface ReplacementResultRecord { interface ReplacementResultRecord extends AgentGenerationReplacementResultRecord {}
url: string | null;
text: string;
status: Extract<ReplacementCardStatus, "completed" | "passthrough" | "fallback">;
updatedAt: number;
compareSourceUrl?: string | null;
}
interface ReplacementExecutionContext { interface ReplacementExecutionContext {
modeType: ReplacementModeType; modeType: ReplacementModeType;
@ -2406,13 +2453,26 @@ type ReplacementResultState = Record<
const MANUAL_PASSTHROUGH_RESULT_TEXT = const MANUAL_PASSTHROUGH_RESULT_TEXT =
"已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。"; "已按人工标记沿用参考图,直接应用当前切片,不参与继续生成。";
function createEmptyReplacementResultState(): ReplacementResultState { function normalizeReplacementResultStateForWorkspace(
value: AgentGenerationProjectReplacementResultState | null | undefined
): ReplacementResultState {
const normalized = normalizeAgentGenerationProjectReplacementResultState(value);
return { return {
detail_replacement: {}, detail_replacement: { ...(normalized.detail_replacement || {}) },
sku_replacement: {}, sku_replacement: { ...(normalized.sku_replacement || {}) },
}; };
} }
function areReplacementResultStatesEqual(
left: ReplacementResultState,
right: ReplacementResultState
) {
return (
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(left)) ===
JSON.stringify(normalizeAgentGenerationProjectReplacementResultState(right))
);
}
function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null { function inferReplacementCardStatusFromText(text: string | null | undefined): ReplacementCardStatus | null {
const normalized = normalizeAssistantText(String(text || "")) const normalized = normalizeAssistantText(String(text || ""))
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
@ -3060,9 +3120,12 @@ export function AgentGenerationWorkspaceDarkV2({
workspaceState = null, workspaceState = null,
resultCollections = null, resultCollections = null,
deletedResultCollections = null, deletedResultCollections = null,
replacementResultState: persistedReplacementResultState = null,
onWorkspaceStateChange, onWorkspaceStateChange,
onReplacementResultStateChange,
onResultPreviewUrlsChange, onResultPreviewUrlsChange,
onDeleteResultImage, onDeleteResultImage,
onPersistProject,
topNavRightSlot, topNavRightSlot,
showcaseConfig, showcaseConfig,
workflowStylePanel, workflowStylePanel,
@ -3214,7 +3277,7 @@ export function AgentGenerationWorkspaceDarkV2({
Record<string, string> Record<string, string>
>({}); >({});
const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>( const [replacementResultState, setReplacementResultState] = useState<ReplacementResultState>(
() => createEmptyReplacementResultState() () => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState)
); );
const [replacementExecutionContext, setReplacementExecutionContext] = const [replacementExecutionContext, setReplacementExecutionContext] =
useState<ReplacementExecutionContext | null>(null); useState<ReplacementExecutionContext | null>(null);
@ -3230,6 +3293,7 @@ export function AgentGenerationWorkspaceDarkV2({
const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] = const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] =
useState<Set<string>>(() => new Set()); useState<Set<string>>(() => new Set());
const processedResultHintTimerRef = useRef<Record<string, number>>({}); const processedResultHintTimerRef = useRef<Record<string, number>>({});
const transportGenerationStartedAtRef = useRef<number | null>(null);
const closePsdTemplateDialog = useCallback(() => { const closePsdTemplateDialog = useCallback(() => {
setIsPsdTemplateDialogOpen(false); setIsPsdTemplateDialogOpen(false);
setPsdTemplateDialogSourceUrl(null); setPsdTemplateDialogSourceUrl(null);
@ -3263,6 +3327,22 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
[activeResultModeType, isPsdTemplateEnabled] [activeResultModeType, isPsdTemplateEnabled]
); );
useEffect(() => {
const nextState = normalizeReplacementResultStateForWorkspace(
persistedReplacementResultState
);
setReplacementResultState((current) =>
areReplacementResultStatesEqual(current, nextState) ? current : nextState
);
}, [persistedReplacementResultState]);
useEffect(() => {
onReplacementResultStateChange?.(
normalizeAgentGenerationProjectReplacementResultState(replacementResultState)
);
}, [onReplacementResultStateChange, replacementResultState]);
const normalizedDeletedResultCollections = useMemo( const normalizedDeletedResultCollections = useMemo(
() => ({ () => ({
scene_generation: normalizeResultCollectionUrls( scene_generation: normalizeResultCollectionUrls(
@ -4038,7 +4118,15 @@ export function AgentGenerationWorkspaceDarkV2({
? "agent-anchor-materials" ? "agent-anchor-materials"
: "agent-anchor-generate"; : "agent-anchor-generate";
const { messages, setMessages, sendMessage, status, stop } = useChat<ChatMessage>({ const {
messages,
setMessages,
sendMessage,
status,
stop,
error: chatError,
clearError,
} = useChat<ChatMessage>({
id: chatId, id: chatId,
messages: historyMessages, messages: historyMessages,
experimental_throttle: 100, experimental_throttle: 100,
@ -4503,63 +4591,48 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards, skuReplacementReferenceCards,
]); ]);
useEffect(() => { useEffect(() => {
if (latestReplacementResults.length === 0) {
return;
}
const detailReferenceCardById = new Map(
detailReplacementReferenceCards.map((card) => [card.id, card])
);
const skuReferenceCardById = new Map(
skuReplacementReferenceCards.map((card) => [card.id, card])
);
const fallbackModeType: ReplacementModeType | null =
currentResultModeType === "detail_replacement" || currentResultModeType === "sku_replacement"
? currentResultModeType
: null;
setReplacementResultState((current) => { setReplacementResultState((current) => {
const detailCardIds = new Set(detailReplacementReferenceCards.map((card) => card.id)); const nextState: ReplacementResultState = {
const skuCardIds = new Set(skuReplacementReferenceCards.map((card) => card.id)); detail_replacement: { ...current.detail_replacement },
let changed = false; sku_replacement: { ...current.sku_replacement },
const nextDetail = Object.fromEntries(
Object.entries(current.detail_replacement).filter(([cardId]) => detailCardIds.has(cardId))
);
if (Object.keys(nextDetail).length !== Object.keys(current.detail_replacement).length) {
changed = true;
}
const nextSku = Object.fromEntries(
Object.entries(current.sku_replacement).filter(([cardId]) => skuCardIds.has(cardId))
);
if (Object.keys(nextSku).length !== Object.keys(current.sku_replacement).length) {
changed = true;
}
if (!changed) {
return current;
}
return {
detail_replacement: nextDetail,
sku_replacement: nextSku,
}; };
});
}, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
useEffect(() => {
if (
(currentResultModeType !== "detail_replacement" && currentResultModeType !== "sku_replacement") ||
latestReplacementResults.length === 0
) {
return;
}
const cardIds = currentReplacementExecutionCardIds;
if (cardIds.length === 0) {
return;
}
setReplacementResultState((current) => {
const nextModeState = { ...current[currentResultModeType] };
let changed = false; let changed = false;
latestReplacementResults.forEach((result, index) => { const applyResult = (
const cardId = result.replacementCardId || cardIds[index]; modeType: ReplacementModeType,
cardId: string | null | undefined,
result: {
url: string | null;
text: string;
replacementCardId: string | null;
},
referenceCard?: {
imageUrl?: string | null;
label?: string | null;
previewUrl?: string | null;
} | null
) => {
if (!cardId) { if (!cardId) {
return; return;
} }
const inferredStatus = inferReplacementCardStatusFromText(result.text); const inferredStatus = inferReplacementCardStatusFromText(result.text);
const nextModeState = nextState[modeType];
const previousRecord = nextModeState[cardId]; const previousRecord = nextModeState[cardId];
const referenceCard =
currentResultModeType === "detail_replacement"
? detailReplacementReferenceCards.find((card) => card.id === cardId)
: skuReplacementReferenceCards.find((card) => card.id === cardId);
const resultUrl = result.url || null; const resultUrl = result.url || null;
const previousUrl = const previousUrl =
previousRecord?.url && previousRecord.url !== resultUrl previousRecord?.url && previousRecord.url !== resultUrl
@ -4579,27 +4652,59 @@ export function AgentGenerationWorkspaceDarkV2({
resultUrl, resultUrl,
previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null previousUrl || referenceSourceUrl || previousRecord?.compareSourceUrl || null
), ),
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
referencePreviewUrl:
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
label: referenceCard?.label || previousRecord?.label || null,
}; };
if ( if (
previousRecord?.url === nextRecord.url && previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text && previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status && previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
previousRecord?.label === nextRecord.label
) { ) {
return; return;
} }
nextModeState[cardId] = nextRecord; nextModeState[cardId] = nextRecord;
changed = true; changed = true;
};
latestReplacementResults.forEach((result, index) => {
const explicitCardId = result.replacementCardId || null;
if (explicitCardId) {
const detailReferenceCard = detailReferenceCardById.get(explicitCardId);
if (detailReferenceCard) {
applyResult("detail_replacement", explicitCardId, result, detailReferenceCard);
return;
}
const skuReferenceCard = skuReferenceCardById.get(explicitCardId);
if (skuReferenceCard) {
applyResult("sku_replacement", explicitCardId, result, skuReferenceCard);
return;
}
}
if (!fallbackModeType) {
return;
}
const fallbackCardId = currentReplacementExecutionCardIds[index];
const fallbackReferenceCard =
fallbackModeType === "detail_replacement"
? detailReferenceCardById.get(fallbackCardId)
: skuReferenceCardById.get(fallbackCardId);
applyResult(fallbackModeType, fallbackCardId, result, fallbackReferenceCard);
}); });
if (!changed) { if (!changed) {
return current; return current;
} }
return { return nextState;
...current,
[currentResultModeType]: nextModeState,
};
}); });
}, [ }, [
currentReplacementExecutionCardIds, currentReplacementExecutionCardIds,
@ -4609,8 +4714,8 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementReferenceCards, skuReplacementReferenceCards,
]); ]);
const detailReplacementResultUrls = useMemo( const detailReplacementResultUrls = useMemo(
() => () => {
detailReplacementReferenceCards const urlsFromCards = detailReplacementReferenceCards
.map((card) => { .map((card) => {
const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null; const recordUrl = replacementResultState.detail_replacement[card.id]?.url || null;
const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null; const fallbackUrl = !recordUrl && card.passthrough ? card.imageUrl || null : null;
@ -4620,7 +4725,15 @@ export function AgentGenerationWorkspaceDarkV2({
? nextUrl ? nextUrl
: null; : null;
}) })
.filter((url): url is string => Boolean(url)), .filter((url): url is string => Boolean(url));
const urlsFromRecords = Object.values(replacementResultState.detail_replacement)
.map((record) => record.url)
.filter((url): url is string => Boolean(url))
.filter(
(url) => !normalizedDeletedResultCollections.detail_replacement.includes(url)
);
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
},
[ [
detailReplacementReferenceCards, detailReplacementReferenceCards,
normalizedDeletedResultCollections.detail_replacement, normalizedDeletedResultCollections.detail_replacement,
@ -4628,15 +4741,21 @@ export function AgentGenerationWorkspaceDarkV2({
] ]
); );
const skuReplacementResultUrls = useMemo( const skuReplacementResultUrls = useMemo(
() => () => {
skuReplacementReferenceCards const urlsFromCards = skuReplacementReferenceCards
.map((card) => { .map((card) => {
const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null; const nextUrl = replacementResultState.sku_replacement[card.id]?.url || null;
return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl) return nextUrl && !normalizedDeletedResultCollections.sku_replacement.includes(nextUrl)
? nextUrl ? nextUrl
: null; : null;
}) })
.filter((url): url is string => Boolean(url)), .filter((url): url is string => Boolean(url));
const urlsFromRecords = Object.values(replacementResultState.sku_replacement)
.map((record) => record.url)
.filter((url): url is string => Boolean(url))
.filter((url) => !normalizedDeletedResultCollections.sku_replacement.includes(url));
return Array.from(new Set([...urlsFromCards, ...urlsFromRecords]));
},
[ [
normalizedDeletedResultCollections.sku_replacement, normalizedDeletedResultCollections.sku_replacement,
replacementResultState.sku_replacement, replacementResultState.sku_replacement,
@ -4727,24 +4846,20 @@ export function AgentGenerationWorkspaceDarkV2({
resultCollections?.detail_generation, resultCollections?.detail_generation,
normalizedDeletedResultCollections.detail_generation normalizedDeletedResultCollections.detail_generation
), ),
detail_replacement: Array.from( detail_replacement:
new Set([ detailReplacementResultUrls.length > 0
...detailReplacementResultUrls, ? detailReplacementResultUrls
...excludeDeletedResultUrls( : excludeDeletedResultUrls(
resultCollections?.detail_replacement, resultCollections?.detail_replacement,
normalizedDeletedResultCollections.detail_replacement normalizedDeletedResultCollections.detail_replacement
), ),
]) sku_replacement:
), skuReplacementResultUrls.length > 0
sku_replacement: Array.from( ? skuReplacementResultUrls
new Set([ : excludeDeletedResultUrls(
...skuReplacementResultUrls,
...excludeDeletedResultUrls(
resultCollections?.sku_replacement, resultCollections?.sku_replacement,
normalizedDeletedResultCollections.sku_replacement normalizedDeletedResultCollections.sku_replacement
), ),
])
),
}; };
return { return {
@ -4950,6 +5065,52 @@ export function AgentGenerationWorkspaceDarkV2({
skuReplacementBatchDownloadUrls.length > 0 skuReplacementBatchDownloadUrls.length > 0
? skuReplacementBatchDownloadUrls ? skuReplacementBatchDownloadUrls
: mergedResultCollections.sku_replacement; : mergedResultCollections.sku_replacement;
const syncReplacementResultCollectionsToParent = useCallback(
(targetModeType?: ReplacementModeType) => {
if (!onResultPreviewUrlsChange) {
return;
}
const syncMode = (modeType: ReplacementModeType, liveUrls: string[]) => {
if (targetModeType && targetModeType !== modeType) {
return;
}
const normalizedLiveUrls = normalizeResultCollectionUrls(liveUrls);
if (normalizedLiveUrls.length === 0) {
return;
}
const deletedUrlSet = new Set(
normalizedDeletedResultCollections[modeType]
);
const nextUrls = Array.from(
new Set(normalizedLiveUrls.filter((url) => !deletedUrlSet.has(url)))
);
if (!areResultUrlListsEqual(resultCollections?.[modeType], nextUrls)) {
onResultPreviewUrlsChange(modeType, nextUrls);
}
};
onReplacementResultStateChange?.(
normalizeAgentGenerationProjectReplacementResultState(
replacementResultState
)
);
syncMode("detail_replacement", detailReplacementResultUrls);
syncMode("sku_replacement", skuReplacementResultUrls);
},
[
detailReplacementResultUrls,
normalizedDeletedResultCollections,
onReplacementResultStateChange,
onResultPreviewUrlsChange,
replacementResultState,
resultCollections,
skuReplacementResultUrls,
]
);
const detailRemainingCount = useMemo( const detailRemainingCount = useMemo(
() => () =>
detailResultCards.filter( detailResultCards.filter(
@ -5237,6 +5398,115 @@ export function AgentGenerationWorkspaceDarkV2({
} }
}, [pendingGeneration, pendingGenerationProgressCount]); }, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
if (!pendingGeneration || !chatError) {
return;
}
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
setPendingGeneration(null);
toast.error(chatError.message || "生成请求失败,请稍后重试");
clearError();
}, [chatError, clearError, pendingGeneration]);
useEffect(() => {
if (!pendingGeneration) {
return;
}
const elapsedMs = Date.now() - pendingGeneration.timestamp;
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
const clearStalePendingGeneration = () => {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
try {
stop();
} catch (error) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] Failed to stop stale generation transport:",
error
);
}
transportGenerationStartedAtRef.current = null;
setPendingGeneration((current) => {
if (
!current ||
current.chatId !== pendingGeneration.chatId ||
current.timestamp !== pendingGeneration.timestamp
) {
return current;
}
return null;
});
toast.warning("生成请求超过 30 分钟未回传,已结束处理中状态,可重新生成未回传结果。");
};
if (remainingMs <= 0) {
clearStalePendingGeneration();
return;
}
const timerId = window.setTimeout(clearStalePendingGeneration, remainingMs);
return () => window.clearTimeout(timerId);
}, [pendingGeneration, stop]);
useEffect(() => {
if (!transportIsGenerating) {
transportGenerationStartedAtRef.current = null;
return;
}
if (transportGenerationStartedAtRef.current === null) {
transportGenerationStartedAtRef.current =
pendingGeneration?.timestamp || Date.now();
}
const startedAt = transportGenerationStartedAtRef.current;
const elapsedMs = Date.now() - startedAt;
const remainingMs = AGENT_PENDING_GENERATION_EXPIRY_MS - elapsedMs;
const abortStaleTransport = () => {
const currentStartedAt = transportGenerationStartedAtRef.current;
if (
currentStartedAt === null ||
Date.now() - currentStartedAt < AGENT_PENDING_GENERATION_EXPIRY_MS
) {
return;
}
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
try {
stop();
} catch (error) {
console.warn(
"[AgentGenerationWorkspaceDarkV2] Failed to abort stale generation transport:",
error
);
}
transportGenerationStartedAtRef.current = null;
setPendingGeneration(null);
toast.warning("生成请求超过 30 分钟未回传,已中断卡住连接,可重新生成未回传结果。");
};
if (remainingMs <= 0) {
abortStaleTransport();
return;
}
const timerId = window.setTimeout(abortStaleTransport, remainingMs);
return () => window.clearTimeout(timerId);
}, [pendingGeneration?.timestamp, stop, transportIsGenerating]);
useEffect(() => { useEffect(() => {
if (!pendingGeneration || transportIsGenerating) { if (!pendingGeneration || transportIsGenerating) {
return; return;
@ -5274,11 +5544,19 @@ export function AgentGenerationWorkspaceDarkV2({
]); ]);
useEffect(() => { useEffect(() => {
onResultPreviewUrlsChange?.( if (!onResultPreviewUrlsChange) {
currentResultModeType, return;
mergedResultCollections[currentResultModeType] || [] }
);
}, [currentResultModeType, mergedResultCollections, onResultPreviewUrlsChange]); ([
"scene_generation",
"detail_generation",
"detail_replacement",
"sku_replacement",
] as AgentGenerationResultModeType[]).forEach((modeType) => {
onResultPreviewUrlsChange(modeType, mergedResultCollections[modeType] || []);
});
}, [mergedResultCollections, onResultPreviewUrlsChange]);
useEffect(() => { useEffect(() => {
if (activeResultModeUrls.length === 0) { if (activeResultModeUrls.length === 0) {
@ -5420,6 +5698,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: string[]; imageUrls: string[];
zipName: string; zipName: string;
scope: BatchDownloadScope; scope: BatchDownloadScope;
modeType?: AgentGenerationResultModeType;
}) => { }) => {
const normalizedUrls = (params.imageUrls || []) const normalizedUrls = (params.imageUrls || [])
.map((item) => String(item || "").trim()) .map((item) => String(item || "").trim())
@ -5429,6 +5708,19 @@ export function AgentGenerationWorkspaceDarkV2({
return; return;
} }
if (
params.modeType === "detail_replacement" ||
params.modeType === "sku_replacement"
) {
syncReplacementResultCollectionsToParent(params.modeType);
}
const persisted = await Promise.resolve(onPersistProject?.() ?? true).catch(
() => false
);
if (!persisted) {
toast.info("结果已保留在当前页面,项目同步稍后会自动重试");
}
setBatchDownloadState(params.scope); setBatchDownloadState(params.scope);
setBatchDownloadDialog({ setBatchDownloadDialog({
scope: params.scope, scope: params.scope,
@ -5508,7 +5800,7 @@ export function AgentGenerationWorkspaceDarkV2({
})); }));
toast.error(message); toast.error(message);
} }
}, []); }, [onPersistProject, syncReplacementResultCollectionsToParent]);
useEffect(() => { useEffect(() => {
const taskId = batchDownloadDialog?.taskId; const taskId = batchDownloadDialog?.taskId;
@ -5607,6 +5899,9 @@ export function AgentGenerationWorkspaceDarkV2({
const link = document.createElement("a"); const link = document.createElement("a");
link.href = batchDownloadDialog.downloadUrl; link.href = batchDownloadDialog.downloadUrl;
link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`; link.download = `${batchDownloadDialog.zipName || "batch-results"}.zip`;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.style.display = "none";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.remove(); link.remove();
@ -5672,6 +5967,10 @@ export function AgentGenerationWorkspaceDarkV2({
setReplacementResultState((current) => { setReplacementResultState((current) => {
const currentModeState = current[params.modeType]; const currentModeState = current[params.modeType];
const previousRecord = currentModeState[params.cardId]; const previousRecord = currentModeState[params.cardId];
const referenceCard =
params.modeType === "detail_replacement"
? detailReplacementReferenceCards.find((card) => card.id === params.cardId)
: skuReplacementReferenceCards.find((card) => card.id === params.cardId);
const inferredStatus = inferReplacementCardStatusFromText(params.text); const inferredStatus = inferReplacementCardStatusFromText(params.text);
const explicitCompareSourceUrl = const explicitCompareSourceUrl =
params.compareSourceUrl === undefined params.compareSourceUrl === undefined
@ -5692,13 +5991,20 @@ export function AgentGenerationWorkspaceDarkV2({
explicitCompareSourceUrl === undefined explicitCompareSourceUrl === undefined
? previousRecord?.compareSourceUrl || null ? previousRecord?.compareSourceUrl || null
: explicitCompareSourceUrl, : explicitCompareSourceUrl,
referenceImageUrl: referenceCard?.imageUrl || previousRecord?.referenceImageUrl || null,
referencePreviewUrl:
referenceCard?.previewUrl || previousRecord?.referencePreviewUrl || null,
label: referenceCard?.label || previousRecord?.label || null,
}; };
if ( if (
previousRecord?.url === nextRecord.url && previousRecord?.url === nextRecord.url &&
previousRecord?.text === nextRecord.text && previousRecord?.text === nextRecord.text &&
previousRecord?.status === nextRecord.status && previousRecord?.status === nextRecord.status &&
previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl previousRecord?.compareSourceUrl === nextRecord.compareSourceUrl &&
previousRecord?.referenceImageUrl === nextRecord.referenceImageUrl &&
previousRecord?.referencePreviewUrl === nextRecord.referencePreviewUrl &&
previousRecord?.label === nextRecord.label
) { ) {
return current; return current;
} }
@ -5711,7 +6017,7 @@ export function AgentGenerationWorkspaceDarkV2({
}, },
}; };
}); });
}, []); }, [detailReplacementReferenceCards, skuReplacementReferenceCards]);
const handleOpenReplacementTextEdit = useCallback((params: { const handleOpenReplacementTextEdit = useCallback((params: {
modeType: ReplacementModeType; modeType: ReplacementModeType;
cardId: string; cardId: string;
@ -6060,7 +6366,15 @@ export function AgentGenerationWorkspaceDarkV2({
toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`); toast.success(`继续处理剩余 ${replacementExecutionCards.length} 张结果图`);
} }
try { try {
sendMessage({ role: "user", parts }); void Promise.resolve(sendMessage({ role: "user", parts })).catch((error) => {
generationRequestSnapshotRef.current = {
hiddenPrompt: null,
replacementExecutionCardIds: null,
replacementRerunReferenceImages: null,
};
setPendingGeneration(null);
toast.error(error instanceof Error ? error.message : "发送生成请求失败");
});
if (selectedAgentPreset?.type !== "image") { if (selectedAgentPreset?.type !== "image") {
setInput(""); setInput("");
} }
@ -6647,15 +6961,19 @@ export function AgentGenerationWorkspaceDarkV2({
: selectedModelId !== "auto" : selectedModelId !== "auto"
? selectedModelId ? selectedModelId
: effectiveDefaultModelId || undefined; : effectiveDefaultModelId || undefined;
const requestedImageSize: DetailResultLocalEditImageSize =
params.imageSize === "2K" ? "2K" : "1K";
setDetailLocalEditSubmittingId(cardId); setDetailLocalEditSubmittingId(cardId);
setDetailLocalEditSubmittingMode(editMode); setDetailLocalEditSubmittingMode(editMode);
toast.info(isScaleAdjust ? "正在提交比例调整..." : "正在提交局部编辑..."); toast.info(
`${isScaleAdjust ? "正在提交比例调整" : "正在提交局部编辑"}(${requestedImageSize})...`
);
try { try {
const submitResult = await imageGenerationService.localEdit({ const submitResult = await imageGenerationService.localEdit({
aspectRatio: "auto", aspectRatio: "auto",
imageSize: "2K", imageSize: requestedImageSize,
model: model:
resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto" resolvedLocalEditModelId && resolvedLocalEditModelId !== "auto"
? resolvedLocalEditModelId ? resolvedLocalEditModelId
@ -6873,6 +7191,23 @@ export function AgentGenerationWorkspaceDarkV2({
delete next[normalizedUrl]; delete next[normalizedUrl];
return next; return next;
}); });
if (modeType === "detail_replacement" || modeType === "sku_replacement") {
setReplacementResultState((current) => {
const currentModeState = current[modeType];
const nextModeState = Object.fromEntries(
Object.entries(currentModeState).filter(
([, record]) => record.url !== normalizedUrl
)
);
if (Object.keys(nextModeState).length === Object.keys(currentModeState).length) {
return current;
}
return {
...current,
[modeType]: nextModeState,
};
});
}
onDeleteResultImage(modeType, normalizedUrl); onDeleteResultImage(modeType, normalizedUrl);
toast.success("结果图已删除"); toast.success("结果图已删除");
@ -9351,6 +9686,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: detailReplacementOrderedDownloadUrls, imageUrls: detailReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`, zipName: `${selectedAgentPreset?.name || "detail-replacement"}-results`,
scope: "detail", scope: "detail",
modeType: "detail_replacement",
}) })
} }
disabled={batchDownloadState === "detail"} disabled={batchDownloadState === "detail"}
@ -9368,6 +9704,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: mergedResultCollections.detail_generation, imageUrls: mergedResultCollections.detail_generation,
zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`, zipName: `${selectedAgentPreset?.name || "ai-detail"}-results`,
scope: "detail", scope: "detail",
modeType: "detail_generation",
}) })
} }
disabled={batchDownloadState === "detail"} disabled={batchDownloadState === "detail"}
@ -9385,6 +9722,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: skuReplacementOrderedDownloadUrls, imageUrls: skuReplacementOrderedDownloadUrls,
zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`, zipName: `${selectedAgentPreset?.name || "sku-replacement"}-results`,
scope: "sku", scope: "sku",
modeType: "sku_replacement",
}) })
} }
disabled={batchDownloadState === "sku"} disabled={batchDownloadState === "sku"}
@ -9401,6 +9739,7 @@ export function AgentGenerationWorkspaceDarkV2({
imageUrls: activeResultModeUrls, imageUrls: activeResultModeUrls,
zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`, zipName: `${selectedAgentPreset?.name || "scene-generation"}-results`,
scope: "scene", scope: "scene",
modeType: "scene_generation",
}) })
} }
disabled={batchDownloadState === "scene"} disabled={batchDownloadState === "scene"}

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, type RefObject } from "react"; import { useEffect, type RefObject } from "react";
import { isAgentModeScopedChatId } from "@/lib/chat/messages-route-access";
export type AgentStyleSection = export type AgentStyleSection =
| "brand" | "brand"
@ -140,6 +141,9 @@ export function useAgentStylePerformanceMonitor({
chatId, chatId,
projectId, projectId,
}: UseAgentStylePerformanceMonitorParams) { }: UseAgentStylePerformanceMonitorParams) {
const persistedChatId =
chatId && !isAgentModeScopedChatId(chatId) ? chatId : null;
useEffect(() => { useEffect(() => {
if ( if (
!getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) || !getBooleanEnvFlag(process.env.NEXT_PUBLIC_AGENT_STYLE_PERF_MONITOR, true) ||
@ -200,7 +204,7 @@ export function useAgentStylePerformanceMonitor({
browser, browser,
path: window.location.pathname, path: window.location.pathname,
projectId: projectId || null, projectId: projectId || null,
chatId: chatId || null, chatId: persistedChatId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
}; };
@ -237,5 +241,5 @@ export function useAgentStylePerformanceMonitor({
root.removeEventListener("keydown", markAction, true); root.removeEventListener("keydown", markAction, true);
root.removeEventListener("scroll", markAction, true); root.removeEventListener("scroll", markAction, true);
}; };
}, [chatId, projectId, rootRef]); }, [persistedChatId, projectId, rootRef]);
} }

View File

@ -97,7 +97,6 @@ import {
createLocalEditRegionId, createLocalEditRegionId,
getLocalEditRegionsFromMetadata, getLocalEditRegionsFromMetadata,
getNormalizedRectOverlayStyle, getNormalizedRectOverlayStyle,
hasScaleAdjustTargetChanged,
isHttpImageUrl, isHttpImageUrl,
LOCAL_EDIT_COLORS, LOCAL_EDIT_COLORS,
LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION, LOCAL_EDIT_MAX_REFERENCE_IMAGES_PER_REGION,
@ -220,12 +219,6 @@ import {
} from "@/lib/ai-wash/config"; } from "@/lib/ai-wash/config";
import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors"; import { normalizeOptionalHexColor } from "@/lib/canvas/text-edit-colors";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state"; import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import type {
CanvasCapabilityClientAction,
SelectedImageToolbarActionKey,
} from "@/lib/canvas/capabilities/types";
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { getCanvasCapabilityByClientAction } from "@/lib/canvas/capabilities/registry";
import { cn, fetcher } from "@/lib/utils"; import { cn, fetcher } from "@/lib/utils";
import type { import type {
CanvasTextEditPayloadItem, CanvasTextEditPayloadItem,
@ -288,6 +281,16 @@ interface CanvasNodeAnnotationDisplayItem {
createdByAgent?: boolean; createdByAgent?: boolean;
} }
interface LayerSplitBounds {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([ const AI_CLOUD_IMAGE_ACTIONS = new Set([
"ai-wash", "ai-wash",
"rmbg", "rmbg",
@ -436,11 +439,11 @@ function getCanvasScaleAdjustRegionPrompt(
hasTargetPosition = false hasTargetPosition = false
) { ) {
return [ return [
"按目标框线对框选范围内的主物体执行等比缩放和位置调整。", "按目标框线对框选范围内的主物体执行等比比例调整。",
hasTargetPosition hasTargetPosition
? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。" ? "红色框只用于识别要调整的主体;青色目标框表示调整后的目标大小和目标位置,主体可以自然超出红色框,并由周边背景补全衔接。"
: "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。", : "框选区域只用于识别要调整的主体,不是调整后的边界;主体可以自然超出原始框选范围,并由周边背景补全衔接。",
"只改变目标主体的尺寸和位置,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。", "只改变目标主体的尺寸,不改变主体外观、颜色、材质、图案和文字内容;不要把整个框选矩形当成图片块缩放。",
"目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。", "目标主体调整后露出或覆盖的背景、桌面、墙面、阴影和遮挡关系必须自然补全。",
].join(" "); ].join(" ");
} }
@ -1473,10 +1476,7 @@ export function ImageNode({
isLayerSplitEnabled && imageLayerSplitEnabledByToolbar; isLayerSplitEnabled && imageLayerSplitEnabledByToolbar;
const selectedImageInlineActionCount = const selectedImageInlineActionCount =
getSelectedImageInlineActionCount(canvasToolbarConfig); getSelectedImageInlineActionCount(canvasToolbarConfig);
const selectedImageToolbarActionCandidates: ReadonlyArray<{ const selectedImageToolbarActionCandidates = [
key: SelectedImageToolbarActionKey;
enabled: boolean;
}> = [
{ key: "pickObject", enabled: imagePickObjectEnabled }, { key: "pickObject", enabled: imagePickObjectEnabled },
{ key: "enhance", enabled: imageEnhanceEnabled }, { key: "enhance", enabled: imageEnhanceEnabled },
{ key: "removeBackground", enabled: imageRemoveBackgroundEnabled }, { key: "removeBackground", enabled: imageRemoveBackgroundEnabled },
@ -1496,7 +1496,9 @@ export function ImageNode({
{ key: "flipRotate", enabled: imageFlipRotateEnabled }, { key: "flipRotate", enabled: imageFlipRotateEnabled },
{ key: "promptRegenerate", enabled: imagePromptRegenerateEnabled }, { key: "promptRegenerate", enabled: imagePromptRegenerateEnabled },
{ key: "note", enabled: imageNoteEnabled }, { key: "note", enabled: imageNoteEnabled },
]; ] as const;
type SelectedImageToolbarActionKey =
(typeof selectedImageToolbarActionCandidates)[number]["key"];
const enabledSelectedImageToolbarActions = const enabledSelectedImageToolbarActions =
selectedImageToolbarActionCandidates.filter((action) => action.enabled); selectedImageToolbarActionCandidates.filter((action) => action.enabled);
const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>( const selectedImageInlineActionKeys = new Set<SelectedImageToolbarActionKey>(
@ -1799,7 +1801,8 @@ export function ImageNode({
: "", : "",
[aiWashQuickLevels, selectedAiWashQuickPreset] [aiWashQuickLevels, selectedAiWashQuickPreset]
); );
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef; const shouldShowAiWashQuickModelSelector =
!aiWashQuickConfig.hideModelSelector && !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef = const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim(); aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo( const selectedAiWashQuickModel = useMemo(
@ -1809,6 +1812,11 @@ export function ImageNode({
null, null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef] [aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
); );
const aiWashQuickHasExecutableModel = Boolean(
effectiveAiWashQuickModelRef &&
selectedAiWashQuickModel &&
selectedAiWashQuickModel.selectable !== false
);
const aiWashQuickPrompt = useMemo( const aiWashQuickPrompt = useMemo(
() => () =>
selectedAiWashQuickPreset selectedAiWashQuickPreset
@ -2034,7 +2042,6 @@ export function ImageNode({
>({}); >({});
const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions); const localEditRegionsRef = useRef<LocalEditRegion[]>(localEditRegions);
const hydratedImageTaskRecoveryRef = useRef<string | null>(null); const hydratedImageTaskRecoveryRef = useRef<string | null>(null);
const processedCapabilityCommandIdsRef = useRef(new Set<string>());
const updateNode = useCanvasStore((state) => state.updateNode); const updateNode = useCanvasStore((state) => state.updateNode);
const deleteNode = useCanvasStore((state) => state.deleteNode); const deleteNode = useCanvasStore((state) => state.deleteNode);
@ -2042,7 +2049,6 @@ export function ImageNode({
const selectNode = useCanvasStore((state) => state.selectNode); const selectNode = useCanvasStore((state) => state.selectNode);
const projectId = useCanvasStore((state) => state.projectId); const projectId = useCanvasStore((state) => state.projectId);
const getCanvasState = useCanvasStore((state) => state.getCanvasState); const getCanvasState = useCanvasStore((state) => state.getCanvasState);
const loadCanvas = useCanvasStore((state) => state.loadCanvas);
const addPendingChatAttachment = useCanvasStore( const addPendingChatAttachment = useCanvasStore(
(state) => state.addPendingChatAttachment (state) => state.addPendingChatAttachment
); );
@ -2145,7 +2151,6 @@ export function ImageNode({
updateCurrentGeneratingTaskNode((currentData) => ({ updateCurrentGeneratingTaskNode((currentData) => ({
...currentData, ...currentData,
src: imageUrl, src: imageUrl,
thumbnailUrl: image?.thumbnailUrl || imageUrl,
status: "completed", status: "completed",
prompt: task.result?.prompt || currentData.prompt || "图片生成结果", prompt: task.result?.prompt || currentData.prompt || "图片生成结果",
model: task.result?.model || task.result?.modelRef || currentData.model, model: task.result?.model || task.result?.modelRef || currentData.model,
@ -2272,7 +2277,7 @@ export function ImageNode({
pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true, pointsEnabled && pointsSystemConfig.enhanceProPricing.enabled === true,
}); });
const aiWashQuickCostShortLabel = const aiWashQuickCostShortLabel =
pointsEnabled && selectedAiWashQuickModel pointsEnabled && !aiWashQuickConfig.hideModelSelector && selectedAiWashQuickModel
? formatImageNodeFeatureCostShortLabel({ ? formatImageNodeFeatureCostShortLabel({
cost: selectedAiWashQuickModel.unitCost, cost: selectedAiWashQuickModel.unitCost,
isHighVip, isHighVip,
@ -2472,18 +2477,14 @@ export function ImageNode({
); );
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSource =
data.thumbnailUrl && !shouldUseHighFidelityDisplayImage
? data.thumbnailUrl
: data.src;
const displayImageSrc = useMemo( const displayImageSrc = useMemo(
() => () =>
buildImageProxyUrl(displayImageSource, { buildImageProxyUrl(data.src, {
width: displayImageWidth, width: displayImageWidth,
quality: displayImageQuality, quality: displayImageQuality,
format: "webp", format: "webp",
}), }),
[displayImageQuality, displayImageSource, displayImageWidth] [data.src, displayImageQuality, displayImageWidth]
); );
const fullscreenPreviewSrc = useMemo(() => { const fullscreenPreviewSrc = useMemo(() => {
const originalUrl = resolveOriginalImageUrl(data.src || ""); const originalUrl = resolveOriginalImageUrl(data.src || "");
@ -4417,11 +4418,15 @@ export function ImageNode({
toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`); toast.error(`请为区域 ${missingTargetRegion.index} 框选青色目标框`);
return; return;
} }
const unchangedRegion = validRegions.find( const unchangedRegion = validRegions.find((region) => {
(region) => !hasScaleAdjustTargetChanged(region.rect, region.targetRect) const regionScalePercent = deriveCanvasScalePercentFromRects(
region.rect,
region.targetRect
); );
return Math.abs(regionScalePercent) < 1;
});
if (unchangedRegion) { if (unchangedRegion) {
toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小或位置`); toast.error(`请调整区域 ${unchangedRegion.index} 的青色目标框大小`);
return; return;
} }
} else { } else {
@ -4555,7 +4560,6 @@ export function ImageNode({
updateNode(newNodeId, { updateNode(newNodeId, {
data: { data: {
src: generatedUrl, src: generatedUrl,
thumbnailUrl: generatedImage?.thumbnailUrl || generatedUrl,
status: "completed", status: "completed",
prompt: prompt:
task.result?.prompt || task.result?.prompt ||
@ -6048,6 +6052,35 @@ export function ImageNode({
await detectTextItems(true); await detectTextItems(true);
}, [data.src, data.textEditItems, data.textEditSource, detectTextItems]); }, [data.src, data.textEditItems, data.textEditSource, detectTextItems]);
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
clearImageNodeActionRequest();
if (imageNodeActionRequest.action === "box-cutout") {
handleEnterBoxCutoutMode();
return;
}
if (imageNodeActionRequest.action === "text-edit") {
void handleOpenTextEdit();
return;
}
if (imageNodeActionRequest.action === "local-edit") {
handleEnterLocalEditMode();
}
}, [
clearImageNodeActionRequest,
handleEnterBoxCutoutMode,
handleEnterLocalEditMode,
handleOpenTextEdit,
imageNodeActionRequest,
node.id,
]);
const handleTextItemChange = (id: string, value: string) => { const handleTextItemChange = (id: string, value: string) => {
setTextEditItems((prev) => setTextEditItems((prev) =>
prev.map((item) => prev.map((item) =>
@ -6332,6 +6365,30 @@ export function ImageNode({
e.target.value = ""; e.target.value = "";
}; };
// 获取操作的中文名称
const getActionName = (action: string): string => {
const names: Record<string, string> = {
rmbg: "抠图",
watermark: "去印",
enhance: "增强",
extend: "扩图",
crop: "裁剪",
adjust: "调整",
"flip-rotate": "翻转与旋转",
resize: "调尺",
angle: "视角",
"layer-split": "图层拆分",
"scene-fusion": "批量处理",
annotate: "标注",
"text-edit": "改字",
note: "添加批注",
"prompt-regenerate": "重绘",
"box-cutout": "框选抠图",
"local-edit": "局部编辑",
};
return names[action] || action;
};
const openThreedGenerationDialog = useCallback( const openThreedGenerationDialog = useCallback(
(mode: "model" | "world") => { (mode: "model" | "world") => {
if (!data.src) { if (!data.src) {
@ -6420,8 +6477,11 @@ export function ImageNode({
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置"); toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return; return;
} }
if (!effectiveAiWashQuickModelRef) { if (!aiWashQuickHasExecutableModel || !selectedAiWashQuickModel) {
toast.error("请先选择一个可用的生图模型"); toast.error(
selectedAiWashQuickModel?.disabledReason ||
"当前 AI洗图模型不可用,请在后台重新配置模型"
);
return; return;
} }
if (isProcessing || isSubmittingAiWashQuick) { if (isProcessing || isSubmittingAiWashQuick) {
@ -6477,7 +6537,7 @@ export function ImageNode({
body: JSON.stringify({ body: JSON.stringify({
prompt: aiWashQuickPrompt, prompt: aiWashQuickPrompt,
referenceImage: data.src, referenceImage: data.src,
selectedModelId: effectiveAiWashQuickModelRef, selectedModelId: selectedAiWashQuickModel.id || effectiveAiWashQuickModelRef,
imageSize: aiWashQuickConfig.imageSize, imageSize: aiWashQuickConfig.imageSize,
promptExtend: aiWashQuickConfig.promptExtend, promptExtend: aiWashQuickConfig.promptExtend,
title: selectedAiWashQuickPreset.title, title: selectedAiWashQuickPreset.title,
@ -6574,6 +6634,7 @@ export function ImageNode({
}, [ }, [
addNode, addNode,
aiWashQuickConfig.enabled, aiWashQuickConfig.enabled,
aiWashQuickConfig.hideModelSelector,
aiWashQuickConfig.imageSize, aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend, aiWashQuickConfig.promptExtend,
aiWashQuickLevels, aiWashQuickLevels,
@ -6581,6 +6642,7 @@ export function ImageNode({
aiWashQuickPrompt, aiWashQuickPrompt,
data.src, data.src,
displayTitle, displayTitle,
aiWashQuickHasExecutableModel,
effectiveAiWashQuickModelRef, effectiveAiWashQuickModelRef,
isProcessing, isProcessing,
isSubmittingAiWashQuick, isSubmittingAiWashQuick,
@ -6588,63 +6650,16 @@ export function ImageNode({
resetBoxCutoutState, resetBoxCutoutState,
resetLocalEditState, resetLocalEditState,
selectNode, selectNode,
selectedAiWashQuickModel?.disabledReason,
selectedAiWashQuickModel?.displayName, selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.id,
selectedAiWashQuickModel?.modelId, selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset, selectedAiWashQuickPreset,
updateNode, updateNode,
]); ]);
const executeSharedToolbarCapability = async ( // 处理图片编辑操作 - 集成我们现有的 API
action: "rmbg" | "watermark" | "enhance" | "extend" | "layer-split" const handleAction = async (action: string) => {
) => {
if (!projectId) {
toast.error("画布还在加载,请稍后再试");
return;
}
const capability = getCanvasCapabilityByClientAction(action);
if (!capability) {
toast.error("画布能力未注册");
return;
}
// The server creates the result node after completion. Keep the source-node
// processing overlay visible while that detached result is pending.
setIsCreatingDetachedResultNode(false);
setIsProcessing(true);
try {
const command = createCanvasCapabilityClientCommand({
capabilityId: capability.id,
nodeIds: [node.id],
projectId,
source: "toolbar",
});
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(command),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || `${capability.title}失败`);
}
const projectResult = await canvasService.getProject(projectId);
if (!projectResult.success || !projectResult.data?.canvasData) {
throw new Error(projectResult.error || "结果已生成,但画布同步失败");
}
loadCanvas(projectResult.data.canvasData);
toast.success(`${capability.title}完成`);
} catch (error) {
const message = error instanceof Error ? error.message : `${capability.title}失败`;
toast.error(message);
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
};
// 处理图片编辑操作
const handleAction = async (action: CanvasCapabilityClientAction | "scene-fusion" | "annotate") => {
if (!data.src) { if (!data.src) {
toast.error("请先上传图片"); toast.error("请先上传图片");
return; return;
@ -6747,7 +6762,34 @@ export function ImageNode({
toast.info("图层拆分功能未开启"); toast.info("图层拆分功能未开启");
return; return;
} }
await executeSharedToolbarCapability("layer-split"); setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
try {
const result = await imageGenerationService.splitImageLayers(data.src);
const layers = result.data?.layers || [];
if (!result.success || layers.length === 0) {
toast.error(result.error || "图层拆分失败");
return;
}
const addedLayerCount = handleLayerSplitComplete(
layers.map((layer) => ({
originalUrl: layer.originalUrl,
title: layer.title,
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds ?? null,
}))
);
toast.success(`图层拆分完成,已新增 ${addedLayerCount} 个图层结果`);
} catch (error) {
console.error("图层拆分失败:", error);
toast.error("图层拆分失败");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
}
return; return;
} }
if (action === "box-cutout") { if (action === "box-cutout") {
@ -6809,13 +6851,102 @@ export function ImageNode({
return; return;
} }
if ( const actionName = getActionName(action);
action === "rmbg" ||
action === "watermark" || setImageAdjustPanelOpen(false);
action === "enhance" || setCropPanelOpen(false);
action === "extend" setCropInteraction(null);
) { setFlipRotatePanelOpen(false);
await executeSharedToolbarCapability(action); setAnglePanelOpen(false);
setIsCreatingDetachedResultNode(true);
setIsProcessing(true);
// 先创建一个"处理中"状态的节点,显示在原图右侧
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "", // 暂时没有图片
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
connections: [node.id],
});
let result;
try {
switch (action) {
case "rmbg":
result = await imageGenerationService.removeBackground(data.src);
break;
case "watermark":
result = await imageGenerationService.removeWatermark(data.src);
break;
case "enhance":
result = await imageGenerationService.enhanceImage(data.src);
break;
case "extend":
result = await imageGenerationService.extendImage(data.src);
break;
default:
// 删除创建的节点
if (newNodeId) deleteNode(newNodeId);
return;
}
if (result?.success && result.data?.url) {
// 更新节点为完成状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
},
});
}
toast.success(`${actionName}处理完成`);
} else {
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error(result?.error || "处理失败");
}
} catch (error) {
console.error(`${action} 处理失败:`, error);
// 更新节点为失败状态
if (newNodeId) {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
},
});
}
toast.error("处理失败,请重试");
} finally {
setIsCreatingDetachedResultNode(false);
setIsProcessing(false);
} }
}; };
@ -6859,16 +6990,7 @@ export function ImageNode({
const action = customEvent.detail?.action; const action = customEvent.detail?.action;
if (!targetNodeId || !action || targetNodeId !== node.id) return; if (!targetNodeId || !action || targetNodeId !== node.id) return;
if (action === "scene-fusion" || action === "annotate") {
void handleAction(action); void handleAction(action);
return;
}
const capability = getCanvasCapabilityByClientAction(
action as CanvasCapabilityClientAction
);
if (capability) {
void handleAction(capability.clientAction);
}
}; };
window.addEventListener( window.addEventListener(
@ -6930,6 +7052,105 @@ export function ImageNode({
] ]
); );
const handleLayerSplitComplete = useCallback(
(
layers: Array<{
originalUrl: string;
title?: string;
layerIndex: number;
totalLayers: number;
bounds?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
coverage: number;
} | null;
}>
) => {
const validLayers = layers.filter(
(item) =>
typeof item?.originalUrl === "string" &&
item.originalUrl.trim().length > 0
);
if (validLayers.length === 0) {
throw new Error("没有可添加到画布的图层结果");
}
const isFullFrameLayer = (bounds?: LayerSplitBounds | null) => {
if (!bounds) return false;
return (
bounds.coverage >= 0.92 &&
bounds.width >= bounds.originalWidth - 2 &&
bounds.height >= bounds.originalHeight - 2
);
};
const backgroundLayers = validLayers
.filter((layer) => isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const subjectLayers = validLayers
.filter((layer) => !isFullFrameLayer(layer.bounds))
.sort(
(left, right) => (left.layerIndex || 0) - (right.layerIndex || 0)
);
const orderedLayers = [...backgroundLayers, ...subjectLayers];
const groupGap = 28;
const groupOffsetX = node.size.width + groupGap;
const groupOffsetY = 0;
orderedLayers.forEach((layer, index) => {
const layerTitle = `${displayTitle}-${layer.title || `图层${layer.layerIndex || index + 1}`}`;
const bounds = layer.bounds;
const fullFrameLayer = isFullFrameLayer(bounds);
const targetPosition = {
x: node.position.x + groupOffsetX,
y: node.position.y + groupOffsetY,
};
const targetSize = { width: node.size.width, height: node.size.height };
addNode({
type: "image",
position: targetPosition,
size: targetSize,
data: {
src: layer.originalUrl,
status: "completed",
prompt: `图层拆分结果 ${layer.layerIndex}/${layer.totalLayers}`,
title: layerTitle,
originalSrc: data.src,
metadata: {
layerIndex: layer.layerIndex,
totalLayers: layer.totalLayers,
bounds: layer.bounds,
allowOverlap: true,
placementMode: "overlay",
layerSplitRole: fullFrameLayer ? "background" : "subject",
},
},
connections: [node.id],
});
});
return orderedLayers.length;
},
[
addNode,
data.src,
displayTitle,
node.id,
node.position.x,
node.position.y,
node.size.height,
node.size.width,
]
);
// 图片标注完成回调 // 图片标注完成回调
const handleAnnotateComplete = async ( const handleAnnotateComplete = async (
annotatedImageUrl: string, annotatedImageUrl: string,
@ -7892,20 +8113,9 @@ export function ImageNode({
setIsCreatingDetachedResultNode(true); setIsCreatingDetachedResultNode(true);
setIsProcessing(true); setIsProcessing(true);
try { try {
const uploadResult = await worldLabsGenerationService.uploadMediaFromUrl(
sourceImageUrl,
"image"
);
if (!uploadResult.success || !uploadResult.data?.mediaAssetId) {
throw new Error(uploadResult.error || "上传世界参考图失败");
}
const providerId = uploadResult.data.providerId || null;
const providerName = uploadResult.data.providerName || null;
const createResult = await worldLabsGenerationService.createTask({ const createResult = await worldLabsGenerationService.createTask({
mode: "image", mode: "image",
mediaAssetId: uploadResult.data.mediaAssetId, sourceUrl: sourceImageUrl,
providerId,
prompt: promptText || undefined, prompt: promptText || undefined,
displayName: displayTitle || promptText || "画布图生世界", displayName: displayTitle || promptText || "画布图生世界",
model: modelVersion, model: modelVersion,
@ -7918,8 +8128,6 @@ export function ImageNode({
const createAssetPayload = { const createAssetPayload = {
...(createResult.data.assetPayload || {}), ...(createResult.data.assetPayload || {}),
providerId,
providerName,
sourceImageUrl, sourceImageUrl,
}; };
updateNode(newNodeId, { updateNode(newNodeId, {
@ -7961,8 +8169,8 @@ export function ImageNode({
void pollCanvasWorld3DTask({ void pollCanvasWorld3DTask({
nodeId: newNodeId, nodeId: newNodeId,
taskId: createResult.data.taskId, taskId: createResult.data.taskId,
providerId, providerId: createAssetPayload.providerId || null,
providerName, providerName: createAssetPayload.providerName || null,
promptText, promptText,
modelVersion, modelVersion,
sourceImageUrl, sourceImageUrl,
@ -8159,78 +8367,6 @@ export function ImageNode({
setDownloadProgress(null); setDownloadProgress(null);
}; };
useEffect(() => {
if (!imageNodeActionRequest || imageNodeActionRequest.nodeId !== node.id) {
return;
}
if (
processedCapabilityCommandIdsRef.current.has(
imageNodeActionRequest.requestId
)
) {
return;
}
processedCapabilityCommandIdsRef.current.add(imageNodeActionRequest.requestId);
const executeCommand = async () => {
const { action, args } = imageNodeActionRequest;
if (action === "add-to-chat") {
await handleAddToChat();
return;
}
if (action === "pick-object") {
handleStartPicking();
return;
}
if (action === "rename") {
const requestedTitle = normalizeImageTitle(String(args.title || ""));
if (requestedTitle) {
updateNode(node.id, {
data: { ...data, title: requestedTitle },
});
setDraftTitle(requestedTitle);
toast.success("图片名称已更新");
} else {
setDraftTitle(displayTitle);
setIsEditingTitle(true);
}
return;
}
if (action === "download") {
await handleDownload();
return;
}
if (action === "delete") {
if (imageNodeActionRequest.confirmed === true) {
deleteNode(node.id);
}
return;
}
await handleAction(action);
};
void executeCommand().finally(() => {
processedCapabilityCommandIdsRef.current.delete(
imageNodeActionRequest.requestId
);
clearImageNodeActionRequest();
});
}, [
clearImageNodeActionRequest,
data,
deleteNode,
displayTitle,
handleAction,
handleAddToChat,
handleDownload,
handleStartPicking,
imageNodeActionRequest,
node.id,
updateNode,
]);
const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => { const imageAdjustOverlayStyle = useMemo<React.CSSProperties>(() => {
if (boxCutoutMetrics) { if (boxCutoutMetrics) {
return { return {
@ -9831,7 +9967,7 @@ export function ImageNode({
typeof document !== "undefined" && typeof document !== "undefined" &&
createPortal( createPortal(
<div <div
className="fixed z-[10000] overflow-hidden rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white text-slate-950 shadow-[0_14px_36px_rgba(15,23,42,0.16)] dark:border-white/12 dark:bg-slate-950 dark:text-white" className="fixed z-[10000] overflow-hidden rounded-[14px] border border-slate-200 bg-white text-slate-950 shadow-[0_18px_44px_rgba(15,23,42,0.18)] dark:border-white/12 dark:bg-slate-950 dark:text-white"
data-canvas-no-drag="true" data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
@ -9845,21 +9981,21 @@ export function ImageNode({
scrollbarWidth: "none", scrollbarWidth: "none",
}} }}
> >
<div className="flex items-center justify-between gap-2 border-b border-slate-100 px-3 py-2.5 dark:border-white/10"> <div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-white/10">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-950 dark:text-white"> <span className="flex h-6 w-6 shrink-0 items-center justify-center text-slate-950 dark:text-white">
<CanvasToolbarIcon <CanvasToolbarIcon
className="h-4 w-4" className="h-5 w-5"
fallback={Sparkles} fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl} iconUrl={aiWashQuickConfig.modeIconUrl}
/> />
</span> </span>
<div className="min-w-0 truncate text-[14px] font-semibold"> <div className="min-w-0 truncate text-[17px] font-semibold tracking-[-0.02em]">
{aiWashQuickModeLabel} {aiWashQuickModeLabel}
</div> </div>
</div> </div>
<button <button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-slate-400 transition hover:bg-slate-100 hover:text-slate-950 dark:text-white/54 dark:hover:bg-white/10 dark:hover:text-white"
onClick={() => setAiWashQuickPanelOpen(false)} onClick={() => setAiWashQuickPanelOpen(false)}
title="关闭" title="关闭"
type="button" type="button"
@ -9869,20 +10005,20 @@ export function ImageNode({
</div> </div>
{aiWashConfigLoading ? ( {aiWashConfigLoading ? (
<div className="flex items-center gap-2 border-b border-slate-100 px-3 py-2.5 text-[12px] text-slate-500 dark:border-white/10 dark:text-white/58"> <div className="flex items-center gap-2 border-b border-slate-100 px-4 py-3 text-[13px] text-slate-500 dark:border-white/10 dark:text-white/58">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载 AI洗图 配置... 正在加载 AI洗图 配置...
</div> </div>
) : null} ) : null}
<div className="divide-y divide-slate-100 dark:divide-white/10"> <div className="divide-y divide-slate-100 dark:divide-white/10">
<div className="px-3 py-2.5"> <div className="px-4 py-3">
<div className="mb-1.5 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[13px] font-semibold text-slate-950 dark:text-white"> <div className="flex items-center gap-2 text-[14px] font-semibold text-slate-950 dark:text-white">
<SlidersHorizontal className="h-4 w-4" /> <SlidersHorizontal className="h-4 w-4" />
目标 目标
</div> </div>
<span className="rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58"> <span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
{getAiWashStatusLabel(aiWashQuickStatus)} {getAiWashStatusLabel(aiWashQuickStatus)}
</span> </span>
</div> </div>
@ -9893,7 +10029,7 @@ export function ImageNode({
}} }}
value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId} value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId}
> >
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8"> <SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
<SelectValue placeholder="选择渲染目标" /> <SelectValue placeholder="选择渲染目标" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white"> <SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
@ -9911,8 +10047,8 @@ export function ImageNode({
) : null} ) : null}
</div> </div>
<div className="px-3 py-2.5"> <div className="px-4 py-3">
<div className="mb-1.5 text-[13px] font-semibold text-slate-950 dark:text-white"> <div className="mb-2 text-[14px] font-semibold text-slate-950 dark:text-white">
参数 参数
</div> </div>
{shouldShowAiWashQuickModelSelector ? ( {shouldShowAiWashQuickModelSelector ? (
@ -9924,7 +10060,7 @@ export function ImageNode({
onValueChange={setAiWashQuickModelId} onValueChange={setAiWashQuickModelId}
value={selectedAiWashQuickModel?.id || aiWashQuickModelId} value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
> >
<SelectTrigger className="h-9 border-slate-200 bg-white px-2.5 text-[13px] shadow-none dark:border-white/10 dark:bg-white/8"> <SelectTrigger className="h-10 rounded-[10px] border-slate-200 bg-white px-3 text-[14px] shadow-none dark:border-white/10 dark:bg-white/8">
<SelectValue placeholder="选择生图模型" /> <SelectValue placeholder="选择生图模型" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white"> <SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
@ -9960,16 +10096,9 @@ export function ImageNode({
: "没有可选模型"} : "没有可选模型"}
</div> </div>
</div> </div>
) : selectedAiWashQuickModel ? (
<div className="mb-3 rounded-[var(--ui-radius-control)] border border-slate-200 bg-slate-50 px-2.5 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
{selectedAiWashQuickModel.displayName}
{selectedAiWashQuickModel.unitCost > 0
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
: ""}
</div>
) : null} ) : null}
<div className="-mx-3 divide-y divide-slate-100 dark:divide-white/10"> <div className="-mx-4 divide-y divide-slate-100 dark:divide-white/10">
{aiWashQuickControls.map((control) => { {aiWashQuickControls.map((control) => {
const selectedIndex = const selectedIndex =
aiWashQuickLevels[control.id] ?? control.defaultLevel; aiWashQuickLevels[control.id] ?? control.defaultLevel;
@ -9979,14 +10108,14 @@ export function ImageNode({
null; null;
return ( return (
<div <div
className="px-3 py-2.5" className="px-4 py-3"
key={control.id} key={control.id}
> >
<div className="mb-1.5 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[13px] font-semibold"> <div className="text-[14px] font-semibold">
{control.label} {control.label}
</div> </div>
<span className="min-w-7 rounded-[5px] border border-slate-200 bg-white px-1.5 py-0.5 text-center text-[10px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64"> <span className="min-w-8 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[12px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
{activeLevel?.label || selectedIndex + 1} {activeLevel?.label || selectedIndex + 1}
</span> </span>
</div> </div>
@ -10012,13 +10141,13 @@ export function ImageNode({
</div> </div>
</div> </div>
<div className="px-3 py-2.5"> <div className="px-3 py-3">
<Button <Button
className="h-9 w-full bg-slate-950 text-[13px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30" className="h-10 w-full rounded-[10px] bg-slate-950 text-[14px] font-semibold text-white shadow-none hover:bg-slate-900 hover:text-white disabled:bg-slate-100 disabled:text-slate-300 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
disabled={ disabled={
isSubmittingAiWashQuick || isSubmittingAiWashQuick ||
!selectedAiWashQuickPreset || !selectedAiWashQuickPreset ||
!effectiveAiWashQuickModelRef !aiWashQuickHasExecutableModel
} }
onClick={() => void handleSubmitAiWashQuick()} onClick={() => void handleSubmitAiWashQuick()}
type="button" type="button"
@ -10031,6 +10160,7 @@ export function ImageNode({
{isSubmittingAiWashQuick {isSubmittingAiWashQuick
? "生成中" ? "生成中"
: `${aiWashQuickModeLabel}${ : `${aiWashQuickModeLabel}${
!aiWashQuickConfig.hideModelSelector &&
selectedAiWashQuickModel?.unitCost selectedAiWashQuickModel?.unitCost
? ` · ${selectedAiWashQuickModel.unitCost} 积分` ? ` · ${selectedAiWashQuickModel.unitCost} 积分`
: "" : ""
@ -11546,7 +11676,7 @@ export function ImageNode({
typeof document !== "undefined" && typeof document !== "undefined" &&
createPortal( createPortal(
<div <div
className="fixed z-[9999] w-[340px] max-w-[calc(100vw-24px)] rounded-[var(--ui-radius-panel)] border border-slate-200 bg-white shadow-xl dark:border-zinc-800 dark:bg-zinc-950" className="fixed z-[9999] w-[360px] rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-zinc-800 dark:bg-zinc-950"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@ -11558,7 +11688,7 @@ export function ImageNode({
<div className="flex items-center justify-between border-border/70 border-b px-4 py-3"> <div className="flex items-center justify-between border-border/70 border-b px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Type className="h-4 w-4 text-primary" /> <Type className="h-4 w-4 text-primary" />
<span className="text-[13px] font-medium">编辑文字</span> <span className="font-medium text-sm">编辑文字</span>
</div> </div>
<Button <Button
className="h-7 w-7" className="h-7 w-7"
@ -11571,12 +11701,12 @@ export function ImageNode({
</div> </div>
<div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3"> <div className="max-h-[360px] space-y-3 overflow-y-auto px-4 py-3">
<p className="text-[11px] leading-4 text-muted-foreground"> <p className="text-muted-foreground text-xs">
识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。 识别图片文字后可逐条修改或删除,应用后会在保持画面风格的前提下完成文字更新。
</p> </p>
{isDetectingText && ( {isDetectingText && (
<div className="flex items-center gap-2 text-[13px] text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
正在识别图片文字... 正在识别图片文字...
</div> </div>
@ -11598,7 +11728,7 @@ export function ImageNode({
{textEditItems.map((item, index) => ( {textEditItems.map((item, index) => (
<div <div
className="space-y-2 rounded-[var(--ui-radius-control)] border border-border/70 p-2.5" className="space-y-2 rounded-lg border border-border/70 p-3"
key={item.id} key={item.id}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">

View File

@ -67,7 +67,7 @@ export const hasScaleAdjustTargetChanged = (
) => { ) => {
if (!targetRect) return false; if (!targetRect) return false;
const minimumNormalizedChange = 0.001; const minimumNormalizedChange = 0.001;
return (['x', 'y', 'width', 'height'] as const).some( return (["x", "y", "width", "height"] as const).some(
(key) => Math.abs(targetRect[key] - sourceRect[key]) >= minimumNormalizedChange (key) => Math.abs(targetRect[key] - sourceRect[key]) >= minimumNormalizedChange
); );
}; };

View File

@ -1,316 +1,93 @@
# World Labs 3D 世界生成中转接口兼容要求 # 3D 世界生成 API 调用规范
本文档用于说明我方系统接入 World Labs 3D 世界生成功能时,对第三方中转服务的接口兼容要求。 当前 3D 世界生成通过智枢外部任务接口调用 World Labs 能力。Base URL 只填写根地址:
## 核心原则
我方系统会按照 World Labs 官方 API 协议发起请求:
```text ```text
{baseUrl} + /marble/v1/... https://api.vip98k.com
``` ```
中转服务需要兼容官方路径、请求体、响应体和错误格式。请不要要求我方把 `baseUrl` 配置成某个完整接口地址。 不要填写 `/v1`,也不要填写旧的 `/marble/v1`。
正确示例: ## 鉴权方式
```text 所有请求都必须携带本站发出的智枢 API Key:
http://your-proxy-domain.com
```
错误示例:
```text
http://your-proxy-domain.com/marble/v1/worlds:generate
```
## 我方请求方式
我方后台会在请求中携带:
```http ```http
WLT-Api-Key: {配置的 API Key} Authorization: Bearer <API_KEY>
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
``` ```
中转服务应支持 `WLT-Api-Key` 请求头。如果中转服务内部自行管理官方 Key,也可以将该字段作为租户标识或鉴权凭据,但不应要求我方改成其他 header。 不要使用管理员 Key、其他平台 Key,也不要使用旧的 `WLT-Api-Key` 作为主链路鉴权。
## 必须兼容的接口 ## 模型目录
### 1. 准备上传素材 正式创建任务前,可以先确认这把 Key 是否开通空间生成分组:
```http ```bash
POST {baseUrl}/marble/v1/media-assets:prepare_upload curl "https://api.vip98k.com/api/external/task/models" \
-H "Authorization: Bearer <API_KEY>"
``` ```
请求体示例: 返回内容里应能找到:
```json ```json
{ {
"file_name": "input.jpg", "modelId": "world-generation"
"extension": "jpg",
"kind": "image",
"metadata": {
"mime_type": "image/jpeg",
"uploaded_from": "canvas_world_mode"
}
} }
``` ```
响应必须包含可用的上传信息: 如果找不到,说明这把 Key 没有勾选空间生成分组。
## 创建任务
```bash
curl "https://api.vip98k.com/api/external/task/generate" \
-X POST \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"model": "world-generation",
"displayName": "Test scene",
"prompt": "A quiet sunlit courtyard with a small stone fountain."
}'
```
正常会先返回 `accepted` 或 `running`,这不是失败:
```json ```json
{ {
"media_asset": {}, "data": {
"upload_info": { "taskId": "任务ID",
"upload_url": "https://...", "status": "accepted",
"upload_method": "PUT",
"required_headers": {}
}
}
```
要求:
- `upload_url` 必须可由我方服务器直接访问。
- `upload_method` 如为 `PUT`,必须支持 `PUT` 上传文件二进制。
- `required_headers` 中返回的 header,我方会原样带上。
- 上传成功时,上传地址必须返回 `2xx`。
### 2. 创建世界生成任务
```http
POST {baseUrl}/marble/v1/worlds:generate
```
请求体示例:
```json
{
"display_name": "",
"model": "marble-1.1",
"permission": {
"public": false
},
"world_prompt": {
"type": "image",
"image_prompt": {
"source": "media_asset",
"media_asset_id": "media_asset_id_here"
}
}
}
```
响应需兼容官方 operation 结构,至少包含:
```json
{
"operation_id": "task-id",
"done": false, "done": false,
"metadata": { "resultPath": "/api/external/task/result?taskId=任务ID"
"progress": {
"status": "RUNNING"
}
} }
} }
``` ```
要求: ## 查询结果
- `operation_id` 必须稳定,后续查询状态必须使用同一个 id。 ```bash
- 不要在创建成功但任务未完成时返回业务失败。 curl "https://api.vip98k.com/api/external/task/result?taskId=<任务ID>" \
- 如果余额不足、Key 无效,应返回明确 HTTP 状态码和错误信息。 -H "Authorization: Bearer <API_KEY>"
### 3. 查询任务状态
```http
GET {baseUrl}/marble/v1/operations/{operation_id}
``` ```
运行中响应示例: 建议每隔 5 到 10 秒查询一次,最长等待 10 分钟。直到返回:
```json ```json
{ {
"operation_id": "task-id", "data": {
"done": false, "status": "succeeded",
"metadata": { "done": true
"progress": {
"status": "RUNNING",
"description": "World generation in progress"
}
} }
} }
``` ```
成功响应示例: ## 常见错误
```json - 不能用 `/v1/chat/completions`。
{ - 不能把模型写成 `marble-1.1`。
"operation_id": "task-id", - 不能把 `accepted` 或 `running` 当作调用失败。
"done": true, - 不能只等待几十秒就判定超时。
"error": null, - 必须携带 `Authorization: Bearer <API_KEY>`。
"metadata": {
"progress": {
"status": "SUCCEEDED"
},
"world_id": "world-id"
},
"response": {
"world_id": "world-id",
"world_marble_url": "https://marble.worldlabs.ai/world/...",
"assets": {
"mesh": {
"collider_mesh_url": "https://cdn.marble.worldlabs.ai/.../model.glb"
},
"imagery": {
"pano_url": "https://cdn.marble.worldlabs.ai/.../rgb_0.png"
},
"thumbnail_url": "https://cdn.marble.worldlabs.ai/.../thumbnail.webp",
"splats": {
"spz_urls": {
"100k": "https://cdn.marble.worldlabs.ai/...100k.spz",
"150k": "https://cdn.marble.worldlabs.ai/...150k.spz",
"500k": "https://cdn.marble.worldlabs.ai/...500k.spz",
"full_res": "https://cdn.marble.worldlabs.ai/...full.spz"
}
}
}
}
}
```
失败响应示例:
```json
{
"operation_id": "task-id",
"done": true,
"error": {
"message": "Insufficient account balance"
}
}
```
要求:
- `done=false` 表示任务仍在运行。
- `done=true` 且 `error=null` 表示任务成功。
- `done=true` 且 `error.message` 存在表示任务失败。
- 不要把运行中的任务返回为 HTTP 404。
- 不要因为任务未完成就返回 `Operation not found`。
### 4. 查询世界详情
```http
GET {baseUrl}/marble/v1/worlds/{world_id}
```
当 `operations` 成功响应中资产不完整时,我方会调用该接口补充详情。响应应包含与 `response` 内 world 对象兼容的数据结构。
## 错误码要求
建议按以下语义返回:
| 场景 | HTTP 状态码 | message 示例 |
| --- | --- | --- |
| API Key 缺失或无效 | `401` | `Unauthorized` |
| 权限不足 | `403` | `Forbidden` |
| 余额不足 | `402` 或 `403` | `Insufficient account balance` |
| 请求体错误 | `400` | `Invalid request body` |
| 任务不存在 | `404` | `Operation not found` |
| 上游超时 | `504` | `Upstream timeout` |
| 上游异常 | `502` | `Upstream error` |
响应体建议:
```json
{
"message": "Insufficient account balance"
}
```
或:
```json
{
"error": {
"message": "Insufficient account balance"
}
}
```
## 超时与稳定性要求
- `prepare_upload` 建议 30 秒内返回。
- `worlds:generate` 建议 60 秒内返回 operation,而不是等待生成完成。
- `operations/{id}` 建议 10 秒内返回当前状态。
- 生成任务本身可异步运行,前端会轮询状态。
- 同一个 `operation_id` 多次查询必须返回同一个任务状态,不应随机切换账号或路由。
## 上传地址要求
`prepare_upload` 返回的 `upload_url` 可以是官方签名地址,也可以是中转服务自己的上传地址,但必须满足:
- 我方服务器可直接访问。
- 支持返回的 `upload_method`。
- 支持上传原始文件二进制。
- 上传成功返回 `2xx`。
- 上传完成后,后续 `worlds:generate` 使用的 `media_asset_id` 必须可用。
## 安全要求
- 不要在错误信息中返回完整官方 API Key。
- 日志中建议只记录 Key 前后少量字符,例如 `sk-****abcd`。
- 中转服务如保存官方 Key,应加密存储。
- 上传 URL 如有签名,应设置合理过期时间。
- 不要允许用户通过 `baseUrl` 或 path 注入访问内网地址。
## 我方验收用例
中转服务上线前,请至少通过以下链路:
1. `POST /marble/v1/media-assets:prepare_upload` 返回 `media_asset_id` 和可上传 URL。
2. 使用返回的 `upload_url` 上传图片,返回 `2xx`。
3. `POST /marble/v1/worlds:generate` 返回稳定 `operation_id`。
4. `GET /marble/v1/operations/{operation_id}` 在运行中返回 `done=false`。
5. 任务完成后返回 `done=true`、`error=null`、`world_id` 和资产 URL。
6. 资产 URL 中至少应包含 thumbnail、pano、mesh 或 spz 中的一种,最好完整返回。
7. `GET /marble/v1/worlds/{world_id}` 可返回完整 world 详情。
## 配置示例
我方系统中的 API 地址应填写中转根地址:
```text
http://23.169.168.133:8080
```
最终我方会请求:
```text
http://23.169.168.133:8080/marble/v1/media-assets:prepare_upload
http://23.169.168.133:8080/marble/v1/worlds:generate
http://23.169.168.133:8080/marble/v1/operations/{operation_id}
http://23.169.168.133:8080/marble/v1/worlds/{world_id}
```
如果切回官方接口,配置为:
```text
https://api.worldlabs.ai
```
最终请求:
```text
https://api.worldlabs.ai/marble/v1/media-assets:prepare_upload
https://api.worldlabs.ai/marble/v1/worlds:generate
https://api.worldlabs.ai/marble/v1/operations/{operation_id}
https://api.worldlabs.ai/marble/v1/worlds/{world_id}
```
## 结论
中转服务需要做的是兼容 World Labs 官方 API 协议,而不是要求我方修改接口路径。只要中转根地址完整支持 `/marble/v1/...`,我方系统就可以在官方接口和中转接口之间无缝切换。

View File

@ -8,8 +8,10 @@ import {
} from "@/lib/agents/workflow-material-config"; } from "@/lib/agents/workflow-material-config";
import type { import type {
AgentGenerationProjectPersistedState, AgentGenerationProjectPersistedState,
AgentGenerationProjectReplacementResultState,
AgentGenerationProjectResultCollections, AgentGenerationProjectResultCollections,
AgentGenerationResultModeType, AgentGenerationResultModeType,
AgentGenerationReplacementResultStatus,
AgentGenerationProjectWorkspaceState, AgentGenerationProjectWorkspaceState,
AgentWorkflowPersistedState, AgentWorkflowPersistedState,
} from "@/types/agent"; } from "@/types/agent";
@ -58,6 +60,7 @@ export function createEmptyAgentGenerationProjectPersistedState(): AgentGenerati
resultImageUrls: [], resultImageUrls: [],
resultCollections: {}, resultCollections: {},
deletedResultCollections: {}, deletedResultCollections: {},
replacementResultState: {},
}; };
} }
@ -86,6 +89,129 @@ function normalizeResultCollections(value: unknown): AgentGenerationProjectResul
}; };
} }
const REPLACEMENT_RESULT_STATUSES = new Set<AgentGenerationReplacementResultStatus>([
"completed",
"passthrough",
"fallback",
]);
function normalizeOptionalHttpUrl(value: unknown) {
const raw = String(value || "").trim();
return /^https?:\/\//i.test(raw) ? raw : null;
}
function normalizeReplacementResultStatus(value: unknown): AgentGenerationReplacementResultStatus {
return REPLACEMENT_RESULT_STATUSES.has(value as AgentGenerationReplacementResultStatus)
? (value as AgentGenerationReplacementResultStatus)
: "completed";
}
export function normalizeAgentGenerationProjectReplacementResultState(
value: unknown
): AgentGenerationProjectReplacementResultState {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const normalizeModeState = (input: unknown) => {
const records =
input && typeof input === "object" && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const entries: Array<
readonly [
string,
NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>[string],
]
> = [];
Object.entries(records).forEach(([rawCardId, rawRecord]) => {
const cardId = String(rawCardId || "").trim();
if (!cardId || !rawRecord || typeof rawRecord !== "object" || Array.isArray(rawRecord)) {
return;
}
const record = rawRecord as Record<string, unknown>;
const resultUrl = normalizeOptionalHttpUrl(record.url);
const text = String(record.text || "").trim();
const compareSourceUrl = normalizeOptionalHttpUrl(record.compareSourceUrl);
const referenceImageUrl = normalizeOptionalHttpUrl(record.referenceImageUrl);
const referencePreviewUrl = normalizeOptionalHttpUrl(record.referencePreviewUrl);
const label = String(record.label || "").trim() || null;
const updatedAt = Number(record.updatedAt);
if (!resultUrl && !text && !compareSourceUrl && !referenceImageUrl && !referencePreviewUrl) {
return;
}
entries.push([
cardId.slice(0, 160),
{
url: resultUrl,
text,
status: normalizeReplacementResultStatus(record.status),
updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? Math.round(updatedAt) : 0,
compareSourceUrl,
referenceImageUrl,
referencePreviewUrl,
label,
},
]);
});
return Object.fromEntries(entries.slice(0, 256));
};
return {
detail_replacement: normalizeModeState(raw.detail_replacement),
sku_replacement: normalizeModeState(raw.sku_replacement),
};
}
function getReplacementResultUrls(
value:
| NonNullable<AgentGenerationProjectReplacementResultState["detail_replacement"]>
| undefined
| null
) {
return Array.from(
new Set(
Object.values(value || {})
.map((record) => String(record?.url || "").trim())
.filter((url) => /^https?:\/\//i.test(url))
)
).slice(0, 128);
}
function reconcileReplacementResultCollections(params: {
resultCollections: AgentGenerationProjectResultCollections;
replacementResultState: AgentGenerationProjectReplacementResultState;
}) {
const detailReplacementUrls = getReplacementResultUrls(
params.replacementResultState.detail_replacement
);
const skuReplacementUrls = getReplacementResultUrls(
params.replacementResultState.sku_replacement
);
if (detailReplacementUrls.length === 0 && skuReplacementUrls.length === 0) {
return params.resultCollections;
}
return {
...params.resultCollections,
detail_replacement:
detailReplacementUrls.length > 0
? detailReplacementUrls
: params.resultCollections.detail_replacement,
sku_replacement:
skuReplacementUrls.length > 0
? skuReplacementUrls
: params.resultCollections.sku_replacement,
} satisfies AgentGenerationProjectResultCollections;
}
function inferLegacyResultModeType( function inferLegacyResultModeType(
workflowState: AgentWorkflowPersistedState, workflowState: AgentWorkflowPersistedState,
resultUrls: string[] resultUrls: string[]
@ -287,12 +413,18 @@ export function normalizeAgentGenerationProjectPersistedState(
).slice(0, 64) ).slice(0, 64)
: []; : [];
const workflowState = normalizeWorkflowState(raw.workflowState); const workflowState = normalizeWorkflowState(raw.workflowState);
const resultCollections = normalizeMigratedResultCollections({ const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
const replacementResultState = normalizeAgentGenerationProjectReplacementResultState(
raw.replacementResultState
);
const resultCollections = reconcileReplacementResultCollections({
replacementResultState,
resultCollections: normalizeMigratedResultCollections({
workflowState, workflowState,
resultImageUrls, resultImageUrls,
resultCollections: normalizeResultCollections(raw.resultCollections), resultCollections: normalizeResultCollections(raw.resultCollections),
}),
}); });
const deletedResultCollections = normalizeResultCollections(raw.deletedResultCollections);
return { return {
workflowState, workflowState,
@ -300,5 +432,6 @@ export function normalizeAgentGenerationProjectPersistedState(
resultImageUrls, resultImageUrls,
resultCollections, resultCollections,
deletedResultCollections, deletedResultCollections,
replacementResultState,
}; };
} }

View File

@ -25,7 +25,7 @@ const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
midjourney: "https://your-midjourney-api.example.com", midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run", "fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi", "tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai", "worldlabs-3d": "https://api.vip98k.com",
"kittycad-cad": "https://api.zoo.dev", "kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com", runway: "https://api.dev.runwayml.com",
krea: "https://api.krea.ai", krea: "https://api.krea.ai",

View File

@ -56,12 +56,14 @@ interface QueueState {
nextBatchEligibleAt: Map<string, number>; nextBatchEligibleAt: Map<string, number>;
nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>; nextBatchTimers: Map<string, ReturnType<typeof setTimeout>>;
awaitingNextBatch: Set<string>; awaitingNextBatch: Set<string>;
lastStaleRunningCleanupAt: number;
draining: boolean; draining: boolean;
} }
const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000; const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000;
const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000; const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 12 * 60 * 1000;
const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000; const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000;
const STALE_RUNNING_CLEANUP_INTERVAL_MS = 60 * 1000;
const PENDING_LOOKAHEAD_LIMIT = 100; const PENDING_LOOKAHEAD_LIMIT = 100;
const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500; const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;
const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120; const QUEUE_STATUS_RECENT_SAMPLE_LIMIT = 120;
@ -82,6 +84,7 @@ function getQueueState(): QueueState {
nextBatchEligibleAt: new Map<string, number>(), nextBatchEligibleAt: new Map<string, number>(),
nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(), nextBatchTimers: new Map<string, ReturnType<typeof setTimeout>>(),
awaitingNextBatch: new Set<string>(), awaitingNextBatch: new Set<string>(),
lastStaleRunningCleanupAt: 0,
draining: false, draining: false,
}; };
} }
@ -394,6 +397,48 @@ async function claimNextPendingTask(
return null; return null;
} }
async function markStaleDatabaseRunningTasksFailed() {
const state = getQueueState();
const now = Date.now();
if (now - state.lastStaleRunningCleanupAt < STALE_RUNNING_CLEANUP_INTERVAL_MS) {
return;
}
state.lastStaleRunningCleanupAt = now;
const localRunningIds = Array.from(state.running.keys());
const staleBefore = new Date(now - LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS);
const staleWhere =
localRunningIds.length > 0
? and(
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore),
notInArray(canvasImageTask.id, localRunningIds)
)
: and(
eq(canvasImageTask.status, "running"),
lt(canvasImageTask.startedAt, staleBefore)
);
const completedAt = new Date(now);
const staleTasks = await db
.update(canvasImageTask)
.set({
status: "failed",
error: "图片生成任务已超时,请重新发起生成",
completedAt,
updatedAt: completedAt,
})
.where(staleWhere)
.returning({ id: canvasImageTask.id });
if (staleTasks.length > 0) {
console.warn("[Canvas Image Task Queue] Marked stale running tasks failed:", {
count: staleTasks.length,
taskIds: staleTasks.slice(0, 10).map((task) => task.id),
});
}
}
async function runSingleTask(entry: PendingTaskEntry) { async function runSingleTask(entry: PendingTaskEntry) {
try { try {
const [task] = await db const [task] = await db
@ -485,6 +530,7 @@ export async function drainCanvasImageTaskQueue() {
state.draining = true; state.draining = true;
try { try {
await markStaleDatabaseRunningTasksFailed();
const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency(); const maxConcurrentPerModel = await getCanvasImageSingleModelConcurrency();
while (true) { while (true) {
const nextTask = await claimNextPendingTask(maxConcurrentPerModel); const nextTask = await claimNextPendingTask(maxConcurrentPerModel);

View File

@ -40,7 +40,7 @@ const providerBaseUrlDefaults = [
{ type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" }, { type: "midjourney", baseUrl: "https://your-midjourney-api.example.com" },
{ type: "fal-video", baseUrl: "https://fal.run" }, { type: "fal-video", baseUrl: "https://fal.run" },
{ type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" }, { type: "tripo-3d", baseUrl: "https://api.tripo3d.ai/v2/openapi" },
{ type: "worldlabs-3d", baseUrl: "https://api.worldlabs.ai" }, { type: "worldlabs-3d", baseUrl: "https://api.vip98k.com" },
{ type: "kittycad-cad", baseUrl: "https://api.zoo.dev" }, { type: "kittycad-cad", baseUrl: "https://api.zoo.dev" },
{ type: "runway", baseUrl: "https://api.dev.runwayml.com" }, { type: "runway", baseUrl: "https://api.dev.runwayml.com" },
{ type: "krea", baseUrl: "https://api.krea.ai" }, { type: "krea", baseUrl: "https://api.krea.ai" },

View File

@ -89,7 +89,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
{ {
id: "world-standard", id: "world-standard",
displayName: "标准质量", displayName: "标准质量",
realModelId: "marble-1.1", realModelId: "world-generation",
description: "默认世界生成质量,适合大多数空间创建。", description: "默认世界生成质量,适合大多数空间创建。",
enabled: true, enabled: true,
isDefault: true, isDefault: true,
@ -97,7 +97,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
{ {
id: "world-fidelity", id: "world-fidelity",
displayName: "高保真", displayName: "高保真",
realModelId: "marble-1.1-plus", realModelId: "world-generation",
description: "更高保真与细节表现,适合高质量空间生成。", description: "更高保真与细节表现,适合高质量空间生成。",
enabled: true, enabled: true,
isDefault: false, isDefault: false,
@ -105,7 +105,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
{ {
id: "world-compatible", id: "world-compatible",
displayName: "兼容质量", displayName: "兼容质量",
realModelId: "marble-1.0", realModelId: "world-generation",
description: "兼容旧版世界任务和历史工作流。", description: "兼容旧版世界任务和历史工作流。",
enabled: true, enabled: true,
isDefault: false, isDefault: false,
@ -113,7 +113,7 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [
{ {
id: "world-draft", id: "world-draft",
displayName: "快速草稿", displayName: "快速草稿",
realModelId: "marble-1.0-draft", realModelId: "world-generation",
description: "更适合快速预览与草稿生成。", description: "更适合快速预览与草稿生成。",
enabled: true, enabled: true,
isDefault: false, isDefault: false,

View File

@ -10,7 +10,8 @@ type WorldLabsRequestOptions = {
timeoutMs?: number; timeoutMs?: number;
}; };
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai"; export const DEFAULT_WORLDLABS_BASE_URL = "https://api.vip98k.com";
export const WORLDLABS_EXTERNAL_TASK_MODEL = "world-generation";
const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000; const DEFAULT_WORLDLABS_REQUEST_TIMEOUT_MS = 180_000;
function toIntMs(value: string | undefined) { function toIntMs(value: string | undefined) {
@ -66,6 +67,17 @@ function normalizeBaseUrl(baseUrl?: string) {
return resolved.replace(/\/+$/, ""); return resolved.replace(/\/+$/, "");
} }
export function isWorldLabsExternalTaskApi(baseUrl?: string | null) {
const normalized = normalizeBaseUrl(baseUrl || "");
try {
const parsed = new URL(normalized);
const hostname = parsed.hostname.toLowerCase();
return hostname === "api.vip98k.com" || normalized.includes("/api/external/task");
} catch {
return normalized.includes("api.vip98k.com") || normalized.includes("/api/external/task");
}
}
function sanitizeApiKey(value: string): string { function sanitizeApiKey(value: string): string {
return String(value || "") return String(value || "")
.replace(/[\u2028\u2029]/g, "") .replace(/[\u2028\u2029]/g, "")
@ -122,7 +134,11 @@ export async function worldLabsRequest<T = unknown>(
}); });
} }
if (isWorldLabsExternalTaskApi(config.baseUrl)) {
headers.set("Authorization", `Bearer ${normalizedApiKey}`);
} else {
headers.set("WLT-Api-Key", normalizedApiKey); headers.set("WLT-Api-Key", normalizedApiKey);
}
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
let body: BodyInit | undefined; let body: BodyInit | undefined;

View File

@ -1,5 +1,5 @@
export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config"; export const WORLDLABS_KEY_POOL_CONFIG_KEY = "worldlabs_key_pool_config";
export const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai"; export const DEFAULT_WORLDLABS_BASE_URL = "https://api.vip98k.com";
export type WorldLabsKeyPoolConfig = { export type WorldLabsKeyPoolConfig = {
importToken: string; importToken: string;

View File

@ -4,9 +4,8 @@ import {
getAiProviderById, getAiProviderById,
getAiProvidersByType, getAiProvidersByType,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { DEFAULT_WORLDLABS_BASE_URL } from "./client";
import type { WorldLabsProviderConfig } from "./types"; import type { WorldLabsProviderConfig } from "./types";
const DEFAULT_WORLDLABS_BASE_URL = "https://api.worldlabs.ai";
let worldLabsProviderCursor = 0; let worldLabsProviderCursor = 0;
export function sanitizeWorldLabsApiKey(value: string): string { export function sanitizeWorldLabsApiKey(value: string): string {

View File

@ -1,4 +1,5 @@
export type WorldLabsModel = export type WorldLabsModel =
| "world-generation"
| "marble-1.0-draft" | "marble-1.0-draft"
| "marble-1.0" | "marble-1.0"
| "marble-1.1" | "marble-1.1"
@ -19,8 +20,11 @@ export interface WorldLabsMediaAsset {
} }
export interface WorldLabsUploadInfo { export interface WorldLabsUploadInfo {
upload_url: string; upload_url?: string;
upload_method: string; uploadUrl?: string;
url?: string;
upload_method?: string;
method?: string;
required_headers?: Record<string, string> | null; required_headers?: Record<string, string> | null;
curl_example?: string | null; curl_example?: string | null;
} }
@ -75,3 +79,37 @@ export interface WorldLabsProviderConfig {
providerId?: string; providerId?: string;
providerName?: string; providerName?: string;
} }
export interface WorldLabsExternalTaskCreateData {
taskId?: string;
task_id?: string;
id?: string;
status?: string;
done?: boolean;
resultPath?: string;
result_path?: string;
[key: string]: unknown;
}
export interface WorldLabsExternalTaskResultData {
taskId?: string;
task_id?: string;
id?: string;
status?: string;
done?: boolean;
error?: unknown;
errorMessage?: string;
message?: string;
result?: unknown;
output?: unknown;
[key: string]: unknown;
}
export interface WorldLabsExternalTaskResponse<T = unknown> {
data?: T;
requestId?: string;
request_id?: string;
message?: string;
error?: unknown;
[key: string]: unknown;
}

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1144,10 +1144,12 @@ export const worldLabsGenerationService = {
): Promise< ): Promise<
ApiResponse<{ ApiResponse<{
mediaAssetId?: string; mediaAssetId?: string;
sourceUrl?: string;
mediaAsset?: Record<string, any>; mediaAsset?: Record<string, any>;
uploadInfo?: Record<string, any>; uploadInfo?: Record<string, any>;
providerId?: string | null; providerId?: string | null;
providerName?: string | null; providerName?: string | null;
uploadFallback?: string | null;
}> }>
> { > {
try { try {
@ -1175,10 +1177,12 @@ export const worldLabsGenerationService = {
): Promise< ): Promise<
ApiResponse<{ ApiResponse<{
mediaAssetId?: string; mediaAssetId?: string;
sourceUrl?: string;
mediaAsset?: Record<string, any>; mediaAsset?: Record<string, any>;
uploadInfo?: Record<string, any>; uploadInfo?: Record<string, any>;
providerId?: string | null; providerId?: string | null;
providerName?: string | null; providerName?: string | null;
uploadFallback?: string | null;
}> }>
> { > {
try { try {

View File

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -19,7 +23,9 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
}, },
"strictNullChecks": true "strictNullChecks": true
}, },
@ -31,7 +37,18 @@
"next.config.js", "next.config.js",
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
".next-build/types/**/*.ts", ".next-build/types/**/*.ts",
".next-build/dev/types/**/*.ts" ".next-build/dev/types/**/*.ts",
".next-vip/types/**/*.ts",
".next-vip/dev/types/**/*.ts",
".next-build-hicad-20260728-135607/types/**/*.ts",
".next-build-hicad-20260728-135607/dev/types/**/*.ts",
".next-build-hicad-20260728-140320/types/**/*.ts",
".next-build-hicad-20260728-140320/dev/types/**/*.ts",
".next-candidate-home-banner-20260804-bg/types/**/*.ts",
".next-candidate-home-banner-20260804-bg/dev/types/**/*.ts",
], ],
"exclude": ["node_modules", "services/pascal-editor"] "exclude": [
"node_modules",
"services/pascal-editor"
]
} }

View File

@ -495,6 +495,34 @@ export interface AgentGenerationProjectResultCollections {
sku_replacement?: string[]; sku_replacement?: string[];
} }
export type AgentGenerationReplacementModeType = Extract<
AgentGenerationResultModeType,
"detail_replacement" | "sku_replacement"
>;
export type AgentGenerationReplacementResultStatus =
| "completed"
| "passthrough"
| "fallback";
export interface AgentGenerationReplacementResultRecord {
url: string | null;
text: string;
status: AgentGenerationReplacementResultStatus;
updatedAt: number;
compareSourceUrl?: string | null;
referenceImageUrl?: string | null;
referencePreviewUrl?: string | null;
label?: string | null;
}
export type AgentGenerationProjectReplacementResultState = Partial<
Record<
AgentGenerationReplacementModeType,
Record<string, AgentGenerationReplacementResultRecord>
>
>;
export interface AgentGenerationProjectWorkspaceState { export interface AgentGenerationProjectWorkspaceState {
input: string; input: string;
attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>; attachments: Array<AgentGenerationProjectWorkspaceAttachment | null>;
@ -530,6 +558,7 @@ export interface AgentGenerationProjectPersistedState {
resultImageUrls?: string[]; resultImageUrls?: string[];
resultCollections?: AgentGenerationProjectResultCollections; resultCollections?: AgentGenerationProjectResultCollections;
deletedResultCollections?: AgentGenerationProjectResultCollections; deletedResultCollections?: AgentGenerationProjectResultCollections;
replacementResultState?: AgentGenerationProjectReplacementResultState;
} }
export interface AgentGenerationProject { export interface AgentGenerationProject {