fix(canvas): recover 3d polling and harden style models
This commit is contained in:
parent
c0a155ea80
commit
8878f53bdd
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user