Compare commits
50 Commits
codex/imag
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa3210a367 | |||
| e335821ca8 | |||
| c2e8ff6eac | |||
| d26bec9864 | |||
| 24a3c930c2 | |||
| ebb0d1514b | |||
| 50ffc8d648 | |||
| ff6381d02e | |||
| feaa18ed3b | |||
| 2939ff8a1f | |||
| 5bffeaf94d | |||
| e221027b74 | |||
| 445d756a19 | |||
| 05c3d90708 | |||
| cea4cd01f5 | |||
| ea0351bb2c | |||
| 37406d7d6e | |||
| 0ce9a95a9c | |||
| 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 |
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,
|
||||
params: {
|
||||
phase: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
userMessageId?: string;
|
||||
providerType?: string | null;
|
||||
modelName?: string | null;
|
||||
imageCount?: number | null;
|
||||
@ -223,6 +224,7 @@ export function writeImageGenerationStatusChunk(
|
||||
"data-imageGenerationStatus",
|
||||
{
|
||||
phase: params.phase,
|
||||
userMessageId: params.userMessageId,
|
||||
providerType: normalizeImageLoadingProviderType(params.providerType),
|
||||
modelName: String(params.modelName || "").trim() || undefined,
|
||||
imageCount:
|
||||
|
||||
@ -426,6 +426,8 @@ import {
|
||||
type VideoModeType,
|
||||
} from "./_lib/video-models";
|
||||
|
||||
import { reportImageGenerationFailure } from "./_lib/image-generation-failure";
|
||||
|
||||
const chatApiLogger = createLogger("chat.route");
|
||||
const createUIMessageStream: typeof createAiUIMessageStream = (options) =>
|
||||
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)}`);
|
||||
}
|
||||
|
||||
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(
|
||||
1,
|
||||
Math.min(8, Number(process.env.AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT || 6) || 6),
|
||||
@ -5688,6 +5680,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName: resolvedModelRef,
|
||||
imageCount: replacementReferences.length,
|
||||
@ -6030,6 +6023,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -6185,6 +6179,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName: resolvedModelDisplayName,
|
||||
imageCount: successCount,
|
||||
@ -6417,6 +6412,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -6968,6 +6964,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7293,6 +7290,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7521,6 +7519,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase:
|
||||
queueStatus.status === "running"
|
||||
? "processing"
|
||||
@ -7839,6 +7838,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: successCount > 0 ? "completed" : "failed",
|
||||
modelName:
|
||||
selectedAgentPreset.defaultModel?.displayName ||
|
||||
@ -8048,6 +8048,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: finalModelId,
|
||||
@ -8086,6 +8087,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
...directImageStatusPayload,
|
||||
},
|
||||
@ -8178,6 +8180,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: requestedModelProviderType,
|
||||
modelName: directImageResult.model,
|
||||
@ -8198,42 +8201,16 @@ export async function POST(request: Request) {
|
||||
);
|
||||
} catch (directToolError) {
|
||||
chatApiLogger.error("[Chat API] Direct agent image generation failed:", directToolError);
|
||||
const errorMessage = getDirectAgentToolErrorMessage(
|
||||
"generate_image",
|
||||
directToolError,
|
||||
);
|
||||
await saveAssistantTextOnlyMessage({
|
||||
chatId: id,
|
||||
messageId: assistantMessageId,
|
||||
text: errorMessage,
|
||||
const errorMessage = await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: assistantMessageId, error: directToolError,
|
||||
label: "agent:generate_image:error",
|
||||
});
|
||||
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(
|
||||
writer,
|
||||
buildDirectAgentImageGenerateFailedProgress(errorMessage),
|
||||
"agent:generate_image:progress-failed",
|
||||
);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"agent:generate_image:end-error",
|
||||
);
|
||||
} finally {
|
||||
stopDirectImageKeepalive();
|
||||
if (stopDirectImageStatus) {
|
||||
@ -9195,6 +9172,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
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.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.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(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -9487,6 +9468,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (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...");
|
||||
|
||||
// 故障转移:尝试使用火山引擎模型
|
||||
@ -9564,6 +9553,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
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");
|
||||
} catch (failoverError) {
|
||||
chatApiLogger.error("[Chat API] Failover to Volcano Vision also failed:", failoverError);
|
||||
finalError = failoverError;
|
||||
}
|
||||
|
||||
// 如果故障转移也失败,返回错误消息
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"gemini:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"gemini:image-end-failed",
|
||||
);
|
||||
|
||||
// 尝试保存错误消息
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error: finalError, label: "gemini:error",
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -9867,6 +9824,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "queued",
|
||||
providerType: "fal-image",
|
||||
modelName: modelDisplayName,
|
||||
@ -9973,6 +9931,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: (languageModel as any).provider,
|
||||
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.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.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(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: (languageModel as any).provider,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10335,43 +10297,10 @@ export async function POST(request: Request) {
|
||||
chatApiLogger.error("[Chat API] Alibaba generation error:", error);
|
||||
|
||||
// 返回错误消息
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"alibaba:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"alibaba:image-end-failed",
|
||||
);
|
||||
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [],
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "alibaba:error",
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -10500,6 +10429,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: "volcano-vision",
|
||||
modelName: volcanoModelConfig?.displayName || actualModelId,
|
||||
@ -10545,6 +10475,7 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
chatApiLogger.error(`[Chat API] ❌ Volcano fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError);
|
||||
if (!shouldFailoverAiModelError(fallbackError)) throw fallbackError;
|
||||
}
|
||||
}
|
||||
if (!fallbackSuccess) {
|
||||
@ -10711,6 +10642,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
writer,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: "volcano-vision",
|
||||
modelName: usedModelDisplayName,
|
||||
@ -10775,44 +10707,10 @@ export async function POST(request: Request) {
|
||||
}
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] Volcano Vision generation error:", error);
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
errorText,
|
||||
"volcano:error",
|
||||
errorMessageId,
|
||||
);
|
||||
|
||||
// 🆕 图片生成结束标记(失败)
|
||||
writeAssistantTextChunk(
|
||||
writer,
|
||||
"<!-- IMAGE_GENERATION_END -->",
|
||||
"volcano:image-end-failed",
|
||||
);
|
||||
|
||||
// 尝试保存错误消息
|
||||
try {
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
chatId: id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
attachments: [], // 必须提供 attachments 字段
|
||||
createdAt: new Date(),
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
await reportImageGenerationFailure({
|
||||
writer, chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "volcano:error",
|
||||
});
|
||||
} catch (saveError) {
|
||||
chatApiLogger.error("[Chat API] Failed to save error message:", saveError);
|
||||
}
|
||||
} finally {
|
||||
stopKeepalive?.();
|
||||
}
|
||||
@ -11385,6 +11283,7 @@ export async function POST(request: Request) {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "processing",
|
||||
providerType: currentModelConfig?.providerId ? resolvedProviderType || providerType : providerType,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
@ -11476,6 +11375,7 @@ export async function POST(request: Request) {
|
||||
setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage);
|
||||
}
|
||||
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(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
userMessageId: message.id,
|
||||
phase: "completed",
|
||||
providerType: resolvedProviderType || providerType,
|
||||
modelName: usedModelDisplayName,
|
||||
@ -11706,54 +11607,10 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
chatApiLogger.error("[Chat API] OpenAI-compatible image model generation error:", error);
|
||||
|
||||
const errorMessageId = generateUUID();
|
||||
const errorText = getPublicImageGenerationErrorText(error, errorMessageId);
|
||||
|
||||
dataStream.write({
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({
|
||||
id: errorMessageId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
}),
|
||||
});
|
||||
dataStream.write({
|
||||
type: "data-appendMessage",
|
||||
data: JSON.stringify({
|
||||
id: generateUUID(),
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "<!-- IMAGE_GENERATION_END -->" }],
|
||||
}),
|
||||
});
|
||||
if (contextPayload?.source === "canvas") {
|
||||
writeImageGenerationStatusChunk(
|
||||
dataStream as unknown as StreamWriter,
|
||||
{
|
||||
phase: "failed",
|
||||
providerType: resolvedProviderType || providerType,
|
||||
modelName: currentModelConfig?.displayName || actualModelId,
|
||||
imageCount: effectiveImageParams?.imageCount || 1,
|
||||
},
|
||||
"openai-compat:image-status-failed",
|
||||
);
|
||||
}
|
||||
|
||||
await saveMessages({
|
||||
messages: [
|
||||
{
|
||||
id: errorMessageId,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: errorText }],
|
||||
createdAt: new Date(),
|
||||
attachments: [],
|
||||
chatId: id,
|
||||
videoStorageStatus: null,
|
||||
temporaryVideoUrl: null,
|
||||
permanentVideoUrl: null,
|
||||
archivedAt: null,
|
||||
videoMetadata: null,
|
||||
},
|
||||
],
|
||||
await reportImageGenerationFailure({
|
||||
writer: dataStream as unknown as StreamWriter,
|
||||
chatId: id, userMessageId: message.id,
|
||||
messageId: generateUUID(), error, label: "openai-compat:error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
8
app/api/app-version/route.ts
Normal file
8
app/api/app-version/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export function GET() {
|
||||
return NextResponse.json({ version: process.env.NEXT_PUBLIC_APP_RELEASE || "development" }, {
|
||||
headers: { "Cache-Control": "no-store, max-age=0" },
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
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 { 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 });
|
||||
}
|
||||
|
||||
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)) {
|
||||
return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 });
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
|
||||
import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command";
|
||||
import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context";
|
||||
import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry";
|
||||
@ -90,7 +91,7 @@ export async function POST(
|
||||
const failure = getRequestFailureDetails(error, "执行画布能力失败");
|
||||
return NextResponse.json(
|
||||
{ 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 {
|
||||
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 { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
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 POST(request: NextRequest) {
|
||||
try {
|
||||
export async function POST(request: Request) {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -10,11 +10,13 @@ import JSZip from "jszip";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
|
||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 300;
|
||||
type DownloadFileItem = { url: string; name?: string };
|
||||
const MAX_DOWNLOAD_FILES = 200;
|
||||
const TASK_TTL_MS = 60 * 60_000;
|
||||
const TASK_TTL_MS = 24 * 60 * 60_000;
|
||||
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
|
||||
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
|
||||
const active = running.__canvasZipRunning ??= new Set<string>();
|
||||
function sanitizeFolderName(value: string): string {
|
||||
@ -98,8 +100,12 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
||||
task = await loadZipTask(id, userId);
|
||||
if (!task || task.status === "completed" || task.status === "failed") return;
|
||||
await assertZipDiskSpace();
|
||||
task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
|
||||
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null);
|
||||
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
|
||||
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
|
||||
await saveZipTask(task);
|
||||
if (!reuseZip) {
|
||||
delete task.zipFile; delete task.contentDisposition; delete task.cdnUrl;
|
||||
// Stage each image on disk, not all PNG buffers in the web process heap.
|
||||
// A restart resumes from these files using the current owner's session.
|
||||
let cursor = 0;
|
||||
@ -137,24 +143,28 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
||||
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
|
||||
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
|
||||
task.contentDisposition = buildContentDisposition(task.folderName);
|
||||
}
|
||||
task.stage = "正在准备下载链接"; await saveZipTask(task);
|
||||
try {
|
||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
|
||||
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!,
|
||||
onProgress: async bytes => {
|
||||
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
|
||||
task!.updatedAt = Date.now(); await saveZipTask(task!);
|
||||
} });
|
||||
if (cdn) {
|
||||
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
|
||||
if (cdn.url) task.cdnUrl = cdn.url;
|
||||
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`);
|
||||
task.cdnUrl = cdn.url;
|
||||
}
|
||||
} catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); }
|
||||
task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
|
||||
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
// The complete ZIP is enough for retries; release staged duplicate bytes.
|
||||
await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined)));
|
||||
console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount,
|
||||
bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
||||
bytes: task.zipFile!.bytes, delivery: task.cdnUrl ? "cdn" : "local" });
|
||||
} catch (error) {
|
||||
if (task) {
|
||||
task.status = "failed"; task.stage = "打包未完成";
|
||||
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成";
|
||||
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
|
||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
|
||||
}
|
||||
@ -162,7 +172,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
|
||||
} finally { await release?.().catch(() => undefined); active.delete(id); }
|
||||
}
|
||||
function schedule(task: ZipTask, request: NextRequest) {
|
||||
const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
|
||||
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""));
|
||||
after(run);
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
@ -187,14 +197,17 @@ export async function POST(request: NextRequest) {
|
||||
const now = Date.now();
|
||||
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
|
||||
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
|
||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
|
||||
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS };
|
||||
await saveZipTask(task);
|
||||
} else if (task.status === "failed" && body.retry === true) {
|
||||
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) {
|
||||
task.status = "pending"; task.error = undefined;
|
||||
await assertZipTaskCapacity(session.user.id);
|
||||
if (!task.zipFile) {
|
||||
for (const file of task.files) delete file.error;
|
||||
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
|
||||
task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
|
||||
task.failedCount = 0;
|
||||
}
|
||||
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
|
||||
await saveZipTask(task);
|
||||
}
|
||||
if (task.status === "pending" || task.status === "running") schedule(task, request);
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
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 { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { buildImageProxyUrl, isAlibabaModelImageUrl } from "@/lib/image-delivery";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const logger = createLogger("image.download");
|
||||
@ -242,6 +243,7 @@ function resolveDirectImageTarget(
|
||||
requestedQuality: number | null,
|
||||
requestedFormat: string | null
|
||||
): URL | null {
|
||||
if (isAlibabaModelImageUrl(target.toString())) return null;
|
||||
if (!isBrowserImageRequest(request)) {
|
||||
return null;
|
||||
}
|
||||
@ -298,6 +300,24 @@ export async function GET(request: NextRequest) {
|
||||
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)) {
|
||||
return NextResponse.json({ error: "Blocked target host" }, { status: 403 });
|
||||
}
|
||||
@ -318,6 +338,25 @@ 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");
|
||||
@ -370,7 +409,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const fetchSignal = AbortSignal.any([
|
||||
request.signal,
|
||||
AbortSignal.timeout(original ? 60_000 : 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) => {
|
||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||
@ -381,6 +420,22 @@ export async function GET(request: NextRequest) {
|
||||
{ status: timedOut ? 504 : 502 },
|
||||
);
|
||||
};
|
||||
let contentType: string;
|
||||
let originalBuffer: Buffer;
|
||||
if (!original && isAlibabaModelImageUrl(target.toString())) {
|
||||
try {
|
||||
const { fetchImageBytes } = await import("@/lib/storage/image-byte-cache");
|
||||
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(), {
|
||||
@ -400,19 +455,17 @@ export async function GET(request: NextRequest) {
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||
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 });
|
||||
}
|
||||
|
||||
let originalBuffer: Buffer;
|
||||
try {
|
||||
originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer());
|
||||
} catch (error) {
|
||||
return upstreamFailure(error);
|
||||
}
|
||||
}
|
||||
if (original) {
|
||||
return buildImageResponse(originalBuffer, {
|
||||
contentType,
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { preprocessProductImageWithRmbg } from "@/lib/scene-fusion/preprocess-product";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
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_MAX_WORKERS = 3;
|
||||
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_THRESHOLD = 8;
|
||||
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 {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
const shouldForcePreRmbg = resolvedSceneMode === "random-bg";
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg(
|
||||
const preprocessedProduct = await preprocessProductImageWithRmbg({
|
||||
request,
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
productImageUrl,
|
||||
shouldForcePreRmbg ? true : config.preRmbgEnabled
|
||||
);
|
||||
preRmbgEnabled: shouldForcePreRmbg ? true : config.preRmbgEnabled,
|
||||
});
|
||||
const fusionProductImageUrl = preprocessedProduct.productImageUrl;
|
||||
logger.info("pre_rmbg_resolved", {
|
||||
requestId,
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
|
||||
import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft";
|
||||
import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { CanvasRemoteOperationContext, type CanvasRemoteOperation, type CanvasRemoteOperationResult } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import equal from "fast-deep-equal";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
@ -168,6 +172,7 @@ import {
|
||||
type VideoCanvasSymbolMarkerId,
|
||||
} from "@/lib/canvas/video-canvas-markers";
|
||||
import {
|
||||
isCanvasImageUploadPending,
|
||||
LOCAL_UPLOAD_PENDING_METADATA_KEY,
|
||||
LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
|
||||
} from "@/lib/canvas/local-upload-state";
|
||||
@ -1929,6 +1934,12 @@ export default function CanvasEditorLegacyPage({
|
||||
() => !initialProjectForCurrentRoute
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [canvasLoadError, setCanvasLoadError] = useState("");
|
||||
const [canvasLoadAttempt, setCanvasLoadAttempt] = useState(0);
|
||||
const canvasReadyRef = useRef(Boolean(initialProjectForCurrentRoute));
|
||||
const savedCanvasSnapshotRef = useRef<{ id: string; snapshot: string } | null>(null);
|
||||
const canvasSyncErrorRef = useRef("");
|
||||
const [canvasSyncConflicts, setCanvasSyncConflicts] = useState<string[]>([]);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
@ -3365,6 +3376,32 @@ export default function CanvasEditorLegacyPage({
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, []);
|
||||
|
||||
const loadPersistedCanvasState = useCallback((input: CanvasState | null | undefined) => {
|
||||
const canvasData = input || { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const };
|
||||
const base = JSON.stringify({ canvasData });
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: base };
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
const draft = readCanvasDraft(id);
|
||||
if (draft) {
|
||||
const merged = mergeCanvasSnapshots(draft.base, draft.snapshot, base);
|
||||
if (merged.conflicts.length) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: draft.base };
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
toast.warning(canvasSyncErrorRef.current);
|
||||
return;
|
||||
}
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
if (merged.snapshot !== base) writeCanvasDraft(id, { base, snapshot: merged.snapshot });
|
||||
else clearCanvasDraft(id);
|
||||
return;
|
||||
}
|
||||
useCanvasStore.getState().loadCanvas(canvasData);
|
||||
}, [id]);
|
||||
|
||||
const hydrateCanvasProject = useCallback(
|
||||
(project: CanvasInitialProject) => {
|
||||
externalCanvasConflictRef.current = false;
|
||||
@ -3374,9 +3411,9 @@ export default function CanvasEditorLegacyPage({
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
|
||||
loadPersistedCanvasState(project.canvasData);
|
||||
|
||||
if (project.canvasData) {
|
||||
useCanvasStore.getState().loadCanvas(project.canvasData);
|
||||
const persistedWorkflow = project.canvasData.workflowState;
|
||||
if (!requestedAgentMode && persistedWorkflow) {
|
||||
const nextAppearanceRecognitionEnabled =
|
||||
@ -3479,7 +3516,7 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
setWorkflowHydrationSource("empty");
|
||||
},
|
||||
[id, requestedAgentMode]
|
||||
[id, requestedAgentMode, loadPersistedCanvasState]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -3487,6 +3524,7 @@ export default function CanvasEditorLegacyPage({
|
||||
if (lastLoadedCanvasIdRef.current === id) return;
|
||||
|
||||
hydrateCanvasProject(initialProjectForCurrentRoute);
|
||||
canvasReadyRef.current = true;
|
||||
setIsLoading(false);
|
||||
lastLoadedCanvasIdRef.current = id;
|
||||
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
|
||||
@ -3494,6 +3532,7 @@ export default function CanvasEditorLegacyPage({
|
||||
// 加载画布数据
|
||||
useEffect(() => {
|
||||
console.log("[Canvas] Loading project:", id);
|
||||
let cancelled = false;
|
||||
|
||||
const loadProject = async () => {
|
||||
if (lastLoadedCanvasIdRef.current === id) {
|
||||
@ -3502,6 +3541,8 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
canvasReadyRef.current = false;
|
||||
setCanvasLoadError("");
|
||||
if (!requestedAgentMode) {
|
||||
setWorkflowHydrationSource("pending");
|
||||
}
|
||||
@ -3534,18 +3575,16 @@ export default function CanvasEditorLegacyPage({
|
||||
setLoadedAgentProjectId(null);
|
||||
}
|
||||
|
||||
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
|
||||
const result = await canvasService.getProject(id);
|
||||
if (cancelled) return;
|
||||
|
||||
if (result.success && result.data) {
|
||||
useCanvasStore.getState().setProject(id, result.data.title);
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||
loadPersistedCanvasState(result.data.canvasData);
|
||||
if (result.data.canvasData) {
|
||||
useCanvasStore.getState().loadCanvas(result.data.canvasData);
|
||||
const persistedWorkflow = result.data.canvasData.workflowState;
|
||||
if (!requestedAgentMode && persistedWorkflow) {
|
||||
const nextAppearanceRecognitionEnabled =
|
||||
@ -3662,8 +3701,15 @@ export default function CanvasEditorLegacyPage({
|
||||
return;
|
||||
}
|
||||
|
||||
// 画布不存在时,创建一个新画布
|
||||
if (result.status !== 404) {
|
||||
setCanvasLoadError(result.error || "画布暂时无法读取,请检查网络后重试加载。现有内容未被覆盖。");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only an explicit missing project can create a new empty canvas.
|
||||
console.log("[Canvas] 画布不存在,创建新画布:", id);
|
||||
useCanvasStore.getState().clearCanvas();
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.setProject(
|
||||
@ -3690,15 +3736,20 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled) return;
|
||||
canvasReadyRef.current = true;
|
||||
setIsLoading(false);
|
||||
lastLoadedCanvasIdRef.current = id;
|
||||
};
|
||||
|
||||
loadProject();
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activeCanvasWorkspaceType,
|
||||
defaultCanvasTitle,
|
||||
id,
|
||||
canvasLoadAttempt,
|
||||
loadPersistedCanvasState,
|
||||
openLoginDialog,
|
||||
requestedAgentMode,
|
||||
]);
|
||||
@ -5071,15 +5122,69 @@ export default function CanvasEditorLegacyPage({
|
||||
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const canvasPersistenceScope = useRef(id);
|
||||
const canvasRemoteOperationActive = useRef(false);
|
||||
const canvasLeaveApprovedRef = useRef(false);
|
||||
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
|
||||
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
|
||||
const [canvasLeaveError, setCanvasLeaveError] = useState("");
|
||||
canvasPersistenceScope.current = id;
|
||||
const hasUnsavedCanvasChanges = useCallback(() => {
|
||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || savedCanvasSnapshotRef.current?.id !== id) return false;
|
||||
if (useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) return true;
|
||||
const saved = JSON.parse(savedCanvasSnapshotRef.current.snapshot).canvasData as CanvasState;
|
||||
const current = JSON.parse(JSON.stringify(buildPersistedCanvasData())) as CanvasState;
|
||||
const content = (value: CanvasState) => [value.nodes.map(node => {
|
||||
const { updatedAt: _updatedAt, ...nodeData } = node;
|
||||
return { ...nodeData, connections: node.connections || [] };
|
||||
}), value.edges || [], value.suppressedMediaKeys || [], normalizeStyleProPersistedState(value.styleProState)];
|
||||
return !equal(content(saved), content(current));
|
||||
}, [buildPersistedCanvasData, id]);
|
||||
const requestCanvasLeave = useCallback((url: string) => {
|
||||
if (hasUnsavedCanvasChanges()) {
|
||||
setCanvasLeaveError("");
|
||||
setCanvasLeaveUrl(url);
|
||||
return;
|
||||
}
|
||||
canvasLeaveApprovedRef.current = true;
|
||||
window.location.assign(url);
|
||||
}, [hasUnsavedCanvasChanges]);
|
||||
const applyCanvasRemoteSnapshot = useCallback((result: CanvasRemoteOperationResult) => {
|
||||
if (result.projectId !== id || canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return false;
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: result.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : remoteSnapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
);
|
||||
if (merged.changed) useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
useCanvasStore.getState().setCanvasRevision(result.updatedAt, result.updatedAt);
|
||||
if (merged.conflicts.length) {
|
||||
canvasSyncErrorRef.current = "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。";
|
||||
if (!externalCanvasConflictRef.current) toast.warning(canvasSyncErrorRef.current);
|
||||
externalCanvasConflictRef.current = true;
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current?.snapshot || remoteSnapshot, snapshot: merged.snapshot });
|
||||
return false;
|
||||
}
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
canvasSyncErrorRef.current = "";
|
||||
return true;
|
||||
}, [buildPersistedCanvasData, id]);
|
||||
const persistCanvasStateNow = useCallback(
|
||||
async (reason: "autosave" | "delete" | "beforeunload") => {
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false;
|
||||
if (externalCanvasConflictRef.current) return false;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
let canvasData = buildPersistedCanvasData();
|
||||
let snapshot = JSON.stringify({ canvasData });
|
||||
if (savedCanvasSnapshotRef.current?.id === id && equal(JSON.parse(savedCanvasSnapshotRef.current.snapshot), JSON.parse(snapshot))) {
|
||||
clearCanvasDraft(id);
|
||||
return true;
|
||||
}
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
let result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType,
|
||||
@ -5089,23 +5194,53 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
);
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (!result.success) {
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
if (result.status === 409) {
|
||||
externalCanvasConflictRef.current = true;
|
||||
console.warn("[Canvas] stale save revision handled without user prompt", {
|
||||
reason,
|
||||
canvasUpdatedAt: result.canvasUpdatedAt,
|
||||
if (result.status === 409 && savedCanvasSnapshotRef.current?.id === id) {
|
||||
const remote = await canvasService.getProject(id);
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
if (remote.success && remote.data?.canvasData) {
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current.snapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
);
|
||||
if (!merged.conflicts.length) {
|
||||
canvasData = JSON.parse(merged.snapshot).canvasData;
|
||||
snapshot = merged.snapshot;
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
useCanvasStore.getState().loadCanvas(canvasData);
|
||||
const revision = String(remote.data.updatedAt);
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
result = await canvasService.saveCanvasState(id, canvasData, activeCanvasWorkspaceType, {
|
||||
baseCanvasRevision: revision, baseCanvasUpdatedAt: revision,
|
||||
});
|
||||
if (!externalCanvasConflictNotifiedRef.current) {
|
||||
externalCanvasConflictNotifiedRef.current = true;
|
||||
toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
|
||||
if (canvasPersistenceScope.current !== id) return false;
|
||||
} else {
|
||||
externalCanvasConflictRef.current = true;
|
||||
setCanvasSyncConflicts(merged.conflicts);
|
||||
}
|
||||
}
|
||||
} else if (result.data?.updatedAt) {
|
||||
}
|
||||
if (!result.success) {
|
||||
const retained = writeCanvasDraft(id, {
|
||||
base: savedCanvasSnapshotRef.current?.id === id ? savedCanvasSnapshotRef.current.snapshot : JSON.stringify({ canvasData: { nodes: [], edges: [] } }),
|
||||
snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
});
|
||||
canvasSyncErrorRef.current = result.status === 409
|
||||
? "画布同步发生冲突,改动已保留。请在画布上方选择保留本页或云端的冲突内容,再继续操作。"
|
||||
: `画布自动同步失败${result.status ? `(HTTP ${result.status})` : "(网络连接失败)"}。${retained ? "本地草稿已保留,系统会继续自动同步,也可稍后重试。" : "请保留当前页面,检查网络后重试同步,暂勿刷新。"}`;
|
||||
console.error(`[Canvas] ${reason} save failed:`, result.error);
|
||||
} else {
|
||||
canvasSyncErrorRef.current = "";
|
||||
savedCanvasSnapshotRef.current = { id, snapshot };
|
||||
clearCanvasDraft(id);
|
||||
externalCanvasConflictRef.current = false;
|
||||
setCanvasSyncConflicts([]);
|
||||
externalCanvasConflictNotifiedRef.current = false;
|
||||
useCanvasStore
|
||||
if (result.data?.canvasData && result.data?.updatedAt) {
|
||||
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) });
|
||||
}
|
||||
if (result.data?.updatedAt) useCanvasStore
|
||||
.getState()
|
||||
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
}
|
||||
@ -5114,7 +5249,7 @@ export default function CanvasEditorLegacyPage({
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[activeCanvasWorkspaceType, buildPersistedCanvasData, id]
|
||||
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id]
|
||||
);
|
||||
|
||||
const persistCanvasState = useCallback(
|
||||
@ -5123,35 +5258,139 @@ export default function CanvasEditorLegacyPage({
|
||||
[persistCanvasStateNow]
|
||||
);
|
||||
|
||||
const finishCanvasLeave = useCallback(async (save: boolean) => {
|
||||
if (!canvasLeaveUrl || isSavingBeforeLeave) return;
|
||||
setCanvasLeaveError("");
|
||||
if (save && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
||||
setCanvasLeaveError("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再保存并离开。");
|
||||
return;
|
||||
}
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
if (save) {
|
||||
setIsSavingBeforeLeave(true);
|
||||
try {
|
||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current || "画布暂时无法同步,请保留此页并稍后重试。");
|
||||
if (hasUnsavedCanvasChanges() && !(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
||||
if (hasUnsavedCanvasChanges()) throw new Error("还有改动正在同步,请稍后重试离开。");
|
||||
} catch (error) {
|
||||
setCanvasLeaveError(error instanceof Error ? error.message : "画布暂时无法同步,请保留此页并稍后重试。");
|
||||
setIsSavingBeforeLeave(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
canvasLeaveApprovedRef.current = true;
|
||||
clearCanvasDraft(id);
|
||||
window.location.assign(canvasLeaveUrl);
|
||||
}, [canvasLeaveUrl, hasUnsavedCanvasChanges, id, isSavingBeforeLeave, persistCanvasState]);
|
||||
|
||||
const resolveCanvasSyncConflict = useCallback((choice: "local" | "remote") => {
|
||||
return canvasSaveQueue.current!.enqueue(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const remote = await canvasService.getProject(id);
|
||||
if (canvasPersistenceScope.current !== id || useCanvasStore.getState().projectId !== id) return;
|
||||
if (!remote.success || !remote.data?.canvasData || savedCanvasSnapshotRef.current?.id !== id) {
|
||||
throw new Error(remote.error || "画布暂时无法读取,改动已保留,请稍后重试同步。");
|
||||
}
|
||||
const remoteSnapshot = JSON.stringify({ canvasData: remote.data.canvasData });
|
||||
const merged = mergeCanvasSnapshots(
|
||||
savedCanvasSnapshotRef.current.snapshot,
|
||||
JSON.stringify({ canvasData: buildPersistedCanvasData() }),
|
||||
remoteSnapshot,
|
||||
choice,
|
||||
);
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: remoteSnapshot };
|
||||
useCanvasStore.getState().loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
const revision = String(remote.data.updatedAt);
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
externalCanvasConflictRef.current = false;
|
||||
canvasSyncErrorRef.current = "";
|
||||
setCanvasSyncConflicts([]);
|
||||
if (!(await persistCanvasStateNow("autosave"))) throw new Error(canvasSyncErrorRef.current);
|
||||
toast.success("画布已合并同步,可以继续操作");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "画布同步失败,改动已保留,请稍后重试。");
|
||||
} finally {
|
||||
if (canvasPersistenceScope.current === id) setIsSaving(false);
|
||||
}
|
||||
});
|
||||
}, [buildPersistedCanvasData, id, persistCanvasStateNow]);
|
||||
|
||||
const runCanvasRemoteOperation = useCallback(
|
||||
(operation: () => Promise<void>) => {
|
||||
(operation: CanvasRemoteOperation) => {
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return canvasSaveQueue.current!.enqueueOperation(
|
||||
() => persistCanvasStateNow("autosave"),
|
||||
async () => {
|
||||
const saved = await persistCanvasStateNow("autosave");
|
||||
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
|
||||
return true;
|
||||
},
|
||||
async () => {
|
||||
canvasRemoteOperationActive.current = true;
|
||||
try {
|
||||
await operation();
|
||||
const result = await operation();
|
||||
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
|
||||
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
|
||||
canvasSyncErrorRef.current = "";
|
||||
}
|
||||
} finally {
|
||||
canvasRemoteOperationActive.current = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[persistCanvasStateNow]
|
||||
[persistCanvasStateNow, id]
|
||||
);
|
||||
|
||||
const persistCanvasBeforeAgent = useCallback(() => {
|
||||
const persistCanvasBeforeAgent = useCallback(async () => {
|
||||
if (!canvasReadyRef.current || useCanvasStore.getState().projectId !== id || externalCanvasConflictRef.current)
|
||||
throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成同步,本次任务未提交,请保留当前页面并重试同步。");
|
||||
if (savedCanvasSnapshotRef.current?.id === id) {
|
||||
if (!hasUnsavedCanvasChanges()) return true;
|
||||
}
|
||||
// Flush the pending local edit once instead of racing its scheduled autosave.
|
||||
if (canvasAutosaveTimer.current) {
|
||||
clearTimeout(canvasAutosaveTimer.current);
|
||||
canvasAutosaveTimer.current = null;
|
||||
}
|
||||
return persistCanvasState("autosave");
|
||||
}, [persistCanvasState]);
|
||||
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。");
|
||||
return true;
|
||||
}, [persistCanvasState, hasUnsavedCanvasChanges, id]);
|
||||
|
||||
const canvasRemoteOperationContext = useMemo(() => ({
|
||||
run: runCanvasRemoteOperation,
|
||||
applySnapshot: applyCanvasRemoteSnapshot,
|
||||
}), [runCanvasRemoteOperation, applyCanvasRemoteSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVideoMode) return;
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
if (!isAgentMode && useCanvasStore.getState().nodes.some(isCanvasImageUploadPending)) {
|
||||
request.block("还有图片未上传成功。请等待上传完成;若上传失败,请重新上传或移除该图片后再刷新。");
|
||||
return;
|
||||
}
|
||||
if (useCanvasStore.getState().isGenerating) {
|
||||
request.block("当前图片任务仍在处理中,请等待结果返回后再刷新。");
|
||||
return;
|
||||
}
|
||||
request.save(async () => {
|
||||
if (isAgentMode) {
|
||||
if (agentProjectId && hasLoadedCurrentAgentProject && !(await persistAgentProject("switch"))) throw new Error("生图项目同步失败,请保留此页并重试刷新。");
|
||||
} else if (canvasReadyRef.current && !(await persistCanvasState("autosave"))) {
|
||||
throw new Error(canvasSyncErrorRef.current || "画布尚未完成同步,请保留此页并重试刷新。");
|
||||
}
|
||||
});
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [isVideoMode, isAgentMode, agentProjectId, hasLoadedCurrentAgentProject, persistAgentProject, persistCanvasState]);
|
||||
|
||||
// 自动保存逻辑
|
||||
useEffect(() => {
|
||||
@ -5172,7 +5411,32 @@ export default function CanvasEditorLegacyPage({
|
||||
clearTimeout(saveTimer);
|
||||
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
|
||||
};
|
||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentMode) return;
|
||||
const retrySync = () => {
|
||||
if (canvasLeaveApprovedRef.current || externalCanvasConflictRef.current || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || !hasUnsavedCanvasChanges()) return;
|
||||
void persistCanvasState("autosave").catch(error => console.error("[Canvas] background sync failed:", error));
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
if (!canvasLeaveApprovedRef.current && document.visibilityState === "hidden" && hasUnsavedCanvasChanges()) {
|
||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData: buildPersistedCanvasData() }) });
|
||||
}
|
||||
};
|
||||
const retryTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible" && navigator.onLine && canvasSyncErrorRef.current) retrySync();
|
||||
}, 20_000);
|
||||
window.addEventListener("online", retrySync);
|
||||
window.addEventListener("focus", retrySync);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
window.clearInterval(retryTimer);
|
||||
window.removeEventListener("online", retrySync);
|
||||
window.removeEventListener("focus", retrySync);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [buildPersistedCanvasData, hasUnsavedCanvasChanges, id, isAgentMode, isAgentOperationRefreshActive, isLoading, persistCanvasState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentOperationRefreshActive()) return;
|
||||
@ -5196,6 +5460,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
try {
|
||||
const beforeRead = useCanvasStore.getState();
|
||||
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
@ -5216,7 +5481,11 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
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();
|
||||
hydrateCanvasProject(result.data);
|
||||
lastLocalCanvasMutationAtRef.current = Date.now();
|
||||
@ -5270,14 +5539,25 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
|
||||
|
||||
// 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
|
||||
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。
|
||||
useEffect(() => {
|
||||
if (isLoading || isAgentMode) return;
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
try {
|
||||
if (isAgentOperationRefreshActive()) return;
|
||||
const retainDraft = () => {
|
||||
if (canvasLeaveApprovedRef.current || !hasUnsavedCanvasChanges()) return null;
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
writeCanvasDraft(id, { base: savedCanvasSnapshotRef.current!.snapshot, snapshot: JSON.stringify({ canvasData }) });
|
||||
return canvasData;
|
||||
};
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!retainDraft()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "true";
|
||||
};
|
||||
const handlePageHide = () => {
|
||||
try {
|
||||
const canvasData = retainDraft();
|
||||
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return;
|
||||
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
|
||||
void fetch(`/api/canvas/${id}`, {
|
||||
method: "PUT",
|
||||
@ -5296,18 +5576,20 @@ export default function CanvasEditorLegacyPage({
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
window.addEventListener("pagehide", handleBeforeUnload);
|
||||
window.addEventListener("pagehide", handlePageHide);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
window.removeEventListener("pagehide", handleBeforeUnload);
|
||||
window.removeEventListener("pagehide", handlePageHide);
|
||||
};
|
||||
}, [
|
||||
activeCanvasWorkspaceType,
|
||||
buildPersistedCanvasData,
|
||||
hasUnsavedCanvasChanges,
|
||||
id,
|
||||
isAgentOperationRefreshActive,
|
||||
isAgentMode,
|
||||
isLoading,
|
||||
isSaving,
|
||||
]);
|
||||
|
||||
// 智能命名与缩略图逻辑
|
||||
@ -5391,21 +5673,12 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
// 保存画布 (手动触发保留作为备用,但UI上移除)
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
const canvasData = buildPersistedCanvasData();
|
||||
const result = await canvasService.saveCanvasState(
|
||||
id,
|
||||
canvasData,
|
||||
activeCanvasWorkspaceType
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (await persistCanvasState("autosave")) {
|
||||
toast.success("保存成功");
|
||||
} else {
|
||||
toast.error(result.error || "保存失败");
|
||||
toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试");
|
||||
}
|
||||
setIsSaving(false);
|
||||
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
|
||||
}, [persistCanvasState]);
|
||||
|
||||
// 更新标题
|
||||
const handleUpdateTitle = async () => {
|
||||
@ -10199,6 +10472,14 @@ export default function CanvasEditorLegacyPage({
|
||||
]
|
||||
);
|
||||
|
||||
if (canvasLoadError && !isAgentMode) {
|
||||
return <div role="alert" className="flex h-dvh flex-col items-center justify-center gap-4">
|
||||
<p>{canvasLoadError}</p>
|
||||
<Button onClick={() => setCanvasLoadAttempt(value => value + 1)}>重新加载画布</Button>
|
||||
{loginDialogElement}
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (isAgentMode) {
|
||||
if (!hasActiveAgentProject) {
|
||||
return (
|
||||
@ -10302,7 +10583,7 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
|
||||
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}>
|
||||
<TooltipProvider>
|
||||
<div
|
||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||
@ -10318,6 +10599,30 @@ export default function CanvasEditorLegacyPage({
|
||||
}
|
||||
>
|
||||
<CanvasAgentUiListener projectId={id} workspace="canvas" />
|
||||
<Dialog open={Boolean(canvasLeaveUrl)} onOpenChange={open => { if (!open && !isSavingBeforeLeave) setCanvasLeaveUrl(""); }}>
|
||||
<DialogContent onEscapeKeyDown={event => { if (isSavingBeforeLeave) event.preventDefault(); }} onPointerDownOutside={event => event.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>离开前保存画布?</DialogTitle>
|
||||
<DialogDescription>平时会自动保存。当前还有改动尚未同步到云端,可以保存后离开,或放弃这些未同步的改动。</DialogDescription>
|
||||
</DialogHeader>
|
||||
{canvasLeaveError ? <p role="alert" className="text-sm text-destructive">{canvasLeaveError}</p> : null}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button disabled={isSavingBeforeLeave} variant="ghost" onClick={() => setCanvasLeaveUrl("")}>继续编辑</Button>
|
||||
<Button disabled={isSavingBeforeLeave} variant="outline" onClick={() => void finishCanvasLeave(false)}>不保存并离开</Button>
|
||||
<Button disabled={isSavingBeforeLeave} onClick={() => void finishCanvasLeave(true)}>{isSavingBeforeLeave ? "正在保存…" : "保存并离开"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{canvasSyncConflicts.length > 0 ? (
|
||||
<div role="alert" className="absolute inset-x-3 top-28 z-[60] mx-auto flex max-w-2xl flex-wrap items-center gap-3 rounded-lg border bg-background p-4 text-sm shadow-md">
|
||||
<div className="min-w-0 flex-1 basis-64">
|
||||
<p className="font-medium">画布同步发生冲突</p>
|
||||
<p className="mt-1 text-muted-foreground">本页和云端的同一内容有不同修改。请选择保留哪边的冲突内容,其余图片和修改会合并保留。</p>
|
||||
</div>
|
||||
<Button disabled={isSaving} onClick={() => void resolveCanvasSyncConflict("local")}>保留本页修改</Button>
|
||||
<Button disabled={isSaving} variant="outline" onClick={() => void resolveCanvasSyncConflict("remote")}>保留云端修改</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@ -11027,14 +11332,12 @@ export default function CanvasEditorLegacyPage({
|
||||
|
||||
{isVideoMode ? brandEntry : (
|
||||
<CanvasSurfaceHeader
|
||||
isSaving={isSaving}
|
||||
onAddImage={handleAddImageNode}
|
||||
onHome={goHome}
|
||||
onHome={() => requestCanvasLeave("/")}
|
||||
onOpenFiles={() => {
|
||||
setIsPanelOpen(true);
|
||||
setPanelMode("fileList");
|
||||
}}
|
||||
onSave={() => void handleSave()}
|
||||
projectTitle={projectTitle}
|
||||
rightInset={panelOcclusionRight}
|
||||
/>
|
||||
@ -11139,6 +11442,7 @@ export default function CanvasEditorLegacyPage({
|
||||
createUrl={createNewCanvasUrl}
|
||||
currentId={id}
|
||||
onClose={() => setPanelMode("chat")}
|
||||
onNavigate={requestCanvasLeave}
|
||||
workspaceLabel={workspaceLabel}
|
||||
workspaceType={activeCanvasWorkspaceType}
|
||||
/>
|
||||
|
||||
@ -48,8 +48,13 @@ export default function CanvasStyleProPage() {
|
||||
const canvasData = result.data.canvasData;
|
||||
const latestState = useCanvasStore.getState();
|
||||
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);
|
||||
const revision = String(result.data.updatedAt || "");
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { Toaster } from "sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { FaviconUpdater } from "@/components/favicon-updater";
|
||||
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
|
||||
import { AppUpdateNotice } from "@/components/app-update-notice";
|
||||
import { getSiteSettings } from "@/lib/db/queries";
|
||||
|
||||
import "./globals.css";
|
||||
@ -215,6 +216,7 @@ export default function RootLayout({
|
||||
enableSystem
|
||||
>
|
||||
<Toaster position="top-center" />
|
||||
<AppUpdateNotice />
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
|
||||
@ -4879,6 +4880,14 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
return recoverReplacementRun({ messages, ...pendingGeneration });
|
||||
}, [messages, pendingGeneration]);
|
||||
const transportIsGenerating = status === "streaming" || status === "submitted";
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
if (transportIsGenerating || pendingGeneration)
|
||||
(event as CustomEvent<PageRefreshRequest>).detail.block("当前生图任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [transportIsGenerating, pendingGeneration]);
|
||||
const pendingGenerationProgressCount = useMemo(() => {
|
||||
if (!pendingGeneration) {
|
||||
return 0;
|
||||
|
||||
76
components/app-update-notice.tsx
Normal file
76
components/app-update-notice.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog";
|
||||
import { preparePageRefresh } from "@/lib/client/page-refresh";
|
||||
|
||||
export function AppUpdateNotice() {
|
||||
const [availableVersion, setAvailableVersion] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let controller: AbortController | undefined;
|
||||
let checking = false;
|
||||
const check = async () => {
|
||||
if (disposed || checking || document.visibilityState !== "visible") return;
|
||||
checking = true;
|
||||
controller = new AbortController();
|
||||
const current = controller;
|
||||
const timeout = window.setTimeout(() => current.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch("/api/app-version", { cache: "no-store", signal: current.signal });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (!disposed && !current.signal.aborted && typeof data.version === "string" && data.version)
|
||||
setAvailableVersion(data.version !== (process.env.NEXT_PUBLIC_APP_RELEASE || "development") ? data.version : "");
|
||||
} catch {
|
||||
// A failed version read leaves the current workspace available.
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
void check();
|
||||
const interval = window.setInterval(check, 60_000);
|
||||
window.addEventListener("focus", check);
|
||||
window.addEventListener("online", check);
|
||||
document.addEventListener("visibilitychange", check);
|
||||
return () => {
|
||||
disposed = true;
|
||||
controller?.abort();
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("focus", check);
|
||||
window.removeEventListener("online", check);
|
||||
document.removeEventListener("visibilitychange", check);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = async () => {
|
||||
if (refreshing) return;
|
||||
setRefreshing(true);
|
||||
setError("");
|
||||
try {
|
||||
await preparePageRefresh();
|
||||
window.location.reload();
|
||||
setRefreshing(false);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "当前内容同步失败,请保留此页并重试。");
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
return <AlertDialog open={Boolean(availableVersion)}>
|
||||
<AlertDialogContent className="z-[10001]" overlayClassName="z-[10000]" onEscapeKeyDown={event => event.preventDefault()}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>平台已更新</AlertDialogTitle>
|
||||
<AlertDialogDescription>需要刷新页面后继续使用。点击后会先同步当前画布并保留输入;正在处理的任务完成后再刷新。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
<AlertDialogFooter>
|
||||
{error ? <Button variant="outline" disabled={refreshing} onClick={() => { setAvailableVersion(""); setError(""); }}>返回处理</Button> : null}
|
||||
<Button disabled={refreshing} onClick={() => void refresh()}>{refreshing ? "正在同步内容…" : "同步并刷新"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>;
|
||||
}
|
||||
@ -2,9 +2,13 @@
|
||||
|
||||
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
|
||||
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress";
|
||||
import { useCanvasRemoteOperation, useCanvasRemoteSnapshot } from "@/components/canvas/CanvasRemoteOperationContext";
|
||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
|
||||
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
|
||||
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
|
||||
@ -67,6 +71,7 @@ import { toast } from "sonner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
|
||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||
import {
|
||||
fetchUploadWithRetry,
|
||||
@ -81,7 +86,8 @@ import { canvasService } from "@/services/canvas";
|
||||
import {
|
||||
inferOriginalImageUrl,
|
||||
} 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 {
|
||||
isImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
@ -3241,6 +3247,8 @@ export function CanvasChatPanel({
|
||||
onRequireLogin,
|
||||
onBeforeAgentRequest,
|
||||
}: CanvasChatPanelProps) {
|
||||
const runCanvasRemoteOperation = useCanvasRemoteOperation();
|
||||
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
|
||||
const isWorkspacePresentation = presentation === "workspace";
|
||||
const isVideoWorkspace = workspaceVariant === "video";
|
||||
const propCanvasToolbarConfig = useMemo(
|
||||
@ -3339,6 +3347,7 @@ export function CanvasChatPanel({
|
||||
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
|
||||
const activeMessagesRef = useRef<ChatMessage[]>([]);
|
||||
const inFlightChatIdRef = useRef<string | null>(null);
|
||||
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
|
||||
const inFlightRawChatIdRef = useRef<string | null>(null);
|
||||
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
|
||||
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
|
||||
@ -3360,15 +3369,15 @@ export function CanvasChatPanel({
|
||||
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
|
||||
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
|
||||
const suppressSkillTagClickRef = useRef(false);
|
||||
const [messageViewportKey, setMessageViewportKey] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" });
|
||||
const initialScrollChatRef = useRef<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const detailModeSwitchRef = useRef(false);
|
||||
const hiddenPromptRef = useRef<string | null>(null);
|
||||
const autoContextRef = useRef<string | null>(null);
|
||||
@ -3397,7 +3406,6 @@ export function CanvasChatPanel({
|
||||
const hasInitializedVideoWorkspaceRef = useRef(false);
|
||||
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
||||
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
|
||||
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const forceHistoryHydrationRef = useRef<string | null>(null);
|
||||
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
|
||||
const composerUserEditedAtRef = useRef(0);
|
||||
@ -3434,6 +3442,7 @@ export function CanvasChatPanel({
|
||||
const [fullscreenPreview, setFullscreenPreview] = useState<{
|
||||
url: string;
|
||||
alt?: string;
|
||||
preview: LoadedImagePreview;
|
||||
} | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<CanvasImageDownloadProgress | null>(null);
|
||||
@ -3471,16 +3480,6 @@ export function CanvasChatPanel({
|
||||
const [downloadingOriginalUrl, setDownloadingOriginalUrl] = useState("");
|
||||
const [addingClawdImageUrl, setAddingClawdImageUrl] = useState("");
|
||||
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(() => {
|
||||
return () => {
|
||||
if (composerUploadClearTimeoutRef.current) {
|
||||
@ -3650,17 +3649,20 @@ export function CanvasChatPanel({
|
||||
|
||||
const setStreamImageGenerationStateForChat = useCallback(
|
||||
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
|
||||
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
|
||||
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
|
||||
setStreamImageGenerationStateByChatId((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) => {
|
||||
if (!(chatSessionId in prev)) return prev;
|
||||
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
|
||||
const next = { ...prev };
|
||||
delete next[chatSessionId];
|
||||
return next;
|
||||
@ -5033,6 +5035,15 @@ export function CanvasChatPanel({
|
||||
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50));
|
||||
}, []);
|
||||
|
||||
const setComposerEditorRef = useCallback((editor: HTMLDivElement | null) => {
|
||||
textareaRef.current = editor as unknown as HTMLTextAreaElement;
|
||||
if (!editor) return;
|
||||
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
|
||||
const text = activeChatIdRef.current === activeChatId ? inputRef.current || draft?.text : draft?.text;
|
||||
if (text) editor.textContent = text;
|
||||
draft?.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
|
||||
}, [activeChatId, insertImageTagInEditor]);
|
||||
|
||||
const handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
|
||||
const payload = getUserMessageCopyPayload(message);
|
||||
if (!payload.text && payload.attachments.length === 0) {
|
||||
@ -6097,7 +6108,7 @@ export function CanvasChatPanel({
|
||||
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
|
||||
? "agent" : rawComposerMode;
|
||||
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
|
||||
const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
|
||||
const [imageAgentEnabled, setImageAgentEnabled] = useState(true);
|
||||
const imageAgentEnabledRef = useRef(imageAgentEnabled);
|
||||
imageAgentEnabledRef.current = imageAgentEnabled;
|
||||
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
|
||||
@ -7980,8 +7991,12 @@ export function CanvasChatPanel({
|
||||
|
||||
const store = useCanvasStore.getState();
|
||||
store.markAgentOperationRefresh();
|
||||
if (applyCanvasRemoteSnapshot) {
|
||||
if (!applyCanvasRemoteSnapshot({ projectId: targetProjectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt || "") })) return false;
|
||||
} else {
|
||||
store.loadCanvas(result.data.canvasData);
|
||||
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
|
||||
}
|
||||
if (createdNodeIds.length > 0) {
|
||||
const refreshedStore = useCanvasStore.getState();
|
||||
refreshedStore.selectNodes(createdNodeIds);
|
||||
@ -8009,7 +8024,7 @@ export function CanvasChatPanel({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
|
||||
);
|
||||
|
||||
const {
|
||||
@ -8026,6 +8041,8 @@ export function CanvasChatPanel({
|
||||
api: "/api/chat",
|
||||
fetch: fetchWithErrorHandlers,
|
||||
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
|
||||
const rawId = request.id.startsWith('canvas-')
|
||||
? request.id.replace('canvas-', '')
|
||||
@ -8138,20 +8155,27 @@ export function CanvasChatPanel({
|
||||
const payload: ImageGenerationStatusStreamPayload =
|
||||
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
|
||||
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
|
||||
if (payload.phase === "completed") {
|
||||
setStreamImageGenerationStateForChat(eventChatId, {
|
||||
phase: "finalizing",
|
||||
userMessageId: payload.userMessageId,
|
||||
providerType: payload.providerType,
|
||||
modelName: getAgentSafeModelName(payload.modelName),
|
||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
|
||||
clearStreamImageGenerationStateForChat(eventChatId);
|
||||
}, 30_000);
|
||||
return;
|
||||
}
|
||||
setStreamImageGenerationStateForChat(eventChatId, {
|
||||
phase: payload.phase || "processing",
|
||||
userMessageId: payload.userMessageId,
|
||||
requestId: payload.requestId,
|
||||
failure: payload.failure,
|
||||
providerType: payload.providerType,
|
||||
modelName: getAgentSafeModelName(payload.modelName),
|
||||
imageCount: Number(payload.imageCount || 0) || undefined,
|
||||
@ -8419,6 +8443,11 @@ export function CanvasChatPanel({
|
||||
)
|
||||
: false;
|
||||
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)
|
||||
? messageData.attachments
|
||||
: [];
|
||||
@ -8487,7 +8516,7 @@ export function CanvasChatPanel({
|
||||
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
|
||||
!shouldDeferClearForActiveMedia
|
||||
) {
|
||||
clearStreamImageGenerationStateForChat(eventChatId);
|
||||
clearStreamImageGenerationStateForChat(eventChatId, true);
|
||||
}
|
||||
|
||||
if (!isEventForActiveChat) {
|
||||
@ -8813,12 +8842,6 @@ export function CanvasChatPanel({
|
||||
hasHydratedHistoryRef.current = true;
|
||||
|
||||
setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
|
||||
|
||||
if (dbMessages.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const reloadMessagesOnce = async () => {
|
||||
@ -8931,7 +8954,7 @@ export function CanvasChatPanel({
|
||||
onRunChange: (run) => {
|
||||
// The live stream owns its message; polling only recovers disconnected replies.
|
||||
if (status === "submitted" || status === "streaming") return;
|
||||
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
|
||||
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return;
|
||||
// Recover the same assistant reply after a stream disconnect or refresh.
|
||||
setMessages(current => {
|
||||
const index = current.findIndex(message => message.id === run.assistantMessageId);
|
||||
@ -8946,6 +8969,33 @@ export function CanvasChatPanel({
|
||||
});
|
||||
const activeCanvasAgentRun = canvasAgentTask.run;
|
||||
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running";
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
if (composerUploadProgress) {
|
||||
request.block("素材仍在上传,请等待上传完成后再刷新。当前页面和输入已保留。");
|
||||
return;
|
||||
}
|
||||
if (isCanvasAgentRunning || isClawdSubmitting || isDetailGenerating || status === "streaming" || status === "submitted" || Object.values(sendGuardRef.current).some(guard => guard.inFlight)) {
|
||||
request.block("当前对话任务仍在处理中,请等待结果返回后再刷新。原任务会继续执行,无需重新提交。");
|
||||
return;
|
||||
}
|
||||
request.save(async () => {
|
||||
if ((input.trim() || attachments.length) && !writeCanvasComposerDraft(activeChatId, { text: input, attachments }))
|
||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||
});
|
||||
};
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "true";
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
if (composerUploadProgress) window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
window.removeEventListener("beforeunload", beforeUnload);
|
||||
};
|
||||
}, [activeChatId, input, attachments, composerUploadProgress, isCanvasAgentRunning, isClawdSubmitting, isDetailGenerating, status]);
|
||||
|
||||
const restorePendingClawdRequestToComposer = useCallback(
|
||||
(snapshot: PendingClawdRequestRestoreSnapshot) => {
|
||||
@ -9089,6 +9139,7 @@ export function CanvasChatPanel({
|
||||
},
|
||||
} as ChatMessage;
|
||||
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
sendMessage(launchUserMessage as any);
|
||||
setMessages((previousMessages) =>
|
||||
sortCanvasMessagesByCreatedAt([
|
||||
@ -9108,7 +9159,7 @@ export function CanvasChatPanel({
|
||||
if (editableDiv) {
|
||||
editableDiv.innerHTML = "";
|
||||
}
|
||||
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
|
||||
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]);
|
||||
|
||||
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
|
||||
const loadedHistorySignatureRef = useRef<string>("");
|
||||
@ -9128,7 +9179,6 @@ export function CanvasChatPanel({
|
||||
hiddenPromptRef.current = null;
|
||||
autoContextRef.current = null;
|
||||
pendingLaunchRef.current = null;
|
||||
setMessageViewportKey((prev) => prev + 1);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
@ -9143,11 +9193,20 @@ export function CanvasChatPanel({
|
||||
}, [activeChatId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyScrollTimerRef.current) {
|
||||
clearTimeout(historyScrollTimerRef.current);
|
||||
historyScrollTimerRef.current = null;
|
||||
}
|
||||
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId);
|
||||
if (!draft || (input && input !== draft.text)) return;
|
||||
if (!input) setInput(draft.text);
|
||||
setAttachments(draft.attachments);
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const editor = textareaRef.current as HTMLDivElement | null;
|
||||
if (!editor || getComposerPlainTextFromElement(editor).trim()) return;
|
||||
editor.textContent = draft.text;
|
||||
draft.attachments.forEach((attachment, index) => insertImageTagInEditor(attachment.url, attachment.name || "参考图片", index));
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [activeChatId, activeComposerMode, input, insertImageTagInEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyData || !rawChatId) return;
|
||||
const historyMessages = historyData.messages || [];
|
||||
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
|
||||
@ -9188,29 +9247,14 @@ export function CanvasChatPanel({
|
||||
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
|
||||
);
|
||||
});
|
||||
setMessageViewportKey((prev) => prev + 1);
|
||||
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature);
|
||||
|
||||
if (historyMessages.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||
});
|
||||
historyScrollTimerRef.current = setTimeout(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
||||
historyScrollTimerRef.current = null;
|
||||
}, 160);
|
||||
return () => {
|
||||
if (historyScrollTimerRef.current) {
|
||||
clearTimeout(historyScrollTimerRef.current);
|
||||
historyScrollTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||
if (!rawChatId || !streamState) return;
|
||||
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return;
|
||||
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
@ -9234,6 +9278,13 @@ export function CanvasChatPanel({
|
||||
const streamState = streamImageGenerationStateByChatId[activeChatId];
|
||||
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]
|
||||
.map((message) => message.role)
|
||||
.lastIndexOf("user");
|
||||
@ -9250,8 +9301,7 @@ export function CanvasChatPanel({
|
||||
.join("\n");
|
||||
return (
|
||||
extractAssistantImageUrls(textContent).length > 0 ||
|
||||
extractVideoUrls(textContent).length > 0 ||
|
||||
/失败|超时|重试|error|failed/i.test(textContent)
|
||||
extractVideoUrls(textContent).length > 0
|
||||
);
|
||||
});
|
||||
|
||||
@ -9272,6 +9322,7 @@ export function CanvasChatPanel({
|
||||
messages,
|
||||
streamImageGenerationStateByChatId,
|
||||
clearStreamImageGenerationStateForChat,
|
||||
setStreamImageGenerationStateForChat,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -9778,11 +9829,13 @@ export function CanvasChatPanel({
|
||||
});
|
||||
}, [activeChatSessionMode, messages, nodes, updateNode]);
|
||||
|
||||
// 滚动到底部
|
||||
// Open each conversation at its latest message; later updates respect manual scrolling.
|
||||
useEffect(() => {
|
||||
if (messages.length === 0 && !isClawdSubmitting) return;
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [isClawdSubmitting, messages]);
|
||||
if (initialScrollChatRef.current === activeChatId) return;
|
||||
initialScrollChatRef.current = activeChatId;
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
|
||||
|
||||
// 标记是否已经为当前画布设置过标题(避免重复调用)
|
||||
const hasSetCanvasTitle = useRef(false);
|
||||
@ -12551,6 +12604,7 @@ export function CanvasChatPanel({
|
||||
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
|
||||
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
|
||||
if (supplement && await canvasAgentTask.control("steer", supplement)) {
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
setInput("");
|
||||
const editor = textareaRef.current as HTMLDivElement | null;
|
||||
if (editor) editor.innerHTML = "";
|
||||
@ -12848,6 +12902,7 @@ export function CanvasChatPanel({
|
||||
requestChatId,
|
||||
clawdRequestRestoreSnapshot
|
||||
);
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
|
||||
setMessages((previousMessages) =>
|
||||
sortCanvasMessagesByCreatedAt([
|
||||
@ -13336,6 +13391,26 @@ export function CanvasChatPanel({
|
||||
});
|
||||
inFlightChatIdRef.current = activeChatId;
|
||||
inFlightRawChatIdRef.current = rawChatId;
|
||||
imageGenerationUserMessageIdsRef.current[activeChatId] = userMessageId;
|
||||
if (shouldUseCanvasAgentRuntime({
|
||||
composerMode: effectiveComposerMode,
|
||||
agentPresetId: effectiveAgentEntryMode === "preset" ? selectedAgentPreset?.id : undefined,
|
||||
workspaceVariant: isVideoWorkspace ? "video" : "canvas",
|
||||
contextPayload: contextPayloadRef.current || undefined,
|
||||
}) && onBeforeAgentRequest) {
|
||||
try {
|
||||
if (!(await onBeforeAgentRequest())) throw new Error("画布自动同步失败,本次任务未提交。输入内容已保留,请检查网络后再次发送。");
|
||||
} catch (error) {
|
||||
releaseSendGuard(activeChatId);
|
||||
inFlightChatIdRef.current = null;
|
||||
inFlightRawChatIdRef.current = null;
|
||||
contextPayloadRef.current = null;
|
||||
writeCanvasComposerDraft(activeChatId, { text: composerInput, attachments: effectiveAttachments });
|
||||
toast.error(error instanceof Error ? error.message : "画布自动同步失败,本次任务未提交,输入内容已保留。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearCanvasComposerDraft(activeChatId);
|
||||
clearStreamImageGenerationStateForChat(activeChatId);
|
||||
clearAgentProgressStateForChat(activeChatId);
|
||||
if (shouldShowOptimisticImageGeneration) {
|
||||
@ -13360,6 +13435,7 @@ export function CanvasChatPanel({
|
||||
|
||||
// 使用与 multimodal-input 相同的格式发送消息
|
||||
try {
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
sendMessage(optimisticUserMessage as any);
|
||||
setMessages((previousMessages) =>
|
||||
sortCanvasMessagesByCreatedAt([
|
||||
@ -13398,6 +13474,9 @@ export function CanvasChatPanel({
|
||||
}
|
||||
}, [
|
||||
isCanvasAgentRunning,
|
||||
scrollMessagesToBottom,
|
||||
onBeforeAgentRequest,
|
||||
isVideoWorkspace,
|
||||
canvasAgentTask.control,
|
||||
activeChatId,
|
||||
activeComposerMode,
|
||||
@ -13535,23 +13614,19 @@ export function CanvasChatPanel({
|
||||
|
||||
console.log('[CanvasChat] 更新画布:', rawId);
|
||||
|
||||
const updateResponse = await fetch(`/api/canvas/${rawId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
thumbnail: firstImageUrl,
|
||||
}),
|
||||
if (!runCanvasRemoteOperation) return;
|
||||
await runCanvasRemoteOperation(async () => {
|
||||
if (useCanvasStore.getState().projectId !== rawId) return;
|
||||
const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl });
|
||||
if (!result.success) throw new Error(result.error || "画布名称同步失败");
|
||||
});
|
||||
|
||||
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
|
||||
} else {
|
||||
console.error('[CanvasChat] auto-title API 请求失败:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CanvasChat] 自动命名失败:', error);
|
||||
}
|
||||
}, [chatId]);
|
||||
}, [chatId, runCanvasRemoteOperation]);
|
||||
|
||||
const addUploadedImageAttachmentToComposer = useCallback(
|
||||
(uploaded: ComposerUploadedImage) => {
|
||||
@ -15105,7 +15180,7 @@ export function CanvasChatPanel({
|
||||
!hasGenerationEnd &&
|
||||
Boolean(currentModel || activeComposerMode === "agent"));
|
||||
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 loadingProviderType =
|
||||
streamImageGenerationState?.providerType ||
|
||||
@ -15168,9 +15243,9 @@ export function CanvasChatPanel({
|
||||
phase === "queued"
|
||||
? "等待开始生成"
|
||||
: phase === "finalizing"
|
||||
? "正在润饰细节"
|
||||
? "正在保存生成结果"
|
||||
: phase === "failed"
|
||||
? "生成失败"
|
||||
? streamState.failure?.message || "生成失败"
|
||||
: "正在创建图片";
|
||||
const status = phase === "failed" ? "failed" : "generating";
|
||||
const currentNodes = useCanvasStore.getState().nodes;
|
||||
@ -15226,14 +15301,10 @@ export function CanvasChatPanel({
|
||||
canvasGenerationPlaceholder: true,
|
||||
chatId: activeChatId,
|
||||
detailProgressLabel: label,
|
||||
generationProgress:
|
||||
phase === "queued"
|
||||
? 12
|
||||
: phase === "finalizing"
|
||||
? 96
|
||||
: phase === "failed"
|
||||
? 100
|
||||
: undefined,
|
||||
generationProgress: undefined,
|
||||
generationFailure: streamState.failure,
|
||||
generationRequestId: streamState.requestId,
|
||||
generationUserMessageId: streamState.userMessageId,
|
||||
modelName: streamState.modelName || currentModel?.displayName || undefined,
|
||||
providerType: streamState.providerType || currentModel?.providerType || undefined,
|
||||
placementMode: "anchored-generation",
|
||||
@ -15327,6 +15398,7 @@ export function CanvasChatPanel({
|
||||
|
||||
if (
|
||||
message.role === "assistant" &&
|
||||
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
|
||||
isInternalCanvasImageGenerationFailureText(cleanedParts)
|
||||
) {
|
||||
return acc;
|
||||
@ -15636,7 +15708,7 @@ export function CanvasChatPanel({
|
||||
processedMessageIds.current.clear();
|
||||
hasHydratedHistoryRef.current = true;
|
||||
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
|
||||
setMessageViewportKey((previous) => previous + 1);
|
||||
void scrollMessagesToBottom({ animation: "instant" });
|
||||
}).catch((error) => {
|
||||
console.error("[CanvasChat] Failed to reopen current conversation:", error);
|
||||
toast.error("聊天记录暂时读取失败,请重试");
|
||||
@ -15650,7 +15722,7 @@ export function CanvasChatPanel({
|
||||
replaceChatSessionQuery(sessionId);
|
||||
setIsSessionMenuOpen(false);
|
||||
toast.success("已切换聊天窗口");
|
||||
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
|
||||
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]);
|
||||
|
||||
const switchToClawdHistorySession = useCallback(
|
||||
(historyItem: ClawdHistoryItem) => {
|
||||
@ -15812,8 +15884,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -16067,11 +16139,17 @@ export function CanvasChatPanel({
|
||||
{isDetailEmptyState ? (
|
||||
<div className="flex-1 min-h-0" />
|
||||
) : (
|
||||
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
key={`${activeChatId}-${messageViewportKey}`}
|
||||
ref={messagesScrollRef}
|
||||
data-testid="canvas-chat-messages"
|
||||
role="region"
|
||||
aria-label="对话消息"
|
||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: The scroll region needs PageUp/PageDown keyboard input.
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex-1 min-h-0 overflow-x-hidden",
|
||||
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
|
||||
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto",
|
||||
isWorkspacePresentation ? "p-5" : "p-3",
|
||||
isPlainCanvasEmptyState
|
||||
? "pt-0"
|
||||
@ -16080,7 +16158,7 @@ export function CanvasChatPanel({
|
||||
: "pt-3"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
<div ref={messagesContentRef} className={cn(
|
||||
"space-y-5 overflow-x-hidden pr-1",
|
||||
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
|
||||
)}>
|
||||
@ -16131,8 +16209,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== attachment.url) {
|
||||
event.currentTarget.src = attachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -16208,8 +16286,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== attachment.url) {
|
||||
event.currentTarget.src = attachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -16394,8 +16472,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== tag.iconUrl) {
|
||||
event.currentTarget.src = tag.iconUrl || "";
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(tag.iconUrl)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(tag.iconUrl);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -16695,8 +16773,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== attachment.url) {
|
||||
event.currentTarget.src = attachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(attachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(attachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -17032,15 +17110,23 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== url) {
|
||||
event.currentTarget.src = url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(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) => {
|
||||
e.preventDefault();
|
||||
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">
|
||||
@ -17275,9 +17361,22 @@ export function CanvasChatPanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
{!isMessageListAtBottom && renderableVisibleMessages.length > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="回到最新消息"
|
||||
title="回到最新消息"
|
||||
className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full shadow-sm"
|
||||
onClick={() => void scrollMessagesToBottom({ animation: "instant" })}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fullscreenPreview && createPortal(
|
||||
@ -17286,7 +17385,7 @@ export function CanvasChatPanel({
|
||||
onClick={() => setFullscreenPreview(null)}
|
||||
>
|
||||
<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)}
|
||||
aria-label="关闭预览"
|
||||
>
|
||||
@ -17295,7 +17394,7 @@ export function CanvasChatPanel({
|
||||
</svg>
|
||||
</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}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@ -17312,13 +17411,11 @@ export function CanvasChatPanel({
|
||||
{isDownloadingOriginalImage ? "下载中" : "下载原图"}
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={fullscreenPreviewSrc}
|
||||
<ProgressiveImagePreview
|
||||
src={inferOriginalImageUrl(fullscreenPreview.url) || fullscreenPreview.url}
|
||||
preview={fullscreenPreview.preview}
|
||||
alt={fullscreenPreview.alt || "图片预览"}
|
||||
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">
|
||||
@ -17885,8 +17982,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== slotAttachment.url) {
|
||||
event.currentTarget.src = slotAttachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -18210,8 +18307,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== activeClawdSkillTag.iconUrl) {
|
||||
event.currentTarget.src = activeClawdSkillTag.iconUrl || "";
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(activeClawdSkillTag.iconUrl)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(activeClawdSkillTag.iconUrl);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -18470,8 +18567,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== item.url) {
|
||||
event.currentTarget.src = item.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(item.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(item.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -18526,7 +18623,7 @@ export function CanvasChatPanel({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={textareaRef as any}
|
||||
ref={setComposerEditorRef}
|
||||
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
|
||||
role="textbox"
|
||||
tabIndex={isSubmitting ? -1 : 0}
|
||||
@ -18560,6 +18657,7 @@ export function CanvasChatPanel({
|
||||
setTagPreview(null);
|
||||
}}
|
||||
onInput={(e) => {
|
||||
clearCanvasComposerDraft(activeChatId);
|
||||
// 从 contenteditable 中提取纯文本(排除附件标签文本)
|
||||
const target = e.target as HTMLDivElement;
|
||||
const clone = target.cloneNode(true) as HTMLDivElement;
|
||||
@ -18655,8 +18753,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== slotAttachment.url) {
|
||||
event.currentTarget.src = slotAttachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -18723,8 +18821,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== slotAttachment.url) {
|
||||
event.currentTarget.src = slotAttachment.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(slotAttachment.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(slotAttachment.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -19423,8 +19521,8 @@ export function CanvasChatPanel({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== tagPreview.url) {
|
||||
event.currentTarget.src = tagPreview.url;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(tagPreview.url)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(tagPreview.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -19,6 +19,7 @@ const PROJECT_LIST_PAGE_SIZE = 24;
|
||||
interface CanvasFileListPanelProps {
|
||||
currentId: string;
|
||||
onClose: () => void;
|
||||
onNavigate?: (url: string) => void;
|
||||
workspaceType?: CanvasWorkspaceType;
|
||||
workspaceLabel?: string;
|
||||
createUrl?: string;
|
||||
@ -28,6 +29,7 @@ interface CanvasFileListPanelProps {
|
||||
export function CanvasFileListPanel({
|
||||
currentId,
|
||||
onClose,
|
||||
onNavigate,
|
||||
workspaceType = "canvas",
|
||||
workspaceLabel = "画布",
|
||||
createUrl = "/canvas?new=1",
|
||||
@ -88,6 +90,10 @@ export function CanvasFileListPanel({
|
||||
|
||||
// 创建新画布
|
||||
const handleCreateNew = async () => {
|
||||
if (onNavigate) {
|
||||
onNavigate(createUrl);
|
||||
return;
|
||||
}
|
||||
router.push(createUrl);
|
||||
};
|
||||
|
||||
@ -100,6 +106,10 @@ export function CanvasFileListPanel({
|
||||
}
|
||||
const targetUrl = buildProjectUrl(id);
|
||||
onClose(); // 关闭文件列表面板
|
||||
if (onNavigate) {
|
||||
onNavigate(targetUrl);
|
||||
return;
|
||||
}
|
||||
// 强制整页跳转,避免仅更新 URL 不刷新
|
||||
setTimeout(() => {
|
||||
window.location.assign(targetUrl);
|
||||
|
||||
@ -1,11 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { CanvasState } from "@/types/canvas";
|
||||
|
||||
export type CanvasRemoteOperationResult = {
|
||||
projectId: string;
|
||||
canvasData: CanvasState;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type CanvasRemoteOperation = (() => Promise<void>) | (() => Promise<CanvasRemoteOperationResult | undefined>);
|
||||
|
||||
export const CanvasRemoteOperationContext = createContext<
|
||||
((operation: () => Promise<void>) => Promise<void>) | null
|
||||
{
|
||||
run: (operation: CanvasRemoteOperation) => Promise<void>;
|
||||
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
export function useCanvasRemoteOperation() {
|
||||
return useContext(CanvasRemoteOperationContext);
|
||||
return useContext(CanvasRemoteOperationContext)?.run || null;
|
||||
}
|
||||
|
||||
export function useCanvasRemoteSnapshot() {
|
||||
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
|
||||
}
|
||||
|
||||
@ -2,14 +2,10 @@
|
||||
|
||||
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
||||
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
type CanvasSurfaceHeaderProps = {
|
||||
isSaving: boolean;
|
||||
onAddImage: () => void;
|
||||
onHome: () => void;
|
||||
onOpenFiles: () => void;
|
||||
onSave: () => void;
|
||||
projectTitle: string;
|
||||
rightInset: number;
|
||||
};
|
||||
@ -18,11 +14,9 @@ const iconButtonClass =
|
||||
"inline-flex h-[28px] w-[28px] items-center justify-center rounded-[8px] p-0 text-[#858585] transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 dark:hover:bg-white/10";
|
||||
|
||||
export function CanvasSurfaceHeader({
|
||||
isSaving,
|
||||
onAddImage,
|
||||
onHome,
|
||||
onOpenFiles,
|
||||
onSave,
|
||||
projectTitle,
|
||||
rightInset,
|
||||
}: CanvasSurfaceHeaderProps) {
|
||||
@ -77,19 +71,6 @@ export function CanvasSurfaceHeader({
|
||||
<ReferenceIcon name="plus" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[8px] border border-[#e5e5e5] px-3 text-[12px] text-[#333333] transition-colors hover:bg-black/5 disabled:opacity-50 dark:border-white/10 dark:text-white"
|
||||
disabled={isSaving}
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -152,16 +152,7 @@ export function shouldRetryCanvasViewOperationAfterRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
export type ImageGenerationStatusStreamPayload = {
|
||||
phase?: "queued" | "processing" | "finalizing" | "failed" | "completed";
|
||||
providerType?: string;
|
||||
modelName?: string;
|
||||
imageCount?: number;
|
||||
queuePosition?: number;
|
||||
sameModelRunningCount?: number;
|
||||
concurrencyLimit?: number;
|
||||
estimatedWaitMs?: number;
|
||||
};
|
||||
export type { ImageGenerationStatusStreamPayload } from "@/lib/canvas/image-generation-status";
|
||||
|
||||
export type AgentTargetLockRefPreview = {
|
||||
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) {
|
||||
const label = String(match[1] || "");
|
||||
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\b[^>]*\/?\s*>/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(
|
||||
String(label || "")
|
||||
);
|
||||
|
||||
@ -6,6 +6,8 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { readCanvasComposerDraft, writeCanvasComposerDraft, clearCanvasComposerDraft } from "@/lib/client/canvas-draft";
|
||||
import {
|
||||
videoAgentSettingsSchema,
|
||||
type VideoAgentSettings,
|
||||
@ -94,6 +96,22 @@ export function VideoCanvasAgentPanel(props: {
|
||||
};
|
||||
}, [props.projectId, setMessages]);
|
||||
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming";
|
||||
useEffect(() => {
|
||||
const draft = readCanvasComposerDraft(`video-agent-${props.projectId}`);
|
||||
if (draft) setInput(draft.text);
|
||||
}, [props.projectId]);
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
const request = (event as CustomEvent<PageRefreshRequest>).detail;
|
||||
if (busy) request.block("当前视频任务仍在处理中,请等待结果返回后再刷新。原任务无需重新提交。");
|
||||
request.save(async () => {
|
||||
if (input.trim() && !writeCanvasComposerDraft(`video-agent-${props.projectId}`, { text: input, attachments: [] }))
|
||||
throw new Error("浏览器无法保存当前输入,请保留此页并检查浏览器存储空间后重试。");
|
||||
});
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [busy, input, props.projectId]);
|
||||
const submit = useCallback(
|
||||
async (resumeRunId?: string) => {
|
||||
if (busy || loading) return;
|
||||
@ -105,6 +123,7 @@ export function VideoCanvasAgentPanel(props: {
|
||||
try {
|
||||
await latest.current.props.beforeSend();
|
||||
if (!mounted.current) return;
|
||||
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
|
||||
resumeRef.current = resumeRunId;
|
||||
setInput("");
|
||||
await sendMessage({ text });
|
||||
@ -298,7 +317,7 @@ export function VideoCanvasAgentPanel(props: {
|
||||
<textarea
|
||||
aria-label="视频创作要求"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
"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 {
|
||||
@ -60,6 +62,7 @@ import { DetailUiDomRenderer } from "@/components/canvas/DetailUiDomRenderer";
|
||||
import { ReferenceImageToolbar } from "@/components/canvas/ReferenceImageToolbar";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import { ProgressiveImagePreview, type LoadedImagePreview } from "@/components/progressive-image-preview";
|
||||
import {
|
||||
areImageAdjustmentsEqual,
|
||||
buildImageAdjustmentFilterStyle,
|
||||
@ -186,7 +189,7 @@ import {
|
||||
normalizeDetailUiSubjectAnalysis,
|
||||
} from "@/lib/detail-ui-subject-analysis";
|
||||
import type { DetailUiScreenOverlaySnapshot } from "@/lib/detail-ui-surface-adapter";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { buildImageFallbackUrl, buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import {
|
||||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG,
|
||||
getDefaultThreeDModelAliasId,
|
||||
@ -1711,6 +1714,7 @@ export function ImageNode({
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [annotatorOpen, setAnnotatorOpen] = useState(false);
|
||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||
const [fullscreenPreviewImage, setFullscreenPreviewImage] = useState<LoadedImagePreview | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<CanvasImageDownloadProgress | null>(null);
|
||||
const downloadAbortControllerRef = useRef<AbortController | null>(null);
|
||||
@ -2500,13 +2504,13 @@ export function ImageNode({
|
||||
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
|
||||
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
|
||||
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
|
||||
return Math.max(
|
||||
return canvasImagePreviewWidth(Math.max(
|
||||
minWidth,
|
||||
Math.min(
|
||||
maxWidth,
|
||||
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
|
||||
)
|
||||
);
|
||||
), maxWidth);
|
||||
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
|
||||
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
|
||||
const displayImageSrc = useMemo(
|
||||
@ -4681,106 +4685,44 @@ export function ImageNode({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingDetachedResultNode(true);
|
||||
if (!projectId || !runCanvasRemoteOperation) {
|
||||
toast.error("画布还在加载,请稍后再试");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingDetachedResultNode(false);
|
||||
setIsProcessing(true);
|
||||
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 {
|
||||
const result = await imageGenerationService.boxCutout({
|
||||
imageUrl: data.src,
|
||||
cropRect: boxCutoutSelection,
|
||||
const command = createCanvasCapabilityClientCommand({
|
||||
capabilityId: "image.box_cutout",
|
||||
nodeIds: [node.id],
|
||||
projectId,
|
||||
source: "toolbar",
|
||||
args: {
|
||||
...boxCutoutSelection,
|
||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||
sourceNodeId: node.id,
|
||||
});
|
||||
|
||||
if (result.success && result.data?.url) {
|
||||
updateNode(newNodeId, {
|
||||
data: {
|
||||
src: result.data.url,
|
||||
status: "completed",
|
||||
prompt: `${actionName}结果`,
|
||||
title: `${displayTitle}-${actionName}`,
|
||||
originalSrc: data.src,
|
||||
metadata: {
|
||||
cropRect: boxCutoutSelection,
|
||||
cropRectApplied: result.data.cropRectApplied,
|
||||
paddedCropRectApplied: result.data.paddedCropRectApplied,
|
||||
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
|
||||
editMode: "box-cutout",
|
||||
},
|
||||
},
|
||||
});
|
||||
await runCanvasRemoteOperation(() => executeCanvasToolbarCapability(command));
|
||||
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}失败,请重试`);
|
||||
toast.error(error instanceof Error ? error.message : `${actionName}失败`);
|
||||
} finally {
|
||||
setIsCreatingDetachedResultNode(false);
|
||||
setIsProcessing(false);
|
||||
resetBoxCutoutState();
|
||||
}
|
||||
}, [
|
||||
addNode,
|
||||
boxCutoutSelection,
|
||||
data.src,
|
||||
displayTitle,
|
||||
isProcessing,
|
||||
node.id,
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.height,
|
||||
node.size.width,
|
||||
projectId,
|
||||
resetBoxCutoutState,
|
||||
updateNode,
|
||||
runCanvasRemoteOperation,
|
||||
]);
|
||||
|
||||
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" />
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
alt={displayTitle}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== data.src) {
|
||||
event.currentTarget.src = data.src;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||
}
|
||||
}}
|
||||
onLoad={handleDisplayImageLoad}
|
||||
@ -8488,15 +8431,16 @@ export function ImageNode({
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={compareSource}
|
||||
alt={`${displayTitle}-原图`}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== compareSource) {
|
||||
event.currentTarget.src = compareSource;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(compareSource);
|
||||
}
|
||||
}}
|
||||
src={displayCompareSrc}
|
||||
@ -8538,7 +8482,8 @@ export function ImageNode({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<img
|
||||
<CanvasPreviewImage
|
||||
sourceKey={data.src}
|
||||
alt={displayTitle}
|
||||
className={cn(
|
||||
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
|
||||
@ -8550,7 +8495,7 @@ export function ImageNode({
|
||||
)}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
onClick={
|
||||
isThisNodePicking && !boxCutoutMode
|
||||
? handlePickerClick
|
||||
@ -8559,12 +8504,17 @@ export function ImageNode({
|
||||
onDoubleClick={(e) => {
|
||||
if (!isThisNodePicking && !boxCutoutMode && !localEditMode) {
|
||||
e.stopPropagation();
|
||||
setFullscreenPreviewImage({
|
||||
src: e.currentTarget.currentSrc || e.currentTarget.src,
|
||||
width: e.currentTarget.naturalWidth,
|
||||
height: e.currentTarget.naturalHeight,
|
||||
});
|
||||
setFullscreenPreviewOpen(true);
|
||||
}
|
||||
}}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== data.src) {
|
||||
event.currentTarget.src = data.src;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(data.src);
|
||||
}
|
||||
}}
|
||||
onLoad={handleDisplayImageLoad}
|
||||
@ -9475,6 +9425,8 @@ export function ImageNode({
|
||||
) : (
|
||||
<ImageNodeEmptyState
|
||||
detailProgressLabel={detailProgressLabel}
|
||||
failure={data.metadata?.generationFailure}
|
||||
requestId={data.metadata?.generationRequestId}
|
||||
fileInputRef={fileInputRef}
|
||||
generationProgress={generationProgress}
|
||||
onImageUpload={handleImageUpload}
|
||||
@ -11964,6 +11916,7 @@ export function ImageNode({
|
||||
{/* 双击放大预览弹窗 */}
|
||||
{fullscreenPreviewOpen &&
|
||||
data.src &&
|
||||
fullscreenPreviewImage &&
|
||||
createPortal(
|
||||
<div
|
||||
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
|
||||
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)}
|
||||
>
|
||||
<svg
|
||||
@ -11989,7 +11942,7 @@ export function ImageNode({
|
||||
</svg>
|
||||
</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}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
@ -12008,13 +11961,11 @@ export function ImageNode({
|
||||
</button>
|
||||
|
||||
{/* 图片 */}
|
||||
<img
|
||||
<ProgressiveImagePreview
|
||||
alt={data.prompt || "图片预览"}
|
||||
className="max-h-[95vh] max-w-[95vw] object-contain"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
src={fullscreenPreviewSrc}
|
||||
src={resolveOriginalImageUrl(data.src) || data.src}
|
||||
preview={fullscreenPreviewImage}
|
||||
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 type React from "react";
|
||||
import { GenerationDotPlaceholder } from "@/components/generation-dot-placeholder";
|
||||
import type { RequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
interface ImageNodeEmptyStateProps {
|
||||
detailProgressLabel?: string;
|
||||
fileInputRef: React.Ref<HTMLInputElement>;
|
||||
generationProgress?: number | null;
|
||||
failure?: RequestFailureDetails;
|
||||
requestId?: string;
|
||||
onImageUpload: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onSelectFile: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
status?: string;
|
||||
@ -52,6 +55,8 @@ export function ImageNodeEmptyState({
|
||||
detailProgressLabel,
|
||||
fileInputRef,
|
||||
generationProgress,
|
||||
failure,
|
||||
requestId,
|
||||
onImageUpload,
|
||||
onSelectFile,
|
||||
status,
|
||||
@ -89,7 +94,7 @@ export function ImageNodeEmptyState({
|
||||
if (status === "failed") {
|
||||
const failedTitle = detailProgressLabel || "生成失败";
|
||||
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)]">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
</span>
|
||||
@ -97,8 +102,9 @@ export function ImageNodeEmptyState({
|
||||
{failedTitle}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-[#b77480]">
|
||||
{failedTitle.includes("上传") ? "请重新选择文件" : "请重新上传或再试一次"}
|
||||
{failure?.recommendation || (failedTitle.includes("上传") ? "请重新选择文件" : "请查看对话中的错误原因和处理建议")}
|
||||
</span>
|
||||
{requestId ? <span className="mt-2 select-text break-all text-xs text-[#b77480]">请求编号:{requestId}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
splitOpenClawChartBlocks,
|
||||
} from "@/components/elements/openclaw-chart-card";
|
||||
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 { stableStreamingMarkdown } from "@/lib/canvas/markdown-image-streaming";
|
||||
import { getGatewayErrorPageMessage } from "@/lib/request-error";
|
||||
@ -442,8 +442,8 @@ function MarkdownTableImagePreview({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== src) {
|
||||
event.currentTarget.src = src;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(src)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(src);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@ -473,8 +473,8 @@ function MarkdownTableImagePreview({
|
||||
decoding="async"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== originalSrc) {
|
||||
event.currentTarget.src = originalSrc;
|
||||
if (event.currentTarget.src !== buildImageFallbackUrl(originalSrc)) {
|
||||
event.currentTarget.src = buildImageFallbackUrl(originalSrc);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||
import type { StateId } from "@/lib/bloub-official/states";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -26,193 +25,17 @@ interface GenerationDotPlaceholderProps {
|
||||
const PHASE_TITLES: Record<GenerationPhase, string> = {
|
||||
idle: "正在创建图片",
|
||||
queued: "等待开始生成",
|
||||
processing: "生成初稿中",
|
||||
finalizing: "正在润饰细节",
|
||||
processing: "等待模型返回图片",
|
||||
finalizing: "正在保存生成结果",
|
||||
failed: "生成失败",
|
||||
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) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
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 {
|
||||
if (phase === "failed") return "alert";
|
||||
if (phase === "completed") return "wink";
|
||||
@ -228,39 +51,10 @@ export function GenerationDotPlaceholder({
|
||||
showProgress = true,
|
||||
title,
|
||||
}: GenerationDotPlaceholderProps) {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 90);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [phase]);
|
||||
|
||||
const resolvedProgress = clampProgress(
|
||||
typeof progress === "number" ? progress : estimateProgress(phase, elapsedMs)
|
||||
);
|
||||
const isLongWaitTitle =
|
||||
(phase === "processing" || phase === "finalizing") &&
|
||||
elapsedMs >= LONG_WAIT_TITLE_THRESHOLD_MS;
|
||||
const statusTitleState = isLongWaitTitle
|
||||
? getLongWaitTitleState(elapsedMs, title)
|
||||
: getStatusTitleState(elapsedMs, title);
|
||||
const rotatingTitle = getTypewriterText(
|
||||
statusTitleState.title,
|
||||
statusTitleState.elapsedInTitle,
|
||||
statusTitleState.index
|
||||
);
|
||||
const resolvedTitle =
|
||||
phase === "processing" || phase === "finalizing"
|
||||
? rotatingTitle
|
||||
: title || PHASE_TITLES[phase];
|
||||
const resolvedProgress = typeof progress === "number" && Number.isFinite(progress)
|
||||
? clampProgress(progress) : null;
|
||||
const resolvedTitle = title || PHASE_TITLES[phase];
|
||||
const playSequence = phase === "processing" || phase === "finalizing";
|
||||
const progressStyle = useMemo(
|
||||
() => ({ width: `${resolvedProgress}%` }),
|
||||
[resolvedProgress]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -281,10 +75,6 @@ export function GenerationDotPlaceholder({
|
||||
)}
|
||||
>
|
||||
{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 className="flex min-h-0 flex-1 items-center justify-center py-3">
|
||||
@ -297,13 +87,13 @@ export function GenerationDotPlaceholder({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showProgress ? (
|
||||
{showProgress && phase !== "completed" ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-[12px] font-medium text-[#7d838b] dark:text-zinc-400">
|
||||
<span>{phase === "failed" ? "已停止" : "生成进度"}</span>
|
||||
<span>{resolvedProgress}%</span>
|
||||
<span>{phase === "failed" ? "已停止" : resolvedProgress === null ? "等待结果,返回后自动展示" : "生成进度"}</span>
|
||||
{resolvedProgress !== null ? <span>{resolvedProgress}%</span> : null}
|
||||
</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
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width] duration-700 ease-out",
|
||||
@ -311,25 +101,12 @@ export function GenerationDotPlaceholder({
|
||||
? "bg-[#c7ccd3] dark:bg-zinc-600"
|
||||
: "bg-[#0a0a0c] dark:bg-[#3ecf8e]"
|
||||
)}
|
||||
style={progressStyle}
|
||||
style={{ width: `${resolvedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes generationCursorBlink {
|
||||
0%,
|
||||
48% {
|
||||
opacity: 1;
|
||||
}
|
||||
49%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -918,9 +918,9 @@ export function ImageAnnotator({
|
||||
|
||||
setIsRemovingBg(true);
|
||||
try {
|
||||
// 使用原图URL获取原图数据,而不是canvas的预览图
|
||||
const base64Data = await imageUrlToBase64(originalImageUrl);
|
||||
logger.debug("rmbg_source_prepared", { base64Length: base64Data.length });
|
||||
const input = /^https?:\/\//i.test(originalImageUrl)
|
||||
? { imageUrl: originalImageUrl }
|
||||
: { image_base64: await imageUrlToBase64(originalImageUrl) };
|
||||
|
||||
// 调用抠图API
|
||||
const response = await fetch("/api/rmbg", {
|
||||
@ -929,7 +929,7 @@ export function ImageAnnotator({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_base64: base64Data,
|
||||
...input,
|
||||
prompt: "画图标注-抠图处理", // 可选:添加提示词
|
||||
}),
|
||||
});
|
||||
|
||||
@ -41,45 +41,11 @@ const PHASE_HINT: Record<NonNullable<ImageGenerationLoadingProps["phase"]>, stri
|
||||
idle: "任务已创建,正在初始化生成参数。",
|
||||
queued: "任务已进入队列,请稍候。",
|
||||
processing: "模型正在生成图片,请稍候。",
|
||||
finalizing: "图片已生成完成,正在同步到对话。",
|
||||
failed: "生成失败,请重试。",
|
||||
finalizing: "图片已返回,正在保存并同步到对话。",
|
||||
failed: "本次处理已停止,请查看对话中的原因和建议。",
|
||||
};
|
||||
|
||||
const LONG_PROCESSING_HINTS = [
|
||||
"正在读取参考图与文字需求。",
|
||||
"正在分析主体、场景和构图关系。",
|
||||
"正在提取画面中的关键视觉特征。",
|
||||
"正在确认图片比例和输出尺寸。",
|
||||
"正在匹配当前模型的最佳生成参数。",
|
||||
"正在把参考图信息提交给模型。",
|
||||
"正在等待模型完成第一阶段推理。",
|
||||
"正在生成初始画面结构。",
|
||||
"正在保持主体特征和参考一致。",
|
||||
"正在补全画面中的空间层次。",
|
||||
"正在调整光影方向和明暗关系。",
|
||||
"正在优化主体边缘与背景融合。",
|
||||
"正在细化材质、纹理和局部细节。",
|
||||
"正在减少画面噪点和不自然区域。",
|
||||
"正在提升清晰度与整体质感。",
|
||||
"正在检查生成结果的完整性。",
|
||||
"正在处理高分辨率输出。",
|
||||
"正在等待服务商返回最终图片。",
|
||||
"正在保存图片并同步到对话。",
|
||||
"正在准备把结果显示到画布。",
|
||||
];
|
||||
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) {
|
||||
const numeric = Number(estimatedWaitMs || 0);
|
||||
@ -138,16 +104,8 @@ export const ImageGenerationLoading = memo(
|
||||
const isLongWait =
|
||||
resolvedElapsedSeconds >= LONG_WAIT_SECONDS &&
|
||||
(phase === "processing" || phase === "finalizing");
|
||||
const estimateRangeLabel = isLongWait
|
||||
? "单次最长约10分钟"
|
||||
: providerType === "grsai-gpt-image"
|
||||
? "2~5分钟"
|
||||
: "60~120秒";
|
||||
const elapsedLabel =
|
||||
resolvedElapsedSeconds > 0
|
||||
? `已等待 ${resolvedElapsedSeconds}s`
|
||||
: `预计 ${estimateRangeLabel}`;
|
||||
const phaseHint = getLongProcessingHint(phase, resolvedElapsedSeconds);
|
||||
const elapsedLabel = resolvedElapsedSeconds > 0 ? `已等待 ${resolvedElapsedSeconds}s` : "";
|
||||
const phaseHint = PHASE_HINT[phase];
|
||||
const queueLine =
|
||||
phase === "queued" && Number.isFinite(queuePosition)
|
||||
? Math.max(0, Number(queuePosition || 0)) > 0
|
||||
@ -189,12 +147,12 @@ export const ImageGenerationLoading = memo(
|
||||
}
|
||||
compact={count > 1}
|
||||
phase={phase}
|
||||
title={isLongWait ? "服务繁忙,排队处理中" : undefined}
|
||||
title={isLongWait && phase === "processing" ? "模型尚未返回结果,请勿重复提交" : undefined}
|
||||
/>
|
||||
|
||||
<div className="space-y-0.5 text-[#9aa3b2]">
|
||||
<div className="text-[13px] font-medium">
|
||||
使用 {resolvedModelName} 生成图片,预计 {estimateRangeLabel}
|
||||
使用 {resolvedModelName} 生成图片
|
||||
</div>
|
||||
{queueLine ? <div className="text-[13px]">{queueLine}</div> : null}
|
||||
{queueDetailLine ? <div className="text-[13px]">{queueDetailLine}</div> : null}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
"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 React, { useState, useEffect } from "react";
|
||||
@ -313,7 +313,7 @@ export function ImagePreview({
|
||||
event.currentTarget.alt = "Generated Image";
|
||||
}}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== (thumbnail || original)) {
|
||||
if (!normalizeLegacyAliyunStorageUrl(thumbnail || original) && event.currentTarget.src !== (thumbnail || original)) {
|
||||
event.currentTarget.src = thumbnail || original;
|
||||
} else if (!compactGrid) {
|
||||
event.currentTarget.style.aspectRatio = "1";
|
||||
@ -610,7 +610,7 @@ export function ImagePreview({
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={original}
|
||||
src={normalizeLegacyAliyunStorageUrl(original) ? buildImageProxyUrl(original) : original}
|
||||
alt="Generated Image - Original"
|
||||
className={`max-h-[90vh] max-w-full rounded-lg object-contain ${
|
||||
isAnnotationMode ? "cursor-crosshair" : ""
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string }
|
||||
>(({ className, overlayClassName, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogOverlay className={overlayClassName} />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@ -92,8 +92,16 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
|
||||
|
||||
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
|
||||
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
|
||||
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
|
||||
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
|
||||
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
|
||||
|
||||
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
|
||||
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
|
||||
|
||||
## ZIP 下载交付
|
||||
|
||||
默认存储为七牛时,完整 ZIP 通过分片上传至七牛,确认下载地址可用后才提供下载。
|
||||
大文件使用三个并发分片,上传进度显示在原打包任务中。传送或链接检查失败会返回具体阶段、
|
||||
下载编号及重试建议,保留已生成的 ZIP;再次点击下载复用原文件,已上传的完整对象不重复上传。
|
||||
后台 ZIP 任务参与发布期间的工作等待。完成或失败的任务及文件保留 24 小时,待处理任务保留 6 小时。
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { BEFORE_PAGE_REFRESH, type PageRefreshRequest } from "@/lib/client/page-refresh";
|
||||
import { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
|
||||
import {
|
||||
CanvasRevisionConflict,
|
||||
@ -149,6 +150,16 @@ export function useVideoCanvasPersistence(
|
||||
return !queue.dirty && !queue.failed;
|
||||
}, [ready]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeRefresh = (event: Event) => {
|
||||
(event as CustomEvent<PageRefreshRequest>).detail.save(async () => {
|
||||
if (!(await saveAndConfirm())) throw new Error("视频画布尚未同步完成,本地草稿已保留,请重试刷新。");
|
||||
});
|
||||
};
|
||||
window.addEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
return () => window.removeEventListener(BEFORE_PAGE_REFRESH, beforeRefresh);
|
||||
}, [saveAndConfirm]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
|
||||
@ -4,6 +4,9 @@ import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
|
||||
const logger = createLogger("box-cutout-service");
|
||||
|
||||
export type NormalizedCropRect = {
|
||||
x: number;
|
||||
@ -13,6 +16,7 @@ export type NormalizedCropRect = {
|
||||
};
|
||||
|
||||
export interface ExecuteBoxCutoutJobParams {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
imageUrl: string;
|
||||
cropRect: Partial<NormalizedCropRect>;
|
||||
@ -143,6 +147,7 @@ function applyPadding(
|
||||
export async function executeBoxCutoutJob(
|
||||
params: ExecuteBoxCutoutJobParams,
|
||||
): Promise<ExecuteBoxCutoutJobResult> {
|
||||
const requestId = params.requestId || crypto.randomUUID();
|
||||
const imageUrl = String(params.imageUrl || "").trim();
|
||||
if (!imageUrl) {
|
||||
throw new BoxCutoutServiceError("缺少 imageUrl 参数", 400);
|
||||
@ -150,7 +155,7 @@ export async function executeBoxCutoutJob(
|
||||
|
||||
const cropRect = sanitizeNormalizedCropRect(params.cropRect);
|
||||
if (!cropRect) {
|
||||
throw new BoxCutoutServiceError("缺少有效的 cropRect 参数", 400);
|
||||
throw new BoxCutoutServiceError("框选范围无效,请重新框选需要抠出的区域后提交。", 400);
|
||||
}
|
||||
|
||||
const paddingRatio = normalizeBoxCutoutPaddingRatio(params.paddingRatio);
|
||||
@ -170,7 +175,7 @@ export async function executeBoxCutoutJob(
|
||||
|
||||
const cropRectPixels = toPixelRect(cropRect, sourceWidth, sourceHeight);
|
||||
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);
|
||||
@ -184,7 +189,18 @@ export async function executeBoxCutoutJob(
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
logger.info("crop_prepared", {
|
||||
requestId,
|
||||
sourceNodeId: params.sourceNodeId,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
cropRectPixels,
|
||||
paddedCropRectPixels,
|
||||
inputBytes: croppedBuffer.length,
|
||||
});
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
requestId,
|
||||
userId: params.userId,
|
||||
imageBuffer: croppedBuffer,
|
||||
imageBase64: croppedBuffer.toString("base64"),
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
summarizeAgentCapabilities,
|
||||
} from "./capability-access";
|
||||
import type { CanvasAgentTool } from "./loop";
|
||||
import { AgentToolRejectedError } from "./protocol";
|
||||
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
|
||||
|
||||
export function registerImageCanvasAccessTools(params: {
|
||||
@ -59,10 +60,10 @@ export function registerImageCanvasAccessTools(params: {
|
||||
(item) => item.workspace === "canvas" && item.id === input.featureId,
|
||||
);
|
||||
if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
|
||||
throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
|
||||
throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。");
|
||||
const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
|
||||
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
|
||||
throw new Error("目标节点不属于当前画布");
|
||||
throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。");
|
||||
params.emit("data-canvasAgentUiCommand", {
|
||||
projectId: params.projectId,
|
||||
requestId: `${params.runId}:${id}`,
|
||||
|
||||
@ -72,6 +72,7 @@ export async function processVideoCanvasImage(
|
||||
const result = await executeRmbgJob({
|
||||
userId: params.userId,
|
||||
imageBuffer: await fetchImageBufferFromUrl(imageUrl),
|
||||
inputImageUrl: imageUrl,
|
||||
sourceImageUrl: imageUrl,
|
||||
prompt: params.prompt,
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"
|
||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||
import {
|
||||
isAlibabaQwenImage3ModelId,
|
||||
resolveAlibabaImageAsyncEndpoint,
|
||||
@ -129,11 +130,16 @@ async function fetchImageBufferWithRetry(
|
||||
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;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const ctx = createAbortSignal(timeoutMs);
|
||||
try {
|
||||
const response = await fetch(imageUrl, {
|
||||
const response = await fetch(sourceUrl, {
|
||||
cache: "no-store",
|
||||
redirect: "follow",
|
||||
signal: ctx.signal,
|
||||
|
||||
@ -8,6 +8,7 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
|
||||
const logger = createLogger("rmbg-service");
|
||||
@ -27,6 +28,7 @@ type RmbgConfig = {
|
||||
};
|
||||
|
||||
export type ExecuteRmbgJobParams = {
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
imageBuffer: Buffer;
|
||||
imageBase64?: string;
|
||||
@ -35,6 +37,8 @@ export type ExecuteRmbgJobParams = {
|
||||
chatId?: string | null;
|
||||
messageId?: string | null;
|
||||
skipGallerySave?: boolean;
|
||||
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
|
||||
inputImageUrl?: string | null;
|
||||
sourceImageUrl?: string | null;
|
||||
};
|
||||
|
||||
@ -53,7 +57,7 @@ export class RmbgServiceError extends Error {
|
||||
detail?: string;
|
||||
|
||||
constructor(message: string, status = 500, detail?: string) {
|
||||
super(message);
|
||||
super(detail && detail !== message ? `${message}:${detail}` : message);
|
||||
this.name = "RmbgServiceError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
@ -61,7 +65,11 @@ export class RmbgServiceError extends Error {
|
||||
}
|
||||
|
||||
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_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_POLL_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 {
|
||||
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
|
||||
if (resolutionMatch) {
|
||||
@ -164,50 +184,42 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
|
||||
async function prepareImageForAos(
|
||||
imageBuffer: Buffer
|
||||
): 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 {
|
||||
const sharp = await import("sharp");
|
||||
const image = sharp.default(imageBuffer);
|
||||
const metadata = await image.metadata();
|
||||
const width = metadata.width;
|
||||
const height = metadata.height;
|
||||
const format = (metadata.format || "png").toLowerCase();
|
||||
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata();
|
||||
} catch (error) {
|
||||
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify.
|
||||
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;
|
||||
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}`,
|
||||
mimeType: format ? `image/${format}` : "application/octet-stream",
|
||||
filename: ext ? `image.${ext}` : "image",
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
logger.warn("aos_image_preprocess_failed", error);
|
||||
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" };
|
||||
}
|
||||
}
|
||||
|
||||
async function processWithRMBG(
|
||||
@ -369,20 +381,38 @@ async function processWithAosSegmentation(
|
||||
foregroundType?: "" | "person" | "object" | "stamp";
|
||||
crop?: 0 | 1;
|
||||
format?: "png" | "jpg";
|
||||
inputImageUrl?: string | null;
|
||||
requestId?: string;
|
||||
}
|
||||
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, "");
|
||||
const createUrl = `${baseUrl}/api/tasks/visual/segmentation`;
|
||||
const headers = { "X-API-KEY": config.apiKey };
|
||||
let stage = "create";
|
||||
let providerTaskId: string | undefined;
|
||||
const sourceImage = await prepareImageForAos(imageBuffer);
|
||||
|
||||
try {
|
||||
const normalizedImage = await prepareImageForAos(imageBuffer);
|
||||
const createForm = new FormData();
|
||||
const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
|
||||
type: normalizedImage.mimeType,
|
||||
let publicSourceUrl: string | null = null;
|
||||
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) {
|
||||
try {
|
||||
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, normalizedImage.filename);
|
||||
createForm.append("image_file", imageBlob, sourceImage.filename);
|
||||
}
|
||||
createForm.append("sync", "0");
|
||||
createForm.append("return_type", "1");
|
||||
createForm.append("output_type", "2");
|
||||
@ -392,6 +422,9 @@ async function processWithAosSegmentation(
|
||||
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(
|
||||
(signal) =>
|
||||
fetch(createUrl, {
|
||||
@ -411,28 +444,37 @@ async function processWithAosSegmentation(
|
||||
});
|
||||
return {
|
||||
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;
|
||||
providerTaskId = taskId;
|
||||
logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt });
|
||||
const taskUrl = `${createUrl}/${taskId}`;
|
||||
const maxPollCount = 60;
|
||||
|
||||
for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) {
|
||||
await sleep(1000);
|
||||
stage = "poll";
|
||||
|
||||
const pollResponse = await withTimeout(
|
||||
(signal) =>
|
||||
fetch(taskUrl, {
|
||||
const pollResponse = await readAosResult(
|
||||
async (signal) => {
|
||||
const response = await fetch(taskUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
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
|
||||
);
|
||||
|
||||
const pollPayload = await pollResponse.json().catch(() => null);
|
||||
const pollPayload = pollResponse.payload;
|
||||
if (!pollResponse.ok || !pollPayload?.data) {
|
||||
logger.warn("aos_task_poll_failed", {
|
||||
hasPayload: pollPayload != null,
|
||||
@ -440,7 +482,7 @@ async function processWithAosSegmentation(
|
||||
});
|
||||
return {
|
||||
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,
|
||||
taskId,
|
||||
});
|
||||
const imageResponse = await withTimeout(
|
||||
(signal) =>
|
||||
fetch(imageUrl, {
|
||||
stage = "download";
|
||||
const imageResponse = await readAosResult(
|
||||
async (signal) => {
|
||||
const response = await fetch(imageUrl, {
|
||||
method: "GET",
|
||||
signal,
|
||||
}),
|
||||
});
|
||||
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
|
||||
},
|
||||
AOS_DOWNLOAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
@ -470,11 +515,11 @@ async function processWithAosSegmentation(
|
||||
});
|
||||
return {
|
||||
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 {
|
||||
success: true,
|
||||
imageBuffer: resultBuffer,
|
||||
@ -490,19 +535,30 @@ async function processWithAosSegmentation(
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
|
||||
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("aos_task_timeout", { maxPollCount });
|
||||
return { success: false, error: "AOS任务超时(最多60秒)" };
|
||||
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount });
|
||||
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` };
|
||||
} catch (error) {
|
||||
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);
|
||||
return { success: false, error: error instanceof Error ? error.message : "未知错误" };
|
||||
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId });
|
||||
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(
|
||||
params: ExecuteRmbgJobParams
|
||||
): Promise<ExecuteRmbgJobResult> {
|
||||
return trackRuntimeWork(() => runRmbgJob(params));
|
||||
}
|
||||
|
||||
async function runRmbgJob(
|
||||
params: ExecuteRmbgJobParams
|
||||
): Promise<ExecuteRmbgJobResult> {
|
||||
const {
|
||||
requestId = crypto.randomUUID(),
|
||||
userId,
|
||||
imageBuffer,
|
||||
imageBase64 = imageBuffer.toString("base64"),
|
||||
@ -609,11 +672,13 @@ export async function executeRmbgJob(
|
||||
chatId = null,
|
||||
messageId = null,
|
||||
skipGallerySave = false,
|
||||
inputImageUrl = null,
|
||||
sourceImageUrl = null,
|
||||
} = params;
|
||||
|
||||
const { provider, config } = await getValidatedRmbgConfig();
|
||||
logger.info("job_started", {
|
||||
requestId,
|
||||
hasChatId: Boolean(chatId),
|
||||
hasMessageId: Boolean(messageId),
|
||||
hasSourceImageUrl: Boolean(sourceImageUrl),
|
||||
@ -657,6 +722,8 @@ export async function executeRmbgJob(
|
||||
foregroundType: foregroundType ?? config.aosForegroundType ?? "",
|
||||
crop: config.aosCrop === 1 ? 1 : 0,
|
||||
format: config.aosFormat === "jpg" ? "jpg" : "png",
|
||||
inputImageUrl,
|
||||
requestId,
|
||||
});
|
||||
|
||||
if (!result.success || !result.imageBuffer) {
|
||||
@ -793,6 +860,7 @@ export async function executeRmbgJob(
|
||||
}
|
||||
|
||||
logger.info("job_completed", {
|
||||
requestId,
|
||||
generatedImageId: savedImage.id,
|
||||
mediaAssetId,
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
@ -815,6 +883,7 @@ export async function executeRmbgJob(
|
||||
}
|
||||
|
||||
logger.info("job_completed", {
|
||||
requestId,
|
||||
processingSeconds: Number(processingTime.toFixed(2)),
|
||||
provider,
|
||||
savedToGallery: false,
|
||||
|
||||
@ -3,6 +3,7 @@ import { agentModelFault, type AgentModelFault } from "./agent-model-failover";
|
||||
export function agentImageFailurePolicy(error: unknown): { retry: boolean; pause: AgentModelFault | null } {
|
||||
const value = error as { message?: string; providerOutcomeUnknown?: boolean; status?: number; statusCode?: number } | null;
|
||||
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;
|
||||
// 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);
|
||||
|
||||
@ -516,6 +516,7 @@ export async function executeDirectAgentImageEditTool(
|
||||
const result = await executeRmbgJob({
|
||||
userId: params.userId,
|
||||
imageBuffer: sourceBuffer,
|
||||
inputImageUrl: params.sourceImageUrl,
|
||||
prompt: params.requestTextContent || "抠图处理",
|
||||
chatId: params.chatId,
|
||||
messageId: params.messageId,
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
isMediaInputFailure,
|
||||
isNonProviderRequestStatus,
|
||||
} 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 =
|
||||
| "success"
|
||||
@ -344,7 +344,12 @@ export function classifyAiModelHealthFailure(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 normalized = getErrorMessage(error).toLowerCase();
|
||||
const imageProviderTaskFailed =
|
||||
|
||||
@ -4,7 +4,8 @@ import { join } from "node:path";
|
||||
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
|
||||
import { runNativeZcode } from "./native-runtime";
|
||||
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
|
||||
import { AgentToolRejectedError, publicAgentError, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
import { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
|
||||
import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
|
||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
@ -37,6 +38,17 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
snapshot.message = message;
|
||||
await ports.checkpoint(structuredClone(snapshot), status); return status;
|
||||
}
|
||||
function failureMessage(call: AgentCallRecord) {
|
||||
if (call.confirmedNoResult) return `${call.error}\n\n请求编号:${ports.runId}`;
|
||||
const details = getRequestFailureDetails(call.error || "任务结果尚未确认");
|
||||
if (call.uncertain || call.status === "started") {
|
||||
if (details.outcome !== "unknown") details.message += "结果尚未确认。";
|
||||
details.outcome = "unknown";
|
||||
details.recommendation = "请联系管理员按请求编号核对原任务结果;不要换模型、新建对话或重新提交,以免重复生成。";
|
||||
}
|
||||
if (!details.recommendation) details.recommendation = "请联系管理员并提供请求编号,核对该步骤的错误。";
|
||||
return `${call.title || "创作工具"}:${formatRequestFailure(details, ports.runId)}`;
|
||||
}
|
||||
let modelError = "";
|
||||
let toolFailure = "";
|
||||
let nativeFailed = false;
|
||||
@ -44,11 +56,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
const seenTextEvents = new Set<string>();
|
||||
let lastTextMessage = "";
|
||||
async function appendText(delta: string) {
|
||||
delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
|
||||
if (!delta) return;
|
||||
snapshot.assistantText = (snapshot.assistantText || "") + delta;
|
||||
const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
|
||||
if (!text) return;
|
||||
snapshot.assistantText = (snapshot.assistantText || "") + text;
|
||||
await ports.checkpoint(structuredClone(snapshot), "running");
|
||||
ports.onAssistantText?.(delta);
|
||||
ports.onAssistantText?.(text);
|
||||
}
|
||||
async function finishVerifiedCutout() {
|
||||
snapshot.nativeRuntime!.state = "stopped";
|
||||
@ -56,8 +68,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
return save(verifiedCompletion, "completed");
|
||||
}
|
||||
try {
|
||||
if (snapshot.calls.some(call => call.status === "started" || call.uncertain))
|
||||
return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review");
|
||||
const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain);
|
||||
if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review");
|
||||
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
|
||||
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)]));
|
||||
const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
|
||||
@ -111,6 +123,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
await save("正在准备处理任务");
|
||||
}),
|
||||
onEvent: async (event: NativeEvent | NativeResult) => {
|
||||
if (attemptController.signal.aborted) return;
|
||||
if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
|
||||
// Child sessions and reasoning/tool-argument deltas are private.
|
||||
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
|
||||
@ -155,9 +168,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
|
||||
throw new Error("请先制定任务计划再执行画布修改");
|
||||
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
|
||||
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []),
|
||||
...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string");
|
||||
if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id)))
|
||||
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])]
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [];
|
||||
const failedIds = snapshot.verification.failedNodeIds;
|
||||
if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id))))
|
||||
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
|
||||
}
|
||||
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
|
||||
@ -205,8 +220,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
|
||||
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
|
||||
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
|
||||
if (call.confirmedNoResult) toolFailure = call.error;
|
||||
await save(call.error);
|
||||
if (call.uncertain || call.confirmedNoResult) toolFailure = failureMessage(call);
|
||||
await save(toolFailure || call.error);
|
||||
if (call.uncertain || call.confirmedNoResult) attemptController.abort();
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@ -228,15 +244,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
|
||||
if (toolFailure) return await save(toolFailure, "needs_review");
|
||||
// A failed read is not an uncertain paid operation. Independently verify
|
||||
// actual outputs below instead of failing a task because an earlier read timed out.
|
||||
if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain))
|
||||
return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
|
||||
const unresolvedRead = snapshot.calls.some((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
|
||||
const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain);
|
||||
if (failedWrite) return await save(failureMessage(failedWrite), "needs_review");
|
||||
const unresolvedRead = snapshot.calls.find((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly &&
|
||||
!snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
|
||||
JSON.stringify(later.input) === JSON.stringify(call.input)));
|
||||
// Without a real result to independently inspect, a viewport/UI action
|
||||
// cannot turn a failed analysis into a completed task.
|
||||
if (unresolvedRead && !snapshot.resultNodeIds.length)
|
||||
return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
|
||||
return await save(failureMessage(unresolvedRead), "needs_review");
|
||||
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
|
||||
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
|
||||
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import {
|
||||
executeBoxCutoutJob,
|
||||
} from "@/lib/ai/box-cutout/service";
|
||||
import { executeBoxCutoutJob } from "@/lib/ai/box-cutout/service";
|
||||
import { executeExtendJob } from "@/lib/ai/extend/service";
|
||||
import { executeLayerSplitJob } from "@/lib/ai/layer-split/service";
|
||||
import { executeResizeJob } from "@/lib/ai/resize/service";
|
||||
import {
|
||||
executeRmbgJob,
|
||||
fetchImageBufferFromUrl,
|
||||
RmbgServiceError,
|
||||
} from "@/lib/ai/rmbg/service";
|
||||
import type { CanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import type { CanvasCapabilityTarget } from "@/lib/canvas/capabilities/context";
|
||||
import { resolveCanvasRuntimeEndpoint } from "@/lib/canvas/capabilities/internal-endpoint";
|
||||
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout";
|
||||
import { generateCanvasImage } from "@/lib/canvas/generate-image";
|
||||
import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import {
|
||||
@ -128,8 +128,10 @@ async function executeForTarget(
|
||||
if (command.capabilityId === "image.remove_background") {
|
||||
const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl);
|
||||
const result = await executeRmbgJob({
|
||||
requestId: command.requestId,
|
||||
userId: params.userId,
|
||||
imageBuffer,
|
||||
inputImageUrl: target.sourceImageUrl,
|
||||
sourceImageUrl: target.sourceImageUrl,
|
||||
prompt: prompt || "抠图处理",
|
||||
skipGallerySave: false,
|
||||
@ -185,8 +187,7 @@ async function executeForTarget(
|
||||
}
|
||||
|
||||
if (command.capabilityId === "image.box_cutout") {
|
||||
const result = await executeBoxCutoutJob({
|
||||
userId: params.userId,
|
||||
const job = {
|
||||
imageUrl: target.sourceImageUrl,
|
||||
sourceNodeId: target.nodeId,
|
||||
cropRect: {
|
||||
@ -197,13 +198,30 @@ async function executeForTarget(
|
||||
},
|
||||
paddingRatio: asFiniteNumber(command.args.paddingRatio),
|
||||
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 [{
|
||||
...common,
|
||||
assetId: result.mediaAssetId || undefined,
|
||||
metadata: {
|
||||
editMode: "box-cutout",
|
||||
cropRectApplied: result.cropRectApplied,
|
||||
paddedCropRectApplied: result.paddedCropRectApplied,
|
||||
cropRectPixels: result.cropRectPixels,
|
||||
paddedCropRectPixels: result.paddedCropRectPixels,
|
||||
},
|
||||
title: `${target.title}-框选抠图`,
|
||||
url: result.original_url,
|
||||
|
||||
@ -44,7 +44,8 @@ import {
|
||||
} from "@/lib/ai/runtime/model-health";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
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 {
|
||||
x: number;
|
||||
@ -4632,10 +4633,10 @@ function findCanvasFailoverModel(params: {
|
||||
}
|
||||
|
||||
export async function generateCanvasImage(
|
||||
request: CanvasGenerateImageRequest
|
||||
input: CanvasGenerateImageRequest
|
||||
): Promise<CanvasGenerateImageResult> {
|
||||
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();
|
||||
if (!prompt) {
|
||||
throw new Error("请输入提示词");
|
||||
@ -4873,11 +4874,16 @@ export async function generateCanvasImage(
|
||||
typeof request.ownerKey === "string" && request.ownerKey.trim()
|
||||
? request.ownerKey.trim()
|
||||
: undefined;
|
||||
const referenceImage =
|
||||
typeof request.referenceImage === "string" ? request.referenceImage : undefined;
|
||||
const referenceImages = Array.isArray(request.referenceImages)
|
||||
const resolveReference = async (url: string) => {
|
||||
if (!normalizeLegacyAliyunStorageUrl(url)) return url;
|
||||
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
|
||||
: [];
|
||||
: []).map(resolveReference));
|
||||
const debugLabel = String(request.debugLabel || "").trim() || "canvas-image";
|
||||
const debugMeta =
|
||||
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.
|
||||
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)
|
||||
? 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 { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import { getImageToolCreditCost } from "@/lib/points/config";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
|
||||
export async function executeRemoveBackgroundPOST(request: Request, trustedUserId: string) {
|
||||
const requestId = request.headers.get("x-request-id") || crypto.randomUUID();
|
||||
try {
|
||||
const session = { user: { id: trustedUserId } };
|
||||
if (!session?.user?.id) {
|
||||
@ -27,6 +29,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
let messageId: string | null = null;
|
||||
let skipGallerySave = false;
|
||||
let sourceImageUrl: string | null = null;
|
||||
let inputImageUrl: string | null = null;
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
const body = await request.json();
|
||||
@ -40,6 +43,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
|
||||
if (imageUrl) {
|
||||
sourceImageUrl = String(imageUrl);
|
||||
inputImageUrl = sourceImageUrl;
|
||||
imageBuffer = await fetchImageBufferFromUrl(sourceImageUrl);
|
||||
} else if (body.image_base64) {
|
||||
imageBase64 = String(body.image_base64);
|
||||
@ -94,6 +98,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
}
|
||||
|
||||
const result = await executeRmbgJob({
|
||||
requestId,
|
||||
userId: session.user.id,
|
||||
imageBuffer,
|
||||
imageBase64,
|
||||
@ -101,6 +106,7 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
chatId,
|
||||
messageId,
|
||||
skipGallerySave,
|
||||
inputImageUrl,
|
||||
sourceImageUrl,
|
||||
});
|
||||
|
||||
@ -114,22 +120,29 @@ export async function executeRemoveBackgroundPOST(request: Request, trustedUserI
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof RmbgServiceError) {
|
||||
const failure = getRequestFailureDetails(error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error.message,
|
||||
success: false,
|
||||
error: formatRequestFailure(failure, requestId),
|
||||
detail: error.detail || error.message,
|
||||
code: failure.code,
|
||||
failure,
|
||||
requestId,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[RMBG API] 处理错误:", error);
|
||||
const failure = getRequestFailureDetails(error, "抠图请求未能完成");
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,22 +40,6 @@ function clamp(value: number, min: number, max: number) {
|
||||
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(
|
||||
rect: CanvasScaleAdjustRect,
|
||||
sourceWidth: number,
|
||||
@ -220,7 +204,7 @@ async function buildPersonExclusionAlpha(params: {
|
||||
userId: string;
|
||||
}) {
|
||||
try {
|
||||
const person = await executeScaleAdjustRmbgJob({
|
||||
const person = await executeRmbgJob({
|
||||
foregroundType: "person",
|
||||
imageBuffer: params.sourceBuffer,
|
||||
prompt: "比例调整人物保护蒙版",
|
||||
@ -330,7 +314,7 @@ export async function buildScaleAdjustProductLockAssets(params: {
|
||||
.png({ compressionLevel: 6 })
|
||||
.toBuffer();
|
||||
semanticSourceCropBuffers.push(cropBuffer);
|
||||
const subject = await executeScaleAdjustRmbgJob({
|
||||
const subject = await executeRmbgJob({
|
||||
imageBuffer: cropBuffer,
|
||||
prompt: "比例调整完整产品蒙版",
|
||||
skipGallerySave: true,
|
||||
|
||||
55
lib/client/canvas-draft.ts
Normal file
55
lib/client/canvas-draft.ts
Normal file
@ -0,0 +1,55 @@
|
||||
export type CanvasDraft = { base: string; snapshot: string };
|
||||
const key = (projectId: string) => `canvas-unsynced:${projectId}`;
|
||||
|
||||
export function readCanvasDraft(projectId: string): CanvasDraft | null {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key(projectId)) || "null");
|
||||
return typeof value?.base === "string" && typeof value?.snapshot === "string" ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCanvasDraft(projectId: string, draft: CanvasDraft): boolean {
|
||||
try {
|
||||
localStorage.setItem(key(projectId), JSON.stringify(draft));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCanvasDraft(projectId: string) {
|
||||
try {
|
||||
localStorage.removeItem(key(projectId));
|
||||
} catch {
|
||||
// Keep the current in-memory canvas when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
const composerKey = (chatId: string) => `canvas-unsent-message:${chatId}`;
|
||||
export function writeCanvasComposerDraft(chatId: string, draft: { text: string; attachments: unknown[] }) {
|
||||
try {
|
||||
localStorage.setItem(composerKey(chatId), JSON.stringify(draft));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readCanvasComposerDraft<T>(chatId: string): { text: string; attachments: T[] } | null {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(composerKey(chatId)) || "null");
|
||||
return typeof value?.text === "string" && Array.isArray(value?.attachments) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCanvasComposerDraft(chatId: string) {
|
||||
try {
|
||||
localStorage.removeItem(composerKey(chatId));
|
||||
} catch {
|
||||
// Keep the current editor contents when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
@ -63,4 +63,5 @@ export async function executeCanvasToolbarCapability(
|
||||
}
|
||||
current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
|
||||
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
|
||||
return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) };
|
||||
}
|
||||
|
||||
15
lib/client/page-refresh.ts
Normal file
15
lib/client/page-refresh.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const BEFORE_PAGE_REFRESH = "app:before-page-refresh";
|
||||
export type PageRefreshRequest = {
|
||||
save: (action: () => Promise<void>) => void;
|
||||
block: (message: string) => void;
|
||||
};
|
||||
|
||||
export async function preparePageRefresh() {
|
||||
const actions: Array<() => Promise<void>> = [];
|
||||
let blocked = "";
|
||||
window.dispatchEvent(new CustomEvent<PageRefreshRequest>(BEFORE_PAGE_REFRESH, {
|
||||
detail: { save: action => actions.push(action), block: message => { blocked ||= message; } },
|
||||
}));
|
||||
if (blocked) throw new Error(blocked);
|
||||
for (const action of actions) await action();
|
||||
}
|
||||
@ -1,5 +1,17 @@
|
||||
import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage";
|
||||
|
||||
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";
|
||||
|
||||
export type ImageProxyOptions = {
|
||||
@ -9,6 +21,26 @@ export type ImageProxyOptions = {
|
||||
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. */
|
||||
export function getImageDownloadExtension(contentType: string) {
|
||||
const mime = contentType.split(";")[0]?.trim().toLowerCase();
|
||||
@ -59,6 +91,15 @@ function isAliyunOssImageHost(hostname: string): boolean {
|
||||
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 {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
let configuredCdnHost = "";
|
||||
@ -200,7 +241,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
||||
return trustedDirectStorageUrl;
|
||||
}
|
||||
|
||||
if (options.delivery !== "proxy") {
|
||||
if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source) && !isAlibabaModelImageUrl(source)) {
|
||||
const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options);
|
||||
if (directDisplayUrl) {
|
||||
return directDisplayUrl;
|
||||
|
||||
@ -228,9 +228,75 @@ export function getRequestFailureDetails(
|
||||
const status = getRequestErrorStatus(error);
|
||||
const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") =>
|
||||
({ 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)) {
|
||||
return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown");
|
||||
}
|
||||
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");
|
||||
}
|
||||
const errorName = isRecord(error) ? String(error.name || "") : "";
|
||||
if (["AbortError", "TimeoutError"].includes(errorName) || /operation was aborted/i.test(text)) {
|
||||
return detail("RESULT_UNCONFIRMED", errorName === "TimeoutError" ? "请求等待超时,任务结果尚未确认。" : "本次请求连接已中止,任务结果尚未确认。", "请先核对原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||
}
|
||||
if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) {
|
||||
return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown");
|
||||
}
|
||||
@ -243,6 +309,9 @@ export function getRequestFailureDetails(
|
||||
if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) {
|
||||
return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。");
|
||||
}
|
||||
if (/旧存储已停用|storage_retired/i.test(text)) {
|
||||
return detail("SOURCE_STORAGE_RETIRED", "参考文件所在的旧存储已停用,且没有可用的归档副本。", "请重新上传原始文件后再提交;切换模型或重复提交无法恢复该文件。");
|
||||
}
|
||||
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||||
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,72 @@
|
||||
import "server-only";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { open } from "node:fs/promises";
|
||||
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
|
||||
import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries";
|
||||
import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service";
|
||||
|
||||
/** Only a completely written archive is uploaded. Local GET remains a fallback. */
|
||||
export async function publishCanvasZip(params: { id: string; path: string; bytes: number; disposition: string }) {
|
||||
type ZipPublication = { id: string; path: string; bytes: number; disposition: string; onProgress?: (bytes: number) => Promise<void> };
|
||||
const PART_BYTES = 8 * 1024 ** 2;
|
||||
|
||||
async function uploadZip(client: S3Client, bucket: string, key: string, params: ZipPublication) {
|
||||
const metadata = { Bucket: bucket, Key: key, ContentType: "application/zip", ContentDisposition: params.disposition, CacheControl: "public, max-age=1800" };
|
||||
if (params.bytes <= 2 * PART_BYTES) {
|
||||
const body = createReadStream(params.path);
|
||||
try {
|
||||
await client.send(new PutObjectCommand({ ...metadata, Body: body, ContentLength: params.bytes }), { abortSignal: AbortSignal.timeout(90_000) });
|
||||
await params.onProgress?.(params.bytes);
|
||||
} finally { body.destroy(); }
|
||||
return;
|
||||
}
|
||||
const initiated = await client.send(new CreateMultipartUploadCommand(metadata), { abortSignal: AbortSignal.timeout(15_000) });
|
||||
if (!initiated.UploadId) throw new Error("七牛未返回分片上传编号");
|
||||
const upload = { Bucket: bucket, Key: key, UploadId: initiated.UploadId };
|
||||
const handle = await open(params.path, "r").catch(async error => {
|
||||
await client.send(new AbortMultipartUploadCommand(upload), { abortSignal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
||||
throw error;
|
||||
});
|
||||
const parts: Array<{ PartNumber: number; ETag: string }> = [];
|
||||
let cursor = 0;
|
||||
let uploaded = 0;
|
||||
let progress = Promise.resolve();
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
const workers = await Promise.allSettled(Array.from({ length: 3 }, async () => {
|
||||
try {
|
||||
while (!controller.signal.aborted) {
|
||||
const index = cursor++;
|
||||
const start = index * PART_BYTES;
|
||||
if (start >= params.bytes) return;
|
||||
const body = Buffer.allocUnsafe(Math.min(PART_BYTES, params.bytes - start));
|
||||
let read = 0;
|
||||
while (read < body.length) {
|
||||
const result = await handle.read(body, read, body.length - read, start + read);
|
||||
if (!result.bytesRead) throw new Error("完整 ZIP 文件读取中断,请重试下载");
|
||||
read += result.bytesRead;
|
||||
}
|
||||
const part = await client.send(new UploadPartCommand({ ...upload, PartNumber: index + 1, Body: body, ContentLength: body.length }), {
|
||||
abortSignal: AbortSignal.any([controller.signal, AbortSignal.timeout(180_000)]),
|
||||
});
|
||||
if (!part.ETag) throw new Error("七牛未确认上传分片");
|
||||
parts.push({ PartNumber: index + 1, ETag: part.ETag });
|
||||
uploaded += body.length;
|
||||
const bytes = uploaded;
|
||||
progress = progress.then(() => params.onProgress?.(bytes));
|
||||
await progress;
|
||||
}
|
||||
} catch (error) { controller.abort(); throw error; }
|
||||
}));
|
||||
const failure = workers.find(worker => worker.status === "rejected");
|
||||
if (failure?.status === "rejected") throw failure.reason;
|
||||
await client.send(new CompleteMultipartUploadCommand({ ...upload, MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) } }), { abortSignal: AbortSignal.timeout(30_000) });
|
||||
} catch (error) {
|
||||
await client.send(new AbortMultipartUploadCommand(upload), { abortSignal: AbortSignal.timeout(10_000) }).catch(() => undefined);
|
||||
throw error;
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
|
||||
/** Publish the complete ZIP before offering the accelerated download. */
|
||||
export async function publishCanvasZip(params: ZipPublication) {
|
||||
const raw = await getDefaultStorageConfig();
|
||||
const config = toDbS3UploadConfig(raw);
|
||||
if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null;
|
||||
@ -13,20 +74,37 @@ export async function publishCanvasZip(params: { id: string; path: string; bytes
|
||||
const client = createS3Client(config);
|
||||
// Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers.
|
||||
client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED";
|
||||
const body = createReadStream(params.path);
|
||||
let phase: "check" | "upload" | "verify" = "check";
|
||||
const startedAt = Date.now();
|
||||
let uploadedBytes = 0;
|
||||
try {
|
||||
await client.send(new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: body,
|
||||
ContentLength: params.bytes, ContentType: "application/zip", ContentDisposition: params.disposition,
|
||||
CacheControl: "public, max-age=1800" }), { abortSignal: AbortSignal.timeout(90_000) });
|
||||
const url = buildS3PublicUrl(config, key);
|
||||
const existing = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }), { abortSignal: AbortSignal.timeout(15_000) }).catch(error => {
|
||||
if (error.$metadata?.httpStatusCode === 404) return null;
|
||||
throw error;
|
||||
});
|
||||
if (existing?.ContentLength !== params.bytes) {
|
||||
phase = "upload";
|
||||
await uploadZip(client, config.bucket, key, { ...params, onProgress: async bytes => {
|
||||
uploadedBytes = bytes;
|
||||
await params.onProgress?.(bytes);
|
||||
} });
|
||||
}
|
||||
phase = "verify";
|
||||
const target = new URL(buildS3PublicUrl(config, key));
|
||||
target.searchParams.set("v", String(params.bytes));
|
||||
const url = target.toString();
|
||||
const response = await fetch(url, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(10_000) });
|
||||
const length = Number(response.headers.get("content-length"));
|
||||
if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready");
|
||||
return { url, storageConfigId: raw!.id, key };
|
||||
} catch (error) {
|
||||
console.warn("[CanvasDownloadZipTask] CDN upload/check failed; using complete local file", { taskId: params.id, kind: (error as Error).name });
|
||||
return { url: null, storageConfigId: raw!.id, key };
|
||||
} finally { body.destroy(); client.destroy(); }
|
||||
const kind = (error as Error).name;
|
||||
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
||||
console.warn("[CanvasDownloadZipTask] CDN publication failed", { taskId: params.id, phase, kind, status, bytes: params.bytes, uploadedBytes, elapsedMs: Date.now() - startedAt });
|
||||
const reason = status === 401 || status === 403 ? `七牛拒绝访问(HTTP ${status}),请管理员检查存储权限` : phase === "check" ? "暂时无法确认七牛文件状态" : phase === "verify" ? "七牛下载链接尚未可用" : /AbortError|TimeoutError/.test(kind) ? "传送至七牛时超时" : "传送至七牛失败";
|
||||
const advice = status === 401 || status === 403 ? "存储权限恢复后再次点击下载即可继续传送原 ZIP" : "请再次点击下载以继续传送原 ZIP";
|
||||
return { url: null, storageConfigId: raw!.id, key, error: `ZIP 已打包完成,但${reason}。${advice};不会重新生成图片。若持续失败,请联系管理员并提供下载编号:${params.id}` };
|
||||
} finally { client.destroy(); }
|
||||
}
|
||||
|
||||
export async function removeCanvasZipObject(object: { storageConfigId: string; key: string }) {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import "server-only";
|
||||
import { isRetiredStorageUrl } from "./retired-storage";
|
||||
import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
@ -22,6 +22,8 @@ export function getImageArchiveDisplayHoldMs() {
|
||||
}
|
||||
|
||||
export function imageArchiveSourceKey(source: string) {
|
||||
const legacy = normalizeLegacyAliyunStorageUrl(source);
|
||||
if (legacy) return legacy;
|
||||
if (!source.startsWith("data:")) return source;
|
||||
const configured =
|
||||
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;
|
||||
}) {
|
||||
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 archiveAfterAt = params.archiveAfterAt || now;
|
||||
@ -312,6 +314,18 @@ export async function getArchivedImageUrlMappings(
|
||||
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.
|
||||
export async function resolveArchivedImageUrls<T>(
|
||||
value: T,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// Shared by the server and canvas; no storage or network work happens here.
|
||||
export type ArchivedImageUrlMap = Record<string, string>;
|
||||
const legacyOssUrlPattern = /https?:\/\/demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/[^\s"'<>`\\)\]}|]+/gi;
|
||||
|
||||
export function isGeneratedImageSource(value: string) {
|
||||
return (
|
||||
@ -11,6 +12,7 @@ export function collectImageArchiveSources(value: unknown): string[] {
|
||||
const urls = new Set<string>();
|
||||
const visit = (item: unknown) => {
|
||||
if (typeof item === "string") {
|
||||
for (const match of item.matchAll(legacyOssUrlPattern)) urls.add(match[0].replace(/,+$/, ""));
|
||||
if (isGeneratedImageSource(item)) urls.add(item);
|
||||
else
|
||||
for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) {
|
||||
@ -35,6 +37,10 @@ export function replaceArchivedImageUrls<T>(value: T, replacements: ArchivedImag
|
||||
.map((part) => replacements[part] || part)
|
||||
.join("|||");
|
||||
return ``;
|
||||
}).replace(legacyOssUrlPattern, (url) => {
|
||||
if (replacements[url]) return replacements[url];
|
||||
const source = url.replace(/,+$/, "");
|
||||
return (replacements[source] || source) + url.slice(source.length);
|
||||
}) as T;
|
||||
}
|
||||
if (!value || typeof value !== "object" || value instanceof Date) return value;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@ -83,13 +83,18 @@ function isMediaResponse(data: ImageBytes) {
|
||||
}
|
||||
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}`);
|
||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||
if (url.startsWith("data:")) {
|
||||
const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i);
|
||||
let sourceUrl = url;
|
||||
if (normalizeLegacyAliyunStorageUrl(url)) {
|
||||
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");
|
||||
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",
|
||||
// The deadline covers the body as well as the response headers.
|
||||
signal: options.signal
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { db } from "@/lib/db";
|
||||
@ -19,6 +19,7 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S
|
||||
config.isEnabled &&
|
||||
!isRetiredStorageUrl(config.endpoint) &&
|
||||
!isRetiredStorageUrl(config.publicUrl) &&
|
||||
!isLegacyAliyunStorageConfig(config) &&
|
||||
String(config.endpoint || "").trim() &&
|
||||
String(config.bucket || "").trim() &&
|
||||
String(config.accessKeyId || "").trim() &&
|
||||
@ -83,7 +84,7 @@ export async function enqueueStorageMigrationTask({
|
||||
migrateAfterAt: Date;
|
||||
cleanupAfterAt: Date;
|
||||
}) {
|
||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return;
|
||||
if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return;
|
||||
|
||||
await db
|
||||
.insert(storageMigrationTask)
|
||||
|
||||
@ -2,6 +2,29 @@
|
||||
export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)';
|
||||
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 {
|
||||
if (!value) return false;
|
||||
try {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 { put } from "@vercel/blob";
|
||||
@ -150,7 +150,7 @@ export function getContentType(filename: string): string {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
|
||||
@ -343,6 +343,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
|
||||
!value.isEnabled ||
|
||||
isRetiredStorageUrl(endpoint) ||
|
||||
isRetiredStorageUrl(String(value.publicUrl || "")) ||
|
||||
isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: String(value.publicUrl || "") }) ||
|
||||
!endpoint ||
|
||||
!bucket ||
|
||||
!accessKeyId ||
|
||||
@ -370,7 +371,8 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.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 {
|
||||
endpoint,
|
||||
region: process.env.S3_REGION?.trim() || "auto",
|
||||
|
||||
@ -107,5 +107,5 @@ export function mergeCanvasSnapshots(
|
||||
(e: any) => ids.has(e.source) && ids.has(e.target),
|
||||
);
|
||||
}
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts };
|
||||
return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) };
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { basename, dirname } from "node:path";
|
||||
|
||||
const appRelease = [basename(process.cwd()), basename(dirname(process.cwd()))]
|
||||
.find(name => /^\d{8}T\d{6}Z-[a-f0-9]{12}$/.test(name)) || "development";
|
||||
|
||||
function resolvePascalEditorFrameSource() {
|
||||
const rawUrl = (
|
||||
@ -45,6 +49,7 @@ function resolveDevMediaOrigins(isDev: boolean) {
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
poweredByHeader: false,
|
||||
allowedDevOrigins: ["127.0.0.1"],
|
||||
|
||||
@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/routes",
|
||||
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/,
|
||||
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access|zcode-agent-task)\.test\.tsx?/,
|
||||
fullyParallel: true,
|
||||
retries: 0,
|
||||
workers: 2,
|
||||
|
||||
@ -3,6 +3,9 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
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/,
|
||||
@ -18,6 +21,7 @@ export default defineConfig({
|
||||
/routes\/video-canvas-media-import\.test\.ts/,
|
||||
/routes\/video-canvas-toolbar-jobs\.test\.ts/,
|
||||
/routes\/image-provider-error-classification\.test\.ts/,
|
||||
/routes\/image-generation-status\.test\.ts/,
|
||||
/routes\/canvas-agent-runtime-image-failover\.test\.ts/,
|
||||
/routes\/video-canvas-audio-remote\.test\.ts/,
|
||||
/routes\/video-canvas-groups-upscale\.test\.ts/,
|
||||
|
||||
5
proxy.ts
5
proxy.ts
@ -54,6 +54,7 @@ function isHighFrequencyReadApi(pathname: string, method: string) {
|
||||
if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true;
|
||||
|
||||
return [
|
||||
"/api/app-version",
|
||||
"/api/ai-wash/config",
|
||||
"/api/models",
|
||||
"/api/canvas",
|
||||
@ -816,6 +817,10 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname === "/api/app-version" && (request.method === "GET" || request.method === "HEAD")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Pascal 服务到服务调用由对应 Route 再次验证共享密钥。
|
||||
if (isPascalInternalProxyRequest(request)) {
|
||||
return NextResponse.next();
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { basename, dirname } from "node:path";
|
||||
|
||||
const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`;
|
||||
const BASE_URL = stripTrailingSlash(
|
||||
process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL
|
||||
);
|
||||
const REQUEST_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 20_000);
|
||||
const EXPECTED_APP_RELEASE = process.env.SMOKE_EXPECTED_APP_RELEASE ||
|
||||
[basename(process.cwd()), basename(dirname(process.cwd()))].find(name => /^\d{8}T\d{6}Z-[a-f0-9]{12}$/.test(name));
|
||||
|
||||
const checks = [
|
||||
{ path: "/api/app-version", method: "GET", expect: { status: [200] } },
|
||||
{
|
||||
path: "/",
|
||||
method: "HEAD",
|
||||
@ -74,6 +79,12 @@ async function runCheck(check) {
|
||||
};
|
||||
}
|
||||
|
||||
if (check.path === "/api/app-version") {
|
||||
const data = await response.json();
|
||||
if (!data.version || (EXPECTED_APP_RELEASE && data.version !== EXPECTED_APP_RELEASE) || !response.headers.get("cache-control")?.includes("no-store"))
|
||||
return { ok: false, message: "deployed app version or cache policy does not match the release" };
|
||||
}
|
||||
|
||||
if (
|
||||
check.expect.location &&
|
||||
!matchesLocation(location, check.expect.location)
|
||||
|
||||
@ -95,18 +95,19 @@ function normalizeRevisionText(value: unknown) {
|
||||
return String(value || "").trim();
|
||||
}
|
||||
|
||||
function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void {
|
||||
function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const revision = normalizeRevisionText(updatedAt);
|
||||
if (!revision) return;
|
||||
useCanvasStore.getState().setCanvasRevision(revision, revision);
|
||||
const current = useCanvasStore.getState();
|
||||
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
|
||||
}
|
||||
|
||||
function syncCanvasStoreRevisionFromProject(
|
||||
project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
||||
project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null
|
||||
): void {
|
||||
if (!project?.canvasData) return;
|
||||
syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt);
|
||||
syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt);
|
||||
}
|
||||
|
||||
export interface NormalizedCropRect {
|
||||
@ -203,12 +204,9 @@ export const canvasService = {
|
||||
try {
|
||||
const response = await fetch(`/api/canvas/${id}`);
|
||||
const data = await response.json();
|
||||
if (data?.success) {
|
||||
syncCanvasStoreRevisionFromProject(data.data);
|
||||
}
|
||||
return data;
|
||||
return { ...data, status: response.status };
|
||||
} catch (error) {
|
||||
return { success: false, error: '获取画布失败' };
|
||||
return { success: false, error: '画布读取失败,请检查网络后重试;现有内容未被覆盖', status: 0 };
|
||||
}
|
||||
},
|
||||
|
||||
@ -335,7 +333,7 @@ export const canvasService = {
|
||||
};
|
||||
}
|
||||
if (data?.success) {
|
||||
syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt);
|
||||
syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt);
|
||||
}
|
||||
return data as ApiResponse;
|
||||
} catch (error) {
|
||||
|
||||
624
tests/e2e/canvas-autosync.test.ts
Normal file
624
tests/e2e/canvas-autosync.test.ts
Normal file
@ -0,0 +1,624 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.setTimeout(60_000);
|
||||
const projectId = "8d3b4bac-4539-4caf-8b49-b063f218de19";
|
||||
const imageOrigin = "https://scqnycdn.jiansun.vip";
|
||||
const imageNode = (id: string) => ({ id, type: "image", position: { x: 160, y: 130 }, size: { width: 300, height: 200 },
|
||||
data: { status: "completed", src: `${imageOrigin}/uploads/${id}.png`, title: id } });
|
||||
|
||||
async function setup(page: Page, failRead = false) {
|
||||
const state = {
|
||||
canvasData: { nodes: [imageNode("source")], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" },
|
||||
revision: 1, failSave: false, failRead, conflictNextSave: false, conflictSameNode: false,
|
||||
writes: [] as any[], metadataWrites: 0, submissions: [] as any[], capabilitySubmissions: [] as any[],
|
||||
appVersion: "development", holdRequest: false, finishRequest: null as (() => void) | null,
|
||||
nativeResultNext: false,
|
||||
holdUpload: false, uploadStarted: 0, finishUpload: null as (() => void) | null,
|
||||
};
|
||||
const revision = () => new Date(Date.UTC(2026, 10, 6, 0, 0, state.revision)).toISOString();
|
||||
const project = () => ({ id: projectId, title: "同步验证", canvasData: JSON.parse(JSON.stringify(state.canvasData, (_key, value) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)))
|
||||
: value)), updatedAt: revision() });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
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 = {};
|
||||
let status = 200;
|
||||
if (path.startsWith("/api/files/") && request.method() === "POST" && state.holdUpload) {
|
||||
state.uploadStarted++;
|
||||
await new Promise<void>(resolve => { state.finishUpload = resolve; });
|
||||
}
|
||||
if (path === `/api/canvas/${projectId}`) {
|
||||
if (request.method() === "GET") {
|
||||
status = state.failRead ? 500 : 200;
|
||||
data = state.failRead ? { success: false, error: "画布暂时无法读取,现有内容未被覆盖。" } : { success: true, data: project() };
|
||||
} else if (request.method() === "PUT") {
|
||||
const body = request.postDataJSON();
|
||||
state.writes.push(body);
|
||||
const previousCanvasRevision = revision();
|
||||
if (body.canvasData) {
|
||||
if (state.conflictNextSave) {
|
||||
state.conflictNextSave = false;
|
||||
if (state.conflictSameNode) state.canvasData.nodes[0].position.x = 500;
|
||||
else state.canvasData.nodes.push(imageNode("remote-result"));
|
||||
state.revision++;
|
||||
}
|
||||
if (state.failSave) {
|
||||
status = 503; data = { success: false, error: "同步服务暂时不可用" };
|
||||
} else if ((body.baseCanvasUpdatedAt || body.baseCanvasRevision) !== revision()) {
|
||||
status = 409; data = { success: false, code: "STALE_CANVAS_REVISION", error: "画布已更新", canvasUpdatedAt: revision() };
|
||||
} else {
|
||||
state.canvasData = body.canvasData;
|
||||
state.revision++;
|
||||
data = { success: true, previousCanvasRevision, data: project() };
|
||||
}
|
||||
} else {
|
||||
state.metadataWrites++;
|
||||
state.revision++;
|
||||
data = { success: true, previousCanvasRevision, data: project() };
|
||||
}
|
||||
}
|
||||
} else if (path === `/api/canvas/${projectId}/capabilities` && request.method() === "POST") {
|
||||
state.capabilitySubmissions.push(request.postDataJSON());
|
||||
state.canvasData.nodes.push({ ...imageNode(`enhanced-${state.capabilitySubmissions.length}`), position: { x: 520, y: 130 } });
|
||||
state.revision++;
|
||||
data = { success: true, task: { status: "succeeded" } };
|
||||
} else if (path === "/api/canvas/agent-chat" && request.method() === "POST") {
|
||||
state.submissions.push(request.postDataJSON());
|
||||
let operation = "";
|
||||
if (state.nativeResultNext) {
|
||||
state.nativeResultNext = false;
|
||||
state.canvasData.nodes.push({ ...imageNode("agent-result"), position: { x: 520, y: 130 } });
|
||||
state.revision++;
|
||||
operation = `data: ${JSON.stringify({ type: "data-canvasOperation", data: { status: "applied", projectId, createdNodeIds: ["agent-result"] } })}\n\n`;
|
||||
}
|
||||
if (state.holdRequest) await new Promise<void>(resolve => { state.finishRequest = resolve; });
|
||||
await route.fulfill({ headers: { "content-type": "text/event-stream", "x-vercel-ai-ui-message-stream": "v1" }, body:
|
||||
'data: {"type":"start","messageId":"sync-assistant"}\n\n' + operation + 'data: {"type":"text-start","id":"text"}\n\ndata: {"type":"text-delta","id":"text","delta":"已收到请求"}\n\ndata: {"type":"text-end","id":"text"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n' });
|
||||
return;
|
||||
} else if (path === "/api/app-version") data = { version: state.appVersion };
|
||||
else if (path === "/api/files/direct-upload") {
|
||||
data = request.postDataJSON().action === "complete"
|
||||
? { url: `${imageOrigin}/uploads/uploaded.png`, pathname: "uploads/uploaded.png" }
|
||||
: { mode: "completed", key: "uploads/uploaded.png" };
|
||||
} else if (path === "/api/files/upload") data = { url: `${imageOrigin}/uploads/uploaded.png` };
|
||||
else if (path === "/api/canvas/auto-title") data = { title: "参考图片" };
|
||||
else if (path.includes("validate-session")) data = { valid: true, success: true };
|
||||
else if (path === "/api/auth/session") data = { user: { id: "sync-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/models") data = { models: [{ id: "image-test", modelId: "gpt-image-2", providerType: "openai", name: "GPT Image", type: "image", enabled: true, supportsImageGen: true }] };
|
||||
else if (path === "/api/vote") data = [];
|
||||
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, contentType: "application/json", body: JSON.stringify(data) });
|
||||
});
|
||||
await page.context().route(`${imageOrigin}/**`, route => route.fulfill({ contentType: "image/svg+xml",
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="300" height="200"><rect width="300" height="200" fill="#43b9bd"/></svg>' }));
|
||||
await page.goto(`/canvas/${projectId}`, { waitUntil: "domcontentloaded" });
|
||||
if (!failRead) await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible();
|
||||
return { state, errors };
|
||||
}
|
||||
|
||||
async function moveImage(page: Page) {
|
||||
const node = page.locator('[data-canvas-node-id="source"]');
|
||||
await expect(node).toBeVisible();
|
||||
const box = (await node.boundingBox())!;
|
||||
await page.mouse.move(box.x + 100, box.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 190, box.y + 120, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
const unloadWouldBeBlocked = (page: Page) => page.evaluate(() => {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
Object.defineProperty(event, "returnValue", { value: "", writable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented && Boolean(event.returnValue);
|
||||
});
|
||||
|
||||
test("background saving recovers when the network returns without a manual save", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await expect(page.getByRole("button", { name: "保存", exact: true })).toHaveCount(0);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
await expect.poll(() => state.writes.filter(body => body.canvasData).length).toBeGreaterThan(0);
|
||||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||||
state.failSave = false;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("online")));
|
||||
await expect.poll(() => state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
await expect.poll(() => unloadWouldBeBlocked(page)).toBe(false);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("selecting a saved image does not require another save before an Agent message", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.locator('[data-canvas-node-id="source"]').click();
|
||||
state.failSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("分析这张已经保存的图片");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source"]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("only unsaved content blocks closing and beforeunload does not issue duplicate saves", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||
await page.locator('[data-canvas-node-id="source"]').click();
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(false);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
const writes = state.writes.length;
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
expect(state.writes).toHaveLength(writes);
|
||||
const draft = await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId);
|
||||
expect(JSON.parse(JSON.parse(draft!).snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("pagehide and visibilitychange issue only one closing save", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
const writes = state.writes.length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expect.poll(() => state.writes.length).toBe(writes + 1);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaving with pending changes can continue editing or save before navigating", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||
await moveImage(page);
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("leave-dialog-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("leave-dialog-mobile.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await moveImage(page);
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed saving keeps the leave dialog and canvas until a successful retry", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("HTTP 503");
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
state.failSave = false;
|
||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("discarding pending changes leaves without saving or restoring the discarded draft", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.route(url => url.pathname === "/", route => route.fulfill({ contentType: "text/html; charset=utf-8", body: "<p>已返回首页</p>" }));
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
const writes = state.writes.length;
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
await page.getByRole("button", { name: "不保存并离开", exact: true }).click();
|
||||
await expect(page.getByText("已返回首页")).toBeVisible();
|
||||
expect(state.writes).toHaveLength(writes);
|
||||
expect(state.canvasData.nodes[0].position.x).toBe(160);
|
||||
expect(await page.evaluate(id => localStorage.getItem(`canvas-unsynced:${id}`), projectId)).toBeNull();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("creating another project also offers to preserve pending canvas changes", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
await page.getByRole("button", { name: "查看项目文件", exact: true }).click();
|
||||
await page.getByRole("button", { name: "新建画布", exact: true }).first().click();
|
||||
await expect(page.getByRole("dialog", { name: "离开前保存画布?" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/canvas/${projectId}$`));
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an enhanced result can be moved and archived before the next save without blocking Agent", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await page.locator('[data-canvas-node-id="source"]').click();
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
const result = page.locator('[data-canvas-node-id="enhanced-1"]');
|
||||
await expect(result).toBeVisible();
|
||||
const box = (await result.boundingBox())!;
|
||||
await page.mouse.move(box.x + 100, box.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
state.canvasData.nodes.find(node => node.id === "enhanced-1")!.data.src = `${imageOrigin}/uploads/archived-result.png`;
|
||||
state.revision++;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("保留增强结果的位置,继续分析");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
const saved = state.canvasData.nodes.find(node => node.id === "enhanced-1")!;
|
||||
expect(saved.position.x).toBeGreaterThan(520);
|
||||
expect(saved.data.src).toContain("archived-result.png");
|
||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("toolbar save failures show the cause and recover without a paid submission", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.failSave = true;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
await expect(page.getByText(/画布自动同步失败(HTTP 503)/)).toBeVisible();
|
||||
await expect(page.getByText(/本次处理未提交/)).toBeVisible();
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
state.failSave = false;
|
||||
await page.locator('[data-tool-key="enhance"]').click();
|
||||
await expect(page.locator('[data-canvas-node-id="enhanced-1"]')).toBeVisible();
|
||||
expect(state.capabilitySubmissions).toHaveLength(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("native Agent results update the save baseline before moving and archiving the result", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
state.nativeResultNext = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("先分析再处理图片");
|
||||
await editor.press("Enter");
|
||||
const result = page.locator('[data-canvas-node-id="agent-result"]');
|
||||
await expect(result).toBeVisible();
|
||||
await expect(editor).toBeEditable();
|
||||
const box = (await result.boundingBox())!;
|
||||
await page.mouse.move(box.x + 100, box.y + 80);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 165, box.y + 110, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
state.canvasData.nodes.find(node => node.id === "agent-result")!.data.src = `${imageOrigin}/uploads/agent-archived.png`;
|
||||
state.revision++;
|
||||
await editor.fill("保留位置,继续分析");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(2);
|
||||
const saved = state.canvasData.nodes.find(node => node.id === "agent-result")!;
|
||||
expect(saved.position.x).toBeGreaterThan(520);
|
||||
expect(saved.data.src).toContain("agent-archived.png");
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("upload naming and consecutive Agent messages sync automatically without manual saves", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.evaluate(element => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "参考图.png", { type: "image/png" }));
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect.poll(() => state.metadataWrites).toBe(1);
|
||||
expect(state.canvasData.nodes).toHaveLength(2);
|
||||
for (const prompt of ["请先分析参考图", "继续调整为白色背景"]) {
|
||||
await editor.fill(prompt);
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(prompt.startsWith("请") ? 1 : 2);
|
||||
await expect(editor).toBeEditable();
|
||||
}
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-canvas-node-id]')).toHaveCount(2);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a save conflict merges a remote result with local movement before one Agent submission", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.conflictNextSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("保留我移动后的位置,再分析图片");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-result"]);
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("failed synchronization preserves the unsent message and canvas draft across refresh", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.failSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
const prompt = "保持产品结构,只更换背景";
|
||||
await editor.fill(prompt);
|
||||
await editor.press("Enter");
|
||||
await expect(page.getByText(/本地草稿已保留/).first()).toBeVisible();
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
await expect(editor).toHaveText(prompt);
|
||||
await page.reload();
|
||||
await expect(editor).toHaveText(prompt);
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Hi, 同步验证/ })).toBeVisible();
|
||||
state.failSave = false;
|
||||
await expect(page.getByRole("button", { name: "发送", exact: true })).toBeEnabled();
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("conflicting edits keep both canvases and do not submit an Agent task", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.conflictSameNode = true;
|
||||
state.conflictNextSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("请处理这张图片");
|
||||
await editor.press("Enter");
|
||||
await expect(page.getByText(/画布同步发生冲突/).first()).toBeVisible();
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(state.canvasData.nodes[0].position.x).toBe(500);
|
||||
const draft = await page.evaluate(id => JSON.parse(localStorage.getItem(`canvas-unsynced:${id}`) || "null"), projectId);
|
||||
expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(JSON.parse(draft.snapshot).canvasData.nodes[0].position.x).not.toBe(500);
|
||||
await expect(editor).toHaveText("请处理这张图片");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const choice of ["local", "remote"] as const) {
|
||||
test(`an existing conflicting draft can keep the ${choice} edit and resume without losing other images`, async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
await moveImage(page);
|
||||
state.conflictSameNode = true;
|
||||
state.conflictNextSave = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("继续分析图片");
|
||||
await editor.press("Enter");
|
||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toBeVisible();
|
||||
state.canvasData.nodes.push(imageNode("remote-addition"));
|
||||
state.revision++;
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-canvas-node-id="remote-addition"]')).toBeVisible();
|
||||
if (choice === "local") {
|
||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("conflict-recovery-mobile.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
}
|
||||
await page.getByRole("button", { name: choice === "local" ? "保留本页修改" : "保留云端修改", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "保留本页修改", exact: true })).toHaveCount(0);
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
const x = state.canvasData.nodes.find(node => node.id === "source")!.position.x;
|
||||
if (choice === "remote") expect(x).toBe(500);
|
||||
else { expect(x).toBeGreaterThan(160); expect(x).not.toBe(500); }
|
||||
expect(state.canvasData.nodes.map(node => node.id)).toEqual(["source", "remote-addition"]);
|
||||
expect(state.capabilitySubmissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("a failed canvas read cannot create or save an empty replacement", async ({ page }) => {
|
||||
const { state, errors } = await setup(page, true);
|
||||
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
||||
expect(state.writes).toHaveLength(0);
|
||||
state.failRead = false;
|
||||
await page.getByRole("button", { name: "重新加载画布" }).click();
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
expect(state.canvasData.nodes).toHaveLength(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
async function showUpdate(page: Page, state: { appVersion: string }) {
|
||||
state.appVersion = "20261106T120000Z-nextrelease1";
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
|
||||
}
|
||||
|
||||
async function countRefreshes(page: Page, state: { appVersion: string }) {
|
||||
let navigations = 0;
|
||||
await page.route(url => url.pathname === `/canvas/${projectId}`, route => {
|
||||
if (route.request().isNavigationRequest()) { navigations++; state.appVersion = "development"; }
|
||||
return route.continue();
|
||||
});
|
||||
return () => navigations;
|
||||
}
|
||||
|
||||
test("unfinished image uploads remain on the page even after their canvas placeholder was saved", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.holdUpload = true;
|
||||
await page.mouse.click(100, 90);
|
||||
await page.evaluate(() => {
|
||||
const bytes = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2d0AAAAASUVORK5CYII="), c => c.charCodeAt(0));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([bytes], "上传保护.png", { type: "image/png" }));
|
||||
window.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
||||
await expect.poll(() => state.canvasData.nodes.filter(node => node.data.src === "").length).toBe(1);
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
await page.getByRole("button", { name: "返回首页", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "离开前保存画布?" });
|
||||
await dialog.getByRole("button", { name: "保存并离开", exact: true }).click();
|
||||
await expect(dialog.getByRole("alert")).toContainText("还有图片未上传成功");
|
||||
await dialog.getByRole("button", { name: "继续编辑", exact: true }).click();
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("还有图片未上传成功");
|
||||
expect(refreshes()).toBe(0);
|
||||
state.holdUpload = false;
|
||||
state.finishUpload?.();
|
||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
expect(state.canvasData.nodes).toHaveLength(2);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a composer upload blocks release refresh and closing until the image and input are retained", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.holdUpload = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("上传后保留这条输入");
|
||||
await editor.evaluate(element => {
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "聊天上传保护.png", { type: "image/png" }));
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect.poll(() => state.uploadStarted).toBeGreaterThan(0);
|
||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||||
expect(await unloadWouldBeBlocked(page)).toBe(true);
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("素材仍在上传");
|
||||
expect(refreshes()).toBe(0);
|
||||
state.holdUpload = false;
|
||||
state.finishUpload?.();
|
||||
await expect.poll(() => state.canvasData.nodes.some(node => node.data.src.includes("uploaded.png"))).toBe(true);
|
||||
await expect(page.getByText("聊天上传保护.png", { exact: true })).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
await expect(editor).toContainText("上传后保留这条输入");
|
||||
await expect(editor.getByText("聊天上传保护.png", { exact: true })).toBeVisible();
|
||||
expect(state.canvasData.nodes).toHaveLength(2);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an older browser without AbortSignal static helpers receives the release notice", async ({ page }) => {
|
||||
await page.context().addInitScript(() => {
|
||||
Object.defineProperty(AbortSignal, "any", { value: undefined, configurable: true });
|
||||
Object.defineProperty(AbortSignal, "timeout", { value: undefined, configurable: true });
|
||||
});
|
||||
const { state, errors } = await setup(page);
|
||||
await showUpdate(page, state);
|
||||
await expect(page.getByRole("button", { name: "同步并刷新", exact: true })).toBeVisible();
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a release is detected by the minute timer without requiring a focus change", async ({ page }) => {
|
||||
await page.clock.install();
|
||||
const { state, errors } = await setup(page);
|
||||
state.appVersion = "20261106T120000Z-nextrelease1";
|
||||
await page.clock.fastForward(60_001);
|
||||
await expect(page.getByRole("alertdialog")).toContainText("平台已更新");
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a new release requires confirmation and refreshes after preserving canvas and input", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
await moveImage(page);
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("刷新后还要保留这条输入");
|
||||
await showUpdate(page, state);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible();
|
||||
expect(refreshes()).toBe(0);
|
||||
await page.screenshot({ path: test.info().outputPath("update-notice-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("update-notice-mobile.png"), animations: "disabled" });
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
await expect(editor).toHaveText("刷新后还要保留这条输入");
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an update cannot interrupt or resubmit a running Agent task", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.holdRequest = true;
|
||||
const editor = page.getByLabel("描述你的设计需求", { exact: true });
|
||||
await editor.fill("先分析这张图片");
|
||||
await editor.press("Enter");
|
||||
await expect.poll(() => state.submissions.length).toBe(1);
|
||||
await showUpdate(page, state);
|
||||
const saves = state.writes.length;
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("当前对话任务仍在处理中");
|
||||
expect(refreshes()).toBe(0);
|
||||
expect(state.writes).toHaveLength(saves);
|
||||
state.finishRequest?.();
|
||||
await expect(editor).toBeEditable();
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
expect(state.submissions).toHaveLength(1);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("an update keeps the current page when automatic synchronization fails", async ({ page }) => {
|
||||
const { state, errors } = await setup(page);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
state.failSave = true;
|
||||
await moveImage(page);
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toContainText("HTTP 503");
|
||||
await expect(page.getByRole("alertdialog")).not.toContainText("本次任务未提交");
|
||||
await expect(page.getByRole("alertdialog")).not.toContainText("再次发送");
|
||||
expect(refreshes()).toBe(0);
|
||||
expect(state.submissions).toHaveLength(0);
|
||||
await page.screenshot({ path: test.info().outputPath("update-error-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.screenshot({ path: test.info().outputPath("update-error-mobile.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.getByRole("button", { name: "返回处理", exact: true }).click();
|
||||
await expect(page.getByRole("alertdialog")).toHaveCount(0);
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
state.failSave = false;
|
||||
await showUpdate(page, state);
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
expect(state.canvasData.nodes[0].position.x).toBeGreaterThan(160);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a release refresh never saves an empty canvas after its initial read failed", async ({ page }) => {
|
||||
const { state, errors } = await setup(page, true);
|
||||
const refreshes = await countRefreshes(page, state);
|
||||
await expect(page.getByRole("alert").filter({ hasText: "画布暂时无法读取" })).toBeVisible();
|
||||
await showUpdate(page, state);
|
||||
state.failRead = false;
|
||||
await page.getByRole("button", { name: "同步并刷新", exact: true }).click();
|
||||
await expect.poll(refreshes).toBe(1);
|
||||
await expect(page.locator('[data-canvas-node-id="source"]')).toBeVisible();
|
||||
expect(state.canvasData.nodes).toHaveLength(1);
|
||||
expect(state.writes.filter(body => body.canvasData?.nodes.length === 0)).toHaveLength(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
198
tests/e2e/canvas-chat-scroll.test.ts
Normal file
198
tests/e2e/canvas-chat-scroll.test.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const projectId="f1827b8f-b810-44d8-9927-c2ef255eed78";
|
||||
const paragraph="保持产品结构与材质一致,先分析参考图片,再安排场景与光线,逐项确认处理要求。";
|
||||
const makeMessage=(id:string,role:string,text:string,offset:number)=>({id,role,parts:[{type:"text",text}],createdAt:new Date(Date.UTC(2026,10,6,0,0,offset)).toISOString()});
|
||||
|
||||
async function setup(page:Page) {
|
||||
const state={history:Array.from({length:16},(_,i)=>makeMessage(`history-${i}`,i%2?"assistant":"user",`历史记录 ${i+1}\n\n${new Array(4).fill(paragraph).join("\n\n")}`,i)),historyReads:0,run:null as any};
|
||||
const errors:string[]=[];page.on("pageerror",error=>errors.push(error.message));
|
||||
await page.context().addInitScript(()=>{
|
||||
localStorage.setItem("theme","light");
|
||||
const originalFetch=window.fetch.bind(window);
|
||||
(window as any).agentScrollStream={};
|
||||
window.fetch=async(input,init)=>{
|
||||
const url=typeof input==="string"?input:input instanceof URL?input.href:input.url;
|
||||
if(new URL(url,location.origin).pathname!=="/api/canvas/agent-chat"||init?.method!=="POST")return originalFetch(input,init);
|
||||
const request=JSON.parse(String(init.body));
|
||||
const stream=(window as any).agentScrollStream;
|
||||
stream.request=request;stream.text="";
|
||||
const encoder=new TextEncoder();
|
||||
return new Response(new ReadableStream({start(controller){
|
||||
const emit=(value:unknown)=>controller.enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`));
|
||||
emit({type:"start",messageId:"stream-assistant"});emit({type:"text-start",id:"text"});
|
||||
stream.append=(text:string)=>{stream.text+=text;emit({type:"text-delta",id:"text",delta:text});};
|
||||
stream.finish=()=>{emit({type:"text-end",id:"text"});emit({type:"finish"});controller.enqueue(encoder.encode("data: [DONE]\n\n"));controller.close();};
|
||||
}}),{headers:{"Content-Type":"text/event-stream","x-vercel-ai-ui-message-stream":"v1"}});
|
||||
};
|
||||
});
|
||||
await page.context().route("**/api/**",async route=>{
|
||||
const request=route.request(),url=new URL(request.url()),path=url.pathname;
|
||||
let data:unknown={};
|
||||
if(path===`/api/canvas/${projectId}`)data={success:true,data:{id:projectId,title:"滚动验证",updatedAt:"2026-11-06T00:00:00.000Z",canvasData:{nodes:[],edges:[],viewport:{x:0,y:0,zoom:1}}}};
|
||||
else if(path==="/api/auth/session")data={user:{id:"scroll-user",name:"滚动验证",type:"admin"},expires:"2099-01-01"};
|
||||
else if(path.includes("validate-session"))data={success:true,valid:true};
|
||||
else if(path==="/api/site-settings")data={canvasEnabled:true,agentEnabled:true,membershipEnabled:false};
|
||||
else if(path==="/api/app-version")data={version:"development"};
|
||||
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}]};
|
||||
else if(path==="/api/vote")data=[];
|
||||
else if(path==="/api/canvas/agent-runs")data={run:state.run};
|
||||
else if(path.includes("messages")){state.historyReads++;data={messages:url.searchParams.get("chatId")===projectId?state.history:[]};}
|
||||
else if(path.includes("sessions"))data={sessions:[]};
|
||||
else if(path.startsWith("/api/canvas/"))data={success:true,runs:[]};
|
||||
await route.fulfill({contentType:"application/json",body:JSON.stringify(data)});
|
||||
});
|
||||
await page.goto(`/canvas/${projectId}`,{waitUntil:"domcontentloaded"});
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录 16");
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
return {state,errors};
|
||||
}
|
||||
const metrics=(page:Page)=>page.getByTestId("canvas-chat-messages").evaluate(element=>({top:element.scrollTop,bottom:element.scrollHeight-element.clientHeight-element.scrollTop}));
|
||||
async function startStream(page:Page){
|
||||
const editor=page.getByLabel("描述你的设计需求",{exact:true});await editor.fill("先分析和规划,再处理图片。");await editor.press("Enter");
|
||||
await expect.poll(()=>page.evaluate(()=>Boolean((window as any).agentScrollStream.append))).toBe(true);
|
||||
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`开始分析\n\n${new Array(8).fill(paragraph).join("\n\n")}\n\n首次输出已显示\n\n`);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("首次输出已显示");
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
}
|
||||
async function scrollUp(page:Page){
|
||||
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
||||
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-650);
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(350);
|
||||
await page.waitForTimeout(100);
|
||||
return (await metrics(page)).top;
|
||||
}
|
||||
|
||||
test("streaming Agent output preserves the position after the reader scrolls up",async({page})=>{
|
||||
const {errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
|
||||
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`新增分析步骤\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("新增分析步骤");
|
||||
await page.waitForTimeout(400);
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("returning to the latest message resumes following streamed output",async({page})=>{
|
||||
const {errors}=await setup(page);await startStream(page);await scrollUp(page);
|
||||
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
|
||||
await page.screenshot({path:test.info().outputPath("reading-history-desktop.png"),animations:"disabled"});
|
||||
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`继续处理\n\n${new Array(5).fill(paragraph).join("\n\n")}`);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("继续处理");
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
await scrollUp(page);
|
||||
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
||||
await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,20000);
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
await page.evaluate(text=>(window as any).agentScrollStream.append(text),`已回到最新消息\n\n${paragraph}`);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("已回到最新消息");
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("finishing a streamed request and reloading its history preserve the reader and viewport",async({page})=>{
|
||||
const {state,errors}=await setup(page);await startStream(page);const top=await scrollUp(page);
|
||||
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
|
||||
const stream=await page.evaluate(()=>({request:(window as any).agentScrollStream.request,text:(window as any).agentScrollStream.text}));
|
||||
state.history.push(stream.request.message,{...makeMessage("stream-assistant","assistant",stream.text,100),createdAt:new Date(Date.now()+100).toISOString()});
|
||||
const reads=state.historyReads;
|
||||
await page.evaluate(()=>(window as any).agentScrollStream.finish());
|
||||
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
|
||||
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
|
||||
await page.waitForTimeout(3800);
|
||||
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("background Agent progress restores text in the original chat without taking over scrolling",async({page})=>{
|
||||
const {state,errors}=await setup(page);const top=await scrollUp(page);
|
||||
const text=state.history.at(-1)!.parts[0].text+"\n\n后台任务继续分析参考图片。";
|
||||
state.run={id:"native-scroll-run",chatId:projectId,projectId,assistantMessageId:state.history.at(-1)!.id,assistantText:text,status:"running",message:"正在分析图片",steps:2,toolCount:2,resultNodeIds:[],inputs:[],updatedAt:new Date().toISOString(),cancelRequested:false,resumable:false};
|
||||
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务继续分析参考图片。");
|
||||
await page.waitForTimeout(100);
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
state.run={...state.run,status:"completed",assistantText:text+"\n\n后台任务处理结束。"};
|
||||
await page.evaluate(run=>window.dispatchEvent(new CustomEvent("canvas-agent-run:update",{detail:run})),state.run);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("后台任务处理结束。");
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("background history refresh preserves the same scroll container and reading position",async({page})=>{
|
||||
await page.clock.install();
|
||||
const {state,errors}=await setup(page);const top=await scrollUp(page);
|
||||
await page.getByTestId("canvas-chat-messages").evaluate(element=>(window as any).readingViewport=element);
|
||||
state.history.at(-1)!.parts[0].text+="\n\n历史记录已从服务端更新。";
|
||||
const reads=state.historyReads;
|
||||
await page.clock.fastForward(11000);
|
||||
await page.evaluate(()=>window.dispatchEvent(new Event("focus")));
|
||||
await expect.poll(()=>state.historyReads).toBeGreaterThan(reads);
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("历史记录已从服务端更新。");
|
||||
expect(await page.getByTestId("canvas-chat-messages").evaluate(element=>element===(window as any).readingViewport)).toBe(true);
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("a generated preview finishing its load does not pull a reader back to the bottom",async({page})=>{
|
||||
let ready=false;let requests=0;const waiting:Array<()=>void>=[];
|
||||
await page.context().route('https://scqnycdn.jiansun.vip/**',async route=>{
|
||||
requests++;
|
||||
if(!ready)await new Promise<void>(resolve=>waiting.push(resolve));
|
||||
await route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1200"><rect width="800" height="1200" fill="#43b9bd"/></svg>'});
|
||||
});
|
||||
const {errors}=await setup(page);await startStream(page);
|
||||
await page.evaluate(()=>(window as any).agentScrollStream.append('\n\n\n\n'));
|
||||
await expect.poll(()=>requests).toBeGreaterThan(0);
|
||||
const top=await scrollUp(page);ready=true;waiting.forEach(release=>release());
|
||||
await expect.poll(()=>page.getByTestId('canvas-chat-messages').locator('img').evaluateAll(images=>images.some(image=>(image as HTMLImageElement).naturalWidth>0))).toBe(true);
|
||||
await page.waitForTimeout(200);
|
||||
expect(Math.abs((await metrics(page)).top-top)).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyboard scrolling pauses following and a new conversation starts at its own latest message",async({page})=>{
|
||||
const {errors}=await setup(page);await startStream(page);
|
||||
await page.getByTestId("canvas-chat-messages").press("PageUp");
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
|
||||
const top=(await metrics(page)).top;
|
||||
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n键盘上翻后的新进度。"));
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("键盘上翻后的新进度。");
|
||||
await page.waitForTimeout(200);
|
||||
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
|
||||
await page.getByRole("button",{name:"新建聊天窗口",exact:true}).click();
|
||||
await expect(page.getByTestId("canvas-chat-messages")).not.toContainText("历史记录 1");
|
||||
await expect(page.getByLabel("描述你的设计需求",{exact:true})).toBeEditable();
|
||||
await startStream(page);
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test.describe("mobile touch reading",()=>{
|
||||
test.use({viewport:{width:390,height:844},isMobile:true,hasTouch:true});
|
||||
test("touch scrolling keeps history readable while the Agent continues streaming",async({page})=>{
|
||||
const {errors}=await setup(page);await startStream(page);
|
||||
const box=(await page.getByTestId("canvas-chat-messages").boundingBox())!;
|
||||
const cdp=await page.context().newCDPSession(page);
|
||||
const x=box.x+box.width/2,y=box.y+70,delta=Math.min(260,box.height-120);
|
||||
await cdp.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x,y}]});
|
||||
for(let i=1;i<=8;i++){
|
||||
await cdp.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x,y:y+delta*i/8}]});
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
await cdp.send("Input.dispatchTouchEvent",{type:"touchEnd",touchPoints:[]});
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeGreaterThan(100);
|
||||
const top=(await metrics(page)).top;
|
||||
await page.evaluate(()=>(window as any).agentScrollStream.append("\n\n手机查看历史时的新进度。"));
|
||||
await expect(page.getByTestId("canvas-chat-messages")).toContainText("手机查看历史时的新进度。");
|
||||
await page.waitForTimeout(300);
|
||||
expect((await metrics(page)).top).toBeLessThanOrEqual(top+4);
|
||||
await expect(page.getByRole("button",{name:"回到最新消息",exact:true})).toBeVisible();
|
||||
await page.screenshot({path:test.info().outputPath("reading-history-mobile.png"),animations:"disabled"});
|
||||
await page.getByRole("button",{name:"回到最新消息",exact:true}).click();
|
||||
await expect.poll(async()=>(await metrics(page)).bottom).toBeLessThan(4);
|
||||
expect(errors).toEqual([]);await cdp.detach();
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
@ -50,9 +50,11 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
|
||||
setImmediate,
|
||||
AbortSignal,
|
||||
AbortController,
|
||||
structuredClone,
|
||||
ReadableStream,
|
||||
Headers,
|
||||
Blob,
|
||||
FormData,
|
||||
Request,
|
||||
Date: mocks.Date || Date,
|
||||
document: mocks.document,
|
||||
|
||||
@ -127,7 +127,9 @@ test("Agent cannot open the retired angle panel through a generic UI command", a
|
||||
const events: unknown[] = [], definitions: any = {}, executable: any = {};
|
||||
const load = serverLoader({ "@/lib/canvas/agent-mutations": {}, "./canvas": {} });
|
||||
load("lib/ai/canvas-agent-runtime/image-access-tools.ts").registerImageCanvasAccessTools({ userId: "u", projectId: "p", runId: "r", selectedNodeIds: ["reference"], definitions, executable, emit: (...args: unknown[]) => events.push(args) });
|
||||
await expect(executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel")).rejects.toThrow("图片画布界面入口");
|
||||
const error = await executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel").catch((failure: unknown) => failure);
|
||||
expect(error).toBeInstanceOf(load("lib/ai/canvas-agent-runtime/protocol.ts").AgentToolRejectedError);
|
||||
expect(error.message).toContain("图片画布界面入口");
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => {
|
||||
|
||||
@ -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();
|
||||
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("任务查询失败"), { 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);
|
||||
});
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
let generated = 0;
|
||||
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/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 },
|
||||
|
||||
@ -1,4 +1,15 @@
|
||||
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 {
|
||||
collectMessageImageUrls,
|
||||
extractAssistantImageUrls,
|
||||
@ -797,6 +808,7 @@ test.describe("canvas chat pure helpers", () => {
|
||||
});
|
||||
|
||||
test("canvas service save uses store revision fallback when options are omitted", async () => {
|
||||
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
|
||||
const canvasData = {
|
||||
nodes: [],
|
||||
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 = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
@ -904,6 +916,7 @@ test.describe("canvas chat pure helpers", () => {
|
||||
},
|
||||
];
|
||||
|
||||
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
|
||||
useCanvasStore.getState().setCanvasRevision(null, null);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
@ -918,7 +931,11 @@ test.describe("canvas chat pure helpers", () => {
|
||||
}) as Response) as typeof fetch;
|
||||
|
||||
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(
|
||||
"2026-06-22T00:01:00.000Z"
|
||||
);
|
||||
|
||||
@ -236,8 +236,6 @@ test.describe("canvas scale adjust product lock", () => {
|
||||
const rmbg = readSource("lib/ai/rmbg/service.ts");
|
||||
|
||||
expect(productLock).toContain('foregroundType: "person"');
|
||||
expect(productLock).toContain("attempt <= 3");
|
||||
expect(productLock).toContain("executeScaleAdjustRmbgJob({");
|
||||
expect(productLock).toContain("regionPersonExclusionAlpha");
|
||||
expect(productLock).toContain("overlapPixelCount >= overlapLimit");
|
||||
expect(productLock).toContain("目标产品被人物遮挡");
|
||||
|
||||
@ -4,6 +4,7 @@ import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capa
|
||||
import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { canvasService } from "@/services/canvas";
|
||||
import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
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"]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = (async (url) => {
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
globalThis.fetch = (async (url) => {
|
||||
if (String(url).endsWith("/capabilities")) {
|
||||
|
||||
@ -7,6 +7,9 @@ import { randomUUID } from "node:crypto";
|
||||
import JSZip from "jszip";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range";
|
||||
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const runtimeWork = { trackRuntimeWork: async (run: () => Promise<void>) => run() };
|
||||
|
||||
test("ranges reject invalid offsets and preserve normal, open and suffix byte ranges", () => {
|
||||
expect(parseDownloadByteRange("bytes=0-3", 20)).toEqual({start:0,end:3});
|
||||
@ -20,7 +23,7 @@ test("multi-select ZIP admission is immediate; complete archives survive restart
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-test-')); let owner:string|null='owner';let fetches=0;
|
||||
const callbacks:Array<()=>Promise<void>>=[];
|
||||
const mocks={
|
||||
'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null},
|
||||
'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null},
|
||||
'next/server': {NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>{
|
||||
fetches++;return {ok:true,filename:`${index}-花瓶.png`,bytes:Buffer.from(`image-${index}`),contentType:'image/png'};
|
||||
@ -56,7 +59,7 @@ test("multi-select ZIP admission is immediate; complete archives survive restart
|
||||
test("all-source failures never produce an empty successful ZIP; retries reuse the task and resolve sources again", async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-retry-'));const callbacks:Array<()=>Promise<void>>=[];let available=false;
|
||||
const route=serverLoader({
|
||||
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>available?{ok:true,filename:'one.png',bytes:Buffer.from('one'),contentType:'image/png'}:{ok:false,error:'expired provider URL'}},
|
||||
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
|
||||
@ -108,7 +111,7 @@ test("the browser polls a complete ZIP, preserves an idempotent task after a los
|
||||
test("partial source failures are explicit; CDN redirects retain an authenticated local range fallback",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-partial-'));const callbacks:Array<()=>Promise<void>>=[];let owner='owner';
|
||||
const route=serverLoader({
|
||||
'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})},
|
||||
'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})},
|
||||
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>index?{ok:false,error:'source expired'}:{ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'}},
|
||||
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>({url:'https://cdn.example/complete.zip',key:'canvas-downloads/x.zip',storageConfigId:'config'})},
|
||||
@ -128,7 +131,7 @@ test("partial source failures are explicit; CDN redirects retain an authenticate
|
||||
test("recovery validates staged files instead of treating missing bytes as successful images",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-missing-'));const callbacks:Array<()=>Promise<void>>=[];const id=randomUUID();
|
||||
const load=serverLoader({
|
||||
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>({ok:false,error:'source expired'})},
|
||||
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null},
|
||||
@ -157,7 +160,7 @@ test("large ZIP writing checks current free space as staged input bytes are rele
|
||||
test("slow expired-object cleanup cannot keep the ZIP admission request open",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cleanup-'));const callbacks:Array<()=>Promise<void>>=[];
|
||||
const mocks:Record<string,unknown>={
|
||||
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{},'@/lib/storage/canvas-zip-delivery':{},
|
||||
};
|
||||
@ -169,3 +172,127 @@ test("slow expired-object cleanup cannot keep the ZIP admission request open",as
|
||||
expect(result).not.toBe('blocked');expect((result as Response).status).toBe(202);
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test("large ZIPs use bounded parallel parts and publish only the complete ordered archive", async () => {
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-parts-'));
|
||||
const bytes=Buffer.alloc(25*1024**2+19,0x5a);bytes[16*1024**2]=0xff;
|
||||
const file=join(dir,'large.zip');await writeFile(file,bytes);
|
||||
const parts=new Map<number,Buffer>();let inFlight=0;let peak=0;let finished=false;let destroyed=false;let trailers='';
|
||||
const progress:number[]=[];
|
||||
const client={config:{requestChecksumCalculation:async()=>''},destroy(){destroyed=true;},async send(command:any){
|
||||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||||
if(command instanceof CreateMultipartUploadCommand){expect(command.input.ContentDisposition).toContain('attachment');return {UploadId:'upload'};}
|
||||
if(command instanceof UploadPartCommand){
|
||||
const input=command.input;inFlight++;peak=Math.max(peak,inFlight);expect(input.ContentLength).toBeLessThanOrEqual(8*1024**2);
|
||||
await new Promise(resolve=>setTimeout(resolve,input.PartNumber===1?30:20));
|
||||
expect(Buffer.isBuffer(input.Body)).toBe(true);
|
||||
parts.set(input.PartNumber!,Buffer.from(input.Body as Buffer));inFlight--;return {ETag:`part-${input.PartNumber}`};
|
||||
}
|
||||
expect(command).toBeInstanceOf(CompleteMultipartUploadCommand);
|
||||
expect(command.input.MultipartUpload.Parts.map((p:any)=>p.PartNumber)).toEqual([1,2,3,4]);
|
||||
expect(Buffer.concat(command.input.MultipartUpload.Parts.map((p:any)=>parts.get(p.PartNumber)!)).equals(bytes)).toBe(true);
|
||||
finished=true;trailers=await client.config.requestChecksumCalculation();return {};
|
||||
}};
|
||||
const publish=serverLoader({
|
||||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||||
fetch:async(url:string,init:any)=>{expect(finished).toBe(true);expect(init.method).toBe('HEAD');expect(new URL(url).searchParams.get('v')).toBe(String(bytes.length));return new Response(null,{headers:{'content-length':String(bytes.length)}});},
|
||||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||||
try{
|
||||
const result=await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="large.zip"',onProgress:async(n:number)=>{progress.push(n);}});
|
||||
expect(result.url).toContain('https://cdn.example/canvas-downloads/');expect(peak).toBeGreaterThan(1);expect(peak).toBeLessThanOrEqual(3);expect(progress.at(-1)).toBe(bytes.length);
|
||||
expect(progress).toEqual([...progress].sort((a,b)=>a-b));expect(trailers).toBe('WHEN_REQUIRED');expect(destroyed).toBe(true);
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test("failed multipart uploads abort their parts and retain the original ZIP with actionable advice",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-abort-'));const file=join(dir,'large.zip');const bytes=17*1024**2;
|
||||
const handle=await (await import('node:fs/promises')).open(file,'w');await handle.truncate(bytes);await handle.close();
|
||||
let aborted=false;let completed=false;
|
||||
const client={config:{},destroy(){},async send(command:any){
|
||||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||||
if(command instanceof CreateMultipartUploadCommand)return {UploadId:'upload'};
|
||||
if(command instanceof UploadPartCommand){if(command.input.PartNumber===2)throw new DOMException('part timeout','TimeoutError');return {ETag:'part'};}
|
||||
if(command instanceof AbortMultipartUploadCommand){aborted=true;return {};}
|
||||
completed=true;return {};
|
||||
}};
|
||||
const publish=serverLoader({
|
||||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client},
|
||||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||||
try{
|
||||
const result=await publish({id:randomUUID(),path:file,bytes,disposition:'attachment'});
|
||||
expect(result.url).toBeNull();expect(result.error).toContain('传送至七牛时超时');expect(result.error).toContain('继续传送原 ZIP');
|
||||
expect(aborted).toBe(true);expect(completed).toBe(false);expect((await (await import('node:fs/promises')).stat(file)).size).toBe(bytes);
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test("retrying CDN verification reuses an already uploaded ZIP without uploading it again",async()=>{
|
||||
let uploads=0;let available=false;
|
||||
const client={config:{},destroy(){},async send(command:any){
|
||||
if(command instanceof HeadObjectCommand)return {ContentLength:100};
|
||||
if(command instanceof PutObjectCommand||command instanceof CreateMultipartUploadCommand)uploads++;
|
||||
throw new Error('Unexpected upload');
|
||||
}};
|
||||
const publish=serverLoader({
|
||||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||||
fetch:async()=>new Response(null,{status:available?200:404,headers:{'content-length':available?'100':'0'}}),
|
||||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||||
const params={id:randomUUID(),path:'unused-already-published.zip',bytes:100,disposition:'attachment'};
|
||||
const first=await publish(params);expect(first.url).toBeNull();expect(first.error).toContain('七牛下载链接尚未可用');
|
||||
available=true;expect((await publish(params)).url).toContain('https://cdn.example/');expect(uploads).toBe(0);
|
||||
});
|
||||
|
||||
test("CDN failures never silently start local downloads; retries reuse the ZIP and keep it resumable beyond one hour",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cdn-retry-'));const callbacks:Array<()=>Promise<void>>=[];
|
||||
let fetches=0;let publications=0;let available=false;let now=Date.now();
|
||||
let activeWork=0;
|
||||
const runtimeWork={trackRuntimeWork:async(run:()=>Promise<void>)=>{activeWork++;try{return await run();}finally{activeWork--;}}};
|
||||
class Clock extends Date{static now(){return now;}}
|
||||
const load=serverLoader({
|
||||
Date:Clock,'@/lib/observability/active-work':runtimeWork,'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
|
||||
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
|
||||
'@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>{fetches++;return {ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'};}},
|
||||
'@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>{expect(activeWork).toBe(1);publications++;return {url:available?'https://cdn.example/complete.zip':null,key:'canvas-downloads/key.zip',storageConfigId:'storage',error:'ZIP 已打包,传送至七牛时超时;请再次点击下载继续原 ZIP,不会重新生成图片。'};},removeCanvasZipObject:async()=>{}},
|
||||
},{CANVAS_DOWNLOAD_ZIP_DIR:dir});
|
||||
const route=load('app/api/canvas/download-zip-task/route.ts');const id=randomUUID();
|
||||
const post=()=>new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,retry:true,files:[{url:'https://cdn.example/original.png'}]})});
|
||||
const request=(suffix='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${suffix}`,{headers:{range:'bytes=0-3'}});
|
||||
try{
|
||||
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||||
const failed=(await(await route.GET(request())).json()).task;
|
||||
expect(failed).toMatchObject({id,status:'failed',successCount:1,downloadUrl:''});expect(failed.error).toContain('不会重新生成图片');
|
||||
expect((await route.GET(request('&download=1'))).status).toBe(409);
|
||||
const before=await readFile(join(dir,id,'task.json'),'utf8');const original=Buffer.from(await readFile(JSON.parse(before).zipFile.path));
|
||||
available=true;await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||||
expect(fetches).toBe(1);expect(publications).toBe(2);
|
||||
const manifest=JSON.parse(await readFile(join(dir,id,'task.json'),'utf8'));expect(await readFile(manifest.zipFile.path)).toEqual(original);
|
||||
delete manifest.cdnUrl;
|
||||
await load('lib/canvas/download-zip-task-store.ts').saveZipTask(manifest);
|
||||
await route.POST(post());while(callbacks.length)await callbacks.shift()!();
|
||||
expect(fetches).toBe(1);expect(publications).toBe(3);
|
||||
expect(activeWork).toBe(0);
|
||||
now+=2*60*60_000;
|
||||
expect((await route.GET(request('&download=1'))).status).toBe(307);
|
||||
const local=await route.GET(request('&download=1&local=1'));expect(local.status).toBe(206);expect(Buffer.from(await local.arrayBuffer())).toEqual(original.subarray(0,4));
|
||||
}finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
test("small ZIP delivery preserves the complete file and download filename",async()=>{
|
||||
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-small-'));const file=join(dir,'small.zip');const bytes=Buffer.from('PK small complete archive');await writeFile(file,bytes);
|
||||
let written=false;
|
||||
const client={config:{},destroy(){},async send(command:any){
|
||||
if(command instanceof HeadObjectCommand)throw Object.assign(new Error('missing'),{$metadata:{httpStatusCode:404}});
|
||||
expect(command).toBeInstanceOf(PutObjectCommand);expect(command.input.ContentLength).toBe(bytes.length);expect(command.input.ContentDisposition).toContain('small.zip');
|
||||
const chunks:Buffer[]=[];for await(const chunk of command.input.Body)chunks.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(chunks).equals(bytes)).toBe(true);written=true;return {};
|
||||
}};
|
||||
const publish=serverLoader({
|
||||
'@/lib/db/queries':{getDefaultStorageConfig:async()=>({id:'storage'})},
|
||||
'./service':{toDbS3UploadConfig:()=>({provider:'qiniu-kodo',bucket:'bucket',publicUrl:'https://cdn.example'}),createS3Client:()=>client,buildS3PublicUrl:(_:any,key:string)=>'https://cdn.example/'+key},
|
||||
fetch:async()=>{expect(written).toBe(true);return new Response(null,{headers:{'content-length':String(bytes.length)}});},
|
||||
})('lib/storage/canvas-zip-delivery.ts').publishCanvasZip;
|
||||
try{expect((await publish({id:randomUUID(),path:file,bytes:bytes.length,disposition:'attachment; filename="small.zip"'})).url).toContain('https://cdn.example/');}
|
||||
finally{await rm(dir,{recursive:true,force:true});}
|
||||
});
|
||||
|
||||
@ -2,9 +2,77 @@ import { expect, test } from "@playwright/test";
|
||||
import { NextRequest } from "next/server";
|
||||
import sharp from "sharp";
|
||||
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("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 () => {
|
||||
const previousFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(new ReadableStream({
|
||||
|
||||
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,7 @@ test("known provider and platform failures explain their cause and next action",
|
||||
["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||
["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"],
|
||||
["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"],
|
||||
["HTTP 410: 旧存储已停用,源文件已无法恢复,请重新上传。", "SOURCE_STORAGE_RETIRED", "重新上传"],
|
||||
["抠图处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||
["去水印处理失败", "PROCESSING_FAILED", "核对原任务"],
|
||||
["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"],
|
||||
@ -68,6 +69,23 @@ 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);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(getRequestFailureDetails(Object.assign(new Error("This operation was aborted"), { name: "AbortError" })).message).toContain("连接已中止");
|
||||
expect(getRequestFailureDetails("This operation was aborted").message).toContain("连接已中止");
|
||||
expect(getRequestFailureDetails(Object.assign(new Error("timed out"), { name: "TimeoutError" })).message).toContain("等待超时");
|
||||
});
|
||||
|
||||
test("the provider submits once when an HTTP 524 request cannot be located", async () => {
|
||||
let submissions = 0;
|
||||
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" });
|
||||
});
|
||||
@ -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 () => {
|
||||
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'}}));
|
||||
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);
|
||||
});
|
||||
|
||||
test('byte cache never fetches retired resources', async () => {
|
||||
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');
|
||||
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 () => {
|
||||
let inserts = 0;
|
||||
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/storage/image-archive-db.ts');
|
||||
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" });
|
||||
});
|
||||
@ -28,3 +28,24 @@ test("durable archive handoff is separate from unfinished original generation",
|
||||
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]);
|
||||
});
|
||||
|
||||
96
tests/routes/zcode-agent-task.test.ts
Normal file
96
tests/routes/zcode-agent-task.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { AgentRunSnapshot } from "@/lib/ai/canvas-agent-runtime/protocol";
|
||||
import type { runZcodeCanvasTask } from "@/lib/ai/zcode-agent/task";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
function fixture() {
|
||||
const load = serverLoader({ "./native-runtime": {} });
|
||||
const run: typeof runZcodeCanvasTask = load("lib/ai/zcode-agent/task.ts").runZcodeCanvasTask;
|
||||
const snapshot: AgentRunSnapshot = load("lib/ai/canvas-agent-runtime/protocol.ts").newAgentRunSnapshot("替换图片中的底座", []);
|
||||
const ports: Parameters<typeof runZcodeCanvasTask>[0] = {
|
||||
runId: "original-request", snapshot, instructions: "", context: {},
|
||||
gateway: { modelId: "test", apiType: "openai-chat-completions", forward: async () => Response.json({}) },
|
||||
signal: new AbortController().signal,
|
||||
definitions: {
|
||||
planCanvasTask: tool({ inputSchema: z.object({}) }),
|
||||
generateCanvasImages: tool({ inputSchema: z.object({ referenceNodeIds: z.array(z.string()) }) }),
|
||||
},
|
||||
tools: {
|
||||
planCanvasTask: { title: "安排步骤", readOnly: true, concurrentSafe: true, execute: async () => ({ steps: ["替换底座"] }) },
|
||||
generateCanvasImages: { title: "生成图片", readOnly: false, concurrentSafe: false, execute: async () => ({ createdNodeIds: ["repaired-result"] }) },
|
||||
},
|
||||
checkpoint: async () => undefined,
|
||||
consumeInputs: async () => ({ cancelled: false }), isCancelled: async () => false,
|
||||
verify: async () => ({ passed: true, issues: [] }),
|
||||
};
|
||||
return { ports, run, snapshot };
|
||||
}
|
||||
|
||||
const result = { type: "result", sessionId: "test-session", response: "已完成" };
|
||||
|
||||
test("an unconfirmed paid tool stops the planner and immediately preserves the reason and original request ID", async () => {
|
||||
const { ports, run, snapshot } = fixture();
|
||||
const emitted: string[] = [], saved: string[] = [];
|
||||
let submissions = 0;
|
||||
ports.tools.generateCanvasImages.execute = async () => {
|
||||
submissions++;
|
||||
throw Object.assign(new Error("This operation was aborted"), { name: "AbortError" });
|
||||
};
|
||||
ports.onAssistantText = text => emitted.push(text);
|
||||
ports.checkpoint = async state => { saved.push(state.message); };
|
||||
ports.native = async options => {
|
||||
await options.execute("planCanvasTask", {}, "plan", options.signal!);
|
||||
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "unknown-result", options.signal!)).rejects.toThrow();
|
||||
expect(options.signal!.aborted).toBe(true);
|
||||
expect(saved.at(-1)).toContain("请求编号:original-request");
|
||||
await options.onEvent?.({ type: "model.streaming", sessionId: "test-session", payload: { kind: "text_delta", assistantMessageId: "after-error", delta: "新建对话绕过原任务后再试" } });
|
||||
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original"] }, "duplicate-paid", options.signal!)).rejects.toThrow();
|
||||
throw new Error("planner stopped");
|
||||
};
|
||||
expect(await run(ports)).toBe("needs_review");
|
||||
expect(submissions).toBe(1);
|
||||
expect(emitted).toEqual([]);
|
||||
expect(snapshot.message).toContain("结果尚未确认");
|
||||
expect(snapshot.message).toContain("连接已中止");
|
||||
expect(snapshot.message).toContain("不要换模型、新建对话或重新提交");
|
||||
expect(snapshot.message).toContain(ports.runId);
|
||||
ports.native = async () => { throw new Error("must not start a new attempt"); };
|
||||
expect(await run(ports)).toBe("needs_review");
|
||||
expect(submissions).toBe(1);
|
||||
});
|
||||
|
||||
test("repairing a failed result permits original images as references without admitting another passed target", async () => {
|
||||
const { ports, run, snapshot } = fixture();
|
||||
let submissions = 0;
|
||||
snapshot.verification = { passed: false, issues: ["底座不符"], retryable: true, failedNodeIds: ["failed-result"] };
|
||||
ports.tools.generateCanvasImages.execute = async input => {
|
||||
submissions++;
|
||||
expect(input.referenceNodeIds).toEqual(["failed-result", "original-scene", "original-product"]);
|
||||
return { createdNodeIds: ["repaired-result"] };
|
||||
};
|
||||
ports.native = async options => {
|
||||
await options.execute("planCanvasTask", {}, "plan", options.signal!);
|
||||
await expect(options.execute("generateCanvasImages", { referenceNodeIds: ["original-scene", "passed-result"] }, "wrong-target", options.signal!)).rejects.toThrow("未通过");
|
||||
await options.execute("generateCanvasImages", { referenceNodeIds: ["failed-result", "original-scene", "original-product"] }, "repair-failed", options.signal!);
|
||||
return result;
|
||||
};
|
||||
expect(await run(ports)).toBe("completed");
|
||||
expect(submissions).toBe(1);
|
||||
});
|
||||
|
||||
test("an unresolved image read reports the concrete input failure and recommendation", async () => {
|
||||
const { ports, run, snapshot } = fixture();
|
||||
ports.definitions.readCanvas = tool({ inputSchema: z.object({}) });
|
||||
ports.tools.readCanvas = { title: "读取图片", readOnly: true, concurrentSafe: true, execute: async () => { throw new Error("无法获取图片,请检查图片链接是否有效:fetch failed"); } };
|
||||
ports.native = async options => {
|
||||
await expect(options.execute("readCanvas", {}, "failed-read", options.signal!)).rejects.toThrow();
|
||||
expect(options.signal!.aborted).toBe(false);
|
||||
return result;
|
||||
};
|
||||
expect(await run(ports)).toBe("needs_review");
|
||||
expect(snapshot.message).toContain("图片无法读取");
|
||||
expect(snapshot.message).toContain("重新上传原图");
|
||||
expect(snapshot.message).toContain(ports.runId);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user