Compare commits

..

No commits in common. "main" and "codex/gallery-download-cache-key-20261104" have entirely different histories.

61 changed files with 1074 additions and 2048 deletions

View File

@ -1,47 +0,0 @@
import { saveMessages } from "@/lib/db/queries";
import type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
import { safeWriteStreamChunk, type StreamWriter, writeAssistantTextChunk, writeStructuredDataChunk } from "./stream";
export async function reportImageGenerationFailure(params: {
writer: StreamWriter;
chatId: string;
userMessageId: string;
messageId: string;
error: unknown;
label: string;
}) {
const failure = getRequestFailureDetails(params.error, "图片生成失败");
const status: ImageGenerationStatusStreamPayload = {
phase: "failed",
userMessageId: params.userMessageId,
requestId: params.messageId,
failure,
};
const text = formatRequestFailure(failure, params.messageId);
const parts = [
{ type: "text", text },
{ type: "data-imageGenerationStatus", data: status },
];
writeStructuredDataChunk(params.writer, "data-imageGenerationStatus", status, `${params.label}:status`);
safeWriteStreamChunk(params.writer, {
type: "data-appendMessage",
data: JSON.stringify({ id: params.messageId, role: "assistant", parts }),
}, params.label);
writeAssistantTextChunk(params.writer, "<!-- IMAGE_GENERATION_END -->", `${params.label}:end`);
// History carries the same terminal state when the browser loses the stream.
await saveMessages({ messages: [{
id: params.messageId,
chatId: params.chatId,
role: "assistant",
parts,
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
}] });
return text;
}

View File

@ -208,7 +208,6 @@ export function writeImageGenerationStatusChunk(
writer: StreamWriter, writer: StreamWriter,
params: { params: {
phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
providerType?: string | null; providerType?: string | null;
modelName?: string | null; modelName?: string | null;
imageCount?: number | null; imageCount?: number | null;
@ -224,7 +223,6 @@ export function writeImageGenerationStatusChunk(
"data-imageGenerationStatus", "data-imageGenerationStatus",
{ {
phase: params.phase, phase: params.phase,
userMessageId: params.userMessageId,
providerType: normalizeImageLoadingProviderType(params.providerType), providerType: normalizeImageLoadingProviderType(params.providerType),
modelName: String(params.modelName || "").trim() || undefined, modelName: String(params.modelName || "").trim() || undefined,
imageCount: imageCount:

View File

@ -426,8 +426,6 @@ import {
type VideoModeType, type VideoModeType,
} from "./_lib/video-models"; } from "./_lib/video-models";
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
const chatApiLogger = createLogger("chat.route"); const chatApiLogger = createLogger("chat.route");
const createUIMessageStream: typeof createAiUIMessageStream = (options) => const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) }); createAiUIMessageStream({ ...options, execute: (writer) => trackRuntimeWork(() => options.execute(writer)) });
@ -448,6 +446,16 @@ 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, requestId: string) {
const message = error instanceof Error ? error.message : String(error || "");
const publicMessage = getUserFacingImageGenerationErrorMessage(error);
if (!message || (publicMessage === message && INTERNAL_IMAGE_GENERATION_FAILURE_PATTERN.test(message))) {
return `图片生成失败,请稍后重试。若连续失败,请联系管理员。\n\n请求编号:${requestId}`;
}
return `${publicMessage}\n\n请求编号:${requestId}`;
}
const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max(
1, 1,
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6), Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
@ -5680,7 +5688,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: resolvedModelRef, modelName: resolvedModelRef,
imageCount: replacementReferences.length, imageCount: replacementReferences.length,
@ -6023,7 +6030,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -6179,7 +6185,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: resolvedModelDisplayName, modelName: resolvedModelDisplayName,
imageCount: successCount, imageCount: successCount,
@ -6412,7 +6417,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -6964,7 +6968,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7290,7 +7293,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7519,7 +7521,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: phase:
queueStatus.status === "running" queueStatus.status === "running"
? "processing" ? "processing"
@ -7838,7 +7839,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: successCount > 0 ? "completed" : "failed", phase: successCount > 0 ? "completed" : "failed",
modelName: modelName:
selectedAgentPreset.defaultModel?.displayName || selectedAgentPreset.defaultModel?.displayName ||
@ -8048,7 +8048,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: finalModelId, modelName: finalModelId,
@ -8087,7 +8086,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
...directImageStatusPayload, ...directImageStatusPayload,
}, },
@ -8180,7 +8178,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: requestedModelProviderType, providerType: requestedModelProviderType,
modelName: directImageResult.model, modelName: directImageResult.model,
@ -8201,16 +8198,42 @@ export async function POST(request: Request) {
); );
} catch (directToolError) { } catch (directToolError) {
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError); chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
const errorMessage = await reportImageGenerationFailure({ const errorMessage = getDirectAgentToolErrorMessage(
writer, chatId: id, userMessageId: message.id, "generate_image",
messageId: assistantMessageId, error: directToolError, directToolError,
label: "agent:generate_image:error", );
await saveAssistantTextOnlyMessage({
chatId: id,
messageId: assistantMessageId,
text: errorMessage,
}); });
writeAssistantTextChunk(
writer,
errorMessage,
"agent:generate_image:error",
assistantMessageId,
);
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
writer,
{
phase: "failed",
providerType: requestedModelProviderType,
modelName: finalModelId,
},
"agent:generate_image:status-failed",
);
}
writeAgentProgressChunk( writeAgentProgressChunk(
writer, writer,
buildDirectAgentImageGenerateFailedProgress(errorMessage), buildDirectAgentImageGenerateFailedProgress(errorMessage),
"agent:generate_image:progress-failed", "agent:generate_image:progress-failed",
); );
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"agent:generate_image:end-error",
);
} finally { } finally {
stopDirectImageKeepalive(); stopDirectImageKeepalive();
if (stopDirectImageStatus) { if (stopDirectImageStatus) {
@ -9172,7 +9195,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: currentModelConfig?.displayName || actualModelId, modelName: currentModelConfig?.displayName || actualModelId,
@ -9260,7 +9282,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record Gemini fallback health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -9335,7 +9356,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record Gemini cross-provider health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Gemini cross-provider fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
} }
@ -9403,7 +9423,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -9468,14 +9487,6 @@ export async function POST(request: Request) {
} }
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] Gemini generation error:", error); chatApiLogger.error("[Chat API] Gemini generation error:", error);
let finalError = error;
if (!shouldFailoverAiModelError(error)) {
await reportImageGenerationFailure({
writer, chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "gemini:error",
});
return;
}
chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision..."); chatApiLogger.info("[Chat API] Attempting failover to Volcano Vision...");
// 故障转移:尝试使用火山引擎模型 // 故障转移:尝试使用火山引擎模型
@ -9553,7 +9564,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModel.model.displayName || volcanoModel.model.modelId, modelName: volcanoModel.model.displayName || volcanoModel.model.modelId,
@ -9619,14 +9629,47 @@ export async function POST(request: Request) {
chatApiLogger.info("[Chat API] No Volcano Vision model available for failover"); chatApiLogger.info("[Chat API] No Volcano Vision model available for failover");
} catch (failoverError) { } catch (failoverError) {
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError); chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
finalError = failoverError;
} }
// 如果故障转移也失败,返回错误消息 // 如果故障转移也失败,返回错误消息
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
messageId: generateUUID(), error: finalError, label: "gemini:error", writeAssistantTextChunk(
}); writer,
errorText,
"gemini:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"gemini:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -9824,7 +9867,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "queued", phase: "queued",
providerType: "fal-image", providerType: "fal-image",
modelName: modelDisplayName, modelName: modelDisplayName,
@ -9931,7 +9973,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: alibabaModelConfig?.displayName || actualModelId, modelName: alibabaModelConfig?.displayName || actualModelId,
@ -10029,7 +10070,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -10164,7 +10204,6 @@ export async function POST(request: Request) {
chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError); chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError);
}); });
chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError); chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError);
if (!shouldFailoverAiModelError(crossError)) throw crossError;
} }
} }
} }
@ -10232,7 +10271,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: (languageModel as any).provider, providerType: (languageModel as any).provider,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10297,10 +10335,43 @@ export async function POST(request: Request) {
chatApiLogger.error("[Chat API] Alibaba generation error:", error); chatApiLogger.error("[Chat API] Alibaba generation error:", error);
// 返回错误消息 // 返回错误消息
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
messageId: generateUUID(), error, label: "alibaba:error", writeAssistantTextChunk(
}); writer,
errorText,
"alibaba:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"alibaba:image-end-failed",
);
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [],
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -10429,7 +10500,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: volcanoModelConfig?.displayName || actualModelId, modelName: volcanoModelConfig?.displayName || actualModelId,
@ -10475,7 +10545,6 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
if (!fallbackSuccess) { if (!fallbackSuccess) {
@ -10642,7 +10711,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
writer, writer,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: "volcano-vision", providerType: "volcano-vision",
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -10707,10 +10775,44 @@ 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);
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer, chatId: id, userMessageId: message.id, const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
messageId: generateUUID(), error, label: "volcano:error", writeAssistantTextChunk(
}); writer,
errorText,
"volcano:error",
errorMessageId,
);
// 🆕 图片生成结束标记(失败)
writeAssistantTextChunk(
writer,
"<!-- IMAGE_GENERATION_END -->",
"volcano:image-end-failed",
);
// 尝试保存错误消息
try {
await saveMessages({
messages: [
{
id: errorMessageId,
chatId: id,
role: "assistant",
parts: [{ type: "text", text: errorText }],
attachments: [], // 必须提供 attachments 字段
createdAt: new Date(),
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
});
} catch (saveError) {
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
}
} finally { } finally {
stopKeepalive?.(); stopKeepalive?.();
} }
@ -11283,7 +11385,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "processing", phase: "processing",
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType, providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
modelName: currentModelConfig?.displayName || actualModelId, modelName: currentModelConfig?.displayName || actualModelId,
@ -11375,7 +11476,6 @@ export async function POST(request: Request) {
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
} }
chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); chatApiLogger.error(`[Chat API] ❌ OpenAI-compatible fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
} }
} }
@ -11547,7 +11647,6 @@ export async function POST(request: Request) {
writeImageGenerationStatusChunk( writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter, dataStream as unknown as StreamWriter,
{ {
userMessageId: message.id,
phase: "completed", phase: "completed",
providerType: resolvedProviderType || providerType, providerType: resolvedProviderType || providerType,
modelName: usedModelDisplayName, modelName: usedModelDisplayName,
@ -11607,10 +11706,54 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error); chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
await reportImageGenerationFailure({ const errorMessageId = generateUUID();
writer: dataStream as unknown as StreamWriter, const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
chatId: id, userMessageId: message.id,
messageId: generateUUID(), error, label: "openai-compat:error", dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
}),
});
dataStream.write({
type: "data-appendMessage",
data: JSON.stringify({
id: generateUUID(),
role: "assistant",
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
}),
});
if (contextPayload?.source === "canvas") {
writeImageGenerationStatusChunk(
dataStream as unknown as StreamWriter,
{
phase: "failed",
providerType: resolvedProviderType || providerType,
modelName: currentModelConfig?.displayName || actualModelId,
imageCount: effectiveImageParams?.imageCount || 1,
},
"openai-compat:image-status-failed",
);
}
await saveMessages({
messages: [
{
id: errorMessageId,
role: "assistant",
parts: [{ type: "text", text: errorText }],
createdAt: new Date(),
attachments: [],
chatId: id,
videoStorageStatus: null,
temporaryVideoUrl: null,
permanentVideoUrl: null,
archivedAt: null,
videoMetadata: null,
},
],
}); });
return; return;
} }

View File

@ -1,5 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url";
@ -182,18 +182,6 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") {
return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) {
try {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString()));
} catch (error) {
if (error instanceof Error && error.message.includes("HTTP 410")) {
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
}
throw error;
}
}
if (!isAllowedModelHost(targetUrl.hostname)) { if (!isAllowedModelHost(targetUrl.hostname)) {
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
} }

View File

@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context"; import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry"; import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
@ -91,7 +90,7 @@ export async function POST(
const failure = getRequestFailureDetails(error, "执行画布能力失败"); const failure = getRequestFailureDetails(error, "执行画布能力失败");
return NextResponse.json( return NextResponse.json(
{ success: false, error: formatRequestFailure(failure, requestId), failure, requestId }, { success: false, error: formatRequestFailure(failure, requestId), failure, requestId },
{ status: error instanceof RmbgServiceError ? error.status : 500 } { status: 500 }
); );
} }
} }

View File

@ -1,15 +1,100 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout"; import {
BoxCutoutServiceError,
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
export async function POST(request: Request) { type BoxCutoutRequest = {
const session = await auth(); imageUrl?: string;
if (!session?.user?.id) { cropRect?: {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); x?: number;
y?: number;
width?: number;
height?: number;
};
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
export async function POST(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
}
const body = (await request.json()) as BoxCutoutRequest;
const rawImageUrl = String(body.imageUrl || "").trim();
if (!rawImageUrl) {
return NextResponse.json({ success: false, error: "缺少 imageUrl 参数" }, { status: 400 });
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id,
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
reason: "image_box_cutout",
modelId: "box-cutout",
note: "框选抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeBoxCutoutJob({
userId: session.user.id,
imageUrl: resolvedImageUrl,
cropRect: body.cropRect || {},
paddingRatio: body.paddingRatio,
prompt: body.prompt || "框选抠图",
chatId: body.chatId || null,
messageId: body.messageId || null,
skipGallerySave: body.skipGallerySave === true,
sourceNodeId: body.sourceNodeId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume box cutout points:", pointsError);
}
}
return NextResponse.json(result);
} catch (error) {
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
return NextResponse.json(
{
success: false,
error: error.detail || error.message,
},
{ status: error.status },
);
}
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "框选抠图失败",
},
{ status: 500 },
);
} }
return executeBoxCutoutPOST(request, session.user.id);
} }

View File

@ -1,9 +1,8 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error"; import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
export const runtime = "nodejs"; export const runtime = "nodejs";
const logger = createLogger("image.download"); const logger = createLogger("image.download");
@ -243,7 +242,6 @@ function resolveDirectImageTarget(
requestedQuality: number | null, requestedQuality: number | null,
requestedFormat: string | null requestedFormat: string | null
): URL | null { ): URL | null {
if (isAlibabaModelImageUrl(target.toString())) return null;
if (!isBrowserImageRequest(request)) { if (!isBrowserImageRequest(request)) {
return null; return null;
} }
@ -300,24 +298,6 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 });
} }
if (normalizeLegacyAliyunStorageUrl(target.toString())) {
try {
if (searchParams.get("original") === "1") {
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
buildImageProxyUrl(target.toString(), { delivery: "original" });
}
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
target = new URL(await resolveLegacyAliyunStorageUrl(target.toString()));
} catch (error) {
if (searchParams.get("original") === "1") return downloadFailure(error);
const failure = getDownloadFailure(error);
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId,
code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, {
status: failure.status, headers: { "Cache-Control": "no-store" },
});
}
}
if (isBlockedHost(target.hostname)) { if (isBlockedHost(target.hostname)) {
return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
} }
@ -338,25 +318,6 @@ export async function GET(request: NextRequest) {
); );
} }
if (!original && isAlibabaModelImageUrl(target.toString())) {
try {
const { getArchivedImageUrlMappings } = await import("@/lib/storage/image-archive-db");
const mappings = await getArchivedImageUrlMappings([target.toString()]);
const archived = mappings[target.toString()];
if (archived) {
const preview = buildImageProxyUrl(archived, {
width: width || undefined,
quality,
format: ["webp", "avif", "jpeg", "png"].includes(requestedFormat || "") ? requestedFormat as OutputFormat : undefined,
});
if (/^https?:\/\//i.test(preview)) return redirectToDirectImage(new URL(preview));
target = new URL(archived);
}
} catch (error) {
logger.warn("preview_archive_lookup_failed", error, { requestId });
}
}
if (original && searchParams.get("download") === "resolve") { if (original && searchParams.get("download") === "resolve") {
try { try {
const { buildImageProxyUrl } = await import("@/lib/image-delivery"); const { buildImageProxyUrl } = await import("@/lib/image-delivery");
@ -409,7 +370,7 @@ export async function GET(request: NextRequest) {
const fetchSignal = AbortSignal.any([ const fetchSignal = AbortSignal.any([
request.signal, request.signal,
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS), AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
]); ]);
const upstreamFailure = (error: unknown) => { const upstreamFailure = (error: unknown) => {
if (request.signal.aborted) return new NextResponse(null, { status: 499 }); if (request.signal.aborted) return new NextResponse(null, { status: 499 });
@ -420,51 +381,37 @@ export async function GET(request: NextRequest) {
{ status: timedOut ? 504 : 502 }, { status: timedOut ? 504 : 502 },
); );
}; };
let contentType: string; let upstreamResponse: Response;
let originalBuffer: Buffer; try {
if (!original && isAlibabaModelImageUrl(target.toString())) { upstreamResponse = await fetch(target.toString(), {
try { signal: fetchSignal,
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache"); headers: {
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS }); Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
originalBuffer = data.buffer; },
contentType = data.contentType; });
if (request.signal.aborted) return new NextResponse(null, { status: 499 }); } catch (error) {
if (!contentType.toLowerCase().startsWith("image/")) { return upstreamFailure(error);
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); }
}
} catch (error) {
return upstreamFailure(error);
}
} else {
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(target.toString(), {
signal: fetchSignal,
headers: {
Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
},
});
} catch (error) {
return upstreamFailure(error);
}
if (!upstreamResponse.ok) { if (!upstreamResponse.ok) {
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`); if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
return NextResponse.json( return NextResponse.json(
{ error: `Upstream responded with ${upstreamResponse.status}` }, { error: `Upstream responded with ${upstreamResponse.status}` },
{ status: 502 } { status: 502 }
); );
} }
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (!contentType.toLowerCase().startsWith("image/")) { const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
if (original) return downloadFailure("Source is not an image"); if (!contentType.toLowerCase().startsWith("image/")) {
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); if (original) return downloadFailure("Source is not an image");
} return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
try { }
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) { let originalBuffer: Buffer;
return upstreamFailure(error); try {
} originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
} catch (error) {
return upstreamFailure(error);
} }
if (original) { if (original) {
return buildImageResponse(originalBuffer, { return buildImageResponse(originalBuffer, {

View File

@ -6,7 +6,6 @@
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -70,6 +69,7 @@ const SCENE_FUSION_REQUEST_STAGGER_MS = 350;
const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000; const SCENE_FUSION_AGENT_TIMEOUT_MS = 20000;
const SCENE_FUSION_AGENT_MAX_WORKERS = 3; const SCENE_FUSION_AGENT_MAX_WORKERS = 3;
const SCENE_FUSION_PROCESS_MAX_WORKERS = 4; const SCENE_FUSION_PROCESS_MAX_WORKERS = 4;
const SCENE_FUSION_PRE_RMBG_TIMEOUT_MS = 60_000;
const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000; const SCENE_FUSION_ECHO_HASH_TIMEOUT_MS = 20_000;
const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8; const SCENE_FUSION_ECHO_HASH_THRESHOLD = 8;
const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000; const SCENE_FUSION_REVIEW_TIMEOUT_MS = 25_000;
@ -236,6 +236,41 @@ function isSceneFusionReviewRejectError(message: string): boolean {
); );
} }
function buildPreRmbgCandidateUrls(request: Request): string[] {
const urls: string[] = [];
const requestUrl = new URL(request.url);
const requestHost = requestUrl.hostname.toLowerCase();
const requestProtocol = requestUrl.protocol.replace(":", "");
if (!(requestProtocol === "https" && (requestHost === "localhost" || requestHost === "127.0.0.1"))) {
urls.push(new URL("/api/rmbg", request.url).toString());
}
const host = request.headers.get("host")?.trim();
const protoHint = request.headers
.get("x-forwarded-proto")
?.split(",")[0]
?.trim()
.toLowerCase();
const protocol = protoHint === "https" || protoHint === "http" ? protoHint : requestUrl.protocol.replace(":", "");
if (host) {
const normalizedHost = host.split(":")[0]?.toLowerCase() || "";
const isLocalHost = normalizedHost === "localhost" || normalizedHost === "127.0.0.1";
if (!(protocol === "https" && isLocalHost)) {
urls.push(`${protocol}://${host}/api/rmbg`);
}
if (isLocalHost) {
urls.push(`http://${host}/api/rmbg`);
}
}
const localPort = requestUrl.port || process.env.PORT || "3125";
urls.push(`http://127.0.0.1:${localPort}/api/rmbg`);
urls.push(`http://localhost:${localPort}/api/rmbg`);
return Array.from(new Set(urls));
}
function normalizeUrlForCompare(input: string): string { function normalizeUrlForCompare(input: string): string {
try { try {
const parsed = new URL(input); const parsed = new URL(input);
@ -953,6 +988,102 @@ async function getSceneFusionConfig() {
}; };
} }
async function preprocessProductImageWithRmbg(
request: Request,
requestId: string,
productImageUrl: string,
preRmbgEnabled: boolean
): Promise<{
productImageUrl: string;
usedPreRmbg: boolean;
provider?: string;
reason?: string;
}> {
if (!preRmbgEnabled) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "disabled_by_config",
};
}
const cookie = request.headers.get("cookie");
if (!cookie) {
return {
productImageUrl,
usedPreRmbg: false,
reason: "missing_cookie",
};
}
const candidateUrls = buildPreRmbgCandidateUrls(request);
let lastReason = "no_candidate_url";
logger.info("pre_rmbg_started", {
requestId,
candidateCount: candidateUrls.length,
});
for (const rmbgUrl of candidateUrls) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), SCENE_FUSION_PRE_RMBG_TIMEOUT_MS);
try {
const response = await fetch(rmbgUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie,
},
body: JSON.stringify({
imageUrl: productImageUrl,
prompt: "场景裂变预处理-抠图",
skipGallerySave: true,
}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null) as Record<string, unknown> | null;
if (!response.ok) {
lastReason = `request_failed_${response.status}`;
logger.warn("pre_rmbg_candidate_failed", {
requestId,
status: response.status,
detail: payload?.detail || payload?.error || "unknown",
});
continue;
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
lastReason = cleanedUrl ? "same_as_source" : "empty_result";
logger.warn("pre_rmbg_candidate_invalid", {
requestId,
reason: lastReason,
});
continue;
}
return {
productImageUrl: cleanedUrl,
usedPreRmbg: true,
provider: String(payload?.provider || "unknown"),
};
} catch (error) {
lastReason = error instanceof Error ? error.message : "exception";
logger.warn("pre_rmbg_candidate_exception", error, { requestId });
} finally {
clearTimeout(timeoutId);
}
}
return {
productImageUrl,
usedPreRmbg: false,
reason: lastReason,
};
}
/** /**
* 创建缩略图 * 创建缩略图
*/ */
@ -2305,13 +2436,12 @@ export async function POST(request: Request) {
productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product"); productImageUrl = await uploadBase64ToS3(productImage, "scene-fusion-product");
} }
const shouldForcePreRmbg = resolvedSceneMode === "random-bg"; const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
const preprocessedProduct = await preprocessProductImageWithRmbg({ const preprocessedProduct = await preprocessProductImageWithRmbg(
request, request,
requestId, requestId,
userId: session.user.id,
productImageUrl, productImageUrl,
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled, shouldForcePreRmbg ? true : config.preRmbgEnabled
}); );
const fusionProductImageUrl = preprocessedProduct.productImageUrl; const fusionProductImageUrl = preprocessedProduct.productImageUrl;
logger.info("pre_rmbg_resolved", { logger.info("pre_rmbg_resolved", {
requestId, requestId,

View File

@ -5196,7 +5196,6 @@ export default function CanvasEditorLegacyPage({
} }
try { try {
const beforeRead = useCanvasStore.getState();
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, { const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
cache: "no-store", cache: "no-store",
}); });
@ -5217,11 +5216,7 @@ export default function CanvasEditorLegacyPage({
} }
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
const current = useCanvasStore.getState(); if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
if (
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
) return;
useCanvasStore.getState().markAgentOperationRefresh(); useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data); hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now(); lastLocalCanvasMutationAtRef.current = Date.now();

View File

@ -48,13 +48,8 @@ export default function CanvasStyleProPage() {
const canvasData = result.data.canvasData; const canvasData = result.data.canvasData;
const latestState = useCanvasStore.getState(); const latestState = useCanvasStore.getState();
setProject(result.data.id, result.data.title); setProject(result.data.id, result.data.title);
if ( if (latestState.nodes.length === 0 && latestState.edges.length === 0) {
latestState.projectId !== id ||
(latestState.nodes.length === 0 && latestState.edges.length === 0)
) {
loadCanvas(canvasData); loadCanvas(canvasData);
const revision = String(result.data.updatedAt || "");
useCanvasStore.getState().setCanvasRevision(revision, revision);
return; return;
} }

View File

@ -67,7 +67,6 @@ 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 { getGatewayErrorPageMessage } from "@/lib/request-error";
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { import {
fetchUploadWithRetry, fetchUploadWithRetry,
@ -82,8 +81,7 @@ import { canvasService } from "@/services/canvas";
import { import {
inferOriginalImageUrl, inferOriginalImageUrl,
} from "@/lib/image-url"; } from "@/lib/image-url";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -3341,7 +3339,6 @@ export function CanvasChatPanel({
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId); const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
const activeMessagesRef = useRef<ChatMessage[]>([]); const activeMessagesRef = useRef<ChatMessage[]>([]);
const inFlightChatIdRef = useRef<string | null>(null); const inFlightChatIdRef = useRef<string | null>(null);
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
const inFlightRawChatIdRef = useRef<string | null>(null); const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]); const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
@ -3437,7 +3434,6 @@ export function CanvasChatPanel({
const [fullscreenPreview, setFullscreenPreview] = useState<{ const [fullscreenPreview, setFullscreenPreview] = useState<{
url: string; url: string;
alt?: string; alt?: string;
preview: LoadedImagePreview;
} | null>(null); } | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
@ -3475,6 +3471,16 @@ export function CanvasChatPanel({
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState(""); const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState(""); const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
const fullscreenPreviewSrc = useMemo(() => {
if (!fullscreenPreview?.url) return "";
const originalUrl = inferOriginalImageUrl(fullscreenPreview.url);
return buildImageProxyUrl(originalUrl || fullscreenPreview.url, {
delivery: "proxy",
width: 2400,
quality: 90,
format: "webp",
});
}, [fullscreenPreview]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (composerUploadClearTimeoutRef.current) { if (composerUploadClearTimeoutRef.current) {
@ -3644,20 +3650,17 @@ export function CanvasChatPanel({
const setStreamImageGenerationStateForChat = useCallback( const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
setStreamImageGenerationStateByChatId((prev) => ({ setStreamImageGenerationStateByChatId((prev) => ({
...prev, ...prev,
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null, [chatSessionId]: next,
})); }));
}, },
[] []
); );
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => { const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
setStreamImageGenerationStateByChatId((prev) => { setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev; if (!(chatSessionId in prev)) return prev;
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
const next = { ...prev }; const next = { ...prev };
delete next[chatSessionId]; delete next[chatSessionId];
return next; return next;
@ -6094,7 +6097,7 @@ export function CanvasChatPanel({
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video") const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
? "agent" : rawComposerMode; ? "agent" : rawComposerMode;
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent"; const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
const [imageAgentEnabled, setImageAgentEnabled] = useState(true); const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
const imageAgentEnabledRef = useRef(imageAgentEnabled); const imageAgentEnabledRef = useRef(imageAgentEnabled);
imageAgentEnabledRef.current = imageAgentEnabled; imageAgentEnabledRef.current = imageAgentEnabled;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video"; const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
@ -8023,8 +8026,6 @@ export function CanvasChatPanel({
api: "/api/chat", api: "/api/chat",
fetch: fetchWithErrorHandlers, fetch: fetchWithErrorHandlers,
async prepareSendMessagesRequest(request) { async prepareSendMessagesRequest(request) {
const userMessageId = request.messages.findLast((item) => item.role === "user")?.id;
if (userMessageId) imageGenerationUserMessageIdsRef.current[request.id] = userMessageId;
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID // 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-') const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '') ? request.id.replace('canvas-', '')
@ -8137,27 +8138,20 @@ export function CanvasChatPanel({
const payload: ImageGenerationStatusStreamPayload = const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
if (payload.phase === "completed") { if (payload.phase === "completed") {
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: "finalizing", phase: "finalizing",
userMessageId: payload.userMessageId,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
}); });
window.setTimeout(() => { window.setTimeout(() => {
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
clearStreamImageGenerationStateForChat(eventChatId); clearStreamImageGenerationStateForChat(eventChatId);
}, 30_000); }, 30_000);
return; return;
} }
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: payload.phase || "processing", phase: payload.phase || "processing",
userMessageId: payload.userMessageId,
requestId: payload.requestId,
failure: payload.failure,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
@ -8425,11 +8419,6 @@ export function CanvasChatPanel({
) )
: false; : false;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const failureStatus = findImageGenerationFailure(
[{ role: messageData.role, parts: normalizedParts }],
imageGenerationUserMessageIdsRef.current[eventChatId],
);
if (failureStatus) setStreamImageGenerationStateForChat(eventChatId, failureStatus);
const normalizedAttachments = Array.isArray(messageData.attachments) const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments ? messageData.attachments
: []; : [];
@ -8498,7 +8487,7 @@ export function CanvasChatPanel({
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
!shouldDeferClearForActiveMedia !shouldDeferClearForActiveMedia
) { ) {
clearStreamImageGenerationStateForChat(eventChatId, true); clearStreamImageGenerationStateForChat(eventChatId);
} }
if (!isEventForActiveChat) { if (!isEventForActiveChat) {
@ -9221,7 +9210,7 @@ export function CanvasChatPanel({
useEffect(() => { useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return; if (!rawChatId || !streamState) return;
let cancelled = false; let cancelled = false;
const poll = async () => { const poll = async () => {
@ -9245,13 +9234,6 @@ export function CanvasChatPanel({
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!streamState || messages.length === 0) return; if (!streamState || messages.length === 0) return;
const failure = findImageGenerationFailure(messages, streamState.userMessageId || "");
if (failure && streamState.phase !== "failed") {
setStreamImageGenerationStateForChat(activeChatId, failure);
return;
}
if (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown") return;
const lastUserIndex = [...messages] const lastUserIndex = [...messages]
.map((message) => message.role) .map((message) => message.role)
.lastIndexOf("user"); .lastIndexOf("user");
@ -9268,7 +9250,8 @@ export function CanvasChatPanel({
.join("\n"); .join("\n");
return ( return (
extractAssistantImageUrls(textContent).length > 0 || extractAssistantImageUrls(textContent).length > 0 ||
extractVideoUrls(textContent).length > 0 extractVideoUrls(textContent).length > 0 ||
/失败|超时|重试|error|failed/i.test(textContent)
); );
}); });
@ -9289,7 +9272,6 @@ export function CanvasChatPanel({
messages, messages,
streamImageGenerationStateByChatId, streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat, clearStreamImageGenerationStateForChat,
setStreamImageGenerationStateForChat,
]); ]);
useEffect(() => { useEffect(() => {
@ -13354,7 +13336,6 @@ export function CanvasChatPanel({
}); });
inFlightChatIdRef.current = activeChatId; inFlightChatIdRef.current = activeChatId;
inFlightRawChatIdRef.current = rawChatId; inFlightRawChatIdRef.current = rawChatId;
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
clearStreamImageGenerationStateForChat(activeChatId); clearStreamImageGenerationStateForChat(activeChatId);
clearAgentProgressStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId);
if (shouldShowOptimisticImageGeneration) { if (shouldShowOptimisticImageGeneration) {
@ -15124,7 +15105,7 @@ export function CanvasChatPanel({
!hasGenerationEnd && !hasGenerationEnd &&
Boolean(currentModel || activeComposerMode === "agent")); Boolean(currentModel || activeComposerMode === "agent"));
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter; const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1; const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType = const loadingProviderType =
streamImageGenerationState?.providerType || streamImageGenerationState?.providerType ||
@ -15187,9 +15168,9 @@ export function CanvasChatPanel({
phase === "queued" phase === "queued"
? "等待开始生成" ? "等待开始生成"
: phase === "finalizing" : phase === "finalizing"
? "正在保存生成结果" ? "正在润饰细节"
: phase === "failed" : phase === "failed"
? streamState.failure?.message || "生成失败" ? "生成失败"
: "正在创建图片"; : "正在创建图片";
const status = phase === "failed" ? "failed" : "generating"; const status = phase === "failed" ? "failed" : "generating";
const currentNodes = useCanvasStore.getState().nodes; const currentNodes = useCanvasStore.getState().nodes;
@ -15245,10 +15226,14 @@ export function CanvasChatPanel({
canvasGenerationPlaceholder: true, canvasGenerationPlaceholder: true,
chatId: activeChatId, chatId: activeChatId,
detailProgressLabel: label, detailProgressLabel: label,
generationProgress: undefined, generationProgress:
generationFailure: streamState.failure, phase === "queued"
generationRequestId: streamState.requestId, ? 12
generationUserMessageId: streamState.userMessageId, : phase === "finalizing"
? 96
: phase === "failed"
? 100
: undefined,
modelName: streamState.modelName || currentModel?.displayName || undefined, modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined,
placementMode: "anchored-generation", placementMode: "anchored-generation",
@ -15342,7 +15327,6 @@ export function CanvasChatPanel({
if ( if (
message.role === "assistant" && message.role === "assistant" &&
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
isInternalCanvasImageGenerationFailureText(cleanedParts) isInternalCanvasImageGenerationFailureText(cleanedParts)
) { ) {
return acc; return acc;
@ -15828,8 +15812,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) { if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl); event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
} }
}} }}
/> />
@ -16147,8 +16131,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) { if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url); event.currentTarget.src = attachment.url;
} }
}} }}
/> />
@ -16224,8 +16208,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) { if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url); event.currentTarget.src = attachment.url;
} }
}} }}
/> />
@ -16410,8 +16394,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) { if (event.currentTarget.src !== tag.iconUrl) {
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl); event.currentTarget.src = tag.iconUrl || "";
} }
}} }}
/> />
@ -16711,8 +16695,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) { if (event.currentTarget.src !== attachment.url) {
event.currentTarget.src = buildImageFallbackUrl(attachment.url); event.currentTarget.src = attachment.url;
} }
}} }}
/> />
@ -17048,23 +17032,15 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(url)) { if (event.currentTarget.src !== url) {
event.currentTarget.src = buildImageFallbackUrl(url); event.currentTarget.src = url;
} }
}} }}
onClick={(event) => setFullscreenPreview({ url, preview: { onClick={() => setFullscreenPreview({ url })}
src: event.currentTarget.currentSrc || event.currentTarget.src,
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
} })}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setFullscreenPreview({ url, preview: { setFullscreenPreview({ url });
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
} });
}} }}
/> />
<div className="absolute bottom-2 right-2 flex items-center gap-1.5"> <div className="absolute bottom-2 right-2 flex items-center gap-1.5">
@ -17310,7 +17286,7 @@ export function CanvasChatPanel({
onClick={() => setFullscreenPreview(null)} onClick={() => setFullscreenPreview(null)}
> >
<button <button
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors" className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
onClick={() => setFullscreenPreview(null)} onClick={() => setFullscreenPreview(null)}
aria-label="关闭预览" aria-label="关闭预览"
> >
@ -17319,7 +17295,7 @@ export function CanvasChatPanel({
</svg> </svg>
</button> </button>
<button <button
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65" className="absolute top-4 right-16 inline-flex items-center gap-1 rounded-full bg-white/10 px-3 py-2 text-sm text-white transition-colors hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-65"
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
@ -17336,11 +17312,13 @@ export function CanvasChatPanel({
{isDownloadingOriginalImage ? "下载中" : "下载原图"} {isDownloadingOriginalImage ? "下载中" : "下载原图"}
</button> </button>
<ProgressiveImagePreview <img
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url} src={fullscreenPreviewSrc}
preview={fullscreenPreview.preview}
alt={fullscreenPreview.alt || "图片预览"} alt={fullscreenPreview.alt || "图片预览"}
className="max-w-[95vw] max-h-[95vh] object-contain" className="max-w-[95vw] max-h-[95vh] object-contain"
decoding="async"
draggable={false}
onClick={(e) => e.stopPropagation()}
/> />
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/60 text-sm">
@ -17907,8 +17885,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) { if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url); event.currentTarget.src = slotAttachment.url;
} }
}} }}
/> />
@ -18232,8 +18210,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) { if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl); event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
} }
}} }}
/> />
@ -18492,8 +18470,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) { if (event.currentTarget.src !== item.url) {
event.currentTarget.src = buildImageFallbackUrl(item.url); event.currentTarget.src = item.url;
} }
}} }}
/> />
@ -18677,8 +18655,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) { if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url); event.currentTarget.src = slotAttachment.url;
} }
}} }}
/> />
@ -18745,8 +18723,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) { if (event.currentTarget.src !== slotAttachment.url) {
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url); event.currentTarget.src = slotAttachment.url;
} }
}} }}
/> />
@ -19445,8 +19423,8 @@ export function CanvasChatPanel({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) { if (event.currentTarget.src !== tagPreview.url) {
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url); event.currentTarget.src = tagPreview.url;
} }
}} }}
/> />

View File

@ -152,7 +152,16 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
); );
} }
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status"; export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export type AgentTargetLockRefPreview = { export type AgentTargetLockRefPreview = {
url: string; url: string;

View File

@ -448,7 +448,7 @@ function extractChatMediaUrls(text: string, kind: ChatMediaKind): string[] {
} }
} }
const markdownLinkPattern = /(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi; const markdownLinkPattern = /\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi;
while ((match = markdownLinkPattern.exec(text)) !== null) { while ((match = markdownLinkPattern.exec(text)) !== null) {
const label = String(match[1] || ""); const label = String(match[1] || "");
const hintedKind = /音频|语音|声音|audio|mp3/i.test(label) const hintedKind = /音频|语音|声音|audio|mp3/i.test(label)
@ -491,7 +491,7 @@ export function stripMediaMarkup(text: string): string {
.replace(/<audio[\s\S]*?<\/audio>/gi, "") .replace(/<audio[\s\S]*?<\/audio>/gi, "")
.replace(/<audio\b[^>]*\/?\s*>/gi, "") .replace(/<audio\b[^>]*\/?\s*>/gi, "")
.replace(/<a[\s\S]*?<\/a>/gi, "") .replace(/<a[\s\S]*?<\/a>/gi, "")
.replace(/(?<!!)\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => { .replace(/\[([^\]]*)]\((https?:\/\/[^)\s]+)\)/gi, (match, label, url) => {
const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test( const hintedMedia = /音频|语音|声音|audio|mp3|视频|影片|播放|video/i.test(
String(label || "") String(label || "")
); );

View File

@ -1,6 +1,4 @@
"use client"; "use client";
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { import {
@ -62,7 +60,6 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar"; import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon"; import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
import { import {
areImageAdjustmentsEqual, areImageAdjustmentsEqual,
buildImageAdjustmentFilterStyle, buildImageAdjustmentFilterStyle,
@ -189,7 +186,7 @@ import {
normalizeDetailUiSubjectAnalysis, normalizeDetailUiSubjectAnalysis,
} from "@/lib/detail-ui-subject-analysis"; } from "@/lib/detail-ui-subject-analysis";
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter"; import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery"; import { buildImageProxyUrl } from "@/lib/image-delivery";
import { import {
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG, DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
getDefaultThreeDModelAliasId, getDefaultThreeDModelAliasId,
@ -1714,7 +1711,6 @@ export function ImageNode({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [annotatorOpen, setAnnotatorOpen] = useState(false); const [annotatorOpen, setAnnotatorOpen] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false); const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
const [downloadProgress, setDownloadProgress] = const [downloadProgress, setDownloadProgress] =
useState<CanvasImageDownloadProgress | null>(null); useState<CanvasImageDownloadProgress | null>(null);
const downloadAbortControllerRef = useRef<AbortController | null>(null); const downloadAbortControllerRef = useRef<AbortController | null>(null);
@ -2504,13 +2500,13 @@ export function ImageNode({
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return canvasImagePreviewWidth(Math.max( return Math.max(
minWidth, minWidth,
Math.min( Math.min(
maxWidth, maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
) )
), maxWidth); );
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo( const displayImageSrc = useMemo(
@ -4685,44 +4681,106 @@ export function ImageNode({
return; return;
} }
if (!projectId || !runCanvasRemoteOperation) { setIsCreatingDetachedResultNode(true);
toast.error("画布还在加载,请稍后再试");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true); setIsProcessing(true);
const actionName = "框选抠图"; const actionName = "框选抠图";
const newNodeId = addNode({
try { type: "image",
const command = createCanvasCapabilityClientCommand({ position: {
capabilityId: "image.box_cutout", x: node.position.x + node.size.width + 20,
nodeIds: [node.id], y: node.position.y,
projectId, },
source: "toolbar", size: { width: node.size.width, height: node.size.height },
args: { data: {
...boxCutoutSelection, src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
}, },
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
}); });
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
toast.success(`${actionName}完成`); if (result.success && result.data?.url) {
updateNode(newNodeId, {
data: {
src: result.data.url,
status: "completed",
prompt: `${actionName}结果`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
cropRectApplied: result.data.cropRectApplied,
paddedCropRectApplied: result.data.paddedCropRectApplied,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
editMode: "box-cutout",
},
},
});
toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) { } catch (error) {
console.error("框选抠图失败:", error); console.error("框选抠图失败:", error);
toast.error(error instanceof Error ? error.message : `${actionName}失败`); updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally { } finally {
setIsCreatingDetachedResultNode(false); setIsCreatingDetachedResultNode(false);
setIsProcessing(false); setIsProcessing(false);
resetBoxCutoutState(); resetBoxCutoutState();
} }
}, [ }, [
addNode,
boxCutoutSelection, boxCutoutSelection,
data.src, data.src,
displayTitle,
isProcessing, isProcessing,
node.id, node.id,
projectId, node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState, resetBoxCutoutState,
runCanvasRemoteOperation, updateNode,
]); ]);
const animateAddToChat = useCallback(async (previewUrl?: string) => { const animateAddToChat = useCallback(async (previewUrl?: string) => {
@ -8401,16 +8459,15 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { if (event.currentTarget.src !== data.src) {
event.currentTarget.src = buildImageFallbackUrl(data.src); event.currentTarget.src = data.src;
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -8431,16 +8488,15 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={compareSource}
alt={`${displayTitle}-原图`} alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) { if (event.currentTarget.src !== compareSource) {
event.currentTarget.src = buildImageFallbackUrl(compareSource); event.currentTarget.src = compareSource;
} }
}} }}
src={displayCompareSrc} src={displayCompareSrc}
@ -8482,8 +8538,7 @@ export function ImageNode({
</div> </div>
) : ( ) : (
<> <>
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className={cn( className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900", "h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
@ -8495,7 +8550,7 @@ export function ImageNode({
)} )}
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onClick={ onClick={
isThisNodePicking && !boxCutoutMode isThisNodePicking && !boxCutoutMode
? handlePickerClick ? handlePickerClick
@ -8504,17 +8559,12 @@ export function ImageNode({
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) { if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
e.stopPropagation(); e.stopPropagation();
setFullscreenPreviewImage({
src: e.currentTarget.currentSrc || e.currentTarget.src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
});
setFullscreenPreviewOpen(true); setFullscreenPreviewOpen(true);
} }
}} }}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { if (event.currentTarget.src !== data.src) {
event.currentTarget.src = buildImageFallbackUrl(data.src); event.currentTarget.src = data.src;
} }
}} }}
onLoad={handleDisplayImageLoad} onLoad={handleDisplayImageLoad}
@ -9425,8 +9475,6 @@ export function ImageNode({
) : ( ) : (
<ImageNodeEmptyState <ImageNodeEmptyState
detailProgressLabel={detailProgressLabel} detailProgressLabel={detailProgressLabel}
failure={data.metadata?.generationFailure}
requestId={data.metadata?.generationRequestId}
fileInputRef={fileInputRef} fileInputRef={fileInputRef}
generationProgress={generationProgress} generationProgress={generationProgress}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}
@ -11916,7 +11964,6 @@ export function ImageNode({
{/* 双击放大预览弹窗 */} {/* 双击放大预览弹窗 */}
{fullscreenPreviewOpen && {fullscreenPreviewOpen &&
data.src && data.src &&
fullscreenPreviewImage &&
createPortal( createPortal(
<div <div
className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90" className="fixed inset-0 z-[9999] flex cursor-zoom-out items-center justify-center bg-black/90"
@ -11924,7 +11971,7 @@ export function ImageNode({
> >
{/* 关闭按钮 */} {/* 关闭按钮 */}
<button <button
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black" className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
onClick={() => setFullscreenPreviewOpen(false)} onClick={() => setFullscreenPreviewOpen(false)}
> >
<svg <svg
@ -11942,7 +11989,7 @@ export function ImageNode({
</svg> </svg>
</button> </button>
<button <button
className="absolute top-4 right-16 z-10 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60" className="absolute top-4 right-16 inline-flex items-center gap-1.5 rounded-full bg-slate-950 px-3 py-2 text-sm text-white transition-colors hover:bg-black disabled:cursor-not-allowed disabled:opacity-60"
disabled={isDownloadingOriginalImage} disabled={isDownloadingOriginalImage}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
@ -11961,11 +12008,13 @@ export function ImageNode({
</button> </button>
{/* 图片 */} {/* 图片 */}
<ProgressiveImagePreview <img
alt={data.prompt || "图片预览"} alt={data.prompt || "图片预览"}
className="max-h-[95vh] max-w-[95vw] object-contain" className="max-h-[95vh] max-w-[95vw] object-contain"
src={resolveOriginalImageUrl(data.src) || data.src} decoding="async"
preview={fullscreenPreviewImage} draggable={false}
onClick={(e) => e.stopPropagation()}
src={fullscreenPreviewSrc}
style={activeImageAdjustmentsStyle} style={activeImageAdjustmentsStyle}
/> />

View File

@ -1,32 +0,0 @@
"use client";
import { forwardRef, type ImgHTMLAttributes, useState } from "react";
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & { src: string; sourceKey: string };
export const CanvasPreviewImage = forwardRef<HTMLImageElement, Props>(function CanvasPreviewImage(
{ src, sourceKey, onLoad, style, ...props }, ref,
) {
const [displayed, setDisplayed] = useState({ sourceKey, src });
const visibleSrc = displayed.sourceKey === sourceKey ? displayed.src : src;
const sources = visibleSrc === src ? [src] : [visibleSrc, src];
return <>{sources.map(candidate => {
const visible = candidate === visibleSrc;
return <img
{...props}
key={candidate}
ref={visible ? ref : undefined}
src={candidate}
alt={visible ? props.alt : ""}
aria-hidden={visible ? props["aria-hidden"] : true}
style={visible ? style : { ...style, position: "absolute", inset: 0, opacity: 0, pointerEvents: "none" }}
onLoad={event => {
const image = event.currentTarget;
onLoad?.(event);
void image.decode().then(() => {
if (image.isConnected && candidate === src) setDisplayed({ sourceKey, src: candidate });
}).catch(() => undefined);
}}
/>;
})}</>;
});

View File

@ -3,14 +3,11 @@
import { AlertCircle, ImagePlus, Loader2 } from "lucide-react"; import { AlertCircle, ImagePlus, Loader2 } from "lucide-react";
import type React from "react"; import type React from "react";
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder"; import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
import type { RequestFailureDetails } from "@/lib/request-error";
interface ImageNodeEmptyStateProps { interface ImageNodeEmptyStateProps {
detailProgressLabel?: string; detailProgressLabel?: string;
fileInputRef: React.Ref<HTMLInputElement>; fileInputRef: React.Ref<HTMLInputElement>;
generationProgress?: number | null; generationProgress?: number | null;
failure?: RequestFailureDetails;
requestId?: string;
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void; onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void; onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
status?: string; status?: string;
@ -55,8 +52,6 @@ export function ImageNodeEmptyState({
detailProgressLabel, detailProgressLabel,
fileInputRef, fileInputRef,
generationProgress, generationProgress,
failure,
requestId,
onImageUpload, onImageUpload,
onSelectFile, onSelectFile,
status, status,
@ -94,7 +89,7 @@ export function ImageNodeEmptyState({
if (status === "failed") { if (status === "failed") {
const failedTitle = detailProgressLabel || "生成失败"; const failedTitle = detailProgressLabel || "生成失败";
return ( return (
<div role="alert" className="flex h-full w-full flex-col items-center justify-center overflow-auto bg-[#fff7f8] px-6 text-center dark:bg-red-950/40"> <div className="flex h-full w-full select-none flex-col items-center justify-center bg-[#fff7f8] px-6 text-center dark:bg-red-950/40">
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]"> <span className="mb-3 flex h-12 w-12 items-center justify-center rounded-[18px] border border-[#f2d7dd] bg-white text-[#d24f63] shadow-[0_0_0_1px_rgba(242,215,221,0.22)]">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
</span> </span>
@ -102,9 +97,8 @@ export function ImageNodeEmptyState({
{failedTitle} {failedTitle}
</span> </span>
<span className="mt-1 text-xs text-[#b77480]"> <span className="mt-1 text-xs text-[#b77480]">
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")} {failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
</span> </span>
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ import {
splitOpenClawChartBlocks, splitOpenClawChartBlocks,
} from "@/components/elements/openclaw-chart-card"; } from "@/components/elements/openclaw-chart-card";
import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download"; import { ResponsePdfDownloadButton } from "@/components/elements/response-pdf-download";
import { buildImageFallbackUrl, 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 { getGatewayErrorPageMessage } from "@/lib/request-error";
@ -442,8 +442,8 @@ function MarkdownTableImagePreview({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(src)) { if (event.currentTarget.src !== src) {
event.currentTarget.src = buildImageFallbackUrl(src); event.currentTarget.src = src;
} }
}} }}
/> />
@ -473,8 +473,8 @@ function MarkdownTableImagePreview({
decoding="async" decoding="async"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) { if (event.currentTarget.src !== originalSrc) {
event.currentTarget.src = buildImageFallbackUrl(originalSrc); event.currentTarget.src = originalSrc;
} }
}} }}
/> />

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { BloubAvatar } from "@/components/bloub-avatar"; import { BloubAvatar } from "@/components/bloub-avatar";
import type { StateId } from "@/lib/bloub-official/states"; import type { StateId } from "@/lib/bloub-official/states";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -25,17 +26,193 @@ interface GenerationDotPlaceholderProps {
const PHASE_TITLES: Record<GenerationPhase, string> = { const PHASE_TITLES: Record<GenerationPhase, string> = {
idle: "正在创建图片", idle: "正在创建图片",
queued: "等待开始生成", queued: "等待开始生成",
processing: "等待模型返回图片", processing: "生成初稿中",
finalizing: "正在保存生成结果", finalizing: "正在润饰细节",
failed: "生成失败", failed: "生成失败",
completed: "生成完成", completed: "生成完成",
}; };
const GENERATION_STATUS_TITLES = [
"正在理解画面关系",
"正在读取参考图片",
"正在分析主体轮廓",
"正在识别关键元素",
"正在整理构图线索",
"正在确认画面比例",
"正在匹配视觉风格",
"正在提取材质细节",
"正在梳理光影方向",
"正在构建初始画面",
"正在生成第一版构图",
"正在安排主体位置",
"正在校准画面重心",
"正在补全背景层次",
"正在保持主体一致",
"正在优化边缘结构",
"正在调整透视关系",
"正在细化空间层次",
"正在增强画面清晰度",
"正在统一色彩倾向",
"正在检查参考一致性",
"正在处理局部细节",
"正在补充纹理信息",
"正在修正不自然区域",
"正在平衡明暗对比",
"正在优化产品质感",
"正在调整阴影落点",
"正在整理背景干扰",
"正在增强真实感",
"正在重绘细小结构",
"正在合成高清结果",
"正在检查主体完整性",
"正在优化画面边缘",
"正在处理反光细节",
"正在提升材质表现",
"正在微调构图节奏",
"正在清理多余噪点",
"正在强化视觉焦点",
"正在校正颜色偏差",
"正在保留参考特征",
"正在生成高分辨率版本",
"正在压实画面层次",
"正在修饰局部瑕疵",
"正在检查文字与图形",
"正在稳定画面结构",
"正在优化细节密度",
"正在完善背景过渡",
"正在增强主体质感",
"正在统一整体光感",
"正在检查比例关系",
"正在修正边缘锯齿",
"正在提升清晰观感",
"正在处理最后细节",
"正在准备预览结果",
"正在同步生成进度",
"正在确认输出尺寸",
"正在整理结果文件",
"正在校验图片可用性",
"正在生成最终预览",
"正在完成高清渲染",
"正在保存生成结果",
"正在写入画布节点",
"正在同步到对话框",
"正在检查结果完整性",
"正在收尾生成任务",
"正在等待服务商返回",
"正在保持连接状态",
"正在确认生成结果",
"正在拉取图片地址",
"正在准备展示图片",
"正在完成最后处理",
"正在刷新画布内容",
"正在整理图片信息",
"正在完成本次生成",
"正在即将展示结果",
];
const LONG_WAIT_TITLE_THRESHOLD_MS = 5 * 60 * 1000;
const LONG_WAIT_STATUS_TITLES = [
"服务繁忙,排队处理中",
"高峰期生成较慢,请耐心等候",
"仍在保持连接,等待模型返回",
"结果返回后会自动展示",
];
const STATUS_TITLE_DURATIONS_MS = GENERATION_STATUS_TITLES.map((_, index) => {
const rhythm = [3600, 5200, 4100, 6100, 3300, 4700, 5700, 3900, 6800, 4400];
return rhythm[index % rhythm.length];
});
const TYPEWRITER_START_DELAY_MS = 120;
const TYPEWRITER_BASE_STEP_MS = 46;
function clampProgress(value: number) { function clampProgress(value: number) {
if (!Number.isFinite(value)) return 0; if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value))); return Math.max(0, Math.min(100, Math.round(value)));
} }
function estimateProgress(phase: GenerationPhase, elapsedMs: number) {
if (phase === "completed" || phase === "failed") return 100;
if (phase === "finalizing") return 96;
if (phase === "queued") {
return clampProgress(6 + Math.min(12, elapsedMs / 3500));
}
if (phase === "processing") {
const eased = 1 - Math.exp(-elapsedMs / 85_000);
return clampProgress(18 + eased * 78);
}
return clampProgress(Math.min(8, elapsedMs / 1000));
}
function getStatusTitleState(elapsedMs: number, initialTitle?: string) {
const titles = initialTitle
? [
initialTitle,
...GENERATION_STATUS_TITLES.filter((item) => item !== initialTitle),
]
: GENERATION_STATUS_TITLES;
const durations = initialTitle
? [4200, ...STATUS_TITLE_DURATIONS_MS]
: STATUS_TITLE_DURATIONS_MS;
let cursor = Math.max(0, elapsedMs);
for (let index = 0; index < titles.length; index += 1) {
const duration = durations[index] || 4600;
if (cursor < duration) {
return {
elapsedInTitle: cursor,
index,
title:
titles[index] ||
titles[titles.length - 1] ||
PHASE_TITLES.processing,
};
}
cursor -= duration;
}
return {
elapsedInTitle: durations[durations.length - 1] || 4600,
index: titles.length - 1,
title: titles[titles.length - 1] || PHASE_TITLES.processing,
};
}
function getLongWaitTitleState(elapsedMs: number, initialTitle?: string) {
const titles = initialTitle
? [
initialTitle,
...LONG_WAIT_STATUS_TITLES.filter((item) => item !== initialTitle),
]
: LONG_WAIT_STATUS_TITLES;
const elapsedAfterThreshold = Math.max(
0,
elapsedMs - LONG_WAIT_TITLE_THRESHOLD_MS
);
const duration = 7600;
const index = Math.floor(elapsedAfterThreshold / duration) % titles.length;
return {
elapsedInTitle: elapsedAfterThreshold % duration,
index,
title: titles[index] || titles[0] || PHASE_TITLES.processing,
};
}
function getTypewriterText(text: string, elapsedInTitle: number, index: number) {
const chars = Array.from(text);
if (chars.length === 0) return "";
const step = TYPEWRITER_BASE_STEP_MS + (index % 5) * 8;
const visibleCount = Math.max(
1,
Math.min(
chars.length,
Math.floor(
Math.max(0, elapsedInTitle - TYPEWRITER_START_DELAY_MS) / step
) + 1
)
);
return chars.slice(0, visibleCount).join("");
}
function getBloubState(phase: GenerationPhase): StateId { function getBloubState(phase: GenerationPhase): StateId {
if (phase === "failed") return "alert"; if (phase === "failed") return "alert";
if (phase === "completed") return "wink"; if (phase === "completed") return "wink";
@ -51,10 +228,39 @@ export function GenerationDotPlaceholder({
showProgress = true, showProgress = true,
title, title,
}: GenerationDotPlaceholderProps) { }: GenerationDotPlaceholderProps) {
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress) const [elapsedMs, setElapsedMs] = useState(0);
? clampProgress(progress) : null;
const resolvedTitle = title || PHASE_TITLES[phase]; useEffect(() => {
const startedAt = Date.now();
const timer = window.setInterval(() => {
setElapsedMs(Date.now() - startedAt);
}, 90);
return () => window.clearInterval(timer);
}, [phase]);
const resolvedProgress = clampProgress(
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
);
const isLongWaitTitle =
(phase === "processing" || phase === "finalizing") &&
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
const statusTitleState = isLongWaitTitle
? getLongWaitTitleState(elapsedMs, title)
: getStatusTitleState(elapsedMs, title);
const rotatingTitle = getTypewriterText(
statusTitleState.title,
statusTitleState.elapsedInTitle,
statusTitleState.index
);
const resolvedTitle =
phase === "processing" || phase === "finalizing"
? rotatingTitle
: title || PHASE_TITLES[phase];
const playSequence = phase === "processing" || phase === "finalizing"; const playSequence = phase === "processing" || phase === "finalizing";
const progressStyle = useMemo(
() => ({ width: `${resolvedProgress}%` }),
[resolvedProgress]
);
return ( return (
<div <div
@ -75,6 +281,10 @@ export function GenerationDotPlaceholder({
)} )}
> >
{resolvedTitle} {resolvedTitle}
<span
aria-hidden="true"
className="ml-1 inline-block h-[1em] w-[0.08em] translate-y-[0.15em] animate-[generationCursorBlink_1s_steps(1,end)_infinite] bg-current"
/>
</div> </div>
<div className="flex min-h-0 flex-1 items-center justify-center py-3"> <div className="flex min-h-0 flex-1 items-center justify-center py-3">
@ -87,13 +297,13 @@ export function GenerationDotPlaceholder({
/> />
</div> </div>
{showProgress && phase !== "completed" ? ( {showProgress ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400"> <div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span> <span>{phase === "failed" ? "已停止" : "生成进度"}</span>
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null} <span>{resolvedProgress}%</span>
</div> </div>
{resolvedProgress !== null ? <div role="progressbar" aria-valuenow={resolvedProgress} aria-valuemin={0} aria-valuemax={100} className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none"> <div className="h-1.5 overflow-hidden rounded-full bg-white/80 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.035)] dark:bg-white/10 dark:shadow-none">
<div <div
className={cn( className={cn(
"h-full rounded-full transition-[width] duration-700 ease-out", "h-full rounded-full transition-[width] duration-700 ease-out",
@ -101,12 +311,25 @@ export function GenerationDotPlaceholder({
? "bg-[#c7ccd3] dark:bg-zinc-600" ? "bg-[#c7ccd3] dark:bg-zinc-600"
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]" : "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
)} )}
style={{ width: `${resolvedProgress}%` }} style={progressStyle}
/> />
</div> : null} </div>
</div> </div>
) : null} ) : null}
</div> </div>
<style jsx>{`
@keyframes generationCursorBlink {
0%,
48% {
opacity: 1;
}
49%,
100% {
opacity: 0;
}
}
`}</style>
</div> </div>
); );
} }

View File

@ -918,9 +918,9 @@ export function ImageAnnotator({
setIsRemovingBg(true); setIsRemovingBg(true);
try { try {
const input = /^https?:\/\//i.test(originalImageUrl) // 使用原图URL获取原图数据,而不是canvas的预览图
? { imageUrl: originalImageUrl } const base64Data = await imageUrlToBase64(originalImageUrl);
: { image_base64: await imageUrlToBase64(originalImageUrl) }; logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
// 调用抠图API // 调用抠图API
const response = await fetch("/api/rmbg", { const response = await fetch("/api/rmbg", {
@ -929,7 +929,7 @@ export function ImageAnnotator({
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
...input, image_base64: base64Data,
prompt: "画图标注-抠图处理", // 可选:添加提示词 prompt: "画图标注-抠图处理", // 可选:添加提示词
}), }),
}); });

View File

@ -41,11 +41,45 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
idle: "任务已创建,正在初始化生成参数。", idle: "任务已创建,正在初始化生成参数。",
queued: "任务已进入队列,请稍候。", queued: "任务已进入队列,请稍候。",
processing: "模型正在生成图片,请稍候。", processing: "模型正在生成图片,请稍候。",
finalizing: "图片已返回,正在保存并同步到对话。", finalizing: "图片已生成完成,正在同步到对话。",
failed: "本次处理已停止,请查看对话中的原因和建议。", failed: "生成失败,请重试。",
}; };
const LONG_PROCESSING_HINTS = [
"正在读取参考图与文字需求。",
"正在分析主体、场景和构图关系。",
"正在提取画面中的关键视觉特征。",
"正在确认图片比例和输出尺寸。",
"正在匹配当前模型的最佳生成参数。",
"正在把参考图信息提交给模型。",
"正在等待模型完成第一阶段推理。",
"正在生成初始画面结构。",
"正在保持主体特征和参考一致。",
"正在补全画面中的空间层次。",
"正在调整光影方向和明暗关系。",
"正在优化主体边缘与背景融合。",
"正在细化材质、纹理和局部细节。",
"正在减少画面噪点和不自然区域。",
"正在提升清晰度与整体质感。",
"正在检查生成结果的完整性。",
"正在处理高分辨率输出。",
"正在等待服务商返回最终图片。",
"正在保存图片并同步到对话。",
"正在准备把结果显示到画布。",
];
const LONG_WAIT_SECONDS = 5 * 60; const LONG_WAIT_SECONDS = 5 * 60;
const LONG_WAIT_HINT =
"服务繁忙,模型可能正在排队或自动尝试备用通道,请耐心等候,结果返回后会自动同步到画布。";
function getLongProcessingHint(phase: NonNullable<ImageGenerationLoadingProps["phase"]>, elapsedSeconds: number) {
if (phase !== "processing" && phase !== "finalizing") return PHASE_HINT[phase];
if (elapsedSeconds >= LONG_WAIT_SECONDS) return LONG_WAIT_HINT;
const index = Math.min(
LONG_PROCESSING_HINTS.length - 1,
Math.floor(Math.max(0, elapsedSeconds) / 15)
);
return LONG_PROCESSING_HINTS[index];
}
function formatImageQueueWaitLabel(estimatedWaitMs?: number) { function formatImageQueueWaitLabel(estimatedWaitMs?: number) {
const numeric = Number(estimatedWaitMs || 0); const numeric = Number(estimatedWaitMs || 0);
@ -104,8 +138,16 @@ export const ImageGenerationLoading = memo(
const isLongWait = const isLongWait =
resolvedElapsedSeconds >= LONG_WAIT_SECONDS && resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
(phase === "processing" || phase === "finalizing"); (phase === "processing" || phase === "finalizing");
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : ""; const estimateRangeLabel = isLongWait
const phaseHint = PHASE_HINT[phase]; ? "单次最长约10分钟"
: providerType === "grsai-gpt-image"
? "2~5分钟"
: "60~120秒";
const elapsedLabel =
resolvedElapsedSeconds > 0
? `已等待 ${resolvedElapsedSeconds}s`
: `预计 ${estimateRangeLabel}`;
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
const queueLine = const queueLine =
phase === "queued" && Number.isFinite(queuePosition) phase === "queued" && Number.isFinite(queuePosition)
? Math.max(0, Number(queuePosition || 0)) > 0 ? Math.max(0, Number(queuePosition || 0)) > 0
@ -147,12 +189,12 @@ export const ImageGenerationLoading = memo(
} }
compact={count > 1} compact={count > 1}
phase={phase} phase={phase}
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined} title={isLongWait ? "服务繁忙,排队处理中" : undefined}
/> />
<div className="space-y-0.5 text-[#9aa3b2]"> <div className="space-y-0.5 text-[#9aa3b2]">
<div className="text-[13px] font-medium"> <div className="text-[13px] font-medium">
使用 {resolvedModelName} 生成图片 使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
</div> </div>
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null} {queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null} {queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
@ -307,18 +307,9 @@ export function ImagePreview({
}} }}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={(event) => {
event.currentTarget.style.aspectRatio = "";
event.currentTarget.parentElement?.style.removeProperty("width");
event.currentTarget.alt = "Generated Image";
}}
onError={(event) => { onError={(event) => {
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) { if (event.currentTarget.src !== (thumbnail || original)) {
event.currentTarget.src = thumbnail || original; event.currentTarget.src = thumbnail || original;
} else if (!compactGrid) {
event.currentTarget.style.aspectRatio = "1";
event.currentTarget.parentElement?.style.setProperty("width", "100%");
event.currentTarget.alt = "预览暂不可用,可尝试下载原图";
} }
}} }}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
@ -610,7 +601,7 @@ export function ImagePreview({
onClick={(e: React.MouseEvent) => e.stopPropagation()} onClick={(e: React.MouseEvent) => e.stopPropagation()}
> >
<img <img
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original} src={original}
alt="Generated Image - Original" alt="Generated Image - Original"
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${ className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
isAnnotationMode ? "cursor-crosshair" : "" isAnnotationMode ? "cursor-crosshair" : ""

View File

@ -1,74 +0,0 @@
"use client";
import { useEffect, useState, type CSSProperties, type MouseEventHandler } from "react";
import { buildImageProxyUrl, getFullscreenPreviewSize } from "@/lib/image-delivery";
export type LoadedImagePreview = { src: string; width: number; height: number };
/** Keep the already displayed image visible while its larger preview loads. */
export function ProgressiveImagePreview({
src,
preview,
alt,
className,
style,
onClick,
}: {
src: string;
preview: LoadedImagePreview;
alt: string;
className?: string;
style?: CSSProperties;
onClick?: MouseEventHandler<HTMLImageElement>;
}) {
const size = getFullscreenPreviewSize({
imageWidth: preview.width,
imageHeight: preview.height,
viewportWidth: typeof window === "undefined" ? 1280 : window.innerWidth,
viewportHeight: typeof window === "undefined" ? 900 : window.innerHeight,
pixelRatio: typeof window === "undefined" ? 1 : window.devicePixelRatio,
});
const fullSrc = buildImageProxyUrl(src, {
delivery: "proxy",
width: size.requestWidth,
quality: 86,
format: "webp",
});
const [loadedSrc, setLoadedSrc] = useState("");
const [failedSrc, setFailedSrc] = useState("");
const ready = fullSrc === preview.src || loadedSrc === fullSrc;
const failed = failedSrc === fullSrc;
useEffect(() => {
if (fullSrc === preview.src) return;
const image = new Image();
image.decoding = "async";
image.onload = () => setLoadedSrc(fullSrc);
image.onerror = () => setFailedSrc(fullSrc);
image.src = fullSrc;
return () => {
image.onload = null;
image.onerror = null;
image.removeAttribute("src");
};
}, [fullSrc, preview.src]);
return (
<div className="relative flex items-center justify-center" onClick={(event) => event.stopPropagation()}>
<img
alt={alt}
className={className}
decoding="async"
draggable={false}
onClick={onClick}
src={ready ? fullSrc : preview.src}
style={{ width: size.displayWidth, ...style }}
/>
{!ready && (
<div role="status" className="pointer-events-none absolute bottom-4 max-w-full rounded-lg bg-black/80 px-3 py-2 text-center text-sm text-white">
{failed ? "高清预览加载失败,已保留当前预览;可关闭后重试或下载原图" : "正在加载高清预览…"}
</div>
)}
</div>
);
}

View File

@ -4,9 +4,6 @@ import {
executeRmbgJob, executeRmbgJob,
fetchImageBufferFromUrl, fetchImageBufferFromUrl,
} from "@/lib/ai/rmbg/service"; } from "@/lib/ai/rmbg/service";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("box-cutout-service");
export type NormalizedCropRect = { export type NormalizedCropRect = {
x: number; x: number;
@ -16,7 +13,6 @@ export type NormalizedCropRect = {
}; };
export interface ExecuteBoxCutoutJobParams { export interface ExecuteBoxCutoutJobParams {
requestId?: string;
userId: string; userId: string;
imageUrl: string; imageUrl: string;
cropRect: Partial<NormalizedCropRect>; cropRect: Partial<NormalizedCropRect>;
@ -147,7 +143,6 @@ function applyPadding(
export async function executeBoxCutoutJob( export async function executeBoxCutoutJob(
params: ExecuteBoxCutoutJobParams, params: ExecuteBoxCutoutJobParams,
): Promise<ExecuteBoxCutoutJobResult> { ): Promise<ExecuteBoxCutoutJobResult> {
const requestId = params.requestId || crypto.randomUUID();
const imageUrl = String(params.imageUrl || "").trim(); const imageUrl = String(params.imageUrl || "").trim();
if (!imageUrl) { if (!imageUrl) {
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400); throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
@ -155,7 +150,7 @@ export async function executeBoxCutoutJob(
const cropRect = sanitizeNormalizedCropRect(params.cropRect); const cropRect = sanitizeNormalizedCropRect(params.cropRect);
if (!cropRect) { if (!cropRect) {
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400); throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
} }
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio); const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
@ -175,7 +170,7 @@ export async function executeBoxCutoutJob(
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight); const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) { if (cropRectPixels.width < MIN_CROP_EDGE_PX || cropRectPixels.height < MIN_CROP_EDGE_PX) {
throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400); throw new BoxCutoutServiceError("框选区域过小,请重新框选", 400);
} }
const paddedCropRect = applyPadding(cropRect, paddingRatio); const paddedCropRect = applyPadding(cropRect, paddingRatio);
@ -189,18 +184,7 @@ export async function executeBoxCutoutJob(
}) })
.toBuffer(); .toBuffer();
logger.info("crop_prepared", {
requestId,
sourceNodeId: params.sourceNodeId,
sourceWidth,
sourceHeight,
cropRectPixels,
paddedCropRectPixels,
inputBytes: croppedBuffer.length,
});
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId,
userId: params.userId, userId: params.userId,
imageBuffer: croppedBuffer, imageBuffer: croppedBuffer,
imageBase64: croppedBuffer.toString("base64"), imageBase64: croppedBuffer.toString("base64"),

View File

@ -72,7 +72,6 @@ export async function processVideoCanvasImage(
const result = await executeRmbgJob({ const result = await executeRmbgJob({
userId: params.userId, userId: params.userId,
imageBuffer: await fetchImageBufferFromUrl(imageUrl), imageBuffer: await fetchImageBufferFromUrl(imageUrl),
inputImageUrl: imageUrl,
sourceImageUrl: imageUrl, sourceImageUrl: imageUrl,
prompt: params.prompt, prompt: params.prompt,
}); });

View File

@ -14,7 +14,6 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
import { import {
isAlibabaQwenImage3ModelId, isAlibabaQwenImage3ModelId,
resolveAlibabaImageAsyncEndpoint, resolveAlibabaImageAsyncEndpoint,
@ -130,16 +129,11 @@ async function fetchImageBufferWithRetry(
if (!match) throw new Error("输入图片数据格式无效"); if (!match) throw new Error("输入图片数据格式无效");
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
} }
let sourceUrl = imageUrl;
if (normalizeLegacyAliyunStorageUrl(sourceUrl)) {
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl);
}
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);
try { try {
const response = await fetch(sourceUrl, { const response = await fetch(imageUrl, {
cache: "no-store", cache: "no-store",
redirect: "follow", redirect: "follow",
signal: ctx.signal, signal: ctx.signal,

View File

@ -8,7 +8,6 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("rmbg-service"); const logger = createLogger("rmbg-service");
@ -28,7 +27,6 @@ type RmbgConfig = {
}; };
export type ExecuteRmbgJobParams = { export type ExecuteRmbgJobParams = {
requestId?: string;
userId: string; userId: string;
imageBuffer: Buffer; imageBuffer: Buffer;
imageBase64?: string; imageBase64?: string;
@ -37,8 +35,6 @@ export type ExecuteRmbgJobParams = {
chatId?: string | null; chatId?: string | null;
messageId?: string | null; messageId?: string | null;
skipGallerySave?: boolean; skipGallerySave?: boolean;
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
inputImageUrl?: string | null;
sourceImageUrl?: string | null; sourceImageUrl?: string | null;
}; };
@ -57,7 +53,7 @@ export class RmbgServiceError extends Error {
detail?: string; detail?: string;
constructor(message: string, status = 500, detail?: string) { constructor(message: string, status = 500, detail?: string) {
super(detail && detail !== message ? `${message}:${detail}` : message); super(message);
this.name = "RmbgServiceError"; this.name = "RmbgServiceError";
this.status = status; this.status = status;
this.detail = detail; this.detail = detail;
@ -65,11 +61,7 @@ export class RmbgServiceError extends Error {
} }
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// https://picwish.cn/background-removal-api-doc
const AOS_MAX_RESOLUTION = 4096; const AOS_MAX_RESOLUTION = 4096;
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
// The shared AOS state -5 documents a separate limit for image_url downloads.
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
const AOS_CREATE_TIMEOUT_MS = 120_000; const AOS_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000; const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000; const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
@ -155,18 +147,6 @@ const withTimeout = async <T,>(
} }
}; };
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await withTimeout(read, timeoutMs);
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
await sleep(1000 * (attempt + 1));
}
}
}
function getAosFriendlyErrorMessage(detail: string): string | null { function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) { if (resolutionMatch) {
@ -184,42 +164,50 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos( async function prepareImageForAos(
imageBuffer: Buffer imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { ): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
throw new RmbgServiceError(
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
400
);
}
let metadata: import("sharp").Metadata;
try { try {
const sharp = await import("sharp"); const sharp = await import("sharp");
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata(); const image = sharp.default(imageBuffer);
const metadata = await image.metadata();
const width = metadata.width;
const height = metadata.height;
const format = (metadata.format || "png").toLowerCase();
const ext = format === "jpeg" ? "jpg" : format;
const filenameExt =
ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png";
const mimeType =
ext === "jpg"
? "image/jpeg"
: ext === "webp"
? "image/webp"
: ext === "tiff"
? "image/tiff"
: "image/png";
if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) {
return {
buffer: imageBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
}
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
};
} catch (error) { } catch (error) {
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify. logger.warn("aos_image_preprocess_failed", error);
logger.warn("aos_image_metadata_unavailable", error); return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" };
} }
const { width, height } = metadata;
if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) {
throw new RmbgServiceError(
`抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`,
400
);
}
const format = metadata.format || "";
if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) {
const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase();
throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400);
}
const ext = format === "jpeg" ? "jpg" : format;
return {
buffer: imageBuffer,
mimeType: format ? `image/${format}` : "application/octet-stream",
filename: ext ? `image.${ext}` : "image",
};
} }
async function processWithRMBG( async function processWithRMBG(
@ -381,38 +369,20 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp"; foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1; crop?: 0 | 1;
format?: "png" | "jpg"; format?: "png" | "jpg";
inputImageUrl?: string | null;
requestId?: string;
} }
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
const startTime = Date.now(); const startTime = Date.now();
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, ""); const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`; const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
const headers = { "X-API-KEY": config.apiKey }; const headers = { "X-API-KEY": config.apiKey };
let stage = "create";
let providerTaskId: string | undefined;
const sourceImage = await prepareImageForAos(imageBuffer);
try { try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData(); const createForm = new FormData();
let publicSourceUrl: string | null = null; const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) { type: normalizedImage.mimeType,
try { });
const source = new URL(config.inputImageUrl); createForm.append("image_file", imageBlob, normalizedImage.filename);
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
publicSourceUrl = source.toString();
}
} catch { /* Uploaded files and other sources use the file API. */ }
}
if (publicSourceUrl) {
createForm.append("image_url", publicSourceUrl);
} else {
const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], {
type: sourceImage.mimeType,
});
createForm.append("image_file", imageBlob, sourceImage.filename);
}
createForm.append("sync", "0"); createForm.append("sync", "0");
createForm.append("return_type", "1"); createForm.append("return_type", "1");
createForm.append("output_type", "2"); createForm.append("output_type", "2");
@ -422,9 +392,6 @@ async function processWithAosSegmentation(
createForm.append("type", config.foregroundType); createForm.append("type", config.foregroundType);
} }
const createStartedAt = Date.now();
logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : sourceImage.buffer.length });
const createResponse = await withTimeout( const createResponse = await withTimeout(
(signal) => (signal) =>
fetch(createUrl, { fetch(createUrl, {
@ -444,37 +411,28 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`, error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
}; };
} }
const taskId = createPayload.data.task_id as string; const taskId = createPayload.data.task_id as string;
providerTaskId = taskId;
logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt });
const taskUrl = `${createUrl}/${taskId}`; const taskUrl = `${createUrl}/${taskId}`;
const maxPollCount = 60; const maxPollCount = 60;
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) { for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
await sleep(1000); await sleep(1000);
stage = "poll";
const pollResponse = await readAosResult( const pollResponse = await withTimeout(
async (signal) => { (signal) =>
const response = await fetch(taskUrl, { fetch(taskUrl, {
method: "GET", method: "GET",
headers, headers,
signal, signal,
}); }),
const payload = await response.json().catch((error) => {
if (error instanceof Error && error.name !== "SyntaxError") throw error;
return null;
});
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
},
AOS_POLL_TIMEOUT_MS AOS_POLL_TIMEOUT_MS
); );
const pollPayload = pollResponse.payload; const pollPayload = await pollResponse.json().catch(() => null);
if (!pollResponse.ok || !pollPayload?.data) { if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", { logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null, hasPayload: pollPayload != null,
@ -482,7 +440,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`, error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
}; };
} }
@ -496,15 +454,12 @@ async function processWithAosSegmentation(
pollCount, pollCount,
taskId, taskId,
}); });
stage = "download"; const imageResponse = await withTimeout(
const imageResponse = await readAosResult( (signal) =>
async (signal) => { fetch(imageUrl, {
const response = await fetch(imageUrl, {
method: "GET", method: "GET",
signal, signal,
}); }),
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS AOS_DOWNLOAD_TIMEOUT_MS
); );
@ -515,11 +470,11 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`, error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
}; };
} }
const resultBuffer = imageResponse.buffer!; const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
return { return {
success: true, success: true,
imageBuffer: resultBuffer, imageBuffer: resultBuffer,
@ -535,30 +490,19 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`, error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
}; };
} }
} }
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount }); logger.warn("aos_task_timeout", { maxPollCount });
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` }; return { success: false, error: "AOS任务超时(最多60秒)" };
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length }); return { success: false, error: "AOS请求超时,请稍后重试" };
const message = stage === "create"
? "AOS创建任务超时(120秒),结果尚未确认"
: stage === "poll"
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
return { success: false, error: message };
} }
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId }); logger.warn("aos_provider_request_error", error);
const detail = error instanceof Error ? error.message : "未知错误"; return { success: false, error: error instanceof Error ? error.message : "未知错误" };
return { success: false, error: stage === "create"
? `AOS创建任务连接失败,结果尚未确认:${detail}`
: stage === "poll"
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
} }
} }
@ -655,15 +599,8 @@ export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer>
export async function executeRmbgJob( export async function executeRmbgJob(
params: ExecuteRmbgJobParams params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> {
return trackRuntimeWork(() => runRmbgJob(params));
}
async function runRmbgJob(
params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> { ): Promise<ExecuteRmbgJobResult> {
const { const {
requestId = crypto.randomUUID(),
userId, userId,
imageBuffer, imageBuffer,
imageBase64 = imageBuffer.toString("base64"), imageBase64 = imageBuffer.toString("base64"),
@ -672,13 +609,11 @@ async function runRmbgJob(
chatId = null, chatId = null,
messageId = null, messageId = null,
skipGallerySave = false, skipGallerySave = false,
inputImageUrl = null,
sourceImageUrl = null, sourceImageUrl = null,
} = params; } = params;
const { provider, config } = await getValidatedRmbgConfig(); const { provider, config } = await getValidatedRmbgConfig();
logger.info("job_started", { logger.info("job_started", {
requestId,
hasChatId: Boolean(chatId), hasChatId: Boolean(chatId),
hasMessageId: Boolean(messageId), hasMessageId: Boolean(messageId),
hasSourceImageUrl: Boolean(sourceImageUrl), hasSourceImageUrl: Boolean(sourceImageUrl),
@ -722,8 +657,6 @@ async function runRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "", foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0, crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png", format: config.aosFormat === "jpg" ? "jpg" : "png",
inputImageUrl,
requestId,
}); });
if (!result.success || !result.imageBuffer) { if (!result.success || !result.imageBuffer) {
@ -860,7 +793,6 @@ async function runRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
generatedImageId: savedImage.id, generatedImageId: savedImage.id,
mediaAssetId, mediaAssetId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
@ -883,7 +815,6 @@ async function runRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
provider, provider,
savedToGallery: false, savedToGallery: false,

View File

@ -3,7 +3,6 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover";
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } { export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null; const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
const message = String(value?.message || "").toLowerCase(); const message = String(value?.message || "").toLowerCase();
if (value?.providerOutcomeUnknown || ["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))) return { retry: false, pause: null };
// A submitted image job may still succeed. Retain its task and reconcile it; // A submitted image job may still succeed. Retain its task and reconcile it;
// never submit another paid job just because a poll/connection timed out. // never submit another paid job just because a poll/connection timed out.
const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message); const unknown = value?.providerOutcomeUnknown || /request_outcome_unknown|may still be running|outcome.unknown|timed out|timeout|超时|fetch failed|econnreset|socket hang up/.test(message);

View File

@ -516,7 +516,6 @@ export async function executeDirectAgentImageEditTool(
const result = await executeRmbgJob({ const result = await executeRmbgJob({
userId: params.userId, userId: params.userId,
imageBuffer: sourceBuffer, imageBuffer: sourceBuffer,
inputImageUrl: params.sourceImageUrl,
prompt: params.requestTextContent || "抠图处理", prompt: params.requestTextContent || "抠图处理",
chatId: params.chatId, chatId: params.chatId,
messageId: params.messageId, messageId: params.messageId,

View File

@ -13,7 +13,7 @@ import {
isMediaInputFailure, isMediaInputFailure,
isNonProviderRequestStatus, isNonProviderRequestStatus,
} from "@/lib/ai/runtime/request-failure-classification"; } from "@/lib/ai/runtime/request-failure-classification";
import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error"; import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
export type AiModelHealthEventType = export type AiModelHealthEventType =
| "success" | "success"
@ -344,12 +344,7 @@ export function classifyAiModelHealthFailure(error: unknown): {
} }
export function shouldFailoverAiModelError(error: unknown) { export function shouldFailoverAiModelError(error: unknown) {
if ( if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
error instanceof ProviderTaskUncertainError ||
isGatewayResponseTimeout(error) ||
getRequestFailureDetails(error).outcome === "unknown" ||
["AbortError", "TimeoutError"].includes(String((error as { name?: string })?.name || ""))
) 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

@ -1,16 +1,16 @@
import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service"; import {
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { executeExtendJob } from "@/lib/ai/extend/service"; import { executeExtendJob } from "@/lib/ai/extend/service";
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service"; import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
import { executeResizeJob } from "@/lib/ai/resize/service"; import { executeResizeJob } from "@/lib/ai/resize/service";
import { import {
executeRmbgJob, executeRmbgJob,
fetchImageBufferFromUrl, fetchImageBufferFromUrl,
RmbgServiceError,
} from "@/lib/ai/rmbg/service"; } from "@/lib/ai/rmbg/service";
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context"; import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint"; import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
import { generateCanvasImage } from "@/lib/canvas/generate-image"; import { generateCanvasImage } from "@/lib/canvas/generate-image";
import { requestLongTaskResult } from "@/lib/client/long-task"; import { requestLongTaskResult } from "@/lib/client/long-task";
import { import {
@ -128,10 +128,8 @@ async function executeForTarget(
if (command.capabilityId === "image.remove_background") { if (command.capabilityId === "image.remove_background") {
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl); const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId: command.requestId,
userId: params.userId, userId: params.userId,
imageBuffer, imageBuffer,
inputImageUrl: target.sourceImageUrl,
sourceImageUrl: target.sourceImageUrl, sourceImageUrl: target.sourceImageUrl,
prompt: prompt || "抠图处理", prompt: prompt || "抠图处理",
skipGallerySave: false, skipGallerySave: false,
@ -187,7 +185,8 @@ async function executeForTarget(
} }
if (command.capabilityId === "image.box_cutout") { if (command.capabilityId === "image.box_cutout") {
const job = { const result = await executeBoxCutoutJob({
userId: params.userId,
imageUrl: target.sourceImageUrl, imageUrl: target.sourceImageUrl,
sourceNodeId: target.nodeId, sourceNodeId: target.nodeId,
cropRect: { cropRect: {
@ -198,30 +197,13 @@ async function executeForTarget(
}, },
paddingRatio: asFiniteNumber(command.args.paddingRatio), paddingRatio: asFiniteNumber(command.args.paddingRatio),
prompt: prompt || "框选抠图", prompt: prompt || "框选抠图",
}; });
let result;
if (command.source === "toolbar") {
const response = await executeBoxCutoutPOST(new Request(params.requestUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Request-ID": command.requestId },
body: JSON.stringify(job),
}), params.userId);
result = await response.json();
if (!response.ok || !result.success) {
throw new RmbgServiceError(result.detail || result.message || result.error || "框选抠图未能完成", response.status);
}
} else {
result = await executeBoxCutoutJob({ ...job, requestId: command.requestId, userId: params.userId });
}
return [{ return [{
...common, ...common,
assetId: result.mediaAssetId || undefined, assetId: result.mediaAssetId || undefined,
metadata: { metadata: {
editMode: "box-cutout",
cropRectApplied: result.cropRectApplied, cropRectApplied: result.cropRectApplied,
paddedCropRectApplied: result.paddedCropRectApplied, paddedCropRectApplied: result.paddedCropRectApplied,
cropRectPixels: result.cropRectPixels,
paddedCropRectPixels: result.paddedCropRectPixels,
}, },
title: `${target.title}-框选抠图`, title: `${target.title}-框选抠图`,
url: result.original_url, url: result.original_url,

View File

@ -44,8 +44,7 @@ import {
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeRegion {
x: number; x: number;
@ -4633,10 +4632,10 @@ function findCanvasFailoverModel(params: {
} }
export async function generateCanvasImage( export async function generateCanvasImage(
input: CanvasGenerateImageRequest request: CanvasGenerateImageRequest
): Promise<CanvasGenerateImageResult> { ): Promise<CanvasGenerateImageResult> {
const startedAt = Date.now(); const startedAt = Date.now();
const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input; if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false };
const prompt = String(request.prompt || "").trim(); const prompt = String(request.prompt || "").trim();
if (!prompt) { if (!prompt) {
throw new Error("请输入提示词"); throw new Error("请输入提示词");
@ -4874,16 +4873,11 @@ export async function generateCanvasImage(
typeof request.ownerKey === "string" && request.ownerKey.trim() typeof request.ownerKey === "string" && request.ownerKey.trim()
? request.ownerKey.trim() ? request.ownerKey.trim()
: undefined; : undefined;
const resolveReference = async (url: string) => { const referenceImage =
if (!normalizeLegacyAliyunStorageUrl(url)) return url; typeof request.referenceImage === "string" ? request.referenceImage : undefined;
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db"); const referenceImages = Array.isArray(request.referenceImages)
return resolveLegacyAliyunStorageUrl(url);
};
const referenceImage = typeof request.referenceImage === "string"
? await resolveReference(request.referenceImage) : undefined;
const referenceImages = await Promise.all((Array.isArray(request.referenceImages)
? request.referenceImages ? request.referenceImages
: []).map(resolveReference)); : [];
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image"; const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
const debugMeta = const debugMeta =
request.debugMeta && typeof request.debugMeta === "object" request.debugMeta && typeof request.debugMeta === "object"
@ -5663,11 +5657,7 @@ export async function generateCanvasImage(
} }
// An accepted or uncertain request must not start another paid model/variant. // An accepted or uncertain request must not start another paid model/variant.
if (caughtError && ( if (caughtError instanceof ProviderTaskUncertainError) throw caughtError;
caughtError instanceof ProviderTaskUncertainError ||
getRequestFailureDetails(caughtError).outcome === "unknown" ||
["AbortError", "TimeoutError"].includes(String((caughtError as { name?: string })?.name || ""))
)) throw caughtError;
const retryVariants = Array.isArray(request.retryVariants) const retryVariants = Array.isArray(request.retryVariants)
? request.retryVariants ? request.retryVariants

View File

@ -1,34 +0,0 @@
import type { RequestFailureDetails } from "@/lib/request-error";
export type ImageGenerationStatusStreamPayload = {
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
userMessageId?: string;
requestId?: string;
failure?: RequestFailureDetails;
providerType?: string;
modelName?: string;
imageCount?: number;
queuePosition?: number;
sameModelRunningCount?: number;
concurrencyLimit?: number;
estimatedWaitMs?: number;
};
export function findImageGenerationFailure(
messages: { role: string; parts?: unknown[] }[],
userMessageId: string,
): ImageGenerationStatusStreamPayload | null {
if (!userMessageId) return null;
for (const message of [...messages].reverse()) {
if (message.role !== "assistant") continue;
for (const part of message.parts || []) {
const candidate = part as { type?: string; data?: ImageGenerationStatusStreamPayload };
if (candidate.type !== "data-imageGenerationStatus") continue;
const status = candidate.data;
if (status?.phase === "failed" && status.failure && status.userMessageId === userMessageId) {
return status;
}
}
}
return null;
}

View File

@ -1,5 +0,0 @@
const PREVIEW_WIDTHS = [640, 960, 1280, 1600, 2048, 2600, 3200, 4096];
export function canvasImagePreviewWidth(width: number, maxWidth: number) {
return Math.min(maxWidth, PREVIEW_WIDTHS.find(value => value >= width) || 4096);
}

View File

@ -1,96 +0,0 @@
import "server-only";
import { NextResponse } from "next/server";
import {
BoxCutoutServiceError,
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
type BoxCutoutRequest = {
imageUrl?: string;
cropRect?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
paddingRatio?: number;
prompt?: string;
chatId?: string | null;
messageId?: string | null;
skipGallerySave?: boolean;
sourceNodeId?: string;
};
export async function executeBoxCutoutPOST(request: Request, userId: string) {
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
try {
if (!userId) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
}
const body = (await request.json()) as BoxCutoutRequest;
const rawImageUrl = String(body.imageUrl || "").trim();
if (!rawImageUrl) {
throw new BoxCutoutServiceError("缺少原图链接,请重新选择图片后提交。", 400);
}
const resolvedImageUrl = new URL(rawImageUrl, request.url).toString();
const pointsConfig = await getPointsSystemConfig();
const pointsCharge = await prepareFeaturePointsCharge({
userId,
unitCost: getImageToolCreditCost(pointsConfig, "box-cutout"),
reason: "image_box_cutout",
modelId: "box-cutout",
note: "框选抠图消耗积分",
pointsConfig,
});
if (!pointsCharge.ok) {
return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status });
}
const result = await executeBoxCutoutJob({
requestId,
userId,
imageUrl: resolvedImageUrl,
cropRect: body.cropRect || {},
paddingRatio: body.paddingRatio,
prompt: body.prompt || "框选抠图",
chatId: body.chatId || null,
messageId: body.messageId || null,
skipGallerySave: body.skipGallerySave === true,
sourceNodeId: body.sourceNodeId,
});
if (pointsCharge.chargeable) {
try {
await pointsCharge.consume();
} catch (pointsError) {
console.error("[Points] Failed to consume box cutout points:", pointsError);
}
}
return NextResponse.json({ ...result, requestId });
} catch (error) {
const failure = getRequestFailureDetails(error, "框选抠图未能完成");
const payload = {
success: false,
error: formatRequestFailure(failure, requestId),
detail: error instanceof Error ? error.message : "框选抠图未能完成",
code: failure.code,
failure,
requestId,
};
if (error instanceof BoxCutoutServiceError || error instanceof RmbgServiceError) {
return NextResponse.json(payload, { status: error.status });
}
console.error("[BoxCutout] 处理失败:", error);
return NextResponse.json(payload, { status: 500 });
}
}

View File

@ -9,11 +9,9 @@ import {
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 } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) { export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
try { try {
const session = { user: { id: trustedUserId } }; const session = { user: { id: trustedUserId } };
if (!session?.user?.id) { if (!session?.user?.id) {
@ -29,7 +27,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
let messageId: string | null = null; let messageId: string | null = null;
let skipGallerySave = false; let skipGallerySave = false;
let sourceImageUrl: string | null = null; let sourceImageUrl: string | null = null;
let inputImageUrl: string | null = null;
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const body = await request.json(); const body = await request.json();
@ -43,7 +40,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
if (imageUrl) { if (imageUrl) {
sourceImageUrl = String(imageUrl); sourceImageUrl = String(imageUrl);
inputImageUrl = sourceImageUrl;
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl); imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
} else if (body.image_base64) { } else if (body.image_base64) {
imageBase64 = String(body.image_base64); imageBase64 = String(body.image_base64);
@ -98,7 +94,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
} }
const result = await executeRmbgJob({ const result = await executeRmbgJob({
requestId,
userId: session.user.id, userId: session.user.id,
imageBuffer, imageBuffer,
imageBase64, imageBase64,
@ -106,7 +101,6 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
chatId, chatId,
messageId, messageId,
skipGallerySave, skipGallerySave,
inputImageUrl,
sourceImageUrl, sourceImageUrl,
}); });
@ -120,29 +114,22 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
requestId,
...result, ...result,
}); });
} catch (error) { } catch (error) {
if (error instanceof RmbgServiceError) { if (error instanceof RmbgServiceError) {
const failure = getRequestFailureDetails(error);
return NextResponse.json( return NextResponse.json(
{ {
success: false, error: error.message,
error: formatRequestFailure(failure, requestId),
detail: error.detail || error.message, detail: error.detail || error.message,
code: failure.code,
failure,
requestId,
}, },
{ status: error.status } { status: error.status }
); );
} }
console.error("[RMBG API] 处理错误:", error); console.error("[RMBG API] 处理错误:", error);
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
return NextResponse.json( return NextResponse.json(
{ success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId }, { error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
{ status: 500 } { status: 500 }
); );
} }

View File

@ -40,6 +40,22 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value)); return Math.min(max, Math.max(min, value));
} }
async function executeScaleAdjustRmbgJob(
params: Parameters<typeof executeRmbgJob>[0]
) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await executeRmbgJob(params);
} catch (error) {
lastError = error;
if (attempt === 3) break;
await new Promise((resolve) => setTimeout(resolve, attempt * 800));
}
}
throw lastError;
}
function rectToPixelBox( function rectToPixelBox(
rect: CanvasScaleAdjustRect, rect: CanvasScaleAdjustRect,
sourceWidth: number, sourceWidth: number,
@ -204,7 +220,7 @@ async function buildPersonExclusionAlpha(params: {
userId: string; userId: string;
}) { }) {
try { try {
const person = await executeRmbgJob({ const person = await executeScaleAdjustRmbgJob({
foregroundType: "person", foregroundType: "person",
imageBuffer: params.sourceBuffer, imageBuffer: params.sourceBuffer,
prompt: "比例调整人物保护蒙版", prompt: "比例调整人物保护蒙版",
@ -314,7 +330,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
.png({ compressionLevel: 6 }) .png({ compressionLevel: 6 })
.toBuffer(); .toBuffer();
semanticSourceCropBuffers.push(cropBuffer); semanticSourceCropBuffers.push(cropBuffer);
const subject = await executeRmbgJob({ const subject = await executeScaleAdjustRmbgJob({
imageBuffer: cropBuffer, imageBuffer: cropBuffer,
prompt: "比例调整完整产品蒙版", prompt: "比例调整完整产品蒙版",
skipGallerySave: true, skipGallerySave: true,

View File

@ -1,17 +1,5 @@
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
const IMAGE_PROXY_PATH = "/api/image-proxy"; const IMAGE_PROXY_PATH = "/api/image-proxy";
/** Error fallbacks must keep unavailable legacy sources behind archive resolution. */
export function buildImageFallbackUrl(value: string | null | undefined): string {
const source = value || "";
const legacy = normalizeLegacyAliyunStorageUrl(source);
const modelImage = isAlibabaModelImageUrl(source);
if (!legacy && !modelImage) return source;
const proxy = buildImageProxyUrl(legacy || source, modelImage ? { width: 960, quality: 86, format: "webp" } : {});
return typeof window === "undefined" ? proxy : new URL(proxy, window.location.origin).toString();
}
type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png"; type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png";
export type ImageProxyOptions = { export type ImageProxyOptions = {
@ -21,26 +9,6 @@ export type ImageProxyOptions = {
delivery?: "display" | "proxy" | "original"; delivery?: "display" | "proxy" | "original";
}; };
export function getFullscreenPreviewSize({
imageWidth,
imageHeight,
viewportWidth,
viewportHeight,
pixelRatio = 1,
}: {
imageWidth: number;
imageHeight: number;
viewportWidth: number;
viewportHeight: number;
pixelRatio?: number;
}) {
const ratio = imageWidth > 0 && imageHeight > 0 ? imageWidth / imageHeight : 1;
const displayWidth = Math.min(viewportWidth * 0.95, viewportHeight * 0.95 * ratio);
const requiredWidth = displayWidth * Math.max(1, pixelRatio || 1);
const requestWidth = [640, 960, 1280, 1600, 2048, 2560, 3200, 4096].find((width) => width >= requiredWidth) || 4096;
return { displayWidth, requestWidth };
}
/** Preserve the actual response format when naming an original download. */ /** Preserve the actual response format when naming an original download. */
export function getImageDownloadExtension(contentType: string) { export function getImageDownloadExtension(contentType: string) {
const mime = contentType.split(";")[0]?.trim().toLowerCase(); const mime = contentType.split(";")[0]?.trim().toLowerCase();
@ -91,15 +59,6 @@ function isAliyunOssImageHost(hostname: string): boolean {
return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-"); return normalized.endsWith(".aliyuncs.com") && normalized.includes(".oss-");
} }
/** Model result buckets allow downloading objects but deny OSS image processing. */
export function isAlibabaModelImageUrl(value: string): boolean {
try {
return /^dashscope(?:-[a-z0-9-]+)?\.oss-[a-z0-9-]+\.aliyuncs\.com$/i.test(new URL(value).hostname);
} catch {
return false;
}
}
function isTrustedDirectStorageImageHost(hostname: string): boolean { function isTrustedDirectStorageImageHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase(); const normalized = hostname.trim().toLowerCase();
let configuredCdnHost = ""; let configuredCdnHost = "";
@ -241,7 +200,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
return trustedDirectStorageUrl; return trustedDirectStorageUrl;
} }
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) { if (options.delivery !== "proxy") {
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options); const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
if (directDisplayUrl) { if (directDisplayUrl) {
return directDisplayUrl; return directDisplayUrl;

View File

@ -228,72 +228,10 @@ export function getRequestFailureDetails(
const status = getRequestErrorStatus(error); const status = getRequestErrorStatus(error);
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") => const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
({ code, message, recommendation, outcome }); ({ code, message, recommendation, outcome });
if (/抠图参考图文件大小超限/.test(text)) {
return detail("RMBG_INPUT_TOO_LARGE", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 50 MB 上限。", "请手动压缩至 50 MB 以内后重新提交。本次未提交上游抠图任务。");
}
if (/抠图参考图分辨率超限/.test(text)) {
return detail("RMBG_INPUT_RESOLUTION_EXCEEDED", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 4096 像素上限。", "请手动将宽、高均调整至 4096 像素以内,再重新提交。本次未提交上游抠图任务。");
}
if (/抠图参考图格式不支持/.test(text)) {
return detail("RMBG_INPUT_FORMAT_UNSUPPORTED", getRequestErrorMessage(error) || "AOS 不支持此参考图格式。", "请导出为 JPG、PNG 或 WebP 后重新提交。本次未提交上游抠图任务。");
}
const aosResolution = text.match(/(?:resolution excess,\s*width=(\d+),\s*height=(\d+)|图片分辨率超限((\d+)x(\d+)))/i);
if (aosResolution) {
return detail("RMBG_PROVIDER_RESOLUTION_EXCEEDED", `AOS 拒绝了参考图:分辨率为 ${aosResolution[1] || aosResolution[3]}×${aosResolution[2] || aosResolution[4]} 像素,宽、高均须不超过 4096 像素。`, "请手动调整尺寸后重新提交。");
}
if (/AOS.*invalid file|图片文件无法识别/i.test(text)) {
return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
}
const aosState = text.match(/AOS任务失败(状态 (-\d+),/);
if (aosState) {
const state = Number(aosState[1]);
if (state === -5) return detail("RMBG_PROVIDER_URL_TOO_LARGE", "AOS 拒绝读取参考图:链接取图大小超过上游的 30 MB 上限。", "请联系管理员核对文件提交方式;不要连续点击。上游文件上传接口允许最大 50 MB。");
if (state === -7) return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。");
if (state === -3) return detail("RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED", "AOS 下载参考图失败,未能读取原图。", "请确认原图链接可打开;若原图正常,请联系管理员检查上游取图线路,并提供请求编号。");
if (state === -2) return detail("RMBG_PROVIDER_RESULT_STORAGE_FAILED", "AOS 已完成处理,但上游保存结果图片失败。", "请联系管理员核对原任务结果,避免立即重复抠图。", "unknown");
if (state === -8) return detail("RMBG_PROVIDER_PROCESSING_TIMEOUT", "AOS 抠图处理超时,上游已返回失败状态。", "请稍后重试;若持续发生,请联系管理员并提供请求编号。");
if (state === -11) return detail("RMBG_PROVIDER_EMPTY_RESULT", "AOS 抠图结果为空,未能得到有效主体。", "请检查图片中的主体是否清晰,尝试使用主体更明确的图片。");
if ([-17, -16, -14, -10].includes(state)) return detail("RMBG_PROVIDER_INPUT_REJECTED", `AOS 拒绝处理参考图,图片内容未通过上游检查(状态 ${state})。`, "请更换符合上游要求的参考图;如认为判断有误,请联系管理员核对请求编号。");
if (state === -15) return detail("RMBG_PROVIDER_RESOURCE_UNAVAILABLE", "AOS 处理资源不足,未能完成本次抠图。", "请稍后再试;持续失败时联系管理员检查上游服务。");
return detail("RMBG_PROVIDER_TASK_FAILED", getRequestErrorMessage(error) || `AOS 抠图任务失败(状态 ${state})。`, "请联系管理员并提供请求编号,核对上游任务详情。");
}
if (/AOS任务创建失败(HTTP 413)/.test(text)) {
return detail("RMBG_PROVIDER_FILE_TOO_LARGE", "AOS 拒绝了参考图:文件超过上游大小限制(文件上传最大 50 MB)。", "请手动压缩文件后重新提交;若文件已小于限制,请联系管理员核对上游返回。");
}
if (/AOS任务创建失败(HTTP (400|401|404))/.test(text)) {
return detail("RMBG_PROVIDER_REQUEST_REJECTED", getRequestErrorMessage(error) || "AOS 拒绝了抠图请求。", "请联系管理员核对上游参数、服务权限及接口配置,并提供请求编号。");
}
if (/AOS任务创建失败(HTTP (200|5\d\d))/.test(text)) {
return detail("RMBG_PROVIDER_CREATE_UNCONFIRMED", getRequestErrorMessage(error) || "AOS 未能确认抠图任务是否创建成功。", "请联系管理员核对原请求,避免连续提交造成重复处理。", "unknown");
}
const aosReadFailure = text.match(/AOS(任务查询|结果下载)失败(HTTP (\d+)/);
if (aosReadFailure) {
return aosReadFailure[1] === "任务查询"
? detail("RMBG_PROVIDER_POLL_FAILED", `AOS 查询抠图任务失败(HTTP ${aosReadFailure[2]}),原任务结果尚未确认。`, "请联系管理员核对原任务,不要重复提交。", "unknown")
: detail("RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", `抠图已完成,但结果图片下载失败(HTTP ${aosReadFailure[2]})。`, "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (isGatewayResponseTimeout(error)) { if (isGatewayResponseTimeout(error)) {
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown"); return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
} }
if (/AOS创建任务超时/.test(text)) { if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
return detail("RMBG_CREATE_TIMEOUT", "抠图服务创建任务超时(120秒),结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
}
if (/AOS创建任务连接失败/.test(text)) {
return detail("RMBG_CREATE_CONNECTION_FAILED", "抠图服务创建任务时连接中断,结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown");
}
if (/AOS查询任务连接失败/.test(text)) {
return detail("RMBG_POLL_CONNECTION_FAILED", "抠图服务查询任务时连接中断,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
}
if (/AOS查询任务超时/.test(text)) {
return detail("RMBG_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown");
}
if (/AOS结果下载连接失败/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", "抠图已完成,但结果图片传输中断。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if (/AOS结果下载超时/.test(text)) {
return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown");
}
if ((isRecord(error) && (error.providerOutcomeUnknown === true || ["AbortError", "TimeoutError"].includes(String(error.name)))) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running|operation was aborted/i.test(text)) {
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown"); return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
} }
if (isProviderBalanceError(error)) { if (isProviderBalanceError(error)) {
@ -305,9 +243,6 @@ export function getRequestFailureDetails(
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) { if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。"); return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
} }
if (/旧存储已停用|storage_retired/i.test(text)) {
return detail("SOURCE_STORAGE_RETIRED", "参考文件所在的旧存储已停用,且没有可用的归档副本。", "请重新上传原始文件后再提交;切换模型或重复提交无法恢复该文件。");
}
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) { if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown"); return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
} }

View File

@ -1,37 +0,0 @@
import { executeRemoveBackgroundPOST } from "@/lib/canvas/operations/rmbg";
import { createLogger } from "@/lib/observability/logger";
const logger = createLogger("scene-fusion");
export async function preprocessProductImageWithRmbg(params: {
request: Request;
requestId: string;
userId: string;
productImageUrl: string;
preRmbgEnabled: boolean;
}): Promise<{ productImageUrl: string; usedPreRmbg: boolean; provider?: string; reason?: string }> {
const { request, requestId, userId, productImageUrl, preRmbgEnabled } = params;
if (!preRmbgEnabled) return { productImageUrl, usedPreRmbg: false, reason: "disabled_by_config" };
logger.info("pre_rmbg_started", { requestId });
try {
const response = await executeRemoveBackgroundPOST(new Request(new URL("/api/rmbg", request.url), {
method: "POST",
headers: { "Content-Type": "application/json", "X-Request-ID": `${requestId}:pre-rmbg` },
body: JSON.stringify({ imageUrl: productImageUrl, prompt: "场景裂变预处理-抠图", skipGallerySave: true }),
}), userId);
const payload = await response.json();
if (!response.ok) {
logger.warn("pre_rmbg_failed", { requestId, status: response.status, code: payload?.failure?.code });
return { productImageUrl, usedPreRmbg: false, reason: String(payload?.error || `request_failed_${response.status}`) };
}
const cleanedUrl = String(payload?.original_url || payload?.thumbnail_url || "").trim();
if (!cleanedUrl || cleanedUrl === productImageUrl) {
return { productImageUrl, usedPreRmbg: false, reason: cleanedUrl ? "same_as_source" : "empty_result" };
}
return { productImageUrl: cleanedUrl, usedPreRmbg: true, provider: String(payload?.provider || "unknown") };
} catch (error) {
logger.warn("pre_rmbg_failed", error, { requestId });
return { productImageUrl, usedPreRmbg: false, reason: error instanceof Error ? error.message : "exception" };
}
}

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { isRetiredStorageUrl } from "./retired-storage";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { import {
@ -22,8 +22,6 @@ export function getImageArchiveDisplayHoldMs() {
} }
export function imageArchiveSourceKey(source: string) { export function imageArchiveSourceKey(source: string) {
const legacy = normalizeLegacyAliyunStorageUrl(source);
if (legacy) return legacy;
if (!source.startsWith("data:")) return source; if (!source.startsWith("data:")) return source;
const configured = const configured =
process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL; process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL;
@ -41,7 +39,7 @@ export async function enqueueImageArchiveTask(params: {
metadata?: Record<string, unknown> | null; metadata?: Record<string, unknown> | null;
}) { }) {
const sourceUrl = String(params.sourceUrl || "").trim(); const sourceUrl = String(params.sourceUrl || "").trim();
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return null; if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null;
const now = new Date(); const now = new Date();
const archiveAfterAt = params.archiveAfterAt || now; const archiveAfterAt = params.archiveAfterAt || now;
@ -314,18 +312,6 @@ export async function getArchivedImageUrlMappings(
return replacements; return replacements;
} }
/** Legacy OSS is unavailable; only an existing durable copy can serve its URLs. */
export async function resolveLegacyAliyunStorageUrl(source: string): Promise<string> {
const legacy = normalizeLegacyAliyunStorageUrl(source);
if (!legacy) return source;
const mappings = await getArchivedImageUrlMappings([source, legacy]);
const target = mappings[source] || mappings[legacy];
if (!target || normalizeLegacyAliyunStorageUrl(target) || isRetiredStorageUrl(target)) {
throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
}
return target;
}
// Normalize late writes too: an archive can finish before a task/canvas save arrives. // Normalize late writes too: an archive can finish before a task/canvas save arrives.
export async function resolveArchivedImageUrls<T>( export async function resolveArchivedImageUrls<T>(
value: T, value: T,

View File

@ -1,6 +1,5 @@
// Shared by the server and canvas; no storage or network work happens here. // Shared by the server and canvas; no storage or network work happens here.
export type ArchivedImageUrlMap = Record<string, string>; export type ArchivedImageUrlMap = Record<string, string>;
const legacyOssUrlPattern = /https?:\/\/demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/[^\s"'<>`\\)\]}|]+/gi;
export function isGeneratedImageSource(value: string) { export function isGeneratedImageSource(value: string) {
return ( return (
@ -12,7 +11,6 @@ export function collectImageArchiveSources(value: unknown): string[] {
const urls = new Set<string>(); const urls = new Set<string>();
const visit = (item: unknown) => { const visit = (item: unknown) => {
if (typeof item === "string") { if (typeof item === "string") {
for (const match of item.matchAll(legacyOssUrlPattern)) urls.add(match[0].replace(/,+$/, ""));
if (isGeneratedImageSource(item)) urls.add(item); if (isGeneratedImageSource(item)) urls.add(item);
else else
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
@ -37,10 +35,6 @@ export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImag
.map((part) => replacements[part] || part) .map((part) => replacements[part] || part)
.join("|||"); .join("|||");
return `![${nextAlt}](${replacements[url] || url})`; return `![${nextAlt}](${replacements[url] || url})`;
}).replace(legacyOssUrlPattern, (url) => {
if (replacements[url]) return replacements[url];
const source = url.replace(/,+$/, "");
return (replacements[source] || source) + url.slice(source.length);
}) as T; }) as T;
} }
if (!value || typeof value !== "object" || value instanceof Date) return value; if (!value || typeof value !== "object" || value instanceof Date) return value;

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises"; import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@ -83,18 +83,13 @@ function isMediaResponse(data: ImageBytes) {
} }
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) { 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}`);
let sourceUrl = url; return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
if (normalizeLegacyAliyunStorageUrl(url)) { if (url.startsWith("data:")) {
const { resolveLegacyAliyunStorageUrl } = await import("./image-archive-db"); const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
sourceUrl = await resolveLegacyAliyunStorageUrl(url);
}
return onceImageBytes(`source:${options.scope || ""}:${sourceUrl}`, async () => {
if (sourceUrl.startsWith("data:")) {
const match = sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
if (!match) throw new Error("Invalid inline generated image"); if (!match) throw new Error("Invalid inline generated image");
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
} }
const response = await fetch(sourceUrl, { 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: options.signal signal: options.signal

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, normalizeLegacyAliyunStorageUrl } from "./retired-storage"; import { isRetiredStorageUrl } from "./retired-storage";
import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm"; import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@ -19,7 +19,6 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
config.isEnabled && config.isEnabled &&
!isRetiredStorageUrl(config.endpoint) && !isRetiredStorageUrl(config.endpoint) &&
!isRetiredStorageUrl(config.publicUrl) && !isRetiredStorageUrl(config.publicUrl) &&
!isLegacyAliyunStorageConfig(config) &&
String(config.endpoint || "").trim() && String(config.endpoint || "").trim() &&
String(config.bucket || "").trim() && String(config.bucket || "").trim() &&
String(config.accessKeyId || "").trim() && String(config.accessKeyId || "").trim() &&
@ -84,7 +83,7 @@ export async function enqueueStorageMigrationTask({
migrateAfterAt: Date; migrateAfterAt: Date;
cleanupAfterAt: Date; cleanupAfterAt: Date;
}) { }) {
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return; if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
await db await db
.insert(storageMigrationTask) .insert(storageMigrationTask)

View File

@ -2,29 +2,6 @@
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)'; export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。'; export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。';
export function normalizeLegacyAliyunStorageUrl(value: string | null | undefined): string | null {
if (!value) return null;
try {
let url = new URL(value, 'https://local.invalid');
if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') {
url = new URL(url.searchParams.get('url') || '');
}
if (url.hostname !== 'demo-sparkai.oss-cn-shanghai.aliyuncs.com') return null;
url.protocol = 'https:';
url.hash = '';
for (const name of ['Signature', 'OSSAccessKeyId', 'Expires', 'security-token']) url.searchParams.delete(name);
url.searchParams.sort();
return url.toString();
} catch { return null; }
}
export function isLegacyAliyunStorageConfig(config: { bucket?: string | null; endpoint?: string | null; publicUrl?: string | null }): boolean {
if (normalizeLegacyAliyunStorageUrl(config.publicUrl)) return true;
try {
return config.bucket === 'demo-sparkai' && new URL(config.endpoint || '').hostname.endsWith('.aliyuncs.com');
} catch { return false; }
}
export function isRetiredStorageUrl(value: string | null | undefined): boolean { export function isRetiredStorageUrl(value: string | null | undefined): boolean {
if (!value) return false; if (!value) return false;
try { try {

View File

@ -1,5 +1,5 @@
import "server-only"; import "server-only";
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob"; import { put } from "@vercel/blob";
@ -150,7 +150,7 @@ export function getContentType(filename: string): string {
} }
export function createS3Client(config: S3UploadConfig): S3Client { export function createS3Client(config: S3UploadConfig): S3Client {
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl) || isLegacyAliyunStorageConfig(config)) { if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) {
throw new Error(RETIRED_STORAGE_MESSAGE); throw new Error(RETIRED_STORAGE_MESSAGE);
} }
const normalizedEndpoint = normalizeEndpoint(config.endpoint); const normalizedEndpoint = normalizeEndpoint(config.endpoint);
@ -343,7 +343,6 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
!value.isEnabled || !value.isEnabled ||
isRetiredStorageUrl(endpoint) || isRetiredStorageUrl(endpoint) ||
isRetiredStorageUrl(String(value.publicUrl || "")) || isRetiredStorageUrl(String(value.publicUrl || "")) ||
isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: String(value.publicUrl || "") }) ||
!endpoint || !endpoint ||
!bucket || !bucket ||
!accessKeyId || !accessKeyId ||
@ -371,8 +370,7 @@ function buildEnvS3Config(): S3UploadConfig | null {
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? ""; const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? ""; const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) {
!isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: process.env.S3_PUBLIC_URL }) && bucket && accessKeyId && secretAccessKey) {
return { return {
endpoint, endpoint,
region: process.env.S3_REGION?.trim() || "auto", region: process.env.S3_REGION?.trim() || "auto",

View File

@ -3,9 +3,6 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests", testDir: "./tests",
testMatch: [ testMatch: [
/routes\/rmbg-delivery\.test\.ts/,
/routes\/legacy-oss-recovery\.test\.ts/,
/routes\/retired-storage\.test\.ts/,
/routes\/original-image-download\.test\.ts/, /routes\/original-image-download\.test\.ts/,
/routes\/canvas-download-speed\.test\.ts/, /routes\/canvas-download-speed\.test\.ts/,
/routes\/canvas-download-recovery\.test\.ts/, /routes\/canvas-download-recovery\.test\.ts/,
@ -21,7 +18,6 @@ export default defineConfig({
/routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-media-import\.test\.ts/,
/routes\/video-canvas-toolbar-jobs\.test\.ts/, /routes\/video-canvas-toolbar-jobs\.test\.ts/,
/routes\/image-provider-error-classification\.test\.ts/, /routes\/image-provider-error-classification\.test\.ts/,
/routes\/image-generation-status\.test\.ts/,
/routes\/canvas-agent-runtime-image-failover\.test\.ts/, /routes\/canvas-agent-runtime-image-failover\.test\.ts/,
/routes\/video-canvas-audio-remote\.test\.ts/, /routes\/video-canvas-audio-remote\.test\.ts/,
/routes\/video-canvas-groups-upscale\.test\.ts/, /routes\/video-canvas-groups-upscale\.test\.ts/,

View File

@ -95,19 +95,18 @@ function normalizeRevisionText(value: unknown) {
return String(value || "").trim(); return String(value || "").trim();
} }
function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void { function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const revision = normalizeRevisionText(updatedAt); const revision = normalizeRevisionText(updatedAt);
if (!revision) return; if (!revision) return;
const current = useCanvasStore.getState(); useCanvasStore.getState().setCanvasRevision(revision, revision);
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
} }
function syncCanvasStoreRevisionFromProject( function syncCanvasStoreRevisionFromProject(
project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null
): void { ): void {
if (!project?.canvasData) return; if (!project?.canvasData) return;
syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt);
} }
export interface NormalizedCropRect { export interface NormalizedCropRect {
@ -204,6 +203,9 @@ export const canvasService = {
try { try {
const response = await fetch(`/api/canvas/${id}`); const response = await fetch(`/api/canvas/${id}`);
const data = await response.json(); const data = await response.json();
if (data?.success) {
syncCanvasStoreRevisionFromProject(data.data);
}
return data; return data;
} catch (error) { } catch (error) {
return { success: false, error: '获取画布失败' }; return { success: false, error: '获取画布失败' };
@ -333,7 +335,7 @@ export const canvasService = {
}; };
} }
if (data?.success) { if (data?.success) {
syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt);
} }
return data as ApiResponse; return data as ApiResponse;
} catch (error) { } catch (error) {

View File

@ -1,119 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(30_000);
const browserErrors = new WeakMap<Page, string[]>();
test.beforeEach(({ page }) => {
const errors: string[] = [];
browserErrors.set(page, errors);
page.on("pageerror", error => errors.push(error.message));
});
test.afterEach(({ page }) => expect(browserErrors.get(page)).toEqual([]));
const projectId = "24cce650-7cfb-48bb-aa65-b04f490ac978";
const imageOrigin = "https://scqnycdn.jiansun.vip";
const failure = {
phase: "failed", requestId: "failed-request", userMessageId: "",
failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed", message: "参考图片无法读取。", recommendation: "请重新上传原图后再提交。" },
};
async function setup(page: Page, lostStream = false) {
const submissions: { path: string; body: any }[] = [];
let messages: any[] = [];
const canvasData = {
nodes: [{ id: "preview-image", type: "image", position: { x: 220, y: 160 }, size: { width: 640, height: 420 },
data: { status: "completed", src: `${imageOrigin}/uploads/feedback-preview.png`, title: "参考图片" } }],
edges: [], viewport: { x: 0, y: 0, zoom: 1 },
};
await page.context().addInitScript(() => localStorage.setItem("theme", "light"));
await page.context().route("**/api/**", async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
let data: unknown = {};
if (["/api/chat", "/api/canvas/agent-chat"].includes(path) && request.method() === "POST") {
const body = request.postDataJSON();
submissions.push({ path, body });
const terminal = { ...failure, userMessageId: body.message.id };
const text = `${terminal.failure.message}${terminal.failure.recommendation}\n\n请求编号:${terminal.requestId}`;
const assistant = { id: "failed-assistant", role: "assistant", createdAt: new Date(Date.now() + 100).toISOString(),
parts: [{ type: "text", text }, { type: "data-imageGenerationStatus", data: terminal }] };
messages = [{ ...body.message, createdAt: new Date().toISOString() }, assistant];
const chunks: any[] = [{ type: "start", messageId: "stream-assistant" },
{ type: "data-imageGenerationStatus", data: JSON.stringify({ phase: "processing", userMessageId: body.message.id }) }];
if (!lostStream) chunks.push(
{ type: "data-imageGenerationStatus", data: JSON.stringify(terminal) },
{ type: "data-appendMessage", data: JSON.stringify(assistant) },
{ type: "data-appendMessage", data: JSON.stringify({ id: "end", role: "assistant", parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }] }) },
);
chunks.push({ type: "finish" });
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" },
body: `${chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n` });
return;
}
if (path.includes("validate-session")) data = { valid: true, success: true };
else if (path === "/api/auth/session") data = { user: { id: "feedback-user", name: "测试", type: "admin" }, expires: "2099-01-01" };
else if (path === "/api/site-settings") data = { canvasEnabled: true, agentEnabled: true, membershipEnabled: false };
else if (path === "/api/vote") data = [];
else if (path === "/api/models") data = { models: [{ id: "image-test", modelId: "gpt-image-2", providerType: "openai", name: "GPT Image", type: "image", enabled: true, supportsImageGen: true, imageGenConfig: { creditCostPerImage: 15 } }] };
else if (path === `/api/canvas/${projectId}` && request.method() === "GET") data = { success: true, data: { id: projectId, title: "反馈验收", canvasData } };
else if (path.includes("messages")) data = { messages };
else if (path.includes("sessions")) data = { sessions: [] };
else if (path.startsWith("/api/canvas/")) data = { success: true, runs: [] };
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(data) });
});
await page.context().route(`${imageOrigin}/**`, async route => {
await new Promise(resolve => setTimeout(resolve, 200));
await route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="420"><rect width="640" height="420" fill="#43b9bd"/></svg>' });
});
await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true })).toBeVisible();
return submissions;
}
test("image workspaces start with Agent enabled and send requests to its planning runtime", async ({ page }) => {
const submissions = await setup(page);
await page.getByLabel("描述你的设计需求", { exact: true }).fill("请先分析参考图片,再规划处理步骤。");
await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter");
await expect.poll(() => submissions.length).toBe(1);
expect(submissions[0].path).toBe("/api/canvas/agent-chat");
expect(submissions[0].body.composerMode).toBe("agent");
});
for (const lostStream of [false, true]) {
test(`image failure ends waiting and shows its cause${lostStream ? " after recovering history" : " from the stream"}`, async ({ page }) => {
const submissions = await setup(page, lostStream);
await page.getByRole("button", { name: "关闭 Agent,直接使用生图模型", exact: true }).click();
await page.getByLabel("描述你的设计需求", { exact: true }).fill("将参考图改成白色背景。");
await page.getByLabel("描述你的设计需求", { exact: true }).press("Enter");
const failedNode = page.locator('[data-canvas-node-id] [role="alert"]');
await expect(failedNode).toContainText("参考图片无法读取。");
await expect(failedNode).toContainText("请重新上传原图后再提交。");
await expect(failedNode).toContainText("请求编号:failed-request");
await expect(page.getByTestId("message-assistant-image-loading")).toHaveCount(0);
expect(submissions).toHaveLength(1);
expect(submissions[0].path).toBe("/api/chat");
await page.screenshot({ path: test.info().outputPath("terminal-failure.png") });
});
}
test("zoom and hover keep a loaded canvas image visible while a new preview loads", async ({ page }) => {
await setup(page);
const node = page.locator('[data-canvas-node-id="preview-image"]');
await expect.poll(() => node.locator("img").first().evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
await page.evaluate(() => {
(window as any).blankPreviewFrames = 0;
const inspect = () => {
const node = document.querySelector('[data-canvas-node-id="preview-image"]');
if (node && !Array.from(node.querySelectorAll("img")).some(image => Number(getComputedStyle(image).opacity) > 0 && image.naturalWidth > 0)) (window as any).blankPreviewFrames++;
requestAnimationFrame(inspect);
};
requestAnimationFrame(inspect);
});
await page.mouse.move(500, 350);
for (let i = 0; i < 18; i++) {
await page.mouse.wheel(0, i < 9 ? -100 : 100);
await page.waitForTimeout(50);
}
await page.mouse.move(100, 100);
await page.waitForTimeout(500);
expect(await page.evaluate(() => (window as any).blankPreviewFrames)).toBe(0);
});

View File

@ -53,7 +53,6 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
ReadableStream, ReadableStream,
Headers, Headers,
Blob, Blob,
FormData,
Request, Request,
Date: mocks.Date || Date, Date: mocks.Date || Date,
document: mocks.document, document: mocks.document,

View File

@ -71,8 +71,6 @@ test("a submitted task with an unknown outcome is not submitted again through a
await expect(h.generate(h.request)).rejects.toThrow(); await expect(h.generate(h.request)).rejects.toThrow();
expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0); expect(h.calls.map(c => c.id)).toEqual(["primary"]); expect(h.paused.size).toBe(0);
expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false); expect(agentImageFailurePolicy(Object.assign(new Error("timeout"), { providerOutcomeUnknown: true })).retry).toBe(false);
expect(agentImageFailurePolicy(Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true })).retry).toBe(false);
expect(agentImageFailurePolicy(Object.assign(new Error("This operation was aborted"), { name: "AbortError" })).retry).toBe(false);
expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true); expect(agentImageFailurePolicy(new Error("VIP98K task failed: timeout")).retry).toBe(true);
}); });

View File

@ -102,7 +102,7 @@ test("mixed-model batches bill and label only the delivered images using their a
test("headless image features cannot substitute their default when a pinned model disappears", async () => { test("headless image features cannot substitute their default when a pinned model disappears", async () => {
let generated = 0; let generated = 0;
const mocks: Record<string, any> = { const mocks: Record<string, any> = {
"@/lib/canvas/operations/box-cutout": {}, "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {}, "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
"@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } }, "@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } },
"@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null }, "@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null },

View File

@ -1,15 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
test("image metadata containing video or audio paths remains an image", () => {
for (const folder of ["video-canvas-demo", "audio-preview"]) {
const url = `https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/${folder}/cover.png`;
const markdown = `![旧链接图片|||${url}|||2K](${url})`;
expect(extractVideoUrls(markdown)).toEqual([]);
expect(extractAudioUrls(markdown)).toEqual([]);
expect(extractAssistantImageUrls(markdown)).toContain(url);
expect(stripMediaMarkup(markdown)).toBe(markdown);
}
});
import { import {
collectMessageImageUrls, collectMessageImageUrls,
extractAssistantImageUrls, extractAssistantImageUrls,
@ -808,7 +797,6 @@ test.describe("canvas chat pure helpers", () => {
}); });
test("canvas service save uses store revision fallback when options are omitted", async () => { test("canvas service save uses store revision fallback when options are omitted", async () => {
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -886,7 +874,7 @@ test.describe("canvas chat pure helpers", () => {
}); });
}); });
test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => { test("canvas service keeps store revision aligned after project load and save", async () => {
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -916,7 +904,6 @@ test.describe("canvas chat pure helpers", () => {
}, },
]; ];
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
useCanvasStore.getState().setCanvasRevision(null, null); useCanvasStore.getState().setCanvasRevision(null, null);
Object.defineProperty(globalThis, "window", { Object.defineProperty(globalThis, "window", {
configurable: true, configurable: true,
@ -931,11 +918,7 @@ test.describe("canvas chat pure helpers", () => {
}) as Response) as typeof fetch; }) as Response) as typeof fetch;
try { try {
const result = await canvasService.getProject("11111111-1111-4111-8111-111111111111"); await canvasService.getProject("11111111-1111-4111-8111-111111111111");
expect(useCanvasStore.getState().canvasRevision).toBeNull();
useCanvasStore.getState().loadCanvas(result.data!.canvasData);
const revision = String(result.data!.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
expect(useCanvasStore.getState().canvasRevision).toBe( expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:00.000Z" "2026-06-22T00:01:00.000Z"
); );

View File

@ -236,6 +236,8 @@ test.describe("canvas scale adjust product lock", () => {
const rmbg = readSource("lib/ai/rmbg/service.ts"); const rmbg = readSource("lib/ai/rmbg/service.ts");
expect(productLock).toContain('foregroundType: "person"'); expect(productLock).toContain('foregroundType: "person"');
expect(productLock).toContain("attempt <= 3");
expect(productLock).toContain("executeScaleAdjustRmbgJob({");
expect(productLock).toContain("regionPersonExclusionAlpha"); expect(productLock).toContain("regionPersonExclusionAlpha");
expect(productLock).toContain("overlapPixelCount >= overlapLimit"); expect(productLock).toContain("overlapPixelCount >= overlapLimit");
expect(productLock).toContain("目标产品被人物遮挡"); expect(productLock).toContain("目标产品被人物遮挡");

View File

@ -4,7 +4,6 @@ import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capa
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { canvasService } from "@/services/canvas"; import { canvasService } from "@/services/canvas";
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 },
@ -87,49 +86,6 @@ test("a lost submission response polls the original task and never resubmits", a
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]); expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]);
}); });
test("a recovered matting rejection shows its dimensions and advice without changing the canvas or resubmitting", async () => {
const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.remove_background", nodeIds: ["source"], source: "toolbar" });
const failure = getRequestFailureDetails("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。");
const message = formatRequestFailure(failure, submitted.requestId);
let posts = 0;
globalThis.fetch = (async (url, options) => {
if (options?.method === "POST") { posts++; return new Response(null, { status: 504 }); }
expect(String(url)).toContain(encodeURIComponent(submitted.requestId));
return Response.json({ success: true, task: { status: "failed", failure, error: message } });
}) as typeof fetch;
await expect(executeCanvasToolbarCapability(submitted)).rejects.toThrow(message);
expect(posts).toBe(1);
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
});
test("a lost box-cutout response recovers the same selection into its original canvas with one submission", async () => {
const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.box_cutout", nodeIds: ["source"], source: "toolbar", args: { x: 0.25, y: 0.2, width: 0.4, height: 0.5, paddingRatio: 0.08 } });
let posts = 0, polls = 0;
const selected = { ...node("cutout"), data: { src: "https://example.com/cutout.png", metadata: { editMode: "box-cutout", cropRectApplied: submitted.args } } };
globalThis.fetch = (async (url, options) => {
if (options?.method === "POST") {
posts++;
expect(JSON.parse(String(options.body))).toMatchObject({ capabilityId: "image.box_cutout", requestId: submitted.requestId, args: submitted.args });
useCanvasStore.getState().moveNode("source", { x: 20, y: 30 });
return new Response(null, { status: 524 });
}
if (String(url).includes("/capabilities?")) {
polls++;
expect(String(url)).toContain(encodeURIComponent(submitted.requestId));
return Response.json({ success: true, task: { status: "succeeded" } });
}
return Response.json({ success: true, data: { updatedAt: "r2", canvasData: { nodes: [node("source"), selected], edges: [] } } });
}) as typeof fetch;
await executeCanvasToolbarCapability(submitted);
expect({ posts, polls }).toEqual({ posts: 1, polls: 1 });
const state = useCanvasStore.getState();
expect(state.projectId).toBe("project-a");
expect(state.nodes.map(n => n.id)).toEqual(["source", "cutout"]);
expect(state.nodes[0].position).toEqual({ x: 20, y: 30 });
expect(state.nodes[1].data).toEqual(selected.data);
});
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")) {
@ -163,71 +119,6 @@ test("metadata changes never adopt an unseen remote canvas revision", async () =
expect(useCanvasStore.getState().canvasRevision).toBe("r1"); expect(useCanvasStore.getState().canvasRevision).toBe("r1");
}); });
test("reading a result without applying it cannot authorize an old snapshot to overwrite the result", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let remoteNodes = [node("source"), node("result")];
globalThis.fetch = (async (_url, options) => {
if (options?.method === "PUT") {
const body = JSON.parse(String(options.body));
if (body.baseCanvasRevision !== "r2") return Response.json({ success: false, error: "画布已更新" }, { status: 409 });
remoteNodes = body.canvasData.nodes;
return Response.json({ success: true, data: { updatedAt: "r3" } });
}
return Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: remoteNodes, edges: [] } } });
}) as typeof fetch;
try {
await canvasService.getProject("project-a");
const saved = await canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
expect(saved).toMatchObject({ success: false, status: 409 });
expect(remoteNodes.map(n => n.id)).toEqual(["source", "result"]);
expect(useCanvasStore.getState().canvasRevision).toBe("r1");
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a delayed read cannot change the revision of a newly selected project", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let complete!: (response: Response) => void;
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
try {
const pending = canvasService.getProject("project-a");
useCanvasStore.getState().setProject("project-b");
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
useCanvasStore.getState().setCanvasRevision("r9", "r9");
complete(Response.json({ success: true, data: { id: "project-a", updatedAt: "r2", canvasData: { nodes: [node("result")], edges: [] } } }));
await pending;
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a delayed save cannot change the revision of a newly selected project", async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Object.defineProperty(globalThis, "window", { configurable: true, value: {} });
let complete!: (response: Response) => void;
globalThis.fetch = (() => new Promise<Response>(resolve => { complete = resolve; })) as typeof fetch;
try {
const pending = canvasService.saveCanvasState("project-a", useCanvasStore.getState().getCanvasState());
useCanvasStore.getState().setProject("project-b");
useCanvasStore.getState().loadCanvas({ nodes: [node("other") as any], edges: [] });
useCanvasStore.getState().setCanvasRevision("r9", "r9");
complete(Response.json({ success: true, data: { updatedAt: "r2" } }));
await pending;
expect(useCanvasStore.getState().canvasRevision).toBe("r9");
expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["other"]);
} finally {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
}
});
test("a result merge conflict retains both the local edit and its original revision", async () => { test("a result merge conflict retains both the local edit and its original revision", async () => {
globalThis.fetch = (async (url) => { globalThis.fetch = (async (url) => {
if (String(url).endsWith("/capabilities")) { if (String(url).endsWith("/capabilities")) {

View File

@ -2,77 +2,9 @@ import { expect, test } from "@playwright/test";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import sharp from "sharp"; import sharp from "sharp";
import { GET } from "@/app/api/image-proxy/route"; import { GET } from "@/app/api/image-proxy/route";
import { buildImageProxyUrl, getFullscreenPreviewSize, getImageDownloadExtension } from "@/lib/image-delivery"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { NextResponse } from "next/server";
test.describe("Qiniu image delivery", () => { test.describe("Qiniu image delivery", () => {
test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png";
const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");
expect(preview.pathname).toBe("/api/image-proxy");
expect(preview.searchParams.get("url")).toBe(source);
expect(preview.searchParams.has("x-oss-process")).toBe(false);
expect(buildImageProxyUrl("https://other-bucket.oss-cn-shanghai.aliyuncs.com/image.png", { width: 960 })).toContain("x-oss-process=");
});
test("a completed archive serves old model-result references from Qiniu immediately", async () => {
const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/archived.png";
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({ [source]: "https://yybb.top/archived.png" }) },
fetch: () => { throw new Error("Must use the completed archive"); },
})("app/api/image-proxy/route.ts");
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", "960"); request.searchParams.set("f", "webp");
const response = await route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("https://yybb.top/archived.png?imageView2/2/w/960/format/webp/q/82");
});
test("unarchived model previews share one original fetch with archiving and return resized WebP", async () => {
const source = `https://dashscope-a717.oss-accelerate.aliyuncs.com/${crypto.randomUUID()}.png`;
const original = await sharp({ create: { width: 1024, height: 1024, channels: 3, background: "#345678" } }).png().toBuffer();
let requests = 0;
const load = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { getArchivedImageUrlMappings: async () => ({}) },
fetch: async (url: string) => {
expect(url).toBe(source); requests++;
await new Promise(resolve => setTimeout(resolve, 30));
return new Response(original, { headers: { "content-type": "image/png" } });
},
});
const route = load("app/api/image-proxy/route.ts");
const archiveRead = load("lib/storage/image-byte-cache.ts").fetchImageBytes(source);
const responses = await Promise.all([100, 200].map(width => {
const request = new URL("https://app.example/api/image-proxy");
request.searchParams.set("url", source); request.searchParams.set("w", String(width)); request.searchParams.set("f", "webp");
return route.GET(new NextRequest(request, { headers: { "sec-fetch-dest": "image" } }));
}));
expect((await archiveRead).buffer.equals(original)).toBe(true);
expect(requests).toBe(1);
for (let i = 0; i < responses.length; i++) {
expect(responses[i].status).toBe(200);
expect(responses[i].headers.get("content-type")).toBe("image/webp");
expect((await sharp(Buffer.from(await responses[i].arrayBuffer())).metadata()).width).toBe([100, 200][i]);
}
});
test("sizes a fullscreen square to its visible height and reuses the chat preview on standard density screens", () => {
const size = getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884 });
expect(size.displayWidth).toBeCloseTo(839.8);
expect(size.requestWidth).toBe(960);
expect(buildImageProxyUrl("https://yybb.top/a.png", { width: size.requestWidth, quality: 86, format: "webp" }))
.toBe(buildImageProxyUrl("https://yybb.top/a.png", { width: 960, quality: 86, format: "webp" }));
});
test("preserves screen density and image aspect ratio without requesting oversized fixed previews", () => {
expect(getFullscreenPreviewSize({ imageWidth: 2048, imageHeight: 2048, viewportWidth: 1657, viewportHeight: 884, pixelRatio: 2 }).requestWidth).toBe(2048);
const portrait = getFullscreenPreviewSize({ imageWidth: 1000, imageHeight: 2000, viewportWidth: 1440, viewportHeight: 900, pixelRatio: 2 });
expect(portrait.displayWidth).toBeCloseTo(427.5);
expect(portrait.requestWidth).toBe(960);
expect(getFullscreenPreviewSize({ imageWidth: 4000, imageHeight: 1000, viewportWidth: 5000, viewportHeight: 2000, pixelRatio: 2 }).requestWidth).toBe(4096);
});
test("reports a timeout while reading image bytes as a retryable response", async () => { test("reports a timeout while reading image bytes as a retryable response", async () => {
const previousFetch = globalThis.fetch; const previousFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(new ReadableStream({ globalThis.fetch = async () => new Response(new ReadableStream({

View File

@ -1,39 +0,0 @@
import { expect, test } from "@playwright/test";
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
import { getRequestFailureDetails } from "@/lib/request-error";
import { serverLoader } from "../helpers/canvas-download-server-loader";
test("a terminal image error reaches the stream and persists for disconnected clients", async () => {
const chunks: any[] = [];
const saved: any[] = [];
const load = serverLoader({ "@/lib/db/queries": { saveMessages: async ({ messages }: any) => saved.push(...messages) } });
const { reportImageGenerationFailure } = load("app/(chat)/api/chat/_lib/image-generation-failure.ts");
await reportImageGenerationFailure({
writer: { write: (chunk: any) => chunks.push(chunk) },
chatId: "chat", userMessageId: "original-user", messageId: "request-id",
error: new Error("get image url failed"), label: "test",
});
const streamed = JSON.parse(chunks.find(chunk => chunk.type === "data-imageGenerationStatus").data);
const persisted = findImageGenerationFailure(saved, "original-user");
expect(persisted).toEqual(streamed);
expect(persisted).toMatchObject({ phase: "failed", requestId: "request-id", failure: { code: "SOURCE_IMAGE_UNAVAILABLE", outcome: "failed" } });
const text = saved[0].parts[0].text;
expect(text).toContain("重新上传原图");
expect(text).toContain("请求编号:request-id");
expect(JSON.parse(chunks.at(-1).data).parts[0].text).toContain("IMAGE_GENERATION_END");
await reportImageGenerationFailure({
writer: { write: () => { throw new Error("browser disconnected"); } },
chatId: "chat", userMessageId: "next-user", messageId: "next-request",
error: new Error("get image url failed"), label: "test",
});
expect(findImageGenerationFailure(saved, "next-user")?.requestId).toBe("next-request");
});
test("late failures remain associated with their original user request", () => {
const status = { phase: "failed" as const, userMessageId: "first", failure: getRequestFailureDetails(new Error("HTTP 524")) };
const messages = [{ role: "user", parts: [] }, { role: "assistant", parts: [{ type: "data-imageGenerationStatus", data: status }] }];
expect(findImageGenerationFailure(messages, "first")).toEqual(status);
expect(findImageGenerationFailure(messages, "second")).toBeNull();
expect(findImageGenerationFailure([{ role: "assistant", parts: [{ type: "text", text: "请分析失败原因" }] }], "second")).toBeNull();
});

View File

@ -17,7 +17,6 @@ test("known provider and platform failures explain their cause and next action",
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"], ["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
["HTTP 410: 旧存储已停用,源文件已无法恢复,请重新上传。", "SOURCE_STORAGE_RETIRED", "重新上传"],
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"], ["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"], ["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"], ["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
@ -69,20 +68,6 @@ test("HTTP 524 does not start another model while the original result is unconfi
expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true); expect(health.shouldFailoverAiModelError(new Error("Service unavailable (HTTP 503)"))).toBe(true);
}); });
test("aborts and unconfirmed fallback results cannot start another paid generation", () => {
const { health } = healthHarness();
for (const error of [
Object.assign(new Error("This operation was aborted"), { name: "AbortError" }),
Object.assign(new Error("request timed out"), { name: "TimeoutError" }),
Object.assign(new Error("任务查询失败"), { providerOutcomeUnknown: true }),
new Error("图片上传到存储桶失败"),
]) {
expect(getRequestFailureDetails(error).outcome).toBe("unknown");
expect(health.shouldFailoverAiModelError(error)).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 () => { test("the provider submits once when an HTTP 524 request cannot be located", async () => {
let submissions = 0; let submissions = 0;
const provider = serverLoader({ const provider = serverLoader({

View File

@ -1,137 +0,0 @@
import { test, expect } from "@playwright/test";
import { NextRequest, NextResponse } from "next/server";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { normalizeLegacyAliyunStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { collectImageArchiveSources, replaceArchivedImageUrls } from "@/lib/storage/image-archive-urls";
import { buildImageProxyUrl } from "@/lib/image-delivery";
const legacy = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/uploads/product.png";
const permanent = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/uploads/product.png";
test("legacy identity preserves image variants while dropping expired OSS credentials", () => {
const signed = legacy.replace("https:", "http:") + "?Signature=expired&Expires=1&OSSAccessKeyId=old&x-oss-process=image%2Fresize%2Cw_100#preview";
expect(normalizeLegacyAliyunStorageUrl(signed)).toBe(legacy + "?x-oss-process=image%2Fresize%2Cw_100");
expect(normalizeLegacyAliyunStorageUrl("/api/image-proxy?url=" + encodeURIComponent(signed))).toBe(normalizeLegacyAliyunStorageUrl(signed));
for (const source of [permanent, "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/image.png", "https://other-bucket.oss-cn-shanghai.aliyuncs.com/a.png"]) {
expect(normalizeLegacyAliyunStorageUrl(source)).toBeNull();
}
expect(isLegacyAliyunStorageConfig({ bucket: "demo-sparkai", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(true);
expect(isLegacyAliyunStorageConfig({ bucket: "other-bucket", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(false);
});
test("late canvas saves replace legacy URLs embedded in SVG, HTML and Markdown originals", () => {
const content = { svg: `<svg><image href="${legacy}"/></svg>`, html: `<img src='${legacy}'>`, text: `![thumb|||${legacy}|||2K](${permanent})` };
expect(collectImageArchiveSources(content)).toContain(legacy);
const result = replaceArchivedImageUrls(content, { [legacy]: permanent });
expect(JSON.stringify(result)).not.toContain("https://demo-sparkai.");
expect(result.svg).toContain(permanent);
expect(result.text).toBe(`![thumb|||${permanent}|||2K](${permanent})`);
});
test("legacy URLs in comma-separated reference descriptions keep sentence punctuation", () => {
const text = `Reference: ${legacy}, next reference`;
expect(collectImageArchiveSources(text)).toContain(legacy);
expect(replaceArchivedImageUrls(text, { [legacy]: permanent })).toBe(`Reference: ${permanent}, next reference`);
});
test("legacy display URLs go through archive resolution while other Alibaba buckets keep working", () => {
const url = buildImageProxyUrl(legacy, { width: 400 });
expect(url).toMatch(/^\/api\/image-proxy\?/);
expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy);
expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toMatch(/^\/api\/image-proxy\?/);
});
test("browser image fallbacks remain on one stable archive URL instead of retrying the old source", () => {
const delivery = serverLoader({ window: { location: { origin: "https://app.example" } } })("lib/image-delivery.ts");
const fallback = delivery.buildImageFallbackUrl(legacy);
expect(fallback).toMatch(/^https:\/\/app\.example\/api\/image-proxy\?/);
expect(delivery.buildImageFallbackUrl(fallback)).toBe(fallback);
expect(delivery.buildImageFallbackUrl(permanent)).toBe(permanent);
});
function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) {
return serverLoader({
"@/lib/db": { db: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => { throw new Error("Legacy sources must not be queued"); } } },
"@/lib/db/schema": { imageArchiveTask: { sourceUrl: "source", targetUrl: "target" } },
})("lib/storage/image-archive-db.ts");
}
test("archive lookup serves signed legacy links from the durable Qiniu alias", async () => {
const archive = archiveLoader([{ sourceUrl: legacy, targetUrl: permanent }]);
expect(await archive.resolveLegacyAliyunStorageUrl(legacy + "?Expires=1&Signature=expired")).toBe(permanent);
expect(await archive.resolveLegacyAliyunStorageUrl(permanent)).toBe(permanent);
expect(await archive.enqueueImageArchiveTask({ sourceUrl: legacy })).toBeNull();
});
test("legacy files without a durable copy fail permanently instead of fetching the old bucket", async () => {
const archive = archiveLoader([]);
await expect(archive.resolveLegacyAliyunStorageUrl(legacy)).rejects.toThrow("HTTP 410");
let fetched = 0;
const bytes = serverLoader({
"./image-archive-db": { resolveLegacyAliyunStorageUrl: archive.resolveLegacyAliyunStorageUrl },
fetch: () => { fetched++; throw new Error("Unexpected old-bucket request"); },
})("lib/storage/image-byte-cache.ts");
await expect(bytes.fetchImageBytes(legacy)).rejects.toThrow(RETIRED_STORAGE_MESSAGE);
expect(fetched).toBe(0);
});
test("byte reads use the resolved Qiniu object and preserve its bytes", async () => {
const seen: string[] = [];
const target = permanent + "?acceptance=" + crypto.randomUUID();
const original = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1]);
const bytes = serverLoader({
"./image-archive-db": { resolveLegacyAliyunStorageUrl: async () => target },
fetch: async (url: string) => { seen.push(url); return new Response(original, { headers: { "content-type": "image/png" } }); },
})("lib/storage/image-byte-cache.ts");
expect((await bytes.fetchImageBytes(legacy)).buffer.equals(original)).toBe(true);
expect(seen).toEqual([target]);
});
test("preview requests redirect to Qiniu after resolving a legacy alias", async () => {
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
fetch: () => { throw new Error("Legacy previews must not fetch the old bucket"); },
})("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?url=" + encodeURIComponent(legacy), { headers: { "sec-fetch-dest": "image" } }));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(permanent);
});
test("original downloads resolve the archived file without reading the legacy source", async () => {
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent },
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async (url: string) => {
expect(url).toBe(permanent);
return { delivery: "cdn", downloadUrl: permanent + "?attname=original.png", filename: "original.png" };
} },
fetch: () => { throw new Error("Unexpected source request"); },
})("app/api/image-proxy/route.ts");
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?original=1&download=resolve&url=" + encodeURIComponent(legacy)));
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ delivery: "cdn", filename: "original.png" });
});
test("unrecoverable preview and download requests return a cause and recommendation immediately", async () => {
let fetched = 0;
const route = serverLoader({
"next/server": { NextResponse },
"@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => { throw new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE); } },
fetch: () => { fetched++; throw new Error("Unexpected source request"); },
})("app/api/image-proxy/route.ts");
for (const query of ["", "original=1&download=resolve&"]) {
const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?" + query + "url=" + encodeURIComponent(legacy)));
const data = await response.json();
expect(response.status).toBe(410);
expect(data.failure).toMatchObject({ code: "STORAGE_RETIRED", retryable: false });
expect(data.failure.recommendation).toContain("重新上传");
expect(data.error).toContain(data.requestId);
}
expect(fetched).toBe(0);
});
test("retired model inputs are reported as missing files rather than a model-service failure", async () => {
const { getRequestFailureDetails } = await import("@/lib/request-error");
expect(getRequestFailureDetails(new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE))).toMatchObject({ code: "SOURCE_STORAGE_RETIRED", outcome: "failed" });
});

View File

@ -18,18 +18,16 @@ test('retired URLs include stored proxy URLs but do not include live storage', (
test('original image proxy returns permanent failure without redirecting or fetching', async () => { test('original image proxy returns permanent failure without redirecting or fetching', async () => {
let fetches = 0; let fetches = 0;
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw new Error('Unexpected network'); } })('app/api/image-proxy/route.ts'); const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}})); const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}}));
expect(response.status).toBe(410); expect(response.status).toBe(410);
const data = await response.json(); expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
expect(data.failure).toMatchObject({code:'STORAGE_RETIRED',retryable:false});
expect(data.error).toContain(data.requestId);
expect(fetches).toBe(0); expect(fetches).toBe(0);
}); });
test('byte cache never fetches retired resources', async () => { test('byte cache never fetches retired resources', async () => {
let fetches = 0; let fetches = 0;
const module = serverLoader({ fetch: () => { fetches++; throw new Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts'); const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410'); await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410');
expect(fetches).toBe(0); expect(fetches).toBe(0);
}); });
@ -37,7 +35,7 @@ test('byte cache never fetches retired resources', async () => {
test('retired sources cannot enter the archive queue', async () => { test('retired sources cannot enter the archive queue', async () => {
let inserts = 0; let inserts = 0;
const module = serverLoader({ const module = serverLoader({
'@/lib/db': {db:{insert:()=>{inserts++;throw new Error('Unexpected insert');}}}, '@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}},
'@/lib/db/schema': {}, '@/lib/db/schema': {},
})('lib/storage/image-archive-db.ts'); })('lib/storage/image-archive-db.ts');
expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull(); expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull();

View File

@ -1,404 +0,0 @@
import { expect, test } from "@playwright/test";
import sharp from "sharp";
import { serverLoader } from "../helpers/canvas-download-server-loader";
import { getRequestFailureDetails } from "@/lib/request-error";
const cdn = "https://scqnycdn.jiansun.vip";
test("box cutout submits exactly the padded selection and keeps the original URL as provenance", async () => {
const pixels = Buffer.alloc(120 * 80 * 3);
for (let y = 0; y < 80; y++) for (let x = 0; x < 120; x++) {
const offset = (y * 120 + x) * 3;
pixels[offset] = x * 2; pixels[offset + 1] = y * 3; pixels[offset + 2] = x + y;
}
const original = await sharp(pixels, { raw: { width: 120, height: 80, channels: 3 } }).png().toBuffer();
let submitted: any;
const box = serverLoader({
"@/lib/ai/rmbg/service": {
fetchImageBufferFromUrl: async () => original,
executeRmbgJob: async (params: any) => { submitted = params; return { original_url: `${cdn}/selected.png` }; },
},
})("lib/ai/box-cutout/service.ts");
const result = await box.executeBoxCutoutJob({ userId: "user-a", requestId: "box-a", sourceNodeId: "source", imageUrl: `${cdn}/original.png`, cropRect: { x: 0.25, y: 0.25, width: 0.5, height: 0.5 }, paddingRatio: 0.1 });
expect(result.cropRectPixels).toEqual({ left: 30, top: 20, width: 60, height: 40 });
expect(result.paddedCropRectPixels).toEqual({ left: 24, top: 16, width: 72, height: 48 });
expect(submitted).toMatchObject({ requestId: "box-a", sourceImageUrl: `${cdn}/original.png` });
expect(submitted.inputImageUrl).toBeUndefined();
const expected = await sharp(original).extract(result.paddedCropRectPixels).raw().toBuffer();
expect((await sharp(submitted.imageBuffer).raw().toBuffer()).equals(expected)).toBe(true);
});
test("box cutout shares the configured charge once after success and never charges a rejected input", async () => {
class InputError extends Error { status = 400; }
for (const rejected of [false, true]) {
let creates = 0, charges = 0;
const operation = serverLoader({
"@/lib/ai/box-cutout/service": { BoxCutoutServiceError: InputError, executeBoxCutoutJob: async (params: any) => {
creates++;
expect(params.requestId).toBe("box-a");
if (rejected) throw new InputError("抠图参考图分辨率超限(5000×3000 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。");
return { success: true, original_url: `${cdn}/selected.png` };
} },
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError },
"@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: true, creditCostPerImageBoxCutout: 7 }) },
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async (params: any) => {
expect(params).toMatchObject({ userId: "user-a", unitCost: 7, reason: "image_box_cutout", modelId: "box-cutout" });
return { ok: true, chargeable: true, consume: async () => { charges++; } };
} },
})("lib/canvas/operations/box-cutout.ts");
const response = await operation.executeBoxCutoutPOST(new Request("https://example.com/api/canvas/box-cutout", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "box-a" }, body: JSON.stringify({ imageUrl: `${cdn}/original.png`, cropRect: { x: 0, y: 0, width: 0.5, height: 0.5 } }) }), "user-a");
const result = await response.json();
expect({ creates, charges }).toEqual({ creates: 1, charges: rejected ? 0 : 1 });
expect(response.status).toBe(rejected ? 400 : 200);
if (rejected) {
expect(result.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(result.error).toContain("5000×3000");
expect(result.error).toContain("本次未提交上游抠图任务");
expect(result.error).toContain("请求编号:box-a");
}
}
});
test("headless box cutout keeps its task identity, pixel bounds and input-rejection status", async () => {
class InputError extends Error {
status: number;
constructor(message: string, status: number) { super(message); this.status = status; }
}
let calls = 0;
let rejectInput = false;
const bounds = { left: 20, top: 30, width: 200, height: 300 };
const executor = serverLoader({
"@/lib/ai/box-cutout/service": {},
"@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async (request: Request, userId: string) => {
calls++;
expect(userId).toBe("user-a");
expect(request.headers.get("x-request-id")).toBe("box-a");
expect(await request.json()).toMatchObject({ sourceNodeId: "source", cropRect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, paddingRatio: 0.08 });
return rejectInput ? Response.json({ success: false, detail: "抠图参考图分辨率超限(5000×3000 像素)" }, { status: 400 })
: Response.json({ success: true, original_url: `${cdn}/cutout.png`, paddedCropRectPixels: bounds });
} },
"@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {},
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError },
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
"@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {},
})("lib/canvas/capabilities/headless-executor.ts");
const params = { userId: "user-a", requestUrl: "https://example.com/api/canvas/project-a/capabilities", requestHeaders: new Headers(), command: { requestId: "box-a", capabilityId: "image.box_cutout", source: "toolbar", args: { x: 0.1, y: 0.2, width: 0.3, height: 0.4, paddingRatio: 0.08 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/original.png` }] };
const results = await executor.executeCanvasCapabilityHeadlessly(params);
expect(calls).toBe(1);
expect(results).toMatchObject([{ sourceNodeId: "source", url: `${cdn}/cutout.png`, metadata: { editMode: "box-cutout", paddedCropRectPixels: bounds } }]);
rejectInput = true;
await expect(executor.executeCanvasCapabilityHeadlessly(params)).rejects.toMatchObject({ status: 400 });
expect(calls).toBe(2);
});
test("Agent and MCP box cutouts retain their existing billing path", async () => {
let jobs = 0, featureCharges = 0;
const executor = serverLoader({
"@/lib/ai/box-cutout/service": { executeBoxCutoutJob: async (params: any) => {
jobs++;
expect(params).toMatchObject({ userId: "user-a", requestId: "box-agent", sourceNodeId: "source" });
return { original_url: `${cdn}/cutout.png` };
} },
"@/lib/canvas/operations/box-cutout": { executeBoxCutoutPOST: async () => { featureCharges++; throw new Error("Unexpected toolbar charge"); } },
"@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
"@/lib/canvas/generate-image": {}, "@/lib/style-pro/model-options": {},
})("lib/canvas/capabilities/headless-executor.ts");
for (const source of ["agent", "mcp", "system"]) {
await executor.executeCanvasCapabilityHeadlessly({ userId: "user-a", command: { requestId: "box-agent", capabilityId: "image.box_cutout", source, args: { x: 0, y: 0, width: 0.5, height: 0.5 } }, targets: [{ nodeId: "source", title: "原图", sourceImageUrl: `${cdn}/source.png` }] });
}
expect({ jobs, featureCharges }).toEqual({ jobs: 3, featureCharges: 0 });
});
function rmbgLoader(fetch: typeof globalThis.fetch, timer?: typeof setTimeout) {
return serverLoader({
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) },
"@/lib/db": { db: {} },
"@/lib/db/schema": { generatedImage: {} },
"@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) },
"@/lib/observability/active-work": { trackRuntimeWork: async (execute: () => unknown) => execute() },
fetch,
setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms <= 2000 ? 0 : ms)),
}, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts");
}
test("AOS uses a public Qiniu original URL without uploading the image file again", async () => {
const image = await sharp({ create: { width: 1024, height: 768, channels: 3, background: "#abcdef" } }).png().toBuffer();
let creates = 0;
const source = `${cdn}/uploads/original.png`;
const service = rmbgLoader(async (input, options) => {
const url = String(input);
if (options?.method === "POST") {
creates++;
const form = options.body as FormData;
expect(form.get("image_url")).toBe(source);
expect(form.has("image_file")).toBe(false);
expect(form.get("sync")).toBe("0");
return Response.json({ data: { task_id: "task-url" } });
}
if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } });
});
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
expect(creates).toBe(1);
expect(result.provider).toBe("aoscdn");
expect(result.original_url).toContain(cdn);
});
test("non-CDN sources upload the original file without changing its bytes or dimensions", async () => {
for (const [width, source] of [[4096, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) {
const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer();
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") {
const form = options.body as FormData;
expect(form.has("image_url")).toBe(false);
const file = form.get("image_file") as File;
const submitted = Buffer.from(await file.arrayBuffer());
expect(submitted.equals(image)).toBe(true);
expect((await sharp(submitted).metadata()).width).toBe(width);
return Response.json({ data: { task_id: "task-file" } });
}
if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(image, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: image, inputImageUrl: source, sourceImageUrl: source, skipGallerySave: true });
}
});
test("a cropped input uses its own bytes instead of the original provenance URL", async () => {
const original = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#abcdef" } }).png().toBuffer();
const cropped = await sharp(original).extract({ left: 20, top: 10, width: 40, height: 20 }).png().toBuffer();
let submitted!: FormData;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { submitted = options.body as FormData; return Response.json({ data: { task_id: "cropped-task" } }); }
if (String(input).endsWith("/cropped-task")) return Response.json({ data: { state: 1, image: "https://output.example/cropped.png" } });
return new Response(cropped, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: cropped, sourceImageUrl: `${cdn}/original.png`, skipGallerySave: true });
expect(submitted.has("image_url")).toBe(false);
const bytes = Buffer.from(await (submitted.get("image_file") as File).arrayBuffer());
expect(bytes.equals(cropped)).toBe(true);
expect(await sharp(bytes).metadata()).toMatchObject({ width: 40, height: 20 });
});
test("scale adjustment does not start a new matting job after an unconfirmed result", async () => {
let creates = 0;
const image = await sharp({ create: { width: 64, height: 64, channels: 3, background: "white" } }).png().toBuffer();
const scale = serverLoader({
"@/lib/ai/rmbg/service": { executeRmbgJob: async () => { creates++; throw new Error("AOS查询任务超时,结果尚未确认"); } },
setTimeout: (fn: () => void) => setTimeout(fn, 0),
})("lib/canvas/scale-adjust-product-lock.ts");
await expect(scale.buildScaleAdjustProductLockAssets({ userId: "test", sourceBuffer: image, sourceWidth: 64, sourceHeight: 64, sourceImageUrl: `${cdn}/source.png`, regions: [{ index: 0, rect: { x: 0.1, y: 0.1, width: 0.5, height: 0.5 }, targetRect: { x: 0.2, y: 0.2, width: 0.5, height: 0.5 } }] })).rejects.toThrow("结果尚未确认");
expect(creates).toBe(1);
});
test("scene preprocessing invokes matting once even when its result is unconfirmed or the call is interrupted", async () => {
for (const interrupted of [false, true]) {
let calls = 0, networkCalls = 0;
const scene = serverLoader({
"@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async (request: Request, userId: string) => {
calls++;
expect(userId).toBe("user-a");
expect(request.headers.get("x-request-id")).toBe("scene-a:pre-rmbg");
expect(await request.json()).toMatchObject({ imageUrl: `${cdn}/source.png`, skipGallerySave: true });
if (interrupted) throw new TypeError("fetch failed");
return Response.json({ error: "抠图已完成,但结果图片下载失败,请核对原任务。", failure: { outcome: "unknown" } }, { status: 500 });
} },
fetch: async () => { networkCalls++; throw new Error("Unexpected HTTP resubmission"); },
})("lib/scene-fusion/preprocess-product.ts");
const result = await scene.preprocessProductImageWithRmbg({ request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png`, preRmbgEnabled: true });
expect(result).toMatchObject({ productImageUrl: `${cdn}/source.png`, usedPreRmbg: false });
expect({ calls, networkCalls }).toEqual({ calls: 1, networkCalls: 0 });
}
});
test("scene preprocessing retains the disabled setting and delivers a successful original result", async () => {
let calls = 0;
const scene = serverLoader({ "@/lib/canvas/operations/rmbg": { executeRemoveBackgroundPOST: async () => { calls++; return Response.json({ original_url: `${cdn}/result.png`, provider: "aoscdn" }); } } })("lib/scene-fusion/preprocess-product.ts");
const params = { request: new Request("https://example.com/api/scene-fusion"), requestId: "scene-a", userId: "user-a", productImageUrl: `${cdn}/source.png` };
expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: false })).toMatchObject({ usedPreRmbg: false });
expect(calls).toBe(0);
expect(await scene.preprocessProductImageWithRmbg({ ...params, preRmbgEnabled: true })).toMatchObject({ usedPreRmbg: true, productImageUrl: `${cdn}/result.png` });
expect(calls).toBe(1);
});
test("the standalone matting API returns the limit, recommendation and request ID", async () => {
class InputError extends Error { status = 400; }
let submitted: any;
const operation = serverLoader({
"@/lib/ai/rmbg/service": { RmbgServiceError: InputError, fetchImageBufferFromUrl: async () => Buffer.from("original"), executeRmbgJob: async (params: any) => { submitted = params; throw new InputError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); } },
"@/lib/db/queries": { getPointsSystemConfig: async () => ({ enabled: false }) },
"@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) },
})("lib/canvas/operations/rmbg.ts");
const response = await operation.executeRemoveBackgroundPOST(new Request("https://example.com/api/rmbg", { method: "POST", headers: { "Content-Type": "application/json", "X-Request-ID": "request-a" }, body: JSON.stringify({ imageUrl: `${cdn}/source.png` }) }), "user-a");
const result = await response.json();
expect(response.status).toBe(400);
expect(submitted).toMatchObject({ requestId: "request-a", inputImageUrl: `${cdn}/source.png` });
expect(result).toMatchObject({ success: false, requestId: "request-a", failure: { code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" } });
expect(result.error).toContain("5056×3392");
expect(result.error).toContain("本次未提交上游抠图任务");
expect(result.error).toContain("请求编号:request-a");
});
test("AOS rejects either oversized dimension before creating a task and gives the exact dimensions", async () => {
for (const [width, height] of [[4097, 20], [20, 4097]]) {
const image = await sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0;
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/large.png`, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(0);
expect(failure).toMatchObject({ status: 400 });
expect(failure?.message).toContain(`${width}×${height}`);
const details = getRequestFailureDetails(failure);
expect(details).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(details.message).toContain("4096");
expect(details.recommendation).toContain("本次未提交上游抠图任务");
}
});
test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submitting it", async () => {
const output = await sharp({ create: { width: 4096, height: 20, channels: 3, background: "white" } }).png().toBuffer();
const boundary = Buffer.alloc(50 * 1024 * 1024);
output.copy(boundary);
let creates = 0;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") {
creates++;
const form = options.body as FormData;
expect(form.has("image_url")).toBe(false);
expect((form.get("image_file") as File).size).toBe(boundary.length);
return Response.json({ data: { task_id: "boundary-task" } });
}
if (String(input).endsWith("/boundary-task")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } });
return new Response(output, { headers: { "content-type": "image/png" } });
});
await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, inputImageUrl: `${cdn}/boundary.png`, sourceImageUrl: `${cdn}/boundary.png`, skipGallerySave: true });
expect(creates).toBe(1);
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: Buffer.alloc(boundary.length + 1), skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(1);
expect(failure).toMatchObject({ status: 400 });
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_INPUT_TOO_LARGE", outcome: "failed" });
expect(getRequestFailureDetails(failure).message).toContain("50 MB");
});
test("formats unsupported by AOS are rejected explicitly without converting or submitting", async () => {
for (const format of ["gif", "avif"] as const) {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).toFormat(format).toBuffer();
let creates = 0;
const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); });
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect(creates).toBe(0);
const details = getRequestFailureDetails(failure);
expect(details).toMatchObject({ code: "RMBG_INPUT_FORMAT_UNSUPPORTED", outcome: "failed" });
expect(details.message).toContain(format.toUpperCase());
expect(details.recommendation).toContain("JPG、PNG 或 WebP");
}
});
test("AOS input rejections retain their cause instead of becoming a generic processing failure", () => {
const resolution = getRequestFailureDetails("抠图处理失败:图片分辨率超限(5000x3000),AOS要求宽高都不超过 4096px。请缩小后重试。");
expect(resolution).toMatchObject({ code: "RMBG_PROVIDER_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(resolution.message).toContain("5000×3000");
expect(getRequestFailureDetails("抠图处理失败:图片文件无法识别,请尝试转换为 JPG/PNG 后重试。")).toMatchObject({ code: "RMBG_PROVIDER_INVALID_FILE", outcome: "failed" });
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 413): Payload Too Large")).toMatchObject({ code: "RMBG_PROVIDER_FILE_TOO_LARGE", outcome: "failed" });
for (const [state, code] of [[-3, "RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED"], [-5, "RMBG_PROVIDER_URL_TOO_LARGE"], [-7, "RMBG_PROVIDER_INVALID_FILE"], [-8, "RMBG_PROVIDER_PROCESSING_TIMEOUT"], [-11, "RMBG_PROVIDER_EMPTY_RESULT"]] as const) {
const details = getRequestFailureDetails(`抠图处理失败:AOS任务失败(状态 ${state},任务 original-task): Failed`);
expect(details).toMatchObject({ code, outcome: "failed" });
expect(details.recommendation).not.toBe("");
}
});
test("the canvas API returns input-limit errors as HTTP 400 and preserves the same advice when polling", async () => {
const service = rmbgLoader(async () => { throw new Error("Unexpected provider call"); });
const error = new service.RmbgServiceError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。", 400);
const route = serverLoader({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-a" } }) },
"@/lib/ai/rmbg/service": { RmbgServiceError: service.RmbgServiceError },
"@/lib/canvas/capabilities/client-command": { canvasCapabilityClientCommandSchema: { parse: (v: unknown) => v } },
"@/lib/canvas/capabilities/context": { CanvasCapabilityRuntimeError: class extends Error {} },
"@/lib/canvas/agent-mutations": { CanvasAgentMutationError: class extends Error {} },
"@/lib/canvas/capabilities/registry": { getCanvasCapabilityManifest: () => [] },
"@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw error; } },
"@/lib/canvas/capabilities/task-store": { getCanvasCapabilityTask: async () => ({ id: "task-a", status: "failed", error: error.message }) },
})("app/api/canvas/[id]/capabilities/route.ts");
const params = { params: Promise.resolve({ id: "project-a" }) };
const response = await route.POST(new Request("https://example.com/api/canvas/project-a/capabilities", { method: "POST", body: JSON.stringify({ requestId: "request-a" }) }), params);
expect(response.status).toBe(400);
const submitted = await response.json();
expect(submitted.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" });
expect(submitted.error).toContain("5056×3392");
expect(submitted.error).toContain("本次未提交上游抠图任务");
const polled = await (await route.GET(new Request("https://example.com/api/canvas/project-a/capabilities?requestId=request-a"), params)).json();
expect(polled.task.failure).toEqual(submitted.failure);
expect(polled.task.error).toContain(submitted.failure.recommendation);
});
test("a create timeout preserves its cause and never creates another paid task", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0;
let abort: Error;
const service = rmbgLoader(async () => { creates++; throw abort; });
abort = new service.RmbgServiceError("request timed out");
abort.name = "AbortError";
let failure: Error | undefined;
try {
await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
} catch (error) { failure = error as Error; }
expect(creates).toBe(1);
expect(failure?.message).toContain("AOS创建任务超时(120秒)");
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_CREATE_TIMEOUT", outcome: "unknown" });
});
test("poll and result download timeouts retain their original task and distinct advice", () => {
const poll = getRequestFailureDetails("抠图处理失败:AOS查询任务超时,结果尚未确认(任务 task-1)");
const download = getRequestFailureDetails("抠图处理失败:AOS结果下载超时,任务已完成(任务 task-1)");
expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" });
expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" });
expect(download.recommendation).toContain("取回原任务结果");
expect(getRequestFailureDetails("抠图处理失败:AOS任务查询失败(HTTP 404,任务 task-1): Not Found")).toMatchObject({ code: "RMBG_PROVIDER_POLL_FAILED", outcome: "unknown" });
expect(getRequestFailureDetails("抠图处理失败:AOS结果下载失败(HTTP 500,任务 task-1): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", outcome: "unknown" });
expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 500): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_CREATE_UNCONFIRMED", outcome: "unknown" });
});
test("network and body interruptions retry only the original task reads", async () => {
const image = await sharp({ create: { width: 32, height: 32, channels: 4, background: "#abcdef00" } }).png().toBuffer();
let creates = 0, polls = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "original-task" } }); }
if (String(input).endsWith("/original-task")) {
if (++polls === 1) throw networkError;
return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
}
if (++downloads === 1) return new Response(new ReadableStream({ start(controller) { controller.error(networkError); } }));
return new Response(image, { headers: { "content-type": "image/png" } });
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true });
expect(result.original_url).toContain(cdn);
expect({ creates, polls, downloads }).toEqual({ creates: 1, polls: 2, downloads: 2 });
});
test("exhausted result reads preserve the completed task and never re-create it", async () => {
const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer();
let creates = 0, downloads = 0;
let networkError: Error;
const service = rmbgLoader(async (input, options) => {
if (options?.method === "POST") { creates++; return Response.json({ data: { task_id: "completed-task" } }); }
if (String(input).endsWith("/completed-task")) return Response.json({ data: { state: 1, image: "https://output.example/original.png" } });
downloads++; throw networkError;
});
networkError = new service.RmbgServiceError("fetch failed"); networkError.name = "TypeError";
let failure: Error | undefined;
try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true }); }
catch (error) { failure = error as Error; }
expect({ creates, downloads }).toEqual({ creates: 1, downloads: 3 });
expect(failure?.message).toContain("completed-task");
expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_CONNECTION_FAILED", outcome: "unknown" });
});

View File

@ -28,24 +28,3 @@ test("durable archive handoff is separate from unfinished original generation",
complete(); await original; complete(); await original;
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 }); expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
}); });
test("direct matting stays visible to release draining until the provider attempt fails", async () => {
const counts: number[] = [];
let rejectProvider!: (error: Error) => void;
let providerStarted!: () => void;
const started = new Promise<void>(resolve => { providerStarted = resolve; });
const service = serverLoader({
"node:fs": { writeFileSync: (_path: string, value: string) => counts.push(JSON.parse(value).active), unlinkSync() {} },
"@/lib/db/queries": { getSystemSetting: async () => ({ value: { provider: "rmbg", apiUrl: "https://provider.example/remove", apiKey: "test" } }) },
"@/lib/db": { db: {} }, "@/lib/db/schema": { generatedImage: {} },
"@/lib/storage/service": {},
fetch: async () => { providerStarted(); return new Promise<Response>((_resolve, reject) => { rejectProvider = reject; }); },
})("lib/ai/rmbg/service.ts");
const pending = service.executeRmbgJob({ userId: "user-a", imageBuffer: Buffer.from("image"), skipGallerySave: true });
const assertion = expect(pending).rejects.toThrow();
await started;
expect(counts).toEqual([1]);
rejectProvider(new Error("provider unavailable"));
await assertion;
expect(counts).toEqual([1, 0]);
});