diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 92bca7c8f..610710fa7 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1633,7 +1633,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = { 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", diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index a33e154b5..4fd38f964 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -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(); diff --git a/app/api/admin/ai-providers/route.ts b/app/api/admin/ai-providers/route.ts index 478cebac5..94d727e3c 100644 --- a/app/api/admin/ai-providers/route.ts +++ b/app/api/admin/ai-providers/route.ts @@ -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"; diff --git a/app/api/canvas/[id]/route.ts b/app/api/canvas/[id]/route.ts index dc4a14769..bb8f5a7dc 100644 --- a/app/api/canvas/[id]/route.ts +++ b/app/api/canvas/[id]/route.ts @@ -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) { diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 54c57d3a0..a96b2948f 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -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>(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 | 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 | 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 +) { + 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 | 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 + >(config, { + method: "GET", + path: `/api/external/task/result?taskId=${encodeURIComponent(normalizedTaskId)}`, + timeoutMs: resolveWorldLabsStatusTimeoutMs(), + }); + const externalData = + unwrapExternalTaskData(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(config, { method: "GET", path: `/marble/v1/operations/${encodeURIComponent(normalizedTaskId)}`, diff --git a/app/api/canvas/world/tasks/route.ts b/app/api/canvas/world/tasks/route.ts index 4a123b15e..ae3961682 100644 --- a/app/api/canvas/world/tasks/route.ts +++ b/app/api/canvas/world/tasks/route.ts @@ -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) : null; +} + +function unwrapExternalTaskData>(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 +): WorldLabsOperation { + const data = unwrapExternalTaskData(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)?.message || + (payload as Record)?.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>, + 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 > | null; + let usedExternalTaskApi = false; let lastError: unknown = null; for (const candidate of candidates) { try { - result = await worldLabsRequest(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 + >(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(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, }, diff --git a/app/api/canvas/world/upload/route.ts b/app/api/canvas/world/upload/route.ts index 83bc3f287..e1342fc7a 100644 --- a/app/api/canvas/world/upload/route.ts +++ b/app/api/canvas/world/upload/route.ts @@ -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 = {}; + 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; + 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, diff --git a/app/api/client-performance/agent-style/route.ts b/app/api/client-performance/agent-style/route.ts index 798477532..37894f2a4 100644 --- a/app/api/client-performance/agent-style/route.ts +++ b/app/api/client-performance/agent-style/route.ts @@ -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", diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 6e53b45af..82b20e25a 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -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(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({}); + const [ + agentProjectReplacementResultState, + setAgentProjectReplacementResultState, + ] = useState({}); 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({ > @@ -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({ ) : null}
- {!isPanelOpen ? null : !areSecondaryPanelsReady ? ( + {!areSecondaryPanelsReady ? ( ) : panelMode === "chat" ? ( // AI 对话内容 - 使用 key 强制在 id 变化时重新挂载 @@ -11449,60 +11601,60 @@ export default function CanvasEditorLegacyPage({ -
-
+
+
视频工作流节点库
-
+
这里按参考 APP 的节点目录整理,先把结构补齐,再逐步接入具体能力。
-
+
{VIDEO_WORKFLOW_LIBRARY_SECTIONS.map((section) => { const sectionNodes = getVideoWorkflowSectionNodes( section.id ); return ( -
-
-
+
+
+
{section.label}
-
+
{section.description}
{sectionNodes.map((nodeDefinition) => ( handleAddWorkflowNode(nodeDefinition.type) } >
-
- +
+ {nodeDefinition.label} {nodeDefinition.type}
-
+
{nodeDefinition.description}
diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 03fe10f43..2252d0174 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -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, }; }); diff --git a/components/agents/AgentGenerationWorkspaceDarkV2.tsx b/components/agents/AgentGenerationWorkspaceDarkV2.tsx index a5602b3c6..7ff5e81ff 100644 --- a/components/agents/AgentGenerationWorkspaceDarkV2.tsx +++ b/components/agents/AgentGenerationWorkspaceDarkV2.tsx @@ -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; 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(null); const [drawingTargetRegionId, setDrawingTargetRegionId] = useState(null); const [mode, setMode] = useState("local_edit"); + const [imageSize, setImageSize] = useState("1K"); const dragStartRef = useRef<{ x: number; y: number } | null>(null); const editSurfaceRef = useRef(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) => { @@ -1818,6 +1841,36 @@ function DetailResultLocalEditDialog({ ? `先框主体,再框目标大小和位置。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}` : `框选要局部修改的位置,填写提示词;可为每个区域添加参考图。最多 ${LOCAL_EDIT_MAX_REGIONS} 个区域。${completionCopy}`}
+ {!activePreviewResult ? ( +
+
+ 输出尺寸 +
+
+ {( + [ + { label: "快速 1K", value: "1K" }, + { label: "高清 2K", value: "2K" }, + ] as const + ).map((option) => ( + + ))} +
+
+ ) : null} {!activePreviewResult && isScaleAdjustMode ? (
@@ -2353,13 +2406,7 @@ type ReplacementModeType = Extract< "detail_replacement" | "sku_replacement" >; -interface ReplacementResultRecord { - url: string | null; - text: string; - status: Extract; - 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 >({}); const [replacementResultState, setReplacementResultState] = useState( - () => createEmptyReplacementResultState() + () => normalizeReplacementResultStateForWorkspace(persistedReplacementResultState) ); const [replacementExecutionContext, setReplacementExecutionContext] = useState(null); @@ -3230,6 +3293,7 @@ export function AgentGenerationWorkspaceDarkV2({ const [detailWatermarkRemoveCardIds, setDetailWatermarkRemoveCardIds] = useState>(() => new Set()); const processedResultHintTimerRef = useRef>({}); + const transportGenerationStartedAtRef = useRef(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({ + const { + messages, + setMessages, + sendMessage, + status, + stop, + error: chatError, + clearError, + } = useChat({ 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"} diff --git a/components/agents/use-agent-style-performance-monitor.ts b/components/agents/use-agent-style-performance-monitor.ts index 699293098..7ac380900 100644 --- a/components/agents/use-agent-style-performance-monitor.ts +++ b/components/agents/use-agent-style-performance-monitor.ts @@ -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]); } diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 0f58b6b9c..459256e3a 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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( @@ -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(localEditRegions); const hydratedImageTaskRecoveryRef = useRef(null); - const processedCapabilityCommandIdsRef = useRef(new Set()); 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 = { + 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(() => { if (boxCutoutMetrics) { return { @@ -9831,7 +9967,7 @@ export function ImageNode({ typeof document !== "undefined" && createPortal(
event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} @@ -9845,21 +9981,21 @@ export function ImageNode({ scrollbarWidth: "none", }} > -
-
- +
+
+ -
+
{aiWashQuickModeLabel}
{aiWashConfigLoading ? ( -
+
正在加载 AI洗图 配置...
) : null}
-
-
-
+
+
+
目标
- + {getAiWashStatusLabel(aiWashQuickStatus)}
@@ -9893,7 +10029,7 @@ export function ImageNode({ }} value={selectedAiWashQuickPreset?.id || aiWashQuickPresetId} > - + @@ -9911,8 +10047,8 @@ export function ImageNode({ ) : null}
-
-
+
+
参数
{shouldShowAiWashQuickModelSelector ? ( @@ -9924,7 +10060,7 @@ export function ImageNode({ onValueChange={setAiWashQuickModelId} value={selectedAiWashQuickModel?.id || aiWashQuickModelId} > - + @@ -9960,16 +10096,9 @@ export function ImageNode({ : "没有可选模型"}
- ) : selectedAiWashQuickModel ? ( -
- {selectedAiWashQuickModel.displayName} - {selectedAiWashQuickModel.unitCost > 0 - ? ` · ${selectedAiWashQuickModel.unitCost} 积分/张` - : ""} -
) : null} -
+
{aiWashQuickControls.map((control) => { const selectedIndex = aiWashQuickLevels[control.id] ?? control.defaultLevel; @@ -9979,14 +10108,14 @@ export function ImageNode({ null; return (
-
-
+
+
{control.label}
- + {activeLevel?.label || selectedIndex + 1}
@@ -10012,13 +10141,13 @@ export function ImageNode({
-
+
@@ -11546,7 +11676,7 @@ export function ImageNode({ typeof document !== "undefined" && createPortal(
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} @@ -11558,7 +11688,7 @@ export function ImageNode({
- 编辑文字 + 编辑文字