Compare commits

..

20 Commits

Author SHA1 Message Date
13684505a6 Merge pull request '失效图片保持容器宽度和下载入口' (#34) from codex/image-preview-error-width-20261104 into main 2026-11-04 20:18:44 +08:00
65b235e747 保持失效图片预览容器宽度 2026-11-04 20:18:38 +08:00
d1ee6d53d5 Merge pull request '失效图片预览保留下载按钮' (#33) from codex/image-preview-download-placeholder-20261104 into main 2026-11-04 20:05:19 +08:00
a484942fb9 失效图片预览保留原图下载入口 2026-11-04 20:04:43 +08:00
084f538e77 Merge pull request '画册同一原图复用稳定下载文件名' (#32) from codex/gallery-download-cache-key-20261104 into main 2026-11-04 20:00:14 +08:00
5250aed7b7 画册原图下载复用稳定文件名 2026-11-04 20:00:07 +08:00
cbec13b285 Merge pull request '普通聊天和画册原图使用 CDN 下载并保留原格式' (#31) from codex/original-download-routing-20261104 into main 2026-11-04 19:43:15 +08:00
5fda42495a 修复普通聊天与画册原图下载中转 2026-11-04 19:42:37 +08:00
73eb276e7f Merge pull request '发布切换等待正在准备的原图下载' (#30) from codex/download-release-drain-20261104 into main 2026-11-04 18:06:34 +08:00
8c7ca174bc 发布切换等待正在准备的原图下载 2026-11-04 18:06:27 +08:00
8fd0dba59f Merge pull request '修复阿里云参考图拉取失败及异步错误原因丢失' (#29) from codex/alibaba-reference-error-20261104 into main 2026-11-04 17:55:27 +08:00
ce94241234 修复阿里云参考图传输及异步任务错误原因 2026-11-04 17:55:19 +08:00
198deca47b Merge pull request '修复图片下载排队、中转速度和错误反馈' (#28) from codex/image-download-reliability-20261104 into main 2026-11-04 17:36:17 +08:00
8908b0d39a 修复图片下载准备与 CDN 交付,细分失败原因和建议 2026-11-04 17:35:34 +08:00
34ba1919c4 Merge pull request '完善图片工具通用失败的核对建议' (#27) from codex/image-tool-failure-advice-20261104 into main 2026-11-04 14:58:11 +08:00
2fdc4299e0 完善通用图片处理失败的原因与核对建议 2026-11-04 14:57:45 +08:00
ac2369b657 Merge pull request '修复平台任务恢复、归档查询限流和失败原因返回' (#26) from codex/platform-failure-recovery-20261104 into main 2026-11-04 14:46:07 +08:00
6642c39198 修复画布任务重复执行并明确失败原因与处理建议 2026-11-04 14:43:28 +08:00
ac7928fe2d 恢复同步生图在网关超时后的原请求结果
同步生图超过边缘等待期限时,生成仍可能在源站继续执行。实际记录中,Cloudflare 在 125.006 秒断开,原请求在约 172 秒完成;应用已经显示 524,成功图片没有送达。原结果 GET 和原图片只读验证均返回 200。

同步图片生成/编辑收到 502、504、524 或提交响应丢失后,现在使用原 Key 和原 Idempotency-Key 查询既有请求。处理中继续查询,成功后 GET 同一请求的保留结果,进入原有图片归档与展示流程。查询复用已有轮询间隔和期限;缺失、失败、未确认、过期或未保留结果不会变成新的生成提交。普通成功响应保持直接返回,原生任务沿用现有协议。ProviderTaskUncertainError 也不触发自动模型切换。

验证:645 项辅助测试通过、1 项原有跳过,包含 524 后处理中到成功取回并进入展示流程、提交响应丢失、查询暂时失败、结果 GET 暂时失败、过期/缺失结果、轮询期限及一次 POST。TypeScript、改动文件 lint、日志脱敏、凭据、测试与制品检查通过。生产构建与上线验收另行记录。

已知问题:全量 lint 有 1004 项历史诊断。Cloudflare 本身的读取期限保持;本次通过取回已受理请求的原结果完成交付,不提高边缘期限。
2026-11-04 11:10:46 +08:00
b06fdbb38b 修复聊天消息加载占位的网关错误展示
画布聊天首次加载 Markdown 组件时,占位组件直接显示原始消息。历史记录包含 Cloudflare 错误页面时,会短暂显示整页 HTML。现在占位组件复用网关错误正文归一化,加载期间和完成后均显示可读提示。普通内容保持原有展示。

验证:TypeScript、改动文件 lint、凭据检查与差异检查通过。网关识别、524 不自动切换模型及供应商只提交一次的测试已包含在 PR #23 的 637 项通过测试中。本次仅修改加载占位组件,生产构建及慢速加载回放验收另行记录。

已知问题:仓库全量 lint 存在 1004 项历史诊断。
2026-11-04 10:43:22 +08:00
44 changed files with 1377 additions and 361 deletions

View File

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

View File

@ -8,6 +8,8 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import Image from "next/image"; import Image from "next/image";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { toast } from "sonner";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type FeaturedImage = { type FeaturedImage = {
id: string; id: string;
@ -74,18 +76,11 @@ export default function FeaturedPage() {
const handleDownload = async (url: string, prompt: string) => { const handleDownload = async (url: string, prompt: string) => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: "featured-image" });
const blob = await response.blob(); toast.success("原图已开始下载");
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = `featured-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -13,6 +13,7 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatBeijingTime } from "@/lib/utils"; import { formatBeijingTime } from "@/lib/utils";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
type GalleryImage = { type GalleryImage = {
id: string; id: string;
@ -59,7 +60,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
} }
} }
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`; return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
} }
export default function GalleryPage() { export default function GalleryPage() {
@ -119,25 +120,11 @@ export default function GalleryPage() {
const parsed = new URL(originalUrl, window.location.origin); const parsed = new URL(originalUrl, window.location.origin);
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl); const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
const response = await fetch( await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
buildImageProxyUrl(source, { delivery: "original" })
);
if (!response.ok) {
throw new Error(`download failed: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
toast.success("图片已开始下载"); toast.success("图片已开始下载");
} catch (error) { } catch (error) {
console.error("Download failed:", error); console.error("Download failed:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试"); toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

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

View File

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

View File

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

View File

@ -1,8 +1,11 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger";
export const runtime = "nodejs"; export const runtime = "nodejs";
const logger = createLogger("image.download");
const DEFAULT_QUALITY = 82; const DEFAULT_QUALITY = 82;
const MAX_WIDTH = 4096; const MAX_WIDTH = 4096;
@ -267,8 +270,17 @@ function redirectToDirectImage(target: URL): NextResponse {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const requestId = crypto.randomUUID();
const downloadFailure = (error: unknown) => {
const failure = getDownloadFailure(error);
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
};
const rawUrl = (searchParams.get("url") || "").trim(); const rawUrl = (searchParams.get("url") || "").trim();
if (isRetiredStorageUrl(rawUrl)) { if (isRetiredStorageUrl(rawUrl)) {
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 }); return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
} }
if (!rawUrl) { if (!rawUrl) {
@ -306,6 +318,18 @@ export async function GET(request: NextRequest) {
); );
} }
if (original && searchParams.get("download") === "resolve") {
try {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
} catch (error) {
return downloadFailure(error);
}
}
if (!forceProxy && !original) { if (!forceProxy && !original) {
const directImageTarget = resolveDirectImageTarget( const directImageTarget = resolveDirectImageTarget(
request, request,
@ -346,10 +370,11 @@ export async function GET(request: NextRequest) {
const fetchSignal = AbortSignal.any([ const fetchSignal = AbortSignal.any([
request.signal, request.signal,
AbortSignal.timeout(width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS), AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]); ]);
const upstreamFailure = (error: unknown) => { const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 }); if (request.signal.aborted) return new NextResponse(null, { status: 499 });
if (original) return downloadFailure(error);
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError"; const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
return NextResponse.json( return NextResponse.json(
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" }, { error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
@ -369,6 +394,7 @@ export async function GET(request: NextRequest) {
} }
if (!upstreamResponse.ok) { if (!upstreamResponse.ok) {
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
return NextResponse.json( return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` }, { error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 } { status: 502 }
@ -377,6 +403,7 @@ export async function GET(request: NextRequest) {
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream"; const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) { if (!contentType.toLowerCase().startsWith("image/")) {
if (original) return downloadFailure("Source is not an image");
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
} }

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery"; import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
@ -6793,31 +6794,11 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => { const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
toast.error("图片地址无效"); toast.error("图片地址无效");
return; return;
} }
const filename = buildDownloadFilename(raw);
try { try {
const response = await fetch(raw); await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
if (!response.ok) { toast.success("原图已开始下载");
throw new Error(`download failed: ${response.status}`); } catch (error) {
} toast.error(getCanvasImageDownloadErrorMessage(error));
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement("a");
link.href = raw;
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.message("已打开原图,可右键另存为");
} }
}, []); }, []);

View File

@ -5,7 +5,8 @@ import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react"; import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
const ImageAnnotator = dynamic( const ImageAnnotator = dynamic(
() => import("./image-annotator").then((module) => module.ImageAnnotator), () => import("./image-annotator").then((module) => module.ImageAnnotator),
@ -23,25 +24,6 @@ const AngleEditDialog = dynamic(
} }
); );
function triggerBrowserDownload(
url: string,
filename?: string,
options?: { openInNewTab?: boolean }
) {
const link = document.createElement("a");
link.href = url;
if (filename) {
link.download = filename;
}
if (options?.openInNewTab) {
link.target = "_blank";
}
link.rel = "noopener noreferrer";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
export function ImagePreview({ export function ImagePreview({
src, src,
alt, alt,
@ -238,24 +220,12 @@ export function ImagePreview({
toast.error("下载失败,请重试"); toast.error("下载失败,请重试");
return; return;
} }
const filenameBase = `generated-image-${Date.now()}`;
try { try {
const response = await fetch( await downloadOriginalImage({ url: safeOriginalUrl });
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" }) toast.success("原图已开始下载");
);
if (!response.ok) {
throw new Error(`下载失败(${response.status})`);
}
const blob = await response.blob();
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
const url = window.URL.createObjectURL(blob);
triggerBrowserDownload(url, filename);
window.URL.revokeObjectURL(url);
toast.success("下载成功");
} catch (error) { } catch (error) {
console.error("下载失败:", error); console.error("下载失败:", error);
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试"); toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };
@ -337,9 +307,18 @@ export function ImagePreview({
}} }}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={(event) => {
event.currentTarget.style.aspectRatio = "";
event.currentTarget.parentElement?.style.removeProperty("width");
event.currentTarget.alt = "Generated Image";
}}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== (thumbnail || original)) { if (event.currentTarget.src !== (thumbnail || original)) {
event.currentTarget.src = thumbnail || original; event.currentTarget.src = thumbnail || original;
} else if (!compactGrid) {
event.currentTarget.style.aspectRatio = "1";
event.currentTarget.parentElement?.style.setProperty("width", "100%");
event.currentTarget.alt = "预览暂不可用,可尝试下载原图";
} }
}} }}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}

View File

@ -9,6 +9,7 @@ import { Button } from "./ui/button";
import { X, Download, Target, Paintbrush } from "lucide-react"; import { X, Download, Target, Paintbrush } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ImageAnnotator } from "./image-annotator"; import { ImageAnnotator } from "./image-annotator";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
export const PreviewAttachment = ({ export const PreviewAttachment = ({
attachment, attachment,
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
// 下载图片函数 // 下载图片函数
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: name ?? "image" });
const blob = await response.blob(); toast.success("原图已开始下载");
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = name ?? `image-${Date.now()}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(downloadUrl);
} catch (error) { } catch (error) {
console.error("下载失败:", error); console.error("下载失败:", error);
toast.error(getCanvasImageDownloadErrorMessage(error));
} }
}; };

View File

@ -1,4 +1,5 @@
"use client"; "use client";
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { import {
ArrowLeft, ArrowLeft,
@ -2073,18 +2074,9 @@ export function SceneFusionView({
const downloadImage = async (url: string, filename: string) => { const downloadImage = async (url: string, filename: string) => {
try { try {
const response = await fetch(url); await downloadOriginalImage({ url, name: filename });
const blob = await response.blob(); } catch (error) {
const blobUrl = URL.createObjectURL(blob); toast.error(getCanvasImageDownloadErrorMessage(error));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
} catch {
window.open(url, "_blank");
} }
}; };

View File

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

View File

@ -1024,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;
@ -1036,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();
@ -1099,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,
@ -1145,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,
@ -1311,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 {

View File

@ -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";
@ -343,7 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
} }
export function shouldFailoverAiModelError(error: unknown) { export function shouldFailoverAiModelError(error: unknown) {
if (isGatewayResponseTimeout(error)) return false; 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 =

View File

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

View File

@ -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,57 +69,17 @@ 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); return task;
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; export async function heartbeatCanvasCapabilityTask(taskId: string) {
return task; 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) {
@ -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(

View File

@ -0,0 +1,31 @@
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("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
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}` : ""}`;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } 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;
@ -166,6 +176,63 @@ function triggerBlobDownload(blob: Blob, filename: string) {
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
} }
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
throw new Error("下载链接无效");
}
signal?.throwIfAborted();
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = result.downloadUrl;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), 10 * 60_000);
return { filename: result.filename, bytes: result.bytes || 0 };
}
/** Keep the source format and bytes for chat, gallery and reference downloads. */
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
const source = String(url || "").trim();
if (!source) throw new Error("图片地址无效");
const proxy = buildImageProxyUrl(source, { delivery: "original" });
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
const target = new URL(proxy, window.location.origin);
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
const checkedFetch = async (input: string) => {
let response: Response;
try { response = await fetch(input, { signal }); }
catch (error) {
if (signal?.aborted) throw error;
const failure = getDownloadFailure(error);
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
}
if (!response.ok) {
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
}
return response;
};
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
target.searchParams.set("download", "resolve");
target.searchParams.set("name", filenameBase);
const metadata = await checkedFetch(target.pathname + target.search);
const delivery = await metadata.json();
if (delivery.delivery === "cdn") {
const result = triggerCdnDownload(delivery, signal);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return result;
}
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
}
const response = await checkedFetch(proxy);
const blob = await readResponseBlobWithProgress(response, onProgress);
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(blob.type);
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
triggerBlobDownload(blob, filename);
return { filename, bytes: blob.size };
}
function toDownloadCandidates(url: string, candidates: string[] = []) { function toDownloadCandidates(url: string, candidates: string[] = []) {
const raw = String(url || "").trim(); const raw = String(url || "").trim();
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim(); const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
@ -257,6 +324,7 @@ export async function downloadCanvasImageAsPng({
let preparationPolls = 0; let preparationPolls = 0;
for (;;) { for (;;) {
signal?.throwIfAborted(); signal?.throwIfAborted();
try {
response = await fetch("/api/canvas/download-image", { response = await fetch("/api/canvas/download-image", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -269,6 +337,11 @@ export async function downloadCanvasImageAsPng({
}), }),
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 +351,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 +372,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,22 +396,18 @@ 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,
} }
); );
} }
if (response.headers.get("content-type")?.includes("application/json")) { if (response.headers.get("content-type")?.includes("application/json")) {
const result = await response.json(); const result = await response.json();
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string" if (result.delivery !== "cdn") throw new Error("下载链接无效");
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效"); const downloaded = triggerCdnDownload(result, signal);
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
const frame = document.createElement("iframe");
frame.hidden = true;
frame.src = result.downloadUrl;
document.body.appendChild(frame);
window.setTimeout(() => frame.remove(), 10 * 60_000);
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
return { filename: result.filename, bytes: result.bytes || 0 }; return downloaded;
} }
const filename = const filename =

View File

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

View File

@ -20,6 +20,8 @@ export function getImageDownloadExtension(contentType: string) {
"image/avif": "avif", "image/avif": "avif",
"image/gif": "gif", "image/gif": "gif",
"image/svg+xml": "svg", "image/svg+xml": "svg",
"image/bmp": "bmp",
"image/tiff": "tiff",
}; };
return extensions[mime] || ""; return extensions[mime] || "";
} }
@ -183,8 +185,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
return source; return source;
} }
// An original download must go through the same-origin byte-preserving // The same-origin endpoint can resolve a native CDN download without relying
// proxy even for trusted storage hosts: a direct fetch may fail CORS. // on cross-origin fetch, while retaining a byte-preserving proxy fallback.
if (options.delivery === "original") { if (options.delivery === "original") {
const params = new URLSearchParams({ url: source, original: "1" }); const params = new URLSearchParams({ url: source, original: "1" });
return `${IMAGE_PROXY_PATH}?${params.toString()}`; return `${IMAGE_PROXY_PATH}?${params.toString()}`;

View File

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

View 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); }
}

View File

@ -189,14 +189,6 @@ export function isGatewayResponseTimeout(error: unknown): boolean {
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error)); /\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
} }
function getPublicGatewayErrorMessage(error: unknown): string | null {
if (isGatewayResponseTimeout(error)) {
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
}
const message = getRequestErrorMessage(error);
return message ? getGatewayErrorPageMessage(message) : null;
}
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));
@ -221,19 +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 = "图片生成失败,请稍后重试",
) { ) {
const gatewayMessage = getPublicGatewayErrorMessage(error); return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
if (gatewayMessage) return gatewayMessage;
if (isProviderBalanceError(error)) {
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "图片服务请求过于频繁,请稍后重试或切换模型。";
}
return getRequestErrorMessage(error) || fallbackMessage;
} }
export function getFriendlyRateLimitMessage( export function getFriendlyRateLimitMessage(
@ -251,15 +304,12 @@ export function getUserFacingRequestErrorMessage(
rateLimitMessage?: string; rateLimitMessage?: string;
} }
) { ) {
const gatewayMessage = getPublicGatewayErrorMessage(error);
if (gatewayMessage) return gatewayMessage;
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;

View File

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

View File

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

View File

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

View File

@ -0,0 +1,45 @@
import "server-only";
import { getDefaultStorageConfig } from "@/lib/db/queries";
import { findImageArchiveDownloadTask } from "./image-archive-db";
import { inferOriginalImageUrl } from "@/lib/image-url";
import { getImageDownloadExtension } from "@/lib/image-delivery";
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
/** Resolve the archived original without waiting for a separate PNG conversion. */
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
const config = await getDefaultStorageConfig();
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
const base = new URL(config.publicUrl);
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
let target = new URL(source);
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
target = new URL(task?.targetUrl || source);
}
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
target.host = base.host;
target.protocol = base.protocol;
}
if (base.protocol !== "https:" || target.origin !== base.origin ||
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
// A Kodo download token signs the complete URL; adding attname would invalidate it.
if (target.searchParams.has("token")) return null;
// Only metadata crosses the application server. The browser receives the file
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
const response = await fetch(target, {
method: "HEAD", cache: "no-store",
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentType = response.headers.get("content-type") || "";
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
const extension = getImageDownloadExtension(contentType);
if (!extension) return null;
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
const filename = `${basename}.${extension}`;
target.searchParams.set("attname", filename);
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
bytes: Number(response.headers.get("content-length")) || 0 };
}

View File

@ -3,6 +3,15 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/original-image-download\.test\.ts/,
/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 +51,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/,

View File

@ -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,6 +669,19 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
} }
); );
} }
} 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)) { } else if (isHighFrequencyReadApi(pathname, request.method)) {
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling); const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
if (!pollingRateLimit.allowed) { if (!pollingRateLimit.allowed) {

View 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);
});
});

View 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");
});

View 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);
});

View File

@ -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: [],

View File

@ -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 }),
}, },

View File

@ -154,7 +154,7 @@ test.describe("Qiniu image delivery", () => {
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403); expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400); expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
expect(fetchCount).toBe(0); expect(fetchCount).toBe(0);
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400); expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(422);
} finally { } finally {
globalThis.fetch = previousFetch; globalThis.fetch = previousFetch;
} }

View File

@ -2,11 +2,34 @@ import { expect, test } from "@playwright/test";
import { import {
getUserFacingImageGenerationErrorMessage, getUserFacingImageGenerationErrorMessage,
getUserFacingRequestErrorMessage, getUserFacingRequestErrorMessage,
getRequestFailureDetails,
formatRequestFailure,
getGatewayErrorPageMessage, getGatewayErrorPageMessage,
isGatewayResponseTimeout, 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";
@ -45,13 +68,14 @@ test("HTTP 524 does not start another model while the original result is unconfi
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true); expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
}); });
test("the provider submits once and keeps an HTTP 524 response unconfirmed", async () => { test("the provider submits once when an HTTP 524 request cannot be located", async () => {
let submissions = 0; let submissions = 0;
const provider = serverLoader({ const provider = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null }, "@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {}, "@/lib/storage/image-archive": {},
"@/lib/storage/service": {}, "@/lib/storage/service": {},
fetch: async () => { fetch: async (_url: string, init: RequestInit) => {
if (init?.method !== "POST") return Response.json({}, { status: 404 });
submissions++; submissions++;
return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } }); return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } });
}, },
@ -61,6 +85,136 @@ test("the provider submits once and keeps an HTTP 524 response unconfirmed", asy
expect(submissions).toBe(1); 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);

View File

@ -0,0 +1,114 @@
import { test, expect } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader";
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
test("original downloads use the archived JPEG rather than the prepared PNG and transfer only HEAD metadata", async () => {
const calls: Array<{ url: string; method: string }> = [];
const loader = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
} }) },
fetch: async (url: URL, options: { method: string }) => {
calls.push({ url: url.toString(), method: options.method });
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
},
});
const result = await loader("lib/storage/original-image-download.ts").resolveOriginalCdnDownload("https://upstream.example/temporary.png", "产品.png", new AbortController().signal);
expect(calls).toEqual([{ url: "https://cdn.example/original.jpg", method: "HEAD" }]);
expect(result).toMatchObject({ delivery: "cdn", filename: "产品.jpg", bytes: 12345 });
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
});
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
fetch() { throw new Error("Do not add filename parameters to a signed URL"); },
})("lib/storage/original-image-download.ts");
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.jpg?token=signature%2Fvalue&e=123", "image.jpg", new AbortController().signal);
expect(result).toBeNull();
});
test("existing CDN objects bypass the archive scan and retain the MIME-derived format", async () => {
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask() { throw new Error("An existing CDN object needs no archive scan"); } },
fetch: async () => ({ ok: true, headers: new Headers({ "content-type": "image/webp" }) }),
})("lib/storage/original-image-download.ts");
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.png", "source.png", new AbortController().signal);
expect(result.filename).toBe("source.webp");
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
});
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
const module = serverLoader({
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
fetch() { throw new Error("The upstream file service does not support HEAD"); },
})("lib/storage/original-image-download.ts");
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
});
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
const route = serverLoader({
"next/server": { NextResponse: Response },
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async () => { throw new Error("HTTP 404"); } },
})("app/api/image-proxy/route.ts");
const request = { nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fcdn.example%2Fmissing.jpg&original=1&download=resolve"), signal: new AbortController().signal, headers: new Headers() };
const response = await route.GET(request);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.failure).toMatchObject({ code: "SOURCE_NOT_FOUND", retryable: false });
expect(data.failure.recommendation).toBeTruthy();
expect(data.error).toContain(data.requestId);
});
function browserMocks() {
const frames: any[] = []; const links: any[] = [];
return { frames, links,
window: { location: { origin: "https://app.example" }, setTimeout() {} },
document: { createElement: (tag: string) => ({ tag, click() { links.push(this); }, remove() {} }),
body: { appendChild: (el: any) => { if (el.tag === "iframe") frames.push(el); }, removeChild() {} } },
};
}
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
const dom = browserMocks(); const calls: string[] = [];
const client = serverLoader({ ...dom, fetch: async (url: string) => {
calls.push(url);
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
} })("lib/client/canvas-image-download.ts");
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
expect(calls).toHaveLength(1);
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
expect(dom.links).toHaveLength(0);
});
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
expect(calls).toBe(2);
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
expect(Buffer.from(actual).equals(bytes)).toBe(true);
URL.revokeObjectURL(dom.links[0].href);
});
test("original downloads reject processed references before network access", async () => {
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
});
test("chat downloads display the server cause and advice without losing its feedback ID", async () => {
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
const client = serverLoader({ ...browserMocks(), fetch: async () => Response.json({ failure, requestId: "original-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
let error: unknown;
try { await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); } catch(e) { error=e; }
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain("下载编号:original-feedback");
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain(failure.recommendation);
});

View 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 });
});