diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts index cb9af5f55..78997c2d9 100644 --- a/app/api/canvas/3d/tasks/[taskId]/route.ts +++ b/app/api/canvas/3d/tasks/[taskId]/route.ts @@ -28,6 +28,11 @@ type RemoteAssetArchiveCacheItem = { archivedUrl: string; }; +type RemoteAssetArchiveResult = { + url: string | null; + pending: boolean; +}; + function isLikelyArchivedStorageUrl(parsed: URL) { const hostname = parsed.hostname.toLowerCase(); return hostname.includes("blob.vercel-storage.com"); @@ -120,23 +125,22 @@ async function archiveTripoModelIfNeeded(params: { sourceUrl?: string | null; filenamePrefix: string; fallbackExtension: string; -}) { +}): Promise { const sourceUrl = String(params.sourceUrl || "").trim(); if (!shouldArchiveRemoteAssetUrl(sourceUrl)) { - return sourceUrl || null; + return { url: sourceUrl || null, pending: false }; } const cache = getTripoArchiveCache(); const cached = cache.get(params.cacheKey); if (cached && cached.sourceUrl === sourceUrl) { - return cached.archivedUrl; + return { url: cached.archivedUrl, pending: false }; } const inflight = getTripoArchiveInflight(); const existingTask = inflight.get(params.cacheKey); if (existingTask) { - const result = await existingTask; - return result || sourceUrl; + return { url: sourceUrl, pending: true }; } const archiveTask = (async () => { @@ -190,15 +194,19 @@ async function archiveTripoModelIfNeeded(params: { sourceUrl, error, }); - return null; + cache.set(params.cacheKey, { + sourceUrl, + archivedUrl: sourceUrl, + }); + return sourceUrl; } finally { inflight.delete(params.cacheKey); } })(); inflight.set(params.cacheKey, archiveTask); - const archivedUrl = await archiveTask; - return archivedUrl || sourceUrl; + void archiveTask; + return { url: sourceUrl, pending: true }; } function replaceArchivedUrls(value: unknown, replacements: Map): unknown { @@ -346,20 +354,23 @@ export async function GET(request: Request, { params }: RouteParams) { : 0; const modelUrl = pickTaskModelUrl(output as Record); const previewImageUrl = pickTaskPreviewImageUrl(output as Record); - const stableModelUrl = await archiveTripoModelIfNeeded({ + const modelArchive = await archiveTripoModelIfNeeded({ taskId: normalizedTaskId, cacheKey: `tripo:${normalizedTaskId}:model`, sourceUrl: modelUrl, filenamePrefix: `tripo-model-${normalizedTaskId}`, fallbackExtension: "glb", }); - const stablePreviewImageUrl = await archiveTripoModelIfNeeded({ + const previewArchive = await archiveTripoModelIfNeeded({ taskId: normalizedTaskId, cacheKey: `tripo:${normalizedTaskId}:preview`, sourceUrl: previewImageUrl, filenamePrefix: `tripo-preview-${normalizedTaskId}`, fallbackExtension: "jpg", }); + const stableModelUrl = modelArchive.url; + const stablePreviewImageUrl = previewArchive.url; + const assetsPending = modelArchive.pending || previewArchive.pending; const replacements = new Map(); if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) { replacements.set(modelUrl, stableModelUrl); @@ -376,15 +387,23 @@ export async function GET(request: Request, { params }: RouteParams) { ? replaceArchivedUrls(output, replacements) : output; const storageArchivedAt = - replacements.size > 0 ? new Date().toISOString() : null; + replacements.size > 0 && !assetsPending ? new Date().toISOString() : null; + const responseStatus = + String(taskStatus).trim().toLowerCase() === "success" && assetsPending + ? "running" + : result.data.status; + const responseProgress = + String(taskStatus).trim().toLowerCase() === "success" && assetsPending + ? 99 + : result.data.progress ?? null; return NextResponse.json({ success: true, data: { taskId: result.data.task_id, type: result.data.type, - status: result.data.status, - progress: result.data.progress ?? null, + status: responseStatus, + progress: responseProgress, output: archivedOutput, modelUrl: stableModelUrl, previewImageUrl: stablePreviewImageUrl, @@ -392,6 +411,7 @@ export async function GET(request: Request, { params }: RouteParams) { providerId: config.providerId || null, providerName: config.providerName || null, storageArchivedAt, + assetsPending, sourceModelUrl: modelUrl && stableModelUrl && modelUrl !== stableModelUrl ? modelUrl diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 402af4127..7b4f08003 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -285,6 +285,23 @@ const FINAL_TASK_STATUS = new Set([ "cancelled", "unknown", ]); +const RECOVERABLE_TASK_QUERY_ERROR_REGEX = + /(?:查询\s*(?:3D\s*)?任务失败|任务查询失败|任务同步超时)/i; + +function restoreRecoverableTaskForPolling(task: TaskRecord) { + if ( + task.provider === "tripo-3d" && + task.status === "failed" && + RECOVERABLE_TASK_QUERY_ERROR_REGEX.test(String(task.error || "")) + ) { + return { + ...task, + status: "running" as const, + error: null, + }; + } + return task; +} const TASK_MODE_SET = new Set([ "text", "image", @@ -850,6 +867,7 @@ export default function CanvasModel3DPage() { ); const pollAttemptsRef = useRef(0); + const pollFailureStreakRef = useRef(0); const multiViewSlotsRef = useRef([]); const batchImageSlotsRef = useRef([]); @@ -1186,6 +1204,7 @@ export default function CanvasModel3DPage() { let normalized = list .map((item) => mapApiEntityToTaskRecord(item)) .filter((item): item is TaskRecord => Boolean(item)) + .map(restoreRecoverableTaskForPolling) .slice(0, TASK_HISTORY_LIMIT); if (normalized.length === 0) { @@ -1418,34 +1437,39 @@ export default function CanvasModel3DPage() { pollAttemptsRef.current = 0; let cancelled = false; + let pollInFlight = false; const runPoll = async () => { - if (cancelled) return; + if (cancelled || pollInFlight) return; + pollInFlight = true; pollAttemptsRef.current += 1; + try { + const isWorldTask = currentTask.provider === "worldlabs-3d"; + const result = + isWorldTask + ? await worldLabsGenerationService.getTask(currentTask.taskId, { + providerId: currentTask.assetPayload?.providerId, + }) + : await model3DGenerationService.getTask(currentTask.taskId, { + providerId: currentTask.assetPayload?.providerId, + }); + if (!result.success || !result.data) { + pollFailureStreakRef.current += 1; + if (pollFailureStreakRef.current < 5) return; + const failMessage = result.error || "任务查询失败"; + const failedRecord: TaskRecord = { + ...currentTask, + status: "failed", + error: failMessage, + }; + setCurrentTask(failedRecord); + upsertTaskHistory(failedRecord); + toast.error(failMessage); + return; + } + pollFailureStreakRef.current = 0; - const isWorldTask = currentTask.provider === "worldlabs-3d"; - const result = - isWorldTask - ? await worldLabsGenerationService.getTask(currentTask.taskId, { - providerId: currentTask.assetPayload?.providerId, - }) - : await model3DGenerationService.getTask(currentTask.taskId, { - providerId: currentTask.assetPayload?.providerId, - }); - if (!result.success || !result.data) { - const failMessage = result.error || "任务查询失败"; - const failedRecord: TaskRecord = { - ...currentTask, - status: "failed", - error: failMessage, - }; - setCurrentTask(failedRecord); - upsertTaskHistory(failedRecord); - toast.error(failMessage); - return; - } - - const status = normalizeTaskStatus(result.data.status); + const status = normalizeTaskStatus(result.data.status); const worldResultData = isWorldTask ? (result.data as NonNullable< Awaited>["data"] @@ -1501,7 +1525,7 @@ export default function CanvasModel3DPage() { } } - if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) { + if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) { const timeoutRecord: TaskRecord = { ...nextRecord, status: "failed", @@ -1510,6 +1534,9 @@ export default function CanvasModel3DPage() { setCurrentTask(timeoutRecord); upsertTaskHistory(timeoutRecord); toast.error("任务轮询超时,请稍后重试"); + } + } finally { + pollInFlight = false; } }; diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 57a5cbc53..d789d3616 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -645,6 +645,7 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024; const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024; const CANVAS_3D_POLL_INTERVAL_MS = 2000; const CANVAS_3D_MAX_POLL_ATTEMPTS = 360; +const CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES = 5; const AI_WASH_TASK_POLL_INTERVAL_MS = 2500; const AI_WASH_TASK_POLL_LIMIT = 80; const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000; @@ -7107,6 +7108,7 @@ export function ImageNode({ sourceImageUrl: string; previewImageUrl?: string | null; }) => { + let consecutiveQueryFailures = 0; for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) { await sleep(CANVAS_3D_POLL_INTERVAL_MS); const result = await model3DGenerationService.getTask(params.taskId, { @@ -7116,6 +7118,12 @@ export function ImageNode({ const progress = result.data?.progress ?? null; if (!result.success || !result.data) { + consecutiveQueryFailures += 1; + if ( + consecutiveQueryFailures < CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES + ) { + continue; + } const error = result.error || "3D 模型任务查询失败"; updateNode(params.nodeId, { data: { @@ -7155,6 +7163,7 @@ export function ImageNode({ toast.error(error); return; } + consecutiveQueryFailures = 0; if ( status === "failed" || diff --git a/lib/ai/reference-image-capability.ts b/lib/ai/reference-image-capability.ts index 3da4e001d..3fc8d2d9b 100644 --- a/lib/ai/reference-image-capability.ts +++ b/lib/ai/reference-image-capability.ts @@ -14,6 +14,7 @@ const BFL_REFERENCE_IMAGE_MODEL_REGEX = /^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i; const REFERENCE_IMAGE_MODE_REGEX = /^(?:image-edit|image-to-image|img2img|reference-images?|image-reference|reference-image)$/i; +const GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX = /^(?:nano-banana-pro|nano-banana-2)$/i; function readStringField(source: unknown, key: string) { if (!source || typeof source !== "object" || !(key in source)) return ""; @@ -86,6 +87,14 @@ export function modelLacksReferenceImageInputSupport( provider?: ProviderDescriptor | null ) { if (isKnownReferenceImageModel(model, provider)) return false; + const providerType = String(provider?.type || "").trim().toLowerCase(); + const modelId = String(model?.modelId || "").trim(); + if ( + providerType === "gemini" && + GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX.test(modelId) + ) { + return true; + } return model?.supportsVision === false; } diff --git a/lib/style-pro/task.ts b/lib/style-pro/task.ts index 0282484c6..ede317775 100644 --- a/lib/style-pro/task.ts +++ b/lib/style-pro/task.ts @@ -11,6 +11,10 @@ import type { StyleProCustomInputMap, StyleProSelectionMap, } from "./config"; +import { + findStyleProImageModelByRef, + getStyleProImageModelEntries, +} from "./model-options"; export type StyleProTaskSelectedOption = { categoryId: string; @@ -266,6 +270,18 @@ export async function executeStyleProTask(params: { userId: string; }) { const { request, userId } = params; + const runnableModels = await getStyleProImageModelEntries({ + includeHidden: request.generationMode === "realistic", + }); + const unavailableModel = request.selectedModels.find( + (selectedModel) => + !findStyleProImageModelByRef(runnableModels, selectedModel.id) + ); + if (unavailableModel) { + throw new Error( + `风格化模型“${unavailableModel.displayName || unavailableModel.id}”当前不支持参考图,请重新选择` + ); + } const aspectRatio = request.prompts.aspectRatio || "auto"; const imageSize = request.prompts.imageSize || "2K"; const negativePrompt = request.prompts.negativePrompt || undefined; diff --git a/tests/routes/model3d-style-pro-regressions.test.ts b/tests/routes/model3d-style-pro-regressions.test.ts index c2cb53070..c919fae60 100644 --- a/tests/routes/model3d-style-pro-regressions.test.ts +++ b/tests/routes/model3d-style-pro-regressions.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "@playwright/test"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability"; function readSource(path: string) { return readFileSync(resolve(process.cwd(), path), "utf8"); @@ -54,6 +55,21 @@ test.describe("3D workspace and Style Pro regressions", () => { } }); + test("does not block a completed Tripo task while permanent assets are archived", () => { + const route = readSource("app/api/canvas/3d/tasks/[taskId]/route.ts"); + const page = readSource("app/canvas/[id]/model3d/page.tsx"); + const imageNode = readSource("components/canvas/nodes/ImageNode.tsx"); + + expect(route).toContain("return { url: sourceUrl, pending: true }"); + expect(route).toContain('String(taskStatus).trim().toLowerCase() === "success" && assetsPending'); + expect(route).toContain('? "running"'); + expect(route).toContain("assetsPending,"); + expect(page).toContain("pollFailureStreakRef.current < 5"); + expect(page).toContain("cancelled || pollInFlight"); + expect(page).toContain("restoreRecoverableTaskForPolling"); + expect(imageNode).toContain("CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES"); + }); + test("uses the same hidden-model access rule in Style Pro as canvas", () => { const route = readSource("app/api/style-pro/model-options/route.ts"); const canvasModelsRoute = readSource("app/(chat)/api/models/route.ts"); @@ -82,12 +98,43 @@ test.describe("3D workspace and Style Pro regressions", () => { const availability = readSource( "lib/style-pro/model-availability.ts" ); + const referenceCapability = readSource( + "lib/ai/reference-image-capability.ts" + ); expect(options).toContain(".filter(isStyleProModelRunnable)"); expect(options).toContain("getStyleProModelAvailability(entry)"); expect(availability).toContain("modelSupportsReferenceImageInput"); expect(availability).toContain('availabilityLabel: "仅文生图"'); expect(availability).toContain('availabilityLabel: "暂不可用"'); + expect(referenceCapability).toContain("GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX"); + + const task = readSource("lib/style-pro/task.ts"); + expect(task).toContain("const runnableModels = await getStyleProImageModelEntries"); + expect(task).toContain("当前不支持参考图,请重新选择"); + }); + + test("keeps known text-only Gemini image aliases out of reference-image tools", () => { + expect( + modelSupportsReferenceImageInput( + { + modelId: "nano-banana-pro", + displayName: "BanA Pro", + supportsVision: true, + }, + { type: "gemini" } + ) + ).toBe(false); + expect( + modelSupportsReferenceImageInput( + { + modelId: "gemini-3-pro-image-preview", + displayName: "BanA Pro V", + supportsVision: true, + }, + { type: "bananaimage" } + ) + ).toBe(true); }); test("opens the canvas conversation panel by default", () => {