Compare commits
13 Commits
codex/part
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 73eb276e7f | |||
| 8c7ca174bc | |||
| 8fd0dba59f | |||
| ce94241234 | |||
| 198deca47b | |||
| 8908b0d39a | |||
| 34ba1919c4 | |||
| 2fdc4299e0 | |||
| ac2369b657 | |||
| 6642c39198 | |||
| ac7928fe2d | |||
| b06fdbb38b | |||
| 61e2bbe0e4 |
@ -1,10 +1,11 @@
|
|||||||
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
import { isExplicitDestructiveIntent } from "@/lib/ai/canvas-agent-runtime/capability-access";
|
||||||
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
import { inferVideoTaskRequirements } from "@/lib/ai/canvas-agent-runtime/video-protocol";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
import { geolocation } from "@vercel/functions";
|
import { geolocation } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream as createAiUIMessageStream,
|
||||||
generateText,
|
generateText,
|
||||||
smoothStream,
|
smoothStream,
|
||||||
stepCountIs,
|
stepCountIs,
|
||||||
@ -426,6 +427,8 @@ import {
|
|||||||
} from "./_lib/video-models";
|
} from "./_lib/video-models";
|
||||||
|
|
||||||
const chatApiLogger = createLogger("chat.route");
|
const chatApiLogger = createLogger("chat.route");
|
||||||
|
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
||||||
|
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
|
||||||
|
|
||||||
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
const INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN =
|
||||||
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
/生成图片失败|图片生成失败|未找到图片数据|未解析到图片|模型未返回生成图片|图片数据不可用|未返回可用图片/i;
|
||||||
@ -443,13 +446,13 @@ function assertGeneratedImageMarkdownHasImage(markdown: string, modelLabel: stri
|
|||||||
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
throw new Error(`${modelLabel} 未返回可用图片:${reason.slice(0, 160)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPublicImageGenerationErrorText(error: unknown) {
|
function getPublicImageGenerationErrorText(error: unknown, requestId: string) {
|
||||||
const message = error instanceof Error ? error.message : String(error || "");
|
const message = error instanceof Error ? error.message : String(error || "");
|
||||||
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
|
||||||
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
|
||||||
return "图片生成失败,请稍后重试";
|
return `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`;
|
||||||
}
|
}
|
||||||
return publicMessage;
|
return `${publicMessage}\n\n请求编号:${requestId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -9630,9 +9633,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 如果故障转移也失败,返回错误消息
|
// 如果故障转移也失败,返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
"图片生成失败,请稍后重试",
|
errorText,
|
||||||
"gemini:error",
|
"gemini:error",
|
||||||
errorMessageId,
|
errorMessageId,
|
||||||
);
|
);
|
||||||
@ -9652,7 +9656,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
parts: [{ type: "text", text: errorText }],
|
||||||
attachments: [],
|
attachments: [],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -10332,7 +10336,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
const errorText = getPublicImageGenerationErrorText(error);
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
errorText,
|
errorText,
|
||||||
@ -10772,9 +10776,10 @@ export async function POST(request: Request) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
writeAssistantTextChunk(
|
writeAssistantTextChunk(
|
||||||
writer,
|
writer,
|
||||||
"图片生成失败,请稍后重试",
|
errorText,
|
||||||
"volcano:error",
|
"volcano:error",
|
||||||
errorMessageId,
|
errorMessageId,
|
||||||
);
|
);
|
||||||
@ -10794,7 +10799,7 @@ export async function POST(request: Request) {
|
|||||||
id: errorMessageId,
|
id: errorMessageId,
|
||||||
chatId: id,
|
chatId: id,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "图片生成失败,请稍后重试" }],
|
parts: [{ type: "text", text: errorText }],
|
||||||
attachments: [], // 必须提供 attachments 字段
|
attachments: [], // 必须提供 attachments 字段
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
videoStorageStatus: null,
|
videoStorageStatus: null,
|
||||||
@ -11702,7 +11707,7 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||||
|
|
||||||
const errorMessageId = generateUUID();
|
const errorMessageId = generateUUID();
|
||||||
const errorText = getPublicImageGenerationErrorText(error);
|
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||||
|
|
||||||
dataStream.write({
|
dataStream.write({
|
||||||
type: "data-appendMessage",
|
type: "data-appendMessage",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry"
|
|||||||
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
import { executeCanvasCapabilityRuntime } from "@/lib/canvas/capabilities/runtime";
|
||||||
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
import { getCanvasCapabilityTask } from "@/lib/canvas/capabilities/task-store";
|
||||||
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
import { CanvasAgentMutationError } from "@/lib/canvas/agent-mutations";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const maxDuration = 600;
|
export const maxDuration = 600;
|
||||||
@ -39,7 +40,8 @@ export async function GET(
|
|||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ success: true, task });
|
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||||
|
return NextResponse.json({ success: true, task: { ...task, failure, error: failure ? formatRequestFailure(failure, task.id) : task.error } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@ -51,9 +53,11 @@ export async function POST(
|
|||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let requestId: string | undefined;
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
const command = canvasCapabilityClientCommandSchema.parse(await request.json());
|
||||||
|
requestId = command.requestId;
|
||||||
const result = await executeCanvasCapabilityRuntime({
|
const result = await executeCanvasCapabilityRuntime({
|
||||||
command,
|
command,
|
||||||
projectId: id,
|
projectId: id,
|
||||||
@ -67,8 +71,9 @@ export async function POST(
|
|||||||
error instanceof CanvasCapabilityRuntimeError ||
|
error instanceof CanvasCapabilityRuntimeError ||
|
||||||
error instanceof CanvasAgentMutationError
|
error instanceof CanvasAgentMutationError
|
||||||
) {
|
) {
|
||||||
|
const failure = getRequestFailureDetails(error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: error.message, code: error.code },
|
{ success: false, error: formatRequestFailure(failure, requestId), code: error.code, failure, requestId },
|
||||||
{ status: error.status }
|
{ status: error.status }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -82,9 +87,9 @@ export async function POST(
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : "执行画布能力失败";
|
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: message },
|
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { after, NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||||
@ -13,6 +16,22 @@ import {
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
|
function failed(failure: DownloadFailure, requestId: string) {
|
||||||
|
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
||||||
|
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||||
|
status: failure.status,
|
||||||
|
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparing(requestId: string) {
|
||||||
|
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
||||||
|
message: "正在准备原图,完成后将自动开始下载" }, {
|
||||||
|
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||||
// A proxy candidate for the same original must not repeat the same slow
|
// A proxy candidate for the same original must not repeat the same slow
|
||||||
@ -21,22 +40,25 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
|
|||||||
const source = extractImageProxySourceUrl(url) || url;
|
const source = extractImageProxySourceUrl(url) || url;
|
||||||
return inferOriginalImageUrl(source) || source;
|
return inferOriginalImageUrl(source) || source;
|
||||||
}))];
|
}))];
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
for (const url of sources) {
|
for (const url of sources) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const result = await fetchCanvasDownloadAsset({
|
const result = await fetchCanvasDownloadAsset({
|
||||||
file: { url, name: "original" }, index: 0,
|
file: { url, name: "original" }, index: 0,
|
||||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||||
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
||||||
});
|
});
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||||
|
lastError = result.error;
|
||||||
}
|
}
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
// No AI/model request is involved and a completed source fetch is shared.
|
||||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
|
else break;
|
||||||
}
|
}
|
||||||
throw new Error("Original image sources unavailable");
|
throw new Error(String(lastError || "Original image sources unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
@ -76,31 +98,43 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = randomUUID();
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return failed(getDownloadFailure("未登录"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
||||||
}
|
}
|
||||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return failed(getDownloadFailure("图片地址无效"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
if (body.preferDirect === true) {
|
if (body.preferDirect === true) {
|
||||||
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
||||||
|
logger.warn("cdn_lookup_failed", error, { requestId });
|
||||||
|
return { task: null, direct: null, canPrepareDirect: false };
|
||||||
|
});
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
if (task && canPrepareDirect && body.preparation === "async") {
|
||||||
|
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
||||||
|
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
||||||
|
return failed(asset.failure || getDownloadFailure(null), task.id);
|
||||||
|
}
|
||||||
|
scheduleImageDownloadPreparation(task.id);
|
||||||
|
return preparing(task.id);
|
||||||
|
}
|
||||||
if (task?.targetUrl) {
|
if (task?.targetUrl) {
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||||
after(() => prepareImageArchiveDownload(task.id));
|
scheduleImageDownloadPreparation(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
@ -109,13 +143,10 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||||
});
|
});
|
||||||
if (result.status === "preparing") {
|
if (result.status === "preparing") {
|
||||||
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
return preparing(result.requestId);
|
||||||
message: "正在准备原图,完成后将自动下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
return failed(result.failure, result.requestId);
|
||||||
}
|
}
|
||||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||||
"Content-Type": result.asset.contentType,
|
"Content-Type": result.asset.contentType,
|
||||||
@ -136,17 +167,14 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(`${url}: ${result.error}`);
|
failures.push(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
logger.warn("all_sources_failed", { requestId, failures });
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return failed(getDownloadFailure(result?.error), requestId);
|
||||||
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -159,7 +187,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasDownloadImage] Failed:", error);
|
logger.error("download_failed", error, { requestId });
|
||||||
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
return failed(getDownloadFailure(error), requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { executeCanvasImageResponse } from "@/lib/canvas/generate-image-response
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
import { getCanvasImageTaskForUser } from "@/lib/canvas/image-task-queue";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
export const maxDuration = 1200;
|
export const maxDuration = 1200;
|
||||||
|
|
||||||
@ -23,13 +24,15 @@ export async function GET(request: Request) {
|
|||||||
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
return NextResponse.json({ success: false, error: "任务不存在" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: task.error || null,
|
error: failure ? formatRequestFailure(failure, task.id) : null,
|
||||||
|
failure,
|
||||||
result: task.result || null,
|
result: task.result || null,
|
||||||
createdAt: task.createdAt,
|
createdAt: task.createdAt,
|
||||||
startedAt: task.startedAt,
|
startedAt: task.startedAt,
|
||||||
|
|||||||
@ -66,6 +66,7 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
fetchUploadWithRetry,
|
fetchUploadWithRetry,
|
||||||
@ -373,7 +374,9 @@ function MarkdownResponseFallback({
|
|||||||
}: Pick<ResponseProps, "children" | "className">) {
|
}: Pick<ResponseProps, "children" | "className">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
|
||||||
{children}
|
{typeof children === "string"
|
||||||
|
? getGatewayErrorPageMessage(children) || children
|
||||||
|
: children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-do
|
|||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
|
||||||
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||||
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@ -1028,7 +1029,10 @@ export const Response = ({
|
|||||||
|
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof props.children === "string"
|
typeof props.children === "string"
|
||||||
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming))
|
? normalizeMarkdownContent(stableStreamingMarkdown(
|
||||||
|
getGatewayErrorPageMessage(props.children) || props.children,
|
||||||
|
isMarkdownStreaming
|
||||||
|
))
|
||||||
: null;
|
: null;
|
||||||
const shouldShowPdfDownload =
|
const shouldShowPdfDownload =
|
||||||
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type { Vote } from "@/lib/db/schema";
|
|||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { cn, sanitizeText } from "@/lib/utils";
|
import { cn, sanitizeText } from "@/lib/utils";
|
||||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||||
|
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||||
import { useDataStream } from "./data-stream-provider";
|
import { useDataStream } from "./data-stream-provider";
|
||||||
import { DocumentToolResult } from "./document";
|
import { DocumentToolResult } from "./document";
|
||||||
import { DocumentPreview } from "./document-preview";
|
import { DocumentPreview } from "./document-preview";
|
||||||
@ -521,7 +522,7 @@ const PurePreviewMessage = ({
|
|||||||
const displayModelName = extractedModelName || inferredFallbackModelName;
|
const displayModelName = extractedModelName || inferredFallbackModelName;
|
||||||
const filteredText =
|
const filteredText =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
? appendImageDoneSummary(contentWithoutBadge)
|
? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
|
||||||
: contentWithoutBadge;
|
: contentWithoutBadge;
|
||||||
const useAssistantTypewriter =
|
const useAssistantTypewriter =
|
||||||
message.role === "assistant" &&
|
message.role === "assistant" &&
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||||
* - qwen-image-edit:仅支持 1 张输出
|
* - qwen-image-edit:仅支持 1 张输出
|
||||||
*
|
*
|
||||||
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@ -46,6 +46,8 @@ interface AlibabaImageResponse {
|
|||||||
output?: {
|
output?: {
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
task_status?: string;
|
task_status?: string;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
message: {
|
message: {
|
||||||
@ -122,6 +124,11 @@ async function fetchImageBufferWithRetry(
|
|||||||
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
||||||
attempts = 2
|
attempts = 2
|
||||||
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
||||||
|
if (imageUrl.startsWith("data:image/")) {
|
||||||
|
const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
|
||||||
|
if (!match) throw new Error("输入图片数据格式无效");
|
||||||
|
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
||||||
|
}
|
||||||
let lastError: unknown = null;
|
let lastError: unknown = null;
|
||||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
const ctx = createAbortSignal(timeoutMs);
|
const ctx = createAbortSignal(timeoutMs);
|
||||||
@ -168,7 +175,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
||||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -186,7 +193,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
|
|||||||
async function inferImageDimensionsFromImageUrl(
|
async function inferImageDimensionsFromImageUrl(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
||||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -302,6 +309,14 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
|
if (isAlibabaQwenImage3ModelId(modelId)) {
|
||||||
|
if (prepared.buffer.length > maxInputBytes) {
|
||||||
|
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
||||||
|
}
|
||||||
|
const format = prepared.transformed ? prepared.extension : metadata.format;
|
||||||
|
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
||||||
|
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
||||||
|
}
|
||||||
if (!prepared.transformed) {
|
if (!prepared.transformed) {
|
||||||
return normalizedUrl;
|
return normalizedUrl;
|
||||||
}
|
}
|
||||||
@ -565,8 +580,10 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
|
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
||||||
|
const code = task.output?.code || task.code;
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastPollError = null;
|
lastPollError = null;
|
||||||
|
|||||||
@ -48,6 +48,7 @@ import {
|
|||||||
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
VIP98K_REFERENCE_STAGING_KEY_PREFIX,
|
||||||
} from "@/lib/ai/vip98k-reference-staging";
|
} from "@/lib/ai/vip98k-reference-staging";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
IMAGE_UPLOAD_MAX_BYTES,
|
IMAGE_UPLOAD_MAX_BYTES,
|
||||||
@ -1023,6 +1024,77 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
|
|||||||
return Math.min(Math.max(value, 1), 3600);
|
return Math.min(Math.max(value, 1), 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSynchronousImagePath(path: string) {
|
||||||
|
return /^\/v1\/images\/(?:generations|edits)\/?$/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverImageSubmission(params: {
|
||||||
|
apiKey: string;
|
||||||
|
baseURL: string;
|
||||||
|
modelId: string;
|
||||||
|
path: string;
|
||||||
|
idempotencyKey: string;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
maxPollMs: number;
|
||||||
|
}) {
|
||||||
|
if (!isSynchronousImagePath(params.path)) return null;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + params.maxPollMs;
|
||||||
|
logger.info("recovering original image request", { modelId: params.modelId });
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const lookup = await fetch(`${params.baseURL}/api/external/requests/by-idempotency-key`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${params.apiKey}`,
|
||||||
|
"Idempotency-Key": params.idempotencyKey,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(Math.min(60_000, deadline - Date.now())),
|
||||||
|
});
|
||||||
|
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
||||||
|
if (lookup.ok) {
|
||||||
|
const state = toRecord(await parseJsonResponse(lookup));
|
||||||
|
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
|
||||||
|
if (state.status === "succeeded") {
|
||||||
|
if (state.result_available !== true || !state.request_id) return null;
|
||||||
|
const result = await fetch(
|
||||||
|
`${params.baseURL}/api/external/requests/${encodeURIComponent(String(state.request_id))}/result`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Authorization: `Bearer ${params.apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(Math.max(1, Math.min(60_000, deadline - Date.now()))),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if ([401, 403, 404, 410].includes(result.status)) return null;
|
||||||
|
if (result.ok) {
|
||||||
|
const payload = await parseJsonResponse(result);
|
||||||
|
const outputs = collectOutputUrls(payload);
|
||||||
|
if (!outputs.length) {
|
||||||
|
throw new ProviderTaskUncertainError("原生图请求已完成,但未取回图片结果,请勿重复生成");
|
||||||
|
}
|
||||||
|
logger.info("original image request recovered", {
|
||||||
|
modelId: params.modelId,
|
||||||
|
providerRequestId: state.request_id,
|
||||||
|
outputCount: outputs.length,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return { payload, taskId: extractTaskId(payload), resultPath: extractResultPath(payload), outputs };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProviderTaskUncertainError) throw error;
|
||||||
|
}
|
||||||
|
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
|
||||||
|
}
|
||||||
|
logger.warn("original image request recovery timed out", {
|
||||||
|
modelId: params.modelId,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async function submitTask(params: {
|
async function submitTask(params: {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseURL?: string | null;
|
baseURL?: string | null;
|
||||||
@ -1035,6 +1107,8 @@ async function submitTask(params: {
|
|||||||
submissionTimeoutMs: number;
|
submissionTimeoutMs: number;
|
||||||
maxSubmissionAttempts: number;
|
maxSubmissionAttempts: number;
|
||||||
submissionRetryBaseDelayMs: number;
|
submissionRetryBaseDelayMs: number;
|
||||||
|
pollIntervalMs: number;
|
||||||
|
maxPollMs: number;
|
||||||
}) {
|
}) {
|
||||||
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
const baseURL = normalizeVip98kBaseUrl(params.baseURL);
|
||||||
const path = String(params.path || "/api/external/task/generate").trim();
|
const path = String(params.path || "/api/external/task/generate").trim();
|
||||||
@ -1098,7 +1172,9 @@ async function submitTask(params: {
|
|||||||
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
signal: AbortSignal.timeout(params.submissionTimeoutMs),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt >= maxAttempts) {
|
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
||||||
|
if (recovered) return recovered;
|
||||||
|
if (isSynchronousImagePath(path) || attempt >= maxAttempts) {
|
||||||
logger.error("provider submission transport failed", {
|
logger.error("provider submission transport failed", {
|
||||||
modelId: params.modelId,
|
modelId: params.modelId,
|
||||||
path,
|
path,
|
||||||
@ -1144,6 +1220,14 @@ async function submitTask(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([502, 504, 524].includes(response.status)) {
|
||||||
|
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
|
||||||
|
if (recovered) return recovered;
|
||||||
|
if (isSynchronousImagePath(path)) {
|
||||||
|
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: extractErrorMessage(payload), status: response.status })} (HTTP ${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = extractErrorMessage(payload);
|
const errorMessage = extractErrorMessage(payload);
|
||||||
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
@ -1188,9 +1272,9 @@ async function submitTask(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|
||||||
|| [502, 504].includes(response.status)
|
|| [502, 504, 524].includes(response.status)
|
||||||
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
|
||||||
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`);
|
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
|
||||||
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
throw new Error(`${errorMessage} (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1310,6 +1394,12 @@ export async function submitVip98kExternalTask(params: {
|
|||||||
),
|
),
|
||||||
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
|
||||||
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
|
||||||
|
pollIntervalMs: getConfiguredPollSeconds(
|
||||||
|
taskParams.modelConfig, "pollIntervalSeconds", DEFAULT_POLL_INTERVAL_SECONDS,
|
||||||
|
) * 1000,
|
||||||
|
maxPollMs: getConfiguredPollSeconds(
|
||||||
|
taskParams.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS,
|
||||||
|
) * 1000,
|
||||||
});
|
});
|
||||||
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
const resultPath = submitted.resultPath || invocation?.resultPath || "";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { ProviderTaskUncertainError } from "@/lib/video-canvas/video-task-lifecycle";
|
||||||
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
import { isProviderAuthenticationFailure, isCredentialFailureBan } from "./authentication-failover";
|
||||||
|
|
||||||
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
@ -12,7 +13,7 @@ import {
|
|||||||
isMediaInputFailure,
|
isMediaInputFailure,
|
||||||
isNonProviderRequestStatus,
|
isNonProviderRequestStatus,
|
||||||
} from "@/lib/ai/runtime/request-failure-classification";
|
} from "@/lib/ai/runtime/request-failure-classification";
|
||||||
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -343,6 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldFailoverAiModelError(error: unknown) {
|
export function shouldFailoverAiModelError(error: unknown) {
|
||||||
|
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
|
||||||
const classification = classifyAiModelHealthFailure(error);
|
const classification = classifyAiModelHealthFailure(error);
|
||||||
const normalized = getErrorMessage(error).toLowerCase();
|
const normalized = getErrorMessage(error).toLowerCase();
|
||||||
const imageProviderTaskFailed =
|
const imageProviderTaskFailed =
|
||||||
|
|||||||
@ -14,13 +14,16 @@ import { getCanvasCapability } from "@/lib/canvas/capabilities/registry";
|
|||||||
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
import { enqueueCanvasCapabilityExecution } from "@/lib/canvas/capabilities/task-queue";
|
||||||
import {
|
import {
|
||||||
createOrRecoverCanvasCapabilityTask,
|
createOrRecoverCanvasCapabilityTask,
|
||||||
MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS,
|
getCanvasCapabilityTask,
|
||||||
|
heartbeatCanvasCapabilityTask,
|
||||||
markCanvasCapabilityTaskFailed,
|
markCanvasCapabilityTaskFailed,
|
||||||
markCanvasCapabilityTaskRunning,
|
markCanvasCapabilityTaskRunning,
|
||||||
markCanvasCapabilityTaskSucceeded,
|
markCanvasCapabilityTaskSucceeded,
|
||||||
} from "@/lib/canvas/capabilities/task-store";
|
} from "@/lib/canvas/capabilities/task-store";
|
||||||
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
import { applyCanvasOperationPlan } from "@/lib/canvas/agent-mutations";
|
||||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
export type CanvasCapabilityRuntimeResult = {
|
export type CanvasCapabilityRuntimeResult = {
|
||||||
clientCommand?: CanvasCapabilityClientCommand;
|
clientCommand?: CanvasCapabilityClientCommand;
|
||||||
@ -58,9 +61,11 @@ function serializeTask(task: {
|
|||||||
status: string;
|
status: string;
|
||||||
version: number;
|
version: number;
|
||||||
}) {
|
}) {
|
||||||
|
const failure = task.status === "failed" ? getRequestFailureDetails(task.error) : undefined;
|
||||||
return {
|
return {
|
||||||
attempts: task.attempts,
|
attempts: task.attempts,
|
||||||
error: task.error,
|
error: failure ? formatRequestFailure(failure, task.id) : task.error,
|
||||||
|
failure,
|
||||||
id: task.id,
|
id: task.id,
|
||||||
requestId: task.requestId,
|
requestId: task.requestId,
|
||||||
result: task.result,
|
result: task.result,
|
||||||
@ -69,6 +74,13 @@ function serializeTask(task: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storedTaskResult(task: Parameters<typeof serializeTask>[0], mode: "headless" | "interactive"): CanvasCapabilityRuntimeResult {
|
||||||
|
const result = task.status === "succeeded" && task.result && typeof task.result === "object"
|
||||||
|
? task.result as Omit<CanvasCapabilityRuntimeResult, "task">
|
||||||
|
: { mode };
|
||||||
|
return { ...result, task: serializeTask(task) };
|
||||||
|
}
|
||||||
|
|
||||||
function buildMediaOperationPlan(params: {
|
function buildMediaOperationPlan(params: {
|
||||||
command: CanvasCapabilityClientCommand;
|
command: CanvasCapabilityClientCommand;
|
||||||
mediaResults: CanvasCapabilityMediaResult[];
|
mediaResults: CanvasCapabilityMediaResult[];
|
||||||
@ -211,11 +223,7 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (task.status === "succeeded") {
|
if (task.status === "succeeded") {
|
||||||
const storedResult =
|
return storedTaskResult(task, "headless");
|
||||||
task.result && typeof task.result === "object"
|
|
||||||
? (task.result as Omit<CanvasCapabilityRuntimeResult, "task">)
|
|
||||||
: ({ mode: "headless" } as const);
|
|
||||||
return { ...storedResult, task: serializeTask(task) };
|
|
||||||
}
|
}
|
||||||
if (task.status === "running") {
|
if (task.status === "running") {
|
||||||
return {
|
return {
|
||||||
@ -225,15 +233,8 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
task: serializeTask(task),
|
task: serializeTask(task),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (task.status === "failed") {
|
||||||
task.status === "failed" &&
|
return { mode: "headless", task: serializeTask(task) };
|
||||||
task.attempts >= MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
throw new CanvasCapabilityRuntimeError(
|
|
||||||
task.error || "画布能力任务超过最大重试次数",
|
|
||||||
409,
|
|
||||||
"CANVAS_CAPABILITY_RETRY_EXHAUSTED"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
const shouldRunHeadlessly = shouldExecuteCanvasCapabilityHeadlessly({
|
||||||
@ -241,7 +242,9 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
command,
|
command,
|
||||||
});
|
});
|
||||||
if (!shouldRunHeadlessly) {
|
if (!shouldRunHeadlessly) {
|
||||||
task = await markCanvasCapabilityTaskRunning(task.id);
|
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
|
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "interactive");
|
||||||
|
task = claimed;
|
||||||
const result = {
|
const result = {
|
||||||
mode: "interactive" as const,
|
mode: "interactive" as const,
|
||||||
clientCommand: command,
|
clientCommand: command,
|
||||||
@ -250,10 +253,18 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
return { ...result, task: serializeTask(task) };
|
return { ...result, task: serializeTask(task) };
|
||||||
}
|
}
|
||||||
|
|
||||||
return enqueueCanvasCapabilityExecution({
|
const heartbeat = setInterval(() => {
|
||||||
|
void heartbeatCanvasCapabilityTask(task.id).catch((error) => {
|
||||||
|
console.warn("[Canvas Capability] Task heartbeat failed:", error);
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
try {
|
||||||
|
return await trackRuntimeWork(() => enqueueCanvasCapabilityExecution({
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
task = await markCanvasCapabilityTaskRunning(task.id);
|
const claimed = await markCanvasCapabilityTaskRunning(task.id);
|
||||||
|
if (!claimed) return storedTaskResult((await getCanvasCapabilityTask({ requestId: command.requestId, userId: params.userId, projectId: params.projectId })) || task, "headless");
|
||||||
|
task = claimed;
|
||||||
try {
|
try {
|
||||||
const projectRevision = context.project.updatedAt.toISOString();
|
const projectRevision = context.project.updatedAt.toISOString();
|
||||||
const mutationPlan = buildMutationOperationPlan({
|
const mutationPlan = buildMutationOperationPlan({
|
||||||
@ -299,5 +310,8 @@ export async function executeCanvasCapabilityRuntime(params: {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { and, eq, lt, sql } from "drizzle-orm";
|
import { and, eq, inArray, lt, sql } from "drizzle-orm";
|
||||||
|
|
||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canvasCapabilityTask } from "@/lib/db/schema";
|
import { canvasCapabilityTask } from "@/lib/db/schema";
|
||||||
|
|
||||||
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
const STALE_RUNNING_TASK_MS = 15 * 60 * 1000;
|
||||||
export const MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS = 3;
|
const INTERRUPTED_TASK_MESSAGE = "任务执行中断,结果尚未确认。请先查看画布和历史记录,或联系管理员核对原任务,避免重复处理。";
|
||||||
|
|
||||||
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
export type CanvasCapabilityTaskResult = Record<string, unknown>;
|
||||||
|
|
||||||
@ -25,7 +25,25 @@ export async function getCanvasCapabilityTask(params: {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return task || null;
|
if (!task) return null;
|
||||||
|
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
||||||
|
if (["pending", "running"].includes(task.status) && task.updatedAt < staleBefore) {
|
||||||
|
const [interrupted] = await db.update(canvasCapabilityTask).set({
|
||||||
|
status: "failed",
|
||||||
|
error: INTERRUPTED_TASK_MESSAGE,
|
||||||
|
completedAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(and(
|
||||||
|
eq(canvasCapabilityTask.id, task.id),
|
||||||
|
inArray(canvasCapabilityTask.status, ["pending", "running"]),
|
||||||
|
lt(canvasCapabilityTask.updatedAt, staleBefore),
|
||||||
|
)).returning();
|
||||||
|
if (interrupted) return interrupted;
|
||||||
|
const [latest] = await db.select().from(canvasCapabilityTask)
|
||||||
|
.where(eq(canvasCapabilityTask.id, task.id)).limit(1);
|
||||||
|
return latest || task;
|
||||||
|
}
|
||||||
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createOrRecoverCanvasCapabilityTask(params: {
|
export async function createOrRecoverCanvasCapabilityTask(params: {
|
||||||
@ -42,6 +60,7 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
capabilityId: params.command.capabilityId,
|
capabilityId: params.command.capabilityId,
|
||||||
source: params.command.source,
|
source: params.command.source,
|
||||||
request: params.command,
|
request: params.command,
|
||||||
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
.onConflictDoNothing({ target: canvasCapabilityTask.requestId });
|
||||||
|
|
||||||
@ -50,59 +69,19 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
|
|||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
};
|
};
|
||||||
let task = await getCanvasCapabilityTask(taskLookup);
|
const task = await getCanvasCapabilityTask(taskLookup);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new Error("无法创建画布能力任务");
|
throw new Error("无法创建画布能力任务");
|
||||||
}
|
}
|
||||||
|
|
||||||
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
|
|
||||||
if (
|
|
||||||
task.status === "failed" &&
|
|
||||||
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "pending",
|
|
||||||
error: null,
|
|
||||||
completedAt: null,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id));
|
|
||||||
} else if (
|
|
||||||
task.status === "running" &&
|
|
||||||
task.updatedAt < staleBefore &&
|
|
||||||
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
|
|
||||||
) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "pending",
|
|
||||||
error: "恢复超时的执行中任务",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(canvasCapabilityTask.id, task.id),
|
|
||||||
lt(canvasCapabilityTask.updatedAt, staleBefore)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (task.status === "running" && task.updatedAt < staleBefore) {
|
|
||||||
await db
|
|
||||||
.update(canvasCapabilityTask)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
error: "画布能力任务超过最大重试次数",
|
|
||||||
completedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(canvasCapabilityTask.id, task.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
task = (await getCanvasCapabilityTask(taskLookup)) || task;
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function heartbeatCanvasCapabilityTask(taskId: string) {
|
||||||
|
await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
|
||||||
|
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
|
||||||
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [task] = await db
|
const [task] = await db
|
||||||
@ -115,11 +94,10 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
|
|||||||
error: null,
|
error: null,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.where(eq(canvasCapabilityTask.id, taskId))
|
.where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!task) throw new Error("画布能力任务不存在");
|
return task || null;
|
||||||
return task;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markCanvasCapabilityTaskSucceeded(
|
export async function markCanvasCapabilityTaskSucceeded(
|
||||||
|
|||||||
30
lib/canvas/download-error.ts
Normal file
30
lib/canvas/download-error.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export type DownloadFailure = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
recommendation: string;
|
||||||
|
status: number;
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDownloadFailure(error: unknown): DownloadFailure {
|
||||||
|
const text = error && typeof error === "object" && "message" in error
|
||||||
|
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
||||||
|
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
||||||
|
({ code, message, recommendation, status, retryable });
|
||||||
|
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
||||||
|
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
||||||
|
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
||||||
|
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
||||||
|
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
||||||
|
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
||||||
|
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
||||||
|
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
||||||
|
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
||||||
|
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
||||||
|
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
||||||
|
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
||||||
|
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export type DownloadPreparationInput = {
|
export type DownloadPreparationInput = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -9,17 +13,18 @@ export type DownloadPreparationInput = {
|
|||||||
cookie: string;
|
cookie: string;
|
||||||
};
|
};
|
||||||
type Result =
|
type Result =
|
||||||
| { status: "preparing" }
|
| { status: "preparing"; requestId: string }
|
||||||
| { status: "ready"; asset: ImageBytes }
|
| { status: "ready"; asset: ImageBytes }
|
||||||
| { status: "failed"; error: string };
|
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
||||||
type Job = {
|
type Job = {
|
||||||
|
requestId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
input: DownloadPreparationInput;
|
input: DownloadPreparationInput;
|
||||||
status: "queued" | "running" | "ready" | "failed";
|
status: "queued" | "running" | "ready" | "failed";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
asset?: ImageBytes;
|
asset?: ImageBytes;
|
||||||
|
failure?: DownloadFailure;
|
||||||
};
|
};
|
||||||
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
|
||||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
// A download is independent of node autosave and of the archive worker's batch.
|
// A download is independent of node autosave and of the archive worker's batch.
|
||||||
@ -69,7 +74,7 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
reject(new Error("Download preparation deadline"));
|
reject(new Error("Download preparation deadline"));
|
||||||
}, deps.timeoutMs || 90_000);
|
}, deps.timeoutMs || 150_000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||||
@ -81,8 +86,10 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
const stored = await deps.read(key).catch(() => null);
|
const stored = await deps.read(key).catch(() => null);
|
||||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
} catch {
|
} catch (error) {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
|
job.failure = getDownloadFailure(error);
|
||||||
|
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
// Do not retain authentication data after a preparation finishes.
|
// Do not retain authentication data after a preparation finishes.
|
||||||
@ -105,8 +112,11 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
prune();
|
prune();
|
||||||
const current = jobs.get(key);
|
const current = jobs.get(key);
|
||||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||||
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
if (current?.status === "failed") {
|
||||||
if (current && current.status !== "ready") return { status: "preparing" };
|
const failure = current.failure || getDownloadFailure(null);
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
||||||
|
}
|
||||||
|
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
||||||
if (jobs.size >= 64) {
|
if (jobs.size >= 64) {
|
||||||
for (const [oldKey, job] of jobs) {
|
for (const [oldKey, job] of jobs) {
|
||||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||||
@ -115,11 +125,14 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
}
|
}
|
||||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||||
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
const requestId = randomUUID();
|
||||||
|
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
||||||
}
|
}
|
||||||
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
const requestId = randomUUID();
|
||||||
|
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||||
deps.schedule(drain);
|
deps.schedule(drain);
|
||||||
return { status: "preparing" };
|
return { status: "preparing", requestId };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { generateCanvasImage, type CanvasGenerateImageRequest } from "@/lib/canv
|
|||||||
import { getPointsSystemConfig } from "@/lib/db/queries";
|
import { getPointsSystemConfig } from "@/lib/db/queries";
|
||||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||||
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
import { getImageToolCreditCost, type ImageToolCreditFeature } from "@/lib/points/config";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
interface GenerateImageRequest extends CanvasGenerateImageRequest {
|
||||||
async?: boolean;
|
async?: boolean;
|
||||||
@ -121,7 +121,7 @@ export async function executeCanvasImageResponse(body: GenerateImageRequest, use
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Canvas Generate Image] Error:", error);
|
console.error("[Canvas Generate Image] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: getUserFacingImageGenerationErrorMessage(error),
|
{ success: false, error: getUserFacingImageGenerationErrorMessage(error), failure: getRequestFailureDetails(error),
|
||||||
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
...(error instanceof ProviderTaskUncertainError ? { code: "PROVIDER_TASK_UNCERTAIN" } : {}) },
|
||||||
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
{ status: error instanceof ProviderTaskUncertainError ? 503 : 500 },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
} from "@/lib/agents/detail-replacement-batch";
|
} from "@/lib/agents/detail-replacement-batch";
|
||||||
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
import { executeDetailReplacementBatch } from "@/lib/agents/detail-replacement-batch-runner";
|
||||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
type CanvasImageTaskStatus =
|
type CanvasImageTaskStatus =
|
||||||
| "waiting"
|
| "waiting"
|
||||||
@ -328,7 +329,7 @@ async function markStaleDatabaseRunningTasksFailed() {
|
|||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
.set({
|
.set({
|
||||||
status: "failed",
|
status: "failed",
|
||||||
error: "图片生成任务已超时,请重新发起生成",
|
error: "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。",
|
||||||
completedAt,
|
completedAt,
|
||||||
updatedAt: completedAt,
|
updatedAt: completedAt,
|
||||||
})
|
})
|
||||||
@ -383,7 +384,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
const runningTimeoutMs = getCanvasImageRunningTimeoutMs(request);
|
||||||
const execution: Promise<CanvasImageTaskResult> = isStyleProTaskRequest(request)
|
const execution: Promise<CanvasImageTaskResult> = trackRuntimeWork<CanvasImageTaskResult>(() => isStyleProTaskRequest(request)
|
||||||
? executeStyleProTask({
|
? executeStyleProTask({
|
||||||
request,
|
request,
|
||||||
userId: String(task.userId || entry.userId),
|
userId: String(task.userId || entry.userId),
|
||||||
@ -412,7 +413,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
ownerKey,
|
ownerKey,
|
||||||
request: imageRequest,
|
request: imageRequest,
|
||||||
});
|
});
|
||||||
})();
|
})());
|
||||||
const heartbeat = isDetailReplacementBatchRequest(request)
|
const heartbeat = isDetailReplacementBatchRequest(request)
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
void db.update(canvasImageTask).set({ updatedAt: new Date() })
|
||||||
@ -426,7 +427,7 @@ async function runSingleTask(entry: PendingTaskEntry) {
|
|||||||
try {
|
try {
|
||||||
result = await withCanvasImageTaskExecutionTimeout(
|
result = await withCanvasImageTaskExecutionTimeout(
|
||||||
execution,
|
execution,
|
||||||
runningTimeoutMs,
|
Math.max(runningTimeoutMs, LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS),
|
||||||
entry.taskId
|
entry.taskId
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@ -751,7 +752,7 @@ export async function getCanvasImageTaskForUser(params: {
|
|||||||
|
|
||||||
if (shouldMarkStale) {
|
if (shouldMarkStale) {
|
||||||
const staleBefore = new Date(now - runningTimeoutMs);
|
const staleBefore = new Date(now - runningTimeoutMs);
|
||||||
const timeoutMessage = "图片生成任务已超时,请重新发起生成";
|
const timeoutMessage = "图片任务执行中断或等待超时,结果尚未确认。请先查看原任务和历史记录,联系管理员核对,避免重复生成。";
|
||||||
const completedAt = new Date();
|
const completedAt = new Date();
|
||||||
const [timedOutTask] = await db
|
const [timedOutTask] = await db
|
||||||
.update(canvasImageTask)
|
.update(canvasImageTask)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { getDownloadFailure } from "./download-error";
|
||||||
|
|
||||||
type DownloadFileItem = {
|
type DownloadFileItem = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -31,6 +32,7 @@ function sleep(ms: number) {
|
|||||||
|
|
||||||
function normalizeDownloadError(error: unknown): string {
|
function normalizeDownloadError(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message || "download failed";
|
if (error instanceof Error) return error.message || "download failed";
|
||||||
|
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
return "download failed";
|
return "download failed";
|
||||||
}
|
}
|
||||||
@ -330,6 +332,7 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchImageBytes(candidateUrl, {
|
const data = await fetchImageBytes(candidateUrl, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
signal: params.signal,
|
||||||
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
||||||
// Authenticated local assets must never share a cache across users.
|
// Authenticated local assets must never share a cache across users.
|
||||||
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
||||||
@ -344,11 +347,13 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.signal?.throwIfAborted();
|
params.signal?.throwIfAborted();
|
||||||
lastError = normalizeDownloadError(error);
|
const message = normalizeDownloadError(error);
|
||||||
|
if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Archiving may have completed during the failed fetch. Resolve aliases
|
// Archiving may have completed during the failed fetch. Resolve aliases
|
||||||
// again before retrying, rather than spending every attempt on the old URL.
|
// again before retrying, rather than spending every attempt on the old URL.
|
||||||
|
if (!getDownloadFailure(lastError).retryable) break;
|
||||||
if (attempt < retryCount) await sleep(350 * attempt);
|
if (attempt < retryCount) await sleep(350 * attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
|
||||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||||
|
|
||||||
@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@ -40,11 +45,14 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.retryAfterSeconds = options.retryAfterSeconds;
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
||||||
this.status = options.status;
|
this.status = options.status;
|
||||||
|
this.failure = options.failure;
|
||||||
|
this.requestId = options.requestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
||||||
if (error instanceof CanvasImageDownloadError) {
|
if (error instanceof CanvasImageDownloadError) {
|
||||||
|
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
|
||||||
if (error.code === "rate_limited") {
|
if (error.code === "rate_limited") {
|
||||||
return error.retryAfterSeconds
|
return error.retryAfterSeconds
|
||||||
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
||||||
@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
|||||||
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
}
|
}
|
||||||
if (error.code === "preparation_timeout") {
|
if (error.code === "preparation_timeout") {
|
||||||
return "原图仍在后台准备中,请稍后再次点击下载";
|
return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : "图片下载失败,请稍后重试";
|
return formatDownloadFailure(getDownloadFailure(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadCanvasImageParams = {
|
type DownloadCanvasImageParams = {
|
||||||
@ -71,6 +79,8 @@ type DownloadCanvasImageParams = {
|
|||||||
type DownloadErrorPayload = {
|
type DownloadErrorPayload = {
|
||||||
error?: string;
|
error?: string;
|
||||||
retryAfter?: number;
|
retryAfter?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_RATE_LIMIT_RETRIES = 1;
|
const MAX_RATE_LIMIT_RETRIES = 1;
|
||||||
@ -257,7 +267,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
let preparationPolls = 0;
|
let preparationPolls = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
response = await fetch("/api/canvas/download-image", {
|
try {
|
||||||
|
response = await fetch("/api/canvas/download-image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -268,7 +279,12 @@ export async function downloadCanvasImageAsPng({
|
|||||||
preparation: "async",
|
preparation: "async",
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
rateLimitRetries = 0;
|
rateLimitRetries = 0;
|
||||||
@ -278,7 +294,7 @@ export async function downloadCanvasImageAsPng({
|
|||||||
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
||||||
}
|
}
|
||||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 });
|
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||||
}
|
}
|
||||||
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||||
@ -299,6 +315,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
code: "rate_limited",
|
code: "rate_limited",
|
||||||
retryAfterSeconds,
|
retryAfterSeconds,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -321,6 +339,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
{
|
{
|
||||||
code: status === 502 ? "source_unavailable" : "request_failed",
|
code: status === 502 ? "source_unavailable" : "request_failed",
|
||||||
status,
|
status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,40 @@
|
|||||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||||
import { useCanvasStore } from "@/store/canvas";
|
import { useCanvasStore } from "@/store/canvas";
|
||||||
|
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||||
|
|
||||||
|
async function requestCapabilityResult(command: CanvasCapabilityClientCommand) {
|
||||||
|
const endpoint = `/api/canvas/${command.projectId}/capabilities`;
|
||||||
|
let payload: any = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
payload = await response.json().catch(() => null);
|
||||||
|
if (payload?.success === false || (!response.ok && ![502, 503, 504, 524].includes(response.status))) {
|
||||||
|
throw Object.assign(new Error(payload?.error || "图片处理请求未通过"), { confirmedRejection: true });
|
||||||
|
}
|
||||||
|
if (!response.ok) payload = null;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { confirmedRejection?: boolean })?.confirmedRejection) throw error;
|
||||||
|
}
|
||||||
|
const deadline = Date.now() + 20 * 60 * 1000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const task = payload?.task;
|
||||||
|
if (task?.status === "succeeded") return payload;
|
||||||
|
if (task?.status === "failed") throw new Error(task.error || "图片处理失败,请联系管理员核对任务结果");
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${endpoint}?requestId=${encodeURIComponent(command.requestId)}`, { cache: "no-store", signal: AbortSignal.timeout(12_000) });
|
||||||
|
payload = response.ok ? await response.json() : null;
|
||||||
|
} catch {
|
||||||
|
payload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(formatRequestFailure(getRequestFailureDetails({ providerOutcomeUnknown: true }), command.requestId));
|
||||||
|
}
|
||||||
|
|
||||||
/** Runs after the editor has saved and while its save queue is reserved. */
|
/** Runs after the editor has saved and while its save queue is reserved. */
|
||||||
export async function executeCanvasToolbarCapability(
|
export async function executeCanvasToolbarCapability(
|
||||||
@ -10,15 +44,7 @@ export async function executeCanvasToolbarCapability(
|
|||||||
const before = useCanvasStore.getState();
|
const before = useCanvasStore.getState();
|
||||||
if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片");
|
if (before.projectId !== projectId) throw new Error("画布已切换,请重新选择图片");
|
||||||
const base = JSON.stringify({ canvasData: before.getCanvasState() });
|
const base = JSON.stringify({ canvasData: before.getCanvasState() });
|
||||||
const response = await fetch(`/api/canvas/${projectId}/capabilities`, {
|
await requestCapabilityResult(command);
|
||||||
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 || "图片处理失败");
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectResponse = await fetch(`/api/canvas/${projectId}`);
|
const projectResponse = await fetch(`/api/canvas/${projectId}`);
|
||||||
const result = await projectResponse.json();
|
const result = await projectResponse.json();
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { preparePendingImageDownloads } from "@/lib/storage/image-download-preparation";
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
import { archiveTemporaryImage } from "@/lib/storage/image-archive";
|
||||||
import {
|
import {
|
||||||
@ -26,10 +27,12 @@ function getBatchSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runImageArchiveJob() {
|
export async function runImageArchiveJob() {
|
||||||
|
return trackRuntimeWork(async () => {
|
||||||
const batchSize = getBatchSize();
|
const batchSize = getBatchSize();
|
||||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||||
|
|
||||||
await recoverStaleImageArchiveTasks();
|
await recoverStaleImageArchiveTasks();
|
||||||
|
scheduleImageDownloadPreparation();
|
||||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||||
let storeSucceeded = 0;
|
let storeSucceeded = 0;
|
||||||
let storeFailed = 0;
|
let storeFailed = 0;
|
||||||
@ -43,6 +46,7 @@ export async function runImageArchiveJob() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
targetUrl: archived.url,
|
targetUrl: archived.url,
|
||||||
});
|
});
|
||||||
|
scheduleImageDownloadPreparation(task.id);
|
||||||
storeSucceeded += 1;
|
storeSucceeded += 1;
|
||||||
console.log("[ImageArchiveJob] stored", {
|
console.log("[ImageArchiveJob] stored", {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@ -106,7 +110,6 @@ export async function runImageArchiveJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTasks = await preparePendingImageDownloads();
|
|
||||||
const stats = await getImageArchiveTaskStats();
|
const stats = await getImageArchiveTaskStats();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -122,7 +125,8 @@ export async function runImageArchiveJob() {
|
|||||||
failed: replaceFailed,
|
failed: replaceFailed,
|
||||||
},
|
},
|
||||||
stats,
|
stats,
|
||||||
downloadTasks,
|
downloadTasks: 0,
|
||||||
pendingDownloads: await hasPendingImageDownloads(),
|
pendingDownloads: await hasPendingImageDownloads(),
|
||||||
};
|
};
|
||||||
|
}, { durable: true });
|
||||||
}
|
}
|
||||||
|
|||||||
24
lib/observability/active-work.ts
Normal file
24
lib/observability/active-work.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { unlinkSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number; __aiChatbotDurableWork?: number };
|
||||||
|
const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`);
|
||||||
|
|
||||||
|
function update(delta: number, durable: boolean) {
|
||||||
|
state.__aiChatbotActiveWork = (state.__aiChatbotActiveWork || 0) + delta;
|
||||||
|
if (durable) state.__aiChatbotDurableWork = (state.__aiChatbotDurableWork || 0) + delta;
|
||||||
|
try {
|
||||||
|
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork, durable: state.__aiChatbotDurableWork || 0 }), { mode: 0o600 });
|
||||||
|
} catch (error) {
|
||||||
|
try { unlinkSync(path); } catch {}
|
||||||
|
console.warn("[Active Work] Failed to record running work:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Includes generation that continues after the client connection closes. */
|
||||||
|
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>, options: { durable?: boolean } = {}): Promise<T> {
|
||||||
|
update(1, options.durable === true);
|
||||||
|
try { return await execute(); } finally { update(-1, options.durable === true); }
|
||||||
|
}
|
||||||
@ -175,6 +175,20 @@ function getRequestErrorText(error: unknown) {
|
|||||||
return candidates.join(" ").slice(0, 4000);
|
return candidates.join(" ").slice(0, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGatewayErrorPageMessage(content: string): string | null {
|
||||||
|
if (!/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(content)) return null;
|
||||||
|
if (!/\bcf-error-details\b/i.test(content)) return null;
|
||||||
|
if (/\b524\b/.test(content)) {
|
||||||
|
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
|
||||||
|
}
|
||||||
|
return "模型服务暂时不可用,请稍后再试或联系管理员。";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGatewayResponseTimeout(error: unknown): boolean {
|
||||||
|
return getRequestErrorStatus(error) === 524 ||
|
||||||
|
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
|
||||||
|
}
|
||||||
|
|
||||||
export function isProviderBalanceError(error: unknown): boolean {
|
export function isProviderBalanceError(error: unknown): boolean {
|
||||||
const message = getRequestErrorText(error);
|
const message = getRequestErrorText(error);
|
||||||
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
|
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
@ -199,17 +213,80 @@ export function isRateLimitError(error: unknown): boolean {
|
|||||||
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
|
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RequestFailureDetails = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
recommendation: string;
|
||||||
|
outcome: "failed" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRequestFailureDetails(
|
||||||
|
error: unknown,
|
||||||
|
fallbackMessage = "请求未能完成",
|
||||||
|
): RequestFailureDetails {
|
||||||
|
const text = getRequestErrorText(error);
|
||||||
|
const status = getRequestErrorStatus(error);
|
||||||
|
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
||||||
|
({ code, message, recommendation, outcome });
|
||||||
|
if (isGatewayResponseTimeout(error)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
|
||||||
|
}
|
||||||
|
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (isProviderBalanceError(error)) {
|
||||||
|
return detail("PROVIDER_BALANCE_EXHAUSTED", "图片服务商线路余额或额度不足,与您的积分余额无关。", "请切换可用模型或联系管理员处理线路额度。");
|
||||||
|
}
|
||||||
|
if (isRateLimitError(error)) {
|
||||||
|
return detail("RATE_LIMITED", "当前请求过于频繁。", "请稍后重试,避免连续点击;模型线路限流时可切换其他可用模型。");
|
||||||
|
}
|
||||||
|
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
||||||
|
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
||||||
|
}
|
||||||
|
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||||||
|
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
|
||||||
|
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
|
||||||
|
}
|
||||||
|
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
||||||
|
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
||||||
|
}
|
||||||
|
if (/版本冲突|画布.*(?:更新|过期)|CANVAS_REVISION_CONFLICT|stale.canvas/i.test(text)) {
|
||||||
|
return detail("CANVAS_REVISION_CONFLICT", "画布已更新,本次保存与当前版本冲突。", "请先重新同步画布,再继续操作,避免覆盖较新的修改。");
|
||||||
|
}
|
||||||
|
if (/no_eligible_route|service_unavailable|service_rejected|channel.*unavailable|暂无可用服务|未配置.*模型|没有可用.*(?:模型|通道)|通道.*(?:不可用|未通过)|鉴权/i.test(text)) {
|
||||||
|
return detail("PROVIDER_UNAVAILABLE", "当前模型服务或通道暂不可用。", "请切换其他可用模型;若多个模型都失败,请联系管理员检查服务配置。");
|
||||||
|
}
|
||||||
|
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
|
||||||
|
}
|
||||||
|
if (/(?:抠图|去水印|图片增强|图片|扩图)处理失败/.test(text)) {
|
||||||
|
return detail("PROCESSING_FAILED", "图片处理服务执行失败,未返回具体原因。", "请联系管理员并提供请求编号,先核对原任务结果,避免重复处理。", "unknown");
|
||||||
|
}
|
||||||
|
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
|
||||||
|
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||||||
|
}
|
||||||
|
if (/timeout|timed out|超时|fetch failed|ECONNRESET|ECONNREFUSED|failed to fetch|networkerror|load failed|network request failed/i.test(text) || [502, 504].includes(status || 0)) {
|
||||||
|
return detail("RESULT_UNCONFIRMED", "请求连接中断或等待超时,任务结果尚未确认。", "请先查看原任务或历史记录,避免重复提交;持续没有结果时联系管理员。", "unknown");
|
||||||
|
}
|
||||||
|
if (/generate(?: image)? failed|generation failed|upstream_server_error|service_error|excessive system load|未返回可用图片|任务FAILED/i.test(text) || (status !== null && status >= 500)) {
|
||||||
|
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||||||
|
}
|
||||||
|
const message = getRequestErrorMessage(error);
|
||||||
|
return detail("REQUEST_FAILED", message && !/^\s*</.test(message) ? message : fallbackMessage, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRequestFailure(details: RequestFailureDetails, requestId?: string) {
|
||||||
|
const text = [details.message, details.recommendation].filter(Boolean).join("");
|
||||||
|
return requestId ? `${text}\n\n请求编号:${requestId}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserFacingImageGenerationErrorMessage(
|
export function getUserFacingImageGenerationErrorMessage(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
fallbackMessage = "图片生成失败,请稍后重试",
|
fallbackMessage = "图片生成失败,请稍后重试",
|
||||||
) {
|
) {
|
||||||
if (isProviderBalanceError(error)) {
|
return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
|
||||||
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
|
|
||||||
}
|
|
||||||
if (isRateLimitError(error)) {
|
|
||||||
return "图片服务请求过于频繁,请稍后重试或切换模型。";
|
|
||||||
}
|
|
||||||
return getRequestErrorMessage(error) || fallbackMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFriendlyRateLimitMessage(
|
export function getFriendlyRateLimitMessage(
|
||||||
@ -227,13 +304,12 @@ export function getUserFacingRequestErrorMessage(
|
|||||||
rateLimitMessage?: string;
|
rateLimitMessage?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (isProviderBalanceError(error)) {
|
|
||||||
return "模型服务商线路余额或额度不足,请切换模型或联系管理员。";
|
|
||||||
}
|
|
||||||
if (isRateLimitError(error)) {
|
if (isRateLimitError(error)) {
|
||||||
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
|
return getFriendlyRateLimitMessage(options?.rateLimitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failure = getRequestFailureDetails(error, fallbackMessage);
|
||||||
|
if (failure.code !== "REQUEST_FAILED") return formatRequestFailure(failure);
|
||||||
const message = getRequestErrorMessage(error);
|
const message = getRequestErrorMessage(error);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return fallbackMessage;
|
return fallbackMessage;
|
||||||
|
|||||||
@ -10,17 +10,21 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
|
|||||||
})));
|
})));
|
||||||
const task = await findImageArchiveDownloadTask(sources, true);
|
const task = await findImageArchiveDownloadTask(sources, true);
|
||||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||||
if (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null };
|
if (!task) return { task, direct: null, canPrepareDirect: false };
|
||||||
const config = await getDefaultStorageConfig();
|
const config = await getDefaultStorageConfig();
|
||||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null };
|
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
||||||
const url = new URL(asset.url);
|
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||||
|
const source = ready ? asset.url : task.targetUrl;
|
||||||
|
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
const url = new URL(source);
|
||||||
const base = new URL(config.publicUrl);
|
const base = new URL(config.publicUrl);
|
||||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||||
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
||||||
if (base.protocol !== "https:" || url.origin !== base.origin
|
if (base.protocol !== "https:" || url.origin !== base.origin
|
||||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null };
|
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
if (!ready) return { task, direct: null, canPrepareDirect: true };
|
||||||
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
||||||
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
||||||
url.searchParams.set("attname", filename);
|
url.searchParams.set("attname", filename);
|
||||||
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset.bytes || 0 } };
|
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset?.bytes || 0 }, canPrepareDirect: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
||||||
return data.buffer.length > 0;
|
return data.buffer.length > 0;
|
||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
||||||
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||||
if (url.startsWith("data:")) {
|
if (url.startsWith("data:")) {
|
||||||
@ -92,7 +92,9 @@ export async function fetchImageBytes(url: string, options: { headers?: HeadersI
|
|||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: options.headers, cache: "no-store", redirect: "follow",
|
headers: options.headers, cache: "no-store", redirect: "follow",
|
||||||
// The deadline covers the body as well as the response headers.
|
// The deadline covers the body as well as the response headers.
|
||||||
signal: AbortSignal.timeout(options.timeoutMs || 60_000),
|
signal: options.signal
|
||||||
|
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs || 60_000)])
|
||||||
|
: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|||||||
@ -2,14 +2,21 @@ import "server-only";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
||||||
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks } from "./image-archive-db";
|
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
||||||
import { uploadFile } from "./service";
|
import { uploadFile } from "./service";
|
||||||
|
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export async function prepareImageArchiveDownload(id: string) {
|
export async function prepareImageArchiveDownload(id: string) {
|
||||||
const task = await claimImageDownloadTask(id);
|
const task = await claimImageDownloadTask(id);
|
||||||
if (!task?.targetUrl) return;
|
if (!task?.targetUrl) return;
|
||||||
|
let phase: "read" | "convert" | "store" = "read";
|
||||||
try {
|
try {
|
||||||
const source = await fetchImageBytes(task.targetUrl);
|
const source = await fetchImageBytes(task.targetUrl);
|
||||||
|
phase = "convert";
|
||||||
const asset = await normalizeCanvasDownloadAsset({
|
const asset = await normalizeCanvasDownloadAsset({
|
||||||
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
||||||
contentType: source.contentType, includeSequencePrefix: false,
|
contentType: source.contentType, includeSequencePrefix: false,
|
||||||
@ -17,15 +24,22 @@ export async function prepareImageArchiveDownload(id: string) {
|
|||||||
if (!asset.ok) throw new Error(asset.error);
|
if (!asset.ok) throw new Error(asset.error);
|
||||||
// An already compatible PNG shares the original object; other formats get
|
// An already compatible PNG shares the original object; other formats get
|
||||||
// one prepared PNG, without changing the original dimensions or transparency.
|
// one prepared PNG, without changing the original dimensions or transparency.
|
||||||
|
phase = "store";
|
||||||
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
||||||
? task.targetUrl
|
? task.targetUrl
|
||||||
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
||||||
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
||||||
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
||||||
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
||||||
} catch {
|
} catch (error) {
|
||||||
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
||||||
await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() });
|
const failure = phase === "store"
|
||||||
|
? getDownloadFailure("STORAGE_WRITE_FAILED")
|
||||||
|
: phase === "convert" && getDownloadFailure(error).code === "DOWNLOAD_FAILED"
|
||||||
|
? getDownloadFailure("图片转换失败") : getDownloadFailure(error);
|
||||||
|
logger.warn("archive_download_preparation_failed", error, { requestId: id, code: failure.code });
|
||||||
|
await saveImageDownloadAsset(id, { status: !failure.retryable || attempts >= 12 ? "abandoned" : "failed", attempts,
|
||||||
|
failure, retryAt: new Date(Date.now() + 60_000).toISOString() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function preparePendingImageDownloads() {
|
export async function preparePendingImageDownloads() {
|
||||||
@ -36,3 +50,45 @@ export async function preparePendingImageDownloads() {
|
|||||||
}));
|
}));
|
||||||
return tasks.length;
|
return tasks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = globalThis as typeof globalThis & {
|
||||||
|
__imageDownloadWorker?: { running: boolean; scanPending: boolean; priority: Set<string>; retry?: ReturnType<typeof setTimeout> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Download preparation runs independently of the archive URL replacement batch. */
|
||||||
|
export function scheduleImageDownloadPreparation(id?: string) {
|
||||||
|
const worker = state.__imageDownloadWorker ??= { running: false, scanPending: false, priority: new Set() };
|
||||||
|
if (id) worker.priority.add(id);
|
||||||
|
else worker.scanPending = true;
|
||||||
|
if (worker.running) return;
|
||||||
|
if (worker.retry) { clearTimeout(worker.retry); worker.retry = undefined; }
|
||||||
|
worker.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void trackRuntimeWork(async () => {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const priority = [...worker.priority].slice(0, 2);
|
||||||
|
for (const taskId of priority) worker.priority.delete(taskId);
|
||||||
|
if (priority.length) {
|
||||||
|
await Promise.all(priority.map(prepareImageArchiveDownload));
|
||||||
|
} else if (!worker.scanPending || await preparePendingImageDownloads() === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("download_worker_failed", error);
|
||||||
|
} finally {
|
||||||
|
worker.running = false;
|
||||||
|
// Eligible retry times live in the database, including interrupted leases.
|
||||||
|
if (worker.priority.size) {
|
||||||
|
scheduleImageDownloadPreparation([...worker.priority][0]);
|
||||||
|
} else if (worker.scanPending && await hasPendingImageDownloads().catch(() => true)) {
|
||||||
|
worker.retry = setTimeout(() => scheduleImageDownloadPreparation(), 60_000);
|
||||||
|
worker.retry.unref?.();
|
||||||
|
} else {
|
||||||
|
worker.scanPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,14 @@ import { defineConfig } from "@playwright/test";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests",
|
testDir: "./tests",
|
||||||
testMatch: [
|
testMatch: [
|
||||||
|
/routes\/canvas-download-speed\.test\.ts/,
|
||||||
|
/routes\/canvas-download-recovery\.test\.ts/,
|
||||||
|
/routes\/canvas-download-rate-limit\.test\.ts/,
|
||||||
|
/routes\/canvas-zip-download\.test\.ts/,
|
||||||
|
/routes\/image-archive-flow\.test\.ts/,
|
||||||
|
/routes\/runtime-active-work\.test\.ts/,
|
||||||
|
/routes\/canvas-capability-task-recovery\.test\.ts/,
|
||||||
|
/routes\/canvas-toolbar-capability\.test\.ts/,
|
||||||
/routes\/agent-project-recovery\.test\.ts/,
|
/routes\/agent-project-recovery\.test\.ts/,
|
||||||
/routes\/admin-media-loading\.test\.ts/,
|
/routes\/admin-media-loading\.test\.ts/,
|
||||||
/routes\/video-canvas-production\.test\.ts/,
|
/routes\/video-canvas-production\.test\.ts/,
|
||||||
@ -42,6 +50,7 @@ export default defineConfig({
|
|||||||
/routes\/replacement-product-identity\.test\.ts/,
|
/routes\/replacement-product-identity\.test\.ts/,
|
||||||
/routes\/detail-replacement-batch\.test\.ts/,
|
/routes\/detail-replacement-batch\.test\.ts/,
|
||||||
/routes\/alibaba-image-input\.test\.ts/,
|
/routes\/alibaba-image-input\.test\.ts/,
|
||||||
|
/routes\/alibaba-task-errors\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||||
|
|||||||
16
proxy.ts
16
proxy.ts
@ -43,6 +43,7 @@ const manualIpBlockCache = new Map<
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
function isHighFrequencyReadApi(pathname: string, method: string) {
|
function isHighFrequencyReadApi(pathname: string, method: string) {
|
||||||
|
if (method === "POST" && /^\/api\/canvas\/[^/]+\/image-archives$/.test(pathname)) return true;
|
||||||
if (method === "GET" && pathname === "/api/canvas/download-zip-task") return true;
|
if (method === "GET" && pathname === "/api/canvas/download-zip-task") return true;
|
||||||
if (method !== "GET") return false;
|
if (method !== "GET") return false;
|
||||||
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
|
if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true;
|
||||||
@ -668,7 +669,20 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
} else if (request.method === "POST" && /^\/api\/canvas\/[^/]+\/image-archives$/.test(pathname)) {
|
||||||
|
const token = await resolveToken();
|
||||||
|
const identifier = typeof token?.sub === "string" && token.sub
|
||||||
|
? `user:${token.sub}:image-archives`
|
||||||
|
: `ip:${clientIP}:image-archives`;
|
||||||
|
const result = checkRateLimitByIdentifier(identifier, rateLimiters.uiPolling);
|
||||||
|
if (!result.allowed) {
|
||||||
|
const retryAfter = Math.max(1, Math.ceil((result.resetTime - Date.now()) / 1000));
|
||||||
|
return withAccessLog(NextResponse.json(
|
||||||
|
{ error: "图片状态查询过于频繁,请稍后再查看", retryAfter },
|
||||||
|
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
||||||
|
), { outcome: "rate_limited", severity: "watch", reasons: ["图片归档状态查询限流"] });
|
||||||
|
}
|
||||||
|
} else if (isHighFrequencyReadApi(pathname, request.method)) {
|
||||||
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
|
||||||
if (!pollingRateLimit.allowed) {
|
if (!pollingRateLimit.allowed) {
|
||||||
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
|
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Neural Particle - Scatter Effect</title>
|
<title>Neural Particle - Scatter Effect</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { overflow: hidden; background-color: #000; }
|
body { overflow: hidden; background-color: #000; }
|
||||||
@ -14,7 +13,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="particle-container"></div>
|
<div id="particle-container"></div>
|
||||||
<script>
|
<script type="module">
|
||||||
|
import * as THREE from "/three/0.160.0/three.module.min.js";
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
effect: "scatter",
|
effect: "scatter",
|
||||||
effectMode: 1,
|
effectMode: 1,
|
||||||
|
|||||||
21
public/three/0.160.0/LICENSE
Normal file
21
public/three/0.160.0/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License
|
||||||
|
|
||||||
|
Copyright © 2010-2023 three.js authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
6
public/three/0.160.0/three.module.min.js
vendored
Normal file
6
public/three/0.160.0/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
59
tests/routes/alibaba-task-errors.test.ts
Normal file
59
tests/routes/alibaba-task-errors.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||||
|
const methods: string[] = [];
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
methods.push(init.method || "GET");
|
||||||
|
return Response.json(methods.length === 1
|
||||||
|
? { output: { task_id: "original-task", task_status: "PENDING" } }
|
||||||
|
: { output: { task_id: "original-task", task_status: "FAILED", code: "InvalidParameter", message: "Failed to download image URL after retries." } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example/api/v1/services/aigc/multimodal-generation/generation" });
|
||||||
|
let error: unknown;
|
||||||
|
try { await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }], imageParams: { aspectRatio: "1:1" } }); } catch (caught) { error = caught; }
|
||||||
|
expect(String(error)).toContain("Failed to download image URL after retries.");
|
||||||
|
expect(String(error)).toContain("InvalidParameter");
|
||||||
|
expect(String(error)).toContain("original-task");
|
||||||
|
expect(methods).toEqual(["POST", "GET"]);
|
||||||
|
const failure = loader("lib/request-error.ts").getRequestFailureDetails(error);
|
||||||
|
expect(failure).toMatchObject({ code: "PROVIDER_REFERENCE_FETCH_FAILED", message: "模型服务未能读取参考图。" });
|
||||||
|
expect(failure.recommendation).toContain("模型取图线路");
|
||||||
|
expect(failure.recommendation).not.toContain("稍后重试");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto resolution", async () => {
|
||||||
|
const png = await sharp({ create: { width: 32, height: 64, channels: 3, background: "blue" } }).png().toBuffer();
|
||||||
|
const jpeg = await sharp(png).jpeg().toBuffer();
|
||||||
|
const webp = await sharp(png).webp().toBuffer();
|
||||||
|
const originals = [png, jpeg, webp];
|
||||||
|
let submitted: any;
|
||||||
|
let posts = 0;
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (url: string, init: RequestInit = {}) => {
|
||||||
|
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
||||||
|
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
||||||
|
const index = Number(new URL(url).pathname.slice(1));
|
||||||
|
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||||
|
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||||
|
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
||||||
|
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
expect(submitted.parameters.size).toBe("1024*2048");
|
||||||
|
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
images.forEach((value: string, index: number) => {
|
||||||
|
expect(value.split(";", 1)[0]).toBe(["data:image/png", "data:image/jpeg", "data:image/webp"][index]);
|
||||||
|
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
tests/routes/canvas-capability-task-recovery.test.ts
Normal file
65
tests/routes/canvas-capability-task-recovery.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
function harness(status = "running") {
|
||||||
|
let now = 2_000_000;
|
||||||
|
const row: any = { id: "task-a", requestId: "request-a", userId: "user-a", projectId: "project-a", status, attempts: status === "pending" ? 0 : 1, updatedAt: new Date(now - 16 * 60_000), error: null };
|
||||||
|
const matches = (predicate: any): boolean => !predicate || (predicate[0] === "and" ? predicate.slice(1).every(matches)
|
||||||
|
: predicate[0] === "eq" ? row[predicate[1]] === predicate[2]
|
||||||
|
: predicate[0] === "lt" ? row[predicate[1]] < predicate[2]
|
||||||
|
: predicate[2].includes(row[predicate[1]]));
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: (p: any) => ({ limit: async () => matches(p) ? [{ ...row }] : [] }) }) }),
|
||||||
|
insert: () => ({ values: () => ({ onConflictDoNothing: async () => undefined }) }),
|
||||||
|
update: () => ({ set: (values: any) => ({ where: (p: any) => {
|
||||||
|
const apply = () => {
|
||||||
|
if (!matches(p)) return [];
|
||||||
|
for (const [key, value] of Object.entries(values)) row[key] = key === "attempts" ? row.attempts + 1 : value;
|
||||||
|
return [{ ...row }];
|
||||||
|
};
|
||||||
|
const result = apply();
|
||||||
|
return Object.assign(Promise.resolve(result), { returning: async () => result });
|
||||||
|
} }) }),
|
||||||
|
};
|
||||||
|
const columns = Object.fromEntries(["id", "requestId", "userId", "projectId", "status", "updatedAt", "attempts"].map(k => [k, k]));
|
||||||
|
const store = serverLoader({
|
||||||
|
"@/lib/db": { db }, "@/lib/db/schema": { canvasCapabilityTask: columns },
|
||||||
|
"drizzle-orm": { eq: (...v: any[]) => ["eq", ...v], lt: (...v: any[]) => ["lt", ...v], inArray: (...v: any[]) => ["in", ...v], and: (...v: any[]) => ["and", ...v], sql: () => "increment" },
|
||||||
|
Date: class extends Date { constructor(value?: any) { super(value ?? now); } static now() { return now; } },
|
||||||
|
})("lib/canvas/capabilities/task-store.ts");
|
||||||
|
const lookup = { requestId: "request-a", userId: "user-a", projectId: "project-a" };
|
||||||
|
return { row, store, lookup, advance: (ms: number) => { now += ms; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a stale task stops reporting running without authorizing another execution", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const task = await h.store.getCanvasCapabilityTask(h.lookup);
|
||||||
|
expect(task.status).toBe("failed");
|
||||||
|
expect(task.error).toContain("结果尚未确认");
|
||||||
|
const repeated = await h.store.createOrRecoverCanvasCapabilityTask({ ...h.lookup, command: { requestId: "request-a" } });
|
||||||
|
expect(repeated.status).toBe("failed");
|
||||||
|
expect(repeated.attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("heartbeats keep a legitimate long-running task active beyond fifteen minutes", async () => {
|
||||||
|
const h = harness();
|
||||||
|
await h.store.heartbeatCanvasCapabilityTask("task-a");
|
||||||
|
h.advance(14 * 60_000);
|
||||||
|
await h.store.heartbeatCanvasCapabilityTask("task-a");
|
||||||
|
h.advance(14 * 60_000);
|
||||||
|
expect((await h.store.getCanvasCapabilityTask(h.lookup)).status).toBe("running");
|
||||||
|
expect(h.row.attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only the pending task can be claimed and a duplicate claim cannot run again", async () => {
|
||||||
|
const h = harness("pending");
|
||||||
|
expect(await h.store.markCanvasCapabilityTaskRunning("task-a")).toMatchObject({ status: "running", attempts: 1 });
|
||||||
|
expect(await h.store.markCanvasCapabilityTaskRunning("task-a")).toBeNull();
|
||||||
|
expect(h.row.attempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user cannot inspect or change a stale task", async () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(await h.store.getCanvasCapabilityTask({ ...h.lookup, userId: "user-b" })).toBeNull();
|
||||||
|
expect(h.row.status).toBe("running");
|
||||||
|
});
|
||||||
159
tests/routes/canvas-download-recovery.test.ts
Normal file
159
tests/routes/canvas-download-recovery.test.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
const auth = { auth: async () => ({ user: { id: "owner" } }) };
|
||||||
|
const request = () => ({
|
||||||
|
url: "https://app.example/api/canvas/download-image", headers: new Headers(),
|
||||||
|
json: async () => ({ url: "https://provider.example/original.jpg", preferDirect: true, preparation: "async" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download failures distinguish permanent sources, transport, conversion and storage with actionable advice", () => {
|
||||||
|
const { getDownloadFailure, formatDownloadFailure } = serverLoader({})("lib/canvas/download-error.ts");
|
||||||
|
for (const [error, code, status] of [
|
||||||
|
["HTTP 404", "SOURCE_NOT_FOUND", 404], ["HTTP 403", "SOURCE_FORBIDDEN", 403],
|
||||||
|
["TimeoutError: slow body", "SOURCE_TIMEOUT", 504], ["fetch failed", "SOURCE_UNAVAILABLE", 503],
|
||||||
|
["source returned an error document", "INVALID_IMAGE", 422], ["unsupported image format", "IMAGE_CONVERSION_FAILED", 422],
|
||||||
|
["STORAGE_WRITE_FAILED", "DOWNLOAD_STORAGE_FAILED", 503], ["DOWNLOAD_BUSY", "DOWNLOAD_BUSY", 429],
|
||||||
|
]) {
|
||||||
|
const failure = getDownloadFailure(new Error(error as string));
|
||||||
|
expect(failure).toMatchObject({ code, status });
|
||||||
|
expect(failure.recommendation).toBeTruthy();
|
||||||
|
expect(formatDownloadFailure(failure, "feedback-id")).toContain("下载编号:feedback-id");
|
||||||
|
if (status === 404) expect(failure.recommendation).not.toContain("稍后重试");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed preparation polls retain the same cause and feedback ID without rerunning the load", async () => {
|
||||||
|
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||||
|
const runs: Array<() => Promise<void>> = [];
|
||||||
|
let loads = 0;
|
||||||
|
const queue = createDownloadPreparationQueue({
|
||||||
|
load: async () => { loads++; throw new Error("HTTP 404 https://source.example/private?token=private"); },
|
||||||
|
read: async () => null, write: async () => {}, schedule: (run: () => Promise<void>) => runs.push(run),
|
||||||
|
});
|
||||||
|
const input = { userId: "owner", urls: ["https://source.example/original"], requestOrigin: "https://app.example", cookie: "session=private" };
|
||||||
|
const accepted = await queue.request(input);
|
||||||
|
await runs[0]();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const failed = await queue.request(input);
|
||||||
|
expect(failed).toMatchObject({ status: "failed", requestId: accepted.requestId, failure: { code: "SOURCE_NOT_FOUND", status: 404 } });
|
||||||
|
expect(failed.error).not.toContain("token=private");
|
||||||
|
}
|
||||||
|
expect(loads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download queue saturation returns a retryable 429 instead of a source 502", async () => {
|
||||||
|
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||||
|
const queue = createDownloadPreparationQueue({ load: async () => { throw new Error("not started"); }, read: async () => null, write: async () => {}, schedule() {} });
|
||||||
|
for (let i = 0; i < 4; i++) await queue.request({ userId: "owner", urls: [`https://source.example/${i}`], requestOrigin: "https://app.example", cookie: "" });
|
||||||
|
expect(await queue.request({ userId: "owner", urls: ["https://source.example/fifth"], requestOrigin: "https://app.example", cookie: "" }))
|
||||||
|
.toMatchObject({ status: "failed", failure: { code: "DOWNLOAD_BUSY", status: 429, retryable: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cold archived downloads wait for CDN preparation and never buffer the file through the app", async () => {
|
||||||
|
const queued: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const route = serverLoader({
|
||||||
|
"@/app/(auth)/auth": auth,
|
||||||
|
"next/server": { NextResponse: Response, after() {} },
|
||||||
|
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({
|
||||||
|
task: { id: "archive-task", targetUrl: "https://cdn.example/original.jpg", metadata: { downloadAsset: { status: "pending" } } },
|
||||||
|
canPrepareDirect: true,
|
||||||
|
direct: ready ? { delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "original.png", bytes: 4000000 } : null,
|
||||||
|
}) },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation: (id: string) => queued.push(id) },
|
||||||
|
"@/lib/canvas/photoshop-safe-download": { sanitizeCanvasDownloadFileName: () => "original", fetchCanvasDownloadAsset: () => { throw new Error("Must use CDN delivery"); } },
|
||||||
|
})("app/api/canvas/download-image/route.ts");
|
||||||
|
const first = await route.POST(request());
|
||||||
|
expect(first.status).toBe(202);
|
||||||
|
expect(await first.json()).toMatchObject({ delivery: "preparing", requestId: "archive-task" });
|
||||||
|
ready = true;
|
||||||
|
const second = await route.POST(request());
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(await second.json()).toMatchObject({ delivery: "cdn", bytes: 4000000 });
|
||||||
|
expect(queued).toEqual(["archive-task"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an expired archived source returns its permanent cause and download task ID", async () => {
|
||||||
|
const errors = serverLoader({})("lib/canvas/download-error.ts");
|
||||||
|
const route = serverLoader({
|
||||||
|
"@/app/(auth)/auth": auth, "next/server": { NextResponse: Response, after() {} },
|
||||||
|
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({ task: {
|
||||||
|
id: "failed-archive", targetUrl: "https://cdn.example/missing.png",
|
||||||
|
metadata: { downloadAsset: { status: "abandoned", failure: errors.getDownloadFailure("HTTP 404") } },
|
||||||
|
}, canPrepareDirect: true, direct: null }) },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { throw new Error("Do not retry a permanent failure"); } },
|
||||||
|
})("app/api/canvas/download-image/route.ts");
|
||||||
|
const response = await route.POST(request());
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toMatchObject({ requestId: "failed-archive", failure: { code: "SOURCE_NOT_FOUND" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the download browser keeps the structured cause, advice and feedback ID", async () => {
|
||||||
|
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||||
|
const client = serverLoader({ fetch: async () => Response.json({ failure, requestId: "real-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||||
|
let caught: unknown;
|
||||||
|
try { await client.downloadCanvasImageAsPng({ url: "https://source.example/original" }); } catch (error) { caught = error; }
|
||||||
|
expect(client.getCanvasImageDownloadErrorMessage(caught)).toBe("图片源站拒绝访问原图。请联系管理员检查访问权限。\n下载编号:real-feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the preparation deadline cancels the source body as well as its headers", async () => {
|
||||||
|
const cache = serverLoader({ fetch: async (_url: string, options: { signal: AbortSignal }) => ({
|
||||||
|
ok: true, headers: new Headers({ "content-type": "image/png" }),
|
||||||
|
arrayBuffer: () => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true })),
|
||||||
|
}) })("lib/storage/image-byte-cache.ts");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetching = cache.fetchImageBytes(`https://source.example/${randomUUID()}`, { timeoutMs: 10000, signal: controller.signal });
|
||||||
|
setTimeout(() => controller.abort(new Error("Preparation canceled")), 20);
|
||||||
|
await expect(fetching).rejects.toThrow("Preparation canceled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requested downloads are prioritized with two workers before scanning background assets", async () => {
|
||||||
|
let active = 0; let maximum = 0; let scans = 0;
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const workRecords: Array<{ active: number; durable: number }> = [];
|
||||||
|
const claimed: string[] = []; const saved: string[] = [];
|
||||||
|
const bytes = Buffer.from("compatible PNG fixture");
|
||||||
|
const worker = serverLoader({
|
||||||
|
"node:fs": { writeFileSync: (_path: string, value: string) => workRecords.push(JSON.parse(value)), unlinkSync() {} },
|
||||||
|
"./image-archive-db": {
|
||||||
|
claimImageDownloadTask: async (id: string) => { claimed.push(id); return { id, targetUrl: `https://cdn.example/${id}.png`, metadata: {} }; },
|
||||||
|
saveImageDownloadAsset: async (id: string) => saved.push(id),
|
||||||
|
getImageDownloadTasks: async () => { scans++; return []; }, hasPendingImageDownloads: async () => false,
|
||||||
|
},
|
||||||
|
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
|
||||||
|
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
|
||||||
|
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
|
||||||
|
})("lib/storage/image-download-preparation.ts");
|
||||||
|
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");
|
||||||
|
await expect.poll(() => active).toBe(2);
|
||||||
|
expect(workRecords.at(-1)).toMatchObject({ active: 1, durable: 0 });
|
||||||
|
expect(scans).toBe(0);
|
||||||
|
releases.splice(0).forEach(release => release());
|
||||||
|
await expect.poll(() => claimed.length).toBe(3);
|
||||||
|
releases.splice(0).forEach(release => release());
|
||||||
|
await expect.poll(() => saved.length).toBe(3);
|
||||||
|
expect(maximum).toBe(2); expect(scans).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archive URL replacement cannot block the download worker wakeup", async () => {
|
||||||
|
let scheduled = false; let finish!: () => void; let replacing = false;
|
||||||
|
const gate = new Promise<void>(resolve => { finish = resolve; });
|
||||||
|
const job = serverLoader({
|
||||||
|
"@/lib/observability/active-work": { trackRuntimeWork: (execute: () => Promise<unknown>) => execute() },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { scheduled = true; } },
|
||||||
|
"@/lib/storage/image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, getImageArchiveTasksToStore: async () => [],
|
||||||
|
getImageArchiveTasksToReplace: async () => [{ id: "archive", sourceUrl: "https://source.example/a", targetUrl: "https://cdn.example/a" }],
|
||||||
|
markImageArchiveTaskReplacing: async () => true, markImageArchiveTaskCompleted: async () => {}, hasPendingImageDownloads: async () => false,
|
||||||
|
getImageArchiveTaskStats: async () => ({ pendingStore: 0, pendingReplace: 0 }),
|
||||||
|
},
|
||||||
|
"@/lib/storage/image-url-replacement": { replaceImageUrlInDatabase: async () => { replacing = true; await gate; return { changed: 1 }; } },
|
||||||
|
"@/lib/storage/image-archive": {},
|
||||||
|
})("lib/jobs/image-archive-job.ts");
|
||||||
|
const running = job.runImageArchiveJob();
|
||||||
|
await expect.poll(() => replacing).toBe(true);
|
||||||
|
expect(scheduled).toBe(true);
|
||||||
|
finish();
|
||||||
|
expect((await running).replace.succeeded).toBe(1);
|
||||||
|
});
|
||||||
@ -47,7 +47,7 @@ test("merges the result with edits made while processing and advances the save r
|
|||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true, task: { status: "succeeded" } });
|
||||||
}
|
}
|
||||||
return Response.json({ success: true, data: {
|
return Response.json({ success: true, data: {
|
||||||
updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] },
|
updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] },
|
||||||
@ -67,12 +67,31 @@ test("a provider failure is shown without reloading or changing the canvas", asy
|
|||||||
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a lost submission response polls the original task and never resubmits", async () => {
|
||||||
|
const requests: { method: string; url: string }[] = [];
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
let polls = 0;
|
||||||
|
globalThis.setTimeout = ((fn: () => void) => originalSetTimeout(fn, 0)) as typeof setTimeout;
|
||||||
|
globalThis.fetch = (async (url, init) => {
|
||||||
|
requests.push({ method: init?.method || "GET", url: String(url) });
|
||||||
|
if (init?.method === "POST") throw new TypeError("fetch failed");
|
||||||
|
if (String(url).includes("/capabilities?")) return Response.json({ success: true, task: { status: ++polls === 1 ? "running" : "succeeded" } });
|
||||||
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("source"), node("result")], edges: [] } } });
|
||||||
|
}) as typeof fetch;
|
||||||
|
const submitted = command();
|
||||||
|
try { await executeCanvasToolbarCapability(submitted); } finally { globalThis.setTimeout = originalSetTimeout; }
|
||||||
|
expect(requests.filter(r => r.method === "POST")).toHaveLength(1);
|
||||||
|
expect(requests.filter(r => r.url.includes("/capabilities?"))).toHaveLength(2);
|
||||||
|
expect(requests.filter(r => r.url.includes("/capabilities?")).every(r => r.url.includes(encodeURIComponent(submitted.requestId)))).toBe(true);
|
||||||
|
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("switching projects during processing does not load the old result into the new project", async () => {
|
test("switching projects during processing does not load the old result into the new project", async () => {
|
||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
useCanvasStore.getState().setProject("project-b");
|
useCanvasStore.getState().setProject("project-b");
|
||||||
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true, task: { status: "succeeded" } });
|
||||||
}
|
}
|
||||||
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } });
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
@ -104,7 +123,7 @@ test("a result merge conflict retains both the local edit and its original revis
|
|||||||
globalThis.fetch = (async (url) => {
|
globalThis.fetch = (async (url) => {
|
||||||
if (String(url).endsWith("/capabilities")) {
|
if (String(url).endsWith("/capabilities")) {
|
||||||
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
useCanvasStore.getState().moveNode("source", { x: 50, y: 80 });
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true, task: { status: "succeeded" } });
|
||||||
}
|
}
|
||||||
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: {
|
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: {
|
||||||
nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [],
|
nodes: [{ ...node("source"), position: { x: 200, y: 80 } }, node("result")], edges: [],
|
||||||
|
|||||||
@ -224,7 +224,7 @@ test("bucket failure retains the provider URL and retry task", async () => {
|
|||||||
let stored = 0;
|
let stored = 0;
|
||||||
let replaced = 0;
|
let replaced = 0;
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||||
"@/lib/storage/image-archive": {
|
"@/lib/storage/image-archive": {
|
||||||
archiveTemporaryImage: async () => {
|
archiveTemporaryImage: async () => {
|
||||||
throw new Error("Bucket unavailable");
|
throw new Error("Bucket unavailable");
|
||||||
@ -414,7 +414,7 @@ test("successful background archive replaces saved references immediately", asyn
|
|||||||
let completed = false;
|
let completed = false;
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||||
"@/lib/storage/image-archive": {
|
"@/lib/storage/image-archive": {
|
||||||
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
|
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,9 +2,34 @@ import { expect, test } from "@playwright/test";
|
|||||||
import {
|
import {
|
||||||
getUserFacingImageGenerationErrorMessage,
|
getUserFacingImageGenerationErrorMessage,
|
||||||
getUserFacingRequestErrorMessage,
|
getUserFacingRequestErrorMessage,
|
||||||
|
getRequestFailureDetails,
|
||||||
|
formatRequestFailure,
|
||||||
|
getGatewayErrorPageMessage,
|
||||||
|
isGatewayResponseTimeout,
|
||||||
isProviderBalanceError,
|
isProviderBalanceError,
|
||||||
isRateLimitError,
|
isRateLimitError,
|
||||||
} from "@/lib/request-error";
|
} from "@/lib/request-error";
|
||||||
|
|
||||||
|
test("known provider and platform failures explain their cause and next action", () => {
|
||||||
|
for (const [message, code, next] of [
|
||||||
|
["We are sorry, this prompt may violate our content policies", "CONTENT_POLICY_REJECTED", "调整描述"],
|
||||||
|
["The channel is temporarily unavailable", "PROVIDER_UNAVAILABLE", "切换其他"],
|
||||||
|
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||||
|
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||||
|
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
|
||||||
|
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||||
|
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||||
|
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
||||||
|
["任务执行中断,结果尚未确认", "RESULT_UNCONFIRMED", "避免重复生成"],
|
||||||
|
]) {
|
||||||
|
const failure = getRequestFailureDetails(new Error(message));
|
||||||
|
expect(failure.code).toBe(code);
|
||||||
|
expect(failure.recommendation).toContain(next);
|
||||||
|
expect(formatRequestFailure(failure, "request-123")).toContain("请求编号:request-123");
|
||||||
|
}
|
||||||
|
expect(getRequestFailureDetails({ providerOutcomeUnknown: true, message: "content policy" }).outcome).toBe("unknown");
|
||||||
|
expect(getRequestFailureDetails(new Error("图片上传到存储桶失败")).outcome).toBe("unknown");
|
||||||
|
});
|
||||||
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
|
||||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
@ -16,6 +41,180 @@ test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
|
|||||||
expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁");
|
expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gatewayHtml = '<!DOCTYPE html><html><head><title>vip98k.com | 524: A timeout occurred</title></head><body><div id="cf-error-details">Error code 524</div><script>window.gatewayDebug = true</script></body></html> (HTTP 524)';
|
||||||
|
|
||||||
|
test("gateway timeout pages become a concise unconfirmed-result message", () => {
|
||||||
|
for (const error of [new Error(gatewayHtml), { status: 524, message: "Gateway failed" }, { error: { message: "Request failed (HTTP 524)" } }]) {
|
||||||
|
expect(isGatewayResponseTimeout(error)).toBe(true);
|
||||||
|
for (const text of [getUserFacingImageGenerationErrorMessage(error), getUserFacingRequestErrorMessage(error)]) {
|
||||||
|
expect(text).toContain("HTTP 524");
|
||||||
|
expect(text).toContain("结果尚未确认");
|
||||||
|
expect(text).not.toMatch(/<html|<script|cf-error-details/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(getGatewayErrorPageMessage(gatewayHtml)).toContain("避免重复提交");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary HTML documents and fenced examples retain their content", () => {
|
||||||
|
for (const text of ['<!DOCTYPE html><html><title>My page</title><p>Hosted on Cloudflare</p></html>', `\`\`\`html\n${gatewayHtml}\n\`\`\``, "Cloudflare 文档示例", "普通图片错误"]) {
|
||||||
|
expect(getGatewayErrorPageMessage(text)).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTTP 524 does not start another model while the original result is unconfirmed", () => {
|
||||||
|
const { health } = healthHarness();
|
||||||
|
expect(health.shouldFailoverAiModelError(new Error(gatewayHtml))).toBe(false);
|
||||||
|
expect(health.shouldFailoverAiModelError({ status: 524, message: "Gateway timeout" })).toBe(false);
|
||||||
|
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
||||||
|
let submissions = 0;
|
||||||
|
const provider = serverLoader({
|
||||||
|
"@/lib/db/queries": { getSystemSetting: async () => null },
|
||||||
|
"@/lib/storage/image-archive": {},
|
||||||
|
"@/lib/storage/service": {},
|
||||||
|
fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
if (init?.method !== "POST") return Response.json({}, { status: 404 });
|
||||||
|
submissions++;
|
||||||
|
return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } });
|
||||||
|
},
|
||||||
|
})("lib/ai/providers/vip98k.ts");
|
||||||
|
const model = provider.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
|
||||||
|
await expect(model.doGenerate({ messages: [{ role: "user", content: "A desk" }] })).rejects.toThrow(/结果未确认.*HTTP 524/);
|
||||||
|
expect(submissions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function imageRecoveryHarness(
|
||||||
|
handler: (path: string, method: string, call: number) => Response | Promise<Response>,
|
||||||
|
maxPollSeconds = 3,
|
||||||
|
) {
|
||||||
|
let now = 1_000;
|
||||||
|
const calls: { path: string; method: string; headers: Headers }[] = [];
|
||||||
|
const archived: string[] = [];
|
||||||
|
const load = serverLoader({
|
||||||
|
"@/lib/db": { db: {} },
|
||||||
|
"@/lib/db/queries": { getSystemSetting: async () => null },
|
||||||
|
"@/lib/storage/image-archive": { createDeferredUploadedImage: async (url: string) => {
|
||||||
|
archived.push(url);
|
||||||
|
return { thumbnail: url, original: url };
|
||||||
|
} },
|
||||||
|
"@/lib/storage/service": {},
|
||||||
|
Date: class extends Date { static now() { return now; } },
|
||||||
|
setTimeout: (fn: () => void, ms: number) => { now += ms; return setTimeout(fn, 0); },
|
||||||
|
fetch: async (url: string, init: RequestInit) => {
|
||||||
|
const path = new URL(url).pathname;
|
||||||
|
const method = init?.method || "GET";
|
||||||
|
calls.push({ path, method, headers: new Headers(init?.headers) });
|
||||||
|
return handler(path, method, calls.length);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = load("lib/ai/providers/vip98k.ts");
|
||||||
|
return {
|
||||||
|
calls, load, archived,
|
||||||
|
generate: () => provider.createVip98k({ apiKey: "test-only-key", baseURL: "https://provider.test" })
|
||||||
|
.chat("gpt-image-2.5", { capabilities: { vip98k: { pollIntervalSeconds: 1, maxPollSeconds } } })
|
||||||
|
.doGenerate({ messages: [{ role: "user", content: "A desk" }] }),
|
||||||
|
run: () => provider.executeVip98kExternalTask({
|
||||||
|
apiKey: "test-only-key", baseURL: "https://provider.test", modelId: "gpt-image-2.5",
|
||||||
|
kind: "image", prompt: "A desk", imageUrls: [],
|
||||||
|
modelConfig: { capabilities: { vip98k: { pollIntervalSeconds: 1, maxPollSeconds } } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const recoveredImage = { created: 1, data: [{ url: "https://image.example/original.png" }] };
|
||||||
|
const originalCompleted = { request_id: "original-request", status: "succeeded", result_available: true };
|
||||||
|
|
||||||
|
test("an edge timeout waits for and delivers the original image without another POST", async () => {
|
||||||
|
let lookups = 0;
|
||||||
|
const h = imageRecoveryHarness((path, method) => {
|
||||||
|
if (method === "POST") return new Response(gatewayHtml, { status: 524 });
|
||||||
|
if (path.endsWith("by-idempotency-key")) {
|
||||||
|
return Response.json(++lookups === 1 ? { status: "processing" } : originalCompleted);
|
||||||
|
}
|
||||||
|
return Response.json(recoveredImage);
|
||||||
|
});
|
||||||
|
expect((await h.generate()).text).toContain(recoveredImage.data[0].url);
|
||||||
|
expect(h.archived).toEqual([recoveredImage.data[0].url]);
|
||||||
|
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
|
||||||
|
expect(lookups).toBe(2);
|
||||||
|
const originalKey = h.calls[0].headers.get("Idempotency-Key");
|
||||||
|
expect(originalKey).toBeTruthy();
|
||||||
|
for (const call of h.calls.filter(c => c.path.endsWith("by-idempotency-key"))) {
|
||||||
|
expect(call.headers.get("Idempotency-Key")).toBe(originalKey);
|
||||||
|
expect(call.headers.get("Authorization")).toBe("Bearer test-only-key");
|
||||||
|
}
|
||||||
|
expect(h.calls.at(-1)?.path).toBe("/api/external/requests/original-request/result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a lost POST response recovers by its original key without resubmitting", async () => {
|
||||||
|
const h = imageRecoveryHarness((path, method) => {
|
||||||
|
if (method === "POST") throw new TypeError("fetch failed");
|
||||||
|
return Response.json(path.endsWith("by-idempotency-key") ? originalCompleted : recoveredImage);
|
||||||
|
});
|
||||||
|
expect((await h.run()).outputUrls).toHaveLength(1);
|
||||||
|
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("temporary lookup and result-read failures retry only GET requests", async () => {
|
||||||
|
let lookups = 0;
|
||||||
|
let reads = 0;
|
||||||
|
const h = imageRecoveryHarness((path, method) => {
|
||||||
|
if (method === "POST") return new Response("Gateway timeout", { status: 504 });
|
||||||
|
if (path.endsWith("by-idempotency-key")) {
|
||||||
|
return ++lookups === 1 ? new Response("Unavailable", { status: 503 }) : Response.json(originalCompleted);
|
||||||
|
}
|
||||||
|
return ++reads === 1 ? Response.json({}, { status: 409 }) : Response.json(recoveredImage);
|
||||||
|
}, 5);
|
||||||
|
expect((await h.run()).outputUrls).toHaveLength(1);
|
||||||
|
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
|
||||||
|
expect(reads).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing, unconfirmed and unretained originals never create a replacement", async () => {
|
||||||
|
for (const state of [null, { status: "failed" }, { status: "unresolved" }, { ...originalCompleted, result_available: false }]) {
|
||||||
|
const h = imageRecoveryHarness((_path, method) => method === "POST"
|
||||||
|
? new Response(gatewayHtml, { status: 524 })
|
||||||
|
: Response.json(state || {}, { status: state ? 200 : 404 }));
|
||||||
|
await expect(h.run()).rejects.toThrow(/结果未确认.*HTTP 524/);
|
||||||
|
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a still-processing original stops at the polling deadline without another POST", async () => {
|
||||||
|
const h = imageRecoveryHarness((_path, method) => method === "POST"
|
||||||
|
? new Response(gatewayHtml, { status: 524 }) : Response.json({ status: "processing" }), 2);
|
||||||
|
await expect(h.run()).rejects.toThrow(/结果未确认.*HTTP 524/);
|
||||||
|
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET", "GET"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("HTTP 502 and 504 without a recoverable original do not repeat the POST", async () => {
|
||||||
|
for (const status of [502, 504]) {
|
||||||
|
const h = imageRecoveryHarness((_path, method) => method === "POST"
|
||||||
|
? new Response("Gateway timeout", { status }) : Response.json({}, { status: 404 }));
|
||||||
|
await expect(h.run()).rejects.toThrow(/结果未确认/);
|
||||||
|
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a completed original with no image stays unconfirmed and cannot fail over", async () => {
|
||||||
|
const h = imageRecoveryHarness((path, method) => {
|
||||||
|
if (method === "POST") return new Response(gatewayHtml, { status: 524 });
|
||||||
|
return Response.json(path.endsWith("by-idempotency-key") ? originalCompleted : { data: [] });
|
||||||
|
});
|
||||||
|
const error = await h.run().then(() => null, (e: Error) => e);
|
||||||
|
expect(error).toBeTruthy();
|
||||||
|
expect(h.load("lib/ai/runtime/model-health.ts").shouldFailoverAiModelError(error)).toBe(false);
|
||||||
|
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ordinary image success uses the original response directly", async () => {
|
||||||
|
const h = imageRecoveryHarness(() => Response.json(recoveredImage));
|
||||||
|
expect((await h.run()).outputUrls).toHaveLength(1);
|
||||||
|
expect(h.calls.map(c => c.method)).toEqual(["POST"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("an explicit provider balance error takes precedence over HTTP 429", () => {
|
test("an explicit provider balance error takes precedence over HTTP 429", () => {
|
||||||
const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 });
|
const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 });
|
||||||
expect(isProviderBalanceError(error)).toBe(true);
|
expect(isProviderBalanceError(error)).toBe(true);
|
||||||
@ -62,8 +261,10 @@ function healthHarness() {
|
|||||||
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
|
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
|
||||||
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
|
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
|
||||||
const events: any[] = [];
|
const events: any[] = [];
|
||||||
const query = (rows: () => any[]) => ({ from() { return this; }, where() { return this; },
|
const query = (rows: () => any[]) => Object.assign(Promise.resolve().then(rows), {
|
||||||
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), returning: async () => rows() });
|
from() { return this; }, where() { return this; },
|
||||||
|
limit: async () => rows(), returning: async () => rows(),
|
||||||
|
});
|
||||||
const db = {
|
const db = {
|
||||||
select: () => query(() => [model]),
|
select: () => query(() => [model]),
|
||||||
insert: () => ({ values: async (value: any) => { events.push(value); } }),
|
insert: () => ({ values: async (value: any) => { events.push(value); } }),
|
||||||
|
|||||||
30
tests/routes/runtime-active-work.test.ts
Normal file
30
tests/routes/runtime-active-work.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
test("release draining includes background work until its original promise settles", async () => {
|
||||||
|
const counts: number[] = [];
|
||||||
|
const work = serverLoader({
|
||||||
|
"node:fs": { writeFileSync: (_path: string, text: string) => counts.push(JSON.parse(text).active), unlinkSync() {} },
|
||||||
|
})("lib/observability/active-work.ts");
|
||||||
|
let finish!: () => void;
|
||||||
|
const original = new Promise<void>(resolve => { finish = resolve; });
|
||||||
|
const pending = work.trackRuntimeWork(() => original);
|
||||||
|
await work.trackRuntimeWork(async () => "another request");
|
||||||
|
expect(counts).toEqual([1, 2, 1]);
|
||||||
|
finish();
|
||||||
|
await pending;
|
||||||
|
expect(counts).toEqual([1, 2, 1, 0]);
|
||||||
|
await expect(work.trackRuntimeWork(async () => { throw new Error("expected failure"); })).rejects.toThrow("expected failure");
|
||||||
|
expect(counts.at(-1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("durable archive handoff is separate from unfinished original generation", async () => {
|
||||||
|
const records: Array<{ active: number; durable: number }> = [];
|
||||||
|
const work = serverLoader({ "node:fs": { writeFileSync: (_path: string, value: string) => records.push(JSON.parse(value)), unlinkSync() {} } })("lib/observability/active-work.ts");
|
||||||
|
let complete!: () => void;
|
||||||
|
const original = work.trackRuntimeWork(() => new Promise<void>(resolve => { complete = resolve; }));
|
||||||
|
await work.trackRuntimeWork(async () => "archive", { durable: true });
|
||||||
|
expect(records.map(x => [x.active, x.durable])).toEqual([[1, 0], [2, 1], [1, 0]]);
|
||||||
|
complete(); await original;
|
||||||
|
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user