Compare commits
48 Commits
codex/plat
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bf8e349ca | |||
| 8039bea86c | |||
| aea18a7fc6 | |||
| e9921cae98 | |||
| 1f5528331d | |||
| 535ea1aee4 | |||
| 8cbc8b1ab8 | |||
| ae9354f747 | |||
| c68bf9a824 | |||
| 21b47751d6 | |||
| c219f5b10b | |||
| 672c1368e0 | |||
| c60585aeca | |||
| 2ee8bd4dea | |||
| 85ff680b65 | |||
| 751cffae05 | |||
| 6e4350fe2b | |||
| 3f52e24076 | |||
| 0b74e71a33 | |||
| 4f8c1a5d5d | |||
| 33662210dd | |||
| 71413c173e | |||
| dbe96db34d | |||
| dfaeb9f09d | |||
| 46abb8259a | |||
| 3b3b5bc78a | |||
| db63f38b9c | |||
| 955ad91ae8 | |||
| 36ac3def8f | |||
| 1d40053c7a | |||
| 07451f5558 | |||
| 13684505a6 | |||
| 65b235e747 | |||
| d1ee6d53d5 | |||
| a484942fb9 | |||
| 084f538e77 | |||
| 5250aed7b7 | |||
| cbec13b285 | |||
| 5fda42495a | |||
| 73eb276e7f | |||
| 8c7ca174bc | |||
| 8fd0dba59f | |||
| ce94241234 | |||
| 198deca47b | |||
| 8908b0d39a | |||
| 34ba1919c4 | |||
| 2fdc4299e0 | |||
| ac2369b657 |
47
app/(chat)/api/chat/_lib/image-generation-failure.ts
Normal file
47
app/(chat)/api/chat/_lib/image-generation-failure.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -208,6 +208,7 @@ 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;
|
||||||
@ -223,6 +224,7 @@ 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:
|
||||||
|
|||||||
@ -426,6 +426,8 @@ 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)) });
|
||||||
@ -446,16 +448,6 @@ 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),
|
||||||
@ -5688,6 +5680,7 @@ 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,
|
||||||
@ -6030,6 +6023,7 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
|
userMessageId: message.id,
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -6185,6 +6179,7 @@ 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,
|
||||||
@ -6417,6 +6412,7 @@ 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 ||
|
||||||
@ -6968,6 +6964,7 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
|
userMessageId: message.id,
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7293,6 +7290,7 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
|
userMessageId: message.id,
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7521,6 +7519,7 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
|
userMessageId: message.id,
|
||||||
phase:
|
phase:
|
||||||
queueStatus.status === "running"
|
queueStatus.status === "running"
|
||||||
? "processing"
|
? "processing"
|
||||||
@ -7839,6 +7838,7 @@ 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,6 +8048,7 @@ 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,
|
||||||
@ -8086,6 +8087,7 @@ export async function POST(request: Request) {
|
|||||||
writeImageGenerationStatusChunk(
|
writeImageGenerationStatusChunk(
|
||||||
writer,
|
writer,
|
||||||
{
|
{
|
||||||
|
userMessageId: message.id,
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
...directImageStatusPayload,
|
...directImageStatusPayload,
|
||||||
},
|
},
|
||||||
@ -8178,6 +8180,7 @@ 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,
|
||||||
@ -8198,42 +8201,16 @@ 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 = getDirectAgentToolErrorMessage(
|
const errorMessage = await reportImageGenerationFailure({
|
||||||
"generate_image",
|
writer, chatId: id, userMessageId: message.id,
|
||||||
directToolError,
|
messageId: assistantMessageId, error: 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) {
|
||||||
@ -9195,6 +9172,7 @@ 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,
|
||||||
@ -9282,6 +9260,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9356,6 +9335,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -9423,6 +9403,7 @@ 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,
|
||||||
@ -9487,6 +9468,14 @@ 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...");
|
||||||
|
|
||||||
// 故障转移:尝试使用火山引擎模型
|
// 故障转移:尝试使用火山引擎模型
|
||||||
@ -9564,6 +9553,7 @@ 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,
|
||||||
@ -9629,47 +9619,14 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果故障转移也失败,返回错误消息
|
// 如果故障转移也失败,返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
await reportImageGenerationFailure({
|
||||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
writer, chatId: id, userMessageId: message.id,
|
||||||
writeAssistantTextChunk(
|
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
||||||
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?.();
|
||||||
}
|
}
|
||||||
@ -9867,6 +9824,7 @@ 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,
|
||||||
@ -9973,6 +9931,7 @@ 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,
|
||||||
@ -10070,6 +10029,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10204,6 +10164,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -10271,6 +10232,7 @@ 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,
|
||||||
@ -10335,43 +10297,10 @@ export async function POST(request: Request) {
|
|||||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||||
|
|
||||||
// 返回错误消息
|
// 返回错误消息
|
||||||
const errorMessageId = generateUUID();
|
await reportImageGenerationFailure({
|
||||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
writer, chatId: id, userMessageId: message.id,
|
||||||
writeAssistantTextChunk(
|
messageId: generateUUID(), error, label: "alibaba:error",
|
||||||
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?.();
|
||||||
}
|
}
|
||||||
@ -10500,6 +10429,7 @@ 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,
|
||||||
@ -10545,6 +10475,7 @@ 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) {
|
||||||
@ -10711,6 +10642,7 @@ 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,
|
||||||
@ -10775,44 +10707,10 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||||
const errorMessageId = generateUUID();
|
await reportImageGenerationFailure({
|
||||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
writer, chatId: id, userMessageId: message.id,
|
||||||
writeAssistantTextChunk(
|
messageId: generateUUID(), error, label: "volcano:error",
|
||||||
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?.();
|
||||||
}
|
}
|
||||||
@ -11385,6 +11283,7 @@ 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,
|
||||||
@ -11476,6 +11375,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11647,6 +11547,7 @@ 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,
|
||||||
@ -11706,54 +11607,10 @@ 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);
|
||||||
|
|
||||||
const errorMessageId = generateUUID();
|
await reportImageGenerationFailure({
|
||||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
writer: dataStream as unknown as StreamWriter,
|
||||||
|
chatId: id, userMessageId: message.id,
|
||||||
dataStream.write({
|
messageId: generateUUID(), error, label: "openai-compat:error",
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
type FeaturedImage = {
|
type FeaturedImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -74,18 +76,11 @@ export default function FeaturedPage() {
|
|||||||
|
|
||||||
const handleDownload = async (url: string, prompt: string) => {
|
const handleDownload = async (url: string, prompt: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: "featured-image" });
|
||||||
const blob = await response.blob();
|
toast.success("原图已开始下载");
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = `featured-${Date.now()}.png`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
|
|||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
import { formatBeijingTime } from "@/lib/utils";
|
import { formatBeijingTime } from "@/lib/utils";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
type GalleryImage = {
|
type GalleryImage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -59,7 +60,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GalleryPage() {
|
export default function GalleryPage() {
|
||||||
@ -119,25 +120,11 @@ export default function GalleryPage() {
|
|||||||
const parsed = new URL(originalUrl, window.location.origin);
|
const parsed = new URL(originalUrl, window.location.origin);
|
||||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||||
|
|
||||||
const response = await fetch(
|
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
||||||
buildImageProxyUrl(source, { delivery: "original" })
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`download failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
|
||||||
toast.success("图片已开始下载");
|
toast.success("图片已开始下载");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Download failed:", error);
|
console.error("Download failed:", error);
|
||||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, 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,6 +182,18 @@ 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ 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";
|
||||||
@ -90,7 +91,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: 500 }
|
{ status: error instanceof RmbgServiceError ? error.status : 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,100 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import {
|
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
||||||
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;
|
||||||
|
|
||||||
type BoxCutoutRequest = {
|
export async function POST(request: Request) {
|
||||||
imageUrl?: string;
|
const session = await auth();
|
||||||
cropRect?: {
|
if (!session?.user?.id) {
|
||||||
x?: number;
|
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { after, NextRequest, NextResponse } from "next/server";
|
import { after, NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/app/(auth)/auth";
|
import { auth } from "@/app/(auth)/auth";
|
||||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||||
@ -13,6 +16,22 @@ import {
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const maxDuration = 120;
|
export const maxDuration = 120;
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
|
function failed(failure: DownloadFailure, requestId: string) {
|
||||||
|
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
||||||
|
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||||
|
status: failure.status,
|
||||||
|
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function preparing(requestId: string) {
|
||||||
|
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
||||||
|
message: "正在准备原图,完成后将自动开始下载" }, {
|
||||||
|
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||||
// A proxy candidate for the same original must not repeat the same slow
|
// A proxy candidate for the same original must not repeat the same slow
|
||||||
@ -21,22 +40,25 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
|
|||||||
const source = extractImageProxySourceUrl(url) || url;
|
const source = extractImageProxySourceUrl(url) || url;
|
||||||
return inferOriginalImageUrl(source) || source;
|
return inferOriginalImageUrl(source) || source;
|
||||||
}))];
|
}))];
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
for (const url of sources) {
|
for (const url of sources) {
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const result = await fetchCanvasDownloadAsset({
|
const result = await fetchCanvasDownloadAsset({
|
||||||
file: { url, name: "original" }, index: 0,
|
file: { url, name: "original" }, index: 0,
|
||||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||||
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
||||||
});
|
});
|
||||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||||
|
lastError = result.error;
|
||||||
}
|
}
|
||||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||||
// No AI/model request is involved and a completed source fetch is shared.
|
// No AI/model request is involved and a completed source fetch is shared.
|
||||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||||
|
else break;
|
||||||
}
|
}
|
||||||
throw new Error("Original image sources unavailable");
|
throw new Error(String(lastError || "Original image sources unavailable"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRequestOrigin(request: NextRequest): string {
|
function getRequestOrigin(request: NextRequest): string {
|
||||||
@ -76,31 +98,43 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const requestId = randomUUID();
|
||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
return failed(getDownloadFailure("未登录"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||||
const requestOrigin = getRequestOrigin(request);
|
const requestOrigin = getRequestOrigin(request);
|
||||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
||||||
}
|
}
|
||||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||||
const requestedUrls = [...urls];
|
const requestedUrls = [...urls];
|
||||||
if (urls.length === 0) {
|
if (urls.length === 0) {
|
||||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
return failed(getDownloadFailure("图片地址无效"), requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||||
if (body.preferDirect === true) {
|
if (body.preferDirect === true) {
|
||||||
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
||||||
|
logger.warn("cdn_lookup_failed", error, { requestId });
|
||||||
|
return { task: null, direct: null, canPrepareDirect: false };
|
||||||
|
});
|
||||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
if (task && canPrepareDirect && body.preparation === "async") {
|
||||||
|
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
||||||
|
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
||||||
|
return failed(asset.failure || getDownloadFailure(null), task.id);
|
||||||
|
}
|
||||||
|
scheduleImageDownloadPreparation(task.id);
|
||||||
|
return preparing(task.id);
|
||||||
|
}
|
||||||
if (task?.targetUrl) {
|
if (task?.targetUrl) {
|
||||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||||
after(() => prepareImageArchiveDownload(task.id));
|
scheduleImageDownloadPreparation(task.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cookie = request.headers.get("cookie") || "";
|
const cookie = request.headers.get("cookie") || "";
|
||||||
@ -109,13 +143,10 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||||
});
|
});
|
||||||
if (result.status === "preparing") {
|
if (result.status === "preparing") {
|
||||||
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
return preparing(result.requestId);
|
||||||
message: "正在准备原图,完成后将自动下载" }, {
|
|
||||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
return failed(result.failure, result.requestId);
|
||||||
}
|
}
|
||||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||||
"Content-Type": result.asset.contentType,
|
"Content-Type": result.asset.contentType,
|
||||||
@ -136,17 +167,14 @@ export async function POST(request: NextRequest) {
|
|||||||
includeSequencePrefix: false,
|
includeSequencePrefix: false,
|
||||||
});
|
});
|
||||||
if (result.ok) break;
|
if (result.ok) break;
|
||||||
failures.push(`${url}: ${result.error}`);
|
failures.push(result.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
if (!result?.ok) {
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
logger.warn("all_sources_failed", { requestId, failures });
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return failed(getDownloadFailure(result?.error), requestId);
|
||||||
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(result.bytes, {
|
return new NextResponse(result.bytes, {
|
||||||
@ -159,7 +187,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[CanvasDownloadImage] Failed:", error);
|
logger.error("download_failed", error, { requestId });
|
||||||
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
return failed(getDownloadFailure(error), requestId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
const logger = createLogger("image.download");
|
||||||
|
|
||||||
const DEFAULT_QUALITY = 82;
|
const DEFAULT_QUALITY = 82;
|
||||||
const MAX_WIDTH = 4096;
|
const MAX_WIDTH = 4096;
|
||||||
@ -239,6 +243,7 @@ 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;
|
||||||
}
|
}
|
||||||
@ -267,8 +272,17 @@ function redirectToDirectImage(target: URL): NextResponse {
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const downloadFailure = (error: unknown) => {
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
||||||
|
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||||
|
status: failure.status, headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
};
|
||||||
const rawUrl = (searchParams.get("url") || "").trim();
|
const rawUrl = (searchParams.get("url") || "").trim();
|
||||||
if (isRetiredStorageUrl(rawUrl)) {
|
if (isRetiredStorageUrl(rawUrl)) {
|
||||||
|
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
||||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||||
}
|
}
|
||||||
if (!rawUrl) {
|
if (!rawUrl) {
|
||||||
@ -286,6 +300,24 @@ 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 });
|
||||||
}
|
}
|
||||||
@ -306,6 +338,37 @@ 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") {
|
||||||
|
try {
|
||||||
|
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
||||||
|
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
||||||
|
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
|
||||||
|
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
|
||||||
|
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
|
||||||
|
} catch (error) {
|
||||||
|
return downloadFailure(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!forceProxy && !original) {
|
if (!forceProxy && !original) {
|
||||||
const directImageTarget = resolveDirectImageTarget(
|
const directImageTarget = resolveDirectImageTarget(
|
||||||
request,
|
request,
|
||||||
@ -346,45 +409,62 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const fetchSignal = AbortSignal.any([
|
const fetchSignal = AbortSignal.any([
|
||||||
request.signal,
|
request.signal,
|
||||||
AbortSignal.timeout(width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
AbortSignal.timeout(original ? 60_000 : isAlibabaModelImageUrl(target.toString()) || width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||||
]);
|
]);
|
||||||
const upstreamFailure = (error: unknown) => {
|
const upstreamFailure = (error: unknown) => {
|
||||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||||
|
if (original) return downloadFailure(error);
|
||||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
||||||
{ status: timedOut ? 504 : 502 },
|
{ status: timedOut ? 504 : 502 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
let upstreamResponse: Response;
|
let contentType: string;
|
||||||
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) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
|
||||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
||||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalBuffer: Buffer;
|
let originalBuffer: Buffer;
|
||||||
try {
|
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
try {
|
||||||
} catch (error) {
|
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
||||||
return upstreamFailure(error);
|
const data = await fetchImageBytes(target.toString(), { timeoutMs: SMALL_PREVIEW_FETCH_TIMEOUT_MS });
|
||||||
|
originalBuffer = data.buffer;
|
||||||
|
contentType = data.contentType;
|
||||||
|
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
|
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 (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||||
|
if (original) return downloadFailure("Source is not an image");
|
||||||
|
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||||
|
} catch (error) {
|
||||||
|
return upstreamFailure(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (original) {
|
if (original) {
|
||||||
return buildImageResponse(originalBuffer, {
|
return buildImageResponse(originalBuffer, {
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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";
|
||||||
@ -69,7 +70,6 @@ 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,41 +236,6 @@ 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);
|
||||||
@ -988,102 +953,6 @@ 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建缩略图
|
* 创建缩略图
|
||||||
*/
|
*/
|
||||||
@ -2436,12 +2305,13 @@ 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,
|
||||||
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
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,
|
||||||
|
|||||||
@ -5196,6 +5196,7 @@ 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",
|
||||||
});
|
});
|
||||||
@ -5216,7 +5217,11 @@ export default function CanvasEditorLegacyPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await canvasService.getProject(id);
|
const result = await canvasService.getProject(id);
|
||||||
if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
|
const current = useCanvasStore.getState();
|
||||||
|
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();
|
||||||
|
|||||||
@ -48,8 +48,13 @@ 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 (latestState.nodes.length === 0 && latestState.edges.length === 0) {
|
if (
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -575,31 +576,11 @@ export function AgentGenerationWorkspace({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = raw;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
toast.message("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||||
@ -6793,31 +6794,11 @@ export function AgentGenerationWorkspaceDarkV2({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = raw;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
toast.message("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
@ -725,30 +726,11 @@ export function AgentGenerationWorkspaceNew({
|
|||||||
toast.error("图片地址无效");
|
toast.error("图片地址无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filename = buildDownloadFilename(raw);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(raw);
|
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||||
if (!response.ok) {
|
toast.success("原图已开始下载");
|
||||||
throw new Error(`download failed: ${response.status}`);
|
} catch (error) {
|
||||||
}
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
|
||||||
} catch {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = raw;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
toast.message("已打开原图,可右键另存为");
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,7 @@ 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,
|
||||||
@ -81,7 +82,8 @@ import { canvasService } from "@/services/canvas";
|
|||||||
import {
|
import {
|
||||||
inferOriginalImageUrl,
|
inferOriginalImageUrl,
|
||||||
} from "@/lib/image-url";
|
} from "@/lib/image-url";
|
||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
||||||
import {
|
import {
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
@ -3339,6 +3341,7 @@ 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);
|
||||||
@ -3434,6 +3437,7 @@ 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);
|
||||||
@ -3471,16 +3475,6 @@ 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) {
|
||||||
@ -3650,17 +3644,20 @@ 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,
|
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
|
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => {
|
||||||
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;
|
||||||
@ -6097,7 +6094,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(false);
|
const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
|
||||||
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
||||||
imageAgentEnabledRef.current = imageAgentEnabled;
|
imageAgentEnabledRef.current = imageAgentEnabled;
|
||||||
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
||||||
@ -8026,6 +8023,8 @@ 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-', '')
|
||||||
@ -8138,20 +8137,27 @@ 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,
|
||||||
@ -8419,6 +8425,11 @@ 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
|
||||||
: [];
|
: [];
|
||||||
@ -8487,7 +8498,7 @@ export function CanvasChatPanel({
|
|||||||
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
||||||
!shouldDeferClearForActiveMedia
|
!shouldDeferClearForActiveMedia
|
||||||
) {
|
) {
|
||||||
clearStreamImageGenerationStateForChat(eventChatId);
|
clearStreamImageGenerationStateForChat(eventChatId, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isEventForActiveChat) {
|
if (!isEventForActiveChat) {
|
||||||
@ -9210,7 +9221,7 @@ export function CanvasChatPanel({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||||
if (!rawChatId || !streamState) return;
|
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@ -9234,6 +9245,13 @@ 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");
|
||||||
@ -9250,8 +9268,7 @@ 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)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -9272,6 +9289,7 @@ export function CanvasChatPanel({
|
|||||||
messages,
|
messages,
|
||||||
streamImageGenerationStateByChatId,
|
streamImageGenerationStateByChatId,
|
||||||
clearStreamImageGenerationStateForChat,
|
clearStreamImageGenerationStateForChat,
|
||||||
|
setStreamImageGenerationStateForChat,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -13336,6 +13354,7 @@ 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) {
|
||||||
@ -15105,7 +15124,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 && !hasActiveTypewriter;
|
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter;
|
||||||
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
|
||||||
const loadingProviderType =
|
const loadingProviderType =
|
||||||
streamImageGenerationState?.providerType ||
|
streamImageGenerationState?.providerType ||
|
||||||
@ -15168,9 +15187,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;
|
||||||
@ -15226,14 +15245,10 @@ export function CanvasChatPanel({
|
|||||||
canvasGenerationPlaceholder: true,
|
canvasGenerationPlaceholder: true,
|
||||||
chatId: activeChatId,
|
chatId: activeChatId,
|
||||||
detailProgressLabel: label,
|
detailProgressLabel: label,
|
||||||
generationProgress:
|
generationProgress: undefined,
|
||||||
phase === "queued"
|
generationFailure: streamState.failure,
|
||||||
? 12
|
generationRequestId: streamState.requestId,
|
||||||
: phase === "finalizing"
|
generationUserMessageId: streamState.userMessageId,
|
||||||
? 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",
|
||||||
@ -15327,6 +15342,7 @@ 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;
|
||||||
@ -15812,8 +15828,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16131,8 +16147,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16208,8 +16224,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16394,8 +16410,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== tag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
|
||||||
event.currentTarget.src = tag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -16695,8 +16711,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== attachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||||
event.currentTarget.src = attachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -17032,15 +17048,23 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(url)) {
|
||||||
event.currentTarget.src = url;
|
event.currentTarget.src = buildImageFallbackUrl(url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={() => setFullscreenPreview({ url })}
|
onClick={(event) => setFullscreenPreview({ url, preview: {
|
||||||
|
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 });
|
setFullscreenPreview({ url, preview: {
|
||||||
|
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">
|
||||||
@ -17286,7 +17310,7 @@ export function CanvasChatPanel({
|
|||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors"
|
||||||
onClick={() => setFullscreenPreview(null)}
|
onClick={() => setFullscreenPreview(null)}
|
||||||
aria-label="关闭预览"
|
aria-label="关闭预览"
|
||||||
>
|
>
|
||||||
@ -17295,7 +17319,7 @@ export function CanvasChatPanel({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -17312,13 +17336,11 @@ export function CanvasChatPanel({
|
|||||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<img
|
<ProgressiveImagePreview
|
||||||
src={fullscreenPreviewSrc}
|
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
|
||||||
|
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">
|
||||||
@ -17885,8 +17907,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18210,8 +18232,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18470,8 +18492,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== item.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
|
||||||
event.currentTarget.src = item.url;
|
event.currentTarget.src = buildImageFallbackUrl(item.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18655,8 +18677,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -18723,8 +18745,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== slotAttachment.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||||
event.currentTarget.src = slotAttachment.url;
|
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -19423,8 +19445,8 @@ export function CanvasChatPanel({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== tagPreview.url) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
|
||||||
event.currentTarget.src = tagPreview.url;
|
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageGenerationStatusStreamPayload = {
|
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
||||||
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;
|
||||||
|
|||||||
@ -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 || "")
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
"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 {
|
||||||
@ -60,6 +62,7 @@ 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,
|
||||||
@ -186,7 +189,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 { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||||
getDefaultThreeDModelAliasId,
|
getDefaultThreeDModelAliasId,
|
||||||
@ -1711,6 +1714,7 @@ 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);
|
||||||
@ -2500,13 +2504,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 Math.max(
|
return canvasImagePreviewWidth(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(
|
||||||
@ -4681,106 +4685,44 @@ export function ImageNode({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsCreatingDetachedResultNode(true);
|
if (!projectId || !runCanvasRemoteOperation) {
|
||||||
|
toast.error("画布还在加载,请稍后再试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreatingDetachedResultNode(false);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const actionName = "框选抠图";
|
const actionName = "框选抠图";
|
||||||
const newNodeId = addNode({
|
|
||||||
type: "image",
|
|
||||||
position: {
|
|
||||||
x: node.position.x + node.size.width + 20,
|
|
||||||
y: node.position.y,
|
|
||||||
},
|
|
||||||
size: { width: node.size.width, height: node.size.height },
|
|
||||||
data: {
|
|
||||||
src: "",
|
|
||||||
status: "generating",
|
|
||||||
prompt: `${actionName}处理中...`,
|
|
||||||
title: `${displayTitle}-${actionName}`,
|
|
||||||
originalSrc: data.src,
|
|
||||||
metadata: {
|
|
||||||
cropRect: boxCutoutSelection,
|
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
connections: [node.id],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await imageGenerationService.boxCutout({
|
const command = createCanvasCapabilityClientCommand({
|
||||||
imageUrl: data.src,
|
capabilityId: "image.box_cutout",
|
||||||
cropRect: boxCutoutSelection,
|
nodeIds: [node.id],
|
||||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
projectId,
|
||||||
sourceNodeId: node.id,
|
source: "toolbar",
|
||||||
});
|
args: {
|
||||||
|
...boxCutoutSelection,
|
||||||
if (result.success && result.data?.url) {
|
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||||
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) {
|
|
||||||
console.error("框选抠图失败:", error);
|
|
||||||
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}失败,请重试`);
|
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||||
|
toast.success(`${actionName}完成`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("框选抠图失败:", error);
|
||||||
|
toast.error(error instanceof Error ? error.message : `${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,
|
||||||
node.position.x,
|
projectId,
|
||||||
node.position.y,
|
|
||||||
node.size.height,
|
|
||||||
node.size.width,
|
|
||||||
resetBoxCutoutState,
|
resetBoxCutoutState,
|
||||||
updateNode,
|
runCanvasRemoteOperation,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
const animateAddToChat = useCallback(async (previewUrl?: string) => {
|
||||||
@ -8459,15 +8401,16 @@ 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" />
|
||||||
<img
|
<CanvasPreviewImage
|
||||||
|
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="lazy"
|
loading="eager"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== data.src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||||
event.currentTarget.src = data.src;
|
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -8488,15 +8431,16 @@ 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" />
|
||||||
<img
|
<CanvasPreviewImage
|
||||||
|
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="lazy"
|
loading="eager"
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== compareSource) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||||
event.currentTarget.src = compareSource;
|
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
src={displayCompareSrc}
|
src={displayCompareSrc}
|
||||||
@ -8538,7 +8482,8 @@ export function ImageNode({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<img
|
<CanvasPreviewImage
|
||||||
|
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",
|
||||||
@ -8550,7 +8495,7 @@ export function ImageNode({
|
|||||||
)}
|
)}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
loading="lazy"
|
loading="eager"
|
||||||
onClick={
|
onClick={
|
||||||
isThisNodePicking && !boxCutoutMode
|
isThisNodePicking && !boxCutoutMode
|
||||||
? handlePickerClick
|
? handlePickerClick
|
||||||
@ -8559,12 +8504,17 @@ 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 !== data.src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||||
event.currentTarget.src = data.src;
|
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onLoad={handleDisplayImageLoad}
|
onLoad={handleDisplayImageLoad}
|
||||||
@ -9475,6 +9425,8 @@ 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}
|
||||||
@ -11964,6 +11916,7 @@ 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"
|
||||||
@ -11971,7 +11924,7 @@ export function ImageNode({
|
|||||||
>
|
>
|
||||||
{/* 关闭按钮 */}
|
{/* 关闭按钮 */}
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
className="absolute top-4 right-4 z-10 rounded-full bg-slate-950 p-2 transition-colors hover:bg-black"
|
||||||
onClick={() => setFullscreenPreviewOpen(false)}
|
onClick={() => setFullscreenPreviewOpen(false)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -11989,7 +11942,7 @@ export function ImageNode({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
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"
|
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"
|
||||||
disabled={isDownloadingOriginalImage}
|
disabled={isDownloadingOriginalImage}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -12008,13 +11961,11 @@ export function ImageNode({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 图片 */}
|
{/* 图片 */}
|
||||||
<img
|
<ProgressiveImagePreview
|
||||||
alt={data.prompt || "图片预览"}
|
alt={data.prompt || "图片预览"}
|
||||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||||
decoding="async"
|
src={resolveOriginalImageUrl(data.src) || data.src}
|
||||||
draggable={false}
|
preview={fullscreenPreviewImage}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
src={fullscreenPreviewSrc}
|
|
||||||
style={activeImageAdjustmentsStyle}
|
style={activeImageAdjustmentsStyle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
32
components/canvas/nodes/image-node/canvas-preview-image.tsx
Normal file
32
components/canvas/nodes/image-node/canvas-preview-image.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"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);
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
})}</>;
|
||||||
|
});
|
||||||
@ -3,11 +3,14 @@
|
|||||||
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;
|
||||||
@ -52,6 +55,8 @@ export function ImageNodeEmptyState({
|
|||||||
detailProgressLabel,
|
detailProgressLabel,
|
||||||
fileInputRef,
|
fileInputRef,
|
||||||
generationProgress,
|
generationProgress,
|
||||||
|
failure,
|
||||||
|
requestId,
|
||||||
onImageUpload,
|
onImageUpload,
|
||||||
onSelectFile,
|
onSelectFile,
|
||||||
status,
|
status,
|
||||||
@ -89,7 +94,7 @@ export function ImageNodeEmptyState({
|
|||||||
if (status === "failed") {
|
if (status === "failed") {
|
||||||
const failedTitle = detailProgressLabel || "生成失败";
|
const failedTitle = detailProgressLabel || "生成失败";
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
<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>
|
||||||
@ -97,8 +102,9 @@ export function ImageNodeEmptyState({
|
|||||||
{failedTitle}
|
{failedTitle}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-xs text-[#b77480]">
|
<span className="mt-1 text-xs text-[#b77480]">
|
||||||
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
||||||
</span>
|
</span>
|
||||||
|
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageFallbackUrl, 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 !== src) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
||||||
event.currentTarget.src = src;
|
event.currentTarget.src = buildImageFallbackUrl(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 !== originalSrc) {
|
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
||||||
event.currentTarget.src = originalSrc;
|
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
"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";
|
||||||
@ -26,193 +25,17 @@ 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";
|
||||||
@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({
|
|||||||
showProgress = true,
|
showProgress = true,
|
||||||
title,
|
title,
|
||||||
}: GenerationDotPlaceholderProps) {
|
}: GenerationDotPlaceholderProps) {
|
||||||
const [elapsedMs, setElapsedMs] = useState(0);
|
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
|
||||||
|
? clampProgress(progress) : null;
|
||||||
useEffect(() => {
|
const resolvedTitle = title || PHASE_TITLES[phase];
|
||||||
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
|
||||||
@ -281,10 +75,6 @@ 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">
|
||||||
@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showProgress ? (
|
{showProgress && phase !== "completed" ? (
|
||||||
<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" ? "已停止" : "生成进度"}</span>
|
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
|
||||||
<span>{resolvedProgress}%</span>
|
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
{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
|
<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",
|
||||||
@ -311,25 +101,12 @@ 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={progressStyle}
|
style={{ width: `${resolvedProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
@keyframes generationCursorBlink {
|
|
||||||
0%,
|
|
||||||
48% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
49%,
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -918,9 +918,9 @@ export function ImageAnnotator({
|
|||||||
|
|
||||||
setIsRemovingBg(true);
|
setIsRemovingBg(true);
|
||||||
try {
|
try {
|
||||||
// 使用原图URL获取原图数据,而不是canvas的预览图
|
const input = /^https?:\/\//i.test(originalImageUrl)
|
||||||
const base64Data = await imageUrlToBase64(originalImageUrl);
|
? { imageUrl: originalImageUrl }
|
||||||
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
|
: { image_base64: await imageUrlToBase64(originalImageUrl) };
|
||||||
|
|
||||||
// 调用抠图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({
|
||||||
image_base64: base64Data,
|
...input,
|
||||||
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,45 +41,11 @@ 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);
|
||||||
@ -138,16 +104,8 @@ 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 estimateRangeLabel = isLongWait
|
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
|
||||||
? "单次最长约10分钟"
|
const phaseHint = PHASE_HINT[phase];
|
||||||
: 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
|
||||||
@ -189,12 +147,12 @@ export const ImageGenerationLoading = memo(
|
|||||||
}
|
}
|
||||||
compact={count > 1}
|
compact={count > 1}
|
||||||
phase={phase}
|
phase={phase}
|
||||||
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
|
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : 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} 生成图片,预计 {estimateRangeLabel}
|
使用 {resolvedModelName} 生成图片
|
||||||
</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}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, 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";
|
||||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
const ImageAnnotator = dynamic(
|
const ImageAnnotator = dynamic(
|
||||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||||
@ -23,25 +24,6 @@ const AngleEditDialog = dynamic(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function triggerBrowserDownload(
|
|
||||||
url: string,
|
|
||||||
filename?: string,
|
|
||||||
options?: { openInNewTab?: boolean }
|
|
||||||
) {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
if (filename) {
|
|
||||||
link.download = filename;
|
|
||||||
}
|
|
||||||
if (options?.openInNewTab) {
|
|
||||||
link.target = "_blank";
|
|
||||||
}
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImagePreview({
|
export function ImagePreview({
|
||||||
src,
|
src,
|
||||||
alt,
|
alt,
|
||||||
@ -238,24 +220,12 @@ export function ImagePreview({
|
|||||||
toast.error("下载失败,请重试");
|
toast.error("下载失败,请重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filenameBase = `generated-image-${Date.now()}`;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
await downloadOriginalImage({ url: safeOriginalUrl });
|
||||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
toast.success("原图已开始下载");
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`下载失败(${response.status})`);
|
|
||||||
}
|
|
||||||
const blob = await response.blob();
|
|
||||||
const extension = getImageDownloadExtension(blob.type);
|
|
||||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
triggerBrowserDownload(url, filename);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
toast.success("下载成功");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -337,9 +307,18 @@ export function ImagePreview({
|
|||||||
}}
|
}}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onLoad={(event) => {
|
||||||
|
event.currentTarget.style.aspectRatio = "";
|
||||||
|
event.currentTarget.parentElement?.style.removeProperty("width");
|
||||||
|
event.currentTarget.alt = "Generated Image";
|
||||||
|
}}
|
||||||
onError={(event) => {
|
onError={(event) => {
|
||||||
if (event.currentTarget.src !== (thumbnail || original)) {
|
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && 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)}
|
||||||
@ -631,7 +610,7 @@ export function ImagePreview({
|
|||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={original}
|
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : 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" : ""
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Button } from "./ui/button";
|
|||||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ImageAnnotator } from "./image-annotator";
|
import { ImageAnnotator } from "./image-annotator";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
export const PreviewAttachment = ({
|
export const PreviewAttachment = ({
|
||||||
attachment,
|
attachment,
|
||||||
@ -73,18 +74,11 @@ export const PreviewAttachment = ({
|
|||||||
// 下载图片函数
|
// 下载图片函数
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: name ?? "image" });
|
||||||
const blob = await response.blob();
|
toast.success("原图已开始下载");
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = downloadUrl;
|
|
||||||
link.download = name ?? `image-${Date.now()}.jpg`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("下载失败:", error);
|
console.error("下载失败:", error);
|
||||||
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
74
components/progressive-image-preview.tsx
Normal file
74
components/progressive-image-preview.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -2073,18 +2074,9 @@ export function SceneFusionView({
|
|||||||
|
|
||||||
const downloadImage = async (url: string, filename: string) => {
|
const downloadImage = async (url: string, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
await downloadOriginalImage({ url, name: filename });
|
||||||
const blob = await response.blob();
|
} catch (error) {
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
|
||||||
} catch {
|
|
||||||
window.open(url, "_blank");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,9 @@ 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;
|
||||||
@ -13,6 +16,7 @@ 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>;
|
||||||
@ -143,6 +147,7 @@ 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);
|
||||||
@ -150,7 +155,7 @@ export async function executeBoxCutoutJob(
|
|||||||
|
|
||||||
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
||||||
if (!cropRect) {
|
if (!cropRect) {
|
||||||
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
||||||
@ -170,7 +175,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("框选区域过小,请重新框选", 400);
|
throw new BoxCutoutServiceError("框选区域过小,宽、高均须至少 24 像素,请扩大框选范围后重新提交。", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
const paddedCropRect = applyPadding(cropRect, paddingRatio);
|
||||||
@ -184,7 +189,18 @@ 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"),
|
||||||
|
|||||||
@ -72,6 +72,7 @@ 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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||||
* - qwen-image-edit:仅支持 1 张输出
|
* - qwen-image-edit:仅支持 1 张输出
|
||||||
*
|
*
|
||||||
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
@ -14,6 +14,7 @@ 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,
|
||||||
@ -46,6 +47,8 @@ interface AlibabaImageResponse {
|
|||||||
output?: {
|
output?: {
|
||||||
task_id?: string;
|
task_id?: string;
|
||||||
task_status?: string;
|
task_status?: string;
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
finish_reason: string;
|
finish_reason: string;
|
||||||
message: {
|
message: {
|
||||||
@ -122,11 +125,21 @@ async function fetchImageBufferWithRetry(
|
|||||||
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
||||||
attempts = 2
|
attempts = 2
|
||||||
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
||||||
|
if (imageUrl.startsWith("data:image/")) {
|
||||||
|
const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
|
||||||
|
if (!match) throw new Error("输入图片数据格式无效");
|
||||||
|
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
||||||
|
}
|
||||||
|
let 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(imageUrl, {
|
const response = await fetch(sourceUrl, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
redirect: "follow",
|
redirect: "follow",
|
||||||
signal: ctx.signal,
|
signal: ctx.signal,
|
||||||
@ -168,7 +181,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
||||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -186,7 +199,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
|
|||||||
async function inferImageDimensionsFromImageUrl(
|
async function inferImageDimensionsFromImageUrl(
|
||||||
imageUrl: string,
|
imageUrl: string,
|
||||||
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
||||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||||
@ -302,6 +315,14 @@ async function prepareAlibabaInputImageUrl(
|
|||||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||||
modelId,
|
modelId,
|
||||||
});
|
});
|
||||||
|
if (isAlibabaQwenImage3ModelId(modelId)) {
|
||||||
|
if (prepared.buffer.length > maxInputBytes) {
|
||||||
|
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
||||||
|
}
|
||||||
|
const format = prepared.transformed ? prepared.extension : metadata.format;
|
||||||
|
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
||||||
|
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
||||||
|
}
|
||||||
if (!prepared.transformed) {
|
if (!prepared.transformed) {
|
||||||
return normalizedUrl;
|
return normalizedUrl;
|
||||||
}
|
}
|
||||||
@ -565,8 +586,10 @@ async function sendAlibabaImageAsyncRequest(params: {
|
|||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||||
|
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
||||||
|
const code = task.output?.code || task.code;
|
||||||
throw new AlibabaImageTerminalTaskError(
|
throw new AlibabaImageTerminalTaskError(
|
||||||
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastPollError = null;
|
lastPollError = null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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");
|
||||||
@ -27,6 +28,7 @@ type RmbgConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ExecuteRmbgJobParams = {
|
export type ExecuteRmbgJobParams = {
|
||||||
|
requestId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
imageBuffer: Buffer;
|
imageBuffer: Buffer;
|
||||||
imageBase64?: string;
|
imageBase64?: string;
|
||||||
@ -35,6 +37,8 @@ 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -53,7 +57,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(message);
|
super(detail && detail !== message ? `${message}:${detail}` : message);
|
||||||
this.name = "RmbgServiceError";
|
this.name = "RmbgServiceError";
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.detail = detail;
|
this.detail = detail;
|
||||||
@ -61,7 +65,11 @@ 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;
|
||||||
@ -147,6 +155,18 @@ 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) {
|
||||||
@ -164,50 +184,42 @@ 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");
|
||||||
const image = sharp.default(imageBuffer);
|
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
|
||||||
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) {
|
||||||
logger.warn("aos_image_preprocess_failed", error);
|
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
|
||||||
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
logger.warn("aos_image_metadata_unavailable", error);
|
||||||
|
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(
|
||||||
@ -369,20 +381,38 @@ 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();
|
||||||
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
let publicSourceUrl: string | null = null;
|
||||||
type: normalizedImage.mimeType,
|
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
||||||
});
|
try {
|
||||||
createForm.append("image_file", imageBlob, normalizedImage.filename);
|
const source = new URL(config.inputImageUrl);
|
||||||
|
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");
|
||||||
@ -392,6 +422,9 @@ 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, {
|
||||||
@ -411,28 +444,37 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
|
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${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 withTimeout(
|
const pollResponse = await readAosResult(
|
||||||
(signal) =>
|
async (signal) => {
|
||||||
fetch(taskUrl, {
|
const response = await 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 = await pollResponse.json().catch(() => null);
|
const pollPayload = pollResponse.payload;
|
||||||
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,
|
||||||
@ -440,7 +482,7 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
|
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,12 +496,15 @@ async function processWithAosSegmentation(
|
|||||||
pollCount,
|
pollCount,
|
||||||
taskId,
|
taskId,
|
||||||
});
|
});
|
||||||
const imageResponse = await withTimeout(
|
stage = "download";
|
||||||
(signal) =>
|
const imageResponse = await readAosResult(
|
||||||
fetch(imageUrl, {
|
async (signal) => {
|
||||||
|
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
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -470,11 +515,11 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
|
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
const resultBuffer = imageResponse.buffer!;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
imageBuffer: resultBuffer,
|
imageBuffer: resultBuffer,
|
||||||
@ -490,19 +535,30 @@ async function processWithAosSegmentation(
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
|
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.warn("aos_task_timeout", { maxPollCount });
|
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
|
||||||
return { success: false, error: "AOS任务超时(最多60秒)" };
|
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.name === "AbortError") {
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
return { success: false, error: "AOS请求超时,请稍后重试" };
|
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
|
||||||
|
const message = stage === "create"
|
||||||
|
? "AOS创建任务超时(120秒),结果尚未确认"
|
||||||
|
: stage === "poll"
|
||||||
|
? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})`
|
||||||
|
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
|
||||||
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
logger.warn("aos_provider_request_error", error);
|
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
|
||||||
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
|
const detail = error instanceof Error ? error.message : "未知错误";
|
||||||
|
return { success: false, error: stage === "create"
|
||||||
|
? `AOS创建任务连接失败,结果尚未确认:${detail}`
|
||||||
|
: stage === "poll"
|
||||||
|
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
|
||||||
|
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -599,8 +655,15 @@ 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"),
|
||||||
@ -609,11 +672,13 @@ export async function executeRmbgJob(
|
|||||||
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),
|
||||||
@ -657,6 +722,8 @@ export async function executeRmbgJob(
|
|||||||
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) {
|
||||||
@ -793,6 +860,7 @@ export async function executeRmbgJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)),
|
||||||
@ -815,6 +883,7 @@ export async function executeRmbgJob(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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);
|
||||||
|
|||||||
@ -516,6 +516,7 @@ 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,
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
isMediaInputFailure,
|
isMediaInputFailure,
|
||||||
isNonProviderRequestStatus,
|
isNonProviderRequestStatus,
|
||||||
} from "@/lib/ai/runtime/request-failure-classification";
|
} from "@/lib/ai/runtime/request-failure-classification";
|
||||||
import { isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
import { getRequestFailureDetails, isGatewayResponseTimeout, isProviderBalanceError, isRateLimitError } from "@/lib/request-error";
|
||||||
|
|
||||||
export type AiModelHealthEventType =
|
export type AiModelHealthEventType =
|
||||||
| "success"
|
| "success"
|
||||||
@ -344,7 +344,12 @@ export function classifyAiModelHealthFailure(error: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldFailoverAiModelError(error: unknown) {
|
export function shouldFailoverAiModelError(error: unknown) {
|
||||||
if (error instanceof ProviderTaskUncertainError || isGatewayResponseTimeout(error)) return false;
|
if (
|
||||||
|
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 =
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import {
|
import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service";
|
||||||
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,8 +128,10 @@ 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,
|
||||||
@ -185,8 +187,7 @@ async function executeForTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.capabilityId === "image.box_cutout") {
|
if (command.capabilityId === "image.box_cutout") {
|
||||||
const result = await executeBoxCutoutJob({
|
const job = {
|
||||||
userId: params.userId,
|
|
||||||
imageUrl: target.sourceImageUrl,
|
imageUrl: target.sourceImageUrl,
|
||||||
sourceNodeId: target.nodeId,
|
sourceNodeId: target.nodeId,
|
||||||
cropRect: {
|
cropRect: {
|
||||||
@ -197,13 +198,30 @@ 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,
|
||||||
|
|||||||
31
lib/canvas/download-error.ts
Normal file
31
lib/canvas/download-error.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export type DownloadFailure = {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
recommendation: string;
|
||||||
|
status: number;
|
||||||
|
retryable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDownloadFailure(error: unknown): DownloadFailure {
|
||||||
|
const text = error && typeof error === "object" && "message" in error
|
||||||
|
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
||||||
|
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
||||||
|
({ code, message, recommendation, status, retryable });
|
||||||
|
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
||||||
|
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
||||||
|
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
||||||
|
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
||||||
|
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
||||||
|
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
||||||
|
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
||||||
|
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
||||||
|
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
||||||
|
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
||||||
|
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
||||||
|
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
||||||
|
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
||||||
|
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
||||||
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export type DownloadPreparationInput = {
|
export type DownloadPreparationInput = {
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -9,17 +13,18 @@ export type DownloadPreparationInput = {
|
|||||||
cookie: string;
|
cookie: string;
|
||||||
};
|
};
|
||||||
type Result =
|
type Result =
|
||||||
| { status: "preparing" }
|
| { status: "preparing"; requestId: string }
|
||||||
| { status: "ready"; asset: ImageBytes }
|
| { status: "ready"; asset: ImageBytes }
|
||||||
| { status: "failed"; error: string };
|
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
||||||
type Job = {
|
type Job = {
|
||||||
|
requestId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
input: DownloadPreparationInput;
|
input: DownloadPreparationInput;
|
||||||
status: "queued" | "running" | "ready" | "failed";
|
status: "queued" | "running" | "ready" | "failed";
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
asset?: ImageBytes;
|
asset?: ImageBytes;
|
||||||
|
failure?: DownloadFailure;
|
||||||
};
|
};
|
||||||
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
|
||||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
// A download is independent of node autosave and of the archive worker's batch.
|
// A download is independent of node autosave and of the archive worker's batch.
|
||||||
@ -69,7 +74,7 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
reject(new Error("Download preparation deadline"));
|
reject(new Error("Download preparation deadline"));
|
||||||
}, deps.timeoutMs || 90_000);
|
}, deps.timeoutMs || 150_000);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||||
@ -81,8 +86,10 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
const stored = await deps.read(key).catch(() => null);
|
const stored = await deps.read(key).catch(() => null);
|
||||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||||
job.status = "ready";
|
job.status = "ready";
|
||||||
} catch {
|
} catch (error) {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
|
job.failure = getDownloadFailure(error);
|
||||||
|
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
// Do not retain authentication data after a preparation finishes.
|
// Do not retain authentication data after a preparation finishes.
|
||||||
@ -105,8 +112,11 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
prune();
|
prune();
|
||||||
const current = jobs.get(key);
|
const current = jobs.get(key);
|
||||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||||
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
if (current?.status === "failed") {
|
||||||
if (current && current.status !== "ready") return { status: "preparing" };
|
const failure = current.failure || getDownloadFailure(null);
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
||||||
|
}
|
||||||
|
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
||||||
if (jobs.size >= 64) {
|
if (jobs.size >= 64) {
|
||||||
for (const [oldKey, job] of jobs) {
|
for (const [oldKey, job] of jobs) {
|
||||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||||
@ -115,11 +125,14 @@ export function createDownloadPreparationQueue(deps: {
|
|||||||
}
|
}
|
||||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||||
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
const requestId = randomUUID();
|
||||||
|
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
||||||
|
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
||||||
}
|
}
|
||||||
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
const requestId = randomUUID();
|
||||||
|
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||||
deps.schedule(drain);
|
deps.schedule(drain);
|
||||||
return { status: "preparing" };
|
return { status: "preparing", requestId };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,8 @@ 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 { isProviderBalanceError } from "@/lib/request-error";
|
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error";
|
||||||
|
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||||
|
|
||||||
export interface CanvasLocalEditCompositeRegion {
|
export interface CanvasLocalEditCompositeRegion {
|
||||||
x: number;
|
x: number;
|
||||||
@ -4632,10 +4633,10 @@ function findCanvasFailoverModel(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateCanvasImage(
|
export async function generateCanvasImage(
|
||||||
request: CanvasGenerateImageRequest
|
input: CanvasGenerateImageRequest
|
||||||
): Promise<CanvasGenerateImageResult> {
|
): Promise<CanvasGenerateImageResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false };
|
const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input;
|
||||||
const prompt = String(request.prompt || "").trim();
|
const prompt = String(request.prompt || "").trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error("请输入提示词");
|
throw new Error("请输入提示词");
|
||||||
@ -4873,11 +4874,16 @@ 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 referenceImage =
|
const resolveReference = async (url: string) => {
|
||||||
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
if (!normalizeLegacyAliyunStorageUrl(url)) return url;
|
||||||
const referenceImages = Array.isArray(request.referenceImages)
|
const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db");
|
||||||
|
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"
|
||||||
@ -5657,7 +5663,11 @@ 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 instanceof ProviderTaskUncertainError) throw caughtError;
|
if (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
|
||||||
|
|||||||
34
lib/canvas/image-generation-status.ts
Normal file
34
lib/canvas/image-generation-status.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
5
lib/canvas/image-preview-width.ts
Normal file
5
lib/canvas/image-preview-width.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
96
lib/canvas/operations/box-cutout.ts
Normal file
96
lib/canvas/operations/box-cutout.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,9 +9,11 @@ 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) {
|
||||||
@ -27,6 +29,7 @@ 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();
|
||||||
@ -40,6 +43,7 @@ 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);
|
||||||
@ -94,6 +98,7 @@ 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,
|
||||||
@ -101,6 +106,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
|||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
skipGallerySave,
|
skipGallerySave,
|
||||||
|
inputImageUrl,
|
||||||
sourceImageUrl,
|
sourceImageUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -114,22 +120,29 @@ 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(
|
||||||
{
|
{
|
||||||
error: error.message,
|
success: false,
|
||||||
|
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(
|
||||||
{ error: "服务器内部错误", detail: error instanceof Error ? error.message : "未知错误" },
|
{ success: false, error: formatRequestFailure(failure, requestId), detail: error instanceof Error ? error.message : "未知错误", code: failure.code, failure, requestId },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { getDownloadFailure } from "./download-error";
|
||||||
|
|
||||||
type DownloadFileItem = {
|
type DownloadFileItem = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -31,6 +32,7 @@ function sleep(ms: number) {
|
|||||||
|
|
||||||
function normalizeDownloadError(error: unknown): string {
|
function normalizeDownloadError(error: unknown): string {
|
||||||
if (error instanceof Error) return error.message || "download failed";
|
if (error instanceof Error) return error.message || "download failed";
|
||||||
|
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
||||||
if (typeof error === "string") return error;
|
if (typeof error === "string") return error;
|
||||||
return "download failed";
|
return "download failed";
|
||||||
}
|
}
|
||||||
@ -330,6 +332,7 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
try {
|
try {
|
||||||
const data = await fetchImageBytes(candidateUrl, {
|
const data = await fetchImageBytes(candidateUrl, {
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
signal: params.signal,
|
||||||
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
||||||
// Authenticated local assets must never share a cache across users.
|
// Authenticated local assets must never share a cache across users.
|
||||||
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
||||||
@ -344,11 +347,13 @@ export async function fetchCanvasDownloadAsset(params: {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
params.signal?.throwIfAborted();
|
params.signal?.throwIfAborted();
|
||||||
lastError = normalizeDownloadError(error);
|
const message = normalizeDownloadError(error);
|
||||||
|
if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Archiving may have completed during the failed fetch. Resolve aliases
|
// Archiving may have completed during the failed fetch. Resolve aliases
|
||||||
// again before retrying, rather than spending every attempt on the old URL.
|
// again before retrying, rather than spending every attempt on the old URL.
|
||||||
|
if (!getDownloadFailure(lastError).retryable) break;
|
||||||
if (attempt < retryCount) await sleep(350 * attempt);
|
if (attempt < retryCount) await sleep(350 * attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,22 +40,6 @@ 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,
|
||||||
@ -220,7 +204,7 @@ async function buildPersonExclusionAlpha(params: {
|
|||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const person = await executeScaleAdjustRmbgJob({
|
const person = await executeRmbgJob({
|
||||||
foregroundType: "person",
|
foregroundType: "person",
|
||||||
imageBuffer: params.sourceBuffer,
|
imageBuffer: params.sourceBuffer,
|
||||||
prompt: "比例调整人物保护蒙版",
|
prompt: "比例调整人物保护蒙版",
|
||||||
@ -330,7 +314,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
|
|||||||
.png({ compressionLevel: 6 })
|
.png({ compressionLevel: 6 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
semanticSourceCropBuffers.push(cropBuffer);
|
semanticSourceCropBuffers.push(cropBuffer);
|
||||||
const subject = await executeScaleAdjustRmbgJob({
|
const subject = await executeRmbgJob({
|
||||||
imageBuffer: cropBuffer,
|
imageBuffer: cropBuffer,
|
||||||
prompt: "比例调整完整产品蒙版",
|
prompt: "比例调整完整产品蒙版",
|
||||||
skipGallerySave: true,
|
skipGallerySave: true,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
|
||||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||||
|
|
||||||
@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
code: CanvasImageDownloadErrorCode;
|
code: CanvasImageDownloadErrorCode;
|
||||||
retryAfterSeconds?: number;
|
retryAfterSeconds?: number;
|
||||||
status?: number;
|
status?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@ -40,11 +45,14 @@ export class CanvasImageDownloadError extends Error {
|
|||||||
this.code = options.code;
|
this.code = options.code;
|
||||||
this.retryAfterSeconds = options.retryAfterSeconds;
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
||||||
this.status = options.status;
|
this.status = options.status;
|
||||||
|
this.failure = options.failure;
|
||||||
|
this.requestId = options.requestId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
||||||
if (error instanceof CanvasImageDownloadError) {
|
if (error instanceof CanvasImageDownloadError) {
|
||||||
|
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
|
||||||
if (error.code === "rate_limited") {
|
if (error.code === "rate_limited") {
|
||||||
return error.retryAfterSeconds
|
return error.retryAfterSeconds
|
||||||
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
||||||
@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
|||||||
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||||
}
|
}
|
||||||
if (error.code === "preparation_timeout") {
|
if (error.code === "preparation_timeout") {
|
||||||
return "原图仍在后台准备中,请稍后再次点击下载";
|
return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : "图片下载失败,请稍后重试";
|
return formatDownloadFailure(getDownloadFailure(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadCanvasImageParams = {
|
type DownloadCanvasImageParams = {
|
||||||
@ -71,6 +79,8 @@ type DownloadCanvasImageParams = {
|
|||||||
type DownloadErrorPayload = {
|
type DownloadErrorPayload = {
|
||||||
error?: string;
|
error?: string;
|
||||||
retryAfter?: number;
|
retryAfter?: number;
|
||||||
|
failure?: DownloadFailure;
|
||||||
|
requestId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_RATE_LIMIT_RETRIES = 1;
|
const MAX_RATE_LIMIT_RETRIES = 1;
|
||||||
@ -166,6 +176,63 @@ function triggerBlobDownload(blob: Blob, filename: string) {
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
|
||||||
|
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
|
||||||
|
throw new Error("下载链接无效");
|
||||||
|
}
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.hidden = true;
|
||||||
|
frame.src = result.downloadUrl;
|
||||||
|
document.body.appendChild(frame);
|
||||||
|
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||||
|
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the source format and bytes for chat, gallery and reference downloads. */
|
||||||
|
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||||
|
const source = String(url || "").trim();
|
||||||
|
if (!source) throw new Error("图片地址无效");
|
||||||
|
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
||||||
|
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
|
||||||
|
const target = new URL(proxy, window.location.origin);
|
||||||
|
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
|
||||||
|
const checkedFetch = async (input: string) => {
|
||||||
|
let response: Response;
|
||||||
|
try { response = await fetch(input, { signal }); }
|
||||||
|
catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
|
||||||
|
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
|
||||||
|
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
|
||||||
|
target.searchParams.set("download", "resolve");
|
||||||
|
target.searchParams.set("name", filenameBase);
|
||||||
|
const metadata = await checkedFetch(target.pathname + target.search);
|
||||||
|
const delivery = await metadata.json();
|
||||||
|
if (delivery.delivery === "cdn") {
|
||||||
|
const result = triggerCdnDownload(delivery, signal);
|
||||||
|
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
||||||
|
}
|
||||||
|
const response = await checkedFetch(proxy);
|
||||||
|
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||||
|
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||||
|
const extension = getImageDownloadExtension(blob.type);
|
||||||
|
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||||
|
triggerBlobDownload(blob, filename);
|
||||||
|
return { filename, bytes: blob.size };
|
||||||
|
}
|
||||||
|
|
||||||
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||||
const raw = String(url || "").trim();
|
const raw = String(url || "").trim();
|
||||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||||
@ -257,7 +324,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
let preparationPolls = 0;
|
let preparationPolls = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
response = await fetch("/api/canvas/download-image", {
|
try {
|
||||||
|
response = await fetch("/api/canvas/download-image", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -268,7 +336,12 @@ export async function downloadCanvasImageAsPng({
|
|||||||
preparation: "async",
|
preparation: "async",
|
||||||
}),
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (signal?.aborted) throw error;
|
||||||
|
const failure = getDownloadFailure(error);
|
||||||
|
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
rateLimitRetries = 0;
|
rateLimitRetries = 0;
|
||||||
@ -278,7 +351,7 @@ export async function downloadCanvasImageAsPng({
|
|||||||
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
||||||
}
|
}
|
||||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 });
|
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||||
}
|
}
|
||||||
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||||
@ -299,6 +372,8 @@ export async function downloadCanvasImageAsPng({
|
|||||||
code: "rate_limited",
|
code: "rate_limited",
|
||||||
retryAfterSeconds,
|
retryAfterSeconds,
|
||||||
status: response.status,
|
status: response.status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -321,22 +396,18 @@ export async function downloadCanvasImageAsPng({
|
|||||||
{
|
{
|
||||||
code: status === 502 ? "source_unavailable" : "request_failed",
|
code: status === 502 ? "source_unavailable" : "request_failed",
|
||||||
status,
|
status,
|
||||||
|
failure: errorPayload?.failure,
|
||||||
|
requestId: errorPayload?.requestId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.headers.get("content-type")?.includes("application/json")) {
|
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|
if (result.delivery !== "cdn") throw new Error("下载链接无效");
|
||||||
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
|
const downloaded = triggerCdnDownload(result, signal);
|
||||||
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
|
|
||||||
const frame = document.createElement("iframe");
|
|
||||||
frame.hidden = true;
|
|
||||||
frame.src = result.downloadUrl;
|
|
||||||
document.body.appendChild(frame);
|
|
||||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
|
||||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
return downloaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename =
|
const filename =
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
|
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 = {
|
||||||
@ -9,6 +21,26 @@ 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();
|
||||||
@ -20,6 +52,8 @@ export function getImageDownloadExtension(contentType: string) {
|
|||||||
"image/avif": "avif",
|
"image/avif": "avif",
|
||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
"image/svg+xml": "svg",
|
"image/svg+xml": "svg",
|
||||||
|
"image/bmp": "bmp",
|
||||||
|
"image/tiff": "tiff",
|
||||||
};
|
};
|
||||||
return extensions[mime] || "";
|
return extensions[mime] || "";
|
||||||
}
|
}
|
||||||
@ -57,6 +91,15 @@ 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 = "";
|
||||||
@ -183,8 +226,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An original download must go through the same-origin byte-preserving
|
// The same-origin endpoint can resolve a native CDN download without relying
|
||||||
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
// on cross-origin fetch, while retaining a byte-preserving proxy fallback.
|
||||||
if (options.delivery === "original") {
|
if (options.delivery === "original") {
|
||||||
const params = new URLSearchParams({ url: source, original: "1" });
|
const params = new URLSearchParams({ url: source, original: "1" });
|
||||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||||
@ -198,7 +241,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
|||||||
return trustedDirectStorageUrl;
|
return trustedDirectStorageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.delivery !== "proxy") {
|
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) {
|
||||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||||
if (directDisplayUrl) {
|
if (directDisplayUrl) {
|
||||||
return directDisplayUrl;
|
return directDisplayUrl;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { preparePendingImageDownloads } from "@/lib/storage/image-download-preparation";
|
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||||
import "server-only";
|
import "server-only";
|
||||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
@ -32,6 +32,7 @@ export async function runImageArchiveJob() {
|
|||||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||||
|
|
||||||
await recoverStaleImageArchiveTasks();
|
await recoverStaleImageArchiveTasks();
|
||||||
|
scheduleImageDownloadPreparation();
|
||||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||||
let storeSucceeded = 0;
|
let storeSucceeded = 0;
|
||||||
let storeFailed = 0;
|
let storeFailed = 0;
|
||||||
@ -45,6 +46,7 @@ export async function runImageArchiveJob() {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
targetUrl: archived.url,
|
targetUrl: archived.url,
|
||||||
});
|
});
|
||||||
|
scheduleImageDownloadPreparation(task.id);
|
||||||
storeSucceeded += 1;
|
storeSucceeded += 1;
|
||||||
console.log("[ImageArchiveJob] stored", {
|
console.log("[ImageArchiveJob] stored", {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@ -108,7 +110,6 @@ export async function runImageArchiveJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadTasks = await preparePendingImageDownloads();
|
|
||||||
const stats = await getImageArchiveTaskStats();
|
const stats = await getImageArchiveTaskStats();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -124,8 +125,8 @@ export async function runImageArchiveJob() {
|
|||||||
failed: replaceFailed,
|
failed: replaceFailed,
|
||||||
},
|
},
|
||||||
stats,
|
stats,
|
||||||
downloadTasks,
|
downloadTasks: 0,
|
||||||
pendingDownloads: await hasPendingImageDownloads(),
|
pendingDownloads: await hasPendingImageDownloads(),
|
||||||
};
|
};
|
||||||
});
|
}, { durable: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,14 @@ import { unlinkSync, writeFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number };
|
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number; __aiChatbotDurableWork?: number };
|
||||||
const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`);
|
const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`);
|
||||||
|
|
||||||
function update(delta: number) {
|
function update(delta: number, durable: boolean) {
|
||||||
state.__aiChatbotActiveWork = (state.__aiChatbotActiveWork || 0) + delta;
|
state.__aiChatbotActiveWork = (state.__aiChatbotActiveWork || 0) + delta;
|
||||||
|
if (durable) state.__aiChatbotDurableWork = (state.__aiChatbotDurableWork || 0) + delta;
|
||||||
try {
|
try {
|
||||||
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork }), { mode: 0o600 });
|
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork, durable: state.__aiChatbotDurableWork || 0 }), { mode: 0o600 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { unlinkSync(path); } catch {}
|
try { unlinkSync(path); } catch {}
|
||||||
console.warn("[Active Work] Failed to record running work:", error);
|
console.warn("[Active Work] Failed to record running work:", error);
|
||||||
@ -17,7 +18,7 @@ function update(delta: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Includes generation that continues after the client connection closes. */
|
/** Includes generation that continues after the client connection closes. */
|
||||||
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>): Promise<T> {
|
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>, options: { durable?: boolean } = {}): Promise<T> {
|
||||||
update(1);
|
update(1, options.durable === true);
|
||||||
try { return await execute(); } finally { update(-1); }
|
try { return await execute(); } finally { update(-1, options.durable === true); }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -228,10 +228,72 @@ 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 ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
if (/AOS创建任务超时/.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)) {
|
||||||
@ -243,9 +305,15 @@ 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");
|
||||||
}
|
}
|
||||||
|
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
|
||||||
|
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
|
||||||
|
}
|
||||||
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
||||||
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
||||||
}
|
}
|
||||||
@ -258,6 +326,9 @@ export function getRequestFailureDetails(
|
|||||||
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
|
if (/neither a task ID nor output URLs|已完成.*未取回|completed without.*output/i.test(text)) {
|
||||||
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
|
return detail("RESULT_UNCONFIRMED", "模型服务没有返回可读取的任务结果,生成状态尚未确认。", "请保留当前任务并联系管理员核对,避免重复生成。", "unknown");
|
||||||
}
|
}
|
||||||
|
if (/(?:抠图|去水印|图片增强|图片|扩图)处理失败/.test(text)) {
|
||||||
|
return detail("PROCESSING_FAILED", "图片处理服务执行失败,未返回具体原因。", "请联系管理员并提供请求编号,先核对原任务结果,避免重复处理。", "unknown");
|
||||||
|
}
|
||||||
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
|
if (/service_error|upstream_server_error|excessive system load|generate(?: image)? failed/i.test(text)) {
|
||||||
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
return detail("PROVIDER_EXECUTION_FAILED", "模型服务未能完成本次处理。", "请稍后重试;若连续失败,请切换模型或联系管理员,并提供请求编号。");
|
||||||
}
|
}
|
||||||
|
|||||||
37
lib/scene-fusion/preprocess-product.ts
Normal file
37
lib/scene-fusion/preprocess-product.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,17 +10,21 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
|
|||||||
})));
|
})));
|
||||||
const task = await findImageArchiveDownloadTask(sources, true);
|
const task = await findImageArchiveDownloadTask(sources, true);
|
||||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||||
if (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null };
|
if (!task) return { task, direct: null, canPrepareDirect: false };
|
||||||
const config = await getDefaultStorageConfig();
|
const config = await getDefaultStorageConfig();
|
||||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null };
|
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
||||||
const url = new URL(asset.url);
|
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||||
|
const source = ready ? asset.url : task.targetUrl;
|
||||||
|
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
const url = new URL(source);
|
||||||
const base = new URL(config.publicUrl);
|
const base = new URL(config.publicUrl);
|
||||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||||
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
||||||
if (base.protocol !== "https:" || url.origin !== base.origin
|
if (base.protocol !== "https:" || url.origin !== base.origin
|
||||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null };
|
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };
|
||||||
|
if (!ready) return { task, direct: null, canPrepareDirect: true };
|
||||||
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
||||||
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
||||||
url.searchParams.set("attname", filename);
|
url.searchParams.set("attname", filename);
|
||||||
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset.bytes || 0 } };
|
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset?.bytes || 0 }, canPrepareDirect: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl } from "./retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
@ -22,6 +22,8 @@ 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;
|
||||||
@ -39,7 +41,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)) return null;
|
if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return null;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const archiveAfterAt = params.archiveAfterAt || now;
|
const archiveAfterAt = params.archiveAfterAt || now;
|
||||||
@ -312,6 +314,18 @@ 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,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// 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 (
|
||||||
@ -11,6 +12,7 @@ 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)) {
|
||||||
@ -35,6 +37,10 @@ export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImag
|
|||||||
.map((part) => replacements[part] || part)
|
.map((part) => replacements[part] || part)
|
||||||
.join("|||");
|
.join("|||");
|
||||||
return ``;
|
return ``;
|
||||||
|
}).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;
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, 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";
|
||||||
@ -81,18 +81,25 @@ function isMediaResponse(data: ImageBytes) {
|
|||||||
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
||||||
return data.buffer.length > 0;
|
return data.buffer.length > 0;
|
||||||
}
|
}
|
||||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
||||||
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
let sourceUrl = url;
|
||||||
if (url.startsWith("data:")) {
|
if (normalizeLegacyAliyunStorageUrl(url)) {
|
||||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
const { resolveLegacyAliyunStorageUrl } = await import("./image-archive-db");
|
||||||
|
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(url, {
|
const response = await fetch(sourceUrl, {
|
||||||
headers: options.headers, cache: "no-store", redirect: "follow",
|
headers: options.headers, cache: "no-store", redirect: "follow",
|
||||||
// The deadline covers the body as well as the response headers.
|
// The deadline covers the body as well as the response headers.
|
||||||
signal: AbortSignal.timeout(options.timeoutMs || 60_000),
|
signal: options.signal
|
||||||
|
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs || 60_000)])
|
||||||
|
: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|||||||
@ -2,14 +2,21 @@ import "server-only";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
||||||
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks } from "./image-archive-db";
|
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
||||||
import { uploadFile } from "./service";
|
import { uploadFile } from "./service";
|
||||||
|
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
||||||
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
|
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||||
|
|
||||||
|
const logger = createLogger("canvas.download");
|
||||||
|
|
||||||
export async function prepareImageArchiveDownload(id: string) {
|
export async function prepareImageArchiveDownload(id: string) {
|
||||||
const task = await claimImageDownloadTask(id);
|
const task = await claimImageDownloadTask(id);
|
||||||
if (!task?.targetUrl) return;
|
if (!task?.targetUrl) return;
|
||||||
|
let phase: "read" | "convert" | "store" = "read";
|
||||||
try {
|
try {
|
||||||
const source = await fetchImageBytes(task.targetUrl);
|
const source = await fetchImageBytes(task.targetUrl);
|
||||||
|
phase = "convert";
|
||||||
const asset = await normalizeCanvasDownloadAsset({
|
const asset = await normalizeCanvasDownloadAsset({
|
||||||
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
||||||
contentType: source.contentType, includeSequencePrefix: false,
|
contentType: source.contentType, includeSequencePrefix: false,
|
||||||
@ -17,15 +24,22 @@ export async function prepareImageArchiveDownload(id: string) {
|
|||||||
if (!asset.ok) throw new Error(asset.error);
|
if (!asset.ok) throw new Error(asset.error);
|
||||||
// An already compatible PNG shares the original object; other formats get
|
// An already compatible PNG shares the original object; other formats get
|
||||||
// one prepared PNG, without changing the original dimensions or transparency.
|
// one prepared PNG, without changing the original dimensions or transparency.
|
||||||
|
phase = "store";
|
||||||
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
||||||
? task.targetUrl
|
? task.targetUrl
|
||||||
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
||||||
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
||||||
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
||||||
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
||||||
} catch {
|
} catch (error) {
|
||||||
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
||||||
await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() });
|
const failure = phase === "store"
|
||||||
|
? getDownloadFailure("STORAGE_WRITE_FAILED")
|
||||||
|
: phase === "convert" && getDownloadFailure(error).code === "DOWNLOAD_FAILED"
|
||||||
|
? getDownloadFailure("图片转换失败") : getDownloadFailure(error);
|
||||||
|
logger.warn("archive_download_preparation_failed", error, { requestId: id, code: failure.code });
|
||||||
|
await saveImageDownloadAsset(id, { status: !failure.retryable || attempts >= 12 ? "abandoned" : "failed", attempts,
|
||||||
|
failure, retryAt: new Date(Date.now() + 60_000).toISOString() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export async function preparePendingImageDownloads() {
|
export async function preparePendingImageDownloads() {
|
||||||
@ -36,3 +50,45 @@ export async function preparePendingImageDownloads() {
|
|||||||
}));
|
}));
|
||||||
return tasks.length;
|
return tasks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = globalThis as typeof globalThis & {
|
||||||
|
__imageDownloadWorker?: { running: boolean; scanPending: boolean; priority: Set<string>; retry?: ReturnType<typeof setTimeout> };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Download preparation runs independently of the archive URL replacement batch. */
|
||||||
|
export function scheduleImageDownloadPreparation(id?: string) {
|
||||||
|
const worker = state.__imageDownloadWorker ??= { running: false, scanPending: false, priority: new Set() };
|
||||||
|
if (id) worker.priority.add(id);
|
||||||
|
else worker.scanPending = true;
|
||||||
|
if (worker.running) return;
|
||||||
|
if (worker.retry) { clearTimeout(worker.retry); worker.retry = undefined; }
|
||||||
|
worker.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void trackRuntimeWork(async () => {
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const priority = [...worker.priority].slice(0, 2);
|
||||||
|
for (const taskId of priority) worker.priority.delete(taskId);
|
||||||
|
if (priority.length) {
|
||||||
|
await Promise.all(priority.map(prepareImageArchiveDownload));
|
||||||
|
} else if (!worker.scanPending || await preparePendingImageDownloads() === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("download_worker_failed", error);
|
||||||
|
} finally {
|
||||||
|
worker.running = false;
|
||||||
|
// Eligible retry times live in the database, including interrupted leases.
|
||||||
|
if (worker.priority.size) {
|
||||||
|
scheduleImageDownloadPreparation([...worker.priority][0]);
|
||||||
|
} else if (worker.scanPending && await hasPendingImageDownloads().catch(() => true)) {
|
||||||
|
worker.retry = setTimeout(() => scheduleImageDownloadPreparation(), 60_000);
|
||||||
|
worker.retry.unref?.();
|
||||||
|
} else {
|
||||||
|
worker.scanPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl } from "./retired-storage";
|
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, normalizeLegacyAliyunStorageUrl } 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,6 +19,7 @@ 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() &&
|
||||||
@ -83,7 +84,7 @@ export async function enqueueStorageMigrationTask({
|
|||||||
migrateAfterAt: Date;
|
migrateAfterAt: Date;
|
||||||
cleanupAfterAt: Date;
|
cleanupAfterAt: Date;
|
||||||
}) {
|
}) {
|
||||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(storageMigrationTask)
|
.insert(storageMigrationTask)
|
||||||
|
|||||||
45
lib/storage/original-image-download.ts
Normal file
45
lib/storage/original-image-download.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||||
|
import { findImageArchiveDownloadTask } from "./image-archive-db";
|
||||||
|
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||||
|
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
||||||
|
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
||||||
|
|
||||||
|
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
||||||
|
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
||||||
|
const config = await getDefaultStorageConfig();
|
||||||
|
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
|
||||||
|
const base = new URL(config.publicUrl);
|
||||||
|
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||||
|
let target = new URL(source);
|
||||||
|
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||||
|
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||||
|
target = new URL(task?.targetUrl || source);
|
||||||
|
}
|
||||||
|
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||||
|
target.host = base.host;
|
||||||
|
target.protocol = base.protocol;
|
||||||
|
}
|
||||||
|
if (base.protocol !== "https:" || target.origin !== base.origin ||
|
||||||
|
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
|
||||||
|
// A Kodo download token signs the complete URL; adding attname would invalidate it.
|
||||||
|
if (target.searchParams.has("token")) return null;
|
||||||
|
|
||||||
|
// Only metadata crosses the application server. The browser receives the file
|
||||||
|
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
||||||
|
const response = await fetch(target, {
|
||||||
|
method: "HEAD", cache: "no-store",
|
||||||
|
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||||
|
const extension = getImageDownloadExtension(contentType);
|
||||||
|
if (!extension) return null;
|
||||||
|
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
|
||||||
|
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
|
||||||
|
const filename = `${basename}.${extension}`;
|
||||||
|
target.searchParams.set("attname", filename);
|
||||||
|
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
|
||||||
|
bytes: Number(response.headers.get("content-length")) || 0 };
|
||||||
|
}
|
||||||
@ -2,6 +2,29 @@
|
|||||||
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 {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, 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)) {
|
if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl) || isLegacyAliyunStorageConfig(config)) {
|
||||||
throw new Error(RETIRED_STORAGE_MESSAGE);
|
throw new Error(RETIRED_STORAGE_MESSAGE);
|
||||||
}
|
}
|
||||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||||
@ -343,6 +343,7 @@ 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 ||
|
||||||
@ -370,7 +371,8 @@ 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) && bucket && accessKeyId && secretAccessKey) {
|
if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) &&
|
||||||
|
!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",
|
||||||
|
|||||||
@ -3,6 +3,15 @@ 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\/canvas-download-speed\.test\.ts/,
|
||||||
|
/routes\/canvas-download-recovery\.test\.ts/,
|
||||||
|
/routes\/canvas-download-rate-limit\.test\.ts/,
|
||||||
|
/routes\/canvas-zip-download\.test\.ts/,
|
||||||
|
/routes\/image-archive-flow\.test\.ts/,
|
||||||
/routes\/runtime-active-work\.test\.ts/,
|
/routes\/runtime-active-work\.test\.ts/,
|
||||||
/routes\/canvas-capability-task-recovery\.test\.ts/,
|
/routes\/canvas-capability-task-recovery\.test\.ts/,
|
||||||
/routes\/canvas-toolbar-capability\.test\.ts/,
|
/routes\/canvas-toolbar-capability\.test\.ts/,
|
||||||
@ -12,6 +21,7 @@ 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/,
|
||||||
@ -45,6 +55,7 @@ export default defineConfig({
|
|||||||
/routes\/replacement-product-identity\.test\.ts/,
|
/routes\/replacement-product-identity\.test\.ts/,
|
||||||
/routes\/detail-replacement-batch\.test\.ts/,
|
/routes\/detail-replacement-batch\.test\.ts/,
|
||||||
/routes\/alibaba-image-input\.test\.ts/,
|
/routes\/alibaba-image-input\.test\.ts/,
|
||||||
|
/routes\/alibaba-task-errors\.test\.ts/,
|
||||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||||
|
|||||||
@ -95,18 +95,19 @@ function normalizeRevisionText(value: unknown) {
|
|||||||
return String(value || "").trim();
|
return String(value || "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void {
|
function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, 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;
|
||||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
const current = useCanvasStore.getState();
|
||||||
|
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCanvasStoreRevisionFromProject(
|
function syncCanvasStoreRevisionFromProject(
|
||||||
project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
||||||
): void {
|
): void {
|
||||||
if (!project?.canvasData) return;
|
if (!project?.canvasData) return;
|
||||||
syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt);
|
syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedCropRect {
|
export interface NormalizedCropRect {
|
||||||
@ -203,9 +204,6 @@ 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: '获取画布失败' };
|
||||||
@ -335,7 +333,7 @@ export const canvasService = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (data?.success) {
|
if (data?.success) {
|
||||||
syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt);
|
syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt);
|
||||||
}
|
}
|
||||||
return data as ApiResponse;
|
return data as ApiResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
119
tests/e2e/canvas-generation-feedback.test.ts
Normal file
119
tests/e2e/canvas-generation-feedback.test.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@ -53,6 +53,7 @@ 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,
|
||||||
|
|||||||
59
tests/routes/alibaba-task-errors.test.ts
Normal file
59
tests/routes/alibaba-task-errors.test.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||||
|
const methods: string[] = [];
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (_url: string, init: RequestInit) => {
|
||||||
|
methods.push(init.method || "GET");
|
||||||
|
return Response.json(methods.length === 1
|
||||||
|
? { output: { task_id: "original-task", task_status: "PENDING" } }
|
||||||
|
: { output: { task_id: "original-task", task_status: "FAILED", code: "InvalidParameter", message: "Failed to download image URL after retries." } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example/api/v1/services/aigc/multimodal-generation/generation" });
|
||||||
|
let error: unknown;
|
||||||
|
try { await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }], imageParams: { aspectRatio: "1:1" } }); } catch (caught) { error = caught; }
|
||||||
|
expect(String(error)).toContain("Failed to download image URL after retries.");
|
||||||
|
expect(String(error)).toContain("InvalidParameter");
|
||||||
|
expect(String(error)).toContain("original-task");
|
||||||
|
expect(methods).toEqual(["POST", "GET"]);
|
||||||
|
const failure = loader("lib/request-error.ts").getRequestFailureDetails(error);
|
||||||
|
expect(failure).toMatchObject({ code: "PROVIDER_REFERENCE_FETCH_FAILED", message: "模型服务未能读取参考图。" });
|
||||||
|
expect(failure.recommendation).toContain("模型取图线路");
|
||||||
|
expect(failure.recommendation).not.toContain("稍后重试");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto resolution", async () => {
|
||||||
|
const png = await sharp({ create: { width: 32, height: 64, channels: 3, background: "blue" } }).png().toBuffer();
|
||||||
|
const jpeg = await sharp(png).jpeg().toBuffer();
|
||||||
|
const webp = await sharp(png).webp().toBuffer();
|
||||||
|
const originals = [png, jpeg, webp];
|
||||||
|
let submitted: any;
|
||||||
|
let posts = 0;
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
|
||||||
|
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||||
|
fetch: async (url: string, init: RequestInit = {}) => {
|
||||||
|
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
||||||
|
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
||||||
|
const index = Number(new URL(url).pathname.slice(1));
|
||||||
|
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||||
|
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||||
|
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
||||||
|
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
|
||||||
|
expect(posts).toBe(1);
|
||||||
|
expect(submitted.parameters.size).toBe("1024*2048");
|
||||||
|
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
||||||
|
expect(images).toHaveLength(3);
|
||||||
|
images.forEach((value: string, index: number) => {
|
||||||
|
expect(value.split(";", 1)[0]).toBe(["data:image/png", "data:image/jpeg", "data:image/webp"][index]);
|
||||||
|
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -71,6 +71,8 @@ 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
|
"@/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/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 },
|
||||||
|
|||||||
@ -1,4 +1,15 @@
|
|||||||
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 = ``;
|
||||||
|
expect(extractVideoUrls(markdown)).toEqual([]);
|
||||||
|
expect(extractAudioUrls(markdown)).toEqual([]);
|
||||||
|
expect(extractAssistantImageUrls(markdown)).toContain(url);
|
||||||
|
expect(stripMediaMarkup(markdown)).toBe(markdown);
|
||||||
|
}
|
||||||
|
});
|
||||||
import {
|
import {
|
||||||
collectMessageImageUrls,
|
collectMessageImageUrls,
|
||||||
extractAssistantImageUrls,
|
extractAssistantImageUrls,
|
||||||
@ -797,6 +808,7 @@ 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: [],
|
||||||
@ -874,7 +886,7 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas service keeps store revision aligned after project load and save", async () => {
|
test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => {
|
||||||
const canvasData = {
|
const canvasData = {
|
||||||
nodes: [],
|
nodes: [],
|
||||||
edges: [],
|
edges: [],
|
||||||
@ -904,6 +916,7 @@ 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,
|
||||||
@ -918,7 +931,11 @@ test.describe("canvas chat pure helpers", () => {
|
|||||||
}) as Response) as typeof fetch;
|
}) as Response) as typeof fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await canvasService.getProject("11111111-1111-4111-8111-111111111111");
|
const result = 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"
|
||||||
);
|
);
|
||||||
|
|||||||
159
tests/routes/canvas-download-recovery.test.ts
Normal file
159
tests/routes/canvas-download-recovery.test.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
const auth = { auth: async () => ({ user: { id: "owner" } }) };
|
||||||
|
const request = () => ({
|
||||||
|
url: "https://app.example/api/canvas/download-image", headers: new Headers(),
|
||||||
|
json: async () => ({ url: "https://provider.example/original.jpg", preferDirect: true, preparation: "async" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download failures distinguish permanent sources, transport, conversion and storage with actionable advice", () => {
|
||||||
|
const { getDownloadFailure, formatDownloadFailure } = serverLoader({})("lib/canvas/download-error.ts");
|
||||||
|
for (const [error, code, status] of [
|
||||||
|
["HTTP 404", "SOURCE_NOT_FOUND", 404], ["HTTP 403", "SOURCE_FORBIDDEN", 403],
|
||||||
|
["TimeoutError: slow body", "SOURCE_TIMEOUT", 504], ["fetch failed", "SOURCE_UNAVAILABLE", 503],
|
||||||
|
["source returned an error document", "INVALID_IMAGE", 422], ["unsupported image format", "IMAGE_CONVERSION_FAILED", 422],
|
||||||
|
["STORAGE_WRITE_FAILED", "DOWNLOAD_STORAGE_FAILED", 503], ["DOWNLOAD_BUSY", "DOWNLOAD_BUSY", 429],
|
||||||
|
]) {
|
||||||
|
const failure = getDownloadFailure(new Error(error as string));
|
||||||
|
expect(failure).toMatchObject({ code, status });
|
||||||
|
expect(failure.recommendation).toBeTruthy();
|
||||||
|
expect(formatDownloadFailure(failure, "feedback-id")).toContain("下载编号:feedback-id");
|
||||||
|
if (status === 404) expect(failure.recommendation).not.toContain("稍后重试");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed preparation polls retain the same cause and feedback ID without rerunning the load", async () => {
|
||||||
|
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||||
|
const runs: Array<() => Promise<void>> = [];
|
||||||
|
let loads = 0;
|
||||||
|
const queue = createDownloadPreparationQueue({
|
||||||
|
load: async () => { loads++; throw new Error("HTTP 404 https://source.example/private?token=private"); },
|
||||||
|
read: async () => null, write: async () => {}, schedule: (run: () => Promise<void>) => runs.push(run),
|
||||||
|
});
|
||||||
|
const input = { userId: "owner", urls: ["https://source.example/original"], requestOrigin: "https://app.example", cookie: "session=private" };
|
||||||
|
const accepted = await queue.request(input);
|
||||||
|
await runs[0]();
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const failed = await queue.request(input);
|
||||||
|
expect(failed).toMatchObject({ status: "failed", requestId: accepted.requestId, failure: { code: "SOURCE_NOT_FOUND", status: 404 } });
|
||||||
|
expect(failed.error).not.toContain("token=private");
|
||||||
|
}
|
||||||
|
expect(loads).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("download queue saturation returns a retryable 429 instead of a source 502", async () => {
|
||||||
|
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||||
|
const queue = createDownloadPreparationQueue({ load: async () => { throw new Error("not started"); }, read: async () => null, write: async () => {}, schedule() {} });
|
||||||
|
for (let i = 0; i < 4; i++) await queue.request({ userId: "owner", urls: [`https://source.example/${i}`], requestOrigin: "https://app.example", cookie: "" });
|
||||||
|
expect(await queue.request({ userId: "owner", urls: ["https://source.example/fifth"], requestOrigin: "https://app.example", cookie: "" }))
|
||||||
|
.toMatchObject({ status: "failed", failure: { code: "DOWNLOAD_BUSY", status: 429, retryable: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cold archived downloads wait for CDN preparation and never buffer the file through the app", async () => {
|
||||||
|
const queued: string[] = [];
|
||||||
|
let ready = false;
|
||||||
|
const route = serverLoader({
|
||||||
|
"@/app/(auth)/auth": auth,
|
||||||
|
"next/server": { NextResponse: Response, after() {} },
|
||||||
|
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({
|
||||||
|
task: { id: "archive-task", targetUrl: "https://cdn.example/original.jpg", metadata: { downloadAsset: { status: "pending" } } },
|
||||||
|
canPrepareDirect: true,
|
||||||
|
direct: ready ? { delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "original.png", bytes: 4000000 } : null,
|
||||||
|
}) },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation: (id: string) => queued.push(id) },
|
||||||
|
"@/lib/canvas/photoshop-safe-download": { sanitizeCanvasDownloadFileName: () => "original", fetchCanvasDownloadAsset: () => { throw new Error("Must use CDN delivery"); } },
|
||||||
|
})("app/api/canvas/download-image/route.ts");
|
||||||
|
const first = await route.POST(request());
|
||||||
|
expect(first.status).toBe(202);
|
||||||
|
expect(await first.json()).toMatchObject({ delivery: "preparing", requestId: "archive-task" });
|
||||||
|
ready = true;
|
||||||
|
const second = await route.POST(request());
|
||||||
|
expect(second.status).toBe(200);
|
||||||
|
expect(await second.json()).toMatchObject({ delivery: "cdn", bytes: 4000000 });
|
||||||
|
expect(queued).toEqual(["archive-task"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an expired archived source returns its permanent cause and download task ID", async () => {
|
||||||
|
const errors = serverLoader({})("lib/canvas/download-error.ts");
|
||||||
|
const route = serverLoader({
|
||||||
|
"@/app/(auth)/auth": auth, "next/server": { NextResponse: Response, after() {} },
|
||||||
|
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({ task: {
|
||||||
|
id: "failed-archive", targetUrl: "https://cdn.example/missing.png",
|
||||||
|
metadata: { downloadAsset: { status: "abandoned", failure: errors.getDownloadFailure("HTTP 404") } },
|
||||||
|
}, canPrepareDirect: true, direct: null }) },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { throw new Error("Do not retry a permanent failure"); } },
|
||||||
|
})("app/api/canvas/download-image/route.ts");
|
||||||
|
const response = await route.POST(request());
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toMatchObject({ requestId: "failed-archive", failure: { code: "SOURCE_NOT_FOUND" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the download browser keeps the structured cause, advice and feedback ID", async () => {
|
||||||
|
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||||
|
const client = serverLoader({ fetch: async () => Response.json({ failure, requestId: "real-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||||
|
let caught: unknown;
|
||||||
|
try { await client.downloadCanvasImageAsPng({ url: "https://source.example/original" }); } catch (error) { caught = error; }
|
||||||
|
expect(client.getCanvasImageDownloadErrorMessage(caught)).toBe("图片源站拒绝访问原图。请联系管理员检查访问权限。\n下载编号:real-feedback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the preparation deadline cancels the source body as well as its headers", async () => {
|
||||||
|
const cache = serverLoader({ fetch: async (_url: string, options: { signal: AbortSignal }) => ({
|
||||||
|
ok: true, headers: new Headers({ "content-type": "image/png" }),
|
||||||
|
arrayBuffer: () => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true })),
|
||||||
|
}) })("lib/storage/image-byte-cache.ts");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const fetching = cache.fetchImageBytes(`https://source.example/${randomUUID()}`, { timeoutMs: 10000, signal: controller.signal });
|
||||||
|
setTimeout(() => controller.abort(new Error("Preparation canceled")), 20);
|
||||||
|
await expect(fetching).rejects.toThrow("Preparation canceled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requested downloads are prioritized with two workers before scanning background assets", async () => {
|
||||||
|
let active = 0; let maximum = 0; let scans = 0;
|
||||||
|
const releases: Array<() => void> = [];
|
||||||
|
const workRecords: Array<{ active: number; durable: number }> = [];
|
||||||
|
const claimed: string[] = []; const saved: string[] = [];
|
||||||
|
const bytes = Buffer.from("compatible PNG fixture");
|
||||||
|
const worker = serverLoader({
|
||||||
|
"node:fs": { writeFileSync: (_path: string, value: string) => workRecords.push(JSON.parse(value)), unlinkSync() {} },
|
||||||
|
"./image-archive-db": {
|
||||||
|
claimImageDownloadTask: async (id: string) => { claimed.push(id); return { id, targetUrl: `https://cdn.example/${id}.png`, metadata: {} }; },
|
||||||
|
saveImageDownloadAsset: async (id: string) => saved.push(id),
|
||||||
|
getImageDownloadTasks: async () => { scans++; return []; }, hasPendingImageDownloads: async () => false,
|
||||||
|
},
|
||||||
|
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
|
||||||
|
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
|
||||||
|
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
|
||||||
|
})("lib/storage/image-download-preparation.ts");
|
||||||
|
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");
|
||||||
|
await expect.poll(() => active).toBe(2);
|
||||||
|
expect(workRecords.at(-1)).toMatchObject({ active: 1, durable: 0 });
|
||||||
|
expect(scans).toBe(0);
|
||||||
|
releases.splice(0).forEach(release => release());
|
||||||
|
await expect.poll(() => claimed.length).toBe(3);
|
||||||
|
releases.splice(0).forEach(release => release());
|
||||||
|
await expect.poll(() => saved.length).toBe(3);
|
||||||
|
expect(maximum).toBe(2); expect(scans).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("archive URL replacement cannot block the download worker wakeup", async () => {
|
||||||
|
let scheduled = false; let finish!: () => void; let replacing = false;
|
||||||
|
const gate = new Promise<void>(resolve => { finish = resolve; });
|
||||||
|
const job = serverLoader({
|
||||||
|
"@/lib/observability/active-work": { trackRuntimeWork: (execute: () => Promise<unknown>) => execute() },
|
||||||
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { scheduled = true; } },
|
||||||
|
"@/lib/storage/image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, getImageArchiveTasksToStore: async () => [],
|
||||||
|
getImageArchiveTasksToReplace: async () => [{ id: "archive", sourceUrl: "https://source.example/a", targetUrl: "https://cdn.example/a" }],
|
||||||
|
markImageArchiveTaskReplacing: async () => true, markImageArchiveTaskCompleted: async () => {}, hasPendingImageDownloads: async () => false,
|
||||||
|
getImageArchiveTaskStats: async () => ({ pendingStore: 0, pendingReplace: 0 }),
|
||||||
|
},
|
||||||
|
"@/lib/storage/image-url-replacement": { replaceImageUrlInDatabase: async () => { replacing = true; await gate; return { changed: 1 }; } },
|
||||||
|
"@/lib/storage/image-archive": {},
|
||||||
|
})("lib/jobs/image-archive-job.ts");
|
||||||
|
const running = job.runImageArchiveJob();
|
||||||
|
await expect.poll(() => replacing).toBe(true);
|
||||||
|
expect(scheduled).toBe(true);
|
||||||
|
finish();
|
||||||
|
expect((await running).replace.succeeded).toBe(1);
|
||||||
|
});
|
||||||
@ -236,8 +236,6 @@ 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("目标产品被人物遮挡");
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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 },
|
||||||
@ -86,6 +87,49 @@ 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")) {
|
||||||
@ -119,6 +163,71 @@ 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")) {
|
||||||
|
|||||||
@ -224,7 +224,7 @@ test("bucket failure retains the provider URL and retry task", async () => {
|
|||||||
let stored = 0;
|
let stored = 0;
|
||||||
let replaced = 0;
|
let replaced = 0;
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||||
"@/lib/storage/image-archive": {
|
"@/lib/storage/image-archive": {
|
||||||
archiveTemporaryImage: async () => {
|
archiveTemporaryImage: async () => {
|
||||||
throw new Error("Bucket unavailable");
|
throw new Error("Bucket unavailable");
|
||||||
@ -414,7 +414,7 @@ test("successful background archive replaces saved references immediately", asyn
|
|||||||
let completed = false;
|
let completed = false;
|
||||||
const calls: string[][] = [];
|
const calls: string[][] = [];
|
||||||
const module = serverLoader({
|
const module = serverLoader({
|
||||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||||
"@/lib/storage/image-archive": {
|
"@/lib/storage/image-archive": {
|
||||||
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
|
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,9 +2,77 @@ 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, getImageDownloadExtension } from "@/lib/image-delivery";
|
import { buildImageProxyUrl, getFullscreenPreviewSize, 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({
|
||||||
@ -154,7 +222,7 @@ test.describe("Qiniu image delivery", () => {
|
|||||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
|
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
|
||||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
|
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
|
||||||
expect(fetchCount).toBe(0);
|
expect(fetchCount).toBe(0);
|
||||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
|
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(422);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = previousFetch;
|
globalThis.fetch = previousFetch;
|
||||||
}
|
}
|
||||||
|
|||||||
39
tests/routes/image-generation-status.test.ts
Normal file
39
tests/routes/image-generation-status.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@ -17,6 +17,9 @@ 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", "核对原任务"],
|
||||||
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
||||||
["任务执行中断,结果尚未确认", "RESULT_UNCONFIRMED", "避免重复生成"],
|
["任务执行中断,结果尚未确认", "RESULT_UNCONFIRMED", "避免重复生成"],
|
||||||
]) {
|
]) {
|
||||||
@ -66,6 +69,20 @@ 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({
|
||||||
|
|||||||
137
tests/routes/legacy-oss-recovery.test.ts
Normal file
137
tests/routes/legacy-oss-recovery.test.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
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: `` };
|
||||||
|
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(``);
|
||||||
|
});
|
||||||
|
|
||||||
|
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" });
|
||||||
|
});
|
||||||
114
tests/routes/original-image-download.test.ts
Normal file
114
tests/routes/original-image-download.test.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||||
|
|
||||||
|
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||||||
|
|
||||||
|
test("original downloads use the archived JPEG rather than the prepared PNG and transfer only HEAD metadata", async () => {
|
||||||
|
const calls: Array<{ url: string; method: string }> = [];
|
||||||
|
const loader = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||||
|
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
||||||
|
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
||||||
|
} }) },
|
||||||
|
fetch: async (url: URL, options: { method: string }) => {
|
||||||
|
calls.push({ url: url.toString(), method: options.method });
|
||||||
|
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
||||||
|
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await loader("lib/storage/original-image-download.ts").resolveOriginalCdnDownload("https://upstream.example/temporary.png", "产品.png", new AbortController().signal);
|
||||||
|
expect(calls).toEqual([{ url: "https://cdn.example/original.jpg", method: "HEAD" }]);
|
||||||
|
expect(result).toMatchObject({ delivery: "cdn", filename: "产品.jpg", bytes: 12345 });
|
||||||
|
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
|
||||||
|
const module = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||||
|
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||||
|
fetch() { throw new Error("Do not add filename parameters to a signed URL"); },
|
||||||
|
})("lib/storage/original-image-download.ts");
|
||||||
|
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.jpg?token=signature%2Fvalue&e=123", "image.jpg", new AbortController().signal);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("existing CDN objects bypass the archive scan and retain the MIME-derived format", async () => {
|
||||||
|
const module = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||||
|
"./image-archive-db": { findImageArchiveDownloadTask() { throw new Error("An existing CDN object needs no archive scan"); } },
|
||||||
|
fetch: async () => ({ ok: true, headers: new Headers({ "content-type": "image/webp" }) }),
|
||||||
|
})("lib/storage/original-image-download.ts");
|
||||||
|
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.png", "source.png", new AbortController().signal);
|
||||||
|
expect(result.filename).toBe("source.webp");
|
||||||
|
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
|
||||||
|
const module = serverLoader({
|
||||||
|
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||||
|
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||||
|
fetch() { throw new Error("The upstream file service does not support HEAD"); },
|
||||||
|
})("lib/storage/original-image-download.ts");
|
||||||
|
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
||||||
|
const route = serverLoader({
|
||||||
|
"next/server": { NextResponse: Response },
|
||||||
|
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async () => { throw new Error("HTTP 404"); } },
|
||||||
|
})("app/api/image-proxy/route.ts");
|
||||||
|
const request = { nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fcdn.example%2Fmissing.jpg&original=1&download=resolve"), signal: new AbortController().signal, headers: new Headers() };
|
||||||
|
const response = await route.GET(request);
|
||||||
|
const data = await response.json();
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(data.failure).toMatchObject({ code: "SOURCE_NOT_FOUND", retryable: false });
|
||||||
|
expect(data.failure.recommendation).toBeTruthy();
|
||||||
|
expect(data.error).toContain(data.requestId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function browserMocks() {
|
||||||
|
const frames: any[] = []; const links: any[] = [];
|
||||||
|
return { frames, links,
|
||||||
|
window: { location: { origin: "https://app.example" }, setTimeout() {} },
|
||||||
|
document: { createElement: (tag: string) => ({ tag, click() { links.push(this); }, remove() {} }),
|
||||||
|
body: { appendChild: (el: any) => { if (el.tag === "iframe") frames.push(el); }, removeChild() {} } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||||
|
const dom = browserMocks(); const calls: string[] = [];
|
||||||
|
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||||
|
calls.push(url);
|
||||||
|
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
||||||
|
} })("lib/client/canvas-image-download.ts");
|
||||||
|
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
|
||||||
|
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
||||||
|
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||||
|
expect(dom.links).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||||||
|
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
|
||||||
|
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
|
||||||
|
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
|
||||||
|
expect(Buffer.from(actual).equals(bytes)).toBe(true);
|
||||||
|
URL.revokeObjectURL(dom.links[0].href);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("original downloads reject processed references before network access", async () => {
|
||||||
|
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
|
||||||
|
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("chat downloads display the server cause and advice without losing its feedback ID", async () => {
|
||||||
|
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||||
|
const client = serverLoader({ ...browserMocks(), fetch: async () => Response.json({ failure, requestId: "original-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||||
|
let error: unknown;
|
||||||
|
try { await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); } catch(e) { error=e; }
|
||||||
|
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain("下载编号:original-feedback");
|
||||||
|
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain(failure.recommendation);
|
||||||
|
});
|
||||||
@ -18,16 +18,18 @@ 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 Error('Unexpected network'); } })('app/api/image-proxy/route.ts');
|
const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw new 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);
|
||||||
expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE});
|
const data = await response.json();
|
||||||
|
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 Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts');
|
const module = serverLoader({ fetch: () => { fetches++; throw new 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);
|
||||||
});
|
});
|
||||||
@ -35,7 +37,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 Error('Unexpected insert');}}},
|
'@/lib/db': {db:{insert:()=>{inserts++;throw new 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();
|
||||||
|
|||||||
404
tests/routes/rmbg-delivery.test.ts
Normal file
404
tests/routes/rmbg-delivery.test.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
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" });
|
||||||
|
});
|
||||||
@ -17,3 +17,35 @@ test("release draining includes background work until its original promise settl
|
|||||||
await expect(work.trackRuntimeWork(async () => { throw new Error("expected failure"); })).rejects.toThrow("expected failure");
|
await expect(work.trackRuntimeWork(async () => { throw new Error("expected failure"); })).rejects.toThrow("expected failure");
|
||||||
expect(counts.at(-1)).toBe(0);
|
expect(counts.at(-1)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("durable archive handoff is separate from unfinished original generation", async () => {
|
||||||
|
const records: Array<{ active: number; durable: number }> = [];
|
||||||
|
const work = serverLoader({ "node:fs": { writeFileSync: (_path: string, value: string) => records.push(JSON.parse(value)), unlinkSync() {} } })("lib/observability/active-work.ts");
|
||||||
|
let complete!: () => void;
|
||||||
|
const original = work.trackRuntimeWork(() => new Promise<void>(resolve => { complete = resolve; }));
|
||||||
|
await work.trackRuntimeWork(async () => "archive", { durable: true });
|
||||||
|
expect(records.map(x => [x.active, x.durable])).toEqual([[1, 0], [2, 1], [1, 0]]);
|
||||||
|
complete(); await original;
|
||||||
|
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user