Compare commits

...

1 Commits

Author SHA1 Message Date
root
3ebd93f2c1 backup: preserve server changes before OpenClaw deploy 2026-08-07 11:28:19 +08:00
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",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
"worldlabs-3d": "https://api.worldlabs.ai",
"worldlabs-3d": "https://api.vip98k.com",
"kittycad-cad": "https://api.zoo.dev",
runway: "https://api.dev.runwayml.com",
krea: "https://api.krea.ai",

View File

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

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 MIDJOURNEY_BASE_URL = "https://your-midjourney-api.example.com";
const TRIPO_3D_BASE_URL = "https://api.tripo3d.ai/v2/openapi";
const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const WORLDLABS_3D_BASE_URL = "https://api.vip98k.com";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
next-env.d.ts vendored
View File

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

View File

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

View File

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

View File

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