Compare commits

...

13 Commits

Author SHA1 Message Date
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
61e2bbe0e4 修复粒子动画加载与生图 524 错误展示
风格化粒子动画依赖外部 CDN 的 Three.js,全站同域脚本策略会阻止其加载,导致 THREE 未定义。现在使用同域 ES module,保留原 Three.js 0.160.0、动画参数及 MIT 许可。

扶苏在 2026-11-04 09:44 收到的 gpt-image-2.5 HTTP 524 响应是 Cloudflare HTML 页面,错误正文被直接保存和显示。现在将这类响应显示为结果未确认的超时提示;已有历史消息在渲染时同样归一化,不改动原始记录。524 不触发自动模型切换,避免原任务结果未确认时再次生成。普通 HTML 文档和代码示例保持原样。

验证:原生产 CSP 下的本地浏览器验证粒子有可见像素、连续帧变化、窗口缩放及无外部请求、无脚本错误;637 项辅助测试通过、1 项原有跳过,包含供应商 524 只提交一次、结果未确认、不自动切换及正常 503 行为。TypeScript、改动文件 lint、凭据、日志脱敏、环境、元数据、测试与制品检查通过。

已知问题:全量 lint 仍有 1004 项历史诊断。524 的根因是上游长请求超时,本次修复错误展示与未确认结果的处理,不承诺消除线路侧偶发超时。
2026-11-04 10:26:01 +08:00
37 changed files with 1181 additions and 187 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,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

@ -66,6 +66,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { import {
fetchUploadWithRetry, fetchUploadWithRetry,
@ -373,7 +374,9 @@ function MarkdownResponseFallback({
}: Pick<ResponseProps, "children" | "className">) { }: Pick<ResponseProps, "children" | "className">) {
return ( return (
<div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}> <div className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>
{children} {typeof children === "string"
? getGatewayErrorPageMessage(children) || children
: children}
</div> </div>
); );
} }

View File

@ -18,6 +18,7 @@ import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-do
import { buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments"; import { splitMarkdownImages } from "@/lib/canvas/markdown-image-segments";
import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming"; import { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
@ -1028,7 +1029,10 @@ export const Response = ({
const rawText = const rawText =
typeof props.children === "string" typeof props.children === "string"
? normalizeMarkdownContent(stableStreamingMarkdown(props.children, isMarkdownStreaming)) ? normalizeMarkdownContent(stableStreamingMarkdown(
getGatewayErrorPageMessage(props.children) || props.children,
isMarkdownStreaming
))
: null; : null;
const shouldShowPdfDownload = const shouldShowPdfDownload =
Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText)); Boolean(enablePdfDownload && rawText && hasDownloadableReportContent(rawText));

View File

@ -7,6 +7,7 @@ import type { Vote } from "@/lib/db/schema";
import type { ChatMessage } from "@/lib/types"; import type { ChatMessage } from "@/lib/types";
import { cn, sanitizeText } from "@/lib/utils"; import { cn, sanitizeText } from "@/lib/utils";
import { extractImageUrlsFromText } from "@/lib/image-url"; import { extractImageUrlsFromText } from "@/lib/image-url";
import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { useDataStream } from "./data-stream-provider"; import { useDataStream } from "./data-stream-provider";
import { DocumentToolResult } from "./document"; import { DocumentToolResult } from "./document";
import { DocumentPreview } from "./document-preview"; import { DocumentPreview } from "./document-preview";
@ -521,7 +522,7 @@ const PurePreviewMessage = ({
const displayModelName = extractedModelName || inferredFallbackModelName; const displayModelName = extractedModelName || inferredFallbackModelName;
const filteredText = const filteredText =
message.role === "assistant" message.role === "assistant"
? appendImageDoneSummary(contentWithoutBadge) ? getGatewayErrorPageMessage(contentWithoutBadge) || appendImageDoneSummary(contentWithoutBadge)
: contentWithoutBadge; : contentWithoutBadge;
const useAssistantTypewriter = const useAssistantTypewriter =
message.role === "assistant" && message.role === "assistant" &&

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

@ -48,6 +48,7 @@ import {
VIP98K_REFERENCE_STAGING_KEY_PREFIX, VIP98K_REFERENCE_STAGING_KEY_PREFIX,
} from "@/lib/ai/vip98k-reference-staging"; } from "@/lib/ai/vip98k-reference-staging";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { import {
IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_MAX_BYTES,
@ -1023,6 +1024,77 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string,
return Math.min(Math.max(value, 1), 3600); return Math.min(Math.max(value, 1), 3600);
} }
function isSynchronousImagePath(path: string) {
return /^\/v1\/images\/(?:generations|edits)\/?$/.test(path);
}
async function recoverImageSubmission(params: {
apiKey: string;
baseURL: string;
modelId: string;
path: string;
idempotencyKey: string;
pollIntervalMs: number;
maxPollMs: number;
}) {
if (!isSynchronousImagePath(params.path)) return null;
const startedAt = Date.now();
const deadline = startedAt + params.maxPollMs;
logger.info("recovering original image request", { modelId: params.modelId });
while (Date.now() < deadline) {
try {
const lookup = await fetch(`${params.baseURL}/api/external/requests/by-idempotency-key`, {
cache: "no-store",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Idempotency-Key": params.idempotencyKey,
},
signal: AbortSignal.timeout(Math.min(60_000, deadline - Date.now())),
});
if ([401, 403, 404, 410].includes(lookup.status)) return null;
if (lookup.ok) {
const state = toRecord(await parseJsonResponse(lookup));
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
if (state.status === "succeeded") {
if (state.result_available !== true || !state.request_id) return null;
const result = await fetch(
`${params.baseURL}/api/external/requests/${encodeURIComponent(String(state.request_id))}/result`,
{
cache: "no-store",
headers: { Authorization: `Bearer ${params.apiKey}` },
signal: AbortSignal.timeout(Math.max(1, Math.min(60_000, deadline - Date.now()))),
},
);
if ([401, 403, 404, 410].includes(result.status)) return null;
if (result.ok) {
const payload = await parseJsonResponse(result);
const outputs = collectOutputUrls(payload);
if (!outputs.length) {
throw new ProviderTaskUncertainError("原生图请求已完成,但未取回图片结果,请勿重复生成");
}
logger.info("original image request recovered", {
modelId: params.modelId,
providerRequestId: state.request_id,
outputCount: outputs.length,
durationMs: Date.now() - startedAt,
});
return { payload, taskId: extractTaskId(payload), resultPath: extractResultPath(payload), outputs };
}
}
}
} catch (error) {
if (error instanceof ProviderTaskUncertainError) throw error;
}
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
}
logger.warn("original image request recovery timed out", {
modelId: params.modelId,
durationMs: Date.now() - startedAt,
});
return null;
}
async function submitTask(params: { async function submitTask(params: {
apiKey: string; apiKey: string;
baseURL?: string | null; baseURL?: string | null;
@ -1035,6 +1107,8 @@ async function submitTask(params: {
submissionTimeoutMs: number; submissionTimeoutMs: number;
maxSubmissionAttempts: number; maxSubmissionAttempts: number;
submissionRetryBaseDelayMs: number; submissionRetryBaseDelayMs: number;
pollIntervalMs: number;
maxPollMs: number;
}) { }) {
const baseURL = normalizeVip98kBaseUrl(params.baseURL); const baseURL = normalizeVip98kBaseUrl(params.baseURL);
const path = String(params.path || "/api/external/task/generate").trim(); const path = String(params.path || "/api/external/task/generate").trim();
@ -1098,7 +1172,9 @@ async function submitTask(params: {
signal: AbortSignal.timeout(params.submissionTimeoutMs), signal: AbortSignal.timeout(params.submissionTimeoutMs),
}); });
} catch (error) { } catch (error) {
if (attempt >= maxAttempts) { const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
if (recovered) return recovered;
if (isSynchronousImagePath(path) || attempt >= maxAttempts) {
logger.error("provider submission transport failed", { logger.error("provider submission transport failed", {
modelId: params.modelId, modelId: params.modelId,
path, path,
@ -1144,6 +1220,14 @@ async function submitTask(params: {
}; };
} }
if ([502, 504, 524].includes(response.status)) {
const recovered = await recoverImageSubmission({ ...params, baseURL, path, idempotencyKey });
if (recovered) return recovered;
if (isSynchronousImagePath(path)) {
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: extractErrorMessage(payload), status: response.status })} (HTTP ${response.status})`);
}
}
const errorMessage = extractErrorMessage(payload); const errorMessage = extractErrorMessage(payload);
const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({ const stagedMediaRetryDelayMs = getVip98kStagedMediaRetryDelay({
status: response.status, status: response.status,
@ -1188,9 +1272,9 @@ async function submitTask(params: {
continue; continue;
} }
if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage)) if ((response.status === 409 && /idempotency\s+key.*(?:still\s+in\s+progress|processing)/i.test(errorMessage))
|| [502, 504].includes(response.status) || [502, 504, 524].includes(response.status)
|| /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage)) || /pending\s+safe\s+reconciliation|request\s+outcome\s+(?:is\s+)?unknown|final\s+outcome\s+could\s+not\s+be\s+confirmed/i.test(errorMessage))
throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${errorMessage} (HTTP ${response.status})`); throw new ProviderTaskUncertainError(`供应商提交结果未确认,可能仍在处理,请勿重复生成:${getUserFacingImageGenerationErrorMessage({ message: errorMessage, status: response.status })} (HTTP ${response.status})`);
throw new Error(`${errorMessage} (HTTP ${response.status})`); throw new Error(`${errorMessage} (HTTP ${response.status})`);
} }
@ -1310,6 +1394,12 @@ export async function submitVip98kExternalTask(params: {
), ),
maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts, maxSubmissionAttempts: params.durable ? 1 : runtimeConfig.maxSubmissionAttempts,
submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs, submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs,
pollIntervalMs: getConfiguredPollSeconds(
taskParams.modelConfig, "pollIntervalSeconds", DEFAULT_POLL_INTERVAL_SECONDS,
) * 1000,
maxPollMs: getConfiguredPollSeconds(
taskParams.modelConfig, "maxPollSeconds", DEFAULT_MAX_POLL_SECONDS,
) * 1000,
}); });
const resultPath = submitted.resultPath || invocation?.resultPath || ""; const resultPath = submitted.resultPath || invocation?.resultPath || "";
return { return {

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";
@ -12,7 +13,7 @@ import {
isMediaInputFailure, isMediaInputFailure,
isNonProviderRequestStatus, isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification"; } from "@/lib/ai/runtime/request-failure-classification";
import { isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
export type AiModelHealthEventType = export type AiModelHealthEventType =
| "success" | "success"
@ -343,6 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
} }
export function shouldFailoverAiModelError(error: unknown) { export function shouldFailoverAiModelError(error: unknown) {
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
const classification = classifyAiModelHealthFailure(error); const classification = classifyAiModelHealthFailure(error);
const normalized = getErrorMessage(error).toLowerCase(); const normalized = getErrorMessage(error).toLowerCase();
const imageProviderTaskFailed = const imageProviderTaskFailed =

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,59 +69,19 @@ export async function createOrRecoverCanvasCapabilityTask(params: {
userId: params.userId, userId: params.userId,
projectId: params.projectId, projectId: params.projectId,
}; };
let task = await getCanvasCapabilityTask(taskLookup); const task = await getCanvasCapabilityTask(taskLookup);
if (!task) { if (!task) {
throw new Error("无法创建画布能力任务"); throw new Error("无法创建画布能力任务");
} }
const staleBefore = new Date(Date.now() - STALE_RUNNING_TASK_MS);
if (
task.status === "failed" &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: null,
completedAt: null,
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
} else if (
task.status === "running" &&
task.updatedAt < staleBefore &&
task.attempts < MAX_CANVAS_CAPABILITY_TASK_ATTEMPTS
) {
await db
.update(canvasCapabilityTask)
.set({
status: "pending",
error: "恢复超时的执行中任务",
updatedAt: new Date(),
})
.where(
and(
eq(canvasCapabilityTask.id, task.id),
lt(canvasCapabilityTask.updatedAt, staleBefore)
)
);
} else if (task.status === "running" && task.updatedAt < staleBefore) {
await db
.update(canvasCapabilityTask)
.set({
status: "failed",
error: "画布能力任务超过最大重试次数",
completedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(canvasCapabilityTask.id, task.id));
}
task = (await getCanvasCapabilityTask(taskLookup)) || task;
return task; return task;
} }
export async function heartbeatCanvasCapabilityTask(taskId: string) {
await db.update(canvasCapabilityTask).set({ updatedAt: new Date() })
.where(and(eq(canvasCapabilityTask.id, taskId), inArray(canvasCapabilityTask.status, ["pending", "running"])));
}
export async function markCanvasCapabilityTaskRunning(taskId: string) { export async function markCanvasCapabilityTaskRunning(taskId: string) {
const now = new Date(); const now = new Date();
const [task] = await db const [task] = await db
@ -115,11 +94,10 @@ export async function markCanvasCapabilityTaskRunning(taskId: string) {
error: null, error: null,
updatedAt: now, updatedAt: now,
}) })
.where(eq(canvasCapabilityTask.id, taskId)) .where(and(eq(canvasCapabilityTask.id, taskId), eq(canvasCapabilityTask.status, "pending")))
.returning(); .returning();
if (!task) throw new Error("画布能力任务不存在"); return task || null;
return task;
} }
export async function markCanvasCapabilityTaskSucceeded( export async function markCanvasCapabilityTaskSucceeded(

View File

@ -0,0 +1,30 @@
export type DownloadFailure = {
code: string;
message: string;
recommendation: string;
status: number;
retryable: boolean;
};
export function getDownloadFailure(error: unknown): DownloadFailure {
const text = error && typeof error === "object" && "message" in error
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
({ code, message, recommendation, status, retryable });
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
}
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
}

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 } from "@/lib/image-delivery";
import { inferOriginalImageUrl } from "@/lib/image-url"; import { inferOriginalImageUrl } from "@/lib/image-url";
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving"; export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error {
code: CanvasImageDownloadErrorCode; code: CanvasImageDownloadErrorCode;
retryAfterSeconds?: number; retryAfterSeconds?: number;
status?: number; status?: number;
failure?: DownloadFailure;
requestId?: string;
constructor( constructor(
message: string, message: string,
@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error {
code: CanvasImageDownloadErrorCode; code: CanvasImageDownloadErrorCode;
retryAfterSeconds?: number; retryAfterSeconds?: number;
status?: number; status?: number;
failure?: DownloadFailure;
requestId?: string;
} }
) { ) {
super(message); super(message);
@ -40,11 +45,14 @@ export class CanvasImageDownloadError extends Error {
this.code = options.code; this.code = options.code;
this.retryAfterSeconds = options.retryAfterSeconds; this.retryAfterSeconds = options.retryAfterSeconds;
this.status = options.status; this.status = options.status;
this.failure = options.failure;
this.requestId = options.requestId;
} }
} }
export function getCanvasImageDownloadErrorMessage(error: unknown) { export function getCanvasImageDownloadErrorMessage(error: unknown) {
if (error instanceof CanvasImageDownloadError) { if (error instanceof CanvasImageDownloadError) {
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
if (error.code === "rate_limited") { if (error.code === "rate_limited") {
return error.retryAfterSeconds return error.retryAfterSeconds
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试` ? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
} }
if (error.code === "preparation_timeout") { if (error.code === "preparation_timeout") {
return "原图仍在后台准备中,请稍后再次点击下载"; return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
} }
} }
return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; return formatDownloadFailure(getDownloadFailure(error));
} }
type DownloadCanvasImageParams = { type DownloadCanvasImageParams = {
@ -71,6 +79,8 @@ type DownloadCanvasImageParams = {
type DownloadErrorPayload = { type DownloadErrorPayload = {
error?: string; error?: string;
retryAfter?: number; retryAfter?: number;
failure?: DownloadFailure;
requestId?: string;
}; };
const MAX_RATE_LIMIT_RETRIES = 1; const MAX_RATE_LIMIT_RETRIES = 1;
@ -257,7 +267,8 @@ export async function downloadCanvasImageAsPng({
let preparationPolls = 0; let preparationPolls = 0;
for (;;) { for (;;) {
signal?.throwIfAborted(); signal?.throwIfAborted();
response = await fetch("/api/canvas/download-image", { try {
response = await fetch("/api/canvas/download-image", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@ -268,7 +279,12 @@ export async function downloadCanvasImageAsPng({
preparation: "async", preparation: "async",
}), }),
signal, signal,
}); });
} catch (error) {
if (signal?.aborted) throw error;
const failure = getDownloadFailure(error);
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
}
if (response.status === 202) { if (response.status === 202) {
rateLimitRetries = 0; rateLimitRetries = 0;
@ -278,7 +294,7 @@ export async function downloadCanvasImageAsPng({
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 }); throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
} }
if (Date.now() >= preparationDeadline || preparationPolls >= 60) { if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 }); throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
} }
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
@ -299,6 +315,8 @@ export async function downloadCanvasImageAsPng({
code: "rate_limited", code: "rate_limited",
retryAfterSeconds, retryAfterSeconds,
status: response.status, status: response.status,
failure: errorPayload?.failure,
requestId: errorPayload?.requestId,
} }
); );
} }
@ -321,6 +339,8 @@ export async function downloadCanvasImageAsPng({
{ {
code: status === 502 ? "source_unavailable" : "request_failed", code: status === 502 ? "source_unavailable" : "request_failed",
status, status,
failure: errorPayload?.failure,
requestId: errorPayload?.requestId,
} }
); );
} }

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

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

@ -175,6 +175,20 @@ function getRequestErrorText(error: unknown) {
return candidates.join(" ").slice(0, 4000); return candidates.join(" ").slice(0, 4000);
} }
export function getGatewayErrorPageMessage(content: string): string | null {
if (!/^\s*(?:<!doctype\s+html\b|<html\b)/i.test(content)) return null;
if (!/\bcf-error-details\b/i.test(content)) return null;
if (/\b524\b/.test(content)) {
return "模型服务响应超时(HTTP 524),生成结果尚未确认。请先查看历史记录,避免重复提交。";
}
return "模型服务暂时不可用,请稍后再试或联系管理员。";
}
export function isGatewayResponseTimeout(error: unknown): boolean {
return getRequestErrorStatus(error) === 524 ||
/\bHTTP\s*524\b|\b524\s*:\s*A timeout occurred\b|Error code\s*524\b/i.test(getRequestErrorText(error));
}
export function isProviderBalanceError(error: unknown): boolean { export function isProviderBalanceError(error: unknown): boolean {
const message = getRequestErrorText(error); const message = getRequestErrorText(error);
return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message)); return PROVIDER_BALANCE_PATTERNS.some((pattern) => pattern.test(message));
@ -199,17 +213,80 @@ export function isRateLimitError(error: unknown): boolean {
RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message)); RATE_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
} }
export type RequestFailureDetails = {
code: string;
message: string;
recommendation: string;
outcome: "failed" | "unknown";
};
export function getRequestFailureDetails(
error: unknown,
fallbackMessage = "请求未能完成",
): RequestFailureDetails {
const text = getRequestErrorText(error);
const status = getRequestErrorStatus(error);
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
({ code, message, recommendation, outcome });
if (isGatewayResponseTimeout(error)) {
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
}
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
}
if (isProviderBalanceError(error)) {
return detail("PROVIDER_BALANCE_EXHAUSTED", "图片服务商线路余额或额度不足,与您的积分余额无关。", "请切换可用模型或联系管理员处理线路额度。");
}
if (isRateLimitError(error)) {
return detail("RATE_LIMITED", "当前请求过于频繁。", "请稍后重试,避免连续点击;模型线路限流时可切换其他可用模型。");
}
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
}
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
}
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
}
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
}
if (/版本冲突|画布.*(?:更新|过期)|CANVAS_REVISION_CONFLICT|stale.canvas/i.test(text)) {
return detail("CANVAS_REVISION_CONFLICT", "画布已更新,本次保存与当前版本冲突。", "请先重新同步画布,再继续操作,避免覆盖较新的修改。");
}
if (/no_eligible_route|service_unavailable|service_rejected|channel.*unavailable|暂无可用服务|未配置.*模型|没有可用.*(?:模型|通道)|通道.*(?:不可用|未通过)|鉴权/i.test(text)) {
return detail("PROVIDER_UNAVAILABLE", "当前模型服务或通道暂不可用。", "请切换其他可用模型;若多个模型都失败,请联系管理员检查服务配置。");
}
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
}
if (/(?:抠图|去水印|图片增强|图片|扩图)处理失败/.test(text)) {
return detail("PROCESSING_FAILED", "图片处理服务执行失败,未返回具体原因。", "请联系管理员并提供请求编号,先核对原任务结果,避免重复处理。", "unknown");
}
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
}
if (/timeout|timed out|超时|fetch failed|ECONNRESET|ECONNREFUSED|failed to fetch|networkerror|load failed|network request failed/i.test(text) || [502, 504].includes(status || 0)) {
return detail("RESULT_UNCONFIRMED", "请求连接中断或等待超时,任务结果尚未确认。", "请先查看原任务或历史记录,避免重复提交;持续没有结果时联系管理员。", "unknown");
}
if (/generate(?: image)? failed|generation failed|upstream_server_error|service_error|excessive system load|未返回可用图片|任务FAILED/i.test(text) || (status !== null && status >= 500)) {
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
}
const message = getRequestErrorMessage(error);
return detail("REQUEST_FAILED", message && !/^\s*</.test(message) ? message : fallbackMessage, "");
}
export function formatRequestFailure(details: RequestFailureDetails, requestId?: string) {
const text = [details.message, details.recommendation].filter(Boolean).join("");
return requestId ? `${text}\n\n请求编号:${requestId}` : text;
}
export function getUserFacingImageGenerationErrorMessage( export function getUserFacingImageGenerationErrorMessage(
error: unknown, error: unknown,
fallbackMessage = "图片生成失败,请稍后重试", fallbackMessage = "图片生成失败,请稍后重试",
) { ) {
if (isProviderBalanceError(error)) { return formatRequestFailure(getRequestFailureDetails(error, fallbackMessage));
return "图片服务商线路余额或额度不足,请切换模型或联系管理员;与您的积分余额无关。";
}
if (isRateLimitError(error)) {
return "图片服务请求过于频繁,请稍后重试或切换模型。";
}
return getRequestErrorMessage(error) || fallbackMessage;
} }
export function getFriendlyRateLimitMessage( export function getFriendlyRateLimitMessage(
@ -227,13 +304,12 @@ export function getUserFacingRequestErrorMessage(
rateLimitMessage?: string; rateLimitMessage?: string;
} }
) { ) {
if (isProviderBalanceError(error)) {
return "模型服务商线路余额或额度不足,请切换模型或联系管理员。";
}
if (isRateLimitError(error)) { if (isRateLimitError(error)) {
return getFriendlyRateLimitMessage(options?.rateLimitMessage); return getFriendlyRateLimitMessage(options?.rateLimitMessage);
} }
const failure = getRequestFailureDetails(error, fallbackMessage);
if (failure.code !== "REQUEST_FAILED") return formatRequestFailure(failure);
const message = getRequestErrorMessage(error); const message = getRequestErrorMessage(error);
if (!message) { if (!message) {
return fallbackMessage; return fallbackMessage;

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

@ -3,6 +3,14 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/canvas-download-speed\.test\.ts/,
/routes\/canvas-download-recovery\.test\.ts/,
/routes\/canvas-download-rate-limit\.test\.ts/,
/routes\/canvas-zip-download\.test\.ts/,
/routes\/image-archive-flow\.test\.ts/,
/routes\/runtime-active-work\.test\.ts/,
/routes\/canvas-capability-task-recovery\.test\.ts/,
/routes\/canvas-toolbar-capability\.test\.ts/,
/routes\/agent-project-recovery\.test\.ts/, /routes\/agent-project-recovery\.test\.ts/,
/routes\/admin-media-loading\.test\.ts/, /routes\/admin-media-loading\.test\.ts/,
/routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-production\.test\.ts/,
@ -42,6 +50,7 @@ export default defineConfig({
/routes\/replacement-product-identity\.test\.ts/, /routes\/replacement-product-identity\.test\.ts/,
/routes\/detail-replacement-batch\.test\.ts/, /routes\/detail-replacement-batch\.test\.ts/,
/routes\/alibaba-image-input\.test\.ts/, /routes\/alibaba-image-input\.test\.ts/,
/routes\/alibaba-task-errors\.test\.ts/,
/routes\/model3d-style-pro-regressions\.test\.ts/, /routes\/model3d-style-pro-regressions\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/, /routes\/clawd-request-lifecycle\.test\.ts/,
/routes\/canvas-skill-tag-order\.test\.ts/, /routes\/canvas-skill-tag-order\.test\.ts/,

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,7 +669,20 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
} }
); );
} }
} else if (isHighFrequencyReadApi(pathname, request.method)) { } else if (request.method === "POST" && /^\/api\/canvas\/[^/]+\/image-archives$/.test(pathname)) {
const token = await resolveToken();
const identifier = typeof token?.sub === "string" && token.sub
? `user:${token.sub}:image-archives`
: `ip:${clientIP}:image-archives`;
const result = checkRateLimitByIdentifier(identifier, rateLimiters.uiPolling);
if (!result.allowed) {
const retryAfter = Math.max(1, Math.ceil((result.resetTime - Date.now()) / 1000));
return withAccessLog(NextResponse.json(
{ error: "图片状态查询过于频繁,请稍后再查看", retryAfter },
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
), { outcome: "rate_limited", severity: "watch", reasons: ["图片归档状态查询限流"] });
}
} else if (isHighFrequencyReadApi(pathname, request.method)) {
const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling); const pollingRateLimit = checkRateLimit(request, rateLimiters.uiPolling);
if (!pollingRateLimit.allowed) { if (!pollingRateLimit.allowed) {
console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, { console.warn(`[Security] UI polling rate limit exceeded for IP: ${clientIP}`, {

View File

@ -4,7 +4,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neural Particle - Scatter Effect</title> <title>Neural Particle - Scatter Effect</title>
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background-color: #000; } body { overflow: hidden; background-color: #000; }
@ -14,7 +13,8 @@
</head> </head>
<body> <body>
<div id="particle-container"></div> <div id="particle-container"></div>
<script> <script type="module">
import * as THREE from "/three/0.160.0/three.module.min.js";
const CONFIG = { const CONFIG = {
effect: "scatter", effect: "scatter",
effectMode: 1, effectMode: 1,

View File

@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2023 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because one or more lines are too long

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

@ -2,9 +2,34 @@ import { expect, test } from "@playwright/test";
import { import {
getUserFacingImageGenerationErrorMessage, getUserFacingImageGenerationErrorMessage,
getUserFacingRequestErrorMessage, getUserFacingRequestErrorMessage,
getRequestFailureDetails,
formatRequestFailure,
getGatewayErrorPageMessage,
isGatewayResponseTimeout,
isProviderBalanceError, isProviderBalanceError,
isRateLimitError, isRateLimitError,
} from "@/lib/request-error"; } from "@/lib/request-error";
test("known provider and platform failures explain their cause and next action", () => {
for (const [message, code, next] of [
["We are sorry, this prompt may violate our content policies", "CONTENT_POLICY_REJECTED", "调整描述"],
["The channel is temporarily unavailable", "PROVIDER_UNAVAILABLE", "切换其他"],
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
["任务执行中断,结果尚未确认", "RESULT_UNCONFIRMED", "避免重复生成"],
]) {
const failure = getRequestFailureDetails(new Error(message));
expect(failure.code).toBe(code);
expect(failure.recommendation).toContain(next);
expect(formatRequestFailure(failure, "request-123")).toContain("请求编号:request-123");
}
expect(getRequestFailureDetails({ providerOutcomeUnknown: true, message: "content policy" }).outcome).toBe("unknown");
expect(getRequestFailureDetails(new Error("图片上传到存储桶失败")).outcome).toBe("unknown");
});
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
@ -16,6 +41,180 @@ test("a bare HTTP 429 is rate limiting, not insufficient balance", () => {
expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁"); expect(getUserFacingRequestErrorMessage(error)).toContain("请求过于频繁");
}); });
const gatewayHtml = '<!DOCTYPE html><html><head><title>vip98k.com | 524: A timeout occurred</title></head><body><div id="cf-error-details">Error code 524</div><script>window.gatewayDebug = true</script></body></html> (HTTP 524)';
test("gateway timeout pages become a concise unconfirmed-result message", () => {
for (const error of [new Error(gatewayHtml), { status: 524, message: "Gateway failed" }, { error: { message: "Request failed (HTTP 524)" } }]) {
expect(isGatewayResponseTimeout(error)).toBe(true);
for (const text of [getUserFacingImageGenerationErrorMessage(error), getUserFacingRequestErrorMessage(error)]) {
expect(text).toContain("HTTP 524");
expect(text).toContain("结果尚未确认");
expect(text).not.toMatch(/<html|<script|cf-error-details/);
}
}
expect(getGatewayErrorPageMessage(gatewayHtml)).toContain("避免重复提交");
});
test("ordinary HTML documents and fenced examples retain their content", () => {
for (const text of ['<!DOCTYPE html><html><title>My page</title><p>Hosted on Cloudflare</p></html>', `\`\`\`html\n${gatewayHtml}\n\`\`\``, "Cloudflare 文档示例", "普通图片错误"]) {
expect(getGatewayErrorPageMessage(text)).toBeNull();
}
});
test("HTTP 524 does not start another model while the original result is unconfirmed", () => {
const { health } = healthHarness();
expect(health.shouldFailoverAiModelError(new Error(gatewayHtml))).toBe(false);
expect(health.shouldFailoverAiModelError({ status: 524, message: "Gateway timeout" })).toBe(false);
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
});
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
let submissions = 0;
const provider = serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": {},
"@/lib/storage/service": {},
fetch: async (_url: string, init: RequestInit) => {
if (init?.method !== "POST") return Response.json({}, { status: 404 });
submissions++;
return new Response(gatewayHtml, { status: 524, headers: { "content-type": "text/html" } });
},
})("lib/ai/providers/vip98k.ts");
const model = provider.createVip98k({ apiKey: "test", baseURL: "https://provider.test" }).chat("gpt-image-2.5");
await expect(model.doGenerate({ messages: [{ role: "user", content: "A desk" }] })).rejects.toThrow(/结果未确认.*HTTP 524/);
expect(submissions).toBe(1);
});
function imageRecoveryHarness(
handler: (path: string, method: string, call: number) => Response | Promise<Response>,
maxPollSeconds = 3,
) {
let now = 1_000;
const calls: { path: string; method: string; headers: Headers }[] = [];
const archived: string[] = [];
const load = serverLoader({
"@/lib/db": { db: {} },
"@/lib/db/queries": { getSystemSetting: async () => null },
"@/lib/storage/image-archive": { createDeferredUploadedImage: async (url: string) => {
archived.push(url);
return { thumbnail: url, original: url };
} },
"@/lib/storage/service": {},
Date: class extends Date { static now() { return now; } },
setTimeout: (fn: () => void, ms: number) => { now += ms; return setTimeout(fn, 0); },
fetch: async (url: string, init: RequestInit) => {
const path = new URL(url).pathname;
const method = init?.method || "GET";
calls.push({ path, method, headers: new Headers(init?.headers) });
return handler(path, method, calls.length);
},
});
const provider = load("lib/ai/providers/vip98k.ts");
return {
calls, load, archived,
generate: () => provider.createVip98k({ apiKey: "test-only-key", baseURL: "https://provider.test" })
.chat("gpt-image-2.5", { capabilities: { vip98k: { pollIntervalSeconds: 1, maxPollSeconds } } })
.doGenerate({ messages: [{ role: "user", content: "A desk" }] }),
run: () => provider.executeVip98kExternalTask({
apiKey: "test-only-key", baseURL: "https://provider.test", modelId: "gpt-image-2.5",
kind: "image", prompt: "A desk", imageUrls: [],
modelConfig: { capabilities: { vip98k: { pollIntervalSeconds: 1, maxPollSeconds } } },
}),
};
}
const recoveredImage = { created: 1, data: [{ url: "https://image.example/original.png" }] };
const originalCompleted = { request_id: "original-request", status: "succeeded", result_available: true };
test("an edge timeout waits for and delivers the original image without another POST", async () => {
let lookups = 0;
const h = imageRecoveryHarness((path, method) => {
if (method === "POST") return new Response(gatewayHtml, { status: 524 });
if (path.endsWith("by-idempotency-key")) {
return Response.json(++lookups === 1 ? { status: "processing" } : originalCompleted);
}
return Response.json(recoveredImage);
});
expect((await h.generate()).text).toContain(recoveredImage.data[0].url);
expect(h.archived).toEqual([recoveredImage.data[0].url]);
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
expect(lookups).toBe(2);
const originalKey = h.calls[0].headers.get("Idempotency-Key");
expect(originalKey).toBeTruthy();
for (const call of h.calls.filter(c => c.path.endsWith("by-idempotency-key"))) {
expect(call.headers.get("Idempotency-Key")).toBe(originalKey);
expect(call.headers.get("Authorization")).toBe("Bearer test-only-key");
}
expect(h.calls.at(-1)?.path).toBe("/api/external/requests/original-request/result");
});
test("a lost POST response recovers by its original key without resubmitting", async () => {
const h = imageRecoveryHarness((path, method) => {
if (method === "POST") throw new TypeError("fetch failed");
return Response.json(path.endsWith("by-idempotency-key") ? originalCompleted : recoveredImage);
});
expect((await h.run()).outputUrls).toHaveLength(1);
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
});
test("temporary lookup and result-read failures retry only GET requests", async () => {
let lookups = 0;
let reads = 0;
const h = imageRecoveryHarness((path, method) => {
if (method === "POST") return new Response("Gateway timeout", { status: 504 });
if (path.endsWith("by-idempotency-key")) {
return ++lookups === 1 ? new Response("Unavailable", { status: 503 }) : Response.json(originalCompleted);
}
return ++reads === 1 ? Response.json({}, { status: 409 }) : Response.json(recoveredImage);
}, 5);
expect((await h.run()).outputUrls).toHaveLength(1);
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
expect(reads).toBe(2);
});
test("missing, unconfirmed and unretained originals never create a replacement", async () => {
for (const state of [null, { status: "failed" }, { status: "unresolved" }, { ...originalCompleted, result_available: false }]) {
const h = imageRecoveryHarness((_path, method) => method === "POST"
? new Response(gatewayHtml, { status: 524 })
: Response.json(state || {}, { status: state ? 200 : 404 }));
await expect(h.run()).rejects.toThrow(/结果未确认.*HTTP 524/);
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET"]);
}
});
test("a still-processing original stops at the polling deadline without another POST", async () => {
const h = imageRecoveryHarness((_path, method) => method === "POST"
? new Response(gatewayHtml, { status: 524 }) : Response.json({ status: "processing" }), 2);
await expect(h.run()).rejects.toThrow(/结果未确认.*HTTP 524/);
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET", "GET"]);
});
test("HTTP 502 and 504 without a recoverable original do not repeat the POST", async () => {
for (const status of [502, 504]) {
const h = imageRecoveryHarness((_path, method) => method === "POST"
? new Response("Gateway timeout", { status }) : Response.json({}, { status: 404 }));
await expect(h.run()).rejects.toThrow(/结果未确认/);
expect(h.calls.map(c => c.method)).toEqual(["POST", "GET"]);
}
});
test("a completed original with no image stays unconfirmed and cannot fail over", async () => {
const h = imageRecoveryHarness((path, method) => {
if (method === "POST") return new Response(gatewayHtml, { status: 524 });
return Response.json(path.endsWith("by-idempotency-key") ? originalCompleted : { data: [] });
});
const error = await h.run().then(() => null, (e: Error) => e);
expect(error).toBeTruthy();
expect(h.load("lib/ai/runtime/model-health.ts").shouldFailoverAiModelError(error)).toBe(false);
expect(h.calls.filter(c => c.method === "POST")).toHaveLength(1);
});
test("ordinary image success uses the original response directly", async () => {
const h = imageRecoveryHarness(() => Response.json(recoveredImage));
expect((await h.run()).outputUrls).toHaveLength(1);
expect(h.calls.map(c => c.method)).toEqual(["POST"]);
});
test("an explicit provider balance error takes precedence over HTTP 429", () => { test("an explicit provider balance error takes precedence over HTTP 429", () => {
const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 }); const error = Object.assign(new Error("余额不足,请点击充值页面续费(HTTP 429)"), { status: 429 });
expect(isProviderBalanceError(error)).toBe(true); expect(isProviderBalanceError(error)).toBe(true);
@ -62,8 +261,10 @@ function healthHarness() {
let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true, let model: any = { id: "test", modelId: "test", isEnabled: true, excludeFromFailover: true,
excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 }; excludeFromHealthBan: true, healthStatus: "healthy", healthConsecutiveFailures: 0 };
const events: any[] = []; const events: any[] = [];
const query = (rows: () => any[]) => ({ from() { return this; }, where() { return this; }, const query = (rows: () => any[]) => Object.assign(Promise.resolve().then(rows), {
limit: async () => rows(), then: (resolve: any) => Promise.resolve(rows()).then(resolve), returning: async () => rows() }); from() { return this; }, where() { return this; },
limit: async () => rows(), returning: async () => rows(),
});
const db = { const db = {
select: () => query(() => [model]), select: () => query(() => [model]),
insert: () => ({ values: async (value: any) => { events.push(value); } }), insert: () => ({ values: async (value: any) => { events.push(value); } }),

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