Compare commits

..

No commits in common. "main" and "codex/rmbg-large-image-timeout-20261105" have entirely different histories.

114 changed files with 1387 additions and 4601 deletions

View File

@ -2377,9 +2377,7 @@ export function LocalAiImagePipelineSections({
items={[ items={[
{ {
label: label:
extendConfig.provider === "fal" extendConfig.provider === "vip98k"
? "Bria 扩图服务"
: extendConfig.provider === "vip98k"
? "VIP98K 服务商" ? "VIP98K 服务商"
: "阿里云服务商", : "阿里云服务商",
tone: "enabled", tone: "enabled",
@ -2393,11 +2391,7 @@ export function LocalAiImagePipelineSections({
} }
title="图像拓展API配置" title="图像拓展API配置"
> >
{extendConfig.provider === "fal" ? ( {extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 Bria 生成新增区域并保留原图主体和文字。密钥从已启用的 fal 官方服务商读取。
</div>
) : extendConfig.provider === "vip98k" ? (
<div className="rounded-lg border bg-muted/40 p-4 text-sm"> <div className="rounded-lg border bg-muted/40 p-4 text-sm">
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的 使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
VIP98K 服务商读取,仅在这里维护扩展比例。 VIP98K 服务商读取,仅在这里维护扩展比例。

View File

@ -274,7 +274,7 @@ export type EnhanceConfig = {
}; };
export type ExtendConfig = { export type ExtendConfig = {
provider: "vip98k" | "dashscope" | "fal"; provider: "vip98k" | "dashscope";
xScale: string; xScale: string;
yScale: string; yScale: string;
}; };

View File

@ -2576,7 +2576,7 @@ export function AdminSettingsClient() {
// 图像拓展API配置状态 // 图像拓展API配置状态
const [extendConfig, setExtendConfig] = useState({ const [extendConfig, setExtendConfig] = useState({
provider: "dashscope" as "vip98k" | "dashscope" | "fal", provider: "dashscope" as "vip98k" | "dashscope",
xScale: "1.5", // X轴放大倍数 xScale: "1.5", // X轴放大倍数
yScale: "1.5", // Y轴放大倍数 yScale: "1.5", // Y轴放大倍数
}); });
@ -2891,12 +2891,12 @@ export function AdminSettingsClient() {
useEffect(() => { useEffect(() => {
if (extendConfigData?.setting?.value) { if (extendConfigData?.setting?.value) {
const config = extendConfigData.setting.value as { const config = extendConfigData.setting.value as {
provider?: "vip98k" | "dashscope" | "fal"; provider?: "vip98k" | "dashscope";
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
}; };
setExtendConfig({ setExtendConfig({
provider: config.provider === "vip98k" || config.provider === "fal" ? config.provider : "dashscope", provider: config.provider === "vip98k" ? "vip98k" : "dashscope",
xScale: config.xScale ? String(config.xScale) : "1.5", xScale: config.xScale ? String(config.xScale) : "1.5",
yScale: config.yScale ? String(config.yScale) : "1.5", yScale: config.yScale ? String(config.yScale) : "1.5",
}); });

View File

@ -4,8 +4,7 @@ import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { getLanguageModelById } from "@/lib/ai/dynamic-provider"; import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
import { import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel, normalizeDetailReplacementImageModelConfig,
isDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config"; } from "@/lib/ai/detail-replacement-image-model-config";
import { import {
compositeReplacementWithReferenceLocks, compositeReplacementWithReferenceLocks,
@ -30,6 +29,7 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { bufferToArrayBuffer } from "./persistence"; import { bufferToArrayBuffer } from "./persistence";
import { isVideoProviderType } from "./video-models";
import type { AgentPresetMessageImagePart } from "./agent-scene-workflow"; import type { AgentPresetMessageImagePart } from "./agent-scene-workflow";
import type { PostRequestBody } from "../schema"; import type { PostRequestBody } from "../schema";
@ -2161,10 +2161,22 @@ export async function resolveDirectReplacementModelRef(params: {
models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>; models?: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>;
}) { }) {
const models = params.models || (await getEnabledAiModelsWithProvider(false)); const models = params.models || (await getEnabledAiModelsWithProvider(false));
const imageModels = models.filter(
({ model, provider }) =>
model.supportsImageGen &&
!model.isSubModel &&
!isVideoProviderType(provider?.type, model),
);
const configuredModelSetting = await getSystemSetting({ const configuredModelSetting = await getSystemSetting({
key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY, key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
}).catch(() => null); }).catch(() => null);
const configuredModel = findConfiguredDetailReplacementImageModel(models, configuredModelSetting?.value); const configuredModelId = normalizeDetailReplacementImageModelConfig(
configuredModelSetting?.value,
).modelId;
const configuredModel = imageModels.find(
({ model }) =>
model.id === configuredModelId || model.modelId === configuredModelId,
);
if (configuredModel) { if (configuredModel) {
return configuredModel.model.id; return configuredModel.model.id;
} }
@ -2178,7 +2190,6 @@ export async function resolveDirectReplacementModelRef(params: {
return routedModel; return routedModel;
} }
const imageModels = models.filter(isDetailReplacementImageModel);
const preferred = const preferred =
imageModels.find(({ model }) => model.isDefault) || imageModels.find(({ model }) => model.isDefault) ||
imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) || imageModels.find(({ provider }) => String(provider?.type || "").includes("image")) ||

View File

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

View File

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

View File

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

View File

@ -10,10 +10,6 @@ import {
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability"; import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import {
DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY,
findConfiguredDetailReplacementImageModel,
} from "@/lib/ai/detail-replacement-image-model-config";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
@ -161,17 +157,10 @@ export async function GET(request: Request) {
: await getEnabledAiModelsWithProvider(false, true); : await getEnabledAiModelsWithProvider(false, true);
const agentModelWithProvider = await getAgentAiModelWithProvider(); const agentModelWithProvider = await getAgentAiModelWithProvider();
const defaultModel = await getDefaultAiModel(); const defaultModel = await getDefaultAiModel();
const [siteSettings, agentWorkflowUiConfigSetting, detailReplacementModelSetting] = await Promise.all([ const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
getSiteSettings(), getSiteSettings(),
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null), getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
getSystemSetting({ key: DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY }).catch(() => null),
]); ]);
const detailReplacementModel = findConfiguredDetailReplacementImageModel(
includeUnavailable ? modelsWithProvider.filter(({ model, provider }) =>
provider.isEnabled && model.isEnabled && getAiModelFrontendAvailability(model).selectable !== false
) : modelsWithProvider,
detailReplacementModelSetting?.value,
);
type ModelWithProvider = (typeof modelsWithProvider)[number]; type ModelWithProvider = (typeof modelsWithProvider)[number];
const serializeModel = ({ model, provider }: ModelWithProvider) => ({ const serializeModel = ({ model, provider }: ModelWithProvider) => ({
id: model.id, id: model.id,
@ -320,7 +309,6 @@ export async function GET(request: Request) {
: null, : null,
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null, agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
agentWorkflowConfiguredModels, agentWorkflowConfiguredModels,
detailReplacementModel: detailReplacementModel ? serializeModel(detailReplacementModel) : null,
agentModel: agentModelWithProvider agentModel: agentModelWithProvider
? { ? {
id: agentModelWithProvider.model.id, id: agentModelWithProvider.model.id,

View File

@ -1,20 +0,0 @@
import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { resolveAgentWorkflowComposerSelections } from "@/lib/agents/workflow-composer";
import { AGENT_WORKFLOW_UI_CONFIG_KEY, normalizeAgentWorkflowUiConfig } from "@/lib/agents/workflow-ui-config";
import { getSystemSetting } from "@/lib/db/queries";
const selectionsSchema = z.record(z.string().max(160), z.string().max(8192))
.refine((selections) => Object.keys(selections).length <= 100);
export async function POST(request: Request) {
const session = await auth();
if (!session?.user?.id) return Response.json({ error: "请先登录" }, { status: 401 });
const selections = selectionsSchema.safeParse(await request.json().catch(() => null));
if (!selections.success) return Response.json({ error: "风格模板选择无效" }, { status: 400 });
const setting = await getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY });
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return Response.json({ selections: resolveAgentWorkflowComposerSelections({ config, selections: selections.data }) });
}

View File

@ -4,7 +4,6 @@ import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canvasImageTask, canvasProject, message } from "@/lib/db/schema"; import { canvasImageTask, canvasProject, message } from "@/lib/db/schema";
import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat"; import { extractAgentProjectStateFromCanvasData, isAgentProjectRow } from "@/lib/agents/agent-project-compat";
import { MAX_DETAIL_REPLACEMENT_REFERENCES } from "@/lib/agents/mode-workspace-state";
import { import {
createCanvasImageTask, createCanvasImageTask,
enqueueCanvasImageTask, enqueueCanvasImageTask,
@ -147,14 +146,6 @@ export async function POST(request: Request) {
.map((item) => [String(item.id), String(item.imageUrl)]), .map((item) => [String(item.id), String(item.imageUrl)]),
); );
const requested = Array.isArray(body.references) ? body.references : []; const requested = Array.isArray(body.references) ? body.references : [];
if (requested.length === 0) {
return NextResponse.json({ error: "请先选择详情参考图" }, { status: 400 });
}
if (requested.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
return NextResponse.json({
error: `本次选中了 ${requested.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`,
}, { status: 400 });
}
const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => { const references: DetailReplacementBatchReference[] = requested.map((raw: unknown) => {
const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {}; const item = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
return { return {
@ -169,6 +160,7 @@ export async function POST(request: Request) {
}; };
}); });
if ( if (
references.length === 0 || references.length > 40 ||
new Set(references.map((item) => item.id)).size !== references.length || new Set(references.map((item) => item.id)).size !== references.length ||
references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl) references.some((item) => !item.id || knownReferences.get(item.id) !== item.imageUrl)
) { ) {
@ -195,12 +187,6 @@ export async function POST(request: Request) {
if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) { if (!model || model === "auto" || !models.some((entry) => entry.model.id === model)) {
return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 }); return NextResponse.json({ error: "详情替换模型不可用,请检查模型配置" }, { status: 400 });
} }
if (typeof body.expectedModelId === "string" && body.expectedModelId !== model) {
return NextResponse.json({
code: "DETAIL_REPLACEMENT_MODEL_CHANGED",
error: "详情替换模型已更新,请确认页面显示的模型后重新提交",
}, { status: 409 });
}
const quota = await checkUserImageQuota(session.user.id); const quota = await checkUserImageQuota(session.user.id);
if (!quota.allowed) { if (!quota.allowed) {
return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 }); return NextResponse.json({ error: "本月图片生成配额已用完" }, { status: 429 });

View File

@ -1,8 +0,0 @@
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" },
});
}

View File

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

View File

@ -329,7 +329,6 @@ export async function POST(request: Request) {
withModel, withModel,
imageSize: snapshot.imagePreferences?.parameters?.imageSize, imageSize: snapshot.imagePreferences?.parameters?.imageSize,
imagePreferences: snapshot.imagePreferences, imagePreferences: snapshot.imagePreferences,
priorCalls: snapshot.calls,
preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined), preferredModelId: snapshot.imagePreferences?.modelId || (preset?.type === "image" ? preset.defaultModelId || undefined : undefined),
operationSelectedNodeIds: selectedNodeIds, operationSelectedNodeIds: selectedNodeIds,
objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join( objective: [snapshot.objective, ...snapshot.inputs.map((input) => input.text)].join(

View File

@ -1,15 +1,100 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { executeBoxCutoutPOST } from "@/lib/canvas/operations/box-cutout"; import {
BoxCutoutServiceError,
executeBoxCutoutJob,
} from "@/lib/ai/box-cutout/service";
import { RmbgServiceError } from "@/lib/ai/rmbg/service";
import { getPointsSystemConfig } from "@/lib/db/queries";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { getImageToolCreditCost } from "@/lib/points/config";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 60; export const maxDuration = 60;
export async function POST(request: Request) { type BoxCutoutRequest = {
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 {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 }); return NextResponse.json({ success: false, error: "未授权,请先登录" }, { status: 401 });
} }
return executeBoxCutoutPOST(request, session.user.id);
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 },
);
}
} }

View File

@ -10,13 +10,11 @@ import JSZip from "jszip";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery"; import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery";
import { trackRuntimeWork } from "@/lib/observability/active-work";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 300; export const maxDuration = 300;
type DownloadFileItem = { url: string; name?: string }; type DownloadFileItem = { url: string; name?: string };
const MAX_DOWNLOAD_FILES = 200; const MAX_DOWNLOAD_FILES = 200;
const TASK_TTL_MS = 24 * 60 * 60_000; const TASK_TTL_MS = 60 * 60_000;
const PREPARATION_TTL_MS = 6 * 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> }); const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>(); const active = running.__canvasZipRunning ??= new Set<string>();
function sanitizeFolderName(value: string): string { function sanitizeFolderName(value: string): string {
@ -100,12 +98,8 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task = await loadZipTask(id, userId); task = await loadZipTask(id, userId);
if (!task || task.status === "completed" || task.status === "failed") return; if (!task || task.status === "completed" || task.status === "failed") return;
await assertZipDiskSpace(); await assertZipDiskSpace();
const existingZip = task.zipFile && task.contentDisposition && await stat(task.zipFile.path).catch(() => null); task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now();
const reuseZip = existingZip && existingZip.size === task.zipFile!.bytes;
task.status = "running"; task.stage = reuseZip ? "正在准备加速下载" : "正在准备原图"; task.updatedAt = Date.now();
await saveZipTask(task); 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. // 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. // A restart resumes from these files using the current owner's session.
let cursor = 0; let cursor = 0;
@ -143,28 +137,24 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task); task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task);
task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; }); task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; });
task.contentDisposition = buildContentDisposition(task.folderName); task.contentDisposition = buildContentDisposition(task.folderName);
}
task.stage = "正在准备下载链接"; await saveZipTask(task); task.stage = "正在准备下载链接"; await saveZipTask(task);
const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile!, disposition: task.contentDisposition!, try {
onProgress: async bytes => { const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition });
task!.stage = `正在传送至七牛 ${Math.round(bytes / task!.zipFile!.bytes * 100)}%`;
task!.updatedAt = Date.now(); await saveZipTask(task!);
} });
if (cdn) { if (cdn) {
task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key }; task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key };
if (!cdn.url) throw new Error(cdn.error || `ZIP 已打包,但加速下载未就绪,请再次点击下载继续传送原 ZIP。下载编号:${task.id}`); if (cdn.url) task.cdnUrl = cdn.url;
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.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成";
task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
// The complete ZIP is enough for retries; release staged duplicate bytes. // 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))); 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, 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) { } catch (error) {
if (task) { if (task) {
task.status = "failed"; task.stage = task.zipFile ? "ZIP 已打包,加速下载未就绪" : "打包未完成"; task.status = "failed"; task.stage = "打包未完成";
task.error = error instanceof Error ? error.message : "打包失败,请稍后重试"; task.error = error instanceof Error ? error.message : "打包失败,请稍后重试";
task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined); task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined);
} }
@ -172,7 +162,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co
} finally { await release?.().catch(() => undefined); active.delete(id); } } finally { await release?.().catch(() => undefined); active.delete(id); }
} }
function schedule(task: ZipTask, request: NextRequest) { function schedule(task: ZipTask, request: NextRequest) {
const run = () => trackRuntimeWork(() => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "")); const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || "");
after(run); after(run);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -197,17 +187,14 @@ export async function POST(request: NextRequest) {
const now = Date.now(); const now = Date.now();
task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files, task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files,
totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0, totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0,
status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + PREPARATION_TTL_MS }; status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS };
await saveZipTask(task); await saveZipTask(task);
} else if ((task.status === "failed" || task.status === "completed" && task.cdnObject && !task.cdnUrl) && body.retry === true) { } else if (task.status === "failed" && body.retry === true) {
task.status = "pending"; task.error = undefined; task.status = "pending"; task.error = undefined;
await assertZipTaskCapacity(session.user.id); await assertZipTaskCapacity(session.user.id);
if (!task.zipFile) {
for (const file of task.files) delete file.error; for (const file of task.files) delete file.error;
task.processedCount = task.successCount = task.files.filter(file => file.prepared).length; task.processedCount = task.successCount = task.files.filter(file => file.prepared).length;
task.failedCount = 0; task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS;
}
task.updatedAt = Date.now(); task.expiresAt = Date.now() + PREPARATION_TTL_MS;
await saveZipTask(task); await saveZipTask(task);
} }
if (task.status === "pending" || task.status === "running") schedule(task, request); if (task.status === "pending" || task.status === "running") schedule(task, request);

View File

@ -1,11 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { and, eq, inArray } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl"; import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import { executeConfiguredVip98kTask, getVip98kRuntimeProviders } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db";
import { aiModel } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import sharp from "sharp"; import sharp from "sharp";
@ -94,39 +90,6 @@ function fallbackItemsFromRaw(raw: string): OcrItem[] {
})); }));
} }
async function detectTextWithVip98k(imageUrl: string): Promise<OcrItem[] | null> {
const providers = await getVip98kRuntimeProviders();
if (!providers.length) return null;
const model = await db.query.aiModel.findFirst({
columns: { providerId: true },
where: and(
inArray(aiModel.providerId, providers.map(({ id }) => id)),
eq(aiModel.modelId, "ocr"),
eq(aiModel.isEnabled, true),
),
});
if (!model) return null;
const result = await executeConfiguredVip98kTask({
modelId: "ocr",
kind: "image-tool",
imageUrls: [imageUrl],
preferredProviderId: model.providerId,
});
const textUrl = result.outputUrls[0];
if (!textUrl) throw new Error("98K 文字识别未返回文本文件");
const response = await fetch(textUrl, { signal: AbortSignal.timeout(15_000) });
const type = response.headers.get("content-type") || "";
if (!response.ok || !/^(?:text\/plain|application\/octet-stream)(?:;|$)/i.test(type)) {
await response.body?.cancel();
throw new Error(`无法读取 98K 文字识别结果(HTTP ${response.status})`);
}
return (await response.text()).split(/\r\n?|\n/).map((line) => line.trim())
.filter(Boolean).slice(0, 50).map((text, index) => ({ id: String(index + 1), text }));
}
function shouldUseDataUrlFallback(error: unknown) { function shouldUseDataUrlFallback(error: unknown) {
const message = const message =
error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase(); error instanceof Error ? error.message.toLowerCase() : String(error || "").toLowerCase();
@ -184,11 +147,6 @@ export async function POST(request: Request) {
return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 }); return NextResponse.json({ success: false, error: "仅支持 URL 图片,不允许 base64 传输" }, { status: 400 });
} }
const detected = await detectTextWithVip98k(imageUrl);
if (detected !== null) {
return NextResponse.json({ success: true, data: { items: detected, model: "ocr" } });
}
logger.debug("model_selected", { model: "qwen3-vl" }); logger.debug("model_selected", { model: "qwen3-vl" });
const prompt = [ const prompt = [
"你是图片文字识别助手。", "你是图片文字识别助手。",
@ -233,9 +191,15 @@ export async function POST(request: Request) {
} }
const parsedJson = parseJsonText(text || ""); const parsedJson = parseJsonText(text || "");
const items = Array.isArray(parsedJson?.items) let items: OcrItem[] = [];
? sanitizeItems(parsedJson.items)
: fallbackItemsFromRaw(text); if (parsedJson && Array.isArray(parsedJson.items)) {
items = sanitizeItems(parsedJson.items);
}
if (items.length === 0 && text) {
items = fallbackItemsFromRaw(text);
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@ -249,7 +213,7 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: "图片文字识别暂不可用,请稍后重试", error: error instanceof Error ? error.message : "文字识别失败",
}, },
{ status: 500 }, { status: 500 },
); );

View File

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

View File

@ -10,8 +10,7 @@ import {
systemSettingSnapshot, systemSettingSnapshot,
systemSettings, systemSettings,
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { omitAgentWorkflowPrompts } from "@/lib/agents/workflow-loading";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin"; import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin";
import { import {
@ -554,8 +553,6 @@ function normalizeAgentCategoryTreeConfig(value: unknown): AgentCategoryTreeConf
type PublicSiteSettingsScope = type PublicSiteSettingsScope =
| "agent-brand-model" | "agent-brand-model"
| "agent-canvas" | "agent-canvas"
| "agent-canvas-base"
| "agent-workflow-templates"
| "agent-entry" | "agent-entry"
| "agent-presets" | "agent-presets"
| "auth" | "auth"
@ -568,8 +565,6 @@ type PublicSiteSettingsScope =
const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
"agent-brand-model", "agent-brand-model",
"agent-canvas", "agent-canvas",
"agent-canvas-base",
"agent-workflow-templates",
"agent-entry", "agent-entry",
"agent-presets", "agent-presets",
"auth", "auth",
@ -687,7 +682,7 @@ function createScopedSiteSettingsResponse(
return response; return response;
} }
async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false) { async function readScopedSiteSettings(keys: string[]) {
const [settings, settingRows] = await Promise.all([ const [settings, settingRows] = await Promise.all([
db db
.select() .select()
@ -695,14 +690,8 @@ async function readScopedSiteSettings(keys: string[], omitWorkflowSteps = false)
.limit(1) .limit(1)
.then((rows) => rows[0]), .then((rows) => rows[0]),
keys.length > 0 keys.length > 0
? (omitWorkflowSteps ? db
? db.select({ .select()
key: systemSettings.key,
value: sql<unknown>`case when ${systemSettings.key} = ${AGENT_WORKFLOW_UI_CONFIG_KEY}
and jsonb_typeof(${systemSettings.value}) = 'object'
then ${systemSettings.value} - 'steps' else ${systemSettings.value} end`,
})
: db.select())
.from(systemSettings) .from(systemSettings)
.where(inArray(systemSettings.key, keys)) .where(inArray(systemSettings.key, keys))
: Promise.resolve([]), : Promise.resolve([]),
@ -790,17 +779,8 @@ async function getScopedPublicSiteSettings(
}); });
} }
if (scope === "agent-workflow-templates") { if (scope === "agent-canvas" || scope === "agent-entry") {
const [setting] = await db.select({ value: systemSettings.value }) const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS);
.from(systemSettings).where(eq(systemSettings.key, AGENT_WORKFLOW_UI_CONFIG_KEY)).limit(1);
const config = normalizeAgentWorkflowUiConfig(setting?.value);
return createScopedSiteSettingsResponse(scope, {
steps: omitAgentWorkflowPrompts(config.steps),
}, "MISS", options.cacheKey || scope, { etag: options.etag });
}
if (scope === "agent-canvas" || scope === "agent-canvas-base" || scope === "agent-entry") {
const { settings, valueByKey } = await readScopedSiteSettings(AGENT_CANVAS_SETTING_KEYS, scope !== "agent-canvas");
const canvasFeatureAvailability = await readCanvasFeatureAvailability( const canvasFeatureAvailability = await readCanvasFeatureAvailability(
valueByKey valueByKey
); );
@ -851,8 +831,6 @@ async function getScopedPublicSiteSettings(
pageFeatureSettings: rawUiConfig?.pageFeatureSettings, pageFeatureSettings: rawUiConfig?.pageFeatureSettings,
businessModeSettings: rawUiConfig?.businessModeSettings, businessModeSettings: rawUiConfig?.businessModeSettings,
} }
: scope === "agent-canvas-base"
? { ...rawUiConfig, steps: [] }
: rawUiConfig : rawUiConfig
), ),
agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig( agentWorkflowShowcaseConfig: normalizeAgentWorkflowShowcaseConfig(
@ -1112,8 +1090,7 @@ export async function GET(request: NextRequest) {
const cacheKey = brandName.trim() const cacheKey = brandName.trim()
? `${scopedScope}:${brandName.trim().toLowerCase()}` ? `${scopedScope}:${brandName.trim().toLowerCase()}`
: scopedScope; : scopedScope;
const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry" || const etag = scopedScope === "agent-canvas" || scopedScope === "agent-entry"
scopedScope === "agent-canvas-base" || scopedScope === "agent-workflow-templates"
? await getAgentCanvasSettingsEtag(scopedScope) ? await getAgentCanvasSettingsEtag(scopedScope)
: undefined; : undefined;
if (etag && request.headers.get("If-None-Match") === etag) { if (etag && request.headers.get("If-None-Match") === etag) {

View File

@ -1,11 +1,7 @@
"use client"; "use client";
import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue"; import { createCanvasSaveQueue } from "@/lib/client/canvas-save-queue";
import { readCanvasDraft, writeCanvasDraft, clearCanvasDraft } from "@/lib/client/canvas-draft"; import { CanvasRemoteOperationContext } from "@/components/canvas/CanvasRemoteOperationContext";
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 { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
@ -172,7 +168,6 @@ import {
type VideoCanvasSymbolMarkerId, type VideoCanvasSymbolMarkerId,
} from "@/lib/canvas/video-canvas-markers"; } from "@/lib/canvas/video-canvas-markers";
import { import {
isCanvasImageUploadPending,
LOCAL_UPLOAD_PENDING_METADATA_KEY, LOCAL_UPLOAD_PENDING_METADATA_KEY,
LOCAL_UPLOAD_PREVIEW_METADATA_KEY, LOCAL_UPLOAD_PREVIEW_METADATA_KEY,
} from "@/lib/canvas/local-upload-state"; } from "@/lib/canvas/local-upload-state";
@ -233,8 +228,7 @@ import {
type WorkflowSceneRecord, type WorkflowSceneRecord,
} from "@/lib/video-workflow/runtime"; } from "@/lib/video-workflow/runtime";
import { agentProjectService } from "@/services/agent-projects"; import { agentProjectService } from "@/services/agent-projects";
import { getWorkflowSelectionsRequest } from "@/lib/agents/workflow-loading"; import { fetchCanvasSiteSettings } from "@/lib/client/canvas-site-settings";
import { fetchCanvasSiteSettings, fetchWorkflowTemplatePrompts } from "@/lib/client/canvas-site-settings";
import { import {
canvasService, canvasService,
imageGenerationService, imageGenerationService,
@ -274,17 +268,6 @@ import type {
const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000; const CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS = 15000;
const CANVAS_SURFACE_BACKGROUND = "#f5f5f5"; const CANVAS_SURFACE_BACKGROUND = "#f5f5f5";
function WorkflowLoadState({ name, error, retrying, onRetry }: { name: string; error?: Error; retrying?: boolean; onRetry: () => void }) {
const failed = Boolean(error && !retrying);
const timedOut = error?.name === "AbortError" || error?.name === "TimeoutError";
return (
<div className="flex items-center gap-3 rounded-xl border border-white/10 px-4 py-3 text-sm text-white/65" role={failed ? "alert" : "status"}>
<span>{failed ? `${name}${timedOut ? "加载超时" : "加载失败"}` : `正在加载${name}…`}</span>
{failed && <Button type="button" size="sm" variant="outline" onClick={onRetry}>重试</Button>}
</div>
);
}
function CanvasLazyPanelLoader({ function CanvasLazyPanelLoader({
label, label,
className, className,
@ -1946,12 +1929,6 @@ export default function CanvasEditorLegacyPage({
() => !initialProjectForCurrentRoute () => !initialProjectForCurrentRoute
); );
const [isSaving, setIsSaving] = useState(false); 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 [isEditingTitle, setIsEditingTitle] = useState(false);
const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false); const [isAddImageDialogOpen, setIsAddImageDialogOpen] = useState(false);
const [editTitle, setEditTitle] = useState(""); const [editTitle, setEditTitle] = useState("");
@ -2188,13 +2165,13 @@ export default function CanvasEditorLegacyPage({
); );
const canvasSiteSettingsKey = requestedAgentMode const canvasSiteSettingsKey = requestedAgentMode
? hasActiveAgentProject ? hasActiveAgentProject
? "/api/site-settings?scope=agent-canvas-base" ? "/api/site-settings?scope=agent-canvas"
: "/api/site-settings?scope=agent-entry" : "/api/site-settings?scope=agent-entry"
: "/api/site-settings?scope=canvas"; : "/api/site-settings?scope=canvas";
const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] = const [hasResolvedCanvasSiteSettings, setHasResolvedCanvasSiteSettings] =
useState(false); useState(false);
const { data: siteSettingsData, error: siteSettingsError, isValidating: siteSettingsLoading, mutate: reloadSiteSettings } = useSWR<{ const { data: siteSettingsData } = useSWR<{
siteName?: string; siteName?: string;
siteLogo?: string | null; siteLogo?: string | null;
siteLogoDark?: string | null; siteLogoDark?: string | null;
@ -2225,7 +2202,6 @@ export default function CanvasEditorLegacyPage({
? 0 ? 0
: CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS, : CANVAS_SITE_SETTINGS_REFRESH_INTERVAL_MS,
errorRetryCount: 1, errorRetryCount: 1,
shouldRetryOnError: !requestedAgentMode || !hasActiveAgentProject,
dedupingInterval: 30_000, dedupingInterval: 30_000,
revalidateOnFocus: true, revalidateOnFocus: true,
revalidateOnReconnect: true, revalidateOnReconnect: true,
@ -2292,34 +2268,9 @@ export default function CanvasEditorLegacyPage({
!requestedAgentMode || !requestedAgentMode ||
!agentProjectId || !agentProjectId ||
loadedAgentProjectId === agentProjectId; loadedAgentProjectId === agentProjectId;
const [workflowTemplateMarker, setWorkflowTemplateMarker] = useState<HTMLDivElement | null>(null);
const [workflowTemplatesRequested, setWorkflowTemplatesRequested] = useState(false);
const hasWorkflowBase = Boolean(siteSettingsData?.agentWorkflowUiConfig);
useEffect(() => {
if (!isAgentMode || !hasActiveAgentProject || !hasWorkflowBase || workflowTemplatesRequested) return;
const element = workflowTemplateMarker;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) setWorkflowTemplatesRequested(true);
});
observer.observe(element);
return () => observer.disconnect();
}, [hasActiveAgentProject, hasWorkflowBase, isAgentMode, workflowTemplateMarker, workflowTemplatesRequested]);
const { data: workflowTemplates, error: workflowTemplatesError, isValidating: workflowTemplatesLoading, mutate: reloadWorkflowTemplates } = useSWR<{
steps: AgentWorkflowUiConfig["steps"];
}>(
isAgentMode && hasActiveAgentProject && hasWorkflowBase && workflowTemplatesRequested
? "/api/site-settings?scope=agent-workflow-templates"
: null,
fetchCanvasSiteSettings,
{ shouldRetryOnError: false }
);
const resolvedWorkflowUiConfig = useMemo( const resolvedWorkflowUiConfig = useMemo(
() => { () => siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig,
const base = siteSettingsData?.agentWorkflowUiConfig || defaultAgentWorkflowUiConfig; [siteSettingsData?.agentWorkflowUiConfig]
return requestedAgentMode ? { ...base, steps: workflowTemplates?.steps ?? [] } : base;
},
[requestedAgentMode, siteSettingsData?.agentWorkflowUiConfig, workflowTemplates?.steps]
); );
const workflowPageFeatureSettings = useMemo( const workflowPageFeatureSettings = useMemo(
() => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig), () => getAgentWorkflowPageFeatureSettings(resolvedWorkflowUiConfig),
@ -2368,7 +2319,7 @@ export default function CanvasEditorLegacyPage({
}); });
const sanitizedWorkflowSelections = useMemo( const sanitizedWorkflowSelections = useMemo(
() => () =>
requestedAgentMode && !workflowTemplates requestedAgentMode && !siteSettingsData?.agentWorkflowUiConfig
? workflowSelections ? workflowSelections
: sanitizeAgentWorkflowSelections({ : sanitizeAgentWorkflowSelections({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
@ -2376,7 +2327,7 @@ export default function CanvasEditorLegacyPage({
}), }),
[ [
requestedAgentMode, requestedAgentMode,
workflowTemplates, siteSettingsData?.agentWorkflowUiConfig,
resolvedWorkflowUiConfig, resolvedWorkflowUiConfig,
workflowSelections, workflowSelections,
] ]
@ -2384,14 +2335,6 @@ export default function CanvasEditorLegacyPage({
const deferredSanitizedWorkflowSelections = useDeferredValue( const deferredSanitizedWorkflowSelections = useDeferredValue(
sanitizedWorkflowSelections sanitizedWorkflowSelections
); );
const workflowSelectionRequest = isAgentMode && hasActiveAgentProject && hasLoadedCurrentAgentProject
? getWorkflowSelectionsRequest(sanitizedWorkflowSelections)
: "";
const { data: workflowPromptData, error: workflowPromptError, isValidating: workflowPromptLoading, mutate: reloadWorkflowPrompts } = useSWR(
workflowSelectionRequest ? ["/api/agent-workflow/template-prompts", workflowSelectionRequest] as const : null,
fetchWorkflowTemplatePrompts,
{ shouldRetryOnError: false }
);
const resolvedBrandModelConfig = useMemo( const resolvedBrandModelConfig = useMemo(
() => () =>
normalizeAgentWorkflowBrandModelConfig( normalizeAgentWorkflowBrandModelConfig(
@ -3422,32 +3365,6 @@ export default function CanvasEditorLegacyPage({
return () => window.clearTimeout(timeoutId); 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( const hydrateCanvasProject = useCallback(
(project: CanvasInitialProject) => { (project: CanvasInitialProject) => {
externalCanvasConflictRef.current = false; externalCanvasConflictRef.current = false;
@ -3457,9 +3374,9 @@ export default function CanvasEditorLegacyPage({
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(projectUpdatedAt, projectUpdatedAt); .setCanvasRevision(projectUpdatedAt, projectUpdatedAt);
loadPersistedCanvasState(project.canvasData);
if (project.canvasData) { if (project.canvasData) {
useCanvasStore.getState().loadCanvas(project.canvasData);
const persistedWorkflow = project.canvasData.workflowState; const persistedWorkflow = project.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3562,7 +3479,7 @@ export default function CanvasEditorLegacyPage({
setWorkflowHydrationSource("empty"); setWorkflowHydrationSource("empty");
}, },
[id, requestedAgentMode, loadPersistedCanvasState] [id, requestedAgentMode]
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@ -3570,7 +3487,6 @@ export default function CanvasEditorLegacyPage({
if (lastLoadedCanvasIdRef.current === id) return; if (lastLoadedCanvasIdRef.current === id) return;
hydrateCanvasProject(initialProjectForCurrentRoute); hydrateCanvasProject(initialProjectForCurrentRoute);
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}, [hydrateCanvasProject, id, initialProjectForCurrentRoute]); }, [hydrateCanvasProject, id, initialProjectForCurrentRoute]);
@ -3578,7 +3494,6 @@ export default function CanvasEditorLegacyPage({
// 加载画布数据 // 加载画布数据
useEffect(() => { useEffect(() => {
console.log("[Canvas] Loading project:", id); console.log("[Canvas] Loading project:", id);
let cancelled = false;
const loadProject = async () => { const loadProject = async () => {
if (lastLoadedCanvasIdRef.current === id) { if (lastLoadedCanvasIdRef.current === id) {
@ -3587,8 +3502,6 @@ export default function CanvasEditorLegacyPage({
} }
setIsLoading(true); setIsLoading(true);
canvasReadyRef.current = false;
setCanvasLoadError("");
if (!requestedAgentMode) { if (!requestedAgentMode) {
setWorkflowHydrationSource("pending"); setWorkflowHydrationSource("pending");
} }
@ -3621,16 +3534,18 @@ export default function CanvasEditorLegacyPage({
setLoadedAgentProjectId(null); setLoadedAgentProjectId(null);
} }
// 🆕 先清空当前画布状态,确保切换时不会显示旧数据
useCanvasStore.getState().clearCanvas();
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
if (cancelled) return;
if (result.success && result.data) { if (result.success && result.data) {
useCanvasStore.getState().setProject(id, result.data.title); useCanvasStore.getState().setProject(id, result.data.title);
useCanvasStore useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); .setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
loadPersistedCanvasState(result.data.canvasData);
if (result.data.canvasData) { if (result.data.canvasData) {
useCanvasStore.getState().loadCanvas(result.data.canvasData);
const persistedWorkflow = result.data.canvasData.workflowState; const persistedWorkflow = result.data.canvasData.workflowState;
if (!requestedAgentMode && persistedWorkflow) { if (!requestedAgentMode && persistedWorkflow) {
const nextAppearanceRecognitionEnabled = const nextAppearanceRecognitionEnabled =
@ -3747,15 +3662,8 @@ export default function CanvasEditorLegacyPage({
return; 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); console.log("[Canvas] 画布不存在,创建新画布:", id);
useCanvasStore.getState().clearCanvas();
useCanvasStore useCanvasStore
.getState() .getState()
.setProject( .setProject(
@ -3782,20 +3690,15 @@ export default function CanvasEditorLegacyPage({
} }
} }
} }
if (cancelled) return;
canvasReadyRef.current = true;
setIsLoading(false); setIsLoading(false);
lastLoadedCanvasIdRef.current = id; lastLoadedCanvasIdRef.current = id;
}; };
loadProject(); loadProject();
return () => { cancelled = true; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
defaultCanvasTitle, defaultCanvasTitle,
id, id,
canvasLoadAttempt,
loadPersistedCanvasState,
openLoginDialog, openLoginDialog,
requestedAgentMode, requestedAgentMode,
]); ]);
@ -5168,69 +5071,15 @@ export default function CanvasEditorLegacyPage({
const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const canvasAutosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const canvasPersistenceScope = useRef(id); const canvasPersistenceScope = useRef(id);
const canvasRemoteOperationActive = useRef(false); const canvasRemoteOperationActive = useRef(false);
const canvasLeaveApprovedRef = useRef(false);
const [canvasLeaveUrl, setCanvasLeaveUrl] = useState("");
const [isSavingBeforeLeave, setIsSavingBeforeLeave] = useState(false);
const [canvasLeaveError, setCanvasLeaveError] = useState("");
canvasPersistenceScope.current = id; 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( const persistCanvasStateNow = useCallback(
async (reason: "autosave" | "delete" | "beforeunload") => { async (reason: "autosave" | "delete" | "beforeunload") => {
if (canvasLeaveApprovedRef.current || canvasPersistenceScope.current !== id || !canvasReadyRef.current || useCanvasStore.getState().projectId !== id) return false; if (canvasPersistenceScope.current !== id) return false;
if (externalCanvasConflictRef.current) return false;
setIsSaving(true); setIsSaving(true);
try { try {
let canvasData = buildPersistedCanvasData(); const 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 { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
let result = await canvasService.saveCanvasState( const result = await canvasService.saveCanvasState(
id, id,
canvasData, canvasData,
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
@ -5240,53 +5089,23 @@ export default function CanvasEditorLegacyPage({
} }
); );
if (canvasPersistenceScope.current !== id) return false; if (canvasPersistenceScope.current !== id) return false;
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 (canvasPersistenceScope.current !== id) return false;
} else {
externalCanvasConflictRef.current = true;
setCanvasSyncConflicts(merged.conflicts);
}
}
}
if (!result.success) { 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); console.error(`[Canvas] ${reason} save failed:`, result.error);
} else { if (result.status === 409) {
canvasSyncErrorRef.current = ""; externalCanvasConflictRef.current = true;
savedCanvasSnapshotRef.current = { id, snapshot }; console.warn("[Canvas] stale save revision handled without user prompt", {
clearCanvasDraft(id); reason,
externalCanvasConflictRef.current = false; canvasUpdatedAt: result.canvasUpdatedAt,
setCanvasSyncConflicts([]); });
externalCanvasConflictNotifiedRef.current = false; if (!externalCanvasConflictNotifiedRef.current) {
if (result.data?.canvasData && result.data?.updatedAt) { externalCanvasConflictNotifiedRef.current = true;
return applyCanvasRemoteSnapshot({ projectId: id, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) }); toast.warning("画布已被外部操作更新,本地改动未覆盖远端;请刷新后继续编辑");
} }
if (result.data?.updatedAt) useCanvasStore }
} else if (result.data?.updatedAt) {
externalCanvasConflictRef.current = false;
externalCanvasConflictNotifiedRef.current = false;
useCanvasStore
.getState() .getState()
.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); .setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
} }
@ -5295,7 +5114,7 @@ export default function CanvasEditorLegacyPage({
if (canvasPersistenceScope.current === id) setIsSaving(false); if (canvasPersistenceScope.current === id) setIsSaving(false);
} }
}, },
[activeCanvasWorkspaceType, applyCanvasRemoteSnapshot, buildPersistedCanvasData, id] [activeCanvasWorkspaceType, buildPersistedCanvasData, id]
); );
const persistCanvasState = useCallback( const persistCanvasState = useCallback(
@ -5304,139 +5123,35 @@ export default function CanvasEditorLegacyPage({
[persistCanvasStateNow] [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( const runCanvasRemoteOperation = useCallback(
(operation: CanvasRemoteOperation) => { (operation: () => Promise<void>) => {
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current); clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null; canvasAutosaveTimer.current = null;
} }
return canvasSaveQueue.current!.enqueueOperation( return canvasSaveQueue.current!.enqueueOperation(
async () => { () => persistCanvasStateNow("autosave"),
const saved = await persistCanvasStateNow("autosave");
if (!saved) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次处理未提交。` : "画布尚未完成加载,本次处理未提交,请等待加载完成后重试。");
return true;
},
async () => { async () => {
canvasRemoteOperationActive.current = true; canvasRemoteOperationActive.current = true;
try { try {
const result = await operation(); await operation();
if (result && result.projectId === id && canvasPersistenceScope.current === id) {
savedCanvasSnapshotRef.current = { id, snapshot: JSON.stringify({ canvasData: result.canvasData }) };
canvasSyncErrorRef.current = "";
}
} finally { } finally {
canvasRemoteOperationActive.current = false; canvasRemoteOperationActive.current = false;
} }
} }
); );
}, },
[persistCanvasStateNow, id] [persistCanvasStateNow]
); );
const persistCanvasBeforeAgent = useCallback(async () => { const persistCanvasBeforeAgent = useCallback(() => {
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. // Flush the pending local edit once instead of racing its scheduled autosave.
if (canvasAutosaveTimer.current) { if (canvasAutosaveTimer.current) {
clearTimeout(canvasAutosaveTimer.current); clearTimeout(canvasAutosaveTimer.current);
canvasAutosaveTimer.current = null; canvasAutosaveTimer.current = null;
} }
if (!(await persistCanvasState("autosave"))) throw new Error(canvasSyncErrorRef.current ? `${canvasSyncErrorRef.current} 本次任务未提交。` : "画布尚未完成加载,本次任务未提交;请等待加载完成后再次发送。"); return persistCanvasState("autosave");
return true; }, [persistCanvasState]);
}, [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(() => { useEffect(() => {
@ -5457,32 +5172,7 @@ export default function CanvasEditorLegacyPage({
clearTimeout(saveTimer); clearTimeout(saveTimer);
if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null; if (canvasAutosaveTimer.current === saveTimer) canvasAutosaveTimer.current = null;
}; };
}, [isAgentMode, isAgentOperationRefreshActive, nodes, edges, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, 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(() => { useEffect(() => {
if (isLoading || isAgentOperationRefreshActive()) return; if (isLoading || isAgentOperationRefreshActive()) return;
@ -5506,7 +5196,6 @@ export default function CanvasEditorLegacyPage({
} }
try { try {
const beforeRead = useCanvasStore.getState();
const revisionResponse = await fetch(`/api/canvas/${id}/revision`, { const revisionResponse = await fetch(`/api/canvas/${id}/revision`, {
cache: "no-store", cache: "no-store",
}); });
@ -5527,11 +5216,7 @@ export default function CanvasEditorLegacyPage({
} }
const result = await canvasService.getProject(id); const result = await canvasService.getProject(id);
const current = useCanvasStore.getState(); if (cancelled || canvasRemoteOperationActive.current || !result.success || !result.data) return;
if (
cancelled || canvasRemoteOperationActive.current || !result.success || !result.data ||
current.projectId !== id || current.nodes !== beforeRead.nodes || current.edges !== beforeRead.edges
) return;
useCanvasStore.getState().markAgentOperationRefresh(); useCanvasStore.getState().markAgentOperationRefresh();
hydrateCanvasProject(result.data); hydrateCanvasProject(result.data);
lastLocalCanvasMutationAtRef.current = Date.now(); lastLocalCanvasMutationAtRef.current = Date.now();
@ -5585,25 +5270,14 @@ export default function CanvasEditorLegacyPage({
} }
}, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]); }, [isAgentMode, isAgentOperationRefreshActive, nodes, isLoading, persistCanvasState]);
// 浏览器离开确认只保护尚未同步的内容;真正离开时保留草稿并发送一次保存。 // 页面关闭/刷新前尝试做一次 keepalive 保存,降低“刚删就刷新”回滚概率
useEffect(() => { useEffect(() => {
if (isLoading || isAgentMode) return; if (isLoading || isAgentMode) return;
const retainDraft = () => { const handleBeforeUnload = () => {
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 { try {
const canvasData = retainDraft(); if (isAgentOperationRefreshActive()) return;
if (!canvasData || isSaving || canvasRemoteOperationActive.current || isAgentOperationRefreshActive() || externalCanvasConflictRef.current) return; const canvasData = buildPersistedCanvasData();
const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState(); const { canvasRevision, canvasUpdatedAt } = useCanvasStore.getState();
void fetch(`/api/canvas/${id}`, { void fetch(`/api/canvas/${id}`, {
method: "PUT", method: "PUT",
@ -5622,20 +5296,18 @@ export default function CanvasEditorLegacyPage({
}; };
window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("pagehide", handlePageHide); window.addEventListener("pagehide", handleBeforeUnload);
return () => { return () => {
window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("pagehide", handlePageHide); window.removeEventListener("pagehide", handleBeforeUnload);
}; };
}, [ }, [
activeCanvasWorkspaceType, activeCanvasWorkspaceType,
buildPersistedCanvasData, buildPersistedCanvasData,
hasUnsavedCanvasChanges,
id, id,
isAgentOperationRefreshActive, isAgentOperationRefreshActive,
isAgentMode, isAgentMode,
isLoading, isLoading,
isSaving,
]); ]);
// 智能命名与缩略图逻辑 // 智能命名与缩略图逻辑
@ -5719,12 +5391,21 @@ export default function CanvasEditorLegacyPage({
// 保存画布 (手动触发保留作为备用,但UI上移除) // 保存画布 (手动触发保留作为备用,但UI上移除)
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (await persistCanvasState("autosave")) { setIsSaving(true);
const canvasData = buildPersistedCanvasData();
const result = await canvasService.saveCanvasState(
id,
canvasData,
activeCanvasWorkspaceType
);
if (result.success) {
toast.success("保存成功"); toast.success("保存成功");
} else { } else {
toast.error(canvasSyncErrorRef.current || "画布同步失败,请检查网络后重试"); toast.error(result.error || "保存失败");
} }
}, [persistCanvasState]); setIsSaving(false);
}, [activeCanvasWorkspaceType, buildPersistedCanvasData, id]);
// 更新标题 // 更新标题
const handleUpdateTitle = async () => { const handleUpdateTitle = async () => {
@ -9967,8 +9648,6 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel, selectedBrandModel,
appearanceRecognitionEnabled, appearanceRecognitionEnabled,
selections: deferredSanitizedWorkflowSelections, selections: deferredSanitizedWorkflowSelections,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(deferredSanitizedWorkflowSelections)
? workflowPromptData?.selections : undefined,
}); });
if (canceled) { if (canceled) {
@ -9991,7 +9670,6 @@ export default function CanvasEditorLegacyPage({
selectedBrandModel, selectedBrandModel,
workflowInputs, workflowInputs,
workflowMaterials, workflowMaterials,
workflowPromptData,
]); ]);
useEffect(() => { useEffect(() => {
workflowInputsRef.current = workflowInputs; workflowInputsRef.current = workflowInputs;
@ -10113,23 +9791,15 @@ export default function CanvasEditorLegacyPage({
[patchPersistedAgentWorkflowState] [patchPersistedAgentWorkflowState]
); );
const getLatestWorkflowContext = useCallback(() => { const getLatestWorkflowContext = useCallback(() => {
const selections = workflowSelectionsRef.current;
return buildAgentWorkflowComposerContext({ return buildAgentWorkflowComposerContext({
config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig, config: resolvedWorkflowUiConfig || defaultAgentWorkflowUiConfig,
inputs: workflowInputsRef.current, inputs: workflowInputsRef.current,
materials: workflowMaterialsRef.current, materials: workflowMaterialsRef.current,
selectedBrandModel: selectedBrandModelRef.current, selectedBrandModel: selectedBrandModelRef.current,
appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current, appearanceRecognitionEnabled: appearanceRecognitionEnabledRef.current,
selections, selections: workflowSelectionsRef.current,
resolvedSelections: workflowPromptData?.selectionRequest === getWorkflowSelectionsRequest(selections)
? workflowPromptData?.selections : undefined,
}); });
}, [resolvedWorkflowUiConfig, workflowPromptData]); }, [resolvedWorkflowUiConfig]);
const getWorkflowPromptStatus = useCallback(() => {
const currentRequest = getWorkflowSelectionsRequest(workflowSelectionsRef.current);
if (!currentRequest || currentRequest === workflowPromptData?.selectionRequest) return "ready";
return workflowPromptError && currentRequest === workflowSelectionRequest ? "error" : "loading";
}, [workflowPromptData, workflowPromptError, workflowSelectionRequest]);
const effectiveSelectedAgentPresetId = null; const effectiveSelectedAgentPresetId = null;
const { data: selectedAgentPresetResponse } = useSWR<{ const { data: selectedAgentPresetResponse } = useSWR<{
success?: boolean; success?: boolean;
@ -10504,9 +10174,8 @@ export default function CanvasEditorLegacyPage({
</div> </div>
) : null; ) : null;
const workflowStylePanel = useMemo(() => ( const workflowStylePanel = useMemo(
<div ref={setWorkflowTemplateMarker} className="space-y-3"> () => (
{workflowTemplates ? (
<AgentWorkflowPanelDarkV2 <AgentWorkflowPanelDarkV2
config={resolvedWorkflowUiConfig} config={resolvedWorkflowUiConfig}
embedded embedded
@ -10518,31 +10187,17 @@ export default function CanvasEditorLegacyPage({
selections={sanitizedWorkflowSelections} selections={sanitizedWorkflowSelections}
showBrandStoreSection={false} showBrandStoreSection={false}
/> />
) : siteSettingsError && !siteSettingsData && !siteSettingsLoading ? ( ),
<p className="text-sm text-white/65">请先在上方重试加载品牌和类目。</p> [
) : !workflowTemplatesRequested ? ( handleWorkflowInputsChange,
<Button type="button" variant="outline" onClick={() => setWorkflowTemplatesRequested(true)}>加载风格模板</Button> handleWorkflowMaterialsChange,
) : ( handleWorkflowSelectionsChange,
<WorkflowLoadState name="风格模板" error={workflowTemplatesError} retrying={workflowTemplatesLoading} onRetry={() => void reloadWorkflowTemplates().catch(() => {})} /> resolvedWorkflowUiConfig,
)} sanitizedWorkflowSelections,
{workflowPromptError && ( workflowInputs,
<WorkflowLoadState name="所选模板的提示词" error={workflowPromptError} retrying={workflowPromptLoading} onRetry={() => void reloadWorkflowPrompts().catch(() => {})} /> workflowMaterials,
)} ]
</div> );
), [
handleWorkflowInputsChange, handleWorkflowMaterialsChange, handleWorkflowSelectionsChange,
reloadWorkflowPrompts, reloadWorkflowTemplates, resolvedWorkflowUiConfig, sanitizedWorkflowSelections,
siteSettingsData, siteSettingsError, siteSettingsLoading, workflowInputs, workflowMaterials, workflowPromptError,
workflowPromptLoading, workflowTemplates, workflowTemplatesError, workflowTemplatesLoading, workflowTemplatesRequested,
]);
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 (isAgentMode) {
if (!hasActiveAgentProject) { if (!hasActiveAgentProject) {
@ -10577,20 +10232,17 @@ export default function CanvasEditorLegacyPage({
theme="dark" theme="dark"
/> />
} }
brandStorePanel={siteSettingsData?.agentWorkflowUiConfig ? ( brandStorePanel={
<AgentWorkflowBrandStorePanel <AgentWorkflowBrandStorePanel
config={resolvedWorkflowUiConfig} config={resolvedWorkflowUiConfig}
inputs={workflowInputs} inputs={workflowInputs}
onInputsChange={handleWorkflowInputsChange} onInputsChange={handleWorkflowInputsChange}
theme="dark" theme="dark"
/> />
) : ( }
<WorkflowLoadState name="品牌和类目" error={siteSettingsError} retrying={siteSettingsLoading} onRetry={() => void reloadSiteSettings().catch(() => {})} />
)}
chatId={agentModeChatId} chatId={agentModeChatId}
deletedResultCollections={agentProjectDeletedResultCollections} deletedResultCollections={agentProjectDeletedResultCollections}
getLatestWorkflowContext={getLatestWorkflowContext} getLatestWorkflowContext={getLatestWorkflowContext}
getWorkflowPromptStatus={getWorkflowPromptStatus}
materialSelectionPanel={ materialSelectionPanel={
isWorkflowMaterialStepEnabled ? ( isWorkflowMaterialStepEnabled ? (
<AgentWorkflowMaterialSelectionPanel <AgentWorkflowMaterialSelectionPanel
@ -10650,7 +10302,7 @@ export default function CanvasEditorLegacyPage({
} }
return ( return (
<CanvasRemoteOperationContext.Provider value={canvasRemoteOperationContext}> <CanvasRemoteOperationContext.Provider value={runCanvasRemoteOperation}>
<TooltipProvider> <TooltipProvider>
<div <div
data-canvas-panel-open={isPanelOpen ? "true" : "false"} data-canvas-panel-open={isPanelOpen ? "true" : "false"}
@ -10666,30 +10318,6 @@ export default function CanvasEditorLegacyPage({
} }
> >
<CanvasAgentUiListener projectId={id} workspace="canvas" /> <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 变化时重新挂载 */} {/* 1. Canvas Layer (Full Screen) - 使用 key 强制在 id 变化时重新挂载 */}
<div <div
className="absolute inset-0 z-0" className="absolute inset-0 z-0"
@ -11399,12 +11027,14 @@ export default function CanvasEditorLegacyPage({
{isVideoMode ? brandEntry : ( {isVideoMode ? brandEntry : (
<CanvasSurfaceHeader <CanvasSurfaceHeader
isSaving={isSaving}
onAddImage={handleAddImageNode} onAddImage={handleAddImageNode}
onHome={() => requestCanvasLeave("/")} onHome={goHome}
onOpenFiles={() => { onOpenFiles={() => {
setIsPanelOpen(true); setIsPanelOpen(true);
setPanelMode("fileList"); setPanelMode("fileList");
}} }}
onSave={() => void handleSave()}
projectTitle={projectTitle} projectTitle={projectTitle}
rightInset={panelOcclusionRight} rightInset={panelOcclusionRight}
/> />
@ -11509,7 +11139,6 @@ export default function CanvasEditorLegacyPage({
createUrl={createNewCanvasUrl} createUrl={createNewCanvasUrl}
currentId={id} currentId={id}
onClose={() => setPanelMode("chat")} onClose={() => setPanelMode("chat")}
onNavigate={requestCanvasLeave}
workspaceLabel={workspaceLabel} workspaceLabel={workspaceLabel}
workspaceType={activeCanvasWorkspaceType} workspaceType={activeCanvasWorkspaceType}
/> />

View File

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

View File

@ -3,7 +3,6 @@ import { Toaster } from "sonner";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { FaviconUpdater } from "@/components/favicon-updater"; import { FaviconUpdater } from "@/components/favicon-updater";
import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer";
import { AppUpdateNotice } from "@/components/app-update-notice";
import { getSiteSettings } from "@/lib/db/queries"; import { getSiteSettings } from "@/lib/db/queries";
import "./globals.css"; import "./globals.css";
@ -216,7 +215,6 @@ export default function RootLayout({
enableSystem enableSystem
> >
<Toaster position="top-center" /> <Toaster position="top-center" />
<AppUpdateNotice />
<SessionProvider>{children}</SessionProvider> <SessionProvider>{children}</SessionProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@ -2,7 +2,6 @@
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download"; import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
import { useChat } from "@ai-sdk/react"; 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 { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery"; import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch"; import type { DetailReplacementBatchCard } from "@/lib/agents/detail-replacement-batch";
@ -159,7 +158,6 @@ import type {
} from "@/types/agent"; } from "@/types/agent";
import { import {
createEmptyAgentModeWorkspaceState, createEmptyAgentModeWorkspaceState,
MAX_DETAIL_REPLACEMENT_REFERENCES,
} from "@/lib/agents/mode-workspace-state"; } from "@/lib/agents/mode-workspace-state";
import { import {
createEmptyAgentGenerationProjectWorkspaceState, createEmptyAgentGenerationProjectWorkspaceState,
@ -177,7 +175,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null; workflowFeatureSettings?: AgentWorkflowPageFeatureSettings | null;
workflowPromptOptimizationEnabled?: boolean; workflowPromptOptimizationEnabled?: boolean;
getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null; getLatestWorkflowContext?: () => AgentWorkflowComposerContext | null;
getWorkflowPromptStatus?: () => "ready" | "loading" | "error";
onBack?: () => void; onBack?: () => void;
brandStorePanel?: ReactNode; brandStorePanel?: ReactNode;
materialSelectionPanel?: ReactNode; materialSelectionPanel?: ReactNode;
@ -214,7 +211,6 @@ export interface AgentGenerationWorkspaceDarkV2Props {
interface AgentWorkflowModelOption { interface AgentWorkflowModelOption {
id: string; id: string;
displayName: string; displayName: string;
providerName?: string;
providerType: string; providerType: string;
supportsImageGen?: boolean; supportsImageGen?: boolean;
isMultimodal?: boolean; isMultimodal?: boolean;
@ -2645,9 +2641,7 @@ function getStoredAgentPendingGenerations(): LocalGenerationPendingState[] {
? record.requestedCardIds ? record.requestedCardIds
.map((entry) => String(entry || "").trim()) .map((entry) => String(entry || "").trim())
.filter(Boolean) .filter(Boolean)
.slice(0, record.modeType === "detail_replacement" .slice(0, MAX_AGENT_MODE_REFERENCE_COUNT)
? MAX_DETAIL_REPLACEMENT_REFERENCES
: MAX_AGENT_MODE_REFERENCE_COUNT)
: null, : null,
} satisfies LocalGenerationPendingState; } satisfies LocalGenerationPendingState;
}); });
@ -3090,7 +3084,6 @@ export function AgentGenerationWorkspaceDarkV2({
workflowFeatureSettings = null, workflowFeatureSettings = null,
workflowPromptOptimizationEnabled = true, workflowPromptOptimizationEnabled = true,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
onBack, onBack,
brandStorePanel, brandStorePanel,
materialSelectionPanel, materialSelectionPanel,
@ -3388,13 +3381,12 @@ export function AgentGenerationWorkspaceDarkV2({
} }
); );
const { data: modelsData, error: modelsError, mutate: refreshModels } = useSWR<{ const { data: modelsData } = useSWR<{
models: AgentWorkflowModelOption[]; models: AgentWorkflowModelOption[];
defaultModelId?: string | null; defaultModelId?: string | null;
agentWorkflowConfiguredDefaultModelId?: string | null; agentWorkflowConfiguredDefaultModelId?: string | null;
agentWorkflowDefaultModelId?: string | null; agentWorkflowDefaultModelId?: string | null;
agentWorkflowConfiguredModels?: AgentWorkflowModelOption[]; agentWorkflowConfiguredModels?: AgentWorkflowModelOption[];
detailReplacementModel?: AgentWorkflowModelOption | null;
agentModel?: any; agentModel?: any;
}>("/api/models", fetcher); }>("/api/models", fetcher);
@ -3440,6 +3432,17 @@ export function AgentGenerationWorkspaceDarkV2({
const activeBusinessModeDefaultModelId = !requiresPresetSelection const activeBusinessModeDefaultModelId = !requiresPresetSelection
? String(activeBusinessMode?.defaultModelId || "").trim() || null ? String(activeBusinessMode?.defaultModelId || "").trim() || null
: null; : null;
const lockedWorkflowModelId =
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId =
lockedWorkflowModelId ||
modelsData?.defaultModelId ||
null;
const presetDrivenResultModeType: AgentGenerationResultModeType = const presetDrivenResultModeType: AgentGenerationResultModeType =
selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement" selectedAgentPreset?.type === "image" && presetModeConfig.modeType === "detail_replacement"
? detailReplacementMethod === "ai_detail_generate" ? detailReplacementMethod === "ai_detail_generate"
@ -3455,19 +3458,6 @@ export function AgentGenerationWorkspaceDarkV2({
const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection const currentResultModeType: AgentGenerationResultModeType = requiresPresetSelection
? presetDrivenResultModeType ? presetDrivenResultModeType
: businessModeResultModeType; : businessModeResultModeType;
const detailReplacementModel = currentResultModeType === "detail_replacement"
? modelsData?.detailReplacementModel
: null;
const isDetailReplacementModelLoading = currentResultModeType === "detail_replacement" && !modelsData;
const lockedWorkflowModelId =
detailReplacementModel?.id ||
activeBusinessModeDefaultModelId ||
selectedAgentPreset?.defaultModelId ||
modelsData?.agentWorkflowConfiguredDefaultModelId ||
modelsData?.agentWorkflowDefaultModelId ||
null;
const isWorkflowModelLocked = Boolean(lockedWorkflowModelId);
const effectiveDefaultModelId = lockedWorkflowModelId || modelsData?.defaultModelId || null;
const isSceneGenerationPreset = currentResultModeType === "scene_generation"; const isSceneGenerationPreset = currentResultModeType === "scene_generation";
const isDetailReplacementPreset = const isDetailReplacementPreset =
currentResultModeType === "detail_replacement" || currentResultModeType === "detail_replacement" ||
@ -4119,15 +4109,12 @@ export function AgentGenerationWorkspaceDarkV2({
); );
const executableModelOptions = useMemo(() => { const executableModelOptions = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
const workflowModels = detailReplacementModel return [...selectableModels, ...configuredWorkflowModels].filter((model) => {
? [detailReplacementModel, ...configuredWorkflowModels]
: configuredWorkflowModels;
return [...selectableModels, ...workflowModels].filter((model) => {
if (seen.has(model.id)) return false; if (seen.has(model.id)) return false;
seen.add(model.id); seen.add(model.id);
return true; return true;
}); });
}, [configuredWorkflowModels, detailReplacementModel, selectableModels]); }, [configuredWorkflowModels, selectableModels]);
const executableModelIdSet = useMemo( const executableModelIdSet = useMemo(
() => new Set(executableModelOptions.map((model) => model.id)), () => new Set(executableModelOptions.map((model) => model.id)),
[executableModelOptions] [executableModelOptions]
@ -4146,9 +4133,8 @@ export function AgentGenerationWorkspaceDarkV2({
selectableModels[0], selectableModels[0],
[executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId] [executableModelOptions, lockedWorkflowModel, selectableModels, selectedModelId]
); );
const lockedWorkflowModelLabel = detailReplacementModel?.providerName const lockedWorkflowModelLabel =
? `${detailReplacementModel.displayName} 「${detailReplacementModel.providerName}」` lockedWorkflowModel?.displayName ||
: lockedWorkflowModel?.displayName ||
selectedAgentPreset?.defaultModel?.displayName || selectedAgentPreset?.defaultModel?.displayName ||
(lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置"); (lockedWorkflowModelId ? `配置模型不可用(${lockedWorkflowModelId})` : "未配置");
const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings"); const [activeToolStep, setActiveToolStep] = useState<ToolStepKey>("settings");
@ -4893,14 +4879,6 @@ export function AgentGenerationWorkspaceDarkV2({
return recoverReplacementRun({ messages, ...pendingGeneration }); return recoverReplacementRun({ messages, ...pendingGeneration });
}, [messages, pendingGeneration]); }, [messages, pendingGeneration]);
const transportIsGenerating = status === "streaming" || status === "submitted"; 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(() => { const pendingGenerationProgressCount = useMemo(() => {
if (!pendingGeneration) { if (!pendingGeneration) {
return 0; return 0;
@ -6327,24 +6305,10 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${detailReferenceSourceLabel}参考图`);
return; return;
} }
if (isDetailReplacementModelLoading) {
if (modelsError) {
toast.error("详情替换模型加载失败,正在重新读取");
void refreshModels().catch(() => {});
} else {
toast.info("正在读取详情替换模型,请稍候");
}
return;
}
if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) { if (isSkuReplacementPreset && skuReplacementReferenceCards.length === 0) {
toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`); toast.error(`请先查询并选择${skuReferenceSourceLabel}参考图`);
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext; const resolvedWorkflowContext = getLatestWorkflowContext?.() ?? workflowContext;
if ( if (
isDetailReplacementPreset && isDetailReplacementPreset &&
@ -6403,10 +6367,6 @@ export function AgentGenerationWorkspaceDarkV2({
} }
if (replacementModeType === "detail_replacement") { if (replacementModeType === "detail_replacement") {
if (detailBatchSubmittingRef.current) return; if (detailBatchSubmittingRef.current) return;
if (replacementExecutionCards.length > MAX_DETAIL_REPLACEMENT_REFERENCES) {
toast.error(`本次选中了 ${replacementExecutionCards.length} 张详情参考图,一次最多替换 ${MAX_DETAIL_REPLACEMENT_REFERENCES} 张,请减少本次选择后重试`);
return;
}
if (!isValidChatUuid(projectId)) { if (!isValidChatUuid(projectId)) {
toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换"); toast.error("当前风格化项目尚未保存,请先保存项目后再执行详情替换");
return; return;
@ -6427,14 +6387,13 @@ export function AgentGenerationWorkspaceDarkV2({
const saved = await onPersistProject?.(); const saved = await onPersistProject?.();
if (saved === false) throw new Error("项目尚未保存,请重试"); if (saved === false) throw new Error("项目尚未保存,请重试");
const requestId = generateUUID(); const requestId = generateUUID();
const response = await fetch("/api/agents/detail-replacement/batches", { const response = await fetchWithErrorHandlers("/api/agents/detail-replacement/batches", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
projectId, projectId,
requestId, requestId,
model: resolvedModelId, model: resolvedModelId,
expectedModelId: resolvedModelId === "auto" ? undefined : resolvedModelId,
imageSize: imageParams.imageSize, imageSize: imageParams.imageSize,
replacementStrategy: detailReplacementStrategy, replacementStrategy: detailReplacementStrategy,
prompt: detailReplacementStrategy === "product_only" prompt: detailReplacementStrategy === "product_only"
@ -6458,7 +6417,6 @@ export function AgentGenerationWorkspaceDarkV2({
}); });
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.task?.id) { if (!response.ok || !payload?.task?.id) {
if (payload?.code === "DETAIL_REPLACEMENT_MODEL_CHANGED") void refreshModels().catch(() => {});
throw new Error(payload?.error || "详情替换任务提交失败"); throw new Error(payload?.error || "详情替换任务提交失败");
} }
setPendingGeneration({ setPendingGeneration({
@ -6646,8 +6604,6 @@ export function AgentGenerationWorkspaceDarkV2({
input, input,
isDetailAiGenerationMethod, isDetailAiGenerationMethod,
isDetailReplacementPreset, isDetailReplacementPreset,
isDetailReplacementModelLoading,
modelsError,
isSelectedBusinessModeUnavailable, isSelectedBusinessModeUnavailable,
isSkuReplacementPreset, isSkuReplacementPreset,
effectiveDefaultModelId, effectiveDefaultModelId,
@ -6664,7 +6620,6 @@ export function AgentGenerationWorkspaceDarkV2({
skuResultCards, skuResultCards,
userMessageCount, userMessageCount,
getLatestWorkflowContext, getLatestWorkflowContext,
getWorkflowPromptStatus,
isSceneGenerationPreset, isSceneGenerationPreset,
workflowContext, workflowContext,
sceneMessageAttachments, sceneMessageAttachments,
@ -6675,7 +6630,6 @@ export function AgentGenerationWorkspaceDarkV2({
onPersistProject, onPersistProject,
projectId, projectId,
refreshDetailBatch, refreshDetailBatch,
refreshModels,
]); ]);
const handleStopGeneration = useCallback(() => { const handleStopGeneration = useCallback(() => {
detailGenerationCancelRef.current = true; detailGenerationCancelRef.current = true;
@ -7839,11 +7793,6 @@ export function AgentGenerationWorkspaceDarkV2({
toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数"); toast.error(detailGenerationValidationIssues[0] || "请先补齐 AI详情生成参数");
return; return;
} }
const promptStatus = getWorkflowPromptStatus?.();
if (promptStatus === "loading" || promptStatus === "error") {
toast.error(promptStatus === "error" ? "请先在风格模板区重试加载提示词" : "正在加载所选模板的提示词,请稍候");
return;
}
const productAttachments = detailGenerationProductSlotIndexes.map((index) => const productAttachments = detailGenerationProductSlotIndexes.map((index) =>
detailGenerationSlots[index] detailGenerationSlots[index]
@ -9448,11 +9397,9 @@ export function AgentGenerationWorkspaceDarkV2({
</Select> </Select>
) : ( ) : (
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70"> <div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 text-sm text-white/70">
{isDetailReplacementModelLoading {isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
? modelsError ? "详情替换模型加载失败,点击批量替换可重试" : "正在读取详情替换模型"
: isWorkflowModelLocked ? "后台已锁定生图模型:" : "当前生图模式使用默认模型:"}
<span className="ml-2 font-medium text-white"> <span className="ml-2 font-medium text-white">
{isDetailReplacementModelLoading ? null : isWorkflowModelLocked {isWorkflowModelLocked
? lockedWorkflowModelLabel ? lockedWorkflowModelLabel
: currentModel?.displayName || "自动选择"} : currentModel?.displayName || "自动选择"}
</span> </span>

View File

@ -1,76 +0,0 @@
"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>;
}

View File

@ -2,13 +2,9 @@
import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming"; import { advancePastAtomicRanges, scanMarkdownImageStreaming } from "@/lib/canvas/markdown-image-streaming";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
import { useStickToBottom } from "use-stick-to-bottom";
import { DefaultChatTransport } from "ai"; import { DefaultChatTransport } from "ai";
import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing"; import { shouldUseCanvasAgentRuntime } from "@/lib/ai/canvas-agent-runtime/routing";
import { CanvasAgentProgressMessage } from "@/components/canvas/chat-panel/canvas-agent-progress"; 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 { canvasHistoryForChat } from "@/lib/client/canvas-history-response";
import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run"; import { useCanvasAgentRun } from "@/components/canvas/chat-panel/use-canvas-agent-run";
import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress"; import { canvasAgentProgress, type CanvasAgentProgress } from "@/lib/ai/canvas-agent-runtime/progress";
@ -71,7 +67,6 @@ import { toast } from "sonner";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getGatewayErrorPageMessage } from "@/lib/request-error"; import { getGatewayErrorPageMessage } from "@/lib/request-error";
import { findImageGenerationFailure } from "@/lib/canvas/image-generation-status";
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { import {
fetchUploadWithRetry, fetchUploadWithRetry,
@ -3247,8 +3242,6 @@ export function CanvasChatPanel({
onRequireLogin, onRequireLogin,
onBeforeAgentRequest, onBeforeAgentRequest,
}: CanvasChatPanelProps) { }: CanvasChatPanelProps) {
const runCanvasRemoteOperation = useCanvasRemoteOperation();
const applyCanvasRemoteSnapshot = useCanvasRemoteSnapshot();
const isWorkspacePresentation = presentation === "workspace"; const isWorkspacePresentation = presentation === "workspace";
const isVideoWorkspace = workspaceVariant === "video"; const isVideoWorkspace = workspaceVariant === "video";
const propCanvasToolbarConfig = useMemo( const propCanvasToolbarConfig = useMemo(
@ -3347,7 +3340,6 @@ export function CanvasChatPanel({
const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId); const activeChatIdRef = useRef<string>(requestedChatSessionId || chatId);
const activeMessagesRef = useRef<ChatMessage[]>([]); const activeMessagesRef = useRef<ChatMessage[]>([]);
const inFlightChatIdRef = useRef<string | null>(null); const inFlightChatIdRef = useRef<string | null>(null);
const imageGenerationUserMessageIdsRef = useRef<Record<string, string>>({});
const inFlightRawChatIdRef = useRef<string | null>(null); const inFlightRawChatIdRef = useRef<string | null>(null);
const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]); const [chatSessions, setChatSessions] = useState<CanvasChatSession[]>([]);
const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false);
@ -3369,15 +3361,15 @@ export function CanvasChatPanel({
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]); const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null); const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(null);
const suppressSkillTagClickRef = useRef(false); const suppressSkillTagClickRef = useRef(false);
const { scrollRef: messagesScrollRef, contentRef: messagesContentRef, scrollToBottom: scrollMessagesToBottom, isAtBottom: isMessageListAtBottom } = useStickToBottom({ initial: false, resize: "instant" }); const [messageViewportKey, setMessageViewportKey] = useState(0);
const initialScrollChatRef = useRef<string | null>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const clawdFolderInputRef = useRef<HTMLInputElement | null>(null); const clawdFolderInputRef = useRef<HTMLInputElement | null>(null);
const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const videoSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const agentSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]); const detailSlotInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]); const [clawdTableFiles, setClawdTableFiles] = useState<File[]>([]);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const detailModeSwitchRef = useRef(false); const detailModeSwitchRef = useRef(false);
const hiddenPromptRef = useRef<string | null>(null); const hiddenPromptRef = useRef<string | null>(null);
const autoContextRef = useRef<string | null>(null); const autoContextRef = useRef<string | null>(null);
@ -3406,6 +3398,7 @@ export function CanvasChatPanel({
const hasInitializedVideoWorkspaceRef = useRef(false); const hasInitializedVideoWorkspaceRef = useRef(false);
const sessionMenuRef = useRef<HTMLDivElement>(null); const sessionMenuRef = useRef<HTMLDivElement>(null);
const clawdHistoryMenuRef = useRef<HTMLDivElement>(null); const clawdHistoryMenuRef = useRef<HTMLDivElement>(null);
const historyScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const forceHistoryHydrationRef = useRef<string | null>(null); const forceHistoryHydrationRef = useRef<string | null>(null);
const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null); const copiedUserMessageRef = useRef<CopiedUserMessagePayload | null>(null);
const composerUserEditedAtRef = useRef(0); const composerUserEditedAtRef = useRef(0);
@ -3649,20 +3642,17 @@ export function CanvasChatPanel({
const setStreamImageGenerationStateForChat = useCallback( const setStreamImageGenerationStateForChat = useCallback(
(chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => { (chatSessionId: string, next: ImageGenerationStatusStreamPayload | null) => {
const userMessageId = imageGenerationUserMessageIdsRef.current[chatSessionId];
if (next?.userMessageId && userMessageId && next.userMessageId !== userMessageId) return;
setStreamImageGenerationStateByChatId((prev) => ({ setStreamImageGenerationStateByChatId((prev) => ({
...prev, ...prev,
[chatSessionId]: next ? { ...next, userMessageId: next.userMessageId || userMessageId } : null, [chatSessionId]: next,
})); }));
}, },
[] []
); );
const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string, preserveFailure = false) => { const clearStreamImageGenerationStateForChat = useCallback((chatSessionId: string) => {
setStreamImageGenerationStateByChatId((prev) => { setStreamImageGenerationStateByChatId((prev) => {
if (!(chatSessionId in prev)) return prev; if (!(chatSessionId in prev)) return prev;
if (preserveFailure && prev[chatSessionId]?.phase === "failed") return prev;
const next = { ...prev }; const next = { ...prev };
delete next[chatSessionId]; delete next[chatSessionId];
return next; return next;
@ -5035,15 +5025,6 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 已插入图片标签:', url.substring(0, 50)); 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 handleCopyUserMessage = useCallback(async (message: ChatMessage) => {
const payload = getUserMessageCopyPayload(message); const payload = getUserMessageCopyPayload(message);
if (!payload.text && payload.attachments.length === 0) { if (!payload.text && payload.attachments.length === 0) {
@ -6108,7 +6089,7 @@ export function CanvasChatPanel({
const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video") const activeComposerMode = unifiedImageAgent && (rawComposerMode === "image" || rawComposerMode === "video")
? "agent" : rawComposerMode; ? "agent" : rawComposerMode;
const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent"; const isImageAgentComposer = unifiedImageAgent && activeComposerMode === "agent";
const [imageAgentEnabled, setImageAgentEnabled] = useState(true); const [imageAgentEnabled, setImageAgentEnabled] = useState(false);
const imageAgentEnabledRef = useRef(imageAgentEnabled); const imageAgentEnabledRef = useRef(imageAgentEnabled);
imageAgentEnabledRef.current = imageAgentEnabled; imageAgentEnabledRef.current = imageAgentEnabled;
const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video"; const isVideoToolbarDark = isVideoWorkspace || activeComposerMode === "video";
@ -7991,12 +7972,8 @@ export function CanvasChatPanel({
const store = useCanvasStore.getState(); const store = useCanvasStore.getState();
store.markAgentOperationRefresh(); 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.loadCanvas(result.data.canvasData);
store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || "")); store.setCanvasRevision(String(result.data.updatedAt || ""), String(result.data.updatedAt || ""));
}
if (createdNodeIds.length > 0) { if (createdNodeIds.length > 0) {
const refreshedStore = useCanvasStore.getState(); const refreshedStore = useCanvasStore.getState();
refreshedStore.selectNodes(createdNodeIds); refreshedStore.selectNodes(createdNodeIds);
@ -8024,7 +8001,7 @@ export function CanvasChatPanel({
return false; return false;
} }
}, },
[applyCanvasRemoteSnapshot, applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate] [applyCanvasViewOperation, canvasId, focusCanvasOnNode, mutate]
); );
const { const {
@ -8041,8 +8018,6 @@ export function CanvasChatPanel({
api: "/api/chat", api: "/api/chat",
fetch: fetchWithErrorHandlers, fetch: fetchWithErrorHandlers,
async prepareSendMessagesRequest(request) { async prepareSendMessagesRequest(request) {
const userMessageId = request.messages.findLast((item) => item.role === "user")?.id;
if (userMessageId) imageGenerationUserMessageIdsRef.current[request.id] = userMessageId;
// 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID // 从 canvas-{uuid} 格式中提取纯 UUID,后端 schema 要求纯 UUID
const rawId = request.id.startsWith('canvas-') const rawId = request.id.startsWith('canvas-')
? request.id.replace('canvas-', '') ? request.id.replace('canvas-', '')
@ -8155,27 +8130,20 @@ export function CanvasChatPanel({
const payload: ImageGenerationStatusStreamPayload = const payload: ImageGenerationStatusStreamPayload =
typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data;
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
const userMessageId = imageGenerationUserMessageIdsRef.current[eventChatId];
if (payload.userMessageId && userMessageId && payload.userMessageId !== userMessageId) return;
if (payload.phase === "completed") { if (payload.phase === "completed") {
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: "finalizing", phase: "finalizing",
userMessageId: payload.userMessageId,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
}); });
window.setTimeout(() => { window.setTimeout(() => {
if (payload.userMessageId && imageGenerationUserMessageIdsRef.current[eventChatId] !== payload.userMessageId) return;
clearStreamImageGenerationStateForChat(eventChatId); clearStreamImageGenerationStateForChat(eventChatId);
}, 30_000); }, 30_000);
return; return;
} }
setStreamImageGenerationStateForChat(eventChatId, { setStreamImageGenerationStateForChat(eventChatId, {
phase: payload.phase || "processing", phase: payload.phase || "processing",
userMessageId: payload.userMessageId,
requestId: payload.requestId,
failure: payload.failure,
providerType: payload.providerType, providerType: payload.providerType,
modelName: getAgentSafeModelName(payload.modelName), modelName: getAgentSafeModelName(payload.modelName),
imageCount: Number(payload.imageCount || 0) || undefined, imageCount: Number(payload.imageCount || 0) || undefined,
@ -8443,11 +8411,6 @@ export function CanvasChatPanel({
) )
: false; : false;
const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : []; const normalizedParts = Array.isArray(messageData.parts) ? messageData.parts : [];
const failureStatus = findImageGenerationFailure(
[{ role: messageData.role, parts: normalizedParts }],
imageGenerationUserMessageIdsRef.current[eventChatId],
);
if (failureStatus) setStreamImageGenerationStateForChat(eventChatId, failureStatus);
const normalizedAttachments = Array.isArray(messageData.attachments) const normalizedAttachments = Array.isArray(messageData.attachments)
? messageData.attachments ? messageData.attachments
: []; : [];
@ -8516,7 +8479,7 @@ export function CanvasChatPanel({
(hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) && (hasImageGenEndMarker || incomingImageUrls.length > 0 || incomingVideoUrls.length > 0) &&
!shouldDeferClearForActiveMedia !shouldDeferClearForActiveMedia
) { ) {
clearStreamImageGenerationStateForChat(eventChatId, true); clearStreamImageGenerationStateForChat(eventChatId);
} }
if (!isEventForActiveChat) { if (!isEventForActiveChat) {
@ -8842,6 +8805,12 @@ export function CanvasChatPanel({
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(dbMessages)); setMessages(sortCanvasMessagesByCreatedAt(dbMessages));
if (dbMessages.length > 0) {
requestAnimationFrame(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
});
}
}; };
const reloadMessagesOnce = async () => { const reloadMessagesOnce = async () => {
@ -8954,7 +8923,7 @@ export function CanvasChatPanel({
onRunChange: (run) => { onRunChange: (run) => {
// The live stream owns its message; polling only recovers disconnected replies. // The live stream owns its message; polling only recovers disconnected replies.
if (status === "submitted" || status === "streaming") return; if (status === "submitted" || status === "streaming") return;
if (!run?.assistantMessageId || !run.assistantText || run.chatId !== toRawCanvasChatId(activeChatIdRef.current)) return; if (!run?.assistantMessageId || !run.assistantText || run.chatId !== activeChatIdRef.current) return;
// Recover the same assistant reply after a stream disconnect or refresh. // Recover the same assistant reply after a stream disconnect or refresh.
setMessages(current => { setMessages(current => {
const index = current.findIndex(message => message.id === run.assistantMessageId); const index = current.findIndex(message => message.id === run.assistantMessageId);
@ -8969,33 +8938,6 @@ export function CanvasChatPanel({
}); });
const activeCanvasAgentRun = canvasAgentTask.run; const activeCanvasAgentRun = canvasAgentTask.run;
const isCanvasAgentRunning = activeCanvasAgentRun?.status === "running"; 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( const restorePendingClawdRequestToComposer = useCallback(
(snapshot: PendingClawdRequestRestoreSnapshot) => { (snapshot: PendingClawdRequestRestoreSnapshot) => {
@ -9139,7 +9081,6 @@ export function CanvasChatPanel({
}, },
} as ChatMessage; } as ChatMessage;
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(launchUserMessage as any); sendMessage(launchUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -9159,7 +9100,7 @@ export function CanvasChatPanel({
if (editableDiv) { if (editableDiv) {
editableDiv.innerHTML = ""; editableDiv.innerHTML = "";
} }
}, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode, scrollMessagesToBottom]); }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]);
// 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新)
const loadedHistorySignatureRef = useRef<string>(""); const loadedHistorySignatureRef = useRef<string>("");
@ -9179,6 +9120,7 @@ export function CanvasChatPanel({
hiddenPromptRef.current = null; hiddenPromptRef.current = null;
autoContextRef.current = null; autoContextRef.current = null;
pendingLaunchRef.current = null; pendingLaunchRef.current = null;
setMessageViewportKey((prev) => prev + 1);
setMessages([]); setMessages([]);
setInput(""); setInput("");
setAttachments([]); setAttachments([]);
@ -9193,20 +9135,11 @@ export function CanvasChatPanel({
}, [activeChatId, setMessages]); }, [activeChatId, setMessages]);
useEffect(() => { useEffect(() => {
const draft = readCanvasComposerDraft<PickedObjectAttachment>(activeChatId); if (historyScrollTimerRef.current) {
if (!draft || (input && input !== draft.text)) return; clearTimeout(historyScrollTimerRef.current);
if (!input) setInput(draft.text); historyScrollTimerRef.current = null;
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; if (!historyData || !rawChatId) return;
const historyMessages = historyData.messages || []; const historyMessages = historyData.messages || [];
const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage); const isClawdHistory = isClawdMode || historyMessages.some(isClawdChatMessage);
@ -9247,14 +9180,29 @@ export function CanvasChatPanel({
missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages missingPendingMessage ? [...historyMessages, pendingMessage] : historyMessages
); );
}); });
setMessageViewportKey((prev) => prev + 1);
console.log("[CanvasChat] 已同步历史消息:", historyMessages.length, signature); 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]); }, [activeChatId, activeClawdRequestUi?.userMessageId, historyData, isClawdMode, isClawdSubmitting, rawChatId, setMessages, status]);
useEffect(() => { useEffect(() => {
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!rawChatId || !streamState || (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown")) return; if (!rawChatId || !streamState) return;
let cancelled = false; let cancelled = false;
const poll = async () => { const poll = async () => {
@ -9278,13 +9226,6 @@ export function CanvasChatPanel({
const streamState = streamImageGenerationStateByChatId[activeChatId]; const streamState = streamImageGenerationStateByChatId[activeChatId];
if (!streamState || messages.length === 0) return; if (!streamState || messages.length === 0) return;
const failure = findImageGenerationFailure(messages, streamState.userMessageId || "");
if (failure && streamState.phase !== "failed") {
setStreamImageGenerationStateForChat(activeChatId, failure);
return;
}
if (streamState.phase === "failed" && streamState.failure?.outcome !== "unknown") return;
const lastUserIndex = [...messages] const lastUserIndex = [...messages]
.map((message) => message.role) .map((message) => message.role)
.lastIndexOf("user"); .lastIndexOf("user");
@ -9301,7 +9242,8 @@ export function CanvasChatPanel({
.join("\n"); .join("\n");
return ( return (
extractAssistantImageUrls(textContent).length > 0 || extractAssistantImageUrls(textContent).length > 0 ||
extractVideoUrls(textContent).length > 0 extractVideoUrls(textContent).length > 0 ||
/失败|超时|重试|error|failed/i.test(textContent)
); );
}); });
@ -9322,7 +9264,6 @@ export function CanvasChatPanel({
messages, messages,
streamImageGenerationStateByChatId, streamImageGenerationStateByChatId,
clearStreamImageGenerationStateForChat, clearStreamImageGenerationStateForChat,
setStreamImageGenerationStateForChat,
]); ]);
useEffect(() => { useEffect(() => {
@ -9829,13 +9770,11 @@ export function CanvasChatPanel({
}); });
}, [activeChatSessionMode, messages, nodes, updateNode]); }, [activeChatSessionMode, messages, nodes, updateNode]);
// Open each conversation at its latest message; later updates respect manual scrolling. // 滚动到底部
useEffect(() => { useEffect(() => {
if (messages.length === 0 && !isClawdSubmitting) return; if (messages.length === 0 && !isClawdSubmitting) return;
if (initialScrollChatRef.current === activeChatId) return; messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
initialScrollChatRef.current = activeChatId; }, [isClawdSubmitting, messages]);
void scrollMessagesToBottom({ animation: "instant" });
}, [activeChatId, isClawdSubmitting, messages.length, scrollMessagesToBottom]);
// 标记是否已经为当前画布设置过标题(避免重复调用) // 标记是否已经为当前画布设置过标题(避免重复调用)
const hasSetCanvasTitle = useRef(false); const hasSetCanvasTitle = useRef(false);
@ -12604,7 +12543,6 @@ export function CanvasChatPanel({
const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim(); const supplement = typeof overrideInput === "string" ? overrideInput.trim() : input.trim();
if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) { if (isCanvasAgentRunning && !isClawdMode && imageAgentEnabled) {
if (supplement && await canvasAgentTask.control("steer", supplement)) { if (supplement && await canvasAgentTask.control("steer", supplement)) {
void scrollMessagesToBottom({ animation: "instant" });
setInput(""); setInput("");
const editor = textareaRef.current as HTMLDivElement | null; const editor = textareaRef.current as HTMLDivElement | null;
if (editor) editor.innerHTML = ""; if (editor) editor.innerHTML = "";
@ -12902,7 +12840,6 @@ export function CanvasChatPanel({
requestChatId, requestChatId,
clawdRequestRestoreSnapshot clawdRequestRestoreSnapshot
); );
void scrollMessagesToBottom({ animation: "instant" });
startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint); startClawdRequestUi(requestChatId, userMessageId, nextProcessingHint);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13391,26 +13328,6 @@ export function CanvasChatPanel({
}); });
inFlightChatIdRef.current = activeChatId; inFlightChatIdRef.current = activeChatId;
inFlightRawChatIdRef.current = rawChatId; 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); clearStreamImageGenerationStateForChat(activeChatId);
clearAgentProgressStateForChat(activeChatId); clearAgentProgressStateForChat(activeChatId);
if (shouldShowOptimisticImageGeneration) { if (shouldShowOptimisticImageGeneration) {
@ -13435,7 +13352,6 @@ export function CanvasChatPanel({
// 使用与 multimodal-input 相同的格式发送消息 // 使用与 multimodal-input 相同的格式发送消息
try { try {
void scrollMessagesToBottom({ animation: "instant" });
sendMessage(optimisticUserMessage as any); sendMessage(optimisticUserMessage as any);
setMessages((previousMessages) => setMessages((previousMessages) =>
sortCanvasMessagesByCreatedAt([ sortCanvasMessagesByCreatedAt([
@ -13474,9 +13390,6 @@ export function CanvasChatPanel({
} }
}, [ }, [
isCanvasAgentRunning, isCanvasAgentRunning,
scrollMessagesToBottom,
onBeforeAgentRequest,
isVideoWorkspace,
canvasAgentTask.control, canvasAgentTask.control,
activeChatId, activeChatId,
activeComposerMode, activeComposerMode,
@ -13614,19 +13527,23 @@ export function CanvasChatPanel({
console.log('[CanvasChat] 更新画布:', rawId); console.log('[CanvasChat] 更新画布:', rawId);
if (!runCanvasRemoteOperation) return; const updateResponse = await fetch(`/api/canvas/${rawId}`, {
await runCanvasRemoteOperation(async () => { method: 'PUT',
if (useCanvasStore.getState().projectId !== rawId) return; headers: { 'Content-Type': 'application/json' },
const result = await canvasService.updateProject(rawId, { title, thumbnail: firstImageUrl }); body: JSON.stringify({
if (!result.success) throw new Error(result.error || "画布名称同步失败"); title,
thumbnail: firstImageUrl,
}),
}); });
console.log('[CanvasChat] 画布更新响应:', updateResponse.status);
} else { } else {
console.error('[CanvasChat] auto-title API 请求失败:', response.status); console.error('[CanvasChat] auto-title API 请求失败:', response.status);
} }
} catch (error) { } catch (error) {
console.error('[CanvasChat] 自动命名失败:', error); console.error('[CanvasChat] 自动命名失败:', error);
} }
}, [chatId, runCanvasRemoteOperation]); }, [chatId]);
const addUploadedImageAttachmentToComposer = useCallback( const addUploadedImageAttachmentToComposer = useCallback(
(uploaded: ComposerUploadedImage) => { (uploaded: ComposerUploadedImage) => {
@ -15180,7 +15097,7 @@ export function CanvasChatPanel({
!hasGenerationEnd && !hasGenerationEnd &&
Boolean(currentModel || activeComposerMode === "agent")); Boolean(currentModel || activeComposerMode === "agent"));
const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0;
const shouldRenderImageLoading = shouldShowImageLoading && streamImageGenerationState?.phase !== "failed" && !hasActiveTypewriter; const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter;
const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1; const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1;
const loadingProviderType = const loadingProviderType =
streamImageGenerationState?.providerType || streamImageGenerationState?.providerType ||
@ -15243,9 +15160,9 @@ export function CanvasChatPanel({
phase === "queued" phase === "queued"
? "等待开始生成" ? "等待开始生成"
: phase === "finalizing" : phase === "finalizing"
? "正在保存生成结果" ? "正在润饰细节"
: phase === "failed" : phase === "failed"
? streamState.failure?.message || "生成失败" ? "生成失败"
: "正在创建图片"; : "正在创建图片";
const status = phase === "failed" ? "failed" : "generating"; const status = phase === "failed" ? "failed" : "generating";
const currentNodes = useCanvasStore.getState().nodes; const currentNodes = useCanvasStore.getState().nodes;
@ -15301,10 +15218,14 @@ export function CanvasChatPanel({
canvasGenerationPlaceholder: true, canvasGenerationPlaceholder: true,
chatId: activeChatId, chatId: activeChatId,
detailProgressLabel: label, detailProgressLabel: label,
generationProgress: undefined, generationProgress:
generationFailure: streamState.failure, phase === "queued"
generationRequestId: streamState.requestId, ? 12
generationUserMessageId: streamState.userMessageId, : phase === "finalizing"
? 96
: phase === "failed"
? 100
: undefined,
modelName: streamState.modelName || currentModel?.displayName || undefined, modelName: streamState.modelName || currentModel?.displayName || undefined,
providerType: streamState.providerType || currentModel?.providerType || undefined, providerType: streamState.providerType || currentModel?.providerType || undefined,
placementMode: "anchored-generation", placementMode: "anchored-generation",
@ -15398,7 +15319,6 @@ export function CanvasChatPanel({
if ( if (
message.role === "assistant" && message.role === "assistant" &&
!rawParts.some((part: any) => part.type === "data-imageGenerationStatus" && part.data?.failure) &&
isInternalCanvasImageGenerationFailureText(cleanedParts) isInternalCanvasImageGenerationFailureText(cleanedParts)
) { ) {
return acc; return acc;
@ -15708,7 +15628,7 @@ export function CanvasChatPanel({
processedMessageIds.current.clear(); processedMessageIds.current.clear();
hasHydratedHistoryRef.current = true; hasHydratedHistoryRef.current = true;
setMessages(sortCanvasMessagesByCreatedAt(fresh.messages)); setMessages(sortCanvasMessagesByCreatedAt(fresh.messages));
void scrollMessagesToBottom({ animation: "instant" }); setMessageViewportKey((previous) => previous + 1);
}).catch((error) => { }).catch((error) => {
console.error("[CanvasChat] Failed to reopen current conversation:", error); console.error("[CanvasChat] Failed to reopen current conversation:", error);
toast.error("聊天记录暂时读取失败,请重试"); toast.error("聊天记录暂时读取失败,请重试");
@ -15722,7 +15642,7 @@ export function CanvasChatPanel({
replaceChatSessionQuery(sessionId); replaceChatSessionQuery(sessionId);
setIsSessionMenuOpen(false); setIsSessionMenuOpen(false);
toast.success("已切换聊天窗口"); toast.success("已切换聊天窗口");
}, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, scrollMessagesToBottom, setMessages, status]); }, [activeChatId, isClawdSubmitting, mutateHistoryData, releaseSendGuard, replaceChatSessionQuery, setMessages, status]);
const switchToClawdHistorySession = useCallback( const switchToClawdHistorySession = useCallback(
(historyItem: ClawdHistoryItem) => { (historyItem: ClawdHistoryItem) => {
@ -16139,17 +16059,11 @@ export function CanvasChatPanel({
{isDetailEmptyState ? ( {isDetailEmptyState ? (
<div className="flex-1 min-h-0" /> <div className="flex-1 min-h-0" />
) : ( ) : (
<div key={activeChatId} className="relative flex min-h-0 flex-1 flex-col">
<div <div
ref={messagesScrollRef} key={`${activeChatId}-${messageViewportKey}`}
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( className={cn(
"flex-1 min-h-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", "flex-1 min-h-0 overflow-x-hidden",
isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-auto", isPlainCanvasEmptyState ? "overflow-hidden" : "overflow-y-auto",
isWorkspacePresentation ? "p-5" : "p-3", isWorkspacePresentation ? "p-5" : "p-3",
isPlainCanvasEmptyState isPlainCanvasEmptyState
? "pt-0" ? "pt-0"
@ -16158,7 +16072,7 @@ export function CanvasChatPanel({
: "pt-3" : "pt-3"
)} )}
> >
<div ref={messagesContentRef} className={cn( <div className={cn(
"space-y-5 overflow-x-hidden pr-1", "space-y-5 overflow-x-hidden pr-1",
isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24" isPlainCanvasEmptyState ? "h-full pb-0" : "pb-24"
)}> )}>
@ -17361,22 +17275,9 @@ export function CanvasChatPanel({
/> />
) : null} ) : null}
<div ref={messagesEndRef} />
</div> </div>
</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( {fullscreenPreview && createPortal(
@ -18623,7 +18524,7 @@ export function CanvasChatPanel({
)} )}
> >
<div <div
ref={setComposerEditorRef} ref={textareaRef as any}
contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)} contentEditable={!isSubmitting || (isCanvasAgentRunning && !canvasAgentTask.stopPending)}
role="textbox" role="textbox"
tabIndex={isSubmitting ? -1 : 0} tabIndex={isSubmitting ? -1 : 0}
@ -18657,7 +18558,6 @@ export function CanvasChatPanel({
setTagPreview(null); setTagPreview(null);
}} }}
onInput={(e) => { onInput={(e) => {
clearCanvasComposerDraft(activeChatId);
// 从 contenteditable 中提取纯文本(排除附件标签文本) // 从 contenteditable 中提取纯文本(排除附件标签文本)
const target = e.target as HTMLDivElement; const target = e.target as HTMLDivElement;
const clone = target.cloneNode(true) as HTMLDivElement; const clone = target.cloneNode(true) as HTMLDivElement;

View File

@ -19,7 +19,6 @@ const PROJECT_LIST_PAGE_SIZE = 24;
interface CanvasFileListPanelProps { interface CanvasFileListPanelProps {
currentId: string; currentId: string;
onClose: () => void; onClose: () => void;
onNavigate?: (url: string) => void;
workspaceType?: CanvasWorkspaceType; workspaceType?: CanvasWorkspaceType;
workspaceLabel?: string; workspaceLabel?: string;
createUrl?: string; createUrl?: string;
@ -29,7 +28,6 @@ interface CanvasFileListPanelProps {
export function CanvasFileListPanel({ export function CanvasFileListPanel({
currentId, currentId,
onClose, onClose,
onNavigate,
workspaceType = "canvas", workspaceType = "canvas",
workspaceLabel = "画布", workspaceLabel = "画布",
createUrl = "/canvas?new=1", createUrl = "/canvas?new=1",
@ -90,10 +88,6 @@ export function CanvasFileListPanel({
// 创建新画布 // 创建新画布
const handleCreateNew = async () => { const handleCreateNew = async () => {
if (onNavigate) {
onNavigate(createUrl);
return;
}
router.push(createUrl); router.push(createUrl);
}; };
@ -106,10 +100,6 @@ export function CanvasFileListPanel({
} }
const targetUrl = buildProjectUrl(id); const targetUrl = buildProjectUrl(id);
onClose(); // 关闭文件列表面板 onClose(); // 关闭文件列表面板
if (onNavigate) {
onNavigate(targetUrl);
return;
}
// 强制整页跳转,避免仅更新 URL 不刷新 // 强制整页跳转,避免仅更新 URL 不刷新
setTimeout(() => { setTimeout(() => {
window.location.assign(targetUrl); window.location.assign(targetUrl);

View File

@ -1,26 +1,11 @@
"use client"; "use client";
import { createContext, useContext } from "react"; 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< export const CanvasRemoteOperationContext = createContext<
{ ((operation: () => Promise<void>) => Promise<void>) | null
run: (operation: CanvasRemoteOperation) => Promise<void>;
applySnapshot: (result: CanvasRemoteOperationResult) => boolean;
} | null
>(null); >(null);
export function useCanvasRemoteOperation() { export function useCanvasRemoteOperation() {
return useContext(CanvasRemoteOperationContext)?.run || null; return useContext(CanvasRemoteOperationContext);
}
export function useCanvasRemoteSnapshot() {
return useContext(CanvasRemoteOperationContext)?.applySnapshot || null;
} }

View File

@ -2,10 +2,14 @@
import { ReferenceIcon } from "@/components/lovart/reference-icon"; import { ReferenceIcon } from "@/components/lovart/reference-icon";
import { Loader2, Save } from "lucide-react";
type CanvasSurfaceHeaderProps = { type CanvasSurfaceHeaderProps = {
isSaving: boolean;
onAddImage: () => void; onAddImage: () => void;
onHome: () => void; onHome: () => void;
onOpenFiles: () => void; onOpenFiles: () => void;
onSave: () => void;
projectTitle: string; projectTitle: string;
rightInset: number; rightInset: number;
}; };
@ -14,9 +18,11 @@ 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"; "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({ export function CanvasSurfaceHeader({
isSaving,
onAddImage, onAddImage,
onHome, onHome,
onOpenFiles, onOpenFiles,
onSave,
projectTitle, projectTitle,
rightInset, rightInset,
}: CanvasSurfaceHeaderProps) { }: CanvasSurfaceHeaderProps) {
@ -71,6 +77,19 @@ export function CanvasSurfaceHeader({
<ReferenceIcon name="plus" size={16} /> <ReferenceIcon name="plus" size={16} />
</button> </button>
</div> </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> </div>
); );
} }

View File

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

View File

@ -6,8 +6,6 @@ import { ArrowUp, MessageSquare, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CanvasAgentRunPanel } from "./canvas-agent-run-panel"; 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 { import {
videoAgentSettingsSchema, videoAgentSettingsSchema,
type VideoAgentSettings, type VideoAgentSettings,
@ -96,22 +94,6 @@ export function VideoCanvasAgentPanel(props: {
}; };
}, [props.projectId, setMessages]); }, [props.projectId, setMessages]);
const busy = backgroundRunning || preparing || status === "submitted" || status === "streaming"; 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( const submit = useCallback(
async (resumeRunId?: string) => { async (resumeRunId?: string) => {
if (busy || loading) return; if (busy || loading) return;
@ -123,7 +105,6 @@ export function VideoCanvasAgentPanel(props: {
try { try {
await latest.current.props.beforeSend(); await latest.current.props.beforeSend();
if (!mounted.current) return; if (!mounted.current) return;
clearCanvasComposerDraft(`video-agent-${props.projectId}`);
resumeRef.current = resumeRunId; resumeRef.current = resumeRunId;
setInput(""); setInput("");
await sendMessage({ text }); await sendMessage({ text });
@ -317,7 +298,7 @@ export function VideoCanvasAgentPanel(props: {
<textarea <textarea
aria-label="视频创作要求" aria-label="视频创作要求"
value={input} value={input}
onChange={(event) => { clearCanvasComposerDraft(`video-agent-${props.projectId}`); setInput(event.target.value); }} onChange={(event) => setInput(event.target.value)}
maxLength={2000} maxLength={2000}
rows={3} rows={3}
placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频" placeholder="例如:先分析选中素材,写分镜,再生成一条5秒商品展示视频"

View File

@ -1,6 +1,4 @@
"use client"; "use client";
import { CanvasPreviewImage } from "./image-node/canvas-preview-image";
import { canvasImagePreviewWidth } from "@/lib/canvas/image-preview-width";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
import { import {
@ -2504,13 +2502,13 @@ export function ImageNode({
const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05; const densityMultiplier = shouldUseHighFidelityDisplayImage ? 1.35 : 1.05;
const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640; const minWidth = shouldUseHighFidelityDisplayImage ? 960 : 640;
const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600; const maxWidth = shouldUseHighFidelityDisplayImage ? 4096 : 2600;
return canvasImagePreviewWidth(Math.max( return Math.max(
minWidth, minWidth,
Math.min( Math.min(
maxWidth, maxWidth,
Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier) Math.round((node.size.width || 320) * zoomMultiplier * devicePixelRatio * densityMultiplier)
) )
), maxWidth); );
}, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]); }, [node.size.width, shouldUseHighFidelityDisplayImage, viewportZoom]);
const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88; const displayImageQuality = shouldUseHighFidelityDisplayImage ? 92 : 88;
const displayImageSrc = useMemo( const displayImageSrc = useMemo(
@ -4685,44 +4683,106 @@ export function ImageNode({
return; return;
} }
if (!projectId || !runCanvasRemoteOperation) { setIsCreatingDetachedResultNode(true);
toast.error("画布还在加载,请稍后再试");
return;
}
setIsCreatingDetachedResultNode(false);
setIsProcessing(true); setIsProcessing(true);
const actionName = "框选抠图"; const actionName = "框选抠图";
const newNodeId = addNode({
try { type: "image",
const command = createCanvasCapabilityClientCommand({ position: {
capabilityId: "image.box_cutout", x: node.position.x + node.size.width + 20,
nodeIds: [node.id], y: node.position.y,
projectId, },
source: "toolbar", size: { width: node.size.width, height: node.size.height },
args: { data: {
...boxCutoutSelection, src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: `${displayTitle}-${actionName}`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO, paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
}, },
},
connections: [node.id],
});
try {
const result = await imageGenerationService.boxCutout({
imageUrl: data.src,
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
sourceNodeId: node.id,
});
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}完成`); toast.success(`${actionName}完成`);
} else {
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(result.error || `${actionName}失败`);
}
} catch (error) { } catch (error) {
console.error("框选抠图失败:", error); console.error("框选抠图失败:", error);
toast.error(error instanceof Error ? error.message : `${actionName}失败`); updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${displayTitle}-${actionName}-失败`,
originalSrc: data.src,
metadata: {
cropRect: boxCutoutSelection,
paddingRatio: BOX_CUTOUT_DEFAULT_PADDING_RATIO,
},
},
});
toast.error(`${actionName}失败,请重试`);
} finally { } finally {
setIsCreatingDetachedResultNode(false); setIsCreatingDetachedResultNode(false);
setIsProcessing(false); setIsProcessing(false);
resetBoxCutoutState(); resetBoxCutoutState();
} }
}, [ }, [
addNode,
boxCutoutSelection, boxCutoutSelection,
data.src, data.src,
displayTitle,
isProcessing, isProcessing,
node.id, node.id,
projectId, node.position.x,
node.position.y,
node.size.height,
node.size.width,
resetBoxCutoutState, resetBoxCutoutState,
runCanvasRemoteOperation, updateNode,
]); ]);
const animateAddToChat = useCallback(async (previewUrl?: string) => { const animateAddToChat = useCallback(async (previewUrl?: string) => {
@ -8401,13 +8461,12 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) { if (event.currentTarget.src !== buildImageFallbackUrl(data.src)) {
event.currentTarget.src = buildImageFallbackUrl(data.src); event.currentTarget.src = buildImageFallbackUrl(data.src);
@ -8431,13 +8490,12 @@ export function ImageNode({
}} }}
> >
<div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" /> <div className="absolute inset-0 bg-slate-50 dark:bg-zinc-900" />
<CanvasPreviewImage <img
sourceKey={compareSource}
alt={`${displayTitle}-原图`} alt={`${displayTitle}-原图`}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onError={(event) => { onError={(event) => {
if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) { if (event.currentTarget.src !== buildImageFallbackUrl(compareSource)) {
event.currentTarget.src = buildImageFallbackUrl(compareSource); event.currentTarget.src = buildImageFallbackUrl(compareSource);
@ -8482,8 +8540,7 @@ export function ImageNode({
</div> </div>
) : ( ) : (
<> <>
<CanvasPreviewImage <img
sourceKey={data.src}
alt={displayTitle} alt={displayTitle}
className={cn( className={cn(
"h-full w-full bg-slate-50 object-contain dark:bg-zinc-900", "h-full w-full bg-slate-50 object-contain dark:bg-zinc-900",
@ -8495,7 +8552,7 @@ export function ImageNode({
)} )}
decoding="async" decoding="async"
draggable={false} draggable={false}
loading="eager" loading="lazy"
onClick={ onClick={
isThisNodePicking && !boxCutoutMode isThisNodePicking && !boxCutoutMode
? handlePickerClick ? handlePickerClick
@ -9425,8 +9482,6 @@ export function ImageNode({
) : ( ) : (
<ImageNodeEmptyState <ImageNodeEmptyState
detailProgressLabel={detailProgressLabel} detailProgressLabel={detailProgressLabel}
failure={data.metadata?.generationFailure}
requestId={data.metadata?.generationRequestId}
fileInputRef={fileInputRef} fileInputRef={fileInputRef}
generationProgress={generationProgress} generationProgress={generationProgress}
onImageUpload={handleImageUpload} onImageUpload={handleImageUpload}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,10 +29,10 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> & { overlayClassName?: string } React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, overlayClassName, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay className={overlayClassName} /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(

View File

@ -92,16 +92,8 @@ public 先从当前运行版本创建写入时复制副本,保留未纳入 Git
原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。 原生产 checkout 的支线及未提交改动仍保留,不能据其 Git HEAD 判断已发布版本。
实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。 实际发布证据是:运行 cwd、`.next/BUILD_ID`、该目录 `release.json` 和 `activation.json`。
`next.config.ts` 将本流程的发布目录编号写入前后端版本标识;`/api/app-version` 返回该编号且不缓存,发布烟测会核对它与运行目录一致。网页每分钟及切回页面时检查更新,发现版本变化后提示同步并刷新;画布同步失败或当前任务仍在处理时保留页面。首次接入前已经打开的旧网页需先刷新一次,后续发布即可自动收到提示。
维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。 维护电脑的 `.git/releases/<发布ID>/` 保留提交、构建日志、烟测、传输校验和及操作回执。
服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。 服务器回滚配置可能含环境元数据,目录权限为 0700、文件为 0600,不放入公开目录或 Git。
仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。 仓库历史全量 lint 仍有已记录的问题,不能声称全部质量门禁通过。
每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。 每次 PR 须记录本次改动的验证、历史失败和未覆盖项目;当前流程不会自动触发生产发布。
## ZIP 下载交付
默认存储为七牛时,完整 ZIP 通过分片上传至七牛,确认下载地址可用后才提供下载。
大文件使用三个并发分片,上传进度显示在原打包任务中。传送或链接检查失败会返回具体阶段、
下载编号及重试建议,保留已生成的 ZIP;再次点击下载复用原文件,已上传的完整对象不重复上传。
后台 ZIP 任务参与发布期间的工作等待。完成或失败的任务及文件保留 24 小时,待处理任务保留 6 小时。

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; 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 { mergeCanvasSnapshots, type CanvasConflictChoice } from "@/lib/video-canvas/merge-snapshot";
import { import {
CanvasRevisionConflict, CanvasRevisionConflict,
@ -150,16 +149,6 @@ export function useVideoCanvasPersistence(
return !queue.dirty && !queue.failed; return !queue.dirty && !queue.failed;
}, [ready]); }, [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(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {

View File

@ -14,8 +14,6 @@ import {
sanitizeAgentDetailBrief, sanitizeAgentDetailBrief,
} from "@/lib/agents/detail-generation-config"; } from "@/lib/agents/detail-generation-config";
export const MAX_DETAIL_REPLACEMENT_REFERENCES = 64;
export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState { export function createEmptyAgentModeWorkspaceState(): AgentModeWorkspaceState {
return { return {
detailReplacement: { detailReplacement: {

View File

@ -407,32 +407,6 @@ function buildPromptSection(title: string, lines: string[]) {
return `【${title}】\n${normalizedLines.join("\n")}`; return `【${title}】\n${normalizedLines.join("\n")}`;
} }
export function resolveAgentWorkflowComposerSelections(params: {
config?: AgentWorkflowUiConfig | null;
selections: Partial<Record<AgentWorkflowStepKey, string>>;
}): AgentWorkflowComposerContext["selections"] {
const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
return steps.flatMap((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
});
}
export function buildAgentWorkflowComposerContext(params: { export function buildAgentWorkflowComposerContext(params: {
config?: AgentWorkflowUiConfig | null; config?: AgentWorkflowUiConfig | null;
inputs: AgentWorkflowInputsValue; inputs: AgentWorkflowInputsValue;
@ -440,9 +414,30 @@ export function buildAgentWorkflowComposerContext(params: {
selectedBrandModel?: AgentWorkflowSelectedBrandModel | null; selectedBrandModel?: AgentWorkflowSelectedBrandModel | null;
appearanceRecognitionEnabled?: boolean; appearanceRecognitionEnabled?: boolean;
selections: Partial<Record<AgentWorkflowStepKey, string>>; selections: Partial<Record<AgentWorkflowStepKey, string>>;
resolvedSelections?: AgentWorkflowComposerContext["selections"];
}): AgentWorkflowComposerContext | null { }): AgentWorkflowComposerContext | null {
const selections = params.resolvedSelections ?? resolveAgentWorkflowComposerSelections(params); const config = params.config || defaultAgentWorkflowUiConfig;
const steps = flattenVisibleAgentWorkflowSteps(
getEnabledAgentWorkflowStepTree(Array.isArray(config.steps) ? config.steps : []),
params.selections
)
.map(({ step }) => step)
.filter((step) => Array.isArray(step.options) && step.options.length > 0);
const selections = steps
.map((step) => {
const selectedOptions = getAgentWorkflowSelectedOptionPath({
step,
selectionValue: params.selections[step.key],
});
if (selectedOptions.length === 0) return [];
return selectedOptions.map((option) => ({
stepKey: step.key,
stepTitle: step.title,
optionKey: option.key,
optionName: option.buttonName,
promptFragment: option.promptFragment || option.buttonName,
}));
})
.flat() as AgentWorkflowComposerContext["selections"];
const referenceImages = getSelectedWorkflowReferenceImages({ const referenceImages = getSelectedWorkflowReferenceImages({
inputs: params.inputs, inputs: params.inputs,
materials: params.materials, materials: params.materials,

View File

@ -1,18 +0,0 @@
import type { AgentWorkflowOptionUiConfig, AgentWorkflowStepUiConfig } from "@/types/agent";
export function omitAgentWorkflowPrompts(steps: AgentWorkflowStepUiConfig[]): AgentWorkflowStepUiConfig[] {
const omitOptionPrompt = ({ promptFragment: _prompt, children, ...option }: AgentWorkflowOptionUiConfig): AgentWorkflowOptionUiConfig => ({
...option,
...(children?.length ? { children: children.map(omitOptionPrompt) } : {}),
});
return steps.map(({ options, children, ...step }) => ({
...step,
options: options.map(omitOptionPrompt),
...(children?.length ? { children: omitAgentWorkflowPrompts(children) } : {}),
}));
}
export function getWorkflowSelectionsRequest(selections: Record<string, string | undefined>) {
const entries = Object.entries(selections).filter(([, value]) => value?.trim());
return entries.length ? JSON.stringify(Object.fromEntries(entries.sort(([a], [b]) => a.localeCompare(b)))) : "";
}

View File

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

View File

@ -9,7 +9,6 @@ import {
summarizeAgentCapabilities, summarizeAgentCapabilities,
} from "./capability-access"; } from "./capability-access";
import type { CanvasAgentTool } from "./loop"; import type { CanvasAgentTool } from "./loop";
import { AgentToolRejectedError } from "./protocol";
import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol"; import type { CanvasOperationPlan } from "@/types/canvas/agent-protocol";
export function registerImageCanvasAccessTools(params: { export function registerImageCanvasAccessTools(params: {
@ -60,10 +59,10 @@ export function registerImageCanvasAccessTools(params: {
(item) => item.workspace === "canvas" && item.id === input.featureId, (item) => item.workspace === "canvas" && item.id === input.featureId,
); );
if (!feature?.authorized || !feature.id.startsWith("canvas.ui.")) if (!feature?.authorized || !feature.id.startsWith("canvas.ui."))
throw new AgentToolRejectedError("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具;本次未打开面板或提交处理。"); throw new Error("请使用清单中的图片画布界面入口,媒体能力请使用对应专用工具");
const { nodes } = await readOwnedCanvas(params.userId, params.projectId); const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId))) if (input.nodeIds.some((nodeId) => !nodes.some((node) => node.id === nodeId)))
throw new AgentToolRejectedError("目标节点不属于当前画布,本次未打开面板或提交处理。"); throw new Error("目标节点不属于当前画布");
params.emit("data-canvasAgentUiCommand", { params.emit("data-canvasAgentUiCommand", {
projectId: params.projectId, projectId: params.projectId,
requestId: `${params.runId}:${id}`, requestId: `${params.runId}:${id}`,

View File

@ -13,18 +13,13 @@ export type AgentImageParameters = {
}; };
export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters }; export type AgentImagePreferences = { modelId?: string; parameters?: AgentImageParameters };
export function selectedAgentImageCount(preferences?: AgentImagePreferences) {
const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return count === undefined ? undefined : Math.floor(count);
}
export function applyAgentImagePreferences<T extends Record<string, unknown>>( export function applyAgentImagePreferences<T extends Record<string, unknown>>(
input: T, input: T,
preferences?: AgentImagePreferences, preferences?: AgentImagePreferences,
): T & AgentImageParameters & { modelId?: string } { ): T & AgentImageParameters & { modelId?: string } {
const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {}) const parameters = Object.fromEntries(Object.entries(preferences?.parameters || {})
.filter(([, value]) => value !== undefined)); .filter(([, value]) => value !== undefined));
const count = selectedAgentImageCount(preferences); const count = preferences?.parameters?.imageCount ?? preferences?.parameters?.n;
return { return {
...input, ...input,
...parameters, ...parameters,

View File

@ -1,14 +1,5 @@
import type { AgentCallRecord, AgentRunSnapshot } from "./protocol"; import type { AgentCallRecord, AgentRunSnapshot } from "./protocol";
export function countAgentGeneratedImages(calls: AgentCallRecord[]) {
return calls.reduce((count, call) => {
const output = call.output;
if (call.status !== "succeeded" || !output?.appliedImagePreferences ||
!Array.isArray(output.createdNodeIds)) return count;
return count + output.createdNodeIds.length;
}, 0);
}
// A spontaneous download command is not an unfinished image edit. Keep genuine // A spontaneous download command is not an unfinished image edit. Keep genuine
// download requests and all editor panels waiting for their actual interaction. // download requests and all editor panels waiting for their actual interaction.
export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) { export function isAncillaryDownload(snapshot: AgentRunSnapshot, call: AgentCallRecord) {

View File

@ -5,8 +5,7 @@ import sharp from "sharp";
import { z } from "zod"; import { z } from "zod";
import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas"; import { readCanvasImages, readOwnedCanvas, type RuntimeImageNode } from "./canvas";
import type { AgentRunSnapshot } from "./protocol"; import type { AgentRunSnapshot } from "./protocol";
import { countAgentGeneratedImages, finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results"; import { finalResultNodeIds, originalSourceNodeIds, requiresCanvasInteraction } from "./results";
import { selectedAgentImageCount } from "./image-preferences";
import { isCompletedSimpleCutout } from "./cutout-completion"; import { isCompletedSimpleCutout } from "./cutout-completion";
export async function inspectImageAlpha(source: Buffer) { export async function inspectImageAlpha(source: Buffer) {
@ -125,16 +124,14 @@ export async function verifyCanvasAgentResults(params: {
(call) => (call) =>
call.status !== "succeeded" || call.status !== "succeeded" ||
requiresCanvasInteraction(snapshot, call) || requiresCanvasInteraction(snapshot, call) ||
(typeof call.output?.status === "string" && call.output.status !== "succeeded" && (typeof call.output?.status === "string" && call.output.status !== "succeeded"),
!(call.output.status === "partial" && call.output.appliedImagePreferences)),
); );
if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] }; if (incomplete.length) return { passed: false, issues: ["部分操作仍在等待交互或未完成"] };
const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output; const plan = calls.filter((call) => call.name === "planCanvasTask").at(-1)?.output;
if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] }; if (!plan) return { passed: false, issues: ["缺少明确的任务验收计划"] };
// Intermediate images consumed by later write tools do not count as final deliverables. // Intermediate images consumed by later write tools do not count as final deliverables.
const finalIds = finalResultNodeIds(snapshot); const finalIds = finalResultNodeIds(snapshot);
const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount ?? const expectedCount = snapshot.requirements?.expectedImageCount ?? plan.expectedImageCount;
(countAgentGeneratedImages(calls) > 0 ? selectedAgentImageCount(snapshot.imagePreferences) : undefined);
if (expectedCount && finalIds.length !== expectedCount) { if (expectedCount && finalIds.length !== expectedCount) {
return { return {
passed: false, passed: false,

View File

@ -1,11 +1,5 @@
import type { AgentVisionExecutor } from "../runtime/agent-vision-failover"; import type { AgentVisionExecutor } from "../runtime/agent-vision-failover";
import { import { resolveUserImageModel, applyAgentImagePreferences, agentProviderImageParameters, type AgentImagePreferences } from "./image-preferences";
agentProviderImageParameters,
applyAgentImagePreferences,
resolveUserImageModel,
selectedAgentImageCount,
type AgentImagePreferences,
} from "./image-preferences";
import { tool, type LanguageModel, type ToolSet } from "ai"; import { tool, type LanguageModel, type ToolSet } from "ai";
import { z } from "zod"; import { z } from "zod";
import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools"; import { createCanvasCapabilityTools } from "@/lib/canvas/capabilities/ai-sdk-tools";
@ -24,8 +18,6 @@ import {
sortStyleProImageModelEntries, sortStyleProImageModelEntries,
} from "@/lib/style-pro/model-options"; } from "@/lib/style-pro/model-options";
import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop"; import type { CanvasAgentLoopPorts, CanvasAgentTool } from "./loop";
import { AgentToolRejectedError, type AgentCallRecord } from "./protocol";
import { countAgentGeneratedImages } from "./results";
import { readCanvasImages, readOwnedCanvas } from "./canvas"; import { readCanvasImages, readOwnedCanvas } from "./canvas";
import { analyzeRuntimeImages } from "./review"; import { analyzeRuntimeImages } from "./review";
import { isExplicitDestructiveIntent } from "./capability-access"; import { isExplicitDestructiveIntent } from "./capability-access";
@ -45,13 +37,11 @@ export async function buildCanvasRuntimeTools(params: {
imageSize?: string; imageSize?: string;
preferredModelId?: string; preferredModelId?: string;
imagePreferences?: AgentImagePreferences; imagePreferences?: AgentImagePreferences;
priorCalls?: AgentCallRecord[];
emit: (type: string, data: unknown) => void; emit: (type: string, data: unknown) => void;
objective?: string; objective?: string;
operationSelectedNodeIds?: string[]; operationSelectedNodeIds?: string[];
}) { }) {
const { nodes } = await readOwnedCanvas(params.userId, params.projectId); const { nodes } = await readOwnedCanvas(params.userId, params.projectId);
const selectedImageCount = selectedAgentImageCount(params.imagePreferences);
const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id); const imageNodeIds = nodes.filter((node) => node.type === "image").map((node) => node.id);
const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id)); const defaults = params.selectedNodeIds.filter((id) => imageNodeIds.includes(id));
const sdk = await createCanvasCapabilityTools({ const sdk = await createCanvasCapabilityTools({
@ -138,8 +128,8 @@ export async function buildCanvasRuntimeTools(params: {
visualCriteria: z.array(z.string().max(200)).max(10), visualCriteria: z.array(z.string().max(200)).max(10),
}), }),
async (input) => ({ ...input, async (input) => ({ ...input,
...(input.expectedImageCount !== undefined && selectedImageCount !== undefined ...(input.expectedImageCount !== undefined && params.imagePreferences?.parameters?.imageCount !== undefined
? { expectedImageCount: selectedImageCount } : {}), ? { expectedImageCount: params.imagePreferences.parameters.imageCount } : {}),
...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto" ...(params.imagePreferences?.parameters?.aspectRatio && params.imagePreferences.parameters.aspectRatio !== "auto"
? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}), ? { aspectRatio: params.imagePreferences.parameters.aspectRatio } : {}),
}), }),
@ -173,8 +163,6 @@ export async function buildCanvasRuntimeTools(params: {
}; };
}, },
); );
const inspectedImages = new Map<string, Buffer>();
let inspectedImageBytes = 0;
register( register(
"analyzeCanvasImages", "analyzeCanvasImages",
"检查图片", "检查图片",
@ -183,28 +171,10 @@ export async function buildCanvasRuntimeTools(params: {
nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4), nodeIds: z.array(z.string().min(1).max(180)).min(1).max(4),
question: z.string().min(1).max(2000), question: z.string().min(1).max(2000),
}), }),
async (input) => analyzeRuntimeImages({ async (input) => analyzeRuntimeImages({ ...params, ...input }),
...params,
...input,
reviewPorts: {
readImage: async (url) => {
const cached = inspectedImages.get(url);
if (cached) return cached;
const { fetchImageBufferFromUrl } = await import("@/lib/ai/rmbg/service");
const original = await fetchImageBufferFromUrl(url);
if (!inspectedImages.has(url) && inspectedImageBytes + original.length <= 32 * 1024 * 1024) {
inspectedImages.set(url, original);
inspectedImageBytes += original.length;
}
return original;
},
},
}),
); );
let submittedCount = 0; let generatedCount = 0;
let occupiedImageSlots = countAgentGeneratedImages(params.priorCalls ?? []);
const countLimitMessage = "本次设定的生成张数已占满,请先查看正在生成或已经生成的结果。";
register( register(
"generateCanvasImages", "generateCanvasImages",
"生成图片", "生成图片",
@ -219,8 +189,9 @@ export async function buildCanvasRuntimeTools(params: {
}), }),
async (toolInput, callId) => { async (toolInput, callId) => {
const input = applyAgentImagePreferences(toolInput, params.imagePreferences); const input = applyAgentImagePreferences(toolInput, params.imagePreferences);
if (selectedImageCount !== undefined && occupiedImageSlots >= selectedImageCount) // Bound per-task spend without introducing any global/model concurrency queue.
throw new AgentToolRejectedError(countLimitMessage); if (generatedCount + input.imageCount > 24)
throw new Error("本次任务已达到 24 张生成预算,请检查结果后继续。");
const sources = await readCanvasImages( const sources = await readCanvasImages(
params.userId, params.userId,
params.projectId, params.projectId,
@ -243,17 +214,10 @@ export async function buildCanvasRuntimeTools(params: {
if (!chosen) throw new Error("系统未配置可用的图片生成模型"); if (!chosen) throw new Error("系统未配置可用的图片生成模型");
const imageSize = input.imageSize || params.imageSize || "2K"; const imageSize = input.imageSize || params.imageSize || "2K";
const quota = await checkUserImageQuota(params.userId); const quota = await checkUserImageQuota(params.userId);
const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId });
// Reserve the remaining slots before dispatch, including calls still in flight.
if (selectedImageCount !== undefined) {
input.imageCount = Math.min(input.imageCount, selectedImageCount - occupiedImageSlots);
if (input.imageCount < 1) throw new AgentToolRejectedError(countLimitMessage);
}
if (submittedCount + input.imageCount > 24)
throw new AgentToolRejectedError("本次任务已达到 24 张生成预算,请检查结果后继续。");
if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining)) if (!quota.allowed || (quota.remaining !== null && input.imageCount > quota.remaining))
throw new Error("本月图片生成配额不足。"); throw new Error("本月图片生成配额不足。");
const points = await getPointsSystemConfig();
const user = await getUserById({ id: params.userId });
const cost = const cost =
getModelGenerationCreditCost( getModelGenerationCreditCost(
{ imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null }, { imageGenConfig: chosen.model.imageGenConfig as DiscountableImageGenConfigLike | null },
@ -267,8 +231,7 @@ export async function buildCanvasRuntimeTools(params: {
(user?.pointsBalance ?? 0) < cost (user?.pointsBalance ?? 0) < cost
) )
throw new Error("积分不足,请充值或联系管理员。"); throw new Error("积分不足,请充值或联系管理员。");
submittedCount += input.imageCount; generatedCount += input.imageCount;
occupiedImageSlots += input.imageCount;
const result = await generateCanvasImage({ const result = await generateCanvasImage({
prompt: input.prompt, prompt: input.prompt,
model: chosen.model.id, model: chosen.model.id,
@ -284,7 +247,6 @@ export async function buildCanvasRuntimeTools(params: {
? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined, ? Math.max(0, user?.pointsBalance ?? 0) / input.imageCount : undefined,
ownerKey: params.userId, ownerKey: params.userId,
referenceImages: sources.map((node) => node.data.src || node.data.url!), referenceImages: sources.map((node) => node.data.src || node.data.url!),
referenceImageBuffers: inspectedImages,
debugLabel: "canvas-agent-runtime", debugLabel: "canvas-agent-runtime",
debugMeta: { runId: params.runId, callId }, debugMeta: { runId: params.runId, callId },
}); });
@ -349,7 +311,6 @@ export async function buildCanvasRuntimeTools(params: {
}, },
); );
} }
occupiedImageSlots += result.images.length - input.imageCount;
return { return {
status: result.images.length < input.imageCount ? "partial" : "succeeded", status: result.images.length < input.imageCount ? "partial" : "succeeded",
appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount }, appliedImagePreferences: { modelId: result.modelRef, imageSize, aspectRatio: input.aspectRatio, imageCount: input.imageCount },

View File

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

View File

@ -1,5 +1,3 @@
import { isVideoModelDescriptor } from "@/lib/ai/model-classification";
export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY = export const DETAIL_REPLACEMENT_IMAGE_MODEL_CONFIG_KEY =
"detail_replacement_image_model_config"; "detail_replacement_image_model_config";
@ -22,28 +20,3 @@ export function normalizeDetailReplacementImageModelConfig(
modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID, modelId: modelId || DEFAULT_DETAIL_REPLACEMENT_IMAGE_MODEL_ID,
}; };
} }
export function isDetailReplacementImageModel({
model,
provider,
}: {
model: { modelId: string; supportsImageGen: boolean; isSubModel: boolean; capabilities?: unknown };
provider?: { type?: string | null } | null;
}) {
return model.supportsImageGen && !model.isSubModel && !isVideoModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: model.capabilities,
supportsImageGen: model.supportsImageGen,
});
}
export function findConfiguredDetailReplacementImageModel<
T extends Parameters<typeof isDetailReplacementImageModel>[0] & { model: { id: string } },
>(models: T[], setting: unknown): T | undefined {
const { modelId } = normalizeDetailReplacementImageModelConfig(setting);
return models.find((entry) =>
(entry.model.id === modelId || entry.model.modelId === modelId) &&
isDetailReplacementImageModel(entry)
);
}

View File

@ -3,19 +3,6 @@ export type ExtendProviderCredential = {
updatedAt?: Date | string | null; updatedAt?: Date | string | null;
}; };
export type ExtendProvider = "vip98k" | "dashscope" | "fal";
export function resolveFalExtendApiKey(providers: Array<ExtendProviderCredential & { baseUrl?: string | null }>) {
for (const { apiKey, baseUrl } of providers) {
if (!apiKey || !baseUrl) continue;
const url = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
if (url?.protocol === "https:" && ["queue.fal.run", "fal.run", "fal.ai", "api.fal.ai"].includes(url.hostname)) {
return apiKey.trim().replace(/^(?:Bearer|Key)\s+/i, "");
}
}
return null;
}
function getUpdatedAtTimestamp(value: Date | string | null | undefined): number { function getUpdatedAtTimestamp(value: Date | string | null | undefined): number {
if (!value) return 0; if (!value) return 0;
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value); const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);

View File

@ -1,93 +0,0 @@
import { createFalClient } from "@fal-ai/client";
import sharp from "sharp";
export const FAL_OUTPAINT_MODEL = "fal-ai/flux-2-pro/outpaint";
export function getOutpaintGeometry(width: number, height: number, xScale: number, yScale: number) {
const targetWidth = Math.round(width * xScale);
const targetHeight = Math.round(height * yScale);
const ratio = Math.min(1, 2048 / Math.max(targetWidth, targetHeight));
const modelWidth = Math.max(1, Math.floor(targetWidth * ratio));
const modelHeight = Math.max(1, Math.floor(targetHeight * ratio));
const sourceWidth = Math.max(1, Math.round(width * modelWidth / targetWidth));
const sourceHeight = Math.max(1, Math.round(height * modelHeight / targetHeight));
const left = Math.floor((targetWidth - width) / 2);
const top = Math.floor((targetHeight - height) / 2);
const modelLeft = Math.round(left * modelWidth / targetWidth);
const modelTop = Math.round(top * modelHeight / targetHeight);
// Align the model canvas to its 16-pixel grid, then remove the extra border.
const extraWidth = (16 - modelWidth % 16) % 16;
const extraHeight = (16 - modelHeight % 16) % 16;
const cropLeft = Math.floor(extraWidth / 2);
const cropTop = Math.floor(extraHeight / 2);
return {
targetWidth, targetHeight, left, top, modelWidth, modelHeight, sourceWidth, sourceHeight,
paddedWidth: modelWidth + extraWidth,
paddedHeight: modelHeight + extraHeight,
cropLeft,
cropTop,
expandLeft: modelLeft + cropLeft,
expandRight: modelWidth - sourceWidth - modelLeft + extraWidth - cropLeft,
expandTop: modelTop + cropTop,
expandBottom: modelHeight - sourceHeight - modelTop + extraHeight - cropTop,
};
}
export async function composeOutpaint(source: Buffer, expanded: Buffer, geometry: ReturnType<typeof getOutpaintGeometry>) {
const { targetWidth, targetHeight, left, top, modelWidth, modelHeight, paddedWidth, paddedHeight, cropLeft, cropTop } = geometry;
const modelImage = sharp(expanded);
const info = await modelImage.metadata();
if (info.width !== paddedWidth || info.height !== paddedHeight) {
throw new Error("扩图服务返回的图片尺寸不正确");
}
// Copy the source rectangle, including transparent pixels, without blending or resizing it.
const { data: original, info: sourceInfo } = await sharp(source).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });
const result = await modelImage
.extract({ left: cropLeft, top: cropTop, width: modelWidth, height: modelHeight })
.resize(targetWidth, targetHeight, { fit: "fill" })
.toColourspace("srgb").ensureAlpha().raw().toBuffer();
const stride = sourceInfo.width * 4;
for (let row = 0; row < sourceInfo.height; row++) {
original.copy(result, ((top + row) * targetWidth + left) * 4, row * stride, (row + 1) * stride);
}
return sharp(result, { raw: { width: targetWidth, height: targetHeight, channels: 4 } }).png().toBuffer();
}
export async function expandWithFalOutpaint({ imageUrl, apiKey, xScale, yScale }: {
imageUrl: string;
apiKey: string;
xScale: number;
yScale: number;
}) {
const response = await fetch(imageUrl, { signal: AbortSignal.timeout(45_000) });
if (!response.ok) throw new Error("无法读取待扩展的原图");
const { data: source, info } = await sharp(Buffer.from(await response.arrayBuffer()))
.rotate().png().toBuffer({ resolveWithObject: true });
const geometry = getOutpaintGeometry(info.width, info.height, xScale, yScale);
if (geometry.targetWidth * geometry.targetHeight > 64_000_000) {
throw new Error("扩图后的尺寸过大,请降低扩展倍数");
}
const fal = createFalClient({ credentials: apiKey });
const modelSource = geometry.sourceWidth === info.width && geometry.sourceHeight === info.height
? source
: await sharp(source).resize(geometry.sourceWidth, geometry.sourceHeight, { fit: "fill" }).png().toBuffer();
const image = await fal.storage.upload(new Blob([new Uint8Array(modelSource)], { type: "image/png" }));
const { data } = await fal.subscribe(FAL_OUTPAINT_MODEL, {
input: {
image_url: image,
expand_left: geometry.expandLeft,
expand_right: geometry.expandRight,
expand_top: geometry.expandTop,
expand_bottom: geometry.expandBottom,
mode: "high",
output_format: "png",
},
timeout: 90_000,
});
const outputUrl = (data as { images?: Array<{ url?: string }> }).images?.[0]?.url;
if (!outputUrl) throw new Error("扩图服务未返回图片");
const output = await fetch(outputUrl, { signal: AbortSignal.timeout(25_000) });
if (!output.ok) throw new Error("无法读取扩图结果");
return composeOutpaint(source, Buffer.from(await output.arrayBuffer()), geometry);
}

View File

@ -5,15 +5,14 @@ import {
getSystemSetting, getSystemSetting,
upsertMediaAsset, upsertMediaAsset,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { type ExtendProvider, resolveAlibabaExtendApiKey, resolveFalExtendApiKey } from "@/lib/ai/extend/config"; import { resolveAlibabaExtendApiKey } from "@/lib/ai/extend/config";
import { expandWithFalOutpaint, FAL_OUTPAINT_MODEL } from "@/lib/ai/extend/fal-outpaint";
import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime"; import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
type ExtendConfig = { type ExtendConfig = {
provider?: ExtendProvider; provider?: "vip98k" | "dashscope";
apiKey?: string; apiKey?: string;
xScale?: number; xScale?: number;
yScale?: number; yScale?: number;
@ -84,12 +83,12 @@ async function createThumbnail(buffer: Buffer): Promise<Buffer> {
} }
} }
async function uploadBufferPairToS3(buffer: Buffer, originalExtension = "jpg"): Promise<{ thumbnail: string; original: string }> { async function uploadBufferPairToS3(buffer: Buffer): Promise<{ thumbnail: string; original: string }> {
try { try {
const timestamp = Date.now(); const timestamp = Date.now();
const randomStr = Math.random().toString(36).slice(2, 8); const randomStr = Math.random().toString(36).slice(2, 8);
const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`; const thumbnailFilename = `extend-thumb-${timestamp}-${randomStr}.jpg`;
const originalFilename = `extend-orig-${timestamp}-${randomStr}.${originalExtension}`; const originalFilename = `extend-orig-${timestamp}-${randomStr}.jpg`;
const thumbnailBuffer = await createThumbnail(buffer); const thumbnailBuffer = await createThumbnail(buffer);
const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice( const thumbnailArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset, thumbnailBuffer.byteOffset,
@ -474,15 +473,6 @@ async function getValidatedExtendConfig(): Promise<ExtendConfig> {
if (config.provider === "vip98k") { if (config.provider === "vip98k") {
return config; return config;
} }
if (config.provider === "fal") {
const [imageProviders, videoProviders] = await Promise.all([
getAiProvidersByType({ type: "fal-image", enabledOnly: true }),
getAiProvidersByType({ type: "fal-video", enabledOnly: true }),
]);
const apiKey = resolveFalExtendApiKey([...imageProviders, ...videoProviders]);
if (!apiKey) throw new ExtendServiceError("扩图服务未配置可用的 fal 密钥", 503);
return { ...config, apiKey };
}
const alibabaProviders = await getAiProvidersByType({ const alibabaProviders = await getAiProvidersByType({
type: "alibaba-image", type: "alibaba-image",
@ -532,29 +522,35 @@ export async function executeExtendJob(
throw new ExtendServiceError("无法获取输入图片URL", 400); throw new ExtendServiceError("无法获取输入图片URL", 400);
} }
const preparedInputImageUrl =
config.provider === "vip98k"
? inputImageUrl
: await normalizeDashscopeExpansionInput(inputImageUrl);
const startTime = Date.now(); const startTime = Date.now();
let buffer: Buffer; const extendedImageUrl = await extendImage(
let modelId: string; preparedInputImageUrl,
if (config.provider === "fal") { config,
buffer = await expandWithFalOutpaint({ imageUrl: inputImageUrl, apiKey: config.apiKey!, xScale, yScale }); xScale,
modelId = FAL_OUTPAINT_MODEL; yScale,
} else { requestId,
const prepared = config.provider === "vip98k" ? inputImageUrl : await normalizeDashscopeExpansionInput(inputImageUrl); );
const url = await extendImage(prepared, config, xScale, yScale, requestId); const imageResponse = await fetch(extendedImageUrl);
const imageResponse = await fetch(url);
if (!imageResponse.ok) { if (!imageResponse.ok) {
throw new ExtendServiceError("下载拓展图片失败", 500, `${imageResponse.status} ${imageResponse.statusText}`); throw new ExtendServiceError(
} "下载拓展图片失败",
buffer = Buffer.from(await imageResponse.arrayBuffer()); 500,
modelId = config.provider === "vip98k" ? "image-expansion" : "extend-1.0"; `${imageResponse.status} ${imageResponse.statusText}`,
);
} }
const buffer = Buffer.from(await imageResponse.arrayBuffer());
const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined; const imageBase64Result = includeBase64 ? buffer.toString("base64") : undefined;
const uploadResult = await uploadBufferPairToS3(buffer, config.provider === "fal" ? "png" : "jpg"); const uploadResult = await uploadBufferPairToS3(buffer);
console.log("[Extend Service] Result uploaded:", { console.log("[Extend Service] Result uploaded:", {
requestId, requestId,
includeBase64, includeBase64,
inputImageUrl: inputImageUrl.slice(0, 120), inputImageUrl: inputImageUrl.slice(0, 120),
modelId, extendedImageUrl: extendedImageUrl.slice(0, 120),
outputBytes: buffer.length, outputBytes: buffer.length,
}); });
let mediaAssetId: string | null = null; let mediaAssetId: string | null = null;
@ -571,7 +567,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
originalUrl: uploadResult.original, originalUrl: uploadResult.original,
prompt, prompt,
modelId, modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
imageSize: "2K", imageSize: "2K",
}) })
.returning(); .returning();
@ -595,7 +591,7 @@ export async function executeExtendJob(
thumbnailUrl: uploadResult.thumbnail, thumbnailUrl: uploadResult.thumbnail,
displayName: prompt.slice(0, 80), displayName: prompt.slice(0, 80),
prompt, prompt,
modelId, modelId: config.provider === "vip98k" ? "image-expansion" : "extend-1.0",
sourceMessageId: messageId, sourceMessageId: messageId,
sourceTable: "GeneratedImage", sourceTable: "GeneratedImage",
sourceRowId: generatedImageId, sourceRowId: generatedImageId,

View File

@ -12,7 +12,6 @@ import sharp from "sharp";
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"; import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
@ -166,7 +165,7 @@ async function fetchImageBufferWithRetry(
} }
} }
throw lastError ?? new Error("下载图片失败"); throw lastError instanceof Error ? lastError : new Error(String(lastError || "下载图片失败"));
} }
function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string { function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): string {
@ -290,20 +289,12 @@ async function prepareAlibabaInputImageUrl(
imageUrl: string, imageUrl: string,
modelId: string, modelId: string,
inputRole?: AlibabaImageInputRole, inputRole?: AlibabaImageInputRole,
original?: Buffer,
): Promise<string> { ): Promise<string> {
const normalizedUrl = String(imageUrl || "").trim(); const normalizedUrl = String(imageUrl || "").trim();
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl; if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
const maxInputBytes = getAlibabaMaxInputBytes(modelId); const maxInputBytes = getAlibabaMaxInputBytes(modelId);
const { buffer, contentType } = original const { buffer, contentType } = await fetchImageBufferWithRetry(normalizedUrl);
? { buffer: original, contentType: null }
: await fetchImageBufferWithRetry(normalizedUrl).catch((error) => {
if (["AbortError", "TimeoutError"].includes(error?.name)) {
throw new ImageReferenceReadTimeoutError();
}
throw error;
});
let metadata: sharp.Metadata; let metadata: sharp.Metadata;
try { try {
metadata = await sharp(buffer, { failOn: "none" }).metadata(); metadata = await sharp(buffer, { failOn: "none" }).metadata();
@ -570,7 +561,7 @@ async function sendAlibabaImageAsyncRequest(params: {
url: params.requestURL, url: params.requestURL,
}); });
const taskId = String(submitted.output?.task_id || "").trim(); const taskId = String(submitted.output?.task_id || "").trim();
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认"); if (!taskId) throw new Error("阿里图片异步任务未返回 task_id");
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId); const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
const startedAt = Date.now(); const startedAt = Date.now();
@ -594,7 +585,7 @@ async function sendAlibabaImageAsyncRequest(params: {
console.log("[Alibaba Image] Async task completed:", taskId); console.log("[Alibaba Image] Async task completed:", taskId);
return task; return task;
} }
if (["FAILED", "CANCELED"].includes(taskStatus)) { if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`; const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
const code = task.output?.code || task.code; const code = task.output?.code || task.code;
throw new AlibabaImageTerminalTaskError( throw new AlibabaImageTerminalTaskError(
@ -610,7 +601,7 @@ async function sendAlibabaImageAsyncRequest(params: {
error.status < 500 && error.status < 500 &&
![408, 425, 429].includes(error.status) ![408, 425, 429].includes(error.status)
) { ) {
throw new Error(`阿里图片任务已提交,结果尚未确认(task_id: ${taskId})`, { cause: error }); throw error;
} }
lastPollError = error; lastPollError = error;
console.warn("[Alibaba Image] Async task poll failed; retrying:", { console.warn("[Alibaba Image] Async task poll failed; retrying:", {
@ -732,7 +723,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
doGenerate: async (options: { doGenerate: async (options: {
messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>; messages: Array<{ role: string; content: string | Array<{ type: string; text?: string; image?: string }> }>;
imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number }; imageParams?: AlibabaImageParams & { aspectRatio?: string; imageSize?: string; imageCount?: number };
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示 modelDisplayName?: string; // 🆕 模型显示名称,用于错误提示
overrideModelId?: string; // 🆕 备用模型 ID overrideModelId?: string; // 🆕 备用模型 ID
}) => { }) => {
@ -808,7 +798,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
imageUrl, imageUrl,
activeModelId, activeModelId,
options.imageParams?.inputImageRoleByUrl?.[imageUrl], options.imageParams?.inputImageRoleByUrl?.[imageUrl],
options.referenceImageBuffers?.get(imageUrl),
), ),
), ),
); );
@ -949,9 +938,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
); );
// 处理响应 // 处理响应
const images = await processResponse(response).catch((error) => { const images = await processResponse(response);
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
});
const markdownText = formatImageResponse(images, params.size, imageSize); const markdownText = formatImageResponse(images, params.size, imageSize);
console.log(`[Alibaba Image] Generated ${images.length} images successfully`); console.log(`[Alibaba Image] Generated ${images.length} images successfully`);

View File

@ -1647,15 +1647,6 @@ export function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvoca
if (params.modelId.toLowerCase() === "image-expansion") { if (params.modelId.toLowerCase() === "image-expansion") {
bindVip98kExpansionParams(body, imageParams); bindVip98kExpansionParams(body, imageParams);
} }
if (params.kind === "image-tool" && params.modelId.toLowerCase() === "ocr") {
// Old synced examples include generic image fields and both language aliases.
const metadata = toRecord(body.metadata);
const language = String(metadata?.language || body.language || "").trim();
body.metadata = { language: language || "English,ChinesePRC,Digits" };
body.output_format = "txt";
delete body.language;
delete body.size;
}
const imageBindingKeys = bindVip98kImages(body, params.imageUrls); const imageBindingKeys = bindVip98kImages(body, params.imageUrls);
if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) { if (params.imageUrls.length > 0 && !invocation && imageBindingKeys.length === 0) {
body.images = params.imageUrls; body.images = params.imageUrls;

View File

@ -8,7 +8,6 @@ import { executeConfiguredVip98kTask } from "@/lib/ai/vip98k-runtime";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { generatedImage } from "@/lib/db/schema"; import { generatedImage } from "@/lib/db/schema";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("rmbg-service"); const logger = createLogger("rmbg-service");
@ -37,8 +36,6 @@ export type ExecuteRmbgJobParams = {
chatId?: string | null; chatId?: string | null;
messageId?: string | null; messageId?: string | null;
skipGallerySave?: boolean; skipGallerySave?: boolean;
// 仅当 imageBuffer 与 URL 对应的输入文件一致时提供。
inputImageUrl?: string | null;
sourceImageUrl?: string | null; sourceImageUrl?: string | null;
}; };
@ -65,11 +62,7 @@ export class RmbgServiceError extends Error {
} }
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// https://picwish.cn/background-removal-api-doc
const AOS_MAX_RESOLUTION = 4096; const AOS_MAX_RESOLUTION = 4096;
const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024;
// The shared AOS state -5 documents a separate limit for image_url downloads.
const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024;
const AOS_CREATE_TIMEOUT_MS = 120_000; const AOS_CREATE_TIMEOUT_MS = 120_000;
const AOS_POLL_TIMEOUT_MS = 60_000; const AOS_POLL_TIMEOUT_MS = 60_000;
const AOS_DOWNLOAD_TIMEOUT_MS = 60_000; const AOS_DOWNLOAD_TIMEOUT_MS = 60_000;
@ -155,18 +148,6 @@ const withTimeout = async <T,>(
} }
}; };
async function readAosResult<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await withTimeout(read, timeoutMs);
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (attempt >= 2 || (name !== "TypeError" && name !== "AbortError")) throw error;
await sleep(1000 * (attempt + 1));
}
}
}
function getAosFriendlyErrorMessage(detail: string): string | null { function getAosFriendlyErrorMessage(detail: string): string | null {
const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i); const resolutionMatch = detail.match(/resolution excess,\s*width=(\d+),\s*height=(\d+)/i);
if (resolutionMatch) { if (resolutionMatch) {
@ -183,45 +164,55 @@ function getAosFriendlyErrorMessage(detail: string): string | null {
async function prepareImageForAos( async function prepareImageForAos(
imageBuffer: Buffer imageBuffer: Buffer
): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { ): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> {
if (imageBuffer.length > AOS_MAX_INPUT_BYTES) {
throw new RmbgServiceError(
`抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`,
400
);
}
let metadata: import("sharp").Metadata;
try { try {
const sharp = await import("sharp"); const sharp = await import("sharp");
metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata(); const image = sharp.default(imageBuffer);
} catch (error) { const metadata = await image.metadata();
// AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify. const width = metadata.width;
logger.warn("aos_image_metadata_unavailable", error); const height = metadata.height;
return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" }; const format = (metadata.format || "png").toLowerCase();
}
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 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 { return {
buffer: imageBuffer, buffer: imageBuffer,
mimeType: format ? `image/${format}` : "application/octet-stream", mimeType,
filename: ext ? `image.${ext}` : "image", filename: `image.${filenameExt}`,
resized: false,
}; };
} }
const resizedBuffer = await image
.resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, {
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return {
buffer: resizedBuffer,
mimeType,
filename: `image.${filenameExt}`,
resized: true,
};
} catch (error) {
logger.warn("aos_image_preprocess_failed", error);
return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true };
}
}
async function processWithRMBG( async function processWithRMBG(
imageBase64: string, imageBase64: string,
config: { apiUrl: string; apiKey?: string } config: { apiUrl: string; apiKey?: string }
@ -381,7 +372,7 @@ async function processWithAosSegmentation(
foregroundType?: "" | "person" | "object" | "stamp"; foregroundType?: "" | "person" | "object" | "stamp";
crop?: 0 | 1; crop?: 0 | 1;
format?: "png" | "jpg"; format?: "png" | "jpg";
inputImageUrl?: string | null; sourceImageUrl?: string | null;
requestId?: string; requestId?: string;
} }
): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> {
@ -391,14 +382,14 @@ async function processWithAosSegmentation(
const headers = { "X-API-KEY": config.apiKey }; const headers = { "X-API-KEY": config.apiKey };
let stage = "create"; let stage = "create";
let providerTaskId: string | undefined; let providerTaskId: string | undefined;
const sourceImage = await prepareImageForAos(imageBuffer);
try { try {
const normalizedImage = await prepareImageForAos(imageBuffer);
const createForm = new FormData(); const createForm = new FormData();
let publicSourceUrl: string | null = null; let publicSourceUrl: string | null = null;
if (config.inputImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) { if (!normalizedImage.resized && config.sourceImageUrl) {
try { try {
const source = new URL(config.inputImageUrl); const source = new URL(config.sourceImageUrl);
const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || ""); const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || "");
if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) { if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) {
publicSourceUrl = source.toString(); publicSourceUrl = source.toString();
@ -408,10 +399,10 @@ async function processWithAosSegmentation(
if (publicSourceUrl) { if (publicSourceUrl) {
createForm.append("image_url", publicSourceUrl); createForm.append("image_url", publicSourceUrl);
} else { } else {
const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], { const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], {
type: sourceImage.mimeType, type: normalizedImage.mimeType,
}); });
createForm.append("image_file", imageBlob, sourceImage.filename); createForm.append("image_file", imageBlob, normalizedImage.filename);
} }
createForm.append("sync", "0"); createForm.append("sync", "0");
createForm.append("return_type", "1"); createForm.append("return_type", "1");
@ -423,7 +414,7 @@ async function processWithAosSegmentation(
} }
const createStartedAt = Date.now(); 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 }); logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : normalizedImage.buffer.length });
const createResponse = await withTimeout( const createResponse = await withTimeout(
(signal) => (signal) =>
@ -444,7 +435,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`, error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`,
}; };
} }
@ -458,23 +449,17 @@ async function processWithAosSegmentation(
await sleep(1000); await sleep(1000);
stage = "poll"; stage = "poll";
const pollResponse = await readAosResult( const pollResponse = await withTimeout(
async (signal) => { (signal) =>
const response = await fetch(taskUrl, { fetch(taskUrl, {
method: "GET", method: "GET",
headers, headers,
signal, signal,
}); }),
const payload = await response.json().catch((error) => {
if (error instanceof Error && error.name !== "SyntaxError") throw error;
return null;
});
return { ok: response.ok, status: response.status, statusText: response.statusText, payload };
},
AOS_POLL_TIMEOUT_MS AOS_POLL_TIMEOUT_MS
); );
const pollPayload = pollResponse.payload; const pollPayload = await pollResponse.json().catch(() => null);
if (!pollResponse.ok || !pollPayload?.data) { if (!pollResponse.ok || !pollPayload?.data) {
logger.warn("aos_task_poll_failed", { logger.warn("aos_task_poll_failed", {
hasPayload: pollPayload != null, hasPayload: pollPayload != null,
@ -482,7 +467,7 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`, error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`,
}; };
} }
@ -497,14 +482,12 @@ async function processWithAosSegmentation(
taskId, taskId,
}); });
stage = "download"; stage = "download";
const imageResponse = await readAosResult( const imageResponse = await withTimeout(
async (signal) => { (signal) =>
const response = await fetch(imageUrl, { fetch(imageUrl, {
method: "GET", method: "GET",
signal, signal,
}); }),
return { ok: response.ok, status: response.status, statusText: response.statusText, buffer: response.ok ? Buffer.from(await response.arrayBuffer()) : null };
},
AOS_DOWNLOAD_TIMEOUT_MS AOS_DOWNLOAD_TIMEOUT_MS
); );
@ -515,11 +498,11 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`, error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`,
}; };
} }
const resultBuffer = imageResponse.buffer!; const resultBuffer = Buffer.from(await imageResponse.arrayBuffer());
return { return {
success: true, success: true,
imageBuffer: resultBuffer, imageBuffer: resultBuffer,
@ -535,13 +518,13 @@ async function processWithAosSegmentation(
}); });
return { return {
success: false, success: false,
error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`, error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`,
}; };
} }
} }
logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount }); logger.warn("aos_task_timeout", { maxPollCount });
return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` }; return { success: false, error: "AOS任务超时(最多60秒)" };
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length }); logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length });
@ -552,13 +535,8 @@ async function processWithAosSegmentation(
: `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`; : `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`;
return { success: false, error: message }; return { success: false, error: message };
} }
logger.warn("aos_provider_request_error", error, { requestId: config.requestId, stage, taskId: providerTaskId }); logger.warn("aos_provider_request_error", error);
const detail = error instanceof Error ? error.message : "未知错误"; return { success: false, error: error instanceof Error ? error.message : "未知错误" };
return { success: false, error: stage === "create"
? `AOS创建任务连接失败,结果尚未确认:${detail}`
: stage === "poll"
? `AOS查询任务连接失败,结果尚未确认(任务 ${providerTaskId}):${detail}`
: `AOS结果下载连接失败,任务已完成(任务 ${providerTaskId}):${detail}` };
} }
} }
@ -655,15 +633,9 @@ export async function fetchImageBufferFromUrl(imageUrl: string): Promise<Buffer>
export async function executeRmbgJob( export async function executeRmbgJob(
params: ExecuteRmbgJobParams params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> {
return trackRuntimeWork(() => runRmbgJob(params));
}
async function runRmbgJob(
params: ExecuteRmbgJobParams
): Promise<ExecuteRmbgJobResult> { ): Promise<ExecuteRmbgJobResult> {
const { const {
requestId = crypto.randomUUID(), requestId,
userId, userId,
imageBuffer, imageBuffer,
imageBase64 = imageBuffer.toString("base64"), imageBase64 = imageBuffer.toString("base64"),
@ -672,13 +644,11 @@ async function runRmbgJob(
chatId = null, chatId = null,
messageId = null, messageId = null,
skipGallerySave = false, skipGallerySave = false,
inputImageUrl = null,
sourceImageUrl = null, sourceImageUrl = null,
} = params; } = params;
const { provider, config } = await getValidatedRmbgConfig(); const { provider, config } = await getValidatedRmbgConfig();
logger.info("job_started", { logger.info("job_started", {
requestId,
hasChatId: Boolean(chatId), hasChatId: Boolean(chatId),
hasMessageId: Boolean(messageId), hasMessageId: Boolean(messageId),
hasSourceImageUrl: Boolean(sourceImageUrl), hasSourceImageUrl: Boolean(sourceImageUrl),
@ -722,7 +692,7 @@ async function runRmbgJob(
foregroundType: foregroundType ?? config.aosForegroundType ?? "", foregroundType: foregroundType ?? config.aosForegroundType ?? "",
crop: config.aosCrop === 1 ? 1 : 0, crop: config.aosCrop === 1 ? 1 : 0,
format: config.aosFormat === "jpg" ? "jpg" : "png", format: config.aosFormat === "jpg" ? "jpg" : "png",
inputImageUrl, sourceImageUrl,
requestId, requestId,
}); });
@ -860,7 +830,6 @@ async function runRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
generatedImageId: savedImage.id, generatedImageId: savedImage.id,
mediaAssetId, mediaAssetId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
@ -883,7 +852,6 @@ async function runRmbgJob(
} }
logger.info("job_completed", { logger.info("job_completed", {
requestId,
processingSeconds: Number(processingTime.toFixed(2)), processingSeconds: Number(processingTime.toFixed(2)),
provider, provider,
savedToGallery: false, savedToGallery: false,

View File

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

View File

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

View File

@ -8,12 +8,6 @@ export class AgentImageGenerationError extends Error {
constructor(message: string, readonly confirmedNoResult: boolean) { super(message); } constructor(message: string, readonly confirmedNoResult: boolean) { super(message); }
} }
export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
constructor() {
super("提交生图前读取参考图超时,任务尚未提交,可以稍后重试。", true);
}
}
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */ /** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") { export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
const rejected = error instanceof ImageSubmissionRejectedError; const rejected = error instanceof ImageSubmissionRejectedError;

View File

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

View File

@ -143,7 +143,6 @@ const IMAGE_TOOL_MODEL_IDS = new Set([
"id-photo", "id-photo",
"image-expansion", "image-expansion",
"image-upscaler", "image-upscaler",
"ocr",
"photo-cleanup", "photo-cleanup",
"remove-background", "remove-background",
"remove-object", "remove-object",
@ -1042,7 +1041,6 @@ function createToolPreset(
const inputModes = getInputModes(model); const inputModes = getInputModes(model);
const outputModes = getOutputModes(model); const outputModes = getOutputModes(model);
const isImageTool = type === "image_tool"; const isImageTool = type === "image_tool";
const isOcr = modelId.toLowerCase() === "ocr";
const toolOperation = IMAGE_TOOL_OPERATION_BY_MODEL_ID[modelId.toLowerCase()] || type; const toolOperation = IMAGE_TOOL_OPERATION_BY_MODEL_ID[modelId.toLowerCase()] || type;
return { return {
@ -1058,7 +1056,7 @@ function createToolPreset(
kind: type.replace(/_/g, "-"), kind: type.replace(/_/g, "-"),
mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document", mediaType: isImageTool ? "image" : type === "audio" ? "audio" : "document",
modelType: type, modelType: type,
outputType: isOcr ? "text" : outputModes[0] || (isImageTool ? "image" : "text"), outputType: outputModes[0] || (isImageTool ? "image" : "text"),
inputModes, inputModes,
outputModes, outputModes,
supportedModes: getCapabilities(model), supportedModes: getCapabilities(model),

View File

@ -4,11 +4,9 @@ import { join } from "node:path";
import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs"; import type { NativeEvent, NativeResult } from "@/services/zcode-agent/runner.mjs";
import { runNativeZcode } from "./native-runtime"; import { runNativeZcode } from "./native-runtime";
import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop"; import type { CanvasAgentTool } from "@/lib/ai/canvas-agent-runtime/loop";
import { AgentToolRejectedError, publicAgentError, type AgentCallRecord, type AgentRunSnapshot, type CanvasAgentRunStatus } from "@/lib/ai/canvas-agent-runtime/protocol"; import { AgentToolRejectedError, publicAgentError, 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 { isCompletedSimpleCutout } from "@/lib/ai/canvas-agent-runtime/cutout-completion";
import { countAgentGeneratedImages, requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results"; import { requiresCanvasInteraction } from "@/lib/ai/canvas-agent-runtime/results";
import { selectedAgentImageCount } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error"; import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { publicAssistantText } from "./assistant-output"; import { publicAssistantText } from "./assistant-output";
@ -39,27 +37,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
snapshot.message = message; snapshot.message = message;
await ports.checkpoint(structuredClone(snapshot), status); return status; 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)}`;
}
function imageCountReached() {
const count = snapshot.workspace === "video" ? undefined : selectedAgentImageCount(snapshot.imagePreferences);
return count !== undefined && countAgentGeneratedImages(snapshot.calls) >= count;
}
function reviewMessage(issues: string[]) {
const summary = `结果待核对:${issues.join(";")}`;
return imageCountReached()
? `${summary}\n\n已保留本次生成的图片。如需再生成,请发送新指令。`
: summary;
}
let modelError = ""; let modelError = "";
let toolFailure = ""; let toolFailure = "";
let nativeFailed = false; let nativeFailed = false;
@ -67,11 +44,11 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const seenTextEvents = new Set<string>(); const seenTextEvents = new Set<string>();
let lastTextMessage = ""; let lastTextMessage = "";
async function appendText(delta: string) { async function appendText(delta: string) {
const text = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0))); delta = publicAssistantText(delta).slice(0, Math.max(0, 100_000 - (snapshot.assistantText?.length || 0)));
if (!text) return; if (!delta) return;
snapshot.assistantText = (snapshot.assistantText || "") + text; snapshot.assistantText = (snapshot.assistantText || "") + delta;
await ports.checkpoint(structuredClone(snapshot), "running"); await ports.checkpoint(structuredClone(snapshot), "running");
ports.onAssistantText?.(text); ports.onAssistantText?.(delta);
} }
async function finishVerifiedCutout() { async function finishVerifiedCutout() {
snapshot.nativeRuntime!.state = "stopped"; snapshot.nativeRuntime!.state = "stopped";
@ -79,8 +56,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
return save(verifiedCompletion, "completed"); return save(verifiedCompletion, "completed");
} }
try { try {
const unconfirmed = snapshot.calls.find(call => call.status === "started" || call.uncertain); if (snapshot.calls.some(call => call.status === "started" || call.uncertain))
if (unconfirmed) return await save(failureMessage(unconfirmed), "needs_review"); return await save("存在结果未确认的工具调用,请先核对画布,避免重复生成。", "needs_review");
snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" }; snapshot.nativeRuntime = { ...snapshot.nativeRuntime, engine: "zcode", state: "starting" };
const schemas = new Map(Object.entries(ports.definitions).map(([name, definition]) => [name, asSchema(definition.inputSchema!)])); 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, const toolDescriptors = Object.entries(ports.tools).map(([name, entry]) => ({ name,
@ -95,7 +72,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
`可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`, `可用业务工具:${tools.join("、")}。工具名称带 mcp__canvas__ 前缀。`,
"原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。", "原生文件、Bash、记忆及子任务能力仅作用于此任务的私有工作区,不能通过 shell 直接访问模型服务商或本站后台;业务操作使用 MCP。",
"用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。", "用户只要求生图或改图时,把结果写入画布并验收即可。除非用户明确要求下载或导出,不要主动调用 image_download,也不要为收尾打开交互面板。",
"用户设置的生成张数是本次任务的总数,包括验收未通过的已生成图片。达到张数后,保留结果并说明问题;如需再次生成,等待用户发送新指令。",
"用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。", "用户指定的生图模型被明确拒绝时,停止本轮收费操作,询问用户希望切换生图模型还是稍后重试。切换需用户确认并更新模型选择;结果未知的超时必须先核对原任务,不可直接换模型重发。不要为普通图片编辑调用 appearance.post 或其他无关业务接口。",
"imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。", "imagePreferences 是用户在输入框选择的生图模型和图片参数,与当前规划模型不同。生成和编辑图片优先用 generateCanvasImages;用户选定的模型不得被技能默认值、你选的其他模型或重试覆盖。自动选择生图模型时,图片工具内部会在明确失败后切换备用模型并补齐结果,不要自行重放付费操作;用户明确指定模型时禁止故障切换。比例、尺寸、数量、反向提示词等控件必须传入;不要通过其他生成接口绕过用户选择。分析、问答、排版等不需要出图的任务无需生成图片。",
"转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。", "转正面、换角度、旋转主体视角时,直接用 generateCanvasImages,referenceNodeIds 指向要编辑的真实图片,提示词描述目标视角并保留产品结构和未要求修改的细节。旧多视角功能、angleEditImage 和 image.multi_angle 已废弃,即使历史对话用过也不得再调用或打开旧面板。",
@ -135,7 +111,6 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
await save("正在准备处理任务"); await save("正在准备处理任务");
}), }),
onEvent: async (event: NativeEvent | NativeResult) => { 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") { if (event.type === "model.streaming" && event.payload?.kind === "text_delta" && typeof event.payload.delta === "string") {
// Child sessions and reasoning/tool-argument deltas are private. // Child sessions and reasoning/tool-argument deltas are private.
if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return; if (snapshot.nativeRuntime?.sessionId && event.sessionId !== snapshot.nativeRuntime.sessionId) return;
@ -180,11 +155,9 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded")) if (!entry.readOnly && !snapshot.calls.some(call => call.name === "planCanvasTask" && call.status === "succeeded"))
throw new Error("请先制定任务计划再执行画布修改"); throw new Error("请先制定任务计划再执行画布修改");
if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) { if (!entry.readOnly && snapshot.verification?.retryable && snapshot.verification.failedNodeIds?.length) {
const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : [])] const targets = [input.nodeId, ...(Array.isArray(input.nodeIds) ? input.nodeIds : []),
.filter((id): id is string => typeof id === "string"); ...(Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : [])].filter((id): id is string => typeof id === "string");
const references = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds : []; if (!targets.length || targets.some(id => !snapshot.verification!.failedNodeIds!.includes(id)))
const failedIds = snapshot.verification.failedNodeIds;
if (targets.length ? targets.some(id => !failedIds.includes(id)) : !references.some(id => failedIds.includes(String(id))))
throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果"); throw new Error("修复只能处理未通过验收的节点,禁止重复生成已通过的结果");
} }
snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" }); snapshot.calls.push({ id: callId, name, title: entry.title, input, status: "started" });
@ -232,9 +205,8 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError; call.status = "failed"; call.error = publicAgentError(error); call.rejected = error instanceof AgentToolRejectedError;
call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult; call.confirmedNoResult = error instanceof AgentImageGenerationError && error.confirmedNoResult;
call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult; call.uncertain = !entry.readOnly && !call.rejected && !call.confirmedNoResult;
if (call.uncertain || call.confirmedNoResult) toolFailure = failureMessage(call); if (call.confirmedNoResult) toolFailure = call.error;
await save(toolFailure || call.error); await save(call.error);
if (call.uncertain || call.confirmedNoResult) attemptController.abort();
}); });
throw error; throw error;
} }
@ -256,15 +228,15 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (toolFailure) return await save(toolFailure, "needs_review"); if (toolFailure) return await save(toolFailure, "needs_review");
// A failed read is not an uncertain paid operation. Independently verify // 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. // actual outputs below instead of failing a task because an earlier read timed out.
const failedWrite = snapshot.calls.find(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain); if (snapshot.calls.some(call => (call.status === "failed" && !call.rejected && !call.confirmedNoResult && !ports.tools[call.name]?.readOnly) || call.uncertain))
if (failedWrite) return await save(failureMessage(failedWrite), "needs_review"); return await save("部分工具结果待确认,已保留执行记录,请核对后继续。", "needs_review");
const unresolvedRead = snapshot.calls.find((call, index) => call.status === "failed" && !call.rejected && ports.tools[call.name]?.readOnly && const unresolvedRead = snapshot.calls.some((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" && !snapshot.calls.slice(index + 1).some(later => later.name === call.name && later.status === "succeeded" &&
JSON.stringify(later.input) === JSON.stringify(call.input))); JSON.stringify(later.input) === JSON.stringify(call.input)));
// Without a real result to independently inspect, a viewport/UI action // Without a real result to independently inspect, a viewport/UI action
// cannot turn a failed analysis into a completed task. // cannot turn a failed analysis into a completed task.
if (unresolvedRead && !snapshot.resultNodeIds.length) if (unresolvedRead && !snapshot.resultNodeIds.length)
return await save(failureMessage(unresolvedRead), "needs_review"); return await save("图片或资料暂未读取成功,已保留记录,请稍后继续核对。", "needs_review");
if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call))) if (question || snapshot.calls.some(call => requiresCanvasInteraction(snapshot, call)))
return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review"); return await save(question || "请在已打开的工具面板中完成操作,然后继续任务。", "needs_review");
const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id)); const supplements = snapshot.inputs.filter(input => !startedInputs.has(input.id));
@ -289,12 +261,12 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification; const verification = await ports.verify(reviewSnapshot); snapshot.verification = verification;
snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens; snapshot.inputTokens = reviewSnapshot.inputTokens; snapshot.outputTokens = reviewSnapshot.outputTokens;
if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed"); if (verification.passed) return await save(result.response || "任务完成,结果已写入画布。", "completed");
if (verification.retryable && snapshot.repairs < 2 && !imageCountReached()) { if (verification.retryable && snapshot.repairs < 2) {
snapshot.repairs++; snapshot.repairs++;
prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`; prompt = `验收需要定点修复:${JSON.stringify(verification)}。只处理失败结果,禁止整批重新生成;保留所有通过结果。`;
continue; continue;
} }
return await save(reviewMessage(verification.issues), "needs_review"); return await save(`结果待核对:${verification.issues.join(";")}`, "needs_review");
} }
} catch (error) { } catch (error) {
if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped"; if (snapshot.nativeRuntime) snapshot.nativeRuntime.state = "stopped";
@ -318,7 +290,7 @@ export async function runZcodeCanvasTask(ports: Ports): Promise<CanvasAgentRunSt
if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled"); if (control.cancelled || ports.signal.aborted) return await save("已停止后续步骤,完成的结果保留在画布。", "cancelled");
if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review"); if (inputs !== JSON.stringify(snapshot.inputs)) return await save("已收到新的补充要求,图片已保留,请继续处理。", "needs_review");
return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。" return await save(verification.passed ? "图片已完成并通过检查,结果已保留在画布。"
: `图片已返回画布,${reviewMessage(verification.issues)}`, verification.passed ? "completed" : "needs_review"); : `图片已返回画布,结果待核对:${verification.issues.join(";")}`, verification.passed ? "completed" : "needs_review");
} catch { /* Keep the original interruption if independent inspection also fails. */ } } catch { /* Keep the original interruption if independent inspection also fails. */ }
} }
return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review"); return await save(cancelled ? "已停止后续步骤,完成的结果保留在画布。" : toolFailure || modelError || publicAgentError(error), cancelled ? "cancelled" : "needs_review");

View File

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

View File

@ -5,7 +5,7 @@ import sharp from "sharp";
import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store"; import { agentImageCircuitStore } from "@/lib/ai/runtime/agent-model-circuit-store";
import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost, type DiscountableImageGenConfigLike } from "@/lib/ai/image-model-pricing";
import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover"; import { agentImageFailurePolicy } from "@/lib/ai/runtime/agent-image-failover";
import { agentImageGenerationError, ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error"; import { agentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries"; import { getAiModelById, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service"; import { fetchImageBufferFromUrl } from "@/lib/ai/rmbg/service";
import { createGeminiImage } from "@/lib/ai/providers/gemini-image"; import { createGeminiImage } from "@/lib/ai/providers/gemini-image";
@ -44,7 +44,7 @@ import {
} from "@/lib/ai/runtime/model-health"; } from "@/lib/ai/runtime/model-health";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance";
import { getRequestFailureDetails, isProviderBalanceError } from "@/lib/request-error"; import { isProviderBalanceError } from "@/lib/request-error";
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
export interface CanvasLocalEditCompositeRegion { export interface CanvasLocalEditCompositeRegion {
@ -181,7 +181,6 @@ export interface CanvasGenerateImageRequest {
providerImageParams?: Record<string, unknown>; providerImageParams?: Record<string, unknown>;
referenceImage?: string; referenceImage?: string;
referenceImages?: string[]; referenceImages?: string[];
referenceImageBuffers?: ReadonlyMap<string, Buffer>;
billingFeature?: "multi-view" | "repaint"; billingFeature?: "multi-view" | "repaint";
requiredReferenceImageCount?: number; requiredReferenceImageCount?: number;
ownerKey?: string; ownerKey?: string;
@ -5122,11 +5121,8 @@ export async function generateCanvasImage(
}); });
} }
const referenceImageBuffers = provider.type === "alibaba-image" ? request.referenceImageBuffers : undefined; const { kept: reachableReferenceImages, dropped: unreachableReferenceImages } =
const referencesToCheck = normalizedReferenceImages.filter(url => !referenceImageBuffers?.has(url)); await filterReachableRemoteUrls(normalizedReferenceImages, { timeoutMs: 3000 });
const { kept, dropped: unreachableReferenceImages } =
await filterReachableRemoteUrls(referencesToCheck, { timeoutMs: 3000 });
const reachableReferenceImages = normalizedReferenceImages.filter(url => referenceImageBuffers?.has(url) || kept.includes(url));
const effectiveReferenceImages = selectCanvasReferenceImages({ const effectiveReferenceImages = selectCanvasReferenceImages({
requested: normalizedReferenceImages, reachable: reachableReferenceImages, requested: normalizedReferenceImages, reachable: reachableReferenceImages,
requiredCount: request.requiredReferenceImageCount, requiredCount: request.requiredReferenceImageCount,
@ -5237,7 +5233,6 @@ export async function generateCanvasImage(
executeGenerateBatch = async (batchImageCount) => { executeGenerateBatch = async (batchImageCount) => {
const result = await modelInstance.doGenerate({ const result = await modelInstance.doGenerate({
messages, messages,
referenceImageBuffers,
imageParams: { imageParams: {
...imageParams, ...imageParams,
imageCount: batchImageCount, imageCount: batchImageCount,
@ -5667,17 +5662,8 @@ export async function generateCanvasImage(
await slot.release(); await slot.release();
} }
if (caughtError instanceof ImageReferenceReadTimeoutError) {
if (!collectedImages.length) throw caughtError;
return { images: collectedImages, model: selectedModel.model.displayName, modelRef: selectedModel.model.id, prompt: resultPrompt };
}
// An accepted or uncertain request must not start another paid model/variant. // An accepted or uncertain request must not start another paid model/variant.
if (caughtError && ( if (caughtError instanceof ProviderTaskUncertainError) throw caughtError;
caughtError instanceof ProviderTaskUncertainError ||
getRequestFailureDetails(caughtError).outcome === "unknown" ||
["AbortError", "TimeoutError"].includes(String((caughtError as { name?: string })?.name || ""))
)) throw caughtError;
const retryVariants = Array.isArray(request.retryVariants) const retryVariants = Array.isArray(request.retryVariants)
? request.retryVariants ? request.retryVariants

View File

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

View File

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

View File

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

View File

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

View File

@ -217,9 +217,7 @@ export async function convertImageToPhotoshopPng(bytes: Buffer): Promise<Buffer>
throw new Error("无法解析图片尺寸"); throw new Error("无法解析图片尺寸");
} }
// RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes. // RGB/RGBA, 8-bit, single-frame PNG is already compatible; preserve its exact bytes.
const rgb = (metadata.space === "srgb" && metadata.depth === "uchar") if (metadata.format === "png" && metadata.space === "srgb" && metadata.depth === "uchar"
|| (metadata.space === "rgb16" && metadata.depth === "ushort");
if (metadata.format === "png" && rgb
&& !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes; && !metadata.isPalette && (metadata.pages || 1) === 1 && (!metadata.orientation || metadata.orientation === 1)) return bytes;
return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer(); return image.rotate().toColorspace("srgb").png({ compressionLevel: 3, adaptiveFiltering: true }).toBuffer();
} }

View File

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

View File

@ -1,55 +0,0 @@
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.
}
}

View File

@ -1,27 +1,11 @@
import type { AgentWorkflowComposerContext } from "@/types/agent";
const settingsCache = new Map<string, { etag: string; data: unknown }>(); const settingsCache = new Map<string, { etag: string; data: unknown }>();
export async function fetchWorkflowTemplatePrompts([url, selectionRequest]: readonly [string, string]) {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: selectionRequest,
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw new Error("风格模板提示词加载失败");
const data = await response.json() as { selections?: AgentWorkflowComposerContext["selections"] };
if (!Array.isArray(data.selections)) throw new Error("风格模板提示词返回异常");
return { selectionRequest, selections: data.selections };
}
export async function fetchCanvasSiteSettings(url: string) { export async function fetchCanvasSiteSettings(url: string) {
const cached = settingsCache.get(url); const cached = settingsCache.get(url);
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
url === "/api/site-settings?scope=agent-canvas" || url === "/api/site-settings?scope=agent-canvas" ? 30_000 : 10_000
url === "/api/site-settings?scope=agent-workflow-templates" ? 30_000 : 10_000
); );
try { try {
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -63,5 +63,4 @@ export async function executeCanvasToolbarCapability(
} }
current.loadCanvas(JSON.parse(merged.snapshot).canvasData); current.loadCanvas(JSON.parse(merged.snapshot).canvasData);
current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt)); current.setCanvasRevision(String(result.data.updatedAt), String(result.data.updatedAt));
return { projectId, canvasData: result.data.canvasData, updatedAt: String(result.data.updatedAt) };
} }

View File

@ -1,15 +0,0 @@
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();
}

View File

@ -41,13 +41,12 @@ export async function runImageArchiveJob() {
try { try {
if (!(await markImageArchiveTaskArchiving(task.id))) return; if (!(await markImageArchiveTaskArchiving(task.id))) return;
const metadata = task.metadata as { inlineSource?: string } | null; const metadata = task.metadata as { inlineSource?: string } | null;
const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl, task.id); const archived = await archiveTemporaryImage(metadata?.inlineSource || task.sourceUrl);
await markImageArchiveTaskStored({ await markImageArchiveTaskStored({
id: task.id, id: task.id,
targetUrl: archived.url, targetUrl: archived.url,
downloadAsset: archived.downloadAsset,
}); });
if (!archived.downloadAsset) scheduleImageDownloadPreparation(task.id); scheduleImageDownloadPreparation(task.id);
storeSucceeded += 1; storeSucceeded += 1;
console.log("[ImageArchiveJob] stored", { console.log("[ImageArchiveJob] stored", {
taskId: task.id, taskId: task.id,

View File

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

View File

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

View File

@ -1,72 +1,11 @@
import "server-only"; import "server-only";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { open } from "node:fs/promises"; import { PutObjectCommand } from "@aws-sdk/client-s3";
import { AbortMultipartUploadCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, HeadObjectCommand, PutObjectCommand, UploadPartCommand, type S3Client } from "@aws-sdk/client-s3";
import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries"; import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries";
import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service"; import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service";
type ZipPublication = { id: string; path: string; bytes: number; disposition: string; onProgress?: (bytes: number) => Promise<void> }; /** Only a completely written archive is uploaded. Local GET remains a fallback. */
const PART_BYTES = 8 * 1024 ** 2; export async function publishCanvasZip(params: { id: string; path: string; bytes: number; disposition: string }) {
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 raw = await getDefaultStorageConfig();
const config = toDbS3UploadConfig(raw); const config = toDbS3UploadConfig(raw);
if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null; if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null;
@ -74,37 +13,20 @@ export async function publishCanvasZip(params: ZipPublication) {
const client = createS3Client(config); const client = createS3Client(config);
// Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers. // Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers.
client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED"; client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED";
let phase: "check" | "upload" | "verify" = "check"; const body = createReadStream(params.path);
const startedAt = Date.now();
let uploadedBytes = 0;
try { try {
const existing = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }), { abortSignal: AbortSignal.timeout(15_000) }).catch(error => { await client.send(new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: body,
if (error.$metadata?.httpStatusCode === 404) return null; ContentLength: params.bytes, ContentType: "application/zip", ContentDisposition: params.disposition,
throw error; CacheControl: "public, max-age=1800" }), { abortSignal: AbortSignal.timeout(90_000) });
}); const url = buildS3PublicUrl(config, key);
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 response = await fetch(url, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(10_000) });
const length = Number(response.headers.get("content-length")); const length = Number(response.headers.get("content-length"));
if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready"); if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready");
return { url, storageConfigId: raw!.id, key }; return { url, storageConfigId: raw!.id, key };
} catch (error) { } catch (error) {
const kind = (error as Error).name; console.warn("[CanvasDownloadZipTask] CDN upload/check failed; using complete local file", { taskId: params.id, kind: (error as Error).name });
const status = (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode; return { url: null, storageConfigId: raw!.id, key };
console.warn("[CanvasDownloadZipTask] CDN publication failed", { taskId: params.id, phase, kind, status, bytes: params.bytes, uploadedBytes, elapsedMs: Date.now() - startedAt }); } finally { body.destroy(); client.destroy(); }
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 }) { export async function removeCanvasZipObject(object: { storageConfigId: string; key: string }) {

View File

@ -138,7 +138,6 @@ export async function markImageArchiveTaskStored(params: {
id: string; id: string;
targetUrl: string; targetUrl: string;
targetStorageConfigId?: string | null; targetStorageConfigId?: string | null;
downloadAsset?: import("./qiniu-image-archive").ReadyImageDownload;
}) { }) {
await db await db
.update(imageArchiveTask) .update(imageArchiveTask)
@ -146,7 +145,7 @@ export async function markImageArchiveTaskStored(params: {
status: "stored", status: "stored",
targetUrl: params.targetUrl, targetUrl: params.targetUrl,
targetStorageConfigId: params.targetStorageConfigId || null, targetStorageConfigId: params.targetStorageConfigId || null,
metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', ${JSON.stringify(params.downloadAsset ?? { status: "pending" })}::jsonb)`, metadata: sql`coalesce(${imageArchiveTask.metadata}, '{}'::jsonb) || jsonb_build_object('downloadAsset', jsonb_build_object('status', 'pending'))`,
archivedAt: new Date(), archivedAt: new Date(),
replaceAfterAt: new Date(), replaceAfterAt: new Date(),
lastError: null, lastError: null,

View File

@ -12,7 +12,6 @@ import {
imageArchiveSourceKey, imageArchiveSourceKey,
} from "@/lib/storage/image-archive-db"; } from "@/lib/storage/image-archive-db";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { archiveImageThroughQiniu } from "./qiniu-image-archive";
const logger = createLogger("image-archive"); const logger = createLogger("image-archive");
@ -183,7 +182,7 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
}); });
return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) }; return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) };
} catch (error) { } catch (error) {
if (attempt >= retries || /\bHTTP (?:404|410)\b/.test(String(error))) throw error; if (attempt >= retries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1))); await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
} }
} }
@ -191,14 +190,7 @@ export async function downloadTemporaryImage(params: { sourceUrl: string; retrie
throw new Error("Image download failed"); throw new Error("Image download failed");
} }
export async function archiveTemporaryImage(sourceUrl: string, archiveId?: string) { export async function archiveTemporaryImage(sourceUrl: string) {
if (archiveId) {
const direct = await archiveImageThroughQiniu(sourceUrl, archiveId).catch(error => {
logger.warn("cloud_archive_fallback", error, { archiveId });
return null;
});
if (direct) return direct;
}
const downloaded = await downloadTemporaryImage({ sourceUrl }); const downloaded = await downloadTemporaryImage({ sourceUrl });
const objectSuffix = `${Date.now()}-${randomUUID()}`; const objectSuffix = `${Date.now()}-${randomUUID()}`;
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`; const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
@ -210,6 +202,5 @@ export async function archiveTemporaryImage(sourceUrl: string, archiveId?: strin
pathname: uploaded.pathname, pathname: uploaded.pathname,
contentType: uploaded.contentType, contentType: uploaded.contentType,
bytes: downloaded.buffer.length, bytes: downloaded.buffer.length,
downloadAsset: undefined,
}; };
} }

View File

@ -4,7 +4,6 @@ import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db"; import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
import { uploadFile } from "./service"; import { uploadFile } from "./service";
import { getReadyQiniuImageDownload } from "./qiniu-image-archive";
import { getDownloadFailure } from "@/lib/canvas/download-error"; import { getDownloadFailure } from "@/lib/canvas/download-error";
import { createLogger } from "@/lib/observability/logger"; import { createLogger } from "@/lib/observability/logger";
import { trackRuntimeWork } from "@/lib/observability/active-work"; import { trackRuntimeWork } from "@/lib/observability/active-work";
@ -16,11 +15,6 @@ export async function prepareImageArchiveDownload(id: string) {
if (!task?.targetUrl) return; if (!task?.targetUrl) return;
let phase: "read" | "convert" | "store" = "read"; let phase: "read" | "convert" | "store" = "read";
try { try {
const ready = await getReadyQiniuImageDownload(task.targetUrl).catch(() => undefined);
if (ready) {
await saveImageDownloadAsset(id, ready);
return;
}
const source = await fetchImageBytes(task.targetUrl); const source = await fetchImageBytes(task.targetUrl);
phase = "convert"; phase = "convert";
const asset = await normalizeCanvasDownloadAsset({ const asset = await normalizeCanvasDownloadAsset({

View File

@ -1,121 +0,0 @@
import "server-only";
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { createHmac } from "node:crypto";
import {
buildS3PublicUrl,
createS3Client,
resolveS3UploadTarget,
} from "@/lib/storage/service";
const QINIU_EAST_CHINA_IO = "iovip-z0.qiniuio.com";
const PNG_HEADER_LIMIT = 64 * 1024;
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
export type ReadyImageDownload = {
status: "ready";
url: string;
contentType: "image/png";
bytes: number;
preparedAt: string;
};
function encodeQiniu(value: string | Buffer) {
return (typeof value === "string" ? Buffer.from(value) : value)
.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
}
function isDownloadReadyPng(prefix: Buffer) {
if (prefix.length < 41 || !prefix.subarray(0, 8).equals(PNG_SIGNATURE)
|| prefix.readUInt32BE(8) !== 13 || prefix.toString("ascii", 12, 16) !== "IHDR"
|| !prefix.readUInt32BE(16) || !prefix.readUInt32BE(20)
|| (prefix[24] !== 8 && prefix[24] !== 16) || (prefix[25] !== 2 && prefix[25] !== 6)) return false;
// Animation and EXIF orientation need the existing full-image preparation.
// An embedded color profile is fine: the browser gets the exact stored bytes.
for (let offset = 33; offset + 8 <= prefix.length;) {
const type = prefix.toString("ascii", offset + 4, offset + 8);
if (type === "IDAT") return true;
if (type === "acTL" || type === "eXIf" || type === "IEND") return false;
offset += prefix.readUInt32BE(offset) + 12;
}
return false;
}
export async function getReadyQiniuImageDownload(url: string, target?: Awaited<ReturnType<typeof resolveS3UploadTarget>>) {
const config = (target ?? await resolveS3UploadTarget())?.config;
if (config?.provider !== "qiniu-kodo" || !config.publicUrl) return;
const base = buildS3PublicUrl(config, "");
if (!url.startsWith(base)) return;
const key = url.slice(base.length);
if (!/^uploads\/[\w.-]+$/.test(key)) return;
const client = createS3Client(config);
try {
const response = await client.send(new GetObjectCommand({
Bucket: config.bucket, Key: key, Range: `bytes=0-${PNG_HEADER_LIMIT - 1}`,
}), { abortSignal: AbortSignal.timeout(10_000) });
const range = response.ContentRange?.match(/^bytes 0-(\d+)\/(\d+)$/);
const length = response.ContentLength ?? 0;
const bytes = Number(range?.[2]);
if (response.$metadata.httpStatusCode !== 206 || response.ContentType !== "image/png"
|| !range || !response.Body || length <= 0 || length > PNG_HEADER_LIMIT
|| Number(range[1]) + 1 !== length || bytes < length) {
(response.Body as NodeJS.ReadableStream & { destroy?: () => void } | undefined)?.destroy?.();
return;
}
const prefix = Buffer.from(await response.Body.transformToByteArray());
if (prefix.length === length && isDownloadReadyPng(prefix)) {
return { status: "ready", url, contentType: "image/png", bytes, preparedAt: new Date().toISOString() } satisfies ReadyImageDownload;
}
} finally {
client.destroy();
}
}
export async function archiveImageThroughQiniu(sourceUrl: string, archiveId: string) {
if (!/^https:\/\/api\.vip98k\.com\/api\/external\/assets\/[A-Za-z0-9_-]+$/.test(sourceUrl)) {
return null;
}
const target = await resolveS3UploadTarget();
const config = target?.config;
if (config?.provider !== "qiniu-kodo" || config.region !== "cn-east-1" || !config.publicUrl) {
return null;
}
// The archive task owns this object key, including retries after a timeout.
const pathname = `uploads/ai-generated-archive-${archiveId}`;
const path = `/fetch/${encodeQiniu(sourceUrl)}/to/${encodeQiniu(`${config.bucket}:${pathname}`)}`;
const contentType = "application/x-www-form-urlencoded";
const signingData = `POST ${path}\nHost: ${QINIU_EAST_CHINA_IO}\nContent-Type: ${contentType}\n\n`;
const signature = encodeQiniu(createHmac("sha1", config.secretAccessKey).update(signingData).digest());
const response = await fetch(`https://${QINIU_EAST_CHINA_IO}${path}`, {
method: "POST",
headers: {
Authorization: `Qiniu ${config.accessKeyId}:${signature}`,
"Content-Type": contentType,
},
body: "",
redirect: "error",
signal: AbortSignal.timeout(40_000),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Qiniu archive fetch returned HTTP ${response.status}`);
}
const result = await response.json() as { key?: string; fsize?: number; mimeType?: string };
if (result.key !== pathname || !result.fsize || !result.mimeType?.startsWith("image/")) {
throw new Error("Qiniu archive fetch did not return an image");
}
const url = buildS3PublicUrl(config, pathname);
return {
url,
thumbnailUrl: url,
pathname,
contentType: result.mimeType,
bytes: result.fsize,
downloadAsset: result.mimeType === "image/png"
? await getReadyQiniuImageDownload(url, target).catch(() => undefined)
: undefined,
};
}

View File

@ -107,5 +107,5 @@ export function mergeCanvasSnapshots(
(e: any) => ids.has(e.source) && ids.has(e.target), (e: any) => ids.has(e.source) && ids.has(e.target),
); );
} }
return { snapshot: JSON.stringify(snapshot), conflicts, changed: !same(snapshot, local) }; return { snapshot: JSON.stringify(snapshot), conflicts };
} }

View File

@ -1,8 +1,4 @@
import type { NextConfig } from "next"; 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() { function resolvePascalEditorFrameSource() {
const rawUrl = ( const rawUrl = (
@ -49,7 +45,6 @@ function resolveDevMediaOrigins(isDev: boolean) {
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
env: { NEXT_PUBLIC_APP_RELEASE: appRelease },
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next", distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
poweredByHeader: false, poweredByHeader: false,
allowedDevOrigins: ["127.0.0.1"], allowedDevOrigins: ["127.0.0.1"],

View File

@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test";
export default defineConfig({ export default defineConfig({
testDir: "./tests/routes", testDir: "./tests/routes",
testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access|zcode-agent-task)\.test\.tsx?/, testMatch: /(?:canvas-agent-runtime(?:-store|-review|-video|-image-preferences|-image-failover|-progress)?|canvas-agent-project-tools|agent-capability-access)\.test\.tsx?/,
fullyParallel: true, fullyParallel: true,
retries: 0, retries: 0,
workers: 2, workers: 2,

View File

@ -15,14 +15,12 @@ export default defineConfig({
/routes\/runtime-active-work\.test\.ts/, /routes\/runtime-active-work\.test\.ts/,
/routes\/canvas-capability-task-recovery\.test\.ts/, /routes\/canvas-capability-task-recovery\.test\.ts/,
/routes\/canvas-toolbar-capability\.test\.ts/, /routes\/canvas-toolbar-capability\.test\.ts/,
/routes\/canvas-text-detect\.test\.ts/,
/routes\/agent-project-recovery\.test\.ts/, /routes\/agent-project-recovery\.test\.ts/,
/routes\/admin-media-loading\.test\.ts/, /routes\/admin-media-loading\.test\.ts/,
/routes\/video-canvas-production\.test\.ts/, /routes\/video-canvas-production\.test\.ts/,
/routes\/video-canvas-media-import\.test\.ts/, /routes\/video-canvas-media-import\.test\.ts/,
/routes\/video-canvas-toolbar-jobs\.test\.ts/, /routes\/video-canvas-toolbar-jobs\.test\.ts/,
/routes\/image-provider-error-classification\.test\.ts/, /routes\/image-provider-error-classification\.test\.ts/,
/routes\/image-generation-status\.test\.ts/,
/routes\/canvas-agent-runtime-image-failover\.test\.ts/, /routes\/canvas-agent-runtime-image-failover\.test\.ts/,
/routes\/video-canvas-audio-remote\.test\.ts/, /routes\/video-canvas-audio-remote\.test\.ts/,
/routes\/video-canvas-groups-upscale\.test\.ts/, /routes\/video-canvas-groups-upscale\.test\.ts/,

View File

@ -54,7 +54,6 @@ function isHighFrequencyReadApi(pathname: string, method: string) {
if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+$/.test(pathname)) return true;
return [ return [
"/api/app-version",
"/api/ai-wash/config", "/api/ai-wash/config",
"/api/models", "/api/models",
"/api/canvas", "/api/canvas",
@ -817,10 +816,6 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) {
return NextResponse.next(); return NextResponse.next();
} }
if (pathname === "/api/app-version" && (request.method === "GET" || request.method === "HEAD")) {
return NextResponse.next();
}
// Pascal 服务到服务调用由对应 Route 再次验证共享密钥。 // Pascal 服务到服务调用由对应 Route 再次验证共享密钥。
if (isPascalInternalProxyRequest(request)) { if (isPascalInternalProxyRequest(request)) {
return NextResponse.next(); return NextResponse.next();

View File

@ -1,17 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import { basename, dirname } from "node:path";
const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`; const DEFAULT_BASE_URL = `http://127.0.0.1:${process.env.PORT || "3001"}`;
const BASE_URL = stripTrailingSlash( const BASE_URL = stripTrailingSlash(
process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL process.env.SMOKE_BASE_URL || process.argv[2] || DEFAULT_BASE_URL
); );
const REQUEST_TIMEOUT_MS = Number(process.env.SMOKE_TIMEOUT_MS || 20_000); 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 = [ const checks = [
{ path: "/api/app-version", method: "GET", expect: { status: [200] } },
{ {
path: "/", path: "/",
method: "HEAD", method: "HEAD",
@ -79,12 +74,6 @@ 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 ( if (
check.expect.location && check.expect.location &&
!matchesLocation(location, check.expect.location) !matchesLocation(location, check.expect.location)

View File

@ -95,19 +95,18 @@ function normalizeRevisionText(value: unknown) {
return String(value || "").trim(); return String(value || "").trim();
} }
function syncCanvasStoreRevisionFromUpdatedAt(projectId: string, updatedAt: unknown): void { function syncCanvasStoreRevisionFromUpdatedAt(updatedAt: unknown): void {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const revision = normalizeRevisionText(updatedAt); const revision = normalizeRevisionText(updatedAt);
if (!revision) return; if (!revision) return;
const current = useCanvasStore.getState(); useCanvasStore.getState().setCanvasRevision(revision, revision);
if (current.projectId === projectId) current.setCanvasRevision(revision, revision);
} }
function syncCanvasStoreRevisionFromProject( function syncCanvasStoreRevisionFromProject(
project?: { id: string; canvasData?: CanvasState | null; updatedAt?: Date | string } | null project?: { canvasData?: CanvasState | null; updatedAt?: Date | string } | null
): void { ): void {
if (!project?.canvasData) return; if (!project?.canvasData) return;
syncCanvasStoreRevisionFromUpdatedAt(project.id, project.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(project.updatedAt);
} }
export interface NormalizedCropRect { export interface NormalizedCropRect {
@ -204,9 +203,12 @@ export const canvasService = {
try { try {
const response = await fetch(`/api/canvas/${id}`); const response = await fetch(`/api/canvas/${id}`);
const data = await response.json(); const data = await response.json();
return { ...data, status: response.status }; if (data?.success) {
syncCanvasStoreRevisionFromProject(data.data);
}
return data;
} catch (error) { } catch (error) {
return { success: false, error: '画布读取失败,请检查网络后重试;现有内容未被覆盖', status: 0 }; return { success: false, error: '获取画布失败' };
} }
}, },
@ -333,7 +335,7 @@ export const canvasService = {
}; };
} }
if (data?.success) { if (data?.success) {
syncCanvasStoreRevisionFromUpdatedAt(id, data?.data?.updatedAt); syncCanvasStoreRevisionFromUpdatedAt(data?.data?.updatedAt);
} }
return data as ApiResponse; return data as ApiResponse;
} catch (error) { } catch (error) {

View File

@ -1,624 +0,0 @@
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([]);
});

View File

@ -1,198 +0,0 @@
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![生成结果](https://scqnycdn.jiansun.vip/uploads/scroll-result.png)\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();
});
});

View File

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

View File

@ -50,7 +50,6 @@ export function serverLoader(mocks: Record<string, unknown>, env: Record<string,
setImmediate, setImmediate,
AbortSignal, AbortSignal,
AbortController, AbortController,
structuredClone,
ReadableStream, ReadableStream,
Headers, Headers,
Blob, Blob,

View File

@ -127,9 +127,7 @@ test("Agent cannot open the retired angle panel through a generic UI command", a
const events: unknown[] = [], definitions: any = {}, executable: any = {}; const events: unknown[] = [], definitions: any = {}, executable: any = {};
const load = serverLoader({ "@/lib/canvas/agent-mutations": {}, "./canvas": {} }); 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) }); 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) });
const error = await executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel").catch((failure: unknown) => failure); await expect(executable.openCanvasFeature.execute({ featureId: "canvas.ui.imageMultiAngle" }, "old-panel")).rejects.toThrow("图片画布界面入口");
expect(error).toBeInstanceOf(load("lib/ai/canvas-agent-runtime/protocol.ts").AgentToolRejectedError);
expect(error.message).toContain("图片画布界面入口");
expect(events).toHaveLength(0); expect(events).toHaveLength(0);
}); });
test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => { test("access manifest covers all configured video functions and node/UI actions without granting fake backends", () => {

View File

@ -72,85 +72,6 @@ function projectRoute() {
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } }; return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
} }
test("a saved full detail set is queued, while overflow and changed references have distinct errors", async () => {
const references = Array.from({ length: 65 }, (_, index) => ({
id: `detail-${index + 1}`,
imageUrl: `https://example.test/detail-${index + 1}.webp`,
}));
const project = {
id, userId: owner, title: "全套详情", workspaceType: "agent",
canvasData: { agentProjectState: { workflowState: {
agentModeState: { detailReplacement: { references } },
} } },
};
const canvasProject = { id: "id", userId: "userId" };
const created: Array<{ id: string; request: { references: typeof references }; status: string }> = [];
const queued: string[] = [];
const db = {
select: () => ({ from: (table: unknown) => ({
where: () => ({
limit: async () => table === canvasProject ? [project] : [],
orderBy: () => ({ limit: async () => [] }),
}),
}) }),
insert: () => ({ values: () => ({ onConflictDoNothing: async () => {} }) }),
};
const route = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db": { db },
"@/lib/db/schema": { canvasProject, canvasImageTask: { request: "request" }, message: { id: "id" } },
"drizzle-orm": { eq: () => null, and: () => null, inArray: () => null, desc: () => null, sql: () => null },
"@/lib/canvas/image-task-queue": {
createCanvasImageTask: async ({ taskId, request }: { taskId: string; request: { references: typeof references } }) => {
const task = { id: taskId, request, status: "pending" };
created.push(task);
return task;
},
getCanvasImageTaskForUser: async ({ taskId }: { taskId: string }) => created.find((task) => task.id === taskId),
enqueueCanvasImageTask: async (taskId: string) => { queued.push(taskId); },
},
"@/lib/db/queries": {
getEnabledAiModelsWithProvider: async () => [{ model: { id: "fixture-model" } }],
checkUserImageQuota: async () => ({ allowed: true, limit: null }),
getPointsSystemConfig: async () => ({ enabled: false }),
getChatById: async () => ({ userId: owner }),
},
"@/app/(chat)/api/chat/_lib/detail-replacement": { resolveDirectReplacementModelRef: async () => "fixture-model" },
"@/app/(chat)/api/chat/_lib/agent-scene-workflow": {
buildAgentSlotPrompt: ({ workflowPrompt }: { workflowPrompt: string }) => workflowPrompt,
normalizeAgentSupplementPrompt: () => "",
},
})("app/api/agents/detail-replacement/batches/route.ts");
const post = (items: typeof references, replacementStrategy = "product_only", expectedModelId = "fixture-model") => route.POST(
new Request("http://localhost/api/agents/detail-replacement/batches", {
method: "POST",
body: JSON.stringify({
projectId: id, requestId: randomUUID(), references: items, replacementStrategy, expectedModelId,
prompt: "沿用品牌场景", productImageUrls: ["https://example.test/product.webp"],
}),
}),
);
for (const [count, strategy] of [[48, "product_only"], [64, "scene_replace"]] as const) {
const expectedIds = references.slice(0, count).map((ref) => ref.id);
const response = await post(references.slice(0, count), strategy);
expect(response.status).toBe(200);
expect((await response.json()).task.requestedCardIds).toEqual(expectedIds);
expect(created.at(-1)?.request.references.map((ref) => ref.id)).toEqual(expectedIds);
}
const overflow = await post(references);
expect(overflow.status).toBe(400);
expect((await overflow.json()).error).toContain("一次最多替换 64 张");
const changed = await post([{ ...references[0], imageUrl: "https://example.test/changed.webp" }]);
expect(changed.status).toBe(400);
expect((await changed.json()).error).toContain("尚未保存或已发生变化");
const changedModel = await post(references.slice(0, 48), "scene_replace", "outdated-model");
expect(changedModel.status).toBe(409);
expect((await changedModel.json()).code).toBe("DETAIL_REPLACEMENT_MODEL_CHANGED");
expect(queued).toEqual(created.map((task) => task.id));
expect(queued).toHaveLength(2);
});
test("duplicate POSTs return one project without overwriting saved state", async () => { test("duplicate POSTs return one project without overwriting saved state", async () => {
const { route, projects } = projectRoute(); const { route, projects } = projectRoute();
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) })); const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
@ -293,11 +214,6 @@ test("entry config stays small and unchanged config returns 304 before loading v
const entryData = await entry.json(); const entryData = await entry.json();
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]); expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
expect(JSON.stringify(entryData).length).toBeLessThan(20_000); expect(JSON.stringify(entryData).length).toBeLessThan(20_000);
const base = await (await route.GET(request("agent-canvas-base"))).json();
expect(base.agentWorkflowUiConfig.steps).toEqual([]);
const templates = await (await route.GET(request("agent-workflow-templates"))).json();
expect(templates.steps[0].options[0]).toMatchObject({ key: "test", buttonName: "风格一" });
expect(JSON.stringify(templates)).not.toContain("生成提示词");
const full = await route.GET(request("agent-canvas")); const full = await route.GET(request("agent-canvas"));
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000)); expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
const etag = full.headers.get("ETag"); const etag = full.headers.get("ETag");
@ -311,25 +227,3 @@ test("entry config stays small and unchanged config returns 304 before loading v
expect(changed.status).toBe(200); expect(changed.status).toBe(200);
expect(changed.headers.get("ETag")).not.toBe(etag); expect(changed.headers.get("ETag")).not.toBe(etag);
}); });
test("selected template prompts preserve the full path without downloading unrelated prompts", async () => {
const option = (key: string, promptFragment: string, children: unknown[] = []) => ({
key, buttonName: key, enabled: true, sortOrder: 0, promptFragment, children, previewImageUrl: "https://example.com/style.webp",
});
const value = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [
option("selected", "完整风格提示词", [option("leaf", "选中的子模板提示词")]),
option("unrelated", "不应下载的其他提示词"),
] }] };
const load = loadRuntime({
"@/app/(auth)/auth": { auth: async () => ({ user: { id: owner } }) },
"@/lib/db/queries": { getSystemSetting: async () => ({ value }) },
});
const { serializeAgentWorkflowSelectionPath } = load("lib/agents/workflow-ui-config.ts");
const { POST } = load("app/api/agent-workflow/template-prompts/route.ts");
const result = await POST(new Request("http://localhost/api/agent-workflow/template-prompts", {
method: "POST", body: JSON.stringify({ style: serializeAgentWorkflowSelectionPath(["selected", "leaf"]) }),
}));
expect(result.status).toBe(200);
const { selections } = await result.json();
expect(selections.map((s: { promptFragment: string }) => s.promptFragment)).toEqual(["完整风格提示词", "选中的子模板提示词"]);
});

View File

@ -32,7 +32,6 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
const jpeg = await sharp(png).jpeg().toBuffer(); const jpeg = await sharp(png).jpeg().toBuffer();
const webp = await sharp(png).webp().toBuffer(); const webp = await sharp(png).webp().toBuffer();
const originals = [png, jpeg, webp]; const originals = [png, jpeg, webp];
const downloads: number[] = [];
let submitted: any; let submitted: any;
let posts = 0; let posts = 0;
const loader = serverLoader({ const loader = serverLoader({
@ -42,16 +41,14 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); } if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } }); if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
const index = Number(new URL(url).pathname.slice(1)); const index = Number(new URL(url).pathname.slice(1));
downloads.push(index);
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } }); return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
}, },
}); });
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" }); const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [ await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })), { type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" }, referenceImageBuffers: new Map([["https://source.example/0", png]]) })).rejects.toThrow("transport-inspection-complete"); ] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
expect(posts).toBe(1); expect(posts).toBe(1);
expect(downloads).toEqual([1, 2]);
expect(submitted.parameters.size).toBe("1024*2048"); expect(submitted.parameters.size).toBe("1024*2048");
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image); const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
expect(images).toHaveLength(3); expect(images).toHaveLength(3);
@ -60,43 +57,3 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true); expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
}); });
}); });
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
throw new DOMException("This operation was aborted", "AbortError");
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
{ type: "text", text: "fixture" }, { type: "image", image: "https://source.example/slow.png" },
] }] }).catch((error: unknown) => error);
expect(methods).toEqual(["GET", "GET"]);
expect(error).toMatchObject({ confirmedNoResult: true });
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({
code: "IMAGE_REFERENCE_READ_TIMEOUT", outcome: "failed", recommendation: "可以稍后重试。",
});
});
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
const methods: string[] = [];
const loader = serverLoader({
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
fetch: async (_url: string, init: RequestInit = {}) => {
methods.push(init.method || "GET");
return init.method === "POST"
? Response.json({ output: { task_id: "accepted-task", task_status: "PENDING" } })
: Response.json({ code: "NotFound", message: "Task lookup failed" }, { status: 404 });
},
});
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
expect(methods).toEqual(["POST", "GET"]);
expect(String(error)).toContain("accepted-task");
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
});

View File

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

View File

@ -1,38 +1,21 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { serverLoader } from "../helpers/canvas-download-server-loader"; import { serverLoader } from "../helpers/canvas-download-server-loader";
import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences"; import { applyAgentImagePreferences, agentProviderImageParameters } from "@/lib/ai/canvas-agent-runtime/image-preferences";
import type { AgentCallRecord } from "@/lib/ai/canvas-agent-runtime/protocol";
const preferences = { const preferences = {
modelId: "user-image-model", modelId: "user-image-model",
parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字", parameters: { imageCount: 3, aspectRatio: "3:4", imageSize: "4K", negativePrompt: "不要文字",
quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 }, quality: "high" as const, size: "1536*2048", promptExtend: false, watermark: false, seed: 0 },
}; };
function harness(results?: any[] | ((count: number) => any[])) { function harness(results?: any[]) {
const saved: any[] = []; const saved: any[] = [];
const generated: any[] = []; const generated: any[] = [];
const operations: any[] = []; const operations: any[] = [];
const referenceUrl = "https://test.example/reference.png";
const original = Buffer.from("original reference bytes");
const mock = serverLoader({ const mock = serverLoader({
"@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) }, "@/lib/canvas/capabilities/ai-sdk-tools": { createCanvasCapabilityTools: async () => ({ editTargetImage: {} }) },
"@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] }, "@/lib/canvas/capabilities/registry": { CANVAS_CAPABILITY_REGISTRY: [{ id: "image.prompt_regenerate", title: "重绘", risk: "external", agent: { toolName: "editTargetImage" } }] },
"@/lib/canvas/agent-mutations": { "@/lib/canvas/agent-mutations": { applyCanvasOperationPlan: async (value: any) => { operations.push(value); return { createdNodeIds: ["new-image"] }; } },
applyCanvasOperationPlan: async (value: any) => { "@/lib/canvas/generate-image": { generateCanvasImage: async (value: any) => { generated.push(value); return { modelRef: results ? "backup" : value.model, images: results || [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }] }; } },
operations.push(value);
return { createdNodeIds: value.plan.mediaItems.map((item: { mediaItemId: string }) => item.mediaItemId) };
},
},
"@/lib/canvas/generate-image": {
generateCanvasImage: async (value: any) => {
generated.push(value);
const images = typeof results === "function" ? results(value.imageCount) : results;
return {
modelRef: Array.isArray(results) ? "backup" : value.model,
images: images ?? [{ originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }],
};
},
},
"@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } }, "@/lib/ai/runtime/generated-image-persistence": { saveGeneratedImages: async (...args: any[]) => { saved.push(args); } },
"@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) }, "@/lib/ai/model-router": { routeToModel: async () => ({ id: "system-default" }) },
"@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) }, "@/lib/db/queries": { checkUserImageQuota: async () => ({ allowed: true, remaining: 10 }), getPointsSystemConfig: async () => ({ enabled: false }), getUserById: async () => ({}) },
@ -40,30 +23,17 @@ function harness(results?: any[] | ((count: number) => any[])) {
getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })), getStyleProImageModelEntries: async () => ["system-default", "user-image-model"].map(id => ({ model: { id, modelId: id, isDefault: id === "system-default", imageGenConfig: {} } })),
sortStyleProImageModelEntries: (entries: any) => entries, sortStyleProImageModelEntries: (entries: any) => entries,
}, },
"./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: referenceUrl } }] }, "./canvas": { readOwnedCanvas: async () => ({ nodes: [{ id: "reference", type: "image" }] }), readCanvasImages: async () => [{ id: "reference", data: { src: "https://test.example/reference.png" } }] },
"./review": { analyzeRuntimeImages: async ({ reviewPorts }: { reviewPorts: { readImage(url: string): Promise<Buffer> } }) => ({ bytes: (await reviewPorts.readImage(referenceUrl)).length }) }, "./review": {},
"@/lib/ai/rmbg/service": { fetchImageBufferFromUrl: async () => original },
"./image-access-tools": { registerImageCanvasAccessTools() {} }, "./image-access-tools": { registerImageCanvasAccessTools() {} },
}); });
const build = async (imagePreferences: any = preferences, priorCalls?: AgentCallRecord[]) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({ const build = async (imagePreferences: any = preferences) => mock("lib/ai/canvas-agent-runtime/tools.ts").buildCanvasRuntimeTools({
userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"], userId: "u", chatId: "c", projectId: "p", runId: "r", assistantMessageId: "a", selectedNodeIds: ["reference"],
request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true, request: new Request("https://test.example/api/canvas/agent-chat"), model: {}, supportsVision: true,
preferredModelId: "system-default", imagePreferences, priorCalls, emit() {}, preferredModelId: "system-default", imagePreferences, emit() {},
}); });
return { build, generated, operations, saved, referenceUrl, original }; return { build, generated, operations, saved };
} }
test("the generation tool can reuse original bytes read by inspection within the same task", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
await tools.executable.analyzeCanvasImages.execute({ nodeIds: ["reference"], question: "识别素材" }, "inspect");
await tools.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "generate");
expect(h.generated[0].referenceImageBuffers.get(h.referenceUrl)).toBe(h.original);
const nextTask = await h.build(selection);
await nextTask.executable.generateCanvasImages.execute({ prompt: "修改参考图", referenceNodeIds: ["reference"] }, "next");
expect(h.generated[1].referenceImageBuffers.has(h.referenceUrl)).toBe(false);
});
test("explicit controls override conflicting planner settings without losing false or zero values", () => { test("explicit controls override conflicting planner settings without losing false or zero values", () => {
const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences); const resolved = applyAgentImagePreferences({ modelId: "planner-choice", imageCount: 1, imageSize: "1K", aspectRatio: "1:1", prompt: "产品图" }, preferences);
expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" }); expect(resolved).toMatchObject({ ...preferences.parameters, modelId: preferences.modelId, prompt: "产品图" });
@ -77,35 +47,12 @@ test("real generation tool honors the chosen image model and all parameters desp
providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } }); providerImageParams: { quality: "high", size: "1536*2048", watermark: false, seed: 0 } });
expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" }); expect(output.appliedImagePreferences).toEqual({ modelId: "user-image-model", imageCount: 3, imageSize: "4K", aspectRatio: "3:4" });
}); });
test("redraw and generation share the task limit and only fill the missing images", async () => { test("redraw and a corrective retry inherit the same user model, batch and image controls", async () => {
const image = { originalUrl: "https://test.example/result.png", thumbnailUrl: "https://test.example/thumb.png" }; const h = harness(); const tools = await h.build();
const h = harness(count => Array.from({ length: count === 3 ? 1 : count }, () => image)); await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
const tools = await h.build(); await tools.executable.generateCanvasImages.execute({ prompt: "修正背景", modelId: "system-default" }, "retry-1");
const first = await tools.executable.editTargetImage.execute({ nodeIds: ["reference"], args: { prompt: "换背景", modelId: "system-default" } }, "edit-1");
expect(first.status).toBe("partial");
const filled = await tools.executable.generateCanvasImages.execute({ prompt: "补齐图片", modelId: "system-default" }, "fill-1");
expect(filled.status).toBe("succeeded");
await expect(tools.executable.generateCanvasImages.execute({ prompt: "再次修正" }, "retry-1")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(2);
expect(h.generated.map(call => call.imageCount)).toEqual([3, 2]);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", aspectRatio: "3:4", allowFailover: false });
});
test("one selected image allows only one submission even when calls overlap or the task resumes", async () => {
const h = harness();
const selection = { parameters: { imageCount: 1 } };
const tools = await h.build(selection);
const generate = (callId: string) => tools.executable.generateCanvasImages.execute({ prompt: "修改图片" }, callId);
const [first, duplicate] = await Promise.allSettled([generate("original"), generate("parallel")]);
expect(first.status).toBe("fulfilled");
expect(duplicate.status).toBe("rejected");
if (first.status !== "fulfilled") throw first.reason;
const resumed = await h.build(selection, [{ id: "original", name: "generateCanvasImages", input: {}, status: "succeeded", output: first.value }]);
await expect(resumed.executable.generateCanvasImages.execute({ prompt: "继续" }, "resume")).rejects.toThrow("张数已占满");
expect(h.generated).toHaveLength(1);
const next = await h.build(selection);
await next.executable.generateCanvasImages.execute({ prompt: "用户新指令" }, "next");
expect(h.generated).toHaveLength(2); expect(h.generated).toHaveLength(2);
for (const invocation of h.generated) expect(invocation).toMatchObject({ model: preferences.modelId, imageSize: "4K", imageCount: 3, aspectRatio: "3:4", allowFailover: false });
}); });
test("an unavailable explicit choice never silently generates or charges through the default", async () => { test("an unavailable explicit choice never silently generates or charges through the default", async () => {
const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" }); const h = harness(); const tools = await h.build({ ...preferences, modelId: "disabled-or-missing" });
@ -155,7 +102,7 @@ test("mixed-model batches bill and label only the delivered images using their a
test("headless image features cannot substitute their default when a pinned model disappears", async () => { test("headless image features cannot substitute their default when a pinned model disappears", async () => {
let generated = 0; let generated = 0;
const mocks: Record<string, any> = { const mocks: Record<string, any> = {
"@/lib/canvas/operations/box-cutout": {}, "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {}, "@/lib/ai/box-cutout/service": {}, "@/lib/ai/extend/service": {}, "@/lib/ai/layer-split/service": {}, "@/lib/ai/resize/service": {}, "@/lib/ai/rmbg/service": {},
"@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {}, "@/lib/canvas/capabilities/internal-endpoint": {}, "@/lib/client/long-task": {},
"@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } }, "@/lib/canvas/generate-image": { generateCanvasImage: async () => { generated++; return { images: [] }; } },
"@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null }, "@/lib/style-pro/model-options": { getStyleProImageModelEntries: async () => [{ model: { id: "default", modelId: "default", isDefault: true } }], sortStyleProImageModelEntries: (entries: any[]) => entries, findStyleProImageModelByRef: () => null },

View File

@ -94,17 +94,6 @@ test("checks real image bytes and compares results to their source images", asyn
expect(result.issues).toEqual([]); expect(result.issues).toEqual([]);
}); });
test("a partial image batch can be filled and verified without discarding the first result", async () => {
const f = await fixture('{"passed":true,"issues":[],"failedNodeIds":[]}');
const { snapshot } = f.params;
snapshot.requirements = { expectedImageCount: 2 };
snapshot.calls[1].output = { status: "partial", createdNodeIds: ["result"], appliedImagePreferences: { imageCount: 2 } };
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: false, retryable: true });
snapshot.resultNodeIds.push("remaining-result");
snapshot.calls.push({ id: "fill", name: "generateCanvasImages", input: {}, status: "succeeded", output: { status: "succeeded", createdNodeIds: ["remaining-result"] } });
await expect(verifyCanvasAgentResults(f.params)).resolves.toMatchObject({ passed: true });
});
test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => { test("malformed vision JSON switches models, while a valid failed review never triggers failover", async () => {
const bad = await fixture("not JSON"); const bad = await fixture("not JSON");
const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}'); const validNegative = await fixture('{"passed":false,"issues":["主体不一致"],"failedNodeIds":["result"]}');

View File

@ -808,7 +808,6 @@ test.describe("canvas chat pure helpers", () => {
}); });
test("canvas service save uses store revision fallback when options are omitted", async () => { test("canvas service save uses store revision fallback when options are omitted", async () => {
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -886,7 +885,7 @@ test.describe("canvas chat pure helpers", () => {
}); });
}); });
test("canvas revision advances after applying the fetched snapshot and saving the current project", async () => { test("canvas service keeps store revision aligned after project load and save", async () => {
const canvasData = { const canvasData = {
nodes: [], nodes: [],
edges: [], edges: [],
@ -916,7 +915,6 @@ test.describe("canvas chat pure helpers", () => {
}, },
]; ];
useCanvasStore.getState().setProject("11111111-1111-4111-8111-111111111111");
useCanvasStore.getState().setCanvasRevision(null, null); useCanvasStore.getState().setCanvasRevision(null, null);
Object.defineProperty(globalThis, "window", { Object.defineProperty(globalThis, "window", {
configurable: true, configurable: true,
@ -931,11 +929,7 @@ test.describe("canvas chat pure helpers", () => {
}) as Response) as typeof fetch; }) as Response) as typeof fetch;
try { try {
const result = await canvasService.getProject("11111111-1111-4111-8111-111111111111"); await canvasService.getProject("11111111-1111-4111-8111-111111111111");
expect(useCanvasStore.getState().canvasRevision).toBeNull();
useCanvasStore.getState().loadCanvas(result.data!.canvasData);
const revision = String(result.data!.updatedAt);
useCanvasStore.getState().setCanvasRevision(revision, revision);
expect(useCanvasStore.getState().canvasRevision).toBe( expect(useCanvasStore.getState().canvasRevision).toBe(
"2026-06-22T00:01:00.000Z" "2026-06-22T00:01:00.000Z"
); );

View File

@ -124,7 +124,6 @@ test("requested downloads are prioritized with two workers before scanning backg
}, },
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} }, "./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) }, "@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
"./qiniu-image-archive": { getReadyQiniuImageDownload: async () => undefined },
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } }, "./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
})("lib/storage/image-download-preparation.ts"); })("lib/storage/image-download-preparation.ts");
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three"); worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");

Some files were not shown because too many files have changed in this diff Show More