fix(canvas): recover 3d polling and harden style models

This commit is contained in:
134 2026-08-10 23:18:46 +08:00
parent c0a155ea80
commit 8878f53bdd
6 changed files with 166 additions and 38 deletions

View File

@ -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<RemoteAssetArchiveResult> {
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<string, string>): unknown {
@ -346,20 +354,23 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0;
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
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<string, string>();
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

View File

@ -285,6 +285,23 @@ const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
"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<TaskMode>([
"text",
"image",
@ -850,6 +867,7 @@ export default function CanvasModel3DPage() {
);
const pollAttemptsRef = useRef(0);
const pollFailureStreakRef = useRef(0);
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
@ -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<ReturnType<typeof worldLabsGenerationService.getTask>>["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;
}
};

View File

@ -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" ||

View File

@ -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;
}

View File

@ -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;

View File

@ -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", () => {